diff --git a/docs/userscript-validation.md b/docs/userscript-validation.md index ab982c9..d2f16b7 100644 --- a/docs/userscript-validation.md +++ b/docs/userscript-validation.md @@ -70,8 +70,10 @@ read the Node version from `.nvmrc`. scope, and available-balance parsing. Canonical gateway symbols retain the separate exact server contract in `src/shared/canonical-symbol.js`. - Async work must carry the symbol, route, and lifecycle identity that started - it. A route change, symbol change, hidden/closed panel, or page teardown - invalidates work before its result can render or update shared state. + it. A route change, symbol change, panel close, or page teardown invalidates + work before its result can render or update shared state. Follow each script's + visibility contract: the two data panels below retain an activated session + while hidden; visibility alone does not invalidate its requests. - Timer, observer, drag, and unload listeners are part of the lifecycle. Stop business work when its route or panel is inactive, remove listeners when the panel is removed, and keep only the route watcher needed to discover a future @@ -124,14 +126,35 @@ prices retain enough significant digits to distinguish nonzero values from zero. ## Binance Trading Data Panel The panel runs only on an actual Binance futures trading route. It derives the -symbol from the shared route parser, aligns five-minute data to Binance server -time, and keeps the business loop stopped while the document is hidden, the -panel is closed, or the route is not a trading page. A route watcher may remain -alive while the panel is paused so a later SPA transition can restart the -business loop. +symbol from the shared route parser and aligns five-minute data to Binance server +time. An initially hidden document waits for its first visible activation. Once +activated, hiding the tab retains the same route session, in-flight requests, +data, and historical schedule. Closing the panel or leaving a trading route stops +the business loop. The route watcher remains active off-route and while hidden +so a later SPA transition can start a new session; old-symbol and old-path +responses cannot publish into that session. + +Current funding refreshes 15 seconds after request completion in the foreground +and 60 seconds after completion in the background. Successful and failed requests +both establish this deadline; the displayed receipt timestamp changes only on +success. Visibility changes recalculate the deadline from that same completion +time, never restart initialization or overlap an existing request. Returning to +an overdue quote schedules one refresh while retaining its existing value and +provenance. An active session includes calibration and initial requests that are +still pending. + +Historical data retains its five-minute boundaries, five-second first delay, +10/15/20/30-second retry schedule, and hourly server calibration while hidden. +A delayed timer starts only the latest publication window. Initial and periodic +responses that arrive after their own five-minute window expires are discarded +before updating values, votes, or the receipt timestamp; the current window is +then scheduled once. Browser timer throttling can delay these requests, and a +frozen or discarded page cannot execute JavaScript until resumed or reloaded. The one-second age display retains its footer elements and updates only changed -text. It does not recreate the timestamp row or alter the data-fetch schedule. +text. It pauses while hidden and updates immediately on return, including removal +of a current funding quote whose settlement has expired. It does not recreate +the timestamp row or alter the historical data-fetch schedule. Each period fetch records which endpoint produced fresh data and which endpoint used a cached value or has no value. Fresh and cached indicators remain distinct @@ -181,14 +204,14 @@ not apply another multiplier to 1000-token contracts. The single funding row combines the last settled rate and its historical chart with the separate current rate, interval, and countdown. Only the settled value -participates in the existing simplified funding vote. Current funding refreshes -15 seconds after each request completes, with a 10-second request deadline. +participates in the existing simplified funding vote. Current funding follows +the visibility cadence above, with a 10-second request deadline. Interval metadata refreshes independently at activation and each full historical cycle, also with a 10-second deadline. It must never block a historical refresh. An absent metadata row means an unknown interval, not an assumed eight hours. Requests and responses retain session, path, and symbol ownership. Changing -routes, hiding, or closing aborts the current-rate, interval, and clock requests; +routes or closing aborts the current-rate, interval, and clock requests; superseded responses cannot publish. A failed current refresh can retain an actual previous quote with a visible cache/error label. A first failure without data is labelled unavailable rather than cached. Metadata cache status is independent. @@ -230,10 +253,22 @@ and timestamp, and a failed refresh must not overwrite a newer symbol's panel. The existing `1000` and `1000000` multiplier mapping recognizes Unicode letters as the start of an asset name; numeric-only names such as `4` retain their digits. -Route changes, hidden documents, panel close, and panel removal invalidate the -refresh epoch and clean up timers and DOM listeners. A route watcher may remain -to detect a later matching page, but it must not continue the business refresh -loop while paused. +Route changes, panel close, and panel removal invalidate the refresh epoch and +clean up business timers. Responses belong to the complete pathname as well as +the symbol, so even a same-symbol language change starts a new session. A route +watcher remains to detect later matching pages, including transitions while +hidden; it does not run business requests off-route or after close. + +An initially hidden document waits until first visited. An activated panel keeps +its data, expanded interpretation, pending requests, and session when hidden. +Automatic refresh uses one timeout: 30 seconds after the last completed attempt +in the foreground and five minutes in the background. Failed attempts also set +the deadline without claiming a successful fetch. Returning +before that deadline makes no request; returning after the foreground deadline +silently refreshes once without replacing the table with Loading. Visibility +cannot duplicate an in-flight refresh or reset its timing anchor. A manual +Refresh keeps its explicit superseding-request behavior. Browser throttling or +freezing can delay the schedule; missed intervals are not replayed. The CMC table has metric, value, and interpretation columns, with expandable definitions. A short name and separate qualifier identify each ratio's numerator, @@ -260,8 +295,8 @@ Interpretations describe what the value supports. The volume ratio's 50% thresho is explicitly a descriptive convention, not a statistical anomaly or trading signal. Valuation comparisons need peers, volume changes need history, DEX liquidity is not Binance order-book depth, and profile disclosure scores do not -establish project safety. The existing 30-second refresh, mapping rules, source -timestamps, and API/page-snapshot provenance remain separate from presentation. +establish project safety. The refresh schedule, mapping rules, source timestamps, +and API/page-snapshot provenance remain separate from presentation. For a browser-openable offline implementation preview, run `node e2e/binance-orderbook/helpers/data-panels-preview.mjs` after the two builds @@ -270,6 +305,24 @@ embeds unchanged generated artifacts and labelled example data. It supports both languages, light/dark themes, and each panel separately, without external network requests. It is not live Binance or Tampermonkey evidence. +### Background lifecycle validation (2026-10-09) + +Trading-data `1.2.2` and CMC-data `0.2.2` passed the full 2548-test Node suite, +including 23 focused background-lifecycle scenarios, and all 41 generated-panel +browser scenarios. Both builds, generated syntax and release metadata checks, +test lint, and `git diff --check` passed. The browser checks cover simulated +visibility and suspended animation frames; the rendered English panels were +inspected with both historical and CMC interpretation details kept open. + +The controlled host verifies that ten quick returns preserve the same rows and +initial request batch, without postponing the original foreground deadline. +Other checks cover background cadence, request completion while hidden, failure +cooldowns, late initial/cycle responses, full-path ownership when the host +replaces its history methods, clock continuity, +and settlement expiry on return. These are deterministic request and UI checks, +not CPU or memory measurements. Current Chrome timer throttling, freezing or +discarding, Tampermonkey installation, and live Binance responses remain untested. + ### Compact layout validation (2026-10-09) Trading-data `1.2.1` and CMC-data `0.2.1` passed the full 2528-test Node suite, @@ -348,8 +401,16 @@ claim live behavior from source inspection alone. new symbol. - Open near a five-minute boundary and verify the current period is fetched after the server-time boundary rather than skipped. -- Hide the tab and return; the business timer stops while hidden and resumes with - a fresh, current-symbol fetch. +- Switch tabs repeatedly within 15 seconds; retain the same rows, current quote, + and history timestamp without new calibration or history requests. Once current + funding is overdue, returning requests it once without clearing the old value. +- Keep the initialized tab hidden: funding refreshes at 60-second completion + intervals, history retains its five-minute schedule, and hourly calibration + continues. The second-by-second display stops and catches up immediately on + return, including a settlement boundary crossed while hidden. +- Delay browser callbacks or pending history across several publication windows; + process only the latest window after its five-second delay. A late initial or + cycle response cannot appear as freshly received current-period data. - Close the panel and verify background polling does not restart it. - Simulate a partial endpoint failure; cached rows are marked as cached and are excluded from directional vote totals while missing rows remain explicit. @@ -373,8 +434,13 @@ claim live behavior from source inspection alone. the known mapping renders data. - Verify the displayed source and update time distinguish a page snapshot from a data API response. -- Hide or close the panel while a refresh is pending; no stale render or timer - survives removal. +- Hide the tab while a refresh is pending; its current-symbol response still + renders and a quick return causes no duplicate request or Loading state. +- Keep the tab hidden through a five-minute refresh, then return; only an overdue + foreground snapshot triggers one silent refresh. Repeated returns after errors + must respect the completion-based cooldown. +- Close or leave the route while a refresh is pending; no stale render or timer + survives removal, and visibility events cannot reopen a closed panel. - Navigate between matching and non-matching Binance routes and verify the route watcher does not leave a business loop running off-route. - Expand interpretations for FDV, volume/cap, liquidity/cap, supply, holders, and diff --git a/e2e/binance-orderbook/specs/data-panels-presentation.pw.js b/e2e/binance-orderbook/specs/data-panels-presentation.pw.js index 116f277..5a1bc7f 100644 --- a/e2e/binance-orderbook/specs/data-panels-presentation.pw.js +++ b/e2e/binance-orderbook/specs/data-panels-presentation.pw.js @@ -3,6 +3,7 @@ import { cmcDetail, tradingDataset } from '../../../test/helpers/data-media-migr import { createDataPanelsFixture, installDataPanels, DATA_PANEL_NOW, DATA_PANELS, POSITION_AUDIT_KEY, } from '../helpers/data-panels-host.js'; +import { installSimulatedVisibility, setSimulatedVisibility } from '../helpers/simulated-visibility.js'; const SIDE_BY_SIDE = { trading: { left: 1040, top: 60 }, cmc: { left: 520, top: 60 } }; @@ -27,6 +28,68 @@ function presentationDataset() { return dataset; } +test('user retains both data panels and expanded details through ten simulated background returns', async ({ page, context }, testInfo) => { + // Given both generated panels are loaded with expanded details under a controlled browser clock + const fixture = await createDataPanelsFixture(context, { dataset: presentationDataset() }); + await fixture.open(page, { locale: 'en', controlledClock: true, viewport: { width: 1366, height: 768 } }); + await installSimulatedVisibility(page); + const trading = page.locator('#jh-binance-trading-data-panel'); + const cmc = page.locator('#jh-binance-cmc-data-panel'); + await trading.locator('[data-metric="funding"] .td-spark-button').click(); + await cmc.locator('[data-expand-metric="fdv"]').click(); + const updated = await trading.locator('[data-role="updated-at"]').textContent(); + await page.evaluate(() => { + window.retainedDataPanelRows = [...document.querySelectorAll('#jh-binance-trading-data-panel-rows > tr, #jh-binance-cmc-data-panel-rows > tr')]; + }); + + // When visibility and suspended animation frames simulate ten one-second tab absences + for (let index = 0; index < 10; index++) { + await setSimulatedVisibility(page, true); + await page.clock.runFor(1_000); + await setSimulatedVisibility(page, false); + } + + // Then existing rows, details and funding stay readable without a duplicate HTTP request or fresh history timestamp + expect(await page.evaluate(() => { + const rows = [...document.querySelectorAll('#jh-binance-trading-data-panel-rows > tr, #jh-binance-cmc-data-panel-rows > tr')]; + return rows.length === window.retainedDataPanelRows.length && rows.every((row, index) => row === window.retainedDataPanelRows[index]); + })).toBe(true); + await expect(trading.locator('[data-role="current-funding"]')).toHaveText('0.00378%'); + await expect(trading.locator('[data-role="funding-countdown"]')).toHaveText('Countdown 03:59:50'); + await expect(trading.locator('[data-role="updated-at"]')).toHaveText(updated); + await expect(trading.locator('[data-metric="funding"] .td-spark-button')).toHaveAttribute('aria-expanded', 'true'); + await expect(cmc.locator('[data-expand-metric="fdv"]')).toHaveAttribute('aria-expanded', 'true'); + await expect(cmc.locator('#jh-binance-cmc-data-panel-explanation-fdv')).toBeVisible(); + expect(fixture.requests).toHaveLength(10); + expect(fixture.gmRequests).toHaveLength(3); + expect(fixture.errors).toEqual([]); + await page.screenshot({ path: testInfo.outputPath('background-panels-en.png'), fullPage: true }); +}); + +test('user sees an expired funding quote withdrawn immediately after a simulated background settlement', async ({ page, context }) => { + // Given a generated trading panel has a current quote whose settlement is two seconds away + const dataset = presentationDataset(); + dataset.premiumIndex.nextFundingTime = DATA_PANEL_NOW + 2_000; + const fixture = await createDataPanelsFixture(context, { dataset }); + await fixture.open(page, { controlledClock: true, panels: ['trading'] }); + await installSimulatedVisibility(page); + const trading = page.locator('#jh-binance-trading-data-panel'); + const updated = await trading.locator('[data-role="updated-at"]').textContent(); + + // When the tab returns after settlement with no newer funding response available + await setSimulatedVisibility(page, true); + await page.clock.runFor(3_000); + await setSimulatedVisibility(page, false); + + // Then the expired quote is unavailable while settled history and its receipt time remain intact + await expect(trading.locator('[data-role="current-funding"]')).toHaveText('--'); + await expect(trading.locator('[data-role="funding-countdown"]')).toHaveText('倒计时 等待更新'); + await expect(trading.locator('[data-metric="funding"] .td-last-value')).toHaveText('0.000029%'); + await expect(trading.locator('[data-role="updated-at"]')).toHaveText(updated); + expect(fixture.requests).toHaveLength(10); + expect(fixture.errors).toEqual([]); +}); + test('user switches both real panels between Chinese and English while retaining precise rates', async ({ page, context }) => { // Given the generated panels receive a tiny settled rate and a distinct current rate const fixture = await createDataPanelsFixture(context, { dataset: presentationDataset() }); diff --git a/scripts/binance-coinmarketcap-data.user.js b/scripts/binance-coinmarketcap-data.user.js index 3e7db63..ae49ad3 100644 --- a/scripts/binance-coinmarketcap-data.user.js +++ b/scripts/binance-coinmarketcap-data.user.js @@ -3,7 +3,7 @@ // @namespace binance.coinmarketcap.data // @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 0.2.1 +// @version 0.2.2 // @author jackhai9 // @description Show localized CoinMarketCap valuation, supply, and metric interpretations on Binance futures pages // @match https://www.binance.com/*/futures/* @@ -277,7 +277,7 @@ fetched: localizedText("拉取", "Fetched"), dataTime: localizedText("CMC 数据时间", "CMC data time"), fetchedTime: localizedText("本次拉取时间", "Fetch time"), - refreshPeriod: localizedText("每 30 秒刷新", "Refreshes every 30s"), + refreshPeriod: localizedText("前台 30 秒 / 后台 5 分钟刷新", "Refresh: 30s active / 5m in background"), changePeriod: localizedText("24小时", "24h"), unavailable: localizedText("暂无数据", "Data unavailable"), comparisonUnavailable: localizedText("暂无可比数据", "Comparison unavailable"), @@ -684,6 +684,7 @@ const STORAGE_POS_KEY = "jh_binance_cmc_data_pos"; const STORAGE_COLLAPSED_KEY = "jh_binance_cmc_data_collapsed"; const REFRESH_MS = 30 * 1e3; + const BACKGROUND_REFRESH_MS = 5 * 60 * 1e3; const ROUTE_WATCHDOG_MS = 5e3; const CMC_MAP_API = "https://api.coinmarketcap.com/data-api/v1/cryptocurrency/map"; const CMC_DETAIL_API = "https://api.coinmarketcap.com/data-api/v3/cryptocurrency/detail"; @@ -692,8 +693,9 @@ RAVE: { id: 38967, symbol: "RAVE", slug: "ravedao" } }; let panelClosed = false; - let lastSymbol = null; + let activePath = null; let lastUpdateTs = 0; + let lastRefreshCompletedAt = null; let refreshTimer = null; let routeTimer = null; let removeSpaRouteChangeListener = null; @@ -711,7 +713,7 @@ return parseFuturesTradingSymbolFromPathname(location.pathname); } function isActiveTradingPage() { - return !panelClosed && !document.hidden && isFuturesTradingPage(); + return !panelClosed && isFuturesTradingPage(); } function baseAssetFromSymbol(symbol) { if (!symbol) return null; @@ -1132,43 +1134,68 @@ } } async function refreshForCurrentSymbol(force, silent) { - if (panelClosed || document.hidden) return; + if (panelClosed) return; if (!isFuturesTradingPage()) { pauseForNonTradingPage(); return; } + const path = location.pathname; + if (activePath !== path) return; const symbol = getCurrentSymbol(); if (!symbol) return; if (!force && symbol === inFlightSymbol) return; const myEpoch = ++refreshEpoch; + const refreshIsCurrent = function() { + return myEpoch === refreshEpoch && activePath === path && location.pathname === path && isActiveTradingPage() && getCurrentSymbol() === symbol; + }; + clearTimeout(refreshTimer); + refreshTimer = null; inFlightSymbol = symbol; if (!silent || !lastRowsHtml) renderLoading(symbol); try { const data = await fetchCmcData(symbol); - if (myEpoch !== refreshEpoch || !isActiveTradingPage() || getCurrentSymbol() !== symbol) return; + if (!refreshIsCurrent()) return; renderData(symbol, data); - lastSymbol = symbol; } catch (error) { - if (myEpoch !== refreshEpoch || !isActiveTradingPage() || getCurrentSymbol() !== symbol) return; + if (!refreshIsCurrent()) return; renderError(symbol, error && error.message ? error.message : String(error)); } finally { - if (myEpoch === refreshEpoch) inFlightSymbol = null; + if (refreshIsCurrent()) { + inFlightSymbol = null; + lastRefreshCompletedAt = Date.now(); + scheduleDataRefresh(); + } } } + function scheduleDataRefresh() { + clearTimeout(refreshTimer); + refreshTimer = null; + if (!isActiveTradingPage() || activePath !== location.pathname || inFlightSymbol || lastRefreshCompletedAt === null) return; + const interval = document.hidden ? BACKGROUND_REFRESH_MS : REFRESH_MS; + const delay = Math.max(0, lastRefreshCompletedAt + interval - Date.now()); + refreshTimer = setTimeout(function() { + refreshTimer = null; + refreshForCurrentSymbol(false, true); + }, delay); + } function startDataLoop() { - if (panelClosed || document.hidden || !isFuturesTradingPage()) return; + if (!isActiveTradingPage()) return; + if (activePath === location.pathname) { + scheduleDataRefresh(); + return; + } + stopDataLoop(); + activePath = location.pathname; + lastPath = location.pathname; ensurePanel(); refreshForCurrentSymbol(true, false); - if (!refreshTimer) { - refreshTimer = setInterval(function() { - refreshForCurrentSymbol(false, true); - }, REFRESH_MS); - } } function stopDataLoop() { refreshEpoch++; + activePath = null; inFlightSymbol = null; - if (refreshTimer) clearInterval(refreshTimer); + lastRefreshCompletedAt = null; + clearTimeout(refreshTimer); refreshTimer = null; } function stopRouteWatcher() { @@ -1201,10 +1228,9 @@ function pauseForNonTradingPage() { stopDataLoop(); removePanel(); - lastSymbol = null; } function handleRouteChange() { - if (document.hidden || panelClosed) return; + if (panelClosed) return; if (location.pathname === lastPath) return; lastPath = location.pathname; if (!isFuturesTradingPage()) { @@ -1214,8 +1240,7 @@ startDataLoop(); } function startRouteWatcher() { - if (document.hidden || panelClosed) return; - lastPath = location.pathname; + if (panelClosed) return; if (!removeSpaRouteChangeListener) { removeSpaRouteChangeListener = installSpaRouteChangeListener(window, handleRouteChange); } @@ -1396,16 +1421,22 @@ return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } document.addEventListener("visibilitychange", function() { + if (panelClosed) return; if (document.hidden) { - stopLoop(); + scheduleDataRefresh(); return; } - if (panelClosed) return; startRouteWatcher(); + if (location.pathname !== lastPath) { + handleRouteChange(); + return; + } if (isFuturesTradingPage()) startDataLoop(); else pauseForNonTradingPage(); }); - startRouteWatcher(); - startDataLoop(); + if (!document.hidden) { + startRouteWatcher(); + startDataLoop(); + } })(); })(); diff --git a/scripts/binance-trading-data.user.js b/scripts/binance-trading-data.user.js index 44f897e..c24db5b 100644 --- a/scripts/binance-trading-data.user.js +++ b/scripts/binance-trading-data.user.js @@ -3,7 +3,7 @@ // @namespace binance.trading.data // @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 1.2.1 +// @version 1.2.2 // @author jackhai9 // @description Bilingual futures metrics with historical trends, current funding, settlement countdown, and indicator signals. // @match https://www.binance.com/*/futures/* @@ -1203,6 +1203,7 @@ ${formatHistoryTime(state.current.time, locale)}`; const DATA_LIMIT = 30; const FUNDING_HISTORY_LIMIT = 40; const CURRENT_FUNDING_REFRESH_MS = 15e3; + const BACKGROUND_FUNDING_REFRESH_MS = 6e4; const FUNDING_REQUEST_TIMEOUT_MS = 1e4; const CLOCK_REQUEST_TIMEOUT_MS = 5e3; const CLOCK_MAX_ROUND_TRIP_MS = 2e3; @@ -1231,11 +1232,12 @@ ${formatHistoryTime(state.current.time, locale)}`; emit("ERR", ...args); } let lastSymbol = null; + let activePath = null; function getCurrentSymbol() { return parseFuturesTradingSymbolFromPathname(location.pathname); } function isActiveTradingPage() { - return !panelClosed && !document.hidden && isFuturesTradingPage(); + return !panelClosed && isFuturesTradingPage(); } async function fetchJson(path, params, signal) { const url = new URL(path, API_BASE); @@ -1292,7 +1294,7 @@ ${formatHistoryTime(state.current.time, locale)}`; return { generation: sessionGeneration, path: location.pathname, symbol: getCurrentSymbol() }; } function sessionIsCurrent(session) { - return session.generation === sessionGeneration && session.path === location.pathname && session.symbol === getCurrentSymbol() && isActiveTradingPage(); + return session.generation === sessionGeneration && session.path === location.pathname && session.path === activePath && session.symbol === getCurrentSymbol() && isActiveTradingPage(); } async function syncServerTime(session) { const requestId = ++clockRequestId; @@ -1452,14 +1454,20 @@ ${formatHistoryTime(state.current.time, locale)}`; const footer = panel.querySelector("#" + PANEL_ID2 + "-footer"); if (footer) updateFooter(footer); } + function updateDisplayClock() { + if (document.hidden || !isActiveTradingPage() || !panelView) return; + const footer = document.getElementById(PANEL_ID2 + "-footer"); + if (footer) updateFooter(footer); + panelView.updateClock(fundingClock()); + } function startDisplayClock() { - if (agoTimer) return; - agoTimer = setInterval(function() { - if (!isActiveTradingPage() || !panelView) return; - const footer = document.getElementById(PANEL_ID2 + "-footer"); - if (footer) updateFooter(footer); - panelView.updateClock(fundingClock()); - }, 1e3); + if (document.hidden || agoTimer) return; + updateDisplayClock(); + agoTimer = setInterval(updateDisplayClock, 1e3); + } + function stopDisplayClock() { + clearInterval(agoTimer); + agoTimer = null; } function updateFooter(el) { const locale = uiLocale(); @@ -1478,6 +1486,7 @@ ${formatHistoryTime(state.current.time, locale)}`; } let currentFundingTimer = null; let currentFundingRequest = null; + let currentFundingCompletedAt = null; let fundingIntervalRequest = null; let currentFundingState = emptyCurrentFundingState(null); function emptyCurrentFundingState(symbol) { @@ -1492,8 +1501,21 @@ ${formatHistoryTime(state.current.time, locale)}`; intervalError: null }; } + function scheduleCurrentFunding(session) { + clearTimeout(currentFundingTimer); + currentFundingTimer = null; + if (!sessionIsCurrent(session) || currentFundingRequest || currentFundingCompletedAt === null) return; + const interval = document.hidden ? BACKGROUND_FUNDING_REFRESH_MS : CURRENT_FUNDING_REFRESH_MS; + const delay = Math.max(0, currentFundingCompletedAt + interval - Date.now()); + currentFundingTimer = setTimeout(function() { + currentFundingTimer = null; + refreshCurrentFunding(session); + }, delay); + } async function refreshCurrentFunding(session) { if (!sessionIsCurrent(session) || currentFundingRequest) return; + clearTimeout(currentFundingTimer); + currentFundingTimer = null; const request = { controller: new AbortController(), timeout: null }; currentFundingRequest = request; request.timeout = setTimeout(function() { @@ -1512,10 +1534,9 @@ ${formatHistoryTime(state.current.time, locale)}`; clearTimeout(request.timeout); if (sessionIsCurrent(session) && currentFundingRequest === request) { currentFundingRequest = null; + currentFundingCompletedAt = Date.now(); panelView.setFunding(currentFundingState, fundingClock()); - currentFundingTimer = setTimeout(function() { - refreshCurrentFunding(session); - }, CURRENT_FUNDING_REFRESH_MS); + scheduleCurrentFunding(session); } } } @@ -1677,8 +1698,11 @@ ${formatHistoryTime(state.current.time, locale)}`; renderPanel(result, symbol); } async function initialFetch(symbol) { + const session = currentSession(); + if (!sessionIsCurrent(session)) return; epoch++; var myEpoch = epoch; + const boundary = Math.floor(serverNow() / PERIOD_MS) * PERIOD_MS; clearTimeout(cycleTimer); clearTimeout(retryTimer); if (symbol !== lastSymbol) { @@ -1691,9 +1715,10 @@ ${formatHistoryTime(state.current.time, locale)}`; fetchPeriodData(symbol, PERIOD_KEYS), fetchFundingRateData(symbol) ]); - refreshFundingInterval(currentSession()); + refreshFundingInterval(session); var [periodEntries, fundingEntry] = await history; - if (epoch !== myEpoch || !isActiveTradingPage() || getCurrentSymbol() !== symbol) return; + if (epoch !== myEpoch || !sessionIsCurrent(session)) return; + if (serverNow() >= boundary + PERIOD_MS) return; applyResults(symbol, periodEntries, fundingEntry); renderAll(symbol); } catch (e) { @@ -1707,7 +1732,7 @@ ${formatHistoryTime(state.current.time, locale)}`; clearTimeout(retryTimer); cycleTimer = null; retryTimer = null; - if (panelClosed || document.hidden) return; + if (panelClosed) return; if (!isFuturesTradingPage()) { pauseForNonTradingPage(); return; @@ -1734,12 +1759,21 @@ ${formatHistoryTime(state.current.time, locale)}`; }, delay); } async function runCycleAttempt(boundary, attempt) { - if (document.hidden || panelClosed) return; + if (panelClosed) return; if (!isFuturesTradingPage()) { pauseForNonTradingPage(); return; } + if (activePath !== location.pathname) { + handlePathChange(); + return; + } + if (serverNow() >= boundary + PERIOD_MS) { + scheduleCycle(); + return; + } if (fetching) return; + const session = currentSession(); var symbol = getCurrentSymbol(); if (!symbol) { scheduleCycle(true); @@ -1760,7 +1794,7 @@ ${formatHistoryTime(state.current.time, locale)}`; fetchPeriodData(symbol, PERIOD_KEYS), fetchFundingRateData(symbol) ]); - refreshFundingInterval(currentSession()); + refreshFundingInterval(session); [periodEntries, fundingEntry] = await history; } else { var pending = getPendingKeys(symbol, targetTs); @@ -1772,7 +1806,11 @@ ${formatHistoryTime(state.current.time, locale)}`; } periodEntries = await fetchPeriodData(symbol, pending); } - if (epoch !== myEpoch || !isActiveTradingPage() || getCurrentSymbol() !== symbol) return; + if (epoch !== myEpoch || !sessionIsCurrent(session)) return; + if (serverNow() >= boundary + PERIOD_MS) { + scheduleCycle(); + return; + } applyResults(symbol, periodEntries, fundingEntry || null); renderAll(symbol); var stillPending = getPendingKeys(symbol, targetTs); @@ -1794,6 +1832,7 @@ ${formatHistoryTime(state.current.time, locale)}`; runCycleAttempt(boundary, attempt + 1); }, retryDelay); } catch (e) { + if (epoch !== myEpoch || !sessionIsCurrent(session)) return; err("数据拉取失败:", e); scheduleCycle(); } finally { @@ -1801,14 +1840,12 @@ ${formatHistoryTime(state.current.time, locale)}`; } } function stopBusinessLoop() { + activePath = null; clearTimeout(cycleTimer); cycleTimer = null; clearTimeout(retryTimer); retryTimer = null; - if (agoTimer) { - clearInterval(agoTimer); - agoTimer = null; - } + stopDisplayClock(); if (serverTimeTimer) { clearInterval(serverTimeTimer); serverTimeTimer = null; @@ -1820,6 +1857,7 @@ ${formatHistoryTime(state.current.time, locale)}`; } clearTimeout(currentFundingTimer); currentFundingTimer = null; + currentFundingCompletedAt = null; if (currentFundingRequest) { clearTimeout(currentFundingRequest.timeout); currentFundingRequest.controller.abort(); @@ -1885,6 +1923,8 @@ ${formatHistoryTime(state.current.time, locale)}`; const session = currentSession(); const symbol = session.symbol; if (!symbol) return; + activePath = session.path; + lastPath = session.path; failedKeys = /* @__PURE__ */ new Set([...PERIOD_KEYS, "fundingRate"]); endpointErrors = {}; currentFundingState = emptyCurrentFundingState(symbol); @@ -1901,7 +1941,7 @@ ${formatHistoryTime(state.current.time, locale)}`; scheduleCycle(); } function handlePathChange() { - if (document.hidden || panelClosed) return; + if (panelClosed) return; if (location.pathname === lastPath) return; lastPath = location.pathname; if (!isFuturesTradingPage()) { @@ -1911,8 +1951,7 @@ ${formatHistoryTime(state.current.time, locale)}`; activateTradingPage(); } function startRouteWatcher() { - if (document.hidden || panelClosed) return; - lastPath = location.pathname; + if (panelClosed) return; if (!removeSpaRouteChangeListener) { removeSpaRouteChangeListener = installSpaRouteChangeListener(window, handlePathChange); } @@ -1926,17 +1965,27 @@ ${formatHistoryTime(state.current.time, locale)}`; function start() { log("脚本启动"); document.addEventListener("visibilitychange", function() { - if (!document.hidden) { - if (panelClosed) return; - startRouteWatcher(); - if (!isFuturesTradingPage()) { - pauseForNonTradingPage(); - return; - } + if (panelClosed) return; + if (document.hidden) { + stopDisplayClock(); + scheduleCurrentFunding(currentSession()); + return; + } + startRouteWatcher(); + if (location.pathname !== lastPath) { + handlePathChange(); + return; + } + if (!isFuturesTradingPage()) { + pauseForNonTradingPage(); + return; + } + if (activePath !== location.pathname) { activateTradingPage(); - } else { - stopLoop(); + return; } + startDisplayClock(); + scheduleCurrentFunding(currentSession()); }); if (!document.hidden) { startRouteWatcher(); diff --git a/src/binance-coinmarketcap-data/index.user.js b/src/binance-coinmarketcap-data/index.user.js index f1690ad..89c59ae 100644 --- a/src/binance-coinmarketcap-data/index.user.js +++ b/src/binance-coinmarketcap-data/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.coinmarketcap.data // @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 0.2.1 +// @version 0.2.2 // @author jackhai9 // @description Show localized CoinMarketCap valuation, supply, and metric interpretations on Binance futures pages // @match https://www.binance.com/*/futures/* @@ -43,6 +43,7 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as const STORAGE_POS_KEY = 'jh_binance_cmc_data_pos'; const STORAGE_COLLAPSED_KEY = 'jh_binance_cmc_data_collapsed'; const REFRESH_MS = 30 * 1000; + const BACKGROUND_REFRESH_MS = 5 * 60 * 1000; const ROUTE_WATCHDOG_MS = 5_000; const CMC_MAP_API = 'https://api.coinmarketcap.com/data-api/v1/cryptocurrency/map'; const CMC_DETAIL_API = 'https://api.coinmarketcap.com/data-api/v3/cryptocurrency/detail'; @@ -53,8 +54,9 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as }; let panelClosed = false; - let lastSymbol = null; + let activePath = null; let lastUpdateTs = 0; + let lastRefreshCompletedAt = null; let refreshTimer = null; let routeTimer = null; let removeSpaRouteChangeListener = null; @@ -75,7 +77,7 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as } function isActiveTradingPage() { - return !panelClosed && !document.hidden && isFuturesTradingPage(); + return !panelClosed && isFuturesTradingPage(); } function baseAssetFromSymbol(symbol) { @@ -460,46 +462,74 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as } async function refreshForCurrentSymbol(force, silent) { - if (panelClosed || document.hidden) return; + if (panelClosed) return; if (!isFuturesTradingPage()) { pauseForNonTradingPage(); return; } + const path = location.pathname; + if (activePath !== path) return; const symbol = getCurrentSymbol(); if (!symbol) return; if (!force && symbol === inFlightSymbol) return; const myEpoch = ++refreshEpoch; + const refreshIsCurrent = function () { + return myEpoch === refreshEpoch && activePath === path && location.pathname === path + && isActiveTradingPage() && getCurrentSymbol() === symbol; + }; + clearTimeout(refreshTimer); + refreshTimer = null; inFlightSymbol = symbol; if (!silent || !lastRowsHtml) renderLoading(symbol); try { const data = await fetchCmcData(symbol); - if (myEpoch !== refreshEpoch || !isActiveTradingPage() || getCurrentSymbol() !== symbol) return; + if (!refreshIsCurrent()) return; renderData(symbol, data); - lastSymbol = symbol; } catch (error) { - if (myEpoch !== refreshEpoch || !isActiveTradingPage() || getCurrentSymbol() !== symbol) return; + if (!refreshIsCurrent()) return; renderError(symbol, error && error.message ? error.message : String(error)); } finally { - if (myEpoch === refreshEpoch) inFlightSymbol = null; + if (refreshIsCurrent()) { + inFlightSymbol = null; + lastRefreshCompletedAt = Date.now(); + scheduleDataRefresh(); + } } } + /** Failed attempts also retain a deadline so tab switching cannot create a retry loop. */ + function scheduleDataRefresh() { + clearTimeout(refreshTimer); + refreshTimer = null; + if (!isActiveTradingPage() || activePath !== location.pathname || inFlightSymbol || lastRefreshCompletedAt === null) return; + const interval = document.hidden ? BACKGROUND_REFRESH_MS : REFRESH_MS; + const delay = Math.max(0, lastRefreshCompletedAt + interval - Date.now()); + refreshTimer = setTimeout(function () { + refreshTimer = null; + refreshForCurrentSymbol(false, true); + }, delay); + } + function startDataLoop() { - if (panelClosed || document.hidden || !isFuturesTradingPage()) return; + if (!isActiveTradingPage()) return; + if (activePath === location.pathname) { + scheduleDataRefresh(); + return; + } + stopDataLoop(); + activePath = location.pathname; + lastPath = location.pathname; ensurePanel(); refreshForCurrentSymbol(true, false); - if (!refreshTimer) { - refreshTimer = setInterval(function () { - refreshForCurrentSymbol(false, true); - }, REFRESH_MS); - } } function stopDataLoop() { refreshEpoch++; + activePath = null; inFlightSymbol = null; - if (refreshTimer) clearInterval(refreshTimer); + lastRefreshCompletedAt = null; + clearTimeout(refreshTimer); refreshTimer = null; } @@ -537,11 +567,10 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as function pauseForNonTradingPage() { stopDataLoop(); removePanel(); - lastSymbol = null; } function handleRouteChange() { - if (document.hidden || panelClosed) return; + if (panelClosed) return; if (location.pathname === lastPath) return; lastPath = location.pathname; if (!isFuturesTradingPage()) { @@ -552,8 +581,7 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as } function startRouteWatcher() { - if (document.hidden || panelClosed) return; - lastPath = location.pathname; + if (panelClosed) return; if (!removeSpaRouteChangeListener) { removeSpaRouteChangeListener = installSpaRouteChangeListener(window, handleRouteChange); } @@ -760,16 +788,23 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as } document.addEventListener('visibilitychange', function () { + if (panelClosed) return; if (document.hidden) { - stopLoop(); + scheduleDataRefresh(); return; } - if (panelClosed) return; startRouteWatcher(); + if (location.pathname !== lastPath) { + handleRouteChange(); + return; + } if (isFuturesTradingPage()) startDataLoop(); else pauseForNonTradingPage(); }); - startRouteWatcher(); - startDataLoop(); + // A newly opened background tab starts only when first visited. + if (!document.hidden) { + startRouteWatcher(); + startDataLoop(); + } })(); diff --git a/src/binance-coinmarketcap-data/ui-copy.js b/src/binance-coinmarketcap-data/ui-copy.js index 18dc024..c036a24 100644 --- a/src/binance-coinmarketcap-data/ui-copy.js +++ b/src/binance-coinmarketcap-data/ui-copy.js @@ -23,7 +23,7 @@ export const CMC_COPY = Object.freeze({ fetched: localizedText('拉取', 'Fetched'), dataTime: localizedText('CMC 数据时间', 'CMC data time'), fetchedTime: localizedText('本次拉取时间', 'Fetch time'), - refreshPeriod: localizedText('每 30 秒刷新', 'Refreshes every 30s'), + refreshPeriod: localizedText('前台 30 秒 / 后台 5 分钟刷新', 'Refresh: 30s active / 5m in background'), changePeriod: localizedText('24小时', '24h'), unavailable: localizedText('暂无数据', 'Data unavailable'), comparisonUnavailable: localizedText('暂无可比数据', 'Comparison unavailable'), diff --git a/src/binance-trading-data/index.user.js b/src/binance-trading-data/index.user.js index e1041ee..581fcf6 100644 --- a/src/binance-trading-data/index.user.js +++ b/src/binance-trading-data/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.trading.data // @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 1.2.1 +// @version 1.2.2 // @author jackhai9 // @description Bilingual futures metrics with historical trends, current funding, settlement countdown, and indicator signals. // @match https://www.binance.com/*/futures/* @@ -53,6 +53,7 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as const DATA_LIMIT = 30; const FUNDING_HISTORY_LIMIT = 40; const CURRENT_FUNDING_REFRESH_MS = 15_000; + const BACKGROUND_FUNDING_REFRESH_MS = 60_000; const FUNDING_REQUEST_TIMEOUT_MS = 10_000; const CLOCK_REQUEST_TIMEOUT_MS = 5_000; const CLOCK_MAX_ROUND_TRIP_MS = 2_000; @@ -87,13 +88,14 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as /* ========== Symbol 检测 ========== */ let lastSymbol = null; + let activePath = null; function getCurrentSymbol() { return parseFuturesTradingSymbolFromPathname(location.pathname); } function isActiveTradingPage() { - return !panelClosed && !document.hidden && isFuturesTradingPage(); + return !panelClosed && isFuturesTradingPage(); } /* ========== API 层 ========== */ @@ -165,6 +167,7 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as function sessionIsCurrent(session) { return session.generation === sessionGeneration && session.path === location.pathname + && session.path === activePath && session.symbol === getCurrentSymbol() && isActiveTradingPage(); } @@ -350,14 +353,22 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as } /** Funding updates and clock ticks must not refresh the historical data timestamp. */ + function updateDisplayClock() { + if (document.hidden || !isActiveTradingPage() || !panelView) return; + const footer = document.getElementById(PANEL_ID + '-footer'); + if (footer) updateFooter(footer); + panelView.updateClock(fundingClock()); + } + function startDisplayClock() { - if (agoTimer) return; - agoTimer = setInterval(function () { - if (!isActiveTradingPage() || !panelView) return; - const footer = document.getElementById(PANEL_ID + '-footer'); - if (footer) updateFooter(footer); - panelView.updateClock(fundingClock()); - }, 1000); + if (document.hidden || agoTimer) return; + updateDisplayClock(); + agoTimer = setInterval(updateDisplayClock, 1000); + } + + function stopDisplayClock() { + clearInterval(agoTimer); + agoTimer = null; } function updateFooter(el) { @@ -380,6 +391,7 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as let currentFundingTimer = null; let currentFundingRequest = null; + let currentFundingCompletedAt = null; let fundingIntervalRequest = null; let currentFundingState = emptyCurrentFundingState(null); @@ -392,8 +404,23 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as }; } + /** Visibility changes the cadence, never the request's completion-time anchor. */ + function scheduleCurrentFunding(session) { + clearTimeout(currentFundingTimer); + currentFundingTimer = null; + if (!sessionIsCurrent(session) || currentFundingRequest || currentFundingCompletedAt === null) return; + const interval = document.hidden ? BACKGROUND_FUNDING_REFRESH_MS : CURRENT_FUNDING_REFRESH_MS; + const delay = Math.max(0, currentFundingCompletedAt + interval - Date.now()); + currentFundingTimer = setTimeout(function () { + currentFundingTimer = null; + refreshCurrentFunding(session); + }, delay); + } + async function refreshCurrentFunding(session) { if (!sessionIsCurrent(session) || currentFundingRequest) return; + clearTimeout(currentFundingTimer); + currentFundingTimer = null; const request = { controller: new AbortController(), timeout: null }; currentFundingRequest = request; request.timeout = setTimeout(function () { @@ -412,10 +439,9 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as clearTimeout(request.timeout); if (sessionIsCurrent(session) && currentFundingRequest === request) { currentFundingRequest = null; + currentFundingCompletedAt = Date.now(); panelView.setFunding(currentFundingState, fundingClock()); - currentFundingTimer = setTimeout(function () { - refreshCurrentFunding(session); - }, CURRENT_FUNDING_REFRESH_MS); + scheduleCurrentFunding(session); } } } @@ -601,11 +627,14 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as renderPanel(result, symbol); } - // 首次全量拉取(启动 / 切交易对 / tab 恢复) + /** Initialize history only for a new route session, including its first visible activation. */ async function initialFetch(symbol) { + const session = currentSession(); + if (!sessionIsCurrent(session)) return; // 作废所有正在进行的异步操作 epoch++; var myEpoch = epoch; + const boundary = Math.floor(serverNow() / PERIOD_MS) * PERIOD_MS; clearTimeout(cycleTimer); clearTimeout(retryTimer); @@ -619,9 +648,10 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as fetchPeriodData(symbol, PERIOD_KEYS), fetchFundingRateData(symbol), ]); - refreshFundingInterval(currentSession()); + refreshFundingInterval(session); var [periodEntries, fundingEntry] = await history; - if (epoch !== myEpoch || !isActiveTradingPage() || getCurrentSymbol() !== symbol) return; // 已被更新的调用取代 + if (epoch !== myEpoch || !sessionIsCurrent(session)) return; + if (serverNow() >= boundary + PERIOD_MS) return; applyResults(symbol, periodEntries, fundingEntry); renderAll(symbol); } catch (e) { err('拉取失败:', e); } @@ -638,7 +668,7 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as cycleTimer = null; retryTimer = null; - if (panelClosed || document.hidden) return; + if (panelClosed) return; if (!isFuturesTradingPage()) { pauseForNonTradingPage(); return; @@ -673,13 +703,24 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as } async function runCycleAttempt(boundary, attempt) { - if (document.hidden || panelClosed) return; + if (panelClosed) return; if (!isFuturesTradingPage()) { pauseForNonTradingPage(); return; } + // A host can replace history methods before the route watchdog repairs its notifications. + if (activePath !== location.pathname) { + handlePathChange(); + return; + } + // A delayed browser callback must observe the current publication window, not replay missed ones. + if (serverNow() >= boundary + PERIOD_MS) { + scheduleCycle(); + return; + } if (fetching) return; + const session = currentSession(); var symbol = getCurrentSymbol(); if (!symbol) { scheduleCycle(true); return; } @@ -700,7 +741,7 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as fetchPeriodData(symbol, PERIOD_KEYS), fetchFundingRateData(symbol), ]); - refreshFundingInterval(currentSession()); + refreshFundingInterval(session); [periodEntries, fundingEntry] = await history; } else { var pending = getPendingKeys(symbol, targetTs); @@ -714,7 +755,11 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as } // await 返回后检查:是否已被 initialFetch 取代 - if (epoch !== myEpoch || !isActiveTradingPage() || getCurrentSymbol() !== symbol) return; + if (epoch !== myEpoch || !sessionIsCurrent(session)) return; + if (serverNow() >= boundary + PERIOD_MS) { + scheduleCycle(); + return; + } applyResults(symbol, periodEntries, fundingEntry || null); renderAll(symbol); @@ -743,6 +788,7 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as runCycleAttempt(boundary, attempt + 1); }, retryDelay); } catch (e) { + if (epoch !== myEpoch || !sessionIsCurrent(session)) return; err('数据拉取失败:', e); scheduleCycle(); } finally { @@ -751,9 +797,10 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as } function stopBusinessLoop() { + activePath = null; clearTimeout(cycleTimer); cycleTimer = null; clearTimeout(retryTimer); retryTimer = null; - if (agoTimer) { clearInterval(agoTimer); agoTimer = null; } + stopDisplayClock(); if (serverTimeTimer) { clearInterval(serverTimeTimer); serverTimeTimer = null; } if (serverTimeRequest) { clearTimeout(serverTimeRequest.timeout); @@ -761,6 +808,7 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as serverTimeRequest = null; } clearTimeout(currentFundingTimer); currentFundingTimer = null; + currentFundingCompletedAt = null; if (currentFundingRequest) { clearTimeout(currentFundingRequest.timeout); currentFundingRequest.controller.abort(); @@ -830,6 +878,9 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as const session = currentSession(); const symbol = session.symbol; if (!symbol) return; + // Pending calibration and initial requests already belong to an active session. + activePath = session.path; + lastPath = session.path; failedKeys = new Set([...PERIOD_KEYS, 'fundingRate']); endpointErrors = {}; currentFundingState = emptyCurrentFundingState(symbol); @@ -847,7 +898,7 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as } function handlePathChange() { - if (document.hidden || panelClosed) return; + if (panelClosed) return; if (location.pathname === lastPath) return; lastPath = location.pathname; if (!isFuturesTradingPage()) { @@ -858,8 +909,7 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as } function startRouteWatcher() { - if (document.hidden || panelClosed) return; - lastPath = location.pathname; + if (panelClosed) return; if (!removeSpaRouteChangeListener) { removeSpaRouteChangeListener = installSpaRouteChangeListener(window, handlePathChange); } @@ -874,20 +924,29 @@ import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, DATA_PANEL_WIDTH as function start() { log('脚本启动'); - // tab 恢复时:重同步服务器时间 + 立即拉取 + 补抓当前周期 + 恢复定时器 - // tab 隐藏时:暂停业务 timer 和 route watcher,减少后台开销 + // Keep the route session alive while hidden; only the display clock and quote cadence change. document.addEventListener('visibilitychange', function () { - if (!document.hidden) { - if (panelClosed) return; // 面板已被用户关闭,不恢复 - startRouteWatcher(); - if (!isFuturesTradingPage()) { - pauseForNonTradingPage(); - return; - } + if (panelClosed) return; + if (document.hidden) { + stopDisplayClock(); + scheduleCurrentFunding(currentSession()); + return; + } + startRouteWatcher(); + if (location.pathname !== lastPath) { + handlePathChange(); + return; + } + if (!isFuturesTradingPage()) { + pauseForNonTradingPage(); + return; + } + if (activePath !== location.pathname) { activateTradingPage(); - } else { - stopLoop(); + return; } + startDisplayClock(); + scheduleCurrentFunding(currentSession()); }); // SPA 切换交易对检测(初始就在后台时延迟到前台再启动) diff --git a/test/dom/binance-cmc-panel-presentation.test.js b/test/dom/binance-cmc-panel-presentation.test.js index 86159de..069f484 100644 --- a/test/dom/binance-cmc-panel-presentation.test.js +++ b/test/dom/binance-cmc-panel-presentation.test.js @@ -146,7 +146,7 @@ test('user switches the complete CMC panel language and number units while retai assert.match(host.element('rows').querySelector('button').getAttribute('aria-label'), /View interpretation basis/); assert.equal(host.element('footer').querySelector('a').href, 'https://coinmarketcap.com/currencies/bitcoin/'); assert.equal(host.element('footer').querySelector('.cmc-times > span').textContent, dataTime); - assert.match(host.element('footer').textContent, /Fetched .*Refreshes every 30s/); + assert.match(host.element('footer').textContent, /Fetched .*Refresh: 30s active \/ 5m in background/); assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/map')).length, 1); assert.equal(host.window.localStorage.getItem('jh_binance_cmc_data_pos'), storage.jh_binance_cmc_data_pos); assert.equal(host.window.localStorage.getItem('jh_binance_cmc_data_collapsed'), '0'); diff --git a/test/dom/binance-data-panels-entrypoint.test.js b/test/dom/binance-data-panels-entrypoint.test.js index a317b6c..5e056ab 100644 --- a/test/dom/binance-data-panels-entrypoint.test.js +++ b/test/dom/binance-data-panels-entrypoint.test.js @@ -60,12 +60,11 @@ test('user sees cached rows without their directional votes after a failed refre const host = createDataPanelHost(t, 'trading'); await host.start(); await activateTrading(host); - host.setHidden(true); - // When returning to the page encounters failures for every endpoint - host.setHidden(false); + // When the next historical refresh encounters failures for every endpoint + host.clock.tick(305_000); const dataset = tradingDataset(Date.now()); - await activateTrading(host, dataset, { status: Object.fromEntries(Object.keys(dataset).map(key => [key, 400])) }); + await completeTradingBatch(host, dataset, { status: Object.fromEntries(Object.keys(dataset).map(key => [key, 400])) }); await host.rendered(() => host.element('composite').textContent.includes('中性 0:0')); // Then cached values are retained with hollow markers and no fresh votes diff --git a/test/dom/binance-trading-data-footer.test.js b/test/dom/binance-trading-data-footer.test.js index e606f56..ac0b48a 100644 --- a/test/dom/binance-trading-data-footer.test.js +++ b/test/dom/binance-trading-data-footer.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { activateTradingData, createDataPanelHost } from '../helpers/data-media-migration-host.js'; +import { activateTradingData, afterDataMediaResponseTurn, completeTradingBatch, createDataPanelHost, tradingDataset } from '../helpers/data-media-migration-host.js'; async function harness(t) { const updatedAt = new Date(2026, 8, 12, 12, 34, 56).getTime(); @@ -27,7 +27,7 @@ test('user keeps the footer elements while sixty clock updates advance displayed assert.equal(labels[1].textContent, '60秒前'); }); -test('user receives a same-second refresh without any footer DOM mutation', { timeout: 5_000 }, async t => { +test('user returns to the tab within the same second without any footer DOM mutation', { timeout: 5_000 }, async t => { // Given an observed footer has just rendered its initial data timestamp const h = await harness(t); const records = []; @@ -35,12 +35,12 @@ test('user receives a same-second refresh without any footer DOM mutation', { ti observer.observe(h.footer, { childList: true, subtree: true, characterData: true }); t.after(() => observer.disconnect()); - // When returning to the tab fetches and renders new data within the same second + // When the user switches away and returns within the same second h.setHidden(true); h.setHidden(false); - await activateTradingData(h); + await afterDataMediaResponseTurn(); - // Then the repeated render leaves the unchanged timestamp and age nodes untouched + // Then the return leaves unchanged timestamp and age nodes untouched assert.equal(records.length, 0); assert.equal(observer.takeRecords().length, 0); assert.equal(h.footer.firstElementChild.children[1].textContent, '0秒前'); @@ -55,7 +55,9 @@ test('user sees the retained timestamp label advance and elapsed seconds reset w // When the user returns five minutes later and the next complete response renders h.clock.setTime(h.updatedAt + 300_000); h.setHidden(false); - await activateTradingData(h); + h.clock.tick(0); + await completeTradingBatch(h, tradingDataset(Date.now())); + await afterDataMediaResponseTurn(); // Then the same label displays the new timestamp and a zero-second age assert.equal(h.footer.firstElementChild.children[0], label); diff --git a/test/dom/binance-trading-data-presentation.test.js b/test/dom/binance-trading-data-presentation.test.js index c55cb3d..caed1d6 100644 --- a/test/dom/binance-trading-data-presentation.test.js +++ b/test/dom/binance-trading-data-presentation.test.js @@ -35,25 +35,25 @@ test('user sees English trading labels on an English futures page', { timeout: 5 assert.equal(host.element('close').getAttribute('aria-label'), 'Close'); }); -test('user keeps the resumed data session when an earlier time request completes late', { timeout: 5_000 }, async t => { +test('user keeps one pending calibration through repeated tab visibility changes', { timeout: 5_000 }, async t => { // Given the original activation has an outstanding server-time request const host = createDataPanelHost(t, 'trading'); await host.start(); const oldTime = await host.network.waitForRequest(request => request.url.pathname.endsWith('/time')); - // When a resumed activation completes before the old calibration - host.setHidden(true); - host.setHidden(false); - const newTime = await host.network.waitForRequest(request => request !== oldTime && request.url.pathname.endsWith('/time')); - newTime.respond({ serverTime: Date.now() }); + // When repeated returns occur before the original calibration finishes + for (let index = 0; index < 10; index++) { + host.setHidden(true); + host.setHidden(false); + } + assert.equal(host.network.requests.length, 1); + assert.equal(oldTime.aborted, false); + oldTime.respond({ serverTime: Date.now() }); await completeTradingBatch(host, tradingDataset(Date.now())); await afterDataMediaResponseTurn(); - const requestCount = host.network.requests.length; - const rendered = host.element('rows').textContent; - assert.equal(oldTime.aborted, true); - await afterDataMediaResponseTurn(); - // Then late calibration cannot change the data or start another request batch - assert.equal(host.network.requests.length, requestCount); - assert.equal(host.element('rows').textContent, rendered); + // Then the original session starts one complete batch and renders current data + assert.equal(host.network.requests.length, 10); + assert.match(host.element('rows').textContent, /200万 ▲/); + assert.equal(host.panel().querySelector('[data-role="funding-countdown"]').textContent, '倒计时 04:00:00'); }); diff --git a/test/dom/data-media-remaining-boundaries.test.js b/test/dom/data-media-remaining-boundaries.test.js index f60be2a..8b266ee 100644 --- a/test/dom/data-media-remaining-boundaries.test.js +++ b/test/dom/data-media-remaining-boundaries.test.js @@ -46,9 +46,7 @@ for (const kind of ['trading', 'cmc']) { // When an ordinary refresh completes while those output elements are absent if (kind === 'trading') { - host.setHidden(false); - const time = await host.network.waitForRequest(request => !request.settled && request.url.pathname.endsWith('/time')); - time.respond({ serverTime: Date.now() }); + host.clock.tick(305_000); await completeTradingBatch(host, tradingDataset(Date.now(), { oi: 3_000_000 })); } else { host.element('refresh').click(); @@ -68,8 +66,8 @@ for (const kind of ['trading', 'cmc']) { for (const { element, parent } of detached) parent.append(element); host.element('collapse').click(); if (kind === 'trading') { - host.setHidden(false); - await activateTradingData(host, tradingDataset(Date.now(), { oi: 4_000_000 })); + host.clock.tick(299_000); + await completeTradingBatch(host, tradingDataset(Date.now(), { oi: 4_000_000 })); } else { host.element('refresh').click(); await completeCmcData(host, cmcDetail({ statistics: { ...cmcDetail().statistics, price: 80_000 } }), { map: false }); @@ -122,10 +120,8 @@ for (const kind of ['trading', 'cmc']) { await host.start(); await completeInitialPanel(host, kind); - // When a repeated browser visibility event refreshes the still-active panel + // When a repeated browser visibility event leaves the active schedule intact host.setHidden(false); - if (kind === 'trading') await activateTradingData(host); - else await completeCmcData(host, cmcDetail(), { map: false }); await afterDataMediaResponseTurn(); const countRequests = () => kind === 'trading' ? host.network.requests.filter(isTradingHistoryRequest).length : host.network.requests.length; diff --git a/test/helpers/data-media-migration-host.js b/test/helpers/data-media-migration-host.js index bd8e348..f037338 100644 --- a/test/helpers/data-media-migration-host.js +++ b/test/helpers/data-media-migration-host.js @@ -140,7 +140,10 @@ export function createDataPanelHost(t, kind, { hidden = value; window.document.dispatchEvent(new window.Event('visibilitychange')); } - t.after(() => { setHidden(true); window.close(); }); + t.after(() => { + window.document.getElementById(`${panelId}-close`)?.click(); + window.close(); + }); return { window, document: window.document, clock, network, errors, panelId, panel: () => window.document.getElementById(panelId), diff --git a/test/unit/binance-data-panel-background.test.js b/test/unit/binance-data-panel-background.test.js new file mode 100644 index 0000000..e7d5e4f --- /dev/null +++ b/test/unit/binance-data-panel-background.test.js @@ -0,0 +1,528 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + activateTradingData, afterDataMediaResponseTurn, cmcDetail, completeCmcData, + completeTradingBatch, createDataPanelHost, isTradingHistoryRequest, tradingDataset, +} from '../helpers/data-media-migration-host.js'; + +async function finishInitial(host, kind) { + if (kind === 'trading') await activateTradingData(host); + else await completeCmcData(host); + await afterDataMediaResponseTurn(); +} + +function fundingRequests(host) { + return host.network.requests.filter(request => request.url.pathname.endsWith('/premiumIndex')); +} + +function read(host, role) { + return host.panel().querySelector(`[data-role="${role}"]`); +} + +async function finishCurrentFunding(host, values = {}) { + const request = await host.network.waitForRequest(request => !request.settled && request.url.pathname.endsWith('/premiumIndex')); + request.respond(tradingDataset(Date.now(), values).premiumIndex); + await afterDataMediaResponseTurn(); +} + +for (const kind of ['trading', 'cmc']) { + test(`user keeps ${kind} data and row nodes through ten quick tab switches without extra requests`, { timeout: 5_000 }, async t => { + // Given a fully rendered panel has a fresh snapshot and stable row nodes + const host = createDataPanelHost(t, kind); + await host.start(); + await finishInitial(host, kind); + const panel = host.panel(); + const rows = [...host.element('rows').children]; + const requestCount = host.network.requests.length; + const updated = kind === 'trading' ? read(host, 'updated-at').textContent : host.element('footer').innerHTML; + const step = kind === 'trading' ? 500 : 1_450; + + // When the user returns ten times before either foreground refresh deadline + for (let index = 0; index < 10; index++) { + host.clock.tick(step); + host.setHidden(true); + host.clock.tick(step); + host.setHidden(false); + host.clock.tick(0); + } + await afterDataMediaResponseTurn(); + + // Then the same data stays visible without Loading, recalibration, or new requests + assert.equal(host.panel(), panel); + assert.deepEqual([...host.element('rows').children], rows); + assert.equal(host.network.requests.length, requestCount); + assert.equal(kind === 'trading' ? read(host, 'updated-at').textContent : host.element('footer').innerHTML, updated); + assert.equal(host.element('rows').textContent.includes('加载中'), false); + if (kind === 'trading') assert.equal(read(host, 'current-funding').textContent, '0.00378%'); + + // When the original foreground deadline arrives after the last return + host.clock.tick((kind === 'trading' ? 15_000 : 30_000) - step * 20 - 1); + assert.equal(host.network.requests.length, requestCount); + host.clock.tick(1); + await afterDataMediaResponseTurn(); + + // Then tab switching has not postponed the next scheduled request + assert.equal(host.network.requests.length, requestCount + 1); + }); + + test(`user returns to an overdue ${kind} snapshot without clearing data or duplicating pending refreshes`, { timeout: 5_000 }, async t => { + // Given a hidden panel has exceeded its foreground interval but not its background interval + const host = createDataPanelHost(t, kind); + await host.start(); + await finishInitial(host, kind); + const rows = [...host.element('rows').children]; + const initialCount = host.network.requests.length; + host.setHidden(true); + host.clock.tick(kind === 'trading' ? 20_000 : 40_000); + + // When repeated returns happen while one due refresh remains pending + host.setHidden(false); + host.clock.tick(0); + for (let index = 0; index < 10; index++) { + host.setHidden(true); + host.setHidden(false); + host.clock.tick(0); + } + await afterDataMediaResponseTurn(); + + // Then only the overdue feed is requested and its previous data remains mounted + assert.deepEqual([...host.element('rows').children], rows); + assert.equal(host.network.requests.length, initialCount + 1); + if (kind === 'trading') { + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, 7); + assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/time')).length, 1); + assert.equal(read(host, 'current-funding').textContent, '0.00378%'); + await finishCurrentFunding(host, { currentFunding: 0.00008 }); + assert.equal(read(host, 'current-funding').textContent, '0.008%'); + } else { + await completeCmcData(host, cmcDetail({ statistics: { ...cmcDetail().statistics, price: 70_000 } }), { map: false }); + await afterDataMediaResponseTurn(); + assert.match(host.element('rows').textContent, /价格\$7万/); + assert.equal(host.network.requests.length, initialCount + 2); + } + }); + + test(`user stops background ${kind} requests immediately when leaving the trading route`, { timeout: 5_000 }, async t => { + // Given an activated panel continues to own its current symbol in a hidden tab + const host = createDataPanelHost(t, kind); + await host.start(); + await finishInitial(host, kind); + host.setHidden(true); + const count = host.network.requests.length; + + // When the hidden page navigates away and remains there through all refresh deadlines + host.navigate('/zh-CN/futures'); + host.clock.tick(3_600_000); + await afterDataMediaResponseTurn(); + + // Then the panel is removed immediately and no business requests run off-route + assert.equal(host.panel(), null); + assert.equal(host.network.requests.length, count); + }); + + test(`user keeps a new background ${kind} symbol when the old symbol response arrives late`, { timeout: 5_000 }, async t => { + // Given the original Bitcoin request is pending when its tab becomes hidden + const host = createDataPanelHost(t, kind); + await host.start(); + if (kind === 'trading') { + host.network.requests[0].respond({ serverTime: Date.now() }); + await host.network.waitForRequest(request => request.url.pathname.endsWith('/fundingRate')); + } + host.setHidden(true); + + // When background navigation completes Ethereum before the old Bitcoin response + host.navigate('/zh-CN/futures/ETHUSDT'); + assert.equal(host.network.requests.filter(request => !request.settled + && (request.url.pathname.endsWith('/time') || request.url.searchParams.get('symbol') === 'ETH')).length, 1); + if (kind === 'trading') { + await activateTradingData(host, tradingDataset(Date.now(), { symbol: 'ETHUSDT', oi: 3000 }), { symbol: 'ETHUSDT' }); + await completeTradingBatch(host, tradingDataset(Date.now()), { includeInterval: false }); + } else { + await completeCmcData(host, cmcDetail({ id: 2, symbol: 'ETH' }), { symbol: 'ETH', slug: 'ethereum' }); + await completeCmcData(host); + } + await afterDataMediaResponseTurn(); + const count = host.network.requests.length; + host.setHidden(false); + host.clock.tick(0); + + // Then returning retains the already loaded Ethereum data without restarting either symbol + assert.match(host.element('symbol').textContent, /^ETH/); + assert.equal(host.network.requests.length, count); + if (kind === 'trading') assert.match(host.element('rows').textContent, /3000 ▼/); + else assert.equal(host.element('footer').querySelector('a').href, 'https://coinmarketcap.com/zh/currencies/ethereum/'); + }); +} + +test('user completes the original trading initialization while hidden without restarting clock or history requests', { timeout: 5_000 }, async t => { + // Given the first server-time request is still pending after panel creation + const host = createDataPanelHost(t, 'trading'); + await host.start(); + const time = host.network.requests[0]; + const panel = host.panel(); + + // When the user hides the tab before calibration and every initial response finishes + host.setHidden(true); + assert.equal(time.aborted, false); + await activateTradingData(host); + host.setHidden(false); + host.clock.tick(0); + await afterDataMediaResponseTurn(); + + // Then the original session renders a calibrated current rate with one request batch + assert.equal(host.panel(), panel); + assert.equal(host.network.requests.length, 10); + assert.match(host.element('rows').textContent, /200万 ▲/); + assert.equal(read(host, 'current-funding').textContent, '0.00378%'); + assert.equal(read(host, 'funding-countdown').textContent, '倒计时 04:00:00'); +}); + +test('user completes a CMC request in the background and returns to its updated snapshot', { timeout: 5_000 }, async t => { + // Given mapping is complete while the first CMC detail request is pending + const host = createDataPanelHost(t, 'cmc'); + await host.start(); + host.network.requests[0].respond({ data: [{ id: 1, symbol: 'BTC', slug: 'bitcoin', is_active: 1 }] }); + const detail = await host.network.waitForRequest(request => request.url.pathname.endsWith('/detail')); + + // When the detail and holder requests complete after hiding the tab + host.setHidden(true); + detail.respond({ data: cmcDetail() }); + const holders = await host.network.waitForRequest(request => request.url.pathname.endsWith('/show_holders')); + holders.respond({ data: { showFlag: true, count: 10_000 } }); + await afterDataMediaResponseTurn(); + + // Then the original request publishes its snapshot without waiting for tab activation + assert.match(host.element('rows').textContent, /价格\$6万/); + assert.equal(host.network.requests.length, 3); + const rows = [...host.element('rows').children]; + + // When the user returns after that background completion + host.setHidden(false); + host.clock.tick(0); + + // Then the same completed snapshot needs no additional request or Loading state + assert.deepEqual([...host.element('rows').children], rows); + assert.equal(host.network.requests.length, 3); +}); + +test('user receives CMC background snapshots every five minutes with a fresh foreground deadline after completion', { timeout: 5_000 }, async t => { + // Given a completed CMC snapshot is kept in a hidden tab + const host = createDataPanelHost(t, 'cmc'); + await host.start(); + await finishInitial(host, 'cmc'); + host.setHidden(true); + + // When two successive background deadlines receive new snapshots + for (const price of [70_000, 80_000]) { + const count = host.network.requests.length; + host.clock.tick(299_999); + assert.equal(host.network.requests.length, count); + host.clock.tick(1); + await afterDataMediaResponseTurn(); + assert.equal(host.network.requests.length, count + 1); + await completeCmcData(host, cmcDetail({ statistics: { ...cmcDetail().statistics, price } }), { map: false }); + await afterDataMediaResponseTurn(); + } + host.setHidden(false); + host.clock.tick(29_999); + + // Then background updates cost one request chain per deadline and stay fresh on return + assert.equal(host.network.requests.length, 7); + assert.match(host.element('rows').textContent, /价格\$8万/); + host.clock.tick(1); + await afterDataMediaResponseTurn(); + assert.equal(host.network.requests.length, 8); +}); + +test('user keeps trading history on five-minute boundaries while current funding refreshes once per background minute', { timeout: 5_000 }, async t => { + // Given trading data is fully loaded before the tab becomes hidden + const host = createDataPanelHost(t, 'trading'); + await host.start(); + await activateTradingData(host); + host.setHidden(true); + + // When five background minutes elapse with completed current funding requests + for (let minute = 1; minute <= 5; minute++) { + host.clock.tick(59_999); + assert.equal(fundingRequests(host).length, minute); + host.clock.tick(1); + assert.equal(fundingRequests(host).length, minute + 1); + await finishCurrentFunding(host); + } + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, 7); + host.clock.tick(4_999); + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, 7); + host.clock.tick(1); + await completeTradingBatch(host, tradingDataset(Date.now(), { oi: 3_000_000 })); + await afterDataMediaResponseTurn(); + + // Then one historical cycle publishes after its existing five-second boundary delay + assert.equal(fundingRequests(host).length, 6); + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, 14); + assert.match(host.element('rows').textContent, /300万 ▲/); + assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/time')).length, 1); +}); + +test('user resumes the trading countdown immediately without drawing it every background second', { timeout: 5_000 }, async t => { + // Given the displayed current funding has a calibrated four-hour countdown + const host = createDataPanelHost(t, 'trading'); + await host.start(); + await activateTradingData(host); + const countdown = read(host, 'funding-countdown'); + const updated = read(host, 'updated-at').textContent; + + // When the tab spends ten seconds hidden and then becomes visible + host.setHidden(true); + host.clock.tick(10_000); + assert.equal(countdown.textContent, '倒计时 04:00:00'); + host.setHidden(false); + + // Then elapsed time is reflected immediately without changing data provenance + assert.equal(countdown.textContent, '倒计时 03:59:50'); + assert.equal(read(host, 'elapsed').textContent, '10秒前'); + assert.equal(read(host, 'updated-at').textContent, updated); + assert.equal(host.network.requests.length, 10); +}); + +test('user keeps a CMC failure visible across tab switches until the next refresh is due', { timeout: 5_000 }, async t => { + // Given the first CMC mapping request has reported a concrete network failure + const host = createDataPanelHost(t, 'cmc'); + await host.start(); + host.network.requests[0].fail('error'); + await afterDataMediaResponseTurn(); + const error = host.element('rows').textContent; + assert.match(error, /CMC API request failed/); + + // When repeated tab switches take place during the failed request's cooldown + for (let index = 0; index < 10; index++) { + host.setHidden(true); + host.clock.tick(1_000); + host.setHidden(false); + host.clock.tick(0); + } + + // Then visibility cannot erase the failure or generate an immediate retry loop + assert.equal(host.element('rows').textContent, error); + assert.equal(host.network.requests.length, 1); + host.clock.tick(19_999); + assert.equal(host.network.requests.length, 1); + host.clock.tick(1); + assert.equal(host.network.requests.length, 2); +}); + +test('user keeps funding failure provenance across tab switches until its retry deadline', { timeout: 5_000 }, async t => { + // Given a displayed rate remains cached after the scheduled funding request fails + const host = createDataPanelHost(t, 'trading'); + await host.start(); + await activateTradingData(host); + host.clock.tick(15_000); + fundingRequests(host).at(-1).respond({}, 403); + await afterDataMediaResponseTurn(); + + // When the user switches tabs repeatedly within the fifteen-second cooldown + for (let index = 0; index < 10; index++) { + host.setHidden(true); + host.clock.tick(1_000); + host.setHidden(false); + host.clock.tick(0); + } + + // Then the cache reason and rate stay visible without another clock or historical request + assert.equal(read(host, 'current-funding').textContent, '0.00378%'); + assert.equal(read(host, 'funding-status').textContent, '缓存 · 更新失败'); + assert.match(read(host, 'funding-status').title, /HTTP 403/); + assert.equal(fundingRequests(host).length, 2); + assert.equal(host.network.requests.length, 11); + host.clock.tick(4_999); + assert.equal(fundingRequests(host).length, 2); + host.clock.tick(1); + assert.equal(fundingRequests(host).length, 3); +}); + +test('user preserves the pending historical retry deadline across visibility changes', { timeout: 5_000 }, async t => { + // Given one period endpoint is delayed and scheduled to retry ten seconds after the first attempt + const host = createDataPanelHost(t, 'trading'); + const previous = tradingDataset(Date.now() - 300_000); + await host.start(); + await activateTradingData(host, previous); + host.clock.tick(5_000); + const partial = tradingDataset(Date.now()); + partial.basis = previous.basis; + await completeTradingBatch(host, partial); + await afterDataMediaResponseTurn(); + + // When the user repeatedly hides and returns before the pending retry is due + for (let index = 0; index < 9; index++) { + host.setHidden(true); + host.clock.tick(1_000); + host.setHidden(false); + host.clock.tick(0); + } + host.clock.tick(999); + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, 14); + host.clock.tick(1); + + // Then the existing deadline retries only the delayed endpoint without restarting a full batch + const pending = host.network.requests.filter(request => !request.settled && isTradingHistoryRequest(request)); + assert.deepEqual(pending.map(request => request.url.pathname), ['/futures/data/basis']); + assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/time')).length, 1); +}); + +test('user waits for the latest publication window after background timers are delayed across multiple periods', { timeout: 5_000 }, async t => { + // Given an active historical schedule is retained while the tab is hidden + const host = createDataPanelHost(t, 'trading'); + await host.start(); + await activateTradingData(host); + host.setHidden(true); + + // When browser scheduling resumes one second into a much later five-minute period + host.clock.tick(1_201_000); + await afterDataMediaResponseTurn(); + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, 7); + host.clock.tick(3_999); + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, 7); + host.clock.tick(1); + + // Then only the latest period receives one complete batch after its five-second publication delay + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, 14); + await completeTradingBatch(host, tradingDataset(Date.now(), { oi: 3_000_000 })); + await afterDataMediaResponseTurn(); + assert.match(host.element('rows').textContent, /300万 ▲/); +}); + +test('user discards a historical response from an expired background cycle and fetches the current period once', { timeout: 5_000 }, async t => { + // Given a five-minute refresh is pending after an earlier snapshot has rendered + const host = createDataPanelHost(t, 'trading'); + await host.start(); + await activateTradingData(host); + const updated = read(host, 'updated-at').textContent; + host.clock.tick(305_000); + const pending = host.network.requests.filter(request => !request.settled && isTradingHistoryRequest(request)); + const old = tradingDataset(Date.now(), { oi: 9_000_000 }); + host.setHidden(true); + + // When the old response arrives only after its entire publication window has expired + host.clock.tick(300_000); + pending.forEach(request => request.respond(old[request.url.pathname.split('/').at(-1)])); + await afterDataMediaResponseTurn(); + + // Then obsolete data cannot advance the displayed snapshot's timestamp or replace its values + assert.equal(read(host, 'updated-at').textContent, updated); + assert.match(host.element('rows').textContent, /200万 ▲/); + host.clock.tick(0); + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, 21); +}); + +test('user schedules CMC refresh from request completion instead of repeatedly resetting or overlapping a slow response', { timeout: 5_000 }, async t => { + // Given initial mapping succeeds but its snapshot takes five seconds to finish + const host = createDataPanelHost(t, 'cmc'); + await host.start(); + host.network.requests[0].respond({ data: [{ id: 1, symbol: 'BTC', slug: 'bitcoin', is_active: 1 }] }); + await host.network.waitForRequest(request => request.url.pathname.endsWith('/detail')); + host.clock.tick(5_000); + await completeCmcData(host, cmcDetail(), { map: false }); + await afterDataMediaResponseTurn(); + + // When the tab is hidden and shown during the next completion-based interval + host.setHidden(true); + host.clock.tick(29_999); + host.setHidden(false); + host.clock.tick(0); + await afterDataMediaResponseTurn(); + + // Then a completed snapshot stays fresh for thirty seconds despite the slow initial request + assert.equal(host.network.requests.length, 3); + host.clock.tick(1); + await afterDataMediaResponseTurn(); + assert.equal(host.network.requests.length, 4); +}); + +test('user discards an initial history batch delayed beyond its publication window', { timeout: 5_000 }, async t => { + // Given calibration has completed but the first historical batch is still pending + const host = createDataPanelHost(t, 'trading'); + await host.start(); + host.network.requests[0].respond({ serverTime: Date.now() }); + await host.network.waitForRequest(request => request.url.pathname.endsWith('/fundingRate')); + const pending = host.network.requests.filter(isTradingHistoryRequest); + const old = tradingDataset(Date.now(), { oi: 9_000_000 }); + const initial = host.element('rows').querySelector('[data-metric="oi"] .td-number').textContent; + + // When initial history arrives after the background tab has crossed two publication windows + host.setHidden(true); + host.clock.tick(605_000); + pending.forEach(request => request.respond(old[request.url.pathname.split('/').at(-1)])); + await afterDataMediaResponseTurn(); + + // Then the late batch cannot certify a fresh snapshot and only the current window is requested + assert.equal(host.element('rows').querySelector('[data-metric="oi"] .td-number').textContent, initial); + assert.equal(read(host, 'updated-at').textContent, '等待数据'); + host.clock.tick(0); + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, 14); +}); + +test('user immediately loses an expired current funding quote on returning from a hidden settlement boundary', { timeout: 5_000 }, async t => { + // Given the latest authoritative funding quote expires in two seconds + const host = createDataPanelHost(t, 'trading'); + const dataset = tradingDataset(Date.now()); + dataset.premiumIndex.nextFundingTime = Date.now() + 2_000; + await host.start(); + await activateTradingData(host, dataset); + const history = host.panel().querySelector('[data-metric="funding"] svg'); + const updated = read(host, 'updated-at').textContent; + + // When settlement passes while hidden and the user returns before another quote is due + host.setHidden(true); + host.clock.tick(3_000); + host.setHidden(false); + + // Then the old quote is immediately withdrawn without fabricating a new period or replacing history + assert.equal(read(host, 'current-funding').textContent, '--'); + assert.equal(read(host, 'funding-countdown').textContent, '倒计时 等待更新'); + assert.equal(host.panel().querySelector('[data-metric="funding"] svg'), history); + assert.equal(read(host, 'updated-at').textContent, updated); + assert.equal(host.network.requests.length, 10); +}); + +for (const phase of ['initial', 'periodic']) { + test(`user rejects an old ${phase} trading response after a background language change precedes route detection`, { timeout: 5_000 }, async t => { + // Given Binance retained a native history method and one Chinese-path history batch is pending + const host = createDataPanelHost(t, 'trading'); + const nativePushState = host.window.history.pushState; + await host.start(); + if (phase === 'initial') { + host.network.requests[0].respond({ serverTime: Date.now() }); + await host.network.waitForRequest(request => request.url.pathname.endsWith('/fundingRate')); + } else { + await activateTradingData(host); + host.clock.tick(305_000); + } + const pending = host.network.requests.filter(request => !request.settled && isTradingHistoryRequest(request)); + assert.equal(pending.length, 7); + const previousValue = host.element('rows').querySelector('[data-metric="oi"] .td-number').textContent; + const updated = read(host, 'updated-at').textContent; + host.setHidden(true); + + // When the host replaces the patched method and the old response returns before the route watchdog + host.window.history.pushState = nativePushState; + host.navigate('/en/futures/BTCUSDT'); + const old = tradingDataset(Date.now(), { oi: 9_000_000 }); + pending.forEach(request => request.respond(old[request.url.pathname.split('/').at(-1)])); + await afterDataMediaResponseTurn(); + + // Then old-path results cannot update values or freshness even though the symbol still matches + assert.equal(host.element('rows').querySelector('[data-metric="oi"] .td-number').textContent, previousValue); + assert.equal(read(host, 'updated-at').textContent, updated); + + // When the retained watchdog discovers the new path and its requests complete + host.clock.tick(5_000); + await activateTradingData(host, tradingDataset(Date.now(), { oi: 3_000_000 })); + + // Then the new path owns English data and subsequent returns do not reinitialize it + assert.equal(host.element('rows').querySelector('[data-metric="oi"] .td-number').textContent, '3M ▲'); + assert.equal(host.element('close').getAttribute('aria-label'), 'Close'); + const count = host.network.requests.length; + host.setHidden(false); + host.clock.tick(0); + assert.equal(host.network.requests.length, count); + }); +} diff --git a/test/unit/binance-data-panel-lifecycle-regressions.test.js b/test/unit/binance-data-panel-lifecycle-regressions.test.js index 2935ab5..a600f0d 100644 --- a/test/unit/binance-data-panel-lifecycle-regressions.test.js +++ b/test/unit/binance-data-panel-lifecycle-regressions.test.js @@ -189,30 +189,29 @@ for (const kind of ['trading', 'cmc']) { }); } -for (const state of ['hidden', 'off-route']) { +for (const state of ['closed', 'off-route']) { test(`user starts no trading data batch when server synchronization finishes after becoming ${state}`, { timeout: 5_000 }, async t => { // Given initial server time is pending while the panel shows its loading state const host = createDataPanelHost(t, 'trading'); await host.start(); const timeRequest = host.network.requests[0]; const panel = host.panel(); - const initialRows = host.element('rows').innerHTML; - // When the route or document becomes inactive before synchronization completes - if (state === 'hidden') host.setHidden(true); + // When the panel is closed or its route becomes inactive before synchronization completes + if (state === 'closed') host.element('close').click(); else host.navigate('/zh-CN/futures'); await afterDataMediaResponseTurn(); host.clock.tick(60_000); // Then cancellation prevents data requests and leaves no late render assert.equal(timeRequest.aborted, true); - assert.equal(host.panel(), state === 'hidden' ? panel : null); - if (state === 'hidden') assert.equal(host.element('rows').innerHTML, initialRows); + assert.equal(host.panel(), state === 'closed' ? panel : null); + if (state === 'closed') assert.equal(panel.style.display, 'none'); assert.deepEqual(host.network.requests.map(request => request.url.pathname), ['/fapi/v1/time']); }); } -for (const state of ['closed', 'hidden', 'off-route']) { +for (const state of ['closed', 'off-route']) { test(`user receives no late CMC error after the loading panel becomes ${state}`, { timeout: 5_000 }, async t => { // Given the CMC panel is waiting for its initial asset mapping const host = createDataPanelHost(t, 'cmc'); @@ -224,7 +223,6 @@ for (const state of ['closed', 'hidden', 'off-route']) { // When the panel becomes inactive before the mapping reports failure if (state === 'closed') host.element('close').click(); - else if (state === 'hidden') host.setHidden(true); else host.navigate('/zh-CN/futures'); mapping.fail('error'); await afterDataMediaResponseTurn(); @@ -237,7 +235,7 @@ for (const state of ['closed', 'hidden', 'off-route']) { }); } -for (const state of ['closed', 'hidden', 'off-route', 'new symbol']) { +for (const state of ['closed', 'off-route', 'new symbol']) { test(`user ignores an old trading cycle response after entering ${state}`, { timeout: 5_000 }, async t => { // Given an already-rendered panel has started the next scheduled five-minute refresh const host = createDataPanelHost(t, 'trading'); @@ -252,7 +250,6 @@ for (const state of ['closed', 'hidden', 'off-route', 'new symbol']) { // When lifecycle invalidation or a symbol change precedes the old cycle completion if (state === 'closed') host.element('close').click(); - else if (state === 'hidden') host.setHidden(true); else if (state === 'off-route') host.navigate('/zh-CN/futures'); else { host.navigate('/zh-CN/futures/ETHUSDT'); @@ -275,7 +272,7 @@ for (const state of ['closed', 'hidden', 'off-route', 'new symbol']) { }); } -for (const state of ['closed', 'hidden', 'off-route']) { +for (const state of ['closed', 'off-route']) { test(`user receives no stale trading render after the panel becomes ${state}`, { timeout: 5_000 }, async t => { // Given the panel exists but its initial data responses remain pending const host = createDataPanelHost(t, 'trading'); @@ -288,7 +285,6 @@ for (const state of ['closed', 'hidden', 'off-route']) { // When the lifecycle is invalidated before the pending responses finish if (state === 'closed') host.element('close').click(); - else if (state === 'hidden') host.setHidden(true); else host.navigate('/zh-CN/futures'); await completeTradingBatch(host, tradingDataset(Date.now()), { includeInterval: false }); await afterDataMediaResponseTurn(); @@ -300,21 +296,24 @@ for (const state of ['closed', 'hidden', 'off-route']) { }); } -test('user stops the trading server clock sync while the document is hidden', { timeout: 5_000 }, async t => { +test('user retains hourly trading server synchronization while the document is hidden', { timeout: 5_000 }, async t => { // Given one initial server-time synchronization has completed const host = createDataPanelHost(t, 'trading'); await host.start(); await activateTradingData(host); const timeCount = () => host.network.requests.filter(request => request.url.pathname.endsWith('/time')).length; - // When an hourly deadline occurs and the following hour is spent hidden + // When successive hourly deadlines occur while the initialized tab remains hidden + host.setHidden(true); host.clock.tick(3_600_000); assert.equal(timeCount(), 2); - host.setHidden(true); + const request = host.network.requests.find(request => !request.settled && request.url.pathname.endsWith('/time')); + request.respond({ serverTime: Date.now() }); + await afterDataMediaResponseTurn(); host.clock.tick(3_600_000); - // Then the hidden panel cannot perform another server synchronization - assert.equal(timeCount(), 2); + // Then the same session maintains its hourly calibration without tab reactivation + assert.equal(timeCount(), 3); }); test('user opens a correctly escaped CMC link when its asset slug contains quotes', { timeout: 5_000 }, async t => {