import { afterEach, describe, expect, it, vi } from "vitest"; const { ANSI_RESET_STATE, cloneAnsiState, createEmptyTerminalBufferState } = require("./terminalBufferState.js"); type TerminalPageOptions = { data?: Record; [key: string]: unknown; }; type TerminalPageInstance = TerminalPageOptions & { data: Record; outputCursorRow: number; outputCursorCol: number; outputCells: unknown[][]; outputReplayText: string; outputReplayBytes: number; outputAnsiState: Record; outputTerminalState: Record; terminalCols: number; terminalRows: number; terminalBufferMaxEntries: number; terminalBufferMaxBytes: number; setData: (patch: Record, callback?: () => void) => void; applyTerminalBufferState: ( state: Record, runtimeOptions?: Record ) => Record; applyTerminalBufferRuntimeState: ( state: Record, runtimeOptions?: Record ) => Record; syncTerminalReplayBuffer: (cleanText: string) => void; createQueuedTerminalOutputTask: (request: Record) => Record; applyQueuedTerminalOutputBatch: (request: Record) => Record; refreshOutputLayout: (options: Record, callback?: (viewState: unknown) => void) => void; queryOutputRect: ReturnType; buildTerminalLayoutState: ReturnType; runAfterTerminalLayout: ReturnType; shouldLogTerminalPerfFrame: ReturnType; logTerminalPerf: ReturnType; }; type MiniprogramGlobals = typeof globalThis & { Page?: (options: TerminalPageOptions) => void; wx?: Record; }; function createTerminalPageHarness() { const globalState = globalThis as MiniprogramGlobals; let capturedPageOptions: TerminalPageOptions | null = null; const noop = () => {}; vi.resetModules(); delete require.cache[require.resolve("./index.js")]; globalState.Page = vi.fn((options: TerminalPageOptions) => { capturedPageOptions = options; }); globalState.wx = { env: { USER_DATA_PATH: "/tmp" }, getStorageSync: vi.fn(() => undefined), setStorageSync: vi.fn(), removeStorageSync: vi.fn(), getRecorderManager: vi.fn(() => ({ onStart: noop, onStop: noop, onError: noop, onFrameRecorded: noop, start: noop, stop: noop })), createInnerAudioContext: vi.fn(() => ({ onCanplay: noop, onPlay: noop, onEnded: noop, onStop: noop, onError: noop, stop: noop, destroy: noop })), setInnerAudioOption: vi.fn(), createSelectorQuery: vi.fn(() => ({ in: vi.fn(() => ({ select: vi.fn(() => ({ boundingClientRect: vi.fn(() => ({ exec: noop })) })) })) })), nextTick: vi.fn((callback?: () => void) => { callback?.(); }), getSystemInfoSync: vi.fn(() => ({})), canIUse: vi.fn(() => false) }; require("./index.js"); if (!capturedPageOptions) { throw new Error("terminal page not captured"); } const captured = capturedPageOptions as TerminalPageOptions; const page = { ...captured, data: JSON.parse(JSON.stringify(captured.data || {})) as Record, setData(patch: Record, callback?: () => void) { Object.assign(this.data, patch); callback?.(); } } as TerminalPageInstance; return { page }; } function initTerminalPageOutputRuntime(page: TerminalPageInstance) { page.terminalCols = 80; page.terminalRows = 24; page.terminalBufferMaxEntries = 5000; page.terminalBufferMaxBytes = 4 * 1024 * 1024; page.outputCursorRow = 0; page.outputCursorCol = 0; page.outputCells = [[]]; page.outputReplayText = ""; page.outputReplayBytes = 0; page.outputAnsiState = cloneAnsiState(ANSI_RESET_STATE); page.outputTerminalState = createEmptyTerminalBufferState({ bufferCols: page.terminalCols, bufferRows: page.terminalRows }); page.applyTerminalBufferState(page.outputTerminalState); } describe("terminal layout rect reuse", () => { const globalState = globalThis as MiniprogramGlobals; const originalPage = globalState.Page; const originalWx = globalState.wx; afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); vi.resetModules(); if (originalPage) { globalState.Page = originalPage; } else { delete globalState.Page; } if (originalWx) { globalState.wx = originalWx; } else { delete globalState.wx; } }); it("stdout 连续 slice 复用 rect 时不应重复查询输出区几何", () => { const { page } = createTerminalPageHarness(); const cachedRect = { left: 0, top: 0, right: 320, bottom: 480, width: 320, height: 480 }; page.queryOutputRect = vi.fn(); page.buildTerminalLayoutState = vi.fn(() => ({ lineHeight: 21, charWidth: 9, paddingLeft: 8, paddingRight: 8, renderLines: [{ lineStyle: "", segments: [] }], renderStartRow: 24, renderEndRow: 25, contentRowCount: 120, topSpacerHeight: 504, bottomSpacerHeight: 1995, keyboardInsetHeight: 0, maxScrollTop: 2079, nextScrollTop: 0, cursorRow: 0, cursorCol: 0, cols: 33, rows: 22, rect: cachedRect })); page.runAfterTerminalLayout = vi.fn((callback?: () => void) => { callback?.(); }); page.shouldLogTerminalPerfFrame = vi.fn(() => false); page.logTerminalPerf = vi.fn(); const done = vi.fn(); page.refreshOutputLayout( { rect: cachedRect, reuseRect: true, skipPostLayoutRectQuery: true }, done ); expect(page.queryOutputRect).not.toHaveBeenCalled(); expect(page.buildTerminalLayoutState).toHaveBeenCalledWith( cachedRect, expect.objectContaining({ rect: cachedRect, reuseRect: true, skipPostLayoutRectQuery: true }) ); expect(page.data.outputRenderLines).toEqual([{ lineStyle: "", segments: [] }]); expect(page.data.outputTopSpacerPx).toBe(504); expect(page.data.outputBottomSpacerPx).toBe(1995); expect(done).toHaveBeenCalledWith( expect.objectContaining({ rect: cachedRect }) ); }); it("滚动补刷窗口时不应回写 outputScrollTop,避免打断手势滚动", () => { const { page } = createTerminalPageHarness(); const cachedRect = { left: 0, top: 0, right: 320, bottom: 480, width: 320, height: 480 }; const patches: Record[] = []; const originalSetData = page.setData; page.setData = function setDataWithSpy(patch: Record, callback?: () => void) { patches.push({ ...patch }); originalSetData.call(this, patch, callback); }; page.currentOutputScrollTop = 960; page.queryOutputRect = vi.fn(); page.buildTerminalLayoutState = vi.fn(() => ({ lineHeight: 21, charWidth: 9, paddingLeft: 8, paddingRight: 8, renderLines: [{ lineStyle: "", segments: [] }], renderStartRow: 40, renderEndRow: 80, contentRowCount: 200, topSpacerHeight: 840, bottomSpacerHeight: 2520, keyboardInsetHeight: 0, maxScrollTop: 3129, nextScrollTop: 960, cursorRow: 50, cursorCol: 0, cols: 33, rows: 22, rect: cachedRect })); page.runAfterTerminalLayout = vi.fn((callback?: () => void) => { callback?.(); }); page.shouldLogTerminalPerfFrame = vi.fn(() => false); page.logTerminalPerf = vi.fn(); page.refreshOutputLayout({ rect: cachedRect, reuseRect: true, skipPostLayoutRectQuery: true, preserveScrollTop: true }); expect(patches[0]).not.toHaveProperty("outputScrollTop"); expect(page.currentOutputScrollTop).toBe(960); }); it("scroll 过程中不应立刻同步 overlay,而是走节流定时器", () => { vi.useFakeTimers(); const { page } = createTerminalPageHarness(); page.outputRectSnapshot = { left: 0, top: 0, right: 320, bottom: 480, width: 320, height: 480 }; page.syncTerminalOverlay = vi.fn(); page.refreshOutputLayout = vi.fn(); page.onOutputScroll({ detail: { scrollTop: 480 } }); expect(page.syncTerminalOverlay).not.toHaveBeenCalled(); vi.advanceTimersByTime(31); expect(page.syncTerminalOverlay).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(page.syncTerminalOverlay).toHaveBeenCalledTimes(1); expect(page.refreshOutputLayout).not.toHaveBeenCalled(); }); it("接近窗口边缘时会在滚动中提前补刷正文窗口", () => { vi.useFakeTimers(); const { page } = createTerminalPageHarness(); const cachedRect = { left: 0, top: 0, right: 320, bottom: 480, width: 320, height: 480 }; page.outputRectSnapshot = cachedRect; page.outputViewportWindow = { renderStartRow: 40, renderEndRow: 100, contentRowCount: 200, lineHeight: 20, visibleRows: 20 }; page.syncTerminalOverlay = vi.fn(); page.refreshOutputLayout = vi.fn( (options: Record, callback?: (viewState: unknown) => void) => { callback?.({ rect: cachedRect, lineHeight: 20, charWidth: 9, paddingLeft: 8, paddingRight: 8, cursorRow: 80, cursorCol: 0, rows: 20 }); } ); page.onOutputScroll({ detail: { scrollTop: 1500 } }); expect(page.refreshOutputLayout).not.toHaveBeenCalled(); vi.advanceTimersByTime(15); expect(page.refreshOutputLayout).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(page.refreshOutputLayout).toHaveBeenCalledWith( expect.objectContaining({ preserveScrollTop: true, scrollViewport: true, rect: cachedRect, reuseRect: true, skipPostLayoutRectQuery: true }), expect.any(Function) ); }); it("可视区已经落进顶部 spacer 时会立刻补刷正文,避免先看到空白", () => { vi.useFakeTimers(); const { page } = createTerminalPageHarness(); const cachedRect = { left: 0, top: 0, right: 320, bottom: 480, width: 320, height: 480 }; page.outputRectSnapshot = cachedRect; page.outputViewportWindow = { renderStartRow: 40, renderEndRow: 100, contentRowCount: 200, lineHeight: 20, visibleRows: 20 }; page.syncTerminalOverlay = vi.fn(); page.refreshOutputLayout = vi.fn( (options: Record, callback?: (viewState: unknown) => void) => { callback?.({ rect: cachedRect, lineHeight: 20, charWidth: 9, paddingLeft: 8, paddingRight: 8, cursorRow: 40, cursorCol: 0, rows: 20 }); } ); page.onOutputScroll({ detail: { scrollTop: 300 } }); expect(page.refreshOutputLayout).toHaveBeenCalledWith( expect.objectContaining({ preserveScrollTop: true, scrollViewport: true, rect: cachedRect, reuseRect: true, skipPostLayoutRectQuery: true }), expect.any(Function) ); }); it("stdout defer slice 只更新运行态,不应立刻同步 replay 文本和可视 rows", () => { let now = 1000; vi.spyOn(Date, "now").mockImplementation(() => { now += 4; return now; }); const { page } = createTerminalPageHarness(); initTerminalPageOutputRuntime(page); const syncReplaySpy = vi.spyOn(page, "syncTerminalReplayBuffer"); const applyStateSpy = vi.spyOn(page, "applyTerminalBufferState"); const applyRuntimeSpy = vi.spyOn(page, "applyTerminalBufferRuntimeState"); const text = "a".repeat(10 * 1024); const request = { options: {}, stdoutSamples: [ { text, appendStartedAt: 1, visibleBytes: text.length, visibleFrameCount: 1 } ] }; request.stdoutTask = page.createQueuedTerminalOutputTask(request); request.stdoutTask.lastRenderCompletedAt = Date.now(); const result = page.applyQueuedTerminalOutputBatch(request); expect(result.shouldRender).toBe(false); expect(syncReplaySpy).not.toHaveBeenCalled(); expect(applyStateSpy).not.toHaveBeenCalled(); expect(applyRuntimeSpy).toHaveBeenCalledTimes(1); expect(page.outputReplayText).toBe(""); expect(page.outputReplayBytes).toBe(0); expect(request.stdoutTask.pendingReplayBytes).toBeGreaterThan(0); }); it("stdout 真正 render 时会一次性提交 defer 期间累积的 replay 文本", () => { let now = 2000; vi.spyOn(Date, "now").mockImplementation(() => { now += 4; return now; }); const { page } = createTerminalPageHarness(); initTerminalPageOutputRuntime(page); const syncReplaySpy = vi.spyOn(page, "syncTerminalReplayBuffer"); const applyStateSpy = vi.spyOn(page, "applyTerminalBufferState"); const text = "a".repeat(10 * 1024); const request = { options: {}, stdoutSamples: [ { text, appendStartedAt: 1, visibleBytes: text.length, visibleFrameCount: 1 } ] }; request.stdoutTask = page.createQueuedTerminalOutputTask(request); request.stdoutTask.lastRenderCompletedAt = Date.now(); const deferred = page.applyQueuedTerminalOutputBatch(request); expect(deferred.shouldRender).toBe(false); request.stdoutTask.slicesSinceLastRender = 7; request.stdoutTask.lastRenderCompletedAt = Date.now() - 1000; const rendered = page.applyQueuedTerminalOutputBatch(request); expect(rendered.shouldRender).toBe(true); expect(syncReplaySpy).toHaveBeenCalledTimes(1); expect(syncReplaySpy).toHaveBeenCalledWith("a".repeat(2048)); expect(applyStateSpy).toHaveBeenCalledTimes(1); expect(page.outputReplayBytes).toBe(2048); expect(request.stdoutTask.pendingReplayBytes).toBe(0); expect(request.stdoutTask.pendingReplayText).toBe(""); }); });