Files
web_huakui_web/tests/pages.test.ts
T
谢宇宁andClaude Opus 5.5 d2ddb1c321 [Ai] feat: 花魁短剧 Next.js 站点初始提交
- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:33:31 +07:00

87 lines
9.0 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 test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createContext } from '../src/server/context';
import { buildPage, matchPageRoute } from '../src/server/pages';
import { list } from '../src/server/pages/catalog';
import { pagination } from '../src/server/pages/pagination';
import { pageRoutes } from '../src/server/pages/routes';
import { play } from '../src/server/pages/playback';
import { applyLegacyContract } from '../src/server/services/contracts';
import { choose, normalizePayload, playURL, positive, profileImage, videoURL } from '../src/server/services/model';
const ctx = (path: string) => {
const context=createContext(new Request('http://localhost'+path,{headers:{cookie:'token=test-session; isRegister=1'}}));
context.user={userId:7,accountNo:'member',recharge:12345,isRegister:true,type:1};context.state.set('auth:fresh',true);return context;
};
test('legacy route families retain exact dynamic parameters and reject repeated slashes',()=>{
const routes:[string,string,Record<string,string>][]=[
['/','Index',{}],['/list/drama/2/3/page/4/','List',{theme:'drama',updateStatus:'2',sortType:'3',page:'4'}],
['/series/anime/12/2/page/3/','Series',{media:'anime',topic:'12',sortType:'2',page:'3'}],
['/more-list/recommend/1/drama/page/2/','MoreList',{configId:'recommend',timeRange:'1',theme:'drama',page:'2'}],
['/more-series-list/recommend/page/2/','MoreSeriesList',{configId:'recommend',page:'2'}],
['/topic/sort/2/page/3/','Topic',{sortType:'2',page:'3'}],
['/topic-detail/title/sort/3/page/2/','TopicDetail',{slug:'title',sortType:'3',page:'2'}],
['/tags/tag/sort/2/page/4/','Tags',{slug:'tag',sortType:'2',page:'4'}],
['/search/%E7%9F%AD%E5%89%A7/page/2/','Search',{q:'短剧',page:'2'}],
['/play/drama/100/recommendations/page/2/','Play',{slug:'drama',ep:'100',guessPage:'2'}],
['/series-play/video/recommendations/page/2/','SeriesPlay',{slug:'video',guessPage:'2'}],
['/popular-recommend/3/page/2/','PopularRecommend',{rankType:'3',page:'2'}],
['/rank/2/page/3/','Rank',{sortOrder:'2',page:'3'}],['/recently-added/page/3/','RecentlyAdded',{page:'3'}],
];
for(const [path,handler,params]of routes)assert.deepEqual(matchPageRoute(path),{handler,params},path);
for(const path of ['/favorites/chapter/page/3/','/history/page/2/','/purchased/page/2/','/profile/','/setting/','/recharge/','/login/','/register/'])assert.equal(matchPageRoute(path),null,path);
for(const path of ['/list//','//list/','/settings//','/list/theme/extra/','/list/drama/2/3/page/2/extra/','/series/anime/'])assert.equal(matchPageRoute(path),null,path);
});
test('the 58 surviving original public HTML route registrations resolve with unchanged parameter names',()=>{
const removedAccountHandlers=new Set(['Profile','History','Favorites','PurchasedSeries','Setting','Recharge','LoginPage','RegisterPage']);
const legacy=JSON.parse(readFileSync(new URL('./fixtures/legacy-page-routes.json',import.meta.url),'utf8')) as [string,string][];
assert.equal(legacy.length,71);const original=legacy.filter(([,handler])=>!removedAccountHandlers.has(handler));assert.equal(original.length,58);
assert.deepEqual(pageRoutes.filter(([,handler])=>handler!=='HomeRoad'),original);assert.equal(pageRoutes.some(([,handler])=>removedAccountHandlers.has(handler)),false);
assert.deepEqual(pageRoutes.filter(([,handler])=>handler==='HomeRoad'),[['/home-road/','HomeRoad']]);
for(const path of ['/home-road/','/return-home/'])assert.deepEqual(matchPageRoute(path),{handler:'HomeRoad',params:{}},path);
const values:Record<string,string>={q:'短剧',theme:'drama',slug:'drama',configId:'recommend',media:'anime',topic:'12',updateStatus:'2',sortType:'3',sortOrder:'2',rankType:'3',timeRange:'1',page:'2',guessPage:'2',ep:'100',filter:'chapter'};
for(const [pattern,handler]of original){const params:Record<string,string>={};const path=pattern.replace(/:(\w+)/g,(_,name)=>{params[name]=values[name];return encodeURIComponent(values[name]);});assert.deepEqual(matchPageRoute(path),{handler,params},pattern);}
});
test('pagination keeps source zero fields and route numbers use decimal integer parsing',()=>{
const small=pagination(n=>'/page/'+n+'/',2,25,0);assert.equal(small.Page,0);assert.equal(small.TotalPages,0);assert.equal(small.Visible,false);assert.equal(small.Items,null);
const many=pagination(n=>'/page/'+n+'/',50,25,250);assert.equal(many.Page,10);assert.equal(many.HasNext,false);assert.equal(many.PrevURL,'/page/9/');assert.equal(many.Items.at(-1).Active,true);
assert.equal(choose('+2',[2,3],1),2);for(const value of ['2.0','2e0','0x2'])assert.equal(choose(value,[2,3],1),1);assert.equal(positive('+2'),2);
});
test('API model aliases preserve JSON fields, latest mutable values and chapter gates',()=>{
const raw=normalizePayload({id:10,videoUrl:'signed-media.m3u8',seoInfo:'{"webTitle":"页面","webKeyworld":"a,b;a"}',payType:1,needUnlock:true,costCoins:25});
assert.equal(raw.ID,10);assert.equal(raw.VideoURL,'signed-media.m3u8');assert.deepEqual(raw.seoInfo.keywords,['a','b']);assert.equal(raw.NeedCoinGate,true);assert.equal(raw.AllowWatch,false);assert.equal(raw.UnlockCost,25);
raw.seoInfo.title='新标题';assert.equal(raw.seoInfo.Title,'新标题');assert.equal(JSON.stringify(raw).includes('"VideoURL"'),false);
});
test('missing upstream fields retain legacy Go zero values rather than blank attributes',()=>{
const detail=normalizePayload(applyLegacyContract('/api/app/playlet/detail',{id:10,playletChapterInfo:[{id:100}]}));
assert.equal(detail.CountLike,0);assert.equal(detail.IsLike,0);assert.equal(detail.Preview,'');assert.equal(detail.ThemeNames,null);assert.equal(detail.PlayletChapterInfo[0].MediaTime,0);assert.equal(detail.PlayletChapterInfo[0].NeedUnlock,false);assert.equal(detail.PlayletChapterInfo[0].AllowWatch,true);
});
test('list and playback page loaders preserve filters, canonical paths, locked media and tracking',async()=>{
const originalFetch=globalThis.fetch,originalUpstream=process.env.UPSTREAM_API_URL;process.env.UPSTREAM_API_URL='http://page-contract-test';const calls:{url:URL;headers:Headers}[]=[];
globalThis.fetch=async(input,init)=>{
const url=new URL(String(input));calls.push({url,headers:new Headers(init?.headers)});let data:any={list:[],totalCount:0};
if(url.pathname==='/api/app/theme/list')data={list:[{id:12,slug:'romance',name:'爱情',status:true,rank:1}]};
if(url.pathname==='/api/app/playlet/all/list')data={list:[{id:10,title:'剧'}],totalCount:100,pageNum:Number(url.searchParams.get('pageNum')),pageSize:Number(url.searchParams.get('pageSize')),seoInfo:{title:'全部短剧'}};
if(url.pathname==='/api/app/playlet/detail')data={id:10,slug:'drama',playName:'剧名',desc:'剧情介绍',themeIds:[12],themeNames:['爱情'],tagIds:[1],tagNames:['甜宠'],totalEpisode:2,preview:'preview.m3u8',playletChapterInfo:[{id:101,currentEpisode:1,payType:0},{id:102,currentEpisode:2,payType:1,needUnlock:true,costCoins:20}],seoInfo:{keywords:['剧名']}};
if(url.pathname==='/api/app/playlet/chapter/detail')data={id:102,currentEpisode:2,payType:1,needUnlock:true,canWatch:false,videoUrl:'protected.m3u8',costCoins:20,balance:12345,mediaTime:60};
if(url.pathname==='/api/app/recharge/package/list')data={vipList:[],coinList:[]};
return Response.json({code:200,data});
};
try{
const listed=await list(ctx('/list/12/2/3/page/2/'),{theme:'12',updateStatus:'2',sortType:'3',page:'2'});
assert.equal(listed.themeId,12);assert.equal(listed.themeSlug,'romance');assert.equal(listed.canonicalPath,'/list/romance/2/3/page/2/');assert.equal(listed.pagination.NextURL,'/list/romance/2/3/page/3/');assert.equal(listed.pageHeading,'爱情短剧');
const catalogue=calls.find(call=>call.url.pathname==='/api/app/playlet/all/list')!;assert.equal(catalogue.url.searchParams.get('themeSlug'),'romance');assert.equal(catalogue.headers.has('authorization'),false);
const played=await play(ctx('/play/drama/102/'),{slug:'drama',ep:'102'});assert.ok(played);assert.equal(played.chapterID,102);assert.equal(played.episode,2);assert.equal(played.canWatch,false);assert.equal(played.mainURL,'');assert.equal(played.previewURL,'preview.m3u8');assert.equal(played.balanceText,'12,345');assert.equal(played.seoInfo.title,'剧名 - 第二集');assert.equal(played.canonicalPath,'/drama/drama/');assert.equal(played.trackerDrama.VideoID,102);assert.equal(played.trackerDrama.DramaTypeID,'12');assert.equal(played.comments,undefined);assert.ok(!calls.some(call=>call.url.pathname==='/api/app/comment/list'));
const chapter=calls.find(call=>call.url.pathname==='/api/app/playlet/chapter/detail')!;assert.equal(chapter.url.searchParams.get('chapterId'),'102');assert.equal(chapter.headers.get('authorization'),'test-session');
assert.equal(await play(ctx('/play/drama/999/'),{slug:'drama',ep:'999'}),null);
for(const path of ['/search/?q=legacy','/search/?page=2','/play/drama/?guessPage=2','/favorites/?filter=all','/favorites/invalid/','/history/page/10001/','/settings//'])assert.equal((await buildPage(ctx(path))).status,404,path);
}finally{globalThis.fetch=originalFetch;if(originalUpstream===undefined)delete process.env.UPSTREAM_API_URL;else process.env.UPSTREAM_API_URL=originalUpstream;}
});