Files
web_hgdjc_h5/tests/page-layouts.test.mjs
T
谢宇宁andClaude Opus 5.5 f65d10e998 Initial commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:07:59 +07:00

221 lines
18 KiB
JavaScript
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 {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {File as NodeFile} from 'node:buffer';
import {readFile} from 'node:fs/promises';
import ts from 'typescript';
if(typeof globalThis.File==='undefined')globalThis.File=NodeFile;
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(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 authUrl=await moduleUrl('../src/lib/client/auth.ts');
const trackingUrl=await moduleUrl('../src/lib/tracking.ts');
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../tracking'",JSON.stringify(trackingUrl)));
const feedbackUrl=await moduleUrl('../src/lib/client/feedback.ts',code=>code.replace('"./render"',JSON.stringify(renderUrl)));
const {card}=await import(renderUrl);
const {accountMenuMarkup,settingsMarkup,memberMarkup,renderMember}=await import(accountUrl);
const {feedbackPayload,feedbackMarkup,renderFeedbackPage}=await import(feedbackUrl);
const flush=()=>new Promise(resolve=>setImmediate(resolve));
test('ranking cards retain original poster frames and global positions, and search highlights only escaped titles',()=>{
const drama={id:7,title:'测试 <短剧>',cover:'https://cdn.example/cover.webp',totalEpisode:10};
const ranked=card(drama,false,{rank:25});
assert.ok(ranked.includes('class="ranking-poster-frame"'));assert.ok(ranked.includes('data-rank="25"'));assert.ok(ranked.includes('第 25 名'));assert.ok(ranked.includes('>25</span>'));
assert.ok(ranked.includes('href="/detail/7/"'));assert.ok(ranked.includes('data-src="https://cdn.example/cover.webp"'));
assert.ok(card(drama,false,{rank:1}).includes('>01</span>'));
assert.ok(!card(drama,false,{rank:-1}).includes('rank-home-badge'));
const search=card(drama,false,{keyword:'<短剧>'});
assert.ok(search.includes('<span class="search-keyword">&lt;短剧&gt;</span>'));assert.ok(!search.includes('<短剧>'));
assert.ok(search.includes('aria-label="查看测试 &lt;短剧&gt;"'));
});
test('personal pages use actual library data and route links inside the original summary and tabs',async()=>{
const oldDocument=globalThis.document,root={innerHTML:''},calls=[];
const user={userId:88,nickName:'真实昵称 <script>',recharge:2400,avatar:'https://cdn.example/avatar.webp'};
const app={signal:new AbortController().signal,home:{},api:{ensureSession:async()=>user,get:async(path,query)=>{calls.push({path,query});return {list:[{id:7,title:'已看过的短剧'}],totalCount:72};}}};
globalThis.document={getElementById:()=>root};
try{
await renderMember(app,'center',1,()=>'/member/center/');
assert.equal(calls.at(-1).path,'playlet/history/list');
for(const className of ['member-name-row','member-settings-link','member-wallet','coin-soft-star','member-library-tabs','member-library-toolbar','member-library-cards','member-library-item'])assert.ok(root.innerHTML.includes(className),className);
assert.ok(root.innerHTML.includes('真实昵称 &lt;script&gt;'));assert.ok(root.innerHTML.includes('2,400'));assert.ok(root.innerHTML.includes('ID:88'));
assert.ok(root.innerHTML.includes('href="/member/settings/"'));assert.ok(root.innerHTML.includes('href="/member/history/page/2/"'));assert.ok(!root.innerHTML.includes('/member/center/page/'));
assert.ok(root.innerHTML.includes('href="/member/favorites/"'));assert.ok(root.innerHTML.includes('href="/member/likes/"'));assert.ok(root.innerHTML.includes('href="/member/purchased/"'));
await renderMember(app,'favorites',2,page=>`/member/favorites/page/${page}/`);
assert.equal(calls.at(-1).path,'playlet/collect/list');assert.equal(calls.at(-1).query.pageNum,2);
assert.ok(root.innerHTML.includes('href="/member/favorites/" aria-current="page"'));
assert.ok(!root.innerHTML.includes('吴秀英'));assert.ok(!root.innerHTML.includes('1,280'));
const icons=Array.from({length:18},(_,index)=>({id:`icon-${index}`,title:`图标${String(index).padStart(2,'0')}`,images:`https://cdn.example/${index}.png`,link:`https://example.com/${index}`}));
const marked=memberMarkup(user,'center',{list:[],totalCount:0},{advitise:{nineGridList:[null,{id:'bad',title:'坏图',images:'javascript:alert(1)'},...icons]}});
assert.equal((marked.match(/class="member-ad-tile"/g)||[]).length,16);
assert.ok(marked.indexOf('member-ad-grid')<marked.indexOf('member-summary'));
assert.ok(marked.includes('图标00'));assert.ok(marked.includes('图标15'));
assert.ok(!marked.includes('图标16'));assert.ok(!marked.includes('javascript:'));
assert.ok(marked.includes('data-ad-type="icon"'));assert.ok(marked.includes('width="76"'));
assert.ok(!memberMarkup(user,'center',{}).includes('member-ad-grid'));
assert.ok(!memberMarkup(user,'settings',{},{advitise:{nineGridList:icons}}).includes('member-ad-tile'));
}finally{globalThis.document=oldDocument;}
});
test('likes use the protected list endpoint, native pagination and the reference library markup',async()=>{
const previous=globalThis.document,root={innerHTML:''},calls=[];
const user={userId:8,nickName:'点赞用户'};
const app={signal:new AbortController().signal,home:{},api:{ensureSession:async()=>user,get:async(path,query)=>{
calls.push({path,query});return {list:[{id:7,slug:'liked-story',title:'点赞 <短剧>',totalEpisode:8}],totalCount:72};
}}};
globalThis.document={getElementById:()=>root};
try{
await renderMember(app,'likes',2,page=>page===1?'/member/likes/':`/member/likes/page/${page}/`);
assert.deepEqual(calls.at(-1),{path:'playlet/like/list',query:{pageNum:2,pageSize:24}});
assert.ok(root.innerHTML.includes('href="/member/likes/" aria-current="page"><h2 id="member-library-title"'));
assert.ok(root.innerHTML.includes('共 72 部短剧'));assert.ok(root.innerHTML.includes('href="/member/likes/page/3/"'));
assert.ok(root.innerHTML.includes('href="/detail/liked-story/"'));assert.ok(root.innerHTML.includes('点赞 &lt;短剧&gt;'));
for(const tab of ['history','favorites','likes','purchased'])assert.ok(root.innerHTML.includes(`href="/member/${tab}/"`));
const initial={user,list:{list:[],totalCount:0}};calls.length=0;
await renderMember(app,'likes',1,()=>'/member/likes/',initial);
assert.equal(calls.length,0,'SSR data must be reused without a second list request');
assert.ok(root.innerHTML.includes('member-library-empty'));assert.ok(root.innerHTML.includes('暂无点赞的短剧'));
assert.ok(root.innerHTML.includes('href="/shorts/">去发现好剧</a>'));
assert.ok(!memberMarkup(user,'likes',{list:null,totalCount:0}).includes('undefined'));
}finally{globalThis.document=previous;}
});
test('the account menu exposes a native likes entry for guests and registered users',()=>{
for(const user of [null,{userId:8,nickName:'用户 <script>',isRegister:true}]){
const html=accountMenuMarkup(user);
assert.ok(html.includes('href="/member/likes/"'));
assert.ok(html.includes('我的点赞'));
assert.ok(html.includes('class="profile-row"'));
assert.ok(html.includes('account-collection-icon'));
assert.ok(html.includes('data-auth-mode="login"'));
assert.ok(html.includes('data-auth-mode="bind"'));
assert.ok(html.includes('class="profile-switch"'));
assert.ok(html.indexOf('/member/favorites/')<html.indexOf('/member/likes/'));
assert.ok(html.indexOf('/member/likes/')<html.indexOf('/member/history/'));
assert.ok(html.indexOf('/member/history/')<html.indexOf('/info/deposit/'));
assert.ok(html.indexOf('/info/deposit/')<html.indexOf('data-auth-mode="switch"'));
assert.ok(!html.includes('已购剧集'));
assert.ok(!html.includes('账号设置'));
assert.ok(!html.includes('退出登录'));
assert.ok(!html.includes('data-logout'));
assert.ok(!html.includes('<script>'));
if(user)assert.ok(html.includes('用户 &lt;script&gt;'));
}
const counted=accountMenuMarkup({userId:8,nickName:'甲',recharge:1280},{collectCount:3,likeCount:5});
assert.ok(counted.includes('id="profile-saved-count">3'));
assert.ok(counted.includes('id="profile-likes-count">5'));
assert.ok(!counted.includes('profile-watch-count'));
assert.ok(!counted.includes('profile-bought-count'));
assert.ok(counted.includes('id="profile-coin-count">1,280')||counted.includes('id="profile-coin-count">1280'));
assert.ok(!counted.includes('data-logout'));
});
test('settings preserve actual profile fields and auth actions inside the reference cards',()=>{
const html=settingsMarkup({userId:8,nickName:'用户 <script>',introduction:'简介 <img>',avatar:'https://cdn.example/avatar.webp',isRegister:true,hasPassword:true});
for(const text of ['member-settings-card','member-nickname-editor','id="member-nickname-form"','账号与安全','data-member-settings="bind"','data-auth-mode="register"','data-member-settings="switch"','data-auth-mode="switch"','ui-chevron-right','maxlength="20"','支持 1–20 个字符'])assert.ok(html.includes(text),text);
assert.ok(html.includes('value="用户 &lt;script&gt;"'));assert.ok(html.includes('data-bind-state>已绑定'));
assert.ok(settingsMarkup({userId:9,nickName:'游客',type:0,isRegister:false,hasPassword:false}).includes('data-bind-state>未绑定'));
assert.ok(settingsMarkup({userId:10,nickName:'会员',type:1}).includes('data-bind-state>已绑定'));
assert.ok(!html.includes('个人简介'));assert.ok(!html.includes('data-logout'));
assert.ok(!html.includes('修改密码'));assert.ok(!html.includes('id="security-form"'));assert.ok(!html.includes('保存密码'));
assert.ok(html.includes('class="member-settings-avatar"'));assert.ok(html.includes('alt="当前头像"'));assert.ok(html.includes('data-src="https://cdn.example/avatar.webp"'));
assert.ok(!html.includes('更换头像'));assert.ok(!html.includes('member-avatar-file'));assert.ok(!html.includes('data-avatar-action'));
});
test('feedback options map to the real API type and keep environment details with actual attachments',()=>{
const html=feedbackMarkup();
for(const text of ['feedback-reference','feedback-options','feedback-panel','feedback-description-wrap','feedback-upload-box','feedback-submit-button'])assert.ok(html.includes(text),text);
assert.ok(!html.includes('video/mp4'));assert.ok(!html.includes('保存到本地'));
const data=new FormData();for(const [key,value] of Object.entries({category:'登录异常',content:'播放时一直缓冲,无法正常观看视频。',contact:' me@example.com ',network:'Wi-Fi',device:'iPhone'}))data.set(key,value);
const body=feedbackPayload(data,['/files/real.png']);assert.equal(body.type,3);assert.equal(body.contact,'me@example.com');assert.deepEqual(body.screenshots,['/files/real.png']);
assert.ok(body.content.includes('网络类型:Wi-Fi'));assert.ok(body.content.includes('设备类型:iPhone'));
data.set('category','无法播放');assert.equal(feedbackPayload(data).type,2);data.set('category','其他问题');assert.equal(feedbackPayload(data).type,4);data.set('category','闪退 / 崩溃');assert.equal(feedbackPayload(data).type,4);
data.set('content','长'.repeat(500));assert.equal(feedbackPayload(data).content,'长'.repeat(500));
data.set('content','中'.repeat(490));assert.equal(feedbackPayload(data).content.length,500);
data.set('content','太短');assert.throws(()=>feedbackPayload(data),/10–500/);
});
class Element extends EventTarget {
nodes=new Map();dataset={};innerHTML='';textContent='';disabled=false;value='';files=[];
querySelector(selector){return this.nodes.get(selector)||null;}
querySelectorAll(){return [];}
reportValidity(){return true;}
reset(){this.resetCount=(this.resetCount||0)+1;}
}
function emit(element,type){element.dispatchEvent(new Event(type,{cancelable:true}));}
test('nickname settings save the nickname once and keep an existing introduction',async()=>{
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
const controller=new AbortController(),root=new Element(),profile=new Element(),status=new Element();
const values=new Map([[profile,{nickname:'新昵称'}]]);
profile.nodes.set('[type="submit"]',new Element());profile.nodes.set('#member-nickname-status',status);
root.nodes.set('#member-nickname-form',profile);
const calls=[];let finish,refreshes=0,routes=0;
const app={signal:controller.signal,home:{},api:{ensureSession:async()=>({userId:8,introduction:'原简介'}),get:async()=>({}),post:async(path,body)=>{calls.push({path,body});if(calls.length===1)await new Promise(resolve=>finish=resolve);},refreshUser:async()=>refreshes++},notify(){},async route(){routes++;}};
globalThis.document={getElementById:()=>root};globalThis.FormData=class extends OldFormData{constructor(form){super();if(form)for(const [key,value] of Object.entries(values.get(form)))this.set(key,value);}};
try{
await renderMember(app,'settings',1,()=>'/member/settings/');
emit(profile,'submit');emit(profile,'submit');assert.equal(calls.length,1);assert.equal(profile.querySelector('[type="submit"]').disabled,true);
finish();await flush();assert.deepEqual(calls[0],{path:'auth/profile',body:{nickName:'新昵称',introduction:'原简介'}});assert.equal(refreshes,1);assert.equal(routes,1);
}finally{controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;}
});
function feedbackFixture(post){
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
const controller=new AbortController(),root=new Element(),form=new Element(),input=new Element(),list=new Element(),status=new Element(),error=new Element(),content=new Element(),count=new Element(),submit=new Element();
const values={content:'播放视频时持续缓冲,无法正常观看。',category:'无法播放',contact:'me@example.com',network:'Wi-Fi'};
root.nodes.set('#feedback-form',form);
for(const [selector,node] of [['#feedback-files',input],['#feedback-file-list',list],['#feedback-status',status],['#feedback-file-error',error],['[name="content"]',content],['#feedback-count',count],['[type="submit"]',submit]])form.nodes.set(selector,node);
globalThis.document={getElementById:()=>root};globalThis.FormData=class extends OldFormData{constructor(form){super();if(form)for(const [key,value] of Object.entries(values))this.set(key,value);}};
const calls=[];renderFeedbackPage({signal:controller.signal,api:{post:async(path,body)=>{calls.push({path,body});return post(path,body);}}});
return {controller,form,input,list,status,error,submit,calls,dispose(){controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;}};
}
test('reference feedback form uploads images only on submit and blocks duplicate feedback writes',async()=>{
let finish;const f=feedbackFixture(path=>path==='upload/image'?new Promise(resolve=>finish=resolve):Promise.resolve({}));
try{
f.input.files=[new File(['image'],'shot.png',{type:'image/png'})];emit(f.input,'change');assert.equal(f.calls.length,0);assert.ok(f.list.innerHTML.includes('shot.png'));
emit(f.form,'submit');emit(f.form,'submit');assert.equal(f.calls.length,1);assert.equal(f.calls[0].path,'upload/image');assert.equal(f.submit.disabled,true);
finish({sourceNameFs:'/uploaded/shot.png'});await flush();
assert.equal(f.calls.length,2);assert.equal(f.calls[1].path,'feedback/add');assert.deepEqual(f.calls[1].body.screenshots,['/uploaded/shot.png']);assert.equal(f.calls[1].body.type,2);
assert.equal(f.form.resetCount,1);assert.match(f.status.textContent,/反馈已提交/);assert.equal(f.submit.disabled,false);
}finally{f.dispose();}
});
test('later feedback images are kept alongside earlier ones until three are selected',()=>{
const f=feedbackFixture(async()=>({}));
const image=(name)=>new File(['image'],name,{type:'image/png'});
try{
f.input.files=[image('one.png')];emit(f.input,'change');
f.input.files=[image('two.png')];emit(f.input,'change');
assert.ok(f.list.innerHTML.includes('one.png'));assert.ok(f.list.innerHTML.includes('two.png'));assert.equal(f.error.textContent,'');
f.input.files=[image('three.png'),image('four.png')];emit(f.input,'change');
assert.match(f.error.textContent,/最多上传 3 张/);assert.ok(!f.list.innerHTML.includes('three.png'));assert.ok(f.list.innerHTML.includes('one.png'));
f.input.files=[image('three.png')];emit(f.input,'change');
assert.equal(f.error.textContent,'');assert.ok(f.list.innerHTML.includes('three.png'));assert.equal(f.input.disabled,true);
f.list.closest=()=>({dataset:{removeFeedbackFile:'0'}});
f.list.dispatchEvent(new Event('click'));
assert.ok(!f.list.innerHTML.includes('one.png'));assert.ok(f.list.innerHTML.includes('two.png'));assert.equal(f.input.disabled,false);
}finally{f.dispose();}
});
test('unsupported files, failed uploads and navigation cannot silently submit feedback',async()=>{
const failed=feedbackFixture(async()=>({}));
try{
failed.input.files=[new File(['bad'],'clip.mp4',{type:'video/mp4'})];emit(failed.input,'change');assert.match(failed.error.textContent,/JPG/);assert.equal(failed.calls.length,0);
failed.input.files=[new File(['valid'],'shot.png',{type:'image/png'})];emit(failed.input,'change');emit(failed.form,'submit');await flush();
assert.deepEqual(failed.calls.map(call=>call.path),['upload/image']);assert.match(failed.status.textContent,/上传失败/);assert.equal(failed.submit.disabled,false);assert.equal(failed.form.resetCount,undefined);
}finally{failed.dispose();}
let finish;const late=feedbackFixture(()=>new Promise(resolve=>finish=resolve));
try{
late.input.files=[new File(['valid'],'shot.png',{type:'image/png'})];emit(late.input,'change');emit(late.form,'submit');late.controller.abort();finish({sourceNameFs:'/uploaded/late.png'});await flush();
assert.deepEqual(late.calls.map(call=>call.path),['upload/image']);assert.equal(late.form.resetCount,undefined);
}finally{late.dispose();}
});