tenseleyflow/sway / f7a9fdd

Browse files

tests/unit: sway report --format html --out — file write, missing-plotly, format guards

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f7a9fdd7cc557c6a9c120d199700a3b8a8929b4a
Parents
8183ee2
Tree
94b4ff4

1 changed file

StatusFile+-
A tests/unit/test_cli_report_html.py 148 0
tests/unit/test_cli_report_html.pyadded
@@ -0,0 +1,148 @@
1
+"""CLI tests for ``sway report --format html`` (S12)."""
2
+
3
+from __future__ import annotations
4
+
5
+import builtins
6
+import json
7
+from pathlib import Path
8
+
9
+import pytest
10
+from typer.testing import CliRunner
11
+
12
+from dlm_sway.cli.app import app
13
+
14
+# Plotly is shipped via the optional [viz] extra. The
15
+# ``test_missing_plotly_surfaces_install_hint`` test monkeypatches it
16
+# away, so it runs unconditionally; every other test needs plotly loaded.
17
+pytest.importorskip("plotly")
18
+
19
+
20
+def _write_sample_result(path: Path) -> None:
21
+    payload = {
22
+        "schema_version": 1,
23
+        "sway_version": "0.1.0.dev0",
24
+        "base_model_id": "base",
25
+        "adapter_id": "adp",
26
+        "started_at": "2026-01-01T12:00:00+00:00",
27
+        "finished_at": "2026-01-01T12:00:02+00:00",
28
+        "score": {
29
+            "overall": 0.77,
30
+            "band": "healthy",
31
+            "components": {
32
+                "adherence": 0.8,
33
+                "attribution": 0.7,
34
+                "calibration": 0.8,
35
+                "ablation": 0.7,
36
+            },
37
+            "weights": {
38
+                "adherence": 0.30,
39
+                "attribution": 0.35,
40
+                "calibration": 0.20,
41
+                "ablation": 0.15,
42
+            },
43
+            "findings": [],
44
+        },
45
+        "probes": [
46
+            {
47
+                "name": "dk",
48
+                "kind": "delta_kl",
49
+                "verdict": "pass",
50
+                "score": 0.8,
51
+                "raw": 0.4,
52
+                "z_score": 4.0,
53
+                "message": "ok",
54
+            },
55
+            {
56
+                "name": "abl",
57
+                "kind": "adapter_ablation",
58
+                "verdict": "pass",
59
+                "score": 0.7,
60
+                "raw": 0.9,
61
+                "message": "R²=0.9",
62
+                "evidence": {
63
+                    "lambdas": [0.0, 0.5, 1.0],
64
+                    "mean_divergence_per_lambda": [0.0, 0.1, 0.2],
65
+                    "saturation_lambda": 0.75,
66
+                },
67
+            },
68
+        ],
69
+    }
70
+    path.write_text(json.dumps(payload), encoding="utf-8")
71
+
72
+
73
+class TestReportHtmlCli:
74
+    def test_writes_file(self, tmp_path: Path) -> None:
75
+        result_json = tmp_path / "result.json"
76
+        out_html = tmp_path / "report.html"
77
+        _write_sample_result(result_json)
78
+        invocation = CliRunner().invoke(
79
+            app, ["report", str(result_json), "--format", "html", "--out", str(out_html)]
80
+        )
81
+        assert invocation.exit_code == 0, invocation.stdout + invocation.stderr
82
+        assert out_html.is_file()
83
+        content = out_html.read_text(encoding="utf-8")
84
+        # Structural markers — the wrapper + panel div ids + probe names.
85
+        assert content.startswith("<!doctype html>")
86
+        assert 'id="sway-gauge"' in content
87
+        assert 'id="sway-scatter"' in content
88
+        assert "dk" in content
89
+        # Stderr carries the "wrote HTML → ..." confirmation.
90
+        assert "wrote HTML" in invocation.stderr
91
+
92
+    def test_requires_out_flag(self, tmp_path: Path) -> None:
93
+        result_json = tmp_path / "result.json"
94
+        _write_sample_result(result_json)
95
+        invocation = CliRunner().invoke(app, ["report", str(result_json), "--format", "html"])
96
+        # Exit 2 — the 3 MB JS bundle has no business on stdout by default.
97
+        assert invocation.exit_code == 2
98
+        assert "requires --out" in invocation.stderr
99
+
100
+    def test_terminal_with_out_errors(self, tmp_path: Path) -> None:
101
+        """``--out`` is only for file-producing formats. Terminal stays
102
+        in the console."""
103
+        result_json = tmp_path / "result.json"
104
+        target = tmp_path / "bogus.txt"
105
+        _write_sample_result(result_json)
106
+        invocation = CliRunner().invoke(
107
+            app,
108
+            ["report", str(result_json), "--format", "terminal", "--out", str(target)],
109
+        )
110
+        assert invocation.exit_code == 2
111
+        assert "does not support --out" in invocation.stderr
112
+
113
+    def test_markdown_with_out_writes_file(self, tmp_path: Path) -> None:
114
+        """The ``--out`` flag works for ``--format md`` too (any file format)."""
115
+        result_json = tmp_path / "result.json"
116
+        md_out = tmp_path / "report.md"
117
+        _write_sample_result(result_json)
118
+        invocation = CliRunner().invoke(
119
+            app,
120
+            ["report", str(result_json), "--format", "md", "--out", str(md_out)],
121
+        )
122
+        assert invocation.exit_code == 0, invocation.stdout + invocation.stderr
123
+        assert md_out.is_file()
124
+        assert "# " in md_out.read_text(encoding="utf-8")
125
+
126
+    def test_missing_plotly_surfaces_install_hint(
127
+        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
128
+    ) -> None:
129
+        """When plotly is unavailable, the CLI exits 2 with the extras hint."""
130
+        result_json = tmp_path / "result.json"
131
+        out_html = tmp_path / "report.html"
132
+        _write_sample_result(result_json)
133
+
134
+        real_import = builtins.__import__
135
+
136
+        def fake_import(name, *args, **kwargs):  # type: ignore[no-untyped-def]
137
+            if name.startswith("plotly"):
138
+                raise ImportError("simulated missing plotly")
139
+            return real_import(name, *args, **kwargs)
140
+
141
+        monkeypatch.setattr(builtins, "__import__", fake_import)
142
+        invocation = CliRunner().invoke(
143
+            app, ["report", str(result_json), "--format", "html", "--out", str(out_html)]
144
+        )
145
+        assert invocation.exit_code == 2
146
+        assert "viz" in invocation.stderr
147
+        # File must not have been written on the error path.
148
+        assert not out_html.exists()