From 7e278a2d8b355dcd25fd42bc84aea9f03f80e0b5 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sat, 31 Oct 2026 15:27:04 -0400 Subject: [PATCH 1/2] fix(analysis): merge event matching into the Analysis page /events/match always reported 0 matched: it added the stored timestamp_start, which is 0.0, to relative times and compared them with absolute device times. /waveforms/compare disagreed with it anyway, using any-match false negatives, counting every device event type, and hardcoding the tolerance. compare_events is now the one implementation, on the batch-validation matcher. Its numbers move to the experimental Analysis page, so Event Explorer no longer calls an experimental route. A boundary test keeps default pages off them. --- AGENTS.md | 4 +- ARCHITECTURE.md | 4 +- src/snore/api/routers/events.py | 34 +- src/snore/cli/groups/waveform.py | 7 +- src/snore/services/event_service.py | 73 +---- src/snore/services/schemas.py | 30 +- src/snore/services/waveform_service.py | 142 ++++----- tests/integration/test_api/test_events.py | 7 +- tests/integration/test_api/test_waveforms.py | 45 +++ tests/unit/test_event_service.py | 210 ------------- tests/unit/test_provenance_tags.py | 1 - tests/unit/test_waveform_service.py | 308 +++++++++---------- ui/src/api/events.ts | 7 +- ui/src/tests/AnalysisView.test.ts | 31 ++ ui/src/tests/EventExplorerView.test.ts | 22 +- ui/src/tests/experimentalBoundary.test.ts | 48 +++ ui/src/types/generated.ts | 88 +----- ui/src/types/index.ts | 1 - ui/src/views/AnalysisView.vue | 16 +- ui/src/views/EventExplorerView.vue | 68 +--- 20 files changed, 390 insertions(+), 756 deletions(-) delete mode 100644 tests/unit/test_event_service.py create mode 100644 ui/src/tests/experimentalBoundary.test.ts diff --git a/AGENTS.md b/AGENTS.md index d278dae3..d65be8cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,7 +149,7 @@ src/snore/ │ ├── backup_service.py # Raw file backup │ ├── database_service.py # DB stats + device listing (system/metadata) │ ├── day_service.py # Day aggregation -│ ├── event_service.py # Event queries + matching +│ ├── event_service.py # Event queries │ ├── export_service.py # Data export (CSV, JSON) │ ├── lttb.py # LTTB downsampling algorithm │ ├── session_service.py # Session management @@ -372,7 +372,7 @@ running every `DEFAULT_TOOLS` entry with SQL capture. **REST gating rule:** no default REST operation may return an Experimental-tagged field. Experimental operations keep their URLs and carry the FastAPI `experimental` tag: at `include_router` for the analysis and validation routers, per operation elsewhere (`GET /days/{date}/experimental`, -`GET /stats/experimental`, `/sessions/{id}/events/match`, `/sessions/{id}/waveforms/compare`). +`GET /stats/experimental`, `/sessions/{id}/waveforms/compare`). `GET /days/{date}` does not touch analysis tables. `tests/unit/test_provenance_tags.py` enforces the rule by walking every untagged operation's response schemas, and `EXPERIMENTAL_OPERATIONS` there pins the tagged list, so tagging a route to escape the check fails until that list is edited too. HTML diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fc5a4622..839c1c79 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -342,7 +342,7 @@ FastAPI application serving the same data as the CLI through HTTP endpoints. Lau | db | `/db` | Stats, vacuum; reset (local mode only) | | validation | `/validate` | Batch validation report | -**Experimental operations:** operations that return SNORE's own heuristics carry the FastAPI `experimental` tag: everything on the analysis and validation routers (tagged at `include_router`), `GET /sessions/{id}/events/match`, `GET /sessions/{id}/waveforms/compare`, `GET /days/{date}/experimental` and `GET /stats/experimental`. No other operation may return an Experimental-tagged field. `tests/unit/test_provenance_tags.py` enforces this and pins the tagged list in `EXPERIMENTAL_OPERATIONS`. +**Experimental operations:** operations that return SNORE's own heuristics carry the FastAPI `experimental` tag: everything on the analysis and validation routers (tagged at `include_router`), `GET /sessions/{id}/waveforms/compare`, `GET /days/{date}/experimental` and `GET /stats/experimental`. No other operation may return an Experimental-tagged field. `tests/unit/test_provenance_tags.py` enforces this and pins the tagged list in `EXPERIMENTAL_OPERATIONS`. **Key patterns:** - Dependency injection: `db: Session = Depends(get_db)` for database sessions @@ -382,7 +382,7 @@ Imports and analysis run as serialised background jobs to avoid SQLite write-loc | DayService | Day aggregation and lookup | | DemoService | Demo mode bootstrap — provisions the demo user, profile, and fixture session data for local evaluation | | DeviceService | Device listing and per-device detail (usage summary, current settings, settings history) | -| EventService | Event queries and matching | +| EventService | Event queries | | ExportService | Data export (CSV, JSON, raw files) | | ImportService | CPAP data import: source detection, file upload, backup, parse orchestration | | lttb (module) | Largest-Triangle-Three-Buckets downsampling via `lttb_downsample()` | diff --git a/src/snore/api/routers/events.py b/src/snore/api/routers/events.py index e9076982..8288f419 100644 --- a/src/snore/api/routers/events.py +++ b/src/snore/api/routers/events.py @@ -4,16 +4,13 @@ from fastapi import APIRouter, Depends, Query from snore.api.deps import service_dep -from snore.api.errors import NotFoundError from snore.api.schemas import EventItem from snore.database import models -from snore.services import AnalysisFacade, EventService -from snore.services.schemas import EventMatchResult +from snore.services import EventService router = APIRouter() EventServiceDep = Annotated[EventService, Depends(service_dep(EventService))] -AnalysisFacadeDep = Annotated[AnalysisFacade, Depends(service_dep(AnalysisFacade))] def _event_to_item(event: models.Event, session_start: datetime) -> EventItem: @@ -36,32 +33,3 @@ async def list_events( ) -> list[EventItem]: events, session_start = await svc.list_session_events(session_id, event_type) return [_event_to_item(e, session_start) for e in events] - - -@router.get( - "/{session_id}/events/match", - response_model=EventMatchResult, - tags=["experimental"], -) -async def match_events( - session_id: int, - svc: EventServiceDep, - facade: AnalysisFacadeDep, - mode: str = Query(default="aasm"), -) -> EventMatchResult: - machine_times = await svc.get_machine_apnea_hypopnea_times(session_id) - - analysis = await facade.get_analysis_result(session_id) - if not analysis: - raise NotFoundError(f"No analysis results found for session {session_id}") - - programmatic_times: list[float] = [] - mode_result = analysis.mode_results.get(mode) - if mode_result is not None: - for apnea in mode_result.apneas: - programmatic_times.append(analysis.timestamp_start + apnea.start_time) - for hyp in mode_result.hypopneas: - programmatic_times.append(analysis.timestamp_start + hyp.start_time) - programmatic_times.sort() - - return svc.match_events(machine_times, programmatic_times) diff --git a/src/snore/cli/groups/waveform.py b/src/snore/cli/groups/waveform.py index be1d70c5..02194e71 100644 --- a/src/snore/cli/groups/waveform.py +++ b/src/snore/cli/groups/waveform.py @@ -366,18 +366,17 @@ async def compare_events( service = WaveformService(ctx.db, ctx.profile_id) try: - comparison = await service.compare_events( - resolved_id, mode, tolerance_seconds=5.0 - ) + comparison = await service.compare_events(resolved_id, mode) except NotFoundError as e: raise click.ClickException(str(e)) from e programmatic = mark_field( "Programmatic", EventComparisonResult, "programmatic_event_count" ) + matched = mark_field("Matched", EventComparisonResult, "matched") console.print(f"Session {resolved_id} - Event Comparison ({mode} mode)") console.print( - f"Machine: {comparison.machine_event_count} events | {programmatic}: {comparison.programmatic_event_count} events" + f"Machine: {comparison.machine_event_count} events | {programmatic}: {comparison.programmatic_event_count} events | {matched}: {comparison.matched}" ) console.print("") diff --git a/src/snore/services/event_service.py b/src/snore/services/event_service.py index 9f9b8393..664a14f3 100644 --- a/src/snore/services/event_service.py +++ b/src/snore/services/event_service.py @@ -1,44 +1,21 @@ -"""Event matching service for comparing machine vs programmatic detections.""" +"""Event query service for device-scored session events.""" from __future__ import annotations from datetime import datetime from typing import Any -from pydantic import BaseModel from sqlalchemy import select -from snore.analysis.modes.postprocess import ( - EVENT_MATCH_TOLERANCE_SECONDS, - match_events_by_start_time, -) -from snore.constants import APNEA_TYPE_MAP, EVENT_TYPE_HYPOPNEA from snore.database import models from snore.exceptions import NotFoundError from snore.services._base import ProfileScopedService, session_device_join -from snore.services.schemas import EventMatchResult -__all__ = [ - "EVENT_MATCH_TOLERANCE_SECONDS", - "MATCHABLE_MACHINE_EVENT_TYPES", - "EventService", -] - -# Device event types matched against programmatic apneas + hypopneas: the apnea -# types batch validation converts (``APNEA_TYPE_MAP``, incl. ResMed's CAA) plus H. -MATCHABLE_MACHINE_EVENT_TYPES: frozenset[str] = frozenset( - {*APNEA_TYPE_MAP, EVENT_TYPE_HYPOPNEA} -) - - -class _TimedEvent(BaseModel, frozen=True): - """A bare timestamp shaped for ``match_events_by_start_time``.""" - - start_time: float +__all__ = ["EventService"] class EventService(ProfileScopedService): - """Service for event matching and comparison.""" + """Service for querying a session's device-scored events.""" async def _get_session_owned(self, session_id: int) -> Any: """Return the session if it belongs to this profile, else raise NotFoundError.""" @@ -73,47 +50,3 @@ async def list_session_events( stmt = stmt.order_by(models.Event.start_time) events = list((await self.db_session.execute(stmt)).scalars().all()) return events, session.start_time - - async def get_machine_apnea_hypopnea_times(self, session_id: int) -> list[float]: - """Return sorted start timestamps of a session's device apneas + hypopneas. - - Only the types the programmatic side detects (``MATCHABLE_MACHINE_EVENT_TYPES``, - the apnea/hypopnea set batch validation compares): other device flags (FL, - VS, LL, PB, RE, ...) would otherwise take one-to-one matches. - """ - - await self._get_session_owned(session_id) - - start_times = ( - await self.db_session.execute( - select(models.Event.start_time).where( - models.Event.session_id == session_id, - models.Event.event_type.in_(MATCHABLE_MACHINE_EVENT_TYPES), - ) - ) - ).scalars() - return sorted(t.timestamp() for t in start_times) - - @staticmethod - def match_events( - machine_times: list[float], - programmatic_times: list[float], - tolerance: float = EVENT_MATCH_TOLERANCE_SECONDS, - ) -> EventMatchResult: - """Match machine vs programmatic events one-to-one by start time. - - Uses the batch-validation matcher, but on one pooled apnea + hypopnea - list rather than batch validation's separate per-type matches. - """ - result = match_events_by_start_time( - [_TimedEvent(start_time=t) for t in sorted(programmatic_times)], - [_TimedEvent(start_time=t) for t in sorted(machine_times)], - tolerance, - ) - return EventMatchResult( - machine_count=len(machine_times), - programmatic_count=len(programmatic_times), - matched=len(result.matched), - false_positives=len(result.false_positives), - false_negatives=len(result.false_negatives), - ) diff --git a/src/snore/services/schemas.py b/src/snore/services/schemas.py index 88299ee4..a97c3541 100644 --- a/src/snore/services/schemas.py +++ b/src/snore/services/schemas.py @@ -30,7 +30,6 @@ "AnalysisListItem", "AnalysisSessionDetail", "AnalysisDeletePreview", - "EventMatchResult", "DeviceInfo", "SettingChangeEntry", "SettingsChange", @@ -792,28 +791,6 @@ class WaveformInfo(BaseModel): ) -class EventMatchResult(BaseModel): - """Result of matching machine vs programmatic events.""" - - machine_count: int = provenance_field( - Provenance.DEVICE, "Machine-scored apneas and hypopneas" - ) - programmatic_count: int = provenance_field( - Provenance.EXPERIMENTAL, "Programmatically detected apneas and hypopneas" - ) - matched: int = provenance_field( - Provenance.EXPERIMENTAL, - "Programmatic/machine event pairs matched one-to-one within tolerance " - "(each event in at most one pair)", - ) - false_positives: int = provenance_field( - Provenance.EXPERIMENTAL, "Programmatic events unmatched" - ) - false_negatives: int = provenance_field( - Provenance.EXPERIMENTAL, "Machine events unmatched" - ) - - class AnalysisListItem(BaseModel): """Session with analysis status for listing.""" @@ -1332,11 +1309,16 @@ class EventComparisonResult(BaseModel): session_id: int = Field(description="Session database ID") mode: str = Field(description="Detection mode used (e.g., 'aasm')") machine_event_count: int = provenance_field( - Provenance.DEVICE, "Total machine-detected events" + Provenance.DEVICE, "Machine-scored apneas and hypopneas" ) programmatic_event_count: int = provenance_field( Provenance.EXPERIMENTAL, "Total programmatically-detected events" ) + matched: int = provenance_field( + Provenance.EXPERIMENTAL, + "Programmatic/machine event pairs matched one-to-one within tolerance " + "(each event in at most one pair)", + ) false_negatives: list[EventComparisonDetail] = provenance_field( Provenance.EXPERIMENTAL, "Machine events missed by programmatic detection", diff --git a/src/snore/services/waveform_service.py b/src/snore/services/waveform_service.py index 44500912..ab4e274c 100644 --- a/src/snore/services/waveform_service.py +++ b/src/snore/services/waveform_service.py @@ -1,6 +1,5 @@ """Waveform service for listing and loading waveform data.""" -from bisect import bisect_left, bisect_right from collections import OrderedDict from datetime import datetime from typing import Any @@ -10,6 +9,9 @@ from sqlalchemy import select from snore.analysis.data.waveform_loader import deserialize_waveform_blob +from snore.analysis.modes.postprocess import match_events_by_start_time +from snore.analysis.shared.types import ApneaEvent, HypopneaEvent +from snore.constants import EVENT_TYPE_HYPOPNEA from snore.database import models from snore.exceptions import NotFoundError from snore.services._base import ( @@ -36,6 +38,25 @@ type _WaveformCacheKey = tuple[int, datetime] +def _comparison_event_type(event: ApneaEvent | HypopneaEvent) -> str: + """Return the event's type code; ``HypopneaEvent`` carries no ``event_type``.""" + if isinstance(event, HypopneaEvent): + return EVENT_TYPE_HYPOPNEA + return event.event_type + + +def _programmatic_detail(event: ApneaEvent | HypopneaEvent) -> EventComparisonDetail: + """Build the comparison detail for an unmatched programmatic event.""" + return EventComparisonDetail( + event_type=_comparison_event_type(event), + start_time=event.start_time, + duration=event.duration, + source="programmatic", + confidence=event.confidence, + flow_reduction=event.flow_reduction, + ) + + class _WaveformArrayCache: """Module-level, byte-capped LRU of deserialized waveform arrays. @@ -380,18 +401,20 @@ async def compare_events( self, session_id: int, mode: str = "aasm", - tolerance_seconds: float = 5.0, ) -> EventComparisonResult: """ - Compare machine vs programmatic events for a session. + Compare machine vs programmatic apneas + hypopneas for a session. + + Both sides are pooled and matched one-to-one by start time with the + batch-validation matcher, so each event is in at most one pair. Args: session_id: Database session ID mode: Detection mode to compare - tolerance_seconds: Time tolerance in seconds for matching events Returns: - EventComparisonResult with false negatives and false positives + EventComparisonResult with matched count, false negatives and + false positives Raises: NotFoundError: If no analysis result found or mode not available @@ -409,77 +432,48 @@ async def compare_events( mode_result = result.mode_results[mode] - machine_events_raw = result.machine_events or [] - machine_apneas, machine_hypopneas = convert_machine_events(machine_events_raw) - all_machine = machine_apneas + machine_hypopneas - - prog_apneas = list(mode_result.apneas) - prog_hypopneas = list(mode_result.hypopneas) - all_prog = prog_apneas + prog_hypopneas - - false_negatives: list[EventComparisonDetail] = [] - false_positives_apnea: list[EventComparisonDetail] = [] - false_positives_hypopnea: list[EventComparisonDetail] = [] - - all_machine_sorted = sorted(all_machine, key=lambda e: e.start_time) - prog_times = sorted(e.start_time for e in all_prog) - machine_times = [e.start_time for e in all_machine_sorted] - - for m_event in all_machine_sorted: - lo = bisect_left(prog_times, m_event.start_time - tolerance_seconds) - hi = bisect_right(prog_times, m_event.start_time + tolerance_seconds) - if lo >= hi: - false_negatives.append( - EventComparisonDetail( - event_type=getattr(m_event, "event_type", "unknown"), - start_time=m_event.start_time, - duration=getattr(m_event, "duration", 0.0), - source="machine", - confidence=None, - flow_reduction=None, - ) - ) - - for apnea_event in prog_apneas: - lo = bisect_left(machine_times, apnea_event.start_time - tolerance_seconds) - hi = bisect_right(machine_times, apnea_event.start_time + tolerance_seconds) - if lo >= hi: - false_positives_apnea.append( - EventComparisonDetail( - event_type=apnea_event.event_type, - start_time=apnea_event.start_time, - duration=apnea_event.duration, - source="programmatic", - confidence=getattr(apnea_event, "confidence", None), - flow_reduction=getattr(apnea_event, "flow_reduction", None), - ) - ) - - for hypopnea_event in prog_hypopneas: - lo = bisect_left( - machine_times, hypopnea_event.start_time - tolerance_seconds - ) - hi = bisect_right( - machine_times, hypopnea_event.start_time + tolerance_seconds - ) - if lo >= hi: - false_positives_hypopnea.append( - EventComparisonDetail( - event_type="H", - start_time=hypopnea_event.start_time, - duration=hypopnea_event.duration, - source="programmatic", - confidence=hypopnea_event.confidence, - flow_reduction=hypopnea_event.flow_reduction, - ) - ) + machine_apneas, machine_hypopneas = convert_machine_events( + result.machine_events or [] + ) + all_machine: list[ApneaEvent | HypopneaEvent] = [ + *machine_apneas, + *machine_hypopneas, + ] + all_prog: list[ApneaEvent | HypopneaEvent] = [ + *mode_result.apneas, + *mode_result.hypopneas, + ] + + matches = match_events_by_start_time( + sorted(all_prog, key=lambda e: e.start_time), + sorted(all_machine, key=lambda e: e.start_time), + ) return EventComparisonResult( session_id=session_id, mode=mode, - machine_event_count=len(machine_events_raw), - programmatic_event_count=len(prog_apneas) + len(prog_hypopneas), - false_negatives=false_negatives, - false_positives_apnea=false_positives_apnea, - false_positives_hypopnea=false_positives_hypopnea, + machine_event_count=len(all_machine), + programmatic_event_count=len(all_prog), + matched=len(matches.matched), + false_negatives=[ + EventComparisonDetail( + event_type=_comparison_event_type(event), + start_time=event.start_time, + duration=event.duration, + source="machine", + confidence=None, + flow_reduction=None, + ) + for event in matches.false_negatives + ], + false_positives_apnea=[ + _programmatic_detail(event) + for event in matches.false_positives + if isinstance(event, ApneaEvent) + ], + false_positives_hypopnea=[ + _programmatic_detail(event) + for event in matches.false_positives + if isinstance(event, HypopneaEvent) + ], ) diff --git a/tests/integration/test_api/test_events.py b/tests/integration/test_api/test_events.py index 561b13bf..fd1547d2 100644 --- a/tests/integration/test_api/test_events.py +++ b/tests/integration/test_api/test_events.py @@ -75,11 +75,7 @@ def test_list_events_filter_by_type( assert len(data) == 1 assert data[0]["event_type"] == "OA" - def test_match_events_session_not_found(self, api_client): - response = api_client.get("/api/v1/sessions/99999/events/match") - assert response.status_code == 404 - - def test_match_events_no_analysis( + def test_match_route_is_gone( self, api_client, db_session, test_device, test_session_factory ): session = test_session_factory( @@ -87,3 +83,4 @@ def test_match_events_no_analysis( ) response = api_client.get(f"/api/v1/sessions/{session.id}/events/match") assert response.status_code == 404 + assert response.json()["detail"] == "Not Found" diff --git a/tests/integration/test_api/test_waveforms.py b/tests/integration/test_api/test_waveforms.py index cc550197..9640019d 100644 --- a/tests/integration/test_api/test_waveforms.py +++ b/tests/integration/test_api/test_waveforms.py @@ -1,10 +1,15 @@ from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock import numpy as np from sqlalchemy.orm import Session +from snore.analysis.shared.types import ApneaEvent +from snore.analysis.types import AnalysisEvent from snore.database.models import Waveform +from snore.services.waveform_service import WaveformService def _seed_waveform( @@ -185,3 +190,43 @@ def test_compare_no_analysis_returns_404( ) response = api_client.get(f"/api/v1/sessions/{session.id}/waveforms/compare") assert response.status_code == 404 + + def test_compare_returns_matched_count( + self, api_client, db_session, test_device, test_session_factory, monkeypatch + ): + session = test_session_factory( + test_device.id, start_time=datetime(2024, 1, 1, 22, 0) + ) + apnea = ApneaEvent( + start_time=100.0, + end_time=110.0, + duration=10.0, + event_type="OA", + flow_reduction=0.9, + confidence=0.8, + baseline_flow=30.0, + ) + analysis = SimpleNamespace( + machine_events=[ + AnalysisEvent( + event_type=event_type, + start_time=100.0, + duration=10.0, + source="machine", + ) + for event_type in ("OA", "FL") + ], + mode_results={"aasm": SimpleNamespace(apneas=[apnea], hypopneas=[])}, + ) + monkeypatch.setattr( + WaveformService, + "_load_analysis_result", + AsyncMock(return_value=analysis), + ) + + response = api_client.get(f"/api/v1/sessions/{session.id}/waveforms/compare") + + assert response.status_code == 200 + data = response.json() + assert data["machine_event_count"] == 1 + assert data["matched"] == 1 diff --git a/tests/unit/test_event_service.py b/tests/unit/test_event_service.py deleted file mode 100644 index 5f74c66c..00000000 --- a/tests/unit/test_event_service.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Unit tests for EventService.""" - -from datetime import datetime, timedelta - -from snore.database.models import Event -from snore.services.event_service import EVENT_MATCH_TOLERANCE_SECONDS, EventService - - -class TestEventService: - """Tests for EventService.match_events().""" - - def test_empty_events(self): - """Empty event lists return all zeros.""" - result = EventService.match_events([], []) - - assert result.machine_count == 0 - assert result.programmatic_count == 0 - assert result.matched == 0 - assert result.false_positives == 0 - assert result.false_negatives == 0 - - def test_all_matched(self): - """Identical timestamps result in all matched, no false positives or negatives.""" - machine_times = [10.0, 20.0, 30.0] - programmatic_times = [10.0, 20.0, 30.0] - - result = EventService.match_events(machine_times, programmatic_times) - - assert result.machine_count == 3 - assert result.programmatic_count == 3 - assert result.matched == 3 - assert result.false_positives == 0 - assert result.false_negatives == 0 - - def test_within_tolerance(self): - """Events within tolerance are matched.""" - machine_times = [10.0, 20.0] - programmatic_times = [13.0, 22.0] - - result = EventService.match_events(machine_times, programmatic_times) - - assert result.machine_count == 2 - assert result.programmatic_count == 2 - assert result.matched == 2 - assert result.false_positives == 0 - assert result.false_negatives == 0 - - def test_outside_tolerance(self): - """Events outside tolerance result in false positives and false negatives.""" - machine_times = [10.0] - programmatic_times = [16.1] - - result = EventService.match_events(machine_times, programmatic_times) - - assert result.machine_count == 1 - assert result.programmatic_count == 1 - assert result.matched == 0 - assert result.false_positives == 1 - assert result.false_negatives == 1 - - def test_false_negatives_only(self): - """Machine events with no programmatic events result in false negatives.""" - machine_times = [10.0, 20.0, 30.0] - programmatic_times = [] - - result = EventService.match_events(machine_times, programmatic_times) - - assert result.machine_count == 3 - assert result.programmatic_count == 0 - assert result.matched == 0 - assert result.false_positives == 0 - assert result.false_negatives == 3 - - def test_false_positives_only(self): - """Programmatic events with no machine events result in false positives.""" - machine_times = [] - programmatic_times = [10.0, 20.0, 30.0] - - result = EventService.match_events(machine_times, programmatic_times) - - assert result.machine_count == 0 - assert result.programmatic_count == 3 - assert result.matched == 0 - assert result.false_positives == 3 - assert result.false_negatives == 0 - - def test_mixed_match(self): - """Mixed scenario with some matched and some unmatched events.""" - machine_times = [10.0, 20.0, 30.0, 40.0] - programmatic_times = [11.0, 35.0, 50.0] - - result = EventService.match_events(machine_times, programmatic_times) - - assert result.machine_count == 4 - assert result.programmatic_count == 3 - assert result.matched == 2 - assert result.false_positives == 1 - assert result.false_negatives == 2 - - def test_custom_tolerance(self): - """Custom tolerance parameter allows tighter matching.""" - machine_times = [10.0, 20.0] - programmatic_times = [12.5, 22.5] - - result_tight = EventService.match_events( - machine_times, programmatic_times, tolerance=2.0 - ) - assert result_tight.matched == 0 - assert result_tight.false_positives == 2 - assert result_tight.false_negatives == 2 - - result_loose = EventService.match_events( - machine_times, programmatic_times, tolerance=3.0 - ) - assert result_loose.matched == 2 - assert result_loose.false_positives == 0 - assert result_loose.false_negatives == 0 - - def test_unsorted_input(self): - """Unsorted input lists are handled correctly.""" - machine_times = [30.0, 10.0, 20.0] - programmatic_times = [20.0, 30.0, 10.0] - - result = EventService.match_events(machine_times, programmatic_times) - - assert result.machine_count == 3 - assert result.programmatic_count == 3 - assert result.matched == 3 - assert result.false_positives == 0 - assert result.false_negatives == 0 - - def test_default_tolerance_constant(self): - """Default tolerance uses the module constant.""" - machine_times = [10.0] - programmatic_times = [10.0 + EVENT_MATCH_TOLERANCE_SECONDS] - - result = EventService.match_events(machine_times, programmatic_times) - - assert result.matched == 1 - assert result.false_positives == 0 - assert result.false_negatives == 0 - - def test_edge_case_exact_tolerance_boundary(self): - """Events exactly at tolerance boundary are matched.""" - machine_times = [10.0] - programmatic_times = [15.0] - - result = EventService.match_events( - machine_times, programmatic_times, tolerance=5.0 - ) - - assert result.matched == 1 - assert result.false_positives == 0 - assert result.false_negatives == 0 - - def test_extra_programmatic_events_are_false_positives(self): - """A machine event pairs with one programmatic event; the rest are FPs.""" - machine_times = [10.0] - programmatic_times = [9.0, 10.0, 11.0] - - result = EventService.match_events(machine_times, programmatic_times) - - assert result.machine_count == 1 - assert result.programmatic_count == 3 - assert result.matched == 1 - assert result.false_positives == 2 - assert result.false_negatives == 0 - - def test_one_detection_cannot_match_two_machine_events(self): - """Two machine events near one detection leave one false negative.""" - result = EventService.match_events([100.0, 105.0], [102.0]) - - assert result.matched == 1 - assert result.false_positives == 0 - assert result.false_negatives == 1 - - -class TestMachineApneaHypopneaTimes: - """Tests for EventService.get_machine_apnea_hypopnea_times().""" - - async def test_non_apnea_device_flags_do_not_steal_matches( - self, - async_db_session, - async_test_profile, - async_test_device, - async_test_session_factory, - ): - """VS/FL flags just before an OA are dropped, so the OA still matches.""" - start = datetime(2025, 1, 10, 22, 0) - session = await async_test_session_factory(async_test_device.id, start) - oa_time = start + timedelta(hours=1) - for event_type, offset_s in (("VS", -2.0), ("FL", -1.0), ("OA", 0.0)): - async_db_session.add( - Event( - session_id=session.id, - event_type=event_type, - start_time=oa_time + timedelta(seconds=offset_s), - duration_seconds=10.0, - ) - ) - await async_db_session.flush() - svc = EventService(async_db_session, profile_id=async_test_profile.id) - - machine_times = await svc.get_machine_apnea_hypopnea_times(session.id) - result = EventService.match_events(machine_times, [oa_time.timestamp()]) - - assert machine_times == [oa_time.timestamp()] - assert result.matched == 1 - assert result.false_negatives == 0 - assert result.false_positives == 0 diff --git a/tests/unit/test_provenance_tags.py b/tests/unit/test_provenance_tags.py index 167df2e5..1112e7a2 100644 --- a/tests/unit/test_provenance_tags.py +++ b/tests/unit/test_provenance_tags.py @@ -227,7 +227,6 @@ "GET /api/v1/analysis/sessions", "GET /api/v1/days/{day_date}/experimental", "GET /api/v1/sessions/{session_id}/analysis", - "GET /api/v1/sessions/{session_id}/events/match", "GET /api/v1/sessions/{session_id}/waveforms/compare", "GET /api/v1/stats/experimental", "GET /api/v1/validate/runs", diff --git a/tests/unit/test_waveform_service.py b/tests/unit/test_waveform_service.py index d89f96fc..26171074 100644 --- a/tests/unit/test_waveform_service.py +++ b/tests/unit/test_waveform_service.py @@ -10,9 +10,13 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +from snore.analysis.modes.postprocess import EVENT_MATCH_TOLERANCE_SECONDS +from snore.analysis.shared.types import ApneaEvent, HypopneaEvent +from snore.analysis.types import AnalysisEvent from snore.database.models import Device, Profile, Session, User, Waveform from snore.exceptions import NotFoundError from snore.services import waveform_service as waveform_service_module +from snore.services.schemas import EventComparisonResult from snore.services.session_service import SessionService from snore.services.waveform_service import WaveformService @@ -338,36 +342,39 @@ async def test_get_waveform_data_with_end_only( assert len(timestamps) == len(values) -def _make_event( - start_time: float, - duration: float = 10.0, - event_type: str = "OA", - confidence: float | None = None, - flow_reduction: float | None = None, -) -> SimpleNamespace: - return SimpleNamespace( +def _machine_event(start_time: float, event_type: str = "OA") -> AnalysisEvent: + return AnalysisEvent( + event_type=event_type, start_time=start_time, duration=10.0, source="machine" + ) + + +def _apnea(start_time: float, event_type: str = "OA") -> ApneaEvent: + return ApneaEvent( start_time=start_time, - duration=duration, + end_time=start_time + 10.0, + duration=10.0, event_type=event_type, - confidence=confidence, - flow_reduction=flow_reduction, + flow_reduction=0.9, + confidence=0.8, + baseline_flow=30.0, ) -def _make_machine_event( - start_time: float, duration: float = 10.0, event_type: str = "OA" -) -> SimpleNamespace: - return SimpleNamespace( +def _hypopnea(start_time: float) -> HypopneaEvent: + return HypopneaEvent( start_time=start_time, - duration=duration, - event_type=event_type, + end_time=start_time + 10.0, + duration=10.0, + flow_reduction=0.4, + confidence=0.7, + baseline_flow=30.0, ) def _make_analysis_result( - machine_events: list[SimpleNamespace] | None = None, - apneas: list[SimpleNamespace] | None = None, - hypopneas: list[SimpleNamespace] | None = None, + machine_events: list[AnalysisEvent] | None = None, + apneas: list[ApneaEvent] | None = None, + hypopneas: list[HypopneaEvent] | None = None, mode: str = "aasm", ) -> SimpleNamespace: mode_result = SimpleNamespace( @@ -380,37 +387,46 @@ def _make_analysis_result( ) -class TestCompareEvents: - async def test_no_analysis_result_raises_not_found( - self, async_db_session, async_test_device, monkeypatch - ): +@pytest.fixture +def compare(async_db_session, async_test_device, monkeypatch): + """Run ``compare_events`` on a fresh session against the given analysis.""" + + async def run( + machine_events: list[AnalysisEvent] | None = None, + apneas: list[ApneaEvent] | None = None, + hypopneas: list[HypopneaEvent] | None = None, + ) -> EventComparisonResult: now = datetime(2025, 1, 1, 0, 0, 0) session = Session( device_id=async_test_device.id, - device_session_id="test_compare_none", + device_session_id="test_compare", start_time=now, end_time=now + timedelta(hours=8), duration_seconds=28800, ) async_db_session.add(session) await async_db_session.flush() - monkeypatch.setattr( WaveformService, "_load_analysis_result", - AsyncMock(return_value=None), + AsyncMock( + return_value=_make_analysis_result(machine_events, apneas, hypopneas) + ), ) service = WaveformService(async_db_session, profile_id=1) - with pytest.raises(NotFoundError, match="No analysis results"): - await service.compare_events(session.id) + return await service.compare_events(session.id) - async def test_missing_mode_raises_not_found( + return run + + +class TestCompareEvents: + async def test_no_analysis_result_raises_not_found( self, async_db_session, async_test_device, monkeypatch ): now = datetime(2025, 1, 1, 0, 0, 0) session = Session( device_id=async_test_device.id, - device_session_id="test_compare_mode", + device_session_id="test_compare_none", start_time=now, end_time=now + timedelta(hours=8), duration_seconds=28800, @@ -418,23 +434,22 @@ async def test_missing_mode_raises_not_found( async_db_session.add(session) await async_db_session.flush() - fake_result = _make_analysis_result(mode="aasm") monkeypatch.setattr( WaveformService, "_load_analysis_result", - AsyncMock(return_value=fake_result), + AsyncMock(return_value=None), ) service = WaveformService(async_db_session, profile_id=1) - with pytest.raises(NotFoundError, match="Mode.*not found"): - await service.compare_events(session.id, mode="resmed") + with pytest.raises(NotFoundError, match="No analysis results"): + await service.compare_events(session.id) - async def test_no_events_returns_empty( + async def test_missing_mode_raises_not_found( self, async_db_session, async_test_device, monkeypatch ): now = datetime(2025, 1, 1, 0, 0, 0) session = Session( device_id=async_test_device.id, - device_session_id="test_compare_empty", + device_session_id="test_compare_mode", start_time=now, end_time=now + timedelta(hours=8), duration_seconds=28800, @@ -442,161 +457,134 @@ async def test_no_events_returns_empty( async_db_session.add(session) await async_db_session.flush() - fake_result = _make_analysis_result() + fake_result = _make_analysis_result(mode="aasm") monkeypatch.setattr( WaveformService, "_load_analysis_result", AsyncMock(return_value=fake_result), ) - monkeypatch.setattr( - "snore.analysis.utils.convert_machine_events", - lambda events: ([], []), - ) - service = WaveformService(async_db_session, profile_id=1) - result = await service.compare_events(session.id) + with pytest.raises(NotFoundError, match="Mode.*not found"): + await service.compare_events(session.id, mode="resmed") + + async def test_no_events_returns_empty(self, compare): + result = await compare() + + assert result.machine_event_count == 0 + assert result.programmatic_event_count == 0 + assert result.matched == 0 assert result.false_negatives == [] assert result.false_positives_apnea == [] assert result.false_positives_hypopnea == [] - async def test_false_negative_detected( - self, async_db_session, async_test_device, monkeypatch - ): - """Machine event with no matching programmatic event → false negative.""" - now = datetime(2025, 1, 1, 0, 0, 0) - session = Session( - device_id=async_test_device.id, - device_session_id="test_compare_fn", - start_time=now, - end_time=now + timedelta(hours=8), - duration_seconds=28800, + async def test_unmatched_events_keep_their_detail(self, compare): + result = await compare( + machine_events=[_machine_event(100.0)], + apneas=[_apnea(1000.0, event_type="CA")], + hypopneas=[_hypopnea(2000.0)], ) - async_db_session.add(session) - await async_db_session.flush() - m_event = _make_machine_event(start_time=100.0, event_type="OA") - fake_result = _make_analysis_result(machine_events=[m_event]) - monkeypatch.setattr( - WaveformService, - "_load_analysis_result", - AsyncMock(return_value=fake_result), - ) - monkeypatch.setattr( - "snore.analysis.utils.convert_machine_events", - lambda events: ([m_event], []), - ) + assert [ + (e.event_type, e.start_time, e.source, e.confidence, e.flow_reduction) + for e in result.false_negatives + ] == [("OA", 100.0, "machine", None, None)] + assert [ + (e.event_type, e.start_time, e.source, e.confidence, e.flow_reduction) + for e in result.false_positives_apnea + ] == [("CA", 1000.0, "programmatic", 0.8, 0.9)] + assert [ + (e.event_type, e.start_time, e.source, e.confidence, e.flow_reduction) + for e in result.false_positives_hypopnea + ] == [("H", 2000.0, "programmatic", 0.7, 0.4)] - service = WaveformService(async_db_session, profile_id=1) - result = await service.compare_events(session.id) - assert len(result.false_negatives) == 1 - assert result.false_negatives[0].start_time == 100.0 + async def test_missed_device_hypopnea_reports_type_h(self, compare): + result = await compare(machine_events=[_machine_event(100.0, "H")]) - async def test_false_positive_detected( - self, async_db_session, async_test_device, monkeypatch - ): - """Programmatic event with no matching machine event → false positive.""" - now = datetime(2025, 1, 1, 0, 0, 0) - session = Session( - device_id=async_test_device.id, - device_session_id="test_compare_fp", - start_time=now, - end_time=now + timedelta(hours=8), - duration_seconds=28800, + assert [e.event_type for e in result.false_negatives] == ["H"] + + async def test_matched_events_are_counted_and_not_listed(self, compare): + result = await compare( + machine_events=[_machine_event(100.0), _machine_event(200.0, "H")], + apneas=[_apnea(100.0)], + hypopneas=[_hypopnea(200.0)], ) - async_db_session.add(session) - await async_db_session.flush() - prog_event = _make_event(start_time=200.0, event_type="OA") - fake_result = _make_analysis_result(apneas=[prog_event]) - monkeypatch.setattr( - WaveformService, - "_load_analysis_result", - AsyncMock(return_value=fake_result), + assert result.matched == 2 + assert result.false_negatives == [] + assert result.false_positives_apnea == [] + assert result.false_positives_hypopnea == [] + + async def test_apnea_and_hypopnea_match_across_types(self, compare): + """Both sides are pooled, so a device OA can pair with a SNORE hypopnea.""" + result = await compare( + machine_events=[_machine_event(100.0)], hypopneas=[_hypopnea(101.0)] ) - monkeypatch.setattr( - "snore.analysis.utils.convert_machine_events", - lambda events: ([], []), + + assert result.matched == 1 + assert result.false_negatives == [] + assert result.false_positives_hypopnea == [] + + @pytest.mark.parametrize("event_type", ["FL", "RE", "LL", "VS", "PB"]) + async def test_non_matchable_device_types_are_ignored(self, compare, event_type): + result = await compare( + machine_events=[_machine_event(99.0, event_type), _machine_event(100.0)], + apneas=[_apnea(100.0)], ) - service = WaveformService(async_db_session, profile_id=1) - result = await service.compare_events(session.id) - assert len(result.false_positives_apnea) == 1 - assert result.false_positives_apnea[0].start_time == 200.0 + assert result.machine_event_count == 1 + assert result.matched == 1 + assert result.false_negatives == [] - async def test_matching_events_excluded( - self, async_db_session, async_test_device, monkeypatch + async def test_second_programmatic_event_near_one_machine_event_is_false_positive( + self, compare ): - """Events at same time should not appear in false positive/negative lists.""" - now = datetime(2025, 1, 1, 0, 0, 0) - session = Session( - device_id=async_test_device.id, - device_session_id="test_compare_match", - start_time=now, - end_time=now + timedelta(hours=8), - duration_seconds=28800, + result = await compare( + machine_events=[_machine_event(100.0)], + apneas=[_apnea(99.0), _apnea(101.0)], ) - async_db_session.add(session) - await async_db_session.flush() - m_event = _make_machine_event(start_time=100.0) - prog_event = _make_event(start_time=100.0, event_type="OA") - fake_result = _make_analysis_result( - machine_events=[m_event], apneas=[prog_event] - ) - monkeypatch.setattr( - WaveformService, - "_load_analysis_result", - AsyncMock(return_value=fake_result), - ) - monkeypatch.setattr( - "snore.analysis.utils.convert_machine_events", - lambda events: ([m_event], []), + assert result.matched == 1 + assert [e.start_time for e in result.false_positives_apnea] == [101.0] + assert result.false_negatives == [] + + async def test_second_machine_event_near_one_programmatic_event_is_false_negative( + self, compare + ): + result = await compare( + machine_events=[_machine_event(99.0), _machine_event(101.0)], + apneas=[_apnea(100.0)], ) - service = WaveformService(async_db_session, profile_id=1) - result = await service.compare_events(session.id) - assert result.false_negatives == [] + assert result.matched == 1 + assert [e.start_time for e in result.false_negatives] == [101.0] assert result.false_positives_apnea == [] - async def test_event_source_names_machine_or_programmatic( - self, async_db_session, async_test_device, monkeypatch + @pytest.mark.parametrize( + ("offset", "expected_matched"), + [ + (EVENT_MATCH_TOLERANCE_SECONDS, 1), + (EVENT_MATCH_TOLERANCE_SECONDS + 0.001, 0), + ], + ) + async def test_start_difference_at_tolerance_boundary( + self, compare, offset, expected_matched ): - """Unmatched machine events are tagged "machine"; detected ones "programmatic".""" - now = datetime(2025, 1, 1, 0, 0, 0) - session = Session( - device_id=async_test_device.id, - device_session_id="test_compare_source", - start_time=now, - end_time=now + timedelta(hours=8), - duration_seconds=28800, + result = await compare( + machine_events=[_machine_event(100.0)], apneas=[_apnea(100.0 + offset)] ) - async_db_session.add(session) - await async_db_session.flush() - m_event = _make_machine_event(start_time=100.0) - prog_apnea = _make_event(start_time=1000.0, event_type="CA") - prog_hypopnea = _make_event( - start_time=2000.0, confidence=0.8, flow_reduction=0.4 - ) - fake_result = _make_analysis_result( - machine_events=[m_event], apneas=[prog_apnea], hypopneas=[prog_hypopnea] - ) - monkeypatch.setattr( - WaveformService, - "_load_analysis_result", - AsyncMock(return_value=fake_result), - ) - monkeypatch.setattr( - "snore.analysis.utils.convert_machine_events", - lambda events: ([m_event], []), - ) + assert result.matched == expected_matched - service = WaveformService(async_db_session, profile_id=1) - result = await service.compare_events(session.id) + async def test_zero_machine_events_makes_every_detection_a_false_positive( + self, compare + ): + result = await compare(apneas=[_apnea(100.0)], hypopneas=[_hypopnea(200.0)]) - assert [e.source for e in result.false_negatives] == ["machine"] - assert [e.source for e in result.false_positives_apnea] == ["programmatic"] - assert [e.source for e in result.false_positives_hypopnea] == ["programmatic"] + assert result.machine_event_count == 0 + assert result.programmatic_event_count == 2 + assert result.matched == 0 + assert len(result.false_positives_apnea) == 1 + assert len(result.false_positives_hypopnea) == 1 async def test_get_waveform_data_leaves_session_open_for_subsequent_queries( self, async_db_session, async_test_device diff --git a/ui/src/api/events.ts b/ui/src/api/events.ts index 0feda8da..905dfc0b 100644 --- a/ui/src/api/events.ts +++ b/ui/src/api/events.ts @@ -1,12 +1,7 @@ import { apiGet } from './client' -import type { EventItem, EventMatchResult } from '@/types' +import type { EventItem } from '@/types' export const getSessionEvents = apiGet( (sessionId) => `/sessions/${sessionId}/events`, (_sessionId, eventType) => ({ params: eventType ? { event_type: eventType } : {} }), ) - -export const getEventMatch = apiGet( - (sessionId) => `/sessions/${sessionId}/events/match`, - (_sessionId, mode = 'aasm') => ({ params: { mode } }), -) diff --git a/ui/src/tests/AnalysisView.test.ts b/ui/src/tests/AnalysisView.test.ts index 4aecd028..6dd4c918 100644 --- a/ui/src/tests/AnalysisView.test.ts +++ b/ui/src/tests/AnalysisView.test.ts @@ -30,6 +30,7 @@ const ANALYSIS = { const COMPARISON = { machine_event_count: 3, programmatic_event_count: 4, + matched: 2, false_negatives: [{ event_type: 'OA', start_time: 100, duration: 12, source: 'machine' }], false_positives_apnea: [ { event_type: 'CA', start_time: 200, duration: 15, source: 'programmatic' }, @@ -97,4 +98,34 @@ describe('AnalysisView', () => { 'False Positives: SNORE-detected Events the Device Did Not Score', ) }) + + function statValue(wrapper: Awaited>, label: string) { + const section = wrapper + .findAll('.section-card') + .find((s) => s.find('h2').text() === 'Event Comparison')! + return section + .findAll('.stat-card') + .find((c) => c.find('.stat-label').text().startsWith(label))! + .find('.stat-value') + .text() + } + + it('test_event_comparison_shows_matched_and_sensitivity', async () => { + const wrapper = await mountView() + + expect(statValue(wrapper, 'Matched')).toBe('2') + expect(statValue(wrapper, 'Sensitivity')).toBe('66.7 %') + }) + + it('test_sensitivity_is_empty_with_no_device_events', async () => { + vi.mocked(getWaveformCompare).mockResolvedValue({ + ...COMPARISON, + machine_event_count: 0, + matched: 0, + } as never) + const wrapper = await mountView() + + expect(statValue(wrapper, 'Matched')).toBe('0') + expect(statValue(wrapper, 'Sensitivity')).toBe('---') + }) }) diff --git a/ui/src/tests/EventExplorerView.test.ts b/ui/src/tests/EventExplorerView.test.ts index d2a8e900..35715661 100644 --- a/ui/src/tests/EventExplorerView.test.ts +++ b/ui/src/tests/EventExplorerView.test.ts @@ -14,13 +14,6 @@ vi.mock('@/api/events', () => ({ peak_flow_limitation: null, }, ]), - getEventMatch: vi.fn().mockResolvedValue({ - machine_count: 1, - programmatic_count: 2, - matched: 1, - false_positives: 1, - false_negatives: 0, - }), })) vi.mock('@/api/sessions', () => ({ getSession: vi.fn().mockResolvedValue({ duration_hours: 2 }), @@ -29,20 +22,17 @@ vi.mock('@/api/sessions', () => ({ import EventExplorerView from '@/views/EventExplorerView.vue' describe('EventExplorerView', () => { - it('test_match_section_is_flagged_experimental_and_event_list_is_not', async () => { + it('test_renders_device_events_without_experimental_match_section', async () => { const wrapper = mount(EventExplorerView, { props: { sessionId: 3 }, global: { stubs: { RouterLink: { template: '' } } }, }) await flushPromises() - const sections = wrapper.findAll('.section-card') - const heading = (s: (typeof sections)[number]) => s.find('h2').text() - expect(sections.map(heading)).toEqual([ - 'Device-scored vs SNORE-detected', - 'Device-scored Events', - ]) - expect(sections[0]!.find('[role="note"]').exists()).toBe(true) - expect(sections[1]!.find('[role="note"]').exists()).toBe(false) + const headings = wrapper.findAll('.section-card h2').map((h) => h.text()) + expect(headings).toEqual(['Device-scored Events']) + expect(wrapper.find('[role="note"]').exists()).toBe(false) + expect(wrapper.text()).toContain('OA') + expect(wrapper.text()).toContain('Total Events') }) }) diff --git a/ui/src/tests/experimentalBoundary.test.ts b/ui/src/tests/experimentalBoundary.test.ts new file mode 100644 index 00000000..55a0cf5a --- /dev/null +++ b/ui/src/tests/experimentalBoundary.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import * as analysisApi from '@/api/analysis' +import * as validationApi from '@/api/validation' +import * as validationRunsApi from '@/api/validationRuns' + +const ALL_WRAPPERS = [ + ...Object.keys(analysisApi), + ...Object.keys(validationApi), + ...Object.keys(validationRunsApi), + 'getDayExperimental', + 'getExperimentalStats', + 'getWaveformCompare', +] +// Cancelling a post-import job reads no metrics, and ImportView needs it. +const EXPERIMENTAL_WRAPPERS = ALL_WRAPPERS.filter((name) => name !== 'cancelAnalysisJob') + +const ALLOWED_SOURCES = [ + /^\.\.\/views\/(AnalysisView|AnalysisManagementView|ValidationView|DayExperimentalView)\.vue$/, + /^\.\.\/components\/validation\//, + /^\.\.\/composables\/useValidationRuns\.ts$/, +] + +const SOURCES = import.meta.glob( + ['../views/*.vue', '../components/**/*.vue', '../composables/*.ts'], + { query: '?raw', import: 'default', eager: true }, +) + +function findOffenders(): string[] { + return Object.entries(SOURCES) + .filter(([file]) => !ALLOWED_SOURCES.some((allowed) => allowed.test(file))) + .flatMap(([file, source]) => + EXPERIMENTAL_WRAPPERS.filter((name) => new RegExp(`\\b${name}\\b`).test(source)).map( + (name) => `${file}: ${name}`, + ), + ) +} + +describe('experimental boundary', () => { + it('test_wrapper_and_source_lists_are_populated', () => { + expect(EXPERIMENTAL_WRAPPERS).toContain('getAnalysis') + expect(EXPERIMENTAL_WRAPPERS).not.toContain('cancelAnalysisJob') + expect(Object.keys(SOURCES)).toContain('../views/DashboardView.vue') + }) + + it('test_default_ui_references_no_experimental_wrappers', () => { + expect(findOffenders()).toEqual([]) + }) +}) diff --git a/ui/src/types/generated.ts b/ui/src/types/generated.ts index 858bb36f..3e629011 100644 --- a/ui/src/types/generated.ts +++ b/ui/src/types/generated.ts @@ -1723,23 +1723,6 @@ export interface paths { patch?: never trace?: never } - '/api/v1/sessions/{session_id}/events/match': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Match Events */ - get: operations['match_events_api_v1_sessions__session_id__events_match_get'] - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } '/api/v1/sessions/{session_id}/waveforms': { parameters: { query?: never @@ -3418,9 +3401,14 @@ export interface components { false_positives_hypopnea?: components['schemas']['EventComparisonDetail'][] /** * Machine Event Count - * @description [DEVICE] Total machine-detected events + * @description [DEVICE] Machine-scored apneas and hypopneas */ machine_event_count: number + /** + * Matched + * @description [EXPERIMENTAL] Programmatic/machine event pairs matched one-to-one within tolerance (each event in at most one pair) + */ + matched: number /** * Mode * @description Detection mode used (e.g., 'aasm') @@ -3463,37 +3451,6 @@ export interface components { /** Start Time */ start_time: number } - /** - * EventMatchResult - * @description Result of matching machine vs programmatic events. - */ - EventMatchResult: { - /** - * False Negatives - * @description [EXPERIMENTAL] Machine events unmatched - */ - false_negatives: number - /** - * False Positives - * @description [EXPERIMENTAL] Programmatic events unmatched - */ - false_positives: number - /** - * Machine Count - * @description [DEVICE] Machine-scored apneas and hypopneas - */ - machine_count: number - /** - * Matched - * @description [EXPERIMENTAL] Programmatic/machine event pairs matched one-to-one within tolerance (each event in at most one pair) - */ - matched: number - /** - * Programmatic Count - * @description [EXPERIMENTAL] Programmatically detected apneas and hypopneas - */ - programmatic_count: number - } /** * EventTypeCount * @description Event type with count and percentage. @@ -8980,39 +8937,6 @@ export interface operations { } } } - match_events_api_v1_sessions__session_id__events_match_get: { - parameters: { - query?: { - mode?: string - } - header?: never - path: { - session_id: number - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['EventMatchResult'] - } - } - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['HTTPValidationError'] - } - } - } - } list_waveforms_api_v1_sessions__session_id__waveforms_get: { parameters: { query?: never diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index dd171fbe..6d0559d9 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -30,7 +30,6 @@ export type WaveformDataResponse = Schemas['WaveformDataResponse'] // Events export type EventItem = Schemas['EventItem'] -export type EventMatchResult = Schemas['EventMatchResult'] // Devices export type DeviceInfo = Schemas['DeviceInfo'] diff --git a/ui/src/views/AnalysisView.vue b/ui/src/views/AnalysisView.vue index f8b763b8..d95040fc 100644 --- a/ui/src/views/AnalysisView.vue +++ b/ui/src/views/AnalysisView.vue @@ -391,13 +391,22 @@ :value="comparison.machine_event_count" :decimals="0" glossary-key="machine_events" - /> + >Apneas and hypopneas only + + Object.keys(analysis.value?.mode_results ?? {})) +const sensitivity = computed(() => { + if (!comparison.value?.machine_event_count) return null + return (comparison.value.matched / comparison.value.machine_event_count) * 100 +}) + const selectedModeResult = computed(() => { if (!analysis.value || !selectedMode.value) return null return analysis.value.mode_results[selectedMode.value] ?? null diff --git a/ui/src/views/EventExplorerView.vue b/ui/src/views/EventExplorerView.vue index a62ae6b9..ecda90e7 100644 --- a/ui/src/views/EventExplorerView.vue +++ b/ui/src/views/EventExplorerView.vue @@ -19,49 +19,6 @@ - -
-

Device-scored vs SNORE-detected

- -
- - - - - - -
-
-

Device-scored Events

@@ -203,15 +160,14 @@ import StatCard from '@/components/StatCard.vue' import PaginationBar from '@/components/PaginationBar.vue' import InfoHint from '@/components/InfoHint.vue' import EventTypeLegend from '@/components/EventTypeLegend.vue' -import { getSessionEvents, getEventMatch } from '@/api/events' +import { getSessionEvents } from '@/api/events' import { getSession } from '@/api/sessions' import { useApiLoad } from '@/composables/useApiLoad' import { useIsMobile } from '@/composables/useIsMobile' import { formatTimeOffset } from '@/utils/formatting' import { EVENT_COLORS } from '@/types' -import type { EventItem, EventMatchResult } from '@/types' +import type { EventItem } from '@/types' import ErrorState from '@/components/ErrorState.vue' -import ExperimentalBanner from '@/components/ExperimentalBanner.vue' const emDash = '\u2014' // em-dash used for null display const props = defineProps<{ sessionId: number }>() @@ -227,17 +183,10 @@ const { data, loading, error, reload } = useApiLoad(async () => { getSessionEvents(props.sessionId), getSession(props.sessionId, false), ]) - let match: EventMatchResult | null = null - try { - match = await getEventMatch(props.sessionId) - } catch { - // No analysis — match panel hidden - } - return { events, duration: session.duration_hours, match } + return { events, duration: session.duration_hours } }, 'Failed to load events') const allEvents = computed(() => data.value?.events ?? []) -const matchResult = computed(() => data.value?.match ?? null) const sessionDuration = computed(() => data.value?.duration ?? 0) const uniqueTypes = computed(() => [...new Set(allEvents.value.map((e) => e.event_type))].sort()) @@ -257,11 +206,6 @@ const eventsPerHour = computed(() => { return filteredEvents.value.length / sessionDuration.value }) -const sensitivity = computed(() => { - if (!matchResult.value || matchResult.value.machine_count === 0) return null - return (matchResult.value.matched / matchResult.value.machine_count) * 100 -}) - watch(activeTypes, () => { currentPage.value = 0 }) @@ -294,12 +238,6 @@ function jumpToWaveform(offsetSec: number): void { margin-bottom: 1.25rem; } -.match-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 0.5rem; -} - .type-chips { display: flex; gap: 0.4rem; From 38e7dba322a2e27c63689438a4cb7546f7abed51 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sun, 1 Nov 2026 15:08:27 -0500 Subject: [PATCH 2/2] fix(analysis): match combined lists one-to-one, scope card to mode `snore analysis` listed missed and extra events from any-match lists, so it could disagree with `snore waveform compare` on one session. Neither list is stored, so no re-analysis is needed. The Analysis page card is the only home for Matched and Sensitivity, so it must appear after a run from the page, follow the selected mode, and page its tables. The experimental operation list moves to JSON that the UI boundary test also reads, so a newly tagged route cannot escape the default-page scan. The compare response's `matched` becomes `matched_events`, as in `EventValidationResult`. --- AGENTS.md | 10 +- ARCHITECTURE.md | 4 +- src/snore/analysis/modes/detector.py | 14 +- src/snore/analysis/modes/postprocess.py | 48 +---- src/snore/cli/groups/waveform.py | 4 +- src/snore/services/schemas.py | 2 +- src/snore/services/waveform_service.py | 19 +- tests/integration/test_api/test_waveforms.py | 2 +- tests/integration/test_cli_commands.py | 144 +++++++++++++++ tests/unit/test_event_detector.py | 63 +++++++ tests/unit/test_match_events_by_start_time.py | 14 +- tests/unit/test_provenance_tags.py | 35 ++-- tests/unit/test_waveform_service.py | 80 +++++++-- ui/src/components/TablePager.vue | 20 +++ ui/src/tests/AnalysisView.test.ts | 164 +++++++++++++++++- ui/src/tests/experimentalBoundary.test.ts | 118 ++++++++++--- ui/src/tests/experimentalOperations.json | 22 +++ ui/src/types/generated.ts | 4 +- ui/src/views/AnalysisView.vue | 118 +++++++------ 19 files changed, 683 insertions(+), 202 deletions(-) create mode 100644 ui/src/components/TablePager.vue create mode 100644 ui/src/tests/experimentalOperations.json diff --git a/AGENTS.md b/AGENTS.md index d65be8cd..3596efba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -374,9 +374,11 @@ operations keep their URLs and carry the FastAPI `experimental` tag: at `include analysis and validation routers, per operation elsewhere (`GET /days/{date}/experimental`, `GET /stats/experimental`, `/sessions/{id}/waveforms/compare`). `GET /days/{date}` does not touch analysis tables. `tests/unit/test_provenance_tags.py` enforces the -rule by walking every untagged operation's response schemas, and `EXPERIMENTAL_OPERATIONS` there pins -the tagged list, so tagging a route to escape the check fails until that list is edited too. HTML -reports have no response schema: `tests/unit/test_report_service.py` checks that the models a report +rule by walking every untagged operation's response schemas, and `EXPERIMENTAL_OPERATIONS` there, read +from `ui/src/tests/experimentalOperations.json`, pins the tagged list, so tagging a route to escape the +check fails until that list is edited too. `ui/src/tests/experimentalBoundary.test.ts` reads the same +file: every listed operation needs a wrapper in `ui/src/api/`, and default pages may not reference those +wrappers, `useValidationRuns` or `components/validation/*`. HTML reports have no response schema: `tests/unit/test_report_service.py` checks that the models a report fetches carry no Experimental field. CLI entry points (`cli/display`): `mark_field(label, Model, "field")` returns the label with the @@ -590,7 +592,7 @@ Integration test seed helpers (`_make_profile`, `_make_device`, `_make_day_sessi | Tune detection thresholds | `src/snore/analysis/modes/config.py` (DetectionModeConfig fields), validate with `validate_against_machine_events()` | | Modify data models | `src/snore/parsers/unified.py` (data), `database/models.py` (ORM), use Pydantic; keep `test_schema_alignment.py` passing | | Add waveform visualization | `src/snore/waveform/renderer.py` (ASCII/plotext), `inspector.py` (data loading) | -| Add API endpoint | `src/snore/api/routers/`, `api/schemas.py`, `api/deps.py`; `EXPERIMENTAL_OPERATIONS` in `tests/unit/test_provenance_tags.py` if it carries the `experimental` tag; then `just ui-generate-types` | +| Add API endpoint | `src/snore/api/routers/`, `api/schemas.py`, `api/deps.py`; `ui/src/tests/experimentalOperations.json` if it carries the `experimental` tag; then `just ui-generate-types` | | Add/modify service | `src/snore/services/`, `services/schemas.py` | | Frontend development | `ui/src/views/`, `ui/src/components/`, `ui/src/api/`, `ui/src/composables/`, `ui/src/utils/` | | Regenerate UI API types | `just ui-generate-types` (`scripts/export_openapi.py` → `ui/src/types/generated.ts`) | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 839c1c79..9a363925 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -329,7 +329,7 @@ FastAPI application serving the same data as the CLI through HTTP endpoints. Lau | profiles | `/profiles` | Profile CRUD | | sessions | `/sessions` | List, detail, enable/disable, delete, bulk delete-preview | | waveforms | `/sessions` | List types, get data (LTTB downsampling), compare events | -| events | `/sessions` | List, match machine vs programmatic | +| events | `/sessions` | List | | analysis | (v1 prefix) | List status, get result, run, delete, batch analysis | | stats | `/stats` | Summary, periods, trends, records, data range; experimental effectiveness band and AHI trend direction (`GET /stats/experimental`) | | devices | `/devices` | List, detail | @@ -342,7 +342,7 @@ FastAPI application serving the same data as the CLI through HTTP endpoints. Lau | db | `/db` | Stats, vacuum; reset (local mode only) | | validation | `/validate` | Batch validation report | -**Experimental operations:** operations that return SNORE's own heuristics carry the FastAPI `experimental` tag: everything on the analysis and validation routers (tagged at `include_router`), `GET /sessions/{id}/waveforms/compare`, `GET /days/{date}/experimental` and `GET /stats/experimental`. No other operation may return an Experimental-tagged field. `tests/unit/test_provenance_tags.py` enforces this and pins the tagged list in `EXPERIMENTAL_OPERATIONS`. +**Experimental operations:** operations that return SNORE's own heuristics carry the FastAPI `experimental` tag: everything on the analysis and validation routers (tagged at `include_router`), `GET /sessions/{id}/waveforms/compare`, `GET /days/{date}/experimental` and `GET /stats/experimental`. No other operation may return an Experimental-tagged field. `tests/unit/test_provenance_tags.py` enforces this and pins the tagged list against `ui/src/tests/experimentalOperations.json`, which `ui/src/tests/experimentalBoundary.test.ts` uses to keep default pages off those operations. **Key patterns:** - Dependency injection: `db: Session = Depends(get_db)` for database sessions diff --git a/src/snore/analysis/modes/detector.py b/src/snore/analysis/modes/detector.py index 99f959e1..2e759196 100644 --- a/src/snore/analysis/modes/detector.py +++ b/src/snore/analysis/modes/detector.py @@ -21,7 +21,7 @@ _deduplicate_events, _merge_adjacent_events, _validate_event, - split_by_tolerance_match, + match_events_by_start_time, validate_event_type, ) from snore.analysis.modes.types import HypopneaMode, ModeResult @@ -955,12 +955,10 @@ def validate_against_machine_events( if has_machine_reras: all_programmatic.extend(programmatic_reras) all_machine.extend(machine_reras) + all_programmatic.sort(key=lambda e: e.start_time) all_machine.sort(key=lambda e: e.start_time) - _, false_negative_events = split_by_tolerance_match( - all_machine, all_programmatic, tolerance_seconds - ) - matched_events, false_positive_events = split_by_tolerance_match( + combined = match_events_by_start_time( all_programmatic, all_machine, tolerance_seconds ) @@ -984,9 +982,9 @@ def validate_against_machine_events( "apnea_matches": apnea_matches, "hypopnea_matches": hypopnea_matches, "rera_matches": rera_matches, - "matched_events": matched_events, - "false_negative_events": false_negative_events, - "false_positive_events": false_positive_events, + "matched_events": [programmatic for programmatic, _ in combined.matched], + "false_negative_events": combined.false_negatives, + "false_positive_events": combined.false_positives, "overall_agreement": { "total_machine_events": total_machine, "total_programmatic_events": total_programmatic, diff --git a/src/snore/analysis/modes/postprocess.py b/src/snore/analysis/modes/postprocess.py index 46905fca..6333ea26 100644 --- a/src/snore/analysis/modes/postprocess.py +++ b/src/snore/analysis/modes/postprocess.py @@ -7,7 +7,7 @@ from collections.abc import Sequence from dataclasses import dataclass -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING import numpy as np @@ -27,13 +27,6 @@ MatchableEvent = ApneaEvent | HypopneaEvent | RERAEvent -class HasStartTime(Protocol): - """Anything one-to-one matching can pair: only ``start_time`` is read.""" - - @property - def start_time(self) -> float: ... - - def _calculate_event_overlap(event1: ApneaEvent, event2: ApneaEvent) -> float: """ Calculate overlap ratio between two events. @@ -240,7 +233,7 @@ def _merge_two_events( @dataclass(frozen=True) -class MatchedEvents[E: HasStartTime]: +class MatchedEvents[E: MatchableEvent]: """ Outcome of greedy one-to-one matching of programmatic vs machine events. @@ -255,7 +248,7 @@ class MatchedEvents[E: HasStartTime]: false_negatives: list[E] -def match_events_by_start_time[E: HasStartTime]( +def match_events_by_start_time[E: MatchableEvent]( programmatic: Sequence[E], machine: Sequence[E], tolerance_seconds: float = EVENT_MATCH_TOLERANCE_SECONDS, @@ -317,41 +310,6 @@ def match_events_by_start_time[E: HasStartTime]( ) -def split_by_tolerance_match( - events: Sequence[MatchableEvent], - references: Sequence[MatchableEvent], - tolerance_seconds: float = EVENT_MATCH_TOLERANCE_SECONDS, -) -> tuple[list[MatchableEvent], list[MatchableEvent]]: - """ - Split events into (matched, unmatched) by start-time proximity. - - An event is matched if ANY reference event starts within tolerance - (references may match multiple events). - - Args: - events: Events to classify - references: Reference events to match against - tolerance_seconds: Max start-time difference for a match - - Returns: - Tuple of (matched events, unmatched events), preserving input order - """ - matched: list[MatchableEvent] = [] - unmatched: list[MatchableEvent] = [] - - for event in events: - is_matched = any( - abs(event.start_time - ref.start_time) <= tolerance_seconds - for ref in references - ) - if is_matched: - matched.append(event) - else: - unmatched.append(event) - - return matched, unmatched - - def validate_event_type( programmatic: Sequence[MatchableEvent], machine: Sequence[MatchableEvent], diff --git a/src/snore/cli/groups/waveform.py b/src/snore/cli/groups/waveform.py index 02194e71..cbd368a7 100644 --- a/src/snore/cli/groups/waveform.py +++ b/src/snore/cli/groups/waveform.py @@ -373,10 +373,10 @@ async def compare_events( programmatic = mark_field( "Programmatic", EventComparisonResult, "programmatic_event_count" ) - matched = mark_field("Matched", EventComparisonResult, "matched") + matched = mark_field("Matched", EventComparisonResult, "matched_events") console.print(f"Session {resolved_id} - Event Comparison ({mode} mode)") console.print( - f"Machine: {comparison.machine_event_count} events | {programmatic}: {comparison.programmatic_event_count} events | {matched}: {comparison.matched}" + f"Machine: {comparison.machine_event_count} events (apneas and hypopneas only) | {programmatic}: {comparison.programmatic_event_count} events | {matched}: {comparison.matched_events}" ) console.print("") diff --git a/src/snore/services/schemas.py b/src/snore/services/schemas.py index a97c3541..02fe6701 100644 --- a/src/snore/services/schemas.py +++ b/src/snore/services/schemas.py @@ -1314,7 +1314,7 @@ class EventComparisonResult(BaseModel): programmatic_event_count: int = provenance_field( Provenance.EXPERIMENTAL, "Total programmatically-detected events" ) - matched: int = provenance_field( + matched_events: int = provenance_field( Provenance.EXPERIMENTAL, "Programmatic/machine event pairs matched one-to-one within tolerance " "(each event in at most one pair)", diff --git a/src/snore/services/waveform_service.py b/src/snore/services/waveform_service.py index ab4e274c..997a73f7 100644 --- a/src/snore/services/waveform_service.py +++ b/src/snore/services/waveform_service.py @@ -10,6 +10,7 @@ from snore.analysis.data.waveform_loader import deserialize_waveform_blob from snore.analysis.modes.postprocess import match_events_by_start_time +from snore.analysis.queries import latest_analysis_row from snore.analysis.shared.types import ApneaEvent, HypopneaEvent from snore.constants import EVENT_TYPE_HYPOPNEA from snore.database import models @@ -376,23 +377,9 @@ async def get_waveform_data( async def _load_analysis_result(self, session_id: int) -> Any: """Load the latest AnalysisResult row for a session, returning a validated result object.""" - from sqlalchemy import select as _select - from snore.analysis.types import AnalysisResult as _AnalysisResult - from snore.database import models as _models - - analysis_row = ( - ( - await self.db_session.execute( - _select(_models.AnalysisResult) - .filter_by(session_id=session_id) - .order_by(_models.AnalysisResult.created_at.desc()) - ) - ) - .scalars() - .first() - ) + analysis_row = await latest_analysis_row(self.db_session, session_id) if analysis_row is None: return None return _AnalysisResult.from_stored_json(analysis_row.programmatic_result_json) @@ -454,7 +441,7 @@ async def compare_events( mode=mode, machine_event_count=len(all_machine), programmatic_event_count=len(all_prog), - matched=len(matches.matched), + matched_events=len(matches.matched), false_negatives=[ EventComparisonDetail( event_type=_comparison_event_type(event), diff --git a/tests/integration/test_api/test_waveforms.py b/tests/integration/test_api/test_waveforms.py index 9640019d..dd370b3f 100644 --- a/tests/integration/test_api/test_waveforms.py +++ b/tests/integration/test_api/test_waveforms.py @@ -229,4 +229,4 @@ def test_compare_returns_matched_count( assert response.status_code == 200 data = response.json() assert data["machine_event_count"] == 1 - assert data["matched"] == 1 + assert data["matched_events"] == 1 diff --git a/tests/integration/test_cli_commands.py b/tests/integration/test_cli_commands.py index c11f6801..6140e20d 100644 --- a/tests/integration/test_cli_commands.py +++ b/tests/integration/test_cli_commands.py @@ -9,6 +9,7 @@ """ import asyncio +import re import sys from datetime import UTC, datetime, timedelta @@ -21,6 +22,9 @@ from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession +from snore.analysis.modes.types import ModeResult +from snore.analysis.shared.types import ApneaEvent, HypopneaEvent +from snore.analysis.types import AnalysisEvent, AnalysisResult from snore.cli import cli from snore.database import models from snore.database.day_manager import DayManager @@ -915,6 +919,146 @@ def test_delete_analysis_all_flag(self, cli_runner, db_with_analysis, db_session assert total_analysis == 4 +@pytest.fixture +async def db_with_comparison(temp_db): + """One analyzed session: machine OA/H/FL, programmatic apnea + hypopnea.""" + await init_database(str(temp_db)) + + async with session_scope() as session: + profile = await _create_test_user_and_profile(session) + device = models.Device( + profile_id=profile.id, + manufacturer="ResMed", + model="AirSense 10", + serial_number="TEST12345", + ) + session.add(device) + await session.flush() + + start_time = datetime(2025, 10, 1, 22, 0, 0) + end_time = start_time + timedelta(hours=8) + sess = models.Session( + device_id=device.id, + device_session_id="test_compare", + start_time=start_time, + end_time=end_time, + duration_seconds=8 * 3600, + ) + session.add(sess) + await session.flush() + + result = AnalysisResult( + session_id=sess.id, + session_duration_hours=8.0, + total_breaths=1000, + machine_events=[ + AnalysisEvent( + event_type=event_type, + start_time=event_start, + duration=10.0, + source="machine", + ) + for event_type, event_start in ( + ("OA", 100.0), + ("H", 200.0), + ("FL", 300.0), + ) + ], + mode_results={ + "aasm": ModeResult( + mode_name="aasm", + apneas=[ + ApneaEvent( + start_time=101.0, + end_time=111.0, + duration=10.0, + event_type="OA", + flow_reduction=0.9, + confidence=0.8, + baseline_flow=30.0, + ) + ], + hypopneas=[ + HypopneaEvent( + start_time=400.0, + end_time=410.0, + duration=10.0, + flow_reduction=0.4, + confidence=0.7, + baseline_flow=30.0, + ) + ], + ahi=0.25, + rdi=0.25, + ) + }, + ) + session.add( + models.AnalysisResult( + session_id=sess.id, + timestamp_start=start_time, + timestamp_end=end_time, + programmatic_result_json=result.model_dump(mode="json"), + processing_time_ms=100, + engine_versions_json={"version": "1.0.0"}, + ) + ) + + return temp_db + + +class TestWaveformCompareCommand: + """Tests for the ``snore waveform compare`` command.""" + + def test_compare_prints_counts_and_unmatched_events( + self, cli_runner, db_with_comparison + ): + result = cli_runner.invoke( + cli, + [ + "waveform", + "compare", + "--db", + str(db_with_comparison), + "--session-id", + "1", + ], + ) + + assert result.exit_code == 0, result.output + output = " ".join(result.output.split()) + assert "Session 1 - Event Comparison (aasm mode)" in output + assert "Machine: 2 events (apneas and hypopneas only)" in output + assert re.search(r"Programmatic\W*: 2 events", output) + assert re.search(r"Matched\W*: 1\b", output) + assert re.search( + r"FALSE NEGATIVES\W* \(machine events missed[^:]*: 1\b", output + ) + assert "H at 00:03:20 (10.0s)" in output + assert re.search(r"FALSE POSITIVES\W* \(programmatic events[^:]*: 1\b", output) + assert "H at 00:06:40 (conf: 0.70, flow_red: 40%)" in output + + def test_compare_unavailable_mode_fails_with_message( + self, cli_runner, db_with_comparison + ): + result = cli_runner.invoke( + cli, + [ + "waveform", + "compare", + "--db", + str(db_with_comparison), + "--session-id", + "1", + "--mode", + "resmed", + ], + ) + + assert result.exit_code != 0 + assert "Mode 'resmed' not found in analysis results" in result.output + + class TestAnalysisCommand: """Test consolidated analysis command.""" diff --git a/tests/unit/test_event_detector.py b/tests/unit/test_event_detector.py index ae7406ca..23f8f529 100644 --- a/tests/unit/test_event_detector.py +++ b/tests/unit/test_event_detector.py @@ -2059,6 +2059,69 @@ def test_validation_reras_at_exactly_tolerance_boundary_match(self, aasm_detecto assert result["false_positive_events"] == [] assert result["false_negative_events"] == [] + @staticmethod + def _make_apnea(start_time: float) -> ApneaEvent: + return ApneaEvent( + start_time=start_time, + end_time=start_time + 10.0, + duration=10.0, + event_type="OA", + flow_reduction=0.9, + confidence=0.8, + baseline_flow=50.0, + ) + + @staticmethod + def _make_hypopnea(start_time: float) -> HypopneaEvent: + return HypopneaEvent( + start_time=start_time, + end_time=start_time + 10.0, + duration=10.0, + flow_reduction=0.4, + confidence=0.7, + baseline_flow=50.0, + ) + + def test_combined_lists_pair_a_machine_event_only_once(self, aasm_detector): + """A second programmatic event near a taken machine event is a false positive.""" + result = aasm_detector.validate_against_machine_events( + [self._make_apnea(100.0), self._make_apnea(102.0)], + [], + [self._make_apnea(101.0)], + [], + ) + + assert [e.start_time for e in result["false_positive_events"]] == [102.0] + assert result["false_negative_events"] == [] + + def test_combined_lists_pair_a_programmatic_event_only_once(self, aasm_detector): + """A second machine event near a taken programmatic event is a false negative.""" + result = aasm_detector.validate_against_machine_events( + [self._make_apnea(100.0)], + [], + [self._make_apnea(99.0), self._make_apnea(101.0)], + [], + ) + + assert [e.start_time for e in result["false_negative_events"]] == [101.0] + assert result["false_positive_events"] == [] + + def test_combined_lists_match_across_types_in_start_time_order(self, aasm_detector): + """Pooled matching follows time order, as ``snore waveform compare`` does. + + Matching the apnea first would take the 101s machine event and strand + the hypopnea, which only 101s can reach. + """ + result = aasm_detector.validate_against_machine_events( + [self._make_apnea(104.0)], + [self._make_hypopnea(97.0)], + [self._make_apnea(101.0), self._make_apnea(108.0)], + [], + ) + + assert result["false_positive_events"] == [] + assert result["false_negative_events"] == [] + def test_validation_reras_average_is_three_way_when_machine_present( self, aasm_detector ): diff --git a/tests/unit/test_match_events_by_start_time.py b/tests/unit/test_match_events_by_start_time.py index 1324e5e9..fa36e872 100644 --- a/tests/unit/test_match_events_by_start_time.py +++ b/tests/unit/test_match_events_by_start_time.py @@ -11,14 +11,20 @@ import pytest -from pydantic import BaseModel - from snore.analysis.modes.postprocess import match_events_by_start_time +from snore.analysis.shared.types import RERAEvent + +class _Ev(RERAEvent): + """A matchable event told apart by ``tag``; the matcher reads only start time.""" -class _Ev(BaseModel, frozen=True): - start_time: float tag: int + end_time: float = 0.0 + duration: float = 0.0 + obstructed_breath_count: int = 2 + recovery_amplitude_increase_pct: float = 0.0 + confidence: float = 0.0 + baseline_flow: float = 0.0 def _reference_match( diff --git a/tests/unit/test_provenance_tags.py b/tests/unit/test_provenance_tags.py index 1112e7a2..6e4c76ed 100644 --- a/tests/unit/test_provenance_tags.py +++ b/tests/unit/test_provenance_tags.py @@ -7,8 +7,11 @@ device, derived, or experimental data. See AGENTS.md "Provenance tagging". """ +import json + from enum import StrEnum from functools import cache +from pathlib import Path from typing import Any, Literal import pytest @@ -217,29 +220,15 @@ # Every REST operation carrying the ``experimental`` tag. Pinned so that tagging # a route cannot quietly exempt it from -# test_default_rest_operations_return_no_experimental_field. -EXPERIMENTAL_OPERATIONS = { - "DELETE /api/v1/analysis", - "DELETE /api/v1/analysis/jobs/{job_id}", - "DELETE /api/v1/validate/runs/{run_id}", - "GET /api/v1/analysis/delete-preview", - "GET /api/v1/analysis/jobs", - "GET /api/v1/analysis/sessions", - "GET /api/v1/days/{day_date}/experimental", - "GET /api/v1/sessions/{session_id}/analysis", - "GET /api/v1/sessions/{session_id}/waveforms/compare", - "GET /api/v1/stats/experimental", - "GET /api/v1/validate/runs", - "GET /api/v1/validate/runs/{run_id}", - "POST /api/v1/analysis/batch", - "POST /api/v1/sessions/{session_id}/analysis", - "POST /api/v1/validate/", - "POST /api/v1/validate/apple", - "POST /api/v1/validate/breaths", - "POST /api/v1/validate/fl", - "POST /api/v1/validate/rera", - "POST /api/v1/validate/runs", -} +# test_default_rest_operations_return_no_experimental_field. The UI's boundary +# test reads the same file, so a tagged route also needs a wrapper the UI gates. +EXPERIMENTAL_OPERATIONS = set( + json.loads( + ( + Path(__file__).parents[2] / "ui/src/tests/experimentalOperations.json" + ).read_text() + ) +) _FIX_HINT = ( "Wrap each metric in provenance_field(Provenance.X, ...); if a field is " diff --git a/tests/unit/test_waveform_service.py b/tests/unit/test_waveform_service.py index 26171074..7841a87c 100644 --- a/tests/unit/test_waveform_service.py +++ b/tests/unit/test_waveform_service.py @@ -1,6 +1,6 @@ """Unit tests for WaveformService.""" -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from types import SimpleNamespace from unittest.mock import AsyncMock @@ -11,8 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from snore.analysis.modes.postprocess import EVENT_MATCH_TOLERANCE_SECONDS +from snore.analysis.modes.types import ModeResult from snore.analysis.shared.types import ApneaEvent, HypopneaEvent -from snore.analysis.types import AnalysisEvent +from snore.analysis.types import AnalysisEvent, AnalysisResult +from snore.database.models import AnalysisResult as AnalysisResultRow from snore.database.models import Device, Profile, Session, User, Waveform from snore.exceptions import NotFoundError from snore.services import waveform_service as waveform_service_module @@ -387,6 +389,31 @@ def _make_analysis_result( ) +def _stored_analysis_row( + session: Session, apneas: list[ApneaEvent] +) -> AnalysisResultRow: + """An analysis row of ``session`` scoring one machine apnea at 100s.""" + result = AnalysisResult( + session_id=session.id, + session_duration_hours=8.0, + total_breaths=1000, + machine_events=[_machine_event(100.0)], + mode_results={ + "aasm": ModeResult( + mode_name="aasm", apneas=apneas, hypopneas=[], ahi=0.0, rdi=0.0 + ) + }, + ) + return AnalysisResultRow( + session_id=session.id, + timestamp_start=session.start_time, + timestamp_end=session.end_time, + programmatic_result_json=result.model_dump(mode="json"), + processing_time_ms=0, + created_at=datetime(2025, 1, 2, 0, 0, 0, tzinfo=UTC), + ) + + @pytest.fixture def compare(async_db_session, async_test_device, monkeypatch): """Run ``compare_events`` on a fresh session against the given analysis.""" @@ -467,12 +494,45 @@ async def test_missing_mode_raises_not_found( with pytest.raises(NotFoundError, match="Mode.*not found"): await service.compare_events(session.id, mode="resmed") + async def test_foreign_session_raises_not_found( + self, async_db_session, async_test_device + ): + session = await _seed_session(async_db_session, async_test_device, "foreign") + async_db_session.add(_stored_analysis_row(session, [_apnea(100.0)])) + other_user = User(canonical_email="other@example.com", role="admin") + async_db_session.add(other_user) + await async_db_session.flush() + other_profile = Profile(user_id=other_user.id, name="Other Profile") + async_db_session.add(other_profile) + await async_db_session.flush() + + owner = WaveformService(async_db_session, async_test_device.profile_id) + assert (await owner.compare_events(session.id)).matched_events == 1 + + stranger = WaveformService(async_db_session, other_profile.id) + with pytest.raises(NotFoundError, match=f"^Session {session.id} not found$"): + await stranger.compare_events(session.id) + + async def test_same_timestamp_analyses_use_the_highest_id( + self, async_db_session, async_test_device + ): + session = await _seed_session(async_db_session, async_test_device, "tied") + async_db_session.add(_stored_analysis_row(session, [_apnea(100.0)])) + await async_db_session.flush() + async_db_session.add(_stored_analysis_row(session, [])) + await async_db_session.flush() + + service = WaveformService(async_db_session, async_test_device.profile_id) + result = await service.compare_events(session.id) + + assert result.programmatic_event_count == 0 + async def test_no_events_returns_empty(self, compare): result = await compare() assert result.machine_event_count == 0 assert result.programmatic_event_count == 0 - assert result.matched == 0 + assert result.matched_events == 0 assert result.false_negatives == [] assert result.false_positives_apnea == [] assert result.false_positives_hypopnea == [] @@ -509,7 +569,7 @@ async def test_matched_events_are_counted_and_not_listed(self, compare): hypopneas=[_hypopnea(200.0)], ) - assert result.matched == 2 + assert result.matched_events == 2 assert result.false_negatives == [] assert result.false_positives_apnea == [] assert result.false_positives_hypopnea == [] @@ -520,7 +580,7 @@ async def test_apnea_and_hypopnea_match_across_types(self, compare): machine_events=[_machine_event(100.0)], hypopneas=[_hypopnea(101.0)] ) - assert result.matched == 1 + assert result.matched_events == 1 assert result.false_negatives == [] assert result.false_positives_hypopnea == [] @@ -532,7 +592,7 @@ async def test_non_matchable_device_types_are_ignored(self, compare, event_type) ) assert result.machine_event_count == 1 - assert result.matched == 1 + assert result.matched_events == 1 assert result.false_negatives == [] async def test_second_programmatic_event_near_one_machine_event_is_false_positive( @@ -543,7 +603,7 @@ async def test_second_programmatic_event_near_one_machine_event_is_false_positiv apneas=[_apnea(99.0), _apnea(101.0)], ) - assert result.matched == 1 + assert result.matched_events == 1 assert [e.start_time for e in result.false_positives_apnea] == [101.0] assert result.false_negatives == [] @@ -555,7 +615,7 @@ async def test_second_machine_event_near_one_programmatic_event_is_false_negativ apneas=[_apnea(100.0)], ) - assert result.matched == 1 + assert result.matched_events == 1 assert [e.start_time for e in result.false_negatives] == [101.0] assert result.false_positives_apnea == [] @@ -573,7 +633,7 @@ async def test_start_difference_at_tolerance_boundary( machine_events=[_machine_event(100.0)], apneas=[_apnea(100.0 + offset)] ) - assert result.matched == expected_matched + assert result.matched_events == expected_matched async def test_zero_machine_events_makes_every_detection_a_false_positive( self, compare @@ -582,7 +642,7 @@ async def test_zero_machine_events_makes_every_detection_a_false_positive( assert result.machine_event_count == 0 assert result.programmatic_event_count == 2 - assert result.matched == 0 + assert result.matched_events == 0 assert len(result.false_positives_apnea) == 1 assert len(result.false_positives_hypopnea) == 1 diff --git a/ui/src/components/TablePager.vue b/ui/src/components/TablePager.vue new file mode 100644 index 00000000..4988520b --- /dev/null +++ b/ui/src/components/TablePager.vue @@ -0,0 +1,20 @@ + + + diff --git a/ui/src/tests/AnalysisView.test.ts b/ui/src/tests/AnalysisView.test.ts index 6dd4c918..43a96759 100644 --- a/ui/src/tests/AnalysisView.test.ts +++ b/ui/src/tests/AnalysisView.test.ts @@ -8,7 +8,7 @@ vi.mock('@/api/waveforms', () => ({ getWaveformCompare: vi.fn() })) import AnalysisView from '@/views/AnalysisView.vue' import { useAuth } from '@/composables/useAuth' -import { getAnalysis } from '@/api/analysis' +import { getAnalysis, runAnalysis } from '@/api/analysis' import { getWaveformCompare } from '@/api/waveforms' import { makeAuthMock } from './helpers/mockUseAuth' import { setMediaMatches } from './matchMedia' @@ -27,10 +27,19 @@ const ANALYSIS = { periodic_breathing: null, } +const TWO_MODE_ANALYSIS = { + ...ANALYSIS, + mode_results: { + ...ANALYSIS.mode_results, + resmed: { ahi: 3.1, rdi: 3.1, apneas: [], hypopneas: [], reras: [] }, + }, +} + const COMPARISON = { + mode: 'aasm', machine_event_count: 3, programmatic_event_count: 4, - matched: 2, + matched_events: 2, false_negatives: [{ event_type: 'OA', start_time: 100, duration: 12, source: 'machine' }], false_positives_apnea: [ { event_type: 'CA', start_time: 200, duration: 15, source: 'programmatic' }, @@ -99,11 +108,22 @@ describe('AnalysisView', () => { ) }) - function statValue(wrapper: Awaited>, label: string) { - const section = wrapper + type View = Awaited> + + function comparisonSection(wrapper: View) { + return wrapper .findAll('.section-card') - .find((s) => s.find('h2').text() === 'Event Comparison')! - return section + .find((s) => s.find('h2').text() === 'Event Comparison') + } + + async function selectMode(wrapper: View, mode: string) { + const item = wrapper.findAll('.mode-selector button').find((b) => b.text() === mode)! + await item.trigger('click') + await flushPromises() + } + + function statValue(wrapper: View, label: string) { + return comparisonSection(wrapper)! .findAll('.stat-card') .find((c) => c.find('.stat-label').text().startsWith(label))! .find('.stat-value') @@ -121,11 +141,141 @@ describe('AnalysisView', () => { vi.mocked(getWaveformCompare).mockResolvedValue({ ...COMPARISON, machine_event_count: 0, - matched: 0, + matched_events: 0, } as never) const wrapper = await mountView() expect(statValue(wrapper, 'Matched')).toBe('0') expect(statValue(wrapper, 'Sensitivity')).toBe('---') }) + + it('test_running_analysis_from_the_page_shows_event_comparison', async () => { + vi.mocked(getAnalysis).mockRejectedValue({ response: { status: 404 } }) + vi.mocked(runAnalysis).mockResolvedValue(ANALYSIS as never) + const wrapper = await mountView() + expect(getWaveformCompare).not.toHaveBeenCalled() + + await wrapper.find('.empty-card button').trigger('click') + await flushPromises() + + expect(statValue(wrapper, 'Matched')).toBe('2') + }) + + it('test_comparison_follows_the_selected_mode', async () => { + vi.mocked(getAnalysis).mockResolvedValue(TWO_MODE_ANALYSIS as never) + vi.mocked(getWaveformCompare).mockImplementation((async ( + _sessionId: number, + params?: { mode?: string }, + ) => ({ + ...COMPARISON, + mode: params?.mode, + matched_events: params?.mode === 'resmed' ? 1 : 2, + })) as never) + const wrapper = await mountView() + expect(comparisonSection(wrapper)!.text()).toContain('Detection mode: aasm') + expect(statValue(wrapper, 'Matched')).toBe('2') + + await selectMode(wrapper, 'resmed') + + expect(getWaveformCompare).toHaveBeenLastCalledWith(7, { mode: 'resmed' }) + expect(comparisonSection(wrapper)!.text()).toContain('Detection mode: resmed') + expect(statValue(wrapper, 'Matched')).toBe('1') + }) + + it('test_mode_without_comparison_hides_the_section', async () => { + vi.mocked(getAnalysis).mockResolvedValue(TWO_MODE_ANALYSIS as never) + const wrapper = await mountView() + vi.mocked(getWaveformCompare).mockRejectedValue(new Error('Mode not found')) + + await selectMode(wrapper, 'resmed') + + expect(comparisonSection(wrapper)).toBeUndefined() + }) + + it('test_slow_response_for_an_earlier_mode_is_dropped', async () => { + vi.mocked(getAnalysis).mockResolvedValue(TWO_MODE_ANALYSIS as never) + const responses: Array<(mode: string) => void> = [] + vi.mocked(getWaveformCompare).mockImplementation( + (() => + new Promise((resolve) => { + responses.push((mode) => resolve({ ...COMPARISON, mode })) + })) as never, + ) + const wrapper = await mountView() + await selectMode(wrapper, 'resmed') + + responses[1]!('resmed') + await flushPromises() + responses[0]!('aasm') + await flushPromises() + + expect(comparisonSection(wrapper)!.text()).toContain('Detection mode: resmed') + }) + + function falseEvents(count: number) { + return Array.from({ length: count }, (_, i) => ({ + event_type: 'OA', + start_time: i, + duration: 10, + source: 'machine', + })) + } + + function compareTable(wrapper: View, heading: string) { + const table = comparisonSection(wrapper)! + .findAll('.compare-table-section') + .find((s) => s.find('h3').text().startsWith(heading))! + return table + } + + async function clickNext(wrapper: View, heading: string) { + const next = compareTable(wrapper, heading) + .findAll('button') + .find((b) => b.text() === 'Next')! + await next.trigger('click') + } + + it.each([ + ['False Negatives', 'false_negatives'], + ['False Positives', 'false_positives_apnea'], + ])('test_%s_table_pages_at_25_rows', async (heading, field) => { + vi.mocked(getWaveformCompare).mockResolvedValue({ + ...COMPARISON, + [field]: falseEvents(30), + } as never) + const wrapper = await mountView() + expect(compareTable(wrapper, heading).findAll('tbody tr')).toHaveLength(25) + expect(compareTable(wrapper, heading).text()).toContain('Page 1 of 2') + + await clickNext(wrapper, heading) + + expect(compareTable(wrapper, heading).findAll('tbody tr')).toHaveLength(5) + expect(compareTable(wrapper, heading).text()).toContain('Page 2 of 2') + }) + + it('test_mobile_false_negative_cards_page_at_25', async () => { + setMediaMatches(true) + vi.mocked(getWaveformCompare).mockResolvedValue({ + ...COMPARISON, + false_negatives: falseEvents(30), + } as never) + const wrapper = await mountView() + + const section = compareTable(wrapper, 'False Negatives') + expect(section.findAll('.data-card')).toHaveLength(25) + }) + + it('test_changing_mode_returns_comparison_tables_to_the_first_page', async () => { + vi.mocked(getAnalysis).mockResolvedValue(TWO_MODE_ANALYSIS as never) + vi.mocked(getWaveformCompare).mockResolvedValue({ + ...COMPARISON, + false_negatives: falseEvents(30), + } as never) + const wrapper = await mountView() + await clickNext(wrapper, 'False Negatives') + + await selectMode(wrapper, 'resmed') + + expect(compareTable(wrapper, 'False Negatives').text()).toContain('Page 1 of 2') + }) }) diff --git a/ui/src/tests/experimentalBoundary.test.ts b/ui/src/tests/experimentalBoundary.test.ts index 55a0cf5a..4ac0e4af 100644 --- a/ui/src/tests/experimentalBoundary.test.ts +++ b/ui/src/tests/experimentalBoundary.test.ts @@ -1,48 +1,114 @@ -import { describe, expect, it } from 'vitest' -import * as analysisApi from '@/api/analysis' -import * as validationApi from '@/api/validation' -import * as validationRunsApi from '@/api/validationRuns' - -const ALL_WRAPPERS = [ - ...Object.keys(analysisApi), - ...Object.keys(validationApi), - ...Object.keys(validationRunsApi), - 'getDayExperimental', - 'getExperimentalStats', - 'getWaveformCompare', -] +import { beforeAll, describe, expect, it } from 'vitest' +import api from '@/api/client' +import experimentalOperations from './experimentalOperations.json' + // Cancelling a post-import job reads no metrics, and ImportView needs it. -const EXPERIMENTAL_WRAPPERS = ALL_WRAPPERS.filter((name) => name !== 'cancelAnalysisJob') +const METRIC_FREE_WRAPPERS = ['cancelAnalysisJob'] const ALLOWED_SOURCES = [ /^\.\.\/views\/(AnalysisView|AnalysisManagementView|ValidationView|DayExperimentalView)\.vue$/, + /^\.\.\/components\/TherapyAssessmentCard\.vue$/, /^\.\.\/components\/validation\//, /^\.\.\/composables\/useValidationRuns\.ts$/, ] +// Allowlisted modules that read experimental data: default pages must not import them. +const EXPERIMENTAL_MODULES = /\buseValidationRuns\b|\/validation\/\w+\.vue/ + const SOURCES = import.meta.glob( - ['../views/*.vue', '../components/**/*.vue', '../composables/*.ts'], + ['../**/*.{vue,ts}', '!../api/**', '!../tests/**', '!../types/**'], { query: '?raw', import: 'default', eager: true }, ) -function findOffenders(): string[] { - return Object.entries(SOURCES) +const API_MODULES = import.meta.glob>( + ['../api/*.ts', '!../api/client.ts'], + { eager: true }, +) + +interface WrapperCall { + name: string + operation: string +} + +/** Calls every exported API function with a fake transport to see which operation it hits. */ +async function callEveryWrapper(): Promise { + const calls: WrapperCall[] = [] + let name = '' + api.defaults.adapter = async (config) => { + calls.push({ name, operation: `${config.method?.toUpperCase()} ${config.url}` }) + return { data: null, status: 200, statusText: 'OK', headers: {}, config } + } + for (const module of Object.values(API_MODULES)) { + for (const [exportName, wrapper] of Object.entries(module)) { + if (typeof wrapper !== 'function') continue + name = exportName + // A helper that rejects these placeholders makes no request, so records nothing. + await Promise.resolve() + .then(() => wrapper('{}', '{}', '{}')) + .catch(() => undefined) + } + } + return calls +} + +function findOffenders(sources: Record, wrappers: string[]): string[] { + return Object.entries(sources) .filter(([file]) => !ALLOWED_SOURCES.some((allowed) => allowed.test(file))) - .flatMap(([file, source]) => - EXPERIMENTAL_WRAPPERS.filter((name) => new RegExp(`\\b${name}\\b`).test(source)).map( - (name) => `${file}: ${name}`, - ), - ) + .flatMap(([file, source]) => [ + ...wrappers + .filter((name) => new RegExp(`\\b${name}\\b`).test(source)) + .map((name) => `${file}: ${name}`), + ...(source.match(EXPERIMENTAL_MODULES) ?? []).map((match) => `${file}: ${match}`), + ]) } describe('experimental boundary', () => { + const operations = experimentalOperations.map((operation) => + operation.replace('/api/v1', '').replace(/\{\w+\}/g, '{}'), + ) + let calls: WrapperCall[] + let experimentalWrappers: string[] + + beforeAll(async () => { + calls = await callEveryWrapper() + experimentalWrappers = calls + .filter(({ operation }) => operations.includes(operation)) + .map(({ name }) => name) + .filter((name) => !METRIC_FREE_WRAPPERS.includes(name)) + }) + + it('test_every_experimental_operation_has_a_wrapper', () => { + const called = new Set(calls.map(({ operation }) => operation)) + expect(operations.filter((operation) => !called.has(operation))).toEqual([]) + }) + it('test_wrapper_and_source_lists_are_populated', () => { - expect(EXPERIMENTAL_WRAPPERS).toContain('getAnalysis') - expect(EXPERIMENTAL_WRAPPERS).not.toContain('cancelAnalysisJob') - expect(Object.keys(SOURCES)).toContain('../views/DashboardView.vue') + expect(experimentalWrappers).toEqual( + expect.arrayContaining(['getAnalysis', 'getDayExperimental', 'getWaveformCompare']), + ) + expect(experimentalWrappers).not.toContain('cancelAnalysisJob') + expect(experimentalWrappers).not.toContain('getDay') + expect(Object.keys(SOURCES)).toEqual( + expect.arrayContaining([ + '../App.vue', + '../router/index.ts', + '../utils/formatting.ts', + '../views/DashboardView.vue', + ]), + ) + expect(Object.keys(SOURCES)).not.toContain('../api/analysis.ts') }) it('test_default_ui_references_no_experimental_wrappers', () => { - expect(findOffenders()).toEqual([]) + expect(findOffenders(SOURCES, experimentalWrappers)).toEqual([]) + }) + + it.each([ + "import { useValidationRuns } from '@/composables/useValidationRuns'", + "import FlValidationPanel from '@/components/validation/FlValidationPanel.vue'", + "import FlValidationPanel from './validation/FlValidationPanel.vue'", + ])('test_default_page_importing_experimental_module_is_flagged: %s', (source) => { + const offenders = findOffenders({ '../views/DashboardView.vue': source }, []) + expect(offenders).toHaveLength(1) }) }) diff --git a/ui/src/tests/experimentalOperations.json b/ui/src/tests/experimentalOperations.json new file mode 100644 index 00000000..4504a997 --- /dev/null +++ b/ui/src/tests/experimentalOperations.json @@ -0,0 +1,22 @@ +[ + "DELETE /api/v1/analysis", + "DELETE /api/v1/analysis/jobs/{job_id}", + "DELETE /api/v1/validate/runs/{run_id}", + "GET /api/v1/analysis/delete-preview", + "GET /api/v1/analysis/jobs", + "GET /api/v1/analysis/sessions", + "GET /api/v1/days/{day_date}/experimental", + "GET /api/v1/sessions/{session_id}/analysis", + "GET /api/v1/sessions/{session_id}/waveforms/compare", + "GET /api/v1/stats/experimental", + "GET /api/v1/validate/runs", + "GET /api/v1/validate/runs/{run_id}", + "POST /api/v1/analysis/batch", + "POST /api/v1/sessions/{session_id}/analysis", + "POST /api/v1/validate/", + "POST /api/v1/validate/apple", + "POST /api/v1/validate/breaths", + "POST /api/v1/validate/fl", + "POST /api/v1/validate/rera", + "POST /api/v1/validate/runs" +] diff --git a/ui/src/types/generated.ts b/ui/src/types/generated.ts index 3e629011..09637a78 100644 --- a/ui/src/types/generated.ts +++ b/ui/src/types/generated.ts @@ -3405,10 +3405,10 @@ export interface components { */ machine_event_count: number /** - * Matched + * Matched Events * @description [EXPERIMENTAL] Programmatic/machine event pairs matched one-to-one within tolerance (each event in at most one pair) */ - matched: number + matched_events: number /** * Mode * @description Detection mode used (e.g., 'aasm') diff --git a/ui/src/views/AnalysisView.vue b/ui/src/views/AnalysisView.vue index d95040fc..75f2aac0 100644 --- a/ui/src/views/AnalysisView.vue +++ b/ui/src/views/AnalysisView.vue @@ -226,29 +226,7 @@ -
- - Page {{ eventsPage + 1 }} of {{ totalEventPages }} - -
- - -
-
+
@@ -385,6 +363,7 @@

Event Comparison

+

Detection mode: {{ comparison.mode }}

- +

False Negatives: Device-scored Events SNORE Missed

-
+
@@ -492,6 +467,10 @@ +

False Positives: SNORE-detected Events the Device Did Not Score

-
+
@@ -588,13 +567,17 @@ +