Files
photo-live/apps/viewer/src/__tests__/lightbox.spec.ts
T

655 lines
27 KiB
TypeScript
Raw Normal View History

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<typeof import('../api/client')>();
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: `<PhotoLightbox v-model:photo-id="photoId" :sequence="sequence" />`,
});
const wrapper = mount(Host, { global: { plugins: [i18n] } });
mounted.push(wrapper);
return wrapper;
}
function lightbox(wrapper: VueWrapper): VueWrapper<Record<string, never>> {
return wrapper.getComponent(PhotoLightbox) as unknown as VueWrapper<Record<string, never>>;
}
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);
// 舞台空白区(目标=容器自身,非 <img>)点击关闭。
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<PublicPhotoList>((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('图片加载失败');
// 重试:清标记并强制 <img> 重挂载(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<PublicPhotoList>((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<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);
});
});