feat(web): add overview system module foundation
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppShell, type InstanceContext } from '../app-shell.js';
|
||||
import {
|
||||
OverviewSystemPage,
|
||||
type OverviewDataSource,
|
||||
type OverviewSnapshot,
|
||||
} from './overview-system.js';
|
||||
import type { InstanceCapabilityMap } from './instance-detail.js';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const owner: InstanceContext = {
|
||||
id: 'alpha',
|
||||
name: 'Alpha',
|
||||
origin: 'https://alpha.example',
|
||||
status: 'online',
|
||||
authentication: 'authenticated',
|
||||
freshness: 'fresh',
|
||||
};
|
||||
const overviewCapability: InstanceCapabilityMap = {
|
||||
overview: { state: 'supported' },
|
||||
};
|
||||
const snapshot: OverviewSnapshot = {
|
||||
observedAt: '2026-07-17T10:00:00Z',
|
||||
device: { Model: 'SIMBox 8', Uptime: '2 days' },
|
||||
sim: { Slots: 8, Active: 6 },
|
||||
network: { Operator: 'Example Mobile', Registration: 'registered' },
|
||||
stats: { 'Messages today': 42, Calls: 7 },
|
||||
cpu: { Usage: '18%', Temperature: '46 °C' },
|
||||
connectivity: { State: 'connected', Latency: '21 ms' },
|
||||
};
|
||||
|
||||
function deferredSource() {
|
||||
let resolve!: (value: OverviewSnapshot) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const load = vi.fn<OverviewDataSource['load']>(
|
||||
(_instanceId, _signal) =>
|
||||
new Promise((done, fail) => {
|
||||
void _instanceId;
|
||||
void _signal;
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
}),
|
||||
);
|
||||
return {
|
||||
source: { load },
|
||||
load,
|
||||
resolve: (value: OverviewSnapshot) => resolve(value),
|
||||
reject: (reason: unknown) => reject(reason),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Phase 6.1 Overview / System read slice', () => {
|
||||
it('loads only through an injected source and renders six structured sections', async () => {
|
||||
const pending = deferredSource();
|
||||
render(
|
||||
<AppShell
|
||||
pathname="/instances/alpha/overview"
|
||||
instance={owner}
|
||||
capabilities={overviewCapability}
|
||||
overviewDataSource={pending.source}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Overview loading status' }).textContent).toContain(
|
||||
'Loading overview',
|
||||
);
|
||||
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
||||
pending.resolve(snapshot);
|
||||
|
||||
for (const heading of ['Device', 'SIM', 'Network', 'Statistics', 'CPU', 'Connectivity']) {
|
||||
expect(await screen.findByRole('heading', { name: heading })).toBeTruthy();
|
||||
}
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: 'Device' })).getByText('SIMBox 8'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/Observed 2026-07-17T10:00:00Z/)).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /restart/i })).toBeNull();
|
||||
expect(screen.getByText(/Restart is unavailable.*R3/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('is honest when no safe read source is injected and never attempts a production endpoint', () => {
|
||||
render(
|
||||
<AppShell
|
||||
pathname="/instances/alpha/overview"
|
||||
instance={owner}
|
||||
capabilities={overviewCapability}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('status', { name: 'Overview unavailable' }).textContent).toMatch(
|
||||
/no safe overview read data source/i,
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /restart/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows authentication-required without loading or leaking prior owner data', async () => {
|
||||
const load = vi.fn<OverviewDataSource['load']>().mockResolvedValue(snapshot);
|
||||
const { rerender } = render(<OverviewSystemPage instance={owner} dataSource={{ load }} />);
|
||||
expect(await screen.findByText('SIMBox 8')).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<OverviewSystemPage
|
||||
instance={{
|
||||
...owner,
|
||||
id: 'bravo',
|
||||
name: 'Bravo',
|
||||
authentication: 'auth-required',
|
||||
status: 'auth-required',
|
||||
}}
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/authentication is required/i);
|
||||
expect(screen.queryByText('SIMBox 8')).toBeNull();
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks injected data stale from owner context while retaining structured values', async () => {
|
||||
render(
|
||||
<OverviewSystemPage
|
||||
instance={{ ...owner, freshness: 'stale' }}
|
||||
dataSource={{ load: async () => snapshot }}
|
||||
/>,
|
||||
);
|
||||
expect((await screen.findByRole('status', { name: 'Overview freshness' })).textContent).toMatch(
|
||||
/stale/i,
|
||||
);
|
||||
expect(screen.getByText('SIMBox 8')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces read errors and supports a real injected retry', async () => {
|
||||
const user = userEvent.setup();
|
||||
const load = vi
|
||||
.fn<OverviewDataSource['load']>()
|
||||
.mockRejectedValueOnce(new Error('collector unavailable'))
|
||||
.mockResolvedValueOnce(snapshot);
|
||||
render(<OverviewSystemPage instance={owner} dataSource={{ load }} />);
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||
'Overview data could not be loaded.',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading overview' }));
|
||||
expect(await screen.findByText('SIMBox 8')).toBeTruthy();
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('aborts old reads, fences late owners, and reloads on refreshSignal', async () => {
|
||||
const alpha = deferredSource();
|
||||
const bravo = deferredSource();
|
||||
const load = vi.fn<OverviewDataSource['load']>((instanceId, signal) =>
|
||||
instanceId === 'alpha'
|
||||
? alpha.source.load(instanceId, signal)
|
||||
: bravo.source.load(instanceId, signal),
|
||||
);
|
||||
const source = { load };
|
||||
const { rerender } = render(
|
||||
<OverviewSystemPage instance={owner} dataSource={source} refreshSignal={0} />,
|
||||
);
|
||||
const alphaSignal = load.mock.calls[0]?.[1];
|
||||
|
||||
rerender(
|
||||
<OverviewSystemPage
|
||||
instance={{ ...owner, id: 'bravo', name: 'Bravo' }}
|
||||
dataSource={source}
|
||||
refreshSignal={0}
|
||||
/>,
|
||||
);
|
||||
expect(alphaSignal?.aborted).toBe(true);
|
||||
alpha.resolve({ ...snapshot, device: { Model: 'Alpha stale' } });
|
||||
bravo.resolve({ ...snapshot, device: { Model: 'Bravo current' } });
|
||||
expect(await screen.findByText('Bravo current')).toBeTruthy();
|
||||
expect(screen.queryByText('Alpha stale')).toBeNull();
|
||||
|
||||
rerender(
|
||||
<OverviewSystemPage
|
||||
instance={{ ...owner, id: 'bravo', name: 'Bravo' }}
|
||||
dataSource={source}
|
||||
refreshSignal={1}
|
||||
/>,
|
||||
);
|
||||
expect(load).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('keeps unsupported capability explicit and does not invoke the overview source', () => {
|
||||
const load = vi.fn<OverviewDataSource['load']>();
|
||||
render(
|
||||
<AppShell
|
||||
pathname="/instances/alpha/overview"
|
||||
instance={owner}
|
||||
capabilities={{
|
||||
overview: { state: 'unsupported', explanation: 'No overview read support' },
|
||||
}}
|
||||
overviewDataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText('No overview read support').length).toBeGreaterThan(0);
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user