feat(viewer): photo viewer with zoom, navigation and deep link

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 11:04:05 +08:00
parent d0b79dd0c9
commit 90fe64f637
15 changed files with 2170 additions and 345 deletions
+299
View File
@@ -0,0 +1,299 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
import { defineComponent, nextTick, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ApiError,
getPublicPhoto,
getPublicPhotos,
type PublicPhoto,
type PublicPhotoList,
} from '../api/client';
import { createViewerI18n, type ViewerI18n } from '../i18n';
import PhotoLightbox from '../components/PhotoLightbox.vue';
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
// PhotoLightbox(Task 8)组件测试:browse 渲染/导航/序列扩展、wheel 缩放与双击
// 复位、Esc/背景/按钮关闭、指针轻扫切换、签名过期静默重取与 404 移除上报。
// 深链解析与 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() };
});
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
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 队列供给(与真实时序一致:挂载后到达)。 */
function mountLightbox(photoId: string): VueWrapper {
const Host = defineComponent({
components: { PhotoLightbox },
setup() {
const sequence = usePhotoSequence({
token: ref('tok-lb'),
locale: ref('zh-CN'),
category: ref(null),
});
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('.lightbox').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 counter 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');
expect(wrapper.find('.counter').text()).toBe('2 / 3');
expect(wrapper.find('.likes').text()).toBe('7 次点赞');
});
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 backdrop click', async () => {
const wrapper = mountLightbox('p2');
await flushPromises();
keydown('Escape');
expect(lightbox(wrapper).emitted('close')).toHaveLength(1);
await wrapper.find('.control.close').trigger('click');
expect(lightbox(wrapper).emitted('close')).toHaveLength(2);
await wrapper.find('.backdrop').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 backdrop does not close', async () => {
const wrapper = mountLightbox('p2');
await flushPromises();
const root = wrapper.find('.lightbox');
// 左滑 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('.backdrop').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');
});
});