Add branch-coverage tests for eight already-tested frontend models

Covers previously-uncovered branches in proxmox backup recovery, AI finding
presentation, chat tool/transcript formatting, docker page model, alert
history/overrides models, and storage page state - 275 assertions through
public entry points, no source changes.
This commit is contained in:
rcourtman
2026-07-10 09:45:11 +01:00
parent f2b327b42d
commit 7da1f0ae99
8 changed files with 2742 additions and 0 deletions
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import { parseToolInputSummary } from '../toolPresentation';
const readSummary = (record: Record<string, unknown>) =>
parseToolInputSummary(JSON.stringify(record), 'pulse_read');
const querySummary = (record: Record<string, unknown>) =>
parseToolInputSummary(JSON.stringify(record), 'pulse_query');
const runCommandSummary = (record: Record<string, unknown>) =>
parseToolInputSummary(JSON.stringify(record), 'pulse_run_command');
const controlSummary = (record: Record<string, unknown>) =>
parseToolInputSummary(JSON.stringify(record), 'pulse_control');
describe('formatPulseReadInputSummary (read tool)', () => {
it('summarizes exec with a command via its read intent and falls back when command is absent', () => {
expect(readSummary({ action: 'exec', command: 'df -h' })).toBe('Inspect filesystems');
expect(readSummary({ action: 'exec' })).toBe('run read-only command');
});
it('summarizes file with and without a path', () => {
expect(readSummary({ action: 'file', path: '/etc/hosts' })).toBe('read /etc/hosts');
expect(readSummary({ action: 'file' })).toBe('read file');
});
it('appends a target suffix when target_host is present', () => {
expect(readSummary({ action: 'file', path: '/etc/hosts', target_host: 'web-01' })).toBe(
'read /etc/hosts on web-01',
);
});
it('summarizes tail with and without a path', () => {
expect(readSummary({ action: 'tail', path: '/var/log/syslog' })).toBe(
'tail /var/log/syslog',
);
expect(readSummary({ action: 'tail' })).toBe('tail file');
});
it('summarizes find with pattern+path, pattern only, and neither', () => {
expect(readSummary({ action: 'find', pattern: 'ERROR', path: '/var/log' })).toBe(
'find "ERROR" in /var/log',
);
expect(readSummary({ action: 'find', pattern: 'OOM' })).toBe('find "OOM"');
expect(readSummary({ action: 'find' })).toBe('find files');
});
it('summarizes logs by container, then source, then a generic fallback', () => {
expect(readSummary({ action: 'logs', container: 'nginx' })).toBe('logs nginx');
expect(readSummary({ action: 'logs', source: 'journald' })).toBe('journald logs');
expect(readSummary({ action: 'logs' })).toBe('read logs');
});
it('uses the type field as an action alias and labels unrecognized actions', () => {
expect(readSummary({ type: 'file', path: '/etc/hosts' })).toBe('read /etc/hosts');
expect(readSummary({ action: 'stat' })).toBe('stat');
});
});
describe('formatQueryInputSummary (query tool)', () => {
it('summarizes search with and without a query term', () => {
expect(querySummary({ action: 'search', query: 'web-101' })).toBe('search "web-101"');
expect(querySummary({ action: 'search' })).toBe('search resources');
});
it('summarizes list with a type, a resource_type alias, and no type', () => {
expect(querySummary({ action: 'list', type: 'vm' })).toBe('list vm');
expect(querySummary({ action: 'list', resource_type: 'storage_pool' })).toBe(
'list storage pool',
);
expect(querySummary({ action: 'list' })).toBe('list resources');
});
it('summarizes get with and without a resource id', () => {
expect(querySummary({ action: 'get', resource_id: 'vm:101' })).toBe('get vm:101');
expect(querySummary({ action: 'get' })).toBe('get resource');
});
it('summarizes config with id+node, id only, and neither', () => {
expect(querySummary({ action: 'config', resource_id: 'vm:101', node: 'pve01' })).toBe(
'config vm:101 on pve01',
);
expect(querySummary({ action: 'config', resource_id: 'vm:101' })).toBe('config vm:101');
expect(querySummary({ action: 'config' })).toBe('resource config');
});
it('summarizes topology honoring summary_only and health as a fixed label', () => {
expect(querySummary({ action: 'topology' })).toBe('topology');
expect(querySummary({ action: 'topology', summary_only: true })).toBe('topology summary');
expect(querySummary({ action: 'topology', summary_only: false })).toBe('topology');
expect(querySummary({ action: 'health' })).toBe('health summary');
});
it('labels unknown actions and falls back to resource-type then a generic query label', () => {
expect(querySummary({ action: 'custom' })).toBe('custom');
expect(querySummary({ resource_type: 'vm' })).toBe('query vm');
expect(querySummary({ foo: 'bar' })).toBe('query resources');
});
});
describe('formatStructuredInputSummary mode split (read vs run_command vs control)', () => {
it('routes run_command and control to write mode, producing "Run command"', () => {
expect(runCommandSummary({ command: 'systemctl restart nginx' })).toBe('Run command');
expect(controlSummary({ command: 'reboot', target_host: 'node-1' })).toBe(
'Run command on node-1',
);
});
it('falls back to "run command" for write tools when no command is present', () => {
expect(runCommandSummary({ foo: 'bar' })).toBe('run command');
expect(controlSummary({ foo: 'bar' })).toBe('run command');
});
it('falls back to "read resource" when the read tool yields no specific summary', () => {
expect(readSummary({ foo: 'bar' })).toBe('read resource');
});
it('splits the same command between read intent and write execution by tool', () => {
expect(readSummary({ command: 'df -h' })).toBe('Inspect filesystems');
expect(runCommandSummary({ command: 'df -h' })).toBe('Run command');
});
});
describe('partialResult rescue during function-style input parsing', () => {
it('recovers args parsed before a malformed token in a partial function call', () => {
expect(parseToolInputSummary('read(action="file", 123)', 'pulse_read')).toBe('read file');
});
it('yields no structured summary when no args are recoverable', () => {
expect(parseToolInputSummary('read(=', 'pulse_read')).toBe('read(=');
});
});
@@ -0,0 +1,287 @@
import { describe, expect, it } from 'vitest';
import type { AssistantTranscriptOptions } from '../transcriptExport';
import { formatAssistantTranscript, hasAssistantTranscriptContent } from '../transcriptExport';
import type { ChatMessage, PendingApproval, Question, StreamDisplayEvent } from '../types';
const timestamp = new Date('2026-06-06T12:34:56Z');
const assistantWithEvents = (events: StreamDisplayEvent[]): ChatMessage => ({
id: 'assistant-1',
role: 'assistant',
content: '',
timestamp,
streamEvents: events,
});
const render = (
events: StreamDisplayEvent[],
options: Partial<AssistantTranscriptOptions> = {},
): string =>
formatAssistantTranscript({
messages: [assistantWithEvents(events)],
generatedAt: timestamp,
...options,
});
const hasContent = (event: StreamDisplayEvent): boolean =>
hasAssistantTranscriptContent([assistantWithEvents([event])]);
describe('formatPendingTool', () => {
it('uses the parsed input summary, default pending status, and command preview', () => {
const transcript = render([
{
type: 'pending_tool',
pendingTool: {
id: 't1',
name: 'pulse_read',
input: JSON.stringify({
action: 'exec',
command: 'ls /dev | wc -l',
target_host: 'current_resource',
}),
},
},
]);
expect(transcript).toContain('[tool:read] Inspect devices on current resource - pending');
expect(transcript).toContain('$ ls /dev | wc -l');
});
it('falls back to the action label and surfaces status plus progress without a command preview', () => {
const transcript = render([
{
type: 'pending_tool',
pendingTool: {
id: 't2',
name: 'pulse_get_disk_health',
input: '',
status: 'waiting',
progress: 'probing controllers',
},
},
]);
expect(transcript).toContain(
'[tool:disks] Checking disks... - waiting - progress: probing controllers',
);
expect(transcript).not.toContain('$');
});
});
describe('formatApproval', () => {
it('marks a non-executing high-risk approval as approval required', () => {
const transcript = render([
{
type: 'approval',
approval: {
toolId: 't1',
toolName: 'pulse_run_command',
description: 'Restart nginx service',
command: 'systemctl restart nginx',
risk: 'high',
runOnHost: true,
},
},
]);
expect(transcript).toContain(
'[approval:cmd] Restart nginx service - approval required - high risk',
);
});
it('falls back to the command and executing status when description is absent', () => {
const transcript = render([
{
type: 'approval',
approval: {
toolId: 't2',
toolName: '',
command: 'rm -rf /tmp/cache',
isExecuting: true,
runOnHost: true,
},
},
]);
expect(transcript).toContain('[approval:approval] rm -rf /tmp/cache - executing');
expect(transcript).not.toContain('risk');
});
it('uses the generic action subject when no description or command is present', () => {
const transcript = render([
{
type: 'approval',
approval: {
toolId: 't3',
toolName: 'pulse_read',
runOnHost: false,
} as unknown as PendingApproval,
},
]);
expect(transcript).toContain('[approval:read] action - approval required');
});
});
describe('formatQuestion', () => {
it('renders a single prompt', () => {
const transcript = render([
{
type: 'question',
question: {
questionId: 'q1',
questions: [{ id: 'q1a', type: 'text', question: 'Which host should I inspect?' }],
},
},
]);
expect(transcript).toContain('[question] Which host should I inspect?');
});
it('joins multiple prompts with a slash separator', () => {
const transcript = render([
{
type: 'question',
question: {
questionId: 'q2',
questions: [
{ id: '1', type: 'select', question: 'Pick severity' },
{ id: '2', type: 'select', question: 'Pick scope' },
],
},
},
]);
expect(transcript).toContain('[question] Pick severity / Pick scope');
});
it('falls back to the header when the question text is missing', () => {
const transcript = render([
{
type: 'question',
question: {
questionId: 'q3',
questions: [{ id: '1', type: 'text', header: 'Resource name' } as unknown as Question],
},
},
]);
expect(transcript).toContain('[question] Resource name');
});
it('omits the question block when every prompt normalizes to empty', () => {
const transcript = render([
{
type: 'question',
question: {
questionId: 'q4',
questions: [{ id: '1', type: 'text', question: ' ', header: '' }],
},
},
]);
expect(transcript).toContain('## Pulse Assistant');
expect(transcript).not.toContain('[question]');
});
});
describe('formatModelSwitch', () => {
const labelFor = (id: string): string =>
(
({
'm-sel': 'Selected Route',
'm-new': 'New Route',
'm-old': 'Old Route',
'm-x': 'X Route',
}) as Record<string, string>
)[id] ?? id;
it('renders a selected route with the using prefix', () => {
const transcript = render(
[{ type: 'model_switch', model: 'm-sel', modelEvent: 'selected' }],
{ getModelRouteLabel: labelFor },
);
expect(transcript).toContain('[model] using Selected Route');
});
it('renders a switch after a failed route', () => {
const transcript = render(
[{ type: 'model_switch', model: 'm-new', failedModel: 'm-old', modelEvent: 'switch' }],
{ getModelRouteLabel: labelFor },
);
expect(transcript).toContain('[model] New Route after Old Route');
});
it('renders a plain switch without a failed route', () => {
const transcript = render([{ type: 'model_switch', model: 'm-x', modelEvent: 'switch' }], {
getModelRouteLabel: labelFor,
});
expect(transcript).toContain('[model] X Route');
expect(transcript).not.toContain('after');
});
it('does not duplicate the route when the failed route matches the new one', () => {
const transcript = render(
[{ type: 'model_switch', model: 'm-x', failedModel: 'm-x', modelEvent: 'switch' }],
{ getModelRouteLabel: labelFor },
);
expect(transcript).toContain('[model] X Route');
expect(transcript).not.toContain('after');
});
});
describe('hasRenderableEvent switch arms', () => {
it('treats thinking as renderable only when included and non-empty', () => {
expect(hasContent({ type: 'thinking', thinking: 'Private reasoning' })).toBe(false);
expect(
render([{ type: 'thinking', thinking: 'Private reasoning' }], { includeThinking: true }),
).toContain('[thinking]');
expect(render([{ type: 'thinking', thinking: ' ' }], { includeThinking: true })).toBe('');
});
it('renders workflow status only when it formats to a message', () => {
expect(
hasContent({
type: 'workflow_status',
workflowStatus: { message: 'Reading current inventory.' },
}),
).toBe(true);
expect(hasContent({ type: 'workflow_status' })).toBe(false);
});
it('renders tool cancellation only when a cancel payload exists', () => {
expect(
hasContent({
type: 'tool_cancel',
toolCancel: { id: 'c1', name: 'pulse_read', input: '' },
}),
).toBe(true);
expect(hasContent({ type: 'tool_cancel' })).toBe(false);
});
it('renders model switch only when a model is present', () => {
expect(hasContent({ type: 'model_switch', model: 'm' })).toBe(true);
expect(hasContent({ type: 'model_switch' })).toBe(false);
});
it('renders approval only when an approval payload exists', () => {
expect(
hasContent({
type: 'approval',
approval: { toolId: 'a', toolName: '', command: '', runOnHost: false },
}),
).toBe(true);
expect(hasContent({ type: 'approval' })).toBe(false);
});
it('renders question only when a question payload exists', () => {
expect(
hasContent({ type: 'question', question: { questionId: 'q', questions: [] } }),
).toBe(true);
expect(hasContent({ type: 'question' })).toBe(false);
});
});
@@ -0,0 +1,397 @@
import { describe, expect, it } from 'vitest';
import type { StorageHealthFilter } from '@/features/storageBackups/models';
import {
buildStorageRouteFields,
coerceSelectedStorageNodeId,
countActiveStorageFilters,
DEFAULT_STORAGE_DISK_GROUP_FILTER,
DEFAULT_STORAGE_DISK_ROLE_FILTER,
DEFAULT_STORAGE_GROUP_KEY,
DEFAULT_STORAGE_SELECTED_NODE_ID,
DEFAULT_STORAGE_SORT_DIRECTION,
DEFAULT_STORAGE_SORT_KEY,
DEFAULT_STORAGE_SOURCE_FILTER,
DEFAULT_STORAGE_STATUS_FILTER,
getDefaultStorageSortDirection,
hasActiveStorageFilters,
normalizeStorageHealthFilter,
normalizeStorageSortKey,
type StoragePageNodeOption,
type StorageFilterActivityState,
} from '@/components/Storage/storagePageState';
const baseInactiveState: StorageFilterActivityState = {
search: '',
sortKey: DEFAULT_STORAGE_SORT_KEY,
sortDirection: DEFAULT_STORAGE_SORT_DIRECTION,
groupBy: DEFAULT_STORAGE_GROUP_KEY,
statusFilter: DEFAULT_STORAGE_STATUS_FILTER,
sourceFilter: DEFAULT_STORAGE_SOURCE_FILTER,
diskRoleFilter: DEFAULT_STORAGE_DISK_ROLE_FILTER,
diskGroupFilter: DEFAULT_STORAGE_DISK_GROUP_FILTER,
};
describe('storagePageState coverage', () => {
describe('normalizeStorageHealthFilter', () => {
it('returns "all" for empty, whitespace, and null-ish inputs', () => {
expect(normalizeStorageHealthFilter('')).toBe('all');
expect(normalizeStorageHealthFilter(' ')).toBe('all');
});
it('returns "all" for the literal "all" token (case-insensitive, trimmed)', () => {
expect(normalizeStorageHealthFilter('all')).toBe('all');
expect(normalizeStorageHealthFilter(' All ')).toBe('all');
expect(normalizeStorageHealthFilter('ALL')).toBe('all');
});
it.each<[string, StorageHealthFilter]>([
['attention', 'attention'],
['needs-attention', 'attention'],
['issue', 'attention'],
['issues', 'attention'],
['unhealthy', 'attention'],
])('maps %s alias to "attention"', (input, expected) => {
expect(normalizeStorageHealthFilter(input)).toBe(expected);
});
it.each<[string, StorageHealthFilter]>([
['available', 'healthy'],
['online', 'healthy'],
['healthy', 'healthy'],
])('maps %s alias to "healthy"', (input, expected) => {
expect(normalizeStorageHealthFilter(input)).toBe(expected);
});
it.each<[string, StorageHealthFilter]>([
['degraded', 'warning'],
['warning', 'warning'],
['critical', 'critical'],
['offline', 'offline'],
['unknown', 'unknown'],
])('maps %s to its canonical bucket', (input, expected) => {
expect(normalizeStorageHealthFilter(input)).toBe(expected);
});
it('is case-insensitive and trims whitespace', () => {
expect(normalizeStorageHealthFilter(' CRITICAL ')).toBe('critical');
expect(normalizeStorageHealthFilter('Offline')).toBe('offline');
expect(normalizeStorageHealthFilter(' Unhealthy ')).toBe('attention');
});
it('falls back to "all" for unrecognized tokens', () => {
expect(normalizeStorageHealthFilter('banana')).toBe('all');
expect(normalizeStorageHealthFilter('error')).toBe('all');
});
});
describe('normalizeStorageSortKey', () => {
it.each([
'priority',
'name',
'state',
'usage',
'type',
'host',
'protection',
'growth',
])('accepts canonical sort key %s (trimmed, lowercased)', (key) => {
expect(normalizeStorageSortKey(key)).toBe(key);
expect(normalizeStorageSortKey(` ${key.toUpperCase()} `)).toBe(key);
});
it('falls back to "priority" for empty / whitespace input', () => {
expect(normalizeStorageSortKey('')).toBe('priority');
expect(normalizeStorageSortKey(' ')).toBe('priority');
});
it('falls back to "priority" for unknown keys', () => {
expect(normalizeStorageSortKey('banana')).toBe('priority');
expect(normalizeStorageSortKey('random')).toBe('priority');
});
it('folds the legacy "source" key back to the default "priority"', () => {
expect(normalizeStorageSortKey('source')).toBe('priority');
});
});
describe('getDefaultStorageSortDirection', () => {
it('returns "desc" for priority, usage, and growth', () => {
expect(getDefaultStorageSortDirection('priority')).toBe('desc');
expect(getDefaultStorageSortDirection('usage')).toBe('desc');
expect(getDefaultStorageSortDirection('growth')).toBe('desc');
});
it('returns "asc" for name, state, type, host, and protection', () => {
expect(getDefaultStorageSortDirection('name')).toBe('asc');
expect(getDefaultStorageSortDirection('state')).toBe('asc');
expect(getDefaultStorageSortDirection('type')).toBe('asc');
expect(getDefaultStorageSortDirection('host')).toBe('asc');
expect(getDefaultStorageSortDirection('protection')).toBe('asc');
});
});
describe('countActiveStorageFilters', () => {
it('returns 0 when every field is at its default', () => {
expect(countActiveStorageFilters({ ...baseInactiveState })).toBe(0);
});
it('counts only non-whitespace search', () => {
expect(countActiveStorageFilters({ ...baseInactiveState, search: ' ' })).toBe(0);
expect(countActiveStorageFilters({ ...baseInactiveState, search: 'tank' })).toBe(1);
});
it('counts groupBy when not "none"', () => {
expect(countActiveStorageFilters({ ...baseInactiveState, groupBy: 'node' })).toBe(1);
expect(countActiveStorageFilters({ ...baseInactiveState, groupBy: 'type' })).toBe(1);
});
it('counts statusFilter when not "all"', () => {
expect(countActiveStorageFilters({ ...baseInactiveState, statusFilter: 'critical' })).toBe(1);
});
it('counts sourceFilter when not "all"', () => {
expect(countActiveStorageFilters({ ...baseInactiveState, sourceFilter: 'agent' })).toBe(1);
});
it('counts diskRoleFilter when not at default', () => {
expect(
countActiveStorageFilters({ ...baseInactiveState, diskRoleFilter: 'nvme-disk' }),
).toBe(1);
});
it('counts diskGroupFilter when not at default', () => {
expect(
countActiveStorageFilters({ ...baseInactiveState, diskGroupFilter: 'data-pool' }),
).toBe(1);
});
it('sums to 6 when all six filter dimensions are active', () => {
expect(
countActiveStorageFilters({
search: 'tank',
sortKey: 'priority',
sortDirection: 'desc',
groupBy: 'node',
statusFilter: 'critical',
sourceFilter: 'agent',
diskRoleFilter: 'nvme-disk',
diskGroupFilter: 'data-pool',
}),
).toBe(6);
});
});
describe('hasActiveStorageFilters', () => {
it('returns false at full default state', () => {
expect(hasActiveStorageFilters({ ...baseInactiveState })).toBe(false);
});
it('returns true for non-whitespace search', () => {
expect(hasActiveStorageFilters({ ...baseInactiveState, search: 'tank' })).toBe(true);
});
it('returns false for whitespace-only search', () => {
expect(hasActiveStorageFilters({ ...baseInactiveState, search: ' ' })).toBe(false);
});
it('returns true when sortKey differs from default', () => {
expect(hasActiveStorageFilters({ ...baseInactiveState, sortKey: 'name' })).toBe(true);
});
it('returns true when sortDirection differs from default', () => {
expect(hasActiveStorageFilters({ ...baseInactiveState, sortDirection: 'asc' })).toBe(true);
});
it('returns true when groupBy differs from default', () => {
expect(hasActiveStorageFilters({ ...baseInactiveState, groupBy: 'type' })).toBe(true);
});
it('returns true when statusFilter differs from default', () => {
expect(hasActiveStorageFilters({ ...baseInactiveState, statusFilter: 'warning' })).toBe(true);
});
it('returns true when sourceFilter differs from default', () => {
expect(hasActiveStorageFilters({ ...baseInactiveState, sourceFilter: 'agent' })).toBe(true);
});
it('returns true when diskRoleFilter differs from default', () => {
expect(
hasActiveStorageFilters({ ...baseInactiveState, diskRoleFilter: 'nvme-disk' }),
).toBe(true);
});
it('returns true when diskGroupFilter differs from default', () => {
expect(
hasActiveStorageFilters({ ...baseInactiveState, diskGroupFilter: 'data-pool' }),
).toBe(true);
});
});
describe('coerceSelectedStorageNodeId', () => {
const nodeOptions: StoragePageNodeOption[] = [
{ id: 'node-1', label: 'pve1' },
{ id: 'node-2', label: 'pve2' },
];
it('returns "all" unchanged', () => {
expect(coerceSelectedStorageNodeId('all', nodeOptions)).toBe('all');
});
it('returns the id when it matches a known node option', () => {
expect(coerceSelectedStorageNodeId('node-1', nodeOptions)).toBe('node-1');
expect(coerceSelectedStorageNodeId('node-2', nodeOptions)).toBe('node-2');
});
it('falls back to "all" when the id is not in nodeOptions', () => {
expect(coerceSelectedStorageNodeId('missing', nodeOptions)).toBe('all');
});
it('falls back to "all" when nodeOptions is empty', () => {
expect(coerceSelectedStorageNodeId('node-1', [])).toBe('all');
});
});
describe('route serde round-trip', () => {
const noop = () => {};
const makeFields = () =>
buildStorageRouteFields({
view: () => 'pools',
setView: noop,
sourceFilter: () => 'all',
setSourceFilter: noop,
healthFilter: () => 'all',
setHealthFilter: noop,
diskRoleFilter: () => 'all',
setDiskRoleFilter: noop,
diskGroupFilter: () => 'all',
setDiskGroupFilter: noop,
selectedNodeId: () => 'all',
setSelectedNodeId: noop,
groupBy: () => 'none',
setGroupBy: noop,
sortKey: () => 'priority',
setSortKey: noop,
sortDirection: () => 'desc',
setSortDirection: noop,
search: () => '',
setSearch: noop,
});
// --- normalizeStorageRouteSelection (private, covered via node field) ---
it('node.read returns "all" for "all" in any case, trimmed', () => {
const fields = makeFields();
expect(fields.node!.read({ node: 'all' } as any)).toBe(DEFAULT_STORAGE_SELECTED_NODE_ID);
expect(fields.node!.read({ node: 'ALL' } as any)).toBe(DEFAULT_STORAGE_SELECTED_NODE_ID);
});
it('node.read returns the trimmed id (case preserved) for non-"all" values', () => {
const fields = makeFields();
expect(fields.node!.read({ node: 'node-9' } as any)).toBe('node-9');
expect(fields.node!.read({ node: ' Node-ABC ' } as any)).toBe('Node-ABC');
});
it('node.read falls back to "all" when normalizeStorageRouteSelection yields empty', () => {
const fields = makeFields();
expect(fields.node!.read({ node: '' } as any)).toBe(DEFAULT_STORAGE_SELECTED_NODE_ID);
expect(fields.node!.read({ node: ' ' } as any)).toBe(DEFAULT_STORAGE_SELECTED_NODE_ID);
expect(fields.node!.read({ node: undefined } as any)).toBe(DEFAULT_STORAGE_SELECTED_NODE_ID);
});
it('node.write returns null when the value resolves to "all"', () => {
const fields = makeFields();
expect(fields.node!.write?.('all')).toBeNull();
expect(fields.node!.write?.(' ')).toBeNull();
});
it('node.write returns the resolved id when it is not "all"', () => {
const fields = makeFields();
expect(fields.node!.write?.('node-9')).toBe('node-9');
});
// --- unknown / invalid string fallbacks to defaults ---
it('tab.read falls back to "pools" for unknown string', () => {
const fields = makeFields();
expect(fields.tab!.read({ tab: 'unknown' } as any)).toBe('pools');
expect(fields.tab!.read({ tab: 'weird' } as any)).toBe('pools');
});
it('source.read falls back to "all" for empty / undefined / whitespace', () => {
const fields = makeFields();
expect(fields.source!.read({ source: '' } as any)).toBe('all');
expect(fields.source!.read({ source: undefined } as any)).toBe('all');
expect(fields.source!.read({ source: ' ' } as any)).toBe('all');
});
it('status.read falls back to "all" for unknown string and undefined', () => {
const fields = makeFields();
expect(fields.status!.read({ status: 'banana' } as any)).toBe('all');
expect(fields.status!.read({ status: undefined } as any)).toBe('all');
expect(fields.status!.read({} as any)).toBe('all');
});
it('diskRole.read falls back to default for undefined / empty', () => {
const fields = makeFields();
expect(fields.diskRole!.read({ diskRole: undefined } as any)).toBe(
DEFAULT_STORAGE_DISK_ROLE_FILTER,
);
expect(fields.diskRole!.read({ diskRole: ' ' } as any)).toBe(
DEFAULT_STORAGE_DISK_ROLE_FILTER,
);
});
it('diskGroup.read falls back to default for undefined / empty', () => {
const fields = makeFields();
expect(fields.diskGroup!.read({ diskGroup: undefined } as any)).toBe(
DEFAULT_STORAGE_DISK_GROUP_FILTER,
);
expect(fields.diskGroup!.read({ diskGroup: ' ' } as any)).toBe(
DEFAULT_STORAGE_DISK_GROUP_FILTER,
);
});
it('group.read falls back to "none" for unknown string', () => {
const fields = makeFields();
expect(fields.group!.read({ group: 'banana' } as any)).toBe('none');
});
it('sort.read falls back to "priority" for unknown string', () => {
const fields = makeFields();
expect(fields.sort!.read({ sort: 'banana' } as any)).toBe('priority');
});
it('order.read falls back to "desc" for unknown string', () => {
const fields = makeFields();
expect(fields.order!.read({ order: 'banana' } as any)).toBe('desc');
});
it('query.read falls back to empty string for undefined', () => {
const fields = makeFields();
expect(fields.query!.read({ query: undefined } as any)).toBe('');
expect(fields.query!.read({} as any)).toBe('');
});
// --- write serde round-trip: default values serialize to null ---
it('source.write returns null for the default "all"', () => {
const fields = makeFields();
expect(fields.source!.write?.('all')).toBeNull();
});
it('group.write returns null for "none"', () => {
const fields = makeFields();
expect(fields.group!.write?.('none')).toBeNull();
});
it('sort.write returns null for "priority"', () => {
const fields = makeFields();
expect(fields.sort!.write?.('priority')).toBeNull();
});
it('order.write returns null for "desc"', () => {
const fields = makeFields();
expect(fields.order!.write?.('desc')).toBeNull();
});
});
});
@@ -0,0 +1,476 @@
import { describe, expect, it } from 'vitest';
import {
applyAlertHistoryWindow,
buildAlertAxisTicks,
buildAlertHistoryParams,
buildAlertTrends,
filterAlertHistoryItems,
getIncidentRowKey,
groupAlertHistoryItems,
MS_PER_HOUR,
type AlertHistoryRange,
type AlertTrendSeries,
type HistoryItem,
} from '../alertHistoryModel';
function makeItem(overrides: Partial<HistoryItem> = {}): HistoryItem {
return {
id: 'alert-1',
source: 'alert',
status: 'resolved',
startTime: '2026-03-22T09:00:00.000Z',
duration: '1h',
resourceName: 'vm-101',
resourceType: 'VM',
severity: 'critical',
title: 'CPU High',
...overrides,
};
}
// ---------------------------------------------------------------------------
// getIncidentRowKey (zero-hit)
// ---------------------------------------------------------------------------
describe('getIncidentRowKey', () => {
it('joins id and startTime with ::', () => {
expect(
getIncidentRowKey(makeItem({ id: 'a1', startTime: '2026-03-22T09:00:00.000Z' })),
).toBe('a1::2026-03-22T09:00:00.000Z');
});
it('produces distinct keys for same id with different startTime', () => {
const a = makeItem({ id: 'x', startTime: '2026-03-22T09:00:00.000Z' });
const b = makeItem({ id: 'x', startTime: '2026-03-22T10:00:00.000Z' });
expect(getIncidentRowKey(a)).not.toBe(getIncidentRowKey(b));
});
});
// ---------------------------------------------------------------------------
// buildAlertHistoryParams – default switch branch + default now param
// ---------------------------------------------------------------------------
describe('buildAlertHistoryParams', () => {
it('falls back to limit 1000 with no startTime for an unrecognised range', () => {
const result = buildAlertHistoryParams(
'bogus' as AlertHistoryRange,
Date.UTC(2026, 2, 22),
);
expect(result).toEqual({ limit: 1000 });
expect(result.startTime).toBeUndefined();
});
it('uses Date.now() when now is omitted', () => {
const before = Date.now();
const result = buildAlertHistoryParams('24h');
const after = Date.now();
const startMs = new Date(result.startTime!).getTime();
expect(startMs).toBeGreaterThanOrEqual(before - 24 * MS_PER_HOUR);
expect(startMs).toBeLessThanOrEqual(after - 24 * MS_PER_HOUR);
});
});
// ---------------------------------------------------------------------------
// filterAlertHistoryItems
// ---------------------------------------------------------------------------
describe('filterAlertHistoryItems', () => {
const items: HistoryItem[] = [
makeItem({
id: '1',
severity: 'critical',
resourceName: 'vm-101',
title: 'CPU High',
description: 'cpu 95%',
node: 'px1',
}),
makeItem({
id: '2',
severity: 'warning',
resourceName: 'db-1',
title: 'Disk Full',
description: 'disk 90%',
node: 'px2',
}),
makeItem({
id: '3',
severity: 'critical',
resourceName: 'web-1',
title: 'Memory',
description: 'mem high',
}),
];
it('returns all items for severity "all" with empty search', () => {
expect(filterAlertHistoryItems(items, 'all', '')).toHaveLength(3);
});
it('treats whitespace-only search as no search', () => {
expect(filterAlertHistoryItems(items, 'all', ' \t ')).toHaveLength(3);
});
it('filters to warning severity only', () => {
const result = filterAlertHistoryItems(items, 'warning', '');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('2');
});
it('filters to critical severity only', () => {
const result = filterAlertHistoryItems(items, 'critical', '');
expect(result.map((i) => i.id)).toEqual(['1', '3']);
});
it('matches case-insensitively on resourceName', () => {
const result = filterAlertHistoryItems(items, 'all', 'VM-101');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('1');
});
it('matches on title', () => {
const result = filterAlertHistoryItems(items, 'all', 'disk');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('2');
});
it('matches on description', () => {
const result = filterAlertHistoryItems(items, 'all', '95%');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('1');
});
it('matches on node', () => {
const result = filterAlertHistoryItems(items, 'all', 'px2');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('2');
});
it('combines severity and search filters', () => {
const result = filterAlertHistoryItems(items, 'critical', 'vm');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('1');
});
it('returns empty array when nothing matches the search', () => {
expect(filterAlertHistoryItems(items, 'all', 'nonexistent')).toHaveLength(0);
});
it('handles items with undefined optional fields without throwing', () => {
const item = makeItem({ id: 'u', description: undefined, node: undefined });
expect(filterAlertHistoryItems([item], 'all', '')).toHaveLength(1);
expect(filterAlertHistoryItems([item], 'all', 'zzz')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// buildAlertTrends – 7d, 30d, all-empty, all-with-data, window edges
// ---------------------------------------------------------------------------
describe('buildAlertTrends', () => {
const now = Date.UTC(2026, 2, 22, 12, 0, 0);
it('uses 6-hour buckets for 7d range', () => {
const trends = buildAlertTrends([], '7d', now);
expect(trends.bucketSize).toBe(6);
expect(trends.buckets).toHaveLength(28);
expect(trends.rangeHours).toBe(168);
});
it('uses 24-hour buckets for 30d range', () => {
const trends = buildAlertTrends([], '30d', now);
expect(trends.bucketSize).toBe(24);
expect(trends.buckets).toHaveLength(30);
expect(trends.rangeHours).toBe(720);
});
it('defaults to 24-hour single bucket for "all" range with no alerts', () => {
const trends = buildAlertTrends([], 'all', now);
expect(trends.bucketSize).toBe(24);
expect(trends.buckets).toHaveLength(1);
expect(trends.buckets[0]).toBe(0);
expect(trends.max).toBe(1);
expect(trends.rangeHours).toBe(24);
});
it('computes bucket size from earliest alert for "all" range with data', () => {
const alerts = [
makeItem({ startTime: new Date(now - 50 * MS_PER_HOUR).toISOString() }),
makeItem({ startTime: new Date(now - 10 * MS_PER_HOUR).toISOString() }),
];
const trends = buildAlertTrends(alerts, 'all', now);
expect(trends.bucketSize).toBe(2);
expect(trends.buckets).toHaveLength(25);
expect(trends.rangeHours).toBe(50);
});
it('snaps bucket size up to the next nice value', () => {
const alerts = [makeItem({ startTime: new Date(now - 200 * MS_PER_HOUR).toISOString() })];
const trends = buildAlertTrends(alerts, 'all', now);
// rawBucketSize = ceil(200/30) = 7, next nice >= 7 is 12
expect(trends.bucketSize).toBe(12);
});
it('clamps alert at window end into the last bucket', () => {
const alerts = [makeItem({ startTime: new Date(now).toISOString() })];
const trends = buildAlertTrends(alerts, '24h', now);
expect(trends.buckets[trends.buckets.length - 1]).toBe(1);
});
it('ignores alerts before the trend window', () => {
const alerts = [makeItem({ startTime: new Date(now - 25 * MS_PER_HOUR).toISOString() })];
const trends = buildAlertTrends(alerts, '24h', now);
expect(trends.buckets.reduce((s, v) => s + v, 0)).toBe(0);
});
it('ignores alerts after now', () => {
const alerts = [makeItem({ startTime: new Date(now + 5 * MS_PER_HOUR).toISOString() })];
const trends = buildAlertTrends(alerts, '24h', now);
expect(trends.buckets.reduce((s, v) => s + v, 0)).toBe(0);
});
it('reports max of at least 1 for empty buckets', () => {
const trends = buildAlertTrends([], '24h', now);
expect(trends.max).toBe(1);
});
});
// ---------------------------------------------------------------------------
// applyAlertHistoryWindow – selectedBar, all-range, cutoffs, sort
// ---------------------------------------------------------------------------
describe('applyAlertHistoryWindow', () => {
const now = Date.UTC(2026, 2, 22, 12, 0, 0);
it('filters to the selected bucket window', () => {
const trends = buildAlertTrends([], '24h', now);
const bucketIndex = 23;
const bucketStart = trends.bucketTimes[bucketIndex];
const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR;
const items: HistoryItem[] = [
makeItem({ id: 'inside', startTime: new Date(bucketStart + 1000).toISOString() }),
makeItem({ id: 'before', startTime: new Date(bucketStart - MS_PER_HOUR).toISOString() }),
makeItem({ id: 'at-end', startTime: new Date(bucketEnd).toISOString() }),
];
const result = applyAlertHistoryWindow({
filteredItems: items,
timeFilter: '24h',
selectedBarIndex: bucketIndex,
trends,
now,
});
expect(result.map((i) => i.id)).toEqual(['inside']);
});
it('does not apply time cutoff for "all" range with no selected bar', () => {
const trends = buildAlertTrends([], 'all', now);
const items: HistoryItem[] = [
makeItem({ id: 'recent', startTime: new Date(now - MS_PER_HOUR).toISOString() }),
makeItem({ id: 'ancient', startTime: new Date(now - 365 * 24 * MS_PER_HOUR).toISOString() }),
];
const result = applyAlertHistoryWindow({
filteredItems: items,
timeFilter: 'all',
selectedBarIndex: null,
trends,
now,
});
expect(result).toHaveLength(2);
});
it('applies 7d cutoff when no bar is selected', () => {
const trends = buildAlertTrends([], '7d', now);
const items: HistoryItem[] = [
makeItem({ id: 'within', startTime: new Date(now - 3 * 24 * MS_PER_HOUR).toISOString() }),
makeItem({ id: 'beyond', startTime: new Date(now - 10 * 24 * MS_PER_HOUR).toISOString() }),
];
const result = applyAlertHistoryWindow({
filteredItems: items,
timeFilter: '7d',
selectedBarIndex: null,
trends,
now,
});
expect(result.map((i) => i.id)).toEqual(['within']);
});
it('sorts items by startTime descending', () => {
const trends = buildAlertTrends([], 'all', now);
const items: HistoryItem[] = [
makeItem({ id: 'oldest', startTime: new Date(now - 100 * MS_PER_HOUR).toISOString() }),
makeItem({ id: 'newest', startTime: new Date(now - 1 * MS_PER_HOUR).toISOString() }),
makeItem({ id: 'middle', startTime: new Date(now - 50 * MS_PER_HOUR).toISOString() }),
];
const result = applyAlertHistoryWindow({
filteredItems: items,
timeFilter: 'all',
selectedBarIndex: null,
trends,
now,
});
expect(result.map((i) => i.id)).toEqual(['newest', 'middle', 'oldest']);
});
});
// ---------------------------------------------------------------------------
// groupAlertHistoryItems
// ---------------------------------------------------------------------------
describe('groupAlertHistoryItems', () => {
it('returns empty array for empty input', () => {
expect(groupAlertHistoryItems([])).toHaveLength(0);
});
it('groups same-day alerts into one group', () => {
const items = [
makeItem({ id: 'a', startTime: '2026-01-15T12:00:00.000Z' }),
makeItem({ id: 'b', startTime: '2026-01-15T14:00:00.000Z' }),
];
const groups = groupAlertHistoryItems(items);
expect(groups).toHaveLength(1);
expect(groups[0].alerts).toHaveLength(2);
});
it('sorts groups descending by date', () => {
const items = [
makeItem({ id: 'old', startTime: '2026-01-15T12:00:00.000Z' }),
makeItem({ id: 'new', startTime: '2026-06-15T12:00:00.000Z' }),
];
const groups = groupAlertHistoryItems(items);
expect(groups).toHaveLength(2);
expect(groups[0].date.getTime()).toBeGreaterThan(groups[1].date.getTime());
});
it('labels today and yesterday relative to the current date', () => {
const realNow = new Date();
const todayMidnight = new Date(
realNow.getFullYear(),
realNow.getMonth(),
realNow.getDate(),
);
const yesterdayMidnight = new Date(todayMidnight);
yesterdayMidnight.setDate(yesterdayMidnight.getDate() - 1);
const items = [
makeItem({
id: 'today',
startTime: new Date(todayMidnight.getTime() + 3600000).toISOString(),
}),
makeItem({
id: 'yesterday',
startTime: new Date(yesterdayMidnight.getTime() + 3600000).toISOString(),
}),
];
const groups = groupAlertHistoryItems(items);
expect(groups).toHaveLength(2);
expect(groups[0].label).toMatch(/^Today /);
expect(groups[1].label).toMatch(/^Yesterday /);
});
it('uses absolute date label for non-relative dates', () => {
const items = [makeItem({ startTime: '2026-01-15T12:00:00.000Z' })];
const groups = groupAlertHistoryItems(items);
expect(groups[0].label).not.toMatch(/^Today/);
expect(groups[0].label).not.toMatch(/^Yesterday/);
expect(groups[0].label).toMatch(/January/);
});
it('includes weekday and year in fullLabel', () => {
const items = [makeItem({ startTime: '2026-01-15T12:00:00.000Z' })];
const groups = groupAlertHistoryItems(items);
expect(groups[0].fullLabel).toMatch(/2026/);
expect(groups[0].fullLabel).toMatch(/January/);
});
});
// ---------------------------------------------------------------------------
// buildAlertAxisTicks
// ---------------------------------------------------------------------------
describe('buildAlertAxisTicks', () => {
const now = Date.UTC(2026, 2, 22, 12, 0, 0);
it('returns empty array when bucketTimes is empty', () => {
const trends: AlertTrendSeries = {
buckets: [],
max: 0,
bucketSize: 1,
bucketTimes: [],
rangeStart: now,
rangeHours: 0,
};
expect(buildAlertAxisTicks(trends, 'en-US')).toEqual([]);
});
it('returns empty array when bucketSize is zero', () => {
const trends: AlertTrendSeries = {
buckets: [1],
max: 1,
bucketSize: 0,
bucketTimes: [now],
rangeStart: now,
rangeHours: 1,
};
expect(buildAlertAxisTicks(trends, 'en-US')).toEqual([]);
});
it('places first tick at position 0 and last tick at position 1', () => {
const trends = buildAlertTrends([], '24h', now);
const ticks = buildAlertAxisTicks(trends, 'en-US');
expect(ticks.length).toBeGreaterThanOrEqual(2);
expect(ticks[0].position).toBe(0);
expect(ticks[0].align).toBe('start');
expect(ticks[ticks.length - 1].position).toBe(1);
expect(ticks[ticks.length - 1].align).toBe('end');
});
it('assigns center align to all intermediate ticks', () => {
const trends = buildAlertTrends([], '30d', now);
const ticks = buildAlertAxisTicks(trends, 'en-US');
for (let i = 1; i < ticks.length - 1; i++) {
expect(ticks[i].align).toBe('center');
}
});
it('produces non-empty string labels for every tick', () => {
const trends = buildAlertTrends([], '24h', now);
const ticks = buildAlertAxisTicks(trends, 'en-US');
for (const tick of ticks) {
expect(typeof tick.label).toBe('string');
expect(tick.label.length).toBeGreaterThan(0);
}
});
it('replaces the last tick when loop already reaches position 1', () => {
// Craft trends so bucketTimes has 2 entries but buckets.length is 1.
// totalDurationMs = 1h, loop hits indices 0 and 1,
// index 1 maps to position 1h/1h = 1.0 → replace branch.
const trends: AlertTrendSeries = {
buckets: [0],
max: 1,
bucketSize: 1,
bucketTimes: [now, now + MS_PER_HOUR],
rangeStart: now,
rangeHours: 1,
};
const ticks = buildAlertAxisTicks(trends, 'en-US');
expect(ticks).toHaveLength(2);
expect(ticks[0].position).toBe(0);
expect(ticks[1].position).toBe(1);
});
it('limits tick count to a reasonable maximum', () => {
const trends = buildAlertTrends([], '30d', now);
const ticks = buildAlertAxisTicks(trends, 'en-US');
expect(ticks.length).toBeLessThanOrEqual(7);
expect(ticks.length).toBeGreaterThanOrEqual(2);
});
});
@@ -0,0 +1,404 @@
import { describe, expect, it } from 'vitest';
import type { RawOverrideConfig } from '@/types/alerts';
import type { Resource } from '@/types/resource';
import {
buildProjectedOverrides,
normalizeRawOverridesConfig,
storageOverrideActionId,
storageOverrideIdCandidates,
} from '../alertOverridesModel';
const makeResource = (overrides: Partial<Resource>): Resource =>
({
id: 'resource-1',
name: 'resource-1',
type: 'vm',
...overrides,
}) as Resource;
const cpuThreshold = (trigger: number, clear: number): RawOverrideConfig =>
({ cpu: { trigger, clear } }) as RawOverrideConfig;
describe('alertOverridesModel coverage', () => {
describe('storageOverrideIdCandidates', () => {
it('prepends metricsTarget resource id when resourceType is storage', () => {
expect(
storageOverrideIdCandidates(
makeResource({
id: 'storage-hash',
metricsTarget: { resourceType: 'storage', resourceId: 'canonical-id' },
}),
),
).toEqual(['canonical-id', 'storage-hash']);
});
it('returns only resource id when metricsTarget resourceType is not storage', () => {
expect(
storageOverrideIdCandidates(
makeResource({
id: 'storage-hash',
metricsTarget: { resourceType: 'agent', resourceId: 'other' },
}),
),
).toEqual(['storage-hash']);
});
it('returns only resource id when metricsTarget is absent', () => {
expect(storageOverrideIdCandidates(makeResource({ id: 'storage-hash' }))).toEqual([
'storage-hash',
]);
});
});
describe('storageOverrideActionId', () => {
it('returns the first candidate (metricsTarget resource id)', () => {
expect(
storageOverrideActionId(
makeResource({
id: 'storage-hash',
metricsTarget: { resourceType: 'storage', resourceId: 'canonical-id' },
}),
),
).toBe('canonical-id');
});
it('falls back to resource id when no valid candidates exist', () => {
expect(storageOverrideActionId(makeResource({ id: '' }))).toBe('');
});
});
describe('buildSharedStorageLegacyKeyMap (via normalizeRawOverridesConfig)', () => {
it('skips non-shared storage resources', () => {
const storage = makeResource({
id: 's1',
name: 'local-lvm',
type: 'storage',
platformId: 'Main',
metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' },
storage: { shared: false, nodes: ['node-a'] },
});
expect(
normalizeRawOverridesConfig(
{ 'Main-node-a-local-lvm': cpuThreshold(90, 80) },
[storage],
),
).toEqual({ 'Main-node-a-local-lvm': cpuThreshold(90, 80) });
});
it('skips resources with whitespace-only name', () => {
const storage = makeResource({
id: 's1',
name: ' ',
type: 'storage',
platformId: 'Main',
metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' },
storage: { shared: true, nodes: ['node-a'] },
});
expect(
normalizeRawOverridesConfig(
{ 'Main-node-a-': cpuThreshold(90, 80) },
[storage],
),
).toEqual({ 'Main-node-a-': cpuThreshold(90, 80) });
});
it('skips resources with empty canonical id', () => {
const storage = makeResource({
id: '',
name: 'ceph-pool',
type: 'storage',
platformId: 'Main',
storage: { shared: true, nodes: ['node-a'] },
});
expect(
normalizeRawOverridesConfig(
{ 'Main-node-a-ceph-pool': cpuThreshold(90, 80) },
[storage],
),
).toEqual({ 'Main-node-a-ceph-pool': cpuThreshold(90, 80) });
});
it('uses platformId as instance when proxmox.instance is absent', () => {
const storage = makeResource({
id: 's1',
name: 'nfs-share',
type: 'storage',
platformId: 'DC1',
metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' },
storage: { shared: true, nodes: ['node-a'] },
});
expect(
normalizeRawOverridesConfig(
{ 'DC1-node-a-nfs-share': cpuThreshold(90, 80) },
[storage],
),
).toEqual({ 'canonical-1': cpuThreshold(90, 80) });
});
it('does not double-prefix when node already starts with instance', () => {
const storage = makeResource({
id: 's1',
name: 'ceph-pool',
type: 'storage',
platformId: 'pve1',
metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' },
storage: { shared: true, nodes: ['pve1-node-a'] },
});
expect(
normalizeRawOverridesConfig(
{ 'pve1-node-a-ceph-pool': cpuThreshold(90, 80) },
[storage],
),
).toEqual({ 'canonical-1': cpuThreshold(90, 80) });
});
it('skips empty and whitespace-only node entries', () => {
const storage = makeResource({
id: 's1',
name: 'ceph-pool',
type: 'storage',
platformId: 'Main',
metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' },
storage: { shared: true, nodes: ['valid-node', '', ' '] },
});
expect(
normalizeRawOverridesConfig(
{ 'Main-valid-node-ceph-pool': cpuThreshold(90, 80) },
[storage],
),
).toEqual({ 'canonical-1': cpuThreshold(90, 80) });
});
it('produces no legacy keys when storage has no nodes array', () => {
const storage = makeResource({
id: 's1',
name: 'ceph-pool',
type: 'storage',
platformId: 'Main',
metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' },
storage: { shared: true },
});
expect(
normalizeRawOverridesConfig(
{ 'Main-node-a-ceph-pool': cpuThreshold(90, 80) },
[storage],
),
).toEqual({ 'Main-node-a-ceph-pool': cpuThreshold(90, 80) });
});
it('uses bare node name when both proxmox.instance and platformId are empty', () => {
const storage = makeResource({
id: 's1',
name: 'ceph-pool',
type: 'storage',
platformId: '',
metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' },
storage: { shared: true, nodes: ['solo-node'] },
});
expect(
normalizeRawOverridesConfig(
{ 'solo-node-ceph-pool': cpuThreshold(90, 80) },
[storage],
),
).toEqual({ 'canonical-1': cpuThreshold(90, 80) });
});
});
describe('normalizeRawOverridesConfig (malformed/empty shapes)', () => {
it('returns empty object for empty input', () => {
expect(normalizeRawOverridesConfig({})).toEqual({});
});
it('passes through plain keys unchanged', () => {
expect(
normalizeRawOverridesConfig({ 'plain-key': cpuThreshold(90, 80) }),
).toEqual({ 'plain-key': cpuThreshold(90, 80) });
});
it('normalizes disk labels that collapse to empty into unknown', () => {
expect(
normalizeRawOverridesConfig({
'agent:agent-1/disk:!!!': cpuThreshold(90, 80),
}),
).toEqual({
'agent:agent-1/disk:unknown': cpuThreshold(90, 80),
});
});
it('resolves canonical guest key regardless of insertion order', () => {
expect(
normalizeRawOverridesConfig({
'cluster-a:node-1:100': cpuThreshold(95, 90),
'guest:cluster-a:100': cpuThreshold(70, 65),
}),
).toEqual({
'guest:cluster-a:100': cpuThreshold(70, 65),
});
});
});
describe('alertResourceIdCandidates (via buildProjectedOverrides)', () => {
it('resolves alert platform overrides through metricsTarget resource id candidate', () => {
const cluster = makeResource({
id: 'k8s-internal-uid',
type: 'k8s-cluster',
name: 'prod-cluster',
displayName: 'prod-cluster',
platformId: 'k8s-platform-1',
metricsTarget: { resourceType: 'k8s-cluster', resourceId: 'k8s-metrics-alias' },
kubernetes: { clusterName: 'prod-cluster' },
});
const result = buildProjectedOverrides({
rawConfig: { 'k8s-metrics-alias': cpuThreshold(90, 80) },
nodeResources: [],
vmResources: [],
containerResources: [],
storageResources: [],
agentResourceList: [],
containerRuntimeResources: [],
getChildren: () => [],
pbsInstanceById: new Map(),
allResources: [cluster],
});
expect(result).toEqual([
expect.objectContaining({
id: 'k8s-metrics-alias',
type: 'kubernetesCluster',
resourceType: 'Kubernetes Cluster',
instance: 'prod-cluster',
}),
]);
});
it('resolves alert platform overrides through platformId candidate', () => {
const cluster = makeResource({
id: 'k8s-internal-uid',
type: 'k8s-cluster',
name: 'prod-cluster',
displayName: 'prod-cluster',
platformId: 'k8s-platform-1',
kubernetes: { clusterName: 'prod-cluster' },
});
const result = buildProjectedOverrides({
rawConfig: { 'k8s-platform-1': cpuThreshold(90, 80) },
nodeResources: [],
vmResources: [],
containerResources: [],
storageResources: [],
agentResourceList: [],
containerRuntimeResources: [],
getChildren: () => [],
pbsInstanceById: new Map(),
allResources: [cluster],
});
expect(result).toEqual([
expect.objectContaining({
id: 'k8s-platform-1',
type: 'kubernetesCluster',
}),
]);
});
});
describe('upsertProjectedOverride (via buildProjectedOverrides)', () => {
it('inserts separate overrides for distinct projected ids', () => {
const guest1 = makeResource({
id: 'cluster-a:node-1:100',
name: 'vm-100',
type: 'vm',
proxmox: { vmid: 100, node: 'node-1', instance: 'cluster-a' },
platformData: { proxmox: { vmid: 100, node: 'node-1', instance: 'cluster-a' } },
});
const guest2 = makeResource({
id: 'cluster-a:node-1:101',
name: 'vm-101',
type: 'vm',
proxmox: { vmid: 101, node: 'node-1', instance: 'cluster-a' },
platformData: { proxmox: { vmid: 101, node: 'node-1', instance: 'cluster-a' } },
});
const result = buildProjectedOverrides({
rawConfig: {
'guest:cluster-a:100': cpuThreshold(70, 65),
'guest:cluster-a:101': cpuThreshold(80, 75),
},
nodeResources: [],
vmResources: [guest1, guest2],
containerResources: [],
storageResources: [],
agentResourceList: [],
containerRuntimeResources: [],
getChildren: () => [],
pbsInstanceById: new Map(),
});
expect(result).toHaveLength(2);
expect(result.map((o) => o.id)).toEqual(
expect.arrayContaining(['guest:cluster-a:100', 'guest:cluster-a:101']),
);
});
it('updates existing override when multiple storage keys resolve to the same canonical id', () => {
const storage = makeResource({
id: 'storage-hash',
name: 'ceph-pool',
displayName: 'ceph-pool',
type: 'storage',
platformId: 'Main',
metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' },
platformData: { node: 'cluster', instance: 'Main' },
});
const result = buildProjectedOverrides({
rawConfig: {
'canonical-1': cpuThreshold(70, 65),
'storage-hash': cpuThreshold(95, 90),
},
nodeResources: [],
vmResources: [],
containerResources: [],
storageResources: [storage],
agentResourceList: [],
containerRuntimeResources: [],
getChildren: () => [],
pbsInstanceById: new Map(),
});
expect(result).toHaveLength(1);
expect(result[0].id).toBe('canonical-1');
expect(result[0].thresholds.cpu).toBe(95);
});
});
describe('buildProjectedOverrides edge cases', () => {
it('skips raw config keys that match no known resource', () => {
expect(
buildProjectedOverrides({
rawConfig: { 'unknown-key': cpuThreshold(90, 80) },
nodeResources: [],
vmResources: [],
containerResources: [],
storageResources: [],
agentResourceList: [],
containerRuntimeResources: [],
getChildren: () => [],
pbsInstanceById: new Map(),
}),
).toEqual([]);
});
});
});
@@ -0,0 +1,520 @@
import { describe, expect, it } from 'vitest';
import type { Resource, ResourceRelationship } from '@/types/resource';
import {
buildDockerNetworkAttachmentRows,
dockerContainerPortLabel,
dockerContainerPortsSummary,
dockerResourceSearchHaystack,
filterDockerResources,
mapDockerContainerStatus,
} from '../dockerPageModel';
const makeResource = (
resource: Partial<Resource> & Pick<Resource, 'id' | 'type'>,
): Resource => ({
...resource,
name: resource.name ?? resource.id,
displayName: resource.displayName ?? resource.id,
platformId: resource.platformId ?? 'lab',
platformType: resource.platformType ?? 'docker',
sourceType: resource.sourceType ?? 'agent',
status: resource.status ?? 'online',
lastSeen: resource.lastSeen ?? 1_700_000_000_000,
});
const rel = (
sourceId: string,
targetId: string,
metadata?: Record<string, unknown>,
): ResourceRelationship => ({
sourceId,
targetId,
type: 'attached_to',
confidence: 1,
active: true,
discoverer: 'docker_adapter',
observedAt: '2026-01-01T00:00:00Z',
lastSeenAt: '2026-01-01T00:00:00Z',
...(metadata ? { metadata } : {}),
});
// ---------------------------------------------------------------------------
// TARGET 1: container-state ranking fallbacks (unknown / empty state)
// ---------------------------------------------------------------------------
describe('mapDockerContainerStatus — state fallbacks', () => {
it('returns Unknown when no container state or status is present', () => {
expect(
mapDockerContainerStatus(
makeResource({ id: 'c-empty', type: 'app-container', status: '' as unknown as Resource['status'], docker: {} }),
),
).toEqual({ variant: 'muted', label: 'Unknown' });
});
it('returns Unknown when docker is entirely absent and status is empty', () => {
expect(
mapDockerContainerStatus(
makeResource({ id: 'c-no-docker', type: 'app-container', status: '' as unknown as Resource['status'] }),
),
).toEqual({ variant: 'muted', label: 'Unknown' });
});
it('title-cases unrecognized container states as muted', () => {
expect(
mapDockerContainerStatus(
makeResource({ id: 'c-unknown', type: 'app-container', docker: { containerState: 'quarantined' } }),
),
).toEqual({ variant: 'muted', label: 'Quarantined' });
});
it('falls back to resource.status when containerState is absent', () => {
expect(
mapDockerContainerStatus(
makeResource({ id: 'c-status', type: 'app-container', status: 'restarting' as unknown as Resource['status'], docker: {} }),
),
).toEqual({ variant: 'warning', label: 'Restarting' });
});
it.each([
['created', 'Created'],
['paused', 'Paused'],
['stopped', 'Stopped'],
])('treats %s as a deliberately stopped state', (state, label) => {
expect(
mapDockerContainerStatus(
makeResource({ id: `c-${state}`, type: 'app-container', docker: { containerState: state } }),
),
).toEqual({ variant: 'muted', label });
});
it('treats removing as a fatal state with a title-cased label', () => {
expect(
mapDockerContainerStatus(
makeResource({ id: 'c-removing', type: 'app-container', docker: { containerState: 'removing' } }),
),
).toEqual({ variant: 'danger', label: 'Removing' });
});
});
// ---------------------------------------------------------------------------
// TARGET 2: image/tag normalization (no-tag, registry-prefixed, digest)
// ---------------------------------------------------------------------------
describe('dockerResourceSearchHaystack — image tags and digests', () => {
it('indexes registry-prefixed repo tags', () => {
const image = makeResource({
id: 'img-registry',
type: 'docker-image',
docker: {
image: 'ghcr.io/acme/api:latest',
repoTags: ['ghcr.io/acme/api:v2'],
},
});
expect(dockerResourceSearchHaystack(image)).toContain('ghcr.io/acme/api:v2');
expect(filterDockerResources([image], 'ghcr.io/acme/api:v2', 'all')).toHaveLength(1);
});
it('indexes repo digests with sha256 references', () => {
const image = makeResource({
id: 'img-digest',
type: 'docker-image',
docker: {
repoDigests: ['acme/api@sha256:deadbeefcafe'],
},
});
expect(dockerResourceSearchHaystack(image)).toContain('acme/api@sha256:deadbeefcafe');
expect(filterDockerResources([image], 'sha256:deadbeefcafe', 'all')).toHaveLength(1);
});
it('handles images with no repoTags or repoDigests without crashing', () => {
const image = makeResource({
id: 'img-bare',
type: 'docker-image',
docker: { image: 'nginx' },
});
expect(filterDockerResources([image], 'nginx', 'all')).toHaveLength(1);
// repoTags/repoDigests absent — they simply contribute nothing
expect(dockerResourceSearchHaystack(image)).not.toContain('@sha256');
});
it('matches the bare image name without a tag', () => {
const container = makeResource({
id: 'c-bare-img',
type: 'app-container',
docker: { image: 'redis' },
});
expect(filterDockerResources([container], 'redis', 'all')).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// TARGET 3: port-map dedupe (port token shapes and summary fallback)
// ---------------------------------------------------------------------------
describe('dockerContainerPortLabel — port token shapes', () => {
it('formats a fully mapped port with host IP', () => {
expect(
dockerContainerPortLabel({ ip: '0.0.0.0', publicPort: 8080, privatePort: 80, protocol: 'tcp' }),
).toBe('0.0.0.0:8080->80/tcp');
});
it('formats a fully mapped port without host IP', () => {
expect(dockerContainerPortLabel({ publicPort: 8080, privatePort: 80 })).toBe('8080->80/tcp');
});
it('exposes only the private port when no host binding exists', () => {
expect(dockerContainerPortLabel({ privatePort: 443, protocol: 'tcp' })).toBe('443/tcp');
});
it('formats a public port with IP when no private port exists', () => {
expect(dockerContainerPortLabel({ ip: '10.0.0.1', publicPort: 53, protocol: 'udp' })).toBe(
'10.0.0.1:53/udp',
);
});
it('formats a public port without IP when no private port exists', () => {
expect(dockerContainerPortLabel({ publicPort: 53 })).toBe('53/tcp');
});
it('falls back to just the protocol when neither port is present', () => {
expect(dockerContainerPortLabel({ protocol: 'udp' })).toBe('udp');
});
it('defaults to tcp when protocol is missing and no ports are set', () => {
expect(dockerContainerPortLabel({})).toBe('tcp');
});
});
describe('dockerContainerPortsSummary — summary and empty fallback', () => {
it('joins multiple port labels with commas', () => {
const resource = makeResource({
id: 'c-multi',
type: 'app-container',
docker: {
ports: [
{ ip: '0.0.0.0', publicPort: 80, privatePort: 8080, protocol: 'tcp' },
{ privatePort: 443, protocol: 'tcp' },
],
},
});
expect(dockerContainerPortsSummary(resource)).toBe('0.0.0.0:80->8080/tcp, 443/tcp');
});
it('returns an em dash when the ports array is empty', () => {
expect(
dockerContainerPortsSummary(
makeResource({ id: 'c-empty-ports', type: 'app-container', docker: { ports: [] } }),
),
).toBe('—');
});
it('returns an em dash when no ports field is present', () => {
expect(
dockerContainerPortsSummary(makeResource({ id: 'c-no-ports', type: 'app-container' })),
).toBe('—');
});
});
// ---------------------------------------------------------------------------
// TARGET 4: volume mount source-candidate guards (missing source/name)
// ---------------------------------------------------------------------------
describe('dockerResourceSearchHaystack — volume mount guards', () => {
it('indexes read-only mounts via rw=false', () => {
const container = makeResource({
id: 'c-ro',
type: 'app-container',
docker: {
mounts: [{ type: 'bind', source: '/host/config', destination: '/config', mode: 'Z', rw: false }],
},
});
expect(dockerResourceSearchHaystack(container)).toContain('read-only');
expect(filterDockerResources([container], 'read-only', 'all')).toHaveLength(1);
});
it('indexes read-write mounts via rw=true', () => {
const container = makeResource({
id: 'c-rw',
type: 'app-container',
docker: {
mounts: [{ type: 'bind', source: '/host/data', destination: '/data', rw: true }],
},
});
expect(dockerResourceSearchHaystack(container)).toContain('read-write');
expect(filterDockerResources([container], 'read-write', 'all')).toHaveLength(1);
});
it('omits read-only and read-write when rw is undefined', () => {
const container = makeResource({
id: 'c-rw-undef',
type: 'app-container',
docker: {
mounts: [{ type: 'volume', source: '/host/data', destination: '/data' }],
},
});
const haystack = dockerResourceSearchHaystack(container);
expect(haystack).not.toContain('read-only');
expect(haystack).not.toContain('read-write');
});
it('still indexes destination when mount source is missing', () => {
const container = makeResource({
id: 'c-no-src',
type: 'app-container',
docker: {
mounts: [{ type: 'volume', destination: '/var/lib/data' }],
},
});
expect(filterDockerResources([container], '/var/lib/data', 'all')).toHaveLength(1);
});
it('handles an anonymous volume mount with no source, name, or mode', () => {
const container = makeResource({
id: 'c-anon',
type: 'app-container',
docker: {
mounts: [{ type: 'volume', destination: '/data' }],
},
});
// Does not crash; resource id still indexed
expect(filterDockerResources([container], 'c-anon', 'all')).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// TARGET 5: network alias empty/missing
// ---------------------------------------------------------------------------
describe('buildDockerNetworkAttachmentRows — network alias and address fallbacks', () => {
it('uses ipv6 address when ipv4 is absent', () => {
const network = makeResource({
id: 'net-1',
type: 'docker-network',
name: 'frontend',
docker: { hostSourceId: 'host-1' },
});
const container = makeResource({
id: 'c-v6',
type: 'app-container',
name: 'api',
displayName: 'api',
status: 'running',
docker: {
hostSourceId: 'host-1',
containerState: 'running',
networks: [{ name: 'frontend', ipv6: 'fd00::42' }],
},
});
const rows = buildDockerNetworkAttachmentRows(network, [network, container]);
expect(rows).toHaveLength(1);
expect(rows[0].address).toBe('fd00::42');
});
it('falls back to relationship metadata ipv4 when no attachment address exists', () => {
const network = makeResource({
id: 'net-1',
type: 'docker-network',
name: 'frontend',
docker: { hostSourceId: 'host-1' },
});
const container = makeResource({
id: 'c-rel-addr',
type: 'app-container',
name: 'api',
displayName: 'api',
status: 'running',
relationships: [rel('c-rel-addr', 'net-1', { ipv4: '172.20.0.5' })],
docker: { hostSourceId: 'host-1', containerState: 'running' },
});
const rows = buildDockerNetworkAttachmentRows(network, [network, container]);
expect(rows).toHaveLength(1);
expect(rows[0].address).toBe('172.20.0.5');
});
it('returns an em dash when no address source is available', () => {
const network = makeResource({
id: 'net-1',
type: 'docker-network',
name: 'frontend',
docker: { hostSourceId: 'host-1' },
});
const container = makeResource({
id: 'c-no-addr',
type: 'app-container',
name: 'api',
displayName: 'api',
status: 'running',
relationships: [rel('c-no-addr', 'net-1')],
docker: { hostSourceId: 'host-1', containerState: 'running' },
});
const rows = buildDockerNetworkAttachmentRows(network, [network, container]);
expect(rows).toHaveLength(1);
expect(rows[0].address).toBe('—');
});
it('uses relationship metadata networkName when attachment is absent', () => {
const network = makeResource({
id: 'net-1',
type: 'docker-network',
name: 'frontend',
docker: { hostSourceId: 'host-1' },
});
const container = makeResource({
id: 'c-rel-name',
type: 'app-container',
name: 'api',
displayName: 'api',
status: 'running',
relationships: [rel('c-rel-name', 'net-1', { networkName: 'legacy-frontend' })],
docker: { hostSourceId: 'host-1', containerState: 'running' },
});
const rows = buildDockerNetworkAttachmentRows(network, [network, container]);
expect(rows).toHaveLength(1);
expect(rows[0].networkName).toBe('legacy-frontend');
});
it('falls back to the network display name when neither attachment nor relationship names exist', () => {
const network = makeResource({
id: 'net-1',
type: 'docker-network',
name: 'frontend',
docker: { hostSourceId: 'host-1' },
});
const container = makeResource({
id: 'c-fb-name',
type: 'app-container',
name: 'api',
displayName: 'api',
status: 'running',
relationships: [rel('c-fb-name', 'net-1')],
docker: { hostSourceId: 'host-1', containerState: 'running' },
});
const rows = buildDockerNetworkAttachmentRows(network, [network, container]);
expect(rows).toHaveLength(1);
expect(rows[0].networkName).toBe('frontend');
});
it('excludes containers when the network has no name and only a host-scope attachment exists', () => {
const network = makeResource({
id: 'net-2',
type: 'docker-network',
name: '',
displayName: '',
docker: { hostSourceId: 'host-1' },
});
const container = makeResource({
id: 'c-excluded',
type: 'app-container',
name: 'api',
displayName: 'api',
status: 'running',
docker: {
hostSourceId: 'host-1',
containerState: 'running',
networks: [{ name: '', ipv4: '10.0.0.1' }],
},
});
// findContainerNetworkAttachment returns undefined when networkName is empty,
// and no relationship exists, so the container is excluded.
expect(buildDockerNetworkAttachmentRows(network, [network, container])).toEqual([]);
});
it('matches by hostname when hostSourceId is present on only one side', () => {
const network = makeResource({
id: 'net-1',
type: 'docker-network',
name: 'frontend',
docker: { hostname: 'edge-01' },
});
const container = makeResource({
id: 'c-hostname',
type: 'app-container',
name: 'api',
displayName: 'api',
status: 'running',
docker: {
hostSourceId: 'host-1',
hostname: 'edge-01',
containerState: 'running',
networks: [{ name: 'frontend', ipv4: '10.0.0.1' }],
},
});
const rows = buildDockerNetworkAttachmentRows(network, [network, container]);
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('api');
});
it('excludes containers when hostnames differ and hostSourceId is only on one side', () => {
const network = makeResource({
id: 'net-1',
type: 'docker-network',
name: 'frontend',
docker: { hostname: 'edge-01' },
});
const container = makeResource({
id: 'c-hostname-mismatch',
type: 'app-container',
name: 'api',
displayName: 'api',
status: 'running',
docker: {
hostSourceId: 'host-2',
hostname: 'edge-02',
containerState: 'running',
networks: [{ name: 'frontend', ipv4: '10.0.0.1' }],
},
});
expect(buildDockerNetworkAttachmentRows(network, [network, container])).toEqual([]);
});
it('uses em dash for image when container has no image set', () => {
const network = makeResource({
id: 'net-1',
type: 'docker-network',
name: 'frontend',
docker: { hostSourceId: 'host-1' },
});
const container = makeResource({
id: 'c-no-img',
type: 'app-container',
name: 'api',
displayName: 'api',
status: 'running',
docker: {
hostSourceId: 'host-1',
containerState: 'running',
networks: [{ name: 'frontend', ipv4: '10.0.0.1' }],
},
});
const rows = buildDockerNetworkAttachmentRows(network, [network, container]);
expect(rows).toHaveLength(1);
expect(rows[0].image).toBe('—');
});
});
describe('dockerResourceSearchHaystack — network aliases', () => {
it('indexes network attachment names and addresses', () => {
const container = makeResource({
id: 'c-net',
type: 'app-container',
docker: {
networks: [{ name: 'bridge', ipv4: '172.17.0.2', ipv6: 'fd00::3' }],
},
});
const haystack = dockerResourceSearchHaystack(container);
expect(haystack).toContain('bridge');
expect(haystack).toContain('172.17.0.2');
expect(haystack).toContain('fd00::3');
expect(filterDockerResources([container], 'bridge', 'all')).toHaveLength(1);
});
it('handles an undefined networks array without crashing', () => {
const container = makeResource({
id: 'c-no-nets',
type: 'app-container',
docker: { containerState: 'running' },
});
expect(filterDockerResources([container], 'c-no-nets', 'all')).toHaveLength(1);
});
});
@@ -0,0 +1,290 @@
import { describe, expect, it } from 'vitest';
import type { PBSBackup } from '@/types/api';
import type { Resource } from '@/types/resource';
import {
buildProxmoxBackupRecoveryModel,
CURRENT_RECOVERY_MS,
getRecoveryAgeBand,
STALE_RECOVERY_MS,
} from '../proxmoxBackupRecoveryModel';
// ---------------------------------------------------------------------------
// Fixture builders (same shapes as the sibling test file)
// ---------------------------------------------------------------------------
const workload = (overrides: Partial<Resource>): Resource =>
({
id: 'vm-100',
type: 'vm',
name: 'web-01',
displayName: 'web-01',
platformId: 'pve-a',
platformType: 'proxmox-pve',
sourceType: 'api',
status: 'running',
lastSeen: Date.parse('2026-07-10T00:00:00Z'),
proxmox: { vmid: 100, node: 'node-a' },
...overrides,
}) as Resource;
const pbsBackup = (overrides: Partial<PBSBackup> = {}): PBSBackup => ({
id: 'pbs/main/ns1/vm/100/2026-07-09T00:00:00Z',
instance: 'pbs',
datastore: 'main',
namespace: 'ns1',
backupType: 'vm',
vmid: '100',
backupTime: '2026-07-09T00:00:00Z',
size: 1_000_000,
protected: false,
verified: true,
files: ['index.json.blob'],
owner: 'backup@pbs',
...overrides,
});
const NOW = Date.parse('2026-07-10T00:00:00Z');
const emptyModel = (pbsBackups: PBSBackup[], workloads: Resource[] = []) =>
buildProxmoxBackupRecoveryModel({
workloads,
pbsBackups,
archives: [],
snapshots: [],
tasks: [],
nowMs: NOW,
});
// ---------------------------------------------------------------------------
// getRecoveryAgeBand — threshold boundaries & non-finite inputs
// ---------------------------------------------------------------------------
describe('getRecoveryAgeBand boundary coverage', () => {
it('returns "current" at exactly the 7-day boundary (inclusive <=)', () => {
expect(getRecoveryAgeBand(NOW - CURRENT_RECOVERY_MS, NOW)).toBe('current');
});
it('returns "aging" one millisecond past the current boundary', () => {
expect(getRecoveryAgeBand(NOW - CURRENT_RECOVERY_MS - 1, NOW)).toBe('aging');
});
it('returns "aging" at exactly the 30-day boundary (inclusive <=)', () => {
expect(getRecoveryAgeBand(NOW - STALE_RECOVERY_MS, NOW)).toBe('aging');
});
it('returns "stale" one millisecond past the stale boundary', () => {
expect(getRecoveryAgeBand(NOW - STALE_RECOVERY_MS - 1, NOW)).toBe('stale');
});
it('returns "current" for a zero-age artifact (createdMs === nowMs)', () => {
expect(getRecoveryAgeBand(NOW, NOW)).toBe('current');
});
});
describe('getRecoveryAgeBand non-finite inputs', () => {
it('returns "unknown" for NaN createdMs', () => {
expect(getRecoveryAgeBand(NaN, NOW)).toBe('unknown');
});
it('returns "unknown" for Infinity createdMs', () => {
expect(getRecoveryAgeBand(Infinity, NOW)).toBe('unknown');
});
it('returns "unknown" when the computed age is non-finite (NaN nowMs)', () => {
// createdMs is finite so it passes the first guard, but ageMs = NaN - 1000 = NaN
expect(getRecoveryAgeBand(1000, NaN)).toBe('unknown');
});
});
// ---------------------------------------------------------------------------
// parseTimestampMs — seconds vs ms vs unparseable (observed via createdMs)
// ---------------------------------------------------------------------------
describe('parseTimestampMs coverage (via PBS backupTime -> createdMs)', () => {
it('parses a valid ISO 8601 timestamp into epoch milliseconds', () => {
const ts = '2026-07-09T12:30:45Z';
const model = emptyModel([pbsBackup({ backupTime: ts })]);
expect(model.recoverableArtifacts[0].createdMs).toBe(Date.parse(ts));
});
it('returns undefined createdMs for an empty timestamp string', () => {
const model = emptyModel([pbsBackup({ backupTime: '' })]);
expect(model.recoverableArtifacts[0].createdMs).toBeUndefined();
});
it('returns undefined createdMs for an unparseable garbage string', () => {
const model = emptyModel([pbsBackup({ backupTime: 'not-a-date' })]);
expect(model.recoverableArtifacts[0].createdMs).toBeUndefined();
});
it('does not interpret a bare epoch-seconds string as a valid timestamp', () => {
// Date.parse('1752019200') returns NaN — the function must not confuse
// raw epoch-seconds with an ISO date.
const model = emptyModel([pbsBackup({ backupTime: '1752019200' })]);
expect(model.recoverableArtifacts[0].createdMs).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// fallbackWorkloadId — host zero-vmid hint paths (via PBS host backups)
// ---------------------------------------------------------------------------
describe('fallbackWorkloadId coverage (via host PBS backups with no inventory)', () => {
it('falls back to the first non-root PBS hint when host vmid is zero', () => {
// hints = [namespace, datastore] = ['root', 'main']; 'root' filtered, 'main' survives
const model = emptyModel([
pbsBackup({
id: 'host-zero-root-ns',
backupType: 'host',
vmid: '0',
namespace: 'root',
datastore: 'main',
}),
]);
expect(model.recoverableArtifacts[0].workload.vmid).toBe('main');
});
it('returns empty vmid when every host hint is root-like', () => {
// firstHostHint filters both 'root' and '(root)'
const model = emptyModel([
pbsBackup({
id: 'host-root-only',
backupType: 'host',
vmid: '0',
namespace: 'root',
datastore: '(root)',
}),
]);
expect(model.recoverableArtifacts[0].workload.vmid).toBe('');
});
it('returns empty vmid when host hints are all blank', () => {
// firstHostHint trims to '' which is falsy
const model = emptyModel([
pbsBackup({
id: 'host-blank-hints',
backupType: 'host',
vmid: '0',
namespace: '',
datastore: '',
}),
]);
expect(model.recoverableArtifacts[0].workload.vmid).toBe('');
});
});
// ---------------------------------------------------------------------------
// workloadFallbackLabel — label branches for host-no-id, unknown-no-id,
// vm-no-id (existing tests already cover "LXC backup" and "<Type> <id>")
// ---------------------------------------------------------------------------
describe('workloadFallbackLabel coverage (via fallback workloads)', () => {
it('renders "Host backup" for a host with no resolvable id', () => {
const model = emptyModel([
pbsBackup({
id: 'host-no-id',
backupType: 'host',
vmid: '0',
namespace: '',
datastore: '',
}),
]);
expect(model.recoverableArtifacts[0].workload.label).toBe('Host backup');
});
it('renders "Guest" for an unknown backup type with no vmid', () => {
const model = emptyModel([
pbsBackup({
id: 'unknown-no-id',
backupType: 'backup',
vmid: '0',
}),
]);
expect(model.recoverableArtifacts[0].workload.label).toBe('Guest');
});
it('renders "VM backup" for a VM with no vmid', () => {
const model = emptyModel([
pbsBackup({
id: 'vm-no-id',
backupType: 'qemu',
vmid: '0',
}),
]);
expect(model.recoverableArtifacts[0].workload.label).toBe('VM backup');
});
});
// ---------------------------------------------------------------------------
// resourceVmid — meta vs platformData precedence (via candidate vmid)
// ---------------------------------------------------------------------------
describe('resourceVmid coverage (via buildCandidateFromResource)', () => {
it('prefers proxmox meta vmid over platformData', () => {
const model = emptyModel([], [
workload({
id: 'vm-300',
proxmox: { vmid: 300, node: 'node-a' },
platformData: { proxmox: { vmid: 999 } },
}),
]);
expect(model.coverageRows[0].workload.vmid).toBe('300');
});
it('reads a numeric vmid from platformData.proxmox when meta vmid is absent', () => {
const model = emptyModel([], [
workload({
id: 'vm-500',
proxmox: { node: 'node-a' },
platformData: { proxmox: { vmid: 500 } },
}),
]);
expect(model.coverageRows[0].workload.vmid).toBe('500');
});
it('reads a string vmid from platformData.proxmox when meta vmid is absent', () => {
const model = emptyModel([], [
workload({
id: 'vm-600',
proxmox: { node: 'node-a' },
platformData: { proxmox: { vmid: '600' } },
}),
]);
expect(model.coverageRows[0].workload.vmid).toBe('600');
});
it('produces no candidate when neither meta nor platformData provides a vmid', () => {
const model = emptyModel([], [
workload({
id: 'vm-noid',
proxmox: { node: 'node-a' },
}),
]);
expect(model.coverageRows).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// backupTypeLabel — qemu/vm/lxc/unknown branches (existing tests cover
// 'ct' and 'host')
// ---------------------------------------------------------------------------
describe('backupTypeLabel coverage (via PBS backupType)', () => {
it.each([
['qemu', 'vm'],
['vm', 'vm'],
['lxc', 'ct'],
['backup', 'unknown'],
])('maps backupType "%s" to workload type "%s"', (backupType, expected) => {
const model = emptyModel([
pbsBackup({
id: `pbs-${backupType}`,
backupType,
vmid: '0',
}),
]);
expect(model.recoverableArtifacts[0].workload.type).toBe(expected);
});
});
@@ -0,0 +1,239 @@
import { describe, expect, it } from 'vitest';
import type { UnifiedFinding } from '@/stores/aiIntelligence';
import {
doesFindingNeedAttention,
getInvestigationConfidenceBadgeClasses,
getInvestigationConfidenceBadgeTone,
getInvestigationOutcomeSortOrder,
hasFindingInvestigationHandoffPointer,
} from '@/utils/aiFindingPresentation';
// Mirrors the module-private fallback so the test asserts the real fallback
// behaviour rather than echoing a mock. These literals are the contract.
const DEFAULT_BADGE_CLASSES = 'border-border bg-surface-alt text-muted';
describe('getInvestigationConfidenceBadgeClasses', () => {
it('returns the high-confidence (emerald) tier classes', () => {
expect(getInvestigationConfidenceBadgeClasses('high')).toBe(
'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300',
);
});
it('returns the medium-confidence (neutral surface) tier classes', () => {
expect(getInvestigationConfidenceBadgeClasses('medium')).toBe(
'border-border bg-surface-alt text-base-content',
);
});
it('returns the low-confidence (amber) tier classes', () => {
expect(getInvestigationConfidenceBadgeClasses('low')).toBe(
'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-300',
);
});
it('falls back to the default classes for an unrecognised confidence', () => {
expect(getInvestigationConfidenceBadgeClasses('critical')).toBe(DEFAULT_BADGE_CLASSES);
});
it('falls back to the default classes for the empty string', () => {
expect(getInvestigationConfidenceBadgeClasses('')).toBe(DEFAULT_BADGE_CLASSES);
});
it('distinguishes the medium tier from the unknown fallback by content', () => {
// Medium keeps base-content text; the unknown fallback uses text-muted.
expect(getInvestigationConfidenceBadgeClasses('medium')).toContain('text-base-content');
expect(getInvestigationConfidenceBadgeClasses('unknown')).toContain('text-muted');
});
});
describe('getInvestigationConfidenceBadgeTone (confidence tone map miss)', () => {
it('maps each known confidence to its tone', () => {
expect(getInvestigationConfidenceBadgeTone('high')).toBe('success');
expect(getInvestigationConfidenceBadgeTone('medium')).toBe('neutral');
expect(getInvestigationConfidenceBadgeTone('low')).toBe('warning');
});
it('falls back to the muted tone for an unknown confidence', () => {
expect(getInvestigationConfidenceBadgeTone('very-sure')).toBe('muted');
});
it('falls back to the muted tone for the empty string', () => {
expect(getInvestigationConfidenceBadgeTone('')).toBe('muted');
});
});
describe('hasFindingInvestigationHandoffPointer', () => {
const base = {
investigationOutcome: undefined as UnifiedFinding['investigationOutcome'],
investigationSessionId: undefined as string | undefined,
lastInvestigatedAt: undefined as string | undefined,
};
it('is true when an investigation outcome is recorded', () => {
expect(hasFindingInvestigationHandoffPointer({ ...base, investigationOutcome: 'fix_failed' }))
.toBe(true);
});
it('is true when a session id is present even without an outcome', () => {
expect(
hasFindingInvestigationHandoffPointer({ ...base, investigationSessionId: 'sess-123' }),
).toBe(true);
});
it('is true when only a last-investigated timestamp is present', () => {
expect(
hasFindingInvestigationHandoffPointer({ ...base, lastInvestigatedAt: '2026-07-01T00:00:00Z' }),
).toBe(true);
});
it('is false when no handoff field is set', () => {
expect(hasFindingInvestigationHandoffPointer(base)).toBe(false);
});
it('is false when every handoff field is an empty string rather than absent', () => {
expect(
hasFindingInvestigationHandoffPointer({
investigationOutcome: '' as unknown as UnifiedFinding['investigationOutcome'],
investigationSessionId: '',
lastInvestigatedAt: '',
}),
).toBe(false);
});
});
describe('getInvestigationOutcomeSortOrder', () => {
it.each([
['fix_verification_failed', 0],
['fix_failed', 0],
['fix_verification_unknown', 1],
['fix_rejected', 1],
['timed_out', 1],
['needs_attention', 1],
['cannot_fix', 1],
['fix_queued', 2],
] as const)('ranks outcome %s at %i', (outcome, expected) => {
expect(getInvestigationOutcomeSortOrder(outcome)).toBe(expected);
});
it('defaults positive-track outcomes that are intentionally unmapped to 3', () => {
expect(getInvestigationOutcomeSortOrder('resolved')).toBe(3);
expect(getInvestigationOutcomeSortOrder('fix_executed')).toBe(3);
expect(getInvestigationOutcomeSortOrder('fix_verified')).toBe(3);
});
it('defaults an unrecognised outcome to 3', () => {
expect(getInvestigationOutcomeSortOrder('not-a-real-outcome')).toBe(3);
});
it('returns 3 for undefined (no outcome)', () => {
expect(getInvestigationOutcomeSortOrder(undefined)).toBe(3);
});
it('returns 3 for the empty string (treated as no outcome)', () => {
expect(getInvestigationOutcomeSortOrder('')).toBe(3);
});
it('ranks a failed verification before a queued fix', () => {
expect(getInvestigationOutcomeSortOrder('fix_verification_failed')).toBeLessThan(
getInvestigationOutcomeSortOrder('fix_queued'),
);
});
});
describe('doesFindingNeedAttention (ATTENTION_OUTCOMES set membership)', () => {
const attentionOutcomes = [
'fix_verification_failed',
'fix_verification_unknown',
'fix_failed',
'fix_rejected',
'timed_out',
'needs_attention',
'cannot_fix',
] as const;
it.each(attentionOutcomes)('flags an active finding with outcome %s as needing attention', (
outcome,
) => {
expect(
doesFindingNeedAttention({
id: 'f1',
status: 'active',
investigationOutcome: outcome,
}),
).toBe(true);
});
it('does not flag a non-attention outcome that is not awaiting approval', () => {
// fix_executed is positive progress, not in the attention set.
expect(
doesFindingNeedAttention({
id: 'f2',
status: 'active',
investigationOutcome: 'fix_executed',
}),
).toBe(false);
});
it('does not flag resolved/verified outcomes', () => {
expect(
doesFindingNeedAttention({
id: 'f3',
status: 'active',
investigationOutcome: 'fix_verified',
}),
).toBe(false);
expect(
doesFindingNeedAttention({
id: 'f4',
status: 'active',
investigationOutcome: 'resolved',
}),
).toBe(false);
});
it('returns false when the finding is not active, regardless of outcome', () => {
expect(
doesFindingNeedAttention({
id: 'f5',
status: 'resolved',
investigationOutcome: 'fix_failed',
}),
).toBe(false);
});
it('returns false when an active finding has no investigation outcome', () => {
expect(
doesFindingNeedAttention({
id: 'f6',
status: 'active',
investigationOutcome: undefined,
}),
).toBe(false);
});
it('flags a queued fix that has no live approval', () => {
expect(
doesFindingNeedAttention(
{ id: 'f7', status: 'active', investigationOutcome: 'fix_queued' },
[],
),
).toBe(true);
});
it('does not flag a queued fix that still has a live pending approval', () => {
const now = Date.now();
const approval = {
status: 'pending' as const,
toolId: 'investigation_fix',
targetId: 'f7',
expiresAt: new Date(now + 60_000).toISOString(),
};
expect(
doesFindingNeedAttention(
{ id: 'f7', status: 'active', investigationOutcome: 'fix_queued' },
[approval],
),
).toBe(false);
});
});