From b04aaf876fea408ab716e637d11baa3c977b9c74 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 10 Jul 2026 16:41:23 +0100 Subject: [PATCH] test(frontend): second-pass branch-coverage for aiFindingPresentation and resourceDetailDrawerTrueNASModel Add aiFindingPresentation.coverage2.test.ts (176 cases; finding source/severity presentation, patrol verification/workflow summaries, resolution reason, attention-queue sort, clipboard formatting) and resourceDetailDrawerTrueNASModel.coverage2.test.ts (175 cases; percent/duration formatters, port/volume/network labels, storage-state tone, section builders). Cover functions disjoint from the existing coverage tests; new test files only. --- ...DetailDrawerTrueNASModel.coverage2.test.ts | 1062 +++++++++++++ .../aiFindingPresentation.coverage2.test.ts | 1408 +++++++++++++++++ 2 files changed, 2470 insertions(+) create mode 100644 frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerTrueNASModel.coverage2.test.ts create mode 100644 frontend-modern/src/utils/__tests__/aiFindingPresentation.coverage2.test.ts diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerTrueNASModel.coverage2.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerTrueNASModel.coverage2.test.ts new file mode 100644 index 0000000000..23035b5301 --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerTrueNASModel.coverage2.test.ts @@ -0,0 +1,1062 @@ +import { describe, expect, it } from 'vitest'; +import { + buildTrueNASDetailSections, + buildTrueNASDetailsSummary, + type ResourceDetailDrawerTrueNASRow, +} from '@/components/Infrastructure/resourceDetailDrawerTrueNASModel'; +import type { + Resource, + ResourcePhysicalDiskMeta, + ResourceStorageMeta, + ResourceTrueNASAppMeta, + ResourceTrueNASAppPort, + ResourceTrueNASMeta, + ResourceTrueNASShareMeta, + ResourceTrueNASVMMeta, +} from '@/types/resource'; + +// Every target helper (except the exported buildTrueNASDetailsSummary) is +// module-private, so each case drives it through the two public entry points +// (buildTrueNASDetailSections / buildTrueNASDetailsSummary) and asserts on the +// rendered summary string or section/row shape. Fixture builders mirror the +// sibling test's `baseResource` convention. + +const baseResource = (overrides: Partial): Resource => + ({ + id: 'truenas-resource', + type: 'vm', + name: 'truenas-resource', + displayName: 'TrueNAS resource', + platformId: 'truenas-main', + platformType: 'truenas', + sourceType: 'api', + status: 'online', + ...overrides, + }) as Resource; + +// --- section / row accessors --------------------------------------------- + +const allRows = (resource: Resource): ResourceDetailDrawerTrueNASRow[] => + buildTrueNASDetailSections(resource).flatMap((section) => section.rows); + +const findRow = ( + resource: Resource, + label: string, +): ResourceDetailDrawerTrueNASRow | undefined => + allRows(resource).find((row) => row.label === label); + +// --- fixture builders ----------------------------------------------------- + +const systemResource = (truenas: Partial): Resource => + baseResource({ type: 'agent', truenas }); + +const storageRes = ( + storage: Partial, + overrides: Partial = {}, +): Resource => baseResource({ storage, ...overrides }); + +const diskRes = (disk: Partial): Resource => + baseResource({ physicalDisk: disk }); + +const shareRes = (share: Partial): Resource => + baseResource({ type: 'network-share', truenas: { share } }); + +const vmRes = (vm: Partial): Resource => + baseResource({ type: 'vm', truenas: { vm } }); + +const appRes = (app: Partial): Resource => + baseResource({ type: 'app-container', truenas: { app } }); + +// ========================================================================= +// formatPercent +// ========================================================================= + +describe('formatPercent (via Storage "Percent" and disk SMART rows)', () => { + it('formats >= 10 with one decimal', () => { + expect(findRow(storageRes({}, { disk: { current: 42.5 } }), 'Percent')?.value).toBe('42.5%'); + }); + + it('formats < 10 with two decimals', () => { + expect(findRow(storageRes({}, { disk: { current: 5.5 } }), 'Percent')?.value).toBe('5.50%'); + }); + + it('treats the >= 10 boundary as one decimal', () => { + expect(findRow(storageRes({}, { disk: { current: 10 } }), 'Percent')?.value).toBe('10.0%'); + }); + + it('treats 9.99 as two decimals', () => { + expect(findRow(storageRes({}, { disk: { current: 9.99 } }), 'Percent')?.value).toBe('9.99%'); + }); + + it('omits the row for undefined', () => { + expect(findRow(storageRes({}), 'Percent')).toBeUndefined(); + }); + + it('omits the row for NaN', () => { + expect(findRow(storageRes({}, { disk: { current: Number.NaN } }), 'Percent')).toBeUndefined(); + }); + + it('omits the row for a non-number value', () => { + expect( + findRow(storageRes({}, { disk: { current: 'lots' as unknown as number } }), 'Percent'), + ).toBeUndefined(); + }); + + it('works through the disk "Available spare" SMART row', () => { + expect(findRow(diskRes({ smart: { availableSpare: 100 } }), 'Available spare')?.value).toBe( + '100.0%', + ); + }); + + it('works through the disk "Percentage used" SMART row with a small value', () => { + expect(findRow(diskRes({ smart: { percentageUsed: 0.5 } }), 'Percentage used')?.value).toBe( + '0.50%', + ); + }); +}); + +// ========================================================================= +// formatDurationSeconds +// ========================================================================= + +describe('formatDurationSeconds (via System "Uptime" row)', () => { + it('formats multi-day durations', () => { + expect(findRow(systemResource({ uptimeSeconds: 172_800 }), 'Uptime')?.value).toBe('2d'); + }); + + it('formats exactly one day', () => { + expect(findRow(systemResource({ uptimeSeconds: 86_400 }), 'Uptime')?.value).toBe('1d'); + }); + + it('formats hours when less than a day', () => { + expect(findRow(systemResource({ uptimeSeconds: 7_200 }), 'Uptime')?.value).toBe('2h'); + }); + + it('formats minutes when less than an hour', () => { + expect(findRow(systemResource({ uptimeSeconds: 120 }), 'Uptime')?.value).toBe('2m'); + }); + + it('returns "<1m" for sub-minute durations', () => { + expect(findRow(systemResource({ uptimeSeconds: 30 }), 'Uptime')?.value).toBe('<1m'); + }); + + it('omits the row for undefined uptime', () => { + expect(findRow(systemResource({}), 'Uptime')).toBeUndefined(); + }); + + it('omits the row for zero uptime', () => { + expect(findRow(systemResource({ uptimeSeconds: 0 }), 'Uptime')).toBeUndefined(); + }); + + it('omits the row for negative uptime', () => { + expect(findRow(systemResource({ uptimeSeconds: -100 }), 'Uptime')).toBeUndefined(); + }); + + it('omits the row for NaN uptime', () => { + expect(findRow(systemResource({ uptimeSeconds: Number.NaN }), 'Uptime')).toBeUndefined(); + }); + + it('falls back to resource.uptime when uptimeSeconds is absent', () => { + const resource = baseResource({ type: 'agent', uptime: 3_600, truenas: {} }); + expect(findRow(resource, 'Uptime')?.value).toBe('1h'); + }); +}); + +// ========================================================================= +// summarizeList +// ========================================================================= + +describe('summarizeList (via App list rows)', () => { + it('joins values within the default visible count', () => { + expect(findRow(appRes({ usedHostIps: ['10.0.0.1', '10.0.0.2'] }), 'Host IPs')?.value).toBe( + '10.0.0.1, 10.0.0.2', + ); + }); + + it('appends a +N suffix when values exceed the visible count', () => { + expect( + findRow(appRes({ usedHostIps: ['a', 'b', 'c', 'd', 'e'] }), 'Host IPs')?.value, + ).toBe('a, b, c +2'); + }); + + it('omits the row when the list is empty', () => { + expect(findRow(appRes({ usedHostIps: [] }), 'Host IPs')).toBeUndefined(); + }); + + it('trims and filters whitespace-only entries', () => { + expect( + findRow(appRes({ usedHostIps: [' x ', '', ' ', 'y'] }), 'Host IPs')?.value, + ).toBe('x, y'); + }); + + it('respects a custom visible count (Images uses 2)', () => { + expect( + findRow(appRes({ images: ['img1', 'img2', 'img3', 'img4'] }), 'Images')?.value, + ).toBe('img1, img2 +2'); + }); + + it('shows no suffix when count equals the visible count', () => { + expect(findRow(appRes({ images: ['img1', 'img2'] }), 'Images')?.value).toBe('img1, img2'); + }); + + it('populates the title attribute with the full list', () => { + expect( + findRow(appRes({ usedHostIps: ['a', 'b', 'c', 'd'] }), 'Host IPs')?.title, + ).toBe('a, b, c, d'); + }); +}); + +// ========================================================================= +// portLabel +// ========================================================================= + +describe('portLabel (via App "Ports" row)', () => { + type PortCase = { name: string; port: ResourceTrueNASAppPort; expected: string | undefined }; + + const cases: PortCase[] = [ + { + name: 'host -> container/protocol', + port: { containerPort: 443, protocol: 'tcp', hostPorts: [{ hostPort: 30443 }] }, + expected: '30443 -> 443/tcp', + }, + { + name: 'host with IP -> container/protocol', + port: { + containerPort: 443, + protocol: 'tcp', + hostPorts: [{ hostIp: '0.0.0.0', hostPort: 30443 }], + }, + expected: '0.0.0.0:30443 -> 443/tcp', + }, + { + name: 'containerPort only with protocol', + port: { containerPort: 443, protocol: 'tcp' }, + expected: '443/tcp', + }, + { + name: 'containerPort only without protocol', + port: { containerPort: 443 }, + expected: '443', + }, + { + name: 'hostPorts only, no containerPort', + port: { hostPorts: [{ hostPort: 30443 }] }, + expected: '30443', + }, + { + name: 'protocol only, no containerPort or hostPorts', + port: { protocol: 'tcp' }, + expected: 'tcp', + }, + { + name: 'multiple hostPorts', + port: { + containerPort: 443, + protocol: 'tcp', + hostPorts: [ + { hostIp: '0.0.0.0', hostPort: 30443 }, + { hostIp: '127.0.0.1', hostPort: 30444 }, + ], + }, + expected: '0.0.0.0:30443, 127.0.0.1:30444 -> 443/tcp', + }, + ]; + + for (const { name, port, expected } of cases) { + it(name, () => { + expect(findRow(appRes({ usedPorts: [port] }), 'Ports')?.value).toBe(expected); + }); + } + + it('filters out invalid (non-positive) host port numbers', () => { + const port: ResourceTrueNASAppPort = { hostPorts: [{ hostPort: 0 }] }; + expect(findRow(appRes({ usedPorts: [port] }), 'Ports')).toBeUndefined(); + }); + + it('uses hostIp-less format when hostIp is whitespace-only', () => { + const port: ResourceTrueNASAppPort = { + containerPort: 80, + hostPorts: [{ hostIp: ' ', hostPort: 30080 }], + }; + expect(findRow(appRes({ usedPorts: [port] }), 'Ports')?.value).toBe('30080 -> 80'); + }); + + it('filters a port whose only hostPort entry has a negative port', () => { + const port: ResourceTrueNASAppPort = { hostPorts: [{ hostPort: -1 }] }; + expect(findRow(appRes({ usedPorts: [port] }), 'Ports')).toBeUndefined(); + }); +}); + +// ========================================================================= +// appVolumeLabels +// ========================================================================= + +describe('appVolumeLabels (via App "Volumes" row)', () => { + it('formats source -> destination', () => { + expect( + findRow( + appRes({ volumes: [{ source: '/mnt/tank/app', destination: '/data' }] }), + 'Volumes', + )?.value, + ).toBe('/mnt/tank/app -> /data'); + }); + + it('returns destination-only when source is absent', () => { + expect(findRow(appRes({ volumes: [{ destination: '/data' }] }), 'Volumes')?.value).toBe( + '/data', + ); + }); + + it('returns source-only when destination is absent', () => { + expect(findRow(appRes({ volumes: [{ source: '/mnt/tank/app' }] }), 'Volumes')?.value).toBe( + '/mnt/tank/app', + ); + }); + + it('filters volumes where neither source nor destination is present', () => { + expect(findRow(appRes({ volumes: [{ source: ' ', destination: '' }] }), 'Volumes')).toBeUndefined(); + }); +}); + +// ========================================================================= +// appNetworkLabels +// ========================================================================= + +describe('appNetworkLabels (via App "Networks" row)', () => { + it('uses the network name', () => { + expect(findRow(appRes({ networks: [{ name: 'bridge' }] }), 'Networks')?.value).toBe('bridge'); + }); + + it('falls back to id when name is absent', () => { + expect(findRow(appRes({ networks: [{ id: 'net1' }] }), 'Networks')?.value).toBe('net1'); + }); + + it('prefers name over id', () => { + expect( + findRow(appRes({ networks: [{ name: 'bridge', id: 'net1' }] }), 'Networks')?.value, + ).toBe('bridge'); + }); + + it('filters networks where neither name nor id is present', () => { + expect(findRow(appRes({ networks: [{}] }), 'Networks')).toBeUndefined(); + }); +}); + +// ========================================================================= +// formatVMCpu +// ========================================================================= + +describe('formatVMCpu (via VM "vCPU" row)', () => { + it('uses vcpus when present', () => { + expect(findRow(vmRes({ vcpus: 4 }), 'vCPU')?.value).toBe('4 vCPU'); + }); + + it('uses single vCPU correctly', () => { + expect(findRow(vmRes({ vcpus: 1 }), 'vCPU')?.value).toBe('1 vCPU'); + }); + + it('falls back to cores x threads when vcpus is absent', () => { + expect(findRow(vmRes({ cores: 2, threads: 4 }), 'vCPU')?.value).toBe('2 cores x 4 threads'); + }); + + it('falls back to cores-only when only cores is present', () => { + expect(findRow(vmRes({ cores: 2 }), 'vCPU')?.value).toBe('2 cores'); + }); + + it('falls back to threads-only when only threads is present', () => { + expect(findRow(vmRes({ threads: 4 }), 'vCPU')?.value).toBe('4 threads'); + }); + + it('omits the row when none of vcpus/cores/threads is present', () => { + expect(findRow(vmRes({ state: 'running' }), 'vCPU')).toBeUndefined(); + }); +}); + +// ========================================================================= +// formatVMTopology +// ========================================================================= + +describe('formatVMTopology (via VM "Topology" row)', () => { + it('formats cores x threads', () => { + expect(findRow(vmRes({ cores: 2, threads: 4 }), 'Topology')?.value).toBe( + '2 cores x 4 threads', + ); + }); + + it('formats cores-only', () => { + expect(findRow(vmRes({ cores: 2 }), 'Topology')?.value).toBe('2 cores'); + }); + + it('formats single core', () => { + expect(findRow(vmRes({ cores: 1 }), 'Topology')?.value).toBe('1 core'); + }); + + it('formats threads-only', () => { + expect(findRow(vmRes({ threads: 4 }), 'Topology')?.value).toBe('4 threads'); + }); + + it('omits the row when neither cores nor threads is present', () => { + expect(findRow(vmRes({ vcpus: 4 }), 'Topology')).toBeUndefined(); + }); +}); + +// ========================================================================= +// storageStateTone +// ========================================================================= + +describe('storageStateTone (via Storage "State" row tone)', () => { + it('returns success for zfsPoolState "online"', () => { + expect(findRow(storageRes({ zfsPoolState: 'ONLINE' }), 'State')?.tone).toBe('success'); + }); + + it('returns success for zfsPoolState "healthy"', () => { + expect(findRow(storageRes({ zfsPoolState: 'HEALTHY' }), 'State')?.tone).toBe('success'); + }); + + it('returns success for arrayState "mounted" when zfsPoolState is absent', () => { + expect(findRow(storageRes({ arrayState: 'mounted' }), 'State')?.tone).toBe('success'); + }); + + it('returns warning for zfsPoolState "degraded"', () => { + expect(findRow(storageRes({ zfsPoolState: 'DEGRADED' }), 'State')?.tone).toBe('warning'); + }); + + it('returns warning for arrayState "warning" when zfsPoolState is absent', () => { + expect(findRow(storageRes({ arrayState: 'warning' }), 'State')?.tone).toBe('warning'); + }); + + it('returns warning for zfsPoolState "offline"', () => { + expect(findRow(storageRes({ zfsPoolState: 'OFFLINE' }), 'State')?.tone).toBe('warning'); + }); + + it('returns default for an unrecognized state', () => { + expect(findRow(storageRes({ zfsPoolState: 'SCRUBBING' }), 'State')?.tone).toBe('default'); + }); + + it('prefers zfsPoolState over arrayState', () => { + const resource = storageRes({ zfsPoolState: 'ONLINE', arrayState: 'degraded' }); + expect(findRow(resource, 'State')?.tone).toBe('success'); + }); + + it('falls back to resource.status when both pool/array states are absent', () => { + expect(findRow(storageRes({}, { status: 'degraded' }), 'State')?.tone).toBe('warning'); + }); +}); + +// ========================================================================= +// storageProtectionLabel +// ========================================================================= + +describe('storageProtectionLabel (via Storage "Protection" row)', () => { + it('uppercases "zfs"', () => { + expect(findRow(storageRes({ protection: 'zfs' }), 'Protection')?.value).toBe('ZFS'); + }); + + it('uppercases "raidz1"', () => { + expect(findRow(storageRes({ protection: 'raidz1' }), 'Protection')?.value).toBe('RAIDZ1'); + }); + + it('uppercases "raidz2"', () => { + expect(findRow(storageRes({ protection: 'raidz2' }), 'Protection')?.value).toBe('RAIDZ2'); + }); + + it('uppercases bare "raidz"', () => { + expect(findRow(storageRes({ protection: 'raidz' }), 'Protection')?.value).toBe('RAIDZ'); + }); + + it('title-cases other protection values', () => { + expect(findRow(storageRes({ protection: 'mirror' }), 'Protection')?.value).toBe('Mirror'); + }); + + it('title-cases delimited values', () => { + expect(findRow(storageRes({ protection: 'stripe_mirror' }), 'Protection')?.value).toBe( + 'Stripe Mirror', + ); + }); + + it('omits the row when protection is absent', () => { + expect(findRow(storageRes({}), 'Protection')).toBeUndefined(); + }); +}); + +// ========================================================================= +// storageUsageLabel +// ========================================================================= + +describe('storageUsageLabel (via Storage "Usage" row)', () => { + it('formats used / total when both are present', () => { + expect( + findRow(storageRes({}, { disk: { current: 0, used: 1024, total: 2048 } }), 'Usage')?.value, + ).toBe('1.00 KB / 2.00 KB'); + }); + + it('prefers used/total over percent', () => { + expect( + findRow( + storageRes({}, { disk: { used: 1024, total: 2048, current: 50 } }), + 'Usage', + )?.value, + ).toBe('1.00 KB / 2.00 KB'); + }); + + it('falls back to percent when used/total are absent', () => { + expect(findRow(storageRes({}, { disk: { current: 42.5 } }), 'Usage')?.value).toBe('42.5%'); + }); + + it('omits the row when nothing is present', () => { + expect(findRow(storageRes({}), 'Usage')).toBeUndefined(); + }); +}); + +// ========================================================================= +// diskTypeLabel +// ========================================================================= + +describe('diskTypeLabel (via Disk "Type" row)', () => { + type DiskTypeCase = { input: string; expected: string }; + + const cases: DiskTypeCase[] = [ + { input: 'nvme', expected: 'NVMe' }, + { input: 'NVME', expected: 'NVMe' }, + { input: 'sata', expected: 'SATA' }, + { input: 'sas', expected: 'SAS' }, + { input: 'ssd', expected: 'SSD' }, + { input: 'hdd', expected: 'HDD' }, + { input: 'usb_flash', expected: 'Usb Flash' }, + ]; + + for (const { input, expected } of cases) { + it(`maps "${input}" to "${expected}"`, () => { + expect(findRow(diskRes({ diskType: input }), 'Type')?.value).toBe(expected); + }); + } + + it('omits the row when diskType is absent', () => { + expect(findRow(diskRes({}), 'Type')).toBeUndefined(); + }); +}); + +// ========================================================================= +// serviceNameLabel +// ========================================================================= + +describe('serviceNameLabel (via System "Names" row)', () => { + type SvcCase = { name: string; service?: string; id?: string; expected: string }; + + const cases: SvcCase[] = [ + { name: 'ftp', service: 'ftp', id: '1', expected: 'FTP' }, + { name: 'nfs', service: 'nfs', id: '1', expected: 'NFS' }, + { name: 's3', service: 's3', id: '1', expected: 'S3' }, + { name: 'smb', service: 'smb', id: '1', expected: 'SMB' }, + { name: 'snmp', service: 'snmp', id: '1', expected: 'SNMP' }, + { name: 'ssh', service: 'ssh', id: '1', expected: 'SSH' }, + { name: 'ups', service: 'ups', id: '1', expected: 'UPS' }, + { name: 'smartd', service: 'smartd', id: '1', expected: 'SMART' }, + { name: 'arbitrary service', service: 'docker', id: '1', expected: 'Docker' }, + { name: 'id fallback', id: 'myservice', expected: 'Myservice' }, + { name: 'delimited id', id: 'web_server', expected: 'Web Server' }, + ]; + + for (const { name, service, id, expected } of cases) { + it(name, () => { + const resource = systemResource({ services: [{ id, service }] }); + expect(findRow(resource, 'Names')?.value).toBe(expected); + }); + } + + it('excludes a service from Names when neither service nor id is set', () => { + const resource = systemResource({ + services: [{ id: '1', service: 'smb' }, {}], + }); + expect(findRow(resource, 'Names')?.value).toBe('SMB'); + }); +}); + +// ========================================================================= +// shareStateLabel +// ========================================================================= + +describe('shareStateLabel (via Share "State" row value)', () => { + it('returns "Disabled" when enabled === false', () => { + expect(findRow(shareRes({ enabled: false }), 'State')?.value).toBe('Disabled'); + }); + + it('returns "Locked" when locked is true and enabled is not false', () => { + expect(findRow(shareRes({ locked: true }), 'State')?.value).toBe('Locked'); + }); + + it('returns "Enabled" when enabled is true', () => { + expect(findRow(shareRes({ enabled: true }), 'State')?.value).toBe('Enabled'); + }); + + it('returns "Enabled" by default when neither flag is set', () => { + expect(findRow(shareRes({}), 'State')?.value).toBe('Enabled'); + }); +}); + +// ========================================================================= +// shareStateTone +// ========================================================================= + +describe('shareStateTone (via Share "State" row tone)', () => { + it('returns warning when enabled === false', () => { + expect(findRow(shareRes({ enabled: false }), 'State')?.tone).toBe('warning'); + }); + + it('returns warning when locked is true', () => { + expect(findRow(shareRes({ locked: true }), 'State')?.tone).toBe('warning'); + }); + + it('returns success when enabled and not locked', () => { + expect(findRow(shareRes({ enabled: true }), 'State')?.tone).toBe('success'); + }); +}); + +// ========================================================================= +// shareModeLabel +// ========================================================================= + +describe('shareModeLabel (via Share "Mode" row)', () => { + it('returns "Read-only" when readOnly === true', () => { + expect(findRow(shareRes({ readOnly: true }), 'Mode')?.value).toBe('Read-only'); + }); + + it('returns "Read/write" when readOnly === false', () => { + expect(findRow(shareRes({ readOnly: false }), 'Mode')?.value).toBe('Read/write'); + }); + + it('omits the row when readOnly is undefined', () => { + expect(findRow(shareRes({}), 'Mode')).toBeUndefined(); + }); +}); + +// ========================================================================= +// shareUserGroupLabel +// ========================================================================= + +describe('shareUserGroupLabel (via Share "Map root" row)', () => { + it('joins user and group with a colon', () => { + expect( + findRow(shareRes({ mapRootUser: 'root', mapRootGroup: 'wheel' }), 'Map root')?.value, + ).toBe('root:wheel'); + }); + + it('returns user-only when group is absent', () => { + expect(findRow(shareRes({ mapRootUser: 'root' }), 'Map root')?.value).toBe('root'); + }); + + it('returns group-only when user is absent', () => { + expect(findRow(shareRes({ mapRootGroup: 'wheel' }), 'Map root')?.value).toBe('wheel'); + }); + + it('omits the row when neither user nor group is set', () => { + expect(findRow(shareRes({}), 'Map root')).toBeUndefined(); + }); + + it('works for the "Map all" row independently', () => { + expect( + findRow(shareRes({ mapAllUser: 'nobody', mapAllGroup: 'nogroup' }), 'Map all')?.value, + ).toBe('nobody:nogroup'); + }); +}); + +// ========================================================================= +// buildTrueNASDetailsSummary +// ========================================================================= + +describe('buildTrueNASDetailsSummary branch coverage', () => { + it('returns null for a non-truenas-scoped resource', () => { + expect(buildTrueNASDetailsSummary(baseResource({ platformType: 'docker' }))).toBeNull(); + }); + + it('returns null for a bare VM with no data', () => { + expect(buildTrueNASDetailsSummary(vmRes({}))).toBeNull(); + }); + + it('returns null for a bare physical disk with no data', () => { + expect(buildTrueNASDetailsSummary(diskRes({}))).toBeNull(); + }); + + it('returns null for a bare truenas system with no data', () => { + expect(buildTrueNASDetailsSummary(systemResource({}))).toBeNull(); + }); + + it('summarizes a VM without a device count', () => { + const resource = vmRes({ state: 'running', vcpus: 2, memoryBytes: 4 * 1024 ** 3 }); + expect(buildTrueNASDetailsSummary(resource)).toBe('Running, 2 vCPU, 4.00 GB'); + }); + + it('summarizes an app with no available updates', () => { + const resource = appRes({ state: 'running', containerCount: 1 }); + expect(buildTrueNASDetailsSummary(resource)).toBe('Running, 1 container, 0 ports'); + }); + + it('summarizes an app with both update types available', () => { + const resource = appRes({ upgradeAvailable: true, imageUpdatesAvailable: true }); + expect(buildTrueNASDetailsSummary(resource)).toBe('0 containers, 0 ports, 2 updates'); + }); + + it('summarizes a disabled share as just "Disabled"', () => { + expect(buildTrueNASDetailsSummary(shareRes({ enabled: false }))).toBe('Disabled'); + }); + + it('summarizes a locked share as just "Locked"', () => { + expect(buildTrueNASDetailsSummary(shareRes({ locked: true }))).toBe('Locked'); + }); + + it('includes uptime in system summary via uptimeSeconds', () => { + expect( + buildTrueNASDetailsSummary(systemResource({ version: 'v1', uptimeSeconds: 86_400 })), + ).toBe('v1, 1d'); + }); + + it('includes service count in system summary', () => { + const resource = systemResource({ + version: 'v1', + services: [{ id: '1', service: 'smb' }], + }); + expect(buildTrueNASDetailsSummary(resource)).toBe('v1, 1 service'); + }); +}); + +// ========================================================================= +// buildTrueNASAppSections +// ========================================================================= + +describe('buildTrueNASAppSections additional branches', () => { + it('sets State tone to success when running', () => { + expect(findRow(appRes({ state: 'running' }), 'State')?.tone).toBe('success'); + }); + + it('sets State tone to warning when not running', () => { + expect(findRow(appRes({ state: 'stopped' }), 'State')?.tone).toBe('warning'); + }); + + it('shows "Available" with warning tone when upgrade is available', () => { + const row = findRow(appRes({ upgradeAvailable: true }), 'App updates'); + expect(row?.value).toBe('Available'); + expect(row?.tone).toBe('warning'); + }); + + it('shows "Current" with success tone when no upgrade', () => { + const row = findRow(appRes({ upgradeAvailable: false }), 'App updates'); + expect(row?.value).toBe('Current'); + expect(row?.tone).toBe('success'); + }); + + it('omits App updates when upgradeAvailable is undefined', () => { + expect(findRow(appRes({ state: 'running' }), 'App updates')).toBeUndefined(); + }); + + it('shows "Available" with warning tone when image updates are available', () => { + const row = findRow(appRes({ imageUpdatesAvailable: true }), 'Image updates'); + expect(row?.value).toBe('Available'); + expect(row?.tone).toBe('warning'); + }); + + it('shows "Current" with success tone when no image updates', () => { + const row = findRow(appRes({ imageUpdatesAvailable: false }), 'Image updates'); + expect(row?.value).toBe('Current'); + expect(row?.tone).toBe('success'); + }); + + it('omits Image updates when imageUpdatesAvailable is undefined', () => { + expect(findRow(appRes({ state: 'running' }), 'Image updates')).toBeUndefined(); + }); + + it('uses containerCount directly for Containers row', () => { + expect(findRow(appRes({ containerCount: 3 }), 'Containers')?.value).toBe('3'); + }); + + it('falls back to containers array length for Containers row', () => { + expect( + findRow(appRes({ containers: [{ id: 'c1' }, { id: 'c2' }] }), 'Containers')?.value, + ).toBe('2'); + }); + + it('prefers humanVersion over version', () => { + expect(findRow(appRes({ humanVersion: '29.0.0', version: '29.0' }), 'Version')?.value).toBe( + '29.0.0', + ); + }); + + it('falls back to version when humanVersion is absent', () => { + expect(findRow(appRes({ version: '29.0' }), 'Version')?.value).toBe('29.0'); + }); + + it('shows "Yes" for custom app', () => { + expect(findRow(appRes({ customApp: true }), 'Custom app')?.value).toBe('Yes'); + }); + + it('shows "No" for non-custom app', () => { + expect(findRow(appRes({ customApp: false }), 'Custom app')?.value).toBe('No'); + }); +}); + +// ========================================================================= +// buildTrueNASVMSections +// ========================================================================= + +describe('buildTrueNASVMSections additional branches', () => { + it('omits Domain state when state and domainState match (sameState)', () => { + const resource = vmRes({ state: 'running', domainState: 'RUNNING', vcpus: 1 }); + expect(findRow(resource, 'Domain state')).toBeUndefined(); + }); + + it('shows Domain state when state and domainState differ', () => { + const resource = vmRes({ state: 'running', domainState: 'paused', vcpus: 1 }); + expect(findRow(resource, 'Domain state')?.value).toBe('Paused'); + }); + + it('sets State tone to warning for a non-running state', () => { + expect(findRow(vmRes({ state: 'stopped', vcpus: 1 }), 'State')?.tone).toBe('warning'); + }); + + it('uses domainState for State when state is absent', () => { + const resource = vmRes({ domainState: 'running', vcpus: 1 }); + expect(findRow(resource, 'State')?.value).toBe('Running'); + expect(findRow(resource, 'State')?.tone).toBe('success'); + }); + + it('joins machineType and archType in the Machine row', () => { + expect( + findRow(vmRes({ vcpus: 1, machineType: 'q35', archType: 'x86_64' }), 'Machine')?.value, + ).toBe('q35 / x86_64'); + }); + + it('shows only machineType when archType is absent', () => { + expect(findRow(vmRes({ vcpus: 1, machineType: 'q35' }), 'Machine')?.value).toBe('q35'); + }); + + it('omits the Machine row when both are absent', () => { + expect(findRow(vmRes({ vcpus: 1 }), 'Machine')).toBeUndefined(); + }); +}); + +// ========================================================================= +// buildTrueNASDiskSections +// ========================================================================= + +describe('buildTrueNASDiskSections additional branches', () => { + it('sets Temperature tone to warning at 55 degrees', () => { + expect(findRow(diskRes({ temperature: 55 }), 'Temperature')?.tone).toBe('warning'); + }); + + it('sets Temperature tone to warning above 55 degrees', () => { + expect(findRow(diskRes({ temperature: 60 }), 'Temperature')?.tone).toBe('warning'); + }); + + it('sets Temperature tone to default below 55 degrees', () => { + expect(findRow(diskRes({ temperature: 40 }), 'Temperature')?.tone).toBe('default'); + }); + + it('formats Wearout percentage for a valid value', () => { + expect(findRow(diskRes({ wearout: 50 }), 'Wearout')?.value).toBe('50.0%'); + }); + + it('omits Wearout for a negative value', () => { + expect(findRow(diskRes({ wearout: -1 }), 'Wearout')).toBeUndefined(); + }); + + it('omits Wearout when undefined', () => { + expect(findRow(diskRes({ health: 'passed' }), 'Wearout')).toBeUndefined(); + }); + + it('shows SMART reallocated sectors with warning tone when count > 0', () => { + const row = findRow(diskRes({ smart: { reallocatedSectors: 4 } }), 'Reallocated'); + expect(row?.value).toBe('4'); + expect(row?.tone).toBe('warning'); + }); + + it('shows SMART reallocated sectors with default tone when count is 0', () => { + const row = findRow(diskRes({ smart: { reallocatedSectors: 0 } }), 'Reallocated'); + expect(row?.value).toBe('0'); + expect(row?.tone).toBe('default'); + }); + + it('shows Risk reasons with summarizeList +N suffix', () => { + const resource = diskRes({ + risk: { + level: 'warning', + reasons: [ + { code: 'r1', severity: 'warning', summary: 'reason one' }, + { code: 'r2', severity: 'warning', summary: 'reason two' }, + { code: 'r3', severity: 'warning', summary: 'reason three' }, + ], + }, + }); + expect(findRow(resource, 'Reasons')?.value).toBe('reason one, reason two +1'); + expect(findRow(resource, 'Risk')?.value).toBe('Warning'); + expect(findRow(resource, 'Risk')?.tone).toBe('warning'); + }); + + it('uses resource.name as Device fallback when devPath is absent', () => { + expect(findRow(diskRes({}), 'Device')?.value).toBe('truenas-resource'); + }); + + it('uses resource.parentName as Group fallback when storageGroup is absent', () => { + const resource = baseResource({ + parentName: 'tank', + physicalDisk: { health: 'passed' }, + }); + expect(findRow(resource, 'Group')?.value).toBe('tank'); + }); +}); + +// ========================================================================= +// buildTrueNASStorageSections +// ========================================================================= + +describe('buildTrueNASStorageSections additional branches', () => { + it('uses resource.parentName as Pool fallback when storage.pool is absent', () => { + const resource = baseResource({ parentName: 'tank', storage: {} }); + expect(findRow(resource, 'Pool')?.value).toBe('tank'); + }); + + it('shows Risk level with warning tone', () => { + const resource = storageRes({ risk: { level: 'warning' } }); + expect(findRow(resource, 'Risk')?.value).toBe('Warning'); + expect(findRow(resource, 'Risk')?.tone).toBe('warning'); + }); + + it('shows Risk level with default tone for non-warning level', () => { + const resource = storageRes({ risk: { level: 'ok' } }); + expect(findRow(resource, 'Risk')?.value).toBe('Ok'); + expect(findRow(resource, 'Risk')?.tone).toBe('default'); + }); + +it('shows Protection reduced (booleanValue) with warning tone when true', () => { + const row = findRow(storageRes({ protectionReduced: true }), 'Protection reduced'); + expect(row?.value).toBe('Enabled'); + expect(row?.tone).toBe('warning'); + }); + + it('shows Protection reduced (booleanValue) with success tone when false', () => { + const row = findRow(storageRes({ protectionReduced: false }), 'Protection reduced'); + expect(row?.value).toBe('Disabled'); + expect(row?.tone).toBe('success'); + }); + + it('shows risk reasons with +N suffix', () => { + const resource = storageRes({ + risk: { + level: 'warning', + reasons: [ + { code: 'r1', severity: 'warning', summary: 'reason one' }, + { code: 'r2', severity: 'warning', summary: 'reason two' }, + { code: 'r3', severity: 'warning', summary: 'reason three' }, + ], + }, + }); + expect(findRow(resource, 'Reasons')?.value).toBe('reason one, reason two +1'); + }); + + it('shows posture summary with warning tone when present', () => { + const row = findRow(storageRes({ postureSummary: 'Needs attention' }), 'Posture'); + expect(row?.value).toBe('Needs attention'); + expect(row?.tone).toBe('warning'); + }); + + it('omits Posture when absent', () => { + expect(findRow(storageRes({}), 'Posture')).toBeUndefined(); + }); +}); + +// ========================================================================= +// buildTrueNASSystemSections +// ========================================================================= + +describe('buildTrueNASSystemSections additional branches', () => { + it('shows Storage risk with warning tone when level is warning', () => { + const row = findRow(systemResource({ storageRisk: { level: 'warning' } }), 'Storage risk'); + expect(row?.value).toBe('Warning'); + expect(row?.tone).toBe('warning'); + }); + + it('shows Storage risk with default tone for a non-warning level', () => { + const row = findRow(systemResource({ storageRisk: { level: 'ok' } }), 'Storage risk'); + expect(row?.value).toBe('Ok'); + expect(row?.tone).toBe('default'); + }); + + it('shows Storage summary with warning tone', () => { + const row = findRow( + systemResource({ storageRiskSummary: 'Pool degraded' }), + 'Storage summary', + ); + expect(row?.value).toBe('Pool degraded'); + expect(row?.tone).toBe('warning'); + }); + + it('shows Protection reduced with warning tone when true', () => { + const row = findRow(systemResource({ protectionReduced: true }), 'Protection reduced'); + expect(row?.value).toBe('Yes'); + expect(row?.tone).toBe('warning'); + }); + + it('shows Protection reduced with success tone when false', () => { + const row = findRow(systemResource({ protectionReduced: false }), 'Protection reduced'); + expect(row?.value).toBe('No'); + expect(row?.tone).toBe('success'); + }); + + it('shows Rebuild active with warning tone when in progress', () => { + const row = findRow(systemResource({ rebuildInProgress: true }), 'Rebuild active'); + expect(row?.value).toBe('Yes'); + expect(row?.tone).toBe('warning'); + }); + + it('shows PIDs row with summarized list when pids are present', () => { + const resource = systemResource({ + services: [{ id: '1', service: 'smb', pids: [100, 200] }], + }); + expect(findRow(resource, 'PIDs')?.value).toBe('100, 200'); + }); + + it('omits PIDs when all pids are non-positive', () => { + const resource = systemResource({ + services: [{ id: '1', service: 'smb', pids: [0, -1] }], + }); + expect(findRow(resource, 'PIDs')).toBeUndefined(); + }); + + it('shows service count when services are present', () => { + const resource = systemResource({ + services: [ + { id: '1', service: 'smb' }, + { id: '2', service: 'nfs' }, + ], + }); + expect(findRow(resource, 'Services')?.value).toBe('2 services'); + }); + + it('shows single service count correctly', () => { + const resource = systemResource({ services: [{ id: '1', service: 'smb' }] }); + expect(findRow(resource, 'Services')?.value).toBe('1 service'); + }); + + it('shows Hostname from truenas.hostname', () => { + expect(findRow(systemResource({ hostname: 'nas01' }), 'Hostname')?.value).toBe('nas01'); + }); + + it('falls back to resource.name when hostname is absent', () => { + expect(findRow(systemResource({}), 'Hostname')?.value).toBe('truenas-resource'); + }); +}); + +// ========================================================================= +// formatDiskHours (additional coverage) +// ========================================================================= + +describe('formatDiskHours additional edge cases', () => { + it('formats a value with thousands separator', () => { + expect(findRow(diskRes({ smart: { powerOnHours: 12_345 } }), 'Power on')?.value).toBe( + '12,345h', + ); + }); + + it('omits the row for a string value cast to number', () => { + const resource = diskRes({ smart: { powerOnHours: 'lots' as unknown as number } }); + expect(findRow(resource, 'Power on')).toBeUndefined(); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/aiFindingPresentation.coverage2.test.ts b/frontend-modern/src/utils/__tests__/aiFindingPresentation.coverage2.test.ts new file mode 100644 index 0000000000..e8dacb3925 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/aiFindingPresentation.coverage2.test.ts @@ -0,0 +1,1408 @@ +import { describe, expect, it } from 'vitest'; + +import type { UnifiedFinding } from '@/stores/aiIntelligence'; +import type { ApprovalRequest } from '@/api/ai'; +import { + formatFindingForClipboard, + getFindingPatrolWorkflowPresentation, + getFindingResolutionReason, + getFindingResourceCriticalitySortOrder, + getFindingSeverityBadgeClasses, + getFindingSeverityPresentation, + getFindingSeveritySortOrder, + getFindingSeverityToneClasses, + getFindingSourceBadgeClasses, + getFindingSourceLabel, + getFindingTitlePresentation, + getOperatorStateDismissCause, + getPatrolFindingsBadgePresentation, + getPatrolFindingResourceGroupKey, + getPatrolFindingRowScaffold, + getPatrolWorkTypeCompositionClause, + isPatrolRuntimeFinding, + normalizePatrolRuntimeFindingLabel, + sortFindingsForAttentionQueue, +} from '@/utils/aiFindingPresentation'; +import type { PatrolWorkTypeComposition } from '@/utils/aiFindingPresentation'; + +// --------------------------------------------------------------------------- +// Fixture builder — minimal finding with sensible defaults so each test +// overrides only the fields relevant to the branch under examination. +// --------------------------------------------------------------------------- + +const NOW = Date.parse('2026-07-01T00:00:00Z'); + +function makeFinding(overrides: Partial): UnifiedFinding { + return { + id: overrides.id ?? 'finding-1', + source: overrides.source ?? 'ai-patrol', + resourceId: overrides.resourceId ?? 'vm:101', + resourceName: overrides.resourceName ?? 'db-primary', + resourceType: overrides.resourceType ?? 'vm', + category: overrides.category ?? 'performance', + severity: overrides.severity ?? 'warning', + title: overrides.title ?? 'CPU saturated', + description: overrides.description ?? 'CPU is high', + detectedAt: overrides.detectedAt ?? '2026-06-30T08:00:00Z', + lastSeenAt: overrides.lastSeenAt ?? overrides.detectedAt ?? '2026-06-30T08:00:00Z', + status: overrides.status ?? 'active', + ...overrides, + }; +} + +/** A live pending investigation_fix approval targeting `findingId`. */ +function liveApproval(findingId: string, now = NOW): ApprovalRequest { + return { + id: 'approval-1', + status: 'pending', + toolId: 'investigation_fix', + targetId: findingId, + expiresAt: new Date(now + 60_000).toISOString(), + } as ApprovalRequest; +} + +// =================================================================== +// getFindingSourceLabel +// =================================================================== + +describe('getFindingSourceLabel', () => { + it('maps every known source to its display label', () => { + expect(getFindingSourceLabel('threshold')).toBe('Alert'); + expect(getFindingSourceLabel('ai-patrol')).toBe('Pulse Patrol'); + expect(getFindingSourceLabel('anomaly')).toBe('Anomaly'); + expect(getFindingSourceLabel('ai-chat')).toBe('Pulse Assistant'); + expect(getFindingSourceLabel('correlation')).toBe('Correlation'); + expect(getFindingSourceLabel('forecast')).toBe('Forecast'); + }); + + it('falls back to the raw source string for an unknown source', () => { + expect(getFindingSourceLabel('custom-tool')).toBe('custom-tool'); + }); + + it('falls back to the raw value for the empty string', () => { + expect(getFindingSourceLabel('')).toBe(''); + }); +}); + +// =================================================================== +// getFindingSourceBadgeClasses +// =================================================================== + +describe('getFindingSourceBadgeClasses', () => { + it('returns the threshold (orange) classes', () => { + expect(getFindingSourceBadgeClasses('threshold')).toContain('border-orange-200'); + }); + + it('returns the ai-patrol (blue) classes', () => { + expect(getFindingSourceBadgeClasses('ai-patrol')).toContain('border-blue-200'); + }); + + it('returns the anomaly (blue) classes', () => { + expect(getFindingSourceBadgeClasses('anomaly')).toContain('border-blue-200'); + }); + + it('returns the ai-chat (teal) classes', () => { + expect(getFindingSourceBadgeClasses('ai-chat')).toContain('border-teal-200'); + }); + + it('returns the correlation (sky) classes', () => { + expect(getFindingSourceBadgeClasses('correlation')).toContain('border-sky-200'); + }); + + it('returns the forecast (emerald) classes', () => { + expect(getFindingSourceBadgeClasses('forecast')).toContain('border-emerald-200'); + }); + + it('falls back to the ai-patrol classes for an unknown source', () => { + expect(getFindingSourceBadgeClasses('unknown')).toBe(getFindingSourceBadgeClasses('ai-patrol')); + }); + + it('falls back to the ai-patrol classes for the empty string', () => { + expect(getFindingSourceBadgeClasses('')).toBe(getFindingSourceBadgeClasses('ai-patrol')); + }); +}); + +// =================================================================== +// getFindingSeverityBadgeClasses +// =================================================================== + +describe('getFindingSeverityBadgeClasses', () => { + it('returns the critical (red) classes', () => { + expect(getFindingSeverityBadgeClasses('critical')).toContain('border-red-200'); + expect(getFindingSeverityBadgeClasses('critical')).toContain('text-red-700'); + }); + + it('returns the warning (amber) classes', () => { + expect(getFindingSeverityBadgeClasses('warning')).toContain('border-amber-200'); + }); + + it('returns the info (blue) classes', () => { + expect(getFindingSeverityBadgeClasses('info')).toContain('border-blue-200'); + }); + + it('returns the watch (neutral surface) classes', () => { + expect(getFindingSeverityBadgeClasses('watch')).toBe('border-border bg-surface-alt text-base-content'); + }); + + it('falls back to the default badge classes for an unknown severity', () => { + expect(getFindingSeverityBadgeClasses('severe')).toBe('border-border bg-surface-alt text-muted'); + }); + + it('falls back to the default badge classes for the empty string', () => { + expect(getFindingSeverityBadgeClasses('')).toBe('border-border bg-surface-alt text-muted'); + }); +}); + +// =================================================================== +// getFindingSeverityToneClasses (named "getFindingSeverityTone" in the +// coverage target list — the exported function is getFindingSeverityToneClasses) +// =================================================================== + +describe('getFindingSeverityToneClasses', () => { + it('returns the critical tone classes', () => { + expect(getFindingSeverityToneClasses('critical')).toBe( + 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', + ); + }); + + it('returns the warning tone classes', () => { + expect(getFindingSeverityToneClasses('warning')).toBe( + 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', + ); + }); + + it('returns the info tone classes', () => { + expect(getFindingSeverityToneClasses('info')).toBe( + 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', + ); + }); + + it('returns the watch tone classes', () => { + expect(getFindingSeverityToneClasses('watch')).toBe('bg-surface-alt text-base-content'); + }); + + it('falls back to the muted surface classes for an unknown severity', () => { + expect(getFindingSeverityToneClasses('unknown')).toBe('bg-surface-alt text-muted'); + }); + + it('falls back to the muted surface classes for the empty string', () => { + expect(getFindingSeverityToneClasses('')).toBe('bg-surface-alt text-muted'); + }); +}); + +// =================================================================== +// getFindingSeveritySortOrder +// =================================================================== + +describe('getFindingSeveritySortOrder', () => { + it('maps each known severity to its sort weight', () => { + expect(getFindingSeveritySortOrder('critical')).toBe(0); + expect(getFindingSeveritySortOrder('warning')).toBe(1); + expect(getFindingSeveritySortOrder('watch')).toBe(2); + expect(getFindingSeveritySortOrder('info')).toBe(3); + }); + + it('defaults an unknown severity to 4', () => { + expect(getFindingSeveritySortOrder('catastrophic')).toBe(4); + }); + + it('defaults the empty string to 4', () => { + expect(getFindingSeveritySortOrder('')).toBe(4); + }); + + it('ranks critical before warning before watch before info', () => { + expect(getFindingSeveritySortOrder('critical')).toBeLessThan(getFindingSeveritySortOrder('warning')); + expect(getFindingSeveritySortOrder('warning')).toBeLessThan(getFindingSeveritySortOrder('watch')); + expect(getFindingSeveritySortOrder('watch')).toBeLessThan(getFindingSeveritySortOrder('info')); + }); +}); + +// =================================================================== +// getFindingResourceCriticalitySortOrder +// =================================================================== + +describe('getFindingResourceCriticalitySortOrder', () => { + it('maps each known criticality to its exact sort weight', () => { + expect(getFindingResourceCriticalitySortOrder('high')).toBe(0); + expect(getFindingResourceCriticalitySortOrder('medium')).toBe(1); + expect(getFindingResourceCriticalitySortOrder('low')).toBe(3); + }); + + it('treats the empty string as the middle tier (2)', () => { + expect(getFindingResourceCriticalitySortOrder('')).toBe(2); + }); + + it('treats undefined as the middle tier (2)', () => { + expect(getFindingResourceCriticalitySortOrder(undefined)).toBe(2); + }); + + it('normalises to lowercase and trims whitespace before lookup', () => { + expect(getFindingResourceCriticalitySortOrder(' HIGH ')).toBe(0); + expect(getFindingResourceCriticalitySortOrder('Medium')).toBe(1); + }); + + it('defaults an unrecognised criticality to the middle tier (2)', () => { + expect(getFindingResourceCriticalitySortOrder('urgent')).toBe(2); + }); +}); + +// =================================================================== +// getFindingSeverityPresentation +// =================================================================== + +describe('getFindingSeverityPresentation', () => { + it('returns the raw severity label for a non-runtime finding with uppercase true', () => { + const result = getFindingSeverityPresentation( + makeFinding({ severity: 'warning', resourceId: 'vm:1' }), + ); + expect(result.label).toBe('warning'); + expect(result.uppercase).toBe(true); + expect(result.badgeClasses).toContain('border-amber-200'); + expect(result.badgeTone).toBe('warning'); + }); + + it('returns the raw severity for an unknown severity on a non-runtime finding', () => { + const result = getFindingSeverityPresentation( + makeFinding({ severity: 'unknown' as UnifiedFinding['severity'], resourceId: 'vm:1' }), + ); + expect(result.label).toBe('unknown'); + expect(result.uppercase).toBe(true); + }); + + it('returns "Runtime critical" for a patrol-runtime finding with critical severity', () => { + const result = getFindingSeverityPresentation( + makeFinding({ severity: 'critical', resourceId: 'ai-service' }), + ); + expect(result.label).toBe('Runtime critical'); + expect(result.uppercase).toBe(false); + expect(result.badgeTone).toBe('danger'); + expect(result.badgeClasses).toContain('border-red-200'); + }); + + it('returns "Runtime issue" for a patrol-runtime finding with non-critical severity', () => { + const result = getFindingSeverityPresentation( + makeFinding({ severity: 'warning', resourceId: 'ai-service' }), + ); + expect(result.label).toBe('Runtime issue'); + expect(result.uppercase).toBe(false); + expect(result.badgeTone).toBe('sky'); + expect(result.badgeClasses).toContain('border-sky-200'); + }); + + it('returns "Runtime issue" even when the runtime finding has critical severity via title match', () => { + // resourceId is not 'ai-service' but title starts with 'Pulse Patrol:' + const result = getFindingSeverityPresentation( + makeFinding({ severity: 'warning', title: 'Pulse Patrol: some issue' }), + ); + expect(result.label).toBe('Runtime issue'); + }); +}); + +// =================================================================== +// isPatrolRuntimeFinding +// =================================================================== + +describe('isPatrolRuntimeFinding', () => { + it('is true when resourceId is "ai-service"', () => { + expect(isPatrolRuntimeFinding({ resourceId: 'ai-service', resourceName: '', title: '' })).toBe(true); + }); + + it('is true when resourceName is "Pulse Patrol Service" (case-insensitive)', () => { + expect( + isPatrolRuntimeFinding({ resourceId: '', resourceName: 'Pulse Patrol Service', title: '' }), + ).toBe(true); + }); + + it('is true when title starts with "Pulse Patrol:" (case-insensitive)', () => { + expect( + isPatrolRuntimeFinding({ resourceId: '', resourceName: '', title: 'pulse patrol: credits' }), + ).toBe(true); + }); + + it('is false when none of the runtime identifiers match', () => { + expect(isPatrolRuntimeFinding({ resourceId: 'vm:1', resourceName: 'node-1', title: 'CPU high' })).toBe( + false, + ); + }); + + it('is false when all fields are empty strings', () => { + expect(isPatrolRuntimeFinding({ resourceId: '', resourceName: '', title: '' })).toBe(false); + }); + + it('trims whitespace before comparing resourceId', () => { + expect( + isPatrolRuntimeFinding({ resourceId: ' ai-service ', resourceName: '', title: '' }), + ).toBe(true); + }); + + it('does not match partial resourceId like "ai-service-backup"', () => { + expect( + isPatrolRuntimeFinding({ resourceId: 'ai-service-backup', resourceName: '', title: '' }), + ).toBe(false); + }); + + it('does not match a title that contains but does not start with "Pulse Patrol:"', () => { + expect( + isPatrolRuntimeFinding({ resourceId: '', resourceName: '', title: 'Issue: Pulse Patrol: down' }), + ).toBe(false); + }); +}); + +// =================================================================== +// normalizePatrolRuntimeFindingLabel +// =================================================================== + +describe('normalizePatrolRuntimeFindingLabel', () => { + it('strips a leading "Pulse Patrol:" prefix (case-insensitive)', () => { + expect(normalizePatrolRuntimeFindingLabel('Pulse Patrol: CPU high')).toBe('CPU high'); + }); + + it('strips a lowercase "pulse patrol:" prefix', () => { + expect(normalizePatrolRuntimeFindingLabel('pulse patrol: CPU high')).toBe('CPU high'); + }); + + it('returns "Provider billing or quota issue" for the insufficient-credits label', () => { + expect(normalizePatrolRuntimeFindingLabel('Insufficient API credits')).toBe( + 'Provider billing or quota issue', + ); + }); + + it('returns "Provider billing or quota issue" even when the prefix is present', () => { + expect(normalizePatrolRuntimeFindingLabel('Pulse Patrol: Insufficient API credits')).toBe( + 'Provider billing or quota issue', + ); + }); + + it('returns the trimmed title unchanged when it has no special form', () => { + expect(normalizePatrolRuntimeFindingLabel(' Disk full ')).toBe('Disk full'); + }); + + it('returns empty string for undefined', () => { + expect(normalizePatrolRuntimeFindingLabel(undefined)).toBe(''); + }); + + it('returns empty string for an empty or whitespace-only title', () => { + expect(normalizePatrolRuntimeFindingLabel(' ')).toBe(''); + expect(normalizePatrolRuntimeFindingLabel('')).toBe(''); + }); +}); + +// =================================================================== +// getFindingTitlePresentation +// =================================================================== + +describe('getFindingTitlePresentation', () => { + it('returns the trimmed raw title for a non-runtime finding', () => { + expect(getFindingTitlePresentation(makeFinding({ title: ' CPU high ', resourceId: 'vm:1' }))).toEqual({ + label: 'CPU high', + }); + }); + + it('returns the empty label for a non-runtime finding with no title', () => { + expect(getFindingTitlePresentation(makeFinding({ title: '', resourceId: 'vm:1' }))).toEqual({ + label: '', + }); + }); + + it('returns the normalized label for a runtime finding with a standard title', () => { + expect( + getFindingTitlePresentation(makeFinding({ title: 'Pulse Patrol: CPU high', resourceId: 'ai-service' })), + ).toEqual({ label: 'CPU high' }); + }); + + it('returns "Patrol runtime issue" when the runtime finding title normalises to empty', () => { + expect( + getFindingTitlePresentation(makeFinding({ title: '', resourceId: 'ai-service' })), + ).toEqual({ label: 'Patrol runtime issue' }); + }); + + it('returns "Provider billing or quota issue" for the insufficient-credits runtime title', () => { + expect( + getFindingTitlePresentation( + makeFinding({ title: 'Pulse Patrol: Insufficient API credits', resourceId: 'ai-service' }), + ), + ).toEqual({ label: 'Provider billing or quota issue' }); + }); +}); + +// =================================================================== +// getPatrolFindingResourceGroupKey +// =================================================================== + +describe('getPatrolFindingResourceGroupKey', () => { + it('groups by subject label when resourceName is present', () => { + const key = getPatrolFindingResourceGroupKey( + makeFinding({ id: 'f1', resourceName: 'db-primary', resourceType: '', resourceId: 'vm:1' }), + ); + expect(key).toBe('subject:db-primary'); + }); + + it('groups by subject label (with formatted type) when only resourceType is present', () => { + const key = getPatrolFindingResourceGroupKey( + makeFinding({ id: 'f1', resourceName: '', resourceType: 'k8s_pod', resourceId: 'vm:1' }), + ); + // getFindingSubjectPresentation formats as "resourceName (type)" but resourceName + // falls back to resourceId when empty. Here resourceName is '' so it uses resourceId. + // resourceType present → label = `${resourceName-or-id} (${formatIdentifierLabel(type)})` + expect(key).toBe('subject:vm:1 (k8s pod)'); + }); + + it('groups by subject label using resourceName when both name and type are present', () => { + const key = getPatrolFindingResourceGroupKey( + makeFinding({ id: 'f1', resourceName: 'web-node', resourceType: 'vm', resourceId: 'vm:1' }), + ); + expect(key).toBe('subject:web-node (vm)'); + }); + + it('groups by resourceId when neither resourceName nor resourceType is present', () => { + const key = getPatrolFindingResourceGroupKey( + makeFinding({ id: 'f1', resourceName: '', resourceType: '', resourceId: 'vm:42' }), + ); + expect(key).toBe('id:vm:42'); + }); + + it('groups by finding id when nothing is present', () => { + const key = getPatrolFindingResourceGroupKey( + makeFinding({ id: 'lonely', resourceName: '', resourceType: '', resourceId: '' }), + ); + expect(key).toBe('finding:lonely'); + }); + + it('lowercases the subject label for consistent grouping', () => { + const key = getPatrolFindingResourceGroupKey( + makeFinding({ id: 'f1', resourceName: 'DB-Primary', resourceType: '', resourceId: 'vm:1' }), + ); + expect(key).toBe('subject:db-primary'); + }); +}); + +// =================================================================== +// getPatrolFindingsBadgePresentation +// =================================================================== + +describe('getPatrolFindingsBadgePresentation', () => { + it('returns danger for an active non-runtime critical finding', () => { + expect( + getPatrolFindingsBadgePresentation([ + makeFinding({ status: 'active', severity: 'critical', resourceId: 'vm:1' }), + ]), + ).toEqual({ tone: 'danger' }); + }); + + it('returns info for an active runtime critical finding', () => { + expect( + getPatrolFindingsBadgePresentation([ + makeFinding({ status: 'active', severity: 'critical', resourceId: 'ai-service' }), + ]), + ).toEqual({ tone: 'info' }); + }); + + it('returns warning for an active non-runtime warning finding', () => { + expect( + getPatrolFindingsBadgePresentation([ + makeFinding({ status: 'active', severity: 'warning', resourceId: 'vm:1' }), + ]), + ).toEqual({ tone: 'warning' }); + }); + + it('returns info for an active runtime warning finding', () => { + expect( + getPatrolFindingsBadgePresentation([ + makeFinding({ status: 'active', severity: 'warning', resourceId: 'ai-service' }), + ]), + ).toEqual({ tone: 'info' }); + }); + + it('returns muted when only non-critical/non-warning active findings exist', () => { + expect( + getPatrolFindingsBadgePresentation([ + makeFinding({ status: 'active', severity: 'info', resourceId: 'vm:1' }), + ]), + ).toEqual({ tone: 'muted' }); + }); + + it('returns muted for an empty findings list', () => { + expect(getPatrolFindingsBadgePresentation([])).toEqual({ tone: 'muted' }); + }); + + it('returns muted when all critical/warning findings are resolved (not active)', () => { + expect( + getPatrolFindingsBadgePresentation([ + makeFinding({ status: 'resolved', severity: 'critical', resourceId: 'vm:1' }), + makeFinding({ status: 'dismissed', severity: 'warning', resourceId: 'vm:2' }), + ]), + ).toEqual({ tone: 'muted' }); + }); + + it('prioritises non-runtime critical (danger) over runtime warning (info)', () => { + expect( + getPatrolFindingsBadgePresentation([ + makeFinding({ status: 'active', severity: 'warning', resourceId: 'ai-service' }), + makeFinding({ status: 'active', severity: 'critical', resourceId: 'vm:1' }), + ]), + ).toEqual({ tone: 'danger' }); + }); + + it('returns info when both runtime-critical and non-runtime-warning are active', () => { + expect( + getPatrolFindingsBadgePresentation([ + makeFinding({ status: 'active', severity: 'critical', resourceId: 'ai-service' }), + makeFinding({ status: 'active', severity: 'warning', resourceId: 'vm:1' }), + ]), + ).toEqual({ tone: 'info' }); + }); +}); + +// =================================================================== +// getFindingPatrolWorkflowPresentation +// =================================================================== + +describe('getFindingPatrolWorkflowPresentation', () => { + it('returns undefined when the finding source is not ai-patrol', () => { + expect( + getFindingPatrolWorkflowPresentation(makeFinding({ source: 'threshold' })), + ).toBeUndefined(); + }); + + it('returns the recorded stage for a resolved finding', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'resolved' }), + ); + expect(result).toEqual({ + stage: 'recorded', + label: 'Outcome recorded', + detail: 'Patrol has a verified or cleared outcome for this finding.', + tone: 'success', + }); + }); + + it('returns the paused stage for a snoozed finding', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'snoozed' }), + ); + expect(result?.stage).toBe('paused'); + expect(result?.label).toBe('Paused until reminder'); + }); + + it('returns undefined for a dismissed finding (status not active/resolved/snoozed)', () => { + expect( + getFindingPatrolWorkflowPresentation(makeFinding({ source: 'ai-patrol', status: 'dismissed' })), + ).toBeUndefined(); + }); + + it('returns the attention stage for a runtime finding even when active', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'ai-service' }), + ); + expect(result?.stage).toBe('attention'); + expect(result?.label).toBe('Fix Patrol setup'); + }); + + it('returns the approval stage with "Approve or reject" when a live approval exists', () => { + const finding = makeFinding({ id: 'f1', source: 'ai-patrol', status: 'active', resourceId: 'vm:1' }); + const result = getFindingPatrolWorkflowPresentation(finding, [liveApproval('f1')], NOW); + expect(result?.stage).toBe('approval'); + expect(result?.label).toBe('Approve or reject'); + expect(result?.tone).toBe('warning'); + }); + + it('returns the investigating stage when investigationStatus is running', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationStatus: 'running' }), + ); + expect(result?.stage).toBe('investigating'); + expect(result?.label).toBe('Patrol investigating'); + expect(result?.tone).toBe('indigo'); + }); + + it('returns the investigating stage when investigationStatus is pending', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationStatus: 'pending' }), + ); + expect(result?.stage).toBe('investigating'); + }); + + it('returns the investigating stage when loopState is investigating', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', loopState: 'investigating' }), + ); + expect(result?.stage).toBe('investigating'); + }); + + it('returns the recorded stage for an active finding with fix_verified outcome', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'fix_verified' }), + ); + expect(result?.stage).toBe('recorded'); + expect(result?.label).toBe('Outcome recorded'); + }); + + it('returns the recorded stage for an active finding with resolved outcome', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'resolved' }), + ); + expect(result?.stage).toBe('recorded'); + }); + + it('returns the approval stage with "Review fix" for fix_queued without a live approval', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ id: 'f1', source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'fix_queued' }), + ); + expect(result?.stage).toBe('approval'); + expect(result?.label).toBe('Review fix'); + }); + + it('returns the verification stage for fix_executed', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'fix_executed' }), + ); + expect(result?.stage).toBe('verification'); + expect(result?.label).toBe('Verify outcome'); + }); + + it('returns the attention stage for fix_failed', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'fix_failed' }), + ); + expect(result?.stage).toBe('attention'); + expect(result?.label).toBe('Fix failed'); + expect(result?.tone).toBe('danger'); + }); + + it('returns the attention stage for fix_verification_failed', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'fix_verification_failed' }), + ); + expect(result?.stage).toBe('attention'); + }); + + it('returns the attention stage for fix_rejected with "Decide follow-up"', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'fix_rejected' }), + ); + expect(result?.stage).toBe('attention'); + expect(result?.label).toBe('Decide follow-up'); + expect(result?.tone).toBe('warning'); + }); + + it('returns the verification stage for fix_verification_unknown', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'fix_verification_unknown' }), + ); + expect(result?.stage).toBe('verification'); + expect(result?.label).toBe('Check outcome'); + }); + + it('returns the attention stage with "Needs input" for needs_attention', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'needs_attention' }), + ); + expect(result?.stage).toBe('attention'); + expect(result?.label).toBe('Needs input'); + }); + + it('returns the attention stage for cannot_fix', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'cannot_fix' }), + ); + expect(result?.stage).toBe('attention'); + }); + + it('returns the attention stage for timed_out', () => { + const result = getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: 'timed_out' }), + ); + expect(result?.stage).toBe('attention'); + }); + + it('returns undefined for an active finding with an unrecognised outcome and no investigation status', () => { + expect( + getFindingPatrolWorkflowPresentation( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', investigationOutcome: undefined }), + ), + ).toBeUndefined(); + }); +}); + +// =================================================================== +// getPatrolFindingVerificationSummary (private, reached via +// getPatrolFindingRowScaffold — the scaffold guard requires source +// 'ai-patrol', status 'active', and NOT a patrol-runtime finding) +// =================================================================== + +describe('getPatrolFindingVerificationSummary (via getPatrolFindingRowScaffold)', () => { + const verificationValue = (overrides: Partial): string | undefined => { + const scaffold = getPatrolFindingRowScaffold( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', ...overrides }), + ); + return scaffold?.items.find((item) => item.id === 'verification')?.value; + }; + + it('reports waiting-for-approval for fix_queued', () => { + expect(verificationValue({ investigationOutcome: 'fix_queued' })).toBe( + 'Waiting for approval before any fix runs.', + ); + }); + + it('reports verification-in-progress for fix_executed', () => { + expect(verificationValue({ investigationOutcome: 'fix_executed' })).toBe( + 'Fix ran; verification is in progress.', + ); + }); + + it('reports verified outcome for fix_verified', () => { + expect(verificationValue({ investigationOutcome: 'fix_verified' })).toBe( + 'Verified outcome recorded.', + ); + }); + + it('reports verified outcome for resolved', () => { + expect(verificationValue({ investigationOutcome: 'resolved' })).toBe( + 'Verified outcome recorded.', + ); + }); + + it('reports verification failed for fix_verification_failed', () => { + expect(verificationValue({ investigationOutcome: 'fix_verification_failed' })).toBe( + 'Verification failed and needs review.', + ); + }); + + it('reports inconclusive for fix_verification_unknown', () => { + expect(verificationValue({ investigationOutcome: 'fix_verification_unknown' })).toBe( + 'Verification was inconclusive.', + ); + }); + + it('reports no verified fix for fix_failed', () => { + expect(verificationValue({ investigationOutcome: 'fix_failed' })).toBe( + 'No verified fix; action needs review.', + ); + }); + + it('reports no verified fix for cannot_fix', () => { + expect(verificationValue({ investigationOutcome: 'cannot_fix' })).toBe( + 'No verified fix; action needs review.', + ); + }); + + it('reports no verified fix for timed_out', () => { + expect(verificationValue({ investigationOutcome: 'timed_out' })).toBe( + 'No verified fix; action needs review.', + ); + }); + + it('reports no verified fix for needs_attention', () => { + expect(verificationValue({ investigationOutcome: 'needs_attention' })).toBe( + 'No verified fix; action needs review.', + ); + }); + + it('reports rejected fix for fix_rejected', () => { + expect(verificationValue({ investigationOutcome: 'fix_rejected' })).toBe( + 'No change ran because the fix was rejected.', + ); + }); + + it('reports investigating when investigationStatus is running and no outcome', () => { + expect(verificationValue({ investigationStatus: 'running' })).toBe( + 'Patrol is investigating; no fix has run yet.', + ); + }); + + it('reports investigating when investigationStatus is pending and no outcome', () => { + expect(verificationValue({ investigationStatus: 'pending' })).toBe( + 'Patrol is investigating; no fix has run yet.', + ); + }); + + it('reports no-fix-yet when no outcome and investigationStatus is completed', () => { + expect(verificationValue({ investigationStatus: 'completed' })).toBe('No fix has run yet.'); + }); + + it('reports no-fix-yet when no outcome and no investigation status', () => { + expect(verificationValue({})).toBe('No fix has run yet.'); + }); +}); + +// =================================================================== +// getPatrolFindingWorkflowSummary (private, reached via +// getPatrolFindingRowScaffold). The 'paused' stage is unreachable here +// because the scaffold guard requires status 'active' and 'paused' +// only arises from status 'snoozed'. +// =================================================================== + +describe('getPatrolFindingWorkflowSummary (via getPatrolFindingRowScaffold)', () => { + const workflowValue = (overrides: Partial, approvals: ApprovalRequest[] = []): string | undefined => { + const scaffold = getPatrolFindingRowScaffold( + makeFinding({ source: 'ai-patrol', status: 'active', resourceId: 'vm:1', ...overrides }), + approvals, + NOW, + ); + return scaffold?.items.find((item) => item.id === 'workflow')?.value; + }; + + it('returns the default review message when no workflow stage is determined', () => { + // Active, non-runtime, no investigation status/outcome → workflow undefined + expect(workflowValue({})).toBe( + 'Review evidence, decide the next action, and verify any outcome before closing.', + ); + }); + + it('returns the approve-or-reject message when a live approval is pending', () => { + expect( + workflowValue({ id: 'f1' }, [liveApproval('f1')]), + ).toBe( + 'Review evidence first; no change runs until the proposed fix is approved, then Patrol verifies the outcome.', + ); + }); + + it('returns the recover-queued-fix message for fix_queued without a live approval (Review fix stage)', () => { + expect( + workflowValue({ investigationOutcome: 'fix_queued' }), + ).toBe( + 'Recover the queued fix before any action can run, then verify the outcome after a decision.', + ); + }); + + it('returns the verification message for fix_executed', () => { + expect( + workflowValue({ investigationOutcome: 'fix_executed' }), + ).toBe('The governed action ran; review follow-up evidence before closing the issue.'); + }); + + it('returns the attention message for fix_failed', () => { + expect( + workflowValue({ investigationOutcome: 'fix_failed' }), + ).toBe('Review the blocked or failed step before approving another change or resolving manually.'); + }); + + it('returns the investigating message for running investigationStatus', () => { + expect( + workflowValue({ investigationStatus: 'running' }), + ).toBe('Patrol is explaining the issue and preparing the next decision point.'); + }); + + it('returns the recorded message for fix_verified outcome', () => { + expect( + workflowValue({ investigationOutcome: 'fix_verified' }), + ).toBe('Patrol recorded the outcome; use history if you need the completed trail.'); + }); +}); + +// =================================================================== +// getFindingResolutionReason +// =================================================================== + +describe('getFindingResolutionReason', () => { + const base = { + isThreshold: false as boolean | undefined, + source: 'ai-chat' as UnifiedFinding['source'], + alertType: undefined as string | undefined, + investigationOutcome: undefined as UnifiedFinding['investigationOutcome'], + autoResolved: undefined as boolean | undefined, + }; + + describe('manual resolution priority', () => { + it('returns "Resolved by you" when autoResolved is explicitly false and outcome is not a patrol fix', () => { + expect( + getFindingResolutionReason({ ...base, autoResolved: false }, 'today'), + ).toBe('Resolved by you today'); + }); + + it('does NOT return "Resolved by you" when autoResolved is false but outcome is fix_verified', () => { + const reason = getFindingResolutionReason( + { ...base, autoResolved: false, investigationOutcome: 'fix_verified' }, + 'today', + ); + expect(reason).not.toContain('Resolved by you'); + }); + + it('does NOT return "Resolved by you" when autoResolved is false but outcome is fix_executed', () => { + const reason = getFindingResolutionReason( + { ...base, autoResolved: false, investigationOutcome: 'fix_executed' }, + 'today', + ); + expect(reason).not.toContain('Resolved by you'); + }); + + it('does NOT return "Resolved by you" when autoResolved is false but outcome is resolved', () => { + const reason = getFindingResolutionReason( + { ...base, autoResolved: false, investigationOutcome: 'resolved' }, + 'today', + ); + expect(reason).not.toContain('Resolved by you'); + }); + + it('does NOT return "Resolved by you" when autoResolved is undefined', () => { + const reason = getFindingResolutionReason({ ...base, autoResolved: undefined }, 'today'); + expect(reason).not.toContain('Resolved by you'); + }); + }); + + describe('threshold alert types', () => { + const thresholdBase = { ...base, isThreshold: true, source: 'threshold' as const }; + + it('returns guest-online for powered-off alertType', () => { + expect( + getFindingResolutionReason({ ...thresholdBase, alertType: 'powered-off' }, 'now'), + ).toBe('Guest came online now'); + }); + + it('returns agent-online for host-offline alertType', () => { + expect( + getFindingResolutionReason({ ...thresholdBase, alertType: 'host-offline' }, 'now'), + ).toBe('Agent came online now'); + }); + + it('returns cpu-normal for cpu alertType', () => { + expect( + getFindingResolutionReason({ ...thresholdBase, alertType: 'cpu' }, 'now'), + ).toBe('CPU returned to normal now'); + }); + + it('returns memory-normal for memory alertType', () => { + expect( + getFindingResolutionReason({ ...thresholdBase, alertType: 'memory' }, 'now'), + ).toBe('Memory returned to normal now'); + }); + + it('returns disk-normal for disk alertType', () => { + expect( + getFindingResolutionReason({ ...thresholdBase, alertType: 'disk' }, 'now'), + ).toBe('Disk usage returned to normal now'); + }); + + it('returns network-recovered for network alertType', () => { + expect( + getFindingResolutionReason({ ...thresholdBase, alertType: 'network' }, 'now'), + ).toBe('Network recovered now'); + }); + + it('returns condition-cleared for an unrecognised alertType', () => { + expect( + getFindingResolutionReason({ ...thresholdBase, alertType: 'custom' }, 'now'), + ).toBe('Condition cleared now'); + }); + + it('returns condition-cleared when alertType is undefined', () => { + expect( + getFindingResolutionReason({ ...thresholdBase, alertType: undefined }, 'now'), + ).toBe('Condition cleared now'); + }); + + it('treats source=threshold (without isThreshold) as threshold', () => { + expect( + getFindingResolutionReason( + { ...base, isThreshold: false, source: 'threshold', alertType: 'cpu' }, + 'now', + ), + ).toBe('CPU returned to normal now'); + }); + }); + + describe('ai-patrol investigation outcomes', () => { + const patrolBase = { ...base, source: 'ai-patrol' as const }; + + it('returns "Fixed by Patrol" for fix_verified', () => { + expect( + getFindingResolutionReason({ ...patrolBase, investigationOutcome: 'fix_verified' }, 'now'), + ).toBe('Fixed by Patrol now'); + }); + + it('returns "Fix applied by Patrol" for fix_executed', () => { + expect( + getFindingResolutionReason({ ...patrolBase, investigationOutcome: 'fix_executed' }, 'now'), + ).toBe('Fix applied by Patrol now'); + }); + + it('returns "Resolved by Patrol" for resolved', () => { + expect( + getFindingResolutionReason({ ...patrolBase, investigationOutcome: 'resolved' }, 'now'), + ).toBe('Resolved by Patrol now'); + }); + + it('returns "Resolved after fix failed" for fix_failed', () => { + expect( + getFindingResolutionReason({ ...patrolBase, investigationOutcome: 'fix_failed' }, 'now'), + ).toBe('Resolved after fix failed now'); + }); + + it('returns "Resolved after rejected fix" for fix_rejected', () => { + expect( + getFindingResolutionReason({ ...patrolBase, investigationOutcome: 'fix_rejected' }, 'now'), + ).toBe('Resolved after rejected fix now'); + }); + + it('returns "Resolved while fix was pending" for fix_queued', () => { + expect( + getFindingResolutionReason({ ...patrolBase, investigationOutcome: 'fix_queued' }, 'now'), + ).toBe('Resolved while fix was pending now'); + }); + + it('returns "Resolved after failed verification" for fix_verification_failed', () => { + expect( + getFindingResolutionReason( + { ...patrolBase, investigationOutcome: 'fix_verification_failed' }, + 'now', + ), + ).toBe('Resolved after failed verification now'); + }); + + it('returns "Resolved after inconclusive verification" for fix_verification_unknown', () => { + expect( + getFindingResolutionReason( + { ...patrolBase, investigationOutcome: 'fix_verification_unknown' }, + 'now', + ), + ).toBe('Resolved after inconclusive verification now'); + }); + + it('returns "Resolved after investigation timeout" for timed_out', () => { + expect( + getFindingResolutionReason({ ...patrolBase, investigationOutcome: 'timed_out' }, 'now'), + ).toBe('Resolved after investigation timeout now'); + }); + + it('returns "Resolved manually" for cannot_fix', () => { + expect( + getFindingResolutionReason({ ...patrolBase, investigationOutcome: 'cannot_fix' }, 'now'), + ).toBe('Resolved manually now'); + }); + + it('returns "Resolved after manual review" for needs_attention', () => { + expect( + getFindingResolutionReason({ ...patrolBase, investigationOutcome: 'needs_attention' }, 'now'), + ).toBe('Resolved after manual review now'); + }); + + it('returns "Fix applied by Patrol" for fix_executed even when autoResolved is false', () => { + expect( + getFindingResolutionReason( + { ...patrolBase, investigationOutcome: 'fix_executed', autoResolved: false }, + 'now', + ), + ).toBe('Fix applied by Patrol now'); + }); + + it('returns the default patrol fallback when no outcome is set', () => { + expect( + getFindingResolutionReason({ ...patrolBase, investigationOutcome: undefined }, 'now'), + ).toBe('Issue no longer detected now'); + }); + }); + + describe('fallback for non-threshold non-patrol sources', () => { + it('returns the generic "Resolved" message', () => { + expect(getFindingResolutionReason({ ...base, source: 'anomaly' }, 'now')).toBe('Resolved now'); + }); + + it('returns the generic "Resolved" message for ai-chat source', () => { + expect(getFindingResolutionReason({ ...base, source: 'ai-chat' }, 'now')).toBe('Resolved now'); + }); + }); +}); + +// =================================================================== +// formatFindingForClipboard +// =================================================================== + +describe('formatFindingForClipboard', () => { + it('formats a fully-populated finding with all optional sections', () => { + const result = formatFindingForClipboard( + makeFinding({ + severity: 'critical', + title: 'DB down', + resourceName: 'db-primary', + resourceType: 'vm', + description: 'The database is unreachable.', + impact: 'All writes are failing.', + recommendation: 'Restart the service.', + investigationRecord: { confidence: 'high' } as UnifiedFinding['investigationRecord'], + regressionCount: 2, + }), + ); + expect(result).toContain('**[CRITICAL] DB down**'); + expect(result).toContain('Resource: db-primary (vm)'); + expect(result).toContain('Description: The database is unreachable.'); + expect(result).toContain('Impact: All writes are failing.'); + expect(result).toContain('Confidence: high'); + expect(result).toContain('Regressed: 2×'); + }); + + it('uses "FINDING" when severity is empty', () => { + const result = formatFindingForClipboard( + makeFinding({ severity: '' as UnifiedFinding['severity'], title: 'X' }), + ); + expect(result).toContain('**[FINDING] X**'); + }); + + it('uses "Untitled finding" when title is empty', () => { + const result = formatFindingForClipboard(makeFinding({ title: '' })); + expect(result).toContain('**[WARNING] Untitled finding**'); + }); + + it('omits the resource line when neither resourceName nor resourceType is set', () => { + const result = formatFindingForClipboard( + makeFinding({ resourceName: '', resourceType: '', title: 'Issue' }), + ); + expect(result).not.toContain('Resource:'); + }); + + it('includes resource with only resourceName (no type parenthetical)', () => { + const result = formatFindingForClipboard( + makeFinding({ resourceName: 'web-node', resourceType: '', title: 'Issue' }), + ); + expect(result).toContain('Resource: web-node'); + expect(result).not.toContain('('); + }); + + it('omits description and impact sections when they are absent', () => { + const result = formatFindingForClipboard( + makeFinding({ description: '', impact: undefined, title: 'Issue' }), + ); + expect(result).not.toContain('Description:'); + expect(result).not.toContain('Impact:'); + }); + + it('omits the trust line when neither confidence nor regressionCount is present', () => { + const result = formatFindingForClipboard( + makeFinding({ title: 'Issue', investigationRecord: undefined, regressionCount: 0 }), + ); + expect(result).not.toContain('Confidence:'); + expect(result).not.toContain('Regressed:'); + }); + + it('includes only confidence (no regression) in the trust line', () => { + const result = formatFindingForClipboard( + makeFinding({ + title: 'Issue', + investigationRecord: { confidence: 'low' } as UnifiedFinding['investigationRecord'], + regressionCount: 0, + }), + ); + expect(result).toContain('Confidence: low'); + expect(result).not.toContain('Regressed:'); + }); + + it('includes only regression (no confidence) in the trust line', () => { + const result = formatFindingForClipboard( + makeFinding({ title: 'Issue', investigationRecord: undefined, regressionCount: 3 }), + ); + expect(result).toContain('Regressed: 3×'); + expect(result).not.toContain('Confidence:'); + }); + + it('does not include regression when regressionCount is zero', () => { + const result = formatFindingForClipboard( + makeFinding({ title: 'Issue', regressionCount: 0 }), + ); + expect(result).not.toContain('Regressed:'); + }); +}); + +// =================================================================== +// getOperatorStateDismissCause +// =================================================================== + +describe('getOperatorStateDismissCause', () => { + it('returns empty string when lifecycle is undefined', () => { + expect(getOperatorStateDismissCause({ lifecycle: undefined })).toBe(''); + }); + + it('returns empty string when lifecycle is an empty array', () => { + expect(getOperatorStateDismissCause({ lifecycle: [] })).toBe(''); + }); + + it('returns the cause from the most recent dismissed event', () => { + expect( + getOperatorStateDismissCause({ + lifecycle: [ + { at: 't1', type: 'detected' }, + { at: 't2', type: 'dismissed', metadata: { operator_state_cause: 'maintenance_window' } }, + ], + }), + ).toBe('maintenance_window'); + }); + + it('returns the cause even when a non-dismissed event follows the dismissed one', () => { + expect( + getOperatorStateDismissCause({ + lifecycle: [ + { at: 't1', type: 'dismissed', metadata: { operator_state_cause: 'intentionally_offline' } }, + { at: 't2', type: 'resolved' }, + ], + }), + ).toBe('intentionally_offline'); + }); + + it('returns empty string for a dismissed event without a cause (manual dismissal)', () => { + expect( + getOperatorStateDismissCause({ + lifecycle: [{ at: 't1', type: 'dismissed' }], + }), + ).toBe(''); + }); + + it('stops scanning at the first dismissed-without-cause event so a stale earlier cause does not leak', () => { + expect( + getOperatorStateDismissCause({ + lifecycle: [ + { at: 't1', type: 'dismissed', metadata: { operator_state_cause: 'maintenance_window' } }, + { at: 't2', type: 'dismissed' }, + ], + }), + ).toBe(''); + }); + + it('returns empty string when no dismissed events exist', () => { + expect( + getOperatorStateDismissCause({ + lifecycle: [ + { at: 't1', type: 'detected' }, + { at: 't2', type: 'resolved' }, + ], + }), + ).toBe(''); + }); + + it('returns empty string when the dismissed metadata exists but has no operator_state_cause key', () => { + expect( + getOperatorStateDismissCause({ + lifecycle: [{ at: 't1', type: 'dismissed', metadata: { other_key: 'val' } }], + }), + ).toBe(''); + }); +}); + +// =================================================================== +// getPatrolWorkTypeCompositionClause +// =================================================================== + +describe('getPatrolWorkTypeCompositionClause', () => { + const emptyComp: PatrolWorkTypeComposition = { + total: 0, + approval: 0, + failed: 0, + inProgress: 0, + recurring: 0, + newIssues: 0, + }; + + it('returns empty string when all counts are zero', () => { + expect(getPatrolWorkTypeCompositionClause(emptyComp)).toBe(''); + }); + + it('uses singular "needs approval" for exactly one approval', () => { + expect( + getPatrolWorkTypeCompositionClause({ ...emptyComp, approval: 1 }), + ).toBe(' — 1 needs approval'); + }); + + it('uses plural "need approval" for multiple approvals', () => { + expect( + getPatrolWorkTypeCompositionClause({ ...emptyComp, approval: 3 }), + ).toBe(' — 3 need approval'); + }); + + it('uses singular "failed fix" for exactly one failed', () => { + expect( + getPatrolWorkTypeCompositionClause({ ...emptyComp, failed: 1 }), + ).toBe(' — 1 failed fix'); + }); + + it('uses plural "failed fixes" for multiple failed', () => { + expect( + getPatrolWorkTypeCompositionClause({ ...emptyComp, failed: 2 }), + ).toBe(' — 2 failed fixes'); + }); + + it('includes in-progress count', () => { + expect( + getPatrolWorkTypeCompositionClause({ ...emptyComp, inProgress: 4 }), + ).toBe(' — 4 in progress'); + }); + + it('includes recurring count', () => { + expect( + getPatrolWorkTypeCompositionClause({ ...emptyComp, recurring: 5 }), + ).toBe(' — 5 recurring'); + }); + + it('joins all parts with commas in priority order', () => { + expect( + getPatrolWorkTypeCompositionClause({ + ...emptyComp, + approval: 1, + failed: 2, + inProgress: 3, + recurring: 4, + }), + ).toBe(' — 1 needs approval, 2 failed fixes, 3 in progress, 4 recurring'); + }); +}); + +// =================================================================== +// sortFindingsForAttentionQueue — outcome branch and recency tiebreaker +// (existing tests cover severity + resource-criticality branches) +// =================================================================== + +describe('sortFindingsForAttentionQueue (outcome ordering + recency tiebreaker)', () => { + it('orders a failed-verification outcome before a queued-fix outcome', () => { + const sorted = sortFindingsForAttentionQueue([ + makeFinding({ id: 'queued', investigationOutcome: 'fix_queued' }), + makeFinding({ id: 'failed-verify', investigationOutcome: 'fix_verification_failed' }), + ]); + expect(sorted.map((f) => f.id)).toEqual(['failed-verify', 'queued']); + }); + + it('orders an active finding with a fix outcome before a resolved finding with the same severity', () => { + const sorted = sortFindingsForAttentionQueue([ + makeFinding({ id: 'resolved', status: 'resolved', investigationOutcome: undefined }), + makeFinding({ id: 'active-failed', status: 'active', investigationOutcome: 'fix_failed' }), + ]); + expect(sorted.map((f) => f.id)).toEqual(['active-failed', 'resolved']); + }); + + it('uses recency as the final tiebreaker (newer lastSeenAt first) when outcome, severity, and criticality are equal', () => { + const sorted = sortFindingsForAttentionQueue([ + makeFinding({ id: 'older', lastSeenAt: '2026-06-30T08:00:00Z' }), + makeFinding({ id: 'newer', lastSeenAt: '2026-06-30T09:00:00Z' }), + ]); + expect(sorted.map((f) => f.id)).toEqual(['newer', 'older']); + }); + + it('does not mutate the input array', () => { + const input = [ + makeFinding({ id: 'b', investigationOutcome: 'fix_queued' }), + makeFinding({ id: 'a', investigationOutcome: 'fix_failed' }), + ]; + const inputIdsBefore = input.map((f) => f.id); + sortFindingsForAttentionQueue(input); + expect(input.map((f) => f.id)).toEqual(inputIdsBefore); + }); + + it('returns an empty array for an empty input', () => { + expect(sortFindingsForAttentionQueue([])).toEqual([]); + }); + + it('uses detectedAt for recency of a resolved finding (not lastSeenAt)', () => { + const sorted = sortFindingsForAttentionQueue([ + makeFinding({ + id: 'recent-detected', + status: 'resolved', + detectedAt: '2026-06-30T10:00:00Z', + lastSeenAt: '2026-06-30T08:00:00Z', + }), + makeFinding({ + id: 'older-detected', + status: 'resolved', + detectedAt: '2026-06-30T09:00:00Z', + lastSeenAt: '2026-06-30T12:00:00Z', + }), + ]); + // Both resolved, same outcome weight (3), same severity/criticality/runtime. + // Recency uses detectedAt for non-active: 10:00 > 09:00 → recent-detected first. + expect(sorted.map((f) => f.id)).toEqual(['recent-detected', 'older-detected']); + }); +});