Files
llm-wiki/harness/tests/test_migrate_graph_contracts.py
T

249 lines
9.1 KiB
Python

from __future__ import annotations
import sys
from pathlib import Path
import tempfile
import unittest
from unittest import mock
RUNTIME = Path(__file__).resolve().parents[1] / "runtime"
sys.path.insert(0, str(RUNTIME))
import fs_transaction # noqa: E402
from migrate_graph_contracts import build_updates, prepare_updates # noqa: E402
class GraphContractMigrationTests(unittest.TestCase):
@staticmethod
def _write_candidate_scope(root: Path) -> tuple[Path, Path]:
projects = root / "raw/project-notes"
branches = root / "raw/branch-notes"
projects.mkdir(parents=True)
branches.mkdir(parents=True)
project = projects / "demo.md"
project.write_text(
"""---
project_revision: 3
---
<!-- section-id: project-decisions -->
## 결정
| Decision ID | Revision | Decision Summary | Owner |
|---|---|---|---|
| `DEC-DEMO-001` | 1 | 기준 결정을 사용한다 | demo |
<!-- section-id: project-work-items -->
## 작업
| Work Item ID | branch slug | 완료 조건 (측정가능) | Applies Decisions | Dependencies | Status |
|---|---|---|---|---|---|
| `WI-DEMO-001` | `feature-demo` | 검사가 통과한다 | `DEC-DEMO-001@1` | - | `planned` |
## Cluster / 묶음
manual
""",
encoding="utf-8",
)
branch = branches / "feature-demo.md"
branch.write_text(
"""---
title: demo
source_type: branch-note
status: raw
tags: [branch]
created: 2026-07-20
parent_branch: legacy-parent
---
# demo
## 부모 (필수)
- [[raw/project-notes/demo]]
## 목표
테스트한다.
""",
encoding="utf-8",
)
return project, branch
def test_direct_work_item_is_migrated_from_registry(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
projects = root / "raw/project-notes"
branches = root / "raw/branch-notes"
projects.mkdir(parents=True)
branches.mkdir(parents=True)
(projects / "demo.md").write_text(
"""---
project_revision: 3
---
## 6.1 Project Decision Registry / 안정 결정 레지스트리
| Decision ID | Revision | Decision Summary | Owner |
|---|---|---|---|
| `DEC-DEMO-001` | 1 | 기준 결정을 사용한다 | demo |
## 8.0 Work Item Registry / 실행계획
| Work Item ID | branch slug | 완료 조건 (측정가능) | Applies Decisions | Dependencies | Status |
|---|---|---|---|---|---|
| `WI-DEMO-001` | `feature-demo` | 검사가 통과한다 | `DEC-DEMO-001@1` | - | `planned` |
""",
encoding="utf-8",
)
branch = branches / "feature-demo.md"
branch.write_text(
"""---
title: demo
parent_branch: legacy-parent
---
# demo
## Parent / 부모 (필수)
- [[raw/project-notes/demo]]
## 목표 / WHY
테스트한다.
""",
encoding="utf-8",
)
updates, stats = build_updates(root)
self.assertEqual(stats["eligible"], ["feature-demo"])
migrated = updates[branch]
self.assertIn("id: BR-DEMO-001", migrated)
self.assertIn("kind: project-work-item", migrated)
self.assertIn("work_item: WI-DEMO-001", migrated)
self.assertIn("inherits: [DEC-DEMO-001@1]", migrated)
self.assertIn("parent_branch:\n", migrated)
self.assertIn("branch: feature-demo", migrated)
self.assertIn("contract_packet_sha256:", migrated)
self.assertIn("<!-- GENERATED: branch-contract:start -->", migrated)
self.assertIn("## 브랜치 계약 패킷", migrated)
self.assertIn("**생성 시 프로젝트 개정**: `3`", migrated)
self.assertIn("| `DEC-DEMO-001@1` | 기준 결정을 사용한다 |", migrated)
def test_unmapped_and_v2_notes_are_not_guessed(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "raw/project-notes").mkdir(parents=True)
branches = root / "raw/branch-notes"
branches.mkdir(parents=True)
(branches / "feature-child.md").write_text("---\ntitle: child\n---\n", encoding="utf-8")
(branches / "feature-v2.md").write_text(
"---\ncontract_packet: 1\n---\n", encoding="utf-8"
)
updates, stats = build_updates(root)
self.assertEqual(updates, {})
self.assertEqual(stats["unmapped"], ["feature-child"])
self.assertEqual(stats["already_v2"], ["feature-v2"])
self.assertEqual(stats["blocked"][0]["code"], "UNMAPPED_V2_BRANCH")
def test_blocked_registry_prevents_all_scope_writes(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
projects = root / "raw/project-notes"
branches = root / "raw/branch-notes"
projects.mkdir(parents=True)
branches.mkdir(parents=True)
(projects / "demo.md").write_text(
"""---
project_revision: 2
---
<!-- section-id: project-decisions -->
## 결정
| Decision ID | Revision | Decision Summary |
|---|---|---|
| `DEC-DEMO-001` | 2 | 기준 |
<!-- section-id: project-work-items -->
## 작업
| Work Item ID | branch slug | 완료 조건 (측정가능) | Applies Decisions | Dependencies | Status |
|---|---|---|---|---|---|
| `WI-DEMO-001` | `feature-demo-one` | 완료 | `DEC-DEMO-001@1` | `WI-DEMO-001` | `planned` |
| `WI-DEMO-001` | `feature-demo-two` | 완료 | - | `WI-DEMO-404` | `planned` |
""",
encoding="utf-8",
)
(branches / "feature-demo-one.md").write_text(
"---\ntitle: demo\n---\n# demo\n\n## 목표\n", encoding="utf-8"
)
updates, stats = build_updates(root)
self.assertEqual(updates, {})
codes = {item["code"] for item in stats["blocked"]}
self.assertTrue(
{"DUPLICATE_WORK_ITEM", "STALE_REVISION", "SELF_DEPENDENCY", "MISSING_APPLIED_DECISION", "MISSING_DEPENDENCY"}
<= codes,
stats,
)
def test_staged_quality_failure_leaves_entire_scope_unchanged(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
project, branch = self._write_candidate_scope(root)
before = {project: project.read_bytes(), branch: branch.read_bytes()}
observed: dict[str, object] = {}
def fail_gate(_root, touched_paths, **kwargs):
observed["touched"] = list(touched_paths)
observed["include_graph"] = kwargs["include_graph"]
return {
"schema_version": "quality-gate-result/v1",
"status": "FAIL",
"findings": [{"code": "INJECTED_GRAPH_FAILURE"}],
"checked_paths": list(touched_paths),
}
updates, stats = prepare_updates(root, quality_runner=fail_gate)
self.assertEqual(updates, {})
self.assertEqual(stats["blocked"][0]["code"], "MIGRATION_QUALITY_GATE_FAILED")
self.assertTrue(observed["include_graph"])
self.assertIn("raw/branch-notes/feature-demo.md", observed["touched"])
self.assertEqual({path: path.read_bytes() for path in before}, before)
def test_single_transaction_rolls_back_and_success_is_idempotent(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
project, branch = self._write_candidate_scope(root)
before = {project: project.read_bytes(), branch: branch.read_bytes()}
def pass_gate(_root, touched_paths, **_kwargs):
return {
"schema_version": "quality-gate-result/v1",
"status": "PASS",
"findings": [],
"checked_paths": list(touched_paths),
"touched_paths": list(touched_paths),
"checks": [],
}
updates, stats = prepare_updates(root, quality_runner=pass_gate)
self.assertGreaterEqual(len(updates), 2, stats)
real_replace = fs_transaction.os.replace
calls = 0
def fail_second(source, target):
nonlocal calls
calls += 1
if calls == 2:
raise OSError("injected failure")
return real_replace(source, target)
with mock.patch("fs_transaction.os.replace", side_effect=fail_second):
with self.assertRaises(fs_transaction.TransactionError):
fs_transaction.replace_many({path: text.encode("utf-8") for path, text in updates.items()})
self.assertEqual({path: path.read_bytes() for path in before}, before)
fs_transaction.replace_many({path: text.encode("utf-8") for path, text in updates.items()})
second, second_stats = prepare_updates(root, quality_runner=pass_gate)
self.assertEqual(second, {}, second_stats)
self.assertEqual(second_stats["blocked"], [])
if __name__ == "__main__":
unittest.main()