2026-09-20 18:32:34 +08:00
import { flushPromises , mount , type VueWrapper } from '@vue/test-utils' ;
2026-09-21 22:20:19 +08:00
import { defineComponent , nextTick , ref } from 'vue' ;
2026-09-20 18:32:34 +08:00
import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest' ;
import {
2026-09-20 23:44:27 +08:00
ApiError ,
2026-09-20 18:32:34 +08:00
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 >;
2026-09-21 22:20:19 +08:00
/** 宿主实例形态:setup 暴露 albumRef/currentRef( ref 经代理解包)+ download 控制器。 */
2026-09-20 18:32:34 +08:00
interface HostVm {
albumRef : PublicAlbum | null ;
2026-09-21 22:20:19 +08:00
currentRef : string ;
2026-09-20 18:32:34 +08:00
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 ,
});
2026-09-21 22:20:19 +08:00
return { sequence , photoId : current , currentRef : current , download , albumRef };
2026-09-20 18:32:34 +08:00
},
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 ();
2026-09-21 21:58:09 +08:00
expect ( browseOnly . find ( '.download-main' ). exists ()). toBe ( true );
expect ( browseOnly . find ( '.download-caret' ). exists ()). toBe ( false );
2026-09-20 18:32:34 +08:00
// on/on:分体按钮(caret 出现,菜单默认收起)。
const split = mountLightbox ( makeAlbum ({ originalDownloadEnabled : true }));
await flushPromises ();
2026-09-21 21:58:09 +08:00
expect ( split . find ( '.download-caret' ). exists ()). toBe ( true );
2026-09-20 18:32:34 +08:00
expect ( split . find ( '.download-menu' ). exists ()). toBe ( false );
// 主按钮 aria 展开 = false。
2026-09-21 21:58:09 +08:00
expect ( split . find ( '.download-caret' ). attributes ( 'aria-expanded' )). toBe ( 'false' );
2026-09-20 18:32:34 +08:00
});
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 ();
2026-09-21 21:58:09 +08:00
await wrapper . find ( '.download-caret' ). trigger ( 'click' );
2026-09-20 18:32:34 +08:00
const items = wrapper . findAll ( '.download-menu button' );
2026-09-21 21:58:09 +08:00
expect ( items . map (( item ) => item . text ())). toEqual ([ '浏览图' , '原图' ]);
2026-09-20 18:32:34 +08:00
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 ();
2026-09-21 21:58:09 +08:00
await wrapper . find ( '.download-main' ). trigger ( 'click' );
2026-09-20 18:32:34 +08:00
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 ();
2026-09-21 21:58:09 +08:00
await wrapper . find ( '.download-main' ). trigger ( 'click' );
2026-09-20 18:32:34 +08:00
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' ));
2026-09-21 21:58:09 +08:00
await wrapper . find ( '.download-main' ). trigger ( 'click' );
2026-09-20 18:32:34 +08:00
await flushPromises ();
expect ( wrapper . find ( '.download-failed[role="alert"]' ). text ()). toContain ( '下载失败' );
expect ( openSpy ). not . toHaveBeenCalled ();
});
2026-09-21 22:20:19 +08:00
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 ( '下载失败' );
});
2026-09-20 23:44:27 +08:00
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 ();
2026-09-21 21:58:09 +08:00
await wrapper . find ( '.download-main' ). trigger ( 'click' );
2026-09-20 23:44:27 +08:00
await flushPromises ();
expect ( wrapper . find ( '.download-failed[role="alert"]' ). text ()). toContain ( '该相册未开放下载' );
expect ( wrapper . find ( '.download-failed' ). text ()). not . toContain ( '下载失败' );
});
2026-09-20 18:32:34 +08:00
it ( 'WeChat webview: click opens the guidance overlay instead of hitting the endpoint' , async () => {
stubUserAgent ( WECHAT_IOS_UA );
const wrapper = mountLightbox ( makeAlbum ());
await flushPromises ();
2026-09-21 21:58:09 +08:00
await wrapper . find ( '.download-main' ). trigger ( 'click' );
2026-09-20 18:32:34 +08:00
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 ();
2026-09-21 21:58:09 +08:00
await wrapper . find ( '.download-main' ). trigger ( 'click' );
2026-09-20 18:32:34 +08:00
await wrapper . find ( '.guide-copy' ). trigger ( 'click' );
await flushPromises ();
expect ( writeText ). toHaveBeenCalledWith ( window . location . href );
expect ( wrapper . find ( '.copy-status' ). text ()). toBe ( '已复制链接' );
});
});