forked from ChelseaKR/olive-bark-logger
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_report_content.py
More file actions
773 lines (642 loc) · 28.9 KB
/
Copy pathtest_report_content.py
File metadata and controls
773 lines (642 loc) · 28.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
"""Merge-blocking: the report carries methodology + limitations and honest framing."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from monitor.config import Config
from monitor.detector import Event
from report.aggregate import summarize
from report.render import (
LIMITATIONS_HEADING,
METHODOLOGY_HEADING,
NO_CLOCK_ANOMALY_NOTE,
NO_SOURCE_NOTE,
PARAM_CHANGE_NOTE,
RELATIVE_DBFS_NOTE,
build_report,
generate_report_from_db,
)
def _report(events=None, **cfg):
config = Config(**cfg)
summary = summarize(events or [], quiet_hours=config.quiet_hours, tz=config.tzinfo())
return build_report(summary, config=config, generated_at="2026-01-01 00:00 UTC")
def test_has_methodology_and_limitations_headings():
html = _report()
assert f"<h2>{METHODOLOGY_HEADING}</h2>" in html
assert f"<h2>{LIMITATIONS_HEADING}</h2>" in html
def test_states_relative_dbfs_limitation():
html = _report()
assert RELATIVE_DBFS_NOTE in html
assert "relative" in html.lower() and "dbfs" in html.lower()
def test_states_no_source_attribution():
html = _report()
assert NO_SOURCE_NOTE in html
assert "cannot prove" in html.lower()
def test_states_no_audio_recorded():
html = _report()
assert "No audio was recorded" in html
def test_uncalibrated_is_disclosed():
html = _report(calibration_offset=0.0)
assert "No calibration offset is applied" in html
def test_calibrated_offset_is_disclosed():
html = _report(calibration_offset=12.5)
assert "+12.5 dB" in html
def test_reports_event_numbers():
start = datetime(2026, 1, 1, 23, tzinfo=timezone.utc).timestamp()
ev = Event(start=start, end=start + 5, duration=5.0, peak_level=-8.0, avg_level=-12.0)
html = _report([ev])
assert "Total events" in html
assert ">1<" in html # the count appears
def test_measurement_conditions_without_session():
html = _report()
assert "<h2>Measurement conditions</h2>" in html
assert "were not recorded" in html
def test_measurement_conditions_with_session():
from store import Session
session = Session(
id=1,
started_at=0.0,
ended_at=1.0,
device_label="pi-1",
mic_model="USB mic",
placement_note="by the wall",
tz="UTC",
calibration_offset=0.0,
calibration_note="x",
frames_seen=990,
frames_dropped=10,
app_version="0.1.0",
)
config = Config()
summary = summarize([], quiet_hours=config.quiet_hours, tz=config.tzinfo())
html = build_report(
summary, config=config, generated_at="2026-01-01 00:00 UTC", session=session
)
assert "pi-1" in html and "USB mic" in html and "by the wall" in html
assert "99.0%" in html # frame coverage
def test_event_types_section_appears_with_tags():
base = datetime(2026, 1, 1, 12, tzinfo=timezone.utc).timestamp()
events = [
Event(base, base + 2, 2.0, -8, -12, coarse_tag="bark-like"),
Event(base + 10, base + 11, 1.0, -9, -13, coarse_tag="ambient"),
Event(base + 20, base + 22, 2.0, -7, -11, coarse_tag="bark-like"),
]
html = _report(events)
assert "<h2>Event types (coarse hint)</h2>" in html
assert "bark-like" in html and "ambient" in html
assert "hint, not a fact" in html
def test_event_types_section_absent_without_tags():
base = datetime(2026, 1, 1, 12, tzinfo=timezone.utc).timestamp()
html = _report([Event(base, base + 2, 2.0, -8, -12)])
assert "Event types" not in html
def test_no_clock_anomalies_disclosed_by_default():
html = _report()
assert NO_CLOCK_ANOMALY_NOTE in html
def test_clock_anomaly_disclosure_line_appears_from_db(tmp_path):
from store import EventStore
db = tmp_path / "olive.db"
with EventStore(db) as store:
store.add_clock_anomaly(
session_id=None,
kind="forward-jump",
wall_before=1010.0,
wall_after=8210.0,
delta=7200.0,
detected_at=8210.0,
)
html = generate_report_from_db(str(db), Config(tz="UTC"), generated_at="2026-01-01 00:00 UTC")
assert "Clock jumped forward by 7200.0 s" in html
assert NO_CLOCK_ANOMALY_NOTE not in html
def _session(sid, started_at, **params):
from store import Session
return Session(
id=sid,
started_at=started_at,
ended_at=started_at + 100,
device_label="pi-1",
mic_model="USB mic",
placement_note="by the wall",
tz="UTC",
calibration_offset=0.0,
calibration_note="x",
frames_seen=100,
frames_dropped=0,
app_version="0.1.0",
**params,
)
def test_single_session_shows_no_parameter_epochs_table():
session = _session(1, 0.0, threshold_dbfs=-35.0, min_duration_s=0.4, debounce_s=1.0)
config = Config()
summary = summarize([], quiet_hours=config.quiet_hours, tz=config.tzinfo())
html = build_report(
summary,
config=config,
generated_at="2026-01-01 00:00 UTC",
session=session,
sessions=[session],
)
assert "Detection-parameter epochs" not in html
assert PARAM_CHANGE_NOTE not in html
def test_two_sessions_with_different_thresholds_render_epochs(tmp_path):
from monitor.detector import Event
from store import EventStore
db = tmp_path / "olive.db"
with EventStore(db) as store:
common = dict(
device_label="pi-1",
mic_model="USB mic",
placement_note="by the wall",
tz="UTC",
calibration_offset=0.0,
calibration_note="x",
app_version="0.1.0",
min_duration_s=0.4,
debounce_s=1.0,
sample_rate=16000,
frame_size=1600,
)
base = datetime(2026, 1, 1, 12, tzinfo=timezone.utc).timestamp()
s1 = store.start_session(started_at=base, threshold_dbfs=-35.0, **common)
store.add_event(Event(base + 1, base + 3, 2.0, -8, -12), session_id=s1)
later = datetime(2026, 1, 5, 12, tzinfo=timezone.utc).timestamp()
s2 = store.start_session(started_at=later, threshold_dbfs=-42.0, **common)
store.add_event(Event(later + 1, later + 3, 2.0, -20, -24), session_id=s2)
html = generate_report_from_db(str(db), Config(tz="UTC"), generated_at="2026-01-06 00:00 UTC")
assert "Detection-parameter epochs" in html
assert PARAM_CHANGE_NOTE in html
# Both thresholds in force during the record are named.
assert "-35 dBFS" in html
assert "-42 dBFS" in html
# Each epoch is dated by its first session's start.
assert "2026-01-01" in html and "2026-01-05" in html
def test_fmt_seconds_minutes_and_hours():
# Exercise the minute/hour formatting branches via long durations.
base = datetime(2026, 1, 1, 12, tzinfo=timezone.utc).timestamp()
long_event = Event(start=base, end=base + 4000, duration=4000.0, peak_level=-5, avg_level=-9)
html = _report([long_event])
assert " h" in html # 4000 s renders in hours
# --- R1: "what this can and cannot prove" cover page -------------------------
def test_cover_page_present_and_states_limits():
html = _report()
assert "<h2>What this can and cannot prove</h2>" in html
assert 'class="cover"' in html
assert "What it can show" in html and "What it cannot prove" in html
# The cover restates the headline limitations in lay terms.
assert "no source attribution" in html
assert "not the units an ordinance" in html
assert "is not the same as a violation" in html
assert "not legal advice" in html
# --- R2: calibration-honesty banner + provenance ----------------------------
def test_uncalibrated_banner_is_prominent():
html = _report(calibration_offset=0.0)
assert 'class="banner"' in html
assert "Uncalibrated — these readings are relative, not dB(A)." in html
assert 'role="note"' in html
def test_calibrated_banner_shows_provenance():
html = _report(
calibration_offset=12.5,
calibration_note="Ref: Brand X, IEC 61672 Class 2",
)
assert "banner-ok" in html
assert "+12.5 dB" in html
assert "Brand X, IEC 61672 Class 2" in html
# --- R3: quiet-hours duration rollup (no verdict) ---------------------------
def test_duration_rollup_reports_minutes_without_a_verdict():
base = datetime(2026, 1, 1, 23, tzinfo=timezone.utc).timestamp()
ev = Event(start=base, end=base + 120, duration=120.0, peak_level=-8.0, avg_level=-12.0)
html = _report([ev]) # 23:00 is within the default 22:00-08:00 window
assert "<h2>Quiet-hours duration rollup</h2>" in html
assert "Loud time within quiet hours, per day" in html
# The no-verdict line is mandatory and must be present verbatim in spirit.
assert "This is a measurement, not a determination" in html
assert "is not the same as a violation" in html
# Ordinance reference framing is hedged as jurisdiction-dependent.
assert "vary by jurisdiction" in html
def test_duration_rollup_empty_when_no_quiet_hours_events():
base = datetime(2026, 1, 1, 12, tzinfo=timezone.utc).timestamp()
ev = Event(start=base, end=base + 5, duration=5.0, peak_level=-8.0, avg_level=-12.0)
html = _report([ev]) # noon -> outside the quiet window
assert "<h2>Quiet-hours duration rollup</h2>" in html
assert "nothing to roll up" in html
# --- R5: reader-facing no-audio rationale -----------------------------------
def test_reader_facing_no_audio_rationale_present():
html = _report()
assert "<h2>Why there is deliberately no audio</h2>" in html
assert "deliberate privacy choice, not missing data" in html
assert "leaked, subpoenaed, or misused" in html
# --- FIX-39: the handed-over quiet-hours report states its own coverage ------
#
# This file is the merge-blocking honesty gate, and until now it only covered
# `build_report`. The violations renderer is the document the README points at for a
# "neighbor/landlord/HOA submission", so it needs its gate here too -- and the gate is
# written as the *absence of the overstatement*: there must be no rendering path that
# prints counts while saying nothing about how much of the window was observed.
def _violation_html(events=None, *, gaps=None, sessions=None):
from monitor.config import QuietHours
from report.violations import build_violation_report_html, compute_violations
report = compute_violations(
events or [],
quiet_hours=QuietHours(22, 8),
tz=timezone.utc,
gaps=gaps,
sessions=sessions,
)
return build_violation_report_html(
report,
threshold_dbfs=-35.0,
min_duration_s=0.4,
generated_at="2026-01-01 00:00 UTC",
calibrated=False,
)
def _gap(start, end, reason="shutdown"):
from store import Gap
return Gap(id=1, session_id=None, start=start, end=end, reason=reason)
def _quiet_hours_scenario():
"""The issue's scenario: a ten-hour quiet window, one 60 s event at 22:10, and a
recorded monitoring gap covering eight of those hours."""
from store import Session
win_start = datetime(2026, 1, 1, 22, tzinfo=timezone.utc).timestamp()
ev_start = win_start + 600 # 22:10
event = Event(start=ev_start, end=ev_start + 60, duration=60.0, peak_level=-8, avg_level=-12)
gap = _gap(win_start + 5400, win_start + 34200) # 23:30 -> 07:30, eight hours
session = Session(
id=1,
started_at=win_start,
ended_at=win_start + 36000, # 08:00
device_label="pi-1",
mic_model="USB mic",
placement_note="by the wall",
tz="UTC",
calibration_offset=0.0,
calibration_note="x",
frames_seen=100,
frames_dropped=0,
app_version="0.1.0",
)
return [event], [gap], session
def test_no_violation_report_shows_counts_without_stating_coverage():
"""Every shape of quiet-hours report the code can produce -- empty, events only,
events plus gaps, and the full session case -- states its coverage next to the
counts. A count with no coverage line is the defect this gate exists to block."""
from report.violations import COVERAGE_HEADING
events, gaps, session = _quiet_hours_scenario()
base = datetime(2026, 1, 1, 23, tzinfo=timezone.utc).timestamp()
one = [Event(start=base, end=base + 5, duration=5.0, peak_level=-8, avg_level=-12)]
cases = {
"empty log": _violation_html(),
"events, no gaps": _violation_html(one),
"events and a gap": _violation_html(events, gaps=gaps),
"events, gap, session": _violation_html(events, gaps=gaps, sessions=[session]),
}
for name, html in cases.items():
assert "Total events logged" in html, f"{name}: counts missing from the report"
assert COVERAGE_HEADING in html, f"{name}: counts shown with no coverage statement"
# Either a real figure or an explicit "could not be determined" -- never silence.
assert "wall-clock hours" in html or "could not be determined" in html, (
f"{name}: coverage neither stated nor declared undeterminable"
)
def test_coverage_is_stated_with_figures_and_above_the_counts():
events, gaps, session = _quiet_hours_scenario()
html = _violation_html(events, gaps=gaps, sessions=[session])
# 10 wall-clock hours, 8 of them inside a recorded gap.
assert "monitored 2.0 of 10.0 wall-clock hours (20%)" in html
assert "the remaining 8.0 hours are shown as not monitored rather than quiet" in html
# Prominent and above the counts, not buried in Limitations.
assert html.index("Monitoring coverage") < html.index("Total events logged")
assert html.index("Monitoring coverage") < html.index("<h2>Limitations</h2>")
assert 'role="note"' in html
def test_unmonitored_time_is_visible_not_absent():
"""The gap itself is printed, and the report says an unmonitored hour is not a quiet
hour -- absence of a recorded event must never read as absence of the event."""
from report.violations import ABSENCE_NOTE
events, gaps, session = _quiet_hours_scenario()
html = _violation_html(events, gaps=gaps, sessions=[session])
assert "Recorded monitoring gaps" in html
assert "not evidence that no sound occurred" in html
assert ABSENCE_NOTE in html
# The gap's own bounds are on the page, so the reader can see when the hole was.
assert "2026-01-01T23:30:00+00:00" in html and "2026-01-02T07:30:00+00:00" in html
def test_coverage_declares_its_own_limit_when_it_cannot_be_computed():
from report.violations import COVERAGE_UNKNOWN_NOTE
html = _violation_html() # nothing recorded at all: no events, no gaps, no session
assert COVERAGE_UNKNOWN_NOTE in html
assert "could not be determined" in html
assert "not determined" in html # the summary cells say so too, rather than showing 0.0
def test_coverage_never_claims_more_than_was_recorded():
"""A gap the monitor never wrote down cannot appear, so the figure is an upper
bound and says so -- including on a log with no recorded gaps at all."""
from report.violations import NO_RECORDED_GAPS_NOTE, UNRECORDED_GAP_CAVEAT
base = datetime(2026, 1, 1, 23, tzinfo=timezone.utc).timestamp()
html = _violation_html([Event(base, base + 5, 5.0, -8, -12)])
assert UNRECORDED_GAP_CAVEAT in html
assert "upper bound" in html
assert NO_RECORDED_GAPS_NOTE in html # stated, not left blank
def test_monitored_flag_reaches_the_human_readable_table():
"""The CSV has carried `monitored` all along; the HTML twin dropped it."""
events, gaps, session = _quiet_hours_scenario()
html = _violation_html(events, gaps=gaps, sessions=[session])
assert '<th scope="col">Monitored</th>' in html
# The single event overlaps nothing, but the column must still render per row.
assert html.count("<td>") >= 1
inside_gap = [
Event(
start=gaps[0].start + 60,
end=gaps[0].start + 65,
duration=5.0,
peak_level=-8,
avg_level=-12,
)
]
flagged = _violation_html(inside_gap, gaps=gaps, sessions=[session])
assert '<th scope="col">Monitored</th>' in flagged
assert "recorded monitoring gap" in flagged
def test_violations_csv_preamble_carries_the_coverage_statement(tmp_path):
from monitor.config import QuietHours
from report.violations import COVERAGE_HEADING, violations_to_csv
events, gaps, session = _quiet_hours_scenario()
out = tmp_path / "violations.csv"
violations_to_csv(
events,
out,
quiet_hours=QuietHours(22, 8),
tz=timezone.utc,
gaps=gaps,
sessions=[session],
)
preamble = [ln for ln in out.read_text().splitlines() if ln.startswith("#")]
text = "\n".join(preamble)
assert COVERAGE_HEADING in text
assert "monitored 2.0 of 10.0 wall-clock hours" in text
assert "Recorded monitoring gaps" in text
def test_report_cli_violations_html_states_coverage(tmp_path):
"""End to end through the CLI, the path a tenant actually runs."""
import json
from monitor.config import Config
from report.render import main as report_main
from store import EventStore
events, gaps, _session = _quiet_hours_scenario()
db = tmp_path / "olive.db"
with EventStore(db) as store:
store.add_event(events[0])
store.add_gap(start=gaps[0].start, end=gaps[0].end, reason=gaps[0].reason)
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"db_path": str(db), "tz": "UTC"}),
encoding="utf-8",
)
vhtml = tmp_path / "v.html"
vcsv = tmp_path / "v.csv"
rc = report_main(
[
"--config",
str(config_path),
"--db",
str(db),
"--out",
str(tmp_path / "r.html"),
"--violations-html",
str(vhtml),
"--violations-csv",
str(vcsv),
"--generated-at",
"2026-01-01 UTC",
]
)
assert rc == 0
html = vhtml.read_text()
assert "Monitoring coverage" in html
assert "wall-clock hours" in html
assert "Recorded monitoring gaps" in html
assert "not monitored rather than quiet" in html
assert "Monitoring coverage" in vcsv.read_text()
assert Config.load(config_path) is not None
# --- Off-air time: the monitor not running is not the same as a quiet hour ----------
#
# The gap ledger only ever receives 'device-error' rows, written by resilient_source
# while the monitor is up. The far more common outage -- the monitor simply not running
# (stopped, rebooted, power cut, crashed) -- leaves no gap row at all. It does leave two
# session rows, so the store knows exactly when the device was off air. Coverage must
# read those, or the handed-over report claims 100% coverage of a window it mostly slept
# through.
def _two_session_log(store):
"""22:00-23:00 monitored, off air until 07:00, 07:00-08:00 monitored. One event each.
Returns nothing; writes straight into the store the way two real runs would.
"""
day = datetime(2026, 3, 10, tzinfo=timezone.utc)
def at(hour, minute=0, next_day=False):
return day.timestamp() + (86400 if next_day else 0) + hour * 3600 + minute * 60
first = store.start_session(
started_at=at(22),
device_label="pi-1",
mic_model="USB mic",
placement_note="by the wall",
tz="UTC",
calibration_offset=0.0,
calibration_note="x",
app_version="0.1.0",
)
store.add_event(
Event(start=at(22, 30), end=at(22, 30) + 12, duration=12.0, peak_level=-20, avg_level=-25),
session_id=first,
)
store.update_session(first, frames_seen=36000, frames_dropped=0, ended_at=at(23))
# --- monitor off air 23:00 -> 07:00: no frames, no gap row, two session rows ---
second = store.start_session(
started_at=at(7, next_day=True),
device_label="pi-1",
mic_model="USB mic",
placement_note="by the wall",
tz="UTC",
calibration_offset=0.0,
calibration_note="x",
app_version="0.1.0",
)
store.add_event(
Event(
start=at(7, 30, next_day=True),
end=at(7, 30, next_day=True) + 9,
duration=9.0,
peak_level=-18,
avg_level=-24,
),
session_id=second,
)
store.update_session(second, frames_seen=36000, frames_dropped=0, ended_at=at(8, next_day=True))
def _render_two_session_artifacts(tmp_path):
import json
from report.render import main as report_main
from store import EventStore
db = tmp_path / "olive.db"
with EventStore(db) as store:
_two_session_log(store)
assert store.gaps() == [] # the ledger genuinely has nothing to say
assert len(store.sessions()) == 2 # but the session rows do
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"db_path": str(db), "tz": "UTC"}), encoding="utf-8")
main_html, vhtml, vcsv = tmp_path / "r.html", tmp_path / "v.html", tmp_path / "v.csv"
rc = report_main(
[
"--config",
str(config_path),
"--db",
str(db),
"--out",
str(main_html),
"--violations-html",
str(vhtml),
"--violations-csv",
str(vcsv),
"--generated-at",
"2026-03-11 UTC",
]
)
assert rc == 0
return main_html.read_text(), vhtml.read_text(), vcsv.read_text()
def test_quiet_hours_coverage_excludes_time_the_monitor_was_not_running(tmp_path):
"""The headline: eight of these ten hours had no monitor running at all.
Before the fix the export read "monitored 9.5 of 9.5 wall-clock hours (100%)" --
a full-coverage claim over a night the device slept through, in the document the
README points at for a landlord submission.
"""
_main, html, csv_text = _render_two_session_artifacts(tmp_path)
assert "monitored 2.0 of 10.0 wall-clock hours (20%)" in html
assert "the remaining 8.0 hours are shown as not monitored rather than quiet" in html
assert "(100%)" not in html
# The CSV preamble travels with the same figure.
assert "monitored 2.0 of 10.0 wall-clock hours" in csv_text
def test_off_air_stretch_is_disclosed_not_just_subtracted(tmp_path):
"""A reader must be able to see *when* the device was off, not only that hours are
missing -- otherwise "no monitoring gaps were recorded" reads as "nothing was wrong"
directly under a 20% coverage figure."""
from report.violations import OFF_AIR_HEADING
_main, html, csv_text = _render_two_session_artifacts(tmp_path)
assert OFF_AIR_HEADING in html
assert "2026-03-10T23:00:00+00:00" in html and "2026-03-11T07:00:00+00:00" in html
assert OFF_AIR_HEADING in csv_text
def test_main_report_marks_off_air_hours_as_not_monitored(tmp_path):
"""The calendar heatmap's third state exists for exactly this; it was reachable only
from a device-error gap, so an off-air night rendered as a row of quiet zeros."""
main_html, _v, _c = _render_two_session_artifacts(tmp_path)
assert "monitored 2.0 of 10.0 wall-clock hours" in main_html
assert "not monitored" in main_html
# 2026-03-11 01:00 is inside the off-air stretch and holds no events.
assert "2026-03-11 01:00 — not monitored" in main_html
def test_coverage_falls_back_when_the_log_has_no_sessions(tmp_path):
"""A pre-session (legacy) log cannot support an off-air claim in either direction, so
the old whole-span-minus-gaps figure stands and the report says why."""
from report.violations import OFF_AIR_UNKNOWN_NOTE
events, gaps, _session = _quiet_hours_scenario()
html = _violation_html(events, gaps=gaps)
assert OFF_AIR_UNKNOWN_NOTE in html
assert "wall-clock hours" in html
def test_overlapping_recorded_gaps_are_not_double_counted():
"""Two gaps covering the same minute are one hole, not two.
The old arithmetic summed each gap's overlap with the window independently, so an
overlapping pair subtracted the shared seconds twice and understated coverage --
the mirror image of the off-air overstatement, and just as wrong.
"""
from report.render import _coverage_hours
base = datetime(2026, 1, 1, tzinfo=timezone.utc).timestamp()
events = [Event(start=base, end=base + 10, duration=10.0, peak_level=-8, avg_level=-12)]
gaps = [
_gap(base + 3600, base + 3600 * 5), # 01:00 -> 05:00
_gap(base + 3600 * 3, base + 3600 * 7), # 03:00 -> 07:00, overlapping the first
]
events.append(
Event(
start=base + 3600 * 10,
end=base + 3600 * 10 + 5,
duration=5.0,
peak_level=-8,
avg_level=-12,
)
)
monitored, wall = _coverage_hours(events, gaps, [])
assert wall == pytest.approx(10.0, abs=0.01) # 00:00 -> just past 10:00
# The union of the two gaps is 01:00 -> 07:00, six hours. Summing them separately
# would have subtracted eight and reported ~2.0 monitored hours instead.
assert monitored == pytest.approx(wall - 6.0, abs=0.01)
def _coverage_session(started_at, ended_at, *, frames_seen=0, framing=True):
from store import Session
return Session(
id=1,
started_at=started_at,
ended_at=ended_at,
device_label="pi-1",
mic_model="",
placement_note="",
tz="UTC",
calibration_offset=0.0,
calibration_note="x",
frames_seen=frames_seen,
frames_dropped=0,
app_version="0.1.0",
sample_rate=16000 if framing else None,
frame_size=1600 if framing else None,
)
def test_a_crashed_session_is_credited_only_for_the_frames_it_recorded():
"""`ended_at` stays NULL when a run dies before its shutdown path -- a power cut,
the very outage this fix is about. Crediting it to the end of the window would
quietly restore the bug: the dead monitor would read as listening until the next run.
The frame counters are checkpointed on a wall-clock cadence, so they bound it: 36000
frames of 1600 samples at 16 kHz is one hour of capture, and not a second more.
"""
from report.render import _coverage_hours
base = datetime(2026, 1, 1, 22, tzinfo=timezone.utc).timestamp()
crashed = _coverage_session(
base, None, frames_seen=36000
) # 22:00, an hour of frames, then dead
later = _coverage_session(base + 3600 * 9, base + 3600 * 10) # 07:00 -> 08:00 the next morning
monitored, wall = _coverage_hours([], [], [crashed, later])
assert wall == pytest.approx(10.0, abs=0.01)
assert monitored == pytest.approx(2.0, abs=0.01)
def test_a_running_session_counts_as_far_as_its_checkpointed_frames_prove():
"""The live case rides the same rule, losing at most one checkpoint interval."""
from report.render import _coverage_hours
base = datetime(2026, 1, 1, tzinfo=timezone.utc).timestamp()
running = _coverage_session(base, None, frames_seen=36000 * 4) # four hours of frames, still up
events = [
Event(
start=base + 3600 * 4 - 60,
end=base + 3600 * 4 - 55,
duration=5.0,
peak_level=-8,
avg_level=-12,
)
]
monitored, wall = _coverage_hours(events, [], [running])
# The window ends at the last event, just short of the four hours of frames.
assert wall == pytest.approx(3.98, abs=0.01)
assert monitored == pytest.approx(wall) # every second of it is vouched for
def test_a_legacy_session_row_vouches_for_nothing_past_its_own_start():
"""No end time and no framing columns (a pre-v4 row): the record cannot prove the
monitor stayed up, so it is not assumed to have."""
from report.render import _coverage_hours
base = datetime(2026, 1, 1, tzinfo=timezone.utc).timestamp()
legacy = _coverage_session(base, None, frames_seen=999, framing=False)
events = [
Event(
start=base + 3600 * 2,
end=base + 3600 * 2 + 5,
duration=5.0,
peak_level=-8,
avg_level=-12,
)
]
monitored, wall = _coverage_hours(events, [], [legacy])
assert wall == pytest.approx(2.0, abs=0.01)
assert monitored == pytest.approx(0.0, abs=0.01) # only the event's own five seconds
def test_interval_helpers_handle_holes_outside_the_base_span():
"""The coverage figure is interval arithmetic, so the arithmetic gets its own test.
Both the "hole entirely before this span" and "hole entirely after it" branches are
live in real logs: gaps from an earlier run precede a later session, and gaps from a
later run follow an earlier one.
"""
from report.render import _merge_spans, _subtract_spans
assert _merge_spans([(10.0, 20.0), (15.0, 18.0), (30.0, 25.0)]) == [(10.0, 20.0)]
assert _merge_spans([(0.0, 5.0), (5.0, 9.0)]) == [(0.0, 9.0)] # touching spans join
base = [(100.0, 200.0)]
holes = [(0.0, 50.0), (120.0, 140.0), (500.0, 600.0)]
# The hole before the span is skipped, the hole after it stops the scan.
assert _subtract_spans(base, holes) == [(100.0, 120.0), (140.0, 200.0)]
# A hole swallowing the whole span leaves nothing.
assert _subtract_spans(base, [(50.0, 250.0)]) == []