feat(viewer): waterfall feed with lazy load, filter and new-photo hint
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -8,11 +8,16 @@ import ActivityPage from '../pages/ActivityPage.vue';
|
||||
import { router } from '../router';
|
||||
import { makeAlbum, makeEnAlbum } from './fixtures';
|
||||
|
||||
// ActivityPage(Task 6)组件测试:api client 以真实模块为底、仅替身 getPublicAlbum,
|
||||
// ActivityPage(Task 6)组件测试:api client 以真实模块为底、仅替身 getPublicAlbum /
|
||||
// getPublicPhotos(Task 7 起活动页内嵌 PhotoFeed,需喂空页避免真实 fetch),
|
||||
// 保证 ApiError/errors 映射等真实逻辑参与断言。
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return { ...actual, getPublicAlbum: vi.fn() };
|
||||
return {
|
||||
...actual,
|
||||
getPublicAlbum: vi.fn(),
|
||||
getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||
@@ -63,8 +68,8 @@ describe('activity page (Task 6)', () => {
|
||||
expect(wrapper.find('.meta').text()).toContain('10月1日');
|
||||
|
||||
const chips = wrapper.findAll('.chip');
|
||||
expect(chips.map((chip) => chip.text())).toEqual(['签到墙', '开场']);
|
||||
expect(chips.every((chip) => chip.attributes('disabled') !== undefined)).toBe(true);
|
||||
expect(chips.map((chip) => chip.text())).toEqual(['全部', '签到墙', '开场']);
|
||||
expect(chips.every((chip) => chip.attributes('disabled') === undefined)).toBe(true);
|
||||
|
||||
expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线');
|
||||
// 无 banner 资产 → 不渲染 img;viewCount 占位 null → 不渲染任何计数节点。
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
containsNewPhotoId,
|
||||
layoutWaterfall,
|
||||
mergeUniqueByPhotoId,
|
||||
} from '../feed/layout';
|
||||
|
||||
// Task 7 瀑布流纯函数层:列放置/高度预留/去重/新照片判定(组件行为在 feed.spec)。
|
||||
// 数字断言手算依据:containerWidth 320、gap 8、双列 → columnWidth = floor(312/2) = 156。
|
||||
|
||||
describe('layoutWaterfall (pure placement)', () => {
|
||||
it('places each item in the shorter column with aspect-preserved height', () => {
|
||||
const layout = layoutWaterfall(
|
||||
[
|
||||
{ width: 200, height: 100 }, // A: ratio .5 → 78
|
||||
{ width: 100, height: 200 }, // B: ratio 2 → 312
|
||||
{ width: 156, height: 156 }, // C: ratio 1 → 156
|
||||
{ width: 300, height: 100 }, // D: ratio 1/3 → 52
|
||||
],
|
||||
320,
|
||||
);
|
||||
|
||||
expect(layout.columnWidth).toBe(156);
|
||||
// A→col0(空列同高取索引小);B→col1(0 < 86);C→col0(86 < 320);D→col0(250 < 320)。
|
||||
expect(layout.placements.map((p) => p.column)).toEqual([0, 1, 0, 0]);
|
||||
expect(layout.placements.map((p) => p.top)).toEqual([0, 0, 86, 250]);
|
||||
expect(layout.placements.map((p) => p.height)).toEqual([78, 312, 156, 52]);
|
||||
// 最高列 320 去掉末尾间距 → 容器高 312。
|
||||
expect(layout.height).toBe(312);
|
||||
});
|
||||
|
||||
it('computes column left offsets with the gap and alternates on equal heights', () => {
|
||||
const layout = layoutWaterfall(
|
||||
[
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
],
|
||||
320,
|
||||
);
|
||||
|
||||
expect(layout.placements.map((p) => p.left)).toEqual([0, 164, 0]);
|
||||
expect(layout.placements.map((p) => p.column)).toEqual([0, 1, 0]);
|
||||
expect(layout.placements.map((p) => p.top)).toEqual([0, 0, 164]);
|
||||
// col0 累计 156+8+156+8=328,col1 156+8=164 → 容器高 328-8=320。
|
||||
expect(layout.height).toBe(320);
|
||||
});
|
||||
|
||||
it('falls back to a square placeholder for degenerate dimensions (no NaN)', () => {
|
||||
const layout = layoutWaterfall([{ width: 0, height: 500 }, { width: 400, height: 0 }], 320);
|
||||
expect(layout.placements.map((p) => p.height)).toEqual([156, 156]);
|
||||
expect(layout.placements.every((p) => Number.isFinite(p.top) && Number.isFinite(p.left))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an empty layout for no items and tolerates zero container width', () => {
|
||||
const empty = layoutWaterfall([], 320);
|
||||
expect(empty.placements).toEqual([]);
|
||||
expect(empty.height).toBe(0);
|
||||
|
||||
const zero = layoutWaterfall([{ width: 100, height: 100 }], 0);
|
||||
expect(zero.columnWidth).toBe(0);
|
||||
expect(zero.placements.map((p) => p.height)).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeUniqueByPhotoId (page append dedupe)', () => {
|
||||
it('appends only unseen photoIds and preserves order', () => {
|
||||
const merged = mergeUniqueByPhotoId(
|
||||
[{ photoId: 'a' }, { photoId: 'b' }],
|
||||
[{ photoId: 'b' }, { photoId: 'c' }, { photoId: 'a' }, { photoId: 'c' }],
|
||||
);
|
||||
expect(merged.map((item) => item.photoId)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('keeps the existing entry when a duplicate id arrives', () => {
|
||||
const existing = [{ photoId: 'a', width: 1 }];
|
||||
const merged = mergeUniqueByPhotoId(existing, [{ photoId: 'a', width: 2 }]);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].width).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('containsNewPhotoId (poll detection)', () => {
|
||||
it('detects ids outside the known set and returns false when all known', () => {
|
||||
expect(containsNewPhotoId(['a', 'b'], [{ photoId: 'b' }, { photoId: 'c' }])).toBe(true);
|
||||
expect(containsNewPhotoId(['a', 'b'], [{ photoId: 'b' }, { photoId: 'a' }])).toBe(false);
|
||||
// 已知集为空(空相册)时任何已发布照片都是「新照片」。
|
||||
expect(containsNewPhotoId([], [{ photoId: 'a' }])).toBe(true);
|
||||
expect(containsNewPhotoId(['a'], [])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,14 @@ import { makeAlbum } from './fixtures';
|
||||
|
||||
// 路由解析(Task 5 脚手架测试对齐 Task 6 真实活动页):token/photoId 参数经
|
||||
// api mock 断言——活动页以 token 取数,photoId 供 Task 8 深链定位(不影响取数)。
|
||||
// getPublicPhotos 一并替身为空页(Task 7 起活动页内嵌 PhotoFeed)。
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return { ...actual, getPublicAlbum: vi.fn() };
|
||||
return {
|
||||
...actual,
|
||||
getPublicAlbum: vi.fn(),
|
||||
getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||
|
||||
Reference in New Issue
Block a user