mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-18 03:03:49 +08:00
feat: add SQL health view summaries and stable pagination
This commit is contained in:
1 parent
497fd1c1b0
commit
9dbfcd73f8
5 files changed
+734
No files matched your search
@@ -0,0 +1,232 @@
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
from mi_fitness_mcp.storage import Database
|
||||
from mi_fitness_mcp.storage.view_queries import ViewRepository, ViewSelection
|
||||
from mi_fitness_mcp.services.view_query_service import ViewQueryService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return Database(tmp_path / 'synthetic.db')
|
||||
|
||||
|
||||
def insert(db, table, **fields):
|
||||
fields = {'provider': 'synthetic', 'source_type': 'test', 'user_id': 'owner', **fields}
|
||||
with db._get_connection() as connection:
|
||||
connection.execute(
|
||||
f"INSERT INTO {table} ({','.join(fields)}) VALUES ({','.join('?' for _ in fields)})",
|
||||
tuple(fields.values()),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def test_selection_before_limit_and_paging(db):
|
||||
for n in range(31):
|
||||
insert(db, 'stress_samples', id=f's{n:02}', timestamp=f'2026-10-15T00:{n:02}:00Z',
|
||||
stress_score=26 + n if n < 25 else 90, level='source-level')
|
||||
service = ViewQueryService(db, 'owner')
|
||||
selection = ViewSelection('stress', '2026-10-15', '2026-10-15', pressure_level='mild', limit=11)
|
||||
result = service.get_view(selection)
|
||||
assert result['statistics']['count'] == 11
|
||||
assert result['statistics']['primary_mean'] == pytest.approx(sum(range(40, 51)) / 11)
|
||||
assert result['page']['total'] == 11
|
||||
assert len(result['page']['rows']) == 10
|
||||
page = service.get_page(selection, 2)
|
||||
assert set(page) == {'status', 'kind', 'range', 'page'}
|
||||
assert len(page['page']['rows']) == 1
|
||||
exported = list(service.iter_export(selection))
|
||||
assert exported == result['page']['rows'] + page['page']['rows']
|
||||
assert [r['stress_score'] for r in exported] == list(range(50, 39, -1))
|
||||
assert all(row['level'] == 'source-level' for row in exported)
|
||||
|
||||
|
||||
def test_equal_timestamps_stable_order_and_owner_isolation(db):
|
||||
for n in range(24):
|
||||
insert(db, 'body_measurements', id=f'b{n:02}', timestamp='2026-10-15T00:00:00Z',
|
||||
device_id=f'private-device-{n}', weight_kg=60 + n)
|
||||
insert(db, 'body_measurements', id='outsider', user_id='other', device_id='other',
|
||||
timestamp='2026-10-15T00:00:00Z', weight_kg=999)
|
||||
service = ViewQueryService(db, 'owner')
|
||||
selection = ViewSelection('body', '2026-10-15', '2026-10-15')
|
||||
pages = [service.get_page(selection, n)['page']['rows'] for n in (1, 2, 3)]
|
||||
records = sum(pages, [])
|
||||
assert records == list(service.iter_export(selection))
|
||||
assert pages[0] == service.get_page(selection)['page']['rows']
|
||||
assert [r['weight_kg'] for r in records] == list(range(83, 59, -1))
|
||||
assert len({r['id'] for r in records}) == 24
|
||||
assert not any(set(row) & {'user_id', 'device_id', 'source_record_id', 'provider'} for row in records)
|
||||
limited = service.get_view(ViewSelection('body', '2026-10-15', '2026-10-15', latest_only=True))
|
||||
assert limited['statistics']['count'] == 1
|
||||
assert limited['statistics']['latest']['weight_kg'] == 83
|
||||
for n, sample_type in enumerate(['resting', 'passive', 'active']):
|
||||
insert(db, 'heart_rate_samples', id=f'h{n}', timestamp='2026-10-15T00:00:00Z',
|
||||
bpm=60 + n * 10, sample_type=sample_type)
|
||||
resting = service.get_view(ViewSelection('heartrate', '2026-10-15', '2026-10-15', sample_type='resting', limit=1))
|
||||
assert resting['statistics']['primary_mean'] == 60
|
||||
assert resting['page']['rows'][0]['sample_type'] == 'resting'
|
||||
|
||||
|
||||
def test_china_midnight_sleep_and_nulls(db):
|
||||
insert(db, 'sleep_sessions', id='night', sleep_id='night', start_at='2026-10-14T16:00:00Z',
|
||||
end_at='2026-10-15T00:15:00Z', duration_minutes=495, time_asleep_minutes=480,
|
||||
time_awake_minutes=15, stages=json.dumps([{'stage': 'deep', 'minutes': 120}]))
|
||||
insert(db, 'sleep_sessions', id='nap', sleep_id='nap', start_at='2026-10-15T05:00:00Z',
|
||||
end_at='2026-10-15T05:30:00Z', duration_minutes=30, time_asleep_minutes=30,
|
||||
time_awake_minutes=0, is_nap=1, stages=None)
|
||||
insert(db, 'sleep_sessions', id='next', sleep_id='next', start_at='2026-10-15T15:00:00Z',
|
||||
end_at='2026-10-15T16:30:00Z', duration_minutes=90, time_asleep_minutes=90,
|
||||
time_awake_minutes=0, stages='not-json')
|
||||
service = ViewQueryService(db, 'owner')
|
||||
result = service.get_view(ViewSelection('sleep', '2026-10-15', '2026-10-17'))
|
||||
assert result['statistics']['primary_sum'] == 600
|
||||
assert result['statistics']['mean_daily_asleep_minutes'] == 300
|
||||
assert result['statistics']['mean_daily_deep_minutes'] == 120
|
||||
assert result['statistics']['nap_count'] == 1
|
||||
assert result['coverage'] == 2
|
||||
assert result['points'][0]['values'] == [2, None, None, 6.5]
|
||||
assert result['points'][1]['values'] == [None, None, None, 1.5]
|
||||
assert result['points'][2]['missing'] is True
|
||||
assert service.get_view(ViewSelection('sleep', '2026-10-15', '2026-10-15', include_naps=False))['statistics']['primary_sum'] == 480
|
||||
for n, timestamp in enumerate(['2026-10-14T16:00:00Z', '2026-10-15T00:00:00+08:00', '2026-10-14 16:00:00']):
|
||||
insert(db, 'heart_rate_samples', id=f't{n}', timestamp=timestamp, bpm=60 + n, sample_type='passive')
|
||||
assert service.get_view(ViewSelection('heartrate', '2026-10-15', '2026-10-15'))['statistics']['count'] == 3
|
||||
insert(db, 'stress_samples', id='zero', timestamp='2026-10-14T16:00:00Z', stress_score=0, level='zero')
|
||||
zero = service.get_view(ViewSelection('stress', '2026-10-15', '2026-10-16'))
|
||||
assert zero['statistics']['primary_mean'] == 0
|
||||
assert zero['points'][1]['values'] == [None]
|
||||
|
||||
|
||||
def test_sleep_daily_report_supersedes_raw_and_old_report(db):
|
||||
for row_id, source, collected, minutes in [('raw', 'raw:1', '2026-10-15', 400),
|
||||
('old', 'daily_report:1', '2026-10-15T01:00:00Z', 450),
|
||||
('new', 'daily_report:2', '2026-10-15T02:00:00Z', 480)]:
|
||||
insert(db, 'sleep_sessions', id=row_id, sleep_id=row_id, source_record_id=source,
|
||||
collected_at=collected, start_at='2026-10-14T15:00:00Z', end_at='2026-10-14T23:00:00Z',
|
||||
duration_minutes=minutes, time_asleep_minutes=minutes, time_awake_minutes=0)
|
||||
result = ViewQueryService(db, 'owner').get_view(ViewSelection('sleep', '2026-10-15', '2026-10-15'))
|
||||
assert result['statistics']['count'] == 1
|
||||
assert result['statistics']['primary_sum'] == 480
|
||||
|
||||
|
||||
def test_body_real_instant_order_precedes_text_and_id(db):
|
||||
for row_id, timestamp, weight in [('z', '2026-10-15T09:00:00+08:00', 80),
|
||||
('a', '2026-10-15T02:00:00Z', 70), ('y', '2026-10-14T15:59:59Z', 999)]:
|
||||
insert(db, 'body_measurements', id=row_id, timestamp=timestamp, weight_kg=weight)
|
||||
service = ViewQueryService(db, 'owner')
|
||||
result = service.get_view(ViewSelection('body', '2026-10-15', '2026-10-15', limit=1))
|
||||
assert result['statistics']['latest']['id'] == 'a'
|
||||
assert result['statistics']['primary_mean'] == 70
|
||||
assert result['points'][0]['values'] == [70]
|
||||
|
||||
|
||||
def test_submillisecond_timestamps_still_order_by_real_instant(db):
|
||||
for row_id, timestamp, weight in [
|
||||
('z', '2026-10-15T09:00:00.000100+08:00', 80),
|
||||
('a', '2026-10-15T01:00:00.000200Z', 70),
|
||||
('x', '2026-10-15T01:00:00.999900Z', 60),
|
||||
('b', '2026-10-15T01:00:01.000000Z', 50),
|
||||
]:
|
||||
insert(db, 'body_measurements', id=row_id, timestamp=timestamp, weight_kg=weight)
|
||||
rows = list(ViewQueryService(db, 'owner').iter_export(ViewSelection('body', '2026-10-15', '2026-10-15')))
|
||||
assert [row['id'] for row in rows] == ['b', 'x', 'a', 'z']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('level, expected', [('relaxed', [25, 1]), ('mild', [50, 26]),
|
||||
('moderate', [80, 51]), ('severe', [100, 81]), ('unclassified', [101, 0])])
|
||||
def test_pressure_numeric_bands_ignore_source_level(db, level, expected):
|
||||
for n, value in enumerate([0, 1, 25, 26, 50, 51, 80, 81, 100, 101]):
|
||||
insert(db, 'stress_samples', id=str(n), timestamp=f'2026-10-15T00:{n:02}:00Z',
|
||||
stress_score=value, level='unrelated-source-value')
|
||||
rows = list(ViewQueryService(db, 'owner').iter_export(ViewSelection('stress', '2026-10-15', '2026-10-15', pressure_level=level)))
|
||||
assert [row['stress_score'] for row in rows] == expected
|
||||
|
||||
|
||||
def test_export_close_releases_short_read_transaction(db, monkeypatch):
|
||||
insert(db, 'stress_samples', id='one', timestamp='2026-10-15T00:00:00Z', stress_score=26, level='source')
|
||||
original = ViewRepository.snapshot
|
||||
closed = []
|
||||
@contextmanager
|
||||
def observed(repository):
|
||||
with original(repository) as connection:
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
closed.append(True)
|
||||
monkeypatch.setattr(ViewRepository, 'snapshot', observed)
|
||||
generator = ViewQueryService(db, 'owner').iter_export(ViewSelection('stress', '2026-10-15', '2026-10-15'))
|
||||
assert next(generator)['stress_score'] == 26
|
||||
assert not closed
|
||||
generator.close()
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
def test_first_view_uses_one_read_snapshot(db, monkeypatch):
|
||||
service = ViewQueryService(db, 'owner')
|
||||
original = ViewRepository.snapshot
|
||||
connections = []
|
||||
|
||||
@contextmanager
|
||||
def observed(repository):
|
||||
with original(repository) as connection:
|
||||
assert connection.in_transaction
|
||||
connections.append(connection)
|
||||
yield connection
|
||||
|
||||
monkeypatch.setattr(ViewRepository, 'snapshot', observed)
|
||||
traces = []
|
||||
for method in ['statistics', 'points', 'page']:
|
||||
implementation = getattr(ViewRepository, method)
|
||||
|
||||
def wrapped(self, *args, _method=implementation, **kwargs):
|
||||
connection = kwargs.get('connection', args[-1])
|
||||
assert connection is connections[-1]
|
||||
assert connection.in_transaction
|
||||
traces.append(connection)
|
||||
return _method(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(ViewRepository, method, wrapped)
|
||||
service.get_view(ViewSelection('stress', '2026-10-15', '2026-10-15'))
|
||||
assert len(connections) == 1
|
||||
assert len(traces) == 3
|
||||
assert all(connection is connections[0] for connection in traces)
|
||||
|
||||
|
||||
def test_large_input_uses_sql_paging(db, monkeypatch):
|
||||
with db._get_connection() as connection:
|
||||
connection.executemany("INSERT INTO stress_samples(id,provider,source_type,user_id,timestamp,stress_score,level) VALUES(?,?,?,?,?,?,?)",
|
||||
[(f's{n:05}', 'synthetic', 'test', 'owner', f'2026-10-15T00:00:{n % 60:02}.{n:06}Z', n % 101, 'source') for n in range(20000)])
|
||||
connection.commit()
|
||||
def forbidden(*args, **kwargs):
|
||||
raise AssertionError('must aggregate and page in SQLite')
|
||||
monkeypatch.setattr(db, 'query_stress_samples', forbidden)
|
||||
monkeypatch.setattr(ViewRepository, 'iter_records', forbidden)
|
||||
statements = []
|
||||
original = ViewRepository.snapshot
|
||||
@contextmanager
|
||||
def observed(repository):
|
||||
with original(repository) as connection:
|
||||
connection.set_trace_callback(statements.append)
|
||||
yield connection
|
||||
monkeypatch.setattr(ViewRepository, 'snapshot', observed)
|
||||
result = ViewQueryService(db, 'owner').get_view(ViewSelection('stress', '2026-10-15', '2026-10-15'), 2)
|
||||
assert result['statistics']['count'] == 20000
|
||||
assert len(result['page']['rows']) == 10
|
||||
assert len(result['points']) == 1
|
||||
assert any('LIMIT 10 OFFSET 10' in sql for sql in statements)
|
||||
assert not any('SELECT * FROM stress_samples' in sql for sql in statements)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kwargs', [dict(kind='unknown'), dict(start_date='2026-02-30'), dict(end_date='2026-10-14'), dict(limit=0), dict(limit=True), dict(pressure_level='sql injection'), dict(start_date='2000-01-01')])
|
||||
def test_invalid_selections(kwargs):
|
||||
values = dict(kind='stress', start_date='2026-10-15', end_date='2026-10-16')
|
||||
with pytest.raises(ValueError):
|
||||
ViewSelection(**(values | kwargs))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('page', [0, -1, True, 1.5, 1000001])
|
||||
def test_invalid_pages(db, page):
|
||||
with pytest.raises(ValueError):
|
||||
ViewQueryService(db, 'owner').get_page(ViewSelection('stress', '2026-10-15', '2026-10-15'), page)
|
||||
@@ -0,0 +1,174 @@
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mi_fitness_mcp.storage import Database
|
||||
from mi_fitness_mcp.storage.view_queries import ViewSelection
|
||||
from mi_fitness_mcp.services.view_query_service import ViewQueryService
|
||||
from mi_fitness_mcp.services.query_service import QueryService
|
||||
|
||||
from test_view_queries import insert
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
NODE = shutil.which('node') or 'D:/nodejs/node.exe'
|
||||
|
||||
|
||||
def seed(db, kind):
|
||||
table, time_field, base = {
|
||||
'activity': ('daily_activity', 'date', dict(steps=100, distance_m=80, active_kcal=5)),
|
||||
'heartrate': ('heart_rate_samples', 'timestamp', dict(bpm=72, sample_type='passive')),
|
||||
'sleep': ('sleep_sessions', 'end_at', dict(sleep_id='s', start_at='2026-10-14T14:00:00Z', duration_minutes=495, time_asleep_minutes=480, time_awake_minutes=15, stages=json.dumps([dict(stage='deep', minutes=100), dict(stage='light', minutes=300), dict(stage='rem', minutes=80)]))),
|
||||
'workouts': ('workouts', 'start_at', dict(workout_id='w', end_at='2026-10-15T02:00:00Z', activity_type='run', duration_minutes=50, distance_m=8000, calories_kcal=400)),
|
||||
'body': ('body_measurements', 'timestamp', dict(weight_kg=75, bmi=22, body_fat_pct=20)),
|
||||
'spo2': ('spo2_samples', 'timestamp', dict(spo2_pct=96)),
|
||||
'stress': ('stress_samples', 'timestamp', dict(stress_score=0, level='source')),
|
||||
'abnormal': ('abnormal_heart_beat_events', 'start_at', dict(event_id='a', end_at='2026-10-15T02:00:00Z', duration_seconds=30)),
|
||||
}[kind]
|
||||
rows = []
|
||||
for n in range(4):
|
||||
row = dict(base, id=f'id{n}', **{time_field: f'2026-10-{15 + n // 3:02}' + ('' if kind == 'activity' else f'T0{n}:00:00Z')})
|
||||
if kind == 'activity':
|
||||
row.update(device_id=f'd{n}', steps=n * 100, total_kcal=None, active_minutes=None)
|
||||
elif kind == 'heartrate':
|
||||
row.update(bpm=60 + n * 20, sample_type=['resting', 'passive', 'passive', 'active'][n])
|
||||
elif kind == 'sleep':
|
||||
row.update(sleep_id=str(n), is_nap=n == 1)
|
||||
if n == 1:
|
||||
row.update(time_asleep_minutes=30, duration_minutes=30, stages=None)
|
||||
if n == 2:
|
||||
row['stages'] = json.dumps([dict(stage='deep', minutes=999)])
|
||||
elif kind == 'body':
|
||||
row['weight_kg'] = 75 - n
|
||||
elif kind == 'spo2':
|
||||
row['spo2_pct'] = 94 + n
|
||||
elif kind == 'stress':
|
||||
row['stress_score'] = [0, 26, 81, 101][n]
|
||||
elif kind == 'workouts':
|
||||
row['workout_id'] = str(n)
|
||||
elif kind == 'abnormal':
|
||||
row['event_id'] = str(n)
|
||||
insert(db, table, **row)
|
||||
if kind == 'sleep':
|
||||
row['stages'] = json.loads(row['stages']) if row['stages'] else None
|
||||
rows.append(row)
|
||||
if kind == 'activity':
|
||||
grouped = {}
|
||||
for row in rows:
|
||||
day = grouped.setdefault(row['date'], dict(date=row['date'], steps=0, distance_m=0, active_kcal=0, total_kcal=0, floors=0, active_minutes=0))
|
||||
for key in ['steps', 'distance_m', 'active_kcal', 'total_kcal', 'floors', 'active_minutes']:
|
||||
day[key] += row.get(key) or 0
|
||||
rows = list(grouped.values())
|
||||
return rows
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', ['activity', 'heartrate', 'sleep', 'workouts', 'body', 'spo2', 'stress', 'abnormal'])
|
||||
@pytest.mark.parametrize('end_date', ['2026-10-17', '2027-10-15'])
|
||||
def test_all_eight_kinds_match_frontend_baseline(tmp_path, kind, end_date):
|
||||
db = Database(tmp_path / 'synthetic.db')
|
||||
rows = seed(db, kind)
|
||||
expected = json.loads(subprocess.run([NODE, str(ROOT / 'qa/view-statistics-oracle.cjs')], cwd=ROOT,
|
||||
input=json.dumps(dict(kind=kind, rows=rows, range=dict(start='2026-10-15', end=end_date))), text=True,
|
||||
encoding='utf-8', capture_output=True, check=True).stdout)
|
||||
result = ViewQueryService(db, 'owner').get_view(ViewSelection(kind, '2026-10-15', end_date))
|
||||
s = result['statistics']
|
||||
cards = {
|
||||
'activity': [s.get('primary_sum'), s.get('total_active_kcal'), None if s.get('total_distance_m') is None else s['total_distance_m'] / 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'), None if s.get('total_distance_m') is None else s['total_distance_m'] / 1000, s.get('total_calories_kcal')],
|
||||
'body': [s.get('latest', {}).get('weight_kg'), s.get('latest', {}).get('bmi'), s.get('latest', {}).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]
|
||||
assert cards == pytest.approx(expected['cards'], abs=1e-9)
|
||||
assert result['coverage'] == expected['coverage']
|
||||
assert result['stride'] == expected['stride']
|
||||
assert s['count'] == expected['count']
|
||||
assert len(result['points']) <= 120
|
||||
for actual, wanted in zip(result['points'], expected['points'], strict=True):
|
||||
assert actual['date'] == wanted['date']
|
||||
assert actual['values'] == pytest.approx(wanted['values'], abs=1e-9)
|
||||
assert actual['missing'] == wanted['missing']
|
||||
if 'average' in expected['references']:
|
||||
reference = s['mean_daily_steps'] if kind == 'activity' else s['mean_daily_asleep_minutes'] / 60 if kind == 'sleep' else s['primary_mean']
|
||||
assert reference == pytest.approx(expected['references']['average'], abs=1e-9)
|
||||
|
||||
|
||||
def test_overview_and_empty_statistics(tmp_path):
|
||||
db = Database(tmp_path / 'synthetic.db')
|
||||
service = ViewQueryService(db, 'owner')
|
||||
empty = service.get_view(ViewSelection('stress', '2026-10-15', '2026-10-15'))
|
||||
assert empty['statistics']['primary_mean'] is None
|
||||
assert empty['statistics']['primary_sum'] is None
|
||||
assert empty['page'] == dict(number=1, size=10, total=0, rows=[])
|
||||
for kind in ['activity', 'heartrate', 'sleep', 'spo2', 'stress']:
|
||||
seed(db, kind)
|
||||
overview = service.get_overview('2026-10-15', '2026-10-17')
|
||||
assert set(overview) == {'steps', 'active_kcal', 'distance_m', 'resting_mean_bpm', 'mean_daily_asleep_minutes', 'mean_spo2_pct', 'mean_stress', 'activity_days', 'sleep_days', 'resting_count', 'spo2_count'}
|
||||
assert overview['steps'] == 600
|
||||
assert overview['resting_mean_bpm'] == 60
|
||||
assert overview['mean_daily_asleep_minutes'] == 735
|
||||
assert overview['activity_days'] == 2
|
||||
assert overview['sleep_days'] == 2
|
||||
assert overview['resting_count'] == 1
|
||||
assert overview['spo2_count'] == 4
|
||||
|
||||
|
||||
def test_sleep_reports_match_existing_query_service_and_frontend(tmp_path):
|
||||
db = Database(tmp_path / 'synthetic.db')
|
||||
for row_id, source, collected, minutes, nap in [
|
||||
('raw', 'source:raw', '2026-10-15T01:00:00Z', 420, False),
|
||||
('old', 'daily_report:old', '2026-10-15T02:00:00Z', 450, False),
|
||||
('night', 'daily_report:new-night', '2026-10-15T03:00:00Z', 480, False),
|
||||
('nap', 'daily_report:new-nap', '2026-10-15T03:00:00Z', 30, True),
|
||||
]:
|
||||
insert(db, 'sleep_sessions', id=row_id, sleep_id=row_id, source_record_id=source,
|
||||
collected_at=collected, start_at='2026-10-14T23:00:00+08:00', end_at='2026-10-15T07:00:00+08:00',
|
||||
duration_minutes=minutes + 15, time_asleep_minutes=minutes, time_awake_minutes=15, is_nap=nap,
|
||||
stages=json.dumps([dict(stage='deep', minutes=100)]) if not nap else None)
|
||||
for include_naps in (True, False):
|
||||
records = QueryService(db, 'owner').get_sleep_sessions('2026-10-15', '2026-10-15', include_naps=include_naps)
|
||||
expected = json.loads(subprocess.run([NODE, str(ROOT / 'qa/view-statistics-oracle.cjs')], cwd=ROOT,
|
||||
input=json.dumps(dict(kind='sleep', rows=records, range=dict(start='2026-10-15', end='2026-10-15'))),
|
||||
text=True, encoding='utf-8', capture_output=True, check=True).stdout)
|
||||
result = ViewQueryService(db, 'owner').get_view(ViewSelection('sleep', '2026-10-15', '2026-10-15', include_naps=include_naps))
|
||||
assert result['statistics']['count'] == expected['count']
|
||||
assert result['statistics']['mean_daily_asleep_minutes'] == expected['cards'][0]
|
||||
assert result['points'] == expected['points']
|
||||
|
||||
|
||||
def test_legacy_sleep_actual_fallback_and_json_stage_types(tmp_path):
|
||||
db = Database(tmp_path / 'synthetic.db')
|
||||
# 仅此合成库放宽旧字段约束,以验证历史 NULL 值的回退与 JSON 容错。
|
||||
with db._get_connection() as connection:
|
||||
connection.execute('CREATE TABLE legacy_sleep AS SELECT * FROM sleep_sessions')
|
||||
connection.execute('DROP TABLE sleep_sessions')
|
||||
connection.execute('ALTER TABLE legacy_sleep RENAME TO sleep_sessions')
|
||||
connection.commit()
|
||||
insert(db, 'sleep_sessions', id='legacy', sleep_id='legacy', start_at='2026-10-14T23:00:00+08:00',
|
||||
end_at='2026-10-15T07:00:00+08:00', duration_minutes=480, time_asleep_minutes=None,
|
||||
time_awake_minutes=0, stages=json.dumps(['ignored', dict(stage='deep', minutes='120'),
|
||||
dict(stage='deep', minutes=60), dict(stage='deep', minutes=None), dict(stage='light', minutes=300)]))
|
||||
result = ViewQueryService(db, 'owner').get_view(ViewSelection('sleep', '2026-10-15', '2026-10-15'))
|
||||
assert result['statistics']['primary_sum'] == 480
|
||||
assert result['statistics']['mean_daily_deep_minutes'] == 60
|
||||
assert result['points'][0]['values'] == [1, 5, None, 2]
|
||||
|
||||
|
||||
def test_activity_limit_applies_to_daily_summary_and_optional_zero_fallback(tmp_path):
|
||||
db = Database(tmp_path / 'synthetic.db')
|
||||
seed(db, 'activity')
|
||||
service = ViewQueryService(db, 'owner')
|
||||
selection = ViewSelection('activity', '2026-10-15', '2026-10-17', limit=1)
|
||||
result = service.get_view(selection)
|
||||
assert result['statistics']['count'] == 1
|
||||
assert result['statistics']['primary_sum'] == 300
|
||||
assert result['statistics']['mean_daily_steps'] == 300
|
||||
assert result['page']['rows'][0]['total_kcal'] == 0
|
||||
assert result['page']['rows'][0]['active_minutes'] == 0
|
||||
assert list(service.iter_export(selection)) == result['page']['rows']
|
||||
Reference in new issue
Block a user