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:
+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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user