同步小程序与管理端更新,完善抽烟记录和解绑冷静期
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -14,8 +14,9 @@ function setup() {
|
||||
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 => require(path.join(__dirname,'../utils', name))};
|
||||
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;
|
||||
@@ -23,7 +24,7 @@ function setup() {
|
||||
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<12;i++) await Promise.resolve();};
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const path=require('node:path');
|
||||
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)));
|
||||
}
|
||||
});
|
||||
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);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ function records(api) {
|
||||
Page:p=>definition=p, console:{error(){}}, setTimeout:f=>f(),
|
||||
wx:{getStorageSync:()=> 'token',navigateTo:p=>routes.push(p.url)},
|
||||
require(name) {
|
||||
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:()=>({})};
|
||||
@@ -45,3 +46,10 @@ test('refresh failure clears busy states and retry replaces old records with aut
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -5,13 +5,17 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const {tabForPage, paths} = require('../utils/navigation');
|
||||
const root = path.join(__dirname, '..');
|
||||
test('only the three main pages mount a tab bar; every secondary page removes its registration and reserved space',()=>{
|
||||
const pages=JSON.parse(fs.readFileSync(path.join(root,'app.json'),'utf8')).pages;
|
||||
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),main,page);
|
||||
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);}
|
||||
}
|
||||
@@ -19,15 +23,39 @@ test('only the three main pages mount a tab bar; every secondary page removes it
|
||||
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';const navigations=[];
|
||||
vm.runInNewContext(fs.readFileSync(path.join(root,'components/app-tabbar/app-tabbar.js'),'utf8'),{
|
||||
Component:c=>definition=c,require:()=>({tabForPage,paths}),getCurrentPages:()=>[{route}],wx:{reLaunch:o=>navigations.push(o.url)}
|
||||
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,'components/app-tabbar/app-tabbar.wxml'),'utf8');
|
||||
assert.ok(markup.includes('wx:if="{{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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,'未领养');
|
||||
});
|
||||
@@ -4,9 +4,7 @@ const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
function load(file, wx) {
|
||||
const context = { module: { exports: {} }, wx, console, Promise, Date, require(name) { return require(path.join(__dirname, '../utils', name)); } };
|
||||
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', file), 'utf8'), context);
|
||||
return context.module.exports;
|
||||
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=[];
|
||||
@@ -40,10 +38,24 @@ 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('/request')?{api,getImageUrl:v=>v}:name.endsWith('/layout')?{getLayout:()=>({})}:{saveScrollPosition(){},getScrollPosition(){return 0;}};},
|
||||
Page(p){definition=p;},require(name){return 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('/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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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);
|
||||
});
|
||||
@@ -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,'');
|
||||
});
|
||||
Reference in New Issue
Block a user