From 0b47dca246df1d832a99239f7dcfebb61e8a7ab7 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:40:03 +0800 Subject: [PATCH] fix: coalesce Binance chart saves during bulk cancel --- .../fixtures/binance-futures.js | 98 +++- .../specs/cancel-covering-matrix.pw.js | 15 + .../specs/cancel-current-symbol.pw.js | 44 +- scripts/binance-orderbook-trade.user.js | 527 +++++++++++++----- ...s-recovery.js => chart-orders-recovery.js} | 16 +- .../core/chart-save-coalescer.js | 169 ++++++ .../core/tradingview-orders.js | 84 --- .../dom/chart-orders.js | 111 ++++ src/binance-orderbook-trade/index.user.js | 292 +++++++--- .../chart-orders.test.js | 198 +++++++ .../chart-orders-recovery.test.js | 48 +- .../chart-save-coalescer.test.js | 241 ++++++++ .../source-regressions.test.js | 102 ++-- .../tradingview-orders.test.js | 124 ----- 14 files changed, 1552 insertions(+), 517 deletions(-) rename src/binance-orderbook-trade/core/{tradingview-orders-recovery.js => chart-orders-recovery.js} (60%) create mode 100644 src/binance-orderbook-trade/core/chart-save-coalescer.js delete mode 100644 src/binance-orderbook-trade/core/tradingview-orders.js create mode 100644 src/binance-orderbook-trade/dom/chart-orders.js create mode 100644 test/dom/binance-orderbook-trade/chart-orders.test.js create mode 100644 test/unit/binance-orderbook-trade/chart-save-coalescer.test.js delete mode 100644 test/unit/binance-orderbook-trade/tradingview-orders.test.js diff --git a/e2e/binance-orderbook/fixtures/binance-futures.js b/e2e/binance-orderbook/fixtures/binance-futures.js index 9c5057c..43ecd8d 100644 --- a/e2e/binance-orderbook/fixtures/binance-futures.js +++ b/e2e/binance-orderbook/fixtures/binance-futures.js @@ -34,6 +34,10 @@ export function renderBinanceFuturesFixture(scenario) { .row-content { display: flex; gap: 16px; } .emit-price { display: inline-block; min-width: 70px; min-height: 20px; } .chart-widget-root { width: 700px; height: 300px; margin-top: 20px; border: 1px solid #d8dce1; } + .chart-toolbar { display: flex; align-items: center; gap: 12px; height: 32px; padding: 6px; } + .chart-toolbar .bn-tooltips-wrap { display: inline-block; min-width: 24px; min-height: 18px; } + .chart-orders-popover { position: absolute; z-index: 50; display: none; padding: 8px; background: #fff; border: 1px solid #d8dce1; } + .chart-orders-popover.active { display: block; } .chart-widget-root iframe { width: 100%; height: 100%; border: 0; } .react-grid-item { margin: 16px 20px; min-height: 240px; padding: 12px; } #account-tabs { margin-bottom: 12px; } @@ -70,7 +74,18 @@ export function renderBinanceFuturesFixture(scenario) { 81.0281.03 81.0481.05 -
+
+
+ intervals + shot + + orders + + 最新价格 +
+ +
+
@@ -320,18 +335,81 @@ export function renderBinanceFuturesFixture(scenario) { } } - const chart = { - properties() { - return { tradingProperties: { showOrders: { value: () => state.showOrders } } }; + const chartOrdersPopover = document.querySelector('#chart-orders-menu'); + const chartOrdersTrigger = document.querySelector('[data-testid="chart-orders-trigger"]'); + const chartRoot = document.querySelector('.chart-widget-root'); + const chartEventListeners = new Map(); + const tradingViewApi = { + saveChart(snapshot) { + record('chart-saved', { snapshot }); }, - applyOverrides(overrides) { - state.showOrders = overrides['tradingProperties.showOrders']; - record('show-orders', { value: state.showOrders }); + subscribe(eventName, listener) { + const listeners = chartEventListeners.get(eventName) || new Set(); + listeners.add(listener); + chartEventListeners.set(eventName, listeners); + }, + unsubscribe(eventName, listener) { + const listeners = chartEventListeners.get(eventName); + if (!listeners?.delete(listener)) { + throw new Error('Fixture could not unsubscribe the TradingView listener'); + } + if (!listeners.size) chartEventListeners.delete(eventName); + }, + emit(eventName, ...args) { + for (const listener of chartEventListeners.get(eventName) || []) listener(...args); }, }; - document.querySelector('.chart-widget-root iframe').contentWindow.tradingViewApi = { - activeChart: () => chart, - }; + + function renderChartOrdersPopover() { + chartOrdersPopover.innerHTML = + '
仓位
' + + '
当前委托
' + + '
止盈止损
'; + chartOrdersPopover.querySelector('[data-chart-orders-checkbox]').addEventListener('click', () => { + const nextChecked = !state.showOrders; + const drawings = currentOrders().map((order) => ({ ...order })); + state.showOrders = nextChecked; + renderChartOrdersPopover(); + record('chart-orders-checked', { value: nextChecked, drawings: drawings.length }); + drawings.forEach((order, index) => { + setTimeout(() => { + tradingViewApi.emit( + 'drawing_event', + 'order-' + order.id, + nextChecked ? 'properties_changed' : 'remove', + ); + record('chart-save-requested', { + checked: nextChecked, + index, + orderId: order.id, + }); + tradingViewApi.saveChart({ + checked: nextChecked, + drawingCount: drawings.length, + finalOrderId: order.id, + }); + }, scenario.host.mutationDelayMs + (index * 2)); + }); + }); + } + + function openChartOrdersPopover() { + chartOrdersPopover.classList.add('active'); + record('chart-orders-popover-opened'); + } + + function closeChartOrdersPopover() { + if (!chartOrdersPopover.classList.contains('active')) return; + chartOrdersPopover.classList.remove('active'); + record('chart-orders-popover-closed'); + } + + chartOrdersTrigger.addEventListener('mouseenter', openChartOrdersPopover); + chartRoot.addEventListener('mouseenter', (event) => { + if (event.target === chartRoot) closeChartOrdersPopover(); + }); + document.querySelector('.chart-widget-root iframe').contentWindow.tradingViewApi = tradingViewApi; + renderChartOrdersPopover(); window.__BINANCE_FIXTURE__ = { snapshot: () => JSON.parse(JSON.stringify({ diff --git a/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js b/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js index a59ed3b..764ee7b 100644 --- a/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js +++ b/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js @@ -78,6 +78,21 @@ for (const entry of CANCEL_COVERING_SCENARIOS) { expect(state.events.filter((event) => event.type === 'cancel-requested')).toHaveLength( hasCurrentOrders && vector.dialogOutcome === 'confirm' ? 1 : 0, ); + const currentOrderCount = currentSymbolOrders(scenario).length; + const expectedChartToggleCount = hasCurrentOrders && scenario.ui.showOrders ? 2 : 0; + const expectedChartSaveBurstCount = expectedChartToggleCount === 0 + ? 0 + : vector.dialogOutcome === 'confirm' ? 1 : 2; + const expectedChartSaveRequestCount = expectedChartSaveBurstCount * currentOrderCount; + expect(state.events.filter((event) => event.type === 'chart-orders-checked')).toHaveLength( + expectedChartToggleCount, + ); + expect(state.events.filter((event) => event.type === 'chart-save-requested')).toHaveLength( + expectedChartSaveRequestCount, + ); + expect(state.events.filter((event) => event.type === 'chart-saved')).toHaveLength( + expectedChartSaveBurstCount, + ); expect(errors).toEqual([]); }); } diff --git a/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js index 9cc9eeb..3fc292e 100644 --- a/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js +++ b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js @@ -105,6 +105,46 @@ test('cancelling the native dialog preserves current and other orders and restor expect(errors).toEqual([]); }); +test('a 70-order drawing burst split across tasks performs one full save per toggle', async ({ page }) => { + const orders = Array.from({ length: 70 }, (_, index) => ({ + id: `current-${index + 1}`, + symbol: 'HYPEUSDT', + side: 'SELL', + price: String(90 + (index / 100)), + quantity: '0.01', + })); + const scenario = createCancelScenario({ + positions: POSITION_SETS.current, + orders, + ui: { hideOtherSymbols: false, accountTab: 'positions', showOrders: true }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + await installInteractionProbe(page, CANCEL_BUTTON_SELECTOR); + + await page.getByRole('button', { name: '撤单' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect.poll(async () => (await readFixtureState(page)).showOrders).toBe(false); + await page.getByRole('button', { name: '取消' }).click(); + await expect(page.getByText('已取消撤单,已恢复页面状态')).toBeVisible(); + + const state = await readFixtureState(page); + expect(state.orders).toEqual(orders); + expect( + state.events.filter((event) => event.type === 'chart-save-requested'), + ).toHaveLength(140); + expect(state.events.filter((event) => event.type === 'chart-saved')).toHaveLength(2); + expect( + state.events + .filter((event) => event.type === 'chart-orders-checked') + .map((event) => event.value), + ).toEqual([false, true]); + await expectRestoredState(page, scenario); + const probe = await finishInteractionProbe(page); + assertResponsiveInteraction(expect, probe); + assertStableGeometry(expect, probe.baseline, probe.current); + expect(errors).toEqual([]); +}); + test('confirming with mixed-symbol orders clears only the current symbol', async ({ page }) => { const scenario = createCancelScenario({ positions: POSITION_SETS.both, @@ -141,7 +181,7 @@ test('an originally enabled symbol filter remains enabled after confirmation', a const state = await readFixtureState(page); expect(state.orders).toEqual(otherSymbolOrders(scenario)); await expectRestoredState(page, scenario); - expect(state.events.filter((event) => event.type === 'show-orders')).toEqual([]); + expect(state.events.filter((event) => event.type === 'chart-orders-checked')).toEqual([]); expect(errors).toEqual([]); }); @@ -279,7 +319,7 @@ for (const dialogMode of ['extraAction', 'missingPrimary']) { expect(state.events.filter((event) => event.type === 'cancel-requested')).toEqual([]); expect( state.events - .filter((event) => event.type === 'show-orders') + .filter((event) => event.type === 'chart-orders-checked') .map((event) => event.value), ).toEqual([false, true]); expect(state.showOrders).toBe(true); diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 6448abe..b220054 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.144 +// @version 2.7.145 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -1822,13 +1822,13 @@ }); } - // src/binance-orderbook-trade/core/tradingview-orders-recovery.js - var TRADINGVIEW_ORDERS_RECOVERY_STORAGE_KEY = "binance-orderbook-trade:tradingview-orders-recovery:v1"; - function createTradingViewOrdersRecoveryRecord(nowMs) { + // src/binance-orderbook-trade/core/chart-orders-recovery.js + var CHART_ORDERS_RECOVERY_STORAGE_KEY = "binance-orderbook-trade:chart-orders-recovery:v2"; + function createChartOrdersRecoveryRecord(nowMs) { if (!Number.isFinite(nowMs)) throw new Error("Chart orders recovery timestamp is invalid"); - return JSON.stringify({ version: 1, originalVisible: true, createdAtMs: nowMs }); + return JSON.stringify({ version: 2, originalChecked: true, createdAtMs: nowMs }); } - function parseTradingViewOrdersRecoveryRecord(rawValue, nowMs) { + function parseChartOrdersRecoveryRecord(rawValue, nowMs) { if (rawValue === null) return { status: "missing", record: null }; if (!Number.isFinite(nowMs)) throw new Error("Chart orders recovery current time is invalid"); let record; @@ -1838,75 +1838,157 @@ return { status: "invalid", record: null }; } const keys = record && typeof record === "object" ? Object.keys(record).sort() : []; - if (keys.join(",") !== "createdAtMs,originalVisible,version" || record.version !== 1 || record.originalVisible !== true || !Number.isFinite(record.createdAtMs) || record.createdAtMs > nowMs) { + if (keys.join(",") !== "createdAtMs,originalChecked,version" || record.version !== 2 || record.originalChecked !== true || !Number.isFinite(record.createdAtMs) || record.createdAtMs > nowMs) { return { status: "invalid", record: null }; } return { status: "valid", record }; } - // src/binance-orderbook-trade/core/tradingview-orders.js - function getTradingViewOrdersContract(tradingViewApi) { - if (!tradingViewApi || typeof tradingViewApi.activeChart !== "function") { + // src/binance-orderbook-trade/core/chart-save-coalescer.js + var IGNORED_DRAWING_EVENT_TYPES = /* @__PURE__ */ new Set(["click", "move"]); + function validateTradingViewApi(api) { + if (!api || typeof api !== "object") { throw new Error("TradingView API is unavailable"); } - const chart = tradingViewApi.activeChart(); - if (!chart || typeof chart.properties !== "function" || typeof chart.applyOverrides !== "function") { - throw new Error("TradingView active chart API is unavailable"); + if (typeof api.saveChart !== "function") { + throw new Error("TradingView saveChart API is unavailable"); } - const showOrders = chart.properties()?.tradingProperties?.showOrders; - if (!showOrders || typeof showOrders.value !== "function") { - throw new Error("TradingView showOrders property is unavailable"); - } - const visible = showOrders.value(); - if (typeof visible !== "boolean") { - throw new Error("TradingView showOrders property is not boolean"); - } - return { chart, visible }; - } - function validateVisibilityState(state) { - if (!state || typeof state.chart !== "object" || typeof state.originalVisible !== "boolean" || typeof state.changed !== "boolean") { - throw new Error("TradingView showOrders state is invalid"); + if (typeof api.subscribe !== "function" || typeof api.unsubscribe !== "function") { + throw new Error("TradingView drawing-event subscription API is unavailable"); } } - function assertSameChart(state, chart) { - if (state.chart !== chart) { - throw new Error("Binance TradingView active chart changed"); + function restoreSaveChartMethod(api, wrapper, originalSaveChart, originalDescriptor) { + if (api.saveChart !== wrapper) { + throw new Error("TradingView saveChart API changed during save coalescing"); } - } - function assertTradingViewOrdersTarget(tradingViewApi, state) { - validateVisibilityState(state); - const { chart } = getTradingViewOrdersContract(tradingViewApi); - assertSameChart(state, chart); - } - function captureTradingViewOrdersVisibility(tradingViewApi) { - const { chart, visible } = getTradingViewOrdersContract(tradingViewApi); - return { chart, originalVisible: visible, changed: false }; - } - function readTradingViewOrdersVisibility(tradingViewApi) { - return getTradingViewOrdersContract(tradingViewApi).visible; - } - function applyTradingViewOrdersVisibility(tradingViewApi, visible) { - if (typeof visible !== "boolean") { - throw new Error("TradingView showOrders target is not boolean"); + if (originalDescriptor) { + Object.defineProperty(api, "saveChart", originalDescriptor); + } else { + delete api.saveChart; + } + if (api.saveChart !== originalSaveChart) { + throw new Error("TradingView saveChart API was not restored"); + } + } + async function coalesceTradingViewDrawingSaves(api, action, { + eventDiscoveryTimeoutMs = 800, + settleQuietMs = 50, + timeoutMs = 1800, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout + } = {}) { + validateTradingViewApi(api); + if (typeof action !== "function") throw new Error("Chart action is unavailable"); + if (!Number.isFinite(eventDiscoveryTimeoutMs) || eventDiscoveryTimeoutMs < 0) { + throw new Error("TradingView drawing-event discovery timeout is invalid"); + } + if (!Number.isFinite(settleQuietMs) || settleQuietMs <= 0) { + throw new Error("TradingView drawing-save quiet window is invalid"); + } + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error("TradingView save coalescing timeout is invalid"); + } + const originalSaveChart = api.saveChart; + const originalDescriptor = Object.getOwnPropertyDescriptor(api, "saveChart"); + let drawingEventCount = 0; + let saveRequestCount = 0; + let pendingSave = null; + let actionFinished = false; + let eventStartResolve; + let settleResolve; + let settleReject; + let eventDiscoveryTimeout = null; + let settleQuietTimeout = null; + let waitTimeout = null; + let subscribed = false; + const drawingEventsStarted = new Promise((resolve) => { + eventStartResolve = resolve; + }); + const burstSettled = new Promise((resolve, reject) => { + settleResolve = resolve; + settleReject = reject; + }); + const scheduleSettleIfReady = () => { + if (settleQuietTimeout !== null) { + clearTimeoutFn(settleQuietTimeout); + settleQuietTimeout = null; + } + if (!actionFinished || drawingEventCount === 0 || saveRequestCount < drawingEventCount) { + return; + } + settleQuietTimeout = setTimeoutFn(() => { + settleQuietTimeout = null; + settleResolve(); + }, settleQuietMs); + }; + const handleDrawingEvent = (_drawingId, eventType) => { + if (IGNORED_DRAWING_EVENT_TYPES.has(eventType)) return; + drawingEventCount += 1; + if (drawingEventCount === 1) eventStartResolve(); + scheduleSettleIfReady(); + }; + const saveChartWrapper = function coalescedSaveChart(...args) { + saveRequestCount += 1; + pendingSave = { thisValue: this, args }; + scheduleSettleIfReady(); + }; + api.subscribe("drawing_event", handleDrawingEvent); + subscribed = true; + try { + api.saveChart = saveChartWrapper; + if (api.saveChart !== saveChartWrapper) { + throw new Error("TradingView saveChart API is not writable"); + } + } catch (error) { + api.unsubscribe("drawing_event", handleDrawingEvent); + throw error; } - const { chart } = getTradingViewOrdersContract(tradingViewApi); - chart.applyOverrides({ "tradingProperties.showOrders": visible }); - const applied = getTradingViewOrdersContract(tradingViewApi).visible; - if (applied !== visible) { - throw new Error(`TradingView showOrders remained ${applied}`); + try { + const actionResult = await action(); + actionFinished = true; + scheduleSettleIfReady(); + if (drawingEventCount === 0 && eventDiscoveryTimeoutMs > 0) { + await Promise.race([ + drawingEventsStarted, + new Promise((resolve) => { + eventDiscoveryTimeout = setTimeoutFn(resolve, eventDiscoveryTimeoutMs); + }) + ]); + } + if (drawingEventCount > 0) { + scheduleSettleIfReady(); + waitTimeout = setTimeoutFn(() => { + if (saveRequestCount < drawingEventCount) { + settleReject(new Error( + `Expected ${drawingEventCount} TradingView saveChart requests, received ${saveRequestCount}` + )); + return; + } + settleReject(new Error( + `TradingView drawing-save burst did not settle within ${timeoutMs}ms` + )); + }, timeoutMs); + await burstSettled; + } + api.unsubscribe("drawing_event", handleDrawingEvent); + subscribed = false; + if (pendingSave) { + originalSaveChart.apply(pendingSave.thisValue, pendingSave.args); + } + return { + actionResult, + drawingEventCount, + saveRequestCount, + fullSaveCount: pendingSave ? 1 : 0 + }; + } finally { + if (eventDiscoveryTimeout !== null) clearTimeoutFn(eventDiscoveryTimeout); + if (settleQuietTimeout !== null) clearTimeoutFn(settleQuietTimeout); + if (waitTimeout !== null) clearTimeoutFn(waitTimeout); + if (subscribed) api.unsubscribe("drawing_event", handleDrawingEvent); + restoreSaveChartMethod(api, saveChartWrapper, originalSaveChart, originalDescriptor); } } - function hideTradingViewOrders(tradingViewApi, state) { - assertTradingViewOrdersTarget(tradingViewApi, state); - if (!state.originalVisible) return; - state.changed = true; - applyTradingViewOrdersVisibility(tradingViewApi, false); - } - function restoreTradingViewOrders(tradingViewApi, state) { - assertTradingViewOrdersTarget(tradingViewApi, state); - if (!state.changed) return; - applyTradingViewOrdersVisibility(tradingViewApi, state.originalVisible); - } // src/binance-orderbook-trade/core/cancel-dialog-decision.js function resolveCancelDialogDecision({ @@ -2118,11 +2200,85 @@ } return { chartRoot, tradingViewApi: tradingViewApis[0] }; } - function getBinanceTradingViewTarget(document2) { - const target = findBinanceTradingViewTarget(document2); - if (!target) throw new Error("Binance TradingView target is unavailable"); + + // src/binance-orderbook-trade/dom/chart-orders.js + var ACTIVE_POPOVER_SELECTOR = ".bn-bubble.active"; + var OPEN_ORDERS_LABEL_PATTERN = /^(?:当前委托|Open Orders)$/i; + function normalizeLabel(value) { + return String(value || "").replace(/\s+/g, " ").trim(); + } + function hasVisibleBox2(element) { + if (!element?.getClientRects().length) return false; + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + function findBinanceChartOrdersTarget(document2) { + const tradingViewTarget = findBinanceTradingViewTarget(document2); + if (!tradingViewTarget) return null; + const { chartRoot, tradingViewApi } = tradingViewTarget; + const toolbars = Array.from(chartRoot.querySelectorAll(".flex.items-center")).filter((toolbar2) => { + if (toolbar2.children.length < 2) return false; + const trigger2 = toolbar2.children[toolbar2.children.length - 2]; + const latestPriceSlot = toolbar2.children[toolbar2.children.length - 1]; + return hasVisibleBox2(toolbar2) && hasVisibleBox2(trigger2) && trigger2.matches(".bn-tooltips-wrap.bn-tooltips-web") && hasVisibleBox2(latestPriceSlot) && latestPriceSlot.matches( + ".bn-tooltips-wrap.bn-tooltips-web.w-full.cursor-pointer" + ); + }); + if (!toolbars.length) return null; + if (toolbars.length > 1) { + throw new Error(`Expected one Binance chart toolbar, found ${toolbars.length}`); + } + const toolbar = toolbars[0]; + const trigger = toolbar.children[toolbar.children.length - 2]; + const popoverReferences = Array.from( + trigger.querySelectorAll(".bn-tooltips-ele[aria-describedby]") + ); + if (popoverReferences.length !== 1) { + throw new Error( + `Expected one Binance chart orders popover reference, found ${popoverReferences.length}` + ); + } + const popoverId = popoverReferences[0].getAttribute("aria-describedby"); + if (!popoverId) throw new Error("Binance chart orders popover id is unavailable"); + return { + chartRoot, + tradingViewApi, + toolbar, + trigger, + popoverId + }; + } + function getBinanceChartOrdersTarget(document2) { + const target = findBinanceChartOrdersTarget(document2); + if (!target) throw new Error("Binance chart orders target is unavailable"); return target; } + function assertSameBinanceChartOrdersTarget(capturedTarget, currentTarget) { + if (!capturedTarget || !currentTarget) { + throw new Error("Binance chart orders target is unavailable"); + } + if (capturedTarget.chartRoot !== currentTarget.chartRoot || capturedTarget.tradingViewApi !== currentTarget.tradingViewApi || capturedTarget.toolbar !== currentTarget.toolbar || capturedTarget.trigger !== currentTarget.trigger || capturedTarget.popoverId !== currentTarget.popoverId) { + throw new Error("Binance chart orders target changed"); + } + } + function findActiveBinanceChartOrdersPopover(document2, target, isVisibleElement) { + if (!target?.popoverId) throw new Error("Binance chart orders target is unavailable"); + const popover = document2.getElementById(target.popoverId); + if (!popover || !popover.matches(ACTIVE_POPOVER_SELECTOR) || !isVisibleElement(popover)) { + return null; + } + const checkboxes = Array.from(popover.querySelectorAll('[role="checkbox"]')).filter(isVisibleElement).filter((checkbox2) => OPEN_ORDERS_LABEL_PATTERN.test(normalizeLabel(checkbox2.textContent))); + if (!checkboxes.length) return null; + if (checkboxes.length > 1) { + throw new Error(`Expected one Binance chart OpenOrders checkbox, found ${checkboxes.length}`); + } + const checkbox = checkboxes[0]; + const checkedValue = checkbox.getAttribute("aria-checked"); + if (checkedValue !== "true" && checkedValue !== "false") { + throw new Error(`Binance chart OpenOrders state is ${checkedValue}`); + } + return { popover, checkbox, checked: checkedValue === "true" }; + } // src/binance-orderbook-trade/index.user.js (function() { @@ -2214,6 +2370,8 @@ const ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS = 6e4; const CANCEL_DIALOG_DISCOVERY_TIMEOUT_MS = 1800; const CANCEL_NO_ORDERS_FEEDBACK_MS = 600; + const CHART_ORDERS_MENU_TIMEOUT_MS = 1800; + const CHART_ORDERS_MENU_POLL_MS = 50; const LADDER_MAKER_BUFFER_LEVELS = 1; const LADDER_REPRICE_MAX_ATTEMPTS = 5; const LADDER_REPRICE_DELAY_MS = 180; @@ -2305,9 +2463,9 @@ let cancelCurrentSymbolOpenOrdersBlocksLadderActions = false; let cancelNoOrdersFeedbackActive = false; let cancelNoOrdersFeedbackTimer = 0; - let tradingViewOrdersRecoveryPendingAtStartup = sessionStorage.getItem(TRADINGVIEW_ORDERS_RECOVERY_STORAGE_KEY) !== null; - let tradingViewOrdersRecoveryTask = null; - let tradingViewOrdersRecoveryLastError = null; + let chartOrdersRecoveryPendingAtStartup = sessionStorage.getItem(CHART_ORDERS_RECOVERY_STORAGE_KEY) !== null; + let chartOrdersRecoveryTask = null; + let chartOrdersRecoveryLastError = null; let ladderStopRequested = false; let ladderStatusText = "空闲"; let ladderStatusTitle = "空闲"; @@ -5004,93 +5162,193 @@ await watcher.dialogSignal.waitForChange(observedVersion, remainingDiscoveryMs); } } - function writeTradingViewOrdersRecoveryRecord() { + function getBinanceChartOrdersTarget2() { + return getBinanceChartOrdersTarget(document); + } + function writeChartOrdersRecoveryRecord() { sessionStorage.setItem( - TRADINGVIEW_ORDERS_RECOVERY_STORAGE_KEY, - createTradingViewOrdersRecoveryRecord(Date.now()) + CHART_ORDERS_RECOVERY_STORAGE_KEY, + createChartOrdersRecoveryRecord(Date.now()) ); } - function clearTradingViewOrdersRecoveryRecord() { - sessionStorage.removeItem(TRADINGVIEW_ORDERS_RECOVERY_STORAGE_KEY); + function clearChartOrdersRecoveryRecord() { + sessionStorage.removeItem(CHART_ORDERS_RECOVERY_STORAGE_KEY); } - function readTradingViewOrdersRecoveryRecord() { - return parseTradingViewOrdersRecoveryRecord( - sessionStorage.getItem(TRADINGVIEW_ORDERS_RECOVERY_STORAGE_KEY), + function readChartOrdersRecoveryRecord() { + return parseChartOrdersRecoveryRecord( + sessionStorage.getItem(CHART_ORDERS_RECOVERY_STORAGE_KEY), Date.now() ); } - async function waitForTwoAnimationFrames() { - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + function dispatchChartOrdersPointerEvents(element, eventTypes, relatedTarget = null) { + for (const type of eventTypes) { + element.dispatchEvent(new MouseEvent(type, { + bubbles: type !== "mouseenter" && type !== "mouseleave", + cancelable: true, + composed: true, + relatedTarget, + view: window + })); + } + } + async function waitForBinanceChartOrdersPopover(target, expectedChecked = null) { + const deadline = Date.now() + CHART_ORDERS_MENU_TIMEOUT_MS; + while (Date.now() < deadline) { + const current2 = findActiveBinanceChartOrdersPopover( + document, + target, + isVisibleElement + ); + if (current2 && (expectedChecked === null || current2.checked === expectedChecked)) { + return current2; + } + await delay(CHART_ORDERS_MENU_POLL_MS); + } + const current = findActiveBinanceChartOrdersPopover( + document, + target, + isVisibleElement + ); + if (current && (expectedChecked === null || current.checked === expectedChecked)) { + return current; + } + throw new Error(expectedChecked === null ? "Binance chart OpenOrders popover did not open" : `Binance chart OpenOrders state did not become ${expectedChecked}`); + } + async function openBinanceChartOrdersPopover(target) { + const currentTarget = getBinanceChartOrdersTarget2(); + assertSameBinanceChartOrdersTarget(target, currentTarget); + dispatchChartOrdersPointerEvents( + currentTarget.trigger, + ["pointerover", "mouseover", "mouseenter"] + ); + return waitForBinanceChartOrdersPopover(currentTarget); } - function getCurrentBinanceTradingViewApi() { - return getBinanceTradingViewTarget(document).tradingViewApi; + async function closeBinanceChartOrdersPopover(target) { + const currentTarget = getBinanceChartOrdersTarget2(); + assertSameBinanceChartOrdersTarget(target, currentTarget); + const current = findActiveBinanceChartOrdersPopover( + document, + currentTarget, + isVisibleElement + ); + if (!current) return; + dispatchChartOrdersPointerEvents( + current.popover, + ["pointerout", "mouseout", "mouseleave"], + currentTarget.chartRoot + ); + dispatchChartOrdersPointerEvents( + currentTarget.trigger, + ["pointerout", "mouseout", "mouseleave"], + currentTarget.chartRoot + ); + dispatchChartOrdersPointerEvents( + currentTarget.chartRoot, + ["pointerover", "mouseover", "mouseenter"], + currentTarget.trigger + ); + const deadline = Date.now() + CHART_ORDERS_MENU_TIMEOUT_MS; + while (Date.now() < deadline) { + if (!findActiveBinanceChartOrdersPopover( + document, + currentTarget, + isVisibleElement + )) return; + await delay(CHART_ORDERS_MENU_POLL_MS); + } + if (findActiveBinanceChartOrdersPopover( + document, + currentTarget, + isVisibleElement + )) { + throw new Error("Binance chart OpenOrders popover did not close"); + } } - function assertTradingViewOrdersVisibility(state, expectedVisible) { - const api = getCurrentBinanceTradingViewApi(); - assertTradingViewOrdersTarget(api, state); - const visible = readTradingViewOrdersVisibility(api); - if (visible !== expectedVisible) { - throw new Error(`TradingView showOrders is ${visible}`); + async function toggleBinanceChartOrdersWithCoalescedSave(target, checkbox, expectedChecked, expectDrawingEvents) { + if (typeof expectDrawingEvents !== "boolean") { + throw new Error("Chart orders drawing-event expectation is invalid"); } + const result = await coalesceTradingViewDrawingSaves( + target.tradingViewApi, + async () => { + checkbox.click(); + await waitForBinanceChartOrdersPopover(target, expectedChecked); + }, + expectDrawingEvents ? {} : { eventDiscoveryTimeoutMs: 0 } + ); + log("图表当前委托保存已合并", { + drawingEvents: result.drawingEventCount, + saveRequests: result.saveRequestCount, + fullSaves: result.fullSaveCount + }); } - async function hideTradingViewOrdersForBulkCancel(api, state) { - assertTradingViewOrdersTarget(api, state); - if (state.originalVisible) writeTradingViewOrdersRecoveryRecord(); - try { - hideTradingViewOrders(api, state); - } catch (error) { - if (!state.changed) clearTradingViewOrdersRecoveryRecord(); - throw error; + async function hideBinanceChartOrdersForBulkCancel(target, state) { + const current = await openBinanceChartOrdersPopover(target); + state.originalChecked = current.checked; + if (current.checked) { + writeChartOrdersRecoveryRecord(); + state.changed = true; + await toggleBinanceChartOrdersWithCoalescedSave( + target, + current.checkbox, + false, + true + ); } - if (!state.changed) return; - await waitForTwoAnimationFrames(); - assertTradingViewOrdersVisibility(state, false); + await closeBinanceChartOrdersPopover(target); } - async function restoreTradingViewOrdersAfterBulkCancel(state) { - const api = getCurrentBinanceTradingViewApi(); - assertTradingViewOrdersTarget(api, state); - restoreTradingViewOrders(api, state); - if (state.changed) { - await waitForTwoAnimationFrames(); - assertTradingViewOrdersVisibility(state, state.originalVisible); + async function restoreBinanceChartOrdersAfterBulkCancel(target, state, expectDrawingEvents) { + if (typeof expectDrawingEvents !== "boolean") { + throw new Error("Chart orders drawing-event expectation is invalid"); + } + assertSameBinanceChartOrdersTarget(target, getBinanceChartOrdersTarget2()); + const current = await openBinanceChartOrdersPopover(target); + if (current.checked !== state.originalChecked) { + await toggleBinanceChartOrdersWithCoalescedSave( + target, + current.checkbox, + state.originalChecked, + expectDrawingEvents + ); } - clearTradingViewOrdersRecoveryRecord(); + await closeBinanceChartOrdersPopover(target); + clearChartOrdersRecoveryRecord(); } async function recoverChartOrdersStateAfterReload() { - const recovery = readTradingViewOrdersRecoveryRecord(); + const recovery = readChartOrdersRecoveryRecord(); if (recovery.status === "missing") { - tradingViewOrdersRecoveryPendingAtStartup = false; + chartOrdersRecoveryPendingAtStartup = false; return { status: "missing" }; } if (recovery.status === "invalid") { emit("ERR", "图表当前委托恢复记录无效,未修改页面状态"); - clearTradingViewOrdersRecoveryRecord(); - tradingViewOrdersRecoveryPendingAtStartup = false; + clearChartOrdersRecoveryRecord(); + chartOrdersRecoveryPendingAtStartup = false; return { status: recovery.status }; } - const target = findBinanceTradingViewTarget(document); + const target = findBinanceChartOrdersTarget(document); if (!target) return { status: "target_not_ready" }; - const state = captureTradingViewOrdersVisibility(target.tradingViewApi); - state.changed = state.originalVisible !== recovery.record.originalVisible; - state.originalVisible = recovery.record.originalVisible; - await restoreTradingViewOrdersAfterBulkCancel(state); - tradingViewOrdersRecoveryPendingAtStartup = false; - tradingViewOrdersRecoveryLastError = null; + await restoreBinanceChartOrdersAfterBulkCancel(target, { + originalChecked: recovery.record.originalChecked, + changed: true + }, true); + chartOrdersRecoveryPendingAtStartup = false; + chartOrdersRecoveryLastError = null; log("已恢复刷新前的图表当前委托显示状态"); return { status: "restored" }; } function scheduleChartOrdersRecovery() { - if (!tradingViewOrdersRecoveryPendingAtStartup || tradingViewOrdersRecoveryTask || cancelCurrentSymbolOpenOrdersTask || document.hidden || !isFuturesTradingPage()) return; + if (!chartOrdersRecoveryPendingAtStartup || chartOrdersRecoveryTask || cancelCurrentSymbolOpenOrdersTask || document.hidden || !isFuturesTradingPage()) return; const task = recoverChartOrdersStateAfterReload(); - tradingViewOrdersRecoveryTask = task; + chartOrdersRecoveryTask = task; task.catch((error) => { const message = String(error?.message || error); - if (message !== tradingViewOrdersRecoveryLastError) { - tradingViewOrdersRecoveryLastError = message; + if (message !== chartOrdersRecoveryLastError) { + chartOrdersRecoveryLastError = message; emit("ERR", "恢复刷新前的图表当前委托显示失败", error); } }).finally(() => { - if (tradingViewOrdersRecoveryTask === task) tradingViewOrdersRecoveryTask = null; + if (chartOrdersRecoveryTask === task) chartOrdersRecoveryTask = null; }); } async function runCancelCurrentSymbolOpenOrders(options = null) { @@ -5114,8 +5372,10 @@ let previousOpenOrdersSubTabIdentity = null; let symbolFilterOriginalChecked = null; let restoreTemporaryUiState = true; - let tradingViewOrdersState = null; - let restoreTradingViewOrdersState = true; + let chartOrdersTarget = null; + const chartOrdersState = { originalChecked: null, changed: false }; + let restoreChartOrdersState = true; + let chartOrdersDefinitivelyCleared = false; let successStatusMessage = null; try { const tabReady = await activateOpenOrdersTab(); @@ -5180,9 +5440,8 @@ cancelCurrentSymbolOpenOrdersBlocksLadderActions = true; scheduleRenderPanel(); try { - const tradingViewApi = getCurrentBinanceTradingViewApi(); - tradingViewOrdersState = captureTradingViewOrdersVisibility(tradingViewApi); - await hideTradingViewOrdersForBulkCancel(tradingViewApi, tradingViewOrdersState); + chartOrdersTarget = getBinanceChartOrdersTarget2(); + await hideBinanceChartOrdersForBulkCancel(chartOrdersTarget, chartOrdersState); } catch (e) { emit("ERR", "撤单前隐藏图表当前委托失败", e); const message = "未能确认图表当前委托已隐藏,未打开撤单确认框"; @@ -5231,7 +5490,7 @@ } if (dialogDecision.status === "aborted") { restoreTemporaryUiState = false; - restoreTradingViewOrdersState = false; + restoreChartOrdersState = false; const interruptedBaseAsset = formatStatusBaseAsset(symbol); const message = `原交易对 ${interruptedBaseAsset} 页面已离开,撤单确认跟踪已停止`; setLadderStatus(message); @@ -5282,6 +5541,7 @@ setLadderStatus(message); return { ok: false, status: "not_cleared", message }; } + chartOrdersDefinitivelyCleared = clearResult.definitivelyCleared === true; successStatusMessage = waitUntilCleared ? "当前币挂单已撤,继续重挂" : "撤单流程结束,已恢复筛选状态"; return { ok: true, status: "cleared" }; } finally { @@ -5302,17 +5562,22 @@ temporaryUiRestoreSucceeded = await restoreAccountOrdersTab(previousAccountOrdersTabIdentity, symbol) && temporaryUiRestoreSucceeded; } } - let tradingViewOrdersRestoreSucceeded = true; - if (restoreTradingViewOrdersState && tradingViewOrdersState?.changed) { + let chartOrdersRestoreSucceeded = true; + if (restoreChartOrdersState && chartOrdersState.changed) { try { - await restoreTradingViewOrdersAfterBulkCancel(tradingViewOrdersState); + const chartOrdersStillDefinitivelyCleared = chartOrdersDefinitivelyCleared && getOpenOrdersTabCount() === 0; + await restoreBinanceChartOrdersAfterBulkCancel( + chartOrdersTarget, + chartOrdersState, + !chartOrdersStillDefinitivelyCleared + ); } catch (e) { - tradingViewOrdersRestoreSucceeded = false; + chartOrdersRestoreSucceeded = false; emit("ERR", "恢复图表当前委托显示失败", e); setLadderStatus("未能恢复图表当前委托显示"); } } - if (restoreTemporaryUiState && isCurrentObservedSymbol(symbol) && tradingViewOrdersRestoreSucceeded && temporaryUiRestoreSucceeded && successStatusMessage) { + if (restoreTemporaryUiState && isCurrentObservedSymbol(symbol) && chartOrdersRestoreSucceeded && temporaryUiRestoreSucceeded && successStatusMessage) { setLadderStatus(successStatusMessage); } } diff --git a/src/binance-orderbook-trade/core/tradingview-orders-recovery.js b/src/binance-orderbook-trade/core/chart-orders-recovery.js similarity index 60% rename from src/binance-orderbook-trade/core/tradingview-orders-recovery.js rename to src/binance-orderbook-trade/core/chart-orders-recovery.js index 2d778e5..aff0528 100644 --- a/src/binance-orderbook-trade/core/tradingview-orders-recovery.js +++ b/src/binance-orderbook-trade/core/chart-orders-recovery.js @@ -1,12 +1,12 @@ -export const TRADINGVIEW_ORDERS_RECOVERY_STORAGE_KEY = - 'binance-orderbook-trade:tradingview-orders-recovery:v1'; +export const CHART_ORDERS_RECOVERY_STORAGE_KEY = + 'binance-orderbook-trade:chart-orders-recovery:v2'; -export function createTradingViewOrdersRecoveryRecord(nowMs) { +export function createChartOrdersRecoveryRecord(nowMs) { if (!Number.isFinite(nowMs)) throw new Error('Chart orders recovery timestamp is invalid'); - return JSON.stringify({ version: 1, originalVisible: true, createdAtMs: nowMs }); + return JSON.stringify({ version: 2, originalChecked: true, createdAtMs: nowMs }); } -export function parseTradingViewOrdersRecoveryRecord(rawValue, nowMs) { +export function parseChartOrdersRecoveryRecord(rawValue, nowMs) { if (rawValue === null) return { status: 'missing', record: null }; if (!Number.isFinite(nowMs)) throw new Error('Chart orders recovery current time is invalid'); @@ -18,9 +18,9 @@ export function parseTradingViewOrdersRecoveryRecord(rawValue, nowMs) { } const keys = record && typeof record === 'object' ? Object.keys(record).sort() : []; if ( - keys.join(',') !== 'createdAtMs,originalVisible,version' - || record.version !== 1 - || record.originalVisible !== true + keys.join(',') !== 'createdAtMs,originalChecked,version' + || record.version !== 2 + || record.originalChecked !== true || !Number.isFinite(record.createdAtMs) || record.createdAtMs > nowMs ) { diff --git a/src/binance-orderbook-trade/core/chart-save-coalescer.js b/src/binance-orderbook-trade/core/chart-save-coalescer.js new file mode 100644 index 0000000..561e730 --- /dev/null +++ b/src/binance-orderbook-trade/core/chart-save-coalescer.js @@ -0,0 +1,169 @@ +const IGNORED_DRAWING_EVENT_TYPES = new Set(['click', 'move']); + +function validateTradingViewApi(api) { + if (!api || typeof api !== 'object') { + throw new Error('TradingView API is unavailable'); + } + if (typeof api.saveChart !== 'function') { + throw new Error('TradingView saveChart API is unavailable'); + } + if (typeof api.subscribe !== 'function' || typeof api.unsubscribe !== 'function') { + throw new Error('TradingView drawing-event subscription API is unavailable'); + } +} + +function restoreSaveChartMethod(api, wrapper, originalSaveChart, originalDescriptor) { + if (api.saveChart !== wrapper) { + throw new Error('TradingView saveChart API changed during save coalescing'); + } + if (originalDescriptor) { + Object.defineProperty(api, 'saveChart', originalDescriptor); + } else { + delete api.saveChart; + } + if (api.saveChart !== originalSaveChart) { + throw new Error('TradingView saveChart API was not restored'); + } +} + +/** + * Binance schedules one complete chart save for every broker drawing event. + * The last request contains the cumulative final state, so one burst can be + * persisted with only its final request after all matching events arrive. + */ +export async function coalesceTradingViewDrawingSaves( + api, + action, + { + eventDiscoveryTimeoutMs = 800, + settleQuietMs = 50, + timeoutMs = 1800, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout, + } = {}, +) { + validateTradingViewApi(api); + if (typeof action !== 'function') throw new Error('Chart action is unavailable'); + if (!Number.isFinite(eventDiscoveryTimeoutMs) || eventDiscoveryTimeoutMs < 0) { + throw new Error('TradingView drawing-event discovery timeout is invalid'); + } + if (!Number.isFinite(settleQuietMs) || settleQuietMs <= 0) { + throw new Error('TradingView drawing-save quiet window is invalid'); + } + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error('TradingView save coalescing timeout is invalid'); + } + + const originalSaveChart = api.saveChart; + const originalDescriptor = Object.getOwnPropertyDescriptor(api, 'saveChart'); + let drawingEventCount = 0; + let saveRequestCount = 0; + let pendingSave = null; + let actionFinished = false; + let eventStartResolve; + let settleResolve; + let settleReject; + let eventDiscoveryTimeout = null; + let settleQuietTimeout = null; + let waitTimeout = null; + let subscribed = false; + + const drawingEventsStarted = new Promise((resolve) => { + eventStartResolve = resolve; + }); + const burstSettled = new Promise((resolve, reject) => { + settleResolve = resolve; + settleReject = reject; + }); + const scheduleSettleIfReady = () => { + if (settleQuietTimeout !== null) { + clearTimeoutFn(settleQuietTimeout); + settleQuietTimeout = null; + } + if ( + !actionFinished + || drawingEventCount === 0 + || saveRequestCount < drawingEventCount + ) { + return; + } + settleQuietTimeout = setTimeoutFn(() => { + settleQuietTimeout = null; + settleResolve(); + }, settleQuietMs); + }; + const handleDrawingEvent = (_drawingId, eventType) => { + if (IGNORED_DRAWING_EVENT_TYPES.has(eventType)) return; + drawingEventCount += 1; + if (drawingEventCount === 1) eventStartResolve(); + scheduleSettleIfReady(); + }; + const saveChartWrapper = function coalescedSaveChart(...args) { + saveRequestCount += 1; + pendingSave = { thisValue: this, args }; + scheduleSettleIfReady(); + }; + + api.subscribe('drawing_event', handleDrawingEvent); + subscribed = true; + try { + api.saveChart = saveChartWrapper; + if (api.saveChart !== saveChartWrapper) { + throw new Error('TradingView saveChart API is not writable'); + } + } catch (error) { + api.unsubscribe('drawing_event', handleDrawingEvent); + throw error; + } + + try { + const actionResult = await action(); + actionFinished = true; + scheduleSettleIfReady(); + + if (drawingEventCount === 0 && eventDiscoveryTimeoutMs > 0) { + // Binance changes the checkbox first and emits drawing events later. + await Promise.race([ + drawingEventsStarted, + new Promise((resolve) => { + eventDiscoveryTimeout = setTimeoutFn(resolve, eventDiscoveryTimeoutMs); + }), + ]); + } + + if (drawingEventCount > 0) { + scheduleSettleIfReady(); + waitTimeout = setTimeoutFn(() => { + if (saveRequestCount < drawingEventCount) { + settleReject(new Error( + `Expected ${drawingEventCount} TradingView saveChart requests, received ${saveRequestCount}`, + )); + return; + } + settleReject(new Error( + `TradingView drawing-save burst did not settle within ${timeoutMs}ms`, + )); + }, timeoutMs); + await burstSettled; + } + + api.unsubscribe('drawing_event', handleDrawingEvent); + subscribed = false; + + if (pendingSave) { + originalSaveChart.apply(pendingSave.thisValue, pendingSave.args); + } + return { + actionResult, + drawingEventCount, + saveRequestCount, + fullSaveCount: pendingSave ? 1 : 0, + }; + } finally { + if (eventDiscoveryTimeout !== null) clearTimeoutFn(eventDiscoveryTimeout); + if (settleQuietTimeout !== null) clearTimeoutFn(settleQuietTimeout); + if (waitTimeout !== null) clearTimeoutFn(waitTimeout); + if (subscribed) api.unsubscribe('drawing_event', handleDrawingEvent); + restoreSaveChartMethod(api, saveChartWrapper, originalSaveChart, originalDescriptor); + } +} diff --git a/src/binance-orderbook-trade/core/tradingview-orders.js b/src/binance-orderbook-trade/core/tradingview-orders.js deleted file mode 100644 index b20fdd9..0000000 --- a/src/binance-orderbook-trade/core/tradingview-orders.js +++ /dev/null @@ -1,84 +0,0 @@ -function getTradingViewOrdersContract(tradingViewApi) { - if (!tradingViewApi || typeof tradingViewApi.activeChart !== 'function') { - throw new Error('TradingView API is unavailable'); - } - - const chart = tradingViewApi.activeChart(); - if (!chart || typeof chart.properties !== 'function' || typeof chart.applyOverrides !== 'function') { - throw new Error('TradingView active chart API is unavailable'); - } - - const showOrders = chart.properties()?.tradingProperties?.showOrders; - if (!showOrders || typeof showOrders.value !== 'function') { - throw new Error('TradingView showOrders property is unavailable'); - } - - const visible = showOrders.value(); - if (typeof visible !== 'boolean') { - throw new Error('TradingView showOrders property is not boolean'); - } - - return { chart, visible }; -} - -function validateVisibilityState(state) { - if ( - !state - || typeof state.chart !== 'object' - || typeof state.originalVisible !== 'boolean' - || typeof state.changed !== 'boolean' - ) { - throw new Error('TradingView showOrders state is invalid'); - } -} - -function assertSameChart(state, chart) { - if (state.chart !== chart) { - throw new Error('Binance TradingView active chart changed'); - } -} - -export function assertTradingViewOrdersTarget(tradingViewApi, state) { - validateVisibilityState(state); - const { chart } = getTradingViewOrdersContract(tradingViewApi); - assertSameChart(state, chart); -} - -export function captureTradingViewOrdersVisibility(tradingViewApi) { - const { chart, visible } = getTradingViewOrdersContract(tradingViewApi); - return { chart, originalVisible: visible, changed: false }; -} - -export function readTradingViewOrdersVisibility(tradingViewApi) { - return getTradingViewOrdersContract(tradingViewApi).visible; -} - -export function applyTradingViewOrdersVisibility(tradingViewApi, visible) { - if (typeof visible !== 'boolean') { - throw new Error('TradingView showOrders target is not boolean'); - } - - const { chart } = getTradingViewOrdersContract(tradingViewApi); - chart.applyOverrides({ 'tradingProperties.showOrders': visible }); - - const applied = getTradingViewOrdersContract(tradingViewApi).visible; - if (applied !== visible) { - throw new Error(`TradingView showOrders remained ${applied}`); - } -} - -export function hideTradingViewOrders(tradingViewApi, state) { - assertTradingViewOrdersTarget(tradingViewApi, state); - if (!state.originalVisible) return; - - // Cleanup must restore the original visibility even if post-apply verification fails. - state.changed = true; - applyTradingViewOrdersVisibility(tradingViewApi, false); -} - -export function restoreTradingViewOrders(tradingViewApi, state) { - assertTradingViewOrdersTarget(tradingViewApi, state); - if (!state.changed) return; - - applyTradingViewOrdersVisibility(tradingViewApi, state.originalVisible); -} diff --git a/src/binance-orderbook-trade/dom/chart-orders.js b/src/binance-orderbook-trade/dom/chart-orders.js new file mode 100644 index 0000000..fcc4ac9 --- /dev/null +++ b/src/binance-orderbook-trade/dom/chart-orders.js @@ -0,0 +1,111 @@ +import { findBinanceTradingViewTarget } from './tradingview-target.js'; + +const ACTIVE_POPOVER_SELECTOR = '.bn-bubble.active'; +const OPEN_ORDERS_LABEL_PATTERN = /^(?:当前委托|Open Orders)$/i; + +function normalizeLabel(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); +} + +function hasVisibleBox(element) { + if (!element?.getClientRects().length) return false; + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} + +export function findBinanceChartOrdersTarget(document) { + const tradingViewTarget = findBinanceTradingViewTarget(document); + if (!tradingViewTarget) return null; + + const { chartRoot, tradingViewApi } = tradingViewTarget; + const toolbars = Array.from(chartRoot.querySelectorAll('.flex.items-center')) + .filter((toolbar) => { + if (toolbar.children.length < 2) return false; + const trigger = toolbar.children[toolbar.children.length - 2]; + const latestPriceSlot = toolbar.children[toolbar.children.length - 1]; + return hasVisibleBox(toolbar) + && hasVisibleBox(trigger) + && trigger.matches('.bn-tooltips-wrap.bn-tooltips-web') + && hasVisibleBox(latestPriceSlot) + && latestPriceSlot.matches( + '.bn-tooltips-wrap.bn-tooltips-web.w-full.cursor-pointer', + ); + }); + if (!toolbars.length) return null; + if (toolbars.length > 1) { + throw new Error(`Expected one Binance chart toolbar, found ${toolbars.length}`); + } + + const toolbar = toolbars[0]; + const trigger = toolbar.children[toolbar.children.length - 2]; + const popoverReferences = Array.from( + trigger.querySelectorAll('.bn-tooltips-ele[aria-describedby]'), + ); + if (popoverReferences.length !== 1) { + throw new Error( + `Expected one Binance chart orders popover reference, found ${popoverReferences.length}`, + ); + } + const popoverId = popoverReferences[0].getAttribute('aria-describedby'); + if (!popoverId) throw new Error('Binance chart orders popover id is unavailable'); + + return { + chartRoot, + tradingViewApi, + toolbar, + trigger, + popoverId, + }; +} + +export function getBinanceChartOrdersTarget(document) { + const target = findBinanceChartOrdersTarget(document); + if (!target) throw new Error('Binance chart orders target is unavailable'); + return target; +} + +export function assertSameBinanceChartOrdersTarget(capturedTarget, currentTarget) { + if (!capturedTarget || !currentTarget) { + throw new Error('Binance chart orders target is unavailable'); + } + if ( + capturedTarget.chartRoot !== currentTarget.chartRoot + || capturedTarget.tradingViewApi !== currentTarget.tradingViewApi + || capturedTarget.toolbar !== currentTarget.toolbar + || capturedTarget.trigger !== currentTarget.trigger + || capturedTarget.popoverId !== currentTarget.popoverId + ) { + throw new Error('Binance chart orders target changed'); + } +} + +export function findActiveBinanceChartOrdersPopover( + document, + target, + isVisibleElement, +) { + if (!target?.popoverId) throw new Error('Binance chart orders target is unavailable'); + const popover = document.getElementById(target.popoverId); + if ( + !popover + || !popover.matches(ACTIVE_POPOVER_SELECTOR) + || !isVisibleElement(popover) + ) { + return null; + } + + const checkboxes = Array.from(popover.querySelectorAll('[role="checkbox"]')) + .filter(isVisibleElement) + .filter((checkbox) => OPEN_ORDERS_LABEL_PATTERN.test(normalizeLabel(checkbox.textContent))); + if (!checkboxes.length) return null; + if (checkboxes.length > 1) { + throw new Error(`Expected one Binance chart OpenOrders checkbox, found ${checkboxes.length}`); + } + + const checkbox = checkboxes[0]; + const checkedValue = checkbox.getAttribute('aria-checked'); + if (checkedValue !== 'true' && checkedValue !== 'false') { + throw new Error(`Binance chart OpenOrders state is ${checkedValue}`); + } + return { popover, checkbox, checked: checkedValue === 'true' }; +} diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index c84dff4..f418b9d 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.144 +// @version 2.7.145 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -158,17 +158,11 @@ import { saveSymbolSide, } from './core/panel-options.js'; import { - TRADINGVIEW_ORDERS_RECOVERY_STORAGE_KEY, - createTradingViewOrdersRecoveryRecord, - parseTradingViewOrdersRecoveryRecord, -} from './core/tradingview-orders-recovery.js'; -import { - assertTradingViewOrdersTarget, - captureTradingViewOrdersVisibility, - hideTradingViewOrders, - readTradingViewOrdersVisibility, - restoreTradingViewOrders, -} from './core/tradingview-orders.js'; + CHART_ORDERS_RECOVERY_STORAGE_KEY, + createChartOrdersRecoveryRecord, + parseChartOrdersRecoveryRecord, +} from './core/chart-orders-recovery.js'; +import { coalesceTradingViewDrawingSaves } from './core/chart-save-coalescer.js'; import { resolveCancelDialogDecision } from './core/cancel-dialog-decision.js'; import { classifyBinanceCancelAllDialogAction, @@ -182,9 +176,11 @@ import { getOpenOrderRowCells, } from './dom/open-order-rows.js'; import { - findBinanceTradingViewTarget as findBinanceTradingViewTargetDom, - getBinanceTradingViewTarget as getBinanceTradingViewTargetDom, -} from './dom/tradingview-target.js'; + assertSameBinanceChartOrdersTarget, + findActiveBinanceChartOrdersPopover, + findBinanceChartOrdersTarget as findBinanceChartOrdersTargetDom, + getBinanceChartOrdersTarget as getBinanceChartOrdersTargetDom, +} from './dom/chart-orders.js'; (function () { 'use strict'; @@ -278,6 +274,8 @@ import { const ROW_CANCEL_DIALOG_CLOSE_TIMEOUT_MS = 60000; const CANCEL_DIALOG_DISCOVERY_TIMEOUT_MS = 1800; const CANCEL_NO_ORDERS_FEEDBACK_MS = 600; + const CHART_ORDERS_MENU_TIMEOUT_MS = 1800; + const CHART_ORDERS_MENU_POLL_MS = 50; const LADDER_MAKER_BUFFER_LEVELS = 1; const LADDER_REPRICE_MAX_ATTEMPTS = 5; const LADDER_REPRICE_DELAY_MS = 180; @@ -370,10 +368,10 @@ import { let cancelCurrentSymbolOpenOrdersBlocksLadderActions = false; let cancelNoOrdersFeedbackActive = false; let cancelNoOrdersFeedbackTimer = 0; - let tradingViewOrdersRecoveryPendingAtStartup = - sessionStorage.getItem(TRADINGVIEW_ORDERS_RECOVERY_STORAGE_KEY) !== null; - let tradingViewOrdersRecoveryTask = null; - let tradingViewOrdersRecoveryLastError = null; + let chartOrdersRecoveryPendingAtStartup = + sessionStorage.getItem(CHART_ORDERS_RECOVERY_STORAGE_KEY) !== null; + let chartOrdersRecoveryTask = null; + let chartOrdersRecoveryLastError = null; let ladderStopRequested = false; let ladderStatusText = '空闲'; let ladderStatusTitle = '空闲'; @@ -3644,112 +3642,226 @@ import { } } - function writeTradingViewOrdersRecoveryRecord() { + function getBinanceChartOrdersTarget() { + return getBinanceChartOrdersTargetDom(document); + } + + function writeChartOrdersRecoveryRecord() { sessionStorage.setItem( - TRADINGVIEW_ORDERS_RECOVERY_STORAGE_KEY, - createTradingViewOrdersRecoveryRecord(Date.now()), + CHART_ORDERS_RECOVERY_STORAGE_KEY, + createChartOrdersRecoveryRecord(Date.now()), ); } - function clearTradingViewOrdersRecoveryRecord() { - sessionStorage.removeItem(TRADINGVIEW_ORDERS_RECOVERY_STORAGE_KEY); + function clearChartOrdersRecoveryRecord() { + sessionStorage.removeItem(CHART_ORDERS_RECOVERY_STORAGE_KEY); } - function readTradingViewOrdersRecoveryRecord() { - return parseTradingViewOrdersRecoveryRecord( - sessionStorage.getItem(TRADINGVIEW_ORDERS_RECOVERY_STORAGE_KEY), + function readChartOrdersRecoveryRecord() { + return parseChartOrdersRecoveryRecord( + sessionStorage.getItem(CHART_ORDERS_RECOVERY_STORAGE_KEY), Date.now(), ); } - async function waitForTwoAnimationFrames() { - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + function dispatchChartOrdersPointerEvents(element, eventTypes, relatedTarget = null) { + for (const type of eventTypes) { + element.dispatchEvent(new MouseEvent(type, { + bubbles: type !== 'mouseenter' && type !== 'mouseleave', + cancelable: true, + composed: true, + relatedTarget, + view: window, + })); + } } - function getCurrentBinanceTradingViewApi() { - return getBinanceTradingViewTargetDom(document).tradingViewApi; + async function waitForBinanceChartOrdersPopover(target, expectedChecked = null) { + const deadline = Date.now() + CHART_ORDERS_MENU_TIMEOUT_MS; + while (Date.now() < deadline) { + const current = findActiveBinanceChartOrdersPopover( + document, + target, + isVisibleElement, + ); + if (current && (expectedChecked === null || current.checked === expectedChecked)) { + return current; + } + await delay(CHART_ORDERS_MENU_POLL_MS); + } + const current = findActiveBinanceChartOrdersPopover( + document, + target, + isVisibleElement, + ); + if (current && (expectedChecked === null || current.checked === expectedChecked)) { + return current; + } + throw new Error(expectedChecked === null + ? 'Binance chart OpenOrders popover did not open' + : `Binance chart OpenOrders state did not become ${expectedChecked}`); } - function assertTradingViewOrdersVisibility(state, expectedVisible) { - const api = getCurrentBinanceTradingViewApi(); - assertTradingViewOrdersTarget(api, state); - const visible = readTradingViewOrdersVisibility(api); - if (visible !== expectedVisible) { - throw new Error(`TradingView showOrders is ${visible}`); - } + async function openBinanceChartOrdersPopover(target) { + const currentTarget = getBinanceChartOrdersTarget(); + assertSameBinanceChartOrdersTarget(target, currentTarget); + dispatchChartOrdersPointerEvents( + currentTarget.trigger, + ['pointerover', 'mouseover', 'mouseenter'], + ); + return waitForBinanceChartOrdersPopover(currentTarget); } - async function hideTradingViewOrdersForBulkCancel(api, state) { - assertTradingViewOrdersTarget(api, state); - if (state.originalVisible) writeTradingViewOrdersRecoveryRecord(); - try { - hideTradingViewOrders(api, state); - } catch (error) { - // Keep the journal only after an override may have changed the chart. - if (!state.changed) clearTradingViewOrdersRecoveryRecord(); - throw error; + async function closeBinanceChartOrdersPopover(target) { + const currentTarget = getBinanceChartOrdersTarget(); + assertSameBinanceChartOrdersTarget(target, currentTarget); + const current = findActiveBinanceChartOrdersPopover( + document, + currentTarget, + isVisibleElement, + ); + if (!current) return; + + dispatchChartOrdersPointerEvents( + current.popover, + ['pointerout', 'mouseout', 'mouseleave'], + currentTarget.chartRoot, + ); + dispatchChartOrdersPointerEvents( + currentTarget.trigger, + ['pointerout', 'mouseout', 'mouseleave'], + currentTarget.chartRoot, + ); + dispatchChartOrdersPointerEvents( + currentTarget.chartRoot, + ['pointerover', 'mouseover', 'mouseenter'], + currentTarget.trigger, + ); + + const deadline = Date.now() + CHART_ORDERS_MENU_TIMEOUT_MS; + while (Date.now() < deadline) { + if (!findActiveBinanceChartOrdersPopover( + document, + currentTarget, + isVisibleElement, + )) return; + await delay(CHART_ORDERS_MENU_POLL_MS); + } + if (findActiveBinanceChartOrdersPopover( + document, + currentTarget, + isVisibleElement, + )) { + throw new Error('Binance chart OpenOrders popover did not close'); } - if (!state.changed) return; + } - await waitForTwoAnimationFrames(); - assertTradingViewOrdersVisibility(state, false); + async function toggleBinanceChartOrdersWithCoalescedSave( + target, + checkbox, + expectedChecked, + expectDrawingEvents, + ) { + if (typeof expectDrawingEvents !== 'boolean') { + throw new Error('Chart orders drawing-event expectation is invalid'); + } + const result = await coalesceTradingViewDrawingSaves( + target.tradingViewApi, + async () => { + checkbox.click(); + await waitForBinanceChartOrdersPopover(target, expectedChecked); + }, + expectDrawingEvents ? {} : { eventDiscoveryTimeoutMs: 0 }, + ); + log('图表当前委托保存已合并', { + drawingEvents: result.drawingEventCount, + saveRequests: result.saveRequestCount, + fullSaves: result.fullSaveCount, + }); } - async function restoreTradingViewOrdersAfterBulkCancel(state) { - const api = getCurrentBinanceTradingViewApi(); - assertTradingViewOrdersTarget(api, state); - restoreTradingViewOrders(api, state); - if (state.changed) { - await waitForTwoAnimationFrames(); - assertTradingViewOrdersVisibility(state, state.originalVisible); + async function hideBinanceChartOrdersForBulkCancel(target, state) { + const current = await openBinanceChartOrdersPopover(target); + state.originalChecked = current.checked; + if (current.checked) { + writeChartOrdersRecoveryRecord(); + state.changed = true; + await toggleBinanceChartOrdersWithCoalescedSave( + target, + current.checkbox, + false, + true, + ); } - clearTradingViewOrdersRecoveryRecord(); + await closeBinanceChartOrdersPopover(target); + } + + async function restoreBinanceChartOrdersAfterBulkCancel( + target, + state, + expectDrawingEvents, + ) { + if (typeof expectDrawingEvents !== 'boolean') { + throw new Error('Chart orders drawing-event expectation is invalid'); + } + assertSameBinanceChartOrdersTarget(target, getBinanceChartOrdersTarget()); + const current = await openBinanceChartOrdersPopover(target); + if (current.checked !== state.originalChecked) { + await toggleBinanceChartOrdersWithCoalescedSave( + target, + current.checkbox, + state.originalChecked, + expectDrawingEvents, + ); + } + await closeBinanceChartOrdersPopover(target); + clearChartOrdersRecoveryRecord(); } async function recoverChartOrdersStateAfterReload() { - const recovery = readTradingViewOrdersRecoveryRecord(); + const recovery = readChartOrdersRecoveryRecord(); if (recovery.status === 'missing') { - tradingViewOrdersRecoveryPendingAtStartup = false; + chartOrdersRecoveryPendingAtStartup = false; return { status: 'missing' }; } if (recovery.status === 'invalid') { emit('ERR', '图表当前委托恢复记录无效,未修改页面状态'); - clearTradingViewOrdersRecoveryRecord(); - tradingViewOrdersRecoveryPendingAtStartup = false; + clearChartOrdersRecoveryRecord(); + chartOrdersRecoveryPendingAtStartup = false; return { status: recovery.status }; } - const target = findBinanceTradingViewTargetDom(document); + const target = findBinanceChartOrdersTargetDom(document); if (!target) return { status: 'target_not_ready' }; - const state = captureTradingViewOrdersVisibility(target.tradingViewApi); - state.changed = state.originalVisible !== recovery.record.originalVisible; - state.originalVisible = recovery.record.originalVisible; - await restoreTradingViewOrdersAfterBulkCancel(state); - tradingViewOrdersRecoveryPendingAtStartup = false; - tradingViewOrdersRecoveryLastError = null; + await restoreBinanceChartOrdersAfterBulkCancel(target, { + originalChecked: recovery.record.originalChecked, + changed: true, + }, true); + chartOrdersRecoveryPendingAtStartup = false; + chartOrdersRecoveryLastError = null; log('已恢复刷新前的图表当前委托显示状态'); return { status: 'restored' }; } function scheduleChartOrdersRecovery() { if ( - !tradingViewOrdersRecoveryPendingAtStartup - || tradingViewOrdersRecoveryTask + !chartOrdersRecoveryPendingAtStartup + || chartOrdersRecoveryTask || cancelCurrentSymbolOpenOrdersTask || document.hidden || !isFuturesTradingPage() ) return; const task = recoverChartOrdersStateAfterReload(); - tradingViewOrdersRecoveryTask = task; + chartOrdersRecoveryTask = task; task.catch((error) => { const message = String(error?.message || error); - if (message !== tradingViewOrdersRecoveryLastError) { - tradingViewOrdersRecoveryLastError = message; + if (message !== chartOrdersRecoveryLastError) { + chartOrdersRecoveryLastError = message; emit('ERR', '恢复刷新前的图表当前委托显示失败', error); } }).finally(() => { - if (tradingViewOrdersRecoveryTask === task) tradingViewOrdersRecoveryTask = null; + if (chartOrdersRecoveryTask === task) chartOrdersRecoveryTask = null; }); } @@ -3775,8 +3887,10 @@ import { let previousOpenOrdersSubTabIdentity = null; let symbolFilterOriginalChecked = null; let restoreTemporaryUiState = true; - let tradingViewOrdersState = null; - let restoreTradingViewOrdersState = true; + let chartOrdersTarget = null; + const chartOrdersState = { originalChecked: null, changed: false }; + let restoreChartOrdersState = true; + let chartOrdersDefinitivelyCleared = false; let successStatusMessage = null; try { @@ -3846,9 +3960,8 @@ import { scheduleRenderPanel(); try { - const tradingViewApi = getCurrentBinanceTradingViewApi(); - tradingViewOrdersState = captureTradingViewOrdersVisibility(tradingViewApi); - await hideTradingViewOrdersForBulkCancel(tradingViewApi, tradingViewOrdersState); + chartOrdersTarget = getBinanceChartOrdersTarget(); + await hideBinanceChartOrdersForBulkCancel(chartOrdersTarget, chartOrdersState); } catch (e) { emit('ERR', '撤单前隐藏图表当前委托失败', e); const message = '未能确认图表当前委托已隐藏,未打开撤单确认框'; @@ -3900,7 +4013,7 @@ import { } if (dialogDecision.status === 'aborted') { restoreTemporaryUiState = false; - restoreTradingViewOrdersState = false; + restoreChartOrdersState = false; const interruptedBaseAsset = formatStatusBaseAsset(symbol); const message = `原交易对 ${interruptedBaseAsset} 页面已离开,撤单确认跟踪已停止`; setLadderStatus(message); @@ -3954,6 +4067,7 @@ import { setLadderStatus(message); return { ok: false, status: 'not_cleared', message }; } + chartOrdersDefinitivelyCleared = clearResult.definitivelyCleared === true; successStatusMessage = waitUntilCleared ? '当前币挂单已撤,继续重挂' : '撤单流程结束,已恢复筛选状态'; @@ -3981,12 +4095,18 @@ import { } } - let tradingViewOrdersRestoreSucceeded = true; - if (restoreTradingViewOrdersState && tradingViewOrdersState?.changed) { + let chartOrdersRestoreSucceeded = true; + if (restoreChartOrdersState && chartOrdersState.changed) { try { - await restoreTradingViewOrdersAfterBulkCancel(tradingViewOrdersState); + const chartOrdersStillDefinitivelyCleared = + chartOrdersDefinitivelyCleared && getOpenOrdersTabCount() === 0; + await restoreBinanceChartOrdersAfterBulkCancel( + chartOrdersTarget, + chartOrdersState, + !chartOrdersStillDefinitivelyCleared, + ); } catch (e) { - tradingViewOrdersRestoreSucceeded = false; + chartOrdersRestoreSucceeded = false; emit('ERR', '恢复图表当前委托显示失败', e); setLadderStatus('未能恢复图表当前委托显示'); } @@ -3995,7 +4115,7 @@ import { if ( restoreTemporaryUiState && isCurrentObservedSymbol(symbol) && - tradingViewOrdersRestoreSucceeded && + chartOrdersRestoreSucceeded && temporaryUiRestoreSucceeded && successStatusMessage ) { diff --git a/test/dom/binance-orderbook-trade/chart-orders.test.js b/test/dom/binance-orderbook-trade/chart-orders.test.js new file mode 100644 index 0000000..a068d1d --- /dev/null +++ b/test/dom/binance-orderbook-trade/chart-orders.test.js @@ -0,0 +1,198 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { isVisibleElement, loadFixtureDom } from '../../helpers/dom.js'; +import { + assertSameBinanceChartOrdersTarget, + findActiveBinanceChartOrdersPopover, + findBinanceChartOrdersTarget, + getBinanceChartOrdersTarget, +} from '../../../src/binance-orderbook-trade/dom/chart-orders.js'; + +function createChartMarkup({ + mode = 'tradingview', + popoverId = 'chart-orders-menu', + latestPriceLabel = '最新价格', +} = {}) { + const chartBody = mode === 'tradingview' + ? '
' + : '
'; + return ` +
+
+ intervals + shot + + orders + + + ${latestPriceLabel} + +
+ ${chartBody} +
+ `; +} + +function createPopoverMarkup({ + labels = ['快捷下单', '当前委托', '持有仓位', '历史委托'], + openOrdersChecked = 'true', + popoverId = 'chart-orders-menu', +} = {}) { + return ` + + `; +} + +function loadChartTarget(options = {}) { + const dom = loadFixtureDom(createChartMarkup(options)); + const tradingViewApi = { saveChart() {}, subscribe() {}, unsubscribe() {} }; + dom.window.document.querySelector('iframe').contentWindow.tradingViewApi = tradingViewApi; + return { dom, tradingViewApi }; +} + +test('locates the current chart Open Orders target in TradingView and Basic modes', () => { + for (const mode of ['tradingview', 'basic']) { + const { dom, tradingViewApi } = loadChartTarget({ mode }); + const target = getBinanceChartOrdersTarget(dom.window.document); + + assert.equal(target.chartRoot.className, 'chart-widget-root'); + assert.equal(target.toolbar.getAttribute('data-testid'), 'chart-toolbar'); + assert.equal(target.trigger.getAttribute('data-testid'), 'orders-trigger'); + assert.equal(target.popoverId, 'chart-orders-menu'); + assert.equal(target.tradingViewApi, tradingViewApi); + } +}); + +test('supports the English current-price label without broad text scanning', () => { + const { dom } = loadChartTarget({ latestPriceLabel: 'Last Price' }); + assert.equal( + getBinanceChartOrdersTarget(dom.window.document).trigger.getAttribute('data-testid'), + 'orders-trigger', + ); +}); + +test('find waits for a visible complete and unambiguous chart contract', () => { + const missingDom = loadFixtureDom('
'); + assert.equal(findBinanceChartOrdersTarget(missingDom.window.document), null); + + const hidden = loadChartTarget(); + hidden.dom.window.document.querySelector('.chart-widget-root').setAttribute('data-hidden', ''); + assert.equal(findBinanceChartOrdersTarget(hidden.dom.window.document), null); + + const incomplete = loadChartTarget(); + incomplete.dom.window.document.querySelector('[aria-describedby]').removeAttribute('aria-describedby'); + assert.throws( + () => getBinanceChartOrdersTarget(incomplete.dom.window.document), + /Expected one Binance chart orders popover reference, found 0/, + ); + + const first = loadChartTarget(); + const duplicateRoot = first.dom.window.document.createElement('div'); + duplicateRoot.innerHTML = createChartMarkup(); + first.dom.window.document.body.append(duplicateRoot.firstElementChild); + assert.throws( + () => getBinanceChartOrdersTarget(first.dom.window.document), + /Expected one visible Binance chart root, found 2/, + ); +}); + +test('finds the trigger-linked Chinese or English Open Orders checkbox', () => { + for (const label of ['当前委托', 'Open Orders']) { + const { dom } = loadChartTarget(); + dom.window.document.body.insertAdjacentHTML( + 'beforeend', + createPopoverMarkup({ labels: ['快捷下单', label, '持有仓位'], openOrdersChecked: 'false' }), + ); + const target = getBinanceChartOrdersTarget(dom.window.document); + const result = findActiveBinanceChartOrdersPopover( + dom.window.document, + target, + isVisibleElement, + ); + + assert.equal(result.checkbox.textContent.trim(), label); + assert.equal(result.checked, false); + } +}); + +test('ignores active lookalike popovers not linked to the chart trigger', () => { + const { dom } = loadChartTarget(); + dom.window.document.body.insertAdjacentHTML( + 'beforeend', + createPopoverMarkup({ popoverId: 'another-menu' }), + ); + const target = getBinanceChartOrdersTarget(dom.window.document); + + assert.equal( + findActiveBinanceChartOrdersPopover(dom.window.document, target, isVisibleElement), + null, + ); +}); + +test('invalid or duplicate Open Orders checkboxes fail explicitly', () => { + const invalid = loadChartTarget(); + invalid.dom.window.document.body.insertAdjacentHTML( + 'beforeend', + createPopoverMarkup({ openOrdersChecked: 'mixed' }), + ); + assert.throws( + () => findActiveBinanceChartOrdersPopover( + invalid.dom.window.document, + getBinanceChartOrdersTarget(invalid.dom.window.document), + isVisibleElement, + ), + /OpenOrders state is mixed/, + ); + + const duplicate = loadChartTarget(); + duplicate.dom.window.document.body.insertAdjacentHTML( + 'beforeend', + createPopoverMarkup({ labels: ['当前委托', 'Open Orders'] }), + ); + assert.throws( + () => findActiveBinanceChartOrdersPopover( + duplicate.dom.window.document, + getBinanceChartOrdersTarget(duplicate.dom.window.document), + isVisibleElement, + ), + /Expected one Binance chart OpenOrders checkbox, found 2/, + ); +}); + +test('rejects a replaced chart root toolbar trigger or runtime', () => { + const first = loadChartTarget(); + const second = loadChartTarget(); + const oldTarget = getBinanceChartOrdersTarget(first.dom.window.document); + const newTarget = getBinanceChartOrdersTarget(second.dom.window.document); + + assert.throws( + () => assertSameBinanceChartOrdersTarget(oldTarget, newTarget), + /Binance chart orders target changed/, + ); + assert.throws( + () => assertSameBinanceChartOrdersTarget(oldTarget, { + ...oldTarget, + toolbar: newTarget.toolbar, + }), + /Binance chart orders target changed/, + ); + assert.throws( + () => assertSameBinanceChartOrdersTarget(oldTarget, { + ...oldTarget, + trigger: newTarget.trigger, + }), + /Binance chart orders target changed/, + ); + assert.throws( + () => assertSameBinanceChartOrdersTarget(oldTarget, { + ...oldTarget, + tradingViewApi: newTarget.tradingViewApi, + }), + /Binance chart orders target changed/, + ); +}); diff --git a/test/unit/binance-orderbook-trade/chart-orders-recovery.test.js b/test/unit/binance-orderbook-trade/chart-orders-recovery.test.js index dce72df..845c74a 100644 --- a/test/unit/binance-orderbook-trade/chart-orders-recovery.test.js +++ b/test/unit/binance-orderbook-trade/chart-orders-recovery.test.js @@ -2,43 +2,43 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { - createTradingViewOrdersRecoveryRecord, - parseTradingViewOrdersRecoveryRecord, -} from '../../../src/binance-orderbook-trade/core/tradingview-orders-recovery.js'; + createChartOrdersRecoveryRecord, + parseChartOrdersRecoveryRecord, +} from '../../../src/binance-orderbook-trade/core/chart-orders-recovery.js'; -test('creates and parses an exact TradingView orders reload recovery record', () => { - const raw = createTradingViewOrdersRecoveryRecord(1_000); +test('creates and parses an exact chart orders reload recovery record', () => { + const raw = createChartOrdersRecoveryRecord(1_000); assert.deepEqual(JSON.parse(raw), { - version: 1, - originalVisible: true, + version: 2, + originalChecked: true, createdAtMs: 1_000, }); - assert.deepEqual(parseTradingViewOrdersRecoveryRecord(raw, 2_000), { + assert.deepEqual(parseChartOrdersRecoveryRecord(raw, 2_000), { status: 'valid', - record: { version: 1, originalVisible: true, createdAtMs: 1_000 }, + record: { version: 2, originalChecked: true, createdAtMs: 1_000 }, }); }); test('distinguishes missing, invalid, and future recovery records', () => { - assert.deepEqual(parseTradingViewOrdersRecoveryRecord(null, 2_000), { + assert.deepEqual(parseChartOrdersRecoveryRecord(null, 2_000), { status: 'missing', record: null, }); - assert.deepEqual(parseTradingViewOrdersRecoveryRecord('{', 2_000), { + assert.deepEqual(parseChartOrdersRecoveryRecord('{', 2_000), { status: 'invalid', record: null, }); - assert.deepEqual(parseTradingViewOrdersRecoveryRecord(JSON.stringify({ - version: 1, - originalVisible: false, + assert.deepEqual(parseChartOrdersRecoveryRecord(JSON.stringify({ + version: 2, + originalChecked: false, createdAtMs: 1_000, }), 2_000), { status: 'invalid', record: null, }); - assert.deepEqual(parseTradingViewOrdersRecoveryRecord(JSON.stringify({ - version: 1, - originalVisible: true, + assert.deepEqual(parseChartOrdersRecoveryRecord(JSON.stringify({ + version: 2, + originalChecked: true, createdAtMs: 3_000, }), 2_000), { status: 'invalid', @@ -47,8 +47,8 @@ test('distinguishes missing, invalid, and future recovery records', () => { }); test('valid recovery records do not expire before the hidden chart state is restored', () => { - const oldRecord = { version: 1, originalVisible: true, createdAtMs: 1_000 }; - assert.deepEqual(parseTradingViewOrdersRecoveryRecord( + const oldRecord = { version: 2, originalChecked: true, createdAtMs: 1_000 }; + assert.deepEqual(parseChartOrdersRecoveryRecord( JSON.stringify(oldRecord), 1_000 + (365 * 24 * 60 * 60 * 1000), ), { @@ -58,14 +58,14 @@ test('valid recovery records do not expire before the hidden chart state is rest }); test('recovery records reject extra fields and invalid timestamps', () => { - assert.throws(() => createTradingViewOrdersRecoveryRecord(Number.NaN), /timestamp is invalid/); + assert.throws(() => createChartOrdersRecoveryRecord(Number.NaN), /timestamp is invalid/); assert.throws( - () => parseTradingViewOrdersRecoveryRecord('{}', Number.NaN), + () => parseChartOrdersRecoveryRecord('{}', Number.NaN), /current time is invalid/, ); - assert.deepEqual(parseTradingViewOrdersRecoveryRecord(JSON.stringify({ - version: 1, - originalVisible: true, + assert.deepEqual(parseChartOrdersRecoveryRecord(JSON.stringify({ + version: 2, + originalChecked: true, createdAtMs: 1_000, symbol: 'HYPEUSDT', }), 2_000), { diff --git a/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js b/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js new file mode 100644 index 0000000..5bec45d --- /dev/null +++ b/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js @@ -0,0 +1,241 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { coalesceTradingViewDrawingSaves } from '../../../src/binance-orderbook-trade/core/chart-save-coalescer.js'; + +function createTradingViewApi() { + const listeners = new Map(); + const saved = []; + const api = { + saveChart(...args) { + saved.push({ thisValue: this, args }); + }, + subscribe(name, callback) { + const callbacks = listeners.get(name) || new Set(); + callbacks.add(callback); + listeners.set(name, callbacks); + }, + unsubscribe(name, callback) { + listeners.get(name)?.delete(callback); + }, + emit(name, ...args) { + for (const callback of listeners.get(name) || []) callback(...args); + }, + }; + return { api, saved, listeners }; +} + +for (const drawingCount of [1, 5, 70, 120, 199, 200]) { + test(`coalesces ${drawingCount} drawing saves into one final full save`, async () => { + const { api, saved, listeners } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + + const result = await coalesceTradingViewDrawingSaves( + api, + () => { + for (let index = 0; index < drawingCount; index += 1) { + api.emit('drawing_event', `order-${index}`, 'remove'); + } + setTimeout(() => { + for (let index = 0; index < drawingCount; index += 1) { + api.saveChart(`snapshot-${index}`); + } + }, 0); + return 'hidden'; + }, + { settleQuietMs: 1, timeoutMs: 100 }, + ); + + assert.deepEqual(result, { + actionResult: 'hidden', + drawingEventCount: drawingCount, + saveRequestCount: drawingCount, + fullSaveCount: 1, + }); + assert.equal(saved.length, 1); + assert.equal(saved[0].thisValue, api); + assert.deepEqual(saved[0].args, [`snapshot-${drawingCount - 1}`]); + assert.equal(api.saveChart, originalSaveChart); + assert.equal(listeners.get('drawing_event')?.size, 0); + }); +} + +test('persists the cumulative final snapshot', async () => { + const { api, saved } = createTradingViewApi(); + const snapshots = [ + { drawings: ['order-1'] }, + { drawings: ['order-1', 'order-2'] }, + { drawings: ['order-1', 'order-2', 'order-3'] }, + ]; + + await coalesceTradingViewDrawingSaves(api, () => { + for (const drawingId of snapshots.at(-1).drawings) { + api.emit('drawing_event', drawingId, 'properties_changed'); + } + setTimeout(() => { + for (const snapshot of snapshots) api.saveChart(snapshot); + }, 0); + }, { settleQuietMs: 1, timeoutMs: 100 }); + + assert.deepEqual(saved.map((entry) => entry.args[0]), [snapshots.at(-1)]); +}); + +test('waits for drawing events that arrive after the checkbox state changes', async () => { + const { api, saved } = createTradingViewApi(); + + const result = await coalesceTradingViewDrawingSaves( + api, + () => { + setTimeout(() => { + api.emit('drawing_event', 'order-1', 'remove'); + api.emit('drawing_event', 'order-2', 'remove'); + setTimeout(() => { + api.saveChart('snapshot-1'); + api.saveChart('snapshot-2'); + }, 5); + }, 10); + return 'checkbox-changed'; + }, + { eventDiscoveryTimeoutMs: 50, settleQuietMs: 1, timeoutMs: 100 }, + ); + + assert.deepEqual(result, { + actionResult: 'checkbox-changed', + drawingEventCount: 2, + saveRequestCount: 2, + fullSaveCount: 1, + }); + assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-2']]); +}); + +test('waits for interleaved drawing saves split across macrotasks', async () => { + const { api, saved } = createTradingViewApi(); + + const result = await coalesceTradingViewDrawingSaves( + api, + () => { + for (let index = 0; index < 3; index += 1) { + setTimeout(() => { + api.emit('drawing_event', `order-${index}`, 'remove'); + api.saveChart(`snapshot-${index}`); + }, index * 5); + } + return 'checkbox-changed'; + }, + { + eventDiscoveryTimeoutMs: 20, + settleQuietMs: 10, + timeoutMs: 100, + }, + ); + + assert.deepEqual(result, { + actionResult: 'checkbox-changed', + drawingEventCount: 3, + saveRequestCount: 3, + fullSaveCount: 1, + }); + assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-2']]); +}); + +test('ignores drawing events that do not schedule chart saves', async () => { + const { api, saved } = createTradingViewApi(); + + const result = await coalesceTradingViewDrawingSaves( + api, + () => { + api.emit('drawing_event', 'order-1', 'click'); + api.emit('drawing_event', 'order-1', 'move'); + return 'unchanged'; + }, + { eventDiscoveryTimeoutMs: 5 }, + ); + + assert.deepEqual(result, { + actionResult: 'unchanged', + drawingEventCount: 0, + saveRequestCount: 0, + fullSaveCount: 0, + }); + assert.deepEqual(saved, []); +}); + +test('skips discovery when the caller proves that no drawings can exist', async () => { + const { api, saved } = createTradingViewApi(); + let timerCalls = 0; + + const result = await coalesceTradingViewDrawingSaves( + api, + () => 'definitively-empty', + { + eventDiscoveryTimeoutMs: 0, + setTimeoutFn() { + timerCalls += 1; + throw new Error('drawing discovery timer must not start'); + }, + }, + ); + + assert.deepEqual(result, { + actionResult: 'definitively-empty', + drawingEventCount: 0, + saveRequestCount: 0, + fullSaveCount: 0, + }); + assert.equal(timerCalls, 0); + assert.deepEqual(saved, []); +}); + +test('restores saveChart and its subscription after action failure', async () => { + const { api, saved, listeners } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + + await assert.rejects( + coalesceTradingViewDrawingSaves(api, () => { + api.emit('drawing_event', 'order-1', 'remove'); + throw new Error('chart toggle failed'); + }), + /chart toggle failed/, + ); + + assert.equal(api.saveChart, originalSaveChart); + assert.equal(listeners.get('drawing_event')?.size, 0); + api.saveChart('after-error'); + assert.deepEqual(saved.map((entry) => entry.args), [['after-error']]); +}); + +test('times out when drawing events do not produce matching save requests', async () => { + const { api, saved } = createTradingViewApi(); + const originalSaveChart = api.saveChart; + + await assert.rejects( + coalesceTradingViewDrawingSaves( + api, + () => api.emit('drawing_event', 'order-1', 'remove'), + { timeoutMs: 10 }, + ), + /Expected 1 TradingView saveChart requests, received 0/, + ); + + assert.equal(api.saveChart, originalSaveChart); + api.saveChart('after-timeout'); + assert.deepEqual(saved.map((entry) => entry.args), [['after-timeout']]); +}); + +test('restores the original method when the final full save throws', async () => { + const { api } = createTradingViewApi(); + const originalSaveChart = function saveChart() { + throw new Error('save failed'); + }; + api.saveChart = originalSaveChart; + + await assert.rejects( + coalesceTradingViewDrawingSaves(api, () => { + api.emit('drawing_event', 'order-1', 'properties_changed'); + setTimeout(() => api.saveChart('snapshot'), 0); + }, { settleQuietMs: 1, timeoutMs: 100 }), + /save failed/, + ); + + assert.equal(api.saveChart, originalSaveChart); +}); diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 838bcaf..2c4e479 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -5,7 +5,7 @@ import assert from 'node:assert/strict'; const source = await readFile(new URL('../../../src/binance-orderbook-trade/index.user.js', import.meta.url), 'utf8'); const generatedSource = await readFile(new URL('../../../scripts/binance-orderbook-trade.user.js', import.meta.url), 'utf8'); const ladderPlanSource = await readFile(new URL('../../../src/binance-orderbook-trade/core/ladder-plan.js', import.meta.url), 'utf8'); -const tradingViewOrdersSource = await readFile(new URL('../../../src/binance-orderbook-trade/core/tradingview-orders.js', import.meta.url), 'utf8'); +const chartSaveCoalescerSource = await readFile(new URL('../../../src/binance-orderbook-trade/core/chart-save-coalescer.js', import.meta.url), 'utf8'); function readFunctionBody(name, sourceText = source) { const start = sourceText.indexOf(`function ${name}(`); @@ -824,36 +824,40 @@ test('panel statuses omit the current full symbol and compact the retained inter assert.doesNotMatch(source, /点击时 \$\{qtyPlan\.symbol\}/); }); -test('bulk cancel settles hidden TradingView orders before opening the native dialog', () => { - assert.match(tradingViewOrdersSource, /applyOverrides/); - assert.match(tradingViewOrdersSource, /tradingProperties\.showOrders/); - assert.doesNotMatch(source, /coalesceTradingViewDrawingSaves/); - - const hideBody = readFunctionBody('hideTradingViewOrdersForBulkCancel'); - assert.match(hideBody, /assertTradingViewOrdersTarget\(api, state\)[\s\S]*state\.originalVisible[\s\S]*writeTradingViewOrdersRecoveryRecord\(\)/); - assert.match(hideBody, /hideTradingViewOrders\(api, state\)/); - assert.match(hideBody, /catch \(error\)[\s\S]*if \(!state\.changed\) clearTradingViewOrdersRecoveryRecord\(\)[\s\S]*throw error/); - assert.match(hideBody, /waitForTwoAnimationFrames\(\)/); - assert.match(hideBody, /assertTradingViewOrdersVisibility\(state, false\)/); - assert.doesNotMatch(hideBody, /checkbox|popover|saveChart/); - - const restoreBody = readFunctionBody('restoreTradingViewOrdersAfterBulkCancel'); - assert.match(restoreBody, /const api = getCurrentBinanceTradingViewApi\(\)/); - assert.match(restoreBody, /assertTradingViewOrdersTarget\(api, state\)/); - assert.match(restoreBody, /restoreTradingViewOrders\(api, state\)/); - assert.match(restoreBody, /assertTradingViewOrdersVisibility\(state, state\.originalVisible\)/); - assert.match(restoreBody, /clearTradingViewOrdersRecoveryRecord\(\)/); - assert.doesNotMatch(restoreBody, /checkbox|popover|saveChart/); +test('bulk cancel removes real chart order drawings with one coalesced save before opening the native dialog', () => { + assert.match(chartSaveCoalescerSource, /api\.subscribe\('drawing_event'/); + assert.match(chartSaveCoalescerSource, /function coalescedSaveChart/); + assert.match(chartSaveCoalescerSource, /originalSaveChart\.apply\(pendingSave\.thisValue, pendingSave\.args\)/); + assert.match(source, /coalesceTradingViewDrawingSaves/); + assert.doesNotMatch(source, /tradingProperties\.showOrders/); + + const toggleBody = readFunctionBody('toggleBinanceChartOrdersWithCoalescedSave'); + assert.match(toggleBody, /coalesceTradingViewDrawingSaves/); + assert.match(toggleBody, /checkbox\.click\(\)/); + assert.match(toggleBody, /waitForBinanceChartOrdersPopover\(target, expectedChecked\)/); + assert.match(toggleBody, /expectDrawingEvents \? \{\} : \{ eventDiscoveryTimeoutMs: 0 \}/); + + const hideBody = readFunctionBody('hideBinanceChartOrdersForBulkCancel'); + assert.match(hideBody, /state\.originalChecked = current\.checked/); + assert.match(hideBody, /writeChartOrdersRecoveryRecord\(\)/); + assert.match(hideBody, /state\.changed = true/); + assert.match(hideBody, /toggleBinanceChartOrdersWithCoalescedSave\([\s\S]*false,[\s\S]*true/); + assert.match(hideBody, /closeBinanceChartOrdersPopover\(target\)/); + + const restoreBody = readFunctionBody('restoreBinanceChartOrdersAfterBulkCancel'); + assert.match(restoreBody, /assertSameBinanceChartOrdersTarget\(target, getBinanceChartOrdersTarget\(\)\)/); + assert.match(restoreBody, /current\.checked !== state\.originalChecked/); + assert.match(restoreBody, /toggleBinanceChartOrdersWithCoalescedSave/); + assert.match(restoreBody, /clearChartOrdersRecoveryRecord\(\)/); const cancelBody = readFunctionBody('runCancelCurrentSymbolOpenOrders'); - const apiIndex = cancelBody.indexOf('const tradingViewApi = getCurrentBinanceTradingViewApi()'); - const visibilityCaptureIndex = cancelBody.indexOf('tradingViewOrdersState = captureTradingViewOrdersVisibility('); + const targetIndex = cancelBody.indexOf('chartOrdersTarget = getBinanceChartOrdersTarget()'); const hideIndex = cancelBody.indexOf( - 'await hideTradingViewOrdersForBulkCancel(tradingViewApi, tradingViewOrdersState)' + 'await hideBinanceChartOrdersForBulkCancel(chartOrdersTarget, chartOrdersState)' ); const destructiveClickIndex = cancelBody.indexOf('cancelAllButton.click()'); const watcherIndex = cancelBody.indexOf('createBinanceCancelAllDialogDecisionWatcher()'); - const chartRestoreIndex = cancelBody.indexOf('await restoreTradingViewOrdersAfterBulkCancel('); + const chartRestoreIndex = cancelBody.indexOf('await restoreBinanceChartOrdersAfterBulkCancel('); const symbolGuardedRestoreIndex = cancelBody.indexOf('if (restoreTemporaryUiState && isCurrentObservedSymbol(symbol))'); const postHideBody = cancelBody.slice(hideIndex); const freshScopeIndex = postHideBody.indexOf('openOrdersScope = await waitForActiveOpenOrdersScope()'); @@ -863,9 +867,9 @@ test('bulk cancel settles hidden TradingView orders before opening the native di ); const postHideClickIndex = postHideBody.indexOf('cancelAllButton.click()'); - assert.ok(apiIndex !== -1 && visibilityCaptureIndex !== -1 && hideIndex !== -1); + assert.ok(targetIndex !== -1 && hideIndex !== -1); assert.ok(watcherIndex !== -1 && destructiveClickIndex !== -1); - assert.ok(apiIndex < visibilityCaptureIndex && visibilityCaptureIndex < hideIndex); + assert.ok(targetIndex < hideIndex); assert.ok(hideIndex < destructiveClickIndex); assert.ok(watcherIndex < destructiveClickIndex, 'dialog decision watcher must exist before destructive click'); assert.ok(freshScopeIndex !== -1 && freshFilterIndex !== -1 && freshButtonIndex !== -1); @@ -877,14 +881,14 @@ test('bulk cancel settles hidden TradingView orders before opening the native di assert.match(cancelBody, /dialogDecision\.status === 'cancelled'[\s\S]*status: 'cancelled'/); assert.match(cancelBody, /dialogDecision\.status === 'cancelled'[\s\S]*return \{ ok: false, status: 'cancelled'[\s\S]*waitForCurrentSymbolOpenOrdersCleared/); assert.match(cancelBody, /dialogDecisionWatcher\.dispose\(\)/); - assert.match(cancelBody, /restoreTradingViewOrdersState = false[\s\S]*status: 'aborted'/); + assert.match(cancelBody, /restoreChartOrdersState = false[\s\S]*status: 'aborted'/); assert.ok(chartRestoreIndex !== -1 && symbolGuardedRestoreIndex !== -1); assert.ok( symbolGuardedRestoreIndex < chartRestoreIndex, 'temporary account UI must recover before chart-order visibility' ); - assert.match(cancelBody, /if \(restoreTemporaryUiState && isCurrentObservedSymbol\(symbol\)\)[\s\S]*await restoreAccountOrdersTab\([\s\S]*let tradingViewOrdersRestoreSucceeded/); - assert.match(cancelBody, /let tradingViewOrdersRestoreSucceeded[\s\S]*await restoreTradingViewOrdersAfterBulkCancel\(/); + assert.match(cancelBody, /if \(restoreTemporaryUiState && isCurrentObservedSymbol\(symbol\)\)[\s\S]*await restoreAccountOrdersTab\([\s\S]*let chartOrdersRestoreSucceeded/); + assert.match(cancelBody, /let chartOrdersRestoreSucceeded[\s\S]*await restoreBinanceChartOrdersAfterBulkCancel\(/); }); test('bulk cancel distinguishes native confirm from cancellation before clear polling', () => { @@ -917,35 +921,35 @@ test('bulk cancel distinguishes native confirm from cancellation before clear po /catch \(error\) \{\s*restoreTemporaryUiState = false;([\s\S]*?)status: 'dialog_contract_invalid'/, )?.[1] || ''; assert.notEqual(invalidDialogBranch, ''); - assert.doesNotMatch(invalidDialogBranch, /restoreTradingViewOrdersState = false/); + assert.doesNotMatch(invalidDialogBranch, /restoreChartOrdersState = false/); assert.doesNotMatch(invalidDialogBranch, /图表当前委托保持隐藏/); }); -test('TradingView showOrders reload recovery remains pending until restoration succeeds', () => { - assert.match(source, /tradingViewOrdersRecoveryPendingAtStartup =\s*sessionStorage\.getItem\(TRADINGVIEW_ORDERS_RECOVERY_STORAGE_KEY\) !== null/); +test('chart Open Orders reload recovery remains pending until restoration succeeds', () => { + assert.match(source, /chartOrdersRecoveryPendingAtStartup =\s*sessionStorage\.getItem\(CHART_ORDERS_RECOVERY_STORAGE_KEY\) !== null/); - const hideBody = readFunctionBody('hideTradingViewOrdersForBulkCancel'); - const validateIndex = hideBody.indexOf('assertTradingViewOrdersTarget(api, state)'); - const writeIndex = hideBody.indexOf('writeTradingViewOrdersRecoveryRecord()'); - const hideIndex = hideBody.indexOf('hideTradingViewOrders('); + const hideBody = readFunctionBody('hideBinanceChartOrdersForBulkCancel'); + const stateIndex = hideBody.indexOf('state.originalChecked = current.checked'); + const writeIndex = hideBody.indexOf('writeChartOrdersRecoveryRecord()'); + const toggleIndex = hideBody.indexOf('toggleBinanceChartOrdersWithCoalescedSave('); assert.ok( - validateIndex !== -1 + stateIndex !== -1 && writeIndex !== -1 - && hideIndex !== -1 - && validateIndex < writeIndex - && writeIndex < hideIndex, + && toggleIndex !== -1 + && stateIndex < writeIndex + && writeIndex < toggleIndex, ); const recoverBody = readFunctionBody('recoverChartOrdersStateAfterReload'); assert.match(recoverBody, /recovery\.status === 'invalid'/); assert.doesNotMatch(recoverBody, /expired/); - assert.match(recoverBody, /clearTradingViewOrdersRecoveryRecord\(\)/); - assert.match(recoverBody, /findBinanceTradingViewTargetDom\(document\)/); - assert.match(recoverBody, /state\.originalVisible = recovery\.record\.originalVisible/); - assert.match(recoverBody, /restoreTradingViewOrdersAfterBulkCancel\(state\)/); + assert.match(recoverBody, /clearChartOrdersRecoveryRecord\(\)/); + assert.match(recoverBody, /findBinanceChartOrdersTargetDom\(document\)/); + assert.match(recoverBody, /originalChecked: recovery\.record\.originalChecked/); + assert.match(recoverBody, /restoreBinanceChartOrdersAfterBulkCancel\(target/); const scheduleBody = readFunctionBody('scheduleChartOrdersRecovery'); - assert.match(scheduleBody, /!tradingViewOrdersRecoveryPendingAtStartup/); + assert.match(scheduleBody, /!chartOrdersRecoveryPendingAtStartup/); assert.match(scheduleBody, /cancelCurrentSymbolOpenOrdersTask/); assert.match(scheduleBody, /document\.hidden/); assert.match(scheduleBody, /recoverChartOrdersStateAfterReload\(\)/); @@ -994,7 +998,9 @@ test('cancel current-symbol open orders wait for confirmed clearing before resto assert.ok(clearWaitIndex < successIndex, 'clearing must be confirmed before success'); assert.ok(successIndex < cleanupIndex && cleanupIndex < restoreIndex, 'page state restores only after the clear result'); - assert.doesNotMatch(cancelBody, /chartOrdersDefinitivelyCleared|expectDrawingEvents/); + assert.match(cancelBody, /chartOrdersDefinitivelyCleared = clearResult\.definitivelyCleared === true/); + assert.match(cancelBody, /chartOrdersDefinitivelyCleared && getOpenOrdersTabCount\(\) === 0/); + assert.match(cancelBody, /!chartOrdersStillDefinitivelyCleared/); }); test('cancel current-symbol open orders are single-flight and follow the native dialog lifecycle', () => { @@ -1025,7 +1031,7 @@ test('cancel current-symbol open orders are single-flight and follow the native const cancelBody = readFunctionBody('runCancelCurrentSymbolOpenOrders'); assert.match(cancelBody, /restoreTemporaryUiState = false/); - assert.match(cancelBody, /restoreTradingViewOrdersState = false/); + assert.match(cancelBody, /restoreChartOrdersState = false/); assert.match(cancelBody, /dialogDecision\.status === 'aborted'[\s\S]*status: 'aborted'/); assert.doesNotMatch(cancelBody, /dialogDecision\.status === 'dialog_not_closed'/); assert.match(cancelBody, /finally\s*\{[\s\S]*if \(restoreTemporaryUiState && isCurrentObservedSymbol\(symbol\)\)/); diff --git a/test/unit/binance-orderbook-trade/tradingview-orders.test.js b/test/unit/binance-orderbook-trade/tradingview-orders.test.js deleted file mode 100644 index 90c254e..0000000 --- a/test/unit/binance-orderbook-trade/tradingview-orders.test.js +++ /dev/null @@ -1,124 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { - applyTradingViewOrdersVisibility, - assertTradingViewOrdersTarget, - captureTradingViewOrdersVisibility, - hideTradingViewOrders, - readTradingViewOrdersVisibility, - restoreTradingViewOrders, -} from '../../../src/binance-orderbook-trade/core/tradingview-orders.js'; - -function createTradingViewApi(initialVisible, { apply = true } = {}) { - let visible = initialVisible; - const overrides = []; - const chart = { - properties() { - return { - tradingProperties: { - showOrders: { - value: () => visible, - }, - }, - }; - }, - applyOverrides(nextOverrides) { - overrides.push(nextOverrides); - if (apply) visible = nextOverrides['tradingProperties.showOrders']; - }, - }; - - return { - api: { activeChart: () => chart }, - chart, - overrides, - visible: () => visible, - }; -} - -test('visible chart orders are hidden and restored to their exact original state', () => { - const tradingView = createTradingViewApi(true); - const state = captureTradingViewOrdersVisibility(tradingView.api); - - hideTradingViewOrders(tradingView.api, state); - assert.equal(readTradingViewOrdersVisibility(tradingView.api), false); - restoreTradingViewOrders(tradingView.api, state); - - assert.equal(state.chart, tradingView.chart); - assert.equal(state.originalVisible, true); - assert.equal(state.changed, true); - assert.deepEqual(tradingView.overrides, [ - { 'tradingProperties.showOrders': false }, - { 'tradingProperties.showOrders': true }, - ]); - assert.equal(tradingView.visible(), true); -}); - -test('an already-hidden chart is left unchanged', () => { - const tradingView = createTradingViewApi(false); - const state = captureTradingViewOrdersVisibility(tradingView.api); - - hideTradingViewOrders(tradingView.api, state); - restoreTradingViewOrders(tradingView.api, state); - - assert.equal(state.originalVisible, false); - assert.equal(state.changed, false); - assert.deepEqual(tradingView.overrides, []); - assert.equal(tradingView.visible(), false); -}); - -test('a rejected hide remains marked for cleanup', () => { - const tradingView = createTradingViewApi(true, { apply: false }); - const state = captureTradingViewOrdersVisibility(tradingView.api); - - assert.throws( - () => hideTradingViewOrders(tradingView.api, state), - /TradingView showOrders remained true/, - ); - assert.equal(state.changed, true); -}); - -test('invalid TradingView contracts and targets fail explicitly', () => { - assert.throws(() => captureTradingViewOrdersVisibility(null), /API is unavailable/); - assert.throws( - () => captureTradingViewOrdersVisibility({ activeChart: () => ({}) }), - /active chart API is unavailable/, - ); - assert.throws( - () => captureTradingViewOrdersVisibility(createTradingViewApi('true').api), - /property is not boolean/, - ); - assert.throws( - () => applyTradingViewOrdersVisibility(createTradingViewApi(true).api, 'false'), - /target is not boolean/, - ); -}); - -test('a replaced active chart is rejected without writing the replacement', () => { - const oldTradingView = createTradingViewApi(true); - const newTradingView = createTradingViewApi(false); - const state = captureTradingViewOrdersVisibility(oldTradingView.api); - - oldTradingView.api.activeChart = () => newTradingView.chart; - - assert.throws( - () => hideTradingViewOrders(oldTradingView.api, state), - /active chart changed/, - ); - assert.deepEqual(newTradingView.overrides, []); - assert.equal(newTradingView.visible(), false); -}); - -test('target validation follows the active chart instead of surrounding toolbar DOM', () => { - const tradingView = createTradingViewApi(true); - const state = captureTradingViewOrdersVisibility(tradingView.api); - - assert.doesNotThrow(() => assertTradingViewOrdersTarget(tradingView.api, state)); - - const replacement = createTradingViewApi(true); - assert.throws( - () => assertTradingViewOrdersTarget(replacement.api, state), - /active chart changed/, - ); -});