dynamic-systems-testing-framework 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,534 @@
1
+ """Unified dashboard renderer.
2
+
3
+ One Jinja template (`dashboard.html`) feeds both live progress
4
+ during a run and the post-comparison report. The template renders
5
+ gracefully whether comparison data is present (post-run) or absent
6
+ (during run).
7
+
8
+ Live mode re-renders the whole page to disk on every state change
9
+ (driven by ProgressReporter) and emits `<meta http-equiv="refresh"
10
+ content="5">`, so the browser full-reloads every 5s. JS-fetch was
11
+ tried first and is silently blocked on `file://` URLs — see the
12
+ comment in `templates/dashboard.js`. Between reloads a 1s JS timer
13
+ ticks the elapsed counters so they don't look frozen.
14
+
15
+ Final mode is rendered by the command that ends a run (cli.cmd_run /
16
+ cmd_compare, the tool's queue as it drains); it drops the refresh meta tag (the page
17
+ becomes static) and adds each test's verdict and numbers (from the
18
+ comparisons the caller holds), per-test report links + post-run columns
19
+ (worst score, warnings, translate/sim/total wall times) from the
20
+ sidecars.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import re
28
+ import time
29
+ import uuid
30
+ from collections.abc import Iterable, Mapping
31
+ from dataclasses import replace
32
+ from pathlib import Path
33
+
34
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
35
+
36
+ from ..backends.base import kept_run_result
37
+ from ..backends.common.result_variables import run_timing
38
+ from ..backends.progress import read_status
39
+ from ..backends.types import TestResult
40
+ from ..comparison.types import TestComparison
41
+ from .rows import (
42
+ COLUMNS,
43
+ FILTER_HINTS,
44
+ TestRow,
45
+ cpu_time,
46
+ enrich_row,
47
+ row_from_status,
48
+ summary_fields,
49
+ )
50
+ from .status import (
51
+ CANCELLED,
52
+ PILL_STATUSES,
53
+ STATUS_CLASSES,
54
+ STATUS_LABELS,
55
+ refusal,
56
+ scored_status,
57
+ )
58
+ from .theme import css_variables
59
+
60
+ _TEMPLATE_DIR = Path(__file__).parent / "templates"
61
+ _env = Environment(
62
+ loader=FileSystemLoader(_TEMPLATE_DIR),
63
+ autoescape=select_autoescape(["html"]),
64
+ )
65
+ # A column filter's placeholder and tooltip, by kind — the Suite table's too.
66
+ _env.globals["filter_hints"] = FILTER_HINTS
67
+ # A sort value as a data-sort-* attribute: None is the empty string, which
68
+ # dashboard.js sorts last in both directions.
69
+ _env.filters["sortval"] = lambda v: "" if v is None else v
70
+
71
+
72
+ def _read_comparison_sidecar(work_dir: Path, report_dir: str) -> dict | None:
73
+ """Read per-test comparison_data.json if present.
74
+
75
+ Per-test reports are at <work_dir>/reports/<report_dir>/comparison_data.json
76
+ (matches generate_comparison_plots layout).
77
+ """
78
+ p = work_dir / "reports" / report_dir / "comparison_data.json"
79
+ if not p.exists():
80
+ return None
81
+ try:
82
+ return json.loads(p.read_text(encoding="utf-8"))
83
+ except json.JSONDecodeError:
84
+ return None
85
+
86
+
87
+ def build_rerun_prefix(config) -> str:
88
+ """Build the CLI prefix the dashboard uses in its rerun-command builder.
89
+
90
+ Produces e.g. `dstf --config "/abs/path/testing.json"` — the global
91
+ flags only, NO subcommand: the dashboard's command menu picks the
92
+ subcommand (`run`, `compare`, `compare --accept`,
93
+ `run --rerun failed,sim-failed`) and appends ` --filter ...`, so the
94
+ user can paste the result into any terminal regardless of CWD.
95
+ Prefers --config when available; otherwise falls back to
96
+ --source-path (+ optional --reference-root).
97
+ """
98
+
99
+ def q(p) -> str:
100
+ s = str(p)
101
+ return f'"{s}"' if " " in s else s
102
+
103
+ if getattr(config, "config_file", None):
104
+ return f"dstf --config {q(config.config_file)}"
105
+
106
+ parts = ["dstf"]
107
+ if getattr(config, "source_path", None):
108
+ parts += ["--source-path", q(config.source_path)]
109
+ if getattr(config, "reference_root", None):
110
+ parts += ["--reference-root", q(config.reference_root)]
111
+ return " ".join(parts)
112
+
113
+
114
+ def format_run_metadata(meta: dict | None) -> dict | None:
115
+ """Turn the raw ``status.json`` ``metadata`` block into a template-friendly
116
+ provenance dict, or ``None`` when no metadata was recorded.
117
+
118
+ Adds a display ``label`` (the configured backend name, or "unknown"
119
+ when a stale stamp does not record one) and a human-readable ``generated_str`` from the epoch
120
+ timestamp. Kept separate from ``build_dashboard_context`` so the per-test
121
+ interactive report can reuse the exact same shaping.
122
+ """
123
+ if not meta:
124
+ return None
125
+ label = meta.get("backend_name") or "unknown"
126
+ generated_at = meta.get("generated_at")
127
+ generated_str = None
128
+ if generated_at:
129
+ try:
130
+ generated_str = time.strftime(
131
+ "%Y-%m-%d %H:%M", time.localtime(generated_at)
132
+ )
133
+ except Exception: # pragma: no cover — defensive against bad epoch
134
+ generated_str = None
135
+ # Surface the Modelica Standard Library version prominently (the second
136
+ # drift suspect); keep the full library_versions dict for completeness.
137
+ lib_versions = meta.get("library_versions") or {}
138
+ msl = lib_versions.get("Modelica") if isinstance(lib_versions, dict) else None
139
+ return {
140
+ "label": label,
141
+ "backend": meta.get("backend"),
142
+ "os": meta.get("os"),
143
+ "tool_version": meta.get("tool_version"),
144
+ "dstf_version": meta.get("dstf_version"),
145
+ "generated_str": generated_str,
146
+ "msl": msl,
147
+ "library_versions": lib_versions or None,
148
+ }
149
+
150
+
151
+ #: A tool version string → its short form, first match wins: Dymola's
152
+ #: ``DymolaVersion()`` ("Dymola Version 2026x, 2025-10-10") and omc's
153
+ #: ``getVersion()`` ("OpenModelica 1.27.0~dev-...", "OpenModelica v1.27.0").
154
+ _SHORT_TOOL_VERSION = (
155
+ (re.compile(r"^Dymola Version ([^,]+)"), "Dymola {}"),
156
+ (re.compile(r"^OpenModelica v?(\d+(?:\.\d+)*)"), "OpenModelica {}"),
157
+ )
158
+
159
+
160
+ def short_tool_version(
161
+ tool_version: str | None, backend_name: str | None
162
+ ) -> str | None:
163
+ """What a narrow place names the backend by: the tool and its release
164
+ from *tool_version* ("Dymola 2026x", "OpenModelica 1.27.0"), or
165
+ *backend_name* when the string is none of those — a version this does
166
+ not know how to shorten reads as the backend's name, never cut."""
167
+ for pattern, form in _SHORT_TOOL_VERSION:
168
+ m = pattern.match(tool_version or "")
169
+ if m:
170
+ return form.format(m.group(1).strip())
171
+ return backend_name
172
+
173
+
174
+ def _with_kept_timing(work_dir: Path, row: TestRow) -> TestRow:
175
+ """*row* with the phase walls its run kept in
176
+ ``<work_dir>/<test_key>/run_stats.json``, through the same
177
+ :func:`run_timing` a result is read with; *row* unchanged when the run
178
+ kept none (a run from before the file existed)."""
179
+ assert row.test_key
180
+ kept = kept_run_result(work_dir / row.test_key, row.model_id, row.test_key)
181
+ if kept is None:
182
+ return row
183
+ timing = run_timing(kept)
184
+ return replace(
185
+ row,
186
+ translation_wall=timing.get("translation_wall"),
187
+ sim_wall=timing.get("sim_wall"),
188
+ total_wall=timing.get("total_wall"),
189
+ )
190
+
191
+
192
+ def _with_verdict(row: TestRow, comp: TestComparison) -> TestRow:
193
+ """*row* with *comp*'s verdict and score numbers, through
194
+ ``summary_fields`` — the computation the sidecar is written with.
195
+
196
+ Applied AFTER a sidecar's enrichment: the comparison held in hand is
197
+ this invocation's verdict, and a sidecar from an earlier ``--report``
198
+ (fresh against this run's start, because nothing re-ran) says what the
199
+ spec said then. The verdict, its numbers and the baseline it was scored
200
+ against are the comparison's; what only the sidecar or the run's kept
201
+ file knows (the phase walls) and what only the live row knows (its
202
+ provenance) survive.
203
+
204
+ The ref_id is the comparison's when it names one — ``compare --backend
205
+ <other>`` on another backend's work dir scores against a baseline the
206
+ report_dir and the sidecar do not name. The sidecar's reference link
207
+ survives only when its ref_id is the comparison's: otherwise it opens
208
+ the other baseline. A comparison with no stored baseline names none,
209
+ and the row keeps whatever it had.
210
+
211
+ The Message is the run's (status.json's ``detail``) — but for a test
212
+ the comparison refused, whose reason is the one thing to read of it.
213
+
214
+ The status is the verdict as a row reads it (``scored_status``): a row
215
+ whose run was stopped keeps CANCELLED over the NOT RUN it scores.
216
+ """
217
+ summary = summary_fields(comp)
218
+ ref_id, ref_file = row.ref_id, row.ref_file
219
+ if comp.test_id:
220
+ ref_id = f"ref_{comp.test_id}"
221
+ if ref_id != row.ref_id:
222
+ ref_file = None
223
+ return replace(
224
+ row,
225
+ status=scored_status(summary["status"], cancelled=row.status == CANCELLED),
226
+ worst_score=summary["worst_nrmse"],
227
+ n_unscored=summary["n_unscored"],
228
+ n_vars=summary["n_vars"],
229
+ n_vars_passed=summary["n_vars_passed"],
230
+ n_warnings=summary["n_warnings"],
231
+ ref_id=ref_id,
232
+ ref_file=ref_file,
233
+ message=refusal(comp) or row.message,
234
+ )
235
+
236
+
237
+ def _manifest_rows(work_dir: Path) -> dict[str, tuple[TestRow, float | None]]:
238
+ """``{model_id: (row, last_run_at)}`` for every test
239
+ ``batch_manifest.json`` keys, in its order: the work-dir key and the
240
+ baseline the run named, and the report dir a run gives such a row
241
+ (``ref_NNNN``, else the key); beside it the wall clock that test's last
242
+ run started at (``assign_test_keys`` stamps ``last_run_at`` then, with
243
+ the clock a sidecar's ``written_at`` uses), ``None`` when the entry
244
+ holds no number. Read as data — never quarantined, as
245
+ ``BatchManifest.load`` does a corrupt one: a renderer moves no file. A
246
+ model under two keys takes the last, as ``last_test_keys`` does."""
247
+ try:
248
+ entries = json.loads(
249
+ (work_dir / "batch_manifest.json").read_text(encoding="utf-8")
250
+ )
251
+ except (OSError, ValueError):
252
+ return {}
253
+ rows: dict[str, tuple[TestRow, float | None]] = {}
254
+ for key, entry in entries.items() if isinstance(entries, dict) else ():
255
+ model_id = entry.get("model_id") if isinstance(entry, dict) else None
256
+ if not isinstance(model_id, str):
257
+ continue
258
+ ref_id = entry.get("ref_id")
259
+ ref_id = ref_id if isinstance(ref_id, str) else None
260
+ last_run_at = entry.get("last_run_at")
261
+ if isinstance(last_run_at, bool) or not isinstance(last_run_at, int | float):
262
+ last_run_at = None
263
+ rows[model_id] = (
264
+ TestRow(
265
+ model_id=model_id, test_key=key, ref_id=ref_id, report_dir=ref_id or key
266
+ ),
267
+ last_run_at,
268
+ )
269
+ return rows
270
+
271
+
272
+ def build_dashboard_context(
273
+ work_dir: Path,
274
+ mode: str,
275
+ rerun_prefix: str | None = None,
276
+ comparisons: Iterable[TestComparison] = (),
277
+ results: Mapping[str, TestResult] | None = None,
278
+ ) -> dict:
279
+ """Build the Jinja context for dashboard.html.
280
+
281
+ mode='live' — auto_refresh=True, post-run fields stay None
282
+ mode='final' — auto_refresh=False, a fresh sidecar (when --report wrote
283
+ one) enriches each row — a row without one takes the phase walls its
284
+ run kept (``run_stats.json``) — then its comparison (when the caller
285
+ holds one) sets its verdict and numbers. The rows are status.json's —
286
+ the last run's scope, which each run rewrites — then every other test
287
+ the caller holds a comparison for, in ``batch_manifest.json``'s order,
288
+ then by model id (D136's amendment: ``compare`` after a ``--filter``
289
+ run, a ``--merge`` and the tool's queue list every test they
290
+ scored). Such a row takes its key and baseline from the manifest.
291
+ A row whose result the caller holds (``results``, by model id) takes
292
+ its CPU time from it: Sim (s) shows that, marked, where no wall time
293
+ was kept (``TestRow.sim_source``).
294
+
295
+ The same template renders both; JS reads `DASHBOARD_MODE` to
296
+ decide whether to start the fetch loop.
297
+ """
298
+ snapshot = read_status(work_dir) or {
299
+ "total": 0,
300
+ "elapsed": 0.0,
301
+ "eta_seconds": None,
302
+ "counts": {},
303
+ "tests": [],
304
+ "updated_at": 0.0,
305
+ }
306
+
307
+ # Wall-clock anchor for the stale-sidecar guard. Sidecars written
308
+ # before this run's start are ignored by `rows.enrich_row` so a prior
309
+ # run's verdict can't override the current run's sim result.
310
+ # Falls back to None when status.json predates the field — guard
311
+ # silently passes through (legacy sidecars never get filtered).
312
+ snapshot_start_wall = snapshot.get("start_wall")
313
+
314
+ # `dstf run` / `compare` without --report write no sidecars; the
315
+ # comparisons they hold are what gives such a page its verdicts.
316
+ by_model = {c.model_id: c for c in comparisons} if mode == "final" else {}
317
+ # (row, the wall clock its sidecar must postdate, what that clock is)
318
+ base = [
319
+ (row_from_status(t), snapshot_start_wall, "start_wall")
320
+ for t in snapshot.get("tests", [])
321
+ ]
322
+ listed = {r.model_id for r, _, _ in base}
323
+ rest = set(by_model) - listed
324
+ if rest:
325
+ # A row the last run did not run is judged against ITS OWN last run
326
+ # — the start its manifest entry keeps: against the last run's
327
+ # start, every sidecar outside its scope read stale (638 warnings
328
+ # and no Reference link on a 331-test page).
329
+ manifest = _manifest_rows(work_dir)
330
+ base += [
331
+ (row, last_run_at, "last_run_at")
332
+ for m, (row, last_run_at) in manifest.items()
333
+ if m in rest
334
+ ]
335
+ base += [
336
+ (TestRow(model_id=m), None, "last_run_at")
337
+ for m in sorted(rest - set(manifest))
338
+ ]
339
+ rows = []
340
+ for row, start_wall, anchor in base:
341
+ before = row
342
+ comp = by_model.get(row.model_id)
343
+ # Where this test's report page may be. The suite writes it under
344
+ # the baseline it was compared against (``ref_NNNN``), else under
345
+ # its test key — a failed simulation's too. The name its RUN gave
346
+ # it is a guess made before either was known: a test accepted
347
+ # since has a baseline its run did not name.
348
+ compared = f"ref_{comp.test_id}" if comp is not None and comp.test_id else None
349
+ places = dict.fromkeys(d for d in (compared, row.report_dir, row.test_key) if d)
350
+ for place in places if mode == "final" else ():
351
+ sidecar = _read_comparison_sidecar(work_dir, place)
352
+ if not sidecar:
353
+ continue
354
+ # Sidecars written before the summary block carry the
355
+ # fields at the top level.
356
+ row = enrich_row(
357
+ row,
358
+ sidecar.get("summary", sidecar),
359
+ start_wall=start_wall,
360
+ anchor=anchor,
361
+ )
362
+ if row is not before:
363
+ # A sidecar of THIS run's verdict was read: the page it
364
+ # was written beside is there to open. Nothing else says
365
+ # a page exists — a run wipes its tests' report dirs.
366
+ row = replace(row, report_page=place)
367
+ break
368
+ # enrich_row hands back the same row when a guard refuses the
369
+ # sidecar. With no fresh sidecar (a run without --report, or a stale
370
+ # one), the walls are the ones the run kept beside the result
371
+ # (storage.run_stats).
372
+ if mode == "final" and row is before and row.test_key:
373
+ row = _with_kept_timing(work_dir, row)
374
+ if comp is not None:
375
+ row = _with_verdict(row, comp)
376
+ result = (results or {}).get(row.model_id) if mode == "final" else None
377
+ cpu = cpu_time(result.statistics) if result is not None else None
378
+ if cpu is not None:
379
+ row = replace(row, sim_cpu=cpu)
380
+ rows.append(row)
381
+
382
+ # rerun_prefix precedence: explicit kwarg > snapshot's prefix > "dstf"
383
+ # fallback. The kwarg path lets the CLI override the snapshot prefix even
384
+ # when status.json was written by a prior run with a different config
385
+ # (e.g. dstf compare reading work_dir from a different invocation).
386
+ prefix = rerun_prefix or snapshot.get("rerun_prefix") or "dstf"
387
+
388
+ return {
389
+ "mode": mode,
390
+ "auto_refresh": mode == "live",
391
+ "title": "Test progress" if mode == "live" else "Test report",
392
+ # A final page counts the rows it lists (status.json's scope and
393
+ # every test scored); a live one, the run's.
394
+ "total": len(rows) if mode == "final" else snapshot.get("total", 0),
395
+ # What the last run ran, for a final page that lists more: its
396
+ # status bar says so rather than "N tests · ran for 0s".
397
+ "run_total": len(snapshot.get("tests", [])),
398
+ "elapsed": snapshot.get("elapsed", 0.0),
399
+ "eta_seconds": snapshot.get("eta_seconds"),
400
+ "counts": snapshot.get("counts", {}),
401
+ "tests": rows,
402
+ "columns": COLUMNS,
403
+ # The count pills, in PILL_STATUSES' order: [status class, the
404
+ # word a person reads]. The class is the pill's key and the row's
405
+ # data-status.
406
+ "statuses": [[STATUS_CLASSES[s], STATUS_LABELS[s]] for s in PILL_STATUSES],
407
+ "rerun_prefix": prefix,
408
+ "updated_at": snapshot.get("updated_at", time.time()),
409
+ # Wall-clock anchor for the dashboard's live elapsed clock. JS reads
410
+ # SNAPSHOT_WALL (= updated_at, when the snapshot was written) and
411
+ # SNAPSHOT_ELAPSED (= elapsed at that moment) to compute a live-
412
+ # ticking elapsed = SNAPSHOT_ELAPSED + (Date.now()/1000 - SNAPSHOT_WALL).
413
+ # This works between meta-refresh ticks (when the snapshot is stale)
414
+ # and right after a refresh (when it's fresh).
415
+ "start_wall": snapshot.get("start_wall"),
416
+ # Identity of the report this page IS: work dir + the wall clock
417
+ # the run started at. The dashboard's localStorage state is keyed
418
+ # by location.pathname, which two successive runs into the same
419
+ # work_dir share — so a selection made in run N was restored into
420
+ # run N+1's page and silently joined the rerun command the user
421
+ # built there. JS scopes the selection to this key and drops a
422
+ # selection stamped with any other.
423
+ "report_key": (
424
+ f"{work_dir.resolve().as_posix()}@"
425
+ f"{snapshot.get('start_wall') or snapshot.get('updated_at') or 0}"
426
+ ),
427
+ # Run provenance (backend/simulator/version/os) for the banner. None
428
+ # for snapshots written before this field existed.
429
+ "run_metadata": format_run_metadata(snapshot.get("metadata")),
430
+ }
431
+
432
+
433
+ def _atomic_write(path: Path, text: str) -> None:
434
+ """Atomic file write — Windows file-locking workaround.
435
+
436
+ Same retry logic as ProgressReporter._atomic_write — uses unique
437
+ tmp name so concurrent writers can't share the same tmp path.
438
+ """
439
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
440
+ tmp.write_text(text, encoding="utf-8")
441
+ last_err: OSError | None = None
442
+ for delay in (0, 0.05, 0.1, 0.2, 0.5):
443
+ if delay:
444
+ time.sleep(delay)
445
+ try:
446
+ tmp.replace(path)
447
+ return
448
+ except OSError as e:
449
+ last_err = e
450
+ try:
451
+ tmp.unlink()
452
+ except OSError:
453
+ pass
454
+ if not (path.parent / path.name).exists():
455
+ raise last_err
456
+
457
+
458
+ def _render(
459
+ work_dir: Path,
460
+ mode: str,
461
+ rerun_prefix: str | None = None,
462
+ output_path: Path | None = None,
463
+ comparisons: Iterable[TestComparison] = (),
464
+ results: Mapping[str, TestResult] | None = None,
465
+ theme: str = "default",
466
+ ) -> None:
467
+ ctx = build_dashboard_context(
468
+ work_dir,
469
+ mode=mode,
470
+ rerun_prefix=rerun_prefix,
471
+ comparisons=comparisons,
472
+ results=results,
473
+ )
474
+ template = _env.get_template("dashboard.html")
475
+ # The token block per render, never at import: the theme is the
476
+ # project's (`Config.theme`), and every caller in `src` says it —
477
+ # `tests/test_themes.py` reads the call sites.
478
+ html = template.render(**ctx, dstf_css=css_variables(theme))
479
+ dest = output_path or (work_dir / "dashboard.html")
480
+ dest.parent.mkdir(parents=True, exist_ok=True)
481
+ _atomic_write(dest, html)
482
+
483
+
484
+ def render_live(
485
+ work_dir: Path, rerun_prefix: str | None = None, theme: str = "default"
486
+ ) -> None:
487
+ """Render dashboard.html in live mode (auto-refreshes via meta tag)."""
488
+ _render(work_dir, mode="live", rerun_prefix=rerun_prefix, theme=theme)
489
+
490
+
491
+ def render_final(
492
+ work_dir: Path,
493
+ rerun_prefix: str | None = None,
494
+ comparisons: Iterable[TestComparison] = (),
495
+ results: Mapping[str, TestResult] | None = None,
496
+ theme: str = "default",
497
+ ) -> None:
498
+ """Render dashboard.html in final mode (refresh stripped, the
499
+ comparisons' verdicts and the sidecars merged; the results' CPU
500
+ times where no wall time was kept)."""
501
+ _render(
502
+ work_dir,
503
+ mode="final",
504
+ rerun_prefix=rerun_prefix,
505
+ comparisons=comparisons,
506
+ results=results,
507
+ theme=theme,
508
+ )
509
+
510
+
511
+ def render_final_to(
512
+ work_dir: Path,
513
+ output_path: Path,
514
+ rerun_prefix: str | None = None,
515
+ comparisons: Iterable[TestComparison] = (),
516
+ results: Mapping[str, TestResult] | None = None,
517
+ theme: str = "default",
518
+ ) -> None:
519
+ """Render the final-mode dashboard page to a caller-chosen path.
520
+
521
+ Backs ``--report-format html``: the same page ``render_final`` writes,
522
+ just to ``output_path``. The page is self-contained (dashboard.js is
523
+ ``{% include %}``d), but its per-test links are relative to ``work_dir``
524
+ — they only resolve when the output lands inside the work dir.
525
+ """
526
+ _render(
527
+ work_dir,
528
+ mode="final",
529
+ rerun_prefix=rerun_prefix,
530
+ output_path=output_path,
531
+ comparisons=comparisons,
532
+ results=results,
533
+ theme=theme,
534
+ )