Skip to content

Commit 29e4bc8

Browse files
committed
fix: resolve empty resource tabs and missing peer component children
Fix two bugs that cause E2E test failures with aggregated peer gateways: 1. EntityResourceTabs: synchronously clear loadedTabsRef in the reset effect so the load effect (same React commit) sees false instead of stale true values from the previous entity. 2. loadChildren: when /components/{id}/hosts returns empty (peer-sourced components), fall back to GET /apps filtered by x-medkit.component_id or _links.is-located-on. Extract filterAppsByComponent as a testable helper. Add 7 new tests covering both fixes. closes #61
1 parent bee0a82 commit 29e4bc8

4 files changed

Lines changed: 208 additions & 3 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright 2026 bburda
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import { describe, it, expect, vi, beforeEach } from 'vitest';
16+
import { render, screen, waitFor } from '@testing-library/react';
17+
import { EntityResourceTabs } from './EntityResourceTabs';
18+
import type { ComponentTopic, Operation, Fault } from '@/lib/types';
19+
20+
// ---- store mock ----
21+
22+
const mockFetchEntityData = vi.fn();
23+
const mockFetchEntityOperations = vi.fn();
24+
const mockFetchConfigurations = vi.fn();
25+
const mockListEntityFaults = vi.fn();
26+
const mockSelectEntity = vi.fn();
27+
28+
vi.mock('@/lib/store', () => ({
29+
useAppStore: vi.fn((selector: (s: Record<string, unknown>) => unknown) =>
30+
selector({
31+
selectEntity: mockSelectEntity,
32+
fetchEntityData: mockFetchEntityData,
33+
fetchEntityOperations: mockFetchEntityOperations,
34+
fetchConfigurations: mockFetchConfigurations,
35+
listEntityFaults: mockListEntityFaults,
36+
configurations: new Map(),
37+
})
38+
),
39+
}));
40+
41+
// ---- helpers ----
42+
43+
function sampleTopics(): ComponentTopic[] {
44+
return [
45+
{
46+
topic: '/engine/temperature',
47+
timestamp: Date.now(),
48+
data: null,
49+
status: 'metadata_only',
50+
type: 'sensor_msgs/msg/Temperature',
51+
},
52+
];
53+
}
54+
55+
// ---- tests ----
56+
57+
describe('EntityResourceTabs', () => {
58+
beforeEach(() => {
59+
vi.clearAllMocks();
60+
mockFetchEntityData.mockResolvedValue([] as ComponentTopic[]);
61+
mockFetchEntityOperations.mockResolvedValue([] as Operation[]);
62+
mockFetchConfigurations.mockResolvedValue(undefined);
63+
mockListEntityFaults.mockResolvedValue({ items: [] as Fault[], count: 0 });
64+
});
65+
66+
it('fetches and displays data items on first render', async () => {
67+
mockFetchEntityData.mockResolvedValue(sampleTopics());
68+
69+
render(<EntityResourceTabs entityId="ecu-primary" entityType="components" />);
70+
71+
await waitFor(() => {
72+
expect(mockFetchEntityData).toHaveBeenCalledWith('components', 'ecu-primary');
73+
});
74+
75+
await waitFor(() => {
76+
expect(screen.getByText('/engine/temperature')).toBeInTheDocument();
77+
});
78+
});
79+
80+
it('re-fetches data when entityId changes (loadedTabs ref race)', async () => {
81+
mockFetchEntityData.mockResolvedValue(sampleTopics());
82+
83+
const { rerender } = render(<EntityResourceTabs entityId="ecu-primary" entityType="components" />);
84+
85+
// Wait for first fetch to complete
86+
await waitFor(() => {
87+
expect(screen.getByText('/engine/temperature')).toBeInTheDocument();
88+
});
89+
90+
// Switch to a different entity - this is the scenario that was broken:
91+
// the ref still had { data: true } from the first entity, so the load
92+
// effect returned early and data stayed empty.
93+
const secondTopics: ComponentTopic[] = [
94+
{
95+
topic: '/brake/pressure',
96+
timestamp: Date.now(),
97+
data: null,
98+
status: 'metadata_only',
99+
type: 'sensor_msgs/msg/FluidPressure',
100+
},
101+
];
102+
mockFetchEntityData.mockResolvedValue(secondTopics);
103+
104+
rerender(<EntityResourceTabs entityId="ecu-mcu" entityType="components" />);
105+
106+
await waitFor(() => {
107+
expect(mockFetchEntityData).toHaveBeenCalledWith('components', 'ecu-mcu');
108+
});
109+
110+
await waitFor(() => {
111+
expect(screen.getByText('/brake/pressure')).toBeInTheDocument();
112+
});
113+
114+
// Old data should be gone
115+
expect(screen.queryByText('/engine/temperature')).not.toBeInTheDocument();
116+
});
117+
});

src/components/EntityResourceTabs.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,19 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate
115115
// Reset tab state when the entity changes so stale data from the
116116
// previous entity does not leak into the new one.
117117
useEffect(() => {
118+
const reset: LoadedResources = {
119+
data: false,
120+
operations: false,
121+
configurations: false,
122+
faults: false,
123+
logs: false,
124+
};
118125
setActiveTab('data');
119-
setLoadedTabs({ data: false, operations: false, configurations: false, faults: false, logs: false });
126+
setLoadedTabs(reset);
127+
// Synchronously update the ref so the load effect (which fires in
128+
// the same commit) sees the cleared flags instead of stale `true`
129+
// values from the previous entity.
130+
loadedTabsRef.current = reset;
120131
setData([]);
121132
setOperations([]);
122133
setFaults([]);

src/lib/store-helpers.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@
1313
// limitations under the License.
1414

1515
import { describe, it, expect } from 'vitest';
16-
import { toTreeNode, updateNodeInTree, findNode, inferEntityTypeFromDepth, parseTreePath } from './store';
16+
import {
17+
toTreeNode,
18+
updateNodeInTree,
19+
findNode,
20+
inferEntityTypeFromDepth,
21+
parseTreePath,
22+
filterAppsByComponent,
23+
} from './store';
1724
import type { SovdEntity, EntityTreeNode } from './types';
1825

1926
// =============================================================================
@@ -408,3 +415,45 @@ describe('parseTreePath', () => {
408415
expect(result.entityId).toBe('chassis');
409416
});
410417
});
418+
419+
// =============================================================================
420+
// filterAppsByComponent
421+
// =============================================================================
422+
423+
describe('filterAppsByComponent', () => {
424+
const apps: Record<string, unknown>[] = [
425+
{ id: 'local-app', 'x-medkit': { component_id: 'ecu-primary' } },
426+
{ id: 'data_viewer', 'x-medkit': { component_id: 'ecu-rtmaps' } },
427+
{ id: 'lidar_sim', _links: { 'is-located-on': '/api/v1/components/ecu-rtmaps' } },
428+
{ id: 'orphan-app', 'x-medkit': {} },
429+
{ id: 'bare-app' },
430+
];
431+
432+
it('matches apps by x-medkit.component_id', () => {
433+
const result = filterAppsByComponent(apps, 'ecu-primary');
434+
expect(result.map((a) => a.id)).toEqual(['local-app']);
435+
});
436+
437+
it('matches apps by _links.is-located-on', () => {
438+
const result = filterAppsByComponent(apps, 'ecu-rtmaps');
439+
expect(result.map((a) => a.id)).toEqual(['data_viewer', 'lidar_sim']);
440+
});
441+
442+
it('returns empty array when no apps match', () => {
443+
const result = filterAppsByComponent(apps, 'nonexistent');
444+
expect(result).toEqual([]);
445+
});
446+
447+
it('handles empty apps list', () => {
448+
const result = filterAppsByComponent([], 'ecu-primary');
449+
expect(result).toEqual([]);
450+
});
451+
452+
it('does not match partial component ID in _links', () => {
453+
const tricky: Record<string, unknown>[] = [
454+
{ id: 'partial', _links: { 'is-located-on': '/api/v1/components/ecu-rtmaps-extended' } },
455+
];
456+
const result = filterAppsByComponent(tricky, 'ecu-rtmaps');
457+
expect(result).toEqual([]);
458+
});
459+
});

src/lib/store.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,22 @@ export function parseTreePath(path: string): {
613613
return { entityType, entityId };
614614
}
615615

616+
/**
617+
* Filter apps that belong to a given component by checking
618+
* `x-medkit.component_id` or `_links.is-located-on`.
619+
*
620+
* Used as a fallback when `/components/{id}/hosts` returns empty
621+
* (peer-sourced components).
622+
*/
623+
export function filterAppsByComponent(apps: Record<string, unknown>[], componentId: string): Record<string, unknown>[] {
624+
return apps.filter((app) => {
625+
const xMedkit = app['x-medkit'] as Record<string, unknown> | undefined;
626+
if (xMedkit?.component_id === componentId) return true;
627+
const links = app['_links'] as Record<string, string> | undefined;
628+
return links?.['is-located-on']?.endsWith(`/components/${componentId}`);
629+
});
630+
}
631+
616632
/** Fallback: fetch entity details from API when not in tree */
617633
async function fetchEntityFromApi(
618634
path: string,
@@ -995,7 +1011,19 @@ export const useAppStore = create<AppState>()(
9951011
);
9961012
const subcompNodes = subcomponents.map((e: SovdEntity) => toTreeNode(e, path));
9971013

998-
const rawApps = appsRes.data ? unwrapItems<Record<string, unknown>>(appsRes.data) : [];
1014+
let rawApps = appsRes.data ? unwrapItems<Record<string, unknown>>(appsRes.data) : [];
1015+
1016+
// Fallback for peer-sourced components: /hosts may return
1017+
// empty while apps still reference this component via
1018+
// x-medkit.component_id. Fetch all apps and filter.
1019+
if (rawApps.length === 0) {
1020+
const allAppsRes = await client.GET('/apps').catch(() => ({ data: undefined }));
1021+
const allApps = allAppsRes.data
1022+
? unwrapItems<Record<string, unknown>>(allAppsRes.data)
1023+
: [];
1024+
rawApps = filterAppsByComponent(allApps, node.id);
1025+
}
1026+
9991027
const apps = rawApps.map((e) => ({ ...e, type: 'app' }) as unknown as SovdEntity);
10001028
const appNodes = apps.map((app: SovdEntity) =>
10011029
toTreeNode({ ...app, type: 'app', hasChildren: false }, path)

0 commit comments

Comments
 (0)