Files
2026-11-06 00:13:27 +07:00

138 lines
10 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(file,replace=code=>code){
const filename=new URL(file,import.meta.url);
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
return 'data:text/javascript;base64,'+Buffer.from(linkShared(replace(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
}
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
const searchUrl=await moduleUrl('../src/lib/client/search.ts',code=>code.replace("'../api/protocol'",JSON.stringify(protocolUrl)).replace("'./render'",JSON.stringify(renderUrl)));
const routingUrl=await moduleUrl('../src/lib/routing.ts');
const {loadSearchResults,searchResultsMarkup,searchErrorMarkup,searchSuggestionsMarkup,searchPicksMarkup,SEARCH_PAGE_SIZE}=await import(searchUrl);
const {highlightSearchTitle}=await import(renderUrl);
const {paginationPath,searchPath,parseSitePath}=await import(routingUrl);
const signal=()=>new AbortController().signal;
const drama={id:7,title:'SEO 标题',playName:'都市 <短剧>',cover:'https://cdn.example/encrypted.webp',totalEpisode:12};
test('search uses reference API parameters, authoritative totals and actual playName without local filtering',async()=>{
const calls=[];
const result=await loadSearchResults({get:async(path,query,requestSignal)=>{calls.push({path,query,requestSignal});return {keyword:'都市剧',list:[null,drama,{id:8,title:'服务端判定的相关作品'}],totalCount:102,pageSize:25};}},' 都市 ',3,signal());
assert.equal(SEARCH_PAGE_SIZE,25);assert.equal(calls.length,1);assert.equal(calls[0].path,'playlet/search');assert.deepEqual(calls[0].query,{keyword:'都市',pageNum:3,pageSize:25});
assert.equal(result.keyword,'都市剧');assert.equal(result.total,102);assert.equal(result.page,3);assert.equal(result.pageSize,25);
assert.equal(result.list[0].title,'都市 <短剧>');assert.equal(result.list.length,2);assert.deepEqual(result.recommended,[]);
});
test('independent search page links preserve encoded keywords and use the response page size',async()=>{
const keyword='都市 / 爱情?#%';
const result=await loadSearchResults({get:async()=>({list:[drama],totalCount:61,pageSize:10})},keyword,2,signal());
const base=searchPath(keyword,2);
const html=searchResultsMarkup(result,page=>paginationPath(base,page));
for(const page of [1,3,7])assert.ok(html.includes('href="'+searchPath(keyword,page)+'"'));
assert.ok(html.includes('name="page" min="1" max="7"'));assert.ok(html.includes('共 <strong>61</strong> 个结果'));assert.ok(html.includes('data-src="https://cdn.example/encrypted.webp"'));
assert.ok(!html.includes('SEO 标题'));assert.ok(html.includes('都市 &lt;短剧&gt;'));assert.ok(!html.includes('#search/'));
});
test('empty results request six hottest works and keep them separate from the search count',async()=>{
const calls=[];
const result=await loadSearchResults({get:async(path,query)=>{
calls.push({path,query});
if(path==='playlet/search')return {list:null,totalCount:0};
return {list:[null,{id:0,title:'无效'},drama,drama,...Array.from({length:7},(_,i)=>({...drama,id:i+10}))]};
}},'没有匹配的关键词',1,signal());
assert.deepEqual(calls[1],{path:'playlet/operate/list',query:{pageNum:1,pageSize:6,type:2}});
assert.equal(result.recommended.length,6);assert.equal(new Set(result.recommended.map(item=>item.id)).size,6);assert.equal(result.total,0);assert.deepEqual(result.list,[]);
const html=searchResultsMarkup(result,()=>assert.fail('empty first page has no pagination'));
assert.ok(html.includes('共 <strong>0</strong> 个结果'));assert.ok(html.includes('搜索无结果'));assert.ok(html.includes('f2ef01f956fdcd80.png'));
assert.ok(html.includes('大家都在看'));assert.ok(html.includes('aria-label="热门推荐"'));assert.ok(html.includes('href="/shorts/"'));
});
test('empty entry skips the search endpoint; unavailable recommendations never replace an empty result with an error',async()=>{
const calls=[];
const result=await loadSearchResults({get:async(path,query)=>{calls.push({path,query});throw new Error('recommendation unavailable');}},' ',1,signal());
assert.deepEqual(calls.map(call=>call.path),['playlet/operate/list']);assert.equal(result.keyword,'');assert.deepEqual(result.recommended,[]);
const html=searchResultsMarkup(result,()=>assert.fail('empty entry cannot paginate'));
assert.ok(html.includes('搜索你想看的短剧'));assert.ok(html.includes('输入剧名、演员或标签'));assert.ok(!html.includes('大家都在看'));assert.ok(!html.includes('recommendation unavailable'));
});
test('search failures have their own retry state and never masquerade as zero matches',async()=>{
const calls=[];
await assert.rejects(loadSearchResults({get:async path=>{calls.push(path);throw new Error('搜索服务超时');}},'原名',1,signal()),/搜索服务超时/);
assert.deepEqual(calls,['playlet/search']);
const html=searchErrorMarkup('<script>','接口失败 <img>');
assert.ok(html.includes('搜索暂时不可用'));assert.ok(html.includes('接口失败 &lt;img&gt;'));assert.ok(html.includes('data-api-action="retry"'));assert.ok(!html.includes('共 <strong>0'));
});
test('leaving a search page ignores late search and recommendation responses',async()=>{
for(const stage of ['search','recommendation']){
const controller=new AbortController(),calls=[];let finish;
const pending=loadSearchResults({get:async path=>{
calls.push(path);
if(stage==='recommendation'&&path==='playlet/search')return {list:[],totalCount:0};
return new Promise(resolve=>finish=resolve);
}},'旧搜索',1,controller.signal);
await new Promise(resolve=>setImmediate(resolve));controller.abort();finish({list:[drama],totalCount:1});
await assert.rejects(pending,error=>error.name==='AbortError');
assert.deepEqual(calls,stage==='search'?['playlet/search']:['playlet/search','playlet/operate/list']);
}
});
test('HTML-style highlighting handles full-width letters, repeated matches, spaces, emoji and escaped markup',()=>{
const html=highlightSearchTitle('😀AI 爱 情 / ai爱情 <script>','ai 爱情');
assert.equal((html.match(/class="search-keyword"/g)||[]).length,2);assert.ok(html.includes('>AI 爱 情</span>'));assert.ok(html.includes('>ai爱情</span>'));assert.ok(html.includes('😀'));assert.ok(html.includes('&lt;script&gt;'));
assert.equal(highlightSearchTitle('<img>',' '),'&lt;img&gt;');assert.equal(highlightSearchTitle('不相关标题','爱情'),'不相关标题');
assert.equal(highlightSearchTitle('爱情爱情','爱情'),'<span class="search-keyword">爱情</span><span class="search-keyword">爱情</span>');
});
test('empty searches use server everyoneWatching without another recommendation request',async()=>{
const calls=[];
const result=await loadSearchResults({get:async(path,query)=>{calls.push({path,query});return {list:[],totalCount:0,everyoneWatching:[drama,drama,{...drama,id:8}]};}},'空结果',1,signal());
assert.deepEqual(calls.map(call=>call.path),['playlet/search']);assert.deepEqual(result.recommended.map(item=>item.id),[7,8]);assert.equal(result.total,0);
});
test('search suggestions open the returned drama while hot keywords open search routes',()=>{
const suggestions=searchSuggestionsMarkup([drama],'都市');
assert.ok(suggestions.includes('href="/detail/7/"'));assert.ok(suggestions.includes('都市 &lt;短剧&gt;'));assert.ok(!suggestions.includes('SEO 标题'));
assert.ok(searchSuggestionsMarkup([{keyword:'都市 / 爱情'}],'').includes('href="'+searchPath('都市 / 爱情')+'"'));
for(const value of [null,{},[null]])assert.ok(searchSuggestionsMarkup(value,'词').includes('暂无搜索建议'));
});
test('dropdown recommendations keep compact cards and real detail links without analytics or full-page metadata',()=>{
const html=searchPicksMarkup([null,...Array.from({length:4},(_,index)=>({...drama,id:7+index,slug:'story-'+index,isNew:true,desc:'简介 <script>',countLike:99}))]);
assert.equal((html.match(/class="search-pick"/g)||[]).length,3);
assert.ok(html.includes('href="/detail/story-0/"'));assert.ok(!html.includes('data-track'));
assert.ok(html.includes('class="search-pick-cover"'));assert.ok(html.includes('width="160" height="240"'));
assert.ok(html.includes('data-src="https://cdn.example/encrypted.webp"'));assert.ok(html.includes('alt="SEO 标题封面"'));
assert.ok(html.includes('简介 &lt;script&gt;'));assert.ok(html.includes('cover-corner-badge--new'));assert.ok(html.includes('>上新</span>'));assert.ok(html.includes('12集'));
assert.ok(searchPicksMarkup([{...drama,isRecommend:true,isFeatured:true,isNew:true}]).includes('>原创</span>'));
assert.ok(searchPicksMarkup([{...drama,isFeatured:true,isNew:true}]).includes('>爆剧</span>'));
assert.ok(!html.includes('drama-data-card'));assert.ok(!html.includes('drama-meta'));
assert.equal(searchPicksMarkup(null),'');
});
test('search routes encode keywords once in the path and retain exact special characters through pagination',()=>{
for(const keyword of ['都市 / 爱情 & 100% #1','字面%2F保持原样','😀+a?b=c']){
for(const page of [1,2,7]){
const address=searchPath(keyword,page),url=new URL(address,'https://drama.test');
assert.equal(url.pathname,'/search/'+encodeURIComponent(keyword)+'/'+(page>1?`page/${page}/`:''));
assert.equal(url.search,'');
assert.deepEqual(parseSitePath(url.pathname,url.search),{group:'search',key:keyword,...(page>1?{page}:{})});
assert.equal(paginationPath(address,3),searchPath(keyword,3));
}
}
assert.equal(searchPath(' '),'/search/');
assert.throws(()=>searchPath('',2));
assert.throws(()=>searchPath('词',0));
assert.equal(paginationPath('/search/'+encodeURIComponent('都市爱情')+'/',2),searchPath('都市爱情',2));
assert.equal(paginationPath('/search/?q='+encodeURIComponent('都市爱情')+'&source=menu',2),searchPath('都市爱情',2)+'?source=menu');
const suggestions=searchSuggestionsMarkup([{keyword:'都市 & 爱情'}],'');
assert.ok(suggestions.includes('href="'+searchPath('都市 & 爱情')+'"'));
});