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,1404 @@
|
|
|
1
|
+
"""The Test page (brief §3.2; roadmap §3): one test — its own header as a
|
|
2
|
+
second row of the shared one, its statistics and its criteria tree each
|
|
3
|
+
closed to a line, and its
|
|
4
|
+
plots stacked, each drawn when it scrolls into view with that variable's
|
|
5
|
+
criteria rows under it and, under the row that was clicked, that
|
|
6
|
+
criterion's editor — one at a time, page-wide, with the plot above it as
|
|
7
|
+
its editing surface. A node's forms open as a row under the row they were
|
|
8
|
+
asked from, and that row is the same one thing: it replaces the editor and
|
|
9
|
+
the editor replaces it. One object per browser tab, like the Suite page. The
|
|
10
|
+
page knows no capability by name: what edits a criterion, a tree node or a
|
|
11
|
+
variable is whatever attaches at that placement."""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from collections.abc import Callable, Sequence
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from nicegui import ui
|
|
21
|
+
|
|
22
|
+
from ...backends.pool import ItemState
|
|
23
|
+
from ...comparison.tree_spec import reads_a_statistic
|
|
24
|
+
from ...config import ConfigError
|
|
25
|
+
from ...discovery.patch_apply import PatchError
|
|
26
|
+
from ...discovery.plots import PlotExtra, ResolvedPlot
|
|
27
|
+
from ...reporting.status import (
|
|
28
|
+
SCORING_CLASS,
|
|
29
|
+
SCORING_FAILED,
|
|
30
|
+
classify,
|
|
31
|
+
classify_with_class,
|
|
32
|
+
refusal,
|
|
33
|
+
)
|
|
34
|
+
from ...reporting.tree_rows import leaves_of
|
|
35
|
+
from ...storage.ref_index import RefIndex
|
|
36
|
+
from ..capabilities import Outcome, plural
|
|
37
|
+
from ..errors import user_text
|
|
38
|
+
from ..family_rows import is_fold, leaf_of
|
|
39
|
+
from ..layout import layout_op, remove_context, remove_watch, reset_op
|
|
40
|
+
from ..queue_rows import queue_row
|
|
41
|
+
from ..situation import Situation, Strip, new_variables, strip_for
|
|
42
|
+
from ..spec_document import edit_counts
|
|
43
|
+
from ..tree_pointer import node_or_none
|
|
44
|
+
from .actions import ActionBar, RowBuild, RowTarget
|
|
45
|
+
from .family_fold import Folding
|
|
46
|
+
from .header import Header
|
|
47
|
+
from .live_view import LIVE_CSS, LiveView
|
|
48
|
+
from .plot_card import GRIPS_URL, UNREAD, PlotCard, folded
|
|
49
|
+
from .rail import Rail
|
|
50
|
+
from .stats_card import StatsCard
|
|
51
|
+
from .strip import StatusStrip
|
|
52
|
+
from .tab_theme import first_paint
|
|
53
|
+
from .tabstate import caught_up, connected_tab_state, heard_spec
|
|
54
|
+
from .tree_card import TreeCard, leaf_status
|
|
55
|
+
|
|
56
|
+
if TYPE_CHECKING:
|
|
57
|
+
from .shell import Shell
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
#: Render on scroll (the 3b spike): ONE observer per browser tab, kept on
|
|
62
|
+
#: `window` so a re-run after a rebuild reuses it; a slot near the viewport
|
|
63
|
+
#: names itself once, and is stamped so a re-run does not observe it twice.
|
|
64
|
+
OBSERVE = """
|
|
65
|
+
(() => {
|
|
66
|
+
window.__dstfPlotObserver ??= new IntersectionObserver((entries) => {
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (!entry.isIntersecting) continue;
|
|
69
|
+
window.__dstfPlotObserver.unobserve(entry.target);
|
|
70
|
+
emitEvent("dstf_plot_visible", entry.target.dataset.plot);
|
|
71
|
+
}
|
|
72
|
+
}, {rootMargin: "200px"});
|
|
73
|
+
for (const el of document.querySelectorAll(".dstf-plot-slot:not([data-seen])")) {
|
|
74
|
+
el.dataset.seen = "1";
|
|
75
|
+
window.__dstfPlotObserver.observe(el);
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
#: The one sentence for both ways a page ends up with no test: built for a
|
|
81
|
+
#: model this project never had, or its project swapped under it. `_gone`
|
|
82
|
+
#: writes it, and a page built in that state reaches `_gone` through its
|
|
83
|
+
#: first `sync`.
|
|
84
|
+
NOT_A_TEST = "{} is not a test of this project"
|
|
85
|
+
|
|
86
|
+
#: `TestPage.open`'s index for a criterion edited in the Statistics card
|
|
87
|
+
#: (U3): the page's one editor is `(plot index, leaf path)`, or `(STATS,
|
|
88
|
+
#: leaf path)` — no plot is ever at index -1.
|
|
89
|
+
STATS = -1
|
|
90
|
+
|
|
91
|
+
#: `TestPage.open`'s index for a criterion edited under its row in the
|
|
92
|
+
#: criteria tree (U5, Y4): no plot is at -2 either, and it is not STATS —
|
|
93
|
+
#: the Statistics card's editor closes with that card, this one with the
|
|
94
|
+
#: tree's rows.
|
|
95
|
+
TREE = -2
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _raw(node: dict | None) -> str:
|
|
99
|
+
"""A raw node as the form row records it, to tell whether it moved."""
|
|
100
|
+
return json.dumps(node, sort_keys=True, default=str)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TestPage:
|
|
104
|
+
def __init__(self, shell: Shell) -> None:
|
|
105
|
+
self.shell = shell
|
|
106
|
+
self.client: Any = None
|
|
107
|
+
self.model_id = ""
|
|
108
|
+
self.badge: ui.badge | None = None
|
|
109
|
+
self.meta: ui.label | None = None
|
|
110
|
+
#: The status strip under the sub-header, and its TEST-placement bar.
|
|
111
|
+
self.strip: StatusStrip | None = None
|
|
112
|
+
self.actions: ActionBar | None = None
|
|
113
|
+
self.tree: TreeCard | None = None
|
|
114
|
+
self.stats: StatsCard | None = None
|
|
115
|
+
self.cards: list[PlotCard] = []
|
|
116
|
+
self.count: ui.label | None = None
|
|
117
|
+
self.column: ui.column | None = None
|
|
118
|
+
#: The page's body, which says whether its criteria have verdicts
|
|
119
|
+
#: (`LiveView.mark`), and the running test's live view (D151).
|
|
120
|
+
self.body: ui.column | None = None
|
|
121
|
+
self.live = LiveView(self)
|
|
122
|
+
self._shape: Any = None
|
|
123
|
+
#: Whether the page is showing "… is not a test of this project"
|
|
124
|
+
#: instead of a test (`_gone`).
|
|
125
|
+
self.gone = False
|
|
126
|
+
#: The one open editor: (plot index, leaf path), (STATS, leaf path)
|
|
127
|
+
#: for a statistics criterion's, in the Statistics card, or (TREE,
|
|
128
|
+
#: leaf path) for one opened under its tree row (U5).
|
|
129
|
+
self.open: tuple[int, str] | None = None
|
|
130
|
+
#: The one open form row: its target, its title, its build, its
|
|
131
|
+
#: column, and the node's raw JSON it was last built from.
|
|
132
|
+
self.form: tuple[RowTarget, str, RowBuild, ui.column, str] | None = None
|
|
133
|
+
#: The open form row's title, which its form may say again
|
|
134
|
+
#: (`retitle`).
|
|
135
|
+
self.form_title: ui.label | None = None
|
|
136
|
+
#: A criterion to open at the end of the next sync, and the plot
|
|
137
|
+
#: it was asked from (`reveal`).
|
|
138
|
+
self.pending_reveal: tuple[str, int | None] | None = None
|
|
139
|
+
#: A move's row to bring into view and the line it says, for the
|
|
140
|
+
#: end of the next sync (`moved`).
|
|
141
|
+
self.pending_moved: tuple[str, str] | None = None
|
|
142
|
+
#: The revision of the last move's own `spec` event
|
|
143
|
+
#: (`Session.spec_revision` right after it staged), or None: only
|
|
144
|
+
#: a `spec` event numbered past it is an edit AFTER the move, and
|
|
145
|
+
#: takes its line away (`handle`). A number, not a flag: an edit or
|
|
146
|
+
#: a second move queued in the same tick sends a `spec` event ahead
|
|
147
|
+
#: of the move's own, and either one, handled first, cleared the
|
|
148
|
+
#: flag — the move's own event then took its line away.
|
|
149
|
+
self.move_revision: int | None = None
|
|
150
|
+
self.focused: int | None = None # the one plot Focus is showing
|
|
151
|
+
self.focus_bar: ui.row | None = None
|
|
152
|
+
self.focus_text: ui.label | None = None
|
|
153
|
+
self.focus_all: ui.button | None = None
|
|
154
|
+
self.focus_switch: ui.select | None = None
|
|
155
|
+
#: The plot the Variables drawer's checkboxes act on (the owner's
|
|
156
|
+
#: ruling, Q5 A): the first until a click on a card, or the
|
|
157
|
+
#: drawer's "+ new plot", selects another. Its card wears
|
|
158
|
+
#: `dstf-plot-selected`.
|
|
159
|
+
self.selected = 0
|
|
160
|
+
#: The selected plot's subject, which a rebuild re-selects wherever
|
|
161
|
+
#: it moved; None when a gesture placed the selection by index.
|
|
162
|
+
self.follow: tuple[str, ...] | None = None
|
|
163
|
+
|
|
164
|
+
async def build(self, model_id: str) -> None:
|
|
165
|
+
self.client = ui.context.client
|
|
166
|
+
# BEFORE the handshake, so it is in the page's first HTML: after it
|
|
167
|
+
# `add_head_html` inserts through insertAdjacentHTML, which never
|
|
168
|
+
# runs a <script> (the Suite page's rule).
|
|
169
|
+
ui.add_head_html(f'<script src="{GRIPS_URL}"></script>')
|
|
170
|
+
ui.add_css(LIVE_CSS)
|
|
171
|
+
first_paint(self.shell)
|
|
172
|
+
state = await connected_tab_state(self.client)
|
|
173
|
+
if state is None:
|
|
174
|
+
return
|
|
175
|
+
self.state = state # the tab's, shared with its Suite page
|
|
176
|
+
caught_up(state, self.shell.session.spec_revision)
|
|
177
|
+
self.theme = self.shell.page_setup(state, self._rethemed)
|
|
178
|
+
self.model_id = model_id
|
|
179
|
+
ui.page_title(f"DSTF — {model_id.rsplit('.', 1)[-1]}")
|
|
180
|
+
# The frame is built whatever the Session holds at this moment: a
|
|
181
|
+
# page opened (or reloaded) while the project is still scanning, or
|
|
182
|
+
# with another library open, was built in the gone state with no
|
|
183
|
+
# frame to fill — `sync` returned early for ever and the test never
|
|
184
|
+
# came back however many events arrived (the final review). `sync`
|
|
185
|
+
# below fills it, or `_gone` writes the sentence into it.
|
|
186
|
+
self.header = Header(
|
|
187
|
+
self.shell, self.theme, below=self._subheader, strip=self._strip
|
|
188
|
+
)
|
|
189
|
+
self.rail = Rail(self)
|
|
190
|
+
with ui.column().classes("dstf-test w-full q-pa-md gap-3") as self.body:
|
|
191
|
+
# Above the tree (the plot-model board): what the run recorded,
|
|
192
|
+
# and the `+` that makes a statistic a criterion.
|
|
193
|
+
self.stats = StatsCard(self)
|
|
194
|
+
self.tree = TreeCard(self)
|
|
195
|
+
self.count = (
|
|
196
|
+
ui.label().classes("text-secondary text-xs").mark("plots:count")
|
|
197
|
+
)
|
|
198
|
+
self.focus_bar = (
|
|
199
|
+
ui.row().classes("dstf-focus-bar items-center gap-3 w-full")
|
|
200
|
+
).mark("focus-bar")
|
|
201
|
+
with self.focus_bar:
|
|
202
|
+
self.focus_text = ui.label().mark("focus-bar:text")
|
|
203
|
+
# Every plot by its subject (Q11 c), plain text (C4):
|
|
204
|
+
# choosing one focuses it.
|
|
205
|
+
self.focus_switch = (
|
|
206
|
+
ui.select({}, on_change=lambda e: self.focus_to(e.value))
|
|
207
|
+
.props("dense options-dense")
|
|
208
|
+
.mark("focus-switch")
|
|
209
|
+
)
|
|
210
|
+
self.focus_switch.props["aria-label"] = "Plot"
|
|
211
|
+
ui.space()
|
|
212
|
+
self.focus_all = (
|
|
213
|
+
ui.button(on_click=self.show_all)
|
|
214
|
+
.props("flat dense no-caps")
|
|
215
|
+
.mark("focus-bar:all")
|
|
216
|
+
)
|
|
217
|
+
self.focus_bar.set_visibility(False)
|
|
218
|
+
self.column = ui.column().classes("dstf-plots w-full gap-3").mark("plots")
|
|
219
|
+
ui.on("dstf_plot_visible", lambda e: self.on_visible(e.args))
|
|
220
|
+
# The plots' own channel (`grips.js`, D149): a click that is no
|
|
221
|
+
# drag, a handle held, and Esc while a field is armed.
|
|
222
|
+
ui.on("dstf_plot_click", lambda e: self.on_plot("clicked", e.args))
|
|
223
|
+
ui.on("dstf_grip_move", lambda e: self.on_plot("moved", e.args))
|
|
224
|
+
ui.on("dstf_disarm", lambda _e: self.disarm())
|
|
225
|
+
self.shell.register(self)
|
|
226
|
+
self.sync()
|
|
227
|
+
self.rail.open(self.state.drawer)
|
|
228
|
+
|
|
229
|
+
def _known(self) -> bool:
|
|
230
|
+
"""Whether this page's test is a test of the OPEN project. It can
|
|
231
|
+
stop being one while the page is open: an `OpenProject` outcome
|
|
232
|
+
swaps the library, `Session.close()` takes it away."""
|
|
233
|
+
project = self.shell.session.project
|
|
234
|
+
return project is not None and project.test(self.model_id) is not None
|
|
235
|
+
|
|
236
|
+
def _view(self) -> dict | None:
|
|
237
|
+
return self.shell.session.tree_view(self.model_id)
|
|
238
|
+
|
|
239
|
+
def _subheader(self) -> None:
|
|
240
|
+
"""The test's own header, drawn inside the shared one (decision 8):
|
|
241
|
+
the way back, the test, its verdict and where it was scored. Its
|
|
242
|
+
TEST-placement buttons are the strip's, under it."""
|
|
243
|
+
# One line: beside a long model id and Queue's caption with an edit
|
|
244
|
+
# pending, "← Suite" broke after the arrow at 1280 px.
|
|
245
|
+
# The accent, in every theme (D148): a link's own blue is NiceGUI's.
|
|
246
|
+
ui.link("← Suite", "/").classes("text-no-wrap text-primary").mark("back")
|
|
247
|
+
ui.label(self.model_id).classes("dstf-model text-subtitle1 dstf-mono").mark(
|
|
248
|
+
"model"
|
|
249
|
+
)
|
|
250
|
+
# `color=None`: a Quasar badge otherwise carries `bg-primary`, whose
|
|
251
|
+
# `!important` beats the inline status fill and paints every verdict
|
|
252
|
+
# blue (the Task 4 review, in Chromium).
|
|
253
|
+
self.badge = ui.badge("", color=None).classes("dstf-badge").mark("status")
|
|
254
|
+
self.meta = ui.label().classes("text-secondary text-xs").mark("meta")
|
|
255
|
+
|
|
256
|
+
def _strip(self) -> None:
|
|
257
|
+
"""The header's third row: the test's situation and its next
|
|
258
|
+
action (Q2 A)."""
|
|
259
|
+
self.strip = StatusStrip(self)
|
|
260
|
+
self.actions = self.strip.actions
|
|
261
|
+
|
|
262
|
+
def result(self) -> Any:
|
|
263
|
+
project = self.shell.session.project
|
|
264
|
+
return project.results.get(self.model_id) if project is not None else None
|
|
265
|
+
|
|
266
|
+
def run_state(self) -> str:
|
|
267
|
+
"""The ONE run-state rule every line of the page words its reason
|
|
268
|
+
by: "none" — no result — "failed" when the result is a failed run,
|
|
269
|
+
else "ok". It reads the result the badge was scored from, so the
|
|
270
|
+
two cannot disagree: the reader hands in no result for a test the
|
|
271
|
+
last run never finished (its manifest entry still pending, D145),
|
|
272
|
+
and a failed one where status.json recorded that run failed — the
|
|
273
|
+
badge's SIM_FAIL, beside which the strip said "No result yet"
|
|
274
|
+
while it asked the manifest instead."""
|
|
275
|
+
result = self.result()
|
|
276
|
+
if result is None:
|
|
277
|
+
return "none"
|
|
278
|
+
return "ok" if result.success else "failed"
|
|
279
|
+
|
|
280
|
+
def plots_withheld(self) -> bool:
|
|
281
|
+
"""O2's gate: a result that is not a success has no plots whatever
|
|
282
|
+
its tree and extras say, and a watch plot made on it would draw
|
|
283
|
+
nothing — a run that failed (`run_state`'s "failed"). A run that
|
|
284
|
+
never finished the test hands in no result (D145), and withholds
|
|
285
|
+
nothing: there is nothing to draw."""
|
|
286
|
+
result = self.result()
|
|
287
|
+
return result is not None and not result.success
|
|
288
|
+
|
|
289
|
+
def _state_reason(self) -> str | None:
|
|
290
|
+
"""The clause a run state that is not "ok" gives every line, or
|
|
291
|
+
None."""
|
|
292
|
+
state = self.run_state()
|
|
293
|
+
if state == "failed":
|
|
294
|
+
return "the run failed"
|
|
295
|
+
if state == "none":
|
|
296
|
+
return "this test has no result"
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
def unscored_reason(self) -> str:
|
|
300
|
+
"""Why this test has no tree, as a clause the line it is read into
|
|
301
|
+
completes ("nothing scored: this test has no baseline"): its run
|
|
302
|
+
failed or it has no result (`run_state`, first), its scoring
|
|
303
|
+
raised, the comparison refused it (its reason, as written), it is
|
|
304
|
+
simulate-only — which a test with a stored baseline can be, once
|
|
305
|
+
its entry tracks nothing — or it has no baseline to be scored
|
|
306
|
+
against. A model whose
|
|
307
|
+
scoring raised keeps its result and has no comparison at all — the
|
|
308
|
+
Suite row says `scoring failed` for the same state — so "no
|
|
309
|
+
baseline" would name the wrong cause."""
|
|
310
|
+
reason = self._state_reason()
|
|
311
|
+
if reason is not None:
|
|
312
|
+
return reason
|
|
313
|
+
comp = self.shell.session.comparisons.get(self.model_id)
|
|
314
|
+
if comp is None:
|
|
315
|
+
return SCORING_FAILED
|
|
316
|
+
if comp.simulate_only:
|
|
317
|
+
return "this test is simulate-only"
|
|
318
|
+
# A test the comparison refused has a baseline, and a reason.
|
|
319
|
+
return refusal(comp) or "this test has no baseline"
|
|
320
|
+
|
|
321
|
+
def _no_plots_reason(self, from_tree: bool) -> str:
|
|
322
|
+
"""Why there is no PLOT, which is not why there is no tree: a test
|
|
323
|
+
with no tree still draws one plot per tracked variable, so a page
|
|
324
|
+
with none has a failed run or no result (`run_state`, first: a
|
|
325
|
+
failed run has no plots whatever its tree says, O2), a result with
|
|
326
|
+
nothing in it, a result the test tracks nothing of (its patterns
|
|
327
|
+
match none of it, or it is simulate-only: the Variables drawer
|
|
328
|
+
lists what it holds, and "the result has no variables" beside
|
|
329
|
+
that list was false) — or a tree whose every criterion scores a
|
|
330
|
+
statistic (a stored-scalar leaf names no series, so it derives no
|
|
331
|
+
plot). ``from_tree`` is `Session.plots_from_tree`, never whether
|
|
332
|
+
there is a tree VIEW: a test with no baseline has none, and its
|
|
333
|
+
authored tree still names its plots."""
|
|
334
|
+
reason = self._state_reason()
|
|
335
|
+
if reason is not None:
|
|
336
|
+
return reason
|
|
337
|
+
if from_tree:
|
|
338
|
+
return "this test's criteria score statistics, not signals"
|
|
339
|
+
if self.shell.session.variable_names(self.model_id):
|
|
340
|
+
return "this test tracks none of the result's variables"
|
|
341
|
+
return "the result has no variables"
|
|
342
|
+
|
|
343
|
+
# -- the one open editor ---------------------------------------------------
|
|
344
|
+
|
|
345
|
+
def editing_for(self, index: int) -> tuple[str, ...]:
|
|
346
|
+
"""The leaf paths whose rings plot ``index`` draws — the open
|
|
347
|
+
criterion's, and only on the card its row is on. A tuple, never a
|
|
348
|
+
bare string: `rings_for` refuses one. None for a folded row's
|
|
349
|
+
editor (D152): it is fields only, and its key is no leaf's path."""
|
|
350
|
+
if (
|
|
351
|
+
self.open is not None
|
|
352
|
+
and self.open[0] == index
|
|
353
|
+
and not is_fold(self.open[1])
|
|
354
|
+
):
|
|
355
|
+
return (self.open[1],)
|
|
356
|
+
return ()
|
|
357
|
+
|
|
358
|
+
# -- the one open form row --------------------------------------------------
|
|
359
|
+
|
|
360
|
+
def _node(self, path: str) -> dict | None:
|
|
361
|
+
return node_or_none(self.shell.session.tree(self.model_id), path)
|
|
362
|
+
|
|
363
|
+
def open_row(self, target: RowTarget, title: str, build: RowBuild) -> None:
|
|
364
|
+
"""A form row under ``target``'s row (the 2026-09-21 board, 1A-1D).
|
|
365
|
+
ONE thing open at a time: the editor goes, and any other row — and
|
|
366
|
+
the card whose rings went with the editor is re-sent, once."""
|
|
367
|
+
view = self._view()
|
|
368
|
+
self.drop_editor()
|
|
369
|
+
self._close_row()
|
|
370
|
+
self._unsay()
|
|
371
|
+
# Its own row's error goes as its form opens; any other row's
|
|
372
|
+
# stays with that row (`RowTarget.hush`).
|
|
373
|
+
target.hush()
|
|
374
|
+
self._redraw_moved(view)
|
|
375
|
+
self.unfold_to(target) # R2: a form under a tree row is shown
|
|
376
|
+
assert target.slot is not None # set by the row's owner at build
|
|
377
|
+
with target.slot:
|
|
378
|
+
column = (
|
|
379
|
+
ui.column()
|
|
380
|
+
.classes("dstf-form-row w-full gap-2")
|
|
381
|
+
.mark(f"form-row:{target.key}")
|
|
382
|
+
)
|
|
383
|
+
target.row.classes(add="dstf-row-open")
|
|
384
|
+
self.form = (target, title, build, column, _raw(self._node(target.node)))
|
|
385
|
+
self._build_row()
|
|
386
|
+
self._paint_tree_clicks()
|
|
387
|
+
# Just enough to show the whole row, like the tree line's jump; a
|
|
388
|
+
# row already in view does not move (the owner's ruling). Opened
|
|
389
|
+
# from a criteria row at the viewport's foot, it was drawn below
|
|
390
|
+
# the fold with nothing to say it had opened.
|
|
391
|
+
ui.run_javascript(
|
|
392
|
+
f'document.getElementById("{column.html_id}")'
|
|
393
|
+
'?.scrollIntoView({block: "nearest"})'
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def _build_row(self) -> None:
|
|
397
|
+
assert self.form is not None
|
|
398
|
+
target, title, build, column, _ = self.form
|
|
399
|
+
column.clear()
|
|
400
|
+
with column:
|
|
401
|
+
self.form_title = (
|
|
402
|
+
ui.label(title)
|
|
403
|
+
.classes("text-subtitle2")
|
|
404
|
+
.mark(f"form-title:{target.key}")
|
|
405
|
+
)
|
|
406
|
+
build(self._close_row)
|
|
407
|
+
|
|
408
|
+
def retitle(self, target: RowTarget, title: str) -> None:
|
|
409
|
+
"""`Panel.retitle`: the open form row under ``target`` reads
|
|
410
|
+
``title`` from now on — a later rebuild of the row starts from it.
|
|
411
|
+
Nothing for a row that is not the open one."""
|
|
412
|
+
if self.form is None or self.form[0] is not target:
|
|
413
|
+
return
|
|
414
|
+
_, _, build, column, built = self.form
|
|
415
|
+
self.form = (target, title, build, column, built)
|
|
416
|
+
if self.form_title is not None and not self.form_title.is_deleted:
|
|
417
|
+
self.form_title.set_text(title)
|
|
418
|
+
|
|
419
|
+
def _close_row(self) -> None:
|
|
420
|
+
"""Take the open form row away, if there is one; nothing is redrawn
|
|
421
|
+
— a row draws on no plot. The error its row's host shows goes with
|
|
422
|
+
it, and no other row's: a page-wide hide took an error off the row
|
|
423
|
+
it was about for a Cancel on another plot's row (the review)."""
|
|
424
|
+
if self.form is None:
|
|
425
|
+
return
|
|
426
|
+
target, _, _, column, _ = self.form
|
|
427
|
+
self.form = None
|
|
428
|
+
target.hush()
|
|
429
|
+
if not column.is_deleted:
|
|
430
|
+
column.delete()
|
|
431
|
+
if not target.row.is_deleted:
|
|
432
|
+
target.row.classes(remove="dstf-row-open")
|
|
433
|
+
self._paint_tree_clicks()
|
|
434
|
+
|
|
435
|
+
def _paint_tree_clicks(self) -> None:
|
|
436
|
+
"""What each clickable tree row says it has open under it (F5)."""
|
|
437
|
+
if self.tree is not None:
|
|
438
|
+
self.tree.paint_clicks()
|
|
439
|
+
|
|
440
|
+
def drop_row(self, target: RowTarget) -> None:
|
|
441
|
+
"""Close the open form if it hangs under ``target``: a host being
|
|
442
|
+
cleared — rebuilt, dropped with its row, hidden — takes its form
|
|
443
|
+
with it. Any other form is not this host's to close."""
|
|
444
|
+
if self.form is not None and self.form[0] is target:
|
|
445
|
+
self._close_row()
|
|
446
|
+
|
|
447
|
+
def _follow_row(self) -> None:
|
|
448
|
+
"""The open row after a sync: gone with the rows it hung under (a
|
|
449
|
+
shape change rebuilt them), or rebuilt when an edit staged ELSEWHERE
|
|
450
|
+
moved its node — the criterion editor's rule, applied to a node: a
|
|
451
|
+
form showing a value the node no longer has would stage it straight
|
|
452
|
+
back. Anything else leaves it alone, so a field being typed in
|
|
453
|
+
survives every unrelated sync. A node that is gone closes the row:
|
|
454
|
+
its ``build`` over a path that names nothing drew a form for no
|
|
455
|
+
node (the Task 5 review's probe built twice for one)."""
|
|
456
|
+
if self.form is None:
|
|
457
|
+
return
|
|
458
|
+
target, title, build, column, built = self.form
|
|
459
|
+
if column.is_deleted or target.row.is_deleted:
|
|
460
|
+
self.form = None
|
|
461
|
+
return
|
|
462
|
+
node = self._node(target.node)
|
|
463
|
+
if node is None:
|
|
464
|
+
self._close_row()
|
|
465
|
+
return
|
|
466
|
+
now = _raw(node)
|
|
467
|
+
if now != built:
|
|
468
|
+
self.form = (target, title, build, column, now)
|
|
469
|
+
self._build_row()
|
|
470
|
+
|
|
471
|
+
def row_click(self, index: int, path: str) -> None:
|
|
472
|
+
"""A criteria row's own click (board 1A): it closes a form open
|
|
473
|
+
under it and opens nothing; else it toggles the row's editor."""
|
|
474
|
+
if self.form is not None and self.form[0].key == f"crit:{index}:{path}":
|
|
475
|
+
self._close_row()
|
|
476
|
+
return
|
|
477
|
+
self.toggle_criterion(index, path)
|
|
478
|
+
|
|
479
|
+
def reveal(self, path: str, index: int | None) -> None:
|
|
480
|
+
"""Open ``path``'s editor at the end of the next sync — the one that
|
|
481
|
+
draws the row an add just made — on plot ``index``, the plot it was
|
|
482
|
+
asked from, when that plot holds the row; else on the first plot
|
|
483
|
+
that does (a tree row's add has no plot). Recorded only when that
|
|
484
|
+
criterion EXISTS: apply is synchronous, so a refused add left no
|
|
485
|
+
node, and a reveal kept for it opened the next add's criterion by
|
|
486
|
+
itself. A folded row's key (D152) is revealed while its first
|
|
487
|
+
member exists."""
|
|
488
|
+
tree = self.shell.session.tree(self.model_id)
|
|
489
|
+
if node_or_none(tree, leaf_of(path)) is not None:
|
|
490
|
+
self.pending_reveal = (path, index)
|
|
491
|
+
|
|
492
|
+
def _reveal(self, path: str, index: int | None) -> None:
|
|
493
|
+
"""The recorded reveal, once the cards are drawn: plot 3's `+`
|
|
494
|
+
opened its editor on plot 0, the first card holding the variable,
|
|
495
|
+
~855 px above the click and never scrolled to (the Part 2 review).
|
|
496
|
+
Brought into view just enough, like a form row: the row, then the
|
|
497
|
+
editor under it — the row alone brought to the viewport's foot left
|
|
498
|
+
the editor just below it.
|
|
499
|
+
|
|
500
|
+
Sent to THIS page's client: `sync` runs from the Shell's timer,
|
|
501
|
+
whose context is no page's, and a bare `ui.run_javascript` from
|
|
502
|
+
there scrolled nothing."""
|
|
503
|
+
if index is not None and not (
|
|
504
|
+
0 <= index < len(self.cards) and path in self.cards[index].rows
|
|
505
|
+
):
|
|
506
|
+
index = None
|
|
507
|
+
# A statistics criterion has no plot: the card is its home, and
|
|
508
|
+
# `open_criterion` opens it there.
|
|
509
|
+
self.open_criterion(path, index)
|
|
510
|
+
# BY THEIR IDS (H6, F3's hazard): a `.dstf-crit-open` query found the
|
|
511
|
+
# tree row an open had just closed, and a `.dstf-editor` one that
|
|
512
|
+
# row's editor — both going with the next update, which this
|
|
513
|
+
# script can overtake (`_scroll_to_open`). The Statistics card's
|
|
514
|
+
# too: a `.dstf-stats .dstf-editor` query was the same hazard.
|
|
515
|
+
row = self._drawn_row(path)
|
|
516
|
+
if row is None or self.open is None:
|
|
517
|
+
return
|
|
518
|
+
editor = self._editor_on(self.open[0])
|
|
519
|
+
if editor is None:
|
|
520
|
+
return
|
|
521
|
+
self.client.run_javascript(
|
|
522
|
+
f'document.getElementById("{row.html_id}")'
|
|
523
|
+
'?.scrollIntoView({block: "nearest"});'
|
|
524
|
+
f'document.getElementById("{editor.html_id}")'
|
|
525
|
+
'?.scrollIntoView({block: "nearest"})'
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
def toggle_criterion(self, index: int, path: str) -> None:
|
|
529
|
+
"""A criteria row's own click: its editor opens, or closes if that
|
|
530
|
+
row was the open one."""
|
|
531
|
+
if self.open == (index, path):
|
|
532
|
+
self.close_editor()
|
|
533
|
+
else:
|
|
534
|
+
self.open_criterion(path, index)
|
|
535
|
+
|
|
536
|
+
def open_criterion(self, path: str, index: int | None = None) -> None:
|
|
537
|
+
"""One editor at a time, page-wide (board 2A). The plot above it is
|
|
538
|
+
its editing surface, so it is drawn if it was not. ONE `tree_view`
|
|
539
|
+
read, handed to every card this touches: reading it per shown card
|
|
540
|
+
cost fourteen reads for one row click on a twelve-plot page (the 3b
|
|
541
|
+
review). A statistics criterion's editor is the Statistics card's
|
|
542
|
+
(U3): it has no plot."""
|
|
543
|
+
self._unsay()
|
|
544
|
+
if index is None and self.stats is not None:
|
|
545
|
+
view = self._view()
|
|
546
|
+
if self.stats.row_for(path) is not None:
|
|
547
|
+
self._close_row()
|
|
548
|
+
self.drop_editor()
|
|
549
|
+
if self.stats.open_editor(path, view):
|
|
550
|
+
self.open = (STATS, path)
|
|
551
|
+
self._redraw_moved(view)
|
|
552
|
+
return
|
|
553
|
+
if index is None:
|
|
554
|
+
index = next((card.index for card in self.cards if path in card.rows), None)
|
|
555
|
+
if index is None or not 0 <= index < len(self.cards):
|
|
556
|
+
return
|
|
557
|
+
card = self.cards[index]
|
|
558
|
+
if path not in card.rows:
|
|
559
|
+
return
|
|
560
|
+
view = self._view()
|
|
561
|
+
self._close_row()
|
|
562
|
+
if self.focused is not None and self.focused != index:
|
|
563
|
+
# The editor has to be on screen: the row it opens under is on a
|
|
564
|
+
# card Focus is hiding (board 5A shows ONE plot).
|
|
565
|
+
self.focused = None
|
|
566
|
+
self._paint_focus(view)
|
|
567
|
+
self.drop_editor()
|
|
568
|
+
self.open = (index, path)
|
|
569
|
+
card.show(view)
|
|
570
|
+
card.open_editor(path, view)
|
|
571
|
+
self._redraw_moved(view)
|
|
572
|
+
|
|
573
|
+
def tree_row_click(self, path: str, *_: Any) -> None:
|
|
574
|
+
"""A tree row's own click, or Enter / Space on it (U5): it closes a
|
|
575
|
+
form open under it and opens nothing (a criteria row's rule); else a
|
|
576
|
+
criterion's row toggles its editor under the row (Q5), and a
|
|
577
|
+
group's does what a panel registered for it (Q6: a k-of-n's or a
|
|
578
|
+
weighted's fields) — or nothing."""
|
|
579
|
+
if self.tree is None:
|
|
580
|
+
return
|
|
581
|
+
if self.form is not None and self.form[0].key == f"tree:{path}":
|
|
582
|
+
self._close_row()
|
|
583
|
+
return
|
|
584
|
+
node = self._node(path)
|
|
585
|
+
if node is None:
|
|
586
|
+
return
|
|
587
|
+
if "combinator" not in node:
|
|
588
|
+
self.toggle_tree_criterion(path)
|
|
589
|
+
return
|
|
590
|
+
target = self.tree.targets.get(path)
|
|
591
|
+
if target is not None and target.click is not None:
|
|
592
|
+
target.click()
|
|
593
|
+
|
|
594
|
+
def toggle_tree_criterion(self, path: str) -> None:
|
|
595
|
+
if self.open == (TREE, path):
|
|
596
|
+
self.close_editor()
|
|
597
|
+
else:
|
|
598
|
+
self.open_tree_criterion(path)
|
|
599
|
+
|
|
600
|
+
def open_tree_criterion(self, path: str) -> None:
|
|
601
|
+
"""The criterion's editor under its tree row (Q5, Y4) — the page's
|
|
602
|
+
one editor (Q11): whatever else is open closes first, and the card
|
|
603
|
+
whose rings went with it is re-sent."""
|
|
604
|
+
if self.tree is None:
|
|
605
|
+
return
|
|
606
|
+
self._unsay()
|
|
607
|
+
view = self._view()
|
|
608
|
+
self._close_row()
|
|
609
|
+
self.drop_editor()
|
|
610
|
+
if self.tree.open_editor(path):
|
|
611
|
+
self.open = (TREE, path)
|
|
612
|
+
self._redraw_moved(view)
|
|
613
|
+
|
|
614
|
+
def edit_on_plot(self, path: str) -> None:
|
|
615
|
+
"""Q12: the tree editor's "Edit on its plot" — the criterion opens
|
|
616
|
+
on the first plot holding it NOW, as a jump opens it (the tree's
|
|
617
|
+
editor closes: one editor page-wide), and its row is brought to the
|
|
618
|
+
middle of the viewport. Found at the click, never when the link was
|
|
619
|
+
drawn: the tree is refreshed before the cards rebuild, so an index
|
|
620
|
+
read then could name the plot it was on (J3). Nothing where no plot
|
|
621
|
+
holds it."""
|
|
622
|
+
index = next((c.index for c in self.cards if path in c.rows), None)
|
|
623
|
+
if index is None:
|
|
624
|
+
return
|
|
625
|
+
self.open_criterion(path, index)
|
|
626
|
+
self._scroll_to_open(path)
|
|
627
|
+
|
|
628
|
+
def toggle_stats_editor(self, path: str, *_: Any) -> None:
|
|
629
|
+
"""A scored Statistics row's own click: its criterion's editor
|
|
630
|
+
opens under it, or closes if it was the open one."""
|
|
631
|
+
if self.open == (STATS, path):
|
|
632
|
+
self.close_editor()
|
|
633
|
+
else:
|
|
634
|
+
self.open_criterion(path)
|
|
635
|
+
|
|
636
|
+
def close_editor(self) -> None:
|
|
637
|
+
view = self._view()
|
|
638
|
+
self.drop_editor()
|
|
639
|
+
self._redraw_moved(view)
|
|
640
|
+
|
|
641
|
+
def drop_editor(self) -> None:
|
|
642
|
+
"""Close the open editor and redraw NOTHING: what is re-sent is the
|
|
643
|
+
caller's to decide, once. A close that redrew on its own re-sent the
|
|
644
|
+
same card twice for a swap between two of its rows — and, in
|
|
645
|
+
`rebuild`, asked the Session for a plot the staged layout had just
|
|
646
|
+
taken away, which ended the rebuild half-done with the new shape
|
|
647
|
+
already recorded (the Task 6 review). The tree card and the
|
|
648
|
+
Statistics card close the page's one editor through it too, as
|
|
649
|
+
`drop_row` closes its form row."""
|
|
650
|
+
if self.open is None:
|
|
651
|
+
return
|
|
652
|
+
index, self.open = self.open[0], None
|
|
653
|
+
if index == STATS:
|
|
654
|
+
if self.stats is not None:
|
|
655
|
+
self.stats.close_editor()
|
|
656
|
+
return
|
|
657
|
+
if index == TREE:
|
|
658
|
+
if self.tree is not None:
|
|
659
|
+
self.tree.close_editor()
|
|
660
|
+
return
|
|
661
|
+
self.cards[index].close_editor()
|
|
662
|
+
|
|
663
|
+
def moved(self, path: str, text: str, paths: dict[str, str]) -> None:
|
|
664
|
+
"""A move went through (U4, V4; `Panel.moved`): the editor and the
|
|
665
|
+
form row close NOW, before the sync the move's events bring — two
|
|
666
|
+
criteria on one variable trading places move no shape, and an
|
|
667
|
+
editor left open showed the other one — and the moved row and its
|
|
668
|
+
line wait for that sync, which draws the row. ``paths``: as for
|
|
669
|
+
`edited`."""
|
|
670
|
+
self.edited(paths)
|
|
671
|
+
view = self._view()
|
|
672
|
+
self.drop_editor()
|
|
673
|
+
self._close_row()
|
|
674
|
+
self._redraw_moved(view)
|
|
675
|
+
self._unsay()
|
|
676
|
+
self.pending_moved = (path, text)
|
|
677
|
+
self.move_revision = self.shell.session.spec_revision
|
|
678
|
+
|
|
679
|
+
def apply(self, outcome: Outcome | Sequence[Outcome]) -> None:
|
|
680
|
+
"""Apply what one of this page's own acts returned — every action
|
|
681
|
+
bar and inline host on the page applies through here."""
|
|
682
|
+
self._own(lambda: self.shell.session.apply(outcome))
|
|
683
|
+
|
|
684
|
+
def _own(self, act: Callable[[], None]) -> None:
|
|
685
|
+
"""Run ``act``, one of this page's own edits, and note the `spec`
|
|
686
|
+
revisions it produced in the TAB's record (`TabState.own_revisions`):
|
|
687
|
+
all of them, a sequence of outcomes stages once per patch. The
|
|
688
|
+
queue's or a scan's work is posted to the UI loop (`Session._post`) and
|
|
689
|
+
cannot stage while ``act`` runs on it. The open is not:
|
|
690
|
+
`Shell.open_async` runs `Session.open` on a thread of its own, whose
|
|
691
|
+
merge emits a `spec` event (`Session._emit_spec`); one emitted
|
|
692
|
+
between the two reads is counted as this tab's and keeps its folds.
|
|
693
|
+
Not guarded."""
|
|
694
|
+
session = self.shell.session
|
|
695
|
+
before = session.spec_revision
|
|
696
|
+
try:
|
|
697
|
+
act()
|
|
698
|
+
finally:
|
|
699
|
+
state = self.state
|
|
700
|
+
state.own_revisions.update(range(before + 1, session.spec_revision + 1))
|
|
701
|
+
state.seen_revision = max(state.seen_revision, session.spec_revision)
|
|
702
|
+
|
|
703
|
+
def edited(self, paths: dict[str, str]) -> None:
|
|
704
|
+
"""An edit of the tree went through (`Panel.edited`, U5 R1):
|
|
705
|
+
``paths`` says where each node it kept went, and the tree card's
|
|
706
|
+
folded groups follow it — before the sync that rebuilds the rows,
|
|
707
|
+
which drops a folded path naming no group (Y6). What the panels
|
|
708
|
+
kept (`kept`) follows its node the same way, and goes with a node
|
|
709
|
+
the edit did not keep."""
|
|
710
|
+
if self.tree is not None:
|
|
711
|
+
self.tree.rekey(paths)
|
|
712
|
+
# The plots' unfolded family rows are kept by their members' paths.
|
|
713
|
+
Folding.rekey(self.state, self.model_id, paths)
|
|
714
|
+
kept = self.state.kept.get(self.model_id)
|
|
715
|
+
if kept:
|
|
716
|
+
self.state.kept[self.model_id] = {
|
|
717
|
+
(paths[node], name): held
|
|
718
|
+
for (node, name), held in kept.items()
|
|
719
|
+
if node in paths
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
def kept(self, node: str, name: str) -> dict[Any, Any]:
|
|
723
|
+
"""`Panel.kept` for capability ``name``'s panel at ``node`` of this
|
|
724
|
+
test: the same dict at every build, until it is dropped — the
|
|
725
|
+
tab's record (`TabState.kept`), which `heard_spec` empties when an
|
|
726
|
+
edit that is not this tab's lands on the test."""
|
|
727
|
+
return self.state.kept.setdefault(self.model_id, {}).setdefault(
|
|
728
|
+
(node, name), {}
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
def unfold_to(self, target: RowTarget) -> None:
|
|
732
|
+
"""R2: something shown under ``target``'s row — a form, a refused
|
|
733
|
+
act's sentence — unfolds the folded groups it is inside, when that
|
|
734
|
+
row is a TREE row. Under a plot's or the Statistics card's row it
|
|
735
|
+
is done outside the tree, and leaves the folds alone."""
|
|
736
|
+
if self.tree is not None and self.tree.targets.get(target.node) is target:
|
|
737
|
+
self.tree.open_path(target.node)
|
|
738
|
+
|
|
739
|
+
def _unsay(self) -> None:
|
|
740
|
+
"""Take a move's line away: the next form, editor or edit."""
|
|
741
|
+
if self.tree is not None:
|
|
742
|
+
self.tree.unsay()
|
|
743
|
+
|
|
744
|
+
def _rethemed(self) -> None:
|
|
745
|
+
"""The tab switched theme (`TabTheme.switch`): every DRAWN plot is
|
|
746
|
+
re-sent in it — a `Plotly.react` on the same node, so its zoom, its
|
|
747
|
+
arrows' map and a staged edit stay. A card not drawn yet takes the
|
|
748
|
+
tab's theme when it is."""
|
|
749
|
+
for card in self.cards:
|
|
750
|
+
if card.plot is not None:
|
|
751
|
+
card.redraw()
|
|
752
|
+
|
|
753
|
+
def _redraw_moved(self, view: Any) -> None:
|
|
754
|
+
"""The signature is the one thing that decides a redraw: only the
|
|
755
|
+
card whose rings came or went is re-sent."""
|
|
756
|
+
for card in self.cards:
|
|
757
|
+
card.redraw_if_moved(view)
|
|
758
|
+
|
|
759
|
+
def jump_to(self, path: str) -> None:
|
|
760
|
+
"""The closed tree line's jump (board 1A): open that criterion and
|
|
761
|
+
bring the row it opened under into view — a plot's criteria row, or
|
|
762
|
+
a Statistics row (U3: every leaf of a tree view is on one or the
|
|
763
|
+
other). A criterion with no row drawn opens nothing and scrolls to
|
|
764
|
+
nothing: with another editor open, the jump scrolled to THAT row."""
|
|
765
|
+
self.open_criterion(path)
|
|
766
|
+
self._scroll_to_open(path)
|
|
767
|
+
|
|
768
|
+
def _scroll_to_open(self, path: str) -> None:
|
|
769
|
+
"""The row ``path``'s editor is open under, brought to the middle of
|
|
770
|
+
the viewport — found BY ITS ID: a `.dstf-crit-open` query found the
|
|
771
|
+
tree row an open had just closed (U5), whose class goes with the
|
|
772
|
+
next update, which this script can overtake. Nothing where the
|
|
773
|
+
open did not land on ``path``."""
|
|
774
|
+
row = self._drawn_row(path)
|
|
775
|
+
if row is not None:
|
|
776
|
+
self.client.run_javascript(
|
|
777
|
+
f'document.getElementById("{row.html_id}")'
|
|
778
|
+
'?.scrollIntoView({block: "center"})'
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
def _drawn_row(self, path: str) -> ui.element | None:
|
|
782
|
+
"""The plot's criteria row or the Statistics row ``path`` is open
|
|
783
|
+
under now, or None."""
|
|
784
|
+
if self.open is None or self.open[1] != path:
|
|
785
|
+
return None
|
|
786
|
+
return self._row_on(self.open[0], path)
|
|
787
|
+
|
|
788
|
+
def _editor_on(self, index: int) -> ui.element | None:
|
|
789
|
+
"""The open editor's box on plot ``index``, or on the Statistics
|
|
790
|
+
card for STATS — None where none is open."""
|
|
791
|
+
if index == STATS:
|
|
792
|
+
return None if self.stats is None else self.stats.editor_box
|
|
793
|
+
if 0 <= index < len(self.cards):
|
|
794
|
+
return self.cards[index].editor_box
|
|
795
|
+
return None
|
|
796
|
+
|
|
797
|
+
def _row_on(self, index: int, path: str) -> ui.element | None:
|
|
798
|
+
"""``path``'s criteria row on plot ``index``, or its Statistics row
|
|
799
|
+
for STATS — None where none is drawn."""
|
|
800
|
+
if index == STATS and self.stats is not None:
|
|
801
|
+
key = self.stats.row_for(path)
|
|
802
|
+
return None if key is None else self.stats.rows_by_path.get(key)
|
|
803
|
+
if 0 <= index < len(self.cards) and path in self.cards[index].rows:
|
|
804
|
+
return self.cards[index].rows[path].row
|
|
805
|
+
return None
|
|
806
|
+
|
|
807
|
+
def show_criterion(self, path: str, *_: Any) -> None:
|
|
808
|
+
"""A tree row's ⤓ (U5, Q7): the criterion's row where it is drawn —
|
|
809
|
+
on its first plot, drawn if it was not and a Focus hiding it ended;
|
|
810
|
+
or its Statistics row, the card opened — scrolled to the middle of
|
|
811
|
+
the viewport and flashed. No editor opens or moves, and no fold
|
|
812
|
+
opens: it shows a plot, not a tree row (R2)."""
|
|
813
|
+
view = self._view()
|
|
814
|
+
index = next((c.index for c in self.cards if path in c.rows), None)
|
|
815
|
+
if index is not None:
|
|
816
|
+
if self.focused is not None and self.focused != index:
|
|
817
|
+
self.focused = None
|
|
818
|
+
self._paint_focus(view)
|
|
819
|
+
self.cards[index].show(view)
|
|
820
|
+
# Its OWN row: a family's folded row would hide it (D152).
|
|
821
|
+
self.cards[index].folding.unfold_to(path, view)
|
|
822
|
+
elif self.stats is not None and self.stats.row_for(path) is not None:
|
|
823
|
+
index = STATS
|
|
824
|
+
if not self.stats.is_open:
|
|
825
|
+
self.stats.is_open = True
|
|
826
|
+
self.stats.refresh(view)
|
|
827
|
+
row = None if index is None else self._row_on(index, path)
|
|
828
|
+
if row is None:
|
|
829
|
+
return
|
|
830
|
+
self.client.run_javascript(
|
|
831
|
+
f'const r = document.getElementById("{row.html_id}");'
|
|
832
|
+
'if (r) { r.scrollIntoView({block: "center"});'
|
|
833
|
+
' r.classList.remove("dstf-flash"); void r.offsetWidth;'
|
|
834
|
+
' r.classList.add("dstf-flash"); }'
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
# -- the cards -------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
def rebuild(self, view: dict | None) -> None:
|
|
840
|
+
"""The cards, from the plots as they are now (`Session.plots`: one
|
|
841
|
+
per scored variable or family in tree order, then the watch plots;
|
|
842
|
+
one per tracked variable with no tree). ``view`` and the label
|
|
843
|
+
scope are read ONCE here and handed to every card — a card that
|
|
844
|
+
read them for itself cost one read per plot (the Task 5-6
|
|
845
|
+
review)."""
|
|
846
|
+
assert self.column is not None and self.count is not None
|
|
847
|
+
session = self.shell.session
|
|
848
|
+
self.gone = False
|
|
849
|
+
# The rows the editor hangs under go with the cards: the criterion
|
|
850
|
+
# it was open on may not even exist after this. Closed WITHOUT a
|
|
851
|
+
# redraw — these cards are about to be dropped, and one of them may
|
|
852
|
+
# be a plot the staged layout no longer has. The Statistics card's
|
|
853
|
+
# editor and the tree's (U5) hang under none of them.
|
|
854
|
+
if self.open is not None and self.open[0] >= 0:
|
|
855
|
+
self.drop_editor()
|
|
856
|
+
# Focus names an INDEX, and after this the indices may name other
|
|
857
|
+
# plots; the plots a user had drawn are re-drawn where they are left.
|
|
858
|
+
self.focused = None
|
|
859
|
+
# The selection follows its PLOT, by subject: a watch plot given a
|
|
860
|
+
# criterion becomes derived and moves to where the tree names it,
|
|
861
|
+
# and the index it had names another plot. A gesture that placed
|
|
862
|
+
# the selection itself (`stage_layout`'s ``select``) left none.
|
|
863
|
+
follow = self.follow
|
|
864
|
+
shown = {card.index for card in self.cards if card.plot is not None}
|
|
865
|
+
for card in self.cards:
|
|
866
|
+
card.dispose()
|
|
867
|
+
self.column.clear()
|
|
868
|
+
plots = session.plots(self.model_id)
|
|
869
|
+
# The label scope too: it is one fact about the TEST, and a card
|
|
870
|
+
# that read it for itself cost one `plots` + `tracked` read per
|
|
871
|
+
# plot — 35 for a five-plot test.
|
|
872
|
+
scope = session.variable_texts(self.model_id)
|
|
873
|
+
with self.column:
|
|
874
|
+
self.cards = [
|
|
875
|
+
PlotCard(self, plot.index, plot, view, scope) for plot in plots
|
|
876
|
+
]
|
|
877
|
+
for card in self.cards:
|
|
878
|
+
# A rebuild must not blank what the user was looking at: the
|
|
879
|
+
# browser's observer has already fired for those slots and will
|
|
880
|
+
# not fire again.
|
|
881
|
+
if card.index in shown:
|
|
882
|
+
card.show(view)
|
|
883
|
+
self._paint_focus(view)
|
|
884
|
+
# The selection names an INDEX too; it is kept — on the plot it
|
|
885
|
+
# followed, else clamped to the cards there are now — and its ring
|
|
886
|
+
# painted on the new cards. The drawer follows at the end of the
|
|
887
|
+
# sync that called this, once.
|
|
888
|
+
moved = next(
|
|
889
|
+
(
|
|
890
|
+
plot.index
|
|
891
|
+
for plot in plots
|
|
892
|
+
if follow and any(name in plot.subject for name in follow)
|
|
893
|
+
),
|
|
894
|
+
None,
|
|
895
|
+
)
|
|
896
|
+
self._ring(self.selected if moved is None else moved)
|
|
897
|
+
self.count.set_text(
|
|
898
|
+
self._count_text(session.plots_from_tree(self.model_id), plots)
|
|
899
|
+
)
|
|
900
|
+
self._observe()
|
|
901
|
+
|
|
902
|
+
def _count_text(self, from_tree: bool, plots: Sequence[ResolvedPlot]) -> str:
|
|
903
|
+
"""What the cards are: derived from the criteria, or — nothing
|
|
904
|
+
scored — one per tracked variable or array; counted apart from a
|
|
905
|
+
person's watch plots, which are neither."""
|
|
906
|
+
if not plots:
|
|
907
|
+
return f"no plots: {self._no_plots_reason(from_tree)}"
|
|
908
|
+
watch = sum(1 for plot in plots if plot.watch)
|
|
909
|
+
if watch == len(plots):
|
|
910
|
+
# Nothing derived: "0 plots · derived from the criteria" counted
|
|
911
|
+
# plots that are not there.
|
|
912
|
+
return f"{plural(watch, 'watch plot')} · no criteria on a signal yet"
|
|
913
|
+
why = (
|
|
914
|
+
"derived from the criteria"
|
|
915
|
+
if from_tree
|
|
916
|
+
else "one per tracked variable or array"
|
|
917
|
+
)
|
|
918
|
+
return f"{plural(len(plots) - watch, 'plot')} · {why}" + (
|
|
919
|
+
f" · {watch} watch" if watch else ""
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
def on_visible(self, arg: Any) -> None:
|
|
923
|
+
"""The browser's `dstf_plot_visible`: the slot's index, as a string.
|
|
924
|
+
Anything else is a page that has moved on since the observer fired."""
|
|
925
|
+
try:
|
|
926
|
+
index = int(arg) if isinstance(arg, str) else -1
|
|
927
|
+
except ValueError:
|
|
928
|
+
return
|
|
929
|
+
if 0 <= index < len(self.cards):
|
|
930
|
+
self.cards[index].show()
|
|
931
|
+
|
|
932
|
+
def on_plot(self, what: str, arg: Any) -> None:
|
|
933
|
+
"""A click on a plot (``clicked``) or a handle held on one
|
|
934
|
+
(``moved``), named by its `ui.plotly`'s element id: handed to the
|
|
935
|
+
card that plot is on. One for a plot this page no longer has — a
|
|
936
|
+
rebuild crossed it — is nothing."""
|
|
937
|
+
plot = arg.get("plot") if isinstance(arg, dict) else None
|
|
938
|
+
for card in self.cards:
|
|
939
|
+
if card.plot is not None and card.plot.id == plot:
|
|
940
|
+
getattr(card, what)(arg)
|
|
941
|
+
return
|
|
942
|
+
|
|
943
|
+
def disarm(self) -> None:
|
|
944
|
+
"""Esc: no field is armed (D149)."""
|
|
945
|
+
for card in self.cards:
|
|
946
|
+
card.placing.disarm()
|
|
947
|
+
|
|
948
|
+
def _observe(self) -> None:
|
|
949
|
+
ui.run_javascript(OBSERVE)
|
|
950
|
+
|
|
951
|
+
# -- the selected plot and the layout ---------------------------------------
|
|
952
|
+
|
|
953
|
+
def select(self, index: int) -> None:
|
|
954
|
+
"""Select plot ``index``, clamped to the cards (0 with none), move
|
|
955
|
+
the ring to its card, and the open Variables drawer's checks with
|
|
956
|
+
it."""
|
|
957
|
+
self._ring(index)
|
|
958
|
+
self.rail.refresh()
|
|
959
|
+
|
|
960
|
+
def _ring(self, index: int) -> None:
|
|
961
|
+
self.selected = max(0, min(index, len(self.cards) - 1))
|
|
962
|
+
self.follow = self.cards[self.selected].resolved.subject if self.cards else None
|
|
963
|
+
for card in self.cards:
|
|
964
|
+
if card.index == self.selected:
|
|
965
|
+
card.card.classes(add="dstf-plot-selected")
|
|
966
|
+
else:
|
|
967
|
+
card.card.classes(remove="dstf-plot-selected")
|
|
968
|
+
|
|
969
|
+
def stage_layout(
|
|
970
|
+
self, extras: Sequence[PlotExtra], select: int | None = None
|
|
971
|
+
) -> bool:
|
|
972
|
+
"""Stage ``extras`` — the test's plot extras after one gesture — as
|
|
973
|
+
the ONE op on `/plots`, and say whether it was staged. An edit that
|
|
974
|
+
changes nothing stages nothing: extras equal to the ones in effect,
|
|
975
|
+
or none on an entry with no key (`layout_op`'s ``None``). ``select``
|
|
976
|
+
is set BEFORE the stage — the rebuild its `spec` event causes reads
|
|
977
|
+
it — and put back if the Session refuses: a refused edit is said,
|
|
978
|
+
and nothing else changes (3c, planner's choice 10)."""
|
|
979
|
+
session = self.shell.session
|
|
980
|
+
if tuple(extras) == session.layout(self.model_id):
|
|
981
|
+
return False
|
|
982
|
+
op = layout_op(self.model_id, session.spec.entry(self.model_id), extras)
|
|
983
|
+
if op is None:
|
|
984
|
+
return False
|
|
985
|
+
before = self.selected, self.follow
|
|
986
|
+
if select is not None:
|
|
987
|
+
self.selected, self.follow = select, None
|
|
988
|
+
try:
|
|
989
|
+
self._own(lambda: session.stage([op]))
|
|
990
|
+
except (PatchError, ConfigError) as e:
|
|
991
|
+
self.selected, self.follow = before
|
|
992
|
+
ui.notify(user_text(e), type="warning")
|
|
993
|
+
return False
|
|
994
|
+
return True
|
|
995
|
+
|
|
996
|
+
def remove_watch(self, index: int, var: str) -> None:
|
|
997
|
+
"""A watch card's Remove plot: watch plot ``index`` (of ``var``)
|
|
998
|
+
gone, the selection kept on the SAME plot — a removal above it
|
|
999
|
+
shifts it up by one; the selected plot removed selects the one now
|
|
1000
|
+
at its index, the last if it was the last (the rebuild clamps). The
|
|
1001
|
+
extras are read NOW: a card drawn before the last change may name
|
|
1002
|
+
an entry that has gone, and that removes nothing."""
|
|
1003
|
+
try:
|
|
1004
|
+
extras = remove_watch(self.shell.session.layout(self.model_id), var)
|
|
1005
|
+
except KeyError:
|
|
1006
|
+
return
|
|
1007
|
+
self.stage_layout(extras, select=self.selected - (index < self.selected))
|
|
1008
|
+
|
|
1009
|
+
def remove_context(self, index: int, name: str) -> None:
|
|
1010
|
+
"""A context series' `×`: ``name`` off plot ``index``, as the plots
|
|
1011
|
+
are NOW — a card drawn before the last change may name a plot that
|
|
1012
|
+
has moved."""
|
|
1013
|
+
plots = self.shell.session.plots(self.model_id)
|
|
1014
|
+
if not 0 <= index < len(plots) or name not in plots[index].context:
|
|
1015
|
+
return
|
|
1016
|
+
extras = self.shell.session.layout(self.model_id)
|
|
1017
|
+
self.stage_layout(remove_context(extras, plots[index], name))
|
|
1018
|
+
|
|
1019
|
+
def reset_layout(self) -> None:
|
|
1020
|
+
"""Stage removing the entry's ``plots`` key: every context series
|
|
1021
|
+
and watch plot goes; the derived plots stay. Refused, it is said,
|
|
1022
|
+
like any layout edit."""
|
|
1023
|
+
try:
|
|
1024
|
+
self._own(lambda: self.shell.session.stage([reset_op(self.model_id)]))
|
|
1025
|
+
except (PatchError, ConfigError) as e:
|
|
1026
|
+
ui.notify(user_text(e), type="warning")
|
|
1027
|
+
|
|
1028
|
+
# -- Focus -----------------------------------------------------------------
|
|
1029
|
+
|
|
1030
|
+
def focus(self, index: int) -> None:
|
|
1031
|
+
"""Board 5A: Focus is a button on each plot, and the focused plot's
|
|
1032
|
+
button reads "Focused" and is the way out of it, like the bar's."""
|
|
1033
|
+
self.focused = None if self.focused == index else index
|
|
1034
|
+
if (
|
|
1035
|
+
self.focused is not None
|
|
1036
|
+
and self.open is not None
|
|
1037
|
+
and self.open[0] != TREE # Focus never hides the tree card
|
|
1038
|
+
and self.open[0] != self.focused
|
|
1039
|
+
):
|
|
1040
|
+
# An editor nobody can see is no editor, and it goes on staging
|
|
1041
|
+
# edits from drags on a plot that is not on screen. The other
|
|
1042
|
+
# direction of `open_criterion`'s, which ends the focus instead.
|
|
1043
|
+
self.close_editor()
|
|
1044
|
+
if self.focused is not None:
|
|
1045
|
+
# A form under a criteria row of a card Focus hides goes the
|
|
1046
|
+
# same way (the Task 5 review).
|
|
1047
|
+
for card in self.cards:
|
|
1048
|
+
if card.index != self.focused:
|
|
1049
|
+
for row in card.rows.values():
|
|
1050
|
+
self.drop_row(row.target)
|
|
1051
|
+
self._paint_focus()
|
|
1052
|
+
|
|
1053
|
+
def focus_to(self, index: Any) -> None:
|
|
1054
|
+
"""The switcher's choice: focus that plot — the same `focus` path,
|
|
1055
|
+
so an editor or a form row on the card it hides closes. Its own
|
|
1056
|
+
repaint (`set_options` to the plot already focused) does nothing:
|
|
1057
|
+
`focus` on the focused plot would END the focus."""
|
|
1058
|
+
if isinstance(index, int) and index != self.focused:
|
|
1059
|
+
self.focus(index)
|
|
1060
|
+
|
|
1061
|
+
def show_all(self) -> None:
|
|
1062
|
+
self.focused = None
|
|
1063
|
+
self._paint_focus()
|
|
1064
|
+
|
|
1065
|
+
def _paint_focus(self, view: Any = UNREAD) -> None:
|
|
1066
|
+
"""The bar and every card's visibility, from `focused` as it is now
|
|
1067
|
+
— the one place either is written, so a rebuild resets both by
|
|
1068
|
+
calling it."""
|
|
1069
|
+
if self.focus_bar is None or self.focus_text is None:
|
|
1070
|
+
return
|
|
1071
|
+
if self.focused is not None and not 0 <= self.focused < len(self.cards):
|
|
1072
|
+
self.focused = None
|
|
1073
|
+
focused = self.focused
|
|
1074
|
+
for card in self.cards:
|
|
1075
|
+
card.card.set_visibility(focused is None or card.index == focused)
|
|
1076
|
+
card.focus.set_text("Focused" if card.index == focused else "Focus")
|
|
1077
|
+
self.focus_bar.set_visibility(focused is not None)
|
|
1078
|
+
if focused is None or self.focus_all is None:
|
|
1079
|
+
return
|
|
1080
|
+
plots = plural(len(self.cards), "plot")
|
|
1081
|
+
# The sentence counts; the switcher beside it names the plot — the
|
|
1082
|
+
# way the plot names itself, its texts, a family folded as on its
|
|
1083
|
+
# title. The name in both said it twice.
|
|
1084
|
+
self.focus_text.set_text(f"Showing 1 of {plots}")
|
|
1085
|
+
if self.focus_switch is not None:
|
|
1086
|
+
self.focus_switch.set_options(
|
|
1087
|
+
{
|
|
1088
|
+
c.index: folded(c.resolved.subject, c.texts)
|
|
1089
|
+
+ (" · watch" if c.resolved.watch else "")
|
|
1090
|
+
for c in self.cards
|
|
1091
|
+
},
|
|
1092
|
+
value=focused,
|
|
1093
|
+
)
|
|
1094
|
+
self.focus_all.set_text(f"Show all {plots}")
|
|
1095
|
+
self.cards[focused].show(view)
|
|
1096
|
+
|
|
1097
|
+
def shape(self, view: dict | None) -> Any:
|
|
1098
|
+
"""What the cards are BUILT from and cannot be repainted through:
|
|
1099
|
+
the criteria and their variables, the plots themselves — each
|
|
1100
|
+
one's subject, context, whether it is a watch plot, and what the
|
|
1101
|
+
result lacks. NOT a leaf's
|
|
1102
|
+
metric — a mode switch repaints a row's name in place. NOT a
|
|
1103
|
+
statistics leaf: it is on no plot, and a `+` in the Statistics card
|
|
1104
|
+
rebuilt every plot card — Focus ended, a zoom was lost, every figure
|
|
1105
|
+
was re-sent."""
|
|
1106
|
+
session = self.shell.session
|
|
1107
|
+
return (
|
|
1108
|
+
tuple(
|
|
1109
|
+
(leaf["path"], leaf.get("variable"))
|
|
1110
|
+
for leaf in leaves_of(view)
|
|
1111
|
+
if not reads_a_statistic(
|
|
1112
|
+
str(leaf.get("metric")), leaf.get("params") or {}
|
|
1113
|
+
)
|
|
1114
|
+
),
|
|
1115
|
+
tuple(
|
|
1116
|
+
(plot.subject, plot.context, plot.watch, plot.missing)
|
|
1117
|
+
for plot in session.plots(self.model_id)
|
|
1118
|
+
),
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
# -- following the Session -------------------------------------------------
|
|
1122
|
+
|
|
1123
|
+
def _paint(self, view: dict | None) -> None:
|
|
1124
|
+
if self.badge is None or self.meta is None:
|
|
1125
|
+
return
|
|
1126
|
+
session = self.shell.session
|
|
1127
|
+
comp = session.comparisons.get(self.model_id)
|
|
1128
|
+
verdict, css = (
|
|
1129
|
+
classify_with_class(comp) if comp is not None else ("", SCORING_CLASS)
|
|
1130
|
+
)
|
|
1131
|
+
self.badge.set_text(verdict or "…")
|
|
1132
|
+
self.badge.style(f"background-color: var(--dstf-{css}-fill)")
|
|
1133
|
+
project = session.project
|
|
1134
|
+
if project is None:
|
|
1135
|
+
return
|
|
1136
|
+
# The index's id alone: `get_reference` reads the whole baseline off
|
|
1137
|
+
# disk, and this runs on every Session event to print one file name.
|
|
1138
|
+
ref_id = project.store.index.get_id(self.model_id)
|
|
1139
|
+
ref = (
|
|
1140
|
+
None if ref_id is None else RefIndex.ref_filename(ref_id).rsplit(".", 1)[0]
|
|
1141
|
+
)
|
|
1142
|
+
self.meta.set_text(f"{project.config.backend_name} · {ref or 'no baseline'}")
|
|
1143
|
+
if self.strip is not None:
|
|
1144
|
+
# `_gone` blanked the strip and hid its bar; this brings both
|
|
1145
|
+
# back, for the project that is open now. The strip never stops
|
|
1146
|
+
# a sync: the tree and the cards below it follow whatever it
|
|
1147
|
+
# raises.
|
|
1148
|
+
try:
|
|
1149
|
+
self.strip.paint(self._situation(comp, view))
|
|
1150
|
+
except Exception:
|
|
1151
|
+
logger.exception("the strip of %s", self.model_id)
|
|
1152
|
+
# The paint refreshes the bar; raised before it, the bar
|
|
1153
|
+
# still follows the queue taking or ending this test. The
|
|
1154
|
+
# bar may be what raised (a capability's `emphasis` that
|
|
1155
|
+
# is no dict): raised again here, it would stop the sync
|
|
1156
|
+
# after all.
|
|
1157
|
+
try:
|
|
1158
|
+
self.strip.actions.refresh()
|
|
1159
|
+
except Exception:
|
|
1160
|
+
logger.exception("the bar of %s", self.model_id)
|
|
1161
|
+
|
|
1162
|
+
def _situation(self, comp: Any, view: dict | None) -> Strip:
|
|
1163
|
+
"""The strip's inputs, read from the Session: the verdict as edited
|
|
1164
|
+
and — only with this test's edits pending — as saved."""
|
|
1165
|
+
session, model_id = self.shell.session, self.model_id
|
|
1166
|
+
project = session.project
|
|
1167
|
+
assert project is not None # `_paint` returned before without one
|
|
1168
|
+
edits = edit_counts(session.spec.pending)[model_id]
|
|
1169
|
+
saved_status: str | None = None
|
|
1170
|
+
if edits:
|
|
1171
|
+
# The saved spec is scored afresh here, and its scoring may raise
|
|
1172
|
+
# where the edited one does not (an edit that fixed it): the
|
|
1173
|
+
# strip says so, and the page goes on following the edit.
|
|
1174
|
+
try:
|
|
1175
|
+
saved = session.saved_comparison(model_id)
|
|
1176
|
+
except Exception:
|
|
1177
|
+
logger.exception("the saved verdict of %s", model_id)
|
|
1178
|
+
saved_status = SCORING_FAILED
|
|
1179
|
+
else:
|
|
1180
|
+
saved_status = classify(saved) if saved is not None else None
|
|
1181
|
+
loading = self.shell.load.in_progress
|
|
1182
|
+
has_result = self.run_state() != "none"
|
|
1183
|
+
statuses = [leaf_status(leaf) for leaf in leaves_of(view)]
|
|
1184
|
+
return strip_for(
|
|
1185
|
+
queue=queue_row(session, model_id),
|
|
1186
|
+
edits=edits,
|
|
1187
|
+
status=classify(comp) if comp is not None else None,
|
|
1188
|
+
saved_status=saved_status,
|
|
1189
|
+
has_result=has_result,
|
|
1190
|
+
loading=loading,
|
|
1191
|
+
# The load names the models whose scoring raised while it read
|
|
1192
|
+
# them; after it, a result with no comparison is one whose
|
|
1193
|
+
# scoring raised later (a finished run's rescore, an Accept's
|
|
1194
|
+
# reload).
|
|
1195
|
+
scoring_failed=comp is None
|
|
1196
|
+
and (
|
|
1197
|
+
model_id in self.shell.load.failed_models
|
|
1198
|
+
or (has_result and not loading)
|
|
1199
|
+
),
|
|
1200
|
+
simulate_only=comp is not None and comp.simulate_only,
|
|
1201
|
+
fails=statuses.count("fail"),
|
|
1202
|
+
criteria=len(statuses),
|
|
1203
|
+
advisory=statuses.count("warn"),
|
|
1204
|
+
# The merged TestModel carries the EFFECTIVE tree (`stage`
|
|
1205
|
+
# re-merges `project.tests`): a staged criterion counts.
|
|
1206
|
+
new_variables=new_variables(
|
|
1207
|
+
project.test(model_id),
|
|
1208
|
+
self.result() if has_result else None,
|
|
1209
|
+
project.config.diagnostic_variables,
|
|
1210
|
+
),
|
|
1211
|
+
refused=refusal(comp) if comp is not None else None,
|
|
1212
|
+
partial=session.partial(model_id),
|
|
1213
|
+
failure=comp.error_message if comp is not None else None,
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
def sync(self, force: bool = False) -> None:
|
|
1217
|
+
"""Follow the Session: ONE `tree_view` read, handed to the tree card
|
|
1218
|
+
and to every plot card. A moved shape rebuilds the cards; otherwise
|
|
1219
|
+
each card is painted in place and re-sent only when what it DRAWS
|
|
1220
|
+
moved — ``force`` re-sends every drawn plot whether it moved or not,
|
|
1221
|
+
which is what a run that started or ended needs (decision 10: it
|
|
1222
|
+
changes the trajectories, and no leaf's signature sees that)."""
|
|
1223
|
+
if not self._known():
|
|
1224
|
+
self._gone()
|
|
1225
|
+
return
|
|
1226
|
+
view = self._view()
|
|
1227
|
+
self._paint(view)
|
|
1228
|
+
self.live.mark(view)
|
|
1229
|
+
if self.stats is not None:
|
|
1230
|
+
self.stats.refresh(view)
|
|
1231
|
+
if self.tree is not None:
|
|
1232
|
+
self.tree.refresh(view)
|
|
1233
|
+
if self.column is None:
|
|
1234
|
+
return
|
|
1235
|
+
shape = self.shape(view)
|
|
1236
|
+
if shape != self._shape:
|
|
1237
|
+
with self.column:
|
|
1238
|
+
self.rebuild(view)
|
|
1239
|
+
# Recorded AFTER the rebuild ran: a raise inside it left the
|
|
1240
|
+
# page with the NEW shape already recorded and half the cards
|
|
1241
|
+
# built, and no later sync ever rebuilt it (the 3b review).
|
|
1242
|
+
self._shape = shape
|
|
1243
|
+
else:
|
|
1244
|
+
for card in self.cards:
|
|
1245
|
+
try:
|
|
1246
|
+
card.paint(view)
|
|
1247
|
+
if force:
|
|
1248
|
+
card.redraw(view)
|
|
1249
|
+
else:
|
|
1250
|
+
card.redraw_if_moved(view)
|
|
1251
|
+
except Exception: # one card's failure is not another's
|
|
1252
|
+
logger.exception("plot %s of %s", card.index, self.model_id)
|
|
1253
|
+
self._follow_row()
|
|
1254
|
+
if self.pending_reveal is not None:
|
|
1255
|
+
# After the cards: the row an add made exists only once a
|
|
1256
|
+
# rebuild drew it.
|
|
1257
|
+
(path, index), self.pending_reveal = self.pending_reveal, None
|
|
1258
|
+
self._reveal(path, index)
|
|
1259
|
+
if self.pending_moved is not None and self.tree is not None:
|
|
1260
|
+
# After the tree's rows: the moved row exists once they are
|
|
1261
|
+
# rebuilt.
|
|
1262
|
+
(path, text), self.pending_moved = self.pending_moved, None
|
|
1263
|
+
self.tree.say(path, text)
|
|
1264
|
+
# Last: the drawer's checks read the cards and the selection.
|
|
1265
|
+
self.rail.refresh()
|
|
1266
|
+
|
|
1267
|
+
def _gone(self) -> None:
|
|
1268
|
+
"""This page's test is no longer a test of the open project — an
|
|
1269
|
+
`OpenProject` outcome swapped the library, or the Session was
|
|
1270
|
+
closed. Every read below `sync` raises for it, so the page went on
|
|
1271
|
+
showing the last project's cards while every event logged a
|
|
1272
|
+
traceback. It says what a page BUILT for an unknown model says, and
|
|
1273
|
+
a later event that finds the test again rebuilds (`_shape`).
|
|
1274
|
+
|
|
1275
|
+
The sub-header and the strip go with the cards: they stayed live
|
|
1276
|
+
and stale — the last project's verdict, its baseline's name, and
|
|
1277
|
+
buttons that queued a run of a model this project does not have
|
|
1278
|
+
(the final review). `_paint` brings them back."""
|
|
1279
|
+
if self.gone or self.column is None:
|
|
1280
|
+
return
|
|
1281
|
+
self.gone = True
|
|
1282
|
+
self.drop_editor()
|
|
1283
|
+
self._close_row()
|
|
1284
|
+
self.pending_reveal = self.pending_moved = None
|
|
1285
|
+
self.move_revision = None
|
|
1286
|
+
if self.strip is not None:
|
|
1287
|
+
self.strip.forget()
|
|
1288
|
+
self.live.mark(None)
|
|
1289
|
+
if self.badge is not None:
|
|
1290
|
+
self.badge.set_text("")
|
|
1291
|
+
self.badge.style("background-color: transparent")
|
|
1292
|
+
if self.meta is not None:
|
|
1293
|
+
self.meta.set_text("")
|
|
1294
|
+
for card in self.cards:
|
|
1295
|
+
card.dispose()
|
|
1296
|
+
self.cards = []
|
|
1297
|
+
self.focused = None
|
|
1298
|
+
self._shape = None
|
|
1299
|
+
self.column.clear()
|
|
1300
|
+
if self.tree is not None:
|
|
1301
|
+
self.tree.forget()
|
|
1302
|
+
if self.stats is not None:
|
|
1303
|
+
self.stats.forget()
|
|
1304
|
+
if self.count is not None:
|
|
1305
|
+
self.count.set_text("")
|
|
1306
|
+
if self.focus_bar is not None:
|
|
1307
|
+
self.focus_bar.set_visibility(False)
|
|
1308
|
+
with self.column:
|
|
1309
|
+
ui.label(NOT_A_TEST.format(self.model_id)).classes("text-negative").mark(
|
|
1310
|
+
"gone"
|
|
1311
|
+
)
|
|
1312
|
+
# `sync` returns right after this, never reaching its own refresh:
|
|
1313
|
+
# an open Variables drawer kept the old test's rows, their checks
|
|
1314
|
+
# reading the Session for a model it no longer has. Its gone guard
|
|
1315
|
+
# (`VariablesDrawer._read`) empties it.
|
|
1316
|
+
self.rail.refresh()
|
|
1317
|
+
|
|
1318
|
+
def flush(self) -> None:
|
|
1319
|
+
"""Nothing coalesced: every event is handled as it comes."""
|
|
1320
|
+
|
|
1321
|
+
def handle(self, event: dict[str, Any]) -> None:
|
|
1322
|
+
kind = event["event"]
|
|
1323
|
+
if kind == "poll":
|
|
1324
|
+
# Once a second while the queue runs or starts: THIS test's
|
|
1325
|
+
# seconds in the strip — or how far its result file has got —
|
|
1326
|
+
# and the samples its drawn plots gained (D151).
|
|
1327
|
+
self._tick()
|
|
1328
|
+
return
|
|
1329
|
+
self.header.handle(event)
|
|
1330
|
+
self.rail.handle(event)
|
|
1331
|
+
known = self._known()
|
|
1332
|
+
if kind == "queue":
|
|
1333
|
+
# It names the tests whose place in the queue moved.
|
|
1334
|
+
syncs = self.model_id in event.get("models", ())
|
|
1335
|
+
else:
|
|
1336
|
+
# A notice with a message is a store write's: it reloaded
|
|
1337
|
+
# every result.
|
|
1338
|
+
syncs = (kind == "notice" and "message" in event) or (
|
|
1339
|
+
kind in ("results", "spec", "loading", "opened", "overlays")
|
|
1340
|
+
and self._mine(event)
|
|
1341
|
+
)
|
|
1342
|
+
if self.actions is not None and known and not syncs:
|
|
1343
|
+
# A sync's paint refreshes the bar; an event that does not sync
|
|
1344
|
+
# (another test's) still moves its availability.
|
|
1345
|
+
self.actions.refresh()
|
|
1346
|
+
if event["event"] == "spec":
|
|
1347
|
+
heard_spec(self.state, event)
|
|
1348
|
+
if self._mine(event):
|
|
1349
|
+
# Any edit of the test disarms (D149): a ⌖ names a row by
|
|
1350
|
+
# its index and a field by its keys, and an edit — a row
|
|
1351
|
+
# added by a click, a Discard, a Save, another tab's — can
|
|
1352
|
+
# put another row or another leaf under that name.
|
|
1353
|
+
self.disarm()
|
|
1354
|
+
if kind == "notice" and syncs:
|
|
1355
|
+
# A store write reloaded the trajectories under every plot and
|
|
1356
|
+
# no leaf's signature sees that (decision 10).
|
|
1357
|
+
self.sync(force=True)
|
|
1358
|
+
elif kind == "queue" and syncs:
|
|
1359
|
+
# A run that started took this test's trajectories with it and
|
|
1360
|
+
# one that ended brought new ones — no leaf's signature sees
|
|
1361
|
+
# either. The other states move the strip and re-send no figure.
|
|
1362
|
+
self.sync(force=event.get("state") in ("started", "ended"))
|
|
1363
|
+
elif syncs and event["event"] == "overlays":
|
|
1364
|
+
# The sibling-backend overlays arrived after the plots were
|
|
1365
|
+
# drawn without them (`Session._overlays_ready`); no leaf's
|
|
1366
|
+
# signature sees an overlay, so the drawn plots are re-sent.
|
|
1367
|
+
self.sync(force=True)
|
|
1368
|
+
elif syncs:
|
|
1369
|
+
if event["event"] == "spec" and (
|
|
1370
|
+
self.move_revision is None or event["revision"] > self.move_revision
|
|
1371
|
+
):
|
|
1372
|
+
# An edit after a move: the line has said its piece. The
|
|
1373
|
+
# move's own event, or one queued ahead of it, is not: the
|
|
1374
|
+
# line is said by this sync, or was by an event before it.
|
|
1375
|
+
self._unsay()
|
|
1376
|
+
self.sync()
|
|
1377
|
+
|
|
1378
|
+
def _tick(self) -> None:
|
|
1379
|
+
if self.strip is None or self.strip.situation not in (
|
|
1380
|
+
Situation.QUEUED, # "waiting · worker starting" comes and goes
|
|
1381
|
+
Situation.RUNNING,
|
|
1382
|
+
):
|
|
1383
|
+
return
|
|
1384
|
+
row = queue_row(self.shell.session, self.model_id)
|
|
1385
|
+
if row is not None and row.state in (ItemState.WAITING, ItemState.RUNNING):
|
|
1386
|
+
self.strip.say(strip_for(queue=row))
|
|
1387
|
+
self.live.tick()
|
|
1388
|
+
|
|
1389
|
+
def _mine(self, event: dict[str, Any]) -> bool:
|
|
1390
|
+
"""Whether an event says anything about THIS test. `results` and
|
|
1391
|
+
`spec` name the models they moved, a per-model `loading` step names
|
|
1392
|
+
the one it read; an event that names neither — the scan's steps,
|
|
1393
|
+
`opened` — is the whole project's and is this test's too. A page
|
|
1394
|
+
that synced on every model's event re-read the tree and repainted
|
|
1395
|
+
every row for a test it says nothing about.
|
|
1396
|
+
|
|
1397
|
+
An EMPTY ``models`` names no model and is the whole project's:
|
|
1398
|
+
`Session.close()` emits one, and it is how a page whose test has
|
|
1399
|
+
gone hears about it."""
|
|
1400
|
+
models = event.get("models")
|
|
1401
|
+
if models:
|
|
1402
|
+
return self.model_id in models
|
|
1403
|
+
model = event.get("model")
|
|
1404
|
+
return model is None or model == self.model_id
|