This commit is contained in:
谢宇宁
2026-11-06 00:13:27 +07:00
parent f65d10e998
commit 37411fcada
98 changed files with 958 additions and 4598 deletions
+50 -237
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
@@ -10,7 +11,7 @@ import {seoUrl} from './helpers/seo.mjs';
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(transform(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+new URL(path,import.meta.url).pathname).toString('base64');
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 playbackModuleUrl=await moduleUrl('../src/lib/client/playback.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
@@ -19,7 +20,6 @@ const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace
const appDownloadUrl=await moduleUrl('../src/lib/app-download.ts',code=>code.replace(/from ['"]\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
const downloadUrl=await moduleUrl('../src/lib/client/download.ts',code=>code.replace(/from ['"]\.\.\/app-download['"]/,`from '${appDownloadUrl}'`));
const detailUrl=await moduleUrl('../src/lib/client/detail.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`).replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`));
const unlockUrl=await moduleUrl('../src/lib/client/unlock.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`).replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`));
const feedbackUrl=await moduleUrl('../src/lib/client/request-feedback.ts');
const markupUrl=await moduleUrl('../src/lib/client/player-markup.ts',code=>code
.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`)
@@ -39,7 +39,6 @@ const playerUrl=await moduleUrl('../src/lib/client/player.ts',code=>code
.replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`)
.replace(/from ['"]\.\/download['"]/,`from '${downloadUrl}'`)
.replace(/from ['"]\.\/detail['"]/,`from '${detailUrl}'`)
.replace(/from ['"]\.\/unlock['"]/,`from '${unlockUrl}'`)
.replace(/from ['"]\.\/request-feedback['"]/,`from '${feedbackUrl}'`)
.replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`));
const {renderPlayer}=await import(playerUrl);
@@ -391,83 +390,41 @@ test('play page like follows the drama status and writes playlet/like/set',async
}finally{dispose?.();f.dispose();}
});
test('comment likes update their local count after success without refetching the comment list',async()=>{
const f=playerFixture(),button=new Element('BUTTON'),label=new Element('SPAN');let dispose,resolve;
f.page.nodes.delete('video');button.dataset.commentLike='8';button.dataset.commentCount='9';button.setAttribute('aria-pressed','false');label.textContent='♡ 9';button.nodes.set('[data-request-label]',label);
f.app.api.post=(path,body)=>{f.posts.push({path,body});return new Promise(yes=>resolve=yes);};
try{
dispose=await renderPlayer(f.app,23456);await flush();const reads=f.getCalls.filter(call=>call.path==='comment/list').length;
f.click(button);f.click(button);assert.equal(f.posts.length,1);assert.equal(label.textContent,'请求中');assert.equal(button.disabled,true);
resolve(null);await flush();assert.equal(label.textContent,'♥ 10');assert.equal(button.getAttribute('aria-pressed'),'true');assert.equal(button.disabled,false);
f.click(button);assert.equal(f.posts.at(-1).body.isLike,false);resolve({countLike:9});await flush();assert.equal(label.textContent,'♡ 9');
assert.equal(f.getCalls.filter(call=>call.path==='comment/list').length,reads);
}finally{dispose?.();f.dispose();}
});
test('comment submission displays sending feedback, blocks duplicate submits and retains text when the write fails',async()=>{
const f=playerFixture(),input=new Element('TEXTAREA'),button=new Element('BUTTON'),context=new Element(),error=new Element();let dispose,resolve,reject;
f.page.nodes.delete('video');button.textContent='发表评论';input.value='评论内容';
const form=f.page.querySelector('form');form.elements={namedItem:()=>input};form.nodes.set('[type="submit"]',button);f.page.nodes.set('#reply-context',context);f.page.nodes.set('#comment-error',error);
const submit=()=>{const event=new Event('submit',{cancelable:true});Object.defineProperty(event,'target',{value:form});form.dispatchEvent(event);};
f.app.api.post=(path,body)=>{f.posts.push({path,body});return new Promise((yes,no)=>{resolve=yes;reject=no;});};
try{
dispose=await renderPlayer(f.app,23456);await flush();
submit();submit();assert.equal(f.posts.length,1);assert.equal(button.textContent,'发送中');assert.equal(button.disabled,true);
reject(new Error('发送失败'));await flush();assert.equal(input.value,'评论内容');assert.equal(button.textContent,'发表评论');assert.equal(error.textContent,'发送失败');assert.equal(button.disabled,false);assert.deepEqual(f.notifications,[]);
assert.deepEqual(f.posts[0],{path:'comment/send',body:{objectId:23456,objectType:10,text:'评论内容',parentsId:0,replyCommentId:0}});
submit();resolve({});await flush();assert.equal(input.value,'');assert.equal(button.textContent,'发表评论');assert.equal(button.disabled,false);assert.equal(error.textContent,'');assert.deepEqual(f.notifications,['评论已发表']);
}finally{dispose?.();f.dispose();}
});
test('comment pagination uses page links and URL-provided page and sorting',async()=>{
const f=playerFixture({allowed:true,commentData:{list:[],totalCount:95}});let dispose;
const href=(page,sort)=>`/play/23456/90042/${page>1?`page/${page}/`:''}${sort===1?'?commentSort=1':''}`;
try{
dispose=await renderPlayer(f.app,23456,90042,false,{page:3,commentSort:1,pageHref:href});await flush();
const request=f.getCalls.find(call=>call.path==='comment/list');
assert.equal(request.query.objectId,23456);assert.equal(request.query.objectType,10);
assert.equal(request.query.pageNum,3);assert.equal(request.query.sortOrder,1);assert.equal(request.query.pageSize,10);
const html=f.page.querySelector('.play-comment-list').innerHTML;
assert.ok(html.includes('href="/play/23456/90042/page/4/?commentSort=1"'));
assert.ok(!html.includes('data-comment-page'));assert.ok(!html.includes('data-page-number'));
assert.ok(f.page.innerHTML.includes('data-comment-sort="1" aria-pressed="true"'));
const sort=new Element('BUTTON');sort.dataset.commentSort='2';f.click(sort);await flush();
assert.equal(f.navigations.at(-1),'/play/23456/90042/');
assert.equal(f.getCalls.filter(call=>call.path==='comment/list').length,1);
}finally{dispose?.();f.dispose();}
});
test('slug playback links survive client episode rendering, next controls, comments and confirmed unlocking with numeric write IDs',async()=>{
const f=playerFixture({allowed:true,detail:{slug:'city-story',tagIds:[9],tagSlugs:['urban'],tagNames:['都市']},commentData:{list:[],totalCount:25}});let dispose;
test('slug playback links survive client episode rendering and next controls; locked tiles still link to their play page',async()=>{
const f=playerFixture({allowed:true,detail:{slug:'city-story',tagIds:[9],tagSlugs:['urban'],tagNames:['都市']}});let dispose;
try{
dispose=await renderPlayer(f.app,23456,90042);await flush();
assert.ok(f.page.innerHTML.includes('href="/detail/city-story/"'));
assert.ok(f.page.innerHTML.includes('href="/tag/urban/"'));
assert.ok(f.page.querySelector('.play-episode-grid').innerHTML.includes('href="/play/city-story/90042/"'));
assert.ok(f.page.querySelector('.play-comment-list').innerHTML.includes('href="/play/city-story/90042/page/2/"'));
assert.ok(f.page.innerHTML.includes('href="/play/city-story/90042/"'));
assert.ok(!f.page.innerHTML.includes('/page/2/'));
const next=new Element('BUTTON');next.dataset.playAction='next';f.click(next);await flush();
assert.equal(f.navigations.at(-1),'/play/city-story/91200/');
}finally{dispose?.();f.dispose();}
const paid=playerFixture({detail:{slug:'city-story'},chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId)});let stop;
paid.page.nodes.delete('video');
try{
stop=await renderPlayer(paid.app,23456);paid.click(lockedButton(90042));await flush();
paid.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
assert.deepEqual(paid.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}});
assert.equal(paid.navigations.at(-1),'/play/city-story/90042/');
stop=await renderPlayer(paid.app,23456);await flush();
const html=paid.page.innerHTML;
assert.ok(html.includes('class="film-tile is-locked" href="/play/city-story/90042/"'));
assert.ok(html.includes('aria-label="第 2 集,请在 App 观看"'));
assert.ok(!html.includes('data-locked-chapter'));assert.ok(!html.includes('金币'));
assert.equal(paid.posts.length,0);
}finally{stop?.();paid.dispose();}
});
test('locked chapters offer only the free preview; choosing main opens confirmation and never auto-unlocks or reports preview views',async()=>{
test('locked chapters offer only the free preview; choosing main shows the App gate and never unlocks or reports',async()=>{
const f=playerFixture();let dispose;
const gate=new Element();gate.hidden=true;f.box.nodes.set('.play-app-gate',gate);
try{
dispose=await renderPlayer(f.app,23456,90042);await flush();
assert.equal(f.box.dataset.playMode,'preview');assert.equal(f.video.src,'https://cdn.example/preview.mp4');assert.equal(f.video.plays,0);
assert.ok(f.page.innerHTML.includes('本集请在 黄果短剧 App 观看'));assert.ok(f.page.innerHTML.includes('下载 App 继续观看'));
assert.equal(f.box.dataset.playMode,'preview');assert.equal(f.video.src,'https://cdn.example/preview.mp4');assert.equal(f.video.plays,0);assert.equal(gate.hidden,true);
await f.controlActions.toggle();assert.equal(f.video.plays,1);assert.equal(f.posts.length,0);
f.mainButton.dispatchEvent(new Event('click'));await flush();
assert.equal(f.video.src,'');assert.equal(f.box.dataset.playMode,'main');
await f.controlActions.toggle();assert.equal(f.lock.open,true);assert.equal(f.posts.length,0);
assert.equal(f.lock.querySelector('.unlock-cost').textContent,'12 金币');
assert.equal(gate.hidden,false);assert.equal(f.status.textContent,'请在 App 观看');
await f.controlActions.toggle();assert.equal(f.posts.length,0);assert.equal(f.video.plays,1);
}finally{dispose?.();f.dispose();}
});
@@ -486,18 +443,7 @@ test('allowed chapters play main despite stale lock flags; only main playback re
}finally{dispose?.();f.dispose();}
});
test('unlocking requires an explicit confirmation, prevents double submission, then refreshes account and chapter data',async()=>{
let finish;const f=playerFixture({preview:false,unlock:()=>new Promise(resolve=>finish=resolve)});let dispose;
try{
dispose=await renderPlayer(f.app,23456,90042);await f.controlActions.toggle();assert.equal(f.lock.open,true);assert.equal(f.posts.length,0);
const pay=f.lock.querySelector('.unlock-pay');pay.dispatchEvent(new Event('click'));pay.dispatchEvent(new Event('click'));
f.lock.close();await f.controlActions.toggle();pay.dispatchEvent(new Event('click'));
assert.equal(f.posts.length,1);assert.deepEqual(f.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}});
finish({canWatch:true});await flush();assert.equal(f.lock.open,false);assert.equal(f.refreshes,1);assert.equal(f.routes,1);
}finally{dispose?.();f.dispose();}
});
test('actual player controls delegate play and retry to the media engine and allow unlock clicks to reach the page',async()=>{
test('actual player controls delegate play and retry to the media engine and let page buttons reach the page',async()=>{
const source=await readFile(new URL('../public/scripts/site-runtime.js',import.meta.url),'utf8');
const start=source.indexOf('function installPlayerControls('),end=source.indexOf('/* ---- sticky-search-header-script ---- */',start);
const install=vm.runInNewContext(source.slice(start,end)+'\ninstallPlayerControls;', {document:{},emptyStateMarkup:()=>'<div>重试</div>'});
@@ -514,55 +460,40 @@ test('actual player controls delegate play and retry to the media engine and all
});
test('detail overview keeps original action groups, actual chapter identifiers and styled comment replies',async()=>{
const f=playerFixture({detail:{description:'接口简介',isNew:true,cover:'https://cdn.example/poster.webp'},commentData:{list:[{id:1,userName:'用户 <script>',avatar:'https://cdn.example/a.webp',text:'父评论',children:[{id:2,parentsId:1,replyCommentId:1,replyUserName:'用户 <script>',userAvatar:'https://cdn.example/b.webp',text:'回复'}]}],totalCount:2}});let dispose;
f.app.api.user={recharge:90,avatar:'https://cdn.example/me.webp'};
test('detail overview keeps the cinematic hero, actual chapter identifiers and recommended videos without comments',async()=>{
const f=playerFixture({detail:{description:'接口简介',isNew:true,cover:'https://cdn.example/poster.webp'}});let dispose;
f.page.nodes.delete('video');
const related=[{id:23456,title:'当前剧'},{id:7,title:'推荐 <一>'},{id:8,title:'推荐二'}];
f.app.api.get=(get=>async(path,query)=>path==='playlet/guess-like/list'?(f.getCalls.push({path,query}),{list:related}):get(path,query))(f.app.api.get);
try{
dispose=await renderPlayer(f.app,23456);await flush();
assert.ok(f.page.innerHTML.includes('href="/play/23456/90001/"'));assert.ok(f.page.innerHTML.includes('series-action-bar'));
assert.ok(f.page.innerHTML.includes('接口简介'));assert.ok(f.page.innerHTML.includes('class="play-official"'));
const html=f.page.innerHTML;
assert.ok(html.includes('href="/play/23456/90001/"'));assert.ok(html.includes('class="cine-hero"'));
assert.ok(html.includes('接口简介'));assert.ok(html.includes('class="play-official"'));
assert.ok(!f.getCalls.some(call=>call.path==='playlet/chapter/detail'));
assert.ok(f.page.innerHTML.includes('class="play-compose-avatar"'));
assert.ok(f.page.innerHTML.includes('data-src="https://cdn.example/me.webp"'));
const comments=f.page.querySelector('.play-comment-list').innerHTML;
assert.ok(comments.includes('class="play-comment-body"'));assert.ok(comments.includes('class="play-comment-replies"'));
assert.ok(comments.includes('play-comment-item play-comment-child'));assert.ok(comments.includes('用户 &lt;script&gt;'));
assert.ok(comments.includes('class="play-reply-target">回复 @用户 &lt;script&gt;</span>'));
assert.ok(comments.includes('class="play-comment-avatar"'));
assert.ok(comments.includes('data-src="https://cdn.example/a.webp"'));
assert.ok(comments.includes('data-src="https://cdn.example/b.webp"'));
assert.ok(comments.includes('data-lazy-eager="1"'));
assert.deepEqual(f.getCalls.find(call=>call.path==='playlet/guess-like/list').query,{playletId:23456,pageNum:1,pageSize:12});
assert.ok(html.includes('<h2 id="play-recommend-title">推荐视频</h2>'));assert.ok(html.includes('看了《测试剧集》的人还在看'));
assert.ok(html.includes('推荐 &lt;一&gt;'));assert.ok(!html.includes('href="/detail/23456/"'));
for(const text of ['评论','play-comment','反馈','客服'])assert.ok(!html.includes(text),text);
assert.ok(html.indexOf('play-recommend')<html.indexOf('play-official'));
const like=new Element('BUTTON');like.dataset.engage='like';like.nodes.set('span',new Element());
f.click(like);await flush();assert.deepEqual(f.posts.at(-1),{path:'playlet/like/set',body:{id:23456,isLike:true}});
assert.equal(like.querySelector('span').textContent,'已点赞');
}finally{dispose?.();f.dispose();}
});
test('comment composer avatar follows the signed-in account avatar and falls back like the header',()=>{
const withUser=playerMarkup({id:1,title:'剧'},[],{user:{userId:7,avatar:'https://cdn.example/account.webp'}});
assert.ok(withUser.includes('class="play-compose-avatar"'));
assert.ok(withUser.includes('data-src="https://cdn.example/account.webp"'));
assert.ok(withUser.includes('src="/icon.svg"'));
const guest=playerMarkup({id:1,title:'剧'},[],{user:null});
assert.ok(guest.includes('class="play-compose-avatar"'));
assert.ok(guest.includes('src="/icon.svg"'));
assert.ok(!guest.includes('data-src='));
});
test('episode segments keep their anchor across desktop/mobile layout changes and show original lock and playing markers',async()=>{
test('the film strip lists every episode with current and locked tiles',async()=>{
const chapterList=Array.from({length:130},(_,index)=>({id:90001+index,currentEpisode:index+1,canWatch:index<10,payType:index<10?0:1}));
const f=playerFixture({allowed:true,chapterList});let dispose;
try{
dispose=await renderPlayer(f.app,23456,90042);await flush();
const grid=f.page.querySelector('.play-episode-grid'),segments=f.page.querySelector('.play-episode-pages');
assert.ok(segments.innerHTML.includes('class="episode-page-track"'));assert.ok(grid.innerHTML.includes('class="episode-playing"'));
assert.ok(grid.innerHTML.includes('class="coin-soft-star"'));
const tab=new Element('BUTTON');tab.dataset.episodePage='2';f.click(tab);await flush();assert.ok(grid.innerHTML.includes('data-locked-chapter="90091"'));
globalThis.matchMedia=()=>({matches:true});window.dispatchEvent(new Event('resize'));
assert.ok(grid.innerHTML.includes('data-locked-chapter="90091"'));assert.ok(!grid.innerHTML.includes('data-locked-chapter="90106"'));
globalThis.matchMedia=()=>({matches:false});window.dispatchEvent(new Event('resize'));
assert.ok(grid.innerHTML.includes('data-locked-chapter="90091"'));assert.ok(grid.innerHTML.includes('data-locked-chapter="90130"'));
const html=f.page.innerHTML;
assert.ok(html.includes('class="film-strip"'));
assert.equal((html.match(/class="film-tile[" ]/g)||[]).length,130);
assert.ok(html.includes('class="film-tile is-current is-locked" href="/play/23456/90042/"'));assert.ok(html.includes('aria-current="true"'));
assert.ok(html.includes('class="film-tile is-locked" href="/play/23456/90130/"'));
assert.ok(!html.includes('class="film-tile is-locked" href="/play/23456/90010/"'));
assert.ok(html.includes('>03</span>'));assert.ok(html.includes('>42</span>'));
}finally{dispose?.();f.dispose();}
});
@@ -577,145 +508,27 @@ const lockedList=[{id:90001,currentEpisode:1,canWatch:true},{id:90042,currentEpi
const lockedChapter=(id=90042,extra={})=>({id,playletId:23456,currentEpisode:id===90042?2:3,needUnlock:true,payType:1,costCoins:12,singleCoins:30,balance:90,...extra});
const lockedButton=id=>{const button=new Element('BUTTON');button.dataset.lockedChapter=String(id);return button;};
test('clicking a coin episode on detail opens the original dialog, fetches live pricing, and waits for confirmation before navigating',async()=>{
const f=playerFixture({chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId),unlock:async()=>({balance:78})});let dispose;
f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);await flush();
const grid=f.page.querySelector('.play-episode-grid').innerHTML;
assert.ok(grid.includes('class="play-episode-locked" data-locked-chapter="90042"'));assert.ok(!grid.includes('href="/play/23456/90042/"'));
f.click(lockedButton(90042));assert.equal(f.lock.open,true);assert.equal(f.lock.querySelector('.unlock-pay').disabled,true);
f.click(lockedButton(90042));await flush();
assert.equal(f.getCalls.filter(call=>call.path==='playlet/chapter/detail').length,1);
assert.deepEqual(f.getCalls.find(call=>call.path==='playlet/chapter/detail').query,{playletId:23456,chapterId:90042});
assert.equal(f.lock.querySelector('.unlock-cost').textContent,'12 金币');assert.equal(f.lock.querySelector('#episode-lock-balance').textContent,'90 金币');
assert.match(f.lock.querySelector('#episode-lock-description').textContent,/第 2 集/);assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[]);
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
assert.deepEqual(f.posts,[{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}}]);
assert.equal(f.app.api.user.recharge,78);assert.equal(f.refreshes,1);assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,['/play/23456/90042/']);
}finally{dispose?.();f.dispose();}
});
test('a late chapter response cannot replace the newer episode selected for payment',async()=>{
const pending=new Map();
const f=playerFixture({chapterList:lockedList,loadChapter:query=>new Promise(resolve=>pending.set(query.chapterId,resolve))});let dispose;
f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));f.click(lockedButton(91200));
pending.get(91200)(lockedChapter(91200,{costCoins:9}));await flush();
pending.get(90042)(lockedChapter(90042,{costCoins:99}));await flush();
assert.equal(f.lock.querySelector('.unlock-cost').textContent,'9 金币');assert.match(f.lock.querySelector('#episode-lock-description').textContent,/第 3 集/);
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
assert.deepEqual(f.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:91200,playletId:23456}});assert.deepEqual(f.navigations,['/play/23456/91200/']);
}finally{dispose?.();f.dispose();}
});
test('closing a pending dialog or leaving the route prevents stale requests from reopening or submitting it',async()=>{
for(const leave of [false,true]){
let finish;const f=playerFixture({chapterList:lockedList,loadChapter:()=>new Promise(resolve=>finish=resolve)});let dispose;
f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));
if(leave)f.dispose();else f.lock.close();
finish(lockedChapter());await flush();
assert.equal(f.lock.open,false);f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[]);
}finally{dispose?.();f.dispose();}
}
});
test('already permitted and VIP chapters follow fresh access results instead of charging coins',async()=>{
for(const [data,path] of [[lockedChapter(90042,{canWatch:true}),'/play/23456/90042/'],[lockedChapter(90042,{needUnlock:false,needVip:true,payType:2}),'/info/deposit/vip/']]){
const f=playerFixture({chapterList:lockedList,loadChapter:()=>data});let dispose;f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
assert.equal(f.lock.open,false);assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[path]);
}finally{dispose?.();f.dispose();}
}
});
test('a zero chapter balance shows the account coin balance',async()=>{
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(90042,{balance:0})});let dispose;f.page.nodes.delete('video');
f.app.api.user.recharge=1000;
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
assert.equal(f.lock.querySelector('#episode-lock-balance').textContent,'1000 金币');
assert.equal(f.lock.querySelector('.unlock-pay').textContent,'支付 12 金币并播放');
assert.equal(f.refreshes,1);
}finally{dispose?.();f.dispose();}
});
test('insufficient displayed balance offers recharge and never sends an unlock request',async()=>{
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(90042,{balance:3})});let dispose;f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
assert.equal(f.lock.querySelector('.unlock-pay').textContent,'金币不足,去充值');
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
assert.equal(f.posts.length,0);assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,['/info/deposit/']);
}finally{dispose?.();f.dispose();}
});
test('mismatched chapter information leaves payment disabled without navigating or charging',async()=>{
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(91200)});let dispose;f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
assert.equal(f.lock.querySelector('.unlock-pay').disabled,true);assert.match(f.lock.querySelector('#episode-lock-error').textContent,/不匹配/);
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[]);
}finally{dispose?.();f.dispose();}
});
test('payment errors remain visible and retryable; balance and auth errors follow reference recovery',async()=>{
const {ApiError}=await import(protocolUrl);
for(const [error,path] of [[new Error('网络暂不可用'),null],[new ApiError('金币余额不足',400),'/info/deposit/'],[new ApiError('登录已过期',1000),'login']]){
let attempts=0,authPrompts=0;
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(),unlock:async()=>{attempts++;if(attempts===1)throw error;return {balance:78};}});let dispose;f.page.nodes.delete('video');f.app.openAuth=()=>authPrompts++;
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
const pay=f.lock.querySelector('.unlock-pay');pay.dispatchEvent(new Event('click'));await flush();
assert.equal(f.refreshes,0);
if(path==='login'){assert.equal(authPrompts,1);assert.equal(f.lock.open,false);}
else if(path){assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,[path]);}
else{
assert.equal(f.lock.open,true);assert.equal(f.lock.querySelector('#episode-lock-error').textContent,error.message);assert.equal(f.lock.querySelector('#episode-lock-error').hidden,false);assert.equal(pay.disabled,false);
pay.dispatchEvent(new Event('click'));await flush();assert.equal(f.posts.length,2);assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,['/play/23456/90042/']);
}
}finally{dispose?.();f.dispose();}
}
});
test('a pending payment cannot be repeated or retargeted to a different episode',async()=>{
let finish;const f=playerFixture({chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId),unlock:()=>new Promise(resolve=>finish=resolve)});let dispose;f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
const pay=f.lock.querySelector('.unlock-pay');pay.dispatchEvent(new Event('click'));pay.dispatchEvent(new Event('click'));f.click(lockedButton(91200));await flush();
const cancel=new Event('cancel',{cancelable:true});f.lock.dispatchEvent(cancel);
assert.equal(cancel.defaultPrevented,true);assert.equal(f.lock.querySelector('.unlock-close').disabled,true);
assert.equal(f.posts.length,1);assert.equal(f.getCalls.filter(call=>call.path==='playlet/chapter/detail').length,1);
finish({balance:78});await flush();assert.deepEqual(f.navigations,['/play/23456/90042/']);assert.equal(f.lock.open,false);
}finally{dispose?.();f.dispose();}
});
test('server player data excludes credentials and denied sources while retaining free episodes, comments and artwork',()=>{
test('server player data excludes credentials and denied sources while retaining free episodes, recommendations and artwork',()=>{
const locked={id:90042,currentEpisode:2,countLike:27,countCollect:8,countComment:3,ratio:0.5625,seoInfo:{title:'章节独立标题'},canWatch:false,needUnlock:true,payType:1,videoUrl:'https://cdn.example/secret-locked.mp4',token:'chapter-secret'};
const free={id:90001,currentEpisode:1,canWatch:true,videoUrl:'https://cdn.example/free.mp4'};
const initial=playerInitialData({id:23456,title:'公开剧名',cover:'https://cdn.example/cover.webp',token:'detail-secret',reportSdkConfig:{secret:'sdk-secret'},advitise:{nineGridList:[{id:'a',images:'https://cdn.example/ad.webp',title:'广告',link:'https://example.com',secret:'ad-secret'}]},relatedRecommendData:[{id:7,title:'相关剧集',token:'related-secret'}]},[free,locked],locked,{list:[{id:1,text:'评论正文',token:'comment-secret',children:[{id:2,text:'回复',replyUserName:'甲',replyCommentId:1,parentsId:1,token:'child-secret'}]}],totalCount:12});
const related=[{id:23456,title:'自身'},...Array.from({length:14},(_,index)=>({id:100+index,title:'相关剧集'+index,token:'related-secret'}))];
const initial=playerInitialData({id:23456,title:'公开剧名',cover:'https://cdn.example/cover.webp',token:'detail-secret',reportSdkConfig:{secret:'sdk-secret'},advitise:{nineGridList:[{id:'a',images:'https://cdn.example/ad.webp',title:'广告',link:'https://example.com',secret:'ad-secret'}]},relatedRecommendData:related},[free,locked],locked);
const json=JSON.stringify(initial);
for(const value of ['detail-secret','chapter-secret','sdk-secret','ad-secret','related-secret','comment-secret','child-secret','secret-locked.mp4'])assert.ok(!json.includes(value),value);
for(const value of ['detail-secret','chapter-secret','sdk-secret','ad-secret','related-secret','secret-locked.mp4'])assert.ok(!json.includes(value),value);
assert.equal(initial.chapters[0].videoUrl,free.videoUrl);assert.equal(initial.chapter.canWatch,false);assert.equal(initial.chapter.needUnlock,true);
assert.equal(initial.chapter.countLike,27);assert.equal(initial.chapter.ratio,0.5625);assert.equal(initial.chapter.seoInfo.title,'章节独立标题');
assert.equal(initial.comments.list[0].text,'评论正文');assert.equal(initial.comments.list[0].children[0].text,'回复');assert.equal(initial.comments.list[0].children[0].replyUserName,'甲');assert.equal(initial.detail.advitise.nineGridList[0].images,'https://cdn.example/ad.webp');
assert.equal(initial.comments,undefined);assert.equal(initial.detail.advitise.nineGridList[0].images,'https://cdn.example/ad.webp');
assert.equal(initial.detail.relatedRecommendData.length,12);assert.ok(!initial.detail.relatedRecommendData.some(item=>item.id===23456));
});
test('player attaches to server markup without repeating initial reads and still verifies coin pricing on click',async()=>{
test('player attaches to server markup without repeating initial reads',async()=>{
const f=playerFixture();let dispose;
const locked={...f.chapter,videoUrl:undefined};
const initial={detail:{id:23456,title:'首屏详情'},chapters:[{id:90001,currentEpisode:1,canWatch:true},locked],chapter:null,comments:{list:[{id:1,text:'首屏评论'}],totalCount:1}};
const initial={detail:{id:23456,title:'首屏详情'},chapters:[{id:90001,currentEpisode:1,canWatch:true},locked],chapter:null};
try{
f.page.innerHTML='<article>首屏详情</article>';f.page.querySelector('.play-comment-list').innerHTML='<p>首屏评论</p>';
f.page.innerHTML='<article>首屏详情</article>';
dispose=await renderPlayer(f.app,23456,undefined,false,{initial,preserveMarkup:true});await flush();
assert.equal(f.getCalls.length,0);assert.equal(f.page.innerHTML,'<article>首屏详情</article>');assert.equal(f.page.querySelector('.play-comment-list').innerHTML,'<p>首屏评论</p>');
f.click(lockedButton(90042));await flush();
assert.equal(f.getCalls.length,1);assert.equal(f.getCalls[0].path,'playlet/chapter/detail');assert.equal(f.lock.open,true);assert.equal(f.posts.length,0);
assert.equal(f.getCalls.length,0);assert.equal(f.page.innerHTML,'<article>首屏详情</article>');assert.equal(f.posts.length,0);
}finally{dispose?.();f.dispose();}
});