mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-17 18:55:37 +08:00
qa: validate server queries and prepare offline backend web update
This commit is contained in:
1 parent
6292755ff9
commit
c076bbc0d6
11 files changed
+640
-1
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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;});
|
||||
Reference in new issue
Block a user