| 1 | """S12 prove-the-value (§F6): HTML report loads offline from a real run. |
| 2 | |
| 3 | Uses the committed `tests/fixtures/sway-history/02-2026-01-22.json` |
| 4 | (the mid-run baseline from S11) as a realistic input: four probe |
| 5 | kinds, healthy-band composite, full evidence dicts for SIS and |
| 6 | ablation. Emits the HTML to a tmp dir and asserts: |
| 7 | |
| 8 | 1. The file is a well-formed HTML document. |
| 9 | 2. No external ``<script src="http...">`` or ``<link rel=stylesheet>`` |
| 10 | references — every byte loads from the file itself. |
| 11 | 3. All four interactive panels (gauge / category / ablation / scatter) |
| 12 | render; the SIS panel skips because the fixture's |
| 13 | ``section_internalization`` evidence doesn't carry a ``per_section`` |
| 14 | array (it's a real terminal-rendered history, not a full export). |
| 15 | 4. Every probe name from the fixture appears in the probe table. |
| 16 | |
| 17 | Closure notes should record the produced file size and the total |
| 18 | probe count for the record. |
| 19 | """ |
| 20 | |
| 21 | from __future__ import annotations |
| 22 | |
| 23 | import json |
| 24 | import re |
| 25 | from html.parser import HTMLParser |
| 26 | from pathlib import Path |
| 27 | |
| 28 | import pytest |
| 29 | |
| 30 | from dlm_sway.suite import report, report_html |
| 31 | |
| 32 | pytest.importorskip("plotly") |
| 33 | |
| 34 | FIXTURE = Path(__file__).parent.parent / "fixtures" / "sway-history" / "02-2026-01-22.json" |
| 35 | |
| 36 | |
| 37 | class _Parser(HTMLParser): |
| 38 | def error(self, message: str) -> None: # pragma: no cover |
| 39 | raise AssertionError(message) |
| 40 | |
| 41 | |
| 42 | def _parse_ok(text: str) -> None: |
| 43 | parser = _Parser(convert_charrefs=True) |
| 44 | parser.feed(text) |
| 45 | parser.close() |
| 46 | |
| 47 | |
| 48 | def test_html_from_real_history_loads_offline(tmp_path: Path) -> None: |
| 49 | raw = json.loads(FIXTURE.read_text(encoding="utf-8")) |
| 50 | suite, score = report.from_json(raw) |
| 51 | |
| 52 | html_text = report_html.to_html(suite, score) |
| 53 | target = tmp_path / "report.html" |
| 54 | target.write_text(html_text, encoding="utf-8") |
| 55 | |
| 56 | # 1. Well-formed. |
| 57 | _parse_ok(target.read_text(encoding="utf-8")) |
| 58 | |
| 59 | # 2. No external script / stylesheet references — fully self-contained. |
| 60 | disk = target.read_text(encoding="utf-8") |
| 61 | external_scripts = re.findall(r'<script[^>]*\bsrc\s*=\s*["\'](https?:[^"\']+)', disk) |
| 62 | external_links = re.findall(r'<link[^>]*\bhref\s*=\s*["\'](https?:[^"\']+)', disk) |
| 63 | assert external_scripts == [], f"external scripts: {external_scripts}" |
| 64 | assert external_links == [], f"external stylesheets: {external_links}" |
| 65 | |
| 66 | # 3. The three always-present panels render. |
| 67 | for required in ("sway-gauge", "sway-category", "sway-scatter"): |
| 68 | assert f'id="{required}"' in disk, f"panel {required!r} missing" |
| 69 | # Evidence-dependent panels: the committed history fixture carries |
| 70 | # terminal-message metadata but not the full evidence dicts. Both |
| 71 | # panels correctly opt out, confirming the renderer handles partial |
| 72 | # inputs without crashing. |
| 73 | assert 'id="sway-sis"' not in disk |
| 74 | assert 'id="sway-ablation"' not in disk |
| 75 | |
| 76 | # 4. All four probe names appear in the probe table. |
| 77 | for probe_name in ( |
| 78 | "delta_kl", |
| 79 | "section_internalization", |
| 80 | "calibration_drift", |
| 81 | "adapter_ablation", |
| 82 | ): |
| 83 | assert probe_name in disk, f"probe {probe_name!r} not in HTML body" |
| 84 | |
| 85 | # Sanity: file is in the expected size range (1-10 MB; Plotly 6.x |
| 86 | # bundles ~4.8 MB of JS). |
| 87 | size = target.stat().st_size |
| 88 | assert 1_000_000 < size < 10_000_000, f"unexpected HTML size: {size:,} bytes" |