aero-agent-roles 1.2.2 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/manifest.json +17 -3
- package/package.json +1 -1
- package/roles/software-product-assurance-engineer/ROLE.md +243 -0
- package/roles/software-product-assurance-engineer/SOURCES.md +40 -0
- package/roles/software-product-assurance-engineer/cli.py +542 -0
- package/roles/software-product-assurance-engineer/core/software_product_assurance_core.py +2108 -0
- package/roles/software-product-assurance-engineer/example/docs/EX-AUDIT-2026-01.md +7 -0
- package/roles/software-product-assurance-engineer/example/docs/EX-CSTD-001.md +7 -0
- package/roles/software-product-assurance-engineer/example/docs/EX-DSA-001.md +7 -0
- package/roles/software-product-assurance-engineer/example/docs/EX-DSTD-001.md +7 -0
- package/roles/software-product-assurance-engineer/example/docs/EX-RB-001.md +7 -0
- package/roles/software-product-assurance-engineer/example/docs/EX-SCMP-001.md +7 -0
- package/roles/software-product-assurance-engineer/example/docs/EX-SDP-001.md +7 -0
- package/roles/software-product-assurance-engineer/example/docs/EX-SPAP-001.md +7 -0
- package/roles/software-product-assurance-engineer/example/docs/EX-SPAR-2026-Q3.md +7 -0
- package/roles/software-product-assurance-engineer/example/docs/EX-SPR-LOG.md +7 -0
- package/roles/software-product-assurance-engineer/example/docs/EX-SRF-001.md +7 -0
- package/roles/software-product-assurance-engineer/example/docs/EX-SVALP-001.md +7 -0
- package/roles/software-product-assurance-engineer/example/docs/EX-SVERP-001.md +7 -0
- package/roles/software-product-assurance-engineer/example/docs/EX-TS-001.md +7 -0
- package/roles/software-product-assurance-engineer/example/evidence.csv +251 -0
- package/roles/software-product-assurance-engineer/example/metrics.csv +11 -0
- package/roles/software-product-assurance-engineer/example/pdr-pack.csv +17 -0
- package/roles/software-product-assurance-engineer/templates/q80-compliance-matrix-template.md +572 -0
- package/roles/software-product-assurance-engineer/tests/test_role_software_product_assurance_engineer.py +202 -0
- package/roles/software-product-assurance-engineer/tests/test_software_product_assurance_cli.py +271 -0
- package/roles/software-product-assurance-engineer/tests/test_software_product_assurance_core.py +570 -0
package/roles/software-product-assurance-engineer/tests/test_software_product_assurance_core.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Test software_product_assurance_core: the engine of the Software Product
|
|
3
|
+
Assurance Engineer role.
|
|
4
|
+
|
|
5
|
+
Proves the role does its job standalone (no Aero Agent Skills needed):
|
|
6
|
+
category derivation and tailoring, the compliance-matrix rules, the
|
|
7
|
+
document trace, the milestone evidence check and review-pack grading, the
|
|
8
|
+
SPAMR skeleton, the metrics grading, the evidence gates on the model and on
|
|
9
|
+
the rendered Markdown (including a human-appended sign-off), and the worked
|
|
10
|
+
example anchors. The example anchors were cross-checked against the bound
|
|
11
|
+
q80 leaf logic (both implementations agree).
|
|
12
|
+
"""
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import unittest
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
18
|
+
"..", "core"))
|
|
19
|
+
import software_product_assurance_core as core # noqa: E402
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def ev(*rows):
|
|
23
|
+
"""Evidence rows from (clause, document, section, status, justification)."""
|
|
24
|
+
return [dict(zip(("clause", "document", "section", "status",
|
|
25
|
+
"justification"), r)) for r in rows]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def row_of(matrix, clause):
|
|
29
|
+
return next(r for r in matrix["rows"] if r["clause"] == clause)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestNormalisation(unittest.TestCase):
|
|
33
|
+
|
|
34
|
+
def test_category_spellings(self):
|
|
35
|
+
for v in ("B", "b", " cat-B ", "category B"):
|
|
36
|
+
self.assertEqual(core.normalise_category(v), "B")
|
|
37
|
+
with self.assertRaises(ValueError):
|
|
38
|
+
core.normalise_category("E")
|
|
39
|
+
|
|
40
|
+
def test_severity_spellings(self):
|
|
41
|
+
self.assertEqual(core.normalise_severity("catastrophic"), "I")
|
|
42
|
+
self.assertEqual(core.normalise_severity("3"), "III")
|
|
43
|
+
self.assertEqual(core.normalise_severity("iv"), "IV")
|
|
44
|
+
with self.assertRaises(ValueError):
|
|
45
|
+
core.normalise_severity("V")
|
|
46
|
+
|
|
47
|
+
def test_clause_id_forms(self):
|
|
48
|
+
for v in ("6.2.3.4", "6.2.3.4a", "6.2.3.4.a", "cl. 6.2.3.4",
|
|
49
|
+
"clause 6.2.3.4", " 06.02.3.4 "):
|
|
50
|
+
self.assertEqual(core.normalise_clause_id(v), "6.2.3.4")
|
|
51
|
+
with self.assertRaises(ValueError):
|
|
52
|
+
core.normalise_clause_id("section six")
|
|
53
|
+
|
|
54
|
+
def test_status_vocabulary_is_closed(self):
|
|
55
|
+
self.assertEqual(core.normalise_status("PC"), "partially-compliant")
|
|
56
|
+
self.assertEqual(core.normalise_status("n/a"), "not-applicable")
|
|
57
|
+
self.assertEqual(core.normalise_status("Non-Compliant"),
|
|
58
|
+
"not-compliant")
|
|
59
|
+
with self.assertRaises(ValueError):
|
|
60
|
+
core.normalise_status("mostly")
|
|
61
|
+
|
|
62
|
+
def test_review_and_scope(self):
|
|
63
|
+
self.assertEqual(core.normalise_review("PDR"), "pdr")
|
|
64
|
+
self.assertEqual(core.normalise_review("critical design review"),
|
|
65
|
+
"cdr")
|
|
66
|
+
with self.assertRaises(ValueError):
|
|
67
|
+
core.normalise_review("MDR")
|
|
68
|
+
self.assertEqual(core.normalise_scope(["Reuse", "suppliers"]),
|
|
69
|
+
("reuse", "suppliers"))
|
|
70
|
+
with self.assertRaises(ValueError):
|
|
71
|
+
core.normalise_scope(["cots"])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TestCategoryAndTailoring(unittest.TestCase):
|
|
75
|
+
|
|
76
|
+
def test_severity_maps_to_base_category(self):
|
|
77
|
+
for sev, cat in zip(core.SEVERITIES, core.CATEGORIES):
|
|
78
|
+
self.assertEqual(core.derive_category(sev)["category"], cat)
|
|
79
|
+
|
|
80
|
+
def test_provision_lowers_one_step(self):
|
|
81
|
+
d = core.derive_category("I", ["hardware"])
|
|
82
|
+
self.assertEqual((d["base_category"], d["category"]), ("A", "B"))
|
|
83
|
+
self.assertTrue(d["lowered_by_provision"])
|
|
84
|
+
|
|
85
|
+
def test_software_provision_carries_a_constraint(self):
|
|
86
|
+
d = core.derive_category("II", ["software"])
|
|
87
|
+
self.assertEqual(d["category"], "C")
|
|
88
|
+
self.assertTrue(any("category B" in c for c in d["constraints"]))
|
|
89
|
+
|
|
90
|
+
def test_no_credit_below_d(self):
|
|
91
|
+
d = core.derive_category("IV", ["hardware"])
|
|
92
|
+
self.assertEqual(d["category"], "D")
|
|
93
|
+
self.assertFalse(d["lowered_by_provision"])
|
|
94
|
+
|
|
95
|
+
def test_provision_software_takes_no_credit(self):
|
|
96
|
+
d = core.derive_category("III", ["hardware"],
|
|
97
|
+
provides_provision_for="II")
|
|
98
|
+
self.assertEqual(d["category"], "B")
|
|
99
|
+
self.assertFalse(d["lowered_by_provision"])
|
|
100
|
+
|
|
101
|
+
def test_tailored_status_codes(self):
|
|
102
|
+
self.assertEqual(core.tailored_status("5.7.1", "D"), "not-applicable")
|
|
103
|
+
self.assertEqual(core.tailored_status("5.7.1", "C"), "applicable")
|
|
104
|
+
self.assertEqual(core.tailored_status("5.1.5.1", "D"), "reduced")
|
|
105
|
+
self.assertEqual(core.tailored_status("6.2.3.7", "C"),
|
|
106
|
+
"not-applicable")
|
|
107
|
+
self.assertEqual(core.tailored_status("6.2.3.7", "B"), "applicable")
|
|
108
|
+
self.assertEqual(core.tailored_status("6.3.8.2", "C"), "reduced")
|
|
109
|
+
|
|
110
|
+
def test_security_clauses_follow_sensitivity(self):
|
|
111
|
+
for cat in core.CATEGORIES:
|
|
112
|
+
self.assertEqual(core.tailored_status("6.2.9.1", cat),
|
|
113
|
+
"not-applicable")
|
|
114
|
+
self.assertEqual(core.tailored_status("6.2.9.1", cat, True),
|
|
115
|
+
"applicable")
|
|
116
|
+
|
|
117
|
+
def test_unknown_clause(self):
|
|
118
|
+
self.assertEqual(core.tailored_status("6.3.7.4", "A"), "unknown")
|
|
119
|
+
|
|
120
|
+
def test_requirement_list_is_unique(self):
|
|
121
|
+
self.assertEqual(len(core.REQUIREMENT_IDS),
|
|
122
|
+
len(set(core.REQUIREMENT_IDS)))
|
|
123
|
+
self.assertTrue(all(c in core._REQUIREMENT_SET
|
|
124
|
+
for c in core.APPLICABILITY))
|
|
125
|
+
|
|
126
|
+
def test_tailoring_summary_totals(self):
|
|
127
|
+
n = len(core.REQUIREMENT_IDS)
|
|
128
|
+
for cat in core.CATEGORIES:
|
|
129
|
+
t = core.tailoring_summary(cat)["totals"]
|
|
130
|
+
self.assertEqual(sum(t.values()), n)
|
|
131
|
+
b = core.tailoring_summary("B")["totals"]
|
|
132
|
+
self.assertEqual(b, {"applicable": 253, "reduced": 0,
|
|
133
|
+
"not-applicable": 11})
|
|
134
|
+
self.assertEqual(core.tailoring_summary("B", True)["totals"][
|
|
135
|
+
"not-applicable"], 0)
|
|
136
|
+
d = core.tailoring_summary("D")
|
|
137
|
+
self.assertTrue(d["reduced"])
|
|
138
|
+
self.assertTrue(all(r["note"] for r in d["reduced"]))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TestMatrixRules(unittest.TestCase):
|
|
142
|
+
|
|
143
|
+
def m(self, rows, clauses=None, cat="B", sec=False):
|
|
144
|
+
clauses = clauses or list(dict.fromkeys(r["clause"] for r in rows))
|
|
145
|
+
return core.build_matrix(clauses, rows, cat, sec)
|
|
146
|
+
|
|
147
|
+
def test_silence_is_not_compliance(self):
|
|
148
|
+
mx = core.build_matrix(["5.2.3"], [], "B")
|
|
149
|
+
r = row_of(mx, "5.2.3")
|
|
150
|
+
self.assertEqual(r["status"], "not-compliant")
|
|
151
|
+
self.assertEqual(r["gaps"], ["no-evidence"])
|
|
152
|
+
|
|
153
|
+
def test_tailored_out_prefills_not_applicable(self):
|
|
154
|
+
mx = core.build_matrix(["5.7.1"], [], "D")
|
|
155
|
+
r = row_of(mx, "5.7.1")
|
|
156
|
+
self.assertEqual(r["status"], "not-applicable")
|
|
157
|
+
self.assertEqual(r["gaps"], [])
|
|
158
|
+
self.assertIn("category D", r["justification"])
|
|
159
|
+
|
|
160
|
+
def test_claim_without_reference_is_downgraded(self):
|
|
161
|
+
mx = self.m(ev(("5.2.4", "", "", "compliant", "we do it")))
|
|
162
|
+
r = row_of(mx, "5.2.4")
|
|
163
|
+
self.assertEqual(r["status"], "partially-compliant")
|
|
164
|
+
self.assertIn("no-evidence-reference", r["gaps"])
|
|
165
|
+
|
|
166
|
+
def test_reference_without_section(self):
|
|
167
|
+
mx = self.m(ev(("6.1.3", "SDP", "", "compliant", "")))
|
|
168
|
+
self.assertEqual(row_of(mx, "6.1.3")["gaps"],
|
|
169
|
+
["reference-without-section"])
|
|
170
|
+
|
|
171
|
+
def test_weakest_status_wins(self):
|
|
172
|
+
mx = self.m(ev(("6.2.4.2", "SCMP", "2", "compliant", ""),
|
|
173
|
+
("6.2.4.2", "AUDIT", "F3", "not-compliant",
|
|
174
|
+
"finding")))
|
|
175
|
+
r = row_of(mx, "6.2.4.2")
|
|
176
|
+
self.assertEqual(r["status"], "not-compliant")
|
|
177
|
+
self.assertEqual(r["documents"], ["SCMP", "AUDIT"])
|
|
178
|
+
|
|
179
|
+
def test_not_applicable_against_tailoring_is_a_gap(self):
|
|
180
|
+
mx = self.m(ev(("5.4.4", "SPAP", "7", "na", "no subcontractor")))
|
|
181
|
+
r = row_of(mx, "5.4.4")
|
|
182
|
+
self.assertEqual(r["status"], "not-applicable")
|
|
183
|
+
self.assertEqual(r["gaps"], ["na-conflicts-with-tailoring"])
|
|
184
|
+
|
|
185
|
+
def test_not_applicable_needs_justification(self):
|
|
186
|
+
mx = self.m(ev(("6.2.9.1", "", "", "na", "")))
|
|
187
|
+
self.assertEqual(row_of(mx, "6.2.9.1")["gaps"],
|
|
188
|
+
["na-without-justification"])
|
|
189
|
+
|
|
190
|
+
def test_evidence_on_tailored_out_clause(self):
|
|
191
|
+
mx = self.m(ev(("5.7.1", "SPAP", "9", "compliant", "")), cat="D")
|
|
192
|
+
self.assertIn("evidence-for-tailored-out-clause",
|
|
193
|
+
row_of(mx, "5.7.1")["gaps"])
|
|
194
|
+
|
|
195
|
+
def test_conflicting_statuses(self):
|
|
196
|
+
mx = self.m(ev(("5.3.1", "SPAP", "6", "compliant", ""),
|
|
197
|
+
("5.3.1", "SPAP", "6", "na", "x")))
|
|
198
|
+
self.assertIn("conflicting-status", row_of(mx, "5.3.1")["gaps"])
|
|
199
|
+
|
|
200
|
+
def test_deviation_without_justification(self):
|
|
201
|
+
mx = self.m(ev(("5.3.1", "SPAP", "6", "pc", "")))
|
|
202
|
+
self.assertIn("deviation-without-justification",
|
|
203
|
+
row_of(mx, "5.3.1")["gaps"])
|
|
204
|
+
|
|
205
|
+
def test_orphan_evidence_is_reported_not_dropped(self):
|
|
206
|
+
mx = core.build_matrix(["5.3.1"], ev(
|
|
207
|
+
("5.3.1", "SPAP", "6", "c", ""),
|
|
208
|
+
("6.3.7.4", "SDP", "8", "pc", "old list")), "B")
|
|
209
|
+
self.assertEqual(mx["orphan_evidence"], ["6.3.7.4"])
|
|
210
|
+
self.assertIn(("6.3.7.4", "evidence-for-unlisted-clause"),
|
|
211
|
+
[(g["clause"], g["kind"]) for g in mx["gaps"]])
|
|
212
|
+
|
|
213
|
+
def test_duplicate_clause_refused(self):
|
|
214
|
+
with self.assertRaises(ValueError):
|
|
215
|
+
core.build_matrix(["5.3.1", "5.3.1a"], [], "B")
|
|
216
|
+
|
|
217
|
+
def test_loose_csv_headers(self):
|
|
218
|
+
rows = core.parse_evidence_csv(
|
|
219
|
+
"Requirement,Doc,Paragraph,Compliance,Comment\n"
|
|
220
|
+
"5.3.1,SPAP,6,C,\n\n")
|
|
221
|
+
self.assertEqual(rows, [{"clause": "5.3.1", "document": "SPAP",
|
|
222
|
+
"section": "6", "status": "C",
|
|
223
|
+
"justification": ""}])
|
|
224
|
+
|
|
225
|
+
def test_csv_needs_clause_and_status(self):
|
|
226
|
+
with self.assertRaises(ValueError):
|
|
227
|
+
core.parse_evidence_csv("clause,document\n5.3.1,SPAP\n")
|
|
228
|
+
|
|
229
|
+
def test_bad_status_names_the_row(self):
|
|
230
|
+
with self.assertRaises(ValueError) as cm:
|
|
231
|
+
core.index_evidence(ev(("5.3.1", "SPAP", "6", "mostly", "")))
|
|
232
|
+
self.assertIn("row 1", str(cm.exception))
|
|
233
|
+
|
|
234
|
+
def test_coverage_fractions(self):
|
|
235
|
+
mx = core.build_matrix(
|
|
236
|
+
["5.3.1", "5.2.3", "5.2.4", "6.2.9.1"],
|
|
237
|
+
ev(("5.3.1", "SPAP", "6", "c", ""),
|
|
238
|
+
("5.2.3", "SPAP", "4", "pc", "audits from CDR")), "B")
|
|
239
|
+
cov = mx["coverage"]
|
|
240
|
+
self.assertEqual(cov["applicable"], 3)
|
|
241
|
+
self.assertEqual(cov["counts"]["not-applicable"], 1)
|
|
242
|
+
self.assertEqual(cov["compliant_fraction"], round(1 / 3, 4))
|
|
243
|
+
self.assertEqual(cov["evidenced_fraction"], round(2 / 3, 4))
|
|
244
|
+
self.assertIsNone(cov["traced_fraction"])
|
|
245
|
+
|
|
246
|
+
def test_every_gap_has_a_next_skill(self):
|
|
247
|
+
mx = core.build_matrix(["5.2.3", "6.1.1", "7.1.1", "7.4.1"], [], "B")
|
|
248
|
+
routes = {g["clause"]: g["next_skill"].rsplit("/", 1)[-1]
|
|
249
|
+
for g in mx["gaps"]}
|
|
250
|
+
self.assertEqual(routes["5.2.3"], "q80-software-product-assurance-plan")
|
|
251
|
+
self.assertEqual(routes["6.1.1"], "q80-software-process-assurance")
|
|
252
|
+
self.assertEqual(routes["7.1.1"],
|
|
253
|
+
"q80-software-product-quality-metrics")
|
|
254
|
+
self.assertEqual(routes["7.4.1"], "q80-software-product-assurance-plan")
|
|
255
|
+
|
|
256
|
+
def test_customer_clause_list(self):
|
|
257
|
+
cl = core.parse_clause_list("id,title\n5.3.1,risk\n6.1.1\n# note\n")
|
|
258
|
+
self.assertEqual(cl, [("5.3.1", "risk"), ("6.1.1", core.topic_of(
|
|
259
|
+
"6.1.1"))])
|
|
260
|
+
with self.assertRaises(ValueError):
|
|
261
|
+
core.parse_clause_list("5.3.1\n5.3.1\n")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class TestDocumentTrace(unittest.TestCase):
|
|
265
|
+
|
|
266
|
+
FILES = ["plans/SPAP-001.md", "SDP-001.pdf", ".hidden", "notes.txt"]
|
|
267
|
+
|
|
268
|
+
def test_resolution_ignores_case_extension_and_folder(self):
|
|
269
|
+
self.assertEqual(core.resolve_document("spap-001", self.FILES),
|
|
270
|
+
"plans/SPAP-001.md")
|
|
271
|
+
self.assertEqual(core.resolve_document("SDP-001.pdf", self.FILES),
|
|
272
|
+
"SDP-001.pdf")
|
|
273
|
+
self.assertIsNone(core.resolve_document("SVR-001", self.FILES))
|
|
274
|
+
|
|
275
|
+
def test_missing_document_is_a_gap_and_downgrades(self):
|
|
276
|
+
mx = core.build_matrix(["6.2.6.5", "6.2.6.6"], ev(
|
|
277
|
+
("6.2.6.5", "SVR-001", "3", "c", ""),
|
|
278
|
+
("6.2.6.6", "SVR-001", "4", "c", ""),
|
|
279
|
+
("6.2.6.6", "SDP-001", "5", "c", "")), "B")
|
|
280
|
+
core.apply_document_trace(mx, self.FILES, "docs/")
|
|
281
|
+
a, b = row_of(mx, "6.2.6.5"), row_of(mx, "6.2.6.6")
|
|
282
|
+
self.assertEqual(a["status"], "partially-compliant")
|
|
283
|
+
self.assertIn("document-not-found", a["gaps"])
|
|
284
|
+
self.assertTrue(a["notes"])
|
|
285
|
+
# one of two citations resolves: the gap stays, no downgrade
|
|
286
|
+
self.assertEqual(b["status"], "compliant")
|
|
287
|
+
self.assertIn("document-not-found", b["gaps"])
|
|
288
|
+
self.assertEqual(mx["trace"]["missing"],
|
|
289
|
+
{"SVR-001": ["6.2.6.5", "6.2.6.6"]})
|
|
290
|
+
self.assertEqual(mx["coverage"]["traced_fraction"], 0.5)
|
|
291
|
+
self.assertIn("plans/SPAP-001.md", mx["trace"]["uncited_files"])
|
|
292
|
+
|
|
293
|
+
def test_list_documents(self):
|
|
294
|
+
here = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..",
|
|
295
|
+
"example", "docs")
|
|
296
|
+
files = core.list_documents(here)
|
|
297
|
+
self.assertIn("EX-SPAP-001.md", files)
|
|
298
|
+
self.assertFalse(any(f.startswith(".") for f in files))
|
|
299
|
+
with self.assertRaises(ValueError):
|
|
300
|
+
core.list_documents(os.path.join(here, "no-such-folder"))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class TestMilestone(unittest.TestCase):
|
|
304
|
+
|
|
305
|
+
def owed(self, review, cat, scope=()):
|
|
306
|
+
return [d["document"] for d in core.evidence_due(review, cat, scope)]
|
|
307
|
+
|
|
308
|
+
def test_scope_turns_documents_on(self):
|
|
309
|
+
self.assertIn("software-reuse-file", self.owed("pdr", "B", ["reuse"]))
|
|
310
|
+
self.assertNotIn("software-reuse-file", self.owed("pdr", "B"))
|
|
311
|
+
|
|
312
|
+
def test_independent_verification_plan_by_category(self):
|
|
313
|
+
self.assertIn("isvv-plan", self.owed("pdr", "B"))
|
|
314
|
+
self.assertNotIn("isvv-plan", self.owed("pdr", "C"))
|
|
315
|
+
|
|
316
|
+
def test_spap_owed_at_srr(self):
|
|
317
|
+
self.assertIn("spap", self.owed("srr", "D"))
|
|
318
|
+
|
|
319
|
+
def test_pack_grading(self):
|
|
320
|
+
res = core.assess_review_pack("pdr", "C", [
|
|
321
|
+
{"document": "spap", "maturity": "issued"},
|
|
322
|
+
{"document": "sdp", "maturity": "draft"},
|
|
323
|
+
{"document": "acceptance-documentation", "maturity": "draft"}])
|
|
324
|
+
self.assertIn("sdp", res["immature"])
|
|
325
|
+
self.assertIn("coding-standards", res["missing"])
|
|
326
|
+
self.assertEqual(res["unplanned"], ["acceptance-documentation"])
|
|
327
|
+
self.assertFalse(res["complete"])
|
|
328
|
+
|
|
329
|
+
def test_pack_refuses_unknown_kind_and_duplicates(self):
|
|
330
|
+
with self.assertRaises(ValueError):
|
|
331
|
+
core.assess_review_pack("pdr", "B", [{"document": "memo",
|
|
332
|
+
"maturity": "issued"}])
|
|
333
|
+
with self.assertRaises(ValueError):
|
|
334
|
+
core.assess_review_pack("pdr", "B", [
|
|
335
|
+
{"document": "spap", "maturity": "issued"},
|
|
336
|
+
{"document": "spap", "maturity": "draft"}])
|
|
337
|
+
|
|
338
|
+
def test_spamr_skeleton(self):
|
|
339
|
+
sk = core.spamr_skeleton("cdr", {"metrics": ["M"], "testing": []})
|
|
340
|
+
st = {s["id"]: s["status"] for s in sk["sections"]}
|
|
341
|
+
self.assertEqual(st["1"], "boilerplate")
|
|
342
|
+
self.assertEqual(st["7"], "filled")
|
|
343
|
+
self.assertEqual(st["8"], "missing-input")
|
|
344
|
+
self.assertIn("testing", sk["missing_inputs"])
|
|
345
|
+
self.assertTrue(sk["status"].startswith("DRAFT"))
|
|
346
|
+
|
|
347
|
+
def test_plan_maturity(self):
|
|
348
|
+
self.assertEqual(core.PLAN_MATURITY_BY_REVIEW["srr"], "issued")
|
|
349
|
+
self.assertEqual(core.PLAN_MATURITY_BY_REVIEW["qr"], "maintained")
|
|
350
|
+
|
|
351
|
+
def test_pack_csv(self):
|
|
352
|
+
rows = core.parse_pack_csv("document,maturity\nspap,issued\n,\n")
|
|
353
|
+
self.assertEqual(rows, [{"document": "spap", "maturity": "issued",
|
|
354
|
+
"file": ""}])
|
|
355
|
+
with self.assertRaises(ValueError):
|
|
356
|
+
core.parse_pack_csv("kind,state\nspap,issued\n")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class TestMetrics(unittest.TestCase):
|
|
360
|
+
|
|
361
|
+
def test_thresholds_and_overrides(self):
|
|
362
|
+
t = core.thresholds_for("B", {"mcdc_coverage": 0.9,
|
|
363
|
+
"cyclomatic_complexity": None})
|
|
364
|
+
self.assertEqual(t["mcdc_coverage"], (0.9, "project"))
|
|
365
|
+
self.assertNotIn("cyclomatic_complexity", t)
|
|
366
|
+
self.assertEqual(t["function_size_loc"], (80, "default"))
|
|
367
|
+
with self.assertRaises(ValueError):
|
|
368
|
+
core.thresholds_for("B", {"lines_of_joy": 1})
|
|
369
|
+
|
|
370
|
+
def test_grading(self):
|
|
371
|
+
res = core.evaluate_metrics({"cyclomatic_complexity": 12,
|
|
372
|
+
"comment_density": 0.3,
|
|
373
|
+
"made_up": 1}, "B")
|
|
374
|
+
rows = {r["metric"]: r for r in res["rows"]}
|
|
375
|
+
self.assertEqual(rows["cyclomatic_complexity"]["status"], "fail")
|
|
376
|
+
self.assertEqual(rows["cyclomatic_complexity"]["margin"], -2)
|
|
377
|
+
self.assertEqual(rows["comment_density"]["status"], "pass")
|
|
378
|
+
self.assertEqual(rows["decision_coverage"]["status"], "missing")
|
|
379
|
+
self.assertEqual(res["unknown"], ["made_up"])
|
|
380
|
+
self.assertEqual(res["verdict"], "fail")
|
|
381
|
+
|
|
382
|
+
def test_metrics_csv(self):
|
|
383
|
+
m = core.parse_metrics_csv("metric,value\nnesting_depth,3\n"
|
|
384
|
+
"statement_coverage,\n")
|
|
385
|
+
self.assertEqual(m, {"nesting_depth": 3.0,
|
|
386
|
+
"statement_coverage": None})
|
|
387
|
+
with self.assertRaises(ValueError):
|
|
388
|
+
core.parse_metrics_csv("metric,value\nnesting_depth,deep\n")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class TestWorkedExample(unittest.TestCase):
|
|
392
|
+
"""Anchors of the bundled example (agree with the bound q80 leaves)."""
|
|
393
|
+
|
|
394
|
+
@classmethod
|
|
395
|
+
def setUpClass(cls):
|
|
396
|
+
cls.model = core.build_report(core.example_item())
|
|
397
|
+
cls.mx = cls.model["matrix"]
|
|
398
|
+
|
|
399
|
+
def test_category_derived(self):
|
|
400
|
+
self.assertEqual(self.model["category"], "B")
|
|
401
|
+
self.assertEqual(self.model["category_derivation"]["base_category"],
|
|
402
|
+
"A")
|
|
403
|
+
|
|
404
|
+
def test_coverage_anchor(self):
|
|
405
|
+
cov = self.mx["coverage"]
|
|
406
|
+
self.assertEqual((cov["clauses"], cov["applicable"]), (264, 226))
|
|
407
|
+
self.assertEqual(cov["counts"], {"compliant": 129,
|
|
408
|
+
"partially-compliant": 91,
|
|
409
|
+
"not-compliant": 6,
|
|
410
|
+
"not-applicable": 38})
|
|
411
|
+
self.assertEqual(cov["traced_fraction"], 0.9646)
|
|
412
|
+
self.assertEqual(len(self.mx["gaps"]), 129)
|
|
413
|
+
|
|
414
|
+
def test_trace_finds_the_absent_report(self):
|
|
415
|
+
self.assertEqual(self.mx["trace"]["missing"],
|
|
416
|
+
{"EX-SVR-001": ["6.2.6.5", "6.2.6.6"]})
|
|
417
|
+
self.assertEqual(row_of(self.mx, "6.2.6.5")["status"],
|
|
418
|
+
"partially-compliant")
|
|
419
|
+
|
|
420
|
+
def test_audit_finding_outweighs_plan(self):
|
|
421
|
+
self.assertEqual(row_of(self.mx, "6.2.4.2")["status"],
|
|
422
|
+
"not-compliant")
|
|
423
|
+
|
|
424
|
+
def test_orphan_from_older_list(self):
|
|
425
|
+
self.assertEqual(self.mx["orphan_evidence"], ["6.3.7.4"])
|
|
426
|
+
|
|
427
|
+
def test_milestone_pack(self):
|
|
428
|
+
pk = self.model["milestone"]["pack"]
|
|
429
|
+
self.assertEqual(pk["missing"], ["isvv-plan", "isvv-report",
|
|
430
|
+
"software-verification-report"])
|
|
431
|
+
self.assertEqual(pk["immature"], ["software-verification-plan",
|
|
432
|
+
"spamr"])
|
|
433
|
+
self.assertEqual(self.model["milestone"]["spap_maturity_due"],
|
|
434
|
+
"updated")
|
|
435
|
+
|
|
436
|
+
def test_metrics_verdict(self):
|
|
437
|
+
self.assertEqual(self.model["metrics"]["failing"], [
|
|
438
|
+
"coding_standard_violations", "cyclomatic_complexity",
|
|
439
|
+
"requirement_test_coverage"])
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class TestGates(unittest.TestCase):
|
|
443
|
+
|
|
444
|
+
def setUp(self):
|
|
445
|
+
self.model = core.build_report(core.example_item())
|
|
446
|
+
self.md = core.render_report_markdown(self.model)
|
|
447
|
+
|
|
448
|
+
def test_model_gates_pass(self):
|
|
449
|
+
g = core.check_report(self.model)
|
|
450
|
+
self.assertTrue(g["all_pass"], g)
|
|
451
|
+
|
|
452
|
+
def test_model_gate_catches_self_approval(self):
|
|
453
|
+
self.model["status"] = "APPROVED"
|
|
454
|
+
self.assertFalse(core.check_report(self.model)["draft_status"])
|
|
455
|
+
|
|
456
|
+
def test_model_gate_catches_edited_coverage(self):
|
|
457
|
+
self.model["matrix"]["coverage"]["counts"]["compliant"] += 1
|
|
458
|
+
self.assertFalse(core.check_report(self.model)["coverage_consistent"])
|
|
459
|
+
|
|
460
|
+
def test_model_gate_catches_silent_compliance(self):
|
|
461
|
+
r = row_of(self.model["matrix"], "7.4.1")
|
|
462
|
+
r["status"], r["gaps"] = "compliant", []
|
|
463
|
+
g = core.check_report(self.model)
|
|
464
|
+
self.assertFalse(g["no_silent_compliance"])
|
|
465
|
+
|
|
466
|
+
def test_markdown_gates_pass(self):
|
|
467
|
+
g = core.check_report_markdown(self.md)
|
|
468
|
+
self.assertTrue(g["all_pass"], g)
|
|
469
|
+
self.assertEqual(g["sign_off"]["state"], "unsigned")
|
|
470
|
+
|
|
471
|
+
def test_markdown_ends_at_stop_line(self):
|
|
472
|
+
self.assertEqual(self.md.rstrip().splitlines()[-1], core.STOP_LINE)
|
|
473
|
+
self.assertNotIn("Signed-off-by", self.md)
|
|
474
|
+
|
|
475
|
+
def test_markdown_gate_catches_removed_stop_line(self):
|
|
476
|
+
g = core.check_report_markdown(self.md.replace(core.STOP_LINE, ""))
|
|
477
|
+
self.assertFalse(g["sign_off_honest"])
|
|
478
|
+
|
|
479
|
+
def test_markdown_gate_catches_edited_status(self):
|
|
480
|
+
md = self.md.replace("| 7.4.1 | buying ground hardware | applicable "
|
|
481
|
+
"| not-compliant |",
|
|
482
|
+
"| 7.4.1 | buying ground hardware | applicable "
|
|
483
|
+
"| compliant |")
|
|
484
|
+
self.assertNotEqual(md, self.md)
|
|
485
|
+
self.assertFalse(core.check_report_markdown(md)[
|
|
486
|
+
"coverage_matches_rows"])
|
|
487
|
+
|
|
488
|
+
def test_markdown_gate_catches_gap_count(self):
|
|
489
|
+
md = self.md.replace("Open gaps: 129.", "Open gaps: 12.")
|
|
490
|
+
self.assertFalse(core.check_report_markdown(md)["open_gaps_match"])
|
|
491
|
+
|
|
492
|
+
def sign(self, **kw):
|
|
493
|
+
block = {"Signed-off-by": "A. Person", "Sign-off-role":
|
|
494
|
+
"software PA manager", "Sign-off-date": "2026-10-01",
|
|
495
|
+
"Sign-off-decision": "approved", "Accepted-open-gaps": "129"}
|
|
496
|
+
block.update(kw)
|
|
497
|
+
return self.md + "\n" + "\n".join(
|
|
498
|
+
"%s: %s" % (k, v) for k, v in block.items() if v is not None) \
|
|
499
|
+
+ "\n"
|
|
500
|
+
|
|
501
|
+
def test_human_sign_off_accepted(self):
|
|
502
|
+
g = core.check_report_markdown(self.sign())
|
|
503
|
+
self.assertTrue(g["all_pass"], g)
|
|
504
|
+
self.assertEqual(g["sign_off"]["signatory"], "A. Person")
|
|
505
|
+
|
|
506
|
+
def test_approval_over_gaps_must_accept_them(self):
|
|
507
|
+
g = core.check_report_markdown(self.sign(**{
|
|
508
|
+
"Accepted-open-gaps": None}))
|
|
509
|
+
self.assertFalse(g["sign_off_honest"])
|
|
510
|
+
|
|
511
|
+
def test_sign_off_needs_name_and_iso_date(self):
|
|
512
|
+
self.assertFalse(core.check_report_markdown(self.sign(**{
|
|
513
|
+
"Signed-off-by": ""}))["sign_off_honest"])
|
|
514
|
+
self.assertFalse(core.check_report_markdown(self.sign(**{
|
|
515
|
+
"Sign-off-date": "1 Oct 2026"}))["sign_off_honest"])
|
|
516
|
+
|
|
517
|
+
def test_rejection_needs_no_gap_acceptance(self):
|
|
518
|
+
g = core.check_report_markdown(self.sign(**{
|
|
519
|
+
"Sign-off-decision": "rejected", "Accepted-open-gaps": None}))
|
|
520
|
+
self.assertTrue(g["all_pass"], g)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
class TestRenderingAndStandalone(unittest.TestCase):
|
|
524
|
+
|
|
525
|
+
def test_csv_marks_every_row_draft(self):
|
|
526
|
+
model = core.build_report(core.example_item())
|
|
527
|
+
lines = core.render_matrix_csv(model).splitlines()
|
|
528
|
+
self.assertTrue(lines[0].startswith("clause,topic,tailoring,status"))
|
|
529
|
+
self.assertEqual(len(lines), 1 + 264 + 1)
|
|
530
|
+
self.assertTrue(all(l.endswith(",DRAFT") for l in lines[1:]))
|
|
531
|
+
self.assertIn(core.STOP_LINE, lines[-1])
|
|
532
|
+
|
|
533
|
+
def test_markdown_structure(self):
|
|
534
|
+
md = core.example_report_markdown()
|
|
535
|
+
for n in range(1, 12):
|
|
536
|
+
self.assertRegex(md, r"(?m)^## %d\. " % n)
|
|
537
|
+
low = md.lower()
|
|
538
|
+
for marker in ("draft", "not an approval", "not a certification",
|
|
539
|
+
"not a statement of compliance"):
|
|
540
|
+
self.assertIn(marker, low)
|
|
541
|
+
|
|
542
|
+
def test_trace_not_run_is_said(self):
|
|
543
|
+
item = core.ProjectInputs(project="p", category="C",
|
|
544
|
+
evidence_text="clause,status\n5.3.1,c\n")
|
|
545
|
+
md = core.render_report_markdown(core.build_report(item))
|
|
546
|
+
self.assertIn("traced fraction not run", md)
|
|
547
|
+
self.assertIn("Not run: no document folder was given", md)
|
|
548
|
+
|
|
549
|
+
def test_category_conflict_is_an_open_point(self):
|
|
550
|
+
item = core.ProjectInputs(category="C", severity="I",
|
|
551
|
+
evidence_text="clause,status\n")
|
|
552
|
+
model = core.build_report(item)
|
|
553
|
+
self.assertEqual(model["category"], "C")
|
|
554
|
+
self.assertTrue(model["category_notes"])
|
|
555
|
+
|
|
556
|
+
def test_category_is_required(self):
|
|
557
|
+
with self.assertRaises(ValueError):
|
|
558
|
+
core.build_report(core.ProjectInputs(
|
|
559
|
+
evidence_text="clause,status\n"))
|
|
560
|
+
|
|
561
|
+
def test_standalone_no_skills_repo(self):
|
|
562
|
+
os.environ["AEROSKILLS_DEV"] = "/nonexistent"
|
|
563
|
+
model = core.build_report(core.example_item())
|
|
564
|
+
self.assertEqual(model["status"], "DRAFT")
|
|
565
|
+
self.assertTrue(model["requires_human_sign_off"])
|
|
566
|
+
self.assertGreater(len(core.render_report_markdown(model)), 20000)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
if __name__ == "__main__":
|
|
570
|
+
unittest.main()
|