mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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.
This commit is contained in:
+121
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+138
@@ -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);
|
||||
});
|
||||
});
|
||||
+148
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+317
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+276
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+127
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user