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(`