Files
remoteconn-gitea/apps/miniprogram/pages/terminal/terminalLayoutReuse.test.ts
2026-03-21 18:57:10 +08:00

501 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { afterEach, describe, expect, it, vi } from "vitest";
const {
ANSI_RESET_STATE,
cloneAnsiState,
createEmptyTerminalBufferState
} = require("./terminalBufferState.js");
type TerminalPageOptions = {
data?: Record<string, unknown>;
[key: string]: unknown;
};
type TerminalPageInstance = TerminalPageOptions & {
data: Record<string, unknown>;
outputCursorRow: number;
outputCursorCol: number;
outputCells: unknown[][];
outputReplayText: string;
outputReplayBytes: number;
outputAnsiState: Record<string, unknown>;
outputTerminalState: Record<string, unknown>;
terminalCols: number;
terminalRows: number;
terminalBufferMaxEntries: number;
terminalBufferMaxBytes: number;
setData: (patch: Record<string, unknown>, callback?: () => void) => void;
applyTerminalBufferState: (
state: Record<string, unknown>,
runtimeOptions?: Record<string, unknown>
) => Record<string, unknown>;
applyTerminalBufferRuntimeState: (
state: Record<string, unknown>,
runtimeOptions?: Record<string, unknown>
) => Record<string, unknown>;
syncTerminalReplayBuffer: (cleanText: string) => void;
createQueuedTerminalOutputTask: (request: Record<string, unknown>) => Record<string, any>;
applyQueuedTerminalOutputBatch: (request: Record<string, unknown>) => Record<string, any>;
refreshOutputLayout: (options: Record<string, unknown>, callback?: (viewState: unknown) => void) => void;
queryOutputRect: ReturnType<typeof vi.fn>;
buildTerminalLayoutState: ReturnType<typeof vi.fn>;
runAfterTerminalLayout: ReturnType<typeof vi.fn>;
shouldLogTerminalPerfFrame: ReturnType<typeof vi.fn>;
logTerminalPerf: ReturnType<typeof vi.fn>;
};
type MiniprogramGlobals = typeof globalThis & {
Page?: (options: TerminalPageOptions) => void;
wx?: Record<string, unknown>;
};
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<string, unknown>,
setData(patch: Record<string, unknown>, 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<string, unknown>[] = [];
const originalSetData = page.setData;
page.setData = function setDataWithSpy(patch: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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("");
});
});