mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 02:28:59 +00:00
test(frontend): branch coverage for 7 pure modules (GLM wave 0712 tier 2d)
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
This commit is contained in:
parent
48ecb93848
commit
906d4823aa
7 changed files with 2484 additions and 0 deletions
|
|
@ -0,0 +1,194 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatAssistantTurnDuration,
|
||||
getAssistantTurnSummary,
|
||||
} from '../assistantTurnSummary';
|
||||
import type { ChatMessage } from '../types';
|
||||
|
||||
// Branch-coverage companion to assistantTurnSummary.test.ts. Each test below
|
||||
// drives a specific arm (early return, ternary branch, optional chain, `||`
|
||||
// fallback, guard) of the three named functions and asserts against concrete
|
||||
// outputs rather than truthiness. assistantTokenSummary is module-private, so
|
||||
// every one of its branches is exercised through getAssistantTurnSummary and
|
||||
// observed via the embedded label/title text (model + completedAt are omitted
|
||||
// to isolate the token-summary output where useful).
|
||||
|
||||
const baseAssistant = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
|
||||
id: 'asst-x',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date('2026-06-06T12:00:00Z'),
|
||||
isStreaming: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatAssistantTurnDuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatAssistantTurnDuration branch coverage', () => {
|
||||
it("returns '' when completedAt is omitted (the !completedAt guard)", () => {
|
||||
expect(formatAssistantTurnDuration(new Date('2026-06-06T12:00:00Z'))).toBe('');
|
||||
});
|
||||
|
||||
it("returns '' when the computed duration is non-finite (invalid completedAt -> NaN)", () => {
|
||||
// new Date('not-a-date') is an Invalid Date whose getTime() is NaN, so
|
||||
// durationMs is NaN and the !Number.isFinite arm fires.
|
||||
expect(
|
||||
formatAssistantTurnDuration(new Date('2026-06-06T12:00:00Z'), new Date('not-a-date')),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it("formats a sub-minute duration as plain seconds ('Ns')", () => {
|
||||
expect(
|
||||
formatAssistantTurnDuration(
|
||||
new Date('2026-06-06T12:00:00Z'),
|
||||
new Date('2026-06-06T12:00:05Z'),
|
||||
),
|
||||
).toBe('5s');
|
||||
});
|
||||
|
||||
it("formats an exact minute boundary with no seconds ('Xm', seconds ternary falsy arm)", () => {
|
||||
expect(
|
||||
formatAssistantTurnDuration(
|
||||
new Date('2026-06-06T12:00:00Z'),
|
||||
new Date('2026-06-06T12:02:00Z'),
|
||||
),
|
||||
).toBe('2m');
|
||||
});
|
||||
|
||||
it("formats hours with leftover minutes ('Xh Ym', remainingMinutes ternary truthy arm)", () => {
|
||||
// 1h 2m 5s -> minutes 62, seconds 5 -> hours 1, remainingMinutes 2.
|
||||
expect(
|
||||
formatAssistantTurnDuration(
|
||||
new Date('2026-06-06T12:00:00Z'),
|
||||
new Date('2026-06-06T13:02:05Z'),
|
||||
),
|
||||
).toBe('1h 2m');
|
||||
});
|
||||
|
||||
it("formats an exact hour with no leftover minutes ('Xh', remainingMinutes ternary falsy arm)", () => {
|
||||
// exactly 3600s -> minutes 60, seconds 0 -> hours 1, remainingMinutes 0.
|
||||
expect(
|
||||
formatAssistantTurnDuration(
|
||||
new Date('2026-06-06T12:00:00Z'),
|
||||
new Date('2026-06-06T13:00:00Z'),
|
||||
),
|
||||
).toBe('1h');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// assistantTokenSummary (module-private — reached via getAssistantTurnSummary).
|
||||
// model + completedAt omitted so label/title carry only the token summary.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('assistantTokenSummary branch coverage', () => {
|
||||
it("emits the singular 'token' word when the total is exactly 1", () => {
|
||||
expect(getAssistantTurnSummary(baseAssistant({ tokens: { input: 0, output: 1 } }))).toEqual({
|
||||
label: 'Last turn: 1 token',
|
||||
title: 'Last assistant turn summary: Usage: 1 total, 0 input, 1 output',
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps the context percentage to 100 when input exceeds the limit (Math.min arm)', () => {
|
||||
expect(
|
||||
getAssistantTurnSummary(
|
||||
baseAssistant({ tokens: { input: 50000, output: 100, contextLimit: 100 } }),
|
||||
),
|
||||
).toEqual({
|
||||
label: 'Last turn: 50,100 tokens (100% of context)',
|
||||
title:
|
||||
'Last assistant turn summary: Usage: 50,100 total, 50,000 input, 100 output, context 50,000 of 100 (100%)',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the context detail when input is 0 even with a contextLimit present (input>0 false arm)', () => {
|
||||
expect(
|
||||
getAssistantTurnSummary(
|
||||
baseAssistant({ tokens: { input: 0, output: 5, contextLimit: 1000 } }),
|
||||
),
|
||||
).toEqual({
|
||||
label: 'Last turn: 5 tokens',
|
||||
title: 'Last assistant turn summary: Usage: 5 total, 0 input, 5 output',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the context detail when contextLimit is 0 (contextLimit>0 false arm)', () => {
|
||||
expect(
|
||||
getAssistantTurnSummary(baseAssistant({ tokens: { input: 10, output: 5, contextLimit: 0 } })),
|
||||
).toEqual({
|
||||
label: 'Last turn: 15 tokens',
|
||||
title: 'Last assistant turn summary: Usage: 15 total, 10 input, 5 output',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getAssistantTurnSummary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getAssistantTurnSummary branch coverage', () => {
|
||||
it('falls back to the raw model id when no getModelRouteLabel option is supplied (?. absent + || model)', () => {
|
||||
expect(
|
||||
getAssistantTurnSummary(
|
||||
baseAssistant({
|
||||
model: 'openrouter:qwen/qwen3.7-plus',
|
||||
tokens: { input: 10, output: 5 },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
label: 'Last turn: openrouter:qwen/qwen3.7-plus · 15 tokens',
|
||||
title:
|
||||
'Last assistant turn summary: Model: openrouter:qwen/qwen3.7-plus. Usage: 15 total, 10 input, 5 output',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the raw model id when getModelRouteLabel returns an empty string (|| model falsy arm)', () => {
|
||||
expect(
|
||||
getAssistantTurnSummary(
|
||||
baseAssistant({ model: 'direct:local', tokens: { input: 2, output: 3 } }),
|
||||
{ getModelRouteLabel: () => '' },
|
||||
),
|
||||
).toEqual({
|
||||
label: 'Last turn: direct:local · 5 tokens',
|
||||
title: 'Last assistant turn summary: Model: direct:local. Usage: 5 total, 2 input, 3 output',
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the model part when model is whitespace-only (model?.trim()='' -> model falsy -> '')", () => {
|
||||
expect(
|
||||
getAssistantTurnSummary(
|
||||
baseAssistant({
|
||||
model: ' ',
|
||||
completedAt: new Date('2026-06-06T12:00:03Z'),
|
||||
tokens: { input: 1, output: 1 },
|
||||
}),
|
||||
{ getModelRouteLabel: () => 'Should Not Appear' },
|
||||
),
|
||||
).toEqual({
|
||||
label: 'Last turn: 3s · 2 tokens',
|
||||
title: 'Last assistant turn summary: Duration: 3s. Usage: 2 total, 1 input, 1 output',
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the model part when model is absent (message.model?. absent short-circuit)", () => {
|
||||
expect(
|
||||
getAssistantTurnSummary(
|
||||
baseAssistant({
|
||||
completedAt: new Date('2026-06-06T12:00:03Z'),
|
||||
tokens: { input: 10, output: 5 },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
label: 'Last turn: 3s · 15 tokens',
|
||||
title: 'Last assistant turn summary: Duration: 3s. Usage: 15 total, 10 input, 5 output',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when there is nothing to summarize (no model, no completedAt, tokens?. absent -> output<=0)', () => {
|
||||
// Drives: message.tokens?. short-circuit in assistantTokenSummary, the
|
||||
// output<=0 -> null guard, and the labelParts.length===0 -> null guard.
|
||||
expect(getAssistantTurnSummary(baseAssistant())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildVMwareDetailSections,
|
||||
buildVMwareDetailsSummary,
|
||||
} from '@/components/Infrastructure/resourceDetailDrawerVmwareModel';
|
||||
import type { DetailRow } from '@/components/shared/detailSectionModel';
|
||||
import type {
|
||||
ResourceVMwareMeta,
|
||||
ResourceVMwareSnapshot,
|
||||
} from '@/types/resource';
|
||||
|
||||
// Branch-coverage complement to resourceDetailDrawerVmwareModel.coverage.test.ts.
|
||||
// Scope is strictly: buildVMwareDetailsSummary, formatMiB, flattenSnapshotRows.
|
||||
// formatMiB and flattenSnapshotRows are module-private, so they are driven
|
||||
// through the public entry points exactly as the sibling coverage test does:
|
||||
// - formatMiB -> "Memory size" row of the Virtual hardware section
|
||||
// - flattenSnapshotRows -> "Snapshot tree" section rows
|
||||
// buildVMwareDetailsSummary is exported and is asserted on directly.
|
||||
|
||||
type Sections = ReturnType<typeof buildVMwareDetailSections>;
|
||||
|
||||
const findSection = (sections: Sections, label: string) =>
|
||||
sections.find((section) => section.label === label);
|
||||
|
||||
const findRow = (
|
||||
sections: Sections,
|
||||
sectionLabel: string,
|
||||
rowLabel: string,
|
||||
): DetailRow | undefined =>
|
||||
findSection(sections, sectionLabel)?.rows.find((row) => row.label === rowLabel);
|
||||
|
||||
const vmSections = (vmware?: Partial<ResourceVMwareMeta>): Sections =>
|
||||
buildVMwareDetailSections('vm', (vmware ?? {}) as ResourceVMwareMeta);
|
||||
|
||||
const hardwareRow = (
|
||||
vmware: Partial<ResourceVMwareMeta>,
|
||||
label: string,
|
||||
): DetailRow | undefined => findRow(vmSections(vmware), 'Virtual hardware', label);
|
||||
|
||||
const snapshotSectionRows = (snapshots: ResourceVMwareSnapshot[]): DetailRow[] =>
|
||||
findSection(vmSections({ snapshotTree: snapshots }), 'Snapshot tree')?.rows ?? [];
|
||||
|
||||
describe('buildVMwareDetailsSummary (additional branches)', () => {
|
||||
it('falls back to vcenterHost when connectionName is only whitespace', () => {
|
||||
expect(
|
||||
buildVMwareDetailsSummary('vm', { connectionName: ' ', vcenterHost: 'vc-02.lab' }),
|
||||
).toBe('vc-02.lab · Read-only vCenter context');
|
||||
});
|
||||
|
||||
it('omits the snapshot part for a non-vm type even when snapshotCount is positive', () => {
|
||||
expect(buildVMwareDetailsSummary('datastore', { snapshotCount: 5 })).toBe(
|
||||
'Read-only vCenter context',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits vNIC and disk counts for a non-vm type even when adapters/disks are present', () => {
|
||||
expect(
|
||||
buildVMwareDetailsSummary('datastore', {
|
||||
networkAdapters: [{ nic: '1' }],
|
||||
virtualDisks: [{ disk: '1', type: 'SCSI' }],
|
||||
}),
|
||||
).toBe('Read-only vCenter context');
|
||||
});
|
||||
|
||||
it('omits both host and vm counts for a network resource with no network membership', () => {
|
||||
expect(buildVMwareDetailsSummary('network', {})).toBe('Read-only vCenter context');
|
||||
});
|
||||
|
||||
it('counts only hosts when the network has hosts but no vms', () => {
|
||||
expect(buildVMwareDetailsSummary('network', { networkHostNames: ['esxi-01'] })).toBe(
|
||||
'Read-only vCenter context · 1 host',
|
||||
);
|
||||
});
|
||||
|
||||
it('counts only vms when the network has vms but no hosts', () => {
|
||||
expect(
|
||||
buildVMwareDetailsSummary('network', { networkVmNames: ['vm-01', 'vm-02'] }),
|
||||
).toBe('Read-only vCenter context · 2 VMs');
|
||||
});
|
||||
|
||||
it('clamps an explicit snapshotCount of 0 to no snapshot part', () => {
|
||||
expect(buildVMwareDetailsSummary('vm', { snapshotCount: 0 })).toBe(
|
||||
'Read-only vCenter context',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits alarm and task parts when both counts are explicitly zero', () => {
|
||||
expect(
|
||||
buildVMwareDetailsSummary('vm', { activeAlarmCount: 0, recentTaskCount: 0 }),
|
||||
).toBe('Read-only vCenter context');
|
||||
});
|
||||
|
||||
// SUSPECT SOURCE BEHAVIOR (reported, not fixed): the host/vm count chains use
|
||||
// `names?.length ?? ids?.length ?? 0`. Because an empty names array has
|
||||
// length 0 (not nullish), the `??` ids fallback is skipped, so present ids are
|
||||
// silently ignored whenever the names array exists but is empty.
|
||||
it('ignores networkHostIds when networkHostNames is an empty array (?? short-circuit)', () => {
|
||||
expect(
|
||||
buildVMwareDetailsSummary('network', {
|
||||
networkHostNames: [],
|
||||
networkHostIds: ['h-1', 'h-2'],
|
||||
}),
|
||||
).toBe('Read-only vCenter context');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMiB (via Memory size row)', () => {
|
||||
const memoryRow = (memorySizeMib?: number): DetailRow | undefined =>
|
||||
hardwareRow({ hardware: { version: 'VMX_10' }, memorySizeMib }, 'Memory size');
|
||||
|
||||
it('formats a fractional gibibyte value with compact precision', () => {
|
||||
expect(memoryRow(1536)?.value).toBe('1.5 GB');
|
||||
});
|
||||
|
||||
it('scales up to the terabyte unit for a large memory size', () => {
|
||||
expect(memoryRow(1_048_576)?.value).toBe('1 TB');
|
||||
});
|
||||
|
||||
it('keeps a sub-gigibyte value in megabytes', () => {
|
||||
expect(memoryRow(768)?.value).toBe('768 MB');
|
||||
});
|
||||
|
||||
it('returns empty for +Infinity (the !Number.isFinite guard arm)', () => {
|
||||
// memorySizeMib is typed number | undefined; Infinity is a valid number and
|
||||
// is rejected by formatMiB's Number.isFinite guard.
|
||||
expect(memoryRow(Number.POSITIVE_INFINITY)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns empty when MiB overflow makes bytes Infinity (the ?? "" fallback arm)', () => {
|
||||
// 2e302 is itself finite and non-negative, so it passes formatMiB's guard,
|
||||
// but 2e302 * 1024 * 1024 overflows to Infinity and formatDetailBytesValue
|
||||
// returns null, exercising formatMiB's `?? ""` fallback.
|
||||
expect(memoryRow(2e302)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenSnapshotRows (via Snapshot tree section)', () => {
|
||||
it('falls back to the snapshot ref when snapshotValue resolves to empty', () => {
|
||||
// No current/state/createdAt/quiesced/description => snapshotValue() is '',
|
||||
// so the row value comes from `|| asTrimmedString(snapshot.snapshot)`.
|
||||
expect(snapshotSectionRows([{ snapshot: 'snap-9' }])[0]).toEqual({
|
||||
label: 'snap-9',
|
||||
value: 'snap-9',
|
||||
tone: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a snapshot whose row makeDetailRow returns null, keeping valid siblings', () => {
|
||||
// A bare {} snapshot has no value (snapshotValue '' and no snapshot ref) so
|
||||
// makeDetailRow returns null and the row is skipped; the sibling survives.
|
||||
expect(
|
||||
snapshotSectionRows([{}, { snapshot: 's-1', state: 'poweredOn' }]).map((row) => row.label),
|
||||
).toEqual(['s-1']);
|
||||
});
|
||||
|
||||
it('indents grandchildren two levels deep via "-".repeat(depth)', () => {
|
||||
expect(
|
||||
snapshotSectionRows([
|
||||
{
|
||||
name: 'root',
|
||||
state: 'poweredOn',
|
||||
children: [
|
||||
{
|
||||
name: 'child',
|
||||
state: 'poweredOn',
|
||||
children: [{ name: 'grandchild', state: 'poweredOn' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]).map((row) => row.label),
|
||||
).toEqual(['root', '- child', '-- grandchild']);
|
||||
});
|
||||
|
||||
it('marks a non-current nested snapshot with the default tone', () => {
|
||||
const rows = snapshotSectionRows([
|
||||
{
|
||||
name: 'root',
|
||||
state: 'poweredOn',
|
||||
current: true,
|
||||
children: [{ name: 'child', state: 'poweredOn' }],
|
||||
},
|
||||
]);
|
||||
expect(rows).toEqual([
|
||||
{ label: 'root', value: 'current · poweredOn', tone: 'accent' },
|
||||
{ label: '- child', value: 'poweredOn', tone: 'default' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import {
|
||||
buildGuestAssistantContext,
|
||||
getGuestAssistantTechnology,
|
||||
} from '../guestAssistantContextModel';
|
||||
|
||||
// `firstTrimmed` is module-private (not exported), so it is exercised
|
||||
// indirectly through `getGuestAssistantTechnology`, which is its only caller.
|
||||
const makeGuest = (overrides?: Partial<WorkloadGuest>): WorkloadGuest =>
|
||||
({
|
||||
id: 'guest-0',
|
||||
vmid: 100,
|
||||
name: 'workload-0',
|
||||
node: 'pve',
|
||||
instance: 'cluster-a',
|
||||
status: 'running',
|
||||
type: 'qemu',
|
||||
cpu: 0.5,
|
||||
cpus: 2,
|
||||
memory: { total: 4096, used: 1024, free: 3072, usage: 0.25 },
|
||||
disk: { total: 102400, used: 10240, free: 92160, usage: 0.1 },
|
||||
networkIn: 100,
|
||||
networkOut: 200,
|
||||
diskRead: 10,
|
||||
diskWrite: 5,
|
||||
uptime: 3600,
|
||||
template: false,
|
||||
lastBackup: 0,
|
||||
tags: [],
|
||||
lock: '',
|
||||
lastSeen: new Date().toISOString(),
|
||||
workloadType: 'vm',
|
||||
...overrides,
|
||||
}) as WorkloadGuest;
|
||||
|
||||
describe('guestAssistantContextModel (branch coverage)', () => {
|
||||
describe('getGuestAssistantTechnology (drives firstTrimmed branches)', () => {
|
||||
describe('app-container arm — firstTrimmed([containerRuntime, platformType, type])', () => {
|
||||
it('returns the containerRuntime when present (first value wins, trimmed)', () => {
|
||||
expect(
|
||||
getGuestAssistantTechnology(
|
||||
makeGuest({
|
||||
workloadType: 'app-container',
|
||||
type: 'app-container',
|
||||
containerRuntime: ' podman ',
|
||||
platformType: 'docker',
|
||||
}),
|
||||
),
|
||||
).toBe('podman');
|
||||
});
|
||||
|
||||
it('skips a whitespace-only containerRuntime and falls back to platformType', () => {
|
||||
// Exercises firstTrimmed's `(value || '').trim()` -> '' (falsy) -> continue.
|
||||
expect(
|
||||
getGuestAssistantTechnology(
|
||||
makeGuest({
|
||||
workloadType: 'app-container',
|
||||
type: 'app-container',
|
||||
containerRuntime: ' ',
|
||||
platformType: 'docker',
|
||||
}),
|
||||
),
|
||||
).toBe('docker');
|
||||
});
|
||||
|
||||
it('falls back to platformType when containerRuntime is undefined', () => {
|
||||
expect(
|
||||
getGuestAssistantTechnology(
|
||||
makeGuest({
|
||||
workloadType: 'app-container',
|
||||
type: 'app-container',
|
||||
containerRuntime: undefined,
|
||||
platformType: 'docker',
|
||||
}),
|
||||
),
|
||||
).toBe('docker');
|
||||
});
|
||||
|
||||
it('falls back to type when containerRuntime and platformType are absent', () => {
|
||||
expect(
|
||||
getGuestAssistantTechnology(
|
||||
makeGuest({
|
||||
workloadType: 'app-container',
|
||||
type: 'app-container',
|
||||
containerRuntime: undefined,
|
||||
platformType: undefined,
|
||||
}),
|
||||
),
|
||||
).toBe('app-container');
|
||||
});
|
||||
|
||||
it('returns undefined when all three candidates are blank (loop completion)', () => {
|
||||
expect(
|
||||
getGuestAssistantTechnology(
|
||||
makeGuest({
|
||||
workloadType: 'app-container',
|
||||
type: '',
|
||||
containerRuntime: '',
|
||||
platformType: '',
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pod arm — firstTrimmed([platformType, 'kubernetes'])", () => {
|
||||
it('returns platformType when present', () => {
|
||||
expect(
|
||||
getGuestAssistantTechnology(
|
||||
makeGuest({
|
||||
workloadType: 'pod',
|
||||
type: 'pod',
|
||||
platformType: 'managed-aks',
|
||||
}),
|
||||
),
|
||||
).toBe('managed-aks');
|
||||
});
|
||||
|
||||
it("falls back to the 'kubernetes' constant when platformType is absent", () => {
|
||||
expect(
|
||||
getGuestAssistantTechnology(
|
||||
makeGuest({
|
||||
workloadType: 'pod',
|
||||
type: 'pod',
|
||||
platformType: undefined,
|
||||
}),
|
||||
),
|
||||
).toBe('kubernetes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default arm (vm / system-container) — firstTrimmed([platformType, type])', () => {
|
||||
it('returns platformType for a vm when present', () => {
|
||||
expect(
|
||||
getGuestAssistantTechnology(
|
||||
makeGuest({ workloadType: 'vm', type: 'qemu', platformType: 'proxmox-ve' }),
|
||||
),
|
||||
).toBe('proxmox-ve');
|
||||
});
|
||||
|
||||
it('falls back to type for a vm when platformType is absent', () => {
|
||||
expect(
|
||||
getGuestAssistantTechnology(
|
||||
makeGuest({ workloadType: 'vm', type: 'qemu', platformType: undefined }),
|
||||
),
|
||||
).toBe('qemu');
|
||||
});
|
||||
|
||||
it('returns undefined for a vm when both platformType and type are blank', () => {
|
||||
expect(
|
||||
getGuestAssistantTechnology(
|
||||
makeGuest({ workloadType: 'vm', type: '', platformType: '' }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns platformType for a system-container when present', () => {
|
||||
expect(
|
||||
getGuestAssistantTechnology(
|
||||
makeGuest({
|
||||
workloadType: 'system-container',
|
||||
type: 'lxc',
|
||||
platformType: 'incus',
|
||||
}),
|
||||
),
|
||||
).toBe('incus');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGuestAssistantContext', () => {
|
||||
describe('display-name resolution (guest.name || canonicalId)', () => {
|
||||
it('uses guest.name as the title and handoff name when present', () => {
|
||||
const result = buildGuestAssistantContext(
|
||||
makeGuest({
|
||||
id: 'vm-101',
|
||||
vmid: 101,
|
||||
name: 'web-vm',
|
||||
node: 'node-a',
|
||||
instance: 'pve-a',
|
||||
workloadType: 'vm',
|
||||
type: 'qemu',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.briefing?.title).toBe('web-vm');
|
||||
expect(result.handoffResources?.[0]?.name).toBe('web-vm');
|
||||
});
|
||||
|
||||
it('falls back to the canonical node-scoped id when name is empty', () => {
|
||||
const result = buildGuestAssistantContext(
|
||||
makeGuest({
|
||||
id: 'vm-101',
|
||||
vmid: 101,
|
||||
name: '',
|
||||
node: 'node-a',
|
||||
instance: 'pve-a',
|
||||
workloadType: 'vm',
|
||||
type: 'qemu',
|
||||
}),
|
||||
);
|
||||
|
||||
// canonicalId = buildCanonicalNodeScopedWorkloadId -> "pve-a:node-a:101"
|
||||
expect(result.targetId).toBe('pve-a:node-a:101');
|
||||
expect(result.briefing?.title).toBe('pve-a:node-a:101');
|
||||
expect(result.handoffResources?.[0]?.name).toBe('pve-a:node-a:101');
|
||||
});
|
||||
});
|
||||
|
||||
it('emits Primary/Parent/Discovery/readiness lines for a discovered VM', () => {
|
||||
const result = buildGuestAssistantContext(
|
||||
makeGuest({
|
||||
id: 'vm-101',
|
||||
vmid: 101,
|
||||
name: 'web-vm',
|
||||
node: 'node-a',
|
||||
instance: 'pve-a',
|
||||
status: 'running',
|
||||
workloadType: 'vm',
|
||||
type: 'qemu',
|
||||
platformType: 'proxmox-ve',
|
||||
discoveryTarget: {
|
||||
resourceType: 'vm',
|
||||
agentId: 'agent-1',
|
||||
resourceId: 'pve-a:node-a:101',
|
||||
},
|
||||
discoveryReadiness: { state: 'fresh', factCount: 5 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
targetType: 'resource',
|
||||
targetId: 'pve-a:node-a:101',
|
||||
context: {
|
||||
source: 'guest-drawer',
|
||||
resourceId: 'pve-a:node-a:101',
|
||||
resourceType: 'vm',
|
||||
resourceStatus: 'running',
|
||||
},
|
||||
briefing: {
|
||||
sourceLabel: 'Pulse resource context',
|
||||
title: 'web-vm',
|
||||
subject: 'vm / running / proxmox-ve',
|
||||
statusLabel: 'Read-only context attached · Discovery fresh',
|
||||
detailLines: [
|
||||
'Resource ID: pve-a:node-a:101',
|
||||
'Primary identity: vm-101',
|
||||
'Parent: node-a',
|
||||
'Discovery: vm:pve-a:node-a:101',
|
||||
'Discovery data: Discovery fresh, 5 facts',
|
||||
],
|
||||
safetyNote: 'Approval required before any action.',
|
||||
},
|
||||
handoffResources: [
|
||||
{ id: 'pve-a:node-a:101', name: 'web-vm', type: 'vm', node: 'node-a' },
|
||||
],
|
||||
handoffMetadata: { kind: 'resource_context' },
|
||||
autonomousMode: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits Primary/Parent/Discovery/readiness lines for a non-discovered app-container', () => {
|
||||
// canonicalId === guest.id for app-container, so the Primary identity line
|
||||
// is suppressed; platformType 'kubernetes' is not docker-managed so
|
||||
// resolveDiscoveryTargetForWorkload returns null.
|
||||
const result = buildGuestAssistantContext(
|
||||
makeGuest({
|
||||
id: 'app-container:sidecar',
|
||||
vmid: 0,
|
||||
name: 'sidecar',
|
||||
node: '',
|
||||
instance: '',
|
||||
status: 'exited',
|
||||
workloadType: 'app-container',
|
||||
type: 'app-container',
|
||||
platformType: 'kubernetes',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
targetType: 'resource',
|
||||
targetId: 'app-container:sidecar',
|
||||
context: {
|
||||
source: 'guest-drawer',
|
||||
resourceId: 'app-container:sidecar',
|
||||
resourceType: 'app-container',
|
||||
resourceStatus: 'exited',
|
||||
},
|
||||
briefing: {
|
||||
sourceLabel: 'Pulse resource context',
|
||||
title: 'sidecar',
|
||||
subject: 'app-container / exited / kubernetes',
|
||||
statusLabel: 'Read-only context attached',
|
||||
detailLines: ['Resource ID: app-container:sidecar'],
|
||||
safetyNote: 'Approval required before any action.',
|
||||
},
|
||||
handoffResources: [
|
||||
{ id: 'app-container:sidecar', name: 'sidecar', type: 'app-container', node: '' },
|
||||
],
|
||||
handoffMetadata: { kind: 'resource_context' },
|
||||
autonomousMode: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,692 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { CephCluster } from '@/types/api';
|
||||
import type { NormalizedHealth, StorageCategory, StorageRecord } from '@/features/storageBackups/models';
|
||||
import {
|
||||
consolidateCephClusterPoolRecords,
|
||||
getCephClusterKeyFromStorageRecord,
|
||||
getCephSummaryText,
|
||||
isCephClusterPoolStorageRecord,
|
||||
} from '@/features/storageBackups/cephRecordPresentation';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture builders — mirror cephRecordPresentation.test.ts so casts and
|
||||
// import paths match the sibling suite. The defaults describe a healthy PVE
|
||||
// cephfs mount ("not" a cluster-internal pool row) so individual cases can
|
||||
// opt into pool-row synthesis by overriding `details`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const makeRecord = (overrides: Partial<StorageRecord> = {}): StorageRecord => ({
|
||||
id: 'storage-1',
|
||||
name: 'ceph-pool',
|
||||
category: 'pool',
|
||||
health: 'healthy',
|
||||
location: { label: 'pve1', scope: 'node' },
|
||||
capacity: { totalBytes: 1_000, usedBytes: 400, freeBytes: 600, usagePercent: 40 },
|
||||
capabilities: ['capacity', 'replication'],
|
||||
source: {
|
||||
platform: 'proxmox-pve',
|
||||
family: 'virtualization',
|
||||
origin: 'resource',
|
||||
adapterId: 'resource-storage',
|
||||
},
|
||||
observedAt: Date.now(),
|
||||
details: { type: 'rbd', parentId: 'cluster-a' },
|
||||
refs: { platformEntityId: 'cluster-a' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// A cluster-internal Ceph pool row (models.StorageFromCephPool): type "ceph"
|
||||
// homed on the "cluster" pseudo-node.
|
||||
const makePoolRecord = (overrides: Partial<StorageRecord> = {}): StorageRecord =>
|
||||
makeRecord({
|
||||
id: 'pool-row',
|
||||
name: 'cephfs-data',
|
||||
health: 'warning',
|
||||
details: { type: 'ceph', node: 'cluster', status: 'degraded' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// A PVE-side mount row that is NOT itself a cluster pool row.
|
||||
const makeMountRecord = (overrides: Partial<StorageRecord> = {}): StorageRecord =>
|
||||
makeRecord({
|
||||
id: 'mount-row',
|
||||
name: 'cephfs-data',
|
||||
health: 'healthy',
|
||||
details: { type: 'cephfs', node: 'shared', status: 'online' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeCluster = (overrides: Partial<CephCluster> = {}): CephCluster =>
|
||||
({
|
||||
id: 'ceph-1',
|
||||
instance: 'cluster-a',
|
||||
name: 'cluster-a Ceph',
|
||||
health: 'HEALTH_OK',
|
||||
healthMessage: '',
|
||||
totalBytes: 1_000,
|
||||
usedBytes: 400,
|
||||
availableBytes: 600,
|
||||
usagePercent: 40,
|
||||
numMons: 3,
|
||||
numMgrs: 2,
|
||||
numOsds: 6,
|
||||
numOsdsUp: 6,
|
||||
numOsdsIn: 6,
|
||||
numPGs: 256,
|
||||
pools: [],
|
||||
services: [],
|
||||
lastUpdated: Date.now(),
|
||||
...overrides,
|
||||
}) as CephCluster;
|
||||
|
||||
// ===========================================================================
|
||||
// isCephClusterPoolStorageRecord
|
||||
// ===========================================================================
|
||||
|
||||
describe('isCephClusterPoolStorageRecord branch coverage', () => {
|
||||
it('returns false on the early-return arm when the derived type is not "ceph"', () => {
|
||||
// type defaults to 'rbd' → getStorageRecordType !== 'ceph' → false.
|
||||
expect(isCephClusterPoolStorageRecord(makeRecord())).toBe(false);
|
||||
// category-derived type: no details.type, category 'pool' → 'pool' → false.
|
||||
expect(
|
||||
isCephClusterPoolStorageRecord(
|
||||
makeRecord({ details: undefined, category: 'pool' }) as StorageRecord,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when type is "ceph" and details.node === "cluster"', () => {
|
||||
expect(
|
||||
isCephClusterPoolStorageRecord(
|
||||
makeRecord({ details: { type: 'ceph', node: 'cluster' } }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when type is "ceph" but details.node is not "cluster"', () => {
|
||||
// Exercises the `details.node === 'cluster'` false arm with a valid ceph type.
|
||||
expect(
|
||||
isCephClusterPoolStorageRecord(
|
||||
makeRecord({ details: { type: 'ceph', node: 'pve1' } }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('exercises the `(record.details || {})` defensive arm: details undefined with type "ceph"', () => {
|
||||
// details is undefined → `(record.details || {})` yields {} → details.node
|
||||
// is undefined → not 'cluster' → false. The type-only path proves the
|
||||
// `|| {}` branch is taken (no throw, undefined.node coerced via {}).
|
||||
expect(
|
||||
isCephClusterPoolStorageRecord(
|
||||
makeRecord({ details: { type: 'ceph' } }) as StorageRecord,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// consolidateCephClusterPoolRecords
|
||||
// (also exercises the module-private storageHealthRank, getRecordPoolDetail
|
||||
// and getRecordStatusDetail through the public surface)
|
||||
// ===========================================================================
|
||||
|
||||
describe('consolidateCephClusterPoolRecords branch coverage', () => {
|
||||
// ---- Branch: `poolRecords.length === 0` → returns the input reference ----
|
||||
it('short-circuits and returns the exact input reference when there are no pool rows', () => {
|
||||
const input: StorageRecord[] = [makeMountRecord(), makeMountRecord({ id: 'm2' })];
|
||||
// Early return at `if (poolRecords.length === 0) return records;` — same ref.
|
||||
expect(consolidateCephClusterPoolRecords(input)).toBe(input);
|
||||
});
|
||||
|
||||
// ---- Branch: `consumedPoolIds.size === 0` → returns the input reference --
|
||||
it('returns the exact input reference when pool rows exist but none match a mount', () => {
|
||||
// A pool row plus a non-ceph record: the pool finds no ceph sibling, so
|
||||
// consumedPoolIds stays empty and the second early-return fires.
|
||||
const orphanPool = makePoolRecord({ id: 'orphan', name: 'unmatched-pool' });
|
||||
const nonCeph = makeRecord({
|
||||
id: 'local',
|
||||
name: 'local-zfs',
|
||||
details: { type: 'zfspool', node: 'pve1' },
|
||||
capabilities: ['capacity'],
|
||||
});
|
||||
const input: StorageRecord[] = [orphanPool, nonCeph];
|
||||
expect(consolidateCephClusterPoolRecords(input)).toBe(input);
|
||||
});
|
||||
|
||||
// ---- Branch L83: `if (!pool) return record;` (record with no lifted pool) -
|
||||
it('passes unrelated records through unchanged (by reference) during consolidation', () => {
|
||||
const pool = makePoolRecord({ id: 'pool-1', name: 'cephfs-data', health: 'critical' });
|
||||
const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data' });
|
||||
const unrelated = makeRecord({
|
||||
id: 'unrelated-1',
|
||||
name: 'local-zfs',
|
||||
details: { type: 'zfspool', node: 'pve1' },
|
||||
capabilities: ['capacity'],
|
||||
});
|
||||
const consolidated = consolidateCephClusterPoolRecords([pool, mount, unrelated]);
|
||||
// Pool consumed; mount lifted; unrelated has no entry in liftBySiblingId
|
||||
// → the `if (!pool) return record;` arm returns it by reference.
|
||||
expect(consolidated.map((r) => r.id)).toEqual(['mount-1', 'unrelated-1']);
|
||||
const unrelatedOut = consolidated.find((r) => r.id === 'unrelated-1');
|
||||
expect(unrelatedOut).toBe(unrelated);
|
||||
});
|
||||
|
||||
// ---- Branch L72 (!existing → set) + L84 lift + L85 status-detail arm ----
|
||||
it('lifts the pool health onto a healthier mount and synthesizes a status from details.status', () => {
|
||||
const pool = makePoolRecord({
|
||||
id: 'pool-1',
|
||||
name: 'cephfs-data',
|
||||
health: 'warning',
|
||||
details: { type: 'ceph', node: 'cluster', status: 'degraded' },
|
||||
});
|
||||
const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data', health: 'healthy' });
|
||||
const [survivor] = consolidateCephClusterPoolRecords([pool, mount]);
|
||||
expect(survivor.id).toBe('mount-1');
|
||||
expect(survivor.health).toBe('warning');
|
||||
// getRecordStatusDetail string arm → 'degraded' feeds poolStatus.
|
||||
expect(survivor.statusLabel).toBe('degraded');
|
||||
expect(survivor.details?.status).toBe('degraded');
|
||||
// No issueSummary on the pool → synthesized fallback string.
|
||||
expect(survivor.issueSummary).toBe('Ceph reports pool cephfs-data degraded');
|
||||
});
|
||||
|
||||
// ---- Branch L72 (existing present, new pool sicker → replace) -----------
|
||||
it('keeps the sicker of two pools that mount onto the same sibling', () => {
|
||||
const poolWarning = makePoolRecord({
|
||||
id: 'pool-w',
|
||||
name: 'cephfs-data',
|
||||
health: 'warning',
|
||||
details: { type: 'ceph', node: 'cluster', status: 'degraded' },
|
||||
});
|
||||
const poolCritical = makePoolRecord({
|
||||
id: 'pool-c',
|
||||
name: 'cephfs-data',
|
||||
health: 'critical',
|
||||
details: { type: 'ceph', node: 'cluster', status: 'unavailable' },
|
||||
});
|
||||
const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data', health: 'healthy' });
|
||||
// storageHealthRank('critical')=4 > storageHealthRank('warning')=2 → replace.
|
||||
const consolidated = consolidateCephClusterPoolRecords([poolWarning, poolCritical, mount]);
|
||||
expect(consolidated.map((r) => r.id)).toEqual(['mount-1']);
|
||||
expect(consolidated[0].health).toBe('critical');
|
||||
expect(consolidated[0].statusLabel).toBe('unavailable');
|
||||
});
|
||||
|
||||
// ---- Branch L72 (existing present, new pool NOT sicker → keep existing) -
|
||||
it('does not replace the existing lift when the second pool is not sicker', () => {
|
||||
const poolCritical = makePoolRecord({
|
||||
id: 'pool-c',
|
||||
name: 'cephfs-data',
|
||||
health: 'critical',
|
||||
issueSummary: 'first pool wins',
|
||||
details: { type: 'ceph', node: 'cluster', status: 'unavailable' },
|
||||
});
|
||||
const poolWarning = makePoolRecord({
|
||||
id: 'pool-w',
|
||||
name: 'cephfs-data',
|
||||
health: 'warning',
|
||||
issueSummary: 'second pool loses',
|
||||
details: { type: 'ceph', node: 'cluster', status: 'degraded' },
|
||||
});
|
||||
const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data', health: 'healthy' });
|
||||
// storageHealthRank('warning')=2 > storageHealthRank('critical')=4 → false → keep critical.
|
||||
const [survivor] = consolidateCephClusterPoolRecords([poolCritical, poolWarning, mount]);
|
||||
expect(survivor.health).toBe('critical');
|
||||
// existing (critical) pool's issueSummary is preserved verbatim.
|
||||
expect(survivor.issueSummary).toBe('first pool wins');
|
||||
});
|
||||
|
||||
// ---- Branch L84: pool not sicker than mount → record unchanged by ref ----
|
||||
it('leaves a mount untouched (by reference) when the pool is not sicker than it', () => {
|
||||
const healthyPool = makePoolRecord({
|
||||
id: 'pool-1',
|
||||
name: 'cephfs-data',
|
||||
health: 'healthy',
|
||||
details: { type: 'ceph', node: 'cluster', status: 'available' },
|
||||
});
|
||||
const sickMount = makeMountRecord({
|
||||
id: 'mount-1',
|
||||
name: 'cephfs-data',
|
||||
health: 'critical',
|
||||
statusLabel: 'unavailable',
|
||||
});
|
||||
// storageHealthRank('healthy')=0 <= storageHealthRank('critical')=4 → return record.
|
||||
const consolidated = consolidateCephClusterPoolRecords([healthyPool, sickMount]);
|
||||
expect(consolidated).toEqual([sickMount]);
|
||||
expect(consolidated[0]).toBe(sickMount);
|
||||
});
|
||||
|
||||
// ---- storageHealthRank `?? 0` arm: unrecognized record health → rank 0 --
|
||||
it('treats an unrecognized mount health as rank 0 so a sicker pool still lifts', () => {
|
||||
const pool = makePoolRecord({
|
||||
id: 'pool-1',
|
||||
name: 'cephfs-data',
|
||||
health: 'critical',
|
||||
details: { type: 'ceph', node: 'cluster', status: 'unavailable' },
|
||||
});
|
||||
const mount = makeMountRecord({
|
||||
id: 'mount-1',
|
||||
name: 'cephfs-data',
|
||||
// 'quarantined' is not in STORAGE_HEALTH_SEVERITY_RANK → `?? 0`.
|
||||
health: 'quarantined' as unknown as NormalizedHealth,
|
||||
});
|
||||
const [survivor] = consolidateCephClusterPoolRecords([pool, mount]);
|
||||
// pool rank 4 > record rank 0 → lift happens; the bogus record health is
|
||||
// overwritten by the pool's 'critical'.
|
||||
expect(survivor.id).toBe('mount-1');
|
||||
expect(survivor.health).toBe('critical');
|
||||
});
|
||||
|
||||
// ---- getRecordPoolDetail string arm + trim(): pool detail with whitespace
|
||||
it('matches a mount via the backing pool detail and trims whitespace before comparing', () => {
|
||||
const pool = makePoolRecord({
|
||||
id: 'pool-1',
|
||||
name: 'vm-pool',
|
||||
health: 'critical',
|
||||
details: { type: 'ceph', node: 'cluster', status: 'unavailable' },
|
||||
});
|
||||
const mount = makeMountRecord({
|
||||
id: 'mount-1',
|
||||
name: 'fast-rbd',
|
||||
// details.pool has surrounding whitespace; getRecordPoolDetail trims it
|
||||
// to 'vm-pool', which then equals pool.name.
|
||||
details: { type: 'rbd', node: 'shared', pool: ' vm-pool ', status: 'online' },
|
||||
});
|
||||
const consolidated = consolidateCephClusterPoolRecords([pool, mount]);
|
||||
expect(consolidated.map((r) => r.id)).toEqual(['mount-1']);
|
||||
expect(consolidated[0].health).toBe('critical');
|
||||
});
|
||||
|
||||
// ---- getRecordPoolDetail non-string arm: pool detail is a non-string ----
|
||||
it('does not treat a non-string pool detail as a backing-pool match', () => {
|
||||
const pool = makePoolRecord({
|
||||
id: 'pool-1',
|
||||
name: 'vm-pool',
|
||||
health: 'critical',
|
||||
details: { type: 'ceph', node: 'cluster', status: 'unavailable' },
|
||||
});
|
||||
const mount = makeMountRecord({
|
||||
id: 'mount-1',
|
||||
name: 'different-name',
|
||||
// details.pool is a number → getRecordPoolDetail returns '' (the
|
||||
// `typeof pool === 'string'` false arm). '' !== 'vm-pool' and the names
|
||||
// differ too, so this pool is orphaned and the mount is left as-is.
|
||||
details: { type: 'rbd', node: 'shared', pool: 999, status: 'online' } as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
const input: StorageRecord[] = [pool, mount];
|
||||
// No sibling consumed → second early-return hands back the input reference.
|
||||
expect(consolidateCephClusterPoolRecords(input)).toBe(input);
|
||||
});
|
||||
|
||||
// ---- getRecordStatusDetail non-string arm → falls through to statusLabel -
|
||||
it('falls back to pool.statusLabel when details.status is not a string', () => {
|
||||
const pool = makePoolRecord({
|
||||
id: 'pool-1',
|
||||
name: 'cephfs-data',
|
||||
health: 'warning',
|
||||
statusLabel: 'down-label',
|
||||
details: {
|
||||
type: 'ceph',
|
||||
node: 'cluster',
|
||||
// status is a number → getRecordStatusDetail returns '' → the `||`
|
||||
// chain falls through to pool.statusLabel ('down-label').
|
||||
status: 42,
|
||||
} as Record<string, unknown>,
|
||||
});
|
||||
const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data', health: 'healthy' });
|
||||
const [survivor] = consolidateCephClusterPoolRecords([pool, mount]);
|
||||
expect(survivor.health).toBe('warning');
|
||||
expect(survivor.statusLabel).toBe('down-label');
|
||||
expect(survivor.details?.status).toBe('down-label');
|
||||
expect(survivor.issueSummary).toBe('Ceph reports pool cephfs-data down-label');
|
||||
});
|
||||
|
||||
// ---- getRecordStatusDetail empty → statusLabel empty → falls to health ---
|
||||
it('falls back to pool.health when neither details.status nor statusLabel resolve', () => {
|
||||
const pool = makePoolRecord({
|
||||
id: 'pool-1',
|
||||
name: 'cephfs-data',
|
||||
health: 'critical',
|
||||
statusLabel: undefined,
|
||||
details: {
|
||||
type: 'ceph',
|
||||
node: 'cluster',
|
||||
// status is a boolean → getRecordStatusDetail returns '' (non-string).
|
||||
status: true,
|
||||
} as Record<string, unknown>,
|
||||
});
|
||||
const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data', health: 'healthy' });
|
||||
const [survivor] = consolidateCephClusterPoolRecords([pool, mount]);
|
||||
// getRecordStatusDetail('') || statusLabel(undefined) || health('critical').
|
||||
expect(survivor.statusLabel).toBe('critical');
|
||||
expect(survivor.details?.status).toBe('critical');
|
||||
expect(survivor.issueSummary).toBe('Ceph reports pool cephfs-data critical');
|
||||
});
|
||||
|
||||
// ---- L91 `pool.issueSummary?.trim() || ...` truthy arm ------------------
|
||||
it('preserves the pool issueSummary verbatim when it trims to a non-empty value', () => {
|
||||
const pool = makePoolRecord({
|
||||
id: 'pool-1',
|
||||
name: 'cephfs-data',
|
||||
health: 'warning',
|
||||
issueSummary: 'Ceph cluster HEALTH_WARN',
|
||||
details: { type: 'ceph', node: 'cluster', status: 'degraded' },
|
||||
});
|
||||
const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data', health: 'healthy' });
|
||||
const [survivor] = consolidateCephClusterPoolRecords([pool, mount]);
|
||||
expect(survivor.issueSummary).toBe('Ceph cluster HEALTH_WARN');
|
||||
});
|
||||
|
||||
// ---- L91 `pool.issueSummary?.trim() || ...` whitespace-only arm ---------
|
||||
it('synthesizes the issue summary when the pool issueSummary is only whitespace', () => {
|
||||
const pool = makePoolRecord({
|
||||
id: 'pool-1',
|
||||
name: 'cephfs-data',
|
||||
health: 'warning',
|
||||
issueSummary: ' ',
|
||||
details: { type: 'ceph', node: 'cluster', status: 'degraded' },
|
||||
});
|
||||
const mount = makeMountRecord({ id: 'mount-1', name: 'cephfs-data', health: 'healthy' });
|
||||
const [survivor] = consolidateCephClusterPoolRecords([pool, mount]);
|
||||
// ' '.trim() === '' → falsy → synthesized fallback string.
|
||||
expect(survivor.issueSummary).toBe('Ceph reports pool cephfs-data degraded');
|
||||
});
|
||||
|
||||
// ---- L92 `{ ...(record.details || {}), status: poolStatus }` arm --------
|
||||
it('preserves the surviving mount details and overlays the lifted status', () => {
|
||||
const pool = makePoolRecord({
|
||||
id: 'pool-1',
|
||||
name: 'cephfs-data',
|
||||
health: 'warning',
|
||||
details: { type: 'ceph', node: 'cluster', status: 'degraded' },
|
||||
});
|
||||
const mount = makeMountRecord({
|
||||
id: 'mount-1',
|
||||
name: 'cephfs-data',
|
||||
health: 'healthy',
|
||||
details: { type: 'cephfs', node: 'shared', status: 'online', extra: 'kept' },
|
||||
});
|
||||
const [survivor] = consolidateCephClusterPoolRecords([pool, mount]);
|
||||
// record.details was truthy → spread keeps existing keys, status overridden.
|
||||
expect(survivor.details).toEqual({
|
||||
type: 'cephfs',
|
||||
node: 'shared',
|
||||
status: 'degraded',
|
||||
extra: 'kept',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getCephClusterKeyFromStorageRecord
|
||||
// ===========================================================================
|
||||
|
||||
describe('getCephClusterKeyFromStorageRecord branch coverage', () => {
|
||||
it('prefers refs.platformEntityId when it is a non-empty string', () => {
|
||||
expect(getCephClusterKeyFromStorageRecord(makeRecord())).toBe('cluster-a');
|
||||
});
|
||||
|
||||
it('uses details.parentId when refs is undefined (optional-chain short-circuit)', () => {
|
||||
// refs undefined → `record.refs?.platformEntityId` is undefined → falls to
|
||||
// parent. Exercises the `record.refs?.` optional-chain falsy arm.
|
||||
const record = makeRecord({
|
||||
refs: undefined,
|
||||
details: { type: 'rbd', parentId: 'parent-key' },
|
||||
});
|
||||
expect(getCephClusterKeyFromStorageRecord(record)).toBe('parent-key');
|
||||
});
|
||||
|
||||
it('uses details.parentId when refs.platformEntityId is an empty string', () => {
|
||||
const record = makeRecord({
|
||||
refs: { platformEntityId: '' },
|
||||
details: { type: 'rbd', parentId: 'parent-key' },
|
||||
});
|
||||
expect(getCephClusterKeyFromStorageRecord(record)).toBe('parent-key');
|
||||
});
|
||||
|
||||
it('coerces a non-string parentId to "" (defensive ternary false arm) then uses location.label', () => {
|
||||
const record = makeRecord({
|
||||
refs: { platformEntityId: '' },
|
||||
details: { type: 'rbd', parentId: 999 } as Record<string, unknown>,
|
||||
location: { label: 'loc-key', scope: 'cluster' },
|
||||
});
|
||||
// typeof parentId === 'string' is false → parent '' → next fallback wins.
|
||||
expect(getCephClusterKeyFromStorageRecord(record)).toBe('loc-key');
|
||||
});
|
||||
|
||||
it('falls back to location.label when refs and parentId are both absent', () => {
|
||||
const record = makeRecord({
|
||||
refs: undefined,
|
||||
details: { type: 'rbd' },
|
||||
location: { label: 'loc-key', scope: 'cluster' },
|
||||
});
|
||||
expect(getCephClusterKeyFromStorageRecord(record)).toBe('loc-key');
|
||||
});
|
||||
|
||||
it('exercises the `(record.details || {})` arm then falls to source.platform', () => {
|
||||
// details undefined → {} → parent '' → refs/platformEntityId absent →
|
||||
// location.label '' → final fallback source.platform.
|
||||
const record = makeRecord({
|
||||
refs: undefined,
|
||||
details: undefined,
|
||||
location: { label: '', scope: 'cluster' },
|
||||
source: {
|
||||
platform: 'proxmox-pve',
|
||||
family: 'virtualization',
|
||||
origin: 'resource',
|
||||
adapterId: 'resource-storage',
|
||||
},
|
||||
});
|
||||
expect(getCephClusterKeyFromStorageRecord(record)).toBe('proxmox-pve');
|
||||
});
|
||||
|
||||
it('reaches the source.platform final fallback when every higher-precedence key is empty', () => {
|
||||
const record = makeRecord({
|
||||
refs: { platformEntityId: '' },
|
||||
details: { type: 'rbd', parentId: '' },
|
||||
location: { label: '', scope: 'cluster' },
|
||||
source: {
|
||||
platform: 'truenas',
|
||||
family: 'virtualization',
|
||||
origin: 'resource',
|
||||
adapterId: 'resource-storage',
|
||||
},
|
||||
});
|
||||
expect(getCephClusterKeyFromStorageRecord(record)).toBe('truenas');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getCephSummaryText
|
||||
// ===========================================================================
|
||||
|
||||
describe('getCephSummaryText branch coverage', () => {
|
||||
// ---- Cluster arm: capacity + OSDs + PGs (all three parts) ---------------
|
||||
it('formats capacity, OSDs and PGs parts when the cluster provides all three', () => {
|
||||
// totalBytes 1000, usedBytes 400 → 40% → "400 B / 1000 B (40%)".
|
||||
// numOsds/numOsdsUp finite (6/6) and numPGs > 0 (256) → all parts pushed.
|
||||
expect(getCephSummaryText(makeRecord(), makeCluster())).toBe(
|
||||
'400 B / 1000 B (40%) • OSDs 6/6 • PGs 256',
|
||||
);
|
||||
});
|
||||
|
||||
// ---- Cluster arm: percent===0 when totalBytes is 0 (no division) -------
|
||||
it('renders a zero-capacity cluster as "0 B / 0 B (0%)" without dividing by zero', () => {
|
||||
expect(
|
||||
getCephSummaryText(
|
||||
makeRecord(),
|
||||
makeCluster({ totalBytes: 0, usedBytes: 0, availableBytes: 0, numOsds: NaN, numPGs: 0 }),
|
||||
),
|
||||
).toBe('0 B / 0 B (0%)');
|
||||
});
|
||||
|
||||
// ---- Cluster arm: OSDs omitted when numOsds/numOsdsUp not finite --------
|
||||
it('omits the OSDs part when numOsds is NaN', () => {
|
||||
expect(
|
||||
getCephSummaryText(
|
||||
makeRecord(),
|
||||
makeCluster({ numOsds: NaN, numOsdsUp: 6, numPGs: 0 }),
|
||||
),
|
||||
).toBe('400 B / 1000 B (40%)');
|
||||
});
|
||||
|
||||
it('omits the OSDs part when numOsdsUp is NaN even if numOsds is finite', () => {
|
||||
expect(
|
||||
getCephSummaryText(
|
||||
makeRecord(),
|
||||
makeCluster({ numOsds: 6, numOsdsUp: NaN, numPGs: 0 }),
|
||||
),
|
||||
).toBe('400 B / 1000 B (40%)');
|
||||
});
|
||||
|
||||
// ---- Cluster arm: PGs omitted when numPGs is 0 or not finite ------------
|
||||
it('keeps OSDs but omits PGs when numPGs is 0', () => {
|
||||
expect(
|
||||
getCephSummaryText(makeRecord(), makeCluster({ numPGs: 0 })),
|
||||
).toBe('400 B / 1000 B (40%) • OSDs 6/6');
|
||||
});
|
||||
|
||||
it('omits PGs when numPGs is NaN (Number.isFinite false)', () => {
|
||||
expect(
|
||||
getCephSummaryText(makeRecord(), makeCluster({ numPGs: NaN })),
|
||||
).toBe('400 B / 1000 B (40%) • OSDs 6/6');
|
||||
});
|
||||
|
||||
// ---- Cluster arm: large numPGs is rendered via toLocaleString ----------
|
||||
it('renders a large numPGs count with toLocaleString grouping', () => {
|
||||
expect(
|
||||
getCephSummaryText(makeRecord(), makeCluster({ numPGs: 1_500 })),
|
||||
).toBe('400 B / 1000 B (40%) • OSDs 6/6 • PGs 1,500');
|
||||
});
|
||||
|
||||
// ---- Cluster `|| 0` clamps: negative bytes treated as 0 ----------------
|
||||
it('clamps negative cluster total/used bytes to 0 before formatting', () => {
|
||||
// Math.max(0, totalBytes || 0): totalBytes -1000 is truthy → max(0,-1000)=0.
|
||||
// usedBytes -400 → 0. percent total>0? no → 0.
|
||||
expect(
|
||||
getCephSummaryText(
|
||||
makeRecord(),
|
||||
makeCluster({
|
||||
totalBytes: -1_000,
|
||||
usedBytes: -400,
|
||||
availableBytes: 0,
|
||||
numOsds: NaN,
|
||||
numPGs: 0,
|
||||
}),
|
||||
),
|
||||
).toBe('0 B / 0 B (0%)');
|
||||
});
|
||||
|
||||
// ---- Cluster guard false: totalBytes NaN → else (record capacity) branch
|
||||
it('falls back to record capacity when cluster.totalBytes is not finite', () => {
|
||||
// Number.isFinite(NaN) is false → the whole cluster arm is skipped.
|
||||
expect(
|
||||
getCephSummaryText(
|
||||
makeRecord({ capacity: { totalBytes: 1_000, usedBytes: 400, freeBytes: 600, usagePercent: 40 } }),
|
||||
makeCluster({ totalBytes: NaN }),
|
||||
),
|
||||
).toBe('400 B / 1000 B (40%)');
|
||||
});
|
||||
|
||||
// ---- Cluster guard false: cluster null → else (record capacity) branch --
|
||||
it('formats the record capacity summary when cluster is null', () => {
|
||||
expect(
|
||||
getCephSummaryText(
|
||||
makeRecord({ capacity: { totalBytes: 1_000, usedBytes: 400, freeBytes: 600, usagePercent: 40 } }),
|
||||
null,
|
||||
),
|
||||
).toBe('400 B / 1000 B (40%)');
|
||||
});
|
||||
|
||||
// ---- Else branch: record total <= 0 → '' -------------------------------
|
||||
it('returns the empty string when the record capacity total is <= 0 and there is no cluster', () => {
|
||||
expect(
|
||||
getCephSummaryText(
|
||||
makeRecord({
|
||||
capacity: { totalBytes: 0, usedBytes: 0, freeBytes: 0, usagePercent: 0 },
|
||||
}),
|
||||
null,
|
||||
),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
// ---- Else branch: record `|| 0` clamps nullish bytes -------------------
|
||||
it('treats null total/used bytes as 0 in the record fallback and returns ""', () => {
|
||||
// totalBytes null → `record.capacity.totalBytes || 0` → 0 → total <= 0 → ''.
|
||||
expect(
|
||||
getCephSummaryText(
|
||||
makeRecord({
|
||||
capacity: {
|
||||
totalBytes: null,
|
||||
usedBytes: null,
|
||||
freeBytes: null,
|
||||
usagePercent: null,
|
||||
},
|
||||
}),
|
||||
null,
|
||||
),
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Supplemental branch coverage for the four in-scope functions
|
||||
// (isCephClusterPoolStorageRecord, getRecordPoolDetail, getRecordStatusDetail,
|
||||
// consolidateCephClusterPoolRecords). These cases reach the `(details || {})`
|
||||
// right operands that the cases above do not actually exercise:
|
||||
// * isCephClusterPoolStorageRecord L21 — only reachable with nullish details
|
||||
// AND a 'ceph' type, which forces the type to derive from `category`.
|
||||
// * consolidateCephClusterPoolRecords L92 survivor spread — requires a
|
||||
// mounting sibling whose `details` are nullish (all mounts above had details).
|
||||
// * (Indirectly) getRecordPoolDetail L36 right operand — the nullish-details
|
||||
// candidate below is evaluated by the sibling `find` before matching on name.
|
||||
// * getRecordStatusDetail L41 right operand is UNREACHABLE through the public
|
||||
// surface: a pool row is only classified as such when details.node ===
|
||||
// 'cluster', so no pool can ever carry nullish details. Reported, not hit.
|
||||
// ===========================================================================
|
||||
|
||||
describe('cephRecordPresentation.branchcov2 supplemental (in-scope functions)', () => {
|
||||
it('isCephClusterPoolStorageRecord: takes the `(details || {})` right operand when details is nullish but the type derives from category', () => {
|
||||
// The pre-existing "defensive arm" case passes a truthy details object, so
|
||||
// the `{}` fallback is never constructed. Here details is undefined AND the
|
||||
// type resolves to 'ceph' via category, so the L20 guard is passed and L21
|
||||
// actually evaluates `undefined || {}` → {}. L22 then reads {}.node
|
||||
// (undefined) !== 'cluster' → false.
|
||||
const record = makeRecord({
|
||||
details: undefined,
|
||||
category: 'ceph' as unknown as StorageCategory,
|
||||
});
|
||||
expect(isCephClusterPoolStorageRecord(record)).toBe(false);
|
||||
});
|
||||
|
||||
it('consolidate: spreads `(record.details || {})` on the survivor when the mount has nullish details', () => {
|
||||
// The mounting sibling carries no `details`. It still qualifies as a ceph
|
||||
// sibling via the isCephStorageRecord fallback (capabilities includes
|
||||
// 'replication' and source.platform includes 'proxmox'), and is matched on
|
||||
// name after getRecordPoolDetail returns '' for the nullish details. On
|
||||
// lift, L92 evaluates `(record.details || {})` → {} and overlays only the
|
||||
// status key, proving the `{}` right operand is taken.
|
||||
const pool = makePoolRecord({
|
||||
id: 'pool-1',
|
||||
name: 'cephfs-data',
|
||||
health: 'warning',
|
||||
details: { type: 'ceph', node: 'cluster', status: 'degraded' },
|
||||
});
|
||||
const mount = makeRecord({
|
||||
id: 'mount-1',
|
||||
name: 'cephfs-data',
|
||||
health: 'healthy',
|
||||
details: undefined,
|
||||
});
|
||||
const [survivor] = consolidateCephClusterPoolRecords([pool, mount]);
|
||||
expect(survivor.id).toBe('mount-1');
|
||||
expect(survivor.health).toBe('warning');
|
||||
expect(survivor.statusLabel).toBe('degraded');
|
||||
// mount.details was nullish → {} spread → only the overlaid status survives.
|
||||
expect(survivor.details).toEqual({ status: 'degraded' });
|
||||
expect(survivor.issueSummary).toBe('Ceph reports pool cephfs-data degraded');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
getResourceStorageIssueLabel,
|
||||
getResourceStorageProtectionLabel,
|
||||
getResourceStorageProtectionSummary,
|
||||
getResourceStorageTopologyLabel,
|
||||
} from '@/features/storageBackups/resourceStoragePresentation';
|
||||
|
||||
// Mirrors the sibling test factories (see resourceStoragePresentation.test.ts
|
||||
// and resourceStoragePresentation.branchcov.test.ts). Pure-function module, so
|
||||
// no Solid root is required. The two private target functions
|
||||
// (getUnraidShortProtectionLabel / getUnraidShortIssueLabel) are exercised
|
||||
// transitively through their exported callers
|
||||
// (getResourceStorageProtectionLabel / getResourceStorageIssueLabel) — no
|
||||
// export hacking. Scope is limited to the four named functions.
|
||||
const makeResource = (overrides: Partial<Resource> = {}): Resource =>
|
||||
({
|
||||
id: 'storage-1',
|
||||
type: 'storage',
|
||||
name: 'tank',
|
||||
platformType: 'truenas',
|
||||
sourceType: 'api',
|
||||
status: 'online',
|
||||
storage: {},
|
||||
...overrides,
|
||||
}) as Resource;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getUnraidShortProtectionLabel (private) — exercised via
|
||||
// getResourceStorageProtectionLabel on Unraid resources (arrayState set so
|
||||
// isUnraidStorageResource returns true). Targets the protectionReduced
|
||||
// sub-branches the sibling suites do not reach: case/whitespace-insensitive
|
||||
// "none" matching, the protection-undefined `|| ""` arm, and the
|
||||
// no-rebuild/no-reduced fall-through.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getUnraidShortProtectionLabel (via getResourceStorageProtectionLabel)', () => {
|
||||
it('treats an uppercase "NONE" protection as No parity via the toLowerCase arm', () => {
|
||||
const resource = makeResource({
|
||||
storage: {
|
||||
arrayState: 'STARTED',
|
||||
protection: 'NONE',
|
||||
protectionReduced: true,
|
||||
},
|
||||
});
|
||||
expect(getResourceStorageProtectionLabel(resource)).toBe('No parity');
|
||||
});
|
||||
|
||||
it('treats a whitespace-padded mixed-case protection as No parity via the trim arm', () => {
|
||||
const resource = makeResource({
|
||||
storage: {
|
||||
arrayState: 'STARTED',
|
||||
protection: ' NoNe ',
|
||||
protectionReduced: true,
|
||||
},
|
||||
});
|
||||
expect(getResourceStorageProtectionLabel(resource)).toBe('No parity');
|
||||
});
|
||||
|
||||
it('hits the protection-undefined `|| ""` falsy arm then resolves No parity from the unraid_no_parity reason', () => {
|
||||
// protectionReduced true, protection absent => `(storage.protection || "")`
|
||||
// takes the falsy arm; the `=== "none"` check is false, so the
|
||||
// unraid_no_parity risk reason decides the label.
|
||||
const resource = makeResource({
|
||||
storage: {
|
||||
arrayState: 'STARTED',
|
||||
protectionReduced: true,
|
||||
risk: {
|
||||
level: 'warning',
|
||||
reasons: [{ code: 'unraid_no_parity', severity: 'warning', summary: 'no parity' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getResourceStorageProtectionLabel(resource)).toBe('No parity');
|
||||
});
|
||||
|
||||
it('returns Protection reduced when protection is absent and neither parity risk reason is present', () => {
|
||||
// Same `|| ""` falsy arm, but both findRiskReason calls return undefined,
|
||||
// landing on the terminal `return "Protection reduced"`.
|
||||
const resource = makeResource({
|
||||
storage: {
|
||||
arrayState: 'STARTED',
|
||||
protectionReduced: true,
|
||||
},
|
||||
});
|
||||
expect(getResourceStorageProtectionLabel(resource)).toBe('Protection reduced');
|
||||
});
|
||||
|
||||
it('returns an empty Unraid label when rebuild and protectionReduced are both false, letting the caller titleize the raw protection', () => {
|
||||
// Exercises getUnraidShortProtectionLabel's final `return ""` so the
|
||||
// exported caller falls through to its generic protection path.
|
||||
const resource = makeResource({
|
||||
storage: {
|
||||
arrayState: 'STARTED',
|
||||
protection: 'dual-parity',
|
||||
},
|
||||
});
|
||||
expect(getResourceStorageProtectionLabel(resource)).toBe('Dual Parity');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getUnraidShortIssueLabel (private) — exercised via
|
||||
// getResourceStorageIssueLabel on Unraid resources. Targets the empty-code
|
||||
// `|| ""` arm inside the loop, the loop fall-through to Healthy, the
|
||||
// case-insensitive prefix regex, and PBS-storageRisk-only reason collection.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getUnraidShortIssueLabel (via getResourceStorageIssueLabel)', () => {
|
||||
it('continues past a reason with an empty code via the `(reason.code || "")` falsy arm', () => {
|
||||
// collectRiskReasons keeps the truthy reason object; inside the loop the
|
||||
// empty code becomes "" and the switch matches no case, so iteration
|
||||
// proceeds to the next reason.
|
||||
const resource = makeResource({
|
||||
storage: {
|
||||
arrayState: 'STARTED',
|
||||
risk: {
|
||||
level: 'warning',
|
||||
reasons: [
|
||||
{ code: '', severity: 'warning', summary: 'ignored empty code' },
|
||||
{ code: 'unraid_missing_disks', severity: 'warning', summary: '2 missing' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getResourceStorageIssueLabel(resource)).toBe('2 missing');
|
||||
});
|
||||
|
||||
it('returns Healthy when every collected reason carries an empty or non-attention code', () => {
|
||||
const resource = makeResource({
|
||||
storage: {
|
||||
arrayState: 'STARTED',
|
||||
risk: {
|
||||
level: 'warning',
|
||||
reasons: [
|
||||
{ code: '', severity: 'warning', summary: 'empty code reason' },
|
||||
{ code: 'unraid_sync_active', severity: 'warning', summary: 'syncing' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getResourceStorageIssueLabel(resource)).toBe('Healthy');
|
||||
});
|
||||
|
||||
it('strips an uppercase "UNRAID ARRAY REPORTS " prefix case-insensitively via the /i regex', () => {
|
||||
const resource = makeResource({
|
||||
storage: {
|
||||
arrayState: 'STARTED',
|
||||
risk: {
|
||||
level: 'warning',
|
||||
reasons: [
|
||||
{
|
||||
code: 'unraid_invalid_disks',
|
||||
severity: 'warning',
|
||||
summary: 'unRAID aRRay reports 4 invalid disks',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getResourceStorageIssueLabel(resource)).toBe('4 invalid disks');
|
||||
});
|
||||
|
||||
it('returns the unraid_disabled_disks summary verbatim when it carries no prefix', () => {
|
||||
const resource = makeResource({
|
||||
storage: {
|
||||
arrayState: 'STARTED',
|
||||
risk: {
|
||||
level: 'warning',
|
||||
reasons: [
|
||||
{ code: 'unraid_disabled_disks', severity: 'warning', summary: 'disk3 disabled' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getResourceStorageIssueLabel(resource)).toBe('disk3 disabled');
|
||||
});
|
||||
|
||||
it('surfaces a reason collected only from pbs.storageRisk for an Unraid resource', () => {
|
||||
// collectRiskReasons walks storage.risk.reasons then pbs.storageRisk.reasons;
|
||||
// with no storage risk, the PBS reason is still collected and matched.
|
||||
const resource = makeResource({
|
||||
storage: { arrayState: 'STARTED' },
|
||||
pbs: {
|
||||
storageRisk: {
|
||||
level: 'warning',
|
||||
reasons: [
|
||||
{ code: 'unraid_parity_unavailable', severity: 'warning', summary: 'parity down' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getResourceStorageIssueLabel(resource)).toBe('Parity unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getResourceStorageTopologyLabel (exported) — edge branches complementing
|
||||
// the existing coverage: topology-arg normalization, datastore precedence over
|
||||
// a pool-like storageType, and whitespace-only storageType fall-through.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getResourceStorageTopologyLabel (branch coverage)', () => {
|
||||
it('normalizes a whitespace-padded mixed-case topology arg before titleizing', () => {
|
||||
// `(topology || "").trim().toLowerCase()` => 'backup-target' => titleize.
|
||||
expect(getResourceStorageTopologyLabel(makeResource(), 'pool', ' BackUP-Target ')).toBe(
|
||||
'Backup Target',
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies a datastore entityType ahead of a pool-like storageType', () => {
|
||||
// resource.type !== 'datastore' but vmware.entityType === 'datastore' wins
|
||||
// over storageType 'zfspool', proving the datastore check precedes the
|
||||
// switch and that the `||` short-circuits to the entityType arm.
|
||||
const resource = makeResource({
|
||||
type: 'storage',
|
||||
vmware: { entityType: 'datastore' },
|
||||
});
|
||||
expect(getResourceStorageTopologyLabel(resource, 'zfspool')).toBe('Datastore');
|
||||
});
|
||||
|
||||
it('falls through a whitespace-only storageType to the titleized resource type', () => {
|
||||
// The switch input `(storageType || "").trim().toLowerCase()` becomes "" =>
|
||||
// default branch => titleize(" ") => "" => `||` titleize(resource.type).
|
||||
const resource = { ...makeResource(), type: 'nvme-tank' } as unknown as Resource;
|
||||
expect(getResourceStorageTopologyLabel(resource, ' ')).toBe('Nvme Tank');
|
||||
});
|
||||
|
||||
it('returns Storage when both storageType and resource type are whitespace-only', () => {
|
||||
// titleize("") || titleize("") || "Storage" => "Storage".
|
||||
const resource = { ...makeResource(), type: ' ' } as unknown as Resource;
|
||||
expect(getResourceStorageTopologyLabel(resource, ' ')).toBe('Storage');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getResourceStorageProtectionSummary (exported) — drives every guard arm and
|
||||
// the trimSummary fallbacks for rebuild/protection/incident states, including
|
||||
// the optional-chain falsy arms when storage metadata is absent.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getResourceStorageProtectionSummary (branch coverage)', () => {
|
||||
it('returns an empty string when a rebuild is in progress but rebuildSummary is absent', () => {
|
||||
// trimSummary(undefined) => "".
|
||||
const resource = makeResource({ storage: { rebuildInProgress: true } });
|
||||
expect(getResourceStorageProtectionSummary(resource)).toBe('');
|
||||
});
|
||||
|
||||
it('trims a whitespace-padded rebuildSummary during an active rebuild', () => {
|
||||
const resource = makeResource({
|
||||
storage: { rebuildInProgress: true, rebuildSummary: ' Resilvering in progress ' },
|
||||
});
|
||||
expect(getResourceStorageProtectionSummary(resource)).toBe('Resilvering in progress');
|
||||
});
|
||||
|
||||
it('returns an empty string when a rebuild is active with a whitespace-only rebuildSummary', () => {
|
||||
const resource = makeResource({
|
||||
storage: { rebuildInProgress: true, rebuildSummary: ' ' },
|
||||
});
|
||||
expect(getResourceStorageProtectionSummary(resource)).toBe('');
|
||||
});
|
||||
|
||||
it('returns the trimmed protectionSummary when protection is reduced and no rebuild is active', () => {
|
||||
const resource = makeResource({
|
||||
storage: { protectionReduced: true, protectionSummary: ' Redundancy degraded ' },
|
||||
});
|
||||
expect(getResourceStorageProtectionSummary(resource)).toBe('Redundancy degraded');
|
||||
});
|
||||
|
||||
it('returns an empty string when protection is reduced but protectionSummary is absent', () => {
|
||||
const resource = makeResource({ storage: { protectionReduced: true } });
|
||||
expect(getResourceStorageProtectionSummary(resource)).toBe('');
|
||||
});
|
||||
|
||||
it('returns the trimmed incidentSummary for a recoverability incident', () => {
|
||||
const resource = makeResource({
|
||||
incidentCategory: 'recoverability',
|
||||
incidentSummary: ' No recent backups ',
|
||||
});
|
||||
expect(getResourceStorageProtectionSummary(resource)).toBe('No recent backups');
|
||||
});
|
||||
|
||||
it('returns an empty string for a recoverability incident with no incidentSummary', () => {
|
||||
const resource = makeResource({ incidentCategory: 'recoverability' });
|
||||
expect(getResourceStorageProtectionSummary(resource)).toBe('');
|
||||
});
|
||||
|
||||
it('returns an empty string when storage metadata is entirely absent (optional-chain falsy arms)', () => {
|
||||
// resource.storage?.rebuildInProgress and ?.protectionReduced both evaluate
|
||||
// to undefined and skip their guards.
|
||||
const resource = { ...makeResource(), storage: undefined } as unknown as Resource;
|
||||
expect(getResourceStorageProtectionSummary(resource)).toBe('');
|
||||
});
|
||||
|
||||
it('returns an empty string for a healthy resource with no protection state', () => {
|
||||
expect(getResourceStorageProtectionSummary(makeResource())).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,441 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { StorageRecord } from '@/features/storageBackups/models';
|
||||
import {
|
||||
getCompactStoragePoolImpactLabel,
|
||||
getCompactStoragePoolIssueLabel,
|
||||
getStoragePoolStateLabel,
|
||||
getStoragePoolStateTitle,
|
||||
} from '@/features/storageBackups/rowPresentation';
|
||||
|
||||
// Minimal valid-enough record. Cast through `unknown` so deliberate gaps/malformed
|
||||
// values still satisfy the strict StorageRecord shape under the real tsconfig.
|
||||
const baseRecord = (): StorageRecord =>
|
||||
({
|
||||
id: 'storage-1',
|
||||
name: 'tank',
|
||||
source: {
|
||||
platform: 'truenas',
|
||||
type: 'storage',
|
||||
label: 'TrueNAS',
|
||||
},
|
||||
category: 'pool',
|
||||
statusLabel: 'Healthy',
|
||||
health: 'healthy',
|
||||
capacity: { totalBytes: 0, usedBytes: 0, freeBytes: 0 },
|
||||
location: { label: 'tower', scope: 'node' },
|
||||
capabilities: ['capacity'],
|
||||
observedAt: Date.now(),
|
||||
refs: {},
|
||||
}) as unknown as StorageRecord;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getCompactStoragePoolIssueLabel
|
||||
//
|
||||
// Shape under test:
|
||||
// label = (issueLabel || '').trim()
|
||||
// protection = getCompactStoragePoolProtectionLabel(record).trim()
|
||||
// if (label && label.toLowerCase() !== 'healthy') { // OUTER
|
||||
// if (protection && protection !== '—' &&
|
||||
// protection.toLowerCase() === label.toLowerCase()) // INNER (3 conjuncts)
|
||||
// return '—'
|
||||
// return label
|
||||
// }
|
||||
// if (pool?.state && pool.state !== 'ONLINE') return pool.state
|
||||
// if (normalizedStatus && !SAFE.includes(normalizedStatus)) return statusLabel || 'Issue'
|
||||
// return '—'
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('getCompactStoragePoolIssueLabel branch coverage', () => {
|
||||
it('returns "—" when a non-healthy issue label matches the derived protection label (case-insensitive)', () => {
|
||||
// INNER all-true: protection truthy, protection !== '—', lowercased equality.
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Scrub Failed',
|
||||
protectionLabel: 'scrub failed',
|
||||
};
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe('—');
|
||||
});
|
||||
|
||||
it('returns the raw issue label when protection is a present but differing value (INNER false via lowercase mismatch)', () => {
|
||||
// protection is a real ('no parity'), non-'—' value that does not equal the issue label.
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Drive failed',
|
||||
protectionLabel: 'No parity',
|
||||
protectionReduced: true,
|
||||
};
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe('Drive failed');
|
||||
});
|
||||
|
||||
it('returns the raw issue label when protection collapses to "—" (INNER false via the `protection !== "—"` guard)', () => {
|
||||
// protectionLabel 'Healthy' -> getCompactStoragePoolProtectionLabel returns '—'.
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Drive failed',
|
||||
protectionLabel: 'Healthy',
|
||||
};
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe('Drive failed');
|
||||
});
|
||||
|
||||
it('skips an issue label that is exactly "Healthy" and falls through to a non-safe statusLabel (OUTER false via healthy equality)', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Healthy',
|
||||
statusLabel: 'Failed',
|
||||
};
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe('Failed');
|
||||
});
|
||||
|
||||
it('skips an issue label that is "HEALTHY" in any case (OUTER false via case-insensitive healthy equality)', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'HEALTHY',
|
||||
statusLabel: 'Faulted',
|
||||
};
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe('Faulted');
|
||||
});
|
||||
|
||||
it('treats an empty-string issue label as absent and falls through to the zfs pool state', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: '',
|
||||
details: {
|
||||
zfsPool: { state: 'DEGRADED', devices: [] },
|
||||
},
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe('DEGRADED');
|
||||
});
|
||||
|
||||
it('treats a whitespace-only issue label as absent (trim -> "") and falls through to statusLabel', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: ' ',
|
||||
statusLabel: 'Faulted',
|
||||
};
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe('Faulted');
|
||||
});
|
||||
|
||||
it('skips an ONLINE zfs pool (pool.state !== "ONLINE" guard false) and uses a non-safe statusLabel', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: {
|
||||
zfsPool: { state: 'ONLINE', devices: [] },
|
||||
},
|
||||
statusLabel: 'Faulted',
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe('Faulted');
|
||||
});
|
||||
|
||||
it('returns "—" for every "safe" statusLabel value (online/available/running/healthy)', () => {
|
||||
const safeValues = ['online', 'available', 'running', 'healthy'];
|
||||
for (const statusLabel of safeValues) {
|
||||
const record = { ...baseRecord(), statusLabel };
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe('—');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the raw (untrimmed) statusLabel when it is padded but non-safe', () => {
|
||||
// normalizedStatus is computed from the trimmed/lowercased value, but the
|
||||
// function returns record.statusLabel verbatim -> padded result is a real
|
||||
// observable quirk worth pinning.
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
statusLabel: ' Failed ',
|
||||
};
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe(' Failed ');
|
||||
});
|
||||
|
||||
it('returns "—" when issue label is healthy, no pool exists, and statusLabel is empty', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Healthy',
|
||||
statusLabel: '',
|
||||
};
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getStoragePoolStateLabel
|
||||
//
|
||||
// Shape under test:
|
||||
// arrayState = details.arrayState (string-only, trimmed-by-caller of getRecordStringDetail)
|
||||
// if (arrayState) return titleize(arrayState) // titleize splits on /[\s_-]+/
|
||||
// if (pool?.state) return pool.state === 'ONLINE' ? 'Online' : pool.state
|
||||
// status = getStorageRecordStatus(record)
|
||||
// return status ? titleize(status) : '—'
|
||||
//
|
||||
// NOTE: getStorageRecordStatus always returns a non-empty string (final fallback
|
||||
// 'unknown'), so the trailing `: '—'` arm is unreachable in practice; see report.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('getStoragePoolStateLabel branch coverage', () => {
|
||||
it('titleizes a single-word details.arrayState', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { arrayState: 'STARTED' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Started');
|
||||
});
|
||||
|
||||
it('titleizes a multi-word arrayState by splitting on whitespace', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { arrayState: 'PARTIALLY DEGRADED' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Partially Degraded');
|
||||
});
|
||||
|
||||
it('titleizes an arrayState that uses underscore separators', () => {
|
||||
// Exercises the `/[\s_-]+/` split in titleize with the `_` separator class.
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { arrayState: 'degraded_online' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Degraded Online');
|
||||
});
|
||||
|
||||
it('falls through when details.arrayState is a non-string (defensive getRecordStringDetail branch)', () => {
|
||||
// A numeric arrayState is coerced to '' by getRecordStringDetail; with no
|
||||
// pool and a healthy record, status resolves to 'available' -> 'Available'.
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { arrayState: 42 },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Available');
|
||||
});
|
||||
|
||||
it('falls through when details.arrayState is the empty string', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { arrayState: '' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Available');
|
||||
});
|
||||
|
||||
it('returns "Online" when only the zfs pool state is "ONLINE"', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { zfsPool: { state: 'ONLINE', devices: [] } },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Online');
|
||||
});
|
||||
|
||||
it('returns the raw pool state when it is anything other than "ONLINE"', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { zfsPool: { state: 'FAULTED', devices: [] } },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('FAULTED');
|
||||
});
|
||||
|
||||
it('skips a zfs pool whose state is the empty string (pool?.state truthiness guard false)', () => {
|
||||
// toZfsPool accepts state='' (it is a string), but `pool?.state` is falsy,
|
||||
// so the function falls through to the status arm.
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { zfsPool: { state: '', devices: [] } },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Available');
|
||||
});
|
||||
|
||||
it('titleizes the health-derived status when arrayState and pool are both absent', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
health: 'critical',
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Critical');
|
||||
});
|
||||
|
||||
it('titleizes an explicit details.status when arrayState and pool are absent', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { status: 'available' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Available');
|
||||
});
|
||||
|
||||
it('exercises the `details || {}` defensive arm when details is undefined', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: undefined,
|
||||
health: 'warning',
|
||||
} as unknown as StorageRecord;
|
||||
// health 'warning' -> status 'degraded' -> titleized 'Degraded'.
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Degraded');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getStoragePoolStateTitle
|
||||
//
|
||||
// Shape under test:
|
||||
// label = getStoragePoolStateLabel(record)
|
||||
// summary = getCompactStoragePoolIssueSummary(record).trim() || getStorageRecordIssueSummary(record).trim()
|
||||
// if (summary && summary.toLowerCase() !== 'healthy' && label.toLowerCase() !== 'started')
|
||||
// return summary
|
||||
// return label === '—' ? '' : label
|
||||
//
|
||||
// NOTE: getStoragePoolStateLabel never returns '—' (status is always non-empty),
|
||||
// so the `label === '—' ? ''` arm is unreachable in practice; see report.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('getStoragePoolStateTitle branch coverage', () => {
|
||||
it('returns a non-healthy summary when the label is not "Started" (main branch)', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Degraded',
|
||||
issueSummary: 'One disk is offline',
|
||||
details: { arrayState: 'DEGRADED' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTitle(record)).toBe('One disk is offline');
|
||||
});
|
||||
|
||||
it('suppresses the summary when the resolved label is "Started" and returns the label', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Degraded',
|
||||
issueSummary: 'Some warning',
|
||||
details: { arrayState: 'STARTED' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTitle(record)).toBe('Started');
|
||||
});
|
||||
|
||||
it('suppresses a "healthy" summary (case-insensitive) and returns the resolved label', () => {
|
||||
// getCompactStoragePoolIssueSummary returns '' (issue summary is 'Healthy'),
|
||||
// so summary falls back to getStorageRecordIssueSummary -> 'Healthy' ->
|
||||
// 'healthy' -> suppressed.
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Degraded',
|
||||
issueSummary: 'Healthy',
|
||||
details: { arrayState: 'DEGRADED' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTitle(record)).toBe('Degraded');
|
||||
});
|
||||
|
||||
it('uses getStorageRecordIssueSummary when getCompactStoragePoolIssueSummary is empty', () => {
|
||||
// issueLabel 'Healthy' -> getCompactStoragePoolIssueLabel '—' -> compact
|
||||
// summary ''; issueSummary 'Manual maintenance' is non-healthy and the label
|
||||
// ('Degraded') is not 'started', so the fallback summary is returned.
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Healthy',
|
||||
issueSummary: 'Manual maintenance',
|
||||
details: { arrayState: 'DEGRADED' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTitle(record)).toBe('Manual maintenance');
|
||||
});
|
||||
|
||||
it('returns the resolved label when there is no summary signal at all', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { arrayState: 'STARTED' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTitle(record)).toBe('Started');
|
||||
});
|
||||
|
||||
it('returns an empty-ish label verbatim via the trailing `: label` arm when summary is absent', () => {
|
||||
// No issue signal; arrayState 'STOPPING' -> label 'Stopping'. Summary '' ->
|
||||
// both clauses of the if false -> return label.
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { arrayState: 'STOPPING' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTitle(record)).toBe('Stopping');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getCompactStoragePoolImpactLabel
|
||||
//
|
||||
// Shape under test:
|
||||
// if ((consumerCount || 0) > 0 ||
|
||||
// (protectedWorkloadCount || 0) > 0 ||
|
||||
// (affectedDatastoreCount || 0) > 0)
|
||||
// return (impactSummary || '').trim() || '—'
|
||||
// return '—'
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('getCompactStoragePoolImpactLabel branch coverage', () => {
|
||||
it('returns the trimmed impact summary when consumerCount > 0', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
consumerCount: 2,
|
||||
impactSummary: '2 consumers',
|
||||
};
|
||||
expect(getCompactStoragePoolImpactLabel(record)).toBe('2 consumers');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from a present impactSummary', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
consumerCount: 1,
|
||||
impactSummary: ' 5 vms ',
|
||||
};
|
||||
expect(getCompactStoragePoolImpactLabel(record)).toBe('5 vms');
|
||||
});
|
||||
|
||||
it('enters the block via protectedWorkloadCount > 0 alone and falls to "—" when impactSummary is blank', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
protectedWorkloadCount: 1,
|
||||
impactSummary: ' ',
|
||||
};
|
||||
expect(getCompactStoragePoolImpactLabel(record)).toBe('—');
|
||||
});
|
||||
|
||||
it('enters the block via affectedDatastoreCount > 0 alone and returns the impact summary', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
affectedDatastoreCount: 3,
|
||||
impactSummary: '3 datastores affected',
|
||||
};
|
||||
expect(getCompactStoragePoolImpactLabel(record)).toBe('3 datastores affected');
|
||||
});
|
||||
|
||||
it('returns "—" via the inner `|| "—"` when impactSummary is undefined but a count is set', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
consumerCount: 1,
|
||||
impactSummary: undefined,
|
||||
};
|
||||
expect(getCompactStoragePoolImpactLabel(record)).toBe('—');
|
||||
});
|
||||
|
||||
it('coerces a null consumerCount to 0 via `consumerCount || 0` and still enters via another count', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
consumerCount: null,
|
||||
protectedWorkloadCount: 2,
|
||||
impactSummary: '2 protected',
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolImpactLabel(record)).toBe('2 protected');
|
||||
});
|
||||
|
||||
it('returns "—" when all three counts are explicitly zero', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
consumerCount: 0,
|
||||
protectedWorkloadCount: 0,
|
||||
affectedDatastoreCount: 0,
|
||||
impactSummary: 'ignored',
|
||||
};
|
||||
expect(getCompactStoragePoolImpactLabel(record)).toBe('—');
|
||||
});
|
||||
|
||||
it('returns "—" when all counts are absent (undefined) even if impactSummary is set', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
impactSummary: 'No dependent resources',
|
||||
};
|
||||
expect(getCompactStoragePoolImpactLabel(record)).toBe('—');
|
||||
});
|
||||
|
||||
it('returns "—" when all counts are null and impactSummary is set', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
consumerCount: null,
|
||||
protectedWorkloadCount: null,
|
||||
affectedDatastoreCount: null,
|
||||
impactSummary: 'No dependent resources',
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolImpactLabel(record)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
/**
|
||||
* Supplemental branch-coverage tests for `patrolFormat`.
|
||||
*
|
||||
* The sibling `patrolFormat.test.ts` exercises the happy paths of every export,
|
||||
* but several branches of these three functions are never driven:
|
||||
*
|
||||
* - `formatPatrolRuntimeFailureDetail`: the `text || ''` / `!raw` empty arms,
|
||||
* five of the eight canonical "return raw" list entries, several keyword
|
||||
* arms inside the classification cascades (tool / credit / rate-limit /
|
||||
* auth / connection / analysis-error), and two redaction regexes (`sk-`
|
||||
* secrets and JSON credential keys).
|
||||
* - `formatPatrolRuntimeFailureSummary`: the "provider analysis failed"
|
||||
* detail-preference sub-branch, the summary===detail fallthrough, the
|
||||
* only-summary / only-detail arms, and the entire `errorCount` fallback
|
||||
* (singular / plural / clamp / undefined).
|
||||
* - `formatScope`: the terminal `return ''` reached when a run has no ids,
|
||||
* no types, and is not the literal `type: 'scoped'`.
|
||||
*
|
||||
* Each test asserts a concrete output string/shape against the real branch
|
||||
* it targets; no truthiness-only assertions are used.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatPatrolRuntimeFailureDetail,
|
||||
formatPatrolRuntimeFailureSummary,
|
||||
formatScope,
|
||||
} from '@/utils/patrolFormat';
|
||||
|
||||
describe('patrolFormat branch coverage (supplemental)', () => {
|
||||
describe('formatPatrolRuntimeFailureDetail', () => {
|
||||
it('returns "" for undefined input (text || "" falsy arm + !raw guard)', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" for whitespace-only input (trim drives raw to "" -> !raw arm)', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('collapses internal whitespace before classification, preserving outer case on a canonical match', () => {
|
||||
// raw normalises 'Provider Rate\nLimited' -> 'Provider Rate Limited';
|
||||
// lowercased it hits the canonical list and the original casing is returned.
|
||||
expect(formatPatrolRuntimeFailureDetail('Provider Rate\nLimited')).toBe(
|
||||
'Provider Rate Limited',
|
||||
);
|
||||
});
|
||||
|
||||
describe('canonical-list arms that return the raw (trimmed) string verbatim', () => {
|
||||
// The sibling test only drives 'provider billing or quota issue',
|
||||
// 'selected model does not support patrol tools', and
|
||||
// 'provider connection issue' (all via the summary wrapper). These hit
|
||||
// the remaining five canonical entries directly.
|
||||
it('matches "provider rate limited"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('Provider rate limited')).toBe(
|
||||
'Provider rate limited',
|
||||
);
|
||||
});
|
||||
|
||||
it('matches "provider authentication issue"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('Provider authentication issue')).toBe(
|
||||
'Provider authentication issue',
|
||||
);
|
||||
});
|
||||
|
||||
it('matches "provider not ready"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('Provider not ready')).toBe(
|
||||
'Provider not ready',
|
||||
);
|
||||
});
|
||||
|
||||
it('matches "selected model unavailable"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('Selected model unavailable')).toBe(
|
||||
'Selected model unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
it('matches "selected model context window too small"', () => {
|
||||
expect(
|
||||
formatPatrolRuntimeFailureDetail('Selected model context window too small'),
|
||||
).toBe('Selected model context window too small');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool-call rejection classification arms', () => {
|
||||
const expected =
|
||||
'Provider rejected Patrol tool calls. Choose a Patrol model and endpoint with tool-call support.';
|
||||
|
||||
it('matches the standalone "tool calling" keyword', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('Tool calling is disabled')).toBe(expected);
|
||||
});
|
||||
|
||||
it('matches the "tools are not supported" phrase', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('tools are not supported here')).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it('matches the compound "no endpoints found" + "tool" condition (both halves required)', () => {
|
||||
// Only "no endpoints found" without "tool" must NOT hit this arm —
|
||||
// exercised separately below to prove the conjunction.
|
||||
expect(
|
||||
formatPatrolRuntimeFailureDetail('no endpoints found for the requested tool'),
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it('does NOT classify when "no endpoints found" appears without "tool" (conjunction false arm)', () => {
|
||||
// Falls through every classifier to the redaction tail; with no
|
||||
// secrets to redact the trimmed raw is returned unchanged.
|
||||
expect(formatPatrolRuntimeFailureDetail('no endpoints found in region')).toBe(
|
||||
'no endpoints found in region',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insufficient credits / token budget classification arms', () => {
|
||||
const expected =
|
||||
'Provider reported insufficient credits or token budget for the requested Patrol analysis.';
|
||||
|
||||
it('matches "insufficient balance"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('insufficient balance on account')).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it('matches "payment required"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('HTTP payment required')).toBe(expected);
|
||||
});
|
||||
|
||||
it('matches "quota"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('daily quota exhausted')).toBe(expected);
|
||||
});
|
||||
|
||||
it('matches "credit"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('out of credit')).toBe(expected);
|
||||
});
|
||||
|
||||
it('matches "max_tokens"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('max_tokens exceeded budget')).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate-limit classification arms', () => {
|
||||
const expected =
|
||||
'Provider rate limit reached. Wait for capacity or adjust provider limits before retrying.';
|
||||
|
||||
it('matches "429"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('server returned 429')).toBe(expected);
|
||||
});
|
||||
|
||||
it('matches "too many requests"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('too many requests sent')).toBe(expected);
|
||||
});
|
||||
|
||||
it('matches "rate limit"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('rate limit hit')).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication classification arms', () => {
|
||||
const expected =
|
||||
'Provider authentication failed. Check the configured provider key and account access.';
|
||||
|
||||
it('matches "401"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('request failed: 401')).toBe(expected);
|
||||
});
|
||||
|
||||
it('matches "403"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('403 forbidden by gateway')).toBe(expected);
|
||||
});
|
||||
|
||||
it('matches "unauthorized"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('unauthorized request')).toBe(expected);
|
||||
});
|
||||
|
||||
it('matches "forbidden"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('forbidden resource')).toBe(expected);
|
||||
});
|
||||
|
||||
it('matches "api key"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('api key revoked')).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection classification arms', () => {
|
||||
const expected =
|
||||
'Provider connection failed. Check provider reachability before retrying Patrol.';
|
||||
|
||||
it('matches "failed to connect"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('failed to connect to upstream')).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it('matches "connection refused"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('connection refused by host')).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it('matches "no such host"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('no such host: api.example')).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it('matches "i/o timeout"', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('i/o timeout reading body')).toBe(expected);
|
||||
});
|
||||
|
||||
it('matches bare "timeout" (distinct from "timed out")', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('request timeout')).toBe(expected);
|
||||
});
|
||||
|
||||
it('matches "returned status 5" (5xx prefix arm)', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('provider returned status 503')).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider-analysis classification arms', () => {
|
||||
const expected =
|
||||
'Provider analysis failed. Check Provider & Models before retrying Patrol.';
|
||||
|
||||
it('matches the exact "agentic patrol failed" sentinel', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail('agentic patrol failed')).toBe(expected);
|
||||
});
|
||||
|
||||
it('matches the "provider analysis error: agentic patrol failed" prefix', () => {
|
||||
expect(
|
||||
formatPatrolRuntimeFailureDetail(
|
||||
'provider analysis error: agentic patrol failed mid-run',
|
||||
),
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it('matches the "agentic patrol failed: provider error" prefix', () => {
|
||||
expect(
|
||||
formatPatrolRuntimeFailureDetail(
|
||||
'agentic patrol failed: provider error something else',
|
||||
),
|
||||
).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defensive redaction tail (no classifier matched)', () => {
|
||||
it('redacts bare "sk-" secrets', () => {
|
||||
const out = formatPatrolRuntimeFailureDetail('using key sk-abcd1234EFGH');
|
||||
expect(out).toBe('using key [redacted-secret]');
|
||||
expect(out).not.toContain('sk-abcd1234EFGH');
|
||||
});
|
||||
|
||||
it('redacts JSON credential keys case-insensitively across naming styles', () => {
|
||||
// Exercises the JSON-key regex for snake_case "api_key" and "token".
|
||||
const out = formatPatrolRuntimeFailureDetail(
|
||||
'payload was {"api_key":"sk-leak123","token":"abc"}',
|
||||
);
|
||||
expect(out).toBe('payload was {"api_key":"[redacted]","token":"[redacted]"}');
|
||||
expect(out).not.toContain('sk-leak123');
|
||||
expect(out).not.toContain('"abc"');
|
||||
});
|
||||
|
||||
it('redacts kebab/no-separator variants (apikey, x-api-key, authorization)', () => {
|
||||
const out = formatPatrolRuntimeFailureDetail(
|
||||
'headers {"apikey":"v1","x-api-key":"v2","authorization":"Bearer z"}',
|
||||
);
|
||||
expect(out).toBe(
|
||||
'headers {"apikey":"[redacted]","x-api-key":"[redacted]","authorization":"[redacted]"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the trimmed raw when nothing sensitive is present (final .trim())', () => {
|
||||
expect(formatPatrolRuntimeFailureDetail(' just a benign message ')).toBe(
|
||||
'just a benign message',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPatrolRuntimeFailureSummary', () => {
|
||||
describe('summary/detail interaction', () => {
|
||||
it('prefers the detail when summary is "Provider analysis failed" and detail starts with "provider " (analysis sub-branch)', () => {
|
||||
// summary -> formatPatrolRuntimeFailureDetail('provider analysis error')
|
||||
// -> 'Provider analysis failed. Check Provider & Models before retrying Patrol.'
|
||||
// detail -> 'provider rate limited' (canonical) -> returned verbatim, starts with 'provider '.
|
||||
// summary !== detail, so the analysis-failed sub-branch returns `detail`.
|
||||
const out = formatPatrolRuntimeFailureSummary({
|
||||
errorSummary: 'provider analysis error',
|
||||
errorDetail: 'provider rate limited',
|
||||
});
|
||||
expect(out).toBe('provider rate limited');
|
||||
});
|
||||
|
||||
it('returns the single value via `summary || detail` when summary === detail (inequality false arm)', () => {
|
||||
// Both classify to the same canonical raw -> equal -> skips the
|
||||
// `${summary}: ${detail}` arm and returns one copy.
|
||||
const out = formatPatrolRuntimeFailureSummary({
|
||||
errorSummary: 'provider rate limited',
|
||||
errorDetail: 'provider rate limited',
|
||||
});
|
||||
expect(out).toBe('provider rate limited');
|
||||
});
|
||||
|
||||
it('returns just the summary when detail is absent (summary || detail, detail empty)', () => {
|
||||
expect(
|
||||
formatPatrolRuntimeFailureSummary({ errorSummary: 'provider rate limited' }),
|
||||
).toBe('provider rate limited');
|
||||
});
|
||||
|
||||
it('returns just the detail when summary is absent (summary || detail, summary empty)', () => {
|
||||
expect(formatPatrolRuntimeFailureSummary({ errorDetail: '401 unauthorized' })).toBe(
|
||||
'Provider authentication failed. Check the configured provider key and account access.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorCount fallback (both summary and detail empty)', () => {
|
||||
it('renders the singular form for errorCount === 1', () => {
|
||||
expect(formatPatrolRuntimeFailureSummary({ errorCount: 1 })).toBe(
|
||||
'1 Patrol runtime error recorded',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the plural form for errorCount > 1', () => {
|
||||
expect(formatPatrolRuntimeFailureSummary({ errorCount: 3 })).toBe(
|
||||
'3 Patrol runtime errors recorded',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for errorCount === 0', () => {
|
||||
expect(formatPatrolRuntimeFailureSummary({ errorCount: 0 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when errorCount is omitted (errorCount || 0 -> 0)', () => {
|
||||
expect(formatPatrolRuntimeFailureSummary({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clamps a negative errorCount to 0 via Math.max and returns undefined', () => {
|
||||
// input.errorCount || 0 -> -2 (truthy), then Math.max(0, -2) -> 0.
|
||||
expect(formatPatrolRuntimeFailureSummary({ errorCount: -2 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatScope terminal fallthrough', () => {
|
||||
it('returns "" for an empty run object (no ids, no types, not "scoped")', () => {
|
||||
// Drives the final `return ''` after idCount===0, types.length===0,
|
||||
// and run.type !== 'scoped' all fall through.
|
||||
expect(formatScope({})).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" when scope_resource_types is an empty array (types.length > 0 false arm)', () => {
|
||||
expect(formatScope({ scope_resource_types: [] })).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" when scope_resource_ids is an empty array (idCount === 0, not nullish)', () => {
|
||||
// [].length is 0 (not undefined), so the `?? 0` keeps 0; with no types
|
||||
// and no scoped type the function still bottoms out at ''.
|
||||
expect(formatScope({ scope_resource_ids: [] })).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue