Files
photo-live/apps/viewer/src/__tests__/heart-particles.spec.ts
T
浅念 160e7e5f1a feat(viewer): UI-02 heart particles, interaction notices, share and download
Task 5 (V05 §5.4/§6): decorative heart particles on the lightbox stage
(local like clicks + others' WS bursts for the current photo), compact
cross-photo interaction notices above the action bar (click to locate),
navigator.share-first share orchestration with clipboard fallback for
album and photo deep links (lang param), and download failures attributed
to the photo that triggered them. Self-echo dedup stays in the realtime
router (own batches never produce particles/notices); particles cap at
12 with visibility/unmount cleanup and are suppressed under reduced
motion; notices cap at 3 with ~4s auto-dismiss and throttled SR summary.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 22:20:19 +08:00

101 lines
3.3 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 { 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);
});
});