const test = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); const createLoader = require('./load-module.cjs'); function setup() { const storage = {userId: 1, coupleId: 2, token: 'token'}; const requests = []; let now = 1000000; class Clock extends Date { static now() { return now; } } const wx = {getStorageSync: key => storage[key], setStorageSync: (key,value)=>storage[key]=value, request: options => requests.push(options), hideLoading(){}, showToast(){}}; const definitions = []; const load = createLoader({wx, console, Promise, Date: Clock, Page: definition=>definitions.push(definition)}); const cache = load(path.join(__dirname, '../utils/data-cache.js')); const {api} = load(path.join(__dirname, '../utils/request.js')); const {withDataCache} = load(path.join(__dirname, '../utils/page-cache.js')); const flush = async () => { for(let i=0;i<30;i++) await Promise.resolve(); }; const respond = (index, data, statusCode=200) => requests[index].success({statusCode, data}); return {load, definitions, storage, requests, cache, api, withDataCache, flush, respond, advance: ms=>now+=ms}; } test('identical reads share a request, return independent objects and expire', async()=>{ const e=setup(); const one=e.api.diaryList({page:1,page_size:20}), two=e.api.diaryList({page_size:20,page:1}); await e.flush(); assert.equal(e.requests.length,1); e.respond(0,{items:[{title:'old'}]});const [a,b]=await Promise.all([one,two]); a.items[0].title='formatted';assert.equal(b.items[0].title,'old'); assert.equal((await e.api.diaryList({page:1,page_size:20})).items[0].title,'old');assert.equal(e.requests.length,1); const different=e.api.diaryList({page:2,page_size:20});await e.flush();assert.equal(e.requests.length,2);e.respond(1,{items:[]});await different; e.advance(e.cache.TTL+1);const expired=e.api.diaryList({page:1,page_size:20});await e.flush();assert.equal(e.requests.length,3);e.respond(2,{items:[]});await expired; }); test('successful mutations invalidate related reads; unrelated and failed writes retain cache',async()=>{ const e=setup();const diary=e.api.diaryList(),album=e.api.albumList();await e.flush();e.respond(0,{items:[]});e.respond(1,[]);await Promise.all([diary,album]); const failed=e.api.diaryUpdate({id:1});await e.flush();e.respond(2,{error:'failed'},500);await assert.rejects(failed); await e.api.diaryList();assert.equal(e.requests.length,3); const edit=e.api.diaryUpdate({id:1});await e.flush();e.respond(3,{success:true});await edit; await e.api.albumList();assert.equal(e.requests.length,4); const updated=e.api.diaryList();await e.flush();assert.equal(e.requests.length,5);e.respond(4,{items:[{id:1}]});await updated; }); test('scope changes, errors and invalidation while reading never reuse stale results',async()=>{ const e=setup();const first=e.api.diaryList();await e.flush();e.cache.invalidate(['diaryList']);e.respond(0,{items:[{id:'old'}]});await first; const fresh=e.api.diaryList();await e.flush();assert.equal(e.requests.length,2);e.respond(1,{items:[]});await fresh; e.storage.userId=3;const other=e.api.diaryList();await e.flush();assert.equal(e.requests.length,3);e.respond(2,{error:'offline'},503);await assert.rejects(other); const retry=e.api.diaryList();await e.flush();assert.equal(e.requests.length,4);e.respond(3,{items:[]});await retry; }); test('each upper page observes changes independently and pull refresh bypasses cached reads',async()=>{ const e=setup();let loads=0; function page() { const definition=e.withDataCache({data:{loading:true}, async loadData(){loads++;this.data.items=await e.api.diaryList();}, async onPullDownRefresh(){await this.loadData();}}, {loadData:['diaryList']}); return {...definition,data:{...definition.data}}; } const a=page(),b=page();let pending=Promise.all([a.loadData(),a.loadData(),b.loadData()]);await e.flush();assert.equal(e.requests.length,1);e.respond(0,{items:[]});await pending; await Promise.all([a.loadData(),b.loadData()]);assert.equal(loads,2); e.cache.mutation('diaryUpdate',{success:true});pending=Promise.all([a.loadData(),b.loadData()]);await e.flush();assert.equal(e.requests.length,2);e.respond(1,{items:[{id:1}]});await pending;assert.equal(loads,4); pending=a.onPullDownRefresh();await e.flush();assert.equal(e.requests.length,3);e.respond(2,{items:[]});await pending; e.advance(e.cache.TTL+1);pending=b.loadData();await e.flush();assert.equal(e.requests.length,4);e.respond(3,{items:[]});await pending; }); test('permission reads remain live and longer profile TTL does not extend list TTL',async()=>{ const e=setup();let p=e.api.periodList();await e.flush();e.respond(0,{cycles:[]});await p; p=e.api.periodList();await e.flush();e.respond(1,{cycles:[]});await p;assert.equal(e.requests.length,2); p=e.api.coupleInfo();await e.flush();e.respond(2,{partner1:{id:1}});await p;e.advance(45001);await e.api.coupleInfo();assert.equal(e.requests.length,3); }); test('actual diary detail keeps data on return and updates after editing in a child page',async()=>{ const e=setup();e.load(path.join(__dirname,'../pages/diary_detail/diary_detail.js')); const definition=e.definitions[0]; const page={...definition,data:{...definition.data,diaryId:7},setData(values){Object.assign(this.data,values);}}; let pending=Promise.all([page.loadDiary(),page.loadComments()]);await e.flush(); assert.equal(e.requests.length,2);e.respond(0,{id:7,title:'before',images:[]});e.respond(1,[]);await pending; page.onShow();await e.flush();assert.equal(e.requests.length,2);assert.equal(page.data.diary.title,'before'); pending=e.api.diaryUpdate({id:7,title:'after'});await e.flush();e.respond(2,{success:true});await pending; page.onShow();await e.flush();assert.equal(e.requests.length,5);assert.equal(page.data.loading,false); e.respond(3,{id:7,title:'after',images:[]});e.respond(4,[]); await Promise.all([page.loadDiary(),page.loadComments()]);assert.equal(page.data.diary.title,'after'); }); test('manual refresh during an existing load waits and then fetches the newest data',async()=>{ const e=setup();const definition=e.withDataCache({data:{},async loadData(){this.data.result=await e.api.diaryList();},onRefresh(){return this.loadData();}},{loadData:['diaryList']}); const page={...definition,data:{}};const old=page.loadData();await e.flush(); const refresh=page.onRefresh();e.respond(0,{items:[{id:'old'}]});await old;await e.flush(); assert.equal(e.requests.length,2);e.respond(1,{items:[{id:'new'}]});await refresh; assert.equal(page.data.result.items[0].id,'new'); }); test('switching back to records resets scroll while retaining cached content without a loader',async()=>{ const e=setup();e.load(path.join(__dirname,'../pages/discover/discover.js')); const definition=e.definitions[0];const updates=[];let tabSyncs=0; const page={...definition,data:{...definition.data},getTabBar:()=>({syncActive(){tabSyncs++;}}),setData(values, callback){updates.push(values);Object.assign(this.data,values);if(callback)callback();}}; const pending=page.loadStats();await e.flush();e.respond(0,[]);e.respond(1,[]);e.respond(2,{items:[{id:7,content:'hello'}],total:1});await pending; page.onShow();await e.flush(); page.onScroll({detail:{scrollTop:600}});page._savePosBeforeLeave();assert.equal(e.storage.tabScrollPositions.discover.scrollTop,600); const {resetScrollOnTabShow}=e.load(path.join(__dirname,'../utils/scroll-pos.js')); resetScrollOnTabShow({data:{scrollTop:0}},'profile'); updates.length=0;page.onShow();await e.flush();assert.equal(tabSyncs,2);assert.equal(e.requests.length,3); assert.equal(page.data.entries[0].id,7);assert.ok(updates.every(value=>value.loading!==true)); assert.deepEqual(updates.filter(value=>'scrollTop' in value).map(value=>value.scrollTop),[600,0]); assert.equal(page._scrollTop,0);assert.equal(e.storage.tabScrollPositions.discover.scrollTop,0); page.onScroll({detail:{scrollTop:250}});updates.length=0;page.onShow();await e.flush(); assert.ok(updates.every(value=>!('scrollTop' in value)));assert.equal(page._scrollTop,250);assert.equal(e.requests.length,3); }); test('photo metadata updates invalidate album and photo caches',async()=>{ const e=setup();let pending=Promise.all([e.api.photos(7),e.api.albumList()]);await e.flush();e.respond(0,[]);e.respond(1,[]);await pending; pending=e.api.photoUpdate({id:1,taken_at:'',location:'公园',activity:'散步'});await e.flush();e.respond(2,{success:true});await pending; pending=Promise.all([e.api.photos(7),e.api.albumList()]);await e.flush();assert.equal(e.requests.length,5);e.respond(3,[]);e.respond(4,[]);await pending; }); test('storage reads are reused and upload or confirmed payment invalidates their usage',async()=>{ const e=setup();let p=e.api.storageInfo();await e.flush();e.respond(0,{used_bytes:10});await p; await e.api.storageInfo();assert.equal(e.requests.length,1); e.cache.mutation('upload',{success:true});p=e.api.storageInfo();await e.flush();assert.equal(e.requests.length,2);e.respond(1,{used_bytes:20});await p; e.cache.mutation('storageOrderStatus',{status:'paid'});p=e.api.storageInfo();await e.flush();assert.equal(e.requests.length,3);e.respond(2,{quota_bytes:1024});await p; }); test('remote versions retain unchanged data and invalidate every page after partner or admin changes',async()=>{ const e=setup();e.cache.applyRemoteRevision('initial');let loads=0; const page=e.withDataCache({data:{},async load(){loads++;this.data.items=await e.api.diaryList();}},{load:['diaryList']}); let p=page.load();await e.flush();e.respond(0,{items:[]});await p; assert.equal(e.cache.applyRemoteRevision('initial'),false);await page.load();assert.equal(e.requests.length,1); assert.equal(e.cache.applyRemoteRevision('partner-change'),true);p=page.onDataChanged();await e.flush();e.respond(1,{items:[{id:1}]});await p;assert.equal(loads,2);assert.equal(page.data.items.items[0].id,1); const stale=e.api.photos(7);await e.flush();e.cache.applyRemoteRevision('admin-change');e.respond(2,{items:[{id:'old'}]});await stale; p=e.api.photos(7);await e.flush();assert.equal(e.requests.length,4);e.respond(3,{items:[{id:'new'}]});await p; });