feat(viewer): UI-02 responsive waterfall with like badges
Task 3 (V03 瀑布流重排, 方案 §4.3): - responsiveColumns 断点:<600px 双列 gap4 / 600-959px 三列 gap6 / ≥960px 四列 gap8;PhotoFeed 经既有 ResizeObserver 容器测量整体重排 - 照片卡片去卡片化:图片边到边 + 4px 圆角,无白色厚包裹/无常驻文件名; 右下角点赞角标(半透明暗底渐变、白心 16px、12px tabular-nums 计数), @click.stop 独立交互不冒泡开大图;计数经 Intl 分组 - 新照片提示改白色 pill(viewer.newPhotos,接口仅 hasNew 不伪造数量); refreshFromHint 不再强制回顶(用户浏览位置保持) - 首屏骨架按 skeletonSources 竖/横/方比例走真实瀑布流放置(非均匀网格); 宽高缺失/NaN/Infinity 回退 4:3 安全比例(无 NaN/无限高度) - 保留懒加载 IO、游标分页、photoId 稳定键、翻页失败底部重试与已到底文案 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,12 @@ import {
|
||||
containsNewPhotoId,
|
||||
layoutWaterfall,
|
||||
mergeUniqueByPhotoId,
|
||||
responsiveColumns,
|
||||
skeletonSources,
|
||||
} from '../feed/layout';
|
||||
|
||||
// Task 7 瀑布流纯函数层:列放置/高度预留/去重/新照片判定(组件行为在 feed.spec)。
|
||||
// UI-02 Task 3:响应式列数断点、4:3 安全比例、首屏骨架占位源。
|
||||
// 数字断言手算依据:containerWidth 320、gap 8、双列 → columnWidth = floor(312/2) = 156。
|
||||
|
||||
describe('layoutWaterfall (pure placement)', () => {
|
||||
@@ -46,10 +49,24 @@ describe('layoutWaterfall (pure placement)', () => {
|
||||
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('falls back to the 4:3 safe ratio for degenerate dimensions (no NaN/infinite heights)', () => {
|
||||
const layout = layoutWaterfall(
|
||||
[
|
||||
{ width: 0, height: 500 },
|
||||
{ width: 400, height: 0 },
|
||||
{ width: Number.NaN, height: 100 },
|
||||
{ width: 1, height: Number.POSITIVE_INFINITY },
|
||||
],
|
||||
320,
|
||||
);
|
||||
// 4:3 安全比例:round(156 * 3/4) = 117(原为正方形 156,UI-02 §4.3 改版)。
|
||||
expect(layout.placements.map((p) => p.height)).toEqual([117, 117, 117, 117]);
|
||||
expect(
|
||||
layout.placements.every(
|
||||
(p) => Number.isFinite(p.top) && Number.isFinite(p.left) && Number.isFinite(p.height),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(Number.isFinite(layout.height)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an empty layout for no items and tolerates zero container width', () => {
|
||||
@@ -63,6 +80,65 @@ describe('layoutWaterfall (pure placement)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('responsiveColumns (UI-02 Task 3 breakpoints)', () => {
|
||||
it('maps container width to 2/3/4 columns with 4/6/8px gaps', () => {
|
||||
// <600px 手机双列 gap 4。
|
||||
expect(responsiveColumns(350)).toEqual({ columns: 2, gap: 4 });
|
||||
expect(responsiveColumns(599)).toEqual({ columns: 2, gap: 4 });
|
||||
// 600-959px 平板三列 gap 6。
|
||||
expect(responsiveColumns(600)).toEqual({ columns: 3, gap: 6 });
|
||||
expect(responsiveColumns(959)).toEqual({ columns: 3, gap: 6 });
|
||||
// ≥960px 桌面四列 gap 8(主内容限宽 1120px 由页面层负责)。
|
||||
expect(responsiveColumns(960)).toEqual({ columns: 4, gap: 8 });
|
||||
expect(responsiveColumns(1120)).toEqual({ columns: 4, gap: 8 });
|
||||
expect(responsiveColumns(1200)).toEqual({ columns: 4, gap: 8 });
|
||||
});
|
||||
|
||||
it('drives layoutWaterfall column geometry when passed as options', () => {
|
||||
const layout = layoutWaterfall(
|
||||
[
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
],
|
||||
700,
|
||||
responsiveColumns(700),
|
||||
);
|
||||
// 700 → 三列 gap 6:columnWidth = floor(688/3) = 229;前三条各占一列,
|
||||
// 第四条三列同高(235)取索引小者 col0(top 235)。
|
||||
expect(layout.columnWidth).toBe(229);
|
||||
expect(layout.placements.map((p) => p.left)).toEqual([0, 235, 470, 0]);
|
||||
expect(layout.placements.map((p) => p.top)).toEqual([0, 0, 0, 235]);
|
||||
// col0 累计 229+6+229+6=470(末尾间距只在条目间生效),容器高 → 464。
|
||||
expect(layout.height).toBe(464);
|
||||
});
|
||||
});
|
||||
|
||||
describe('skeletonSources (first-load waterfall placeholder)', () => {
|
||||
it('cycles varied aspects so the skeleton silhouette is not a uniform grid', () => {
|
||||
const sources = skeletonSources();
|
||||
expect(sources).toHaveLength(12);
|
||||
|
||||
const layout = layoutWaterfall(sources, 320, responsiveColumns(320));
|
||||
const heights = layout.placements.map((p) => p.height);
|
||||
// 320 → 双列 gap 4:columnWidth = floor(316/2) = 158。
|
||||
// 比例序列 [3:4, 4:3, 1:1, 3:2, 4:5, 2:3, ...] → 高度 211/119/158/105/198/237…
|
||||
expect(heights[0]).toBe(Math.round(158 * (4 / 3)));
|
||||
expect(heights[1]).toBe(Math.round(158 * (3 / 4)));
|
||||
expect(new Set(heights).size).toBeGreaterThan(1);
|
||||
// 放置仍走最矮列规则(骨架与真实瀑布流同一轮廓)。
|
||||
expect(layout.placements.every((p) => p.column === 0 || p.column === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('supports a custom count and repeats the aspect cycle beyond its length', () => {
|
||||
expect(skeletonSources(3)).toHaveLength(3);
|
||||
const extended = skeletonSources(15);
|
||||
expect(extended).toHaveLength(15);
|
||||
expect(extended[12]).toEqual(skeletonSources()[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeUniqueByPhotoId (page append dedupe)', () => {
|
||||
it('appends only unseen photoIds and preserves order', () => {
|
||||
const merged = mergeUniqueByPhotoId(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { computed, defineComponent } from 'vue';
|
||||
import { computed, defineComponent, nextTick } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ApiError,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type PublicPhotoList,
|
||||
} from '../api/client';
|
||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
import type { LikesController } from '../likes/useLikes';
|
||||
import PhotoFeed from '../components/PhotoFeed.vue';
|
||||
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
|
||||
|
||||
@@ -113,8 +114,11 @@ function makeList(items: PublicPhoto[], nextCursor: string | null = null): Publi
|
||||
let i18n: ViewerI18n;
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
/** 宿主:创建 sequence 并渲染 PhotoFeed(镜像 ActivityPage 组装);捕获 open 事件。 */
|
||||
function mountFeed(props: { token?: string; locale?: string; category?: string | null } = {}): {
|
||||
/** 宿主:创建 sequence 并渲染 PhotoFeed(镜像 ActivityPage 组装);捕获 open 事件。
|
||||
* likes 可注入替身控制器(组件级点赞角标测试,不拉真实批次端点)。 */
|
||||
function mountFeed(
|
||||
props: { token?: string; locale?: string; category?: string | null; likes?: LikesController } = {},
|
||||
): {
|
||||
wrapper: VueWrapper;
|
||||
sequence: PhotoSequence;
|
||||
opened: string[];
|
||||
@@ -128,9 +132,13 @@ function mountFeed(props: { token?: string; locale?: string; category?: string |
|
||||
locale: computed(() => props.locale ?? 'zh-CN'),
|
||||
category: computed(() => props.category ?? null),
|
||||
});
|
||||
return { sequence, onOpen: (photoId: string) => opened.push(photoId) };
|
||||
return {
|
||||
sequence,
|
||||
likes: props.likes ?? undefined,
|
||||
onOpen: (photoId: string) => opened.push(photoId),
|
||||
};
|
||||
},
|
||||
template: `<PhotoFeed :sequence="sequence" @open="onOpen" />`,
|
||||
template: `<PhotoFeed :sequence="sequence" :likes="likes" @open="onOpen" />`,
|
||||
});
|
||||
const wrapper = mount(Host, { global: { plugins: [i18n] } });
|
||||
mounted.push(wrapper);
|
||||
@@ -145,7 +153,8 @@ describe('photo feed (Task 7 / Task 8 sequence props)', () => {
|
||||
FakeIntersectionObserver.instances = [];
|
||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver);
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
|
||||
// jsdom 无布局:clientWidth 恒 0 → 原型 getter mock 为 700(columnWidth=346)。
|
||||
// jsdom 无布局:clientWidth 恒 0 → 原型 getter mock 为 700。UI-02 Task 3 起
|
||||
// 响应式列数:700 落在 600-959 段 → 三列 gap 6(columnWidth = floor(688/3) = 229)。
|
||||
const proto = Object.prototype.hasOwnProperty.call(HTMLElement.prototype, 'clientWidth')
|
||||
? HTMLElement.prototype
|
||||
: Element.prototype;
|
||||
@@ -169,11 +178,12 @@ describe('photo feed (Task 7 / Task 8 sequence props)', () => {
|
||||
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');
|
||||
// 700 容器宽 → 三列 gap 6 → columnWidth 229;800x500 比例 → 条高 round(229*.625)=143。
|
||||
// 3 条各占一列(left 0/235/470,top 全 0)→ 容器高 143。
|
||||
expect(wrapper.find('.waterfall').attributes('style')).toContain('height: 143px');
|
||||
expect(cells[0].attributes('style')).toContain('left: 0px');
|
||||
expect(cells[1].attributes('style')).toContain('left: 235px');
|
||||
expect(cells[2].attributes('style')).toContain('left: 470px');
|
||||
});
|
||||
|
||||
it('emits open with the photoId when a cell is clicked', async () => {
|
||||
@@ -270,4 +280,99 @@ describe('photo feed (Task 7 / Task 8 sequence props)', () => {
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(3);
|
||||
expect(mockedGetPublicPhoto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// —— UI-02 Task 3:骨架占位 / 点赞角标 / 新照片提示 pill ——
|
||||
|
||||
/** 从内联 style 提取 height("left: 0px; top: 0px; width: 229px; height: 305px")。 */
|
||||
function styleHeight(el: { attributes: (name: string) => unknown }): number {
|
||||
const match = /height: (\d+)px/.exec(String(el.attributes('style') ?? ''));
|
||||
if (match === null) throw new Error('height not found in style');
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
it('shows a waterfall-shaped skeleton while the first page loads, then swaps in cells', async () => {
|
||||
let resolvePage: (list: PublicPhotoList) => void = () => {};
|
||||
mockedGetPublicPhotos.mockReturnValueOnce(
|
||||
new Promise<PublicPhotoList>((resolve) => {
|
||||
resolvePage = resolve;
|
||||
}),
|
||||
);
|
||||
const { wrapper } = mountFeed();
|
||||
// 等挂载后的 post watch 完成容器测量(clientWidth mock 700)再断言几何。
|
||||
await flushPromises();
|
||||
|
||||
// 骨架:占位盒按 skeletonSources 比例放置(700 → 三列 gap 6 → columnWidth 229:
|
||||
// 3:4 → 305、4:3 → 172…),不是均匀网格;读屏另有隐藏 loading 文案。
|
||||
expect(wrapper.find('.waterfall.skeleton').exists()).toBe(true);
|
||||
expect(wrapper.find('.sr-only[role="status"]').text()).toBe('加载中…');
|
||||
const boxes = wrapper.findAll('.cell-skeleton');
|
||||
expect(boxes).toHaveLength(12);
|
||||
expect(styleHeight(boxes[0])).toBe(Math.round(229 * (4 / 3)));
|
||||
expect(styleHeight(boxes[1])).toBe(Math.round(229 * (3 / 4)));
|
||||
expect(new Set(boxes.map((box) => styleHeight(box))).size).toBeGreaterThan(1);
|
||||
expect(wrapper.find('.cell').exists()).toBe(false);
|
||||
|
||||
resolvePage(makeList([makePhoto('p1')], null));
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.waterfall.skeleton').exists()).toBe(false);
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders the like badge overlay with the real count and no card chrome', async () => {
|
||||
const likes = {
|
||||
displayFor: (photo: PublicPhoto) => photo.likeCount,
|
||||
popNonce: () => 0,
|
||||
like: vi.fn(),
|
||||
} as unknown as LikesController;
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(
|
||||
makeList([{ ...makePhoto('p1'), likeCount: 1286 }, makePhoto('p2')], null),
|
||||
);
|
||||
const { wrapper } = mountFeed({ likes });
|
||||
await flushPromises();
|
||||
|
||||
const badge = wrapper.find('.cell .cell-like');
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.find('.heart').exists()).toBe(true);
|
||||
// 真实计数(useLikes displayFor 值)经 Intl 分组。
|
||||
expect(badge.find('.count').text()).toBe('1,286');
|
||||
// 无白色卡片包裹/无常驻文件名/无额外标题:单元格可见文本只有点赞计数
|
||||
//(图片 alt 非可见文本,懒加载绑定不影响该断言)。
|
||||
expect(wrapper.findAll('.cell')[0].text()).toBe('1,286');
|
||||
});
|
||||
|
||||
it('clicking the like badge likes the photo without opening the lightbox', async () => {
|
||||
const like = vi.fn();
|
||||
const likes = { displayFor: () => 0, popNonce: () => 0, like } as unknown as LikesController;
|
||||
const { wrapper, opened } = mountFeed({ likes });
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('.cell-like').trigger('click');
|
||||
expect(like).toHaveBeenCalledWith('p1');
|
||||
expect(opened).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows the new-photo pill on hasNew; clicking refreshes without scrolling to top', async () => {
|
||||
const { wrapper, sequence } = mountFeed();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.new-photo-bar').exists()).toBe(false);
|
||||
|
||||
sequence.hasNew = true;
|
||||
await nextTick();
|
||||
const pill = wrapper.find('.new-photo-bar');
|
||||
expect(pill.exists()).toBe(true);
|
||||
// 接口只有 hasNew 布尔 → 文案不伪造数量(§4.3)。
|
||||
expect(pill.text()).toBe('有新照片');
|
||||
|
||||
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(
|
||||
makeList([makePhoto('p9'), makePhoto('p1'), makePhoto('p2'), makePhoto('p3')], 'cur-1'),
|
||||
);
|
||||
await pill.trigger('click');
|
||||
await flushPromises();
|
||||
expect(sequence.hasNew).toBe(false);
|
||||
expect(wrapper.find('.new-photo-bar').exists()).toBe(false);
|
||||
expect(sequence.photos.map((photo) => photo.photoId)).toEqual(['p9', 'p1', 'p2', 'p3']);
|
||||
// 不强制回顶(UI-02 §4.3:用户浏览位置保持)。
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,7 +250,7 @@ describe('photo sequence polling (Task 8)', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('raises the hint when page 1 contains an unknown id; refresh scrolls top', async () => {
|
||||
it('raises the hint when page 1 contains an unknown id; refresh merges without scrolling top', async () => {
|
||||
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||
const { wrapper, sequence } = mountSequence();
|
||||
await flushPromises();
|
||||
@@ -267,7 +267,8 @@ describe('photo sequence polling (Task 8)', () => {
|
||||
await flushPromises();
|
||||
expect(sequence.hasNew).toBe(false);
|
||||
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p9', 'p1', 'p2']);
|
||||
expect(scrollTo).toHaveBeenCalledWith(0, 0);
|
||||
// UI-02 §4.3:提示点击只加载合并,不强制回顶(用户浏览位置保持)。
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user