mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 12:15:42 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
+290
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user