192 lines
7.3 KiB
TypeScript
192 lines
7.3 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { computeCoverage, detectGaps, qualityScore, toReport, formatTrace } from '../src/index.js'
|
|
import { overall, validateReport } from '@extant2000/evidence-record'
|
|
import type { TraceInput } from '../src/index.js'
|
|
|
|
const req = (id: string, parent?: string | null) => ({
|
|
id, parent_id: parent ?? null, req_number: `R-${id}`, title: `Requirement ${id}`,
|
|
})
|
|
|
|
describe('computeCoverage', () => {
|
|
it('returns an all-zero shape for no requirements', () => {
|
|
const c = computeCoverage({ requirements: [] })
|
|
expect(c.total_requirements).toBe(0)
|
|
expect(c.pct_verified).toBe(0)
|
|
})
|
|
|
|
it('counts a requirement traced by junction OR by verification FK, without double counting', () => {
|
|
const input: TraceInput = {
|
|
requirements: [req('a'), req('b')],
|
|
requirementVerifications: [{ requirement_id: 'a' }],
|
|
verificationItems: [{ id: 'v1', requirement_id: 'a', status: 'Passed' }],
|
|
}
|
|
// 'a' is linked by both routes but must count once.
|
|
expect(computeCoverage(input).traced_to_test).toBe(1)
|
|
})
|
|
|
|
it('treats a requirement with zero verifications as NOT verified', () => {
|
|
// Guards the vacuous-every() trap: [].every(...) is true, which would
|
|
// report a requirement with no evidence at all as fully verified.
|
|
const input: TraceInput = { requirements: [req('a')], verificationItems: [] }
|
|
expect(computeCoverage(input).verified).toBe(0)
|
|
})
|
|
|
|
it('requires EVERY linked verification to pass or be waived', () => {
|
|
const mixed: TraceInput = {
|
|
requirements: [req('a')],
|
|
verificationItems: [
|
|
{ id: 'v1', requirement_id: 'a', status: 'Passed' },
|
|
{ id: 'v2', requirement_id: 'a', status: 'Failed' },
|
|
],
|
|
}
|
|
expect(computeCoverage(mixed).verified).toBe(0)
|
|
|
|
const allGood: TraceInput = {
|
|
requirements: [req('a')],
|
|
verificationItems: [
|
|
{ id: 'v1', requirement_id: 'a', status: 'Passed' },
|
|
{ id: 'v2', requirement_id: 'a', status: 'Waived' },
|
|
],
|
|
}
|
|
expect(computeCoverage(allGood).verified).toBe(1)
|
|
})
|
|
|
|
it('computes percentages against the requirement total', () => {
|
|
const input: TraceInput = {
|
|
requirements: [req('a', 'root'), req('b'), req('c'), req('d')],
|
|
}
|
|
expect(computeCoverage(input).pct_parent).toBe(25)
|
|
})
|
|
})
|
|
|
|
describe('detectGaps', () => {
|
|
it('reports nothing for an empty requirement set', () => {
|
|
expect(detectGaps({ requirements: [] })).toEqual({ gaps: [], total: 0 })
|
|
})
|
|
|
|
it('flags an untested, unrisked, parentless requirement three ways', () => {
|
|
const { gaps } = detectGaps({ requirements: [req('a')] })
|
|
expect(gaps.map(g => g.gap_type).sort()).toEqual(['no_risk', 'no_test', 'orphaned'])
|
|
})
|
|
|
|
it('rates a failed verification critical and everything else lower', () => {
|
|
const { gaps } = detectGaps({
|
|
requirements: [req('a', 'root')],
|
|
requirementVerifications: [{ requirement_id: 'a' }],
|
|
requirementRisks: [{ requirement_id: 'a' }],
|
|
verificationItems: [{ id: 'v1', requirement_id: 'a', status: 'Failed', method: 'Test' }],
|
|
})
|
|
expect(gaps).toHaveLength(1)
|
|
expect(gaps[0]!.gap_type).toBe('failed_verification')
|
|
expect(gaps[0]!.severity).toBe('critical')
|
|
})
|
|
|
|
it('flags suspect links', () => {
|
|
const { gaps } = detectGaps({
|
|
requirements: [req('a', 'root')],
|
|
requirementVerifications: [{ requirement_id: 'a' }],
|
|
requirementRisks: [{ requirement_id: 'a' }],
|
|
suspectLinks: [{ requirement_id: 'a' }],
|
|
})
|
|
expect(gaps.map(g => g.gap_type)).toEqual(['suspect'])
|
|
})
|
|
|
|
it('falls back to a short id when req_number is absent', () => {
|
|
const { gaps } = detectGaps({ requirements: [{ id: 'abcdef1234567890' }] })
|
|
expect(gaps[0]!.display_id).toBe('abcdef12')
|
|
})
|
|
})
|
|
|
|
describe('qualityScore', () => {
|
|
it('applies the documented 0.3/0.3/0.2/0.2 weighting', () => {
|
|
const s = qualityScore({ pct_test: 100, pct_verified: 100, pct_risk: 0, pct_parent: 0 })
|
|
expect(s.score).toBe(60)
|
|
expect(s.grade).toBe('C')
|
|
})
|
|
|
|
it('grades the boundaries inclusively', () => {
|
|
const at = (n: number) => qualityScore({
|
|
pct_test: n, pct_verified: n, pct_risk: n, pct_parent: n,
|
|
}).grade
|
|
expect(at(90)).toBe('A')
|
|
expect(at(75)).toBe('B')
|
|
expect(at(60)).toBe('C')
|
|
expect(at(40)).toBe('D')
|
|
expect(at(39)).toBe('F')
|
|
})
|
|
|
|
it('accepts custom weights for a different accreditation regime', () => {
|
|
const s = qualityScore(
|
|
{ pct_test: 0, pct_verified: 100, pct_risk: 0, pct_parent: 0 },
|
|
{ traced: 0, verified: 1, risk: 0, parent: 0 },
|
|
)
|
|
expect(s.score).toBe(100)
|
|
expect(s.grade).toBe('A')
|
|
})
|
|
|
|
it('round-trips from computeCoverage output', () => {
|
|
const coverage = computeCoverage({
|
|
requirements: [req('a', 'root')],
|
|
requirementVerifications: [{ requirement_id: 'a' }],
|
|
requirementRisks: [{ requirement_id: 'a' }],
|
|
verificationItems: [{ id: 'v1', requirement_id: 'a', status: 'Passed' }],
|
|
})
|
|
expect(qualityScore(coverage).score).toBe(100)
|
|
})
|
|
})
|
|
|
|
describe('report — the score has to lead back to the rows', () => {
|
|
const input = {
|
|
requirements: [
|
|
{ id: 'r1-uuid-aaaa', req_number: 'SYS-001', title: 'Deorbit within 25 years', parent_id: 'p1' },
|
|
{ id: 'r2-uuid-bbbb', req_number: 'SYS-002', title: 'Survive launch loads' },
|
|
],
|
|
verificationItems: [
|
|
{ id: 'v1-uuid-cccc', requirement_id: 'r1-uuid-aaaa', status: 'Failed', method: 'Test' },
|
|
],
|
|
}
|
|
|
|
it('cites the requirement row behind every gap', () => {
|
|
// The gap this closes: "quality 50/100, grade D" with no route back to
|
|
// the requirements that produced it.
|
|
const text = formatTrace(input, { requirementTable: 'mission_requirements', evidence: 'full' })
|
|
expect(text).toContain('mission_requirements#r2-uuid-bbbb')
|
|
expect(text).toContain('SYS-002')
|
|
})
|
|
|
|
it('cites the verification row behind a failed verification', () => {
|
|
const text = formatTrace(input, { verificationTable: 'mission_verification_items', evidence: 'full' })
|
|
expect(text).toContain('mission_verification_items#v1-uuid-cccc')
|
|
})
|
|
|
|
it('breaks the composite score into checkable measurements', () => {
|
|
const r = toReport(input)
|
|
const traced = r.findings.find(f => f.id === 'score.traced')!
|
|
expect(traced.evidence[0]!.source).toMatchObject({ kind: 'measurement', samples: 2 })
|
|
expect(formatTrace(input)).toContain('composite quality')
|
|
})
|
|
|
|
it('treats an empty requirement set as not-assessed, not as a score of zero', () => {
|
|
// computeCoverage correctly returns zeros, but 0% next to grade F reads
|
|
// as "traced nothing" when the truth is "loaded nothing".
|
|
const r = toReport({ requirements: [] })
|
|
expect(overall(r)).toBe('not-assessed')
|
|
const text = formatTrace({ requirements: [] })
|
|
expect(text).toContain('NOT ASSESSED')
|
|
expect(text).toContain('nothing was traced because nothing was read')
|
|
expect(text).not.toContain('grade F')
|
|
})
|
|
|
|
it('separates absent evidence from evidence of failure', () => {
|
|
const r = toReport(input)
|
|
const failed = r.findings.find(f => f.summary.includes('Failed'))!
|
|
expect(failed.determination).toBe('fail')
|
|
const noTest = r.findings.find(f => f.summary.includes('No verification item'))!
|
|
expect(noTest.determination).toBe('not-assessed')
|
|
})
|
|
|
|
it('every conclusion carries a citation', () => {
|
|
expect(validateReport(toReport(input))).toEqual([])
|
|
})
|
|
})
|