first commit
This commit is contained in:
738
apps/miniprogram/pages/terminal/terminalBufferState.test.ts
Normal file
738
apps/miniprogram/pages/terminal/terminalBufferState.test.ts
Normal file
@@ -0,0 +1,738 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
ANSI_RESET_STATE,
|
||||
applyTerminalOutput,
|
||||
cloneTerminalBufferState,
|
||||
getActiveTerminalBuffer,
|
||||
rebuildTerminalBufferStateFromReplayText,
|
||||
trimTerminalReplayTextToMaxBytes
|
||||
} = require("./terminalBufferState.js");
|
||||
const { lineCellsToText } = require("./terminalCursorModel.js");
|
||||
const { takeTerminalReplaySlice } = require("./vtParser.js");
|
||||
|
||||
describe("terminalBufferState", () => {
|
||||
it("宽字符和组合字符仍按 cell 列推进,不回退到字符串长度语义", () => {
|
||||
const result = applyTerminalOutput(
|
||||
{
|
||||
cells: [[]],
|
||||
ansiState: { ...ANSI_RESET_STATE },
|
||||
cursorRow: 0,
|
||||
cursorCol: 0
|
||||
},
|
||||
"中e\u0301A",
|
||||
{ bufferCols: 10, maxEntries: 20, maxBytes: 1024 }
|
||||
);
|
||||
|
||||
const row = result.state.cells[0];
|
||||
expect(lineCellsToText(row)).toBe("中e\u0301A");
|
||||
expect(result.state.cursorRow).toBe(0);
|
||||
expect(result.state.cursorCol).toBe(4);
|
||||
expect(row[0]).toMatchObject({ text: "中", width: 2, continuation: false });
|
||||
expect(row[1]).toMatchObject({ text: "", width: 0, continuation: true });
|
||||
expect(row[2]).toMatchObject({ text: "e\u0301", width: 1, continuation: false });
|
||||
expect(row[3]).toMatchObject({ text: "A", width: 1, continuation: false });
|
||||
});
|
||||
|
||||
it("覆盖宽字符 continuation 时会先清理 owner,避免留下脏半格", () => {
|
||||
const result = applyTerminalOutput(
|
||||
{
|
||||
cells: [[]],
|
||||
ansiState: { ...ANSI_RESET_STATE },
|
||||
cursorRow: 0,
|
||||
cursorCol: 0
|
||||
},
|
||||
"中\bA",
|
||||
{ bufferCols: 10, maxEntries: 20, maxBytes: 1024 }
|
||||
);
|
||||
|
||||
const row = result.state.cells[0];
|
||||
expect(lineCellsToText(row)).toBe(" A");
|
||||
expect(result.state.cursorCol).toBe(2);
|
||||
expect(row[0]).toMatchObject({ text: "", width: 1, continuation: false, placeholder: true });
|
||||
expect(row[1]).toMatchObject({ text: "A", width: 1, continuation: false });
|
||||
expect(row.some((cell: { continuation?: boolean }) => !!cell && !!cell.continuation)).toBe(false);
|
||||
});
|
||||
|
||||
it("在列数变化后可按重放文本重新排布旧输出", () => {
|
||||
const narrowResult = applyTerminalOutput(
|
||||
{
|
||||
cells: [[]],
|
||||
ansiState: { ...ANSI_RESET_STATE },
|
||||
cursorRow: 0,
|
||||
cursorCol: 0
|
||||
},
|
||||
"abcdef",
|
||||
{ bufferCols: 4, maxEntries: 20, maxBytes: 1024 }
|
||||
);
|
||||
const replayState = rebuildTerminalBufferStateFromReplayText(narrowResult.cleanText, {
|
||||
bufferCols: 8,
|
||||
maxEntries: 20,
|
||||
maxBytes: 1024
|
||||
});
|
||||
|
||||
expect(narrowResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abcd", "ef"]);
|
||||
expect(replayState.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abcdef"]);
|
||||
expect(replayState.cursorRow).toBe(0);
|
||||
expect(replayState.cursorCol).toBe(6);
|
||||
});
|
||||
|
||||
it("重放文本会保留 ANSI 清行后的最终屏幕状态", () => {
|
||||
const result = applyTerminalOutput(
|
||||
{
|
||||
cells: [[]],
|
||||
ansiState: { ...ANSI_RESET_STATE },
|
||||
cursorRow: 0,
|
||||
cursorCol: 0
|
||||
},
|
||||
"hello\r\u001b[0Kworld",
|
||||
{ bufferCols: 10, maxEntries: 20, maxBytes: 1024 }
|
||||
);
|
||||
const replayState = rebuildTerminalBufferStateFromReplayText(result.cleanText, {
|
||||
bufferCols: 10,
|
||||
maxEntries: 20,
|
||||
maxBytes: 1024
|
||||
});
|
||||
|
||||
expect(replayState.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["world"]);
|
||||
expect(replayState.cursorCol).toBe(5);
|
||||
});
|
||||
|
||||
it("快照裁剪时保留尾部重放文本,便于恢复后按新列宽重建", () => {
|
||||
expect(trimTerminalReplayTextToMaxBytes("ab中cd", 5)).toBe("中cd");
|
||||
expect(trimTerminalReplayTextToMaxBytes("abcd", 8)).toBe("abcd");
|
||||
});
|
||||
|
||||
it("宽字符输出在列数变化后仍可按 replay 文本重建", () => {
|
||||
const narrowResult = applyTerminalOutput(
|
||||
{
|
||||
cells: [[]],
|
||||
ansiState: { ...ANSI_RESET_STATE },
|
||||
cursorRow: 0,
|
||||
cursorCol: 0
|
||||
},
|
||||
"中AB",
|
||||
{ bufferCols: 3, maxEntries: 20, maxBytes: 1024 }
|
||||
);
|
||||
const replayState = rebuildTerminalBufferStateFromReplayText(narrowResult.cleanText, {
|
||||
bufferCols: 4,
|
||||
maxEntries: 20,
|
||||
maxBytes: 1024
|
||||
});
|
||||
|
||||
expect(narrowResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["中A", "B"]);
|
||||
expect(replayState.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["中AB"]);
|
||||
expect(replayState.cursorRow).toBe(0);
|
||||
expect(replayState.cursorCol).toBe(4);
|
||||
});
|
||||
|
||||
it("按安全切片连续推进后,最终终端状态应与整段推进一致", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const source = "ab\r\n\u001b[31mcd\u001b[0mZ";
|
||||
const whole = applyTerminalOutput(createEmptyState(), source, options);
|
||||
|
||||
let remaining = source;
|
||||
let state = createEmptyState();
|
||||
while (remaining) {
|
||||
const part = takeTerminalReplaySlice(remaining, 4);
|
||||
expect(part.slice.length).toBeGreaterThan(0);
|
||||
const partial = applyTerminalOutput(state, part.slice, options);
|
||||
state = partial.state;
|
||||
remaining = part.rest;
|
||||
}
|
||||
|
||||
expect(state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(
|
||||
whole.state.cells.map((row: unknown[]) => lineCellsToText(row))
|
||||
);
|
||||
expect(state.cursorRow).toBe(whole.state.cursorRow);
|
||||
expect(state.cursorCol).toBe(whole.state.cursorCol);
|
||||
expect(state.ansiState).toEqual(whole.state.ansiState);
|
||||
});
|
||||
|
||||
it("stdout 运行态复用时,最终状态应与常规不可变推进一致", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "ab\r\ncd", options);
|
||||
const regularBase = cloneTerminalBufferState(base.state, options);
|
||||
const runtimeBase = cloneTerminalBufferState(base.state, options, { cloneRows: false });
|
||||
const regular = applyTerminalOutput(regularBase, "\u001b[31mZ", options);
|
||||
const reused = applyTerminalOutput(runtimeBase, "\u001b[31mZ", options, {
|
||||
reuseState: true,
|
||||
reuseRows: true
|
||||
});
|
||||
|
||||
expect(reused.state).toBe(runtimeBase);
|
||||
expect(reused.state.cells).toBe(reused.state.buffers.normal.cells);
|
||||
expect(reused.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(
|
||||
regular.state.cells.map((row: unknown[]) => lineCellsToText(row))
|
||||
);
|
||||
expect(reused.state.cursorRow).toBe(regular.state.cursorRow);
|
||||
expect(reused.state.cursorCol).toBe(regular.state.cursorCol);
|
||||
expect(reused.state.ansiState).toEqual(regular.state.ansiState);
|
||||
});
|
||||
|
||||
it("1049 alt screen 切换后会保留 normal buffer 历史,并在退出时恢复", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "shell>", options);
|
||||
const entered = applyTerminalOutput(base.state, "\u001b[?1049hTOP", options);
|
||||
const restored = applyTerminalOutput(entered.state, "\u001b[?1049l", options);
|
||||
|
||||
expect(getActiveTerminalBuffer(entered.state).isAlt).toBe(true);
|
||||
expect(entered.state.activeBufferName).toBe("alt");
|
||||
expect(entered.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["TOP", "", "", ""]);
|
||||
expect(restored.state.activeBufferName).toBe("normal");
|
||||
expect(restored.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["shell>"]);
|
||||
});
|
||||
|
||||
it("私有模式会驱动 Codex 依赖的关键终端模式位", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const hidden = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?25l\u001b[?1h\u001b[?45h\u001b[?66h\u001b[?1004h\u001b[?2004h\u001b[4h",
|
||||
options
|
||||
);
|
||||
const shown = applyTerminalOutput(
|
||||
hidden.state,
|
||||
"\u001b[?25h\u001b[?1l\u001b[?45l\u001b[?66l\u001b[?1004l\u001b[?2004l\u001b[4l",
|
||||
options
|
||||
);
|
||||
|
||||
expect(hidden.state.modes).toMatchObject({
|
||||
cursorHidden: true,
|
||||
applicationCursorKeys: true,
|
||||
applicationKeypad: true,
|
||||
reverseWraparound: true,
|
||||
sendFocus: true,
|
||||
bracketedPasteMode: true,
|
||||
insertMode: true
|
||||
});
|
||||
expect(shown.state.modes).toMatchObject({
|
||||
cursorHidden: false,
|
||||
applicationCursorKeys: false,
|
||||
applicationKeypad: false,
|
||||
reverseWraparound: false,
|
||||
sendFocus: false,
|
||||
bracketedPasteMode: false,
|
||||
insertMode: false
|
||||
});
|
||||
});
|
||||
|
||||
it("DSR/CPR 查询会生成响应,而不会污染屏幕内容", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "ab\u001b[6n\u001b[5n", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["ab"]);
|
||||
expect(result.responses).toEqual(["\u001b[1;3R", "\u001b[0n"]);
|
||||
});
|
||||
|
||||
it("DA1/DA2 查询会分别返回 primary 和 secondary device attributes", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "\u001b[c\u001b[>c", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([""]);
|
||||
expect(result.responses).toEqual(["\u001b[?1;2c", "\u001b[>0;276;0c"]);
|
||||
});
|
||||
|
||||
it("OSC 10/11/12 颜色查询会返回最小可用响应,而不会污染屏幕内容", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b]12;?\u001b\\",
|
||||
options,
|
||||
{
|
||||
defaultForeground: "#112233",
|
||||
defaultBackground: "#445566",
|
||||
defaultCursor: "#778899"
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([""]);
|
||||
expect(result.responses).toEqual([
|
||||
"\u001b]10;rgb:1111/2222/3333\u001b\\",
|
||||
"\u001b]11;rgb:4444/5555/6666\u001b\\",
|
||||
"\u001b]12;rgb:7777/8888/9999\u001b\\"
|
||||
]);
|
||||
});
|
||||
|
||||
it("normal buffer 的绝对定位会以当前可视尾部为基准,而不是写回历史顶部", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5", options);
|
||||
const result = applyTerminalOutput(base.state, "\u001b[1;1HX", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"X",
|
||||
"3",
|
||||
"4",
|
||||
"5"
|
||||
]);
|
||||
expect(result.responses).toEqual([]);
|
||||
});
|
||||
|
||||
it("CSI B 在 normal buffer 中下移时会真实扩出目标行,而不是钳死在 viewport 内", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 2, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "\u001b[3BZ", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["", "", "", "Z"]);
|
||||
expect(result.state.cursorRow).toBe(3);
|
||||
expect(result.state.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it("origin mode 下的 VPA/CUP 会以滚动区顶部为基准定位", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[2;4r\u001b[?6h\u001b[2dA\u001b[1;3HZ",
|
||||
options
|
||||
);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["", " Z", "A"]);
|
||||
expect(result.state.cursorRow).toBe(1);
|
||||
expect(result.state.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it("normal buffer 有历史时,开启 origin mode 会把光标归位到当前滚动区顶部", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5", options);
|
||||
const result = applyTerminalOutput(base.state, "\u001b[2;4r\u001b[?6hX", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"X",
|
||||
"4",
|
||||
"5"
|
||||
]);
|
||||
expect(result.state.cursorRow).toBe(2);
|
||||
expect(result.state.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it("origin mode 下的 CUU/CUD/CNL/CPL 会被滚动区夹住,不越过固定区", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const movedUpDown = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;4r\u001b[?6h\u001b[1;1H\u001b[AZ\u001b[3;1H\u001b[B#",
|
||||
options
|
||||
);
|
||||
const movedPrevNextLine = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;4r\u001b[?6h\u001b[1;3H\u001b[FZ\u001b[3;3H\u001b[EQ",
|
||||
options
|
||||
);
|
||||
|
||||
expect(movedUpDown.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "Z", "3", "#"]);
|
||||
expect(movedPrevNextLine.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "Z", "3", "Q"]);
|
||||
});
|
||||
|
||||
it("normal buffer 有历史时,CUU/CPL 不会越过当前视口顶部并写回隐藏历史区", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5", options);
|
||||
const movedUp = applyTerminalOutput(base.state, "\u001b[1;1H\u001b[AZ", options);
|
||||
const movedPrevLine = applyTerminalOutput(base.state, "\u001b[1;3H\u001b[FQ", options);
|
||||
|
||||
expect(movedUp.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"Z",
|
||||
"3",
|
||||
"4",
|
||||
"5"
|
||||
]);
|
||||
expect(movedUp.state.cursorRow).toBe(1);
|
||||
expect(movedUp.state.cursorCol).toBe(1);
|
||||
|
||||
expect(movedPrevLine.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"Q",
|
||||
"3",
|
||||
"4",
|
||||
"5"
|
||||
]);
|
||||
expect(movedPrevLine.state.cursorRow).toBe(1);
|
||||
expect(movedPrevLine.state.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it("normal buffer 顶部滚动区上卷时会保留历史,并维持底部固定区", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4", options);
|
||||
const result = applyTerminalOutput(base.state, "\u001b[1;3r\u001b[3;1H\n", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"",
|
||||
"4"
|
||||
]);
|
||||
expect(result.state.cells.slice(-4).map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"2",
|
||||
"3",
|
||||
"",
|
||||
"4"
|
||||
]);
|
||||
});
|
||||
|
||||
it("normal buffer 的 ESC M 会在局部滚动区顶部插入空行", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 6, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5\r\n6", options);
|
||||
const result = applyTerminalOutput(base.state, "\u001b[4;6r\u001b[4;1H\u001bM", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"",
|
||||
"4",
|
||||
"5"
|
||||
]);
|
||||
});
|
||||
|
||||
it("ESC D / ESC E / ESC M 在 alt buffer 固定头尾区域时,不会误滚中间滚动区", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const indResult = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;3r\u001b[4;1H\u001bD#",
|
||||
options
|
||||
);
|
||||
const nelResult = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;3r\u001b[4;1H\u001bE#",
|
||||
options
|
||||
);
|
||||
const riResult = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;3r\u001b[1;1H\u001bM#",
|
||||
options
|
||||
);
|
||||
|
||||
expect(indResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "2", "3", "#"]);
|
||||
expect(indResult.state.cursorRow).toBe(3);
|
||||
expect(indResult.state.cursorCol).toBe(1);
|
||||
expect(nelResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "2", "3", "#"]);
|
||||
expect(nelResult.state.cursorRow).toBe(3);
|
||||
expect(nelResult.state.cursorCol).toBe(1);
|
||||
expect(riResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["#", "2", "3", "4"]);
|
||||
expect(riResult.state.cursorRow).toBe(0);
|
||||
expect(riResult.state.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it("ESC D / ESC M 在 normal buffer 固定头尾区域时,不会误滚正文区或回写历史区", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5\r\n6", options);
|
||||
const indResult = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[4;1H\u001bD#", options);
|
||||
const riResult = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[1;1H\u001bM#", options);
|
||||
|
||||
expect(indResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"#"
|
||||
]);
|
||||
expect(indResult.state.cursorRow).toBe(5);
|
||||
expect(indResult.state.cursorCol).toBe(1);
|
||||
|
||||
expect(riResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"#",
|
||||
"4",
|
||||
"5",
|
||||
"6"
|
||||
]);
|
||||
expect(riResult.state.cursorRow).toBe(2);
|
||||
expect(riResult.state.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it("normal buffer 的 CPR 会返回可视窗口内的行号,而不是历史绝对行号", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5", options);
|
||||
const result = applyTerminalOutput(base.state, "\u001b[1;1H\u001b[6n", options);
|
||||
|
||||
expect(result.responses).toEqual(["\u001b[1;1R"]);
|
||||
});
|
||||
|
||||
it("接近 codex /status 的 normal buffer 重排后,不会只停在 Permissions 这一行", () => {
|
||||
const options = { bufferCols: 80, bufferRows: 24, maxEntries: 200, maxBytes: 8192 };
|
||||
const historyText = Array.from({ length: 30 }, (_, index) => `pre${index + 1}`).join("\r\n");
|
||||
const base = applyTerminalOutput(createEmptyState(), historyText, options);
|
||||
const statusPayload =
|
||||
"\u001b[18;1H\u001b[J\u001b[18;24r\u001b[18;1H\u001bM\u001bM\u001b[r\u001b[1;19r\u001b[17;1H" +
|
||||
"\r\n/status\r\n\r\nStatusHeader\r\nModel\r\nDirectory\r\nPermissions\r\nAgents\r\nAccount\r\n" +
|
||||
"Collaboration\r\nSession\r\nLimit5h\r\nLimitReset\r\nWeekly\r\nWeeklyReset\r\n" +
|
||||
"\u001b[r\u001b[21;3H";
|
||||
const result = applyTerminalOutput(base.state, statusPayload, options);
|
||||
const visibleTail = result.state.cells.slice(-24).map((row: unknown[]) => lineCellsToText(row));
|
||||
|
||||
expect(visibleTail).toContain("Permissions");
|
||||
expect(visibleTail).toContain("Weekly");
|
||||
expect(visibleTail).toContain("WeeklyReset");
|
||||
});
|
||||
|
||||
it("DECRQM 会按当前实现真实维护的模式位返回状态", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1004h\u001b[4h\u001b[?1049$p\u001b[?1004$p\u001b[4$p\u001b[?2026$p",
|
||||
options
|
||||
);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([""]);
|
||||
expect(result.responses).toEqual([
|
||||
"\u001b[?1049;2$y",
|
||||
"\u001b[?1004;1$y",
|
||||
"\u001b[4;1$y",
|
||||
"\u001b[?2026;0$y"
|
||||
]);
|
||||
});
|
||||
|
||||
it("DCS $ q 状态字符串查询会返回最小可用响应", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001bP$qm\u001b\\\u001bP$qr\u001b\\\u001bP$q q\u001b\\\u001bP$qz\u001b\\",
|
||||
options
|
||||
);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([""]);
|
||||
expect(result.responses).toEqual([
|
||||
"\u001bP1$r0m\u001b\\",
|
||||
"\u001bP1$r1;4r\u001b\\",
|
||||
"\u001bP1$r2 q\u001b\\",
|
||||
"\u001bP0$r\u001b\\"
|
||||
]);
|
||||
});
|
||||
|
||||
it("DECSTR 会软重置模式位、样式和滚动区域,但保留现有屏幕内容", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h\u001b[2;3r\u001b[?25l\u001b[?1h\u001b[?2004h\u001b[31mX\u001b[!p",
|
||||
options
|
||||
);
|
||||
const active = getActiveTerminalBuffer(result.state);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["X", "", "", ""]);
|
||||
expect(result.state.ansiState).toEqual(ANSI_RESET_STATE);
|
||||
expect(result.state.modes).toMatchObject({
|
||||
applicationCursorKeys: false,
|
||||
originMode: false,
|
||||
wraparound: true,
|
||||
cursorHidden: false,
|
||||
bracketedPasteMode: false
|
||||
});
|
||||
expect(active.scrollTop).toBe(0);
|
||||
expect(active.scrollBottom).toBe(3);
|
||||
expect(active.savedCursorRow).toBe(0);
|
||||
expect(active.savedCursorCol).toBe(0);
|
||||
expect(active.savedAnsiState).toEqual(ANSI_RESET_STATE);
|
||||
});
|
||||
|
||||
it("CSI 2J 清屏后保留当前光标位置,后续输出不会错位到左上角", () => {
|
||||
const options = { bufferCols: 6, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "ABCD\u001b[1;3H\u001b[2JX", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([" X", "", "", ""]);
|
||||
expect(result.state.cursorRow).toBe(0);
|
||||
expect(result.state.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it("带背景色的清屏会把整屏空白位也染成当前擦除背景,而不是只给文字底部上色", () => {
|
||||
const options = { bufferCols: 6, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "\u001b[100m\u001b[2JX", options);
|
||||
const firstRow = result.state.cells[0];
|
||||
const secondRow = result.state.cells[1];
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["X", "", "", ""]);
|
||||
expect(firstRow[1]).toMatchObject({
|
||||
placeholder: true,
|
||||
style: { bg: "#666666" }
|
||||
});
|
||||
expect(secondRow[0]).toMatchObject({
|
||||
placeholder: true,
|
||||
style: { bg: "#666666" }
|
||||
});
|
||||
});
|
||||
|
||||
it("CSI X 会从当前光标起按列擦除字符而不左移后续内容", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "abcdef\u001b[1;3H\u001b[2X", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["ab ef"]);
|
||||
expect(result.state.cursorRow).toBe(0);
|
||||
expect(result.state.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it("CSI @ / P 会按当前光标插删行内字符,而不是重写整行", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const inserted = applyTerminalOutput(createEmptyState(), "abcd\u001b[1;3H\u001b[@Z", options);
|
||||
const deleted = applyTerminalOutput(createEmptyState(), "abcdef\u001b[1;3H\u001b[2P", options);
|
||||
|
||||
expect(inserted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abZcd"]);
|
||||
expect(inserted.state.cursorRow).toBe(0);
|
||||
expect(inserted.state.cursorCol).toBe(3);
|
||||
expect(deleted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abef"]);
|
||||
expect(deleted.state.cursorRow).toBe(0);
|
||||
expect(deleted.state.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it("CSI @ / P 切进宽字符中间时,不会留下悬空 continuation 或半个宽字符", () => {
|
||||
const options = { bufferCols: 5, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const inserted = applyTerminalOutput(createEmptyState(), "A中BC\u001b[1;3H\u001b[@", options);
|
||||
const deleted = applyTerminalOutput(createEmptyState(), "A中BC\u001b[1;2H\u001b[P", options);
|
||||
|
||||
expect(inserted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["A B"]);
|
||||
expect(deleted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["A BC"]);
|
||||
expect(
|
||||
inserted.state.cells[0].some((cell: { continuation?: boolean }) => !!cell && !!cell.continuation)
|
||||
).toBe(false);
|
||||
expect(
|
||||
deleted.state.cells[0].some((cell: { continuation?: boolean }) => !!cell && !!cell.continuation)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("normal buffer 有历史和固定头尾时,CSI L / M / S / T 只作用于当前滚动区", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5\r\n6", options);
|
||||
const inserted = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[2;1H\u001b[L", options);
|
||||
const deleted = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[2;1H\u001b[M", options);
|
||||
const scrolledUp = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[S", options);
|
||||
const scrolledDown = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[T", options);
|
||||
|
||||
expect(inserted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"",
|
||||
"4",
|
||||
"6"
|
||||
]);
|
||||
expect(deleted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"5",
|
||||
"",
|
||||
"6"
|
||||
]);
|
||||
expect(scrolledUp.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"5",
|
||||
"",
|
||||
"6"
|
||||
]);
|
||||
expect(scrolledDown.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"",
|
||||
"4",
|
||||
"6"
|
||||
]);
|
||||
});
|
||||
|
||||
it("CSI L / M 会在 alt buffer 当前行插删整行", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const inserted = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;1H\u001b[L",
|
||||
options
|
||||
);
|
||||
const deleted = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;1H\u001b[M",
|
||||
options
|
||||
);
|
||||
|
||||
expect(inserted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "", "2", "3"]);
|
||||
expect(inserted.state.cursorRow).toBe(1);
|
||||
expect(inserted.state.cursorCol).toBe(0);
|
||||
expect(deleted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "3", "4", ""]);
|
||||
expect(deleted.state.cursorRow).toBe(1);
|
||||
expect(deleted.state.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it("CSI S / T 会在当前滚动区内上卷和下卷,而不改动其它语义", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const scrolledUp = applyTerminalOutput(createEmptyState(), "\u001b[?1049h1\n2\n3\n4\u001b[S", options);
|
||||
const scrolledDown = applyTerminalOutput(createEmptyState(), "\u001b[?1049h1\n2\n3\n4\u001b[T", options);
|
||||
|
||||
expect(scrolledUp.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["2", "3", "4", ""]);
|
||||
expect(scrolledUp.state.cursorRow).toBe(3);
|
||||
expect(scrolledUp.state.cursorCol).toBe(1);
|
||||
expect(scrolledDown.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["", "1", "2", "3"]);
|
||||
expect(scrolledDown.state.cursorRow).toBe(3);
|
||||
expect(scrolledDown.state.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it("CSI r 只给顶部参数时会把底边默认到最后一行", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4", options);
|
||||
const result = applyTerminalOutput(base.state, "\u001b[2r\u001b[4;1H\n", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "3", "4", ""]);
|
||||
expect(result.state.cursorRow).toBe(3);
|
||||
expect(result.state.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it("insert mode 打开后,普通打印会按当前光标位置插入而不是覆盖", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "abcd\u001b[1;3H\u001b[4hZ", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abZcd"]);
|
||||
expect(result.state.cursorRow).toBe(0);
|
||||
expect(result.state.cursorCol).toBe(3);
|
||||
expect(result.state.modes.insertMode).toBe(true);
|
||||
});
|
||||
|
||||
it("ESC D / ESC E / ESC M 会按滚动区域语义推进全屏缓冲", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const indResult = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;4r\u001b[4;1H\u001bD",
|
||||
options
|
||||
);
|
||||
const nelResult = applyTerminalOutput(indResult.state, "\u001bE#", options);
|
||||
const riResult = applyTerminalOutput(nelResult.state, "\u001b[2;1H\u001bM", options);
|
||||
|
||||
expect(indResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "3", "4", ""]);
|
||||
expect(nelResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "4", "", "#"]);
|
||||
expect(nelResult.state.cursorRow).toBe(3);
|
||||
expect(nelResult.state.cursorCol).toBe(1);
|
||||
expect(riResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "", "4", ""]);
|
||||
expect(riResult.state.cursorRow).toBe(1);
|
||||
expect(riResult.state.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it("CSI s/u 与 ESC 7/8 会恢复之前保存的光标位置", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const csiResult = applyTerminalOutput(createEmptyState(), "ab\u001b[scd\u001b[uZ", options);
|
||||
const escResult = applyTerminalOutput(createEmptyState(), "ab\u001b7cd\u001b8Z", options);
|
||||
|
||||
expect(csiResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abZd"]);
|
||||
expect(csiResult.state.cursorRow).toBe(0);
|
||||
expect(csiResult.state.cursorCol).toBe(3);
|
||||
expect(escResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abZd"]);
|
||||
expect(escResult.state.cursorRow).toBe(0);
|
||||
expect(escResult.state.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it("带 > 私有标记的 CSI u 不应误当成光标恢复", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "abc\u001b[>7uZ", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abcZ"]);
|
||||
expect(result.state.cursorRow).toBe(0);
|
||||
expect(result.state.cursorCol).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
function createEmptyState() {
|
||||
return {
|
||||
cells: [[]],
|
||||
ansiState: { ...ANSI_RESET_STATE },
|
||||
cursorRow: 0,
|
||||
cursorCol: 0
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user