docs: UI-02 acceptance record and visual verification
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -50,12 +50,13 @@ test('renders album info in zh with timezone-adjusted time, chips and empty stat
|
||||
await expect(time).not.toContainText('9月30');
|
||||
await expect(page.locator('.meta')).toContainText('大宴会厅 · 上海市浦东新区世纪大道 100 号');
|
||||
|
||||
// Task 10 起排序 chips(最新/热门)与分类 chips 并存——断言收窄到分类栏。
|
||||
// Task 10 起排序(最新/热门)与分类 chips 并存——UI-02 V02 起排序为标签页
|
||||
// (.sort-tabs .sort-tab),断言收窄到分类栏。
|
||||
const chips = page.locator('.categories .chip');
|
||||
await expect(chips).toHaveCount(3); // Task 7 起:全部 + 各分类(可点选)
|
||||
await expect(chips.first()).toBeEnabled();
|
||||
await expect(chips).toHaveText(['全部', '签到墙', '开场']);
|
||||
await expect(page.locator('.sort .chip')).toHaveText(['最新', '热门']);
|
||||
await expect(page.locator('.sort-tabs .sort-tab')).toHaveText(['最新', '热门']);
|
||||
|
||||
await expect(page.locator('.empty-notice')).toHaveText('照片即将上线');
|
||||
// 该相册无 banner 资产 → 不渲染图。
|
||||
@@ -77,7 +78,9 @@ test('switching locale re-fetches and shows the English translation', async ({ p
|
||||
await page.goto(`/a/${album.publicToken}`);
|
||||
await expect(page.locator('h1.title')).toHaveText('双语测试相册');
|
||||
|
||||
await page.locator('button.locale-switcher').click();
|
||||
// UI-02 V01 紧凑语言控件:展开菜单后选择 English(两步)。
|
||||
await page.locator('.locale-trigger').click();
|
||||
await page.locator('.locale-option', { hasText: 'English' }).click();
|
||||
await expect(page.locator('h1.title')).toHaveText('Bilingual Test Album');
|
||||
await expect(page.locator('.description')).toHaveText('English intro');
|
||||
await expect(page.locator('.meta')).toContainText('Main Hall');
|
||||
|
||||
@@ -39,7 +39,7 @@ async function openLightbox(page: Page, token: string, init?: () => void): Promi
|
||||
await page.goto(`/a/${token}`);
|
||||
await expect(page.locator('.cell').first()).toBeVisible();
|
||||
await page.locator('.cell').first().click();
|
||||
await expect(page.locator('.lightbox')).toBeVisible();
|
||||
await expect(page.locator('.photo-viewer')).toBeVisible();
|
||||
}
|
||||
|
||||
test.afterAll(async () => {
|
||||
@@ -56,18 +56,18 @@ test('download entry visibility follows album two-tier switches', async ({ page
|
||||
await openLightbox(page, offOff);
|
||||
await expect(page.locator('.download-group')).toHaveCount(0);
|
||||
|
||||
// on/off:单按钮(无 caret/菜单)。
|
||||
// on/off:单按钮(无 caret/菜单)。UI-02 V05:下载位在底部操作栏(.download-main)。
|
||||
const onOff = await seedDownloadAlbum('dl-onoff', {});
|
||||
await openLightbox(page, onOff);
|
||||
await expect(page.locator('.control.download')).toBeVisible();
|
||||
await expect(page.locator('.control.download-caret')).toHaveCount(0);
|
||||
await expect(page.locator('.download-main')).toBeVisible();
|
||||
await expect(page.locator('.download-caret')).toHaveCount(0);
|
||||
|
||||
// on/on:分体按钮,菜单两选项(下载图片 / 下载原图)。
|
||||
// on/on:分体按钮,菜单两选项(浏览图 / 原图)。
|
||||
const onOn = await seedDownloadAlbum('dl-onon', { originalDownloadEnabled: true });
|
||||
await openLightbox(page, onOn);
|
||||
await expect(page.locator('.control.download-caret')).toBeVisible();
|
||||
await page.locator('.control.download-caret').click();
|
||||
await expect(page.locator('.download-menu button')).toHaveText(['下载图片', '下载原图']);
|
||||
await expect(page.locator('.download-caret')).toBeVisible();
|
||||
await page.locator('.download-caret').click();
|
||||
await expect(page.locator('.download-menu button')).toHaveText(['浏览图', '原图']);
|
||||
});
|
||||
|
||||
test('WeChat webview UA shows guidance overlay instead of downloading', async ({ page }) => {
|
||||
@@ -85,7 +85,7 @@ test('WeChat webview UA shows guidance overlay instead of downloading', async ({
|
||||
if (request.url().includes('/download?')) endpointHit = true;
|
||||
});
|
||||
|
||||
await page.locator('.control.download').click();
|
||||
await page.locator('.download-main').click();
|
||||
await expect(page.locator('.wechat-guide[role="dialog"]')).toBeVisible();
|
||||
await expect(page.locator('.wechat-guide h2')).toHaveText('保存图片指引');
|
||||
await expect(page.locator('.guide-copy')).toBeVisible();
|
||||
@@ -112,7 +112,7 @@ test('real click downloads browse via signed url from the endpoint', async ({ pa
|
||||
(response) =>
|
||||
response.url().includes('/download?variant=browse') && response.status() === 200,
|
||||
);
|
||||
await page.locator('.control.download').click();
|
||||
await page.locator('.download-main').click();
|
||||
const response = await endpoint;
|
||||
expect((await response.json()).variant).toBe('browse');
|
||||
|
||||
|
||||
@@ -384,9 +384,11 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
|
||||
await expect(page.locator('h1.title')).toHaveText(zhTitle);
|
||||
await expect(page.locator('.categories .chip')).toHaveCount(2); // 全部 + 主舞台
|
||||
|
||||
await page.locator('button.locale-switcher').click();
|
||||
await page.locator('.locale-trigger').click();
|
||||
await page.locator('.locale-option', { hasText: 'English' }).click();
|
||||
await expect(page.locator('h1.title')).toHaveText(enTitle);
|
||||
await page.locator('button.locale-switcher').click(); // 切回 zh,后续断言用中文
|
||||
await page.locator('.locale-trigger').click(); // 切回 zh,后续断言用中文
|
||||
await page.locator('.locale-option', { hasText: '中文' }).click();
|
||||
await expect(page.locator('h1.title')).toHaveText(zhTitle);
|
||||
|
||||
// 瀑布流 3 张 → 分类 chip 过滤 1 张 → 全部恢复 3 张。
|
||||
@@ -397,18 +399,18 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
|
||||
await expect(page.locator('.cell')).toHaveCount(3);
|
||||
|
||||
// Task 10 互动化:浏览计数行(首访上报后权威回填)+ 排序 chips 在位。
|
||||
await expect(page.locator('.view-count')).toHaveText('1', { timeout: 15_000 });
|
||||
await expect(page.locator('.sort .chip')).toHaveText(['最新', '热门']);
|
||||
await expect(page.locator('.view-count')).toHaveText('1 次浏览', { timeout: 15_000 });
|
||||
await expect(page.locator('.sort-tabs .sort-tab')).toHaveText(['最新', '热门']);
|
||||
|
||||
// 大图:点击开图 → 真实字节 → 滚轮缩放 → 双击复位 → 关闭回列表。
|
||||
const lightboxPhoto = photoIds[1]!; // 800×500(后续 admin 腿按尺寸锚定隐藏)
|
||||
await page.locator('.cell').nth(1).click();
|
||||
await expect(page.locator('.lightbox')).toBeVisible();
|
||||
await expect(page.locator('.photo-viewer')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('img.photo'),
|
||||
).toHaveAttribute('src', new RegExp(lightboxPhoto));
|
||||
// Task 10:大图内真实点赞 1 次 → 防抖冲批 → 权威计数 1。
|
||||
await page.locator('.control.like-button').click();
|
||||
await page.locator('.action-bar .like-slot .action-btn').click();
|
||||
await expect(page.locator('.like-count')).toHaveText('1');
|
||||
await expect
|
||||
.poll(async () =>
|
||||
@@ -416,7 +418,7 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
expect(page.url()).toContain(`/a/${publicToken}/p/${lightboxPhoto}`);
|
||||
const box = await page.locator('.lightbox').boundingBox();
|
||||
const box = await page.locator('.photo-viewer').boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
||||
await page.mouse.wheel(0, -360);
|
||||
@@ -435,12 +437,12 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
|
||||
.evaluate((img) => new DOMMatrixReadOnly(getComputedStyle(img).transform).a),
|
||||
)
|
||||
.toBeCloseTo(1, 1);
|
||||
await page.locator('.control.close').click();
|
||||
await expect(page.locator('.lightbox')).toHaveCount(0);
|
||||
await page.locator('.viewer-close').click();
|
||||
await expect(page.locator('.photo-viewer')).toHaveCount(0);
|
||||
|
||||
// 深链直达:/a/{token}/p/{photoId} 直接开在大图上。
|
||||
await page.goto(`/a/${publicToken}/p/${lightboxPhoto}`);
|
||||
await expect(page.locator('.lightbox')).toBeVisible();
|
||||
await expect(page.locator('.photo-viewer')).toBeVisible();
|
||||
await expect(page.locator('img.photo')).toHaveAttribute(
|
||||
'src',
|
||||
new RegExp(lightboxPhoto),
|
||||
@@ -473,7 +475,7 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
|
||||
const notice = page.locator('.deep-link-notice');
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(notice).toContainText('照片不存在或已下线');
|
||||
await expect(page.locator('.lightbox')).toHaveCount(0);
|
||||
await expect(page.locator('.photo-viewer')).toHaveCount(0);
|
||||
|
||||
// ── ⑤ 空相册 B:viewer 空态提示(B 经 API 建于 cn,先同步到 ov)──
|
||||
await flushSync(emptyAlbumId);
|
||||
|
||||
@@ -74,9 +74,9 @@ test('lightbox likes accumulate to 3 and persist across reload', async ({ page }
|
||||
await openAlbum(page, token);
|
||||
|
||||
await page.locator('.cell').first().click();
|
||||
await expect(page.locator('.lightbox')).toBeVisible();
|
||||
await expect(page.locator('.photo-viewer')).toBeVisible();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.locator('.control.like-button').click();
|
||||
await page.locator('.action-bar .like-slot .action-btn').click();
|
||||
}
|
||||
// 防抖 500ms 冲批 → 服务端权威校准 3(响应/WS burst 先后到达均为 3)。
|
||||
await expect(page.locator('.like-count')).toHaveText('3');
|
||||
@@ -92,10 +92,10 @@ test('lightbox likes accumulate to 3 and persist across reload', async ({ page }
|
||||
// 刷新后从服务端列表回归:深链自动重开大图(URL 仍停在该照片),计数 3;
|
||||
// 关闭后格子计数亦 3(sessionId 会话内持久,幂等面不变)。
|
||||
await page.reload();
|
||||
await expect(page.locator('.lightbox')).toBeVisible();
|
||||
await expect(page.locator('.photo-viewer')).toBeVisible();
|
||||
await expect(page.locator('.like-count')).toHaveText('3');
|
||||
await page.locator('.control.close').click();
|
||||
await expect(page.locator('.lightbox')).toHaveCount(0);
|
||||
await page.locator('.viewer-close').click();
|
||||
await expect(page.locator('.photo-viewer')).toHaveCount(0);
|
||||
await expect(page.locator('.cell-like .count').first()).toHaveText('3');
|
||||
});
|
||||
|
||||
@@ -124,7 +124,7 @@ test('others burst bubbles on my page; my own like does not', async ({ browser,
|
||||
await expect(page.locator('.like-bubble .bubble-delta')).toHaveText('+1');
|
||||
await expect(page.locator('.cell-like .count').first()).toHaveText('2');
|
||||
await page.locator('.like-bubble').click();
|
||||
await expect(page.locator('.lightbox')).toBeVisible();
|
||||
await expect(page.locator('.photo-viewer')).toBeVisible();
|
||||
} finally {
|
||||
await ctx2.close();
|
||||
}
|
||||
@@ -173,7 +173,7 @@ test('hidden photo disappears from the feed and closes the lightbox via WS push'
|
||||
|
||||
// 大图停在第 2 张:下线推送应回退邻居并收口(closeLightbox 路径)。
|
||||
await page.locator('.cell').nth(1).click();
|
||||
await expect(page.locator('.lightbox')).toBeVisible();
|
||||
await expect(page.locator('.photo-viewer')).toBeVisible();
|
||||
|
||||
await deleteSeededPhoto(photoIds[1]!);
|
||||
await flushSync(albumId); // photo.deleted 走真实管线 → ov 撤下 + WS 房间广播
|
||||
@@ -181,7 +181,7 @@ test('hidden photo disappears from the feed and closes the lightbox via WS push'
|
||||
// 推送到达:列表无刷新即时 2 张 + 下线提示;大图回退到后邻(第 3 张)仍可看。
|
||||
await expect(page.locator('.cell')).toHaveCount(2, { timeout: 20_000 });
|
||||
await expect(page.locator('.removed-notice')).toBeVisible();
|
||||
await expect(page.locator('.lightbox')).toBeVisible();
|
||||
await expect(page.locator('.photo-viewer')).toBeVisible();
|
||||
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[2]!));
|
||||
});
|
||||
|
||||
@@ -198,7 +198,7 @@ test('sort toggle switches latest/hot snapshots and is disabled in the lightbox'
|
||||
await apiLike(token, older, 'e2e-hot-session', 10);
|
||||
await page.getByRole('button', { name: '热门' }).click();
|
||||
await expect(page.locator('.cell-img').first()).toHaveAttribute('src', new RegExp(older));
|
||||
await expect(page.locator('.sort .chip').nth(1)).toHaveClass(/active/);
|
||||
await expect(page.locator('.sort-tabs .sort-tab').nth(1)).toHaveClass(/active/);
|
||||
|
||||
// 切回最新:原顺序恢复(冷快照语义,各自独立)。
|
||||
await page.getByRole('button', { name: '最新' }).click();
|
||||
@@ -206,9 +206,9 @@ test('sort toggle switches latest/hot snapshots and is disabled in the lightbox'
|
||||
|
||||
// 大图打开期间排序切换禁用(快照冻结,避免半开序列换序)。
|
||||
await page.locator('.cell').first().click();
|
||||
await expect(page.locator('.lightbox')).toBeVisible();
|
||||
await expect(page.locator('.sort .chip').first()).toBeDisabled();
|
||||
await expect(page.locator('.sort .chip').nth(1)).toBeDisabled();
|
||||
await expect(page.locator('.photo-viewer')).toBeVisible();
|
||||
await expect(page.locator('.sort-tabs .sort-tab').first()).toBeDisabled();
|
||||
await expect(page.locator('.sort-tabs .sort-tab').nth(1)).toBeDisabled();
|
||||
});
|
||||
|
||||
test('visit counting: fresh context counts once, reload is idempotent, second context counts', async ({
|
||||
@@ -222,19 +222,19 @@ test('visit counting: fresh context counts once, reload is idempotent, second co
|
||||
try {
|
||||
await openAlbum(page1, token);
|
||||
// 首访计数(上报成功后以权威 viewCount 回填)。
|
||||
await expect(page1.locator('.view-count')).toHaveText('1', { timeout: 15_000 });
|
||||
await expect(page1.locator('.view-count')).toHaveText('1 次浏览', { timeout: 15_000 });
|
||||
|
||||
// 刷新:visitorId 持久 + 窗口去重 → 不再计数。
|
||||
await page1.reload();
|
||||
await expect(page1.locator('.cell').first()).toBeVisible();
|
||||
await expect(page1.locator('.view-count')).toHaveText('1');
|
||||
await expect(page1.locator('.view-count')).toHaveText('1 次浏览');
|
||||
|
||||
// 第二个全新上下文:新 visitorId → 再计数。
|
||||
const ctx2 = await browser.newContext();
|
||||
try {
|
||||
const page2 = await ctx2.newPage();
|
||||
await openAlbum(page2, token);
|
||||
await expect(page2.locator('.view-count')).toHaveText('2', { timeout: 15_000 });
|
||||
await expect(page2.locator('.view-count')).toHaveText('2 次浏览', { timeout: 15_000 });
|
||||
} finally {
|
||||
await ctx2.close();
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ test('open from feed, navigate in sequence, zoom, close restores scroll and url'
|
||||
|
||||
// 点击第 3 格 → 大图:browse 图取回真实字节,URL 换为深链。
|
||||
await page.locator('.cell').nth(2).click();
|
||||
await expect(page.locator('.lightbox')).toBeVisible();
|
||||
await expect(page.locator('.photo-viewer')).toBeVisible();
|
||||
await expect
|
||||
.poll(() => browseNaturalWidth(page))
|
||||
.toBeGreaterThan(0);
|
||||
@@ -88,10 +88,10 @@ test('open from feed, navigate in sequence, zoom, close restores scroll and url'
|
||||
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[step]!), { timeout: 10_000 });
|
||||
}
|
||||
expect(page.url()).toContain(`/a/${token}/p/${photoIds[5]}`);
|
||||
await expect(page.locator('.counter')).toHaveText('6 / 8');
|
||||
await expect(page.locator('.position-indicator')).toHaveText('6 / 8');
|
||||
|
||||
// 滚轮缩放(无需 ctrl):transform scale > 1;双击复位。
|
||||
const box = await page.locator('.lightbox').boundingBox();
|
||||
const box = await page.locator('.photo-viewer').boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
||||
await page.mouse.wheel(0, -360);
|
||||
@@ -109,8 +109,8 @@ test('open from feed, navigate in sequence, zoom, close restores scroll and url'
|
||||
.toBeCloseTo(1, 1);
|
||||
|
||||
// 关闭:URL 弹回相册路由,滚动精确回位。
|
||||
await page.locator('.control.close').click();
|
||||
await expect(page.locator('.lightbox')).toHaveCount(0);
|
||||
await page.locator('.viewer-close').click();
|
||||
await expect(page.locator('.photo-viewer')).toHaveCount(0);
|
||||
await expect(page).toHaveURL(new RegExp(`/a/${token}/?$`));
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.scrollY))
|
||||
@@ -123,12 +123,12 @@ test('deep link opens on the target photo with the list loaded behind', async ({
|
||||
const target = photoIds[4]!; // 第 5 张
|
||||
await openLocale(page, token, `/p/${target}`);
|
||||
|
||||
await expect(page.locator('.lightbox')).toBeVisible();
|
||||
await expect(page.locator('.photo-viewer')).toBeVisible();
|
||||
await expect
|
||||
.poll(() => browseNaturalWidth(page))
|
||||
.toBeGreaterThan(0);
|
||||
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(target));
|
||||
await expect(page.locator('.counter')).toHaveText('5 / 8');
|
||||
await expect(page.locator('.position-indicator')).toHaveText('5 / 8');
|
||||
// 背后列表已构成序列(8 张)。
|
||||
await expect(page.locator('.cell')).toHaveCount(8);
|
||||
|
||||
@@ -138,8 +138,8 @@ test('deep link opens on the target photo with the list loaded behind', async ({
|
||||
expect(page.url()).toContain(`/a/${token}/p/${photoIds[3]}`);
|
||||
|
||||
// 关闭 → 回相册路由(深链直达无本会话 push:replace 语义)。
|
||||
await page.locator('.control.close').click();
|
||||
await expect(page.locator('.lightbox')).toHaveCount(0);
|
||||
await page.locator('.viewer-close').click();
|
||||
await expect(page.locator('.photo-viewer')).toHaveCount(0);
|
||||
await expect(page).toHaveURL(new RegExp(`/a/${token}/?$`));
|
||||
});
|
||||
|
||||
@@ -155,7 +155,7 @@ test('deep link to a removed photo shows the localized notice and a working back
|
||||
const notice = page.locator('.deep-link-notice');
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(notice).toContainText('照片不存在或已下线');
|
||||
await expect(page.locator('.lightbox')).toHaveCount(0);
|
||||
await expect(page.locator('.photo-viewer')).toHaveCount(0);
|
||||
|
||||
const back = notice.getByRole('link', { name: '返回相册' });
|
||||
await back.click();
|
||||
@@ -172,13 +172,13 @@ test('navigating past the first page extends the sequence', async ({ page }) =>
|
||||
|
||||
await expect(page.locator('.cell')).toHaveCount(30);
|
||||
await page.locator('.cell').nth(29).click(); // 第 1 页最后一张
|
||||
await expect(page.locator('.lightbox')).toBeVisible();
|
||||
await expect(page.locator('.photo-viewer')).toBeVisible();
|
||||
|
||||
// 打开即临近末页边界:相邻预取已后台拉取第 2 页(设计行为),右切越过末尾
|
||||
// 落在第 31 张(序列第 2 页),列表同步扩展。
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[30]!), { timeout: 15_000 });
|
||||
await expect(page.locator('.counter')).toHaveText('31 / 32');
|
||||
await expect(page.locator('.position-indicator')).toHaveText('31 / 32');
|
||||
// 背后列表同步扩展到 32 张。
|
||||
await expect(page.locator('.cell')).toHaveCount(32);
|
||||
});
|
||||
|
||||
@@ -232,6 +232,31 @@ describe('photo feed (Task 7 / Task 8 sequence props)', () => {
|
||||
expect(wrapper.find('.new-photo-bar').exists()).toBe(false);
|
||||
});
|
||||
|
||||
// —— UI-02 Task 6(§7 状态矩阵):加载更多失败——保留已有照片,底部重试 ——
|
||||
it('load-more failure keeps the loaded photos and offers a bottom retry', async () => {
|
||||
const { wrapper } = mountFeed();
|
||||
await flushPromises();
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(3);
|
||||
|
||||
const sentinel = wrapper.find('.sentinel');
|
||||
mockedGetPublicPhotos.mockRejectedValueOnce(new ApiError('NETWORK_ERROR', 'network down', 0, null));
|
||||
sentinelObserver().trigger([sentinel.element], true);
|
||||
await flushPromises();
|
||||
|
||||
// 已有照片不被清空;不渲染整页错误块,只在底部给重试。
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(3);
|
||||
expect(wrapper.find('.error[role="alert"]').exists()).toBe(false);
|
||||
const retry = wrapper.find('.more-failed button');
|
||||
expect(retry.text()).toBe('重试');
|
||||
|
||||
// 底部重试成功 → 追加第 2 页并到尽头。
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p4')], null));
|
||||
await retry.trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(4);
|
||||
expect(wrapper.find('.end-note').text()).toBe('已经到底了');
|
||||
});
|
||||
|
||||
it('renders the localized empty state when the album has no photos', async () => {
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([]));
|
||||
const { wrapper } = mountFeed();
|
||||
|
||||
@@ -53,4 +53,26 @@ describe('viewer i18n', () => {
|
||||
expect(wrapper.text()).toContain('打开相册链接即可观看');
|
||||
expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh-CN');
|
||||
});
|
||||
|
||||
// UI-02 Task 6(V05 §5.4 分享链路闭环):分享 URL 携带 ?lang=,接收端
|
||||
// 首载据此定语言——优先于本地持久化偏好;不合法值(fr/auto)自然回退。
|
||||
it('initial locale honors the ?lang= share parameter over the stored preference', () => {
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, 'zh-CN');
|
||||
|
||||
window.history.pushState({}, '', '/a/token?lang=en');
|
||||
expect(createViewerI18n().global.locale.value).toBe('en');
|
||||
|
||||
// 非法/不支持值:回落到持久化偏好。
|
||||
window.history.pushState({}, '', '/a/token?lang=auto');
|
||||
expect(createViewerI18n().global.locale.value).toBe('zh-CN');
|
||||
window.history.pushState({}, '', '/a/token?lang=fr');
|
||||
expect(createViewerI18n().global.locale.value).toBe('zh-CN');
|
||||
|
||||
// 无参数:持久化偏好照旧生效。
|
||||
window.history.pushState({}, '', '/a/token');
|
||||
expect(createViewerI18n().global.locale.value).toBe('zh-CN');
|
||||
|
||||
// 参数不写 localStorage——接收者未做选择,显式切换才持久化。
|
||||
expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh-CN');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -432,6 +432,32 @@ describe('photo lightbox dark viewer (UI-02 V04)', () => {
|
||||
trigger.remove();
|
||||
});
|
||||
|
||||
// UI-02 Task 6 无障碍抽检(§5.3):Tab 循环困于对话框——两端折回,不穿透。
|
||||
it('traps Tab focus inside the dialog (wrap-around at both ends)', async () => {
|
||||
const wrapper = mountLightbox('p2');
|
||||
document.body.appendChild(wrapper.element);
|
||||
await flushPromises();
|
||||
|
||||
const focusables = Array.from(
|
||||
wrapper.element.querySelectorAll('button:not([disabled])'),
|
||||
).filter((el): el is HTMLElement => el instanceof HTMLElement);
|
||||
expect(focusables.length).toBeGreaterThan(1);
|
||||
const first = focusables[0]!;
|
||||
const last = focusables[focusables.length - 1]!;
|
||||
|
||||
// 末元素正向 Tab → 折回首元素。
|
||||
last.focus();
|
||||
last.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||
expect(document.activeElement).toBe(first);
|
||||
|
||||
// 首元素 Shift+Tab → 折回末元素。
|
||||
first.focus();
|
||||
first.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
expect(document.activeElement).toBe(last);
|
||||
});
|
||||
|
||||
it('locks body scroll while open and restores the previous value on close', async () => {
|
||||
document.body.style.overflow = 'auto';
|
||||
const wrapper = mountLightbox('p2');
|
||||
@@ -454,6 +480,16 @@ describe('photo lightbox dark viewer (UI-02 V04)', () => {
|
||||
expect(source).toContain('env(safe-area-inset-top, 0px)');
|
||||
expect(source).toContain('env(safe-area-inset-bottom, 0px)');
|
||||
});
|
||||
|
||||
it('layers the fullscreen overlay above the sticky topbar (close button clickable)', () => {
|
||||
// jsdom 无布局:源级断言(同上)。覆盖层 z=200 须高于 sticky 顶栏 z=120
|
||||
// (App.vue)——否则顶栏盖住关闭按钮的可点区(e2e 曾拦截 click 的真实回归)。
|
||||
const source = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), '..', 'components', 'PhotoLightbox.vue'),
|
||||
'utf8',
|
||||
);
|
||||
expect(source).toMatch(/\.photo-viewer\s*\{[^}]*z-index:\s*200/s);
|
||||
});
|
||||
});
|
||||
|
||||
describe('photo lightbox interactions (UI-02 V05)', () => {
|
||||
|
||||
@@ -705,7 +705,9 @@ onBeforeUnmount(() => {
|
||||
.photo-viewer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
/* 200:盖住 sticky 顶栏(z=120)与偏好菜单(z=130)——全屏模态下顶栏不可
|
||||
* 点也不可见;下线轻提示(z=210)仍在其上。 */
|
||||
z-index: 200;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
|
||||
@@ -449,6 +449,8 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-top: 8px;
|
||||
/* 标签与信息区 16px 对齐(效果图 §3.1/§4.2);分隔线保持通栏。 */
|
||||
padding: 0 var(--viewer-pad-info, 16px);
|
||||
border-bottom: 1px solid var(--viewer-divider, #e5eaf1);
|
||||
}
|
||||
.sort-tab {
|
||||
@@ -494,7 +496,9 @@ onBeforeUnmount(() => {
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 2px var(--viewer-pad-info, 16px) 6px;
|
||||
margin: -2px calc(var(--viewer-pad-info, 16px) * -1) 0;
|
||||
/* 仅纵向负边距抵消 padding:本元件父级已是通栏宽,横向负边距会把盒子推出
|
||||
* 视口(页面出现横向滚动);首胶囊的 16px 缩进由上方 padding 提供。 */
|
||||
margin: -2px 0 0;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.chip {
|
||||
|
||||
@@ -11,8 +11,17 @@ function isSupported(value: string): value is ViewerLocale {
|
||||
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
// 持久化优先(localStorage),其次浏览器语言(zh*/en 前缀),兜底 zh-CN。
|
||||
// 分享链接 ?lang= 参数优先(V05 §5.4:相册/照片分享 URL 携带语言,接收端
|
||||
// 据此呈现——不持久化,接收者未做选择;显式切换仍走 LocaleSwitcher 落盘),
|
||||
// 其次持久化(localStorage),再次浏览器语言(zh*/en 前缀),兜底 zh-CN。
|
||||
// 嵌入 iframe 的 lang 查询(zh-CN/en/auto)语义在 embed/feed.ts 独立解析,
|
||||
// 此处仅认 zh-CN/en——auto/非法值自然落入既有解析链,EmbedPage 加载后仍会
|
||||
// 按相册 defaultLocale 覆盖,互不冲突。
|
||||
function resolveInitialLocale(): ViewerLocale {
|
||||
if (typeof window !== 'undefined') {
|
||||
const param = new URLSearchParams(window.location.search).get('lang');
|
||||
if (param !== null && isSupported(param)) return param;
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
if (stored !== null && isSupported(stored)) return stored;
|
||||
|
||||
@@ -437,7 +437,9 @@ onBeforeUnmount(() => {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
z-index: 110;
|
||||
/* 大图覆盖层(.photo-viewer z=200)之上——下线提示须在查看器打开时仍可见;
|
||||
* 顶栏 z=120 已被覆盖层盖住,不再互相遮挡。 */
|
||||
z-index: 210;
|
||||
border-radius: 8px;
|
||||
background: rgba(17, 24, 39, 0.9);
|
||||
color: #f9fafb;
|
||||
|
||||
Reference in New Issue
Block a user