283 lines
20 KiB
JavaScript
283 lines
20 KiB
JavaScript
import {linkShared} from './helpers/shared-modules.mjs';
|
||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||
import {test} from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {readFile} from 'node:fs/promises';
|
||
import ts from 'typescript';
|
||
|
||
async function moduleUrl(path,transform=code=>code){
|
||
const source=await readFile(new URL(path,import.meta.url),'utf8');
|
||
const {outputText}=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||
return 'data:text/javascript;base64,'+Buffer.from(linkShared(transform(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+new URL(path,import.meta.url).pathname).toString('base64');
|
||
}
|
||
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
||
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
|
||
const {ads,promotionRow,renderHome,activeBanners,hero,homeRecommendations,heroFeed,card,coverCornerBadge,topicCard,imageAttributes,blockSection}=await import(renderUrl);
|
||
const drama={id:23456,title:'正常剧集 <script>alert(1)</script>',cover:'https://example.com/poster.webp',tagNames:['都市']};
|
||
const advert={id:'ad-one',title:'有效广告',images:'https://example.com/ad.webp',link:'https://example.com/landing'};
|
||
|
||
const itemCount=html=>(html.match(/class="(?:hg-card|curated-card drama-data-card|ranked-card drama-data-card|topic-recommendation-card)"/g)||[]).length;
|
||
|
||
test('Swagger homepage modules render actual titles, drama cards and theme destinations',()=>{
|
||
const html=renderHome({modules:[{id:31,title:'新接口短剧栏目',slug:'editors',moduleType:1,list:[drama]},{id:32,title:'新接口题材',moduleType:2,topicList:[{id:7,slug:'urban',name:'都市题材',list:[drama]}]}]});
|
||
for(const text of ['新接口短剧栏目','新接口题材','href="/config/editors/"','href="/theme/urban/"','href="/detail/23456/"'])assert.ok(html.includes(text),text);
|
||
assert.equal(homeRecommendations({modules:[{id:31,title:'短剧',moduleType:1,list:[drama]}]})[0].id,drama.id);
|
||
const emptyModules=renderHome({modules:[],homeConfigData:[{id:1,name:'旧缓存不应复活',type:1,status:true,list:[drama]}]});
|
||
assert.ok(!emptyModules.includes('旧缓存不应复活'));
|
||
});
|
||
|
||
test('topic cards escape metadata and carry no analytics attributes',()=>{
|
||
const html=topicCard({id:7,name:'都市 <甜宠>',icon:'https://example.com/topic.png'});
|
||
assert.ok(html.includes('都市 <甜宠>'));assert.ok(!html.includes('data-track'));assert.ok(!html.includes('data-item-'));
|
||
});
|
||
|
||
test('drama cards use the 9:16 gallery layout with episode counter, square badge and views/theme meta',()=>{
|
||
const series=card({id:5,title:'连载剧',totalEpisode:12,updateStatus:1,isFeatured:true,heatValue:12345,themeNames:['都市'],cover:'https://example.com/c.webp'});
|
||
for(const text of ['class="hg-card"','class="hg-card-cover"','>1/12+<','class="hg-card-badge"','cover-corner-badge--hot','class="hg-card-rule"','class="hg-card-views"','1.2万','class="hg-card-theme">都市<'])assert.ok(series.includes(text),text);
|
||
assert.ok(!series.includes('drama-description'));assert.ok(!series.includes('data-track'));
|
||
assert.ok(card({id:6,title:'完结',totalEpisode:30,updateStatus:2}).includes('>1/30<'));
|
||
assert.ok(card({id:7,title:'单集',totalEpisode:1}).includes('>单集<'));
|
||
});
|
||
|
||
test('API images use data sources and local placeholders until decrypted, including video posters',()=>{
|
||
const cases=[card(drama),hero([drama]),topicCard({id:1,icon:drama.cover}),ads([advert])];
|
||
for(const html of cases){
|
||
const images=[...html.matchAll(/<img\b[^>]*>/g)];assert.ok(images.length>0);
|
||
for(const [tag] of images){
|
||
assert.match(tag,/\ssrc="\/placeholder\.png"/);assert.match(tag,/\sdata-src="https:\/\//);
|
||
assert.ok(!/\ssrc="https?:/.test(tag));
|
||
}
|
||
}
|
||
assert.match(imageAttributes(drama.cover,undefined,'poster'),/^poster="\/placeholder\.png" data-poster="https:\/\//);
|
||
assert.equal(imageAttributes('javascript:alert(1)','/icon.svg'),'src="/icon.svg"');
|
||
assert.ok(imageAttributes('https://example.com/a?one=1&two=2').includes('one=1&two=2'));
|
||
});
|
||
|
||
test('ads tolerates null, missing, and non-array API fields',()=>{
|
||
for(const value of [null,undefined,{},'',false,0,'[]'])assert.equal(ads(value),'');
|
||
assert.equal(ads([]),'');
|
||
});
|
||
|
||
test('shared square ad rows keep real ad data and links and omit empty placements',()=>{
|
||
const html=promotionRow([advert,{...advert,id:'ad-two',title:'第二个广告'}]);
|
||
assert.ok(html.includes('class="promo-marquee" data-promotion-row'));
|
||
assert.equal((html.match(/class="promo-tile"/g)||[]).length,2);
|
||
assert.ok(html.includes('alt="第二个广告"'));assert.ok(!html.includes('data-ad-'));
|
||
assert.ok(html.includes('href="https://example.com/landing"'));
|
||
assert.ok(html.includes('width="132" height="132"'));assert.ok(html.includes('alt="有效广告"'));
|
||
for(const value of [null,{},[],[null]])assert.equal(promotionRow(value),'');
|
||
});
|
||
|
||
test('ads ignores malformed entries and keeps valid ads in order with escaped text',()=>{
|
||
const html=ads([null,undefined,false,42,[],{...advert,images:null},{...advert,images:'javascript:alert(1)'},advert,{...advert,id:'two',title:'第二条 <script>',link:'javascript:alert(1)'}]);
|
||
assert.equal((html.match(/class="promo-app"/g)||[]).length,2);
|
||
assert.ok(html.indexOf('有效广告')<html.indexOf('第二条'));
|
||
assert.ok(html.includes('href="https://example.com/landing"'));
|
||
assert.ok(!html.includes('/apps/'));assert.ok(html.includes('<a class="promo-app"><img'));assert.ok(html.includes('第二条 <script>'));
|
||
assert.ok(!html.includes('javascript:'));assert.ok(!html.includes('<script>'));
|
||
});
|
||
|
||
test('home renders when nineGridList is explicitly null, preserving valid content',()=>{
|
||
const html=renderHome({homeBannerData:[drama],homeConfigData:[{id:31,name:'后台栏目',type:1,status:true,list:[drama]}],advitise:{nineGridList:null}});
|
||
assert.ok(html.includes('后台栏目'));assert.ok(html.includes('href="/detail/23456/"'));
|
||
assert.ok(html.includes('正常剧集 <script>'));assert.ok(!html.includes('<script>alert(1)</script>'));
|
||
});
|
||
|
||
test('dynamic home sections share a grid without inheriting the prototype editorial rows',()=>{
|
||
const html=renderHome({homeConfigData:[
|
||
{id:31,name:'必看好剧',status:true,type:1,list:[drama,{...drama,id:2,totalEpisode:10}]},
|
||
{id:32,name:'热播榜',status:true,type:1,list:[{...drama,id:3}]},
|
||
{id:33,name:'剧集',status:true,type:2,videoList:[{...drama,id:4,totalEpisode:8}]},
|
||
{id:34,name:'题材',status:true,type:3,themeList:[{id:8,name:'都市'}]},
|
||
]});
|
||
assert.equal((html.match(/class="topic-section home-block"/g)||[]).length,3);
|
||
assert.equal((html.match(/class="cards"/g)||[]).length,2);
|
||
assert.ok(!html.includes('id="romance-rows"'));assert.ok(!html.includes('romance-editorial'));
|
||
assert.ok(!html.includes('id="library"'));
|
||
for(const id of [31,32,34])assert.ok(html.includes(`href="/config/${id}/"`));
|
||
for(const id of [23456,2,3])assert.ok(html.includes(`href="/detail/${id}/"`));
|
||
assert.ok(!html.includes('href="/video/4/"'));assert.ok(!html.includes('href="/config/33/"'));assert.ok(html.includes('href="/topics/8/"'));
|
||
assert.equal((html.split('id="home-blocks"')[1].match(/class="hg-card-counter"/g)||[]).length,3);
|
||
assert.ok(html.indexOf('必看好剧')<html.indexOf('热播榜'));
|
||
});
|
||
|
||
test('homepage banner ads follow each second topic section and keep real links',()=>{
|
||
const banner=id=>({id,title:'横幅 '+id,images:'https://cdn.example/'+id+'.gif',link:'https://example.com/'+id});
|
||
const block=(id,name)=>({id,name,status:true,type:1,list:[drama]});
|
||
const html=renderHome({homeConfigData:[block(1,'栏目一'),block(2,'栏目二'),block(3,'栏目三'),block(4,'栏目四'),block(5,'栏目五')],advitise:{detailbannerList:[null,banner('a'),{...banner('b'),images:'javascript:alert(1)'},banner('c'),banner('d'),banner('e'),banner('f')]}});
|
||
const blocks=html.split('id="home-blocks"')[1].split('popular-searches')[0];
|
||
assert.equal((blocks.match(/class="wide-ad-row"/g)||[]).length,2);
|
||
assert.ok(blocks.indexOf('栏目二')<blocks.indexOf('横幅 a')&&blocks.indexOf('横幅 a')<blocks.indexOf('横幅 c')&&blocks.indexOf('横幅 c')<blocks.indexOf('栏目三'));
|
||
assert.ok(blocks.indexOf('栏目四')<blocks.indexOf('横幅 d')&&blocks.indexOf('横幅 d')<blocks.indexOf('横幅 e')&&blocks.indexOf('横幅 e')<blocks.indexOf('栏目五'));
|
||
assert.ok(!blocks.includes('横幅 f'));assert.ok(!blocks.includes('横幅 b'));assert.ok(!blocks.includes('javascript:'));
|
||
assert.ok(!blocks.includes('data-ad-'));assert.ok(blocks.includes('href="https://example.com/a"'));
|
||
assert.ok(!renderHome({homeConfigData:[block(1,'栏目一'),block(2,'栏目二')]}).includes('wide-ad-row'));
|
||
assert.ok(!renderHome({homeConfigData:[block(1,'单独栏目')],advitise:{detailbannerList:[banner('a')]}}).includes('wide-ad-row'));
|
||
});
|
||
|
||
test('empty or malformed homepage lists fall back to empty states instead of throwing',()=>{
|
||
for(const value of [null,undefined,{},'',false,1]){
|
||
const html=renderHome({homeBannerData:value,homeConfigData:value,announcements:value,advitise:{nineGridList:value}});
|
||
assert.ok(html.includes('暂无推荐剧集'));assert.ok(html.includes('暂时没有内容'));
|
||
}
|
||
assert.ok(renderHome(null).includes('暂无推荐剧集'));
|
||
});
|
||
|
||
test('homepage category styles follow API values rather than titles, ranks or module positions',()=>{
|
||
const modules=[
|
||
{id:1,slug:'first',title:'热门推荐',moduleType:1,rank:99,style:' d ',list:[{...drama,slug:'first-story'}]},
|
||
{id:2,title:'热门推荐',moduleType:1,rank:1,style:'C',label:'样式C <script>',list:[drama]},
|
||
{id:3,title:'随意命名',moduleType:1,style:'B',list:[drama]},
|
||
{id:4,title:'随意命名',moduleType:1,style:'A',list:[drama]},
|
||
{id:5,title:'随意命名',moduleType:2,style:'E',topicList:[{id:8,slug:'urban',name:'都市'}]},
|
||
];
|
||
const html=renderHome({modules});
|
||
assert.deepEqual([...html.matchAll(/data-home-style="([A-E])"/g)].map(match=>match[1]),['D','C','B','A','E']);
|
||
assert.ok(html.includes('class="topic-section romance-editorial home-block" data-home-style="A"'));
|
||
assert.ok(html.includes('id="library" class="home-block" data-home-style="B"'));
|
||
assert.ok(html.includes('class="topic-section home-block" data-home-style="C"'));
|
||
assert.ok(html.includes('class="topic-section home-block" data-home-style="D"'));
|
||
assert.ok(html.includes('class="topic-section topic-recommendations home-block" data-home-style="E"'));
|
||
assert.ok(html.includes('class="ranked-card drama-data-card"'));assert.ok(html.includes('class="curated-card drama-data-card"'));
|
||
assert.ok(html.includes('href="/detail/first-story/"'));assert.ok(html.includes('href="/config/first/"'));assert.ok(html.includes('href="/theme/urban/"'));
|
||
assert.ok(html.includes('class="pick">最新上架</span>'));assert.ok(!html.includes('样式C'));assert.ok(!html.includes('<script>'));
|
||
});
|
||
|
||
test('A–E previews use API limits while retaining backend order and full source lists',()=>{
|
||
for(const [style,limit] of [['A',3],['B',9],['C',8],['D',7],['E',5]]){
|
||
const list=Array.from({length:14},(_,index)=>({...drama,id:500-index,slug:'story-'+index,countLike:index}));
|
||
const topics=list.map(item=>({id:item.id,name:'专题 '+item.id,slug:item.slug}));
|
||
const block={id:1,name:'任意分类',type:style==='E'?3:1,status:true,style,limit,list,themeList:topics,topicKind:'theme'};
|
||
const html=blockSection(block);
|
||
assert.equal(itemCount(html),limit,style);
|
||
const links=[...html.matchAll(/href="\/(?:detail|theme)\/story-(\d+)\/"/g)].map(match=>Number(match[1]));
|
||
assert.deepEqual(links,Array.from({length:limit},(_,index)=>index),style);
|
||
assert.equal(list.length,14);assert.equal(topics.length,14);
|
||
if(style==='C'){
|
||
assert.equal((html.match(/class="curated-card drama-data-card"/g)||[]).length,limit);
|
||
assert.equal((html.match(/class="watch-link"/g)||[]).length,limit);
|
||
}
|
||
if(style==='D'){
|
||
for(let rank=1;rank<=limit;rank++)assert.ok(html.includes(`aria-label="第 ${rank} 名,查看`));
|
||
assert.ok(html.includes('>01</strong>'));assert.ok(html.includes('>07</strong>'));assert.ok(!html.includes('rank-home-badge'));
|
||
assert.ok(blockSection({...block,id:2,list:[drama]}).includes('aria-label="第 1 名,查看'));
|
||
}
|
||
}
|
||
});
|
||
|
||
test('category limits apply to current and legacy modules, including unknown styles',()=>{
|
||
const list=Array.from({length:14},(_,index)=>({...drama,id:100+index}));
|
||
for(const style of ['A','B','C','D','E','F',undefined]){
|
||
const topic=style==='E';
|
||
const items=topic?list.map(item=>({id:item.id,name:'题材 '+item.id})):list;
|
||
for(const limit of [0,1,9,20]){
|
||
const sources=[{modules:[{id:1,title:'分类',style,limit,moduleType:topic?2:1,list:items,topicList:items}]},
|
||
{homeConfigData:[{id:1,name:'分类',style,limit,type:topic?3:1,status:true,list:items,themeList:items}]}];
|
||
for(const home of sources){
|
||
const html=renderHome(home).split('id="home-blocks"')[1].split('</section>')[0];
|
||
assert.equal(itemCount(html),Math.min(limit,items.length),String(style)+' '+limit);
|
||
if(limit===0)assert.ok(html.includes('暂时没有内容'));
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
test('missing or invalid category limits retain all valid returned items without a style cap',()=>{
|
||
const list=Array.from({length:14},(_,index)=>({...drama,id:100+index}));
|
||
for(const limit of [undefined,null,-1,1.5,NaN,Infinity,'3','',false,{},Number.MAX_SAFE_INTEGER+1]){
|
||
const html=blockSection({id:1,name:'分类',type:1,status:true,style:'B',limit,list:[null,false,...list]});
|
||
assert.equal(itemCount(html),14,String(limit));
|
||
}
|
||
const html=blockSection({id:1,name:'分类',type:1,status:true,style:'C',limit:2,list:[null,list[0],false,list[1],list[2]]});
|
||
assert.equal(itemCount(html),2);
|
||
assert.ok(html.includes('/detail/100/'));assert.ok(html.includes('/detail/101/'));assert.ok(!html.includes('/detail/102/'));
|
||
});
|
||
|
||
test('missing, reserved, malformed and incompatible category styles preserve the default content layout',()=>{
|
||
const list=Array.from({length:12},(_,index)=>({...drama,id:index+1}));
|
||
for(const style of [undefined,'','F','Z','AA',0,{},'E','A" onclick="bad']){
|
||
const html=blockSection({id:1,name:'热门好剧',type:1,status:true,style,list});
|
||
assert.ok(!html.includes('data-home-style='));assert.ok(html.includes('class="cards"'));
|
||
assert.equal((html.match(/class="hg-card"/g)||[]).length,12);
|
||
assert.ok(!html.includes('onclick='));
|
||
}
|
||
const topics=list.map(item=>({id:item.id,name:'专题'}));
|
||
const html=blockSection({id:2,name:'专题推荐',type:3,status:true,style:'D',themeList:topics});
|
||
assert.ok(!html.includes('data-home-style='));
|
||
assert.ok(html.includes('class="topic-section home-block"'));
|
||
assert.ok(!html.includes('topic-recommendations'));
|
||
assert.equal((html.match(/class="topic-recommendation-card"/g)||[]).length,12);
|
||
});
|
||
|
||
test('cover corner badges prefer 原创 over 爆剧 over 上新',()=>{
|
||
assert.equal(coverCornerBadge({isRecommend:true,isFeatured:true,isNew:true}),'<span class="cover-corner-badge cover-corner-badge--original">原创</span>');
|
||
assert.equal(coverCornerBadge({isFeatured:true,isNew:true}),'<span class="cover-corner-badge cover-corner-badge--hot">爆剧</span>');
|
||
assert.equal(coverCornerBadge({isNew:true}),'<span class="cover-corner-badge cover-corner-badge--new">上新</span>');
|
||
assert.equal(coverCornerBadge({}),'');
|
||
assert.ok(card({...drama,isRecommend:true,isFeatured:true,isNew:true}).includes('>原创</span>'));
|
||
assert.ok(!card({...drama,isRecommend:true,isFeatured:true,isNew:true}).includes('爆剧'));
|
||
assert.ok(card({...drama,isFeatured:true,isNew:true}).includes('>爆剧</span>'));
|
||
assert.ok(card({...drama,isNew:true}).includes('>上新</span>'));
|
||
});
|
||
|
||
test('styled category cards keep escaped metadata, native links and empty states',()=>{
|
||
for(const style of ['C','D']){
|
||
const html=blockSection({id:1,name:'分类',type:1,status:true,style,label:'新剧 <b>',list:[null,{...drama,desc:'简介 <script>',recommendTraceId:'trace-123',totalEpisode:36,isNew:true,tagNames:['都市 <img>']}]});
|
||
assert.ok(!html.includes('data-recommend-trace-id'));
|
||
for(const value of ['data-src="https://example.com/poster.webp"','loading="lazy"','cover-corner-badge--new','上新','36集','简介 <script>','都市 <img>'])assert.ok(html.includes(value),style+' '+value);
|
||
assert.ok(!html.includes('<script>'));assert.ok(!html.includes('<b>'));
|
||
if(style==='C'){assert.ok(html.includes('class="pick">最新上架</span>'));assert.ok(!html.includes('新剧 <b>'));assert.ok(!html.includes('class="pick">新剧'));}
|
||
}
|
||
for(const style of ['A','B','C','D','E']){
|
||
const html=blockSection({id:1,name:'空分类',type:style==='E'?3:1,status:true,style,list:null,themeList:[]});
|
||
assert.ok(html.includes('role="status"'));assert.ok(html.includes('暂时没有内容'));
|
||
}
|
||
});
|
||
|
||
test('nested home blocks tolerate invalid rows and non-array child lists',()=>{
|
||
const html=renderHome({homeBannerData:[null,false,drama],announcements:[null,{title:'有效公告'},4],homeConfigData:[null,7,
|
||
{id:31,name:'有效栏目',status:true,type:1,list:[null,drama]},
|
||
{id:32,name:'空视频栏目',status:true,type:2,videoList:{}},
|
||
{id:33,name:'空题材栏目',status:true,type:3,themeList:'bad'},
|
||
{id:34,name:'题材栏目',status:true,type:3,themeList:[null,{id:8,name:'悬疑'}]},
|
||
],advitise:{nineGridList:[null,advert]}});
|
||
assert.ok(!html.includes('空视频栏目'));
|
||
for(const content of ['有效栏目','空题材栏目','有效公告','href="/topics/8/"','href="/detail/23456/"','有效广告'])assert.ok(html.includes(content),content);
|
||
});
|
||
|
||
test('banner selection and card metadata tolerate malformed records without losing valid items',()=>{
|
||
assert.deepEqual(activeBanners({homeBannerData:[null,drama,{...drama,id:2,showOnBanner:true}]}).map(item=>item.id),[2]);
|
||
assert.ok(hero([null,drama]).includes('href="/detail/23456/"'));
|
||
assert.ok(hero(null).includes('暂无推荐剧集'));
|
||
assert.ok(card({...drama,tagNames:'bad',themeNames:['悬疑']}).includes('悬疑'));
|
||
assert.equal(card(null),'');assert.equal(topicCard(null),'');
|
||
});
|
||
|
||
test('carousel frames keep their own titles below the stable page heading, including fade snapshots',()=>{
|
||
const items=[drama,{...drama,id:2,title:'下一部短剧'}];
|
||
for(const selected of [0,1]){
|
||
const frame=hero(items,selected);
|
||
assert.equal((frame.match(/<h2\b/g)||[]).length,1);
|
||
assert.ok(!/<h1\b/.test(frame),'live and outgoing frames must not duplicate the page h1');
|
||
assert.ok(frame.includes(`href="/detail/${items[selected].id}/"`));
|
||
assert.ok(frame.includes(`data-feature="${selected}"`));
|
||
}
|
||
assert.ok(!/<h1\b/.test(hero(null)));
|
||
});
|
||
|
||
|
||
test('homepage landscape recommendations use enabled API content, deduplicate IDs and keep real routes and images',()=>{
|
||
const items=Array.from({length:8},(_,index)=>({...drama,id:index+1,title:'横版推荐 '+index,horizontalCover:'https://example.com/wide-'+index+'.webp',slug:'story-'+index}));
|
||
const home={homeBannerData:[{...drama,id:9}],homeConfigData:[{id:1,name:'关闭栏目',type:1,status:false,list:[{...drama,id:99}]},{id:2,name:'启用栏目',type:1,status:true,list:[items[0],items[0],null,...items.slice(1)]}]};
|
||
const recommendations=homeRecommendations(home);
|
||
assert.deepEqual(recommendations.map(item=>item.id),[1,2,3,4,5,6]);
|
||
const html=heroFeed(recommendations);
|
||
assert.equal((html.match(/class="hero-feed-card drama-data-card"/g)||[]).length,6);
|
||
assert.ok(html.includes('href="/detail/story-0/"'));assert.ok(html.includes('data-src="https://example.com/wide-0.webp"'));
|
||
assert.ok(!html.includes('关闭栏目'));assert.equal(heroFeed(null),'');
|
||
const rendered=renderHome(home);
|
||
assert.ok(rendered.includes('id="hero-feature-carousel"'));assert.ok(rendered.includes('aria-label="精选横版作品"'));
|
||
});
|
||
|
||
|