feat: render health pages from server summaries

This commit is contained in:
weiyuqing committed 2026-10-17 14:10:27 +08:00
1 parent c9ca625de1
commit bcd4cd3759
9 files changed
+241 -103

No files matched your search

+7 -5
View File
@@ -8,22 +8,24 @@ function fixture(){
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 copied=[],requests=[];
const context=vm.createContext({document:{getElementById:get,querySelectorAll(){return [];},addEventListener(){}},
localStorage:{getItem(){return '';},setItem(){}},performance,console,setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){},
AbortController,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()]||[]})})});
fetch:async url=>{requests.push(url);return {status:200,ok:true,text:async()=>JSON.stringify({status:'ok',data:url.startsWith('/proxy/api/overview?')?{steps:6000,active_kcal:500,distance_m:4000,resting_mean_bpm:62,mean_daily_asleep_minutes:450,mean_spo2_pct:97,mean_stress:30,activity_days:1,sleep_days:1,resting_count:1,spo2_count:1}: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};
return {context,get,copied,requests};
}
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');
assert.equal(vm.runInContext('state.lastRequestInfo.path.split("?")[0]',ui.context),'/api/overview');
assert.deepEqual(ui.requests,['/proxy/api/overview?start_date=2026-09-18&end_date=2026-10-17']);
assert.ok(JSON.stringify(vm.runInContext('state.lastResponse',ui.context)).length<500);
});
test('silent and superseded requests do not overwrite inspector state',async()=>{
const ui=fixture();ui.get('inspectorBody').innerHTML='keep-current';
+7 -13
View File
@@ -14,21 +14,17 @@ function dashboard(responses) {
const context = vm.createContext({
localStorage: {getItem() {return '';}, setItem() {}},
document: {getElementById: get, querySelectorAll() {return [];}, addEventListener() {}},
responses, console, setTimeout() {}, clearTimeout() {}, setInterval() {}, clearInterval() {},
responses, console, AbortController, 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);
vm.runInContext("showToast = () => {}; api = async (method, url) => responses[url.split('?')[0]] || {status:'ok',data:{steps:null,active_kcal:null,distance_m:null,resting_mean_bpm:null,mean_daily_asleep_minutes:null,mean_spo2_pct:null,mean_stress:null,activity_days:0,sleep_days:0,resting_count:0,spo2_count:0}};", 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}]},
'/api/overview': {status:'ok',data:{steps:8754,active_kcal:846,distance_m:5364,resting_mean_bpm:62,mean_daily_asleep_minutes:495.5,mean_spo2_pct:98,mean_stress:0,activity_days:2,sleep_days:2,resting_count:1,spo2_count:1}},
});
await vm.runInContext('loadOverview()',ui.context);
assert.equal(ui.get('statSteps').textContent, (8754).toLocaleString());
@@ -40,12 +36,9 @@ test('overview aggregates the selected range instead of taking the latest day',
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 () => {
test('overview displays server range averages and wake-day coverage 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}]},
'/api/overview':{status:'ok',data:{steps:null,active_kcal:null,distance_m:null,resting_mean_bpm:65,mean_spo2_pct:97,mean_stress:30,mean_daily_asleep_minutes:525.5,activity_days:0,sleep_days:2,resting_count:2,spo2_count:2}},
});
ui.get('startDate').value='2026-10-01';
await vm.runInContext('loadOverview()',ui.context);
@@ -63,7 +56,8 @@ test('30 day preset is inclusive and refreshes the overview without manual reloa
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.equal(calls.length,1);
assert.equal(calls[0],'/api/overview?start_date=2026-09-17&end_date=2026-10-16');
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');
+6 -5
View File
@@ -3,17 +3,18 @@ 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 summary(kind='sleep',rows=[]){return {status:'ok',kind,statistics:{count:rows.length,mean_daily_asleep_minutes:400,mean_daily_deep_minutes:null,nap_count:0},coverage:rows.length,stride:1,points:[{date:'2026-10-16',values:[null,null,null,400/60],missing:false}],page:{number:1,size:10,total:rows.length,rows}};}
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(){}});
const c=vm.createContext({HealthViews,summary,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);
const f=fixture();vm.runInContext('calls=[];api=async(method,path)=>{calls.push(path);return summary();};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);
@@ -28,15 +29,15 @@ test('后返回的旧日期请求不会覆盖新日期结果或控制台',async(
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;
waits[1].resolve(summary('sleep',[{end_at:'2026-10-16T08:00:00+08:00',time_asleep_minutes:400}]));await second;
waits[0].resolve(summary('sleep',[{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);
vm.runInContext('api=async()=>summary("spo2");',f.c);
await vm.runInContext('querySpo2()',f.c);
assert.match(f.get('healthView-spo2').innerHTML,/未保存/);
vm.runInContext('setInspectorCollapsed(true)',f.c);
+79
View File
@@ -0,0 +1,79 @@
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 summary(kind='stress',number=1,total=21){return {status:'ok',kind,range:{start:'2026-10-15',end:'2026-10-17'},statistics:{count:total,primary_mean:33,primary_min:10,primary_max:60},coverage:1,stride:1,points:[{date:'2026-10-16',values:[33],missing:false}],page:{number,size:10,total,rows:[{timestamp:'2026-10-16T00:00:00Z',stress_score:number}]}};}
function fixture(){
const nodes=new Map(),links=[],requests=[],calendars=[];
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,setAttribute(){},classList:{toggle(){},add(){},remove(){}},querySelector(){return get(id+'-table');}});return nodes.get(id);};
const context=vm.createContext({HealthViews,AbortController,console,performance,URL,document:{getElementById:get,querySelectorAll(){return [];},addEventListener(){},createElement(){const a={click(){links.push(this.href);},remove(){}};return a;},body:{appendChild(){}}},localStorage:{getItem(){return '';},setItem(){}},setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){},fetch:url=>new Promise(resolve=>{if(url.startsWith('/api/calendar'))calendars.push({url,resolve});else requests.push({url,resolve});})});
for(const name of ['health-ui','app'])vm.runInContext(fs.readFileSync(`src/mi_fitness_mcp/web_assets/static/js/${name}.js`,'utf8'),context);
vm.runInContext('showToast=()=>{};state.activeTab="stress";',context);
const reply=(i,p)=>requests[i].resolve({status:200,ok:true,text:async()=>JSON.stringify(p)});
return {context,get,links,requests,calendars,reply,run:code=>vm.runInContext(code,context)};
}
test('test_pagination_fetches_only_page_and_preserves_chart',async()=>{
const f=fixture(),initial=f.run('loadHealthView("stress")');assert.match(f.requests[0].url,/^\/proxy\/api\/views\/stress\?/);f.reply(0,summary());await initial;
const model=f.run('state.healthViews.stress.model'),html=f.get('healthView-stress').innerHTML;
const second=f.run('healthPage("stress",2)');assert.match(f.requests[1].url,/\/views\/stress\/records\?.*page=2/);
const p=summary('stress',2);delete p.statistics;delete p.points;f.reply(1,p);await second;
assert.equal(f.run('state.healthViews.stress.model'),model);assert.equal(model.cards[0].raw,33);assert.equal(f.get('healthView-stress').innerHTML,html);assert.match(f.get('healthView-stress-table').innerHTML,/2 \/ 3/);
f.run('exportHealthRecords("stress")');assert.match(f.links[0],/^\/proxy\/api\/views\/stress\/export\?/);assert.doesNotMatch(f.links[0],/page=/);
});
test('test_late_page_and_overview_responses_do_not_overwrite_new_range',async()=>{
const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial;
const old=f.run('healthPage("stress",2)');f.get('startDate').value='2026-10-16';const fresh=f.run('loadHealthView("stress")');f.reply(2,summary('stress',1,5));await fresh;
const html=f.get('healthView-stress').innerHTML,inspector=f.get('inspectorBody').innerHTML;
f.reply(1,summary('stress',2));await old;f.calendars[0].resolve({ok:true,json:async()=>({years:{old:true}})});await new Promise(resolve=>setImmediate(resolve));
assert.equal(f.get('healthView-stress').innerHTML,html);assert.equal(f.get('inspectorBody').innerHTML,inspector);assert.equal(f.run('state.healthViews.stress.model.total'),5);
f.run('state.activeTab="overview"');const overview=f.run('loadOverview()');f.get('endDate').value='2026-10-16';f.reply(3,{status:'ok',data:{steps:999}});await overview;assert.equal(f.get('statSteps').textContent,'--');assert.equal(f.get('inspectorBody').innerHTML.includes('999'),false);
});
test('switching type and filters rejects pending page results, latest page wins',async()=>{
const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial;
const a=f.run('healthPage("stress",2)'),b=f.run('healthPage("stress",3)');f.reply(2,summary('stress',3));await b;f.reply(1,summary('stress',2));await a;assert.equal(f.run('state.healthViews.stress.page'),3);
const pending=f.run('healthPage("stress",2)');f.get('stressLevel').value='mild';f.run('state.activeTab="sleep"');f.reply(3,summary('stress',2));await pending;assert.equal(f.run('state.healthViews.stress.page'),3);
});
test('shrinking totals re-fetch the last valid page instead of showing false empty',async()=>{
const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial;
const pending=f.run('healthPage("stress",3)');const p=summary('stress',3,11);p.page.rows=[];f.reply(1,p);await new Promise(resolve=>setImmediate(resolve));assert.match(f.requests[2].url,/page=2/);f.reply(2,summary('stress',2,11));await pending;assert.equal(f.run('state.healthViews.stress.page'),2);assert.equal(f.run('state.healthViews.stress.model.total'),11);
});
test('test_empty_and_failure_remain_distinct',async()=>{
const f=fixture();let p=f.run('loadHealthView("stress")');f.reply(0,{status:'error'});await p;assert.match(f.get('healthView-stress').innerHTML,/查询失败/);
p=f.run('loadHealthView("stress")');const empty=summary('stress',1,0);empty.page.rows=[];f.reply(1,empty);await p;assert.match(f.get('healthView-stress').innerHTML,/未保存/);assert.doesNotMatch(f.get('healthView-stress').innerHTML,/查询失败/);
});
test('filters use exact Beijing dates and server selection parameters for all eight views',async()=>{
const f=fixture();f.get('hrType').value='resting';f.get('hrLimit').value='25';f.get('stressLevel').value='unclassified';f.get('stressLimit').value='12';f.get('latestOnlyCheck').checked=true;
for(const kind of HealthViews.kinds){f.context.kind=kind;f.run('state.activeTab=kind');const pending=f.run('loadHealthView(kind)');const i=f.requests.length-1;const u=new URL(f.requests[i].url,'http://local');assert.equal(u.pathname,'/proxy/api/views/'+kind);assert.equal(u.searchParams.get('start_date'),'2026-10-15');assert.equal(u.searchParams.get('end_date'),'2026-10-17');
if(kind==='heartrate'){assert.equal(u.searchParams.get('limit'),'25');assert.equal(u.searchParams.get('sample_type'),'resting');}if(kind==='stress'){assert.equal(u.searchParams.get('pressure_level'),'unclassified');assert.equal(u.searchParams.get('limit'),'12');}if(kind==='body')assert.equal(u.searchParams.get('latest_only'),'true');
f.reply(i,summary(kind));await pending;}
});
for(const change of ['date','type','filter','limit'])test(`pending pages and calendars reject changed ${change} selection`,async()=>{
const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial;
const page=f.run('healthPage("stress",2)'),model=f.run('state.healthViews.stress.model');
if(change==='date')f.get('endDate').value='2026-10-16';
if(change==='type')f.run('state.activeTab="sleep"');
if(change==='filter')f.get('stressLevel').value='mild';
if(change==='limit')f.get('stressLimit').value='5';
const html=f.get('healthView-stress').innerHTML,inspector=f.get('inspectorBody').innerHTML;
f.reply(1,summary('stress',2));await page;f.calendars[0].resolve({ok:true,json:async()=>({years:{unexpected:true}})});await new Promise(resolve=>setImmediate(resolve));
assert.equal(f.run('state.healthViews.stress.page'),1);assert.equal(model.range.calendar,undefined);assert.equal(f.get('healthView-stress').innerHTML,html);assert.equal(f.get('inspectorBody').innerHTML,inspector);
});
test('a current calendar can update date marks after paging without reverting rows',async()=>{
const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial;
const next=f.run('healthPage("stress",2)');f.reply(1,summary('stress',2));await next;
f.calendars[0].resolve({ok:true,json:async()=>({years:{'2026':{days:[{date:'2026-10-16',isOffDay:true}],papers:['synthetic']}}})});await new Promise(resolve=>setImmediate(resolve));
assert.equal(f.run('state.healthViews.stress.page'),2);assert.equal(f.run('state.healthViews.stress.model.records[0].stress_score'),2);assert.match(f.get('healthView-stress').innerHTML,/2 \/ 3/);assert.match(f.get('healthView-stress').innerHTML,/stroke="#22a06b"/);
});
test('a failed page leaves summary intact and allows retrying the same page',async()=>{
const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial;
const html=f.get('healthView-stress').innerHTML;
let next=f.run('healthPage("stress",2)');f.reply(1,{status:'error'});await next;assert.match(f.get('healthView-stress-table').innerHTML,/明细查询失败/);assert.equal(f.get('healthView-stress').innerHTML,html);assert.equal(f.run('state.healthViews.stress.page'),1);
next=f.run('healthPage("stress",2)');f.reply(2,summary('stress',2));await next;assert.equal(f.run('state.healthViews.stress.page'),2);assert.doesNotMatch(f.get('healthView-stress-table').innerHTML,/查询失败/);
});
test('out-of-order overview loads preserve new range values and inspector',async()=>{
const f=fixture();f.run('state.activeTab="overview"');const old=f.run('loadOverview()');f.get('startDate').value='2026-10-16';const current=f.run('loadOverview()');
f.reply(1,{status:'ok',data:{steps:42}});await current;const inspector=f.get('inspectorBody').innerHTML;
f.reply(0,{status:'ok',data:{steps:999}});await old;assert.equal(f.get('statSteps').textContent,'42');assert.equal(f.get('inspectorBody').innerHTML,inspector);assert.match(f.get('overviewRange').textContent,/2026-10-16 至 2026-10-17/);
});
+33
View File
@@ -0,0 +1,33 @@
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'};
function payload(kind,statistics,total=90000){return {status:'ok',kind,statistics,coverage:23,stride:1,points:Array.from({length:30},(_,i)=>({date:`2026-10-${String(i+1).padStart(2,'0')}`,values:[40],missing:false})),page:{number:1,size:10,total,rows:Array.from({length:10},()=>({timestamp:'2026-10-15T00:00:00Z',stress_score:1,bpm:1,weight_kg:1,steps:1}))}};}
test('test_server_summary_drives_cards_without_raw_records',()=>{
const m=H.fromSummary('stress',payload('stress',{count:90000,primary_mean:47.5,primary_min:0,primary_max:120}),range);
assert.equal(m.cards[0].raw,47.5);assert.equal(m.cards[1].raw,0);assert.equal(m.cards[2].raw,120);assert.equal(m.cards[3].raw,23);
assert.equal(m.references.find(r=>r.key==='average').value,47.5);assert.equal(m.records.length,10);assert.equal(m.total,90000);assert.equal(m.points.length,30);
assert.match(H.detail(m),/90,000/);assert.match(H.detail(m),/1 \/ 9000/);assert.match(H.render(m),/health-calendar-mark/);
});
test('all eight kinds map numeric server statistics and latest projections',()=>{
const cases={activity:[{primary_sum:800,total_active_kcal:50,total_distance_m:2500,mean_daily_steps:400},[800,50,2.5,23]],heartrate:[{passive_mean_bpm:70,resting_mean_bpm:60,primary_min:40,primary_max:180,primary_mean:69},[70,60,40,180]],sleep:[{mean_daily_asleep_minutes:450,mean_daily_deep_minutes:90,nap_count:3},[450,23,90,3]],workouts:[{count:17,primary_sum:600,total_distance_m:9000,total_calories_kcal:500},[17,600,9,500]],body:[{count:18,primary_mean:64,latest:{weight_kg:65,bmi:22,body_fat_pct:19}},[65,22,19,18]],spo2:[{primary_mean:97,primary_min:89,primary_max:100},[97,89,100,23]],abnormal:[{count:20,primary_sum:90,latest:{start_at:'2026-10-15T00:00:00Z'}},[20,90,23,null]]};
for(const [kind,[stats,expected]] of Object.entries(cases)){
const m=H.fromSummary(kind,payload(kind,stats),range);assert.deepEqual(m.cards.map(c=>c.raw),expected,kind);
if(['body','spo2','heartrate'].includes(kind))assert.equal(H.referenceLayout(m).min,{body:40,spo2:80,heartrate:40}[kind]);
}
});
test('server total governs empty chart even when a requested detail page has no rows',()=>{
const p=payload('stress',{count:90000,primary_mean:null});p.page.rows=[];
const m=H.fromSummary('stress',p,range);assert.match(H.render(m),/<svg/);assert.doesNotMatch(H.render(m),/未保存/);assert.ok(!m.references.some(r=>r.key==='average'));
p.page.total=0;p.statistics.count=0;assert.doesNotMatch(H.render(H.fromSummary('stress',p,range)),/<svg/);
const b=H.fromSummary('body',payload('body',{count:0,latest:null},0),range);assert.equal(b.cards[0].value,'—');
});
test('server points, fixed references and two-color date marks keep the existing renderer',()=>{
const calendar={'2026':{days:[{date:'2026-10-01',isOffDay:true},{date:'2026-10-10',isOffDay:false}],papers:['synthetic']}};
const p=payload('heartrate',{primary_mean:65,resting_mean_bpm:60,passive_mean_bpm:70,primary_min:40,primary_max:75});
p.points=p.points.map(point=>({...point,values:[70,60,null]}));
const m=H.fromSummary('heartrate',p,{...range,calendar}),html=H.render(m);
assert.equal(m.points,p.points);assert.equal(H.referenceLayout(m).min,40);assert.equal(H.referenceLayout(m).max,70);assert.deepEqual(H.referenceLayout(m).outside.map(r=>r.value),[170]);
const marks=[...html.matchAll(/<line class="health-calendar-mark"[^>]+/g)].map(x=>x[0]);assert.equal(marks.length,30);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"'));
});