diff --git a/src/components/EntityDetailPanel.test.tsx b/src/components/EntityDetailPanel.test.tsx new file mode 100644 index 0000000..4ed4dc4 --- /dev/null +++ b/src/components/EntityDetailPanel.test.tsx @@ -0,0 +1,131 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { EntityDetailPanel } from './EntityDetailPanel'; + +// Mock heavy child components - we only care about the top-level routing +// (header + tab bar + tab content selection) for these tests. +vi.mock('@/components/DataPanel', () => ({ DataPanel: () =>
})); +vi.mock('@/components/ConfigurationPanel', () => ({ ConfigurationPanel: () =>
})); +vi.mock('@/components/OperationsPanel', () => ({ OperationsPanel: () =>
})); +vi.mock('@/components/AreasPanel', () => ({ AreasPanel: () =>
})); +vi.mock('@/components/AppsPanel', () => ({ AppsPanel: () =>
})); +vi.mock('@/components/FunctionsPanel', () => ({ FunctionsPanel: () =>
})); +vi.mock('@/components/ServerInfoPanel', () => ({ ServerInfoPanel: () =>
})); +vi.mock('@/components/FaultsDashboard', () => ({ FaultsDashboard: () =>
})); +vi.mock('@/components/UpdatesDashboard', () => ({ UpdatesDashboard: () =>
})); +vi.mock('@/components/EmptyState', () => ({ EmptyState: () =>
})); +vi.mock('@/components/EntityDetailSkeleton', () => ({ EntityDetailSkeleton: () =>
})); +vi.mock('@/components/ResourceTabs', async () => { + const actual = await vi.importActual('@/components/ResourceTabs'); + return { + ...actual, + renderResourceTabContent: (tab: string) =>
, + }; +}); + +const mockPrefetchResourceCounts = vi.fn(); +const mockFetchEntityData = vi.fn(); +const mockSelectEntity = vi.fn(); +const mockRefreshSelectedEntity = vi.fn(); + +let storeState: Record = {}; + +vi.mock('@/lib/store', () => ({ + useAppStore: vi.fn((selector: (s: Record) => unknown) => selector(storeState)), + // The breadcrumb builder resolves segment types via findNode; these tests + // don't load a tree, so it always falls back to position-based inference. + findNode: () => null, +})); + +function setStore(overrides: Record) { + storeState = { + selectedPath: null, + selectedEntity: null, + rootEntities: [], + isLoadingDetails: false, + isRefreshing: false, + isConnected: true, + selectEntity: mockSelectEntity, + refreshSelectedEntity: mockRefreshSelectedEntity, + prefetchResourceCounts: mockPrefetchResourceCounts, + fetchEntityData: mockFetchEntityData, + ...overrides, + }; +} + +describe('EntityDetailPanel - nested entity types', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPrefetchResourceCounts.mockResolvedValue({ data: 0, operations: 0, configurations: 0, faults: 0, logs: 0 }); + mockFetchEntityData.mockResolvedValue([]); + }); + + it('renders resource tabs and fetches counts as components for subcomponent entity', async () => { + setStore({ + selectedPath: '/server/area1/component1/planning-ecu', + selectedEntity: { + id: 'planning-ecu', + name: 'planning-ecu', + type: 'subcomponent', + }, + }); + + render( {}} />); + + // Bug repro: subcomponent should fetch resource counts using the + // 'components' entity type (gateway routes subcomponents through + // /api/v1/components/{id}/...). + await waitFor(() => { + expect(mockPrefetchResourceCounts).toHaveBeenCalledWith('components', 'planning-ecu', expect.anything()); + }); + + // Bug repro: subcomponent should render the resource tab bar + // (Data / Operations / Config / Faults / Logs) just like a component. + expect(screen.getByRole('button', { name: /Data/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Operations/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Config/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Faults/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Logs/ })).toBeInTheDocument(); + + // The fallback "No detailed information available" must not appear. + expect(screen.queryByText(/No detailed information available/i)).not.toBeInTheDocument(); + }); + + it('renders the area panel and fetches counts as areas for subarea entity', async () => { + setStore({ + selectedPath: '/server/area1/zone-a', + selectedEntity: { + id: 'zone-a', + name: 'zone-a', + type: 'subarea', + }, + }); + + render( {}} />); + + // Bug repro: subarea should fetch resource counts using the 'areas' + // entity type (gateway routes subareas through /api/v1/areas/{id}/...). + await waitFor(() => { + expect(mockPrefetchResourceCounts).toHaveBeenCalledWith('areas', 'zone-a', expect.anything()); + }); + + // Bug repro: subarea should render the area panel just like an area, + // not the "No detailed information available" fallback. + expect(screen.getByTestId('areas-panel')).toBeInTheDocument(); + expect(screen.queryByText(/No detailed information available/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index 12baed2..ca03e14 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -31,7 +31,7 @@ import { FunctionsPanel } from '@/components/FunctionsPanel'; import { ServerInfoPanel } from '@/components/ServerInfoPanel'; import { FaultsDashboard } from '@/components/FaultsDashboard'; import { UpdatesDashboard } from '@/components/UpdatesDashboard'; -import { useAppStore, type AppState } from '@/lib/store'; +import { useAppStore, findNode, type AppState } from '@/lib/store'; import type { ComponentTopic, Parameter, SovdResourceEntityType } from '@/lib/types'; type ComponentTab = ResourceTabId; @@ -53,10 +53,12 @@ function getEntityTypeForApi(entityType: string | undefined): SovdResourceEntity case 'app': return 'apps'; case 'component': + case 'subcomponent': return 'components'; case 'function': return 'functions'; case 'area': + case 'subarea': return 'areas'; default: return 'components'; // default fallback @@ -71,8 +73,10 @@ function getBreadcrumbIcon(type: string) { case 'server': return ; case 'area': + case 'subarea': return ; case 'component': + case 'subcomponent': return ; case 'app': return ; @@ -382,6 +386,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit const { selectedPath, selectedEntity, + rootEntities, isLoadingDetails, isRefreshing, isConnected, @@ -393,6 +398,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit useShallow((state: AppState) => ({ selectedPath: state.selectedPath, selectedEntity: state.selectedEntity, + rootEntities: state.rootEntities, isLoadingDetails: state.isLoadingDetails, isRefreshing: state.isRefreshing, isConnected: state.isConnected, @@ -444,9 +450,9 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit } const entityId = selectedEntity.id; - const isComponent = selectedEntity.type === 'component'; + const isComponent = selectedEntity.type === 'component' || selectedEntity.type === 'subcomponent'; const isApp = selectedEntity.type === 'app'; - const isArea = selectedEntity.type === 'area'; + const isArea = selectedEntity.type === 'area' || selectedEntity.type === 'subarea'; const isFunction = selectedEntity.type === 'function'; // Only fetch counts for entity types that have resources @@ -554,8 +560,8 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit // Entity detail view if (selectedEntity) { const isTopic = selectedEntity.type === 'topic'; - const isComponent = selectedEntity.type === 'component'; - const isArea = selectedEntity.type === 'area'; + const isComponent = selectedEntity.type === 'component' || selectedEntity.type === 'subcomponent'; + const isArea = selectedEntity.type === 'area' || selectedEntity.type === 'subarea'; const isApp = selectedEntity.type === 'app'; const isFunction = selectedEntity.type === 'function'; const isServer = selectedEntity.type === 'server'; @@ -626,8 +632,10 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit case 'server': return ; case 'area': + case 'subarea': return ; case 'component': + case 'subcomponent': return ; case 'app': return ; @@ -644,8 +652,10 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit case 'server': return 'bg-primary/10'; case 'area': + case 'subarea': return 'bg-cyan-100 dark:bg-cyan-900'; case 'component': + case 'subcomponent': return 'bg-indigo-100 dark:bg-indigo-900'; case 'app': return 'bg-emerald-100 dark:bg-emerald-900'; @@ -656,23 +666,30 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit } }; - // Build breadcrumb from path with type inference + // Build breadcrumb from path. Prefer the real entity type from the + // loaded tree so nested types (subarea / subcomponent) and deeper + // hierarchies get the correct icon; fall back to position-based + // inference for segments not present in the tree. const breadcrumbs = pathParts.map((part, index) => { const breadcrumbPath = '/' + pathParts.slice(0, index + 1).join('/'); // Decode URL-encoded parts for display const decodedPart = decodeURIComponent(part); - // Infer type from path position: server -> area -> component -> app/folder let type: string; if (part === 'server') { type = 'server'; - } else if (index === 1) { - type = 'area'; - } else if (index === 2) { - type = 'component'; } else if (['data', 'operations', 'configurations', 'faults', 'resources'].includes(part)) { type = 'folder'; } else { - type = 'app'; + const node = findNode(rootEntities, breadcrumbPath); + if (node) { + type = node.type; + } else if (index === 1) { + type = 'area'; + } else if (index === 2) { + type = 'component'; + } else { + type = 'app'; + } } return { label: decodedPart, diff --git a/src/components/SearchCommand.tsx b/src/components/SearchCommand.tsx index 994ee4e..e04efe5 100644 --- a/src/components/SearchCommand.tsx +++ b/src/components/SearchCommand.tsx @@ -39,8 +39,10 @@ function flattenTree(nodes: EntityTreeNode[]): EntityTreeNode[] { function getEntityIcon(type: string) { switch (type) { case 'area': + case 'subarea': return Layers; case 'component': + case 'subcomponent': return Box; case 'app': return Cpu; @@ -57,8 +59,10 @@ function getEntityIcon(type: string) { function getEntityColorClass(type: string): string { switch (type) { case 'area': + case 'subarea': return 'text-cyan-500'; case 'component': + case 'subcomponent': return 'text-indigo-500'; case 'app': return 'text-emerald-500'; @@ -154,7 +158,7 @@ export function SearchCommand({ open, onOpenChange }: SearchCommandProps) { No entities found. {/* Group by entity type */} - {['area', 'component', 'app', 'function'].map((type) => { + {['area', 'subarea', 'component', 'subcomponent', 'app', 'function'].map((type) => { const typeEntities = filteredEntities.filter((e) => e.type === type); if (typeEntities.length === 0) return null;