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(); 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; /** 宿主实例形态: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(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: ``, }); 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('已复制链接'); }); });