From 906d4823aabcb033aa552586c42d790d2b932c78 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 12 Jul 2026 10:49:05 +0100 Subject: [PATCH] test(frontend): branch coverage for 7 pure modules (GLM wave 0712 tier 2d) --- ...ssistantTurnSummary.branchcov0712b.test.ts | 194 +++++ ...ilDrawerVmwareModel.branchcov0712b.test.ts | 188 +++++ ...sistantContextModel.branchcov0712b.test.ts | 306 ++++++++ ...hRecordPresentation.branchcov0712b.test.ts | 692 ++++++++++++++++++ ...StoragePresentation.branchcov0712b.test.ts | 299 ++++++++ .../rowPresentation.branchcov0712b.test.ts | 441 +++++++++++ .../patrolFormat.branchcov0712b.test.ts | 364 +++++++++ 7 files changed, 2484 insertions(+) create mode 100644 frontend-modern/src/components/AI/Chat/__tests__/assistantTurnSummary.branchcov0712b.test.ts create mode 100644 frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.branchcov0712b.test.ts create mode 100644 frontend-modern/src/components/Workloads/__tests__/guestAssistantContextModel.branchcov0712b.test.ts create mode 100644 frontend-modern/src/features/storageBackups/__tests__/cephRecordPresentation.branchcov0712b.test.ts create mode 100644 frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.branchcov0712b.test.ts create mode 100644 frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts create mode 100644 frontend-modern/src/utils/__tests__/patrolFormat.branchcov0712b.test.ts diff --git a/frontend-modern/src/components/AI/Chat/__tests__/assistantTurnSummary.branchcov0712b.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/assistantTurnSummary.branchcov0712b.test.ts new file mode 100644 index 0000000000..cd72511d4a --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/__tests__/assistantTurnSummary.branchcov0712b.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest'; +import { + formatAssistantTurnDuration, + getAssistantTurnSummary, +} from '../assistantTurnSummary'; +import type { ChatMessage } from '../types'; + +// Branch-coverage companion to assistantTurnSummary.test.ts. Each test below +// drives a specific arm (early return, ternary branch, optional chain, `||` +// fallback, guard) of the three named functions and asserts against concrete +// outputs rather than truthiness. assistantTokenSummary is module-private, so +// every one of its branches is exercised through getAssistantTurnSummary and +// observed via the embedded label/title text (model + completedAt are omitted +// to isolate the token-summary output where useful). + +const baseAssistant = (overrides: Partial = {}): ChatMessage => ({ + id: 'asst-x', + role: 'assistant', + content: '', + timestamp: new Date('2026-06-06T12:00:00Z'), + isStreaming: false, + ...overrides, +}); + +// --------------------------------------------------------------------------- +// formatAssistantTurnDuration +// --------------------------------------------------------------------------- + +describe('formatAssistantTurnDuration branch coverage', () => { + it("returns '' when completedAt is omitted (the !completedAt guard)", () => { + expect(formatAssistantTurnDuration(new Date('2026-06-06T12:00:00Z'))).toBe(''); + }); + + it("returns '' when the computed duration is non-finite (invalid completedAt -> NaN)", () => { + // new Date('not-a-date') is an Invalid Date whose getTime() is NaN, so + // durationMs is NaN and the !Number.isFinite arm fires. + expect( + formatAssistantTurnDuration(new Date('2026-06-06T12:00:00Z'), new Date('not-a-date')), + ).toBe(''); + }); + + it("formats a sub-minute duration as plain seconds ('Ns')", () => { + expect( + formatAssistantTurnDuration( + new Date('2026-06-06T12:00:00Z'), + new Date('2026-06-06T12:00:05Z'), + ), + ).toBe('5s'); + }); + + it("formats an exact minute boundary with no seconds ('Xm', seconds ternary falsy arm)", () => { + expect( + formatAssistantTurnDuration( + new Date('2026-06-06T12:00:00Z'), + new Date('2026-06-06T12:02:00Z'), + ), + ).toBe('2m'); + }); + + it("formats hours with leftover minutes ('Xh Ym', remainingMinutes ternary truthy arm)", () => { + // 1h 2m 5s -> minutes 62, seconds 5 -> hours 1, remainingMinutes 2. + expect( + formatAssistantTurnDuration( + new Date('2026-06-06T12:00:00Z'), + new Date('2026-06-06T13:02:05Z'), + ), + ).toBe('1h 2m'); + }); + + it("formats an exact hour with no leftover minutes ('Xh', remainingMinutes ternary falsy arm)", () => { + // exactly 3600s -> minutes 60, seconds 0 -> hours 1, remainingMinutes 0. + expect( + formatAssistantTurnDuration( + new Date('2026-06-06T12:00:00Z'), + new Date('2026-06-06T13:00:00Z'), + ), + ).toBe('1h'); + }); +}); + +// --------------------------------------------------------------------------- +// assistantTokenSummary (module-private — reached via getAssistantTurnSummary). +// model + completedAt omitted so label/title carry only the token summary. +// --------------------------------------------------------------------------- + +describe('assistantTokenSummary branch coverage', () => { + it("emits the singular 'token' word when the total is exactly 1", () => { + expect(getAssistantTurnSummary(baseAssistant({ tokens: { input: 0, output: 1 } }))).toEqual({ + label: 'Last turn: 1 token', + title: 'Last assistant turn summary: Usage: 1 total, 0 input, 1 output', + }); + }); + + it('clamps the context percentage to 100 when input exceeds the limit (Math.min arm)', () => { + expect( + getAssistantTurnSummary( + baseAssistant({ tokens: { input: 50000, output: 100, contextLimit: 100 } }), + ), + ).toEqual({ + label: 'Last turn: 50,100 tokens (100% of context)', + title: + 'Last assistant turn summary: Usage: 50,100 total, 50,000 input, 100 output, context 50,000 of 100 (100%)', + }); + }); + + it('drops the context detail when input is 0 even with a contextLimit present (input>0 false arm)', () => { + expect( + getAssistantTurnSummary( + baseAssistant({ tokens: { input: 0, output: 5, contextLimit: 1000 } }), + ), + ).toEqual({ + label: 'Last turn: 5 tokens', + title: 'Last assistant turn summary: Usage: 5 total, 0 input, 5 output', + }); + }); + + it('drops the context detail when contextLimit is 0 (contextLimit>0 false arm)', () => { + expect( + getAssistantTurnSummary(baseAssistant({ tokens: { input: 10, output: 5, contextLimit: 0 } })), + ).toEqual({ + label: 'Last turn: 15 tokens', + title: 'Last assistant turn summary: Usage: 15 total, 10 input, 5 output', + }); + }); +}); + +// --------------------------------------------------------------------------- +// getAssistantTurnSummary +// --------------------------------------------------------------------------- + +describe('getAssistantTurnSummary branch coverage', () => { + it('falls back to the raw model id when no getModelRouteLabel option is supplied (?. absent + || model)', () => { + expect( + getAssistantTurnSummary( + baseAssistant({ + model: 'openrouter:qwen/qwen3.7-plus', + tokens: { input: 10, output: 5 }, + }), + ), + ).toEqual({ + label: 'Last turn: openrouter:qwen/qwen3.7-plus · 15 tokens', + title: + 'Last assistant turn summary: Model: openrouter:qwen/qwen3.7-plus. Usage: 15 total, 10 input, 5 output', + }); + }); + + it('falls back to the raw model id when getModelRouteLabel returns an empty string (|| model falsy arm)', () => { + expect( + getAssistantTurnSummary( + baseAssistant({ model: 'direct:local', tokens: { input: 2, output: 3 } }), + { getModelRouteLabel: () => '' }, + ), + ).toEqual({ + label: 'Last turn: direct:local · 5 tokens', + title: 'Last assistant turn summary: Model: direct:local. Usage: 5 total, 2 input, 3 output', + }); + }); + + it("omits the model part when model is whitespace-only (model?.trim()='' -> model falsy -> '')", () => { + expect( + getAssistantTurnSummary( + baseAssistant({ + model: ' ', + completedAt: new Date('2026-06-06T12:00:03Z'), + tokens: { input: 1, output: 1 }, + }), + { getModelRouteLabel: () => 'Should Not Appear' }, + ), + ).toEqual({ + label: 'Last turn: 3s · 2 tokens', + title: 'Last assistant turn summary: Duration: 3s. Usage: 2 total, 1 input, 1 output', + }); + }); + + it("omits the model part when model is absent (message.model?. absent short-circuit)", () => { + expect( + getAssistantTurnSummary( + baseAssistant({ + completedAt: new Date('2026-06-06T12:00:03Z'), + tokens: { input: 10, output: 5 }, + }), + ), + ).toEqual({ + label: 'Last turn: 3s · 15 tokens', + title: 'Last assistant turn summary: Duration: 3s. Usage: 15 total, 10 input, 5 output', + }); + }); + + it('returns null when there is nothing to summarize (no model, no completedAt, tokens?. absent -> output<=0)', () => { + // Drives: message.tokens?. short-circuit in assistantTokenSummary, the + // output<=0 -> null guard, and the labelParts.length===0 -> null guard. + expect(getAssistantTurnSummary(baseAssistant())).toBeNull(); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.branchcov0712b.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.branchcov0712b.test.ts new file mode 100644 index 0000000000..c6eb873add --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.branchcov0712b.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; +import { + buildVMwareDetailSections, + buildVMwareDetailsSummary, +} from '@/components/Infrastructure/resourceDetailDrawerVmwareModel'; +import type { DetailRow } from '@/components/shared/detailSectionModel'; +import type { + ResourceVMwareMeta, + ResourceVMwareSnapshot, +} from '@/types/resource'; + +// Branch-coverage complement to resourceDetailDrawerVmwareModel.coverage.test.ts. +// Scope is strictly: buildVMwareDetailsSummary, formatMiB, flattenSnapshotRows. +// formatMiB and flattenSnapshotRows are module-private, so they are driven +// through the public entry points exactly as the sibling coverage test does: +// - formatMiB -> "Memory size" row of the Virtual hardware section +// - flattenSnapshotRows -> "Snapshot tree" section rows +// buildVMwareDetailsSummary is exported and is asserted on directly. + +type Sections = ReturnType; + +const findSection = (sections: Sections, label: string) => + sections.find((section) => section.label === label); + +const findRow = ( + sections: Sections, + sectionLabel: string, + rowLabel: string, +): DetailRow | undefined => + findSection(sections, sectionLabel)?.rows.find((row) => row.label === rowLabel); + +const vmSections = (vmware?: Partial): Sections => + buildVMwareDetailSections('vm', (vmware ?? {}) as ResourceVMwareMeta); + +const hardwareRow = ( + vmware: Partial, + label: string, +): DetailRow | undefined => findRow(vmSections(vmware), 'Virtual hardware', label); + +const snapshotSectionRows = (snapshots: ResourceVMwareSnapshot[]): DetailRow[] => + findSection(vmSections({ snapshotTree: snapshots }), 'Snapshot tree')?.rows ?? []; + +describe('buildVMwareDetailsSummary (additional branches)', () => { + it('falls back to vcenterHost when connectionName is only whitespace', () => { + expect( + buildVMwareDetailsSummary('vm', { connectionName: ' ', vcenterHost: 'vc-02.lab' }), + ).toBe('vc-02.lab · Read-only vCenter context'); + }); + + it('omits the snapshot part for a non-vm type even when snapshotCount is positive', () => { + expect(buildVMwareDetailsSummary('datastore', { snapshotCount: 5 })).toBe( + 'Read-only vCenter context', + ); + }); + + it('omits vNIC and disk counts for a non-vm type even when adapters/disks are present', () => { + expect( + buildVMwareDetailsSummary('datastore', { + networkAdapters: [{ nic: '1' }], + virtualDisks: [{ disk: '1', type: 'SCSI' }], + }), + ).toBe('Read-only vCenter context'); + }); + + it('omits both host and vm counts for a network resource with no network membership', () => { + expect(buildVMwareDetailsSummary('network', {})).toBe('Read-only vCenter context'); + }); + + it('counts only hosts when the network has hosts but no vms', () => { + expect(buildVMwareDetailsSummary('network', { networkHostNames: ['esxi-01'] })).toBe( + 'Read-only vCenter context · 1 host', + ); + }); + + it('counts only vms when the network has vms but no hosts', () => { + expect( + buildVMwareDetailsSummary('network', { networkVmNames: ['vm-01', 'vm-02'] }), + ).toBe('Read-only vCenter context · 2 VMs'); + }); + + it('clamps an explicit snapshotCount of 0 to no snapshot part', () => { + expect(buildVMwareDetailsSummary('vm', { snapshotCount: 0 })).toBe( + 'Read-only vCenter context', + ); + }); + + it('omits alarm and task parts when both counts are explicitly zero', () => { + expect( + buildVMwareDetailsSummary('vm', { activeAlarmCount: 0, recentTaskCount: 0 }), + ).toBe('Read-only vCenter context'); + }); + + // SUSPECT SOURCE BEHAVIOR (reported, not fixed): the host/vm count chains use + // `names?.length ?? ids?.length ?? 0`. Because an empty names array has + // length 0 (not nullish), the `??` ids fallback is skipped, so present ids are + // silently ignored whenever the names array exists but is empty. + it('ignores networkHostIds when networkHostNames is an empty array (?? short-circuit)', () => { + expect( + buildVMwareDetailsSummary('network', { + networkHostNames: [], + networkHostIds: ['h-1', 'h-2'], + }), + ).toBe('Read-only vCenter context'); + }); +}); + +describe('formatMiB (via Memory size row)', () => { + const memoryRow = (memorySizeMib?: number): DetailRow | undefined => + hardwareRow({ hardware: { version: 'VMX_10' }, memorySizeMib }, 'Memory size'); + + it('formats a fractional gibibyte value with compact precision', () => { + expect(memoryRow(1536)?.value).toBe('1.5 GB'); + }); + + it('scales up to the terabyte unit for a large memory size', () => { + expect(memoryRow(1_048_576)?.value).toBe('1 TB'); + }); + + it('keeps a sub-gigibyte value in megabytes', () => { + expect(memoryRow(768)?.value).toBe('768 MB'); + }); + + it('returns empty for +Infinity (the !Number.isFinite guard arm)', () => { + // memorySizeMib is typed number | undefined; Infinity is a valid number and + // is rejected by formatMiB's Number.isFinite guard. + expect(memoryRow(Number.POSITIVE_INFINITY)).toBeUndefined(); + }); + + it('returns empty when MiB overflow makes bytes Infinity (the ?? "" fallback arm)', () => { + // 2e302 is itself finite and non-negative, so it passes formatMiB's guard, + // but 2e302 * 1024 * 1024 overflows to Infinity and formatDetailBytesValue + // returns null, exercising formatMiB's `?? ""` fallback. + expect(memoryRow(2e302)).toBeUndefined(); + }); +}); + +describe('flattenSnapshotRows (via Snapshot tree section)', () => { + it('falls back to the snapshot ref when snapshotValue resolves to empty', () => { + // No current/state/createdAt/quiesced/description => snapshotValue() is '', + // so the row value comes from `|| asTrimmedString(snapshot.snapshot)`. + expect(snapshotSectionRows([{ snapshot: 'snap-9' }])[0]).toEqual({ + label: 'snap-9', + value: 'snap-9', + tone: 'default', + }); + }); + + it('drops a snapshot whose row makeDetailRow returns null, keeping valid siblings', () => { + // A bare {} snapshot has no value (snapshotValue '' and no snapshot ref) so + // makeDetailRow returns null and the row is skipped; the sibling survives. + expect( + snapshotSectionRows([{}, { snapshot: 's-1', state: 'poweredOn' }]).map((row) => row.label), + ).toEqual(['s-1']); + }); + + it('indents grandchildren two levels deep via "-".repeat(depth)', () => { + expect( + snapshotSectionRows([ + { + name: 'root', + state: 'poweredOn', + children: [ + { + name: 'child', + state: 'poweredOn', + children: [{ name: 'grandchild', state: 'poweredOn' }], + }, + ], + }, + ]).map((row) => row.label), + ).toEqual(['root', '- child', '-- grandchild']); + }); + + it('marks a non-current nested snapshot with the default tone', () => { + const rows = snapshotSectionRows([ + { + name: 'root', + state: 'poweredOn', + current: true, + children: [{ name: 'child', state: 'poweredOn' }], + }, + ]); + expect(rows).toEqual([ + { label: 'root', value: 'current · poweredOn', tone: 'accent' }, + { label: '- child', value: 'poweredOn', tone: 'default' }, + ]); + }); +}); diff --git a/frontend-modern/src/components/Workloads/__tests__/guestAssistantContextModel.branchcov0712b.test.ts b/frontend-modern/src/components/Workloads/__tests__/guestAssistantContextModel.branchcov0712b.test.ts new file mode 100644 index 0000000000..0af6fc5211 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/guestAssistantContextModel.branchcov0712b.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, it } from 'vitest'; +import type { WorkloadGuest } from '@/types/workloads'; +import { + buildGuestAssistantContext, + getGuestAssistantTechnology, +} from '../guestAssistantContextModel'; + +// `firstTrimmed` is module-private (not exported), so it is exercised +// indirectly through `getGuestAssistantTechnology`, which is its only caller. +const makeGuest = (overrides?: Partial): WorkloadGuest => + ({ + id: 'guest-0', + vmid: 100, + name: 'workload-0', + node: 'pve', + instance: 'cluster-a', + status: 'running', + type: 'qemu', + cpu: 0.5, + cpus: 2, + memory: { total: 4096, used: 1024, free: 3072, usage: 0.25 }, + disk: { total: 102400, used: 10240, free: 92160, usage: 0.1 }, + networkIn: 100, + networkOut: 200, + diskRead: 10, + diskWrite: 5, + uptime: 3600, + template: false, + lastBackup: 0, + tags: [], + lock: '', + lastSeen: new Date().toISOString(), + workloadType: 'vm', + ...overrides, + }) as WorkloadGuest; + +describe('guestAssistantContextModel (branch coverage)', () => { + describe('getGuestAssistantTechnology (drives firstTrimmed branches)', () => { + describe('app-container arm — firstTrimmed([containerRuntime, platformType, type])', () => { + it('returns the containerRuntime when present (first value wins, trimmed)', () => { + expect( + getGuestAssistantTechnology( + makeGuest({ + workloadType: 'app-container', + type: 'app-container', + containerRuntime: ' podman ', + platformType: 'docker', + }), + ), + ).toBe('podman'); + }); + + it('skips a whitespace-only containerRuntime and falls back to platformType', () => { + // Exercises firstTrimmed's `(value || '').trim()` -> '' (falsy) -> continue. + expect( + getGuestAssistantTechnology( + makeGuest({ + workloadType: 'app-container', + type: 'app-container', + containerRuntime: ' ', + platformType: 'docker', + }), + ), + ).toBe('docker'); + }); + + it('falls back to platformType when containerRuntime is undefined', () => { + expect( + getGuestAssistantTechnology( + makeGuest({ + workloadType: 'app-container', + type: 'app-container', + containerRuntime: undefined, + platformType: 'docker', + }), + ), + ).toBe('docker'); + }); + + it('falls back to type when containerRuntime and platformType are absent', () => { + expect( + getGuestAssistantTechnology( + makeGuest({ + workloadType: 'app-container', + type: 'app-container', + containerRuntime: undefined, + platformType: undefined, + }), + ), + ).toBe('app-container'); + }); + + it('returns undefined when all three candidates are blank (loop completion)', () => { + expect( + getGuestAssistantTechnology( + makeGuest({ + workloadType: 'app-container', + type: '', + containerRuntime: '', + platformType: '', + }), + ), + ).toBeUndefined(); + }); + }); + + describe("pod arm — firstTrimmed([platformType, 'kubernetes'])", () => { + it('returns platformType when present', () => { + expect( + getGuestAssistantTechnology( + makeGuest({ + workloadType: 'pod', + type: 'pod', + platformType: 'managed-aks', + }), + ), + ).toBe('managed-aks'); + }); + + it("falls back to the 'kubernetes' constant when platformType is absent", () => { + expect( + getGuestAssistantTechnology( + makeGuest({ + workloadType: 'pod', + type: 'pod', + platformType: undefined, + }), + ), + ).toBe('kubernetes'); + }); + }); + + describe('default arm (vm / system-container) — firstTrimmed([platformType, type])', () => { + it('returns platformType for a vm when present', () => { + expect( + getGuestAssistantTechnology( + makeGuest({ workloadType: 'vm', type: 'qemu', platformType: 'proxmox-ve' }), + ), + ).toBe('proxmox-ve'); + }); + + it('falls back to type for a vm when platformType is absent', () => { + expect( + getGuestAssistantTechnology( + makeGuest({ workloadType: 'vm', type: 'qemu', platformType: undefined }), + ), + ).toBe('qemu'); + }); + + it('returns undefined for a vm when both platformType and type are blank', () => { + expect( + getGuestAssistantTechnology( + makeGuest({ workloadType: 'vm', type: '', platformType: '' }), + ), + ).toBeUndefined(); + }); + + it('returns platformType for a system-container when present', () => { + expect( + getGuestAssistantTechnology( + makeGuest({ + workloadType: 'system-container', + type: 'lxc', + platformType: 'incus', + }), + ), + ).toBe('incus'); + }); + }); + }); + + describe('buildGuestAssistantContext', () => { + describe('display-name resolution (guest.name || canonicalId)', () => { + it('uses guest.name as the title and handoff name when present', () => { + const result = buildGuestAssistantContext( + makeGuest({ + id: 'vm-101', + vmid: 101, + name: 'web-vm', + node: 'node-a', + instance: 'pve-a', + workloadType: 'vm', + type: 'qemu', + }), + ); + + expect(result.briefing?.title).toBe('web-vm'); + expect(result.handoffResources?.[0]?.name).toBe('web-vm'); + }); + + it('falls back to the canonical node-scoped id when name is empty', () => { + const result = buildGuestAssistantContext( + makeGuest({ + id: 'vm-101', + vmid: 101, + name: '', + node: 'node-a', + instance: 'pve-a', + workloadType: 'vm', + type: 'qemu', + }), + ); + + // canonicalId = buildCanonicalNodeScopedWorkloadId -> "pve-a:node-a:101" + expect(result.targetId).toBe('pve-a:node-a:101'); + expect(result.briefing?.title).toBe('pve-a:node-a:101'); + expect(result.handoffResources?.[0]?.name).toBe('pve-a:node-a:101'); + }); + }); + + it('emits Primary/Parent/Discovery/readiness lines for a discovered VM', () => { + const result = buildGuestAssistantContext( + makeGuest({ + id: 'vm-101', + vmid: 101, + name: 'web-vm', + node: 'node-a', + instance: 'pve-a', + status: 'running', + workloadType: 'vm', + type: 'qemu', + platformType: 'proxmox-ve', + discoveryTarget: { + resourceType: 'vm', + agentId: 'agent-1', + resourceId: 'pve-a:node-a:101', + }, + discoveryReadiness: { state: 'fresh', factCount: 5 }, + }), + ); + + expect(result).toStrictEqual({ + targetType: 'resource', + targetId: 'pve-a:node-a:101', + context: { + source: 'guest-drawer', + resourceId: 'pve-a:node-a:101', + resourceType: 'vm', + resourceStatus: 'running', + }, + briefing: { + sourceLabel: 'Pulse resource context', + title: 'web-vm', + subject: 'vm / running / proxmox-ve', + statusLabel: 'Read-only context attached · Discovery fresh', + detailLines: [ + 'Resource ID: pve-a:node-a:101', + 'Primary identity: vm-101', + 'Parent: node-a', + 'Discovery: vm:pve-a:node-a:101', + 'Discovery data: Discovery fresh, 5 facts', + ], + safetyNote: 'Approval required before any action.', + }, + handoffResources: [ + { id: 'pve-a:node-a:101', name: 'web-vm', type: 'vm', node: 'node-a' }, + ], + handoffMetadata: { kind: 'resource_context' }, + autonomousMode: false, + }); + }); + + it('omits Primary/Parent/Discovery/readiness lines for a non-discovered app-container', () => { + // canonicalId === guest.id for app-container, so the Primary identity line + // is suppressed; platformType 'kubernetes' is not docker-managed so + // resolveDiscoveryTargetForWorkload returns null. + const result = buildGuestAssistantContext( + makeGuest({ + id: 'app-container:sidecar', + vmid: 0, + name: 'sidecar', + node: '', + instance: '', + status: 'exited', + workloadType: 'app-container', + type: 'app-container', + platformType: 'kubernetes', + }), + ); + + expect(result).toStrictEqual({ + targetType: 'resource', + targetId: 'app-container:sidecar', + context: { + source: 'guest-drawer', + resourceId: 'app-container:sidecar', + resourceType: 'app-container', + resourceStatus: 'exited', + }, + briefing: { + sourceLabel: 'Pulse resource context', + title: 'sidecar', + subject: 'app-container / exited / kubernetes', + statusLabel: 'Read-only context attached', + detailLines: ['Resource ID: app-container:sidecar'], + safetyNote: 'Approval required before any action.', + }, + handoffResources: [ + { id: 'app-container:sidecar', name: 'sidecar', type: 'app-container', node: '' }, + ], + handoffMetadata: { kind: 'resource_context' }, + autonomousMode: false, + }); + }); + }); +}); diff --git a/frontend-modern/src/features/storageBackups/__tests__/cephRecordPresentation.branchcov0712b.test.ts b/frontend-modern/src/features/storageBackups/__tests__/cephRecordPresentation.branchcov0712b.test.ts new file mode 100644 index 0000000000..dc91d629ac --- /dev/null +++ b/frontend-modern/src/features/storageBackups/__tests__/cephRecordPresentation.branchcov0712b.test.ts @@ -0,0 +1,692 @@ +import { describe, expect, it } from 'vitest'; +import type { CephCluster } from '@/types/api'; +import type { NormalizedHealth, StorageCategory, StorageRecord } from '@/features/storageBackups/models'; +import { + consolidateCephClusterPoolRecords, + getCephClusterKeyFromStorageRecord, + getCephSummaryText, + isCephClusterPoolStorageRecord, +} from '@/features/storageBackups/cephRecordPresentation'; + +// --------------------------------------------------------------------------- +// Fixture builders — mirror cephRecordPresentation.test.ts so casts and +// import paths match the sibling suite. The defaults describe a healthy PVE +// cephfs mount ("not" a cluster-internal pool row) so individual cases can +// opt into pool-row synthesis by overriding `details`. +// --------------------------------------------------------------------------- + +const makeRecord = (overrides: Partial = {}): StorageRecord => ({ + id: 'storage-1', + name: 'ceph-pool', + category: 'pool', + health: 'healthy', + location: { label: 'pve1', scope: 'node' }, + capacity: { totalBytes: 1_000, usedBytes: 400, freeBytes: 600, usagePercent: 40 }, + capabilities: ['capacity', 'replication'], + source: { + platform: 'proxmox-pve', + family: 'virtualization', + origin: 'resource', + adapterId: 'resource-storage', + }, + observedAt: Date.now(), + details: { type: 'rbd', parentId: 'cluster-a' }, + refs: { platformEntityId: 'cluster-a' }, + ...overrides, +}); + +// A cluster-internal Ceph pool row (models.StorageFromCephPool): type "ceph" +// homed on the "cluster" pseudo-node. +const makePoolRecord = (overrides: Partial = {}): StorageRecord => + makeRecord({ + id: 'pool-row', + name: 'cephfs-data', + health: 'warning', + details: { type: 'ceph', node: 'cluster', status: 'degraded' }, + ...overrides, + }); + +// A PVE-side mount row that is NOT itself a cluster pool row. +const makeMountRecord = (overrides: Partial = {}): StorageRecord => + makeRecord({ + id: 'mount-row', + name: 'cephfs-data', + health: 'healthy', + details: { type: 'cephfs', node: 'shared', status: 'online' }, + ...overrides, + }); + +const makeCluster = (overrides: Partial = {}): CephCluster => + ({ + id: 'ceph-1', + instance: 'cluster-a', + name: 'cluster-a Ceph', + health: 'HEALTH_OK', + healthMessage: '', + totalBytes: 1_000, + usedBytes: 400, + availableBytes: 600, + usagePercent: 40, + numMons: 3, + numMgrs: 2, + numOsds: 6, + numOsdsUp: 6, + numOsdsIn: 6, + numPGs: 256, + pools: [], + services: [], + lastUpdated: Date.now(), + ...overrides, + }) as CephCluster; + +// =========================================================================== +// isCephClusterPoolStorageRecord +// =========================================================================== + +describe('isCephClusterPoolStorageRecord branch coverage', () => { + it('returns false on the early-return arm when the derived type is not "ceph"', () => { + // type defaults to 'rbd' → getStorageRecordType !== 'ceph' → false. + expect(isCephClusterPoolStorageRecord(makeRecord())).toBe(false); + // category-derived type: no details.type, category 'pool' → 'pool' → false. + expect( + isCephClusterPoolStorageRecord( + makeRecord({ details: undefined, category: 'pool' }) as StorageRecord, + ), + ).toBe(false); + }); + + it('returns true when type is "ceph" and details.node === "cluster"', () => { + expect( + isCephClusterPoolStorageRecord( + makeRecord({ details: { type: 'ceph', node: 'cluster' } }), + ), + ).toBe(true); + }); + + it('returns false when type is "ceph" but details.node is not "cluster"', () => { + // Exercises the `details.node === 'cluster'` false arm with a valid ceph type. + expect( + isCephClusterPoolStorageRecord( + makeRecord({ details: { type: 'ceph', node: 'pve1' } }), + ), + ).toBe(false); + }); + + it('exercises the `(record.details || {})` defensive arm: details undefined with type "ceph"', () => { + // details is undefined → `(record.details || {})` yields {} → details.node + // is undefined → not 'cluster' → false. The type-only path proves the + // `|| {}` branch is taken (no throw, undefined.node coerced via {}). + expect( + isCephClusterPoolStorageRecord( + makeRecord({ details: { type: 'ceph' } }) as StorageRecord, + ), + ).toBe(false); + }); +}); + +// =========================================================================== +// consolidateCephClusterPoolRecords +// (also exercises the module-private storageHealthRank, getRecordPoolDetail +// and getRecordStatusDetail through the public surface) +// =========================================================================== + +describe('consolidateCephClusterPoolRecords branch coverage', () => { + // ---- Branch: `poolRecords.length === 0` → returns the input reference ---- + it('short-circuits and returns the exact input reference when there are no pool rows', () => { + const input: StorageRecord[] = [makeMountRecord(), makeMountRecord({ id: 'm2' })]; + // Early return at `if (poolRecords.length === 0) return records;` — same ref. + expect(consolidateCephClusterPoolRecords(input)).toBe(input); + }); + + // ---- Branch: `consumedPoolIds.size === 0` → returns the input reference -- + it('returns the exact input reference when pool rows exist but none match a mount', () => { + // A pool row plus a non-ceph record: the pool finds no ceph sibling, so + // consumedPoolIds stays empty and the second early-return fires. + const orphanPool = makePoolRecord({ id: 'orphan', name: 'unmatched-pool' }); + const nonCeph = makeRecord({ + id: 'local', + name: 'local-zfs', + details: { type: 'zfspool', node: 'pve1' }, + capabilities: ['capacity'], + }); + const input: StorageRecord[] = [orphanPool, nonCeph]; + expect(consolidateCephClusterPoolRecords(input)).toBe(input); + }); + + // ---- Branch L83: `if (!pool) return record;` (record with no lifted pool) - + it('passes unrelated records through unchanged (by reference) during consolidation', () => { + const pool = makePoolRecord({ id: 'pool-1', name: 'cephfs-data', health: 'critical' }); + const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data' }); + const unrelated = makeRecord({ + id: 'unrelated-1', + name: 'local-zfs', + details: { type: 'zfspool', node: 'pve1' }, + capabilities: ['capacity'], + }); + const consolidated = consolidateCephClusterPoolRecords([pool, mount, unrelated]); + // Pool consumed; mount lifted; unrelated has no entry in liftBySiblingId + // → the `if (!pool) return record;` arm returns it by reference. + expect(consolidated.map((r) => r.id)).toEqual(['mount-1', 'unrelated-1']); + const unrelatedOut = consolidated.find((r) => r.id === 'unrelated-1'); + expect(unrelatedOut).toBe(unrelated); + }); + + // ---- Branch L72 (!existing → set) + L84 lift + L85 status-detail arm ---- + it('lifts the pool health onto a healthier mount and synthesizes a status from details.status', () => { + const pool = makePoolRecord({ + id: 'pool-1', + name: 'cephfs-data', + health: 'warning', + details: { type: 'ceph', node: 'cluster', status: 'degraded' }, + }); + const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data', health: 'healthy' }); + const [survivor] = consolidateCephClusterPoolRecords([pool, mount]); + expect(survivor.id).toBe('mount-1'); + expect(survivor.health).toBe('warning'); + // getRecordStatusDetail string arm → 'degraded' feeds poolStatus. + expect(survivor.statusLabel).toBe('degraded'); + expect(survivor.details?.status).toBe('degraded'); + // No issueSummary on the pool → synthesized fallback string. + expect(survivor.issueSummary).toBe('Ceph reports pool cephfs-data degraded'); + }); + + // ---- Branch L72 (existing present, new pool sicker → replace) ----------- + it('keeps the sicker of two pools that mount onto the same sibling', () => { + const poolWarning = makePoolRecord({ + id: 'pool-w', + name: 'cephfs-data', + health: 'warning', + details: { type: 'ceph', node: 'cluster', status: 'degraded' }, + }); + const poolCritical = makePoolRecord({ + id: 'pool-c', + name: 'cephfs-data', + health: 'critical', + details: { type: 'ceph', node: 'cluster', status: 'unavailable' }, + }); + const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data', health: 'healthy' }); + // storageHealthRank('critical')=4 > storageHealthRank('warning')=2 → replace. + const consolidated = consolidateCephClusterPoolRecords([poolWarning, poolCritical, mount]); + expect(consolidated.map((r) => r.id)).toEqual(['mount-1']); + expect(consolidated[0].health).toBe('critical'); + expect(consolidated[0].statusLabel).toBe('unavailable'); + }); + + // ---- Branch L72 (existing present, new pool NOT sicker → keep existing) - + it('does not replace the existing lift when the second pool is not sicker', () => { + const poolCritical = makePoolRecord({ + id: 'pool-c', + name: 'cephfs-data', + health: 'critical', + issueSummary: 'first pool wins', + details: { type: 'ceph', node: 'cluster', status: 'unavailable' }, + }); + const poolWarning = makePoolRecord({ + id: 'pool-w', + name: 'cephfs-data', + health: 'warning', + issueSummary: 'second pool loses', + details: { type: 'ceph', node: 'cluster', status: 'degraded' }, + }); + const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data', health: 'healthy' }); + // storageHealthRank('warning')=2 > storageHealthRank('critical')=4 → false → keep critical. + const [survivor] = consolidateCephClusterPoolRecords([poolCritical, poolWarning, mount]); + expect(survivor.health).toBe('critical'); + // existing (critical) pool's issueSummary is preserved verbatim. + expect(survivor.issueSummary).toBe('first pool wins'); + }); + + // ---- Branch L84: pool not sicker than mount → record unchanged by ref ---- + it('leaves a mount untouched (by reference) when the pool is not sicker than it', () => { + const healthyPool = makePoolRecord({ + id: 'pool-1', + name: 'cephfs-data', + health: 'healthy', + details: { type: 'ceph', node: 'cluster', status: 'available' }, + }); + const sickMount = makeMountRecord({ + id: 'mount-1', + name: 'cephfs-data', + health: 'critical', + statusLabel: 'unavailable', + }); + // storageHealthRank('healthy')=0 <= storageHealthRank('critical')=4 → return record. + const consolidated = consolidateCephClusterPoolRecords([healthyPool, sickMount]); + expect(consolidated).toEqual([sickMount]); + expect(consolidated[0]).toBe(sickMount); + }); + + // ---- storageHealthRank `?? 0` arm: unrecognized record health → rank 0 -- + it('treats an unrecognized mount health as rank 0 so a sicker pool still lifts', () => { + const pool = makePoolRecord({ + id: 'pool-1', + name: 'cephfs-data', + health: 'critical', + details: { type: 'ceph', node: 'cluster', status: 'unavailable' }, + }); + const mount = makeMountRecord({ + id: 'mount-1', + name: 'cephfs-data', + // 'quarantined' is not in STORAGE_HEALTH_SEVERITY_RANK → `?? 0`. + health: 'quarantined' as unknown as NormalizedHealth, + }); + const [survivor] = consolidateCephClusterPoolRecords([pool, mount]); + // pool rank 4 > record rank 0 → lift happens; the bogus record health is + // overwritten by the pool's 'critical'. + expect(survivor.id).toBe('mount-1'); + expect(survivor.health).toBe('critical'); + }); + + // ---- getRecordPoolDetail string arm + trim(): pool detail with whitespace + it('matches a mount via the backing pool detail and trims whitespace before comparing', () => { + const pool = makePoolRecord({ + id: 'pool-1', + name: 'vm-pool', + health: 'critical', + details: { type: 'ceph', node: 'cluster', status: 'unavailable' }, + }); + const mount = makeMountRecord({ + id: 'mount-1', + name: 'fast-rbd', + // details.pool has surrounding whitespace; getRecordPoolDetail trims it + // to 'vm-pool', which then equals pool.name. + details: { type: 'rbd', node: 'shared', pool: ' vm-pool ', status: 'online' }, + }); + const consolidated = consolidateCephClusterPoolRecords([pool, mount]); + expect(consolidated.map((r) => r.id)).toEqual(['mount-1']); + expect(consolidated[0].health).toBe('critical'); + }); + + // ---- getRecordPoolDetail non-string arm: pool detail is a non-string ---- + it('does not treat a non-string pool detail as a backing-pool match', () => { + const pool = makePoolRecord({ + id: 'pool-1', + name: 'vm-pool', + health: 'critical', + details: { type: 'ceph', node: 'cluster', status: 'unavailable' }, + }); + const mount = makeMountRecord({ + id: 'mount-1', + name: 'different-name', + // details.pool is a number → getRecordPoolDetail returns '' (the + // `typeof pool === 'string'` false arm). '' !== 'vm-pool' and the names + // differ too, so this pool is orphaned and the mount is left as-is. + details: { type: 'rbd', node: 'shared', pool: 999, status: 'online' } as Record< + string, + unknown + >, + }); + const input: StorageRecord[] = [pool, mount]; + // No sibling consumed → second early-return hands back the input reference. + expect(consolidateCephClusterPoolRecords(input)).toBe(input); + }); + + // ---- getRecordStatusDetail non-string arm → falls through to statusLabel - + it('falls back to pool.statusLabel when details.status is not a string', () => { + const pool = makePoolRecord({ + id: 'pool-1', + name: 'cephfs-data', + health: 'warning', + statusLabel: 'down-label', + details: { + type: 'ceph', + node: 'cluster', + // status is a number → getRecordStatusDetail returns '' → the `||` + // chain falls through to pool.statusLabel ('down-label'). + status: 42, + } as Record, + }); + const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data', health: 'healthy' }); + const [survivor] = consolidateCephClusterPoolRecords([pool, mount]); + expect(survivor.health).toBe('warning'); + expect(survivor.statusLabel).toBe('down-label'); + expect(survivor.details?.status).toBe('down-label'); + expect(survivor.issueSummary).toBe('Ceph reports pool cephfs-data down-label'); + }); + + // ---- getRecordStatusDetail empty → statusLabel empty → falls to health --- + it('falls back to pool.health when neither details.status nor statusLabel resolve', () => { + const pool = makePoolRecord({ + id: 'pool-1', + name: 'cephfs-data', + health: 'critical', + statusLabel: undefined, + details: { + type: 'ceph', + node: 'cluster', + // status is a boolean → getRecordStatusDetail returns '' (non-string). + status: true, + } as Record, + }); + const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data', health: 'healthy' }); + const [survivor] = consolidateCephClusterPoolRecords([pool, mount]); + // getRecordStatusDetail('') || statusLabel(undefined) || health('critical'). + expect(survivor.statusLabel).toBe('critical'); + expect(survivor.details?.status).toBe('critical'); + expect(survivor.issueSummary).toBe('Ceph reports pool cephfs-data critical'); + }); + + // ---- L91 `pool.issueSummary?.trim() || ...` truthy arm ------------------ + it('preserves the pool issueSummary verbatim when it trims to a non-empty value', () => { + const pool = makePoolRecord({ + id: 'pool-1', + name: 'cephfs-data', + health: 'warning', + issueSummary: 'Ceph cluster HEALTH_WARN', + details: { type: 'ceph', node: 'cluster', status: 'degraded' }, + }); + const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data', health: 'healthy' }); + const [survivor] = consolidateCephClusterPoolRecords([pool, mount]); + expect(survivor.issueSummary).toBe('Ceph cluster HEALTH_WARN'); + }); + + // ---- L91 `pool.issueSummary?.trim() || ...` whitespace-only arm --------- + it('synthesizes the issue summary when the pool issueSummary is only whitespace', () => { + const pool = makePoolRecord({ + id: 'pool-1', + name: 'cephfs-data', + health: 'warning', + issueSummary: ' ', + details: { type: 'ceph', node: 'cluster', status: 'degraded' }, + }); + const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data', health: 'healthy' }); + const [survivor] = consolidateCephClusterPoolRecords([pool, mount]); + // ' '.trim() === '' → falsy → synthesized fallback string. + expect(survivor.issueSummary).toBe('Ceph reports pool cephfs-data degraded'); + }); + + // ---- L92 `{ ...(record.details || {}), status: poolStatus }` arm -------- + it('preserves the surviving mount details and overlays the lifted status', () => { + const pool = makePoolRecord({ + id: 'pool-1', + name: 'cephfs-data', + health: 'warning', + details: { type: 'ceph', node: 'cluster', status: 'degraded' }, + }); + const mount = makeMountRecord({ + id: 'mount-1', + name: 'cephfs-data', + health: 'healthy', + details: { type: 'cephfs', node: 'shared', status: 'online', extra: 'kept' }, + }); + const [survivor] = consolidateCephClusterPoolRecords([pool, mount]); + // record.details was truthy → spread keeps existing keys, status overridden. + expect(survivor.details).toEqual({ + type: 'cephfs', + node: 'shared', + status: 'degraded', + extra: 'kept', + }); + }); +}); + +// =========================================================================== +// getCephClusterKeyFromStorageRecord +// =========================================================================== + +describe('getCephClusterKeyFromStorageRecord branch coverage', () => { + it('prefers refs.platformEntityId when it is a non-empty string', () => { + expect(getCephClusterKeyFromStorageRecord(makeRecord())).toBe('cluster-a'); + }); + + it('uses details.parentId when refs is undefined (optional-chain short-circuit)', () => { + // refs undefined → `record.refs?.platformEntityId` is undefined → falls to + // parent. Exercises the `record.refs?.` optional-chain falsy arm. + const record = makeRecord({ + refs: undefined, + details: { type: 'rbd', parentId: 'parent-key' }, + }); + expect(getCephClusterKeyFromStorageRecord(record)).toBe('parent-key'); + }); + + it('uses details.parentId when refs.platformEntityId is an empty string', () => { + const record = makeRecord({ + refs: { platformEntityId: '' }, + details: { type: 'rbd', parentId: 'parent-key' }, + }); + expect(getCephClusterKeyFromStorageRecord(record)).toBe('parent-key'); + }); + + it('coerces a non-string parentId to "" (defensive ternary false arm) then uses location.label', () => { + const record = makeRecord({ + refs: { platformEntityId: '' }, + details: { type: 'rbd', parentId: 999 } as Record, + location: { label: 'loc-key', scope: 'cluster' }, + }); + // typeof parentId === 'string' is false → parent '' → next fallback wins. + expect(getCephClusterKeyFromStorageRecord(record)).toBe('loc-key'); + }); + + it('falls back to location.label when refs and parentId are both absent', () => { + const record = makeRecord({ + refs: undefined, + details: { type: 'rbd' }, + location: { label: 'loc-key', scope: 'cluster' }, + }); + expect(getCephClusterKeyFromStorageRecord(record)).toBe('loc-key'); + }); + + it('exercises the `(record.details || {})` arm then falls to source.platform', () => { + // details undefined → {} → parent '' → refs/platformEntityId absent → + // location.label '' → final fallback source.platform. + const record = makeRecord({ + refs: undefined, + details: undefined, + location: { label: '', scope: 'cluster' }, + source: { + platform: 'proxmox-pve', + family: 'virtualization', + origin: 'resource', + adapterId: 'resource-storage', + }, + }); + expect(getCephClusterKeyFromStorageRecord(record)).toBe('proxmox-pve'); + }); + + it('reaches the source.platform final fallback when every higher-precedence key is empty', () => { + const record = makeRecord({ + refs: { platformEntityId: '' }, + details: { type: 'rbd', parentId: '' }, + location: { label: '', scope: 'cluster' }, + source: { + platform: 'truenas', + family: 'virtualization', + origin: 'resource', + adapterId: 'resource-storage', + }, + }); + expect(getCephClusterKeyFromStorageRecord(record)).toBe('truenas'); + }); +}); + +// =========================================================================== +// getCephSummaryText +// =========================================================================== + +describe('getCephSummaryText branch coverage', () => { + // ---- Cluster arm: capacity + OSDs + PGs (all three parts) --------------- + it('formats capacity, OSDs and PGs parts when the cluster provides all three', () => { + // totalBytes 1000, usedBytes 400 → 40% → "400 B / 1000 B (40%)". + // numOsds/numOsdsUp finite (6/6) and numPGs > 0 (256) → all parts pushed. + expect(getCephSummaryText(makeRecord(), makeCluster())).toBe( + '400 B / 1000 B (40%) • OSDs 6/6 • PGs 256', + ); + }); + + // ---- Cluster arm: percent===0 when totalBytes is 0 (no division) ------- + it('renders a zero-capacity cluster as "0 B / 0 B (0%)" without dividing by zero', () => { + expect( + getCephSummaryText( + makeRecord(), + makeCluster({ totalBytes: 0, usedBytes: 0, availableBytes: 0, numOsds: NaN, numPGs: 0 }), + ), + ).toBe('0 B / 0 B (0%)'); + }); + + // ---- Cluster arm: OSDs omitted when numOsds/numOsdsUp not finite -------- + it('omits the OSDs part when numOsds is NaN', () => { + expect( + getCephSummaryText( + makeRecord(), + makeCluster({ numOsds: NaN, numOsdsUp: 6, numPGs: 0 }), + ), + ).toBe('400 B / 1000 B (40%)'); + }); + + it('omits the OSDs part when numOsdsUp is NaN even if numOsds is finite', () => { + expect( + getCephSummaryText( + makeRecord(), + makeCluster({ numOsds: 6, numOsdsUp: NaN, numPGs: 0 }), + ), + ).toBe('400 B / 1000 B (40%)'); + }); + + // ---- Cluster arm: PGs omitted when numPGs is 0 or not finite ------------ + it('keeps OSDs but omits PGs when numPGs is 0', () => { + expect( + getCephSummaryText(makeRecord(), makeCluster({ numPGs: 0 })), + ).toBe('400 B / 1000 B (40%) • OSDs 6/6'); + }); + + it('omits PGs when numPGs is NaN (Number.isFinite false)', () => { + expect( + getCephSummaryText(makeRecord(), makeCluster({ numPGs: NaN })), + ).toBe('400 B / 1000 B (40%) • OSDs 6/6'); + }); + + // ---- Cluster arm: large numPGs is rendered via toLocaleString ---------- + it('renders a large numPGs count with toLocaleString grouping', () => { + expect( + getCephSummaryText(makeRecord(), makeCluster({ numPGs: 1_500 })), + ).toBe('400 B / 1000 B (40%) • OSDs 6/6 • PGs 1,500'); + }); + + // ---- Cluster `|| 0` clamps: negative bytes treated as 0 ---------------- + it('clamps negative cluster total/used bytes to 0 before formatting', () => { + // Math.max(0, totalBytes || 0): totalBytes -1000 is truthy → max(0,-1000)=0. + // usedBytes -400 → 0. percent total>0? no → 0. + expect( + getCephSummaryText( + makeRecord(), + makeCluster({ + totalBytes: -1_000, + usedBytes: -400, + availableBytes: 0, + numOsds: NaN, + numPGs: 0, + }), + ), + ).toBe('0 B / 0 B (0%)'); + }); + + // ---- Cluster guard false: totalBytes NaN → else (record capacity) branch + it('falls back to record capacity when cluster.totalBytes is not finite', () => { + // Number.isFinite(NaN) is false → the whole cluster arm is skipped. + expect( + getCephSummaryText( + makeRecord({ capacity: { totalBytes: 1_000, usedBytes: 400, freeBytes: 600, usagePercent: 40 } }), + makeCluster({ totalBytes: NaN }), + ), + ).toBe('400 B / 1000 B (40%)'); + }); + + // ---- Cluster guard false: cluster null → else (record capacity) branch -- + it('formats the record capacity summary when cluster is null', () => { + expect( + getCephSummaryText( + makeRecord({ capacity: { totalBytes: 1_000, usedBytes: 400, freeBytes: 600, usagePercent: 40 } }), + null, + ), + ).toBe('400 B / 1000 B (40%)'); + }); + + // ---- Else branch: record total <= 0 → '' ------------------------------- + it('returns the empty string when the record capacity total is <= 0 and there is no cluster', () => { + expect( + getCephSummaryText( + makeRecord({ + capacity: { totalBytes: 0, usedBytes: 0, freeBytes: 0, usagePercent: 0 }, + }), + null, + ), + ).toBe(''); + }); + + // ---- Else branch: record `|| 0` clamps nullish bytes ------------------- + it('treats null total/used bytes as 0 in the record fallback and returns ""', () => { + // totalBytes null → `record.capacity.totalBytes || 0` → 0 → total <= 0 → ''. + expect( + getCephSummaryText( + makeRecord({ + capacity: { + totalBytes: null, + usedBytes: null, + freeBytes: null, + usagePercent: null, + }, + }), + null, + ), + ).toBe(''); + }); +}); + +// =========================================================================== +// Supplemental branch coverage for the four in-scope functions +// (isCephClusterPoolStorageRecord, getRecordPoolDetail, getRecordStatusDetail, +// consolidateCephClusterPoolRecords). These cases reach the `(details || {})` +// right operands that the cases above do not actually exercise: +// * isCephClusterPoolStorageRecord L21 — only reachable with nullish details +// AND a 'ceph' type, which forces the type to derive from `category`. +// * consolidateCephClusterPoolRecords L92 survivor spread — requires a +// mounting sibling whose `details` are nullish (all mounts above had details). +// * (Indirectly) getRecordPoolDetail L36 right operand — the nullish-details +// candidate below is evaluated by the sibling `find` before matching on name. +// * getRecordStatusDetail L41 right operand is UNREACHABLE through the public +// surface: a pool row is only classified as such when details.node === +// 'cluster', so no pool can ever carry nullish details. Reported, not hit. +// =========================================================================== + +describe('cephRecordPresentation.branchcov2 supplemental (in-scope functions)', () => { + it('isCephClusterPoolStorageRecord: takes the `(details || {})` right operand when details is nullish but the type derives from category', () => { + // The pre-existing "defensive arm" case passes a truthy details object, so + // the `{}` fallback is never constructed. Here details is undefined AND the + // type resolves to 'ceph' via category, so the L20 guard is passed and L21 + // actually evaluates `undefined || {}` → {}. L22 then reads {}.node + // (undefined) !== 'cluster' → false. + const record = makeRecord({ + details: undefined, + category: 'ceph' as unknown as StorageCategory, + }); + expect(isCephClusterPoolStorageRecord(record)).toBe(false); + }); + + it('consolidate: spreads `(record.details || {})` on the survivor when the mount has nullish details', () => { + // The mounting sibling carries no `details`. It still qualifies as a ceph + // sibling via the isCephStorageRecord fallback (capabilities includes + // 'replication' and source.platform includes 'proxmox'), and is matched on + // name after getRecordPoolDetail returns '' for the nullish details. On + // lift, L92 evaluates `(record.details || {})` → {} and overlays only the + // status key, proving the `{}` right operand is taken. + const pool = makePoolRecord({ + id: 'pool-1', + name: 'cephfs-data', + health: 'warning', + details: { type: 'ceph', node: 'cluster', status: 'degraded' }, + }); + const mount = makeRecord({ + id: 'mount-1', + name: 'cephfs-data', + health: 'healthy', + details: undefined, + }); + const [survivor] = consolidateCephClusterPoolRecords([pool, mount]); + expect(survivor.id).toBe('mount-1'); + expect(survivor.health).toBe('warning'); + expect(survivor.statusLabel).toBe('degraded'); + // mount.details was nullish → {} spread → only the overlaid status survives. + expect(survivor.details).toEqual({ status: 'degraded' }); + expect(survivor.issueSummary).toBe('Ceph reports pool cephfs-data degraded'); + }); +}); diff --git a/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.branchcov0712b.test.ts b/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.branchcov0712b.test.ts new file mode 100644 index 0000000000..de530f64d5 --- /dev/null +++ b/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.branchcov0712b.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { + getResourceStorageIssueLabel, + getResourceStorageProtectionLabel, + getResourceStorageProtectionSummary, + getResourceStorageTopologyLabel, +} from '@/features/storageBackups/resourceStoragePresentation'; + +// Mirrors the sibling test factories (see resourceStoragePresentation.test.ts +// and resourceStoragePresentation.branchcov.test.ts). Pure-function module, so +// no Solid root is required. The two private target functions +// (getUnraidShortProtectionLabel / getUnraidShortIssueLabel) are exercised +// transitively through their exported callers +// (getResourceStorageProtectionLabel / getResourceStorageIssueLabel) — no +// export hacking. Scope is limited to the four named functions. +const makeResource = (overrides: Partial = {}): Resource => + ({ + id: 'storage-1', + type: 'storage', + name: 'tank', + platformType: 'truenas', + sourceType: 'api', + status: 'online', + storage: {}, + ...overrides, + }) as Resource; + +// --------------------------------------------------------------------------- +// getUnraidShortProtectionLabel (private) — exercised via +// getResourceStorageProtectionLabel on Unraid resources (arrayState set so +// isUnraidStorageResource returns true). Targets the protectionReduced +// sub-branches the sibling suites do not reach: case/whitespace-insensitive +// "none" matching, the protection-undefined `|| ""` arm, and the +// no-rebuild/no-reduced fall-through. +// --------------------------------------------------------------------------- + +describe('getUnraidShortProtectionLabel (via getResourceStorageProtectionLabel)', () => { + it('treats an uppercase "NONE" protection as No parity via the toLowerCase arm', () => { + const resource = makeResource({ + storage: { + arrayState: 'STARTED', + protection: 'NONE', + protectionReduced: true, + }, + }); + expect(getResourceStorageProtectionLabel(resource)).toBe('No parity'); + }); + + it('treats a whitespace-padded mixed-case protection as No parity via the trim arm', () => { + const resource = makeResource({ + storage: { + arrayState: 'STARTED', + protection: ' NoNe ', + protectionReduced: true, + }, + }); + expect(getResourceStorageProtectionLabel(resource)).toBe('No parity'); + }); + + it('hits the protection-undefined `|| ""` falsy arm then resolves No parity from the unraid_no_parity reason', () => { + // protectionReduced true, protection absent => `(storage.protection || "")` + // takes the falsy arm; the `=== "none"` check is false, so the + // unraid_no_parity risk reason decides the label. + const resource = makeResource({ + storage: { + arrayState: 'STARTED', + protectionReduced: true, + risk: { + level: 'warning', + reasons: [{ code: 'unraid_no_parity', severity: 'warning', summary: 'no parity' }], + }, + }, + }); + expect(getResourceStorageProtectionLabel(resource)).toBe('No parity'); + }); + + it('returns Protection reduced when protection is absent and neither parity risk reason is present', () => { + // Same `|| ""` falsy arm, but both findRiskReason calls return undefined, + // landing on the terminal `return "Protection reduced"`. + const resource = makeResource({ + storage: { + arrayState: 'STARTED', + protectionReduced: true, + }, + }); + expect(getResourceStorageProtectionLabel(resource)).toBe('Protection reduced'); + }); + + it('returns an empty Unraid label when rebuild and protectionReduced are both false, letting the caller titleize the raw protection', () => { + // Exercises getUnraidShortProtectionLabel's final `return ""` so the + // exported caller falls through to its generic protection path. + const resource = makeResource({ + storage: { + arrayState: 'STARTED', + protection: 'dual-parity', + }, + }); + expect(getResourceStorageProtectionLabel(resource)).toBe('Dual Parity'); + }); +}); + +// --------------------------------------------------------------------------- +// getUnraidShortIssueLabel (private) — exercised via +// getResourceStorageIssueLabel on Unraid resources. Targets the empty-code +// `|| ""` arm inside the loop, the loop fall-through to Healthy, the +// case-insensitive prefix regex, and PBS-storageRisk-only reason collection. +// --------------------------------------------------------------------------- + +describe('getUnraidShortIssueLabel (via getResourceStorageIssueLabel)', () => { + it('continues past a reason with an empty code via the `(reason.code || "")` falsy arm', () => { + // collectRiskReasons keeps the truthy reason object; inside the loop the + // empty code becomes "" and the switch matches no case, so iteration + // proceeds to the next reason. + const resource = makeResource({ + storage: { + arrayState: 'STARTED', + risk: { + level: 'warning', + reasons: [ + { code: '', severity: 'warning', summary: 'ignored empty code' }, + { code: 'unraid_missing_disks', severity: 'warning', summary: '2 missing' }, + ], + }, + }, + }); + expect(getResourceStorageIssueLabel(resource)).toBe('2 missing'); + }); + + it('returns Healthy when every collected reason carries an empty or non-attention code', () => { + const resource = makeResource({ + storage: { + arrayState: 'STARTED', + risk: { + level: 'warning', + reasons: [ + { code: '', severity: 'warning', summary: 'empty code reason' }, + { code: 'unraid_sync_active', severity: 'warning', summary: 'syncing' }, + ], + }, + }, + }); + expect(getResourceStorageIssueLabel(resource)).toBe('Healthy'); + }); + + it('strips an uppercase "UNRAID ARRAY REPORTS " prefix case-insensitively via the /i regex', () => { + const resource = makeResource({ + storage: { + arrayState: 'STARTED', + risk: { + level: 'warning', + reasons: [ + { + code: 'unraid_invalid_disks', + severity: 'warning', + summary: 'unRAID aRRay reports 4 invalid disks', + }, + ], + }, + }, + }); + expect(getResourceStorageIssueLabel(resource)).toBe('4 invalid disks'); + }); + + it('returns the unraid_disabled_disks summary verbatim when it carries no prefix', () => { + const resource = makeResource({ + storage: { + arrayState: 'STARTED', + risk: { + level: 'warning', + reasons: [ + { code: 'unraid_disabled_disks', severity: 'warning', summary: 'disk3 disabled' }, + ], + }, + }, + }); + expect(getResourceStorageIssueLabel(resource)).toBe('disk3 disabled'); + }); + + it('surfaces a reason collected only from pbs.storageRisk for an Unraid resource', () => { + // collectRiskReasons walks storage.risk.reasons then pbs.storageRisk.reasons; + // with no storage risk, the PBS reason is still collected and matched. + const resource = makeResource({ + storage: { arrayState: 'STARTED' }, + pbs: { + storageRisk: { + level: 'warning', + reasons: [ + { code: 'unraid_parity_unavailable', severity: 'warning', summary: 'parity down' }, + ], + }, + }, + }); + expect(getResourceStorageIssueLabel(resource)).toBe('Parity unavailable'); + }); +}); + +// --------------------------------------------------------------------------- +// getResourceStorageTopologyLabel (exported) — edge branches complementing +// the existing coverage: topology-arg normalization, datastore precedence over +// a pool-like storageType, and whitespace-only storageType fall-through. +// --------------------------------------------------------------------------- + +describe('getResourceStorageTopologyLabel (branch coverage)', () => { + it('normalizes a whitespace-padded mixed-case topology arg before titleizing', () => { + // `(topology || "").trim().toLowerCase()` => 'backup-target' => titleize. + expect(getResourceStorageTopologyLabel(makeResource(), 'pool', ' BackUP-Target ')).toBe( + 'Backup Target', + ); + }); + + it('classifies a datastore entityType ahead of a pool-like storageType', () => { + // resource.type !== 'datastore' but vmware.entityType === 'datastore' wins + // over storageType 'zfspool', proving the datastore check precedes the + // switch and that the `||` short-circuits to the entityType arm. + const resource = makeResource({ + type: 'storage', + vmware: { entityType: 'datastore' }, + }); + expect(getResourceStorageTopologyLabel(resource, 'zfspool')).toBe('Datastore'); + }); + + it('falls through a whitespace-only storageType to the titleized resource type', () => { + // The switch input `(storageType || "").trim().toLowerCase()` becomes "" => + // default branch => titleize(" ") => "" => `||` titleize(resource.type). + const resource = { ...makeResource(), type: 'nvme-tank' } as unknown as Resource; + expect(getResourceStorageTopologyLabel(resource, ' ')).toBe('Nvme Tank'); + }); + + it('returns Storage when both storageType and resource type are whitespace-only', () => { + // titleize("") || titleize("") || "Storage" => "Storage". + const resource = { ...makeResource(), type: ' ' } as unknown as Resource; + expect(getResourceStorageTopologyLabel(resource, ' ')).toBe('Storage'); + }); +}); + +// --------------------------------------------------------------------------- +// getResourceStorageProtectionSummary (exported) — drives every guard arm and +// the trimSummary fallbacks for rebuild/protection/incident states, including +// the optional-chain falsy arms when storage metadata is absent. +// --------------------------------------------------------------------------- + +describe('getResourceStorageProtectionSummary (branch coverage)', () => { + it('returns an empty string when a rebuild is in progress but rebuildSummary is absent', () => { + // trimSummary(undefined) => "". + const resource = makeResource({ storage: { rebuildInProgress: true } }); + expect(getResourceStorageProtectionSummary(resource)).toBe(''); + }); + + it('trims a whitespace-padded rebuildSummary during an active rebuild', () => { + const resource = makeResource({ + storage: { rebuildInProgress: true, rebuildSummary: ' Resilvering in progress ' }, + }); + expect(getResourceStorageProtectionSummary(resource)).toBe('Resilvering in progress'); + }); + + it('returns an empty string when a rebuild is active with a whitespace-only rebuildSummary', () => { + const resource = makeResource({ + storage: { rebuildInProgress: true, rebuildSummary: ' ' }, + }); + expect(getResourceStorageProtectionSummary(resource)).toBe(''); + }); + + it('returns the trimmed protectionSummary when protection is reduced and no rebuild is active', () => { + const resource = makeResource({ + storage: { protectionReduced: true, protectionSummary: ' Redundancy degraded ' }, + }); + expect(getResourceStorageProtectionSummary(resource)).toBe('Redundancy degraded'); + }); + + it('returns an empty string when protection is reduced but protectionSummary is absent', () => { + const resource = makeResource({ storage: { protectionReduced: true } }); + expect(getResourceStorageProtectionSummary(resource)).toBe(''); + }); + + it('returns the trimmed incidentSummary for a recoverability incident', () => { + const resource = makeResource({ + incidentCategory: 'recoverability', + incidentSummary: ' No recent backups ', + }); + expect(getResourceStorageProtectionSummary(resource)).toBe('No recent backups'); + }); + + it('returns an empty string for a recoverability incident with no incidentSummary', () => { + const resource = makeResource({ incidentCategory: 'recoverability' }); + expect(getResourceStorageProtectionSummary(resource)).toBe(''); + }); + + it('returns an empty string when storage metadata is entirely absent (optional-chain falsy arms)', () => { + // resource.storage?.rebuildInProgress and ?.protectionReduced both evaluate + // to undefined and skip their guards. + const resource = { ...makeResource(), storage: undefined } as unknown as Resource; + expect(getResourceStorageProtectionSummary(resource)).toBe(''); + }); + + it('returns an empty string for a healthy resource with no protection state', () => { + expect(getResourceStorageProtectionSummary(makeResource())).toBe(''); + }); +}); diff --git a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts new file mode 100644 index 0000000000..99ee1f17b7 --- /dev/null +++ b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts @@ -0,0 +1,441 @@ +import { describe, expect, it } from 'vitest'; +import type { StorageRecord } from '@/features/storageBackups/models'; +import { + getCompactStoragePoolImpactLabel, + getCompactStoragePoolIssueLabel, + getStoragePoolStateLabel, + getStoragePoolStateTitle, +} from '@/features/storageBackups/rowPresentation'; + +// Minimal valid-enough record. Cast through `unknown` so deliberate gaps/malformed +// values still satisfy the strict StorageRecord shape under the real tsconfig. +const baseRecord = (): StorageRecord => + ({ + id: 'storage-1', + name: 'tank', + source: { + platform: 'truenas', + type: 'storage', + label: 'TrueNAS', + }, + category: 'pool', + statusLabel: 'Healthy', + health: 'healthy', + capacity: { totalBytes: 0, usedBytes: 0, freeBytes: 0 }, + location: { label: 'tower', scope: 'node' }, + capabilities: ['capacity'], + observedAt: Date.now(), + refs: {}, + }) as unknown as StorageRecord; + +// --------------------------------------------------------------------------- +// getCompactStoragePoolIssueLabel +// +// Shape under test: +// label = (issueLabel || '').trim() +// protection = getCompactStoragePoolProtectionLabel(record).trim() +// if (label && label.toLowerCase() !== 'healthy') { // OUTER +// if (protection && protection !== '—' && +// protection.toLowerCase() === label.toLowerCase()) // INNER (3 conjuncts) +// return '—' +// return label +// } +// if (pool?.state && pool.state !== 'ONLINE') return pool.state +// if (normalizedStatus && !SAFE.includes(normalizedStatus)) return statusLabel || 'Issue' +// return '—' +// --------------------------------------------------------------------------- +describe('getCompactStoragePoolIssueLabel branch coverage', () => { + it('returns "—" when a non-healthy issue label matches the derived protection label (case-insensitive)', () => { + // INNER all-true: protection truthy, protection !== '—', lowercased equality. + const record = { + ...baseRecord(), + issueLabel: 'Scrub Failed', + protectionLabel: 'scrub failed', + }; + expect(getCompactStoragePoolIssueLabel(record)).toBe('—'); + }); + + it('returns the raw issue label when protection is a present but differing value (INNER false via lowercase mismatch)', () => { + // protection is a real ('no parity'), non-'—' value that does not equal the issue label. + const record = { + ...baseRecord(), + issueLabel: 'Drive failed', + protectionLabel: 'No parity', + protectionReduced: true, + }; + expect(getCompactStoragePoolIssueLabel(record)).toBe('Drive failed'); + }); + + it('returns the raw issue label when protection collapses to "—" (INNER false via the `protection !== "—"` guard)', () => { + // protectionLabel 'Healthy' -> getCompactStoragePoolProtectionLabel returns '—'. + const record = { + ...baseRecord(), + issueLabel: 'Drive failed', + protectionLabel: 'Healthy', + }; + expect(getCompactStoragePoolIssueLabel(record)).toBe('Drive failed'); + }); + + it('skips an issue label that is exactly "Healthy" and falls through to a non-safe statusLabel (OUTER false via healthy equality)', () => { + const record = { + ...baseRecord(), + issueLabel: 'Healthy', + statusLabel: 'Failed', + }; + expect(getCompactStoragePoolIssueLabel(record)).toBe('Failed'); + }); + + it('skips an issue label that is "HEALTHY" in any case (OUTER false via case-insensitive healthy equality)', () => { + const record = { + ...baseRecord(), + issueLabel: 'HEALTHY', + statusLabel: 'Faulted', + }; + expect(getCompactStoragePoolIssueLabel(record)).toBe('Faulted'); + }); + + it('treats an empty-string issue label as absent and falls through to the zfs pool state', () => { + const record = { + ...baseRecord(), + issueLabel: '', + details: { + zfsPool: { state: 'DEGRADED', devices: [] }, + }, + } as unknown as StorageRecord; + expect(getCompactStoragePoolIssueLabel(record)).toBe('DEGRADED'); + }); + + it('treats a whitespace-only issue label as absent (trim -> "") and falls through to statusLabel', () => { + const record = { + ...baseRecord(), + issueLabel: ' ', + statusLabel: 'Faulted', + }; + expect(getCompactStoragePoolIssueLabel(record)).toBe('Faulted'); + }); + + it('skips an ONLINE zfs pool (pool.state !== "ONLINE" guard false) and uses a non-safe statusLabel', () => { + const record = { + ...baseRecord(), + details: { + zfsPool: { state: 'ONLINE', devices: [] }, + }, + statusLabel: 'Faulted', + } as unknown as StorageRecord; + expect(getCompactStoragePoolIssueLabel(record)).toBe('Faulted'); + }); + + it('returns "—" for every "safe" statusLabel value (online/available/running/healthy)', () => { + const safeValues = ['online', 'available', 'running', 'healthy']; + for (const statusLabel of safeValues) { + const record = { ...baseRecord(), statusLabel }; + expect(getCompactStoragePoolIssueLabel(record)).toBe('—'); + } + }); + + it('returns the raw (untrimmed) statusLabel when it is padded but non-safe', () => { + // normalizedStatus is computed from the trimmed/lowercased value, but the + // function returns record.statusLabel verbatim -> padded result is a real + // observable quirk worth pinning. + const record = { + ...baseRecord(), + statusLabel: ' Failed ', + }; + expect(getCompactStoragePoolIssueLabel(record)).toBe(' Failed '); + }); + + it('returns "—" when issue label is healthy, no pool exists, and statusLabel is empty', () => { + const record = { + ...baseRecord(), + issueLabel: 'Healthy', + statusLabel: '', + }; + expect(getCompactStoragePoolIssueLabel(record)).toBe('—'); + }); +}); + +// --------------------------------------------------------------------------- +// getStoragePoolStateLabel +// +// Shape under test: +// arrayState = details.arrayState (string-only, trimmed-by-caller of getRecordStringDetail) +// if (arrayState) return titleize(arrayState) // titleize splits on /[\s_-]+/ +// if (pool?.state) return pool.state === 'ONLINE' ? 'Online' : pool.state +// status = getStorageRecordStatus(record) +// return status ? titleize(status) : '—' +// +// NOTE: getStorageRecordStatus always returns a non-empty string (final fallback +// 'unknown'), so the trailing `: '—'` arm is unreachable in practice; see report. +// --------------------------------------------------------------------------- +describe('getStoragePoolStateLabel branch coverage', () => { + it('titleizes a single-word details.arrayState', () => { + const record = { + ...baseRecord(), + details: { arrayState: 'STARTED' }, + } as unknown as StorageRecord; + expect(getStoragePoolStateLabel(record)).toBe('Started'); + }); + + it('titleizes a multi-word arrayState by splitting on whitespace', () => { + const record = { + ...baseRecord(), + details: { arrayState: 'PARTIALLY DEGRADED' }, + } as unknown as StorageRecord; + expect(getStoragePoolStateLabel(record)).toBe('Partially Degraded'); + }); + + it('titleizes an arrayState that uses underscore separators', () => { + // Exercises the `/[\s_-]+/` split in titleize with the `_` separator class. + const record = { + ...baseRecord(), + details: { arrayState: 'degraded_online' }, + } as unknown as StorageRecord; + expect(getStoragePoolStateLabel(record)).toBe('Degraded Online'); + }); + + it('falls through when details.arrayState is a non-string (defensive getRecordStringDetail branch)', () => { + // A numeric arrayState is coerced to '' by getRecordStringDetail; with no + // pool and a healthy record, status resolves to 'available' -> 'Available'. + const record = { + ...baseRecord(), + details: { arrayState: 42 }, + } as unknown as StorageRecord; + expect(getStoragePoolStateLabel(record)).toBe('Available'); + }); + + it('falls through when details.arrayState is the empty string', () => { + const record = { + ...baseRecord(), + details: { arrayState: '' }, + } as unknown as StorageRecord; + expect(getStoragePoolStateLabel(record)).toBe('Available'); + }); + + it('returns "Online" when only the zfs pool state is "ONLINE"', () => { + const record = { + ...baseRecord(), + details: { zfsPool: { state: 'ONLINE', devices: [] } }, + } as unknown as StorageRecord; + expect(getStoragePoolStateLabel(record)).toBe('Online'); + }); + + it('returns the raw pool state when it is anything other than "ONLINE"', () => { + const record = { + ...baseRecord(), + details: { zfsPool: { state: 'FAULTED', devices: [] } }, + } as unknown as StorageRecord; + expect(getStoragePoolStateLabel(record)).toBe('FAULTED'); + }); + + it('skips a zfs pool whose state is the empty string (pool?.state truthiness guard false)', () => { + // toZfsPool accepts state='' (it is a string), but `pool?.state` is falsy, + // so the function falls through to the status arm. + const record = { + ...baseRecord(), + details: { zfsPool: { state: '', devices: [] } }, + } as unknown as StorageRecord; + expect(getStoragePoolStateLabel(record)).toBe('Available'); + }); + + it('titleizes the health-derived status when arrayState and pool are both absent', () => { + const record = { + ...baseRecord(), + health: 'critical', + } as unknown as StorageRecord; + expect(getStoragePoolStateLabel(record)).toBe('Critical'); + }); + + it('titleizes an explicit details.status when arrayState and pool are absent', () => { + const record = { + ...baseRecord(), + details: { status: 'available' }, + } as unknown as StorageRecord; + expect(getStoragePoolStateLabel(record)).toBe('Available'); + }); + + it('exercises the `details || {}` defensive arm when details is undefined', () => { + const record = { + ...baseRecord(), + details: undefined, + health: 'warning', + } as unknown as StorageRecord; + // health 'warning' -> status 'degraded' -> titleized 'Degraded'. + expect(getStoragePoolStateLabel(record)).toBe('Degraded'); + }); +}); + +// --------------------------------------------------------------------------- +// getStoragePoolStateTitle +// +// Shape under test: +// label = getStoragePoolStateLabel(record) +// summary = getCompactStoragePoolIssueSummary(record).trim() || getStorageRecordIssueSummary(record).trim() +// if (summary && summary.toLowerCase() !== 'healthy' && label.toLowerCase() !== 'started') +// return summary +// return label === '—' ? '' : label +// +// NOTE: getStoragePoolStateLabel never returns '—' (status is always non-empty), +// so the `label === '—' ? ''` arm is unreachable in practice; see report. +// --------------------------------------------------------------------------- +describe('getStoragePoolStateTitle branch coverage', () => { + it('returns a non-healthy summary when the label is not "Started" (main branch)', () => { + const record = { + ...baseRecord(), + issueLabel: 'Degraded', + issueSummary: 'One disk is offline', + details: { arrayState: 'DEGRADED' }, + } as unknown as StorageRecord; + expect(getStoragePoolStateTitle(record)).toBe('One disk is offline'); + }); + + it('suppresses the summary when the resolved label is "Started" and returns the label', () => { + const record = { + ...baseRecord(), + issueLabel: 'Degraded', + issueSummary: 'Some warning', + details: { arrayState: 'STARTED' }, + } as unknown as StorageRecord; + expect(getStoragePoolStateTitle(record)).toBe('Started'); + }); + + it('suppresses a "healthy" summary (case-insensitive) and returns the resolved label', () => { + // getCompactStoragePoolIssueSummary returns '' (issue summary is 'Healthy'), + // so summary falls back to getStorageRecordIssueSummary -> 'Healthy' -> + // 'healthy' -> suppressed. + const record = { + ...baseRecord(), + issueLabel: 'Degraded', + issueSummary: 'Healthy', + details: { arrayState: 'DEGRADED' }, + } as unknown as StorageRecord; + expect(getStoragePoolStateTitle(record)).toBe('Degraded'); + }); + + it('uses getStorageRecordIssueSummary when getCompactStoragePoolIssueSummary is empty', () => { + // issueLabel 'Healthy' -> getCompactStoragePoolIssueLabel '—' -> compact + // summary ''; issueSummary 'Manual maintenance' is non-healthy and the label + // ('Degraded') is not 'started', so the fallback summary is returned. + const record = { + ...baseRecord(), + issueLabel: 'Healthy', + issueSummary: 'Manual maintenance', + details: { arrayState: 'DEGRADED' }, + } as unknown as StorageRecord; + expect(getStoragePoolStateTitle(record)).toBe('Manual maintenance'); + }); + + it('returns the resolved label when there is no summary signal at all', () => { + const record = { + ...baseRecord(), + details: { arrayState: 'STARTED' }, + } as unknown as StorageRecord; + expect(getStoragePoolStateTitle(record)).toBe('Started'); + }); + + it('returns an empty-ish label verbatim via the trailing `: label` arm when summary is absent', () => { + // No issue signal; arrayState 'STOPPING' -> label 'Stopping'. Summary '' -> + // both clauses of the if false -> return label. + const record = { + ...baseRecord(), + details: { arrayState: 'STOPPING' }, + } as unknown as StorageRecord; + expect(getStoragePoolStateTitle(record)).toBe('Stopping'); + }); +}); + +// --------------------------------------------------------------------------- +// getCompactStoragePoolImpactLabel +// +// Shape under test: +// if ((consumerCount || 0) > 0 || +// (protectedWorkloadCount || 0) > 0 || +// (affectedDatastoreCount || 0) > 0) +// return (impactSummary || '').trim() || '—' +// return '—' +// --------------------------------------------------------------------------- +describe('getCompactStoragePoolImpactLabel branch coverage', () => { + it('returns the trimmed impact summary when consumerCount > 0', () => { + const record = { + ...baseRecord(), + consumerCount: 2, + impactSummary: '2 consumers', + }; + expect(getCompactStoragePoolImpactLabel(record)).toBe('2 consumers'); + }); + + it('trims surrounding whitespace from a present impactSummary', () => { + const record = { + ...baseRecord(), + consumerCount: 1, + impactSummary: ' 5 vms ', + }; + expect(getCompactStoragePoolImpactLabel(record)).toBe('5 vms'); + }); + + it('enters the block via protectedWorkloadCount > 0 alone and falls to "—" when impactSummary is blank', () => { + const record = { + ...baseRecord(), + protectedWorkloadCount: 1, + impactSummary: ' ', + }; + expect(getCompactStoragePoolImpactLabel(record)).toBe('—'); + }); + + it('enters the block via affectedDatastoreCount > 0 alone and returns the impact summary', () => { + const record = { + ...baseRecord(), + affectedDatastoreCount: 3, + impactSummary: '3 datastores affected', + }; + expect(getCompactStoragePoolImpactLabel(record)).toBe('3 datastores affected'); + }); + + it('returns "—" via the inner `|| "—"` when impactSummary is undefined but a count is set', () => { + const record = { + ...baseRecord(), + consumerCount: 1, + impactSummary: undefined, + }; + expect(getCompactStoragePoolImpactLabel(record)).toBe('—'); + }); + + it('coerces a null consumerCount to 0 via `consumerCount || 0` and still enters via another count', () => { + const record = { + ...baseRecord(), + consumerCount: null, + protectedWorkloadCount: 2, + impactSummary: '2 protected', + } as unknown as StorageRecord; + expect(getCompactStoragePoolImpactLabel(record)).toBe('2 protected'); + }); + + it('returns "—" when all three counts are explicitly zero', () => { + const record = { + ...baseRecord(), + consumerCount: 0, + protectedWorkloadCount: 0, + affectedDatastoreCount: 0, + impactSummary: 'ignored', + }; + expect(getCompactStoragePoolImpactLabel(record)).toBe('—'); + }); + + it('returns "—" when all counts are absent (undefined) even if impactSummary is set', () => { + const record = { + ...baseRecord(), + impactSummary: 'No dependent resources', + }; + expect(getCompactStoragePoolImpactLabel(record)).toBe('—'); + }); + + it('returns "—" when all counts are null and impactSummary is set', () => { + const record = { + ...baseRecord(), + consumerCount: null, + protectedWorkloadCount: null, + affectedDatastoreCount: null, + impactSummary: 'No dependent resources', + } as unknown as StorageRecord; + expect(getCompactStoragePoolImpactLabel(record)).toBe('—'); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/patrolFormat.branchcov0712b.test.ts b/frontend-modern/src/utils/__tests__/patrolFormat.branchcov0712b.test.ts new file mode 100644 index 0000000000..d47e25656c --- /dev/null +++ b/frontend-modern/src/utils/__tests__/patrolFormat.branchcov0712b.test.ts @@ -0,0 +1,364 @@ +/** + * Supplemental branch-coverage tests for `patrolFormat`. + * + * The sibling `patrolFormat.test.ts` exercises the happy paths of every export, + * but several branches of these three functions are never driven: + * + * - `formatPatrolRuntimeFailureDetail`: the `text || ''` / `!raw` empty arms, + * five of the eight canonical "return raw" list entries, several keyword + * arms inside the classification cascades (tool / credit / rate-limit / + * auth / connection / analysis-error), and two redaction regexes (`sk-` + * secrets and JSON credential keys). + * - `formatPatrolRuntimeFailureSummary`: the "provider analysis failed" + * detail-preference sub-branch, the summary===detail fallthrough, the + * only-summary / only-detail arms, and the entire `errorCount` fallback + * (singular / plural / clamp / undefined). + * - `formatScope`: the terminal `return ''` reached when a run has no ids, + * no types, and is not the literal `type: 'scoped'`. + * + * Each test asserts a concrete output string/shape against the real branch + * it targets; no truthiness-only assertions are used. + */ +import { describe, expect, it } from 'vitest'; + +import { + formatPatrolRuntimeFailureDetail, + formatPatrolRuntimeFailureSummary, + formatScope, +} from '@/utils/patrolFormat'; + +describe('patrolFormat branch coverage (supplemental)', () => { + describe('formatPatrolRuntimeFailureDetail', () => { + it('returns "" for undefined input (text || "" falsy arm + !raw guard)', () => { + expect(formatPatrolRuntimeFailureDetail(undefined)).toBe(''); + }); + + it('returns "" for whitespace-only input (trim drives raw to "" -> !raw arm)', () => { + expect(formatPatrolRuntimeFailureDetail(' ')).toBe(''); + }); + + it('collapses internal whitespace before classification, preserving outer case on a canonical match', () => { + // raw normalises 'Provider Rate\nLimited' -> 'Provider Rate Limited'; + // lowercased it hits the canonical list and the original casing is returned. + expect(formatPatrolRuntimeFailureDetail('Provider Rate\nLimited')).toBe( + 'Provider Rate Limited', + ); + }); + + describe('canonical-list arms that return the raw (trimmed) string verbatim', () => { + // The sibling test only drives 'provider billing or quota issue', + // 'selected model does not support patrol tools', and + // 'provider connection issue' (all via the summary wrapper). These hit + // the remaining five canonical entries directly. + it('matches "provider rate limited"', () => { + expect(formatPatrolRuntimeFailureDetail('Provider rate limited')).toBe( + 'Provider rate limited', + ); + }); + + it('matches "provider authentication issue"', () => { + expect(formatPatrolRuntimeFailureDetail('Provider authentication issue')).toBe( + 'Provider authentication issue', + ); + }); + + it('matches "provider not ready"', () => { + expect(formatPatrolRuntimeFailureDetail('Provider not ready')).toBe( + 'Provider not ready', + ); + }); + + it('matches "selected model unavailable"', () => { + expect(formatPatrolRuntimeFailureDetail('Selected model unavailable')).toBe( + 'Selected model unavailable', + ); + }); + + it('matches "selected model context window too small"', () => { + expect( + formatPatrolRuntimeFailureDetail('Selected model context window too small'), + ).toBe('Selected model context window too small'); + }); + }); + + describe('tool-call rejection classification arms', () => { + const expected = + 'Provider rejected Patrol tool calls. Choose a Patrol model and endpoint with tool-call support.'; + + it('matches the standalone "tool calling" keyword', () => { + expect(formatPatrolRuntimeFailureDetail('Tool calling is disabled')).toBe(expected); + }); + + it('matches the "tools are not supported" phrase', () => { + expect(formatPatrolRuntimeFailureDetail('tools are not supported here')).toBe( + expected, + ); + }); + + it('matches the compound "no endpoints found" + "tool" condition (both halves required)', () => { + // Only "no endpoints found" without "tool" must NOT hit this arm — + // exercised separately below to prove the conjunction. + expect( + formatPatrolRuntimeFailureDetail('no endpoints found for the requested tool'), + ).toBe(expected); + }); + + it('does NOT classify when "no endpoints found" appears without "tool" (conjunction false arm)', () => { + // Falls through every classifier to the redaction tail; with no + // secrets to redact the trimmed raw is returned unchanged. + expect(formatPatrolRuntimeFailureDetail('no endpoints found in region')).toBe( + 'no endpoints found in region', + ); + }); + }); + + describe('insufficient credits / token budget classification arms', () => { + const expected = + 'Provider reported insufficient credits or token budget for the requested Patrol analysis.'; + + it('matches "insufficient balance"', () => { + expect(formatPatrolRuntimeFailureDetail('insufficient balance on account')).toBe( + expected, + ); + }); + + it('matches "payment required"', () => { + expect(formatPatrolRuntimeFailureDetail('HTTP payment required')).toBe(expected); + }); + + it('matches "quota"', () => { + expect(formatPatrolRuntimeFailureDetail('daily quota exhausted')).toBe(expected); + }); + + it('matches "credit"', () => { + expect(formatPatrolRuntimeFailureDetail('out of credit')).toBe(expected); + }); + + it('matches "max_tokens"', () => { + expect(formatPatrolRuntimeFailureDetail('max_tokens exceeded budget')).toBe( + expected, + ); + }); + }); + + describe('rate-limit classification arms', () => { + const expected = + 'Provider rate limit reached. Wait for capacity or adjust provider limits before retrying.'; + + it('matches "429"', () => { + expect(formatPatrolRuntimeFailureDetail('server returned 429')).toBe(expected); + }); + + it('matches "too many requests"', () => { + expect(formatPatrolRuntimeFailureDetail('too many requests sent')).toBe(expected); + }); + + it('matches "rate limit"', () => { + expect(formatPatrolRuntimeFailureDetail('rate limit hit')).toBe(expected); + }); + }); + + describe('authentication classification arms', () => { + const expected = + 'Provider authentication failed. Check the configured provider key and account access.'; + + it('matches "401"', () => { + expect(formatPatrolRuntimeFailureDetail('request failed: 401')).toBe(expected); + }); + + it('matches "403"', () => { + expect(formatPatrolRuntimeFailureDetail('403 forbidden by gateway')).toBe(expected); + }); + + it('matches "unauthorized"', () => { + expect(formatPatrolRuntimeFailureDetail('unauthorized request')).toBe(expected); + }); + + it('matches "forbidden"', () => { + expect(formatPatrolRuntimeFailureDetail('forbidden resource')).toBe(expected); + }); + + it('matches "api key"', () => { + expect(formatPatrolRuntimeFailureDetail('api key revoked')).toBe(expected); + }); + }); + + describe('connection classification arms', () => { + const expected = + 'Provider connection failed. Check provider reachability before retrying Patrol.'; + + it('matches "failed to connect"', () => { + expect(formatPatrolRuntimeFailureDetail('failed to connect to upstream')).toBe( + expected, + ); + }); + + it('matches "connection refused"', () => { + expect(formatPatrolRuntimeFailureDetail('connection refused by host')).toBe( + expected, + ); + }); + + it('matches "no such host"', () => { + expect(formatPatrolRuntimeFailureDetail('no such host: api.example')).toBe( + expected, + ); + }); + + it('matches "i/o timeout"', () => { + expect(formatPatrolRuntimeFailureDetail('i/o timeout reading body')).toBe(expected); + }); + + it('matches bare "timeout" (distinct from "timed out")', () => { + expect(formatPatrolRuntimeFailureDetail('request timeout')).toBe(expected); + }); + + it('matches "returned status 5" (5xx prefix arm)', () => { + expect(formatPatrolRuntimeFailureDetail('provider returned status 503')).toBe( + expected, + ); + }); + }); + + describe('provider-analysis classification arms', () => { + const expected = + 'Provider analysis failed. Check Provider & Models before retrying Patrol.'; + + it('matches the exact "agentic patrol failed" sentinel', () => { + expect(formatPatrolRuntimeFailureDetail('agentic patrol failed')).toBe(expected); + }); + + it('matches the "provider analysis error: agentic patrol failed" prefix', () => { + expect( + formatPatrolRuntimeFailureDetail( + 'provider analysis error: agentic patrol failed mid-run', + ), + ).toBe(expected); + }); + + it('matches the "agentic patrol failed: provider error" prefix', () => { + expect( + formatPatrolRuntimeFailureDetail( + 'agentic patrol failed: provider error something else', + ), + ).toBe(expected); + }); + }); + + describe('defensive redaction tail (no classifier matched)', () => { + it('redacts bare "sk-" secrets', () => { + const out = formatPatrolRuntimeFailureDetail('using key sk-abcd1234EFGH'); + expect(out).toBe('using key [redacted-secret]'); + expect(out).not.toContain('sk-abcd1234EFGH'); + }); + + it('redacts JSON credential keys case-insensitively across naming styles', () => { + // Exercises the JSON-key regex for snake_case "api_key" and "token". + const out = formatPatrolRuntimeFailureDetail( + 'payload was {"api_key":"sk-leak123","token":"abc"}', + ); + expect(out).toBe('payload was {"api_key":"[redacted]","token":"[redacted]"}'); + expect(out).not.toContain('sk-leak123'); + expect(out).not.toContain('"abc"'); + }); + + it('redacts kebab/no-separator variants (apikey, x-api-key, authorization)', () => { + const out = formatPatrolRuntimeFailureDetail( + 'headers {"apikey":"v1","x-api-key":"v2","authorization":"Bearer z"}', + ); + expect(out).toBe( + 'headers {"apikey":"[redacted]","x-api-key":"[redacted]","authorization":"[redacted]"}', + ); + }); + + it('returns the trimmed raw when nothing sensitive is present (final .trim())', () => { + expect(formatPatrolRuntimeFailureDetail(' just a benign message ')).toBe( + 'just a benign message', + ); + }); + }); + }); + + describe('formatPatrolRuntimeFailureSummary', () => { + describe('summary/detail interaction', () => { + it('prefers the detail when summary is "Provider analysis failed" and detail starts with "provider " (analysis sub-branch)', () => { + // summary -> formatPatrolRuntimeFailureDetail('provider analysis error') + // -> 'Provider analysis failed. Check Provider & Models before retrying Patrol.' + // detail -> 'provider rate limited' (canonical) -> returned verbatim, starts with 'provider '. + // summary !== detail, so the analysis-failed sub-branch returns `detail`. + const out = formatPatrolRuntimeFailureSummary({ + errorSummary: 'provider analysis error', + errorDetail: 'provider rate limited', + }); + expect(out).toBe('provider rate limited'); + }); + + it('returns the single value via `summary || detail` when summary === detail (inequality false arm)', () => { + // Both classify to the same canonical raw -> equal -> skips the + // `${summary}: ${detail}` arm and returns one copy. + const out = formatPatrolRuntimeFailureSummary({ + errorSummary: 'provider rate limited', + errorDetail: 'provider rate limited', + }); + expect(out).toBe('provider rate limited'); + }); + + it('returns just the summary when detail is absent (summary || detail, detail empty)', () => { + expect( + formatPatrolRuntimeFailureSummary({ errorSummary: 'provider rate limited' }), + ).toBe('provider rate limited'); + }); + + it('returns just the detail when summary is absent (summary || detail, summary empty)', () => { + expect(formatPatrolRuntimeFailureSummary({ errorDetail: '401 unauthorized' })).toBe( + 'Provider authentication failed. Check the configured provider key and account access.', + ); + }); + }); + + describe('errorCount fallback (both summary and detail empty)', () => { + it('renders the singular form for errorCount === 1', () => { + expect(formatPatrolRuntimeFailureSummary({ errorCount: 1 })).toBe( + '1 Patrol runtime error recorded', + ); + }); + + it('renders the plural form for errorCount > 1', () => { + expect(formatPatrolRuntimeFailureSummary({ errorCount: 3 })).toBe( + '3 Patrol runtime errors recorded', + ); + }); + + it('returns undefined for errorCount === 0', () => { + expect(formatPatrolRuntimeFailureSummary({ errorCount: 0 })).toBeUndefined(); + }); + + it('returns undefined when errorCount is omitted (errorCount || 0 -> 0)', () => { + expect(formatPatrolRuntimeFailureSummary({})).toBeUndefined(); + }); + + it('clamps a negative errorCount to 0 via Math.max and returns undefined', () => { + // input.errorCount || 0 -> -2 (truthy), then Math.max(0, -2) -> 0. + expect(formatPatrolRuntimeFailureSummary({ errorCount: -2 })).toBeUndefined(); + }); + }); + }); + + describe('formatScope terminal fallthrough', () => { + it('returns "" for an empty run object (no ids, no types, not "scoped")', () => { + // Drives the final `return ''` after idCount===0, types.length===0, + // and run.type !== 'scoped' all fall through. + expect(formatScope({})).toBe(''); + }); + + it('returns "" when scope_resource_types is an empty array (types.length > 0 false arm)', () => { + expect(formatScope({ scope_resource_types: [] })).toBe(''); + }); + + it('returns "" when scope_resource_ids is an empty array (idCount === 0, not nullish)', () => { + // [].length is 0 (not undefined), so the `?? 0` keeps 0; with no types + // and no scoped type the function still bottoms out at ''. + expect(formatScope({ scope_resource_ids: [] })).toBe(''); + }); + }); +});