test: add input-parser parity tests from Ink (+45)

Port all 57 tests from Ink's input-parser test suite (43 named + 14
backspace/delete splitting via test.each). Replaces the previous 12
skeleton tests with full Ink parity covering: CSI parameters, SS3
sequences, meta+CSI/SS3 double escape, escaped printable/supplementary
codepoints, legacy ESC[[ sequences, incomplete holding states, flush
pending, empty chunks, text + incomplete escape, 14 backspace/delete
splitting cases, and 10 bracketed paste tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-27 15:05:35 +08:00
parent cc8f4e1a29
commit 3a836c1e5b
+352 -43
View File
@@ -1,76 +1,385 @@
import { describe, test, expect } from "vite-plus/test";
import type { InputEvent } from "./input-parser.ts";
import { createInputParser } from "./input-parser.ts";
const parseChunks = (chunks: string[]): InputEvent[] => {
const parser = createInputParser();
const events: InputEvent[] = [];
for (const chunk of chunks) {
events.push(...parser.push(chunk));
}
return events;
};
describe("input-parser", () => {
test("splits plain text into single event", () => {
const parser = createInputParser();
expect(parser.push("hello")).toEqual(["hello"]);
// ── Plain text ──────────────────────────────────────────────────────
test("passes through plain text chunks", () => {
expect(parseChunks(["hello", " ", "world"])).toEqual(["hello", " ", "world"]);
});
test("parses CSI sequence (arrow up)", () => {
const parser = createInputParser();
expect(parser.push("\x1b[A")).toEqual(["\x1b[A"]);
test("keeps plain text and control sequences separate", () => {
expect(parseChunks(["a\x1b[Ab"])).toEqual(["a", "\x1b[A", "b"]);
});
test("handles incomplete CSI as pending", () => {
// ── CSI sequences ──────────────────────────────────────────────────
test("parses multiple standard CSI keys in one chunk", () => {
expect(parseChunks(["\x1b[A\x1b[B\x1b[C\x1b[D"])).toEqual([
"\x1b[A",
"\x1b[B",
"\x1b[C",
"\x1b[D",
]);
});
test("parses CSI sequences with parameters", () => {
expect(parseChunks(["\x1b[1;5A\x1b[5~\x1b[6~"])).toEqual(["\x1b[1;5A", "\x1b[5~", "\x1b[6~"]);
});
test("parses kitty protocol sequence as one key event", () => {
expect(parseChunks(["\x1b[97;5u"])).toEqual(["\x1b[97;5u"]);
});
// ── SS3 sequences ─────────────────────────────────────────────────
test("parses SS3 sequences as one key event", () => {
expect(parseChunks(["\x1bOA\x1bOB\x1bOC\x1bOD"])).toEqual([
"\x1bOA",
"\x1bOB",
"\x1bOC",
"\x1bOD",
]);
});
test("does not consume a following escape as SS3 final byte", () => {
expect(parseChunks(["\x1bO\x1b[A"])).toEqual(["\x1bO", "\x1b[A"]);
});
// ── Double-escape (meta) sequences ────────────────────────────────
test("parses meta+CSI sequence with double escape", () => {
expect(parseChunks(["\x1b\x1b[A"])).toEqual(["\x1b\x1b[A"]);
});
test("parses meta+SS3 sequence with double escape", () => {
expect(parseChunks(["\x1b\x1bOA"])).toEqual(["\x1b\x1bOA"]);
});
test("emits double escape as single event for non-control character", () => {
expect(parseChunks(["\x1b\x1bx"])).toEqual(["\x1b\x1b", "x"]);
});
// ── Escaped printable / supplementary code points ─────────────────
test("parses escaped printable code points", () => {
expect(parseChunks(["\x1bx\x1b1"])).toEqual(["\x1bx", "\x1b1"]);
});
test("parses escaped supplementary code points", () => {
expect(parseChunks(["\x1b\u{1F600}"])).toEqual(["\x1b\u{1F600}"]);
});
// ── Legacy ESC[[... sequences ─────────────────────────────────────
test("preserves legacy ESC[[... sequences in a mixed chunk", () => {
expect(parseChunks(["\x1b[[A\x1b[[5~"])).toEqual(["\x1b[[A", "\x1b[[5~"]);
});
test("preserves legacy ESC[[... sequences across chunks", () => {
expect(parseChunks(["\x1b[[", "A\x1b[[5~"])).toEqual(["\x1b[[A", "\x1b[[5~"]);
});
test("parses legacy and standard CSI sequences mixed together", () => {
expect(parseChunks(["\x1b[[A\x1b[B\x1b[[6~\x1b[1;5D"])).toEqual([
"\x1b[[A",
"\x1b[B",
"\x1b[[6~",
"\x1b[1;5D",
]);
});
// ── Incomplete / holding states ───────────────────────────────────
test("holds incomplete CSI sequence until final byte arrives", () => {
const parser = createInputParser();
expect(parser.push("\x1b[")).toEqual([]);
expect(parser.hasPendingEscape()).toBe(true);
expect(parser.push("1;5")).toEqual([]);
expect(parser.push("A")).toEqual(["\x1b[1;5A"]);
});
test("holds incomplete legacy ESC[[... sequence until final byte arrives", () => {
const parser = createInputParser();
expect(parser.push("\x1b[[")).toEqual([]);
expect(parser.push("5")).toEqual([]);
expect(parser.push("~")).toEqual(["\x1b[[5~"]);
});
test("holds incomplete SS3 sequence until final byte arrives", () => {
const parser = createInputParser();
expect(parser.push("\x1bO")).toEqual([]);
expect(parser.push("A")).toEqual(["\x1bOA"]);
});
test("holds incomplete double-escape CSI sequence until final byte arrives", () => {
const parser = createInputParser();
expect(parser.push("\x1b\x1b[")).toEqual([]);
expect(parser.push("A")).toEqual(["\x1b\x1b[A"]);
});
test("holds incomplete double-escape SS3 sequence until final byte arrives", () => {
const parser = createInputParser();
expect(parser.push("\x1b\x1bO")).toEqual([]);
expect(parser.hasPendingEscape()).toBe(true);
expect(parser.push("A")).toEqual(["\x1b\x1bOA"]);
});
test("assembles CSI sequence from single-byte chunks", () => {
const parser = createInputParser();
expect(parser.push("\x1b")).toEqual([]);
expect(parser.push("[")).toEqual([]);
expect(parser.push("1")).toEqual([]);
expect(parser.push(";")).toEqual([]);
expect(parser.push("5")).toEqual([]);
expect(parser.push("A")).toEqual(["\x1b[1;5A"]);
});
// ── Flush pending ─────────────────────────────────────────────────
test("keeps pending plain escape and can flush it", () => {
const parser = createInputParser();
expect(parser.push("\x1b")).toEqual([]);
expect(parser.hasPendingEscape()).toBe(true);
expect(parser.flushPendingEscape()).toBe("\x1b");
expect(parser.hasPendingEscape()).toBe(false);
});
test("flushes pending CSI prefix as literal input", () => {
const parser = createInputParser();
expect(parser.push("\x1b[")).toEqual([]);
expect(parser.hasPendingEscape()).toBe(true);
expect(parser.flushPendingEscape()).toBe("\x1b[");
expect(parser.hasPendingEscape()).toBe(false);
expect(parser.push("A")).toEqual(["A"]);
});
test("flushes pending SS3 prefix as literal input", () => {
const parser = createInputParser();
expect(parser.push("\x1bO")).toEqual([]);
expect(parser.hasPendingEscape()).toBe(true);
expect(parser.flushPendingEscape()).toBe("\x1bO");
expect(parser.push("x")).toEqual(["x"]);
});
test("flushes pending legacy CSI prefix as literal input", () => {
const parser = createInputParser();
expect(parser.push("\x1b[[")).toEqual([]);
expect(parser.hasPendingEscape()).toBe(true);
expect(parser.flushPendingEscape()).toBe("\x1b[[");
expect(parser.push("x")).toEqual(["x"]);
});
// ── Reset ─────────────────────────────────────────────────────────
test("reset clears pending input state", () => {
const parser = createInputParser();
expect(parser.push("\x1b[")).toEqual([]);
parser.reset();
expect(parser.push("A")).toEqual(["A"]);
});
// ── Invalid CSI continuation ──────────────────────────────────────
test("treats invalid CSI continuation as escaped code point plus plain text", () => {
expect(parseChunks(["\x1b[\n"])).toEqual(["\x1b[", "\n"]);
});
// ── Mixed text and key events ─────────────────────────────────────
test("parses mixed text and many key events in one read", () => {
expect(parseChunks(["start\x1b[A mid \x1bOH end\x1b[[5~"])).toEqual([
"start",
"\x1b[A",
" mid ",
"\x1bOH",
" end",
"\x1b[[5~",
]);
});
// ── Empty chunks ──────────────────────────────────────────────────
test("empty chunk produces no events", () => {
expect(parseChunks([""])).toEqual([]);
});
test("empty chunk does not disturb pending state", () => {
const parser = createInputParser();
expect(parser.push("\x1b[")).toEqual([]);
expect(parser.push("")).toEqual([]);
expect(parser.hasPendingEscape()).toBe(true);
expect(parser.push("A")).toEqual(["\x1b[A"]);
});
test("parses bracketed paste", () => {
const parser = createInputParser();
const events = parser.push("\x1b[200~pasted text\x1b[201~");
expect(events).toEqual([{ paste: "pasted text" }]);
});
// ── Text + incomplete escape ──────────────────────────────────────
test("splits backspace bytes", () => {
test("plain text followed by incomplete escape holds escape as pending", () => {
const parser = createInputParser();
const events = parser.push("ab\x7F\x7Fc");
expect(events).toEqual(["ab", "\x7F", "\x7F", "c"]);
});
test("hasPendingEscape is false during paste assembly", () => {
const parser = createInputParser();
parser.push("\x1b[200");
expect(parser.hasPendingEscape()).toBe(false);
});
test("flushPendingEscape returns pending sequence", () => {
const parser = createInputParser();
parser.push("\x1b");
expect(parser.push("hello\x1b")).toEqual(["hello"]);
expect(parser.hasPendingEscape()).toBe(true);
expect(parser.flushPendingEscape()).toBe("\x1b");
});
test("parses SS3 sequence", () => {
const parser = createInputParser();
expect(parser.push("\x1bOP")).toEqual(["\x1bOP"]);
// ── Backspace / delete splitting ──────────────────────────────────
const deleteAndBackspaceCases = [
{
title: "splits batched 0x7F backspace characters into individual events",
chunks: ["\x7F\x7F\x7F"],
events: ["\x7F", "\x7F", "\x7F"],
},
{
title: "splits batched backspace characters into individual events",
chunks: ["\x08\x08\x08"],
events: ["\x08", "\x08", "\x08"],
},
{
title: "splits mixed 0x7F and 0x08 backspace characters",
chunks: ["\x7F\x08\x7F"],
events: ["\x7F", "\x08", "\x7F"],
},
{
title: "splits mixed printable text and 0x7F backspace characters",
chunks: ["abc\x7F\x7F\x7F"],
events: ["abc", "\x7F", "\x7F", "\x7F"],
},
{
title: "single 0x7F backspace character is preserved as individual event",
chunks: ["\x7F"],
events: ["\x7F"],
},
{
title: "single backspace character is preserved as individual event",
chunks: ["\x08"],
events: ["\x08"],
},
{
title: "splits trailing 0x7F backspace from text",
chunks: ["abc\x7F"],
events: ["abc", "\x7F"],
},
{
title: "splits 0x7F backspace characters before escape sequences",
chunks: ["\x7F\x7F\x1b[A"],
events: ["\x7F", "\x7F", "\x1b[A"],
},
{
title: "splits 0x7F backspace characters after escape sequences",
chunks: ["\x1b[A\x7F\x7F"],
events: ["\x1b[A", "\x7F", "\x7F"],
},
{
title: "splits 0x7F backspace characters between escape sequences",
chunks: ["\x1b[A\x7F\x1b[B"],
events: ["\x1b[A", "\x7F", "\x1b[B"],
},
{
title: "splits backspace characters around escape sequences",
chunks: ["\x08\x1b[A\x08"],
events: ["\x08", "\x1b[A", "\x08"],
},
{
title: "splits interleaved text and 0x7F backspace characters",
chunks: ["ab\x7Fcd"],
events: ["ab", "\x7F", "cd"],
},
{
title: "does not split pasted carriage return from text",
chunks: ["\rtest"],
events: ["\rtest"],
},
{
title: "does not split pasted tab from text",
chunks: ["\ttest"],
events: ["\ttest"],
},
];
test.each(deleteAndBackspaceCases)("$title", ({ chunks, events }) => {
expect(parseChunks(chunks)).toEqual(events);
});
test("parses double-ESC prefix (meta+arrow)", () => {
const parser = createInputParser();
expect(parser.push("\x1b\x1b[A")).toEqual(["\x1b\x1b[A"]);
// ── Bracketed paste ───────────────────────────────────────────────
test("emits paste event for bracketed paste sequence", () => {
expect(parseChunks(["\x1b[200~hello world\x1b[201~"])).toEqual([{ paste: "hello world" }]);
});
test("reset clears pending state", () => {
test("emits paste event for multiline bracketed paste", () => {
expect(parseChunks(["\x1b[200~line1\nline2\x1b[201~"])).toEqual([{ paste: "line1\nline2" }]);
});
test("paste content with escape sequences is delivered verbatim", () => {
expect(parseChunks(["\x1b[200~hello\x1b[Aworld\x1b[201~"])).toEqual([
{ paste: "hello\x1b[Aworld" },
]);
});
test("emits normal events before and after bracketed paste", () => {
expect(parseChunks(["before\x1b[200~pasted\x1b[201~after"])).toEqual([
"before",
{ paste: "pasted" },
"after",
]);
});
test("emits multiple paste events in one chunk", () => {
expect(parseChunks(["\x1b[200~first\x1b[201~mid\x1b[200~second\x1b[201~"])).toEqual([
{ paste: "first" },
"mid",
{ paste: "second" },
]);
});
test("holds incomplete bracketed paste as pending", () => {
const parser = createInputParser();
parser.push("\x1b[");
expect(parser.push("\x1b[200~hello")).toEqual([]);
expect(parser.hasPendingEscape()).toBe(false);
expect(parser.push(" world\x1b[201~")).toEqual([{ paste: "hello world" }]);
});
test("assembles bracketed paste from chunk-by-chunk delivery", () => {
const parser = createInputParser();
expect(parser.push("\x1b[200~")).toEqual([]);
expect(parser.push("hello")).toEqual([]);
expect(parser.push("\x1b[201~")).toEqual([{ paste: "hello" }]);
});
test("emits empty paste for adjacent paste markers", () => {
expect(parseChunks(["\x1b[200~\x1b[201~"])).toEqual([{ paste: "" }]);
});
test("handles pasteStart split before the tilde (\\x1b[200 without ~)", () => {
const parser = createInputParser();
expect(parser.push("\x1b[200")).toEqual([]);
expect(parser.hasPendingEscape()).toBe(false);
expect(parser.push("~hello\x1b[201~")).toEqual([{ paste: "hello" }]);
});
test("hasPendingEscape returns true for length-3 pasteStart prefix (\\x1b[2)", () => {
const parser = createInputParser();
expect(parser.push("\x1b[2")).toEqual([]);
expect(parser.hasPendingEscape()).toBe(true);
parser.reset();
expect(parser.hasPendingEscape()).toBe(false);
});
test("incomplete paste start does not trigger flush", () => {
test("hasPendingEscape returns true for length-4 pasteStart prefix (\\x1b[20)", () => {
const parser = createInputParser();
parser.push("\x1b[200");
expect(parser.hasPendingEscape()).toBe(false);
expect(parser.push("\x1b[20")).toEqual([]);
expect(parser.hasPendingEscape()).toBe(true);
});
test("text mixed with escape sequences", () => {
const parser = createInputParser();
const events = parser.push("a\x1b[Ab\x1b[B");
expect(events).toEqual(["a", "\x1b[A", "b", "\x1b[B"]);
test("paste event delivers backspace chars verbatim without splitting", () => {
expect(parseChunks(["\x1b[200~\x7F\x08\x7F\x1b[201~"])).toEqual([{ paste: "\x7F\x08\x7F" }]);
});
});