Files
photo-live/apps/viewer/src/__tests__/lightbox.spec.ts
T
浅念 3d45350711 feat(viewer): share sheet with QR code + link + copy (replaces inline toast, App-style)
- ShareSheet.vue: bottom sheet (mobile) / centered card (desktop) — QR canvas (qrcode dep), selectable deep link, copy with on-button feedback (2.5s), optional navigator.share button, Esc/scrim/close dismiss, focus on open
- AlbumHeader + PhotoLightbox: share buttons open the sheet (album URL / photo deep link); page-level copied strips removed
- tests rewritten for sheet behavior — viewer 244 green

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 11:04:11 +08:00

671 lines
27 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 { 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');
});
/** 分享弹层(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<HTMLButtonElement>('[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<HTMLButtonElement>('[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);
});
});