Files
photo-live/apps/viewer/src/__tests__/feed.spec.ts
T
2026-09-18 10:23:27 +08:00

381 lines
14 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 {
ApiError,
getPublicPhoto,
getPublicPhotos,
type PublicPhoto,
type PublicPhotoList,
} from '../api/client';
import { createViewerI18n, type ViewerI18n } from '../i18n';
import PhotoFeed from '../components/PhotoFeed.vue';
// PhotoFeed(Task 7)组件测试:api client 以真实模块为底、替身 getPublicPhotos /
// getPublicPhoto;IntersectionObserver / ResizeObserver 以可控假件替换(jsdom 无实现),
// clientWidth 以原型 getter mock(jsdom 恒 0)。布局纯函数已在 feed-layout.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);
/** 可控 IO 假件:记录实例与 init(rootMargin 区分懒加载 400px / 哨兵 600px)。 */
class FakeIntersectionObserver {
static instances: FakeIntersectionObserver[] = [];
readonly root: Element | Document | null = null;
readonly thresholds: ReadonlyArray<number> = [];
private readonly targets = new Set<Element>();
constructor(
readonly callback: IntersectionObserverCallback,
readonly init: IntersectionObserverInit = {},
) {
FakeIntersectionObserver.instances.push(this);
}
get rootMargin(): string {
return typeof this.init.rootMargin === 'string' ? this.init.rootMargin : '';
}
get observed(): Element[] {
return [...this.targets];
}
observe(target: Element): void {
this.targets.add(target);
}
unobserve(target: Element): void {
this.targets.delete(target);
}
disconnect(): void {
this.targets.clear();
}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
trigger(targets: Element[], isIntersecting: boolean): void {
this.callback(
targets.map((target) => ({ target, isIntersecting }) as IntersectionObserverEntry),
this as unknown as IntersectionObserver,
);
}
}
class FakeResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
function lazyObserver(): FakeIntersectionObserver {
const observer = FakeIntersectionObserver.instances.find((instance) =>
instance.rootMargin.includes('400px'),
);
if (observer === undefined) throw new Error('lazy observer not created');
return observer;
}
function sentinelObserver(): FakeIntersectionObserver {
const observer = FakeIntersectionObserver.instances.find((instance) =>
instance.rootMargin.includes('600px'),
);
if (observer === undefined) throw new Error('sentinel observer not created');
return observer;
}
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 };
}
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,
},
});
mounted.push(wrapper);
return wrapper;
}
describe('photo feed (Task 7)', () => {
beforeEach(() => {
localStorage.clear();
i18n = createViewerI18n('zh-CN');
FakeIntersectionObserver.instances = [];
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver);
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
// jsdom 无布局:clientWidth 恒 0 → 原型 getter mock 为 700(columnWidth=346)。
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'), makePhoto('p3')], 'cur-1'));
mockedGetPublicPhoto.mockReset();
});
afterEach(() => {
while (mounted.length > 0) {
mounted.pop()?.unmount();
}
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.useRealTimers();
});
it('renders first page as absolute-positioned cells with reserved heights', async () => {
const wrapper = mountFeed();
await flushPromises();
expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-feed', { locale: 'zh-CN' });
const cells = wrapper.findAll('.cell');
expect(cells).toHaveLength(3);
// 700 容器宽 → columnWidth 346;800x500 比例 → 条高 round(346*.625)=216。
// 3 条:col0 [224,0] → p2 入 col1 → p3 入 col0(top 224)→ 容器高 224+216=440。
expect(wrapper.find('.waterfall').attributes('style')).toContain('height: 440px');
expect(cells[1].attributes('style')).toContain('left: 354px');
expect(cells[2].attributes('style')).toContain('top: 224px');
});
it('binds img src only after the cell approaches the viewport (lazy swap)', async () => {
const wrapper = mountFeed();
await flushPromises();
expect(wrapper.findAll('img.cell-img')).toHaveLength(0);
const cells = wrapper.findAll('.cell');
expect(lazyObserver().observed).toHaveLength(3);
lazyObserver().trigger([cells[0].element, cells[1].element], true);
await vi.waitFor(() => expect(wrapper.findAll('img.cell-img')).toHaveLength(2));
expect(wrapper.find('img.cell-img').attributes('src')).toBe('http://img.test/p1-thumb.jpg?s=1');
// 已显示的单元格不再被观察。
expect(lazyObserver().observed).toHaveLength(1);
});
it('loads the next page when the sentinel intersects, dedupes and stops at exhaustion', async () => {
const wrapper = mountFeed();
await flushPromises();
const sentinel = wrapper.find('.sentinel');
expect(sentinel.exists()).toBe(true);
// 第 2 页带回重复的 p3(游标边界漂移防御)→ 仅追加新 id。
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p3'), makePhoto('p4')], null));
sentinelObserver().trigger([sentinel.element], true);
await flushPromises();
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith(
'tok-feed',
expect.objectContaining({ cursor: 'cur-1' }),
);
expect(wrapper.findAll('.cell')).toHaveLength(4);
// 游标耗尽:哨兵卸载 + 底部提示。
expect(wrapper.find('.sentinel').exists()).toBe(false);
expect(wrapper.find('.end-note').text()).toBe('已经到底了');
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();
await flushPromises();
expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线');
expect(wrapper.find('.cell').exists()).toBe(false);
});
it('initial load failure shows the localized error with retry; retry recovers', async () => {
mockedGetPublicPhotos
.mockRejectedValueOnce(new ApiError('NETWORK_ERROR', 'network down', 0, null))
.mockResolvedValueOnce(makeList([makePhoto('p1')], null));
const wrapper = mountFeed();
await flushPromises();
expect(wrapper.find('.error[role="alert"]').text()).toContain('网络连接失败');
expect(wrapper.find('.cell').exists()).toBe(false);
await wrapper.find('.error button').trigger('click');
await flushPromises();
expect(wrapper.findAll('.cell')).toHaveLength(1);
});
it('retries a failed thumb once with a refreshed signed URL, then hides the img', async () => {
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));
mockedGetPublicPhoto.mockResolvedValue({
...makePhoto('p1'),
categoryId: null,
thumb: { url: 'http://img.test/p1-thumb.jpg?s=2', expiresAt: '2026-10-01T01:00:00.000Z' },
});
await wrapper.findAll('img.cell-img')[0].trigger('error');
await flushPromises();
expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-feed', 'p1');
expect(wrapper.findAll('img.cell-img')[0].attributes('src')).toBe('http://img.test/p1-thumb.jpg?s=2');
// 第二次失败 → 收起该图(占位底色仍在,布局不塌)。
await wrapper.findAll('img.cell-img')[0].trigger('error');
await flushPromises();
expect(wrapper.findAll('img.cell-img')).toHaveLength(2);
expect(wrapper.findAll('.cell')).toHaveLength(3);
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);
});
});