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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user