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,388 @@
1
+ """The per-test status classification every report surface agrees on.
2
+
3
+ One classifier, one ordering. The same ordering bug had to be fixed twice
4
+ in separate copies of it (review 2026-07-06: finding 5 — a failing
5
+ baseline-free test read NO_REF and exited 0; finding 69 — NO_REF was
6
+ counted as passed and SIM_FAIL as failed), which is what this module
7
+ exists to make impossible.
8
+
9
+ Ordering, and why:
10
+
11
+ 0. ``NOT RUN`` — there is no result: the last run never ran the test, or
12
+ never finished it (D145). Nothing ran, so nothing failed, and no verdict
13
+ about the trajectories exists. (A run in which NO test ran still exits
14
+ 1: ``exit_code``.)
15
+ 1. ``SIM_FAIL`` — the simulation never produced data, so no verdict
16
+ about the trajectories is meaningful.
17
+ 2. ``FAIL`` — a failing verdict outranks a missing baseline: baseline-free
18
+ modes (``range``, ``points`` with explicit values) carry a real verdict
19
+ with nothing stored. A test the comparison refused is here too, baseline
20
+ or none (:func:`refusal`): one that scored NOTHING is not a pass.
21
+ 3. simulate-only — the pass criterion IS "it simulated", so a missing
22
+ baseline is irrelevant and the verdict is PASS, never NO_REF.
23
+ 4. ``NO_REF`` — reserved for tests where nothing was evaluated at all.
24
+ 5. ``PASS``.
25
+
26
+ The buckets are mutually exclusive, so header counts always reconcile
27
+ with row statuses.
28
+
29
+ D117 left the report suite with a private copy of this ordering because
30
+ it had the ``TestModel`` in hand and this function did not: simulate-only
31
+ tests were the one verdict the two surfaces disagreed on. ``TestComparison``
32
+ now carries ``simulate_only`` itself, so the input asymmetry — and the
33
+ copy — are gone. Every surface classifies here.
34
+ """
35
+
36
+ from collections import Counter
37
+ from collections.abc import Iterable
38
+ from dataclasses import dataclass
39
+
40
+ from ..comparison.types import TestComparison, VariableComparison
41
+
42
+ PASS = "PASS"
43
+ FAIL = "FAIL"
44
+ SIM_FAIL = "SIM_FAIL"
45
+ NO_REF = "NO_REF"
46
+ #: No result exists (D145): no manifest entry, or one the last run left
47
+ #: ``pending``. The key is the word a person reads, so a surface that shows
48
+ #: the classifier's key reads it. Not a failure (``FAILURES``).
49
+ NOT_RUN = "NOT RUN"
50
+
51
+ #: The statuses that fail a run: what ``compare`` and ``run`` exit on
52
+ #: (``exit_code``), beside a run in which nothing ran. NO_REF and NOT RUN
53
+ #: are not in it.
54
+ FAILURES = frozenset({FAIL, SIM_FAIL})
55
+
56
+ #: The states a test is in before it has a verdict, from a live run's
57
+ #: status.json. A test that simulated and is not yet scored has no word of
58
+ #: its own: the dashboard shows it as its live row says until the compare
59
+ #: phase writes its verdict.
60
+ QUEUED = "QUEUED"
61
+ RUNNING = "RUNNING"
62
+ TIMED_OUT = "TIMED_OUT"
63
+
64
+ #: A run stopped on request (the simulation queue's cancel, D150). It is
65
+ #: recorded as cancelled and read back as NO result, so the classifier's
66
+ #: word for the test is NOT RUN; this is the word a live row says for why
67
+ #: (:func:`scored_status` is where a row keeps it).
68
+ #: It wears NOT RUN's class: the pills, the tint and the exit code count it
69
+ #: there — nothing ran to a verdict, so nothing failed (D145).
70
+ CANCELLED = "CANCELLED"
71
+
72
+ #: What the tool says for a test whose scoring raised: it has a result and
73
+ #: no comparison at all. Not a status of the classifier's — it has no
74
+ #: comparison to classify — and the Tests drawer counts it under SIM_FAIL.
75
+ SCORING_FAILED = "scoring failed"
76
+
77
+ #: What the tool says for a test its Session has not scored yet — every
78
+ #: row while a project opens — and the class it wears: the neutral grey,
79
+ #: under a name of its own. NOT ``QUEUED``: since D150 that word is a test
80
+ #: in the simulation queue (and a waiting test of a CLI run), and a Suite
81
+ #: pill reading ``QUEUED 331`` beside a header reading ``idle`` said two
82
+ #: things at once. No status of the classifier's, and no status.json row
83
+ #: says it: the dashboard has no pill for it.
84
+ SCORING = "SCORING"
85
+ SCORING_CLASS = "scoring"
86
+
87
+ #: Every status, in the order a sort by status uses.
88
+ STATUS_ORDER = (
89
+ QUEUED,
90
+ RUNNING,
91
+ PASS,
92
+ FAIL,
93
+ SIM_FAIL,
94
+ NO_REF,
95
+ TIMED_OUT,
96
+ NOT_RUN,
97
+ CANCELLED,
98
+ )
99
+
100
+ #: The statuses the count pills are drawn from, in their order: one per
101
+ #: class. CANCELLED has none of its own — it is counted under NOT RUN's.
102
+ PILL_STATUSES = tuple(s for s in STATUS_ORDER if s != CANCELLED)
103
+
104
+ #: Status → the CSS class both HTML surfaces style it with. One table, so
105
+ #: the dashboard, the report header and the tool cannot drift into two
106
+ #: names for one state.
107
+ STATUS_CLASSES = {
108
+ PASS: "pass",
109
+ FAIL: "fail",
110
+ SIM_FAIL: "sim-fail",
111
+ NO_REF: "no-ref",
112
+ QUEUED: "queued",
113
+ RUNNING: "running",
114
+ TIMED_OUT: "timed-out",
115
+ NOT_RUN: "not-run",
116
+ CANCELLED: "not-run",
117
+ }
118
+
119
+ #: Status → the word a person reads. Only TIMED_OUT differs from its key.
120
+ STATUS_LABELS = {s: s for s in STATUS_ORDER} | {TIMED_OUT: "TIMED OUT"}
121
+
122
+ _LIVE = {
123
+ "queued": QUEUED,
124
+ "running": RUNNING,
125
+ "timed_out": TIMED_OUT,
126
+ "failed": SIM_FAIL,
127
+ "cancelled": CANCELLED,
128
+ }
129
+
130
+
131
+ #: Every state a status.json entry is in: the ones above, and "passed" —
132
+ #: the simulation ran, and the verdict is the compare phase's.
133
+ LIVE_STATES = frozenset(_LIVE) | {"passed"}
134
+
135
+
136
+ def live_status(raw: str) -> str | None:
137
+ """A live status.json state as a status, or None for "passed": the
138
+ simulation ran and the verdict is the compare phase's to give."""
139
+ return _LIVE.get(raw)
140
+
141
+
142
+ def classify(comp: TestComparison) -> str:
143
+ """Return this test's status: NOT RUN / PASS / FAIL / SIM_FAIL / NO_REF."""
144
+ if not comp.ran:
145
+ return NOT_RUN
146
+ if not comp.sim_success:
147
+ return SIM_FAIL
148
+ if not comp.passed:
149
+ return FAIL
150
+ if comp.simulate_only:
151
+ # Pass criterion is "the simulation ran"; a baseline is not part
152
+ # of it, so this must not fall through to NO_REF.
153
+ return PASS
154
+ if not comp.has_reference and not comp.evaluated:
155
+ return NO_REF
156
+ return PASS
157
+
158
+
159
+ def refusal(comp: TestComparison) -> str | None:
160
+ """Why the comparison REFUSED this test — FAILED it without scoring a
161
+ variable (a base tolerance that is no number; a result that holds
162
+ nothing the test tracks) — or ``None``. The reason
163
+ is the whole finding: there is no failing variable to name and no tree
164
+ to draw, so every surface says it where it would name those. Not a
165
+ SIM_FAIL's or a NO_REF's message, which their own status says."""
166
+ if classify(comp) == FAIL and not comp.variables and comp.error_message:
167
+ return comp.error_message
168
+ return None
169
+
170
+
171
+ def scored_status(verdict: str, *, cancelled: bool) -> str:
172
+ """What a ROW reads once its test has a verdict: *verdict*, the
173
+ classifier's — but a run stopped on request keeps its word. A cancelled
174
+ run scores NOT RUN, there being no result, and CANCELLED says why
175
+ (D150); any other verdict means the test has a result since, and
176
+ replaces the word as it replaces every live state.
177
+
178
+ The one place this is decided. *cancelled* is what the caller's row
179
+ knows — a status.json row's state, a queue item's — and every surface
180
+ that puts a verdict on such a row asks here: the sidecar's enrichment
181
+ (``rows.enrich_row``), the dashboard's comparison in hand and the
182
+ Suite's row. A surface with no row (the console, JUnit) prints the
183
+ verdict itself.
184
+ """
185
+ return CANCELLED if cancelled and verdict == NOT_RUN else verdict
186
+
187
+
188
+ def classify_with_class(comp: TestComparison) -> tuple[str, str]:
189
+ """Return ``(status_text, css_class)`` for the HTML surfaces."""
190
+ state = classify(comp)
191
+ return state, STATUS_CLASSES[state]
192
+
193
+
194
+ def count_by_status(comparisons: list[TestComparison]) -> Counter[str]:
195
+ """Count *comparisons* into the mutually-exclusive status buckets.
196
+
197
+ Missing buckets read 0 (``Counter``), so callers can index every one
198
+ without guarding.
199
+ """
200
+ return Counter(classify(c) for c in comparisons)
201
+
202
+
203
+ def nothing_ran(comparisons: Iterable[TestComparison]) -> bool:
204
+ """True when no test ran: no comparisons at all, or every one NOT RUN.
205
+
206
+ A compare over an empty or wrong work dir, or after a run whose worker
207
+ pool never started (every test stamped pending), is here. That is not a
208
+ green run, though no test in it failed (D145 §5)."""
209
+ return not any(c.ran for c in comparisons)
210
+
211
+
212
+ def exit_code(comparisons: Iterable[TestComparison]) -> int:
213
+ """1 when any test FAILED or failed to simulate, or when nothing ran
214
+ (:func:`nothing_ran`), else 0: a test NOT RUN beside tests that ran, or
215
+ one with no baseline, does not fail a run (D145)."""
216
+ comps = list(comparisons)
217
+ if nothing_ran(comps):
218
+ return 1
219
+ return 1 if any(classify(c) in FAILURES for c in comps) else 0
220
+
221
+
222
+ def group_failures(comp: TestComparison) -> list[str]:
223
+ """One line per group of the test's tree that fails on its own
224
+ (``tree_eval.failed_groups``), in tree order: which group, and its score
225
+ against its threshold — ``weighted group at metrics.children[1]:
226
+ weighted sum 2.500000e-03 is not below its threshold 0.001``. A test
227
+ that fails only there has no failing criterion, and JUnit said ``0
228
+ criteria failed`` over an empty body. Imported late, as below."""
229
+ from ..comparison.tree_eval import failed_groups
230
+
231
+ lines = []
232
+ for place, node in failed_groups(comp.metric_tree):
233
+ kind = node.label.split("[", 1)[0]
234
+ diag = node.diagnostics or {}
235
+ if "weighted_sum" in diag:
236
+ side = "below" if diag.get("direction", "less") == "less" else "above"
237
+ why = (
238
+ f"weighted sum {diag['weighted_sum']:.6e} is not {side} its "
239
+ f"threshold {diag['threshold']:g}"
240
+ )
241
+ else:
242
+ why = str(diag.get("reason") or "failed with no failing criterion under it")
243
+ lines.append(f"{kind} group at {place}: {why}")
244
+ return lines
245
+
246
+
247
+ def _window_text(window: object) -> str:
248
+ """``window 0–0.5 s`` for a leaf scoped to part of the run; ``""`` for
249
+ one that is not."""
250
+ if not isinstance(window, dict):
251
+ return ""
252
+ start, end = window.get("start"), window.get("end")
253
+ if start is None and end is None:
254
+ return ""
255
+ if start is None:
256
+ return f"window to {end:g} s"
257
+ if end is None:
258
+ return f"window from {start:g} s"
259
+ return f"window {start:g}–{end:g} s"
260
+
261
+
262
+ @dataclass(frozen=True)
263
+ class FailedCriterion:
264
+ """One criterion that did not pass, in the words a text surface prints
265
+ for it — the console as a block, JUnit as a line."""
266
+
267
+ #: Which criterion it is: the variable (``pipe.T[1] (unitTests.x[1])``,
268
+ #: the name alone when they agree — the expression says WHICH quantity,
269
+ #: the name what to write a criterion or an override against), then its
270
+ #: metric, its window if it has one and the baseline it is scored
271
+ #: against; led by ``advisory — `` under a ``warn``, where it cannot
272
+ #: fail the test.
273
+ head: str
274
+ advisory: bool
275
+ #: The criterion in the metric's own words (the measured value against
276
+ #: its tolerance or bound — ``registry.summarize``, what the report's
277
+ #: tree row reads); ``""`` for one that was not scored.
278
+ sentence: str
279
+ #: The metric's own detail pairs; none for one that was not scored.
280
+ details: tuple[tuple[str, str], ...]
281
+ #: Why it was not scored (:func:`not_scored_reason`), else ``None``.
282
+ reason: str | None
283
+
284
+ @property
285
+ def line(self) -> str:
286
+ """The criterion on ONE line: its head, then its sentence or the
287
+ reason it was not scored."""
288
+ said = f"Not scored: {self.reason}" if self.reason else self.sentence
289
+ return f"{self.head} — {said}"
290
+
291
+
292
+ def failed_criteria(comp: TestComparison) -> list[FailedCriterion]:
293
+ """The criteria of ``comp`` that did not pass, in TREE order — two
294
+ criteria on one variable are two entries, and each says which it is.
295
+ THE one reading of a failing criterion for a surface with no page: the
296
+ console's failure blocks and JUnit's failure lines are both written from
297
+ it, so they cannot name a criterion differently. (JUnit wrote every
298
+ metric's number as ``x: NRMSE=…``, and two failing criteria on one
299
+ variable as two identical lines.) Imported late, as below."""
300
+ from ..backends.types import label_with_name
301
+ from ..comparison.registry import summarize
302
+ from ..comparison.tree_eval import (
303
+ advisory_comparisons,
304
+ flatten_evaluation,
305
+ leaf_comparisons_by_path,
306
+ )
307
+
308
+ tree = comp.metric_tree
309
+ leaves: list[tuple[VariableComparison, dict]]
310
+ if tree is not None:
311
+ said = flatten_evaluation(tree)
312
+ leaves = [
313
+ (vc, said.get(path, {}))
314
+ for path, vc in leaf_comparisons_by_path(tree).items()
315
+ ]
316
+ else:
317
+ leaves = [(vc, {}) for vc in comp.variables]
318
+ advisory = {id(vc) for vc in advisory_comparisons(tree)}
319
+ out: list[FailedCriterion] = []
320
+ for vc, leaf in leaves:
321
+ if vc.passed:
322
+ continue
323
+ name = label_with_name(vc.name, vc.expression)
324
+ # The metric is the comparison's own `mode`, as on every surface;
325
+ # the one criterion with none (a flat override naming a mode
326
+ # nothing is registered under) is headed by its variable alone.
327
+ what = [vc.mode] if vc.mode else []
328
+ window = _window_text(leaf.get("window"))
329
+ if window:
330
+ what.append(window)
331
+ # The baseline it READ, where it reads one: a `range` names none.
332
+ if vc.against:
333
+ what.append(f"against {vc.against}")
334
+ under_warn = id(vc) in advisory
335
+ lead = "advisory — " if under_warn else ""
336
+ head = f"{lead}{': '.join([name, ' · '.join(what)] if what else [name])}"
337
+ reason = not_scored_reason(vc)
338
+ summary = None if reason else summarize(vc)
339
+ out.append(
340
+ FailedCriterion(
341
+ head=head,
342
+ advisory=under_warn,
343
+ sentence=summary.criterion if summary is not None else "",
344
+ details=tuple(summary.details) if summary is not None else (),
345
+ reason=reason,
346
+ )
347
+ )
348
+ return out
349
+
350
+
351
+ def advisory_failures(comp: TestComparison) -> list[FailedCriterion]:
352
+ """The criteria that failed under a ``warn`` in a test that PASSES —
353
+ what such a test has to say and no verdict says: it is PASS, it exits 0
354
+ and it is counted with the passes, and a text surface printed nothing
355
+ else of it. Empty for a test of any other status: a failing test's
356
+ advisory criteria are in its failure details."""
357
+ if classify(comp) != PASS:
358
+ return []
359
+ return [c for c in failed_criteria(comp) if c.advisory]
360
+
361
+
362
+ def advisory_text(n: int) -> str:
363
+ """``1 advisory failure`` / ``2 advisory failures``: the words every
364
+ surface counts them in (the tool's strip, JUnit's message, the console)."""
365
+ return f"{n} advisory failure{'' if n == 1 else 's'}"
366
+
367
+
368
+ def not_scored_reason(var: VariableComparison) -> str | None:
369
+ """Why a leaf produced no score, or ``None`` when it scored one.
370
+
371
+ Sentinels (a metric config that could not build, a baseline that is
372
+ not there, a quantity path absent from one side, no data to compare)
373
+ carry every number as inf/nan; the reason is the whole finding, and
374
+ every surface must print it in place of that wall. It rides in
375
+ ``diagnostics["error"]`` — the one exception is a scalar sentinel
376
+ whose summary states the reason without a diagnostic, so the mode's
377
+ own summary is the fallback. Imported late: ``registry`` reaches back
378
+ into ``comparison`` and this module is imported from the CLI.
379
+ """
380
+ error = (var.diagnostics or {}).get("error")
381
+ if error:
382
+ return str(error)
383
+ from ..comparison.registry import summarize
384
+
385
+ summary = summarize(var)
386
+ if summary.display == "not scored":
387
+ return summary.criterion
388
+ return None
@@ -0,0 +1,61 @@
1
+ // ---- axes.js ----
2
+ // A secondary y-axis (one with `overlaying`) is visible exactly while a
3
+ // trace on it is visible. The figure layer lays every such axis out
4
+ // hidden (dstf.figures.plotly: a laid-out axis with only legend-only
5
+ // traces draws a stray zero line); this script shows and hides it as the
6
+ // traces toggle — by the report's select (Plotly.restyle) or by a legend
7
+ // click, which is a client-side restyle the server never hears.
8
+ //
9
+ // The report's script: it loads as the first part of interactive.js. The
10
+ // authoring tool does not load it — its error traces are out of the
11
+ // legend, and the figure it sends names the one error axis shown
12
+ // (dstf.figures.plotly.Visibility), so nothing in its page toggles a
13
+ // trace behind the server's back. It computes nothing about the data —
14
+ // it reads `visible` flags and writes `visible` flags, and `autorange` on
15
+ // an axis whose shown traces change — on a restyle (the select, a legend
16
+ // click).
17
+ (function () {
18
+ // The indices of the visible traces on the axis, as one string: the
19
+ // report never adds, removes or reorders a trace, so an index names one.
20
+ function visibleOn(gd, axisId) {
21
+ var ids = [];
22
+ (gd.data || []).forEach(function (t, i) {
23
+ if ((t.yaxis || 'y') !== axisId) return;
24
+ if (t.visible === 'legendonly' || t.visible === false) return;
25
+ ids.push(i);
26
+ });
27
+ return ids.join(',');
28
+ }
29
+
30
+ function dstfFollowSecondaryAxes(gd) {
31
+ var layout = gd.layout || {};
32
+ var seen = gd._dstfShownOn || (gd._dstfShownOn = {});
33
+ var update = {};
34
+ Object.keys(layout).forEach(function (key) {
35
+ var m = /^yaxis(\d+)$/.exec(key);
36
+ if (!m || !layout[key] || !layout[key].overlaying) return;
37
+ var ids = visibleOn(gd, 'y' + m[1]);
38
+ var was = seen[key];
39
+ seen[key] = ids;
40
+ var want = ids !== '';
41
+ if (Boolean(layout[key].visible) !== want) update[key + '.visible'] = want;
42
+ // A box zoom scales the axis too, shown or hidden (it overlays `y`):
43
+ // kept when another series took the axis, it cut that series off or
44
+ // drew it as a flat line. A new set of series scales it to their
45
+ // data; a restyle that leaves the set alone (an overlay on `y`)
46
+ // keeps a zoom.
47
+ if (want && ids !== was) update[key + '.autorange'] = true;
48
+ });
49
+ if (Object.keys(update).length) Plotly.relayout(gd, update);
50
+ }
51
+
52
+ // report.js calls this once per plot, right after its one newPlot of
53
+ // that node; the report never reacts or re-plots.
54
+ function dstfWireSecondaryAxes(gd) {
55
+ if (!gd || typeof gd.on !== 'function') return;
56
+ gd.on('plotly_restyle', function () { dstfFollowSecondaryAxes(gd); });
57
+ dstfFollowSecondaryAxes(gd);
58
+ }
59
+
60
+ window.dstfWireSecondaryAxes = dstfWireSecondaryAxes;
61
+ })();