Files
ucvl-home-vision/test_recording_tools.js

91 lines
8.8 KiB
JavaScript

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('microsecond recording starts round inward and never seek into the preceding gap',()=>{
const raw='2026-10-04T00:00:00.340240Z';
const ranges=Ranges.normalize([{start:raw,duration:41.1355}],date(0),date(100));
assert.equal(new Date(ranges[0].start).toISOString(),'2026-10-04T00:00:00.341Z');
assert.equal(new Date(ranges[0].end).toISOString(),'2026-10-04T00:00:41.475Z');
const exact=Ranges.normalize([{start:'2026-10-04T00:00:00.340000Z',duration:10}],date(0),date(100));
assert.equal(exact[0].start,Date.parse(raw));
assert.deepEqual(Ranges.normalize([{start:raw,duration:0.0001}],date(0),date(1)),[]);
});
test('long recording continues in bounded five-minute sessions',()=>{
const ranges=[{start:+date(0),end:+date(8000)}];assert.deepEqual(Ranges.at(ranges,date(0)),{start:+date(0),end:+date(300)});
assert.deepEqual(Ranges.next(ranges,date(300)),{start:+date(300),end:+date(600)});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('recording health distinguishes configured from writing and never changes the player',()=>{
const c=ui({state:{cameras:[{id:'camera',recordingState:'off'}],storage:{retentionDays:30,maxGB:40,reserveGB:8}},isAdmin:()=>true});
c.$('#play-camera').value='camera';c.$('#archive-source').value='local';c.$('#recording-configure').dataset={};
c.$('#play-video').src='keep-playing';
vm.runInContext(section('recording-ui.js','function renderRecordingStatus(','function localDateTime('),c);
for(const [status,text] of [['off','未开启'],['waiting','等待文件写入'],['recording','正在录像'],['paused','磁盘保护'],['offline','等待摄像头'],['unavailable','服务未连接'],['disabled','设备连接已停用']]){
c.state.cameras[0].recordingState=status;c.renderRecordingStatus();assert.ok(c.$('#recording-health-text').textContent.includes(text));
}
assert.match(c.$('#recording-policy-note').textContent,/30 天/);assert.equal(c.$('#recording-configure').dataset.editCamera,'camera');
assert.equal(c.$('#play-video').src,'keep-playing');
c.isAdmin=()=>false;c.state.storage={};c.renderRecordingStatus();assert.equal(c.$('#recording-configure').hidden,true);assert.doesNotMatch(c.$('#recording-policy-note').textContent,/undefined|40 GB/);
c.$('#archive-source').value='recorder';c.renderRecordingStatus();assert.equal(c.$('#recording-health').hidden,true);
});
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('resuming a long pause reopens the recorded position without a page refresh',()=>{
const c=ui({playbackRange:{start:10000,end:300000},playbackPausedAt:Date.now()-20000,playAt:(date,preserve)=>c.played={time:+date,preserve}});
c.$('#play-video').currentTime=45;
vm.runInContext(section('recording-ui.js','function pausePlayback(','function makeClip('),c);
c.resumePlayback();assert.deepEqual(c.played,{time:55000,preserve:true});assert.equal(c.playbackPausedAt,0);
c.played=null;c.playbackPausedAt=Date.now();c.resumePlayback();assert.equal(c.played,null);
c.playbackPausedAt=Date.now()-20000;c.loggedIn=false;c.resumePlayback();assert.equal(c.played,null);
});
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);
});