diff --git a/frontend-modern/src/components/Storage/__tests__/storagePageState.branchcov0723pm.test.ts b/frontend-modern/src/components/Storage/__tests__/storagePageState.branchcov0723pm.test.ts new file mode 100644 index 0000000000..2f94d228a8 --- /dev/null +++ b/frontend-modern/src/components/Storage/__tests__/storagePageState.branchcov0723pm.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { + getActiveStorageNodeOptions, + getStorageFilterGroupBy, + storageResourceMatchesSourceFilter, + syncExpandedStorageGroups, + toStorageHealthFilterValue, + type StoragePageNodeOption, + type StorageStatusFilterValue, + type StorageView, +} from '@/components/Storage/storagePageState'; + +const makeNodeOption = (overrides: Partial = {}): StoragePageNodeOption => ({ + id: 'node-1', + label: 'pve1', + ...overrides, +}); + +const makeFilterableResource = (overrides: Partial = {}): Resource => + ({ + id: 'node-1', + type: 'agent', + name: 'pve1', + platformType: 'proxmox-pve', + sourceType: 'api', + platformData: { sources: ['proxmox-pve', 'agent'] }, + ...overrides, + }) as Resource; + +describe('storagePageState branch coverage 0723pm', () => { + describe('getActiveStorageNodeOptions', () => { + it('returns the diskNodeOptions array (by reference) when view is "disks"', () => { + const nodes = [makeNodeOption({ id: 'node-1', label: 'pve1' })]; + const disks = [makeNodeOption({ id: 'node-2', label: 'pve2' })]; + // Reference equality proves the function performs no defensive copy and + // selects the disk set, not the pool set. + expect(getActiveStorageNodeOptions('disks', nodes, disks)).toBe(disks); + }); + + it('returns the nodeOptions array (by reference) when view is "pools"', () => { + const nodes = [makeNodeOption({ id: 'node-1', label: 'pve1' })]; + const disks = [makeNodeOption({ id: 'node-2', label: 'pve2' })]; + expect(getActiveStorageNodeOptions('pools', nodes, disks)).toBe(nodes); + }); + + it('preserves a several-node ordering without sorting', () => { + const disks = [ + makeNodeOption({ id: 'c', label: 'gamma' }), + makeNodeOption({ id: 'a', label: 'alpha' }), + makeNodeOption({ id: 'b', label: 'beta' }), + ]; + const result = getActiveStorageNodeOptions('disks', [], disks); + expect(result.map((node) => node.id)).toEqual(['c', 'a', 'b']); + }); + + it('does NOT dedupe duplicate ids or labels (returns them as-is)', () => { + // The function is a pure selector: it performs no deduplication. + const disks = [ + makeNodeOption({ id: 'dup', label: 'same' }), + makeNodeOption({ id: 'dup', label: 'same' }), + ]; + const result = getActiveStorageNodeOptions('disks', [], disks); + expect(result).toHaveLength(2); + expect(result.map((node) => node.id)).toEqual(['dup', 'dup']); + }); + + it('preserves options whose label is missing/empty (no filtering by name)', () => { + const disks = [ + makeNodeOption({ id: 'has-name', label: 'pve1' }), + makeNodeOption({ id: 'no-name', label: '' }), + ]; + const result = getActiveStorageNodeOptions('disks', [], disks); + expect(result.map((node) => node.id)).toEqual(['has-name', 'no-name']); + }); + + it('treats an unrecognized view string as pools (returns nodeOptions)', () => { + // StorageView is a closed union, but the function only special-cases + // the literal "disks"; every other value resolves to the pool set. + const nodes = [makeNodeOption({ id: 'node-1', label: 'pve1' })]; + const disks = [makeNodeOption({ id: 'node-2', label: 'pve2' })]; + const result = getActiveStorageNodeOptions('banana' as unknown as StorageView, nodes, disks); + expect(result).toBe(nodes); + }); + }); + + describe('getStorageFilterGroupBy (ternary true arm)', () => { + // Existing coverage only exercised value === 'node', which hits the false + // arm (default). These exercise the true arm: an accepted canonical key is + // returned unchanged. + it.each(['type', 'status', 'none'] as const)( + 'returns %s unchanged when it is a recognized group key (true arm)', + (value) => { + expect(getStorageFilterGroupBy(value)).toBe(value); + }, + ); + }); + + describe('toStorageHealthFilterValue (value === "all" arm)', () => { + // Existing coverage exercised attention/available and the fallthrough, but + // never the literal "all" early-return. + it('returns "all" when the value is "all"', () => { + const input: StorageStatusFilterValue = 'all'; + expect(toStorageHealthFilterValue(input)).toBe('all'); + }); + }); + + describe('storageResourceMatchesSourceFilter (sources-is-not-an-array arm)', () => { + it('falls back to the empty sources list when platformData.sources is missing', () => { + // Array.isArray(undefined) === false => the `: []` arm fires, so matching + // must rely on platformType / sourceType only. + const node = makeFilterableResource({ platformData: {} }); + expect(storageResourceMatchesSourceFilter(node, 'proxmox-pve')).toBe(true); + expect(storageResourceMatchesSourceFilter(node, 'truenas')).toBe(false); + }); + + it('falls back to the empty sources list when platformData.sources is a non-array', () => { + const node = makeFilterableResource({ + platformData: { sources: 'proxmox-pve' } as unknown as Resource['platformData'], + }); + // platformType / sourceType still match even though the bogus sources value is ignored. + expect(storageResourceMatchesSourceFilter(node, 'proxmox-pve')).toBe(true); + expect(storageResourceMatchesSourceFilter(node, 'api')).toBe(true); + expect(storageResourceMatchesSourceFilter(node, 'agent')).toBe(false); + }); + + it('matches via sourceType alone when platformData is undefined', () => { + const node = makeFilterableResource({ + platformData: undefined, + platformType: undefined, + sourceType: 'agent', + }); + expect(storageResourceMatchesSourceFilter(node, 'agent')).toBe(true); + expect(storageResourceMatchesSourceFilter(node, 'proxmox-pve')).toBe(false); + }); + }); + + describe('syncExpandedStorageGroups (changed=false arm)', () => { + it('returns the previous set unchanged (same reference) when every key is already present', () => { + // No new keys => `changed` stays false => the `: previous` arm fires and + // the input reference is returned verbatim. + const previous = new Set(['A', 'B']); + const result = syncExpandedStorageGroups(previous, ['A', 'B']); + expect(result).toBe(previous); + expect(result).toEqual(new Set(['A', 'B'])); + }); + + it('returns previous unchanged when allKeys is empty but previous is non-empty', () => { + const previous = new Set(['A']); + const result = syncExpandedStorageGroups(previous, []); + expect(result).toBe(previous); + }); + }); +}); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageBarPresentation.branchcov0723pm.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageBarPresentation.branchcov0723pm.test.ts new file mode 100644 index 0000000000..5139ab2e9d --- /dev/null +++ b/frontend-modern/src/features/storageBackups/__tests__/storageBarPresentation.branchcov0723pm.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest'; +import type { ZFSPool } from '@/types/api'; +import { + getStorageBarLabel, + getStorageBarUsagePercent, + getStorageBarZfsSummary, +} from '@/features/storageBackups/storageBarPresentation'; + +// --------------------------------------------------------------------------- +// Fixture builder — mirrors the convention in storageBarPresentation.test.ts +// (which casts a partial with `as any`) but stays fully typed so the pre-push +// tsc stays happy. Only the fields getStorageBarZfsSummary reads need to be +// overridden; everything else defaults to a clean ONLINE pool with no errors. +// --------------------------------------------------------------------------- + +const makeZfsPool = (overrides: Partial = {}): ZFSPool => + ({ + name: 'tank', + state: 'ONLINE', + status: 'Healthy', + scan: 'none', + readErrors: 0, + writeErrors: 0, + checksumErrors: 0, + devices: [], + ...overrides, + }) as ZFSPool; + +// =========================================================================== +// Branch map for storageBarPresentation.ts +// +// getStorageBarUsagePercent (L36-39) +// L37 `if (total <= 0) return 0` +// true arm : total<=0 -> 0 [UNCOVERED — covered here] +// false arm : total>0 (covered by happy path) +// +// getStorageBarZfsSummary (L61-77) +// L62 `if (!zfsPool) return null` +// true arm : zfsPool missing/null [UNCOVERED — covered here] +// false arm (covered by happy path) +// L64 `zfsPool.scan || ''` +// falsy arm : scan undefined/'' [UNCOVERED — covered here] +// truthy arm (covered by happy path) +// L65 `readErrors>0 || writeErrors>0 || checksumErrors>0` +// readErrors>0 short-circuit only [UNCOVERED — covered here] +// writeErrors>0 arm [UNCOVERED — covered here] +// checksumErrors>0 arm [UNCOVERED — covered here] +// all-false arm [UNCOVERED — covered here] +// L69 `scan.toLowerCase().includes('scrub')` +// true arm [UNCOVERED — covered here] +// false arm (covered by happy path: 'resilver…') +// L70 `scan.toLowerCase().includes('resilver')` +// true arm (covered by happy path) +// false arm [UNCOVERED — covered here] +// L72 `scan && scan !== 'none' ? scan : ''` +// false arm : scan==='none' [UNCOVERED — covered here] +// true arm (covered by happy path) +// L73 `hasErrors ? 'Errors:…' : ''` +// false arm [UNCOVERED — covered here] +// true arm (covered by happy path) +// =========================================================================== + +describe('storageBarPresentation branch coverage', () => { + // ========================================================================= + // getStorageBarUsagePercent — L37 `if (total <= 0) return 0` true arm + // ========================================================================= + describe('getStorageBarUsagePercent — total<=0 guard (L37 true arm)', () => { + it('returns exactly 0 when total is 0 (no NaN, no Infinity)', () => { + expect(getStorageBarUsagePercent(40, 0)).toBe(0); + }); + + it('returns exactly 0 when total is negative (guard is <=, not <)', () => { + // Locks the boundary: -100 is strictly < 0, proving the guard matches + // the `<= 0` predicate rather than `=== 0`. + expect(getStorageBarUsagePercent(40, -100)).toBe(0); + }); + + it('still computes a real ratio for total === 1 (proves guard is total<=0, not total<1)', () => { + // 0.5 of 1 byte = 50% — sanity check that small positive totals are NOT + // collapsed to 0 by some other implicit clamp. + expect(getStorageBarUsagePercent(0.5, 1)).toBe(50); + }); + }); + + describe('getStorageBarUsagePercent — boundary ratios', () => { + it('returns exactly 0 when used is 0 (zero percent usage)', () => { + expect(getStorageBarUsagePercent(0, 100)).toBe(0); + }); + + it('returns a value over 100 when used exceeds total (over-capacity)', () => { + expect(getStorageBarUsagePercent(150, 100)).toBe(150); + }); + + it('returns a negative ratio when used is negative (no clamp on `used`)', () => { + // Documents current behaviour: only `total` is guarded; `used` flows + // straight into the division, so a malformed negative `used` surfaces + // as a negative percentage rather than 0. + expect(getStorageBarUsagePercent(-10, 100)).toBe(-10); + }); + }); + + // ========================================================================= + // getStorageBarLabel — composes the percent guard + formatters; pin the + // observable string for the boundary cases so the format is locked. + // ========================================================================= + describe('getStorageBarLabel — observable label for guard + over-100 cases', () => { + it('produces "0% (used/0 B)" when total is 0 (percent short-circuited to 0)', () => { + // percent(40,0)=0 -> formatPercent(0)='0%'; formatBytes(40)='40.0 B'; formatBytes(0)='0 B'. + expect(getStorageBarLabel(40, 0)).toBe('0% (40.0 B/0 B)'); + }); + + it('produces a >100% label when used exceeds total (formatPercent has no clamp either)', () => { + // percent(150,100)=150 -> '150%'; formatBytes(150)='150 B'; formatBytes(100)='100 B'. + expect(getStorageBarLabel(150, 100)).toBe('150% (150 B/100 B)'); + }); + }); + + // ========================================================================= + // getStorageBarZfsSummary — L62 `if (!zfsPool) return null` true arm + // ========================================================================= + describe('getStorageBarZfsSummary — missing pool (L62 true arm)', () => { + it('returns null when called with no argument', () => { + expect(getStorageBarZfsSummary()).toBeNull(); + }); + + it('returns null when called with explicit undefined', () => { + expect(getStorageBarZfsSummary(undefined)).toBeNull(); + }); + + it('returns null when called with null cast through unknown (defensive null guard)', () => { + // ZFSPool is typed as a required interface, so null must be injected + // via `as unknown as ZFSPool` to bypass strict null checks at the call + // site without weakening the source signature. + expect(getStorageBarZfsSummary(null as unknown as ZFSPool)).toBeNull(); + }); + }); + + // ========================================================================= + // getStorageBarZfsSummary — L64 `zfsPool.scan || ''` falsy arm + // ========================================================================= + describe('getStorageBarZfsSummary — scan falsy fallback (L64 falsy arm)', () => { + it('substitutes empty string when scan is undefined (observable via output.scan / substring flags)', () => { + // L64 falsy -> scan becomes '' -> L72 ternary false -> output.scan = ''. + // Also proves the downstream substring checks run against '', so both + // isScrubbing and isResilvering come back false. + const summary = getStorageBarZfsSummary( + makeZfsPool({ scan: undefined } as unknown as ZFSPool), + ); + expect(summary).not.toBeNull(); + expect(summary!.scan).toBe(''); + expect(summary!.isScrubbing).toBe(false); + expect(summary!.isResilvering).toBe(false); + }); + + it('substitutes empty string when scan is the empty string', () => { + const summary = getStorageBarZfsSummary(makeZfsPool({ scan: '' })); + expect(summary!.scan).toBe(''); + }); + }); + + // ========================================================================= + // getStorageBarZfsSummary — L65 OR chain (3 arms + all-false arm) + // ========================================================================= + describe('getStorageBarZfsSummary — error-count OR chain (L65)', () => { + it('flags hasErrors=true via readErrors only (first OR arm, short-circuit)', () => { + const summary = getStorageBarZfsSummary( + makeZfsPool({ readErrors: 1, writeErrors: 0, checksumErrors: 0 }), + ); + expect(summary!.hasErrors).toBe(true); + expect(summary!.errorSummary).toBe('Errors: R:1 W:0 C:0'); + }); + + it('flags hasErrors=true via writeErrors only (second OR arm)', () => { + const summary = getStorageBarZfsSummary( + makeZfsPool({ readErrors: 0, writeErrors: 7, checksumErrors: 0 }), + ); + expect(summary!.hasErrors).toBe(true); + expect(summary!.errorSummary).toBe('Errors: R:0 W:7 C:0'); + }); + + it('flags hasErrors=true via checksumErrors only (third OR arm)', () => { + const summary = getStorageBarZfsSummary( + makeZfsPool({ readErrors: 0, writeErrors: 0, checksumErrors: 4 }), + ); + expect(summary!.hasErrors).toBe(true); + expect(summary!.errorSummary).toBe('Errors: R:0 W:0 C:4'); + }); + + it('hasErrors=false when all error counts are 0 (OR chain all-false arm + L73 false arm)', () => { + const summary = getStorageBarZfsSummary( + makeZfsPool({ readErrors: 0, writeErrors: 0, checksumErrors: 0 }), + ); + expect(summary!.hasErrors).toBe(false); + // L73 false arm: errorSummary collapses to ''. + expect(summary!.errorSummary).toBe(''); + }); + + it('treats negative error counts as no-error (each `> 0` is false)', () => { + // Documents current behaviour: the guards are strictly `> 0`, so a + // malformed negative count does NOT flip hasErrors. Exercises the false + // arm of every comparison in the OR chain simultaneously. + const summary = getStorageBarZfsSummary( + makeZfsPool({ readErrors: -1, writeErrors: -2, checksumErrors: -3 }), + ); + expect(summary!.hasErrors).toBe(false); + expect(summary!.errorSummary).toBe(''); + }); + }); + + // ========================================================================= + // getStorageBarZfsSummary — L69/L70 scan substring arms + // ========================================================================= + describe('getStorageBarZfsSummary — scan substring arms (L69/L70)', () => { + it('detects scrub when scan contains "scrub" (L69 true arm) and not resilver (L70 false arm)', () => { + const summary = getStorageBarZfsSummary(makeZfsPool({ scan: 'scrub in progress' })); + expect(summary!.isScrubbing).toBe(true); + expect(summary!.isResilvering).toBe(false); + }); + + it('detects resilver when scan contains "resilver" (L70 true arm) and not scrub (L69 false arm)', () => { + const summary = getStorageBarZfsSummary(makeZfsPool({ scan: 'resilver completed' })); + expect(summary!.isResilvering).toBe(true); + expect(summary!.isScrubbing).toBe(false); + }); + + it('detects both substrings when scan mentions scrub and resilver', () => { + const summary = getStorageBarZfsSummary(makeZfsPool({ scan: 'scrub+resilver running' })); + expect(summary!.isScrubbing).toBe(true); + expect(summary!.isResilvering).toBe(true); + }); + + it('matches neither substring for an unrelated scan (both false arms)', () => { + const summary = getStorageBarZfsSummary(makeZfsPool({ scan: 'idle' })); + expect(summary!.isScrubbing).toBe(false); + expect(summary!.isResilvering).toBe(false); + }); + + it('matches scrub case-insensitively (proves .toLowerCase() runs on the scan value)', () => { + // 'SCRUB PENDING' -> lowercased 'scrub pending' -> includes('scrub') true. + const summary = getStorageBarZfsSummary(makeZfsPool({ scan: 'SCRUB PENDING' })); + expect(summary!.isScrubbing).toBe(true); + }); + }); + + // ========================================================================= + // getStorageBarZfsSummary — L72 `scan && scan !== 'none' ? scan : ''` + // ========================================================================= + describe('getStorageBarZfsSummary — scan output ternary (L72)', () => { + it('collapses scan === "none" to empty string (L72 false arm via equality)', () => { + // 'none' is truthy, so the first half of the && is true, but the + // second half (`scan !== 'none'`) is false -> whole && is false -> ''. + const summary = getStorageBarZfsSummary(makeZfsPool({ scan: 'none' })); + expect(summary!.scan).toBe(''); + }); + + it('passes through a non-"none" scan value verbatim (L72 true arm)', () => { + const summary = getStorageBarZfsSummary(makeZfsPool({ scan: 'scrub repaired 0 errors' })); + expect(summary!.scan).toBe('scrub repaired 0 errors'); + }); + }); + + // ========================================================================= + // Full-shape regression for the clean-pool case — exercises every "false" + // arm of getStorageBarZfsSummary in one assertion so the canonical object + // shape is locked alongside the per-arm tests above. + // ========================================================================= + describe('getStorageBarZfsSummary — clean-pool canonical shape', () => { + it('returns the canonical clean-pool summary object', () => { + // Arms exercised simultaneously: + // L62 false (pool present) + // L64 truthy (scan='none' is truthy) + // L65 all-false (all counts 0) + // L69 false, L70 false ('none' contains neither substring) + // L72 false (scan==='none') + // L73 false (hasErrors false) + expect( + getStorageBarZfsSummary( + makeZfsPool({ + state: 'ONLINE', + scan: 'none', + readErrors: 0, + writeErrors: 0, + checksumErrors: 0, + }), + ), + ).toEqual({ + hasErrors: false, + isScrubbing: false, + isResilvering: false, + state: 'ONLINE', + scan: '', + errorSummary: '', + }); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/discoveryPresentation.branchcov0723pm.test.ts b/frontend-modern/src/utils/__tests__/discoveryPresentation.branchcov0723pm.test.ts new file mode 100644 index 0000000000..8db9c52031 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/discoveryPresentation.branchcov0723pm.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; + +import type { ResourceDiscovery } from '@/types/discovery'; +import { + getDiscoveryIdentifiedSummary, + getDiscoverySuggestedURLCodeClass, + getDiscoverySuggestedURLFallback, + getDiscoverySuggestedURLHeadingClass, + getDiscoverySuggestedURLTextClass, +} from '@/utils/discoveryPresentation'; + +// Baseline discovery record carries no meaningful signal: every "unknown" / +// empty value, no ports, paths, facts, url or probe. Mirrors the fixture in +// discoveryPresentation.branchcov.test.ts so behaviour stays consistent. +const makeDiscovery = (overrides: Partial = {}): ResourceDiscovery => + ({ + id: 'disc-1', + resource_type: 'docker', + resource_id: 'res-1', + target_id: 'agent-1', + hostname: 'host', + service_type: 'unknown', + service_name: 'unknown', + service_version: 'unknown', + category: 'unknown', + cli_access: '', + facts: [], + config_paths: [], + data_paths: [], + log_paths: [], + ports: [], + user_notes: '', + user_secrets: {}, + confidence: 0, + ai_reasoning: '', + discovered_at: '', + updated_at: '', + scan_duration: 0, + ...overrides, + }) as ResourceDiscovery; + +// --------------------------------------------------------------------------- +// Three presentation class helpers are never called by any existing test. +// They take no arguments and have no internal branches, so "all inputs" is a +// single arm each: assert the exact returned class string. +// --------------------------------------------------------------------------- + +describe('getDiscoverySuggestedURLHeadingClass (function + branch coverage)', () => { + it('returns the canonical heading class string and takes no inputs', () => { + expect(getDiscoverySuggestedURLHeadingClass()).toBe( + 'text-[11px] font-medium uppercase tracking-wide text-blue-800 dark:text-blue-200 mb-1', + ); + }); +}); + +describe('getDiscoverySuggestedURLTextClass (function + branch coverage)', () => { + it('returns the canonical body-text class string and takes no inputs', () => { + expect(getDiscoverySuggestedURLTextClass()).toBe('text-blue-700 dark:text-blue-300'); + }); +}); + +describe('getDiscoverySuggestedURLCodeClass (function + branch coverage)', () => { + it('returns the canonical mono code class string and takes no inputs', () => { + expect(getDiscoverySuggestedURLCodeClass()).toBe( + 'min-w-0 flex-1 rounded bg-blue-100 px-2 py-1.5 text-xs text-blue-800 dark:bg-blue-950 dark:text-blue-100 font-mono break-all', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Branch coverage: line 143 `const serviceName = (discovery.service_name || '').trim();` +// The makeDiscovery default ships service_name='unknown' (truthy), so the +// fallback arm only fires for a null/empty name. The record must stay +// meaningful via another signal or getDiscoveryIdentifiedSummary short-circuits +// to null before line 143 executes; a single port supplies that signal here. +// --------------------------------------------------------------------------- + +describe('getDiscoveryIdentifiedSummary: (service_name || "") fallback arm (branch coverage)', () => { + // Only genuinely falsy values flip the `||` to its fallback arm. (A + // whitespace-only string is truthy and so never reaches this branch.) + it.each<[string, unknown]>([ + ['null', null], + ['undefined', undefined], + ['empty string', ''], + ])( + 'falls back to "" and labels "Unidentified service" when service_name is %s', + (_label, name) => { + const summary = getDiscoveryIdentifiedSummary( + makeDiscovery({ + service_name: name as unknown as string, + ports: [{ port: 80, protocol: 'tcp', process: 'nginx', address: '0.0.0.0' }], + }), + ); + expect(summary).not.toBeNull(); + // The fallback arm produced '' which isMeaningfulDiscoveryText rejects, + // so the summary surfaces the placeholder name rather than the raw input. + expect(summary?.serviceName).toBe('Unidentified service'); + expect(summary?.portCount).toBe(1); + // service_type/version/category stay "unknown" so they are dropped too. + expect(summary?.serviceType).toBeUndefined(); + expect(summary?.serviceVersion).toBeUndefined(); + expect(summary?.category).toBeUndefined(); + }, + ); + + it('keeps the raw service_name when it is a meaningful truthy value (truthy-arm regression guard)', () => { + const summary = getDiscoveryIdentifiedSummary(makeDiscovery({ service_name: 'Nginx' })); + expect(summary?.serviceName).toBe('Nginx'); + }); + + it('treats a whitespace-only service_name as truthy at the ||, then trims it to empty (no-trim-before-|| guard)', () => { + // service_name=' ' is truthy, so the || keeps it; the subsequent .trim() + // then empties it and isMeaningfulDiscoveryText rejects the empty result. + // This documents that the trim happens AFTER the ||, not before. + const summary = getDiscoveryIdentifiedSummary( + makeDiscovery({ + service_name: ' ', + ports: [{ port: 80, protocol: 'tcp', process: 'nginx', address: '0.0.0.0' }], + }), + ); + expect(summary?.serviceName).toBe('Unidentified service'); + }); +}); + +// --------------------------------------------------------------------------- +// Branch coverage: line 302 `description: diagnostic || ''` in +// getDiscoverySuggestedURLFallback. Existing tests only pass a truthy 'diag', +// so exercise every falsy shape the public signature accepts. +// --------------------------------------------------------------------------- + +describe('getDiscoverySuggestedURLFallback: (diagnostic || "") fallback arm (branch coverage)', () => { + // Only genuinely falsy values flip the `||` to its fallback arm. (A + // whitespace-only string is truthy — see the dedicated test below.) + it.each<[string, string | null | undefined]>([ + ['undefined', undefined], + ['null', null], + ['empty string', ''], + ])( + 'collapses a %s diagnostic to an empty description while keeping the title', + (_label, diagnostic) => { + expect(getDiscoverySuggestedURLFallback(diagnostic)).toEqual({ + title: 'No suggested URL available', + description: '', + }); + }, + ); + + // Suspected source finding (reported, not fixed): unlike + // normalizeDiscoverySuggestedUrl / cli_access handling, this fallback does + // NOT trim — a whitespace-only diagnostic survives verbatim because it is + // truthy at the ||. + it('forwards a whitespace-only diagnostic verbatim (documents the missing trim)', () => { + expect(getDiscoverySuggestedURLFallback(' ')).toEqual({ + title: 'No suggested URL available', + description: ' ', + }); + }); +}); + +// --------------------------------------------------------------------------- +// Suspected source finding (reported, NOT fixed): +// Line 176 `suggestedUrlReasonTitle: suggestedUrlReason.title || undefined`. +// The falsy arm of this || is unreachable through the public API. Reasoning: +// - Line 176 only executes after `getDiscoveryIdentifiedSummary` has passed +// the `if (!discovery) return null` guard at line 141, so discovery is a +// non-null object when `getDiscoverySuggestedURLReason(discovery)` runs. +// - Inside getDiscoverySuggestedURLReason, the only path returning an empty +// title is `if (!discovery) return { text: '', title: '' }`, which cannot +// fire here. For any non-null discovery, `title` resolves to either +// `${label}: ${detail}` or `label`, and `label` always falls back to the +// non-empty default 'Discovery heuristic' from getDiscoveryURLSuggestionSourceLabel. +// - Therefore suggestedUrlReason.title is always a non-empty (truthy) string +// at line 176, and the `|| undefined` short-circuit arm is dead code. +// No test is written for it because faking it would require constructing an +// impossible state. Documented here and in the report. +// --------------------------------------------------------------------------- diff --git a/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.branchcov0723pm.test.ts b/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.branchcov0723pm.test.ts new file mode 100644 index 0000000000..7848474b2c --- /dev/null +++ b/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.branchcov0723pm.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import { getInfrastructureSourcePickerItemForRouteStep } from '@/utils/infrastructureOnboardingPresentation'; + +// The 6 uncovered branches + the never-called function reported by coverage +// all live in getInfrastructureSourcePickerItemForRouteStep (source lines +// 480-490). That function has exactly three decision points, each with two +// arms: +// 1. `(step || '').trim()` -> `||` truthy arm vs falsy arm +// 2. `if (!normalized)` -> empty (return null) vs non-empty (continue) +// 3. `if (item.routeStep === normalized)` -> match (return) vs miss (loop on) +// This file closes every one of those arms and exercises each of the nine +// route steps the function recognises, plus one it does not. +// +// SOURCE_PICKER_ITEM_ORDER (the only ids the loop can ever return) is, in +// order: pve, pbs, pmg, truenas, vmware, unraid, docker, kubernetes, linux-host. + +const RECOGNISED_STEP_EXPECTATIONS = [ + { step: 'pve', id: 'pve', connectionType: 'pve', label: 'Proxmox VE' }, + { step: 'pbs', id: 'pbs', connectionType: 'pbs', label: 'Proxmox Backup Server' }, + { step: 'pmg', id: 'pmg', connectionType: 'pmg', label: 'Proxmox Mail Gateway' }, + { step: 'truenas', id: 'truenas', connectionType: 'truenas', label: 'TrueNAS SCALE' }, + { step: 'vmware', id: 'vmware', connectionType: 'vmware', label: 'VMware vCenter' }, + { step: 'unraid', id: 'unraid', connectionType: 'agent', label: 'Unraid' }, + { step: 'docker', id: 'docker', connectionType: 'agent', label: 'Docker' }, + { step: 'kubernetes', id: 'kubernetes', connectionType: 'agent', label: 'Kubernetes' }, + { + step: 'linux-host', + id: 'linux-host', + connectionType: 'agent', + label: 'Linux, macOS, Windows host', + }, +] as const; + +describe('infrastructureOnboardingPresentation — branch coverage (branchcov0723pm)', () => { + describe('getInfrastructureSourcePickerItemForRouteStep', () => { + describe('`(step || "")` falsy arm + `!normalized` true arm -> null', () => { + // Each of these is falsy, so `(step || "")` collapses to `""`, `.trim()` + // stays `""`, and `if (!normalized) return null` fires. + it('returns null for null', () => { + expect(getInfrastructureSourcePickerItemForRouteStep(null)).toBeNull(); + }); + + it('returns null for undefined', () => { + expect(getInfrastructureSourcePickerItemForRouteStep(undefined)).toBeNull(); + }); + + it('returns null for the empty string', () => { + expect(getInfrastructureSourcePickerItemForRouteStep('')).toBeNull(); + }); + }); + + describe('`(step || "")` truthy arm + trim empties it + `!normalized` true arm -> null', () => { + // A whitespace-only string is a TRUTHY string, so it takes the `step` + // side of `||` (not the `""` side), then `.trim()` produces `""` and the + // `!normalized` early return fires. This is the only input shape that + // exercises the truthy `||` arm while still landing on the null return. + it('returns null for a whitespace-only string (trim empties it)', () => { + expect(getInfrastructureSourcePickerItemForRouteStep(' ')).toBeNull(); + expect(getInfrastructureSourcePickerItemForRouteStep('\t\n ')).toBeNull(); + }); + }); + + describe('recognised route steps (`routeStep === normalized` match arm)', () => { + // Exercises the truthy `||` arm, the `!normalized` false arm (non-empty, + // so the loop runs), and the TRUE arm of the inner equality for the + // matching iteration. Each case asserts the concrete id / connectionType + // / label of the resolved card so we prove WHICH branch matched rather + // than merely that something came back. + it.each(RECOGNISED_STEP_EXPECTATIONS)( + 'resolves the "$step" route step to the $label card', + ({ step, id, connectionType, label }) => { + const item = getInfrastructureSourcePickerItemForRouteStep(step); + expect(item).not.toBeNull(); + expect(item!.id).toBe(id); + expect(item!.routeStep).toBe(step); + expect(item!.connectionType).toBe(connectionType); + expect(item!.label).toBe(label); + }, + ); + + it('returns the Proxmox VE card verbatim for the first ordered step (pve)', () => { + // pve is the FIRST id in SOURCE_PICKER_ITEM_ORDER, so this confirms the + // loop returns on the very first iteration rather than scanning further. + const item = getInfrastructureSourcePickerItemForRouteStep('pve'); + expect(item).toMatchObject({ + id: 'pve', + connectionType: 'pve', + label: 'Proxmox VE', + sourceStrategy: 'api-agent', + }); + }); + + it('returns the linux-host card for the LAST ordered step', () => { + // linux-host is the LAST id in SOURCE_PICKER_ITEM_ORDER, so the loop + // must scan every earlier id (all missing) before matching — exercising + // the inner `===` FALSE arm repeatedly before the single TRUE arm. + const item = getInfrastructureSourcePickerItemForRouteStep('linux-host'); + expect(item).toMatchObject({ + id: 'linux-host', + connectionType: 'agent', + label: 'Linux, macOS, Windows host', + }); + }); + }); + + describe('whitespace padding (`.trim()` is applied to the input)', () => { + // Still the truthy `||` arm + `!normalized` false arm + match TRUE arm, + // but proves the comparison runs against the TRIMMED value, not the raw + // input. Without the trim, ` pve ` would miss and return null. + it('strips surrounding whitespace before matching a recognised step', () => { + const item = getInfrastructureSourcePickerItemForRouteStep(' pve\t'); + expect(item).not.toBeNull(); + expect(item!.id).toBe('pve'); + expect(item!.routeStep).toBe('pve'); + }); + }); + + describe('unrecognised step (inner `===` FALSE on every iteration -> final null)', () => { + // A valid non-empty string that no item's routeStep equals: the loop runs + // to completion, the inner `if` is never true, and control reaches the + // final `return null`. This is the only path that exercises the FALSE arm + // of the inner equality without a subsequent TRUE arm rescuing it. + it('returns null for a completely unknown step', () => { + expect(getInfrastructureSourcePickerItemForRouteStep('hyper-v')).toBeNull(); + }); + + it('returns null for a recognised connection-type that is not itself a route step', () => { + // 'agent' is a connectionType but is NOT a routeStep id (the host path + // is 'linux-host'), so it must miss every item and return null. + expect(getInfrastructureSourcePickerItemForRouteStep('agent')).toBeNull(); + }); + + it('is case-sensitive: an uppercased recognised step does not match', () => { + // The function trims but never lowercases, and every routeStep is + // lowercase, so 'PVE' !== 'pve' and the lookup misses. + expect(getInfrastructureSourcePickerItemForRouteStep('PVE')).toBeNull(); + expect(getInfrastructureSourcePickerItemForRouteStep('Docker')).toBeNull(); + }); + }); + }); +});