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,1171 @@
1
+ """One plot of the Test page (brief §3.2; DESIGN.md "Plots are modular",
2
+ "Criteria rows"): its title and status pills, its context series, the slot
3
+ the plot is drawn into when it scrolls into view, a criteria header holding
4
+ what attaches at the VARIABLE placement (the plot's `+`), the criteria rows
5
+ of its SUBJECT in tree order with each row's node controls, and the one
6
+ open criterion's editor below its row. A card owns its `ui.plotly`, its
7
+ `FigureHook` and its snap-back; the card knows no capability by name.
8
+
9
+ A plot is a view of the criteria tree (`derive_plots`): a DERIVED card
10
+ exists because criteria name its subject and goes when the last of them
11
+ goes — it has no Remove; a WATCH card is a person's unscored plot of one
12
+ variable, and has.
13
+
14
+ A card is a 320 px slot until `show()` is called — by the browser's
15
+ `dstf_plot_visible`, by an opening editor, or by a test. Nothing on the
16
+ page draws a figure before that: a test with a dozen variables would
17
+ otherwise assemble a dozen figures nobody has scrolled to."""
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ from collections.abc import Mapping, Sequence
24
+ from functools import partial
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ from nicegui import ui
29
+
30
+ from ...backends.types import VariableText
31
+ from ...comparison import registry
32
+ from ...discovery.plots import ResolvedPlot
33
+ from ...figures.assemble import leaves_by_variable
34
+ from ...figures.families import family_label, group_families
35
+ from ...figures.plotly import _LABELS, Drags, Visibility, drags_of
36
+ from ...reporting.tree_rows import criteria_rows, leaves_of, names_a_series
37
+ from ..capabilities import Placement
38
+ from ..family_rows import is_fold, leaf_of
39
+ from ..session import Selection
40
+ from ..tree_text import leaf_name, parent_kinds
41
+ from .actions import InlineHost, RowTarget
42
+ from .family_fold import Folding
43
+ from .figure_hook import FigureHook
44
+ from .placing import Placing
45
+ from .tree_card import leaf_readout, leaf_status
46
+ from .widgets import MiddleCut, Pill
47
+
48
+ if TYPE_CHECKING:
49
+ from .test_page import TestPage
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+ #: Worst first: a card's pill is the worst of its variable's criteria, never
54
+ #: the first one's (a passing leaf above a failing one must not read PASS).
55
+ _WORST = ("fail", "warn", "pass", "queued")
56
+
57
+ #: What a leaf contributes to a plot's signature: everything a staged edit
58
+ #: can move in what the plot DRAWS — its geometry (the metric and its
59
+ #: parameters, the window it is scored over, the baseline it is scored
60
+ #: against) and its verdict, which the row beside the plot reads out. Not
61
+ #: the trajectories: no leaf sees a rerun, which forces its own redraw.
62
+ _SIGNED = (
63
+ "metric",
64
+ "params",
65
+ "window",
66
+ "against",
67
+ "passed",
68
+ "score_display",
69
+ "criterion",
70
+ "error",
71
+ )
72
+
73
+ #: What the OPEN editor's fields were built from: the leaf at the open path,
74
+ #: as the page's tree view showed it. Its verdict is not in it — a rerun
75
+ #: changes no field — and nothing else in the document is either: comparing
76
+ #: the whole document's pending ops meant an edit to ANOTHER model, followed
77
+ #: by any event that synced the page, rebuilt this editor and threw away a
78
+ #: half-typed table row; and a combinator edit that moved no leaf rebuilt it for nothing (the
79
+ #: 3b review). ``advisory`` is the one key no test pins: nothing the editor
80
+ #: offers writes it, so a change to it can only arrive from a hand-written
81
+ #: patch — it is here because the field IS the leaf's, not because a path
82
+ #: was found that moves it.
83
+ _EDITED = ("metric", "variable", "params", "window", "against", "advisory")
84
+
85
+ #: The open editor's hint line (`PlotCard._say_hint`), by what the plot
86
+ #: draws for the open criterion to be dragged by (`figures.plotly.drags_of`)
87
+ #: — rings and arrows, arrows with no ring to hover, rings alone, bound
88
+ #: lines alone, nothing — and what follows each. A constant tube and an
89
+ #: event's window were told to drag a ring and to hover a point: they have
90
+ #: arrows, and neither.
91
+ EDITOR_HINTS: dict[Drags, str] = {
92
+ "rings+arrows": (
93
+ "Drag a ring or a tolerance arrow on the plot (hover a point to show "
94
+ "its arrows), or edit the fields."
95
+ ),
96
+ "arrows": (
97
+ "Drag a tolerance arrow on the plot (hover near it to show the "
98
+ "arrows), or edit the fields."
99
+ ),
100
+ "rings": "Drag a ring on the plot, or edit the fields.",
101
+ "lines": "Drag a line on the plot, or edit the fields.",
102
+ "": "Edit the fields.",
103
+ }
104
+ STAGED = " Edits are staged as you make them; Save writes them."
105
+ #: What the hint adds for a criterion whose metric has rows a Ctrl+click
106
+ #: adds to (D149), ``{}`` being what the row is called — the metric's own
107
+ #: word (`MetricDescriptor.row_noun`) — and what it says INSTEAD while a
108
+ #: field is armed, ``{}`` being that field's label.
109
+ ADDS = " Ctrl+click adds {}."
110
+ ARMED = "Click the plot to set {} — Esc cancels."
111
+ #: What the line under a family's plot says for a Ctrl+click made while a
112
+ #: FOLDED row's editor is open (D152: fields only), ``{}`` as in `ADDS`: a
113
+ #: row is added to one criterion's table, and a fold is several.
114
+ FOLD_ADDS = (
115
+ "Ctrl+click adds {} to one criterion: unfold this row and open the one to add to."
116
+ )
117
+
118
+ #: The hover module of the tolerance grips (`grips.js`): its file, and where
119
+ #: the shell serves it — beside the Suite table's scripts.
120
+ GRIPS_JS = Path(__file__).parent / "grips.js"
121
+ GRIPS_URL = "/_dstf/grips.js"
122
+
123
+ #: "Not read yet" — `None` cannot mean it: a test with nothing scored has a
124
+ #: `tree_view` of None, and a card that read it for itself cost one read per
125
+ #: shown card on every row click (the 3b review).
126
+ UNREAD: Any = object()
127
+
128
+
129
+ def worst(statuses: list[str]) -> str:
130
+ return next((status for status in _WORST if status in statuses), "queued")
131
+
132
+
133
+ def drawn(leaf: dict, choice: bool | None, is_open: bool) -> bool:
134
+ """Whether one criterion draws on its plot (legend b). The open one
135
+ always does — the plot is its editing surface. Else the swatch's choice,
136
+ when there is one; else a failing criterion does, so a FAIL never starts
137
+ hidden; else the metric's own default."""
138
+ if is_open:
139
+ return True
140
+ if choice is not None:
141
+ return choice
142
+ if leaf.get("passed") is False:
143
+ return True
144
+ desc = registry.metric(str(leaf.get("metric")))
145
+ return desc is None or desc.drawn_by_default
146
+
147
+
148
+ def folded(
149
+ names: Sequence[str],
150
+ labels: Mapping[str, str] | None = None,
151
+ by: Mapping[str, str] | None = None,
152
+ ) -> str:
153
+ """``names`` as a plot names them, the way the report's plot titles do
154
+ (`report_suite`): each array family one entry, READ through ``labels``
155
+ (``pipe.T[1:3]``), the fold taken over ``by`` — ``labels`` when not
156
+ given — so two series whose texts collide are no family. The card's
157
+ title, its muted names line and the focus bar all name a plot through
158
+ this, so they fold alike: the focus bar read the
159
+ unfolded texts while the title above it folded them."""
160
+ fold_over = labels if by is None else by
161
+ return ", ".join(
162
+ family_label(key, members, labels)
163
+ for key, members in group_families(names, fold_over)
164
+ )
165
+
166
+
167
+ #: The swatch's tooltip: what a click on it would do, or why it does nothing.
168
+ _SWATCH_TIPS = {
169
+ "open": "the open criterion always draws",
170
+ True: "Hide this criterion's drawing",
171
+ False: "Show this criterion's drawing",
172
+ }
173
+ _SWATCH_STATES = " ".join(
174
+ [
175
+ *(
176
+ f"dstf-swatch-{s}"
177
+ for s in ("on", "off", "locked", "pass", "fail", "warn", "queued")
178
+ ),
179
+ "invisible",
180
+ ]
181
+ )
182
+
183
+
184
+ #: The node the criteria header's forms act on: the tree's root, which a
185
+ #: plot's `+` adds under.
186
+ TREE_ROOT = "/metrics"
187
+
188
+
189
+ def _root_kind(view: dict | None) -> str | None:
190
+ """The root's combinator (None for a root leaf or no tree): what the
191
+ header's `+` reads — it is grey under a warn or a weighted root."""
192
+ return None if view is None else view.get("combinator")
193
+
194
+
195
+ class CriterionRow:
196
+ """One criterion under its plot (DESIGN.md "Criteria rows"): the swatch
197
+ that shows or hides its drawing, the pill, the metric and its variable,
198
+ the CLI's readout, and that node's controls — whatever attaches at NODE.
199
+ `paint` writes a new verdict into the row that is already there."""
200
+
201
+ def __init__(
202
+ self,
203
+ card: PlotCard,
204
+ leaf: dict,
205
+ taker: str | None,
206
+ crumb: str = "",
207
+ crumb_ch: int = 0,
208
+ ) -> None:
209
+ """``crumb_ch`` is the card's longest crumb, in characters: every
210
+ crumb on the card is that wide, so the names start in one column."""
211
+ index, path = card.index, str(leaf["path"])
212
+ self.card, self.leaf = card, leaf
213
+ #: The parent's combinator the controls were built under: it takes
214
+ #: the child `+` adds, so a kind that moves rebuilds them (`paint`).
215
+ self.taker = taker
216
+ self.path, self.variable = path, str(leaf.get("variable"))
217
+ model, variable = card.page.model_id, self.variable
218
+ # The row WRAPS, as a Quasar row does unless told not to: a long
219
+ # name keeps one line, and the readout and the node's controls move
220
+ # under it.
221
+ self.row = ui.row().classes("dstf-crit items-center gap-3 w-full")
222
+ # The row IS the way in: its editor opens below it, and a second
223
+ # click closes it — or closes a form row open under it and opens
224
+ # nothing (board 1A). A click on the node's controls inside the row
225
+ # is not a click on the row — `InlineHost`'s column stops it.
226
+ self.row.on("click", partial(card.page.row_click, index, path))
227
+ with self.row.mark(f"criterion:{index}:{path}"):
228
+ # The criterion's drawing on the plot above, shown or hidden
229
+ # (legend b). Its click is stopped in the BROWSER before it
230
+ # reaches the row, which would open the editor too; the user
231
+ # simulation's click never bubbles, so only Chromium can say so.
232
+ # A button to the keyboard as well: Tab reaches it, and Enter or
233
+ # Space toggles it the way a click does (Space's default would
234
+ # scroll the page).
235
+ self.swatch = (
236
+ ui.element("span")
237
+ .classes("dstf-swatch")
238
+ .props('role=button tabindex="0"')
239
+ .mark(f"swatch:{index}:{path}")
240
+ .on(
241
+ "click",
242
+ self._toggle,
243
+ js_handler="(e) => { e.stopPropagation(); emit(); }",
244
+ )
245
+ .on(
246
+ "keydown",
247
+ self._toggle,
248
+ js_handler=(
249
+ "(e) => { if (e.key === 'Enter' || e.key === ' ') "
250
+ "{ e.preventDefault(); e.stopPropagation(); emit(); } }"
251
+ ),
252
+ )
253
+ )
254
+ with self.swatch:
255
+ self.swatch_tip = ui.tooltip("")
256
+ # Hidden until the plot is drawn: which leaves draw anything is
257
+ # known only from the figure (`PlotCard.drawable`). INVISIBLE,
258
+ # never display:none — a hidden swatch keeps its box, so every
259
+ # row's name starts in one column (D140).
260
+ self.swatch.classes("invisible")
261
+ # Its ancestors' kinds, muted: where in the tree it sits. The
262
+ # card's rows come from anywhere in it (a plot is a view of the
263
+ # tree), and their order alone does not say which is under what.
264
+ if crumb:
265
+ ui.label(crumb).classes("dstf-crumb dstf-why").style(
266
+ f"min-width: {crumb_ch}ch"
267
+ ).mark(f"crumb:{index}:{path}")
268
+ self.pill = Pill(leaf_status(leaf), f"criterion-pill:{index}:{path}")
269
+ self.name = MiddleCut().classes(
270
+ "dstf-crit-name text-body2 text-weight-medium"
271
+ )
272
+ self.readout = ui.label().classes("dstf-mono text-xs")
273
+ self.readout.mark(f"criterion-readout:{index}:{path}")
274
+ self.target = RowTarget(
275
+ card.page, f"crit:{index}:{path}", self.row, path, index
276
+ )
277
+ self.host = InlineHost(
278
+ card.page.shell,
279
+ Placement.NODE,
280
+ partial(
281
+ Selection,
282
+ tests=frozenset({model}),
283
+ variable=variable,
284
+ node_path=path,
285
+ ),
286
+ figure=None,
287
+ marker=f"node:{index}:{path}",
288
+ row=self.target,
289
+ apply=card.page.apply,
290
+ )
291
+ # A host drawn inside a row keeps to its own width, or the
292
+ # cluster takes the rest of the row and sits left of the end
293
+ # (the 3b review, in Chromium). The stylesheet's margin puts it
294
+ # at the row's right end, on the name's line or, the row
295
+ # wrapped, on the next — a spacer before it did that only when
296
+ # it wrapped together with the readout.
297
+ self.host.column.classes(remove="w-full", add="dstf-node-host")
298
+ # The row's slot: its editor, or a form row — one at a time. The
299
+ # host is built once its panels have somewhere to open a row.
300
+ self.slot = ui.column().classes("w-full gap-0")
301
+ self.target.slot = self.slot
302
+ self.host.build()
303
+ self.paint(leaf)
304
+
305
+ def paint(self, leaf: dict) -> None:
306
+ self.pill.set(leaf_status(leaf))
307
+ self.name.set_text(leaf_name(leaf))
308
+ self.readout.set_text(leaf_readout(leaf))
309
+ self.paint_swatch(leaf)
310
+
311
+ def paint_swatch(self, leaf: dict) -> None:
312
+ """Filled in the pill's status colour while the drawing shows, an
313
+ outline of it while hidden; locked while this criterion is open. A
314
+ verdict that flips moves a default, so every paint repaints it."""
315
+ self.leaf = leaf
316
+ card = self.card
317
+ is_open = card.is_open(self.path)
318
+ on = drawn(leaf, card.choices.get(self.path), is_open)
319
+ # ONE call, its visibility too: a remove and then an add are two
320
+ # changes to the element even when they cancel, and a no-op sync
321
+ # re-sent every swatch.
322
+ self.swatch.classes(
323
+ remove=_SWATCH_STATES,
324
+ add=f"dstf-swatch-{'on' if on else 'off'} dstf-swatch-{leaf_status(leaf)}"
325
+ + (" dstf-swatch-locked" if is_open else "")
326
+ + ("" if self.path in card.drawable else " invisible"),
327
+ )
328
+ if is_open:
329
+ self.swatch.props("aria-disabled=true")
330
+ else:
331
+ self.swatch.props(remove="aria-disabled")
332
+ self.swatch_tip.set_text(_SWATCH_TIPS["open" if is_open else on])
333
+
334
+ def _toggle(self) -> None:
335
+ card = self.card
336
+ if card.is_open(self.path):
337
+ return # the open criterion always draws: it is the editing surface
338
+ card.choices[self.path] = not drawn(
339
+ self.leaf, card.choices.get(self.path), False
340
+ )
341
+ # By the signature, which carries the hidden set: one send, and only
342
+ # of this card.
343
+ card.redraw_if_moved()
344
+
345
+
346
+ class PlotCard:
347
+ def __init__(
348
+ self,
349
+ page: TestPage,
350
+ index: int,
351
+ resolved: ResolvedPlot,
352
+ view: dict | None,
353
+ scope: Mapping[str, VariableText],
354
+ ) -> None:
355
+ """``view`` and ``scope`` are the page's ONE read of
356
+ each, handed to every card: a card that read them for itself cost
357
+ one read per plot on every rebuild (the Task 5-6 review)."""
358
+ self.page, self.index, self.resolved = page, index, resolved
359
+ self.plot: ui.plotly | None = None
360
+ # Created here, attached in `show()`: a panel hosted under this card
361
+ # subscribes before there is a plot to attach to.
362
+ self.hook = FigureHook(on_shape_moved=self.redraw)
363
+ #: The open editor's ⌖ buttons and its armed field (D149).
364
+ self.placing = Placing(self.hook, self._armed_moved)
365
+ self.rows: dict[str, CriterionRow] = {}
366
+ self.pills: dict[str, Pill] = {}
367
+ #: The criteria header's host: what attaches at VARIABLE, inline
368
+ #: (Q3 A: the plot's `+`), and the root's kind it was built under.
369
+ #: The card's shape does not hold the root's kind, so `paint`
370
+ #: rebuilds the host when it moves.
371
+ self.variable_host: InlineHost | None = None
372
+ self.root_kind: str | None = None
373
+ self.editor: InlineHost | None = None # the open criterion's, if it is here
374
+ #: The open editor's own column in its row's slot: the slot also
375
+ #: holds the row host's error labels, so closing takes only this.
376
+ self.editor_box: ui.column | None = None
377
+ self.hint: ui.label | None = None # the open editor's hint line
378
+ #: A refused drag's reason, directly under the plot (`_said`); None
379
+ #: on a card with no plot.
380
+ self.refusal: ui.label | None = None
381
+ self.armed_line: ui.label | None = None
382
+ self.open_path: str | None = None
383
+ self._drawn: str | None = None # the signature of what the plot shows
384
+ #: Whether this card has been asked to draw. A plot that raised is
385
+ #: retried on the next sync, and only on a card somebody wanted.
386
+ self.wanted = False
387
+ #: The text of the error the last try raised, or None. The retry is
388
+ #: free; the traceback is not — a card that cannot draw is tried
389
+ #: again by every sync, a rescan's progress ticks included, and 50
390
+ #: of those over two failing cards logged 100 tracebacks for one
391
+ #: broken result (the final review). A new text is a new line.
392
+ self._error: str | None = None
393
+ #: Stamped on every figure this card sends; see `_figure`.
394
+ self._revision = 0
395
+ #: The leaf the open editor was built from (`_EDITED`).
396
+ self._edited: tuple | None = None
397
+ #: Legend b: the swatches' choices by leaf path (a leaf with none
398
+ #: follows `drawn`'s defaults), the one error series shown, and the
399
+ #: leaves the last figure drew anything for — only those get a
400
+ #: swatch. Set before the rows, which paint their swatch from them.
401
+ self.choices: dict[str, bool] = {}
402
+ self.error: str | None = None
403
+ self.drawable: frozenset[str] = frozenset()
404
+ self.error_select: ui.select | None = None
405
+ model = page.model_id
406
+ #: The plot's SUBJECT — one variable, or the members of one family —
407
+ #: whose criteria are its rows and whose first member the header's
408
+ #: `+` adds for. Its context is drawn and nothing more.
409
+ self.subject = list(resolved.subject)
410
+ #: A card of ONE variable has it in its title, so its pill is the
411
+ #: bare status; several pills side by side read "FAIL FAIL" and look
412
+ #: like the plot's verdict said twice (the board).
413
+ self.named_pills = len(resolved.subject) > 1
414
+ with (
415
+ ui.card()
416
+ .classes("dstf-card dstf-plot w-full")
417
+ .mark(f"plot:{index}") as card
418
+ ):
419
+ self.card = card
420
+ # A click anywhere on the card selects its plot — the toolbar,
421
+ # the title, a criteria row — except on the plot itself, which
422
+ # takes zooms and handle drags (3c, planner's choice 8). The guard is
423
+ # the browser's: the user simulation calls every click listener
424
+ # and ignores `js_handler`.
425
+ card.on(
426
+ "click",
427
+ partial(page.select, index),
428
+ js_handler=(
429
+ "(e) => { if (!e.target.closest('.dstf-plot-slot')) emit(); }"
430
+ ),
431
+ )
432
+ # The card's header row WRAPS: the title with its pills, and the
433
+ # toolbar as one unit, right-aligned on the title's line when it
434
+ # fits and on a line of its own when not. A row that neither
435
+ # wrapped nor shrank put Remove plot at 1140-1202 px of a 900 px
436
+ # window beside a drawer, on a real library's long names.
437
+ with ui.row().classes("dstf-card-head items-center gap-3 w-full"):
438
+ # The title READS the plain labels (the expressions the
439
+ # model wrote); the names it is keyed by ride beside it,
440
+ # muted, and only when one of them differs — a card for
441
+ # `x` would otherwise say `x x`. The muted name is what
442
+ # tells two series sharing one expression apart HERE, so
443
+ # the title does not also carry it in parentheses.
444
+ labels = {n: scope[n].label if n in scope else n for n in self.subject}
445
+ #: What each of this card's series READS as everywhere the
446
+ #: name is NOT beside it — its pill, the focus bar, its
447
+ #: "Add a criterion for x" `+`, and the context row, which
448
+ #: is why the context is in it too. Disambiguated over the
449
+ #: whole TEST, so all of those agree.
450
+ names = (*self.subject, *resolved.context)
451
+ self.texts = {n: scope[n].text if n in scope else n for n in names}
452
+ # Array families fold, as the report's plot titles do
453
+ # (`report_suite`): the fold over the texts, so two series
454
+ # whose labels collide are no family; each family READS its
455
+ # labels' slice (`pipe.T[1:3]`), and the muted line folds
456
+ # the names the same way. The title is the SUBJECT's: the
457
+ # context is listed under it.
458
+ series = self.subject
459
+ with ui.row().classes("dstf-card-title items-center gap-3"):
460
+ MiddleCut(folded(series, labels, self.texts)).classes(
461
+ "text-subtitle2"
462
+ ).mark(f"plot-title:{index}")
463
+ if any(labels[n] != n for n in series):
464
+ ui.label(folded(series)).classes("dstf-varname").mark(
465
+ f"plot-name:{index}"
466
+ )
467
+ for variable in self.subject:
468
+ if self._leaves(view, variable):
469
+ pill = Pill("queued", f"plot-pill:{index}:{variable}")
470
+ if self.named_pills:
471
+ # The name keeps its own case: the pill's
472
+ # text is uppercased by the stylesheet, and
473
+ # `X FAIL` names no variable of this test.
474
+ pill.label.classes(add="dstf-pill-named")
475
+ self.pills[variable] = pill
476
+ # The plot's whole toolbar (decision 9): the legend toggles
477
+ # the signals and the overlays; the criteria are toggled
478
+ # from their rows (legend b) and the errors from this select,
479
+ # built once the plot is drawn.
480
+ with ui.row().classes("dstf-card-tools items-center no-wrap gap-3"):
481
+ self.error_slot = ui.row().classes("items-center gap-0")
482
+ self.focus = ui.button("Focus", on_click=partial(page.focus, index))
483
+ self.focus.props("flat dense no-caps").mark(f"focus:{index}")
484
+ self._remove_button(index)
485
+ if resolved.missing:
486
+ ui.label(f"Not in this result: {', '.join(resolved.missing)}").classes(
487
+ "dstf-why"
488
+ ).mark(f"plot-missing:{index}")
489
+ if resolved.context:
490
+ self._context_row(index)
491
+ # A subject the result lacks entirely has no plot: an empty
492
+ # chart said nothing the note does not, and no slot is nothing
493
+ # for the browser's observer to ask to draw.
494
+ self.slot: ui.element | None = None
495
+ if not set(resolved.subject) <= set(resolved.missing):
496
+ self.slot = ui.element("div").classes("dstf-plot-slot w-full")
497
+ self.slot.props(f'data-plot="{index}"').mark(f"plot-slot:{index}")
498
+ # Between the plot and its criteria rows: the editor that
499
+ # says a refused drag's reason can be a screen below the
500
+ # plot the hand is on (measured at y = 1020 of a 900 px
501
+ # window, the plot at 350-670).
502
+ self.refusal = ui.label("").classes(
503
+ "dstf-plot-refusal text-negative text-xs"
504
+ )
505
+ self.refusal.mark(f"plot-refusal:{index}").set_visibility(False)
506
+ # And what an ARMED field's next click does (D149), where
507
+ # the hand is: the ⌖ itself can be a screen below the plot.
508
+ self.armed_line = ui.label("").classes("dstf-plot-armed text-xs")
509
+ self.armed_line.mark(f"plot-armed:{index}").set_visibility(False)
510
+ self._criteria_header(index, model, scope, view)
511
+ criteria = ui.column().classes("dstf-criteria w-full gap-0")
512
+ takers = parent_kinds(view)
513
+ with criteria.mark(f"criteria:{index}"):
514
+ # TREE order, never the subject's member order: a family's
515
+ # rows are where the tree has them, each with its crumb —
516
+ # the report's walk (`criteria_rows`), so both tiers lead a
517
+ # row the same way.
518
+ rows = criteria_rows(view, self.subject)
519
+ crumb_ch = max((len(row["crumbs"]) for row in rows), default=0)
520
+ for row in rows:
521
+ path = str(row["leaf"]["path"])
522
+ self.rows[path] = CriterionRow(
523
+ self, row["leaf"], takers.get(path), row["crumbs"], crumb_ch
524
+ )
525
+ #: A family's identical criteria, folded into one row each
526
+ #: (D152): painted with the rows, in `paint`.
527
+ self.folding = Folding(self, criteria, crumb_ch)
528
+ if resolved.watch:
529
+ ui.label("No criteria yet.").classes("dstf-why").mark(
530
+ f"plot-empty:{index}"
531
+ )
532
+ self.paint(view)
533
+
534
+ def _criteria_header(
535
+ self,
536
+ index: int,
537
+ model: str,
538
+ scope: Mapping[str, VariableText],
539
+ view: dict | None,
540
+ ) -> None:
541
+ """A muted "Criteria" and, at its right, whatever attaches at
542
+ VARIABLE — inline, for the FIRST member of the plot's subject, its forms
543
+ opening as a row under the header (Q3 A; the dialog and the footer
544
+ per variable are gone: one per line pushed the next plot off the
545
+ screen, and a form's select offers the test's other variables)."""
546
+ head = (
547
+ ui.row()
548
+ .classes("dstf-criteria-head items-center gap-3 w-full")
549
+ .mark(f"criteria-header:{index}")
550
+ )
551
+ with head:
552
+ ui.label("Criteria").classes("text-secondary text-xs")
553
+ target = RowTarget(self.page, f"plot:{index}", head, TREE_ROOT, index)
554
+ self.variable_host = InlineHost(
555
+ self.page.shell,
556
+ Placement.VARIABLE,
557
+ partial(
558
+ Selection,
559
+ tests=frozenset({model}),
560
+ variable=self.subject[0],
561
+ # The page's one read of the scope, so the `+` reads
562
+ # this variable's text without building it per card
563
+ # (`Selection.texts`).
564
+ texts=scope,
565
+ ),
566
+ figure=self.hook,
567
+ marker=f"variable:{index}",
568
+ row=target,
569
+ apply=self.page.apply,
570
+ )
571
+ # Its own width, pushed to the header's right end (style.py).
572
+ self.variable_host.column.classes(remove="w-full", add="dstf-node-host")
573
+ # The form row's slot, between the header and the criteria rows; the
574
+ # host is built once its panels have somewhere to open a row.
575
+ target.slot = ui.column().classes("dstf-criteria-slot w-full gap-0")
576
+ self.root_kind = _root_kind(view)
577
+ self.variable_host.build()
578
+
579
+ def _remove_button(self, index: int) -> None:
580
+ """Remove plot, on a WATCH card only: a derived plot exists because
581
+ of its criteria and goes when the last of them goes (Q16) — in its
582
+ place an icon says so."""
583
+ if not self.resolved.watch:
584
+ with (
585
+ ui.icon("info_outline", size="xs")
586
+ .classes("text-secondary")
587
+ .mark(f"plot-derived:{index}")
588
+ ):
589
+ ui.tooltip("This plot exists because of its criteria.")
590
+ return
591
+ # Its click stops in the BROWSER: bubbling to the card selected it,
592
+ # and after the rebuild the ring sat on whichever plot moved into
593
+ # the removed one's place.
594
+ ui.button("Remove plot").props("flat dense no-caps").mark(
595
+ f"remove-plot:{index}"
596
+ ).on(
597
+ "click",
598
+ partial(self.page.remove_watch, index, self.subject[0]),
599
+ js_handler="(e) => { e.stopPropagation(); emit(); }",
600
+ )
601
+
602
+ def _context_row(self, index: int) -> None:
603
+ """The context series, each with a `×` that takes it off this plot
604
+ — one op. Drawn grey on the plot; listed here because Plotly's
605
+ legend cannot hold a button. Read as the test's texts, like the
606
+ title."""
607
+ with (
608
+ ui.row()
609
+ .classes("dstf-context items-center gap-2 w-full")
610
+ .mark(f"context:{index}")
611
+ ):
612
+ ui.label("Context").classes("text-secondary text-xs")
613
+ for name in self.resolved.context:
614
+ text = self.texts.get(name, name)
615
+ with ui.row().classes("dstf-context-chip items-center no-wrap gap-0"):
616
+ ui.label(text).classes("dstf-mono text-xs").mark(
617
+ f"context-name:{index}:{name}"
618
+ )
619
+ button = (
620
+ ui.button(icon="close")
621
+ .props("flat dense round size=xs")
622
+ .mark(f"context-remove:{index}:{name}")
623
+ .on(
624
+ "click",
625
+ partial(self.page.remove_context, index, name),
626
+ js_handler="(e) => { e.stopPropagation(); emit(); }",
627
+ )
628
+ )
629
+ # On the props dict: a name can hold a `"`.
630
+ button.props["aria-label"] = f"Remove {text} from this plot"
631
+
632
+ @staticmethod
633
+ def _leaves(view: dict | None, variable: str) -> list[dict]:
634
+ return [
635
+ leaf
636
+ for leaf in leaves_of(view)
637
+ if leaf.get("variable") == variable and names_a_series(leaf)
638
+ ]
639
+
640
+ def _view(self, view: Any = UNREAD) -> dict | None:
641
+ """The page's read of this sync, or one of this card's own when it
642
+ was not given one."""
643
+ if view is UNREAD:
644
+ return self.page.shell.session.tree_view(self.page.model_id)
645
+ return view
646
+
647
+ def _edited_now(self, view: dict | None) -> tuple | None:
648
+ """The leaf under the open editor, as ``view`` shows it — what the
649
+ editor's fields say. ``None`` when no editor is open, or when the
650
+ leaf it was opened on is no longer in the tree (a shape change,
651
+ which rebuilds the cards anyway)."""
652
+ if self.open_path is None:
653
+ return None
654
+ leaf = next(
655
+ (n for n in leaves_of(view) if str(n["path"]) == self.open_path), None
656
+ )
657
+ if leaf is None:
658
+ return None
659
+ return tuple(
660
+ json.dumps(leaf.get(key), sort_keys=True, default=str) for key in _EDITED
661
+ )
662
+
663
+ def paint(self, view: dict | None) -> None:
664
+ """Pills, rows and the criteria header in place, from the page's one `tree_view`
665
+ read: a verdict that flips must not cost the card it sits in."""
666
+ for variable, pill in self.pills.items():
667
+ status = worst([leaf_status(leaf) for leaf in self._leaves(view, variable)])
668
+ # The pill READS the label, like the title above it: `x[1] FAIL`
669
+ # on a TRANSFORM card named nothing a person recognises.
670
+ text = (
671
+ f"{self.texts.get(variable, variable)} {status.upper()}"
672
+ if self.named_pills
673
+ else None
674
+ )
675
+ pill.set(status, text)
676
+ self._paint_rows(view)
677
+ self.folding.paint(view)
678
+ takers = parent_kinds(view)
679
+ for path, row in self.rows.items():
680
+ if takers.get(path) != row.taker:
681
+ # The row stands, but its parent changed kind — `+` grey
682
+ # under a warn or a weighted — and the controls read it
683
+ # when they are built (the Task 6 review): an edit from
684
+ # weighted to and left `+` grey with the weighted reason.
685
+ row.taker = takers.get(path)
686
+ row.host.build()
687
+ else:
688
+ # What its node's controls MAY do moves too, without the
689
+ # shape moving: a run of the test starts or ends, and a
690
+ # flat entry's criteria are edited one by one only while
691
+ # they are scored (D150).
692
+ row.host.follow()
693
+ if self.editor is not None:
694
+ edited = self._edited_now(view)
695
+ if edited != self._edited:
696
+ # The leaf under the open editor moved by an edit staged
697
+ # ELSEWHERE — a Discard, a Save, another node's controls.
698
+ # The fields kept showing the value that edit undid, and the
699
+ # next keystroke staged it straight back (the Task 6 review).
700
+ self.editor.build()
701
+ self._said(None) # the rebuild took the editor's own line
702
+ self._say_hint()
703
+ self._edited = edited
704
+ else:
705
+ # The editor is not rebuilt while it is open, so an answer
706
+ # that moves under it has to reach its panels here — and
707
+ # its hint, which says what the plot takes: a run that
708
+ # starts takes the handles and the table with it (D151).
709
+ self.editor.follow()
710
+ self._say_hint()
711
+ if self.variable_host is not None:
712
+ kind = _root_kind(view)
713
+ if kind != self.root_kind:
714
+ # The `+` reads the root's kind when it is built — grey
715
+ # under a warn or a weighted root — and the card's shape
716
+ # does not hold it, so the card stands and the host is
717
+ # rebuilt.
718
+ self.root_kind = kind
719
+ self.variable_host.build()
720
+ else:
721
+ # What it MAY do moves too (a run starts or ends).
722
+ self.variable_host.follow()
723
+
724
+ def _paint_rows(self, view: dict | None) -> None:
725
+ for leaf in leaves_of(view):
726
+ row = self.rows.get(str(leaf["path"]))
727
+ if row is not None:
728
+ row.paint(leaf)
729
+
730
+ # -- the plot ------------------------------------------------------------------
731
+
732
+ def _leaves_all(self, view: dict | None) -> list[dict]:
733
+ """Every leaf on this plot's SUBJECT, in tree order: its rows, what
734
+ it draws, what its signature signs. Not its context — a context
735
+ variable scored on another plot draws nothing of its criteria here —
736
+ and no stored-scalar leaf, whose ``variable`` names the leaf, not a
737
+ series (`names_a_series`)."""
738
+ return [row["leaf"] for row in criteria_rows(view, self.resolved.subject)]
739
+
740
+ def is_open(self, path: str) -> bool:
741
+ """Whether ``path`` is the open criterion — the page's word, the ONE
742
+ source the rings read too. Not `open_path`: that is set after
743
+ `show()` draws, so the first open of a hidden criterion on an
744
+ undrawn card sent it hidden and then, at once, drawn."""
745
+ return path in self.page.editing_for(self.index)
746
+
747
+ def visibility(self, view: Any = UNREAD) -> Visibility:
748
+ """What this card chose to show (legend b): the leaves `drawn` says
749
+ are off, and the one error series."""
750
+ hidden = frozenset(
751
+ str(leaf["path"])
752
+ for leaf in self._leaves_all(self._view(view))
753
+ if not drawn(
754
+ leaf,
755
+ self.choices.get(str(leaf["path"])),
756
+ self.is_open(str(leaf["path"])),
757
+ )
758
+ )
759
+ return Visibility(hidden=hidden, error=self.error)
760
+
761
+ def _drawable(self, figure: dict, view: dict | None) -> frozenset[str]:
762
+ """The leaves this figure draws ANYTHING for: a trace with the path
763
+ in ``meta.leaf``, or a shape named under it. A windowed nrmse draws
764
+ one shape and no trace, and its swatch must still show. A shape is
765
+ matched on its path plus ``/``, so ``/metrics/children/1`` never
766
+ claims a shape of ``/metrics/children/10``."""
767
+ paths = [str(leaf["path"]) for leaf in self._leaves_all(view)]
768
+ found = {(tr.get("meta") or {}).get("leaf") for tr in figure["data"]}
769
+ for shape in figure["layout"].get("shapes", []):
770
+ name = str(shape.get("name", ""))
771
+ hit = next((p for p in paths if name.startswith(p + "/")), None)
772
+ if hit is not None:
773
+ found.add(hit)
774
+ return frozenset(str(p) for p in found if p is not None)
775
+
776
+ def _figure(self, view: Any = UNREAD) -> dict:
777
+ """The Session's figure with this card's own `editrevision` on it.
778
+ Plotly's `editrevision` DEFAULTS to `uirevision`, which the tool's
779
+ figure pins to "keep" so a zoom survives a redraw — and with it
780
+ pinned, `Plotly.react` keeps a shape the user dragged whenever the
781
+ value the server sends equals the one it sent before, which is
782
+ exactly the snap-back after a refused drag (measured in Chromium).
783
+ An incrementing revision says the shapes are new. The layout is
784
+ COPIED: a Session figure is deterministic, and nothing here writes
785
+ into what it was handed."""
786
+ view = self._view(view)
787
+ figure = self.page.shell.session.plot_figure(
788
+ self.page.model_id,
789
+ self.index,
790
+ editing=self.page.editing_for(self.index),
791
+ visibility=self.visibility(view),
792
+ theme=self.page.theme.name,
793
+ )
794
+ self.drawable = self._drawable(figure, view)
795
+ self._revision += 1
796
+ return {
797
+ **figure,
798
+ "layout": {**figure["layout"], "editrevision": self._revision},
799
+ }
800
+
801
+ def show(self, view: Any = UNREAD) -> None:
802
+ """Draw the plot, once: the browser said this slot is near the
803
+ viewport (or a test did). The card remembers it was asked, so a try
804
+ that raised is retried by the next sync."""
805
+ self.wanted = True
806
+ if self.plot is not None or self.slot is None:
807
+ return
808
+ view = self._view(view)
809
+ # Whatever a previous try left: `show()` is called again on every
810
+ # opening row, and a slot that kept each failure's label stacked
811
+ # them — with the recovered plot drawn UNDER them.
812
+ self.slot.clear()
813
+ with self.slot:
814
+ try:
815
+ figure = self._figure(view)
816
+ except Exception as e: # a plot that cannot draw says so in place
817
+ text = f"{type(e).__name__}: {e}"
818
+ if text != self._error: # the same failure is not news
819
+ logger.exception("plot %s of %s", self.index, self.page.model_id)
820
+ self._error = text
821
+ ui.label(text).classes("text-negative")
822
+ return
823
+ self._error = None
824
+ self.plot = ui.plotly(figure).classes("w-full").style("height: 320px")
825
+ # A relayout passes through this plot's instance of the hover
826
+ # module on its way here: a ring, an arrow or a bound line released
827
+ # outside the plot area is held to the visible range, and says no
828
+ # position on an axis the pointer barely moved along (`grips.js`
829
+ # `released`).
830
+ self.hook.attach(
831
+ self.plot,
832
+ relayout_js=(
833
+ f"window.DstfGrips ? DstfGrips.released({self.plot.id}, e) : e"
834
+ ),
835
+ )
836
+ # This plot's instance of the hover module: the open criterion's
837
+ # grips show while the pointer is near them. Sent to THIS page's
838
+ # client — `show()` also runs from the Shell's timer.
839
+ self.page.client.run_javascript(f"DstfGrips.install({self.plot.id})")
840
+ self._drawn = self.signature(view)
841
+ self._error_select(figure)
842
+ # `drawable` is first known here: the swatches show with the plot.
843
+ self._paint_rows(view)
844
+
845
+ def _error_select(self, figure: dict) -> None:
846
+ """One select for the error series (legend b), on a plot whose
847
+ figure has any; a family's plot has none. Asked of every
848
+ figure sent, so a redraw that gains error traces gains the select."""
849
+ ids = [
850
+ tr["meta"]["id"]
851
+ for tr in figure["data"]
852
+ if (tr.get("meta") or {}).get("kind") == "error"
853
+ ]
854
+ if not ids or self.error_select is not None:
855
+ return
856
+ with self.error_slot:
857
+ self.error_select = (
858
+ ui.select(
859
+ {"none": "No error", **{i: _LABELS[i] for i in ids}},
860
+ value="none",
861
+ on_change=lambda e: self._choose_error(e.value),
862
+ )
863
+ .props("dense options-dense")
864
+ .mark(f"error-select:{self.index}")
865
+ )
866
+
867
+ def _choose_error(self, value: str) -> None:
868
+ self.error = None if value == "none" else value
869
+ # By the signature, which carries the error: one send.
870
+ self.redraw_if_moved()
871
+
872
+ def _retried(self, view: Any) -> bool:
873
+ """A card with no plot: retry the draw if somebody asked for one.
874
+ Nothing else asked again — a result file being rewritten under a
875
+ `show()` left the card showing its error until a row opened or
876
+ Focus landed on it (the 3b review)."""
877
+ if self.plot is not None:
878
+ return False
879
+ if self.wanted:
880
+ self.show(view)
881
+ return True
882
+
883
+ def redraw(self, view: Any = UNREAD) -> None:
884
+ """`Plotly.react` on the same node — the figure's `config` never
885
+ depends on what is being edited, so the zoom survives. Also the
886
+ hook's snap-back after every drag, which is the ONE redraw that is
887
+ not decided by the signature: a refused drag moved nothing, and the
888
+ picture still has to go back. ``view`` is the page's read of this
889
+ sync, so a forced redraw costs no read of its own."""
890
+ if self._retried(view):
891
+ return
892
+ assert self.plot is not None
893
+ view = self._view(view)
894
+ figure = self._figure(view)
895
+ self.plot.update_figure(figure)
896
+ self._drawn = self.signature(view)
897
+ self._error_select(figure)
898
+ # The swatches say what was just sent (an open or a close moves one).
899
+ self._paint_rows(view)
900
+
901
+ def signature(self, view: Any = UNREAD) -> str:
902
+ """What this plot draws that an edit can move: which criterion is
903
+ open — its rings, and only its — every leaf on this plot's subject,
904
+ which leaves are hidden and which error series shows. Comparing it
905
+ to what was last sent is the ONE thing that decides a redraw."""
906
+ view = self._view(view)
907
+ leaves = [
908
+ [leaf.get("path"), *(leaf.get(key) for key in _SIGNED)]
909
+ for leaf in self._leaves_all(view)
910
+ ]
911
+ return json.dumps(
912
+ [
913
+ list(self.page.editing_for(self.index)),
914
+ leaves,
915
+ sorted(self.visibility(view).hidden),
916
+ self.error,
917
+ ],
918
+ sort_keys=True,
919
+ default=str,
920
+ )
921
+
922
+ def redraw_if_moved(self, view: Any = UNREAD) -> None:
923
+ """Re-send the figure when what it draws has moved, and only then: a
924
+ plot nobody's edit touched must not be re-sent (the cost the 3b
925
+ review measured is elements on the wire, not a figure assembled)."""
926
+ if self._retried(view):
927
+ return
928
+ if self.signature(view) != self._drawn:
929
+ self.redraw(view)
930
+
931
+ # -- the one open criterion's editor -------------------------------------------
932
+
933
+ def open_editor(self, path: str, view: Any = UNREAD) -> None:
934
+ """The editor, below its row and at the plot's width (board 2A):
935
+ one hint line and Done, then every capability placed at CRITERION,
936
+ stacked without headings — the row above already names the
937
+ criterion, so the editor reads as one thing (brief §3.2). A folded
938
+ row's editor is its own (`family_fold.FoldRow.open_editor`)."""
939
+ view = self._view(view)
940
+ if self.folding.opens(path, view):
941
+ return
942
+ row, index = self.rows[path], self.index
943
+ model, variable = self.page.model_id, row.variable
944
+ self.open_path = path
945
+ row.row.classes(add="dstf-crit-open")
946
+ with row.slot:
947
+ self.editor_box = ui.column().classes("dstf-editor w-full gap-2")
948
+ with self.editor_box:
949
+ with ui.row().classes("items-center no-wrap gap-3 w-full"):
950
+ self.hint = (
951
+ ui.label("").classes("dstf-why").mark(f"editor-hint:{index}:{path}")
952
+ )
953
+ ui.space()
954
+ ui.button("Done", on_click=self.page.close_editor).props(
955
+ "flat dense no-caps"
956
+ ).mark(f"editor-done:{index}:{path}")
957
+ self.editor = InlineHost(
958
+ self.page.shell,
959
+ Placement.CRITERION,
960
+ partial(
961
+ Selection,
962
+ tests=frozenset({model}),
963
+ variable=variable,
964
+ leaf_path=path,
965
+ ),
966
+ figure=self.hook,
967
+ marker=f"editor:{index}:{path}",
968
+ on_applied=self._applied,
969
+ apply=self.page.apply,
970
+ # What the editor's panels show that the spec does not hold
971
+ # outlives them: a drag rebuilds the whole editor.
972
+ kept=partial(self.page.kept, path),
973
+ on_said=self._said,
974
+ placing=self.placing,
975
+ )
976
+ self.editor.build()
977
+ view = self._view(view)
978
+ self._say_hint()
979
+ self._edited = self._edited_now(view)
980
+
981
+ def _scored(self, path: str | None = None) -> Any:
982
+ """The criterion at ``path`` (the open one, unless said) as it was
983
+ scored — its comparison, whose geometry the plot draws — or None
984
+ where it was not: a test that is running, or whose last run did
985
+ not finish (D151)."""
986
+ path = self.open_path if path is None else path
987
+ comp = self.page.shell.session.comparisons.get(self.page.model_id)
988
+ tree = comp.metric_tree if comp is not None else None
989
+ for pairs in leaves_by_variable(tree).values():
990
+ for at, vc in pairs:
991
+ if at == path:
992
+ return vc
993
+ return None
994
+
995
+ def _drags(self) -> Drags:
996
+ """What the plot draws for the open criterion to be dragged by —
997
+ a key of `EDITOR_HINTS` — read off the geometry its leaf was scored
998
+ with, as the figure reads it: a range has lines and no ring, a
999
+ constant tube arrows and no ring, nrmse nothing, and none is known
1000
+ here by its name. Nothing where the
1001
+ leaf was not scored, or where its metric has no editor for a drag
1002
+ (one would be refused)."""
1003
+ vc = self._scored()
1004
+ if vc is None:
1005
+ return ""
1006
+ desc = registry.metric(vc.mode)
1007
+ if desc is None or desc.edit_handle is None:
1008
+ return ""
1009
+ return drags_of(vc.geometry)
1010
+
1011
+ def _say_hint(self) -> None:
1012
+ """The editor's hint line: what drags on the plot for the open
1013
+ criterion. The arrows show only while the pointer is near their
1014
+ point — or, where they stand around no point, near themselves — so
1015
+ the line is what tells a person they exist; a criterion with rings
1016
+ and no arrow is told to drag a ring, one with lines alone to drag a
1017
+ line, and one that draws nothing to drag (nrmse) only to edit its
1018
+ fields — its Edit on plot place says why. Said again after an edit:
1019
+ the mode is a field of the editor."""
1020
+ if self.hint is None or self.hint.is_deleted:
1021
+ return
1022
+ if is_fold(self.open_path or ""):
1023
+ # A folded row's editor is this card's too, and its line is
1024
+ # its own (`family_fold.HINT`): fields only, nothing to drag.
1025
+ return
1026
+ armed = self.placing.armed
1027
+ if armed is not None:
1028
+ # While a field is armed the line says the one thing a click
1029
+ # on the plot now does.
1030
+ self.hint.set_text(ARMED.format(armed.label))
1031
+ return
1032
+ self.hint.set_text(EDITOR_HINTS[self._drags()] + self._adds() + STAGED)
1033
+
1034
+ def _row_noun(self, path: str) -> str | None:
1035
+ """What a Ctrl+click adds to the criterion at ``path`` — its
1036
+ metric's own word for a row — or None where a click adds none: a
1037
+ metric with no rows, a tube of constant width, a metric no drag
1038
+ edits (its Edit on plot place is not drawn), and a criterion that
1039
+ was not scored: the click is read off its scored geometry, and its
1040
+ place says why it takes none."""
1041
+ if self._scored(path) is None:
1042
+ return None
1043
+ raw = self.page._node(path)
1044
+ desc = registry.metric(str((raw or {}).get("metric", registry.DEFAULT_METRIC)))
1045
+ if raw is None or desc is None or desc.place_row is None:
1046
+ return None
1047
+ return desc.row_noun(raw) if desc.row_noun is not None else None
1048
+
1049
+ def _adds(self) -> str:
1050
+ """What a Ctrl+click adds to the open criterion, as the hint's
1051
+ sentence (`_row_noun`), or nothing."""
1052
+ noun = self._row_noun(self.open_path or "")
1053
+ return ADDS.format(noun) if noun else ""
1054
+
1055
+ def _armed_moved(self) -> None:
1056
+ """The armed field changed (`Placing`): the editor's hint says it,
1057
+ and so does the plot's own card — the ⌖ and the hint can be a
1058
+ screen below the plot the click is made on. The card carries an
1059
+ ATTRIBUTE the stylesheet reads (an accent outline about the plot),
1060
+ and the line under the plot says what the next click sets."""
1061
+ self._say_hint()
1062
+ armed = self.placing.armed
1063
+ if self.card.is_deleted:
1064
+ return
1065
+ if armed is not None:
1066
+ self.card.props("data-armed")
1067
+ else:
1068
+ self.card.props(remove="data-armed")
1069
+ if self.armed_line is not None and not self.armed_line.is_deleted:
1070
+ self.armed_line.set_text("" if armed is None else ARMED.format(armed.label))
1071
+ self.armed_line.set_visibility(armed is not None)
1072
+
1073
+ def clicked(self, click: Any) -> None:
1074
+ """A click on this card's plot, as the page reports it (`grips.js`,
1075
+ D149): Ctrl+click adds a row to the open criterion, and a plain one
1076
+ sets its armed field. Nothing with no editor open here. A FOLDED
1077
+ row's editor is fields only (D152) and takes no click: a Ctrl+click
1078
+ that would add a row to any one of its members says, under the
1079
+ plot, where that gesture is."""
1080
+ if self.plot is None or self.editor is None or not isinstance(click, dict):
1081
+ return
1082
+ if not is_fold(self.open_path or ""):
1083
+ self.placing.clicked(click)
1084
+ return
1085
+ noun = self._row_noun(leaf_of(self.open_path or ""))
1086
+ if noun and (click.get("ctrl") or click.get("meta")) and self.refusal:
1087
+ self.refusal.set_text(FOLD_ADDS.format(noun))
1088
+ self.refusal.set_visibility(True)
1089
+
1090
+ def fold_unfolded(self) -> None:
1091
+ """The folded row whose editor is open here was unfolded
1092
+ (`Folding.toggle`): the line that said to unfold it (`clicked`)
1093
+ goes — under a fold's editor the line says nothing else, and it
1094
+ stayed for as long as that editor did."""
1095
+ if self.refusal is not None and not self.refusal.is_deleted:
1096
+ self.refusal.set_visibility(False)
1097
+
1098
+ def moved(self, move: Any) -> None:
1099
+ """A handle of this card's plot is HELD (`grips.js`, D149): its
1100
+ would-be position, for the editor's fields to follow."""
1101
+ if self.plot is not None:
1102
+ self.hook.move(move)
1103
+
1104
+ def _said(self, text: str | None) -> None:
1105
+ """What the open editor's error line just said (`InlineHost`'s
1106
+ ``on_said``): a refused DRAG's reason is shown under the plot too,
1107
+ in the editor's own words; an act that went through — a drag, a
1108
+ field — takes it away. A refused field edit is said where the
1109
+ typing is, and leaves this line as it was."""
1110
+ if self.refusal is None or self.refusal.is_deleted:
1111
+ return
1112
+ if text is None:
1113
+ self.refusal.set_visibility(False)
1114
+ elif self.hook.dispatching:
1115
+ self.refusal.set_text(text)
1116
+ self.refusal.set_visibility(True)
1117
+
1118
+ def close_editor(self) -> None:
1119
+ """The editor goes, and its subscriptions with it: a panel nobody
1120
+ can see must not go on staging edits from drags. Only the editor's
1121
+ own column: the row's slot also holds its host's error labels, and
1122
+ a close that cleared the slot deleted them — a refused Wrap on that
1123
+ row then staged nothing and showed nothing (the review, in
1124
+ Chromium). A refused drag's line under the plot goes with it."""
1125
+ self._said(None)
1126
+ self.placing.forget()
1127
+ if self.editor is not None:
1128
+ self.editor.clear()
1129
+ if self.editor_box is not None and not self.editor_box.is_deleted:
1130
+ self.editor_box.delete()
1131
+ row = self.rows.get(self.open_path or "")
1132
+ if row is not None:
1133
+ row.row.classes(remove="dstf-crit-open")
1134
+ self.editor, self.editor_box, self.hint = None, None, None
1135
+ self.open_path, self._edited = None, None
1136
+ self.folding.released()
1137
+
1138
+ def _applied(self, name: str) -> None:
1139
+ """What a successful submit from the editor leaves to do. A drag
1140
+ rebuilds the WHOLE editor — no field is being typed during one, and
1141
+ the table has to follow the ring. A panel's own submit rebuilds
1142
+ every OTHER panel and leaves that one alone, so the control being
1143
+ typed in survives."""
1144
+ if self.editor is None:
1145
+ return
1146
+ if self.hook.dispatching:
1147
+ self.editor.build()
1148
+ else:
1149
+ self.editor.build(skip=name)
1150
+ # Recorded AFTER this apply and before its events reach the page,
1151
+ # from a FRESH tree view, so a panel's own edit is not read as one
1152
+ # staged elsewhere — and a first edit of an unmaterialized tree,
1153
+ # which stages the materialization AND the replace, is one edit
1154
+ # here rather than two.
1155
+ view = self._view()
1156
+ self._say_hint()
1157
+ self._edited = self._edited_now(view)
1158
+
1159
+ def dispose(self) -> None:
1160
+ """Give up this card's handlers before it is dropped: a rebuilt page
1161
+ must not leave a removed row's panel subscribed to the hook."""
1162
+ if self.editor is not None:
1163
+ self.editor.clear()
1164
+ for row in self.rows.values():
1165
+ row.host.clear()
1166
+ if self.variable_host is not None:
1167
+ self.variable_host.clear()
1168
+ self.hook.clear()
1169
+ if self.plot is not None:
1170
+ # Its hover instance listens on the document and the window.
1171
+ self.page.client.run_javascript(f"DstfGrips.destroy({self.plot.id})")