Files
vue-tui/packages/runtime-tests/integration/composables/use-animation.test.tsx
T
Yunfei He 4c18ec0dd0 fix(runtime): useAnimation reset() while paused keeps the last frame, zeros on resume (Ink parity) (#92)
reset() zeroed frame/time/delta unconditionally, so calling it while paused
(isActive=false) flipped the frozen frame to 0 immediately. Ink keeps the last frame
until resume: its reset only bumps a key consumed by the isActive-gated effect, which
early-returns while inactive (use-animation.ts:83-89). Now reset() zeros + restarts only
when active (via start()); while paused it's a no-op and the next resume's start() zeros
-- matching Ink. Active reset is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:42:51 +08:00

1186 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { PassThrough } from "node:stream";
import { defineComponent, nextTick, shallowRef, watchEffect } from "vue";
import type { ShallowRef } from "vue";
import { describe, expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Text, useAnimation, createApp, renderToString } from "@vue-tui/runtime";
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
describe("useAnimation", () => {
// ---------------------------------------------------------------
// Original 9 tests
// ---------------------------------------------------------------
test("increments frame over time", async () => {
const frames: number[] = [];
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 50 });
watchEffect(() => {
frames.push(frame.value);
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(200);
unmount();
expect(frames.length).toBeGreaterThan(2);
});
test("isActive false pauses animation", async () => {
const active = shallowRef(false);
let lastFrame = 0;
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 50, isActive: active });
watchEffect(() => {
lastFrame = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(200);
expect(lastFrame).toBe(0);
unmount();
});
test("toggling isActive to true starts animation", async () => {
const active = shallowRef(false);
let lastFrame = 0;
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 50, isActive: active });
watchEffect(() => {
lastFrame = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(100);
expect(lastFrame).toBe(0);
active.value = true;
await delay(200);
expect(lastFrame).toBeGreaterThan(0);
unmount();
});
test("reset resets all values to 0", async () => {
let resetFn: (() => void) | undefined;
let frameRef: { readonly value: number } | undefined;
let timeRef: { readonly value: number } | undefined;
let deltaRef: { readonly value: number } | undefined;
const App = defineComponent(() => {
const { frame, time, delta, reset } = useAnimation({ interval: 50 });
resetFn = reset;
frameRef = frame;
timeRef = time;
deltaRef = delta;
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(200);
expect(frameRef!.value).toBeGreaterThan(0);
resetFn!();
expect(frameRef!.value).toBe(0);
expect(timeRef!.value).toBe(0);
expect(deltaRef!.value).toBe(0);
unmount();
});
test("time and delta are populated", async () => {
let timeVal = 0;
let deltaVal = 0;
const App = defineComponent(() => {
const { time, delta } = useAnimation({ interval: 50 });
watchEffect(() => {
timeVal = time.value;
deltaVal = delta.value;
});
return () => <Text>anim</Text>;
});
const { unmount } = await render(App);
await delay(200);
expect(timeVal).toBeGreaterThan(0);
expect(deltaVal).toBeGreaterThan(0);
unmount();
});
test("non-finite interval defaults to 100ms", async () => {
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: Infinity });
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(250);
unmount();
});
test("NaN interval defaults to 100ms", async () => {
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: NaN });
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(250);
unmount();
});
test("cleans up timer on unmount", async () => {
const App = defineComponent(() => {
useAnimation({ interval: 50 });
return () => <Text>cleanup</Text>;
});
const { unmount } = await render(App);
unmount();
});
test("default options work", async () => {
const frames: number[] = [];
const App = defineComponent(() => {
const { frame } = useAnimation();
watchEffect(() => {
frames.push(frame.value);
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(350);
unmount();
expect(frames.length).toBeGreaterThan(1);
});
// ---------------------------------------------------------------
// New 34 tests ported from Ink
// ---------------------------------------------------------------
test("multiple animations with the same interval stay in sync", async () => {
const frames: Array<[number, number]> = [];
const App = defineComponent(() => {
const { frame: frame1 } = useAnimation({ interval: 50 });
const { frame: frame2 } = useAnimation({ interval: 50 });
watchEffect(() => {
frames.push([frame1.value, frame2.value]);
});
return () => (
<Text>
{String(frame1.value)},{String(frame2.value)}
</Text>
);
});
const { unmount } = await render(App);
await delay(200);
unmount();
const last = frames.at(-1)!;
expect(Math.abs(last[0] - last[1])).toBeLessThanOrEqual(1);
expect(last[0]).toBeGreaterThanOrEqual(1);
});
test("multiple animations with the same interval both advance", async () => {
let frame1Val = 0;
let frame2Val = 0;
const App = defineComponent(() => {
const { frame: frame1 } = useAnimation({ interval: 50 });
const { frame: frame2 } = useAnimation({ interval: 50 });
watchEffect(() => {
frame1Val = frame1.value;
frame2Val = frame2.value;
});
return () => (
<Text>
{String(frame1.value)},{String(frame2.value)}
</Text>
);
});
const { unmount } = await render(App);
await delay(200);
unmount();
expect(frame1Val).toBeGreaterThanOrEqual(1);
expect(frame2Val).toBeGreaterThanOrEqual(1);
expect(Math.abs(frame1Val - frame2Val)).toBeLessThanOrEqual(1);
});
test("animations with different intervals advance at different rates", async () => {
let fastFrame = 0;
let slowFrame = 0;
const App = defineComponent(() => {
const { frame: fast } = useAnimation({ interval: 50 });
const { frame: slow } = useAnimation({ interval: 200 });
watchEffect(() => {
fastFrame = fast.value;
slowFrame = slow.value;
});
return () => (
<Text>
{String(fast.value)},{String(slow.value)}
</Text>
);
});
const { unmount } = await render(App);
await delay(500);
unmount();
expect(fastFrame).toBeGreaterThan(slowFrame);
});
test("timer is cleaned up and recreated after unmount and remount", async () => {
let frameVal = 0;
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 50 });
watchEffect(() => {
frameVal = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const first = await render(App);
await delay(150);
expect(frameVal).toBeGreaterThanOrEqual(1);
first.unmount();
frameVal = 0;
const second = await render(App);
await delay(150);
expect(frameVal).toBeGreaterThanOrEqual(1);
second.unmount();
});
test("animation continues when a sibling animation unmounts", async () => {
const showSecond = shallowRef(true);
let firstFrame = 0;
const AnimationValue = defineComponent(() => {
const { frame } = useAnimation({ interval: 50 });
watchEffect(() => {
firstFrame = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const SecondAnimation = defineComponent(() => {
const { frame } = useAnimation({ interval: 50 });
return () => <Text>{String(frame.value)}</Text>;
});
const App = defineComponent(() => {
return () => (
<>
<AnimationValue />
{showSecond.value ? <SecondAnimation /> : undefined}
</>
);
});
const { unmount } = await render(App);
await delay(150);
const frameBeforeRemoval = firstFrame;
expect(frameBeforeRemoval).toBeGreaterThanOrEqual(1);
showSecond.value = false;
await nextTick();
await delay(150);
expect(firstFrame).toBeGreaterThan(frameBeforeRemoval);
unmount();
});
test("animation with different-interval sibling continues after sibling unmounts", async () => {
const showSecond = shallowRef(true);
let fastFrame = 0;
const FastAnimation = defineComponent(() => {
const { frame } = useAnimation({ interval: 50 });
watchEffect(() => {
fastFrame = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const SlowAnimation = defineComponent(() => {
const { frame } = useAnimation({ interval: 80 });
return () => <Text>{String(frame.value)}</Text>;
});
const App = defineComponent(() => {
return () => (
<>
<FastAnimation />
{showSecond.value ? <SlowAnimation /> : undefined}
</>
);
});
const { unmount } = await render(App);
await delay(150);
const frameBeforeRemoval = fastFrame;
expect(frameBeforeRemoval).toBeGreaterThanOrEqual(1);
showSecond.value = false;
await nextTick();
await delay(150);
expect(fastFrame).toBeGreaterThan(frameBeforeRemoval);
unmount();
});
test("inactive animations do not start timer until one becomes active", async () => {
const isFirstActive = shallowRef(false);
const isSecondActive = shallowRef(false);
let firstFrame = 0;
let secondFrame = 0;
const App = defineComponent(() => {
const { frame: f1 } = useAnimation({ interval: 50, isActive: isFirstActive });
const { frame: f2 } = useAnimation({ interval: 50, isActive: isSecondActive });
watchEffect(() => {
firstFrame = f1.value;
secondFrame = f2.value;
});
return () => (
<Text>
{String(f1.value)},{String(f2.value)}
</Text>
);
});
const { unmount } = await render(App);
await delay(150);
expect(firstFrame).toBe(0);
expect(secondFrame).toBe(0);
isFirstActive.value = true;
await delay(150);
expect(firstFrame).toBeGreaterThanOrEqual(1);
expect(secondFrame).toBe(0);
unmount();
});
test("cleans up on unmount - no new frames after unmount", async () => {
let frameVal = 0;
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 50 });
watchEffect(() => {
frameVal = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(120);
unmount();
const frameAfterUnmount = frameVal;
await delay(150);
expect(frameVal).toBe(frameAfterUnmount);
});
test("no timer leak when all animations are inactive", async () => {
const active = shallowRef(false);
let frameVal = 0;
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 50, isActive: active });
watchEffect(() => {
frameVal = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(150);
expect(frameVal).toBe(0);
active.value = true;
await delay(150);
expect(frameVal).toBeGreaterThanOrEqual(1);
active.value = false;
await nextTick();
const frozenFrame = frameVal;
await delay(150);
expect(frameVal).toBe(frozenFrame);
unmount();
});
test("resets frame when isActive toggles from false to true", async () => {
const active = shallowRef(true);
let frameVal = 0;
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 50, isActive: active });
watchEffect(() => {
frameVal = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(150);
expect(frameVal).toBeGreaterThanOrEqual(1);
active.value = false;
await nextTick();
active.value = true;
await nextTick();
expect(frameVal).toBe(0);
await delay(150);
expect(frameVal).toBeGreaterThanOrEqual(1);
unmount();
});
test("resets frame when remounted with different interval", async () => {
let frameVal = 0;
const makeApp = (interval: number) =>
defineComponent(() => {
const { frame } = useAnimation({ interval });
watchEffect(() => {
frameVal = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const first = await render(makeApp(50));
await delay(150);
expect(frameVal).toBeGreaterThanOrEqual(1);
first.unmount();
const second = await render(makeApp(200));
expect(frameVal).toBe(0);
second.unmount();
});
// Ink parity (use-animation.tsx:529-566 "time and delta reset to 0 when
// interval changes"): changing `interval` reactively on a LIVE, mounted,
// ACTIVE animation (no remount) must reset frame/time/delta to 0 and
// re-subscribe at the new interval — Ink recomputes safeInterval every render
// and resets when it differs while active. This is the bug repro: a captured
// (non-reactive) interval makes this a no-op.
test("frame/time/delta reset to 0 when interval changes live while active", async () => {
const interval = shallowRef(50);
let frameVal = 0;
let timeVal = 0;
let deltaVal = 0;
const App = defineComponent(() => {
const { frame, time, delta } = useAnimation({ interval });
watchEffect(() => {
frameVal = frame.value;
timeVal = time.value;
deltaVal = delta.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(200);
expect(frameVal).toBeGreaterThanOrEqual(1);
expect(timeVal).toBeGreaterThanOrEqual(50);
// Mirror Ink's '0,0,0' assertion: changing the live interval resets all three.
interval.value = 200;
await nextTick();
expect(frameVal).toBe(0);
expect(timeVal).toBe(0);
expect(deltaVal).toBe(0);
// And it keeps running at the new interval afterwards.
await delay(250);
expect(frameVal).toBeGreaterThanOrEqual(1);
unmount();
});
// Ink parity: `shouldReset` is gated on `isActive`. Changing `interval` while
// INACTIVE must NOT reset values (the animation is paused — nothing to reset)
// and must NOT start a timer. This matches vue-tui's existing reset-while-paused
// behavior. The new interval only takes effect on the next activation.
test("changing interval while inactive does not reset and does not start", async () => {
const interval = shallowRef(50);
const active = shallowRef(false);
let frameVal = -1;
const App = defineComponent(() => {
const { frame } = useAnimation({ interval, isActive: active });
watchEffect(() => {
frameVal = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
// Inactive from mount: frame stays 0.
await delay(120);
expect(frameVal).toBe(0);
// Change interval while still inactive — must remain frozen at 0, no timer.
interval.value = 200;
await nextTick();
await delay(120);
expect(frameVal).toBe(0);
// Activating now uses the new interval and advances.
active.value = true;
await delay(250);
expect(frameVal).toBeGreaterThanOrEqual(1);
unmount();
});
test("interval accepts a plain number (backward compatible)", async () => {
// The widening to MaybeRefOrGetter<number> must remain a strict superset:
// a literal number still works exactly as before.
let frameVal = 0;
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 50 });
watchEffect(() => {
frameVal = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(150);
expect(frameVal).toBeGreaterThanOrEqual(1);
unmount();
});
test("time and delta reset to 0 when animation is resumed", async () => {
const active = shallowRef(true);
let frameVal = 0;
let timeVal = 0;
let deltaVal = 0;
const App = defineComponent(() => {
const { frame, time, delta } = useAnimation({ interval: 50, isActive: active });
watchEffect(() => {
frameVal = frame.value;
timeVal = time.value;
deltaVal = delta.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(200);
expect(frameVal).toBeGreaterThanOrEqual(1);
expect(timeVal).toBeGreaterThanOrEqual(50);
active.value = false;
await nextTick();
active.value = true;
await nextTick();
expect(frameVal).toBe(0);
expect(timeVal).toBe(0);
expect(deltaVal).toBe(0);
unmount();
});
test("treats negative Infinity interval as the default interval", async () => {
let frameVal = 0;
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: Number.NEGATIVE_INFINITY });
watchEffect(() => {
frameVal = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(250);
expect(frameVal).toBeGreaterThanOrEqual(1);
unmount();
});
test("clamps oversized finite interval to the timer maximum", async () => {
let frameVal = 0;
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: Number.MAX_SAFE_INTEGER });
watchEffect(() => {
frameVal = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(200);
expect(frameVal).toBe(0);
unmount();
});
// -- Behavior over real time (migrated from fake-timer tests) --
// Timer-precision and exact-frame assertions now live in the scheduler
// unit tests (packages/runtime-tests/unit/animation-scheduler.test.ts).
// These verify observable end-to-end behavior only, using real timers.
describe("behavior over real time", () => {
test("frame increments over time", async () => {
let frame!: Readonly<ShallowRef<number>>;
const App = defineComponent(() => {
frame = useAnimation({ interval: 50 }).frame;
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(220);
expect(frame.value).toBeGreaterThanOrEqual(3);
unmount();
});
test("two same-interval animations stay in sync", async () => {
let f1!: Readonly<ShallowRef<number>>;
let f2!: Readonly<ShallowRef<number>>;
const App = defineComponent(() => {
f1 = useAnimation({ interval: 50 }).frame;
f2 = useAnimation({ interval: 50 }).frame;
return () => <Text>{`${f1.value},${f2.value}`}</Text>;
});
const { unmount } = await render(App);
await delay(180);
expect(f1.value).toBe(f2.value);
expect(f1.value).toBeGreaterThanOrEqual(1);
unmount();
});
test("different intervals advance at different rates", async () => {
let fast!: Readonly<ShallowRef<number>>;
let slow!: Readonly<ShallowRef<number>>;
const App = defineComponent(() => {
fast = useAnimation({ interval: 30 }).frame;
slow = useAnimation({ interval: 120 }).frame;
return () => <Text>{`${fast.value},${slow.value}`}</Text>;
});
const { unmount } = await render(App);
await delay(300);
expect(fast.value).toBeGreaterThan(slow.value);
unmount();
});
test("pause via isActive freezes the frame", async () => {
const active = shallowRef(true);
let frame!: Readonly<ShallowRef<number>>;
const App = defineComponent(() => {
frame = useAnimation({ interval: 30, isActive: () => active.value }).frame;
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(150);
expect(frame.value).toBeGreaterThanOrEqual(1);
active.value = false;
await nextTick();
const frozen = frame.value;
await delay(150);
expect(frame.value).toBe(frozen);
unmount();
});
test("reactivating isActive resets the frame to 0 then advances", async () => {
const active = shallowRef(true);
let frame!: Readonly<ShallowRef<number>>;
const App = defineComponent(() => {
frame = useAnimation({ interval: 30, isActive: () => active.value }).frame;
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(150);
active.value = false;
await nextTick();
active.value = true;
await nextTick();
expect(frame.value).toBe(0);
await delay(150);
expect(frame.value).toBeGreaterThanOrEqual(1);
unmount();
});
// Ink parity (use-animation.ts:83-89,138): reset() while PAUSED only bumps
// resetKey; the zeroing (setAnimState(zeroAnimState)) lives inside the
// layout effect, which early-returns while !isActive. shouldReset is gated
// on isActive too. So a paused reset() keeps the last frame frozen and zeros
// only on the NEXT resume. (Previously this test locked the divergent
// immediate-zero-while-paused behavior; flipped to match Ink.)
test("reset() while paused keeps the last frame; zeros on resume", async () => {
const active = shallowRef(true);
let frame!: Readonly<ShallowRef<number>>;
let reset!: () => void;
const App = defineComponent(() => {
const anim = useAnimation({ interval: 30, isActive: () => active.value });
frame = anim.frame;
reset = anim.reset;
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(120);
active.value = false;
await nextTick();
const frozen = frame.value;
expect(frozen).toBeGreaterThanOrEqual(1);
// reset() while paused must NOT zero — the last frame stays frozen.
reset();
await nextTick();
expect(frame.value).toBe(frozen);
await delay(120);
expect(frame.value).toBe(frozen);
// Resuming zeros (the deferred reset lands here) then advances.
active.value = true;
await nextTick();
expect(frame.value).toBe(0);
await delay(120);
expect(frame.value).toBeGreaterThanOrEqual(1);
unmount();
});
// Guard: reset() while ACTIVE must still zero frame/time/delta immediately
// (Ink: the layout effect re-runs on resetKey while isActive and calls
// setAnimState(zeroAnimState)). The paused-reset fix must not regress this.
test("reset() while active zeros immediately then keeps advancing", async () => {
let frame!: Readonly<ShallowRef<number>>;
let time!: Readonly<ShallowRef<number>>;
let delta!: Readonly<ShallowRef<number>>;
let reset!: () => void;
const App = defineComponent(() => {
const anim = useAnimation({ interval: 30 });
frame = anim.frame;
time = anim.time;
delta = anim.delta;
reset = anim.reset;
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(150);
expect(frame.value).toBeGreaterThanOrEqual(1);
reset();
expect(frame.value).toBe(0);
expect(time.value).toBe(0);
expect(delta.value).toBe(0);
await delay(150);
expect(frame.value).toBeGreaterThanOrEqual(1);
unmount();
});
test("all-inactive animations never advance", async () => {
let f1!: Readonly<ShallowRef<number>>;
let f2!: Readonly<ShallowRef<number>>;
const App = defineComponent(() => {
f1 = useAnimation({ interval: 30, isActive: () => false }).frame;
f2 = useAnimation({ interval: 30, isActive: () => false }).frame;
return () => <Text>{`${f1.value},${f2.value}`}</Text>;
});
const { unmount } = await render(App);
await delay(150);
expect(f1.value).toBe(0);
expect(f2.value).toBe(0);
unmount();
});
});
// ---------------------------------------------------------------
// Real-timer tests that need actual wall-clock delays
// ---------------------------------------------------------------
test("delta is positive after each tick", async () => {
const deltas: number[] = [];
const App = defineComponent(() => {
const { delta } = useAnimation({ interval: 20 });
watchEffect(() => {
deltas.push(delta.value);
});
return () => <Text>x</Text>;
});
const { unmount } = await render(App);
await delay(120);
unmount();
expect(deltas[0]).toBe(0);
for (let i = 1; i < deltas.length; i++) {
expect(deltas[i]).toBeGreaterThan(0);
}
});
test("isActive as getter function works", async () => {
const active = shallowRef(false);
let frameVal = 0;
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 50, isActive: () => active.value });
watchEffect(() => {
frameVal = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(150);
expect(frameVal).toBe(0);
active.value = true;
await delay(150);
expect(frameVal).toBeGreaterThanOrEqual(1);
unmount();
});
test("animation produces monotonically increasing frame values", async () => {
const frames: number[] = [];
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 30 });
watchEffect(() => {
frames.push(frame.value);
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(200);
unmount();
for (let i = 1; i < frames.length; i++) {
expect(frames[i]).toBeGreaterThanOrEqual(frames[i - 1]!);
}
});
test("animation produces monotonically increasing time values", async () => {
const times: number[] = [];
const App = defineComponent(() => {
const { time } = useAnimation({ interval: 30 });
watchEffect(() => {
times.push(time.value);
});
return () => <Text>x</Text>;
});
const { unmount } = await render(App);
await delay(200);
unmount();
for (let i = 1; i < times.length; i++) {
expect(times[i]).toBeGreaterThanOrEqual(times[i - 1]!);
}
});
test("reset restarts timing from current moment", async () => {
let resetFnLocal!: () => void;
let timeRefLocal!: { readonly value: number };
const App = defineComponent(() => {
const { time, reset } = useAnimation({ interval: 50 });
resetFnLocal = reset;
timeRefLocal = time;
return () => <Text>{String(Math.round(time.value))}</Text>;
});
const { unmount } = await render(App);
await delay(200);
const timeBefore = timeRefLocal.value;
expect(timeBefore).toBeGreaterThan(0);
resetFnLocal();
expect(timeRefLocal.value).toBe(0);
await delay(120);
expect(timeRefLocal.value).toBeLessThan(timeBefore);
expect(timeRefLocal.value).toBeGreaterThan(0);
unmount();
});
test("multiple independent animations run without interference", async () => {
let frame30 = 0;
let frame100 = 0;
const App = defineComponent(() => {
const anim1 = useAnimation({ interval: 30 });
const anim2 = useAnimation({ interval: 100 });
watchEffect(() => {
frame30 = anim1.frame.value;
frame100 = anim2.frame.value;
});
return () => <Text>multi</Text>;
});
const { unmount } = await render(App);
await delay(350);
unmount();
expect(frame30).toBeGreaterThan(frame100);
expect(frame100).toBeGreaterThanOrEqual(1);
});
test("animation with isActive=true from mount starts immediately", async () => {
let frameVal = 0;
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 50, isActive: true });
watchEffect(() => {
frameVal = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(150);
expect(frameVal).toBeGreaterThanOrEqual(1);
unmount();
});
test("wall clock changes do not move animations backwards", async () => {
const frames: number[] = [];
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 30 });
watchEffect(() => {
frames.push(frame.value);
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(200);
unmount();
// All frames should be monotonically non-decreasing (clock jumps backward
// should never cause frame to decrease).
for (let i = 1; i < frames.length; i++) {
expect(frames[i]).toBeGreaterThanOrEqual(frames[i - 1]!);
}
expect(frames.at(-1)!).toBeGreaterThanOrEqual(1);
});
test("pause and resume multiple times maintains correct frame sequence", async () => {
const active = shallowRef(true);
const frameHistory: number[] = [];
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 40, isActive: active });
watchEffect(() => {
frameHistory.push(frame.value);
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(120);
expect(frameHistory.at(-1)!).toBeGreaterThanOrEqual(1);
// Pause
active.value = false;
await delay(50);
// Resume - resets to 0
active.value = true;
await nextTick();
expect(frameHistory.at(-1)!).toBe(0);
await delay(120);
expect(frameHistory.at(-1)!).toBeGreaterThanOrEqual(1);
// Pause again
active.value = false;
await delay(50);
// Resume again - resets to 0
active.value = true;
await nextTick();
expect(frameHistory.at(-1)!).toBe(0);
unmount();
});
// ---------------------------------------------------------------
// Tests using createApp directly (for maxFps / interactive options)
// The testing render() helper always uses debug:true which disables
// render throttling. These tests need mount-level options.
// ---------------------------------------------------------------
/**
* Helper: mount a component via createApp with custom MountOptions.
* Returns frames array, lastFrame getter, and unmount function.
*/
function mountWithOptions(
component: ReturnType<typeof defineComponent>,
mountOpts: {
debug?: boolean;
maxFps?: number;
interactive?: boolean;
} = {},
) {
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() {},
});
const frames: string[] = [];
stdout.on("data", (chunk) => {
let raw = chunk.toString();
if (raw.endsWith("\n")) raw = raw.slice(0, -1);
frames.push(raw);
});
const app = createApp(component);
app.mount({
stdout,
stdin,
stderr,
debug: mountOpts.debug ?? false,
exitOnCtrlC: false,
maxFps: mountOpts.maxFps,
interactive: mountOpts.interactive,
});
return {
frames,
lastFrame: () => frames.at(-1),
unmount: () => app.unmount(),
};
}
test("low maxFps caps animation rerenders", async () => {
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 10 });
return () => <Text>{String(frame.value)}</Text>;
});
// maxFps: 1 => throttleMs = 1000ms. Animation ticks every 10ms but
// commits (stdout writes) should be throttled to ~1 per second.
const { frames, unmount } = mountWithOptions(App, { maxFps: 1 });
await nextTick();
await nextTick();
const framesAfterMount = frames.length;
// Wait 500ms — animation has ticked ~50 times but commits are throttled.
// With maxFps=1 (1000ms throttle), no new commit should have happened yet.
await delay(500);
const framesDuringThrottle = frames.length - framesAfterMount;
// Should have very few committed frames during the throttle window
expect(framesDuringThrottle).toBeLessThanOrEqual(2);
// Wait for a full throttle window to elapse
await delay(1200);
// Now at least one more frame should have been committed
expect(frames.length).toBeGreaterThan(framesAfterMount);
unmount();
});
test("delta accumulates across coalesced ticks within the render-throttle window", async () => {
// Ink parity (G02): useAnimation coalesces ticks while inside the current
// render-throttle window and reports delta = time since the LAST RENDERED
// tick, so velocity-driven motion (position += speed * delta) advances at
// correct wall-clock speed despite throttled commits. With a fast interval
// and a slow throttle, each rendered delta must reflect the accumulated
// window (~throttleMs), NOT a single ~interval.
const renderedDeltas: number[] = [];
const App = defineComponent(() => {
const { delta } = useAnimation({ interval: 20 });
watchEffect(() => {
renderedDeltas.push(delta.value);
});
return () => <Text>x</Text>;
});
// maxFps: 5 => throttleMs = 200ms. Animation ticks every 20ms, so ~10
// ticks fall inside each throttle window and must coalesce into one
// rendered delta of ~200ms.
const { unmount } = mountWithOptions(App, { maxFps: 5 });
await nextTick();
// Initial rendered delta is 0.
expect(renderedDeltas[0]).toBe(0);
// Wait across several full 200ms throttle windows.
await delay(700);
unmount();
// The non-zero rendered deltas correspond to ticks that actually committed
// after coalescing. Each should be on the order of the throttle window
// (~200ms), well above a single 20ms interval. A buggy implementation that
// reports one interval per committed tick yields deltas ~20ms and fails here.
const committedDeltas = renderedDeltas.filter((d) => d > 0);
expect(committedDeltas.length).toBeGreaterThan(0);
const maxDelta = Math.max(...committedDeltas);
expect(maxDelta).toBeGreaterThan(120);
});
test("default path (no explicit maxFps) coalesces ticks via the 30fps throttle", async () => {
// Ink parity (G02): with no explicit maxFps, the render throttle must still
// engage at the default 30fps (~34ms window), so a fast animation coalesces
// its ticks and reports a delta of ~one throttle window, NOT one interval.
//
// Discrimination strategy (Approach A — count-based):
// interval = 8ms, window = ~250ms
// Fixed (renderThrottleMs=34): ~7 rendered commits (250/34 ≈ 7)
// Unfixed (renderThrottleMs=0): ~31 rendered commits (250/8 ≈ 31)
//
// We assert committedDeltas.length < 15 AND maxDelta >= 30.
// • Even with 2× CI scheduling jitter some callbacks fire late and run
// together, the unfixed per-tick path still produces one delta per
// scheduler callback — at least ~25 over 250ms — never < 15.
// • The fixed path accumulates across skipped ticks so each rendered
// delta spans the ~34ms window; even with conservative 30ms threshold
// it never falls to the ~8ms per-tick value.
//
// A plain maxDelta > 20 assertion (the old form) was non-discriminating:
// a single OS-jitter-delayed callback on the unfixed path can exceed 20ms
// and silently pass. The count assertion rules that out completely.
const renderedDeltas: number[] = [];
const App = defineComponent(() => {
const { delta } = useAnimation({ interval: 8 });
watchEffect(() => {
renderedDeltas.push(delta.value);
});
return () => <Text>x</Text>;
});
// No maxFps -> defaults to 30 -> renderThrottleMs = ceil(1000/30) = 34ms.
// Animation ticks every 8ms, so ~4 ticks fall inside each window and must
// coalesce into one rendered delta of ~34ms.
const { unmount } = mountWithOptions(App, {});
await nextTick();
expect(renderedDeltas[0]).toBe(0);
const windowMs = 250;
const intervalMs = 8;
await delay(windowMs);
unmount();
const committedDeltas = renderedDeltas.filter((d) => d > 0);
expect(committedDeltas.length).toBeGreaterThan(0);
// COUNT assertion: fixed path coalesces → far fewer renders than raw ticks.
// Upper bound is half the expected raw-tick count; no amount of per-callback
// jitter on the unfixed path can produce fewer than this.
const rawTicksExpected = windowMs / intervalMs; // ~31
expect(committedDeltas.length).toBeLessThan(rawTicksExpected / 2); // < ~15
// DELTA assertion: each coalesced render covers ~34ms, well above 8ms.
const maxDelta = Math.max(...committedDeltas);
expect(maxDelta).toBeGreaterThanOrEqual(30);
});
test("animations advance in debug mode when interactive is false", async () => {
let frameVal = 0;
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 50 });
watchEffect(() => {
frameVal = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
// Mount with debug: true + interactive: false. Animations should still
// advance because the animation timer (setInterval) is independent of
// the interactive/non-interactive rendering mode.
const { unmount } = mountWithOptions(App, {
debug: true,
interactive: false,
});
await nextTick();
expect(frameVal).toBe(0);
await delay(200);
expect(frameVal).toBeGreaterThanOrEqual(1);
unmount();
});
test("renderToString renders frame 0 without throwing or leaking timers", () => {
const App = defineComponent(() => {
const { frame } = useAnimation({ interval: 50 });
return () => <Text>{`frame:${frame.value}`}</Text>;
});
const output = renderToString(App);
expect(output).toContain("frame:0");
});
});