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,354 @@
1
+ """The simulation queue as data (D150): one :class:`QueueRow` per item the
2
+ Session's queue lists, in run order, and the header's indicator — the words
3
+ each state is said in, the status its badge wears, what its row offers. The
4
+ queue panel, the header and the Test page's strip draw from these; no UI
5
+ framework here (the Session tier), and no status word of its own
6
+ (``reporting.status``)."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import time
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from ..backends.pool import ItemState
17
+ from ..reporting.status import CANCELLED, NOT_RUN, QUEUED, RUNNING, SIM_FAIL, classify
18
+ from .capabilities import plural
19
+
20
+ if TYPE_CHECKING:
21
+ from ..backends.pool import QueueItem, StartingWorker
22
+ from .session import Session
23
+
24
+ #: What a row may offer, by the name the panel draws it under.
25
+ REMOVE, CANCEL, OPEN = "remove", "cancel", "open"
26
+
27
+ #: A running row a stop was asked of, until it ends.
28
+ CANCELLING = "cancelling…"
29
+
30
+ #: How much of the stopped pool's message the header shows; the rest is its
31
+ #: tooltip.
32
+ INDICATOR_WIDTH = 48
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class QueueRow:
37
+ """One item of the queue, as its row says it."""
38
+
39
+ #: Its place in the queue's list, from 1: the run order (first in,
40
+ #: first out).
41
+ number: int
42
+ model_id: str
43
+ state: ItemState
44
+ #: The state in words: ``waiting · 3 ahead`` (``waiting · next`` with
45
+ #: none, ``waiting · worker starting`` while no worker is up, ``waiting
46
+ #: · checking the library`` while its click's read is out and the queue
47
+ #: does not hold it yet),
48
+ #: ``running · 42 s`` (``running · t = 42 / 100 s`` where the backend
49
+ #: has a live view, D151; ``running · worker restarting`` while its
50
+ #: worker is brought back after a death, ``running · reading the
51
+ #: library`` while its worker reads files that changed since it read
52
+ #: them),
53
+ #: ``cancelling…`` once a stop was asked of a running one and until it
54
+ #: ends, ``finished · 9 s``, ``failed: <the backend's message, one
55
+ #: line>``, ``cancelled``.
56
+ words: str
57
+ #: The badge: QUEUED / RUNNING while the queue holds it, the test's
58
+ #: verdict once it finished, SIM_FAIL for a run that failed, CANCELLED,
59
+ #: and NOT RUN for an item no worker could start. None for a finished
60
+ #: row that is not its test's `latest`: a verdict is the TEST's, as it
61
+ #: stands now, and one test has one — on the row of its last run. Two
62
+ #: rows of one test both read the later run's verdict, the earlier
63
+ #: one beside the words of a run that had ended otherwise.
64
+ status: str | None
65
+ #: The worker it runs on, from 1, while it runs.
66
+ worker: int | None = None
67
+ #: A running item a stop was asked of (`QueueItem.stopping`): it
68
+ #: offers no second Cancel, and says so until it ends.
69
+ cancelling: bool = False
70
+ #: The items ahead of a waiting one: running, or waiting before it.
71
+ ahead: int = 0
72
+ #: Seconds since it started while it runs; its run's length once ended.
73
+ elapsed: float | None = None
74
+ #: How far a running item's result file has got (``t = 42 / 100 s``,
75
+ #: ``starting`` before its first row), or ``worker restarting``, or
76
+ #: ``reading the library``; None on a backend with no live view, and
77
+ #: for an item that is not running.
78
+ progress: str | None = None
79
+ #: The backend's message for a failed item, in full.
80
+ message: str | None = None
81
+ #: Whether a worker ever took it: False for a waiting item and for one
82
+ #: the pool failed because no worker could start.
83
+ started: bool = False
84
+ #: ``remove`` while it waits, ``cancel`` while it runs on a backend that
85
+ #: can stop a test, ``open`` (its Test page) once it has ended; none
86
+ #: while its click is being read — there is no item to act on yet.
87
+ actions: tuple[str, ...] = ()
88
+ #: Whether no later item of its test is listed. An ended row that is
89
+ #: not the latest says how its own run ended (``finished · 3 s``) and
90
+ #: offers its Test page, nothing else: what acts on the TEST — Accept,
91
+ #: Queue again — acts on the test's latest results, and under an
92
+ #: earlier row's name that reads as accepting that run's.
93
+ latest: bool = True
94
+ #: Why a running item has no ``cancel``: the backend's sentence.
95
+ cancel_why: str | None = None
96
+
97
+
98
+ #: Where Python's traceback starts in a message.
99
+ _TRACEBACK = "Traceback (most recent call last):"
100
+
101
+
102
+ def one_line(text: str) -> str:
103
+ """``text`` as one line: its first line that says something — and, for
104
+ a message that carries a Python traceback, what was raised (the
105
+ traceback's LAST line) after whatever the message said before it. The
106
+ first line alone read "Python execution failed: Traceback (most recent
107
+ call last):" for every failing `simulate`."""
108
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
109
+ if not lines:
110
+ return ""
111
+ head, found, _ = lines[0].partition(_TRACEBACK)
112
+ if found and len(lines) > 1:
113
+ return f"{head.strip()} {lines[-1]}".strip()
114
+ return lines[0]
115
+
116
+
117
+ #: A quoted absolute path in a backend's phase: all but its last part is
118
+ #: cut (``loadFile("…/package.mo")``).
119
+ _LONG_PATH = re.compile(r'"(?:[A-Za-z]:)?[/\\][^"]*[/\\]([^"/\\]+)"')
120
+
121
+
122
+ #: A running row whose worker died and is being brought back.
123
+ RESTARTING = "worker restarting"
124
+
125
+ #: A running row whose worker is up and reads the library again before the
126
+ #: test: its files changed since the worker read them, and a large library
127
+ #: is as long to read again as it was to load. The ONE phrase for it — the
128
+ #: row's words, the strip's sentence and the worker's line all say this.
129
+ READING = "reading the library"
130
+
131
+ #: A row of a test a Queue click named, while that click's read of the
132
+ #: library is still out (`Session.checking`): the tool has taken the test
133
+ #: and is looking at the files — a stat of each, and the test's own file
134
+ #: read again where it changed. It has no place in the queue yet, and the
135
+ #: read may still refuse it (its file is gone). The ONE phrase for it.
136
+ CHECKING = "checking the library"
137
+
138
+
139
+ def starting_text(worker: StartingWorker) -> str:
140
+ """One worker that is not ready, as the queue panel lists it:
141
+ ``starting worker 1 — loadFile("…/package.mo") · 85 s``, the phase in
142
+ the backend's own words with a path cut to its last part; ``starting
143
+ worker 1 · 85 s`` where the backend names no phase; ``restarting worker
144
+ 1 — …`` for one brought back after a death; ``worker 1 reading the
145
+ library — …`` for one that is up and reads changed files again."""
146
+ phase = _LONG_PATH.sub(r'"…/\1"', worker.phase)
147
+ doing = f" — {phase}" if phase else ""
148
+ number = worker.worker_id + 1
149
+ if worker.reload:
150
+ return f"worker {number} {READING}{doing} · {worker.seconds:.0f} s"
151
+ verb = "restarting" if worker.restart else "starting"
152
+ return f"{verb} worker {number}{doing} · {worker.seconds:.0f} s"
153
+
154
+
155
+ def starting_texts(session: Session) -> list[str]:
156
+ """The queue's workers being brought up right now, by worker."""
157
+ return [starting_text(worker) for worker in session.starting_workers]
158
+
159
+
160
+ def _row(
161
+ session: Session,
162
+ item: QueueItem,
163
+ number: int,
164
+ ahead: int,
165
+ now: float,
166
+ starting: Mapping[int, StartingWorker],
167
+ latest: bool,
168
+ ) -> QueueRow:
169
+ state = session.item_state(item)
170
+ started = item.started_at is not None
171
+ fields: dict[str, Any] = {}
172
+ if state is ItemState.WAITING:
173
+ if ahead:
174
+ words = f"waiting · {ahead} ahead"
175
+ else:
176
+ # Nothing ahead and it has not started: no worker is up. One
177
+ # being brought up says so — a library's load is minutes, and
178
+ # "next" for that long reads as a hang.
179
+ words = "waiting · worker starting" if starting else "waiting · next"
180
+ fields = {
181
+ "words": words,
182
+ "status": QUEUED,
183
+ "ahead": ahead,
184
+ "actions": (REMOVE,),
185
+ }
186
+ # Failed by the pool and not yet taken in: no worker took it.
187
+ started = False
188
+ elif state is ItemState.RUNNING:
189
+ elapsed = max(0.0, now - (item.started_at or now))
190
+ why = session.cannot_cancel
191
+ # Its worker died and is being brought back, or is reading the
192
+ # library again: the test has not begun, whatever its file or its
193
+ # clock would say.
194
+ busy = starting.get(item.worker_id) if item.worker_id is not None else None
195
+ live = (
196
+ session.live_runs.progress(item)
197
+ if busy is None
198
+ else (READING if busy.reload else RESTARTING)
199
+ )
200
+ fields = {
201
+ "words": f"running · {live or f'{elapsed:.0f} s'}",
202
+ "progress": live,
203
+ "status": RUNNING,
204
+ "worker": None if item.worker_id is None else item.worker_id + 1,
205
+ "elapsed": elapsed,
206
+ "actions": () if why is not None else (CANCEL,),
207
+ "cancel_why": why,
208
+ }
209
+ if item.stopping:
210
+ # Cancel was taken and the simulation has not ended: one that
211
+ # does not look at the request runs on for its worker's grace
212
+ # (Dymola, a model blocked in an external call: about 11 s),
213
+ # and `running · 42 s` beside a Cancel button said the click
214
+ # went nowhere.
215
+ fields.update(words=CANCELLING, cancelling=True, actions=())
216
+ else:
217
+ ran = (
218
+ item.ended_at - item.started_at
219
+ if item.started_at is not None and item.ended_at is not None
220
+ else None
221
+ )
222
+ fields = {"elapsed": ran, "actions": (OPEN,)}
223
+ if state is ItemState.FINISHED:
224
+ comp = session.comparisons.get(item.model_id)
225
+ # The test's verdict as it stands — the latest row's alone.
226
+ fields["status"] = (
227
+ None if not latest else classify(comp) if comp is not None else SIM_FAIL
228
+ )
229
+ fields["words"] = "finished" + (
230
+ f" · {ran:.0f} s" if ran is not None else ""
231
+ )
232
+ elif state is ItemState.CANCELLED:
233
+ fields.update(status=CANCELLED, words="cancelled")
234
+ else:
235
+ message = item.message or "the simulation failed"
236
+ fields.update(
237
+ status=SIM_FAIL if started else NOT_RUN,
238
+ words=f"failed: {one_line(message)}",
239
+ message=message,
240
+ )
241
+ return QueueRow(
242
+ number=number,
243
+ model_id=item.model_id,
244
+ state=state,
245
+ started=started,
246
+ latest=latest,
247
+ **fields,
248
+ )
249
+
250
+
251
+ def queue_rows(session: Session, *, now: float | None = None) -> list[QueueRow]:
252
+ """The queue's rows, in run order — and after them a row per test a
253
+ click named whose read of the library is still out
254
+ (`Session.checking`): taken, waiting, with no item yet. ``now`` is the
255
+ clock a running row's elapsed seconds are read against
256
+ (``time.time()``)."""
257
+ clock = time.time() if now is None else now
258
+ starting = {w.worker_id: w for w in session.starting_workers}
259
+ rows: list[QueueRow] = []
260
+ ahead = 0
261
+ last = {item.model_id: item for item in session.queue}
262
+ for number, item in enumerate(session.queue, start=1):
263
+ latest = last[item.model_id] is item
264
+ row = _row(session, item, number, ahead, clock, starting, latest)
265
+ if row.state in (ItemState.WAITING, ItemState.RUNNING):
266
+ ahead += 1
267
+ rows.append(row)
268
+ for model_id in session.checking:
269
+ rows.append(
270
+ QueueRow(
271
+ number=len(rows) + 1,
272
+ model_id=model_id,
273
+ state=ItemState.WAITING,
274
+ words=f"waiting · {CHECKING}",
275
+ status=QUEUED,
276
+ ahead=ahead,
277
+ )
278
+ )
279
+ ahead += 1
280
+ return rows
281
+
282
+
283
+ def queue_row(
284
+ session: Session, model_id: str, *, now: float | None = None
285
+ ) -> QueueRow | None:
286
+ """The row of ``model_id``'s item — the last one listed — or None."""
287
+ return next(
288
+ (r for r in reversed(queue_rows(session, now=now)) if r.model_id == model_id),
289
+ None,
290
+ )
291
+
292
+
293
+ def counts_text(rows: list[QueueRow]) -> str:
294
+ """The panel's header: how many rows are in each state, in the order a
295
+ queue passes through them; ``queue empty`` without a row."""
296
+ parts = [
297
+ f"{n} {word}"
298
+ for word, n in (
299
+ ("running", sum(r.state is ItemState.RUNNING for r in rows)),
300
+ ("queued", sum(r.state is ItemState.WAITING for r in rows)),
301
+ ("finished", sum(r.state is ItemState.FINISHED for r in rows)),
302
+ ("failed", sum(r.state is ItemState.FAILED for r in rows)),
303
+ ("cancelled", sum(r.state is ItemState.CANCELLED for r in rows)),
304
+ )
305
+ if n
306
+ ]
307
+ return " · ".join(parts) if parts else "queue empty"
308
+
309
+
310
+ @dataclass(frozen=True)
311
+ class Indicator:
312
+ """The header's queue indicator: ``3 running · 5 queued``, ``idle``, or
313
+ — ``tone`` ``failed`` — the first words of the message the pool stopped
314
+ with, the whole of it in ``tip``."""
315
+
316
+ text: str
317
+ tone: str # "idle" | "running" | "failed"
318
+ tip: str
319
+
320
+
321
+ def indicator(session: Session) -> Indicator:
322
+ rows = queue_rows(session)
323
+ running = sum(r.state is ItemState.RUNNING for r in rows)
324
+ queued = sum(r.state is ItemState.WAITING for r in rows)
325
+ busy = " · ".join(
326
+ f"{n} {word}" for word, n in (("running", running), ("queued", queued)) if n
327
+ )
328
+ stopped = session.queue_stopped
329
+ if stopped is not None:
330
+ line = one_line(stopped)
331
+ short = (
332
+ line
333
+ if len(line) <= INDICATOR_WIDTH
334
+ else line[:INDICATOR_WIDTH].rstrip() + "…"
335
+ )
336
+ return Indicator(
337
+ " · ".join(t for t in (short, busy) if t),
338
+ "failed",
339
+ f"{stopped} — Clear finished in the queue panel dismisses this.",
340
+ )
341
+ if busy:
342
+ return Indicator(busy, "running", "The simulation queue. Click to open it.")
343
+ return Indicator("idle", "idle", "Nothing is queued. Click to open the queue.")
344
+
345
+
346
+ def stop_reason(session: Session) -> str | None:
347
+ """What Stop server says of the queue (brief §3.6: it confirms when the
348
+ queue is not empty): how many tests it still holds, or None."""
349
+ held = sum(
350
+ r.state in (ItemState.WAITING, ItemState.RUNNING) for r in queue_rows(session)
351
+ )
352
+ if not held:
353
+ return None
354
+ return f"{plural(held, 'test')} {'is' if held == 1 else 'are'} queued or running"