const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs'),Ranges=require('./web/recording-ranges.js'); const start=Date.parse('2026-10-04T00:00:00Z'),date=n=>new Date(start+n*1000); test('ranges clip to selected day and merge overlaps without filling gaps',()=>{ const items=[{start:date(-10),duration:40},{start:date(20),duration:30},{start:date(60),duration:100},{start:'invalid',duration:20},{start:date(3),duration:-4},{start:date(3),duration:Infinity}]; assert.deepEqual(Ranges.normalize(items,date(0),date(100)),[{start:+date(0),end:+date(50)},{start:+date(60),end:+date(100)}]); }); test('long recording continues after each one-hour session',()=>{ const ranges=[{start:+date(0),end:+date(8000)}];assert.deepEqual(Ranges.at(ranges,date(0)),{start:+date(0),end:+date(3600)}); assert.deepEqual(Ranges.next(ranges,date(3600)),{start:+date(3600),end:+date(7200)});assert.equal(Ranges.next(ranges,date(8000)),null); }); test('next segment skips the actual gap and does not replay the previous end',()=>{ const ranges=[{start:+date(0),end:+date(10)},{start:+date(30),end:+date(40)}];assert.equal(Ranges.at(ranges,date(10)),null); assert.deepEqual(Ranges.next(ranges,date(10)),{start:+date(30),end:+date(40)}); }); test('clip requires valid, positive, fully recorded interval up to one hour',()=>{ const r=[{start:+date(0),end:+date(4000)},{start:+date(5000),end:+date(8000)}]; for(const [a,b] of [[date(1),date(1)],[date(4),date(3)],[date(0),date(3601)],[date(3900),date(5100)],[date(-1),date(5)],[new Date(NaN),date(5)]])assert.throws(()=>Ranges.clip(r,a,b)); assert.deepEqual(Ranges.clip(r,date(0),date(3600)),{start:+date(0),end:+date(3600)}); }); function section(file,a,b){const s=fs.readFileSync('web/'+file,'utf8'),i=s.indexOf(a),j=s.indexOf(b,i+a.length);assert.ok(i>=0&&j>i);return s.slice(i,j);} function deferred(){let resolve;const promise=new Promise(r=>resolve=r);return{promise,resolve};} function ui(extra={}){const elements=new Map();const $=s=>{if(!elements.has(s))elements.set(s,{replaceChildren(){},reset(){},value:'',hidden:false});return elements.get(s);};return vm.createContext({$,Number,Date,URLSearchParams,loggedIn:true,activePage:'playback',RecordingRanges:Ranges,...extra});} test('auto-continue preserves user choice, stops short clips and ends at last segment',()=>{ const c=ui({playbackRange:{start:0,end:10000},spans:[{start:0,end:10000},{start:20000,end:30000}],playAt:d=>c.played=+d}); vm.runInContext(section('recording-ui.js','function continuePlayback(','function makeClip('),c); c.$('#play-video').ended=true;c.$('#play-video').currentTime=10;c.$('#play-autonext').checked=false;c.continuePlayback();assert.equal(c.played,undefined); c.$('#play-autonext').checked=true;c.$('#play-video').currentTime=1;c.continuePlayback();assert.match(c.$('#play-info').textContent,/提前结束/);assert.equal(c.played,undefined); c.$('#play-video').currentTime=10;c.continuePlayback();assert.equal(c.played,20000);assert.match(c.$('#play-info').textContent,/跳过/); c.playbackRange={start:20000,end:30000};c.continuePlayback();assert.match(c.$('#play-info').textContent,/播放完毕/); }); test('late recording list cannot replace another camera or a logged-out view',async()=>{ const p=deferred(),c=ui({playbackGeneration:2,queryCamera:'',resetPlayback(){},api:()=>p.promise,drawTimeline(){throw Error('late draw');}}); c.$('#play-camera').value='camera';c.$('#play-date').value='2026-10-04';c.$('#archive-source').value='local'; vm.runInContext(section('recording-ui.js','async function queryRecordings(','function drawTimeline('),c); const work=c.queryRecordings();c.playbackGeneration++;p.resolve({items:[]});await work;assert.equal(c.spans,undefined); }); test('download generation refuses gaps and invalidates old links first',()=>{ const c=ui({queryCamera:'camera',spans:[{start:+date(0),end:+date(60)}],playbackParams:()=>new URLSearchParams({start:'test'}),Error}); vm.runInContext(section('recording-ui.js','function makeClip(',"$('#query-recordings')"),c); c.$('#clip-start').value=date(0).toISOString();c.$('#clip-end').value=date(61).toISOString();c.makeClip();assert.equal(c.$('#clip-download').hidden,true);assert.match(c.$('#clip-status').textContent,/超出/); }); test('preset load from a closed panel is ignored',async()=>{ const p=deferred(),c=ui({ptzCamera:'first',ptzBusy:false,ptzPresetGeneration:0,clearPresetConfirmation(){},api:()=>p.promise,renderPresets(){throw Error('late render');}}); vm.runInContext(section('monitor.js','async function loadPresets(','async function openPtz('),c); const work=c.loadPresets();c.ptzPresetGeneration++;c.ptzCamera='';p.resolve({items:[]});await work; }); test('late goto result cannot overwrite a newer stop response',async()=>{ const p=deferred(),c=ui({ptzBusy:false,ptzCamera:'camera',ptzPresets:{revision:'1'},ptzGeneration:1,ptzCommandGeneration:1,clearPresetConfirmation(){},setPtzBusy(){},api:()=>p.promise}); vm.runInContext(section('monitor.js','async function presetCommand(','function closePtz('),c); const work=c.presetCommand('goto','1');c.ptzCommandGeneration++;c.$('#preset-status').textContent='stopped';p.resolve({message:'old goto'});await work;assert.equal(c.$('#preset-status').textContent,'stopped'); }); test('snapshot callback cannot reveal a prior login frame after logout',async()=>{ let callback,created=0;const c=ui({sessionGeneration:1,snapshotGeneration:0,clearSnapshot(){c.snapshotGeneration++;},toast(){},URL:{createObjectURL(){created++;}},document:{createElement(){return{getContext:()=>({drawImage(){}}),toBlob:f=>callback=f};}}}); vm.runInContext(section('snapshot.js','async function captureFrame(','document.addEventListener'),c); const work=c.captureFrame({readyState:4,videoWidth:100,videoHeight:50},'camera','Test');c.sessionGeneration++;c.loggedIn=false;callback({});await work;assert.equal(created,0); }); test('snapshot rejects empty or errored video without producing a blob',async()=>{ let messages=0;const c=ui({toast:()=>messages++});vm.runInContext(section('snapshot.js','async function captureFrame(','document.addEventListener'),c); await c.captureFrame({readyState:0,videoWidth:0});await c.captureFrame({readyState:4,videoWidth:100,error:{code:3}});assert.equal(messages,2); });