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

739 lines
30 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 { 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
};
}