2026-10-04 17:22:42 +08:00
const test = require ( 'node:test' ), assert = require ( 'node:assert/strict' ), vm = require ( 'node:vm' ), fs = require ( 'node:fs' );
const source = fs . readFileSync ( 'web/personalization.js' , 'utf8' ), homes = fs . readFileSync ( 'web/households.js' , 'utf8' );
const part = ( s , a , b )=> s . slice ( s . indexOf ( a ), s . indexOf ( b , s . indexOf ( a ) + a . length ));
function preferences (){ const c = vm . createContext ({ pageInfo : {}, document : { documentElement : { dataset : {}}}}); vm . runInContext ( source . slice ( 0 , source . indexOf ( 'function applyBranding(' )), c ); return c ;}
test ( 'unset preferences inherit platform defaults and unknown values cannot become CSS' ,()=>{
const c = preferences (); const values = c . effectiveAppearance ({ theme : 'javascript:alert(1)' , layout : 'top' },{ theme : 'ink' });
assert . equal ( values . theme , 'ink' ); assert . equal ( values . layout , 'top' ); assert . equal ( values . density , 'comfortable' );
c . applyAppearance ({ theme : 'clean' , font : 'sans' }); assert . equal ( c . document . documentElement . dataset . theme , 'clean' );
assert . equal ( c . document . documentElement . dataset . font , 'sans' );
});
test ( 'explicit personal appearance is preserved when the platform default changes' ,()=>{
const c = preferences (); assert . equal ( c . effectiveAppearance ({ theme : 'paper' },{ theme : 'ink' }). theme , 'paper' );
assert . equal ( c . effectiveAppearance ({ theme : 'default' },{ theme : 'ink' }). theme , 'ink' );
});
test ( 'leaving settings invalidates outstanding work and restores the saved appearance' ,()=>{
let applied = 0 , c = vm . createContext ({ appearanceGeneration : 3 , settingsTimer : 7 , clearTimeout : ()=>{}, applyAppearance : ()=> applied ++ });
vm . runInContext ( part ( source , 'function leaveSettings()' , "$('#preferences-form')" ), c ); c . leaveSettings ();
assert . equal ( c . appearanceGeneration , 4 ); assert . equal ( c . settingsTimer , null ); assert . equal ( applied , 1 );
});
test ( 'a late backup response cannot disclose details after leaving settings' , async ()=>{
let finish , writes = 0 ; const c = vm . createContext ({ appearanceGeneration : 1 , activePage : 'settings' , api : ()=> new Promise ( r => finish = r ), renderBackup : ()=> writes ++ });
vm . runInContext ( part ( source , 'async function pollBackup()' , "$('#backup-form')" ), c );
const pending = c . pollBackup (); c . appearanceGeneration ++ ; c . activePage = 'households' ; finish ({ private : true }); await pending ; assert . equal ( writes , 0 );
});
test ( 'late backup save or run cannot update a newly opened account' , async ()=>{
let save , finish , writes = 0 ; const button = {}, elements = { enabled : { checked : true }, time : { value : '03:00' }, keep : { value : '14' }, networkPath : { value : '' }}, node = { addEventListener : ( name , fn )=> save = fn };
const c = vm . createContext ({ appearanceGeneration : 1 , backupRevision : 0 , $ : ()=> node , fillBackup : ()=> writes ++ , pollBackup : ()=> writes ++ , api : ()=> new Promise ( r => finish = r )});
vm . runInContext ( source . slice ( source . indexOf ( "$('#backup-form').addEventListener" )), c );
const pending = save ({ preventDefault (){}, currentTarget : { elements , querySelector : ()=> button }}); c . appearanceGeneration ++ ; finish ({}); await pending ; assert . equal ( writes , 0 );
const run = node . onclick (); c . appearanceGeneration ++ ; finish ({}); await run ; assert . equal ( writes , 0 );
});
test ( 'backup messages distinguish local-only, network copy, and failure' ,()=>{
const c = vm . createContext ({}); vm . runInContext ( part ( source , 'function backupLabel(' , 'function renderBackup(' ), c );
assert . match ( c . backupLabel ({ localOk : true , networkOk : false }), /本机/ ); assert . doesNotMatch ( c . backupLabel ({ localOk : true }), /网络|云端/ );
assert . match ( c . backupLabel ({ error : 'offline' , localOk : true }), /未完成/ ); assert . match ( c . backupLabel ({ networkOk : true }), /网络目录/ );
});
test ( 'place cards group by city without exposing an inline photo payload' ,()=>{
const out = { innerHTML : '' }, filter = {}; const c = vm . createContext ({ state : { familyId : 'a' , households : [{ id : 'a' , name : 'Home' , province : 'Province' , city : 'City A' , members : 1 , sites : 1 , coverUrl : '/api/households/cover?id=a' , coverData : 'PRIVATE-PHOTO' },{ id : 'b' , name : 'Office' , city : 'City B' , kind : 'office' , members : 1 , sites : 0 }]},
2026-10-04 21:30:30 +08:00
ZhaoGeography : require ( './web/geography.js' ), activePage : 'households' , setOptions : ()=>{}, $ : s => s === '#household-list' ? out : filter , isAdmin : ()=> false , esc : s => String ( s ), scopeKind : h => h . kind || 'home' });
2026-10-04 17:22:42 +08:00
vm . runInContext ( part ( homes , 'function renderHouseholds(' , 'async function switchHousehold(' ), c ); c . renderHouseholds ();
assert . match ( out . innerHTML , /City A/ ); assert . match ( out . innerHTML , /City B/ ); assert . match ( out . innerHTML , /\/api\/households\/cover\?id=a/ );
assert . doesNotMatch ( out . innerHTML , /PRIVATE-PHOTO|data-edit-household/ );
});
test ( 'publication text is rendered as text and the export omits private state' ,()=>{
const apply = part ( source , 'function applyBranding(' , 'async function loadBranding(' ); assert . doesNotMatch ( apply , /innerHTML/ );
const payload = part ( source , 'function brandPayload()' , "\n$('#brand-form').addEventListener" ); assert . doesNotMatch ( payload , /password|currentUser|backupRevision|networkPath/ );
assert . match ( source , /digital-steward-appearance/ );
});
test ( 'image conversion resizes oversized output and closes its decoded bitmap' , async ()=>{
let closed = 0 , draws = 0 ; const bitmap = { width : 1600 , height : 800 , close (){ closed ++ ;}}, canvas = { width : 0 , height : 0 , getContext : ()=>({ drawImage (){ draws ++ ;}}), toDataURL : ()=> draws === 1 ? 'data:image/png;base64,' + 'A' . repeat ( 3000000 ) : 'data:image/png;base64,AA==' };
const c = vm . createContext ({ createImageBitmap : async ()=> bitmap , document : { createElement : ()=> canvas }});
vm . runInContext ( part ( source , 'async function readImage(' , 'for(const [id,type,max]' ), c );
await assert . rejects ( c . readImage ({ type : 'image/svg+xml' , size : 20 }), /PNG/ );
assert . equal ( await c . readImage ({ type : 'image/png' , size : 100 }, 960 ), 'data:image/png;base64,AA==' );
assert . equal ( canvas . width , 768 ); assert . equal ( canvas . height , 384 ); assert . equal ( closed , 1 );
});
2026-10-04 17:31:37 +08:00
test ( 'settings opened before login resolves reloads once after the account is known' ,()=>{
let loads = 0 ; const c = vm . createContext ({ applyBranding (){}, applyAppearance (){}, currentUser : { id : 'root' }, appearanceOwner : '' , activePage : 'settings' , branding : {}, loadSettings (){ loads ++ ;}});
vm . runInContext ( part ( source , 'function onAppearanceSession(' , 'function resetAppearance(' ), c );
c . onAppearanceSession ({ values : {}, defaults : {}, revision : 0 }); c . onAppearanceSession ({ values : {}, defaults : {}, revision : 0 });
assert . equal ( loads , 1 ); assert . equal ( c . appearanceOwner , 'root' );
});
2026-10-04 19:22:31 +08:00
test ( 'replacing or resetting an image invalidates older decode jobs' ,()=>{
const c = vm . createContext ({ pageInfo : {}, document : { documentElement : { dataset : {}}}});
vm . runInContext ( source . slice ( 0 , source . indexOf ( 'function applyBranding(' )), c );
const first = c . nextImageJob ( 'logo' ), second = c . nextImageJob ( 'logo' ); assert . equal ( c . imageJobCurrent ( 'logo' , first ), false ); assert . equal ( c . imageJobCurrent ( 'logo' , second ), true );
c . invalidateBrandImages (); assert . equal ( c . imageJobCurrent ( 'logo' , second ), false );
});
2026-10-04 21:30:30 +08:00
test ( 'cover accepts a large original but compresses below proxy request limits' , async ()=>{
let size = 0 ; const canvas = { getContext : ()=>({ drawImage (){}}), toDataURL : ()=> 'data:image/png;base64,' + 'A' . repeat ( ++ size === 1 ? 1000000 : 500000 )};
const c = vm . createContext ({ createImageBitmap : async ()=>({ width : 8000 , height : 6000 , close (){}}), document : { createElement : ()=> canvas }});
vm . runInContext ( part ( source , 'async function readImage(' , 'for(const [id,type,max]' ), c );
const result = await c . readImage ({ type : 'image/jpeg' , size : 15 * 1024 * 1024 }, 960 );
assert . ok (( result . length - 22 ) * . 75 < 600 * 1024 ); assert . ok ( canvas . width <= 960 );
await assert . rejects ( c . readImage ({ type : 'image/jpeg' , size : 31 * 1024 * 1024 }, 960 ), /30 MB/ );
await assert . rejects ( c . readImage ({ type : 'image/jpeg' , size : 15 * 1024 * 1024 }, 256 ), /5 MB/ );
});
test ( 'failed and still-processing covers block profile save instead of reporting success' , async ()=>{
let submit , calls = 0 ; const error = { textContent : '' }, f = { querySelector : ()=> error , addEventListener : ( _ , fn )=> submit = fn };
const jobs = new Map ([[ 'cover' ,{ error : true }]]);
const c = vm . createContext ({ coverSaving : false , $ : ()=> f , imageJobs : jobs , imageJobPending : ()=> false , api : async ()=>{ calls ++ ;}, formValues : ()=>{ throw Error ( 'must not serialize failed image' );}});
vm . runInContext ( part ( homes , "$('#household-form').addEventListener('submit'" , 'document.addEventListener' ), c );
await submit ({ preventDefault (){}, currentTarget : f }); assert . equal ( calls , 0 ); assert . match ( error . textContent , /封面未准备成功/ );
c . imageJobPending = ()=> true ; await submit ({ preventDefault (){}, currentTarget : f }); assert . match ( error . textContent , /仍在处理/ );
});