Add non-metric category filters to compliance page
Adds a CategoryFilterBar with pill-shaped FilterChip components below the metric health cards. Non-metric categories (Missing_AppID, Aging Vulns, Missing_DF, etc.) are derived dynamically from device data and displayed as color-coded filterable chips with device counts. Unified filter state replaces the old metricFilter array, ensuring mutual exclusivity between metric card filters and non-metric chip filters. Includes 4 property-based tests validating derivation, filter predicate, mutual exclusivity, and color resolution correctness. Closes #26
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Property-Based Test: Color resolution with fallback
|
||||
*
|
||||
* Feature: compliance-nonmetric-filters, Property 4: Color resolution with fallback
|
||||
* **Validates: Requirements 6.2, 6.3**
|
||||
*
|
||||
* For any non-metric category metric_id, the resolved color equals
|
||||
* CATEGORY_COLORS[metricCategoriesConfig[metricId]] when both lookups succeed,
|
||||
* else #94A3B8.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
import { deriveNonMetricCategories, CATEGORY_COLORS } from '../components/pages/CompliancePage';
|
||||
|
||||
// The color resolution logic extracted for direct testing
|
||||
function resolveColor(metricId, categoriesConfig) {
|
||||
const categoryName = categoriesConfig[metricId] || null;
|
||||
return (categoryName && CATEGORY_COLORS[categoryName]) || '#94A3B8';
|
||||
}
|
||||
|
||||
// Generators
|
||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
||||
|
||||
const knownCategories = Object.keys(CATEGORY_COLORS);
|
||||
const categoryNameArb = fc.oneof(
|
||||
fc.constantFrom(...knownCategories),
|
||||
fc.string({ minLength: 1, maxLength: 20 }) // may not be in CATEGORY_COLORS
|
||||
);
|
||||
|
||||
const categoriesConfigArb = fc.dictionary(metricIdArb, categoryNameArb);
|
||||
|
||||
describe('Compliance Non-Metric Filter — Property 4: Color resolution with fallback', () => {
|
||||
it('color equals CATEGORY_COLORS[config[metricId]] when both lookups succeed, else #94A3B8', () => {
|
||||
fc.assert(
|
||||
fc.property(metricIdArb, categoriesConfigArb, (metricId, config) => {
|
||||
const result = resolveColor(metricId, config);
|
||||
|
||||
const categoryName = config[metricId];
|
||||
if (categoryName && CATEGORY_COLORS[categoryName]) {
|
||||
expect(result).toBe(CATEGORY_COLORS[categoryName]);
|
||||
} else {
|
||||
expect(result).toBe('#94A3B8');
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('deriveNonMetricCategories produces correct colors for all returned categories', () => {
|
||||
const deviceWithMetricArb = metricIdArb.map(id => ({
|
||||
hostname: 'host-' + id,
|
||||
failing_metrics: [{ metric_id: id, metric_desc: '', category: '' }],
|
||||
}));
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(deviceWithMetricArb, { minLength: 1, maxLength: 15 }),
|
||||
categoriesConfigArb,
|
||||
(devices, config) => {
|
||||
// Empty summary means all device metric_ids are non-metric
|
||||
const result = deriveNonMetricCategories(devices, [], config);
|
||||
|
||||
for (const item of result) {
|
||||
const expectedColor = resolveColor(item.metricId, config);
|
||||
expect(item.color).toBe(expectedColor);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Property-Based Test: Non-metric category derivation is the set difference with accurate counts
|
||||
*
|
||||
* Feature: compliance-nonmetric-filters, Property 1: Non-metric category derivation
|
||||
* **Validates: Requirements 1.1, 1.2, 2.2, 2.4**
|
||||
*
|
||||
* For any set of devices and any set of summary entries for a team,
|
||||
* deriveNonMetricCategories returns exactly the metric_ids present in at least
|
||||
* one device's failing_metrics array that do not appear in any summary entry's
|
||||
* metric_id — deduplicated, sorted alphabetically by metricId, and each with a
|
||||
* count equal to the number of devices whose failing_metrics contains that metric_id.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
import { deriveNonMetricCategories } from '../components/pages/CompliancePage';
|
||||
|
||||
// Generators
|
||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
||||
|
||||
const failingMetricArb = fc.record({
|
||||
metric_id: metricIdArb,
|
||||
metric_desc: fc.constant(''),
|
||||
category: fc.constant(''),
|
||||
});
|
||||
|
||||
const deviceArb = fc.record({
|
||||
hostname: fc.string({ minLength: 1, maxLength: 30 }),
|
||||
failing_metrics: fc.array(failingMetricArb, { minLength: 0, maxLength: 8 }),
|
||||
});
|
||||
|
||||
const summaryEntryArb = fc.record({
|
||||
metric_id: metricIdArb,
|
||||
team: fc.constant('STEAM'),
|
||||
compliance_pct: fc.double({ min: 0, max: 1 }),
|
||||
status: fc.constantFrom('Meets/Exceeds Target', 'Within 15% of Target', 'Below 15% of Target'),
|
||||
});
|
||||
|
||||
const categoriesConfigArb = fc.dictionary(metricIdArb, fc.constantFrom(
|
||||
'Vulnerability Management', 'Access & MFA', 'Logging & Monitoring',
|
||||
'Asset Data Quality', 'Endpoint Protection', 'Application Security',
|
||||
'Disaster Recovery', 'Decommissioned Assets'
|
||||
));
|
||||
|
||||
describe('Compliance Non-Metric Derivation — Property 1: Set difference with accurate counts', () => {
|
||||
it('returned metric_ids are exactly the set difference of device metric_ids minus summary metric_ids', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(deviceArb, { minLength: 0, maxLength: 20 }),
|
||||
fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }),
|
||||
categoriesConfigArb,
|
||||
(devices, summaryEntries, config) => {
|
||||
const result = deriveNonMetricCategories(devices, summaryEntries, config);
|
||||
|
||||
const summaryIds = new Set(summaryEntries.map(e => e.metric_id));
|
||||
const deviceMetricIds = new Set();
|
||||
for (const d of devices) {
|
||||
for (const m of (d.failing_metrics || [])) {
|
||||
if (m.metric_id) deviceMetricIds.add(m.metric_id);
|
||||
}
|
||||
}
|
||||
const expectedIds = new Set([...deviceMetricIds].filter(id => !summaryIds.has(id)));
|
||||
|
||||
const resultIds = new Set(result.map(r => r.metricId));
|
||||
expect(resultIds).toEqual(expectedIds);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('returned list is deduplicated and sorted alphabetically by metricId', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(deviceArb, { minLength: 0, maxLength: 20 }),
|
||||
fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }),
|
||||
categoriesConfigArb,
|
||||
(devices, summaryEntries, config) => {
|
||||
const result = deriveNonMetricCategories(devices, summaryEntries, config);
|
||||
|
||||
// Deduplicated
|
||||
const ids = result.map(r => r.metricId);
|
||||
expect(ids.length).toBe(new Set(ids).size);
|
||||
|
||||
// Sorted alphabetically
|
||||
const sorted = [...ids].sort((a, b) => a.localeCompare(b));
|
||||
expect(ids).toEqual(sorted);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('each count equals the number of devices whose failing_metrics contains that metric_id', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(deviceArb, { minLength: 0, maxLength: 20 }),
|
||||
fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }),
|
||||
categoriesConfigArb,
|
||||
(devices, summaryEntries, config) => {
|
||||
const result = deriveNonMetricCategories(devices, summaryEntries, config);
|
||||
|
||||
for (const { metricId, count } of result) {
|
||||
const expectedCount = devices.filter(d =>
|
||||
(d.failing_metrics || []).some(m => m.metric_id === metricId)
|
||||
).length;
|
||||
expect(count).toBe(expectedCount);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Property-Based Test: Filter predicate correctness
|
||||
*
|
||||
* Feature: compliance-nonmetric-filters, Property 2: Filter predicate correctness
|
||||
* **Validates: Requirements 3.1, 5.2, 5.3, 5.4**
|
||||
*
|
||||
* For any filter state and any set of devices: when null, all devices pass;
|
||||
* when metric, exactly devices with matching metric_id in ids pass;
|
||||
* when nonmetric, exactly devices with matching metric_id pass.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
|
||||
// Replicate the filter predicate logic from CompliancePage
|
||||
function applyFilter(devices, filterState) {
|
||||
return devices.filter(d => {
|
||||
if (!filterState) return true;
|
||||
if (filterState.type === 'metric') return d.failing_metrics.some(m => filterState.ids.includes(m.metric_id));
|
||||
if (filterState.type === 'nonmetric') return d.failing_metrics.some(m => m.metric_id === filterState.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Generators
|
||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
||||
|
||||
const failingMetricArb = fc.record({
|
||||
metric_id: metricIdArb,
|
||||
metric_desc: fc.constant(''),
|
||||
category: fc.constant(''),
|
||||
});
|
||||
|
||||
const deviceArb = fc.record({
|
||||
hostname: fc.string({ minLength: 1, maxLength: 30 }),
|
||||
failing_metrics: fc.array(failingMetricArb, { minLength: 0, maxLength: 8 }),
|
||||
});
|
||||
|
||||
const devicesArb = fc.array(deviceArb, { minLength: 0, maxLength: 20 });
|
||||
|
||||
const filterStateArb = fc.oneof(
|
||||
fc.constant(null),
|
||||
fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric', ids })),
|
||||
metricIdArb.map(id => ({ type: 'nonmetric', id }))
|
||||
);
|
||||
|
||||
describe('Compliance Non-Metric Filter — Property 2: Filter predicate correctness', () => {
|
||||
it('when filterState is null, all devices pass', () => {
|
||||
fc.assert(
|
||||
fc.property(devicesArb, (devices) => {
|
||||
const result = applyFilter(devices, null);
|
||||
expect(result.length).toBe(devices.length);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('when filterState is metric, exactly devices with a matching metric_id pass', () => {
|
||||
fc.assert(
|
||||
fc.property(devicesArb, fc.array(metricIdArb, { minLength: 1, maxLength: 5 }), (devices, ids) => {
|
||||
const filterState = { type: 'metric', ids };
|
||||
const result = applyFilter(devices, filterState);
|
||||
|
||||
const expected = devices.filter(d =>
|
||||
d.failing_metrics.some(m => ids.includes(m.metric_id))
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('when filterState is nonmetric, exactly devices with that metric_id pass', () => {
|
||||
fc.assert(
|
||||
fc.property(devicesArb, metricIdArb, (devices, id) => {
|
||||
const filterState = { type: 'nonmetric', id };
|
||||
const result = applyFilter(devices, filterState);
|
||||
|
||||
const expected = devices.filter(d =>
|
||||
d.failing_metrics.some(m => m.metric_id === id)
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Property-Based Test: Filter state mutual exclusivity
|
||||
*
|
||||
* Feature: compliance-nonmetric-filters, Property 3: Filter state mutual exclusivity
|
||||
* **Validates: Requirements 3.4, 3.5, 5.1**
|
||||
*
|
||||
* For any prior filter state and any filter action (metric card click, chip click,
|
||||
* or clear), the resulting state is exactly one of: null, { type: 'metric', ids: [...] },
|
||||
* or { type: 'nonmetric', id: string } — never undefined, never both.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
|
||||
// Replicate the filter state transition logic from CompliancePage
|
||||
function applyFilterAction(currentState, action) {
|
||||
if (action.type === 'clear') {
|
||||
return null;
|
||||
}
|
||||
if (action.type === 'metric_click') {
|
||||
const familyIds = action.ids;
|
||||
// If currently active metric with same ids, toggle off
|
||||
if (currentState?.type === 'metric' &&
|
||||
currentState.ids.length === familyIds.length &&
|
||||
familyIds.every(id => currentState.ids.includes(id))) {
|
||||
return null;
|
||||
}
|
||||
return { type: 'metric', ids: familyIds };
|
||||
}
|
||||
if (action.type === 'chip_click') {
|
||||
const metricId = action.id;
|
||||
// If currently active nonmetric with same id, toggle off
|
||||
if (currentState?.type === 'nonmetric' && currentState.id === metricId) {
|
||||
return null;
|
||||
}
|
||||
return { type: 'nonmetric', id: metricId };
|
||||
}
|
||||
return currentState;
|
||||
}
|
||||
|
||||
function isValidFilterState(state) {
|
||||
if (state === null) return true;
|
||||
if (state && state.type === 'metric' && Array.isArray(state.ids) && state.ids.length > 0) return true;
|
||||
if (state && state.type === 'nonmetric' && typeof state.id === 'string' && state.id.length > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generators
|
||||
const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/);
|
||||
|
||||
const filterStateArb = fc.oneof(
|
||||
fc.constant(null),
|
||||
fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric', ids })),
|
||||
metricIdArb.map(id => ({ type: 'nonmetric', id }))
|
||||
);
|
||||
|
||||
const filterActionArb = fc.oneof(
|
||||
fc.constant({ type: 'clear' }),
|
||||
fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric_click', ids })),
|
||||
metricIdArb.map(id => ({ type: 'chip_click', id }))
|
||||
);
|
||||
|
||||
describe('Compliance Non-Metric Filter — Property 3: Filter state mutual exclusivity', () => {
|
||||
it('resulting state is always exactly one of null, metric, or nonmetric — never undefined or combined', () => {
|
||||
fc.assert(
|
||||
fc.property(filterStateArb, filterActionArb, (priorState, action) => {
|
||||
const result = applyFilterAction(priorState, action);
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(isValidFilterState(result)).toBe(true);
|
||||
|
||||
// Never has both metric and nonmetric properties
|
||||
if (result !== null) {
|
||||
if (result.type === 'metric') {
|
||||
expect(result).not.toHaveProperty('id');
|
||||
}
|
||||
if (result.type === 'nonmetric') {
|
||||
expect(result).not.toHaveProperty('ids');
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('clear action always results in null regardless of prior state', () => {
|
||||
fc.assert(
|
||||
fc.property(filterStateArb, (priorState) => {
|
||||
const result = applyFilterAction(priorState, { type: 'clear' });
|
||||
expect(result).toBeNull();
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('metric click replaces any prior state with metric filter or null (toggle off)', () => {
|
||||
fc.assert(
|
||||
fc.property(filterStateArb, fc.array(metricIdArb, { minLength: 1, maxLength: 5 }), (priorState, ids) => {
|
||||
const result = applyFilterAction(priorState, { type: 'metric_click', ids });
|
||||
|
||||
if (result === null) {
|
||||
// Toggled off — prior must have been metric with same ids
|
||||
expect(priorState?.type).toBe('metric');
|
||||
} else {
|
||||
expect(result.type).toBe('metric');
|
||||
expect(result.ids).toEqual(ids);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('chip click replaces any prior state with nonmetric filter or null (toggle off)', () => {
|
||||
fc.assert(
|
||||
fc.property(filterStateArb, metricIdArb, (priorState, id) => {
|
||||
const result = applyFilterAction(priorState, { type: 'chip_click', id });
|
||||
|
||||
if (result === null) {
|
||||
// Toggled off — prior must have been nonmetric with same id
|
||||
expect(priorState?.type).toBe('nonmetric');
|
||||
expect(priorState?.id).toBe(id);
|
||||
} else {
|
||||
expect(result.type).toBe('nonmetric');
|
||||
expect(result.id).toBe(id);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user