160e7e5f1a
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>
325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||
import { defineComponent, nextTick, ref } from 'vue';
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import {
|
||
ApiError,
|
||
getPhotoDownload,
|
||
getPublicPhotos,
|
||
type PublicAlbum,
|
||
type PublicPhoto,
|
||
type PublicPhotoDownload,
|
||
type PublicPhotoList,
|
||
} from '../api/client';
|
||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||
import PhotoLightbox from '../components/PhotoLightbox.vue';
|
||
import { usePhotoSequence } from '../feed/sequence';
|
||
import {
|
||
isExpired,
|
||
isWechatWebview,
|
||
useDownload,
|
||
type DownloadController,
|
||
} from '../download/useDownload';
|
||
import { makeAlbum } from './fixtures';
|
||
|
||
// P4a Task 4 下载(T12 收口):开关→入口可见性矩阵、分体菜单选项、微信 UA
|
||
// 判定与引导浮层、过期凭证重取一次、失败提示、复制深链。
|
||
|
||
vi.mock('../api/client', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('../api/client')>();
|
||
return {
|
||
...actual,
|
||
getPublicPhotos: vi.fn(),
|
||
getPublicPhoto: vi.fn(),
|
||
getPhotoDownload: vi.fn(),
|
||
};
|
||
});
|
||
|
||
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
|
||
const mockedGetPhotoDownload = vi.mocked(getPhotoDownload);
|
||
|
||
const WECHAT_IOS_UA =
|
||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.49(0x18003133) NetType/WIFI Language/zh_CN';
|
||
const WECHAT_ANDROID_UA =
|
||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/122.0.0.0 Mobile Safari/537.36 MicroMessenger/8.0.49.2500(0x28004935)';
|
||
const WECHAT_WINDOWS_UA =
|
||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x670c0123) WindowsWechat(0x63090a13)';
|
||
const PLAIN_IOS_SAFARI_UA =
|
||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||
const DESKTOP_CHROME_UA =
|
||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36';
|
||
|
||
function makePhoto(id: string): PublicPhoto {
|
||
return {
|
||
photoId: id,
|
||
width: 800,
|
||
height: 500,
|
||
thumb: { url: `http://img.test/${id}-thumb.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' },
|
||
browse: { url: `http://img.test/${id}-browse.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' },
|
||
likeCount: 0,
|
||
publishedAt: '2026-10-01T00:00:00.000Z',
|
||
};
|
||
}
|
||
|
||
function makeList(items: PublicPhoto[]): PublicPhotoList {
|
||
return { items, nextCursor: null, hasMore: false };
|
||
}
|
||
|
||
function credential(url: string, expiresAt: string, variant: 'browse' | 'original'): PublicPhotoDownload {
|
||
return { url, expiresAt, variant };
|
||
}
|
||
|
||
const FUTURE = new Date(Date.now() + 14 * 60_000).toISOString();
|
||
const PAST = new Date(Date.now() - 60_000).toISOString();
|
||
|
||
function stubUserAgent(ua: string): void {
|
||
Object.defineProperty(window.navigator, 'userAgent', { value: ua, configurable: true });
|
||
}
|
||
|
||
function restoreUserAgent(): void {
|
||
// 删除实例自有属性,回落原型 getter(jsdom 原始 UA)。
|
||
delete (window.navigator as { userAgent?: string }).userAgent;
|
||
}
|
||
|
||
let i18n: ViewerI18n;
|
||
const mounted: VueWrapper[] = [];
|
||
|
||
/** window.open 桩(记录调用即可):经工厂推导 spy 类型,绕开 spyOn 泛型约束。 */
|
||
function spyWindowOpen() {
|
||
return vi.spyOn(window, 'open').mockReturnValue({} as Window);
|
||
}
|
||
let openSpy: ReturnType<typeof spyWindowOpen>;
|
||
|
||
/** 宿主实例形态:setup 暴露 albumRef/currentRef(ref 经代理解包)+ download 控制器。 */
|
||
interface HostVm {
|
||
albumRef: PublicAlbum | null;
|
||
currentRef: string;
|
||
download: DownloadController;
|
||
}
|
||
|
||
/** 宿主:sequence + 相册 ref + 下载控制器 + lightbox(镜像 ActivityPage 组装)。 */
|
||
function mountLightbox(album: PublicAlbum): VueWrapper & { vm: HostVm } {
|
||
const Host = defineComponent({
|
||
components: { PhotoLightbox },
|
||
setup() {
|
||
const sequence = usePhotoSequence({
|
||
token: ref('tok-dl'),
|
||
locale: ref('zh-CN'),
|
||
category: ref(null),
|
||
});
|
||
const albumRef = ref<PublicAlbum | null>(album);
|
||
const current = ref('p1');
|
||
const download = useDownload({
|
||
token: ref('tok-dl'),
|
||
album: () => albumRef.value,
|
||
photoId: () => current.value,
|
||
});
|
||
return { sequence, photoId: current, currentRef: current, download, albumRef };
|
||
},
|
||
template: `<PhotoLightbox v-model:photo-id="photoId" :sequence="sequence" :download="download" />`,
|
||
});
|
||
const wrapper = mount(Host, { global: { plugins: [i18n] } });
|
||
mounted.push(wrapper);
|
||
return wrapper as VueWrapper & { vm: HostVm };
|
||
}
|
||
|
||
describe('photo download (P4a Task 4)', () => {
|
||
beforeEach(() => {
|
||
localStorage.clear();
|
||
i18n = createViewerI18n('zh-CN');
|
||
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')]));
|
||
mockedGetPhotoDownload.mockReset();
|
||
openSpy = spyWindowOpen();
|
||
});
|
||
|
||
afterEach(() => {
|
||
while (mounted.length > 0) {
|
||
mounted.pop()?.unmount();
|
||
}
|
||
restoreUserAgent();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it('detects WeChat webview UAs (iOS/Android/Windows) and passes others', () => {
|
||
for (const ua of [WECHAT_IOS_UA, WECHAT_ANDROID_UA, WECHAT_WINDOWS_UA]) {
|
||
expect(isWechatWebview(ua)).toBe(true);
|
||
}
|
||
for (const ua of [PLAIN_IOS_SAFARI_UA, DESKTOP_CHROME_UA, '']) {
|
||
expect(isWechatWebview(ua)).toBe(false);
|
||
}
|
||
});
|
||
|
||
it('treats expiresAt at-or-before now as expired', () => {
|
||
expect(isExpired(PAST)).toBe(true);
|
||
expect(isExpired(new Date().toISOString())).toBe(true);
|
||
expect(isExpired(FUTURE)).toBe(false);
|
||
expect(isExpired('not-a-date')).toBe(false); // 非法时间戳不误判过期
|
||
});
|
||
|
||
it('controller model: switch matrix incl. off/on cascade (original implies browse)', async () => {
|
||
const wrapper = mountLightbox(makeAlbum());
|
||
await flushPromises();
|
||
const controller = wrapper.vm.download;
|
||
|
||
// on/off(夹具缺省)。
|
||
expect(controller.canDownload).toBe(true);
|
||
expect(controller.canDownloadOriginal).toBe(false);
|
||
|
||
const cases: Array<[boolean, boolean, boolean, boolean]> = [
|
||
// [downloadEnabled, originalDownloadEnabled, canDownload, canDownloadOriginal]
|
||
[true, true, true, true],
|
||
[true, false, true, false],
|
||
[false, false, false, false],
|
||
[false, true, false, false], // 级联:browse 关则 original 项不出现
|
||
];
|
||
for (const [downloadEnabled, originalDownloadEnabled, can, canOriginal] of cases) {
|
||
wrapper.vm.albumRef = makeAlbum({ downloadEnabled, originalDownloadEnabled });
|
||
await flushPromises();
|
||
expect(controller.canDownload).toBe(can);
|
||
expect(controller.canDownloadOriginal).toBe(canOriginal);
|
||
}
|
||
});
|
||
|
||
it('toolbar visibility: off/on → no entry; on/off → single button; on/on → split', async () => {
|
||
// off/off:无下载入口。
|
||
const off = mountLightbox(makeAlbum({ downloadEnabled: false, originalDownloadEnabled: false }));
|
||
await flushPromises();
|
||
expect(off.find('.download-group').exists()).toBe(false);
|
||
|
||
// on/off:单按钮,无 caret/菜单。
|
||
const browseOnly = mountLightbox(makeAlbum());
|
||
await flushPromises();
|
||
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('.download-caret').exists()).toBe(true);
|
||
expect(split.find('.download-menu').exists()).toBe(false);
|
||
// 主按钮 aria 展开 = false。
|
||
expect(split.find('.download-caret').attributes('aria-expanded')).toBe('false');
|
||
});
|
||
|
||
it('split menu offers both variants; picking original requests the original endpoint', async () => {
|
||
mockedGetPhotoDownload.mockResolvedValue(credential('http://img.test/p1-original.jpg', FUTURE, 'original'));
|
||
const wrapper = mountLightbox(makeAlbum({ originalDownloadEnabled: true }));
|
||
await flushPromises();
|
||
|
||
await wrapper.find('.download-caret').trigger('click');
|
||
const items = wrapper.findAll('.download-menu button');
|
||
expect(items.map((item) => item.text())).toEqual(['浏览图', '原图']);
|
||
|
||
await items[1]!.trigger('click');
|
||
await flushPromises();
|
||
expect(mockedGetPhotoDownload).toHaveBeenCalledWith('tok-dl', 'p1', 'original');
|
||
expect(openSpy).toHaveBeenCalledWith('http://img.test/p1-original.jpg', '_blank', 'noopener,noreferrer');
|
||
// 选择后菜单收起。
|
||
expect(wrapper.find('.download-menu').exists()).toBe(false);
|
||
});
|
||
|
||
it('main button downloads browse; expired credential is re-requested exactly once', async () => {
|
||
// 首响应已过期(缓存/时钟漂移)→ 重取一次 → 打开第二个 URL。
|
||
mockedGetPhotoDownload
|
||
.mockResolvedValueOnce(credential('http://img.test/p1-browse-stale.jpg', PAST, 'browse'))
|
||
.mockResolvedValueOnce(credential('http://img.test/p1-browse-fresh.jpg', FUTURE, 'browse'));
|
||
const wrapper = mountLightbox(makeAlbum());
|
||
await flushPromises();
|
||
|
||
await wrapper.find('.download-main').trigger('click');
|
||
await flushPromises();
|
||
expect(mockedGetPhotoDownload).toHaveBeenCalledTimes(2);
|
||
expect(mockedGetPhotoDownload).toHaveBeenCalledWith('tok-dl', 'p1', 'browse');
|
||
expect(openSpy).toHaveBeenCalledWith('http://img.test/p1-browse-fresh.jpg', '_blank', 'noopener,noreferrer');
|
||
});
|
||
|
||
it('fresh credential opens directly without a second request; failure shows a retryable notice', async () => {
|
||
mockedGetPhotoDownload.mockResolvedValue(credential('http://img.test/p1-browse.jpg', FUTURE, 'browse'));
|
||
const wrapper = mountLightbox(makeAlbum());
|
||
await flushPromises();
|
||
|
||
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');
|
||
expect(wrapper.find('.download-failed').exists()).toBe(false);
|
||
|
||
// 端点失败(网络/403 等)→ 失败提示可再见(再次点击重试)。
|
||
openSpy.mockClear();
|
||
mockedGetPhotoDownload.mockReset().mockRejectedValue(new Error('boom'));
|
||
await wrapper.find('.download-main').trigger('click');
|
||
await flushPromises();
|
||
expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('下载失败');
|
||
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(
|
||
new ApiError('DOWNLOAD_DISABLED', 'downloads disabled', 403, null),
|
||
);
|
||
const wrapper = mountLightbox(makeAlbum());
|
||
await flushPromises();
|
||
|
||
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('下载失败');
|
||
});
|
||
|
||
it('WeChat webview: click opens the guidance overlay instead of hitting the endpoint', async () => {
|
||
stubUserAgent(WECHAT_IOS_UA);
|
||
const wrapper = mountLightbox(makeAlbum());
|
||
await flushPromises();
|
||
|
||
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('保存图片指引');
|
||
expect(mockedGetPhotoDownload).not.toHaveBeenCalled();
|
||
expect(openSpy).not.toHaveBeenCalled();
|
||
|
||
// 知道了:浮层收起(再点击可重新触发)。
|
||
await wrapper.find('.guide-dismiss').trigger('click');
|
||
expect(wrapper.find('.wechat-guide').exists()).toBe(false);
|
||
});
|
||
|
||
it('guidance copy link writes the current photo deep link with feedback', async () => {
|
||
stubUserAgent(WECHAT_IOS_UA);
|
||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||
Object.defineProperty(window.navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||
const wrapper = mountLightbox(makeAlbum());
|
||
await flushPromises();
|
||
|
||
await wrapper.find('.download-main').trigger('click');
|
||
await wrapper.find('.guide-copy').trigger('click');
|
||
await flushPromises();
|
||
expect(writeText).toHaveBeenCalledWith(window.location.href);
|
||
expect(wrapper.find('.copy-status').text()).toBe('已复制链接');
|
||
});
|
||
});
|