2026-10-03 20:27:11 +08:00
// 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 );
});
2026-10-04 04:52:39 +08:00
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 );
});