Show estimated resolution date per metric in compliance sidebar
Add a read-only estimated resolution date line at the top of each noncompliant metric's section in the asset sidebar, sourced from that metric's own resolution_date. Formats valid dates as YYYY-MM-DD and shows placeholders for unset and invalid dates. Resolved metrics are unaffected and the existing editable Resolution Date field is unchanged. Date classification is isolated in a pure helper (frontend/src/utils/ resolutionDate.js) covered by example and fast-check property tests, with render and interaction tests for the sidebar. Closes #20
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield, Trash2, Calendar, FileText, Save } from 'lucide-react';
|
||||
import ConfirmModal from '../ConfirmModal';
|
||||
import {
|
||||
formatResolutionDate,
|
||||
RESOLUTION_DATE_LABEL,
|
||||
NO_DATE_PLACEHOLDER,
|
||||
INVALID_DATE_PLACEHOLDER,
|
||||
} from '../../utils/resolutionDate';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
@@ -796,6 +802,19 @@ function MetricRow({ metric, resolved, onNavigate }) {
|
||||
if (extra['Splunk - Last Seen']) highlights.push({ label: 'Splunk', value: extra['Splunk - Last Seen'] });
|
||||
if (extra['MFA - Software']) highlights.push({ label: 'MFA SW', value: extra['MFA - Software'] });
|
||||
|
||||
// Read-only estimated resolution date, shown only for active (noncompliant)
|
||||
// metrics at the top of the section. Derived solely from this metric's own
|
||||
// resolution_date — no editing, no shared/"Multiple values" collapsing.
|
||||
const resolutionDisplay = resolved ? null : formatResolutionDate(metric.resolution_date);
|
||||
const resolutionValueText = resolutionDisplay
|
||||
? (resolutionDisplay.state === 'set'
|
||||
? resolutionDisplay.value
|
||||
: resolutionDisplay.state === 'invalid'
|
||||
? INVALID_DATE_PLACEHOLDER
|
||||
: NO_DATE_PLACEHOLDER)
|
||||
: null;
|
||||
const resolutionMuted = resolutionDisplay && resolutionDisplay.state !== 'set';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: '0.625rem', padding: '0.625rem 0.75rem',
|
||||
@@ -804,6 +823,23 @@ function MetricRow({ metric, resolved, onNavigate }) {
|
||||
borderRadius: '0.375rem',
|
||||
opacity: resolved ? 0.5 : 1,
|
||||
}}>
|
||||
{resolutionDisplay && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.4rem' }}>
|
||||
<Calendar size={12} style={{ color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.68rem', color: '#64748B', fontFamily: 'monospace', flexShrink: 0 }}>
|
||||
{RESOLUTION_DATE_LABEL}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '0.68rem',
|
||||
color: resolutionMuted ? '#475569' : TEAL,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: resolutionMuted ? '400' : '600',
|
||||
fontStyle: resolutionMuted ? 'italic' : 'normal',
|
||||
}}>
|
||||
{resolutionValueText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: highlights.length ? '0.4rem' : 0 }}>
|
||||
<MetricChip metricId={metric.metric_id} category={metric.category} status={metric.status} />
|
||||
{resolved && <span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>resolved {metric.resolved_on || ''}</span>}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Render and interaction tests for the per-metric estimated-resolution-date
|
||||
* line in the asset sidebar (ComplianceDetailPanel.js / MetricRow).
|
||||
*
|
||||
* Feature: compliance-metric-estimated-resolution-date
|
||||
* Spec: .kiro/specs/compliance-metric-estimated-resolution-date
|
||||
*
|
||||
* Covers tasks 3.2 (placement, labels, placeholders), 3.3 (resolved
|
||||
* suppression, read-only structure, role-independence, existing editor
|
||||
* preserved), and 3.4 (existing save round-trip).
|
||||
*
|
||||
* Requirements covered: 1.1, 1.2, 1.4, 1.5, 1.6, 2.1, 3.1, 3.4, 4.1, 4.2,
|
||||
* 4.3, 4.4, 4.5, 5.1, 5.2, 5.3, 5.4.
|
||||
*
|
||||
* The component fetches the asset detail via GET
|
||||
* `${API_BASE}/compliance/items/:hostname` (credentials included) and saves
|
||||
* metadata via PATCH `${API_BASE}/compliance/items/:hostname/metadata`, so
|
||||
* global.fetch is mocked to serve the asset detail JSON and PATCH responses.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import ComplianceDetailPanel from '../ComplianceDetailPanel';
|
||||
import {
|
||||
RESOLUTION_DATE_LABEL,
|
||||
NO_DATE_PLACEHOLDER,
|
||||
INVALID_DATE_PLACEHOLDER,
|
||||
} from '../../../utils/resolutionDate';
|
||||
|
||||
const HOSTNAME = 'host-1.example.com';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMetric(overrides = {}) {
|
||||
return {
|
||||
metric_id: '2.3.6i',
|
||||
category: 'Vulnerability Management',
|
||||
status: 'active',
|
||||
metric_desc: 'Outbound encryption required on all endpoints',
|
||||
resolution_date: null,
|
||||
remediation_plan: null,
|
||||
seen_count: 1,
|
||||
first_seen: '2025-01-01',
|
||||
resolved_on: null,
|
||||
extra: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetail(metrics, overrides = {}) {
|
||||
return {
|
||||
hostname: HOSTNAME,
|
||||
ip_address: '10.0.0.1',
|
||||
device_type: 'server',
|
||||
team: 'STEAM',
|
||||
metrics,
|
||||
history: [],
|
||||
notes: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const jsonResponse = (body, ok = true) => ({ ok, json: async () => body });
|
||||
|
||||
/**
|
||||
* Mock global.fetch. GET requests to the detail endpoint are served from a
|
||||
* queue of detail objects (the last one is reused once the queue drains, so
|
||||
* the initial load and any re-fetch can each return a distinct snapshot).
|
||||
* PATCH requests to the metadata endpoint return the configured response.
|
||||
*/
|
||||
function mockFetch({ details, patchOk = true, patchBody = {} }) {
|
||||
const queue = [...details];
|
||||
let last = details[details.length - 1];
|
||||
global.fetch = jest.fn((url, options = {}) => {
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
if (method === 'PATCH') {
|
||||
return Promise.resolve(jsonResponse(patchBody, patchOk));
|
||||
}
|
||||
// GET detail (fetchDetail)
|
||||
const next = queue.length > 0 ? queue.shift() : last;
|
||||
last = next;
|
||||
return Promise.resolve(jsonResponse(next));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM query helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The estimated-resolution date line renders as:
|
||||
// <div>
|
||||
// <svg .../> (Calendar icon)
|
||||
// <span>{RESOLUTION_DATE_LABEL}</span>
|
||||
// <span>{value | placeholder}</span>
|
||||
// </div>
|
||||
// We locate each line by its label span, which contains exactly the label text.
|
||||
|
||||
function getDateLineLabels(container) {
|
||||
return Array.from(container.querySelectorAll('span')).filter(
|
||||
(s) => s.textContent === RESOLUTION_DATE_LABEL
|
||||
);
|
||||
}
|
||||
|
||||
function getDateLineValueTexts(container) {
|
||||
return getDateLineLabels(container).map((label) =>
|
||||
label.nextElementSibling ? label.nextElementSibling.textContent : null
|
||||
);
|
||||
}
|
||||
|
||||
async function renderAndLoad(detail, props = {}) {
|
||||
const utils = render(
|
||||
<ComplianceDetailPanel
|
||||
hostname={HOSTNAME}
|
||||
onClose={() => {}}
|
||||
onNoteAdded={() => {}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
// Wait for the async detail load to complete (a metric description renders).
|
||||
await screen.findByText(detail.metrics[0].metric_desc);
|
||||
return utils;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.REACT_APP_API_BASE = 'http://localhost:3001/api';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Task 3.2 — Render tests for placement, labels, and placeholders
|
||||
// ===========================================================================
|
||||
|
||||
describe('Task 3.2 — placement, labels, and placeholders', () => {
|
||||
test('placement (Req 1.2): estimated-resolution element precedes the metric description', async () => {
|
||||
const detail = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
const labelEl = screen.getByText(RESOLUTION_DATE_LABEL);
|
||||
const descEl = screen.getByText(detail.metrics[0].metric_desc);
|
||||
|
||||
// descEl must come AFTER labelEl in document order.
|
||||
expect(
|
||||
labelEl.compareDocumentPosition(descEl) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
|
||||
// Sanity: there is exactly one date line for the single active metric.
|
||||
expect(getDateLineLabels(container)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('label presence (Req 1.5): RESOLUTION_DATE_LABEL appears adjacent to the value', async () => {
|
||||
const detail = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
const labels = getDateLineLabels(container);
|
||||
expect(labels).toHaveLength(1);
|
||||
// The value span is the immediate next sibling of the label span.
|
||||
expect(labels[0].nextElementSibling).not.toBeNull();
|
||||
expect(labels[0].nextElementSibling.textContent).toBe('2026-07-01');
|
||||
});
|
||||
|
||||
test('set value (Req 1.1, 1.4): an active row with 2026-07-01 renders 2026-07-01', async () => {
|
||||
const detail = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
|
||||
});
|
||||
|
||||
test('no-date placeholder (Req 2.1, 4.5): null/empty/whitespace render NO_DATE_PLACEHOLDER and keep the description', async () => {
|
||||
const metrics = [
|
||||
makeMetric({
|
||||
metric_id: '2.3.6i',
|
||||
metric_desc: 'Metric with null resolution date',
|
||||
resolution_date: null,
|
||||
}),
|
||||
makeMetric({
|
||||
metric_id: '2.3.8i',
|
||||
metric_desc: 'Metric with empty resolution date',
|
||||
resolution_date: '',
|
||||
}),
|
||||
makeMetric({
|
||||
metric_id: 'Vulns_Aging',
|
||||
metric_desc: 'Metric with whitespace resolution date',
|
||||
resolution_date: ' ',
|
||||
}),
|
||||
];
|
||||
const detail = makeDetail(metrics);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
// All three active rows show the no-date placeholder.
|
||||
expect(getDateLineValueTexts(container)).toEqual([
|
||||
NO_DATE_PLACEHOLDER,
|
||||
NO_DATE_PLACEHOLDER,
|
||||
NO_DATE_PLACEHOLDER,
|
||||
]);
|
||||
|
||||
// Each metric's description still renders.
|
||||
for (const m of metrics) {
|
||||
expect(screen.getByText(m.metric_desc)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid placeholder (Req 1.6): malformed date renders INVALID_DATE_PLACEHOLDER and keeps the description', async () => {
|
||||
const detail = makeDetail([
|
||||
makeMetric({
|
||||
metric_desc: 'Metric with a malformed resolution date',
|
||||
resolution_date: '2026-13-99',
|
||||
}),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
expect(getDateLineValueTexts(container)).toEqual([INVALID_DATE_PLACEHOLDER]);
|
||||
expect(
|
||||
screen.getByText('Metric with a malformed resolution date')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Task 3.3 — resolved suppression, read-only structure, role-independence
|
||||
// ===========================================================================
|
||||
|
||||
describe('Task 3.3 — suppression, read-only structure, role-independence', () => {
|
||||
test('resolved suppression (Req 3.1, 3.4): a resolved metric with a populated date renders no estimated-resolution line', async () => {
|
||||
const detail = makeDetail([
|
||||
makeMetric({
|
||||
metric_id: '2.3.6i',
|
||||
status: 'resolved',
|
||||
metric_desc: 'Resolved metric with a populated resolution date',
|
||||
resolution_date: '2026-07-01',
|
||||
resolved_on: '2026-06-15',
|
||||
}),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
// No estimated-resolution line anywhere for a resolved-only asset.
|
||||
expect(getDateLineLabels(container)).toHaveLength(0);
|
||||
expect(screen.queryByText(RESOLUTION_DATE_LABEL)).toBeNull();
|
||||
});
|
||||
|
||||
test('mixed list (Req 3.4): the estimated-resolution line appears only within active rows', async () => {
|
||||
const metrics = [
|
||||
makeMetric({
|
||||
metric_id: '2.3.6i',
|
||||
status: 'active',
|
||||
metric_desc: 'Active metric one',
|
||||
resolution_date: '2026-07-01',
|
||||
}),
|
||||
makeMetric({
|
||||
metric_id: '2.3.8i',
|
||||
status: 'active',
|
||||
metric_desc: 'Active metric two',
|
||||
resolution_date: '2026-09-30',
|
||||
}),
|
||||
makeMetric({
|
||||
metric_id: 'Vulns_Aging',
|
||||
status: 'resolved',
|
||||
metric_desc: 'Resolved metric',
|
||||
resolution_date: '2026-01-15',
|
||||
resolved_on: '2026-01-10',
|
||||
}),
|
||||
];
|
||||
const detail = makeDetail(metrics);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
// Exactly two date lines (one per active metric), each its own value.
|
||||
expect(getDateLineValueTexts(container)).toEqual([
|
||||
'2026-07-01',
|
||||
'2026-09-30',
|
||||
]);
|
||||
});
|
||||
|
||||
test('read-only structure (Req 5.3): the date-line subtree has no input, button, or anchor', async () => {
|
||||
const detail = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
const labels = getDateLineLabels(container);
|
||||
expect(labels).toHaveLength(1);
|
||||
const dateLineSubtree = labels[0].parentElement;
|
||||
|
||||
// Plain text only — no interactive controls capable of modifying the field.
|
||||
expect(
|
||||
dateLineSubtree.querySelectorAll('input, button, a, select, textarea')
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('role-independence (Req 5.1, 5.2, 5.4): the date line is plain, non-interactive text', async () => {
|
||||
// ComplianceDetailPanel does not consume a role/auth context for the
|
||||
// estimated-resolution subtree: the line is derived purely from
|
||||
// metric.resolution_date and rendered as static spans. The display is
|
||||
// therefore role-independent by construction — a viewer, editor, and
|
||||
// admin all receive byte-for-byte identical output and no editing control
|
||||
// is introduced. We assert the plain-text value and the absence of any
|
||||
// interactive element in the subtree.
|
||||
const detail = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
|
||||
|
||||
const dateLineSubtree = getDateLineLabels(container)[0].parentElement;
|
||||
expect(
|
||||
dateLineSubtree.querySelectorAll('input, button, a, select, textarea')
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('existing editor preserved (Req 4.1): the editable Resolution Date input[type=date] still renders', async () => {
|
||||
const detail = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
expect(container.querySelector('input[type="date"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Task 3.4 — Interaction tests for the existing save round-trip
|
||||
// ===========================================================================
|
||||
|
||||
describe('Task 3.4 — save round-trip', () => {
|
||||
test('successful save (Req 4.2, 4.3): displayed estimated-resolution updates to the new date after save + re-fetch', async () => {
|
||||
const before = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
const after = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-08-15' }),
|
||||
]);
|
||||
// GET (initial) -> before, PATCH -> ok, GET (re-fetch) -> after
|
||||
mockFetch({ details: [before, after], patchOk: true });
|
||||
|
||||
const { container } = await renderAndLoad(before);
|
||||
|
||||
// Pre-condition: original date displayed.
|
||||
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
|
||||
|
||||
// Editor updates the editable Resolution Date field and saves.
|
||||
const dateInput = container.querySelector('input[type="date"]');
|
||||
fireEvent.change(dateInput, { target: { value: '2026-08-15' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
// The displayed estimated-resolution value updates from the re-fetch.
|
||||
await waitFor(() => {
|
||||
expect(getDateLineValueTexts(container)).toEqual(['2026-08-15']);
|
||||
});
|
||||
});
|
||||
|
||||
test('successful clear (Req 4.5): clearing the field renders NO_DATE_PLACEHOLDER after save + re-fetch', async () => {
|
||||
const before = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
const after = makeDetail([makeMetric({ resolution_date: '' })]);
|
||||
mockFetch({ details: [before, after], patchOk: true });
|
||||
|
||||
const { container } = await renderAndLoad(before);
|
||||
|
||||
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
|
||||
|
||||
const dateInput = container.querySelector('input[type="date"]');
|
||||
fireEvent.change(dateInput, { target: { value: '' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getDateLineValueTexts(container)).toEqual([NO_DATE_PLACEHOLDER]);
|
||||
});
|
||||
});
|
||||
|
||||
test('failed save (Req 4.4): previously displayed date is retained and an error is shown', async () => {
|
||||
const before = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
// PATCH fails; fetchDetail is never re-issued, so the queue only needs the
|
||||
// initial detail.
|
||||
mockFetch({
|
||||
details: [before],
|
||||
patchOk: false,
|
||||
patchBody: { error: 'Save failed' },
|
||||
});
|
||||
|
||||
const { container } = await renderAndLoad(before);
|
||||
|
||||
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
|
||||
|
||||
const dateInput = container.querySelector('input[type="date"]');
|
||||
fireEvent.change(dateInput, { target: { value: '2099-01-01' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
// Error indication appears.
|
||||
await screen.findByText('Save failed');
|
||||
|
||||
// The previously displayed estimated-resolution date is retained.
|
||||
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
|
||||
});
|
||||
});
|
||||
292
frontend/src/utils/__tests__/resolutionDate.property.test.js
Normal file
292
frontend/src/utils/__tests__/resolutionDate.property.test.js
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Property-Based Tests: Resolution-date helper
|
||||
*
|
||||
* Feature: compliance-metric-estimated-resolution-date
|
||||
*
|
||||
* Exercises the pure helper `formatResolutionDate(raw)` from
|
||||
* `frontend/src/utils/resolutionDate.js`, which classifies a raw per-metric
|
||||
* `resolution_date` value into a discriminated union:
|
||||
* { state: 'set', value } | { state: 'none' } | { state: 'invalid' }
|
||||
*
|
||||
* Library: fast-check (v4) with Jest (react-scripts test). Generators are built
|
||||
* from fast-check arbitraries only — none are hand-rolled. Each property runs a
|
||||
* minimum of 100 iterations.
|
||||
*
|
||||
* Validates: Requirements 1.1, 1.3, 1.4, 1.6, 2.1, 2.2, 3.2, 3.3, 4.5
|
||||
*/
|
||||
|
||||
import * as fc from 'fast-check';
|
||||
import { formatResolutionDate } from '../resolutionDate';
|
||||
|
||||
const NUM_RUNS = 200;
|
||||
const VALID_STATES = ['set', 'none', 'invalid'];
|
||||
const SHARED_SENTINEL = 'Multiple values';
|
||||
|
||||
// --- Independent oracle (NOT the function under test) ----------------------
|
||||
// Used only to filter generated inputs so we never assert the wrong class.
|
||||
|
||||
function daysInMonthOracle(year, month) {
|
||||
const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||||
const lengths = [31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
return lengths[month - 1];
|
||||
}
|
||||
|
||||
// True iff `s` is a strict YYYY-MM-DD string that names a real calendar date.
|
||||
function isValidCalendarYmd(s) {
|
||||
if (typeof s !== 'string') return false;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
|
||||
const year = Number(s.slice(0, 4));
|
||||
const month = Number(s.slice(5, 7));
|
||||
const day = Number(s.slice(8, 10));
|
||||
if (month < 1 || month > 12) return false;
|
||||
if (day < 1 || day > daysInMonthOracle(year, month)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Shared arbitraries -----------------------------------------------------
|
||||
|
||||
// Four-digit zero-padded year string (0000–9999) — always matches \d{4}.
|
||||
const year4Arb = fc.integer({ min: 0, max: 9999 }).map(y => String(y).padStart(4, '0'));
|
||||
|
||||
// Valid calendar dates spanning years, all months, month-length boundaries
|
||||
// (28/29/30/31), and leap days. The `dim` boundary value guarantees the true
|
||||
// last day of each month is exercised, including Feb 29 in leap years.
|
||||
const validDateStringArb = year4Arb.chain(y =>
|
||||
fc.integer({ min: 1, max: 12 }).chain(m => {
|
||||
const dim = daysInMonthOracle(Number(y), m);
|
||||
const dayArb = fc.oneof(
|
||||
fc.constant(1),
|
||||
fc.constant(dim),
|
||||
fc.integer({ min: 1, max: dim })
|
||||
);
|
||||
return dayArb.map(
|
||||
d => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// null / undefined / empty / whitespace-only (spaces, tabs, newlines).
|
||||
const whitespaceChar = fc.constantFrom(' ', '\t', '\n');
|
||||
const whitespaceStringArb = fc
|
||||
.array(whitespaceChar, { minLength: 1, maxLength: 20 })
|
||||
.map(chars => chars.join(''));
|
||||
const absentArb = fc.oneof(
|
||||
fc.constantFrom(null, undefined, ''),
|
||||
whitespaceStringArb
|
||||
);
|
||||
|
||||
// Non-empty, non-whitespace-only strings that are NOT valid YYYY-MM-DD dates.
|
||||
// Built from several invalid-by-construction families, then defensively
|
||||
// filtered against the oracle to drop any accidentally-valid value.
|
||||
const twoDigitArb = fc.integer({ min: 0, max: 99 }).map(n => String(n).padStart(2, '0'));
|
||||
const nonLeapYear4Arb = fc
|
||||
.integer({ min: 0, max: 9999 })
|
||||
.filter(y => !((y % 4 === 0 && y % 100 !== 0) || y % 400 === 0))
|
||||
.map(y => String(y).padStart(4, '0'));
|
||||
|
||||
// Valid shape but month out of range (00 or 13–99).
|
||||
const badMonthArb = fc
|
||||
.tuple(
|
||||
year4Arb,
|
||||
fc.oneof(fc.constant('00'), fc.integer({ min: 13, max: 99 }).map(n => String(n).padStart(2, '0'))),
|
||||
fc.integer({ min: 1, max: 28 }).map(n => String(n).padStart(2, '0'))
|
||||
)
|
||||
.map(([y, m, d]) => `${y}-${m}-${d}`);
|
||||
|
||||
// Valid shape but day out of range (00 or 32–99).
|
||||
const badDayArb = fc
|
||||
.tuple(
|
||||
year4Arb,
|
||||
fc.integer({ min: 1, max: 12 }).map(n => String(n).padStart(2, '0')),
|
||||
fc.oneof(fc.constant('00'), fc.integer({ min: 32, max: 99 }).map(n => String(n).padStart(2, '0')))
|
||||
)
|
||||
.map(([y, m, d]) => `${y}-${m}-${d}`);
|
||||
|
||||
// Valid shape but impossible calendar day (Feb 30/31, 31 in 30-day months,
|
||||
// Feb 29 in a non-leap year).
|
||||
const impossibleDayArb = fc.oneof(
|
||||
fc.tuple(year4Arb, fc.constant('02'), fc.constantFrom('30', '31')).map(([y, m, d]) => `${y}-${m}-${d}`),
|
||||
fc.tuple(year4Arb, fc.constantFrom('04', '06', '09', '11'), fc.constant('31')).map(([y, m, d]) => `${y}-${m}-${d}`),
|
||||
nonLeapYear4Arb.map(y => `${y}-02-29`)
|
||||
);
|
||||
|
||||
// Wrong shapes (not matching ^\d{4}-\d{2}-\d{2}$).
|
||||
const wrongShapeArb = fc.oneof(
|
||||
fc.constantFrom(
|
||||
'2026-7-1',
|
||||
'2026-7-01',
|
||||
'2026-07-1',
|
||||
'07/01/2026',
|
||||
'2026/07/01',
|
||||
'20260701',
|
||||
'2026-07',
|
||||
'2026-07-01T00:00:00',
|
||||
'2026-07-01 ', // trailing handled by trim, still wrong below
|
||||
'not-a-date',
|
||||
'July 1 2026'
|
||||
),
|
||||
fc.string({ minLength: 1, maxLength: 30 })
|
||||
);
|
||||
|
||||
const invalidStringArb = fc
|
||||
.oneof(wrongShapeArb, badMonthArb, badDayArb, impossibleDayArb, twoDigitArb)
|
||||
.filter(s => typeof s === 'string' && s.trim() !== '' && !isValidCalendarYmd(s.trim()));
|
||||
|
||||
// Any input category (used for totality / independence properties).
|
||||
const anyInputArb = fc.oneof(
|
||||
validDateStringArb,
|
||||
absentArb,
|
||||
invalidStringArb
|
||||
);
|
||||
|
||||
// --- Property 1 -------------------------------------------------------------
|
||||
|
||||
// Feature: compliance-metric-estimated-resolution-date, Property 1: Valid calendar dates classify as "set" and format as YYYY-MM-DD
|
||||
describe('Property 1: Valid calendar dates classify as "set" and format as YYYY-MM-DD', () => {
|
||||
/**
|
||||
* **Validates: Requirements 1.1, 1.4**
|
||||
*
|
||||
* For any valid calendar date in YYYY-MM-DD form, formatResolutionDate
|
||||
* returns { state: 'set', value } where value matches ^\d{4}-\d{2}-\d{2}$
|
||||
* and equals the canonical normalized form (the input itself).
|
||||
*/
|
||||
test('valid calendar dates are classified set and normalized to YYYY-MM-DD', () => {
|
||||
fc.assert(
|
||||
fc.property(validDateStringArb, dateStr => {
|
||||
const result = formatResolutionDate(dateStr);
|
||||
expect(result.state).toBe('set');
|
||||
expect(result.value).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(result.value).toBe(dateStr);
|
||||
}),
|
||||
{ numRuns: NUM_RUNS }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property 2 -------------------------------------------------------------
|
||||
|
||||
// Feature: compliance-metric-estimated-resolution-date, Property 2: Absent values classify as "none"
|
||||
describe('Property 2: Absent values classify as "none"', () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.1, 4.5**
|
||||
*
|
||||
* For any input that is null, undefined, the empty string, or a string
|
||||
* composed entirely of whitespace, formatResolutionDate returns
|
||||
* { state: 'none' }.
|
||||
*/
|
||||
test('null, undefined, empty, and whitespace-only inputs classify as none', () => {
|
||||
fc.assert(
|
||||
fc.property(absentArb, raw => {
|
||||
const result = formatResolutionDate(raw);
|
||||
expect(result.state).toBe('none');
|
||||
}),
|
||||
{ numRuns: NUM_RUNS }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property 3 -------------------------------------------------------------
|
||||
|
||||
// Feature: compliance-metric-estimated-resolution-date, Property 3: Non-empty non-calendar-date values classify as "invalid"
|
||||
describe('Property 3: Non-empty non-calendar-date values classify as "invalid"', () => {
|
||||
/**
|
||||
* **Validates: Requirements 1.6**
|
||||
*
|
||||
* For any non-empty, non-whitespace-only string that is not a valid
|
||||
* YYYY-MM-DD calendar date — wrong shapes, out-of-range months/days,
|
||||
* impossible days such as 2026-02-30, and arbitrary text —
|
||||
* formatResolutionDate returns { state: 'invalid' }.
|
||||
*/
|
||||
test('non-empty non-calendar-date strings classify as invalid', () => {
|
||||
fc.assert(
|
||||
fc.property(invalidStringArb, raw => {
|
||||
const result = formatResolutionDate(raw);
|
||||
expect(result.state).toBe('invalid');
|
||||
}),
|
||||
{ numRuns: NUM_RUNS }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property 4 -------------------------------------------------------------
|
||||
|
||||
// Feature: compliance-metric-estimated-resolution-date, Property 4: Classification is total over any metric list
|
||||
describe('Property 4: Classification is total over any metric list', () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.2**
|
||||
*
|
||||
* For any array of resolution_date values drawn from all categories,
|
||||
* formatResolutionDate never throws, every result's state is one of
|
||||
* { 'set', 'none', 'invalid' }, and the number of classified results
|
||||
* equals the number of inputs.
|
||||
*/
|
||||
test('classification is total: no throw, valid state, one result per input', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(anyInputArb, { maxLength: 30 }), inputs => {
|
||||
const results = inputs.map(raw => formatResolutionDate(raw));
|
||||
expect(results).toHaveLength(inputs.length);
|
||||
results.forEach(result => {
|
||||
expect(VALID_STATES).toContain(result.state);
|
||||
});
|
||||
}),
|
||||
{ numRuns: NUM_RUNS }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property 5 -------------------------------------------------------------
|
||||
|
||||
// Metric-like object with an independently chosen resolution_date.
|
||||
const metricArb = fc.record({
|
||||
metric_id: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
resolution_date: anyInputArb,
|
||||
});
|
||||
|
||||
// General metric arrays plus arrays forced to contain two differing dates.
|
||||
const differingMetricsArb = fc
|
||||
.tuple(validDateStringArb, validDateStringArb)
|
||||
.filter(([a, b]) => a !== b)
|
||||
.chain(([a, b]) =>
|
||||
fc.array(metricArb, { maxLength: 5 }).map(rest => [
|
||||
{ metric_id: 'm-a', resolution_date: a },
|
||||
{ metric_id: 'm-b', resolution_date: b },
|
||||
...rest,
|
||||
])
|
||||
);
|
||||
const metricsArrayArb = fc.oneof(
|
||||
fc.array(metricArb, { maxLength: 30 }),
|
||||
differingMetricsArb
|
||||
);
|
||||
|
||||
// Feature: compliance-metric-estimated-resolution-date, Property 5: Each metric's display derives only from its own field (no collapsing)
|
||||
describe('Property 5: Each metric\'s display derives only from its own field (no collapsing)', () => {
|
||||
/**
|
||||
* **Validates: Requirements 1.3, 3.2, 3.3**
|
||||
*
|
||||
* For any array of metrics — including arrays where metrics carry different
|
||||
* resolution_date values — the derived display for each metric equals
|
||||
* formatResolutionDate applied to that same metric's own field in isolation,
|
||||
* independent of every sibling, and no result collapses to a shared
|
||||
* "Multiple values" sentinel.
|
||||
*/
|
||||
test('each metric maps to its own field with no shared/collapsed value', () => {
|
||||
fc.assert(
|
||||
fc.property(metricsArrayArb, metrics => {
|
||||
const displayed = metrics.map(m => formatResolutionDate(m.resolution_date));
|
||||
expect(displayed).toHaveLength(metrics.length);
|
||||
|
||||
metrics.forEach((metric, index) => {
|
||||
// Computed in isolation from this metric's own field only.
|
||||
const isolated = formatResolutionDate(metric.resolution_date);
|
||||
expect(displayed[index]).toEqual(isolated);
|
||||
|
||||
// No collapsing to a shared "Multiple values" sentinel.
|
||||
expect(displayed[index].state).not.toBe(SHARED_SENTINEL);
|
||||
expect(displayed[index].value).not.toBe(SHARED_SENTINEL);
|
||||
expect(VALID_STATES).toContain(displayed[index].state);
|
||||
});
|
||||
}),
|
||||
{ numRuns: NUM_RUNS }
|
||||
);
|
||||
});
|
||||
});
|
||||
88
frontend/src/utils/__tests__/resolutionDate.test.js
Normal file
88
frontend/src/utils/__tests__/resolutionDate.test.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Example and edge-case unit tests for the resolution-date helper.
|
||||
*
|
||||
* Spec: .kiro/specs/compliance-metric-estimated-resolution-date
|
||||
* Task: 1.7 — Example and edge-case unit tests for the helper
|
||||
* Requirements: 1.1, 1.4, 1.6, 2.1
|
||||
*
|
||||
* These concrete fixtures anchor the contract of `formatResolutionDate` and
|
||||
* double as regression cases. The universal behavior is covered separately by
|
||||
* the property-based tests in `resolutionDate.property.test.js`.
|
||||
*/
|
||||
|
||||
import {
|
||||
formatResolutionDate,
|
||||
RESOLUTION_DATE_LABEL,
|
||||
NO_DATE_PLACEHOLDER,
|
||||
INVALID_DATE_PLACEHOLDER,
|
||||
} from '../resolutionDate';
|
||||
|
||||
describe('formatResolutionDate', () => {
|
||||
describe('set — valid calendar dates (Requirements 1.1, 1.4)', () => {
|
||||
it("classifies '2026-07-01' as set with the normalized YYYY-MM-DD value", () => {
|
||||
expect(formatResolutionDate('2026-07-01')).toEqual({
|
||||
state: 'set',
|
||||
value: '2026-07-01',
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies the leap day '2024-02-29' as set (2024 is a leap year)", () => {
|
||||
expect(formatResolutionDate('2024-02-29')).toEqual({
|
||||
state: 'set',
|
||||
value: '2024-02-29',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid — present but not a valid calendar date (Requirement 1.6)', () => {
|
||||
it("classifies '2026-7-1' as invalid (components not zero-padded)", () => {
|
||||
expect(formatResolutionDate('2026-7-1')).toEqual({ state: 'invalid' });
|
||||
});
|
||||
|
||||
it("classifies '07/01/2026' as invalid (wrong shape / separators)", () => {
|
||||
expect(formatResolutionDate('07/01/2026')).toEqual({ state: 'invalid' });
|
||||
});
|
||||
|
||||
it("classifies '2023-02-29' as invalid (2023 is not a leap year)", () => {
|
||||
expect(formatResolutionDate('2023-02-29')).toEqual({ state: 'invalid' });
|
||||
});
|
||||
|
||||
it("classifies '2026-13-01' as invalid (month out of range)", () => {
|
||||
expect(formatResolutionDate('2026-13-01')).toEqual({ state: 'invalid' });
|
||||
});
|
||||
|
||||
it("classifies '2026-00-10' as invalid (month below range)", () => {
|
||||
expect(formatResolutionDate('2026-00-10')).toEqual({ state: 'invalid' });
|
||||
});
|
||||
|
||||
it("classifies '2026-01-32' as invalid (day above month length)", () => {
|
||||
expect(formatResolutionDate('2026-01-32')).toEqual({ state: 'invalid' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('none — absent values (Requirements 2.1)', () => {
|
||||
it('classifies whitespace-only input as none', () => {
|
||||
expect(formatResolutionDate(' ')).toEqual({ state: 'none' });
|
||||
});
|
||||
|
||||
it('classifies null as none', () => {
|
||||
expect(formatResolutionDate(null)).toEqual({ state: 'none' });
|
||||
});
|
||||
|
||||
it('classifies undefined as none', () => {
|
||||
expect(formatResolutionDate(undefined)).toEqual({ state: 'none' });
|
||||
});
|
||||
|
||||
it('classifies the empty string as none', () => {
|
||||
expect(formatResolutionDate('')).toEqual({ state: 'none' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('display constants', () => {
|
||||
it('exposes the expected label and placeholder strings', () => {
|
||||
expect(RESOLUTION_DATE_LABEL).toBe('Est. Resolution');
|
||||
expect(NO_DATE_PLACEHOLDER).toBe('not set');
|
||||
expect(INVALID_DATE_PLACEHOLDER).toBe('invalid date');
|
||||
});
|
||||
});
|
||||
});
|
||||
86
frontend/src/utils/resolutionDate.js
Normal file
86
frontend/src/utils/resolutionDate.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Resolution-date helper — classifies and formats a raw per-metric
|
||||
* `resolution_date` value for read-only display in the asset sidebar.
|
||||
*
|
||||
* Spec: .kiro/specs/compliance-metric-estimated-resolution-date
|
||||
* Requirements: 1.1, 1.4, 1.6, 2.1
|
||||
*
|
||||
* Pure and deterministic: the result depends only on `raw`. It does not read
|
||||
* the system clock, timezone, or locale. Validation is strict YYYY-MM-DD with
|
||||
* a real-calendar-date check (correct month lengths and leap years), which
|
||||
* matches how the value is produced by the <input type="date"> editor.
|
||||
*/
|
||||
|
||||
// Display constants (single source of truth for component + tests)
|
||||
export const RESOLUTION_DATE_LABEL = 'Est. Resolution';
|
||||
export const NO_DATE_PLACEHOLDER = 'not set';
|
||||
export const INVALID_DATE_PLACEHOLDER = 'invalid date';
|
||||
|
||||
// Strict YYYY-MM-DD shape: four-digit year, two-digit month, two-digit day.
|
||||
const YMD_SHAPE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Returns the number of days in the given month for the given year,
|
||||
* accounting for leap years. `month` is 1-based (1 = January).
|
||||
*
|
||||
* @param {number} year
|
||||
* @param {number} month - 1-based month (1–12)
|
||||
* @returns {number} days in that month
|
||||
*/
|
||||
function daysInMonth(year, month) {
|
||||
// Leap year: divisible by 4, except centuries not divisible by 400.
|
||||
const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||||
const lengths = [31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
return lengths[month - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify and format a raw per-metric resolution_date value for display.
|
||||
*
|
||||
* @param {string|null|undefined} raw - the metric's resolution_date field
|
||||
* @returns {{ state: 'set', value: string } | { state: 'none' } | { state: 'invalid' }}
|
||||
* - { state: 'set', value } the value is a valid calendar date; `value` is YYYY-MM-DD
|
||||
* - { state: 'none' } the value is null, undefined, empty, or whitespace-only
|
||||
* - { state: 'invalid' } the value is non-empty but not a valid calendar date
|
||||
*/
|
||||
export function formatResolutionDate(raw) {
|
||||
// Null/undefined → no date set.
|
||||
if (raw === null || raw === undefined) {
|
||||
return { state: 'none' };
|
||||
}
|
||||
|
||||
// Anything that is not a string is treated as not-a-valid-date once it is
|
||||
// non-empty; coerce defensively so the helper never throws on bad input.
|
||||
if (typeof raw !== 'string') {
|
||||
return { state: 'invalid' };
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
|
||||
// Empty or whitespace-only → no date set.
|
||||
if (trimmed === '') {
|
||||
return { state: 'none' };
|
||||
}
|
||||
|
||||
// Must match the strict YYYY-MM-DD shape.
|
||||
if (!YMD_SHAPE.test(trimmed)) {
|
||||
return { state: 'invalid' };
|
||||
}
|
||||
|
||||
// Shape is correct; verify it is a real calendar date.
|
||||
const year = Number(trimmed.slice(0, 4));
|
||||
const month = Number(trimmed.slice(5, 7));
|
||||
const day = Number(trimmed.slice(8, 10));
|
||||
|
||||
if (month < 1 || month > 12) {
|
||||
return { state: 'invalid' };
|
||||
}
|
||||
|
||||
if (day < 1 || day > daysInMonth(year, month)) {
|
||||
return { state: 'invalid' };
|
||||
}
|
||||
|
||||
// Valid calendar date; the trimmed value is already the canonical
|
||||
// zero-padded YYYY-MM-DD form.
|
||||
return { state: 'set', value: trimmed };
|
||||
}
|
||||
Reference in New Issue
Block a user