diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 4c8e863..d04ebc3 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.119 +// @version 2.7.120 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -1159,6 +1159,47 @@ check(); }); } + function waitForTradeFormFrameState(observationRoot, readState, timeoutMs, requiredStableFrames = 2) { + const view = observationRoot?.ownerDocument?.defaultView; + if (!view || typeof view.requestAnimationFrame !== "function" || typeof view.cancelAnimationFrame !== "function") { + throw new Error("Trade form frame scheduler is unavailable"); + } + if (!Number.isInteger(requiredStableFrames) || requiredStableFrames < 1) { + throw new Error("requiredStableFrames must be a positive integer"); + } + return new Promise((resolve) => { + let settled = false; + let frameHandle = 0; + let timer = 0; + let stableFrames = 0; + let stableState = null; + const finish = (value) => { + if (settled) return; + settled = true; + if (frameHandle) view.cancelAnimationFrame(frameHandle); + view.clearTimeout(timer); + resolve(value); + }; + const check = () => { + frameHandle = 0; + const state = readState(); + if (state) { + stableFrames += 1; + stableState = state; + if (stableFrames >= requiredStableFrames) { + finish(stableState); + return; + } + } else { + stableFrames = 0; + stableState = null; + } + frameHandle = view.requestAnimationFrame(check); + }; + timer = view.setTimeout(() => finish(null), timeoutMs); + frameHandle = view.requestAnimationFrame(check); + }); + } function isTradeModeTab(node, { panelId }) { if (!node?.matches?.('[role="tab"]')) return false; if (node.closest(`#${panelId}`)) return false; @@ -1746,8 +1787,8 @@ const BINANCE_PLACE_ORDER_BAPI_PATH = "/bapi/futures/v1/private/future/order/place-order"; const BINANCE_USER_POSITION_BAPI_PATH = "/bapi/futures/v6/private/future/user-data/user-position"; const LADDER_OPEN_QTY_READY_TIMEOUT_MS = 1200; - const SINGLE_ORDER_PRICE_SYNC_DELAY_MS = 90; - const SINGLE_ORDER_QTY_SYNC_DELAY_MS = 120; + const TRADE_INPUT_SYNC_TIMEOUT_MS = 350; + const TRADE_INPUT_SYNC_STABLE_FRAMES = 2; const ORDERBOOK_PRECISION_MANUAL_SAMPLE_DURATION_MS = 6e3; const ORDERBOOK_PRECISION_SAMPLE_DURATION_MS = ORDERBOOK_PRECISION_MANUAL_SAMPLE_DURATION_MS; const ORDERBOOK_PRECISION_SAMPLE_POLL_MS = 300; @@ -3389,14 +3430,73 @@ } if (!isPostOnlyOrderTypeActive()) throw new Error("执行中只做Maker (Post Only) 状态丢失,请刷新页面后重试"); } - function assertSubmittedPriceMatchesClickedPrice(clickedPrice, submittedPrice) { - const clicked = normalizeDecimalString(clickedPrice); + function assertSubmittedPriceMatchesExpectedPrice(expectedPrice, submittedPrice, expectedLabel = "点击价") { + const expected = normalizeDecimalString(expectedPrice); const submitted = normalizeDecimalString(submittedPrice); - const cmp = compareDecimalStrings(clicked, submitted); + const cmp = compareDecimalStrings(expected, submitted); if (cmp !== 0) { - throw new Error(`价格框未同步,点击价 ${clicked || clickedPrice},当前提交价 ${submitted || submittedPrice || "-"}`); + throw new Error(`价格框未同步,${expectedLabel} ${expected || expectedPrice},当前提交价 ${submitted || submittedPrice || "-"}`); } } + function assertSubmittedQtyMatchesExpectedQty(expectedQty, submittedQty, expectedLabel = "目标量") { + const expected = normalizeDecimalString(expectedQty); + const submitted = normalizeDecimalString(submittedQty); + const cmp = compareDecimalStrings(expected, submitted); + if (cmp !== 0) { + 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); + const qtyReady = await waitForTradeFormFrameState( + observationRoot, + () => { + const liveQtyInput = findQtyInput(); + const submittedQty = normalizeDecimalString(liveQtyInput?.value || ""); + return compareDecimalStrings(expectedQty, submittedQty) === 0 ? { qtyInput: liveQtyInput, submittedQty } : null; + }, + TRADE_INPUT_SYNC_TIMEOUT_MS, + TRADE_INPUT_SYNC_STABLE_FRAMES + ); + if (!qtyReady) { + assertSubmittedQtyMatchesExpectedQty(expectedQty, findQtyInput()?.value || "", qtyLabel); + throw new Error("数量框状态未稳定,已停止提交"); + } + observationRoot = getTradeMutationRoot(); + const priceInput = findPriceInput(); + if (!observationRoot || !priceInput) throw new Error("未找到当前交易表单或价格输入框"); + setInputValueReact(priceInput, expectedPrice); + const synchronized = await waitForTradeFormFrameState( + observationRoot, + () => readSynchronizedTradeInputs(expectedPrice, expectedQty), + TRADE_INPUT_SYNC_TIMEOUT_MS, + TRADE_INPUT_SYNC_STABLE_FRAMES + ); + if (synchronized) return synchronized; + assertSubmittedPriceMatchesExpectedPrice( + expectedPrice, + findPriceInput()?.value || "", + priceLabel + ); + assertSubmittedQtyMatchesExpectedQty(expectedQty, findQtyInput()?.value || "", qtyLabel); + throw new Error("价格和数量状态未稳定,已停止提交"); + } function isSubmitButtonBusy(button) { if (!button) return false; const text = (button.textContent || "").toLowerCase(); @@ -3503,13 +3603,11 @@ const currentPriceInput = findPriceInput(); const currentQtyInput = findQtyInput(); if (!currentPriceInput || !currentQtyInput) throw new Error("执行中价格或数量输入框丢失"); - setInputValueReact(currentPriceInput, order.price); - await delay(90); - setInputValueReact(currentQtyInput, order.qty); - await delay(120); - const submittedPrice = normalizeDecimalString(currentPriceInput.value); - if (!submittedPrice) throw new Error("执行中价格输入框值无效"); - assertSubmittedPriceMatchesClickedPrice(order.price, submittedPrice); + const synchronizedInputs = await syncTradeInputs(order.price, order.qty, { + priceLabel: "计划价", + qtyLabel: "计划量" + }); + const submittedPrice = synchronizedInputs.submittedPrice; assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, submittedPrice); const button = plan.spec.buttonGetter(); @@ -6333,12 +6431,10 @@ return; } lastTs = now; - setInputValueReact(priceInput, clickedPrice); - await delay(SINGLE_ORDER_PRICE_SYNC_DELAY_MS); - setInputValueReact(qtyInput, qtyPlan.qty); - await delay(SINGLE_ORDER_QTY_SYNC_DELAY_MS); - const submittedPriceInput = findPriceInput() || priceInput; - assertSubmittedPriceMatchesClickedPrice(clickedPrice, submittedPriceInput.value); + await syncTradeInputs(clickedPrice, qtyPlan.qty, { + priceLabel: "点击价", + qtyLabel: "目标量" + }); const currentSymbol = getCurrentSymbol(); if (!isCurrentObservedSymbol(qtyPlan.symbol)) { throw new Error(`交易对已变化,点击时 ${qtyPlan.symbol},当前 ${currentSymbol || "-"}`); diff --git a/src/binance-orderbook-trade/dom/trade-form.js b/src/binance-orderbook-trade/dom/trade-form.js index 406da5f..b9c84b9 100644 --- a/src/binance-orderbook-trade/dom/trade-form.js +++ b/src/binance-orderbook-trade/dom/trade-form.js @@ -185,6 +185,63 @@ export function waitForTradeFormMutationState(observationRoot, readState, timeou }); } +/** + * Confirm property-based controlled-input state across consecutive paint frames. + * MutationObserver cannot observe React restoring an input's value property, so + * trade submission must read the current live inputs after React has settled. + */ +export function waitForTradeFormFrameState( + observationRoot, + readState, + timeoutMs, + requiredStableFrames = 2, +) { + const view = observationRoot?.ownerDocument?.defaultView; + if ( + !view + || typeof view.requestAnimationFrame !== 'function' + || typeof view.cancelAnimationFrame !== 'function' + ) { + throw new Error('Trade form frame scheduler is unavailable'); + } + if (!Number.isInteger(requiredStableFrames) || requiredStableFrames < 1) { + throw new Error('requiredStableFrames must be a positive integer'); + } + + return new Promise((resolve) => { + let settled = false; + let frameHandle = 0; + let timer = 0; + let stableFrames = 0; + let stableState = null; + const finish = (value) => { + if (settled) return; + settled = true; + if (frameHandle) view.cancelAnimationFrame(frameHandle); + view.clearTimeout(timer); + resolve(value); + }; + const check = () => { + frameHandle = 0; + const state = readState(); + if (state) { + stableFrames += 1; + stableState = state; + if (stableFrames >= requiredStableFrames) { + finish(stableState); + return; + } + } else { + stableFrames = 0; + stableState = null; + } + frameHandle = view.requestAnimationFrame(check); + }; + timer = view.setTimeout(() => finish(null), timeoutMs); + frameHandle = view.requestAnimationFrame(check); + }); +} + export function isTradeModeTab(node, { panelId }) { if (!node?.matches?.('[role="tab"]')) return false; if (node.closest(`#${panelId}`)) return false; diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 48526f4..d7f0c64 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.119 +// @version 2.7.120 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -119,6 +119,7 @@ import { parseLeverageButtonText, placeTradePanelSpacer, readTradeAvailableBalance, + waitForTradeFormFrameState, waitForTradeFormMutationState, } from './dom/trade-form.js'; import { @@ -264,8 +265,8 @@ import { const BINANCE_PLACE_ORDER_BAPI_PATH = '/bapi/futures/v1/private/future/order/place-order'; const BINANCE_USER_POSITION_BAPI_PATH = '/bapi/futures/v6/private/future/user-data/user-position'; const LADDER_OPEN_QTY_READY_TIMEOUT_MS = 1200; - const SINGLE_ORDER_PRICE_SYNC_DELAY_MS = 90; - const SINGLE_ORDER_QTY_SYNC_DELAY_MS = 120; + const TRADE_INPUT_SYNC_TIMEOUT_MS = 350; + const TRADE_INPUT_SYNC_STABLE_FRAMES = 2; const ORDERBOOK_PRECISION_MANUAL_SAMPLE_DURATION_MS = 6000; const ORDERBOOK_PRECISION_SAMPLE_DURATION_MS = ORDERBOOK_PRECISION_MANUAL_SAMPLE_DURATION_MS; const ORDERBOOK_PRECISION_SAMPLE_POLL_MS = 300; @@ -2291,15 +2292,85 @@ import { if (!isPostOnlyOrderTypeActive()) throw new Error('执行中只做Maker (Post Only) 状态丢失,请刷新页面后重试'); } - function assertSubmittedPriceMatchesClickedPrice(clickedPrice, submittedPrice) { - const clicked = normalizeDecimalString(clickedPrice); + function assertSubmittedPriceMatchesExpectedPrice(expectedPrice, submittedPrice, expectedLabel = '点击价') { + const expected = normalizeDecimalString(expectedPrice); const submitted = normalizeDecimalString(submittedPrice); - const cmp = compareDecimalStrings(clicked, submitted); + const cmp = compareDecimalStrings(expected, submitted); if (cmp !== 0) { - throw new Error(`价格框未同步,点击价 ${clicked || clickedPrice},当前提交价 ${submitted || submittedPrice || '-'}`); + throw new Error(`价格框未同步,${expectedLabel} ${expected || expectedPrice},当前提交价 ${submitted || submittedPrice || '-'}`); } } + function assertSubmittedQtyMatchesExpectedQty(expectedQty, submittedQty, expectedLabel = '目标量') { + const expected = normalizeDecimalString(expectedQty); + const submitted = normalizeDecimalString(submittedQty); + const cmp = compareDecimalStrings(expected, submitted); + if (cmp !== 0) { + 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); + const qtyReady = await waitForTradeFormFrameState( + observationRoot, + () => { + const liveQtyInput = findQtyInput(); + const submittedQty = normalizeDecimalString(liveQtyInput?.value || ''); + return compareDecimalStrings(expectedQty, submittedQty) === 0 + ? { qtyInput: liveQtyInput, submittedQty } + : null; + }, + TRADE_INPUT_SYNC_TIMEOUT_MS, + TRADE_INPUT_SYNC_STABLE_FRAMES, + ); + if (!qtyReady) { + assertSubmittedQtyMatchesExpectedQty(expectedQty, findQtyInput()?.value || '', qtyLabel); + throw new Error('数量框状态未稳定,已停止提交'); + } + + observationRoot = getTradeMutationRoot(); + const priceInput = findPriceInput(); + if (!observationRoot || !priceInput) throw new Error('未找到当前交易表单或价格输入框'); + setInputValueReact(priceInput, expectedPrice); + const synchronized = await waitForTradeFormFrameState( + observationRoot, + () => readSynchronizedTradeInputs(expectedPrice, expectedQty), + TRADE_INPUT_SYNC_TIMEOUT_MS, + TRADE_INPUT_SYNC_STABLE_FRAMES, + ); + if (synchronized) return synchronized; + + assertSubmittedPriceMatchesExpectedPrice( + expectedPrice, + findPriceInput()?.value || '', + priceLabel, + ); + assertSubmittedQtyMatchesExpectedQty(expectedQty, findQtyInput()?.value || '', qtyLabel); + throw new Error('价格和数量状态未稳定,已停止提交'); + } + function isSubmitButtonBusy(button) { if (!button) return false; const text = (button.textContent || '').toLowerCase(); @@ -2437,13 +2508,11 @@ import { const currentQtyInput = findQtyInput(); if (!currentPriceInput || !currentQtyInput) throw new Error('执行中价格或数量输入框丢失'); - setInputValueReact(currentPriceInput, order.price); - await delay(90); - setInputValueReact(currentQtyInput, order.qty); - await delay(120); - const submittedPrice = normalizeDecimalString(currentPriceInput.value); - if (!submittedPrice) throw new Error('执行中价格输入框值无效'); - assertSubmittedPriceMatchesClickedPrice(order.price, submittedPrice); + const synchronizedInputs = await syncTradeInputs(order.price, order.qty, { + priceLabel: '计划价', + qtyLabel: '计划量', + }); + const submittedPrice = synchronizedInputs.submittedPrice; assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, submittedPrice); @@ -5699,12 +5768,10 @@ import { } lastTs = now; - setInputValueReact(priceInput, clickedPrice); - await delay(SINGLE_ORDER_PRICE_SYNC_DELAY_MS); - setInputValueReact(qtyInput, qtyPlan.qty); - await delay(SINGLE_ORDER_QTY_SYNC_DELAY_MS); - const submittedPriceInput = findPriceInput() || priceInput; - assertSubmittedPriceMatchesClickedPrice(clickedPrice, submittedPriceInput.value); + await syncTradeInputs(clickedPrice, qtyPlan.qty, { + priceLabel: '点击价', + qtyLabel: '目标量', + }); const currentSymbol = getCurrentSymbol(); if (!isCurrentObservedSymbol(qtyPlan.symbol)) { diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 99ce361..73c21f2 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -144,6 +144,27 @@ test('trade mode and Post Only switches wait for observed state instead of fixed assert.doesNotMatch(source, /findConditionalSubtypeCombobox|findPostOnlyOption|clickElementLikeUser/); }); +test('trade input synchronization confirms live controlled values instead of sleeping', () => { + const syncBody = readFunctionBody('syncTradeInputs'); + const executeBody = readFunctionBody('executeLadderPlan'); + + assert.match(syncBody, /setInputValueReact\(qtyInput,\s*expectedQty\)/); + 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, /findPriceInput\(\)/); + assert.match(syncBody, /findQtyInput\(\)/); + assert.match(syncBody, /assertSubmittedPriceMatchesExpectedPrice/); + assert.match(syncBody, /assertSubmittedQtyMatchesExpectedQty/); + assert.doesNotMatch(syncBody, /delay\(/); + assert.match(executeBody, /syncTradeInputs\(order\.price,\s*order\.qty,\s*\{[\s\S]*priceLabel:\s*'计划价'/); + assert.doesNotMatch(executeBody, /await delay\(90\)|await delay\(120\)/); + assert.match(source, /syncTradeInputs\(clickedPrice,\s*qtyPlan\.qty,\s*\{[\s\S]*priceLabel:\s*'点击价'/); +}); + test('labeled quantity matching resets its global regexp for every DOM node', () => { const readBody = readFunctionBody('readQtyTextNearButton'); assert.match(readBody, /new RegExp\([^;]+, 'gi'\)/); diff --git a/test/unit/binance-orderbook-trade/trade-form.test.js b/test/unit/binance-orderbook-trade/trade-form.test.js index c154d09..c469712 100644 --- a/test/unit/binance-orderbook-trade/trade-form.test.js +++ b/test/unit/binance-orderbook-trade/trade-form.test.js @@ -11,6 +11,7 @@ import { parseTradeModeLabel, placeTradePanelSpacer, readTradeAvailableBalance, + waitForTradeFormFrameState, waitForTradeFormMutationState, } from '../../../src/binance-orderbook-trade/dom/trade-form.js'; import { loadFixtureDom } from '../../helpers/dom.js'; @@ -174,6 +175,50 @@ test('trade form mutation wait returns the final state at its deadline', async ( ); }); +test('trade form frame wait requires consecutive live-state confirmations', async () => { + const dom = loadFixtureDom('
'); + const root = dom.window.document.querySelector('#trade-form'); + const states = [ + { price: '81.9', qty: '0.01' }, + null, + { price: '81.9', qty: '0.01' }, + { price: '81.9', qty: '0.01' }, + ]; + dom.window.requestAnimationFrame = (callback) => dom.window.setTimeout(callback, 0); + dom.window.cancelAnimationFrame = (handle) => dom.window.clearTimeout(handle); + + const result = await waitForTradeFormFrameState( + root, + () => states.shift() ?? null, + 100, + 2, + ); + + assert.deepEqual(result, { price: '81.9', qty: '0.01' }); + assert.equal(states.length, 0); +}); + +test('trade form frame wait rejects a value that never remains synchronized', async () => { + const dom = loadFixtureDom('
'); + const root = dom.window.document.querySelector('#trade-form'); + let matches = false; + dom.window.requestAnimationFrame = (callback) => dom.window.setTimeout(() => { + matches = !matches; + callback(); + }, 0); + dom.window.cancelAnimationFrame = (handle) => dom.window.clearTimeout(handle); + + assert.equal( + await waitForTradeFormFrameState( + root, + () => (matches ? { price: '81.9', qty: '0.01' } : null), + 20, + 2, + ), + null, + ); +}); + test('unexpected trade-mode structure is rejected instead of inserting at a guessed location', () => { const dom = loadFixtureDom(`