3d45350711
- 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>
671 lines
27 KiB
TypeScript
671 lines
27 KiB
TypeScript
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);
|
||
});
|
||
});
|