diff --git a/frontend-modern/src/features/docker/__tests__/dockerHostDrawerModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerHostDrawerModel.test.ts new file mode 100644 index 0000000000..d97cb88d66 --- /dev/null +++ b/frontend-modern/src/features/docker/__tests__/dockerHostDrawerModel.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest'; + +import type { Resource } from '@/types/resource'; + +import { + DOCKER_HOST_DRAWER_HISTORY_GROUPS, + getDockerHostDrawerHistoryFallbackMetrics, + getDockerHostDrawerHistoryTarget, +} from '../dockerHostDrawerModel'; + +type HostOverrides = { + agent?: { agentId?: string } | undefined; + id?: string; + name?: string; + temperature?: number; + docker?: { temperature?: number } | undefined; +}; + +// Minimal Resource projection for the two functions under test. id/name are +// required strings on Resource but the lookup functions must tolerate empty +// strings, so the factory defaults them to '' (which yields a null target). +const makeHost = (over: HostOverrides = {}): Resource => + ({ + id: over.id ?? '', + name: over.name ?? '', + type: 'docker-host', + agent: over.agent, + temperature: over.temperature, + docker: over.docker, + }) as unknown as Resource; + +describe('dockerHostDrawerModel', () => { + describe('getDockerHostDrawerHistoryTarget', () => { + it('builds an agent-scoped target from the agent agentId', () => { + expect(getDockerHostDrawerHistoryTarget(makeHost({ agent: { agentId: 'agent-9' } }))).toEqual({ + resourceType: 'agent', + resourceId: 'agent-9', + }); + }); + + it('strips a leading agent: prefix from the agentId', () => { + expect( + getDockerHostDrawerHistoryTarget(makeHost({ agent: { agentId: 'agent:agent-9' } })), + ).toEqual({ resourceType: 'agent', resourceId: 'agent-9' }); + }); + + it('only strips the agent: prefix once', () => { + expect( + getDockerHostDrawerHistoryTarget(makeHost({ agent: { agentId: 'agent:agent:foo' } })), + ).toEqual({ resourceType: 'agent', resourceId: 'agent:foo' }); + }); + + it('leaves an agentId that does not start with agent: untouched', () => { + expect( + getDockerHostDrawerHistoryTarget(makeHost({ agent: { agentId: 'node-1' } })), + ).toEqual({ resourceType: 'agent', resourceId: 'node-1' }); + }); + + it('trims surrounding whitespace before resolving the id', () => { + expect( + getDockerHostDrawerHistoryTarget(makeHost({ agent: { agentId: ' agent:foo ' } })), + ).toEqual({ resourceType: 'agent', resourceId: 'foo' }); + }); + + it('falls back to the resource id when no agentId is reported', () => { + expect(getDockerHostDrawerHistoryTarget(makeHost({ id: 'host-1' }))).toEqual({ + resourceType: 'agent', + resourceId: 'host-1', + }); + }); + + it('strips the agent: prefix from the id fallback too', () => { + expect( + getDockerHostDrawerHistoryTarget(makeHost({ id: 'agent:host-1' })), + ).toEqual({ resourceType: 'agent', resourceId: 'host-1' }); + }); + + it('falls back to the resource name when neither agentId nor id are present', () => { + expect(getDockerHostDrawerHistoryTarget(makeHost({ name: 'edge-01' }))).toEqual({ + resourceType: 'agent', + resourceId: 'edge-01', + }); + }); + + it('prefers agentId over id, and id over name', () => { + expect( + getDockerHostDrawerHistoryTarget( + makeHost({ agent: { agentId: 'agent:agent-1' }, id: 'id-1', name: 'named' }), + )?.resourceId, + ).toBe('agent-1'); + expect( + getDockerHostDrawerHistoryTarget(makeHost({ id: 'id-1', name: 'named' }))?.resourceId, + ).toBe('id-1'); + }); + + it('treats an empty agentId string as absent and falls through to the id', () => { + expect( + getDockerHostDrawerHistoryTarget(makeHost({ agent: { agentId: '' }, id: 'id-1' })) + ?.resourceId, + ).toBe('id-1'); + }); + + it('always reports the agent resourceType when a target is resolved', () => { + const target = getDockerHostDrawerHistoryTarget(makeHost({ name: 'edge-01' })); + expect(target?.resourceType).toBe('agent'); + }); + + it('returns null when every candidate is empty or missing', () => { + expect(getDockerHostDrawerHistoryTarget(makeHost())).toBeNull(); + }); + + it('returns null when the only candidate is whitespace', () => { + expect( + getDockerHostDrawerHistoryTarget(makeHost({ id: ' ', name: ' ' })), + ).toBeNull(); + }); + }); + + describe('getDockerHostDrawerHistoryFallbackMetrics', () => { + it('returns the host temperature when it is a finite number', () => { + expect( + getDockerHostDrawerHistoryFallbackMetrics(makeHost({ temperature: 42 })), + ).toEqual({ temperature: 42 }); + }); + + it('falls back to docker.temperature when host temperature is absent', () => { + expect( + getDockerHostDrawerHistoryFallbackMetrics(makeHost({ docker: { temperature: 51 } })), + ).toEqual({ temperature: 51 }); + }); + + it('prefers host temperature over docker.temperature', () => { + expect( + getDockerHostDrawerHistoryFallbackMetrics( + makeHost({ temperature: 10, docker: { temperature: 99 } }), + ), + ).toEqual({ temperature: 10 }); + }); + + it('keeps zero as a valid temperature (boundary)', () => { + expect(getDockerHostDrawerHistoryFallbackMetrics(makeHost({ temperature: 0 }))).toEqual({ + temperature: 0, + }); + }); + + it('keeps a finite negative temperature', () => { + expect(getDockerHostDrawerHistoryFallbackMetrics(makeHost({ temperature: -5 }))).toEqual({ + temperature: -5, + }); + }); + + it('rejects NaN on the host temperature and falls through to docker', () => { + expect( + getDockerHostDrawerHistoryFallbackMetrics( + makeHost({ temperature: Number.NaN, docker: { temperature: 33 } }), + ), + ).toEqual({ temperature: 33 }); + }); + + it('rejects Infinity and -Infinity on the host temperature', () => { + expect( + getDockerHostDrawerHistoryFallbackMetrics( + makeHost({ temperature: Number.POSITIVE_INFINITY }), + ), + ).toEqual({ temperature: undefined }); + expect( + getDockerHostDrawerHistoryFallbackMetrics( + makeHost({ temperature: Number.NEGATIVE_INFINITY }), + ), + ).toEqual({ temperature: undefined }); + }); + + it('returns undefined when both host and docker temperatures are non-finite', () => { + expect( + getDockerHostDrawerHistoryFallbackMetrics( + makeHost({ temperature: Number.NaN, docker: { temperature: Number.POSITIVE_INFINITY } }), + ), + ).toEqual({ temperature: undefined }); + }); + + it('returns undefined when neither temperature source is present', () => { + expect(getDockerHostDrawerHistoryFallbackMetrics(makeHost())).toEqual({ + temperature: undefined, + }); + }); + + it('always returns exactly one temperature key', () => { + const metrics = getDockerHostDrawerHistoryFallbackMetrics(makeHost({ temperature: 7 })); + expect(Object.keys(metrics)).toEqual(['temperature']); + }); + }); + + describe('DOCKER_HOST_DRAWER_HISTORY_GROUPS', () => { + it('declares the four operator chart groups for a Docker host', () => { + expect(DOCKER_HOST_DRAWER_HISTORY_GROUPS.map((group) => group.id)).toEqual([ + 'utilization', + 'network', + 'disk-io', + 'thermals', + ]); + }); + + it.each([ + ['utilization', 'Utilization', '%'], + ['network', 'Network I/O', 'B/s'], + ['disk-io', 'Disk I/O', 'B/s'], + ['thermals', 'Thermals', 'C'], + ])('group %s has label %s and unit %s', (id, label, unit) => { + const group = DOCKER_HOST_DRAWER_HISTORY_GROUPS.find((entry) => entry.id === id); + expect(group?.label).toBe(label); + expect(group?.unit).toBe(unit); + }); + + it('declares the utilization cpu/memory/disk series', () => { + const utilization = DOCKER_HOST_DRAWER_HISTORY_GROUPS.find((g) => g.id === 'utilization'); + expect(utilization?.series.map((series) => series.metric)).toEqual([ + 'cpu', + 'memory', + 'disk', + ]); + expect(utilization?.series.every((series) => series.unit === '%')).toBe(true); + }); + + it('declares the network in/out series', () => { + const network = DOCKER_HOST_DRAWER_HISTORY_GROUPS.find((g) => g.id === 'network'); + expect(network?.series.map((series) => series.metric)).toEqual(['netin', 'netout']); + }); + + it('declares the disk-io read/write series', () => { + const diskIo = DOCKER_HOST_DRAWER_HISTORY_GROUPS.find((g) => g.id === 'disk-io'); + expect(diskIo?.series.map((series) => series.metric)).toEqual(['diskread', 'diskwrite']); + }); + + it('declares a single temperature series in the thermals group', () => { + const thermals = DOCKER_HOST_DRAWER_HISTORY_GROUPS.find((g) => g.id === 'thermals'); + expect(thermals?.series.map((series) => series.metric)).toEqual(['temperature']); + expect(thermals?.series[0].label).toBe('CPU'); + expect(thermals?.series[0].unit).toBe('C'); + }); + + it('gives every series a label, unit, and a hex color', () => { + for (const group of DOCKER_HOST_DRAWER_HISTORY_GROUPS) { + for (const series of group.series) { + expect(series.label.length).toBeGreaterThan(0); + expect(series.unit.length).toBeGreaterThan(0); + expect(series.color).toMatch(/^#[0-9a-f]{6}$/i); + } + } + }); + + it('exposes a temperature metric that matches the fallback metrics key', () => { + // The drawer fallback can only synthesize a flat line for metrics it + // declares a current value for; the only such metric for a Docker host + // is temperature, which must exist as a series metric here. + const thermals = DOCKER_HOST_DRAWER_HISTORY_GROUPS.find((g) => g.id === 'thermals'); + expect(thermals?.series.some((series) => series.metric === 'temperature')).toBe(true); + }); + }); +}); diff --git a/frontend-modern/src/features/platformPage/columnAlignment.test.ts b/frontend-modern/src/features/platformPage/columnAlignment.test.ts new file mode 100644 index 0000000000..3cb49e9104 --- /dev/null +++ b/frontend-modern/src/features/platformPage/columnAlignment.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; + +import { + PLATFORM_COLUMN_ALIGN_BY_KIND, + getPlatformColumnAlign, + type PlatformTableColumnKind, +} from './columnAlignment'; + +const ALL_KINDS: PlatformTableColumnKind[] = [ + 'name', + 'text', + 'metric-bar', + 'numeric-value', + 'badge', +]; + +describe('columnAlignment', () => { + describe('PLATFORM_COLUMN_ALIGN_BY_KIND', () => { + it('maps every canonical column kind to its alignment value', () => { + // The single source of truth for all platform tables. Each assertion + // documents the rationale-backed alignment from the module docblock. + expect(PLATFORM_COLUMN_ALIGN_BY_KIND.name).toBe('left'); + expect(PLATFORM_COLUMN_ALIGN_BY_KIND.text).toBe('left'); + expect(PLATFORM_COLUMN_ALIGN_BY_KIND['metric-bar']).toBe('center'); + expect(PLATFORM_COLUMN_ALIGN_BY_KIND['numeric-value']).toBe('right'); + expect(PLATFORM_COLUMN_ALIGN_BY_KIND.badge).toBe('center'); + }); + + it.each([ + ['name', 'left'], + ['text', 'left'], + ['metric-bar', 'center'], + ['numeric-value', 'right'], + ['badge', 'center'], + ] as Array<[PlatformTableColumnKind, 'left' | 'right' | 'center']>)( + 'kind %s aligns %s', + (kind, align) => { + expect(PLATFORM_COLUMN_ALIGN_BY_KIND[kind]).toBe(align); + }, + ); + + it('is exhaustive over the PlatformTableColumnKind union (no missing or extra keys)', () => { + expect(Object.keys(PLATFORM_COLUMN_ALIGN_BY_KIND).sort()).toEqual( + [...ALL_KINDS].sort(), + ); + }); + + it('only emits valid PlatformTableCellAlign values', () => { + for (const value of Object.values(PLATFORM_COLUMN_ALIGN_BY_KIND)) { + expect(['left', 'right', 'center']).toContain(value); + } + }); + + it('covers all three alignment directions across the kinds', () => { + const directions = new Set(Object.values(PLATFORM_COLUMN_ALIGN_BY_KIND)); + expect(directions).toEqual(new Set(['left', 'right', 'center'])); + }); + + it('left-aligns the readable content kinds (name and text)', () => { + expect(PLATFORM_COLUMN_ALIGN_BY_KIND.name).toBe('left'); + expect(PLATFORM_COLUMN_ALIGN_BY_KIND.text).toBe('left'); + }); + + it('right-aligns scannable numeric values', () => { + expect(PLATFORM_COLUMN_ALIGN_BY_KIND['numeric-value']).toBe('right'); + }); + + it('center-aligns the visual cell kinds (metric bars and badges)', () => { + expect(PLATFORM_COLUMN_ALIGN_BY_KIND['metric-bar']).toBe('center'); + expect(PLATFORM_COLUMN_ALIGN_BY_KIND.badge).toBe('center'); + }); + }); + + describe('getPlatformColumnAlign', () => { + it.each(ALL_KINDS)('returns the map value for kind %s', (kind) => { + expect(getPlatformColumnAlign(kind)).toBe(PLATFORM_COLUMN_ALIGN_BY_KIND[kind]); + }); + + it('returns left for name', () => { + expect(getPlatformColumnAlign('name')).toBe('left'); + }); + + it('returns left for text', () => { + expect(getPlatformColumnAlign('text')).toBe('left'); + }); + + it('returns center for metric-bar', () => { + expect(getPlatformColumnAlign('metric-bar')).toBe('center'); + }); + + it('returns right for numeric-value', () => { + expect(getPlatformColumnAlign('numeric-value')).toBe('right'); + }); + + it('returns center for badge', () => { + expect(getPlatformColumnAlign('badge')).toBe('center'); + }); + + it('is consistent with the map across repeated calls', () => { + for (const kind of ALL_KINDS) { + expect(getPlatformColumnAlign(kind)).toBe(getPlatformColumnAlign(kind)); + } + }); + + // Runtime guard: the lookup is a plain property access on the map, so a + // value outside the declared union resolves to `undefined` rather than a + // fallback. This documents the current (typed-safe) behaviour. + it('returns undefined for an unknown kind at runtime', () => { + expect( + getPlatformColumnAlign('unknown' as unknown as PlatformTableColumnKind), + ).toBeUndefined(); + }); + }); +}); diff --git a/frontend-modern/src/features/platformPage/platformIcon.test.ts b/frontend-modern/src/features/platformPage/platformIcon.test.ts new file mode 100644 index 0000000000..5b48e3f62b --- /dev/null +++ b/frontend-modern/src/features/platformPage/platformIcon.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import CpuIcon from 'lucide-solid/icons/cpu'; +import ServerIcon from 'lucide-solid/icons/server'; +import UsersIcon from 'lucide-solid/icons/users'; + +import { DockerIcon } from '@/components/icons/DockerIcon'; +import { KubernetesIcon } from '@/components/icons/KubernetesIcon'; +import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon'; +import { TrueNASIcon } from '@/components/icons/TrueNASIcon'; + +import { + getPlatformIcon, + type PlatformIconKey, +} from './platformIcon'; + +const ALL_KEYS: PlatformIconKey[] = [ + 'proxmox', + 'docker', + 'kubernetes', + 'truenas', + 'vmware', + 'standalone', + 'systems', +]; + +describe('platformIcon', () => { + describe('getPlatformIcon', () => { + // Brand marks come from the inlined simple-icons SVG components. + it.each([ + ['proxmox', ProxmoxIcon], + ['docker', DockerIcon], + ['kubernetes', KubernetesIcon], + ['truenas', TrueNASIcon], + ] as Array<[PlatformIconKey, ReturnType]>)( + 'returns the brand glyph for %s', + (key, icon) => { + expect(getPlatformIcon(key)).toBe(icon); + }, + ); + + // vSphere has no legible square brand glyph, so it keeps a generic CPU + // mark; standalone/systems are not third-party brands and use semantic + // generic lucide icons. + it.each([ + ['vmware', CpuIcon], + ['standalone', ServerIcon], + ['systems', UsersIcon], + ] as Array<[PlatformIconKey, ReturnType]>)( + 'returns the generic icon for %s', + (key, icon) => { + expect(getPlatformIcon(key)).toBe(icon); + }, + ); + + it('resolves an icon for every key in the PlatformIconKey union', () => { + for (const key of ALL_KEYS) { + const icon = getPlatformIcon(key); + expect(icon).toBeDefined(); + expect(typeof icon).toBe('function'); + } + }); + + it('returns the same icon component reference for repeated lookups', () => { + for (const key of ALL_KEYS) { + expect(getPlatformIcon(key)).toBe(getPlatformIcon(key)); + } + }); + + it('returns undefined for an unknown key at runtime', () => { + expect( + getPlatformIcon('does-not-exist' as unknown as PlatformIconKey), + ).toBeUndefined(); + }); + + it('every resolved icon accepts a Solid props object (renderable component shape)', () => { + for (const key of ALL_KEYS) { + const icon = getPlatformIcon(key)!; + expect(typeof icon).toBe('function'); + // Solid components have a finite arity (props). A non-component value + // like a bare object would not be callable. + expect(icon.length).toBeLessThanOrEqual(1); + } + }); + }); +}); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupActivityPresentation.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupActivityPresentation.test.ts new file mode 100644 index 0000000000..3a517c5c6a --- /dev/null +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupActivityPresentation.test.ts @@ -0,0 +1,461 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { recoveryDateKeyFromTimestamp } from '@/utils/recoveryDatePresentation'; +import { getRecoveryTimelineDayFilterStateLabel } from '@/utils/recoveryTimelinePresentation'; + +import { + BACKUP_ACTIVITY_RANGE_DAYS, + buildBackupActivityTimeline, + getBackupActivityAxisLabel, + getBackupActivityColumnAriaLabel, + getBackupActivityDayFilterStateLabel, + getBackupActivityPointTotalLabel, + getBackupActivitySegmentPresentation, + getBackupActivityTooltipRows, + type BackupActivityMetricMode, + type BackupActivityNoun, + type BackupActivityPoint, + type BackupActivityRangeDays, + type BackupActivitySegmentKind, +} from '../proxmoxBackupActivityPresentation'; +import { getProxmoxBackupSourcePresentation } from '../proxmoxBackupSourcePresentation'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +// Anchor "now" at a February date so the window never crosses a DST +// transition (DST never changes in February in either hemisphere). Every +// timestamp below is built with the local Date constructor, matching the +// module's local-day bucketing, so the assertions are timezone-independent. +const NOW = new Date(2026, 1, 13, 10, 30, 0); + +const localMs = (year: number, month: number, day: number, hours = 0, minutes = 0): number => + new Date(year, month, day, hours, minutes, 0).getTime(); + +const dateKey = (year: number, month: number, day: number): string => + recoveryDateKeyFromTimestamp(localMs(year, month, day)); + +interface ActivityItem { + ts?: number; + kind: BackupActivitySegmentKind | null; + bytes?: number; +} + +const itemMs = (item: ActivityItem): number | undefined => item.ts; +const classify = (item: ActivityItem): BackupActivitySegmentKind | null => item.kind; + +const emptyCounts = (): Record => ({ + archive: 0, + pbs: 0, + ok: 0, + failed: 0, + running: 0, + snapshot: 0, +}); + +describe('proxmoxBackupActivityPresentation', () => { + describe('BACKUP_ACTIVITY_RANGE_DAYS', () => { + it('exposes the canonical backup activity ranges in ascending order', () => { + expect(BACKUP_ACTIVITY_RANGE_DAYS).toEqual([7, 30, 90, 365]); + }); + + it.each([...BACKUP_ACTIVITY_RANGE_DAYS])('supports the %s-day range', (days) => { + expect(BACKUP_ACTIVITY_RANGE_DAYS).toContain(days); + }); + }); + + describe('getBackupActivitySegmentPresentation', () => { + it.each([ + ['archive', 'PVE backup files', 'bg-blue-500', 'bg-blue-500'], + ['pbs', 'PBS snapshots', 'bg-cyan-500', 'bg-cyan-500'], + ['snapshot', 'Guest snapshots', 'bg-violet-500', 'bg-violet-500'], + ['ok', 'OK', 'bg-emerald-500', 'bg-emerald-500'], + ['failed', 'Failed', 'bg-red-500', 'bg-red-500'], + ['running', 'Running', 'bg-amber-500', 'bg-amber-500'], + ])('returns the canonical presentation for %s', (kind, label, segmentClassName, swatchClassName) => { + expect(getBackupActivitySegmentPresentation(kind as BackupActivitySegmentKind)).toEqual({ + label, + segmentClassName, + swatchClassName, + }); + }); + + it('returns a stable reference for repeated lookups', () => { + expect(getBackupActivitySegmentPresentation('ok')).toBe(getBackupActivitySegmentPresentation('ok')); + }); + + it('delegates source kinds to the proxmox backup source vocabulary', () => { + for (const kind of ['pbs', 'archive', 'snapshot'] as const) { + const source = getProxmoxBackupSourcePresentation(kind); + const segment = getBackupActivitySegmentPresentation(kind); + expect(segment.label).toBe(source.timelineLabel); + expect(segment.segmentClassName).toBe(source.timelineSegmentClassName); + expect(segment.swatchClassName).toBe(source.timelineSwatchClassName); + } + }); + + it('returns undefined for an unknown segment kind at runtime', () => { + expect( + getBackupActivitySegmentPresentation('unknown' as BackupActivitySegmentKind), + ).toBeUndefined(); + }); + }); + + describe('buildBackupActivityTimeline', () => { + it('creates one empty bucket per day in chronological order', () => { + const timeline = buildBackupActivityTimeline(7, [], itemMs, classify, { now: NOW }); + + expect(timeline.points).toHaveLength(7); + expect(timeline.points.map((point) => point.key)).toEqual([ + dateKey(2026, 1, 7), + dateKey(2026, 1, 8), + dateKey(2026, 1, 9), + dateKey(2026, 1, 10), + dateKey(2026, 1, 11), + dateKey(2026, 1, 12), + dateKey(2026, 1, 13), + ]); + + for (const point of timeline.points) { + expect(point.total).toBe(0); + expect(point.counts).toEqual(emptyCounts()); + } + expect(timeline.axisMax).toBe(2); + expect(timeline.labelEvery).toBe(1); + }); + + it.each<[BackupActivityRangeDays, string, string, number]>([ + [7, '2026-02-07', '2026-02-13', 1], + [30, '2026-01-15', '2026-02-13', 5], + [90, '2025-11-16', '2026-02-13', 10], + [365, '2025-02-14', '2026-02-13', 30], + ])( + 'sizes a %s-day window from %s to %s with labelEvery %s when empty', + (days, firstKey, lastKey, labelEvery) => { + const timeline = buildBackupActivityTimeline(days, [], itemMs, classify, { now: NOW }); + + expect(timeline.points).toHaveLength(days); + expect(timeline.points.at(0)!.key).toBe(firstKey); + expect(timeline.points.at(-1)!.key).toBe(lastKey); + expect(timeline.axisMax).toBe(2); + expect(timeline.labelEvery).toBe(labelEvery); + }, + ); + + it('keeps bucket keys strictly increasing and unique across long ranges', () => { + const timeline = buildBackupActivityTimeline(365, [], itemMs, classify, { now: NOW }); + const keys = timeline.points.map((point) => point.key); + const asTimestamps = keys.map((key) => new Date(`${key}T00:00:00`).getTime()); + expect(new Set(keys).size).toBe(keys.length); + for (let i = 1; i < asTimestamps.length; i += 1) { + expect(asTimestamps[i]).toBeGreaterThan(asTimestamps[i - 1]); + } + }); + + it('buckets items by their local-day timestamp and accumulates per-kind counts', () => { + const items: ActivityItem[] = [ + { ts: localMs(2026, 1, 13, 9, 0), kind: 'ok' }, + { ts: localMs(2026, 1, 12, 23, 30), kind: 'failed' }, + { ts: localMs(2026, 1, 7, 0, 0), kind: 'pbs' }, + { ts: localMs(2026, 1, 10, 12, 0), kind: 'archive' }, + { ts: localMs(2026, 1, 10, 18, 0), kind: 'snapshot' }, + ]; + const timeline = buildBackupActivityTimeline(7, items, itemMs, classify, { now: NOW }); + const byKey = new Map(timeline.points.map((point) => [point.key, point])); + + expect(byKey.get(dateKey(2026, 1, 13))!.counts.ok).toBe(1); + expect(byKey.get(dateKey(2026, 1, 13))!.total).toBe(1); + expect(byKey.get(dateKey(2026, 1, 12))!.counts.failed).toBe(1); + expect(byKey.get(dateKey(2026, 1, 7))!.counts.pbs).toBe(1); + + const feb10 = byKey.get(dateKey(2026, 1, 10))!; + expect(feb10.counts.archive).toBe(1); + expect(feb10.counts.snapshot).toBe(1); + expect(feb10.total).toBe(2); + }); + + it('includes the window-start edge and excludes anything at or past start-of-tomorrow', () => { + const todayStart = localMs(2026, 1, 13); + const items: ActivityItem[] = [ + { ts: localMs(2026, 1, 6, 23, 59), kind: 'ok' }, + { ts: localMs(2026, 1, 7, 0, 0), kind: 'ok' }, + { ts: todayStart, kind: 'failed' }, + { ts: todayStart + DAY_MS - 1, kind: 'running' }, + { ts: todayStart + DAY_MS, kind: 'ok' }, + { ts: localMs(2026, 1, 14, 12, 0), kind: 'ok' }, + ]; + const timeline = buildBackupActivityTimeline(7, items, itemMs, classify, { now: NOW }); + const totals = new Map(timeline.points.map((point) => [point.key, point.total])); + + expect(totals.get(dateKey(2026, 1, 7))).toBe(1); + expect(totals.get(dateKey(2026, 1, 13))).toBe(2); + expect(totals.get(dateKey(2026, 1, 6))).toBeUndefined(); + expect(totals.get(dateKey(2026, 1, 14))).toBeUndefined(); + }); + + it.each([ + ['undefined', undefined], + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ['-Infinity', Number.NEGATIVE_INFINITY], + ])('drops items whose timestamp is %s', (_label, ts) => { + const items: ActivityItem[] = [{ ts, kind: 'ok' }]; + const timeline = buildBackupActivityTimeline(7, items, itemMs, classify, { now: NOW }); + expect(timeline.points.every((point) => point.total === 0)).toBe(true); + }); + + it('skips in-window items classified as null without classifying out-of-window items', () => { + const classifySpy = vi.fn((item: ActivityItem) => item.kind); + const items: ActivityItem[] = [ + { ts: localMs(2026, 1, 6, 12, 0), kind: 'ok' }, + { ts: localMs(2026, 1, 13, 9, 0), kind: null }, + { ts: localMs(2026, 1, 13, 10, 0), kind: 'ok' }, + ]; + const timeline = buildBackupActivityTimeline(7, items, itemMs, classifySpy, { now: NOW }); + + expect(classifySpy).toHaveBeenCalledTimes(2); + expect(timeline.points.at(-1)!.total).toBe(1); + }); + + it('counts one per item when no getValue is supplied', () => { + const items: ActivityItem[] = [ + { ts: localMs(2026, 1, 13, 9, 0), kind: 'ok' }, + { ts: localMs(2026, 1, 13, 10, 0), kind: 'ok' }, + { ts: localMs(2026, 1, 13, 11, 0), kind: 'failed' }, + ]; + const timeline = buildBackupActivityTimeline(7, items, itemMs, classify, { now: NOW }); + + expect(timeline.points.at(-1)!.counts.ok).toBe(2); + expect(timeline.points.at(-1)!.counts.failed).toBe(1); + expect(timeline.points.at(-1)!.total).toBe(3); + }); + + it('accumulates per-item byte values in volume mode and rejects non-positive/non-finite values', () => { + const items: ActivityItem[] = [ + { ts: localMs(2026, 1, 13, 9, 0), kind: 'ok', bytes: 1073741824 }, + { ts: localMs(2026, 1, 13, 10, 0), kind: 'failed', bytes: 2147483648 }, + { ts: localMs(2026, 1, 13, 11, 0), kind: 'ok', bytes: 0 }, + { ts: localMs(2026, 1, 13, 12, 0), kind: 'ok', bytes: -100 }, + { ts: localMs(2026, 1, 13, 13, 0), kind: 'ok', bytes: Number.NaN }, + { ts: localMs(2026, 1, 13, 14, 0), kind: 'ok', bytes: Number.POSITIVE_INFINITY }, + ]; + const timeline = buildBackupActivityTimeline(7, items, itemMs, classify, { + now: NOW, + getValue: (item) => item.bytes ?? 0, + }); + const today = timeline.points.at(-1)!; + + expect(today.counts.ok).toBe(1073741824); + expect(today.counts.failed).toBe(2147483648); + expect(today.total).toBe(1073741824 + 2147483648); + }); + + it('keeps fractional positive values in volume mode', () => { + const items: ActivityItem[] = [ + { ts: localMs(2026, 1, 13, 9, 0), kind: 'ok', bytes: 0.5 }, + { ts: localMs(2026, 1, 13, 10, 0), kind: 'ok', bytes: 1.25 }, + ]; + const timeline = buildBackupActivityTimeline(7, items, itemMs, classify, { + now: NOW, + getValue: (item) => item.bytes ?? 0, + }); + + expect(timeline.points.at(-1)!.counts.ok).toBeCloseTo(1.75); + expect(timeline.points.at(-1)!.total).toBeCloseTo(1.75); + }); + + it.each<[string, ActivityItem[], number]>([ + ['empty stays at the floor of 2', [], 2], + ['a max of 1 stays at the floor of 2', [{ ts: localMs(2026, 1, 13, 9, 0), kind: 'ok', bytes: 1 }], 2], + ['a max of 2 maps to 2', [{ ts: localMs(2026, 1, 13, 9, 0), kind: 'ok', bytes: 2 }], 2], + ['a max of 5 maps to 5', [{ ts: localMs(2026, 1, 13, 9, 0), kind: 'ok', bytes: 5 }], 5], + ['a max of 6 rounds up to 10', [{ ts: localMs(2026, 1, 13, 9, 0), kind: 'ok', bytes: 6 }], 10], + ['a max of 50 maps to 50', [{ ts: localMs(2026, 1, 13, 9, 0), kind: 'ok', bytes: 50 }], 50], + ['a max of 100 maps to 100', [{ ts: localMs(2026, 1, 13, 9, 0), kind: 'ok', bytes: 100 }], 100], + ['a max of 1000 maps to 1000', [{ ts: localMs(2026, 1, 13, 9, 0), kind: 'ok', bytes: 1000 }], 1000], + ])('axisMax %s', (_label, items, axisMax) => { + const timeline = buildBackupActivityTimeline(7, items, itemMs, classify, { + now: NOW, + getValue: (item) => item.bytes ?? 0, + }); + expect(timeline.axisMax).toBe(axisMax); + }); + + it('defaults `now` to the current wall-clock time', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 1, 13, 10, 30, 0)); + try { + const timeline = buildBackupActivityTimeline(7, [], itemMs, classify); + expect(timeline.points).toHaveLength(7); + expect(timeline.points.at(0)!.key).toBe('2026-02-07'); + expect(timeline.points.at(-1)!.key).toBe('2026-02-13'); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('getBackupActivityTooltipRows', () => { + const point = ( + overrides: Partial> & { total?: number }, + ): BackupActivityPoint => ({ + key: '2026-02-13', + total: overrides.total ?? 0, + counts: { ...emptyCounts(), ...overrides }, + }); + + it('omits the percentage when a single kind is requested', () => { + const rows = getBackupActivityTooltipRows(point({ total: 5, ok: 5 }), ['ok']); + expect(rows).toEqual([ + { kind: 'ok', label: 'OK', count: 5, value: '5', segmentClassName: 'bg-emerald-500', muted: false }, + ]); + }); + + it('appends rounded percentages when multiple kinds are requested', () => { + const rows = getBackupActivityTooltipRows(point({ total: 10, ok: 6, failed: 4 }), ['ok', 'failed']); + expect(rows.map((row) => [row.value, row.muted])).toEqual([ + ['6 (60%)', false], + ['4 (40%)', false], + ]); + }); + + it('mutes zero-count kinds and shows no percentage for them', () => { + const rows = getBackupActivityTooltipRows(point({ total: 5, ok: 5 }), ['ok', 'failed']); + expect(rows[0]).toMatchObject({ value: '5 (100%)', muted: false }); + expect(rows[1]).toMatchObject({ value: '0', muted: true }); + }); + + it('rounds the displayed count while keeping the raw count field', () => { + const rows = getBackupActivityTooltipRows(point({ total: 3, ok: 2.6 }), ['ok']); + expect(rows[0].count).toBe(2.6); + expect(rows[0].value).toBe('3'); + }); + + it('clamps negative totals and counts to zero', () => { + const rows = getBackupActivityTooltipRows( + point({ total: -5, ok: -3, failed: -2 }), + ['ok', 'failed'], + ); + expect(rows.map((row) => row.value)).toEqual(['0', '0']); + expect(rows.every((row) => row.count === 0 && row.muted)).toBe(true); + }); + + it('reads missing count keys as zero', () => { + const partial = { key: '2026-02-13', total: 2, counts: { ok: 2 } } as unknown as BackupActivityPoint; + const rows = getBackupActivityTooltipRows(partial, ['ok', 'failed']); + expect(rows[1].count).toBe(0); + expect(rows[1].muted).toBe(true); + }); + + it('returns no rows for an empty kind list', () => { + expect(getBackupActivityTooltipRows(point({ total: 5, ok: 5 }), [])).toEqual([]); + }); + + it('formats volume-mode values as bytes with percentages', () => { + const rows = getBackupActivityTooltipRows( + point({ total: 3221225472, ok: 1073741824, failed: 2147483648 }), + ['ok', 'failed'], + 'volume', + ); + expect(rows[0].value).toBe('1.00 GB (33%)'); + expect(rows[1].value).toBe('2.00 GB (67%)'); + }); + + it('formats a single volume-mode row without a percentage', () => { + const rows = getBackupActivityTooltipRows(point({ total: 1073741824, ok: 1073741824 }), ['ok'], 'volume'); + expect(rows[0].value).toBe('1.00 GB'); + }); + + it('shows 0 B for zero-count volume rows', () => { + const rows = getBackupActivityTooltipRows(point({}), ['ok'], 'volume'); + expect(rows[0].value).toBe('0 B'); + expect(rows[0].muted).toBe(true); + }); + + it('defaults to count mode when mode is omitted', () => { + expect(getBackupActivityTooltipRows(point({ total: 1, ok: 1 }), ['ok'])[0].value).toBe('1'); + }); + }); + + describe('getBackupActivityPointTotalLabel', () => { + it.each([ + [0, 'backup', '0 backups'], + [1, 'backup', '1 backup'], + [2, 'backup', '2 backups'], + [1, 'snapshot', '1 snapshot'], + [5, 'task', '5 tasks'], + [2, 'archive', '2 archives'], + [-3, 'backup', '0 backups'], + [2.6, 'backup', '3 backups'], + [2.4, 'backup', '2 backups'], + ])('count mode: total %s %s -> %s', (total, noun, expected) => { + expect(getBackupActivityPointTotalLabel(total, noun as BackupActivityNoun)).toBe(expected); + }); + + it.each([ + [0, '0 B'], + [1073741824, '1.00 GB'], + [2147483648, '2.00 GB'], + [-5, '0 B'], + ])('volume mode: total %s -> %s', (total, expected) => { + expect(getBackupActivityPointTotalLabel(total, 'backup', 'volume')).toBe(expected); + }); + + it('defaults to count mode when mode is omitted', () => { + expect(getBackupActivityPointTotalLabel(1, 'backup')).toBe('1 backup'); + }); + }); + + describe('getBackupActivityColumnAriaLabel', () => { + it.each([ + ['Feb 13, 2026', 5, false, 'backup', 'Feb 13, 2026: 5 backups'], + ['Feb 13, 2026', 5, true, 'backup', 'Feb 13, 2026: 5 backups, selected'], + ['Feb 13, 2026', 1, false, 'snapshot', 'Feb 13, 2026: 1 snapshot'], + ['Feb 13, 2026', 0, true, 'backup', 'Feb 13, 2026: 0 backups, selected'], + ])('builds aria label for %s/%s/%s/%s', (dateLabel, total, selected, noun, expected) => { + expect( + getBackupActivityColumnAriaLabel(dateLabel, total, selected, noun as BackupActivityNoun), + ).toBe(expected); + }); + + it('formats volume totals in the aria label', () => { + expect( + getBackupActivityColumnAriaLabel('Feb 13, 2026', 1073741824, false, 'backup', 'volume'), + ).toBe('Feb 13, 2026: 1.00 GB'); + }); + }); + + describe('getBackupActivityAxisLabel', () => { + it.each([ + [0, 'count', '0'], + [1, 'count', '1'], + [2.6, 'count', '3'], + [100, 'count', '100'], + [-5, 'count', '0'], + ])('count axis label for %s is %s', (value, mode, expected) => { + expect(getBackupActivityAxisLabel(value, mode as BackupActivityMetricMode)).toBe(expected); + }); + + it.each([ + [0, '0 B'], + [1073741824, '1.00 GB'], + [-5, '0 B'], + ])('volume axis label for %s is %s', (value, expected) => { + expect(getBackupActivityAxisLabel(value, 'volume')).toBe(expected); + }); + }); + + describe('getBackupActivityDayFilterStateLabel', () => { + it.each([ + [true, true, 'Day filter'], + [false, true, 'Outside day filter'], + [false, false, 'Timeline day'], + [true, false, 'Day filter'], + ])('selected=%s hasDayFilter=%s -> %s', (selected, hasDayFilter, expected) => { + expect(getBackupActivityDayFilterStateLabel(selected, hasDayFilter)).toBe(expected); + }); + + it('is a re-export of the recovery timeline day filter state label', () => { + expect(getBackupActivityDayFilterStateLabel).toBe(getRecoveryTimelineDayFilterStateLabel); + }); + }); +}); diff --git a/frontend-modern/src/features/storageBackups/__tests__/models.test.ts b/frontend-modern/src/features/storageBackups/__tests__/models.test.ts new file mode 100644 index 0000000000..e77c30dd9d --- /dev/null +++ b/frontend-modern/src/features/storageBackups/__tests__/models.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { KNOWN_STORAGE_BACKUP_PLATFORMS } from '@/features/storageBackups/models'; +import { KNOWN_SOURCE_PLATFORM_KEYS } from '@/utils/sourcePlatforms'; + +describe('storageBackups/models', () => { + describe('KNOWN_STORAGE_BACKUP_PLATFORMS', () => { + it('re-exports the canonical source platform key list by reference', () => { + expect(KNOWN_STORAGE_BACKUP_PLATFORMS).toBe(KNOWN_SOURCE_PLATFORM_KEYS); + }); + + it('lists every governed platform plus the availability provider', () => { + expect([...KNOWN_STORAGE_BACKUP_PLATFORMS]).toEqual([ + 'agent', + 'docker', + 'kubernetes', + 'proxmox-pve', + 'proxmox-pbs', + 'proxmox-pmg', + 'truenas', + 'vmware-vsphere', + 'unraid', + 'synology-dsm', + 'microsoft-hyperv', + 'aws', + 'azure', + 'gcp', + 'generic', + 'availability', + ]); + }); + + it('keeps availability as the final provider-synthesized key', () => { + expect(KNOWN_STORAGE_BACKUP_PLATFORMS.at(-1)).toBe('availability'); + }); + + it('contains only non-empty lowercase string keys with no duplicates', () => { + const keys = [...KNOWN_STORAGE_BACKUP_PLATFORMS]; + expect( + keys.every((key) => typeof key === 'string' && key.length > 0 && key === key.toLowerCase()), + ).toBe(true); + expect(new Set(keys).size).toBe(keys.length); + }); + + it.each([ + 'agent', + 'docker', + 'kubernetes', + 'proxmox-pve', + 'proxmox-pbs', + 'proxmox-pmg', + 'truenas', + 'vmware-vsphere', + 'generic', + 'availability', + ])('includes the storage-relevant platform %s', (platform) => { + expect(KNOWN_STORAGE_BACKUP_PLATFORMS).toContain(platform); + }); + }); +});