Files
vue-tui/packages/runtime-tests/integration/composables/use-animation.test.tsx
T

981 lines
30 KiB
TypeScript

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();
});
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();
});
test("reset() while paused zeroes refs and stays at 0", 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();
reset();
await nextTick();
expect(frame.value).toBe(0);
await delay(120);
expect(frame.value).toBe(0);
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 accounts for throttled ticks", async () => {
// With low maxFps, renders are throttled. When the animation finally
// re-renders, delta should reflect the actual elapsed time since the
// last rendered tick, not just one interval.
let lastRenderedDelta = 0;
const App = defineComponent(() => {
const { delta } = useAnimation({ interval: 20 });
watchEffect(() => {
lastRenderedDelta = delta.value;
});
return () => <Text>x</Text>;
});
// maxFps: 5 => throttleMs = 200ms. Animation ticks every 20ms.
// Renders are throttled to ~200ms windows.
const { unmount } = mountWithOptions(App, { maxFps: 5 });
await nextTick();
expect(lastRenderedDelta).toBe(0);
// Wait well past one full 200ms throttle window
await delay(350);
// Delta should reflect the actual time between ticks (each tick is 20ms),
// not the throttle window. The last delta seen should be ~20ms per tick.
expect(lastRenderedDelta).toBeGreaterThan(0);
unmount();
});
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");
});
});