Files
ucvl-home-vision/test_ui_logic.js
T

125 lines
12 KiB
JavaScript
Raw Normal View History

// Run actual UI functions in isolated contexts; no live accounts or browser state.
const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs');
const source=name=>fs.readFileSync('web/'+name,'utf8');
function section(file,start,end){const s=source(file),a=s.indexOf(start),b=s.indexOf(end,a+start.length);assert.ok(a>=0&&b>a);return s.slice(a,b);}
const apiSource=section('app.js','async function api(','function toast(');
const refreshSource=section('app.js','async function refresh(','async function auth(');
function context(extra={}){return vm.createContext({sessionGeneration:0,refreshGeneration:0,authGeneration:0,loggedIn:true,currentUser:{id:'a'},state:{},Error,...extra});}
function deferred(){let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b;});return {promise,resolve,reject};}
test('late successful response cannot restore private data after account switch',async()=>{
const pending=deferred(),c=context({fetch:()=>pending.promise});vm.runInContext(apiSource,c);
const request=c.api('/api/family');c.sessionGeneration++;c.currentUser={id:'b'};
pending.resolve({ok:true,json:async()=>({privatePerson:'previous account'})});
await assert.rejects(request,e=>e.stale===true);
});
test('old unauthorized response cannot log out a new session',async()=>{
const pending=deferred();let clears=0;const c=context({fetch:()=>pending.promise,clearPrivateView:()=>clears++,auth:()=>{}});vm.runInContext(apiSource,c);
const request=c.api('/api/state');c.sessionGeneration++;
pending.resolve({ok:false,status:401,json:async()=>({error:'old session'})});
await assert.rejects(request,e=>e.stale===true);assert.equal(clears,0);
});
test('API errors retain conflict status for explicit reload workflow',async()=>{
const c=context({fetch:async()=>({ok:false,status:409,json:async()=>({error:'家谱已被更新'})})});vm.runInContext(apiSource,c);
await assert.rejects(c.api('/api/family/person',{}),e=>e.status===409);
});
test('late state refresh cannot overwrite newer state',async()=>{
const first=deferred(),second=deferred(),requests=[first,second];let renders=0;
const c=context({api:()=>requests.shift().promise,render:()=>renders++});vm.runInContext(refreshSource,c);
const a=c.refresh(),b=c.refresh();second.resolve({version:'new',user:{id:'a'}});await b;
first.resolve({version:'old',user:{id:'a'}});await a;
assert.equal(c.state.version,'new');assert.equal(renders,1);
});
test('late refresh failure cannot cover a newer successful screen',async()=>{
const first=deferred(),second=deferred(),requests=[first,second];let banners=0;
const c=context({api:()=>requests.shift().promise,render:()=>{},$:()=>{banners++;return {};}});vm.runInContext(refreshSource,c);
const a=c.refresh(),b=c.refresh();second.resolve({user:{id:'a'}});await b;first.reject(Error('old failure'));await a;assert.equal(banners,0);
});
test('another tab changing the shared login clears the previous private view',async()=>{
let clears=0;const c=context({api:async()=>({user:{id:'b'},cameras:[]}),render:()=>{},
clearPrivateView:()=>{clears++;c.loggedIn=false;c.currentUser=null;}});
vm.runInContext(refreshSource,c);await c.refresh();assert.equal(clears,1);assert.equal(c.currentUser.id,'b');assert.equal(c.loggedIn,true);
});
test('expired session clears private view when session endpoint itself returns 200',async()=>{
const fields=new Map();const $=s=>{if(!fields.has(s))fields.set(s,{elements:{username:{}},showModal(){this.open=true;}});return fields.get(s);};
let clears=0;const c=context({api:async()=>({authenticated:false,user:null,setupRequired:false}),$,
clearPrivateView:()=>{clears++;c.currentUser=null;c.loggedIn=false;},applyPermissions:()=>{},refresh:()=>{},toast:()=>{}});
vm.runInContext(section('app.js','async function auth(','function openSite('),c);await c.auth();
assert.equal(clears,1);assert.equal(c.loggedIn,false);assert.equal($('#auth-dialog').open,true);
});
test('archived person reached from an event is revealed before graph location',()=>{
const toggle={checked:false};let rendered=false,located='';
const c=context({family:{people:[{id:'ancestor',archived:true}]},$:()=>toggle,window:{innerWidth:1200},document:{},
renderFamily:()=>{assert.equal(toggle.checked,true);rendered=true;},familyMap:{locate:id=>located=id}});
vm.runInContext(section('family.js','function selectFamilyPerson(','function openPerson('),c);c.selectFamilyPerson('ancestor',true);
assert.ok(rendered);assert.equal(c.familyView,'tree');assert.equal(located,'ancestor');
});
test('event default save preserves published state and exposes explicit withdrawal',()=>{
const controls=new Map(),form={dataset:{},querySelector:s=>{if(!controls.has(s))controls.set(s,{});return controls.get(s);}};
const elements=new Map([['#event-form',form]]);const $=s=>{if(!elements.has(s))elements.set(s,{showModal(){},close(){}});return elements.get(s);};
const c=context({family:{canEdit:true,revision:3,people:[],events:[{id:'published',status:'published',personIds:[]}]},$,fillForm:()=>{},initDateFields:()=>{},esc:String});
vm.runInContext(section('family-events.js','function editFamilyEvent(',"$('#event-form').addEventListener"),c);
c.editFamilyEvent('published');assert.equal(form.querySelector('[data-save-event]').value,'published');assert.equal(form.querySelector('[data-save-event]').textContent,'保存修改');assert.equal(form.querySelector('[data-publish-event]').hidden,true);assert.equal(form.querySelector('[data-withdraw-event]').hidden,false);
c.editFamilyEvent();assert.equal(form.querySelector('[data-save-event]').value,'draft');assert.equal(form.querySelector('[data-withdraw-event]').hidden,true);
});
test('event date validation never clears a separately edited person death date',()=>{
const c=context({$:()=>{throw Error('unexpected person form access');},syncLifeStatus:()=>{throw Error('unexpected life state change');},dateHint:()=>{}});
vm.runInContext(section('date-fields.js','function syncDateFields(','for(const field of $$'),c);
c.syncDateFields(true,{id:'event-form',querySelectorAll:()=>[]});
});
test('older family request errors cannot repopulate logged-out view',async()=>{
const pending=deferred();let messages=0;const c=context({familyGeneration:0,api:()=>pending.promise,$:()=>{messages++;return {};}});
vm.runInContext(section('family.js','async function loadFamily(','function relationText('),c);
const p=c.loadFamily();c.loggedIn=false;c.familyGeneration++;pending.reject(Error('late error'));assert.equal(await p,false);assert.equal(messages,0);
});
const historySource=section('family-history.js','async function showFamilyChange(','function prepareFamilyRestore(');
test('history prose is never translated as an enum value',()=>{
const c=context({personName:id=>'Person '+id});vm.runInContext(section('family-history.js','function historyValue(','async function openFamilyHistory('),c);
assert.equal(c.historyValue('body','published'),'published');assert.equal(c.historyValue('status','published'),'已发布');
assert.equal(c.historyValue('personIds',['a','b']),'Person a、Person b');assert.equal(c.historyValue('active',false),'否');
});
test('history detail from a previous selection cannot replace newer detail',async()=>{
const a=deferred(),b=deferred(),calls=[a,b],elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{});return elements.get(s);};
const c=context({historyContext:{},historyDetailGeneration:0,historySelection:null,api:()=>calls.shift().promise,$,historyFields:{name:'Name'},esc:String,historyValue:(_,v)=>v});
vm.runInContext(historySource,c);const old=c.showFamilyChange(1),latest=c.showFamilyChange(2);
b.resolve({revision:2,before:null,after:{name:'New'}});await latest;const html=$('#history-detail').innerHTML;
a.resolve({revision:1,before:null,after:{name:'Old'}});await old;assert.equal($('#history-detail').innerHTML,html);assert.equal(c.historySelection.revision,2);
});
test('closing history prevents late private detail from rendering',async()=>{
const pending=deferred(),detail={};const c=context({historyContext:{},historyDetailGeneration:0,historySelection:null,api:()=>pending.promise,$:()=>detail});
vm.runInContext(historySource,c);const request=c.showFamilyChange(1);c.historyContext=null;c.historyDetailGeneration++;pending.resolve({after:{name:'Private'}});await request;
assert.equal(detail.innerHTML,undefined);assert.equal(c.historySelection,null);
});
test('password form requires matching, distinct and bounded new password',()=>{
const c=context();vm.runInContext(section('app.js','function validateNewPassword(','$(\'#account-password\').onclick='),c);
for(const data of [{newPassword:'different',confirmPassword:'mismatch'},{newPassword:'samepass',confirmPassword:'samepass',currentPassword:'samepass'},{newPassword:'short',confirmPassword:'short'},{newPassword:'x'.repeat(129),confirmPassword:'x'.repeat(129)}])assert.throws(()=>c.validateNewPassword(data));
assert.doesNotThrow(()=>c.validateNewPassword({newPassword:'new-password',confirmPassword:'new-password',currentPassword:'old-password'}));
});
test('history restore sends the reviewed revision and never the full text',async()=>{
let sent;const elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{replaceChildren(){}});return elements.get(s);};
const c=context({historyContext:{revision:10},historySelection:{revision:3,before:{name:'Original'}},historyDetailGeneration:1,$,$$:()=>[],
api:async(path,data)=>{sent={path,data};},loadFamilyHistory:async()=>{},loadFamily:async()=>true,toast:()=>{}});
vm.runInContext(section('family-history.js','async function restoreFamilyChange(','$(\'#history-more\').onclick='),c);await c.restoreFamilyChange('before');
assert.equal(sent.path,'/api/family/restore');assert.equal(JSON.stringify(sent.data),JSON.stringify({changeRevision:3,side:'before',revision:10}));assert.equal(c.historySelection,null);
});
test('camera form sends only changed and advertised parameters',()=>{
const c=context();vm.runInContext(section('camera-settings.js','function changedCameraValues(','async function saveCameraSettings('),c);
const form={elements:{brightness:{value:'60'},dayNight:{value:'AUTO'},malicious:{value:'anything'}}};
const settings={imaging:{brightness:{value:50,min:0,max:100},dayNight:{value:'AUTO',choices:['AUTO','ON']}}};
assert.equal(JSON.stringify(c.changedCameraValues(form,'imaging',settings)),JSON.stringify({brightness:60}));
});
test('late camera settings cannot populate a closed or different camera dialog',async()=>{
const pending=deferred(),elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{value:'main'});return elements.get(s);};let renders=0;
const c=context({cameraSettingsContext:{id:'old'},cameraSettingsGeneration:0,$,$$:()=>[],URLSearchParams,api:()=>pending.promise,renderCameraSettings:()=>renders++});
vm.runInContext(section('camera-settings.js','async function loadCameraSettings(','async function openCameraSettings('),c);const request=c.loadCameraSettings();c.cameraSettingsContext={id:'new'};c.cameraSettingsGeneration++;pending.resolve({device:{}});await request;assert.equal(renders,0);
});
test('ambiguous device save requires re-read before another write',async()=>{
const elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{});return elements.get(s);};const buttons=[{disabled:false}];
const c=context({cameraSettingsContext:{id:'camera',quality:'main',settings:{revision:'old'}},cameraSettingsGeneration:0,$,$$:()=>buttons,
changedCameraValues:()=>({brightness:60}),api:async()=>{throw Error('connection lost');},toast:()=>{}});
vm.runInContext(section('camera-settings.js','async function saveCameraSettings(','for(const [id,section]'),c);await c.saveCameraSettings({},'imaging');
assert.equal(c.cameraSettingsContext.settings,null);assert.equal(buttons[0].disabled,true);assert.equal($('#camera-settings-reload').disabled,false);
});