import {linkShared} from './helpers/shared-modules.mjs'; import {routingModuleUrl} from './helpers/routing.mjs'; import {test} from 'node:test'; import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; import ts from 'typescript'; 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(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}'`)); 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 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 ['"]\.\/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收藏')); 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('>已点赞')); 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('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.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);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 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.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'); 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();} }); 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('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:()=>'