feat(viewer): photo viewer with zoom, navigation and deep link
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { computed, defineComponent } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ApiError,
|
||||
@@ -9,10 +10,13 @@ import {
|
||||
} from '../api/client';
|
||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
import PhotoFeed from '../components/PhotoFeed.vue';
|
||||
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
|
||||
|
||||
// PhotoFeed(Task 7)组件测试:api client 以真实模块为底、替身 getPublicPhotos /
|
||||
// getPublicPhoto;IntersectionObserver / ResizeObserver 以可控假件替换(jsdom 无实现),
|
||||
// clientWidth 以原型 getter mock(jsdom 恒 0)。布局纯函数已在 feed-layout.spec 覆盖。
|
||||
// PhotoFeed(Task 7;Task 8 起数据层在 PhotoSequence)组件测试:api client 以真实
|
||||
// 模块为底、替身 getPublicPhotos / getPublicPhoto;IntersectionObserver /
|
||||
// ResizeObserver 以可控假件替换(jsdom 无实现),clientWidth 以原型 getter mock
|
||||
// (jsdom 恒 0)。序列数据/轮询已在 sequence.spec.ts 覆盖;布局纯函数在
|
||||
// feed-layout.spec。宿主组件持 sequence(对齐 ActivityPage 的真实组装方式)。
|
||||
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
@@ -109,20 +113,32 @@ function makeList(items: PublicPhoto[], nextCursor: string | null = null): Publi
|
||||
let i18n: ViewerI18n;
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
function mountFeed(props: { token?: string; locale?: string; category?: string | null } = {}): VueWrapper {
|
||||
const wrapper = mount(PhotoFeed, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
token: props.token ?? 'tok-feed',
|
||||
locale: props.locale ?? 'zh-CN',
|
||||
category: props.category ?? null,
|
||||
/** 宿主:创建 sequence 并渲染 PhotoFeed(镜像 ActivityPage 组装);捕获 open 事件。 */
|
||||
function mountFeed(props: { token?: string; locale?: string; category?: string | null } = {}): {
|
||||
wrapper: VueWrapper;
|
||||
sequence: PhotoSequence;
|
||||
opened: string[];
|
||||
} {
|
||||
const opened: string[] = [];
|
||||
const Host = defineComponent({
|
||||
components: { PhotoFeed },
|
||||
setup() {
|
||||
const sequence = usePhotoSequence({
|
||||
token: computed(() => props.token ?? 'tok-feed'),
|
||||
locale: computed(() => props.locale ?? 'zh-CN'),
|
||||
category: computed(() => props.category ?? null),
|
||||
});
|
||||
return { sequence, onOpen: (photoId: string) => opened.push(photoId) };
|
||||
},
|
||||
template: `<PhotoFeed :sequence="sequence" @open="onOpen" />`,
|
||||
});
|
||||
const wrapper = mount(Host, { global: { plugins: [i18n] } });
|
||||
mounted.push(wrapper);
|
||||
return wrapper;
|
||||
const sequence = (wrapper.vm as unknown as { sequence: PhotoSequence }).sequence;
|
||||
return { wrapper, sequence, opened };
|
||||
}
|
||||
|
||||
describe('photo feed (Task 7)', () => {
|
||||
describe('photo feed (Task 7 / Task 8 sequence props)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
@@ -144,11 +160,10 @@ describe('photo feed (Task 7)', () => {
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders first page as absolute-positioned cells with reserved heights', async () => {
|
||||
const wrapper = mountFeed();
|
||||
const { wrapper } = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-feed', { locale: 'zh-CN' });
|
||||
@@ -161,8 +176,16 @@ describe('photo feed (Task 7)', () => {
|
||||
expect(cells[2].attributes('style')).toContain('top: 224px');
|
||||
});
|
||||
|
||||
it('emits open with the photoId when a cell is clicked', async () => {
|
||||
const { wrapper, opened } = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.findAll('.cell')[1].trigger('click');
|
||||
expect(opened).toEqual(['p2']);
|
||||
});
|
||||
|
||||
it('binds img src only after the cell approaches the viewport (lazy swap)', async () => {
|
||||
const wrapper = mountFeed();
|
||||
const { wrapper } = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findAll('img.cell-img')).toHaveLength(0);
|
||||
@@ -177,7 +200,7 @@ describe('photo feed (Task 7)', () => {
|
||||
});
|
||||
|
||||
it('loads the next page when the sentinel intersects, dedupes and stops at exhaustion', async () => {
|
||||
const wrapper = mountFeed();
|
||||
const { wrapper } = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
const sentinel = wrapper.find('.sentinel');
|
||||
@@ -199,59 +222,9 @@ describe('photo feed (Task 7)', () => {
|
||||
expect(wrapper.find('.new-photo-bar').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not fire a second page fetch while one is in flight', async () => {
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhotos.mockClear();
|
||||
|
||||
let resolvePage!: (value: PublicPhotoList) => void;
|
||||
mockedGetPublicPhotos.mockReturnValueOnce(
|
||||
new Promise<PublicPhotoList>((resolve) => {
|
||||
resolvePage = resolve;
|
||||
}),
|
||||
);
|
||||
const sentinel = wrapper.find('.sentinel');
|
||||
sentinelObserver().trigger([sentinel.element], true);
|
||||
sentinelObserver().trigger([sentinel.element], true);
|
||||
await flushPromises();
|
||||
expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolvePage(makeList([makePhoto('p4')], null));
|
||||
await flushPromises();
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('switching category resets the list and passes the filter through', async () => {
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('c1p1'), makePhoto('c1p2')], null));
|
||||
|
||||
await wrapper.setProps({ category: 'cat-1' });
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith(
|
||||
'tok-feed',
|
||||
expect.objectContaining({ category: 'cat-1', locale: 'zh-CN' }),
|
||||
);
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('locale switch re-fetches the current category (filter preserved)', async () => {
|
||||
const wrapper = mountFeed({ category: 'cat-1' });
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.setProps({ locale: 'en' });
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith(
|
||||
'tok-feed',
|
||||
expect.objectContaining({ locale: 'en', category: 'cat-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the localized empty state when the album has no photos', async () => {
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([]));
|
||||
const wrapper = mountFeed();
|
||||
const { wrapper } = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线');
|
||||
@@ -262,7 +235,7 @@ describe('photo feed (Task 7)', () => {
|
||||
mockedGetPublicPhotos
|
||||
.mockRejectedValueOnce(new ApiError('NETWORK_ERROR', 'network down', 0, null))
|
||||
.mockResolvedValueOnce(makeList([makePhoto('p1')], null));
|
||||
const wrapper = mountFeed();
|
||||
const { wrapper } = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.error[role="alert"]').text()).toContain('网络连接失败');
|
||||
@@ -274,7 +247,7 @@ describe('photo feed (Task 7)', () => {
|
||||
});
|
||||
|
||||
it('retries a failed thumb once with a refreshed signed URL, then hides the img', async () => {
|
||||
const wrapper = mountFeed();
|
||||
const { wrapper } = mountFeed();
|
||||
await flushPromises();
|
||||
lazyObserver().trigger(wrapper.findAll('.cell').map((cell) => cell.element), true);
|
||||
await vi.waitFor(() => expect(wrapper.findAll('img.cell-img')).toHaveLength(3));
|
||||
@@ -298,83 +271,3 @@ describe('photo feed (Task 7)', () => {
|
||||
expect(mockedGetPublicPhoto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('photo feed polling (Task 7)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
FakeIntersectionObserver.instances = [];
|
||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver);
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
|
||||
const proto = Object.prototype.hasOwnProperty.call(HTMLElement.prototype, 'clientWidth')
|
||||
? HTMLElement.prototype
|
||||
: Element.prototype;
|
||||
vi.spyOn(proto, 'clientWidth', 'get').mockReturnValue(700);
|
||||
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], null));
|
||||
mockedGetPublicPhoto.mockReset();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) {
|
||||
mounted.pop()?.unmount();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('poll detects new photos, shows the hint bar; click refreshes to page 1, clears the hint and scrolls top', async () => {
|
||||
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.new-photo-bar').exists()).toBe(false);
|
||||
|
||||
// 轮询取到的第 1 页含未知 id p9 → 只亮提示条,不自动插入。
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p9'), makePhoto('p1'), makePhoto('p2')], null));
|
||||
vi.advanceTimersByTime(30_000);
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.new-photo-bar').text()).toBe('有新照片,点击查看');
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(2);
|
||||
|
||||
// 点击提示条:整表刷新(第 1 页含新照片)并回顶。
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p9'), makePhoto('p1'), makePhoto('p2')], null));
|
||||
await wrapper.find('.new-photo-bar').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(3);
|
||||
expect(wrapper.find('.new-photo-bar').exists()).toBe(false);
|
||||
expect(scrollTo).toHaveBeenCalledWith(0, 0);
|
||||
});
|
||||
|
||||
it('keeps polling silent on failure (no error state, no hint)', async () => {
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
mockedGetPublicPhotos.mockRejectedValueOnce(new ApiError('INTERNAL_ERROR', 'boom', 500, 'req-1'));
|
||||
vi.advanceTimersByTime(30_000);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.error').exists()).toBe(false);
|
||||
expect(wrapper.find('.new-photo-bar').exists()).toBe(false);
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('pauses polling while the tab is hidden and resumes when visible again', async () => {
|
||||
mountFeed();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhotos.mockClear();
|
||||
|
||||
const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
vi.advanceTimersByTime(120_000);
|
||||
await flushPromises();
|
||||
expect(mockedGetPublicPhotos).not.toHaveBeenCalled();
|
||||
|
||||
hidden.mockReturnValue(false);
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
vi.advanceTimersByTime(30_000);
|
||||
await flushPromises();
|
||||
expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,27 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import App from '../App.vue';
|
||||
import { getPublicAlbum } from '../api/client';
|
||||
import { getPublicAlbum, getPublicPhoto } from '../api/client';
|
||||
import { createViewerI18n } from '../i18n';
|
||||
import { router } from '../router';
|
||||
import { makeAlbum } from './fixtures';
|
||||
|
||||
// 路由解析(Task 5 脚手架测试对齐 Task 6 真实活动页):token/photoId 参数经
|
||||
// api mock 断言——活动页以 token 取数,photoId 供 Task 8 深链定位(不影响取数)。
|
||||
// getPublicPhotos 一并替身为空页(Task 7 起活动页内嵌 PhotoFeed)。
|
||||
// api mock 断言——活动页以 token 取数,photoId 驱动 Task 8 深链(单图先行 + 序列
|
||||
// 兜底)。getPublicPhotos / getPublicPhoto 一并替身(Task 7 起活动页内嵌 PhotoFeed,
|
||||
// Task 8 起深链取单图)。
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return {
|
||||
...actual,
|
||||
getPublicAlbum: vi.fn(),
|
||||
getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }),
|
||||
getPublicPhoto: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
|
||||
|
||||
// 逐挂载独立 i18n + 逐用例卸载(背景见 activity.spec.ts 头注)。
|
||||
let i18n: ReturnType<typeof createViewerI18n>;
|
||||
@@ -49,6 +52,16 @@ describe('viewer routing', () => {
|
||||
});
|
||||
|
||||
it('deep link resolves token and photoId params on the activity page', async () => {
|
||||
mockedGetPublicPhoto.mockResolvedValue({
|
||||
photoId: 'photo-42',
|
||||
width: 800,
|
||||
height: 500,
|
||||
thumb: { url: 'http://img.test/photo-42-thumb.jpg?s=1', expiresAt: '2026-10-01T00:00:00.000Z' },
|
||||
browse: { url: 'http://img.test/photo-42-browse.jpg?s=1', expiresAt: '2026-10-01T00:00:00.000Z' },
|
||||
likeCount: 0,
|
||||
publishedAt: '2026-10-01T00:00:00.000Z',
|
||||
categoryId: null,
|
||||
});
|
||||
await router.push('/a/tok-abc/p/photo-42');
|
||||
await router.isReady();
|
||||
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||
@@ -56,8 +69,10 @@ describe('viewer routing', () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicAlbum).toHaveBeenCalledWith('tok-abc', 'zh-CN');
|
||||
expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-abc', 'photo-42');
|
||||
expect(wrapper.text()).toContain('2026 公司年会');
|
||||
// photoId 仅用于 Task 8 大图定位,不进入信息区文案。
|
||||
// 深链以大图呈现(solo 序列),id 本身不进入文案。
|
||||
expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/photo-42-browse.jpg?s=1');
|
||||
expect(wrapper.text()).not.toContain('photo-42');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ApiError,
|
||||
getPublicPhoto,
|
||||
getPublicPhotos,
|
||||
type PublicPhoto,
|
||||
type PublicPhotoList,
|
||||
} from '../api/client';
|
||||
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
|
||||
|
||||
// PhotoSequence(Task 8 数据层):分页追加去重、深链 solo 条目(就位/自然归位/
|
||||
// 越上限保留)、单图签名刷新(换绑/404 移除)与轮询提示。jsdom 下 IO/RO 无关
|
||||
// (本层无 DOM 观察者);轮询用假时钟驱动。
|
||||
|
||||
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, width = 800, height = 500): PublicPhoto {
|
||||
return {
|
||||
photoId: id,
|
||||
width,
|
||||
height,
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 挂载一个仅创建 sequence 的宿主组件(composable 的 onMounted/watch 需组件上下文)。 */
|
||||
function mountSequence(options: { token?: string; category?: string | null } = {}): {
|
||||
wrapper: VueWrapper;
|
||||
sequence: PhotoSequence;
|
||||
} {
|
||||
let sequence!: PhotoSequence;
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
sequence = usePhotoSequence({
|
||||
token: ref(options.token ?? 'tok-seq'),
|
||||
locale: ref('zh-CN'),
|
||||
category: ref(options.category ?? null),
|
||||
});
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
const wrapper = mount(Host);
|
||||
return { wrapper, sequence };
|
||||
}
|
||||
|
||||
describe('photo sequence pagination (Task 8)', () => {
|
||||
beforeEach(() => {
|
||||
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], 'cur-1'));
|
||||
mockedGetPublicPhoto.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('loads the first page and exposes cursor state', async () => {
|
||||
const { wrapper, sequence } = mountSequence();
|
||||
expect(sequence.initialLoading).toBe(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-seq', { locale: 'zh-CN' });
|
||||
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2']);
|
||||
expect(sequence.initialLoading).toBe(false);
|
||||
expect(sequence.hasMore).toBe(true);
|
||||
expect(sequence.pagesLoaded).toBe(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('appends the next page with dedupe and stops at cursor exhaustion', async () => {
|
||||
const { wrapper, sequence } = mountSequence();
|
||||
await flushPromises();
|
||||
// 第 2 页带回重复 p2(游标边界漂移防御)→ 仅追加新 id。
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p2'), makePhoto('p3')], null));
|
||||
|
||||
await sequence.loadMore();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith('tok-seq', expect.objectContaining({ cursor: 'cur-1' }));
|
||||
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2', 'p3']);
|
||||
expect(sequence.hasMore).toBe(false);
|
||||
expect(sequence.pagesLoaded).toBe(2);
|
||||
|
||||
// 游标耗尽后再 loadMore 为无操作(大图导航越过末尾的安全边界)。
|
||||
await sequence.loadMore();
|
||||
expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(2);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('dedupes against an in-flight loadMore (single fetch while pending)', async () => {
|
||||
const { wrapper, sequence } = mountSequence();
|
||||
await flushPromises();
|
||||
|
||||
let resolvePage!: (value: PublicPhotoList) => void;
|
||||
mockedGetPublicPhotos.mockReturnValueOnce(
|
||||
new Promise<PublicPhotoList>((resolve) => {
|
||||
resolvePage = resolve;
|
||||
}),
|
||||
);
|
||||
void sequence.loadMore();
|
||||
void sequence.loadMore();
|
||||
await flushPromises();
|
||||
expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(2);
|
||||
|
||||
resolvePage(makeList([makePhoto('p9')], null));
|
||||
await flushPromises();
|
||||
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2', 'p9']);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('photo sequence deep-link solo entries (Task 8)', () => {
|
||||
beforeEach(() => {
|
||||
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], null));
|
||||
mockedGetPublicPhoto.mockReset();
|
||||
});
|
||||
|
||||
it('ensureInSequence returns the existing index without duplicating', async () => {
|
||||
const { wrapper, sequence } = mountSequence();
|
||||
await flushPromises();
|
||||
|
||||
expect(sequence.ensureInSequence(makePhoto('p2'))).toBe(1);
|
||||
expect(sequence.photos).toHaveLength(2);
|
||||
expect(sequence.isSolo('p2')).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('appends an unknown photo as a solo entry navigable at the tail', async () => {
|
||||
const { wrapper, sequence } = mountSequence();
|
||||
await flushPromises();
|
||||
|
||||
const index = sequence.ensureInSequence(makePhoto('p9'));
|
||||
expect(index).toBe(2);
|
||||
expect(sequence.isSolo('p9')).toBe(true);
|
||||
expect(sequence.photos[2]?.photoId).toBe('p9');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('replaces the solo copy with its natural position when a later page brings it', async () => {
|
||||
mockedGetPublicPhotos.mockReset();
|
||||
mockedGetPublicPhotos
|
||||
.mockResolvedValueOnce(makeList([makePhoto('p1'), makePhoto('p2')], 'cur-1'))
|
||||
.mockResolvedValueOnce(makeList([makePhoto('p4'), makePhoto('p9')], null));
|
||||
const { wrapper, sequence } = mountSequence();
|
||||
await flushPromises();
|
||||
sequence.ensureInSequence(makePhoto('p9')); // 深链目标不在第 1 页
|
||||
|
||||
// 相邻页把 p4 与 p9 带回:solo 副本被自然位置副本取代,顺序纠正。
|
||||
await sequence.loadMore();
|
||||
await flushPromises();
|
||||
|
||||
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2', 'p4', 'p9']);
|
||||
expect(sequence.isSolo('p9')).toBe(false);
|
||||
expect(sequence.indexOf('p9')).toBe(3);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps surviving solos across a full reset, and drops ones covered by the page', async () => {
|
||||
const { wrapper, sequence } = mountSequence();
|
||||
await flushPromises();
|
||||
sequence.ensureInSequence(makePhoto('p9')); // 保留
|
||||
sequence.ensureInSequence(makePhoto('p2')); // 第 1 页含 p2 → 自然副本接管
|
||||
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p2'), makePhoto('p1')], null));
|
||||
await sequence.resetAndLoad();
|
||||
await flushPromises();
|
||||
|
||||
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p2', 'p1', 'p9']);
|
||||
expect(sequence.isSolo('p9')).toBe(true);
|
||||
expect(sequence.isSolo('p2')).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('removes a photo from the sequence (404 removal path)', async () => {
|
||||
const { wrapper, sequence } = mountSequence();
|
||||
await flushPromises();
|
||||
|
||||
sequence.removePhoto('p1');
|
||||
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p2']);
|
||||
expect(sequence.indexOf('p1')).toBe(-1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('photo sequence url refresh (Task 8)', () => {
|
||||
beforeEach(() => {
|
||||
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], null));
|
||||
mockedGetPublicPhoto.mockReset();
|
||||
});
|
||||
|
||||
it('replaces signed urls in place on success', async () => {
|
||||
const { wrapper, sequence } = mountSequence();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhoto.mockResolvedValue({
|
||||
...makePhoto('p1'),
|
||||
categoryId: null,
|
||||
thumb: { url: 'http://img.test/p1-thumb.jpg?s=2', expiresAt: '2026-10-01T01:00:00.000Z' },
|
||||
browse: { url: 'http://img.test/p1-browse.jpg?s=2', expiresAt: '2026-10-01T01:00:00.000Z' },
|
||||
});
|
||||
|
||||
expect(await sequence.refreshPhotoUrls('p1')).toBe('replaced');
|
||||
expect(sequence.photos[0]?.browse.url).toBe('http://img.test/p1-browse.jpg?s=2');
|
||||
expect(sequence.photos[0]?.thumb.url).toBe('http://img.test/p1-thumb.jpg?s=2');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('removes the photo on PHOTO_NOT_PUBLIC', async () => {
|
||||
const { wrapper, sequence } = mountSequence();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhoto.mockRejectedValue(new ApiError('PHOTO_NOT_PUBLIC', 'gone', 404, 'req-1'));
|
||||
|
||||
expect(await sequence.refreshPhotoUrls('p1')).toBe('removed');
|
||||
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p2']);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps the photo on transient failures', async () => {
|
||||
const { wrapper, sequence } = mountSequence();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhoto.mockRejectedValue(new ApiError('NETWORK_ERROR', 'down', 0, null));
|
||||
|
||||
expect(await sequence.refreshPhotoUrls('p1')).toBe('failed');
|
||||
expect(sequence.photos).toHaveLength(2);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('photo sequence polling (Task 8)', () => {
|
||||
beforeEach(() => {
|
||||
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], null));
|
||||
mockedGetPublicPhoto.mockReset();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('raises the hint when page 1 contains an unknown id; refresh scrolls top', async () => {
|
||||
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||
const { wrapper, sequence } = mountSequence();
|
||||
await flushPromises();
|
||||
expect(sequence.hasNew).toBe(false);
|
||||
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p9'), makePhoto('p1')], null));
|
||||
vi.advanceTimersByTime(30_000);
|
||||
await flushPromises();
|
||||
expect(sequence.hasNew).toBe(true);
|
||||
expect(sequence.photos).toHaveLength(2); // 只亮提示不自动插入
|
||||
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p9'), makePhoto('p1'), makePhoto('p2')], null));
|
||||
await sequence.refreshFromHint();
|
||||
await flushPromises();
|
||||
expect(sequence.hasNew).toBe(false);
|
||||
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p9', 'p1', 'p2']);
|
||||
expect(scrollTo).toHaveBeenCalledWith(0, 0);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('stays silent when a poll fails', async () => {
|
||||
const { wrapper, sequence } = mountSequence();
|
||||
await flushPromises();
|
||||
|
||||
mockedGetPublicPhotos.mockRejectedValueOnce(new ApiError('INTERNAL_ERROR', 'boom', 500, 'req-1'));
|
||||
vi.advanceTimersByTime(30_000);
|
||||
await flushPromises();
|
||||
expect(sequence.hasNew).toBe(false);
|
||||
expect(sequence.initialErrorKey).toBe(null);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
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, 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 三端点替身。
|
||||
|
||||
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(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
|
||||
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
|
||||
|
||||
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(() => {
|
||||
while (mounted.length > 0) {
|
||||
mounted.pop()?.unmount();
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
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('.lightbox').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('.lightbox').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('.lightbox').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 序列 1/1)。
|
||||
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('.counter').text()).toBe('1 / 1');
|
||||
|
||||
// 第 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('.counter').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('.lightbox').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 公司年会');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INITIAL_ZOOM,
|
||||
MAX_SCALE,
|
||||
MIN_SCALE,
|
||||
clampScale,
|
||||
panBy,
|
||||
resetZoom,
|
||||
wheelFactor,
|
||||
zoomAt,
|
||||
type ZoomState,
|
||||
} from '../viewer/zoom';
|
||||
|
||||
// 大图查看器缩放状态机(Task 8)纯函数层:钳制、不动点缩放、平移门控与复位。
|
||||
|
||||
describe('zoom state machine (Task 8)', () => {
|
||||
it('clamps scale into [1, 4]', () => {
|
||||
expect(clampScale(0.2)).toBe(MIN_SCALE);
|
||||
expect(clampScale(3.9)).toBe(3.9);
|
||||
expect(clampScale(11)).toBe(MAX_SCALE);
|
||||
});
|
||||
|
||||
it('zooms around the given anchor point: the anchor stays fixed on screen', () => {
|
||||
const state: ZoomState = { scale: 1, tx: 0, ty: 0 };
|
||||
// 锚点 (100, 40)(相对图片中心):缩放前屏幕位置 = 100*1+0 = 100。
|
||||
const next = zoomAt(state, 2, 100, 40);
|
||||
expect(next.scale).toBe(2);
|
||||
expect(next.tx).toBe(100 * (1 - 2));
|
||||
expect(next.ty).toBe(40 * (1 - 2));
|
||||
// 锚点屏幕位置不变:100*2 + (-100) = 100。
|
||||
expect(100 * next.scale + next.tx).toBe(100);
|
||||
expect(40 * next.scale + next.ty).toBe(40);
|
||||
});
|
||||
|
||||
it('clamps at max scale and keeps the anchor fixed with the actual factor', () => {
|
||||
const state: ZoomState = { scale: 3, tx: -50, ty: 20 };
|
||||
const next = zoomAt(state, 10, 200, 0);
|
||||
expect(next.scale).toBe(MAX_SCALE);
|
||||
// 实际因子 4/3;锚点 200*3-50 = 550 → 200*4 + tx' = 550 → tx' = -250。
|
||||
expect(200 * next.scale + next.tx).toBe(200 * state.scale + state.tx);
|
||||
});
|
||||
|
||||
it('resets translation when scale returns to min', () => {
|
||||
const state: ZoomState = { scale: 2, tx: -120, ty: 80 };
|
||||
const next = zoomAt(state, 0.1, 300, 300);
|
||||
expect(next).toEqual(resetZoom());
|
||||
});
|
||||
|
||||
it('pans only when zoomed in; pan accumulates', () => {
|
||||
const flat: ZoomState = { ...INITIAL_ZOOM };
|
||||
expect(panBy(flat, 40, 20)).toBe(flat); // scale=1:无平移空间
|
||||
|
||||
const zoomed = zoomAt(flat, 2, 0, 0);
|
||||
const panned = panBy(panBy(zoomed, 10, -5), 6, 6);
|
||||
expect(panned.tx).toBe(16);
|
||||
expect(panned.ty).toBe(1);
|
||||
});
|
||||
|
||||
it('wheel factor maps deltaY sign consistently and stays continuous', () => {
|
||||
expect(wheelFactor(-120)).toBeGreaterThan(1); // 上滚/外张 → 放大
|
||||
expect(wheelFactor(120)).toBeLessThan(1); // 下滚/内收 → 缩小
|
||||
expect(wheelFactor(0)).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user