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,783 @@
|
|
|
1
|
+
"""The header both pages share (DESIGN.md "Header"; brief §3.3): "DSTF", the
|
|
2
|
+
library and the backend's name on the left; the queue indicator in the
|
|
3
|
+
centre; the pending count with Save and Discard on the right, and a ⋮ menu
|
|
4
|
+
at the end holding the rarely used actions — Export, Change backend…, Open
|
|
5
|
+
in another backend…, the dashboard, the other theme and Stop server, each with its full
|
|
6
|
+
label. At 900 px the row cut off "dashboard" and "Stop server", and the
|
|
7
|
+
owner chose one layout at every width (3c, Q10 A) over a row that changes
|
|
8
|
+
with it. The backend was a select in the row until a rerun running, an
|
|
9
|
+
edit pending and a caption naming a new entry put Save on four lines at
|
|
10
|
+
900 px: the owner moved it into the menu (Q13 A), and the row names the
|
|
11
|
+
backend as text. A button may take two lines at 900 px and one at 1280.
|
|
12
|
+
Every button's label says its object, effect and count; a disabled one
|
|
13
|
+
carries the sentence that says why (DESIGN.md "Disabled states explain
|
|
14
|
+
themselves"). Off-white with a hairline below, never an accent bar.
|
|
15
|
+
|
|
16
|
+
A page may ask for a second row of its own (``below``): it is drawn INSIDE
|
|
17
|
+
this fixed header, which is what makes it sticky without offset arithmetic
|
|
18
|
+
(decision 8). The Test page's header is the one. The Test page also asks
|
|
19
|
+
for a third row (``strip``), drawn inside it for the same reason: its status
|
|
20
|
+
strip, the test's situation and its next action."""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
from collections.abc import Callable, Sequence
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
|
|
29
|
+
from nicegui import ui
|
|
30
|
+
|
|
31
|
+
from ...backends.progress import read_status
|
|
32
|
+
from ...reporting.dashboard_render import format_run_metadata, short_tool_version
|
|
33
|
+
from ...reporting.status import SCORING_FAILED, classify
|
|
34
|
+
from ...storage.ref_index import _atomic_write_text
|
|
35
|
+
from ..capabilities import plural
|
|
36
|
+
from ..errors import user_text
|
|
37
|
+
from ..queue_rows import stop_reason
|
|
38
|
+
from ..session import StaleSpecError, UnscorableSaveError
|
|
39
|
+
from ..situation import SEP, verdict_phrase
|
|
40
|
+
from ..spec_document import PatchOp, edit_count, edit_counts
|
|
41
|
+
from ..spec_preview import entry_lines, spans
|
|
42
|
+
from .actions import log_unless_users
|
|
43
|
+
from .queue_panel import QueueIndicator
|
|
44
|
+
from .widgets import Action
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from .shell import Shell
|
|
48
|
+
from .tab_theme import TabTheme
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_caption(pending: Sequence[PatchOp], loaded: dict, file_name: str) -> str:
|
|
54
|
+
"""The Save button's caption (brief §3.3: object, effect, count). A
|
|
55
|
+
pending edit to a model with no entry in the file as LOADED creates
|
|
56
|
+
that entry on Save, and the caption says so (the owner's ruling,
|
|
57
|
+
2026-09-25): a layout staged for a test tracked only by its `.mo`
|
|
58
|
+
annotation writes that test's first entry. Counted over ``loaded``,
|
|
59
|
+
never the effective document, where the pending op has already made
|
|
60
|
+
the entry."""
|
|
61
|
+
from ...discovery.spec_update import spec_entry
|
|
62
|
+
|
|
63
|
+
models = list(dict.fromkeys(op.model_id for op in pending))
|
|
64
|
+
new = sum(1 for m in models if spec_entry(loaded, m) is None)
|
|
65
|
+
tests = plural(len(models), "test")
|
|
66
|
+
what = f"{tests}, {plural(new, 'new entry', 'new entries')}" if new else tests
|
|
67
|
+
return f"Save {plural(edit_count(pending), 'spec edit')} ({what}) to {file_name}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def backend_caption(
|
|
71
|
+
backend_name: str | None, os_name: str | None, metadata: dict | None
|
|
72
|
+
) -> tuple[str, str]:
|
|
73
|
+
"""The header's backend text and its tooltip. The text names the tool's
|
|
74
|
+
version the last run recorded, short ("Dymola 2026x · linux"), when the
|
|
75
|
+
configured backend is the one that ran; a backend that has not run here
|
|
76
|
+
reads its name, its version unknown until it runs. The tooltip says
|
|
77
|
+
whose the version is: the LAST RUN's, in full (``metadata`` is
|
|
78
|
+
status.json's)."""
|
|
79
|
+
meta = format_run_metadata(metadata)
|
|
80
|
+
name = backend_name
|
|
81
|
+
if meta is None:
|
|
82
|
+
tip = "No run here has recorded the tool's version yet"
|
|
83
|
+
else:
|
|
84
|
+
ran = meta["tool_version"] or meta["label"]
|
|
85
|
+
tip = "Last run: " + " · ".join(
|
|
86
|
+
t
|
|
87
|
+
for t in (
|
|
88
|
+
ran,
|
|
89
|
+
meta["msl"] and f"MSL {meta['msl']}",
|
|
90
|
+
meta["os"],
|
|
91
|
+
meta["generated_str"],
|
|
92
|
+
)
|
|
93
|
+
if t
|
|
94
|
+
)
|
|
95
|
+
if metadata and metadata.get("backend_name") == backend_name:
|
|
96
|
+
name = short_tool_version(meta["tool_version"], backend_name)
|
|
97
|
+
else:
|
|
98
|
+
tip = (
|
|
99
|
+
f"{backend_name} has not run here: its version is known once "
|
|
100
|
+
f"it runs. {tip}"
|
|
101
|
+
)
|
|
102
|
+
return " · ".join(t for t in (name, os_name) if t), tip
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
#: The role every item of the header's menu carries (see `Header`).
|
|
106
|
+
MENUITEM = 'role="menuitem"'
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Header:
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
shell: Shell,
|
|
113
|
+
theme: TabTheme,
|
|
114
|
+
below: Callable[[], None] | None = None,
|
|
115
|
+
strip: Callable[[], None] | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
self.shell = shell
|
|
118
|
+
self.theme = theme
|
|
119
|
+
self.drawer = PendingDrawer(shell, save=self._save, discard=self._discard)
|
|
120
|
+
self.dialog = ui.dialog() # the header's dialogs, one element rebuilt per use
|
|
121
|
+
# The backend's own dialog, built once: `refresh` keeps its select's
|
|
122
|
+
# options and value as the row's text, open or closed.
|
|
123
|
+
with ui.dialog() as self.backend_dialog, ui.card():
|
|
124
|
+
ui.label("Change backend").classes("text-h6")
|
|
125
|
+
ui.label(
|
|
126
|
+
"Reopens this project on the chosen backend, in this tab; its "
|
|
127
|
+
"results and baselines are that backend's."
|
|
128
|
+
).classes("text-xs text-secondary")
|
|
129
|
+
self.backend = (
|
|
130
|
+
ui.select([], label="backend", on_change=self._change_backend)
|
|
131
|
+
.props("dense outlined options-dense")
|
|
132
|
+
.mark("backend")
|
|
133
|
+
)
|
|
134
|
+
ui.button("Close", on_click=self.backend_dialog.close).props(
|
|
135
|
+
"flat dense no-caps"
|
|
136
|
+
)
|
|
137
|
+
with ui.header().classes("dstf-header column no-wrap gap-0") as bar:
|
|
138
|
+
row = ui.row().classes("dstf-header-row items-center no-wrap gap-3 w-full")
|
|
139
|
+
with row:
|
|
140
|
+
ui.label("DSTF").classes("dstf-wordmark").mark("wordmark")
|
|
141
|
+
self.library = ui.label().classes("text-secondary").mark("project")
|
|
142
|
+
self.backend_name = (
|
|
143
|
+
ui.label().classes("text-secondary").mark("backend-name")
|
|
144
|
+
)
|
|
145
|
+
with self.backend_name:
|
|
146
|
+
self.backend_tip = ui.tooltip("").mark("backend-name:tip")
|
|
147
|
+
ui.space()
|
|
148
|
+
self.queue = QueueIndicator(shell)
|
|
149
|
+
ui.space()
|
|
150
|
+
self.pending = (
|
|
151
|
+
ui.button(on_click=self.drawer.toggle)
|
|
152
|
+
.props("flat dense no-caps no-wrap")
|
|
153
|
+
.mark("pending")
|
|
154
|
+
)
|
|
155
|
+
self.save = Action(
|
|
156
|
+
self._save,
|
|
157
|
+
props="dense no-caps color=primary",
|
|
158
|
+
marker="save",
|
|
159
|
+
filled=True,
|
|
160
|
+
)
|
|
161
|
+
self.discard = Action(
|
|
162
|
+
self._discard, props="flat dense no-caps", marker="discard", why=False
|
|
163
|
+
)
|
|
164
|
+
# The ⋮ button says it opens a menu and whether it is open; the
|
|
165
|
+
# items are `menuitem`s of that menu (Quasar draws a `q-item` as
|
|
166
|
+
# a `listitem`, and the attribute given here is what the browser
|
|
167
|
+
# keeps).
|
|
168
|
+
self.more = (
|
|
169
|
+
ui.button(icon="more_vert")
|
|
170
|
+
.props(
|
|
171
|
+
'flat dense round aria-label="More actions" aria-haspopup="menu" '
|
|
172
|
+
'aria-expanded="false"'
|
|
173
|
+
)
|
|
174
|
+
.classes("dstf-header-menu")
|
|
175
|
+
.mark("header-menu")
|
|
176
|
+
)
|
|
177
|
+
with self.more, ui.menu() as menu:
|
|
178
|
+
menu.on_value_change(self._menu_toggled)
|
|
179
|
+
# A `ui.menu_item` has no text of its own to set: Export's
|
|
180
|
+
# caption is a label in it, and the reason it is disabled a
|
|
181
|
+
# second line under that.
|
|
182
|
+
with (
|
|
183
|
+
ui.menu_item(on_click=self._export)
|
|
184
|
+
.props(MENUITEM)
|
|
185
|
+
.mark("export") as export
|
|
186
|
+
):
|
|
187
|
+
self.export = export
|
|
188
|
+
with ui.item_section():
|
|
189
|
+
self.export_text = ui.item_label()
|
|
190
|
+
self.export_why = (
|
|
191
|
+
ui.item_label().props("caption").mark("export:why")
|
|
192
|
+
)
|
|
193
|
+
# Changed rarely, and refused while anything is pending or
|
|
194
|
+
# running: in the row, its select and why-sentence pushed
|
|
195
|
+
# Save onto four lines at 900 px (the owner's ruling, Q13 A).
|
|
196
|
+
with (
|
|
197
|
+
ui.menu_item(on_click=self.backend_dialog.open)
|
|
198
|
+
.props(MENUITEM)
|
|
199
|
+
.mark("change-backend") as change
|
|
200
|
+
):
|
|
201
|
+
self.change = change
|
|
202
|
+
with ui.item_section():
|
|
203
|
+
ui.item_label("Change backend…")
|
|
204
|
+
self.backend_why = (
|
|
205
|
+
ui.item_label().props("caption").mark("backend:why")
|
|
206
|
+
)
|
|
207
|
+
self.other = (
|
|
208
|
+
ui.menu_item("Open in another backend…", on_click=self._open_other)
|
|
209
|
+
.props(MENUITEM)
|
|
210
|
+
.mark("other-backend")
|
|
211
|
+
)
|
|
212
|
+
# A link the browser follows into a new tab: a `window.open`
|
|
213
|
+
# sent from the server (`ui.navigate.to(..., new_tab=True)`)
|
|
214
|
+
# is what a popup blocker may refuse.
|
|
215
|
+
ui.menu_item("dashboard").props(
|
|
216
|
+
f'href="/dashboard/dashboard.html" target="_blank" {MENUITEM}'
|
|
217
|
+
).mark("dashboard")
|
|
218
|
+
# The session switch (D148): the item names the theme it
|
|
219
|
+
# goes TO, for this tab; the dashboard and the report pages
|
|
220
|
+
# keep the project's.
|
|
221
|
+
with (
|
|
222
|
+
ui.menu_item(on_click=self._switch_theme)
|
|
223
|
+
.props(MENUITEM)
|
|
224
|
+
.mark("theme")
|
|
225
|
+
):
|
|
226
|
+
self.theme_text = ui.item_label(self._theme_offer())
|
|
227
|
+
self.stop = (
|
|
228
|
+
ui.menu_item("Stop server", on_click=self._stop)
|
|
229
|
+
.props(MENUITEM)
|
|
230
|
+
.classes("text-negative")
|
|
231
|
+
.mark("stop")
|
|
232
|
+
)
|
|
233
|
+
if below is not None:
|
|
234
|
+
# A second row INSIDE the fixed header (decision 8): sticky with
|
|
235
|
+
# no offset arithmetic and no dependence on the first row's
|
|
236
|
+
# height. The Test page's own header is what `below` draws.
|
|
237
|
+
with bar:
|
|
238
|
+
sub = ui.row().classes("dstf-subheader items-center gap-3 w-full")
|
|
239
|
+
with sub.mark("subheader"):
|
|
240
|
+
below()
|
|
241
|
+
if strip is not None:
|
|
242
|
+
# A third row, under the second: the Test page's status strip,
|
|
243
|
+
# sticky for the same reason. It draws its own row.
|
|
244
|
+
with bar:
|
|
245
|
+
strip()
|
|
246
|
+
self.refresh()
|
|
247
|
+
|
|
248
|
+
def handle(self, event: dict[str, Any]) -> None:
|
|
249
|
+
self.refresh()
|
|
250
|
+
|
|
251
|
+
def _menu_toggled(self, e: Any) -> None:
|
|
252
|
+
self.more.props(f'aria-expanded="{"true" if e.value else "false"}"')
|
|
253
|
+
|
|
254
|
+
def _theme_offer(self) -> str:
|
|
255
|
+
return f"{self.theme.other.capitalize()} theme"
|
|
256
|
+
|
|
257
|
+
def _switch_theme(self) -> None:
|
|
258
|
+
self.theme.switch()
|
|
259
|
+
self.theme_text.set_text(self._theme_offer())
|
|
260
|
+
|
|
261
|
+
def refresh(self) -> None:
|
|
262
|
+
shell, s = self.shell, self.shell.session
|
|
263
|
+
project, load = s.project, shell.load
|
|
264
|
+
if project is not None:
|
|
265
|
+
self.library.set_text(project.config.library_name or "")
|
|
266
|
+
config = project.config
|
|
267
|
+
snapshot = read_status(config.work_dir) if config.work_dir else None
|
|
268
|
+
text, tip = backend_caption(
|
|
269
|
+
config.backend_name,
|
|
270
|
+
config.os_name,
|
|
271
|
+
(snapshot or {}).get("metadata"),
|
|
272
|
+
)
|
|
273
|
+
self.backend_name.set_text(text)
|
|
274
|
+
self.backend_tip.set_text(tip)
|
|
275
|
+
if not load.in_progress:
|
|
276
|
+
# Not while a reopen runs: the select shows the user's choice
|
|
277
|
+
# until the Session commits it.
|
|
278
|
+
current = project.config.backend_name
|
|
279
|
+
options = sorted(
|
|
280
|
+
set(project.config.backends) | ({current} if current else set())
|
|
281
|
+
)
|
|
282
|
+
self.backend.set_options(options, value=current)
|
|
283
|
+
else:
|
|
284
|
+
self.library.set_text("opening…" if load.in_progress else "no project")
|
|
285
|
+
self.backend_name.set_text("")
|
|
286
|
+
self.backend_tip.set_text("No project is open")
|
|
287
|
+
why = self._backend_why()
|
|
288
|
+
self.change.set_enabled(why is None)
|
|
289
|
+
self.backend.set_enabled(why is None)
|
|
290
|
+
self.backend_why.set_text(why or "")
|
|
291
|
+
self.backend_why.set_visibility(why is not None)
|
|
292
|
+
self.other.set_enabled(project is not None)
|
|
293
|
+
self.queue.refresh()
|
|
294
|
+
n = edit_count(s.spec.pending)
|
|
295
|
+
self.pending.set_text(plural(n, "pending edit"))
|
|
296
|
+
reason = "no pending edits" if n == 0 else shell.busy_reason()
|
|
297
|
+
self.save.set(
|
|
298
|
+
save_caption(s.spec.pending, s.spec.loaded, s.spec.path.name), reason
|
|
299
|
+
)
|
|
300
|
+
self.discard.set(f"Discard {plural(n, 'edit')}", reason)
|
|
301
|
+
# The drawer's own Save and Discard, gated as these are — here, not
|
|
302
|
+
# in its build: that runs only when what the preview shows moved,
|
|
303
|
+
# and the load moves nothing it shows.
|
|
304
|
+
self.drawer.save.set("Save these", reason)
|
|
305
|
+
self.drawer.discard.set("Discard these", reason)
|
|
306
|
+
self.export_text.set_text(f"Export {plural(n, 'edit')} to spec_patch.json")
|
|
307
|
+
self.export.set_enabled(n > 0)
|
|
308
|
+
self.export_why.set_text("no pending edits" if n == 0 else "")
|
|
309
|
+
self.export_why.set_visibility(n == 0)
|
|
310
|
+
self.drawer.refresh()
|
|
311
|
+
|
|
312
|
+
# -- the backend -------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
def _backend_why(self) -> str | None:
|
|
315
|
+
"""Why the backend cannot change now (brief §3.6: refused while edits
|
|
316
|
+
are pending or the queue holds a test), or None."""
|
|
317
|
+
s = self.shell.session
|
|
318
|
+
if s.project is None:
|
|
319
|
+
return "no project open"
|
|
320
|
+
busy = self.shell.busy_reason() or stop_reason(s)
|
|
321
|
+
if busy is not None:
|
|
322
|
+
return f"backend change waits: {busy}"
|
|
323
|
+
n = edit_count(s.spec.pending)
|
|
324
|
+
if n:
|
|
325
|
+
return (
|
|
326
|
+
f"backend change waits: save or discard {plural(n, 'pending edit')} "
|
|
327
|
+
"first"
|
|
328
|
+
)
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
def _change_backend(self, e: Any) -> None:
|
|
332
|
+
"""Reopen on the chosen backend (brief §3.6), on the shell's open
|
|
333
|
+
thread; the page follows the loading events as on startup."""
|
|
334
|
+
project = self.shell.session.project
|
|
335
|
+
if project is None or e.value in (None, project.config.backend_name):
|
|
336
|
+
return
|
|
337
|
+
why = self._backend_why()
|
|
338
|
+
if why is not None:
|
|
339
|
+
ui.notify(why, type="warning")
|
|
340
|
+
self.backend.set_value(project.config.backend_name)
|
|
341
|
+
return
|
|
342
|
+
self.backend_dialog.close()
|
|
343
|
+
self.shell.open_async(e.value)
|
|
344
|
+
self.refresh()
|
|
345
|
+
|
|
346
|
+
def _open_other(self) -> None:
|
|
347
|
+
"""A second tool process on another backend (brief §3.6): its own
|
|
348
|
+
port and browser tab; one project and backend per process."""
|
|
349
|
+
project = self.shell.session.project
|
|
350
|
+
if project is None:
|
|
351
|
+
return
|
|
352
|
+
current = project.config.backend_name # `str | None`, as in `refresh`
|
|
353
|
+
options = sorted(
|
|
354
|
+
set(project.config.backends) | ({current} if current else set())
|
|
355
|
+
)
|
|
356
|
+
self.dialog.clear()
|
|
357
|
+
with self.dialog, ui.card():
|
|
358
|
+
ui.label("Open this project in another backend").classes("text-h6")
|
|
359
|
+
ui.label(
|
|
360
|
+
"A second dstf author process on its own port, opening its own browser "
|
|
361
|
+
"tab; one project and backend per process. Stop server here ends it too."
|
|
362
|
+
).classes("text-xs text-secondary")
|
|
363
|
+
choice = (
|
|
364
|
+
ui.select(options, value=current, label="backend")
|
|
365
|
+
.props("dense outlined")
|
|
366
|
+
.mark("other-choice")
|
|
367
|
+
)
|
|
368
|
+
link = ui.link("", "/", new_tab=True).mark("other-link")
|
|
369
|
+
link.set_visibility(False)
|
|
370
|
+
|
|
371
|
+
def start() -> None:
|
|
372
|
+
url = self.shell.spawn(str(choice.value))
|
|
373
|
+
link.set_text(f"open {url}")
|
|
374
|
+
link.props(f'href="{url}"')
|
|
375
|
+
link.set_visibility(True)
|
|
376
|
+
|
|
377
|
+
with ui.row():
|
|
378
|
+
ui.button("Start", on_click=start).props(
|
|
379
|
+
"unelevated dense no-caps color=primary"
|
|
380
|
+
).mark("start-other")
|
|
381
|
+
ui.button("Close", on_click=self.dialog.close).props(
|
|
382
|
+
"flat dense no-caps"
|
|
383
|
+
)
|
|
384
|
+
self.dialog.open()
|
|
385
|
+
|
|
386
|
+
# -- the edits -----------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
def stale(self, e: StaleSpecError) -> None:
|
|
389
|
+
"""The stale-file refusal's dialog: the spec changed on disk since
|
|
390
|
+
it was loaded, so nothing that writes from the loaded one went on.
|
|
391
|
+
Save's, and — handed here by the page's action bars — Accept's:
|
|
392
|
+
one dialog, one way out (Reload, or keep the edits and look).
|
|
393
|
+
|
|
394
|
+
It says what was NOT done (the Accept's own dialog has closed for
|
|
395
|
+
this one, and nothing else on the page says it), and its Reload
|
|
396
|
+
button says what it costs before it is clicked: the pending edits
|
|
397
|
+
it drops, counted as the header counts them. With none pending
|
|
398
|
+
there is nothing to keep, and the other button is Close."""
|
|
399
|
+
pending = edit_count(self.shell.session.spec.pending)
|
|
400
|
+
self.dialog.clear()
|
|
401
|
+
with self.dialog, ui.card():
|
|
402
|
+
|
|
403
|
+
def reload() -> None:
|
|
404
|
+
try:
|
|
405
|
+
self.shell.session.reload()
|
|
406
|
+
except Exception as e: # said in the dialog's place, never raised
|
|
407
|
+
log_unless_users(e, "reload")
|
|
408
|
+
ui.notify(user_text(e), type="warning")
|
|
409
|
+
return
|
|
410
|
+
self.dialog.close()
|
|
411
|
+
|
|
412
|
+
ui.label(f"{e.path} changed on disk since it was loaded.").mark(
|
|
413
|
+
"stale-spec"
|
|
414
|
+
)
|
|
415
|
+
ui.label(e.not_done).mark("stale-not-done")
|
|
416
|
+
with ui.row():
|
|
417
|
+
ui.button(
|
|
418
|
+
f"Reload and discard {plural(pending, 'pending edit')}"
|
|
419
|
+
if pending
|
|
420
|
+
else "Reload",
|
|
421
|
+
on_click=reload,
|
|
422
|
+
).props("outline dense no-caps").mark("stale-reload")
|
|
423
|
+
if pending:
|
|
424
|
+
ui.button("Keep my edits", on_click=self.dialog.close).props(
|
|
425
|
+
"flat dense no-caps"
|
|
426
|
+
).mark("stale-keep")
|
|
427
|
+
else:
|
|
428
|
+
ui.button("Close", on_click=self.dialog.close).props(
|
|
429
|
+
"flat dense no-caps"
|
|
430
|
+
).mark("stale-close")
|
|
431
|
+
self.dialog.open()
|
|
432
|
+
|
|
433
|
+
def _save(self) -> None:
|
|
434
|
+
try:
|
|
435
|
+
self.shell.session.save()
|
|
436
|
+
ui.notify("saved")
|
|
437
|
+
except StaleSpecError as e:
|
|
438
|
+
self.stale(e)
|
|
439
|
+
except UnscorableSaveError as e:
|
|
440
|
+
# The other refusal of a Save: said where the stale one is, in
|
|
441
|
+
# the dialog — a sentence to act on, not a toast that goes.
|
|
442
|
+
self.dialog.clear()
|
|
443
|
+
with self.dialog, ui.card():
|
|
444
|
+
ui.label(str(e)).mark("save-refused")
|
|
445
|
+
ui.button("Close", on_click=self.dialog.close).props(
|
|
446
|
+
"flat dense no-caps"
|
|
447
|
+
)
|
|
448
|
+
self.dialog.open()
|
|
449
|
+
except Exception as e:
|
|
450
|
+
ui.notify(f"save failed: {e}", type="negative")
|
|
451
|
+
|
|
452
|
+
def _discard(self) -> None:
|
|
453
|
+
try:
|
|
454
|
+
self.shell.session.discard()
|
|
455
|
+
except Exception as e:
|
|
456
|
+
log_unless_users(e, "discard")
|
|
457
|
+
ui.notify(user_text(e), type="warning")
|
|
458
|
+
|
|
459
|
+
def _export(self) -> None:
|
|
460
|
+
project = self.shell.session.project
|
|
461
|
+
assert project is not None # the Action is disabled without one
|
|
462
|
+
work_dir = project.config.work_dir
|
|
463
|
+
assert work_dir is not None # resolved in Config.__post_init__
|
|
464
|
+
path = work_dir / "spec_patch.json"
|
|
465
|
+
_atomic_write_text(
|
|
466
|
+
path, json.dumps(self.shell.session.export(), indent=2) + "\n"
|
|
467
|
+
)
|
|
468
|
+
ui.notify(f"wrote {path}")
|
|
469
|
+
|
|
470
|
+
# -- stop ----------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
def _stop(self) -> None:
|
|
473
|
+
"""Stop server (brief §3.3, §3.6): at once with nothing to lose; else
|
|
474
|
+
a confirm naming what would be lost — unsaved edits, and the tests
|
|
475
|
+
the queue still holds, which stopping removes or cancels."""
|
|
476
|
+
reasons = self.shell.stop_reasons()
|
|
477
|
+
if not reasons:
|
|
478
|
+
self.shell.stop()
|
|
479
|
+
return
|
|
480
|
+
self.dialog.clear()
|
|
481
|
+
with self.dialog, ui.card():
|
|
482
|
+
ui.label("Stop the server?").classes("text-h6")
|
|
483
|
+
for reason in reasons:
|
|
484
|
+
ui.label(reason).mark("stop-reason")
|
|
485
|
+
with ui.row():
|
|
486
|
+
ui.button("Stop anyway", on_click=self.shell.stop).props(
|
|
487
|
+
"outline dense no-caps color=negative"
|
|
488
|
+
).mark("stop-anyway")
|
|
489
|
+
ui.button("Keep running", on_click=self.dialog.close).props(
|
|
490
|
+
"flat dense no-caps"
|
|
491
|
+
)
|
|
492
|
+
self.dialog.open()
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
#: The event Esc sends to the drawer's column, and the one listener that
|
|
496
|
+
#: sends it — installed once per page load, when the drawer first opens. A
|
|
497
|
+
#: keydown on the document, not on the drawer: focus is where the click that
|
|
498
|
+
#: opened it left it (the header's button, the strip's). Sent to an element
|
|
499
|
+
#: the drawer built with its listener on, never through `ui.on`: that adds a
|
|
500
|
+
#: listener to the page's layout, which on the Suite page is already drawn
|
|
501
|
+
#: when the header is built, and NiceGUI says so in the browser's console.
|
|
502
|
+
#: An Esc while a dialog or a menu is open is theirs (Quasar closes it): the
|
|
503
|
+
#: drawer under it stays. One already closing is not open: it stays in the
|
|
504
|
+
#: page for its leave animation (~300 ms (Quasar's default
|
|
505
|
+
#: transition-duration); Quasar's `-leave-` classes, on a dialog's
|
|
506
|
+
#: `.q-dialog__inner`), and a guard that counted it swallowed a second Esc
|
|
507
|
+
#: 40 ms after the first.
|
|
508
|
+
ESC_EVENT = "dstf_pending_esc"
|
|
509
|
+
ESC_JS = f"""
|
|
510
|
+
window.__dstfPendingEsc ??= (document.addEventListener("keydown", (e) => {{
|
|
511
|
+
if (e.key !== "Escape") return;
|
|
512
|
+
const open = [...document.querySelectorAll(".q-dialog, .q-menu")].some(
|
|
513
|
+
(el) => !/-leave-/.test((el.querySelector(".q-dialog__inner") ?? el).className));
|
|
514
|
+
if (open) return;
|
|
515
|
+
document.querySelector(".dstf-pending-body")
|
|
516
|
+
?.dispatchEvent(new CustomEvent("{ESC_EVENT}"));
|
|
517
|
+
}}), true);
|
|
518
|
+
"""
|
|
519
|
+
|
|
520
|
+
#: A diff line's prefix, as `git diff` and the board print it.
|
|
521
|
+
_PREFIX = {"same": " ", "add": "+ ", "del": "- ", "gap": " "}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def materialized_note(leaves: int, new_entry: bool) -> str:
|
|
525
|
+
"""The note over a test whose edits begin with its implicit tree written
|
|
526
|
+
out (`PatchOp.materializes`): the diff is against that tree, and Save
|
|
527
|
+
writes it too."""
|
|
528
|
+
they = "it scores" if leaves == 1 else "they score"
|
|
529
|
+
note = (
|
|
530
|
+
f"Save also writes this test's {plural(leaves, 'criterion', 'criteria')} "
|
|
531
|
+
f"out as a tree; {they} the same."
|
|
532
|
+
)
|
|
533
|
+
return f"A new entry for this test. {note}" if new_entry else note
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _leaves(node: Any) -> int:
|
|
537
|
+
"""How many criteria a raw tree node holds."""
|
|
538
|
+
if isinstance(node, dict) and isinstance(node.get("children"), list):
|
|
539
|
+
return sum(_leaves(child) for child in node["children"])
|
|
540
|
+
return 1
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class PendingDrawer:
|
|
544
|
+
"""The pending-edit drawer is the preview (the owner's ruling Q6 A): per
|
|
545
|
+
test, in staging order, its entry before and after its staged edits as
|
|
546
|
+
`test_spec.json` will hold it — added lines green, removed lines red,
|
|
547
|
+
each variable read by its label with its result-file name muted beside
|
|
548
|
+
it — with how many edits it has and what they do to its verdict. The
|
|
549
|
+
`before` is the tree as scoring sees it (`SpecDocument.preview_base`):
|
|
550
|
+
a flat entry's first per-leaf edit writes its implicit tree out, and
|
|
551
|
+
the diff shows the edit, with a note that Save writes the tree too.
|
|
552
|
+
"Save these" and "Discard these" are the header's own Save and Discard
|
|
553
|
+
(the stale-file dialog included), greyed when they are and for the same
|
|
554
|
+
reason. Toggled by the header's pending button; `show(model)` opens it
|
|
555
|
+
on one test's section (the strip's Preview and save). Its close button
|
|
556
|
+
and Esc close it: under 1024 px it is an overlay over the header and
|
|
557
|
+
the strip, and a click on the page behind was the only other way out.
|
|
558
|
+
|
|
559
|
+
Built only while open: the header refreshes on every event (the 1 s
|
|
560
|
+
poll while the queue runs included), and a drawer rebuilt there scored
|
|
561
|
+
every edited test's saved spec each time and put every diff back at its
|
|
562
|
+
left edge. Opening it builds it; while open it builds again only when what
|
|
563
|
+
it shows moved (`_key`). Closing it forgets what it drew."""
|
|
564
|
+
|
|
565
|
+
WIDTH = 520
|
|
566
|
+
|
|
567
|
+
def __init__(
|
|
568
|
+
self,
|
|
569
|
+
shell: Shell,
|
|
570
|
+
save: Callable[[], None],
|
|
571
|
+
discard: Callable[[], None],
|
|
572
|
+
) -> None:
|
|
573
|
+
self.shell = shell
|
|
574
|
+
self.element = (
|
|
575
|
+
ui.right_drawer(value=False)
|
|
576
|
+
.props(f"bordered width={self.WIDTH}")
|
|
577
|
+
.classes("dstf-pending-drawer")
|
|
578
|
+
)
|
|
579
|
+
with (
|
|
580
|
+
self.element,
|
|
581
|
+
ui.column().classes("dstf-pending-body w-full min-w-0 gap-3") as body,
|
|
582
|
+
):
|
|
583
|
+
with ui.row().classes("w-full items-center no-wrap gap-2"):
|
|
584
|
+
self.summary = ui.label().classes("text-bold").mark("drawer-summary")
|
|
585
|
+
ui.space()
|
|
586
|
+
ui.button(icon="close", on_click=self.close).props(
|
|
587
|
+
'flat dense round aria-label="Close"'
|
|
588
|
+
).mark("drawer-close")
|
|
589
|
+
self.body = ui.column().classes("w-full min-w-0 gap-4")
|
|
590
|
+
with ui.row().classes("gap-2") as self.buttons:
|
|
591
|
+
# Captions and reasons are the header's (`Header.refresh`).
|
|
592
|
+
self.save = Action(
|
|
593
|
+
save,
|
|
594
|
+
props="dense no-caps color=primary",
|
|
595
|
+
marker="drawer-save",
|
|
596
|
+
filled=True,
|
|
597
|
+
)
|
|
598
|
+
self.discard = Action(
|
|
599
|
+
discard,
|
|
600
|
+
props="flat dense no-caps",
|
|
601
|
+
marker="drawer-discard",
|
|
602
|
+
why=False,
|
|
603
|
+
)
|
|
604
|
+
self.sections: dict[str, ui.element] = {}
|
|
605
|
+
#: What the drawn preview was built from (`_key`); None: not built.
|
|
606
|
+
self.drawn: object = None
|
|
607
|
+
#: The comparisons whose ids `drawn` records, held while it does: a
|
|
608
|
+
#: freed one's id can be reused by the next, and the key would miss
|
|
609
|
+
#: the verdict it brought.
|
|
610
|
+
self.held: list[Any] = []
|
|
611
|
+
self.element.on_value_change(self._toggled)
|
|
612
|
+
body.on(ESC_EVENT, lambda _: self.close())
|
|
613
|
+
|
|
614
|
+
def _toggled(self, e: Any) -> None:
|
|
615
|
+
if e.value:
|
|
616
|
+
ui.run_javascript(ESC_JS)
|
|
617
|
+
self.refresh()
|
|
618
|
+
else:
|
|
619
|
+
# Closed, it follows nothing: its next open builds again.
|
|
620
|
+
self.drawn = None
|
|
621
|
+
self.held = []
|
|
622
|
+
|
|
623
|
+
def toggle(self) -> None:
|
|
624
|
+
self.element.toggle()
|
|
625
|
+
|
|
626
|
+
def close(self) -> None:
|
|
627
|
+
self.element.set_value(False)
|
|
628
|
+
|
|
629
|
+
def show(self, model_id: str) -> None:
|
|
630
|
+
"""Open the drawer with ``model_id``'s section scrolled into view
|
|
631
|
+
(the strip's Preview and save)."""
|
|
632
|
+
self.element.set_value(True)
|
|
633
|
+
self.refresh()
|
|
634
|
+
section = self.sections.get(model_id)
|
|
635
|
+
if section is not None:
|
|
636
|
+
ui.run_javascript(
|
|
637
|
+
f'document.getElementById("{section.html_id}")'
|
|
638
|
+
'?.scrollIntoView({block: "start"})'
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
def _key(self) -> tuple[object, list[Any]]:
|
|
642
|
+
"""What the preview shows moves with: the pending ops, the file, and
|
|
643
|
+
each edited test's verdict (a rescore replaces the comparison) —
|
|
644
|
+
and the comparisons it names by id, to hold."""
|
|
645
|
+
session = self.shell.session
|
|
646
|
+
spec = session.spec
|
|
647
|
+
edited = list(dict.fromkeys(op.model_id for op in spec.pending))
|
|
648
|
+
comps = [session.comparisons.get(m) for m in edited]
|
|
649
|
+
key = (
|
|
650
|
+
tuple(spec.pending),
|
|
651
|
+
spec.content_hash,
|
|
652
|
+
tuple((m, id(c)) for m, c in zip(edited, comps, strict=True)),
|
|
653
|
+
)
|
|
654
|
+
return key, comps
|
|
655
|
+
|
|
656
|
+
def refresh(self) -> None:
|
|
657
|
+
"""Build the preview again — only while the drawer is open, and only
|
|
658
|
+
when what it shows moved."""
|
|
659
|
+
if not self.element.value:
|
|
660
|
+
return
|
|
661
|
+
key, comps = self._key()
|
|
662
|
+
if key == self.drawn:
|
|
663
|
+
return
|
|
664
|
+
self.drawn = None # a raise below leaves it to be built again
|
|
665
|
+
self._build()
|
|
666
|
+
self.drawn, self.held = key, comps
|
|
667
|
+
|
|
668
|
+
def _saved_statuses(self, models: list[str]) -> dict[str, str | None]:
|
|
669
|
+
"""The classifier's word for each test as SAVED, the saved spec
|
|
670
|
+
merged once for all of them. A saved spec whose scoring raises for
|
|
671
|
+
one test says ``scoring failed`` for that test alone (as the strip
|
|
672
|
+
does), the others scored one by one."""
|
|
673
|
+
session = self.shell.session
|
|
674
|
+
|
|
675
|
+
def word(comp: Any) -> str | None:
|
|
676
|
+
return classify(comp) if comp is not None else None
|
|
677
|
+
|
|
678
|
+
try:
|
|
679
|
+
saved = session.saved_comparisons(models)
|
|
680
|
+
except Exception:
|
|
681
|
+
logger.exception("the saved verdicts of the edited tests")
|
|
682
|
+
else:
|
|
683
|
+
return {m: word(saved[m]) for m in models}
|
|
684
|
+
out: dict[str, str | None] = {}
|
|
685
|
+
for m in models:
|
|
686
|
+
try:
|
|
687
|
+
out[m] = word(session.saved_comparison(m))
|
|
688
|
+
except Exception:
|
|
689
|
+
out[m] = SCORING_FAILED
|
|
690
|
+
return out
|
|
691
|
+
|
|
692
|
+
def _build(self) -> None:
|
|
693
|
+
session = self.shell.session
|
|
694
|
+
spec = session.spec
|
|
695
|
+
groups = spec.grouped()
|
|
696
|
+
self.body.clear()
|
|
697
|
+
self.sections = {}
|
|
698
|
+
self.summary.set_text(
|
|
699
|
+
f"{plural(edit_count(spec.pending), 'pending edit')} · "
|
|
700
|
+
f"{plural(len(groups), 'test')}"
|
|
701
|
+
)
|
|
702
|
+
self.summary.set_visibility(bool(groups))
|
|
703
|
+
self.buttons.set_visibility(bool(groups))
|
|
704
|
+
with self.body:
|
|
705
|
+
if not groups:
|
|
706
|
+
ui.label("nothing staged").classes("text-secondary")
|
|
707
|
+
return
|
|
708
|
+
saved = self._saved_statuses([m for m, _ in groups])
|
|
709
|
+
counts = edit_counts(spec.pending)
|
|
710
|
+
for model_id, _ops in groups:
|
|
711
|
+
comp = session.comparisons.get(model_id)
|
|
712
|
+
with (
|
|
713
|
+
ui.column()
|
|
714
|
+
.classes("dstf-drawer-section w-full min-w-0 gap-1")
|
|
715
|
+
.mark(f"drawer-test:{model_id}") as section
|
|
716
|
+
):
|
|
717
|
+
self.sections[model_id] = section
|
|
718
|
+
self._section(model_id, counts[model_id], comp, saved[model_id])
|
|
719
|
+
|
|
720
|
+
def _section(
|
|
721
|
+
self, model_id: str, n: int, comp: Any, saved_status: str | None
|
|
722
|
+
) -> None:
|
|
723
|
+
session = self.shell.session
|
|
724
|
+
spec = session.spec
|
|
725
|
+
ui.label(model_id).classes("text-bold dstf-mono").mark("drawer-model")
|
|
726
|
+
# One sentence, as the strip's: a plain block of inline labels, its
|
|
727
|
+
# separator a label of its own, whose spaces stand between the words
|
|
728
|
+
# as any inline text's do. Not a flex row: its items are blocks, and
|
|
729
|
+
# a copy or a screen reader reads one per line.
|
|
730
|
+
with ui.element("div").classes("dstf-drawer-status text-xs text-secondary"):
|
|
731
|
+
ui.label(plural(n, "edit")).classes("inline").mark("drawer-count")
|
|
732
|
+
ui.label(SEP).classes("inline")
|
|
733
|
+
ui.label(
|
|
734
|
+
verdict_phrase(
|
|
735
|
+
classify(comp) if comp is not None else None, saved_status
|
|
736
|
+
)
|
|
737
|
+
).classes("inline").mark("drawer-verdict")
|
|
738
|
+
trees = [
|
|
739
|
+
op.value
|
|
740
|
+
for op in spec.pending
|
|
741
|
+
if op.model_id == model_id and op.materializes
|
|
742
|
+
]
|
|
743
|
+
if trees:
|
|
744
|
+
ui.label(
|
|
745
|
+
materialized_note(
|
|
746
|
+
sum(_leaves(t) for t in trees),
|
|
747
|
+
spec.saved_entry(model_id) is None,
|
|
748
|
+
)
|
|
749
|
+
).classes("text-xs text-secondary").mark("drawer-note")
|
|
750
|
+
# What each gesture on a folded criteria row did, in words (D152,
|
|
751
|
+
# `PatchOp.notes`): its op is the whole tree, and twenty changed
|
|
752
|
+
# lines of the diff below do not say "one tolerance, twenty times".
|
|
753
|
+
said = [
|
|
754
|
+
text
|
|
755
|
+
for op in spec.pending
|
|
756
|
+
if op.model_id == model_id
|
|
757
|
+
for _, text in op.notes
|
|
758
|
+
]
|
|
759
|
+
for text in dict.fromkeys(said):
|
|
760
|
+
ui.label(text).classes("text-xs dstf-mono").mark("drawer-gesture")
|
|
761
|
+
# Never an empty box: edits that cancel out are not here at all, and
|
|
762
|
+
# neither is a tree written out and not edited (a `rescore`, a value
|
|
763
|
+
# typed and cleared on a flat test) — an entry put back as saved has
|
|
764
|
+
# no pending op, and the tree it already scores by is none of the
|
|
765
|
+
# ops it has (`Session.stage`).
|
|
766
|
+
lines = entry_lines(spec.preview_base(model_id), spec.entry(model_id))
|
|
767
|
+
texts = session.variable_texts(model_id)
|
|
768
|
+
with (
|
|
769
|
+
ui.element("div").classes("dstf-diff").mark("drawer-diff"),
|
|
770
|
+
ui.element("div").classes("dstf-diff-lines"),
|
|
771
|
+
):
|
|
772
|
+
for line in lines:
|
|
773
|
+
with (
|
|
774
|
+
ui.element("div")
|
|
775
|
+
.classes(f"dstf-diff-line dstf-diff-{line.kind}")
|
|
776
|
+
.mark(f"diff-{line.kind}")
|
|
777
|
+
):
|
|
778
|
+
ui.label(_PREFIX[line.kind]).classes("dstf-diff-text")
|
|
779
|
+
for text, muted in spans(line.text, texts):
|
|
780
|
+
if muted:
|
|
781
|
+
ui.label(text).classes("dstf-diff-muted").mark("diff-muted")
|
|
782
|
+
else:
|
|
783
|
+
ui.label(text).classes("dstf-diff-text")
|