test_chess_plugin.py
python
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