gabriel / muse public
test_chess_plugin.py python
167 lines 6.6 KB
Raw
sha256:78a3b3b54d1ef5e0d4d65eb8506437b2c5083714d4bee38326fe19411059fa53 feat: add chess domain plugin (Episode 22 finale) Sonnet 5 patch 4 days ago
1 """Tests for muse/plugins/chess/plugin.py — the Episode 22 finale domain.
2
3 Built live in Build With Muse, Episode 22 ("Build Something Weird With
4 Muse"). Covers the six required protocol methods, plus the domain's real
5 point: two branches diverging after a shared opening produce a genuine
6 "variation" conflict, not a silent auto-merge.
7 """
8
9 from __future__ import annotations
10
11 import pathlib
12
13 from muse.core.object_store import read_object, write_object
14 from muse.core.types import blob_id
15 from muse.plugins.chess.plugin import ChessPlugin, _GAME_FILE
16
17
18 def _repo(tmp_path: pathlib.Path) -> pathlib.Path:
19 (tmp_path / ".muse" / "objects").mkdir(parents=True)
20 return tmp_path
21
22
23 def _commit_moves(plugin: ChessPlugin, repo_root: pathlib.Path, moves: list[str]) -> dict:
24 body = "\n".join(moves) + ("\n" if moves else "")
25 (repo_root / _GAME_FILE).write_text(body)
26 snap = plugin.snapshot(repo_root)
27 if moves:
28 raw = body.encode("utf-8")
29 write_object(repo_root, blob_id(raw), raw)
30 return snap
31
32
33 class TestSchema:
34 def test_top_level_is_position_addressed_sequence(self) -> None:
35 schema = ChessPlugin().schema()
36 assert schema["top_level"]["kind"] == "sequence"
37 assert schema["top_level"]["identity"] == "by_position"
38 assert schema["top_level"]["diff_algorithm"] == "lcs"
39
40 def test_merge_mode_is_three_way(self) -> None:
41 assert ChessPlugin().schema()["merge_mode"] == "three_way"
42
43
44 class TestSnapshot:
45 def test_empty_repo_has_no_files(self, tmp_path: pathlib.Path) -> None:
46 root = _repo(tmp_path)
47 snap = ChessPlugin().snapshot(root)
48 assert snap["files"] == {}
49
50 def test_object_id_uses_canonical_blob_format(self, tmp_path: pathlib.Path) -> None:
51 root = _repo(tmp_path)
52 (root / _GAME_FILE).write_text("e4\n")
53 snap = ChessPlugin().snapshot(root)
54 assert snap["files"][_GAME_FILE] == blob_id(b"e4\n")
55
56
57 class TestDiff:
58 def test_no_change_when_hashes_match(self, tmp_path: pathlib.Path) -> None:
59 root = tmp_path
60 snap = _commit_moves(ChessPlugin(), root, ["e4", "e5"])
61 delta = ChessPlugin().diff(snap, snap, repo_root=root)
62 assert delta["ops"] == []
63
64 def test_top_level_op_is_keyed_by_file_not_move(self, tmp_path: pathlib.Path) -> None:
65 """Regression class from Episode 06: `muse checkout` restores files
66 by matching op["address"] against the manifest's file paths -- the
67 top-level op must be keyed by game.moves, not by move position.
68 """
69 plugin = ChessPlugin()
70 root = tmp_path
71 base = _commit_moves(plugin, root, [])
72 target = _commit_moves(plugin, root, ["e4"])
73 delta = plugin.diff(base, target, repo_root=root)
74 assert len(delta["ops"]) == 1
75 assert delta["ops"][0]["op"] == "patch"
76 assert delta["ops"][0]["address"] == _GAME_FILE
77
78 def test_appending_moves_produces_insert_ops_at_correct_positions(
79 self, tmp_path: pathlib.Path
80 ) -> None:
81 plugin = ChessPlugin()
82 root = tmp_path
83 base = _commit_moves(plugin, root, ["e4", "e5"])
84 target = _commit_moves(plugin, root, ["e4", "e5", "Nf3", "Nc6"])
85 delta = plugin.diff(base, target, repo_root=root)
86 child_ops = delta["ops"][0]["child_ops"]
87 assert [op["op"] for op in child_ops] == ["insert", "insert"]
88 assert [op["position"] for op in child_ops] == [2, 3]
89
90 def test_a_different_next_move_is_a_delete_plus_insert_at_same_position(
91 self, tmp_path: pathlib.Path
92 ) -> None:
93 plugin = ChessPlugin()
94 root = tmp_path
95 base = _commit_moves(plugin, root, ["e4", "e5", "Nf3"])
96 target = _commit_moves(plugin, root, ["e4", "e5", "Bc4"])
97 delta = plugin.diff(base, target, repo_root=root)
98 child_ops = delta["ops"][0]["child_ops"]
99 kinds = {(op["op"], op["position"]) for op in child_ops}
100 assert kinds == {("delete", 2), ("insert", 2)}
101
102
103 class TestMerge:
104 def test_one_side_extending_the_game_merges_cleanly(self, tmp_path: pathlib.Path) -> None:
105 plugin = ChessPlugin()
106 root = tmp_path
107 base = _commit_moves(plugin, root, ["e4", "e5"])
108 left = _commit_moves(plugin, root, ["e4", "e5", "Nf3", "Nc6"])
109 right = _commit_moves(plugin, root, ["e4", "e5"]) # right added nothing
110
111 result = plugin.merge(base, left, right, repo_root=root)
112
113 assert result.conflicts == []
114 merged_raw = read_object(root, result.merged["files"][_GAME_FILE])
115 assert merged_raw.decode("utf-8").splitlines() == ["e4", "e5", "Nf3", "Nc6"]
116
117 def test_two_different_next_moves_is_a_real_variation_conflict(
118 self, tmp_path: pathlib.Path
119 ) -> None:
120 """The actual point of this domain: Nf3 (Ruy Lopez / Italian setups)
121 vs Bc4 (Italian Game) from the same position is not something Muse
122 should silently pick a winner for.
123 """
124 plugin = ChessPlugin()
125 root = tmp_path
126 base = _commit_moves(plugin, root, ["e4", "e5"])
127 left = _commit_moves(plugin, root, ["e4", "e5", "Nf3"])
128 right = _commit_moves(plugin, root, ["e4", "e5", "Bc4"])
129
130 result = plugin.merge(base, left, right, repo_root=root)
131
132 assert result.conflicts == [_GAME_FILE]
133 assert len(result.conflict_records) == 1
134 assert result.conflict_records[0].conflict_type == "variation"
135
136 def test_identical_continuations_merge_with_no_conflict(self, tmp_path: pathlib.Path) -> None:
137 plugin = ChessPlugin()
138 root = tmp_path
139 base = _commit_moves(plugin, root, ["e4"])
140 left = _commit_moves(plugin, root, ["e4", "e5", "Nf3"])
141 right = _commit_moves(plugin, root, ["e4", "e5", "Nf3"])
142
143 result = plugin.merge(base, left, right, repo_root=root)
144
145 assert result.conflicts == []
146 merged_raw = read_object(root, result.merged["files"][_GAME_FILE])
147 assert merged_raw.decode("utf-8").splitlines() == ["e4", "e5", "Nf3"]
148
149
150 class TestDrift:
151 def test_uncommitted_move_is_detected(self, tmp_path: pathlib.Path) -> None:
152 plugin = ChessPlugin()
153 root = tmp_path
154 committed = _commit_moves(plugin, root, ["e4"])
155 (root / _GAME_FILE).write_text("e4\ne5\n") # uncommitted
156
157 report = plugin.drift(committed, root)
158
159 assert report.has_drift is True
160 assert report.summary == "1 move(s) played, 0 move(s) removed"
161
162 def test_clean_tree_has_no_drift(self, tmp_path: pathlib.Path) -> None:
163 plugin = ChessPlugin()
164 root = tmp_path
165 committed = _commit_moves(plugin, root, ["e4"])
166 report = plugin.drift(committed, root)
167 assert report.has_drift is False
File History 1 commit
sha256:78a3b3b54d1ef5e0d4d65eb8506437b2c5083714d4bee38326fe19411059fa53 feat: add chess domain plugin (Episode 22 finale) Sonnet 5 patch 4 days ago