From e3e70535573282d01bca5585f97fa614e4cc7d30 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:34:44 +0800 Subject: [PATCH] fix: recover acknowledged ladder input rollbacks --- scripts/binance-orderbook-trade.user.js | 62 +++++- src/binance-orderbook-trade/dom/trade-form.js | 56 +++-- src/binance-orderbook-trade/index.user.js | 34 ++- .../source-regressions.test.js | 19 +- .../trade-form.test.js | 208 +++++++++++++++++- 5 files changed, 342 insertions(+), 37 deletions(-) diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index f67459a..9c18031 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.140 +// @version 2.7.141 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -1227,6 +1227,19 @@ return true; }; } + function isScriptOwnedTradeInputRecoveryState({ + preWriteValue, + rollbackValue, + submittedValue, + previousSubmittedValue, + compareValues + }) { + if (typeof compareValues !== "function") { + throw new Error("Trade input recovery comparison dependency is invalid"); + } + const isScriptOwnedOrEmpty = (value) => value === null || previousSubmittedValue != null && compareValues(previousSubmittedValue, value) === 0; + return isScriptOwnedOrEmpty(preWriteValue) && isScriptOwnedOrEmpty(rollbackValue) && isScriptOwnedOrEmpty(submittedValue); + } function createTradeInputStateReader({ resolveInputs, expectedPrice, @@ -1239,7 +1252,7 @@ requiredStableMatchFrames = 1, maxWriteAttempts = 2, recoverProvisionalMatchRollback = false, - isRecoveryWriteAllowed = () => true + isRecoveryWriteAllowed = ({ rollbackValue, submittedValue }) => rollbackValue === submittedValue }) { if (typeof resolveInputs !== "function" || typeof normalizeValue !== "function" || typeof compareValues !== "function" || typeof writeValue !== "function" || typeof isRecoveryWriteAllowed !== "function") { throw new Error("Trade input synchronizer dependencies are invalid"); @@ -1256,20 +1269,23 @@ if (typeof recoverProvisionalMatchRollback !== "boolean") { throw new Error("Provisional trade input recovery flag must be boolean"); } - const createSyncSlot = () => { + const createSyncSlot = (field) => { let root = null; let input = null; let writeCount = 0; + let preWriteValue = null; let rollbackValue = null; let recoveryEligible = false; let stableRollbackFrames = 0; let stableMatchFrames = 0; const clearRecovery = () => { + preWriteValue = null; rollbackValue = null; recoveryEligible = false; stableRollbackFrames = 0; }; const writeExpectedValue = (currentInput, expectedValue, submittedValue) => { + preWriteValue = submittedValue; const wrote = writeValue(currentInput, expectedValue); if (wrote === false) { writeCount = maxWriteAttempts; @@ -1307,7 +1323,7 @@ } if (stableMatchFrames > 0) { stableMatchFrames = 0; - recoveryEligible = rollbackValue === submittedValue && writeCount < maxWriteAttempts; + recoveryEligible = writeCount < maxWriteAttempts; stableRollbackFrames = 0; if (!recoveryEligible) clearRecovery(); } @@ -1316,15 +1332,14 @@ return false; } if (writeCount >= maxWriteAttempts || !recoveryEligible) return false; - if (rollbackValue !== submittedValue) { - clearRecovery(); - return false; - } if (!isRecoveryWriteAllowed({ + field, currentRoot, currentInput, expectedValue, + preWriteValue, rollbackValue, + submittedValue, writeCount })) { clearRecovery(); @@ -1336,8 +1351,8 @@ return false; }; }; - const syncQty = createSyncSlot(); - const syncPrice = createSyncSlot(); + const syncQty = createSyncSlot("qty"); + const syncPrice = createSyncSlot("price"); return () => { const inputs = resolveInputs(); if (!inputs?.qtyInput || includePrice && !inputs.priceInput) return null; @@ -3674,7 +3689,24 @@ const syncTimeoutMs = settleControlledForm ? LADDER_INPUT_SETTLE_TIMEOUT_MS : TRADE_INPUT_SYNC_TIMEOUT_MS; const stableMismatchFrames = settleControlledForm ? LADDER_INPUT_SETTLE_MISMATCH_FRAMES : TRADE_INPUT_SYNC_STABLE_FRAMES; const maxWriteAttempts = settleControlledForm ? LADDER_INPUT_SETTLE_MAX_WRITES : 2; - const isRecoveryWriteAllowed = settleControlledForm ? ({ rollbackValue }) => rollbackValue === null : () => true; + const previousSubmittedInputs = settleControlledForm ? options?.previousSubmittedInputs || null : null; + const isRecoveryWriteAllowed = settleControlledForm ? ({ + field, + preWriteValue, + rollbackValue, + submittedValue + }) => { + if (field !== "qty" && field !== "price") { + throw new Error("未知交易输入字段"); + } + return isScriptOwnedTradeInputRecoveryState({ + preWriteValue, + rollbackValue, + submittedValue, + previousSubmittedValue: field === "qty" ? previousSubmittedInputs?.submittedQty : previousSubmittedInputs?.submittedPrice, + compareValues: compareDecimalStrings + }); + } : ({ rollbackValue, submittedValue }) => rollbackValue === submittedValue; const writeTradeInputValue = settleControlledForm ? createBoundedInputWriter({ writeValue: setInputValueReact, maxWriteAttempts @@ -3834,6 +3866,7 @@ let done = 0; let repriceAttempts = 0; let lastRepriceApiErrorCode = null; + let previousAcknowledgedInputs = null; while (done < plan.orders.length) { throwIfAborted(abortSignal); if (ladderStopRequested) break; @@ -3850,7 +3883,8 @@ const synchronizedInputs = await syncTradeInputs(order.price, order.qty, { priceLabel: "计划价", qtyLabel: "计划量", - settleControlledForm: true + settleControlledForm: true, + previousSubmittedInputs: previousAcknowledgedInputs }); throwIfAborted(abortSignal); const submittedPrice = synchronizedInputs.submittedPrice; @@ -3879,6 +3913,10 @@ } finally { endLadderSubmitResponseCapture(submitCaptureId); } + previousAcknowledgedInputs = { + submittedPrice: synchronizedInputs.submittedPrice, + submittedQty: synchronizedInputs.submittedQty + }; } } catch (e) { if (!isRetryableLadderMakerPriceFailure(plan, e)) throw e; diff --git a/src/binance-orderbook-trade/dom/trade-form.js b/src/binance-orderbook-trade/dom/trade-form.js index 2ede08e..fe03f5a 100644 --- a/src/binance-orderbook-trade/dom/trade-form.js +++ b/src/binance-orderbook-trade/dom/trade-form.js @@ -143,13 +143,40 @@ export function createBoundedInputWriter({ writeValue, maxWriteAttempts }) { }; } +export function isScriptOwnedTradeInputRecoveryState({ + preWriteValue, + rollbackValue, + submittedValue, + previousSubmittedValue, + compareValues, +}) { + if (typeof compareValues !== 'function') { + throw new Error('Trade input recovery comparison dependency is invalid'); + } + + const isScriptOwnedOrEmpty = (value) => ( + value === null + || ( + previousSubmittedValue != null + && compareValues(previousSubmittedValue, value) === 0 + ) + ); + + return ( + isScriptOwnedOrEmpty(preWriteValue) + && isScriptOwnedOrEmpty(rollbackValue) + && isScriptOwnedOrEmpty(submittedValue) + ); +} + /** * Synchronize each live React input identity with a bounded post-transition budget. * Binance can synchronously restore a controlled input while a replacement form is * settling. A caller may preserve the pre-write value as a provisional rollback * contract because React can restore it after the write returns but before the - * first frame observation. Only an identical same-node rollback observed across - * consecutive frame reads earns another bounded write. + * first frame observation. Recovery policy owns the accepted state transition; + * generic callers retain exact rollback matching while ladder callers may identify + * a previous acknowledged script-owned value or Binance's empty post-submit state. */ export function createTradeInputStateReader({ resolveInputs, @@ -163,7 +190,9 @@ export function createTradeInputStateReader({ requiredStableMatchFrames = 1, maxWriteAttempts = 2, recoverProvisionalMatchRollback = false, - isRecoveryWriteAllowed = () => true, + isRecoveryWriteAllowed = ({ rollbackValue, submittedValue }) => ( + rollbackValue === submittedValue + ), }) { if ( typeof resolveInputs !== 'function' @@ -190,22 +219,25 @@ export function createTradeInputStateReader({ throw new Error('Provisional trade input recovery flag must be boolean'); } - const createSyncSlot = () => { + const createSyncSlot = (field) => { let root = null; let input = null; let writeCount = 0; + let preWriteValue = null; let rollbackValue = null; let recoveryEligible = false; let stableRollbackFrames = 0; let stableMatchFrames = 0; const clearRecovery = () => { + preWriteValue = null; rollbackValue = null; recoveryEligible = false; stableRollbackFrames = 0; }; const writeExpectedValue = (currentInput, expectedValue, submittedValue) => { + preWriteValue = submittedValue; const wrote = writeValue(currentInput, expectedValue); if (wrote === false) { writeCount = maxWriteAttempts; @@ -249,10 +281,7 @@ export function createTradeInputStateReader({ if (stableMatchFrames > 0) { stableMatchFrames = 0; - recoveryEligible = ( - rollbackValue === submittedValue - && writeCount < maxWriteAttempts - ); + recoveryEligible = writeCount < maxWriteAttempts; stableRollbackFrames = 0; if (!recoveryEligible) clearRecovery(); } @@ -263,15 +292,14 @@ export function createTradeInputStateReader({ } if (writeCount >= maxWriteAttempts || !recoveryEligible) return false; - if (rollbackValue !== submittedValue) { - clearRecovery(); - return false; - } if (!isRecoveryWriteAllowed({ + field, currentRoot, currentInput, expectedValue, + preWriteValue, rollbackValue, + submittedValue, writeCount, })) { clearRecovery(); @@ -286,8 +314,8 @@ export function createTradeInputStateReader({ }; }; - const syncQty = createSyncSlot(); - const syncPrice = createSyncSlot(); + const syncQty = createSyncSlot('qty'); + const syncPrice = createSyncSlot('price'); return () => { const inputs = resolveInputs(); if (!inputs?.qtyInput || (includePrice && !inputs.priceInput)) return null; diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 5a1f628..24e9de7 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.140 +// @version 2.7.141 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -121,6 +121,7 @@ import { findTradeFormRoot, findTradePanelInsertionPoint, findCurrentLeverageButtonFromScopes, + isScriptOwnedTradeInputRecoveryState, isTradeActionButton as isTradeActionButtonDom, isTradeModeTab as isTradeModeTabDom, mutationTouchesCloseQuantity, @@ -2247,9 +2248,30 @@ import { const maxWriteAttempts = settleControlledForm ? LADDER_INPUT_SETTLE_MAX_WRITES : 2; + const previousSubmittedInputs = settleControlledForm + ? options?.previousSubmittedInputs || null + : null; const isRecoveryWriteAllowed = settleControlledForm - ? ({ rollbackValue }) => rollbackValue === null - : () => true; + ? ({ + field, + preWriteValue, + rollbackValue, + submittedValue, + }) => { + if (field !== 'qty' && field !== 'price') { + throw new Error('未知交易输入字段'); + } + return isScriptOwnedTradeInputRecoveryState({ + preWriteValue, + rollbackValue, + submittedValue, + previousSubmittedValue: field === 'qty' + ? previousSubmittedInputs?.submittedQty + : previousSubmittedInputs?.submittedPrice, + compareValues: compareDecimalStrings, + }); + } + : ({ rollbackValue, submittedValue }) => rollbackValue === submittedValue; const writeTradeInputValue = settleControlledForm ? createBoundedInputWriter({ writeValue: setInputValueReact, @@ -2448,6 +2470,7 @@ import { let done = 0; let repriceAttempts = 0; let lastRepriceApiErrorCode = null; + let previousAcknowledgedInputs = null; while (done < plan.orders.length) { throwIfAborted(abortSignal); if (ladderStopRequested) break; @@ -2467,6 +2490,7 @@ import { priceLabel: '计划价', qtyLabel: '计划量', settleControlledForm: true, + previousSubmittedInputs: previousAcknowledgedInputs, }); throwIfAborted(abortSignal); const submittedPrice = synchronizedInputs.submittedPrice; @@ -2497,6 +2521,10 @@ import { } finally { endLadderSubmitResponseCapture(submitCaptureId); } + previousAcknowledgedInputs = { + submittedPrice: synchronizedInputs.submittedPrice, + submittedQty: synchronizedInputs.submittedQty, + }; } } catch (e) { if (!isRetryableLadderMakerPriceFailure(plan, e)) throw e; diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 681a004..9d44f6e 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -169,12 +169,14 @@ test('trade input synchronization confirms live controlled values instead of sle (syncBody.match(/recoverProvisionalMatchRollback:\s*settleControlledForm/g) || []).length, 2, ); - assert.match( - syncBody, - /\?\s*\(\{\s*rollbackValue\s*\}\)\s*=>\s*rollbackValue\s*===\s*null/, - ); - assert.doesNotMatch(syncBody, /rollbackValue\s*===\s*''/); + assert.match(syncBody, /isScriptOwnedTradeInputRecoveryState/); + assert.match(syncBody, /field[\s\S]*preWriteValue[\s\S]*rollbackValue[\s\S]*submittedValue/); + assert.match(syncBody, /previousSubmittedInputs\?\.submittedQty/); + assert.match(syncBody, /previousSubmittedInputs\?\.submittedPrice/); + assert.doesNotMatch(syncBody, /isRecoveryWriteAllowed\s*=\s*settleControlledForm[\s\S]*=>\s*true/); assert.match(syncBody, /settleControlledForm[\s\S]*LADDER_INPUT_SETTLE_MISMATCH_FRAMES/); + assert.match(source, /const LADDER_INPUT_SETTLE_TIMEOUT_MS = 1200;/); + assert.match(source, /const LADDER_INPUT_SETTLE_MAX_WRITES = 5;/); assert.match(syncBody, /waitForTradeFormFrameState/); assert.match(syncBody, /includePrice:\s*false/); assert.match(syncBody, /includePrice:\s*true/); @@ -184,7 +186,12 @@ test('trade input synchronization confirms live controlled values instead of sle 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*'计划价'[\s\S]*settleControlledForm:\s*true/); + assert.match(executeBody, /syncTradeInputs\(order\.price,\s*order\.qty,\s*\{[\s\S]*priceLabel:\s*'计划价'[\s\S]*settleControlledForm:\s*true[\s\S]*previousSubmittedInputs:\s*previousAcknowledgedInputs/); + assert.equal((executeBody.match(/previousAcknowledgedInputs\s*=/g) || []).length, 2); + assert.ok( + executeBody.indexOf('previousAcknowledgedInputs = {') + > executeBody.indexOf('await waitForOrderSubmitAcknowledgement'), + ); assert.doesNotMatch(executeBody, /await delay\(90\)|await delay\(120\)/); const clickSyncCall = source.match(/syncTradeInputs\(clickedPrice,\s*qtyPlan\.qty,\s*\{[\s\S]*?\}\);/)?.[0] || ''; assert.match(clickSyncCall, /priceLabel:\s*'点击价'/); diff --git a/test/unit/binance-orderbook-trade/trade-form.test.js b/test/unit/binance-orderbook-trade/trade-form.test.js index 7f8a452..13fb127 100644 --- a/test/unit/binance-orderbook-trade/trade-form.test.js +++ b/test/unit/binance-orderbook-trade/trade-form.test.js @@ -9,6 +9,7 @@ import { findActiveTradeInputs, findTradeFormRoot, findTradePanelInsertionPoint, + isScriptOwnedTradeInputRecoveryState, isTradeModeTab, mutationTouchesCloseQuantity, parseTradeModeLabel, @@ -446,7 +447,17 @@ test('trade input synchronization recovers a provisional match rolled back befor requiredStableMatchFrames: 2, maxWriteAttempts: 5, recoverProvisionalMatchRollback: true, - isRecoveryWriteAllowed: ({ rollbackValue }) => rollbackValue === null, + isRecoveryWriteAllowed: ({ + preWriteValue, + rollbackValue, + submittedValue, + }) => isScriptOwnedTradeInputRecoveryState({ + preWriteValue, + rollbackValue, + submittedValue, + previousSubmittedValue: null, + compareValues: compareDecimalStrings, + }), }); assert.equal(readState(), null); @@ -461,6 +472,189 @@ test('trade input synchronization recovers a provisional match rolled back befor assert.deepEqual(writes.map(({ value }) => value), ['0.07', '0.07']); }); +test('script-owned trade input recovery accepts only the same field previous value or empty state', () => { + const previousSubmittedInputs = { + submittedPrice: '84.5', + submittedQty: '0.05', + }; + const isAllowed = (field, state) => isScriptOwnedTradeInputRecoveryState({ + ...state, + previousSubmittedValue: field === 'qty' + ? previousSubmittedInputs.submittedQty + : previousSubmittedInputs.submittedPrice, + compareValues: compareDecimalStrings, + }); + + assert.equal(isAllowed('qty', { + preWriteValue: '0.05', + rollbackValue: '0.05', + submittedValue: '0.05', + }), true); + assert.equal(isAllowed('qty', { + preWriteValue: '0.05', + rollbackValue: null, + submittedValue: null, + }), true); + assert.equal(isAllowed('qty', { + preWriteValue: '84.5', + rollbackValue: '84.5', + submittedValue: '84.5', + }), false); + assert.equal(isAllowed('price', { + preWriteValue: '84.5', + rollbackValue: '84.5', + submittedValue: null, + }), true); + assert.equal(isAllowed('qty', { + preWriteValue: '0.03', + rollbackValue: '0.03', + submittedValue: '0.03', + }), false); +}); + +test('trade input synchronization recovers a previous acknowledged quantity cleared by Binance', () => { + const currentInputs = { + root: {}, + priceInput: null, + qtyInput: { value: '0.05' }, + }; + const writes = []; + const previousSubmittedInputs = { + submittedPrice: '84.5', + submittedQty: '0.05', + }; + const readState = createTradeInputStateReader({ + resolveInputs: () => currentInputs, + expectedQty: '0.1', + includePrice: false, + normalizeValue: normalizeDecimalString, + compareValues: compareDecimalStrings, + writeValue: (input, value) => { + writes.push({ input, value }); + input.value = value; + }, + requiredStableMismatchFrames: 2, + requiredStableMatchFrames: 2, + maxWriteAttempts: 5, + recoverProvisionalMatchRollback: true, + isRecoveryWriteAllowed: ({ + field, + preWriteValue, + rollbackValue, + submittedValue, + }) => isScriptOwnedTradeInputRecoveryState({ + preWriteValue, + rollbackValue, + submittedValue, + previousSubmittedValue: field === 'qty' + ? previousSubmittedInputs.submittedQty + : previousSubmittedInputs.submittedPrice, + compareValues: compareDecimalStrings, + }), + }); + + assert.equal(readState(), null); + assert.equal(readState(), null); + currentInputs.qtyInput.value = ''; + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.deepEqual(readState(), { + ...currentInputs, + submittedQty: '0.1', + }); + assert.deepEqual(writes.map(({ value }) => value), ['0.1', '0.1']); +}); + +test('previous acknowledged recovery remains bounded when Binance repeatedly clears the input', () => { + const currentInputs = { + root: {}, + priceInput: null, + qtyInput: { value: '0.05' }, + }; + const writes = []; + const readState = createTradeInputStateReader({ + resolveInputs: () => currentInputs, + expectedQty: '0.1', + includePrice: false, + normalizeValue: normalizeDecimalString, + compareValues: compareDecimalStrings, + writeValue: (input, value) => { + writes.push({ input, value }); + input.value = ''; + }, + requiredStableMismatchFrames: 1, + maxWriteAttempts: 3, + recoverProvisionalMatchRollback: true, + isRecoveryWriteAllowed: ({ + preWriteValue, + rollbackValue, + submittedValue, + }) => isScriptOwnedTradeInputRecoveryState({ + preWriteValue, + rollbackValue, + submittedValue, + previousSubmittedValue: '0.05', + compareValues: compareDecimalStrings, + }), + }); + + for (let frame = 0; frame < 10; frame += 1) { + assert.equal(readState(), null); + } + assert.deepEqual(writes.map(({ value }) => value), ['0.1', '0.1', '0.1']); +}); + +test('previous acknowledged recovery gives a replacement input an independent bounded write', () => { + let currentInputs = { + root: {}, + priceInput: null, + qtyInput: { value: '0.05' }, + }; + const writes = []; + const readState = createTradeInputStateReader({ + resolveInputs: () => currentInputs, + expectedQty: '0.1', + includePrice: false, + normalizeValue: normalizeDecimalString, + compareValues: compareDecimalStrings, + writeValue: (input, value) => { + writes.push({ input, value }); + input.value = value; + }, + requiredStableMismatchFrames: 2, + requiredStableMatchFrames: 2, + maxWriteAttempts: 5, + recoverProvisionalMatchRollback: true, + isRecoveryWriteAllowed: ({ + preWriteValue, + rollbackValue, + submittedValue, + }) => isScriptOwnedTradeInputRecoveryState({ + preWriteValue, + rollbackValue, + submittedValue, + previousSubmittedValue: '0.05', + compareValues: compareDecimalStrings, + }), + }); + + assert.equal(readState(), null); + assert.equal(readState(), null); + currentInputs = { + root: {}, + priceInput: null, + qtyInput: { value: '0.05' }, + }; + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.deepEqual(readState(), { + ...currentInputs, + submittedQty: '0.1', + }); + assert.deepEqual(writes.map(({ value }) => value), ['0.1', '0.1']); +}); + test('trade input synchronization cancels provisional recovery for a different non-empty value', () => { const currentInputs = { root: {}, @@ -482,7 +676,17 @@ test('trade input synchronization cancels provisional recovery for a different n requiredStableMatchFrames: 2, maxWriteAttempts: 5, recoverProvisionalMatchRollback: true, - isRecoveryWriteAllowed: ({ rollbackValue }) => rollbackValue === null, + isRecoveryWriteAllowed: ({ + preWriteValue, + rollbackValue, + submittedValue, + }) => isScriptOwnedTradeInputRecoveryState({ + preWriteValue, + rollbackValue, + submittedValue, + previousSubmittedValue: null, + compareValues: compareDecimalStrings, + }), }); assert.equal(readState(), null);