Skip to content

Commit 7cbb966

Browse files
authored
Merge pull request #32 from aai-research-lab/dktesting
Adding Coverage
2 parents 2b6042c + 88b7f2f commit 7cbb966

3 files changed

Lines changed: 362 additions & 0 deletions

File tree

tests/test_cli.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,41 @@ def test_cli_phase_dashboard_uses_cli_output_path(tmp_path: Path) -> None:
342342
assert session.stopped is True
343343

344344

345+
@pytest.mark.parametrize("command", ["simulate", "analyze", "report"])
346+
def test_cli_dashboard_uses_cli_output_path_for_other_phases(
347+
tmp_path: Path,
348+
command: str,
349+
) -> None:
350+
pdb = _make_pdb_stub(tmp_path)
351+
out = tmp_path / f"{command}_run"
352+
session = _FakeDashboardSession()
353+
354+
method_name = {"simulate": "simulate", "analyze": "analyze", "report": "report"}[command]
355+
with patch(
356+
"fastmdxplora.live.server.start_dashboard_session",
357+
return_value=session,
358+
) as start, patch.object(
359+
FastMDXplora,
360+
method_name,
361+
return_value=SimpleNamespace(status="ok"),
362+
):
363+
rc = main(
364+
[
365+
command,
366+
"--system",
367+
str(pdb),
368+
"--output",
369+
str(out),
370+
"--dashboard",
371+
"--dashboard-stop-on-complete",
372+
]
373+
)
374+
375+
assert rc == 0
376+
assert start.call_args.kwargs["output"] == out.resolve()
377+
assert session.stopped is True
378+
379+
345380
def test_cli_dashboard_prints_port_conflict_and_host_warning(
346381
tmp_path: Path,
347382
capsys,

tests/test_dashboard.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
from types import SimpleNamespace
6+
7+
import numpy as np
8+
import pytest
9+
10+
from fastmdxplora.report.dashboard import build_dashboard
11+
from fastmdxplora.report.region_highlights import (
12+
RegionHighlight,
13+
build_pymol_script,
14+
build_region_highlight_artifacts,
15+
validate_region_highlights,
16+
)
17+
18+
19+
PNG_BYTES = (
20+
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
21+
b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
22+
b"\x00\x00\x00\x0cIDATx\x9cc```\x00\x00\x00\x04\x00\x01"
23+
b"\xf6\x178U\x00\x00\x00\x00IEND\xaeB`\x82"
24+
)
25+
26+
27+
def test_static_dashboard_discovers_sections_links_and_dark_assets(tmp_path: Path) -> None:
28+
root = tmp_path / "run"
29+
_write_json(
30+
root / "manifest.json",
31+
{
32+
"system": "demo-system",
33+
"phases": [
34+
{"name": "analysis", "status": "ok"},
35+
{"name": "report", "status": "ok"},
36+
],
37+
},
38+
)
39+
_write_json(
40+
root / "analysis" / "analysis_manifest.json",
41+
{"n_frames": 2, "n_atoms": 12, "results": {"rmsd": {"status": "ok"}}},
42+
)
43+
(root / "report").mkdir(parents=True)
44+
(root / "report" / "report.md").write_text("# Report\n", encoding="utf-8")
45+
(root / "report" / "slides.pptx").write_bytes(b"pptx")
46+
(root / "report" / "project_bundle.zip").write_bytes(b"zip")
47+
48+
_write_plot(root, "analysis/rmsd/rmsd.png", "analysis/rmsd/rmsd.dat", "0 0.10\n1 0.20\n")
49+
_write_plot(root, "analysis/rg/rg.png", "analysis/rg/rg.dat", "0 1.0\n1 1.2\n")
50+
_write_plot(root, "analysis/sasa/sasa.png", "analysis/sasa/sasa.dat", "0 20.0\n1 22.0\n")
51+
_write_plot(root, "analysis/dimred/dimred_pca.png", "analysis/dimred/dimred_pca.dat", "0 0.1 0.2\n1 0.3 0.4\n")
52+
_write_plot(root, "analysis/cluster/cluster_kmeans.png", "analysis/cluster/cluster_kmeans.dat", "frame,cluster\n0,1\n1,2\n2,1\n")
53+
_write_plot(root, "analysis/cluster/cluster_kmeans_counts.png", None, None)
54+
_write_plot(root, "analysis/ss/ss.png", "analysis/ss/ss.dat", "frame,1,2\n0,H,C\n1,E,T\n")
55+
_write_plot(root, "analysis/qvalue/qvalue.png", "analysis/qvalue/qvalue.dat", "0.9\n0.8\n")
56+
(root / "report" / "region_highlight_summary.png").write_bytes(PNG_BYTES)
57+
58+
artifacts = build_dashboard(
59+
orchestrator=SimpleNamespace(output_dir=root, system="demo-system"),
60+
output_dir=root / "report",
61+
title="Demo Dashboard",
62+
include_bundle_link=True,
63+
)
64+
65+
html = (root / "report" / "dashboard.html").read_text(encoding="utf-8")
66+
assert artifacts == ["dashboard.html"]
67+
for section in (
68+
"Core Metrics",
69+
"SASA",
70+
"Secondary Structure",
71+
"Dimensionality Reduction",
72+
"Clustering",
73+
"Region Highlights",
74+
"Other",
75+
):
76+
assert section in html
77+
for link in (
78+
"report.md",
79+
"slides.pptx",
80+
"project_bundle.zip",
81+
"../analysis/analysis_manifest.json",
82+
"../analysis/rmsd/rmsd.png",
83+
"../analysis/cluster/cluster_kmeans_counts.png",
84+
):
85+
assert link in html
86+
for asset in (
87+
"rmsd_dashboard.png",
88+
"rg_dashboard.png",
89+
"sasa_dashboard.png",
90+
"pca_dashboard.png",
91+
"kmeans_trajectory_dashboard.png",
92+
"kmeans_population_dashboard.png",
93+
"ss_dashboard.png",
94+
"qvalue_dashboard.png",
95+
):
96+
assert (root / "report" / "dashboard_assets" / asset).is_file()
97+
assert f"dashboard_assets/{asset}" in html
98+
assert "dashboard view" in html
99+
assert "artifact fallback" in html
100+
assert "Analysis/report workflow from existing trajectory." in html
101+
102+
103+
def test_region_highlights_validate_ranges_and_defaults() -> None:
104+
regions = validate_region_highlights(
105+
[{"start": 2, "end": 4}, {"label": "Loop", "start": 5, "end": 5, "color": "red"}],
106+
np.array([1, 2, 3, 4, 5]),
107+
)
108+
109+
assert regions[0] == RegionHighlight("Region 1", 2, 4, "#4E79A7")
110+
assert regions[1].label == "Loop"
111+
assert regions[1].color == "red"
112+
113+
with pytest.raises(ValueError, match="end must be >= start"):
114+
validate_region_highlights([{"start": 4, "end": 3}], np.array([1, 2, 3, 4]))
115+
with pytest.raises(ValueError, match="outside the RMSF residue range"):
116+
validate_region_highlights([{"start": 1, "end": 10}], np.array([2, 3, 4]))
117+
with pytest.raises(ValueError, match="missing required key"):
118+
validate_region_highlights([{"end": 3}], np.array([1, 2, 3]))
119+
120+
121+
def test_region_highlight_artifacts_and_dashboard_metadata(tmp_path: Path) -> None:
122+
root = tmp_path / "run"
123+
report = root / "report"
124+
rmsf = root / "analysis" / "rmsf" / "rmsf.dat"
125+
rmsf.parent.mkdir(parents=True)
126+
rmsf.write_text("1 0.1\n2 0.3\n3 0.2\n4 0.5\n", encoding="utf-8")
127+
_write_json(
128+
root / "manifest.json",
129+
{"system": "demo", "phases": [{"name": "analysis", "status": "ok"}]},
130+
)
131+
_write_json(root / "analysis" / "analysis_manifest.json", {"n_frames": 4})
132+
133+
artifacts = build_region_highlight_artifacts(
134+
project_root=root,
135+
output_dir=report,
136+
region_highlights=[{"label": "active loop", "start": 2, "end": 3, "color": "#E15759"}],
137+
)
138+
139+
manifest = json.loads((report / "region_highlight_manifest.json").read_text(encoding="utf-8"))
140+
assert "region_highlight_manifest.json" in artifacts
141+
assert "region_highlight_summary.png" in artifacts
142+
assert (root / "analysis" / "rmsf" / "rmsf_region_highlights.png").is_file()
143+
assert (report / "region_highlight_summary.png").is_file()
144+
assert manifest["status"] == "ok"
145+
assert manifest["skipped"][0]["artifact"] == "structure_region_highlights.png"
146+
147+
build_dashboard(
148+
orchestrator=SimpleNamespace(output_dir=root, system="demo"),
149+
output_dir=report,
150+
title="Region Dashboard",
151+
)
152+
html = (report / "dashboard.html").read_text(encoding="utf-8")
153+
assert "Region Highlights" in html
154+
assert "region_highlight_summary.png" in html
155+
assert "rmsf_region_highlights.png" in html
156+
157+
158+
def test_region_highlight_pymol_script_contains_region_commands(tmp_path: Path) -> None:
159+
script = build_pymol_script(
160+
topology_path=tmp_path / "topology with spaces.pdb",
161+
output_path=tmp_path / "out.png",
162+
regions=[
163+
RegionHighlight("Alpha", 2, 5, "#E15759"),
164+
RegionHighlight("Beta", 8, 9, "not-a-color"),
165+
],
166+
)
167+
168+
assert "load " in script
169+
assert "set_color fastmdx_region_1, [0.8824, 0.3412, 0.3490]" in script
170+
assert "select fastmdx_sel_1, prot and polymer.protein and resi 2-5" in script
171+
assert "show sticks, fastmdx_sel_2 and sidechain" in script
172+
assert "png " in script
173+
assert "ray 1800, 1200" in script
174+
175+
176+
def _write_json(path: Path, data: dict) -> None:
177+
path.parent.mkdir(parents=True, exist_ok=True)
178+
path.write_text(json.dumps(data), encoding="utf-8")
179+
180+
181+
def _write_plot(root: Path, image_rel: str, data_rel: str | None, data: str | None) -> None:
182+
image = root / image_rel
183+
image.parent.mkdir(parents=True, exist_ok=True)
184+
image.write_bytes(PNG_BYTES)
185+
if data_rel is not None and data is not None:
186+
path = root / data_rel
187+
path.parent.mkdir(parents=True, exist_ok=True)
188+
path.write_text(data, encoding="utf-8")

tests/test_live_dashboard.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
import socket
55
from pathlib import Path
6+
from urllib.error import HTTPError
67
from urllib.request import urlopen
78

89
from fastmdxplora.cli.main import _build_parser
@@ -240,6 +241,66 @@ def test_live_json_endpoints_are_no_store(tmp_path: Path) -> None:
240241
server.server_close()
241242

242243

244+
def test_live_json_endpoints_are_sane_for_empty_run(tmp_path: Path) -> None:
245+
run = tmp_path / "empty_run"
246+
247+
server, base_url = start_test_server(run)
248+
try:
249+
status_payload = json.loads(urlopen(f"{base_url}/api/status", timeout=5).read())
250+
metrics_payload = json.loads(urlopen(f"{base_url}/api/metrics", timeout=5).read())
251+
events_payload = json.loads(urlopen(f"{base_url}/api/events", timeout=5).read())
252+
artifacts_payload = json.loads(urlopen(f"{base_url}/api/artifacts", timeout=5).read())
253+
results_payload = json.loads(urlopen(f"{base_url}/api/results", timeout=5).read())
254+
finally:
255+
server.shutdown()
256+
server.server_close()
257+
258+
assert status_payload["status"] == {}
259+
assert status_payload["health"]["state"] == "unknown"
260+
assert metrics_payload["metrics"] == []
261+
assert events_payload["events"] == []
262+
assert artifacts_payload["artifacts"] == []
263+
assert results_payload["has_analysis"] is False
264+
assert results_payload["has_report"] is False
265+
assert results_payload["plots"] == []
266+
267+
268+
def test_live_server_rejects_artifact_path_traversal(tmp_path: Path) -> None:
269+
run = tmp_path / "run"
270+
run.mkdir()
271+
outside = tmp_path / "outside.txt"
272+
outside.write_text("secret", encoding="utf-8")
273+
274+
server, base_url = start_test_server(run)
275+
try:
276+
try:
277+
urlopen(f"{base_url}/artifacts/../outside.txt", timeout=5)
278+
except HTTPError as exc:
279+
assert exc.code in {403, 404}
280+
else:
281+
raise AssertionError("path traversal unexpectedly succeeded")
282+
finally:
283+
server.shutdown()
284+
server.server_close()
285+
286+
287+
def test_live_server_does_not_serve_static_path_traversal(tmp_path: Path) -> None:
288+
run = tmp_path / "run"
289+
run.mkdir()
290+
291+
server, base_url = start_test_server(run)
292+
try:
293+
try:
294+
urlopen(f"{base_url}/static/../server.py", timeout=5)
295+
except HTTPError as exc:
296+
assert exc.code == 404
297+
else:
298+
raise AssertionError("static traversal unexpectedly succeeded")
299+
finally:
300+
server.shutdown()
301+
server.server_close()
302+
303+
243304
def test_static_3dmol_asset_is_served_locally(tmp_path: Path) -> None:
244305
run = tmp_path / "run"
245306
run.mkdir()
@@ -294,6 +355,43 @@ def test_dashboard_session_stop_is_safe(tmp_path: Path) -> None:
294355
assert not session.thread.is_alive()
295356

296357

358+
def test_telemetry_readers_handle_missing_and_malformed_files(tmp_path: Path) -> None:
359+
run = tmp_path / "run"
360+
sim = run / "simulation"
361+
sim.mkdir(parents=True)
362+
363+
assert read_status(run) == {}
364+
assert read_metrics(run) == []
365+
assert read_events(run) == []
366+
367+
(sim / "live_status.json").write_text("{not-json", encoding="utf-8")
368+
(sim / "live_metrics.csv").write_text('timestamp,stage,total_energy\n"unterminated', encoding="utf-8")
369+
(sim / "live_events.log").write_text("free-form event without tabs\n", encoding="utf-8")
370+
371+
assert read_status(run) == {}
372+
assert isinstance(read_metrics(run), list)
373+
assert read_events(run) == [
374+
{"timestamp": "", "level": "info", "message": "free-form event without tabs"}
375+
]
376+
377+
378+
def test_analyze_health_temperature_and_stale_warning() -> None:
379+
stale = analyze_health(
380+
{"status": "running", "last_update_timestamp": "2000-01-01T00:00:00+00:00"},
381+
[],
382+
stale_after_seconds=1,
383+
)
384+
assert stale["state"] == "warning"
385+
assert "stale" in stale["message"].lower()
386+
387+
hot = analyze_health(
388+
{"status": "running", "target_temperature_K": 300.0},
389+
[{"temperature": "420.0"}],
390+
)
391+
assert hot["state"] == "warning"
392+
assert "Temperature" in hot["message"]
393+
394+
297395
def test_protein_preview_unavailable_without_structure(tmp_path: Path) -> None:
298396
payload = protein_preview_payload(tmp_path / "run")
299397

@@ -322,6 +420,47 @@ def test_protein_preview_requires_pymol_when_missing(tmp_path: Path, monkeypatch
322420
assert "PyMOL preview unavailable" in payload["message"]
323421

324422

423+
def test_protein_preview_uses_existing_image_without_structure(tmp_path: Path) -> None:
424+
run = tmp_path / "run"
425+
preview = run / "report" / "dashboard_assets" / "protein_preview.png"
426+
preview.parent.mkdir(parents=True)
427+
preview.write_bytes(b"\x89PNG\r\n\x1a\n")
428+
429+
payload = protein_preview_payload(run)
430+
431+
assert payload["available"] is True
432+
assert payload["static_available"] is True
433+
assert payload["static_mode"] == "existing"
434+
assert payload["path"] == "report/dashboard_assets/protein_preview.png"
435+
assert payload["structure_available"] is False
436+
assert payload["viewer_available"] is False
437+
438+
439+
def test_protein_preview_finds_manifest_system_path_and_handles_missing_viewer(
440+
tmp_path: Path,
441+
monkeypatch,
442+
) -> None:
443+
system_pdb = tmp_path / "source.pdb"
444+
system_pdb.write_text(_tiny_pdb(), encoding="utf-8")
445+
run = tmp_path / "run"
446+
run.mkdir()
447+
(run / "manifest.json").write_text(json.dumps({"system": str(system_pdb)}), encoding="utf-8")
448+
449+
monkeypatch.setattr("fastmdxplora.live.protein_preview._find_pymol_executable", lambda _root: None)
450+
monkeypatch.setattr("fastmdxplora.live.protein_preview.viewer_asset_available", lambda: False)
451+
452+
payload = protein_preview_payload(run)
453+
454+
assert payload["available"] is True
455+
assert payload["static_available"] is False
456+
assert payload["structure_path"] == str(system_pdb)
457+
assert payload["structure_url"] is None
458+
assert payload["structure_available"] is False
459+
assert payload["viewer_available"] is False
460+
assert payload["fallback_available"] is False
461+
assert payload["fallback_mode"] is None
462+
463+
325464
def test_protein_preview_api_finds_topology(tmp_path: Path, monkeypatch) -> None:
326465
def _fake_render(_pymol, _structure, output_path):
327466
output_path.parent.mkdir(parents=True, exist_ok=True)

0 commit comments

Comments
 (0)