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