diff --git a/scripts/run-browser-mock-e2e.mjs b/scripts/run-browser-mock-e2e.mjs index 7c41942..b05e64f 100644 --- a/scripts/run-browser-mock-e2e.mjs +++ b/scripts/run-browser-mock-e2e.mjs @@ -6,10 +6,10 @@ import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const pnpm = process.env.npm_execpath || (process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'); const serverUrl = 'http://127.0.0.1:5173/'; const readyDeadlineMs = 40_000; const viteCli = resolve(root, 'node_modules', 'vite', 'bin', 'vite.js'); +const wdioCli = resolve(root, 'node_modules', '@wdio/cli', 'bin', 'wdio.js'); let vite; function delay(milliseconds) { @@ -91,7 +91,12 @@ async function main() { }); if (startError) throw startError; await waitForServer(); - await run(pnpm, ['run', 'e2e:browser', ...process.argv.slice(2)]); + await run(process.execPath, [ + wdioCli, + 'run', + 'wdio.browser-mock.config.mjs', + ...process.argv.slice(2), + ]); } try { diff --git a/src/features/app-shell/__tests__/navigation-status-components.test.ts b/src/features/app-shell/__tests__/navigation-status-components.test.ts index 2717844..d683bb6 100644 --- a/src/features/app-shell/__tests__/navigation-status-components.test.ts +++ b/src/features/app-shell/__tests__/navigation-status-components.test.ts @@ -16,7 +16,6 @@ import ModbusRegisterRow from '@/features/terminal/ui/ModbusRegisterRow.vue'; import ParserConfigBar from '@/features/terminal/ui/ParserConfigBar.vue'; import ParserFrameDetail from '@/features/terminal/ui/ParserFrameDetail.vue'; import ParserStatsBar from '@/features/terminal/ui/ParserStatsBar.vue'; -import ParserBytePager from '@/features/terminal/ui/ParserBytePager.vue'; import ParserCborTree from '@/features/terminal/ui/ParserCborTree.vue'; import AppSelect from '@/design-system/AppSelect.vue'; import ChecksumPanel from '@/features/send-panel/ui/ChecksumPanel.vue'; @@ -95,11 +94,25 @@ const packetVirtualMocks = vi.hoisted(() => ({ onScroll: vi.fn(), scrollToIndex: vi.fn(), options: null as { + frameCount?: { readonly value: number }; + rowSize?: (index: number) => number; itemKey?: (index: number) => string | number; rowSizeVersion?: { readonly value: unknown }; } | null, })); +const parserRecordListMocks = vi.hoisted(() => ({ + measureElement: vi.fn(), + onScroll: vi.fn(), + scrollToIndex: vi.fn(), + options: null as { + recordCount?: { readonly value: number }; + listKind?: { readonly value: 'legacy' | 'smp' }; + autoScroll?: { readonly value: boolean }; + itemKey?: (index: number) => string | number; + } | null, +})); + vi.mock('@/features/sessions/application/use-session-actions', () => ({ useSessionActions: () => sessionActions, })); @@ -130,17 +143,29 @@ vi.mock('@/features/sessions', async (importOriginal) => ({ })); vi.mock('@/features/terminal/application/use-packet-virtual-scroll', async () => { - const { ref } = await import('vue'); + const { computed, ref, unref } = await import('vue'); return { usePacketVirtualScroll: (options: NonNullable) => { packetVirtualMocks.options = options; + const virtualItems = computed(() => { + const count = Math.max(0, Number(unref(options.frameCount)) || 0); + const limit = Math.min(count, 64); + const size = options.rowSize?.(0) ?? 28; + return Array.from({ length: limit }, (_, index) => ({ + index, + start: index * size, + size, + })); + }); + const totalSize = computed(() => { + const count = Math.max(0, Number(unref(options.frameCount)) || 0); + const size = options.rowSize?.(0) ?? 28; + return count * size; + }); return { scrollRef: ref(null), - virtualItems: ref([ - { index: 0, start: 0, size: 28 }, - { index: 1, start: 28, size: 28 }, - ]), - totalSize: ref(56), + virtualItems, + totalSize, measureElement: packetVirtualMocks.measureElement, onScroll: packetVirtualMocks.onScroll, scrollToIndex: packetVirtualMocks.scrollToIndex, @@ -149,13 +174,92 @@ vi.mock('@/features/terminal/application/use-packet-virtual-scroll', async () => }; }); +vi.mock('@/features/terminal/application/use-parser-record-list', async () => { + const { computed, ref, unref } = await import('vue'); + const { PARSER_LIST_ROW_HEIGHT } = await import('@/lib/parser-virtual-list.ts'); + return { + useParserRecordList: (options: NonNullable) => { + parserRecordListMocks.options = options; + const virtualRows = computed(() => { + const count = Math.max(0, Number(unref(options.recordCount)) || 0); + const limit = Math.min(count, 64); + return Array.from({ length: limit }, (_, index) => ({ + index, + start: index * PARSER_LIST_ROW_HEIGHT, + size: PARSER_LIST_ROW_HEIGHT, + })); + }); + return { + scrollRef: ref(null), + virtualRows, + visibleListColumns: computed(() => { + const kind = unref(options.listKind); + if (kind === 'smp') { + return [ + { key: 'idx', start: 0, size: 56 }, + { key: 'direction', start: 56, size: 40 }, + { key: 'transaction', start: 96, size: 56 }, + { key: 'route', start: 152, size: 280 }, + ]; + } + return [ + { key: 'idx', start: 0, size: 56 }, + { key: 'direction', start: 56, size: 40 }, + { key: 'framing', start: 176, size: 84 }, + { key: 'hex', start: 260, size: 462 }, + { key: 'copy', start: 722, size: 28 }, + ]; + }), + listTotalHeight: computed( + () => Math.max(0, Number(unref(options.recordCount)) || 0) * PARSER_LIST_ROW_HEIGHT, + ), + listTotalWidth: computed(() => 900), + measureElement: parserRecordListMocks.measureElement, + onScroll: parserRecordListMocks.onScroll, + scrollToIndex: parserRecordListMocks.scrollToIndex, + }; + }, + }; +}); + vi.mock('@tanstack/vue-virtual', async () => { - const { ref } = await import('vue'); + const { computed, unref } = await import('vue'); return { - useVirtualizer: () => - ref({ - getTotalSize: () => 32, - getVirtualItems: () => [{ index: 0, start: 0, size: 32 }], + useVirtualizer: (options: unknown) => + computed(() => { + const opts = (unref(options) ?? {}) as { + count?: number; + estimateSize?: (index: number) => number; + paddingStart?: number; + paddingEnd?: number; + gap?: number; + }; + const count = Math.max(0, Number(opts.count) || 0); + const limit = Math.min(count, 32); + const items: Array<{ + index: number; + start: number; + size: number; + end: number; + key: number; + lane: number; + }> = []; + let start = opts.paddingStart ?? 0; + const gap = opts.gap ?? 0; + for (let index = 0; index < limit; index += 1) { + const size = opts.estimateSize?.(index) ?? 32; + items.push({ index, start, size, end: start + size, key: index, lane: 0 }); + start += size + gap; + } + const tail = Math.max(0, count - limit); + const lastSize = opts.estimateSize?.(Math.max(0, limit - 1)) ?? 32; + const total = + (limit === 0 ? 0 : start - gap) + (opts.paddingEnd ?? 0) + tail * (lastSize + gap); + return { + getTotalSize: () => Math.max(0, total), + getVirtualItems: () => items, + scrollToIndex: () => undefined, + }; }), }; }); @@ -246,6 +350,10 @@ beforeEach(() => { packetVirtualMocks.onScroll.mockReset(); packetVirtualMocks.scrollToIndex.mockReset(); packetVirtualMocks.options = null; + parserRecordListMocks.measureElement.mockReset(); + parserRecordListMocks.onScroll.mockReset(); + parserRecordListMocks.scrollToIndex.mockReset(); + parserRecordListMocks.options = null; }); afterEach(() => { @@ -892,7 +1000,7 @@ test('ParserFrameDetail reserves Header and CBOR tabs for SMP records', () => { expect(smp.text()).toContain('CBOR'); }); -test('ParserFrameDetail keeps HEX byte cells separate from the fixed-width ASCII column', () => { +test('ParserFrameDetail keeps HEX byte cells separate from the fixed-width ASCII column', async () => { const data = new TextEncoder().encode('ABCDEFGHIJKLMNOP'); const wrapper = mount(ParserFrameDetail, { props: { frame: { offset: 0, data }, dump: [] }, @@ -903,6 +1011,54 @@ test('ParserFrameDetail keeps HEX byte cells separate from the fixed-width ASCII expect(byteCells).toHaveLength(16); expect(byteCells.every((cell) => /^[0-9A-F]{2}$/.test(cell.text()))).toBe(true); expect(row.get('.dump-ascii').text()).toBe('ABCDEFGHIJKLMNOP'); + expect(wrapper.find('.byte-pager').exists()).toBe(false); + expect(wrapper.get('.byte-dump').classes()).toContain('scrollbar-thin'); + expect(wrapper.get('.byte-dump-space').attributes('style')).toMatch(/height:/); + expect(wrapper.get('.byte-dump-space').attributes('style')).toMatch(/width:/); + expect(byteCells[0].attributes('style')).toMatch(/translateX\(/); + + const rawTab = wrapper + .findAll('[role="tab"]') + .find((tab) => tab.text().includes('Raw') || tab.text().includes('原始')); + expect(rawTab).toBeDefined(); + await rawTab!.trigger('click'); + expect(wrapper.find('.byte-pager').exists()).toBe(false); + expect(wrapper.get('.byte-dump').exists()).toBe(true); + expect(wrapper.get('.byte-dump-space').attributes('style')).toMatch(/width:/); + expect(wrapper.get('.byte-dump-space').text()).toMatch(/41|AB/); + const dumpPanels = wrapper.findAll('.detail-content.is-virtual-dump'); + expect(dumpPanels.filter((panel) => panel.attributes('hidden') !== undefined)).toHaveLength(1); + expect( + dumpPanels.some((panel) => !panel.attributes('hidden') && panel.find('.byte-dump').exists()), + ).toBe(true); +}); + +test('ParserFrameDetail raw dump falls back to payload when transport bytes are empty', async () => { + const wrapper = mount(ParserFrameDetail, { + props: { + frame: { + offset: 0, + data: new Uint8Array([0x50, 0x51]), + transportData: new Uint8Array(), + }, + }, + }); + const rawTab = wrapper + .findAll('[role="tab"]') + .find((tab) => tab.text().includes('Raw') || tab.text().includes('原始')); + expect(rawTab).toBeDefined(); + await rawTab!.trigger('click'); + expect(wrapper.find('.dump-empty').exists()).toBe(false); + expect(wrapper.get('.byte-dump-space').text()).toContain('50'); + expect(wrapper.get('.byte-dump-space').text()).toContain('51'); + expect( + wrapper + .findAll('[role="tabpanel"]') + .filter( + (panel) => + panel.classes().includes('is-virtual-dump') && panel.attributes('hidden') === undefined, + ), + ).toHaveLength(1); }); test('Parser CBOR tree bounds scalar DOM and pages every map entry', async () => { @@ -927,14 +1083,6 @@ test('Parser CBOR tree bounds scalar DOM and pages every map entry', async () => expect(tree.text()).toContain('key-149'); }); -test('Parser byte pager displays an inclusive final byte offset', () => { - const wrapper = mount(ParserBytePager, { - props: { page: 0, pageCount: 1, start: 0, end: 2, total: 2 }, - }); - expect(wrapper.text()).toContain('0–1'); - expect(wrapper.text()).not.toContain('0–2'); -}); - test('ModbusRegisterRow updates read and write register state while preserving typed row actions', async () => { const sessions = setupSessions(); const sessionId = sessions.createSession('COM-row', config); @@ -1461,7 +1609,7 @@ test('ParserPanel edits resident parser settings, filters/selects parsed frames, }, }); await wrapper.vm.$nextTick(); - expect(packetVirtualMocks.scrollToIndex).toHaveBeenCalledWith(1); + expect(parserRecordListMocks.scrollToIndex).toHaveBeenCalledWith(1); expect(wrapper.find('.parser-dropped-stat').exists()).toBe(false); await wrapper.setProps({ droppedFrames: 3, droppedBytes: 42 }); expect(wrapper.get('.parser-dropped-stat').text()).toContain('Retention evicted'); @@ -1471,6 +1619,9 @@ test('ParserPanel edits resident parser settings, filters/selects parsed frames, expect(wrapper.get('.parser-dropped-stat').text()).toContain('保留窗口淘汰'); expect(wrapper.get('.parser-dropped-stat').text()).toContain('3 帧 / 42 B'); expect(wrapper.findAll('.pp-frame')).toHaveLength(2); + expect(wrapper.get('.pp-virtual-space').attributes('style')).toMatch(/width:/); + expect(wrapper.findAll('.pp-cell').length).toBeGreaterThan(0); + expect(wrapper.get('.pp-cell-hex').attributes('style')).toMatch(/translateX\(/); await wrapper.findAll('.pp-frame')[0].trigger('click'); expect(wrapper.find('.pp-frame').classes()).toContain('selected'); await wrapper.find('.pp-copy').trigger('click'); @@ -1514,6 +1665,29 @@ test('ParserPanel edits resident parser settings, filters/selects parsed frames, expect(wrapper.emitted('close')).toEqual([[]]); }); +test('ParserPanel record list renders more than a single viewport of TX/RX messages', async () => { + const sessions = setupSessions(); + const sessionId = sessions.createSession('COM-parser-many', config); + const parsedFrames = Array.from({ length: 20 }, (_, index) => ({ + offset: index, + data: new Uint8Array([index]), + direction: index % 2 === 0 ? ('RX' as const) : ('TX' as const), + })); + const wrapper = mount(ParserPanel, { + props: { + sessionId, + parsedFrames, + droppedFrames: 0, + droppedBytes: 0, + throughputBps: 0, + parserResetVersion: 0, + }, + }); + await wrapper.vm.$nextTick(); + expect(wrapper.findAll('.pp-frame')).toHaveLength(20); + expect(wrapper.get('.pp-virtual-space').attributes('style')).toMatch(/height:\s*880px/); +}); + test('ParserPanel inspector supports bounded pointer and keyboard resizing across both layouts', async () => { await ensureLocaleLoaded('en'); setLocale('en'); @@ -1589,7 +1763,7 @@ test('ParserPanel inspector supports bounded pointer and keyboard resizing acros 'aria-valuetext': '440px', }); expect(handle.attributes('aria-controls')).toBe(inspector.attributes('id')); - expect(handle.element.previousElementSibling).toBe(body.get('.pp-list').element); + expect(handle.element.previousElementSibling).toBe(body.get('.pp-list-root').element); expect(handle.element.nextElementSibling).toBe(inspector.element); expect((inspector.element as HTMLElement).style.width).toBe('440px'); diff --git a/src/features/sessions/__tests__/session-protocol-runtime.test.ts b/src/features/sessions/__tests__/session-protocol-runtime.test.ts index 8e463e7..f671eb2 100644 --- a/src/features/sessions/__tests__/session-protocol-runtime.test.ts +++ b/src/features/sessions/__tests__/session-protocol-runtime.test.ts @@ -429,7 +429,7 @@ test('resident protocol parser rejects invalid injected limits', () => { ); }); -test('SMP history replay is asynchronous, capture ordered, and excludes MCUmgr trace', () => { +test('SMP history replay is asynchronous, capture ordered, and consumes the shared TX/RX timeline', () => { const replay = fakeReplayScheduler(); const parser = new SessionProtocolRuntime({ replayScheduler: replay.scheduler, @@ -486,17 +486,15 @@ test('SMP history replay is asynchronous, capture ordered, and excludes MCUmgr t replay.drain(); const records = parser.snapshot().frames; - const diagnostics = records.filter((record) => !('header' in record) || !record.header); - assert.equal(diagnostics.length, 1, 'originless history produces one aggregated warning'); - assert.equal(diagnostics[0].diagnostics[0]?.code, 'smp.runtime.untrusted-origin'); const messages = records.filter((record) => 'header' in record && record.header); assert.deepEqual( messages.map((record) => record.captureSeq), - [1, 3], + [0, 1, 2, 3], + 'shared timeline frames are parsed regardless of origin metadata', ); assert.deepEqual( messages.map((record) => ('direction' in record ? record.direction : undefined)), - ['RX', 'TX'], + ['RX', 'RX', 'RX', 'TX'], ); assert.ok(changes > 0, 'time-sliced replay publishes progress'); }); @@ -667,6 +665,50 @@ test('SMP ignores a cancelled expiry callback after a newer timer is armed', () assert.equal(records[0].diagnostics?.[0]?.code, 'smp.raw.timeout'); }); +test('SMP live capture parses MCUmgr traces and correlates their request/response', () => { + const parser = new SessionProtocolRuntime(); + parser.configure( + { + kind: 'mcumgr-smp', + transport: 'raw-uart', + maxPacketBytes: 1024, + reassemblyTimeoutMs: 3000, + }, + [], + { replayHistory: false }, + ); + const request = rawSmpMessage(0, 11); + const response = rawSmpMessage(1, 11); + + parser.syncCaptureTimeline([ + { + captureSeq: 0, + direction: 'TX', + origin: 'mcumgr-trace', + timestamp: 10, + data: request, + }, + { + captureSeq: 1, + direction: 'RX', + origin: 'mcumgr-trace', + timestamp: 25, + data: response, + }, + ]); + + const records = parser.snapshot().frames; + assert.equal(records.length, 2); + const [requestRecord, responseRecord] = records; + assert.ok('header' in requestRecord && requestRecord.header); + assert.ok('header' in responseRecord && responseRecord.header); + assert.equal(requestRecord.direction, 'TX'); + assert.equal(responseRecord.direction, 'RX'); + assert.equal(responseRecord.requestId, requestRecord.id); + assert.equal(requestRecord.responseId, responseRecord.id); + assert.equal(responseRecord.rttMs, 15); +}); + test('SMP keeps TX and RX reassembly independent while consuming the capture timeline', () => { const parser = new SessionProtocolRuntime(); parser.configure( diff --git a/src/features/sessions/__tests__/session-runtime-controller.test.ts b/src/features/sessions/__tests__/session-runtime-controller.test.ts index 00bb591..7728a5f 100644 --- a/src/features/sessions/__tests__/session-runtime-controller.test.ts +++ b/src/features/sessions/__tests__/session-runtime-controller.test.ts @@ -463,7 +463,7 @@ test('resident controller publishes bounded-parser drop counters and resets them scope.stop(); }); -test('controller SMP mode consumes ordered serial capture, ignores trace, and honors no-replay Apply', async () => { +test('controller SMP mode consumes ordered serial capture, MCUmgr traces, and honors no-replay Apply', async () => { const { id, runtime, scope, serial, store } = setup(); store.addFrame(id, { direction: 'RX', @@ -502,13 +502,16 @@ test('controller SMP mode consumes ordered serial capture, ignores trace, and ho }); await vi.advanceTimersByTimeAsync(17); - assert.equal(runtime.parser.frames.value.length, 1); + assert.equal(runtime.parser.frames.value.length, 2); assert.deepEqual( runtime.parser.frames.value.map((record) => ({ captureSeq: record.captureSeq, direction: 'direction' in record ? record.direction : undefined, })), - [{ captureSeq: 2, direction: 'TX' }], + [ + { captureSeq: 1, direction: 'RX' }, + { captureSeq: 2, direction: 'TX' }, + ], ); await runtime.dispose(); diff --git a/src/features/sessions/runtime/session-protocol-runtime.ts b/src/features/sessions/runtime/session-protocol-runtime.ts index 59d2b22..c0653f1 100644 --- a/src/features/sessions/runtime/session-protocol-runtime.ts +++ b/src/features/sessions/runtime/session-protocol-runtime.ts @@ -54,13 +54,10 @@ type ProtocolCaptureFrame = Pick & interface ReplayJob { readonly generation: number; readonly history: readonly ProtocolCaptureFrame[]; - readonly untrustedOriginCount: number; - readonly untrustedOriginBoundary?: ProtocolCaptureFrame; historyIndex: number; live: ProtocolCaptureFrame[]; liveIndex: number; liveBytes: number; - untrustedOriginWarningEmitted: boolean; } export const DEFAULT_SESSION_PROTOCOL_MAX_FRAMES = 5_000; @@ -74,9 +71,10 @@ const DEFAULT_REPLAY_FRAMES_PER_SLICE = 128; * Resident protocol data plane for one serial session. * * Legacy byte framers stay attached to native RX so capture/UI publication - * cannot delay stream reassembly. SMP instead consumes the retained capture - * timeline: that is the only existing session boundary which contains both TX - * and RX with their capture sequence, origin and timestamp. + * cannot delay stream reassembly. SMP instead consumes the shared session + * TX/RX timeline used by every other module: native serial, MCUmgr wire + * replay, paused capture, and presentation all publish onto that one stream. + * Frame `origin` is display attribution, not a consumption filter. */ export class SessionProtocolRuntime { private readonly maxFrames: number; @@ -204,9 +202,9 @@ export class SessionProtocolRuntime { } /** - * Consume one native RX chunk. SMP deliberately ignores this path because - * origin, TX traffic, timestamp and capture ordering only exist on the - * capture timeline. + * Consume one native RX chunk. SMP ignores this path so it cannot double- + * ingest bytes that already land on the shared TX/RX timeline (including + * MCUmgr replay, which broadcasts RX before capture publication). */ feed(bytes: Uint8Array, now = Date.now()): boolean { if (!this.byteParser || bytes.length === 0) return false; @@ -227,16 +225,15 @@ export class SessionProtocolRuntime { if (!this.captureInitialized) { this.updateCaptureBoundary(timeline); - const eligible = timeline.filter(isSerialCaptureFrame); if (this.replayJob) { - this.queueReplayLive(eligible); - return this.recordLiveThroughput(totalBytes(eligible), now); + this.queueReplayLive(timeline); + return this.recordLiveThroughput(totalBytes(timeline), now); } const before = this.liveFrameCount(); - this.feedSmpFrames(eligible); + this.feedSmpFrames(timeline); this.armSmpExpiry(); return ( - this.recordLiveThroughput(totalBytes(eligible), now) || this.liveFrameCount() !== before + this.recordLiveThroughput(totalBytes(timeline), now) || this.liveFrameCount() !== before ); } @@ -260,18 +257,16 @@ export class SessionProtocolRuntime { } this.updateCaptureBoundary(timeline); - const eligible = appended.filter(isSerialCaptureFrame); - const bytes = totalBytes(eligible); + const bytes = totalBytes(appended); const throughputChanged = this.recordLiveThroughput(bytes, now); - if (eligible.length === 0) return throughputChanged; if (this.replayJob) { - this.queueReplayLive(eligible); + this.queueReplayLive(appended); return throughputChanged; } const before = this.liveFrameCount(); - this.feedSmpFrames(eligible); + this.feedSmpFrames(appended); this.armSmpExpiry(); return throughputChanged || this.liveFrameCount() !== before; } @@ -351,29 +346,16 @@ export class SessionProtocolRuntime { private startReplay(timeline: readonly ProtocolCaptureFrame[]): void { if (!this.smpParser) return; - const history: ProtocolCaptureFrame[] = []; - let untrustedOriginCount = 0; - let untrustedOriginBoundary: ProtocolCaptureFrame | undefined; - for (const frame of timeline) { - if (isSerialCaptureFrame(frame)) { - history.push(frame); - } else if (frame.origin === undefined) { - untrustedOriginCount += 1; - untrustedOriginBoundary = frame; - } - } - if (history.length === 0 && untrustedOriginCount === 0) return; + const history = timeline.filter((frame) => frame.data.length > 0); + if (history.length === 0) return; const generation = ++this.replayGeneration; this.replayJob = { generation, history, - untrustedOriginCount, - ...(untrustedOriginBoundary ? { untrustedOriginBoundary } : {}), historyIndex: 0, live: [], liveIndex: 0, liveBytes: 0, - untrustedOriginWarningEmitted: false, }; this.scheduleReplay(generation); } @@ -431,21 +413,6 @@ export class SessionProtocolRuntime { const before = this.liveFrameCount(); let processed = 0; - if (!job.untrustedOriginWarningEmitted && job.untrustedOriginCount > 0) { - job.untrustedOriginWarningEmitted = true; - const boundary = job.untrustedOriginBoundary; - this.pushParsedFrame( - parser.diagnostic({ - direction: boundary?.direction ?? 'RX', - timestamp: boundary?.timestamp ?? Date.now(), - captureSeq: boundary?.captureSeq, - code: 'smp.runtime.untrusted-origin', - message: `Skipped ${job.untrustedOriginCount} historical capture frame(s) without a verifiable serial origin`, - severity: 'warning', - }), - ); - } - while ( processed < this.replayFramesPerSlice && (processed === 0 || this.replayScheduler.now() - startedAt < this.replayTimeSliceMs) @@ -492,7 +459,7 @@ export class SessionProtocolRuntime { } private feedSmpFrame(frame: ProtocolCaptureFrame): void { - if (!this.smpParser || !isSerialCaptureFrame(frame) || frame.data.length === 0) return; + if (!this.smpParser || frame.data.length === 0) return; const timestamp = frame.timestamp ?? Date.now(); this.updateSmpClock(timestamp); const records = this.smpParser.feed({ @@ -629,10 +596,6 @@ function isEmptyDelimiter(config: ParserConfig): boolean { return config.kind === 'delimiter' && config.delimiter.length === 0; } -function isSerialCaptureFrame(frame: ProtocolCaptureFrame): boolean { - return frame.origin === 'serial-rx' || frame.origin === 'serial-tx'; -} - function hasCaptureSequenceGap( frames: readonly ProtocolCaptureFrame[], previousSequence: number, diff --git a/src/features/sessions/ui/SessionView.vue b/src/features/sessions/ui/SessionView.vue index 4fa764a..09cb264 100644 --- a/src/features/sessions/ui/SessionView.vue +++ b/src/features/sessions/ui/SessionView.vue @@ -443,8 +443,13 @@ function handleExportCancel() { overflow: hidden; } +/* Must stay a flex container: every display panel sizes itself with `flex: 1`, + which silently resolves to content height in a block box and leaves long + lists clipped by the `overflow: hidden` below instead of scrolling. */ .display-area { flex: 1; + display: flex; + flex-direction: column; overflow: hidden; min-height: 0; } diff --git a/src/features/terminal/__tests__/display-panel-layout.test.ts b/src/features/terminal/__tests__/display-panel-layout.test.ts new file mode 100644 index 0000000..a1eeb3e --- /dev/null +++ b/src/features/terminal/__tests__/display-panel-layout.test.ts @@ -0,0 +1,59 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +/** + * SessionView renders every display mode into `.display-area`. Panels size + * themselves with `flex: 1`, which only resolves against the viewport while + * that container stays a flex box — as a block box it collapses to content + * height and long lists get clipped by `overflow: hidden` instead of + * scrolling. Both halves of that contract are asserted here: the container + * stays flex, and each panel root also pins its own height so neither change + * alone can silently reintroduce the clipping. + */ +const DISPLAY_AREA_PANELS = [ + ['src/features/terminal/ui/DataPacketList.vue', 'packet-list'], + ['src/features/terminal/ui/ParserPanel.vue', 'parser-panel'], + ['src/features/terminal/ui/ModbusPanel.vue', 'modbus-panel'], + ['src/features/terminal/ui/WaveformPanel.vue', 'waveform-panel'], + ['src/features/terminal/ui/SerialShellPanel.vue', 'shell-panel'], + ['src/features/terminal/ui/mcumgr/McumgrPanel.vue', 'mcumgr-panel'], +] as const; + +function rootRule(path: string, className: string): string { + const source = readFileSync(path, 'utf8'); + const match = new RegExp(`^\\.${className}\\s*\\{([^}]*)\\}`, 'm').exec(source); + assert.ok(match, `${path} is missing a top-level .${className} rule`); + return match[1]; +} + +test('display-area stays a flex container so panel `flex: 1` resolves', () => { + const rule = rootRule('src/features/sessions/ui/SessionView.vue', 'display-area'); + assert.match(rule, /display:\s*flex/); + assert.match(rule, /flex-direction:\s*column/); + assert.match(rule, /min-height:\s*0/); +}); + +test('display-area panels size themselves so inner lists scroll instead of clipping', () => { + for (const [path, className] of DISPLAY_AREA_PANELS) { + assert.match( + rootRule(path, className), + /height:\s*100%/, + `.${className} in ${path} must declare height: 100%`, + ); + } +}); + +test('parser record list owns a bounded scrollport under the panel body', () => { + const panel = readFileSync('src/features/terminal/ui/ParserPanel.vue', 'utf8'); + const list = readFileSync('src/features/terminal/ui/ParserRecordList.vue', 'utf8'); + + // The body is the row flex container; the list root stretches inside it and + // the scrollport takes the remaining space with its own overflow. + assert.match(panel, /\.parser-body\s*\{[^}]*flex:\s*1/); + assert.match(panel, /\.parser-body\s*\{[^}]*min-height:\s*0/); + assert.match(list, /\.pp-list-root\s*\{[^}]*flex:\s*1/); + assert.match(list, /\.pp-list-root\s*\{[^}]*min-height:\s*0/); + assert.match(list, /\.pp-list\s*\{[^}]*overflow:\s*auto/); + assert.match(list, /\.pp-list\s*\{[^}]*min-height:\s*0/); +}); diff --git a/src/features/terminal/application/use-packet-virtual-scroll.ts b/src/features/terminal/application/use-packet-virtual-scroll.ts index 692196e..6470204 100644 --- a/src/features/terminal/application/use-packet-virtual-scroll.ts +++ b/src/features/terminal/application/use-packet-virtual-scroll.ts @@ -13,6 +13,7 @@ interface PacketVirtualScrollOptions { } const ROW_HEIGHT = 28; +const INITIAL_SCROLL_RECT = { width: 800, height: 480 }; /** * Pure threshold test for "user is parked near the bottom of the scroll area", @@ -57,17 +58,30 @@ export function usePacketVirtualScroll({ const shouldAutoScroll = ref(true); const virtualizer = useVirtualizer( - computed(() => ({ - count: frameCount.value, - getScrollElement: () => scrollRef.value, - estimateSize: (index) => rowSize?.(index) ?? ROW_HEIGHT, - ...(itemKey ? { getItemKey: itemKey } : {}), - overscan: 15, - })), + computed(() => { + const element = scrollRef.value; + return { + count: frameCount.value, + getScrollElement: () => scrollRef.value ?? element, + estimateSize: (index: number) => rowSize?.(index) ?? ROW_HEIGHT, + ...(itemKey ? { getItemKey: itemKey } : {}), + overscan: 15, + initialRect: INITIAL_SCROLL_RECT, + }; + }), ); const virtualItems = computed(() => virtualizer.value.getVirtualItems()); - const totalSize = computed(() => virtualizer.value.getTotalSize()); + const totalSize = computed(() => { + const size = virtualizer.value.getTotalSize(); + if (size > 0) return size; + return Math.max(0, frameCount.value) * (rowSize?.(0) ?? ROW_HEIGHT); + }); + + watch(scrollRef, (element) => { + if (!element) return; + virtualizer.value.measure?.(); + }); function measureElement(element: Element | ComponentPublicInstance | null) { virtualizer.value.measureElement(element as Element | null); diff --git a/src/features/terminal/application/use-parser-record-list.ts b/src/features/terminal/application/use-parser-record-list.ts new file mode 100644 index 0000000..0858fbf --- /dev/null +++ b/src/features/terminal/application/use-parser-record-list.ts @@ -0,0 +1,191 @@ +import { + computed, + nextTick, + onMounted, + onUnmounted, + ref, + watch, + type ComponentPublicInstance, + type Ref, +} from 'vue'; +import { useVirtualizer } from '@tanstack/vue-virtual'; +import { isPinnedToBottom } from '@/features/terminal/application/use-packet-virtual-scroll'; +import { + PARSER_LIST_COL_OVERSCAN, + PARSER_LIST_COLUMN_GAP, + PARSER_LIST_ROW_HEIGHT, + PARSER_LIST_ROW_OVERSCAN, + PARSER_LIST_ROW_PAD, + PARSER_VIRTUAL_INITIAL_RECT, + parserFallbackVirtualItems, + parserListColumns, + parserListContentWidth, + parserListTotalHeight, + type ParserListKind, +} from '@/lib/parser-virtual-list'; + +interface ParserRecordListOptions { + recordCount: Ref; + listKind: Ref; + autoScroll: Ref; + itemKey: (index: number) => string | number; +} + +export function useParserRecordList({ + recordCount, + listKind, + autoScroll, + itemKey, +}: ParserRecordListOptions) { + const scrollRef = ref(null); + const shouldAutoScroll = ref(true); + + const rowVirtualizer = useVirtualizer( + computed(() => { + const element = scrollRef.value; + return { + count: recordCount.value, + getScrollElement: () => scrollRef.value ?? element, + estimateSize: () => PARSER_LIST_ROW_HEIGHT, + getItemKey: itemKey, + overscan: PARSER_LIST_ROW_OVERSCAN, + initialRect: PARSER_VIRTUAL_INITIAL_RECT, + }; + }), + ); + + const columnVirtualizer = useVirtualizer( + computed(() => { + const element = scrollRef.value; + const columns = parserListColumns(listKind.value); + return { + horizontal: true as const, + count: columns.length, + getScrollElement: () => scrollRef.value ?? element, + estimateSize: (index: number) => columns[index]?.width ?? 48, + overscan: PARSER_LIST_COL_OVERSCAN, + paddingStart: PARSER_LIST_ROW_PAD, + paddingEnd: PARSER_LIST_ROW_PAD, + gap: PARSER_LIST_COLUMN_GAP, + initialRect: PARSER_VIRTUAL_INITIAL_RECT, + }; + }), + ); + + const listTotalHeight = computed(() => parserListTotalHeight(recordCount.value)); + const listTotalWidth = computed(() => { + const measured = columnVirtualizer.value.getTotalSize(); + return measured > 0 ? measured : parserListContentWidth(listKind.value); + }); + + const virtualRows = computed(() => { + const virtualItems = rowVirtualizer.value.getVirtualItems(); + const items = + virtualItems.length > 0 + ? virtualItems + : parserFallbackVirtualItems( + recordCount.value, + () => PARSER_LIST_ROW_HEIGHT, + recordCount.value, + ); + return items.map((item) => ({ + index: item.index, + start: item.start, + size: item.size, + })); + }); + + const visibleListColumns = computed(() => { + const columns = parserListColumns(listKind.value); + const virtualItems = columnVirtualizer.value.getVirtualItems(); + const items = + virtualItems.length > 0 + ? virtualItems + : parserFallbackVirtualItems( + columns.length, + (index) => columns[index]?.width ?? 48, + columns.length, + ); + return items.flatMap((item) => { + const column = columns[item.index]; + return column ? [{ key: column.key, start: item.start, size: item.size }] : []; + }); + }); + + function remeasure() { + rowVirtualizer.value.measure?.(); + columnVirtualizer.value.measure?.(); + } + + watch(scrollRef, (element) => { + if (!element) return; + remeasure(); + }); + + function measureElement(element: Element | ComponentPublicInstance | null) { + rowVirtualizer.value.measureElement(element as Element | null); + } + + function scrollToIndex(index: number) { + rowVirtualizer.value.scrollToIndex(index, { align: 'start' }); + } + + function onScroll() { + if (!scrollRef.value) return; + const { scrollTop, scrollHeight, clientHeight } = scrollRef.value; + shouldAutoScroll.value = isPinnedToBottom(scrollTop, scrollHeight, clientHeight); + } + + let autoScrollRafId: number | null = null; + + function pinToBottom() { + autoScrollRafId = null; + const element = scrollRef.value; + if (!element) return; + element.scrollTop = element.scrollHeight; + } + + watch(recordCount, () => { + if (!shouldAutoScroll.value || !autoScroll.value) return; + if (autoScrollRafId !== null) return; + autoScrollRafId = requestAnimationFrame(pinToBottom); + }); + + watch(autoScroll, (enabled) => { + if (!enabled) return; + shouldAutoScroll.value = true; + if (autoScrollRafId === null) { + autoScrollRafId = requestAnimationFrame(pinToBottom); + } + }); + + let resizeObserver: ResizeObserver | null = null; + + onMounted(() => { + void nextTick(remeasure); + const element = scrollRef.value; + if (!element || typeof ResizeObserver === 'undefined') return; + resizeObserver = new ResizeObserver(() => remeasure()); + resizeObserver.observe(element); + }); + + onUnmounted(() => { + if (autoScrollRafId !== null) { + cancelAnimationFrame(autoScrollRafId); + autoScrollRafId = null; + } + resizeObserver?.disconnect(); + resizeObserver = null; + }); + + return { + scrollRef, + virtualRows, + visibleListColumns, + listTotalHeight, + listTotalWidth, + measureElement, + onScroll, + scrollToIndex, + }; +} diff --git a/src/features/terminal/ui/ModbusPanel.vue b/src/features/terminal/ui/ModbusPanel.vue index 1c1c7d6..361a11a 100644 --- a/src/features/terminal/ui/ModbusPanel.vue +++ b/src/features/terminal/ui/ModbusPanel.vue @@ -418,6 +418,7 @@ function onReplayFilePicked(e: Event) { flex-direction: column; background: var(--bg-inset); flex: 1; + height: 100%; min-height: 0; } diff --git a/src/features/terminal/ui/ParserByteDump.vue b/src/features/terminal/ui/ParserByteDump.vue new file mode 100644 index 0000000..fe43839 --- /dev/null +++ b/src/features/terminal/ui/ParserByteDump.vue @@ -0,0 +1,299 @@ + + + + + diff --git a/src/features/terminal/ui/ParserBytePager.vue b/src/features/terminal/ui/ParserBytePager.vue deleted file mode 100644 index 4802748..0000000 --- a/src/features/terminal/ui/ParserBytePager.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - - - diff --git a/src/features/terminal/ui/ParserFrameDetail.vue b/src/features/terminal/ui/ParserFrameDetail.vue index 10e98f6..bb5951a 100644 --- a/src/features/terminal/ui/ParserFrameDetail.vue +++ b/src/features/terminal/ui/ParserFrameDetail.vue @@ -72,6 +72,7 @@ :id="`${inspectorId}-panel-${tab.key}`" :key="`${tab.key}-panel`" class="detail-content scrollbar-thin" + :class="{ 'is-virtual-dump': tab.key === 'hex' || tab.key === 'raw' }" role="tabpanel" :aria-labelledby="`${inspectorId}-tab-${tab.key}`" :hidden="activeTab !== tab.key" @@ -126,57 +127,28 @@
-
- -
-
- {{ formatOffset(row.offset, frame.data.length) }} - - {{ row.bytes.map((byte) => byte.hex).join(' ') }} - {{ row.ascii }} -
+
-
-
{{ rawPageText }}
+
@@ -227,13 +199,14 @@ diff --git a/src/features/terminal/ui/ParserRecordList.vue b/src/features/terminal/ui/ParserRecordList.vue new file mode 100644 index 0000000..e9f8c27 --- /dev/null +++ b/src/features/terminal/ui/ParserRecordList.vue @@ -0,0 +1,490 @@ + + + + + diff --git a/src/lib/__tests__/parser-panel.test.ts b/src/lib/__tests__/parser-panel.test.ts index 4acf011..beffad5 100644 --- a/src/lib/__tests__/parser-panel.test.ts +++ b/src/lib/__tests__/parser-panel.test.ts @@ -16,6 +16,7 @@ import { parseStrictDelimiterHex, parsedFrameStats, positiveInteger, + protocolRecordRawBytes, renderedParsedFrameWindow, truncateHexPreview, } from '@/lib/parser-panel.ts'; @@ -225,3 +226,13 @@ test('preview and ascii helpers mirror parser panel rendering behavior', () => { assert.equal(truncateHexPreview('AABB', 4), 'AABB'); assert.equal(frameAsciiText({ data: new Uint8Array([0x41, 0x00, 0x42]) }), 'A.B'); }); + +test('protocolRecordRawBytes prefers non-empty transport bytes and falls back to payload', () => { + const payload = new Uint8Array([0x41, 0x42]); + const transport = new Uint8Array([0x06, 0x09, 0x41, 0x42]); + + assert.equal(protocolRecordRawBytes(null).length, 0); + assert.equal(protocolRecordRawBytes({ data: payload }), payload); + assert.equal(protocolRecordRawBytes({ data: payload, transportData: new Uint8Array() }), payload); + assert.equal(protocolRecordRawBytes({ data: payload, transportData: transport }), transport); +}); diff --git a/src/lib/__tests__/parser-virtual-list.test.ts b/src/lib/__tests__/parser-virtual-list.test.ts new file mode 100644 index 0000000..6e08e74 --- /dev/null +++ b/src/lib/__tests__/parser-virtual-list.test.ts @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; + +import { + boundParserVirtualItems, + PARSER_DUMP_ASCII_COL, + PARSER_DUMP_BYTES_PER_ROW, + PARSER_DUMP_COLUMN_COUNT, + PARSER_DUMP_MAX_DOM_COLS, + PARSER_DUMP_MAX_DOM_ROWS, + PARSER_DUMP_ROW_HEIGHT, + PARSER_LIST_COLUMN_GAP, + PARSER_LIST_HEX_PREVIEW_BYTES, + PARSER_LIST_ROW_HEIGHT, + parserDumpColumnIndexForByte, + parserDumpColumnKind, + parserDumpColumnSizePx, + parserDumpHexByteIndex, + parserDumpOffsetChars, + parserDumpRowCount, + parserDumpRowStart, + parserDumpTotalWidthPx, + parserFallbackVirtualItems, + parserListColumns, + parserListContentWidth, + parserListTotalHeight, + parserVirtualWheelDelta, + applyParserVirtualWheel, + normalizeParserWheelDelta, +} from '@/lib/parser-virtual-list.ts'; + +test('parser dump helpers count fixed-width hex rows', () => { + assert.equal(parserDumpRowCount(0), 0); + assert.equal(parserDumpRowCount(-1), 0); + assert.equal(parserDumpRowCount(1), 1); + assert.equal(parserDumpRowCount(16), 1); + assert.equal(parserDumpRowCount(17), 2); + assert.equal(parserDumpRowStart(0), 0); + assert.equal(parserDumpRowStart(2), 32); + assert.equal(PARSER_DUMP_ROW_HEIGHT, 22); +}); + +test('parser dump columns cover offset, sixteen hex cells, and ascii', () => { + assert.equal(PARSER_DUMP_COLUMN_COUNT, PARSER_DUMP_BYTES_PER_ROW + 2); + assert.equal(parserDumpColumnKind(0), 'offset'); + assert.equal(parserDumpColumnKind(1), 'hex'); + assert.equal(parserDumpColumnKind(16), 'hex'); + assert.equal(parserDumpColumnKind(PARSER_DUMP_ASCII_COL), 'ascii'); + assert.equal(parserDumpHexByteIndex(1), 0); + assert.equal(parserDumpHexByteIndex(16), 15); + assert.equal(parserDumpColumnIndexForByte(0), 1); + assert.equal(parserDumpColumnIndexForByte(17), 2); +}); + +test('parser dump geometry scales with offset digits and ch size', () => { + assert.equal(parserDumpOffsetChars(0), 4); + assert.equal(parserDumpOffsetChars(0xffff), 4); + assert.equal(parserDumpOffsetChars(0x10000), 5); + const narrow = parserDumpTotalWidthPx(7, 4); + const wide = parserDumpTotalWidthPx(7, 6); + const largerCh = parserDumpTotalWidthPx(8, 4); + assert.ok(wide > narrow); + assert.ok(largerCh > narrow); + assert.equal(parserDumpColumnSizePx(1, 7, 4), 2.5 * 7); +}); + +test('parser dump virtual windows stay within the DOM budget', () => { + const rows = Array.from({ length: 80 }, (_, index) => ({ index })); + const cols = Array.from({ length: 30 }, (_, index) => ({ index })); + assert.equal(boundParserVirtualItems(rows, PARSER_DUMP_MAX_DOM_ROWS).length, 48); + assert.equal(boundParserVirtualItems(cols, PARSER_DUMP_MAX_DOM_COLS).length, 20); + assert.equal(boundParserVirtualItems(rows.slice(0, 12), PARSER_DUMP_MAX_DOM_ROWS).length, 12); +}); + +test('parser fallback virtual items keep a visible window when the virtualizer is empty', () => { + assert.deepEqual( + parserFallbackVirtualItems(0, () => 22, 48), + [], + ); + const items = parserFallbackVirtualItems(80, () => 22, 4); + assert.deepEqual(items, [ + { index: 0, start: 0, size: 22 }, + { index: 1, start: 22, size: 22 }, + { index: 2, start: 44, size: 22 }, + { index: 3, start: 66, size: 22 }, + ]); +}); + +test('parser list columns differ between legacy hex and SMP records', () => { + const legacy = parserListColumns('legacy'); + const smp = parserListColumns('smp'); + assert.deepEqual( + legacy.map((column) => column.key), + ['idx', 'direction', 'time', 'framing', 'hex', 'len', 'copy'], + ); + assert.deepEqual( + smp.map((column) => column.key), + ['idx', 'direction', 'time', 'transaction', 'route', 'seq', 'status', 'rtt', 'len', 'copy'], + ); + assert.equal(PARSER_LIST_HEX_PREVIEW_BYTES, 22); + assert.equal(PARSER_LIST_ROW_HEIGHT, 44); + assert.equal(parserListTotalHeight(35), 35 * PARSER_LIST_ROW_HEIGHT); + assert.ok(parserListContentWidth('legacy') > parserListColumns('legacy')[4].width); + assert.equal( + parserListContentWidth('smp'), + smp.reduce((sum, column) => sum + column.width, 0) + + (smp.length - 1) * PARSER_LIST_COLUMN_GAP + + 14, + ); +}); + +test('parser virtual wheel uses the mouse wheel for both axes', () => { + assert.equal(normalizeParserWheelDelta(2, 1, 22), 44); + assert.equal(normalizeParserWheelDelta(1, 2, 22), 352); + assert.deepEqual( + parserVirtualWheelDelta({ deltaX: 0, deltaY: 40, deltaMode: 0, shiftKey: false }, 22), + { x: 0, y: 40 }, + ); + assert.deepEqual( + parserVirtualWheelDelta({ deltaX: 0, deltaY: 40, deltaMode: 0, shiftKey: true }, 22), + { x: 40, y: 0 }, + ); + assert.deepEqual( + parserVirtualWheelDelta({ deltaX: 12, deltaY: 40, deltaMode: 0, shiftKey: false }, 22), + { x: 0, y: 40 }, + ); + assert.deepEqual( + parserVirtualWheelDelta({ deltaX: 12, deltaY: 4, deltaMode: 0, shiftKey: false }, 22), + { x: 12, y: 0 }, + ); + + const element = { + scrollTop: 0, + scrollLeft: 0, + scrollHeight: 200, + scrollWidth: 300, + clientHeight: 100, + clientWidth: 100, + }; + assert.deepEqual(applyParserVirtualWheel(element, { x: 50, y: 80 }), { x: 50, y: 80 }); + assert.equal(element.scrollTop, 80); + assert.equal(element.scrollLeft, 50); + assert.deepEqual(applyParserVirtualWheel(element, { x: 999, y: 999 }), { x: 150, y: 20 }); + assert.equal(element.scrollTop, 100); + assert.equal(element.scrollLeft, 200); +}); diff --git a/src/lib/__tests__/parser-virtual-wheel.test.ts b/src/lib/__tests__/parser-virtual-wheel.test.ts new file mode 100644 index 0000000..725cf63 --- /dev/null +++ b/src/lib/__tests__/parser-virtual-wheel.test.ts @@ -0,0 +1,41 @@ +// @vitest-environment happy-dom + +import assert from 'node:assert/strict'; +import { test } from 'vitest'; + +import { bindParserVirtualWheel } from '@/lib/parser-virtual-list.ts'; + +function scrollBox(): HTMLDivElement { + const element = document.createElement('div'); + Object.defineProperties(element, { + clientHeight: { configurable: true, get: () => 80 }, + scrollHeight: { configurable: true, get: () => 800 }, + clientWidth: { configurable: true, get: () => 80 }, + scrollWidth: { configurable: true, get: () => 400 }, + }); + element.scrollTop = 0; + element.scrollLeft = 0; + return element; +} + +test('bindParserVirtualWheel scrolls vertically and maps Shift+wheel to the horizontal axis', () => { + const element = scrollBox(); + const unbind = bindParserVirtualWheel(element, 22); + + element.dispatchEvent(new WheelEvent('wheel', { deltaY: 60, deltaX: 12, cancelable: true })); + assert.equal(element.scrollTop, 60); + assert.equal(element.scrollLeft, 0); + + element.dispatchEvent(new WheelEvent('wheel', { deltaY: 0, deltaX: 24, cancelable: true })); + assert.equal(element.scrollLeft, 24); + + const shiftWheel = new WheelEvent('wheel', { deltaY: 40, deltaX: 0, cancelable: true }); + Object.defineProperty(shiftWheel, 'shiftKey', { configurable: true, value: true }); + element.dispatchEvent(shiftWheel); + assert.equal(element.scrollTop, 60); + assert.equal(element.scrollLeft, 64); + + unbind(); + element.dispatchEvent(new WheelEvent('wheel', { deltaY: 20, cancelable: true })); + assert.equal(element.scrollTop, 60); +}); diff --git a/src/lib/locales/en.ts b/src/lib/locales/en.ts index c9d099a..485f5d6 100644 --- a/src/lib/locales/en.ts +++ b/src/lib/locales/en.ts @@ -590,10 +590,8 @@ const en: Catalog = { 'parser.inspector.copyDiagnostics': 'Copy diagnostics', 'parser.inspector.diagnostic': 'Diagnostic', 'parser.inspector.byteRange': 'Bytes {start}–{end}', - 'parser.inspector.bytePager': 'Byte page', - 'parser.inspector.previousPage': 'Previous byte page', - 'parser.inspector.nextPage': 'Next byte page', - 'parser.inspector.bytePage': 'Bytes {start}–{end} of {total}', + 'parser.inspector.hexDump': 'Canonical payload hex dump', + 'parser.inspector.rawDump': 'Transport raw hex dump', 'parser.field.direction': 'Direction', 'parser.field.timestamp': 'Timestamp', 'parser.field.captureSeq': 'Capture sequence', diff --git a/src/lib/locales/zh.ts b/src/lib/locales/zh.ts index 80ee034..10b07b5 100644 --- a/src/lib/locales/zh.ts +++ b/src/lib/locales/zh.ts @@ -557,10 +557,8 @@ const zh: Catalog = { 'parser.inspector.copyDiagnostics': '复制诊断', 'parser.inspector.diagnostic': '诊断', 'parser.inspector.byteRange': '字节 {start}–{end}', - 'parser.inspector.bytePager': '字节分页', - 'parser.inspector.previousPage': '上一字节页', - 'parser.inspector.nextPage': '下一字节页', - 'parser.inspector.bytePage': '字节 {start}–{end},共 {total}', + 'parser.inspector.hexDump': '规范负载十六进制转储', + 'parser.inspector.rawDump': '传输层原始十六进制转储', 'parser.field.direction': '方向', 'parser.field.timestamp': '时间戳', 'parser.field.captureSeq': '捕获序列', diff --git a/src/lib/mcumgr-smp-metadata.ts b/src/lib/mcumgr-smp-metadata.ts index 098b96e..8cd741c 100644 --- a/src/lib/mcumgr-smp-metadata.ts +++ b/src/lib/mcumgr-smp-metadata.ts @@ -182,7 +182,6 @@ const DIAGNOSTIC_NAMES_ZH: Readonly> = Object.freeze({ 'smp.transaction.orphan-response': '未找到匹配的 SMP 请求', 'smp.transaction.unmatched-request': 'SMP 请求未获得匹配响应', 'smp.runtime.replay-backlog': '实时积压超限,已放弃历史重放并继续实时解析', - 'smp.runtime.untrusted-origin': '已跳过来源无法验证的历史捕获帧', }); export function smpDiagnosticMessageZh(code: string, fallback: string): string { diff --git a/src/lib/parser-panel.ts b/src/lib/parser-panel.ts index b43d463..7ba9bb6 100644 --- a/src/lib/parser-panel.ts +++ b/src/lib/parser-panel.ts @@ -415,3 +415,12 @@ export function truncateHexPreview(value: string, max: number): string { export function frameAsciiText(frame: { data: Uint8Array }): string { return Array.from(frame.data, byteAscii).join(''); } + +export function protocolRecordRawBytes( + record: { data: Uint8Array; transportData?: Uint8Array } | null | undefined, +): Uint8Array { + if (!record) return new Uint8Array(); + const transport = record.transportData; + if (transport && transport.length > 0) return transport; + return record.data; +} diff --git a/src/lib/parser-virtual-list.ts b/src/lib/parser-virtual-list.ts new file mode 100644 index 0000000..98b11da --- /dev/null +++ b/src/lib/parser-virtual-list.ts @@ -0,0 +1,203 @@ +/** Fixed hex/raw dump geometry used by both inspector data views. */ +export const PARSER_DUMP_BYTES_PER_ROW = 16; +export const PARSER_DUMP_ROW_HEIGHT = 22; +export const PARSER_DUMP_ROW_OVERSCAN = 16; +export const PARSER_DUMP_COL_OVERSCAN = 8; +export const PARSER_DUMP_HEX_CELL_CH = 2.5; +export const PARSER_DUMP_GROUP_GAP_CH = 1; +export const PARSER_DUMP_DEFAULT_CH_PX = 7; +export const PARSER_DUMP_COLUMN_COUNT = PARSER_DUMP_BYTES_PER_ROW + 2; +export const PARSER_DUMP_ASCII_COL = PARSER_DUMP_BYTES_PER_ROW + 1; +export const PARSER_DUMP_MAX_DOM_ROWS = 48; +export const PARSER_DUMP_MAX_DOM_COLS = 20; +export const PARSER_VIRTUAL_INITIAL_RECT = { width: 720, height: 480 }; + +/** Record-list column geometry. Widths stay fixed so both axes can virtualize. */ +export const PARSER_LIST_ROW_HEIGHT = 44; +export const PARSER_LIST_COLUMN_GAP = 7; +export const PARSER_LIST_ROW_PAD = 7; +export const PARSER_LIST_COL_OVERSCAN = 2; +export const PARSER_LIST_ROW_OVERSCAN = 12; +export const PARSER_LIST_HEX_PREVIEW_BYTES = 22; +export const PARSER_LIST_MAX_FALLBACK_ROWS = 48; + +export type ParserDumpColumnKind = 'offset' | 'hex' | 'ascii'; +export type ParserListKind = 'legacy' | 'smp'; +export type ParserListColumnKey = + | 'idx' + | 'direction' + | 'time' + | 'transaction' + | 'route' + | 'seq' + | 'status' + | 'rtt' + | 'framing' + | 'hex' + | 'len' + | 'copy'; + +export interface ParserListColumn { + key: ParserListColumnKey; + width: number; +} + +export function parserDumpRowCount(byteLength: number): number { + if (!Number.isInteger(byteLength) || byteLength < 1) return 0; + return Math.ceil(byteLength / PARSER_DUMP_BYTES_PER_ROW); +} + +export function parserDumpRowStart(index: number): number { + return Math.max(0, index) * PARSER_DUMP_BYTES_PER_ROW; +} + +export function parserDumpOffsetChars(byteLength: number): number { + if (!Number.isInteger(byteLength) || byteLength < 1) return 4; + return Math.max(4, byteLength.toString(16).length); +} + +export function parserDumpColumnKind(index: number): ParserDumpColumnKind { + if (index <= 0) return 'offset'; + if (index >= PARSER_DUMP_ASCII_COL) return 'ascii'; + return 'hex'; +} + +export function parserDumpHexByteIndex(columnIndex: number): number { + return columnIndex - 1; +} + +export function parserDumpColumnIndexForByte(byteOffset: number): number { + const index = Number.isInteger(byteOffset) ? byteOffset : 0; + return (Math.max(0, index) % PARSER_DUMP_BYTES_PER_ROW) + 1; +} + +export function parserDumpColumnSizeCh(index: number, offsetChars: number): number { + const kind = parserDumpColumnKind(index); + if (kind === 'offset') return Math.max(4, offsetChars) + PARSER_DUMP_GROUP_GAP_CH; + if (kind === 'ascii') return PARSER_DUMP_BYTES_PER_ROW + PARSER_DUMP_GROUP_GAP_CH; + return PARSER_DUMP_HEX_CELL_CH; +} + +export function parserDumpColumnSizePx(index: number, chPx: number, offsetChars: number): number { + return parserDumpColumnSizeCh(index, offsetChars) * Math.max(1, chPx); +} + +export function parserDumpTotalWidthPx(chPx: number, offsetChars: number): number { + let width = 0; + for (let index = 0; index < PARSER_DUMP_COLUMN_COUNT; index += 1) { + width += parserDumpColumnSizePx(index, chPx, offsetChars); + } + return width; +} + +export function boundParserVirtualItems(items: readonly T[], max: number): readonly T[] { + return items.length <= max ? items : items.slice(0, max); +} + +export function parserFallbackVirtualItems( + count: number, + estimateSize: (index: number) => number, + max: number, +): Array<{ index: number; start: number; size: number }> { + const n = Math.min(Math.max(0, count), max); + const items: Array<{ index: number; start: number; size: number }> = []; + let start = 0; + for (let index = 0; index < n; index += 1) { + const size = estimateSize(index); + items.push({ index, start, size }); + start += size; + } + return items; +} + +export function parserListColumns(kind: ParserListKind): readonly ParserListColumn[] { + const leading: ParserListColumn[] = [ + { key: 'idx', width: 56 }, + { key: 'direction', width: 40 }, + { key: 'time', width: 80 }, + ]; + const trailing: ParserListColumn[] = [ + { key: 'len', width: 56 }, + { key: 'copy', width: 28 }, + ]; + if (kind === 'smp') { + return [ + ...leading, + { key: 'transaction', width: 56 }, + { key: 'route', width: 280 }, + { key: 'seq', width: 48 }, + { key: 'status', width: 72 }, + { key: 'rtt', width: 56 }, + ...trailing, + ]; + } + return [...leading, { key: 'framing', width: 84 }, { key: 'hex', width: 462 }, ...trailing]; +} + +export function normalizeParserWheelDelta( + delta: number, + deltaMode: number, + lineSize: number, +): number { + if (!Number.isFinite(delta)) return 0; + const line = Math.max(1, lineSize); + if (deltaMode === 1) return delta * line; + if (deltaMode === 2) return delta * line * 16; + return delta; +} + +export function parserVirtualWheelDelta( + event: Pick, + lineSize: number, +): { x: number; y: number } { + const x = normalizeParserWheelDelta(event.deltaX, event.deltaMode, lineSize); + const y = normalizeParserWheelDelta(event.deltaY, event.deltaMode, lineSize); + if (event.shiftKey) return { x: Math.abs(y) >= Math.abs(x) ? y : x, y: 0 }; + if (Math.abs(y) >= Math.abs(x)) return { x: 0, y }; + return { x, y: 0 }; +} + +export function applyParserVirtualWheel( + element: { + scrollTop: number; + scrollLeft: number; + scrollHeight: number; + scrollWidth: number; + clientHeight: number; + clientWidth: number; + }, + delta: { x: number; y: number }, +): { x: number; y: number } { + const maxTop = Math.max(0, element.scrollHeight - element.clientHeight); + const maxLeft = Math.max(0, element.scrollWidth - element.clientWidth); + const nextTop = Math.min(maxTop, Math.max(0, element.scrollTop + delta.y)); + const nextLeft = Math.min(maxLeft, Math.max(0, element.scrollLeft + delta.x)); + const moved = { x: nextLeft - element.scrollLeft, y: nextTop - element.scrollTop }; + element.scrollTop = nextTop; + element.scrollLeft = nextLeft; + return moved; +} + +export function bindParserVirtualWheel(element: HTMLElement, lineSize: number): () => void { + const onWheel = (event: WheelEvent) => { + if (event.ctrlKey || event.metaKey) return; + const moved = applyParserVirtualWheel(element, parserVirtualWheelDelta(event, lineSize)); + if (moved.x === 0 && moved.y === 0) return; + event.preventDefault(); + event.stopPropagation(); + }; + element.addEventListener('wheel', onWheel, { passive: false }); + return () => element.removeEventListener('wheel', onWheel); +} + +export function parserListContentWidth(kind: ParserListKind): number { + const columns = parserListColumns(kind); + const widths = columns.reduce((sum, column) => sum + column.width, 0); + const gaps = Math.max(0, columns.length - 1) * PARSER_LIST_COLUMN_GAP; + return widths + gaps + PARSER_LIST_ROW_PAD * 2; +} + +/** Scroll track height for fixed-height parser rows — never rely on virtualizer totals. */ +export function parserListTotalHeight(count: number): number { + return Math.max(0, count) * PARSER_LIST_ROW_HEIGHT; +}