Skip to content

Commit 569056c

Browse files
sp00ktoberclaude
andcommitted
feat(studio): the dock reads behaviour.params and refused; a window test that runs
The one-path copy that found the refused paths goes: the refused blocks come from `behaviour.refused`, and a finding names the blocks that share a reason. A move's automatic column clears the grid's own spacing. Spread out keeps the order of rows and columns, so no path's priority changes (tested on the Zinogre); NodeGraphQt's auto_layout_nodes does not, it put the flinch path first. The dock opens in a window offscreen, over the real Behaviour dock spec, and the display-only assertions are gone. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 27510eb commit 569056c

7 files changed

Lines changed: 85 additions & 58 deletions

File tree

‎apps/studio/src/mhfu_studio/monster/behaviour.py‎

Lines changed: 8 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,12 @@
1111

1212
from __future__ import annotations
1313

14-
import dataclasses
1514
import typing
1615
from collections.abc import Callable, Mapping, Sequence
1716
from typing import Any, NamedTuple, Protocol
1817

1918
from mhfu_port import behaviour, sequence
20-
from mhfu_port.behaviour import KINDS, Behaviour, Block, MoveNode, Param, Path
19+
from mhfu_port.behaviour import KINDS, Block, MoveNode, Param, Path
2120
from mhfu_port.manifest import Manifest, ManifestError
2221

2322
from mhfu_studio.monster.document import PortDocument
@@ -28,8 +27,6 @@
2827
IN, OUT = "in", "out"
2928
PLAY, WHILE, THEN = "play", "while playing", "then"
3029
"""A move node's ports: the input, then the two outputs."""
31-
BLOCK_W, MOVE_W = 280, 380
32-
"""How far right of a block, or a move's node, the next column starts: a node's width and a gap."""
3330
ROLE_TITLES = {
3431
"event": "Events",
3532
"state": "State",
@@ -146,8 +143,8 @@ def spots(m: Manifest) -> dict[str, Point]:
146143
"""Where each move sits: its node's place, else a column right of everything placed, in
147144
sequence order so a chain sits together."""
148145
b = m.behaviour
149-
clear = [blk.at[0] + BLOCK_W for blk in b.blocks.values()]
150-
clear += [n.at[0] + MOVE_W for n in b.moves.values()]
146+
clear = [blk.at[0] + behaviour.COLUMN for blk in b.blocks.values()]
147+
clear += [n.at[0] + behaviour.LEFT for n in b.moves.values()]
151148
x = max(clear, default=float(behaviour.LEFT))
152149
order = [n for steps in sequence.groups(m).values() for n in steps]
153150
return {
@@ -163,8 +160,8 @@ class Reading(NamedTuple):
163160
priority: dict[str, list[int]]
164161
"""Block id -> the 1-based places in `paths` of the paths that play from it."""
165162
loose: list[str]
166-
refused: dict[Path, str]
167-
"""The paths `compile` refuses, and why."""
163+
refused: dict[str, str]
164+
"""Block id -> why, for the blocks on a path `compile` refuses."""
168165
capped: str | None
169166
"""Set when the paths outnumber the rules the seam holds."""
170167

@@ -174,7 +171,7 @@ def read(m: Manifest) -> Reading:
174171
priority: dict[str, list[int]] = {}
175172
for k, p in enumerate(ps, 1):
176173
priority.setdefault(p.blocks[-1], []).append(k)
177-
return Reading(ps, priority, behaviour.loose(m), refusals(m, ps), capped(m, ps))
174+
return Reading(ps, priority, behaviour.loose(m), behaviour.refused(m), capped(m, ps))
178175

179176

180177
def capped(m: Manifest, ps: Sequence[Path] | None = None) -> str | None:
@@ -183,31 +180,6 @@ def capped(m: Manifest, ps: Sequence[Path] | None = None) -> str | None:
183180
return f"{n} paths, the seam holds {behaviour.SEAM_RULES}" if n > behaviour.SEAM_RULES else None
184181

185182

186-
def refusals(m: Manifest, ps: Sequence[Path] | None = None) -> dict[Path, str]:
187-
"""Why `compile` refuses each path it does, in its words: each path alone."""
188-
out: dict[Path, str] = {}
189-
for p in behaviour.paths(m) if ps is None else ps:
190-
try:
191-
behaviour.compile(_alone(m, p))
192-
except ManifestError as e:
193-
out[p] = str(e).partition(": ")[2]
194-
return out
195-
196-
197-
def _alone(m: Manifest, p: Path) -> Manifest:
198-
"""`m` with the graph cut down to the path `p`."""
199-
b = m.behaviour
200-
blocks = {}
201-
for k, i in enumerate(p.blocks):
202-
last = k == len(p.blocks) - 1
203-
blk = b.blocks[i]
204-
blocks[i] = dataclasses.replace(
205-
blk, next=[] if last else [p.blocks[k + 1]], play=[p.play] if last else []
206-
)
207-
moves = {} if p.during is None else {p.during: MoveNode(b.moves[p.during].at, [p.blocks[0]])}
208-
return dataclasses.replace(m, behaviour=Behaviour(blocks, moves))
209-
210-
211183
def part_options(m: Manifest, optional: bool) -> list[tuple[str, int | None]]:
212184
"""The parts to pick from: the manifest's named ones, then `part k`; `any` for none."""
213185
named: dict[int, str] = {}
@@ -472,8 +444,8 @@ def place(m: Manifest) -> None:
472444

473445

474446
def spread(doc: Edits, kx: float = 1.5, ky: float = 1.7) -> str:
475-
"""Every placed node's place scaled out from the origin: a graph laid on a tight grid (the
476-
rules of a schema 1 file) stops overlapping, and which block is higher stays so."""
447+
"""Every placed node's place scaled out from the origin, so crowded nodes stop overlapping.
448+
A scale keeps the order of the canvas rows and columns, so every path's priority holds."""
477449
b = doc.manifest.behaviour
478450

479451
def scale(m: Manifest) -> None:

‎apps/studio/src/mhfu_studio/monster/panels/behaviour.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ def __init__(self, ws: MonsterWorkspace, studio: Studio) -> None:
6767
)
6868
self.spread = kit.button(
6969
"Spread out",
70-
tip="Moves every node away from the top left so none overlaps: a graph made from"
71-
" rules is laid out tight",
70+
tip="Moves every node away from the top left so crowded ones stop overlapping; which"
71+
" block is higher stays so, and so does every path's priority",
7272
on=studio.act("spread nodes", lambda: ws.edit("", self._spread)),
7373
icon="ph.arrows-out-simple",
7474
)
@@ -182,8 +182,9 @@ def _blocks(m: Manifest, r: graph.Reading) -> list[NodeSpec]:
182182
out = []
183183
for i, blk in m.behaviour.blocks.items():
184184
k = KINDS[blk.kind]
185-
why = list(dict.fromkeys(w for p, w in r.refused.items() if i in p.blocks))
185+
why = r.refused.get(i)
186186
marks = [*(f"#{n}" for n in r.priority.get(i, [])), *([WARN] if why else [])]
187+
values = model.params(blk)
187188
options: dict[str, list[tuple[str, object]]] = {}
188189
for p in k.params:
189190
if p.type == "part":
@@ -199,12 +200,12 @@ def _blocks(m: Manifest, r: graph.Reading) -> list[NodeSpec]:
199200
(graph.IN,),
200201
(graph.OUT,),
201202
k.params,
202-
{p.name: blk.params.get(p.name, p.default) for p in k.params},
203+
{p.name: values.get(p.name) for p in k.params},
203204
options,
204205
" ".join(marks),
205206
i in r.loose,
206207
blk.label,
207-
"\n".join([k.tip, *(f"{WARN} {w}" for w in why)]),
208+
"\n".join([k.tip, *([f"{WARN} {why}"] if why else [])]),
208209
)
209210
)
210211
return out

‎apps/studio/src/mhfu_studio/monster/validate.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -555,17 +555,20 @@ def _rules(m: Manifest) -> list[Rule]:
555555

556556

557557
def _behaviour(m: Manifest) -> list[Finding]:
558-
"""What `compile` refuses: each path, and more paths than the seam holds rules."""
558+
"""What `compile` refuses: the blocks of a bad path, and more paths than the seam holds."""
559+
by_reason: dict[str, list[str]] = {}
560+
for i, why in behaviour.refused(m).items():
561+
by_reason.setdefault(why, []).append(i)
559562
out = [
560563
_f(
561564
"error",
562565
"BEHAVIOUR_PATH_REFUSED",
563-
f"behaviour path {' > '.join(p.blocks)} plays {p.play}",
566+
f"behaviour blocks {', '.join(ids)}",
564567
why,
565-
("block", p.blocks[0]),
568+
("block", ids[0]),
566569
BEHAVIOUR,
567570
)
568-
for p, why in graph.refusals(m).items()
571+
for why, ids in by_reason.items()
569572
]
570573
if (cap := graph.capped(m)) is not None:
571574
out.append(

‎apps/studio/tests/monster/test_behaviour.py‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ def test_the_users_example(d: PortDocument) -> None:
259259

260260

261261
def test_moves_without_a_node_sit_in_a_column_in_sequence_order(d: PortDocument) -> None:
262-
x = 260.0 + B.BLOCK_W
262+
x = 260.0 + model.COLUMN
263263
want = {
264264
n: (x, float(k * model.ROW)) for k, n in enumerate(["stamp", "stamp_2", "flinch_head"], 1)
265265
}
@@ -278,8 +278,8 @@ def test_what_the_graph_says_of_itself(d: PortDocument) -> None:
278278
r = B.read(d.manifest)
279279
assert [p.play for p in r.paths] == ["stamp", "flinch_head"]
280280
assert r.priority == {"b1": [1], "b4": [2]} and r.loose == []
281-
[(path, why)] = r.refused.items()
282-
assert path.blocks == ("b2", "b3", "b4") and "two 'Then wait' blocks" in why
281+
assert set(r.refused) == {"b2", "b3", "b4"}
282+
assert all("two 'Then wait' blocks" in why for why in r.refused.values())
283283
assert r.capped is None
284284

285285

@@ -300,6 +300,28 @@ def test_spread_keeps_the_order(d: PortDocument) -> None:
300300
assert undone(d, before)
301301

302302

303+
def test_spread_keeps_every_priority(ports: Path) -> None:
304+
z = PortDocument(manifest.load(ports / "zinogre.toml"))
305+
order = [(p.blocks, p.play, p.during) for p in model.paths(z.manifest)]
306+
rules = model.compile(z.manifest)
307+
for _ in range(3):
308+
B.spread(z)
309+
assert [(p.blocks, p.play, p.during) for p in model.paths(z.manifest)] == order
310+
assert model.compile(z.manifest) == rules
311+
312+
313+
def test_spread_keeps_a_tie_a_tie(d: PortDocument) -> None:
314+
B.add_block(d, "on_noticed", (260.0, 0.0))
315+
B.add_block(d, "on_flinch", (100.0, 0.0))
316+
317+
def order() -> list[tuple[tuple[str, ...], str]]:
318+
return [(p.blocks, p.play) for p in model.paths(d.manifest)]
319+
320+
before = order()
321+
B.spread(d)
322+
assert order() == before
323+
324+
303325
def test_the_cap(d: PortDocument) -> None:
304326
def many(m: Manifest) -> None:
305327
for k in range(model.SEAM_RULES + 1):
@@ -332,6 +354,7 @@ def test_the_zinogre_reads(ports: Path) -> None:
332354
assert len(m.behaviour.blocks) == 15 and len(r.paths) == 7
333355
assert sorted(n for ns in r.priority.values() for n in ns) == list(range(1, 8))
334356
assert r.loose == [] and r.refused == {} and r.capped is None
357+
assert min(b.at[0] for b in m.behaviour.blocks.values()) == model.LEFT
335358
assert {w.dst for w in B.wires(m) if w.dst_port == "play"} >= {"move:flinch_head"}
336359
assert len(B.spots(m)) == len(m.moves)
337360

‎apps/studio/tests/monster/test_validate.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ def test_a_refused_path_is_a_finding(make):
275275
'\n[behaviour.blocks.b3]\nkind = "cooldown"\nat = [0.0, 0.0]\nframes = 9\nplay = ["o"]\n'
276276
)
277277
f = found(V.validate(make(OWN + two), sources={1: 1}), "BEHAVIOUR_PATH_REFUSED")
278-
assert (f.level, f.where) == ("error", "behaviour path b1 > b2 > b3 plays o")
278+
assert (f.level, f.where) == ("error", "behaviour blocks b1, b2, b3")
279279
assert (f.target, f.focus) == (("block", "b1"), V.BEHAVIOUR) and "two" in f.message
280280
assert "OWN_MOVE_REFUSED" not in found(V.validate(make(OWN + two), sources={1: 1}))
281281

‎apps/studio/tests/monster/ui/test_behaviour.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
from collections.abc import Callable
89
from pathlib import Path
910
from typing import Any
1011

@@ -18,9 +19,12 @@
1819
from mhfu_studio.monster.panels.node_graph import GraphView, LinkSpec
1920
from mhfu_studio.monster.workspace import MonsterWorkspace
2021
from mhfu_studio.shell.studio import Studio
22+
from mhfu_studio.shell.workspace import Dock
2123
from mhfu_studio.ui import kit, theme
24+
from mhfu_studio.ui.testing import FakeWorkspace
2225
from PySide6.QtCore import QPoint, QPointF, Qt
2326
from PySide6.QtTest import QTest
27+
from PySide6.QtWidgets import QDockWidget
2428

2529
LEFT, NONE = Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier
2630
STAMP, FLINCH, STEP = B.move_id("stamp"), B.move_id("flinch_head"), B.move_id("stamp_2")
@@ -141,6 +145,39 @@ def test_the_dock_is_registered_at_the_bottom() -> None:
141145
assert (dock.area, dock.shown, dock.alone) == ("bottom", False, False)
142146

143147

148+
class Hosted(FakeWorkspace):
149+
"""A fake workspace carrying the monster workspace's own Behaviour dock, and a strip that
150+
is never tabbed under it, as the Timeline is."""
151+
152+
def __init__(self, own: MonsterWorkspace) -> None:
153+
super().__init__("monster")
154+
self.own = own
155+
156+
def docks(self) -> tuple[Dock, ...]:
157+
[spec] = [d for d in self.own.docks() if d.label == "Behaviour"]
158+
strip = Dock("Strip", "bottom", lambda s: kit.Panel(), "A panel never tabbed", alone=True)
159+
return (*super().docks(), spec, strip)
160+
161+
162+
def test_the_window_opens_the_dock(
163+
make_window: Callable[..., Any], qtbot: Any, port_doc: PortDocument
164+
) -> None:
165+
own = MonsterWorkspace()
166+
own.doc = port_doc
167+
host = Hosted(own)
168+
w = make_window(host)
169+
dock = w.findChild(QDockWidget, "monster/Behaviour")
170+
strip = w.findChild(QDockWidget, "monster/Strip")
171+
assert dock is not None and strip is not None and not dock.isVisible()
172+
assert isinstance(dock.widget(), BehaviourPanel) and kit.missing_tips(dock.widget()) == []
173+
host.focus = "Behaviour" # what the Moves dock's button asks for
174+
w.studio.changed()
175+
w.sync()
176+
qtbot.wait(20)
177+
assert dock.isVisible() and w.dockWidgetArea(dock) == Qt.DockWidgetArea.BottomDockWidgetArea
178+
assert dock.height() > 2 * strip.height() and dock.geometry().bottom() < strip.geometry().top()
179+
180+
144181
def test_empty(qtbot: Any) -> None:
145182
p = make(qtbot, MonsterWorkspace())
146183
assert p.pages.currentWidget() is p.no_scene

‎apps/studio/tests/monster/ui/test_workspace.py‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -216,11 +216,6 @@ def test_window_builds_the_docks(
216216
d = w.findChild(QDockWidget, f"monster/{label}")
217217
assert d is not None and isinstance(d.widget(), cls)
218218
assert kit.missing_tips(d.widget()) == []
219-
from mhfu_studio.monster.panels.behaviour import BehaviourPanel
220-
221-
behaviour = w.findChild(QDockWidget, "monster/Behaviour")
222-
assert behaviour is not None and isinstance(behaviour.widget(), BehaviourPanel)
223-
assert not behaviour.isVisible() and kit.missing_tips(behaviour.widget()) == []
224219
actions = w.findChild(QDockWidget, "monster/Actions")
225220
assert actions is not None and actions.isVisible(), "in front of Clips"
226221
clips = w.findChild(QDockWidget, "monster/Clips")
@@ -229,10 +224,6 @@ def test_window_builds_the_docks(
229224
w.studio.changed()
230225
w.sync()
231226
assert clips.isVisible()
232-
ws.focus("Behaviour")
233-
w.studio.changed()
234-
w.sync()
235-
assert behaviour.isVisible() and behaviour.height() > 200
236227

237228

238229
def test_window_shows_the_host(

0 commit comments

Comments
 (0)