import copy import json import os import subprocess import sys import tempfile import unittest from pathlib import Path from harness.tests._support.paths import ROOT from harness.tests._support.spec import MODPATH, mod, publication_fixture, result, verify_fixture class ACClassificationTests(unittest.TestCase): TEMPLATE = ROOT / 'harness/docs/requirements/TEMPLATE.md' def body(self): return ('## 7. 인수 기준\n\n### REQ-001.01-AC01 — example\n\n' '**판정 기준**\n\nexample\n\n**검증**\n\nexample\n\n' '**검증 분류**\n\n- 수준: unit\n- 백엔드: 없음\n- 케이스: normal\n\n' '**상태 불변조건**\n\n없음\n\n## 8. 오류 및 충돌 계약\n\n없음\n') def check(self, body): import hashlib self.assertTrue(callable(getattr(mod, 'check_ac_classification', None)), 'trusted-template AC classification checker missing') return mod.check_ac_classification(body, hashlib.sha256(self.TEMPLATE.read_bytes()).hexdigest()) def test_valid_canonical_ac_classification(self): out = self.check(self.body()) self.assertEqual(out['ac_ids'], ['REQ-001.01-AC01']) self.assertEqual(out['checked_fields'], ['수준', '백엔드', '케이스']) self.assertTrue(out['read_only']) def test_missing_empty_duplicate_and_placeholder_fields_rejected_per_ac(self): valid = self.body() self.check(valid) for changed in (valid.replace('- 수준: unit\n', ''), valid.replace('- 백엔드: 없음', '- 백엔드: '), valid.replace('- 케이스: normal', '- 케이스: ${verification_cases}'), valid.replace('- 수준: unit', '- 수준: unit\n- 수준: unit'), valid.replace('**검증 분류**', '**검증**'), valid.replace('**검증 분류**', '**검증 분류**\n\n**검증 분류**')): with self.subTest(changed=changed): with self.assertRaises(mod.ValidationError): self.check(changed) def test_other_ac_and_fenced_examples_cannot_supply_missing_field(self): good = self.body().split('## 8.', 1)[0] bad = good.replace('AC01', 'AC02').replace('- 수준: unit\n', '') self.check(good + good.replace('AC01', 'AC02').split('\n\n', 1)[1] + '## 8. 오류 및 충돌 계약\n') for extra in ('', '```markdown\n- 수준: unit\n```\n', '~~~\n- 수준: unit\n~~~\n'): with self.subTest(extra=extra): with self.assertRaises(mod.ValidationError): self.check(good + bad.split('\n\n', 1)[1].replace('**상태 불변조건**', extra + '**상태 불변조건**') + '## 8. 오류 및 충돌 계약\n') def test_ambiguous_hidden_sections_and_template_drift_fail_closed(self): valid = self.body() self.check(valid) import hashlib with self.assertRaises(mod.ValidationError): mod.check_ac_classification(valid, '0' * 64) for bad in ('', valid + '\n```unclosed', valid.replace('### REQ', '#### REQ'), valid.replace('## 8. 오류 및 충돌 계약', '## 7. 인수 기준'), valid.replace('- 수준: unit', ' - 수준: unit')): with self.subTest(bad=bad): with self.assertRaises(mod.ValidationError): self.check(bad) crlf = valid.replace('\n', '\r\n') self.assertEqual(self.check(crlf)['document_sha256'], hashlib.sha256(crlf.encode()).hexdigest()) def test_cli_read_only_exact_bytes_digest_and_sanitized_failure(self): import hashlib with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / 'req.md' raw = self.body().replace('\n', '\r\n').encode() path.write_bytes(raw) os.chmod(path, 0o600) before = (path.read_bytes(), path.stat().st_mode, path.stat().st_mtime_ns, path.stat().st_ino) argv = [sys.executable, '-B', str(MODPATH), 'check-ac-classification', '--document', str(path), '--expected-document-sha256', hashlib.sha256(raw).hexdigest(), '--expected-template-sha256', hashlib.sha256(self.TEMPLATE.read_bytes()).hexdigest()] actual = subprocess.run(argv, capture_output=True, text=True, timeout=10) self.assertEqual(actual.returncode, 0, actual.stderr) self.assertEqual(json.loads(actual.stdout)['document_sha256'], hashlib.sha256(raw).hexdigest()) self.assertEqual(before, (path.read_bytes(), path.stat().st_mode, path.stat().st_mtime_ns, path.stat().st_ino)) argv[-3] = '0' * 64 bad = subprocess.run(argv, capture_output=True, text=True, timeout=10) self.assertEqual(bad.returncode, 2) self.assertEqual(json.loads(bad.stderr), {'error': 'invalid input'}) self.assertEqual(bad.stdout, '') def test_supported_scope_rejects_hidden_html_and_noncanonical_ac_heading(self): valid = self.body() self.check(valid) for bad in ('