test(catalyst-ui): Architecture force-graph render lock-in (15 cases)

P2 of openova-io/openova#309. Rewrites Architecture.test.tsx to
match the new force-directed canvas — the legacy SVG-layered
assertions (depth labels, zoom-on-click, data-dim toggles) were
retired with the layout itself.

15 cases covering:
  - Empty state when the tree has no nodes
  - Force-graph mounts; node groups for every type render with
    composite ids (arch-graph-node-{type}-{compositeId})
  - Edge legend lists every relation type
  - Live nodes/edges stats overlay
  - Search box debounces, then shows the "X matches" counter
  - Node click opens detail panel with type label
  - Detail panel lists neighbors with drill-in
  - Detail panel close button works
  - Right-click on node opens context menu with kind-aware items
    (Cluster context exposes add-vcluster + add-nodepool + delete)
  - Right-click on canvas exposes "Add region"
  - Global density slider exists at default 50%
  - Per-type badges render for all 8 types
  - CRUD modals (AddCluster, AddVCluster, AddRegion) still mount
    via the new wiring

All 15 pass. Full suite: 512/512 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hatiyildiz 2026-04-30 21:52:10 +02:00 committed by e3mrah
parent d17ae7c7de
commit 1d172b235a

View File

@ -1,20 +1,26 @@
/**
* Architecture.test.tsx render lock-in for the Sovereign Cloud /
* Architecture sub-page hierarchical canvas (issue #309 supersedes
* #228).
* Architecture sub-page force-directed canvas (P2 of #309).
*
* The legacy SVG-layered tests (depth labels, zoom-on-click,
* data-dim toggles) have been retired with the layout itself. The
* new coverage:
*
* Coverage:
* 1. Empty state shows when the tree has no nodes.
* 2. With the synthetic fixture, the SVG canvas mounts with all 4
* depths (Cloud Region Cluster vCluster) rendered.
* 3. Clicking a node opens the right-side InfrastructureDetailPanel.
* 4. Closing the panel removes it from the DOM.
* 5. Clicking a cluster node sets zoom state and brightens its
* vClusters (data-dim="false").
* 2. With the synthetic fixture, the force-graph mounts and renders
* a node per type with composite ids.
* 3. Edge legend lists relations.
* 4. Search isolates matches + neighbors and shows the counter.
* 5. Clicking a node opens the right-side detail panel + neighbor list.
* 6. Right-clicking a node opens the context menu with kind-aware items.
* 7. Right-clicking the canvas surface offers "Add region".
* 8. Density slider for a tunable type renders.
* 9. CRUD modals (Add cluster / Add vCluster) still mount via the
* detail panel same testids the legacy tests asserted.
*/
import { describe, it, expect, afterEach } from 'vitest'
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
RouterProvider,
@ -32,7 +38,7 @@ import type { HierarchicalInfrastructure } from '@/lib/infrastructure.types'
import { useWizardStore } from '@/entities/deployment/store'
import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model'
function renderTopologyPage(data: HierarchicalInfrastructure) {
function renderArchitecturePage(data: HierarchicalInfrastructure) {
useWizardStore.setState({ ...INITIAL_WIZARD_STATE })
globalThis.fetch = (() =>
Promise.resolve({
@ -44,7 +50,9 @@ function renderTopologyPage(data: HierarchicalInfrastructure) {
const cloudRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/cloud',
component: () => <CloudPage disableStream initialDataOverride={data} deploymentsOverride={[]} />,
component: () => (
<CloudPage disableStream initialDataOverride={data} deploymentsOverride={[]} />
),
})
const architectureRoute = createRoute({
getParentRoute: () => cloudRoute,
@ -77,83 +85,179 @@ describe('Architecture — empty', () => {
topology: { pattern: 'solo', regions: [] },
storage: { pvcs: [], buckets: [], volumes: [] },
}
renderTopologyPage(empty)
renderArchitecturePage(empty)
expect(await screen.findByTestId('cloud-architecture-empty')).toBeTruthy()
})
})
describe('Architecture — hierarchical render', () => {
it('renders the canvas with all 4 depths', async () => {
renderTopologyPage(infrastructureTopologyFixture)
expect(await screen.findByTestId('cloud-architecture-svg')).toBeTruthy()
describe('Architecture — force graph render', () => {
it('renders the canvas + node groups for every type in the fixture', async () => {
renderArchitecturePage(infrastructureTopologyFixture)
expect(await screen.findByTestId('arch-graph-canvas')).toBeTruthy()
expect(screen.getByTestId('arch-graph-svg')).toBeTruthy()
// Depth 0 — cloud
expect(screen.getByTestId('cloud-node-cloud-hetzner')).toBeTruthy()
// Depth 1 — region
expect(screen.getByTestId('cloud-node-region-eu-central')).toBeTruthy()
// Depth 2 — cluster
expect(screen.getByTestId('cloud-node-cluster-eu-central-primary')).toBeTruthy()
// Depth 3 — vcluster
expect(screen.getByTestId('cloud-node-vc-eu-central-dmz')).toBeTruthy()
// Composite ids: ${type}:${elementId}
expect(screen.getByTestId('arch-graph-node-Cloud-Cloud:cloud-hetzner')).toBeTruthy()
expect(screen.getByTestId('arch-graph-node-Region-Region:region-eu-central')).toBeTruthy()
expect(
screen.getByTestId('arch-graph-node-Cluster-Cluster:cluster-eu-central-primary'),
).toBeTruthy()
expect(screen.getByTestId('arch-graph-node-vCluster-vCluster:vc-eu-central-dmz')).toBeTruthy()
expect(screen.getByTestId('arch-graph-node-WorkerNode-WorkerNode:node-eu-cp-0')).toBeTruthy()
expect(
screen.getByTestId('arch-graph-node-LoadBalancer-LoadBalancer:lb-eu-central-edge'),
).toBeTruthy()
expect(screen.getByTestId('arch-graph-node-Network-Network:net-eu-central')).toBeTruthy()
})
it('renders edges between parent and child', async () => {
renderTopologyPage(infrastructureTopologyFixture)
await screen.findByTestId('cloud-architecture-svg')
expect(screen.getByTestId('cloud-edge-cloud-hetzner-region-eu-central')).toBeTruthy()
expect(screen.getByTestId('cloud-edge-region-eu-central-cluster-eu-central-primary')).toBeTruthy()
it('renders the edge legend with every relation type', async () => {
renderArchitecturePage(infrastructureTopologyFixture)
await screen.findByTestId('arch-graph-svg')
expect(screen.getByTestId('cloud-architecture-edge-legend')).toBeTruthy()
expect(screen.getByTestId('cloud-architecture-edge-legend-contains')).toBeTruthy()
expect(screen.getByTestId('cloud-architecture-edge-legend-runs-on')).toBeTruthy()
expect(screen.getByTestId('cloud-architecture-edge-legend-routes-to')).toBeTruthy()
expect(screen.getByTestId('cloud-architecture-edge-legend-attached-to')).toBeTruthy()
})
it('opens the right-side detail panel on node click and closes it on dismiss', async () => {
renderTopologyPage(infrastructureTopologyFixture)
const node = await screen.findByTestId('cloud-node-cluster-eu-central-primary')
it('shows the live nodes/edges stats overlay', async () => {
renderArchitecturePage(infrastructureTopologyFixture)
await screen.findByTestId('arch-graph-svg')
expect(screen.getByTestId('arch-graph-stats-nodes')).toBeTruthy()
expect(screen.getByTestId('arch-graph-stats-edges')).toBeTruthy()
})
})
describe('Architecture — search isolation', () => {
it('isolates matches + neighbors when typing in the search box', async () => {
renderArchitecturePage(infrastructureTopologyFixture)
await screen.findByTestId('arch-graph-svg')
const search = screen.getByTestId('cloud-architecture-search') as HTMLInputElement
fireEvent.change(search, { target: { value: 'omantel-primary' } })
// Counter shows up after the 250ms debounce; we advance via Vitest
// fake timers OR simply test the rendered counter element exists
// once the value has been applied. React Testing Library defers
// by-state updates to next tick, so we wait for the counter to
// appear.
const counter = await screen.findByTestId('cloud-architecture-search-counter')
expect(counter.textContent).toMatch(/matches/)
})
})
describe('Architecture — detail panel', () => {
it('opens the panel on node click and shows the type label', async () => {
renderArchitecturePage(infrastructureTopologyFixture)
const node = await screen.findByTestId(
'arch-graph-node-Cluster-Cluster:cluster-eu-central-primary',
)
expect(screen.queryByTestId('infrastructure-detail-panel')).toBeNull()
fireEvent.click(node)
expect(screen.getByTestId('infrastructure-detail-panel')).toBeTruthy()
expect(screen.getByTestId('infrastructure-detail-panel-name').textContent).toBe('omantel-primary')
expect(screen.getByTestId('infrastructure-detail-panel-name').textContent).toBe(
'omantel-primary',
)
expect(screen.getByTestId('infrastructure-detail-panel-type').textContent).toBe('Cluster')
})
it('lists neighbors and lets the operator drill into one', async () => {
renderArchitecturePage(infrastructureTopologyFixture)
fireEvent.click(
await screen.findByTestId('arch-graph-node-Cluster-Cluster:cluster-eu-central-primary'),
)
const neighbors = screen.getByTestId('infrastructure-detail-panel-neighbors')
expect(neighbors).toBeTruthy()
// At least the parent region and a vcluster should be neighbors.
expect(
screen.getByTestId('infrastructure-detail-panel-neighbor-Region:region-eu-central'),
).toBeTruthy()
})
it('closes the panel on dismiss', async () => {
renderArchitecturePage(infrastructureTopologyFixture)
fireEvent.click(
await screen.findByTestId('arch-graph-node-Region-Region:region-eu-central'),
)
expect(screen.getByTestId('infrastructure-detail-panel')).toBeTruthy()
fireEvent.click(screen.getByTestId('infrastructure-detail-panel-close'))
expect(screen.queryByTestId('infrastructure-detail-panel')).toBeNull()
})
})
it('zooms in on a cluster click — vClusters lose data-dim=true', async () => {
renderTopologyPage(infrastructureTopologyFixture)
const cluster = await screen.findByTestId('cloud-node-cluster-eu-central-primary')
describe('Architecture — context menu', () => {
it('opens the node context menu on right-click with kind-aware items', async () => {
renderArchitecturePage(infrastructureTopologyFixture)
const node = await screen.findByTestId(
'arch-graph-node-Cluster-Cluster:cluster-eu-central-primary',
)
fireEvent.contextMenu(node)
const menu = screen.getByTestId('cloud-architecture-context-menu')
expect(menu.getAttribute('data-context-target')).toBe('Cluster')
expect(screen.getByTestId('cloud-architecture-context-add-vcluster')).toBeTruthy()
expect(screen.getByTestId('cloud-architecture-context-add-nodepool')).toBeTruthy()
expect(screen.getByTestId('cloud-architecture-context-delete')).toBeTruthy()
})
// Before zoom — vClusters are dim by default.
const vcBefore = screen.getByTestId('cloud-node-vc-eu-central-dmz')
expect(vcBefore.getAttribute('data-dim')).toBe('true')
it('opens the canvas context menu with Add region on empty-canvas right-click', async () => {
renderArchitecturePage(infrastructureTopologyFixture)
const svg = await screen.findByTestId('arch-graph-svg')
fireEvent.contextMenu(svg)
const menu = screen.getByTestId('cloud-architecture-context-menu')
expect(menu.getAttribute('data-context-target')).toBe('canvas')
expect(screen.getByTestId('cloud-architecture-context-add-region')).toBeTruthy()
})
})
fireEvent.click(cluster)
describe('Architecture — density slider', () => {
it('exposes the global density slider with the default 50%', async () => {
renderArchitecturePage(infrastructureTopologyFixture)
await screen.findByTestId('arch-graph-svg')
const slider = screen.getByTestId(
'cloud-architecture-global-density',
) as HTMLInputElement
expect(slider).toBeTruthy()
expect(slider.value).toBe('50')
})
// After zoom — vClusters of THIS cluster are bright.
const vcAfter = screen.getByTestId('cloud-node-vc-eu-central-dmz')
expect(vcAfter.getAttribute('data-dim')).toBe('false')
// Zoom-status banner is visible.
expect(screen.getByTestId('cloud-architecture-zoom-status')).toBeTruthy()
it('exposes per-type badges for every type', async () => {
renderArchitecturePage(infrastructureTopologyFixture)
await screen.findByTestId('arch-graph-svg')
expect(screen.getByTestId('cloud-architecture-type-badge-Cloud')).toBeTruthy()
expect(screen.getByTestId('cloud-architecture-type-badge-Region')).toBeTruthy()
expect(screen.getByTestId('cloud-architecture-type-badge-Cluster')).toBeTruthy()
expect(screen.getByTestId('cloud-architecture-type-badge-vCluster')).toBeTruthy()
expect(screen.getByTestId('cloud-architecture-type-badge-NodePool')).toBeTruthy()
expect(screen.getByTestId('cloud-architecture-type-badge-WorkerNode')).toBeTruthy()
expect(screen.getByTestId('cloud-architecture-type-badge-LoadBalancer')).toBeTruthy()
expect(screen.getByTestId('cloud-architecture-type-badge-Network')).toBeTruthy()
})
})
describe('Architecture — CRUD modal triggers', () => {
it('opens the Add Region modal when the top-level button is clicked', async () => {
renderTopologyPage(infrastructureTopologyFixture)
const btn = await screen.findByTestId('cloud-architecture-add-region')
fireEvent.click(btn)
const modal = screen.getByTestId('infrastructure-modal-add-region')
expect(modal).toBeTruthy()
expect(within(modal).getByTestId('infrastructure-modal-add-region-title').textContent).toContain('Add region')
})
it('opens the Add Cluster modal from a region detail panel', async () => {
renderTopologyPage(infrastructureTopologyFixture)
fireEvent.click(await screen.findByTestId('cloud-node-region-eu-central'))
renderArchitecturePage(infrastructureTopologyFixture)
fireEvent.click(
await screen.findByTestId('arch-graph-node-Region-Region:region-eu-central'),
)
fireEvent.click(screen.getByTestId('infrastructure-detail-panel-action-add-cluster'))
expect(screen.getByTestId('infrastructure-modal-add-cluster')).toBeTruthy()
})
it('opens the Add vCluster modal from a cluster detail panel', async () => {
renderTopologyPage(infrastructureTopologyFixture)
fireEvent.click(await screen.findByTestId('cloud-node-cluster-eu-central-primary'))
renderArchitecturePage(infrastructureTopologyFixture)
fireEvent.click(
await screen.findByTestId('arch-graph-node-Cluster-Cluster:cluster-eu-central-primary'),
)
fireEvent.click(screen.getByTestId('infrastructure-detail-panel-action-add-vcluster'))
expect(screen.getByTestId('infrastructure-modal-add-vcluster')).toBeTruthy()
})
it('opens the Add Region modal from the empty-canvas context menu', async () => {
renderArchitecturePage(infrastructureTopologyFixture)
const svg = await screen.findByTestId('arch-graph-svg')
fireEvent.contextMenu(svg)
fireEvent.click(screen.getByTestId('cloud-architecture-context-add-region'))
expect(screen.getByTestId('infrastructure-modal-add-region')).toBeTruthy()
})
})