Files
photo-live/apps/viewer/src/__tests__/viewer-route.spec.ts
T
浅念 89f45896b7 feat(viewer): UI-02 dark immersive photo viewer with thumbnail strip
V04 (方案 §5):PhotoLightbox 模板重构为深色沉浸布局——顶栏(关闭/位置
计数/⋯ 减少动画菜单)+ 舞台(contain 大图 + 边缘 ‹› + 粒子/互动占位层)+
缩略图条 + 底部操作栏;业务逻辑(序列导航/缩放手势/签名刷新/下载/点赞)
原样保留。

- 新增 viewer/position.ts:§5.2 位置计数真实回退(solo→照片预览;无
  total 未到末页→已加载 N 张;全部加载→i/n;不伪造总数)
- 新增 ThumbnailStrip:当前 ±3 窗口(≤7 项)、主色 2px 描边、thumb 变体、
  滚入可见(遵守减少动画)、点击选择不新增历史
- 新增 PhotoActionBar:Like(coral 心形+计数+连续点赞提示)/Share/
  Download 均分大触控;下载关闭整项移除;原图二级菜单(▾ + 长按)
- 模态可用性:body 滚动锁、焦点进入/困住/归还触发元素、Esc 先收菜单、
  菜单内左右键不抢占、dvh + 安全区、chrome 区手势/滚轮隔离
- 分享 V04 最小实现(复制当前深链,support/clipboard 从 AlbumHeader
  提取共用);navigator.share 编排属 Task 5
- zh-CN/en 新词条;vitest 新增 23 例(位置回退/缩略图/操作栏/焦点/滚锁)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 21:58:09 +08:00

207 lines
8.8 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 { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import App from '../App.vue';
import { ApiError, getPublicAlbum, getPublicPhoto, getPublicPhotos, postAlbumVisit, type PublicPhoto, type PublicPhotoList } from '../api/client';
import { createViewerI18n } from '../i18n';
import { router } from '../router';
import { makeAlbum } from './fixtures';
// 大图查看器路由编排(Task 8):URL 同步(打开 push / 切换 replace / 关闭 back 或
// replace)、滚动锚点恢复、深链解析顺序(单图先行 → 相邻页构成序列)、深链 404
// 本地化提示与返回链接。使用真实 router 单例(jsdom 提供_history),对齐
// routing.spec.ts 的既定模式;api 三端点替身。Task 10 起活动页组装 WS/浏览
// 上报:RealtimeClient 以无操作桩替换、postAlbumVisit 替身防真实 fetch。
vi.mock('../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/client')>();
return {
...actual,
getPublicAlbum: vi.fn(),
getPublicPhotos: vi.fn(),
getPublicPhoto: vi.fn(),
postAlbumVisit: vi.fn().mockResolvedValue({ viewCount: 0, counted: false }),
};
});
vi.mock('../realtime/ws', () => {
class RealtimeClientStub {
start(): void {}
stop(): void {}
}
return { RealtimeClient: RealtimeClientStub, wsUrlFromApiBase: () => 'ws://test/v1/realtime' };
});
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
const mockedPostAlbumVisit = vi.mocked(postAlbumVisit);
function makePhoto(id: string): 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: 0,
publishedAt: '2026-10-01T00:00:00.000Z',
};
}
function makeList(items: PublicPhoto[], nextCursor: string | null = null): PublicPhotoList {
return { items, nextCursor, hasMore: nextCursor !== null };
}
let i18n: ReturnType<typeof createViewerI18n>;
const mounted: VueWrapper[] = [];
function mountApp(): VueWrapper {
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
mounted.push(wrapper);
return wrapper;
}
function keydown(key: string): void {
window.dispatchEvent(new KeyboardEvent('keydown', { key, cancelable: true }));
}
describe('viewer url sync and deep link (Task 8)', () => {
beforeEach(async () => {
localStorage.clear();
i18n = createViewerI18n('zh-CN');
mockedGetPublicAlbum.mockReset().mockResolvedValue(makeAlbum());
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')]));
mockedGetPublicPhoto.mockReset().mockImplementation(async (_token, photoId) => ({
...makePhoto(photoId),
categoryId: null,
}));
// afterEach 的 restoreAllMocks 会清工厂期实现:逐用例重置兜底值
//(活动页加载成功即触发浏览上报,见 visitor.ts)。
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 0, counted: false });
});
afterEach(() => {
while (mounted.length > 0) {
mounted.pop()?.unmount();
}
vi.restoreAllMocks();
vi.unstubAllGlobals();
// restoreAllMocks 将工厂期实现清成返回 undefined 的裸 vi.fn:用例收尾后
// 仍可能有在途的浏览上报微任务落到 postAlbumVisit(.then 于 undefined 即
// 未处理拒绝)——恢复后立即补挂兜底实现。
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 0, counted: false });
});
it('open pushes the photo route, navigate replaces, close restores scroll and pops back', async () => {
await router.push('/a/tok-view');
await router.isReady();
const wrapper = mountApp();
await flushPromises();
expect(wrapper.findAll('.cell')).toHaveLength(3);
const pushSpy = vi.spyOn(router, 'push');
const replaceSpy = vi.spyOn(router, 'replace');
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
vi.stubGlobal('scrollY', 650);
// 点击第 2 格开大图:push 深链路由,browse 渲染。
await wrapper.findAll('.cell')[1].trigger('click');
await flushPromises();
expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/p2-browse.jpg?s=1');
expect(router.currentRoute.value.path).toBe('/a/tok-view/p/p2');
expect(pushSpy).toHaveBeenCalledTimes(1);
// 键盘右切:replace(不新增历史条目)。
keydown('ArrowRight');
await flushPromises();
expect(router.currentRoute.value.path).toBe('/a/tok-view/p/p3');
expect(replaceSpy).toHaveBeenCalledTimes(1);
expect(pushSpy).toHaveBeenCalledTimes(1);
// Esc 关闭:恢复滚动锚点 + 历史出栈回相册路由(jsdom 的 popstate 异步,需等待)。
scrollTo.mockClear();
keydown('Escape');
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/a/tok-view'));
expect(wrapper.find('.photo-viewer').exists()).toBe(false);
expect(scrollTo).toHaveBeenCalledWith(0, 650);
expect(replaceSpy).toHaveBeenCalledTimes(1); // 关闭未额外 replace
});
it('browser back from the photo route closes the lightbox without touching history further', async () => {
await router.push('/a/tok-view');
await router.isReady();
const wrapper = mountApp();
await flushPromises();
vi.stubGlobal('scrollY', 320);
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
await wrapper.findAll('.cell')[0].trigger('click');
await flushPromises();
expect(wrapper.find('.photo-viewer').exists()).toBe(true);
scrollTo.mockClear();
router.back(); // 等价浏览器后退(popstate 异步派发)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/a/tok-view'));
await flushPromises();
expect(wrapper.find('.photo-viewer').exists()).toBe(false);
expect(scrollTo).toHaveBeenCalledWith(0, 320);
});
it('deep link renders the single photo first, then naturalizes it as pages arrive', async () => {
// 第 1 页挂起(受控):验证「先单图、后序列」的解析顺序。
let resolveFirstPage!: (value: PublicPhotoList) => void;
mockedGetPublicPhotos.mockReset();
mockedGetPublicPhotos
.mockReturnValueOnce(
new Promise<PublicPhotoList>((resolve) => {
resolveFirstPage = resolve;
}),
)
.mockResolvedValueOnce(makeList([makePhoto('p4'), makePhoto('p5')], null));
await router.push('/a/tok-view/p/p5');
await router.isReady();
const wrapper = mountApp();
await flushPromises();
// 单图先行:列表仍在加载,大图已可看(solo 位置未知 → 「照片预览」,§5.2 不伪造排名)。
expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-view', 'p5');
expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/p5-browse.jpg?s=1');
expect(wrapper.find('.position-indicator').text()).toBe('照片预览');
// 第 1 页到达(无 p5)→ 深链 watcher 追拉第 2 页 → p5 归位自然顺序(5/5)。
resolveFirstPage(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')], 'cur-1'));
await vi.waitFor(() => expect(wrapper.findAll('.cell')).toHaveLength(5));
expect(wrapper.find('.position-indicator').text()).toBe('5 / 5');
// 序列已构成:左切到前一张。
keydown('ArrowLeft');
await flushPromises();
expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/p4-browse.jpg?s=1');
expect(router.currentRoute.value.path).toBe('/a/tok-view/p/p4');
});
it('deep link to a non-public photo shows the localized notice with a back link', async () => {
mockedGetPublicPhoto.mockRejectedValue(new ApiError('PHOTO_NOT_PUBLIC', 'hidden', 404, 'req-1'));
await router.push('/a/tok-view/p/gone');
await router.isReady();
const wrapper = mountApp();
await flushPromises();
expect(wrapper.find('.photo-viewer').exists()).toBe(false);
const notice = wrapper.find('.deep-link-notice[role="alert"]');
expect(notice.text()).toContain('照片不存在或已下线');
expect(notice.text()).not.toContain('PHOTO_NOT_PUBLIC'); // 不裸露错误码
const back = notice.find('a');
expect(back.text()).toBe('返回相册');
expect(back.attributes('href')).toBe('/a/tok-view');
await back.trigger('click');
await flushPromises();
expect(router.currentRoute.value.path).toBe('/a/tok-view');
expect(wrapper.find('.deep-link-notice').exists()).toBe(false);
expect(wrapper.text()).toContain('2026 公司年会');
});
});