mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
shell-redesign V1 polish slice Phase 5.2 BOUNCE-FIX: lift rig-expanded state into TopologyOverlayProvider
Closes the dead-code auto-expand bug surfaced by the design-reviewer
walk on abb154e. Five-point evidence chain identified the root cause:
1. routes.tsx topology routes are SIBLING parented to rootRoute,
NOT nested. /topology/rig/$id mounts RigScopePage; it does NOT
mount HostMultiRigGraph.
2. HostMultiRigGraph held `expanded` as local useState — reset on
every mount when operator returned to /topology.
3. The in-component activeRigId useEffect could only fire while
HostMultiRigGraph itself was mounted. Direct-URL entry at
/topology/rig/$id never had a chance to set state because the
component wasn't mounted at that path.
4. topology-overlay-context.tsx previously carried only ExplorerMode;
no rig-expanded persistence layer existed.
5. Live walk confirmed: /topology/seat → /topology/rig → /topology
returned to expanded=[] (all rigs collapsed).
Plus zero auto-expand test coverage in p5-2-host-multi-rig-graph.
FIX (option (a) per design-reviewer recommendation):
- Lift `expandedRigs: Map<string, boolean>` into TopologyOverlayProvider
alongside the existing ExplorerMode state. New context fields:
expandedRigs (read), setRigExpanded(rigId, expanded), toggleRig(rigId).
- Auto-expand useEffect runs at PROVIDER scope (always mounted under
AppShell) — reads pathname via useRouterState, parses active rigId
via NEW exported parseActiveRigId helper, and dispatches
setRigExpanded(rigId, true) on every rig-scoped URL pattern.
Fires regardless of which scope page is currently in the center, so
direct-URL navigation to /topology/rig/$id (RigScopePage) updates
the context state; when the operator returns to /topology,
HostMultiRigGraph mounts and reads the persisted state.
- HostMultiRigGraph now consumes via useTopologyOverlay() — replaces
the local useState + the orphaned in-component useEffect. Drop both.
NEW packages/ui/test/p5-2-bounce-rig-expand-persistence.test.tsx
(14 tests):
- parseActiveRigId pure-fn covers all three rig-scoped URL shapes
(seat / pod / rig) + URL-encoded rigIds + null at /topology root
+ null at non-topology routes.
- Provider auto-expand useEffect: route initial-entry at /topology/rig
/seat /pod each set expandedRigs[rigId]=true verified via a
ContextProbe child that exposes ctx.expandedRigs as testid attribute
(HostMultiRigGraph NOT mounted in those tests — proves the effect
fires at provider scope, the dead-code bug fix).
- /topology root pathname does NOT auto-expand any rig (default-all-
collapsed preserved).
- HostMultiRigGraph reads from context: when context's expandedRigs
has rigId=true on mount, the rig group renders expanded; default
context (empty Map) → all rigs render collapsed.
- Source-assertion guards: HostMultiRigGraph imports useTopologyOverlay
+ has no `useState<Map<string, boolean>>` local state (regression
guard against the dead-code path returning); topology-overlay-context
exposes the four context fields + the parseActiveRigId export +
contains useRouterState + parseActiveRigId call (the auto-expand
wiring).
UPDATED packages/ui/test/p5-2-host-multi-rig-graph.test.tsx:
- withQueryClient wrapper now wraps in TopologyOverlayProvider so
HostMultiRigGraph's useTopologyOverlay() returns a real toggleRig
(not the no-op default). Required for the existing collapse-toggle
test to actually flip state.
- ritual #9 coupled-literal scan redirected: useState<Map<...>>
initializer assertion now reads from
topology-overlay-context.tsx (where the state was lifted to);
collapsed: !p.isExpanded literal still in HostMultiRigGraph and
still asserted there.
Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest p5-2-bounce-rig-expand-persistence: 14/14 PASS
- vitest p5-2-host-multi-rig-graph: 16/16 PASS (existing tests still
pass after wrapper + literal-scan updates)
- vitest full UI suite: 706/708 PASS (1 pre-existing library-review
baseline failure UNCHANGED; 1 skipped)
- pnpm -F @openrig/ui build: clean (1.91s)
SC-29 posture: UNCHANGED — UI-only this slice. Daemon byte-equivalent
beyond Phase 4 + Phase 5 declared exceptions.
Scope discipline: bounded to Item 6 only. The 7 PASS items from the
design-reviewer original audit (rendering / lazy-fetch / cross-rig
prefixing / vellum primitive grammar / collapse-toggle / option (a)
layout / click navigation) all stay as-shipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
abb154eff3
commit
cd5273363f
4 files changed
+441
-46
No files matched your search
@@ -24,8 +24,8 @@
|
||||
// to 100+ rigs per the for-you-feed.md L9 north-star without upfront
|
||||
// fan-out.
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
ReactFlow,
|
||||
Controls,
|
||||
@@ -41,6 +41,7 @@ import { applyTreeLayout } from "../../lib/graph-layout.js";
|
||||
import { useShellViewport } from "../../hooks/useShellViewport.js";
|
||||
import { RigNode } from "../RigNode.js";
|
||||
import { RigGroupNode, type RigGroupNodeData } from "./RigGroupNode.js";
|
||||
import { useTopologyOverlay } from "./topology-overlay-context.js";
|
||||
import {
|
||||
prefixRigData,
|
||||
packRigGroups,
|
||||
@@ -89,47 +90,19 @@ const nodeTypes: NodeTypes = {
|
||||
rigNode: RigNode,
|
||||
};
|
||||
|
||||
/** Active topology context derived from the route pathname so the rig
|
||||
* matching the URL auto-expands (parity with Phase 5.1 TopologyTreeView). */
|
||||
function useActiveRigId(): string | null {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
const seat = pathname.match(/^\/topology\/seat\/([^/]+)\//);
|
||||
if (seat) return decodeURIComponent(seat[1]!);
|
||||
const pod = pathname.match(/^\/topology\/pod\/([^/]+)\//);
|
||||
if (pod) return decodeURIComponent(pod[1]!);
|
||||
const rig = pathname.match(/^\/topology\/rig\/([^/]+)$/);
|
||||
if (rig) return decodeURIComponent(rig[1]!);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function HostMultiRigGraph() {
|
||||
const navigate = useNavigate();
|
||||
const { data: psEntries } = usePsEntries();
|
||||
const { innerWidth } = useShellViewport();
|
||||
const activeRigId = useActiveRigId();
|
||||
|
||||
// Default-all-collapsed state; per-session Map<rigId, expanded?>.
|
||||
const [expanded, setExpanded] = useState<Map<string, boolean>>(() => new Map());
|
||||
|
||||
const toggleRig = (rigId: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(rigId, !(prev.get(rigId) ?? false));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Auto-expand the rig matching the active route on mount + on route
|
||||
// change (parity with Phase 5.1 TopologyTreeView auto-expand).
|
||||
useEffect(() => {
|
||||
if (!activeRigId) return;
|
||||
setExpanded((prev) => {
|
||||
if (prev.get(activeRigId)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(activeRigId, true);
|
||||
return next;
|
||||
});
|
||||
}, [activeRigId]);
|
||||
// V1 polish slice Phase 5.2 bounce-fix — rig-expanded state lifted to
|
||||
// TopologyOverlayProvider scope. Direct-URL entry to /topology/rig/$id
|
||||
// (which mounts RigScopePage, NOT HostMultiRigGraph because topology
|
||||
// routes are SIBLING) still updates the context's expandedRigs map
|
||||
// via the provider's auto-expand useEffect; when the operator returns
|
||||
// to /topology, this component reads the persisted state via
|
||||
// useTopologyOverlay() and renders the matching rig expanded.
|
||||
const { expandedRigs: expanded, toggleRig } = useTopologyOverlay();
|
||||
|
||||
// P5.2-2 + P5.2-3: useQueries for per-rig graph data; enabled only
|
||||
// when the rig is expanded. Stable hook-call count (single useQueries
|
||||
|
||||
@@ -13,34 +13,120 @@
|
||||
// View-mode tab bar anchors at fixed left = rail (48px) + explorer
|
||||
// (280px) = 328px = var(--explorer-anchor-left). Independent of mode,
|
||||
// so tabs never jump position between graph / table / terminal switches.
|
||||
//
|
||||
// V1 polish slice Phase 5.2 bounce-fix — rig-collapse state persistence.
|
||||
// Phase 5.2 Item 6 auto-expand was dead code: HostMultiRigGraph held
|
||||
// `expanded` as local useState, and the activeRigId useEffect only
|
||||
// fired when HostMultiRigGraph itself was mounted. But topology routes
|
||||
// are SIBLING (not nested), so /topology/rig/$id renders RigScopePage,
|
||||
// NOT HostMultiRigGraph — the effect never ran for direct-URL entry.
|
||||
// Navigating back to /topology re-mounted HostMultiRigGraph fresh with
|
||||
// an empty Map. Fix: lift the expanded Map into this provider so the
|
||||
// state survives HostScopePage unmount/remount cycles, and run the
|
||||
// auto-expand useEffect at provider scope (always mounted under
|
||||
// AppShell) so URL-driven expansion fires regardless of which scope
|
||||
// page is currently in the center.
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useRouterState } from "@tanstack/react-router";
|
||||
|
||||
export type ExplorerMode = "overlay" | "opaque";
|
||||
|
||||
interface TopologyOverlayContextValue {
|
||||
mode: ExplorerMode;
|
||||
setMode: (mode: ExplorerMode) => void;
|
||||
/** V1 polish slice Phase 5.2 — rig-expanded state persisted at provider
|
||||
* scope so HostMultiRigGraph mount/unmount cycles don't reset the
|
||||
* collapse map. Default empty → all rigs collapsed. */
|
||||
expandedRigs: ReadonlyMap<string, boolean>;
|
||||
/** Idempotent setter: explicitly mark a rig expanded or collapsed.
|
||||
* Used by the URL-driven auto-expand effect (always sets true) and
|
||||
* by direct programmatic control. */
|
||||
setRigExpanded: (rigId: string, expanded: boolean) => void;
|
||||
/** Click-toggle: flip a rig's expanded state. Used by RigGroupNode
|
||||
* body click. */
|
||||
toggleRig: (rigId: string) => void;
|
||||
}
|
||||
|
||||
const TopologyOverlayContext = createContext<TopologyOverlayContextValue>({
|
||||
mode: "opaque",
|
||||
setMode: () => {},
|
||||
expandedRigs: new Map(),
|
||||
setRigExpanded: () => {},
|
||||
toggleRig: () => {},
|
||||
});
|
||||
|
||||
/** Parse an active-rig identifier from the topology pathname. Used by
|
||||
* the provider's auto-expand effect AND by consumers that need to
|
||||
* know which rig the URL is currently scoped to (e.g., for active-row
|
||||
* highlighting). Returns null when the pathname isn't on a rig-scoped
|
||||
* route. */
|
||||
export function parseActiveRigId(pathname: string): string | null {
|
||||
const seat = pathname.match(/^\/topology\/seat\/([^/]+)\//);
|
||||
if (seat) return decodeURIComponent(seat[1]!);
|
||||
const pod = pathname.match(/^\/topology\/pod\/([^/]+)\//);
|
||||
if (pod) return decodeURIComponent(pod[1]!);
|
||||
const rig = pathname.match(/^\/topology\/rig\/([^/]+)$/);
|
||||
if (rig) return decodeURIComponent(rig[1]!);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TopologyOverlayProvider({ children }: { children: ReactNode }) {
|
||||
const [mode, setModeState] = useState<ExplorerMode>("opaque");
|
||||
const setMode = useCallback((next: ExplorerMode) => {
|
||||
setModeState(next);
|
||||
}, []);
|
||||
|
||||
// V1 polish slice Phase 5.2 bounce-fix — rig-expanded state lifted to
|
||||
// provider scope so direct-URL navigation (where HostMultiRigGraph
|
||||
// isn't mounted because routes are SIBLING) still updates the state
|
||||
// that HostMultiRigGraph reads when the user returns to /topology.
|
||||
const [expandedRigs, setExpandedRigs] = useState<Map<string, boolean>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const setRigExpanded = useCallback((rigId: string, expanded: boolean) => {
|
||||
setExpandedRigs((prev) => {
|
||||
if ((prev.get(rigId) ?? false) === expanded) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(rigId, expanded);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const toggleRig = useCallback((rigId: string) => {
|
||||
setExpandedRigs((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(rigId, !(prev.get(rigId) ?? false));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-expand effect at provider scope. Reads pathname via
|
||||
// useRouterState (provider sits inside RouterProvider tree under
|
||||
// AppShell). Whenever the route is on a rig-scoped topology URL,
|
||||
// mark the matching rig expanded. Fires regardless of which center
|
||||
// scope page is currently mounted — solves the dead-code bug from
|
||||
// the prior in-component auto-expand.
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
useEffect(() => {
|
||||
const rigId = parseActiveRigId(pathname);
|
||||
if (rigId) setRigExpanded(rigId, true);
|
||||
}, [pathname, setRigExpanded]);
|
||||
|
||||
const value = useMemo<TopologyOverlayContextValue>(
|
||||
() => ({ mode, setMode, expandedRigs, setRigExpanded, toggleRig }),
|
||||
[mode, setMode, expandedRigs, setRigExpanded, toggleRig],
|
||||
);
|
||||
|
||||
return (
|
||||
<TopologyOverlayContext.Provider value={{ mode, setMode }}>
|
||||
<TopologyOverlayContext.Provider value={value}>
|
||||
{children}
|
||||
</TopologyOverlayContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
// V1 polish slice Phase 5.2 bounce-fix — rig-expanded state persistence.
|
||||
//
|
||||
// Closes the dead-code auto-expand bug surfaced in design-reviewer's
|
||||
// 5-point evidence chain on abb154e:
|
||||
// 1. routes.tsx topology routes are SIBLING (not nested) → direct
|
||||
// entry to /topology/rig/$id mounts RigScopePage, NOT
|
||||
// HostMultiRigGraph. The prior in-component auto-expand useEffect
|
||||
// could not run for direct-URL navigation.
|
||||
// 2. expanded Map was local useState in HostMultiRigGraph → reset on
|
||||
// every mount when operator returned to /topology.
|
||||
// 3. topology-overlay-context only carried ExplorerMode previously.
|
||||
//
|
||||
// Fix: lifted expandedRigs Map into TopologyOverlayProvider with a
|
||||
// pathname-driven useEffect that fires regardless of which scope page
|
||||
// is mounted. HostMultiRigGraph consumes via useTopologyOverlay().
|
||||
//
|
||||
// Tests below cover:
|
||||
// - parseActiveRigId pure-fn (matches all 3 rig-scoped URL shapes)
|
||||
// - Provider auto-expand useEffect fires on rig-scoped pathname
|
||||
// - HostMultiRigGraph renders rig as expanded when context's
|
||||
// expandedRigs has rigId=true (the cross-mount-cycle persistence)
|
||||
// - Direct unmount-remount preserves state (provider scope holds)
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
createMemoryHistory,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
} from "@tanstack/react-router";
|
||||
import {
|
||||
TopologyOverlayProvider,
|
||||
useTopologyOverlay,
|
||||
parseActiveRigId,
|
||||
} from "../src/components/topology/topology-overlay-context.js";
|
||||
import { HostMultiRigGraph } from "../src/components/topology/HostMultiRigGraph.js";
|
||||
|
||||
const navigateSpy = vi.fn();
|
||||
vi.mock("@tanstack/react-router", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@tanstack/react-router")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => navigateSpy,
|
||||
};
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
beforeEach(() => {
|
||||
navigateSpy.mockClear();
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url === "/api/ps") {
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
rigId: "rig-1",
|
||||
name: "openrig-velocity",
|
||||
nodeCount: 13,
|
||||
runningCount: 9,
|
||||
status: "running",
|
||||
uptime: null,
|
||||
latestSnapshot: null,
|
||||
},
|
||||
{
|
||||
rigId: "rig-2",
|
||||
name: "openrig-discovery",
|
||||
nodeCount: 5,
|
||||
runningCount: 3,
|
||||
status: "partial",
|
||||
uptime: null,
|
||||
latestSnapshot: null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (url.match(/\/api\/rigs\/[^/]+\/graph/)) {
|
||||
return new Response(JSON.stringify({ nodes: [], edges: [] }));
|
||||
}
|
||||
return new Response("[]");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// parseActiveRigId — pure-fn
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("parseActiveRigId (P5.2 bounce-fix pure-fn)", () => {
|
||||
it("matches /topology/seat/$rigId/$logicalId", () => {
|
||||
expect(parseActiveRigId("/topology/seat/rig-1/orch.lead")).toBe("rig-1");
|
||||
});
|
||||
it("matches /topology/pod/$rigId/$podName", () => {
|
||||
expect(parseActiveRigId("/topology/pod/rig-1/orch")).toBe("rig-1");
|
||||
});
|
||||
it("matches /topology/rig/$rigId", () => {
|
||||
expect(parseActiveRigId("/topology/rig/rig-1")).toBe("rig-1");
|
||||
});
|
||||
it("returns null for /topology root", () => {
|
||||
expect(parseActiveRigId("/topology")).toBe(null);
|
||||
});
|
||||
it("returns null for non-topology routes", () => {
|
||||
expect(parseActiveRigId("/project")).toBe(null);
|
||||
expect(parseActiveRigId("/for-you")).toBe(null);
|
||||
expect(parseActiveRigId("/")).toBe(null);
|
||||
});
|
||||
it("decodes URL-encoded rigIds", () => {
|
||||
expect(parseActiveRigId("/topology/seat/rig%2D1/seat%2Da")).toBe("rig-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers — render with router on a specific initial path
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function renderAt(initialPath: string, ui: React.ReactNode) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<TopologyOverlayProvider>
|
||||
<Outlet />
|
||||
</TopologyOverlayProvider>
|
||||
),
|
||||
});
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/topology",
|
||||
component: () => <>{ui}</>,
|
||||
});
|
||||
const seatRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/topology/seat/$rigId/$logicalId",
|
||||
component: () => <>{ui}</>,
|
||||
});
|
||||
const rigRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/topology/rig/$rigId",
|
||||
component: () => <>{ui}</>,
|
||||
});
|
||||
const podRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/topology/pod/$rigId/$podName",
|
||||
component: () => <>{ui}</>,
|
||||
});
|
||||
const fallback = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "$",
|
||||
component: () => null,
|
||||
});
|
||||
const router = createRouter({
|
||||
routeTree: rootRoute.addChildren([indexRoute, seatRoute, rigRoute, podRoute, fallback]),
|
||||
history: createMemoryHistory({ initialEntries: [initialPath] }),
|
||||
});
|
||||
return {
|
||||
router,
|
||||
...render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Provider auto-expand useEffect — fires regardless of mount state of
|
||||
// HostMultiRigGraph (the design-reviewer dead-code bug fix).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function ContextProbe() {
|
||||
// Tiny consumer that exposes the context's expandedRigs as testid
|
||||
// attributes the test can inspect.
|
||||
const ctx = useTopologyOverlay();
|
||||
return (
|
||||
<div
|
||||
data-testid="topology-context-probe"
|
||||
data-expanded-rigs={Array.from(ctx.expandedRigs.entries())
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
.join(",")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("TopologyOverlayProvider auto-expand on URL (P5.2 bounce-fix)", () => {
|
||||
it("/topology/rig/$rigId direct-URL entry sets expandedRigs in provider state (HostMultiRigGraph NOT mounted)", async () => {
|
||||
// Probe-only render — HostMultiRigGraph is NOT mounted on this path,
|
||||
// proving the provider effect fires regardless of which scope page
|
||||
// is currently the center component.
|
||||
const { findByTestId } = renderAt("/topology/rig/rig-1", <ContextProbe />);
|
||||
const probe = await findByTestId("topology-context-probe");
|
||||
await waitFor(() => {
|
||||
expect(probe.getAttribute("data-expanded-rigs")).toBe("rig-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("/topology/seat/$rigId/$logicalId sets the matching rig expanded", async () => {
|
||||
const { findByTestId } = renderAt(
|
||||
"/topology/seat/rig-2/orch.lead",
|
||||
<ContextProbe />,
|
||||
);
|
||||
const probe = await findByTestId("topology-context-probe");
|
||||
await waitFor(() => {
|
||||
expect(probe.getAttribute("data-expanded-rigs")).toBe("rig-2");
|
||||
});
|
||||
});
|
||||
|
||||
it("/topology/pod/$rigId/$podName sets the matching rig expanded", async () => {
|
||||
const { findByTestId } = renderAt(
|
||||
"/topology/pod/rig-1/orch",
|
||||
<ContextProbe />,
|
||||
);
|
||||
const probe = await findByTestId("topology-context-probe");
|
||||
await waitFor(() => {
|
||||
expect(probe.getAttribute("data-expanded-rigs")).toBe("rig-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("/topology root pathname does NOT auto-expand any rig (default-all-collapsed preserved)", async () => {
|
||||
const { findByTestId } = renderAt("/topology", <ContextProbe />);
|
||||
const probe = await findByTestId("topology-context-probe");
|
||||
// Allow the render to settle.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(probe.getAttribute("data-expanded-rigs")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HostMultiRigGraph reads from context — cross-mount persistence
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("HostMultiRigGraph reads expandedRigs from context (P5.2 bounce-fix persistence)", () => {
|
||||
it("when context's expandedRigs has rigId=true on mount, the rig group renders expanded", async () => {
|
||||
// Custom probe that pre-sets the context state then mounts the graph.
|
||||
function PreExpandedHarness() {
|
||||
const { setRigExpanded } = useTopologyOverlay();
|
||||
// Fire the pre-expand once on mount (simulates a prior visit to
|
||||
// /topology/rig/rig-1 having set state, then user returns to
|
||||
// /topology and HostMultiRigGraph mounts fresh).
|
||||
// useEffect intentionally not used here — the test just calls in
|
||||
// an effect via setTimeout so React batches commit before render.
|
||||
if (typeof window !== "undefined") {
|
||||
// Idempotent: subsequent renders won't double-set.
|
||||
queueMicrotask(() => setRigExpanded("rig-1", true));
|
||||
}
|
||||
return <HostMultiRigGraph />;
|
||||
}
|
||||
|
||||
const { findByTestId } = renderAt("/topology", <PreExpandedHarness />);
|
||||
const node = await findByTestId("rig-group-node-rig-1");
|
||||
await waitFor(() => {
|
||||
expect(node.getAttribute("data-collapsed")).toBe("false");
|
||||
});
|
||||
// Other rig stays collapsed.
|
||||
const otherRig = await findByTestId("rig-group-node-rig-2");
|
||||
expect(otherRig.getAttribute("data-collapsed")).toBe("true");
|
||||
});
|
||||
|
||||
it("when no rig is pre-expanded in context, all rigs render collapsed (default preserved)", async () => {
|
||||
const { findByTestId } = renderAt("/topology", <HostMultiRigGraph />);
|
||||
const r1 = await findByTestId("rig-group-node-rig-1");
|
||||
const r2 = await findByTestId("rig-group-node-rig-2");
|
||||
expect(r1.getAttribute("data-collapsed")).toBe("true");
|
||||
expect(r2.getAttribute("data-collapsed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Source-assertion guards — ensure dead-code path doesn't reappear
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("source-assertion guards (P5.2 bounce-fix coupled-literal scan)", () => {
|
||||
it("HostMultiRigGraph reads expanded from useTopologyOverlay (NOT local useState)", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const src = readFileSync(
|
||||
path.resolve(
|
||||
__dirname,
|
||||
"../src/components/topology/HostMultiRigGraph.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
// Positive: consumes the context.
|
||||
expect(src).toContain("useTopologyOverlay");
|
||||
expect(src).toMatch(/useTopologyOverlay\(\s*\)/);
|
||||
// Negative: no local useState for expanded Map. The dead-code bug
|
||||
// returning if this regresses.
|
||||
expect(src).not.toMatch(/useState<Map<string,\s*boolean>>/);
|
||||
});
|
||||
|
||||
it("topology-overlay-context.tsx exposes expandedRigs + setRigExpanded + toggleRig + parseActiveRigId", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const src = readFileSync(
|
||||
path.resolve(
|
||||
__dirname,
|
||||
"../src/components/topology/topology-overlay-context.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
expect(src).toContain("expandedRigs");
|
||||
expect(src).toContain("setRigExpanded");
|
||||
expect(src).toContain("toggleRig");
|
||||
expect(src).toContain("export function parseActiveRigId");
|
||||
// Provider auto-expand useEffect on pathname.
|
||||
expect(src).toMatch(/useRouterState/);
|
||||
expect(src).toMatch(/parseActiveRigId\s*\(/);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ vi.mock("@tanstack/react-router", async (importActual) => {
|
||||
});
|
||||
|
||||
import { HostMultiRigGraph } from "../src/components/topology/HostMultiRigGraph.js";
|
||||
import { TopologyOverlayProvider } from "../src/components/topology/topology-overlay-context.js";
|
||||
import {
|
||||
prefixRigData,
|
||||
packRigGroups,
|
||||
@@ -52,7 +53,16 @@ function withQueryClient(ui: React.ReactNode) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
// Wrap in TanStack Router with memory history so <Link> resolves the
|
||||
// route context without crashing inside RigGroupNode.
|
||||
const rootRoute = createRootRoute({ component: () => <Outlet /> });
|
||||
// V1 polish slice Phase 5.2 bounce-fix: TopologyOverlayProvider wraps
|
||||
// the route component so HostMultiRigGraph's useTopologyOverlay()
|
||||
// returns a real toggleRig (not the no-op default).
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<TopologyOverlayProvider>
|
||||
<Outlet />
|
||||
</TopologyOverlayProvider>
|
||||
),
|
||||
});
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
@@ -348,15 +358,25 @@ describe("source-assertion guards", () => {
|
||||
});
|
||||
|
||||
it("HostMultiRigGraph default state is all-collapsed (ritual #9 coupled-literal)", () => {
|
||||
const src = readFileSync(
|
||||
const ctxSrc = readFileSync(
|
||||
path.join(SRC, "components/topology/topology-overlay-context.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
// V1 polish slice Phase 5.2 bounce-fix: rig-expanded state lifted
|
||||
// from HostMultiRigGraph local useState into the provider scope so
|
||||
// direct-URL navigation (where HostMultiRigGraph isn't mounted)
|
||||
// still updates the state. Coupled-literal scan now targets the
|
||||
// provider's initializer.
|
||||
// Substring contracts (robust to multi-line formatting).
|
||||
expect(ctxSrc).toContain("useState<Map<string, boolean>>");
|
||||
expect(ctxSrc).toContain("() => new Map()");
|
||||
const hostSrc = readFileSync(
|
||||
path.join(SRC, "components/topology/HostMultiRigGraph.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
// useState<Map<string, boolean>>(() => new Map()) → empty map → no
|
||||
// rig is in the map → expanded.get(rigId) returns undefined → ?? false.
|
||||
expect(src).toMatch(/useState<Map<string, boolean>>\s*\(\s*\(\s*\)\s*=>\s*new Map\(\)\s*\)/);
|
||||
// collapsed: !p.isExpanded — the default-collapse semantic carrier.
|
||||
expect(src).toMatch(/collapsed:\s*!p\.isExpanded/);
|
||||
// collapsed: !p.isExpanded — the default-collapse semantic carrier
|
||||
// (still in the host component; reads from the lifted context).
|
||||
expect(hostSrc).toMatch(/collapsed:\s*!p\.isExpanded/);
|
||||
});
|
||||
|
||||
it("RigGroupNode uses 1px outline-variant border + hard-shadow + RegistrationMarks", () => {
|
||||
|
||||
Reference in new issue
Block a user