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,1404 @@
1
+ """The Test page (brief §3.2; roadmap §3): one test — its own header as a
2
+ second row of the shared one, its statistics and its criteria tree each
3
+ closed to a line, and its
4
+ plots stacked, each drawn when it scrolls into view with that variable's
5
+ criteria rows under it and, under the row that was clicked, that
6
+ criterion's editor — one at a time, page-wide, with the plot above it as
7
+ its editing surface. A node's forms open as a row under the row they were
8
+ asked from, and that row is the same one thing: it replaces the editor and
9
+ the editor replaces it. One object per browser tab, like the Suite page. The
10
+ page knows no capability by name: what edits a criterion, a tree node or a
11
+ variable is whatever attaches at that placement."""
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ from collections.abc import Callable, Sequence
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from nicegui import ui
21
+
22
+ from ...backends.pool import ItemState
23
+ from ...comparison.tree_spec import reads_a_statistic
24
+ from ...config import ConfigError
25
+ from ...discovery.patch_apply import PatchError
26
+ from ...discovery.plots import PlotExtra, ResolvedPlot
27
+ from ...reporting.status import (
28
+ SCORING_CLASS,
29
+ SCORING_FAILED,
30
+ classify,
31
+ classify_with_class,
32
+ refusal,
33
+ )
34
+ from ...reporting.tree_rows import leaves_of
35
+ from ...storage.ref_index import RefIndex
36
+ from ..capabilities import Outcome, plural
37
+ from ..errors import user_text
38
+ from ..family_rows import is_fold, leaf_of
39
+ from ..layout import layout_op, remove_context, remove_watch, reset_op
40
+ from ..queue_rows import queue_row
41
+ from ..situation import Situation, Strip, new_variables, strip_for
42
+ from ..spec_document import edit_counts
43
+ from ..tree_pointer import node_or_none
44
+ from .actions import ActionBar, RowBuild, RowTarget
45
+ from .family_fold import Folding
46
+ from .header import Header
47
+ from .live_view import LIVE_CSS, LiveView
48
+ from .plot_card import GRIPS_URL, UNREAD, PlotCard, folded
49
+ from .rail import Rail
50
+ from .stats_card import StatsCard
51
+ from .strip import StatusStrip
52
+ from .tab_theme import first_paint
53
+ from .tabstate import caught_up, connected_tab_state, heard_spec
54
+ from .tree_card import TreeCard, leaf_status
55
+
56
+ if TYPE_CHECKING:
57
+ from .shell import Shell
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+ #: Render on scroll (the 3b spike): ONE observer per browser tab, kept on
62
+ #: `window` so a re-run after a rebuild reuses it; a slot near the viewport
63
+ #: names itself once, and is stamped so a re-run does not observe it twice.
64
+ OBSERVE = """
65
+ (() => {
66
+ window.__dstfPlotObserver ??= new IntersectionObserver((entries) => {
67
+ for (const entry of entries) {
68
+ if (!entry.isIntersecting) continue;
69
+ window.__dstfPlotObserver.unobserve(entry.target);
70
+ emitEvent("dstf_plot_visible", entry.target.dataset.plot);
71
+ }
72
+ }, {rootMargin: "200px"});
73
+ for (const el of document.querySelectorAll(".dstf-plot-slot:not([data-seen])")) {
74
+ el.dataset.seen = "1";
75
+ window.__dstfPlotObserver.observe(el);
76
+ }
77
+ })();
78
+ """
79
+
80
+ #: The one sentence for both ways a page ends up with no test: built for a
81
+ #: model this project never had, or its project swapped under it. `_gone`
82
+ #: writes it, and a page built in that state reaches `_gone` through its
83
+ #: first `sync`.
84
+ NOT_A_TEST = "{} is not a test of this project"
85
+
86
+ #: `TestPage.open`'s index for a criterion edited in the Statistics card
87
+ #: (U3): the page's one editor is `(plot index, leaf path)`, or `(STATS,
88
+ #: leaf path)` — no plot is ever at index -1.
89
+ STATS = -1
90
+
91
+ #: `TestPage.open`'s index for a criterion edited under its row in the
92
+ #: criteria tree (U5, Y4): no plot is at -2 either, and it is not STATS —
93
+ #: the Statistics card's editor closes with that card, this one with the
94
+ #: tree's rows.
95
+ TREE = -2
96
+
97
+
98
+ def _raw(node: dict | None) -> str:
99
+ """A raw node as the form row records it, to tell whether it moved."""
100
+ return json.dumps(node, sort_keys=True, default=str)
101
+
102
+
103
+ class TestPage:
104
+ def __init__(self, shell: Shell) -> None:
105
+ self.shell = shell
106
+ self.client: Any = None
107
+ self.model_id = ""
108
+ self.badge: ui.badge | None = None
109
+ self.meta: ui.label | None = None
110
+ #: The status strip under the sub-header, and its TEST-placement bar.
111
+ self.strip: StatusStrip | None = None
112
+ self.actions: ActionBar | None = None
113
+ self.tree: TreeCard | None = None
114
+ self.stats: StatsCard | None = None
115
+ self.cards: list[PlotCard] = []
116
+ self.count: ui.label | None = None
117
+ self.column: ui.column | None = None
118
+ #: The page's body, which says whether its criteria have verdicts
119
+ #: (`LiveView.mark`), and the running test's live view (D151).
120
+ self.body: ui.column | None = None
121
+ self.live = LiveView(self)
122
+ self._shape: Any = None
123
+ #: Whether the page is showing "… is not a test of this project"
124
+ #: instead of a test (`_gone`).
125
+ self.gone = False
126
+ #: The one open editor: (plot index, leaf path), (STATS, leaf path)
127
+ #: for a statistics criterion's, in the Statistics card, or (TREE,
128
+ #: leaf path) for one opened under its tree row (U5).
129
+ self.open: tuple[int, str] | None = None
130
+ #: The one open form row: its target, its title, its build, its
131
+ #: column, and the node's raw JSON it was last built from.
132
+ self.form: tuple[RowTarget, str, RowBuild, ui.column, str] | None = None
133
+ #: The open form row's title, which its form may say again
134
+ #: (`retitle`).
135
+ self.form_title: ui.label | None = None
136
+ #: A criterion to open at the end of the next sync, and the plot
137
+ #: it was asked from (`reveal`).
138
+ self.pending_reveal: tuple[str, int | None] | None = None
139
+ #: A move's row to bring into view and the line it says, for the
140
+ #: end of the next sync (`moved`).
141
+ self.pending_moved: tuple[str, str] | None = None
142
+ #: The revision of the last move's own `spec` event
143
+ #: (`Session.spec_revision` right after it staged), or None: only
144
+ #: a `spec` event numbered past it is an edit AFTER the move, and
145
+ #: takes its line away (`handle`). A number, not a flag: an edit or
146
+ #: a second move queued in the same tick sends a `spec` event ahead
147
+ #: of the move's own, and either one, handled first, cleared the
148
+ #: flag — the move's own event then took its line away.
149
+ self.move_revision: int | None = None
150
+ self.focused: int | None = None # the one plot Focus is showing
151
+ self.focus_bar: ui.row | None = None
152
+ self.focus_text: ui.label | None = None
153
+ self.focus_all: ui.button | None = None
154
+ self.focus_switch: ui.select | None = None
155
+ #: The plot the Variables drawer's checkboxes act on (the owner's
156
+ #: ruling, Q5 A): the first until a click on a card, or the
157
+ #: drawer's "+ new plot", selects another. Its card wears
158
+ #: `dstf-plot-selected`.
159
+ self.selected = 0
160
+ #: The selected plot's subject, which a rebuild re-selects wherever
161
+ #: it moved; None when a gesture placed the selection by index.
162
+ self.follow: tuple[str, ...] | None = None
163
+
164
+ async def build(self, model_id: str) -> None:
165
+ self.client = ui.context.client
166
+ # BEFORE the handshake, so it is in the page's first HTML: after it
167
+ # `add_head_html` inserts through insertAdjacentHTML, which never
168
+ # runs a <script> (the Suite page's rule).
169
+ ui.add_head_html(f'<script src="{GRIPS_URL}"></script>')
170
+ ui.add_css(LIVE_CSS)
171
+ first_paint(self.shell)
172
+ state = await connected_tab_state(self.client)
173
+ if state is None:
174
+ return
175
+ self.state = state # the tab's, shared with its Suite page
176
+ caught_up(state, self.shell.session.spec_revision)
177
+ self.theme = self.shell.page_setup(state, self._rethemed)
178
+ self.model_id = model_id
179
+ ui.page_title(f"DSTF — {model_id.rsplit('.', 1)[-1]}")
180
+ # The frame is built whatever the Session holds at this moment: a
181
+ # page opened (or reloaded) while the project is still scanning, or
182
+ # with another library open, was built in the gone state with no
183
+ # frame to fill — `sync` returned early for ever and the test never
184
+ # came back however many events arrived (the final review). `sync`
185
+ # below fills it, or `_gone` writes the sentence into it.
186
+ self.header = Header(
187
+ self.shell, self.theme, below=self._subheader, strip=self._strip
188
+ )
189
+ self.rail = Rail(self)
190
+ with ui.column().classes("dstf-test w-full q-pa-md gap-3") as self.body:
191
+ # Above the tree (the plot-model board): what the run recorded,
192
+ # and the `+` that makes a statistic a criterion.
193
+ self.stats = StatsCard(self)
194
+ self.tree = TreeCard(self)
195
+ self.count = (
196
+ ui.label().classes("text-secondary text-xs").mark("plots:count")
197
+ )
198
+ self.focus_bar = (
199
+ ui.row().classes("dstf-focus-bar items-center gap-3 w-full")
200
+ ).mark("focus-bar")
201
+ with self.focus_bar:
202
+ self.focus_text = ui.label().mark("focus-bar:text")
203
+ # Every plot by its subject (Q11 c), plain text (C4):
204
+ # choosing one focuses it.
205
+ self.focus_switch = (
206
+ ui.select({}, on_change=lambda e: self.focus_to(e.value))
207
+ .props("dense options-dense")
208
+ .mark("focus-switch")
209
+ )
210
+ self.focus_switch.props["aria-label"] = "Plot"
211
+ ui.space()
212
+ self.focus_all = (
213
+ ui.button(on_click=self.show_all)
214
+ .props("flat dense no-caps")
215
+ .mark("focus-bar:all")
216
+ )
217
+ self.focus_bar.set_visibility(False)
218
+ self.column = ui.column().classes("dstf-plots w-full gap-3").mark("plots")
219
+ ui.on("dstf_plot_visible", lambda e: self.on_visible(e.args))
220
+ # The plots' own channel (`grips.js`, D149): a click that is no
221
+ # drag, a handle held, and Esc while a field is armed.
222
+ ui.on("dstf_plot_click", lambda e: self.on_plot("clicked", e.args))
223
+ ui.on("dstf_grip_move", lambda e: self.on_plot("moved", e.args))
224
+ ui.on("dstf_disarm", lambda _e: self.disarm())
225
+ self.shell.register(self)
226
+ self.sync()
227
+ self.rail.open(self.state.drawer)
228
+
229
+ def _known(self) -> bool:
230
+ """Whether this page's test is a test of the OPEN project. It can
231
+ stop being one while the page is open: an `OpenProject` outcome
232
+ swaps the library, `Session.close()` takes it away."""
233
+ project = self.shell.session.project
234
+ return project is not None and project.test(self.model_id) is not None
235
+
236
+ def _view(self) -> dict | None:
237
+ return self.shell.session.tree_view(self.model_id)
238
+
239
+ def _subheader(self) -> None:
240
+ """The test's own header, drawn inside the shared one (decision 8):
241
+ the way back, the test, its verdict and where it was scored. Its
242
+ TEST-placement buttons are the strip's, under it."""
243
+ # One line: beside a long model id and Queue's caption with an edit
244
+ # pending, "← Suite" broke after the arrow at 1280 px.
245
+ # The accent, in every theme (D148): a link's own blue is NiceGUI's.
246
+ ui.link("← Suite", "/").classes("text-no-wrap text-primary").mark("back")
247
+ ui.label(self.model_id).classes("dstf-model text-subtitle1 dstf-mono").mark(
248
+ "model"
249
+ )
250
+ # `color=None`: a Quasar badge otherwise carries `bg-primary`, whose
251
+ # `!important` beats the inline status fill and paints every verdict
252
+ # blue (the Task 4 review, in Chromium).
253
+ self.badge = ui.badge("", color=None).classes("dstf-badge").mark("status")
254
+ self.meta = ui.label().classes("text-secondary text-xs").mark("meta")
255
+
256
+ def _strip(self) -> None:
257
+ """The header's third row: the test's situation and its next
258
+ action (Q2 A)."""
259
+ self.strip = StatusStrip(self)
260
+ self.actions = self.strip.actions
261
+
262
+ def result(self) -> Any:
263
+ project = self.shell.session.project
264
+ return project.results.get(self.model_id) if project is not None else None
265
+
266
+ def run_state(self) -> str:
267
+ """The ONE run-state rule every line of the page words its reason
268
+ by: "none" — no result — "failed" when the result is a failed run,
269
+ else "ok". It reads the result the badge was scored from, so the
270
+ two cannot disagree: the reader hands in no result for a test the
271
+ last run never finished (its manifest entry still pending, D145),
272
+ and a failed one where status.json recorded that run failed — the
273
+ badge's SIM_FAIL, beside which the strip said "No result yet"
274
+ while it asked the manifest instead."""
275
+ result = self.result()
276
+ if result is None:
277
+ return "none"
278
+ return "ok" if result.success else "failed"
279
+
280
+ def plots_withheld(self) -> bool:
281
+ """O2's gate: a result that is not a success has no plots whatever
282
+ its tree and extras say, and a watch plot made on it would draw
283
+ nothing — a run that failed (`run_state`'s "failed"). A run that
284
+ never finished the test hands in no result (D145), and withholds
285
+ nothing: there is nothing to draw."""
286
+ result = self.result()
287
+ return result is not None and not result.success
288
+
289
+ def _state_reason(self) -> str | None:
290
+ """The clause a run state that is not "ok" gives every line, or
291
+ None."""
292
+ state = self.run_state()
293
+ if state == "failed":
294
+ return "the run failed"
295
+ if state == "none":
296
+ return "this test has no result"
297
+ return None
298
+
299
+ def unscored_reason(self) -> str:
300
+ """Why this test has no tree, as a clause the line it is read into
301
+ completes ("nothing scored: this test has no baseline"): its run
302
+ failed or it has no result (`run_state`, first), its scoring
303
+ raised, the comparison refused it (its reason, as written), it is
304
+ simulate-only — which a test with a stored baseline can be, once
305
+ its entry tracks nothing — or it has no baseline to be scored
306
+ against. A model whose
307
+ scoring raised keeps its result and has no comparison at all — the
308
+ Suite row says `scoring failed` for the same state — so "no
309
+ baseline" would name the wrong cause."""
310
+ reason = self._state_reason()
311
+ if reason is not None:
312
+ return reason
313
+ comp = self.shell.session.comparisons.get(self.model_id)
314
+ if comp is None:
315
+ return SCORING_FAILED
316
+ if comp.simulate_only:
317
+ return "this test is simulate-only"
318
+ # A test the comparison refused has a baseline, and a reason.
319
+ return refusal(comp) or "this test has no baseline"
320
+
321
+ def _no_plots_reason(self, from_tree: bool) -> str:
322
+ """Why there is no PLOT, which is not why there is no tree: a test
323
+ with no tree still draws one plot per tracked variable, so a page
324
+ with none has a failed run or no result (`run_state`, first: a
325
+ failed run has no plots whatever its tree says, O2), a result with
326
+ nothing in it, a result the test tracks nothing of (its patterns
327
+ match none of it, or it is simulate-only: the Variables drawer
328
+ lists what it holds, and "the result has no variables" beside
329
+ that list was false) — or a tree whose every criterion scores a
330
+ statistic (a stored-scalar leaf names no series, so it derives no
331
+ plot). ``from_tree`` is `Session.plots_from_tree`, never whether
332
+ there is a tree VIEW: a test with no baseline has none, and its
333
+ authored tree still names its plots."""
334
+ reason = self._state_reason()
335
+ if reason is not None:
336
+ return reason
337
+ if from_tree:
338
+ return "this test's criteria score statistics, not signals"
339
+ if self.shell.session.variable_names(self.model_id):
340
+ return "this test tracks none of the result's variables"
341
+ return "the result has no variables"
342
+
343
+ # -- the one open editor ---------------------------------------------------
344
+
345
+ def editing_for(self, index: int) -> tuple[str, ...]:
346
+ """The leaf paths whose rings plot ``index`` draws — the open
347
+ criterion's, and only on the card its row is on. A tuple, never a
348
+ bare string: `rings_for` refuses one. None for a folded row's
349
+ editor (D152): it is fields only, and its key is no leaf's path."""
350
+ if (
351
+ self.open is not None
352
+ and self.open[0] == index
353
+ and not is_fold(self.open[1])
354
+ ):
355
+ return (self.open[1],)
356
+ return ()
357
+
358
+ # -- the one open form row --------------------------------------------------
359
+
360
+ def _node(self, path: str) -> dict | None:
361
+ return node_or_none(self.shell.session.tree(self.model_id), path)
362
+
363
+ def open_row(self, target: RowTarget, title: str, build: RowBuild) -> None:
364
+ """A form row under ``target``'s row (the 2026-09-21 board, 1A-1D).
365
+ ONE thing open at a time: the editor goes, and any other row — and
366
+ the card whose rings went with the editor is re-sent, once."""
367
+ view = self._view()
368
+ self.drop_editor()
369
+ self._close_row()
370
+ self._unsay()
371
+ # Its own row's error goes as its form opens; any other row's
372
+ # stays with that row (`RowTarget.hush`).
373
+ target.hush()
374
+ self._redraw_moved(view)
375
+ self.unfold_to(target) # R2: a form under a tree row is shown
376
+ assert target.slot is not None # set by the row's owner at build
377
+ with target.slot:
378
+ column = (
379
+ ui.column()
380
+ .classes("dstf-form-row w-full gap-2")
381
+ .mark(f"form-row:{target.key}")
382
+ )
383
+ target.row.classes(add="dstf-row-open")
384
+ self.form = (target, title, build, column, _raw(self._node(target.node)))
385
+ self._build_row()
386
+ self._paint_tree_clicks()
387
+ # Just enough to show the whole row, like the tree line's jump; a
388
+ # row already in view does not move (the owner's ruling). Opened
389
+ # from a criteria row at the viewport's foot, it was drawn below
390
+ # the fold with nothing to say it had opened.
391
+ ui.run_javascript(
392
+ f'document.getElementById("{column.html_id}")'
393
+ '?.scrollIntoView({block: "nearest"})'
394
+ )
395
+
396
+ def _build_row(self) -> None:
397
+ assert self.form is not None
398
+ target, title, build, column, _ = self.form
399
+ column.clear()
400
+ with column:
401
+ self.form_title = (
402
+ ui.label(title)
403
+ .classes("text-subtitle2")
404
+ .mark(f"form-title:{target.key}")
405
+ )
406
+ build(self._close_row)
407
+
408
+ def retitle(self, target: RowTarget, title: str) -> None:
409
+ """`Panel.retitle`: the open form row under ``target`` reads
410
+ ``title`` from now on — a later rebuild of the row starts from it.
411
+ Nothing for a row that is not the open one."""
412
+ if self.form is None or self.form[0] is not target:
413
+ return
414
+ _, _, build, column, built = self.form
415
+ self.form = (target, title, build, column, built)
416
+ if self.form_title is not None and not self.form_title.is_deleted:
417
+ self.form_title.set_text(title)
418
+
419
+ def _close_row(self) -> None:
420
+ """Take the open form row away, if there is one; nothing is redrawn
421
+ — a row draws on no plot. The error its row's host shows goes with
422
+ it, and no other row's: a page-wide hide took an error off the row
423
+ it was about for a Cancel on another plot's row (the review)."""
424
+ if self.form is None:
425
+ return
426
+ target, _, _, column, _ = self.form
427
+ self.form = None
428
+ target.hush()
429
+ if not column.is_deleted:
430
+ column.delete()
431
+ if not target.row.is_deleted:
432
+ target.row.classes(remove="dstf-row-open")
433
+ self._paint_tree_clicks()
434
+
435
+ def _paint_tree_clicks(self) -> None:
436
+ """What each clickable tree row says it has open under it (F5)."""
437
+ if self.tree is not None:
438
+ self.tree.paint_clicks()
439
+
440
+ def drop_row(self, target: RowTarget) -> None:
441
+ """Close the open form if it hangs under ``target``: a host being
442
+ cleared — rebuilt, dropped with its row, hidden — takes its form
443
+ with it. Any other form is not this host's to close."""
444
+ if self.form is not None and self.form[0] is target:
445
+ self._close_row()
446
+
447
+ def _follow_row(self) -> None:
448
+ """The open row after a sync: gone with the rows it hung under (a
449
+ shape change rebuilt them), or rebuilt when an edit staged ELSEWHERE
450
+ moved its node — the criterion editor's rule, applied to a node: a
451
+ form showing a value the node no longer has would stage it straight
452
+ back. Anything else leaves it alone, so a field being typed in
453
+ survives every unrelated sync. A node that is gone closes the row:
454
+ its ``build`` over a path that names nothing drew a form for no
455
+ node (the Task 5 review's probe built twice for one)."""
456
+ if self.form is None:
457
+ return
458
+ target, title, build, column, built = self.form
459
+ if column.is_deleted or target.row.is_deleted:
460
+ self.form = None
461
+ return
462
+ node = self._node(target.node)
463
+ if node is None:
464
+ self._close_row()
465
+ return
466
+ now = _raw(node)
467
+ if now != built:
468
+ self.form = (target, title, build, column, now)
469
+ self._build_row()
470
+
471
+ def row_click(self, index: int, path: str) -> None:
472
+ """A criteria row's own click (board 1A): it closes a form open
473
+ under it and opens nothing; else it toggles the row's editor."""
474
+ if self.form is not None and self.form[0].key == f"crit:{index}:{path}":
475
+ self._close_row()
476
+ return
477
+ self.toggle_criterion(index, path)
478
+
479
+ def reveal(self, path: str, index: int | None) -> None:
480
+ """Open ``path``'s editor at the end of the next sync — the one that
481
+ draws the row an add just made — on plot ``index``, the plot it was
482
+ asked from, when that plot holds the row; else on the first plot
483
+ that does (a tree row's add has no plot). Recorded only when that
484
+ criterion EXISTS: apply is synchronous, so a refused add left no
485
+ node, and a reveal kept for it opened the next add's criterion by
486
+ itself. A folded row's key (D152) is revealed while its first
487
+ member exists."""
488
+ tree = self.shell.session.tree(self.model_id)
489
+ if node_or_none(tree, leaf_of(path)) is not None:
490
+ self.pending_reveal = (path, index)
491
+
492
+ def _reveal(self, path: str, index: int | None) -> None:
493
+ """The recorded reveal, once the cards are drawn: plot 3's `+`
494
+ opened its editor on plot 0, the first card holding the variable,
495
+ ~855 px above the click and never scrolled to (the Part 2 review).
496
+ Brought into view just enough, like a form row: the row, then the
497
+ editor under it — the row alone brought to the viewport's foot left
498
+ the editor just below it.
499
+
500
+ Sent to THIS page's client: `sync` runs from the Shell's timer,
501
+ whose context is no page's, and a bare `ui.run_javascript` from
502
+ there scrolled nothing."""
503
+ if index is not None and not (
504
+ 0 <= index < len(self.cards) and path in self.cards[index].rows
505
+ ):
506
+ index = None
507
+ # A statistics criterion has no plot: the card is its home, and
508
+ # `open_criterion` opens it there.
509
+ self.open_criterion(path, index)
510
+ # BY THEIR IDS (H6, F3's hazard): a `.dstf-crit-open` query found the
511
+ # tree row an open had just closed, and a `.dstf-editor` one that
512
+ # row's editor — both going with the next update, which this
513
+ # script can overtake (`_scroll_to_open`). The Statistics card's
514
+ # too: a `.dstf-stats .dstf-editor` query was the same hazard.
515
+ row = self._drawn_row(path)
516
+ if row is None or self.open is None:
517
+ return
518
+ editor = self._editor_on(self.open[0])
519
+ if editor is None:
520
+ return
521
+ self.client.run_javascript(
522
+ f'document.getElementById("{row.html_id}")'
523
+ '?.scrollIntoView({block: "nearest"});'
524
+ f'document.getElementById("{editor.html_id}")'
525
+ '?.scrollIntoView({block: "nearest"})'
526
+ )
527
+
528
+ def toggle_criterion(self, index: int, path: str) -> None:
529
+ """A criteria row's own click: its editor opens, or closes if that
530
+ row was the open one."""
531
+ if self.open == (index, path):
532
+ self.close_editor()
533
+ else:
534
+ self.open_criterion(path, index)
535
+
536
+ def open_criterion(self, path: str, index: int | None = None) -> None:
537
+ """One editor at a time, page-wide (board 2A). The plot above it is
538
+ its editing surface, so it is drawn if it was not. ONE `tree_view`
539
+ read, handed to every card this touches: reading it per shown card
540
+ cost fourteen reads for one row click on a twelve-plot page (the 3b
541
+ review). A statistics criterion's editor is the Statistics card's
542
+ (U3): it has no plot."""
543
+ self._unsay()
544
+ if index is None and self.stats is not None:
545
+ view = self._view()
546
+ if self.stats.row_for(path) is not None:
547
+ self._close_row()
548
+ self.drop_editor()
549
+ if self.stats.open_editor(path, view):
550
+ self.open = (STATS, path)
551
+ self._redraw_moved(view)
552
+ return
553
+ if index is None:
554
+ index = next((card.index for card in self.cards if path in card.rows), None)
555
+ if index is None or not 0 <= index < len(self.cards):
556
+ return
557
+ card = self.cards[index]
558
+ if path not in card.rows:
559
+ return
560
+ view = self._view()
561
+ self._close_row()
562
+ if self.focused is not None and self.focused != index:
563
+ # The editor has to be on screen: the row it opens under is on a
564
+ # card Focus is hiding (board 5A shows ONE plot).
565
+ self.focused = None
566
+ self._paint_focus(view)
567
+ self.drop_editor()
568
+ self.open = (index, path)
569
+ card.show(view)
570
+ card.open_editor(path, view)
571
+ self._redraw_moved(view)
572
+
573
+ def tree_row_click(self, path: str, *_: Any) -> None:
574
+ """A tree row's own click, or Enter / Space on it (U5): it closes a
575
+ form open under it and opens nothing (a criteria row's rule); else a
576
+ criterion's row toggles its editor under the row (Q5), and a
577
+ group's does what a panel registered for it (Q6: a k-of-n's or a
578
+ weighted's fields) — or nothing."""
579
+ if self.tree is None:
580
+ return
581
+ if self.form is not None and self.form[0].key == f"tree:{path}":
582
+ self._close_row()
583
+ return
584
+ node = self._node(path)
585
+ if node is None:
586
+ return
587
+ if "combinator" not in node:
588
+ self.toggle_tree_criterion(path)
589
+ return
590
+ target = self.tree.targets.get(path)
591
+ if target is not None and target.click is not None:
592
+ target.click()
593
+
594
+ def toggle_tree_criterion(self, path: str) -> None:
595
+ if self.open == (TREE, path):
596
+ self.close_editor()
597
+ else:
598
+ self.open_tree_criterion(path)
599
+
600
+ def open_tree_criterion(self, path: str) -> None:
601
+ """The criterion's editor under its tree row (Q5, Y4) — the page's
602
+ one editor (Q11): whatever else is open closes first, and the card
603
+ whose rings went with it is re-sent."""
604
+ if self.tree is None:
605
+ return
606
+ self._unsay()
607
+ view = self._view()
608
+ self._close_row()
609
+ self.drop_editor()
610
+ if self.tree.open_editor(path):
611
+ self.open = (TREE, path)
612
+ self._redraw_moved(view)
613
+
614
+ def edit_on_plot(self, path: str) -> None:
615
+ """Q12: the tree editor's "Edit on its plot" — the criterion opens
616
+ on the first plot holding it NOW, as a jump opens it (the tree's
617
+ editor closes: one editor page-wide), and its row is brought to the
618
+ middle of the viewport. Found at the click, never when the link was
619
+ drawn: the tree is refreshed before the cards rebuild, so an index
620
+ read then could name the plot it was on (J3). Nothing where no plot
621
+ holds it."""
622
+ index = next((c.index for c in self.cards if path in c.rows), None)
623
+ if index is None:
624
+ return
625
+ self.open_criterion(path, index)
626
+ self._scroll_to_open(path)
627
+
628
+ def toggle_stats_editor(self, path: str, *_: Any) -> None:
629
+ """A scored Statistics row's own click: its criterion's editor
630
+ opens under it, or closes if it was the open one."""
631
+ if self.open == (STATS, path):
632
+ self.close_editor()
633
+ else:
634
+ self.open_criterion(path)
635
+
636
+ def close_editor(self) -> None:
637
+ view = self._view()
638
+ self.drop_editor()
639
+ self._redraw_moved(view)
640
+
641
+ def drop_editor(self) -> None:
642
+ """Close the open editor and redraw NOTHING: what is re-sent is the
643
+ caller's to decide, once. A close that redrew on its own re-sent the
644
+ same card twice for a swap between two of its rows — and, in
645
+ `rebuild`, asked the Session for a plot the staged layout had just
646
+ taken away, which ended the rebuild half-done with the new shape
647
+ already recorded (the Task 6 review). The tree card and the
648
+ Statistics card close the page's one editor through it too, as
649
+ `drop_row` closes its form row."""
650
+ if self.open is None:
651
+ return
652
+ index, self.open = self.open[0], None
653
+ if index == STATS:
654
+ if self.stats is not None:
655
+ self.stats.close_editor()
656
+ return
657
+ if index == TREE:
658
+ if self.tree is not None:
659
+ self.tree.close_editor()
660
+ return
661
+ self.cards[index].close_editor()
662
+
663
+ def moved(self, path: str, text: str, paths: dict[str, str]) -> None:
664
+ """A move went through (U4, V4; `Panel.moved`): the editor and the
665
+ form row close NOW, before the sync the move's events bring — two
666
+ criteria on one variable trading places move no shape, and an
667
+ editor left open showed the other one — and the moved row and its
668
+ line wait for that sync, which draws the row. ``paths``: as for
669
+ `edited`."""
670
+ self.edited(paths)
671
+ view = self._view()
672
+ self.drop_editor()
673
+ self._close_row()
674
+ self._redraw_moved(view)
675
+ self._unsay()
676
+ self.pending_moved = (path, text)
677
+ self.move_revision = self.shell.session.spec_revision
678
+
679
+ def apply(self, outcome: Outcome | Sequence[Outcome]) -> None:
680
+ """Apply what one of this page's own acts returned — every action
681
+ bar and inline host on the page applies through here."""
682
+ self._own(lambda: self.shell.session.apply(outcome))
683
+
684
+ def _own(self, act: Callable[[], None]) -> None:
685
+ """Run ``act``, one of this page's own edits, and note the `spec`
686
+ revisions it produced in the TAB's record (`TabState.own_revisions`):
687
+ all of them, a sequence of outcomes stages once per patch. The
688
+ queue's or a scan's work is posted to the UI loop (`Session._post`) and
689
+ cannot stage while ``act`` runs on it. The open is not:
690
+ `Shell.open_async` runs `Session.open` on a thread of its own, whose
691
+ merge emits a `spec` event (`Session._emit_spec`); one emitted
692
+ between the two reads is counted as this tab's and keeps its folds.
693
+ Not guarded."""
694
+ session = self.shell.session
695
+ before = session.spec_revision
696
+ try:
697
+ act()
698
+ finally:
699
+ state = self.state
700
+ state.own_revisions.update(range(before + 1, session.spec_revision + 1))
701
+ state.seen_revision = max(state.seen_revision, session.spec_revision)
702
+
703
+ def edited(self, paths: dict[str, str]) -> None:
704
+ """An edit of the tree went through (`Panel.edited`, U5 R1):
705
+ ``paths`` says where each node it kept went, and the tree card's
706
+ folded groups follow it — before the sync that rebuilds the rows,
707
+ which drops a folded path naming no group (Y6). What the panels
708
+ kept (`kept`) follows its node the same way, and goes with a node
709
+ the edit did not keep."""
710
+ if self.tree is not None:
711
+ self.tree.rekey(paths)
712
+ # The plots' unfolded family rows are kept by their members' paths.
713
+ Folding.rekey(self.state, self.model_id, paths)
714
+ kept = self.state.kept.get(self.model_id)
715
+ if kept:
716
+ self.state.kept[self.model_id] = {
717
+ (paths[node], name): held
718
+ for (node, name), held in kept.items()
719
+ if node in paths
720
+ }
721
+
722
+ def kept(self, node: str, name: str) -> dict[Any, Any]:
723
+ """`Panel.kept` for capability ``name``'s panel at ``node`` of this
724
+ test: the same dict at every build, until it is dropped — the
725
+ tab's record (`TabState.kept`), which `heard_spec` empties when an
726
+ edit that is not this tab's lands on the test."""
727
+ return self.state.kept.setdefault(self.model_id, {}).setdefault(
728
+ (node, name), {}
729
+ )
730
+
731
+ def unfold_to(self, target: RowTarget) -> None:
732
+ """R2: something shown under ``target``'s row — a form, a refused
733
+ act's sentence — unfolds the folded groups it is inside, when that
734
+ row is a TREE row. Under a plot's or the Statistics card's row it
735
+ is done outside the tree, and leaves the folds alone."""
736
+ if self.tree is not None and self.tree.targets.get(target.node) is target:
737
+ self.tree.open_path(target.node)
738
+
739
+ def _unsay(self) -> None:
740
+ """Take a move's line away: the next form, editor or edit."""
741
+ if self.tree is not None:
742
+ self.tree.unsay()
743
+
744
+ def _rethemed(self) -> None:
745
+ """The tab switched theme (`TabTheme.switch`): every DRAWN plot is
746
+ re-sent in it — a `Plotly.react` on the same node, so its zoom, its
747
+ arrows' map and a staged edit stay. A card not drawn yet takes the
748
+ tab's theme when it is."""
749
+ for card in self.cards:
750
+ if card.plot is not None:
751
+ card.redraw()
752
+
753
+ def _redraw_moved(self, view: Any) -> None:
754
+ """The signature is the one thing that decides a redraw: only the
755
+ card whose rings came or went is re-sent."""
756
+ for card in self.cards:
757
+ card.redraw_if_moved(view)
758
+
759
+ def jump_to(self, path: str) -> None:
760
+ """The closed tree line's jump (board 1A): open that criterion and
761
+ bring the row it opened under into view — a plot's criteria row, or
762
+ a Statistics row (U3: every leaf of a tree view is on one or the
763
+ other). A criterion with no row drawn opens nothing and scrolls to
764
+ nothing: with another editor open, the jump scrolled to THAT row."""
765
+ self.open_criterion(path)
766
+ self._scroll_to_open(path)
767
+
768
+ def _scroll_to_open(self, path: str) -> None:
769
+ """The row ``path``'s editor is open under, brought to the middle of
770
+ the viewport — found BY ITS ID: a `.dstf-crit-open` query found the
771
+ tree row an open had just closed (U5), whose class goes with the
772
+ next update, which this script can overtake. Nothing where the
773
+ open did not land on ``path``."""
774
+ row = self._drawn_row(path)
775
+ if row is not None:
776
+ self.client.run_javascript(
777
+ f'document.getElementById("{row.html_id}")'
778
+ '?.scrollIntoView({block: "center"})'
779
+ )
780
+
781
+ def _drawn_row(self, path: str) -> ui.element | None:
782
+ """The plot's criteria row or the Statistics row ``path`` is open
783
+ under now, or None."""
784
+ if self.open is None or self.open[1] != path:
785
+ return None
786
+ return self._row_on(self.open[0], path)
787
+
788
+ def _editor_on(self, index: int) -> ui.element | None:
789
+ """The open editor's box on plot ``index``, or on the Statistics
790
+ card for STATS — None where none is open."""
791
+ if index == STATS:
792
+ return None if self.stats is None else self.stats.editor_box
793
+ if 0 <= index < len(self.cards):
794
+ return self.cards[index].editor_box
795
+ return None
796
+
797
+ def _row_on(self, index: int, path: str) -> ui.element | None:
798
+ """``path``'s criteria row on plot ``index``, or its Statistics row
799
+ for STATS — None where none is drawn."""
800
+ if index == STATS and self.stats is not None:
801
+ key = self.stats.row_for(path)
802
+ return None if key is None else self.stats.rows_by_path.get(key)
803
+ if 0 <= index < len(self.cards) and path in self.cards[index].rows:
804
+ return self.cards[index].rows[path].row
805
+ return None
806
+
807
+ def show_criterion(self, path: str, *_: Any) -> None:
808
+ """A tree row's ⤓ (U5, Q7): the criterion's row where it is drawn —
809
+ on its first plot, drawn if it was not and a Focus hiding it ended;
810
+ or its Statistics row, the card opened — scrolled to the middle of
811
+ the viewport and flashed. No editor opens or moves, and no fold
812
+ opens: it shows a plot, not a tree row (R2)."""
813
+ view = self._view()
814
+ index = next((c.index for c in self.cards if path in c.rows), None)
815
+ if index is not None:
816
+ if self.focused is not None and self.focused != index:
817
+ self.focused = None
818
+ self._paint_focus(view)
819
+ self.cards[index].show(view)
820
+ # Its OWN row: a family's folded row would hide it (D152).
821
+ self.cards[index].folding.unfold_to(path, view)
822
+ elif self.stats is not None and self.stats.row_for(path) is not None:
823
+ index = STATS
824
+ if not self.stats.is_open:
825
+ self.stats.is_open = True
826
+ self.stats.refresh(view)
827
+ row = None if index is None else self._row_on(index, path)
828
+ if row is None:
829
+ return
830
+ self.client.run_javascript(
831
+ f'const r = document.getElementById("{row.html_id}");'
832
+ 'if (r) { r.scrollIntoView({block: "center"});'
833
+ ' r.classList.remove("dstf-flash"); void r.offsetWidth;'
834
+ ' r.classList.add("dstf-flash"); }'
835
+ )
836
+
837
+ # -- the cards -------------------------------------------------------------
838
+
839
+ def rebuild(self, view: dict | None) -> None:
840
+ """The cards, from the plots as they are now (`Session.plots`: one
841
+ per scored variable or family in tree order, then the watch plots;
842
+ one per tracked variable with no tree). ``view`` and the label
843
+ scope are read ONCE here and handed to every card — a card that
844
+ read them for itself cost one read per plot (the Task 5-6
845
+ review)."""
846
+ assert self.column is not None and self.count is not None
847
+ session = self.shell.session
848
+ self.gone = False
849
+ # The rows the editor hangs under go with the cards: the criterion
850
+ # it was open on may not even exist after this. Closed WITHOUT a
851
+ # redraw — these cards are about to be dropped, and one of them may
852
+ # be a plot the staged layout no longer has. The Statistics card's
853
+ # editor and the tree's (U5) hang under none of them.
854
+ if self.open is not None and self.open[0] >= 0:
855
+ self.drop_editor()
856
+ # Focus names an INDEX, and after this the indices may name other
857
+ # plots; the plots a user had drawn are re-drawn where they are left.
858
+ self.focused = None
859
+ # The selection follows its PLOT, by subject: a watch plot given a
860
+ # criterion becomes derived and moves to where the tree names it,
861
+ # and the index it had names another plot. A gesture that placed
862
+ # the selection itself (`stage_layout`'s ``select``) left none.
863
+ follow = self.follow
864
+ shown = {card.index for card in self.cards if card.plot is not None}
865
+ for card in self.cards:
866
+ card.dispose()
867
+ self.column.clear()
868
+ plots = session.plots(self.model_id)
869
+ # The label scope too: it is one fact about the TEST, and a card
870
+ # that read it for itself cost one `plots` + `tracked` read per
871
+ # plot — 35 for a five-plot test.
872
+ scope = session.variable_texts(self.model_id)
873
+ with self.column:
874
+ self.cards = [
875
+ PlotCard(self, plot.index, plot, view, scope) for plot in plots
876
+ ]
877
+ for card in self.cards:
878
+ # A rebuild must not blank what the user was looking at: the
879
+ # browser's observer has already fired for those slots and will
880
+ # not fire again.
881
+ if card.index in shown:
882
+ card.show(view)
883
+ self._paint_focus(view)
884
+ # The selection names an INDEX too; it is kept — on the plot it
885
+ # followed, else clamped to the cards there are now — and its ring
886
+ # painted on the new cards. The drawer follows at the end of the
887
+ # sync that called this, once.
888
+ moved = next(
889
+ (
890
+ plot.index
891
+ for plot in plots
892
+ if follow and any(name in plot.subject for name in follow)
893
+ ),
894
+ None,
895
+ )
896
+ self._ring(self.selected if moved is None else moved)
897
+ self.count.set_text(
898
+ self._count_text(session.plots_from_tree(self.model_id), plots)
899
+ )
900
+ self._observe()
901
+
902
+ def _count_text(self, from_tree: bool, plots: Sequence[ResolvedPlot]) -> str:
903
+ """What the cards are: derived from the criteria, or — nothing
904
+ scored — one per tracked variable or array; counted apart from a
905
+ person's watch plots, which are neither."""
906
+ if not plots:
907
+ return f"no plots: {self._no_plots_reason(from_tree)}"
908
+ watch = sum(1 for plot in plots if plot.watch)
909
+ if watch == len(plots):
910
+ # Nothing derived: "0 plots · derived from the criteria" counted
911
+ # plots that are not there.
912
+ return f"{plural(watch, 'watch plot')} · no criteria on a signal yet"
913
+ why = (
914
+ "derived from the criteria"
915
+ if from_tree
916
+ else "one per tracked variable or array"
917
+ )
918
+ return f"{plural(len(plots) - watch, 'plot')} · {why}" + (
919
+ f" · {watch} watch" if watch else ""
920
+ )
921
+
922
+ def on_visible(self, arg: Any) -> None:
923
+ """The browser's `dstf_plot_visible`: the slot's index, as a string.
924
+ Anything else is a page that has moved on since the observer fired."""
925
+ try:
926
+ index = int(arg) if isinstance(arg, str) else -1
927
+ except ValueError:
928
+ return
929
+ if 0 <= index < len(self.cards):
930
+ self.cards[index].show()
931
+
932
+ def on_plot(self, what: str, arg: Any) -> None:
933
+ """A click on a plot (``clicked``) or a handle held on one
934
+ (``moved``), named by its `ui.plotly`'s element id: handed to the
935
+ card that plot is on. One for a plot this page no longer has — a
936
+ rebuild crossed it — is nothing."""
937
+ plot = arg.get("plot") if isinstance(arg, dict) else None
938
+ for card in self.cards:
939
+ if card.plot is not None and card.plot.id == plot:
940
+ getattr(card, what)(arg)
941
+ return
942
+
943
+ def disarm(self) -> None:
944
+ """Esc: no field is armed (D149)."""
945
+ for card in self.cards:
946
+ card.placing.disarm()
947
+
948
+ def _observe(self) -> None:
949
+ ui.run_javascript(OBSERVE)
950
+
951
+ # -- the selected plot and the layout ---------------------------------------
952
+
953
+ def select(self, index: int) -> None:
954
+ """Select plot ``index``, clamped to the cards (0 with none), move
955
+ the ring to its card, and the open Variables drawer's checks with
956
+ it."""
957
+ self._ring(index)
958
+ self.rail.refresh()
959
+
960
+ def _ring(self, index: int) -> None:
961
+ self.selected = max(0, min(index, len(self.cards) - 1))
962
+ self.follow = self.cards[self.selected].resolved.subject if self.cards else None
963
+ for card in self.cards:
964
+ if card.index == self.selected:
965
+ card.card.classes(add="dstf-plot-selected")
966
+ else:
967
+ card.card.classes(remove="dstf-plot-selected")
968
+
969
+ def stage_layout(
970
+ self, extras: Sequence[PlotExtra], select: int | None = None
971
+ ) -> bool:
972
+ """Stage ``extras`` — the test's plot extras after one gesture — as
973
+ the ONE op on `/plots`, and say whether it was staged. An edit that
974
+ changes nothing stages nothing: extras equal to the ones in effect,
975
+ or none on an entry with no key (`layout_op`'s ``None``). ``select``
976
+ is set BEFORE the stage — the rebuild its `spec` event causes reads
977
+ it — and put back if the Session refuses: a refused edit is said,
978
+ and nothing else changes (3c, planner's choice 10)."""
979
+ session = self.shell.session
980
+ if tuple(extras) == session.layout(self.model_id):
981
+ return False
982
+ op = layout_op(self.model_id, session.spec.entry(self.model_id), extras)
983
+ if op is None:
984
+ return False
985
+ before = self.selected, self.follow
986
+ if select is not None:
987
+ self.selected, self.follow = select, None
988
+ try:
989
+ self._own(lambda: session.stage([op]))
990
+ except (PatchError, ConfigError) as e:
991
+ self.selected, self.follow = before
992
+ ui.notify(user_text(e), type="warning")
993
+ return False
994
+ return True
995
+
996
+ def remove_watch(self, index: int, var: str) -> None:
997
+ """A watch card's Remove plot: watch plot ``index`` (of ``var``)
998
+ gone, the selection kept on the SAME plot — a removal above it
999
+ shifts it up by one; the selected plot removed selects the one now
1000
+ at its index, the last if it was the last (the rebuild clamps). The
1001
+ extras are read NOW: a card drawn before the last change may name
1002
+ an entry that has gone, and that removes nothing."""
1003
+ try:
1004
+ extras = remove_watch(self.shell.session.layout(self.model_id), var)
1005
+ except KeyError:
1006
+ return
1007
+ self.stage_layout(extras, select=self.selected - (index < self.selected))
1008
+
1009
+ def remove_context(self, index: int, name: str) -> None:
1010
+ """A context series' `×`: ``name`` off plot ``index``, as the plots
1011
+ are NOW — a card drawn before the last change may name a plot that
1012
+ has moved."""
1013
+ plots = self.shell.session.plots(self.model_id)
1014
+ if not 0 <= index < len(plots) or name not in plots[index].context:
1015
+ return
1016
+ extras = self.shell.session.layout(self.model_id)
1017
+ self.stage_layout(remove_context(extras, plots[index], name))
1018
+
1019
+ def reset_layout(self) -> None:
1020
+ """Stage removing the entry's ``plots`` key: every context series
1021
+ and watch plot goes; the derived plots stay. Refused, it is said,
1022
+ like any layout edit."""
1023
+ try:
1024
+ self._own(lambda: self.shell.session.stage([reset_op(self.model_id)]))
1025
+ except (PatchError, ConfigError) as e:
1026
+ ui.notify(user_text(e), type="warning")
1027
+
1028
+ # -- Focus -----------------------------------------------------------------
1029
+
1030
+ def focus(self, index: int) -> None:
1031
+ """Board 5A: Focus is a button on each plot, and the focused plot's
1032
+ button reads "Focused" and is the way out of it, like the bar's."""
1033
+ self.focused = None if self.focused == index else index
1034
+ if (
1035
+ self.focused is not None
1036
+ and self.open is not None
1037
+ and self.open[0] != TREE # Focus never hides the tree card
1038
+ and self.open[0] != self.focused
1039
+ ):
1040
+ # An editor nobody can see is no editor, and it goes on staging
1041
+ # edits from drags on a plot that is not on screen. The other
1042
+ # direction of `open_criterion`'s, which ends the focus instead.
1043
+ self.close_editor()
1044
+ if self.focused is not None:
1045
+ # A form under a criteria row of a card Focus hides goes the
1046
+ # same way (the Task 5 review).
1047
+ for card in self.cards:
1048
+ if card.index != self.focused:
1049
+ for row in card.rows.values():
1050
+ self.drop_row(row.target)
1051
+ self._paint_focus()
1052
+
1053
+ def focus_to(self, index: Any) -> None:
1054
+ """The switcher's choice: focus that plot — the same `focus` path,
1055
+ so an editor or a form row on the card it hides closes. Its own
1056
+ repaint (`set_options` to the plot already focused) does nothing:
1057
+ `focus` on the focused plot would END the focus."""
1058
+ if isinstance(index, int) and index != self.focused:
1059
+ self.focus(index)
1060
+
1061
+ def show_all(self) -> None:
1062
+ self.focused = None
1063
+ self._paint_focus()
1064
+
1065
+ def _paint_focus(self, view: Any = UNREAD) -> None:
1066
+ """The bar and every card's visibility, from `focused` as it is now
1067
+ — the one place either is written, so a rebuild resets both by
1068
+ calling it."""
1069
+ if self.focus_bar is None or self.focus_text is None:
1070
+ return
1071
+ if self.focused is not None and not 0 <= self.focused < len(self.cards):
1072
+ self.focused = None
1073
+ focused = self.focused
1074
+ for card in self.cards:
1075
+ card.card.set_visibility(focused is None or card.index == focused)
1076
+ card.focus.set_text("Focused" if card.index == focused else "Focus")
1077
+ self.focus_bar.set_visibility(focused is not None)
1078
+ if focused is None or self.focus_all is None:
1079
+ return
1080
+ plots = plural(len(self.cards), "plot")
1081
+ # The sentence counts; the switcher beside it names the plot — the
1082
+ # way the plot names itself, its texts, a family folded as on its
1083
+ # title. The name in both said it twice.
1084
+ self.focus_text.set_text(f"Showing 1 of {plots}")
1085
+ if self.focus_switch is not None:
1086
+ self.focus_switch.set_options(
1087
+ {
1088
+ c.index: folded(c.resolved.subject, c.texts)
1089
+ + (" · watch" if c.resolved.watch else "")
1090
+ for c in self.cards
1091
+ },
1092
+ value=focused,
1093
+ )
1094
+ self.focus_all.set_text(f"Show all {plots}")
1095
+ self.cards[focused].show(view)
1096
+
1097
+ def shape(self, view: dict | None) -> Any:
1098
+ """What the cards are BUILT from and cannot be repainted through:
1099
+ the criteria and their variables, the plots themselves — each
1100
+ one's subject, context, whether it is a watch plot, and what the
1101
+ result lacks. NOT a leaf's
1102
+ metric — a mode switch repaints a row's name in place. NOT a
1103
+ statistics leaf: it is on no plot, and a `+` in the Statistics card
1104
+ rebuilt every plot card — Focus ended, a zoom was lost, every figure
1105
+ was re-sent."""
1106
+ session = self.shell.session
1107
+ return (
1108
+ tuple(
1109
+ (leaf["path"], leaf.get("variable"))
1110
+ for leaf in leaves_of(view)
1111
+ if not reads_a_statistic(
1112
+ str(leaf.get("metric")), leaf.get("params") or {}
1113
+ )
1114
+ ),
1115
+ tuple(
1116
+ (plot.subject, plot.context, plot.watch, plot.missing)
1117
+ for plot in session.plots(self.model_id)
1118
+ ),
1119
+ )
1120
+
1121
+ # -- following the Session -------------------------------------------------
1122
+
1123
+ def _paint(self, view: dict | None) -> None:
1124
+ if self.badge is None or self.meta is None:
1125
+ return
1126
+ session = self.shell.session
1127
+ comp = session.comparisons.get(self.model_id)
1128
+ verdict, css = (
1129
+ classify_with_class(comp) if comp is not None else ("", SCORING_CLASS)
1130
+ )
1131
+ self.badge.set_text(verdict or "…")
1132
+ self.badge.style(f"background-color: var(--dstf-{css}-fill)")
1133
+ project = session.project
1134
+ if project is None:
1135
+ return
1136
+ # The index's id alone: `get_reference` reads the whole baseline off
1137
+ # disk, and this runs on every Session event to print one file name.
1138
+ ref_id = project.store.index.get_id(self.model_id)
1139
+ ref = (
1140
+ None if ref_id is None else RefIndex.ref_filename(ref_id).rsplit(".", 1)[0]
1141
+ )
1142
+ self.meta.set_text(f"{project.config.backend_name} · {ref or 'no baseline'}")
1143
+ if self.strip is not None:
1144
+ # `_gone` blanked the strip and hid its bar; this brings both
1145
+ # back, for the project that is open now. The strip never stops
1146
+ # a sync: the tree and the cards below it follow whatever it
1147
+ # raises.
1148
+ try:
1149
+ self.strip.paint(self._situation(comp, view))
1150
+ except Exception:
1151
+ logger.exception("the strip of %s", self.model_id)
1152
+ # The paint refreshes the bar; raised before it, the bar
1153
+ # still follows the queue taking or ending this test. The
1154
+ # bar may be what raised (a capability's `emphasis` that
1155
+ # is no dict): raised again here, it would stop the sync
1156
+ # after all.
1157
+ try:
1158
+ self.strip.actions.refresh()
1159
+ except Exception:
1160
+ logger.exception("the bar of %s", self.model_id)
1161
+
1162
+ def _situation(self, comp: Any, view: dict | None) -> Strip:
1163
+ """The strip's inputs, read from the Session: the verdict as edited
1164
+ and — only with this test's edits pending — as saved."""
1165
+ session, model_id = self.shell.session, self.model_id
1166
+ project = session.project
1167
+ assert project is not None # `_paint` returned before without one
1168
+ edits = edit_counts(session.spec.pending)[model_id]
1169
+ saved_status: str | None = None
1170
+ if edits:
1171
+ # The saved spec is scored afresh here, and its scoring may raise
1172
+ # where the edited one does not (an edit that fixed it): the
1173
+ # strip says so, and the page goes on following the edit.
1174
+ try:
1175
+ saved = session.saved_comparison(model_id)
1176
+ except Exception:
1177
+ logger.exception("the saved verdict of %s", model_id)
1178
+ saved_status = SCORING_FAILED
1179
+ else:
1180
+ saved_status = classify(saved) if saved is not None else None
1181
+ loading = self.shell.load.in_progress
1182
+ has_result = self.run_state() != "none"
1183
+ statuses = [leaf_status(leaf) for leaf in leaves_of(view)]
1184
+ return strip_for(
1185
+ queue=queue_row(session, model_id),
1186
+ edits=edits,
1187
+ status=classify(comp) if comp is not None else None,
1188
+ saved_status=saved_status,
1189
+ has_result=has_result,
1190
+ loading=loading,
1191
+ # The load names the models whose scoring raised while it read
1192
+ # them; after it, a result with no comparison is one whose
1193
+ # scoring raised later (a finished run's rescore, an Accept's
1194
+ # reload).
1195
+ scoring_failed=comp is None
1196
+ and (
1197
+ model_id in self.shell.load.failed_models
1198
+ or (has_result and not loading)
1199
+ ),
1200
+ simulate_only=comp is not None and comp.simulate_only,
1201
+ fails=statuses.count("fail"),
1202
+ criteria=len(statuses),
1203
+ advisory=statuses.count("warn"),
1204
+ # The merged TestModel carries the EFFECTIVE tree (`stage`
1205
+ # re-merges `project.tests`): a staged criterion counts.
1206
+ new_variables=new_variables(
1207
+ project.test(model_id),
1208
+ self.result() if has_result else None,
1209
+ project.config.diagnostic_variables,
1210
+ ),
1211
+ refused=refusal(comp) if comp is not None else None,
1212
+ partial=session.partial(model_id),
1213
+ failure=comp.error_message if comp is not None else None,
1214
+ )
1215
+
1216
+ def sync(self, force: bool = False) -> None:
1217
+ """Follow the Session: ONE `tree_view` read, handed to the tree card
1218
+ and to every plot card. A moved shape rebuilds the cards; otherwise
1219
+ each card is painted in place and re-sent only when what it DRAWS
1220
+ moved — ``force`` re-sends every drawn plot whether it moved or not,
1221
+ which is what a run that started or ended needs (decision 10: it
1222
+ changes the trajectories, and no leaf's signature sees that)."""
1223
+ if not self._known():
1224
+ self._gone()
1225
+ return
1226
+ view = self._view()
1227
+ self._paint(view)
1228
+ self.live.mark(view)
1229
+ if self.stats is not None:
1230
+ self.stats.refresh(view)
1231
+ if self.tree is not None:
1232
+ self.tree.refresh(view)
1233
+ if self.column is None:
1234
+ return
1235
+ shape = self.shape(view)
1236
+ if shape != self._shape:
1237
+ with self.column:
1238
+ self.rebuild(view)
1239
+ # Recorded AFTER the rebuild ran: a raise inside it left the
1240
+ # page with the NEW shape already recorded and half the cards
1241
+ # built, and no later sync ever rebuilt it (the 3b review).
1242
+ self._shape = shape
1243
+ else:
1244
+ for card in self.cards:
1245
+ try:
1246
+ card.paint(view)
1247
+ if force:
1248
+ card.redraw(view)
1249
+ else:
1250
+ card.redraw_if_moved(view)
1251
+ except Exception: # one card's failure is not another's
1252
+ logger.exception("plot %s of %s", card.index, self.model_id)
1253
+ self._follow_row()
1254
+ if self.pending_reveal is not None:
1255
+ # After the cards: the row an add made exists only once a
1256
+ # rebuild drew it.
1257
+ (path, index), self.pending_reveal = self.pending_reveal, None
1258
+ self._reveal(path, index)
1259
+ if self.pending_moved is not None and self.tree is not None:
1260
+ # After the tree's rows: the moved row exists once they are
1261
+ # rebuilt.
1262
+ (path, text), self.pending_moved = self.pending_moved, None
1263
+ self.tree.say(path, text)
1264
+ # Last: the drawer's checks read the cards and the selection.
1265
+ self.rail.refresh()
1266
+
1267
+ def _gone(self) -> None:
1268
+ """This page's test is no longer a test of the open project — an
1269
+ `OpenProject` outcome swapped the library, or the Session was
1270
+ closed. Every read below `sync` raises for it, so the page went on
1271
+ showing the last project's cards while every event logged a
1272
+ traceback. It says what a page BUILT for an unknown model says, and
1273
+ a later event that finds the test again rebuilds (`_shape`).
1274
+
1275
+ The sub-header and the strip go with the cards: they stayed live
1276
+ and stale — the last project's verdict, its baseline's name, and
1277
+ buttons that queued a run of a model this project does not have
1278
+ (the final review). `_paint` brings them back."""
1279
+ if self.gone or self.column is None:
1280
+ return
1281
+ self.gone = True
1282
+ self.drop_editor()
1283
+ self._close_row()
1284
+ self.pending_reveal = self.pending_moved = None
1285
+ self.move_revision = None
1286
+ if self.strip is not None:
1287
+ self.strip.forget()
1288
+ self.live.mark(None)
1289
+ if self.badge is not None:
1290
+ self.badge.set_text("")
1291
+ self.badge.style("background-color: transparent")
1292
+ if self.meta is not None:
1293
+ self.meta.set_text("")
1294
+ for card in self.cards:
1295
+ card.dispose()
1296
+ self.cards = []
1297
+ self.focused = None
1298
+ self._shape = None
1299
+ self.column.clear()
1300
+ if self.tree is not None:
1301
+ self.tree.forget()
1302
+ if self.stats is not None:
1303
+ self.stats.forget()
1304
+ if self.count is not None:
1305
+ self.count.set_text("")
1306
+ if self.focus_bar is not None:
1307
+ self.focus_bar.set_visibility(False)
1308
+ with self.column:
1309
+ ui.label(NOT_A_TEST.format(self.model_id)).classes("text-negative").mark(
1310
+ "gone"
1311
+ )
1312
+ # `sync` returns right after this, never reaching its own refresh:
1313
+ # an open Variables drawer kept the old test's rows, their checks
1314
+ # reading the Session for a model it no longer has. Its gone guard
1315
+ # (`VariablesDrawer._read`) empties it.
1316
+ self.rail.refresh()
1317
+
1318
+ def flush(self) -> None:
1319
+ """Nothing coalesced: every event is handled as it comes."""
1320
+
1321
+ def handle(self, event: dict[str, Any]) -> None:
1322
+ kind = event["event"]
1323
+ if kind == "poll":
1324
+ # Once a second while the queue runs or starts: THIS test's
1325
+ # seconds in the strip — or how far its result file has got —
1326
+ # and the samples its drawn plots gained (D151).
1327
+ self._tick()
1328
+ return
1329
+ self.header.handle(event)
1330
+ self.rail.handle(event)
1331
+ known = self._known()
1332
+ if kind == "queue":
1333
+ # It names the tests whose place in the queue moved.
1334
+ syncs = self.model_id in event.get("models", ())
1335
+ else:
1336
+ # A notice with a message is a store write's: it reloaded
1337
+ # every result.
1338
+ syncs = (kind == "notice" and "message" in event) or (
1339
+ kind in ("results", "spec", "loading", "opened", "overlays")
1340
+ and self._mine(event)
1341
+ )
1342
+ if self.actions is not None and known and not syncs:
1343
+ # A sync's paint refreshes the bar; an event that does not sync
1344
+ # (another test's) still moves its availability.
1345
+ self.actions.refresh()
1346
+ if event["event"] == "spec":
1347
+ heard_spec(self.state, event)
1348
+ if self._mine(event):
1349
+ # Any edit of the test disarms (D149): a ⌖ names a row by
1350
+ # its index and a field by its keys, and an edit — a row
1351
+ # added by a click, a Discard, a Save, another tab's — can
1352
+ # put another row or another leaf under that name.
1353
+ self.disarm()
1354
+ if kind == "notice" and syncs:
1355
+ # A store write reloaded the trajectories under every plot and
1356
+ # no leaf's signature sees that (decision 10).
1357
+ self.sync(force=True)
1358
+ elif kind == "queue" and syncs:
1359
+ # A run that started took this test's trajectories with it and
1360
+ # one that ended brought new ones — no leaf's signature sees
1361
+ # either. The other states move the strip and re-send no figure.
1362
+ self.sync(force=event.get("state") in ("started", "ended"))
1363
+ elif syncs and event["event"] == "overlays":
1364
+ # The sibling-backend overlays arrived after the plots were
1365
+ # drawn without them (`Session._overlays_ready`); no leaf's
1366
+ # signature sees an overlay, so the drawn plots are re-sent.
1367
+ self.sync(force=True)
1368
+ elif syncs:
1369
+ if event["event"] == "spec" and (
1370
+ self.move_revision is None or event["revision"] > self.move_revision
1371
+ ):
1372
+ # An edit after a move: the line has said its piece. The
1373
+ # move's own event, or one queued ahead of it, is not: the
1374
+ # line is said by this sync, or was by an event before it.
1375
+ self._unsay()
1376
+ self.sync()
1377
+
1378
+ def _tick(self) -> None:
1379
+ if self.strip is None or self.strip.situation not in (
1380
+ Situation.QUEUED, # "waiting · worker starting" comes and goes
1381
+ Situation.RUNNING,
1382
+ ):
1383
+ return
1384
+ row = queue_row(self.shell.session, self.model_id)
1385
+ if row is not None and row.state in (ItemState.WAITING, ItemState.RUNNING):
1386
+ self.strip.say(strip_for(queue=row))
1387
+ self.live.tick()
1388
+
1389
+ def _mine(self, event: dict[str, Any]) -> bool:
1390
+ """Whether an event says anything about THIS test. `results` and
1391
+ `spec` name the models they moved, a per-model `loading` step names
1392
+ the one it read; an event that names neither — the scan's steps,
1393
+ `opened` — is the whole project's and is this test's too. A page
1394
+ that synced on every model's event re-read the tree and repainted
1395
+ every row for a test it says nothing about.
1396
+
1397
+ An EMPTY ``models`` names no model and is the whole project's:
1398
+ `Session.close()` emits one, and it is how a page whose test has
1399
+ gone hears about it."""
1400
+ models = event.get("models")
1401
+ if models:
1402
+ return self.model_id in models
1403
+ model = event.get("model")
1404
+ return model is None or model == self.model_id