test(runtime): lock vt220 fn-keys, terminal-size fallbacks, animation offsets, arrow meta (#114)
Round-2 test-only locks (behaviors already at parity with Ink): - parse-keypress (Ink parse-keypress.ts): Ctrl+F1–F4 (\x1b[1;5P/Q/R/S → f1–f4 ctrl), unmapped ctrl (\x1b[1;5I/X → name '' ctrl), Shift+F1 (\x1b[1;2P → f1 shift). - terminal-size (Ink terminal-resize.tsx): 0-columns → positive fallback; resize-listener returns to baseline on unmount; env.LINES rows fallback (a .sequential file — mutates process.env/stdout; deletes absent env vars in teardown to avoid pollution). - use-animation (Ink use-animation.tsx, a .sequential file with deterministic fake timers): newly mounted/activated same-interval animations don't inherit elapsed time (firstFrame - secondFrame === 1); a re-render with an unchanged interval doesn't reset the frame (forced via an unrelated reactive dep — a same-value assign is a Vue no-op); reset is a stable reference across re-renders (collected in the render fn). - use-input: plain arrows assert key.meta === false (Ink's `&& !key.meta` gate). Codex-reviewed; the same-value-no-reset (was vacuous) and env teardown (left "undefined") were tightened per its notes. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
// Sequential: mutates process-global state — process.env.COLUMNS/LINES and
|
||||
// process.stdout/process.stderr columns+rows. The terminal-size package (used by
|
||||
// resolveSize's fallback) reads these globals directly, so a concurrent sibling
|
||||
// would perturb the result. Tests restore every mutated prop in a finally block.
|
||||
|
||||
import { PassThrough } from "node:stream";
|
||||
import process from "node:process";
|
||||
import { defineComponent } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { createApp, Text, useTerminalSize } from "@vue-tui/runtime";
|
||||
|
||||
function makeTtyStream(columns: number): NodeJS.WriteStream {
|
||||
const s = new PassThrough() as unknown as NodeJS.WriteStream;
|
||||
// Deliberately no `rows` — forces resolveSize() into the terminal-size fallback.
|
||||
Object.assign(s, { columns, isTTY: true });
|
||||
return s;
|
||||
}
|
||||
|
||||
function makeFakeStdin(): NodeJS.ReadStream {
|
||||
const s = new PassThrough() as unknown as NodeJS.ReadStream;
|
||||
Object.assign(s, {
|
||||
isTTY: true,
|
||||
setRawMode() {
|
||||
return s;
|
||||
},
|
||||
setEncoding() {
|
||||
return s;
|
||||
},
|
||||
});
|
||||
(s as unknown as { ref: () => void }).ref = () => {};
|
||||
(s as unknown as { unref: () => void }).unref = () => {};
|
||||
return s;
|
||||
}
|
||||
|
||||
// Mirrors Ink terminal-resize.tsx:110-152 ("falls back to terminal-size rows
|
||||
// when stdout.rows is missing"). With the mount stdout reporting columns 0 and
|
||||
// no rows, resolveSize() calls terminal-size, which — after we zero out the real
|
||||
// process.stdout/stderr dimensions — resolves rows from process.env.LINES.
|
||||
test.sequential("useTerminalSize falls back to terminal-size rows from env.LINES when stdout.rows is missing", async () => {
|
||||
const stdout = makeTtyStream(0);
|
||||
const stderr = makeTtyStream(0);
|
||||
const stdin = makeFakeStdin();
|
||||
|
||||
const originalColumns = process.env.COLUMNS;
|
||||
const originalLines = process.env.LINES;
|
||||
const originalStdoutColumns = process.stdout.columns;
|
||||
const originalStdoutRows = process.stdout.rows;
|
||||
const originalStderrColumns = process.stderr.columns;
|
||||
const originalStderrRows = process.stderr.rows;
|
||||
|
||||
let capturedRows = -1;
|
||||
const App = defineComponent(() => {
|
||||
const { rows } = useTerminalSize();
|
||||
capturedRows = rows.value;
|
||||
return () => <Text>{String(rows.value)}</Text>;
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
try {
|
||||
// terminal-size prefers process.stdout/stderr dimensions, then env.
|
||||
// Zero the real streams so env.COLUMNS/LINES is the winning source.
|
||||
process.env.COLUMNS = "123";
|
||||
process.env.LINES = "45";
|
||||
process.stdout.columns = 0;
|
||||
process.stdout.rows = 0;
|
||||
process.stderr.columns = 0;
|
||||
process.stderr.rows = 0;
|
||||
|
||||
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
|
||||
await new Promise<void>((r) => setTimeout(r, 60));
|
||||
|
||||
expect(capturedRows).toBe(45);
|
||||
} finally {
|
||||
app.unmount();
|
||||
// Restore env precisely: a var that was ABSENT must be DELETED, not set to
|
||||
// the string "undefined" (which would pollute later tests' CI/size detection).
|
||||
if (originalColumns === undefined) delete process.env.COLUMNS;
|
||||
else process.env.COLUMNS = originalColumns;
|
||||
if (originalLines === undefined) delete process.env.LINES;
|
||||
else process.env.LINES = originalLines;
|
||||
process.stdout.columns = originalStdoutColumns;
|
||||
process.stdout.rows = originalStdoutRows;
|
||||
process.stderr.columns = originalStderrColumns;
|
||||
process.stderr.rows = originalStderrRows;
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,35 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
import { defineComponent, onScopeDispose } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { render } from "@vue-tui/testing";
|
||||
import { Box, Text, useTerminalSize } from "@vue-tui/runtime";
|
||||
import { Box, createApp, Text, useTerminalSize } from "@vue-tui/runtime";
|
||||
|
||||
// A TTY-like writable that we control directly (columns/rows + resize listeners)
|
||||
// — the @vue-tui/testing render() helper hides the underlying stdout, but the
|
||||
// fallback-and-listener locks below need to read listenerCount('resize') and
|
||||
// mount with columns:0 / no rows, mirroring Ink's createStdout-based fixtures.
|
||||
function makeTtyStream(columns: number, rows?: number): NodeJS.WriteStream {
|
||||
const s = new PassThrough() as unknown as NodeJS.WriteStream;
|
||||
Object.assign(s, { columns, isTTY: true });
|
||||
if (rows !== undefined) (s as unknown as { rows: number }).rows = rows;
|
||||
return s;
|
||||
}
|
||||
|
||||
function makeFakeStdin(): NodeJS.ReadStream {
|
||||
const s = new PassThrough() as unknown as NodeJS.ReadStream;
|
||||
Object.assign(s, {
|
||||
isTTY: true,
|
||||
setRawMode() {
|
||||
return s;
|
||||
},
|
||||
setEncoding() {
|
||||
return s;
|
||||
},
|
||||
});
|
||||
(s as unknown as { ref: () => void }).ref = () => {};
|
||||
(s as unknown as { unref: () => void }).unref = () => {};
|
||||
return s;
|
||||
}
|
||||
|
||||
test("useTerminalSize reacts to resize event", async () => {
|
||||
const App = defineComponent(() => {
|
||||
@@ -160,3 +188,59 @@ test("resize listener is cleaned up via onScopeDispose", async () => {
|
||||
unmount();
|
||||
expect(disposeCalled).toBe(true);
|
||||
});
|
||||
|
||||
// Mirrors Ink terminal-resize.tsx:91-108 ("falls back to a positive column
|
||||
// count when stdout.columns is 0"). When the mount stdout reports columns 0,
|
||||
// resolveSize() falls through to the terminal-size package / 80 default, so the
|
||||
// captured value must be a positive number (never 0).
|
||||
test("useTerminalSize falls back to a positive column count when stdout.columns is 0", async () => {
|
||||
const stdout = makeTtyStream(0, 24);
|
||||
const stderr = makeTtyStream(0, 24);
|
||||
const stdin = makeFakeStdin();
|
||||
|
||||
let capturedColumns = -1;
|
||||
const App = defineComponent(() => {
|
||||
const { columns } = useTerminalSize();
|
||||
capturedColumns = columns.value;
|
||||
return () => <Text>{String(columns.value)}</Text>;
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
|
||||
await new Promise<void>((r) => setTimeout(r, 60));
|
||||
|
||||
try {
|
||||
expect(capturedColumns).toBeGreaterThan(0);
|
||||
} finally {
|
||||
app.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
// Mirrors Ink terminal-resize.tsx:43-64 ("removes resize listener on unmount").
|
||||
// The resize listener count must grow by mounting a useTerminalSize component
|
||||
// and return exactly to baseline after unmount (no leaked listener).
|
||||
test("useTerminalSize resize listener returns to baseline on unmount", async () => {
|
||||
const stdout = makeTtyStream(80, 24);
|
||||
const stderr = makeTtyStream(80, 24);
|
||||
const stdin = makeFakeStdin();
|
||||
|
||||
const baseline = stdout.listenerCount("resize");
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const { columns, rows } = useTerminalSize();
|
||||
return () => (
|
||||
<Text>
|
||||
{columns.value}x{rows.value}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
|
||||
await new Promise<void>((r) => setTimeout(r, 60));
|
||||
|
||||
expect(stdout.listenerCount("resize")).toBeGreaterThan(baseline);
|
||||
|
||||
app.unmount();
|
||||
expect(stdout.listenerCount("resize")).toBe(baseline);
|
||||
});
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
// Sequential: uses vi.useFakeTimers (process-global setTimeout/performance
|
||||
// mocking) to lock EXACT frame offsets deterministically — the same offsets Ink
|
||||
// asserts (test/use-animation.tsx) but which real wall-clock timers can only
|
||||
// approximate. The composable's scheduler reads performance.now()/setTimeout, so
|
||||
// faking both makes frame = floor((now - startTime) / interval) fully reproducible.
|
||||
|
||||
import { PassThrough } from "node:stream";
|
||||
import { defineComponent, nextTick, shallowRef, watchEffect } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
import { createApp, Text, useAnimation } from "@vue-tui/runtime";
|
||||
|
||||
function makeStreams() {
|
||||
const stdout = new PassThrough() as unknown as NodeJS.WriteStream;
|
||||
Object.assign(stdout, { columns: 100, rows: 100, isTTY: true });
|
||||
const stderr = new PassThrough() as unknown as NodeJS.WriteStream;
|
||||
Object.assign(stderr, { columns: 100, rows: 100, isTTY: true });
|
||||
const stdin = new PassThrough() as unknown as NodeJS.ReadStream;
|
||||
Object.assign(stdin, {
|
||||
isTTY: true,
|
||||
setRawMode() {
|
||||
return stdin;
|
||||
},
|
||||
setEncoding() {
|
||||
return stdin;
|
||||
},
|
||||
ref() {},
|
||||
unref() {},
|
||||
});
|
||||
return { stdout, stderr, stdin };
|
||||
}
|
||||
|
||||
// Flush Vue's microtask-based reactivity (fake timers do NOT gate microtasks),
|
||||
// then nextTick so the committed frame value settles after a scheduler tick.
|
||||
async function flush() {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe.sequential("useAnimation exact frame offsets (deterministic)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Ink test/use-animation.tsx:1080-1135 ("newly mounted animations do not
|
||||
// inherit elapsed time"). A second animation appearing one interval after the
|
||||
// first starts from frame 0 and stays EXACTLY one frame behind.
|
||||
test("a newly mounted same-interval animation starts at frame 0 and stays one frame behind", async () => {
|
||||
const interval = 20;
|
||||
const showSecond = shallowRef(false);
|
||||
let firstFrame = 0;
|
||||
let secondFrame = 0;
|
||||
|
||||
const First = defineComponent(() => {
|
||||
const { frame } = useAnimation({ interval });
|
||||
watchEffect(() => {
|
||||
firstFrame = frame.value;
|
||||
});
|
||||
return () => <Text>{String(frame.value)}</Text>;
|
||||
});
|
||||
const Second = defineComponent(() => {
|
||||
const { frame } = useAnimation({ interval });
|
||||
watchEffect(() => {
|
||||
secondFrame = frame.value;
|
||||
});
|
||||
return () => <Text>{String(frame.value)}</Text>;
|
||||
});
|
||||
const App = defineComponent(() => {
|
||||
return () => (
|
||||
<>
|
||||
<First />
|
||||
{showSecond.value ? <Second /> : <Text>-</Text>}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const { stdout, stderr, stdin } = makeStreams();
|
||||
const app = createApp(App);
|
||||
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
|
||||
await flush();
|
||||
|
||||
// Advance just past one interval, then mount the second animation. The first
|
||||
// is now at frame 1; the second subscribes at this moment → frame 0.
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
showSecond.value = true;
|
||||
await flush();
|
||||
|
||||
expect(firstFrame).toBe(1);
|
||||
expect(secondFrame).toBe(0);
|
||||
|
||||
// Advance two more intervals: first → 3, second → 2. Exactly one apart.
|
||||
await vi.advanceTimersByTimeAsync(40);
|
||||
await flush();
|
||||
|
||||
expect(firstFrame).toBeGreaterThanOrEqual(2);
|
||||
expect(secondFrame).toBeGreaterThanOrEqual(1);
|
||||
expect(firstFrame - secondFrame).toBe(1);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
// Ink test/use-animation.tsx:1137-1201 ("newly activated animations do not
|
||||
// inherit elapsed time"). Same exact-offset lock, but the second animation is
|
||||
// mounted from the start and only ACTIVATED one interval later.
|
||||
test("a newly activated same-interval animation starts at frame 0 and stays one frame behind", async () => {
|
||||
const interval = 20;
|
||||
const secondActive = shallowRef(false);
|
||||
let firstFrame = 0;
|
||||
let secondFrame = 0;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const { frame: f1 } = useAnimation({ interval });
|
||||
const { frame: f2 } = useAnimation({ interval, isActive: secondActive });
|
||||
watchEffect(() => {
|
||||
firstFrame = f1.value;
|
||||
secondFrame = f2.value;
|
||||
});
|
||||
return () => (
|
||||
<Text>
|
||||
{f1.value},{f2.value}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
||||
const { stdout, stderr, stdin } = makeStreams();
|
||||
const app = createApp(App);
|
||||
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
|
||||
await flush();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
secondActive.value = true;
|
||||
await flush();
|
||||
|
||||
expect(firstFrame).toBe(1);
|
||||
expect(secondFrame).toBe(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(40);
|
||||
await flush();
|
||||
|
||||
expect(firstFrame).toBeGreaterThanOrEqual(2);
|
||||
expect(secondFrame).toBeGreaterThanOrEqual(1);
|
||||
expect(firstFrame - secondFrame).toBe(1);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
// Ink test/use-animation.tsx:1203-1238 ("rerendering with the same interval
|
||||
// does not reset the frame"). A re-render that leaves the interval unchanged must
|
||||
// NOT restart timing. We force a genuine re-render via an UNRELATED reactive dep
|
||||
// (`bump`) the render reads — assigning the SAME interval value would be a no-op in
|
||||
// Vue (the watcher only fires on change), so it wouldn't exercise anything. The
|
||||
// interval ref stays 50 across the re-render; the frame must keep its value.
|
||||
test("a re-render with an unchanged interval does not reset the frame", async () => {
|
||||
const interval = shallowRef(50);
|
||||
const bump = shallowRef(0);
|
||||
let frameVal = 0;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const { frame } = useAnimation({ interval });
|
||||
watchEffect(() => {
|
||||
frameVal = frame.value;
|
||||
});
|
||||
// Read `bump` in the render so changing it forces a real re-render.
|
||||
return () => <Text>{`${frame.value}:${bump.value}`}</Text>;
|
||||
});
|
||||
|
||||
const { stdout, stderr, stdin } = makeStreams();
|
||||
const app = createApp(App);
|
||||
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
|
||||
await flush();
|
||||
|
||||
// Advance past frame 1.
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
await flush();
|
||||
const frameBefore = frameVal;
|
||||
expect(frameBefore).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Force a re-render with the interval unchanged. A bug that reset on re-render
|
||||
// would drop frameVal to 0; correct behavior keeps it at frameBefore.
|
||||
bump.value++;
|
||||
await flush();
|
||||
|
||||
expect(frameVal).toBe(frameBefore);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
// Ink test/use-animation.tsx:1356-1378 ("reset is a stable function
|
||||
// reference"). reset() identity must survive re-renders. Ink re-runs the
|
||||
// component body each render and collects reset every time; in Vue setup()
|
||||
// runs once, so we collect reset inside the RENDER function (which DOES re-run
|
||||
// per render) to exercise the same "observed across renders" property.
|
||||
test("reset is a stable function reference across re-renders", async () => {
|
||||
const tick = shallowRef(0);
|
||||
const resets: Array<() => void> = [];
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const { reset } = useAnimation({ interval: 50 });
|
||||
// Read tick.value in render so changing it forces a re-render; collect the
|
||||
// reset reference on every render pass.
|
||||
return () => {
|
||||
resets.push(reset);
|
||||
return <Text>{String(tick.value)}</Text>;
|
||||
};
|
||||
});
|
||||
|
||||
const { stdout, stderr, stdin } = makeStreams();
|
||||
const app = createApp(App);
|
||||
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
|
||||
await flush();
|
||||
|
||||
tick.value = 1;
|
||||
await flush();
|
||||
tick.value = 2;
|
||||
await flush();
|
||||
|
||||
expect(resets.length).toBeGreaterThanOrEqual(2);
|
||||
expect(resets[0]).toBe(resets.at(-1));
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,9 @@ test("useInput - handle up arrow", async () => {
|
||||
const { stdin } = await render(App);
|
||||
await stdin.write("\x1b[A");
|
||||
expect(calls[0]?.key.upArrow).toBe(true);
|
||||
// Ink fixtures/use-input.tsx:111 gate on `key.upArrow && !key.meta`; lock that
|
||||
// a plain arrow never spuriously sets meta.
|
||||
expect(calls[0]?.key.meta).toBe(false);
|
||||
});
|
||||
|
||||
test("useInput - handle down arrow", async () => {
|
||||
@@ -133,6 +136,8 @@ test("useInput - handle down arrow", async () => {
|
||||
const { stdin } = await render(App);
|
||||
await stdin.write("\x1b[B");
|
||||
expect(calls[0]?.key.downArrow).toBe(true);
|
||||
// Ink fixtures/use-input.tsx:116 gate on `key.downArrow && !key.meta`.
|
||||
expect(calls[0]?.key.meta).toBe(false);
|
||||
});
|
||||
|
||||
test("useInput - handle right arrow", async () => {
|
||||
@@ -145,6 +150,8 @@ test("useInput - handle right arrow", async () => {
|
||||
const { stdin } = await render(App);
|
||||
await stdin.write("\x1b[C");
|
||||
expect(calls[0]?.key.rightArrow).toBe(true);
|
||||
// Ink fixtures/use-input.tsx:126 gate on `key.rightArrow && !key.meta`.
|
||||
expect(calls[0]?.key.meta).toBe(false);
|
||||
});
|
||||
|
||||
test("useInput - handle left arrow", async () => {
|
||||
@@ -157,6 +164,8 @@ test("useInput - handle left arrow", async () => {
|
||||
const { stdin } = await render(App);
|
||||
await stdin.write("\x1b[D");
|
||||
expect(calls[0]?.key.leftArrow).toBe(true);
|
||||
// Ink fixtures/use-input.tsx:121 gate on `key.leftArrow && !key.meta`.
|
||||
expect(calls[0]?.key.meta).toBe(false);
|
||||
});
|
||||
|
||||
test("useInput - handles rapid arrows and enter in one chunk per write", async () => {
|
||||
|
||||
@@ -88,4 +88,60 @@ describe("parse-keypress", () => {
|
||||
expect(key.name).toBe("a");
|
||||
expect(key.shift).toBe(true);
|
||||
});
|
||||
|
||||
// --- vt220-style Ctrl+F1–F4 (ESC [ 1 ; 5 P/Q/R/S) ---
|
||||
// Mirrors Ink test/parse-keypress.ts:5-29. These come through the fnKeyRe
|
||||
// path (modifier 5 → ctrl) against the "[P".."[S" → f1..f4 keyName map.
|
||||
|
||||
test("Ctrl+F1 resolves to name f1", () => {
|
||||
const key = parseKeypress("\x1b[1;5P");
|
||||
expect(key.name).toBe("f1");
|
||||
expect(key.ctrl).toBe(true);
|
||||
expect(key.shift).toBe(false);
|
||||
expect(key.meta).toBe(false);
|
||||
});
|
||||
|
||||
test("Ctrl+F2 resolves to name f2", () => {
|
||||
const key = parseKeypress("\x1b[1;5Q");
|
||||
expect(key.name).toBe("f2");
|
||||
expect(key.ctrl).toBe(true);
|
||||
});
|
||||
|
||||
test("Ctrl+F3 resolves to name f3", () => {
|
||||
const key = parseKeypress("\x1b[1;5R");
|
||||
expect(key.name).toBe("f3");
|
||||
expect(key.ctrl).toBe(true);
|
||||
});
|
||||
|
||||
test("Ctrl+F4 resolves to name f4", () => {
|
||||
const key = parseKeypress("\x1b[1;5S");
|
||||
expect(key.name).toBe("f4");
|
||||
expect(key.ctrl).toBe(true);
|
||||
});
|
||||
|
||||
// --- Unmapped ctrl-modifier sequences fall back to empty name ---
|
||||
// Mirrors Ink test/parse-keypress.ts:32-42. The fnKeyRe matches but the code
|
||||
// (e.g. "[I", "[X") has no keyName entry, so name is "" while ctrl stays true.
|
||||
|
||||
test("unmapped ctrl sequence returns empty name", () => {
|
||||
const key = parseKeypress("\x1b[1;5I");
|
||||
expect(key.name).toBe("");
|
||||
expect(key.ctrl).toBe(true);
|
||||
});
|
||||
|
||||
test("another unmapped ctrl sequence returns empty name", () => {
|
||||
const key = parseKeypress("\x1b[1;5X");
|
||||
expect(key.name).toBe("");
|
||||
expect(key.ctrl).toBe(true);
|
||||
});
|
||||
|
||||
// --- Shift+F1 (modifier 2) uses the same [P mapping ---
|
||||
// Mirrors Ink test/parse-keypress.ts:45-50.
|
||||
|
||||
test("Shift+F1 resolves to name f1 with shift", () => {
|
||||
const key = parseKeypress("\x1b[1;2P");
|
||||
expect(key.name).toBe("f1");
|
||||
expect(key.shift).toBe(true);
|
||||
expect(key.ctrl).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user