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,83 @@
|
|
|
1
|
+
"""The pending drawer's preview (the owner's ruling Q6 A): a test's entry
|
|
2
|
+
before and after its staged edits, as ``test_spec.json`` will hold it —
|
|
3
|
+
``json.dumps(entry, indent=2)``, the indentation Save writes — diffed line
|
|
4
|
+
by line with ``difflib``, and each line's variable names read by their
|
|
5
|
+
label with the result-file name muted beside it. Pure: stdlib only."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import difflib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Literal
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ..backends.types import VariableText
|
|
18
|
+
|
|
19
|
+
Kind = Literal["same", "add", "del", "gap"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Line:
|
|
24
|
+
kind: Kind
|
|
25
|
+
text: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
#: What stands between two groups of changes.
|
|
29
|
+
GAP = Line("gap", "…")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _dumped(entry: dict | None) -> list[str]:
|
|
33
|
+
return json.dumps(entry, indent=2).splitlines() if entry is not None else []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def entry_lines(
|
|
37
|
+
before: dict | None, after: dict | None, context: int = 3
|
|
38
|
+
) -> list[Line]:
|
|
39
|
+
"""The changed lines of ``before`` → ``after`` (None: no entry) with
|
|
40
|
+
``context`` equal lines around each group of changes, a :data:`GAP`
|
|
41
|
+
between two groups, a replaced run as its removed lines then its added
|
|
42
|
+
ones; ``[]`` when the two are equal. ``autojunk`` is off: in a long
|
|
43
|
+
tree ``}`` and ``"metric": "nrmse",`` are each more than 1% of the
|
|
44
|
+
lines, and junking them turned two changed tolerances into some two
|
|
45
|
+
hundred replaced lines."""
|
|
46
|
+
a, b = _dumped(before), _dumped(after)
|
|
47
|
+
matcher = difflib.SequenceMatcher(a=a, b=b, autojunk=False)
|
|
48
|
+
out: list[Line] = []
|
|
49
|
+
for n, group in enumerate(matcher.get_grouped_opcodes(context)):
|
|
50
|
+
if n:
|
|
51
|
+
out.append(GAP)
|
|
52
|
+
for tag, i1, i2, j1, j2 in group:
|
|
53
|
+
if tag == "equal":
|
|
54
|
+
out += [Line("same", t) for t in a[i1:i2]]
|
|
55
|
+
continue
|
|
56
|
+
out += [Line("del", t) for t in a[i1:i2]]
|
|
57
|
+
out += [Line("add", t) for t in b[j1:j2]]
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
#: A JSON string token, escapes included.
|
|
62
|
+
_STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def spans(text: str, texts: Mapping[str, VariableText]) -> list[tuple[str, bool]]:
|
|
66
|
+
"""``text`` as ``(fragment, muted)`` pieces: each JSON string that names
|
|
67
|
+
a variable of ``texts`` whose label is not its name reads as the label,
|
|
68
|
+
then the name muted, its quotes dropped; everything else is kept
|
|
69
|
+
verbatim, so a line with no such name joins back to itself."""
|
|
70
|
+
out: list[tuple[str, bool]] = []
|
|
71
|
+
pos = 0
|
|
72
|
+
for m in _STRING.finditer(text):
|
|
73
|
+
name = json.loads(m.group())
|
|
74
|
+
vt = texts.get(name)
|
|
75
|
+
if vt is None or vt.label == name:
|
|
76
|
+
continue
|
|
77
|
+
if m.start() > pos:
|
|
78
|
+
out.append((text[pos : m.start()], False))
|
|
79
|
+
out += [(vt.label, False), (" " + name, True)]
|
|
80
|
+
pos = m.end()
|
|
81
|
+
if pos < len(text):
|
|
82
|
+
out.append((text[pos:], False))
|
|
83
|
+
return out
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""A Suite row from the Session: the row model's ``TestRow``
|
|
2
|
+
(``reporting.rows``) built from what the Session holds — its comparison
|
|
3
|
+
through ``summary_fields`` (the numbers the report's sidecar carries,
|
|
4
|
+
computed the same way), the result's timing, the test's provenance, its
|
|
5
|
+
staged edits and its place in the simulation queue. In process: a sidecar is the
|
|
6
|
+
full-resolution context, one JSON parse per test. No UI framework here
|
|
7
|
+
(the Session tier)."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Collection, Iterable, Mapping
|
|
12
|
+
from dataclasses import replace
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from ..backends.pool import ItemState
|
|
16
|
+
from ..reporting.rows import TestRow, summary_fields
|
|
17
|
+
from ..reporting.status import (
|
|
18
|
+
QUEUED,
|
|
19
|
+
RUNNING,
|
|
20
|
+
SCORING_FAILED,
|
|
21
|
+
SIM_FAIL,
|
|
22
|
+
scored_status,
|
|
23
|
+
)
|
|
24
|
+
from .spec_document import edit_counts
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from .session import Session
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _edits(session: Session) -> Mapping[str, int]:
|
|
31
|
+
return edit_counts(session.spec.pending)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def suite_row(
|
|
35
|
+
session: Session,
|
|
36
|
+
model_id: str,
|
|
37
|
+
*,
|
|
38
|
+
failed: Collection[str] = (),
|
|
39
|
+
edits: Mapping[str, int] | None = None,
|
|
40
|
+
) -> TestRow:
|
|
41
|
+
"""One Suite row. A test the queue holds reads QUEUED or RUNNING,
|
|
42
|
+
whatever else is true of it — QUEUED from the click on, while the
|
|
43
|
+
click's read of the library is still out (`Session.checking`); then the
|
|
44
|
+
comparison — CANCELLED where it
|
|
45
|
+
has no result because its run in the queue was stopped (until that
|
|
46
|
+
row is cleared); then a scoring failure the open announced (``failed`` —
|
|
47
|
+
the shell's set, kept until the next open, so a model a later rescore
|
|
48
|
+
scored reads its verdict, not the failure); else the row waits for its
|
|
49
|
+
verdict. ``edits`` is the pending-edit count per model; ``suite_rows``
|
|
50
|
+
counts it once for every row."""
|
|
51
|
+
counts = _edits(session) if edits is None else edits
|
|
52
|
+
project = session.project
|
|
53
|
+
test = project.test(model_id) if project is not None else None
|
|
54
|
+
row = TestRow(
|
|
55
|
+
model_id=model_id,
|
|
56
|
+
field_sources=dict(test.field_sources) if test is not None else {},
|
|
57
|
+
edits=counts.get(model_id, 0),
|
|
58
|
+
)
|
|
59
|
+
state = session.queue_state(model_id)
|
|
60
|
+
comp = session.comparisons.get(model_id)
|
|
61
|
+
if state is ItemState.WAITING or model_id in session.checking:
|
|
62
|
+
return replace(row, status=QUEUED)
|
|
63
|
+
if state is ItemState.RUNNING:
|
|
64
|
+
return replace(row, status=RUNNING)
|
|
65
|
+
if comp is not None:
|
|
66
|
+
result = project.results.get(model_id) if project is not None else None
|
|
67
|
+
s = summary_fields(
|
|
68
|
+
comp,
|
|
69
|
+
stats=result.statistics if result is not None else None,
|
|
70
|
+
ref_id=f"ref_{comp.test_id}" if comp.test_id else None,
|
|
71
|
+
field_sources=row.field_sources,
|
|
72
|
+
)
|
|
73
|
+
return replace(
|
|
74
|
+
row,
|
|
75
|
+
status=scored_status(s["status"], cancelled=state is ItemState.CANCELLED),
|
|
76
|
+
ref_id=s["ref_id"],
|
|
77
|
+
worst_score=s["worst_nrmse"],
|
|
78
|
+
n_unscored=s["n_unscored"],
|
|
79
|
+
n_vars=s["n_vars"],
|
|
80
|
+
n_vars_passed=s["n_vars_passed"],
|
|
81
|
+
n_warnings=s["n_warnings"],
|
|
82
|
+
translation_wall=s["translation_wall"],
|
|
83
|
+
sim_wall=s["sim_wall"],
|
|
84
|
+
sim_cpu=s["sim_cpu"],
|
|
85
|
+
total_wall=s["total_wall"],
|
|
86
|
+
message=comp.error_message,
|
|
87
|
+
)
|
|
88
|
+
if model_id in failed:
|
|
89
|
+
return replace(row, status=SIM_FAIL, message=f"{SCORING_FAILED} (see the log)")
|
|
90
|
+
return replace(row, waiting=True)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def suite_rows(
|
|
94
|
+
session: Session, model_ids: Iterable[str], *, failed: Collection[str] = ()
|
|
95
|
+
) -> list[TestRow]:
|
|
96
|
+
"""The rows of ``model_ids``, in that order; the edits counted once."""
|
|
97
|
+
counts = _edits(session)
|
|
98
|
+
failed_set = set(failed)
|
|
99
|
+
return [suite_row(session, m, failed=failed_set, edits=counts) for m in model_ids]
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""Moving a subtree of a raw metrics tree (U4): the whole new tree, or the
|
|
2
|
+
one sentence saying why not. UI-free and pure: the Edit tree capability
|
|
3
|
+
stages the tree `plan_move` returns as ONE ``add /metrics`` op, and lists
|
|
4
|
+
every place a node can go from `move_destinations`.
|
|
5
|
+
|
|
6
|
+
The owner's rulings it implements: a move that empties a combinator
|
|
7
|
+
removes it, up the tree (D2); a ``k-of-n`` whose ``k`` exceeds its
|
|
8
|
+
criteria lowers ``k``, a weight leaves a ``weighted`` with its child and a
|
|
9
|
+
node moved into one gets weight 1 — each said in the move's notes (D3); a
|
|
10
|
+
move that puts under an ``or`` or a ``k-of-n`` a child that can never fail
|
|
11
|
+
is refused (D4, by D142's reason: it would pass its parent with nothing
|
|
12
|
+
else passing). Only a pairing the move MAKES is refused — a tree as it is
|
|
13
|
+
stays as it is.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import copy
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import TYPE_CHECKING, Literal
|
|
23
|
+
|
|
24
|
+
from ..comparison.validator import BaselineRoleLookup
|
|
25
|
+
from .tree_pointer import node_or_none
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from .spec_document import PatchOp
|
|
29
|
+
|
|
30
|
+
ROOT = "/metrics"
|
|
31
|
+
|
|
32
|
+
# V5's sentences, in the order they are checked (a move that changes
|
|
33
|
+
# nothing is None between INTO_ITSELF and INTO_WARN), the validator's last.
|
|
34
|
+
ROOT_REFUSAL = "The root holds the whole tree: move what is in it instead."
|
|
35
|
+
INTO_ITSELF = "A criterion group can't move into itself."
|
|
36
|
+
INTO_WARN = (
|
|
37
|
+
"A warning holds exactly one criterion or group: drop onto it to wrap "
|
|
38
|
+
"both in `and`."
|
|
39
|
+
)
|
|
40
|
+
WARN_UNDER = (
|
|
41
|
+
"A warning passes whatever it scores: under or / k-of-n it would pass its "
|
|
42
|
+
"parent with nothing else passing."
|
|
43
|
+
)
|
|
44
|
+
#: Not the tree's refusal but the drag's: one that started on rows rebuilt
|
|
45
|
+
#: before its drop (`TreeCard.dropped`), whose paths name another tree.
|
|
46
|
+
TREE_CHANGED = "The tree changed while you dragged: try again."
|
|
47
|
+
|
|
48
|
+
#: The place a node already is, as `move_destinations` lists it.
|
|
49
|
+
WHERE_IT_IS = "where it is"
|
|
50
|
+
#: A place before a combinator's child, as `move_destinations` says it:
|
|
51
|
+
#: this, a space, and that child's text (the menu cuts that in its middle).
|
|
52
|
+
BEFORE = "before"
|
|
53
|
+
|
|
54
|
+
#: The combinators a child that can never fail may not newly sit under.
|
|
55
|
+
_NO_FREE_PASS_UNDER = ("or", "k-of-n")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Dest:
|
|
60
|
+
"""Where a node goes. ``at``: into the combinator at ``path``, before
|
|
61
|
+
the child at ``index`` as the tree is NOW, before the move (``None``:
|
|
62
|
+
at its end). ``onto``: onto the node at ``path`` — a criterion is
|
|
63
|
+
wrapped with the moved node in an ``and`` (it first), a combinator
|
|
64
|
+
takes it at its end."""
|
|
65
|
+
|
|
66
|
+
kind: Literal["at", "onto"]
|
|
67
|
+
path: str
|
|
68
|
+
index: int | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Moved:
|
|
73
|
+
tree: dict # the whole new tree: what `add /metrics` stages
|
|
74
|
+
path: str # where the moved node is in it
|
|
75
|
+
notes: tuple[str, ...] # what else changed: "k 3 → 2", ...
|
|
76
|
+
#: Every node the move kept: its path before -> its path in ``tree``.
|
|
77
|
+
#: A move shifts its old and new siblings too; what is keyed by a
|
|
78
|
+
#: node's path (the tree card's folded groups, U5 R1) follows this.
|
|
79
|
+
paths: dict[str, str]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class MoveRefused(ValueError):
|
|
83
|
+
"""``str()`` is the one sentence the row shows (V5)."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def validator_for(role_of: BaselineRoleLookup) -> Callable[[dict], None]:
|
|
87
|
+
"""What a move's new tree must pass: the parser, then the D66 roles —
|
|
88
|
+
raising `MoveRefused` with the text `Session.stage`'s refusal shows (the
|
|
89
|
+
parser's message without its path; the validator's joined)."""
|
|
90
|
+
from ..comparison.tree_spec import MetricSpecError, parse_metric_tree
|
|
91
|
+
from ..comparison.validator import validate_tree
|
|
92
|
+
|
|
93
|
+
def validate(raw: dict) -> None:
|
|
94
|
+
try:
|
|
95
|
+
spec = parse_metric_tree(raw)
|
|
96
|
+
except MetricSpecError as e:
|
|
97
|
+
raise MoveRefused(e.message) from e
|
|
98
|
+
errors = validate_tree(spec, role_of)
|
|
99
|
+
if errors:
|
|
100
|
+
raise MoveRefused(" ".join(e.message for e in errors))
|
|
101
|
+
|
|
102
|
+
return validate
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parent(path: str) -> tuple[str, int]:
|
|
106
|
+
parent, index = path.rsplit("/children/", 1)
|
|
107
|
+
return parent, int(index)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _inside(path: str, src: str) -> bool:
|
|
111
|
+
return path == src or path.startswith(src + "/")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _index_of(kids: list, node: dict) -> int:
|
|
115
|
+
return next(i for i, k in enumerate(kids) if k is node)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _nodes(tree: dict) -> Iterator[dict]:
|
|
119
|
+
yield tree
|
|
120
|
+
for child in tree.get("children") or []:
|
|
121
|
+
yield from _nodes(child)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _paths(tree: dict, path: str = ROOT) -> Iterator[tuple[int, str]]:
|
|
125
|
+
"""``(id(node), path)`` for every node of ``tree``."""
|
|
126
|
+
yield id(tree), path
|
|
127
|
+
for i, child in enumerate(tree.get("children") or []):
|
|
128
|
+
yield from _paths(child, f"{path}/children/{i}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _path_of(tree: dict, node: dict, path: str = ROOT) -> str | None:
|
|
132
|
+
if tree is node:
|
|
133
|
+
return path
|
|
134
|
+
for i, child in enumerate(tree.get("children") or []):
|
|
135
|
+
found = _path_of(child, node, f"{path}/children/{i}")
|
|
136
|
+
if found is not None:
|
|
137
|
+
return found
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def always_passes(node: dict) -> bool:
|
|
142
|
+
"""Can this node never fail, whatever its criteria score? A ``warn``
|
|
143
|
+
cannot; a combinator can by its children (an ``and`` all, an ``or``
|
|
144
|
+
any, a ``k-of-n`` at least k of them); a ``weighted`` sum and a
|
|
145
|
+
criterion can always fail."""
|
|
146
|
+
kind = node.get("combinator")
|
|
147
|
+
kids = node.get("children") or []
|
|
148
|
+
if kind == "warn":
|
|
149
|
+
return True
|
|
150
|
+
if kind == "and":
|
|
151
|
+
return bool(kids) and all(always_passes(c) for c in kids)
|
|
152
|
+
if kind == "or":
|
|
153
|
+
return any(always_passes(c) for c in kids)
|
|
154
|
+
if kind == "k-of-n":
|
|
155
|
+
k = node.get("k")
|
|
156
|
+
return isinstance(k, int) and sum(always_passes(c) for c in kids) >= k
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _free_passes(tree: dict) -> Iterator[tuple[dict, dict]]:
|
|
161
|
+
"""Every ``(parent, child)`` of ``tree`` with an ``or`` / ``k-of-n``
|
|
162
|
+
parent and a child that can never fail."""
|
|
163
|
+
for node in _nodes(tree):
|
|
164
|
+
if node.get("combinator") in _NO_FREE_PASS_UNDER:
|
|
165
|
+
for child in node.get("children") or []:
|
|
166
|
+
if always_passes(child):
|
|
167
|
+
yield node, child
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _same_place(tree: dict, src: str, dest: Dest) -> bool:
|
|
171
|
+
"""Is ``dest`` where the node at ``src`` already is — before itself,
|
|
172
|
+
before the child after it, or the end when it is last?"""
|
|
173
|
+
parent, i = _parent(src)
|
|
174
|
+
if dest.path != parent:
|
|
175
|
+
return False
|
|
176
|
+
holder = node_or_none(tree, parent)
|
|
177
|
+
assert holder is not None # the parent of a node that exists
|
|
178
|
+
n = len(holder["children"])
|
|
179
|
+
index = dest.index if dest.kind == "at" else None
|
|
180
|
+
if index is None or index >= n:
|
|
181
|
+
return i == n - 1
|
|
182
|
+
return index in (i, i + 1)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def plan_move(
|
|
186
|
+
tree: dict,
|
|
187
|
+
src: str,
|
|
188
|
+
dest: Dest,
|
|
189
|
+
*,
|
|
190
|
+
validate: Callable[[dict], None] | None = None,
|
|
191
|
+
) -> Moved | None:
|
|
192
|
+
"""The whole new tree with the node at ``src`` moved to ``dest``, or
|
|
193
|
+
None when it would stay where it is. `MoveRefused` says why a move
|
|
194
|
+
cannot be made; ``validate`` (`validator_for`) is checked last."""
|
|
195
|
+
if src == ROOT:
|
|
196
|
+
raise MoveRefused(ROOT_REFUSAL)
|
|
197
|
+
if _inside(dest.path, src):
|
|
198
|
+
raise MoveRefused(INTO_ITSELF)
|
|
199
|
+
if node_or_none(tree, src) is None:
|
|
200
|
+
raise ValueError(f"{src} names no node")
|
|
201
|
+
target = node_or_none(tree, dest.path)
|
|
202
|
+
if target is None:
|
|
203
|
+
raise ValueError(f"{dest.path} names no node")
|
|
204
|
+
wrap = dest.kind == "onto" and "combinator" not in target
|
|
205
|
+
if not wrap and "combinator" not in target:
|
|
206
|
+
raise ValueError(f"{dest.path} is a criterion: nothing goes in it")
|
|
207
|
+
if not wrap and _same_place(tree, src, dest):
|
|
208
|
+
return None
|
|
209
|
+
if not wrap and target["combinator"] == "warn":
|
|
210
|
+
raise MoveRefused(INTO_WARN)
|
|
211
|
+
|
|
212
|
+
new = copy.deepcopy(tree)
|
|
213
|
+
# Each new node beside the one it was copied from, so a pairing D4
|
|
214
|
+
# finds is compared with the old tree by reference. The new node is
|
|
215
|
+
# held in the value too: a pruned dict freed mid-move would free its
|
|
216
|
+
# id() for the wrap's new `and`.
|
|
217
|
+
origin = {id(n): (n, o) for o, n in zip(_nodes(tree), _nodes(new))}
|
|
218
|
+
notes: list[str] = []
|
|
219
|
+
# Everything the move names, resolved BEFORE anything shifts: the dicts
|
|
220
|
+
# themselves are the identities (a removal renumbers a list, never
|
|
221
|
+
# replaces the dicts in it).
|
|
222
|
+
moved = node_or_none(new, src)
|
|
223
|
+
old_parent_path, old_index = _parent(src)
|
|
224
|
+
old_parent = node_or_none(new, old_parent_path)
|
|
225
|
+
into = node_or_none(new, dest.path)
|
|
226
|
+
assert moved is not None and old_parent is not None and into is not None
|
|
227
|
+
chain = [old_parent]
|
|
228
|
+
path = old_parent_path
|
|
229
|
+
while path != ROOT:
|
|
230
|
+
path = _parent(path)[0]
|
|
231
|
+
up = node_or_none(new, path)
|
|
232
|
+
assert up is not None
|
|
233
|
+
chain.append(up)
|
|
234
|
+
holder = node_or_none(new, _parent(dest.path)[0]) if wrap else None
|
|
235
|
+
if wrap:
|
|
236
|
+
assert holder is not None # a criterion at the root holds no src
|
|
237
|
+
anchor = None
|
|
238
|
+
else:
|
|
239
|
+
kids = into["children"]
|
|
240
|
+
index = dest.index if dest.kind == "at" else None
|
|
241
|
+
anchor = kids[index] if index is not None and index < len(kids) else None
|
|
242
|
+
|
|
243
|
+
# Out: the node leaves its parent; its weight goes with it — said,
|
|
244
|
+
# unless it only moves among the same weighted's children.
|
|
245
|
+
del old_parent["children"][old_index]
|
|
246
|
+
weight = _drop_weight(old_parent, old_index)
|
|
247
|
+
if weight is not None and into is not old_parent:
|
|
248
|
+
notes.append(_went(weight))
|
|
249
|
+
# In, BEFORE any repair: the destination then holds the node, so the
|
|
250
|
+
# prune below never removes it nor anything holding it, and a place
|
|
251
|
+
# before a combinator the prune removes is where that combinator was.
|
|
252
|
+
if holder is not None:
|
|
253
|
+
at = _index_of(holder["children"], into)
|
|
254
|
+
holder["children"][at] = {"combinator": "and", "children": [into, moved]}
|
|
255
|
+
else:
|
|
256
|
+
kids = into["children"]
|
|
257
|
+
at = len(kids) if anchor is None else _index_of(kids, anchor)
|
|
258
|
+
kids.insert(at, moved)
|
|
259
|
+
if into.get("combinator") == "weighted" and isinstance(
|
|
260
|
+
into.get("weights"), list
|
|
261
|
+
):
|
|
262
|
+
if into is old_parent and weight is not None:
|
|
263
|
+
# A reorder among one weighted's children: the weight
|
|
264
|
+
# travels with its child, and nothing else changed.
|
|
265
|
+
into["weights"].insert(at, weight)
|
|
266
|
+
else:
|
|
267
|
+
into["weights"].insert(at, 1)
|
|
268
|
+
notes.append("weight 1 given")
|
|
269
|
+
# The source's side, repaired upward: an emptied combinator goes (D2)
|
|
270
|
+
# — the root never empties, it holds the destination — then every
|
|
271
|
+
# k-of-n that lost a child lowers k to its criteria (D3).
|
|
272
|
+
for node, up in zip(chain, chain[1:]):
|
|
273
|
+
if node["children"]:
|
|
274
|
+
break
|
|
275
|
+
i = _index_of(up["children"], node)
|
|
276
|
+
del up["children"][i]
|
|
277
|
+
notes.append(f"the empty {node['combinator']} was removed")
|
|
278
|
+
pruned = _drop_weight(up, i)
|
|
279
|
+
if pruned is not None:
|
|
280
|
+
notes.append(_went(pruned))
|
|
281
|
+
for node in chain:
|
|
282
|
+
k, n = node.get("k"), len(node["children"])
|
|
283
|
+
if node.get("combinator") == "k-of-n" and n and isinstance(k, int) and k > n:
|
|
284
|
+
node["k"] = n
|
|
285
|
+
notes.append(f"k {k} → {n}")
|
|
286
|
+
|
|
287
|
+
if not wrap and new == tree:
|
|
288
|
+
# Two equal siblings trading places: the tree is as it was, and a
|
|
289
|
+
# pending edit that changes nothing is no edit.
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
# D4, by D142's reason: a child that can never fail under an `or` or
|
|
293
|
+
# a `k-of-n` is refused where the move made that pairing; one the old
|
|
294
|
+
# tree already had is left as it is.
|
|
295
|
+
for parent, child in _free_passes(new):
|
|
296
|
+
old_parent_of = origin.get(id(parent))
|
|
297
|
+
old_child_of = origin.get(id(child))
|
|
298
|
+
was = (
|
|
299
|
+
old_parent_of is not None
|
|
300
|
+
and old_child_of is not None
|
|
301
|
+
and old_parent_of[0] is parent
|
|
302
|
+
and old_child_of[0] is child
|
|
303
|
+
and any(c is old_child_of[1] for c in old_parent_of[1]["children"])
|
|
304
|
+
and always_passes(old_child_of[1])
|
|
305
|
+
)
|
|
306
|
+
if not was:
|
|
307
|
+
raise MoveRefused(WARN_UNDER)
|
|
308
|
+
if validate is not None:
|
|
309
|
+
validate(new)
|
|
310
|
+
where = _path_of(new, moved)
|
|
311
|
+
assert where is not None
|
|
312
|
+
# Each kept node by its copy (`origin`): a node the move removed (D2)
|
|
313
|
+
# is in no path of ``new``, the wrap's new `and` in no path of ``tree``.
|
|
314
|
+
before, after = dict(_paths(tree)), dict(_paths(new))
|
|
315
|
+
paths = {
|
|
316
|
+
before[id(old)]: after[key] for key, (_, old) in origin.items() if key in after
|
|
317
|
+
}
|
|
318
|
+
return Moved(tree=new, path=where, notes=tuple(notes), paths=paths)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
#: A JSON pointer that names a node of the tree, not one of its fields: the
|
|
322
|
+
#: root, or a child at an index or at the end (``-``).
|
|
323
|
+
_NODE_PATH = re.compile(rf"{ROOT}(/children/(\d+|-))*")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def path_map(tree: dict, ops: Sequence[PatchOp]) -> dict[str, str]:
|
|
327
|
+
"""Every node of ``tree`` that ``ops`` keep: its path before -> after
|
|
328
|
+
(U5 R1) — what is keyed by a node's path follows it. Not by comparing
|
|
329
|
+
the trees on both sides of a stage: the Session stages a copy, so no
|
|
330
|
+
node is the same object on both. By the ops' own semantics, over
|
|
331
|
+
``tree`` as the edit read it: an add at an index shifts the siblings
|
|
332
|
+
after it, a remove drops its subtree and shifts them back, a replace (an
|
|
333
|
+
add at the root) puts its value where the node was — and a node of
|
|
334
|
+
``tree`` that an op's value CARRIES (a wrap's child, an unwrap's
|
|
335
|
+
children, the root leaf an add pairs with the new one) is that node,
|
|
336
|
+
found by identity: `EditTree.act` builds its values from the very tree
|
|
337
|
+
it read. A copy is a new node — a move's whole new tree is one, which is
|
|
338
|
+
why a move reports `Moved.paths`. An op on a field (``/k``,
|
|
339
|
+
``/combinator``) moves no node."""
|
|
340
|
+
was = {key: path for key, path in _paths(tree)}
|
|
341
|
+
|
|
342
|
+
def shadow(node: dict) -> dict:
|
|
343
|
+
return {
|
|
344
|
+
"was": was.get(id(node)),
|
|
345
|
+
"children": [shadow(c) for c in node.get("children") or []],
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
root = shadow(tree)
|
|
349
|
+
for op in ops:
|
|
350
|
+
if not _NODE_PATH.fullmatch(op.path):
|
|
351
|
+
continue
|
|
352
|
+
if op.path == ROOT:
|
|
353
|
+
if op.op != "remove":
|
|
354
|
+
root = shadow(op.value)
|
|
355
|
+
continue
|
|
356
|
+
parent_path, index = op.path.rsplit("/children/", 1)
|
|
357
|
+
parent = root
|
|
358
|
+
for i in parent_path[len(ROOT) :].split("/children/")[1:]:
|
|
359
|
+
parent = parent["children"][int(i)]
|
|
360
|
+
kids = parent["children"]
|
|
361
|
+
at = len(kids) if index == "-" else int(index)
|
|
362
|
+
if op.op == "remove":
|
|
363
|
+
del kids[at]
|
|
364
|
+
elif op.op == "add":
|
|
365
|
+
kids.insert(at, shadow(op.value))
|
|
366
|
+
elif op.op == "replace":
|
|
367
|
+
kids[at] = shadow(op.value)
|
|
368
|
+
else:
|
|
369
|
+
raise ValueError(f"{op.op!r} is no op an edit of the tree stages")
|
|
370
|
+
out: dict[str, str] = {}
|
|
371
|
+
|
|
372
|
+
def walk(node: dict, path: str) -> None:
|
|
373
|
+
if node["was"] is not None:
|
|
374
|
+
out[node["was"]] = path
|
|
375
|
+
for i, child in enumerate(node["children"]):
|
|
376
|
+
walk(child, f"{path}/children/{i}")
|
|
377
|
+
|
|
378
|
+
walk(root, ROOT)
|
|
379
|
+
return out
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _drop_weight(node: dict, index: int) -> float | None:
|
|
383
|
+
"""A ``weighted`` node's weight at ``index``, taken out with its child."""
|
|
384
|
+
if node.get("combinator") != "weighted":
|
|
385
|
+
return None
|
|
386
|
+
weights = node.get("weights")
|
|
387
|
+
if not isinstance(weights, list) or index >= len(weights):
|
|
388
|
+
return None
|
|
389
|
+
return weights.pop(index)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _went(weight: object) -> str:
|
|
393
|
+
"""The note for a weight that left its ``weighted`` with its child."""
|
|
394
|
+
text = format(weight, "g") if isinstance(weight, (int, float)) else str(weight)
|
|
395
|
+
return f"weight {text} went with it"
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _view(node: dict, names: Mapping[str, str]) -> dict:
|
|
399
|
+
"""The raw node as `tree_text` reads a `tree_view` node: a criterion's
|
|
400
|
+
label is what the page reads for its variable."""
|
|
401
|
+
if "combinator" not in node:
|
|
402
|
+
return {"kind": "leaf", "label": names.get(node.get("variable", "")), **node}
|
|
403
|
+
return {
|
|
404
|
+
**node,
|
|
405
|
+
"kind": "combinator",
|
|
406
|
+
"children": [_view(c, names) for c in node.get("children") or []],
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def move_destinations(
|
|
411
|
+
tree: dict,
|
|
412
|
+
src: str,
|
|
413
|
+
*,
|
|
414
|
+
validate: Callable[[dict], None] | None = None,
|
|
415
|
+
names: Mapping[str, str] | None = None,
|
|
416
|
+
) -> list[tuple[str, str, Dest, str | None]]:
|
|
417
|
+
"""Every place the node at ``src`` can go, in tree order, as ``(text,
|
|
418
|
+
where, dest, reason)``: a combinator's (``text``) positions between its
|
|
419
|
+
children's rows — "before" the row there, the drag's own word, and "at
|
|
420
|
+
the end" — and a criterion's "wrap both in and". ``names`` is each
|
|
421
|
+
variable's text as the page reads it (`SessionView.labels`). Where two
|
|
422
|
+
siblings read the same, "before" either would not say which: their
|
|
423
|
+
places are numbered instead ("at 2").
|
|
424
|
+
|
|
425
|
+
The places beside the node read as ONE row, "where it is" (reason None;
|
|
426
|
+
the caller greys it: `plan_move` gives None). The node's own row is
|
|
427
|
+
not listed; the places in its own subtree are, with their refusal."""
|
|
428
|
+
from .tree_text import node_text
|
|
429
|
+
|
|
430
|
+
names = names or {}
|
|
431
|
+
rows: list[tuple[str, str, Dest, str | None]] = []
|
|
432
|
+
here = False
|
|
433
|
+
|
|
434
|
+
def one(text: str, where: str, dest: Dest) -> None:
|
|
435
|
+
nonlocal here
|
|
436
|
+
try:
|
|
437
|
+
planned = plan_move(tree, src, dest, validate=validate)
|
|
438
|
+
except MoveRefused as e:
|
|
439
|
+
rows.append((text, where, dest, str(e)))
|
|
440
|
+
return
|
|
441
|
+
if planned is None:
|
|
442
|
+
if not here:
|
|
443
|
+
here = True
|
|
444
|
+
rows.append((text, WHERE_IT_IS, dest, None))
|
|
445
|
+
return
|
|
446
|
+
rows.append((text, where, dest, None))
|
|
447
|
+
|
|
448
|
+
def walk(node: dict, path: str) -> None:
|
|
449
|
+
text = node_text(_view(node, names))
|
|
450
|
+
if path == ROOT:
|
|
451
|
+
text += " (root)"
|
|
452
|
+
if "combinator" not in node:
|
|
453
|
+
if path != src:
|
|
454
|
+
one(text, "wrap both in and", Dest("onto", path))
|
|
455
|
+
return
|
|
456
|
+
kids = node.get("children") or []
|
|
457
|
+
befores = [node_text(_view(child, names)) for child in kids]
|
|
458
|
+
for i, child in enumerate(kids):
|
|
459
|
+
before = befores[i]
|
|
460
|
+
unique = befores.count(before) == 1
|
|
461
|
+
where = f"{BEFORE} {before}" if unique else f"at {i + 1}"
|
|
462
|
+
one(text, where, Dest("at", path, i))
|
|
463
|
+
walk(child, f"{path}/children/{i}")
|
|
464
|
+
one(text, "at the end", Dest("at", path, None))
|
|
465
|
+
|
|
466
|
+
walk(tree, ROOT)
|
|
467
|
+
return rows
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
#: A drag's zones on a row (D1 as amended by the owner): its top half is
|
|
471
|
+
#: before it, its bottom half onto it — no "after": every pixel line means
|
|
472
|
+
#: one place. To put a node last at an outer level, drop onto that outer
|
|
473
|
+
#: combinator. The root has only "onto": nothing sits beside it.
|
|
474
|
+
ZONES = ("before", "onto")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def zone_dest(path: str, zone: str) -> Dest | None:
|
|
478
|
+
"""The destination a drop in ``zone`` of ``path``'s row means: before
|
|
479
|
+
it among its siblings, or onto it (a criterion: wrap both in ``and``;
|
|
480
|
+
a combinator: its end). None for a zone the row has not got."""
|
|
481
|
+
if zone == "onto":
|
|
482
|
+
return Dest("onto", path)
|
|
483
|
+
if zone == "before" and path != ROOT:
|
|
484
|
+
parent, index = _parent(path)
|
|
485
|
+
return Dest("at", parent, index)
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def zone_map(
|
|
490
|
+
tree: dict, src: str, *, validate: Callable[[dict], None] | None = None
|
|
491
|
+
) -> dict[str, dict[str, str | None]]:
|
|
492
|
+
"""``{row path: {zone: reason}}`` over every row of ``tree`` for a drag
|
|
493
|
+
of the node at ``src``: ``None`` allowed, ``""`` where it already is,
|
|
494
|
+
else the sentence that refuses it. The page asks once per drag and
|
|
495
|
+
draws every zone from the answer, with no round trip while dragging:
|
|
496
|
+
it grows with the square of the tree (134 nodes ~0.2 s)."""
|
|
497
|
+
out: dict[str, dict[str, str | None]] = {}
|
|
498
|
+
|
|
499
|
+
def walk(node: dict, path: str) -> None:
|
|
500
|
+
zones: dict[str, str | None] = {}
|
|
501
|
+
for zone in ZONES:
|
|
502
|
+
dest = zone_dest(path, zone)
|
|
503
|
+
if dest is None:
|
|
504
|
+
continue
|
|
505
|
+
try:
|
|
506
|
+
planned = plan_move(tree, src, dest, validate=validate)
|
|
507
|
+
except MoveRefused as e:
|
|
508
|
+
zones[zone] = str(e)
|
|
509
|
+
continue
|
|
510
|
+
zones[zone] = "" if planned is None else None
|
|
511
|
+
out[path] = zones
|
|
512
|
+
for i, child in enumerate(node.get("children") or []):
|
|
513
|
+
walk(child, f"{path}/children/{i}")
|
|
514
|
+
|
|
515
|
+
walk(tree, ROOT)
|
|
516
|
+
return out
|