[2] Добавлено:

- Тесты на классы Cell, Maze
- Алгоритмы поиска пути: BFS, DFS, Astar
This commit is contained in:
SerKin0 2026-05-24 17:17:02 +03:00
parent 535c706af8
commit f7577f803c
10 changed files with 484 additions and 25 deletions

View File

@ -103,12 +103,12 @@ class Cell:
Строковый символ, соответствующий текущему типу клетки. Строковый символ, соответствующий текущему типу клетки.
""" """
if self._is_wall: if self._is_wall:
return cell_mapping['wall'] return cell_mapping["wall"]
if self._is_start: if self._is_start:
return cell_mapping['start'] return cell_mapping["start"]
if self._is_exit: if self._is_exit:
return cell_mapping['exit'] return cell_mapping["exit"]
return cell_mapping['empty'] return cell_mapping["empty"]
def __str__(self) -> str: def __str__(self) -> str:
return self._get_type_cell() return self._get_type_cell()
@ -132,8 +132,7 @@ class Maze:
""" """
self._width, self._height = size self._width, self._height = size
self._map: list[list[Cell]] = [ self._map: list[list[Cell]] = [
[Cell(x, y) for x in range(self._width)] [Cell(x, y) for x in range(self._width)] for y in range(self._height)
for y in range(self._height)
] ]
def _check_point_in_map(self, x: int, y: int) -> bool: def _check_point_in_map(self, x: int, y: int) -> bool:
@ -185,6 +184,10 @@ class Maze:
return neighbors return neighbors
@property
def shape(self) -> tuple[int, int]:
return self._height, self._width
def __getitem__(self, index: tuple[int, int]) -> Cell: def __getitem__(self, index: tuple[int, int]) -> Cell:
"""Возвращает клетку по индексу [row, col]. """Возвращает клетку по индексу [row, col].
@ -226,13 +229,13 @@ class Maze:
if cell_type is None: if cell_type is None:
raise ValueError(f"Символ '{value}' не соответствует ни одному типу клетки") raise ValueError(f"Символ '{value}' не соответствует ни одному типу клетки")
if cell_type == 'empty': if cell_type == "empty":
cell._clear_flags() cell._clear_flags()
else: else:
setattr(cell, f"is_{cell_type}", True) setattr(cell, f"is_{cell_type}", True)
def __str__(self) -> str: def __str__(self) -> str:
return '\n'.join( return "\n".join(
''.join(str(self._map[y][x]) for x in range(self._width)) "".join(str(self._map[y][x]) for x in range(self._width))
for y in range(self._height) for y in range(self._height)
) )

View File

@ -1,6 +1 @@
cell_mapping = { cell_mapping = {"wall": "#", "empty": " ", "start": "S", "exit": "E"}
'wall': '#',
'empty': ' ',
'start': 'S',
'exit': 'E'
}

View File

@ -0,0 +1,11 @@
from source.strategy.algorithms import PathFindingStrategy
from source.strategy.astar import AStarStrategy
from source.strategy.bfs import BFSStrategy
from source.strategy.dfs import DFSStrategy
__all__ = [
"PathFindingStrategy",
"BFSStrategy",
"DFSStrategy",
"AStarStrategy",
]

View File

@ -1,5 +1,4 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections import deque
from typing import Optional from typing import Optional
from source.models.base import Maze, Cell from source.models.base import Maze, Cell
@ -9,7 +8,9 @@ class PathFindingStrategy(ABC):
"""Интерфейс стратегии поиска пути в лабиринте.""" """Интерфейс стратегии поиска пути в лабиринте."""
@abstractmethod @abstractmethod
def find_path(self, maze: Maze, start: Cell, exit: Cell) -> list[Cell]: def find_path(
self, maze: Maze, start: Cell = None, exit: Cell = None
) -> list[Cell]:
"""Найти путь от start до exit. """Найти путь от start до exit.
Args: Args:
@ -22,7 +23,27 @@ class PathFindingStrategy(ABC):
Пустой список, если путь не найден. Пустой список, если путь не найден.
""" """
def _reconstruct_path(came_from: dict[Cell, Optional[Cell]], end: Cell) -> list[Cell]: def _find_start(self, maze: Maze) -> Optional[Cell]:
row, col = maze.shape
for y in range(row):
for x in range(col):
if maze[y, x].is_start:
return maze[y, x]
return None
def _find_exit(self, maze: Maze) -> Optional[Cell]:
row, col = maze.shape
for y in range(row):
for x in range(col):
if maze[y, x].is_exit:
return maze[y, x]
return None
def _reconstruct_path(
self, came_from: dict[Cell, Optional[Cell]], end: Cell
) -> list[Cell]:
"""Восстанавливает путь от старта до end, идя по came_from в обратном порядке. """Восстанавливает путь от старта до end, идя по came_from в обратном порядке.
Args: Args:
@ -44,6 +65,10 @@ class PathFindingStrategy(ABC):
path.reverse() path.reverse()
return path return path
class BFS(PathFindingStrategy):
def find_path(self, maze: Maze, start: Cell, exit: Cell) -> list[Cell]:
pass

View File

@ -0,0 +1,46 @@
import heapq
from typing import Optional
from source.models.base import Cell, Maze
from source.strategy.algorithms import PathFindingStrategy
def _manhattan(a: Cell, b: Cell) -> int:
"""Манхэттенское расстояние между двумя клетками."""
return abs(a.x - b.x) + abs(a.y - b.y)
class AStarStrategy(PathFindingStrategy):
"""Алгоритм A* с манхэттенской эвристикой."""
def find_path(self, maze: Maze, start: Optional[Cell] = None, exit: Optional[Cell] = None) -> list[Cell]:
if start is None:
start = self._find_start(maze)
if exit is None:
exit = self._find_exit(maze)
g_score: dict[Cell, int] = {start: 0}
came_from: dict[Cell, Optional[Cell]] = {start: None}
counter = 0
open_heap: list[tuple[int, int, Cell]] = [
(_manhattan(start, exit), counter, start)
]
while open_heap:
_, _, current = heapq.heappop(open_heap)
if current is exit:
return self._reconstruct_path(came_from, exit)
for neighbor in maze.get_neighbors(current.x, current.y):
tentative_g = g_score[current] + 1
if tentative_g < g_score.get(neighbor, float("inf")):
g_score[neighbor] = tentative_g
came_from[neighbor] = current
f = tentative_g + _manhattan(neighbor, exit)
counter += 1
heapq.heappush(open_heap, (f, counter, neighbor))
return []

View File

@ -0,0 +1,37 @@
from collections import deque
from typing import Optional
from source.models.base import Cell, Maze
from source.strategy.algorithms import PathFindingStrategy
class BFSStrategy(PathFindingStrategy):
"""Поиск в ширину (Breadth-First Search).
Гарантирует кратчайший путь по количеству шагов.
Сложность: O(V + E) по времени и памяти.
"""
def find_path(
self, maze: Maze, start: Optional[Cell] = None, exit: Optional[Cell] = None
) -> list[Cell]:
if start is None:
start = self._find_start(maze)
if exit is None:
exit = self._find_exit(maze)
came_from: dict[Cell, Optional[Cell]] = {start: None}
queue: deque[Cell] = deque([start])
while queue:
current = queue.popleft()
if current is exit:
return self._reconstruct_path(came_from, exit)
for neighbor in maze.get_neighbors(current.x, current.y):
if neighbor not in came_from:
came_from[neighbor] = current
queue.append(neighbor)
return []

View File

@ -0,0 +1,35 @@
from typing import Optional
from source.models.base import Maze, Cell
from source.strategy.algorithms import PathFindingStrategy
class DFSStrategy(PathFindingStrategy):
"""Поиск в глубину (Depth-First Search).
Находит путь, но не гарантирует кратчайший.
"""
def find_path(
self, maze: Maze, start: Optional[Cell] = None, exit: Optional[Cell] = None
) -> list[Cell]:
if start is None:
start = self._find_start(maze)
if exit is None:
exit = self._find_exit(maze)
came_from: dict[Cell, Optional[Cell]] = {start: None}
stack: list[Cell] = [start]
while stack:
current = stack.pop()
if current is exit:
return self._reconstruct_path(came_from, exit)
for neighbor in maze.get_neighbors(current.x, current.y):
if neighbor not in came_from:
came_from[neighbor] = current
stack.append(neighbor)
return []

View File

@ -0,0 +1,93 @@
import time
from dataclasses import dataclass
from source.models.base import Maze, Cell
from source.strategy import PathFindingStrategy
@dataclass
class SearchStats:
"""Статистика выполнения поиска пути.
Attributes:
elapsed_ms: Время выполнения в миллисекундах.
visited_count: Количество посещённых клеток.
path_length: Длина найденного пути (0 если путь не найден).
path: Найденный путь список клеток от старта до выхода.
"""
elapsed_ms: float
visited_count: int
path_length: int
path: list[Cell]
def __str__(self) -> str:
return (
f"Время: {self.elapsed_ms:.3f} мс | "
f"Посещено клеток: {self.visited_count} | "
f"Длина пути: {self.path_length}"
)
class MazeSolver:
"""Оркестратор поиска пути в лабиринте.
Принимает лабиринт и стратегию поиска, выполняет поиск
и возвращает результат вместе со статистикой выполнения.
Example:
solver = MazeSolver(maze, BFSStrategy())
stats = solver.solve()
print(stats)
solver.set_strategy(AStarStrategy())
stats = solver.solve()
"""
def __init__(self, maze: Maze, strategy: PathFindingStrategy) -> None:
"""Инициализирует солвер с лабиринтом и стратегией поиска.
Args:
maze: Объект лабиринта.
strategy: Стратегия поиска пути.
"""
self._maze = maze
self._strategy = strategy
def set_strategy(self, strategy: PathFindingStrategy) -> None:
"""Заменяет текущую стратегию поиска.
Args:
strategy: Новая стратегия поиска пути.
"""
self._strategy = strategy
def solve(
self,
start: Cell = None,
exit: Cell = None,
) -> SearchStats:
"""Выполняет поиск пути и собирает статистику.
Если start или exit не переданы явно, стратегия найдёт
их самостоятельно по флагам is_start / is_exit в лабиринте.
Args:
start: Стартовая клетка (опционально).
exit: Конечная клетка (опционально).
Returns:
Объект SearchStats с временем выполнения, количеством
посещённых клеток и длиной найденного пути.
"""
t_start = time.perf_counter()
path = self._strategy.find_path(self._maze, start, exit)
t_end = time.perf_counter()
elapsed_ms = (t_end - t_start) * 1000
return SearchStats(
elapsed_ms=elapsed_ms,
visited_count=len(path),
path_length=len(path),
path=path,
)

View File

@ -1,8 +1,111 @@
import pytest import pytest
import sys import sys
import os import os
import copy
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "./../../models"))) from source.models.base import Cell
from base import Cell class TestCellCreation:
"""Тесты создания клетки и начальных значений."""
def test_coordinates_are_set(self):
cell = Cell(3, 7)
assert cell.x == 3
assert cell.y == 7
def test_default_flags_are_false(self):
cell = Cell(0, 0)
assert cell.is_wall is False
assert cell.is_start is False
assert cell.is_exit is False
def test_create_wall(self):
cell = Cell(0, 0, is_wall=True)
assert cell.is_wall is True
def test_create_start(self):
cell = Cell(0, 0, is_start=True)
assert cell.is_start is True
def test_create_exit(self):
cell = Cell(0, 0, is_exit=True)
assert cell.is_exit is True
class TestCellIsPassable:
"""Тесты метода is_possible."""
def test_empty_cell_is_passable(self):
cell = Cell(0, 0)
assert cell.is_possible() is True
def test_wall_is_not_passable(self):
cell = Cell(0, 0, is_wall=True)
assert cell.is_possible() is False
def test_start_cell_is_passable(self):
cell = Cell(0, 0, is_start=True)
assert cell.is_possible() is True
def test_exit_cell_is_passable(self):
cell = Cell(0, 0, is_exit=True)
assert cell.is_possible() is True
class TestCellFlagsAreMutuallyExclusive:
"""Тесты взаимного исключения флагов."""
def test_set_wall_clears_start(self):
cell = Cell(0, 0, is_start=True)
cell.is_wall = True
assert cell.is_start is False
assert cell.is_wall is True
def test_set_wall_clears_exit(self):
cell = Cell(0, 0, is_exit=True)
cell.is_wall = True
assert cell.is_exit is False
assert cell.is_wall is True
def test_set_start_clears_wall(self):
cell = Cell(0, 0, is_wall=True)
cell.is_start = True
assert cell.is_wall is False
assert cell.is_start is True
def test_set_start_clears_exit(self):
cell = Cell(0, 0, is_exit=True)
cell.is_start = True
assert cell.is_exit is False
assert cell.is_start is True
def test_set_exit_clears_wall(self):
cell = Cell(0, 0, is_wall=True)
cell.is_exit = True
assert cell.is_wall is False
assert cell.is_exit is True
def test_set_exit_clears_start(self):
cell = Cell(0, 0, is_start=True)
cell.is_exit = True
assert cell.is_start is False
assert cell.is_exit is True
def test_unset_wall_does_not_clear_others(self):
# снятие флага (False) не должно трогать остальные
cell = Cell(0, 0, is_wall=True)
cell.is_wall = False
assert cell.is_start is False
assert cell.is_exit is False
class TestCellStr:
"""Тесты строкового представления клетки."""
def test_str_returns_string(self):
cell = Cell(0, 0)
assert isinstance(str(cell), str)
def test_repr_contains_coordinates(self):
cell = Cell(4, 9)
assert "4" in repr(cell)
assert "9" in repr(cell)

View File

@ -0,0 +1,111 @@
import pytest
import random
random.seed("РФ СЛФ!")
from source.models.base import Cell, Maze
from source.settings import cell_mapping
class TestMaze:
def test_default_size(self):
"""Проверка размеров лабиринта со значениями по умолчанию"""
maze = Maze()
row, col = maze.shape
assert row == 10
assert col == 10
def test_custom_size(self):
"""Проверка размеров лабиринта с заданными размерами"""
maze = Maze(size=(7, 3))
assert maze._width == 7
assert maze._height == 3
def test_all_cells_empty_on_init(self):
"""Проверка создания пустого лабиринта с заданными размерами"""
maze = Maze(size=(3, 3))
for y in range(3):
for x in range(3):
cell = maze.get_cell(x, y)
assert not cell.is_wall
assert not cell.is_start
assert not cell.is_exit
def test_get_cell_valid(self):
"""Проверка получения объекта Cell из лабиринта функцией `get_cell()`"""
maze = Maze(size=(5, 5))
assert isinstance(maze.get_cell(2, 3), Cell)
def test_get_cell_out_of_bounds(self):
"""Проверка неправильных указанных индексов лабиринта"""
maze = Maze(size=(5, 5))
assert maze.get_cell(-1, 0) is None
assert maze.get_cell(0, -1) is None
assert maze.get_cell(5, 0) is None
assert maze.get_cell(0, 5) is None
def test_center_has_four_neighbors(self):
"""Проверка нахождения соседей"""
maze = Maze(size=(5, 5))
assert len(maze.get_neighbors(2, 2)) == 4
def test_corner_has_two_neighbors(self):
"""Проверка нахождения соседей, когда указанное поле в углу лабиринта"""
maze = Maze(size=(5, 5))
assert len(maze.get_neighbors(0, 0)) == 2
def test_wall_excluded_from_neighbors(self):
"""Проверка что стена не попадает в список соседей"""
maze = Maze(size=(5, 5))
maze[1, 2] = cell_mapping['wall']
assert all(not n.is_wall for n in maze.get_neighbors(2, 2))
def test_setitem_wall(self):
"""Проверка установки стены через оператор []"""
maze = Maze(size=(5, 5))
maze[0, 0] = cell_mapping['wall']
assert maze[0, 0].is_wall is True
def test_setitem_start(self):
"""Проверка установки старта через оператор []"""
maze = Maze(size=(5, 5))
maze[0, 0] = cell_mapping['start']
assert maze[0, 0].is_start is True
def test_setitem_exit(self):
"""Проверка установки выхода через оператор []"""
maze = Maze(size=(5, 5))
maze[0, 0] = cell_mapping['exit']
assert maze[0, 0].is_exit is True
def test_setitem_empty_clears_flags(self):
"""Проверка сброса флагов клетки при установке пустого типа"""
maze = Maze(size=(5, 5))
maze[0, 0] = cell_mapping['wall']
maze[0, 0] = cell_mapping['empty']
assert not maze[0, 0].is_wall
def test_getitem_out_of_bounds_raises(self):
"""Проверка выброса IndexError при обращении к клетке вне границ лабиринта"""
maze = Maze(size=(5, 5))
with pytest.raises(IndexError):
_ = maze[10, 10]
def test_setitem_invalid_symbol_raises(self):
"""Проверка выброса ValueError при установке неизвестного символа"""
maze = Maze(size=(5, 5))
with pytest.raises(ValueError):
maze[0, 0] = "?"
def test_str_lines_match_height(self):
"""Проверка что количество строк в строковом представлении совпадает с высотой"""
maze = Maze(size=(4, 6))
print(str(maze).splitlines())
assert len(str(maze).splitlines()) == 6
def test_str_line_length_matches_width(self):
"""Проверка что длина каждой строки в строковом представлении совпадает с шириной"""
maze = Maze(size=(5, 3))
for line in str(maze).strip().splitlines():
assert len(line) == 5