diff --git a/canvas/src/components/__tests__/ApprovalBanner.test.tsx b/canvas/src/components/__tests__/ApprovalBanner.test.tsx
index 9d97ef5a..f8cb1133 100644
--- a/canvas/src/components/__tests__/ApprovalBanner.test.tsx
+++ b/canvas/src/components/__tests__/ApprovalBanner.test.tsx
@@ -4,9 +4,14 @@
*
* Covers: renders nothing when no approvals, polls /approvals/pending,
* shows approval cards, approve/deny decisions, toast notifications.
+ *
+ * Note: does NOT mock @/lib/api — uses vi.spyOn on the real module.
+ * vi.restoreAllMocks() is omitted from afterEach so queued mock values
+ * (set up via mockResolvedValueOnce in beforeEach) are preserved for the
+ * component's useEffect to consume.
*/
import React from "react";
-import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
+import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { ApprovalBanner } from "../ApprovalBanner";
import { showToast } from "@/components/Toaster";
@@ -45,13 +50,12 @@ describe("ApprovalBanner — empty state", () => {
});
afterEach(() => {
- vi.restoreAllMocks();
+ cleanup();
vi.useRealTimers();
});
it("renders nothing when there are no pending approvals", async () => {
render();
- // Wait for the initial useEffect + api.get to resolve
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
expect(screen.queryByRole("alert")).toBeNull();
});
@@ -74,7 +78,7 @@ describe("ApprovalBanner — renders approval cards", () => {
});
afterEach(() => {
- vi.restoreAllMocks();
+ cleanup();
vi.useRealTimers();
});
@@ -137,7 +141,6 @@ describe("ApprovalBanner — decisions", () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
- vi.restoreAllMocks();
});
it("calls POST /workspaces/:id/approvals/:id/decide on Approve click", async () => {
@@ -188,7 +191,7 @@ describe("ApprovalBanner — decisions", () => {
});
it("shows an error toast when POST fails", async () => {
- vi.spyOn(api, "post").mockRejectedValue(new Error("Network error"));
+ vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
render();
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
@@ -200,7 +203,8 @@ describe("ApprovalBanner — decisions", () => {
});
it("keeps the card visible when the POST fails", async () => {
- vi.spyOn(api, "post").mockRejectedValue(new Error("Network error"));
+ // Use mockRejectedValueOnce on the same spy as beforeEach (don't call spyOn again)
+ vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
render();
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
@@ -216,7 +220,7 @@ describe("ApprovalBanner — handles empty list from server", () => {
});
afterEach(() => {
- vi.restoreAllMocks();
+ cleanup();
vi.useRealTimers();
});
@@ -225,4 +229,4 @@ describe("ApprovalBanner — handles empty list from server", () => {
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
expect(screen.queryByRole("alert")).toBeNull();
});
-});
\ No newline at end of file
+});
diff --git a/canvas/src/store/__tests__/canvas-topology-pure.test.ts b/canvas/src/store/__tests__/canvas-topology-pure.test.ts
index 18a3830b..bf72a016 100644
--- a/canvas/src/store/__tests__/canvas-topology-pure.test.ts
+++ b/canvas/src/store/__tests__/canvas-topology-pure.test.ts
@@ -98,6 +98,19 @@ describe("sortParentsBeforeChildren", () => {
const result = sortParentsBeforeChildren(nodes);
expect(result.map((n) => n.id)).toEqual(["root", "orphan"]);
});
+
+ it("places roots first, valid children second, orphans last", () => {
+ // Orphan has an invalid parentId; valid child has a real parent.
+ // All three groups should appear in that order.
+ const nodes = [
+ { id: "orphan", parentId: "ghost" },
+ { id: "root", parentId: undefined },
+ { id: "child", parentId: "root" },
+ ];
+ const ids = sortParentsBeforeChildren(nodes).map((n) => n.id);
+ expect(ids.indexOf("root")).toBeLessThan(ids.indexOf("child"));
+ expect(ids.indexOf("child")).toBeLessThan(ids.indexOf("orphan"));
+ });
});
// ─── defaultChildSlot ─────────────────────────────────────────────────────────
diff --git a/canvas/src/store/canvas-topology.ts b/canvas/src/store/canvas-topology.ts
index 0fac72a5..12a1cc45 100644
--- a/canvas/src/store/canvas-topology.ts
+++ b/canvas/src/store/canvas-topology.ts
@@ -35,11 +35,19 @@ export function sortParentsBeforeChildren !n.parentId);
- const nonRoots = out.filter((n) => n.parentId !== undefined);
- return [...roots, ...nonRoots];
+ const children = out.filter(
+ (n) => n.parentId !== undefined && byId.has(n.parentId),
+ );
+ const orphans = out.filter(
+ (n) => n.parentId !== undefined && !byId.has(n.parentId),
+ );
+ return [...roots, ...children, ...orphans];
}
// Grid-slot defaults for children laid under a parent. The card