feat(viewer): photo viewer with zoom, navigation and deep link
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user