diff --git a/doc/source/_static/tutorials/clipboard.mp4 b/doc/source/_static/tutorials/clipboard.mp4 index 824cdc7..de2a783 100644 Binary files a/doc/source/_static/tutorials/clipboard.mp4 and b/doc/source/_static/tutorials/clipboard.mp4 differ diff --git a/doc/source/_static/tutorials/context_menus.mp4 b/doc/source/_static/tutorials/context_menus.mp4 index be9850f..e1b5e4c 100644 Binary files a/doc/source/_static/tutorials/context_menus.mp4 and b/doc/source/_static/tutorials/context_menus.mp4 differ diff --git a/doc/source/_static/tutorials/delete_and_select.mp4 b/doc/source/_static/tutorials/delete_and_select.mp4 index 0044d3f..8fc0b3f 100644 Binary files a/doc/source/_static/tutorials/delete_and_select.mp4 and b/doc/source/_static/tutorials/delete_and_select.mp4 differ diff --git a/doc/source/tutorials/clipboard.rst b/doc/source/tutorials/clipboard.rst index b2e311b..d13569f 100644 --- a/doc/source/tutorials/clipboard.rst +++ b/doc/source/tutorials/clipboard.rst @@ -72,8 +72,9 @@ selects it and focuses the canvas — then sends a real Ctrl chord: post_command(app, "imgui_key_chord", JV((mods = ["Ctrl"], key = "D"))) -``set_user_control(false)`` hands IO to the synthetic timeline so the real OS cursor can't -race the synth and steal the canvas focus the chord needs. The headless regression -(``test_clipboard_tutorial.das``) drives the same real chords — distinct from -``test_shortcuts`` / ``test_clipboard``, which exercise ``shader_graph`` through the -``ne_shortcut`` injection rail. +The recording app holds ``set_user_control(false)`` for the whole run, so the real OS +cursor can't race the synth and steal the canvas focus the chord needs. It also overlays +the ``imgui_key_hud`` keycap strip, so each ``Ctrl`` chord is visible on screen as it +fires. The headless regression (``test_clipboard_tutorial.das``) drives the same real +chords — distinct from ``test_shortcuts`` / ``test_clipboard``, which exercise +``shader_graph`` through the ``ne_shortcut`` injection rail. diff --git a/doc/source/tutorials/context_menus.rst b/doc/source/tutorials/context_menus.rst index 7f93565..b95e748 100644 --- a/doc/source/tutorials/context_menus.rst +++ b/doc/source/tutorials/context_menus.rst @@ -47,6 +47,12 @@ Walkthrough .. video:: context_menus.mp4 +The recording is voiced and self-verifying: it pulses the A→B link with ``flow()`` +to show it is live, then each synthetic right-click MUST open the matching menu — +the node menu's *Delete node* MUST remove A and cascade its link, and the +background menu's *Node* MUST spawn a node at the click point (a no-op aborts at +teardown). + .. literalinclude:: ../../../examples/tutorial/context_menus.das :language: das :linenos: diff --git a/doc/source/tutorials/delete_and_select.rst b/doc/source/tutorials/delete_and_select.rst index ba7c61d..e77a6c1 100644 --- a/doc/source/tutorials/delete_and_select.rst +++ b/doc/source/tutorials/delete_and_select.rst @@ -38,6 +38,11 @@ Walkthrough .. video:: delete_and_select.mp4 +The recording is voiced and self-verifying: it pulses both links with ``flow()`` +to show the chain is live, then a real synthetic click MUST select ``B`` and a +real Delete key MUST remove ``B`` *and* cascade both links on its pins (a no-op +aborts at teardown). + .. literalinclude:: ../../../examples/tutorial/delete_and_select.das :language: das :linenos: diff --git a/tests/integration/record_clipboard.das b/tests/integration/record_clipboard.das index 622a572..3f8d7eb 100644 --- a/tests/integration/record_clipboard.das +++ b/tests/integration/record_clipboard.das @@ -8,20 +8,20 @@ require imgui/imgui_editor_playwright public require daslib/json public require daslib/json_boost public -//! Driver: record clipboard.apng — click a node and Ctrl+D to duplicate it, then click -//! another and Ctrl+C / Ctrl+V to copy + paste. Narrated, with synthetic clicks + real -//! Ctrl chords. Run with `daslang tests/integration/record_clipboard.das`, then ffmpeg -//! the .apng to .mp4. -//! -//! NB: set_user_control(false) hands IO fully to the synthetic timeline — without it the -//! real OS cursor races the synth and the canvas never gains focus, so the editor's -//! shortcut gate (IsFocused) stays shut and the chords no-op. Shortcuts are Ctrl on every -//! platform. The headless regression is test_clipboard_tutorial.das. +//! Driver: record clipboard.apng - click a node and Ctrl+D to duplicate, then click +//! another and Ctrl+C / Ctrl+V to copy + paste. Voiced and self-verifying: each click +//! MUST select its node, Ctrl+D MUST spawn the duplicate, and Ctrl+V MUST spawn the +//! pasted node (Ctrl+C is verified transitively through the paste), or the recording +//! aborts at teardown. The keycap + modifier HUD (imgui_key_hud) is on, and each chord +//! fires partway INTO its voice line so the keycap + Ctrl pill pop while it is named. +//! Shortcuts are Ctrl on every platform; the click focuses the canvas the gate needs. +//! The headless regression is test_clipboard_tutorial.das. let CANVAS = "MAIN_WIN/graph" def title_point(var snap : JsonValue?; ident : string) : tuple { - // The node's title row (top edge) — clicking it selects the node AND focuses the canvas. + // The node's title row - clicking it selects the node AND focuses the canvas (the gate + // the Ctrl shortcuts need); the center would hit the output pin. let b = find_widget(snap, ident)?["bbox"] return (((b?["x"] ?? 0.0f) + (b?["z"] ?? 0.0f)) * 0.5f, (b?["y"] ?? 0.0f) + 10.0f) } @@ -33,47 +33,65 @@ def click_node(app : ImguiApp; pt : tuple) { wait_for_mouse_idle(app) } +def chord_under_voice(app : ImguiApp; dwell : uint; key : string) { + // Fire the Ctrl chord partway into the already-posted voice line, so the keycap + Ctrl + // pill pop while the line names it, then hold out the rest of the dwell. + let lead = dwell > 1200u ? 700u : dwell / 3u + sleep(lead) + post_command(app, "imgui_key_chord", JV((mods = ["Ctrl"], key = key))) + wait_for_key_idle(app, 4.0f) + sleep(dwell > lead ? dwell - lead : 0u) +} + [export] def main { with_node_editor_recording_app("examples/tutorial/clipboard.das", - "clipboard.apng", 55) $(app) { - var snap = wait_for_widget(app, "{CANVAS}/node_1", 15.0f) + "clipboard.apng", 60) $(app) { + let T_A = "{CANVAS}/node_1" + let T_B = "{CANVAS}/node_2" + let T_DUP = "{CANVAS}/node_200" + let T_PASTE = "{CANVAS}/node_210" + + var snap = wait_for_widget(app, T_A, 15.0f) if (snap == null) { - panic("{CANVAS}/node_1 never rendered — wrong app running?") + panic("{T_A} never rendered - wrong app running?") } - // Synthetic IO only — no real-cursor race (see header note). - post_command(app, "set_user_control", JV((enabled = false))) + // Keyboard helper: the bottom-center keycap + modifier strip, so the Ctrl chords + // are visible on screen as they fire (this tutorial is all about Ctrl+D/C/V). Bump + // the keycap linger to 2.5s so [Ctrl][D] stays readable through its voice line. + post_command(app, "imgui_key_hud", JV((enabled = true, show_modifiers = true, keycap_fade_s = 2.5f))) - let pA = title_point(snap, "{CANVAS}/node_1") - let pB = title_point(snap, "{CANVAS}/node_2") + let pA = title_point(snap, T_A) + let pB = title_point(snap, T_B) - // ---- duplicate A ---- + // ---- Beat 1: click A -> select + focus the canvas ---- move_to(app, pA, 600) wait_for_mouse_idle(app) - say(app, "Click a node to select it - this also focuses the canvas.", "{CANVAS}/node_1") click_node(app, pA) - say(app, "Ctrl+D duplicates the selection - accept_duplicate fires; the app clones it.", "{CANVAS}/node_1") - post_command(app, "imgui_key_chord", JV((mods = ["Ctrl"], key = "D"))) - wait_for_key_idle(app, 4.0f) - var dupSnap = wait_for_widget(app, "{CANVAS}/node_200", 6.0f) - print("duplicate -> {dupSnap != null}\n") + record_check_value(app, T_A, "selected", true) + say(app, "click a node to select it", T_A, + [voice = "Click a node to select it - the click also focuses the canvas, which the edit shortcuts need."]) + + // ---- Beat 2: Ctrl+D -> duplicate (chord pops under the voice) ---- + chord_under_voice(app, say_begin(app, "Ctrl+D duplicates", T_DUP, + [voice = "Ctrl+D duplicates the selection - accept_duplicate fires and the app clones it next to the original."]), "D") + record_check_rendered(app, T_DUP, true) - // ---- copy + paste B ---- + // ---- Beat 3: click B -> select ---- move_to(app, pB, 600) wait_for_mouse_idle(app) - say(app, "Select another node, then Ctrl+C to copy.", "{CANVAS}/node_2") click_node(app, pB) - post_command(app, "imgui_key_chord", JV((mods = ["Ctrl"], key = "C"))) - wait_for_key_idle(app, 4.0f) - say(app, "Ctrl+V pastes it - accept_paste fires; the app recreates the copied node.", "{CANVAS}/node_2") - post_command(app, "imgui_key_chord", JV((mods = ["Ctrl"], key = "V"))) - wait_for_key_idle(app, 4.0f) - var pasteSnap = wait_for_widget(app, "{CANVAS}/node_210", 6.0f) - print("paste -> {pasteSnap != null}\n") - if (pasteSnap != null) { - say(app, "The app owns the clipboard - the editor only reports the chord through accept_copy / accept_paste.", "{CANVAS}/node_210") - } else { - say(app, "(the paste did not land - see test_clipboard_tutorial for the sequence)", "{CANVAS}/node_2") - } + record_check_value(app, T_B, "selected", true) + say(app, "select another node", T_B, + [voice = "Now select a different node to copy."]) + + // ---- Beat 4: Ctrl+C -> copy (internal; proven by the paste below) ---- + chord_under_voice(app, say_begin(app, "Ctrl+C copies", T_B, + [voice = "Ctrl+C copies it - the editor only reports the chord; the app serializes the selection into its own clipboard."]), "C") + + // ---- Beat 5: Ctrl+V -> paste ---- + chord_under_voice(app, say_begin(app, "Ctrl+V pastes", T_PASTE, + [voice = "Ctrl+V pastes - accept_paste fires and the app recreates the copied node from its clipboard."]), "V") + record_check_rendered(app, T_PASTE, true) } } diff --git a/tests/integration/record_context_menus.das b/tests/integration/record_context_menus.das index 9ca454b..492c0ed 100644 --- a/tests/integration/record_context_menus.das +++ b/tests/integration/record_context_menus.das @@ -8,16 +8,13 @@ require imgui/imgui_editor_playwright public require daslib/json public require daslib/json_boost public -//! Driver: record context_menus.apng — right-click a node for its "Delete node" menu -//! (delete it, link cascades), then right-click the empty canvas for the background -//! "Add a node" menu (create one at the cursor). Narrated, synthetic right-clicks. -//! Run with `daslang tests/integration/record_context_menus.das`, then ffmpeg the -//! .apng to .mp4. -//! -//! NB: set_user_control(false) hands IO fully to the synthetic timeline — without it -//! the real OS cursor races the synth and consumes the menu clicks (a popup-close -//! swallows the next right-click). The right-click must hit the node BODY (its center); -//! a click on a pin opens the pin menu instead. +//! Driver: record context_menus.apng - right-click a node for its "Delete node" menu +//! (delete it, the link cascades), then right-click empty canvas for the background +//! "Add a node" menu (create one at the cursor). Voiced and self-verifying: each +//! right-click MUST open the matching menu, the delete MUST remove the node + cascade +//! its link, and the create MUST spawn a node, or the recording aborts at teardown. +//! Pulses the A -> B link first so the cascade reads as cutting a live connection. +//! The headless regression for the same gestures is test_context_menus.das. let CANVAS = "MAIN_WIN/graph" @@ -37,50 +34,68 @@ def right_click(app : ImguiApp; pt : tuple) { [export] def main { with_node_editor_recording_app("examples/tutorial/context_menus.das", - "context_menus.apng", 55) $(app) { - var snap = wait_for_widget(app, "{CANVAS}/node_1", 15.0f) + "context_menus.apng", 60) $(app) { + let T_A = "{CANVAS}/node_1" + let T_B = "{CANVAS}/node_2" + let T_LINK = "{CANVAS}/link_100" + let DEL_ITEM = "{CANVAS}/NODE_MENU/DEL_ITEM" + let ADD_ITEM = "{CANVAS}/BG_MENU/ADD_ITEM" + let NEW_NODE = "{CANVAS}/node_200" + + let s = ne_open(app, CANVAS) + if (s.handle == uint64(0)) { + panic("{CANVAS} never rendered / no editor handle - wrong app running?") + } + var snap = wait_for_widget(app, T_A, 15.0f) if (snap == null) { - panic("{CANVAS}/node_1 never rendered — wrong app running?") + panic("{T_A} never rendered - wrong app running?") } - // Synthetic IO only — no real-cursor race (see header note). - post_command(app, "set_user_control", JV((enabled = false))) + let cA = center(snap, T_A) + let bg_pt : tuple = (720.0f, 430.0f) // empty canvas - let del_item = "{CANVAS}/NODE_MENU/DEL_ITEM" - let add_item = "{CANVAS}/BG_MENU/ADD_ITEM" - let new_node = "{CANVAS}/node_200" - let cA = center(snap, "{CANVAS}/node_1") - let bg_pt : tuple = (720.0, 430.0) // empty canvas + // ---- Beat 1: the live link ---- + record_check_rendered(app, T_A, true) + let dwell = say_begin(app, "A -> B: a live link", T_A, + [voice = "A and B share a live link - we can pulse it to see data flow before we touch the menus."]) + var pulses = int(dwell) / 500 + if (pulses < 4) { pulses = 4 } + let interval = dwell / uint(pulses) + for (_i in range(pulses)) { + ne_flow(s, 100) + sleep(interval) + } - // ---- node menu: right-click node A's body ---- - say(app, "Right-click a node for its own context menu.", "{CANVAS}/node_1") + // ---- Beat 2: right-click A -> node menu ---- move_to(app, cA, 700) wait_for_mouse_idle(app) right_click(app, cA) - var nodeMenuSnap = wait_for_widget(app, del_item, 6.0f) - print("node menu opened -> {nodeMenuSnap != null}\n") - say(app, "show_node_context_menu reports which node was hit; 'Delete node' enqueues it.", del_item) - click(app, del_item) - var goneSnap = wait_until_sec(app, 6.0f) $(var sn) { - return !widget_exists(sn, "{CANVAS}/node_1") - } - print("node A deleted via node menu -> {goneSnap != null}\n") - say(app, "Deleted through begin_delete - the link on its pin cascaded away too.", "{CANVAS}/node_2") + // Hard self-verify: the right-click MUST open the node menu. + record_check_rendered(app, DEL_ITEM, true) + say(app, "right-click a node -> its menu", DEL_ITEM, + [voice = "Right-click a node and the editor pops its own context menu - here it offers Delete node."]) - // ---- background menu: right-click empty canvas ---- - say(app, "Right-click empty canvas for the background menu.", "{CANVAS}/node_2") + // ---- Beat 3: Delete node -> A + link cascade ---- + click(app, DEL_ITEM) + // Hard self-verify: A is gone AND its link cascaded out. + record_check_rendered(app, T_A, false) + record_check_rendered(app, T_LINK, false) + say(app, "Delete node -> A + link cascade", T_B, + [voice = "Delete node enqueues A through begin_delete - the same path the Delete key uses - so A and the link on its pin cascade away."]) + + // ---- Beat 4: right-click empty canvas -> background menu ---- move_to(app, bg_pt, 700) wait_for_mouse_idle(app) right_click(app, bg_pt) - var bgSnap = wait_for_widget(app, add_item, 6.0f) - print("bg menu opened -> {bgSnap != null}\n") - say(app, "show_background_context_menu opens the menu at the cursor; 'Node' creates one here.", add_item) - click(app, add_item) - var nSnap = wait_for_widget(app, new_node, 5.0f) - print("node spawned via bg menu -> {nSnap != null}\n") - if (nSnap != null) { - say(app, "A new node, created at the click point.", new_node) - } else { - say(app, "(the menu pick did not land)", "{CANVAS}/node_2") - } + // Hard self-verify: the right-click MUST open the background menu. + record_check_rendered(app, ADD_ITEM, true) + say(app, "right-click empty -> background menu", ADD_ITEM, + [voice = "Right-click empty canvas instead and you get the background menu, opened right at the cursor."]) + + // ---- Beat 5: Node -> spawned at the click point ---- + click(app, ADD_ITEM) + // Hard self-verify: the pick MUST spawn the node. + record_check_rendered(app, NEW_NODE, true) + say(app, "Node -> created at the click point", NEW_NODE, + [voice = "Picking Node creates a fresh node exactly where you clicked."]) } } diff --git a/tests/integration/record_delete_and_select.das b/tests/integration/record_delete_and_select.das index 0f9c9b4..1f51a1a 100644 --- a/tests/integration/record_delete_and_select.das +++ b/tests/integration/record_delete_and_select.das @@ -9,21 +9,17 @@ require imgui/imgui_editor_playwright public require daslib/json public require daslib/json_boost public -//! Driver: record delete_and_select.apng — select the middle node, press Delete, and -//! watch the node plus the two links on its pins cascade away. Narrated, with a -//! synthetic left-click to select then a synthetic Delete key. Run with -//! `daslang tests/integration/record_delete_and_select.das`, then ffmpeg the .apng to .mp4. -//! -//! NB: set_user_control(false) hands IO fully to the synthetic timeline — without it -//! the real OS cursor races the synth and the canvas never gains hover, so the Delete -//! key's CanAcceptUserInput gate stays shut. The editor's native Delete key raises the -//! selection through begin_delete — the same path delete_and_select.das handles. The -//! title row selects the node (its center hits a pin, which eats the click). +//! Driver: record delete_and_select.apng - select the middle node of an A -> B -> C +//! chain, press Delete, and watch B plus the two links on its pins cascade away. Voiced +//! and self-verifying: the click MUST select B, and the Delete MUST remove B and both +//! its links, or the recording aborts at teardown. Pulses the chain first to show it is +//! live before it is cut. The headless regression for the same gesture is +//! test_delete_and_select.das. let CANVAS = "MAIN_WIN/graph" def title_point(var snap : JsonValue?; ident : string) : tuple { - // The node's title row (top edge) — clicking the center hits a pin item, which + // The node's title row (top edge) - clicking the center hits a pin item, which // eats the click; the title row reliably selects the node. let b = find_widget(snap, ident)?["bbox"] return (((b?["x"] ?? 0.0f) + (b?["z"] ?? 0.0f)) * 0.5f, (b?["y"] ?? 0.0f) + 10.0f) @@ -32,53 +28,59 @@ def title_point(var snap : JsonValue?; ident : string) : tuple { [export] def main { with_node_editor_recording_app("examples/tutorial/delete_and_select.das", - "delete_and_select.apng", 45) $(app) { - var snap = wait_for_widget(app, "{CANVAS}/node_2", 15.0f) + "delete_and_select.apng", 55) $(app) { + let T_B = "{CANVAS}/node_2" + let T_L100 = "{CANVAS}/link_100" + let T_L101 = "{CANVAS}/link_101" + + let s = ne_open(app, CANVAS) + if (s.handle == uint64(0)) { + panic("{CANVAS} never rendered / no editor handle - wrong app running?") + } + var snap = wait_for_widget(app, T_B, 15.0f) if (snap == null) { - panic("{CANVAS}/node_2 never rendered — wrong app running?") + panic("{T_B} never rendered - wrong app running?") } - // Synthetic IO only — no real-cursor race (see header note). - post_command(app, "set_user_control", JV((enabled = false))) + let sel_pt = title_point(snap, T_B) - let sel_pt = title_point(snap, "{CANVAS}/node_2") + // ---- Beat 1: the chain ---- + record_check_rendered(app, T_B, true) + say(app, "A -> B -> C: two links", T_B, + [voice = "Here is a tiny chain - node A to B to C, joined by two links."]) - say(app, "A -> B -> C: one chain, two links. We will delete the middle node, B.", "{CANVAS}/node_2") + // ---- Beat 2: show it is live (pulse both links) ---- + let dwell = say_begin(app, "the chain is live", T_B, + [voice = "Both links carry data end to end - we can pulse them to see the chain is live before we cut it."]) + var pulses = int(dwell) / 500 + if (pulses < 4) { pulses = 4 } + let interval = dwell / uint(pulses) + for (_i in range(pulses)) { + ne_flow(s, 100) + ne_flow(s, 101) + sleep(interval) + } - // Select B (title-row click). wait_for_mouse_idle lets the click fully drain - // before we poll the selection (the snapshot-during-recording lesson from #2). + // ---- Beat 3: select B (title-row click) ---- move_to(app, sel_pt, 700) wait_for_mouse_idle(app) - say(app, "Click a node to select it - the editor highlights the selection.", "{CANVAS}/node_2") var ev : array ev |> click_at(0, sel_pt, 250, 0) post_command(app, "imgui_mouse_play", JV((events = ev))) wait_for_mouse_idle(app) - var selSnap = wait_until_sec(app, 6.0f) $(var sn) { - return find_widget(sn, "{CANVAS}/node_2")?["payload"]?["selected"] ?? false - } - print("SELECT node_2 -> {selSnap != null}\n") + // Hard self-verify: the click MUST select B. + record_check_value(app, T_B, "selected", true) + say(app, "click B to select it", T_B, + [voice = "Click a node to select it - the editor highlights the selection for you."]) - // Press Delete: the editor raises the selection through begin_delete, where the - // app accepts it and cascades the dangling links. - say(app, "Press Delete - the editor raises the selection through begin_delete; the app accepts it.", "{CANVAS}/node_2") + // ---- Beat 4: Delete -> B and both its links cascade ---- let del = int(ImGuiKey.Delete) - post_command(app, "imgui_key_press", JV((key = del))) + post_command(app, "imgui_key_press", JV((key = del))) post_command(app, "imgui_key_release", JV((key = del))) - - // Gate: B is gone AND both its links cascaded out. - var goneSnap = wait_until_sec(app, 8.0f) $(var sn) { - return !widget_exists(sn, "{CANVAS}/node_2") - } - let node_gone = goneSnap != null - let links_gone = (node_gone && - !widget_exists(goneSnap, "{CANVAS}/link_100") && - !widget_exists(goneSnap, "{CANVAS}/link_101")) - print("DELETE node_2 gone -> {node_gone}, links cascaded -> {links_gone}\n") - - if (links_gone) { - say(app, "accept_deleted_node removed B; the two links on its pins cascaded away. A and C remain, disconnected.", "{CANVAS}/node_1") - } else { - say(app, "(the delete did not land - check the selection / Delete key)", "{CANVAS}/node_2") - } + // Hard self-verify: B is gone AND both links cascaded out, or abort at teardown. + record_check_rendered(app, T_B, false) + record_check_rendered(app, T_L100, false) + record_check_rendered(app, T_L101, false) + say(app, "Delete -> B + both links cascade", "{CANVAS}/node_1", + [voice = "Press Delete and the editor raises B through begin_delete; accepting it drops B and cascades the two links on its pins. A and C remain, disconnected."]) } }