@@ -0,0 +1,721 @@
|
||||
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';
|
||||
import {setMaxListeners} from 'node:events';
|
||||
import vm from 'node:vm';
|
||||
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');
|
||||
}
|
||||
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}'`));
|
||||
const {playbackUrl,chapterAccess,chapterListGate,chapterUnlockCost,initialPlayMode,preferNativeHls,isCodecFailure,MediaPlayback}=await import(playbackModuleUrl);
|
||||
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
|
||||
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}'`)
|
||||
.replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`)
|
||||
.replace(/from ['"]\.\/detail['"]/,`from '${detailUrl}'`)
|
||||
.replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`));
|
||||
const playerDataUrl=await moduleUrl('../src/lib/player-data.ts',code=>code
|
||||
.replace(/from ['"]\.\/api\/protocol['"]/,`from '${protocolUrl}'`)
|
||||
.replace(/from ['"]\.\/seo['"]/,`from '${seoUrl}'`)
|
||||
.replace(/from ['"]\.\/client\/playback['"]/,`from '${playbackModuleUrl}'`));
|
||||
const {playerInitialData}=await import(playerDataUrl);
|
||||
const playerUrl=await moduleUrl('../src/lib/client/player.ts',code=>code
|
||||
.replace(/from ['"]\.\/player-markup['"]/,`from '${markupUrl}'`)
|
||||
.replace(/from ['"]\.\.\/player-data['"]/,`from '${playerDataUrl}'`)
|
||||
.replace(/from ['"]\.\.\/seo['"]/,`from '${seoUrl}'`)
|
||||
.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`)
|
||||
.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);
|
||||
const {playerMarkup}=await import(markupUrl);
|
||||
const nav=(userAgent,maxTouchPoints=0)=>({userAgent,maxTouchPoints});
|
||||
const chrome=nav('Mozilla/5.0 Chrome/130 Safari/537.36');
|
||||
const safari=nav('Mozilla/5.0 Macintosh Version/18 Safari/605.1.15');
|
||||
const flush=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
|
||||
class Element extends EventTarget {
|
||||
tagName;dataset={};attrs=new Map();nodes=new Map();lists=new Map();children=[];hidden=false;disabled=false;textContent='';innerHTML='';
|
||||
classList={toggle(){}};
|
||||
style={setProperty(){}};
|
||||
constructor(tagName='DIV'){super();this.tagName=tagName;}
|
||||
setAttribute(key,value){this.attrs.set(key,String(value));}
|
||||
getAttribute(key){return this.attrs.get(key)??null;}
|
||||
hasAttribute(key){return this.attrs.has(key);}
|
||||
removeAttribute(key){this.attrs.delete(key);}
|
||||
querySelector(selector){return this.nodes.get(selector)??null;}
|
||||
querySelectorAll(selector){return this.lists.get(selector)||[];}
|
||||
matches(){return false;}
|
||||
append(child){this.children.push(child);}
|
||||
closest(){return this;}
|
||||
insertAdjacentHTML(_position,html){this.innerHTML+=html;}
|
||||
}
|
||||
class Video extends Element {
|
||||
paused=true;controls=false;native=false;plays=0;loads=0;sourceWrites=[];rejectPlay=false;error=null;
|
||||
constructor(native=false){super('VIDEO');this.native=native;}
|
||||
canPlayType(){return this.native?'maybe':'';}
|
||||
pause(){this.paused=true;}
|
||||
load(){this.loads++;this.error=null;}
|
||||
async play(){this.plays++;if(this.rejectPlay)throw new Error('NotAllowed');this.paused=false;this.dispatchEvent(new Event('playing'));}
|
||||
set src(value){this.sourceWrites.push(value);this.setAttribute('src',value);}
|
||||
get src(){return this.getAttribute('src')||'';}
|
||||
}
|
||||
function hlsClass(supported=true){
|
||||
return class {
|
||||
static instances=[];static Events={MANIFEST_PARSED:'manifest',ERROR:'error'};static ErrorTypes={NETWORK_ERROR:'network',MEDIA_ERROR:'media'};
|
||||
static DefaultConfig={manifestLoadPolicy:{default:{}},fragLoadPolicy:{default:{}}};
|
||||
static isSupported(){return supported;}
|
||||
handlers=new Map();destroyed=0;network=0;media=0;stopped=0;
|
||||
constructor(config){this.config=config;this.constructor.instances.push(this);}
|
||||
on(event,handler){this.handlers.set(event,handler);}
|
||||
emit(event,data){this.handlers.get(event)?.(event,data);}
|
||||
loadSource(source){this.source=source;}
|
||||
attachMedia(video){this.video=video;}
|
||||
startLoad(){this.network++;}
|
||||
recoverMediaError(){this.media++;}
|
||||
stopLoad(){this.stopped++;}
|
||||
destroy(){this.destroyed++;}
|
||||
};
|
||||
}
|
||||
function fixture({native=false,navigator=chrome,Hls=hlsClass(),loadHls=async()=>Hls}={}){
|
||||
const video=new Video(native),controller=new AbortController(),messages=[];let errors=0;
|
||||
const media=new MediaPlayback(video,{signal:controller.signal,navigator,loadHls,status:value=>messages.push(value),error:()=>errors++});
|
||||
return {video,controller,Hls,media,messages,get errors(){return errors;}};
|
||||
}
|
||||
|
||||
test('playback addresses preserve signed queries, use the relative proxy once, and reject unsafe paths',()=>{
|
||||
for(const path of ['folder/file.m3u8?token=a%2Fb+1&x=2','/folder/file.m3u8?token=a%2Fb+1&x=2','/h5/m3u8/folder/file.m3u8?token=a%2Fb+1&x=2','h5/m3u8/folder/file.m3u8?token=a%2Fb+1&x=2'])assert.equal(playbackUrl(path),'/h5/m3u8/folder/file.m3u8?token=a%2Fb+1&x=2');
|
||||
assert.equal(playbackUrl(' https://cdn.example/a.m3u8?token=abc#x '),'https://cdn.example/a.m3u8?token=abc#x');
|
||||
for(const value of [null,{},'', 'javascript:alert(1)','blob:abc','//external/stream','../secret','a/%2e%2e/b','a\\b','https://user:password@example.com/a'])assert.equal(playbackUrl(value),'');
|
||||
});
|
||||
|
||||
test('chapter access follows reference precedence, supports legacy free URLs, and never supplies gated main sources',()=>{
|
||||
const url='https://cdn.example/main.mp4';
|
||||
assert.equal(chapterAccess({canWatch:true,needUnlock:true,videoUrl:url}).allowed,true);
|
||||
assert.equal(chapterAccess({canWatch:false,payType:0,videoUrl:url}).allowed,true);
|
||||
for(const chapter of [{needUnlock:true},{payType:1},{canWatch:false,payType:1}]){
|
||||
const access=chapterAccess({...chapter,videoUrl:url});assert.equal(access.allowed,false);assert.equal(access.gate,'coins');assert.equal(access.source,'');assert.equal(initialPlayMode(access,url),'preview');
|
||||
}
|
||||
assert.equal(chapterAccess({needVip:true,videoUrl:url}).gate,'vip');
|
||||
assert.equal(chapterAccess({payType:2,videoUrl:url}).gate,'vip');
|
||||
assert.equal(chapterAccess(null).allowed,false);
|
||||
assert.equal(chapterUnlockCost({costCoins:12,singleCoins:30}),12);assert.equal(chapterUnlockCost({costCoins:-1,singleCoins:30}),30);
|
||||
assert.equal(initialPlayMode(chapterAccess({canWatch:true,videoUrl:url}),url),'main');
|
||||
assert.equal(initialPlayMode(chapterAccess({canWatch:true}),url),'preview');
|
||||
});
|
||||
|
||||
test('native HLS is preferred on Apple browsers while Chromium and shell browsers try MSE first',()=>{
|
||||
const video=new Video(true);
|
||||
for(const browser of [safari,nav('Mozilla iPhone Mobile Safari'),nav('Mozilla Macintosh Safari',5)])assert.equal(preferNativeHls(video,browser),true);
|
||||
for(const browser of [chrome,nav('Android Chrome Safari'),nav('iPhone Quark Safari'),nav('iPhone MicroMessenger Safari')])assert.equal(preferNativeHls(video,browser),false);
|
||||
assert.equal(preferNativeHls(new Video(false),safari),false);
|
||||
});
|
||||
|
||||
test('Chromium uses hls.js even with a native-HLS claim, with reference buffers and modern load policies',async()=>{
|
||||
const f=fixture({native:true});
|
||||
try{
|
||||
await f.media.setSource('/h5/m3u8/stream?token=1');const hls=f.Hls.instances[0];
|
||||
assert.equal(hls.source,'/h5/m3u8/stream?token=1');assert.equal(hls.video,f.video);assert.deepEqual(f.video.sourceWrites,[]);
|
||||
assert.equal(hls.config.maxBufferLength,30);assert.equal(hls.config.maxMaxBufferLength,60);
|
||||
assert.equal(hls.config.manifestLoadPolicy.default.maxLoadTimeMs,10000);assert.equal(hls.config.fragLoadPolicy.default.maxLoadTimeMs,20000);
|
||||
assert.equal(f.video.getAttribute('x5-video-player-type'),'h5');assert.equal(f.video.playsInline,true);
|
||||
assert.equal(f.video.plays,0);await f.media.toggle();assert.equal(f.video.paused,false);await f.media.toggle();assert.equal(f.video.paused,true);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('Safari and ordinary MP4 playback avoid loading hls.js',async()=>{
|
||||
let imports=0;const loadHls=async()=>{imports++;throw Error('must not import');};
|
||||
for(const [navigator,url] of [[safari,'https://cdn.example/main.m3u8'],[chrome,'https://cdn.example/main.mp4']]){
|
||||
const f=fixture({native:true,navigator,loadHls});
|
||||
try{await f.media.setSource(url);assert.equal(f.video.src,url);assert.equal(f.errors,0);}finally{f.media.dispose();}
|
||||
}
|
||||
assert.equal(imports,0);
|
||||
});
|
||||
|
||||
test('shell browsers without MSE fall back to native HLS; unsupported and failed library loads show retry states',async()=>{
|
||||
for(const native of [true,false]){
|
||||
const f=fixture({native,navigator:nav('iPhone Quark Safari'),Hls:hlsClass(false)});
|
||||
try{await f.media.setSource('/h5/m3u8/main.m3u8');assert.equal(!!f.video.src,native);assert.equal(f.errors,native?0:1);}finally{f.media.dispose();}
|
||||
const failed=fixture({native,loadHls:async()=>{throw Error('offline');}});
|
||||
try{await failed.media.setSource('/h5/m3u8/main.m3u8');assert.equal(!!failed.video.src,native);assert.equal(failed.errors,native?0:1);}finally{failed.media.dispose();}
|
||||
}
|
||||
});
|
||||
|
||||
test('fatal HLS network and media errors recover at most three times before presenting retry',async()=>{
|
||||
const f=fixture();
|
||||
try{
|
||||
await f.media.setSource('/h5/m3u8/main.m3u8');const hls=f.Hls.instances[0];
|
||||
hls.emit('error',{fatal:false,type:'network'});assert.equal(hls.network,0);
|
||||
for(const type of ['network','media','network','network'])hls.emit('error',{fatal:true,type});
|
||||
assert.equal(hls.network,2);assert.equal(hls.media,1);assert.equal(hls.stopped,1);assert.equal(f.errors,1);
|
||||
await f.media.retry();assert.equal(hls.destroyed,1);assert.equal(f.Hls.instances.length,2);assert.equal(f.video.plays,1);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('source switches destroy the old HLS instance and ignore its late events',async()=>{
|
||||
const f=fixture();
|
||||
try{
|
||||
await f.media.setSource('/h5/m3u8/old.m3u8');const old=f.Hls.instances[0];
|
||||
await f.media.setSource('https://cdn.example/new.mp4');assert.equal(old.destroyed,1);
|
||||
old.emit('error',{fatal:true,type:'media'});assert.equal(old.media,0);assert.equal(f.errors,0);
|
||||
assert.equal(f.video.src,'https://cdn.example/new.mp4');
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('route cancellation during a pending HLS import cannot create a stale player',async()=>{
|
||||
let finish;const Hls=hlsClass();const f=fixture({Hls,loadHls:()=>new Promise(resolve=>finish=resolve)});
|
||||
const pending=f.media.setSource('/h5/m3u8/old.m3u8');
|
||||
f.controller.abort();finish(Hls);await pending;
|
||||
assert.equal(Hls.instances.length,0);assert.equal(f.video.src,'');await f.media.toggle();assert.equal(f.video.plays,0);
|
||||
});
|
||||
|
||||
test('a newer native source wins when an older HLS import completes late, and rejected play remains retryable',async()=>{
|
||||
let finish;const Hls=hlsClass();const f=fixture({Hls,loadHls:()=>new Promise(resolve=>finish=resolve)});
|
||||
try{
|
||||
const pending=f.media.setSource('/h5/m3u8/old.m3u8');await f.media.setSource('https://cdn.example/new.mp4');finish(Hls);await pending;
|
||||
assert.equal(Hls.instances.length,0);assert.equal(f.video.src,'https://cdn.example/new.mp4');
|
||||
f.video.rejectPlay=true;await f.media.toggle();assert.equal(f.video.paused,true);assert.equal(f.messages.at(-1),'请再次点击播放');
|
||||
f.video.rejectPlay=false;await f.media.toggle();assert.equal(f.video.paused,false);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('codec incompatibility is distinguished from network, quota, and decryption failures',()=>{
|
||||
for(const details of ['manifestIncompatibleCodecsError','bufferAddCodecError','bufferIncompatibleCodecsError'])assert.equal(isCodecFailure({details}),true);
|
||||
for(const reason of ['Unsupported HEVC in M2TS found','H.265 codec not supported','incompatible AVC codec'])assert.equal(isCodecFailure({details:'fragParsingError',reason}),true);
|
||||
assert.equal(isCodecFailure({error:new DOMException('unsupported','NotSupportedError')}),true);
|
||||
for(const details of ['keyLoadError','fragDecryptError','fragLoadError'])assert.equal(isCodecFailure({details,error:Error('network unavailable')}),false);
|
||||
assert.equal(isCodecFailure({details:'bufferAppendError',error:new DOMException('full','QuotaExceededError')}),false);
|
||||
});
|
||||
|
||||
test('HEVC MSE codec errors try native HLS once, retaining signatures and playback position',async()=>{
|
||||
const f=fixture({native:true});const source='/h5/m3u8/hevc.m3u8?id=1&t=2&ticket=a%2Fb+1';
|
||||
try{
|
||||
await f.media.setSource(source);const hls=f.Hls.instances[0];f.video.currentTime=42;
|
||||
hls.emit('error',{fatal:true,type:'media',details:'bufferIncompatibleCodecsError',mimeType:'video/mp4;codecs="hvc1.1.6.L93.B0"'});
|
||||
assert.equal(hls.destroyed,1);assert.equal(f.video.src,source);assert.equal(f.errors,0);
|
||||
f.video.currentTime=0;f.video.dispatchEvent(new Event('loadedmetadata'));assert.equal(f.video.currentTime,42);
|
||||
hls.emit('error',{fatal:true,type:'media',details:'bufferAddCodecError'});assert.equal(f.errors,0);
|
||||
f.video.error={code:4};f.video.dispatchEvent(new Event('error'));await flush();
|
||||
assert.equal(f.errors,1);assert.equal(f.Hls.instances.length,1);assert.match(f.messages.at(-1),/H\.264\/H\.265/);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('native decode failures can switch to the full HLS engine without showing a premature generic error',async()=>{
|
||||
const f=fixture({native:true,navigator:safari});let genericErrors=0;f.video.addEventListener('error',()=>genericErrors++);
|
||||
try{
|
||||
await f.media.setSource('https://cdn.example/hevc.m3u8?ticket=signed');f.video.currentTime=12.5;
|
||||
f.video.error={code:3};f.video.dispatchEvent(new Event('error'));f.video.dispatchEvent(new Event('error'));await flush();
|
||||
assert.equal(genericErrors,0);assert.equal(f.Hls.instances.length,1);assert.equal(f.video.src,'');assert.equal(f.errors,0);
|
||||
const hls=f.Hls.instances[0];assert.equal(hls.config.startPosition,12.5);assert.equal(hls.source,'https://cdn.example/hevc.m3u8?ticket=signed');
|
||||
hls.emit('error',{fatal:true,type:'media',details:'bufferAddCodecError'});
|
||||
assert.equal(f.errors,1);assert.equal(f.video.sourceWrites.length,1);assert.equal(hls.media,0);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('nonfatal codec errors and compatible rendition retries are left to hls.js',async()=>{
|
||||
const f=fixture({native:true});
|
||||
try{
|
||||
await f.media.setSource('/h5/m3u8/multi-codec.m3u8');const hls=f.Hls.instances[0];
|
||||
hls.emit('error',{fatal:false,type:'media',details:'bufferAddCodecError'});
|
||||
hls.emit('error',{fatal:true,levelRetry:true,type:'media',details:'bufferAddCodecError'});
|
||||
assert.equal(f.video.src,'');assert.equal(hls.destroyed,0);assert.equal(f.errors,0);
|
||||
assert.equal(hls.config.videoPreference,undefined);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('unsupported HEVC reports once without repeatedly recovering an incompatible decoder',async()=>{
|
||||
const f=fixture();
|
||||
try{
|
||||
await f.media.setSource('/h5/m3u8/hevc-only.m3u8');const hls=f.Hls.instances[0];
|
||||
for(let index=0;index<3;index++)hls.emit('error',{fatal:true,type:'media',details:'manifestIncompatibleCodecsError'});
|
||||
assert.equal(hls.media,0);assert.equal(hls.network,0);assert.equal(f.errors,1);assert.match(f.messages.at(-1),/不支持此视频编码/);
|
||||
await f.media.toggle();assert.equal(f.video.plays,0);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('native MP4 codec failures show the decoding limitation without treating MP4 as an HLS playlist',async()=>{
|
||||
let imports=0;const f=fixture({loadHls:async()=>{imports++;return hlsClass();}});
|
||||
try{
|
||||
await f.media.setSource('https://cdn.example/hevc.mp4');
|
||||
f.video.error={code:2};f.video.dispatchEvent(new Event('error'));assert.equal(f.errors,1);assert.match(f.messages.at(-1),/视频加载失败/);
|
||||
await f.media.setSource('https://cdn.example/hevc.mp4');
|
||||
f.video.error={code:4};f.video.dispatchEvent(new Event('error'));
|
||||
assert.equal(imports,0);assert.equal(f.errors,2);assert.match(f.messages.at(-1),/H\.264\/H\.265/);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('native HLS network failure reports the terminal error once and an aborted load is not counted',async()=>{
|
||||
const f=fixture({native:true,navigator:safari});
|
||||
try{
|
||||
await f.media.setSource('https://cdn.example/main.m3u8');
|
||||
f.video.error={code:1};f.video.dispatchEvent(new Event('error'));assert.equal(f.errors,0);
|
||||
f.video.error={code:2};f.video.dispatchEvent(new Event('error'));f.video.dispatchEvent(new Event('error'));
|
||||
assert.equal(f.errors,1);assert.match(f.messages.at(-1),/视频加载失败/);assert.equal(f.Hls.instances.length,0);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('switching routes during a native-to-MSE fallback cannot install an outdated decoder',async()=>{
|
||||
let finish;const Hls=hlsClass();const f=fixture({native:true,navigator:safari,Hls,loadHls:()=>new Promise(resolve=>finish=resolve)});
|
||||
await f.media.setSource('/h5/m3u8/old.m3u8');
|
||||
f.video.error={code:4};f.video.dispatchEvent(new Event('error'));f.controller.abort();finish(Hls);await flush();
|
||||
assert.equal(Hls.instances.length,0);assert.equal(f.video.src,'');assert.equal(f.errors,0);
|
||||
});
|
||||
|
||||
function playerFixture({allowed=false,preview=true,unlock=async()=>({}),commentData={list:[],totalCount:0},detail={},chapterList,mobile=false,loadChapter}={}){
|
||||
const original=Object.fromEntries(['document','window','matchMedia'].map(key=>[key,globalThis[key]]));
|
||||
const signal=new AbortController();setMaxListeners(30,signal.signal);
|
||||
const page=new Element('MAIN'),box=new Element(),video=new Video(),overlay=new Element(),status=new Element(),lock=new Element('DIALOG');
|
||||
lock.open=false;lock.showModal=()=>{lock.open=true;};lock.close=()=>{const wasOpen=lock.open;lock.open=false;if(wasOpen)lock.dispatchEvent(new Event('close'));};
|
||||
const controlActions={};const mainButton=new Element('BUTTON'),previewButton=new Element('BUTTON');
|
||||
mainButton.dataset.playMode='main';previewButton.dataset.playMode='preview';
|
||||
const node=(parent,key)=>{if(!parent.nodes.has(key))parent.nodes.set(key,new Element());return parent.nodes.get(key);};
|
||||
page.nodes.set('video',video);page.nodes.set('.play-screen',box);page.nodes.set('.play-source-status',status);
|
||||
box.nodes.set('.play-screen-overlay',overlay);box.nodes.set('[data-media="toggle"]',new Element('BUTTON'));
|
||||
box.nodes.set('[data-play-action="previous"]',new Element('BUTTON'));box.nodes.set('[data-play-action="next"]',new Element('BUTTON'));
|
||||
for(const key of ['.play-episode-pages','.play-episode-grid','.play-comment-list','#play-comments-title','form'])node(page,key);
|
||||
page.nodes.set('.play-comment-form',page.nodes.get('form'));
|
||||
for(const key of ['.unlock-close','.unlock-pay','#episode-lock-balance','.unlock-cost','#episode-lock-error','#episode-lock-description','.unlock-recharge'])node(lock,key);
|
||||
page.lists.set('button[data-play-mode]',preview?[mainButton,previewButton]:[]);page.lists.set('[data-play-mode]',preview?[box,mainButton,previewButton]:[box]);
|
||||
const doc=new EventTarget();doc.getElementById=id=>id==='drama-play-page'?page:lock;doc.createElement=tag=>new Element(tag.toUpperCase());
|
||||
const browser=new EventTarget();browser.navigator=chrome;
|
||||
browser.installPlayerControls=(_box,_episode,actions)=>{Object.assign(controlActions,actions);};
|
||||
globalThis.document=doc;globalThis.window=browser;globalThis.matchMedia=()=>({matches:mobile});
|
||||
const posts=[],navigations=[],notifications=[],getCalls=[];let routes=0,refreshes=0;
|
||||
const chapters=chapterList||[{id:90001,currentEpisode:1},{id:90042,currentEpisode:2},{id:91200,currentEpisode:3}];
|
||||
const chapter={id:90042,playletId:23456,currentEpisode:2,canWatch:allowed,needUnlock:!allowed,payType:1,costCoins:12,balance:90,videoUrl:'https://cdn.example/main.mp4'};
|
||||
const api={user:{recharge:90},get:async(path,query)=>{
|
||||
getCalls.push({path,query});
|
||||
if(path==='playlet/detail')return {id:23456,title:'测试剧集',preview:preview?'https://cdn.example/preview.mp4':'',playletChapterInfo:chapters,...detail};
|
||||
if(path==='playlet/chapter/list')return {list:chapters};
|
||||
if(path==='playlet/chapter/detail')return loadChapter?loadChapter(query):chapter;
|
||||
if(path==='comment/list')return commentData;
|
||||
throw Error(path);
|
||||
},post:async(path,body)=>{posts.push({path,body});return path==='playlet/chapter/unlock'?unlock():{};},setUser(user){api.user=user;},refreshUser:async()=>{refreshes++;}};
|
||||
const app={api,signal:signal.signal,config:{},home:{},notify:value=>notifications.push(value),navigate:path=>navigations.push(path),route:async()=>{routes++;}};
|
||||
return {app,page,video,box,status,lock,posts,navigations,notifications,getCalls,mainButton,previewButton,controlActions,chapter,
|
||||
get routes(){return routes;},get refreshes(){return refreshes;},
|
||||
click(button){const event=new Event('click');Object.defineProperty(event,'target',{value:button});page.dispatchEvent(event);},
|
||||
dispose(){signal.abort();for(const [key,value] of Object.entries(original)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}}};
|
||||
}
|
||||
|
||||
function engagementControls(f,{like=197,collect=2}={}){
|
||||
const result={};
|
||||
for(const [kind,value] of [['like',like],['collect',collect]]){
|
||||
const button=new Element('BUTTON'),label=new Element('SPAN'),stat=new Element('SPAN');
|
||||
button.dataset.engage=kind;button.setAttribute('aria-pressed','false');label.textContent=kind==='like'?'点赞':'收藏';stat.textContent=value+(kind==='like'?'点赞':'收藏');
|
||||
button.nodes.set('span',label);f.page.lists.set(`[data-engage="${kind}"]`,[button]);f.page.lists.set(`[data-engage-count="${kind}"]`,[stat]);
|
||||
result[kind]={button,label,stat};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
test('likes and collections show pending feedback, reject duplicate clicks and update both counters after empty successes',async()=>{
|
||||
const f=playerFixture({detail:{countLike:197,countCollect:2}}),controls=engagementControls(f),requests=[];let dispose;
|
||||
f.page.nodes.delete('video');
|
||||
f.app.api.post=(path,body)=>{f.posts.push({path,body});return new Promise(resolve=>requests.push(resolve));};
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);await flush();const reads=f.getCalls.length;
|
||||
assert.ok(f.page.innerHTML.includes('data-engage-count="like"'));
|
||||
f.click(controls.like.button);f.click(controls.like.button);
|
||||
assert.equal(f.posts.length,1);assert.equal(controls.like.button.disabled,true);assert.equal(controls.like.button.getAttribute('aria-busy'),'true');assert.equal(controls.like.label.textContent,'请求中');
|
||||
assert.equal(controls.like.stat.textContent,'197点赞','counts change only after the write succeeds');
|
||||
f.click(controls.collect.button);assert.equal(f.posts.length,2);
|
||||
requests[0](null);await flush();assert.equal(controls.like.stat.textContent,'198点赞');assert.equal(controls.like.label.textContent,'已点赞');
|
||||
requests[1]({countCollect:3,isLike:0,countLike:0});await flush();assert.equal(controls.collect.stat.textContent,'3收藏');assert.equal(controls.collect.label.textContent,'已收藏');
|
||||
f.click(controls.like.button);assert.equal(f.posts.at(-1).body.isLike,false,'a concurrent collection response must not undo the like state');requests[2]({});await flush();
|
||||
assert.equal(controls.like.stat.textContent,'197点赞');assert.equal(controls.like.label.textContent,'点赞');assert.equal(controls.like.button.getAttribute('aria-pressed'),'false');
|
||||
assert.equal(controls.like.button.disabled,false);assert.equal(controls.like.button.hasAttribute('aria-busy'),false);
|
||||
assert.equal(f.getCalls.length,reads,'engagement writes do not wait for a detail refetch');
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('failed engagement writes restore controls and preserve counts; late responses after leaving do not change them',async()=>{
|
||||
const f=playerFixture({detail:{countLike:0,countCollect:0}}),controls=engagementControls(f,{like:0,collect:0});let dispose,resolve,reject;
|
||||
f.page.nodes.delete('video');
|
||||
f.app.api.post=()=>new Promise((yes,no)=>{resolve=yes;reject=no;});
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);await flush();
|
||||
f.click(controls.like.button);reject(new Error('请求失败'));await flush();
|
||||
assert.equal(controls.like.stat.textContent,'0点赞');assert.equal(controls.like.label.textContent,'点赞');assert.equal(controls.like.button.disabled,false);assert.equal(controls.like.button.hasAttribute('aria-busy'),false);assert.equal(f.notifications.at(-1),'请求失败');
|
||||
f.click(controls.like.button);resolve(null);await flush();assert.equal(controls.like.stat.textContent,'1点赞');
|
||||
f.click(controls.like.button);f.dispose();resolve(null);await flush();assert.equal(controls.like.stat.textContent,'1点赞');
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('play page likes and collections write the drama endpoints',async()=>{
|
||||
const f=playerFixture({allowed:true,detail:{countCollect:200,countLike:300}}),controls=engagementControls(f,{collect:9,like:7});let dispose;
|
||||
f.chapter.countCollect=9;f.chapter.countLike=7;f.chapter.isCollect=1;f.page.nodes.delete('video');
|
||||
f.app.api.post=async(path,body)=>{f.posts.push({path,body});return null;};
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456,90042);await flush();
|
||||
assert.ok(f.page.innerHTML.includes('>200收藏</span>'));
|
||||
assert.ok(f.page.innerHTML.includes('data-engage="collect" aria-pressed="false"'));
|
||||
f.click(controls.collect.button);await flush();
|
||||
assert.deepEqual(f.posts.at(-1),{path:'playlet/collect/set',body:{id:23456,isCollect:true}});
|
||||
assert.equal(controls.collect.stat.textContent,'201收藏');assert.equal(controls.collect.label.textContent,'已收藏');assert.equal(f.chapter.countCollect,9);assert.equal(f.chapter.isCollect,1);
|
||||
f.chapter.isLike=1;f.app.api.post=async(path,body)=>{f.posts.push({path,body});return {id:23456,isLike:1,countLike:301};};
|
||||
f.click(controls.like.button);await flush();
|
||||
assert.deepEqual(f.posts.at(-1),{path:'playlet/like/set',body:{id:23456,isLike:true}},'play page likes the drama, not the chapter');
|
||||
assert.equal(controls.like.stat.textContent,'301点赞');assert.equal(controls.like.label.textContent,'已点赞');assert.equal(f.chapter.isLike,1);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('play page like follows the drama status and writes playlet/like/set',async()=>{
|
||||
const f=playerFixture({allowed:true,detail:{isLike:1,countLike:12,isCollect:2,countCollect:4}}),controls=engagementControls(f,{like:12,collect:4});let dispose;
|
||||
f.chapter.isLike=2;f.chapter.isCollect=1;f.chapter.countCollect=4;f.page.nodes.delete('video');
|
||||
f.app.api.post=async(path,body)=>{f.posts.push({path,body});return {id:23456,isLike:2,countLike:11};};
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456,90042);await flush();
|
||||
assert.ok(f.page.innerHTML.includes('data-engage="like" aria-pressed="true"'));
|
||||
assert.ok(f.page.innerHTML.includes('>已点赞</span>'));
|
||||
assert.ok(f.page.innerHTML.includes('data-engage="collect" aria-pressed="false"'));
|
||||
f.click(controls.like.button);await flush();
|
||||
assert.deepEqual(f.posts.at(-1),{path:'playlet/like/set',body:{id:23456,isLike:false}});
|
||||
assert.equal(controls.like.label.textContent,'点赞');assert.equal(controls.like.stat.textContent,'11点赞');assert.equal(controls.like.button.getAttribute('aria-pressed'),'false');
|
||||
assert.equal(f.chapter.isLike,2);
|
||||
}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;
|
||||
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/"'));
|
||||
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/');
|
||||
}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()=>{
|
||||
const f=playerFixture();let dispose;
|
||||
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);
|
||||
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 金币');
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('allowed chapters play main despite stale lock flags; only main playback reports, and previous/next use actual IDs',async()=>{
|
||||
const f=playerFixture({allowed:true});let dispose;
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456,90042);await flush();
|
||||
assert.equal(f.video.src,'https://cdn.example/main.mp4');await f.controlActions.toggle();await flush();
|
||||
assert.deepEqual(f.posts,[{path:'playlet/chapter/watch/report',body:{playletId:23456,chapterId:90042}}]);
|
||||
f.video.dispatchEvent(new Event('playing'));await flush();assert.equal(f.posts.length,1);
|
||||
f.previewButton.dispatchEvent(new Event('click'));await flush();assert.equal(f.video.src,'https://cdn.example/preview.mp4');assert.equal(f.posts.length,1);
|
||||
const previous=new Element('BUTTON');previous.dataset.playAction='previous';f.click(previous);
|
||||
const next=new Element('BUTTON');next.dataset.playAction='next';f.click(next);await flush();
|
||||
assert.deepEqual(f.navigations,['/play/23456/90001/','/play/23456/91200/']);
|
||||
dispose();assert.equal(f.video.src,'');assert.equal(f.video.paused,true);
|
||||
}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()=>{
|
||||
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>'});
|
||||
const screen=new Element(),video=new Video(),bar=new Element(),overlay=new Element(),status=new Element(),center=new Element('BUTTON');
|
||||
video.volume=1;video.currentTime=0;video.duration=0;
|
||||
screen.nodes.set('video',video);screen.nodes.set('.play-screen-controls',bar);screen.nodes.set('.play-screen-overlay',overlay);screen.nodes.set('.play-source-status',status);overlay.nodes.set('button',center);
|
||||
for(const selector of ['[data-play-action="next"]','.player-seek','.player-volume input','[data-media="toggle"]','[data-media="mute"]','[data-media="fullscreen"]','.player-time'])bar.nodes.set(selector,new Element());
|
||||
let plays=0,retries=0;install(screen,2,{toggle:async()=>{plays++;},retry:async()=>{retries++;}});
|
||||
const click=(button)=>{const event=new Event('click');Object.defineProperty(event,'target',{value:button});let stopped=false;event.stopPropagation=()=>{stopped=true;};screen.dispatchEvent(event);return stopped;};
|
||||
assert.equal(click(center),true);await flush();assert.equal(plays,1);assert.equal(video.plays,0);
|
||||
const retry=new Element('BUTTON');retry.dataset.media='retry';click(retry);await flush();assert.equal(retries,1);assert.equal(video.loads,0);
|
||||
const unlock=new Element('BUTTON');unlock.setAttribute('data-show-unlock','');assert.equal(click(unlock),false);
|
||||
screen.dispatchEvent(new Event('playback:error'));assert.equal(center.hidden,true);assert.ok(overlay.innerHTML.includes('重试'));
|
||||
});
|
||||
|
||||
|
||||
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'};
|
||||
f.page.nodes.delete('video');
|
||||
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"'));
|
||||
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('用户 <script>'));
|
||||
assert.ok(comments.includes('class="play-reply-target">回复 @用户 <script></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"'));
|
||||
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()=>{
|
||||
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"'));
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('episode list gating follows reference permissions independently of video URLs',()=>{
|
||||
for(const chapter of [{payType:0},{payType:1,isUnlocked:true},{canWatch:true,needUnlock:true,needVip:true,payType:1},{isUnlocked:true,needUnlock:true},{needVip:true,isUnlocked:true}])assert.equal(chapterListGate(chapter),'');
|
||||
for(const chapter of [{needUnlock:true},{payType:1}])assert.equal(chapterListGate(chapter),'coins');
|
||||
for(const chapter of [{needVip:true},{payType:2}])assert.equal(chapterListGate(chapter),'vip');
|
||||
assert.equal(chapterAccess({isUnlocked:true,needUnlock:true,payType:1,videoUrl:'https://cdn.example/owned.mp4'}).allowed,true);
|
||||
});
|
||||
|
||||
const lockedList=[{id:90001,currentEpisode:1,canWatch:true},{id:90042,currentEpisode:2,needUnlock:true,payType:1,singleCoins:30},{id:91200,currentEpisode:3,needUnlock:true,payType:1,singleCoins:30}];
|
||||
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',()=>{
|
||||
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 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);
|
||||
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');
|
||||
});
|
||||
|
||||
test('player attaches to server markup without repeating initial reads and still verifies coin pricing on click',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}};
|
||||
try{
|
||||
f.page.innerHTML='<article>首屏详情</article>';f.page.querySelector('.play-comment-list').innerHTML='<p>首屏评论</p>';
|
||||
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);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
Reference in New Issue
Block a user