From ff5cfa8b2aa09e0e66619720a792a3094ea94e4d Mon Sep 17 00:00:00 2001
From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com>
Date: Thu, 27 Aug 2026 13:37:30 +0800
Subject: [PATCH] fix: resync replaced Binance trade inputs
---
scripts/binance-orderbook-trade.user.js | 141 ++++++++++++++----
src/binance-orderbook-trade/dom/trade-form.js | 102 +++++++++++++
src/binance-orderbook-trade/index.user.js | 87 ++++++-----
.../source-regressions.test.js | 12 +-
.../trade-form.test.js | 95 ++++++++++++
5 files changed, 357 insertions(+), 80 deletions(-)
diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js
index eec7791..6cd7f58 100644
--- a/scripts/binance-orderbook-trade.user.js
+++ b/scripts/binance-orderbook-trade.user.js
@@ -3,7 +3,7 @@
// @namespace binance.orderbook.trade
// @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E
// @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E
-// @version 2.7.132
+// @version 2.7.133
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
@@ -1101,6 +1101,21 @@
return !!button?.closest?.(`#${panelId}`);
}
var CLOSE_QUANTITY_SELECTOR = '[data-testid="max-sell-amount"], [data-testid="max-buy-amount"]';
+ var TRADE_MODE_TAB_SELECTOR = [
+ '#position-direction [role="tab"][aria-selected="true"]',
+ '.bn-tabs__buySell [role="tab"][aria-selected="true"]',
+ '[role="tab"].bn-tab__buySell[aria-selected="true"]'
+ ].join(",");
+ var TRADE_QTY_INPUT_SELECTOR = [
+ 'input[id^="unitAmount-"]',
+ 'input[aria-label="数量"]',
+ 'input[placeholder="数量"]'
+ ].join(",");
+ var TRADE_PRICE_INPUT_SELECTOR = [
+ 'input[id^="limitPrice-"]',
+ 'input[aria-label="委托价格"]',
+ 'input[placeholder="委托价格"]'
+ ].join(",");
function parseTradeModeLabel(value) {
if (matchesBinancePageText(value, BINANCE_PAGE_TEXT.tradeMode.OPEN)) return "OPEN";
if (matchesBinancePageText(value, BINANCE_PAGE_TEXT.tradeMode.CLOSE)) return "CLOSE";
@@ -1141,6 +1156,65 @@
}
return null;
}
+ function findActiveTradeInputs(ownerDocument, {
+ panelId,
+ isVisibleElement,
+ requirePrice = true
+ }) {
+ if (!ownerDocument?.querySelectorAll || typeof isVisibleElement !== "function") return null;
+ const activeTabs = Array.from(ownerDocument.querySelectorAll(TRADE_MODE_TAB_SELECTOR)).filter((tab) => !tab.closest(`#${panelId}`) && isVisibleElement(tab));
+ const qtyInputs = Array.from(ownerDocument.querySelectorAll(TRADE_QTY_INPUT_SELECTOR)).filter((input) => !input.closest(`#${panelId}`) && isVisibleElement(input));
+ const matches = [];
+ for (const activeTab of activeTabs) {
+ for (const qtyInput of qtyInputs) {
+ const root = findTradeFormRoot(activeTab, qtyInput);
+ if (!root) continue;
+ const priceInputs = Array.from(root.querySelectorAll(TRADE_PRICE_INPUT_SELECTOR)).filter((input) => !input.closest(`#${panelId}`) && isVisibleElement(input));
+ if (priceInputs.length > 1 || requirePrice && priceInputs.length !== 1) continue;
+ matches.push({ root, activeTab, priceInput: priceInputs[0] || null, qtyInput });
+ }
+ }
+ return matches.length === 1 ? matches[0] : null;
+ }
+ function createTradeInputStateReader({
+ resolveInputs,
+ expectedPrice,
+ expectedQty,
+ includePrice,
+ normalizeValue,
+ compareValues,
+ writeValue
+ }) {
+ if (typeof resolveInputs !== "function" || typeof normalizeValue !== "function" || typeof compareValues !== "function" || typeof writeValue !== "function") {
+ throw new Error("Trade input synchronizer dependencies are invalid");
+ }
+ const writtenQtyInputs = /* @__PURE__ */ new WeakSet();
+ const writtenPriceInputs = /* @__PURE__ */ new WeakSet();
+ return () => {
+ const inputs = resolveInputs();
+ if (!inputs?.qtyInput || includePrice && !inputs.priceInput) return null;
+ const submittedQty = normalizeValue(inputs.qtyInput.value);
+ if (compareValues(expectedQty, submittedQty) !== 0) {
+ if (!writtenQtyInputs.has(inputs.qtyInput)) {
+ writtenQtyInputs.add(inputs.qtyInput);
+ writeValue(inputs.qtyInput, expectedQty);
+ }
+ return null;
+ }
+ if (!includePrice) {
+ return { ...inputs, submittedQty };
+ }
+ const submittedPrice = normalizeValue(inputs.priceInput.value);
+ if (compareValues(expectedPrice, submittedPrice) !== 0) {
+ if (!writtenPriceInputs.has(inputs.priceInput)) {
+ writtenPriceInputs.add(inputs.priceInput);
+ writeValue(inputs.priceInput, expectedPrice);
+ }
+ return null;
+ }
+ return { ...inputs, submittedPrice, submittedQty };
+ };
+ }
function findTradePanelInsertionPoint(root) {
const modeTabs = root?.querySelector?.("#position-direction");
if (!modeTabs) return null;
@@ -2215,10 +2289,17 @@
input.style.boxShadow = "none";
}
function findQtyInput() {
- return document.querySelector('input[id^="unitAmount-"]') || document.querySelector('input[aria-label="数量"]') || document.querySelector('input[placeholder="数量"]');
+ return findTradeInputs({ requirePrice: false })?.qtyInput || null;
}
function findPriceInput() {
- return document.querySelector('input[id^="limitPrice-"]') || document.querySelector('input[aria-label="委托价格"]') || document.querySelector('input[placeholder="委托价格"]') || null;
+ return findTradeInputs()?.priceInput || null;
+ }
+ function findTradeInputs(options = null) {
+ return findActiveTradeInputs(document, {
+ panelId: PANEL_ID,
+ isVisibleElement,
+ requirePrice: options?.requirePrice !== false
+ });
}
function isOwnPanelButton2(button) {
return !!button?.closest?.(`#${PANEL_ID}`);
@@ -2343,7 +2424,7 @@
return findVisibleElementInScopes(getTradeSearchScopes(), selector, predicate);
}
function getTradeMutationRoot() {
- return findTradeFormRoot(getActiveTradeTab(), findQtyInput());
+ return findTradeInputs({ requirePrice: false })?.root || null;
}
function collectTradeButtons(mode) {
const now = Date.now();
@@ -3421,31 +3502,24 @@
throw new Error(`数量框未同步,${expectedLabel} ${expected || expectedQty},当前提交量 ${submitted || submittedQty || "-"}`);
}
}
- function readSynchronizedTradeInputs(expectedPrice, expectedQty) {
- const priceInput = findPriceInput();
- const qtyInput = findQtyInput();
- if (!priceInput || !qtyInput) return null;
- const submittedPrice = normalizeDecimalString(priceInput.value);
- const submittedQty = normalizeDecimalString(qtyInput.value);
- if (compareDecimalStrings(expectedPrice, submittedPrice) !== 0 || compareDecimalStrings(expectedQty, submittedQty) !== 0) {
- return null;
- }
- return { priceInput, qtyInput, submittedPrice, submittedQty };
- }
async function syncTradeInputs(expectedPrice, expectedQty, options = null) {
const priceLabel = options?.priceLabel || "点击价";
const qtyLabel = options?.qtyLabel || "目标量";
- let observationRoot = getTradeMutationRoot();
- const qtyInput = findQtyInput();
- if (!observationRoot || !qtyInput) throw new Error("未找到当前交易表单或数量输入框");
- setInputValueReact(qtyInput, expectedQty);
+ let inputs = findTradeInputs();
+ if (!inputs) throw new Error("未找到唯一的当前交易表单输入框");
+ let observationRoot = inputs.root;
+ const readQtyState = createTradeInputStateReader({
+ resolveInputs: findTradeInputs,
+ expectedQty,
+ includePrice: false,
+ normalizeValue: normalizeDecimalString,
+ compareValues: compareDecimalStrings,
+ writeValue: setInputValueReact
+ });
+ readQtyState();
const qtyReady = await waitForTradeFormFrameState(
observationRoot,
- () => {
- const liveQtyInput = findQtyInput();
- const submittedQty = normalizeDecimalString(liveQtyInput?.value || "");
- return compareDecimalStrings(expectedQty, submittedQty) === 0 ? { qtyInput: liveQtyInput, submittedQty } : null;
- },
+ readQtyState,
TRADE_INPUT_SYNC_TIMEOUT_MS,
TRADE_INPUT_SYNC_STABLE_FRAMES
);
@@ -3453,13 +3527,22 @@
assertSubmittedQtyMatchesExpectedQty(expectedQty, findQtyInput()?.value || "", qtyLabel);
throw new Error("数量框状态未稳定,已停止提交");
}
- observationRoot = getTradeMutationRoot();
- const priceInput = findPriceInput();
- if (!observationRoot || !priceInput) throw new Error("未找到当前交易表单或价格输入框");
- setInputValueReact(priceInput, expectedPrice);
+ inputs = findTradeInputs();
+ if (!inputs) throw new Error("未找到唯一的当前交易表单输入框");
+ observationRoot = inputs.root;
+ const readTradeState = createTradeInputStateReader({
+ resolveInputs: findTradeInputs,
+ expectedPrice,
+ expectedQty,
+ includePrice: true,
+ normalizeValue: normalizeDecimalString,
+ compareValues: compareDecimalStrings,
+ writeValue: setInputValueReact
+ });
+ readTradeState();
const synchronized = await waitForTradeFormFrameState(
observationRoot,
- () => readSynchronizedTradeInputs(expectedPrice, expectedQty),
+ readTradeState,
TRADE_INPUT_SYNC_TIMEOUT_MS,
TRADE_INPUT_SYNC_STABLE_FRAMES
);
diff --git a/src/binance-orderbook-trade/dom/trade-form.js b/src/binance-orderbook-trade/dom/trade-form.js
index b9c84b9..a6d3f98 100644
--- a/src/binance-orderbook-trade/dom/trade-form.js
+++ b/src/binance-orderbook-trade/dom/trade-form.js
@@ -13,6 +13,21 @@ function isOwnPanelButton(button, panelId) {
}
const CLOSE_QUANTITY_SELECTOR = '[data-testid="max-sell-amount"], [data-testid="max-buy-amount"]';
+const TRADE_MODE_TAB_SELECTOR = [
+ '#position-direction [role="tab"][aria-selected="true"]',
+ '.bn-tabs__buySell [role="tab"][aria-selected="true"]',
+ '[role="tab"].bn-tab__buySell[aria-selected="true"]',
+].join(',');
+const TRADE_QTY_INPUT_SELECTOR = [
+ 'input[id^="unitAmount-"]',
+ 'input[aria-label="数量"]',
+ 'input[placeholder="数量"]',
+].join(',');
+const TRADE_PRICE_INPUT_SELECTOR = [
+ 'input[id^="limitPrice-"]',
+ 'input[aria-label="委托价格"]',
+ 'input[placeholder="委托价格"]',
+].join(',');
export function parseTradeModeLabel(value) {
if (matchesBinancePageText(value, BINANCE_PAGE_TEXT.tradeMode.OPEN)) return 'OPEN';
@@ -78,6 +93,93 @@ export function findTradeFormRoot(activeTab, qtyInput) {
return null;
}
+/**
+ * Resolve one coherent pair of live controlled inputs from the visible trade form.
+ * Binance can leave hidden or replaced form nodes in the document during React
+ * transitions, so global first-match selectors are not a safe submission contract.
+ */
+export function findActiveTradeInputs(ownerDocument, {
+ panelId,
+ isVisibleElement,
+ requirePrice = true,
+}) {
+ if (!ownerDocument?.querySelectorAll || typeof isVisibleElement !== 'function') return null;
+
+ const activeTabs = Array.from(ownerDocument.querySelectorAll(TRADE_MODE_TAB_SELECTOR))
+ .filter((tab) => !tab.closest(`#${panelId}`) && isVisibleElement(tab));
+ const qtyInputs = Array.from(ownerDocument.querySelectorAll(TRADE_QTY_INPUT_SELECTOR))
+ .filter((input) => !input.closest(`#${panelId}`) && isVisibleElement(input));
+ const matches = [];
+
+ for (const activeTab of activeTabs) {
+ for (const qtyInput of qtyInputs) {
+ const root = findTradeFormRoot(activeTab, qtyInput);
+ if (!root) continue;
+ const priceInputs = Array.from(root.querySelectorAll(TRADE_PRICE_INPUT_SELECTOR))
+ .filter((input) => !input.closest(`#${panelId}`) && isVisibleElement(input));
+ if (priceInputs.length > 1 || (requirePrice && priceInputs.length !== 1)) continue;
+ matches.push({ root, activeTab, priceInput: priceInputs[0] || null, qtyInput });
+ }
+ }
+
+ return matches.length === 1 ? matches[0] : null;
+}
+
+/**
+ * Synchronize each live React input identity at most once. A replacement node is
+ * a defined Binance render transition and must receive the intended value again;
+ * a value rejected by the same node remains a hard failure instead of a retry loop.
+ */
+export function createTradeInputStateReader({
+ resolveInputs,
+ expectedPrice,
+ expectedQty,
+ includePrice,
+ normalizeValue,
+ compareValues,
+ writeValue,
+}) {
+ if (
+ typeof resolveInputs !== 'function'
+ || typeof normalizeValue !== 'function'
+ || typeof compareValues !== 'function'
+ || typeof writeValue !== 'function'
+ ) {
+ throw new Error('Trade input synchronizer dependencies are invalid');
+ }
+
+ const writtenQtyInputs = new WeakSet();
+ const writtenPriceInputs = new WeakSet();
+ return () => {
+ const inputs = resolveInputs();
+ if (!inputs?.qtyInput || (includePrice && !inputs.priceInput)) return null;
+
+ const submittedQty = normalizeValue(inputs.qtyInput.value);
+ if (compareValues(expectedQty, submittedQty) !== 0) {
+ if (!writtenQtyInputs.has(inputs.qtyInput)) {
+ writtenQtyInputs.add(inputs.qtyInput);
+ writeValue(inputs.qtyInput, expectedQty);
+ }
+ return null;
+ }
+
+ if (!includePrice) {
+ return { ...inputs, submittedQty };
+ }
+
+ const submittedPrice = normalizeValue(inputs.priceInput.value);
+ if (compareValues(expectedPrice, submittedPrice) !== 0) {
+ if (!writtenPriceInputs.has(inputs.priceInput)) {
+ writtenPriceInputs.add(inputs.priceInput);
+ writeValue(inputs.priceInput, expectedPrice);
+ }
+ return null;
+ }
+
+ return { ...inputs, submittedPrice, submittedQty };
+ };
+}
+
export function findTradePanelInsertionPoint(root) {
const modeTabs = root?.querySelector?.('#position-direction');
if (!modeTabs) return null;
diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js
index 381f542..b108c6e 100644
--- a/src/binance-orderbook-trade/index.user.js
+++ b/src/binance-orderbook-trade/index.user.js
@@ -3,7 +3,7 @@
// @namespace binance.orderbook.trade
// @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E
// @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E
-// @version 2.7.132
+// @version 2.7.133
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
@@ -111,6 +111,8 @@ import {
import {
calculateFloatingPanelLayout,
collectTradeButtonsFromScopes,
+ createTradeInputStateReader,
+ findActiveTradeInputs as findActiveTradeInputsDom,
findTradeFormRoot,
findTradePanelInsertionPoint,
findCurrentLeverageButtonFromScopes,
@@ -706,20 +708,19 @@ import {
}
function findQtyInput() {
- return (
- document.querySelector('input[id^="unitAmount-"]') ||
- document.querySelector('input[aria-label="数量"]') ||
- document.querySelector('input[placeholder="数量"]')
- );
+ return findTradeInputs({ requirePrice: false })?.qtyInput || null;
}
function findPriceInput() {
- return (
- document.querySelector('input[id^="limitPrice-"]') ||
- document.querySelector('input[aria-label="委托价格"]') ||
- document.querySelector('input[placeholder="委托价格"]') ||
- null
- );
+ return findTradeInputs()?.priceInput || null;
+ }
+
+ function findTradeInputs(options = null) {
+ return findActiveTradeInputsDom(document, {
+ panelId: PANEL_ID,
+ isVisibleElement,
+ requirePrice: options?.requirePrice !== false,
+ });
}
function isOwnPanelButton(button) {
@@ -885,7 +886,7 @@ import {
}
function getTradeMutationRoot() {
- return findTradeFormRoot(getActiveTradeTab(), findQtyInput());
+ return findTradeInputs({ requirePrice: false })?.root || null;
}
function collectTradeButtons(mode) {
@@ -2214,38 +2215,25 @@ import {
}
}
- function readSynchronizedTradeInputs(expectedPrice, expectedQty) {
- const priceInput = findPriceInput();
- const qtyInput = findQtyInput();
- if (!priceInput || !qtyInput) return null;
- const submittedPrice = normalizeDecimalString(priceInput.value);
- const submittedQty = normalizeDecimalString(qtyInput.value);
- if (
- compareDecimalStrings(expectedPrice, submittedPrice) !== 0
- || compareDecimalStrings(expectedQty, submittedQty) !== 0
- ) {
- return null;
- }
- return { priceInput, qtyInput, submittedPrice, submittedQty };
- }
-
async function syncTradeInputs(expectedPrice, expectedQty, options = null) {
const priceLabel = options?.priceLabel || '点击价';
const qtyLabel = options?.qtyLabel || '目标量';
- let observationRoot = getTradeMutationRoot();
- const qtyInput = findQtyInput();
- if (!observationRoot || !qtyInput) throw new Error('未找到当前交易表单或数量输入框');
-
- setInputValueReact(qtyInput, expectedQty);
+ let inputs = findTradeInputs();
+ if (!inputs) throw new Error('未找到唯一的当前交易表单输入框');
+
+ let observationRoot = inputs.root;
+ const readQtyState = createTradeInputStateReader({
+ resolveInputs: findTradeInputs,
+ expectedQty,
+ includePrice: false,
+ normalizeValue: normalizeDecimalString,
+ compareValues: compareDecimalStrings,
+ writeValue: setInputValueReact,
+ });
+ readQtyState();
const qtyReady = await waitForTradeFormFrameState(
observationRoot,
- () => {
- const liveQtyInput = findQtyInput();
- const submittedQty = normalizeDecimalString(liveQtyInput?.value || '');
- return compareDecimalStrings(expectedQty, submittedQty) === 0
- ? { qtyInput: liveQtyInput, submittedQty }
- : null;
- },
+ readQtyState,
TRADE_INPUT_SYNC_TIMEOUT_MS,
TRADE_INPUT_SYNC_STABLE_FRAMES,
);
@@ -2254,13 +2242,22 @@ import {
throw new Error('数量框状态未稳定,已停止提交');
}
- observationRoot = getTradeMutationRoot();
- const priceInput = findPriceInput();
- if (!observationRoot || !priceInput) throw new Error('未找到当前交易表单或价格输入框');
- setInputValueReact(priceInput, expectedPrice);
+ inputs = findTradeInputs();
+ if (!inputs) throw new Error('未找到唯一的当前交易表单输入框');
+ observationRoot = inputs.root;
+ const readTradeState = createTradeInputStateReader({
+ resolveInputs: findTradeInputs,
+ expectedPrice,
+ expectedQty,
+ includePrice: true,
+ normalizeValue: normalizeDecimalString,
+ compareValues: compareDecimalStrings,
+ writeValue: setInputValueReact,
+ });
+ readTradeState();
const synchronized = await waitForTradeFormFrameState(
observationRoot,
- () => readSynchronizedTradeInputs(expectedPrice, expectedQty),
+ readTradeState,
TRADE_INPUT_SYNC_TIMEOUT_MS,
TRADE_INPUT_SYNC_STABLE_FRAMES,
);
diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js
index e516a86..d2326ec 100644
--- a/test/unit/binance-orderbook-trade/source-regressions.test.js
+++ b/test/unit/binance-orderbook-trade/source-regressions.test.js
@@ -148,13 +148,13 @@ test('trade input synchronization confirms live controlled values instead of sle
const syncBody = readFunctionBody('syncTradeInputs');
const executeBody = readFunctionBody('executeLadderPlan');
- assert.match(syncBody, /setInputValueReact\(qtyInput,\s*expectedQty\)/);
+ assert.match(syncBody, /createTradeInputStateReader/);
+ assert.match(syncBody, /resolveInputs:\s*findTradeInputs/);
+ assert.match(syncBody, /writeValue:\s*setInputValueReact/);
assert.match(syncBody, /waitForTradeFormFrameState/);
- assert.match(syncBody, /setInputValueReact\(priceInput,\s*expectedPrice\)/);
- assert.ok(
- syncBody.indexOf('setInputValueReact(qtyInput, expectedQty)')
- < syncBody.indexOf('setInputValueReact(priceInput, expectedPrice)'),
- );
+ assert.match(syncBody, /includePrice:\s*false/);
+ assert.match(syncBody, /includePrice:\s*true/);
+ assert.ok(syncBody.indexOf('includePrice: false') < syncBody.indexOf('includePrice: true'));
assert.match(syncBody, /findPriceInput\(\)/);
assert.match(syncBody, /findQtyInput\(\)/);
assert.match(syncBody, /assertSubmittedPriceMatchesExpectedPrice/);
diff --git a/test/unit/binance-orderbook-trade/trade-form.test.js b/test/unit/binance-orderbook-trade/trade-form.test.js
index c469712..bec2566 100644
--- a/test/unit/binance-orderbook-trade/trade-form.test.js
+++ b/test/unit/binance-orderbook-trade/trade-form.test.js
@@ -4,6 +4,8 @@ import assert from 'node:assert/strict';
import {
calculateFloatingPanelLayout,
+ createTradeInputStateReader,
+ findActiveTradeInputs,
findTradeFormRoot,
findTradePanelInsertionPoint,
isTradeModeTab,
@@ -292,6 +294,99 @@ test('trade form root observes live position state after React replaces descenda
assert.ok(observedTexts.includes('0.00 HYPE'));
});
+test('active trade inputs ignore hidden duplicate forms and remain one coherent pair', () => {
+ const dom = loadFixtureDom(`
+
+
+ `);
+ const { document } = dom.window;
+ const inputs = findActiveTradeInputs(document, {
+ panelId: 'jh-binance-close-qty-multiplier-panel',
+ isVisibleElement: (element) => element.closest('section')?.dataset.form === 'visible',
+ });
+
+ assert.equal(inputs?.root.dataset.form, 'visible');
+ assert.equal(inputs?.priceInput.id, 'limitPrice-close');
+ assert.equal(inputs?.qtyInput.id, 'unitAmount-close');
+});
+
+test('active trade form can be resolved before a limit-price input is rendered', () => {
+ const dom = loadFixtureDom(`
+
+ `);
+ const { document } = dom.window;
+ const visibility = (element) => element.closest('section')?.dataset.form === 'visible';
+
+ assert.equal(findActiveTradeInputs(document, {
+ panelId: 'jh-binance-close-qty-multiplier-panel',
+ isVisibleElement: visibility,
+ }), null);
+ const form = findActiveTradeInputs(document, {
+ panelId: 'jh-binance-close-qty-multiplier-panel',
+ isVisibleElement: visibility,
+ requirePrice: false,
+ });
+ assert.equal(form?.root.dataset.form, 'visible');
+ assert.equal(form?.qtyInput.id, 'unitAmount-open');
+ assert.equal(form?.priceInput, null);
+});
+
+test('trade input synchronization writes each replacement identity once', () => {
+ let currentInputs = {
+ priceInput: { value: '' },
+ qtyInput: { value: '' },
+ };
+ const writes = [];
+ const readState = createTradeInputStateReader({
+ resolveInputs: () => currentInputs,
+ expectedPrice: '81.9',
+ expectedQty: '0.01',
+ includePrice: true,
+ normalizeValue: String,
+ compareValues: (expected, actual) => expected === actual ? 0 : 1,
+ writeValue: (input, value) => {
+ writes.push({ input, value });
+ input.value = value;
+ },
+ });
+
+ assert.equal(readState(), null);
+ assert.equal(readState(), null);
+ assert.deepEqual(readState(), {
+ ...currentInputs,
+ submittedPrice: '81.9',
+ submittedQty: '0.01',
+ });
+
+ currentInputs = {
+ priceInput: { value: '' },
+ qtyInput: { value: '' },
+ };
+ assert.equal(readState(), null);
+ assert.equal(readState(), null);
+ assert.deepEqual(readState(), {
+ ...currentInputs,
+ submittedPrice: '81.9',
+ submittedQty: '0.01',
+ });
+ assert.deepEqual(writes.map(({ value }) => value), ['0.01', '81.9', '0.01', '81.9']);
+
+ currentInputs.qtyInput.value = '';
+ assert.equal(readState(), null);
+ assert.equal(writes.length, 4);
+});
+
test('recognizes only close-quantity mutations as a confirmed close snapshot', async () => {
const dom = loadFixtureDom(`