diff --git a/docs/binance-strategy27-events-development.md b/docs/binance-strategy27-events-development.md index 6ddf16f..6710040 100644 --- a/docs/binance-strategy27-events-development.md +++ b/docs/binance-strategy27-events-development.md @@ -35,7 +35,7 @@ errors/timeouts and protocol-validated HTTP 503 unavailability as recoverable: it retains the current cursor and displayed history, shows a reconnecting status, and retries after two seconds. A bootstrap 503 remains in bootstrap; a live `redis_unavailable` response retries the same live cursor. Stale cursors still -use the existing reset/bootstrap contract. Other HTTP/gateway errors, malformed +use the reset/bootstrap protocol while retaining verified display history. Other HTTP/gateway errors, malformed JSON, cursor violations, and rendering contract failures stop immediately. A terminal ordinary-job failure suspends drawing and polling without deleting @@ -95,8 +95,21 @@ be later than that snapshot's end when an ineligible bucket advances the event to its lifecycle deadline without joining the event. The script stores only its own returned marker IDs and its bounded in-memory -panel records. Route, symbol, interval, epoch and explicit cursor resets remove -only those transient entities. Sequence/cursor contract violations stop the +panel records. Route, symbol, interval and manual clear remove only those +transient entities. Ordinary stream epochs and cursor resets reset protocol +validation while preserving verified display history in the same chart context. +The independent ordinary display registry retains at most 80 events (including +neutral events), evicts by last observation time with event ID as the tie-break, +and expires after two hours without another observation. Reset messages and +bootstrap replay do not refresh its observation timestamps. Protocol lifecycle +pruning does not delete this independently bounded display history. +Old panel records display Historical until that event receives another accepted +observation; this flag changes neither the last received event status nor the +protocol phase. Same-event replay merges by the source event ID, which is stable +across transport epochs, and preserves the first directional marker. A bootstrap +merges its records instead of deleting history absent from the new epoch's +snapshot. Full reload still depends on the server snapshot and cannot recover +previous-epoch records that were never retained by the server. Sequence/cursor contract violations stop the ordinary job with an error while retaining its already verified history. Marker count and age are bounded on the chart; the panel retains at most eight events. @@ -108,7 +121,8 @@ message arrives. Compound candidates restore only missing parts of their icon/label pair, preserving the original slot and surviving entity IDs. Each record shares one in-flight repair across timer and message callbacks. Cleanup skips IDs proven absent, while native removal failures still stop the owning job. -Clear, reset, context changes and retention eviction invalidate repair ownership; +Manual clear, context changes and display retention eviction invalidate ordinary +repair ownership; compound resets also invalidate their own repair ownership; late-created entities are removed instead of resurrecting retired records. Reconciliation does not refresh retention timestamps. Drawings remain transient and use `disableSave: true`, but a full page reload requests a bounded display @@ -124,7 +138,7 @@ ADR 032 in CorsairQuant owns the server-side rule and transport contract. The browser does not reconstruct candidates from ordinary events or recalculate market evidence. The client, lifecycle, panel, native chart layer and optional-job controller are wired into the entrypoint and tested together. The source and -generated install artifact are version 0.4.3 with identical metadata headers. +generated install artifact are version 0.4.4 with identical metadata headers. The generated artifact passes syntax, release-contract and isolated execution checks, including candidate delivery, paired entities, clear and context stop. Binance operator-page validation remains outstanding. Server/gateway rollout @@ -241,3 +255,14 @@ Live validation must confirm the current Binance main-world chart API, exact `1S` resolution, exact route symbol, successful create/readback/remove behavior, Tampermonkey source readback, and the actually loaded source after a hard reload. A source-level method name alone is not end-to-end proof. + +## 2026-09-06 ordinary stream recovery evidence + +The loaded 0.4.3 Binance script was observed through non-pausing CDP probes on +BTRUSDT at 1S. At 03:47:33.716 UTC a successful response delivered +stream_state / transport_recovered with a new runtime epoch. The entrypoint +then reached stream_reset_clear with two ordinary markers and eight panel rows; +the next response had zero ordinary markers. Earlier event_closed responses +had retained those markers. Version 0.4.4 separates protocol reset from bounded +display ownership. This evidence identifies the browser deletion path; it does +not distinguish the underlying server Redis connection error from a timeout. diff --git a/scripts/binance-strategy27-events.user.js b/scripts/binance-strategy27-events.user.js index e3badff..85f43da 100644 --- a/scripts/binance-strategy27-events.user.js +++ b/scripts/binance-strategy27-events.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy27.events // @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.4.3 +// @version 0.4.4 // @author jackhai9 // @description 在 Binance 一秒图表标注 VPS Strategy 27 的实时订单流候选观察 // @match https://www.binance.com/*/futures/* @@ -1627,11 +1627,12 @@ styles: { color: selectedKind === "compound" ? annotation.titleColor : annotation.markerColor ?? "#EAECEF", fontWeight: "700", flex: "1" } })); title.appendChild(createElement(document, "span", { - text: selectedKind === "compound" ? "探索版" : STATUS_LABELS[annotation.status], + text: selectedKind === "compound" ? "探索版" : record.historical ? "Historical" : STATUS_LABELS[annotation.status], styles: { color: "#848E9C", fontSize: "11px" } })); detail.appendChild(title); appendDetailLine(document, detail, "时间", formatClock(annotation.eventTimeMs)); + if (record.historical) appendDetailLine(document, detail, "History", "Stream restarted; showing the last received observation.", "#F0B90B"); if (selectedKind === "compound") { for (const row of annotation.detailRows) appendDetailLine(document, detail, row.label, row.value); const identity = createElement(document, "details", { role: "compound-identity", styles: { color: "#848E9C" } }); @@ -1682,6 +1683,7 @@ }); row.type = "button"; row.dataset.eventId = eventId; + if (record.historical) row.dataset.historical = "true"; row.title = `${annotation.title}|${annotation.summary}`; row.appendChild(createElement(document, "span", { styles: { @@ -1751,6 +1753,11 @@ removeCompound(eventId) { removeRecord(compoundRecords, eventId); }, + /** Retain facts and selection without presenting a previous stream as live. */ + retainHistory() { + for (const record of records.values()) record.historical = true; + render(); + }, clear() { records.clear(); reconcileSelection(); @@ -2704,13 +2711,29 @@ removeStrategy27StatusView(pageDocument); statusView = null; } + function removeOrdinaryEvent(context, eventId) { + context.ordinaryHistory.delete(eventId); + context.layer.remove(eventId); + context.panel.remove(eventId); + context.candidatePresentations.delete(eventId); + } function pruneOrdinaryEvents(context) { - for (const eventId of context.lifecycle.prune(Date.now())) { - context.layer.remove(eventId); - context.panel.remove(eventId); - context.candidatePresentations.delete(eventId); + const now = Date.now(); + context.lifecycle.prune(now); + for (const [eventId, observedAtMs] of context.ordinaryHistory) { + if (now - observedAtMs > MAX_EVENT_AGE_MS) removeOrdinaryEvent(context, eventId); } } + function retainOrdinaryEvent(context, eventId, observedAtMs) { + const previous = context.ordinaryHistory.get(eventId); + const retainedAtMs = previous === void 0 ? observedAtMs : Math.max(previous, observedAtMs); + context.ordinaryHistory.set(eventId, retainedAtMs); + const oldestFirst = [...context.ordinaryHistory].sort(([leftId, leftTime], [rightId, rightTime]) => leftTime - rightTime || leftId.localeCompare(rightId)); + while (context.ordinaryHistory.size > MAX_RETAINED_EVENTS) { + removeOrdinaryEvent(context, oldestFirst.shift()[0]); + } + return retainedAtMs; + } function failOrdinary(context, error) { if (error.name === "AbortError" || active !== context || context.failed) return; context.failed = true; @@ -2735,9 +2758,7 @@ pruneOrdinaryEvents(context); if (response.status === "reset") { context.lifecycle.reset(response.reason); - context.layer.clear(); - context.panel.clear(); - context.candidatePresentations.clear(); + context.panel.retainHistory(); hideStatus(); return; } @@ -2747,9 +2768,7 @@ runtimeEpoch: response.runtime_epoch, observedAtMs: response.bootstrap_observed_at_ms }); - context.layer.clear(); - context.panel.clear(); - context.candidatePresentations.clear(); + context.panel.retainHistory(); const bySequence = /* @__PURE__ */ new Map(); for (const record of response.records) { for (const message of [record.marker_envelope, record.event_envelope, record.outcome_envelope]) { @@ -2766,19 +2785,14 @@ for (const message of messages) { if (active !== context || context.failed) return; const action = context.lifecycle.apply(message); - for (const eventId of action.evictedEventIds ?? []) { - context.layer.remove(eventId); - context.panel.remove(eventId); - context.candidatePresentations.delete(eventId); - } if (action.type === "stream_reset") { - context.layer.clear(); - context.panel.clear(); - context.candidatePresentations.clear(); + context.panel.retainHistory(); hideStatus(); continue; } if (action.type === "event_evicted") continue; + const retainedAtMs = retainOrdinaryEvent(context, action.eventId, action.observedAtMs); + if (!context.ordinaryHistory.has(action.eventId)) continue; const annotation = stabilizeCandidatePresentation( context.candidatePresentations, action.eventId, @@ -2793,9 +2807,9 @@ event_closed: "renderClosed", event_outcome: "renderOutcome" }[action.messageKind]; - const rendered = await context.layer[renderMethod](action.eventId, annotation, action.observedAtMs); - if (!rendered || active !== context || context.failed) continue; - context.panel.upsert(action.eventId, annotation, action.observedAtMs); + const rendered = await context.layer[renderMethod](action.eventId, annotation, retainedAtMs); + if (!rendered || active !== context || context.failed || !context.ordinaryHistory.has(action.eventId)) continue; + context.panel.upsert(action.eventId, annotation, retainedAtMs); hideStatus(); } if (response.status === "bootstrap") { @@ -2825,6 +2839,7 @@ savePosition: (position) => GM_setValue(PANEL_POSITION_KEY, position) }), candidatePresentations: /* @__PURE__ */ new Map(), + ordinaryHistory: /* @__PURE__ */ new Map(), reconciliation: null, failed: false }; @@ -2944,6 +2959,8 @@ active?.compound.clear(); active?.layer.clear(); active?.panel.clear(); + active?.ordinaryHistory.clear(); + active?.candidatePresentations.clear(); if (!active?.failed) hideStatus(); }); GM_registerMenuCommand("Reconnect Strategy 27 and restore history", restart); diff --git a/src/binance-strategy27-events/dom/strategy27-event-panel.js b/src/binance-strategy27-events/dom/strategy27-event-panel.js index 2c2dc2a..0d75386 100644 --- a/src/binance-strategy27-events/dom/strategy27-event-panel.js +++ b/src/binance-strategy27-events/dom/strategy27-event-panel.js @@ -316,11 +316,12 @@ export function createStrategy27EventPanel(document, chartRoot, { styles: { color: selectedKind === 'compound' ? annotation.titleColor : annotation.markerColor ?? '#EAECEF', fontWeight: '700', flex: '1' }, })); title.appendChild(createElement(document, 'span', { - text: selectedKind === 'compound' ? '探索版' : STATUS_LABELS[annotation.status], + text: selectedKind === 'compound' ? '探索版' : record.historical ? 'Historical' : STATUS_LABELS[annotation.status], styles: { color: '#848E9C', fontSize: '11px' }, })); detail.appendChild(title); appendDetailLine(document, detail, '时间', formatClock(annotation.eventTimeMs)); + if (record.historical) appendDetailLine(document, detail, 'History', 'Stream restarted; showing the last received observation.', '#F0B90B'); if (selectedKind === 'compound') { for (const row of annotation.detailRows) appendDetailLine(document, detail, row.label, row.value); const identity = createElement(document, 'details', { role: 'compound-identity', styles: { color: '#848E9C' } }); @@ -371,6 +372,7 @@ export function createStrategy27EventPanel(document, chartRoot, { }); row.type = 'button'; row.dataset.eventId = eventId; + if (record.historical) row.dataset.historical = 'true'; row.title = `${annotation.title}|${annotation.summary}`; row.appendChild(createElement(document, 'span', { styles: { @@ -445,6 +447,11 @@ export function createStrategy27EventPanel(document, chartRoot, { removeCompound(eventId) { removeRecord(compoundRecords, eventId); }, + /** Retain facts and selection without presenting a previous stream as live. */ + retainHistory() { + for (const record of records.values()) record.historical = true; + render(); + }, clear() { records.clear(); reconcileSelection(); diff --git a/src/binance-strategy27-events/index.user.js b/src/binance-strategy27-events/index.user.js index 6d85828..dac0306 100644 --- a/src/binance-strategy27-events/index.user.js +++ b/src/binance-strategy27-events/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy27.events // @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.4.3 +// @version 0.4.4 // @author jackhai9 // @description 在 Binance 一秒图表标注 VPS Strategy 27 的实时订单流候选观察 // @match https://www.binance.com/*/futures/* @@ -86,12 +86,33 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; statusView = null; } + function removeOrdinaryEvent(context, eventId) { + context.ordinaryHistory.delete(eventId); + context.layer.remove(eventId); + context.panel.remove(eventId); + context.candidatePresentations.delete(eventId); + } + function pruneOrdinaryEvents(context) { - for (const eventId of context.lifecycle.prune(Date.now())) { - context.layer.remove(eventId); - context.panel.remove(eventId); - context.candidatePresentations.delete(eventId); + const now = Date.now(); + context.lifecycle.prune(now); + for (const [eventId, observedAtMs] of context.ordinaryHistory) { + if (now - observedAtMs > MAX_EVENT_AGE_MS) removeOrdinaryEvent(context, eventId); + } + } + + /** Transport epochs reset validation, not the lifetime of verified chart evidence. */ + function retainOrdinaryEvent(context, eventId, observedAtMs) { + const previous = context.ordinaryHistory.get(eventId); + const retainedAtMs = previous === undefined ? observedAtMs : Math.max(previous, observedAtMs); + context.ordinaryHistory.set(eventId, retainedAtMs); + const oldestFirst = [...context.ordinaryHistory].sort(([leftId, leftTime], [rightId, rightTime]) => ( + leftTime - rightTime || leftId.localeCompare(rightId) + )); + while (context.ordinaryHistory.size > MAX_RETAINED_EVENTS) { + removeOrdinaryEvent(context, oldestFirst.shift()[0]); } + return retainedAtMs; } function failOrdinary(context, error) { @@ -120,9 +141,7 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; pruneOrdinaryEvents(context); if (response.status === 'reset') { context.lifecycle.reset(response.reason); - context.layer.clear(); - context.panel.clear(); - context.candidatePresentations.clear(); + context.panel.retainHistory(); hideStatus(); return; } @@ -133,9 +152,7 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; runtimeEpoch: response.runtime_epoch, observedAtMs: response.bootstrap_observed_at_ms, }); - context.layer.clear(); - context.panel.clear(); - context.candidatePresentations.clear(); + context.panel.retainHistory(); const bySequence = new Map(); for (const record of response.records) { for (const message of [record.marker_envelope, record.event_envelope, record.outcome_envelope]) { @@ -153,19 +170,14 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; for (const message of messages) { if (active !== context || context.failed) return; const action = context.lifecycle.apply(message); - for (const eventId of action.evictedEventIds ?? []) { - context.layer.remove(eventId); - context.panel.remove(eventId); - context.candidatePresentations.delete(eventId); - } if (action.type === 'stream_reset') { - context.layer.clear(); - context.panel.clear(); - context.candidatePresentations.clear(); + context.panel.retainHistory(); hideStatus(); continue; } if (action.type === 'event_evicted') continue; + const retainedAtMs = retainOrdinaryEvent(context, action.eventId, action.observedAtMs); + if (!context.ordinaryHistory.has(action.eventId)) continue; const annotation = stabilizeCandidatePresentation( context.candidatePresentations, action.eventId, @@ -180,9 +192,9 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; event_closed: 'renderClosed', event_outcome: 'renderOutcome', }[action.messageKind]; - const rendered = await context.layer[renderMethod](action.eventId, annotation, action.observedAtMs); - if (!rendered || active !== context || context.failed) continue; - context.panel.upsert(action.eventId, annotation, action.observedAtMs); + const rendered = await context.layer[renderMethod](action.eventId, annotation, retainedAtMs); + if (!rendered || active !== context || context.failed || !context.ordinaryHistory.has(action.eventId)) continue; + context.panel.upsert(action.eventId, annotation, retainedAtMs); hideStatus(); } if (response.status === 'bootstrap') { @@ -213,6 +225,7 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; savePosition: (position) => GM_setValue(PANEL_POSITION_KEY, position), }), candidatePresentations: new Map(), + ordinaryHistory: new Map(), reconciliation: null, failed: false, }; @@ -339,6 +352,8 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; active?.compound.clear(); active?.layer.clear(); active?.panel.clear(); + active?.ordinaryHistory.clear(); + active?.candidatePresentations.clear(); if (!active?.failed) hideStatus(); }); GM_registerMenuCommand('Reconnect Strategy 27 and restore history', restart); diff --git a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js index 0d95cfe..24bf169 100644 --- a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js +++ b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js @@ -448,3 +448,133 @@ for (const generated of [false, true]) { assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); }); } + +function ordinaryStreamReset(epoch = 'c'.repeat(32)) { + return { ...ordinaryMessage(), runtime_epoch: epoch, sequence: 1, + message_kind: 'stream_state', symbol: null, event_id: null, + observed_at_ms: 7000, event_time_ms: 7000, data_status: 'ready', + payload: { state: 'ready', reason: 'transport_recovered' } }; +} + +for (const generated of [false, true]) { + test(`${generated ? 'generated' : 'source'} transport epoch reset retains historical arrows and rehydrates without duplication`, async (t) => { + const h = await harness(t, { generated }); + await h.ordinaryBootstrap(); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [ordinaryMessage()] }); + await until(() => h.pending('ordinary').length === 1); + const ids = [...h.shapes.keys()]; + assert.equal(ids.length, 2); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '2-0', next_cursor: '3-0', messages: [ordinaryStreamReset()] }); + await until(() => h.pending('ordinary').length === 1); + assert.deepEqual([...h.shapes.keys()], ids); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); + assert.match(h.page.document.body.textContent, /Historical/); + const replay = { ...ordinaryMessage(), runtime_epoch: 'c'.repeat(32), sequence: 2 }; + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '3-0', next_cursor: '4-0', messages: [replay] }); + await until(() => h.pending('ordinary').length === 1); + assert.deepEqual([...h.shapes.keys()], ids); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); + assert.doesNotMatch(h.page.document.body.textContent, /Historical/); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '4-0', next_cursor: '5-0', messages: [ordinaryStreamReset('d'.repeat(32))] }); + await until(() => h.pending('ordinary').length === 1); + h.setNow(7207001); + h.tick(); + await new Promise(setImmediate); + assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); + }); +} + +for (const generated of [false, true]) { + test(`${generated ? 'generated' : 'source'} stale cursor bootstrap merges retained history and context exit removes it`, async (t) => { + const h = await harness(t, { generated }); + await h.ordinaryBootstrap(); + const ordinary = ordinaryMessage(); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [ordinary] }); + await until(() => h.pending('ordinary').length === 1); + const ids = [...h.shapes.keys()]; + await h.respond('ordinary', { schema_version: 1, status: 'reset', reason: 'stale_cursor', requested_cursor: '2-0', next_cursor: '5-0', messages: [] }, 409); + await until(() => h.pending('ordinary').length === 1); + assert.deepEqual([...h.shapes.keys()], ids); + await h.respond('ordinary', { schema_version: 1, status: 'bootstrap', projection_kind: 'strategy27_events', requested_cursor: null, + next_cursor: '6-0', runtime_epoch: ordinary.runtime_epoch, last_sequence: 2, bootstrap_observed_at_ms: 7000, + records: [{ event_id: ordinary.event_id, event_envelope: ordinary, marker_envelope: ordinary, outcome_envelope: null }] }); + await until(() => h.pending('ordinary').length === 1); + assert.deepEqual([...h.shapes.keys()], ids); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); + h.setResolution('1'); + h.tick(); + assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); + }); + + test(`${generated ? 'generated' : 'source'} retained display history stays bounded across epochs and manual clear removes it`, async (t) => { + const h = await harness(t, { generated }); + await h.ordinaryBootstrap(); + const messages = Array.from({ length: 80 }, (_, i) => ({ ...ordinaryMessage(), sequence: i + 2, event_id: i.toString(16).padStart(64, '0') })); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '81-0', messages }); + await until(() => h.pending('ordinary').length === 1); + assert.equal(h.shapes.size, 81); + const firstId = [...h.shapes.keys()][1]; + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '81-0', next_cursor: '82-0', messages: [ordinaryStreamReset()] }); + await until(() => h.pending('ordinary').length === 1); + assert.equal(h.shapes.size, 81); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '82-0', next_cursor: '83-0', messages: [{ ...ordinaryMessage(), runtime_epoch: 'c'.repeat(32), sequence: 2 }] }); + await until(() => h.pending('ordinary').length === 1); + assert.equal(h.shapes.size, 81); + assert.equal(h.shapes.has(firstId), false); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 8); + h.clear(); + assert.deepEqual([...h.shapes.keys()], ['user-owned']); + h.tick(); + await new Promise(setImmediate); + assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); + }); +} + +test('display capacity uses last observation rather than insertion order after epoch rehydration', async (t) => { + const h = await harness(t); + await h.ordinaryBootstrap(); + const events = Array.from({ length: 80 }, (_, i) => ({ ...ordinaryMessage(), sequence: i + 2, event_id: i.toString(16).padStart(64, '0') })); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '81-0', messages: events }); + await until(() => h.pending('ordinary').length === 1); + const ids = [...h.shapes.keys()]; + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '81-0', next_cursor: '82-0', messages: [ordinaryStreamReset(), { ...events[0], runtime_epoch: 'c'.repeat(32), sequence: 2, observed_at_ms: 7000 }] }); + await until(() => h.pending('ordinary').length === 1); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '82-0', next_cursor: '83-0', messages: [{ ...ordinaryMessage(), runtime_epoch: 'c'.repeat(32), sequence: 3, observed_at_ms: 7000 }] }); + await until(() => h.pending('ordinary').length === 1); + assert.equal(h.shapes.size, 81); + assert.equal(h.shapes.has(ids[1]), true); + assert.equal(h.shapes.has(ids[2]), false); + h.setNow(7202001); + h.tick(); + await new Promise(setImmediate); + assert.equal(h.shapes.size, 3); + assert.equal(h.shapes.has(ids[1]), true); +}); + +test('older bootstrap replay cannot shorten the retained display lifetime', async (t) => { + const h = await harness(t); + await h.ordinaryBootstrap(); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [{ ...ordinaryMessage(), observed_at_ms: 7000 }] }); + await until(() => h.pending('ordinary').length === 1); + const ids = [...h.shapes.keys()]; + await h.respond('ordinary', { schema_version: 1, status: 'reset', reason: 'stale_cursor', requested_cursor: '2-0', next_cursor: '5-0', messages: [] }, 409); + await until(() => h.pending('ordinary').length === 1); + const old = ordinaryMessage(); + await h.respond('ordinary', { schema_version: 1, status: 'bootstrap', projection_kind: 'strategy27_events', requested_cursor: null, + next_cursor: '6-0', runtime_epoch: old.runtime_epoch, last_sequence: 2, bootstrap_observed_at_ms: 7000, + records: [{ event_id: old.event_id, event_envelope: old, marker_envelope: old, outcome_envelope: null }] }); + await until(() => h.pending('ordinary').length === 1); + h.setNow(7202001); + h.tick(); + await new Promise(setImmediate); + assert.deepEqual([...h.shapes.keys()], ids); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); + h.setNow(7207001); + h.tick(); + await new Promise(setImmediate); + assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); +}); diff --git a/test/dom/binance-strategy27-events/strategy27-event-panel.test.js b/test/dom/binance-strategy27-events/strategy27-event-panel.test.js index 2063b5e..cf19b12 100644 --- a/test/dom/binance-strategy27-events/strategy27-event-panel.test.js +++ b/test/dom/binance-strategy27-events/strategy27-event-panel.test.js @@ -230,3 +230,29 @@ test('drags the panel by its header, persists the bounded position, and ignores document.dispatchEvent(new dom.window.MouseEvent('mouseup', { bubbles: true })); assert.equal(savedPositions.length, 1); }); + +test('transport history marking preserves selection, facts, bounds and compound ownership', () => { + const dom = loadFixtureDom('
'); + const { document } = dom.window; + const panel = createStrategy27EventPanel(document, document.querySelector('.chart-widget-root'), panelOptions(2)); + panel.upsert('old', { ...annotation(), status: 'active' }, 1100); + panel.upsert('latest', { ...annotation({ time: 2000 }), status: 'complete' }, 2100); + panel.upsertCompound('compound', compoundAnnotation(3000), 3100); + document.querySelector('[data-event-id="old"]').click(); + panel.retainHistory(); + panel.retainHistory(); + assert.equal(panel.size, 2); + assert.equal(panel.compoundSize, 1); + const detail = document.querySelector('[data-role="event-detail"]'); + assert.match(detail.textContent, /Historical/); + assert.match(detail.textContent, /12\.3K USDT/); + assert.equal(detail.textContent.split('Stream restarted; showing the last received observation.').length, 2); + panel.upsert('latest', { ...annotation({ time: 2000 }), status: 'complete' }, 2200); + assert.match(detail.textContent, /Historical/); + panel.upsert('old', { ...annotation(), status: 'complete' }, 2300); + assert.doesNotMatch(detail.textContent, /Historical|Stream restarted/); + assert.match(detail.textContent, /已结束/); + assert.equal(panel.compoundSize, 1); + panel.destroy(); + dom.window.close(); +}); diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index 20d3372..d4dc4c8 100644 --- a/test/unit/userscript-release-contract.test.js +++ b/test/unit/userscript-release-contract.test.js @@ -63,7 +63,7 @@ test('release contract identifies the generated Strategy 27 annotation artifact' assert.equal(contract.name, '【自写】Binance Strategy 27 事件标注'); assert.equal(contract.namespace, 'binance.strategy27.events'); - assert.equal(contract.version, '0.4.3'); + assert.equal(contract.version, '0.4.4'); assert.equal(contract.runAt, 'document-idle'); assert.equal(contract.updateURL, contract.downloadURL); assert.deepEqual(contract.matches, [