"""Tests for muse/plugins/chess/plugin.py — the Episode 22 finale domain. Built live in Build With Muse, Episode 22 ("Build Something Weird With Muse"). Covers the six required protocol methods, plus the domain's real point: two branches diverging after a shared opening produce a genuine "variation" conflict, not a silent auto-merge. """ from __future__ import annotations import pathlib from muse.core.object_store import read_object, write_object from muse.core.types import blob_id from muse.plugins.chess.plugin import ChessPlugin, _GAME_FILE def _repo(tmp_path: pathlib.Path) -> pathlib.Path: (tmp_path / ".muse" / "objects").mkdir(parents=True) return tmp_path def _commit_moves(plugin: ChessPlugin, repo_root: pathlib.Path, moves: list[str]) -> dict: body = "\n".join(moves) + ("\n" if moves else "") (repo_root / _GAME_FILE).write_text(body) snap = plugin.snapshot(repo_root) if moves: raw = body.encode("utf-8") write_object(repo_root, blob_id(raw), raw) return snap class TestSchema: def test_top_level_is_position_addressed_sequence(self) -> None: schema = ChessPlugin().schema() assert schema["top_level"]["kind"] == "sequence" assert schema["top_level"]["identity"] == "by_position" assert schema["top_level"]["diff_algorithm"] == "lcs" def test_merge_mode_is_three_way(self) -> None: assert ChessPlugin().schema()["merge_mode"] == "three_way" class TestSnapshot: def test_empty_repo_has_no_files(self, tmp_path: pathlib.Path) -> None: root = _repo(tmp_path) snap = ChessPlugin().snapshot(root) assert snap["files"] == {} def test_object_id_uses_canonical_blob_format(self, tmp_path: pathlib.Path) -> None: root = _repo(tmp_path) (root / _GAME_FILE).write_text("e4\n") snap = ChessPlugin().snapshot(root) assert snap["files"][_GAME_FILE] == blob_id(b"e4\n") class TestDiff: def test_no_change_when_hashes_match(self, tmp_path: pathlib.Path) -> None: root = tmp_path snap = _commit_moves(ChessPlugin(), root, ["e4", "e5"]) delta = ChessPlugin().diff(snap, snap, repo_root=root) assert delta["ops"] == [] def test_top_level_op_is_keyed_by_file_not_move(self, tmp_path: pathlib.Path) -> None: """Regression class from Episode 06: `muse checkout` restores files by matching op["address"] against the manifest's file paths -- the top-level op must be keyed by game.moves, not by move position. """ plugin = ChessPlugin() root = tmp_path base = _commit_moves(plugin, root, []) target = _commit_moves(plugin, root, ["e4"]) delta = plugin.diff(base, target, repo_root=root) assert len(delta["ops"]) == 1 assert delta["ops"][0]["op"] == "patch" assert delta["ops"][0]["address"] == _GAME_FILE def test_appending_moves_produces_insert_ops_at_correct_positions( self, tmp_path: pathlib.Path ) -> None: plugin = ChessPlugin() root = tmp_path base = _commit_moves(plugin, root, ["e4", "e5"]) target = _commit_moves(plugin, root, ["e4", "e5", "Nf3", "Nc6"]) delta = plugin.diff(base, target, repo_root=root) child_ops = delta["ops"][0]["child_ops"] assert [op["op"] for op in child_ops] == ["insert", "insert"] assert [op["position"] for op in child_ops] == [2, 3] def test_a_different_next_move_is_a_delete_plus_insert_at_same_position( self, tmp_path: pathlib.Path ) -> None: plugin = ChessPlugin() root = tmp_path base = _commit_moves(plugin, root, ["e4", "e5", "Nf3"]) target = _commit_moves(plugin, root, ["e4", "e5", "Bc4"]) delta = plugin.diff(base, target, repo_root=root) child_ops = delta["ops"][0]["child_ops"] kinds = {(op["op"], op["position"]) for op in child_ops} assert kinds == {("delete", 2), ("insert", 2)} class TestMerge: def test_one_side_extending_the_game_merges_cleanly(self, tmp_path: pathlib.Path) -> None: plugin = ChessPlugin() root = tmp_path base = _commit_moves(plugin, root, ["e4", "e5"]) left = _commit_moves(plugin, root, ["e4", "e5", "Nf3", "Nc6"]) right = _commit_moves(plugin, root, ["e4", "e5"]) # right added nothing result = plugin.merge(base, left, right, repo_root=root) assert result.conflicts == [] merged_raw = read_object(root, result.merged["files"][_GAME_FILE]) assert merged_raw.decode("utf-8").splitlines() == ["e4", "e5", "Nf3", "Nc6"] def test_two_different_next_moves_is_a_real_variation_conflict( self, tmp_path: pathlib.Path ) -> None: """The actual point of this domain: Nf3 (Ruy Lopez / Italian setups) vs Bc4 (Italian Game) from the same position is not something Muse should silently pick a winner for. """ plugin = ChessPlugin() root = tmp_path base = _commit_moves(plugin, root, ["e4", "e5"]) left = _commit_moves(plugin, root, ["e4", "e5", "Nf3"]) right = _commit_moves(plugin, root, ["e4", "e5", "Bc4"]) result = plugin.merge(base, left, right, repo_root=root) assert result.conflicts == [_GAME_FILE] assert len(result.conflict_records) == 1 assert result.conflict_records[0].conflict_type == "variation" def test_identical_continuations_merge_with_no_conflict(self, tmp_path: pathlib.Path) -> None: plugin = ChessPlugin() root = tmp_path base = _commit_moves(plugin, root, ["e4"]) left = _commit_moves(plugin, root, ["e4", "e5", "Nf3"]) right = _commit_moves(plugin, root, ["e4", "e5", "Nf3"]) result = plugin.merge(base, left, right, repo_root=root) assert result.conflicts == [] merged_raw = read_object(root, result.merged["files"][_GAME_FILE]) assert merged_raw.decode("utf-8").splitlines() == ["e4", "e5", "Nf3"] class TestDrift: def test_uncommitted_move_is_detected(self, tmp_path: pathlib.Path) -> None: plugin = ChessPlugin() root = tmp_path committed = _commit_moves(plugin, root, ["e4"]) (root / _GAME_FILE).write_text("e4\ne5\n") # uncommitted report = plugin.drift(committed, root) assert report.has_drift is True assert report.summary == "1 move(s) played, 0 move(s) removed" def test_clean_tree_has_no_drift(self, tmp_path: pathlib.Path) -> None: plugin = ChessPlugin() root = tmp_path committed = _commit_moves(plugin, root, ["e4"]) report = plugin.drift(committed, root) assert report.has_drift is False