From ae1f9c2ad30ed05627a38e93c536b2d07cc5e7cf Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 25 Jul 2026 01:35:31 +0100 Subject: [PATCH] Cover eight frontend modules no prior coverage pass had reached These are pure modules that already had a spec but had never been through a branch-coverage pass, so their cold arms had never been measured. Counts are uncovered branches, module scoped, existing spec versus existing plus new. - resourceTypeCompat 12 -> 0, the legacy type-alias paths: unrecognised type strings, casing variants and the fallback identity mapping. - summaryCardInteraction 8 -> 0 and diskDetailPresentation 6 -> 0. - commercialBillingModel: asUnlimitedLimit, buildHostedCommercialPlanModel and buildHostedCommercialUsageModel had never been called by any test. All three are now covered across unlimited and numeric limits and usage at, below and above the limit. - alertDetailPresentation 4 -> 0, resourceTypePresentation 2 -> 0, unifiedResourceTableStateModel 4 -> 0, assistantSlashCommands 4 -> 1. No source file is modified. Adversarial review returned no rejects but found the heaviest padding of this run, around twenty-five near-duplicate cases plus one assertion that mirrored the source's own date formatting. Every one was removed or replaced with a concrete literal, and each module's coverage was re-measured as identical afterwards, proving the removed cases carried nothing. PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT=test-only branch coverage, no source or contract change --- ...stantSlashCommands.branchcov0724pm.test.ts | 96 ++++++ ...rceTableStateModel.branchcov0724pm.test.ts | 173 +++++++++++ ...aryCardInteraction.branchcov0724pm.test.ts | 254 +++++++++++++++ ...DetailPresentation.branchcov0724pm.test.ts | 132 ++++++++ ...DetailPresentation.branchcov0724pm.test.ts | 140 +++++++++ ...ercialBillingModel.branchcov0724pm.test.ts | 290 ++++++++++++++++++ ...resourceTypeCompat.branchcov0724pm.test.ts | 38 +++ ...ceTypePresentation.branchcov0724pm.test.ts | 125 ++++++++ 8 files changed, 1248 insertions(+) create mode 100644 frontend-modern/src/components/AI/Chat/__tests__/assistantSlashCommands.branchcov0724pm.test.ts create mode 100644 frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.branchcov0724pm.test.ts create mode 100644 frontend-modern/src/components/shared/__tests__/summaryCardInteraction.branchcov0724pm.test.ts create mode 100644 frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.branchcov0724pm.test.ts create mode 100644 frontend-modern/src/utils/__tests__/alertDetailPresentation.branchcov0724pm.test.ts create mode 100644 frontend-modern/src/utils/__tests__/commercialBillingModel.branchcov0724pm.test.ts create mode 100644 frontend-modern/src/utils/__tests__/resourceTypeCompat.branchcov0724pm.test.ts create mode 100644 frontend-modern/src/utils/__tests__/resourceTypePresentation.branchcov0724pm.test.ts diff --git a/frontend-modern/src/components/AI/Chat/__tests__/assistantSlashCommands.branchcov0724pm.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/assistantSlashCommands.branchcov0724pm.test.ts new file mode 100644 index 0000000000..c7820d45c7 --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/__tests__/assistantSlashCommands.branchcov0724pm.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { filterAssistantSlashCommands } from '../assistantSlashCommands'; + +// Branch-coverage companion to assistantSlashCommands.test.ts. Each test below +// drives a specific cold arm uncovered by the happy-path spec and asserts +// against concrete outputs rather than truthiness. +// +// Cold arms identified by scoped v8 coverage (coverage-final.json): +// L66 - `import.meta.env.DEV || import.meta.env.MODE === 'test'`: the +// right operand (`MODE === 'test'`) is never evaluated because DEV is +// truthy in the test environment. Forcing DEV falsy exercises it. +// L275 - commandMatchScore `return 2`: command name *contains* the query but +// does not *start* with it (and no alias starts with it). +// L276 - commandMatchScore `return 3`: an alias *contains* the query but no +// alias/name *starts* with it and the name does not contain it. +// +// L214 (`if (!token) return null;`) is intentionally NOT exercised here: the +// regex `^(\S+)...` guarantees `match[1]` is a non-empty string, so `!token` +// is always false and the `return null` arm is provably unreachable through +// the public API. See GLM_REPORT_fe3-slashcmd.md for details. + +const PROD_COMMAND_NAMES_WITHOUT_DEV = [ + 'new', + 'sessions', + 'queue', + 'compact', + 'fork', + 'undo', + 'redo', + 'models', + 'providers', + 'status', + 'copy', + 'export', + 'help', +]; + +describe('assistantSlashCommands branch coverage', () => { + describe('DEV-gated dev-command surface (L66 logical-OR right operand)', () => { + const originalDev = import.meta.env.DEV; + const originalMode = import.meta.env.MODE; + + afterEach(() => { + vi.stubEnv('DEV', originalDev); + vi.stubEnv('MODE', originalMode); + }); + + it('admits dev fixture commands when DEV is off but MODE is test (right operand true)', () => { + vi.stubEnv('DEV', false); + vi.stubEnv('MODE', 'test'); + const names = filterAssistantSlashCommands('', undefined, {}).map((command) => command.name); + expect(names).toContain('fixture'); + // Sanity: the rest of the production surface is still present and ordered. + expect(names).toEqual([...PROD_COMMAND_NAMES_WITHOUT_DEV, 'fixture']); + }); + + it('hides dev fixture commands when both DEV and MODE deny the dev surface (right operand false)', () => { + vi.stubEnv('DEV', false); + vi.stubEnv('MODE', 'production'); + const names = filterAssistantSlashCommands('', undefined, {}).map((command) => command.name); + expect(names).not.toContain('fixture'); + expect(names).toEqual(PROD_COMMAND_NAMES_WITHOUT_DEV); + }); + }); + + describe('commandMatchScore name-substring ranking (L275 `return 2`)', () => { + it('ranks a name-substring match ahead of a description-substring match', () => { + // Two commands match "ew", so the sort comparator actually invokes + // commandMatchScore: "new" contains "ew" in its name (name-substring, + // score 2) while "fork" only matches via its description ("...into a new + // copy", score 4). New must therefore sort first, driving the `return 2` + // arm that a single-match query never reaches (the comparator is not + // called on a one-element array). + expect(filterAssistantSlashCommands('ew').map((command) => command.name)).toEqual([ + 'new', + 'fork', + ]); + }); + }); + + describe('commandMatchScore alias-substring ranking (L276 `return 3`)', () => { + it('ranks an alias-substring match ahead of description-substring matches', () => { + // Three commands match "ear", so the comparator scores each. "new" has + // alias "clear" which contains "ear" but neither the name nor any alias + // starts with "ear" and the name does not contain it -> alias-substring + // arm (score 3). "sessions" and "models" match only via their + // descriptions ("Search..."), scoring 4. New sorts first, exercising the + // `return 3` arm. + expect(filterAssistantSlashCommands('ear').map((command) => command.name)).toEqual([ + 'new', + 'sessions', + 'models', + ]); + }); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.branchcov0724pm.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.branchcov0724pm.test.ts new file mode 100644 index 0000000000..b224899b81 --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.branchcov0724pm.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { + UNIFIED_RESOURCE_TABLE_DEFAULT_LAYOUT_WIDTH, + buildHostRowIndexById, + buildHostTableItems, + getHostRevealTargetIndex, + getHostSpacerHeights, + getNextUnifiedResourceTableSortState, + normalizeUnifiedResourceTableLayoutWidth, + shouldShowClusterGroupTypeLabel, + sortServiceResources, +} from '@/components/Infrastructure/unifiedResourceTableStateModel'; + +/** + * Branch-coverage additions for unifiedResourceTableStateModel.ts targeting the + * arms left cold by the existing happy-path spec + * (unifiedResourceTableStateModel.test.ts). + * + * Each test drives a specific measured-cold branch (default fallback, map miss, + * nullish coalescing RHS, non-windowed early return, ternary consequent) and + * asserts on the concrete returned value — never a tautology. + * + * The makeResource fixture mirrors the existing spec's builder so import style + * and field shape stay consistent. + */ + +const makeResource = (id: string, overrides: Partial = {}): Resource => + ({ + id, + type: 'agent', + name: `name-${id}`, + displayName: `Display ${id}`, + platformId: `platform-${id}`, + platformType: 'proxmox-pve', + sourceType: 'api', + status: 'online', + lastSeen: Date.now(), + platformData: { sources: ['proxmox'] }, + ...overrides, + }) as Resource; + +describe('normalizeUnifiedResourceTableLayoutWidth — default-fallback arm', () => { + // Drives branch 6 arm 0 (lines 69-71): the trailing + // `return UNIFIED_RESOURCE_TABLE_DEFAULT_LAYOUT_WIDTH;` reached only when the + // width argument is invalid AND the fallback is also invalid. The existing + // spec only exercises a valid width or an invalid width with a valid + // fallback, so the final default return is never reached. + it('falls back to the default constant when the width is absent and the fallback is NaN', () => { + expect(normalizeUnifiedResourceTableLayoutWidth(null, Number.NaN)).toBe( + UNIFIED_RESOURCE_TABLE_DEFAULT_LAYOUT_WIDTH, + ); + }); + + it('falls back to the default constant when the width is absent and the fallback is 0', () => { + expect(normalizeUnifiedResourceTableLayoutWidth(null, 0)).toBe( + UNIFIED_RESOURCE_TABLE_DEFAULT_LAYOUT_WIDTH, + ); + }); +}); + +describe('getHostRevealTargetIndex — map-miss returns null', () => { + // Drives branch 31 arm 0 (line 150: `rowIndexById.get(targetId) ?? null`): + // the LHS of `??` is undefined because targetId is absent from the map. The + // existing spec always resolves a present id, so the nullish-coalescing RHS + // is never taken. + const items = buildHostTableItems( + [{ cluster: 'cluster-a', resources: [makeResource('a'), makeResource('b')] }], + 'grouped', + ); + const rowIndexById = buildHostRowIndexById(items); + + it('returns null when expandedResourceId is not present in the row index', () => { + // rowIndexById only holds 'a' (1) and 'b' (2); 'missing' is absent. + expect(getHostRevealTargetIndex(rowIndexById, 'missing', null)).toBeNull(); + }); + + it('returns null when revealedResourceId is the chosen target but absent from the row index', () => { + // expandedResourceId is null so revealedResourceId 'ghost' wins the chain, + // then misses the map lookup. + expect(getHostRevealTargetIndex(rowIndexById, null, null, 'ghost')).toBeNull(); + }); +}); + +describe('shouldShowClusterGroupTypeLabel — nullish clusterLabel arm', () => { + // Drives branch 35 arm 0 (line 166: `(clusterLabel ?? '')`): the RHS of `??` + // is only evaluated when clusterLabel is null/undefined. The existing spec + // passes only concrete strings, so the coalescing RHS stays cold. + it('returns false for a null clusterLabel without throwing', () => { + expect(shouldShowClusterGroupTypeLabel(null)).toBe(false); + }); + + it('returns false for an undefined clusterLabel without throwing', () => { + expect(shouldShowClusterGroupTypeLabel(undefined)).toBe(false); + }); +}); + +describe('getHostSpacerHeights — non-windowed early-return arm', () => { + // Drives branch 38 arm 0 (lines 177-179): the `if (!isWindowed)` consequent + // that returns `{ top: 0, bottom: 0 }`. The existing spec always calls with + // isWindowed = true, so the zero-spacer early return is never produced. + it('returns zero spacers regardless of window bounds when windowing is disabled', () => { + const result = getHostSpacerHeights(100, 5, 20, false); + expect(result).toEqual({ top: 0, bottom: 0 }); + }); + + it('returns zero spacers even when a custom row height is supplied', () => { + // Discriminator: a custom estimatedRowHeight (40) would otherwise scale the + // top spacer to 5 * 40 = 200; the early return suppresses it entirely. + const result = getHostSpacerHeights(100, 5, 20, false, 40); + expect(result).toEqual({ top: 0, bottom: 0 }); + expect(result.top).not.toBe(200); + }); +}); + +describe('getNextUnifiedResourceTableSortState — asc default for name/source', () => { + // Drives branch 43 arm 0 (line 204: the `'asc'` consequent of + // `nextKey === 'name' || nextKey === 'source' ? 'asc' : 'desc'`). The existing + // spec only enters the function with currentKey === nextKey (so it never + // reaches line 204) or with nextKey 'cpu' (which takes the 'desc' alternate). + it('initializes a name sort to ascending when switching from a different key', () => { + expect(getNextUnifiedResourceTableSortState('default', 'asc', 'name')).toEqual({ + key: 'name', + direction: 'asc', + }); + }); + + it('initializes a source sort to ascending when switching from a different key', () => { + // Also drives the second operand of the `||` (`nextKey === 'source'`) true. + expect(getNextUnifiedResourceTableSortState('cpu', 'desc', 'source')).toEqual({ + key: 'source', + direction: 'asc', + }); + }); +}); + +describe('sortServiceResources — uncovered exported function', () => { + // Drives fn 15 (sortServiceResources, line 217): the only exported function + // the existing spec never invokes. It must (a) filter to the requested + // service type and (b) delegate to sortResources with 'default'/'asc', which + // ranks online resources first then by display name. Assertions are made on + // the concrete id sequence so they cannot pass against an unfiltered or + // unsorted list. + const services: Resource[] = [ + makeResource('zebra', { type: 'pbs', displayName: 'Zeta', status: 'offline' }), + makeResource('alpha', { type: 'pbs', displayName: 'Alpha', status: 'online' }), + makeResource('mango', { type: 'pbs', displayName: 'Mango', status: 'online' }), + makeResource('pmg-only', { type: 'pmg', displayName: 'Should Be Filtered', status: 'online' }), + makeResource('agent-only', { type: 'agent', displayName: 'Also Filtered', status: 'online' }), + ]; + + it('returns only pbs resources, online-first then by display name', () => { + const result = sortServiceResources(services, 'pbs'); + expect(result.map((r) => r.id)).toEqual(['alpha', 'mango', 'zebra']); + expect(result.every((r) => r.type === 'pbs')).toBe(true); + }); + + it('returns only pmg resources when type is pmg', () => { + const result = sortServiceResources(services, 'pmg'); + expect(result.map((r) => r.id)).toEqual(['pmg-only']); + expect(result.every((r) => r.type === 'pmg')).toBe(true); + }); + + it('returns an empty array when the input list is empty', () => { + expect(sortServiceResources([], 'pbs')).toEqual([]); + }); + + it('does not mutate the input array', () => { + const snapshot = services.map((r) => r.id); + sortServiceResources(services, 'pbs'); + expect(services.map((r) => r.id)).toEqual(snapshot); + }); +}); diff --git a/frontend-modern/src/components/shared/__tests__/summaryCardInteraction.branchcov0724pm.test.ts b/frontend-modern/src/components/shared/__tests__/summaryCardInteraction.branchcov0724pm.test.ts new file mode 100644 index 0000000000..6be35c0ec1 --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/summaryCardInteraction.branchcov0724pm.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest'; + +import { + filterSummarySeriesByGroupScope, + isSummarySeriesInGroupScope, + normalizeSummarySeriesGroupScope, + resolveSummaryGroupMemberInteractionState, + resolveSummaryScopeState, + type SummarySeriesGroupScope, +} from '@/components/shared/summaryCardInteraction'; + +// Branch-coverage tests for the UNCOVERED guard arms of +// summaryCardInteraction.ts. The existing spec (summaryCardInteraction.test.ts) +// exercises the precedence/happy paths; this file targets the null/empty/ +// malformed-input arms that never fire there. A scoped v8 coverage run against +// the existing spec alone leaves exactly 8 of 56 branch arms cold (0 functions): +// b6 normalizeSummarySeriesGroupScope: `scope.seriesIds ?? []` nullish-fallback +// b8 normalizeSummarySeriesGroupScope: valid id but `seriesIds.length === 0` -> null +// b10 normalizeSummarySeriesGroupScope: `typeof scope.label !== 'string'` else arm +// b11 normalizeSummarySeriesGroupScope: `label || null` right operand (falsy label) +// b14 isSummarySeriesInGroupScope: valid scope but empty seriesId -> false +// b19 resolveSummaryGroupMemberInteractionState: empty seriesId -> 'default' +// b37 resolveSummaryScopeState: hoveredGroupScope truthy -> group/preview return +// b43 filterSummarySeriesByGroupScope: null/invalid scope -> `[...series]` +// Every asserted value below is hand-computed against the source in +// src/components/shared/summaryCardInteraction.ts — no snapshots, no +// constant-equals-itself tautologies. Deliberately-malformed inputs (missing +// required `seriesIds`, non-string `label`) are cast via `as unknown as`. + +const validScope: SummarySeriesGroupScope = { + id: 'cluster-a', + label: 'Cluster A', + seriesIds: ['alpha', 'beta'], +}; + +describe('summaryCardInteraction.branchcov0724pm', () => { + // ------------------------------------------------------------------------- + // normalizeSummarySeriesGroupScope — the 4 normalization cold arms live here. + // The existing spec always passes a fully-populated scope with at least one + // valid series id and a string label, so every defensive branch is cold. + // ------------------------------------------------------------------------- + describe('normalizeSummarySeriesGroupScope', () => { + it('returns null when seriesIds is nullish, exercising the `?? []` fallback (b6) and the empty -> null path (b8)', () => { + // `scope.seriesIds` is undefined -> the `?? []` right operand is taken + // (b6). The resulting list is empty, so `seriesIds.length === 0` is true + // (b8) and the function returns null. seriesIds is a required field on the + // type, so the malformed object is cast. + const malformed = { id: 'cluster-a' } as unknown as SummarySeriesGroupScope; + expect(normalizeSummarySeriesGroupScope(malformed)).toBeNull(); + }); + + it('returns null when seriesIds is present but empty, proving b8 fires independently of the `?? []` fallback', () => { + // seriesIds is a real (empty) array -> `?? []` NOT taken (b6 stays cold + // here), but `seriesIds.length === 0` is still true -> b8 -> null. + expect(normalizeSummarySeriesGroupScope({ id: 'cluster-a', seriesIds: [] })).toBeNull(); + }); + + it('coerces a non-string label to "" then null, covering the ternary else arm (b10) and the `|| null` arm (b11)', () => { + // scope.label omitted -> `typeof undefined === 'string'` is false (b10 + // else arm) -> label = '' -> `'' || null` takes the right operand (b11) + // -> returned label is null. Asserts the full observable shape, not just + // the label slot. + expect(normalizeSummarySeriesGroupScope({ id: 'cluster-a', seriesIds: ['alpha'] })).toEqual({ + id: 'cluster-a', + label: null, + seriesIds: ['alpha'], + }); + }); + + it('yields label: null for a whitespace-only string label that trims to empty', () => { + // label IS a string (ternary then-arm, already covered) but trims to '' + // -> `'' || null` right operand (b11) -> label: null. + expect( + normalizeSummarySeriesGroupScope({ + id: 'cluster-a', + label: ' ', + seriesIds: ['alpha'], + }), + ).toEqual({ + id: 'cluster-a', + label: null, + seriesIds: ['alpha'], + }); + }); + + it('trims and preserves a real label string (then + left-operand path, regression guard for the b10/b11 neighbours)', () => { + // label is a non-empty string -> then-arm trims it -> `label || null` + // left operand -> returned verbatim. Locks the non-cold neighbour so the + // b10/b11 coverage cannot be silently regarded by a refactor. Asserting + // the full shape avoids a null-deref (the helper returns ... | null). + expect( + normalizeSummarySeriesGroupScope({ + id: 'cluster-a', + label: ' Cluster A ', + seriesIds: ['alpha'], + }), + ).toEqual({ + id: 'cluster-a', + label: 'Cluster A', + seriesIds: ['alpha'], + }); + }); + }); + + // ------------------------------------------------------------------------- + // isSummarySeriesInGroupScope — the `!normalizedSeriesId` short-circuit. + // The existing spec only ever calls this with a non-empty seriesId. + // ------------------------------------------------------------------------- + describe('isSummarySeriesInGroupScope', () => { + it('returns false when the scope is valid but the seriesId is empty/blank (b14)', () => { + // normalizedScope is valid (truthy) so `!normalizedScope` is false; the + // `||` evaluates `!normalizedSeriesId` which is true for '' / ' ' / + // null -> returns false without reaching .includes. + expect(isSummarySeriesInGroupScope(validScope, '')).toBe(false); + expect(isSummarySeriesInGroupScope(validScope, ' ')).toBe(false); + expect(isSummarySeriesInGroupScope(validScope, null)).toBe(false); + expect(isSummarySeriesInGroupScope(validScope, undefined)).toBe(false); + }); + + it('still returns true for a real match so the b14 guard is proven not to over-reject', () => { + expect(isSummarySeriesInGroupScope(validScope, 'alpha')).toBe(true); + // 'alpha' with surrounding whitespace still matches after trim. + expect(isSummarySeriesInGroupScope(validScope, ' alpha ')).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // resolveSummaryGroupMemberInteractionState — the empty-seriesId early + // return. The existing spec always passes a concrete seriesId. + // ------------------------------------------------------------------------- + describe('resolveSummaryGroupMemberInteractionState', () => { + it("returns 'default' when seriesId is missing/blank, before consulting any group scope (b19)", () => { + // The very first guard `if (!normalizedSeriesId) return 'default';` fires + // for '' / ' ' / undefined / null. + expect(resolveSummaryGroupMemberInteractionState({})).toBe('default'); + expect(resolveSummaryGroupMemberInteractionState({ seriesId: '' })).toBe('default'); + expect(resolveSummaryGroupMemberInteractionState({ seriesId: ' ' })).toBe('default'); + expect(resolveSummaryGroupMemberInteractionState({ seriesId: null })).toBe('default'); + }); + + it("returns 'default' for an empty seriesId even when a hoveredGroupScope is supplied, proving the guard precedes the scope checks", () => { + // A valid hoveredGroupScope would otherwise yield 'preview' for an in-scope + // id; the empty-seriesId guard must short-circuit first. + expect( + resolveSummaryGroupMemberInteractionState({ + seriesId: '', + hoveredGroupScope: validScope, + focusedGroupScope: validScope, + }), + ).toBe('default'); + }); + }); + + // ------------------------------------------------------------------------- + // resolveSummaryScopeState — the hoveredGroupScope truthy arm. Every existing + // call either resolves an active entity series first, or reaches this point + // only with hoveredGroupScope === null/undefined (focused path). + // ------------------------------------------------------------------------- + describe('resolveSummaryScopeState', () => { + it('returns a group/preview scope state when only hoveredGroupScope is supplied (b37)', () => { + // No series ids and no explicit groupScope option -> groupScope resolves + // to hoveredGroupScope via resolveSummaryGroupScope; all three series + // guards are skipped (empty ids) -> hoveredGroupScope is truthy at + // line 150 -> kind 'group', source 'preview'. + expect(resolveSummaryScopeState({ hoveredGroupScope: validScope })).toEqual({ + groupScope: validScope, + kind: 'group', + seriesId: null, + source: 'preview', + }); + }); + + it('falls through to the hoveredGroupScope group return even when a hovered series id is filtered out by that same scope (b37 via the scope-mismatch skip)', () => { + // groupScope resolves to hoveredGroupScope (alpha,beta). hoveredSeriesId + // 'gamma' is truthy but NOT in the resolved scope -> the entity condition + // is false -> skipped -> reaches the hoveredGroupScope check -> b37. + expect( + resolveSummaryScopeState({ + hoveredSeriesId: 'gamma', + hoveredGroupScope: validScope, + }), + ).toEqual({ + groupScope: validScope, + kind: 'group', + seriesId: null, + source: 'preview', + }); + }); + + it('prefers a valid chartHoveredSeriesId over the hoveredGroupScope group return, confirming b37 is precedence-gated (regression guard)', () => { + // chartHoveredSeriesId 'alpha' is in the resolved scope -> entity/preview + // return wins; the hoveredGroupScope group arm (b37) is NOT taken here. + expect( + resolveSummaryScopeState({ + chartHoveredSeriesId: 'alpha', + hoveredGroupScope: validScope, + }), + ).toEqual({ + groupScope: validScope, + kind: 'entity', + seriesId: 'alpha', + source: 'preview', + }); + }); + }); + + // ------------------------------------------------------------------------- + // filterSummarySeriesByGroupScope — the no-scope -> shallow-copy arm. The + // existing spec only calls this with a valid scope (the .filter path). + // ------------------------------------------------------------------------- + describe('filterSummarySeriesByGroupScope', () => { + it('returns a copy of every series when the scope is null/undefined (b43)', () => { + const series = [{ id: 'alpha' }, { id: 'beta' }, { id: 'gamma' }]; + // null scope -> normalizedScope null -> `return [...series]` (b43). + const fromNull = filterSummarySeriesByGroupScope(series, null); + expect(fromNull.map((s) => s.id)).toEqual(['alpha', 'beta', 'gamma']); + // Must be a NEW array reference (shallow copy), not the input identity. + expect(fromNull).not.toBe(series); + expect(fromNull).toEqual(series); + + const fromUndefined = filterSummarySeriesByGroupScope(series, undefined); + expect(fromUndefined.map((s) => s.id)).toEqual(['alpha', 'beta', 'gamma']); + expect(fromUndefined).not.toBe(series); + }); + + it('returns a copy of every series when the scope normalizes to null because its id is blank (b43 via invalid id)', () => { + const series = [{ id: 'alpha' }]; + // id '' -> normalizeSummarySeriesGroupScope returns null -> b43. + const result = filterSummarySeriesByGroupScope(series, { + id: '', + seriesIds: ['alpha'], + }); + expect(result.map((s) => s.id)).toEqual(['alpha']); + expect(result).not.toBe(series); + }); + + it('returns a copy of every series when the scope normalizes to null because its seriesIds are empty (b43 via empty list)', () => { + const series = [{ id: 'alpha' }, { id: 'beta' }]; + // valid id but empty seriesIds -> normalize returns null -> b43. + const result = filterSummarySeriesByGroupScope(series, { + id: 'cluster-a', + seriesIds: [], + }); + expect(result.map((s) => s.id)).toEqual(['alpha', 'beta']); + expect(result).not.toBe(series); + }); + + it('still filters down to in-scope series for a valid scope so the b43 copy path is proven not to over-include', () => { + const series = [{ id: 'alpha' }, { id: 'beta' }, { id: 'gamma' }]; + const result = filterSummarySeriesByGroupScope(series, validScope); + expect(result.map((s) => s.id)).toEqual(['alpha', 'beta']); + }); + }); +}); diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.branchcov0724pm.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.branchcov0724pm.test.ts new file mode 100644 index 0000000000..46b10a037f --- /dev/null +++ b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.branchcov0724pm.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import type { PhysicalDiskPresentationData } from '@/features/storageBackups/diskPresentation'; +import { + getDiskDetailAttributeCards, + getDiskDetailHistoryCharts, + getLinkedDiskTemperatureTextClass, +} from '@/features/storageBackups/diskDetailPresentation'; + +function makeDiskData( + overrides: Partial = {}, +): PhysicalDiskPresentationData { + return { + node: '', + instance: '', + devPath: '', + model: '', + serial: '', + wwn: '', + size: 0, + health: 'UNKNOWN', + riskReasons: [], + wearout: -1, + type: '', + temperature: 0, + rpm: 0, + used: '', + ...overrides, + }; +} + +describe('diskDetailPresentation.branchcov0724pm', () => { + describe('getLinkedDiskTemperatureTextClass non-positive / non-finite guard (L20)', () => { + it('returns text-muted for NaN temperature', () => { + expect(getLinkedDiskTemperatureTextClass(NaN)).toBe('text-muted'); + }); + + it('returns text-muted for zero temperature', () => { + expect(getLinkedDiskTemperatureTextClass(0)).toBe('text-muted'); + }); + + it('short-circuits before consulting thresholds when the guard trips', () => { + // 0 would be flagged critical by these inverted thresholds, but the + // guard returns text-muted without ever calling getMetricSeverity. + expect(getLinkedDiskTemperatureTextClass(0, { warning: -10, critical: -5 })).toBe( + 'text-muted', + ); + }); + }); + + describe('getDiskDetailAttributeCards empty-collection guard (L86)', () => { + it('returns an empty array when smartAttributes is absent', () => { + expect(getDiskDetailAttributeCards(makeDiskData({ type: 'hdd' }))).toEqual([]); + }); + }); + + describe('getDiskDetailAttributeCards SATA offlineUncorrectable arm (L129)', () => { + it('emits an Offline Uncorrectable card marked ok when the count is zero', () => { + const cards = getDiskDetailAttributeCards( + makeDiskData({ type: 'hdd', smartAttributes: { offlineUncorrectable: 0 } }), + ); + expect(cards).toContainEqual({ + label: 'Offline Uncorrectable', + value: '0', + ok: true, + }); + }); + + it('emits an Offline Uncorrectable card marked not-ok when the count is non-zero', () => { + const cards = getDiskDetailAttributeCards( + makeDiskData({ type: 'hdd', smartAttributes: { offlineUncorrectable: 7 } }), + ); + expect(cards).toContainEqual({ + label: 'Offline Uncorrectable', + value: '7', + ok: false, + }); + }); + + it('omits the Offline Uncorrectable card for an NVMe disk even when the field is set', () => { + const cards = getDiskDetailAttributeCards( + makeDiskData({ type: 'nvme', smartAttributes: { offlineUncorrectable: 7 } }), + ); + expect(cards.find((c) => c.label === 'Offline Uncorrectable')).toBeUndefined(); + }); + }); + + describe('getDiskDetailAttributeCards SATA udmaCrcErrors arm (L136)', () => { + it('emits a CRC Errors card marked ok when the count is zero', () => { + const cards = getDiskDetailAttributeCards( + makeDiskData({ type: 'hdd', smartAttributes: { udmaCrcErrors: 0 } }), + ); + expect(cards).toContainEqual({ label: 'CRC Errors', value: '0', ok: true }); + }); + + it('emits a CRC Errors card marked not-ok when the count is non-zero', () => { + const cards = getDiskDetailAttributeCards( + makeDiskData({ type: 'hdd', smartAttributes: { udmaCrcErrors: 42 } }), + ); + expect(cards).toContainEqual({ label: 'CRC Errors', value: '42', ok: false }); + }); + }); + + describe('getDiskDetailHistoryCharts SATA reallocated-sectors arm (L192)', () => { + it('emits only the smart_reallocated_sectors chart for a SATA disk with the field set', () => { + const charts = getDiskDetailHistoryCharts( + makeDiskData({ + type: 'hdd', + temperature: 0, + smartAttributes: { reallocatedSectors: 3 }, + }), + ); + expect(charts.map((c) => c.metric)).toEqual(['smart_reallocated_sectors']); + expect(charts[0]).toMatchObject({ + metric: 'smart_reallocated_sectors', + label: 'Reallocated Sectors', + unit: 'sectors', + color: '#f59e0b', + }); + }); + + it('omits the reallocated-sectors chart for an NVMe disk even when the field is set', () => { + const charts = getDiskDetailHistoryCharts( + makeDiskData({ type: 'nvme', smartAttributes: { reallocatedSectors: 3 } }), + ); + expect(charts.find((c) => c.metric === 'smart_reallocated_sectors')).toBeUndefined(); + }); + + it('returns an empty chart list when smartAttributes is absent', () => { + expect(getDiskDetailHistoryCharts(makeDiskData({ type: 'hdd' }))).toEqual([]); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/alertDetailPresentation.branchcov0724pm.test.ts b/frontend-modern/src/utils/__tests__/alertDetailPresentation.branchcov0724pm.test.ts new file mode 100644 index 0000000000..e0c568abae --- /dev/null +++ b/frontend-modern/src/utils/__tests__/alertDetailPresentation.branchcov0724pm.test.ts @@ -0,0 +1,140 @@ +/** + * Branch-coverage tests for alertDetailPresentation.ts (0724pm batch). + * + * Measured gap against the module's own existing spec + * (alertDetailPresentation.test.ts): 4 of 28 branches uncovered, + * 0 uncovered functions. This file targets exactly those 4 cold arms: + * + * - formatPlatformAlertResourceType: `?? type` label fallback (line 87) + * reached when a provider's label table has no entry for the given + * ResourceType (a realistic cold arm — the backend emitting a type + * the provider's presentation table does not cover). + * - formatPlatformAlertEntityType: the `normalized === 'host'` arm (line 92) + * existing tests cover 'vm' / 'datastore' but never 'host'. + * - formatPlatformAlertStartedAt: the valid-date fall-through to toLocaleString + * (line 102 -> 110); existing tests only feed absent / pre-2000 values. + * - formatPlatformAlertDetailDateTime: the valid-date fall-through to + * toLocaleString (line 117 -> 126); existing tests only feed absent / + * NaN / pre-2000 values. + * + * Does NOT modify the source module or any existing test file. + */ +import { beforeAll, afterAll, describe, expect, it } from 'vitest'; + +import { + formatPlatformAlertDetailDateTime, + formatPlatformAlertEntityType, + formatPlatformAlertResourceType, + formatPlatformAlertStartedAt, +} from '@/utils/alertDetailPresentation'; + +/* ------------------------------------------------------------------ * + * formatPlatformAlertResourceType — `?? type` fallback (line 87) + * + * RESOURCE_TYPE_LABELS[provider] is a Partial>. + * A provider alert whose resource type the table does not list falls + * through to the raw type string. This arm was never exercised by the + * existing suite, which only passes in-table (provider, type) pairs. + * ------------------------------------------------------------------ */ + +describe('formatPlatformAlertResourceType - unmapped resource-type label fallback', () => { + it('returns the raw type when the docker label table has no entry for it', () => { + // 'pbs' is a valid ResourceType but is absent from the docker label + // table (docker only labels containers/services/images/etc.). + expect(formatPlatformAlertResourceType('pbs', 'docker')).toBe('pbs'); + }); +}); + +/* ------------------------------------------------------------------ * + * formatPlatformAlertEntityType — `normalized === 'host'` arm (line 92) + * + * Existing tests cover 'vm', 'datastore', the generic title-case arm, and + * the empty-string placeholder, but never the dedicated 'host' canonical + * spelling. + * ------------------------------------------------------------------ */ + +describe('formatPlatformAlertEntityType - host canonicalization', () => { + it('canonicalizes a lower-case host token to the Host label', () => { + expect(formatPlatformAlertEntityType('host')).toBe('Host'); + }); + + it('canonicalizes host regardless of surrounding whitespace or casing', () => { + // value.trim().toLowerCase() normalizes before the comparison, so a + // whitespace-padded / mixed-case host still hits the canonical arm + // rather than the generic title-case arm. + expect(formatPlatformAlertEntityType(' HoSt ')).toBe('Host'); + }); +}); + +/* ------------------------------------------------------------------ * + * Date formatters — valid-timestamp happy paths + * + * Both functions hardcode their Intl options and accept no timeZone / + * locale overrides, so output is locale- and timezone-dependent. To stay + * deterministic AND non-tautological we: + * 1. pin process.env.TZ = 'UTC' so the rendered day is fixed, then + * restore it afterwards; + * 2. assert the exact locale-rendered string by mirroring the same + * option set the function hardcodes (matches the runtime default + * locale the function itself uses); + * 3. assert a structural, locale-independent fact that distinguishes the + * two formats — startedAt OMITS the year, detailDateTime INCLUDES it. + * A swapped-options regression would be caught here. + * + * The existing suite only covers the placeholder / fallback arms of these + * functions (absent value, NaN date, pre-2000 date); the post-2000 valid + * timestamp fall-through was never reached. + * ------------------------------------------------------------------ */ + +describe('platform alert date formatting - present, post-2000 timestamps', () => { + const previousTz = process.env.TZ; + + beforeAll(() => { + process.env.TZ = 'UTC'; + }); + + afterAll(() => { + if (previousTz === undefined) delete process.env.TZ; + else process.env.TZ = previousTz; + }); + + it('formatPlatformAlertStartedAt renders a valid timestamp, omitting the year (table format)', () => { + const value = '2024-03-15T14:30:00Z'; + const result = formatPlatformAlertStartedAt(value); + + // Happy path, not the placeholder and not a raw passthrough. + expect(result).not.toBe('-'); + expect(result).not.toBe(value); + + // The compact table format has no year — the structural difference + // from the detail format and from the raw ISO string. + expect(result).not.toMatch(/2024/); + + // Under TZ=UTC the day-of-month renders as 15 in every locale. + expect(result).toMatch(/15/); + + // Mirrors the exact option set the function hardcodes; pinned to the + // concrete en-US rendering under TZ=UTC (the structural regex checks + // above carry the locale-independent verification). + expect(result).toBe('Mar 15, 02:30 PM'); + }); + + it('formatPlatformAlertDetailDateTime renders a valid timestamp, including the year (detail format)', () => { + const value = '2024-03-15T14:30:00Z'; + const result = formatPlatformAlertDetailDateTime(value); + + expect(result).not.toBe('-'); + expect(result).not.toBe(value); + + // The detail format includes the year — distinguishing it from the + // table format and proving the two option sets were not swapped. + expect(result).toMatch(/2024/); + + // Under TZ=UTC the day-of-month renders as 15 in every locale. + expect(result).toMatch(/15/); + + // Concrete en-US rendering under TZ=UTC; structural checks above carry the + // locale-independent verification (presence of the year + day 15). + expect(result).toBe('Mar 15, 2024, 02:30 PM'); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/commercialBillingModel.branchcov0724pm.test.ts b/frontend-modern/src/utils/__tests__/commercialBillingModel.branchcov0724pm.test.ts new file mode 100644 index 0000000000..0302a8c6af --- /dev/null +++ b/frontend-modern/src/utils/__tests__/commercialBillingModel.branchcov0724pm.test.ts @@ -0,0 +1,290 @@ +/** + * Branch-coverage tests for commercialBillingModel.ts (0724pm batch). + * + * Measured baseline (existing commercialBillingModel.test.ts only): + * functions: 2/5 covered + * - asUnlimitedLimit (line 50) NEVER called + * - buildHostedCommercialPlanModel (line 120) NEVER called + * - buildHostedCommercialUsageModel (line 153) NEVER called + * branches : 3/5 arms covered + * - cold: `input.licensedEmail || 'Not available'` (line 62) false arm + * - cold: `input.planTerms ? [...] : []` (line 70) false arm + * + * This file exercises: + * - asUnlimitedLimit (private) via buildHostedCommercialUsageModel: + * positive limit, zero, negative, absent (status null), and a + * deliberately-malformed non-number value. + * - buildHostedCommercialPlanModel: status present (email shown) and + * status null ('Not configured' fallback), plus full summary shape. + * - buildHostedCommercialUsageModel: guests limit shown vs absent; + * guest usage below / at / above the limit; zero usage. + * - buildSelfHostedBaseDetails cold arms reached via + * buildSelfHostedCommercialPlanModel: empty licensedEmail and + * absent planTerms. + * + * Does NOT modify the source module or any existing test file. + */ +import { describe, expect, it } from 'vitest'; + +import { + buildHostedCommercialPlanModel, + buildHostedCommercialUsageModel, + buildSelfHostedCommercialPlanModel, + type HostedCommercialModelInput, +} from '../commercialBillingModel'; + +const createHostedBaseInput = (): HostedCommercialModelInput => ({ + status: { + email: 'owner@example.com', + is_lifetime: false, + expires_at: '2026-12-31', + max_guests: 10, + }, + tierLabel: 'Business', + licenseStatusLabel: 'Active', + organizationCount: 3, + memberCount: 24, + nodeUsage: 7, + guestUsage: 5, + renewsOrExpires: 'Renews 2026-12-31', +}); + +/* ------------------------------------------------------------------ * + * asUnlimitedLimit — exercised solely through buildHostedCommercialUsageModel + * typeof value === 'number' && value > 0 ? value : undefined + * + * Arms: + * (A) number && > 0 → returns the value (limit shown) + * (B) else → undefined (covers 0, negative, absent, non-number) + * ------------------------------------------------------------------ */ + +describe('asUnlimitedLimit via buildHostedCommercialUsageModel - positive limit shown', () => { + it('surfaces max_guests as the guests meter limit when it is a positive number', () => { + const model = buildHostedCommercialUsageModel(createHostedBaseInput()); + const guests = model.meters[1]; + expect(guests.limit).toBe(10); + expect(guests.current).toBe(5); + expect(guests.label).toBe('Guests'); + expect(guests.accentClass).toBe('bg-emerald-600 dark:bg-emerald-500'); + }); +}); + +describe('asUnlimitedLimit via buildHostedCommercialUsageModel - zero limit hidden', () => { + it('drops the guests limit when max_guests is 0 (returns undefined, no limit shown)', () => { + // NOTE: src/api/license.ts:19 documents "0 means unlimited" for limit + // fields, but asUnlimitedLimit treats 0 as "no limit to display" + // (value > 0 is false). See GLM_REPORT for the suspected source mismatch. + const model = buildHostedCommercialUsageModel({ + ...createHostedBaseInput(), + status: { + email: 'owner@example.com', + is_lifetime: false, + expires_at: '2026-12-31', + max_guests: 0, + }, + }); + expect(model.meters[1].limit).toBeUndefined(); + }); +}); + +describe('asUnlimitedLimit via buildHostedCommercialUsageModel - absent status', () => { + it('drops the guests limit and still reports node/guest usage when status is null', () => { + // Exercises both the `status?.` optional-chaining short-circuit and the + // asUnlimitedLimit(undefined) arm. + const model = buildHostedCommercialUsageModel({ + ...createHostedBaseInput(), + status: null, + }); + expect(model.meters[1].limit).toBeUndefined(); + // Monitored Systems meter is unaffected by status. + expect(model.meters[0]).toEqual({ + label: 'Monitored Systems', + current: 7, + accentClass: 'bg-blue-600 dark:bg-blue-500', + }); + expect(model.meters[1].current).toBe(5); + }); +}); + +describe('asUnlimitedLimit via buildHostedCommercialUsageModel - malformed non-number', () => { + it('drops the guests limit when max_guests is a non-number string (typeof guard false arm)', () => { + // Deliberately-malformed input: the declared type is number, but a string + // still reaches the guard at runtime; `typeof value === 'number'` is false. + const model = buildHostedCommercialUsageModel({ + ...createHostedBaseInput(), + status: { + email: 'owner@example.com', + is_lifetime: false, + expires_at: '2026-12-31', + max_guests: '10' as unknown as number, + }, + }); + expect(model.meters[1].limit).toBeUndefined(); + }); +}); + +/* ------------------------------------------------------------------ * + * buildHostedCommercialPlanModel — optional `status` field + * + * Arms in `input.status?.email || 'Not configured'` (line 144): + * (A) status present + email present → email + * (B) status null / email absent → 'Not configured' + * ------------------------------------------------------------------ */ + +describe('buildHostedCommercialPlanModel - status present surfaces licensed email', () => { + it('builds the full summary and shows the licensed email in details', () => { + const model = buildHostedCommercialPlanModel(createHostedBaseInput()); + expect(model.summary).toEqual([ + { label: 'Plan Tier', value: 'Business' }, + { label: 'License Status', value: 'Active' }, + { label: 'Organizations', value: 3 }, + { label: 'Members (Current Org)', value: 24 }, + ]); + expect(model.details).toEqual([ + { label: 'Licensed Email', value: 'owner@example.com' }, + { label: 'Renews / Expires', value: 'Renews 2026-12-31' }, + ]); + }); +}); + +describe('buildHostedCommercialPlanModel - status null falls back to Not configured', () => { + it('shows "Not configured" for the licensed email when status is absent', () => { + const model = buildHostedCommercialPlanModel({ + ...createHostedBaseInput(), + status: null, + }); + expect(model.details[0]).toEqual({ + label: 'Licensed Email', + value: 'Not configured', + }); + // Summary still reflects the non-optional scalar inputs. + expect(model.summary[2]).toEqual({ label: 'Organizations', value: 3 }); + }); +}); + +describe('buildHostedCommercialPlanModel - status present but email empty', () => { + it('falls back to "Not configured" when status exists but email is empty string', () => { + const model = buildHostedCommercialPlanModel({ + ...createHostedBaseInput(), + status: { + email: '', + is_lifetime: true, + expires_at: null, + max_guests: undefined, + }, + }); + expect(model.details[0]).toEqual({ + label: 'Licensed Email', + value: 'Not configured', + }); + }); +}); + +/* ------------------------------------------------------------------ * + * buildHostedCommercialUsageModel — guest usage relative to the limit + * + * The model is a pure passthrough for `current`; it does not branch on + * whether usage is at / below / above the limit. These cases assert the + * observable current+limit pair is faithfully recorded in each regime. + * ------------------------------------------------------------------ */ + +describe('buildHostedCommercialUsageModel - guest usage below the limit', () => { + it('records current 5 against limit 10', () => { + const model = buildHostedCommercialUsageModel({ + ...createHostedBaseInput(), + guestUsage: 5, + }); + expect(model.meters[1]).toMatchObject({ current: 5, limit: 10 }); + }); +}); + +describe('buildHostedCommercialUsageModel - guest usage at the limit', () => { + it('records current 10 against limit 10', () => { + const model = buildHostedCommercialUsageModel({ + ...createHostedBaseInput(), + guestUsage: 10, + }); + expect(model.meters[1]).toMatchObject({ current: 10, limit: 10 }); + }); +}); + +describe('buildHostedCommercialUsageModel - guest usage above the limit', () => { + it('records current 15 against limit 10 (model does not clamp)', () => { + const model = buildHostedCommercialUsageModel({ + ...createHostedBaseInput(), + guestUsage: 15, + }); + expect(model.meters[1]).toMatchObject({ current: 15, limit: 10 }); + }); +}); + +describe('buildHostedCommercialUsageModel - zero guest usage', () => { + it('records current 0 and still surfaces the configured limit', () => { + const model = buildHostedCommercialUsageModel({ + ...createHostedBaseInput(), + guestUsage: 0, + }); + expect(model.meters[1]).toMatchObject({ current: 0, limit: 10 }); + }); +}); + +/* ------------------------------------------------------------------ * + * buildSelfHostedBaseDetails — cold arms reached via + * buildSelfHostedCommercialPlanModel (no retailPlanDefinition so the + * base-details path is exercised). + * + * Cold arm 1: `input.licensedEmail || 'Not available'` (line 62) + * Cold arm 2: `input.planTerms ? [...] : []` (line 70) + * ------------------------------------------------------------------ */ + +describe('buildSelfHostedBaseDetails - empty licensed email falls back to Not available', () => { + it('shows "Not available" when licensedEmail is undefined', () => { + const model = buildSelfHostedCommercialPlanModel({ + statusLabel: 'Active', + tierLabel: 'Pro', + expires: '12/31/2026', + daysRemaining: 30, + retailPlanDefinition: null, + }); + const licensedEmailRow = model.details.find((d) => d.label === 'Licensed Email'); + expect(licensedEmailRow).toEqual({ label: 'Licensed Email', value: 'Not available' }); + }); +}); + +describe('buildSelfHostedBaseDetails - absent plan terms omits the Plan Terms row', () => { + it('produces only 4 detail rows (no Plan Terms) when planTerms is undefined', () => { + const model = buildSelfHostedCommercialPlanModel({ + licensedEmail: 'owner@example.com', + statusLabel: 'Active', + tierLabel: 'Pro', + expires: '12/31/2026', + daysRemaining: 30, + retailPlanDefinition: null, + }); + expect(model.details.map((d) => d.label)).toEqual([ + 'Tier', + 'Licensed Email', + 'Expires', + 'Days Remaining', + ]); + expect(model.details.some((d) => d.label === 'Plan Terms')).toBe(false); + }); +}); + +describe('buildSelfHostedBaseDetails - both cold arms together', () => { + it('shows "Not available" and omits Plan Terms when both are absent', () => { + const model = buildSelfHostedCommercialPlanModel({ + statusLabel: 'Active', + tierLabel: 'Pro', + expires: '12/31/2026', + daysRemaining: 0, + retailPlanDefinition: null, + }); + expect(model.details).toEqual([ + { label: 'Tier', value: 'Pro' }, + { label: 'Licensed Email', value: 'Not available' }, + { label: 'Expires', value: '12/31/2026' }, + { label: 'Days Remaining', value: 0 }, + ]); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/resourceTypeCompat.branchcov0724pm.test.ts b/frontend-modern/src/utils/__tests__/resourceTypeCompat.branchcov0724pm.test.ts new file mode 100644 index 0000000000..8575b76a33 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/resourceTypeCompat.branchcov0724pm.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { canonicalizeFrontendResourceType } from '@/utils/resourceTypeCompat'; + +describe('resourceTypeCompat.branchcov0724pm', () => { + it('rejects non-string input by returning undefined', () => { + expect(canonicalizeFrontendResourceType(123)).toBeUndefined(); + expect(canonicalizeFrontendResourceType(0)).toBeUndefined(); + expect(canonicalizeFrontendResourceType(true)).toBeUndefined(); + expect(canonicalizeFrontendResourceType(null)).toBeUndefined(); + expect(canonicalizeFrontendResourceType(undefined)).toBeUndefined(); + expect(canonicalizeFrontendResourceType({ type: 'agent' })).toBeUndefined(); + expect(canonicalizeFrontendResourceType(['agent'])).toBeUndefined(); + }); + + it('rejects empty and whitespace-only strings by returning undefined', () => { + expect(canonicalizeFrontendResourceType('')).toBeUndefined(); + expect(canonicalizeFrontendResourceType(' ')).toBeUndefined(); + expect(canonicalizeFrontendResourceType('\t\n')).toBeUndefined(); + }); + + it('canonicalizes the remaining hyphenated / unseparated legacy aliases', () => { + expect(canonicalizeFrontendResourceType('dockerhost')).toBe('docker-host'); + expect(canonicalizeFrontendResourceType('swarm-secret')).toBe('docker-secret'); + expect(canonicalizeFrontendResourceType('swarm-config')).toBe('docker-config'); + expect(canonicalizeFrontendResourceType('kubernetes-cluster')).toBe('k8s-cluster'); + }); + + it('identity-maps canonical type strings that only appear in the second switch arm', () => { + expect(canonicalizeFrontendResourceType('agent')).toBe('agent'); + expect(canonicalizeFrontendResourceType('storage')).toBe('storage'); + expect(canonicalizeFrontendResourceType('disk')).toBe('disk'); + expect(canonicalizeFrontendResourceType('docker-host')).toBe('docker-host'); + }); + + it('maps the bare "node" legacy alias to agent', () => { + expect(canonicalizeFrontendResourceType('node')).toBe('agent'); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/resourceTypePresentation.branchcov0724pm.test.ts b/frontend-modern/src/utils/__tests__/resourceTypePresentation.branchcov0724pm.test.ts new file mode 100644 index 0000000000..37dd84ab5b --- /dev/null +++ b/frontend-modern/src/utils/__tests__/resourceTypePresentation.branchcov0724pm.test.ts @@ -0,0 +1,125 @@ +/** + * Branch-coverage tests for resourceTypePresentation.ts (0724pm batch). + * + * Measured baseline (existing resourceTypePresentation.test.ts only): + * functions: 4/4 covered + * branches : 6/10 arms covered -> 4 cold + * + * Cold arms found by scoped v8 coverage (coverage-final.json): + * branch#2 L240:40-72 `canonicalResourceTypeForDisplay` + * `canonicalizeFrontendResourceType(value) || value.trim().toLowerCase()` + * -> the `value.trim().toLowerCase()` fallback, taken when the + * input is a non-empty string that is NOT a known canonical alias. + * branch#4 L245:21-33 `getResourceTypePresentation` + * `if (!resourceType) return null;` + * -> the `return null` arm (falsy / empty / undefined resourceType). + * branch#7 L251:27-256 `getResourceTypePresentation` + * `if (presentation) return presentation;` fall-through to the + * default-badge object returned for an unknown canonical type. + * branch#9 L259:45-59 `getResourceTypeLabel` + * `getResourceTypePresentation(resourceType)?.label || null` + * -> the `|| null` arm, taken when no presentation resolves. + * + * This file exercises each cold arm with real observable values. It does NOT + * modify the source module or any existing test file. + * + * Suspected SOURCE bug (reported, NOT fixed): canonicalResourceTypeForDisplay + * (line 239) is typed `(value: string)` and falls back to `value.trim().toLowerCase()`. + * Its callee canonicalizeFrontendResourceType accepts `unknown` and gracefully + * returns undefined for non-strings, but this wrapper calls `.trim()` on the + * raw value and throws TypeError if a non-string reaches it at runtime + * (see the "malformed non-string input" test below). + */ +import { describe, expect, it } from 'vitest'; + +import { + canonicalResourceTypeForDisplay, + getResourceTypeLabel, + getResourceTypePresentation, +} from '@/utils/resourceTypePresentation'; + +const DEFAULT_BADGE_CLASSES = 'bg-surface-alt text-base-content'; + +/* ------------------------------------------------------------------ * + * branch#2 - canonicalResourceTypeForDisplay fallback arm + * canonicalizeFrontendResourceType(value) || value.trim().toLowerCase() + * + * The fallback fires for any non-empty string that is NOT one of the + * canonical aliases defined in resourceTypeCompat.ts. + * ------------------------------------------------------------------ */ + +describe('canonicalResourceTypeForDisplay - unknown string falls back to trimmed lowercase form', () => { + it('returns the trimmed, lowercased value when canonicalization has no mapping', () => { + expect(canonicalResourceTypeForDisplay('totally-unknown-xyz')).toBe('totally-unknown-xyz'); + }); +}); + +/* ------------------------------------------------------------------ * + * branch#2 + branch#7 - getResourceTypePresentation unknown-type default + * + * externalPresentation undefined (line 247-248, already hot) + * -> canonicalType from canonicalResourceTypeForDisplay fallback (branch#2) + * -> presentation undefined (line 250-251) + * -> fall through to the default badge object (branch#7) + * ------------------------------------------------------------------ */ + +describe('getResourceTypePresentation - unknown type resolves to a default badge', () => { + it('uses the canonicalized type as the label with the default badge classes', () => { + const presentation = getResourceTypePresentation('totally-unknown-xyz'); + expect(presentation).toEqual({ + label: 'totally-unknown-xyz', + badgeClasses: DEFAULT_BADGE_CLASSES, + }); + }); + + it('returns a distinct object per call (default is built inline, not cached)', () => { + const a = getResourceTypePresentation('unknown-a'); + const b = getResourceTypePresentation('unknown-b'); + expect(a).not.toBe(b); + expect(a?.label).toBe('unknown-a'); + expect(b?.label).toBe('unknown-b'); + }); +}); + +/* ------------------------------------------------------------------ * + * branch#4 - getResourceTypePresentation falsy resourceType arm + * if (!resourceType) return null; + * ------------------------------------------------------------------ */ + +describe('getResourceTypePresentation - falsy resourceType returns null', () => { + it('returns null for an empty string', () => { + expect(getResourceTypePresentation('')).toBeNull(); + }); +}); + +/* ------------------------------------------------------------------ * + * branch#9 - getResourceTypeLabel null-when-unresolved arm + * getResourceTypePresentation(resourceType)?.label || null + * + * Fires when getResourceTypePresentation itself returns null (falsy input), + * leaving `?.label` undefined and the `|| null` arm to provide the value. + * ------------------------------------------------------------------ */ + +describe('getResourceTypeLabel - returns null when no presentation resolves', () => { + it('returns null for an empty string', () => { + expect(getResourceTypeLabel('')).toBeNull(); + }); +}); + +/* ------------------------------------------------------------------ * + * Malformed input (deliberately mistyped) - documents suspected source bug. + * + * canonicalResourceTypeForDisplay is typed (value: string) but its callee + * accepts unknown. A non-string passed at runtime reaches the fallback + * `value.trim()` and throws TypeError instead of degrading gracefully. + * Reported in GLM_REPORT; NOT fixed here. + * ------------------------------------------------------------------ */ + +describe('canonicalResourceTypeForDisplay - malformed non-string input', () => { + it('throws TypeError because the fallback calls .trim() on the raw value', () => { + expect(() => canonicalResourceTypeForDisplay(123 as unknown as string)).toThrow(TypeError); + expect(() => canonicalResourceTypeForDisplay(123 as unknown as string)).toThrow( + 'value.trim is not a function', + ); + }); +});