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, 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 滚动锁、安全区; // V05 新增(独立 describe):本地点赞/他人点赞 → 心形粒子与跨照片互动提示 // 分层、navigator.share 分享与剪贴板回退、下载失败归属照片。深链解析与 URL // 同步在 viewer-route.spec(真实路由)覆盖。 vi.mock('../api/client', async (importOriginal) => { const actual = await importOriginal(); 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 { photoId: id, width: 800, height: 500, thumb: { url: `http://img.test/${id}-thumb.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' }, browse: { url: `http://img.test/${id}-browse.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' }, likeCount, publishedAt: '2026-10-01T00:00:00.000Z', }; } function makeList(items: PublicPhoto[], nextCursor: string | null = null): PublicPhotoList { return { items, nextCursor, hasMore: nextCursor !== null }; } let i18n: ViewerI18n; const mounted: VueWrapper[] = []; /** 宿主:sequence + v-model photoId(镜像 ActivityPage 组装)。页面数据经 * mockedGetPublicPhotos 的当前 mock 队列供给(与真实时序一致:挂载后到达)。 * solo:深链兜底条目(ensureInSequence 追加在序列尾部,自然位置未知)。 */ function mountLightbox(photoId: string, options: { solo?: PublicPhoto } = {}): VueWrapper { const Host = defineComponent({ components: { PhotoLightbox }, setup() { const sequence = usePhotoSequence({ token: ref('tok-lb'), locale: ref('zh-CN'), category: ref(null), }); if (options.solo !== undefined) sequence.ensureInSequence(options.solo); const current = ref(photoId); return { sequence, photoId: current }; }, template: ``, }); const wrapper = mount(Host, { global: { plugins: [i18n] } }); mounted.push(wrapper); return wrapper; } function lightbox(wrapper: VueWrapper): VueWrapper> { return wrapper.getComponent(PhotoLightbox) as unknown as VueWrapper>; } function img(wrapper: VueWrapper) { return wrapper.find('img.photo'); } /** jsdom 无 PointerEvent 构造器:MouseEvent 补 pointerId/pointerType 字段。 */ function pointerEvent( type: 'pointerdown' | 'pointermove' | 'pointerup', init: { pointerId?: number; clientX?: number; clientY?: number; pointerType?: string }, ): PointerEvent { const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientX: init.clientX ?? 0, clientY: init.clientY ?? 0, }); return Object.assign(event, { pointerId: init.pointerId ?? 1, pointerType: init.pointerType ?? 'touch', }) as unknown as PointerEvent; } function keydown(key: string): void { window.dispatchEvent(new KeyboardEvent('keydown', { key, cancelable: true })); } /** test-utils 的 trigger 无法写 MouseEvent 只读坐标:直接派生 WheelEvent。 */ function wheel(wrapper: VueWrapper, deltaY: number, clientX = 100, clientY = 80): void { wrapper.find('.photo-viewer').element.dispatchEvent( new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY, clientX, clientY }), ); } describe('photo lightbox (Task 8)', () => { beforeEach(() => { localStorage.clear(); i18n = createViewerI18n('zh-CN'); mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')])); mockedGetPublicPhoto.mockReset(); }); afterEach(() => { while (mounted.length > 0) { mounted.pop()?.unmount(); } vi.restoreAllMocks(); }); it('renders the current browse url with position indicator and like count', async () => { mockedGetPublicPhotos.mockResolvedValue( makeList([makePhoto('p1'), makePhoto('p2', 7), makePhoto('p3')]), ); const wrapper = mountLightbox('p2'); await flushPromises(); expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=1'); // 3 张全部加载完成(无更多页)→ 筛选序列 i / n(§5.2)。 expect(wrapper.find('.position-indicator').text()).toBe('2 / 3'); // 底部操作栏(V04):Like 计数 + Like/Share 标签(无下载控制器 → 下载位省略)。 expect(wrapper.find('.like-count').text()).toBe('7'); expect(wrapper.findAll('.action-label').map((label) => label.text())).toEqual(['点赞', '分享']); }); it('navigates with buttons and arrow keys, keeping the list order', async () => { const wrapper = mountLightbox('p2'); await flushPromises(); await wrapper.find('.nav.next').trigger('click'); expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3']]); keydown('ArrowLeft'); await flushPromises(); expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3'], ['p2']]); keydown('ArrowLeft'); await flushPromises(); expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3'], ['p2'], ['p1']]); // 首张:previous 按钮禁用,再按左键不再发事件;末张同理(无更多页)。 expect(wrapper.find('.nav.prev').attributes('disabled')).toBeDefined(); keydown('ArrowLeft'); await flushPromises(); expect(lightbox(wrapper).emitted('update:photoId')).toHaveLength(3); expect(wrapper.find('.nav.next').attributes('disabled')).toBeUndefined(); }); it('closes on Esc, close button and empty-stage click', async () => { const wrapper = mountLightbox('p2'); await flushPromises(); keydown('Escape'); expect(lightbox(wrapper).emitted('close')).toHaveLength(1); await wrapper.find('.viewer-close').trigger('click'); expect(lightbox(wrapper).emitted('close')).toHaveLength(2); // 舞台空白区(目标=容器自身,非 )点击关闭。 await wrapper.find('.image-container').trigger('click'); expect(lightbox(wrapper).emitted('close')).toHaveLength(3); }); it('zooms with wheel around the cursor and clamps to [1, 4]; dblclick resets', async () => { const wrapper = mountLightbox('p2'); await flushPromises(); // jsdom 下容器 rect 为 0 → 锚点 = 事件坐标(100, 80)。 wheel(wrapper, -240); await nextTick(); let style = img(wrapper).attributes('style') ?? ''; expect(style).toContain('scale('); const zoomedScale = Number(/scale\(([\d.]+)\)/.exec(style)?.[1]); expect(zoomedScale).toBeGreaterThan(1); // 不动点:锚点屏幕位置不变(translate 补偿)。 const zoomedTx = Number(/translate\((-?[\d.]+)px/.exec(style)?.[1]); expect(100 * zoomedScale + zoomedTx).toBeCloseTo(100, 5); // 连续放大钳制到 4。 for (let i = 0; i < 8; i++) wheel(wrapper, -600); await nextTick(); style = img(wrapper).attributes('style') ?? ''; expect(Number(/scale\(([\d.]+)\)/.exec(style)?.[1])).toBe(4); // 缩到底回到 1 且平移归零(复位对齐)。 for (let i = 0; i < 12; i++) wheel(wrapper, 600); await nextTick(); style = img(wrapper).attributes('style') ?? ''; expect(style).toContain('translate(0px, 0px) scale(1)'); // 双击复位。 wheel(wrapper, -240); await nextTick(); await img(wrapper).trigger('dblclick'); expect(img(wrapper).attributes('style')).toContain('scale(1)'); }); it('swipe navigates when flat; drag on stage does not close', async () => { const wrapper = mountLightbox('p2'); await flushPromises(); const root = wrapper.find('.photo-viewer'); // 左滑 80px → 下一张。 root.element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1, clientX: 200, clientY: 150 })); root.element.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 150, clientY: 150 })); root.element.dispatchEvent(pointerEvent('pointerup', { pointerId: 1, clientX: 120, clientY: 150 })); await flushPromises(); expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3']]); // 拖拽距离超过阈值后收尾在舞台上:click 不关闭。 root.element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1, clientX: 200, clientY: 150 })); root.element.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 100, clientY: 160 })); root.element.dispatchEvent(pointerEvent('pointerup', { pointerId: 1, clientX: 100, clientY: 160 })); await wrapper.find('.image-container').trigger('click'); expect(lightbox(wrapper).emitted('close')).toBeUndefined(); }); it('extends the sequence when navigating past the end with more pages', async () => { let resolvePage!: (value: PublicPhotoList) => void; mockedGetPublicPhotos.mockReset(); mockedGetPublicPhotos .mockResolvedValueOnce(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')], 'cur-1')) .mockReturnValueOnce( new Promise((resolve) => { resolvePage = resolve; }), ); const wrapper = mountLightbox('p2'); await flushPromises(); // 临近末尾的预取已在途(受控 promise 挂起)→ 右切越过末尾与预取共用同一请求。 await wrapper.find('.nav.next').trigger('click'); // → p3(预取仍未完成) expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3']]); await wrapper.find('.nav.next').trigger('click'); // 越过末尾:等待扩展 expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3']]); // 未落地前不切换 expect(wrapper.find('.loading').exists()).toBe(true); resolvePage(makeList([makePhoto('p4')], null)); await flushPromises(); expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3'], ['p4']]); expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(2); }); it('silently refreshes an expired signed url on img error', async () => { const wrapper = mountLightbox('p2'); await flushPromises(); mockedGetPublicPhoto.mockResolvedValue({ ...makePhoto('p2'), categoryId: null, browse: { url: 'http://img.test/p2-browse.jpg?s=2', expiresAt: '2026-10-01T01:00:00.000Z' }, }); await img(wrapper).trigger('error'); await flushPromises(); expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-lb', 'p2'); expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=2'); }); it('reports removal with the pre-removal index when the photo is gone (404)', async () => { const wrapper = mountLightbox('p2'); await flushPromises(); mockedGetPublicPhoto.mockRejectedValue(new ApiError('PHOTO_NOT_PUBLIC', 'gone', 404, 'req-1')); await img(wrapper).trigger('error'); await flushPromises(); expect(lightbox(wrapper).emitted('removed')).toEqual([['p2', 1]]); expect((wrapper.vm as unknown as { sequence: PhotoSequence }).sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p3']); }); it('shows a retryable failure notice for non-404 refresh failures', async () => { const wrapper = mountLightbox('p2'); await flushPromises(); mockedGetPublicPhoto.mockRejectedValue(new ApiError('NETWORK_ERROR', 'down', 0, null)); await img(wrapper).trigger('error'); await flushPromises(); expect(wrapper.find('.img-failed[role="alert"]').text()).toContain('图片加载失败'); // 重试:清标记并强制 重挂载(key 变化),错误链可再次触发。 mockedGetPublicPhoto.mockResolvedValue({ ...makePhoto('p2'), categoryId: null, browse: { url: 'http://img.test/p2-browse.jpg?s=3', expiresAt: '2026-10-01T02:00:00.000Z' }, }); await wrapper.find('.img-failed button').trigger('click'); await nextTick(); expect(wrapper.find('.img-failed').exists()).toBe(false); // 占位收起,img 回到加载中 expect(img(wrapper).exists()).toBe(true); await img(wrapper).trigger('error'); await flushPromises(); expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=3'); }); }); describe('photo lightbox dark viewer (UI-02 V04)', () => { beforeEach(() => { localStorage.clear(); i18n = createViewerI18n('zh-CN'); mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')])); mockedGetPublicPhoto.mockReset(); document.body.style.overflow = ''; }); afterEach(() => { while (mounted.length > 0) { mounted.pop()?.unmount(); } vi.restoreAllMocks(); document.body.style.overflow = ''; document.documentElement.className = ''; }); it('shows the loaded-count fallback while more pages remain (no fake total)', async () => { let resolvePage!: (value: PublicPhotoList) => void; mockedGetPublicPhotos.mockReset(); mockedGetPublicPhotos .mockResolvedValueOnce(makeList([makePhoto('p1'), makePhoto('p2')], 'cur-1')) .mockReturnValueOnce( new Promise((resolve) => { resolvePage = resolve; }), ); const wrapper = mountLightbox('p2'); await flushPromises(); // 未到末页且接口无 total → 「已加载 N 张」(§5.2),不显示 i/n。 expect(wrapper.find('.position-indicator').text()).toBe('已加载 2 张'); resolvePage(makeList([makePhoto('p3')], null)); await flushPromises(); // 全部加载完成 → 当前筛选序列 i / n。 expect(wrapper.find('.position-indicator').text()).toBe('2 / 3'); }); it('shows photo preview for an unpositioned solo deep-link entry', async () => { const wrapper = mountLightbox('solo-9', { solo: makePhoto('solo-9') }); await flushPromises(); // solo 自然位置未知(首页加载后仍追加在尾部)→ 「照片预览」,不伪造排名。 expect(wrapper.find('.position-indicator').text()).toBe('照片预览'); }); it('renders the thumbnail strip window and selects without extra history entries', async () => { const wrapper = mountLightbox('p2'); await flushPromises(); // 3 张全部落在 ±3 窗口内;当前项描边 + aria-current;点击只上抛切换。 const thumbs = wrapper.findAll('.thumb'); expect(thumbs.map((thumb) => thumb.find('img').attributes('src'))).toEqual([ 'http://img.test/p1-thumb.jpg?s=1', 'http://img.test/p2-thumb.jpg?s=1', 'http://img.test/p3-thumb.jpg?s=1', ]); expect(thumbs[1].classes()).toContain('current'); expect(thumbs[1].attributes('aria-current')).toBe('true'); await thumbs[0].trigger('click'); expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p1']]); }); it('more menu exposes the reduce-motion toggle; Escape closes the menu before the viewer', async () => { const wrapper = mountLightbox('p2'); await flushPromises(); await wrapper.find('.viewer-more').trigger('click'); expect(wrapper.find('.more-popover').exists()).toBe(true); const item = wrapper.find('.more-item'); expect(item.text()).toContain('减少动画'); expect(item.attributes('aria-checked')).toBe('false'); await item.trigger('click'); expect(wrapper.find('.more-item').attributes('aria-checked')).toBe('true'); // 还原模块级偏好单例,避免影响后续用例。 await wrapper.find('.more-item').trigger('click'); keydown('Escape'); await flushPromises(); expect(wrapper.find('.more-popover').exists()).toBe(false); // 先收菜单 expect(lightbox(wrapper).emitted('close')).toBeUndefined(); // 不关查看器 keydown('Escape'); await flushPromises(); expect(lightbox(wrapper).emitted('close')).toHaveLength(1); }); it('moves focus into the dialog on open and returns it to the trigger on close', async () => { const trigger = document.createElement('button'); document.body.appendChild(trigger); trigger.focus(); const wrapper = mountLightbox('p2'); // jsdom 下脱离文档的元素 focus() 不改 activeElement:赶在调度器微任务 // (rootEl watcher 调 focus)之前同步挂进文档。 document.body.appendChild(wrapper.element); await flushPromises(); const root = wrapper.find('.photo-viewer'); expect(root.attributes('role')).toBe('dialog'); expect(root.attributes('aria-modal')).toBe('true'); expect(root.attributes('tabindex')).toBe('-1'); expect(root.attributes('aria-label')).toBe('照片查看器'); expect(document.activeElement).toBe(root.element); // 手动卸载(绕过 mounted 清单)验证焦点归还触发元素。 const index = mounted.indexOf(wrapper); if (index >= 0) mounted.splice(index, 1); wrapper.unmount(); expect(document.activeElement).toBe(trigger); trigger.remove(); }); // UI-02 Task 6 无障碍抽检(§5.3):Tab 循环困于对话框——两端折回,不穿透。 it('traps Tab focus inside the dialog (wrap-around at both ends)', async () => { const wrapper = mountLightbox('p2'); document.body.appendChild(wrapper.element); await flushPromises(); const focusables = Array.from( wrapper.element.querySelectorAll('button:not([disabled])'), ).filter((el): el is HTMLElement => el instanceof HTMLElement); expect(focusables.length).toBeGreaterThan(1); const first = focusables[0]!; const last = focusables[focusables.length - 1]!; // 末元素正向 Tab → 折回首元素。 last.focus(); last.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })); expect(document.activeElement).toBe(first); // 首元素 Shift+Tab → 折回末元素。 first.focus(); first.dispatchEvent( new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }), ); expect(document.activeElement).toBe(last); }); it('locks body scroll while open and restores the previous value on close', async () => { document.body.style.overflow = 'auto'; const wrapper = mountLightbox('p2'); await flushPromises(); expect(document.body.style.overflow).toBe('hidden'); const index = mounted.indexOf(wrapper); if (index >= 0) mounted.splice(index, 1); wrapper.unmount(); expect(document.body.style.overflow).toBe('auto'); }); it('applies dvh viewport height and safe-area insets for the immersive layout', () => { // vitest 不加载 SFC 样式:源级断言(与 viewer-shell 的 token 源级断言同法)。 const source = readFileSync( join(dirname(fileURLToPath(import.meta.url)), '..', 'components', 'PhotoLightbox.vue'), 'utf8', ); expect(source).toContain('100dvh'); expect(source).toContain('env(safe-area-inset-top, 0px)'); expect(source).toContain('env(safe-area-inset-bottom, 0px)'); }); it('layers the fullscreen overlay above the sticky topbar (close button clickable)', () => { // jsdom 无布局:源级断言(同上)。覆盖层 z=200 须高于 sticky 顶栏 z=120 // (App.vue)——否则顶栏盖住关闭按钮的可点区(e2e 曾拦截 click 的真实回归)。 const source = readFileSync( join(dirname(fileURLToPath(import.meta.url)), '..', 'components', 'PhotoLightbox.vue'), 'utf8', ); expect(source).toMatch(/\.photo-viewer\s*\{[^}]*z-index:\s*200/s); }); }); 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; current: Ref } { let bubbles!: Ref; let current!: Ref; 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: ``, }); 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'); }); /** 分享弹层(Teleport 到 body):从 document 定位。 */ function lbSheet(): HTMLElement | null { return document.querySelector('.share-sheet'); } it('share opens the sheet with the photo deep link; native share via sheet button', 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(lbSheet()).not.toBeNull(); expect( lbSheet()!.querySelector('[data-test="share-sheet-url"]')!.textContent, ).toBe('/a/tok-lb/p/p1?lang=zh-CN'); expect(wrapper.find('.share-status').exists()).toBe(false); const nativeBtn = lbSheet()!.querySelector('[data-test="share-sheet-native"]')!; nativeBtn.click(); await flushPromises(); expect(share).toHaveBeenCalledWith({ title: '2026 公司年会', url: '/a/tok-lb/p/p1?lang=zh-CN' }); }); it('sheet copy button writes the deep link with on-button feedback', 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(); const copyBtn = lbSheet()!.querySelector('[data-test="share-sheet-copy"]')!; copyBtn.click(); await flushPromises(); expect(writeText).toHaveBeenCalledWith('/a/tok-lb/p/p1?lang=zh-CN'); expect(copyBtn.textContent).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); }); });