Add unit tests for platformPage, docker drawer, and backup presentation modules

This commit is contained in:
rcourtman
2026-07-09 21:32:29 +01:00
parent 0fa841f66f
commit ba49c28878
5 changed files with 980 additions and 0 deletions
@@ -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);
});
});
});
@@ -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();
});
});
});
@@ -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<typeof getPlatformIcon>]>)(
'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<typeof getPlatformIcon>]>)(
'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);
}
});
});
});
@@ -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<BackupActivitySegmentKind, number> => ({
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<Record<BackupActivitySegmentKind, number>> & { 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);
});
});
});
@@ -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);
});
});
});