From 1d96b918c2aae0447f699bc31eccd563fb1c110d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 23 Jul 2026 18:24:21 +0100 Subject: [PATCH] Cover infrastructure operations, thresholds and chart API branch arms Four more frontend modules with a measured residue their existing specs never reached, including seven functions no test called at all. Deltas are uncovered counts, module scoped, existing specs versus existing plus new. - infrastructureOperationsModel: branches 38 -> 1 uncovered and createSurfaceScopedRow, previously never called, is now covered. This was the largest proportional branch gap left in the frontend. - summaryTableFocus: branches 26 -> 11 uncovered and scheduleViewportRefresh, previously never called, is now covered. - thresholdsResourceModel: hasThresholdDiff, dockerContainerOverrideIdCandidates, findOverrideByCandidates and buildAgentHeaderMeta were all uncalled; covered branches move 114 -> 130. - api/charts: getStorageSummaryCharts and timeRangeToMinutes were uncalled; covered branches move 41 -> 55. The fetch mocking follows the existing specs and asserts parsed results, not that the mock was called. No source file is modified. Adversarial review found no rejects; one subtest duplicating an arm a sibling already covered was removed before committing and the coverage was re-measured unchanged. A fifth candidate, resourceBadgePresentation, was written and discarded rather than landed: 263 tests moved the module by a single branch, because its residual arms are unreachable defensive fallbacks. PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT=test-only branch coverage, no source or contract change Contract-Neutral: test-only branch coverage, no source or contract change --- .../__tests__/charts.branchcov0723pm.test.ts | 241 +++++++ ...ureOperationsModel.branchcov0723pm.test.ts | 613 ++++++++++++++++++ .../summaryTableFocus.branchcov0723pm.test.ts | 514 +++++++++++++++ ...holdsResourceModel.branchcov0723pm.test.ts | 219 +++++++ 4 files changed, 1587 insertions(+) create mode 100644 frontend-modern/src/api/__tests__/charts.branchcov0723pm.test.ts create mode 100644 frontend-modern/src/components/Settings/__tests__/infrastructureOperationsModel.branchcov0723pm.test.ts create mode 100644 frontend-modern/src/components/shared/__tests__/summaryTableFocus.branchcov0723pm.test.ts create mode 100644 frontend-modern/src/features/alerts/thresholds/__tests__/thresholdsResourceModel.branchcov0723pm.test.ts diff --git a/frontend-modern/src/api/__tests__/charts.branchcov0723pm.test.ts b/frontend-modern/src/api/__tests__/charts.branchcov0723pm.test.ts new file mode 100644 index 000000000..9ae3c918c --- /dev/null +++ b/frontend-modern/src/api/__tests__/charts.branchcov0723pm.test.ts @@ -0,0 +1,241 @@ +/** + * Branch-coverage tests for the two currently-uncalled ChartsAPI surface points: + * - ChartsAPI.getStorageSummaryCharts (function never invoked by existing specs) + * - timeRangeToMinutes (module-private helper, never invoked; + * not exported, so exercised solely + * through getStorageSummaryCharts by + * asserting the resulting ?range= + * query value for every switch arm) + * + * Mock harness matches charts.branchcov0718.test.ts / chartsApi.test.ts: + * vi.mock('@/utils/apiClient', () => ({ apiFetchJSON: vi.fn() })) + * No real network call is made. Nothing already covered by charts.test.ts, + * chartsApi.test.ts or charts.branchcov0718.test.ts is re-asserted here. + * + * Branches exercised: + * getStorageSummaryCharts: + * - range_ default ('1h') when called with no args + * - signal present vs undefined (always wrapped in { signal } either way) + * - options undefined entirely -> `node=` omitted + * - options.nodeId undefined -> `node=` omitted + * - options.nodeId '' -> `node=` omitted (falsy branch) + * - options.nodeId truthy string -> `node=` appended + * - special chars in nodeId URL-encoded by URLSearchParams.toString + * - combined range + node + signal in a single request + * - response with EMPTY pools/disks returned verbatim (empty arm) + * - fully-populated response returned verbatim + * - rejection from apiFetchJSON propagates verbatim (error arm) + * timeRangeToMinutes (observed via the ?range= value): + * - every recognised token: 5m/15m/30m/1h/4h/12h/24h/7d/30d + * - default fallback for an UNRECOGNISED token -> 60 + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/utils/apiClient', () => ({ + apiFetchJSON: vi.fn(), +})); + +import { ChartsAPI, type StorageSummaryChartsResponse, type TimeRange } from '@/api/charts'; +import { apiFetchJSON } from '@/utils/apiClient'; + +describe('ChartsAPI.getStorageSummaryCharts — branch coverage', () => { + const apiFetchJSONMock = vi.mocked(apiFetchJSON); + + beforeEach(() => { + apiFetchJSONMock.mockReset(); + }); + + it('routes to /api/storage-charts with range=60 (default 1h) and signal undefined when called with no args', async () => { + // Exercises BOTH the default `range_ = '1h'` parameter AND the default + // `signal = undefined` / `options = undefined` branches together. + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getStorageSummaryCharts(); + + expect(apiFetchJSONMock).toHaveBeenCalledTimes(1); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/storage-charts?range=60', { + signal: undefined, + }); + }); + + it('forwards an AbortSignal through to apiFetchJSON wrapped in { signal }', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + const controller = new AbortController(); + + await ChartsAPI.getStorageSummaryCharts('1h', controller.signal); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/storage-charts?range=60', { + signal: controller.signal, + }); + }); + + it('omits the node query param when options is undefined entirely', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getStorageSummaryCharts('1h', undefined, undefined); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/storage-charts?range=60', { + signal: undefined, + }); + }); + + it('omits the node query param when options.nodeId is undefined', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getStorageSummaryCharts('1h', undefined, { nodeId: undefined }); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/storage-charts?range=60', { + signal: undefined, + }); + }); + + it('omits the node query param when options.nodeId is an empty string (falsy branch)', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getStorageSummaryCharts('1h', undefined, { nodeId: '' }); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/storage-charts?range=60', { + signal: undefined, + }); + }); + + it('appends node= when options.nodeId is a non-empty string', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getStorageSummaryCharts('1h', undefined, { nodeId: 'pve1' }); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/storage-charts?range=60&node=pve1', { + signal: undefined, + }); + }); + + it('URL-encodes special characters in the node id (URLSearchParams.toString)', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getStorageSummaryCharts('1h', undefined, { nodeId: 'node a/b' }); + + // space -> '+', '/' -> '%2F' + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/storage-charts?range=60&node=node+a%2Fb', { + signal: undefined, + }); + }); + + it('combines range + node + signal in a single request with range-first/node-second ordering', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + const controller = new AbortController(); + + await ChartsAPI.getStorageSummaryCharts('4h', controller.signal, { nodeId: 'pve1' }); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/storage-charts?range=240&node=pve1', { + signal: controller.signal, + }); + }); + + it('returns a payload with EMPTY pools/disks verbatim from apiFetchJSON (empty arm)', async () => { + const emptyPayload: StorageSummaryChartsResponse = { + pools: {}, + disks: {}, + stats: { oldestDataTimestamp: 1733700000000 }, + }; + apiFetchJSONMock.mockResolvedValueOnce(emptyPayload as never); + + const result = await ChartsAPI.getStorageSummaryCharts('1h'); + + expect(result).toBe(emptyPayload); + expect(result.pools).toEqual({}); + expect(result.disks).toEqual({}); + }); + + it('returns a fully-populated StorageSummaryChartsResponse payload verbatim', async () => { + const payload: StorageSummaryChartsResponse = { + pools: { + 'local-zfs': { + name: 'local-zfs', + usage: [{ timestamp: 1700000000000, value: 42 }], + used: [{ timestamp: 1700000000000, value: 2100 }], + avail: [{ timestamp: 1700000000000, value: 2900 }], + }, + }, + disks: { + 'serial-1': { + name: 'sda', + node: 'pve1', + temperature: [{ timestamp: 1700000000000, value: 38 }], + }, + }, + stats: { + oldestDataTimestamp: 1699900000000, + range: '1h', + rangeSeconds: 3600, + metricsStoreEnabled: true, + }, + }; + apiFetchJSONMock.mockResolvedValueOnce(payload as never); + + const result = await ChartsAPI.getStorageSummaryCharts('1h'); + + expect(result).toBe(payload); + expect(result.pools['local-zfs'].used![0].value).toBe(2100); + expect(result.disks['serial-1'].temperature[0].value).toBe(38); + }); + + it('propagates a rejection from apiFetchJSON verbatim (error arm)', async () => { + const error = new Error('storage-charts 503'); + apiFetchJSONMock.mockRejectedValueOnce(error); + + await expect(ChartsAPI.getStorageSummaryCharts('1h')).rejects.toBe(error); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/storage-charts?range=60', { + signal: undefined, + }); + }); +}); + +describe('timeRangeToMinutes — every switch arm (observed via getStorageSummaryCharts)', () => { + const apiFetchJSONMock = vi.mocked(apiFetchJSON); + + beforeEach(() => { + apiFetchJSONMock.mockReset(); + }); + + // Each entry asserts the specific minute value the helper computes for a + // given range token, observed as the `?range=` query value. This + // is real observable behaviour, not a tautology: the source maps tokens to + // distinct magic numbers and we pin each one down. + const cases: Array<{ token: TimeRange; minutes: number }> = [ + { token: '5m', minutes: 5 }, + { token: '15m', minutes: 15 }, + { token: '30m', minutes: 30 }, + { token: '1h', minutes: 60 }, + { token: '4h', minutes: 240 }, + { token: '12h', minutes: 720 }, + { token: '24h', minutes: 1440 }, + { token: '7d', minutes: 10080 }, + { token: '30d', minutes: 43200 }, + ]; + + it.each(cases)( + 'maps TimeRange="$token" to range=$minutes minutes in the storage-charts URL', + async ({ token, minutes }) => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getStorageSummaryCharts(token); + + expect(apiFetchJSONMock).toHaveBeenCalledWith(`/api/storage-charts?range=${minutes}`, { + signal: undefined, + }); + }, + ); + + it('falls back to range=60 for an UNRECOGNISED range token (default switch arm)', async () => { + // '2h' is not in the TimeRange union and not handled by any case in + // timeRangeToMinutes -> hits the `default: return 60` branch. + const unknownToken = '2h' as unknown as TimeRange; + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getStorageSummaryCharts(unknownToken); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/storage-charts?range=60', { + signal: undefined, + }); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/infrastructureOperationsModel.branchcov0723pm.test.ts b/frontend-modern/src/components/Settings/__tests__/infrastructureOperationsModel.branchcov0723pm.test.ts new file mode 100644 index 000000000..744c4ea6c --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/infrastructureOperationsModel.branchcov0723pm.test.ts @@ -0,0 +1,613 @@ +import { describe, expect, it } from 'vitest'; +import type { AgentCapability } from '@/utils/agentCapabilityPresentation'; +import type { ConnectedInfrastructureItem, ConnectedInfrastructureSurface } from '@/types/api'; +import type { UnifiedAgentRow, UnifiedAgentSurface } from '../infrastructureOperationsModel'; +import { + createSurfaceScopedRow, + getCapabilityManagementPath, + getCapabilitySurfaceLabel, + getPlatformConnectionsViewForCapability, + getStopMonitoringScopeLabel, + getStopMonitoringSurfaces, + hasMachineInstallActions, + isPlatformConnectionsCapability, + rowFromConnectedInfrastructureItem, +} from '../infrastructureOperationsModel'; + +type SurfaceKey = Parameters[1]; + +const makeRow = (overrides: Partial): UnifiedAgentRow => ({ + rowKey: 'row-1', + id: 'agent-1', + name: 'node-a', + capabilities: [], + status: 'active', + upgradePlatform: 'linux', + scope: { label: 'Default', category: 'default' }, + installFlags: [], + searchText: '', + surfaces: [], + ...overrides, +}); + +const surface = ( + label: string, + kind: AgentCapability, + overrides: Partial = {}, +): UnifiedAgentSurface => ({ + key: kind, + kind, + label, + detail: '', + ...overrides, +}); + +const connectedSurface = ( + kind: ConnectedInfrastructureSurface['kind'], + overrides: Partial = {}, +): ConnectedInfrastructureSurface => ({ + id: `surface-${kind}`, + kind, + label: `${kind} label`, + ...overrides, +}); + +const makeItem = ( + overrides: Partial, +): ConnectedInfrastructureItem => ({ + id: 'agent-1', + name: 'node-a', + status: 'active', + surfaces: [], + ...overrides, +}); + +describe('createSurfaceScopedRow', () => { + // A row that carries every surface kind plus every action id / linked node + // so each scope block can be distinguished by what it clears vs preserves. + const fullRow = makeRow({ + rowKey: 'host-1', + capabilities: ['agent', 'docker', 'kubernetes', 'proxmox', 'pbs', 'pmg', 'truenas'], + agentActionId: 'agent-act', + dockerActionId: 'docker-act', + kubernetesActionId: 'k8s-act', + linkedNodeId: 'node-99', + surfaces: [ + surface('Host telemetry', 'agent'), + surface('Docker runtime data', 'docker'), + surface('Kubernetes cluster data', 'kubernetes'), + surface('Proxmox data', 'proxmox'), + surface('PBS data', 'pbs'), + surface('PMG data', 'pmg'), + surface('TrueNAS data', 'truenas'), + ], + }); + + it('scopes to docker: clears agent + k8s action ids and the linked node, keeps docker', () => { + const scoped = createSurfaceScopedRow(fullRow, 'docker'); + expect(scoped.rowKey).toBe('host-1-docker-surface'); + expect(scoped.capabilities).toEqual(['docker']); + expect(scoped.agentActionId).toBeUndefined(); + expect(scoped.kubernetesActionId).toBeUndefined(); + expect(scoped.linkedNodeId).toBeUndefined(); + expect(scoped.dockerActionId).toBe('docker-act'); + expect(scoped.surfaces.map((s) => s.kind)).toEqual(['docker']); + }); + + it('scopes to kubernetes: clears agent + docker action ids and the linked node, keeps k8s', () => { + const scoped = createSurfaceScopedRow(fullRow, 'kubernetes'); + expect(scoped.rowKey).toBe('host-1-kubernetes-surface'); + expect(scoped.capabilities).toEqual(['kubernetes']); + expect(scoped.agentActionId).toBeUndefined(); + expect(scoped.dockerActionId).toBeUndefined(); + expect(scoped.linkedNodeId).toBeUndefined(); + expect(scoped.kubernetesActionId).toBe('k8s-act'); + expect(scoped.surfaces.map((s) => s.kind)).toEqual(['kubernetes']); + }); + + it('scopes to pbs: clears docker + k8s action ids but keeps the agent action id and node', () => { + const scoped = createSurfaceScopedRow(fullRow, 'pbs'); + expect(scoped.rowKey).toBe('host-1-pbs-surface'); + expect(scoped.capabilities).toEqual(['pbs']); + expect(scoped.dockerActionId).toBeUndefined(); + expect(scoped.kubernetesActionId).toBeUndefined(); + expect(scoped.agentActionId).toBe('agent-act'); + expect(scoped.linkedNodeId).toBe('node-99'); + expect(scoped.surfaces.map((s) => s.kind)).toEqual(['pbs']); + }); + + it('scopes to pmg: clears docker + k8s action ids but keeps the agent action id and node', () => { + const scoped = createSurfaceScopedRow(fullRow, 'pmg'); + expect(scoped.rowKey).toBe('host-1-pmg-surface'); + expect(scoped.capabilities).toEqual(['pmg']); + expect(scoped.dockerActionId).toBeUndefined(); + expect(scoped.kubernetesActionId).toBeUndefined(); + expect(scoped.agentActionId).toBe('agent-act'); + expect(scoped.linkedNodeId).toBe('node-99'); + expect(scoped.surfaces.map((s) => s.kind)).toEqual(['pmg']); + }); + + it('scopes to proxmox via the explicit surfaceKey', () => { + const scoped = createSurfaceScopedRow(fullRow, 'proxmox'); + expect(scoped.rowKey).toBe('host-1-proxmox-surface'); + expect(scoped.capabilities).toEqual(['proxmox']); + expect(scoped.dockerActionId).toBeUndefined(); + expect(scoped.kubernetesActionId).toBeUndefined(); + expect(scoped.surfaces.map((s) => s.kind)).toEqual(['proxmox']); + }); + + it('falls back to the proxmox block for an unrecognised surfaceKey', () => { + // 'availability' is not a member of the surfaceKey union, so it misses + // every `if` and lands in the default (proxmox) return. + const scoped = createSurfaceScopedRow(fullRow, 'availability' as unknown as SurfaceKey); + expect(scoped.rowKey).toBe('host-1-proxmox-surface'); + expect(scoped.capabilities).toEqual(['proxmox']); + expect(scoped.surfaces.map((s) => s.kind)).toEqual(['proxmox']); + }); + + it('scopes to agent with a host-only row: capabilities collapse to just ["agent"]', () => { + const scoped = createSurfaceScopedRow( + makeRow({ + capabilities: ['agent'], + surfaces: [surface('Host telemetry', 'agent')], + }), + 'agent', + ); + expect(scoped.rowKey).toBe('row-1-agent-surface'); + expect(scoped.capabilities).toEqual(['agent']); + expect(scoped.surfaces.map((s) => s.kind)).toEqual(['agent']); + }); + + it('scopes to agent with docker present: appends docker to the host-managed set', () => { + const scoped = createSurfaceScopedRow( + makeRow({ + capabilities: ['agent', 'docker'], + surfaces: [ + surface('Host telemetry', 'agent'), + surface('Docker runtime data', 'docker'), + surface('PBS data', 'pbs'), + ], + }), + 'agent', + ); + expect(scoped.capabilities).toEqual(['agent', 'docker']); + // The agent block surfaces filter keeps agent/docker/kubernetes only, so + // the pbs surface is dropped even though it exists on the source row. + expect(scoped.surfaces.map((s) => s.kind)).toEqual(['agent', 'docker']); + }); + + it('scopes to agent with kubernetes present (no docker): appends kubernetes only', () => { + const scoped = createSurfaceScopedRow( + makeRow({ + capabilities: ['agent', 'kubernetes'], + surfaces: [ + surface('Host telemetry', 'agent'), + surface('Kubernetes cluster data', 'kubernetes'), + ], + }), + 'agent', + ); + expect(scoped.capabilities).toEqual(['agent', 'kubernetes']); + expect(scoped.surfaces.map((s) => s.kind)).toEqual(['agent', 'kubernetes']); + }); + + it('scopes to agent with docker + kubernetes: keeps all three host-managed kinds', () => { + const scoped = createSurfaceScopedRow(fullRow, 'agent'); + expect(scoped.capabilities).toEqual(['agent', 'docker', 'kubernetes']); + expect(scoped.dockerActionId).toBeUndefined(); + expect(scoped.kubernetesActionId).toBeUndefined(); + // Unlike the docker/kubernetes blocks, the agent block does NOT clear the + // host action id or the linked node. + expect(scoped.agentActionId).toBe('agent-act'); + expect(scoped.linkedNodeId).toBe('node-99'); + expect(scoped.surfaces.map((s) => s.kind)).toEqual(['agent', 'docker', 'kubernetes']); + }); +}); + +describe('getStopMonitoringSurfaces', () => { + it('returns only the stop-monitoring surfaces when no agent surface is host-managed (false arm)', () => { + // The docker surface has a stop-monitoring action but there is no agent + // surface at all, so hostManagedStopApplies is false and the function + // returns just the filtered stop-monitoring list instead of expanding. + const row = makeRow({ + surfaces: [ + surface('Docker runtime data', 'docker', { action: 'stop-monitoring' }), + surface('PBS data', 'pbs'), + ], + }); + expect(getStopMonitoringSurfaces(row).map((s) => s.kind)).toEqual(['docker']); + }); + + it('returns an empty list when no surface carries a stop-monitoring action', () => { + const row = makeRow({ + surfaces: [ + surface('Host telemetry', 'agent', { action: 'allow-reconnect' }), + surface('Docker runtime data', 'docker', { action: 'allow-reconnect' }), + ], + }); + expect(getStopMonitoringSurfaces(row)).toEqual([]); + }); +}); + +describe('getStopMonitoringScopeLabel', () => { + it('falls back to the static copy when there are no stop-monitoring surfaces', () => { + const row = makeRow({ + surfaces: [surface('Host telemetry', 'agent', { action: 'allow-reconnect' })], + }); + expect(getStopMonitoringScopeLabel(row)).toBe('Reporting for this item'); + }); +}); + +describe('hasMachineInstallActions', () => { + it('returns false when both ids are undefined', () => { + expect(hasMachineInstallActions(makeRow({}))).toBe(false); + }); + + it('returns false when both ids are whitespace-only (trim() zeroes them out)', () => { + expect(hasMachineInstallActions(makeRow({ agentActionId: ' ', agentId: '\t' }))).toBe(false); + }); + + it('returns true when agentActionId is whitespace but agentId is present', () => { + expect(hasMachineInstallActions(makeRow({ agentActionId: ' ', agentId: 'agent-1' }))).toBe( + true, + ); + }); + + it('returns true when only agentActionId is present', () => { + expect(hasMachineInstallActions(makeRow({ agentActionId: 'uninstall-1' }))).toBe(true); + }); +}); + +describe('getCapabilitySurfaceLabel', () => { + it.each<[AgentCapability, string]>([ + ['agent', 'Host telemetry'], + ['docker', 'Docker runtime data'], + ['kubernetes', 'Kubernetes cluster data'], + ['proxmox', 'Proxmox data'], + ['pbs', 'PBS data'], + ['pmg', 'PMG data'], + ['truenas', 'TrueNAS data'], + ])('returns the dedicated surface label for %s', (capability, label) => { + expect(getCapabilitySurfaceLabel(capability)).toBe(label); + }); + + it('falls through to getAgentCapabilityLabel for an unrecognised capability, yielding undefined', () => { + // The default arm delegates to getAgentCapabilityLabel, which itself has no + // default case and returns undefined for a value outside its switch. + expect(getCapabilitySurfaceLabel('foobar' as unknown as AgentCapability)).toBeUndefined(); + }); +}); + +describe('getPlatformConnectionsViewForCapability', () => { + it.each<[AgentCapability, 'proxmox' | 'truenas']>([ + ['proxmox', 'proxmox'], + ['pbs', 'proxmox'], + ['pmg', 'proxmox'], + ['truenas', 'truenas'], + ])('routes %s to the %s connections view', (capability, view) => { + expect(getPlatformConnectionsViewForCapability(capability)).toBe(view); + }); + + it.each(['agent', 'docker', 'kubernetes'])( + 'returns null for the host-managed capability %s (default arm)', + (capability) => { + expect(getPlatformConnectionsViewForCapability(capability)).toBeNull(); + }, + ); +}); + +describe('getCapabilityManagementPath', () => { + it.each(['agent', 'docker', 'kubernetes'])( + 'returns null for non-platform host capabilities (%s)', + (capability) => { + expect(getCapabilityManagementPath(capability)).toBeNull(); + }, + ); +}); + +describe('isPlatformConnectionsCapability', () => { + it.each(['docker', 'kubernetes', 'agent'])( + 'returns false for the host capability %s', + (capability) => { + expect(isPlatformConnectionsCapability(capability)).toBe(false); + }, + ); + + it.each(['proxmox', 'pbs', 'pmg', 'truenas'])( + 'returns true for the platform capability %s', + (capability) => { + expect(isPlatformConnectionsCapability(capability)).toBe(true); + }, + ); +}); + +describe('installFlagsForCapabilities (via rowFromConnectedInfrastructureItem)', () => { + const scope = { label: 'Default', category: 'default' as const }; + + it('emits the docker host-disable flag pair for a docker-only item', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ surfaces: [connectedSurface('docker')] }), + scope, + ); + expect(row.installFlags).toEqual(['--enable-docker', '--disable-host']); + }); + + it('emits only --enable-kubernetes for a kubernetes-only item', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ surfaces: [connectedSurface('kubernetes')] }), + scope, + ); + expect(row.installFlags).toEqual(['--enable-kubernetes']); + }); + + it('emits the pve proxmox type for a proxmox-only item', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ surfaces: [connectedSurface('proxmox')] }), + scope, + ); + expect(row.installFlags).toEqual(['--enable-proxmox', '--proxmox-type pve']); + }); + + it('prefers proxmox-pve over pbs when both capabilities are present (else-if precedence)', () => { + // installFlagsForCapabilities uses `else if (capabilities.includes('pbs'))`, + // so a proxmox+pbs item pins to pve and never emits the pbs type. + const row = rowFromConnectedInfrastructureItem( + makeItem({ + surfaces: [connectedSurface('proxmox'), connectedSurface('pbs')], + }), + scope, + ); + expect(row.installFlags).toEqual(['--enable-proxmox', '--proxmox-type pve']); + }); + + it('emits no flags for an agent-only item', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ surfaces: [connectedSurface('agent')] }), + scope, + ); + expect(row.installFlags).toEqual([]); + }); +}); + +describe('rowFromConnectedInfrastructureItem', () => { + const scope = { label: 'Default', category: 'default' as const }; + + describe('name fallback chain (name || displayName || hostname || id)', () => { + it('falls back to displayName when name is empty', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ name: '', displayName: 'Display Name' }), + scope, + ); + expect(row.name).toBe('Display Name'); + }); + + it('falls back to hostname when name and displayName are empty', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ name: '', displayName: '', hostname: 'host.internal' }), + scope, + ); + expect(row.name).toBe('host.internal'); + }); + + it('falls back to the id when name, displayName and hostname are all empty', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ id: 'fallback-id', name: '', displayName: '', hostname: '' }), + scope, + ); + expect(row.name).toBe('fallback-id'); + }); + }); + + describe('rowKey derivation', () => { + it('prefixes removed-docker for an ignored item whose first surface is docker', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ + id: 'd-1', + status: 'ignored', + surfaces: [connectedSurface('docker')], + }), + scope, + ); + expect(row.rowKey).toBe('removed-docker-d-1'); + }); + + it('prefixes removed-k8s for an ignored item whose first surface is kubernetes', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ + id: 'k-1', + status: 'ignored', + surfaces: [connectedSurface('kubernetes')], + }), + scope, + ); + expect(row.rowKey).toBe('removed-k8s-k-1'); + }); + + it('prefixes removed-host for an ignored item whose first surface is agent', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ + id: 'h-1', + status: 'ignored', + surfaces: [connectedSurface('agent')], + }), + scope, + ); + expect(row.rowKey).toBe('removed-host-h-1'); + }); + + it('prefixes removed-host for an ignored item with no surfaces', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ id: 'h-2', status: 'ignored', surfaces: [] }), + scope, + ); + expect(row.rowKey).toBe('removed-host-h-2'); + }); + + it('uses the kubernetes controlId for a kubernetes-only active item', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ + id: 'k-2', + surfaces: [connectedSurface('kubernetes', { controlId: 'k8s-ctrl' })], + }), + scope, + ); + expect(row.rowKey).toBe('k8s-k8s-ctrl'); + }); + + it('falls back to the item id when a kubernetes-only active item has no controlId', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ + id: 'k-3', + surfaces: [connectedSurface('kubernetes', { controlId: undefined })], + }), + scope, + ); + expect(row.rowKey).toBe('k8s-k-3'); + }); + }); + + describe('status mapping', () => { + it('maps an ignored item status to the removed unified status', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ status: 'ignored', surfaces: [connectedSurface('agent')] }), + scope, + ); + expect(row.status).toBe('removed'); + }); + + it('keeps an active item status as active', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ status: 'active', surfaces: [connectedSurface('agent')] }), + scope, + ); + expect(row.status).toBe('active'); + }); + }); + + describe('upgradePlatform fallback', () => { + it('defaults to linux when upgradePlatform is absent', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ surfaces: [connectedSurface('agent')] }), + scope, + ); + expect(row.upgradePlatform).toBe('linux'); + }); + + it('preserves an explicit freebsd upgrade platform', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ upgradePlatform: 'freebsd', surfaces: [connectedSurface('agent')] }), + scope, + ); + expect(row.upgradePlatform).toBe('freebsd'); + }); + }); + + describe('agentActionId / agentId fallbacks', () => { + it('prefers uninstallAgentId over the agent surface controlId for agentActionId', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ + uninstallAgentId: 'uninstall-1', + surfaces: [connectedSurface('agent', { controlId: 'agent-ctrl' })], + }), + scope, + ); + expect(row.agentActionId).toBe('uninstall-1'); + }); + + it('uses the agent surface controlId when uninstallAgentId is absent', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ + surfaces: [connectedSurface('agent', { controlId: 'agent-ctrl' })], + }), + scope, + ); + expect(row.agentActionId).toBe('agent-ctrl'); + }); + + it('prefers scopeAgentId over uninstallAgentId for agentId', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ + scopeAgentId: 'scope-1', + uninstallAgentId: 'uninstall-1', + surfaces: [connectedSurface('agent')], + }), + scope, + ); + expect(row.agentId).toBe('scope-1'); + }); + + it('falls back to uninstallAgentId for agentId when scopeAgentId is absent', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ + uninstallAgentId: 'uninstall-1', + surfaces: [connectedSurface('agent')], + }), + scope, + ); + expect(row.agentId).toBe('uninstall-1'); + }); + }); + + describe('kubernetesInfo', () => { + it('builds a kubernetesInfo object when a kubernetes surface is present', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ surfaces: [connectedSurface('kubernetes')] }), + scope, + ); + expect(row.kubernetesInfo).toEqual({ + server: undefined, + context: undefined, + tokenName: undefined, + }); + }); + + it('leaves kubernetesInfo undefined for a host-only item with no kubernetes', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ surfaces: [connectedSurface('agent')] }), + scope, + ); + expect(row.kubernetesInfo).toBeUndefined(); + }); + }); + + describe('surfaceBreakdown label/detail fallbacks', () => { + it('substitutes the capability surface label when the surface label is empty', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ surfaces: [connectedSurface('docker', { label: '' })] }), + scope, + ); + expect(row.surfaces[0].label).toBe('Docker runtime data'); + }); + + it('defaults an absent detail to an empty string', () => { + const row = rowFromConnectedInfrastructureItem( + makeItem({ + surfaces: [connectedSurface('agent', { label: 'Host', detail: undefined })], + }), + scope, + ); + expect(row.surfaces[0].detail).toBe(''); + }); + + it('routes an unrecognised surface kind through the agent default of agentCapabilityFromSurfaceKind', () => { + // agentCapabilityFromSurfaceKind has a default -> 'agent' arm that is + // only reachable when surface.kind is not one of the known union + // members. Cast a bogus kind to exercise it; the capability collapses to + // 'agent'. + const row = rowFromConnectedInfrastructureItem( + makeItem({ + surfaces: [ + connectedSurface('agent', { + id: 'bogus', + kind: 'not-a-real-kind' as unknown as ConnectedInfrastructureSurface['kind'], + label: 'Bogus', + }), + ], + }), + scope, + ); + expect(row.capabilities).toEqual(['agent']); + expect(row.surfaces[0].kind).toBe('agent'); + }); + }); +}); diff --git a/frontend-modern/src/components/shared/__tests__/summaryTableFocus.branchcov0723pm.test.ts b/frontend-modern/src/components/shared/__tests__/summaryTableFocus.branchcov0723pm.test.ts new file mode 100644 index 000000000..09d1b4455 --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/summaryTableFocus.branchcov0723pm.test.ts @@ -0,0 +1,514 @@ +import { renderHook } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + useSummaryPageInteractionState, + useSummaryTableFocusBridge, +} from '@/components/shared/summaryTableFocus'; + +// Branch-coverage tests for the UNCOVERED guard arms of summaryTableFocus.ts. +// The existing spec (summaryTableFocus.test.tsx) exercises the happy paths; +// this file targets the null/empty/early-return arms that never fire there: +// - scheduleViewportRefresh (scroll/resize -> viewportVersion tick) — the +// primary measured gap; never invoked by the existing suite. +// - activeRow / focusedGroupRow guards: missing container ref, empty id, +// querySelector miss, attribute-selector escaping. +// - jumpToActiveRow: no-active-id no-op + frame-exhaustion arm. +// - shouldShowJumpToActiveRow: empty summary collections + already-visible. +// - Escape handler: defaultPrevented, every modifier arm, dialog target. +// Every asserted value below is hand-computed against the source in +// src/components/shared/summaryTableFocus.ts — no snapshots, no constant- +// equals-itself tautologies. + +const buildRect = (top: number, height = 32): DOMRect => + ({ + x: 0, + y: top, + width: 240, + height, + top, + bottom: top + height, + left: 0, + right: 240, + toJSON: () => ({}), + }) as DOMRect; + +describe('summaryTableFocus.branchcov0723pm', () => { + beforeEach(() => { + vi.stubGlobal('innerHeight', 800); + // Default synchronous rAF (matches the existing spec convention). + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', () => {}); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + // ------------------------------------------------------------------------- + // PRIMARY TARGET: scheduleViewportRefresh + // (createEffect at summaryTableFocus.ts:226). The existing spec never + // dispatches scroll/resize after mounting a table root, so the entire + // effect body — listener registration, the rAF callback that ticks + // viewportVersion, the dedup guard, and the cancel-on-cleanup arm — is + // uncovered. + // ------------------------------------------------------------------------- + describe('scheduleViewportRefresh', () => { + it('ticks viewportVersion via the rAF callback after a scroll event, recomputing isActiveRowVisible', () => { + // Deferred rAF: capture the callback so we control when viewportVersion + // increments (the synchronous default stub would tick instantly and hide + // the dedup arm tested below). + const scheduled: FrameRequestCallback[] = []; + let nextId = 7; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + scheduled.push(cb); + return nextId++; + }); + + const root = document.createElement('div'); + const row = document.createElement('div'); + row.setAttribute('data-summary-series-id', 'workload-a'); + let rowTop = 1200; // below innerHeight(800) -> not visible + Object.defineProperty(row, 'getBoundingClientRect', { + configurable: true, + value: () => buildRect(rowTop), + }); + root.appendChild(row); + document.body.appendChild(root); + + const [hoveredSeriesId] = createSignal('workload-a'); + const { result } = renderHook(() => useSummaryPageInteractionState({ hoveredSeriesId })); + result.setTableRootRef(root); + + // Row below the viewport -> jump affordance shown. + expect(result.shouldShowJumpToActiveRow()).toBe(true); + + // Slide the row into the viewport. The memo must NOT pick this up yet + // because viewportVersion has not ticked (no rAF has fired). + rowTop = 64; + expect(result.shouldShowJumpToActiveRow()).toBe(true); + + // scroll -> scheduleViewportRefresh schedules exactly one rAF. + window.dispatchEvent(new Event('scroll')); + expect(scheduled).toHaveLength(1); + + // Fire the rAF -> viewportVersion++ -> isActiveRowVisible recomputes -> + // row is now in-viewport -> affordance hidden. + scheduled[0](0); + expect(result.shouldShowJumpToActiveRow()).toBe(false); + }); + + it('coalesces back-to-back scroll events into a single rAF (rafId !== undefined dedup arm)', () => { + const scheduled: FrameRequestCallback[] = []; + let nextId = 3; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + scheduled.push(cb); + return nextId++; + }); + + const root = document.createElement('div'); + document.body.appendChild(root); + + const { result } = renderHook(() => + useSummaryTableFocusBridge({ activeSeriesId: () => null }), + ); + result.setTableRootRef(root); + + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('scroll')); + + // Only the first scroll schedules an rAF; subsequent ones hit + // `if (rafId !== undefined) return;` while the callback is pending. + expect(scheduled).toHaveLength(1); + }); + + it('refreshes viewportVersion on window resize as well as scroll (resize listener arm)', () => { + const scheduled: FrameRequestCallback[] = []; + let nextId = 11; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + scheduled.push(cb); + return nextId++; + }); + + const root = document.createElement('div'); + const row = document.createElement('div'); + row.setAttribute('data-summary-series-id', 'workload-a'); + let rowTop = 900; + Object.defineProperty(row, 'getBoundingClientRect', { + configurable: true, + value: () => buildRect(rowTop), + }); + root.appendChild(row); + document.body.appendChild(root); + + const [hoveredSeriesId] = createSignal('workload-a'); + const { result } = renderHook(() => useSummaryPageInteractionState({ hoveredSeriesId })); + result.setTableRootRef(root); + expect(result.shouldShowJumpToActiveRow()).toBe(true); + + rowTop = 50; + window.dispatchEvent(new Event('resize')); + expect(scheduled).toHaveLength(1); + scheduled[0](0); + expect(result.shouldShowJumpToActiveRow()).toBe(false); + }); + + it('cancels the pending rAF id when the owner is disposed (cancelAnimationFrame cleanup arm)', () => { + const scheduled: FrameRequestCallback[] = []; + const cancelSpy = vi.fn(); + const pendingId = 42; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + scheduled.push(cb); + return pendingId; + }); + vi.stubGlobal('cancelAnimationFrame', cancelSpy); + + const root = document.createElement('div'); + document.body.appendChild(root); + + const { result, cleanup } = renderHook(() => + useSummaryTableFocusBridge({ activeSeriesId: () => null }), + ); + result.setTableRootRef(root); + + window.dispatchEvent(new Event('scroll')); + expect(cancelSpy).not.toHaveBeenCalled(); + + cleanup(); + + // The rAF id returned by the deferred stub (42) is cancelled on dispose. + expect(cancelSpy).toHaveBeenCalledWith(pendingId); + }); + + it('does not attach scroll/resize listeners when tableRoot ref is missing (missing container ref arm)', () => { + const rafSpy = vi.fn((cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + vi.stubGlobal('requestAnimationFrame', rafSpy); + + renderHook(() => useSummaryTableFocusBridge({ activeSeriesId: () => null })); + + // No setTableRootRef -> effect hits `if (!root ...) return;` -> no + // listeners -> scroll/resize schedule no rAF. + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('resize')); + expect(rafSpy).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // activeRow guard arms: missing container ref, empty id, querySelector miss, + // attribute-selector escaping. + // ------------------------------------------------------------------------- + describe('activeRow guard arms', () => { + it('returns null when the tableRoot ref has never been set (missing container ref)', () => { + const { result } = renderHook(() => + useSummaryTableFocusBridge({ activeSeriesId: () => 'workload-a' }), + ); + expect(result.activeRow()).toBeNull(); + }); + + it('returns null when setTableRootRef receives undefined (setter `element ?? null` right arm)', () => { + const { result } = renderHook(() => + useSummaryTableFocusBridge({ activeSeriesId: () => 'workload-a' }), + ); + result.setTableRootRef(undefined); + expect(result.activeRow()).toBeNull(); + }); + + it('returns null for null / whitespace-only active ids and the row for a real id (normalizedActiveSeriesId guard)', () => { + const root = document.createElement('div'); + const row = document.createElement('div'); + row.setAttribute('data-summary-series-id', 'workload-a'); + root.appendChild(row); + document.body.appendChild(root); + + const [activeSeriesId, setActiveSeriesId] = createSignal(null); + const { result } = renderHook(() => useSummaryTableFocusBridge({ activeSeriesId })); + result.setTableRootRef(root); + + expect(result.activeRow()).toBeNull(); + setActiveSeriesId(' '); + expect(result.activeRow()).toBeNull(); + setActiveSeriesId('workload-a'); + expect(result.activeRow()).toBe(row); + }); + + it('returns null when no descendant carries the matching data attribute (querySelector miss)', () => { + const root = document.createElement('div'); + const decoy = document.createElement('div'); + decoy.setAttribute('data-summary-series-id', 'other'); + root.appendChild(decoy); + document.body.appendChild(root); + + const { result } = renderHook(() => + useSummaryTableFocusBridge({ activeSeriesId: () => 'workload-a' }), + ); + result.setTableRootRef(root); + + expect(result.activeRow()).toBeNull(); + }); + + it('escapes backslashes and double quotes in the series id before injecting it into the attribute selector', () => { + const root = document.createElement('div'); + const row = document.createElement('div'); + // Literal id containing both a double-quote and a backslash — the exact + // characters escapeAttributeSelectorValue rewrites. + row.setAttribute('data-summary-series-id', 'id"with\\special'); + root.appendChild(row); + document.body.appendChild(root); + + const { result } = renderHook(() => + useSummaryTableFocusBridge({ activeSeriesId: () => 'id"with\\special' }), + ); + result.setTableRootRef(root); + + // If escaping were broken the selector would not match the literal id. + expect(result.activeRow()).toBe(row); + }); + }); + + // ------------------------------------------------------------------------- + // jumpToActiveRow guard arms + // ------------------------------------------------------------------------- + describe('jumpToActiveRow guard arms', () => { + it('is a no-op when there is no active series id (normalizedActiveSeriesId falsy -> early return)', () => { + const revealActiveSeries = vi.fn(); + const rafSpy = vi.fn((cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + vi.stubGlobal('requestAnimationFrame', rafSpy); + + const root = document.createElement('div'); + document.body.appendChild(root); + + const { result } = renderHook(() => + useSummaryTableFocusBridge({ + activeSeriesId: () => null, + revealActiveSeries, + }), + ); + result.setTableRootRef(root); + + result.jumpToActiveRow(); + + expect(revealActiveSeries).not.toHaveBeenCalled(); + expect(rafSpy).not.toHaveBeenCalled(); + }); + + it('exhausts the 6-frame retry budget without scrolling when the active row never mounts (no matching row)', () => { + const revealActiveSeries = vi.fn(); + const rafSpy = vi.fn((cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + vi.stubGlobal('requestAnimationFrame', rafSpy); + + const root = document.createElement('div'); + document.body.appendChild(root); + + const { result } = renderHook(() => + useSummaryTableFocusBridge({ + activeSeriesId: () => 'workload-a', + revealActiveSeries, + }), + ); + result.setTableRootRef(root); + + result.jumpToActiveRow(); + + // revealActiveSeries fires once before the scroll retry loop. + expect(revealActiveSeries).toHaveBeenCalledWith('workload-a'); + expect(revealActiveSeries).toHaveBeenCalledTimes(1); + // attemptScroll(6) recurses once per synchronous rAF until remainingHits + // 0 -> 6 rAF calls total, then the `remainingFrames <= 0` arm returns. + expect(rafSpy).toHaveBeenCalledTimes(6); + }); + }); + + // ------------------------------------------------------------------------- + // shouldShowJumpToActiveRow — empty summary collections + already-visible row + // ------------------------------------------------------------------------- + describe('shouldShowJumpToActiveRow arms', () => { + it('is false when no hover/focus/group signal resolves an active series (empty summary collections / page-default state)', () => { + const root = document.createElement('div'); + document.body.appendChild(root); + + const { result } = renderHook(() => useSummaryPageInteractionState({})); + result.setTableRootRef(root); + + // resolveSummaryScopeState returns { kind: 'page', seriesId: null }. + expect(result.activeSeriesId()).toBeNull(); + expect(result.activeGroupScope()).toBeNull(); + // Boolean(null) && !isActiveRowVisible() -> false && ... -> false. + expect(result.shouldShowJumpToActiveRow()).toBe(false); + }); + + it('is false when the active row is already inside the viewport (already-focused / visible arm)', () => { + const root = document.createElement('div'); + const row = document.createElement('div'); + row.setAttribute('data-summary-series-id', 'workload-a'); + // top=200, height=40, innerHeight=800 -> 0 < 200 and 240 < 800 -> visible. + Object.defineProperty(row, 'getBoundingClientRect', { + configurable: true, + value: () => buildRect(200, 40), + }); + root.appendChild(row); + document.body.appendChild(root); + + const [hoveredSeriesId] = createSignal('workload-a'); + const { result } = renderHook(() => useSummaryPageInteractionState({ hoveredSeriesId })); + result.setTableRootRef(root); + + expect(result.activeSeriesId()).toBe('workload-a'); + // Boolean('workload-a') && !true -> false. + expect(result.shouldShowJumpToActiveRow()).toBe(false); + }); + }); + + // ------------------------------------------------------------------------- + // Focused-group reveal effect (summaryTableFocus.ts:350) — the + // already-visible arm skips scrollIntoView; the out-of-viewport arm scrolls. + // ------------------------------------------------------------------------- + describe('focused-group reveal arms', () => { + it('does not call scrollIntoView when the focused group row is already visible (already-focused arm)', () => { + const scrollIntoView = vi.fn(); + const root = document.createElement('div'); + const row = document.createElement('div'); + row.setAttribute('data-summary-group-id', 'group-a'); + Object.defineProperty(row, 'getBoundingClientRect', { + configurable: true, + value: () => buildRect(120, 40), // inside viewport + }); + Object.defineProperty(row, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }); + root.appendChild(row); + document.body.appendChild(root); + + const [focusedGroupId] = createSignal('group-a'); + const { result } = renderHook(() => + useSummaryTableFocusBridge({ activeSeriesId: () => null, focusedGroupId }), + ); + result.setTableRootRef(root); + + // `!isElementVisibleWithinViewport(row) && ...` short-circuits on the + // left operand when the row is already on-screen. + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it('calls scrollIntoView with nearest-block when the focused group row sits below the viewport', () => { + const scrollIntoView = vi.fn(); + const root = document.createElement('div'); + const row = document.createElement('div'); + row.setAttribute('data-summary-group-id', 'group-a'); + Object.defineProperty(row, 'getBoundingClientRect', { + configurable: true, + value: () => buildRect(1200, 40), // below innerHeight(800) + }); + Object.defineProperty(row, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }); + root.appendChild(row); + document.body.appendChild(root); + + const [focusedGroupId] = createSignal('group-a'); + const { result } = renderHook(() => + useSummaryTableFocusBridge({ activeSeriesId: () => null, focusedGroupId }), + ); + result.setTableRootRef(root); + + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'nearest', + }); + }); + + it('retries via rAF then stops when the focused group row never mounts (no matching group row -> remainingFrames exhausted)', () => { + const rafSpy = vi.fn((cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + vi.stubGlobal('requestAnimationFrame', rafSpy); + + const root = document.createElement('div'); + document.body.appendChild(root); + + const [focusedGroupId] = createSignal('group-a'); + const { result } = renderHook(() => + useSummaryTableFocusBridge({ activeSeriesId: () => null, focusedGroupId }), + ); + result.setTableRootRef(root); + + // Initial rAF + one per decrement: remainingFrames 12->0 = 13 calls. + expect(rafSpy).toHaveBeenCalledTimes(13); + }); + }); + + // ------------------------------------------------------------------------- + // Escape handler guard arms (summaryTableFocus.ts:174). The existing spec + // only covers the happy path; every short-circuit in the `||` chain and the + // dialog-target guard are uncovered. + // ------------------------------------------------------------------------- + describe('escape handler guard arms', () => { + it('does not invoke onEscapeClear when the event default is already prevented', () => { + const onEscapeClear = vi.fn(); + renderHook(() => useSummaryPageInteractionState({ onEscapeClear })); + + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + event.preventDefault(); + document.dispatchEvent(event); + + expect(onEscapeClear).not.toHaveBeenCalled(); + }); + + it.each([ + ['altKey', { altKey: true }], + ['ctrlKey', { ctrlKey: true }], + ['metaKey', { metaKey: true }], + ['shiftKey', { shiftKey: true }], + ] as const)( + 'ignores Escape pressed while %s is held (modifier short-circuit arm)', + (_label, modifiers) => { + const onEscapeClear = vi.fn(); + renderHook(() => useSummaryPageInteractionState({ onEscapeClear })); + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, ...modifiers }), + ); + expect(onEscapeClear).not.toHaveBeenCalled(); + }, + ); + + it('ignores Escape dispatched from inside an open dialog (dialog-target guard)', () => { + const onEscapeClear = vi.fn(); + renderHook(() => useSummaryPageInteractionState({ onEscapeClear })); + + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + document.body.appendChild(dialog); + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(onEscapeClear).not.toHaveBeenCalled(); + }); + + it('ignores non-Escape keys (event.key !== "Escape" arm)', () => { + const onEscapeClear = vi.fn(); + renderHook(() => useSummaryPageInteractionState({ onEscapeClear })); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(onEscapeClear).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend-modern/src/features/alerts/thresholds/__tests__/thresholdsResourceModel.branchcov0723pm.test.ts b/frontend-modern/src/features/alerts/thresholds/__tests__/thresholdsResourceModel.branchcov0723pm.test.ts new file mode 100644 index 000000000..f9210d53e --- /dev/null +++ b/frontend-modern/src/features/alerts/thresholds/__tests__/thresholdsResourceModel.branchcov0723pm.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vitest'; + +import type { Resource } from '@/types/resource'; + +import type { Override } from '../types'; +import { + buildAgentHeaderMeta, + dockerContainerOverrideIdCandidates, + findOverrideByCandidates, + hasThresholdDiff, +} from '../thresholdsResourceModel'; + +/** + * Build a minimal Resource fixture. `as unknown as Resource` keeps strict + * TypeScript clean while letting us exercise the runtime guards with + * deliberately partial / malformed payloads. + */ +const makeAgent = (overrides: Partial = {}): Resource => + ({ + id: 'agent-id', + type: 'agent', + name: 'agent-name', + displayName: 'agent-display', + platformId: 'platform-id', + platformType: 'generic', + sourceType: 'agent', + status: 'online', + lastSeen: 0, + ...overrides, + }) as unknown as Resource; + +const makeOverride = (id: string, thresholds: Override['thresholds'] = {}): Override => + ({ + id, + name: id, + type: 'guest', + thresholds, + }) as Override; + +describe('thresholdsResourceModel branch coverage 0723pm', () => { + describe('hasThresholdDiff', () => { + it('returns false when the override is undefined (?.thresholds short-circuit)', () => { + expect(hasThresholdDiff(undefined, { cpu: 80 })).toBe(false); + }); + + it('returns false when every threshold equals its default', () => { + const override = makeOverride('a', { cpu: 80, memory: 70 }); + expect(hasThresholdDiff(override, { cpu: 80, memory: 70 })).toBe(false); + }); + + it('returns true when one threshold diverges while a sibling matches', () => { + const override = makeOverride('a', { cpu: 80, memory: 90 }); + expect(hasThresholdDiff(override, { cpu: 80, memory: 70 })).toBe(true); + }); + + it('returns true when the first keyed threshold already diverges (some early-exit)', () => { + const override = makeOverride('a', { cpu: 99, memory: 99 }); + expect(hasThresholdDiff(override, { cpu: 80, memory: 70 })).toBe(true); + }); + + it('returns false for an empty thresholds object (some() over an empty key list)', () => { + const override = makeOverride('a', {}); + expect(hasThresholdDiff(override, { cpu: 80 })).toBe(false); + }); + + it('ignores keys whose override value is undefined even when a default exists', () => { + // The predicate's `!== undefined` guard must filter out absent values. + const override = makeOverride('a', { cpu: undefined }); + expect(hasThresholdDiff(override, { cpu: 80 })).toBe(false); + }); + }); + + describe('dockerContainerOverrideIdCandidates', () => { + it('prefixes every host candidate as docker:/, preserving order', () => { + const host = makeAgent({ + id: 'runtime-1', + discoveryTarget: { + resourceType: 'app-container', + resourceId: 'container-123', + agentId: 'agent-7', + }, + platformData: { + docker: { hostSourceId: 'docker-platform' }, + hostSourceId: 'host-platform', + }, + }); + expect(dockerContainerOverrideIdCandidates(host, 'a1b2c3d4e5f6')).toEqual([ + 'docker:container-123/a1b2c3d4e5f6', + 'docker:docker-platform/a1b2c3d4e5f6', + 'docker:host-platform/a1b2c3d4e5f6', + 'docker:agent-7/a1b2c3d4e5f6', + 'docker:runtime-1/a1b2c3d4e5f6', + ]); + }); + + it('produces a single prefixed entry when the host exposes only its resource id', () => { + const host = makeAgent({ id: 'lone-host' }); + expect(dockerContainerOverrideIdCandidates(host, 'deadbeef')).toEqual([ + 'docker:lone-host/deadbeef', + ]); + }); + + it('falls back to platformData.docker.hostSourceId when discoveryTarget is absent', () => { + const host = makeAgent({ + id: 'host-1', + platformData: { docker: { hostSourceId: 'docker-src' } }, + }); + expect(dockerContainerOverrideIdCandidates(host, 'cid')).toEqual([ + 'docker:docker-src/cid', + 'docker:host-1/cid', + ]); + }); + + it('returns an empty array when the host has no usable id signal at all', () => { + // resource.id is whitespace -> readString drops it -> uniqueIds yields []. + const host = makeAgent({ id: ' ' }); + expect(dockerContainerOverrideIdCandidates(host, 'cid')).toEqual([]); + }); + }); + + describe('findOverrideByCandidates', () => { + it('returns the override held under the first candidate (early return)', () => { + const first = makeOverride('first', { cpu: 1 }); + const map = new Map([ + ['c1', first], + ['c2', makeOverride('second', { cpu: 2 })], + ]); + expect(findOverrideByCandidates(map, ['c1', 'c2'])).toBe(first); + }); + + it('skips missing candidates and returns the first hit further down the list', () => { + const later = makeOverride('later', { cpu: 5 }); + const map = new Map([['c3', later]]); + expect(findOverrideByCandidates(map, ['c1', 'c2', 'c3'])).toBe(later); + }); + + it('returns undefined when no candidate is present in the map', () => { + const map = new Map([['unrelated', makeOverride('x')]]); + expect(findOverrideByCandidates(map, ['c1', 'c2'])).toBeUndefined(); + }); + + it('returns undefined for an empty candidate list (loop body never executes)', () => { + const map = new Map([['c1', makeOverride('x')]]); + expect(findOverrideByCandidates(map, [])).toBeUndefined(); + }); + + it('skips a candidate whose map value is explicitly undefined (truthy guard)', () => { + // Map.get yields undefined for both missing keys and keys set to undefined; + // the `if (override)` guard must treat them identically and keep scanning. + const real = makeOverride('real', { cpu: 9 }); + const map = new Map([ + ['hole', undefined], + ['real', real], + ]) as unknown as Map; + expect(findOverrideByCandidates(map, ['hole', 'real'])).toBe(real); + }); + }); + + describe('buildAgentHeaderMeta', () => { + it('uses identity.hostname for rawName and collects distinct display/hostname/id keys', () => { + const agent = makeAgent({ + id: 'agent-7', + name: 'titan-host', + displayName: 'Titan Server', + status: 'warning', + identity: { hostname: 'titan.example.com' }, + }); + const { headerMeta, keys } = buildAgentHeaderMeta(agent); + expect(headerMeta.type).toBe('agent'); + expect(headerMeta.status).toBe('warning'); + expect(headerMeta.rawName).toBe('titan.example.com'); + expect(headerMeta.displayName).toBe('Titan Server'); + expect([...keys]).toEqual(['Titan Server', 'titan.example.com', 'agent-7']); + }); + + it('falls back to the trimmed agent.name for rawName when no hostname signal is present', () => { + const agent = makeAgent({ + id: 'agent-2', + name: 'box-2', + displayName: undefined, + }); + const { headerMeta, keys } = buildAgentHeaderMeta(agent); + // getPreferredResourceHostname bottoms out at asTrimmedString(resource.name). + expect(headerMeta.rawName).toBe('box-2'); + // displayName collapses onto the same value, so keys dedupe to two entries. + expect(headerMeta.displayName).toBe('box-2'); + expect([...keys]).toEqual(['box-2', 'agent-2']); + }); + + it('falls back to agent.id for rawName when both hostname and name are absent', () => { + const agent = makeAgent({ + id: 'agent-3', + name: '', + displayName: undefined, + platformId: undefined, + }); + const { headerMeta, keys } = buildAgentHeaderMeta(agent); + expect(headerMeta.rawName).toBe('agent-3'); + expect(headerMeta.displayName).toBe('agent-3'); + expect([...keys]).toEqual(['agent-3']); + }); + + it('uses a whitespace-only name for rawName but keeps it out of the keys set', () => { + // asTrimmedString drops the whitespace name (and platformId is cleared), + // so getPreferredResourceHostname is undefined and the `|| agent.name` arm + // fires, making rawName the raw " " string. The keys loop then skips it + // via its `value.trim()` guard. + const agent = makeAgent({ + id: 'real-id', + name: ' ', + displayName: undefined, + platformId: undefined, + }); + const { headerMeta, keys } = buildAgentHeaderMeta(agent); + expect(headerMeta.rawName).toBe(' '); + expect([...keys]).toEqual(['real-id']); + }); + }); +});