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:
@@ -48,9 +48,9 @@ test('renders album info in zh with timezone-adjusted time, chips and empty stat
|
||||
await expect(page.locator('.meta')).toContainText('大宴会厅 · 上海市浦东新区世纪大道 100 号');
|
||||
|
||||
const chips = page.locator('.chip');
|
||||
await expect(chips).toHaveCount(2);
|
||||
await expect(chips.first()).toBeDisabled();
|
||||
await expect(chips).toHaveText(['签到墙', '开场']);
|
||||
await expect(chips).toHaveCount(3); // Task 7 起:全部 + 各分类(可点选)
|
||||
await expect(chips.first()).toBeEnabled();
|
||||
await expect(chips).toHaveText(['全部', '签到墙', '开场']);
|
||||
|
||||
await expect(page.locator('.empty-notice')).toHaveText('照片即将上线');
|
||||
// 该相册无 banner 资产 → 不渲染图。
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { cleanupSeededAlbums, seedAlbum, seedCategory, seedPhoto } from './helpers';
|
||||
|
||||
// Task 7 瀑布流 Playwright(真实后端):35 张已发布照片(公开列表默认页 30)→
|
||||
// 初始 30 + 滚动取第 2 页 5 张;分类 chips 过滤;新照片轮询提示(__viewerPollMs
|
||||
// 注入缩短间隔,见 PhotoFeed pollIntervalMs 的 dev/test 覆盖钩子)。
|
||||
|
||||
test.afterAll(async () => {
|
||||
await cleanupSeededAlbums();
|
||||
});
|
||||
|
||||
/** 5 种宽高比例轮换(瀑布流高度预留的真实形状输入)。 */
|
||||
const RATIOS: Array<{ width: number; height: number }> = [
|
||||
{ width: 480, height: 300 },
|
||||
{ width: 300, height: 480 },
|
||||
{ width: 400, height: 400 },
|
||||
{ width: 360, height: 480 },
|
||||
{ width: 480, height: 270 },
|
||||
];
|
||||
|
||||
const TOTAL = 35; // 30(第 1 页)+ 5(第 2 页)
|
||||
|
||||
/** 种「瀑布流相册」:2 分类 + 35 张已发布照片(前 20 张 catA、后 15 张 catB;
|
||||
* publishedAt 逐张递减 60s 保证列表顺序确定)。 */
|
||||
async function seedFeedAlbum(alias: string): Promise<{ albumId: string; token: string }> {
|
||||
const album = await seedAlbum({
|
||||
alias,
|
||||
translations: [{ locale: 'zh-CN', title: '瀑布流测试相册' }],
|
||||
});
|
||||
const catA = await seedCategory(album.id, 0, [{ locale: 'zh-CN', name: '签到墙' }]);
|
||||
const catB = await seedCategory(album.id, 1, [{ locale: 'zh-CN', name: '开场' }]);
|
||||
const base = Date.now();
|
||||
for (let i = 0; i < TOTAL; i++) {
|
||||
const ratio = RATIOS[i % RATIOS.length]!;
|
||||
await seedPhoto(album.id, `${alias}-p${i}`, {
|
||||
categoryId: i < 20 ? catA : catB,
|
||||
width: ratio.width,
|
||||
height: ratio.height,
|
||||
publishedAt: new Date(base - (i + 1) * 60_000),
|
||||
});
|
||||
}
|
||||
return { albumId: album.id, token: album.publicToken };
|
||||
}
|
||||
|
||||
/** 反复滚到底直至游标耗尽(end-note 出现)或循环上限。 */
|
||||
async function scrollToLoadAll(page: Page): Promise<void> {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
if ((await page.locator('.end-note').count()) > 0) return;
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
}
|
||||
|
||||
test('scroll loads both pages until exhausted and thumbs decode', async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const { token } = await seedFeedAlbum('scroll');
|
||||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||||
await page.goto(`/a/${token}`);
|
||||
|
||||
// 第 1 页 30 张 + 哨兵在位(hasMore)。
|
||||
await expect(page.locator('.cell')).toHaveCount(30);
|
||||
await expect(page.locator('.sentinel')).toHaveCount(1);
|
||||
|
||||
// 懒加载:首屏缩略图经签名 URL 取回真实字节(解码成功 naturalWidth > 0)。
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.locator('img.cell-img').first().evaluate((img) => (img as HTMLImageElement).naturalWidth),
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await scrollToLoadAll(page);
|
||||
|
||||
// 第 2 页 5 张追加,游标耗尽 → 哨兵卸载、底部提示出现。
|
||||
await expect(page.locator('.cell')).toHaveCount(35);
|
||||
await expect(page.locator('.end-note')).toHaveText('已经到底了');
|
||||
await expect(page.locator('.sentinel')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('category chips filter the feed; All restores pagination', async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const { token } = await seedFeedAlbum('filter');
|
||||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||||
await page.goto(`/a/${token}`);
|
||||
await expect(page.locator('.cell')).toHaveCount(30);
|
||||
|
||||
await page.getByRole('button', { name: '签到墙' }).click();
|
||||
await expect(page.locator('.cell')).toHaveCount(20);
|
||||
await expect(page.locator('.end-note')).toBeVisible(); // 20 ≤ 30 单页即尽
|
||||
|
||||
await page.getByRole('button', { name: '开场' }).click();
|
||||
await expect(page.locator('.cell')).toHaveCount(15);
|
||||
|
||||
await page.getByRole('button', { name: '全部' }).click();
|
||||
await expect(page.locator('.cell')).toHaveCount(30);
|
||||
await scrollToLoadAll(page);
|
||||
await expect(page.locator('.cell')).toHaveCount(35);
|
||||
});
|
||||
|
||||
test('new-photo poll hint appears; click refreshes to include new photos', async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const { albumId, token } = await seedFeedAlbum('poll');
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('viewer.locale', 'zh-CN');
|
||||
(window as unknown as { __viewerPollMs?: number }).__viewerPollMs = 500;
|
||||
});
|
||||
await page.goto(`/a/${token}`);
|
||||
await expect(page.locator('.cell')).toHaveCount(30);
|
||||
await scrollToLoadAll(page);
|
||||
await expect(page.locator('.cell')).toHaveCount(35);
|
||||
await expect(page.locator('.new-photo-bar')).toHaveCount(0);
|
||||
|
||||
// 轮询期间直种 2 张更新的照片(publishedAt 晚于全部既有)。
|
||||
const base = Date.now();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await seedPhoto(albumId, `poll-new-${i}`, {
|
||||
categoryId: null,
|
||||
width: 480,
|
||||
height: 300,
|
||||
publishedAt: new Date(base + (i + 1) * 60_000),
|
||||
});
|
||||
}
|
||||
|
||||
// 提示条出现(不自动插入——仍 35 张)。
|
||||
await expect(page.locator('.new-photo-bar')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('.cell')).toHaveCount(35);
|
||||
|
||||
// 点击 → 整表刷新回第 1 页(含新照片),滚动后 37 张。
|
||||
await page.locator('.new-photo-bar').click();
|
||||
await expect(page.locator('.new-photo-bar')).toHaveCount(0);
|
||||
await scrollToLoadAll(page);
|
||||
await expect(page.locator('.cell')).toHaveCount(37);
|
||||
});
|
||||
+102
-1
@@ -1,10 +1,13 @@
|
||||
// apps/viewer/e2e/helpers.ts —— Playwright 夹具:真实后端直种(决策见计划 Task 6
|
||||
// Step 2:真实 API + compose 基础设施,fixture 走 Prisma/Storage 直种,镜像
|
||||
// services/api/test/public.e2e-spec.ts 的种子形状)。公开端点匿名可达,无需登录态。
|
||||
// Task 7 起补 seedPhoto:直种「已发布完成态」照片(photos + thumb/browse 变体行
|
||||
// + MinIO 真实 JPEG 对象,行形状同 worker 发布事务落库形态)。
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { config } from 'dotenv';
|
||||
import sharp from 'sharp';
|
||||
import { PrismaClient } from '@photo/db-cn';
|
||||
import { createStorageFromEnv, type MinioStorageAdapter } from '@photo/storage';
|
||||
|
||||
@@ -108,13 +111,111 @@ export async function seedBannerAsset(albumId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** 测试内生成真实 JPEG(sharp,同 services/api test 约定;尺寸小控制体积/耗时)。 */
|
||||
async function makeJpeg(width: number, height: number): Promise<Buffer> {
|
||||
return sharp({
|
||||
create: { width, height, channels: 3, background: { r: 96, g: 130, b: 182 } },
|
||||
})
|
||||
.jpeg({ quality: 70 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/** cuid 形状 ID(对象键含 photoId,须先有 ID 再一次 insert 成形,同 api 约定)。 */
|
||||
function cuidLike(alias: string): string {
|
||||
return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`;
|
||||
}
|
||||
|
||||
/** 与 worker publish.service variantsObjectKey 同布局的变体键。 */
|
||||
function variantKey(albumId: string, photoId: string, variant: 'thumb' | 'browse'): string {
|
||||
return `${KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/${variant}.jpg`;
|
||||
}
|
||||
|
||||
export interface SeedPhotoOptions {
|
||||
categoryId?: string | null;
|
||||
/** browse 宽高(驱动列表占位与瀑布流比例;thumb 减半生成)。 */
|
||||
width?: number;
|
||||
height?: number;
|
||||
publishedAt: Date;
|
||||
}
|
||||
|
||||
/** 直种已发布照片:photos 行(ready/published,entityVersion=2 对齐发布事务)+
|
||||
* thumb/browse 变体行 + MinIO 真实对象。publishedAt 必传(列表排序确定性)。 */
|
||||
export async function seedPhoto(
|
||||
albumId: string,
|
||||
alias: string,
|
||||
options: SeedPhotoOptions,
|
||||
): Promise<string> {
|
||||
const photoId = cuidLike(alias);
|
||||
const width = options.width ?? 480;
|
||||
const height = options.height ?? 300;
|
||||
const thumbWidth = Math.max(1, Math.round(width / 2));
|
||||
const thumbHeight = Math.max(1, Math.round(height / 2));
|
||||
const [thumb, browse] = await Promise.all([
|
||||
makeJpeg(thumbWidth, thumbHeight),
|
||||
makeJpeg(width, height),
|
||||
]);
|
||||
await storage.put(variantKey(albumId, photoId, 'thumb'), thumb, 'image/jpeg');
|
||||
await storage.put(variantKey(albumId, photoId, 'browse'), browse, 'image/jpeg');
|
||||
await prisma.photos.create({
|
||||
data: {
|
||||
id: photoId,
|
||||
albumId,
|
||||
uploaderId: `u-viewer-e2e-${alias}`,
|
||||
categoryId: options.categoryId ?? null,
|
||||
objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg`,
|
||||
bytes: browse.length,
|
||||
width,
|
||||
height,
|
||||
uploadedAt: options.publishedAt,
|
||||
publishedAt: options.publishedAt,
|
||||
processingState: 'ready',
|
||||
visibility: 'published',
|
||||
entityVersion: 2,
|
||||
},
|
||||
});
|
||||
await prisma.photo_variants.createMany({
|
||||
data: [
|
||||
{
|
||||
photoId,
|
||||
variant: 'thumb',
|
||||
objectKey: variantKey(albumId, photoId, 'thumb'),
|
||||
bytes: thumb.length,
|
||||
version: 1,
|
||||
width: thumbWidth,
|
||||
height: thumbHeight,
|
||||
},
|
||||
{
|
||||
photoId,
|
||||
variant: 'browse',
|
||||
objectKey: variantKey(albumId, photoId, 'browse'),
|
||||
bytes: browse.length,
|
||||
version: 1,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
],
|
||||
});
|
||||
return photoId;
|
||||
}
|
||||
|
||||
/** 清理本文件种下的相册域行(扁平字符串外键、无 DB 级联——按子表在前手工删;
|
||||
* category_translations 只挂 categoryId,先取分类 id 再删翻译)。
|
||||
* category_translations 只挂 categoryId,先取分类 id 再删翻译;Task 7 起照片域
|
||||
* 同理:先按相册取照片 id 删变体行,再删 photos 主行)。
|
||||
* MinIO 对象不在此删:services/api e2e 的 reset 会整体清 `photo-live/dev` 前缀,
|
||||
* dev 桶内残余有界(同 api e2e 约定)。 */
|
||||
export async function cleanupSeededAlbums(): Promise<void> {
|
||||
try {
|
||||
if (seededAlbumIds.length > 0) {
|
||||
const photos = await prisma.photos.findMany({
|
||||
where: { albumId: { in: seededAlbumIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (photos.length > 0) {
|
||||
await prisma.photo_variants.deleteMany({
|
||||
where: { photoId: { in: photos.map((p) => p.id) } },
|
||||
});
|
||||
}
|
||||
await prisma.photos.deleteMany({ where: { albumId: { in: seededAlbumIds } } });
|
||||
const categories = await prisma.categories.findMany({
|
||||
where: { albumId: { in: seededAlbumIds } },
|
||||
select: { id: true },
|
||||
|
||||
Reference in New Issue
Block a user