diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index e32b58c3..afdb3232 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -10448,6 +10448,30 @@ test "graph publication adopts the daemon canonical path for the shell's own fol } } +test "open workspace applies production stopped state and removes Stop control" { + const F = GraphPublicationTest; + var app = try F.init(.v2); + defer F.deinit(&app); + try F.seed(&app); + app.surface = .workspace; + + app.onFrameWithEffects( + \\{"version":2,"kind":"event","sequence":4,"event":{"graphChanged":{"_0":{"project":{"path":"B","name":"Beta"},"nodes":[{"id":"other","title":"Other","state":{"idle":{}}},{"id":"loop","title":"Beta loop","state":{"stopped":{}}}],"edges":[]}}}} + , F.rebind, F.refresh, F.publish); + + const graph = workspaceGraph(&app.model) orelse return error.WorkspaceGraphMissing; + const selected_index = app.model.selectedIndex() orelse return error.WorkspaceSelectionMissing; + try std.testing.expectEqualStrings("loop", graph.nodes.items[selected_index].id); + try std.testing.expectEqualStrings("stopped", graph.nodes.items[selected_index].state); + const loop_bar = TerminalWorkspace.loopBarLayout( + Tokens.sidebar_width, + 1200 - Tokens.loop_detail_width, + isResolvedLoopState(graph.nodes.items[selected_index].state), + ); + try std.testing.expect(loop_bar.stop == null); + try std.testing.expectEqual(@as(usize, 1), F.publications); +} + /// Swift's `UUID.uuidString` echoes request IDs in uppercase. fn upperRequestID(request: [36]u8) [36]u8 { var upper: [36]u8 = undefined; diff --git a/graphcode-windows/src/GraphModel.zig b/graphcode-windows/src/GraphModel.zig index d5c68512..4203460b 100644 --- a/graphcode-windows/src/GraphModel.zig +++ b/graphcode-windows/src/GraphModel.zig @@ -1742,6 +1742,41 @@ const JsonFields = struct { } }; +fn isLoopState(value: []const u8) bool { + return for ([_][]const u8{ + "idle", + "running", + "awaitingInput", + "blocked", + "succeeded", + "failed", + "stalled", + "waiting", + "stopped", + }) |state| { + if (std.mem.eql(u8, value, state)) break true; + } else false; +} + +fn decodeLoopState(allocator: std.mem.Allocator, value: JsonValue) ![]u8 { + if (value.isNull()) return allocator.dupe(u8, "idle"); + if (value.container('"') != null) { + const state = try value.duplicateString(allocator, ""); + errdefer allocator.free(state); + if (!isLoopState(state)) return error.UnsupportedLoopState; + return state; + } + const object = value.container('{') orelse return error.MalformedLoopState; + var fields = try JsonFields.init(allocator, object); + defer fields.deinit(); + if (fields.fields.items.len == 0) return allocator.dupe(u8, "idle"); + if (fields.fields.items.len != 1) return error.MalformedLoopState; + const field = fields.fields.items[0]; + if (!isLoopState(field.key) or field.value.container('{') == null) + return error.UnsupportedLoopState; + return allocator.dupe(u8, field.key); +} + fn validateGraphStructure(allocator: std.mem.Allocator, bytes: []const u8) !void { return validateGraphStructureMeasured(allocator, bytes, null); } @@ -1835,7 +1870,7 @@ fn decodeNodes( node.id = try fields.get("id").duplicateString(allocator, ""); node.title = try fields.get("title").duplicateString(allocator, "Untitled"); node.loop_type = try fields.get("loopType").duplicateString(allocator, "turnBased"); - node.state = try fields.get("state").duplicateString(allocator, "idle"); + node.state = try decodeLoopState(allocator, fields.get("state")); node.activity = try fields.get("activity").duplicateString(allocator, ""); const presence = fields.get("presence"); if (presence.container('{')) |reading| { @@ -3626,6 +3661,45 @@ test "production daemon graphChanged associated-value frame decodes project and try std.testing.expectEqualStrings("C:/GraphCode-Fixtures/Core", model.graph.?.project.path); } +test "production LoopState objects decode exhaustively and malformed states preserve the prior graph" { + const allocator = std.testing.allocator; + var nodes = std.array_list.Managed(Node).init(allocator); + defer { + for (nodes.items) |node| freeNode(allocator, node); + nodes.deinit(); + } + try decodeNodes( + allocator, + \\[{"id":"idle","state":{"idle":{}}},{"id":"running","state":{"running":{}}},{"id":"awaiting","state":{"awaitingInput":{}}},{"id":"blocked","state":{"blocked":{}}},{"id":"succeeded","state":{"succeeded":{}}},{"id":"failed","state":{"failed":{}}},{"id":"stalled","state":{"stalled":{}}},{"id":"waiting","state":{"waiting":{}}},{"id":"stopped","state":{"stopped":{}}}] + , + &nodes, + ); + for (nodes.items, [_][]const u8{ + "idle", + "running", + "awaitingInput", + "blocked", + "succeeded", + "failed", + "stalled", + "waiting", + "stopped", + }) |node, expected| try std.testing.expectEqualStrings(expected, node.state); + + var model = Model.init(allocator); + defer model.deinit(); + _ = try model.updateFromFrame( + \\{"graphChanged":{"project":{"path":"C:\\work\\graph","name":"Graph"},"nodes":[{"id":"loop","title":"Loop","state":{"running":{}}}],"edges":[]}} + ); + try std.testing.expectError( + error.UnsupportedLoopState, + model.updateFromFrame( + \\{"graphChanged":{"project":{"path":"C:\\work\\graph","name":"Graph"},"nodes":[{"id":"loop","title":"Loop","state":{"paused":{}}}],"edges":[]}} + ), + ); + try std.testing.expectEqualStrings("running", model.currentGraph().?.nodes.items[0].state); +} + test "field scope keys and delimiters inside scalar text cannot select graph fields" { const frame = \\{"metadata":{"graphChanged":{"project":{"path":"wrong"},"nodes":[],"edges":[]}},"event":{"graphChanged":{"note":"\"nodes\": [{\"id\":\"fake\"}], \"edges\": [{}] } \\ \u2603","nodes":[{"note":"\"id\":\"fake\" } [","id" : "real\u2603","title" : "Brace } quote \" slash \\ \uD83D\uDE80"}],"edges":[],"project":{"note":{"path":"wrong"},"path" : "right","name":"Root"}}}}