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,468 @@
1
+ """A plot card's folded criteria rows (D152; the owner's R2): under an
2
+ array family's plot, the elements whose criterion is one and the same read
3
+ as ONE row — the metric, the family with the folded indices, a count by
4
+ verdict — whose chevron unfolds it to its members' own rows and whose click
5
+ opens an editor that changes every member at once. Which rows fold is
6
+ `authoring.family_rows.fold_rows`' rule; this draws them and knows none of
7
+ it.
8
+
9
+ The fold is DERIVED from the tree at every paint, and nothing remembers it:
10
+ a member whose criterion is edited is its own row at the next paint, and
11
+ one edited back folds again. So nothing here is rebuilt for it, and no row
12
+ is moved. The criteria's rows stand where the card drew them, flat and in
13
+ tree order; a fold's row is put in before its first member's, and a member
14
+ is that same row with two attributes — ``data-member`` (its indent) and
15
+ ``hidden`` (while its fold is closed). Attributes, never classes: a server
16
+ repaint of a row's classes writes its whole class attribute (the U5 spike),
17
+ and both survive every repaint. Where a row is SHOWN is its CSS ``order``
18
+ in the column: a fold's row, then its members as one block, then — a
19
+ sibling of the fold, never between two of its members — the row of an
20
+ element with a criterion of its own. An editor being typed in is therefore
21
+ never re-parented, moved or redrawn by a row joining or leaving a fold (a
22
+ row moved in the document loses the focus inside it); the cost is that Tab
23
+ goes through the rows in tree order, not in the order shown.
24
+
25
+ Which folds are open is the browser tab's (`TabState.family_unfolded`),
26
+ kept as their members' paths so it follows the members — a fold whose first
27
+ member left it is still the fold that was opened."""
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ from functools import partial
33
+ from typing import TYPE_CHECKING, Any
34
+
35
+ from nicegui import ui
36
+
37
+ from ...reporting.tree_rows import criteria_rows
38
+ from ..capabilities import Placement
39
+ from ..family_rows import Fold, criterion_of, fold_rows, is_fold, leaf_of, tally
40
+ from ..session import Selection
41
+ from ..spec_document import json_equal
42
+ from .actions import InlineHost, RowTarget
43
+ from .tree_card import _set_prop, leaf_status
44
+ from .widgets import MiddleCut, Pill
45
+
46
+ if TYPE_CHECKING:
47
+ from .plot_card import PlotCard
48
+
49
+ #: A folded row's editor hint (the controller's D3): fields only. A handle
50
+ #: on the plot belongs to ONE criterion's drawing, and twenty of them at
51
+ #: one place could not be told apart or dragged as one.
52
+ HINT = "These fields change all {n} criteria. Unfold to drag one on the plot."
53
+
54
+
55
+ class _NoControls:
56
+ """A folded row's place in `PlotCard.rows`' ``host``: it has no node
57
+ controls — `+`, wrap and `−` act on ONE node, and are its members'."""
58
+
59
+ def build(self) -> None:
60
+ pass
61
+
62
+ def follow(self) -> None:
63
+ pass
64
+
65
+ def clear(self) -> None:
66
+ pass
67
+
68
+
69
+ class FoldRow:
70
+ """One folded row. It stands in `PlotCard.rows` under its key
71
+ (`family_rows.fold_key`) beside the criteria's rows, so the page opens,
72
+ reveals, closes and scrolls to it exactly as it does theirs; what a
73
+ criterion's row has and this has not (`taker`, `host`) is here as
74
+ nothing."""
75
+
76
+ def __init__(self, folding: Folding, fold: Fold, crumb_ch: int) -> None:
77
+ card = folding.card
78
+ index, key = card.index, fold.key
79
+ self.folding, self.key, self.fold = folding, key, fold
80
+ self.taker: str | None = None
81
+ self.host = _NoControls()
82
+ #: What the open editor's fields were built from (`stamp`).
83
+ self._edited: str | None = None
84
+ self.row = ui.row().classes("dstf-crit dstf-fold items-center gap-3 w-full")
85
+ # The row IS the way in, as a criterion's is: its editor opens below
86
+ # it, and a second click closes it.
87
+ self.row.on("click", partial(card.page.row_click, index, key))
88
+ with self.row.mark(f"family:{index}:{key}"):
89
+ # In the swatch's place, so every row's name starts in one
90
+ # column. A button: Tab reaches it, Enter and Space toggle it.
91
+ self.chevron = (
92
+ ui.element("button")
93
+ .classes("dstf-chev dstf-fold-chev")
94
+ .props('type="button"')
95
+ .mark(f"family-chev:{index}:{key}")
96
+ .on(
97
+ "click",
98
+ partial(folding.toggle, key),
99
+ js_handler="(e) => { e.stopPropagation(); emit(); }",
100
+ )
101
+ )
102
+ with self.chevron:
103
+ ui.icon("expand_more").props('aria-hidden="true"')
104
+ if fold.crumbs:
105
+ ui.label(fold.crumbs).classes("dstf-crumb dstf-why").style(
106
+ f"min-width: {crumb_ch}ch"
107
+ )
108
+ self.pill = Pill("queued", f"family-pill:{index}:{key}")
109
+ self.name = MiddleCut().classes(
110
+ "dstf-crit-name text-body2 text-weight-medium"
111
+ )
112
+ self.count = (
113
+ ui.label()
114
+ .classes("dstf-mono text-xs")
115
+ .mark(f"family-count:{index}:{key}")
116
+ )
117
+ self.slot = ui.column().classes("w-full gap-0")
118
+ self.target = RowTarget(
119
+ card.page, f"family:{index}:{key}", self.row, leaf_of(key), index
120
+ )
121
+ self.target.slot = self.slot
122
+
123
+ def paint(self, fold: Fold, shut: bool) -> None:
124
+ from .plot_card import worst # plot_card imports this module
125
+
126
+ self.fold = fold
127
+ statuses = [leaf_status(leaf) for leaf in fold.leaves]
128
+ self.pill.set(worst(statuses))
129
+ self.name.set_text(fold.name)
130
+ # A family with nothing scored because of a run says the word its
131
+ # members' rows say (D151), not a count of verdicts it has none of.
132
+ self.count.set_text(str(fold.leaves[0].get("unscored") or tally(statuses)))
133
+ if _set_prop(self.row, "data-folded", "1" if shut else None):
134
+ self.row.update()
135
+ moved = _set_prop(self.chevron, "aria-expanded", "false" if shut else "true")
136
+ moved |= _set_prop(
137
+ self.chevron, "aria-label", f"{'Unfold' if shut else 'Fold'} {fold.name}"
138
+ )
139
+ if moved:
140
+ self.chevron.update()
141
+ card = self.folding.card
142
+ if card.open_path == self.key and card.hint is not None:
143
+ card.hint.set_text(self._hint())
144
+
145
+ # -- its editor ---------------------------------------------------------------
146
+
147
+ def _hint(self) -> str:
148
+ from .plot_card import STAGED # plot_card imports this module
149
+
150
+ return HINT.format(n=len(self.fold.paths)) + STAGED
151
+
152
+ def stamp(self, view: dict | None) -> str | None:
153
+ """The criterion its members share, as ``view`` shows it — what
154
+ the open editor's fields say. ``None`` when they no longer share
155
+ one, or are no longer all there: the editor has nothing to show."""
156
+ wanted = set(self.fold.paths)
157
+ rows = criteria_rows(view, self.folding.card.subject)
158
+ leaves = [row["leaf"] for row in rows if str(row["leaf"]["path"]) in wanted]
159
+ if len(leaves) != len(wanted):
160
+ return None
161
+ criteria = [criterion_of(leaf) for leaf in leaves]
162
+ if not all(json_equal(criteria[0], other) for other in criteria[1:]):
163
+ return None
164
+ return json.dumps(criteria[0], sort_keys=True, default=str)
165
+
166
+ def open_editor(self, view: dict | None) -> None:
167
+ """Its editor, below it, in the card's own editor fields — so
168
+ `PlotCard.close_editor`, the page's one-editor rule and its scroll
169
+ find it as they find a criterion's: the hint line and Done, then
170
+ every capability placed at FAMILY_CRITERION. No figure: nothing of
171
+ a fold is dragged."""
172
+ card, index, key = self.folding.card, self.folding.card.index, self.key
173
+ model = card.page.model_id
174
+ card.open_path, card._edited = key, None
175
+ self.row.classes(add="dstf-crit-open")
176
+ with self.slot:
177
+ card.editor_box = ui.column().classes("dstf-editor w-full gap-2")
178
+ with card.editor_box:
179
+ with ui.row().classes("items-center no-wrap gap-3 w-full"):
180
+ card.hint = (
181
+ ui.label(self._hint())
182
+ .classes("dstf-why")
183
+ .mark(f"editor-hint:{index}:{key}")
184
+ )
185
+ ui.space()
186
+ ui.button("Done", on_click=card.page.close_editor).props(
187
+ "flat dense no-caps"
188
+ ).mark(f"editor-done:{index}:{key}")
189
+ # Where the host says a refusal: above the fields it is about.
190
+ self.target.slot = ui.column().classes("w-full gap-0")
191
+ card.editor = InlineHost(
192
+ card.page.shell,
193
+ Placement.FAMILY_CRITERION,
194
+ # Read at every build: the members are the fold's as it is.
195
+ lambda: Selection(
196
+ tests=frozenset({model}),
197
+ variable=self.fold.variables[0],
198
+ leaf_path=self.fold.paths[0],
199
+ leaf_paths=self.fold.paths,
200
+ ),
201
+ figure=None,
202
+ marker=f"editor:{index}:{key}",
203
+ on_applied=self._applied,
204
+ row=self.target,
205
+ apply=card.page.apply,
206
+ )
207
+ card.editor.build()
208
+ self._edited = self.stamp(view)
209
+
210
+ def _applied(self, name: str) -> None:
211
+ """A panel's own submit: recorded from a FRESH tree view, so its
212
+ edit is not read as one staged elsewhere (`PlotCard._applied`'s
213
+ rule). No panel is rebuilt: neither shows what the other edits,
214
+ and an Indices edit redraws the whole card."""
215
+ self._edited = self.stamp(self.folding.card._view())
216
+
217
+ def follow(self, view: dict | None) -> bool:
218
+ """The open editor after a paint: False when its members no longer
219
+ share one criterion — a Discard, a Save, another tab's edit took
220
+ them apart — and it has to close; rebuilt when the criterion they
221
+ share moved by an edit staged ELSEWHERE, whose fields would stage
222
+ the undone value straight back."""
223
+ stamp = self.stamp(view)
224
+ if stamp is None:
225
+ return False
226
+ editor = self.folding.card.editor
227
+ if stamp != self._edited and editor is not None:
228
+ editor.build()
229
+ self._edited = stamp
230
+ return True
231
+
232
+
233
+ def _show_at(element: ui.element, order: int) -> None:
234
+ """Where ``element`` is shown in its column (its CSS ``order``), sent
235
+ only when it moved."""
236
+ if element.style.get("order") != str(order):
237
+ element.style(f"order: {order}")
238
+
239
+
240
+ class Folding:
241
+ """One card's folds: `PlotCard` builds it after its criteria rows,
242
+ calls `paint` wherever it paints them, and `released` when its editor
243
+ closes."""
244
+
245
+ def __init__(self, card: PlotCard, column: ui.column, crumb_ch: int) -> None:
246
+ self.card, self.column, self.crumb_ch = card, column, crumb_ch
247
+ #: Each member's fold, by the member's path, as last painted.
248
+ self.fold_of: dict[str, Fold] = {}
249
+ #: Whether the last paint held an open fold's members together
250
+ #: (`fold_rows`' ``pinned``): what `released` has to undo.
251
+ self.held = False
252
+
253
+ @property
254
+ def rows(self) -> dict[str, Any]:
255
+ """`PlotCard.rows` as it IS: the criteria's rows by path, and each
256
+ `FoldRow` by its key among them (its docstring) — the card's own
257
+ annotation names the criteria's rows alone."""
258
+ return self.card.rows
259
+
260
+ # -- the tab's record ---------------------------------------------------------
261
+
262
+ @property
263
+ def unfolded(self) -> set[str]:
264
+ """The member paths of this test's unfolded folds, the tab's."""
265
+ page = self.card.page
266
+ return set(page.state.family_unfolded.get(page.model_id, []))
267
+
268
+ def _keep(self, unfolded: set[str]) -> None:
269
+ page = self.card.page
270
+ if unfolded:
271
+ page.state.family_unfolded[page.model_id] = sorted(unfolded)
272
+ else:
273
+ page.state.family_unfolded.pop(page.model_id, None)
274
+
275
+ # -- what is open on this card ------------------------------------------------
276
+
277
+ def _open(self) -> str | None:
278
+ """What the page's one editor is open on, when it is on this card:
279
+ a fold's key or a criterion's path."""
280
+ is_open = self.card.page.open
281
+ if is_open is None or is_open[0] != self.card.index:
282
+ return None
283
+ return is_open[1]
284
+
285
+ def _under(self) -> set[str]:
286
+ """The criteria rows something is open under — the page's editor or
287
+ its form row. A fold such a row is a member of is shown unfolded,
288
+ or what is open under it is open under nothing: a criterion typed
289
+ to its fold's value while that fold is shut joins it, and its
290
+ editor would go on staging edits from a row nobody can see."""
291
+ under = set()
292
+ opened = self._open()
293
+ if opened is not None and not is_fold(opened):
294
+ under.add(opened)
295
+ form = self.card.page.form
296
+ if form is not None:
297
+ under |= {
298
+ path
299
+ for path, row in self.rows.items()
300
+ if row.target is form[0] and not is_fold(path)
301
+ }
302
+ return under
303
+
304
+ # -- painting -----------------------------------------------------------------
305
+
306
+ def paint(self, view: dict | None) -> None:
307
+ """The folds as the tree has them now, into the rows standing: a
308
+ fold's row comes, goes or is repainted, every criterion's row is a
309
+ member of one or its own, and each is shown in its place."""
310
+ card, rows = self.card, self.rows
311
+ if len(card.subject) < 2:
312
+ # One variable is no family: nothing on this card can fold, and
313
+ # most cards are this one — asked on every sync. It is also
314
+ # what keeps a UnitTests element out of every fold: each is a
315
+ # plot of its own (D140, `derive_plots`).
316
+ return
317
+ criteria = criteria_rows(view, card.subject)
318
+ standing = {path for path in rows if not is_fold(path)}
319
+ if {str(row["leaf"]["path"]) for row in criteria} != standing:
320
+ # The rows standing are another tree's — a leaf came or went,
321
+ # and the rebuild that draws the new ones follows. Reached when
322
+ # an editor closes on the way into that rebuild (`released`).
323
+ return
324
+ opened = self._open()
325
+ pinned: tuple[str, ...] = ()
326
+ if opened is not None and is_fold(opened) and opened in rows:
327
+ if rows[opened].follow(view):
328
+ # The open editor's members stay one fold whatever they
329
+ # equal (`fold_rows`): a value on its way through another
330
+ # group's must not merge the two under the typing hand.
331
+ pinned = rows[opened].fold.paths
332
+ else:
333
+ self.held = False # the close below has nothing to release
334
+ card.page.drop_editor()
335
+ self.held = bool(pinned)
336
+ entries = fold_rows(criteria, pinned=pinned)
337
+ folds = {entry.key: entry for entry in entries if isinstance(entry, Fold)}
338
+ for key in [key for key in rows if is_fold(key) and key not in folds]:
339
+ gone = rows.pop(key)
340
+ gone.slot.delete()
341
+ gone.row.delete()
342
+ self.fold_of = {path: fold for fold in folds.values() for path in fold.paths}
343
+ # A fold holding something open is unfolded, and stays so.
344
+ unfolded = self.unfolded
345
+ for path in self._under():
346
+ fold = self.fold_of.get(path)
347
+ if fold is not None and not unfolded & set(fold.paths):
348
+ unfolded |= set(fold.paths)
349
+ self._keep(unfolded)
350
+ for key, fold in folds.items():
351
+ row = rows.get(key)
352
+ if row is None:
353
+ row = self._add(fold)
354
+ row.paint(fold, not unfolded & set(fold.paths))
355
+ for path, row in rows.items():
356
+ if is_fold(path):
357
+ continue
358
+ fold = self.fold_of.get(path)
359
+ shut = fold is not None and not unfolded & set(fold.paths)
360
+ moved = _set_prop(row.row, "data-member", "1" if fold is not None else None)
361
+ moved |= _set_prop(row.row, "hidden", True if shut else None)
362
+ if moved:
363
+ row.row.update()
364
+ if _set_prop(row.slot, "hidden", True if shut else None):
365
+ row.slot.update()
366
+ self._lay_out(entries)
367
+
368
+ def _lay_out(self, entries: list[dict | Fold]) -> None:
369
+ """Where each row is shown: `fold_rows`' order — a fold where its
370
+ first member's row was, a criterion's own row where it was — with
371
+ each fold's members as one block right under it, and every row's
372
+ slot (its editor's place) right under the row."""
373
+ shown: list[Any] = []
374
+ for entry in entries:
375
+ if isinstance(entry, Fold):
376
+ shown.append(self.rows[entry.key])
377
+ shown += [self.rows[path] for path in entry.paths]
378
+ else:
379
+ shown.append(self.rows[str(entry["leaf"]["path"])])
380
+ for k, row in enumerate(shown):
381
+ _show_at(row.row, 2 * k)
382
+ _show_at(row.slot, 2 * k + 1)
383
+
384
+ def _add(self, fold: Fold) -> FoldRow:
385
+ """A fold's row and its slot, put in before its first member's row
386
+ — in the document too, so Tab reaches its chevron before them."""
387
+ with self.column:
388
+ row = FoldRow(self, fold, self.crumb_ch)
389
+ first = self.rows[fold.paths[0]].row
390
+ at = self.column.default_slot.children.index(first)
391
+ row.row.move(target_index=at)
392
+ row.slot.move(target_index=at + 1)
393
+ self.rows[fold.key] = row
394
+ return row
395
+
396
+ def released(self) -> None:
397
+ """An editor on this card closed (`PlotCard.close_editor`'s last
398
+ word): if it was a fold's, its members are no longer held together,
399
+ and the rows are repainted from the tree — at once, since no event
400
+ follows a close. Left as they were, a fold typed to its neighbour's
401
+ value read as two rows of one criterion, and its Indices then
402
+ staged a second leaf on an element that had one (the executing
403
+ review's H1)."""
404
+ if self.held:
405
+ self.held = False
406
+ self.paint(self.card._view())
407
+
408
+ # -- folding ------------------------------------------------------------------
409
+
410
+ def toggle(self, key: str, *_: Any) -> None:
411
+ """A chevron's click. Folding a row shut closes what is open under
412
+ its members — an editor nobody can see is no editor, and a form
413
+ nobody can see no form (the tree card's rule)."""
414
+ row = self.rows.get(key)
415
+ if row is None:
416
+ return
417
+ page, paths = self.card.page, set(row.fold.paths)
418
+ unfolded = self.unfolded
419
+ if unfolded & paths:
420
+ unfolded -= paths
421
+ if self._open() in paths:
422
+ page.close_editor()
423
+ for path in paths:
424
+ page.drop_row(self.rows[path].target)
425
+ else:
426
+ unfolded |= paths
427
+ if self._open() == key:
428
+ # The row its open editor is on: what the plot's line said
429
+ # of a FOLDED row is no longer so.
430
+ self.card.fold_unfolded()
431
+ self._keep(unfolded)
432
+ self.paint(self.card._view())
433
+
434
+ def unfold_to(self, path: str, view: dict | None) -> None:
435
+ """Show ``path``'s own row: its fold, if it is in a closed one, is
436
+ unfolded — a criterion asked for by name (a jump, a reveal, the
437
+ tree's ⤓) is shown, not the row that folds it away."""
438
+ fold = self.fold_of.get(path)
439
+ unfolded = self.unfolded
440
+ if fold is not None and not unfolded & set(fold.paths):
441
+ self._keep(unfolded | set(fold.paths))
442
+ self.paint(view)
443
+
444
+ def opens(self, path: str, view: dict | None) -> bool:
445
+ """`PlotCard.open_editor`'s first question: a fold's key opens that
446
+ fold's editor here (True); a criterion's path has its own row shown
447
+ first, and its editor is the card's to open."""
448
+ row = self.rows.get(path)
449
+ if isinstance(row, FoldRow):
450
+ row.open_editor(view)
451
+ return True
452
+ self.unfold_to(path, view)
453
+ return False
454
+
455
+ @staticmethod
456
+ def rekey(state: Any, model_id: str, paths: dict[str, str]) -> None:
457
+ """An edit of the tree went through (`TestPage.edited`): each
458
+ unfolded member is recorded where the edit put it; one it removed
459
+ is dropped."""
460
+ now = sorted(
461
+ paths[path]
462
+ for path in state.family_unfolded.get(model_id, ())
463
+ if path in paths
464
+ )
465
+ if now:
466
+ state.family_unfolded[model_id] = now
467
+ else:
468
+ state.family_unfolded.pop(model_id, None)