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,528 @@
1
+ """The shell as a process (roadmap §2; D129): one Session, one thread that
2
+ opens the project, one queue every Session event crosses on its way to the
3
+ pages, one app-level timer that drains it, the pages a browser has open,
4
+ and the child processes "Open in another backend…" starts. It knows no
5
+ capability by name: `placed` lists what is registered at a placement with
6
+ each one's answer to `available`. Nothing here is a page — the pages are
7
+ `suite.py` and `test_page.py`, one object per browser tab."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import queue
13
+ import socket
14
+ import subprocess
15
+ import sys
16
+ import threading
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any, Protocol
21
+ from urllib.parse import unquote
22
+
23
+ from fastapi.responses import FileResponse, JSONResponse
24
+ from nicegui import app, ui
25
+
26
+ from ...backends.pool import ItemState
27
+ from ...cli.common import _filter_tests
28
+ from ...reporting.theme import level_variables
29
+ from ..capabilities import Capability, Placement, plural, registered_capabilities
30
+ from ..errors import user_text
31
+ from ..queue_rows import stop_reason
32
+ from ..session import Selection, Session, SessionView
33
+ from ..spec_document import edit_count
34
+ from .actions import log_unless_users
35
+ from .style import QUASAR, STYLE
36
+ from .tab_theme import TabTheme
37
+ from .tabstate import TabState
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ TICK = 0.1 # seconds; a Session event reaches every page within one
42
+ #: How long the shutdown waits for a drained queue's dashboard render still
43
+ #: running (1.6 s over TRANSFORM's work dir with --report sidecars).
44
+ SHUTDOWN_WAIT = 5.0
45
+
46
+
47
+ class Page(Protocol):
48
+ """What the shell fans events out to: a live page and its client."""
49
+
50
+ client: Any # nicegui.Client; `is_deleted` prunes the page
51
+
52
+ def handle(self, event: dict[str, Any]) -> None: ...
53
+
54
+ def flush(self) -> None:
55
+ """After every event of one tick: send what the page coalesced (the
56
+ Suite table sends one transaction per tick, not one per event)."""
57
+ ...
58
+
59
+
60
+ @dataclass
61
+ class LoadState:
62
+ """Where the open stands, kept by the shell from the Session's `loading`
63
+ events (decision 9) and read by every page's progress line."""
64
+
65
+ phase: str = "idle" # idle | scan | results | done | failed
66
+ done: int = 0
67
+ total: int = 0
68
+ cached: bool = False
69
+ #: The last open's failure, or a warning from an open that completed —
70
+ #: one model's scoring raised; a reopen that could not build its backend
71
+ #: and left the project as it was.
72
+ error: str | None = None
73
+ #: Models announced with `error=True`: scored by nobody, drawn as such.
74
+ failed_models: list[str] = field(default_factory=list)
75
+
76
+ @property
77
+ def in_progress(self) -> bool:
78
+ return self.phase in ("scan", "results")
79
+
80
+ def text(self) -> str:
81
+ """The progress line (brief §3.8: "scanning library… N tests found")."""
82
+ if self.phase == "scan":
83
+ if self.cached:
84
+ return f"library scan from cache: {plural(self.total, 'test')}"
85
+ if self.total:
86
+ return f"scanning library… {self.done} of {self.total}"
87
+ return "scanning library…"
88
+ if self.phase == "results":
89
+ return f"scoring results… {self.done} of {self.total}"
90
+ if self.phase == "failed":
91
+ return f"could not open the project: {self.error}"
92
+ if self.error:
93
+ return f"warning: {self.error}"
94
+ return ""
95
+
96
+
97
+ class Shell:
98
+ def __init__(
99
+ self,
100
+ config_path: Path,
101
+ *,
102
+ work_dir: Path | None = None,
103
+ filter: str | None = None,
104
+ package: str | None = None,
105
+ show_browser: bool = True,
106
+ ) -> None:
107
+ self.config_path = Path(config_path)
108
+ self.work_dir = work_dir
109
+ self.filter, self.package = filter, package
110
+ self.show_browser = show_browser
111
+ # What other threads post — the simulation pool's callbacks, the
112
+ # rescan's merge: Session mutations that must run on the UI loop.
113
+ # `tick` runs them.
114
+ self._posted: queue.Queue[Callable[[], None]] = queue.Queue()
115
+ self.session = Session(post=self._posted.put)
116
+ # Every Session event, from whichever thread emitted it, crosses here
117
+ # and is fanned out to the pages on the UI loop by `tick`.
118
+ self._events: queue.Queue[dict[str, Any]] = queue.Queue()
119
+ #: The models an open named before its scan finished (its `known`
120
+ #: event), until the merge's `spec` event: the tests while an open
121
+ #: is under way.
122
+ self.known: list[str] = []
123
+ for event in (
124
+ "spec",
125
+ "results",
126
+ "queue",
127
+ "notice",
128
+ "loading",
129
+ "known",
130
+ "overlays",
131
+ ):
132
+ self.session.on(event, self._events.put)
133
+ self.pages: list[Page] = []
134
+ self.load = LoadState()
135
+ self._open_thread: threading.Thread | None = None
136
+ self.children: list[subprocess.Popen[bytes]] = []
137
+ #: (capability name, what) already logged: `available` / `caption` are read
138
+ #: on every refresh, so a raising one is logged once, not per refresh.
139
+ self._reported: set[tuple[str, str]] = set()
140
+ self._mounted = False
141
+ self._timer: Any = None
142
+ self._ticks = 0
143
+
144
+ # -- the open ------------------------------------------------------------
145
+
146
+ def open_async(self, backend: str | None = None) -> None:
147
+ """Open the project — or reopen it on `backend` (brief §3.6) — on a
148
+ thread; the pages follow the `loading` events and the `opened`
149
+ event this shell adds at the end. Refused while an open runs.
150
+ `Session.open` itself refuses while edits are pending or the queue
151
+ holds a test, and that refusal arrives as the `opened` event's error."""
152
+ if self._open_thread is not None and self._open_thread.is_alive():
153
+ raise RuntimeError("the project is still opening")
154
+ self.load = LoadState(phase="scan")
155
+ thread = threading.Thread(
156
+ target=self._open_worker, args=(backend,), name="dstf-open", daemon=True
157
+ )
158
+ self._open_thread = thread
159
+ thread.start()
160
+
161
+ def _open_worker(self, backend: str | None) -> None:
162
+ error: str | None = None
163
+ try:
164
+ self.session.open(self.config_path, backend=backend, work_dir=self.work_dir)
165
+ except Exception as e: # shown on the page (decision 8), never lost on a thread
166
+ # A person's own — a testing.json or a spec edited by hand
167
+ # while the tool is open, then reopened — is its sentence on
168
+ # the page and no traceback in the console; a bug's is logged
169
+ # and keeps its class name (`user_text`).
170
+ log_unless_users(e, f"opening {self.config_path}")
171
+ error = user_text(e)
172
+ self._events.put({"event": "opened", "error": error})
173
+
174
+ # -- the loop --------------------------------------------------------------
175
+
176
+ def start(self) -> None:
177
+ """The app-level timer: one per process, once the event loop runs. A
178
+ stopped app nulls its timers' callbacks (`Timer._cleanup`), so a
179
+ Shell that outlives one app run gets a new timer with the next."""
180
+ if self._timer is None or self._timer.callback is None:
181
+ self._timer = app.timer(TICK, self.tick)
182
+
183
+ def register(self, page: Page) -> None:
184
+ self.pages.append(page)
185
+
186
+ def tick(self) -> None:
187
+ """Run what worker threads posted — each on its own: one that
188
+ raises is logged, and the rest of the tick follows — then hand every
189
+ queued Session event to every live page, then let each flush what it
190
+ coalesced — a page whose client is gone is dropped.
191
+ Once a second while a test runs or a worker is being started, the
192
+ running tests' growing result files are read (`Session.live_runs`,
193
+ D151) and a `poll` event follows: the clock a running row's words
194
+ and a starting worker's seconds are repainted by, and a running
195
+ test's page draws what was read by. A run's start and end are the queue's callbacks,
196
+ never a file's (D150)."""
197
+ while True:
198
+ try:
199
+ fn = self._posted.get_nowait()
200
+ except queue.Empty:
201
+ break
202
+ try:
203
+ fn()
204
+ except Exception: # one call's failure is not the others' wait
205
+ logger.exception("a posted call failed")
206
+ events: list[dict[str, Any]] = []
207
+ while True:
208
+ try:
209
+ events.append(self._events.get_nowait())
210
+ except queue.Empty:
211
+ break
212
+ self._ticks += 1
213
+ every = max(1, round(1.0 / TICK)) # once a second, whatever the tick
214
+ if self._ticks % every == 0 and self.clocked():
215
+ try:
216
+ self.session.live_runs.poll()
217
+ except Exception: # a read that failed is not the tick's end
218
+ logger.exception("the live poll failed")
219
+ events.append({"event": "poll"})
220
+ # `is_deleted` is set by the browser's disconnect (after NiceGUI's
221
+ # reconnect timeout); a page navigated away from in the user
222
+ # simulation is never deleted, so tests pick a page by its client.
223
+ self.pages = [p for p in self.pages if not p.client.is_deleted]
224
+ for event in _folded(events):
225
+ self._absorb(event)
226
+ for page in list(self.pages):
227
+ try:
228
+ page.handle(event)
229
+ except Exception: # one page's failure is not another's
230
+ logger.exception("a page failed on %r", event.get("event"))
231
+ for page in list(self.pages):
232
+ try:
233
+ page.flush()
234
+ except Exception:
235
+ logger.exception("a page failed to flush")
236
+
237
+ def _absorb(self, event: dict[str, Any]) -> None:
238
+ """`LoadState` from the events (decision 9)."""
239
+ kind = event["event"]
240
+ load = self.load
241
+ if kind == "known":
242
+ self.known = [str(m) for m in event.get("models", ())]
243
+ return
244
+ if kind == "spec":
245
+ # The merge (the project's tests now), or a close: either way
246
+ # the open's names are done with.
247
+ self.known = []
248
+ if kind == "loading":
249
+ phase = event.get("phase")
250
+ if phase == "scan":
251
+ if load.phase != "scan":
252
+ # An open this shell did not start (an `OpenProject`
253
+ # outcome runs `Session.open` on the UI loop): a fresh
254
+ # state, so the previous project's failures do not carry.
255
+ load = self.load = LoadState(phase="scan")
256
+ load.cached = bool(event.get("cached"))
257
+ load.done = int(event.get("done", 0))
258
+ load.total = int(event.get("total", 0))
259
+ elif phase == "results":
260
+ if not load.in_progress:
261
+ # A reload after the open — a store write's — announces
262
+ # the same events, and no `done` follows them: read
263
+ # here, it left the shell "still loading" for good (the
264
+ # Task 5 review's probe).
265
+ return
266
+ load.phase = "results"
267
+ load.done, load.total = int(event["done"]), int(event["total"])
268
+ if event.get("error"):
269
+ load.failed_models.append(str(event["model"]))
270
+ elif phase == "done":
271
+ load.phase = "done"
272
+ elif kind == "spec" and load.phase == "scan":
273
+ # The merge is done: the scan is over and the results are next.
274
+ load.phase = "results"
275
+ load.done, load.total = 0, len(event.get("models", ()))
276
+ elif kind == "opened":
277
+ error = event.get("error")
278
+ if error is None:
279
+ return
280
+ if self.session.project is not None:
281
+ # A project is open: this one, complete after a scoring
282
+ # failure, or the previous one after a refused reopen.
283
+ load.phase, load.error = "done", error
284
+ else:
285
+ load.phase, load.error = "failed", error
286
+
287
+ # -- what the pages read -------------------------------------------------
288
+
289
+ def placed(
290
+ self, placement: Placement, view: SessionView
291
+ ) -> list[tuple[Capability, bool | str]]:
292
+ """Every registered capability that attaches at `placement`, with
293
+ `available`'s answer for `view`, in registration order. An
294
+ `available` that raises answers with its exception: read on every
295
+ refresh of every page, it must never break the page (a plug-in
296
+ that raised here blanked the Suite page — the Task 7 review)."""
297
+ placed: list[tuple[Capability, bool | str]] = []
298
+ for cap in registered_capabilities():
299
+ if placement not in cap.placements:
300
+ continue
301
+ try:
302
+ answer: bool | str = cap.available(view)
303
+ except Exception as e: # the plug-in's, shown as its button's reason
304
+ answer = f"{type(e).__name__}: {e}"
305
+ self.report_once(cap.name, "available", e)
306
+ placed.append((cap, answer))
307
+ return placed
308
+
309
+ def report_once(self, name: str, what: str, error: Exception) -> None:
310
+ """Log a capability's raising `available` / `caption` with its
311
+ traceback, once per capability and method: the button's why-line
312
+ is the user's signal, the log is the plug-in author's."""
313
+ if (name, what) in self._reported:
314
+ return
315
+ self._reported.add((name, what))
316
+ logger.warning(
317
+ "capability %r: %s raised %s: %s",
318
+ name,
319
+ what,
320
+ type(error).__name__,
321
+ error,
322
+ exc_info=error,
323
+ )
324
+
325
+ def view_for(self, selection: Selection) -> SessionView:
326
+ return SessionView(self.session, selection)
327
+
328
+ def visible_tests(self) -> list[str]:
329
+ """The project's tests after the CLI's `--filter` / `--package` —
330
+ while an open is under way, the models it named, filtered the same
331
+ way (`_filter_tests` reads nothing but the model id)."""
332
+ project = self.session.project
333
+ if self.known:
334
+ tests: list[Any] = [_Named(m) for m in self.known]
335
+ elif project is not None:
336
+ tests = list(project.tests)
337
+ else:
338
+ return []
339
+ return [t.model_id for t in _filter_tests(tests, self.filter, self.package)]
340
+
341
+ def running(self) -> bool:
342
+ """Whether the queue is running a test now."""
343
+ session = self.session
344
+ return any(session.item_state(i) is ItemState.RUNNING for i in session.queue)
345
+
346
+ def clocked(self) -> bool:
347
+ """Whether anything of the queue moves by the second — what the
348
+ `poll` is for: a test is running, or a worker is being started for
349
+ one that waits (a library's load is minutes)."""
350
+ return self.running() or bool(self.session.starting_workers)
351
+
352
+ def busy_reason(self) -> str | None:
353
+ """Why a Save, a Discard or a backend change is refused now, or None:
354
+ the open thread is still writing. The queue refuses none of the
355
+ first two (D150): a queued or running test stays editable."""
356
+ if self.load.in_progress:
357
+ return "the project is still loading"
358
+ return None
359
+
360
+ def stop_reasons(self) -> list[str]:
361
+ """What Stop server would lose (brief §3.3, §3.6): pending edits, and
362
+ the tests the queue still holds."""
363
+ reasons: list[str] = []
364
+ n = edit_count(self.session.spec.pending)
365
+ if n:
366
+ reasons.append(f"{plural(n, 'unsaved edit')} will be lost")
367
+ held = stop_reason(self.session)
368
+ if held is not None:
369
+ reasons.append(f"{held} — stop anyway?")
370
+ return reasons
371
+
372
+ def stop(self) -> None:
373
+ """Stop server: the queue first — its waiting tests removed, its
374
+ running ones stopped, every worker's simulator process ended — then
375
+ the children (decision 7), then NiceGUI."""
376
+ self.session.stop_queue()
377
+ self.terminate_children()
378
+ app.shutdown()
379
+
380
+ def shutdown(self) -> None:
381
+ """NiceGUI's shutdown (Stop server, a Ctrl-C): the queue's pool ends
382
+ — a Ctrl-C leaves no simulator process behind either — then a
383
+ drained queue's dashboard render still running off the UI loop
384
+ lands (it is on a daemon thread the process's exit would end
385
+ mid-write), then the children end (decision 7)."""
386
+ self.session.stop_queue()
387
+ if not self.session.wait_background(SHUTDOWN_WAIT):
388
+ logger.warning(
389
+ "exiting with the queue's dashboard render still running after %.0f s",
390
+ SHUTDOWN_WAIT,
391
+ )
392
+ self.terminate_children()
393
+
394
+ def terminate_children(self) -> None:
395
+ for child in self.children:
396
+ if child.poll() is None:
397
+ child.terminate()
398
+
399
+ def spawn(self, backend: str) -> str:
400
+ """`Open in another backend…` (brief §3.6): a second `dstf author` on
401
+ `backend`, on a free port, from the same config — with this
402
+ process's `--work-dir`, `--filter` and `--package` when it got them
403
+ (decision 6: `--work-dir` means "this work dir"). The child opens its
404
+ own browser tab unless this process was started with `--no-browser`,
405
+ so no popup for the browser to block; the URL is returned for the
406
+ dialog's link. `terminate_children` ends what this started
407
+ (decision 7)."""
408
+ port = _free_port()
409
+ cmd = [
410
+ sys.executable,
411
+ "-m",
412
+ "dstf",
413
+ "--config",
414
+ str(self.config_path),
415
+ "author",
416
+ "--backend",
417
+ backend,
418
+ "--port",
419
+ str(port),
420
+ ]
421
+ if self.work_dir is not None:
422
+ cmd += ["--work-dir", str(self.work_dir)]
423
+ if self.filter:
424
+ cmd += ["--filter", self.filter]
425
+ if self.package:
426
+ cmd += ["--package", self.package]
427
+ if not self.show_browser:
428
+ cmd.append("--no-browser")
429
+ self.children.append(subprocess.Popen(cmd))
430
+ return f"http://127.0.0.1:{port}/"
431
+
432
+ # -- the pages ---------------------------------------------------------------
433
+
434
+ def page_setup(
435
+ self, state: TabState, switched: Callable[[], None] | None = None
436
+ ) -> TabTheme:
437
+ """Every page starts here: the Quasar palette and the token
438
+ stylesheet from `reporting.theme`, in the tab's theme (`TabTheme`,
439
+ which ``switched`` is handed to), the shell's own on top — one token
440
+ table for the dashboard, the report and the tool (roadmap §1
441
+ predicate 2; the body font is this shell's to apply)."""
442
+ theme = TabTheme(self, state, switched)
443
+ ui.add_css(level_variables() + STYLE + QUASAR)
444
+ return theme
445
+
446
+ def mount(self) -> None:
447
+ """The routes beside the index: the Suite table's two scripts, the
448
+ Test page's grips module, the dashboard's files, resolved against the OPEN project's work dir per
449
+ request so a reopen follows, and the Test page. Once per process; `index` calls it too, so a
450
+ simulation that mounted only the root page gets them on its first
451
+ build."""
452
+ if self._mounted:
453
+ return
454
+ self._mounted = True
455
+
456
+ # The Suite table's scripts: its own and the typed filters the
457
+ # dashboard inlines (one module, two surfaces).
458
+ from .suite_grid import ASSETS, FILTERS_JS, HERE
459
+
460
+ app.add_static_file(
461
+ local_file=HERE / "suite_grid.js", url_path=f"{ASSETS}/suite_grid.js"
462
+ )
463
+ app.add_static_file(local_file=FILTERS_JS, url_path=f"{ASSETS}/filters.js")
464
+ # The Test page's: the tolerance grips' hover (`grips.js`).
465
+ from .plot_card import GRIPS_JS, GRIPS_URL
466
+
467
+ app.add_static_file(local_file=GRIPS_JS, url_path=GRIPS_URL)
468
+
469
+ @app.get("/dashboard/{path:path}")
470
+ def _dashboard(path: str) -> Any:
471
+ project = self.session.project
472
+ if project is None:
473
+ return JSONResponse({"error": "no project open"}, status_code=409)
474
+ work_dir = project.config.work_dir
475
+ assert work_dir is not None # resolved in Config.__post_init__
476
+ target = (work_dir / path).resolve()
477
+ if work_dir.resolve() not in target.parents or not target.is_file():
478
+ return JSONResponse({"error": "not found"}, status_code=404)
479
+ return FileResponse(target)
480
+
481
+ @ui.page("/test/{model_id}")
482
+ async def _test(model_id: str) -> None:
483
+ from .test_page import TestPage
484
+
485
+ self.start()
486
+ await TestPage(self).build(unquote(model_id))
487
+
488
+ async def index(self) -> None:
489
+ """The Suite page at `/`: `ui.run(shell.index, ...)` mounts it, the
490
+ tests `user_simulation(root=shell.index)`."""
491
+ from .suite import SuitePage
492
+
493
+ self.mount()
494
+ self.start()
495
+ await SuitePage(self).build()
496
+
497
+
498
+ def _is_scan_step(event: dict[str, Any]) -> bool:
499
+ return event["event"] == "loading" and event.get("phase") == "scan"
500
+
501
+
502
+ def _folded(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
503
+ """``events`` with each run of adjacent scan steps reduced to its last:
504
+ a step carries the running count, so the last says all the run does.
505
+ One by one, every step synced an open Test page in full and re-read
506
+ `status.json` in its header: one tick over 3,269 queued steps
507
+ (TRANSFORM's .mo count, a TRANSFORM-sized `status.json`, one Test page)
508
+ held the UI loop about 13 s, and 0.02 s folded."""
509
+ out: list[dict[str, Any]] = []
510
+ for event in events:
511
+ if out and _is_scan_step(event) and _is_scan_step(out[-1]):
512
+ out[-1] = event
513
+ else:
514
+ out.append(event)
515
+ return out
516
+
517
+
518
+ @dataclass(frozen=True)
519
+ class _Named:
520
+ """A model id where `_filter_tests` wants a test."""
521
+
522
+ model_id: str
523
+
524
+
525
+ def _free_port() -> int:
526
+ with socket.socket() as s:
527
+ s.bind(("127.0.0.1", 0))
528
+ return int(s.getsockname()[1])