2026-09-18 11:04:05 +08:00
|
|
|
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
|
|
import App from '../App.vue';
|
2026-09-18 19:52:27 +08:00
|
|
|
|
import { ApiError, getPublicAlbum, getPublicPhoto, getPublicPhotos, postAlbumVisit, type PublicPhoto, type PublicPhotoList } from '../api/client';
|
2026-09-18 11:04:05 +08:00
|
|
|
|
import { createViewerI18n } from '../i18n';
|
|
|
|
|
|
import { router } from '../router';
|
|
|
|
|
|
import { makeAlbum } from './fixtures';
|
|
|
|
|
|
|
|
|
|
|
|
// 大图查看器路由编排(Task 8):URL 同步(打开 push / 切换 replace / 关闭 back 或
|
|
|
|
|
|
// replace)、滚动锚点恢复、深链解析顺序(单图先行 → 相邻页构成序列)、深链 404
|
|
|
|
|
|
// 本地化提示与返回链接。使用真实 router 单例(jsdom 提供_history),对齐
|
2026-09-18 19:52:27 +08:00
|
|
|
|
// routing.spec.ts 的既定模式;api 三端点替身。Task 10 起活动页组装 WS/浏览
|
|
|
|
|
|
// 上报:RealtimeClient 以无操作桩替换、postAlbumVisit 替身防真实 fetch。
|
2026-09-18 11:04:05 +08:00
|
|
|
|
|
|
|
|
|
|
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(),
|
2026-09-18 19:52:27 +08:00
|
|
|
|
postAlbumVisit: vi.fn().mockResolvedValue({ viewCount: 0, counted: false }),
|
2026-09-18 11:04:05 +08:00
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-18 19:52:27 +08:00
|
|
|
|
vi.mock('../realtime/ws', () => {
|
|
|
|
|
|
class RealtimeClientStub {
|
|
|
|
|
|
start(): void {}
|
|
|
|
|
|
stop(): void {}
|
|
|
|
|
|
}
|
|
|
|
|
|
return { RealtimeClient: RealtimeClientStub, wsUrlFromApiBase: () => 'ws://test/v1/realtime' };
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-18 11:04:05 +08:00
|
|
|
|
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
|
|
|
|
|
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
|
|
|
|
|
|
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
|
2026-09-18 19:52:27 +08:00
|
|
|
|
const mockedPostAlbumVisit = vi.mocked(postAlbumVisit);
|
2026-09-18 11:04:05 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
}));
|
2026-09-18 19:52:27 +08:00
|
|
|
|
// afterEach 的 restoreAllMocks 会清工厂期实现:逐用例重置兜底值
|
|
|
|
|
|
//(活动页加载成功即触发浏览上报,见 visitor.ts)。
|
|
|
|
|
|
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 0, counted: false });
|
2026-09-18 11:04:05 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
|
while (mounted.length > 0) {
|
|
|
|
|
|
mounted.pop()?.unmount();
|
|
|
|
|
|
}
|
|
|
|
|
|
vi.restoreAllMocks();
|
|
|
|
|
|
vi.unstubAllGlobals();
|
2026-09-18 19:52:27 +08:00
|
|
|
|
// restoreAllMocks 将工厂期实现清成返回 undefined 的裸 vi.fn:用例收尾后
|
|
|
|
|
|
// 仍可能有在途的浏览上报微任务落到 postAlbumVisit(.then 于 undefined 即
|
|
|
|
|
|
// 未处理拒绝)——恢复后立即补挂兜底实现。
|
|
|
|
|
|
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 0, counted: false });
|
2026-09-18 11:04:05 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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'));
|
2026-09-21 21:58:09 +08:00
|
|
|
|
expect(wrapper.find('.photo-viewer').exists()).toBe(false);
|
2026-09-18 11:04:05 +08:00
|
|
|
|
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();
|
2026-09-21 21:58:09 +08:00
|
|
|
|
expect(wrapper.find('.photo-viewer').exists()).toBe(true);
|
2026-09-18 11:04:05 +08:00
|
|
|
|
|
|
|
|
|
|
scrollTo.mockClear();
|
|
|
|
|
|
router.back(); // 等价浏览器后退(popstate 异步派发)
|
|
|
|
|
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/a/tok-view'));
|
|
|
|
|
|
await flushPromises();
|
2026-09-21 21:58:09 +08:00
|
|
|
|
expect(wrapper.find('.photo-viewer').exists()).toBe(false);
|
2026-09-18 11:04:05 +08:00
|
|
|
|
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();
|
|
|
|
|
|
|
2026-09-21 21:58:09 +08:00
|
|
|
|
// 单图先行:列表仍在加载,大图已可看(solo 位置未知 → 「照片预览」,§5.2 不伪造排名)。
|
2026-09-18 11:04:05 +08:00
|
|
|
|
expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-view', 'p5');
|
|
|
|
|
|
expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/p5-browse.jpg?s=1');
|
2026-09-21 21:58:09 +08:00
|
|
|
|
expect(wrapper.find('.position-indicator').text()).toBe('照片预览');
|
2026-09-18 11:04:05 +08:00
|
|
|
|
|
|
|
|
|
|
// 第 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));
|
2026-09-21 21:58:09 +08:00
|
|
|
|
expect(wrapper.find('.position-indicator').text()).toBe('5 / 5');
|
2026-09-18 11:04:05 +08:00
|
|
|
|
|
|
|
|
|
|
// 序列已构成:左切到前一张。
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
2026-09-21 21:58:09 +08:00
|
|
|
|
expect(wrapper.find('.photo-viewer').exists()).toBe(false);
|
2026-09-18 11:04:05 +08:00
|
|
|
|
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 公司年会');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|