101 lines
3.3 KiB
TypeScript
101 lines
3.3 KiB
TypeScript
|
|
import { mount, type VueWrapper } from '@vue/test-utils';
|
|||
|
|
import { nextTick } from 'vue';
|
|||
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
|
import HeartParticles from '../components/viewer/HeartParticles.vue';
|
|||
|
|
import { setReducedMotion } from '../ui/prefs';
|
|||
|
|
|
|||
|
|
// UI-02 V05 HeartParticles 单测(§6.2/§6.3):burst 生成粒子上限 12(超限弃
|
|||
|
|
// 最旧)、动画时长后自清理、页面隐藏即清空、「减少动画」时不生成/开启即清空。
|
|||
|
|
// 粒子为纯装饰层(aria-hidden + pointer-events:none)。
|
|||
|
|
|
|||
|
|
const mounted: VueWrapper[] = [];
|
|||
|
|
|
|||
|
|
interface ParticlesExposed {
|
|||
|
|
burst: (count: number) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function mountParticles(): VueWrapper & { vm: ParticlesExposed } {
|
|||
|
|
const wrapper = mount(HeartParticles);
|
|||
|
|
mounted.push(wrapper);
|
|||
|
|
return wrapper as VueWrapper & { vm: ParticlesExposed };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setHidden(hidden: boolean): void {
|
|||
|
|
Object.defineProperty(document, 'hidden', { value: hidden, configurable: true });
|
|||
|
|
document.dispatchEvent(new Event('visibilitychange'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
describe('heart particles (UI-02 V05)', () => {
|
|||
|
|
beforeEach(() => {
|
|||
|
|
localStorage.clear();
|
|||
|
|
setReducedMotion(false);
|
|||
|
|
vi.useFakeTimers();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
afterEach(() => {
|
|||
|
|
while (mounted.length > 0) mounted.pop()?.unmount();
|
|||
|
|
vi.useRealTimers();
|
|||
|
|
setReducedMotion(false);
|
|||
|
|
setHidden(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('creates particles on burst and removes them after the float animation', async () => {
|
|||
|
|
const wrapper = mountParticles();
|
|||
|
|
|
|||
|
|
wrapper.vm.burst(3);
|
|||
|
|
await nextTick();
|
|||
|
|
expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(3);
|
|||
|
|
|
|||
|
|
// 动画时长上限 1.8s + 收尾缓冲:之后自清理(不留冻结的过期粒子)。
|
|||
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|||
|
|
expect(wrapper.findAll('.particle')).toHaveLength(0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('caps concurrent particles at 12, dropping the oldest', async () => {
|
|||
|
|
const wrapper = mountParticles();
|
|||
|
|
|
|||
|
|
wrapper.vm.burst(10);
|
|||
|
|
await nextTick();
|
|||
|
|
expect(wrapper.findAll('.particle')).toHaveLength(10);
|
|||
|
|
|
|||
|
|
wrapper.vm.burst(5);
|
|||
|
|
await nextTick();
|
|||
|
|
expect(wrapper.findAll('.particle')).toHaveLength(12);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('clears particles and timers when the page becomes hidden', async () => {
|
|||
|
|
const wrapper = mountParticles();
|
|||
|
|
|
|||
|
|
wrapper.vm.burst(6);
|
|||
|
|
await nextTick();
|
|||
|
|
expect(wrapper.findAll('.particle')).toHaveLength(6);
|
|||
|
|
|
|||
|
|
setHidden(true);
|
|||
|
|
await nextTick();
|
|||
|
|
expect(wrapper.findAll('.particle')).toHaveLength(0);
|
|||
|
|
|
|||
|
|
// 隐藏期间清理的计时器不再复活(§6.3 不回放)。
|
|||
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|||
|
|
expect(wrapper.findAll('.particle')).toHaveLength(0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('does not render particles under reduced motion, and clears in-flight ones when it turns on', async () => {
|
|||
|
|
const wrapper = mountParticles();
|
|||
|
|
|
|||
|
|
setReducedMotion(true);
|
|||
|
|
wrapper.vm.burst(4);
|
|||
|
|
await nextTick();
|
|||
|
|
expect(wrapper.findAll('.particle')).toHaveLength(0); // 不漂浮(§6.3)
|
|||
|
|
|
|||
|
|
setReducedMotion(false);
|
|||
|
|
wrapper.vm.burst(2);
|
|||
|
|
await nextTick();
|
|||
|
|
expect(wrapper.findAll('.particle')).toHaveLength(2);
|
|||
|
|
|
|||
|
|
// 漂浮中途开启「减少动画」:在屏粒子立即清空(不残留冻结心形)。
|
|||
|
|
setReducedMotion(true);
|
|||
|
|
await nextTick();
|
|||
|
|
expect(wrapper.findAll('.particle')).toHaveLength(0);
|
|||
|
|
});
|
|||
|
|
});
|