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}]}, activePage:'households',setOptions:()=>{},$:s=>s==='#household-list'?out:filter,isAdmin:()=>false,esc:s=>String(s),scopeKind:h=>h.kind||'home'}); 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); }); 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'); }); 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); });