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:
@@ -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);
|
||||
|
||||
@@ -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'); // 不裸露错误码
|
||||
|
||||
Reference in New Issue
Block a user