Files
next-tjwater-frontend/src/shared/ai-elements/persona-hover.test.ts
T
jiang e57e0f099f fix: smooth persona hover transitions
Preserve the original reflection material while adding pointer inertia and a short exit hold. Retain the Rive pointer constraint through the fade and blend rapid re-entry to avoid abrupt changes.

Cover timing, re-entry, cancellation, renderer disposal and native Rive frames. The fade regression fails with the previous resource and passes with the updated resource.
2026-10-28 11:46:14 +08:00

96 lines
3.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPersonaHover } from "./persona-hover";
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
function setup() {
const canvas = document.createElement("canvas");
const moves: number[] = [];
const enter = vi.fn();
const exit = vi.fn();
canvas.addEventListener("mousemove", (event) => moves.push(event.clientX));
canvas.addEventListener("mouseover", enter);
canvas.addEventListener("mouseout", exit);
return { hover: createPersonaHover(canvas), moves, enter, exit };
}
describe("Persona hover timing", () => {
it("positions the original reflection before entry and follows movement with inertia", () => {
const { hover, moves } = setup();
hover.enter({ clientX: 16, clientY: 32 });
expect(moves).toEqual([16]);
hover.move({ clientX: 48, clientY: 32 });
expect(moves).toEqual([16]);
vi.advanceTimersByTime(80);
expect(moves.at(-1)).toBeGreaterThan(32);
expect(moves.at(-1)).toBeLessThan(48);
vi.advanceTimersByTime(800);
expect(moves.at(-1)).toBe(48);
expect(vi.getTimerCount()).toBe(0);
hover.dispose();
});
it("holds the last in-bounds position for 100ms before asking Rive to fade", () => {
const { hover, moves, exit } = setup();
hover.enter({ clientX: 16, clientY: 32 });
hover.move({ clientX: 48, clientY: 32 });
hover.leave();
vi.advanceTimersByTime(99);
expect(exit).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(exit).toHaveBeenCalledOnce();
const count = moves.length;
vi.advanceTimersByTime(1000);
expect(moves).toHaveLength(count);
expect(vi.getTimerCount()).toBe(0);
});
it("cancels a pending exit on quick re-entry without replaying the entrance", () => {
const { hover, enter, exit } = setup();
hover.enter({ clientX: 16, clientY: 32 });
hover.leave();
vi.advanceTimersByTime(60);
hover.enter({ clientX: 48, clientY: 32 });
vi.advanceTimersByTime(1000);
expect(enter).toHaveBeenCalledOnce();
expect(exit).not.toHaveBeenCalled();
hover.dispose();
expect(vi.getTimerCount()).toBe(0);
});
it("releases cancellation immediately and removes pending work on disposal", () => {
const { hover, moves, exit } = setup();
hover.enter({ clientX: 16, clientY: 32 });
hover.leave();
hover.cancel();
expect(exit).toHaveBeenCalledOnce();
hover.enter({ clientX: 48, clientY: 32 });
hover.move({ clientX: 32, clientY: 32 });
hover.leave();
hover.dispose();
const count = moves.length;
vi.advanceTimersByTime(1000);
expect(moves).toHaveLength(count);
expect(exit).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it("continues from the visible reflection when re-entering during its fade", () => {
const { hover, moves } = setup();
hover.enter({ clientX: 16, clientY: 32 });
hover.leave();
vi.advanceTimersByTime(200);
hover.enter({ clientX: 48, clientY: 32 });
expect(moves.at(-1)).toBe(16);
vi.advanceTimersByTime(80);
expect(moves.at(-1)).toBeGreaterThan(16);
expect(moves.at(-1)).toBeLessThan(48);
hover.leave();
vi.advanceTimersByTime(800);
hover.enter({ clientX: 32, clientY: 32 });
expect(moves.at(-1)).toBe(32);
hover.dispose();
});
});