feat(viewer): UI-02 heart particles, interaction notices, share and download
Task 5 (V05 §5.4/§6): decorative heart particles on the lightbox stage (local like clicks + others' WS bursts for the current photo), compact cross-photo interaction notices above the action bar (click to locate), navigator.share-first share orchestration with clipboard fallback for album and photo deep links (lang param), and download failures attributed to the photo that triggered them. Self-echo dedup stays in the realtime router (own batches never produce particles/notices); particles cap at 12 with visibility/unmount cleanup and are suppressed under reduced motion; notices cap at 3 with ~4s auto-dismiss and throttled SR summary. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,7 @@ describe('album header info area (UI-02 V02)', () => {
|
||||
clientSpy?.mockRestore();
|
||||
clientSpy = null;
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
Reflect.deleteProperty(navigator, 'share');
|
||||
});
|
||||
|
||||
it('renders banner, title, multi-line description, Intl view count, venue and sort/category toolbar', () => {
|
||||
@@ -179,22 +180,39 @@ describe('album header info area (UI-02 V02)', () => {
|
||||
expect(wrapper.emitted('selectCategory')).toEqual([['cat-open'], [null]]);
|
||||
});
|
||||
|
||||
it('share copies the album url to the clipboard and reports success; failure keeps a selectable url', async () => {
|
||||
it('share falls back to the clipboard with the album url (lang param) and reports success; failure keeps a selectable url', async () => {
|
||||
const writeText = stubClipboard(() => Promise.resolve());
|
||||
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
|
||||
|
||||
await wrapper.find('.share-btn').trigger('click');
|
||||
await Promise.resolve();
|
||||
expect(writeText).toHaveBeenCalledWith('http://localhost:3000/a/tokfixture0000000000aaaa');
|
||||
expect(writeText).toHaveBeenCalledWith('http://localhost:3000/a/tokfixture0000000000aaaa?lang=zh-CN');
|
||||
expect(wrapper.find('.share-status').text()).toContain('链接已复制');
|
||||
expect(wrapper.find('.share-url').exists()).toBe(false);
|
||||
|
||||
// 复制失败:轻提示 + 可选中文本(§5.4 回退语义,完整 navigator.share 属 Task 5)。
|
||||
// 复制失败:轻提示 + 可选中文本(§5.4 回退语义)。
|
||||
writeText.mockImplementation(() => Promise.reject(new Error('denied')));
|
||||
await wrapper.find('.share-btn').trigger('click');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(wrapper.find('.share-status').text()).toContain('复制失败');
|
||||
expect(wrapper.find('.share-url').text()).toBe('http://localhost:3000/a/tokfixture0000000000aaaa');
|
||||
expect(wrapper.find('.share-url').text()).toBe('http://localhost:3000/a/tokfixture0000000000aaaa?lang=zh-CN');
|
||||
});
|
||||
|
||||
it('share uses navigator.share with the album title when available and stays silent on success', async () => {
|
||||
const share = vi.fn(() => Promise.resolve());
|
||||
Object.defineProperty(navigator, 'share', { value: share, configurable: true });
|
||||
const writeText = stubClipboard(() => Promise.resolve());
|
||||
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
|
||||
|
||||
await wrapper.find('.share-btn').trigger('click');
|
||||
await Promise.resolve();
|
||||
expect(share).toHaveBeenCalledWith({
|
||||
title: '2026 公司年会',
|
||||
url: 'http://localhost:3000/a/tokfixture0000000000aaaa?lang=zh-CN',
|
||||
});
|
||||
// 原生分享成功:无额外轻提示(面板已收尾,§5.4),也不落剪贴板。
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
expect(wrapper.find('.share-status').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { defineComponent, nextTick, ref } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ApiError,
|
||||
@@ -89,9 +89,10 @@ function spyWindowOpen() {
|
||||
}
|
||||
let openSpy: ReturnType<typeof spyWindowOpen>;
|
||||
|
||||
/** 宿主实例形态:setup 暴露 albumRef(ref 经代理解包)+ download 控制器。 */
|
||||
/** 宿主实例形态:setup 暴露 albumRef/currentRef(ref 经代理解包)+ download 控制器。 */
|
||||
interface HostVm {
|
||||
albumRef: PublicAlbum | null;
|
||||
currentRef: string;
|
||||
download: DownloadController;
|
||||
}
|
||||
|
||||
@@ -112,7 +113,7 @@ function mountLightbox(album: PublicAlbum): VueWrapper & { vm: HostVm } {
|
||||
album: () => albumRef.value,
|
||||
photoId: () => current.value,
|
||||
});
|
||||
return { sequence, photoId: current, download, albumRef };
|
||||
return { sequence, photoId: current, currentRef: current, download, albumRef };
|
||||
},
|
||||
template: `<PhotoLightbox v-model:photo-id="photoId" :sequence="sequence" :download="download" />`,
|
||||
});
|
||||
@@ -251,6 +252,31 @@ describe('photo download (P4a Task 4)', () => {
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('attributes an async failure to the photo that triggered the download, not the current one', async () => {
|
||||
// 在途请求挂起期间切图:错误归属触发时的 photoId(UI-02 §5.4)。
|
||||
let rejectDownload!: (error: Error) => void;
|
||||
mockedGetPhotoDownload.mockReset().mockImplementation(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectDownload = reject;
|
||||
}),
|
||||
);
|
||||
const wrapper = mountLightbox(makeAlbum());
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('.download-main').trigger('click'); // p1 触发
|
||||
wrapper.vm.currentRef = 'p2'; // 请求在途时切到 p2
|
||||
rejectDownload(new Error('boom'));
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.vm.download.failedFor).toBe('p1');
|
||||
expect(wrapper.find('.download-failed').exists()).toBe(false); // p2 不背锅
|
||||
|
||||
wrapper.vm.currentRef = 'p1'; // 切回 p1:错误提示可见
|
||||
await nextTick();
|
||||
expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('下载失败');
|
||||
});
|
||||
|
||||
it('DOWNLOAD_DISABLED surfaces the dedicated localized notice (T15 漏译修复)', async () => {
|
||||
// 相册中途关闭下载开关(载荷仍开但端点 403)→ 专属文案而非通用失败。
|
||||
mockedGetPhotoDownload.mockReset().mockRejectedValue(
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import HeartParticles from '../components/viewer/HeartParticles.vue';
|
||||
import { setReducedMotion } from '../ui/prefs';
|
||||
|
||||
// UI-02 V05 HeartParticles 单测(§6.2/§6.3):burst 生成粒子上限 12(超限弃
|
||||
// 最旧)、动画时长后自清理、页面隐藏即清空、「减少动画」时不生成/开启即清空。
|
||||
// 粒子为纯装饰层(aria-hidden + pointer-events:none)。
|
||||
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
interface ParticlesExposed {
|
||||
burst: (count: number) => void;
|
||||
}
|
||||
|
||||
function mountParticles(): VueWrapper & { vm: ParticlesExposed } {
|
||||
const wrapper = mount(HeartParticles);
|
||||
mounted.push(wrapper);
|
||||
return wrapper as VueWrapper & { vm: ParticlesExposed };
|
||||
}
|
||||
|
||||
function setHidden(hidden: boolean): void {
|
||||
Object.defineProperty(document, 'hidden', { value: hidden, configurable: true });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}
|
||||
|
||||
describe('heart particles (UI-02 V05)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setReducedMotion(false);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) mounted.pop()?.unmount();
|
||||
vi.useRealTimers();
|
||||
setReducedMotion(false);
|
||||
setHidden(false);
|
||||
});
|
||||
|
||||
it('creates particles on burst and removes them after the float animation', async () => {
|
||||
const wrapper = mountParticles();
|
||||
|
||||
wrapper.vm.burst(3);
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(3);
|
||||
|
||||
// 动画时长上限 1.8s + 收尾缓冲:之后自清理(不留冻结的过期粒子)。
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(wrapper.findAll('.particle')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('caps concurrent particles at 12, dropping the oldest', async () => {
|
||||
const wrapper = mountParticles();
|
||||
|
||||
wrapper.vm.burst(10);
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.particle')).toHaveLength(10);
|
||||
|
||||
wrapper.vm.burst(5);
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.particle')).toHaveLength(12);
|
||||
});
|
||||
|
||||
it('clears particles and timers when the page becomes hidden', async () => {
|
||||
const wrapper = mountParticles();
|
||||
|
||||
wrapper.vm.burst(6);
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.particle')).toHaveLength(6);
|
||||
|
||||
setHidden(true);
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.particle')).toHaveLength(0);
|
||||
|
||||
// 隐藏期间清理的计时器不再复活(§6.3 不回放)。
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(wrapper.findAll('.particle')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not render particles under reduced motion, and clears in-flight ones when it turns on', async () => {
|
||||
const wrapper = mountParticles();
|
||||
|
||||
setReducedMotion(true);
|
||||
wrapper.vm.burst(4);
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.particle')).toHaveLength(0); // 不漂浮(§6.3)
|
||||
|
||||
setReducedMotion(false);
|
||||
wrapper.vm.burst(2);
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.particle')).toHaveLength(2);
|
||||
|
||||
// 漂浮中途开启「减少动画」:在屏粒子立即清空(不残留冻结心形)。
|
||||
setReducedMotion(true);
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.particle')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import InteractionNotice from '../components/viewer/InteractionNotice.vue';
|
||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
|
||||
// UI-02 V05 InteractionNotice 单测(§6.2/§6.3):他人点赞紧凑通知(缩略图 +
|
||||
// ♥ + delta)、点击定位上抛 select、同屏至多 3 条(超限弃最旧)、约 4s 自动
|
||||
// 消散、页面隐藏即清空;列表不 aria-live(读屏节流汇总,§6.3)。
|
||||
|
||||
let i18n: ViewerI18n;
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
interface NoticeExposed {
|
||||
notify: (entry: { photoId: string; delta: number; thumbUrl: string | null }) => void;
|
||||
}
|
||||
|
||||
function mountNotice(): VueWrapper & { vm: NoticeExposed } {
|
||||
const wrapper = mount(InteractionNotice, { global: { plugins: [i18n] } });
|
||||
mounted.push(wrapper);
|
||||
return wrapper as VueWrapper & { vm: NoticeExposed };
|
||||
}
|
||||
|
||||
function setHidden(hidden: boolean): void {
|
||||
Object.defineProperty(document, 'hidden', { value: hidden, configurable: true });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}
|
||||
|
||||
function entry(photoId: string, delta: number) {
|
||||
return { photoId, delta, thumbUrl: `http://img.test/${photoId}-thumb.jpg` };
|
||||
}
|
||||
|
||||
describe('interaction notice (UI-02 V05)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) mounted.pop()?.unmount();
|
||||
vi.useRealTimers();
|
||||
setHidden(false);
|
||||
});
|
||||
|
||||
it('renders a compact notice with thumbnail, heart and +delta; click emits select', async () => {
|
||||
const wrapper = mountNotice();
|
||||
|
||||
wrapper.vm.notify(entry('p2', 3));
|
||||
await nextTick();
|
||||
|
||||
const notice = wrapper.find('.notice');
|
||||
expect(notice.exists()).toBe(true);
|
||||
expect(notice.find('.notice-thumb').attributes('src')).toBe('http://img.test/p2-thumb.jpg');
|
||||
expect(notice.find('.notice-heart').text()).toBe('♥');
|
||||
expect(notice.find('.notice-delta').text()).toBe('+3');
|
||||
// 列表本身不 aria-live(§6.3 取消逐条播报;节流汇总另置 role=status)。
|
||||
expect(wrapper.find('.interaction-notice-zone').attributes('aria-live')).toBeUndefined();
|
||||
expect(wrapper.find('.sr-summary').attributes('role')).toBe('status');
|
||||
|
||||
await notice.trigger('click');
|
||||
expect(wrapper.emitted('select')).toEqual([['p2']]);
|
||||
});
|
||||
|
||||
it('caps concurrent notices at 3, dropping the oldest', async () => {
|
||||
const wrapper = mountNotice();
|
||||
|
||||
wrapper.vm.notify(entry('p2', 1));
|
||||
wrapper.vm.notify(entry('p3', 2));
|
||||
wrapper.vm.notify(entry('p4', 4));
|
||||
wrapper.vm.notify(entry('p5', 8));
|
||||
await nextTick();
|
||||
|
||||
const deltas = wrapper.findAll('.notice-delta').map((delta) => delta.text());
|
||||
expect(deltas).toEqual(['+2', '+4', '+8']); // 最旧的 +1 被丢弃
|
||||
});
|
||||
|
||||
it('auto-dismisses notices after ~4s', async () => {
|
||||
const wrapper = mountNotice();
|
||||
|
||||
wrapper.vm.notify(entry('p2', 1));
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.notice')).toHaveLength(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
expect(wrapper.findAll('.notice')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clears notices when the page becomes hidden', async () => {
|
||||
const wrapper = mountNotice();
|
||||
|
||||
wrapper.vm.notify(entry('p2', 1));
|
||||
wrapper.vm.notify(entry('p3', 2));
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.notice')).toHaveLength(2);
|
||||
|
||||
setHidden(true);
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.notice')).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(wrapper.findAll('.notice')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -2,32 +2,39 @@ 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 { defineComponent, nextTick, reactive, ref, type Ref } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ApiError,
|
||||
getPublicPhoto,
|
||||
getPublicPhotos,
|
||||
postLikeBatch,
|
||||
type PublicPhoto,
|
||||
type PublicPhotoList,
|
||||
} from '../api/client';
|
||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
import PhotoLightbox from '../components/PhotoLightbox.vue';
|
||||
import type { DownloadController } from '../download/useDownload';
|
||||
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
|
||||
import { useLikes } from '../likes/useLikes';
|
||||
import type { LikeBubble } from '../realtime/useAlbumRealtime';
|
||||
|
||||
// PhotoLightbox 组件测试(Task 8 业务 + UI-02 V04 深色布局):
|
||||
// browse 渲染/导航/序列扩展、wheel 缩放与双击复位、Esc/关闭/舞台空白点击关闭、
|
||||
// 指针轻扫切换、签名过期静默重取与 404 移除上报;V04 新增:位置计数三种
|
||||
// 真实回退、缩略图条渲染与选择、更多菜单、焦点管理、body 滚动锁、安全区。
|
||||
// 深链解析与 URL 同步在 viewer-route.spec(真实路由)覆盖。
|
||||
// 真实回退、缩略图条渲染与选择、更多菜单、焦点管理、body 滚动锁、安全区;
|
||||
// V05 新增(独立 describe):本地点赞/他人点赞 → 心形粒子与跨照片互动提示
|
||||
// 分层、navigator.share 分享与剪贴板回退、下载失败归属照片。深链解析与 URL
|
||||
// 同步在 viewer-route.spec(真实路由)覆盖。
|
||||
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return { ...actual, getPublicPhotos: vi.fn(), getPublicPhoto: vi.fn() };
|
||||
return { ...actual, getPublicPhotos: vi.fn(), getPublicPhoto: vi.fn(), postLikeBatch: vi.fn() };
|
||||
});
|
||||
|
||||
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
|
||||
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
|
||||
const mockedPostLikeBatch = vi.mocked(postLikeBatch);
|
||||
|
||||
function makePhoto(id: string, likeCount = 0): PublicPhoto {
|
||||
return {
|
||||
@@ -448,3 +455,164 @@ describe('photo lightbox dark viewer (UI-02 V04)', () => {
|
||||
expect(source).toContain('env(safe-area-inset-bottom, 0px)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('photo lightbox interactions (UI-02 V05)', () => {
|
||||
interface V05Options {
|
||||
bubbles?: LikeBubble[];
|
||||
download?: unknown;
|
||||
albumTitle?: string;
|
||||
shareUrl?: string;
|
||||
}
|
||||
|
||||
/** 宿主:sequence + likes + 实时气泡 ref + v-model photoId(镜像 ActivityPage
|
||||
* 的 V05 组装:粒子/互动提示/分享深链)。 */
|
||||
function mountV05(
|
||||
photoId: string,
|
||||
options: V05Options = {},
|
||||
): { wrapper: VueWrapper; bubbles: Ref<LikeBubble[]>; current: Ref<string> } {
|
||||
let bubbles!: Ref<LikeBubble[]>;
|
||||
let current!: Ref<string>;
|
||||
const Host = defineComponent({
|
||||
components: { PhotoLightbox },
|
||||
setup() {
|
||||
const sequence = usePhotoSequence({
|
||||
token: ref('tok-lb'),
|
||||
locale: ref('zh-CN'),
|
||||
category: ref(null),
|
||||
});
|
||||
const likes = useLikes({ token: ref('tok-lb'), photos: () => sequence.photos });
|
||||
bubbles = ref(options.bubbles ?? []);
|
||||
current = ref(photoId);
|
||||
return {
|
||||
sequence,
|
||||
likes,
|
||||
bubbles,
|
||||
current,
|
||||
albumTitle: options.albumTitle,
|
||||
shareUrl: options.shareUrl,
|
||||
download: options.download,
|
||||
};
|
||||
},
|
||||
template: `<PhotoLightbox
|
||||
v-model:photo-id="current"
|
||||
:sequence="sequence"
|
||||
:likes="likes"
|
||||
:like-bubbles="bubbles"
|
||||
:album-title="albumTitle"
|
||||
:share-url="shareUrl"
|
||||
:download="download"
|
||||
/>`,
|
||||
});
|
||||
const wrapper = mount(Host, { global: { plugins: [i18n] } });
|
||||
mounted.push(wrapper);
|
||||
return { wrapper, bubbles, current };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')]));
|
||||
mockedGetPublicPhoto.mockReset();
|
||||
mockedPostLikeBatch.mockReset().mockResolvedValue({ total: 0, countVersion: 1, batchAccepted: true });
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) {
|
||||
mounted.pop()?.unmount();
|
||||
}
|
||||
Reflect.deleteProperty(navigator, 'share');
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
vi.restoreAllMocks();
|
||||
document.body.style.overflow = '';
|
||||
document.documentElement.className = '';
|
||||
});
|
||||
|
||||
it('bursts heart particles for a local like click on the current photo', async () => {
|
||||
const { wrapper } = mountV05('p1');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(0);
|
||||
await wrapper.find('.like-slot .action-btn').trigger('click');
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('routes realtime bursts: particles for the current photo, a notice for others; notice click navigates', async () => {
|
||||
const { wrapper, bubbles, current } = mountV05('p1');
|
||||
await flushPromises();
|
||||
|
||||
// 当前照片的他人点赞(router 已滤自身回声)→ 舞台右区粒子。
|
||||
bubbles.value = [...bubbles.value, { key: 1, photoId: 'p1', delta: 5, thumbUrl: null }];
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(2);
|
||||
|
||||
// 其他照片 → 互动提示(缩略图 + ♥ + delta),不新增当前照片粒子。
|
||||
bubbles.value = [...bubbles.value, { key: 2, photoId: 'p2', delta: 3, thumbUrl: 'http://img.test/p2-thumb.jpg' }];
|
||||
await nextTick();
|
||||
const notice = wrapper.find('.interaction-notice-zone .notice');
|
||||
expect(notice.exists()).toBe(true);
|
||||
expect(notice.find('.notice-thumb').attributes('src')).toBe('http://img.test/p2-thumb.jpg');
|
||||
expect(notice.find('.notice-delta').text()).toBe('+3');
|
||||
expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(2);
|
||||
|
||||
// 点击定位:与缩略图条共用入口(update:photoId,序列内导航)。
|
||||
await notice.trigger('click');
|
||||
expect(current.value).toBe('p2');
|
||||
});
|
||||
|
||||
it('shares via navigator.share with the album title and photo deep link; success is silent', async () => {
|
||||
const share = vi.fn(() => Promise.resolve());
|
||||
Object.defineProperty(navigator, 'share', { value: share, configurable: true });
|
||||
const { wrapper } = mountV05('p1', {
|
||||
albumTitle: '2026 公司年会',
|
||||
shareUrl: '/a/tok-lb/p/p1?lang=zh-CN',
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('button[aria-label="分享照片"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(share).toHaveBeenCalledWith({ title: '2026 公司年会', url: '/a/tok-lb/p/p1?lang=zh-CN' });
|
||||
// 原生分享成功:无应用内轻提示(面板已收尾,§5.4)。
|
||||
expect(wrapper.find('.share-status').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the clipboard with feedback when native share is unavailable', async () => {
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||
const { wrapper } = mountV05('p1', { shareUrl: '/a/tok-lb/p/p1?lang=zh-CN' });
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('button[aria-label="分享照片"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(writeText).toHaveBeenCalledWith('/a/tok-lb/p/p1?lang=zh-CN');
|
||||
expect(wrapper.find('.share-status').text()).toContain('链接已复制');
|
||||
});
|
||||
|
||||
it('shows the download failure only on the photo that triggered it', async () => {
|
||||
// 控制器桩:失败已归属 p1(useDownload.failedFor,§5.4 异步错误归属)。
|
||||
const download = reactive({
|
||||
busy: false,
|
||||
failed: 'download.failed',
|
||||
failedFor: 'p1',
|
||||
wechatGuide: false,
|
||||
copyNotice: '',
|
||||
canDownload: true,
|
||||
canDownloadOriginal: false,
|
||||
}) as unknown as DownloadController;
|
||||
const { wrapper, current } = mountV05('p1', { download });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.download-failed').text()).toContain('下载失败');
|
||||
|
||||
// 切到 p2:p1 的下载错误不张冠李戴到当前照片。
|
||||
current.value = 'p2';
|
||||
await nextTick();
|
||||
expect(wrapper.find('.download-failed').exists()).toBe(false);
|
||||
|
||||
// 切回 p1:错误提示仍归属原照片(可再见)。
|
||||
current.value = 'p1';
|
||||
await nextTick();
|
||||
expect(wrapper.find('.download-failed').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,13 +14,11 @@ 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] },
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { copyText } from '../support/clipboard';
|
||||
import { shareOrCopy } from '../support/share';
|
||||
|
||||
// UI-02 V05 分享编排单测(§5.4):navigator.share 优先(成功即原生面板收尾);
|
||||
// AbortError = 用户取消(静默,不落剪贴板);其他失败/不可用回退剪贴板;
|
||||
// 复制失败返回 failed(调用方展示可选中文本)。
|
||||
|
||||
vi.mock('../support/clipboard', () => ({ copyText: vi.fn() }));
|
||||
|
||||
const mockedCopyText = vi.mocked(copyText);
|
||||
|
||||
function stubShare(implementation: () => Promise<void>): ReturnType<typeof vi.fn> {
|
||||
const fn = vi.fn(implementation);
|
||||
Object.defineProperty(navigator, 'share', { value: fn, configurable: true });
|
||||
return fn;
|
||||
}
|
||||
|
||||
describe('share orchestration (UI-02 V05)', () => {
|
||||
beforeEach(() => {
|
||||
mockedCopyText.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(navigator, 'share');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses navigator.share when available and does not touch the clipboard', async () => {
|
||||
const share = stubShare(() => Promise.resolve());
|
||||
|
||||
const outcome = await shareOrCopy({ title: 'Annual Party', url: 'https://x.test/a/tok?lang=en' });
|
||||
expect(outcome).toBe('shared');
|
||||
expect(share).toHaveBeenCalledWith({ title: 'Annual Party', url: 'https://x.test/a/tok?lang=en' });
|
||||
expect(mockedCopyText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats AbortError as a silent cancel (no error, no clipboard fallback)', async () => {
|
||||
const share = stubShare(() => {
|
||||
const error = new Error('The operation was aborted.');
|
||||
error.name = 'AbortError';
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
const outcome = await shareOrCopy({ title: 't', url: 'https://x.test/a/tok' });
|
||||
expect(outcome).toBe('cancelled');
|
||||
expect(share).toHaveBeenCalledTimes(1);
|
||||
expect(mockedCopyText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the clipboard when navigator.share rejects with another error', async () => {
|
||||
stubShare(() => Promise.reject(new TypeError('Not allowed to share')));
|
||||
mockedCopyText.mockResolvedValue(true);
|
||||
|
||||
const outcome = await shareOrCopy({ title: 't', url: 'https://x.test/a/tok' });
|
||||
expect(outcome).toBe('copied');
|
||||
expect(mockedCopyText).toHaveBeenCalledWith('https://x.test/a/tok');
|
||||
});
|
||||
|
||||
it('falls back to the clipboard without navigator.share; reports failure when copying fails', async () => {
|
||||
expect('share' in navigator).toBe(false); // jsdom 默认无 navigator.share
|
||||
|
||||
mockedCopyText.mockResolvedValueOnce(true);
|
||||
await expect(shareOrCopy({ title: 't', url: 'https://x.test/a/tok' })).resolves.toBe('copied');
|
||||
|
||||
mockedCopyText.mockResolvedValueOnce(false);
|
||||
await expect(shareOrCopy({ title: 't', url: 'https://x.test/a/tok' })).resolves.toBe('failed');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user