diff --git a/graphcode-windows/src/GraphContextMenu.zig b/graphcode-windows/src/GraphContextMenu.zig index 81cddb19..10e63f80 100644 --- a/graphcode-windows/src/GraphContextMenu.zig +++ b/graphcode-windows/src/GraphContextMenu.zig @@ -447,6 +447,83 @@ test "sketch promotion real menu plan exposes only three eligible target actions } } +test "sketch promotion production popup installs native Goal Turn Timed submenu on hidden HWND" { + const hwnd = c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("STATIC"), + std.unicode.utf8ToUtf16LeStringLiteral("Promotion menu test"), + c.WS_OVERLAPPEDWINDOW, + 0, + 0, + 0, + 0, + null, + null, + c.GetModuleHandleW(null), + null, + ) orelse return error.WindowCreationFailed; + defer _ = c.DestroyWindow(hwnd); + + const context = SketchPromotion.Context{}; + const cases = [_]struct { sketch: bool, context: ?*const SketchPromotion.Context, enabled: bool }{ + .{ .sketch = false, .context = null, .enabled = false }, + .{ .sketch = true, .context = null, .enabled = false }, + .{ .sketch = true, .context = &context, .enabled = true }, + }; + for (cases) |case| { + const popup = buildMenu(.{ .node = .{ + .project_path = "C:\\fixture", + .id = "sketch-a", + .sketch = case.sketch, + .promotion_context = case.context, + } }) orelse return error.MenuCreationFailed; + const bar = c.CreateMenu() orelse { + _ = c.DestroyMenu(popup); + return error.MenuCreationFailed; + }; + defer { + _ = c.SetMenu(hwnd, null); + _ = c.DestroyMenu(bar); + } + if (c.AppendMenuW(bar, c.MF_POPUP | c.MF_STRING, @intFromPtr(popup), std.unicode.utf8ToUtf16LeStringLiteral("Node").ptr) == 0) { + _ = c.DestroyMenu(popup); + return error.MenuInstallFailed; + } + if (c.SetMenu(hwnd, bar) == 0) return error.MenuInstallFailed; + const menu = c.GetSubMenu(c.GetMenu(hwnd), 0); + try std.testing.expect(menu != null); + var edit_position: c_int = 0; + while (edit_position < c.GetMenuItemCount(menu) and c.GetMenuItemID(menu, edit_position) != ids.edit_node) : (edit_position += 1) {} + try std.testing.expect(edit_position < c.GetMenuItemCount(menu)); + const submenu = c.GetSubMenu(menu, edit_position + 1); + if (!case.sketch) { + try std.testing.expect(submenu == null); + for ([_]c.UINT{ 5116, 5117, 5118 }) |id| + try std.testing.expectEqual(std.math.maxInt(c.UINT), c.GetMenuState(menu, id, c.MF_BYCOMMAND)); + continue; + } + try std.testing.expect(submenu != null); + var title: [64]u16 = undefined; + const title_len = c.GetMenuStringW(menu, @intCast(edit_position + 1), &title, title.len, c.MF_BYPOSITION); + try std.testing.expectEqualSlices(u16, std.unicode.utf8ToUtf16LeStringLiteral("Promote to..."), title[0..@intCast(title_len)]); + try std.testing.expectEqual(@as(c_int, 3), c.GetMenuItemCount(submenu)); + for ([_]struct { id: c.UINT, label: []const u16, action: Action }{ + .{ .id = 5116, .label = std.unicode.utf8ToUtf16LeStringLiteral("Goal - asks for a done check"), .action = .promote_goal }, + .{ .id = 5117, .label = std.unicode.utf8ToUtf16LeStringLiteral("Turn - asks where to pause"), .action = .promote_turn }, + .{ .id = 5118, .label = std.unicode.utf8ToUtf16LeStringLiteral("Timed - asks for a cadence"), .action = .promote_timed }, + }, 0..) |item, index| { + try std.testing.expectEqual(item.id, c.GetMenuItemID(submenu, @intCast(index))); + const state = c.GetMenuState(submenu, item.id, c.MF_BYCOMMAND); + try std.testing.expect(state != std.math.maxInt(c.UINT)); + try std.testing.expectEqual(case.enabled, state & c.MF_GRAYED == 0); + var label: [64]u16 = undefined; + const length = c.GetMenuStringW(submenu, item.id, &label, label.len, c.MF_BYCOMMAND); + try std.testing.expectEqualSlices(u16, item.label, label[0..@intCast(length)]); + try std.testing.expectEqual(item.action, actionForCommand(@intCast(item.id))); + } + } +} + test "background menu exposes supported folder actions and gates edge creation" { const menu = buildMenu(.{ .background = .{ .project_path = "C:\\fixture", diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 07392c6a..deb2d074 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -97,7 +97,7 @@ native keyboard/accelerator/window proof; the workspace row remains Partial. | Node update/rename | Dedicated rename prompt and safe typed updates | Rename retains its dedicated safe prompt. Edit Details uses `NativeForms.update` and `sendUpdateNodeForm`; its project/node identity is now owned across the modal message loop and re-resolved afterward, and changed strings are compared against the form's owned initial snapshot rather than potentially freed graph strings. Original numeric scalars and clear-versus-unchanged semantics are preserved. Production-helper tests cover source mutation, unchanged/changed typed fields, cancellation, clearing, and allocation failures. The lifetime test rejects the original borrowed baseline. Live attempts stopped at background-menu activation before reaching editor or rename actions, so app-level dispatch, cancellation, and returned model results remain unverified in this work | Partial | | Delete confirmations | Named object, consequences, safe default | Loop deletion names the loop and explains graph-connection removal. Edge deletion now names both endpoint loops and the connection kind, explains that the loops remain, re-resolves the stable edge after confirmation, and defaults to cancellation | Validated | | Canvas context menu | Folder actions on background; complete node/edge actions | Project backgrounds now expose existing Worktrees, Project Settings, and Explorer routes with captured project context; unavailable non-filesystem actions are disabled and global scope omits them. Create Edge remains present but disabled with fewer than two loops. Resolved node menus omit Stop, matching macOS; composite/template/unwired actions and the absence of Message/Memo are preserved. Tests inspect the same native HMENU builder used by `TrackPopupMenu`, including an exact disabled-item assertion that fails when Create Edge is removed. The existing gate already inspects plain/composite/unwired popup contents; its succeeded-node Stop assertion now requires absence. Focused live attempts read the real background popup but did not achieve an action/result, and a minimal native control failed foreground acquisition before opening its menu; the cause remains unproven. Live node/edge/background action results, New Child live proof, and import/export remain deferred; custody child creation and sketch promotion retain their separate Partial evidence rows | Partial | -| Sketch promotion | Promote a sketch to Goal, Turn, or Timed without replacing its identity or session | Canvas and sidebar context menus use one production submenu plan and focused native forms. Owned popup/form snapshots reject stale scope, selection, type, and deletion; initial selection of a different cached project remains supported independently of the observation subscription. Filtered pure tests exercise the actual App selection adapter, typed native-form builder, and unstarted DaemonClient outbound queue, including composite addressing and allocation failures. Three emitted fixtures decode as existing Swift `promoteNode` variants, and the full Windows application builds ReleaseSafe. This proves local command construction/queueing only: no native keyboard, HMENU/HWND, UIA, app launch, or real-daemon acceptance/persistence was exercised for this feature | Partial | +| Sketch promotion | Promote a sketch to Goal, Turn, or Timed without replacing its identity or session | Canvas and sidebar context menus use one production submenu plan and focused native forms. Owned popup/form snapshots reject stale scope, selection, type, and deletion; initial selection of a different cached project remains supported independently of the observation subscription. Filtered pure tests exercise the actual App selection adapter, typed native-form builder, and unstarted DaemonClient outbound queue, including composite addressing and allocation failures. Three emitted fixtures decode as existing Swift `promoteNode` variants, and the full Windows application builds ReleaseSafe. A native Win32 test attaches the production node popup to a hidden HWND and checks the Goal/Turn/Timed submenu's presence, IDs, labels, and enabled states through the actual HMENU; it does not show or select the popup. Local command construction/queueing and this native menu state are proven, but native keyboard interaction, UIA, app launch, and real-daemon acceptance/persistence remain unexercised | Partial | ## Quick Chats