qa: validate server queries and prepare offline backend web update

This commit is contained in:
weiyuqing committed 2026-10-17 14:41:45 +08:00
1 parent 6292755ff9
commit c076bbc0d6
11 files changed
+640 -1

No files matched your search

+161
View File
@@ -0,0 +1,161 @@
"""合成只读库的旧查询基线、新汇总、分页与 CSV 对照;输出到 artifacts。"""
import argparse
import csv
import gc
import io
import json
import math
import shutil
import subprocess
import time
import tracemalloc
from collections import Counter
from dataclasses import replace
from serve_query_fixture import KINDS, OWNER, ROOT, ReadOnlyDatabase, artifact_path, generate
from mi_fitness_mcp.services.query_service import QueryService
from mi_fitness_mcp.services.view_query_service import ViewQueryService
from mi_fitness_mcp.storage.view_queries import KINDS as SPECS, ViewSelection
from mi_fitness_mcp.api import VIEW_CSV_COLUMNS, _view_csv_chunks
METHODS = dict(zip(KINDS, ['get_daily_summaries', 'get_heart_rate_samples', 'get_sleep_sessions', 'get_workouts', 'get_body_measurements', 'get_spo2_samples', 'get_stress_samples', 'get_abnormal_heart_beat_events']))
def measured(call):
gc.collect()
tracemalloc.start()
started = time.perf_counter()
value = call()
elapsed = time.perf_counter() - started
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return value, dict(seconds=round(elapsed, 4), python_peak_bytes=peak)
def encoded(value):
return json.dumps(value, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
def test_payload_does_not_scale_with_raw_samples(results):
for kind in ('heartrate', 'spo2', 'stress'):
a, b = [next(row for row in results if row['density'] == density and row['kind'] == kind) for density in (1, 10)]
assert b['raw_count'] == a['raw_count'] * 10
assert b['summary_bytes'] < a['summary_bytes'] * 1.25, (kind, a, b)
assert a['detail_rows'] == b['detail_rows'] == 10
assert a['point_count'] == b['point_count'] == 30
def cards(kind, result):
s = result['statistics']
return {
'activity': [s.get('primary_sum'), s.get('total_active_kcal'), (s.get('total_distance_m') or 0) / 1000, result['coverage']],
'heartrate': [s.get('passive_mean_bpm'), s.get('resting_mean_bpm'), s.get('primary_min'), s.get('primary_max')],
'sleep': [s.get('mean_daily_asleep_minutes'), result['coverage'], s.get('mean_daily_deep_minutes'), s.get('nap_count')],
'workouts': [s['count'], s.get('primary_sum'), (s.get('total_distance_m') or 0) / 1000, s.get('total_calories_kcal')],
'body': [(s.get('latest') or {}).get('weight_kg'), (s.get('latest') or {}).get('bmi'), (s.get('latest') or {}).get('body_fat_pct'), s['count']],
'spo2': [s.get('primary_mean'), s.get('primary_min'), s.get('primary_max'), result['coverage']],
'stress': [s.get('primary_mean'), s.get('primary_min'), s.get('primary_max'), result['coverage']],
'abnormal': [s['count'], s.get('primary_sum'), result['coverage'], None],
}[kind]
def close_enough(actual, expected):
if isinstance(expected, (int, float)):
assert math.isclose(actual, expected, abs_tol=1e-8), (actual, expected)
elif isinstance(expected, list):
assert len(actual) == len(expected)
for a, b in zip(actual, expected):
close_enough(a, b)
elif isinstance(expected, dict):
for key, value in expected.items():
close_enough(actual[key], value)
else:
assert actual == expected, (actual, expected)
def consistency(db, node):
old, new = QueryService(db, OWNER), ViewQueryService(db, OWNER)
checked = []
for days, start in [(7, '2026-10-11'), (30, '2026-09-18')]:
for kind in KINDS:
selection = ViewSelection(kind, start, '2026-10-17')
rows = getattr(old, METHODS[kind])(start, selection.end_date)
expected = json.loads(subprocess.run([node, str(ROOT / 'qa/view-statistics-oracle.cjs')], cwd=ROOT,
input=encoded(dict(kind=kind, rows=rows, range=dict(start=start, end=selection.end_date))).decode('utf-8'),
text=True, encoding='utf-8', capture_output=True, check=True).stdout)
result = new.get_view(selection)
if kind == 'body':
# 多设备同刻的旧接口次序未定义,冻结oracle保持原样;单独按批准规格验新主键次序。
with db._get_connection() as conn:
stable = dict(conn.execute('SELECT id,weight_kg,bmi,body_fat_pct FROM body_measurements WHERE user_id=? ORDER BY timestamp DESC,id DESC LIMIT 1', (OWNER,)).fetchone())
assert result['statistics']['latest']['id'] == stable['id']
close_enough(cards(kind, result)[:3], [stable['weight_kg'], stable['bmi'], stable['body_fat_pct']])
assert cards(kind, result)[3] == expected['cards'][3]
close_enough(result['statistics']['primary_mean'], sum(row['weight_kg'] for row in rows) / len(rows))
for point in result['points']:
with db._get_connection() as conn:
wanted = conn.execute('SELECT weight_kg FROM body_measurements WHERE user_id=? AND substr(timestamp,1,10)=? ORDER BY timestamp DESC,id DESC LIMIT 1', (OWNER, point['date'])).fetchone()[0]
close_enough(point['values'], [wanted])
else:
close_enough(cards(kind, result), expected['cards'])
close_enough(result['points'], expected['points'])
assert result['coverage'] == expected['coverage']
assert result['statistics']['count'] == expected['count']
# CSV 与旧查询逐行业务字段集合核对;投影附加字段另由专项测试验证。
data = b''.join(_view_csv_chunks(new, selection)).decode('utf-8-sig')
parsed = list(csv.reader(io.StringIO(data)))
columns = [field for field, _ in VIEW_CSV_COLUMNS[kind]]
shared = [field for field in columns if all(field in row for row in rows)]
def cell(value):
return '' if value is None else json.dumps(value, ensure_ascii=False) if isinstance(value, (list, dict)) else str(int(value)) if isinstance(value, bool) else str(value)
actual = Counter(tuple(row[columns.index(field)] for field in shared) for row in parsed[1:])
wanted = Counter(tuple(str(bool(row.get(field))) if field == 'is_nap' else cell(row.get(field)) for field in shared) for row in rows)
assert actual == wanted, (kind, days, 'CSV集合不一致')
# 有界31条检查分页并集;body/workouts/abnormal 包含相同时间的稳定id次序。
limited = replace(selection, limit=31)
exported = list(new.iter_export(limited))
pages = sum([new.get_page(limited, page)['page']['rows'] for page in range(1, (len(exported) + 9) // 10 + 1)], [])
assert pages == exported
assert len({row['id'] for row in pages}) == len(pages)
assert new.get_page(limited, 1)['page']['rows'] == pages[:10]
checked.append(dict(kind=kind, days=days, count=len(rows), csv_rows=len(parsed) - 1, pagination_rows=len(pages)))
return checked
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', default=str(ROOT / 'artifacts/query-summary/benchmark.json'))
parser.add_argument('--node', default=shutil.which('node') or 'D:/nodejs/node.exe')
args = parser.parse_args()
output = artifact_path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
report = dict(scope='本机合成数据;没有NAS性能实测', measurements=[], consistency=[])
for density in (1, 10):
path = output.parent / f'synthetic-{density}x.db'
if not path.exists():
generate(path, density)
db = ReadOnlyDatabase(path)
old, new = QueryService(db, OWNER), ViewQueryService(db, OWNER)
for kind in ('heartrate', 'spo2', 'stress'):
rows, baseline = measured(lambda: getattr(old, METHODS[kind])('2026-09-18', '2026-10-17'))
raw_bytes = len(encoded(dict(status='ok', count=len(rows), data=rows)))
raw_count = len(rows)
del rows
result, summary = measured(lambda: new.get_view(ViewSelection(kind, '2026-09-18', '2026-10-17')))
assert set(result) == {'status', 'kind', 'range', 'statistics', 'points', 'coverage', 'stride', 'page'}
assert len(result['page']['rows']) == 10 and len(result['points']) <= 120
row = dict(density=density, kind=kind, raw_count=raw_count, raw_json_bytes=raw_bytes, summary_bytes=len(encoded(result)), detail_rows=len(result['page']['rows']), point_count=len(result['points']), baseline=baseline, summary=summary)
report['measurements'].append(row)
print(json.dumps(row), flush=True)
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
if density == 1:
report['consistency'] = consistency(db, args.node)
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
test_payload_does_not_scale_with_raw_samples(report['measurements'])
report['test_payload_does_not_scale_with_raw_samples'] = 'PASS'
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print('PASS: 16组统计/CSV/分页对照;1x/10x首屏有界。')
if __name__ == '__main__':
main()
+139
View File
@@ -0,0 +1,139 @@
"""仅本机合成数据入口;不加载用户配置、凭据或同步适配器。"""
import argparse
import json
import os
import sqlite3
import threading
import time
from contextlib import asynccontextmanager, contextmanager
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from mi_fitness_mcp.storage import Database
ROOT = Path(__file__).resolve().parents[1]
KINDS = ('activity', 'heartrate', 'sleep', 'workouts', 'body', 'spo2', 'stress', 'abnormal')
OWNER = 'synthetic-owner'
KEY = 'synthetic-query-key'
PASSWORD = 'synthetic-web-password'
def artifact_path(value):
path = Path(value).resolve()
if not path.is_relative_to(ROOT / 'artifacts'):
raise ValueError('合成数据和输出必须位于本仓库 artifacts 目录')
return path
def generate(path, density=1):
path = artifact_path(path)
if path.exists():
raise FileExistsError('拒绝覆盖已有文件:' + str(path))
db = Database(path)
tz = timezone(timedelta(hours=8))
with db._get_connection() as conn:
def insert(table, rows):
fields = ('id', 'provider', 'source_type', 'user_id') + tuple(rows[0])
conn.executemany(f"INSERT INTO {table} ({','.join(fields)}) VALUES ({','.join('?' for _ in fields)})",
[(f'{table}-{offset:08}', 'synthetic', 'generated', OWNER, *row.values()) for offset, row in enumerate(rows)])
for table, count, field in [('heart_rate_samples', 3000, 'bpm'), ('spo2_samples', 1000, 'spo2_pct'), ('stress_samples', 1000, 'stress_score')]:
rows = []
for day in range(30):
base = datetime(2026, 9, 18, tzinfo=tz) + timedelta(days=day)
for n in range(count * density):
stamp = (base + timedelta(seconds=n * 86400 / (count * density))).isoformat()
row = dict(timestamp=stamp)
row[field] = 55 + n % 70 if field == 'bpm' else 92 + n % 9 if field == 'spo2_pct' else n % 102
if field == 'bpm':
row['sample_type'] = ('passive', 'resting', 'active')[n % 3]
if field == 'stress_score':
row['level'] = 'synthetic-source'
rows.append(row)
insert(table, rows)
for table in ('daily_activity', 'sleep_sessions', 'workouts', 'body_measurements', 'abnormal_heart_beat_events'):
rows = []
for day in range(30):
d = date(2026, 9, 18) + timedelta(days=day)
stamp = d.isoformat() + 'T08:00:00+08:00'
for n in range(2):
if table == 'daily_activity':
row = dict(date=d.isoformat(), device_id=f'device-{n}', steps=2000 + day * 10 + n, distance_m=1800 + day, active_kcal=100 + n, total_kcal=1000 + day)
elif table == 'sleep_sessions':
row = dict(sleep_id=f's-{day}-{n}', start_at=(d - timedelta(days=1)).isoformat() + 'T23:00:00+08:00', end_at=stamp, duration_minutes=495 if n == 0 else 30, time_asleep_minutes=480 if n == 0 else 30, time_awake_minutes=15 if n == 0 else 0, is_nap=n, stages=json.dumps([dict(stage='deep', minutes=120), dict(stage='light', minutes=280), dict(stage='rem', minutes=80)]) if n == 0 else None)
elif table == 'workouts':
row = dict(workout_id=f'w-{day}-{n}', activity_type='run', start_at=stamp, end_at=d.isoformat() + 'T09:00:00+08:00', duration_minutes=60, distance_m=8000, calories_kcal=400)
elif table == 'body_measurements':
row = dict(timestamp=stamp, device_id=f'device-{n}', weight_kg=70 + day / 10 + n / 100, bmi=22, body_fat_pct=20)
else:
row = dict(event_id=f'a-{day}-{n}', start_at=stamp, end_at=d.isoformat() + 'T08:01:00+08:00', duration_seconds=60)
rows.append(row)
insert(table, rows)
conn.execute('CREATE TABLE api_keys (key TEXT PRIMARY KEY,user_id TEXT,pass_token TEXT,region TEXT,revoked INTEGER DEFAULT 0)')
conn.executemany('INSERT INTO api_keys VALUES (?,?,?,?,0)', [(KEY, OWNER, '', 'cn'), ('synthetic-other-key', 'synthetic-other', '', 'cn')])
conn.commit()
return path
class ReadOnlyDatabase(Database):
def __init__(self, path):
self.db_path = artifact_path(path)
if not self.db_path.is_file():
raise FileNotFoundError(self.db_path)
@contextmanager
def _get_connection(self):
conn = sqlite3.connect(self.db_path.as_uri() + '?mode=ro', uri=True)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
def backend(path):
# 替换 lifespan 只为隔离配置和密钥环;路由、鉴权、查询服务使用产品实现。
from mi_fitness_mcp import api
from mi_fitness_mcp.services.query_service import QueryService
db = ReadOnlyDatabase(path)
@asynccontextmanager
async def lifespan(app):
app.state.db = db
app.state.default_context = None
app.state.key_contexts = {}
app.state.key_context_lock = threading.RLock()
for key, owner in [(KEY, OWNER), ('synthetic-other-key', 'synthetic-other')]:
ctx = api.UserContext(owner, 'synthetic-unused', 'cn', query_service=QueryService(db, owner))
app.state.key_contexts[key] = dict(expires=time.monotonic() + 86400, identity=(owner, 'cn', ''), context=ctx)
yield
api.app.router.lifespan_context = lifespan
return api.app
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('mode', choices=['generate', 'backend', 'web'])
parser.add_argument('--db', default=str(ROOT / 'artifacts/query-summary/synthetic-1x.db'))
parser.add_argument('--density', type=int, choices=[1, 10], default=1)
parser.add_argument('--port', type=int, default=18431)
parser.add_argument('--backend-url', default='http://127.0.0.1:18431')
parser.add_argument('--server-key', action='store_true')
parser.add_argument('--host', default='127.0.0.1', choices=['127.0.0.1', '0.0.0.0'])
args = parser.parse_args()
for name in ('MI_FITNESS_API_KEY', 'MI_FITNESS_ADMIN_KEY'):
os.environ.pop(name, None)
os.environ['MI_FITNESS_ALLOWED_HOSTS'] = '127.0.0.1,localhost,backend,web'
os.environ['MI_FITNESS_WEB_PASSWORD'] = PASSWORD
os.environ['MI_FITNESS_CALENDAR_DIR'] = str(ROOT / 'artifacts/query-summary/calendar')
if args.mode == 'generate':
print(generate(args.db, args.density))
elif args.mode == 'backend':
import uvicorn
uvicorn.run(backend(args.db), host=args.host, port=args.port, access_log=False)
else:
from mi_fitness_mcp.web import create_app
app = create_app(backend_url=args.backend_url, api_key=KEY if args.server_key else None)
app.run(host=args.host, port=args.port, debug=False, use_reloader=False)
if __name__ == '__main__':
main()
+128
View File
@@ -0,0 +1,128 @@
/* 本机 Edge 独立profile真实DOM验收,无第三方依赖。仅访问合成fixture。 */
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const {spawn}=require('node:child_process');
const ROOT=path.resolve(__dirname,'..');
const OUT=path.resolve(process.env.QUERY_QA_OUTPUT||path.join(ROOT,'artifacts/query-summary/browser'));
assert(OUT.startsWith(path.join(ROOT,'artifacts')+path.sep));
const EDGE=process.env.QUERY_QA_EDGE||'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe';
const BASE=process.env.QUERY_QA_URL||'http://127.0.0.1:18432';
assert(['127.0.0.1','localhost'].includes(new URL(BASE).hostname),'只允许本机fixture');
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
class CDP{
constructor(ws){this.ws=ws;this.next=0;this.pending=new Map();this.handlers=new Map();ws.addEventListener('message',event=>{const m=JSON.parse(event.data);if(m.id){const p=this.pending.get(m.id);this.pending.delete(m.id);m.error?p.reject(Error(JSON.stringify(m.error))):p.resolve(m.result);}else for(const fn of this.handlers.get(m.method)||[])fn(m.params,m.sessionId);});}
send(method,params={},sessionId){return new Promise((resolve,reject)=>{const id=++this.next;this.pending.set(id,{resolve,reject});this.ws.send(JSON.stringify({id,method,params,sessionId}));});}
on(method,fn){if(!this.handlers.has(method))this.handlers.set(method,[]);this.handlers.get(method).push(fn);}
}
async function main(){
fs.mkdirSync(OUT,{recursive:true});
const profile=path.join(OUT,'profile-'+Date.now());
const edge=spawn(EDGE,['--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check','--disable-background-networking','--remote-debugging-port=0','--remote-debugging-address=127.0.0.1','--user-data-dir='+profile,'about:blank'],{windowsHide:true,stdio:'ignore'});
let cdp;
try{
let port;for(let n=0;n<100;n++){try{port=fs.readFileSync(path.join(profile,'DevToolsActivePort'),'utf8').split('\n')[0];break;}catch{await sleep(100);}}
assert(port,'Edge未就绪');
const info=await(await fetch('http://127.0.0.1:'+port+'/json/version')).json();
const ws=new WebSocket(info.webSocketDebuggerUrl);await new Promise((resolve,reject)=>{ws.addEventListener('open',resolve,{once:true});ws.addEventListener('error',reject,{once:true});});cdp=new CDP(ws);
const {targetId}=await cdp.send('Target.createTarget',{url:'about:blank'});
const {sessionId:sid}=await cdp.send('Target.attachToTarget',{targetId,flatten:true});
const send=(m,p)=>cdp.send(m,p,sid);
const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});assert(!r.exceptionDetails,JSON.stringify(r.exceptionDetails));return r.result.value;};
const requests=[],errors=[],downloads=[],downloadStarts=[],responses=[],report={base:BASE,scope:'本机合成fixture;未接触用户浏览器或NAS',views:[],downloads:[],failures:[]};
cdp.on('Network.requestWillBeSent',(p,s)=>{if(s===sid)requests.push({id:p.requestId,url:p.request.url,method:p.request.method});});
cdp.on('Network.responseReceived',(p,s)=>{if(s===sid)responses.push({id:p.requestId,url:p.response.url,status:p.response.status,bytes:p.response.encodedDataLength});});
cdp.on('Runtime.exceptionThrown',(p,s)=>{if(s===sid)errors.push(p.exceptionDetails);});
cdp.on('Browser.downloadProgress',p=>downloads.push(p));
cdp.on('Browser.downloadWillBegin',p=>downloadStarts.push(p));
await send('Runtime.enable');await send('Network.enable');await send('Page.enable');
// 通过真实Basic挑战建立浏览器凭据缓存;注入请求头无法代表原生GET附件认证。
cdp.on('Fetch.requestPaused',(p,s)=>{if(s===sid)send('Fetch.continueRequest',{requestId:p.requestId}).catch(()=>{});});
cdp.on('Fetch.authRequired',(p,s)=>{if(s===sid)send('Fetch.continueWithAuth',{requestId:p.requestId,authChallengeResponse:{response:'ProvideCredentials',username:'admin',password:'synthetic-web-password'}}).catch(()=>{});});
await send('Fetch.enable',{handleAuthRequests:true});
await cdp.send('Browser.setDownloadBehavior',{behavior:'allow',downloadPath:OUT,eventsEnabled:true});
await send('Page.addScriptToEvaluateOnNewDocument',{source:"localStorage.setItem('mi_fitness_api_key','synthetic-query-key');"});
await send('Page.navigate',{url:BASE});
for(let n=0;n<100;n++){if(await evaluate("typeof switchTab==='function' && document.readyState==='complete'"))break;await sleep(100);}
if(process.env.QUERY_QA_SCREENSHOTS_ONLY==='1'){
await send('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:1,mobile:false});
await evaluate("(async()=>{$('startDate').value='2026-09-18';$('endDate').value='2026-10-17';$('hrLimit').value='';await switchTab('heartrate');})()");
await sleep(4500);
for(const theme of ['dark','light']){
await evaluate(`state.theme='${theme}';document.documentElement.setAttribute('data-theme','${theme}');`);
fs.writeFileSync(path.join(OUT,theme+'.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
}
await send('Emulation.setDeviceMetricsOverride',{width:390,height:1100,deviceScaleFactor:1,mobile:false});
fs.writeFileSync(path.join(OUT,'narrow.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
console.log('PASS: 1440px深浅主题与390px窄屏截图;只重拍截图,未重复性能/整体验证。');return;
}
await evaluate("$('startDate').value='2026-10-11';$('endDate').value='2026-10-17';loadOverview()");
assert.equal(await evaluate("$('statSteps').textContent.includes('--')"),false);
for(const days of [7,30])for(const kind of ['activity','heartrate','sleep','workouts','body','spo2','stress','abnormal']){
const start=days===7?'2026-10-11':'2026-09-18',before=requests.length;
const elapsed=await evaluate(`(async()=>{$('startDate').value='${start}';$('endDate').value='2026-10-17';$('hrLimit').value='';$('spo2Limit').value='';$('stressLimit').value='';$('latestOnlyCheck').checked=false;const t=performance.now();await switchTab('${kind}');return performance.now()-t;})()`);
const value=await evaluate(`(()=>{const m=state.healthViews.${kind}?.model;return m?{rows:m.records.length,total:m.total,points:m.points.length,dom:document.querySelectorAll('*').length,html:$('healthView-${kind}').innerHTML,cards:m.cards.map(c=>c.raw),references:m.references}:null;})()`);
assert(value,kind+'未生成模型');assert(value.rows<=10);assert(value.points<=120);assert(value.html.includes('<svg'),kind+'缺图表');
const relevant=requests.slice(before).filter(r=>new URL(r.url).pathname==='/proxy/api/views/'+kind);
assert.equal(relevant.length,1,kind+'首屏请求次数');
const response=responses.find(r=>r.id===relevant[0].id);assert.equal(response.status,200);
const body=await send('Network.getResponseBody',{requestId:relevant[0].id});const bytes=Buffer.byteLength(body.body);
report.views.push({days,kind,detailRows:value.rows,total:value.total,points:value.points,domNodes:value.dom,summaryBytes:bytes,loadMs:Math.round(elapsed*100)/100});
if(value.total>10){const chart=await evaluate(`$('healthView-${kind}').querySelector('svg').outerHTML`);await evaluate(`healthPage('${kind}',2)`);assert.equal(await evaluate(`state.healthViews.${kind}.page`),2);assert.equal(await evaluate(`$('healthView-${kind}').querySelector('svg').outerHTML`),chart);assert(requests.slice(before).some(r=>r.url.includes('/records?')&&r.url.includes('page=2')));}
}
await evaluate("(async()=>{$('hrLimit').value='31';await switchTab('heartrate');})()");
assert(await evaluate("document.querySelector('#healthView-heartrate svg').innerHTML.includes('40')"),'心率纵轴下限');
assert(await evaluate("state.healthViews.heartrate.model.references.length>0"),'参考线');
for(let n=0;n<30;n++){if(await evaluate("Object.keys(state.healthViews.heartrate.model.range.calendar||{}).length>0"))break;await sleep(100);}
assert(await evaluate("Object.keys(state.healthViews.heartrate.model.range.calendar||{}).length>0"),'日历未载入');
assert(await evaluate("document.querySelector('#healthView-heartrate svg').innerHTML.includes('#22a06b')"),'非工作日颜色');
for(const theme of ['light','dark']){
await evaluate(`if(state.theme!=='${theme}')toggleTheme();`);
assert.equal(await evaluate("document.documentElement.dataset.theme"),theme);
const image=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:false});fs.writeFileSync(path.join(OUT,theme+'.png'),Buffer.from(image.data,'base64'));
}
await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:false});
fs.writeFileSync(path.join(OUT,'narrow.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await send('Emulation.clearDeviceMetricsOverride');
// 手填、扫码state、server优先(独立fixture端口)均使用原生附件,不在浏览器读取CSV。
for(const mode of ['input','state']){
await evaluate(`(async()=>{state.apiKey=${mode==='state'?"'synthetic-query-key'":"''"};$('apiKeyInput').value='synthetic-query-key';$('hrLimit').value='31';await loadHealthView('heartrate');exportHealthRecords('heartrate');})()`);
let completed;for(let n=0;n<150;n++){completed=downloads.filter(d=>d.state==='completed').length;if(completed>=report.downloads.length+1)break;await sleep(100);}
assert(completed>=report.downloads.length+1,'原生附件未完成');
const file=path.join(OUT,'mi_fitness_heartrate_2026-09-18_2026-10-17.csv');assert(fs.existsSync(file));
const csv=fs.readFileSync(file,'utf8');assert.equal(csv.trim().split('\n').length,32);
assert.equal(await evaluate("document.querySelectorAll('form input[name=api_key]').length"),0);
const request=requests.filter(r=>r.url.includes('/heartrate/export?')).at(-1);assert.equal(request.method,'POST');assert(!request.url.includes('synthetic'));
fs.renameSync(file,path.join(OUT,mode+'.csv'));report.downloads.push({mode,method:request.method,bytes:Buffer.byteLength(csv),rows:31});
}
const serverBase=process.env.QUERY_QA_SERVER_URL||'http://127.0.0.1:18433';
assert(['127.0.0.1','localhost'].includes(new URL(serverBase).hostname));
await send('Page.navigate',{url:serverBase});
for(let n=0;n<100;n++){if(await evaluate("typeof switchTab==='function' && document.readyState==='complete' && location.port==='"+new URL(serverBase).port+"'"))break;await sleep(100);}
for(const mode of ['server-only','server-priority']){
await evaluate(`(async()=>{state.apiKey=${mode==='server-priority'?"'synthetic-other-key'":"''"};$('apiKeyInput').value='';$('startDate').value='2026-09-18';$('endDate').value='2026-10-17';$('hrLimit').value='31';await switchTab('heartrate');exportHealthRecords('heartrate');})()`);
assert.equal(await evaluate('state.healthViews.heartrate.model.total'),31,'服务端Key身份应优先');
let completed;for(let n=0;n<150;n++){completed=downloads.filter(d=>d.state==='completed').length;if(completed>=report.downloads.length+1)break;await sleep(100);}
if(completed<report.downloads.length+1)fs.writeFileSync(path.join(OUT,'download-failure.json'),JSON.stringify({mode,downloads,requests:requests.slice(-8),responses:responses.slice(-8),errors},null,2));
assert(completed>=report.downloads.length+1,'server Key原生附件未完成');
const file=path.join(OUT,'mi_fitness_heartrate_2026-09-18_2026-10-17.csv'),csv=fs.readFileSync(file,'utf8');
assert.equal(csv,fs.readFileSync(path.join(OUT,'input.csv'),'utf8'),'摘要/附件合成身份不一致');
const download=downloadStarts.at(-1);assert(new URL(download.url).pathname==='/proxy/api/views/heartrate/export');assert(!download.url.includes('synthetic'));
// GET原生链接target=_blank,下载由Browser事件观测;POST在当前页另验真实method。
const method=mode==='server-only'?'GET (native anchor)':'POST';
if(mode==='server-priority')assert.equal(requests.filter(r=>r.url.includes('/heartrate/export?')).at(-1).method,'POST');
fs.renameSync(file,path.join(OUT,mode+'.csv'));report.downloads.push({mode,method,bytes:Buffer.byteLength(csv),rows:31});
}
// 真实代理故障和取消;取消只应留下当前范围的模型。
await evaluate("(async()=>{const saved=window.fetch;window.fetch=(url,options)=>url.includes('/views/stress?')?Promise.resolve(new Response('{}',{status:503})):saved(url,options);await switchTab('stress');window.fetch=saved;})()");
assert(await evaluate("$('healthView-stress').textContent.includes('查询失败')"));
await evaluate("(async()=>{const original=window.fetch;window.fetch=(url,opt)=>url.includes('/views/stress?')?new Promise(resolve=>setTimeout(()=>resolve(new Response(JSON.stringify({status:'error'}))),300)):original(url,opt);const old=switchTab('stress');await switchTab('body');await old;window.fetch=original;})()");
assert.equal(await evaluate('state.activeTab'),'body');assert(await evaluate('!!state.healthViews.body?.model'));
const raw=requests.filter(r=>/\/proxy\/api\/(heart-rate|sleep|workouts|body-measurements|spo2|stress|abnormal-heart-beat|summary|metric-series)(\?|$)/.test(r.url));assert.deepEqual(raw,[],'浏览器不应调用全量原始接口');
report.pageExceptions=errors;report.rawRequests=raw;report.requestCount=requests.length;
fs.writeFileSync(path.join(OUT,'report.json'),JSON.stringify(report,null,2)+'\n','utf8');
assert.deepEqual(errors,[]);
console.log(JSON.stringify(report,null,2));
}finally{if(cdp){await cdp.send('Browser.close').catch(()=>{});cdp.ws.close();}else edge.kill();}
}
main().catch(error=>{console.error(error);process.exitCode=1;});