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