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,501 @@
|
|
|
1
|
+
"""The Statistics card (U3): every statistic the run and its baseline
|
|
2
|
+
recorded, side by side, above the criteria tree. Closed it is one line
|
|
3
|
+
naming the counts that changed, then briefly the times; open, a row per count or time under its
|
|
4
|
+
section, each ending in whatever attaches at STATISTIC (a `+` that adds a
|
|
5
|
+
requirement on it) — or, for a row a criterion reads, that criterion's
|
|
6
|
+
pill and its requirement in a word or two. The card is a VIEW of the one
|
|
7
|
+
tree (Q1 b): which rows are scored is the tree as authored, staged edits
|
|
8
|
+
included (`Session.statistic_leaves`); the tree view only says each
|
|
9
|
+
criterion's verdict. A statistics criterion is edited under its row, by
|
|
10
|
+
whatever attaches at STATISTIC_CRITERION. It knows no capability by name.
|
|
11
|
+
|
|
12
|
+
The rows are `Session.statistics` (`reporting.stats_rows`), the row model
|
|
13
|
+
the report renders too: a row is "changed" when the criterion its `+`
|
|
14
|
+
would add would fail. A refresh rebuilds them only when their SHAPE moved
|
|
15
|
+
— the model, the rows' paths, which criteria read them, the root a `+`
|
|
16
|
+
lands in (the `TreeCard` rule); a value or a verdict repaints in place."""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import math
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from functools import partial
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
from nicegui import ui
|
|
27
|
+
|
|
28
|
+
from ...backends.common.statistics import SUMMARIES
|
|
29
|
+
from ...comparison.modes import ScalarConfig
|
|
30
|
+
from ...comparison.sides import parse_sides, sides_text, signed_sides
|
|
31
|
+
from ...reporting.stats_rows import SECTION_TITLES, StatRow, card_text
|
|
32
|
+
from ...reporting.tree_rows import leaves_of
|
|
33
|
+
from ..capabilities import Placement
|
|
34
|
+
from ..session import Selection
|
|
35
|
+
from ..tree_pointer import node_or_none
|
|
36
|
+
from .actions import InlineHost, RowTarget
|
|
37
|
+
from .tree_card import leaf_status
|
|
38
|
+
from .widgets import Pill
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from ...comparison.tree_spec import LeafSpec
|
|
42
|
+
from .test_page import TestPage
|
|
43
|
+
|
|
44
|
+
NAMED = 3 #: changed counts the closed line names (S6); the rest are counted
|
|
45
|
+
TIMES = 2 #: changed times it names after them (the owner, 2026-09-30)
|
|
46
|
+
#: The node a STATISTIC panel's add acts on: the root, whose END it lands at.
|
|
47
|
+
ROOT = "/metrics"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _entry(row: StatRow) -> tuple[tuple[str, str], int]:
|
|
51
|
+
"""What the closed line names ``row`` under, and its rank there: a
|
|
52
|
+
list's `_count` / `_total` / `_max` are ONE entry, the list's (B6),
|
|
53
|
+
ranked in that order; any other row is its own."""
|
|
54
|
+
base, _, field = row.key.rpartition("_")
|
|
55
|
+
if base and field in SUMMARIES:
|
|
56
|
+
return (row.section, base), SUMMARIES.index(field)
|
|
57
|
+
return (row.section, row.key), 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _moved(row: StatRow) -> float:
|
|
61
|
+
"""How far a changed time moved, relative to its baseline: its rank
|
|
62
|
+
on the closed line. Off a zero baseline, farther than any."""
|
|
63
|
+
run, base = float(row.run), float(row.baseline)
|
|
64
|
+
return abs((run - base) / base) if base else math.inf
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _more(named: Sequence[StatRow], cap: int) -> str:
|
|
68
|
+
text = ", ".join(f"{r.label} {r.delta}" for r in named[:cap])
|
|
69
|
+
return f"{text} +{len(named) - cap} more" if len(named) > cap else text
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def line_text(rows: Sequence[StatRow], has_run: bool) -> str:
|
|
73
|
+
"""The closed line after "run vs baseline ·": the changed COUNTS — in
|
|
74
|
+
section order, at most `NAMED` named, then `+N more` — or `no count
|
|
75
|
+
changed`; then, after a `·`, the changed times, the farthest moved
|
|
76
|
+
first, at most `TIMES`. `all match` where no count and no time
|
|
77
|
+
changed; or why it names none — "no run result" where ``has_run`` is
|
|
78
|
+
false: no run, or one that failed. The owner's ruling
|
|
79
|
+
(2026-09-30): over TRANSFORM CPUtime ran ~2.2x its baseline on 324 of
|
|
80
|
+
331 tests, so a line naming times among the counts named CPUtime on
|
|
81
|
+
every test; the open table still tints a changed time."""
|
|
82
|
+
shown = [r for r in rows if r.kind != "text"]
|
|
83
|
+
if not shown:
|
|
84
|
+
return "nothing recorded"
|
|
85
|
+
if not has_run:
|
|
86
|
+
return "no run result"
|
|
87
|
+
if all(r.baseline is None for r in shown):
|
|
88
|
+
return "no baseline statistics"
|
|
89
|
+
entries: dict[tuple[str, str], tuple[int, StatRow]] = {}
|
|
90
|
+
for row in shown:
|
|
91
|
+
if not row.changed or row.kind != "count":
|
|
92
|
+
continue
|
|
93
|
+
key, rank = _entry(row)
|
|
94
|
+
if key not in entries or rank < entries[key][0]:
|
|
95
|
+
entries[key] = (rank, row)
|
|
96
|
+
sections = list(dict.fromkeys(r.section for r in shown))
|
|
97
|
+
counts = sorted(
|
|
98
|
+
(row for _, row in entries.values()),
|
|
99
|
+
key=lambda r: sections.index(r.section),
|
|
100
|
+
)
|
|
101
|
+
times = sorted(
|
|
102
|
+
(r for r in shown if r.changed and r.kind == "time"), key=_moved, reverse=True
|
|
103
|
+
)
|
|
104
|
+
if not counts and not times:
|
|
105
|
+
return "all match"
|
|
106
|
+
text = (
|
|
107
|
+
f"{len(counts)} changed: {_more(counts, NAMED)}"
|
|
108
|
+
if counts
|
|
109
|
+
else "no count changed"
|
|
110
|
+
)
|
|
111
|
+
return f"{text} · {_more(times, TIMES)}" if times else text
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def summary(leaf: dict) -> str:
|
|
115
|
+
"""A scored row's requirement in a word or two (B11): `exact`, `±0.5`,
|
|
116
|
+
`±10%`, `in [a, b]`, `≥ a`, `≤ b`; a tolerance whose two sides differ
|
|
117
|
+
reads `−0.5 / +1`, `−5% / +10%`. A value the leaf leaves out is the
|
|
118
|
+
scalar mode's default."""
|
|
119
|
+
p = leaf.get("params") or {}
|
|
120
|
+
lo, hi = p.get("min_value"), p.get("max_value")
|
|
121
|
+
if lo is not None and hi is not None:
|
|
122
|
+
return f"in [{lo:g}, {hi:g}]"
|
|
123
|
+
if lo is not None:
|
|
124
|
+
return f"≥ {lo:g}"
|
|
125
|
+
if hi is not None:
|
|
126
|
+
return f"≤ {hi:g}"
|
|
127
|
+
tol = parse_sides(p.get("tolerance", ScalarConfig.tolerance), "tolerance")
|
|
128
|
+
rel = p.get("tolerance_mode", ScalarConfig.tolerance_mode) == "rel"
|
|
129
|
+
if not tol.equal:
|
|
130
|
+
return (
|
|
131
|
+
signed_sides(tol, "g", scale=100, unit="%") if rel else sides_text(tol, "g")
|
|
132
|
+
)
|
|
133
|
+
if rel:
|
|
134
|
+
return f"±{tol.upper * 100:g}%"
|
|
135
|
+
return "exact" if tol.upper == 0 else f"±{tol.upper:g}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cell_text(row: StatRow, value: Any) -> str:
|
|
139
|
+
"""A value as the card prints it, rounded (`card_text`); `—` for a
|
|
140
|
+
side that recorded none."""
|
|
141
|
+
return card_text(row.kind, value) or "—"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def row_of(quantity: str, paths: Sequence[str] | set[str]) -> str:
|
|
145
|
+
"""The row a leaf's quantity is shown on: its own, or — U2's add form
|
|
146
|
+
writes `CPUtime.final` — the top-level statistic's (S2), as
|
|
147
|
+
`statistics_rows` holds it."""
|
|
148
|
+
if quantity in paths:
|
|
149
|
+
return quantity
|
|
150
|
+
name, _, field = quantity.rpartition(".")
|
|
151
|
+
return name if field == "final" and name in paths else quantity
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def scored(
|
|
155
|
+
leaves: Sequence[tuple[LeafSpec, str]],
|
|
156
|
+
view: dict | None,
|
|
157
|
+
paths: Sequence[str],
|
|
158
|
+
) -> dict[str, list[dict]]:
|
|
159
|
+
"""Row path -> the criteria reading it, in tree order: the AUTHORED
|
|
160
|
+
tree's statistics leaves (``leaves``, `Session.statistic_leaves`), each
|
|
161
|
+
as ``{"path", "params", "status"}`` — its status the view's verdict,
|
|
162
|
+
"pending" where the view has none. Read from the view, a test with
|
|
163
|
+
nothing compared (no baseline: no view) showed a staged criterion's row
|
|
164
|
+
as unscored, and its `+` staged the same leaf again."""
|
|
165
|
+
held = set(paths)
|
|
166
|
+
verdicts = {str(n["path"]): leaf_status(n) for n in leaves_of(view)}
|
|
167
|
+
out: dict[str, list[dict]] = {}
|
|
168
|
+
for leaf, path in leaves:
|
|
169
|
+
out.setdefault(row_of(str(leaf.params["quantity"]), held), []).append(
|
|
170
|
+
{
|
|
171
|
+
"path": path,
|
|
172
|
+
"params": leaf.params,
|
|
173
|
+
"status": verdicts.get(path, "pending"),
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class _Row:
|
|
180
|
+
"""One statistic's row, and what a repaint writes into it."""
|
|
181
|
+
|
|
182
|
+
def __init__(
|
|
183
|
+
self,
|
|
184
|
+
row: ui.row,
|
|
185
|
+
run: ui.label,
|
|
186
|
+
base: ui.label,
|
|
187
|
+
delta: ui.label,
|
|
188
|
+
pill: Pill | None = None,
|
|
189
|
+
rule: ui.label | None = None,
|
|
190
|
+
more: ui.label | None = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
self.row, self.run, self.base, self.delta = row, run, base, delta
|
|
193
|
+
self.pill, self.rule, self.more = pill, rule, more
|
|
194
|
+
|
|
195
|
+
def paint(self, stat: StatRow, leaves: list[dict]) -> None:
|
|
196
|
+
self.run.set_text(cell_text(stat, stat.run))
|
|
197
|
+
self.base.set_text(cell_text(stat, stat.baseline))
|
|
198
|
+
self.delta.set_text(stat.delta)
|
|
199
|
+
if stat.changed:
|
|
200
|
+
self.row.classes(add="dstf-stat-changed")
|
|
201
|
+
else:
|
|
202
|
+
self.row.classes(remove="dstf-stat-changed")
|
|
203
|
+
if self.pill is not None and leaves:
|
|
204
|
+
self.pill.set(leaves[0]["status"])
|
|
205
|
+
if self.rule is not None and leaves:
|
|
206
|
+
self.rule.set_text(summary(leaves[0]))
|
|
207
|
+
if self.more is not None:
|
|
208
|
+
self.more.set_text(f"+{len(leaves) - 1}" if len(leaves) > 1 else "")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class StatsCard:
|
|
212
|
+
def __init__(self, page: TestPage) -> None:
|
|
213
|
+
self.page = page
|
|
214
|
+
self.is_open = False
|
|
215
|
+
self.hosts: list[InlineHost] = []
|
|
216
|
+
self.painters: dict[str, _Row] = {}
|
|
217
|
+
#: Each statistic's row by its path, and the slot under it.
|
|
218
|
+
self.rows_by_path: dict[str, ui.row] = {}
|
|
219
|
+
self.slots: dict[str, ui.column] = {}
|
|
220
|
+
self._shape: tuple = ()
|
|
221
|
+
self._toggle: ui.button | None = None
|
|
222
|
+
self._summary: ui.label | None = None
|
|
223
|
+
#: The one open editor: its leaf, its host and box, and the leaf as
|
|
224
|
+
#: its fields show it (an edit staged ELSEWHERE rebuilds them).
|
|
225
|
+
self.open_path: str | None = None
|
|
226
|
+
self.editor: InlineHost | None = None
|
|
227
|
+
self.editor_box: ui.column | None = None
|
|
228
|
+
self._edited: str | None = None
|
|
229
|
+
with ui.card().classes("dstf-card dstf-stats w-full gap-1").mark("stats"):
|
|
230
|
+
self.line = ui.row().classes("dstf-stats-line items-center gap-3 w-full")
|
|
231
|
+
self.line.mark("stats:line")
|
|
232
|
+
self.rows = ui.column().classes("w-full gap-0").mark("stats:rows")
|
|
233
|
+
# Not refreshed here, as the tree card is not: the page's `sync`
|
|
234
|
+
# fills it, or `forget` leaves it saying nothing.
|
|
235
|
+
self.rows.set_visibility(False)
|
|
236
|
+
|
|
237
|
+
def toggle(self) -> None:
|
|
238
|
+
self.is_open = not self.is_open
|
|
239
|
+
if not self.is_open and self.open_path is not None:
|
|
240
|
+
# An editor nobody can see is no editor.
|
|
241
|
+
self.page.close_editor()
|
|
242
|
+
self.refresh(self.page._view())
|
|
243
|
+
|
|
244
|
+
def forget(self) -> None:
|
|
245
|
+
"""The page's test is gone: the card says nothing (`TreeCard.forget`)."""
|
|
246
|
+
self.close_editor()
|
|
247
|
+
for host in self.hosts:
|
|
248
|
+
host.clear()
|
|
249
|
+
self.hosts, self.painters = [], {}
|
|
250
|
+
self.rows_by_path, self.slots = {}, {}
|
|
251
|
+
self.is_open = False
|
|
252
|
+
self._shape = ()
|
|
253
|
+
self.rows.clear()
|
|
254
|
+
self.rows.set_visibility(False)
|
|
255
|
+
self.line.clear()
|
|
256
|
+
self._toggle, self._summary = None, None
|
|
257
|
+
|
|
258
|
+
def refresh(self, view: dict | None) -> None:
|
|
259
|
+
"""Follow the Session: the line every time, the rows in place unless
|
|
260
|
+
their shape moved. ``view`` is the page's ONE `tree_view` read."""
|
|
261
|
+
session, model = self.page.shell.session, self.page.model_id
|
|
262
|
+
stats = session.statistics(model)
|
|
263
|
+
self._line(stats)
|
|
264
|
+
self.rows.set_visibility(self.is_open)
|
|
265
|
+
if not self.is_open:
|
|
266
|
+
return
|
|
267
|
+
shown = [r for r in stats if r.kind != "text"]
|
|
268
|
+
paths = [r.path for r in shown]
|
|
269
|
+
reads = scored(session.statistic_leaves(model), view, paths)
|
|
270
|
+
tree = session.tree(model)
|
|
271
|
+
shape = (
|
|
272
|
+
model,
|
|
273
|
+
tuple(paths),
|
|
274
|
+
tuple(
|
|
275
|
+
(k, tuple(str(leaf["path"]) for leaf in v)) for k, v in reads.items()
|
|
276
|
+
),
|
|
277
|
+
# What a `+` lands in, and whether it can: its reason and its
|
|
278
|
+
# sentence read the root.
|
|
279
|
+
None if tree is None else str(tree.get("combinator", "leaf")),
|
|
280
|
+
)
|
|
281
|
+
if shape != self._shape or not self.painters:
|
|
282
|
+
self._shape = shape
|
|
283
|
+
if self.open_path is not None:
|
|
284
|
+
# The rows its editor hangs under go, as a plot card's
|
|
285
|
+
# rebuild takes its editor: the page's one editor with them.
|
|
286
|
+
self.page.drop_editor()
|
|
287
|
+
self._build(shown, reads)
|
|
288
|
+
return
|
|
289
|
+
for host in self.hosts:
|
|
290
|
+
host.follow()
|
|
291
|
+
for stat in shown:
|
|
292
|
+
self.painters[stat.path].paint(stat, reads.get(stat.path, []))
|
|
293
|
+
if self.editor is not None:
|
|
294
|
+
edited = self._edited_now()
|
|
295
|
+
if edited != self._edited:
|
|
296
|
+
# The leaf moved by an edit staged ELSEWHERE (a Discard):
|
|
297
|
+
# fields showing the undone value would stage it back.
|
|
298
|
+
self.editor.build()
|
|
299
|
+
self._edited = edited
|
|
300
|
+
else:
|
|
301
|
+
self.editor.follow()
|
|
302
|
+
|
|
303
|
+
# -- the closed line -------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
def _line(self, stats: list[StatRow]) -> None:
|
|
306
|
+
"""Built once, repainted in place: a line cleared and rebuilt on
|
|
307
|
+
every sync shrinks the page for a frame, and whatever sits below it
|
|
308
|
+
moves."""
|
|
309
|
+
if self._toggle is None:
|
|
310
|
+
with self.line:
|
|
311
|
+
self._toggle = (
|
|
312
|
+
ui.button("Statistics", on_click=self.toggle)
|
|
313
|
+
.props("flat dense no-caps")
|
|
314
|
+
.mark("stats:toggle")
|
|
315
|
+
)
|
|
316
|
+
ui.label("run vs baseline ·").classes("text-secondary")
|
|
317
|
+
self._summary = (
|
|
318
|
+
ui.label().classes("text-secondary").mark("stats:summary")
|
|
319
|
+
)
|
|
320
|
+
assert self._summary is not None
|
|
321
|
+
self._toggle.props(f"icon={'expand_more' if self.is_open else 'chevron_right'}")
|
|
322
|
+
# A failed run is no result to compare: it recorded no statistics,
|
|
323
|
+
# and its line read "all match" with nothing on the run's side.
|
|
324
|
+
self._summary.set_text(line_text(stats, self.page.run_state() == "ok"))
|
|
325
|
+
|
|
326
|
+
# -- the rows ----------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
def _build(self, shown: list[StatRow], reads: dict[str, list[dict]]) -> None:
|
|
329
|
+
self.close_editor()
|
|
330
|
+
for host in self.hosts:
|
|
331
|
+
host.clear()
|
|
332
|
+
self.hosts, self.painters = [], {}
|
|
333
|
+
self.rows_by_path, self.slots = {}, {}
|
|
334
|
+
self.rows.clear()
|
|
335
|
+
section = None
|
|
336
|
+
with self.rows:
|
|
337
|
+
if shown:
|
|
338
|
+
# The board's columns: the statistic, the run, its baseline.
|
|
339
|
+
with (
|
|
340
|
+
ui.row()
|
|
341
|
+
.classes("dstf-stat-row dstf-stat-head items-center no-wrap gap-3")
|
|
342
|
+
.mark("stats:head")
|
|
343
|
+
):
|
|
344
|
+
ui.label("Statistic").classes("dstf-stat-label")
|
|
345
|
+
ui.label("Run").classes("dstf-stat-num")
|
|
346
|
+
ui.label("Baseline").classes("dstf-stat-num")
|
|
347
|
+
ui.element("span").classes("dstf-stat-delta")
|
|
348
|
+
ui.element("span").classes("dstf-stat-end")
|
|
349
|
+
for stat in shown:
|
|
350
|
+
if stat.section != section:
|
|
351
|
+
section = stat.section
|
|
352
|
+
title = SECTION_TITLES.get(
|
|
353
|
+
section, section.replace("_", " ").capitalize()
|
|
354
|
+
)
|
|
355
|
+
ui.label(title).classes("dstf-group").mark("stats:section")
|
|
356
|
+
self._row(stat, reads.get(stat.path, []))
|
|
357
|
+
|
|
358
|
+
def _row(self, stat: StatRow, leaves: list[dict]) -> None:
|
|
359
|
+
path, model = stat.path, self.page.model_id
|
|
360
|
+
row = ui.row().classes("dstf-stat-row items-center no-wrap gap-3")
|
|
361
|
+
self.rows_by_path[path] = row
|
|
362
|
+
host: InlineHost | None = None
|
|
363
|
+
target: RowTarget | None = None
|
|
364
|
+
pill: Pill | None = None
|
|
365
|
+
rule: ui.label | None = None
|
|
366
|
+
more: ui.label | None = None
|
|
367
|
+
with row.mark(f"stats:row:{path}"):
|
|
368
|
+
with ui.label(stat.label).classes("dstf-stat-label text-body2"):
|
|
369
|
+
ui.tooltip(path)
|
|
370
|
+
run = ui.label().classes("dstf-stat-num dstf-mono")
|
|
371
|
+
base = ui.label().classes("dstf-stat-num dstf-mono")
|
|
372
|
+
delta = ui.label().classes("dstf-stat-delta text-secondary text-xs")
|
|
373
|
+
with ui.element("span").classes("dstf-stat-end"):
|
|
374
|
+
if leaves:
|
|
375
|
+
pill = Pill(leaves[0]["status"], f"stats:pill:{path}")
|
|
376
|
+
rule = ui.label().classes("dstf-mono text-xs")
|
|
377
|
+
rule.mark(f"stats:rule:{path}")
|
|
378
|
+
more = ui.label().classes("text-secondary text-xs")
|
|
379
|
+
more.mark(f"stats:more:{path}")
|
|
380
|
+
else:
|
|
381
|
+
target = RowTarget(self.page, f"stat:{path}", row, ROOT, None)
|
|
382
|
+
host = InlineHost(
|
|
383
|
+
self.page.shell,
|
|
384
|
+
Placement.STATISTIC,
|
|
385
|
+
partial(Selection, tests=frozenset({model}), quantity=path),
|
|
386
|
+
figure=None,
|
|
387
|
+
marker=f"statistic:{path}",
|
|
388
|
+
row=target,
|
|
389
|
+
apply=self.page.apply,
|
|
390
|
+
)
|
|
391
|
+
host.column.classes(remove="w-full", add="dstf-node-host")
|
|
392
|
+
if leaves:
|
|
393
|
+
# A scored row's own click opens its (first) criterion's editor
|
|
394
|
+
# under it, or closes it, as a criteria row's does.
|
|
395
|
+
row.classes(add="cursor-pointer")
|
|
396
|
+
row.on(
|
|
397
|
+
"click",
|
|
398
|
+
partial(self.page.toggle_stats_editor, str(leaves[0]["path"])),
|
|
399
|
+
)
|
|
400
|
+
# The row's slot: a refused add's error, and the editor.
|
|
401
|
+
slot = ui.column().classes("w-full gap-0")
|
|
402
|
+
self.slots[path] = slot
|
|
403
|
+
if host is not None and target is not None:
|
|
404
|
+
target.slot = slot
|
|
405
|
+
host.build()
|
|
406
|
+
self.hosts.append(host)
|
|
407
|
+
self.painters[path] = _Row(row, run, base, delta, pill, rule, more)
|
|
408
|
+
self.painters[path].paint(stat, leaves)
|
|
409
|
+
|
|
410
|
+
# -- the one open editor -------------------------------------------------
|
|
411
|
+
|
|
412
|
+
def row_for(self, leaf_path: str) -> str | None:
|
|
413
|
+
"""The row a statistics criterion is shown on, or None — a leaf
|
|
414
|
+
that reads no statistic, or none at that path. The authored tree's,
|
|
415
|
+
as `scored` reads it."""
|
|
416
|
+
session, model = self.page.shell.session, self.page.model_id
|
|
417
|
+
leaf = next(
|
|
418
|
+
(
|
|
419
|
+
leaf
|
|
420
|
+
for leaf, path in session.statistic_leaves(model)
|
|
421
|
+
if path == leaf_path
|
|
422
|
+
),
|
|
423
|
+
None,
|
|
424
|
+
)
|
|
425
|
+
if leaf is None:
|
|
426
|
+
return None
|
|
427
|
+
paths = [r.path for r in session.statistics(model)]
|
|
428
|
+
return row_of(str(leaf.params["quantity"]), paths)
|
|
429
|
+
|
|
430
|
+
def open_editor(self, leaf_path: str, view: dict | None) -> bool:
|
|
431
|
+
"""The criterion's editor under its row (the `PlotCard.open_editor`
|
|
432
|
+
pattern, with no plot), the card opened if it was closed. The page
|
|
433
|
+
keeps the one-editor rule: it closes what else is open first."""
|
|
434
|
+
key = self.row_for(leaf_path)
|
|
435
|
+
if key is None:
|
|
436
|
+
return False
|
|
437
|
+
if not self.is_open:
|
|
438
|
+
self.is_open = True
|
|
439
|
+
self.refresh(view)
|
|
440
|
+
# Every row a statistics leaf is shown on is built — `row_for` and
|
|
441
|
+
# `statistics_rows` agree on it (S4: a leaf's quantity always has
|
|
442
|
+
# a row) — so its slot exists.
|
|
443
|
+
slot = self.slots[key]
|
|
444
|
+
self.close_editor()
|
|
445
|
+
model = self.page.model_id
|
|
446
|
+
self.open_path = leaf_path
|
|
447
|
+
self.rows_by_path[key].classes(add="dstf-crit-open")
|
|
448
|
+
with slot:
|
|
449
|
+
self.editor_box = ui.column().classes("dstf-editor gap-2")
|
|
450
|
+
with self.editor_box:
|
|
451
|
+
with ui.row().classes("items-center no-wrap gap-3 w-full"):
|
|
452
|
+
ui.label(
|
|
453
|
+
"Edits are staged as you make them; Save writes them."
|
|
454
|
+
).classes("dstf-why")
|
|
455
|
+
ui.space()
|
|
456
|
+
ui.button("Done", on_click=self.page.close_editor).props(
|
|
457
|
+
"flat dense no-caps"
|
|
458
|
+
).mark(f"stats-editor-done:{leaf_path}")
|
|
459
|
+
self.editor = InlineHost(
|
|
460
|
+
self.page.shell,
|
|
461
|
+
Placement.STATISTIC_CRITERION,
|
|
462
|
+
partial(Selection, tests=frozenset({model}), leaf_path=leaf_path),
|
|
463
|
+
figure=None,
|
|
464
|
+
marker=f"stats-editor:{leaf_path}",
|
|
465
|
+
on_applied=self._applied,
|
|
466
|
+
apply=self.page.apply,
|
|
467
|
+
)
|
|
468
|
+
self.editor.build()
|
|
469
|
+
self._edited = self._edited_now()
|
|
470
|
+
return True
|
|
471
|
+
|
|
472
|
+
def _applied(self, name: str) -> None:
|
|
473
|
+
"""A panel's own submit rebuilds every OTHER panel, leaving the one
|
|
474
|
+
being typed in; the leaf recorded as it now stands, so its own edit
|
|
475
|
+
is not read as one staged elsewhere."""
|
|
476
|
+
if self.editor is None:
|
|
477
|
+
return
|
|
478
|
+
self.editor.build(skip=name)
|
|
479
|
+
self._edited = self._edited_now()
|
|
480
|
+
|
|
481
|
+
def _edited_now(self) -> str | None:
|
|
482
|
+
"""The open editor's leaf as AUTHORED — staged edits included — not
|
|
483
|
+
the view's: a test with nothing compared has no view, and its
|
|
484
|
+
editor never redrew for an edit staged elsewhere."""
|
|
485
|
+
if self.open_path is None:
|
|
486
|
+
return None
|
|
487
|
+
session = self.page.shell.session
|
|
488
|
+
leaf = node_or_none(session.tree(self.page.model_id), self.open_path)
|
|
489
|
+
return None if leaf is None else json.dumps(leaf, sort_keys=True, default=str)
|
|
490
|
+
|
|
491
|
+
def close_editor(self) -> None:
|
|
492
|
+
"""The editor goes — its column only: the row's slot also holds
|
|
493
|
+
nothing else a close may take."""
|
|
494
|
+
if self.editor is not None:
|
|
495
|
+
self.editor.clear()
|
|
496
|
+
if self.editor_box is not None and not self.editor_box.is_deleted:
|
|
497
|
+
self.editor_box.delete()
|
|
498
|
+
for row in self.rows_by_path.values():
|
|
499
|
+
row.classes(remove="dstf-crit-open")
|
|
500
|
+
self.editor, self.editor_box = None, None
|
|
501
|
+
self.open_path, self._edited = None, None
|