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,783 @@
1
+ """The header both pages share (DESIGN.md "Header"; brief §3.3): "DSTF", the
2
+ library and the backend's name on the left; the queue indicator in the
3
+ centre; the pending count with Save and Discard on the right, and a ⋮ menu
4
+ at the end holding the rarely used actions — Export, Change backend…, Open
5
+ in another backend…, the dashboard, the other theme and Stop server, each with its full
6
+ label. At 900 px the row cut off "dashboard" and "Stop server", and the
7
+ owner chose one layout at every width (3c, Q10 A) over a row that changes
8
+ with it. The backend was a select in the row until a rerun running, an
9
+ edit pending and a caption naming a new entry put Save on four lines at
10
+ 900 px: the owner moved it into the menu (Q13 A), and the row names the
11
+ backend as text. A button may take two lines at 900 px and one at 1280.
12
+ Every button's label says its object, effect and count; a disabled one
13
+ carries the sentence that says why (DESIGN.md "Disabled states explain
14
+ themselves"). Off-white with a hairline below, never an accent bar.
15
+
16
+ A page may ask for a second row of its own (``below``): it is drawn INSIDE
17
+ this fixed header, which is what makes it sticky without offset arithmetic
18
+ (decision 8). The Test page's header is the one. The Test page also asks
19
+ for a third row (``strip``), drawn inside it for the same reason: its status
20
+ strip, the test's situation and its next action."""
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import logging
26
+ from collections.abc import Callable, Sequence
27
+ from typing import TYPE_CHECKING, Any
28
+
29
+ from nicegui import ui
30
+
31
+ from ...backends.progress import read_status
32
+ from ...reporting.dashboard_render import format_run_metadata, short_tool_version
33
+ from ...reporting.status import SCORING_FAILED, classify
34
+ from ...storage.ref_index import _atomic_write_text
35
+ from ..capabilities import plural
36
+ from ..errors import user_text
37
+ from ..queue_rows import stop_reason
38
+ from ..session import StaleSpecError, UnscorableSaveError
39
+ from ..situation import SEP, verdict_phrase
40
+ from ..spec_document import PatchOp, edit_count, edit_counts
41
+ from ..spec_preview import entry_lines, spans
42
+ from .actions import log_unless_users
43
+ from .queue_panel import QueueIndicator
44
+ from .widgets import Action
45
+
46
+ if TYPE_CHECKING:
47
+ from .shell import Shell
48
+ from .tab_theme import TabTheme
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ def save_caption(pending: Sequence[PatchOp], loaded: dict, file_name: str) -> str:
54
+ """The Save button's caption (brief §3.3: object, effect, count). A
55
+ pending edit to a model with no entry in the file as LOADED creates
56
+ that entry on Save, and the caption says so (the owner's ruling,
57
+ 2026-09-25): a layout staged for a test tracked only by its `.mo`
58
+ annotation writes that test's first entry. Counted over ``loaded``,
59
+ never the effective document, where the pending op has already made
60
+ the entry."""
61
+ from ...discovery.spec_update import spec_entry
62
+
63
+ models = list(dict.fromkeys(op.model_id for op in pending))
64
+ new = sum(1 for m in models if spec_entry(loaded, m) is None)
65
+ tests = plural(len(models), "test")
66
+ what = f"{tests}, {plural(new, 'new entry', 'new entries')}" if new else tests
67
+ return f"Save {plural(edit_count(pending), 'spec edit')} ({what}) to {file_name}"
68
+
69
+
70
+ def backend_caption(
71
+ backend_name: str | None, os_name: str | None, metadata: dict | None
72
+ ) -> tuple[str, str]:
73
+ """The header's backend text and its tooltip. The text names the tool's
74
+ version the last run recorded, short ("Dymola 2026x · linux"), when the
75
+ configured backend is the one that ran; a backend that has not run here
76
+ reads its name, its version unknown until it runs. The tooltip says
77
+ whose the version is: the LAST RUN's, in full (``metadata`` is
78
+ status.json's)."""
79
+ meta = format_run_metadata(metadata)
80
+ name = backend_name
81
+ if meta is None:
82
+ tip = "No run here has recorded the tool's version yet"
83
+ else:
84
+ ran = meta["tool_version"] or meta["label"]
85
+ tip = "Last run: " + " · ".join(
86
+ t
87
+ for t in (
88
+ ran,
89
+ meta["msl"] and f"MSL {meta['msl']}",
90
+ meta["os"],
91
+ meta["generated_str"],
92
+ )
93
+ if t
94
+ )
95
+ if metadata and metadata.get("backend_name") == backend_name:
96
+ name = short_tool_version(meta["tool_version"], backend_name)
97
+ else:
98
+ tip = (
99
+ f"{backend_name} has not run here: its version is known once "
100
+ f"it runs. {tip}"
101
+ )
102
+ return " · ".join(t for t in (name, os_name) if t), tip
103
+
104
+
105
+ #: The role every item of the header's menu carries (see `Header`).
106
+ MENUITEM = 'role="menuitem"'
107
+
108
+
109
+ class Header:
110
+ def __init__(
111
+ self,
112
+ shell: Shell,
113
+ theme: TabTheme,
114
+ below: Callable[[], None] | None = None,
115
+ strip: Callable[[], None] | None = None,
116
+ ) -> None:
117
+ self.shell = shell
118
+ self.theme = theme
119
+ self.drawer = PendingDrawer(shell, save=self._save, discard=self._discard)
120
+ self.dialog = ui.dialog() # the header's dialogs, one element rebuilt per use
121
+ # The backend's own dialog, built once: `refresh` keeps its select's
122
+ # options and value as the row's text, open or closed.
123
+ with ui.dialog() as self.backend_dialog, ui.card():
124
+ ui.label("Change backend").classes("text-h6")
125
+ ui.label(
126
+ "Reopens this project on the chosen backend, in this tab; its "
127
+ "results and baselines are that backend's."
128
+ ).classes("text-xs text-secondary")
129
+ self.backend = (
130
+ ui.select([], label="backend", on_change=self._change_backend)
131
+ .props("dense outlined options-dense")
132
+ .mark("backend")
133
+ )
134
+ ui.button("Close", on_click=self.backend_dialog.close).props(
135
+ "flat dense no-caps"
136
+ )
137
+ with ui.header().classes("dstf-header column no-wrap gap-0") as bar:
138
+ row = ui.row().classes("dstf-header-row items-center no-wrap gap-3 w-full")
139
+ with row:
140
+ ui.label("DSTF").classes("dstf-wordmark").mark("wordmark")
141
+ self.library = ui.label().classes("text-secondary").mark("project")
142
+ self.backend_name = (
143
+ ui.label().classes("text-secondary").mark("backend-name")
144
+ )
145
+ with self.backend_name:
146
+ self.backend_tip = ui.tooltip("").mark("backend-name:tip")
147
+ ui.space()
148
+ self.queue = QueueIndicator(shell)
149
+ ui.space()
150
+ self.pending = (
151
+ ui.button(on_click=self.drawer.toggle)
152
+ .props("flat dense no-caps no-wrap")
153
+ .mark("pending")
154
+ )
155
+ self.save = Action(
156
+ self._save,
157
+ props="dense no-caps color=primary",
158
+ marker="save",
159
+ filled=True,
160
+ )
161
+ self.discard = Action(
162
+ self._discard, props="flat dense no-caps", marker="discard", why=False
163
+ )
164
+ # The ⋮ button says it opens a menu and whether it is open; the
165
+ # items are `menuitem`s of that menu (Quasar draws a `q-item` as
166
+ # a `listitem`, and the attribute given here is what the browser
167
+ # keeps).
168
+ self.more = (
169
+ ui.button(icon="more_vert")
170
+ .props(
171
+ 'flat dense round aria-label="More actions" aria-haspopup="menu" '
172
+ 'aria-expanded="false"'
173
+ )
174
+ .classes("dstf-header-menu")
175
+ .mark("header-menu")
176
+ )
177
+ with self.more, ui.menu() as menu:
178
+ menu.on_value_change(self._menu_toggled)
179
+ # A `ui.menu_item` has no text of its own to set: Export's
180
+ # caption is a label in it, and the reason it is disabled a
181
+ # second line under that.
182
+ with (
183
+ ui.menu_item(on_click=self._export)
184
+ .props(MENUITEM)
185
+ .mark("export") as export
186
+ ):
187
+ self.export = export
188
+ with ui.item_section():
189
+ self.export_text = ui.item_label()
190
+ self.export_why = (
191
+ ui.item_label().props("caption").mark("export:why")
192
+ )
193
+ # Changed rarely, and refused while anything is pending or
194
+ # running: in the row, its select and why-sentence pushed
195
+ # Save onto four lines at 900 px (the owner's ruling, Q13 A).
196
+ with (
197
+ ui.menu_item(on_click=self.backend_dialog.open)
198
+ .props(MENUITEM)
199
+ .mark("change-backend") as change
200
+ ):
201
+ self.change = change
202
+ with ui.item_section():
203
+ ui.item_label("Change backend…")
204
+ self.backend_why = (
205
+ ui.item_label().props("caption").mark("backend:why")
206
+ )
207
+ self.other = (
208
+ ui.menu_item("Open in another backend…", on_click=self._open_other)
209
+ .props(MENUITEM)
210
+ .mark("other-backend")
211
+ )
212
+ # A link the browser follows into a new tab: a `window.open`
213
+ # sent from the server (`ui.navigate.to(..., new_tab=True)`)
214
+ # is what a popup blocker may refuse.
215
+ ui.menu_item("dashboard").props(
216
+ f'href="/dashboard/dashboard.html" target="_blank" {MENUITEM}'
217
+ ).mark("dashboard")
218
+ # The session switch (D148): the item names the theme it
219
+ # goes TO, for this tab; the dashboard and the report pages
220
+ # keep the project's.
221
+ with (
222
+ ui.menu_item(on_click=self._switch_theme)
223
+ .props(MENUITEM)
224
+ .mark("theme")
225
+ ):
226
+ self.theme_text = ui.item_label(self._theme_offer())
227
+ self.stop = (
228
+ ui.menu_item("Stop server", on_click=self._stop)
229
+ .props(MENUITEM)
230
+ .classes("text-negative")
231
+ .mark("stop")
232
+ )
233
+ if below is not None:
234
+ # A second row INSIDE the fixed header (decision 8): sticky with
235
+ # no offset arithmetic and no dependence on the first row's
236
+ # height. The Test page's own header is what `below` draws.
237
+ with bar:
238
+ sub = ui.row().classes("dstf-subheader items-center gap-3 w-full")
239
+ with sub.mark("subheader"):
240
+ below()
241
+ if strip is not None:
242
+ # A third row, under the second: the Test page's status strip,
243
+ # sticky for the same reason. It draws its own row.
244
+ with bar:
245
+ strip()
246
+ self.refresh()
247
+
248
+ def handle(self, event: dict[str, Any]) -> None:
249
+ self.refresh()
250
+
251
+ def _menu_toggled(self, e: Any) -> None:
252
+ self.more.props(f'aria-expanded="{"true" if e.value else "false"}"')
253
+
254
+ def _theme_offer(self) -> str:
255
+ return f"{self.theme.other.capitalize()} theme"
256
+
257
+ def _switch_theme(self) -> None:
258
+ self.theme.switch()
259
+ self.theme_text.set_text(self._theme_offer())
260
+
261
+ def refresh(self) -> None:
262
+ shell, s = self.shell, self.shell.session
263
+ project, load = s.project, shell.load
264
+ if project is not None:
265
+ self.library.set_text(project.config.library_name or "")
266
+ config = project.config
267
+ snapshot = read_status(config.work_dir) if config.work_dir else None
268
+ text, tip = backend_caption(
269
+ config.backend_name,
270
+ config.os_name,
271
+ (snapshot or {}).get("metadata"),
272
+ )
273
+ self.backend_name.set_text(text)
274
+ self.backend_tip.set_text(tip)
275
+ if not load.in_progress:
276
+ # Not while a reopen runs: the select shows the user's choice
277
+ # until the Session commits it.
278
+ current = project.config.backend_name
279
+ options = sorted(
280
+ set(project.config.backends) | ({current} if current else set())
281
+ )
282
+ self.backend.set_options(options, value=current)
283
+ else:
284
+ self.library.set_text("opening…" if load.in_progress else "no project")
285
+ self.backend_name.set_text("")
286
+ self.backend_tip.set_text("No project is open")
287
+ why = self._backend_why()
288
+ self.change.set_enabled(why is None)
289
+ self.backend.set_enabled(why is None)
290
+ self.backend_why.set_text(why or "")
291
+ self.backend_why.set_visibility(why is not None)
292
+ self.other.set_enabled(project is not None)
293
+ self.queue.refresh()
294
+ n = edit_count(s.spec.pending)
295
+ self.pending.set_text(plural(n, "pending edit"))
296
+ reason = "no pending edits" if n == 0 else shell.busy_reason()
297
+ self.save.set(
298
+ save_caption(s.spec.pending, s.spec.loaded, s.spec.path.name), reason
299
+ )
300
+ self.discard.set(f"Discard {plural(n, 'edit')}", reason)
301
+ # The drawer's own Save and Discard, gated as these are — here, not
302
+ # in its build: that runs only when what the preview shows moved,
303
+ # and the load moves nothing it shows.
304
+ self.drawer.save.set("Save these", reason)
305
+ self.drawer.discard.set("Discard these", reason)
306
+ self.export_text.set_text(f"Export {plural(n, 'edit')} to spec_patch.json")
307
+ self.export.set_enabled(n > 0)
308
+ self.export_why.set_text("no pending edits" if n == 0 else "")
309
+ self.export_why.set_visibility(n == 0)
310
+ self.drawer.refresh()
311
+
312
+ # -- the backend -------------------------------------------------------------
313
+
314
+ def _backend_why(self) -> str | None:
315
+ """Why the backend cannot change now (brief §3.6: refused while edits
316
+ are pending or the queue holds a test), or None."""
317
+ s = self.shell.session
318
+ if s.project is None:
319
+ return "no project open"
320
+ busy = self.shell.busy_reason() or stop_reason(s)
321
+ if busy is not None:
322
+ return f"backend change waits: {busy}"
323
+ n = edit_count(s.spec.pending)
324
+ if n:
325
+ return (
326
+ f"backend change waits: save or discard {plural(n, 'pending edit')} "
327
+ "first"
328
+ )
329
+ return None
330
+
331
+ def _change_backend(self, e: Any) -> None:
332
+ """Reopen on the chosen backend (brief §3.6), on the shell's open
333
+ thread; the page follows the loading events as on startup."""
334
+ project = self.shell.session.project
335
+ if project is None or e.value in (None, project.config.backend_name):
336
+ return
337
+ why = self._backend_why()
338
+ if why is not None:
339
+ ui.notify(why, type="warning")
340
+ self.backend.set_value(project.config.backend_name)
341
+ return
342
+ self.backend_dialog.close()
343
+ self.shell.open_async(e.value)
344
+ self.refresh()
345
+
346
+ def _open_other(self) -> None:
347
+ """A second tool process on another backend (brief §3.6): its own
348
+ port and browser tab; one project and backend per process."""
349
+ project = self.shell.session.project
350
+ if project is None:
351
+ return
352
+ current = project.config.backend_name # `str | None`, as in `refresh`
353
+ options = sorted(
354
+ set(project.config.backends) | ({current} if current else set())
355
+ )
356
+ self.dialog.clear()
357
+ with self.dialog, ui.card():
358
+ ui.label("Open this project in another backend").classes("text-h6")
359
+ ui.label(
360
+ "A second dstf author process on its own port, opening its own browser "
361
+ "tab; one project and backend per process. Stop server here ends it too."
362
+ ).classes("text-xs text-secondary")
363
+ choice = (
364
+ ui.select(options, value=current, label="backend")
365
+ .props("dense outlined")
366
+ .mark("other-choice")
367
+ )
368
+ link = ui.link("", "/", new_tab=True).mark("other-link")
369
+ link.set_visibility(False)
370
+
371
+ def start() -> None:
372
+ url = self.shell.spawn(str(choice.value))
373
+ link.set_text(f"open {url}")
374
+ link.props(f'href="{url}"')
375
+ link.set_visibility(True)
376
+
377
+ with ui.row():
378
+ ui.button("Start", on_click=start).props(
379
+ "unelevated dense no-caps color=primary"
380
+ ).mark("start-other")
381
+ ui.button("Close", on_click=self.dialog.close).props(
382
+ "flat dense no-caps"
383
+ )
384
+ self.dialog.open()
385
+
386
+ # -- the edits -----------------------------------------------------------------
387
+
388
+ def stale(self, e: StaleSpecError) -> None:
389
+ """The stale-file refusal's dialog: the spec changed on disk since
390
+ it was loaded, so nothing that writes from the loaded one went on.
391
+ Save's, and — handed here by the page's action bars — Accept's:
392
+ one dialog, one way out (Reload, or keep the edits and look).
393
+
394
+ It says what was NOT done (the Accept's own dialog has closed for
395
+ this one, and nothing else on the page says it), and its Reload
396
+ button says what it costs before it is clicked: the pending edits
397
+ it drops, counted as the header counts them. With none pending
398
+ there is nothing to keep, and the other button is Close."""
399
+ pending = edit_count(self.shell.session.spec.pending)
400
+ self.dialog.clear()
401
+ with self.dialog, ui.card():
402
+
403
+ def reload() -> None:
404
+ try:
405
+ self.shell.session.reload()
406
+ except Exception as e: # said in the dialog's place, never raised
407
+ log_unless_users(e, "reload")
408
+ ui.notify(user_text(e), type="warning")
409
+ return
410
+ self.dialog.close()
411
+
412
+ ui.label(f"{e.path} changed on disk since it was loaded.").mark(
413
+ "stale-spec"
414
+ )
415
+ ui.label(e.not_done).mark("stale-not-done")
416
+ with ui.row():
417
+ ui.button(
418
+ f"Reload and discard {plural(pending, 'pending edit')}"
419
+ if pending
420
+ else "Reload",
421
+ on_click=reload,
422
+ ).props("outline dense no-caps").mark("stale-reload")
423
+ if pending:
424
+ ui.button("Keep my edits", on_click=self.dialog.close).props(
425
+ "flat dense no-caps"
426
+ ).mark("stale-keep")
427
+ else:
428
+ ui.button("Close", on_click=self.dialog.close).props(
429
+ "flat dense no-caps"
430
+ ).mark("stale-close")
431
+ self.dialog.open()
432
+
433
+ def _save(self) -> None:
434
+ try:
435
+ self.shell.session.save()
436
+ ui.notify("saved")
437
+ except StaleSpecError as e:
438
+ self.stale(e)
439
+ except UnscorableSaveError as e:
440
+ # The other refusal of a Save: said where the stale one is, in
441
+ # the dialog — a sentence to act on, not a toast that goes.
442
+ self.dialog.clear()
443
+ with self.dialog, ui.card():
444
+ ui.label(str(e)).mark("save-refused")
445
+ ui.button("Close", on_click=self.dialog.close).props(
446
+ "flat dense no-caps"
447
+ )
448
+ self.dialog.open()
449
+ except Exception as e:
450
+ ui.notify(f"save failed: {e}", type="negative")
451
+
452
+ def _discard(self) -> None:
453
+ try:
454
+ self.shell.session.discard()
455
+ except Exception as e:
456
+ log_unless_users(e, "discard")
457
+ ui.notify(user_text(e), type="warning")
458
+
459
+ def _export(self) -> None:
460
+ project = self.shell.session.project
461
+ assert project is not None # the Action is disabled without one
462
+ work_dir = project.config.work_dir
463
+ assert work_dir is not None # resolved in Config.__post_init__
464
+ path = work_dir / "spec_patch.json"
465
+ _atomic_write_text(
466
+ path, json.dumps(self.shell.session.export(), indent=2) + "\n"
467
+ )
468
+ ui.notify(f"wrote {path}")
469
+
470
+ # -- stop ----------------------------------------------------------------------
471
+
472
+ def _stop(self) -> None:
473
+ """Stop server (brief §3.3, §3.6): at once with nothing to lose; else
474
+ a confirm naming what would be lost — unsaved edits, and the tests
475
+ the queue still holds, which stopping removes or cancels."""
476
+ reasons = self.shell.stop_reasons()
477
+ if not reasons:
478
+ self.shell.stop()
479
+ return
480
+ self.dialog.clear()
481
+ with self.dialog, ui.card():
482
+ ui.label("Stop the server?").classes("text-h6")
483
+ for reason in reasons:
484
+ ui.label(reason).mark("stop-reason")
485
+ with ui.row():
486
+ ui.button("Stop anyway", on_click=self.shell.stop).props(
487
+ "outline dense no-caps color=negative"
488
+ ).mark("stop-anyway")
489
+ ui.button("Keep running", on_click=self.dialog.close).props(
490
+ "flat dense no-caps"
491
+ )
492
+ self.dialog.open()
493
+
494
+
495
+ #: The event Esc sends to the drawer's column, and the one listener that
496
+ #: sends it — installed once per page load, when the drawer first opens. A
497
+ #: keydown on the document, not on the drawer: focus is where the click that
498
+ #: opened it left it (the header's button, the strip's). Sent to an element
499
+ #: the drawer built with its listener on, never through `ui.on`: that adds a
500
+ #: listener to the page's layout, which on the Suite page is already drawn
501
+ #: when the header is built, and NiceGUI says so in the browser's console.
502
+ #: An Esc while a dialog or a menu is open is theirs (Quasar closes it): the
503
+ #: drawer under it stays. One already closing is not open: it stays in the
504
+ #: page for its leave animation (~300 ms (Quasar's default
505
+ #: transition-duration); Quasar's `-leave-` classes, on a dialog's
506
+ #: `.q-dialog__inner`), and a guard that counted it swallowed a second Esc
507
+ #: 40 ms after the first.
508
+ ESC_EVENT = "dstf_pending_esc"
509
+ ESC_JS = f"""
510
+ window.__dstfPendingEsc ??= (document.addEventListener("keydown", (e) => {{
511
+ if (e.key !== "Escape") return;
512
+ const open = [...document.querySelectorAll(".q-dialog, .q-menu")].some(
513
+ (el) => !/-leave-/.test((el.querySelector(".q-dialog__inner") ?? el).className));
514
+ if (open) return;
515
+ document.querySelector(".dstf-pending-body")
516
+ ?.dispatchEvent(new CustomEvent("{ESC_EVENT}"));
517
+ }}), true);
518
+ """
519
+
520
+ #: A diff line's prefix, as `git diff` and the board print it.
521
+ _PREFIX = {"same": " ", "add": "+ ", "del": "- ", "gap": " "}
522
+
523
+
524
+ def materialized_note(leaves: int, new_entry: bool) -> str:
525
+ """The note over a test whose edits begin with its implicit tree written
526
+ out (`PatchOp.materializes`): the diff is against that tree, and Save
527
+ writes it too."""
528
+ they = "it scores" if leaves == 1 else "they score"
529
+ note = (
530
+ f"Save also writes this test's {plural(leaves, 'criterion', 'criteria')} "
531
+ f"out as a tree; {they} the same."
532
+ )
533
+ return f"A new entry for this test. {note}" if new_entry else note
534
+
535
+
536
+ def _leaves(node: Any) -> int:
537
+ """How many criteria a raw tree node holds."""
538
+ if isinstance(node, dict) and isinstance(node.get("children"), list):
539
+ return sum(_leaves(child) for child in node["children"])
540
+ return 1
541
+
542
+
543
+ class PendingDrawer:
544
+ """The pending-edit drawer is the preview (the owner's ruling Q6 A): per
545
+ test, in staging order, its entry before and after its staged edits as
546
+ `test_spec.json` will hold it — added lines green, removed lines red,
547
+ each variable read by its label with its result-file name muted beside
548
+ it — with how many edits it has and what they do to its verdict. The
549
+ `before` is the tree as scoring sees it (`SpecDocument.preview_base`):
550
+ a flat entry's first per-leaf edit writes its implicit tree out, and
551
+ the diff shows the edit, with a note that Save writes the tree too.
552
+ "Save these" and "Discard these" are the header's own Save and Discard
553
+ (the stale-file dialog included), greyed when they are and for the same
554
+ reason. Toggled by the header's pending button; `show(model)` opens it
555
+ on one test's section (the strip's Preview and save). Its close button
556
+ and Esc close it: under 1024 px it is an overlay over the header and
557
+ the strip, and a click on the page behind was the only other way out.
558
+
559
+ Built only while open: the header refreshes on every event (the 1 s
560
+ poll while the queue runs included), and a drawer rebuilt there scored
561
+ every edited test's saved spec each time and put every diff back at its
562
+ left edge. Opening it builds it; while open it builds again only when what
563
+ it shows moved (`_key`). Closing it forgets what it drew."""
564
+
565
+ WIDTH = 520
566
+
567
+ def __init__(
568
+ self,
569
+ shell: Shell,
570
+ save: Callable[[], None],
571
+ discard: Callable[[], None],
572
+ ) -> None:
573
+ self.shell = shell
574
+ self.element = (
575
+ ui.right_drawer(value=False)
576
+ .props(f"bordered width={self.WIDTH}")
577
+ .classes("dstf-pending-drawer")
578
+ )
579
+ with (
580
+ self.element,
581
+ ui.column().classes("dstf-pending-body w-full min-w-0 gap-3") as body,
582
+ ):
583
+ with ui.row().classes("w-full items-center no-wrap gap-2"):
584
+ self.summary = ui.label().classes("text-bold").mark("drawer-summary")
585
+ ui.space()
586
+ ui.button(icon="close", on_click=self.close).props(
587
+ 'flat dense round aria-label="Close"'
588
+ ).mark("drawer-close")
589
+ self.body = ui.column().classes("w-full min-w-0 gap-4")
590
+ with ui.row().classes("gap-2") as self.buttons:
591
+ # Captions and reasons are the header's (`Header.refresh`).
592
+ self.save = Action(
593
+ save,
594
+ props="dense no-caps color=primary",
595
+ marker="drawer-save",
596
+ filled=True,
597
+ )
598
+ self.discard = Action(
599
+ discard,
600
+ props="flat dense no-caps",
601
+ marker="drawer-discard",
602
+ why=False,
603
+ )
604
+ self.sections: dict[str, ui.element] = {}
605
+ #: What the drawn preview was built from (`_key`); None: not built.
606
+ self.drawn: object = None
607
+ #: The comparisons whose ids `drawn` records, held while it does: a
608
+ #: freed one's id can be reused by the next, and the key would miss
609
+ #: the verdict it brought.
610
+ self.held: list[Any] = []
611
+ self.element.on_value_change(self._toggled)
612
+ body.on(ESC_EVENT, lambda _: self.close())
613
+
614
+ def _toggled(self, e: Any) -> None:
615
+ if e.value:
616
+ ui.run_javascript(ESC_JS)
617
+ self.refresh()
618
+ else:
619
+ # Closed, it follows nothing: its next open builds again.
620
+ self.drawn = None
621
+ self.held = []
622
+
623
+ def toggle(self) -> None:
624
+ self.element.toggle()
625
+
626
+ def close(self) -> None:
627
+ self.element.set_value(False)
628
+
629
+ def show(self, model_id: str) -> None:
630
+ """Open the drawer with ``model_id``'s section scrolled into view
631
+ (the strip's Preview and save)."""
632
+ self.element.set_value(True)
633
+ self.refresh()
634
+ section = self.sections.get(model_id)
635
+ if section is not None:
636
+ ui.run_javascript(
637
+ f'document.getElementById("{section.html_id}")'
638
+ '?.scrollIntoView({block: "start"})'
639
+ )
640
+
641
+ def _key(self) -> tuple[object, list[Any]]:
642
+ """What the preview shows moves with: the pending ops, the file, and
643
+ each edited test's verdict (a rescore replaces the comparison) —
644
+ and the comparisons it names by id, to hold."""
645
+ session = self.shell.session
646
+ spec = session.spec
647
+ edited = list(dict.fromkeys(op.model_id for op in spec.pending))
648
+ comps = [session.comparisons.get(m) for m in edited]
649
+ key = (
650
+ tuple(spec.pending),
651
+ spec.content_hash,
652
+ tuple((m, id(c)) for m, c in zip(edited, comps, strict=True)),
653
+ )
654
+ return key, comps
655
+
656
+ def refresh(self) -> None:
657
+ """Build the preview again — only while the drawer is open, and only
658
+ when what it shows moved."""
659
+ if not self.element.value:
660
+ return
661
+ key, comps = self._key()
662
+ if key == self.drawn:
663
+ return
664
+ self.drawn = None # a raise below leaves it to be built again
665
+ self._build()
666
+ self.drawn, self.held = key, comps
667
+
668
+ def _saved_statuses(self, models: list[str]) -> dict[str, str | None]:
669
+ """The classifier's word for each test as SAVED, the saved spec
670
+ merged once for all of them. A saved spec whose scoring raises for
671
+ one test says ``scoring failed`` for that test alone (as the strip
672
+ does), the others scored one by one."""
673
+ session = self.shell.session
674
+
675
+ def word(comp: Any) -> str | None:
676
+ return classify(comp) if comp is not None else None
677
+
678
+ try:
679
+ saved = session.saved_comparisons(models)
680
+ except Exception:
681
+ logger.exception("the saved verdicts of the edited tests")
682
+ else:
683
+ return {m: word(saved[m]) for m in models}
684
+ out: dict[str, str | None] = {}
685
+ for m in models:
686
+ try:
687
+ out[m] = word(session.saved_comparison(m))
688
+ except Exception:
689
+ out[m] = SCORING_FAILED
690
+ return out
691
+
692
+ def _build(self) -> None:
693
+ session = self.shell.session
694
+ spec = session.spec
695
+ groups = spec.grouped()
696
+ self.body.clear()
697
+ self.sections = {}
698
+ self.summary.set_text(
699
+ f"{plural(edit_count(spec.pending), 'pending edit')} · "
700
+ f"{plural(len(groups), 'test')}"
701
+ )
702
+ self.summary.set_visibility(bool(groups))
703
+ self.buttons.set_visibility(bool(groups))
704
+ with self.body:
705
+ if not groups:
706
+ ui.label("nothing staged").classes("text-secondary")
707
+ return
708
+ saved = self._saved_statuses([m for m, _ in groups])
709
+ counts = edit_counts(spec.pending)
710
+ for model_id, _ops in groups:
711
+ comp = session.comparisons.get(model_id)
712
+ with (
713
+ ui.column()
714
+ .classes("dstf-drawer-section w-full min-w-0 gap-1")
715
+ .mark(f"drawer-test:{model_id}") as section
716
+ ):
717
+ self.sections[model_id] = section
718
+ self._section(model_id, counts[model_id], comp, saved[model_id])
719
+
720
+ def _section(
721
+ self, model_id: str, n: int, comp: Any, saved_status: str | None
722
+ ) -> None:
723
+ session = self.shell.session
724
+ spec = session.spec
725
+ ui.label(model_id).classes("text-bold dstf-mono").mark("drawer-model")
726
+ # One sentence, as the strip's: a plain block of inline labels, its
727
+ # separator a label of its own, whose spaces stand between the words
728
+ # as any inline text's do. Not a flex row: its items are blocks, and
729
+ # a copy or a screen reader reads one per line.
730
+ with ui.element("div").classes("dstf-drawer-status text-xs text-secondary"):
731
+ ui.label(plural(n, "edit")).classes("inline").mark("drawer-count")
732
+ ui.label(SEP).classes("inline")
733
+ ui.label(
734
+ verdict_phrase(
735
+ classify(comp) if comp is not None else None, saved_status
736
+ )
737
+ ).classes("inline").mark("drawer-verdict")
738
+ trees = [
739
+ op.value
740
+ for op in spec.pending
741
+ if op.model_id == model_id and op.materializes
742
+ ]
743
+ if trees:
744
+ ui.label(
745
+ materialized_note(
746
+ sum(_leaves(t) for t in trees),
747
+ spec.saved_entry(model_id) is None,
748
+ )
749
+ ).classes("text-xs text-secondary").mark("drawer-note")
750
+ # What each gesture on a folded criteria row did, in words (D152,
751
+ # `PatchOp.notes`): its op is the whole tree, and twenty changed
752
+ # lines of the diff below do not say "one tolerance, twenty times".
753
+ said = [
754
+ text
755
+ for op in spec.pending
756
+ if op.model_id == model_id
757
+ for _, text in op.notes
758
+ ]
759
+ for text in dict.fromkeys(said):
760
+ ui.label(text).classes("text-xs dstf-mono").mark("drawer-gesture")
761
+ # Never an empty box: edits that cancel out are not here at all, and
762
+ # neither is a tree written out and not edited (a `rescore`, a value
763
+ # typed and cleared on a flat test) — an entry put back as saved has
764
+ # no pending op, and the tree it already scores by is none of the
765
+ # ops it has (`Session.stage`).
766
+ lines = entry_lines(spec.preview_base(model_id), spec.entry(model_id))
767
+ texts = session.variable_texts(model_id)
768
+ with (
769
+ ui.element("div").classes("dstf-diff").mark("drawer-diff"),
770
+ ui.element("div").classes("dstf-diff-lines"),
771
+ ):
772
+ for line in lines:
773
+ with (
774
+ ui.element("div")
775
+ .classes(f"dstf-diff-line dstf-diff-{line.kind}")
776
+ .mark(f"diff-{line.kind}")
777
+ ):
778
+ ui.label(_PREFIX[line.kind]).classes("dstf-diff-text")
779
+ for text, muted in spans(line.text, texts):
780
+ if muted:
781
+ ui.label(text).classes("dstf-diff-muted").mark("diff-muted")
782
+ else:
783
+ ui.label(text).classes("dstf-diff-text")