const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs'); const app=fs.readFileSync('web/app.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)); test('API uses current household scope for each new request',async()=>{ const calls=[];const c=vm.createContext({sessionGeneration:0,householdScope:'house-a',fetch:async(p,o)=>{calls.push(o);return {ok:true,json:async()=>({})};}}); vm.runInContext(part(app,'async function api(','function toast('),c); await c.api('/api/state');c.householdScope='house-b';await c.api('/api/users',{}); assert.equal(calls[0].headers['X-Household-Id'],'house-a');assert.equal(calls[1].headers['X-Household-Id'],'house-b'); }); test('switch destroys old private state before requesting another household',async()=>{ const events=[];const c=vm.createContext({state:{familyId:'a'},currentUser:{id:'root'},isPlatformAdmin:()=>true, clearPrivateView(){events.push('clear');c.currentUser=null;c.state={};},auth:async()=>{events.push('auth');assert.equal(c.householdScope,'b');assert.equal(c.currentUser.id,'root');}}); vm.runInContext(part(homes,'async function switchHousehold(','function openHousehold('),c);await c.switchHousehold('b'); assert.deepEqual(events,['clear','auth']); }); test('family administrator cannot invoke household switching',async()=>{ const c=vm.createContext({state:{familyId:'a'},isPlatformAdmin:()=>false,clearPrivateView:()=>{throw Error('should not switch');}}); vm.runInContext(part(homes,'async function switchHousehold(','function openHousehold('),c);await c.switchHousehold('b'); }); test('household switch invalidates outstanding API data',async()=>{ let finish;const c=vm.createContext({sessionGeneration:0,householdScope:'a',fetch:()=>new Promise(r=>finish=r)}); vm.runInContext(part(app,'async function api(','function toast('),c); const pending=c.api('/api/users');c.sessionGeneration++;c.householdScope='b';finish({ok:true,json:async()=>({users:['private-a']})}); await assert.rejects(pending,e=>e.stale===true); }); test('owner is visibly distinct from family and super administrators',()=>{ const c=vm.createContext({state:{households:[{ownerId:'owner'}]}}); vm.runInContext(part(app,'const roleName =','function applyPermissions(')+';globalThis.label=roleName;',c); assert.equal(c.label({id:'owner',role:'family_admin'}),'一家之主'); assert.equal(c.label({id:'backup',role:'family_admin'}),'家庭管理员'); assert.equal(c.label({id:'root',role:'admin'}),'超级管理员'); }); function saveHarness({saveError,refreshError}={}){ const events=[],error={textContent:''},elements=[{disabled:false},{disabled:true}]; elements.id={value:'home'};elements.profileRevision={value:0}; const form={elements,querySelector:()=>error,closest:()=>({close:()=>events.push('closed')})}; let submit; const state={households:[{id:'home',name:'原档案'}]}; const c=vm.createContext({coverSaving:false,coverDraft:'data:image/png;base64,test',sessionGeneration:1, $:()=>({addEventListener:(_type,fn)=>{submit=fn;}}),imageJobPending:()=>false,imageJobs:new Map(), formValues:()=>({id:'home',name:'已保存'}),state, api:async()=>{if(saveError)throw saveError;return {id:'home',name:'已保存',profileRevision:1,coverRevision:'new',coverRef:{fsId:'1'}};}, renderHouseholds:()=>events.push('rendered'),toast:t=>events.push(t), refresh:async()=>{events.push('refresh');if(refreshError)throw refreshError;}}); vm.runInContext(part(homes,"$('#household-form').addEventListener('submit'",'document.addEventListener'),c); return {run:()=>submit({preventDefault(){},currentTarget:form}),events,error,elements,state,c}; } test('saved cover stays committed and success is visible before a failed refresh',async()=>{ const h=saveHarness({refreshError:Error('network')});await h.run(); assert.equal(h.state.households[0].name,'已保存');assert.equal(h.elements.profileRevision.value,1); assert.match(h.state.households[0].coverUrl,/v=new/); assert.ok(h.events.indexOf('封面已存百度网盘,归属档案已保存')e.disabled),[false,true]);assert.equal(h.c.coverSaving,false); }); test('failed cover save keeps the draft open and does not claim success',async()=>{ const h=saveHarness({saveError:Error('网盘暂时不可用')});await h.run(); assert.equal(h.state.households[0].name,'原档案');assert.equal(h.error.textContent,'网盘暂时不可用'); assert.deepEqual(h.events,[]);assert.equal(h.c.coverDraft,'data:image/png;base64,test'); }); test('a stale post-save refresh does not show a new-session warning',async()=>{ const h=saveHarness({refreshError:Object.assign(Error('stale'),{stale:true})});await h.run(); assert.equal(h.events.at(-1),'refresh');assert.equal(h.state.households[0].name,'已保存'); }); test('storage guide names cloud categories and avoids old all-local claims',()=>{ const html=fs.readFileSync('web/index.html','utf8'); assert.ok(html.includes('id="baidu-storage-guide"')); for(const label of ['家庭与场所:','平台外观:','个人记事:','数据库备份:'])assert.ok(html.includes(label)); assert.ok(!html.includes('自有部署 · 本地存储'));assert.ok(!app.includes('数据存于本机')); });