@@ -0,0 +1,88 @@ |
| 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" |