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 support saved layout, city grouping and private photo references',()=>{ const nodes=new Map(),node=s=>{if(!nodes.has(s))nodes.set(s,{innerHTML:'',style:{setProperty(){}},setAttribute(){},addEventListener(){}});return nodes.get(s);}; const c=vm.createContext({state:{familyId:'a',householdLayout:{revision:0,columns:2,rows:2,groupCity:true,order:['b','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}]}, currentUser:{id:'viewer'},pageInfo:{},ZhaoGeography:require('./web/geography.js'),activePage:'households',setOptions:()=>{},$:node,$$:()=>[],isAdmin:()=>false,esc:s=>String(s),scopeKind:h=>h.kind||'home'}); vm.runInContext(homes.slice(0,homes.indexOf('async function switchHousehold(')),c);c.renderHouseholds(); const out=node('#household-list');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/);assert.ok(out.innerHTML.indexOf('Office'){ 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); }); 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,/仍在处理/); });