import hashlib import importlib.util import json import os import tempfile import unittest from pathlib import Path SCRIPT = Path(__file__).parents[1] / "scripts" / "sync_state.py" SPEC = importlib.util.spec_from_file_location("sync_state", SCRIPT) sync_state = importlib.util.module_from_spec(SPEC) assert SPEC.loader is not None SPEC.loader.exec_module(sync_state) def write_json(path: Path, value: dict) -> str: payload = (json.dumps(value, indent=2) + "\n").encode() path.write_bytes(payload) return hashlib.sha256(payload).hexdigest() class SyncStateTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.root = Path(self.temporary.name) self.original_cwd = Path.cwd() os.chdir(self.root) self.state_path = self.root / "agent-ui" / ".sync-state.json" self.state_path.parent.mkdir(parents=True) self.base_state = { "schema_version": 1, "surface_type": "ops-dev", "baseline_mode": "migration", "last_sync_mode": "baseline", "last_synced_head": "a" * 40, "last_synced_at": "2026-01-01T00:00:00Z", "agent_ui_paths": [], "code_paths": [], "notes": [], } def tearDown(self): os.chdir(self.original_cwd) self.temporary.cleanup() def prepare_args(self, digest: str, **overrides): values = { "state": self.state_path, "expected_sha256": digest, "task_path": "agent-task/ui_work", "previous_task_path": None, "scope": "view:dashboard", "status_path": ["agent-ui/definition/views/dashboard/index.md"], "frame_path": ["agent-ui/frame/views/dashboard/index.md"], "code_path": ["src/dashboard.py"], "verification_requirement": ["python -m unittest: PASS"], "prepared_at": "2026-07-29T00:00:00Z", "replace": False, } values.update(overrides) return type("Args", (), values) def reconcile_args(self, digest: str, completion_log: Path, **overrides): values = { "state": self.state_path, "expected_sha256": digest, "task_path": "agent-task/ui_work", "completion_log": completion_log, "validation_evidence": "validate-agent-ui scope=view:dashboard PASS", "reconciled_at": "2026-07-29T01:00:00Z", "sync_result_head": None, } values.update(overrides) return type("Args", (), values) def prepare_milestone_args(self, digest: str, **overrides): values = { "state": self.state_path, "expected_sha256": digest, "milestone_path": ( "agent-roadmap/phase/product/milestones/dashboard-refresh.md" ), "scope": "view:dashboard", "status_path": ["agent-ui/definition/views/dashboard/index.md"], "frame_path": ["agent-ui/frame/views/dashboard/index.md"], "code_path": ["src/dashboard.py"], "verification_requirement": ["python -m unittest: PASS"], "prepared_at": "2026-07-29T00:00:00Z", "replace": False, } values.update(overrides) return type("Args", (), values) def reconcile_milestone_args(self, digest: str, **overrides): values = { "state": self.state_path, "expected_sha256": digest, "milestone_path": ( "agent-roadmap/phase/product/milestones/dashboard-refresh.md" ), "closure_evidence": "complete-milestone check-only: closure-ready", "validation_evidence": "validate-agent-ui scope=view:dashboard PASS", "reconciled_at": "2026-07-29T01:00:00Z", "sync_result_head": None, } values.update(overrides) return type("Args", (), values) def record_sync_args(self, digest: str, **overrides): values = { "state": self.state_path, "expected_sha256": digest, "sync_mode": "incremental", "sync_result_head": "b" * 40, "synced_at": "2026-07-29T02:00:00Z", "agent_ui_path": ["agent-ui/definition/views/dashboard/index.md"], "code_path": ["src/dashboard.py"], "note": ["direct sync"], } values.update(overrides) return type("Args", (), values) def test_prepare_migrates_v1_and_records_typed_paths(self): digest = write_json(self.state_path, self.base_state) result = sync_state._prepare(self.prepare_args(digest)) state = json.loads(self.state_path.read_text()) self.assertEqual("prepared", result["status"]) self.assertEqual(4, state["schema_version"]) self.assertEqual( ["agent-ui/definition/views/dashboard/index.md"], state["pending_code_work"][0]["status_paths"], ) self.assertEqual( ["agent-ui/frame/views/dashboard/index.md"], state["pending_code_work"][0]["frame_paths"], ) self.assertEqual([], state["reconciled_code_work"]) self.assertEqual([], state["pending_milestone_work"]) self.assertEqual([], state["reconciled_milestone_work"]) def test_prepare_is_idempotent_and_requires_replace_for_scope_change(self): digest = write_json(self.state_path, self.base_state) first = sync_state._prepare(self.prepare_args(digest)) second = sync_state._prepare( self.prepare_args( first["after_sha256"], prepared_at="2026-07-29T00:05:00Z", ) ) self.assertEqual("already_prepared", second["status"]) with self.assertRaises(sync_state.StateError): sync_state._prepare( self.prepare_args( second["after_sha256"], code_path=["src/other.py"], ) ) replaced = sync_state._prepare( self.prepare_args( second["after_sha256"], code_path=["src/other.py"], replace=True, ) ) self.assertEqual("replaced", replaced["status"]) def test_prepare_rejects_stale_digest(self): digest = write_json(self.state_path, self.base_state) with self.assertRaises(sync_state.StateError): sync_state._prepare(self.prepare_args("0" * 64)) self.assertEqual(digest, hashlib.sha256(self.state_path.read_bytes()).hexdigest()) def test_prepare_rejects_malformed_digest(self): digest = write_json(self.state_path, self.base_state) with self.assertRaises(sync_state.StateError): sync_state._prepare(self.prepare_args("not-a-sha256")) self.assertEqual(digest, hashlib.sha256(self.state_path.read_bytes()).hexdigest()) def test_prepare_rebinds_refined_task_path(self): digest = write_json(self.state_path, self.base_state) prepared = sync_state._prepare(self.prepare_args(digest)) rebound = sync_state._prepare( self.prepare_args( prepared["after_sha256"], previous_task_path="agent-task/ui_work", task_path="agent-task/ui_work/01_closure", ) ) state = json.loads(self.state_path.read_text()) self.assertEqual("rebound", rebound["status"]) self.assertEqual( "agent-task/ui_work/01_closure", state["pending_code_work"][0]["task_path"], ) def test_prepare_rejects_status_path_owned_by_another_task(self): digest = write_json(self.state_path, self.base_state) prepared = sync_state._prepare(self.prepare_args(digest)) with self.assertRaises(sync_state.StateError): sync_state._prepare( self.prepare_args( prepared["after_sha256"], task_path="agent-task/other_ui_work", frame_path=[], code_path=["src/other.py"], ) ) state = json.loads(self.state_path.read_text()) self.assertEqual(1, len(state["pending_code_work"])) self.assertEqual( "agent-task/ui_work", state["pending_code_work"][0]["task_path"], ) def test_prepare_rebind_requires_replace_for_changed_scope(self): digest = write_json(self.state_path, self.base_state) prepared = sync_state._prepare(self.prepare_args(digest)) with self.assertRaises(sync_state.StateError): sync_state._prepare( self.prepare_args( prepared["after_sha256"], previous_task_path="agent-task/ui_work", task_path="agent-task/ui_work/01_closure", code_path=["src/closure.py"], ) ) state = json.loads(self.state_path.read_text()) self.assertEqual( "agent-task/ui_work", state["pending_code_work"][0]["task_path"], ) def test_prepare_milestone_records_mapping_and_blocks_shared_ui_owner(self): digest = write_json(self.state_path, self.base_state) milestone = self.root / ( "agent-roadmap/phase/product/milestones/dashboard-refresh.md" ) milestone.parent.mkdir(parents=True) milestone.write_text("# Milestone: Dashboard refresh\n") prepared = sync_state._prepare_milestone( self.prepare_milestone_args(digest) ) state = json.loads(self.state_path.read_text()) self.assertEqual("prepared", prepared["status"]) self.assertEqual(1, len(state["pending_milestone_work"])) with self.assertRaises(sync_state.StateError): sync_state._prepare( self.prepare_args( prepared["after_sha256"], frame_path=[], ) ) def test_reconcile_milestone_moves_mapping_and_is_idempotent(self): digest = write_json(self.state_path, self.base_state) milestone = self.root / ( "agent-roadmap/phase/product/milestones/dashboard-refresh.md" ) milestone.parent.mkdir(parents=True) milestone.write_text("# Milestone: Dashboard refresh\n") prepared = sync_state._prepare_milestone( self.prepare_milestone_args(digest) ) reconciled = sync_state._reconcile_milestone( self.reconcile_milestone_args(prepared["after_sha256"]) ) state = json.loads(self.state_path.read_text()) self.assertEqual("reconciled", reconciled["status"]) self.assertEqual([], state["pending_milestone_work"]) self.assertEqual(1, len(state["reconciled_milestone_work"])) self.assertEqual("a" * 40, state["last_synced_head"]) milestone.unlink() repeated = sync_state._reconcile_milestone( self.reconcile_milestone_args(reconciled["after_sha256"]) ) self.assertEqual("already_reconciled", repeated["status"]) def test_v2_legacy_pending_paths_are_typed_during_migration(self): state = dict(self.base_state) state["schema_version"] = 2 state["pending_code_work"] = [ { "task_path": "agent-task/ui_work", "scope": "all", "agent_ui_paths": [ "agent-ui/definition/views/dashboard/index.md", "agent-ui/frame/views/dashboard/index.md", ], "code_paths": ["src/dashboard.py"], "verification_requirements": [], "prepared_at": "2026-07-29T00:00:00Z", "state": "pending", } ] normalized = sync_state._normalize_state(state) entry = normalized["pending_code_work"][0] self.assertEqual( ["agent-ui/definition/views/dashboard/index.md"], entry["status_paths"], ) self.assertEqual( ["agent-ui/frame/views/dashboard/index.md"], entry["frame_paths"], ) def test_normalize_rejects_non_list_and_duplicate_pending_entries(self): invalid = dict(self.base_state) invalid["pending_code_work"] = {} with self.assertRaises(sync_state.StateError): sync_state._normalize_state(invalid) entry = { "task_path": "agent-task/ui_work", "scope": "all", "status_paths": ["agent-ui/definition/views/dashboard/index.md"], "frame_paths": [], "code_paths": ["src/dashboard.py"], "verification_requirements": [], "prepared_at": "2026-07-29T00:00:00Z", "state": "pending", } duplicate = dict(self.base_state) duplicate["schema_version"] = 3 duplicate["pending_code_work"] = [entry, dict(entry)] duplicate["reconciled_code_work"] = [] with self.assertRaises(sync_state.StateError): sync_state._normalize_state(duplicate) second_owner = dict(entry) second_owner["task_path"] = "agent-task/other" duplicate_owner = dict(self.base_state) duplicate_owner["schema_version"] = 3 duplicate_owner["pending_code_work"] = [entry, second_owner] duplicate_owner["reconciled_code_work"] = [] with self.assertRaises(sync_state.StateError): sync_state._normalize_state(duplicate_owner) def test_reconcile_moves_pending_entry_and_is_idempotent(self): digest = write_json(self.state_path, self.base_state) prepared = sync_state._prepare(self.prepare_args(digest)) completion_log = Path("agent-task/archive/2026/07/ui_work/complete.log") (self.root / completion_log).parent.mkdir(parents=True) (self.root / completion_log).write_text( "# Complete - ui_work\n\n" "## 루프 이력\n\n" "| Plan | Review | Verdict | 메모 |\n" "|---|---|---|---|\n" "| `plan.log` | `review.log` | PASS | done |\n" ) reconciled = sync_state._reconcile( self.reconcile_args(prepared["after_sha256"], self.root / completion_log) ) state = json.loads(self.state_path.read_text()) self.assertEqual("reconciled", reconciled["status"]) self.assertEqual([], state["pending_code_work"]) self.assertEqual(1, len(state["reconciled_code_work"])) self.assertEqual("a" * 40, state["last_synced_head"]) self.assertIn( "baseline=preserved:no validated sync_result_head", state["notes"][-1], ) repeated = sync_state._reconcile( self.reconcile_args(reconciled["after_sha256"], self.root / completion_log) ) self.assertEqual("already_reconciled", repeated["status"]) def test_reconcile_rejects_completion_log_for_another_task(self): digest = write_json(self.state_path, self.base_state) prepared = sync_state._prepare(self.prepare_args(digest)) completion_log = Path("agent-task/archive/2026/07/other/complete.log") (self.root / completion_log).parent.mkdir(parents=True) (self.root / completion_log).write_text( "# Complete - other\n\n" "## 루프 이력\n\n" "| Plan | Review | Verdict | 메모 |\n" "|---|---|---|---|\n" "| `plan.log` | `review.log` | PASS | done |\n" ) with self.assertRaises(sync_state.StateError): sync_state._reconcile( self.reconcile_args(prepared["after_sha256"], completion_log) ) def test_record_sync_preserves_pending_and_reconciled_state(self): digest = write_json(self.state_path, self.base_state) prepared = sync_state._prepare(self.prepare_args(digest)) recorded = sync_state._record_sync( self.record_sync_args( prepared["after_sha256"], agent_ui_path=[ "agent-ui/definition/views/other-dashboard/index.md" ], code_path=["src/other_dashboard.py"], ) ) state = json.loads(self.state_path.read_text()) self.assertEqual("sync_recorded", recorded["status"]) self.assertEqual("incremental", state["last_sync_mode"]) self.assertEqual("b" * 40, state["last_synced_head"]) self.assertEqual(1, len(state["pending_code_work"])) self.assertEqual([], state["reconciled_code_work"]) def test_record_sync_preserves_pending_milestone_state(self): digest = write_json(self.state_path, self.base_state) milestone = self.root / ( "agent-roadmap/phase/product/milestones/dashboard-refresh.md" ) milestone.parent.mkdir(parents=True) milestone.write_text("# Milestone: Dashboard refresh\n") prepared = sync_state._prepare_milestone( self.prepare_milestone_args(digest) ) state_before = self.state_path.read_bytes() with self.assertRaisesRegex( sync_state.StateError, "overlaps pending work paths", ): sync_state._record_sync( self.record_sync_args(prepared["after_sha256"]) ) self.assertEqual(state_before, self.state_path.read_bytes()) recorded = sync_state._record_sync( self.record_sync_args( prepared["after_sha256"], agent_ui_path=[ "agent-ui/definition/views/other-dashboard/index.md" ], code_path=["src/other_dashboard.py"], ) ) state = json.loads(self.state_path.read_text()) self.assertEqual("sync_recorded", recorded["status"]) self.assertEqual(1, len(state["pending_milestone_work"])) self.assertEqual([], state["reconciled_milestone_work"]) def test_record_sync_rejects_paths_owned_by_pending_work(self): digest = write_json(self.state_path, self.base_state) prepared = sync_state._prepare(self.prepare_args(digest)) state_before = self.state_path.read_bytes() for agent_ui_path in ( "agent-ui/definition/views/dashboard/index.md", "agent-ui/definition/views/dashboard", ): with self.subTest(agent_ui_path=agent_ui_path): with self.assertRaisesRegex( sync_state.StateError, "overlaps pending work paths", ): sync_state._record_sync( self.record_sync_args( prepared["after_sha256"], agent_ui_path=[agent_ui_path], ) ) self.assertEqual(state_before, self.state_path.read_bytes()) def test_record_migration_allows_no_code_paths(self): digest = write_json(self.state_path, self.base_state) recorded = sync_state._record_sync( self.record_sync_args( digest, sync_mode="migration", code_path=[], ) ) state = json.loads(self.state_path.read_text()) self.assertEqual("sync_recorded", recorded["status"]) self.assertEqual("migration", state["last_sync_mode"]) self.assertEqual([], state["code_paths"]) if __name__ == "__main__": unittest.main()