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:
浅念
2026-09-21 22:20:19 +08:00
parent 89f45896b7
commit 160e7e5f1a
18 changed files with 1042 additions and 58 deletions
+22 -4
View File
@@ -57,6 +57,7 @@ describe('album header info area (UI-02 V02)', () => {
clientSpy?.mockRestore();
clientSpy = null;
Reflect.deleteProperty(navigator, 'clipboard');
Reflect.deleteProperty(navigator, 'share');
});
it('renders banner, title, multi-line description, Intl view count, venue and sort/category toolbar', () => {
@@ -179,22 +180,39 @@ describe('album header info area (UI-02 V02)', () => {
expect(wrapper.emitted('selectCategory')).toEqual([['cat-open'], [null]]);
});
it('share copies the album url to the clipboard and reports success; failure keeps a selectable url', async () => {
it('share falls back to the clipboard with the album url (lang param) and reports success; failure keeps a selectable url', async () => {
const writeText = stubClipboard(() => Promise.resolve());
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
await wrapper.find('.share-btn').trigger('click');
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith('http://localhost:3000/a/tokfixture0000000000aaaa');
expect(writeText).toHaveBeenCalledWith('http://localhost:3000/a/tokfixture0000000000aaaa?lang=zh-CN');
expect(wrapper.find('.share-status').text()).toContain('链接已复制');
expect(wrapper.find('.share-url').exists()).toBe(false);
// 复制失败:轻提示 + 可选中文本(§5.4 回退语义,完整 navigator.share 属 Task 5)。
// 复制失败:轻提示 + 可选中文本(§5.4 回退语义)。
writeText.mockImplementation(() => Promise.reject(new Error('denied')));
await wrapper.find('.share-btn').trigger('click');
await Promise.resolve();
await Promise.resolve();
expect(wrapper.find('.share-status').text()).toContain('复制失败');
expect(wrapper.find('.share-url').text()).toBe('http://localhost:3000/a/tokfixture0000000000aaaa');
expect(wrapper.find('.share-url').text()).toBe('http://localhost:3000/a/tokfixture0000000000aaaa?lang=zh-CN');
});
it('share uses navigator.share with the album title when available and stays silent on success', async () => {
const share = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, 'share', { value: share, configurable: true });
const writeText = stubClipboard(() => Promise.resolve());
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
await wrapper.find('.share-btn').trigger('click');
await Promise.resolve();
expect(share).toHaveBeenCalledWith({
title: '2026 公司年会',
url: 'http://localhost:3000/a/tokfixture0000000000aaaa?lang=zh-CN',
});
// 原生分享成功:无额外轻提示(面板已收尾,§5.4),也不落剪贴板。
expect(writeText).not.toHaveBeenCalled();
expect(wrapper.find('.share-status').exists()).toBe(false);
});
});
+29 -3
View File
@@ -1,5 +1,5 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
import { defineComponent, ref } from 'vue';
import { defineComponent, nextTick, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ApiError,
@@ -89,9 +89,10 @@ function spyWindowOpen() {
}
let openSpy: ReturnType<typeof spyWindowOpen>;
/** 宿主实例形态:setup 暴露 albumRef(ref 经代理解包)+ download 控制器。 */
/** 宿主实例形态:setup 暴露 albumRef/currentRef(ref 经代理解包)+ download 控制器。 */
interface HostVm {
albumRef: PublicAlbum | null;
currentRef: string;
download: DownloadController;
}
@@ -112,7 +113,7 @@ function mountLightbox(album: PublicAlbum): VueWrapper & { vm: HostVm } {
album: () => albumRef.value,
photoId: () => current.value,
});
return { sequence, photoId: current, download, albumRef };
return { sequence, photoId: current, currentRef: current, download, albumRef };
},
template: `<PhotoLightbox v-model:photo-id="photoId" :sequence="sequence" :download="download" />`,
});
@@ -251,6 +252,31 @@ describe('photo download (P4a Task 4)', () => {
expect(openSpy).not.toHaveBeenCalled();
});
it('attributes an async failure to the photo that triggered the download, not the current one', async () => {
// 在途请求挂起期间切图:错误归属触发时的 photoId(UI-02 §5.4)。
let rejectDownload!: (error: Error) => void;
mockedGetPhotoDownload.mockReset().mockImplementation(
() =>
new Promise((_resolve, reject) => {
rejectDownload = reject;
}),
);
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.download-main').trigger('click'); // p1 触发
wrapper.vm.currentRef = 'p2'; // 请求在途时切到 p2
rejectDownload(new Error('boom'));
await flushPromises();
expect(wrapper.vm.download.failedFor).toBe('p1');
expect(wrapper.find('.download-failed').exists()).toBe(false); // p2 不背锅
wrapper.vm.currentRef = 'p1'; // 切回 p1:错误提示可见
await nextTick();
expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('下载失败');
});
it('DOWNLOAD_DISABLED surfaces the dedicated localized notice (T15 漏译修复)', async () => {
// 相册中途关闭下载开关(载荷仍开但端点 403)→ 专属文案而非通用失败。
mockedGetPhotoDownload.mockReset().mockRejectedValue(
@@ -0,0 +1,100 @@
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);
});
});
@@ -0,0 +1,104 @@
import { mount, type VueWrapper } from '@vue/test-utils';
import { nextTick } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import InteractionNotice from '../components/viewer/InteractionNotice.vue';
import { createViewerI18n, type ViewerI18n } from '../i18n';
// UI-02 V05 InteractionNotice 单测(§6.2/§6.3):他人点赞紧凑通知(缩略图 +
// ♥ + delta)、点击定位上抛 select、同屏至多 3 条(超限弃最旧)、约 4s 自动
// 消散、页面隐藏即清空;列表不 aria-live(读屏节流汇总,§6.3)。
let i18n: ViewerI18n;
const mounted: VueWrapper[] = [];
interface NoticeExposed {
notify: (entry: { photoId: string; delta: number; thumbUrl: string | null }) => void;
}
function mountNotice(): VueWrapper & { vm: NoticeExposed } {
const wrapper = mount(InteractionNotice, { global: { plugins: [i18n] } });
mounted.push(wrapper);
return wrapper as VueWrapper & { vm: NoticeExposed };
}
function setHidden(hidden: boolean): void {
Object.defineProperty(document, 'hidden', { value: hidden, configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
}
function entry(photoId: string, delta: number) {
return { photoId, delta, thumbUrl: `http://img.test/${photoId}-thumb.jpg` };
}
describe('interaction notice (UI-02 V05)', () => {
beforeEach(() => {
localStorage.clear();
i18n = createViewerI18n('zh-CN');
vi.useFakeTimers();
});
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.unmount();
vi.useRealTimers();
setHidden(false);
});
it('renders a compact notice with thumbnail, heart and +delta; click emits select', async () => {
const wrapper = mountNotice();
wrapper.vm.notify(entry('p2', 3));
await nextTick();
const notice = wrapper.find('.notice');
expect(notice.exists()).toBe(true);
expect(notice.find('.notice-thumb').attributes('src')).toBe('http://img.test/p2-thumb.jpg');
expect(notice.find('.notice-heart').text()).toBe('♥');
expect(notice.find('.notice-delta').text()).toBe('+3');
// 列表本身不 aria-live(§6.3 取消逐条播报;节流汇总另置 role=status)。
expect(wrapper.find('.interaction-notice-zone').attributes('aria-live')).toBeUndefined();
expect(wrapper.find('.sr-summary').attributes('role')).toBe('status');
await notice.trigger('click');
expect(wrapper.emitted('select')).toEqual([['p2']]);
});
it('caps concurrent notices at 3, dropping the oldest', async () => {
const wrapper = mountNotice();
wrapper.vm.notify(entry('p2', 1));
wrapper.vm.notify(entry('p3', 2));
wrapper.vm.notify(entry('p4', 4));
wrapper.vm.notify(entry('p5', 8));
await nextTick();
const deltas = wrapper.findAll('.notice-delta').map((delta) => delta.text());
expect(deltas).toEqual(['+2', '+4', '+8']); // 最旧的 +1 被丢弃
});
it('auto-dismisses notices after ~4s', async () => {
const wrapper = mountNotice();
wrapper.vm.notify(entry('p2', 1));
await nextTick();
expect(wrapper.findAll('.notice')).toHaveLength(1);
await vi.advanceTimersByTimeAsync(4000);
expect(wrapper.findAll('.notice')).toHaveLength(0);
});
it('clears notices when the page becomes hidden', async () => {
const wrapper = mountNotice();
wrapper.vm.notify(entry('p2', 1));
wrapper.vm.notify(entry('p3', 2));
await nextTick();
expect(wrapper.findAll('.notice')).toHaveLength(2);
setHidden(true);
await nextTick();
expect(wrapper.findAll('.notice')).toHaveLength(0);
await vi.advanceTimersByTimeAsync(5000);
expect(wrapper.findAll('.notice')).toHaveLength(0);
});
});
+172 -4
View File
@@ -2,32 +2,39 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
import { defineComponent, nextTick, ref } from 'vue';
import { defineComponent, nextTick, reactive, ref, type Ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ApiError,
getPublicPhoto,
getPublicPhotos,
postLikeBatch,
type PublicPhoto,
type PublicPhotoList,
} from '../api/client';
import { createViewerI18n, type ViewerI18n } from '../i18n';
import PhotoLightbox from '../components/PhotoLightbox.vue';
import type { DownloadController } from '../download/useDownload';
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
import { useLikes } from '../likes/useLikes';
import type { LikeBubble } from '../realtime/useAlbumRealtime';
// PhotoLightbox 组件测试(Task 8 业务 + UI-02 V04 深色布局):
// browse 渲染/导航/序列扩展、wheel 缩放与双击复位、Esc/关闭/舞台空白点击关闭、
// 指针轻扫切换、签名过期静默重取与 404 移除上报;V04 新增:位置计数三种
// 真实回退、缩略图条渲染与选择、更多菜单、焦点管理、body 滚动锁、安全区。
// 深链解析与 URL 同步在 viewer-route.spec(真实路由)覆盖。
// 真实回退、缩略图条渲染与选择、更多菜单、焦点管理、body 滚动锁、安全区;
// V05 新增(独立 describe):本地点赞/他人点赞 → 心形粒子与跨照片互动提示
// 分层、navigator.share 分享与剪贴板回退、下载失败归属照片。深链解析与 URL
// 同步在 viewer-route.spec(真实路由)覆盖。
vi.mock('../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/client')>();
return { ...actual, getPublicPhotos: vi.fn(), getPublicPhoto: vi.fn() };
return { ...actual, getPublicPhotos: vi.fn(), getPublicPhoto: vi.fn(), postLikeBatch: vi.fn() };
});
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
const mockedPostLikeBatch = vi.mocked(postLikeBatch);
function makePhoto(id: string, likeCount = 0): PublicPhoto {
return {
@@ -448,3 +455,164 @@ describe('photo lightbox dark viewer (UI-02 V04)', () => {
expect(source).toContain('env(safe-area-inset-bottom, 0px)');
});
});
describe('photo lightbox interactions (UI-02 V05)', () => {
interface V05Options {
bubbles?: LikeBubble[];
download?: unknown;
albumTitle?: string;
shareUrl?: string;
}
/** 宿主:sequence + likes + 实时气泡 ref + v-model photoId(镜像 ActivityPage
* 的 V05 组装:粒子/互动提示/分享深链)。 */
function mountV05(
photoId: string,
options: V05Options = {},
): { wrapper: VueWrapper; bubbles: Ref<LikeBubble[]>; current: Ref<string> } {
let bubbles!: Ref<LikeBubble[]>;
let current!: Ref<string>;
const Host = defineComponent({
components: { PhotoLightbox },
setup() {
const sequence = usePhotoSequence({
token: ref('tok-lb'),
locale: ref('zh-CN'),
category: ref(null),
});
const likes = useLikes({ token: ref('tok-lb'), photos: () => sequence.photos });
bubbles = ref(options.bubbles ?? []);
current = ref(photoId);
return {
sequence,
likes,
bubbles,
current,
albumTitle: options.albumTitle,
shareUrl: options.shareUrl,
download: options.download,
};
},
template: `<PhotoLightbox
v-model:photo-id="current"
:sequence="sequence"
:likes="likes"
:like-bubbles="bubbles"
:album-title="albumTitle"
:share-url="shareUrl"
:download="download"
/>`,
});
const wrapper = mount(Host, { global: { plugins: [i18n] } });
mounted.push(wrapper);
return { wrapper, bubbles, current };
}
beforeEach(() => {
localStorage.clear();
i18n = createViewerI18n('zh-CN');
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')]));
mockedGetPublicPhoto.mockReset();
mockedPostLikeBatch.mockReset().mockResolvedValue({ total: 0, countVersion: 1, batchAccepted: true });
document.body.style.overflow = '';
});
afterEach(() => {
while (mounted.length > 0) {
mounted.pop()?.unmount();
}
Reflect.deleteProperty(navigator, 'share');
Reflect.deleteProperty(navigator, 'clipboard');
vi.restoreAllMocks();
document.body.style.overflow = '';
document.documentElement.className = '';
});
it('bursts heart particles for a local like click on the current photo', async () => {
const { wrapper } = mountV05('p1');
await flushPromises();
expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(0);
await wrapper.find('.like-slot .action-btn').trigger('click');
await nextTick();
expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(2);
});
it('routes realtime bursts: particles for the current photo, a notice for others; notice click navigates', async () => {
const { wrapper, bubbles, current } = mountV05('p1');
await flushPromises();
// 当前照片的他人点赞(router 已滤自身回声)→ 舞台右区粒子。
bubbles.value = [...bubbles.value, { key: 1, photoId: 'p1', delta: 5, thumbUrl: null }];
await nextTick();
expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(2);
// 其他照片 → 互动提示(缩略图 + ♥ + delta),不新增当前照片粒子。
bubbles.value = [...bubbles.value, { key: 2, photoId: 'p2', delta: 3, thumbUrl: 'http://img.test/p2-thumb.jpg' }];
await nextTick();
const notice = wrapper.find('.interaction-notice-zone .notice');
expect(notice.exists()).toBe(true);
expect(notice.find('.notice-thumb').attributes('src')).toBe('http://img.test/p2-thumb.jpg');
expect(notice.find('.notice-delta').text()).toBe('+3');
expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(2);
// 点击定位:与缩略图条共用入口(update:photoId,序列内导航)。
await notice.trigger('click');
expect(current.value).toBe('p2');
});
it('shares via navigator.share with the album title and photo deep link; success is silent', async () => {
const share = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, 'share', { value: share, configurable: true });
const { wrapper } = mountV05('p1', {
albumTitle: '2026 公司年会',
shareUrl: '/a/tok-lb/p/p1?lang=zh-CN',
});
await flushPromises();
await wrapper.find('button[aria-label="分享照片"]').trigger('click');
await flushPromises();
expect(share).toHaveBeenCalledWith({ title: '2026 公司年会', url: '/a/tok-lb/p/p1?lang=zh-CN' });
// 原生分享成功:无应用内轻提示(面板已收尾,§5.4)。
expect(wrapper.find('.share-status').exists()).toBe(false);
});
it('falls back to the clipboard with feedback when native share is unavailable', async () => {
const writeText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
const { wrapper } = mountV05('p1', { shareUrl: '/a/tok-lb/p/p1?lang=zh-CN' });
await flushPromises();
await wrapper.find('button[aria-label="分享照片"]').trigger('click');
await flushPromises();
expect(writeText).toHaveBeenCalledWith('/a/tok-lb/p/p1?lang=zh-CN');
expect(wrapper.find('.share-status').text()).toContain('链接已复制');
});
it('shows the download failure only on the photo that triggered it', async () => {
// 控制器桩:失败已归属 p1(useDownload.failedFor,§5.4 异步错误归属)。
const download = reactive({
busy: false,
failed: 'download.failed',
failedFor: 'p1',
wechatGuide: false,
copyNotice: '',
canDownload: true,
canDownloadOriginal: false,
}) as unknown as DownloadController;
const { wrapper, current } = mountV05('p1', { download });
await flushPromises();
expect(wrapper.find('.download-failed').text()).toContain('下载失败');
// 切到 p2:p1 的下载错误不张冠李戴到当前照片。
current.value = 'p2';
await nextTick();
expect(wrapper.find('.download-failed').exists()).toBe(false);
// 切回 p1:错误提示仍归属原照片(可再见)。
current.value = 'p1';
await nextTick();
expect(wrapper.find('.download-failed').exists()).toBe(true);
});
});
@@ -14,13 +14,11 @@ function mountBar(props: {
likeCount: number;
canDownload?: boolean;
canDownloadOriginal?: boolean;
isLiking?: boolean;
}): VueWrapper {
const wrapper = mount(PhotoActionBar, {
props: {
canDownload: false,
canDownloadOriginal: false,
isLiking: false,
...props,
},
global: { plugins: [i18n] },
+69
View File
@@ -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');
});
});