mirror of
https://github.com/tiennm99/ai-coding-workflow-labs.git
synced 2026-09-17 02:20:46 +00:00
feat(03-01): implement BFS pathfinding with turn counting
- Created PathFinder class with static findPath method - Implements BFS algorithm with turn tracking (max 2 turns default) - Direction encoding: -1=none/start, 0=up, 1=right, 2=down, 3=left - Visited state tracking using 'row,col,direction' keys to avoid cycles - Turn counting: only direction changes count, first move doesn't count - Boundary checking using CONFIG.grid.rows and CONFIG.grid.cols - Passable tile check: only cleared tiles are traversable - Returns PathNode with path array and turn count, or null if no path - Added 10 comprehensive test cases covering: * 0-turn paths (direct horizontal/vertical) * 1-turn L-shaped paths * 2-turn Z-shaped paths * 3+ turn rejection * Blocked path detection * Path inclusion of start/end positions * Correct turn counting behavior
This commit is contained in:
@@ -1,6 +1,27 @@
|
||||
// src/__tests__/PathFinder.test.ts - Tests for PathFinder class
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PathNode, MatchResult } from '../types';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { PathNode, MatchResult, Tile, TilePosition } from '../types';
|
||||
import { PathFinder } from '../matching/PathFinder';
|
||||
import { CONFIG } from '../config';
|
||||
|
||||
// Helper function to create a test grid
|
||||
function createTestGrid(clearedPositions: TilePosition[][]): Tile[][] {
|
||||
const grid: Tile[][] = [];
|
||||
for (let row = 0; row < CONFIG.grid.rows; row++) {
|
||||
grid[row] = [];
|
||||
for (let col = 0; col < CONFIG.grid.cols; col++) {
|
||||
// Check if this position should be cleared
|
||||
const isCleared = clearedPositions.some(pos => pos[0].row === row && pos[0].col === col);
|
||||
grid[row][col] = {
|
||||
id: `tile-${row}-${col}`,
|
||||
type: 0,
|
||||
position: { row, col },
|
||||
cleared: isCleared
|
||||
};
|
||||
}
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
describe('PathFinder Types', () => {
|
||||
describe('PathNode interface', () => {
|
||||
@@ -106,3 +127,202 @@ describe('PathFinder Types', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PathFinder', () => {
|
||||
describe('Test 1: Direct horizontal path (0 turns)', () => {
|
||||
it('should find path when tiles are on same row with cleared tiles between', () => {
|
||||
// Create grid with cleared path from (0,0) to (0,4)
|
||||
const clearedPositions = [
|
||||
[{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }, { row: 0, col: 3 }, { row: 0, col: 4 }]
|
||||
];
|
||||
const grid = createTestGrid(clearedPositions);
|
||||
|
||||
const start = { row: 0, col: 0 };
|
||||
const end = { row: 0, col: 4 };
|
||||
|
||||
const result = PathFinder.findPath(start, end, grid);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.turns).toBe(0);
|
||||
expect(result!.path).toHaveLength(5);
|
||||
expect(result!.path[0]).toEqual(start);
|
||||
expect(result!.path[4]).toEqual(end);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 2: Direct vertical path (0 turns)', () => {
|
||||
it('should find path when tiles are on same column with cleared tiles between', () => {
|
||||
const clearedPositions = [
|
||||
[{ row: 0, col: 0 }, { row: 1, col: 0 }, { row: 2, col: 0 }, { row: 3, col: 0 }, { row: 4, col: 0 }]
|
||||
];
|
||||
const grid = createTestGrid(clearedPositions);
|
||||
|
||||
const start = { row: 0, col: 0 };
|
||||
const end = { row: 4, col: 0 };
|
||||
|
||||
const result = PathFinder.findPath(start, end, grid);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.turns).toBe(0);
|
||||
expect(result!.path).toHaveLength(5);
|
||||
expect(result!.path[0]).toEqual(start);
|
||||
expect(result!.path[4]).toEqual(end);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 3: L-shaped path (1 turn)', () => {
|
||||
it('should find path with one turn around a corner', () => {
|
||||
// Create L-shaped path: (0,0) -> (0,2) -> (2,2)
|
||||
const clearedPositions = [
|
||||
[{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }],
|
||||
[{ row: 1, col: 2 }],
|
||||
[{ row: 2, col: 2 }]
|
||||
];
|
||||
const grid = createTestGrid(clearedPositions);
|
||||
|
||||
const start = { row: 0, col: 0 };
|
||||
const end = { row: 2, col: 2 };
|
||||
|
||||
const result = PathFinder.findPath(start, end, grid);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.turns).toBe(1);
|
||||
expect(result!.path[0]).toEqual(start);
|
||||
expect(result!.path[result!.path.length - 1]).toEqual(end);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 4: Z-shaped path (2 turns)', () => {
|
||||
it('should find path with two turns (Z-shaped)', () => {
|
||||
// Create Z-shaped path: (0,0) -> (0,2) -> (2,2) -> (2,4)
|
||||
const clearedPositions = [
|
||||
[{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }],
|
||||
[{ row: 1, col: 2 }],
|
||||
[{ row: 2, col: 2 }, { row: 2, col: 3 }, { row: 2, col: 4 }]
|
||||
];
|
||||
const grid = createTestGrid(clearedPositions);
|
||||
|
||||
const start = { row: 0, col: 0 };
|
||||
const end = { row: 2, col: 4 };
|
||||
|
||||
const result = PathFinder.findPath(start, end, grid);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.turns).toBe(2);
|
||||
expect(result!.path[0]).toEqual(start);
|
||||
expect(result!.path[result!.path.length - 1]).toEqual(end);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 5: Path with 3 turns is rejected', () => {
|
||||
it('should return null when path requires 3 or more turns', () => {
|
||||
// Create a path that requires 3 turns
|
||||
const clearedPositions = [
|
||||
[{ row: 0, col: 0 }, { row: 0, col: 1 }],
|
||||
[{ row: 1, col: 1 }, { row: 2, col: 1 }],
|
||||
[{ row: 2, col: 2 }],
|
||||
[{ row: 3, col: 2 }, { row: 4, col: 2 }]
|
||||
];
|
||||
const grid = createTestGrid(clearedPositions);
|
||||
|
||||
const start = { row: 0, col: 0 };
|
||||
const end = { row: 4, col: 2 };
|
||||
|
||||
const result = PathFinder.findPath(start, end, grid, 2);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 6: Path through uncleared tile is rejected', () => {
|
||||
it('should return null when direct path is blocked by uncleared tile', () => {
|
||||
// Clear only start and end, but not the middle
|
||||
const clearedPositions = [
|
||||
[{ row: 0, col: 0 }],
|
||||
[{ row: 0, col: 2 }]
|
||||
];
|
||||
const grid = createTestGrid(clearedPositions);
|
||||
|
||||
const start = { row: 0, col: 0 };
|
||||
const end = { row: 0, col: 2 };
|
||||
|
||||
const result = PathFinder.findPath(start, end, grid);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 7: No path exists returns null', () => {
|
||||
it('should return null when no valid path exists', () => {
|
||||
// Clear only isolated tiles with no connection
|
||||
const clearedPositions = [
|
||||
[{ row: 0, col: 0 }],
|
||||
[{ row: 5, col: 5 }]
|
||||
];
|
||||
const grid = createTestGrid(clearedPositions);
|
||||
|
||||
const start = { row: 0, col: 0 };
|
||||
const end = { row: 5, col: 5 };
|
||||
|
||||
const result = PathFinder.findPath(start, end, grid);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 8: Returns path including start and end positions', () => {
|
||||
it('should include both start and end positions in the path', () => {
|
||||
const clearedPositions = [
|
||||
[{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }]
|
||||
];
|
||||
const grid = createTestGrid(clearedPositions);
|
||||
|
||||
const start = { row: 0, col: 0 };
|
||||
const end = { row: 0, col: 2 };
|
||||
|
||||
const result = PathFinder.findPath(start, end, grid);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.path[0]).toEqual(start);
|
||||
expect(result!.path[result!.path.length - 1]).toEqual(end);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 9: Correctly counts turns (direction changes only)', () => {
|
||||
it('should count only direction changes as turns', () => {
|
||||
// Path: right 3 steps, down 1 step, right 2 steps = 1 turn
|
||||
const clearedPositions = [
|
||||
[{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }, { row: 0, col: 3 }],
|
||||
[{ row: 1, col: 3 }],
|
||||
[{ row: 1, col: 4 }, { row: 1, col: 5 }]
|
||||
];
|
||||
const grid = createTestGrid(clearedPositions);
|
||||
|
||||
const start = { row: 0, col: 0 };
|
||||
const end = { row: 1, col: 5 };
|
||||
|
||||
const result = PathFinder.findPath(start, end, grid);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.turns).toBe(1); // Only one direction change (right -> down)
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 10: Start position with direction=-1 has 0 turns', () => {
|
||||
it('should not count the first move as a turn', () => {
|
||||
// Moving in any direction from start should be 0 turns initially
|
||||
const clearedPositions = [
|
||||
[{ row: 0, col: 0 }, { row: 0, col: 1 }]
|
||||
];
|
||||
const grid = createTestGrid(clearedPositions);
|
||||
|
||||
const start = { row: 0, col: 0 };
|
||||
const end = { row: 0, col: 1 };
|
||||
|
||||
const result = PathFinder.findPath(start, end, grid);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.turns).toBe(0); // First move doesn't count as turn
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// src/matching/PathFinder.ts - BFS pathfinding algorithm with turn counting
|
||||
import { TilePosition, Tile, PathNode } from '../types';
|
||||
import { CONFIG } from '../config';
|
||||
|
||||
/**
|
||||
* PathFinder implements BFS pathfinding with turn constraints
|
||||
* Finds paths between two tiles with maximum 2 turns (3 straight lines)
|
||||
*/
|
||||
export class PathFinder {
|
||||
/**
|
||||
* Direction encoding: 0=up, 1=right, 2=down, 3=left
|
||||
* Using these deltas: row changes by -1/+1, col changes by -1/+1
|
||||
*/
|
||||
private static readonly DIRECTIONS = [
|
||||
{ row: -1, col: 0 }, // 0: up
|
||||
{ row: 0, col: 1 }, // 1: right
|
||||
{ row: 1, col: 0 }, // 2: down
|
||||
{ row: 0, col: -1 } // 3: left
|
||||
];
|
||||
|
||||
/**
|
||||
* Finds a valid path between two tiles with maximum turns constraint
|
||||
* @param start - Starting tile position
|
||||
* @param end - Ending tile position
|
||||
* @param grid - 2D array of tiles
|
||||
* @param maxTurns - Maximum allowed turns (default: 2)
|
||||
* @returns PathNode if valid path found, null otherwise
|
||||
*/
|
||||
static findPath(
|
||||
start: TilePosition,
|
||||
end: TilePosition,
|
||||
grid: Tile[][],
|
||||
maxTurns: number = 2
|
||||
): PathNode | null {
|
||||
// Initialize BFS queue with start node
|
||||
// Start with direction=-1 (no direction yet), 0 turns, path containing start position
|
||||
const queue: PathNode[] = [{
|
||||
row: start.row,
|
||||
col: start.col,
|
||||
direction: -1, // No direction yet (first move doesn't count as turn)
|
||||
turns: 0,
|
||||
path: [{ row: start.row, col: start.col }]
|
||||
}];
|
||||
|
||||
// Track visited states to avoid cycles
|
||||
// State key: "row,col,direction" - same position with different direction is different state
|
||||
const visited = new Set<string>();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentNode = queue.shift()!;
|
||||
const { row, col, direction, turns, path } = currentNode;
|
||||
|
||||
// Check if we reached the destination
|
||||
if (row === end.row && col === end.col) {
|
||||
return currentNode;
|
||||
}
|
||||
|
||||
// Skip if already exceeded max turns
|
||||
if (turns > maxTurns) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try all 4 directions
|
||||
for (let newDirection = 0; newDirection < 4; newDirection++) {
|
||||
const newRow = row + PathFinder.DIRECTIONS[newDirection].row;
|
||||
const newCol = col + PathFinder.DIRECTIONS[newDirection].col;
|
||||
|
||||
// Check bounds
|
||||
if (newRow < 0 || newRow >= CONFIG.grid.rows ||
|
||||
newCol < 0 || newCol >= CONFIG.grid.cols) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if passable (tile must be cleared)
|
||||
const tile = grid[newRow][newCol];
|
||||
if (!tile.cleared) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate turn increment
|
||||
// First move (direction=-1) doesn't count as turn
|
||||
// Changing direction counts as turn, continuing straight doesn't
|
||||
let turnIncrement = 0;
|
||||
if (direction !== -1 && newDirection !== direction) {
|
||||
turnIncrement = 1;
|
||||
}
|
||||
|
||||
const newTurns = turns + turnIncrement;
|
||||
|
||||
// Create state key for visited tracking
|
||||
const stateKey = `${newRow},${newCol},${newDirection}`;
|
||||
|
||||
// Skip if this state already visited
|
||||
if (visited.has(stateKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark as visited
|
||||
visited.add(stateKey);
|
||||
|
||||
// Add to queue with extended path
|
||||
queue.push({
|
||||
row: newRow,
|
||||
col: newCol,
|
||||
direction: newDirection,
|
||||
turns: newTurns,
|
||||
path: [...path, { row: newRow, col: newCol }]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// No valid path found
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user