From fbebdddb7d0e181bba64306e168ab63bcdf92e7f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 20 Jul 2026 14:22:43 +0100 Subject: [PATCH] Add frontend branch-coverage tests for eight untested pure models A full V8 branch-coverage regen (18326-test suite clean, so the counts are ground truth) flagged eight pure frontend modules as the only remaining defensible coverage gaps. This adds one branchcov test per module, exercising genuinely-untested exported functions and previously-uncovered branch arms. Modules and targets covered. - utils/agentInstallCommand buildPowerShellInstallScriptBootstrap, a completely untested export, plus its empty-URL throw arm - i18n/locales resolveSupportedLocale across all four resolution strategies and the unsupported-base null arm, plus getLocaleFallbackChain - shared/helpIconModel calculateHelpPopoverPosition geometry (top and bottom flip arms, horizontal clamp) and resolveHelpContent - Workloads/workloadsFilterModel countActiveWorkloadsFilters across all eight filter arms and hasActiveWorkloadsFilters - Workloads/metricBarModel buildMetricBarPresentation showLabel and showSublabel threshold arms - shared/selectionCardGroupModel variant and tone resolvers and the class helpers - shared/animatedNumberModel sanitizeAnimatedNumberValue non-finite arm - shared/tagInputModel getTagInputPlaceholder arms, getNextTagsAfterRemove, canAddTag Tests only, no source changes. 173 new test cases, all green. tsc and eslint clean. --- .../metricBarModel.branchcov0720pm.test.ts | 121 +++++++ ...rkloadsFilterModel.branchcov0720pm.test.ts | 138 ++++++++ ...nimatedNumberModel.branchcov0720pm.test.ts | 148 ++++++++ .../helpIconModel.branchcov0720pm.test.ts | 317 ++++++++++++++++++ ...tionCardGroupModel.branchcov0720pm.test.ts | 276 +++++++++++++++ .../tagInputModel.branchcov0720pm.test.ts | 127 +++++++ .../__tests__/locales.branchcov0720pm.test.ts | 180 ++++++++++ ...gentInstallCommand.branchcov0720pm.test.ts | 180 ++++++++++ 8 files changed, 1487 insertions(+) create mode 100644 frontend-modern/src/components/Workloads/__tests__/metricBarModel.branchcov0720pm.test.ts create mode 100644 frontend-modern/src/components/Workloads/__tests__/workloadsFilterModel.branchcov0720pm.test.ts create mode 100644 frontend-modern/src/components/shared/__tests__/animatedNumberModel.branchcov0720pm.test.ts create mode 100644 frontend-modern/src/components/shared/__tests__/helpIconModel.branchcov0720pm.test.ts create mode 100644 frontend-modern/src/components/shared/__tests__/selectionCardGroupModel.branchcov0720pm.test.ts create mode 100644 frontend-modern/src/components/shared/__tests__/tagInputModel.branchcov0720pm.test.ts create mode 100644 frontend-modern/src/i18n/__tests__/locales.branchcov0720pm.test.ts create mode 100644 frontend-modern/src/utils/__tests__/agentInstallCommand.branchcov0720pm.test.ts diff --git a/frontend-modern/src/components/Workloads/__tests__/metricBarModel.branchcov0720pm.test.ts b/frontend-modern/src/components/Workloads/__tests__/metricBarModel.branchcov0720pm.test.ts new file mode 100644 index 000000000..a3af452cc --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/metricBarModel.branchcov0720pm.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; + +import { estimateTextWidth } from '@/utils/format'; + +import type { MetricBarProps } from '../metricBarModel'; +import { buildMetricBarPresentation } from '../metricBarModel'; + +// estimateTextWidth(text) = text.length * 5.5 + 8 (mirrored from @/utils/format). +// For label='CPU' + sublabel='8c' the composed threshold text is 'CPU (8c)' +// (length 8) -> estimateTextWidth = 8 * 5.5 + 8 = 52. So a containerWidth of 52 +// trips the >= true arm of the showSublabel check, and 51 trips the false arm. +const LABEL = 'CPU'; +const SUBLABEL = '8c'; +const SUBLABEL_THRESHOLD = estimateTextWidth(`${LABEL} (${SUBLABEL})`); // 52 + +// Tailwind background class mirrored from metricThresholds.BG_CLASSES.normal. +// Default cpu thresholds are warning 80 / critical 90 (METRIC_THRESHOLDS.cpu), +// used whenever thresholds is undefined or null. value 42 < 80 -> normal. +const NORMAL_CLASS = 'bg-metric-normal-bg dark:bg-metric-normal-bg'; + +const makeProps = (overrides: Partial = {}): MetricBarProps => ({ + value: 42, + label: LABEL, + sublabel: SUBLABEL, + ...overrides, +}); + +describe('buildMetricBarPresentation (branch coverage 0720pm)', () => { + describe('showLabel branch (props.showLabel !== false && label.trim().length > 0)', () => { + it('returns showLabel=true when showLabel is omitted (true arm of !== false)', () => { + // showLabel omitted -> props.showLabel is undefined -> !== false is true. + // label 'CPU' trims to a non-empty string -> showLabel true. + const p = buildMetricBarPresentation(makeProps(), 200); + expect(p.showLabel).toBe(true); + // The other emitted fields are also concrete and verified here. + expect(p.width).toBe(42); + expect(p.progressColorClass).toBe(NORMAL_CLASS); + // With a wide container the sublabel is also shown. + expect(p.showSublabel).toBe(true); + }); + + it('returns showLabel=true when showLabel is explicitly true (true arm of !== false)', () => { + const p = buildMetricBarPresentation(makeProps({ showLabel: true }), 200); + expect(p.showLabel).toBe(true); + }); + + it('returns showLabel=false when showLabel is explicitly false (false arm of !== false)', () => { + // Even with a wide container and a non-empty sublabel, showLabel=false + // propagates straight through to showLabel and forces showSublabel=false + // via the leading && of the showSublabel expression. + const p = buildMetricBarPresentation(makeProps({ showLabel: false }), 200); + expect(p.showLabel).toBe(false); + expect(p.showSublabel).toBe(false); + // width and progressColorClass are computed independently of showLabel. + expect(p.width).toBe(42); + expect(p.progressColorClass).toBe(NORMAL_CLASS); + }); + + it('returns showLabel=false when the label is whitespace-only (label.trim().length > 0 false arm)', () => { + // showLabel !== false is true, but label.trim().length === 0 -> the + // right operand of the && is false -> showLabel false -> showSublabel false. + const p = buildMetricBarPresentation(makeProps({ label: ' ', showLabel: true }), 200); + expect(p.showLabel).toBe(false); + expect(p.showSublabel).toBe(false); + }); + }); + + describe('showSublabel threshold branch (containerWidth >= estimateTextWidth(...))', () => { + it('returns showSublabel=true when containerWidth meets the threshold exactly (>= true arm)', () => { + // estimateTextWidth('CPU (8c)') === 52; containerWidth 52 -> 52 >= 52 true. + expect(SUBLABEL_THRESHOLD).toBe(52); + const p = buildMetricBarPresentation(makeProps(), SUBLABEL_THRESHOLD); + expect(p.showLabel).toBe(true); + expect(p.showSublabel).toBe(true); + }); + + it('returns showSublabel=false when containerWidth is one below the threshold (false arm)', () => { + // containerWidth 51 < 52 -> the >= arm is false -> showSublabel false. + // showLabel itself is unaffected by the width check (still true here). + const p = buildMetricBarPresentation(makeProps(), SUBLABEL_THRESHOLD - 1); + expect(p.showLabel).toBe(true); + expect(p.showSublabel).toBe(false); + }); + + it('returns showSublabel=false when sublabel is empty (Boolean(props.sublabel) false arm)', () => { + // Even with a very wide container, Boolean('') is false -> showSublabel false. + const p = buildMetricBarPresentation(makeProps({ sublabel: '' }), 1000); + expect(p.showLabel).toBe(true); + expect(p.showSublabel).toBe(false); + }); + }); + + describe('width field (Math.min(props.value, 100))', () => { + it('returns the raw value when value <= 100', () => { + const p = buildMetricBarPresentation(makeProps({ value: 42 }), 200); + expect(p.width).toBe(42); + }); + + it('clamps to 100 when value > 100', () => { + // Math.min(150, 100) -> 100. + const p = buildMetricBarPresentation(makeProps({ value: 150 }), 200); + expect(p.width).toBe(100); + }); + }); + + describe('progressColorClass threading (type/thresholds)', () => { + it('maps a sub-warning value to the normal class with default cpu thresholds', () => { + // value 42 < 80 (warning) -> normal; metric defaults to 'cpu' when type + // is omitted. + const p = buildMetricBarPresentation(makeProps({ value: 42 }), 200); + expect(p.progressColorClass).toBe(NORMAL_CLASS); + }); + + it('honors type=generic by mapping it to cpu for color purposes', () => { + // metric = props.type || 'cpu' -> 'generic'; metricType = metric === + // 'generic' ? 'cpu' : metric -> 'cpu'. Same normal-band class. + const p = buildMetricBarPresentation(makeProps({ value: 42, type: 'generic' }), 200); + expect(p.progressColorClass).toBe(NORMAL_CLASS); + }); + }); +}); diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadsFilterModel.branchcov0720pm.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadsFilterModel.branchcov0720pm.test.ts new file mode 100644 index 000000000..a674b75fe --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/workloadsFilterModel.branchcov0720pm.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; + +import type { CountActiveWorkloadsFiltersOptions } from '../workloadsFilterModel'; +import { + countActiveWorkloadsFilters, + DEFAULT_WORKLOADS_STATUS_MODE, + DEFAULT_WORKLOADS_VIEW_MODE, + hasActiveWorkloadsFilters, +} from '../workloadsFilterModel'; + +// `countActiveWorkloadsFilters` is an 8-accumulator reducer with eight independent +// `if` arms (search / viewMode / statusMode / hostFilter / platformFilter / +// namespaceFilter / clusterFilter / containerRuntimeFilter). Each arm is exercised +// below in both the true (active) and false (default) direction by asserting on +// the real returned count, not on source text. + +// Build a fixture where EVERY arm is at its inactive default. The five optional +// filter-value fields are omitted entirely so the `(value ?? '')` nullish arms +// are also driven (they must collapse to the empty string and NOT count). +const inactiveOptions = (): CountActiveWorkloadsFiltersOptions => ({ + search: '', + viewMode: DEFAULT_WORKLOADS_VIEW_MODE, + statusMode: DEFAULT_WORKLOADS_STATUS_MODE, +}); + +describe('countActiveWorkloadsFilters — all-default baseline (every if false)', () => { + it('returns 0 when every field is at its real default', () => { + expect(countActiveWorkloadsFilters(inactiveOptions())).toBe(0); + }); + + it('returns 0 when the five optional filter values are explicitly undefined', () => { + // Drives the `(value ?? '')` nullish-coalesce arm on each of the five + // filter-value guards — undefined must collapse to '' and not count. + expect( + countActiveWorkloadsFilters({ + ...inactiveOptions(), + hostFilterValue: undefined, + platformFilterValue: undefined, + namespaceFilterValue: undefined, + clusterFilterValue: undefined, + containerRuntimeFilterValue: undefined, + }), + ).toBe(0); + }); + + it('treats a whitespace-only search as inactive (search.trim() !== "" false arm)', () => { + expect(countActiveWorkloadsFilters({ ...inactiveOptions(), search: ' ' })).toBe(0); + }); +}); + +describe('countActiveWorkloadsFilters — single-arm active (each if true independently)', () => { + it('counts only the search arm when search has non-whitespace content', () => { + expect(countActiveWorkloadsFilters({ ...inactiveOptions(), search: 'nginx' })).toBe(1); + }); + + it('counts only the viewMode arm when viewMode differs from DEFAULT_WORKLOADS_VIEW_MODE', () => { + expect(countActiveWorkloadsFilters({ ...inactiveOptions(), viewMode: 'vm' })).toBe(1); + }); + + it('counts only the statusMode arm when statusMode differs from DEFAULT_WORKLOADS_STATUS_MODE', () => { + expect(countActiveWorkloadsFilters({ ...inactiveOptions(), statusMode: 'running' })).toBe(1); + }); + + it('counts only the hostFilter arm when hostFilterValue is non-empty', () => { + expect(countActiveWorkloadsFilters({ ...inactiveOptions(), hostFilterValue: 'host-1' })).toBe( + 1, + ); + }); + + it('counts only the platformFilter arm when platformFilterValue is non-empty', () => { + expect( + countActiveWorkloadsFilters({ ...inactiveOptions(), platformFilterValue: 'vmware' }), + ).toBe(1); + }); + + it('counts only the namespaceFilter arm when namespaceFilterValue is non-empty', () => { + expect( + countActiveWorkloadsFilters({ ...inactiveOptions(), namespaceFilterValue: 'kube-system' }), + ).toBe(1); + }); + + it('counts only the clusterFilter arm when clusterFilterValue is non-empty', () => { + expect( + countActiveWorkloadsFilters({ ...inactiveOptions(), clusterFilterValue: 'prod-us-1' }), + ).toBe(1); + }); + + it('counts only the containerRuntimeFilter arm when containerRuntimeFilterValue is non-empty', () => { + expect( + countActiveWorkloadsFilters({ + ...inactiveOptions(), + containerRuntimeFilterValue: 'containerd', + }), + ).toBe(1); + }); +}); + +describe('countActiveWorkloadsFilters — all arms active simultaneously', () => { + it('returns 8 when every arm is set to a non-default value', () => { + expect( + countActiveWorkloadsFilters({ + search: 'nginx', + viewMode: 'vm', + statusMode: 'running', + hostFilterValue: 'host-1', + platformFilterValue: 'vmware', + namespaceFilterValue: 'kube-system', + clusterFilterValue: 'prod-us-1', + containerRuntimeFilterValue: 'containerd', + }), + ).toBe(8); + }); +}); + +describe('hasActiveWorkloadsFilters — wraps countActiveWorkloadsFilters', () => { + it('returns false when the count is 0 (all-default baseline)', () => { + expect(hasActiveWorkloadsFilters(inactiveOptions())).toBe(false); + }); + + it('returns true when the count is greater than 0 (single search arm active)', () => { + expect(hasActiveWorkloadsFilters({ ...inactiveOptions(), search: 'nginx' })).toBe(true); + }); + + it('returns true when every arm is active (count === 8)', () => { + expect( + hasActiveWorkloadsFilters({ + search: 'nginx', + viewMode: 'vm', + statusMode: 'running', + hostFilterValue: 'host-1', + platformFilterValue: 'vmware', + namespaceFilterValue: 'kube-system', + clusterFilterValue: 'prod-us-1', + containerRuntimeFilterValue: 'containerd', + }), + ).toBe(true); + }); +}); diff --git a/frontend-modern/src/components/shared/__tests__/animatedNumberModel.branchcov0720pm.test.ts b/frontend-modern/src/components/shared/__tests__/animatedNumberModel.branchcov0720pm.test.ts new file mode 100644 index 000000000..59af1dd2e --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/animatedNumberModel.branchcov0720pm.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; + +import { + easeAnimatedNumberProgress, + formatAnimatedInteger, + sanitizeAnimatedNumberValue, +} from '@/components/shared/animatedNumberModel'; + +// Every assertion below is a hand-computed expected value against the real +// runtime output of the three exported helpers in animatedNumberModel.ts +// (no `?raw` source-string reads, no snapshots, no constant-equals-itself +// tautologies). See src/components/shared/animatedNumberModel.ts. + +describe('animatedNumberModel.branchcov0720pm', () => { + describe('sanitizeAnimatedNumberValue', () => { + it('returns 0 for NaN (!Number.isFinite(value) true arm)', () => { + // NaN is not finite -> early-return 0. + expect(sanitizeAnimatedNumberValue(NaN)).toBe(0); + }); + + it('returns 0 for +Infinity (!Number.isFinite(value) true arm)', () => { + expect(sanitizeAnimatedNumberValue(Infinity)).toBe(0); + }); + + it('returns 0 for -Infinity (!Number.isFinite(value) true arm)', () => { + expect(sanitizeAnimatedNumberValue(-Infinity)).toBe(0); + }); + + it('returns a positive finite value verbatim (happy path, identity arm)', () => { + // Number.isFinite(42) -> true -> fall through to `return value`. + expect(sanitizeAnimatedNumberValue(42)).toBe(42); + }); + + it('returns a negative finite value verbatim (happy path, identity arm)', () => { + expect(sanitizeAnimatedNumberValue(-7)).toBe(-7); + }); + + it('returns 0 verbatim at the zero boundary (happy path, identity arm)', () => { + // 0 is finite, so the guard is skipped — the value flows through unchanged. + expect(sanitizeAnimatedNumberValue(0)).toBe(0); + }); + + it('returns a fractional finite value verbatim (happy path, identity arm)', () => { + expect(sanitizeAnimatedNumberValue(3.14)).toBe(3.14); + }); + }); + + describe('formatAnimatedInteger', () => { + // `String(Math.round(sanitizeAnimatedNumberValue(value)))` — composition of + // sanitize (guard) + Math.round + String(). Each case locks a distinct arm + // of that pipeline by asserting on the observable string output. + + it('formats a representative positive integer as its decimal string', () => { + // sanitize(42) -> 42; Math.round(42) -> 42; String(42) -> '42'. + expect(formatAnimatedInteger(42)).toBe('42'); + }); + + it('rounds a fractional value up to the nearest integer before formatting', () => { + // Math.round(42.7) -> 43. + expect(formatAnimatedInteger(42.7)).toBe('43'); + }); + + it('rounds a fractional value down to the nearest integer before formatting', () => { + // Math.round(42.4) -> 42. + expect(formatAnimatedInteger(42.4)).toBe('42'); + }); + + it('formats a negative integer (preserves the leading minus)', () => { + expect(formatAnimatedInteger(-5)).toBe('-5'); + }); + + it('formats 0 as "0" (zero boundary, no rounding artefacts)', () => { + expect(formatAnimatedInteger(0)).toBe('0'); + }); + + it('formats a value just below .5 by rounding down (Math.round half-up boundary)', () => { + // Math.round(0.4999) -> 0. + expect(formatAnimatedInteger(0.4999)).toBe('0'); + }); + + it('formats a value at exactly .5 by rounding up (Math.round half-up boundary)', () => { + // Math.round(0.5) -> 1. + expect(formatAnimatedInteger(0.5)).toBe('1'); + }); + + it('sanitizes NaN to "0" via the sanitize guard before rounding', () => { + // sanitize(NaN) -> 0; Math.round(0) -> 0; String(0) -> '0'. + expect(formatAnimatedInteger(NaN)).toBe('0'); + }); + + it('sanitizes +Infinity to "0" via the sanitize guard before rounding', () => { + expect(formatAnimatedInteger(Infinity)).toBe('0'); + }); + + it('sanitizes -Infinity to "0" via the sanitize guard before rounding', () => { + expect(formatAnimatedInteger(-Infinity)).toBe('0'); + }); + }); + + describe('easeAnimatedNumberProgress', () => { + // `Math.max(0, Math.min(progress, 1))` clamps to [0, 1]; then cubic ease-out + // `1 - (1 - bounded) ^ 3`. Each case drives a distinct clamp arm and asserts + // a hand-computed expected numeric output. + + it('clamps progress < 0 to 0 and yields the eased value 0 (max-wins arm)', () => { + // bounded = max(0, min(-0.5, 1)) = max(0, -0.5) = 0 + // eased = 1 - (1 - 0) ^ 3 = 1 - 1 = 0 + expect(easeAnimatedNumberProgress(-0.5)).toBe(0); + }); + + it('clamps progress > 1 to 1 and yields the eased value 1 (min-wins arm)', () => { + // bounded = max(0, min(1.5, 1)) = max(0, 1) = 1 + // eased = 1 - (1 - 1) ^ 3 = 1 - 0 = 1 + expect(easeAnimatedNumberProgress(1.5)).toBe(1); + }); + + it('passes an in-range progress through both clamps untouched (identity arm)', () => { + // bounded = max(0, min(0.5, 1)) = max(0, 0.5) = 0.5 + // eased = 1 - (1 - 0.5) ^ 3 = 1 - 0.125 = 0.875 + expect(easeAnimatedNumberProgress(0.5)).toBe(0.875); + }); + + it('eases the quarter-progress point with the cubic curve (identity arm, second sample)', () => { + // bounded = 0.25 -> eased = 1 - (0.75) ^ 3 = 1 - 0.421875 = 0.578125 + expect(easeAnimatedNumberProgress(0.25)).toBe(0.578125); + }); + + it('returns 0 at the progress === 0 lower boundary (no clamp needed)', () => { + // bounded = 0 -> eased = 1 - 1 = 0. + expect(easeAnimatedNumberProgress(0)).toBe(0); + }); + + it('returns 1 at the progress === 1 upper boundary (no clamp needed)', () => { + // bounded = 1 -> eased = 1 - 0 = 1. + expect(easeAnimatedNumberProgress(1)).toBe(1); + }); + + it('clamps a deeply-negative progress to 0 (max-wins arm, far side)', () => { + // bounded = max(0, min(-100, 1)) = 0 -> eased = 0. + expect(easeAnimatedNumberProgress(-100)).toBe(0); + }); + + it('clamps a large overflow progress to 1 (min-wins arm, far side)', () => { + // bounded = max(0, min(100, 1)) = 1 -> eased = 1. + expect(easeAnimatedNumberProgress(100)).toBe(1); + }); + }); +}); diff --git a/frontend-modern/src/components/shared/__tests__/helpIconModel.branchcov0720pm.test.ts b/frontend-modern/src/components/shared/__tests__/helpIconModel.branchcov0720pm.test.ts new file mode 100644 index 000000000..0c25d2754 --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/helpIconModel.branchcov0720pm.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it } from 'vitest'; + +import { + calculateHelpPopoverPosition, + getHelpIconSize, + getHelpPopoverMaxWidth, + getHelpPopoverPreferredPosition, + getMissingHelpContentWarning, + helpIconSizeClasses, + resolveHelpContent, +} from '@/components/shared/helpIconModel'; +import type { HelpIconProps } from '@/components/shared/helpIconModel'; + +// Module-internal constants mirrored here so each geometry assertion is a +// concrete, hand-computed expected value rather than a re-derivation of source. +// VIEWPORT_PADDING (min inset from any viewport edge) = 8 +// POPOVER_OFFSET (gap between the anchor button and the popover) = 8 +const VIEWPORT_PADDING = 8; +const POPOVER_OFFSET = 8; + +// Minimal DOMRect builder. `calculateHelpPopoverPosition` reads +// `left`, `width`, `top`, `bottom` off buttonRect and `width`, `height` off +// popoverRect, so we derive `right`/`bottom` from the position + size to mirror +// real DOMRect semantics (callers pass only left/top/width/height). +const makeRect = (rect: { + left?: number; + top?: number; + width?: number; + height?: number; +}): DOMRect => { + const left = rect.left ?? 0; + const top = rect.top ?? 0; + const width = rect.width ?? 0; + const height = rect.height ?? 0; + return { + left, + top, + right: left + width, + bottom: top + height, + width, + height, + x: left, + y: top, + toJSON: () => ({}), + } as DOMRect; +}; + +describe('helpIconModel.branchcov0720pm', () => { + describe('resolveHelpContent — branch coverage', () => { + it('returns the inline-shaped HelpContent when props.inline is truthy (inline arm)', () => { + // The inline arm builds a brand-new object with id: 'inline' and copies + // title/description/examples/docUrl off props.inline verbatim. + const props: HelpIconProps = { + inline: { + title: 'Inline help', + description: 'Inline description', + examples: ['Example A', 'Example B'], + docUrl: 'https://example.com/docs/inline', + }, + }; + const result = resolveHelpContent(props); + expect(result).toEqual({ + id: 'inline', + title: 'Inline help', + description: 'Inline description', + examples: ['Example A', 'Example B'], + docUrl: 'https://example.com/docs/inline', + }); + }); + + it('omits examples/docUrl from the inline shape when the inline content omits them', () => { + // Same inline arm; the source spreads `examples: props.inline.examples` + // and `docUrl: props.inline.docUrl` directly, so undefined fields stay + // undefined and do not appear on the returned object. + const result = resolveHelpContent({ + inline: { title: 'Bare', description: 'No extras' }, + }); + expect(result).toEqual({ + id: 'inline', + title: 'Bare', + description: 'No extras', + examples: undefined, + docUrl: undefined, + }); + expect(result).not.toHaveProperty('related'); + expect(result).not.toHaveProperty('addedInVersion'); + }); + + it('prefers inline content over a contentId when both are supplied (inline arm wins)', () => { + // The inline check comes first, so a present `inline` short-circuits the + // contentId lookup entirely. + const result = resolveHelpContent({ + contentId: 'alerts.thresholds.delay', + inline: { title: 'Wins', description: 'Inline beats contentId' }, + }); + expect(result?.id).toBe('inline'); + expect(result?.title).toBe('Wins'); + }); + + it('delegates to getHelpContent when only props.contentId is set (contentId arm)', () => { + // Real registry hit: 'alerts.thresholds.delay' is a registered entry in + // src/content/help/alerts.ts, so we get the live HelpContent back. + const result = resolveHelpContent({ contentId: 'alerts.thresholds.delay' }); + expect(result).toBeDefined(); + expect(result?.id).toBe('alerts.thresholds.delay'); + expect(result?.title).toBe('Alert Delay (Sustained Duration)'); + expect(typeof result?.description).toBe('string'); + expect(result?.description).toContain('Alert delay'); + expect(result?.examples).toEqual( + expect.arrayContaining([expect.stringContaining('30 seconds')]), + ); + expect(result?.addedInVersion).toBe('v4.0.0'); + }); + + it('returns undefined from the contentId arm when the id is not in the registry', () => { + // getHelpContent returns undefined for an unknown id -> resolveHelpContent + // returns that undefined verbatim. + const result = resolveHelpContent({ contentId: 'no.such.content.id' }); + expect(result).toBeUndefined(); + }); + + it('returns undefined when neither inline nor contentId is supplied (neither arm)', () => { + // Both guards (props.inline, props.contentId) are falsy on an empty + // object -> falls through to the trailing `return undefined`. + expect(resolveHelpContent({})).toBeUndefined(); + }); + }); + + describe('calculateHelpPopoverPosition — top/bottom and viewport-flip branches', () => { + it("opens above the button when preferredPosition='top' and there is room (top-fits arm)", () => { + // viewport 1000x800, button at top:200 height:20 -> bottom:220. + // popover height 100 -> top = 200 - 100 - 8 = 92 (>= padding, no flip). + const result = calculateHelpPopoverPosition({ + buttonRect: makeRect({ left: 500, top: 200, width: 20, height: 20 }), + popoverRect: makeRect({ width: 200, height: 100 }), + preferredPosition: 'top', + viewportWidth: 1000, + viewportHeight: 800, + }); + // left = 500 + 20/2 - 200/2 = 500 + 10 - 100 = 410 (in range, no clamp). + expect(result).toEqual({ top: 200 - 100 - POPOVER_OFFSET, left: 410 }); + }); + + it("flips below the button when preferredPosition='top' would overflow the top of the viewport", () => { + // button top:50, popover height:100 -> top = 50 - 100 - 8 = -58 (< 8). + // Flip arm: top = buttonRect.bottom + POPOVER_OFFSET = 70 + 8 = 78. + const result = calculateHelpPopoverPosition({ + buttonRect: makeRect({ left: 500, top: 50, width: 20, height: 20 }), + popoverRect: makeRect({ width: 200, height: 100 }), + preferredPosition: 'top', + viewportWidth: 1000, + viewportHeight: 800, + }); + expect(result.top).toBe(50 + 20 + POPOVER_OFFSET); + expect(result.left).toBe(410); + }); + + it("opens below the button when preferredPosition='bottom' and there is room (bottom-fits arm)", () => { + // button bottom:420, popover height:100 -> top = 420 + 8 = 428. + // 428 + 100 = 528 <= 800 - 8 = 792 -> no flip. + const result = calculateHelpPopoverPosition({ + buttonRect: makeRect({ left: 500, top: 400, width: 20, height: 20 }), + popoverRect: makeRect({ width: 200, height: 100 }), + preferredPosition: 'bottom', + viewportWidth: 1000, + viewportHeight: 800, + }); + expect(result).toEqual({ top: 420 + POPOVER_OFFSET, left: 410 }); + }); + + it("flips above the button when preferredPosition='bottom' would overflow the bottom of the viewport", () => { + // button bottom:770, popover height:100 -> top = 770 + 8 = 778. + // 778 + 100 = 878 > 800 - 8 = 792 -> flip arm. + // top = buttonRect.top - popoverRect.height - POPOVER_OFFSET = 750 - 100 - 8 = 642. + const result = calculateHelpPopoverPosition({ + buttonRect: makeRect({ left: 500, top: 750, width: 20, height: 20 }), + popoverRect: makeRect({ width: 200, height: 100 }), + preferredPosition: 'bottom', + viewportWidth: 1000, + viewportHeight: 800, + }); + expect(result.top).toBe(750 - 100 - POPOVER_OFFSET); + expect(result.left).toBe(410); + }); + + it('clamps left to VIEWPORT_PADDING when the centered popover would overflow the left edge', () => { + // button at left:0 width:20 -> center at x=10 -> popover left = 10 - 100 = -90. + // max(8, min(-90, 792)) = max(8, -90) = 8. + const result = calculateHelpPopoverPosition({ + buttonRect: makeRect({ left: 0, top: 400, width: 20, height: 20 }), + popoverRect: makeRect({ width: 200, height: 100 }), + preferredPosition: 'top', + viewportWidth: 1000, + viewportHeight: 800, + }); + expect(result.left).toBe(VIEWPORT_PADDING); + // top axis is unaffected: 400 - 100 - 8 = 292. + expect(result.top).toBe(292); + }); + + it('clamps left to viewportWidth - popoverRect.width - VIEWPORT_PADDING when overflowing right', () => { + // button at left:990 width:20 -> center at x=1000 -> popover left = 1000 - 100 = 900. + // maxRight = 1000 - 200 - 8 = 792 -> min(900, 792) = 792 -> max(8, 792) = 792. + const result = calculateHelpPopoverPosition({ + buttonRect: makeRect({ left: 990, top: 400, width: 20, height: 20 }), + popoverRect: makeRect({ width: 200, height: 100 }), + preferredPosition: 'top', + viewportWidth: 1000, + viewportHeight: 800, + }); + expect(result.left).toBe(1000 - 200 - VIEWPORT_PADDING); + expect(result.top).toBe(292); + }); + + it('clamps the final top to VIEWPORT_PADDING when the post-flip value is still negative', () => { + // Edge case: top arm flips to bottom, but bottom also pushes past viewport + // bottom, so the trailing top clamp pulls it back down to VIEWPORT_PADDING. + // viewport 100x80, button top:0 height:4 bottom:4, popover height:100. + // top arm: top = 0 - 100 - 8 = -108 (< 8) -> flip -> top = 4 + 8 = 12. + // Final clamp: max(8, min(12, 80 - 100 - 8 = -28)) = max(8, -28) = 8. + const result = calculateHelpPopoverPosition({ + buttonRect: makeRect({ left: 10, top: 0, width: 4, height: 4 }), + popoverRect: makeRect({ width: 20, height: 100 }), + preferredPosition: 'top', + viewportWidth: 100, + viewportHeight: 80, + }); + expect(result.top).toBe(VIEWPORT_PADDING); + }); + + it('clamps the final top to viewportHeight - popoverRect.height - VIEWPORT_PADDING on the bottom arm', () => { + // viewport 100x80, button bottom near the bottom edge, popover taller than viewport. + // bottom arm: top = buttonRect.bottom + 8. + // Final clamp: max(8, min(top, 80 - 100 - 8 = -28)) = max(8, -28) = 8. + const result = calculateHelpPopoverPosition({ + buttonRect: makeRect({ left: 10, top: 70, width: 4, height: 4 }), + popoverRect: makeRect({ width: 20, height: 100 }), + preferredPosition: 'bottom', + viewportWidth: 100, + viewportHeight: 80, + }); + expect(result.top).toBe(VIEWPORT_PADDING); + }); + + it('returns a plain { top, left } object with no extra enumerable keys', () => { + const result = calculateHelpPopoverPosition({ + buttonRect: makeRect({ left: 100, top: 100, width: 10, height: 10 }), + popoverRect: makeRect({ width: 50, height: 50 }), + preferredPosition: 'top', + viewportWidth: 1000, + viewportHeight: 800, + }); + expect(Object.keys(result).sort()).toEqual(['left', 'top']); + expect(typeof result.left).toBe('number'); + expect(typeof result.top).toBe('number'); + }); + }); + + describe('getHelpIconSize — ?? fallback branches', () => { + it("returns 'sm' when size is omitted (right operand arm)", () => { + expect(getHelpIconSize(undefined)).toBe('sm'); + }); + + it('returns the supplied size verbatim when set (left operand arm)', () => { + expect(getHelpIconSize('xs')).toBe('xs'); + expect(getHelpIconSize('sm')).toBe('sm'); + expect(getHelpIconSize('md')).toBe('md'); + }); + }); + + describe('getHelpPopoverMaxWidth — ?? fallback branches', () => { + it('returns 320 when maxWidth is omitted (right operand arm)', () => { + expect(getHelpPopoverMaxWidth(undefined)).toBe(320); + }); + + it('returns the supplied maxWidth verbatim when set (left operand arm)', () => { + expect(getHelpPopoverMaxWidth(200)).toBe(200); + expect(getHelpPopoverMaxWidth(0)).toBe(0); + }); + }); + + describe('getHelpPopoverPreferredPosition — ?? fallback branches', () => { + it("returns 'top' when position is omitted (right operand arm)", () => { + expect(getHelpPopoverPreferredPosition(undefined)).toBe('top'); + }); + + it('returns the supplied position verbatim when set (left operand arm)', () => { + expect(getHelpPopoverPreferredPosition('top')).toBe('top'); + expect(getHelpPopoverPreferredPosition('bottom')).toBe('bottom'); + }); + }); + + describe('getMissingHelpContentWarning — branch coverage', () => { + it('returns undefined when contentId is falsy (early-return arm)', () => { + expect(getMissingHelpContentWarning(undefined)).toBeUndefined(); + expect(getMissingHelpContentWarning('')).toBeUndefined(); + }); + + it('formats the warning string with the supplied contentId (template arm)', () => { + expect(getMissingHelpContentWarning('alerts.thresholds.delay')).toBe( + '[HelpIcon] No content found for ID: alerts.thresholds.delay', + ); + expect(getMissingHelpContentWarning('some.missing.id')).toBe( + '[HelpIcon] No content found for ID: some.missing.id', + ); + }); + }); + + describe('helpIconSizeClasses — constant map contents', () => { + it('exposes a tailwind class for every declared size and nothing else', () => { + expect(Object.keys(helpIconSizeClasses).sort()).toEqual(['md', 'sm', 'xs']); + expect(helpIconSizeClasses.xs).toBe('w-3 h-3'); + expect(helpIconSizeClasses.sm).toBe('w-3.5 h-3.5'); + expect(helpIconSizeClasses.md).toBe('w-4 h-4'); + }); + }); +}); diff --git a/frontend-modern/src/components/shared/__tests__/selectionCardGroupModel.branchcov0720pm.test.ts b/frontend-modern/src/components/shared/__tests__/selectionCardGroupModel.branchcov0720pm.test.ts new file mode 100644 index 000000000..f60837f4f --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/selectionCardGroupModel.branchcov0720pm.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from 'vitest'; + +import { + getSelectionCardButtonClass, + getSelectionCardDescriptionClass, + getSelectionCardGroupClass, + getSelectionCardIconContainerClass, + getSelectionCardTitleClass, + resolveSelectionCardGroupVariant, + resolveSelectionCardTone, +} from '@/components/shared/selectionCardGroupModel'; +import type { + SelectionCardGroupVariant, + SelectionCardTone, +} from '@/components/shared/selectionCardGroupModel'; + +// Module-internal class-string fragments mirrored here so every assertion is a +// concrete, hand-copied expected value (no `?raw` source string reads, no +// snapshots, no constant-equals-itself tautologies). See +// src/components/shared/selectionCardGroupModel.ts. + +const GROUP_CLASS_COMPACT = 'grid grid-cols-2 gap-2'; +const GROUP_CLASS_DETAIL = 'grid grid-cols-1 gap-3'; + +const BUTTON_BASE_DETAIL = 'p-4 rounded-md border-2 transition-all text-left'; +const BUTTON_BASE_COMPACT = 'p-3 rounded-md border-2 transition-all text-center'; + +const ACTIVE_SUCCESS = 'border-green-500 bg-green-50 dark:bg-green-900'; +const ACTIVE_ACCENT = 'border-blue-500 bg-blue-50 dark:bg-blue-900'; + +const INACTIVE_COMPACT = 'border-border hover:border-blue-300'; +const INACTIVE_DETAIL = 'border-border hover:border-border'; + +const DISABLED_CLASSES = 'disabled:opacity-50 disabled:cursor-not-allowed'; + +describe('selectionCardGroupModel.branchcov0720pm', () => { + describe('resolveSelectionCardGroupVariant', () => { + it("defaults to 'detail' when variant is undefined (?? right arm)", () => { + expect(resolveSelectionCardGroupVariant(undefined)).toBe('detail'); + }); + + it("returns 'compact' verbatim when explicitly passed (identity arm)", () => { + expect(resolveSelectionCardGroupVariant('compact')).toBe('compact'); + }); + + it("returns 'detail' verbatim when explicitly passed (identity arm)", () => { + expect(resolveSelectionCardGroupVariant('detail')).toBe('detail'); + }); + }); + + describe('resolveSelectionCardTone', () => { + it("defaults to 'accent' when tone is undefined (?? right arm)", () => { + expect(resolveSelectionCardTone(undefined)).toBe('accent'); + }); + + it("returns 'accent' verbatim when explicitly passed (identity arm)", () => { + expect(resolveSelectionCardTone('accent')).toBe('accent'); + }); + + it("returns 'success' verbatim when explicitly passed (identity arm)", () => { + expect(resolveSelectionCardTone('success')).toBe('success'); + }); + }); + + describe('getSelectionCardGroupClass', () => { + it("emits the detail grid tokens when variant='detail' and no className is supplied (?? '' arm, then trim)", () => { + // `${groupClassByVariant[variant]} ${className ?? ''}`.trim() + // With className undefined the trailing ' ' is trimmed away. + expect(getSelectionCardGroupClass('detail', undefined)).toBe(GROUP_CLASS_DETAIL); + }); + + it("emits the compact grid tokens when variant='compact' and no className is supplied", () => { + expect(getSelectionCardGroupClass('compact', undefined)).toBe(GROUP_CLASS_COMPACT); + }); + + it("appends a caller-supplied className for variant='detail' (className truthy arm)", () => { + expect(getSelectionCardGroupClass('detail', 'mt-4')).toBe(`${GROUP_CLASS_DETAIL} mt-4`); + }); + + it("appends a caller-supplied className for variant='compact' (className truthy arm)", () => { + expect(getSelectionCardGroupClass('compact', 'sm:grid-cols-3')).toBe( + `${GROUP_CLASS_COMPACT} sm:grid-cols-3`, + ); + }); + }); + + describe('getSelectionCardButtonClass', () => { + it("uses the detail (text-left) base, accent active classes, and skips the disabled tail when disabled=false and active=true with tone='accent'", () => { + // base: variant === 'detail' true arm. + // active true + tone accent -> getSelectionCardActiveClass('accent'). + // disabled false -> '' third slot, which still contributes a single space. + const expected = [BUTTON_BASE_DETAIL, ACTIVE_ACCENT, ''].join(' '); + expect(getSelectionCardButtonClass('detail', 'accent', true, false)).toBe(expected); + // Sanity-check the three observable fragments directly. + expect(getSelectionCardButtonClass('detail', 'accent', true, false)).toContain( + BUTTON_BASE_DETAIL, + ); + expect(getSelectionCardButtonClass('detail', 'accent', true, false)).toContain(ACTIVE_ACCENT); + expect(getSelectionCardButtonClass('detail', 'accent', true, false)).not.toContain( + DISABLED_CLASSES, + ); + }); + + it("uses the success active classes for tone='success' (getSelectionCardActiveClass success arm)", () => { + const result = getSelectionCardButtonClass('detail', 'success', true, false); + expect(result).toContain(ACTIVE_SUCCESS); + expect(result).not.toContain(ACTIVE_ACCENT); + }); + + it("uses the compact (text-center) base when variant='compact' (variant === 'detail' false arm)", () => { + const result = getSelectionCardButtonClass('compact', 'accent', true, false); + expect(result).toContain(BUTTON_BASE_COMPACT); + expect(result).not.toContain(BUTTON_BASE_DETAIL); + }); + + it("uses the compact inactive classes when active=false and variant='compact' (getSelectionCardInactiveClass compact arm)", () => { + const result = getSelectionCardButtonClass('compact', 'accent', false, false); + expect(result).toContain(INACTIVE_COMPACT); + expect(result).not.toContain(INACTIVE_DETAIL); + expect(result).not.toContain(ACTIVE_ACCENT); + }); + + it("uses the detail inactive classes when active=false and variant='detail' (getSelectionCardInactiveClass else arm)", () => { + const result = getSelectionCardButtonClass('detail', 'accent', false, false); + expect(result).toContain(INACTIVE_DETAIL); + expect(result).not.toContain(INACTIVE_COMPACT); + }); + + it("appends the disabled tail when disabled=true (disabled ? ... : '' true arm)", () => { + const result = getSelectionCardButtonClass('detail', 'accent', false, true); + expect(result).toContain(DISABLED_CLASSES); + // The button is still inactive here, so the inactive-detail classes must + // remain present alongside the disabled tail. + expect(result).toContain(INACTIVE_DETAIL); + }); + + it('joins base + active + disabled into a single string when all three slots are populated', () => { + const expected = [BUTTON_BASE_DETAIL, ACTIVE_SUCCESS, DISABLED_CLASSES].join(' '); + expect(getSelectionCardButtonClass('detail', 'success', true, true)).toBe(expected); + }); + }); + + describe('getSelectionCardIconContainerClass', () => { + it("emits the green active container when tone='success' and active=true (tone === 'success' arm)", () => { + const result = getSelectionCardIconContainerClass('success', true); + expect(result).toBe('p-2 rounded-md bg-green-100 dark:bg-green-800'); + expect(result).toContain('bg-green-100'); + expect(result).toContain('dark:bg-green-800'); + }); + + it("emits the blue active container when tone='accent' and active=true (else arm)", () => { + const result = getSelectionCardIconContainerClass('accent', true); + expect(result).toBe('p-2 rounded-md bg-blue-100 dark:bg-blue-800'); + expect(result).toContain('bg-blue-100'); + expect(result).not.toContain('bg-green-100'); + }); + + it('emits the surface-alt container when active=false regardless of tone (active false arm)', () => { + expect(getSelectionCardIconContainerClass('accent', false)).toBe( + 'p-2 rounded-md bg-surface-alt', + ); + // Same surface-alt path for the success tone proves the inactive arm is + // independent of tone. + expect(getSelectionCardIconContainerClass('success', false)).toBe( + 'p-2 rounded-md bg-surface-alt', + ); + }); + }); + + describe('getSelectionCardTitleClass', () => { + it("returns the compact title class for variant='compact' regardless of tone/active (variant === 'compact' early return)", () => { + const expected = 'text-sm font-medium text-base-content'; + // active true, success tone — but compact short-circuits before tone is read. + expect(getSelectionCardTitleClass('compact', 'success', true)).toBe(expected); + // active false, accent tone — same early return. + expect(getSelectionCardTitleClass('compact', 'accent', false)).toBe(expected); + }); + + it("returns the muted title class for variant='detail' and active=false (!active early return)", () => { + // tone is irrelevant on this arm; only the !active check matters. + expect(getSelectionCardTitleClass('detail', 'success', false)).toBe( + 'text-sm font-semibold text-base-content', + ); + expect(getSelectionCardTitleClass('detail', 'accent', false)).toBe( + 'text-sm font-semibold text-base-content', + ); + }); + + it("returns the green title class for variant='detail', active=true, tone='success' (tone === 'success' arm)", () => { + expect(getSelectionCardTitleClass('detail', 'success', true)).toBe( + 'text-sm font-semibold text-green-900 dark:text-green-100', + ); + }); + + it("returns the blue title class for variant='detail', active=true, tone='accent' (else arm)", () => { + expect(getSelectionCardTitleClass('detail', 'accent', true)).toBe( + 'text-sm font-semibold text-blue-900 dark:text-blue-100', + ); + }); + }); + + describe('getSelectionCardDescriptionClass', () => { + it("emits the compact description class for variant='compact' (variant === 'compact' true arm)", () => { + expect(getSelectionCardDescriptionClass('compact')).toBe('text-xs text-slate-500 mt-0.5'); + }); + + it("emits the detail description class for variant='detail' (else arm)", () => { + expect(getSelectionCardDescriptionClass('detail')).toBe('text-xs text-muted'); + }); + }); + + // Cross-product sanity sweep — locks the tone x variant x active matrix that + // callers rely on so a future refactor cannot silently swap a class fragment + // for a different arm. Each cell asserts the tone/variant/active-specific + // tokens that the source actually emits for that combination. + describe('tone x variant x active matrix', () => { + const tones: SelectionCardTone[] = ['accent', 'success']; + const variants: SelectionCardGroupVariant[] = ['compact', 'detail']; + + it('selects the green or blue active class on the button based on tone, for every variant', () => { + for (const variant of variants) { + for (const tone of tones) { + const result = getSelectionCardButtonClass(variant, tone, true, false); + const activeToken = + tone === 'success' ? 'border-green-500 bg-green-50' : 'border-blue-500 bg-blue-50'; + expect(result).toContain(activeToken); + } + } + }); + + it('selects the inactive class on the button based on variant, not tone, when active=false', () => { + for (const variant of variants) { + for (const tone of tones) { + const result = getSelectionCardButtonClass(variant, tone, false, false); + const inactiveToken = + variant === 'compact' ? 'hover:border-blue-300' : 'hover:border-border'; + expect(result).toContain(inactiveToken); + // No active classes leak through when inactive. + expect(result).not.toContain('bg-green-50'); + expect(result).not.toContain('bg-blue-50'); + } + } + }); + + it('routes the icon container class by tone when active and by surface-alt when inactive', () => { + for (const tone of tones) { + const activeResult = getSelectionCardIconContainerClass(tone, true); + expect(activeResult).toContain(tone === 'success' ? 'bg-green-100' : 'bg-blue-100'); + + const inactiveResult = getSelectionCardIconContainerClass(tone, false); + expect(inactiveResult).toContain('bg-surface-alt'); + } + }); + + it('switches the detail-mode active title colour between blue and green by tone', () => { + for (const tone of tones) { + const result = getSelectionCardTitleClass('detail', tone, true); + const titleToken = + tone === 'success' + ? 'text-green-900 dark:text-green-100' + : 'text-blue-900 dark:text-blue-100'; + expect(result).toContain(titleToken); + } + }); + + it('keeps the compact title class tone- and active-independent across the matrix', () => { + for (const tone of tones) { + for (const active of [true, false]) { + expect(getSelectionCardTitleClass('compact', tone, active)).toBe( + 'text-sm font-medium text-base-content', + ); + } + } + }); + }); +}); diff --git a/frontend-modern/src/components/shared/__tests__/tagInputModel.branchcov0720pm.test.ts b/frontend-modern/src/components/shared/__tests__/tagInputModel.branchcov0720pm.test.ts new file mode 100644 index 000000000..ff6e25623 --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/tagInputModel.branchcov0720pm.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; + +import { + canAddTag, + getNextTagsAfterRemove, + getTagInputPlaceholder, +} from '@/components/shared/tagInputModel'; + +// Every assertion below is a hand-computed expected value against the real +// runtime output of the three branching helpers in tagInputModel.ts (no +// `?raw` source-string reads, no snapshots, no constant-equals-itself +// tautologies). See src/components/shared/tagInputModel.ts. + +describe('tagInputModel.branchcov0720pm', () => { + describe('getTagInputPlaceholder', () => { + // `tagCount === 0 ? (placeholder ?? '') : ''` — three arms: the + // tagCount===0 branch with a truthy placeholder, the `?? ''` fallback when + // placeholder is omitted, and the tagCount>0 else arm that always yields ''. + + it('returns the placeholder verbatim when tagCount is 0 and a placeholder is supplied (ternary true arm)', () => { + expect(getTagInputPlaceholder(0, 'Add tags…')).toBe('Add tags…'); + }); + + it('falls back to the empty string when tagCount is 0 and placeholder is undefined (?? right arm)', () => { + expect(getTagInputPlaceholder(0, undefined)).toBe(''); + }); + + it('falls back to the empty string when tagCount is 0 and placeholder is omitted entirely (?? right arm, parameter missing)', () => { + // No second argument at all — same `?? ''` arm as passing undefined. + expect(getTagInputPlaceholder(0)).toBe(''); + }); + + it('returns the empty string when tagCount > 0 regardless of placeholder (ternary false arm, placeholder supplied)', () => { + // A populated tag list hides the placeholder even when one is provided. + expect(getTagInputPlaceholder(3, 'Add tags…')).toBe(''); + }); + + it('returns the empty string when tagCount > 0 and no placeholder is supplied (ternary false arm, placeholder missing)', () => { + expect(getTagInputPlaceholder(1, undefined)).toBe(''); + }); + + it('returns the empty string when tagCount is large and a placeholder is supplied (ternary false arm, far side)', () => { + expect(getTagInputPlaceholder(1000, 'Add tags…')).toBe(''); + }); + }); + + describe('getNextTagsAfterRemove', () => { + // `tags.filter((_, index) => index !== indexToRemove)` — the filter + // callback branches true (keep) for non-matching indices and false (drop) + // for the matching index. Each case locks the observable output array. + + it('removes the target tag at the given index and leaves the others untouched (middle element)', () => { + expect(getNextTagsAfterRemove(['a', 'b', 'c'], 1)).toEqual(['a', 'c']); + }); + + it('removes the first tag when indexToRemove === 0 (head element)', () => { + expect(getNextTagsAfterRemove(['a', 'b', 'c'], 0)).toEqual(['b', 'c']); + }); + + it('removes the last tag when indexToRemove === tags.length - 1 (tail element)', () => { + expect(getNextTagsAfterRemove(['a', 'b', 'c'], 2)).toEqual(['a', 'b']); + }); + + it('returns all tags unchanged when indexToRemove matches no index (out-of-range high)', () => { + // No index satisfies `index === indexToRemove`, so every element is kept. + expect(getNextTagsAfterRemove(['a', 'b', 'c'], 99)).toEqual(['a', 'b', 'c']); + }); + + it('returns all tags unchanged when indexToRemove is negative (no index matches)', () => { + // filter indices are 0-based and non-negative; -1 never matches. + expect(getNextTagsAfterRemove(['a', 'b', 'c'], -1)).toEqual(['a', 'b', 'c']); + }); + + it('returns a new empty array when removing from a single-element list at index 0', () => { + const result = getNextTagsAfterRemove(['only'], 0); + expect(result).toEqual([]); + // Defensive: confirm a fresh array, not the same reference. + expect(result).not.toBe(['only']); + }); + + it('returns a new array (does not mutate the input) and drops only the first match', () => { + // Duplicate tag values are independent by index — only index 1 is dropped. + const input = ['x', 'x', 'y']; + const result = getNextTagsAfterRemove(input, 1); + expect(result).toEqual(['x', 'y']); + expect(input).toEqual(['x', 'x', 'y']); + }); + }); + + describe('canAddTag', () => { + // `Boolean(value) && !tags.includes(value)` — short-circuit AND with two + // distinct false arms (empty value, duplicate value) and one true arm. + + it('returns true when value is non-empty and not already present (Boolean truthy && !includes true)', () => { + expect(canAddTag(['a', 'b'], 'c')).toBe(true); + }); + + it('returns true when the tags list is empty and value is non-empty (truthy && !includes on empty list)', () => { + expect(canAddTag([], 'first')).toBe(true); + }); + + it('returns false when value is an empty string (Boolean(value) falsy short-circuit)', () => { + // Empty string never reaches the .includes check. + expect(canAddTag(['a', 'b'], '')).toBe(false); + }); + + it('returns false when value is already in the tags list (duplicate, !includes false arm)', () => { + expect(canAddTag(['a', 'b', 'c'], 'b')).toBe(false); + }); + + it('returns false when the duplicate check fires even on an otherwise-empty list', () => { + // Single-element duplicate must still be rejected. + expect(canAddTag(['only'], 'only')).toBe(false); + }); + + it('treats a whitespace-only value as truthy (Boolean(" ") is true) and adds it when not duplicated', () => { + // NOTE: this is a real behaviour assertion — canAddTag does NOT trim, + // so a whitespace value passes the Boolean check. Trimming is the + // caller's responsibility (normalizeTagInputValue). + expect(canAddTag([], ' ')).toBe(true); + }); + + it('treats the string "0" as a valid addable tag (Boolean("0") is true)', () => { + expect(canAddTag([], '0')).toBe(true); + }); + }); +}); diff --git a/frontend-modern/src/i18n/__tests__/locales.branchcov0720pm.test.ts b/frontend-modern/src/i18n/__tests__/locales.branchcov0720pm.test.ts new file mode 100644 index 000000000..0ab69438e --- /dev/null +++ b/frontend-modern/src/i18n/__tests__/locales.branchcov0720pm.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_LOCALE, + SUPPORTED_LOCALES, + getLocaleFallbackChain, + resolveSupportedLocale, +} from '@/i18n/locales'; + +// `LOCALE_ALIASES`, `SUPPORTED_LOCALE_SET`, `isSupportedLocale`, and +// `normalizeLocale` are module-private (not exported); their branches are +// exercised transitively through `resolveSupportedLocale` and +// `getLocaleFallbackChain`, asserting on the observable return values. + +describe('resolveSupportedLocale — branch coverage', () => { + describe('(a) direct supported-locale hit — isSupportedLocale(normalized) true arm', () => { + it('returns every supported locale verbatim (locks the supported set)', () => { + expect(SUPPORTED_LOCALES).toEqual(['en', 'de', 'es']); + for (const locale of SUPPORTED_LOCALES) { + expect(resolveSupportedLocale(locale)).toBe(locale); + } + }); + + it('lowercases upper/mixed-case input before the direct lookup (toLowerCase step)', () => { + // 'EN' -> trim -> 'EN' -> replace _ -> 'EN' -> toLowerCase -> 'en' -> direct hit. + expect(resolveSupportedLocale('EN')).toBe('en'); + expect(resolveSupportedLocale('De')).toBe('de'); + expect(resolveSupportedLocale('eS')).toBe('es'); + }); + }); + + describe('(b) normalization arm (trim + underscore->hyphen)', () => { + it('trims surrounding whitespace before the direct-hit lookup', () => { + // ' en ' -> trim -> 'en' -> direct hit. + expect(resolveSupportedLocale(' en ')).toBe('en'); + // trim() also strips tabs and newlines. + expect(resolveSupportedLocale('\tde\n')).toBe('de'); + }); + + it('rewrites underscores to hyphens before the alias lookup', () => { + // 'es_MX' -> 'es-MX' -> toLowerCase 'es-mx' -> LOCALE_ALIASES hit -> 'es'. + expect(resolveSupportedLocale('es_MX')).toBe('es'); + // 'DE_AT' exercises both toLowerCase and underscore->hyphen before the alias hit. + expect(resolveSupportedLocale('DE_AT')).toBe('de'); + }); + + it('rewrites underscores to hyphens before the base-locale fallback', () => { + // 'de_XX' (no alias entry) -> 'de-xx' -> not alias -> base 'de' supported -> 'de'. + expect(resolveSupportedLocale('de_XX')).toBe('de'); + }); + }); + + describe('(c) LOCALE_ALIASES lookup hit arm', () => { + it('maps English regional variants to en', () => { + expect(resolveSupportedLocale('en-GB')).toBe('en'); + expect(resolveSupportedLocale('en-US')).toBe('en'); + }); + + it('maps German regional variants to de', () => { + expect(resolveSupportedLocale('de-AT')).toBe('de'); + expect(resolveSupportedLocale('de-CH')).toBe('de'); + expect(resolveSupportedLocale('de-DE')).toBe('de'); + }); + + it('maps Spanish regional variants (including es-419) to es', () => { + expect(resolveSupportedLocale('es-419')).toBe('es'); + expect(resolveSupportedLocale('es-AR')).toBe('es'); + expect(resolveSupportedLocale('es-MX')).toBe('es'); + expect(resolveSupportedLocale('es-ES')).toBe('es'); + expect(resolveSupportedLocale('es-US')).toBe('es'); + }); + }); + + describe('(d) base-locale fallback arm (region unlisted, base supported)', () => { + it('falls back to the base locale when the region is not aliased but the base is supported', () => { + // 'de-XX' -> not direct, not in LOCALE_ALIASES, base 'de' supported -> 'de'. + expect(resolveSupportedLocale('de-XX')).toBe('de'); + // 'en-CA' -> not in alias map -> base 'en' supported -> 'en'. + expect(resolveSupportedLocale('en-CA')).toBe('en'); + // 'es-EC' -> not in alias map -> base 'es' supported -> 'es'. + expect(resolveSupportedLocale('es-EC')).toBe('es'); + }); + }); + + describe('null/false arm — base unsupported or input falsy', () => { + it('returns null when the base locale is itself unsupported (ternary false arm)', () => { + // 'fr-FR' -> not direct, not alias, base 'fr' NOT supported -> null. + expect(resolveSupportedLocale('fr-FR')).toBeNull(); + // 'zh-Hans' -> base 'zh' not supported -> null. + expect(resolveSupportedLocale('zh-Hans')).toBeNull(); + // 'pt-BR' -> base 'pt' not supported -> null. + expect(resolveSupportedLocale('pt-BR')).toBeNull(); + // 'ja-JP' -> base 'ja' not supported -> null. + expect(resolveSupportedLocale('ja-JP')).toBeNull(); + // A bare unsupported code with no region also returns null. + expect(resolveSupportedLocale('fr')).toBeNull(); + expect(resolveSupportedLocale('xyz')).toBeNull(); + }); + + it('returns null for null input (optional-chain short-circuits to undefined)', () => { + // value?.trim().replace().toLowerCase() -> undefined -> !normalized -> null. + expect(resolveSupportedLocale(null)).toBeNull(); + }); + + it('returns null for undefined input (optional-chain short-circuits to undefined)', () => { + expect(resolveSupportedLocale(undefined)).toBeNull(); + }); + + it('returns null for the empty string (normalized is falsy)', () => { + // '' -> trim '' -> replace '' -> toLowerCase '' -> !normalized -> null. + expect(resolveSupportedLocale('')).toBeNull(); + }); + + it('returns null for a whitespace-only string (trims to empty -> falsy)', () => { + expect(resolveSupportedLocale(' ')).toBeNull(); + expect(resolveSupportedLocale('\t\n')).toBeNull(); + }); + }); +}); + +describe('getLocaleFallbackChain — branch coverage', () => { + describe('single-element chain arm (locale === fallbackLocale)', () => { + it('returns [locale] when the resolved locale is its own fallback (en)', () => { + // SUPPORTED_LOCALE_REGISTRY.en.fallbackLocale === 'en' -> locale === fallbackLocale + // -> ternary true -> [locale]. + expect(getLocaleFallbackChain('en')).toEqual(['en']); + }); + + it('returns a single-element chain when a regional alias resolves to en', () => { + // 'en-GB' -> resolveSupportedLocale -> 'en' -> 'en' === 'en' -> ['en']. + expect(getLocaleFallbackChain('en-GB')).toEqual(['en']); + }); + + it('returns a single-element chain for null/undefined/empty input (defaults to en)', () => { + // normalizeLocale falls back to DEFAULT_LOCALE -> ['en']. + expect(DEFAULT_LOCALE).toBe('en'); + expect(getLocaleFallbackChain(null)).toEqual(['en']); + expect(getLocaleFallbackChain(undefined)).toEqual(['en']); + expect(getLocaleFallbackChain('')).toEqual(['en']); + }); + + it('returns a single-element chain for an unsupported locale (defaults to en)', () => { + // 'fr-FR' resolves to null -> normalizeLocale -> DEFAULT_LOCALE 'en' -> ['en']. + expect(getLocaleFallbackChain('fr-FR')).toEqual(['en']); + }); + }); + + describe('multi-element chain arm (locale !== fallbackLocale)', () => { + it('returns [locale, fallback] for the de supported locale', () => { + // SUPPORTED_LOCALE_REGISTRY.de.fallbackLocale === 'en', 'de' !== 'en' + // -> ternary false -> [locale, fallbackLocale]. + expect(getLocaleFallbackChain('de')).toEqual(['de', 'en']); + }); + + it('returns [locale, fallback] for the es supported locale', () => { + expect(getLocaleFallbackChain('es')).toEqual(['es', 'en']); + }); + + it('returns a multi-element chain when a regional alias resolves to de/es', () => { + expect(getLocaleFallbackChain('de-AT')).toEqual(['de', 'en']); + expect(getLocaleFallbackChain('es-MX')).toEqual(['es', 'en']); + }); + + it('returns a multi-element chain when a base-fallback region resolves to de', () => { + // 'de-XX' base-fallback -> 'de' -> ['de', 'en']. + expect(getLocaleFallbackChain('de-XX')).toEqual(['de', 'en']); + }); + }); + + describe('return shape', () => { + it('always emits members of SUPPORTED_LOCALES', () => { + for (const input of ['en', 'de', 'es', 'en-GB', 'de-AT', 'es-MX', 'de-XX', 'fr-FR', null]) { + const chain = getLocaleFallbackChain(input); + for (const member of chain) { + expect((SUPPORTED_LOCALES as readonly string[]).includes(member)).toBe(true); + } + } + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/agentInstallCommand.branchcov0720pm.test.ts b/frontend-modern/src/utils/__tests__/agentInstallCommand.branchcov0720pm.test.ts new file mode 100644 index 000000000..f29ce2e92 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/agentInstallCommand.branchcov0720pm.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; + +import { buildPowerShellInstallScriptBootstrap } from '@/utils/agentInstallCommand'; + +// `normalizeInstallerBaseUrl` and `powerShellQuote` are module-private to the +// bootstrap builder's call graph; their branches are exercised transitively +// through the single exported entry point below, asserting on the observable +// PowerShell string that the builder emits. + +describe('buildPowerShellInstallScriptBootstrap — URL validation branch coverage', () => { + it('throws the required-URL error when baseUrl is the empty string', () => { + expect(() => buildPowerShellInstallScriptBootstrap('')).toThrow( + 'Pulse install endpoint URL is required.', + ); + }); + + it('throws the required-URL error when baseUrl is whitespace-only', () => { + // normalizeInstallerBaseUrl does not trim; only the post-normalize .trim() + // guard catches this. Asserts the guard fires on whitespace-only input. + expect(() => buildPowerShellInstallScriptBootstrap(' ')).toThrow( + 'Pulse install endpoint URL is required.', + ); + }); + + it('throws the required-URL error when baseUrl is only trailing slashes', () => { + // normalizeInstallerBaseUrl strips the slashes -> '' -> trim() === '' -> throw. + expect(() => buildPowerShellInstallScriptBootstrap('///')).toThrow( + 'Pulse install endpoint URL is required.', + ); + }); + + it('does NOT throw on the non-empty happy path (https URL)', () => { + expect(() => buildPowerShellInstallScriptBootstrap('https://pulse.example')).not.toThrow(); + }); + + it('does NOT throw on the non-empty happy path (plain-http URL)', () => { + expect(() => buildPowerShellInstallScriptBootstrap('http://pulse.example:7655')).not.toThrow(); + }); +}); + +describe('buildPowerShellInstallScriptBootstrap — baseUrl normalization branch coverage', () => { + it('strips a single trailing slash before appending /install.ps1', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example/'); + expect(script).toContain('$pulseScriptUrl="https://pulse.example/install.ps1"'); + expect(script).not.toContain('https://pulse.example//install.ps1'); + }); + + it('strips multiple trailing slashes before appending /install.ps1', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example/base///'); + expect(script).toContain('$pulseScriptUrl="https://pulse.example/base/install.ps1"'); + expect(script).not.toContain('https://pulse.example/base//install.ps1'); + expect(script).not.toContain('https://pulse.example/base///install.ps1'); + }); + + it('preserves a baseUrl that already has no trailing slash (no-op normalization arm)', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example:7655'); + expect(script).toContain('$pulseScriptUrl="https://pulse.example:7655/install.ps1"'); + }); + + it('does not rewrite the URL scheme (https stays https, http stays http)', () => { + // The builder performs no scheme upgrade/downgrade — it only strips trailing slashes. + const httpsScript = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + const httpScript = buildPowerShellInstallScriptBootstrap('http://pulse.example'); + expect(httpsScript).toContain('$pulseScriptUrl="https://pulse.example/install.ps1"'); + expect(httpScript).toContain('$pulseScriptUrl="http://pulse.example/install.ps1"'); + }); +}); + +describe('buildPowerShellInstallScriptBootstrap — bootstrap script wiring', () => { + it('emits the $pulseScriptUrl assignment as the first statement inside the script block', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + expect(script.startsWith('& { $pulseScriptUrl="https://pulse.example/install.ps1"; ')).toBe( + true, + ); + }); + + it('gates the custom-CA / insecure branch on the PULSE_INSECURE_SKIP_VERIFY env var', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + expect(script).toContain('if ($env:PULSE_INSECURE_SKIP_VERIFY -eq "true"'); + }); + + it('also gates the custom-CA / insecure branch on the PULSE_CACERT env var (OR arm)', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + expect(script).toContain('-or -not [string]::IsNullOrWhiteSpace($env:PULSE_CACERT))'); + }); + + it('reads the custom CA bytes from $env:PULSE_CACERT when populated', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + expect(script).toContain('if (-not [string]::IsNullOrWhiteSpace($env:PULSE_CACERT)) {'); + expect(script).toContain( + '$pulseCustomCaBytes = [System.IO.File]::ReadAllBytes($env:PULSE_CACERT);', + ); + }); + + it('routes a PEM-encoded CA through X509Certificate2::CreateFromPem', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + expect(script).toContain('if ($pulseCustomCaText.Contains("-----BEGIN CERTIFICATE-----"))'); + expect(script).toContain( + '$pulseCustomCa = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromPem($pulseCustomCaText)', + ); + }); + + it('routes a DER-encoded CA through X509Certificate2::new (else arm)', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + expect(script).toContain( + '$pulseCustomCa = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pulseCustomCaBytes)', + ); + }); + + it('installs the X509 chain-validation ServerCertificateValidationCallback', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + expect(script).toContain( + '$pulsePrev = [System.Net.ServicePointManager]::ServerCertificateValidationCallback;', + ); + expect(script).toContain( + '[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { param($sender, $certificate, $chain, $sslPolicyErrors)', + ); + expect(script).toContain( + '} finally { [System.Net.ServicePointManager]::ServerCertificateValidationCallback = $pulsePrev }', + ); + }); + + it('short-circuits the callback to $true when PULSE_INSECURE_SKIP_VERIFY is "true"', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + expect(script).toContain('if ($env:PULSE_INSECURE_SKIP_VERIFY -eq "true") { return $true };'); + }); + + it('returns the raw sslPolicyErrors verdict when no custom CA was loaded', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + expect(script).toContain( + 'if ($null -eq $pulseCustomCa) { return $sslPolicyErrors -eq [System.Net.Security.SslPolicyErrors]::None };', + ); + }); + + it('returns $false when the server supplied no certificate', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + expect(script).toContain('if ($null -eq $certificate) { return $false };'); + }); + + it('builds an X509Chain with NoCheck revocation and the custom CA in ExtraStore', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + expect(script).toContain( + '$pulseChain = [System.Security.Cryptography.X509Certificates.X509Chain]::new();', + ); + expect(script).toContain( + '$pulseChain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck;', + ); + expect(script).toContain('$null = $pulseChain.ChainPolicy.ExtraStore.Add($pulseCustomCa);'); + expect(script).toContain('$null = $pulseChain.Build($certificate);'); + }); + + it('walks ChainElements and trusts the chain when the custom CA Thumbprint matches', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + expect(script).toContain('foreach ($pulseElement in $pulseChain.ChainElements) {'); + expect(script).toContain( + 'if ($pulseElement.Certificate.Thumbprint -eq $pulseCustomCa.Thumbprint) { return $true }', + ); + expect(script).toContain('return $false };'); + }); + + it('fetches the script via `irm $pulseScriptUrl` inside both the custom-trust and the bare else arm', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + // custom-trust arm: + expect(script).toContain('irm $pulseScriptUrl '); + // bare else arm (no env-var set): + expect(script).toContain('} else { irm $pulseScriptUrl } } | iex'); + }); + + it('pipes the entire bootstrap through `| iex` so it is executed inline', () => { + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + expect(script.endsWith('} | iex')).toBe(true); + }); + + it('does not PowerShell-escape a plain https URL (no backticks/quotes injected)', () => { + // powerShellQuote only transforms `, ", and $ — none appear in a bare URL, + // so the scriptUrl should pass through verbatim. + const script = buildPowerShellInstallScriptBootstrap('https://pulse.example'); + expect(script).toContain('$pulseScriptUrl="https://pulse.example/install.ps1"'); + }); +});