feat(viewer): UI-02 dark immersive photo viewer with thumbnail strip

V04 (方案 §5):PhotoLightbox 模板重构为深色沉浸布局——顶栏(关闭/位置
计数/⋯ 减少动画菜单)+ 舞台(contain 大图 + 边缘 ‹› + 粒子/互动占位层)+
缩略图条 + 底部操作栏;业务逻辑(序列导航/缩放手势/签名刷新/下载/点赞)
原样保留。

- 新增 viewer/position.ts:§5.2 位置计数真实回退(solo→照片预览;无
  total 未到末页→已加载 N 张;全部加载→i/n;不伪造总数)
- 新增 ThumbnailStrip:当前 ±3 窗口(≤7 项)、主色 2px 描边、thumb 变体、
  滚入可见(遵守减少动画)、点击选择不新增历史
- 新增 PhotoActionBar:Like(coral 心形+计数+连续点赞提示)/Share/
  Download 均分大触控;下载关闭整项移除;原图二级菜单(▾ + 长按)
- 模态可用性:body 滚动锁、焦点进入/困住/归还触发元素、Esc 先收菜单、
  菜单内左右键不抢占、dvh + 安全区、chrome 区手势/滚轮隔离
- 分享 V04 最小实现(复制当前深链,support/clipboard 从 AlbumHeader
  提取共用);navigator.share 编排属 Task 5
- zh-CN/en 新词条;vitest 新增 23 例(位置回退/缩略图/操作栏/焦点/滚锁)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 21:58:09 +08:00
parent 3d89fcdb99
commit 89f45896b7
14 changed files with 1536 additions and 340 deletions
+12 -12
View File
@@ -187,16 +187,16 @@ describe('photo download (P4a Task 4)', () => {
// on/off:单按钮,无 caret/菜单。
const browseOnly = mountLightbox(makeAlbum());
await flushPromises();
expect(browseOnly.find('.control.download').exists()).toBe(true);
expect(browseOnly.find('.control.download-caret').exists()).toBe(false);
expect(browseOnly.find('.download-main').exists()).toBe(true);
expect(browseOnly.find('.download-caret').exists()).toBe(false);
// on/on:分体按钮(caret 出现,菜单默认收起)。
const split = mountLightbox(makeAlbum({ originalDownloadEnabled: true }));
await flushPromises();
expect(split.find('.control.download-caret').exists()).toBe(true);
expect(split.find('.download-caret').exists()).toBe(true);
expect(split.find('.download-menu').exists()).toBe(false);
// 主按钮 aria 展开 = false。
expect(split.find('.control.download-caret').attributes('aria-expanded')).toBe('false');
expect(split.find('.download-caret').attributes('aria-expanded')).toBe('false');
});
it('split menu offers both variants; picking original requests the original endpoint', async () => {
@@ -204,9 +204,9 @@ describe('photo download (P4a Task 4)', () => {
const wrapper = mountLightbox(makeAlbum({ originalDownloadEnabled: true }));
await flushPromises();
await wrapper.find('.control.download-caret').trigger('click');
await wrapper.find('.download-caret').trigger('click');
const items = wrapper.findAll('.download-menu button');
expect(items.map((item) => item.text())).toEqual(['下载图片', '下载原图']);
expect(items.map((item) => item.text())).toEqual(['浏览图', '原图']);
await items[1]!.trigger('click');
await flushPromises();
@@ -224,7 +224,7 @@ describe('photo download (P4a Task 4)', () => {
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.control.download').trigger('click');
await wrapper.find('.download-main').trigger('click');
await flushPromises();
expect(mockedGetPhotoDownload).toHaveBeenCalledTimes(2);
expect(mockedGetPhotoDownload).toHaveBeenCalledWith('tok-dl', 'p1', 'browse');
@@ -236,7 +236,7 @@ describe('photo download (P4a Task 4)', () => {
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.control.download').trigger('click');
await wrapper.find('.download-main').trigger('click');
await flushPromises();
expect(mockedGetPhotoDownload).toHaveBeenCalledTimes(1);
expect(openSpy).toHaveBeenCalledWith('http://img.test/p1-browse.jpg', '_blank', 'noopener,noreferrer');
@@ -245,7 +245,7 @@ describe('photo download (P4a Task 4)', () => {
// 端点失败(网络/403 等)→ 失败提示可再见(再次点击重试)。
openSpy.mockClear();
mockedGetPhotoDownload.mockReset().mockRejectedValue(new Error('boom'));
await wrapper.find('.control.download').trigger('click');
await wrapper.find('.download-main').trigger('click');
await flushPromises();
expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('下载失败');
expect(openSpy).not.toHaveBeenCalled();
@@ -259,7 +259,7 @@ describe('photo download (P4a Task 4)', () => {
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.control.download').trigger('click');
await wrapper.find('.download-main').trigger('click');
await flushPromises();
expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('该相册未开放下载');
expect(wrapper.find('.download-failed').text()).not.toContain('下载失败');
@@ -270,7 +270,7 @@ describe('photo download (P4a Task 4)', () => {
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.control.download').trigger('click');
await wrapper.find('.download-main').trigger('click');
await flushPromises();
expect(wrapper.find('.wechat-guide[role="dialog"]').exists()).toBe(true);
expect(wrapper.find('.wechat-guide h2').text()).toBe('保存图片指引');
@@ -289,7 +289,7 @@ describe('photo download (P4a Task 4)', () => {
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.control.download').trigger('click');
await wrapper.find('.download-main').trigger('click');
await wrapper.find('.guide-copy').trigger('click');
await flushPromises();
expect(writeText).toHaveBeenCalledWith(window.location.href);
+166 -15
View File
@@ -1,3 +1,6 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
import { defineComponent, nextTick, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -12,8 +15,10 @@ import { createViewerI18n, type ViewerI18n } from '../i18n';
import PhotoLightbox from '../components/PhotoLightbox.vue';
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
// PhotoLightbox(Task 8)组件测试:browse 渲染/导航/序列扩展、wheel 缩放与双击
// 复位、Esc/背景/按钮关闭、指针轻扫切换、签名过期静默重取与 404 移除上报。
// PhotoLightbox 组件测试(Task 8 业务 + UI-02 V04 深色布局):
// browse 渲染/导航/序列扩展、wheel 缩放与双击复位、Esc/关闭/舞台空白点击关闭、
// 指针轻扫切换、签名过期静默重取与 404 移除上报;V04 新增:位置计数三种
// 真实回退、缩略图条渲染与选择、更多菜单、焦点管理、body 滚动锁、安全区。
// 深链解析与 URL 同步在 viewer-route.spec(真实路由)覆盖。
vi.mock('../api/client', async (importOriginal) => {
@@ -44,8 +49,9 @@ let i18n: ViewerI18n;
const mounted: VueWrapper[] = [];
/** 宿主:sequence + v-model photoId(镜像 ActivityPage 组装)。页面数据经
* mockedGetPublicPhotos 的当前 mock 队列供给(与真实时序一致:挂载后到达)。 */
function mountLightbox(photoId: string): VueWrapper {
* mockedGetPublicPhotos 的当前 mock 队列供给(与真实时序一致:挂载后到达)。
* solo:深链兜底条目(ensureInSequence 追加在序列尾部,自然位置未知)。 */
function mountLightbox(photoId: string, options: { solo?: PublicPhoto } = {}): VueWrapper {
const Host = defineComponent({
components: { PhotoLightbox },
setup() {
@@ -54,6 +60,7 @@ function mountLightbox(photoId: string): VueWrapper {
locale: ref('zh-CN'),
category: ref(null),
});
if (options.solo !== undefined) sequence.ensureInSequence(options.solo);
const current = ref(photoId);
return { sequence, photoId: current };
},
@@ -95,7 +102,7 @@ function keydown(key: string): void {
/** test-utils 的 trigger 无法写 MouseEvent 只读坐标:直接派生 WheelEvent。 */
function wheel(wrapper: VueWrapper, deltaY: number, clientX = 100, clientY = 80): void {
wrapper.find('.lightbox').element.dispatchEvent(
wrapper.find('.photo-viewer').element.dispatchEvent(
new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY, clientX, clientY }),
);
}
@@ -115,7 +122,7 @@ describe('photo lightbox (Task 8)', () => {
vi.restoreAllMocks();
});
it('renders the current browse url with counter and like count', async () => {
it('renders the current browse url with position indicator and like count', async () => {
mockedGetPublicPhotos.mockResolvedValue(
makeList([makePhoto('p1'), makePhoto('p2', 7), makePhoto('p3')]),
);
@@ -123,8 +130,11 @@ describe('photo lightbox (Task 8)', () => {
await flushPromises();
expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=1');
expect(wrapper.find('.counter').text()).toBe('2 / 3');
expect(wrapper.find('.likes').text()).toBe('7 次点赞');
// 3 张全部加载完成(无更多页)→ 筛选序列 i / n(§5.2)。
expect(wrapper.find('.position-indicator').text()).toBe('2 / 3');
// 底部操作栏(V04):Like 计数 + Like/Share 标签(无下载控制器 → 下载位省略)。
expect(wrapper.find('.like-count').text()).toBe('7');
expect(wrapper.findAll('.action-label').map((label) => label.text())).toEqual(['点赞', '分享']);
});
it('navigates with buttons and arrow keys, keeping the list order', async () => {
@@ -150,17 +160,18 @@ describe('photo lightbox (Task 8)', () => {
expect(wrapper.find('.nav.next').attributes('disabled')).toBeUndefined();
});
it('closes on Esc, close button and backdrop click', async () => {
it('closes on Esc, close button and empty-stage click', async () => {
const wrapper = mountLightbox('p2');
await flushPromises();
keydown('Escape');
expect(lightbox(wrapper).emitted('close')).toHaveLength(1);
await wrapper.find('.control.close').trigger('click');
await wrapper.find('.viewer-close').trigger('click');
expect(lightbox(wrapper).emitted('close')).toHaveLength(2);
await wrapper.find('.backdrop').trigger('click');
// 舞台空白区(目标=容器自身,非 <img>)点击关闭。
await wrapper.find('.image-container').trigger('click');
expect(lightbox(wrapper).emitted('close')).toHaveLength(3);
});
@@ -198,10 +209,10 @@ describe('photo lightbox (Task 8)', () => {
expect(img(wrapper).attributes('style')).toContain('scale(1)');
});
it('swipe navigates when flat; drag on backdrop does not close', async () => {
it('swipe navigates when flat; drag on stage does not close', async () => {
const wrapper = mountLightbox('p2');
await flushPromises();
const root = wrapper.find('.lightbox');
const root = wrapper.find('.photo-viewer');
// 左滑 80px → 下一张。
root.element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1, clientX: 200, clientY: 150 }));
@@ -210,11 +221,11 @@ describe('photo lightbox (Task 8)', () => {
await flushPromises();
expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3']]);
// 拖拽距离超过阈值后收尾在背景上:click 不关闭。
// 拖拽距离超过阈值后收尾在舞台上:click 不关闭。
root.element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1, clientX: 200, clientY: 150 }));
root.element.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 100, clientY: 160 }));
root.element.dispatchEvent(pointerEvent('pointerup', { pointerId: 1, clientX: 100, clientY: 160 }));
await wrapper.find('.backdrop').trigger('click');
await wrapper.find('.image-container').trigger('click');
expect(lightbox(wrapper).emitted('close')).toBeUndefined();
});
@@ -297,3 +308,143 @@ describe('photo lightbox (Task 8)', () => {
expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=3');
});
});
describe('photo lightbox dark viewer (UI-02 V04)', () => {
beforeEach(() => {
localStorage.clear();
i18n = createViewerI18n('zh-CN');
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')]));
mockedGetPublicPhoto.mockReset();
document.body.style.overflow = '';
});
afterEach(() => {
while (mounted.length > 0) {
mounted.pop()?.unmount();
}
vi.restoreAllMocks();
document.body.style.overflow = '';
document.documentElement.className = '';
});
it('shows the loaded-count fallback while more pages remain (no fake total)', async () => {
let resolvePage!: (value: PublicPhotoList) => void;
mockedGetPublicPhotos.mockReset();
mockedGetPublicPhotos
.mockResolvedValueOnce(makeList([makePhoto('p1'), makePhoto('p2')], 'cur-1'))
.mockReturnValueOnce(
new Promise<PublicPhotoList>((resolve) => {
resolvePage = resolve;
}),
);
const wrapper = mountLightbox('p2');
await flushPromises();
// 未到末页且接口无 total → 「已加载 N 张」(§5.2),不显示 i/n。
expect(wrapper.find('.position-indicator').text()).toBe('已加载 2 张');
resolvePage(makeList([makePhoto('p3')], null));
await flushPromises();
// 全部加载完成 → 当前筛选序列 i / n。
expect(wrapper.find('.position-indicator').text()).toBe('2 / 3');
});
it('shows photo preview for an unpositioned solo deep-link entry', async () => {
const wrapper = mountLightbox('solo-9', { solo: makePhoto('solo-9') });
await flushPromises();
// solo 自然位置未知(首页加载后仍追加在尾部)→ 「照片预览」,不伪造排名。
expect(wrapper.find('.position-indicator').text()).toBe('照片预览');
});
it('renders the thumbnail strip window and selects without extra history entries', async () => {
const wrapper = mountLightbox('p2');
await flushPromises();
// 3 张全部落在 ±3 窗口内;当前项描边 + aria-current;点击只上抛切换。
const thumbs = wrapper.findAll('.thumb');
expect(thumbs.map((thumb) => thumb.find('img').attributes('src'))).toEqual([
'http://img.test/p1-thumb.jpg?s=1',
'http://img.test/p2-thumb.jpg?s=1',
'http://img.test/p3-thumb.jpg?s=1',
]);
expect(thumbs[1].classes()).toContain('current');
expect(thumbs[1].attributes('aria-current')).toBe('true');
await thumbs[0].trigger('click');
expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p1']]);
});
it('more menu exposes the reduce-motion toggle; Escape closes the menu before the viewer', async () => {
const wrapper = mountLightbox('p2');
await flushPromises();
await wrapper.find('.viewer-more').trigger('click');
expect(wrapper.find('.more-popover').exists()).toBe(true);
const item = wrapper.find('.more-item');
expect(item.text()).toContain('减少动画');
expect(item.attributes('aria-checked')).toBe('false');
await item.trigger('click');
expect(wrapper.find('.more-item').attributes('aria-checked')).toBe('true');
// 还原模块级偏好单例,避免影响后续用例。
await wrapper.find('.more-item').trigger('click');
keydown('Escape');
await flushPromises();
expect(wrapper.find('.more-popover').exists()).toBe(false); // 先收菜单
expect(lightbox(wrapper).emitted('close')).toBeUndefined(); // 不关查看器
keydown('Escape');
await flushPromises();
expect(lightbox(wrapper).emitted('close')).toHaveLength(1);
});
it('moves focus into the dialog on open and returns it to the trigger on close', async () => {
const trigger = document.createElement('button');
document.body.appendChild(trigger);
trigger.focus();
const wrapper = mountLightbox('p2');
// jsdom 下脱离文档的元素 focus() 不改 activeElement:赶在调度器微任务
// (rootEl watcher 调 focus)之前同步挂进文档。
document.body.appendChild(wrapper.element);
await flushPromises();
const root = wrapper.find('.photo-viewer');
expect(root.attributes('role')).toBe('dialog');
expect(root.attributes('aria-modal')).toBe('true');
expect(root.attributes('tabindex')).toBe('-1');
expect(root.attributes('aria-label')).toBe('照片查看器');
expect(document.activeElement).toBe(root.element);
// 手动卸载(绕过 mounted 清单)验证焦点归还触发元素。
const index = mounted.indexOf(wrapper);
if (index >= 0) mounted.splice(index, 1);
wrapper.unmount();
expect(document.activeElement).toBe(trigger);
trigger.remove();
});
it('locks body scroll while open and restores the previous value on close', async () => {
document.body.style.overflow = 'auto';
const wrapper = mountLightbox('p2');
await flushPromises();
expect(document.body.style.overflow).toBe('hidden');
const index = mounted.indexOf(wrapper);
if (index >= 0) mounted.splice(index, 1);
wrapper.unmount();
expect(document.body.style.overflow).toBe('auto');
});
it('applies dvh viewport height and safe-area insets for the immersive layout', () => {
// vitest 不加载 SFC 样式:源级断言(与 viewer-shell 的 token 源级断言同法)。
const source = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), '..', 'components', 'PhotoLightbox.vue'),
'utf8',
);
expect(source).toContain('100dvh');
expect(source).toContain('env(safe-area-inset-top, 0px)');
expect(source).toContain('env(safe-area-inset-bottom, 0px)');
});
});
@@ -0,0 +1,117 @@
import { mount, type VueWrapper } from '@vue/test-utils';
import { nextTick } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import PhotoActionBar from '../components/viewer/PhotoActionBar.vue';
import { createViewerI18n, type ViewerI18n } from '../i18n';
// UI-02 §5.1/§5.4 底部操作栏单测:Like/Share/Download 三键与标签、计数
// Intl 格式化、下载关闭时移除整项(剩余均分)、原图二级菜单(▾ 与长按)、
// 事件上抛(like/share/download(variant))。
let i18n: ViewerI18n;
const mounted: VueWrapper[] = [];
function mountBar(props: {
likeCount: number;
canDownload?: boolean;
canDownloadOriginal?: boolean;
isLiking?: boolean;
}): VueWrapper {
const wrapper = mount(PhotoActionBar, {
props: {
canDownload: false,
canDownloadOriginal: false,
isLiking: false,
...props,
},
global: { plugins: [i18n] },
});
mounted.push(wrapper);
return wrapper;
}
describe('photo action bar (UI-02 V04)', () => {
beforeEach(() => {
localStorage.clear();
i18n = createViewerI18n('zh-CN');
});
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.unmount();
vi.useRealTimers();
vi.restoreAllMocks();
});
it('renders like/share/download as evenly distributed slots with formatted count and hint', () => {
const wrapper = mountBar({ likeCount: 1286, canDownload: true });
// 三个 action-slot flex 均分(下载隐藏时由剩余 slot 自然重排,见下例)。
expect(wrapper.findAll('.action-slot')).toHaveLength(3);
const labels = wrapper.findAll('.action-label').map((label) => label.text());
expect(labels).toEqual(['点赞', '分享', '下载']);
// 计数 Intl 分组 + tabular-nums 容器;Like 下方次级提示(§5.1)。
expect(wrapper.find('.like-count').text()).toBe('1,286');
expect(wrapper.find('.like-hint').text()).toBe('连续点击,为照片点赞');
});
it('removes the download action entirely when downloads are disabled', () => {
const wrapper = mountBar({ likeCount: 0, canDownload: false });
expect(wrapper.find('.download-group').exists()).toBe(false);
expect(wrapper.findAll('.action-slot')).toHaveLength(2); // 剩余两键均分栏宽
expect(wrapper.findAll('.action-label').map((label) => label.text())).toEqual(['点赞', '分享']);
});
it('emits like and share from the large tap targets', async () => {
const wrapper = mountBar({ likeCount: 3 });
const buttons = wrapper.findAll('.action-btn');
expect(buttons).toHaveLength(2);
await buttons[0].trigger('click');
await buttons[1].trigger('click');
expect(wrapper.emitted('like')).toHaveLength(1);
expect(wrapper.emitted('share')).toHaveLength(1);
expect(wrapper.emitted('download')).toBeUndefined();
});
it('downloads the browse variant directly when original download is disabled', async () => {
const wrapper = mountBar({ likeCount: 0, canDownload: true, canDownloadOriginal: false });
expect(wrapper.find('.download-caret').exists()).toBe(false);
await wrapper.find('.download-group .action-btn').trigger('click');
expect(wrapper.emitted('download')).toEqual([['browse']]);
});
it('offers browse/original choices via the caret menu', async () => {
const wrapper = mountBar({ likeCount: 0, canDownload: true, canDownloadOriginal: true });
const caret = wrapper.find('.download-caret');
expect(caret.attributes('aria-haspopup')).toBe('menu');
await caret.trigger('click');
const menu = wrapper.find('.download-menu');
expect(menu.attributes('role')).toBe('menu');
expect(menu.text()).toContain('浏览图');
expect(menu.text()).toContain('原图');
const items = menu.findAll('button');
await items[1].trigger('click');
expect(wrapper.emitted('download')).toEqual([['original']]);
});
it('long-pressing the download button opens the variant menu instead of firing browse', async () => {
vi.useFakeTimers();
const wrapper = mountBar({ likeCount: 0, canDownload: true, canDownloadOriginal: true });
const button = wrapper.find('.download-group .action-btn');
await button.trigger('pointerdown');
vi.advanceTimersByTime(500);
await nextTick(); // 定时器改状态后等一帧再查 DOM
expect(wrapper.find('.download-menu').exists()).toBe(true);
// 长按已展开菜单:紧随的 click 被吞掉,不触发默认浏览图下载。
await button.trigger('click');
expect(wrapper.emitted('download')).toBeUndefined();
await wrapper.find('.download-menu button').trigger('click');
expect(wrapper.emitted('download')).toEqual([['browse']]);
});
});
@@ -0,0 +1,115 @@
import { mount, type VueWrapper } from '@vue/test-utils';
import { nextTick } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ThumbnailStrip, { type ThumbnailItem } from '../components/viewer/ThumbnailStrip.vue';
import { createViewerI18n, type ViewerI18n } from '../i18n';
import { reducedMotion } from '../ui/prefs';
// UI-02 §5.3 缩略图条单测:±3 窗口(最多 7 项,首尾自然收缩)、当前项描边与
// aria-current、点击上抛 select(无浏览器历史副作用)、选中项滚入可见范围
// (遵守减少动画)、无溢出不渲染渐隐提示。
function makeItems(count: number): ThumbnailItem[] {
return Array.from({ length: count }, (_, i) => ({
photoId: `p${i + 1}`,
thumbUrl: `http://img.test/p${i + 1}-thumb.jpg`,
}));
}
let i18n: ViewerI18n;
const mounted: VueWrapper[] = [];
function mountStrip(photos: ThumbnailItem[], currentPhotoId: string | null): VueWrapper {
const wrapper = mount(ThumbnailStrip, {
props: { photos, currentPhotoId },
global: { plugins: [i18n] },
});
mounted.push(wrapper);
return wrapper;
}
function thumbIds(wrapper: VueWrapper): (string | undefined)[] {
return wrapper.findAll('.thumb').map((thumb) => thumb.find('img').attributes('src'));
}
describe('thumbnail strip (UI-02 V04)', () => {
beforeEach(() => {
localStorage.clear();
i18n = createViewerI18n('zh-CN');
});
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.unmount();
reducedMotion.value = false;
vi.restoreAllMocks();
});
it('renders the current ±3 window (max 7 items) centered on the current photo', () => {
const wrapper = mountStrip(makeItems(12), 'p6');
// p6 的 ±3 → p3..p9 共 7 项;thumb 变体 URL。
expect(thumbIds(wrapper)).toEqual([
'http://img.test/p3-thumb.jpg',
'http://img.test/p4-thumb.jpg',
'http://img.test/p5-thumb.jpg',
'http://img.test/p6-thumb.jpg',
'http://img.test/p7-thumb.jpg',
'http://img.test/p8-thumb.jpg',
'http://img.test/p9-thumb.jpg',
]);
});
it('clamps the window at both edges without wrapping or extra fetching', () => {
const head = mountStrip(makeItems(12), 'p1');
expect(thumbIds(head)[0]).toBe('http://img.test/p1-thumb.jpg');
expect(thumbIds(head)).toHaveLength(7);
const tail = mountStrip(makeItems(12), 'p12');
expect(thumbIds(tail)).toHaveLength(7);
expect(thumbIds(tail).at(-1)).toBe('http://img.test/p12-thumb.jpg');
// 少于 7 项全量展示(不加载整个相册补位)。
const short = mountStrip(makeItems(3), 'p2');
expect(thumbIds(short)).toHaveLength(3);
});
it('highlights the current item with the primary border and aria-current', () => {
const wrapper = mountStrip(makeItems(5), 'p3');
const current = wrapper.findAll('.thumb')[2];
expect(current.classes()).toContain('current');
expect(current.attributes('aria-current')).toBe('true');
expect(wrapper.findAll('.thumb')[0].attributes('aria-current')).toBeUndefined();
// 无障碍名称带序号(第 {n} 张)。
expect(current.attributes('aria-label')).toBe('第 3 张');
});
it('emits select with the photoId on click (no browser history side effects)', async () => {
const wrapper = mountStrip(makeItems(9), 'p5');
// p5 的 ±3 窗口从 p2 起。
await wrapper.findAll('.thumb')[0].trigger('click');
expect(wrapper.emitted('select')).toEqual([['p2']]);
});
it('scrolls the selected item into view, honoring the reduced-motion preference', async () => {
const scrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoView;
reducedMotion.value = false;
const wrapper = mountStrip(makeItems(12), 'p6');
await nextTick(); // flush:'post' 滚动 watcher 在渲染后微任务执行
expect(scrollIntoView).toHaveBeenCalledWith(
expect.objectContaining({ behavior: 'smooth', inline: 'center', block: 'nearest' }),
);
scrollIntoView.mockClear();
reducedMotion.value = true;
await wrapper.setProps({ currentPhotoId: 'p8' });
expect(scrollIntoView).toHaveBeenCalledWith(
expect.objectContaining({ behavior: 'auto', inline: 'center' }),
);
});
it('hides the overflow fades when the track is not scrollable', () => {
// jsdom 布局为 0:scrollWidth === clientWidth → 两侧均无渐隐提示。
const wrapper = mountStrip(makeItems(3), 'p2');
expect(wrapper.find('.thumb-fade').exists()).toBe(false);
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { positionDisplay } from '../viewer/position';
// UI-02 §5.2 位置计数形态判定(纯数据层):三种真实回退 + 前瞻 total 形态。
// 组件侧当前传 total=null(公开端点游标分页无总数);lightbox.spec 覆盖
// loaded / index / preview 三种模式的端到端渲染。
describe('viewer position display fallbacks (UI-02 §5.2)', () => {
it('shows i / total only when the API provides an accurate total', () => {
expect(positionDisplay({ index: 11, loaded: 24, total: 248, atEnd: false, solo: false })).toEqual({
kind: 'total',
current: 12,
total: 248,
});
});
it('shows the loaded count while more pages remain and no total exists', () => {
expect(positionDisplay({ index: 2, loaded: 24, total: null, atEnd: false, solo: false })).toEqual({
kind: 'loaded',
count: 24,
});
});
it('shows i / n of the filtered sequence once all pages are loaded', () => {
expect(positionDisplay({ index: 4, loaded: 30, total: null, atEnd: true, solo: false })).toEqual({
kind: 'index',
current: 5,
total: 30,
});
});
it('never fabricates a position for unpositioned or solo deep-link entries', () => {
// solo:即使有准确 total,也不知道目标在筛选序列中的自然位置。
expect(positionDisplay({ index: 24, loaded: 25, total: 248, atEnd: true, solo: true })).toEqual({
kind: 'preview',
});
// 下标未知(-1,防御分支)。
expect(positionDisplay({ index: -1, loaded: 3, total: null, atEnd: true, solo: false })).toEqual({
kind: 'preview',
});
});
});
@@ -122,7 +122,7 @@ describe('viewer url sync and deep link (Task 8)', () => {
scrollTo.mockClear();
keydown('Escape');
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/a/tok-view'));
expect(wrapper.find('.lightbox').exists()).toBe(false);
expect(wrapper.find('.photo-viewer').exists()).toBe(false);
expect(scrollTo).toHaveBeenCalledWith(0, 650);
expect(replaceSpy).toHaveBeenCalledTimes(1); // 关闭未额外 replace
});
@@ -137,13 +137,13 @@ describe('viewer url sync and deep link (Task 8)', () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
await wrapper.findAll('.cell')[0].trigger('click');
await flushPromises();
expect(wrapper.find('.lightbox').exists()).toBe(true);
expect(wrapper.find('.photo-viewer').exists()).toBe(true);
scrollTo.mockClear();
router.back(); // 等价浏览器后退(popstate 异步派发)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/a/tok-view'));
await flushPromises();
expect(wrapper.find('.lightbox').exists()).toBe(false);
expect(wrapper.find('.photo-viewer').exists()).toBe(false);
expect(scrollTo).toHaveBeenCalledWith(0, 320);
});
@@ -164,15 +164,15 @@ describe('viewer url sync and deep link (Task 8)', () => {
const wrapper = mountApp();
await flushPromises();
// 单图先行:列表仍在加载,大图已可看(solo 序列 1/1)。
// 单图先行:列表仍在加载,大图已可看(solo 位置未知 → 「照片预览」,§5.2 不伪造排名)。
expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-view', 'p5');
expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/p5-browse.jpg?s=1');
expect(wrapper.find('.counter').text()).toBe('1 / 1');
expect(wrapper.find('.position-indicator').text()).toBe('照片预览');
// 第 1 页到达(无 p5)→ 深链 watcher 追拉第 2 页 → p5 归位自然顺序(5/5)。
resolveFirstPage(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')], 'cur-1'));
await vi.waitFor(() => expect(wrapper.findAll('.cell')).toHaveLength(5));
expect(wrapper.find('.counter').text()).toBe('5 / 5');
expect(wrapper.find('.position-indicator').text()).toBe('5 / 5');
// 序列已构成:左切到前一张。
keydown('ArrowLeft');
@@ -189,7 +189,7 @@ describe('viewer url sync and deep link (Task 8)', () => {
const wrapper = mountApp();
await flushPromises();
expect(wrapper.find('.lightbox').exists()).toBe(false);
expect(wrapper.find('.photo-viewer').exists()).toBe(false);
const notice = wrapper.find('.deep-link-notice[role="alert"]');
expect(notice.text()).toContain('照片不存在或已下线');
expect(notice.text()).not.toContain('PHOTO_NOT_PUBLIC'); // 不裸露错误码