Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/components/DataPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DataPanel } from './DataPanel';
import type { ComponentTopic } from '@/lib/types';

vi.mock('@/lib/store', () => ({
useAppStore: vi.fn((selector: (s: { isConnected: boolean }) => unknown) => selector({ isConnected: true })),
}));

// Capture the latest `initialValue` so tests can assert what the form would
// receive when the user clicks "Copy to Publish".
const publishFormInitialValues: unknown[] = [];
vi.mock('@/components/TopicPublishForm', () => ({
TopicPublishForm: ({ initialValue }: { initialValue: unknown }) => {
publishFormInitialValues.push(initialValue);
return <div data-testid="topic-publish-form" />;
},
}));

vi.mock('@/components/JsonFormViewer', () => ({
JsonFormViewer: () => <div data-testid="json-form-viewer" />,
}));

beforeEach(() => {
publishFormInitialValues.length = 0;
});

function makeTopic(overrides: Partial<ComponentTopic> = {}): ComponentTopic {
return {
topic: '/test/data',
timestamp: 0,
data: null,
status: 'data',
...overrides,
};
}

describe('DataPanel canWrite', () => {
it('shows write form when access is write even if data is 0 and no type is present', () => {
// Regression for the falsy-scalar bug: a counter reading of exactly 0
// with an explicit `access: 'write'` must not hide the write section.
render(<DataPanel topic={makeTopic({ access: 'write', data: 0 })} entityId="motor" />);
expect(screen.getByText('Write Value')).toBeInTheDocument();
expect(screen.getByTestId('topic-publish-form')).toBeInTheDocument();
});

it('shows write form when access is readwrite even if data is false and no type is present', () => {
render(<DataPanel topic={makeTopic({ access: 'readwrite', data: false })} entityId="motor" />);
expect(screen.getByText('Write Value')).toBeInTheDocument();
});

it('hides write form when access is read regardless of data or type', () => {
render(
<DataPanel
topic={makeTopic({ access: 'read', type: 'sensor_msgs/msg/Temperature', data: { value: 23 } })}
entityId="motor"
/>
);
expect(screen.queryByText('Write Value')).not.toBeInTheDocument();
expect(screen.queryByText('Publish Message')).not.toBeInTheDocument();
});

it('falls back to typed-topic heuristic when access is absent', () => {
render(<DataPanel topic={makeTopic({ type: 'std_msgs/msg/String', data: null })} entityId="motor" />);
expect(screen.getByText('Publish Message')).toBeInTheDocument();
});

it('hides write form when access is absent and there is no type hint and no value', () => {
render(<DataPanel topic={makeTopic({ data: null })} entityId="motor" />);
expect(screen.queryByText('Publish Message')).not.toBeInTheDocument();
expect(screen.queryByText('Write Value')).not.toBeInTheDocument();
});
});

describe('DataPanel publishValue initialization', () => {
it('preserves a scalar zero data value as the initial publish value when no default is set', () => {
// `||` would have collapsed `0` to `{}`; `??` keeps the falsy scalar.
render(<DataPanel topic={makeTopic({ access: 'write', data: 0 })} entityId="motor" />);
expect(publishFormInitialValues.at(-1)).toBe(0);
});

it('prefers type_info.default_value over data when both are present', () => {
render(
<DataPanel
topic={makeTopic({
access: 'write',
data: 0,
type_info: { schema: {}, default_value: 42 as unknown as Record<string, unknown> },
})}
entityId="motor"
/>
);
expect(publishFormInitialValues.at(-1)).toBe(42);
});
});

describe('DataPanel Copy to Publish', () => {
it('copies a scalar zero value from the last received data into the publish form', async () => {
// With the previous truthy guard, clicking Copy did nothing for a
// valid reading of exactly 0 (or false / ''). Presence check fixes it.
render(
<DataPanel
topic={makeTopic({
access: 'write',
data: 0,
type_info: { schema: {}, default_value: 42 as unknown as Record<string, unknown> },
})}
entityId="motor"
/>
);

expect(publishFormInitialValues.at(-1)).toBe(42);

await userEvent.click(screen.getByRole('button', { name: /copy to publish/i }));

expect(publishFormInitialValues.at(-1)).toBe(0);
});
});
32 changes: 26 additions & 6 deletions src/components/DataPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,14 +197,34 @@ export function DataPanel({
isRefreshing = false,
onRefresh,
}: DataPanelProps) {
const [publishValue, setPublishValue] = useState<unknown>(topic.type_info?.default_value || topic.data || {});
// Use nullish coalescing so legitimate falsy scalars (0, false, '') are
// preserved as the initial publish value instead of collapsing to `{}`.
const [publishValue, setPublishValue] = useState<unknown>(topic.type_info?.default_value ?? topic.data ?? {});

const isConnected = useAppStore((state) => state.isConnected);
const hasData = topic.status === 'data' && topic.data !== null && topic.data !== undefined;
const canPublish = isConnected && !!(topic.type || topic.type_info || topic.data);
// `access` is the explicit per-item write capability; when present it
// overrides the legacy "any typed topic is publishable" heuristic so a
// read-only data item never surfaces a write form. Falsy scalar values
// (0, false, '') count as present - checking truthiness of `topic.data`
// would incorrectly hide the write form for e.g. a counter reading 0.
const hasTypeHint = !!(topic.type || topic.type_info);
const hasValuePresent = topic.data !== null && topic.data !== undefined;
const canWrite =
isConnected &&
(topic.access === 'write' ||
topic.access === 'readwrite' ||
(topic.access !== 'read' && (hasTypeHint || hasValuePresent)));
// Use "Write Value" when the gateway told us this is a writable scalar
// (access === 'write' / 'readwrite'); fall back to "Publish Message" for
// streaming topics where the operation really is a publish.
const writeSectionLabel =
topic.access === 'write' || topic.access === 'readwrite' ? 'Write Value' : 'Publish Message';

const handleCopyFromLast = () => {
if (topic.data) {
// Presence check, not truthiness, so a reported value of exactly 0
// (or false / empty string) still copies into the publish form.
if (topic.data !== null && topic.data !== undefined) {
setPublishValue(JSON.parse(JSON.stringify(topic.data)));
}
};
Expand Down Expand Up @@ -270,10 +290,10 @@ export function DataPanel({
)}
</div>

{/* Publish Section */}
{canPublish && (
{/* Write/Publish Section */}
{canWrite && (
<div className="border-t pt-4 space-y-2">
<span className="text-sm font-medium">Publish Message</span>
<span className="text-sm font-medium">{writeSectionLabel}</span>
<TopicPublishForm
topic={topic}
entityId={entityId}
Expand Down
17 changes: 15 additions & 2 deletions src/components/EntityDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,12 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
faults: 0,
logs: 0,
};
// Guard against late results from a previous entity overwriting the
// current entity's state. The cleanup aborts in-flight requests AND
// flips `cancelled` so any setState calls after the entity changed
// become no-ops (covers transforms that ignore the abort signal).
const controller = new AbortController();
let cancelled = false;
const doFetchResourceCounts = async () => {
// Mark topicsData as "not loaded yet for the current entity" so the
Comment thread
bburda marked this conversation as resolved.
// Data tab renders a skeleton instead of an empty-state flash while
Expand Down Expand Up @@ -459,24 +465,31 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
try {
// Fetch resource counts and data in parallel
const [counts, dataRes] = await Promise.all([
prefetchResourceCounts(entityType, entityId),
fetchEntityData(entityType, entityId).catch(() => [] as ComponentTopic[]),
prefetchResourceCounts(entityType, entityId, controller.signal),
fetchEntityData(entityType, entityId, controller.signal).catch(() => [] as ComponentTopic[]),
]);

if (cancelled) return;

// Store the fetched data for the Data tab
const fetchedData = Array.isArray(dataRes) ? dataRes : [];
setTopicsData(fetchedData);

// Use the already-fetched data length instead of a separate request
setResourceCounts({ ...counts, data: fetchedData.length, logs: 0 });
} catch {
if (cancelled) return;
// On unexpected failure fall back to "loaded empty" so the UI
// doesn't get stuck showing the skeleton forever.
setTopicsData([]);
}
};

doFetchResourceCounts();
return () => {
cancelled = true;
controller.abort();
};
}, [selectedEntity, prefetchResourceCounts, fetchEntityData]);

const handleCopyEntity = async () => {
Expand Down
39 changes: 31 additions & 8 deletions src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@ export interface AppState {
getFunctionHosts: (functionId: string) => Promise<unknown[]>;
prefetchResourceCounts: (
entityType: SovdResourceEntityType,
entityId: string
entityId: string,
signal?: AbortSignal
) => Promise<{ data: number; operations: number; configurations: number; faults: number }>;
}

Expand Down Expand Up @@ -2132,32 +2133,54 @@ export const useAppStore = create<AppState>()(
return data ? unwrapItems<unknown>(data) : [];
},

prefetchResourceCounts: async (entityType: SovdResourceEntityType, entityId: string) => {
prefetchResourceCounts: async (
entityType: SovdResourceEntityType,
entityId: string,
signal?: AbortSignal
) => {
const { client } = get();
if (!client) return { data: 0, operations: 0, configurations: 0, faults: 0 };

// Note: data count is NOT fetched here to avoid a duplicate request.
// The caller (EntityDetailPanel) already fetches entity data via fetchEntityData
// and overrides counts.data with the result length.
const [opsRes, configRes, faultsRes] = await Promise.all([
getEntityOperations(client, entityType, entityId).catch(() => ({
getEntityOperations(client, entityType, entityId, signal).catch(() => ({
data: undefined,
error: undefined,
})),
getEntityConfigurations(client, entityType, entityId).catch(() => ({
getEntityConfigurations(client, entityType, entityId, signal).catch(() => ({
data: undefined,
error: undefined,
})),
getEntityFaults(client, entityType, entityId, signal).catch(() => ({
data: undefined,
error: undefined,
})),
getEntityFaults(client, entityType, entityId).catch(() => ({ data: undefined, error: undefined })),
]);

// Isolate each transform call: a malformed payload from one
// resource (e.g. UDS DTC faults with non-canonical schema)
// must not crash the others or the caller's Promise.all.
const safeCount = <T>(fn: () => T, fallback: T): T => {
try {
return fn();
} catch {
return fallback;
}
};
return {
data: 0,
operations: opsRes.data ? unwrapItems(opsRes.data).length : 0,
operations: opsRes.data ? safeCount(() => unwrapItems(opsRes.data).length, 0) : 0,
configurations: configRes.data
? transformConfigurationsResponse(configRes.data, entityId).parameters.length
? safeCount(
() => transformConfigurationsResponse(configRes.data, entityId).parameters.length,
0
)
: 0,
faults: faultsRes.data
? safeCount(() => transformFaultsResponse(faultsRes.data).items.length, 0)
: 0,
faults: faultsRes.data ? transformFaultsResponse(faultsRes.data).items.length : 0,
};
},

Expand Down
Loading
Loading