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.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- 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
|
+
})();
|