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
+ """The criteria tree card (brief §3.2; the 2026-09-20 board, 1A / 1B):
2
+ closed it is one line — the root's verdict, what the root is, the tally,
3
+ and the first failures by name, each a jump to its criterion; open it is a
4
+ row per node with that node's controls, which are whatever capabilities
5
+ attach at the NODE placement. The card knows no capability by name.
6
+
7
+ A refresh rebuilds the rows only when the tree's SHAPE moved — the paths,
8
+ kinds and variables of its nodes. A verdict that flips, a readout, a
9
+ combinator's own count: those repaint into the row that is already there.
10
+ A cluster is elements enough that re-sending every row on every Session
11
+ event was the cost the 3b review measured (40 leaves: ~1150 elements per
12
+ sync with the tree open)."""
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from functools import partial
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from nicegui import ui
21
+
22
+ from ...comparison.tree_spec import reads_a_statistic
23
+ from ...reporting.tree_rows import leaves_of
24
+ from ..capabilities import Placement
25
+ from ..session import Selection
26
+ from ..tree_move import TREE_CHANGED, MoveRefused
27
+ from ..tree_pointer import node_or_none
28
+ from ..tree_text import count_text, leaf_name, node_text, nodes_of
29
+ from .actions import InlineHost, RowTarget
30
+ from .widgets import Pill
31
+
32
+ if TYPE_CHECKING:
33
+ from .test_page import TestPage
34
+
35
+ NAMED = 2 #: failures the closed line names; the rest are counted
36
+
37
+ #: One level's bar and the gap after it (U5, Q1): 6 px + 2 px.
38
+ BAR_STEP = 8
39
+ #: The row's gap between its parts (`gap-3`), the chevron's place and the
40
+ #: drag handle's (`.dstf-chev-slot`, `.dstf-grip`), px.
41
+ _GAP, _CHEVRON, _GRIP = 12, 28, 20
42
+
43
+
44
+ def content_left(depth: int) -> int:
45
+ """Where a row's content starts, right of its bars: its chevron's place
46
+ (U5, E4) — where a drag's "before" line starts (U4 C15)."""
47
+ return BAR_STEP * depth + _GAP
48
+
49
+
50
+ def pill_left(depth: int) -> int:
51
+ """Where a row's pill starts: past its chevron's place and its handle."""
52
+ return content_left(depth) + _CHEVRON + _GAP + _GRIP + _GAP
53
+
54
+
55
+ #: Appended to a move's announcement when the live region already holds
56
+ #: that text: a live region is read out when its text CHANGES, and a
57
+ #: second "Moved." would not change it (`TreeCard.say`).
58
+ _AGAIN = "\u200b"
59
+
60
+ #: The drag (U4), drawn in the browser — only the drag's start and the drop
61
+ #: reach the server. A row's top half is BEFORE it, its bottom half ONTO
62
+ #: it (the owner's ruling, D1 as amended); the root has only onto. The map
63
+ #: of each row's zones (`Panel.movable`'s ``zones``) arrives once per drag
64
+ #: (`TreeCard._drag_start`) and is emptied at every start, so a slow reply
65
+ #: never draws one drag with the last one's map; until it has arrived a
66
+ #: zone draws as allowed, and a drop there is the act's to refuse. A
67
+ #: refused zone outlines its row and says why in the card's header line,
68
+ #: in place of the line's text — said in the row, it reflowed the row's
69
+ #: icons, and the browser shows no title while it drags.
70
+ #:
71
+ #: The drag carries its own MIME type, never text: a drag that does not
72
+ #: (a file, a selection) draws nothing and drops nothing here, and ours,
73
+ #: released over a text field, types no path into it. It carries the
74
+ #: rows' revision from its start too (`TreeCard.revision`): a drop on rows
75
+ #: rebuilt since is refused (`TreeCard.dropped`).
76
+ _DRAG_COMMON = """const D = window.dstfMove || (window.dstfMove = {src: null, map: {}});
77
+ const MIME = __MIME__;
78
+ const ours = (e) => !!e.dataTransfer && [...e.dataTransfer.types].includes(MIME);
79
+ const zoneOf = (row, e) => {
80
+ if (row.dataset.root === '1') return 'onto';
81
+ const r = row.getBoundingClientRect();
82
+ return (e.clientY - r.top) / r.height < 0.5 ? 'before' : 'onto';
83
+ };
84
+ const say = (rows, why) => {
85
+ const line = rows.closest('.dstf-tree')?.querySelector('.dstf-tree-line');
86
+ if (!line) return;
87
+ if (why) { line.dataset.why = why; line.classList.add('dstf-line-why'); }
88
+ else { delete line.dataset.why; line.classList.remove('dstf-line-why'); }
89
+ };
90
+ //: The drag resting on a folded group (U5, Q10): 700 ms over its row
91
+ //: unfolds it, as its chevron's click would; leaving the row, the drop
92
+ //: and the drag's end cancel it (E6).
93
+ const unhover = () => { if (D.hover) { clearTimeout(D.hover.t); D.hover = null; } };
94
+ const hover = (row) => {
95
+ const at = row && row.dataset.folded === '1' ? row : null;
96
+ if (D.hover && D.hover.row === at) return;
97
+ unhover();
98
+ if (!at) return;
99
+ D.hover = {row: at, t: setTimeout(() => {
100
+ D.hover = null;
101
+ if (at.dataset.folded === '1') at.querySelector(':scope > .dstf-chev-slot > .dstf-chev')?.click();
102
+ }, __REST__)};
103
+ };
104
+ const clear = (rows) => {
105
+ rows.querySelectorAll('.dstf-drop').forEach(el => el.classList.remove(
106
+ 'dstf-drop', 'dstf-drop-before', 'dstf-drop-onto', 'dstf-drop-no'));
107
+ say(rows, null);
108
+ };"""
109
+ _DRAG_START = """(e) => {
110
+ __COMMON__
111
+ const grip = e.target.closest && e.target.closest('.dstf-grip');
112
+ const row = grip && grip.closest('[data-tree-path]');
113
+ if (!row) return;
114
+ D.src = row.dataset.treePath;
115
+ D.rev = e.currentTarget.dataset.rev;
116
+ D.map = {};
117
+ e.dataTransfer.effectAllowed = 'move';
118
+ e.dataTransfer.setData(MIME, D.src);
119
+ e.dataTransfer.setDragImage(row, 12, row.offsetHeight / 2);
120
+ emit(D.src);
121
+ }"""
122
+ _DRAG_OVER = """(e) => {
123
+ __COMMON__
124
+ if (!D.src || !ours(e)) return;
125
+ const row = e.target.closest && e.target.closest('[data-tree-path]');
126
+ hover(row);
127
+ // Over no row (a form row, a slot, a move's line): no place, nothing drawn.
128
+ if (!row) { clear(e.currentTarget); return; }
129
+ const zone = zoneOf(row, e);
130
+ const why = (D.map[row.dataset.treePath] || {})[zone];
131
+ clear(e.currentTarget);
132
+ row.classList.add('dstf-drop');
133
+ if (why === '') return; // where it already is: nothing drawn, no drop
134
+ if (why) {
135
+ row.classList.add('dstf-drop-no');
136
+ say(e.currentTarget, why);
137
+ e.dataTransfer.dropEffect = 'none';
138
+ return;
139
+ }
140
+ e.preventDefault();
141
+ e.dataTransfer.dropEffect = 'move';
142
+ row.classList.add('dstf-drop-' + zone);
143
+ }"""
144
+ _DRAG_LEAVE = """(e) => {
145
+ __COMMON__
146
+ if (!e.currentTarget.contains(e.relatedTarget)) { unhover(); clear(e.currentTarget); }
147
+ }"""
148
+ _DROP = """(e) => {
149
+ __COMMON__
150
+ if (!ours(e)) return;
151
+ const row = e.target.closest && e.target.closest('[data-tree-path]');
152
+ const src = D.src;
153
+ unhover();
154
+ clear(e.currentTarget);
155
+ D.src = null;
156
+ if (!row || !src) return;
157
+ e.preventDefault();
158
+ const zone = zoneOf(row, e);
159
+ if ((D.map[row.dataset.treePath] || {})[zone] != null) return;
160
+ emit({src: src, target: row.dataset.treePath, zone: zone, rev: D.rev});
161
+ }"""
162
+ _DRAG_END = """(e) => {
163
+ __COMMON__
164
+ unhover();
165
+ clear(e.currentTarget);
166
+ D.src = null;
167
+ }"""
168
+
169
+
170
+ #: The hovered branch (U5, Y2), drawn in the browser: hovering a group's
171
+ #: row deepens that group's level bar on its row and every row under it; a
172
+ #: criterion's row does the same for its parent group; the root, and a
173
+ #: criterion right under it, deepen nothing. Marked on the BAR, never the
174
+ #: row: a server repaint of a row's classes (`row.classes`) writes the
175
+ #: row's whole class attribute and takes a client-set row class with it —
176
+ #: the U5 spike measured it — and the server never re-classes a bar.
177
+ _BRANCH = """(e) => {
178
+ const rows = e.currentTarget;
179
+ const row = e.target.closest && e.target.closest('[data-tree-path]');
180
+ let group = null;
181
+ if (row && row.dataset.root !== '1') {
182
+ const p = row.dataset.group === '1' ? row.dataset.treePath
183
+ : row.dataset.treePath.slice(0, row.dataset.treePath.lastIndexOf('/children/'));
184
+ group = rows.querySelector(`[data-tree-path="${p}"]`);
185
+ if (group && group.dataset.root === '1') group = null;
186
+ }
187
+ // The rows' revision in the key: rows rebuilt under a resting pointer
188
+ // are new rows, and are marked again.
189
+ const at = group ? group.dataset.treePath : '';
190
+ const key = `${rows.dataset.rev}|${at}`;
191
+ if (rows.dataset.branch === key) return;
192
+ rows.querySelectorAll('.dstf-lvl-on').forEach(b => b.classList.remove('dstf-lvl-on'));
193
+ rows.dataset.branch = key;
194
+ if (!group) return;
195
+ const k = Number(group.dataset.depth);
196
+ const mark = (r) => {
197
+ const b = r.querySelector(':scope > .dstf-bars').children[k - 1];
198
+ if (b) b.classList.add('dstf-lvl-on');
199
+ };
200
+ mark(group);
201
+ rows.querySelectorAll(`[data-tree-path^="${at}/"]`).forEach(mark);
202
+ }"""
203
+ _BRANCH_OFF = """(e) => {
204
+ const rows = e.currentTarget;
205
+ rows.querySelectorAll('.dstf-lvl-on').forEach(b => b.classList.remove('dstf-lvl-on'));
206
+ rows.dataset.branch = '';
207
+ }"""
208
+
209
+ #: A group folded or unfolded in the page (U5, Q3): its row's
210
+ #: ``data-folded``, and every row and form slot under a folded group
211
+ #: ``hidden`` — attributes, never classes (E2): a server repaint of a
212
+ #: row's classes writes its whole class attribute (the U5 spike) and leaves
213
+ #: its other attributes alone. No rows are rebuilt, so a drag's revision
214
+ #: stays (U4 W5). The server is told, records it in the tab and sets the
215
+ #: same attributes, so a later rebuild draws them too.
216
+ _FOLD_CLICK = """(e) => {
217
+ e.stopPropagation(); // a chevron's click is never its row's
218
+ const row = e.target.closest('[data-tree-path]');
219
+ if (!row) return;
220
+ const rows = row.parentElement;
221
+ const on = row.dataset.folded !== '1';
222
+ if (on) row.dataset.folded = '1'; else delete row.dataset.folded;
223
+ const chev = e.currentTarget;
224
+ chev.setAttribute('aria-expanded', on ? 'false' : 'true');
225
+ chev.setAttribute('aria-label', (on ? 'Unfold ' : 'Fold ') + chev.dataset.name);
226
+ const shut = [...rows.querySelectorAll(':scope > [data-folded="1"]')].map(
227
+ r => r.dataset.treePath + '/');
228
+ rows.querySelectorAll(':scope > [data-tree-path], :scope > [data-slot-of]').forEach(el => {
229
+ const p = el.dataset.treePath || el.dataset.slotOf;
230
+ el.hidden = shut.some(s => p.startsWith(s));
231
+ });
232
+ emit(on);
233
+ }"""
234
+
235
+ #: A click on a row's own control is the control's, never the row's (U5):
236
+ #: the row's click opens its editor or its fields.
237
+ _STOP = "(e) => e.stopPropagation()"
238
+
239
+ #: Enter or Space on a row that is a button (U5, F5) is its click — on the
240
+ #: row itself, never bubbled up from a control inside it; Space's default
241
+ #: would scroll the page.
242
+ _ROW_KEYS = (
243
+ "(e) => { if (e.target === e.currentTarget && "
244
+ "(e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); emit(); } }"
245
+ )
246
+
247
+ #: The drag's own MIME type (`_DRAG_COMMON`).
248
+ MIME = "application/x-dstf-node"
249
+
250
+ #: How long a drag rests on a folded group before it unfolds (Q10), ms.
251
+ REST_MS = 700
252
+
253
+
254
+ def _drag_js(handler: str) -> str:
255
+ return (
256
+ handler.replace("__COMMON__", _DRAG_COMMON)
257
+ .replace("__MIME__", json.dumps(MIME))
258
+ .replace("__REST__", str(REST_MS))
259
+ )
260
+
261
+
262
+ def shape_of(view: dict | None) -> tuple[tuple[str, str, str, str], ...]:
263
+ """What a row is drawn from and cannot be repainted through. The paths
264
+ are what decides the rows — one per node, painted by its path — and the
265
+ kind and the leaf's variable ride along harmlessly: a row is built for
266
+ that node's selection and controls, so a row whose kind or variable
267
+ moved is one worth rebuilding anyway. The combinator is in it because
268
+ the controls read it: `+` is grey under a warn or a weighted, and a
269
+ node edited from weighted to and kept a grey `+` with the weighted
270
+ reason (the Task 6 review). The metric is NOT in it: a mode switch
271
+ repaints the row's name in place."""
272
+ return tuple(
273
+ (
274
+ str(node.get("path")),
275
+ str(node.get("kind")),
276
+ str(node.get("variable")),
277
+ str(node.get("combinator")),
278
+ )
279
+ for node in nodes_of(view)
280
+ )
281
+
282
+
283
+ def leaf_status(leaf: dict) -> str:
284
+ """One of "pass", "fail", "warn", "queued": an advisory failure is the
285
+ warn one — a soft check is not a regression."""
286
+ passed = leaf.get("passed")
287
+ if passed is True:
288
+ return "pass"
289
+ if passed is False:
290
+ return "warn" if leaf.get("advisory") else "fail"
291
+ return "queued"
292
+
293
+
294
+ def root_status(node: dict) -> str:
295
+ """A combinator's own verdict, the evaluator's — never the tally of its
296
+ leaves: an `or` passes on one child, a `warn` on none."""
297
+ passed = node.get("passed")
298
+ return "pass" if passed is True else "fail" if passed is False else "queued"
299
+
300
+
301
+ def node_status(node: dict) -> str:
302
+ return leaf_status(node) if node.get("kind") == "leaf" else root_status(node)
303
+
304
+
305
+ def leaf_readout(leaf: dict) -> str:
306
+ """The CLI's own words for the score: the criterion the leaf was held
307
+ to — which ALREADY carries the score ("NRMSE 1.531e-01 vs tolerance
308
+ 1.000e-02 → FAIL"), so the display beside it said the number twice (the
309
+ first screenshot) — else the display alone. A sentinel's criterion says
310
+ the reason itself ("scalar leaf not scored — …"), so the error is not a
311
+ branch of its own. The trailing verdict is not stripped: `tube` and
312
+ `range` do not end with it ("… → FAIL (requires 0)"), so a strip would
313
+ read the string wrong for half the modes."""
314
+ return str(leaf.get("criterion") or leaf.get("score_display") or "")
315
+
316
+
317
+ def root_text(node: dict) -> str:
318
+ """What the root IS, in the closed line. A staged root leaf is the
319
+ test's one criterion — its metric and variable are already the line's
320
+ only jump — and a combinator names itself and counts the LEAVES under
321
+ it, however deep: `and[k-of-n[l, l], l]` is 3 criteria, not 2."""
322
+ return "1 criterion" if node.get("kind") == "leaf" else node_text(node)
323
+
324
+
325
+ def summary_text(view: dict) -> tuple[str, list[dict], int]:
326
+ """The closed line's three parts: the tally ("0 pass · 2 fail", plus an
327
+ advisory and an unscored count when either is non-zero), the failing
328
+ leaves the line NAMES — hard failures before advisory ones, at most
329
+ `NAMED` of them — and how many failures are left over for "+k more".
330
+ Board 1A draws each failure's name once, and it is the thing that
331
+ jumps, so the tally does not repeat the names."""
332
+ if view.get("unscored"):
333
+ # Nothing scored because of a run (D151): the word, and no tally.
334
+ return str(view["unscored"]), [], 0
335
+ leaves = leaves_of(view)
336
+ statuses = [leaf_status(leaf) for leaf in leaves]
337
+ hard = [leaf for leaf, st in zip(leaves, statuses) if st == "fail"]
338
+ advisory = [leaf for leaf, st in zip(leaves, statuses) if st == "warn"]
339
+ parts = [f"{statuses.count('pass')} pass"]
340
+ if hard:
341
+ parts.append(f"{len(hard)} fail")
342
+ if advisory:
343
+ parts.append(f"{len(advisory)} advisory")
344
+ if statuses.count("queued"):
345
+ parts.append(f"{statuses.count('queued')} unscored")
346
+ failed = hard + advisory
347
+ return " · ".join(parts), failed[:NAMED], max(len(failed) - NAMED, 0)
348
+
349
+
350
+ def fold_text(node: dict) -> str:
351
+ """What a folded group's row adds (Q4, E6): its criteria, however deep,
352
+ counted by verdict — "· 3 hidden · 2 pass · 1 fail", with an advisory
353
+ and an unscored count when either is non-zero, as the closed line's
354
+ tally. A folded group inside it is inside it: each criterion is counted
355
+ once."""
356
+ statuses = [leaf_status(leaf) for leaf in leaves_of(node)]
357
+ parts = [f"{len(statuses)} hidden", f"{statuses.count('pass')} pass"]
358
+ for status, word in (
359
+ ("fail", "fail"),
360
+ ("warn", "advisory"),
361
+ ("queued", "unscored"),
362
+ ):
363
+ if statuses.count(status):
364
+ parts.append(f"{statuses.count(status)} {word}")
365
+ return "· " + " · ".join(parts)
366
+
367
+
368
+ def show_tip(leaf: dict) -> str:
369
+ """The ⤓'s name (U5, Q7): where it shows the criterion — a statistics
370
+ criterion is drawn on the Statistics card, any other on its plot."""
371
+ statistic = reads_a_statistic(str(leaf.get("metric")), leaf.get("params") or {})
372
+ return "Show on the Statistics card" if statistic else "Show on its plot"
373
+
374
+
375
+ def _ancestors(path: str) -> list[str]:
376
+ """Every proper ancestor's path of ``path``, the root's included."""
377
+ out = []
378
+ while "/children/" in path:
379
+ path = path.rsplit("/children/", 1)[0]
380
+ out.append(path)
381
+ return out
382
+
383
+
384
+ def _set_prop(element: ui.element, key: str, value: str | bool | None) -> bool:
385
+ """``element``'s prop ``key`` set to ``value`` (None: removed), and
386
+ whether it moved; the caller sends the element once."""
387
+ if element.props.get(key) == value:
388
+ return False
389
+ if value is None:
390
+ del element.props[key]
391
+ else:
392
+ element.props[key] = value
393
+ return True
394
+
395
+
396
+ class _Row:
397
+ """One node's row, and what a repaint writes into it."""
398
+
399
+ def __init__(
400
+ self,
401
+ pill: Pill,
402
+ text: ui.label,
403
+ readout: ui.label | None,
404
+ lead: ui.element | None = None,
405
+ summary: ui.label | None = None,
406
+ chevron: ui.element | None = None,
407
+ show: tuple[ui.element, ui.tooltip] | None = None,
408
+ ) -> None:
409
+ self.pill, self.text, self.readout, self.lead = pill, text, readout, lead
410
+ #: A group's fold summary (Q4), shown by CSS while its row is
411
+ #: folded: painted every time, so a fold needs no round trip.
412
+ self.summary = summary
413
+ #: A group's chevron (Q3): named by the group's text.
414
+ self.chevron = chevron
415
+ #: A criterion's ⤓ and its tooltip (Q7): named by where it is
416
+ #: drawn, which a mode switch moves without rebuilding the row.
417
+ self.show = show
418
+
419
+ def paint(self, node: dict) -> None:
420
+ if self.summary is not None:
421
+ self.summary.set_text(fold_text(node))
422
+ if self.chevron is not None:
423
+ name = node_text(node)
424
+ if self.chevron.props.get("data-name") != name:
425
+ self.chevron.props["data-name"] = name
426
+ verb = (
427
+ "Fold"
428
+ if self.chevron.props.get("aria-expanded") == "true"
429
+ else "Unfold"
430
+ )
431
+ self.chevron.props["aria-label"] = f"{verb} {name}"
432
+ self.chevron.update()
433
+ self.pill.set(node_status(node))
434
+ # A lead holding the kind (a combinator's dropdown) leaves the text
435
+ # the count; an empty one — a host that drew only its why-line —
436
+ # leaves the kind in the text, so it never disappears.
437
+ led = self.lead is not None and bool(self.lead.default_slot.children)
438
+ self.text.set_text(count_text(node) if led else node_text(node))
439
+ if self.show is not None:
440
+ button, tip = self.show
441
+ said = show_tip(node)
442
+ if _set_prop(button, "aria-label", said):
443
+ button.update()
444
+ tip.set_text(said)
445
+ if self.readout is not None:
446
+ # Cut to one line with an ellipsis: its title holds the whole.
447
+ said = leaf_readout(node)
448
+ self.readout.set_text(said)
449
+ if _set_prop(self.readout, "title", said):
450
+ self.readout.update()
451
+
452
+
453
+ class TreeCard:
454
+ def __init__(self, page: TestPage) -> None:
455
+ self.page = page
456
+ self.is_open = False
457
+ self.named: list[dict] = []
458
+ self.root_pill: Pill | None = None
459
+ self.hosts: list[InlineHost] = []
460
+ self.painters: dict[str, _Row] = {}
461
+ #: Each node's row by its path: what a reveal brings into view.
462
+ self.rows_by_path: dict[str, ui.row] = {}
463
+ #: Each node's row target by its path: where a drop lands and a
464
+ #: drag's zones come from (`Panel.movable`).
465
+ self.targets: dict[str, RowTarget] = {}
466
+ #: A move's line, under the moved row (`say`).
467
+ self.said: ui.label | None = None
468
+ #: The rows' revision, one up at each rebuild: what a drag carries
469
+ #: from its start and its drop is checked against (`dropped`).
470
+ self.revision = 0
471
+ self._shape: tuple[tuple[str, str, str, str], ...] = ()
472
+ #: Each group's chevron by its path (U5, Q3): the root has none.
473
+ self.chevrons: dict[str, ui.element] = {}
474
+ #: The criteria's rows by path: a click on each opens its editor.
475
+ self.leaves: set[str] = set()
476
+ #: The one criterion editor opened under a tree row (U5, Y4): its
477
+ #: leaf, its host and box, the leaf as authored when its fields
478
+ #: were built, and its "Edit on its plot" link's place and whether
479
+ #: it is drawn.
480
+ self.editor_path: str | None = None
481
+ self.editor: InlineHost | None = None
482
+ self.editor_box: ui.column | None = None
483
+ self.link_slot: ui.element | None = None
484
+ self._edited: str | None = None
485
+ self._link = False
486
+ with ui.card().classes("dstf-card dstf-tree w-full gap-1").mark("tree"):
487
+ self.line = ui.row().classes("dstf-tree-line items-center gap-3 w-full")
488
+ self.rows = ui.column().classes("w-full gap-0").mark("tree:rows")
489
+ #: What a screen reader is told of a move (`say`): ONE region,
490
+ #: there from the start and visually hidden — a live region is
491
+ #: read out when its text changes, and one that arrives holding
492
+ #: its text already is not. The line under the row is not it:
493
+ #: made for each move, it needed a timer to arrive empty first.
494
+ self.live = (
495
+ ui.label("")
496
+ .classes("dstf-live")
497
+ .props('role="status" aria-live="polite"')
498
+ .mark("moved-live")
499
+ )
500
+ rows = self.rows
501
+ rows.on("dragstart", self._drag_start, js_handler=_drag_js(_DRAG_START))
502
+ rows.on("dragover", js_handler=_drag_js(_DRAG_OVER))
503
+ rows.on("dragleave", js_handler=_drag_js(_DRAG_LEAVE))
504
+ rows.on("drop", self._drop, js_handler=_drag_js(_DROP))
505
+ rows.on("dragend", js_handler=_drag_js(_DRAG_END))
506
+ rows.on("mouseover", js_handler=_BRANCH)
507
+ rows.on("mouseleave", js_handler=_BRANCH_OFF)
508
+ # Not refreshed here: the page builds this card before it knows
509
+ # whether the test is one of the open project's, and `_view` raises
510
+ # for a model the project has not got. The page's own `sync` fills
511
+ # it, or `forget` leaves it saying nothing.
512
+ self.rows.set_visibility(False)
513
+
514
+ def _view(self) -> dict | None:
515
+ return self.page.shell.session.tree_view(self.page.model_id)
516
+
517
+ # -- the drag ------------------------------------------------------------------
518
+
519
+ def zones_for(self, src: str) -> dict[str, dict[str, str | None]]:
520
+ """What a drag from ``src``'s row draws over every row: its panel's
521
+ answer, read NOW — the tree may have moved since the rows were
522
+ built — and nothing where no panel made that row draggable."""
523
+ target = self.targets.get(src)
524
+ if target is None or target.zones is None:
525
+ return {}
526
+ return target.zones()
527
+
528
+ def _drag_start(self, e: Any) -> None:
529
+ src = str(e.args)
530
+ zones = self.zones_for(src)
531
+ # Only while that drag is the browser's: a reply arriving after
532
+ # another drag started would draw it with this one's map.
533
+ self.page.client.run_javascript(
534
+ f"if (window.dstfMove && window.dstfMove.src === {json.dumps(src)})"
535
+ f" window.dstfMove.map = {json.dumps(zones)};"
536
+ )
537
+
538
+ def _drop(self, e: Any) -> None:
539
+ self.dropped(e.args)
540
+
541
+ def dropped(self, args: dict) -> None:
542
+ """A drop the browser allowed: the row it landed on hands it to the
543
+ panel that registered for it there, if one did. A drag that started
544
+ on rows rebuilt since is refused under that row: its paths name the
545
+ nodes of a tree that is no longer there."""
546
+ target = self.targets.get(str(args.get("target")))
547
+ if target is None or target.on_drop is None:
548
+ return
549
+ if str(args.get("rev")) != str(self.revision):
550
+ target.refuse(MoveRefused(TREE_CHANGED))
551
+ return
552
+ target.on_drop(str(args.get("src")), str(args.get("zone")))
553
+
554
+ def say(self, path: str, text: str) -> None:
555
+ """A move's line under ``path``'s row, the row brought into view and
556
+ flashed (V4), and the same text told to the card's live region. One
557
+ at a time; `unsay` takes the line away."""
558
+ self.unsay()
559
+ target, row = self.targets.get(path), self.rows_by_path.get(path)
560
+ if target is None or target.slot is None or row is None:
561
+ return
562
+ self.open_path(path) # R2: a tree event shows its row
563
+ depth = path.count("/children/")
564
+ with target.slot:
565
+ # Under the row's pill: its bars, the chevron's place and the
566
+ # handle's (E4).
567
+ self.said = (
568
+ ui.label(text)
569
+ .classes("dstf-moved text-xs")
570
+ .style(f"padding-left: {pill_left(depth)}px")
571
+ .mark("moved-note")
572
+ )
573
+ self.live.set_text(text if self.live.text != text else text + _AGAIN)
574
+ # To THIS page's client: `sync` runs from the Shell's timer, whose
575
+ # context is no page's (`TestPage._reveal`). The row, then the line
576
+ # under it: the row alone, scrolled to its nearest edge from above,
577
+ # left the line below the fold.
578
+ self.page.client.run_javascript(
579
+ f'const r = document.getElementById("{row.html_id}");'
580
+ f'const l = document.getElementById("{self.said.html_id}");'
581
+ 'if (r) { r.scrollIntoView({block: "nearest"});'
582
+ ' r.classList.remove("dstf-flash"); void r.offsetWidth;'
583
+ ' r.classList.add("dstf-flash"); }'
584
+ 'if (l) l.scrollIntoView({block: "nearest"});'
585
+ )
586
+
587
+ def unsay(self) -> None:
588
+ if self.said is not None and not self.said.is_deleted:
589
+ self.said.delete()
590
+ self.said = None
591
+
592
+ # -- folding (U5) --------------------------------------------------------------
593
+
594
+ @property
595
+ def folded(self) -> set[str]:
596
+ """This test's folded groups, the tab's (Q8)."""
597
+ return set(self.page.state.tree_folded.get(self.page.model_id, []))
598
+
599
+ def _keep(self, folded: set[str]) -> None:
600
+ store = self.page.state.tree_folded
601
+ if folded:
602
+ store[self.page.model_id] = sorted(folded)
603
+ else:
604
+ store.pop(self.page.model_id, None)
605
+
606
+ def _apply_folds(self) -> None:
607
+ """The rows as the tab's folded set says — the attributes the page's
608
+ script sets, by a props update of the elements that moved and no
609
+ rebuild (E2), so the two agree and the drag's revision stands. A
610
+ form under a row that is now hidden goes (E6: a form nobody can see
611
+ is no form, as Hide tree)."""
612
+ folded = self.folded
613
+ for path, row in self.rows_by_path.items():
614
+ hide = any(up in folded for up in _ancestors(path))
615
+ target = self.targets.get(path)
616
+ slot = target.slot if target is not None else None
617
+ if slot is not None and _set_prop(slot, "hidden", True if hide else None):
618
+ slot.update()
619
+ moved = _set_prop(row, "hidden", True if hide else None)
620
+ chev = self.chevrons.get(path)
621
+ if chev is not None:
622
+ on = path in folded
623
+ moved |= _set_prop(row, "data-folded", "1" if on else None)
624
+ name = chev.props.get("data-name", "")
625
+ turned = _set_prop(chev, "aria-expanded", "false" if on else "true")
626
+ turned |= _set_prop(
627
+ chev, "aria-label", f"{'Unfold' if on else 'Fold'} {name}"
628
+ )
629
+ if turned:
630
+ chev.update()
631
+ if moved:
632
+ row.update()
633
+ if hide and target is not None:
634
+ self.page.drop_row(target)
635
+ if self.editor_path == path:
636
+ # The editor under it goes the same way (F9).
637
+ self.page.drop_editor()
638
+
639
+ def _fold(self, path: str, e: Any) -> None:
640
+ """A chevron's click (or a drag resting on its row, Q10): the page
641
+ has folded or unfolded the group already; the tab records it."""
642
+ folded = self.folded
643
+ on = e.args if isinstance(e.args, bool) else path not in folded
644
+ if on:
645
+ folded.add(path)
646
+ else:
647
+ folded.discard(path)
648
+ self._keep(folded)
649
+ self._apply_folds()
650
+
651
+ def fold_all(self, on: bool) -> None:
652
+ """Collapse all / Expand all (Y7): every group of the test but the
653
+ root, which is the tree (Show / Hide tree folds it)."""
654
+ self._keep(set(self.chevrons) if on else set())
655
+ self._apply_folds()
656
+
657
+ def open_path(self, path: str) -> None:
658
+ """R2: unfold every folded group ``path`` is inside, and nothing
659
+ else — a group at ``path`` itself stays as it is. Only for what the
660
+ tree itself shows under a row (a move's line, a refusal, a form);
661
+ what is done outside the tree leaves the folds alone."""
662
+ folded = self.folded
663
+ opened = folded - set(_ancestors(path))
664
+ if opened != folded:
665
+ self._keep(opened)
666
+ self._apply_folds()
667
+
668
+ def rekey(self, paths: dict[str, str]) -> None:
669
+ """An edit went through (R1): every folded group is folded where the
670
+ edit put it (``paths``: each kept node, before -> after); one the
671
+ edit removed is dropped. Applied by the sync that redraws the rows
672
+ — the rows standing now are the old tree's."""
673
+ self._keep({paths[p] for p in self.folded if p in paths})
674
+
675
+ def toggle(self) -> None:
676
+ self.is_open = not self.is_open
677
+ self.refresh(self._view())
678
+
679
+ def forget(self) -> None:
680
+ """The page's test is gone (another project was opened under it):
681
+ the card says nothing rather than the last project's verdicts, and
682
+ `refresh` is not the way there — its "nothing scored" line asks the
683
+ page WHY, which for a test this project does not have is a sentence
684
+ about a baseline that was never the reason."""
685
+ self.close_editor()
686
+ for host in self.hosts:
687
+ host.clear()
688
+ self.hosts, self.painters, self.named = [], {}, []
689
+ self.rows_by_path, self.targets, self.chevrons = {}, {}, {}
690
+ self.leaves = set()
691
+ self.root_pill = None
692
+ self.is_open = False
693
+ self._shape = ()
694
+ self.rows.clear()
695
+ self.rows.set_visibility(False)
696
+ self.line.clear()
697
+
698
+ def refresh(self, view: dict | None) -> None:
699
+ """Follow the Session: the line every time (it is five elements and
700
+ the toggle's own text is one of them), the rows in place unless
701
+ their shape moved. Open or closed is the card's and survives this.
702
+ ``view`` is the page's ONE `tree_view` read of this sync — the cards
703
+ draw their criteria rows from the same dict."""
704
+ if view is None:
705
+ self.is_open = False
706
+ self._line(view)
707
+ self.rows.set_visibility(self.is_open)
708
+ if not self.is_open:
709
+ # A form nobody can see is no form: hiding the tree takes a
710
+ # form open under one of its rows away (the Task 5 review).
711
+ for host in self.hosts:
712
+ if host.row is not None:
713
+ self.page.drop_row(host.row)
714
+ # And an editor nobody can see is no editor (U5).
715
+ if self.editor is not None:
716
+ self.page.drop_editor()
717
+ if view is None or not self.is_open:
718
+ return
719
+ shape = shape_of(view)
720
+ if shape != self._shape or not self.painters:
721
+ self._shape = shape
722
+ self._build_rows(view)
723
+ return
724
+ for host in self.hosts:
725
+ # The rows stand; what a node's controls MAY do does not (an
726
+ # `available` answer can move without the shape moving).
727
+ # Before the paint: a host rebuilt to its why-line empties its
728
+ # row's lead, and the paint then reads the kind into the text.
729
+ host.follow()
730
+ for node in nodes_of(view):
731
+ self.painters[str(node.get("path"))].paint(node)
732
+ # The folded set may have been re-keyed under rows whose shape did
733
+ # not move (twin groups trading places, the U5 review): it is drawn
734
+ # on the rows standing, not left on the ones it was folded on.
735
+ self._apply_folds()
736
+ # A host rebuilt to its why-line takes its row's click away.
737
+ self.paint_clicks()
738
+ self._follow_editor()
739
+
740
+ def paint_clicks(self) -> None:
741
+ """Each row whose click opens something — a criterion's (its
742
+ editor), a group's a panel registered one for (its fields) — is a
743
+ button to the keyboard and says whether that is open under it now
744
+ (U5, F5); any other row is neither. Sent only where it moved."""
745
+ form = self.page.form[0] if self.page.form is not None else None
746
+ for path, target in self.targets.items():
747
+ row = target.row
748
+ if row.is_deleted:
749
+ continue
750
+ on = path in self.leaves or target.click is not None
751
+ shown = None
752
+ if on:
753
+ shown = (
754
+ "true" if self.editor_path == path or form is target else "false"
755
+ )
756
+ moved = _set_prop(row, "role", "button" if on else None)
757
+ moved |= _set_prop(row, "tabindex", "0" if on else None)
758
+ moved |= _set_prop(row, "aria-expanded", shown)
759
+ if moved:
760
+ row.update()
761
+
762
+ # -- the criterion editor under a tree row (U5) -----------------------------
763
+
764
+ def open_editor(self, path: str) -> bool:
765
+ """``path``'s editor under its tree row: Done, the "Edit on its
766
+ plot" link where its plot can do more, then every capability placed
767
+ at TREE_CRITERION (`PlotCard.open_editor`'s pattern with no plot).
768
+ The page keeps the one-editor rule: it closes what else is open
769
+ first. False where no row is drawn at ``path``."""
770
+ target, row = self.targets.get(path), self.rows_by_path.get(path)
771
+ if target is None or row is None or target.slot is None:
772
+ return False
773
+ self.close_editor()
774
+ self.open_path(path) # R2: an editor under a tree row is shown
775
+ model = self.page.model_id
776
+ self.editor_path = path
777
+ row.classes(add="dstf-crit-open")
778
+ with target.slot:
779
+ self.editor_box = ui.column().classes("dstf-editor gap-2")
780
+ with self.editor_box:
781
+ with ui.row().classes("items-center no-wrap gap-3 w-full"):
782
+ ui.label(
783
+ "Edits are staged as you make them; Save writes them."
784
+ ).classes("dstf-why")
785
+ ui.space()
786
+ self.link_slot = ui.element("span")
787
+ ui.button("Done", on_click=self.page.close_editor).props(
788
+ "flat dense no-caps"
789
+ ).mark(f"tree-editor-done:{path}")
790
+ self.editor = InlineHost(
791
+ self.page.shell,
792
+ Placement.TREE_CRITERION,
793
+ partial(Selection, tests=frozenset({model}), leaf_path=path),
794
+ figure=None,
795
+ marker=f"tree-editor:{path}",
796
+ on_applied=self._applied,
797
+ apply=self.page.apply,
798
+ )
799
+ self.editor.build()
800
+ self._edited = self._edited_now()
801
+ self._link = False
802
+ self._paint_link()
803
+ self.paint_clicks()
804
+ return True
805
+
806
+ def close_editor(self) -> None:
807
+ """The editor goes, its column only: the row's slot also holds its
808
+ host's error labels and a move's line."""
809
+ if self.editor is not None:
810
+ self.editor.clear()
811
+ if self.editor_box is not None and not self.editor_box.is_deleted:
812
+ self.editor_box.delete()
813
+ row = self.rows_by_path.get(self.editor_path or "")
814
+ if row is not None and not row.is_deleted:
815
+ row.classes(remove="dstf-crit-open")
816
+ was = self.editor_path
817
+ self.editor, self.editor_box, self.link_slot = None, None, None
818
+ self.editor_path, self._edited, self._link = None, None, False
819
+ if was is not None:
820
+ self.paint_clicks()
821
+
822
+ def _applied(self, name: str) -> None:
823
+ """A panel's own submit rebuilds every OTHER panel, leaving the one
824
+ being typed in; the leaf recorded as it now stands, so its own edit
825
+ is not read as one staged elsewhere."""
826
+ if self.editor is None:
827
+ return
828
+ self.editor.build(skip=name)
829
+ self._edited = self._edited_now()
830
+
831
+ def _edited_now(self) -> str | None:
832
+ """The open editor's leaf as AUTHORED — staged edits included (the
833
+ Statistics card's rule: a test with nothing compared has no view)."""
834
+ if self.editor_path is None:
835
+ return None
836
+ session = self.page.shell.session
837
+ leaf = node_or_none(session.tree(self.page.model_id), self.editor_path)
838
+ return None if leaf is None else json.dumps(leaf, sort_keys=True, default=str)
839
+
840
+ def _follow_editor(self) -> None:
841
+ if self.editor is None:
842
+ return
843
+ edited = self._edited_now()
844
+ if edited != self._edited:
845
+ # The leaf moved by an edit staged ELSEWHERE (a Discard, its
846
+ # plot's drag): fields showing the undone value stage it back.
847
+ self.editor.build()
848
+ self._edited = edited
849
+ else:
850
+ self.editor.follow()
851
+ self._paint_link()
852
+
853
+ def _paint_link(self) -> None:
854
+ """Q12 (F2): "Edit on its plot" while the plot that holds the
855
+ criterion — the first, as a jump opens it — offers more than this
856
+ editor does: a capability placed at CRITERION and not here is
857
+ available there (one that drags its handles). Asked of the
858
+ placements, never of a capability by name; redrawn only when its
859
+ answer moves (a mode switched to one with no handles takes it).
860
+ Bound to no plot: the page finds the plot when it is clicked
861
+ (`TestPage.edit_on_plot`) — this runs in a sync BEFORE the cards
862
+ rebuild, and an index read here named the plot the criterion was
863
+ on until a later sync drew the link again (J3)."""
864
+ if self.link_slot is None or self.link_slot.is_deleted:
865
+ return
866
+ path = self.editor_path
867
+ page, shell = self.page, self.page.shell
868
+ index = next((c.index for c in page.cards if path in c.rows), None)
869
+ if index is not None and path is not None:
870
+ selection = Selection(
871
+ tests=frozenset({page.model_id}),
872
+ variable=page.cards[index].rows[path].variable,
873
+ leaf_path=path,
874
+ )
875
+ more = any(
876
+ avail is True and Placement.TREE_CRITERION not in cap.placements
877
+ for cap, avail in shell.placed(
878
+ Placement.CRITERION, shell.view_for(selection)
879
+ )
880
+ )
881
+ if not more:
882
+ index = None
883
+ shown = index is not None and path is not None
884
+ if shown == self._link:
885
+ return
886
+ self._link = shown
887
+ self.link_slot.clear()
888
+ if not shown or path is None:
889
+ return
890
+ with self.link_slot:
891
+ ui.button(
892
+ "Edit on its plot",
893
+ on_click=partial(page.edit_on_plot, path),
894
+ ).props("flat dense no-caps").mark(f"tree-editor-on-plot:{path}")
895
+
896
+ # -- the closed line -----------------------------------------------------------
897
+
898
+ def _reset_layout(self) -> None:
899
+ """Reset layout, at the line's end while the test's EFFECTIVE entry
900
+ has a ``plots`` key (the owner's ruling, Q8 A) — on both of the
901
+ line's branches: a test with no tree can have extras. It takes the
902
+ context series and the watch plots; the derived plots are the
903
+ tree's, and stay."""
904
+ page = self.page
905
+ if "plots" not in (page.shell.session.spec.entry(page.model_id) or {}):
906
+ return
907
+ ui.space()
908
+ with (
909
+ ui.button("Reset layout", on_click=page.reset_layout)
910
+ .props("flat dense no-caps")
911
+ .mark("tree:reset-layout")
912
+ ):
913
+ ui.tooltip(
914
+ "Stage removing this test's plots key: its context series and "
915
+ "watch plots go"
916
+ )
917
+
918
+ def _line(self, view: dict | None) -> None:
919
+ self.line.clear()
920
+ self.named, self.root_pill = [], None
921
+ with self.line:
922
+ if view is None:
923
+ ui.label(f"nothing scored: {self.page.unscored_reason()}").classes(
924
+ "text-secondary"
925
+ ).mark("tree:summary")
926
+ self._reset_layout()
927
+ return
928
+ ui.button(
929
+ "Hide tree" if self.is_open else "Show tree",
930
+ icon="expand_more" if self.is_open else "chevron_right",
931
+ on_click=self.toggle,
932
+ ).props("flat dense no-caps").mark("tree:toggle")
933
+ self.root_pill = Pill(root_status(view), "tree:root-pill")
934
+ ui.label(root_text(view)).classes("text-weight-medium").mark("tree:root")
935
+ text, self.named, more = summary_text(view)
936
+ ui.label(text).classes("text-secondary").mark("tree:summary")
937
+ for leaf in self.named:
938
+ button = ui.button(
939
+ leaf_name(leaf),
940
+ on_click=partial(self.page.jump_to, str(leaf["path"])),
941
+ ).props("flat dense no-caps")
942
+ with button.mark("tree:jump"):
943
+ # Two criteria on one variable read identically ("range ·
944
+ # x, range · x"): the path is what tells them apart.
945
+ ui.tooltip(str(leaf["path"]))
946
+ if more:
947
+ ui.label(f"+{more} more").classes("text-secondary").mark("tree:more")
948
+ if self.is_open and any(
949
+ n.get("kind") != "leaf" for n in nodes_of(view)[1:]
950
+ ):
951
+ # Y7: beside the tally, while the rows show and hold a group.
952
+ # Icons (H4, the U5 review): their words wrapped the line at
953
+ # 900 px. Named for a screen reader and by a tooltip.
954
+ for icon, on, name, marker in (
955
+ ("unfold_more", False, "Expand all groups", "tree:expand-all"),
956
+ ("unfold_less", True, "Collapse all groups", "tree:collapse-all"),
957
+ ):
958
+ button = (
959
+ ui.button(icon=icon, on_click=partial(self.fold_all, on))
960
+ .props("flat dense round size=sm")
961
+ .mark(marker)
962
+ )
963
+ button.props["aria-label"] = name
964
+ with button:
965
+ ui.tooltip(name)
966
+ self._reset_layout()
967
+
968
+ # -- the rows ------------------------------------------------------------------
969
+
970
+ def _build_rows(self, view: dict) -> None:
971
+ if self.editor is not None:
972
+ # The rows it hangs under go, and the page's one editor with
973
+ # them: the node at its path may be another criterion now.
974
+ self.page.drop_editor()
975
+ for host in self.hosts:
976
+ host.clear()
977
+ self.hosts, self.painters, self.rows_by_path = [], {}, {}
978
+ self.targets, self.chevrons, self.leaves = {}, {}, set()
979
+ # A folded path that names no group any more is dropped (Y6): an
980
+ # edit that said where its nodes went has re-keyed the set already
981
+ # (`rekey`); this is for any other.
982
+ groups = {
983
+ str(n.get("path")) for n in nodes_of(view)[1:] if n.get("kind") != "leaf"
984
+ }
985
+ self._keep(self.folded & groups)
986
+ self.rows.clear()
987
+ self.revision += 1
988
+ self.rows.props["data-rev"] = str(self.revision)
989
+ self.rows.update()
990
+ with self.rows:
991
+ self._row(view, 0, self.folded)
992
+ self.paint_clicks()
993
+
994
+ def _show_icon(self, node: dict, path: str) -> tuple[ui.element, ui.tooltip]:
995
+ """⤓ (Q7), at the start of the criterion's icon cluster: the
996
+ criterion where it is drawn — its plot's row, or its Statistics
997
+ row — scrolled to and flashed; nothing opens. The page owns the
998
+ jump, as it owns the closed line's named buttons. Its click is
999
+ stopped in the browser: the row's own click opens the editor."""
1000
+ tip = show_tip(node)
1001
+ button = (
1002
+ ui.button(icon="vertical_align_bottom")
1003
+ .props("flat dense round size=sm")
1004
+ .classes("dstf-show")
1005
+ .on(
1006
+ "click",
1007
+ partial(self.page.show_criterion, path),
1008
+ js_handler="(e) => { e.stopPropagation(); emit(); }",
1009
+ )
1010
+ )
1011
+ # Named for a screen reader: its icon's ligature names nothing.
1012
+ button.props["aria-label"] = tip
1013
+ with button:
1014
+ tooltip = ui.tooltip(tip).props('anchor="top middle" self="bottom middle"')
1015
+ button.mark(f"show:{path}")
1016
+ return button, tooltip
1017
+
1018
+ def _row(self, node: dict, depth: int, folded: set[str]) -> None:
1019
+ path, model = str(node["path"]), self.page.model_id
1020
+ group = node.get("kind") != "leaf"
1021
+ hide = any(up in folded for up in _ancestors(path))
1022
+ row = ui.row().classes("dstf-tree-row items-center no-wrap gap-3 w-full")
1023
+ self.rows_by_path[path] = row
1024
+ # What the page's scripts read: the row's node, its depth and
1025
+ # whether it is a group (the hovered branch, U5 Y2), and the root's
1026
+ # one drop zone.
1027
+ row.props["data-tree-path"] = path
1028
+ row.props["data-depth"] = str(depth)
1029
+ if group:
1030
+ row.props["data-group"] = "1"
1031
+ if depth == 0:
1032
+ row.props["data-root"] = "1"
1033
+ # The tab's folds, drawn at build as the page's script draws them.
1034
+ if hide:
1035
+ row.props["hidden"] = True
1036
+ if path in folded:
1037
+ row.props["data-folded"] = "1"
1038
+ # The row IS the way in (U5): a criterion's editor opens under it,
1039
+ # a k-of-n's or a weighted's fields — whatever a panel registered
1040
+ # (`Panel.on_click`) — and Enter or Space does the same where the
1041
+ # row is a button (`paint_clicks`). Its own controls stop the click
1042
+ # before it gets here: the chevron, the grip, the lead (the kind
1043
+ # dropdown) and the host.
1044
+ row.on("click", partial(self.page.tree_row_click, path))
1045
+ row.on("keydown", partial(self.page.tree_row_click, path), js_handler=_ROW_KEYS)
1046
+ if not group:
1047
+ self.leaves.add(path)
1048
+ # `--indent` is where a "before" line starts: the row's content,
1049
+ # right of its bars (U4 C15, U5 E4).
1050
+ with row.style(f"--indent: {content_left(depth)}px").mark(f"tree-row:{path}"):
1051
+ # A bar per level (U5, Q1): what says what is under what, in
1052
+ # place of an indent (Y3). Level i's colour cycles every four;
1053
+ # the root draws none.
1054
+ with (
1055
+ ui.element("span")
1056
+ .classes("dstf-bars")
1057
+ .style(f"width: {BAR_STEP * depth}px")
1058
+ .props('aria-hidden="true"')
1059
+ ):
1060
+ for i in range(1, depth + 1):
1061
+ ui.element("span").classes(f"dstf-bar dstf-lvl-{(i - 1) % 4 + 1}")
1062
+ # The chevron's place (Q3), kept on every row — empty on a
1063
+ # criterion — so the pills stay in line. The root's folds with
1064
+ # the card's Show / Hide tree.
1065
+ chevron: ui.element | None = None
1066
+ with ui.element("span").classes("dstf-chev-slot"):
1067
+ if group and depth:
1068
+ on, name = path in folded, node_text(node)
1069
+ chevron = (
1070
+ ui.element("button")
1071
+ .classes("dstf-chev")
1072
+ .props('type="button"')
1073
+ .mark(f"fold:{path}")
1074
+ )
1075
+ # Set as items, not through the props string, whose
1076
+ # parser a quote in a node's text would break.
1077
+ chevron.props["data-name"] = name
1078
+ chevron.props["aria-expanded"] = "false" if on else "true"
1079
+ chevron.props["aria-label"] = f"{'Unfold' if on else 'Fold'} {name}"
1080
+ with chevron:
1081
+ ui.icon("expand_more").props('aria-hidden="true"')
1082
+ chevron.on(
1083
+ "click", partial(self._fold, path), js_handler=_FOLD_CLICK
1084
+ )
1085
+ self.chevrons[path] = chevron
1086
+ # The drag handle's place, before the pill: hidden — its width
1087
+ # kept, so every pill stays in line — until a panel makes the
1088
+ # row draggable (`Panel.movable`). The root's holds no handle:
1089
+ # nothing sits beside it. Hidden by its own class, never
1090
+ # `set_visibility`: Quasar's `.hidden` sits in a cascade layer
1091
+ # that beats an unlayered `!important`, and took the width too.
1092
+ grip = (
1093
+ ui.element("span")
1094
+ .classes("dstf-grip dstf-grip-off")
1095
+ .mark(f"grip:{path}")
1096
+ )
1097
+ grip.on("click", js_handler=_STOP)
1098
+ if depth:
1099
+ grip.props["draggable"] = "true"
1100
+ grip.props["aria-label"] = "Drag to move"
1101
+ grip.props["title"] = "Drag to move"
1102
+ with grip:
1103
+ ui.icon("drag_indicator").props('aria-hidden="true"')
1104
+ pill = Pill(node_status(node), f"tree-pill:{path}")
1105
+ # A combinator's lead place, right after its pill: where its
1106
+ # kind is read (`Panel.lead`) — a leaf has none.
1107
+ lead: ui.element | None = None
1108
+ if group:
1109
+ lead = ui.element("span").classes("dstf-node-lead")
1110
+ lead.on("click", js_handler=_STOP)
1111
+ text = (
1112
+ ui.label(node_text(node))
1113
+ .classes("dstf-tree-text text-body2")
1114
+ .mark(f"tree-text:{path}")
1115
+ )
1116
+ if depth == 0:
1117
+ # Q2: "and · 5 criteria · root" — the "· " is the tag's CSS.
1118
+ ui.label("root").classes("dstf-root-tag").mark("tree:root-tag")
1119
+ summary: ui.label | None = None
1120
+ if chevron is not None:
1121
+ summary = (
1122
+ ui.label(fold_text(node))
1123
+ .classes("dstf-fold-summary")
1124
+ .mark(f"tree-summary:{path}")
1125
+ )
1126
+ readout: ui.label | None = None
1127
+ show: tuple[ui.element, ui.tooltip] | None = None
1128
+ if node.get("kind") == "leaf":
1129
+ readout = ui.label(leaf_readout(node)).classes(
1130
+ "dstf-tree-readout dstf-mono text-xs"
1131
+ )
1132
+ ui.space()
1133
+ if not group:
1134
+ show = self._show_icon(node, path)
1135
+ target = RowTarget(self.page, f"tree:{path}", row, path, None)
1136
+ target.lead = lead
1137
+ # The tree card's rows are where a node moves from (V2).
1138
+ target.drag = True
1139
+ target.grip = grip if depth else None
1140
+ # And, on a group's row, where a click is a panel's to say (Q6,
1141
+ # `Panel.on_click`); a criterion's click is its editor (J4).
1142
+ target.clickable = group
1143
+ self.targets[path] = target
1144
+ host = InlineHost(
1145
+ self.page.shell,
1146
+ Placement.NODE,
1147
+ partial(Selection, tests=frozenset({model}), node_path=path),
1148
+ figure=None,
1149
+ marker=f"node:{path}",
1150
+ row=target,
1151
+ apply=self.page.apply,
1152
+ )
1153
+ # A host drawn inside a row keeps to its own width, or the
1154
+ # cluster sits left of where it belongs (the 3b review, in
1155
+ # Chromium).
1156
+ host.column.classes(remove="w-full", add="dstf-node-host")
1157
+ # The row's form slot, between it and the next node's row; the
1158
+ # host is built once its panels have somewhere to open a row.
1159
+ target.slot = ui.column().classes("w-full gap-0")
1160
+ # What the fold's script hides with the row (`_FOLD_CLICK`).
1161
+ target.slot.props["data-slot-of"] = path
1162
+ if hide:
1163
+ target.slot.props["hidden"] = True
1164
+ host.build()
1165
+ self.hosts.append(host)
1166
+ self.painters[path] = _Row(pill, text, readout, lead, summary, chevron, show)
1167
+ # Once the host has drawn: whether its lead holds the kind decides
1168
+ # the text.
1169
+ self.painters[path].paint(node)
1170
+ for child in node.get("children") or []:
1171
+ self._row(child, depth + 1, folded)