401 lines
15 KiB
JavaScript
401 lines
15 KiB
JavaScript
// Feature: atlas-metrics-report
|
||
// Property tests for Atlas donut chart data correctness and color assignment
|
||
|
||
import fc from 'fast-check';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Since the donut components and getStatusColor are defined inline in
|
||
// ReportingPage.js and not exported, we replicate the exact data
|
||
// transformation logic here and test the mathematical properties directly.
|
||
// This validates that the formulas used in the components are correct
|
||
// for all valid inputs.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Replicated logic from ReportingPage.js
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Coverage donut segment computation — mirrors AtlasCoverageDonut logic.
|
||
*/
|
||
function computeCoverageDonutData(hostsWithPlans, hostsWithoutPlans) {
|
||
const totalHosts = hostsWithPlans + hostsWithoutPlans;
|
||
if (totalHosts === 0) return { totalHosts, segments: [] };
|
||
|
||
const segments = [
|
||
{ label: 'With Plans', count: hostsWithPlans, color: '#10B981', percentage: ((hostsWithPlans / totalHosts) * 100).toFixed(1) },
|
||
{ label: 'Without Plans', count: hostsWithoutPlans, color: '#F59E0B', percentage: ((hostsWithoutPlans / totalHosts) * 100).toFixed(1) },
|
||
].filter((s) => s.count > 0);
|
||
|
||
return { totalHosts, segments };
|
||
}
|
||
|
||
/**
|
||
* Plan type definitions — mirrors PLAN_TYPE_DEFS in ReportingPage.js.
|
||
*/
|
||
const PLAN_TYPE_DEFS = [
|
||
{ key: 'decommission', label: 'Decommission', color: '#EF4444' },
|
||
{ key: 'remediation', label: 'Remediation', color: '#0EA5E9' },
|
||
{ key: 'false_positive', label: 'False Positive', color: '#A855F7' },
|
||
{ key: 'risk_acceptance', label: 'Risk Acceptance', color: '#F59E0B' },
|
||
{ key: 'scan_exclusion', label: 'Scan Exclusion', color: '#64748B' },
|
||
];
|
||
|
||
/**
|
||
* Plan type donut segment computation — mirrors AtlasPlanTypeDonut logic.
|
||
*/
|
||
function computePlanTypeDonutData(plansByType) {
|
||
const totalPlans = Object.values(plansByType).reduce((sum, c) => sum + c, 0);
|
||
if (totalPlans === 0) return { totalPlans, segments: [] };
|
||
|
||
const segments = PLAN_TYPE_DEFS.map((def) => {
|
||
const count = plansByType[def.key] || 0;
|
||
return { ...def, count, percentage: ((count / totalPlans) * 100).toFixed(0) };
|
||
}).filter((s) => s.count > 0);
|
||
|
||
return { totalPlans, segments };
|
||
}
|
||
|
||
/**
|
||
* Plan status color assignment — mirrors getStatusColor in ReportingPage.js.
|
||
*/
|
||
function getStatusColor(status) {
|
||
if (status === 'active') return '#10B981';
|
||
if (status === 'expired') return '#EF4444';
|
||
if (status === 'completed') return '#0EA5E9';
|
||
return '#64748B';
|
||
}
|
||
|
||
/**
|
||
* Plan status donut segment computation — mirrors AtlasPlanStatusDonut logic.
|
||
*/
|
||
function computePlanStatusDonutData(plansByStatus) {
|
||
const totalPlans = Object.values(plansByStatus).reduce((sum, c) => sum + c, 0);
|
||
if (totalPlans === 0) return { totalPlans, segments: [] };
|
||
|
||
const entries = Object.entries(plansByStatus).filter(([, count]) => count > 0);
|
||
const segments = entries.map(([status, count]) => ({
|
||
key: status,
|
||
label: status.charAt(0).toUpperCase() + status.slice(1),
|
||
color: getStatusColor(status),
|
||
count,
|
||
percentage: ((count / totalPlans) * 100).toFixed(0),
|
||
}));
|
||
|
||
return { totalPlans, segments };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Generators
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const KNOWN_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
|
||
const KNOWN_STATUSES = ['active', 'expired', 'completed'];
|
||
|
||
/**
|
||
* Generate a pair of non-negative integers where at least one is > 0.
|
||
*/
|
||
const coveragePairArb = fc
|
||
.tuple(
|
||
fc.nat({ max: 10000 }),
|
||
fc.nat({ max: 10000 }),
|
||
)
|
||
.filter(([a, b]) => a + b > 0);
|
||
|
||
/**
|
||
* Generate a plansByType object with 1–5 known plan type keys mapped to positive integers.
|
||
*/
|
||
const plansByTypeArb = fc
|
||
.subarray(KNOWN_PLAN_TYPES, { minLength: 1, maxLength: 5 })
|
||
.chain((keys) =>
|
||
fc.tuple(...keys.map(() => fc.integer({ min: 1, max: 5000 }))).map((counts) => {
|
||
const obj = {};
|
||
keys.forEach((key, i) => { obj[key] = counts[i]; });
|
||
return obj;
|
||
}),
|
||
);
|
||
|
||
/**
|
||
* Generate a plansByStatus object with 1–3 known status keys mapped to positive integers.
|
||
* Also allows arbitrary unknown status strings.
|
||
*/
|
||
const statusKeyArb = fc.oneof(
|
||
{ weight: 3, arbitrary: fc.constantFrom(...KNOWN_STATUSES) },
|
||
{ weight: 1, arbitrary: fc.stringMatching(/^[a-z_]{2,15}$/).filter((s) => !KNOWN_STATUSES.includes(s)) },
|
||
);
|
||
|
||
const plansByStatusArb = fc
|
||
.array(
|
||
fc.tuple(statusKeyArb, fc.integer({ min: 1, max: 5000 })),
|
||
{ minLength: 1, maxLength: 4 },
|
||
)
|
||
.map((pairs) => {
|
||
const obj = {};
|
||
for (const [key, count] of pairs) {
|
||
// Use first occurrence if duplicate keys generated
|
||
if (!(key in obj)) obj[key] = count;
|
||
}
|
||
return obj;
|
||
})
|
||
.filter((obj) => Object.keys(obj).length >= 1);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Property 2: Coverage donut data correctness
|
||
// Validates: Requirements 3.3, 3.4
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Property 2: Coverage donut data correctness', () => {
|
||
test('center text (totalHosts) equals hostsWithPlans + hostsWithoutPlans', () => {
|
||
fc.assert(
|
||
fc.property(coveragePairArb, ([hostsWithPlans, hostsWithoutPlans]) => {
|
||
const { totalHosts } = computeCoverageDonutData(hostsWithPlans, hostsWithoutPlans);
|
||
expect(totalHosts).toBe(hostsWithPlans + hostsWithoutPlans);
|
||
}),
|
||
{ numRuns: 100 },
|
||
);
|
||
});
|
||
|
||
test('legend percentages equal (count / totalHosts) * 100 for each segment', () => {
|
||
fc.assert(
|
||
fc.property(coveragePairArb, ([hostsWithPlans, hostsWithoutPlans]) => {
|
||
const totalHosts = hostsWithPlans + hostsWithoutPlans;
|
||
const { segments } = computeCoverageDonutData(hostsWithPlans, hostsWithoutPlans);
|
||
|
||
for (const seg of segments) {
|
||
const expectedPct = ((seg.count / totalHosts) * 100).toFixed(1);
|
||
expect(seg.percentage).toBe(expectedPct);
|
||
}
|
||
}),
|
||
{ numRuns: 100 },
|
||
);
|
||
});
|
||
|
||
test('segments only include entries with count > 0', () => {
|
||
fc.assert(
|
||
fc.property(coveragePairArb, ([hostsWithPlans, hostsWithoutPlans]) => {
|
||
const { segments } = computeCoverageDonutData(hostsWithPlans, hostsWithoutPlans);
|
||
|
||
for (const seg of segments) {
|
||
expect(seg.count).toBeGreaterThan(0);
|
||
}
|
||
|
||
// If one value is 0, only one segment should appear
|
||
if (hostsWithPlans === 0) {
|
||
expect(segments.length).toBe(1);
|
||
expect(segments[0].label).toBe('Without Plans');
|
||
} else if (hostsWithoutPlans === 0) {
|
||
expect(segments.length).toBe(1);
|
||
expect(segments[0].label).toBe('With Plans');
|
||
} else {
|
||
expect(segments.length).toBe(2);
|
||
}
|
||
}),
|
||
{ numRuns: 100 },
|
||
);
|
||
});
|
||
|
||
test('segment percentages sum to approximately 100', () => {
|
||
fc.assert(
|
||
fc.property(coveragePairArb, ([hostsWithPlans, hostsWithoutPlans]) => {
|
||
const { segments } = computeCoverageDonutData(hostsWithPlans, hostsWithoutPlans);
|
||
const totalPct = segments.reduce((sum, s) => sum + parseFloat(s.percentage), 0);
|
||
// Allow small rounding tolerance due to toFixed(1)
|
||
expect(totalPct).toBeCloseTo(100, 0);
|
||
}),
|
||
{ numRuns: 100 },
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Property 3: Plan type donut data correctness
|
||
// Validates: Requirements 4.3, 4.4
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Property 3: Plan type donut data correctness', () => {
|
||
test('center text (totalPlans) equals sum of all plan type counts', () => {
|
||
fc.assert(
|
||
fc.property(plansByTypeArb, (plansByType) => {
|
||
const expectedTotal = Object.values(plansByType).reduce((s, c) => s + c, 0);
|
||
const { totalPlans } = computePlanTypeDonutData(plansByType);
|
||
expect(totalPlans).toBe(expectedTotal);
|
||
}),
|
||
{ numRuns: 100 },
|
||
);
|
||
});
|
||
|
||
test('legend entries match input — only types with count > 0 appear', () => {
|
||
fc.assert(
|
||
fc.property(plansByTypeArb, (plansByType) => {
|
||
const { segments } = computePlanTypeDonutData(plansByType);
|
||
|
||
// Every segment should have count > 0
|
||
for (const seg of segments) {
|
||
expect(seg.count).toBeGreaterThan(0);
|
||
}
|
||
|
||
// Every key in plansByType with count > 0 should appear in segments
|
||
const segmentKeys = new Set(segments.map((s) => s.key));
|
||
for (const [key, count] of Object.entries(plansByType)) {
|
||
if (count > 0 && KNOWN_PLAN_TYPES.includes(key)) {
|
||
expect(segmentKeys.has(key)).toBe(true);
|
||
}
|
||
}
|
||
|
||
// Every segment key should be in the input
|
||
for (const seg of segments) {
|
||
expect(plansByType[seg.key]).toBeDefined();
|
||
expect(plansByType[seg.key]).toBe(seg.count);
|
||
}
|
||
}),
|
||
{ numRuns: 100 },
|
||
);
|
||
});
|
||
|
||
test('legend percentages equal (count / totalPlans) * 100 for each entry', () => {
|
||
fc.assert(
|
||
fc.property(plansByTypeArb, (plansByType) => {
|
||
const { totalPlans, segments } = computePlanTypeDonutData(plansByType);
|
||
|
||
for (const seg of segments) {
|
||
const expectedPct = ((seg.count / totalPlans) * 100).toFixed(0);
|
||
expect(seg.percentage).toBe(expectedPct);
|
||
}
|
||
}),
|
||
{ numRuns: 100 },
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Property 4: Plan status donut data correctness
|
||
// Validates: Requirements 5.3, 5.4
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Property 4: Plan status donut data correctness', () => {
|
||
test('center text (totalPlans) equals sum of all status counts', () => {
|
||
fc.assert(
|
||
fc.property(plansByStatusArb, (plansByStatus) => {
|
||
const expectedTotal = Object.values(plansByStatus).reduce((s, c) => s + c, 0);
|
||
const { totalPlans } = computePlanStatusDonutData(plansByStatus);
|
||
expect(totalPlans).toBe(expectedTotal);
|
||
}),
|
||
{ numRuns: 100 },
|
||
);
|
||
});
|
||
|
||
test('legend entries match input — only statuses with count > 0 appear', () => {
|
||
fc.assert(
|
||
fc.property(plansByStatusArb, (plansByStatus) => {
|
||
const { segments } = computePlanStatusDonutData(plansByStatus);
|
||
|
||
// Every segment should have count > 0
|
||
for (const seg of segments) {
|
||
expect(seg.count).toBeGreaterThan(0);
|
||
}
|
||
|
||
// Every key in plansByStatus with count > 0 should appear in segments
|
||
const segmentKeys = new Set(segments.map((s) => s.key));
|
||
for (const [key, count] of Object.entries(plansByStatus)) {
|
||
if (count > 0) {
|
||
expect(segmentKeys.has(key)).toBe(true);
|
||
}
|
||
}
|
||
|
||
// Every segment key should be in the input with matching count
|
||
for (const seg of segments) {
|
||
expect(plansByStatus[seg.key]).toBeDefined();
|
||
expect(plansByStatus[seg.key]).toBe(seg.count);
|
||
}
|
||
}),
|
||
{ numRuns: 100 },
|
||
);
|
||
});
|
||
|
||
test('legend percentages equal (count / totalPlans) * 100 for each entry', () => {
|
||
fc.assert(
|
||
fc.property(plansByStatusArb, (plansByStatus) => {
|
||
const { totalPlans, segments } = computePlanStatusDonutData(plansByStatus);
|
||
|
||
for (const seg of segments) {
|
||
const expectedPct = ((seg.count / totalPlans) * 100).toFixed(0);
|
||
expect(seg.percentage).toBe(expectedPct);
|
||
}
|
||
}),
|
||
{ numRuns: 100 },
|
||
);
|
||
});
|
||
|
||
test('segment labels are capitalized versions of status keys', () => {
|
||
fc.assert(
|
||
fc.property(plansByStatusArb, (plansByStatus) => {
|
||
const { segments } = computePlanStatusDonutData(plansByStatus);
|
||
|
||
for (const seg of segments) {
|
||
const expectedLabel = seg.key.charAt(0).toUpperCase() + seg.key.slice(1);
|
||
expect(seg.label).toBe(expectedLabel);
|
||
}
|
||
}),
|
||
{ numRuns: 100 },
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Property 5: Plan status color assignment
|
||
// Validates: Requirements 5.2
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Property 5: Plan status color assignment', () => {
|
||
test('known statuses return their specified colors', () => {
|
||
fc.assert(
|
||
fc.property(
|
||
fc.constantFrom('active', 'expired', 'completed'),
|
||
(status) => {
|
||
const color = getStatusColor(status);
|
||
if (status === 'active') expect(color).toBe('#10B981');
|
||
if (status === 'expired') expect(color).toBe('#EF4444');
|
||
if (status === 'completed') expect(color).toBe('#0EA5E9');
|
||
},
|
||
),
|
||
{ numRuns: 100 },
|
||
);
|
||
});
|
||
|
||
test('arbitrary unknown strings return the fallback color #64748B', () => {
|
||
fc.assert(
|
||
fc.property(
|
||
fc.string({ minLength: 0, maxLength: 50 }).filter(
|
||
(s) => s !== 'active' && s !== 'expired' && s !== 'completed',
|
||
),
|
||
(status) => {
|
||
const color = getStatusColor(status);
|
||
expect(color).toBe('#64748B');
|
||
},
|
||
),
|
||
{ numRuns: 100 },
|
||
);
|
||
});
|
||
|
||
test('mixed known and unknown statuses all return correct colors', () => {
|
||
fc.assert(
|
||
fc.property(
|
||
fc.oneof(
|
||
fc.constantFrom('active', 'expired', 'completed'),
|
||
fc.string({ minLength: 0, maxLength: 30 }),
|
||
),
|
||
(status) => {
|
||
const color = getStatusColor(status);
|
||
const expected =
|
||
status === 'active' ? '#10B981' :
|
||
status === 'expired' ? '#EF4444' :
|
||
status === 'completed' ? '#0EA5E9' :
|
||
'#64748B';
|
||
expect(color).toBe(expected);
|
||
},
|
||
),
|
||
{ numRuns: 100 },
|
||
);
|
||
});
|
||
});
|