Files
photo-live/apps/viewer/src/__tests__/download.spec.ts
T
浅念 160e7e5f1a 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>
2026-09-21 22:20:19 +08:00

325 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('已复制链接');
});
});