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,604 @@
1
+ // Unified dashboard JS: filter / sort / fetch loop / refresh button.
2
+ // Loaded inline via a Jinja include directive in dashboard.html.
3
+ // Reads the top-level Jinja-injected constants: DASHBOARD_MODE
4
+ // ('live' or 'final'), DASHBOARD_TOTAL (int) and STATUSES (the count
5
+ // pills, [status class, word] in PILL_STATUSES' order).
6
+
7
+ (function() {
8
+ const tbody = document.getElementById('results-tbody');
9
+ const headers = document.querySelectorAll('#results-table thead tr:first-child th[data-sort]');
10
+ const colFilters = document.querySelectorAll('input.col-filter');
11
+
12
+ // ---- Column resize (drag handles on header th's) ----
13
+ // Each header th has a `<span class="col-resize-handle">` strip on its
14
+ // right edge; mousedown there starts a drag that updates th.style.width
15
+ // (the table inherits from the header in auto-layout so tds follow).
16
+ // For the Model + Detail columns, dragging also updates the
17
+ // `--col-w-<key>` CSS variable so .model-cell / .detail-cell's
18
+ // max-width tracks the new column width — without this, the td's
19
+ // ellipsis-truncation cap would override the resize.
20
+ // Widths persist to localStorage so they survive the 5s meta-refresh
21
+ // ticks; restored on page load alongside filter / selection / sort.
22
+ const colWidths = {}; // {key: '500px'} — populated by drag + restore
23
+
24
+ function applyColWidth(th, width) {
25
+ th.style.width = width;
26
+ th.style.minWidth = width;
27
+ const key = th.dataset.key;
28
+ if (key) {
29
+ colWidths[key] = width;
30
+ // Mirror to a CSS variable so cells with max-width truncation
31
+ // (Model, Detail) widen along with the th. Cells without
32
+ // truncation just respect the th's width via auto-layout.
33
+ document.documentElement.style.setProperty('--col-w-' + key, width);
34
+ }
35
+ }
36
+
37
+ function startColResize(e, th) {
38
+ e.preventDefault();
39
+ e.stopPropagation(); // Don't trigger the th's click-to-sort handler
40
+ const startX = e.clientX;
41
+ const startWidth = th.offsetWidth;
42
+ th.classList.add('resizing');
43
+ document.body.style.cursor = 'col-resize';
44
+
45
+ function onMove(ev) {
46
+ const delta = ev.clientX - startX;
47
+ const newWidth = Math.max(40, startWidth + delta);
48
+ applyColWidth(th, newWidth + 'px');
49
+ }
50
+ function onUp() {
51
+ th.classList.remove('resizing');
52
+ document.body.style.cursor = '';
53
+ document.removeEventListener('mousemove', onMove);
54
+ document.removeEventListener('mouseup', onUp);
55
+ persistState();
56
+ }
57
+ document.addEventListener('mousemove', onMove);
58
+ document.addEventListener('mouseup', onUp);
59
+ }
60
+
61
+ document.querySelectorAll('.col-resize-handle').forEach(handle => {
62
+ handle.addEventListener('mousedown', e => {
63
+ const th = handle.closest('th');
64
+ if (th) startColResize(e, th);
65
+ });
66
+ // Eat clicks so they don't bubble up to the th's sort handler
67
+ handle.addEventListener('click', e => e.stopPropagation());
68
+ });
69
+
70
+ // ---- Sticky-chrome height tracking ----
71
+ // The chrome (h1 + status bar + progress bar + counter pills) is sticky
72
+ // at top:0; the two thead rows stack below it via CSS variables. The
73
+ // chrome's height varies (counter pills wrap on narrow viewports, header
74
+ // copy length differs between live and final modes), so we measure it
75
+ // and feed --chrome-height + --header-row-height to CSS. Fallback values
76
+ // in the CSS rules cover the brief moment before the first measurement.
77
+ function updateChromeHeight() {
78
+ const chrome = document.getElementById('sticky-chrome');
79
+ const headerRow = document.querySelector('#results-table thead tr:first-child');
80
+ if (chrome) {
81
+ document.documentElement.style.setProperty(
82
+ '--chrome-height', chrome.offsetHeight + 'px',
83
+ );
84
+ }
85
+ if (headerRow) {
86
+ document.documentElement.style.setProperty(
87
+ '--header-row-height', headerRow.offsetHeight + 'px',
88
+ );
89
+ }
90
+ }
91
+ updateChromeHeight();
92
+ if (typeof ResizeObserver !== 'undefined') {
93
+ const chrome = document.getElementById('sticky-chrome');
94
+ if (chrome) new ResizeObserver(updateChromeHeight).observe(chrome);
95
+ }
96
+ window.addEventListener('resize', updateChromeHeight);
97
+
98
+ // ---- Filter (status pills + per-column text) ----
99
+ // Counter pills are dual-purpose: they show the count AND act as filter
100
+ // toggles. Click a pill to add its status to the filter set; click again
101
+ // to remove. Click Total to clear all filters. Empty set = show all.
102
+ // Pills with zero count are disabled (no-op on click).
103
+ const activeStatuses = new Set();
104
+ const colFilterValues = {};
105
+
106
+ // Every column filter compiles through DstfFilters (filters.js): text by
107
+ // substring, glob or (with .* on) regex; numbers by comparison or range.
108
+ // A number filter reads the row's data-sort-<key> — the raw value, never
109
+ // its display text. The .* and Aa toggles apply to every column at once.
110
+ const toggles = { regex: false, case: false };
111
+
112
+ function statusMatches(rowStatus, sortWarnings) {
113
+ if (activeStatuses.size === 0) return true;
114
+ // A pill carries its status class, the row's data-status; "warnings"
115
+ // is the cross-cut: any row with a warning, whatever its status.
116
+ for (const pill of activeStatuses) {
117
+ if (pill === 'warnings' && sortWarnings > 0) return true;
118
+ if (pill === rowStatus) return true;
119
+ }
120
+ return false;
121
+ }
122
+
123
+ function applyFilters() {
124
+ // Compile per-column matchers once per filter event (cheap; rebuilt
125
+ // when any input or toggle changes). A query that cannot be read marks
126
+ // its input (red outline, the reason in its tooltip) and filters
127
+ // nothing.
128
+ const matchers = [];
129
+ colFilters.forEach(inp => {
130
+ const col = inp.dataset.colFilter;
131
+ const kind = inp.dataset.kind;
132
+ if (inp.dataset.help === undefined) inp.dataset.help = inp.title;
133
+ inp.classList.remove('bad');
134
+ inp.title = inp.dataset.help;
135
+ let pred = null;
136
+ try {
137
+ pred = DstfFilters.compile(kind, colFilterValues[col] || '', toggles);
138
+ } catch (e) {
139
+ if (!(e instanceof DstfFilters.BadFilter)) throw e;
140
+ inp.classList.add('bad');
141
+ inp.title = e.message;
142
+ }
143
+ if (pred) {
144
+ const attr = kind === 'number' ? 'sort' + col[0].toUpperCase() + col.slice(1) : col;
145
+ matchers.push(row => pred(row.dataset[attr]));
146
+ }
147
+ });
148
+ Array.from(tbody.querySelectorAll('tr')).forEach(row => {
149
+ const sortWarnings = parseInt(row.dataset.sortWarnings || '0');
150
+ let visible = statusMatches(row.dataset.status, sortWarnings);
151
+ if (visible) {
152
+ for (const match of matchers) {
153
+ if (!match(row)) {
154
+ visible = false;
155
+ break;
156
+ }
157
+ }
158
+ }
159
+ row.style.display = visible ? '' : 'none';
160
+ });
161
+ // Filter changes shift which rows are visible, which affects the
162
+ // tristate header checkbox + the "(M hidden by filter)" hint in the
163
+ // footer. The selection itself doesn't change here — filters never
164
+ // touch ticks. syncSelectionUI is hoisted (function declaration) so
165
+ // forward-reference is fine even though it's defined further down.
166
+ if (typeof syncSelectionUI === 'function') syncSelectionUI();
167
+ }
168
+
169
+ function syncPillStates() {
170
+ document.querySelectorAll('.counter[data-pill]').forEach(p => {
171
+ const key = p.dataset.pill;
172
+ p.classList.toggle('active', activeStatuses.has(key));
173
+ });
174
+ }
175
+
176
+ // Pill click handler. Total clears all filters; other pills toggle
177
+ // their key in/out of the active set. Disabled pills (zero count)
178
+ // are inert.
179
+ window.togglePill = function(key, el) {
180
+ if (el && el.classList.contains('disabled')) return;
181
+ if (key === 'total') {
182
+ activeStatuses.clear();
183
+ } else if (activeStatuses.has(key)) {
184
+ activeStatuses.delete(key);
185
+ } else {
186
+ activeStatuses.add(key);
187
+ }
188
+ syncPillStates();
189
+ applyFilters();
190
+ };
191
+
192
+ const TOGGLE_BUTTONS = [['regex', 'toggle-regex'], ['case', 'toggle-case']];
193
+ function syncToggles() {
194
+ for (const [key, id] of TOGGLE_BUTTONS) {
195
+ const b = document.getElementById(id);
196
+ if (b) b.setAttribute('aria-pressed', toggles[key] ? 'true' : 'false');
197
+ }
198
+ }
199
+ for (const [key, id] of TOGGLE_BUTTONS) {
200
+ const b = document.getElementById(id);
201
+ if (b) b.addEventListener('click', () => {
202
+ toggles[key] = !toggles[key];
203
+ syncToggles();
204
+ applyFilters();
205
+ persistState();
206
+ });
207
+ }
208
+
209
+ colFilters.forEach(inp => {
210
+ inp.addEventListener('input', () => {
211
+ colFilterValues[inp.dataset.colFilter] = inp.value;
212
+ applyFilters();
213
+ });
214
+ });
215
+
216
+ // ---- 3-state sort cycle: none → sorted → reverse → none ----
217
+ // Numeric columns descend first (largest NRMSE first when triaging);
218
+ // text columns ascend first (alphabetical).
219
+ let lastSorted = { th: null, dir: null };
220
+
221
+ headers.forEach(th => {
222
+ th.addEventListener('click', () => sortBy(th));
223
+ });
224
+
225
+ // Apply a specific direction to a column. Used by both the interactive
226
+ // sortBy cycle and the localStorage-restored sort on page reload.
227
+ function applySort(th, dir) {
228
+ const key = th.dataset.key;
229
+ const kind = th.dataset.sort; // 'num' or 'text'
230
+
231
+ headers.forEach(h => h.classList.remove('sorted-asc', 'sorted-desc'));
232
+ if (dir) th.classList.add('sorted-' + dir);
233
+ lastSorted = { th, dir };
234
+
235
+ const rows = Array.from(tbody.querySelectorAll('tr'));
236
+ if (dir === null) {
237
+ rows.sort((a, b) => {
238
+ const at = a.dataset.sortTest || a.dataset.sortModel || '';
239
+ const bt = b.dataset.sortTest || b.dataset.sortModel || '';
240
+ return at < bt ? -1 : at > bt ? 1 : 0;
241
+ });
242
+ } else {
243
+ rows.sort((a, b) => {
244
+ const ak = 'sort' + key[0].toUpperCase() + key.slice(1);
245
+ let av = a.dataset[ak], bv = b.dataset[ak];
246
+ // An empty sort value (the row has no value there) sorts last in
247
+ // both directions.
248
+ const ae = av === undefined || av === '', be = bv === undefined || bv === '';
249
+ if (ae || be) return ae === be ? 0 : (ae ? 1 : -1);
250
+ if (kind === 'num') {
251
+ av = parseFloat(av); bv = parseFloat(bv);
252
+ } else {
253
+ av = (av || '').toLowerCase();
254
+ bv = (bv || '').toLowerCase();
255
+ }
256
+ if (av < bv) return dir === 'asc' ? -1 : 1;
257
+ if (av > bv) return dir === 'asc' ? 1 : -1;
258
+ return 0;
259
+ });
260
+ }
261
+ rows.forEach(r => tbody.appendChild(r));
262
+ }
263
+
264
+ function sortBy(th) {
265
+ const kind = th.dataset.sort;
266
+ const firstClick = (kind === 'num') ? 'desc' : 'asc';
267
+ let dir;
268
+ if (lastSorted.th === th) {
269
+ // Cycle: firstClick → opposite → none → firstClick → ...
270
+ // The null-state has to restart the cycle, otherwise the third click
271
+ // gets stuck at null and the column never sorts again.
272
+ if (lastSorted.dir === null) {
273
+ dir = firstClick;
274
+ } else if (lastSorted.dir === firstClick) {
275
+ dir = (firstClick === 'asc') ? 'desc' : 'asc';
276
+ } else {
277
+ dir = null;
278
+ }
279
+ } else {
280
+ dir = firstClick;
281
+ }
282
+ applySort(th, dir);
283
+ persistState();
284
+ }
285
+
286
+ // ---- Counters + progress bar (rendered from status snapshot) ----
287
+ // Each pill is a clickable filter toggle. Disabled pills (zero count)
288
+ // stay rendered so the user sees the full set and can spot the moment
289
+ // an outcome appears. Total acts as the clear-all button.
290
+ function renderCounters(counts, total) {
291
+ const cn = document.getElementById('counters');
292
+ const order = STATUSES.concat([['warnings', 'warnings']]);
293
+ let html = '';
294
+ for (const [k, label] of order) {
295
+ const n = counts[k] || 0;
296
+ const disabled = n === 0 ? ' disabled' : '';
297
+ const active = activeStatuses.has(k) ? ' active' : '';
298
+ html += `<div class="counter ${k}${disabled}${active}" data-pill="${k}" ` +
299
+ `onclick="togglePill('${k}', this)">` +
300
+ `<div class="label">${label}</div>` +
301
+ `<div class="value">${n}</div></div>`;
302
+ }
303
+ html += `<div class="counter total" data-pill="total" ` +
304
+ `onclick="togglePill('total', this)" title="Clear all filters">` +
305
+ `<div class="label">Total</div>` +
306
+ `<div class="value">${total}</div></div>`;
307
+ cn.innerHTML = html;
308
+
309
+ const bar = document.getElementById('progress-bar');
310
+ const pct = (n) => total ? (n / total * 100).toFixed(2) : 0;
311
+ // A simulated row with no verdict yet is "pending": its segment moves
312
+ // the live bar while tests finish.
313
+ bar.innerHTML =
314
+ `<span class="b-pass" style="width:${pct(counts.pass || 0)}%"></span>` +
315
+ `<span class="b-fail" style="width:${pct((counts.fail || 0) + (counts['sim-fail'] || 0))}%"></span>` +
316
+ `<span class="b-timed-out" style="width:${pct(counts['timed-out'] || 0)}%"></span>` +
317
+ `<span class="b-pending" style="width:${pct(counts.pending || 0)}%"></span>` +
318
+ `<span class="b-running" style="width:${pct(counts.running || 0)}%"></span>`;
319
+ }
320
+
321
+ // Initial render from inline data (read from data-* on tbody rows).
322
+ // Counts are keyed by status class — the row's data-status and the
323
+ // pill's key — plus "pending" (simulated, no verdict yet: in no pill,
324
+ // only in the bar). Warnings cross-cuts status — any row with
325
+ // sortWarnings>0 contributes regardless of its outcome.
326
+ function initialCountsFromRows() {
327
+ const counts = { pending: 0, warnings: 0 };
328
+ for (const [cls] of STATUSES) counts[cls] = 0;
329
+ Array.from(tbody.querySelectorAll('tr')).forEach(row => {
330
+ const s = row.dataset.status;
331
+ if (s in counts && s !== 'warnings') counts[s]++;
332
+ if (parseInt(row.dataset.sortWarnings || '0') > 0) counts.warnings++;
333
+ });
334
+ return counts;
335
+ }
336
+ renderCounters(initialCountsFromRows(), DASHBOARD_TOTAL);
337
+
338
+ // ---- Selection (per-row checkbox + header tristate + sticky footer) ----
339
+ // Selection persists across filter changes ("curated basket" model). The
340
+ // header checkbox is tristate and toggles only currently-visible rows.
341
+ // Hidden-but-selected rows stay selected; the footer surfaces the count.
342
+ const footer = document.getElementById('sel-footer');
343
+ const selAllBox = document.getElementById('sel-all');
344
+ const selCount = document.getElementById('sel-count');
345
+ const hiddenHint = document.getElementById('sel-hidden-hint');
346
+ const cmdOut = document.getElementById('cmd-out');
347
+
348
+ function visibleRows() {
349
+ return Array.from(tbody.querySelectorAll('tr')).filter(r => r.style.display !== 'none');
350
+ }
351
+ function selectedRows() {
352
+ return Array.from(tbody.querySelectorAll('tr.row-selected'));
353
+ }
354
+ function setRowSelected(row, selected) {
355
+ row.classList.toggle('row-selected', selected);
356
+ const cb = row.querySelector('input.row-sel');
357
+ if (cb) cb.checked = selected;
358
+ }
359
+
360
+ window.toggleAllVisible = function(checked) {
361
+ visibleRows().forEach(r => setRowSelected(r, checked));
362
+ syncSelectionUI();
363
+ };
364
+ window.onRowToggle = function(cb) {
365
+ cb.closest('tr').classList.toggle('row-selected', cb.checked);
366
+ syncSelectionUI();
367
+ };
368
+ window.clearSelection = function() {
369
+ selectedRows().forEach(r => setRowSelected(r, false));
370
+ syncSelectionUI();
371
+ };
372
+
373
+ // RERUN_PREFIX carries the global flags only (`dstf --config <cfg>`); the
374
+ // subcommand comes from the #cmd-kind menu, so the builder is the one place
375
+ // that decides what `dstf` is actually asked to do. --merge/--report belong
376
+ // to a plain `run`: `compare` never re-simulates (nothing to merge) and
377
+ // `--rerun` already selects its own scope.
378
+ function commandKind() {
379
+ const sel = document.getElementById('cmd-kind');
380
+ return (sel && sel.value) || 'run';
381
+ }
382
+ function buildRerunCommand(modelIds) {
383
+ if (!modelIds.length) return '';
384
+ const kind = commandKind();
385
+ const filter = modelIds.length <= 3
386
+ ? `--filter "${modelIds.join(',')}"`
387
+ : '--filter @selected.txt';
388
+ const tail = kind === 'run' ? ' --merge --report' : '';
389
+ return `${RERUN_PREFIX} ${kind} ${filter}${tail}`;
390
+ }
391
+ window.syncCommand = function() {
392
+ cmdOut.value = buildRerunCommand(selectedRows().map(r => r.dataset.model));
393
+ };
394
+
395
+ function syncSelectionUI() {
396
+ const sel = selectedRows();
397
+ const vis = visibleRows();
398
+ const visSel = vis.filter(r => r.classList.contains('row-selected'));
399
+
400
+ selCount.textContent = sel.length;
401
+ const hiddenSel = sel.length - visSel.length;
402
+ hiddenHint.textContent = hiddenSel > 0 ? ` (${hiddenSel} hidden by filter)` : '';
403
+
404
+ // Tristate header checkbox: checked if all visible are selected,
405
+ // indeterminate if some, unchecked if none.
406
+ selAllBox.checked = vis.length > 0 && visSel.length === vis.length;
407
+ selAllBox.indeterminate = visSel.length > 0 && visSel.length < vis.length;
408
+
409
+ footer.classList.toggle('shown', sel.length > 0);
410
+ document.body.classList.toggle('has-selection', sel.length > 0);
411
+
412
+ const ids = sel.map(r => r.dataset.model);
413
+ cmdOut.value = buildRerunCommand(ids);
414
+ }
415
+
416
+ function flashSaveStatus() {
417
+ const el = document.getElementById('save-status');
418
+ el.classList.add('shown');
419
+ setTimeout(() => el.classList.remove('shown'), 1200);
420
+ }
421
+ function copyToClipboard(text) {
422
+ if (navigator.clipboard) {
423
+ navigator.clipboard.writeText(text).then(flashSaveStatus, () => fallbackCopy(text));
424
+ } else {
425
+ fallbackCopy(text);
426
+ }
427
+ }
428
+ function fallbackCopy(text) {
429
+ const ta = document.createElement('textarea');
430
+ ta.value = text;
431
+ document.body.appendChild(ta);
432
+ ta.select();
433
+ try { document.execCommand('copy'); flashSaveStatus(); } catch (e) {}
434
+ document.body.removeChild(ta);
435
+ }
436
+ window.copyCommand = function() {
437
+ const ids = selectedRows().map(r => r.dataset.model);
438
+ copyToClipboard(buildRerunCommand(ids));
439
+ };
440
+ window.downloadFilter = function() {
441
+ const ids = selectedRows().map(r => r.dataset.model);
442
+ if (!ids.length) return;
443
+ const blob = new Blob([ids.join('\n') + '\n'], { type: 'text/plain' });
444
+ const url = URL.createObjectURL(blob);
445
+ const a = document.createElement('a');
446
+ a.href = url; a.download = 'selected.txt';
447
+ document.body.appendChild(a); a.click();
448
+ document.body.removeChild(a); URL.revokeObjectURL(url);
449
+ flashSaveStatus();
450
+ };
451
+
452
+ // ---- Persistence: survive meta-refresh page reloads ----
453
+ // Live mode reloads the entire page every 5s via the meta-refresh tag,
454
+ // which would otherwise wipe the user's filter/selection/sort state.
455
+ // localStorage is keyed by origin + path so each dashboard.html gets
456
+ // its own scope. Failures (private mode, quota, blocked) fall through
457
+ // silently — the dashboard still works without persistence, just resets
458
+ // each tick.
459
+ // v2: the pills and counts are keyed by status class; a v1 state named
460
+ // pill keys ("passed", "failed", ...) no pill carries, and restoring one
461
+ // hid every row.
462
+ const STATE_KEY = 'dstf-dashboard-v2:' + location.pathname;
463
+
464
+ function loadState() {
465
+ try { return JSON.parse(localStorage.getItem(STATE_KEY)) || {}; }
466
+ catch (e) { return {}; }
467
+ }
468
+ function persistState() {
469
+ try {
470
+ localStorage.setItem(STATE_KEY, JSON.stringify({
471
+ activeStatuses: Array.from(activeStatuses),
472
+ colFilterValues: { ...colFilterValues },
473
+ toggles: { ...toggles },
474
+ // Stamped with the report the selection was made in — see the
475
+ // restore step for why. Filters / sort / widths are user
476
+ // preferences and deliberately carry across reports; a selection
477
+ // is a claim about a specific run's rows and does not.
478
+ selectionKey: REPORT_KEY,
479
+ selection: selectedRows().map(r => r.dataset.model),
480
+ sort: lastSorted.th
481
+ ? { key: lastSorted.th.dataset.key, dir: lastSorted.dir }
482
+ : null,
483
+ colWidths: { ...colWidths },
484
+ }));
485
+ } catch (e) { /* quota / blocked / private mode — non-fatal */ }
486
+ }
487
+
488
+ // Hook persistState into the state-change handlers. We extend the
489
+ // existing window functions (defined above) so callers don't have to
490
+ // know about persistence — every UI action already routes through
491
+ // these handlers.
492
+ const _togglePill = window.togglePill;
493
+ window.togglePill = function(k, el) { _togglePill(k, el); persistState(); };
494
+ const _toggleAllVisible = window.toggleAllVisible;
495
+ window.toggleAllVisible = function(c) { _toggleAllVisible(c); persistState(); };
496
+ const _onRowToggle = window.onRowToggle;
497
+ window.onRowToggle = function(cb) { _onRowToggle(cb); persistState(); };
498
+ const _clearSelection = window.clearSelection;
499
+ window.clearSelection = function() { _clearSelection(); persistState(); };
500
+ colFilters.forEach(inp => inp.addEventListener('input', persistState));
501
+
502
+ // Restore saved state on page load. Order: filters first (so the
503
+ // selection step's tristate header reflects the right visible-row
504
+ // population), then selection, then sort.
505
+ (function restoreState() {
506
+ const s = loadState();
507
+ if (s.activeStatuses && Array.isArray(s.activeStatuses)) {
508
+ for (const k of s.activeStatuses) activeStatuses.add(k);
509
+ syncPillStates();
510
+ }
511
+ // Only the filters of columns this page has (DstfFilters.known): a
512
+ // column renamed since the view was stored has no input to show it in.
513
+ Object.assign(colFilterValues, DstfFilters.known(
514
+ s.colFilterValues, Array.from(colFilters, inp => inp.dataset.colFilter)));
515
+ colFilters.forEach(inp => {
516
+ const k = inp.dataset.colFilter;
517
+ if (colFilterValues[k]) inp.value = colFilterValues[k];
518
+ });
519
+ // The toggles BEFORE applyFilters below: a restored regex filter is
520
+ // compiled as a regex, not as the text it would read without .* on.
521
+ if (s.toggles && typeof s.toggles === 'object') {
522
+ toggles.regex = !!s.toggles.regex;
523
+ toggles.case = !!s.toggles.case;
524
+ syncToggles();
525
+ }
526
+ // Re-render counters so the active class on pills reflects the
527
+ // restored set (initial render happened before restore).
528
+ renderCounters(initialCountsFromRows(), DASHBOARD_TOTAL);
529
+ applyFilters();
530
+
531
+ // Selection is restored ONLY when it was made in THIS report. The
532
+ // storage key is location.pathname, which every run into the same
533
+ // work_dir shares, so a prior run's selection used to be re-applied
534
+ // to whichever of its models still existed — and then rode silently
535
+ // into the rerun command the user built from a fresh selection here.
536
+ // A selection stamped with any other report (or with none, from a
537
+ // page written before this stamp existed) is dropped on load.
538
+ if (s.selection && Array.isArray(s.selection) && s.selectionKey === REPORT_KEY) {
539
+ const wantSet = new Set(s.selection);
540
+ Array.from(tbody.querySelectorAll('tr')).forEach(row => {
541
+ if (wantSet.has(row.dataset.model)) setRowSelected(row, true);
542
+ });
543
+ }
544
+ if (s.sort && s.sort.key) {
545
+ const th = document.querySelector(`th[data-key="${s.sort.key}"]`);
546
+ if (th) applySort(th, s.sort.dir);
547
+ }
548
+ if (s.colWidths && typeof s.colWidths === 'object') {
549
+ for (const [key, width] of Object.entries(s.colWidths)) {
550
+ const th = document.querySelector(`#results-table thead tr:first-child th[data-key="${key}"]`);
551
+ if (th) applyColWidth(th, width);
552
+ }
553
+ }
554
+ syncSelectionUI();
555
+ })();
556
+
557
+ // Live mode is driven by an http-equiv refresh meta tag in the Jinja
558
+ // template — the browser does a full reload every 5s, which works
559
+ // correctly on file:// URLs. We tried JS-fetch first; it's silently
560
+ // blocked on file:// in Chrome/Edge for security reasons (CORS), so the
561
+ // fetch path was non-functional in practice. Final mode strips the
562
+ // refresh tag at template-render time and the page becomes static.
563
+
564
+ // ---- Live elapsed tick (header + per-row "running for Ns") ----
565
+ // The server only writes a fresh snapshot on test state changes. Between
566
+ // those, meta-refresh would just show the same stale "elapsed Xs". We
567
+ // tick a JS-side counter every 1s so the header elapsed + running-test
568
+ // cells advance smoothly, regardless of when the snapshot was last
569
+ // touched. Final mode does a single render of the wall time and stops
570
+ // (no ticking — the report is static).
571
+ function fmtClock(epochSeconds) {
572
+ return new Date(epochSeconds * 1000).toLocaleTimeString();
573
+ }
574
+ function fmtSeconds(s) {
575
+ return s.toFixed(1) + 's';
576
+ }
577
+ function tickLiveElapsed() {
578
+ const nowEpoch = Date.now() / 1000;
579
+ const liveRunElapsed = SNAPSHOT_ELAPSED + (nowEpoch - SNAPSHOT_WALL);
580
+ const headerEl = document.getElementById('header-elapsed');
581
+ if (headerEl) headerEl.textContent = liveRunElapsed.toFixed(0) + 's';
582
+ const wallEl = document.getElementById('header-wall');
583
+ if (wallEl) wallEl.textContent = fmtClock(nowEpoch);
584
+ // Per-row: any row currently in 'running' status with a started_wall
585
+ // gets its elapsed cell live-updated to (now - started_wall).
586
+ Array.from(tbody.querySelectorAll('tr[data-status="running"]')).forEach(row => {
587
+ const startedWall = parseFloat(row.dataset.startedWall);
588
+ if (!isNaN(startedWall)) {
589
+ const cell = row.querySelector('td.elapsed-cell');
590
+ if (cell) cell.textContent = fmtSeconds(nowEpoch - startedWall);
591
+ }
592
+ });
593
+ }
594
+ if (DASHBOARD_MODE === 'live') {
595
+ tickLiveElapsed();
596
+ setInterval(tickLiveElapsed, 1000);
597
+ } else {
598
+ // Final mode: render the finish wall-clock once. The header copy
599
+ // shows "ran for Xs · finished HH:MM:SS"; we just need to fill in
600
+ // the time-of-finish from the snapshot's updated_at.
601
+ const finalEl = document.getElementById('header-wall-final');
602
+ if (finalEl) finalEl.textContent = fmtClock(SNAPSHOT_WALL);
603
+ }
604
+ })();