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,49 @@
|
||||
"""本机修改网页资源,保留文件编码和换行。"""
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
base=Path('work/mi-fitness-mcp-cn/src/mi_fitness_mcp/web_assets')
|
||||
def edit(path,fn):
|
||||
raw=path.read_bytes();crlf=b'\r\n' in raw
|
||||
text=raw.decode('utf-8').replace('\r\n','\n')
|
||||
updated=fn(text)
|
||||
path.write_bytes((updated.replace('\n','\r\n') if crlf else updated).encode('utf-8'))
|
||||
|
||||
def app(s):
|
||||
s=s.replace(" inspectorRequestId: 0,", " inspectorRequestId: 0,\n healthRequestId: 0,\n healthViews: {},\n healthController: null,")
|
||||
s=s.replace(" if (state.activeTab === 'overview') return loadOverview();", " return reloadActiveView();",1)
|
||||
s=s.replace("function switchTab(tabId) {\n state.activeTab = tabId;", "function switchTab(tabId) {\n invalidateHealthQuery();\n clearInspector();\n state.activeTab = tabId;")
|
||||
s=s.replace(" if (tabId === 'overview') return loadOverview();", " return reloadActiveView();",1)
|
||||
s=s.replace(" headers: getHeaders(),\n };", " headers: getHeaders(),\n ...(view.signal ? {signal: view.signal} : {}),\n };",1)
|
||||
replacements={
|
||||
'querySummary':"return loadHealthView('activity');",
|
||||
'queryHeartRate':"return loadHealthView('heartrate');",
|
||||
'querySleep':"return loadHealthView('sleep');",
|
||||
'queryWorkouts':"return loadHealthView('workouts');",
|
||||
'queryBody':"return loadHealthView('body');",
|
||||
'querySpo2':"return loadHealthView('spo2');",
|
||||
'queryStress':"return loadHealthView('stress');",
|
||||
'queryAbnormalHr':"return loadHealthView('abnormal');",
|
||||
}
|
||||
for name,body in replacements.items():
|
||||
s,n=re.subn(r'async function '+name+r'\(\) \{.*?\n\}',f'async function {name}() {{\n {body}\n}}',s,count=1,flags=re.S)
|
||||
assert n==1,name
|
||||
s=s.replace(' initTheme();\n initDates();',' initTheme();\n initHealthViews();\n initDates();',1)
|
||||
s=s.replace(" if (state.activeTab === 'overview') loadOverview();", " clearInspector();\n reloadActiveView();",1)
|
||||
return s
|
||||
|
||||
def html(s):
|
||||
s=s.replace('20261017-ui3','20261017-ui4')
|
||||
s=s.replace('<div class="main-layout">','<div class="main-layout inspector-collapsed" id="mainLayout">',1)
|
||||
s=s.replace('<aside class="app-inspector">','<aside class="app-inspector" id="inspectorPanel" hidden>',1)
|
||||
s=s.replace('<button class="preset-pill" data-preset="month" id="preset-month">本月</button>', '<button class="preset-pill" data-preset="month" id="preset-month">本月</button>\n <button class="btn btn-outline btn-sm" id="inspectorToggle" onclick="toggleInspector()" aria-expanded="false" aria-controls="inspectorPanel" style="margin-left:auto">展开控制台</button>')
|
||||
for kind in ['activity','heartrate','sleep','workouts','body','spo2','stress','abnormal']:
|
||||
pattern=r'(<section class="tab-pane" id="tab-'+kind+r'">)(.*?)(\n </section>)'
|
||||
s,n=re.subn(pattern,lambda m:m[1]+m[2]+'\n <div class="health-results" id="healthView-'+kind+'" aria-live="polite"></div>'+m[3],s,count=1,flags=re.S)
|
||||
assert n==1,kind
|
||||
s=s.replace('<div id="summaryVisualContainer"','<div hidden id="summaryVisualContainer"').replace('<div id="sleepTimelineWrapper"','<div hidden id="sleepTimelineWrapper"')
|
||||
s=s.replace('<div class="btn-group">','<div class="btn-group">\n <button class="btn btn-secondary btn-sm" onclick="setInspectorCollapsed(true)" title="收起控制台">收起</button>',1)
|
||||
s=s.replace('<script src="/static/js/app.js', '<script src="/static/js/health-views.js?v=20261017-ui4"></script>\n <script src="/static/js/health-ui.js?v=20261017-ui4"></script>\n <script src="/static/js/app.js',1)
|
||||
return s
|
||||
edit(base/'static/js/app.js',app)
|
||||
edit(base/'templates/index.html',html)
|
||||
@@ -0,0 +1,61 @@
|
||||
// 本机 Edge 实测,固定合成数据,不访问 NAS。
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
async function main() {
|
||||
const tabs = await (await fetch('http://127.0.0.1:18382/json')).json();
|
||||
const tab = tabs.find(tab => tab.type === 'page');
|
||||
const ws = new WebSocket(tab.webSocketDebuggerUrl);
|
||||
await new Promise((resolve,reject) => {ws.onopen=resolve;ws.onerror=reject;});
|
||||
let id=0;
|
||||
const pending = new Map();
|
||||
ws.onmessage = event => {
|
||||
const result=JSON.parse(event.data);
|
||||
if (result.id && pending.has(result.id)) {
|
||||
const {resolve,reject}=pending.get(result.id);pending.delete(result.id);
|
||||
result.error ? reject(result.error) : resolve(result.result);
|
||||
}
|
||||
};
|
||||
function call(method,params={}) {return new Promise((resolve,reject)=>{const next=++id;pending.set(next,{resolve,reject});ws.send(JSON.stringify({id:next,method,params}));});}
|
||||
await call('Page.enable');await call('Runtime.enable');await call('Network.enable');
|
||||
await call('Network.setBlockedURLs',{urls:['https://*']});
|
||||
await call('Emulation.setDeviceMetricsOverride',{width:1400,height:900,deviceScaleFactor:1,mobile:false});
|
||||
const version=process.argv[2] || 'before';
|
||||
const preset=process.argv[3] || '30d';
|
||||
await call('Page.navigate',{url:'http://127.0.0.1:18363/?version='+version});
|
||||
let ready=false;
|
||||
for(let i=0;i<100;i++) {
|
||||
await sleep(100);
|
||||
const result=await call('Runtime.evaluate',{expression:"document.readyState==='complete' && document.getElementById('statSpo2')?.textContent==='97%'",returnByValue:true});
|
||||
if(result.result.value){ready=true;break;}
|
||||
}
|
||||
if(!ready)throw new Error('Fixture dashboard did not become ready');
|
||||
const measured=await call('Runtime.evaluate',{expression:`(async()=>{
|
||||
const measurements={version:${JSON.stringify(version)},renders:0,render_ms:0,max_inspector_nodes:0};
|
||||
const original=renderJsonViewer;
|
||||
renderJsonViewer=function(data){const start=performance.now();original(data);measurements.render_ms+=performance.now()-start;measurements.renders++;measurements.max_inspector_nodes=Math.max(measurements.max_inspector_nodes,document.getElementById('inspectorBody').querySelectorAll('*').length);};
|
||||
const tasks=[];const observer=new PerformanceObserver(list=>tasks.push(...list.getEntries().map(entry=>entry.duration)));
|
||||
observer.observe({type:'longtask'});
|
||||
const start=performance.now();await setDatePreset(${JSON.stringify(preset)});
|
||||
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
||||
measurements.elapsed_ms=performance.now()-start;
|
||||
measurements.steps=document.getElementById('statSteps').textContent;
|
||||
measurements.spo2=document.getElementById('statSpo2').textContent;
|
||||
measurements.stress=document.getElementById('statStress').textContent;
|
||||
measurements.inspector_nodes=document.getElementById('inspectorBody').querySelectorAll('*').length;
|
||||
measurements.header_backdrop=getComputedStyle(document.querySelector('.top-header')).backdropFilter;
|
||||
measurements.inspector_visible=getComputedStyle(document.querySelector('.app-inspector')).display!=='none';
|
||||
await new Promise(resolve=>setTimeout(resolve,100));observer.disconnect();
|
||||
measurements.long_tasks=tasks;renderJsonViewer=original;
|
||||
return measurements;
|
||||
})()`,awaitPromise:true,returnByValue:true});
|
||||
if(measured.exceptionDetails)throw new Error(JSON.stringify(measured.exceptionDetails));
|
||||
const result=measured.result.value;
|
||||
result.preset=preset;
|
||||
const target=path.resolve('work/perf-validation/'+version+'-'+preset+'.json');
|
||||
fs.writeFileSync(target,JSON.stringify(result,null,2)+'\n','utf8');
|
||||
const screenshot=await call('Page.captureScreenshot',{format:'png',captureBeyondViewport:false});
|
||||
fs.writeFileSync(path.resolve('work/perf-validation/'+version+'-'+preset+'.png'),Buffer.from(screenshot.data,'base64'));
|
||||
console.log(JSON.stringify(result));ws.close();
|
||||
}
|
||||
main().catch(error=>{console.error(error);process.exit(1);});
|
||||
@@ -0,0 +1,30 @@
|
||||
"""对本机隔离 Linux 容器做鉴权及持久化验收。"""
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
with httpx.Client(base_url='http://127.0.0.1:18372', trust_env=False, timeout=20) as client:
|
||||
assert client.get('/').status_code == 401
|
||||
assert client.get('/proxy/api/auth/keys').status_code == 401
|
||||
assert client.get('/', auth=('admin', 'wrong')).status_code == 401
|
||||
client.auth = ('admin', 'synthetic-web-for-local-test')
|
||||
page = client.get('/')
|
||||
assert page.status_code == 200 and '20261016-ui2' in page.text
|
||||
response = client.get('/proxy/api/summary', params={'start_date': '2026-10-15', 'end_date': '2026-10-15'},
|
||||
headers={'X-API-Key': 'mif_sk_syntheticlocal'})
|
||||
assert response.status_code == 200, response.status_code
|
||||
assert response.json()['data'][0]['steps'] == 4691
|
||||
assert response.json()['data'][0]['active_kcal'] == 596
|
||||
assert response.headers['Cache-Control'] == 'no-store'
|
||||
assert client.get('/proxy/api/auth/keys').status_code == 200
|
||||
assert client.get('/proxy/api/auth/keys', headers={'X-API-Key': 'mif_sk_invalid'}).status_code == 200
|
||||
assert client.delete('/proxy/api/auth/keys/mif_sk_syntheticlocal', headers={'Origin': 'https://untrusted.invalid'}).status_code == 403
|
||||
assert client.get('/proxy/api/summary', params={'start_date': '2026-10-15', 'end_date': '2026-10-15'},
|
||||
headers={'X-API-Key': 'mif_sk_invalid'}).status_code == 401
|
||||
with sqlite3.connect(ROOT / 'work/audit2-container-validation/state/data/mi_fitness.db') as conn:
|
||||
assert conn.execute("SELECT count(*) FROM api_keys WHERE pass_token!=''").fetchone()[0] == 0
|
||||
assert conn.execute('PRAGMA quick_check').fetchone()[0] == 'ok'
|
||||
print(json.dumps({'browser_auth': 'ok', 'query': 'ok', 'csrf_block': 'ok', 'invalid_api_key': 'denied',
|
||||
'sqlite_integrity': 'ok', 'sqlite_plaintext_tokens': 0, 'fixture': 'synthetic only'}, indent=2))
|
||||
@@ -0,0 +1,26 @@
|
||||
"""仅针对本机合成容器库核查备份和只读查询。"""
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, '/opt/health')
|
||||
import runtime
|
||||
from readonly import ReadOnlyDatabase, query_records
|
||||
|
||||
source = Path('/state/data/mi_fitness.db')
|
||||
backup = runtime.backup_database(source, '/state/backups')
|
||||
with sqlite3.connect(backup) as conn:
|
||||
assert conn.execute('SELECT steps FROM daily_activity').fetchone()[0] == 4691
|
||||
assert conn.execute('SELECT count(*) FROM api_keys').fetchone()[0] == 0
|
||||
assert conn.execute('PRAGMA quick_check').fetchone()[0] == 'ok'
|
||||
assert query_records(source, 'daily_activity', '2026-10-15', '2026-10-15')[0]['steps'] == 4691
|
||||
db = ReadOnlyDatabase(source)
|
||||
with db._get_connection() as conn:
|
||||
for statement in ('SELECT * FROM api_keys', 'DELETE FROM daily_activity'):
|
||||
try:
|
||||
conn.execute(statement)
|
||||
except sqlite3.DatabaseError:
|
||||
continue
|
||||
raise AssertionError('Read-only boundary bypass')
|
||||
print(json.dumps({'backup_restore': 'ok', 'backup_credentials_removed': True,
|
||||
'readonly_health_query': 'ok', 'credential_table_denied': True, 'database_write_denied': True}))
|
||||
@@ -0,0 +1,58 @@
|
||||
"""在本机 Linux 候选镜像内核查数据边界;无真实账号或网络。"""
|
||||
import asyncio
|
||||
import json
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from mi_fitness_mcp import api
|
||||
from mi_fitness_mcp.models import DailyActivity, StressSample, Workout
|
||||
from mi_fitness_mcp.storage import Database
|
||||
from mi_fitness_mcp.services.sync_service import SyncService
|
||||
|
||||
CN = timezone(timedelta(hours=8))
|
||||
with tempfile.TemporaryDirectory(prefix='audit3-edges-') as temporary:
|
||||
db = Database(Path(temporary) / 'synthetic.sqlite')
|
||||
item = DailyActivity(id='day-A', provider='mi_fitness', source_type='cloud_session', user_id='A',
|
||||
date='2026-10-16', steps=1111, active_kcal=100, distance_m=1000)
|
||||
db.insert_daily_activity(item)
|
||||
with db._get_connection() as conn:
|
||||
conn.execute('CREATE TABLE api_keys (key TEXT,label TEXT,user_id TEXT,pass_token TEXT,region TEXT,created_at TEXT,revoked INTEGER DEFAULT 0)')
|
||||
conn.commit()
|
||||
try:
|
||||
db.insert_daily_activity(item.model_copy(update={'user_id':'B','steps':2222}))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError('Account collision not blocked')
|
||||
try:
|
||||
api._create_key_record(db, 'B', 'synthetic', 'cn', 'other')
|
||||
except api.HTTPException as exc:
|
||||
assert exc.status_code == 409
|
||||
else:
|
||||
raise AssertionError('Second owner authorization accepted')
|
||||
assert db.query_daily_activity('A','2026-10-16','2026-10-16')[0]['steps'] == 1111
|
||||
db.update_sync_state('heart_rate', datetime(2026,10,16,9,tzinfo=CN), user_id='A')
|
||||
assert db.get_sync_state('heart_rate', user_id='B') is None
|
||||
class Adapter:
|
||||
user_id='A'
|
||||
region='cn'
|
||||
calls=[]
|
||||
def is_connected(self): return True
|
||||
async def iter_heart_rate(self,start,end):
|
||||
self.calls.append((start,end))
|
||||
if False: yield None
|
||||
adapter=Adapter()
|
||||
result=asyncio.run(SyncService(adapter,db).sync_data_type('heart_rate',end_date='2026-10-16'))
|
||||
assert result['status']=='ok' and adapter.calls==[('2026-10-16','2026-10-16')]
|
||||
for index,hour in enumerate((1,9)):
|
||||
db.insert_stress_sample(StressSample(id=f'stress-{index}',provider='mi_fitness',source_type='cloud_session',user_id='A',
|
||||
timestamp=datetime(2026,10,16,hour,tzinfo=CN),stress_score=30,level='medium'))
|
||||
coverage=next(row for row in db.get_data_coverage('A') if row['data_type']=='stress')
|
||||
assert coverage['days_with_data']==1 and coverage['first_date']=='2026-10-16'
|
||||
exercise=Workout(id='exercise',workout_id='exercise',provider='mi_fitness',source_type='cloud_session',user_id='A',
|
||||
activity_type='running',start_at=datetime(2026,10,16,9,tzinfo=CN),end_at=datetime(2026,10,16,9,30,tzinfo=CN),duration_minutes=30)
|
||||
db.insert_workout(exercise)
|
||||
db.insert_workout(exercise.model_copy(update={'total_steps':4000,'avg_pace_sec_per_km':300}))
|
||||
row=db.query_workouts('A','2026-10-16','2026-10-16')[0]
|
||||
assert row['total_steps']==4000 and row['avg_pace_sec_per_km']==300
|
||||
print(json.dumps({'account_isolation':'ok','cursor_isolation':'ok','same_day_incremental':'ok','coverage_dates':'ok','workout_updates':'ok'}))
|
||||
@@ -0,0 +1,55 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
root=Path(tempfile.mkdtemp())
|
||||
os.environ['XDG_CONFIG_HOME']=str(root/'config')
|
||||
os.environ['XDG_DATA_HOME']=str(root/'data')
|
||||
os.environ['HOME']=str(root)
|
||||
from mi_fitness_mcp.api import app
|
||||
from mi_fitness_mcp.models import HeartRateSample
|
||||
from mi_fitness_mcp.services.sync_service import SyncService
|
||||
from mi_fitness_mcp.storage import Database
|
||||
|
||||
|
||||
class SlowDisk(Database):
|
||||
def insert_heart_rate_sample(self, sample, **kwargs):
|
||||
time.sleep(0.01)
|
||||
return super().insert_heart_rate_sample(sample, **kwargs)
|
||||
|
||||
|
||||
class SyntheticAdapter:
|
||||
def is_connected(self): return True
|
||||
async def iter_heart_rate(self,start,end):
|
||||
stamp=datetime(2026,10,15,tzinfo=timezone.utc)
|
||||
for i in range(40):
|
||||
yield HeartRateSample(id=f'local-proof-{i}',provider='mi_fitness',source_type='cloud_session',user_id='synthetic-only',timestamp=stamp+timedelta(minutes=i),bpm=70)
|
||||
|
||||
|
||||
async def main():
|
||||
db=SlowDisk(root/'health.sqlite')
|
||||
service=SyncService(SyntheticAdapter(),db)
|
||||
task=asyncio.create_task(service.sync_data_type('heart_rate','2026-10-15','2026-10-15'))
|
||||
await asyncio.sleep(0.02)
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app),base_url='http://127.0.0.1') as client:
|
||||
started=time.perf_counter()
|
||||
r=await client.get('/')
|
||||
elapsed=time.perf_counter()-started
|
||||
responsive=not task.done()
|
||||
result=await task
|
||||
with sqlite3.connect(db.db_path) as c:
|
||||
rows=c.execute('SELECT count(*) FROM heart_rate_samples').fetchone()[0]
|
||||
integrity=c.execute('PRAGMA quick_check').fetchone()[0]
|
||||
report={'linux_http_status_during_disk_wait':r.status_code,'request_returned_while_sync_active':responsive,'local_request_ms':round(elapsed*1000,1),'synthetic_records_stored':rows,'sync_status':result['status'],'database_quick_check':integrity}
|
||||
print(json.dumps(report))
|
||||
assert r.status_code==200 and responsive and rows==40 and integrity=='ok'
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,53 @@
|
||||
"""容器内执行的真实链路检查,只输出验收布尔值与任务状态。"""
|
||||
import gzip
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
sys.path.insert(0, '/opt/health')
|
||||
import runtime
|
||||
|
||||
db_path = Path('/state/data/mi_fitness.db')
|
||||
key = runtime.load_single_key(db_path)
|
||||
mode = sys.argv[1]
|
||||
with httpx.Client(base_url='http://127.0.0.1:8321',headers={'X-API-Key':key},trust_env=False,timeout=25) as client:
|
||||
if mode == 'start':
|
||||
r=client.post('/api/sync',json={'start_date':'2026-10-15','end_date':'2026-10-16','background':True})
|
||||
r.raise_for_status()
|
||||
Path('/state/linux-check-task.json').write_text(json.dumps(r.json()),encoding='utf-8')
|
||||
print(json.dumps({'status':r.json()['status']}))
|
||||
elif mode == 'poll':
|
||||
task=json.loads(Path('/state/linux-check-task.json').read_text(encoding='utf-8'))
|
||||
r=client.get('/api/sync/'+task['sync_id'])
|
||||
r.raise_for_status()
|
||||
result=r.json()
|
||||
print(json.dumps({'status':result['status'],'types':[{'type':t['data_type'],'status':t['status']} for t in result.get('results',[])]}))
|
||||
elif mode == 'verify':
|
||||
params={'start_date':'2026-10-15','end_date':'2026-10-16'}
|
||||
summaries=client.get('/api/summary',params=params); summaries.raise_for_status()
|
||||
yesterday=next(d for d in summaries.json()['data'] if d['date']=='2026-10-15')
|
||||
sleep=client.get('/api/sleep',params=params);sleep.raise_for_status()
|
||||
sessions=sleep.json()['data']
|
||||
weights=client.get('/api/body-measurements',params=params);weights.raise_for_status()
|
||||
print(json.dumps({'daily_matches_phone':yesterday['steps']==4691 and yesterday['active_kcal']==596,
|
||||
'sleep_matches_phone':len(sessions)==2 and sorted(s['duration_minutes'] for s in sessions)==[474,517],
|
||||
'weight_matches_phone':any(w.get('weight_kg')==87.3 for w in weights.json()['data'])}))
|
||||
raw_count=0
|
||||
paths=set()
|
||||
for f in Path('/state/raw').glob('*.jsonl.gz'):
|
||||
with gzip.open(f,'rt',encoding='utf-8') as lines:
|
||||
for line in lines:
|
||||
entry=json.loads(line);raw_count+=1;paths.add(entry['path'])
|
||||
backup=runtime.backup_database(db_path,Path('/state/backups'))
|
||||
with sqlite3.connect(backup) as c:
|
||||
clean=c.execute('SELECT count(*) FROM api_keys').fetchone()[0]==0
|
||||
good=c.execute('PRAGMA quick_check').fetchone()[0]=='ok'
|
||||
rows=c.execute('SELECT count(*) FROM daily_activity').fetchone()[0]
|
||||
with sqlite3.connect(db_path) as c:
|
||||
plaintext=c.execute("SELECT count(*) FROM api_keys WHERE pass_token!=''").fetchone()[0]
|
||||
print(json.dumps({'raw_health_responses_archived':raw_count,'only_allowed_health_paths':paths<=runtime.HEALTH_PATHS,'backup_quick_check_ok':good,'backup_excludes_api_credentials':clean,'backup_daily_rows':rows,'plaintext_token_rows':plaintext}))
|
||||
else:
|
||||
raise SystemExit('未知模式')
|
||||
@@ -0,0 +1,34 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
|
||||
async def main():
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
data = root / 'work/local-production-validation/state/data'
|
||||
parameters = StdioServerParameters(
|
||||
command=r'C:/Users/35861/AppData/Local/Programs/DockerDesktop/resources/bin/docker.exe',
|
||||
args=['--context','desktop-linux','run','--rm','-i','--network','none','--read-only',
|
||||
'--mount',f'type=bind,source={data},target=/health,readonly',
|
||||
'--entrypoint','python','local/mi-fitness-nas:20261016','/opt/health/readonly.py'])
|
||||
async with stdio_client(parameters) as (read, write):
|
||||
async with ClientSession(read,write) as session:
|
||||
await session.initialize()
|
||||
tools = await session.list_tools()
|
||||
result = await session.call_tool('get_health_records',arguments={'kind':'daily_activity','start_date':'2026-10-15','end_date':'2026-10-15'})
|
||||
payload = result.structuredContent or json.loads(result.content[0].text)
|
||||
denied = await session.call_tool('get_health_records',arguments={'kind':'api_keys','start_date':'2026-10-15','end_date':'2026-10-15'})
|
||||
report = {'mcp_handshake':True,'tools':[t.name for t in tools.tools],
|
||||
'read_only_annotation':all(t.annotations.readOnlyHint for t in tools.tools),
|
||||
'real_daily_matches_phone':not result.isError and payload['data'][0]['steps']==4691 and payload['data'][0]['active_kcal']==596,
|
||||
'credential_query_denied':bool(denied.isError),'container_network':'none','database_mount':'read_only'}
|
||||
print(json.dumps(report))
|
||||
(root/'work/deployment-tests/mcp-proof.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
if report['tools']!=['get_health_records'] or not report['real_daily_matches_phone'] or not denied.isError:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,21 @@
|
||||
// 只读接口响应 -> 实际已部署脚本 -> 卡片数值,不读取浏览器凭据。
|
||||
const fs=require('node:fs');
|
||||
const vm=require('node:vm');
|
||||
const payload=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
const nodes=new Map();
|
||||
const get=id=>{
|
||||
if(!nodes.has(id)) nodes.set(id,{textContent:'--',innerHTML:'',value:'2026-10-16'});
|
||||
return nodes.get(id);
|
||||
};
|
||||
const context=vm.createContext({document:{getElementById:get,querySelectorAll(){return [];},addEventListener(){}},localStorage:{getItem(){return '';},setItem(){}},responses:payload.responses,setTimeout(){},clearTimeout(){},setInterval(){},clearInterval(){},console});
|
||||
vm.runInContext(payload.javascript,context);
|
||||
vm.runInContext("showToast=()=>{}; api=async(method,path)=>responses[path.split('?')[0]];",context);
|
||||
(async()=>{
|
||||
await vm.runInContext('loadOverview()',context);
|
||||
const cards=Object.fromEntries(['statSteps','statCalories','statDistance','statHr','statSleep','statSpo2','statStress'].map(id=>[id,get(id).textContent]));
|
||||
const source=payload.responses['/api/summary'].data;
|
||||
const known=source.find(row=>row.date==='2026-10-15');
|
||||
const report={deployed_script_populates_cards:cards.statSteps!=='--'&&cards.statCalories!=='--'&&cards.statSleep!=='--',phone_confirmed_day_unchanged:known?.steps===4691&&known?.active_kcal===596,cards};
|
||||
console.log(JSON.stringify(report));
|
||||
if(!report.deployed_script_populates_cards||!report.phone_confirmed_day_unchanged)process.exitCode=1;
|
||||
})().catch(()=>{console.log(JSON.stringify({card_inspection_error:true}));process.exitCode=1;});
|
||||
@@ -0,0 +1,56 @@
|
||||
"""仅本机 Linux 容器验收,使用临时虚构凭据,不连接 NAS。"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
docker='C:/Users/35861/AppData/Local/Programs/DockerDesktop/resources/bin/docker.exe'
|
||||
prefix=[docker,'--context','desktop-linux']
|
||||
image='local/mi-fitness-nas:20261017-ui4'
|
||||
name='mi-fitness-ui4-local-check'
|
||||
env={**os.environ,'MI_FITNESS_WEB_PASSWORD':'Local-UI4-Validation-Only','MI_FITNESS_ADMIN_KEY':'local-validation-admin-key'}
|
||||
def run(args,stdin=None):
|
||||
p=subprocess.run(prefix+args,input=stdin.encode('utf-8') if stdin is not None else None,capture_output=True,env=env)
|
||||
if p.returncode:raise RuntimeError(p.stderr.decode('utf-8')[-1800:])
|
||||
return p.stdout.decode('utf-8')
|
||||
|
||||
check=r'''
|
||||
import os,json,time,base64,urllib.request,urllib.error
|
||||
token=base64.b64encode(('admin:'+os.environ['MI_FITNESS_WEB_PASSWORD']).encode()).decode()
|
||||
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
def get(path,auth=True):
|
||||
r=urllib.request.Request('http://127.0.0.1:8322'+path,headers={'Authorization':'Basic '+token} if auth else {})
|
||||
with opener.open(r,timeout=3) as response:return response.status,response.read().decode('utf-8')
|
||||
for i in range(30):
|
||||
try:
|
||||
status,html=get('/');break
|
||||
except OSError:time.sleep(.5)
|
||||
else:raise RuntimeError('网页容器未就绪')
|
||||
assert status==200 and '20261017-ui4' in html
|
||||
for kind in ['activity','heartrate','sleep','workouts','body','spo2','stress','abnormal']:
|
||||
assert 'id="healthView-'+kind+'"' in html
|
||||
assets={}
|
||||
for path,marker in [('/static/js/health-views.js','HealthViews'),('/static/js/health-ui.js','loadHealthView'),('/static/css/health.css','health-cards'),('/static/js/app.js','reloadActiveView')]:
|
||||
status,text=get(path);assert status==200 and marker in text;assets[path]=len(text)
|
||||
try:get('/',False)
|
||||
except urllib.error.HTTPError as e:assert e.code==401
|
||||
else:raise RuntimeError('匿名访问未被拦截')
|
||||
assert 'inspector-collapsed' in html and 'id="inspectorPanel" hidden' in html
|
||||
print(json.dumps({'authenticated_http':200,'anonymous_http':401,'query_views':8,'console_default_collapsed':True,'assets':assets}))
|
||||
'''
|
||||
try:
|
||||
run(['run','-d','--name',name,'--network','none','--read-only','--tmpfs','/tmp:rw,noexec,nosuid,size=32m','--cap-drop','ALL','--security-opt','no-new-privileges:true','--memory','256m','--cpus','0.5','-e','MI_FITNESS_WEB_PASSWORD','-e','MI_FITNESS_ADMIN_KEY',image,'web'])
|
||||
result=json.loads(run(['exec','-i',name,'python','-'],check))
|
||||
config=json.loads(run(['inspect',name]))[0]
|
||||
assert config['HostConfig']['ReadonlyRootfs'] and config['HostConfig']['NetworkMode']=='none'
|
||||
assert not config['HostConfig']['PortBindings']
|
||||
result['network']='none';result['host_ports']=[]
|
||||
run(['exec','-i',name,'sh','-n'],Path('outputs/health-query-ui4/update-web.sh').read_text(encoding='utf-8'))
|
||||
result['installer_shell_syntax']='ok'
|
||||
result['image_id']=json.loads(run(['image','inspect',image]))[0]['Id']
|
||||
Path('outputs/health-query-ui4/local-container-check.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
||||
print(json.dumps(result,ensure_ascii=False))
|
||||
finally:
|
||||
# 只删除本次创建、含虚构配置且无持久卷的验收容器。
|
||||
subprocess.run(prefix+['rm','-f',name],capture_output=True)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""仅本机 Linux 容器验收,使用临时虚构凭据,不连接 NAS。"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
docker='C:/Users/35861/AppData/Local/Programs/DockerDesktop/resources/bin/docker.exe'
|
||||
prefix=[docker,'--context','desktop-linux']
|
||||
image='local/mi-fitness-nas:20261017-ui5'
|
||||
name='mi-fitness-ui5-local-check'
|
||||
env={**os.environ,'MI_FITNESS_WEB_PASSWORD':'Local-UI4-Validation-Only','MI_FITNESS_ADMIN_KEY':'local-validation-admin-key'}
|
||||
def run(args,stdin=None):
|
||||
p=subprocess.run(prefix+args,input=stdin.encode('utf-8') if stdin is not None else None,capture_output=True,env=env)
|
||||
if p.returncode:raise RuntimeError(p.stderr.decode('utf-8')[-1800:])
|
||||
return p.stdout.decode('utf-8')
|
||||
|
||||
check=r'''
|
||||
import os,json,time,base64,urllib.request,urllib.error
|
||||
token=base64.b64encode(('admin:'+os.environ['MI_FITNESS_WEB_PASSWORD']).encode()).decode()
|
||||
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
def get(path,auth=True):
|
||||
r=urllib.request.Request('http://127.0.0.1:8322'+path,headers={'Authorization':'Basic '+token} if auth else {})
|
||||
with opener.open(r,timeout=3) as response:return response.status,response.read().decode('utf-8')
|
||||
for i in range(30):
|
||||
try:
|
||||
status,html=get('/');break
|
||||
except OSError:time.sleep(.5)
|
||||
else:raise RuntimeError('网页容器未就绪')
|
||||
assert status==200 and '20261017-ui5' in html
|
||||
for kind in ['activity','heartrate','sleep','workouts','body','spo2','stress','abnormal']:
|
||||
assert 'id="healthView-'+kind+'"' in html
|
||||
assets={}
|
||||
for path,marker in [('/static/js/health-views.js','HealthViews'),('/static/js/health-ui.js','loadHealthView'),('/static/css/health.css','health-cards'),('/static/js/app.js','reloadActiveView')]:
|
||||
status,text=get(path);assert status==200 and marker in text;assets[path]=len(text)
|
||||
try:get('/',False)
|
||||
except urllib.error.HTTPError as e:assert e.code==401
|
||||
else:raise RuntimeError('匿名访问未被拦截')
|
||||
assert 'inspector-collapsed' in html and 'id="inspectorPanel" hidden' in html
|
||||
print(json.dumps({'authenticated_http':200,'anonymous_http':401,'query_views':8,'console_default_collapsed':True,'assets':assets}))
|
||||
'''
|
||||
try:
|
||||
run(['run','-d','--name',name,'--network','none','--read-only','--tmpfs','/tmp:rw,noexec,nosuid,size=32m','--cap-drop','ALL','--security-opt','no-new-privileges:true','--memory','256m','--cpus','0.5','-e','MI_FITNESS_WEB_PASSWORD','-e','MI_FITNESS_ADMIN_KEY',image,'web'])
|
||||
result=json.loads(run(['exec','-i',name,'python','-'],check))
|
||||
config=json.loads(run(['inspect',name]))[0]
|
||||
assert config['HostConfig']['ReadonlyRootfs'] and config['HostConfig']['NetworkMode']=='none'
|
||||
assert not config['HostConfig']['PortBindings']
|
||||
result['network']='none';result['host_ports']=[]
|
||||
run(['exec','-i',name,'sh','-n'],Path('outputs/health-reference-ui5/update-web.sh').read_text(encoding='utf-8'))
|
||||
result['installer_shell_syntax']='ok'
|
||||
result['image_id']=json.loads(run(['image','inspect',image]))[0]['Id']
|
||||
Path('outputs/health-reference-ui5/local-container-check.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
||||
print(json.dumps(result,ensure_ascii=False))
|
||||
finally:
|
||||
# 只删除本次创建、含虚构配置且无持久卷的验收容器。
|
||||
subprocess.run(prefix+['rm','-f',name],capture_output=True)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""仅本机 Linux 容器验收,使用临时虚构凭据,不连接 NAS。"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
docker='C:/Users/35861/AppData/Local/Programs/DockerDesktop/resources/bin/docker.exe'
|
||||
prefix=[docker,'--context','desktop-linux']
|
||||
image='local/mi-fitness-nas:20261017-ui6'
|
||||
name='mi-fitness-ui6-local-check'
|
||||
env={**os.environ,'MI_FITNESS_WEB_PASSWORD':'Local-UI4-Validation-Only','MI_FITNESS_ADMIN_KEY':'local-validation-admin-key'}
|
||||
def run(args,stdin=None):
|
||||
p=subprocess.run(prefix+args,input=stdin.encode('utf-8') if stdin is not None else None,capture_output=True,env=env)
|
||||
if p.returncode:raise RuntimeError(p.stderr.decode('utf-8')[-1800:])
|
||||
return p.stdout.decode('utf-8')
|
||||
|
||||
check=r'''
|
||||
import os,json,time,base64,urllib.request,urllib.error
|
||||
token=base64.b64encode(('admin:'+os.environ['MI_FITNESS_WEB_PASSWORD']).encode()).decode()
|
||||
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
def get(path,auth=True):
|
||||
r=urllib.request.Request('http://127.0.0.1:8322'+path,headers={'Authorization':'Basic '+token} if auth else {})
|
||||
with opener.open(r,timeout=3) as response:return response.status,response.read().decode('utf-8')
|
||||
for i in range(30):
|
||||
try:
|
||||
status,html=get('/');break
|
||||
except OSError:time.sleep(.5)
|
||||
else:raise RuntimeError('网页容器未就绪')
|
||||
assert status==200 and '20261017-ui6' in html
|
||||
for kind in ['activity','heartrate','sleep','workouts','body','spo2','stress','abnormal']:
|
||||
assert 'id="healthView-'+kind+'"' in html
|
||||
assets={}
|
||||
for path,marker in [('/static/js/health-views.js','HealthViews'),('/static/js/health-ui.js','loadHealthView'),('/static/css/health.css','health-cards'),('/static/js/app.js','reloadActiveView')]:
|
||||
status,text=get(path);assert status==200 and marker in text;assets[path]=len(text)
|
||||
try:get('/',False)
|
||||
except urllib.error.HTTPError as e:assert e.code==401
|
||||
else:raise RuntimeError('匿名访问未被拦截')
|
||||
assert 'inspector-collapsed' in html and 'id="inspectorPanel" hidden' in html
|
||||
print(json.dumps({'authenticated_http':200,'anonymous_http':401,'query_views':8,'console_default_collapsed':True,'assets':assets}))
|
||||
'''
|
||||
try:
|
||||
run(['run','-d','--name',name,'--network','none','--read-only','--tmpfs','/tmp:rw,noexec,nosuid,size=32m','--cap-drop','ALL','--security-opt','no-new-privileges:true','--memory','256m','--cpus','0.5','-e','MI_FITNESS_WEB_PASSWORD','-e','MI_FITNESS_ADMIN_KEY',image,'web'])
|
||||
result=json.loads(run(['exec','-i',name,'python','-'],check))
|
||||
config=json.loads(run(['inspect',name]))[0]
|
||||
assert config['HostConfig']['ReadonlyRootfs'] and config['HostConfig']['NetworkMode']=='none'
|
||||
assert not config['HostConfig']['PortBindings']
|
||||
result['network']='none';result['host_ports']=[]
|
||||
run(['exec','-i',name,'sh','-n'],Path('outputs/health-axis-ui6/update-web.sh').read_text(encoding='utf-8'))
|
||||
result['installer_shell_syntax']='ok'
|
||||
result['image_id']=json.loads(run(['image','inspect',image]))[0]['Id']
|
||||
Path('outputs/health-axis-ui6/local-container-check.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
||||
print(json.dumps(result,ensure_ascii=False))
|
||||
finally:
|
||||
# 只删除本次创建、含虚构配置且无持久卷的验收容器。
|
||||
subprocess.run(prefix+['rm','-f',name],capture_output=True)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""仅本机 Linux 容器验收,使用临时虚构凭据,不连接 NAS。"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
docker='C:/Users/35861/AppData/Local/Programs/DockerDesktop/resources/bin/docker.exe'
|
||||
prefix=[docker,'--context','desktop-linux']
|
||||
image='local/mi-fitness-nas:20261017-ui7'
|
||||
name='mi-fitness-ui7-local-check'
|
||||
env={**os.environ,'MI_FITNESS_WEB_PASSWORD':'Local-UI4-Validation-Only','MI_FITNESS_ADMIN_KEY':'local-validation-admin-key'}
|
||||
def run(args,stdin=None):
|
||||
p=subprocess.run(prefix+args,input=stdin.encode('utf-8') if stdin is not None else None,capture_output=True,env=env)
|
||||
if p.returncode:raise RuntimeError(p.stderr.decode('utf-8')[-1800:])
|
||||
return p.stdout.decode('utf-8')
|
||||
|
||||
check=r'''
|
||||
import os,json,time,base64,urllib.request,urllib.error
|
||||
token=base64.b64encode(('admin:'+os.environ['MI_FITNESS_WEB_PASSWORD']).encode()).decode()
|
||||
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
def get(path,auth=True):
|
||||
r=urllib.request.Request('http://127.0.0.1:8322'+path,headers={'Authorization':'Basic '+token} if auth else {})
|
||||
with opener.open(r,timeout=3) as response:return response.status,response.read().decode('utf-8')
|
||||
for i in range(30):
|
||||
try:
|
||||
status,html=get('/');break
|
||||
except OSError:time.sleep(.5)
|
||||
else:raise RuntimeError('网页容器未就绪')
|
||||
assert status==200 and '20261017-ui7' in html
|
||||
calendar=json.loads(get('/api/calendar?start=2026&end=2027')[1])
|
||||
assert '2026' in calendar['years'] and '2027' not in calendar['years']
|
||||
for kind in ['activity','heartrate','sleep','workouts','body','spo2','stress','abnormal']:
|
||||
assert 'id="healthView-'+kind+'"' in html
|
||||
assets={}
|
||||
for path,marker in [('/static/js/health-views.js','HealthViews'),('/static/js/health-ui.js','loadHealthView'),('/static/css/health.css','health-cards'),('/static/js/app.js','reloadActiveView')]:
|
||||
status,text=get(path);assert status==200 and marker in text;assets[path]=len(text)
|
||||
try:get('/',False)
|
||||
except urllib.error.HTTPError as e:assert e.code==401
|
||||
else:raise RuntimeError('匿名访问未被拦截')
|
||||
assert 'inspector-collapsed' in html and 'id="inspectorPanel" hidden' in html
|
||||
print(json.dumps({'authenticated_http':200,'anonymous_http':401,'query_views':8,'console_default_collapsed':True,'assets':assets}))
|
||||
'''
|
||||
try:
|
||||
run(['run','-d','--name',name,'--network','none','--read-only','--tmpfs','/tmp:rw,noexec,nosuid,size=32m','--cap-drop','ALL','--security-opt','no-new-privileges:true','--memory','256m','--cpus','0.5','-e','MI_FITNESS_WEB_PASSWORD','-e','MI_FITNESS_ADMIN_KEY',image,'web'])
|
||||
result=json.loads(run(['exec','-i',name,'python','-'],check))
|
||||
config=json.loads(run(['inspect',name]))[0]
|
||||
assert config['HostConfig']['ReadonlyRootfs'] and config['HostConfig']['NetworkMode']=='none'
|
||||
assert not config['HostConfig']['PortBindings']
|
||||
result['network']='none';result['host_ports']=[]
|
||||
run(['exec','-i',name,'sh','-n'],Path('outputs/health-calendar-ui7/update-web.sh').read_text(encoding='utf-8'))
|
||||
result['installer_shell_syntax']='ok'
|
||||
result['image_id']=json.loads(run(['image','inspect',image]))[0]['Id']
|
||||
Path('outputs/health-calendar-ui7/local-container-check.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
||||
print(json.dumps(result,ensure_ascii=False))
|
||||
finally:
|
||||
# 只删除本次创建、含虚构配置且无持久卷的验收容器。
|
||||
subprocess.run(prefix+['rm','-f',name],capture_output=True)
|
||||
@@ -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('work/mi-fitness-mcp-cn/src/mi_fitness_mcp/web_assets/static/js/health-ui.js','utf8'),context);
|
||||
vm.runInContext(fs.readFileSync('work/mi-fitness-mcp-cn/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('work/mi-fitness-mcp-cn/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('work/mi-fitness-mcp-cn/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('../mi-fitness-mcp-cn/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('../mi-fitness-mcp-cn/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('../mi-fitness-mcp-cn/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('work/mi-fitness-mcp-cn/src/mi_fitness_mcp/web_assets/static/js/health-ui.js','utf8'),c);
|
||||
vm.runInContext(fs.readFileSync('work/mi-fitness-mcp-cn/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('../mi-fitness-mcp-cn/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='work/mi-fitness-mcp-cn/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,48 @@
|
||||
"""同一台本机内把已授权凭据从 Windows 密钥环传入加密容器;从不输出凭据。"""
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(root / 'work/mi-fitness-mcp-cn/src'))
|
||||
from mi_fitness_mcp.security import load_api_key_secret
|
||||
|
||||
with sqlite3.connect(root / 'work/local-validation/state/mi_fitness.db') as conn:
|
||||
rows = conn.execute('SELECT key,user_id,region,pass_token FROM api_keys WHERE revoked=0').fetchall()
|
||||
if len(rows) != 1 or rows[0][3]:
|
||||
raise SystemExit('本机授权状态不符合预期,停止')
|
||||
key, user, region, _ = rows[0]
|
||||
token = load_api_key_secret(key)
|
||||
if not token:
|
||||
raise SystemExit('无法读取本机已授权凭据,停止')
|
||||
code = r'''
|
||||
import sys,json,sqlite3
|
||||
sys.path.insert(0,'/opt/health')
|
||||
import runtime
|
||||
runtime.setup_credentials()
|
||||
from mi_fitness_mcp.api import _create_key_record
|
||||
from mi_fitness_mcp.storage import Database
|
||||
from mi_fitness_mcp.security import load_api_key_secret
|
||||
data=json.load(sys.stdin)
|
||||
db=Database('/state/data/mi_fitness.db')
|
||||
with db._get_connection() as c:
|
||||
if c.execute('SELECT count(*) FROM api_keys WHERE revoked=0').fetchone()[0]:
|
||||
raise SystemExit('目标不是空授权库,停止')
|
||||
new_key=_create_key_record(db,data['user_id'],data['pass_token'],data['region'],'local-linux-verification')
|
||||
with db._get_connection() as c:
|
||||
clear=c.execute("SELECT count(*) FROM api_keys WHERE pass_token!=''").fetchone()[0]
|
||||
encrypted=load_api_key_secret(new_key)==data['pass_token']
|
||||
not_plain=data['pass_token'] not in open('/state/credentials/encrypted-keyring.cfg',encoding='utf-8').read()
|
||||
print(json.dumps({'active_authorizations':1,'plaintext_account_token_rows':clear,'encrypted_keyring_round_trip':encrypted,'token_absent_from_keyring_plaintext':not_plain}))
|
||||
if clear or not encrypted or not not_plain: raise SystemExit(1)
|
||||
'''
|
||||
docker = r'C:/Users/35861/AppData/Local/Programs/DockerDesktop/resources/bin/docker.exe'
|
||||
result = subprocess.run([docker,'--context','desktop-linux','exec','-i','mi-fitness-production-local-backend-1','python','-c',code],input=json.dumps({'user_id':user,'pass_token':token,'region':region}),capture_output=True,text=True,encoding='utf-8',timeout=60)
|
||||
if result.returncode:
|
||||
# 错误输出可能包含第三方异常上下文,因此不直接转发。
|
||||
raise SystemExit('容器授权导入未通过,错误内容留在进程内,未输出凭据')
|
||||
report = json.loads(result.stdout)
|
||||
print(json.dumps(report))
|
||||
(root / 'work/deployment-tests/linux-credential-proof.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
@@ -0,0 +1,28 @@
|
||||
"""创建本机专用合成数据库;运行中不调用云端接口。"""
|
||||
import sqlite3
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / 'work/mi-fitness-mcp-cn/src'))
|
||||
from mi_fitness_mcp.models import DailyActivity
|
||||
from mi_fitness_mcp.storage import Database
|
||||
|
||||
target = ROOT / 'work/audit2-container-validation'
|
||||
target.mkdir(exist_ok=True)
|
||||
(target / 'state/data').mkdir(parents=True, exist_ok=True)
|
||||
(target / 'state/credentials').mkdir(parents=True, exist_ok=True)
|
||||
(target / 'state/config/mi-fitness-mcp').mkdir(parents=True, exist_ok=True)
|
||||
(target / 'state/config/mi-fitness-mcp/config.json').write_text(json.dumps({
|
||||
'mode': 'mi_fitness_cloud', 'region': 'cn', 'timezone': 'Asia/Shanghai',
|
||||
'database_path': '/state/data/mi_fitness.db', 'auto_sync_on_start': False}), encoding='utf-8')
|
||||
(target / 'keyring_master').write_text('synthetic-only-keyring-unlock-value-20261017', encoding='utf-8')
|
||||
db = Database(target / 'state/data/mi_fitness.db')
|
||||
db.insert_daily_activity(DailyActivity(id='synthetic-day', provider='mi_fitness', source_type='cloud_session',
|
||||
user_id='synthetic-owner', date='2026-10-15', steps=4691, active_kcal=596, distance_m=2979))
|
||||
with db._get_connection() as conn:
|
||||
conn.execute('CREATE TABLE IF NOT EXISTS api_keys (key TEXT PRIMARY KEY,label TEXT,user_id TEXT NOT NULL,pass_token TEXT NOT NULL,region TEXT NOT NULL DEFAULT \'cn\',created_at TEXT NOT NULL,last_used_at TEXT,revoked INTEGER NOT NULL DEFAULT 0)')
|
||||
conn.execute("INSERT OR IGNORE INTO api_keys(key,label,user_id,pass_token,region,created_at) VALUES ('mif_sk_syntheticlocal','synthetic only','synthetic-owner','synthetic-token','cn','2026-10-17')")
|
||||
conn.commit()
|
||||
print('Synthetic container fixture ready; no real health records or credentials used.')
|
||||
@@ -0,0 +1,95 @@
|
||||
"""第二轮补充审查复现;仅本机临时 SQLite 与合成账号,不访问网络。"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / 'work/mi-fitness-mcp-cn/src'))
|
||||
from mi_fitness_mcp.adapters.mi_fitness_cloud import MiFitnessCloudAdapter
|
||||
from mi_fitness_mcp.models import StressSample, Workout
|
||||
from mi_fitness_mcp.services.query_service import QueryService
|
||||
from mi_fitness_mcp.services.sync_service import SyncService
|
||||
from mi_fitness_mcp.storage import Database
|
||||
|
||||
CN = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
async def daily_for(owner, steps):
|
||||
adapter = MiFitnessCloudAdapter(user_id=owner, pass_token='synthetic', region='cn')
|
||||
adapter._connected = True
|
||||
adapter._client = object()
|
||||
async def reports(key, start, end):
|
||||
value = {'steps': steps, 'distance': steps} if key == 'steps' else {'calories': 100}
|
||||
return [{'time': int(datetime(2026, 10, 16, tzinfo=CN).timestamp()), 'value': value}]
|
||||
adapter._fetch_daily_reports = reports
|
||||
return [row async for row in adapter.iter_daily_activity('2026-10-16', '2026-10-16')][0]
|
||||
|
||||
|
||||
async def check(directory):
|
||||
db = Database(directory / 'synthetic.sqlite')
|
||||
first = await daily_for('synthetic-A', 1111)
|
||||
second = await daily_for('synthetic-B', 2222)
|
||||
db.insert_daily_activity(first)
|
||||
db.insert_daily_activity(second)
|
||||
a = QueryService(db, 'synthetic-A').get_daily_summaries('2026-10-16', '2026-10-16')
|
||||
b = QueryService(db, 'synthetic-B').get_daily_summaries('2026-10-16', '2026-10-16')
|
||||
assert first.id == second.id
|
||||
assert a[0]['steps'] == 2222 and b == []
|
||||
|
||||
db.update_sync_state('heart_rate', datetime(2026, 10, 16, 9, tzinfo=CN))
|
||||
adapter = MiFitnessCloudAdapter(user_id='synthetic-A', pass_token='synthetic', region='cn')
|
||||
adapter._connected = True
|
||||
adapter._client = object()
|
||||
try:
|
||||
await SyncService(adapter, db).sync_data_type('heart_rate', end_date='2026-10-16')
|
||||
except ValueError as exc:
|
||||
assert str(exc) == 'start_date must not be after end_date'
|
||||
sync_error = str(exc)
|
||||
else:
|
||||
raise AssertionError('Expected same-day incremental rejection was not reproduced')
|
||||
|
||||
db.update_sync_state('daily_activity', datetime(2026, 10, 15, tzinfo=CN))
|
||||
seen_ranges = []
|
||||
class SecondAccountAdapter:
|
||||
user_id = 'synthetic-B'
|
||||
def is_connected(self): return True
|
||||
async def iter_daily_activity(self, start, end):
|
||||
seen_ranges.append((start, end))
|
||||
if False:
|
||||
yield None
|
||||
await SyncService(SecondAccountAdapter(), db).sync_data_type('daily_activity', end_date='2026-10-16')
|
||||
assert seen_ranges[0][0] == '2026-10-15'
|
||||
|
||||
for index, hour in enumerate((1, 9)):
|
||||
db.insert_stress_sample(StressSample(id=f'synthetic-stress-{index}', provider='mi_fitness',
|
||||
source_type='cloud_session', user_id='synthetic-A', timestamp=datetime(2026, 10, 16, hour, tzinfo=CN),
|
||||
stress_score=30, level='medium'))
|
||||
service = QueryService(db, 'synthetic-A')
|
||||
samples = service.get_stress_samples('2026-10-16', '2026-10-16')
|
||||
coverage = next(row for row in service.get_data_coverage() if row['data_type'] == 'stress')
|
||||
assert len(samples) == 2
|
||||
assert coverage['first_date'] == '2026-10-15' and coverage['days_with_data'] == 2
|
||||
|
||||
exercise = Workout(id='synthetic-workout', workout_id='synthetic-workout', provider='mi_fitness',
|
||||
source_type='cloud_session', user_id='synthetic-A', activity_type='running',
|
||||
start_at=datetime(2026, 10, 16, 9, tzinfo=CN), end_at=datetime(2026, 10, 16, 9, 30, tzinfo=CN),
|
||||
duration_minutes=30)
|
||||
db.insert_workout(exercise)
|
||||
db.insert_workout(exercise.model_copy(update={'total_steps': 4000, 'avg_pace_sec_per_km': 300}))
|
||||
updated = service.get_workouts('2026-10-16', '2026-10-16')[0]
|
||||
assert updated['total_steps'] is None and updated['avg_pace_sec_per_km'] is None
|
||||
return {
|
||||
'cross_account_collision': {'owner_A_steps_after_owner_B_sync': a[0]['steps'], 'owner_B_records': len(b)},
|
||||
'same_day_incremental_sync': sync_error,
|
||||
'shared_cursor': {'new_account_first_sync_start': seen_ranges[0][0], 'expected_without_another_account_cursor': '2026-09-17'},
|
||||
'coverage_timezone_mismatch': {'requested_day_records': len(samples), 'first_date': coverage['first_date'], 'days_with_data': coverage['days_with_data']},
|
||||
'workout_correction_lost': {'supplied_steps': 4000, 'stored_steps': updated['total_steps'], 'supplied_pace': 300, 'stored_pace': updated['avg_pace_sec_per_km']},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
with tempfile.TemporaryDirectory(prefix='health-edge-review-') as temporary:
|
||||
print(json.dumps(asyncio.run(check(Path(temporary))), indent=2))
|
||||
@@ -0,0 +1,55 @@
|
||||
"""本机隔离复现审查发现;仅使用合成数据,不连接 NAS 或小米云。"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / 'work/mi-fitness-mcp-cn/src'))
|
||||
from mi_fitness_mcp.adapters.mi_fitness_cloud import MiFitnessCloudAdapter
|
||||
from mi_fitness_mcp.models import BodyMeasurement
|
||||
from mi_fitness_mcp.storage import Database
|
||||
from mi_fitness_mcp.web import create_app
|
||||
|
||||
|
||||
async def pagination():
|
||||
adapter = MiFitnessCloudAdapter(user_id='synthetic', pass_token='synthetic', region='cn')
|
||||
async def request(*args):
|
||||
return {'data_list': [{'time': 123}], 'has_more': True}
|
||||
adapter._request = request
|
||||
rows = await adapter._fetch_key('heart_rate', '2026-10-15', '2026-10-16')
|
||||
return {'has_more_without_cursor_silently_accepted': len(rows) == 1}
|
||||
|
||||
|
||||
def corrections():
|
||||
with tempfile.TemporaryDirectory(prefix='health-review-') as directory:
|
||||
db = Database(Path(directory) / 'synthetic.sqlite')
|
||||
first = BodyMeasurement(id='synthetic-weight', provider='mi_fitness',
|
||||
source_type='cloud_session', user_id='synthetic', timestamp=datetime.now(timezone.utc),
|
||||
weight_kg=87.3, bone_mass_kg=None, basal_metabolism_kcal=None)
|
||||
db.insert_body_measurement(first)
|
||||
db.insert_body_measurement(first.model_copy(update={'bone_mass_kg': 3.1, 'basal_metabolism_kcal': 1800}))
|
||||
record = db.query_body_measurements('synthetic', '2000-01-01', '2100-01-01')[0]
|
||||
return {'late_body_fields_not_updated': record['bone_mass_kg'] is None and record['basal_metabolism_kcal'] is None}
|
||||
|
||||
|
||||
def proxy_privilege():
|
||||
seen = []
|
||||
class FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
def request(self, **kwargs):
|
||||
seen.append(kwargs)
|
||||
import httpx
|
||||
return httpx.Response(200, json={'status': 'ok'})
|
||||
with patch('mi_fitness_mcp.web.httpx.Client', FakeClient):
|
||||
app = create_app(backend_url='http://backend:8321', admin_key='SYNTHETIC_ADMIN')
|
||||
response = app.test_client().delete('/proxy/api/auth/keys/mif_sk_demo')
|
||||
return {'unauthenticated_browser_request_gets_admin_header': response.status_code == 200 and seen[0]['headers'].get('X-Admin-Key') == 'SYNTHETIC_ADMIN'}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps({**asyncio.run(pagination()), **corrections(), **proxy_privilege()}, indent=2))
|
||||
@@ -0,0 +1,40 @@
|
||||
"""仅本机界面核验:使用固定示例响应,不连接 NAS 或小米云。"""
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root=Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0,str(root/'work/mi-fitness-mcp-cn/src'))
|
||||
from flask import jsonify, request
|
||||
from mi_fitness_mcp.web import create_app
|
||||
|
||||
logging.getLogger('werkzeug').disabled=True
|
||||
app=create_app(backend_url='http://127.0.0.1:1')
|
||||
fixtures={
|
||||
'summary':[{'date':'2026-10-15','steps':4691,'active_kcal':596,'distance_m':2979},{'date':'2026-10-16','steps':4063,'active_kcal':250,'distance_m':2385}],
|
||||
'heart-rate':[{'timestamp':'2026-10-16T09:00:00+08:00','bpm':62},{'timestamp':'2026-10-15T09:00:00+08:00','bpm':58}],
|
||||
'sleep':[{'end_at':'2026-10-16T08:54:00+08:00','duration_minutes':517},{'end_at':'2026-10-15T07:55:00+08:00','duration_minutes':474}],
|
||||
'spo2':[{'timestamp':'2026-10-16T09:00:00+08:00','spo2_pct':98},{'timestamp':'2026-10-15T09:00:00+08:00','spo2_pct':96}],
|
||||
'stress':[{'timestamp':'2026-10-16T09:00:00+08:00','stress_score':0},{'timestamp':'2026-10-15T09:00:00+08:00','stress_score':60}],
|
||||
}
|
||||
|
||||
@app.before_request
|
||||
def local_fixture():
|
||||
if request.path=='/api/proxy_status':
|
||||
return jsonify({'backend_reachable':True,'backend_latency_ms':1})
|
||||
if request.path.startswith('/proxy/api/'):
|
||||
rows=fixtures.get(request.path.rsplit('/',1)[-1],[])
|
||||
return jsonify({'status':'ok','count':len(rows),'data':rows})
|
||||
|
||||
@app.after_request
|
||||
def exercise_preset(response):
|
||||
if response.mimetype=='text/html' and request.args.get('preset')=='30d':
|
||||
html=response.get_data(as_text=True)
|
||||
clock="<script>const RealTestDate=Date; window.Date=class extends RealTestDate {constructor(...args){super(...(args.length?args:['2026-10-16T12:00:00+08:00']));}static now(){return new RealTestDate('2026-10-16T12:00:00+08:00').getTime();}};</script>"
|
||||
click="<script>document.addEventListener('DOMContentLoaded',()=>setTimeout(()=>document.getElementById('preset-30d').click(),100));</script>"
|
||||
html=html.replace('<script src="/static/js/app.js',clock+'<script src="/static/js/app.js',1)
|
||||
html=html.replace('</body>',click+'</body>')
|
||||
response.set_data(html)
|
||||
return response
|
||||
|
||||
app.run(host='127.0.0.1',port=18362,debug=False,use_reloader=False)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""本机界面验收用合成数据,不连接 NAS 或小米云。"""
|
||||
import datetime as dt
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root=Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0,str(root/'work/mi-fitness-mcp-cn/src'))
|
||||
from flask import jsonify,request
|
||||
from mi_fitness_mcp.web import create_app
|
||||
|
||||
logging.getLogger('werkzeug').disabled=True
|
||||
app=create_app(backend_url='http://127.0.0.1:1')
|
||||
app.config['TEMPLATES_AUTO_RELOAD']=True
|
||||
days=[dt.date(2026,9,18)+dt.timedelta(days=i) for i in range(30)]
|
||||
fixtures={'summary':[],'sleep':[],'heart-rate':[],'spo2':[],'stress':[], 'workouts':[], 'body-measurements':[], 'abnormal-heart-beat':[]}
|
||||
for i,day in enumerate(days):
|
||||
key=day.isoformat()
|
||||
fixtures['summary'].append({'date':key,'steps':3500+i*173%7000,'distance_m':3000+i*101,'active_kcal':300+i*11})
|
||||
if i<29:
|
||||
minutes=390+i*17%160
|
||||
fixtures['sleep'].append({'start_at':f'{day-dt.timedelta(days=1)}T23:10:00+08:00','end_at':key+'T07:20:00+08:00','time_asleep_minutes':minutes,'duration_minutes':minutes,'time_awake_minutes':12,'sleep_score':80+i%9,'is_nap':False,'stages':[{'stage':'deep','minutes':120+i%25},{'stage':'light','minutes':minutes-210-i%25},{'stage':'rem','minutes':90}]})
|
||||
fixtures['heart-rate'].append({'timestamp':key+'T08:00:00+08:00','bpm':55+i%17,'sample_type':'resting'})
|
||||
for j in range(300):
|
||||
time=(dt.datetime.combine(day,dt.time(9))+dt.timedelta(minutes=j)).isoformat()+'+08:00'
|
||||
fixtures['heart-rate'].append({'timestamp':time,'bpm':60+(i+j)%35,'sample_type':'passive'})
|
||||
if j%4==0:fixtures['spo2'].append({'timestamp':time,'spo2_pct':95+j%5})
|
||||
if j%3==0:fixtures['stress'].append({'timestamp':time,'stress_score':j%60,'level':'low' if j%60<30 else 'medium'})
|
||||
fixtures['body-measurements']=[{'timestamp':'2026-10-15T09:33:00+08:00','weight_kg':87.3,'bmi':28.2,'body_fat_pct':26.3,'muscle_mass_kg':69.8,'water_pct':51.7,'bone_mass_kg':3.4}]
|
||||
|
||||
@app.before_request
|
||||
def fixture():
|
||||
if request.path=='/api/proxy_status':return jsonify({'backend_reachable':True,'backend_latency_ms':2})
|
||||
if request.path.startswith('/proxy/api/'):
|
||||
rows=fixtures.get(request.path.rsplit('/',1)[-1],[])
|
||||
lo,hi=request.args.get('start_date',''),request.args.get('end_date','9999')
|
||||
rows=[r for r in rows if lo<=str(r.get('date') or r.get('timestamp') or r.get('end_at') or r.get('start_at'))[:10]<=hi]
|
||||
sample=request.args.get('sample_type')
|
||||
if sample:rows=[r for r in rows if r.get('sample_type')==sample]
|
||||
level=request.args.get('level')
|
||||
if level:rows=[r for r in rows if r.get('level')==level]
|
||||
if request.args.get('latest_only')=='true':rows=rows[-1:]
|
||||
if request.args.get('limit'):rows=rows[:int(request.args['limit'])]
|
||||
return jsonify({'status':'ok','count':len(rows),'data':rows})
|
||||
|
||||
app.run(host='127.0.0.1',port=18364,debug=False,use_reloader=False)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""仅本机浏览器性能复现,固定合成30天记录,无真实账号或云端请求。"""
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / 'work/mi-fitness-mcp-cn/src'))
|
||||
from flask import jsonify, request, send_file
|
||||
from mi_fitness_mcp.web import create_app
|
||||
|
||||
logging.getLogger('werkzeug').disabled = True
|
||||
app = create_app(backend_url='http://127.0.0.1:1')
|
||||
start = datetime(2026, 9, 18, tzinfo=timezone(timedelta(hours=8)))
|
||||
def samples(count, field, value):
|
||||
return [{'timestamp': (start + timedelta(seconds=i*30*86400/count)).isoformat(), field: value} for i in range(count)]
|
||||
fixtures = {
|
||||
'summary': [{'date': (start+timedelta(days=i)).date().isoformat(), 'steps': 6000+i, 'active_kcal': 500+i, 'distance_m': 4000+i} for i in range(30)],
|
||||
'heart-rate': samples(19, 'bpm', 62),
|
||||
'sleep': [{'end_at': (start+timedelta(days=i,hours=8)).isoformat(), 'duration_minutes': 450} for i in range(30)],
|
||||
'spo2': samples(2000, 'spo2_pct', 97),
|
||||
'stress': samples(3000, 'stress_score', 30),
|
||||
}
|
||||
|
||||
@app.before_request
|
||||
def local_fixture():
|
||||
if request.path == '/api/proxy_status':
|
||||
return jsonify({'backend_reachable': True, 'backend_latency_ms': 1})
|
||||
if request.path.startswith('/proxy/api/'):
|
||||
rows = fixtures.get(request.path.rsplit('/',1)[-1], [])
|
||||
sd, ed = request.args.get('start_date'), request.args.get('end_date')
|
||||
if sd and ed:
|
||||
rows = [row for row in rows if sd <= (row.get('date') or row.get('end_at') or row.get('timestamp'))[:10] <= ed]
|
||||
return jsonify({'status':'ok','count':len(rows),'data':rows})
|
||||
|
||||
@app.route('/benchmark/before.js')
|
||||
def baseline_script():
|
||||
return send_file(ROOT/'work/perf-validation/app.before.js', mimetype='application/javascript')
|
||||
|
||||
@app.route('/benchmark/before.css')
|
||||
def baseline_styles():
|
||||
return send_file(ROOT/'work/perf-validation/style.before.css', mimetype='text/css')
|
||||
|
||||
@app.after_request
|
||||
def baseline_version(response):
|
||||
if response.mimetype == 'text/html' and request.args.get('version') == 'before':
|
||||
import re
|
||||
text = re.sub(r'/static/js/app\.js\?v=[^"\s]+', '/benchmark/before.js', response.get_data(as_text=True))
|
||||
response.set_data(re.sub(r'/static/css/style\.css(?:\?v=[^"\s]+)?', '/benchmark/before.css', text))
|
||||
return response
|
||||
|
||||
app.run(host='127.0.0.1', port=18363, debug=False, use_reloader=False)
|
||||
@@ -0,0 +1,97 @@
|
||||
import importlib.util
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0,str(ROOT/'work/mi-fitness-mcp-cn/src'))
|
||||
from mi_fitness_mcp.services.query_service import QueryService
|
||||
|
||||
|
||||
@pytest.mark.parametrize('method',['_aggregate_by_week','_aggregate_by_month'])
|
||||
def test_latest_aggregation_uses_latest_date_instead_of_sum(method):
|
||||
service=QueryService(None,'synthetic')
|
||||
result=getattr(service,method)([{'date':'2026-10-16','value':9},{'date':'2026-10-15','value':2}],'latest')
|
||||
assert result[0]['value']==9
|
||||
|
||||
|
||||
def runtime():
|
||||
spec=importlib.util.spec_from_file_location('audit_runtime',ROOT/'outputs/nas-deployment/runtime.py')
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_repeated_login_for_same_account_keeps_sync_but_multiple_accounts_are_rejected(tmp_path):
|
||||
db=tmp_path/'health.sqlite'
|
||||
with sqlite3.connect(db) as c:
|
||||
c.execute('CREATE TABLE api_keys (key TEXT,user_id TEXT,region TEXT,created_at TEXT,revoked INTEGER)')
|
||||
c.executemany('INSERT INTO api_keys VALUES (?,?,?,?,0)',[('old','owner','cn','2026-10-15'),('new','owner','cn','2026-10-16')])
|
||||
assert runtime().load_single_key(db)=='new'
|
||||
with sqlite3.connect(db) as c:c.execute("INSERT INTO api_keys VALUES ('other','another-owner','cn','2026-10-17',0)")
|
||||
with pytest.raises(RuntimeError):runtime().load_single_key(db)
|
||||
|
||||
|
||||
def test_cached_credential_does_not_repeat_expensive_unlock_or_bypass_revocation(tmp_path,monkeypatch):
|
||||
from mi_fitness_mcp import api
|
||||
from mi_fitness_mcp.storage import Database
|
||||
from starlette.requests import Request
|
||||
|
||||
db=Database(tmp_path/'health.sqlite')
|
||||
with db._get_connection() as c:
|
||||
c.execute('CREATE TABLE api_keys (key TEXT,user_id TEXT,region TEXT,pass_token TEXT,revoked INTEGER,last_used_at TEXT)')
|
||||
c.execute("INSERT INTO api_keys VALUES ('synthetic-key','owner','cn','',0,NULL)")
|
||||
c.commit()
|
||||
state=SimpleNamespace(default_context=None,db=db,config=None,contexts={},key_contexts={},key_context_lock=threading.RLock())
|
||||
request=Request({'type':'http','headers':[(b'x-api-key',b'synthetic-key')],'app':SimpleNamespace(state=state)})
|
||||
calls=[]
|
||||
clock=[100.0]
|
||||
monkeypatch.setattr(api,'time',SimpleNamespace(monotonic=lambda:clock[0]))
|
||||
def unlock(key):
|
||||
calls.append(key)
|
||||
time.sleep(0.05)
|
||||
return 'synthetic-account-token'
|
||||
monkeypatch.setattr(api,'load_api_key_secret',unlock)
|
||||
monkeypatch.setattr(api,'_build_context',lambda *args:SimpleNamespace(query_service=object()))
|
||||
barrier=threading.Barrier(2)
|
||||
def resolve():
|
||||
barrier.wait()
|
||||
return api._resolve_context(request)
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
list(pool.map(lambda _:resolve(),range(2)))
|
||||
assert len(calls)==1, '每个请求都执行昂贵的密钥环解锁,概览五个请求被串行拖慢'
|
||||
clock[0]+=61
|
||||
api._resolve_context(request)
|
||||
assert len(calls)==2, '短期缓存到期后必须重新读取密钥环'
|
||||
with db._get_connection() as c:
|
||||
c.execute("UPDATE api_keys SET revoked=1 WHERE key='synthetic-key'");c.commit()
|
||||
with pytest.raises(api.HTTPException):api._resolve_context(request)
|
||||
state.default_context=SimpleNamespace(query_service=object())
|
||||
bad=Request({'type':'http','headers':[(b'x-api-key',b'unknown-key')],'app':SimpleNamespace(state=state)})
|
||||
with pytest.raises(api.HTTPException):api._resolve_context(bad)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ambiguous_key_prefix_cannot_revoke_multiple_authorizations(tmp_path,monkeypatch):
|
||||
from mi_fitness_mcp import api
|
||||
from mi_fitness_mcp.storage import Database
|
||||
from starlette.requests import Request
|
||||
db=Database(tmp_path/'keys.sqlite')
|
||||
with db._get_connection() as c:
|
||||
c.execute('CREATE TABLE api_keys (key TEXT,revoked INTEGER)')
|
||||
c.executemany('INSERT INTO api_keys VALUES (?,0)',[('mif_sk_abcde0000',),('mif_sk_abcde1111',)])
|
||||
c.commit()
|
||||
deleted=[]
|
||||
monkeypatch.setattr(api,'_require_admin',lambda request:None)
|
||||
monkeypatch.setattr(api,'delete_api_key_secret',deleted.append)
|
||||
request=Request({'type':'http','headers':[],'app':SimpleNamespace(state=SimpleNamespace(db=db))})
|
||||
with pytest.raises(api.HTTPException) as error:
|
||||
await api.revoke_key('mif_sk_abcde',request)
|
||||
assert error.value.status_code==409
|
||||
assert not deleted
|
||||
with db._get_connection() as c:assert c.execute('SELECT sum(revoked) FROM api_keys').fetchone()[0]==0
|
||||
@@ -0,0 +1,69 @@
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0,str(ROOT/'work/mi-fitness-mcp-cn/src'))
|
||||
from mi_fitness_mcp import storage
|
||||
from mi_fitness_mcp.models import HeartRateSample
|
||||
from mi_fitness_mcp.services.sync_service import SyncService
|
||||
|
||||
|
||||
def samples(count):
|
||||
start=datetime(2026,10,15,tzinfo=timezone.utc)
|
||||
return [HeartRateSample(id=f'synthetic-{i}',provider='mi_fitness',source_type='cloud_session',user_id='synthetic-user',timestamp=start+timedelta(minutes=i),bpm=70) for i in range(count)]
|
||||
|
||||
|
||||
def test_bulk_records_commit_once_remain_idempotent_and_rollback_together(tmp_path,monkeypatch):
|
||||
db=storage.Database(tmp_path/'health.sqlite')
|
||||
assert callable(getattr(db,'insert_records_batch',None)), '缺少批量事务,每条记录都在提交'
|
||||
statements=[]
|
||||
connect=sqlite3.connect
|
||||
|
||||
def traced(*args,**kwargs):
|
||||
conn=connect(*args,**kwargs)
|
||||
conn.set_trace_callback(statements.append)
|
||||
return conn
|
||||
|
||||
monkeypatch.setattr(storage.sqlite3,'connect',traced)
|
||||
records=samples(250)
|
||||
db.insert_records_batch('heart_rate',records)
|
||||
assert sum(s=='COMMIT' for s in statements)==1
|
||||
db.insert_records_batch('heart_rate',records)
|
||||
with db._get_connection() as c:
|
||||
assert c.execute('SELECT count(*) FROM heart_rate_samples').fetchone()[0]==250
|
||||
valid=samples(1)[0].model_copy(update={'id':'rollback-proof'})
|
||||
invalid=valid.model_copy(update={'id':'invalid','user_id':None})
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.insert_records_batch('heart_rate',[valid,invalid])
|
||||
with db._get_connection() as c:
|
||||
assert c.execute("SELECT count(*) FROM heart_rate_samples WHERE id='rollback-proof'").fetchone()[0]==0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disk_wait_during_sync_does_not_block_request_event_loop(tmp_path):
|
||||
class SlowDiskDatabase(storage.Database):
|
||||
def insert_heart_rate_sample(self, sample, **kwargs):
|
||||
time.sleep(0.01)
|
||||
return super().insert_heart_rate_sample(sample,**kwargs)
|
||||
|
||||
class Adapter:
|
||||
def is_connected(self): return True
|
||||
async def iter_heart_rate(self,start,end):
|
||||
for record in samples(40): yield record
|
||||
|
||||
db=SlowDiskDatabase(tmp_path/'health.sqlite')
|
||||
service=SyncService(Adapter(),db)
|
||||
task=asyncio.create_task(service.sync_data_type('heart_rate','2026-10-15','2026-10-15'))
|
||||
await asyncio.sleep(0.01)
|
||||
responsive=not task.done()
|
||||
result=await task
|
||||
assert responsive, '磁盘写入阻塞了整个请求事件循环'
|
||||
assert result['status']=='ok'
|
||||
with db._get_connection() as c:
|
||||
assert c.execute('SELECT count(*) FROM heart_rate_samples').fetchone()[0]==40
|
||||
@@ -0,0 +1,184 @@
|
||||
"""数据边界修复回归:全部为本机合成数据。"""
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / 'work/mi-fitness-mcp-cn/src'))
|
||||
from mi_fitness_mcp import api
|
||||
from mi_fitness_mcp.models import DailyActivity, StressSample, Workout, SleepSession
|
||||
from mi_fitness_mcp.services.query_service import QueryService
|
||||
from mi_fitness_mcp.services.sync_service import SyncService
|
||||
from mi_fitness_mcp.storage import Database
|
||||
|
||||
CN = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def database(tmp_path):
|
||||
db = Database(tmp_path / 'synthetic.sqlite')
|
||||
with db._get_connection() as conn:
|
||||
conn.execute("CREATE TABLE api_keys (key TEXT PRIMARY KEY,label TEXT,user_id TEXT,pass_token TEXT,region TEXT,created_at TEXT,last_used_at TEXT,revoked INTEGER DEFAULT 0)")
|
||||
conn.commit()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.mark.parametrize('existing', ['revoked_key', 'health_data'])
|
||||
def test_new_authorization_cannot_change_database_owner(tmp_path, monkeypatch, existing):
|
||||
db = database(tmp_path)
|
||||
if existing == 'revoked_key':
|
||||
with db._get_connection() as conn:
|
||||
conn.execute("INSERT INTO api_keys VALUES ('old','old','A','','cn','2026-10-01',NULL,1)")
|
||||
conn.commit()
|
||||
else:
|
||||
db.insert_daily_activity(DailyActivity(id='day-A', provider='mi_fitness', source_type='cloud_session',
|
||||
user_id='A', date='2026-10-16', steps=1111, active_kcal=100, distance_m=1000))
|
||||
stored = []
|
||||
monkeypatch.setattr(api, 'store_api_key_secret', lambda *args: stored.append(args) or True)
|
||||
with pytest.raises(api.HTTPException) as exc:
|
||||
api._create_key_record(db, 'B', 'synthetic', 'cn', 'other')
|
||||
assert exc.value.status_code == 409
|
||||
assert not stored
|
||||
|
||||
|
||||
def test_same_owner_reauthorization_allowed_but_region_switch_rejected(tmp_path, monkeypatch):
|
||||
db = database(tmp_path)
|
||||
monkeypatch.setattr(api, 'store_api_key_secret', lambda *args: True)
|
||||
first = api._create_key_record(db, 'A', 'synthetic', 'cn', 'first')
|
||||
second = api._create_key_record(db, 'A', 'synthetic', 'cn', 'second')
|
||||
assert first != second
|
||||
with pytest.raises(api.HTTPException) as exc:
|
||||
api._create_key_record(db, 'A', 'synthetic', 'us', 'other-region')
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_sync_refuses_context_for_another_data_owner(tmp_path):
|
||||
db = database(tmp_path)
|
||||
db.insert_daily_activity(DailyActivity(id='day-A', provider='mi_fitness', source_type='cloud_session',
|
||||
user_id='A', date='2026-10-16', steps=1111, active_kcal=100, distance_m=1000))
|
||||
ctx = SimpleNamespace(user_id='B', region='cn', sync_service=SimpleNamespace(db=db), adapter=None)
|
||||
with pytest.raises(api.HTTPException) as exc:
|
||||
await api._run_sync(ctx, None, {'sync_id': 'synthetic'})
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
class Adapter:
|
||||
def __init__(self, owner):
|
||||
self.user_id = owner
|
||||
self.calls = []
|
||||
def is_connected(self): return True
|
||||
async def iter_heart_rate(self, start, end):
|
||||
self.calls.append((start, end))
|
||||
if False: yield None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_day_incremental_uses_calendar_day(tmp_path):
|
||||
db = database(tmp_path)
|
||||
adapter = Adapter('A')
|
||||
# 旧版全局游标也必须安全,不能将当天 09:00 与午夜比较后拒绝。
|
||||
db.update_sync_state('heart_rate', datetime(2026, 10, 16, 9, tzinfo=CN))
|
||||
db.update_sync_state('heart_rate', datetime(2026, 10, 16, 9, tzinfo=CN), user_id='A')
|
||||
await SyncService(adapter, db).sync_data_type('heart_rate', end_date='2026-10-16')
|
||||
assert adapter.calls
|
||||
assert adapter.calls[-1][1] == '2026-10-16'
|
||||
|
||||
|
||||
def test_direct_and_batch_writes_cannot_overwrite_another_owner(tmp_path):
|
||||
db = database(tmp_path)
|
||||
item = DailyActivity(id='same-day', provider='mi_fitness', source_type='cloud_session', user_id='A',
|
||||
date='2026-10-16', steps=1111, active_kcal=100, distance_m=1000)
|
||||
db.insert_daily_activity(item)
|
||||
other = item.model_copy(update={'user_id': 'B', 'steps': 2222})
|
||||
with pytest.raises(ValueError, match='账号'):
|
||||
db.insert_daily_activity(other)
|
||||
with pytest.raises(ValueError, match='账号'):
|
||||
db.insert_records_batch('daily_activity', [other])
|
||||
assert QueryService(db, 'A').get_daily_summaries('2026-10-16', '2026-10-16')[0]['steps'] == 1111
|
||||
|
||||
|
||||
def test_concurrent_first_authorizations_bind_exactly_one_owner(tmp_path, monkeypatch):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import threading
|
||||
db = database(tmp_path)
|
||||
monkeypatch.setattr(api, 'store_api_key_secret', lambda *args: True)
|
||||
barrier = threading.Barrier(2)
|
||||
def create(owner):
|
||||
barrier.wait()
|
||||
try:
|
||||
api._create_key_record(db, owner, 'synthetic', 'cn', owner)
|
||||
return 'ok'
|
||||
except api.HTTPException as exc:
|
||||
return exc.status_code
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
assert sorted(pool.map(create, ['A', 'B']), key=str) == [409, 'ok']
|
||||
with db._get_connection() as conn:
|
||||
assert conn.execute('SELECT count(*) FROM api_keys').fetchone()[0] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_another_account_cursor_is_not_used_for_first_incremental_sync(tmp_path):
|
||||
db = database(tmp_path)
|
||||
db.update_sync_state('heart_rate', datetime(2026, 10, 15, tzinfo=CN))
|
||||
adapter = Adapter('B')
|
||||
await SyncService(adapter, db).sync_data_type('heart_rate', end_date='2026-10-16')
|
||||
assert adapter.calls[0][0] == '2026-09-17'
|
||||
|
||||
|
||||
def test_cursor_storage_is_scoped_by_owner(tmp_path):
|
||||
db = database(tmp_path)
|
||||
stamp = datetime(2026, 10, 16, 9, tzinfo=CN)
|
||||
db.update_sync_state('heart_rate', stamp, user_id='A')
|
||||
assert db.get_sync_state('heart_rate', user_id='A')['last_record_timestamp'] == stamp.isoformat()
|
||||
assert db.get_sync_state('heart_rate', user_id='B') is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_mcp_status_reads_current_owner_cursor(tmp_path, monkeypatch):
|
||||
from mi_fitness_mcp import server
|
||||
db = database(tmp_path)
|
||||
db.update_sync_state('heart_rate', datetime(2026,10,16,9,tzinfo=CN), user_id='A')
|
||||
class DisconnectedAdapter:
|
||||
user_id = 'A'
|
||||
last_error = None
|
||||
last_health_check_at = None
|
||||
async def health_check(self): return False
|
||||
monkeypatch.setattr(server, 'db', db)
|
||||
monkeypatch.setattr(server, 'adapter', DisconnectedAdapter())
|
||||
monkeypatch.setattr(server, 'sync_service', None)
|
||||
monkeypatch.setattr(server, 'config', SimpleNamespace(mode='mi_fitness_cloud',region='cn',health_check_timeout_seconds=1))
|
||||
result = await server._handle_get_connection_status()
|
||||
assert result['last_sync_at'] is not None
|
||||
assert 'heart_rate' in result['available_data_types']
|
||||
|
||||
|
||||
def test_coverage_counts_local_measurement_and_sleep_wake_days(tmp_path):
|
||||
db = database(tmp_path)
|
||||
for index, hour in enumerate((1, 9)):
|
||||
db.insert_stress_sample(StressSample(id=f'stress-{index}', provider='mi_fitness', source_type='cloud_session',
|
||||
user_id='A', timestamp=datetime(2026, 10, 16, hour, tzinfo=CN), stress_score=30, level='medium'))
|
||||
db.insert_sleep_session(SleepSession(id='sleep-A', sleep_id='sleep-A', provider='mi_fitness', source_type='cloud_session',
|
||||
user_id='A', start_at=datetime(2026, 10, 15, 23, tzinfo=CN), end_at=datetime(2026, 10, 16, 7, tzinfo=CN),
|
||||
duration_minutes=480, time_asleep_minutes=480, time_awake_minutes=0))
|
||||
coverage = {row['data_type']: row for row in db.get_data_coverage('A')}
|
||||
for kind in ('stress', 'sleep'):
|
||||
assert coverage[kind]['first_date'] == coverage[kind]['last_date'] == '2026-10-16'
|
||||
assert coverage[kind]['days_with_data'] == 1
|
||||
|
||||
|
||||
def test_repeated_workout_sync_updates_all_mutable_fields(tmp_path):
|
||||
db = database(tmp_path)
|
||||
item = Workout(id='exercise', workout_id='exercise', provider='mi_fitness', source_type='cloud_session', user_id='A',
|
||||
activity_type='walking', start_at=datetime(2026, 10, 16, 9, tzinfo=CN),
|
||||
end_at=datetime(2026, 10, 16, 9, 30, tzinfo=CN), duration_minutes=30)
|
||||
db.insert_workout(item)
|
||||
changes = {'total_steps': 4000, 'avg_pace_sec_per_km': 300, 'max_pace_sec_per_km': 240,
|
||||
'activity_type': 'running', 'end_at': datetime(2026, 10, 16, 9, 40, tzinfo=CN)}
|
||||
db.insert_workout(item.model_copy(update=changes))
|
||||
result = QueryService(db, 'A').get_workouts('2026-10-16', '2026-10-16')[0]
|
||||
for key, value in changes.items():
|
||||
assert result[key] == (value.isoformat() if isinstance(value, datetime) else value)
|
||||
@@ -0,0 +1,44 @@
|
||||
import importlib.util
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / 'work/mi-fitness-mcp-cn/src'))
|
||||
from mi_fitness_mcp.models import DailyActivity
|
||||
from mi_fitness_mcp.storage import Database
|
||||
|
||||
|
||||
def readonly():
|
||||
path = ROOT / 'outputs/nas-deployment/readonly.py'
|
||||
assert path.exists(), '缺少服务端强制只读查询入口'
|
||||
spec = importlib.util.spec_from_file_location('health_readonly', path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_readonly_database_denies_writes_and_credential_reads(tmp_path):
|
||||
m = readonly()
|
||||
path = tmp_path / 'health.sqlite'
|
||||
db = Database(path)
|
||||
db.insert_daily_activity(DailyActivity(id='proof',provider='mi_fitness',source_type='cloud_session',user_id='private-user',date='2026-10-15',steps=1000,distance_m=700,active_kcal=50))
|
||||
with sqlite3.connect(path) as c:
|
||||
c.execute('CREATE TABLE api_keys (key TEXT)')
|
||||
c.execute("INSERT INTO api_keys VALUES ('private-key')")
|
||||
ro = m.ReadOnlyDatabase(path)
|
||||
with ro._get_connection() as c:
|
||||
assert c.execute('SELECT steps FROM daily_activity').fetchone()[0] == 1000
|
||||
with pytest.raises(sqlite3.DatabaseError):
|
||||
c.execute('DELETE FROM daily_activity')
|
||||
with pytest.raises(sqlite3.DatabaseError):
|
||||
c.execute('SELECT key FROM api_keys')
|
||||
records = m.query_records(path, 'daily_activity', '2026-10-15', '2026-10-15')
|
||||
assert records[0]['steps'] == 1000
|
||||
assert 'private-user' not in str(records)
|
||||
with pytest.raises(ValueError):
|
||||
m.query_records(path, 'api_keys', '2026-10-15', '2026-10-15')
|
||||
with pytest.raises(ValueError):
|
||||
m.query_records(path, 'daily_activity', '2020-01-01', '2026-10-15')
|
||||
@@ -0,0 +1,155 @@
|
||||
"""隔离回归:不使用真实账号、不访问 NAS 或云端。"""
|
||||
import asyncio
|
||||
import base64
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / 'work/mi-fitness-mcp-cn/src'))
|
||||
from mi_fitness_mcp.adapters.mi_fitness_cloud import MiFitnessCloudAdapter
|
||||
from mi_fitness_mcp.models import BodyMeasurement
|
||||
from mi_fitness_mcp.storage import Database
|
||||
from mi_fitness_mcp.web import create_app
|
||||
|
||||
|
||||
def basic(password):
|
||||
return {'Authorization': 'Basic ' + base64.b64encode(('admin:' + password).encode()).decode()}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('path,method', [('/', 'get'), ('/api/proxy_status', 'get'),
|
||||
('/proxy/api/auth/keys', 'get'), ('/proxy/api/auth/keys/mif_sk_demo', 'delete')])
|
||||
def test_web_requires_browser_auth_before_injecting_admin(path, method):
|
||||
client = create_app(admin_key='synthetic-admin').test_client()
|
||||
result = getattr(client, method)(path)
|
||||
assert result.status_code == 401
|
||||
assert 'Basic' in result.headers['WWW-Authenticate']
|
||||
|
||||
|
||||
def test_authenticated_browser_admin_proxy_keeps_credentials_local(monkeypatch):
|
||||
monkeypatch.setenv('MI_FITNESS_WEB_PASSWORD', 'synthetic-web-password')
|
||||
captured = []
|
||||
class Client:
|
||||
def __init__(self, **kwargs): pass
|
||||
def request(self, **kwargs):
|
||||
captured.append(kwargs)
|
||||
return httpx.Response(200, json={'status': 'ok'})
|
||||
monkeypatch.setattr('mi_fitness_mcp.web.httpx.Client', Client)
|
||||
client = create_app(admin_key='synthetic-admin').test_client()
|
||||
assert client.get('/', headers=basic('synthetic-admin')).status_code == 401
|
||||
result = client.get('/proxy/api/auth/keys', headers=basic('synthetic-web-password'))
|
||||
assert result.status_code == 200
|
||||
assert captured[0]['headers']['X-Admin-Key'] == 'synthetic-admin'
|
||||
assert not any(key.lower() == 'authorization' for key in captured[0]['headers'])
|
||||
assert result.headers.get('Cache-Control') == 'no-store'
|
||||
response = client.delete('/proxy/api/auth/keys/demo', headers={**basic('synthetic-web-password'), 'Origin': 'https://untrusted.example'})
|
||||
assert response.status_code == 403
|
||||
assert len(captured) == 1
|
||||
|
||||
|
||||
def test_explicit_https_origin_supports_tls_termination_without_trusting_forwarded_headers(monkeypatch):
|
||||
monkeypatch.setenv('MI_FITNESS_WEB_ORIGIN', 'https://health.example')
|
||||
client = create_app(admin_key='synthetic-admin').test_client()
|
||||
assert client.get('/', headers={**basic('synthetic-admin'), 'Origin': 'https://health.example'}).status_code == 200
|
||||
assert client.get('/', headers={**basic('synthetic-admin'), 'Origin': 'https://other.example',
|
||||
'X-Forwarded-Host': 'other.example', 'X-Forwarded-Proto': 'https'}).status_code == 403
|
||||
|
||||
|
||||
def test_stale_health_api_key_does_not_lock_browser_out_of_reauthorization(monkeypatch):
|
||||
captured = []
|
||||
class Client:
|
||||
def __init__(self, **kwargs): pass
|
||||
def request(self, **kwargs):
|
||||
captured.append(kwargs)
|
||||
return httpx.Response(200, json={})
|
||||
monkeypatch.setattr('mi_fitness_mcp.web.httpx.Client', Client)
|
||||
client = create_app(admin_key='synthetic-admin').test_client()
|
||||
headers = {**basic('synthetic-admin'), 'X-API-Key': 'mif_sk_revoked'}
|
||||
client.post('/proxy/api/auth/qr/start', headers=headers)
|
||||
assert not any(key.lower() == 'x-api-key' for key in captured[0]['headers'])
|
||||
assert captured[0]['headers']['X-Admin-Key'] == 'synthetic-admin'
|
||||
client.get('/proxy/api/summary', headers=headers)
|
||||
assert any(key.lower() == 'x-api-key' and value == 'mif_sk_revoked' for key, value in captured[1]['headers'].items())
|
||||
|
||||
|
||||
def test_body_measurement_later_sync_updates_all_body_fields(tmp_path):
|
||||
db = Database(tmp_path / 'synthetic.sqlite')
|
||||
item = BodyMeasurement(id='synthetic', provider='mi_fitness', source_type='cloud_session',
|
||||
user_id='synthetic', timestamp=datetime(2026, 10, 15, 9, tzinfo=timezone.utc), weight_kg=87.3)
|
||||
db.insert_body_measurement(item)
|
||||
additions = {'bone_mass_kg': 3.1, 'visceral_fat_score': 9, 'basal_metabolism_kcal': 1800, 'metabolic_age': 30}
|
||||
db.insert_body_measurement(item.model_copy(update=additions))
|
||||
result = db.query_body_measurements('synthetic', '2026-10-15', '2026-10-15')[0]
|
||||
assert {key: result[key] for key in additions} == additions
|
||||
|
||||
|
||||
@pytest.mark.parametrize('method,list_name', [('_fetch_key', 'data_list'), ('_fetch_sport_records_by_time', 'sport_records')])
|
||||
@pytest.mark.parametrize('bad_cursor', [None, '', 123])
|
||||
def test_incomplete_pagination_fails_instead_of_succeeding(monkeypatch, method, list_name, bad_cursor):
|
||||
adapter = MiFitnessCloudAdapter(user_id='synthetic', pass_token='synthetic', region='cn')
|
||||
calls = []
|
||||
async def request(*args):
|
||||
calls.append(args)
|
||||
return {list_name: [], 'has_more': True, 'next_key': bad_cursor}
|
||||
monkeypatch.setattr(adapter, '_request', request)
|
||||
args = ('heart_rate', '2026-10-15', '2026-10-16') if method == '_fetch_key' else ('2026-10-15', '2026-10-16')
|
||||
with pytest.raises(ValueError, match='pagination cursor'):
|
||||
asyncio.run(getattr(adapter, method)(*args))
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('method,list_name', [('_fetch_key', 'data_list'), ('_fetch_sport_records_by_time', 'sport_records')])
|
||||
def test_valid_pagination_returns_all_pages(monkeypatch, method, list_name):
|
||||
adapter = MiFitnessCloudAdapter(user_id='synthetic', pass_token='synthetic', region='cn')
|
||||
async def request(base, path, payload):
|
||||
if payload.get('next_key') == 'page2':
|
||||
return {list_name: [{'id': 2}], 'has_more': False}
|
||||
return {list_name: [{'id': 1}], 'has_more': True, 'next_key': 'page2'}
|
||||
monkeypatch.setattr(adapter, '_request', request)
|
||||
args = ('heart_rate', '2026-10-15', '2026-10-16') if method == '_fetch_key' else ('2026-10-15', '2026-10-16')
|
||||
assert asyncio.run(getattr(adapter, method)(*args)) == [{'id': 1}, {'id': 2}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('method,list_name', [('_fetch_key', 'data_list'), ('_fetch_sport_records_by_time', 'sport_records')])
|
||||
def test_missing_record_list_is_not_treated_as_empty_success(monkeypatch, method, list_name):
|
||||
adapter = MiFitnessCloudAdapter(user_id='synthetic', pass_token='synthetic', region='cn')
|
||||
async def request(*args):
|
||||
return {'has_more': False}
|
||||
monkeypatch.setattr(adapter, '_request', request)
|
||||
args = ('heart_rate', '2026-10-15', '2026-10-16') if method == '_fetch_key' else ('2026-10-15', '2026-10-16')
|
||||
with pytest.raises(ValueError, match='response'):
|
||||
asyncio.run(getattr(adapter, method)(*args))
|
||||
|
||||
|
||||
def test_concurrent_qr_polls_issue_only_one_key(monkeypatch):
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from starlette.requests import Request
|
||||
from mi_fitness_mcp import api
|
||||
monkeypatch.setenv('MI_FITNESS_ADMIN_KEY', 'synthetic-admin')
|
||||
state = SimpleNamespace(db=None, qr_sessions={'qr': {'status': 'waiting', 'lp': 'http://synthetic.invalid',
|
||||
'created_at': time.time(), 'expires_in': 300, 'region': 'cn'}})
|
||||
request = Request({'type': 'http', 'headers': [(b'x-admin-key', b'synthetic-admin')],
|
||||
'app': SimpleNamespace(state=state)})
|
||||
keys = []
|
||||
class Client:
|
||||
def __init__(self, **kwargs): pass
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *args): pass
|
||||
async def get(self, *args):
|
||||
await asyncio.sleep(0.01)
|
||||
return httpx.Response(200, json={'code': 0, 'userId': 'synthetic', 'passToken': 'synthetic'})
|
||||
def create(*args):
|
||||
key = 'synthetic-' + str(len(keys))
|
||||
keys.append(key)
|
||||
return key
|
||||
monkeypatch.setattr(api.httpx, 'AsyncClient', Client)
|
||||
monkeypatch.setattr(api, '_create_key_record', create)
|
||||
async def polls():
|
||||
return await asyncio.gather(api.qr_poll('qr', request), api.qr_poll('qr', request))
|
||||
results = asyncio.run(polls())
|
||||
assert len(keys) == 1
|
||||
assert results[0]['api_key'] == results[1]['api_key']
|
||||
@@ -0,0 +1,104 @@
|
||||
import gzip
|
||||
import importlib.util
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
RUNTIME = Path(__file__).resolve().parents[2] / 'outputs/nas-deployment/runtime.py'
|
||||
|
||||
|
||||
def runtime():
|
||||
assert RUNTIME.exists(), '缺少部署运行支持:安全归档、断线补偿和备份'
|
||||
spec = importlib.util.spec_from_file_location('deployment_runtime', RUNTIME)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_archive_preserves_health_versions_but_rejects_auth_endpoints(tmp_path):
|
||||
m = runtime()
|
||||
for steps in (1000, 1100):
|
||||
m.archive_response(tmp_path, '/app/v1/data/get_aggregated_fitness_data_by_time', {'key': 'steps'}, {'steps': steps})
|
||||
files = list(tmp_path.glob('*.jsonl.gz'))
|
||||
assert len(files) == 1
|
||||
with gzip.open(files[0], 'rt', encoding='utf-8') as f:
|
||||
assert [json.loads(line)['result']['steps'] for line in f] == [1000, 1100]
|
||||
with pytest.raises(ValueError):
|
||||
m.archive_response(tmp_path, '/pass/serviceLoginAuth2', {}, {'passToken': 'never-archive'})
|
||||
assert len(list(tmp_path.glob('*'))) == 1
|
||||
|
||||
|
||||
def test_failed_sync_never_moves_success_cursor_and_recovery_covers_gap():
|
||||
m = runtime()
|
||||
state = {'last_success_date': '2026-10-01'}
|
||||
assert m.sync_start(state, date(2026, 10, 16)) == '2026-09-29'
|
||||
m.finish_sync(state, {'status': 'partial'}, date(2026, 10, 16))
|
||||
assert state['last_success_date'] == '2026-10-01'
|
||||
m.finish_sync(state, {'status': 'ok'}, date(2026, 10, 16))
|
||||
assert state['last_success_date'] == '2026-10-16'
|
||||
|
||||
|
||||
def test_online_backup_restores_without_credentials_and_without_touching_original(tmp_path):
|
||||
m = runtime()
|
||||
source = tmp_path / 'live.sqlite'
|
||||
with sqlite3.connect(source) as c:
|
||||
c.execute('CREATE TABLE daily_activity (steps INTEGER)')
|
||||
c.execute('INSERT INTO daily_activity VALUES (1000)')
|
||||
c.execute('CREATE TABLE api_keys (key TEXT, pass_token TEXT)')
|
||||
c.execute("INSERT INTO api_keys VALUES ('test-api-key', 'test-account-token')")
|
||||
backup = m.backup_database(source, tmp_path / 'backups')
|
||||
with sqlite3.connect(backup) as c:
|
||||
assert c.execute('PRAGMA quick_check').fetchone()[0] == 'ok'
|
||||
assert c.execute('SELECT steps FROM daily_activity').fetchone()[0] == 1000
|
||||
assert c.execute('SELECT count(*) FROM api_keys').fetchone()[0] == 0
|
||||
with sqlite3.connect(source) as c:
|
||||
assert c.execute('SELECT count(*) FROM api_keys').fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_worker_recovers_after_network_failure_without_losing_gap(tmp_path, monkeypatch):
|
||||
import httpx
|
||||
|
||||
m = runtime()
|
||||
m.STATE = tmp_path
|
||||
db = tmp_path / 'data/mi_fitness.db'
|
||||
db.parent.mkdir()
|
||||
with sqlite3.connect(db) as c:
|
||||
c.execute('CREATE TABLE daily_activity (steps INTEGER)')
|
||||
c.execute('CREATE TABLE api_keys (key TEXT, revoked INTEGER, user_id TEXT, region TEXT, created_at TEXT)')
|
||||
c.execute("INSERT INTO api_keys VALUES ('synthetic-key',0,'synthetic-user','cn','2026-10-01')")
|
||||
status_file = tmp_path / 'automation/status.json'
|
||||
m.atomic_json(status_file, {'last_success_date':'2026-10-01'})
|
||||
original_client = httpx.Client
|
||||
offline = True
|
||||
observed_starts = []
|
||||
|
||||
def respond(request):
|
||||
if offline:
|
||||
raise httpx.ConnectError('synthetic offline', request=request)
|
||||
if request.method == 'POST':
|
||||
observed_starts.append(json.loads(request.content)['start_date'])
|
||||
return httpx.Response(200, json={'sync_id':'synthetic-task','status':'accepted'})
|
||||
return httpx.Response(200, json={'status':'ok'})
|
||||
|
||||
class StopWorker(Exception):
|
||||
pass
|
||||
|
||||
def sleep(seconds):
|
||||
if seconds in (300,1800):
|
||||
raise StopWorker()
|
||||
|
||||
monkeypatch.setattr(httpx,'Client',lambda **kw: original_client(transport=httpx.MockTransport(respond),**kw))
|
||||
monkeypatch.setattr(m.time,'sleep',sleep)
|
||||
with pytest.raises(StopWorker):
|
||||
m.worker()
|
||||
failed=json.loads(status_file.read_text(encoding='utf-8'))
|
||||
assert failed['status']=='error' and failed['last_success_date']=='2026-10-01'
|
||||
offline=False
|
||||
with pytest.raises(StopWorker):
|
||||
m.worker()
|
||||
recovered=json.loads(status_file.read_text(encoding='utf-8'))
|
||||
assert recovered['status']=='ok' and recovered['last_success_date']!='2026-10-01'
|
||||
assert observed_starts==['2026-09-29']
|
||||
@@ -0,0 +1,46 @@
|
||||
"""本机模拟容器切换时的短暂连接重置,不访问 NAS。"""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import urllib.error
|
||||
|
||||
import pytest
|
||||
|
||||
def helper():
|
||||
path=Path(__file__).resolve().parents[2]/'outputs/frontend-performance-fix/wait-web-ready.py'
|
||||
assert path.exists(), '更新就绪检查缺少容错重试'
|
||||
spec=importlib.util.spec_from_file_location('web_ready',path)
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
class Response:
|
||||
status=200
|
||||
def __init__(self,body): self.body=body
|
||||
def __enter__(self): return self
|
||||
def __exit__(self,*args): pass
|
||||
def read(self,*args): return self.body
|
||||
|
||||
def test_readiness_retries_connection_reset_and_stale_page():
|
||||
outcomes=[ConnectionResetError(),Response(b'old-ui'),Response(b'20261017-ui3')]
|
||||
class Opener:
|
||||
def open(self,*args,**kwargs):
|
||||
value=outcomes.pop(0)
|
||||
if isinstance(value,Exception): raise value
|
||||
return value
|
||||
helper().wait_for_ready(Opener(),object(),pause=lambda seconds:None)
|
||||
assert not outcomes
|
||||
|
||||
def test_readiness_has_bounded_failure():
|
||||
calls=[]
|
||||
class Opener:
|
||||
def open(self,*args,**kwargs):
|
||||
calls.append(1);raise ConnectionResetError()
|
||||
with pytest.raises(RuntimeError,match='就绪'):
|
||||
helper().wait_for_ready(Opener(),object(),attempts=3,pause=lambda seconds:None)
|
||||
assert len(calls)==3
|
||||
|
||||
def test_bad_password_is_not_treated_as_slow_startup():
|
||||
class Opener:
|
||||
def open(self,*args,**kwargs):
|
||||
raise urllib.error.HTTPError('http://local',401,'denied',{},None)
|
||||
with pytest.raises(RuntimeError,match='鉴权'):
|
||||
helper().wait_for_ready(Opener(),object(),pause=lambda seconds:None)
|
||||
@@ -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]/'mi-fitness-mcp-cn/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]/'mi-fitness-mcp-cn/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()
|
||||
@@ -0,0 +1,22 @@
|
||||
const fs=require('node:fs');const path=require('node:path');const assert=require('node:assert/strict');
|
||||
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
(async()=>{
|
||||
const tabs=await(await fetch('http://127.0.0.1:18384/json')).json();
|
||||
const ws=new WebSocket(tabs.find(t=>t.type==='page').webSocketDebuggerUrl);await new Promise(r=>ws.onopen=r);
|
||||
let id=0;const pending=new Map(),errors=[];
|
||||
ws.onmessage=e=>{const m=JSON.parse(e.data);if(m.method==='Runtime.exceptionThrown')errors.push(m.params.exceptionDetails.exception?.description||m.params.exceptionDetails.text);if(m.id&&pending.has(m.id)){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(m.error):p.resolve(m.result);}};
|
||||
const call=(method,params={})=>new Promise((resolve,reject)=>{const k=++id;pending.set(k,{resolve,reject});ws.send(JSON.stringify({id:k,method,params}));});
|
||||
const evaluate=async expression=>{const r=await call('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value;};
|
||||
const dir=path.resolve('work/axis-minimum-validation');fs.mkdirSync(dir,{recursive:true});
|
||||
const shot=async name=>{await pause(400);const s=await call('Page.captureScreenshot',{format:'png',captureBeyondViewport:false});fs.writeFileSync(path.join(dir,name+'.png'),Buffer.from(s.data,'base64'));};
|
||||
await call('Runtime.enable');await call('Page.enable');await call('Emulation.setDeviceMetricsOverride',{width:1600,height:1050,deviceScaleFactor:1,mobile:false});
|
||||
await call('Page.navigate',{url:'http://127.0.0.1:18364/'});
|
||||
for(let i=0;i<80;i++){await pause(100);if(await evaluate('document.readyState==="complete" && typeof HealthViews.referenceLayout==="function"'))break;}
|
||||
await evaluate(`state.theme='light';initTheme();showToast=()=>{};$('toastContainer').innerHTML='';setInspectorCollapsed(true);$('startDate').value='2026-09-18';$('endDate').value='2026-10-17';$$('.preset-pill').forEach(p=>p.classList.remove('active'));$('preset-30d').classList.add('active');window.testOriginalFetch=fetch;window.pressurePeak=18;fetch=async(url,options)=>String(url).startsWith('/proxy/api/stress')?new Response(JSON.stringify({status:'ok',data:Array.from({length:30},(_,i)=>({timestamp:new Date(Date.UTC(2026,8,18+i,8)).toISOString(),stress_score:i===15?window.pressurePeak:5+i%12,level:'source-original'}))}),{status:200}):testOriginalFetch(url,options);`);
|
||||
const results=[];
|
||||
for(const [kind,min] of [['body',40],['spo2',80],['heartrate',40]]){
|
||||
const actual=await evaluate(`(async()=>{await switchTab('${kind}');const m=state.healthViews['${kind}'].model;const layout=HealthViews.referenceLayout(m);return {kind:'${kind}',min:layout.min,max:layout.max,axisLabels:[...$('healthView-${kind}').querySelectorAll('svg text[text-anchor="end"]')].map(t=>t.textContent),bars:$('healthView-${kind}').querySelectorAll('rect:not(.health-reference-label)').length};})()`);
|
||||
assert.equal(actual.min,min);assert.equal(Number(actual.axisLabels[0]),min);assert.ok(actual.max>min);assert.ok(actual.bars>0);results.push(actual);await shot(kind+'-axis');
|
||||
}
|
||||
assert.deepEqual(errors,[]);const result={results,errors};fs.writeFileSync(path.join(dir,'axis-browser-check.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result));ws.close();
|
||||
})().catch(e=>{console.error(e);process.exit(1);});
|
||||
@@ -0,0 +1,30 @@
|
||||
const fs=require('node:fs');const path=require('node:path');const assert=require('node:assert/strict');
|
||||
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
(async()=>{
|
||||
const tabs=await(await fetch('http://127.0.0.1:18384/json')).json();
|
||||
const ws=new WebSocket(tabs.find(t=>t.type==='page').webSocketDebuggerUrl);await new Promise(r=>ws.onopen=r);
|
||||
let id=0;const pending=new Map(),errors=[];
|
||||
ws.onmessage=e=>{const m=JSON.parse(e.data);if(m.method==='Runtime.exceptionThrown')errors.push(m.params.exceptionDetails.exception?.description||m.params.exceptionDetails.text);if(m.id&&pending.has(m.id)){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(m.error):p.resolve(m.result);}};
|
||||
const call=(method,params={})=>new Promise((resolve,reject)=>{const k=++id;pending.set(k,{resolve,reject});ws.send(JSON.stringify({id:k,method,params}));});
|
||||
const evaluate=async expression=>{const r=await call('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value;};
|
||||
const dir=path.resolve('work/calendar-validation');fs.mkdirSync(dir,{recursive:true});
|
||||
const shot=async name=>{await pause(400);const s=await call('Page.captureScreenshot',{format:'png',captureBeyondViewport:false});fs.writeFileSync(path.join(dir,name+'.png'),Buffer.from(s.data,'base64'));};
|
||||
await call('Runtime.enable');await call('Page.enable');await call('Emulation.setDeviceMetricsOverride',{width:1600,height:1050,deviceScaleFactor:1,mobile:false});
|
||||
await call('Page.navigate',{url:'http://127.0.0.1:18364/'});
|
||||
for(let i=0;i<80;i++){await pause(100);if(await evaluate('document.readyState==="complete" && typeof HealthViews.calendarDay==="function"'))break;}
|
||||
await evaluate(`state.theme='light';initTheme();showToast=()=>{};$('toastContainer').innerHTML='';setInspectorCollapsed(true);$('startDate').value='2026-09-18';$('endDate').value='2026-10-17';$$('.preset-pill').forEach(p=>p.classList.remove('active'));$('preset-30d').classList.add('active');`);
|
||||
const results=[];
|
||||
for(const kind of ['activity','heartrate','sleep','body','spo2','stress']){
|
||||
await evaluate(`switchTab('${kind}')`);
|
||||
for(let i=0;i<40;i++){if(await evaluate(`!!state.healthViews['${kind}']?.model.range.calendar?.['2026']`))break;await pause(50);}
|
||||
const actual=await evaluate(`(()=>{const el=$('healthView-${kind}'),marks=[...el.querySelectorAll('.health-calendar-mark')];return {kind:'${kind}',ys:[...new Set(marks.map(m=>m.getBoundingClientRect().y))],marks:marks.length,nodes:el.querySelectorAll('*').length,colors:Object.fromEntries(marks.map(m=>[m.dataset.date,m.getAttribute('stroke')])),warning:el.textContent.includes('调休安排未更新'),cards:el.querySelectorAll('.health-card').length};})()`);
|
||||
assert.equal(actual.marks,30);assert.equal(actual.ys.length,1);assert.equal(actual.warning,false);assert.equal(actual.cards,4);assert.ok(actual.nodes<800);
|
||||
assert.equal(actual.colors['2026-09-20'],'#e78325');assert.equal(actual.colors['2026-10-10'],'#e78325');assert.equal(actual.colors['2026-10-03'],'#22a06b');
|
||||
results.push(actual);if(kind==='sleep')await shot('sleep-workday-markers');
|
||||
}
|
||||
await evaluate(`state.theme='dark';initTheme();`);await shot('stress-workday-markers-dark');
|
||||
await evaluate(`$('startDate').value='2026-12-31';$('endDate').value='2027-01-02';const m=HealthViews.build('activity',[{date:'2027-01-01',steps:1234}],{start:'2026-12-31',end:'2027-01-02',calendar:state.healthViews.stress.model.range.calendar});$('healthView-stress').innerHTML=HealthViews.render(m);`);
|
||||
assert.equal(await evaluate(`$('healthView-stress').textContent.includes('2027 年调休安排未更新')`),true);
|
||||
assert.deepEqual(errors,[]);fs.writeFileSync(path.join(dir,'calendar-browser-check.json'),JSON.stringify({results,errors,cross_year_fallback:true},null,2));
|
||||
console.log(JSON.stringify({views:results.map(r=>({kind:r.kind,marks:r.marks,y:r.ys,nodes:r.nodes})),errors,cross_year_fallback:true}));ws.close();
|
||||
})().catch(e=>{console.error(e);process.exit(1);});
|
||||
@@ -0,0 +1,43 @@
|
||||
const fs=require('node:fs');
|
||||
const path=require('node:path');
|
||||
const assert=require('node:assert/strict');
|
||||
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
(async()=>{
|
||||
const tabs=await(await fetch('http://127.0.0.1:18384/json')).json();
|
||||
const ws=new WebSocket(tabs.find(t=>t.type==='page').webSocketDebuggerUrl);
|
||||
await new Promise(r=>ws.onopen=r);let id=0;const pending=new Map(),errors=[];
|
||||
ws.onmessage=e=>{const m=JSON.parse(e.data);if(m.method==='Runtime.exceptionThrown')errors.push(m.params.exceptionDetails.exception?.description||m.params.exceptionDetails.text);if(m.id&&pending.has(m.id)){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(m.error):p.resolve(m.result);}};
|
||||
const call=(method,params={})=>new Promise((resolve,reject)=>{const k=++id;pending.set(k,{resolve,reject});ws.send(JSON.stringify({id:k,method,params}));});
|
||||
const evaluate=async expression=>{const r=await call('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value;};
|
||||
await call('Runtime.enable');await call('Page.enable');await call('Emulation.setDeviceMetricsOverride',{width:1600,height:1050,deviceScaleFactor:1,mobile:false});
|
||||
await call('Page.navigate',{url:'http://127.0.0.1:18364/'});
|
||||
for(let i=0;i<80;i++){await pause(100);if(await evaluate('document.readyState==="complete" && typeof loadHealthView==="function"'))break;}
|
||||
await evaluate(`setInspectorCollapsed(true);localStorage.setItem('mi_fitness_theme','light');state.theme='light';initTheme();showToast=()=>{};$('toastContainer').innerHTML='';$$('.preset-pill').forEach(p=>p.classList.remove('active'));$('preset-30d').classList.add('active');$('startDate').value='2026-09-18';$('endDate').value='2026-10-17';`);
|
||||
const results=[];const dir=path.resolve('work/health-ui-validation');fs.mkdirSync(dir,{recursive:true});
|
||||
for(const kind of ['activity','heartrate','sleep','workouts','body','spo2','stress','abnormal']){
|
||||
const r=await evaluate(`(async()=>{const start=performance.now();await switchTab('${kind}');const el=$('healthView-${kind}');const saved=state.healthViews['${kind}'];return {kind:'${kind}',ms:performance.now()-start,nodes:el.querySelectorAll('*').length,rows:el.querySelectorAll('tbody tr').length,records:saved?.model.records.length,empty:el.textContent.includes('未保存'),cards:el.querySelectorAll('.health-card').length,consoleHidden:$('inspectorPanel').hidden};})()`);
|
||||
assert.equal(r.cards,4);assert.ok(r.rows<=10);assert.ok(r.nodes<750);assert.ok(r.consoleHidden);results.push(r);
|
||||
if(['sleep','heartrate','body'].includes(kind)){
|
||||
await evaluate('document.querySelector(".app-content").scrollTop=0');await pause(350);
|
||||
const shot=await call('Page.captureScreenshot',{format:'png',captureBeyondViewport:false});fs.writeFileSync(path.join(dir,kind+'.png'),Buffer.from(shot.data,'base64'));
|
||||
}
|
||||
}
|
||||
await evaluate("(async()=>{await switchTab('heartrate');healthPage('heartrate',2)})()");
|
||||
assert.equal(await evaluate("state.healthViews.heartrate.page"),2);
|
||||
assert.equal(await evaluate("$('healthView-heartrate').querySelectorAll('tbody tr').length"),10);
|
||||
await evaluate('setInspectorCollapsed(false)');
|
||||
assert.equal(await evaluate("getComputedStyle($('inspectorPanel')).display==='none'"),false);
|
||||
await call('Page.reload');await pause(700);
|
||||
assert.equal(await evaluate("$('inspectorPanel').hidden"),false);
|
||||
await evaluate("(async()=>{setInspectorCollapsed(true);showToast=()=>{};$('toastContainer').innerHTML='';state.theme='dark';initTheme();$('startDate').value='2026-09-18';$('endDate').value='2026-10-17';await switchTab('sleep')})()");
|
||||
await pause(350);let shot=await call('Page.captureScreenshot',{format:'png',captureBeyondViewport:false});fs.writeFileSync(path.join(dir,'sleep-dark.png'),Buffer.from(shot.data,'base64'));
|
||||
await call('Emulation.setDeviceMetricsOverride',{width:1100,height:900,deviceScaleFactor:1,mobile:false});
|
||||
assert.equal(await evaluate('document.documentElement.scrollWidth<=window.innerWidth'),true);
|
||||
await call('Emulation.setDeviceMetricsOverride',{width:760,height:900,deviceScaleFactor:1,mobile:false});
|
||||
const narrow=await evaluate('({width:document.documentElement.scrollWidth,viewport:window.innerWidth})');
|
||||
assert.equal(narrow.width<=narrow.viewport,true);
|
||||
await evaluate('setInspectorCollapsed(false)');
|
||||
assert.equal(await evaluate("getComputedStyle($('inspectorPanel')).display==='none'"),false);
|
||||
assert.deepEqual(errors,[]);
|
||||
fs.writeFileSync(path.join(dir,'browser-check.json'),JSON.stringify({results,errors,narrow},null,2));console.log(JSON.stringify({results,errors,narrow}));ws.close();
|
||||
})().catch(e=>{console.error(e);process.exit(1);});
|
||||
@@ -0,0 +1,30 @@
|
||||
const fs=require('node:fs');const path=require('node:path');const assert=require('node:assert/strict');
|
||||
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
(async()=>{
|
||||
const tabs=await(await fetch('http://127.0.0.1:18384/json')).json();
|
||||
const ws=new WebSocket(tabs.find(t=>t.type==='page').webSocketDebuggerUrl);await new Promise(r=>ws.onopen=r);
|
||||
let id=0;const pending=new Map(),errors=[];
|
||||
ws.onmessage=e=>{const m=JSON.parse(e.data);if(m.method==='Runtime.exceptionThrown')errors.push(m.params.exceptionDetails.exception?.description||m.params.exceptionDetails.text);if(m.id&&pending.has(m.id)){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(m.error):p.resolve(m.result);}};
|
||||
const call=(method,params={})=>new Promise((resolve,reject)=>{const k=++id;pending.set(k,{resolve,reject});ws.send(JSON.stringify({id:k,method,params}));});
|
||||
const evaluate=async expression=>{const r=await call('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value;};
|
||||
const dir=path.resolve('work/reference-lines-validation');fs.mkdirSync(dir,{recursive:true});
|
||||
const shot=async name=>{await pause(400);const s=await call('Page.captureScreenshot',{format:'png',captureBeyondViewport:false});fs.writeFileSync(path.join(dir,name+'.png'),Buffer.from(s.data,'base64'));};
|
||||
await call('Runtime.enable');await call('Page.enable');await call('Emulation.setDeviceMetricsOverride',{width:1600,height:1050,deviceScaleFactor:1,mobile:false});
|
||||
await call('Page.navigate',{url:'http://127.0.0.1:18364/'});
|
||||
for(let i=0;i<80;i++){await pause(100);if(await evaluate('document.readyState==="complete" && typeof HealthViews.referenceLayout==="function"'))break;}
|
||||
await evaluate(`state.theme='light';initTheme();showToast=()=>{};$('toastContainer').innerHTML='';setInspectorCollapsed(true);$('startDate').value='2026-09-18';$('endDate').value='2026-10-17';$$('.preset-pill').forEach(p=>p.classList.remove('active'));$('preset-30d').classList.add('active');window.testOriginalFetch=fetch;window.pressurePeak=18;fetch=async(url,options)=>String(url).startsWith('/proxy/api/stress')?new Response(JSON.stringify({status:'ok',data:Array.from({length:30},(_,i)=>({timestamp:new Date(Date.UTC(2026,8,18+i,8)).toISOString(),stress_score:i===15?window.pressurePeak:5+i%12,level:'source-original'}))}),{status:200}):testOriginalFetch(url,options);`);
|
||||
const stress=await evaluate(`(async()=>{await switchTab('stress');const m=state.healthViews.stress.model;return {max:HealthViews.referenceLayout(m).max,inside:HealthViews.referenceLayout(m).inside.map(r=>r.value),outside:HealthViews.referenceLayout(m).outside.map(r=>r.value),renderedLines:$('healthView-stress').querySelectorAll('.health-reference').length,sourceKept:m.records[0].level,columns:m.columns.length};})()`);
|
||||
assert.equal(stress.max,20);assert.deepEqual(stress.outside,[25,50,80]);assert.equal(stress.renderedLines,1);await shot('pressure-below25');
|
||||
const higher=await evaluate(`(async()=>{window.pressurePeak=58;await queryStress();const m=state.healthViews.stress.model;return {max:HealthViews.referenceLayout(m).max,outside:HealthViews.referenceLayout(m).outside.map(r=>r.value),lines:$('healthView-stress').querySelectorAll('.health-reference').length};})()`);
|
||||
assert.equal(higher.max,60);assert.deepEqual(higher.outside,[80]);assert.equal(higher.lines,3);await shot('pressure-below60');
|
||||
const selected=await evaluate(`(async()=>{$('stressLevel').value='moderate';await queryStress();return {count:state.healthViews.stress.model.records.length,average:state.healthViews.stress.model.cards[0].raw,text:$('healthView-stress').textContent};})()`);
|
||||
assert.equal(selected.count,1);assert.equal(selected.average,58);assert.match(selected.text,/中度/);
|
||||
const heart=await evaluate(`(async()=>{await switchTab('heartrate');const m=state.healthViews.heartrate.model;return {max:HealthViews.referenceLayout(m).max,outside:HealthViews.referenceLayout(m).outside.map(r=>r.value),inside:HealthViews.referenceLayout(m).inside.map(r=>r.key),bars:$('healthView-heartrate').querySelectorAll('rect:not(.health-reference-label)').length};})()`);
|
||||
assert.equal(heart.max,80);assert.deepEqual(heart.outside,[170]);assert.equal(heart.inside.length,2);assert.ok(heart.bars>30);await shot('heart-reference-lines');
|
||||
for(const kind of ['activity','sleep','body','spo2']){await evaluate(`switchTab('${kind}')`);await shot(kind+'-reference-lines');}
|
||||
await evaluate(`(async()=>{window.pressurePeak=18;$('stressLevel').value='';state.theme='dark';initTheme();await switchTab('stress');})()`);await shot('pressure-dark');
|
||||
await call('Emulation.setDeviceMetricsOverride',{width:760,height:900,deviceScaleFactor:1,mobile:false});
|
||||
assert.equal(await evaluate('document.documentElement.scrollWidth<=window.innerWidth'),true);
|
||||
assert.deepEqual(errors,[]);
|
||||
const result={stress,higher,selected:{count:selected.count,average:selected.average},heart,errors};fs.writeFileSync(path.join(dir,'reference-browser-check.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result));ws.close();
|
||||
})().catch(e=>{console.error(e);process.exit(1);});
|
||||
Reference in new issue
Block a user