// 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); });