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>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { copyText } from '../support/clipboard';
|
||||
import { shareOrCopy } from '../support/share';
|
||||
|
||||
// UI-02 V05 分享编排单测(§5.4):navigator.share 优先(成功即原生面板收尾);
|
||||
// AbortError = 用户取消(静默,不落剪贴板);其他失败/不可用回退剪贴板;
|
||||
// 复制失败返回 failed(调用方展示可选中文本)。
|
||||
|
||||
vi.mock('../support/clipboard', () => ({ copyText: vi.fn() }));
|
||||
|
||||
const mockedCopyText = vi.mocked(copyText);
|
||||
|
||||
function stubShare(implementation: () => Promise<void>): ReturnType<typeof vi.fn> {
|
||||
const fn = vi.fn(implementation);
|
||||
Object.defineProperty(navigator, 'share', { value: fn, configurable: true });
|
||||
return fn;
|
||||
}
|
||||
|
||||
describe('share orchestration (UI-02 V05)', () => {
|
||||
beforeEach(() => {
|
||||
mockedCopyText.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(navigator, 'share');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses navigator.share when available and does not touch the clipboard', async () => {
|
||||
const share = stubShare(() => Promise.resolve());
|
||||
|
||||
const outcome = await shareOrCopy({ title: 'Annual Party', url: 'https://x.test/a/tok?lang=en' });
|
||||
expect(outcome).toBe('shared');
|
||||
expect(share).toHaveBeenCalledWith({ title: 'Annual Party', url: 'https://x.test/a/tok?lang=en' });
|
||||
expect(mockedCopyText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats AbortError as a silent cancel (no error, no clipboard fallback)', async () => {
|
||||
const share = stubShare(() => {
|
||||
const error = new Error('The operation was aborted.');
|
||||
error.name = 'AbortError';
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
const outcome = await shareOrCopy({ title: 't', url: 'https://x.test/a/tok' });
|
||||
expect(outcome).toBe('cancelled');
|
||||
expect(share).toHaveBeenCalledTimes(1);
|
||||
expect(mockedCopyText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the clipboard when navigator.share rejects with another error', async () => {
|
||||
stubShare(() => Promise.reject(new TypeError('Not allowed to share')));
|
||||
mockedCopyText.mockResolvedValue(true);
|
||||
|
||||
const outcome = await shareOrCopy({ title: 't', url: 'https://x.test/a/tok' });
|
||||
expect(outcome).toBe('copied');
|
||||
expect(mockedCopyText).toHaveBeenCalledWith('https://x.test/a/tok');
|
||||
});
|
||||
|
||||
it('falls back to the clipboard without navigator.share; reports failure when copying fails', async () => {
|
||||
expect('share' in navigator).toBe(false); // jsdom 默认无 navigator.share
|
||||
|
||||
mockedCopyText.mockResolvedValueOnce(true);
|
||||
await expect(shareOrCopy({ title: 't', url: 'https://x.test/a/tok' })).resolves.toBe('copied');
|
||||
|
||||
mockedCopyText.mockResolvedValueOnce(false);
|
||||
await expect(shareOrCopy({ title: 't', url: 'https://x.test/a/tok' })).resolves.toBe('failed');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user