a78fd67cea
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>
226 lines
7.7 KiB
TypeScript
226 lines
7.7 KiB
TypeScript
// 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();
|
|
});
|
|
});
|