更新最新代码

This commit is contained in:
CodeBuddy
2026-10-26 17:32:47 +08:00
commit 0f5dc01674
315 changed files with 42640 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const path=require('node:path');
const createLoader=require('./load-module.cjs');
const {metadataRows}=require('../utils/photo-metadata');
function setup(update=async()=>({success:true})) {
let definition;const calls=[],toasts=[],prompts=[];
const wx={getStorageSync:key=>key==='userId'?1:2,showToast:v=>toasts.push(v),showModal:v=>prompts.push(v)};
const api={photoUpdate:async data=>{calls.push(data);return update(data);}};
const load=createLoader({wx,console,Promise,Date,Page:p=>definition=p},{'../../utils/request':{api,getImageUrl:v=>v}, '../../utils/user-location':{currentUserLocation:async()=>({location:'',lat:0,lng:0})}});
load(path.join(__dirname,'../pages/album/album.js'));
const photos=[{id:1,url:'one.jpg',taken_at:'2026-10-07',location:'公园',activity:'散步',lat:23},{id:2,url:'two.jpg',taken_at:'',location:'',activity:''}];
const page={...definition,data:{...definition.data,batches:[{batch_id:'batch',photos,metadata:metadataRows(photos)}]},setData(v){Object.assign(this.data,v);}};
return {page,calls,toasts,prompts};
}
const event=(dataset,value)=>({currentTarget:{dataset},detail:{value}});
test('card combines identical information and labels individual photos when their stories differ',()=>{
assert.deepEqual(metadataRows([{id:1},{id:2}]),[]);
const same=metadataRows([{id:1,location:'公园'},{id:2,location:'公园'}]);assert.equal(same.length,1);assert.equal(same[0].label,'');
const different=metadataRows([{id:1,activity:'散步'},{id:2,location:'公园'}]);assert.equal(different.length,2);assert.equal(different[1].label,'第 2 张');
});
test('saving updates only the chosen photo and preview while preserving the list and coordinates',async()=>{
const e=setup(),p=e.page;p.onPhotoTap(event({batch:0,index:0}));assert.equal(p.data.previewImages[0].location,'公园');
p.openPhotoEditor(event({batch:0}));p.onEditPhotoInput(event({field:'activity'},'第一次一起看日落'));await p.savePhotoInfo();
assert.equal(e.calls.length,1);assert.equal(e.calls[0].id,1);assert.equal(p.data.batches[0].photos[0].activity,'第一次一起看日落');assert.equal(p.data.batches[0].photos[0].lat,23);
assert.equal(p.data.batches[0].photos[1].activity,'');assert.equal(p.data.previewImages[0].activity,'第一次一起看日落');assert.equal(p.data.showPhotoEditor,false);assert.equal(p.data.savingPhoto,false);
});
test('failed saves keep the draft and original card; switching photos confirms discarding changes',async()=>{
const e=setup(async()=>{throw {error:'保存失败'};}),p=e.page;p.openPhotoEditor(event({batch:0}));p.onEditPhotoInput(event({field:'location'},'新的地点'));
p.selectEditPhoto(event({index:1}));assert.equal(e.prompts.length,1);assert.equal(p.data.editPhotoIdx,0);e.prompts[0].success({confirm:false});assert.equal(p.data.editForm.location,'新的地点');
await p.savePhotoInfo();assert.equal(p.data.showPhotoEditor,true);assert.equal(p.data.savingPhoto,false);assert.equal(p.data.editForm.location,'新的地点');assert.equal(p.data.batches[0].photos[0].location,'公园');
p.selectEditPhoto(event({index:1}));e.prompts[1].success({confirm:true});assert.equal(p.data.editPhotoIdx,1);assert.equal(p.data.editForm.location,'');
});
test('date and optional fields can be cleared without duplicate saves',async()=>{
let resolve;const e=setup(()=>new Promise(done=>resolve=done)),p=e.page;p.openPhotoEditor(event({batch:0}));p.clearPhotoDate();
p.onEditPhotoInput(event({field:'location'},''));p.onEditPhotoInput(event({field:'activity'},''));const saving=p.savePhotoInfo();await p.savePhotoInfo();assert.equal(e.calls.length,1);assert.equal(e.calls[0].taken_at,'');
resolve({success:true});await saving;assert.equal(p.data.batches[0].metadata.length,0);
});
test('blank edit address uses current user location; a supplied address wins',async()=>{
const e=setup(),p=e.page;
// 使用实际页面的保存逻辑和可控的地址提供者。
let definition;
const loader=createLoader({wx:{getStorageSync:()=>1,showToast(){}},Date,Promise,console,Page:d=>definition=d},{'../../utils/request':{api:{photoUpdate:async data=>{e.calls.push(data);return {success:true,location:data.location};}},getImageUrl:v=>v},'../../utils/user-location':{currentUserLocation:async()=>({location:'当前用户地址',lat:25,lng:102})}});
loader(path.join(__dirname,'../pages/album/album.js'));
p.savePhotoInfo=definition.savePhotoInfo;
p.openPhotoEditor(event({batch:0}));p.onEditPhotoInput(event({field:'location'},' '));await p.savePhotoInfo();assert.equal(e.calls[0].location,'当前用户地址');assert.equal(e.calls[0].lat,25);assert.equal(p.data.batches[0].photos[0].location,'当前用户地址');
p.openPhotoEditor(event({batch:0}));p.onEditPhotoInput(event({field:'location'},' 手填地址 '));await p.savePhotoInfo();assert.equal(e.calls[1].location,'手填地址');assert.equal(e.calls[1].lat,0);
});
+72
View File
@@ -0,0 +1,72 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
function setup() {
const storage = new Map();
const logins = [], requests = [];
let app;
const wx = {
getStorageSync: key => storage.get(key), setStorageSync: (key,value) => storage.set(key,value),
removeStorageSync: key => storage.delete(key), getStorageInfoSync: () => ({keys:[...storage.keys()]}),
login: options => logins.push(options), request: options => requests.push(options),
getNetworkType: options => options.success({networkType:'wifi'}), onNetworkStatusChange(){},
hideLoading(){}, showToast(){}
};
const loadModule = require('./load-module.cjs')({wx, getApp:()=>app, console, Promise, Date});
const ctx = {wx, getApp:()=>app, console, Promise, setTimeout, clearTimeout,
module:{exports:{}}, require: name => loadModule(path.join(__dirname,'../utils', name + '.js'))};
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../utils/request.js'),'utf8'),ctx);
const request = ctx.module.exports;
ctx.require = () => request;
ctx.App = definition => {app=definition;};
vm.runInNewContext('(function(){' + fs.readFileSync(path.join(__dirname,'../app.js'),'utf8') + '\n})()',ctx);
return {app,wx,storage,logins,requests,api:request.api};
}
const flush = async()=> {for(let i=0;i<40;i++) await Promise.resolve();};
async function finishLogin(env, token='fresh') {
env.logins.at(-1).success({code:'wechat-code'});
await flush();
const login = env.requests.findLast(req=>req.url.endsWith('/wechatLogin'));
assert.ok(login, 'login endpoint bypasses auth gate');
login.success({statusCode:200,data:{token,userId:87}});
await flush();
}
test('cold start refreshes even cached token and holds concurrent business requests until login completes',async()=>{
const env=setup(); env.storage.set('token','old'); env.app.onLaunch();
const first=env.api.coupleInfo(), second=env.api.diaryList();
await flush(); assert.equal(env.logins.length,1); assert.equal(env.requests.length,0);
await finishLogin(env);
assert.equal(env.app.globalData.authState,'ready');
const business=env.requests.filter(req=>!req.url.endsWith('/wechatLogin'));
assert.equal(business.length,2); business.forEach(req=>{assert.equal(req.header.Authorization,'Bearer fresh');req.success({statusCode:200,data:[]});});
await Promise.all([first,second]);
});
test('first launch without any stored token issues no anonymous business request',async()=>{
const env=setup();env.app.onLaunch();const pending=env.api.periodList();
await flush();assert.equal(env.requests.length,0);assert.equal(env.logins.length,1);
await finishLogin(env);const business=env.requests.find(req=>req.url.endsWith('/periodList'));
assert.equal(business.header.Authorization,'Bearer fresh');
business.success({statusCode:200,data:{cycles:[],canAccess:true}});await pending;
});
test('login failure rejects waiting loads and explicit retry obtains a usable token',async()=>{
const env=setup();env.app.onLaunch();
const failed=assert.rejects(env.api.diaryList(),error=>error.authFailed===true);
env.logins[0].success({});await failed;await flush();
assert.equal(env.app.globalData.authState,'error');assert.equal(env.requests.length,0);
await assert.rejects(env.app.ensureAuth());assert.equal(env.logins.length,1);
const retry=env.app.silentLogin();await finishLogin(env);await retry;
assert.equal(env.storage.get('token'),'fresh');
});
test('parallel 401 responses share token refresh and retry at most once',async()=>{
const env=setup();env.app.onLaunch();await finishLogin(env,'first');
const one=env.api.coupleInfo(),two=env.api.diaryList();await flush();
const old=env.requests.filter(req=>!req.url.endsWith('/wechatLogin'));
old.forEach(req=>req.success({statusCode:401,data:{error:'expired'}}));await flush();
assert.equal(env.logins.length,2);await finishLogin(env,'second');
const retried=env.requests.filter(req=>req.header.Authorization==='Bearer second');assert.equal(retried.length,2);
const failed=assert.rejects(one,error=>error._statusCode===401);
retried[0].success({statusCode:401,data:{error:'still unauthorized'}});retried[1].success({statusCode:200,data:[]});
await failed;await two;assert.equal(env.logins.length,2);
});
+37
View File
@@ -0,0 +1,37 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const path=require('node:path');
const load=require('./load-module.cjs');
function setup(checkinDetail){
let definition;
load({Page:p=>definition=p,wx:{getStorageSync:()=>1},console,Date,Promise,Set},{
'../../utils/page-cache':{withDataCache:p=>p},'../../utils/layout':{getLayout:()=>({})},
'../../utils/request':{api:{checkinDetail},getImageUrl:v=>v},
'../../utils/util':{createSwipeBack:()=>({})},'../../utils/subscribe':{requestSubscribeAfterAction(){}}
})(path.join(__dirname,'../pages/checkin-detail/checkin-detail.js'));
const page={...definition,data:JSON.parse(JSON.stringify(definition.data)),setData(patch){Object.assign(this.data,patch);}};
page.data.detailId=9;return page;
}
const first={item:{id:9,type:'both',created_by:1},logs:[{id:3,created_at:'2026-10-11 09:00:00'}],total:3,has_more:true,my_year:99};
test('first page resets records; next page appends once without replacing full stats',async()=>{
let calls=[];let release;
const page=setup((id,params)=>{calls.push(params);return params.page===1?Promise.resolve(first):new Promise(resolve=>release=resolve);});
await page.loadDetail(9);const pending=page.loadMoreLogs();await page.loadMoreLogs();
assert.equal(calls.length,2);assert.equal(calls[1].page,2);
release({logs:[{id:3},{id:2},{id:1}],total:3,has_more:false,my_year:1});await pending;
assert.deepEqual(page.data.logs.map(x=>x.id),[3,2,1]);assert.equal(page.data.myYear,99);
await page.loadMoreLogs();assert.equal(calls.length,2);
await page.loadDetail(9);assert.equal(page.data.logs.length,1);assert.equal(page.data.logsPage,1);
});
test('failed next page preserves list and retries the same page',async()=>{
let fail=true;const calls=[];
const page=setup(async(id,params)=>{calls.push(params.page);if(params.page===1)return first;if(fail)throw Error('offline');return {logs:[{id:2}],total:3,has_more:true};});
await page.loadDetail(9);await page.loadMoreLogs();assert.equal(page.data.logsPage,1);assert.equal(page.data.logsError,true);assert.equal(page.data.logs.length,1);
fail=false;await page.loadMoreLogs();assert.deepEqual(calls,[1,2,2]);assert.equal(page.data.logsError,false);
});
test('an old next-page response cannot append after refreshed first page',async()=>{
let release;const page=setup(async(id,params)=>params.page===1?first:new Promise(resolve=>release=resolve));
await page.loadDetail(9);const pending=page.loadMoreLogs();await page.loadDetail(9);
release({logs:[{id:1}],has_more:false});await pending;
assert.deepEqual(page.data.logs.map(x=>x.id),[3]);assert.equal(page.data.logsPage,1);
});
+101
View File
@@ -0,0 +1,101 @@
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(45001);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(45001);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;
});
+56
View File
@@ -0,0 +1,56 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const createLoader = require('./load-module.cjs');
function setup(api = {}) {
let definition; const patches = []; let hidden = 0;
createLoader({ Page: value => definition = value, wx: { hideKeyboard: () => hidden++, showToast() {} }, console, Date, Promise }, {
'../../utils/page-cache': { withDataCache: value => value },
'../../utils/layout': { getLayout: () => ({}) },
'../../utils/request': { api, getImageUrl: value => value }
})(path.join(__dirname, '../pages/diary_detail/diary_detail.js'));
const page = { ...definition, data: { ...definition.data, diaryId: 1, diary: { comment_count: 0 } },
setData(patch) { patches.push(patch); for (const [key, value] of Object.entries(patch)) {
if (key === 'diary.comment_count') this.data.diary.comment_count = value;
else this.data[key] = value;
} } };
return { page, patches, hidden: () => hidden };
}
test('composer follows keyboard without rewriting text, and caps long input height', () => {
const { page, patches } = setup(); page.onCommentTap(); patches.length = 0;
page.onCommentInput({ detail: { value: '字'.repeat(300) } });
page.onCommentKeyboardHeight({ detail: { height: 360 } });
page.onCommentLineChange({ detail: { height: 400 } });
assert.equal(page.data.commentKeyboardHeight, 360);
assert.equal(page.data.commentEditorHeight, 160);
assert.equal(page.data.commentText.length, 300);
assert.ok(patches.every(p => !('commentInitialText' in p) && !('commentText' in p)));
page.onCommentKeyboardHeight({ detail: { height: 0 } });
assert.equal(page.data.commentKeyboardHeight, 0);
});
test('closing and reopening preserves draft while changing reply target resets it', () => {
const { page } = setup(); page.onCommentTap(); page.onCommentInput({ detail: { value: '未发送' } });
page.cancelComment(); page.onCommentTap(); assert.equal(page.data.commentInitialText, '未发送');
page.onReplyTap({ currentTarget: { dataset: { item: { id: 2, user_id: 3, nickname: 'TA' } } } });
assert.equal(page.data.commentText, ''); assert.equal(page.data.replyTo.id, 2);
page.onHide(); assert.equal(page.data.commentKeyboardHeight, 0); assert.equal(page.data.commentFocus, false);
});
test('failed send preserves draft and allows retry; concurrent send is ignored', async () => {
let release; let calls = 0;
const { page } = setup({ diaryCommentAdd: () => { calls++; return new Promise((resolve, reject) => release = reject); } });
page.onCommentTap(); page.onCommentInput({ detail: { value: '保留这句话' } });
const pending = page.submitComment(); await page.submitComment(); assert.equal(calls, 1);
release({ error: '网络错误' }); await pending;
assert.equal(page.data.commentSending, false); assert.equal(page.data.commentText, '保留这句话');
assert.equal(page.data.showCommentInput, true);
});
test('successful reply sends reply metadata and clears composer', async () => {
let params;
const { page, hidden } = setup({ diaryCommentAdd: async value => { params = value; return { success: true, comment: { id: 5 } }; } });
page.onReplyTap({ currentTarget: { dataset: { item: { id: 2, user_id: 3, nickname: 'TA' } } } });
page.onCommentInput({ detail: { value: '回复内容' } }); await page.submitComment();
assert.equal(params.parent_id, 2); assert.equal(params.reply_to_user_id, 3);
assert.equal(page.data.commentText, ''); assert.equal(page.data.commentKeyboardHeight, 0);
assert.equal(page.data.showCommentInput, false); assert.equal(page.data.diary.comment_count, 1);
assert.equal(hidden(), 1);
});
+47
View File
@@ -0,0 +1,47 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const path=require('node:path');
const fs=require('node:fs');
const createLoader=require('./load-module.cjs');
function setup(file,api={}) {
let definition,id=0;const timers=new Map(),storage=new Map(),writes=[];
const wx={getStorageSync:key=>storage.get(key),setStorageSync:(key,value)=>{writes.push(value);storage.set(key,value);},removeStorageSync:key=>storage.delete(key),showToast(){}};
createLoader({Page:p=>definition=p,wx,console,Promise,Date,clearTimeout:key=>timers.delete(key),setTimeout:fn=>{timers.set(++id,fn);return id;}},{'../../utils/request':{api,getImageUrl:v=>v}})(path.join(__dirname,'../pages',file+'.js'));
const patches=[];const page={...definition,data:JSON.parse(JSON.stringify(definition.data)),setData(values){patches.push(values);for(const [key,value] of Object.entries(values)){const parts=key.split('.');let target=this.data;for(const part of parts.slice(0,-1))target=target[part];target[parts.at(-1)]=value;}}};
return {page,timers,storage,writes,patches};
}
test('diary input continues past 100 characters and never rewrites the native textarea value',()=>{
for(const file of ['publish/publish','diary_edit/diary_edit']){
const e=setup(file);if(file.startsWith('publish'))e.page.resetForm('diary');
e.patches.length=0;
for(const length of [100,101,140,1000,5000]){const value='字'.repeat(length);e.page.onContentInput({detail:{value}});assert.equal(e.page.data.form.content,value);assert.equal(e.page.data.contentLength,length);}
assert.ok(e.patches.every(patch=>!('editorContent' in patch)));
assert.ok(e.patches.every(patch=>!('form.content' in patch)));
}
});
test('both diary templates explicitly allow 5000 characters and grow with long text',()=>{
for(const file of ['publish/publish','diary_edit/diary_edit']){
const base=path.join(__dirname,'../pages',file);
const template=fs.readFileSync(base+'.wxml','utf8');
const editor=template.match(/<textarea\b[^>]*class="[^"]*diary-editor[^"]*"[^>]*\/>/)[0];
assert.match(editor,/maxlength="5000"/);
assert.match(editor,/auto-height="\{\{true\}\}"/);
const styles=fs.readFileSync(base+'.wxss','utf8');
assert.doesNotMatch(styles,/textarea\.diary-editor\s*\{[^}]*[;{]\s*height\s*:/);
}
});
test('rapid typing writes a single draft, and leaving the page flushes the newest content',()=>{
const e=setup('publish/publish');e.page.resetForm('diary');e.page.data.type='diary';
for(let i=1;i<=200;i++)e.page.onContentInput({detail:{value:'字'.repeat(i)}});
assert.equal(e.writes.length,0);assert.equal(e.timers.size,1);
for(const callback of [...e.timers.values()])callback();e.timers.clear();assert.equal(e.writes.length,1);assert.equal(JSON.parse(e.writes[0]).form.content.length,200);
e.page.onContentInput({detail:{value:'最后的内容'}});e.page.onHide();assert.equal(e.timers.size,0);assert.equal(JSON.parse(e.writes.at(-1)).form.content,'最后的内容');
});
test('published drafts are not recreated by pending timers or page teardown',()=>{
const e=setup('publish/publish');e.page.resetForm('diary');e.page.data.type='diary';e.page.onContentInput({detail:{value:'已发布'}});
e.page._clearDraft();e.page.onHide();e.page.onUnload();assert.equal(e.timers.size,0);assert.equal(e.writes.length,0);assert.equal(e.storage.has('publishDraft_diary'),false);
});
test('restoring a draft and loading an existing diary initialize the textarea display value',async()=>{
const e=setup('publish/publish');e.page.resetForm('diary');e.storage.set('publishDraft_diary',JSON.stringify({form:{title:'标题',content:'字'.repeat(1000)}}));e.page.loadDraft('diary');assert.equal(e.page.data.editorContent.length,1000);
const edited=setup('diary_edit/diary_edit',{diaryDetail:async()=>({content:'字'.repeat(1500),images:[]})});edited.page.data.diaryId=1;await edited.page.loadDiary();assert.equal(edited.page.data.editorContent.length,1500);assert.equal(edited.page.data.contentLength,1500);
});
+56
View File
@@ -0,0 +1,56 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const root = path.join(__dirname, '..');
function formatter() {
const context = {module:{exports:{}}, require(){return {getImageUrl:p=>p ? 'media/' + p : ''};}};
vm.runInNewContext(fs.readFileSync(path.join(root,'utils/journal.js'),'utf8'),context);
return context.module.exports;
}
function records(api) {
let definition; const routes=[];
vm.runInNewContext(fs.readFileSync(path.join(root,'pages/discover/discover.js'),'utf8'), {
Page:p=>definition=p, console:{error(){}}, setTimeout:f=>f(),
wx:{getStorageSync:()=> 'token',navigateTo:p=>routes.push(p.url)},
require(name) {
if(name.endsWith('/list-pagination'))return require('../utils/list-pagination');
if(name.endsWith('/page-cache'))return {withDataCache:p=>p};
if(name.endsWith('/request'))return {api};
if(name.endsWith('/journal'))return formatter();
if(name.endsWith('/layout'))return {getLayout:()=>({})};
return {saveScrollPosition(){},getScrollPosition:()=>0};
}
});
return {page:{...definition,data:{...definition.data},setData(v){Object.assign(this.data,v);}},routes};
}
test('journal handles both stored image formats, malformed images and text-only entries',()=>{
const {diaryEntry}=formatter();
for(const images of [['one.jpg','two.jpg'],'["one.jpg","two.jpg"]']) {
const entry=diaryEntry({id:314,images,content:'a\n b',created_at:'2026-10-04 18:32:00'});
assert.equal(entry.cover,'media/one.jpg');assert.equal(entry.imageCount,2);assert.equal(entry.summary,'a b');assert.equal(entry.dateLabel,'2026.10.04');assert.equal(entry.id,314);
}
for(const images of ['invalid','{}',null])assert.equal(diaryEntry({images}).cover,'');
});
test('records filters use fresh API data and open registered detail/publish destinations',async()=>{
const {page,routes}=records({timelineList:async()=>[{id:3,event_date:'2026-10-12',title:'旅行'}],albumList:async()=>[{id:4,cover:'a.jpg',photo_count:2}],diaryList:async()=>({items:[{id:314,images:'["b.jpg"]'}],total:1})});
await page.loadStats();assert.equal(page.data.entries[0].id,314);assert.equal(page.data.entries[0].cover,'media/b.jpg');assert.equal(page.data.memories[0].dateDay,'12');assert.equal(page.data.albums[0].cover,'media/a.jpg');assert.equal(page.data.loading,false);
page.selectFilter({currentTarget:{dataset:{filter:'album'}}});assert.equal(page.data.activeFilter,'album');
page.openJournalEntry({currentTarget:{dataset:{id:314}}});page.openAlbum({currentTarget:{dataset:{id:4}}});
page.showPublish();page.createMemory();assert.equal(page.data.publishOpen,false);
assert.deepEqual(routes,['/pages/diary_detail/diary_detail?id=314','/pages/album/album?album_id=4','/pages/publish/publish?type=timeline']);
});
test('refresh failure clears busy states and retry replaces old records with authoritative empty data',async()=>{
let fail=true;
const {page}=records({timelineList:async()=>[],albumList:async()=>[],diaryList:async()=>{if(fail)throw Error('offline');return {items:[],total:0};}});
page.data.entries=[{id:'old'}];await page.onRefresh();assert.equal(page.data.loadError,true);assert.equal(page.data.refreshing,false);assert.equal(page.data.loading,false);
fail=false;await page.loadStats();assert.equal(page.data.loadError,false);assert.equal(page.data.entries.length,0);
});
test('publish drawer hides the native custom tab bar and restores it when closing or navigating',()=>{
const {page}=records({});const hidden=[];page.getTabBar=()=>({setHidden:value=>hidden.push(value)});
page.showPublish();assert.equal(page.data.publishOpen,true);assert.equal(hidden.at(-1),true);
page.hidePublish();assert.equal(page.data.publishOpen,false);assert.equal(hidden.at(-1),false);
page.showPublish();page.onHide();assert.equal(page.data.publishOpen,false);assert.equal(hidden.at(-1),false);
page.showPublish();page.createMemory();assert.equal(page.data.publishOpen,false);assert.equal(hidden.at(-1),false);
});
+39
View File
@@ -0,0 +1,39 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const createLoader = require('./load-module.cjs');
function setup(initial, seen = true) {
const storage = new Map(seen ? [['launchIntroSeen', true]] : []);
let definition, notify, unsubscribed = false;
const timers = new Map();let timerId=0;
const app={observeAuth(callback){notify=callback;callback(initial,'');return ()=>{unsubscribed=true;};}};
const load=createLoader({wx:{getStorageSync:k=>storage.get(k),setStorageSync:(k,v)=>storage.set(k,v)},Component:c=>definition=c,getApp:()=>app,Date,
setTimeout(fn){timers.set(++timerId,fn);return timerId;},clearTimeout(id){timers.delete(id);}});
load(path.join(__dirname,'../components/launch-transition/launch-transition.js'));
const updates=[];
const component={...definition.methods,data:{...definition.data},setData(values){updates.push(values);Object.assign(this.data,values);}};
return {definition,component,updates,timers,storage,notify:(...args)=>notify(...args),unsubscribed:()=>unsubscribed};
}
test('authenticated page transitions never show the launch overlay, even on initial render',()=>{
const e=setup('ready');assert.equal(e.component.data.visible,false);
e.definition.lifetimes.attached.call(e.component);
assert.equal(e.component.data.visible,false);assert.ok(e.updates.every(update=>update.visible!==true));assert.equal(e.timers.size,0);
});
test('cold-start waiting and login failures still show overlay and release subscriptions',()=>{
const e=setup('loading',false);e.definition.lifetimes.attached.call(e.component);assert.equal(e.component.data.visible,true);
e.notify('error','login failed');assert.equal(e.component.data.state,'error');assert.equal(e.component.data.message,'login failed');
e.notify('ready');assert.equal(e.timers.size,0);assert.equal(e.component.data.visible,false);
e.definition.lifetimes.detached.call(e.component);assert.equal(e.unsubscribed(),true);assert.equal(e.timers.size,0);
});
test('introduction is shown once per device, including when login is already ready',()=>{
const first=setup('ready',false);first.definition.lifetimes.attached.call(first.component);
assert.equal(first.component.data.visible,true);assert.equal(first.component.data.intro,true);
assert.equal(first.storage.get('launchIntroSeen'),true);
for(const fn of first.timers.values())fn();assert.equal(first.component.data.visible,false);
first.notify('loading');assert.equal(first.component.data.visible,false);
const next=setup('loading',first.storage.get('launchIntroSeen'));next.definition.lifetimes.attached.call(next.component);
assert.equal(next.component.data.visible,false);
next.notify('error','network unavailable');assert.equal(next.component.data.visible,true);assert.equal(next.component.data.intro,false);
next.notify('ready');assert.equal(next.component.data.visible,false);
});
+39
View File
@@ -0,0 +1,39 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const path=require('node:path');
const helpers=require('../utils/list-pagination');
const envelope=(items,total=42,has_more=true)=>({items,total,has_more});
function page(file,api){
let definition;
const load=require('./load-module.cjs')({Page:p=>definition=p,wx:{getStorageSync:()=>1,showToast(){}},console,Date,Promise,setTimeout},{'../../utils/request':{api,getImageUrl:v=>v},'../../utils/page-cache':{withDataCache:p=>p}});
load(path.join(__dirname,'../pages',file));
return {...definition,data:{...definition.data},setData(values){Object.assign(this.data,values);}};
}
test('songs append once, preserve full total, and retry the failed page',async()=>{
let fail=true;const calls=[];
const p=page('lovewords/lovewords.js',{songsList:async({page,page_size})=>{calls.push(page);assert.equal(page_size,20);if(page===1)return envelope([{id:1}]);if(fail)throw Error('offline');return envelope([{id:1},{id:2}],42,false);}});
await p.loadData();assert.equal(p.data.total,42);
const originalError=console.error;console.error=()=>{};
try {await p.loadMore();}finally{console.error=originalError;}
assert.equal(p.data.page,1);assert.equal(p.data.list.length,1);assert.equal(p.data.moreError,true);
fail=false;await p.loadMore();assert.deepEqual(calls,[1,2,2]);assert.equal(p.data.list.length,2);assert.equal(p.data.hasMore,false);
await p.loadMore();assert.equal(calls.length,3);
});
test('refresh invalidates an in-flight next page',()=>{
const p={data:{list:[]},setData(v){Object.assign(this.data,v);}};
let t=helpers.startList(p,1);helpers.applyList(p,t,envelope([{id:1}]),[{id:1}],'list');helpers.finishList(p,t);
const old=helpers.startList(p,2);t=helpers.startList(p,1);
helpers.applyList(p,t,envelope([{id:9}]),[{id:9}],'list');helpers.finishList(p,t);
helpers.applyList(p,old,envelope([{id:2}]),[{id:2}],'list');helpers.finishList(p,old);
assert.deepEqual(p.data.list,[{id:9}]);assert.equal(p.data.page,1);
});
test('album list appends the next page instead of loading all albums',async()=>{
const calls=[];const p=page('albums/albums.js',{albumList:async({page,page_size})=>{calls.push([page,page_size]);return envelope([{id:page,title:'album'}],21,page===1);}});
await p.loadAlbums();await p.loadMore();assert.deepEqual(calls,[[1,20],[2,20]]);assert.equal(p.data.albums.length,2);assert.equal(p.data.total,21);
});
test('photos crossing page boundaries stay in the same upload card and retain saved metadata',async()=>{
const calls=[];const p=page('album/album.js',{photos:async(id,{page,page_size})=>{calls.push([id,page,page_size]);return envelope([{id:page,batch_id:'same',created_by:1,path:page+'.jpg',created_at:'2026-10-11',location:page===1?'old':'second'}],2,page===1);},coupleInfo:async()=>({partner1:{id:1,nickname:'我'},partner2:{id:2,nickname:'TA'}}),photoUpdate:async()=>({success:true})});
p.data.albumId=8;await p.loadData();
p.data.editPhotos=p.data.batches[0].photos;p.data.editForm={taken_at:'',location:'saved',activity:''};await p.savePhotoInfo();
await p.loadMore();assert.deepEqual(calls,[[8,1,20],[8,2,20]]);assert.equal(p.data.batches.length,1);assert.equal(p.data.batches[0].photos.length,2);assert.equal(p.data.batches[0].photos[0].location,'saved');assert.equal(p.data.hasMore,false);
});
+23
View File
@@ -0,0 +1,23 @@
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
module.exports = function createLoader(globals, mocks = {}) {
const modules = new Map();
function load(file) {
file = path.resolve(file);
if (modules.has(file)) return modules.get(file).exports;
const module = { exports: {} };
modules.set(file, module);
vm.runInNewContext(fs.readFileSync(file, 'utf8'), {
...globals, module, exports: module.exports,
require(name) {
if (mocks[name]) return mocks[name];
if (!name.startsWith('.')) return require(name);
const resolved = path.resolve(path.dirname(file), name);
return load(path.extname(resolved) ? resolved : resolved + '.js');
}
}, { filename: file });
return module.exports;
}
return load;
};
+61
View File
@@ -0,0 +1,61 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const {tabForPage, paths} = require('../utils/navigation');
const root = path.join(__dirname, '..');
test('native custom tabs retain the three main pages; pages never mount a duplicate tab bar',()=>{
const config=JSON.parse(fs.readFileSync(path.join(root,'app.json'),'utf8'));
assert.equal(config.tabBar.custom,true);
assert.deepEqual(config.tabBar.list.map(tab=>tab.pagePath),Object.values(paths));
const pages=config.pages;
for(const page of pages){
const main=Object.values(paths).includes(page);
const markup=fs.readFileSync(path.join(root,page+'.wxml'),'utf8');
const components=JSON.parse(fs.readFileSync(path.join(root,page+'.json'),'utf8')).usingComponents||{};
assert.equal(/<app-tabbar\b/.test(markup),false,page);
assert.ok(!components['app-tabbar'],page);
assert.equal(!!tabForPage(page),main,page);
if(!main){assert.ok(!components['app-tabbar'],page);assert.ok(markup.includes('app-shell--secondary'),page);}
}
assert.equal(tabForPage('/pages/profile/profile?bind=1'),'profile');
assert.equal(tabForPage('unknown'),'');
});
test('component selects the main page, hides on child routes and avoids reloading its current tab',()=>{
let definition,route='pages/profile/profile',pageData={};const navigations=[];
vm.runInNewContext(fs.readFileSync(path.join(root,'custom-tab-bar/index.js'),'utf8'),{
Component:c=>definition=c,require:()=>({tabForPage,paths}),getCurrentPages:()=>[{route,data:pageData}],wx:{switchTab:o=>{navigations.push(o.url);o.complete();},reLaunch(){assert.fail('normal tab switching must retain pages');}}
});
const component={...definition.methods,data:{...definition.data},setData(v){Object.assign(this.data,v);}};
definition.lifetimes.attached.call(component);assert.equal(component.data.active,'profile');
component.navigate({currentTarget:{dataset:{tab:'profile'}}});assert.equal(navigations.length,0);
component.navigate({currentTarget:{dataset:{tab:'records'}}});assert.deepEqual(navigations,['/pages/discover/discover']);
pageData={showEditProfileModal:true};definition.pageLifetimes.show.call(component);assert.equal(component.data.hidden,true);
pageData={showEditPartnerModal:true};definition.pageLifetimes.show.call(component);assert.equal(component.data.hidden,true);
pageData={};definition.pageLifetimes.show.call(component);assert.equal(component.data.hidden,false);
route='pages/settings/settings';definition.pageLifetimes.show.call(component);assert.equal(component.data.active,'');
const markup=fs.readFileSync(path.join(root,'custom-tab-bar/index.wxml'),'utf8');
assert.ok(markup.includes('wx:if="{{active && !hidden}}"'));
});
test('profile editing hides tabs until close and reserves a fixed save area outside the scroll view',()=>{
const createLoader=require('./load-module.cjs');let definition;const visibility=[];
const load=createLoader({Page:value=>definition=value,wx:{getStorageSync:()=>1},console,Date,setTimeout,clearTimeout,setInterval,clearInterval});
load(path.join(root,'pages/profile/profile.js'));
const page={...definition,data:{...definition.data,myNickname:'名字',myInitial:'名',partnerDetail:'20岁'},setData(value){Object.assign(this.data,value);},getTabBar:()=>({setHidden:hidden=>visibility.push(hidden)})};
page.showEditProfileModal();page.hideEditProfileModal();page.showEditPartnerModal();page.hideEditPartnerModal();
assert.deepEqual(visibility,[true,false,true,false]);
const markup=fs.readFileSync(path.join(root,'pages/profile/profile.wxml'),'utf8');
assert.ok(markup.includes('<scroll-view class="edit-body" scroll-y'));
assert.ok(markup.includes('</scroll-view>\n <view class="edit-footer">'));
});
test('photo memory detail uses light system controls and restores them on exit',()=>{
const createLoader=require('./load-module.cjs');let definition;const colors=[];let back=0;
const load=createLoader({Page:value=>definition=value,wx:{setNavigationBarColor:value=>colors.push(value),navigateBack:()=>back++},console,Date},{'../../utils/request':{api:{},getImageUrl:value=>'https://past.sundynix.cn/backend/'+value}});
load(path.join(root,'pages/timeline-detail/timeline-detail.js'));
const page={...definition,data:{...definition.data,item:{bg_image:'uploads/memory.jpg'}},setData(value){Object.assign(this.data,value);}};
page.loadBgImage();assert.equal(colors.at(-1).frontColor,'#ffffff');assert.ok(page.data.bgImageUrl.endsWith('memory.jpg'));
page.onHide();assert.equal(colors.at(-1).frontColor,'#000000');page.goBack();assert.equal(back,1);
page.data.item.bg_image='';page.loadBgImage();assert.equal(page.data.bgImageUrl,'');assert.equal(colors.at(-1).frontColor,'#000000');
});
+31
View File
@@ -0,0 +1,31 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const path=require('node:path');
const fs=require('node:fs');
const createLoader=require('./load-module.cjs');
function setup(update) {
let definition;const calls=[],toasts=[],tabs=[];
const api={updatePartnerProfile:async payload=>{calls.push(payload);return update(payload);}};
const load=createLoader({Page:value=>definition=value,wx:{getStorageSync:()=>1,showToast:value=>toasts.push(value)},console,Date}, {'../../utils/request':{api,getImageUrl:value=>value},'../../utils/page-cache':{withDataCache:page=>page}});
load(path.join(__dirname,'../pages/profile/profile.js'));
let refreshes=0;
const page={...definition,data:{...definition.data,hasPartner:true,partnerId:42,coupleId:2,partnerName:'TA原昵称',partnerDetail:'20岁',partnerGenderRaw:'female',partnerBirthday:'2006-01-01',tempLoveDate:'2025-07-03',loveStartDate:'2025-07-03',tempLoveStory:'原故事'},setData(value){Object.assign(this.data,value);},getTabBar:()=>({setHidden:value=>tabs.push(value)}),async loadData(){refreshes++;}};
return {page,calls,toasts,tabs,refreshes:()=>refreshes};
}
test('partner card opens editing directly and saves the internal partner id with changed fields',async()=>{
const e=setup(async()=>({success:true}));e.page.showEditPartnerModal();
e.page.onEditPartnerNicknameInput({detail:{value:' 新昵称 '}});e.page.onEditPartnerGenderChange({detail:{value:0}});
await e.page.savePartnerProfile();
assert.equal(e.calls[0].partner_id,42);assert.equal(e.calls[0].nickname,'新昵称');assert.equal(e.calls[0].gender,'male');assert.equal(e.calls[0].birthday,'2006-01-01');
assert.equal(e.page.data.showEditPartnerModal,false);assert.equal(e.refreshes(),1);assert.deepEqual(e.tabs,[true,false]);
const markup=fs.readFileSync(path.join(__dirname,'../pages/profile/profile.wxml'),'utf8');
assert.ok(markup.includes('class="j-relation-card" bindtap="showEditPartnerModal"'));assert.ok(!markup.includes('关系管理'));assert.ok(!markup.includes('编辑TA的资料 →'));
});
test('failed partner save preserves editing, repeated save is blocked, and changed relationship cannot submit',async()=>{
let finish;const e=setup(()=>new Promise(resolve=>finish=resolve));e.page.showEditPartnerModal();
const pending=e.page.savePartnerProfile();await e.page.savePartnerProfile();e.page.hideEditPartnerModal();
assert.equal(e.calls.length,1);assert.equal(e.page.data.showEditPartnerModal,true);
finish({success:false,error:'保存失败'});await pending;
assert.equal(e.page.data.showEditPartnerModal,true);assert.equal(e.page.data.partnerSaving,false);assert.equal(e.refreshes(),0);
e.page.data.partnerId=43;await e.page.savePartnerProfile();assert.equal(e.calls.length,1);
});
+60
View File
@@ -0,0 +1,60 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const path=require('node:path');
function loadPage(file,api,wxExtra={}) {
let definition;
const storage=new Map([['userId',1],['coupleId',10]]),intervals=[];
const wx={getStorageSync:k=>storage.get(k),setStorageSync:(k,v)=>storage.set(k,v),removeStorageSync:k=>storage.delete(k),showToast(){},...wxExtra};
const loader=require('./load-module.cjs')({Page:p=>definition=p,wx,console,Date,Promise,setTimeout,clearTimeout,setInterval:fn=>{intervals.push(fn);return intervals.length;},clearInterval(){}},{'../../utils/request':{api},'../../utils/layout':{getLayout:()=>({})}});
loader(path.join(__dirname,'../pages',file));
const page={...definition,data:JSON.parse(JSON.stringify(definition.data)),setData(v){Object.assign(this.data,v);}};
return {page,storage,intervals,wx};
}
const tick=async()=>{for(let i=0;i<20;i++)await Promise.resolve();};
const journal=overrides=>({cycles:[],daily:[],sex_records:[],can_access:true,has_couple:true,partner_user_id:2,period_visible:0,...overrides});
test('legacy shared journal displays returned cycles and clears them when access closes',async()=>{
let denied=false;
const {page}=loadPage('period/period.js',{periodList:async()=>journal({can_access:!denied,cycles:denied?[]:[{id:7,start_date:'2026-10-01',end_date:'2026-10-05'}]})});
page.calculateStats=()=>{};page.buildCalendar=()=>{};
await page.loadData();assert.equal(page.data.accessDenied,false);assert.equal(page.data.savedCycles.length,1);
denied=true;await page.loadData();assert.equal(page.data.accessDenied,true);assert.equal(page.data.savedCycles.length,0);
});
test('cooling keeps the relationship and server completion clears it',async()=>{
let status='cooling';const {page,storage}=loadPage('unbind/unbind.js',{unbindStatus:async()=>({status,remaining_seconds:259200,is_requester:false})});page._visible=true;
await page.loadStatus();await tick();assert.equal(page.data.status,'cooling');assert.equal(storage.get('coupleId'),10);assert.equal(page.data.timerText,'72时 0分 0秒');
status='completed';await page.loadStatus();await tick();assert.equal(storage.has('coupleId'),false);assert.equal(page.data.status,'completed');
});
test('server relation changes discard cached data before another read',async()=>{
const storage=new Map([['userId',1],['coupleId',10],['token','private']]);let redirects=0,reads=0;
const wx={getStorageSync:k=>storage.get(k),setStorageSync:(k,v)=>storage.set(k,v),hideLoading(){},request(o){
if(o.url.includes('/diaryList')){reads++;o.success({statusCode:200,data:{items:[{title:reads===1?'old space':'new space'}]}});}
else o.success({statusCode:200,header:{'X-Couple-ID':'22','X-User-ID':'1'},data:{loggedIn:true}});
}};
const loader=require('./load-module.cjs')({wx,console,Date,Promise,getApp:()=>({onRelationChanged(){redirects++;}})});
const {api,getImageUrl}=loader(path.join(__dirname,'../utils/request.js'));
await api.diaryList();await api.diaryList();assert.equal(reads,1);
await api.checkAuth();assert.equal(storage.get('coupleId'),'22');assert.equal(redirects,1);
const fresh=await api.diaryList();assert.equal(reads,2);assert.equal(fresh.items[0].title,'new space');
assert.equal(getImageUrl('/backend/uploads/a.jpg?media_sig=abc'),'https://past.sundynix.cn/backend/uploads/a.jpg?media_sig=abc');
});
test('settings and profile consent retain couple cache and lead into cooling',async()=>{
for (const file of ['settings/settings.js','profile/profile.js']) {
let agreed=0;
const e=loadPage(file,{unbindRespond:async()=>{agreed++;}}, {
showActionSheet:o=>o.success({tapIndex:0}),showModal:o=>o.success({confirm:true}),showLoading(){},hideLoading(){},navigateTo(){}
});
e.page.loadUnbindStatus=()=>{};
e.page.doRespondUnbind();await tick();
assert.equal(agreed,1);assert.equal(e.storage.get('coupleId'),10);
}
});
test('either partner can use cooling withdrawal and state refresh preserves binding',async()=>{
for (const isRequester of [true,false]) {
let cancelled=0;
const e=loadPage('unbind/unbind.js',{unbindCancel:async()=>{cancelled++;},unbindStatus:async()=>({status:'cancelled',is_requester:isRequester})},{showModal:o=>o.success({confirm:true}),showLoading(){},hideLoading(){}});
e.page._visible=true;e.page.data.status='cooling';e.page.data.isRequester=isRequester;
e.page.cancelUnbind();await tick();
assert.equal(cancelled,1);assert.equal(e.page.data.status,'cancelled');assert.equal(e.storage.get('coupleId'),10);
}
});
+16
View File
@@ -0,0 +1,16 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const path=require('node:path');
const createLoader=require('./load-module.cjs');
test('pet page guides unbound users to binding and loads adoption after binding',async()=>{
let definition,unbound=true,calls=0,url;const app={globalData:{}};
const load=createLoader({Page:p=>definition=p,console,Date,getApp:()=>app,wx:{switchTab:p=>url=p.url}}, {
'../../utils/page-cache':{withDataCache:p=>p},
'../../utils/request':{api:{coupleInfo:async()=>({not_bound:unbound}),petInfo:async()=>{calls++;return {has_pet:false};}}}
});
load(path.join(__dirname,'../pages/pet/pet.js'));
const page={...definition,data:{...definition.data},setData:p=>Object.assign(page.data,p)};
await page.loadPetInfo();assert.equal(calls,0);assert.equal(page.data.needsBinding,true);assert.equal(page.data.loading,false);assert.equal(page.data.loadError,false);
page.bindPartner();assert.equal(app.globalData.openProfileBind,true);assert.equal(url,'/pages/profile/profile');
unbound=false;await page.loadPetInfo();assert.equal(calls,1);assert.equal(page.data.needsBinding,false);assert.equal(page.data.hasPet,false);
});
+33
View File
@@ -0,0 +1,33 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const createLoader=require('./load-module.cjs');
function setup(overrides={},coupleId=1) {
let definition;const urls=[];
const api={timelineList:async()=>[{id:1},{id:2}],albumList:async()=>[{photo_count:'3'},{photo_count:7}],diaryList:async()=>({items:[{id:1}],total:'27'}),songsList:async()=>[{id:1}],checkinList:async()=>({items:[{id:1}],checkin_log_total:'17'}),petInfo:async()=>({has_pet:true,pet:{level:'13'}}),...overrides};
const load=createLoader({Page:value=>definition=value,wx:{getStorageSync:key=>key==='coupleId'?coupleId:1,setStorageSync(){},navigateTo:value=>urls.push(value.url)},console,Date}, {'../../utils/request':{api},'../../utils/page-cache':{withDataCache:value=>value}});
load(path.join(__dirname,'../pages/profile/profile.js'));
const page={...definition,data:JSON.parse(JSON.stringify(definition.data)),setData:value=>Object.assign(page.data,value)};
return {page,urls};
}
test('profile statistics use photo and diary totals, checkin count and explicit pet level; all eight destinations exist',async()=>{
const e=setup();await e.page.loadProfileStats();
assert.deepEqual(e.page.data.statisticItems.map(item=>item.count),[2,10,27,1,17,'Lv.13','','']);
assert.equal(e.page.data.statsError,false);assert.equal(e.page.data.statsLoading,false);
const config=JSON.parse(fs.readFileSync(path.join(__dirname,'../app.json'),'utf8'));
for(const item of e.page.data.statisticItems) e.page.openStatistic({currentTarget:{dataset:{key:item.key}}});
assert.equal(e.urls.length,8);for(const url of e.urls)assert.ok(config.pages.includes(url.slice(1)),url);
});
test('a failed statistic is unknown while other entries stay available and retry replaces it',async()=>{
let fail=true;const e=setup({albumList:async()=>{if(fail)throw Error('offline');return [{photo_count:4}];}});
await e.page.loadProfileStats();assert.equal(e.page.data.statisticItems[1].count,'—');assert.equal(e.page.data.statisticItems[2].count,27);assert.equal(e.page.data.statsError,true);
fail=false;await e.page.loadProfileStats();assert.equal(e.page.data.statisticItems[1].count,4);assert.equal(e.page.data.statsError,false);
});
test('unbound profile skips couple-only pets without reporting a statistics failure',async()=>{
let calls=0;const e=setup({petInfo:async()=>{calls++;throw Error('请先绑定情侣');}},null);
await e.page.loadProfileStats();
assert.equal(calls,0);assert.equal(e.page.data.statsError,false);
assert.equal(e.page.data.statisticItems.find(item=>item.key==='pet').count,'未领养');
});
+61
View File
@@ -0,0 +1,61 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
function load(file, wx) {
return require('./load-module.cjs')({wx, console, Promise, Date})(path.join(__dirname, '..', file));
}
test('GET business parameters and POST bodies use clean paths with auth', async () => {
const calls=[];
const {api}=load('utils/request.js', { getStorageSync:()=> 'test-token', hideLoading(){}, request(o){calls.push(o);o.success({statusCode:200,data:{items:[], pageSize:20}});} });
const response = await api.diaryList({page:2,page_size:20});
assert.equal(response.page_size,20);
await api.periodVisibleSet({period_visible:0});
assert.equal(calls[0].url,'https://past.sundynix.cn/api/diaryList?page=2&pageSize=20');
assert.equal(calls[0].header.Authorization,'Bearer test-token');
assert.equal(calls[1].url,'https://past.sundynix.cn/api/periodVisibleSet');
assert.equal(calls[1].data.periodVisible,0);
});
test('multipart upload retains action and token when Android omits headers',async()=>{
let upload;
const {uploadFileCompat}=load('utils/request.js',{getStorageSync:()=> 'test-token',compressImage(o){o.fail();},uploadFile(o){upload=o;o.success({statusCode:200,data:'{"success":true,"path":"uploads/test.jpg"}'});} });
await uploadFileCompat({url:'https://past.sundynix.cn/api/uploadPartnerAvatar',filePath:'test.jpg',name:'file',formData:{partner_id:87}});
assert.equal(upload.url,'https://past.sundynix.cn/api/uploadPartnerAvatar?token=test-token');
assert.equal(upload.formData.action,'uploadPartnerAvatar');
assert.equal(upload.formData.token,'test-token');
assert.equal(upload.formData.partnerId,87);
assert.equal(upload.formData.partner_id,undefined);
});
test('navigation responds to phone statusbar and capsule geometry, with fallback',()=>{
for(const device of [{status:20,top:24,height:32,width:360,left:263},{status:54,top:58,height:32,width:393,left:285}]){
const {getLayout}=load('utils/layout.js',{getWindowInfo:()=>({statusBarHeight:device.status,windowWidth:device.width}),getMenuButtonBoundingClientRect:()=>({top:device.top,height:device.height,left:device.left})});
const layout=getLayout();assert.equal(layout.navHeight,device.status+40);assert.equal(layout.navSide,device.width-device.left+12);
}
const {getLayout}=load('utils/layout.js',{});assert.equal(getLayout().navHeight,64);
});
test('failed homepage load ends the recent-record spinner',async()=>{
let definition;
const api={coupleInfo:async()=>{throw { _statusCode:404 };}};
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../pages/index/index.js'),'utf8'),{
Page(p){definition=p;},require(name){return name.endsWith('/list-pagination')?require('../utils/list-pagination'):name.endsWith('/page-cache')?{withDataCache:p=>p}:name.endsWith('/request')?{api,getImageUrl:v=>v}:name.endsWith('/layout')?{getLayout:()=>({})}:{saveScrollPosition(){},getScrollPosition(){return 0;}};},
wx:{getStorageSync:()=> 'test-token'}, console:{error(){}}, Date, setTimeout
});
const page={...definition,data:{...definition.data},setData(data){Object.assign(this.data,data);}};
await page.loadData();
assert.equal(page.data.homeError,true);assert.equal(page.data.statsLoading,false);assert.equal(page.data.statsError,true);assert.equal(page._loadingData,false);
});
test('unbound newcomer loads homepage records without requesting couple-only pets',async()=>{
let definition;let petCalls=0;
const api={coupleInfo:async()=>({not_bound:true,partner1:{nickname:'新用户'}}),timelineList:async()=>[],albumList:async()=>[],diaryList:async()=>({items:[],total:0}),songsList:async()=>[],checkinList:async()=>({items:[]}),petInfo:async()=>{petCalls++;throw Error('请先绑定情侣');}};
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../pages/index/index.js'),'utf8'),{
Page(p){definition=p;},require(name){return name.endsWith('/list-pagination')?require('../utils/list-pagination'):name.endsWith('/page-cache')?{withDataCache:p=>p}:name.endsWith('/request')?{api,getImageUrl:v=>v}:name.endsWith('/layout')?{getLayout:()=>({})}:name.endsWith('/journal')?{diaryEntry:v=>v}:{};},
wx:{getStorageSync:()=>1,removeStorageSync(){}},console,Date,setTimeout
});
const page={...definition,data:{...definition.data,coupleId:99},setData(data){Object.assign(this.data,data);}};
await page.loadData();
assert.equal(petCalls,0);assert.equal(page.data.coupleId,null);
assert.equal(page.data.homeError,false);assert.equal(page.data.statsError,false);assert.equal(page.data.statsLoading,false);
assert.equal(page.data.recentDiary.length,0);assert.equal(page.data.stats.pet,0);
});
+58
View File
@@ -0,0 +1,58 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const path=require('node:path');
const createLoader=require('./load-module.cjs');
function setup(action,info) {
let definition;const calls=[],toasts=[],events=[];
const api={coupleInfo:async()=>({partner1:{id:1,nickname:'本人',avatar:'uploads/me.jpg'},partner2:{id:2,nickname:'伴侣',avatar:'uploads/partner.jpg'}}),smokingAction:async payload=>{calls.push(payload);return action(payload);},smokingInfo:info||(async()=>({stock:5,today_count:0,logs:[],daily:[],available:true,read_only:false}))};
const load=createLoader({Component:c=>definition=c,Date,Math,Promise,wx:{getStorageSync:()=>1,showToast:v=>toasts.push(v),showModal:o=>o.success({confirm:true}),navigateTo(){}}},{'../../utils/request':{api,getImageUrl:value=>value?'https://past.sundynix.cn/backend/'+value:''}});
load(path.join(__dirname,'../components/smoking-card/smoking-card.js'));
const component={...definition.methods,properties:{detailed:false},data:JSON.parse(JSON.stringify(definition.data)),setData(values){Object.assign(this.data,values);},triggerEvent:name=>events.push(name)};
component.applyInfo({stock:5,today_count:0,logs:[],daily:[],available:true,read_only:false});
return {component,calls,toasts,events};
}
const result=()=>({success:true,info:{stock:4,today_count:1,logs:[],daily:[],available:true,read_only:false}});
test('double tap records once and uncertain network retries reuse the idempotency key',async()=>{
let resolve;const e=setup(()=>new Promise(done=>resolve=done));const first=e.component.smoke();await e.component.smoke();assert.equal(e.calls.length,1);resolve(result());await first;assert.equal(e.component.data.info.stock,4);assert.equal(e.events.length,1);
let fail=true;const retry=setup(async()=>{if(fail)throw Error('offline');return result();});await retry.component.smoke();fail=false;await retry.component.smoke();assert.equal(retry.calls[0].request_key,retry.calls[1].request_key);
});
test('partner view is read only and received-then-smoked is a single atomic operation',async()=>{
const e=setup(async()=>result());e.component.data.info.read_only=true;await e.component.smoke();assert.equal(e.calls.length,0);
e.component.data.info.read_only=false;await e.component.receiveSmoke();assert.equal(e.calls[0].kind,'receive_smoke');assert.equal(e.calls[0].quantity,1);
});
test('purchase converts packs to pieces and cents; invalid quantities never submit',async()=>{
const e=setup(async()=>result());e.component.replenish();e.component.setData({quantity:'2',packSize:'20',amount:'30.50'});await e.component.submit();assert.equal(e.calls[0].quantity,40);assert.equal(e.calls[0].amount_cents,3050);
e.component.setData({quantity:'1.5'});await e.component.submit();assert.equal(e.calls.length,1);
});
test('undo includes the original record and fills a seven-day chart with zero days',async()=>{
const e=setup(async()=>result());e.component.undo({currentTarget:{dataset:{id:17}}});await Promise.resolve();await Promise.resolve();assert.equal(e.calls[0].kind,'undo');assert.equal(e.calls[0].original_id,17);assert.equal(e.component.data.daily.length,7);
});
test('string zero is active while one is undone, and ledger identity follows the selected account',async()=>{
const e=setup(async()=>result(),async uid=>({user_id:uid||1,owner_name:uid?'伴侣':'本人',stock:0,available:true,read_only:!!uid,logs:[{id:1,kind:'receive_smoke',undone:'0',delta:'0'},{id:2,kind:'consume',undone:'1',delta:'-1'},{id:3,kind:'consume',undone:0,delta:-1}],daily:[]}));
await e.component.load();
assert.equal(e.component.data.logs[0].undone,false);assert.equal(e.component.data.logs[1].undone,true);assert.equal(e.component.data.logs[2].undone,false);
assert.equal(e.component.data.ownerName,'本人');assert.ok(e.component.data.ownerAvatar.endsWith('/uploads/me.jpg'));
await e.component.switchOwner({currentTarget:{dataset:{user:2}}});
assert.equal(e.component.data.ownerName,'伴侣');assert.ok(e.component.data.ownerAvatar.endsWith('/uploads/partner.jpg'));
e.component.avatarFailed();assert.equal(e.component.data.ownerAvatar,'');assert.equal(e.component.data.ownerInitial,'伴');
});
test('giving a cigarette submits one stock operation, labels its ledger and rejects empty stock or partner view',async()=>{
const e=setup(async()=>({success:true,info:{user_id:1,stock:4,today_count:0,total_count:0,logs:[{id:9,kind:'give',delta:'-1',balance:'4',undone:'0'}],daily:[],available:true,read_only:false}}));
await e.component.giveSmoke();assert.equal(e.calls[0].kind,'give');assert.equal(e.calls[0].quantity,1);assert.equal(e.component.data.info.today_count,0);
assert.equal(e.component.data.logs[0].title,'递给别人一支');assert.equal(e.component.data.logs[0].change,'-1');assert.equal(e.component.data.logs[0].undone,false);
e.component.data.info.stock=0;await e.component.giveSmoke();assert.equal(e.calls.length,1);assert.equal(e.toasts.at(-1).title,'库存不足,请先补充库存');
e.component.data.info.stock=5;e.component.data.info.read_only=true;await e.component.giveSmoke();assert.equal(e.calls.length,1);
});
test('weekly and monthly purchase totals use cents and refresh with selected owner',()=>{
const {component}=setup(async()=>result());
component.applyInfo({user_id:1,week_count:7,month_count:29,week_purchase_cents:3050,month_purchase_cents:12500,logs:[],daily:[]});
assert.equal(component.data.periodStats[0].count,7);assert.equal(component.data.periodStats[0].money,'30.50');
assert.equal(component.data.periodStats[1].count,29);assert.equal(component.data.periodStats[1].money,'125.00');
component.applyInfo({user_id:2,read_only:true,week_count:0,month_count:2,week_purchase_cents:0,month_purchase_cents:999,logs:[],daily:[]});
assert.equal(component.data.periodStats[0].money,'0.00');assert.equal(component.data.periodStats[1].money,'9.99');
assert.equal(component.data.periodStats[1].count,2);
component.applyInfo({logs:[],daily:[]});assert.equal(component.data.periodStats[0].count,'—');
});
+53
View File
@@ -0,0 +1,53 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const path=require('node:path');
const template='tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8';
function setup(save){
const storage={coupleId:10},calls=[];
const wx={getStorageSync:key=>storage[key],setStorageSync:(key,v)=>storage[key]=v,requestSubscribeMessage:o=>calls.push(o)};
const load=require('./load-module.cjs')({wx,console:{warn(){},error(){}}},{'./request':{api:{subscribeTemplate:save}}});
return {helper:load(path.join(__dirname,'../utils/subscribe.js')),storage,calls};
}
test('authorization is immediate and concurrent calls share one prompt until persistence finishes',async()=>{
let complete,saves=0;const e=setup(()=>{saves++;return new Promise(resolve=>complete=resolve)});
const first=e.helper.requestSubscribe('notify');assert.equal(e.calls.length,1);
assert.equal(e.helper.requestSubscribe('notify'),first);
e.calls[0].success({[template]:'accept'});
let finished=false;first.then(()=>finished=true);await Promise.resolve();
assert.equal(finished,false);assert.equal(e.storage.subscribed_notify,undefined);
complete({success:true});const result=await first;assert.equal(result.notify,true);assert.equal(saves,1);assert.equal(e.storage.subscribed_notify,true);
});
test('failed persistence never reports successful subscription and a later tap can retry',async()=>{
let fail=true;const e=setup(async()=>{if(fail)throw Error('offline');return {success:true}});
let pending=e.helper.requestSubscribe('notify');e.calls[0].success({[template]:'accept'});
await assert.rejects(pending,/offline/);assert.equal(e.storage.subscribed_notify,undefined);
fail=false;pending=e.helper.requestSubscribe('notify');assert.equal(e.calls.length,2);e.calls[1].success({[template]:'accept'});
assert.equal((await pending).notify,true);
});
test('each subsequent user action can replenish authorization; denial does not prevent later attempts',async()=>{
const e=setup(async()=>({success:true}));
let pending=e.helper.requestSubscribeAfterAction('diary');assert.equal(e.calls.length,1);e.calls[0].success({[template]:'reject'});await pending;
pending=e.helper.requestSubscribeAfterAction('photo');assert.equal(e.calls.length,2);e.calls[1].success({[template]:'accept'});await pending;
pending=e.helper.requestSubscribeAfterAction('checkin');assert.equal(e.calls.length,3);e.calls[2].fail(Error('cancelled'));await pending;
pending=e.helper.requestSubscribeAfterAction('checkin');assert.equal(e.calls.length,4);e.calls[3].success({[template]:'accept'});await pending;
});
test('explicitly disabled notifications and unbound users do not prompt during business actions',async()=>{
const e=setup(async()=>({success:true}));e.storage.subscribed_notify=false;await e.helper.requestSubscribeAfterAction('diary');assert.equal(e.calls.length,0);
delete e.storage.subscribed_notify;delete e.storage.coupleId;await e.helper.requestSubscribeAfterAction('diary');assert.equal(e.calls.length,0);
});
test('manual renewal requests authorization again without disabling existing notifications',async()=>{
let page, calls=0, cancelled=0, complete;
const loader=require('./load-module.cjs')({Page:p=>page=p,wx:{showToast(){}}},{
'../../utils/layout':{getLayout:()=>({})},
'../../utils/request':{api:{unsubscribeTemplate:()=>cancelled++}},
'../../utils/subscribe':{requestSubscribe:()=>{calls++;return new Promise(resolve=>complete=resolve)},getSubscribeStatus:async()=>({notify:true})}
});
loader(path.join(__dirname,'../pages/settings/settings.js'));
const instance={data:{subscribeChecked:true},setData(v){Object.assign(this.data,v)}};
let pending=page.renewSubscribe.call(instance);
assert.equal(calls,1);await page.renewSubscribe.call(instance);assert.equal(calls,1);
complete({notify:true});await pending;assert.equal(instance.data.subscribeChecked,true);assert.equal(cancelled,0);
pending=page.renewSubscribe.call(instance);assert.equal(calls,2);complete({notify:false});await pending;
assert.equal(instance.data.subscribeChecked,true);assert.equal(instance._subscribing,false);
});
+17
View File
@@ -0,0 +1,17 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const path=require('node:path');
const createLoader=require('./load-module.cjs');
function load(wx,api) {return createLoader({wx,Date,Promise},{'./request':{api}})(path.join(__dirname,'../utils/user-location.js'));}
test('current address uses live coordinates first and shares concurrent lookups',async()=>{
let reports=0;const {currentUserLocation}=load({getStorageSync:()=>1,getFuzzyLocation:o=>o.success({latitude:25,longitude:102})},{updateIpLocation:async data=>{reports++;assert.equal(data.lat,25);return {location:'云南省 昆明市 盘龙区'};}});
const [a,b]=await Promise.all([currentUserLocation(),currentUserLocation()]);assert.equal(a.location,'云南省 昆明市 盘龙区');assert.equal(b.location,a.location);assert.equal(reports,1);await currentUserLocation();assert.equal(reports,1);
});
test('location permission failure falls back to current user, never partner',async()=>{
const {currentUserLocation}=load({getStorageSync:()=>2,getFuzzyLocation:o=>o.fail()},{updateIpLocation:async()=>{throw Error('offline');},coupleInfo:async()=>({partner1:{id:1,location:'对方地址'},partner2:{id:2,location:'我的地址'}})});
assert.equal((await currentUserLocation()).location,'我的地址');
});
test('switching users clears address cache and unavailable coordinates do not become an address',async()=>{
let uid=1;const {currentUserLocation}=load({getStorageSync:()=>uid},{updateIpLocation:async()=>({}),coupleInfo:async()=>({partner1:{id:uid,location:uid===1?'我的地址':'25.0,102.0'}})});
assert.equal((await currentUserLocation()).location,'我的地址');uid=2;assert.equal((await currentUserLocation()).location,'');
});