mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-19 11:43:47 +08:00
保存 NAS 健康系统 UI7 代码与本机维护基线
This commit is contained in:
1 parent
1767b4921a
commit
8546ba23ff
80 files changed
+4984
-381
No files matched your search
@@ -0,0 +1,58 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const vm=require('node:vm');
|
||||
const fs=require('node:fs');
|
||||
function fixture(){
|
||||
const nodes=new Map();
|
||||
const get=id=>{if(!nodes.has(id))nodes.set(id,{textContent:'--',innerHTML:'',value:id==='startDate'?'2026-09-18':id==='endDate'?'2026-10-17':'',classList:{add(){},remove(){}}});return nodes.get(id);};
|
||||
const records=Array.from({length:3000},(_,i)=>({timestamp:`2026-10-16T09:${String(i%60).padStart(2,'0')}:00+08:00`,stress_score:30}));
|
||||
const responses={summary:[{date:'2026-10-16',steps:6000,active_kcal:500,distance_m:4000}],
|
||||
'heart-rate':[{bpm:62}],sleep:[{end_at:'2026-10-16T08:00:00+08:00',duration_minutes:450}],spo2:[{spo2_pct:97}],stress:records};
|
||||
const copied=[];
|
||||
const context=vm.createContext({document:{getElementById:get,querySelectorAll(){return [];},addEventListener(){}},
|
||||
localStorage:{getItem(){return '';},setItem(){}},performance,console,setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){},
|
||||
navigator:{clipboard:{writeText(text){copied.push(text);return Promise.resolve();}}},
|
||||
fetch:async url=>({status:200,ok:true,text:async()=>JSON.stringify({status:'ok',data:responses[url.split('?')[0].split('/').pop()]||[]})})});
|
||||
vm.runInContext(fs.readFileSync('src/mi_fitness_mcp/web_assets/static/js/health-ui.js','utf8'),context);
|
||||
vm.runInContext(fs.readFileSync('src/mi_fitness_mcp/web_assets/static/js/app.js','utf8'),context);
|
||||
vm.runInContext('showToast=()=>{}; renderCount=0; originalRender=renderJsonViewer; renderJsonViewer=(data)=>{renderCount++;originalRender(data);};',context);
|
||||
return {context,get,copied};
|
||||
}
|
||||
test('overview paints one small response instead of five full datasets',async()=>{
|
||||
const ui=fixture();await vm.runInContext('loadOverview()',ui.context);
|
||||
assert.equal(vm.runInContext('renderCount',ui.context),1);
|
||||
assert.equal(ui.get('statStress').textContent,'30');
|
||||
assert.equal(ui.get('statSteps').textContent,(6000).toLocaleString());
|
||||
assert.equal(vm.runInContext('state.lastRequestInfo.path.split("?")[0]',ui.context),'/api/summary');
|
||||
});
|
||||
test('silent and superseded requests do not overwrite inspector state',async()=>{
|
||||
const ui=fixture();ui.get('inspectorBody').innerHTML='keep-current';
|
||||
await vm.runInContext("api('GET','/api/stress',null,{inspect:false})",ui.context);
|
||||
await vm.runInContext("api('GET','/api/stress',null,{isCurrent:()=>false})",ui.context);
|
||||
assert.equal(ui.get('inspectorBody').innerHTML,'keep-current');
|
||||
assert.equal(vm.runInContext('state.lastResponse',ui.context),null);
|
||||
});
|
||||
test('large detail preview is bounded while copy keeps every record',async()=>{
|
||||
const ui=fixture();await vm.runInContext("api('GET','/api/stress')",ui.context);
|
||||
const html=ui.get('inspectorBody').innerHTML;
|
||||
assert.ok((html.match(/<span /g)||[]).length<=1000);
|
||||
assert.match(html,/预览/);
|
||||
assert.equal(vm.runInContext('state.lastResponse.data.length',ui.context),3000);
|
||||
vm.runInContext('copyResponse()',ui.context);
|
||||
assert.equal(JSON.parse(ui.copied[0]).data.length,3000);
|
||||
vm.runInContext("filterInspectorJson('timestamp')",ui.context);
|
||||
assert.ok((ui.get('inspectorBody').innerHTML.match(/<span /g)||[]).length<=1000);
|
||||
});
|
||||
|
||||
test('a late overview response cannot overwrite a newer manual inspector query',async()=>{
|
||||
const ui=fixture();
|
||||
const pending={};
|
||||
ui.context.fetch=url=>new Promise(resolve=>{pending[url]=resolve;});
|
||||
const first=vm.runInContext("api('GET','/api/summary',null,{isCurrent:()=>true})",ui.context);
|
||||
const second=vm.runInContext("api('GET','/api/coverage')",ui.context);
|
||||
const response=marker=>({status:200,ok:true,text:async()=>JSON.stringify({marker})});
|
||||
pending['/proxy/api/coverage'](response('new'));await second;
|
||||
pending['/proxy/api/summary'](response('old'));await first;
|
||||
assert.equal(vm.runInContext('state.lastResponse.marker',ui.context),'new');
|
||||
assert.equal(vm.runInContext('state.lastRequestInfo.path',ui.context),'/api/coverage');
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
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 file = path.resolve('src/mi_fitness_mcp/web_assets/static/js/app.js');
|
||||
function dashboard(responses) {
|
||||
const nodes = new Map();
|
||||
const get = id => {
|
||||
if (!nodes.has(id)) nodes.set(id, {textContent: '--', innerHTML: '', value: id === 'startDate' ? '2026-10-15' : '2026-10-16', classList: {add(){}, remove(){}}});
|
||||
return nodes.get(id);
|
||||
};
|
||||
const context = vm.createContext({
|
||||
localStorage: {getItem() {return '';}, setItem() {}},
|
||||
document: {getElementById: get, querySelectorAll() {return [];}, addEventListener() {}},
|
||||
responses, console, setTimeout() {}, clearTimeout() {}, setInterval() {}, clearInterval() {},
|
||||
});
|
||||
vm.runInContext(fs.readFileSync('src/mi_fitness_mcp/web_assets/static/js/health-ui.js','utf8'),context);
|
||||
vm.runInContext(fs.readFileSync(file, 'utf8'), context);
|
||||
vm.runInContext("showToast = () => {}; api = async (method, url) => responses[url.split('?')[0]] || {status:'ok',data:[]};", context);
|
||||
return {context, get};
|
||||
}
|
||||
|
||||
test('overview aggregates the selected range instead of taking the latest day', async () => {
|
||||
const ui = dashboard({
|
||||
'/api/summary': {status:'ok',data:[{date:'2026-10-16',steps:4063,active_kcal:250,distance_m:2385},{date:'2026-10-15',steps:4691,active_kcal:596,distance_m:2979}]},
|
||||
'/api/heart-rate': {status:'ok',data:[{timestamp:'2026-10-16T09:00:00+08:00',bpm:62}]},
|
||||
'/api/sleep': {status:'ok',data:[{end_at:'2026-10-16T08:54:00+08:00',duration_minutes:517},{end_at:'2026-10-15T07:55:00+08:00',duration_minutes:474}]},
|
||||
'/api/spo2': {status:'ok',data:[{timestamp:'2026-10-16T09:00:00+08:00',spo2_pct:98}]},
|
||||
'/api/stress': {status:'ok',data:[{timestamp:'2026-10-16T09:00:00+08:00',stress_score:0}]},
|
||||
});
|
||||
await vm.runInContext('loadOverview()',ui.context);
|
||||
assert.equal(ui.get('statSteps').textContent, (8754).toLocaleString());
|
||||
assert.equal(String(ui.get('statCalories').textContent),'846');
|
||||
assert.equal(ui.get('statDistance').textContent,'5.36');
|
||||
assert.equal(String(ui.get('statHr').textContent),'62');
|
||||
assert.equal(ui.get('statSleep').textContent,'8.3');
|
||||
assert.equal(ui.get('statSpo2').textContent,'98%');
|
||||
assert.equal(String(ui.get('statStress').textContent),'0');
|
||||
});
|
||||
|
||||
test('range averages include all samples and group naps by wake date without filling missing days', async () => {
|
||||
const ui=dashboard({
|
||||
'/api/heart-rate':{status:'ok',data:[{bpm:60},{bpm:70}]},
|
||||
'/api/spo2':{status:'ok',data:[{spo2_pct:96},{spo2_pct:98}]},
|
||||
'/api/stress':{status:'ok',data:[{stress_score:0},{stress_score:60}]},
|
||||
'/api/sleep':{status:'ok',data:[{end_at:'2026-10-15T07:55:00+08:00',duration_minutes:474},{end_at:'2026-10-16T08:54:00+08:00',duration_minutes:517},{end_at:'2026-10-16T14:00:00+08:00',duration_minutes:60,is_nap:true}]},
|
||||
});
|
||||
ui.get('startDate').value='2026-10-01';
|
||||
await vm.runInContext('loadOverview()',ui.context);
|
||||
assert.equal(String(ui.get('statHr').textContent),'65');
|
||||
assert.equal(ui.get('statSpo2').textContent,'97%');
|
||||
assert.equal(String(ui.get('statStress').textContent),'30');
|
||||
assert.equal(ui.get('statSleep').textContent,'8.8');
|
||||
assert.match(ui.get('statSleepCoverage').textContent,/2/);
|
||||
});
|
||||
|
||||
test('30 day preset is inclusive and refreshes the overview without manual reload', async () => {
|
||||
const ui=dashboard({});
|
||||
vm.runInContext(`Date = class extends Date { constructor(...args) { super(...(args.length ? args : ['2026-10-16T12:00:00+08:00'])); } }; calls=[]; api=async(method,path)=>{ calls.push(path); return {status:'ok',data:[]}; };`,ui.context);
|
||||
await vm.runInContext("setDatePreset('30d')",ui.context);
|
||||
assert.equal(ui.get('startDate').value,'2026-09-17');
|
||||
assert.equal(ui.get('endDate').value,'2026-10-16');
|
||||
const calls=vm.runInContext('calls',ui.context);
|
||||
assert.equal(calls.length,5);
|
||||
assert.ok(calls.every(path=>!path.includes('limit=1')));
|
||||
await vm.runInContext("setDatePreset('yesterday')",ui.context);
|
||||
assert.equal(ui.get('startDate').value,'2026-10-15');
|
||||
assert.equal(ui.get('endDate').value,'2026-10-16');
|
||||
});
|
||||
|
||||
test('empty date range clears old values rather than showing stale measurements', async () => {
|
||||
const ui=dashboard({});
|
||||
for (const id of ['statSteps','statCalories','statDistance','statHr','statSleep','statSpo2','statStress']) ui.get(id).textContent='old-value';
|
||||
await vm.runInContext('loadOverview()',ui.context);
|
||||
for (const id of ['statSteps','statCalories','statDistance','statHr','statSleep','statSpo2','statStress']) assert.equal(ui.get(id).textContent,'--');
|
||||
});
|
||||
|
||||
test('daily table and metric chart consume response envelope without losing units', () => {
|
||||
const ui=dashboard({});
|
||||
ui.context.payload={status:'ok',data:[{date:'2026-10-15',steps:4691,active_kcal:596,distance_m:2979}]};
|
||||
vm.runInContext('renderSummaryVisual(payload)',ui.context);
|
||||
assert.match(ui.get('summaryVisualContainer').innerHTML,/596 kcal/);
|
||||
assert.match(ui.get('summaryVisualContainer').innerHTML,/2\.98 km/);
|
||||
ui.context.payload={status:'ok',data:[{date:'2026-10-15',value:4691}]};
|
||||
vm.runInContext("renderMetricChart(payload,'steps')",ui.context);
|
||||
assert.match(ui.get('metricChartContainer').innerHTML,/4,691/);
|
||||
});
|
||||
|
||||
test('sleep timeline reads stage list and separately reported awake minutes', () => {
|
||||
const ui=dashboard({});
|
||||
ui.context.payload={status:'ok',data:[{end_at:'2026-10-16T08:54:00+08:00',duration_minutes:517,time_awake_minutes:85,stages:[{stage:'deep',minutes:120},{stage:'light',minutes:300},{stage:'rem',minutes:97}]}]};
|
||||
vm.runInContext('renderSleepTimeline(payload)',ui.context);
|
||||
const html=ui.get('sleepTimelineWrapper').innerHTML;
|
||||
assert.match(html,/深睡 120m/);
|
||||
assert.match(html,/浅睡 300m/);
|
||||
assert.match(html,/REM 97m/);
|
||||
assert.match(html,/清醒 85m/);
|
||||
});
|
||||
|
||||
test('failed synchronization does not show a success message', async () => {
|
||||
const ui=dashboard({});
|
||||
vm.runInContext("notices=[]; showToast=(...args)=>notices.push(args); api=async()=>({status:'error'});",ui.context);
|
||||
await vm.runInContext('triggerSync(false)',ui.context);
|
||||
const notices=vm.runInContext('notices',ui.context);
|
||||
assert.ok(!notices.some(args=>args[2]==='success'));
|
||||
assert.ok(notices.some(args=>args[2]==='error'));
|
||||
});
|
||||
|
||||
test('key list reads masked keys from envelope and safely renders labels', () => {
|
||||
const ui=dashboard({});
|
||||
ui.context.payload={status:'ok',data:[{key_masked:'mif_sk_abcde…1234',label:'<script>example</script>',user_id:'synthetic',created_at:'2026-10-16',revoked:false}]};
|
||||
vm.runInContext('renderKeysTable(payload)',ui.context);
|
||||
const html=ui.get('keysTableWrapper').innerHTML;
|
||||
assert.match(html,/mif_sk_abcde…1234/);
|
||||
assert.ok(!html.includes('<script>'));
|
||||
assert.match(html,/revokeKeyByPrefix\('mif_sk_abcde'\)/);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
const test=require('node:test');const assert=require('node:assert/strict');
|
||||
const h=require('../src/mi_fitness_mcp/web_assets/static/js/health-views.js');
|
||||
const range={start:'2026-10-01',end:'2026-10-30'};
|
||||
test('体重40、血氧80、心率40为固定起点,上限继续按实际数据决定',()=>{
|
||||
for(const [kind,row,min,max] of [
|
||||
['body',{timestamp:'2026-10-01T08:00:00+08:00',weight_kg:87.3},40,90],
|
||||
['spo2',{timestamp:'2026-10-01T08:00:00+08:00',spo2_pct:97},80,100],
|
||||
['heartrate',{timestamp:'2026-10-01T08:00:00+08:00',bpm:77,sample_type:'passive'},40,80]]){
|
||||
const m=h.build(kind,[row],range),layout=h.referenceLayout(m);
|
||||
assert.equal(layout.min,min);assert.equal(layout.max,max);
|
||||
assert.match(h.render(m),new RegExp('text-anchor="end">'+min+'<'));
|
||||
}
|
||||
});
|
||||
test('参考线和柱高使用相同的新轴比例,170不撑大心率轴',()=>{
|
||||
const m=h.build('body',[{timestamp:'2026-10-01T08:00:00+08:00',weight_kg:87.3}],range);
|
||||
const line=h.referenceLayout(m).inside.find(r=>r.value===65);
|
||||
assert.equal(line.lineY,93);
|
||||
const hr=h.referenceLayout(h.build('heartrate',[{timestamp:'2026-10-01T08:00:00+08:00',bpm:77,sample_type:'passive'}],range));
|
||||
assert.deepEqual(hr.outside.map(r=>r.value),[170]);assert.equal(hr.min,40);
|
||||
});
|
||||
test('低于下限的记录保留原值,图上明确标记且不产生负高度',()=>{
|
||||
const m=h.build('heartrate',[{timestamp:'2026-10-01T08:00:00+08:00',bpm:35,sample_type:'resting'}],range);
|
||||
const html=h.render(m);
|
||||
assert.equal(m.records[0].bpm,35);assert.equal(m.cards[1].raw,35);
|
||||
assert.match(html,/低于纵轴下限/);assert.ok(!/height="-/.test(html));
|
||||
assert.ok(h.referenceLayout(m).max>40);
|
||||
});
|
||||
test('其他图表继续从0开始,压力低于25时仍保持紧凑纵轴',()=>{
|
||||
for(const kind of ['activity','sleep','workouts','stress','abnormal'])assert.equal(h.referenceLayout(h.build(kind,[],range)).min,0);
|
||||
const m=h.build('stress',[{timestamp:'2026-10-01T08:00:00+08:00',stress_score:18}],range);
|
||||
assert.equal(h.referenceLayout(m).max,20);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
const test=require('node:test');const assert=require('node:assert/strict');
|
||||
const h=require('../src/mi_fitness_mcp/web_assets/static/js/health-views.js');
|
||||
const calendar={'2026':{year:2026,days:[{date:'2026-09-20',isOffDay:false},{date:'2026-10-01',isOffDay:true},{date:'2026-10-10',isOffDay:false}],papers:['https://www.gov.cn/example']}};
|
||||
test('补班周末为工作日,法定假日为非工作日,未知年份明确降级',()=>{
|
||||
assert.equal(h.calendarDay('2026-09-20',calendar).off,false);
|
||||
assert.equal(h.calendarDay('2026-10-01',calendar).off,true);
|
||||
assert.equal(h.calendarDay('2026-10-10',calendar).off,false);
|
||||
assert.equal(h.calendarDay('2026-10-11',calendar).off,true);
|
||||
assert.equal(h.calendarDay('2027-01-02',calendar).provisional,true);
|
||||
assert.equal(h.calendarDay('2027-01-02',calendar).off,true);
|
||||
});
|
||||
test('每天短横线高度相同,橙色工作日、绿色非工作日,缺记录日期也标记',()=>{
|
||||
const m=h.build('activity',[{date:'2026-10-01',steps:1234}],{start:'2026-10-01',end:'2026-10-11',calendar});
|
||||
const html=h.render(m),marks=[...html.matchAll(/<line class="health-calendar-mark"[^>]+/g)].map(x=>x[0]);
|
||||
assert.equal(marks.length,11);assert.ok(marks.every(x=>x.includes('y1="207" y2="207"')));
|
||||
assert.ok(marks[0].includes('stroke="#22a06b"'));assert.ok(marks[9].includes('stroke="#e78325"'));
|
||||
assert.match(html,/工作日/);assert.match(html,/非工作日/);assert.equal(m.cards[0].raw,1234);
|
||||
});
|
||||
test('跨年查询分别判断日历,未收录年份给出提示,睡眠按北京时间醒来日',()=>{
|
||||
const m=h.build('sleep',[{start_at:'2026-12-31T23:00:00+08:00',end_at:'2027-01-01T00:30:00Z',time_asleep_minutes:480}],{start:'2026-12-31',end:'2027-01-02',calendar});
|
||||
assert.equal(m.points[1].date,'2027-01-01');assert.equal(m.points[1].values.reduce((a,b)=>a+(b||0),0),8);
|
||||
assert.match(h.render(m),/2027 年调休安排未更新/);
|
||||
const updated={...calendar,'2027':{year:2027,days:[{date:'2027-01-01',isOffDay:true}],papers:['https://www.gov.cn/example']}};
|
||||
assert.equal(h.calendarDay('2027-01-01',updated).off,true);
|
||||
assert.doesNotMatch(h.render(h.build('sleep',m.records,{...m.range,calendar:updated})),/调休安排未更新/);
|
||||
});
|
||||
test('多天合并柱不冒充单日属性,标记数量始终受限',()=>{
|
||||
const html=h.render(h.build('activity',[{date:'2026-01-01',steps:100}],{start:'2026-01-01',end:'2026-12-31',calendar}));
|
||||
assert.doesNotMatch(html,/class="health-calendar-mark"/);assert.match(html,/多日汇总不标注单日/);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const vm=require('node:vm');
|
||||
const fs=require('node:fs');
|
||||
const HealthViews=require('../src/mi_fitness_mcp/web_assets/static/js/health-views.js');
|
||||
function fixture(){
|
||||
const nodes=new Map(),saved={};let callback;
|
||||
const get=id=>{if(!nodes.has(id))nodes.set(id,{value:({startDate:'2026-10-15',endDate:'2026-10-17',napsSelect:'true'})[id]||'',textContent:'',innerHTML:'',checked:false,dataset:{},setAttribute(k,v){this[k]=v;},classList:{toggle(){},add(){},remove(){}}});return nodes.get(id);};
|
||||
const c=vm.createContext({HealthViews,AbortController,console,performance,document:{getElementById:get,querySelectorAll(){return [];},addEventListener(name,fn){callback=fn;},documentElement:{setAttribute(){}}},localStorage:{getItem(k){return saved[k]||'';},setItem(k,v){saved[k]=v;}},setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){}});
|
||||
vm.runInContext(fs.readFileSync('src/mi_fitness_mcp/web_assets/static/js/health-ui.js','utf8'),c);
|
||||
vm.runInContext(fs.readFileSync('src/mi_fitness_mcp/web_assets/static/js/app.js','utf8'),c);
|
||||
vm.runInContext('showToast=()=>{};',c);
|
||||
return{c,get,saved};
|
||||
}
|
||||
test('切换到睡眠页自动查询并清空上个接口,修改日期会重新加载',async()=>{
|
||||
const f=fixture();vm.runInContext('calls=[];api=async(method,path)=>{calls.push(path);return {status:"ok",data:[]};};state.lastResponse={old:true};',f.c);
|
||||
await vm.runInContext("switchTab('sleep')",f.c);
|
||||
assert.equal(vm.runInContext('state.lastResponse',f.c),null);
|
||||
assert.equal(vm.runInContext('calls.length',f.c),1);
|
||||
assert.match(f.get('healthView-sleep').innerHTML,/睡眠分析明细/);
|
||||
await vm.runInContext("setDatePreset('7d')",f.c);
|
||||
assert.equal(vm.runInContext('calls.length',f.c),2);
|
||||
});
|
||||
test('后返回的旧日期请求不会覆盖新日期结果或控制台',async()=>{
|
||||
const f=fixture();const waits=[];f.c.pending=waits;
|
||||
vm.runInContext('state.activeTab="sleep";api=(method,path,body,view)=>new Promise(resolve=>pending.push({resolve,view}));',f.c);
|
||||
const first=vm.runInContext('querySleep()',f.c);
|
||||
f.get('startDate').value='2026-10-16';
|
||||
const second=vm.runInContext('querySleep()',f.c);
|
||||
assert.equal(waits[0].view.isCurrent(),false);
|
||||
waits[1].resolve({status:'ok',data:[{end_at:'2026-10-16T08:00:00+08:00',time_asleep_minutes:400}]});await second;
|
||||
waits[0].resolve({status:'ok',data:[{end_at:'2026-10-15T08:00:00+08:00',time_asleep_minutes:100}]});await first;
|
||||
assert.equal(vm.runInContext('state.healthViews.sleep.model.records[0].time_asleep_minutes',f.c),400);
|
||||
});
|
||||
test('失败与无数据分别显示,控制台开关会记忆选择',async()=>{
|
||||
const f=fixture();vm.runInContext('state.activeTab="spo2";api=async()=>null;',f.c);
|
||||
await vm.runInContext('querySpo2()',f.c);
|
||||
assert.match(f.get('healthView-spo2').innerHTML,/查询失败/);
|
||||
vm.runInContext('api=async()=>({status:"ok",data:[]});',f.c);
|
||||
await vm.runInContext('querySpo2()',f.c);
|
||||
assert.match(f.get('healthView-spo2').innerHTML,/未保存/);
|
||||
vm.runInContext('setInspectorCollapsed(true)',f.c);
|
||||
assert.equal(f.saved.mi_fitness_inspector_collapsed,'true');
|
||||
assert.equal(f.get('inspectorToggle').textContent,'展开控制台');
|
||||
assert.equal(f.get('inspectorToggle')['aria-expanded'],'false');
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const h=require('../src/mi_fitness_mcp/web_assets/static/js/health-views.js');
|
||||
const range={start:'2026-10-01',end:'2026-10-30'};
|
||||
const stress=values=>values.map((v,i)=>({timestamp:`2026-10-${String(i+1).padStart(2,'0')}T08:00:00+08:00`,stress_score:v,level:'old-source-level'}));
|
||||
test('所有压力低于25时保持20以内的纵轴,25/50/80仅在上方标注,平均值在图内',()=>{
|
||||
const m=h.build('stress',stress([5,12,18]),range);
|
||||
assert.ok(Array.isArray(m.references),'参考线尚未实现');
|
||||
const layout=h.referenceLayout(m);
|
||||
assert.equal(layout.max,20);
|
||||
assert.deepEqual(layout.outside.map(r=>r.value),[25,50,80]);
|
||||
assert.deepEqual(layout.inside.map(r=>r.value),[35/3]);
|
||||
const html=h.render(m);
|
||||
assert.equal((html.match(/class="health-reference"/g)||[]).length,1);
|
||||
assert.match(html,/平均/);assert.match(html,/放松 1–25/);assert.match(html,/重度 81–100/);
|
||||
});
|
||||
test('58的压力数据纵轴至60,25/50在图内,80在上方;25恰在上界时不隐藏',()=>{
|
||||
const m=h.build('stress',stress([10,58]),range),layout=h.referenceLayout(m);
|
||||
assert.equal(layout.max,60);
|
||||
assert.ok(layout.inside.some(r=>r.value===25));assert.ok(layout.inside.some(r=>r.value===50));
|
||||
assert.deepEqual(layout.outside.map(r=>r.value),[80]);
|
||||
const edge=h.referenceLayout(h.build('stress',stress([25]),range));
|
||||
assert.equal(edge.max,25);assert.ok(edge.inside.some(r=>r.key==='stress-25'));
|
||||
});
|
||||
test('六类页面平均值按日期和筛选结果计算,心率170不撑高纵轴',()=>{
|
||||
const activity=h.build('activity',[{date:'2026-10-01',steps:4000},{date:'2026-10-02',steps:8000}],range);
|
||||
assert.equal(activity.references.find(r=>r.key==='average').value,6000);
|
||||
assert.equal(activity.references.find(r=>r.key==='fixed').value,7000);
|
||||
const seven=h.build('activity',[{date:'2026-10-01',steps:4000},{date:'2026-10-02',steps:8000}],{start:'2026-10-02',end:'2026-10-08'});
|
||||
assert.equal(seven.references.find(r=>r.key==='average').value,8000);
|
||||
const hr=h.build('heartrate',[{timestamp:'2026-10-01T08:00:00+08:00',bpm:60,sample_type:'resting'},{timestamp:'2026-10-01T08:01:00+08:00',bpm:100,sample_type:'passive'},{timestamp:'2026-10-01T08:02:00+08:00',bpm:110,sample_type:'passive'}],range);
|
||||
assert.equal(hr.references.find(r=>r.key==='average').value,90);
|
||||
assert.equal(hr.references.find(r=>r.key==='resting-average').value,60);
|
||||
assert.ok(h.referenceLayout(hr).max<170);assert.equal(h.referenceLayout(hr).outside[0].value,170);
|
||||
const sleep=h.build('sleep',[{end_at:'2026-10-01T08:00:00+08:00',time_asleep_minutes:420},{end_at:'2026-10-01T14:00:00+08:00',time_asleep_minutes:30,is_nap:1},{end_at:'2026-10-02T08:00:00+08:00',time_asleep_minutes:510}],range);
|
||||
assert.equal(sleep.references.find(r=>r.key==='average').value,8);assert.equal(sleep.references.find(r=>r.key==='fixed').value,8);
|
||||
const body=h.build('body',[{timestamp:'2026-10-01T08:00:00+08:00',weight_kg:80},{timestamp:'2026-10-02T08:00:00+08:00',weight_kg:84}],range);
|
||||
assert.equal(body.references.find(r=>r.key==='average').value,82);assert.equal(body.references.find(r=>r.key==='fixed').value,65);
|
||||
const spo2=h.build('spo2',[{timestamp:'2026-10-01T08:00:00+08:00',spo2_pct:94},{timestamp:'2026-10-02T08:00:00+08:00',spo2_pct:98}],range);
|
||||
assert.equal(spo2.references.length,1);assert.equal(spo2.references[0].value,96);
|
||||
});
|
||||
test('压力四档含边界,0保持未分类;筛选和导出不改源等级',()=>{
|
||||
for(const [v,name]of [[1,'放松'],[25,'放松'],[26,'轻度'],[50,'轻度'],[51,'中度'],[80,'中度'],[81,'重度'],[100,'重度'],[0,'未分类']])assert.equal(h.pressureBand(v).label,name);
|
||||
const m=h.build('stress',stress([0,25,26,50,51,80,81,100]),{...range,pressureLevel:'moderate',limit:1});
|
||||
assert.equal(m.records.length,1);assert.equal(m.records[0].stress_score,80);assert.equal(m.cards[0].raw,80);
|
||||
assert.match(h.csv(m),/old-source-level/);assert.match(h.csv(m),/压力等级(当前分界)/);assert.match(h.csv(m),/中度/);assert.match(h.detail(m),/中度/);
|
||||
});
|
||||
test('重叠均值和固定值的标注分开摆放;无数值不生成伪均值',()=>{
|
||||
const m=h.build('sleep',[{end_at:'2026-10-01T08:00:00+08:00',time_asleep_minutes:480}],range);
|
||||
const layout=h.referenceLayout(m);
|
||||
assert.equal(layout.inside.length,2);assert.ok(Math.abs(layout.inside[0].labelY-layout.inside[1].labelY)>=18);
|
||||
const empty=h.build('stress',[],range);assert.ok(!empty.references.some(r=>r.key==='average'));assert.ok(!h.render(empty).includes('class="health-reference"'));
|
||||
const zero=h.build('stress',stress([0]),range);assert.equal(zero.references.find(r=>r.key==='average').value,0);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const file='src/mi_fitness_mcp/web_assets/static/js/health-views.js';
|
||||
const load=()=>{assert.ok(fs.existsSync(file),'健康数据展示模块尚未实现');return require('../'+file);};
|
||||
const range={start:'2026-10-15',end:'2026-10-17'};
|
||||
test('睡眠按北京时间醒来日汇总并包含小睡,不以缺失补零',()=>{
|
||||
const h=load();const m=h.build('sleep',[
|
||||
{start_at:'2026-10-14T23:00:00+08:00',end_at:'2026-10-15T07:00:00+08:00',time_asleep_minutes:474,duration_minutes:480,time_awake_minutes:6,stages:[{stage:'deep',minutes:120},{stage:'light',minutes:300},{stage:'rem',minutes:54}]},
|
||||
{end_at:'2026-10-15T14:00:00+08:00',time_asleep_minutes:30,is_nap:1},
|
||||
{end_at:'2026-10-16T00:37:00Z',time_asleep_minutes:517}],range);
|
||||
assert.equal(m.cards[0].raw,510.5);assert.equal(m.cards[1].raw,2);assert.equal(m.cards[3].raw,1);
|
||||
assert.deepEqual(m.points.map(p=>p.values.reduce((s,x)=>s+(x??0),0)),[8.4,517/60,0]);
|
||||
assert.equal(m.points[2].missing,true);
|
||||
});
|
||||
test('心率均值不混合静息和被动记录,0压力保留且缺失不补零',()=>{
|
||||
const h=load();const m=h.build('heartrate',[{timestamp:'2026-10-15T08:00:00+08:00',bpm:60,sample_type:'resting'},{timestamp:'2026-10-15T08:01:00+08:00',bpm:100,sample_type:'passive'}],range);
|
||||
assert.equal(m.cards[0].raw,100);assert.equal(m.cards[1].raw,60);
|
||||
assert.deepEqual(m.points[0].values,[100,60,null]);
|
||||
const s=h.build('stress',[{timestamp:'2026-10-15T08:00:00+08:00',stress_score:0}],range);
|
||||
assert.equal(s.cards[0].raw,0);assert.equal(s.points[1].missing,true);
|
||||
});
|
||||
test('八类页面都有四项概览、图表和分页明细,空记录不代表正常',()=>{
|
||||
const h=load();for(const kind of h.kinds){
|
||||
const m=h.build(kind,[],range);assert.equal(m.cards.length,4);assert.ok(m.columns.length>=3);
|
||||
const html=h.render(m,1);assert.match(html,/health-trend/);assert.match(html,/health-details/);assert.match(html,/未保存/);
|
||||
}
|
||||
});
|
||||
test('大数据只渲染一页,图表点数有上限,完整导出不会漏行且阻止表格公式注入',()=>{
|
||||
const h=load();const rows=Array.from({length:10000},(_,i)=>({timestamp:'2026-10-15T08:00:00+08:00',bpm:60+i%20,sample_type:i===0?'=DANGEROUS()':'passive'}));
|
||||
const m=h.build('heartrate',rows,range);const html=h.render(m,1);
|
||||
assert.equal((html.match(/<tr>/g)||[]).length,11);assert.ok(html.length<22000);
|
||||
assert.ok(m.points.length<=120);assert.equal(m.records.length,10000);
|
||||
const csv=h.csv(m);assert.equal(csv.split('\r\n').length,10001);assert.match(csv,/'=DANGEROUS\(\)/);
|
||||
const last=h.render(m,1000);assert.match(last,/1000 \/ 1000/);
|
||||
});
|
||||
test('源文本转义,最新体重按时间确定,有限记录明确标注',()=>{
|
||||
const h=load();const m=h.build('body',[{timestamp:'2026-10-16T09:00:00+08:00',weight_kg:87.3},{timestamp:'2026-10-15T09:00:00+08:00',weight_kg:88}],{...range,note:'仅返回最新一条'});
|
||||
assert.equal(m.cards[0].raw,87.3);assert.match(h.render(m),/仅返回最新一条/);
|
||||
const a=h.build('workouts',[{start_at:'2026-10-15T08:00:00+08:00',activity_type:'<img src=x onerror=alert(1)>'}],range);
|
||||
assert.ok(!h.render(a).includes('<img'));
|
||||
});
|
||||
test('长范围体重分桶选桶内最近测量,卡片明细和图表都按北京时间日期过滤',()=>{
|
||||
const h=load();const m=h.build('body',[{timestamp:'2026-01-01T09:00:00+08:00',weight_kg:80},{timestamp:'2026-01-02T09:00:00+08:00',weight_kg:75}],{start:'2026-01-01',end:'2026-12-31'});
|
||||
assert.equal(m.points[0].values[0],75);
|
||||
const boundary=h.build('heartrate',[{timestamp:'2026-10-14T20:00:00Z',bpm:70,sample_type:'passive'},{timestamp:'2026-10-17T20:00:00Z',bpm:110,sample_type:'passive'}],range);
|
||||
assert.equal(boundary.records.length,1);assert.equal(boundary.cards[0].raw,70);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
"""日历缓存和失败恢复验证,仅使用本机临时目录与模拟下载。"""
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'src'))
|
||||
from mi_fitness_mcp.workday_calendar import CalendarStore, validate_calendar
|
||||
|
||||
def data(year=2026):
|
||||
return {'year':year,'papers':['https://www.gov.cn/zhengce/example.htm'],'days':[{'date':f'{year}-01-01','name':'元旦','isOffDay':True}]}
|
||||
|
||||
class CalendarTest(unittest.TestCase):
|
||||
def test_reject_placeholder_wrong_year_and_malformed(self):
|
||||
for value in [{'year':2027,'papers':[],'days':[]},{**data(),'year':2027},{**data(),'days':[{'date':'2026-02-30','isOffDay':True}]},{**data(),'days':[{'date':'2026-01-01','isOffDay':1}]}]:
|
||||
with self.assertRaises(ValueError):validate_calendar(value,2026)
|
||||
|
||||
def test_persist_refresh_and_keep_good_cache_on_failure(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store=CalendarStore(Path(tmp),Path(tmp)/'seed',fetcher=lambda year:data(year))
|
||||
store.refresh(2026)
|
||||
self.assertEqual(store.get(2026)['days'][0]['isOffDay'],True)
|
||||
restored=CalendarStore(Path(tmp),Path(tmp)/'seed',fetcher=lambda year:(_ for _ in ()).throw(OSError('offline')))
|
||||
self.assertEqual(restored.get(2026)['year'],2026)
|
||||
restored.refresh(2026)
|
||||
self.assertEqual(restored.get(2026)['year'],2026)
|
||||
restored.refresh(2027)
|
||||
self.assertIsNone(restored.get(2027))
|
||||
|
||||
def test_empty_next_year_is_not_cached_as_published(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store=CalendarStore(Path(tmp),Path(tmp)/'seed',fetcher=lambda year:{'year':year,'days':[],'papers':[]})
|
||||
store.refresh(2027)
|
||||
self.assertIsNone(store.get(2027));self.assertFalse((Path(tmp)/'2027.json').exists())
|
||||
|
||||
def test_bundled_2026_is_available_without_network(self):
|
||||
root=Path(__file__).resolve().parents[1]/'src/mi_fitness_mcp/web_assets/static/calendars'
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store=CalendarStore(Path(tmp),root)
|
||||
value=store.get(2026)
|
||||
self.assertIsNotNone(value)
|
||||
days={r['date']:r['isOffDay'] for r in value['days']}
|
||||
self.assertFalse(days['2026-09-20']);self.assertFalse(days['2026-10-10']);self.assertTrue(days['2026-10-01'])
|
||||
|
||||
def test_schedule_is_nonblocking_deduplicated_and_future_limited(self):
|
||||
import threading
|
||||
import time
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
calls=[]
|
||||
def fetch(year):
|
||||
calls.append(year);entered.set();release.wait(2);return data(year)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store=CalendarStore(Path(tmp),Path(tmp)/'seed',fetcher=fetch)
|
||||
started=time.monotonic();store.schedule([2026,2099]);store.schedule([2026])
|
||||
self.assertLess(time.monotonic()-started,.5);self.assertTrue(entered.wait(1))
|
||||
release.set()
|
||||
for _ in range(100):
|
||||
if not store.running:break
|
||||
time.sleep(.01)
|
||||
self.assertEqual(calls,[2026]);self.assertIsNotNone(store.get(2026))
|
||||
|
||||
def test_calendar_route_uses_existing_auth_and_rejects_invalid_ranges(self):
|
||||
from mi_fitness_mcp.web import create_app
|
||||
import base64
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.dict('os.environ',{'MI_FITNESS_WEB_PASSWORD':'Synthetic-calendar-only','MI_FITNESS_CALENDAR_DIR':tmp}):
|
||||
app=create_app();client=app.test_client()
|
||||
headers={'Authorization':'Basic '+base64.b64encode(b'admin:Synthetic-calendar-only').decode()}
|
||||
with patch.object(app.extensions['workday_calendar'],'schedule'):
|
||||
self.assertEqual(client.get('/api/calendar?start=2026&end=2026').status_code,401)
|
||||
result=client.get('/api/calendar?start=2026&end=2027',headers=headers)
|
||||
self.assertEqual(result.status_code,200);self.assertIn('2026',result.json['years']);self.assertNotIn('2027',result.json['years'])
|
||||
self.assertEqual(client.get('/api/calendar?start=2026&end=2100',headers=headers).status_code,400)
|
||||
self.assertEqual(client.get('/api/calendar?start=2026&end=2026',headers={**headers,'Origin':'https://untrusted.example'}).status_code,403)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
Reference in new issue
Block a user