import pytest from gridedit import ( cell_column, cell_line, edit, h_segment_column, wall_column_left_of, wall_line_above, ) from dojo.board import ( BOARD_SIZE, Board, BoardError, Pos, build_board, edge, load_board, parse_legend, quadrant_of, rotate, ) from dojo.grid import parse_grid, render_grid def build(text: str, offsets: dict[str, int] | None = None) -> Board: return build_board(parse_grid(text), offsets) def build_problems(text: str, offsets: dict[str, int] | None = None) -> list[str]: with pytest.raises(BoardError) as raised: build(text, offsets) return raised.value.problems def test_rotation_is_clockwise_around_the_centre() -> None: corner = Pos(0, 0) assert [rotate(corner, k) for k in range(4)] == [(0, 0), (0, 15), (15, 15), (15, 0)] assert [quadrant_of(rotate(Pos(3, 5), k)) for k in range(4)] == [0, 1, 2, 3] assert rotate(Pos(3, 5), 4) == Pos(3, 5) def test_board_is_sixteen_by_sixteen_with_central_dojo(board: Board) -> None: assert board.size == BOARD_SIZE assert board.dojo == {Pos(r, c) for r in range(6, 10) for c in range(6, 10)} def test_every_cell_matches_its_rotated_source(board: Board, quadrant_text: str) -> None: quadrant = parse_grid(quadrant_text) for r in range(8): for c in range(8): for k in range(4): assert board.code(rotate(Pos(r, c), k)) == quadrant.cells[r][c] def test_walls_are_rotated(board: Board) -> None: safe_room_east_wall = edge(Pos(0, 2), Pos(0, 3)) for k in range(4): a, b = safe_room_east_wall assert edge(rotate(a, k), rotate(b, k)) in board.walls assert edge(Pos(1, 2), Pos(1, 3)) not in board.walls def test_neighbours_respect_walls_and_border(board: Board) -> None: assert set(board.neighbours(Pos(0, 0))) == {Pos(0, 1), Pos(1, 0)} assert Pos(0, 3) not in set(board.neighbours(Pos(0, 2))) assert Pos(1, 3) in set(board.neighbours(Pos(1, 2))) def test_neighbours_come_north_east_south_west(board: Board) -> None: square = Pos(1, 2) compass = [Pos(0, 2), Pos(1, 3), Pos(2, 2), Pos(1, 1)] assert list(board.neighbours(square)) == [q for q in compass if q in board.neighbours(square)] def test_square_lookups_without_matches_are_empty(board: Board) -> None: assert board.cells_with("unknown") == frozenset() assert board.safe_room(4) == frozenset() def test_seams_join_neighbouring_quadrants(board: Board) -> None: assert Pos(0, 8) in set(board.neighbours(Pos(0, 7))) assert Pos(8, 0) in set(board.neighbours(Pos(7, 0))) def test_to_grid_round_trips_through_render(board: Board) -> None: grid = board.to_grid() assert parse_grid(render_grid(grid)) == grid def test_tunnels_pair_within_the_quadrant_by_default(board: Board) -> None: a, b = Pos(1, 6), Pos(3, 4) for k in range(4): assert board.tunnels[rotate(a, k)] == rotate(b, k) assert board.tunnels[rotate(b, k)] == rotate(a, k) assert len(board.tunnels) == 8 @pytest.mark.parametrize("offset", [1, 2, 3]) def test_tunnel_offset_links_the_first_cell_to_a_later_quadrant( quadrant_text: str, offset: int ) -> None: board = build(quadrant_text, {"1": offset}) a, b = Pos(1, 6), Pos(3, 4) for k in range(4): assert board.tunnels[rotate(a, k)] == rotate(b, k + offset) assert board.tunnels[rotate(b, k + offset)] == rotate(a, k) assert all(board.tunnels[board.tunnels[p]] == p for p in board.tunnels) def test_quadrant_must_be_eight_by_eight() -> None: small = "+---+\n| D |\n+---+\n" assert build_problems(small) == ["quadrant must be 8x8, got 1x1"] def test_unknown_cell_code(quadrant_text: str) -> None: text = edit(quadrant_text, cell_line(4), cell_column(4), "X") assert "cell (4, 4): unknown code 'X'" in build_problems(text) def test_outer_border_must_be_walled(quadrant_text: str) -> None: open_top = edit(quadrant_text, wall_line_above(0), h_segment_column(4), " ") open_left = edit(quadrant_text, cell_line(4), wall_column_left_of(0), " ") assert any("top border" in p for p in build_problems(open_top)) assert any("left border" in p for p in build_problems(open_left)) def test_seams_must_match(quadrant_text: str) -> None: text = edit(quadrant_text, cell_line(2), wall_column_left_of(8), "|") assert any("seam mismatch: right border at row 2" in p for p in build_problems(text)) def test_matching_seam_walls_are_accepted(quadrant_text: str) -> None: text = edit(quadrant_text, cell_line(2), wall_column_left_of(8), "|") text = edit(text, wall_line_above(8), h_segment_column(2), "---") board = build(text) assert edge(Pos(2, 7), Pos(2, 8)) in board.walls assert edge(Pos(7, 2), Pos(8, 2)) in board.walls def test_dojo_cells_are_fixed(quadrant_text: str) -> None: text = edit(quadrant_text, cell_line(5), cell_column(7), "D") assert any("Dojo cells must be exactly" in p for p in build_problems(text)) def test_tunnel_needs_exactly_two_cells(quadrant_text: str) -> None: text = edit(quadrant_text, cell_line(4), cell_column(4), "1") assert "tunnel '1' must appear on exactly 2 cells, found 3" in build_problems(text) def test_legend_offset_must_be_valid(quadrant_text: str) -> None: problems = build_problems(quadrant_text, {"1": 4, "2": 0}) assert "tunnel '1': quadrant_offset must be 0..3, got 4" in problems assert "legend links tunnel '2', which is not on the quadrant" in problems def test_parse_legend() -> None: assert parse_legend({"tunnels": {"1": {"quadrant_offset": 2}}}) == {"1": 2} assert parse_legend({}) == {} @pytest.mark.parametrize( ("data", "message"), [ ({"extra": {}}, "unknown sections"), ({"tunnels": 3}, "must be a table"), ({"tunnels": {"a": {"quadrant_offset": 1}}}, "must be a digit"), ({"tunnels": {"1": {"offset": 1}}}, "only quadrant_offset"), ], ) def test_parse_legend_errors(data: dict[str, object], message: str) -> None: with pytest.raises(BoardError) as raised: parse_legend(data) assert any(message in p for p in raised.value.problems) def test_load_board_reads_legend(tmp_path, quadrant_text: str) -> None: quadrant = tmp_path / "quadrant.txt" legend = tmp_path / "legend.toml" quadrant.write_text(quadrant_text, encoding="utf-8") legend.write_text("[tunnels.1]\nquadrant_offset = 1\n", encoding="utf-8") board = load_board(quadrant, legend) assert board.tunnels[Pos(1, 6)] == rotate(Pos(3, 4), 1)