Files
vue-tui/packages/runtime-tests/integration/composables/use-animation-offsets.sequential.test.tsx
T
Yunfei He a78fd67cea 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>
2026-06-01 02:49:31 +08:00

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();
});
});