feat(framework): server testing utils, transaction storage simplify, pathfinding tests (#384)
## Server Testing Utils - Add TestServer, TestClient, MockRoom for unit testing - Export testing utilities from @esengine/server/testing ## Transaction Storage (BREAKING) - Simplify RedisStorage/MongoStorage to factory pattern only - Remove DI client injection option - Add lazy connection and Symbol.asyncDispose support - Add 161 unit tests with full coverage ## Pathfinding Tests - Add 150 unit tests covering all components - BinaryHeap, Heuristics, AStarPathfinder, GridMap, NavMesh, PathSmoother ## Docs - Update storage.md for new factory pattern API
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { AStarPathfinder } from '../../src/core/AStarPathfinder';
|
||||
import { GridMap } from '../../src/grid/GridMap';
|
||||
|
||||
describe('AStarPathfinder', () => {
|
||||
let grid: GridMap;
|
||||
let pathfinder: AStarPathfinder;
|
||||
|
||||
beforeEach(() => {
|
||||
grid = new GridMap(10, 10);
|
||||
pathfinder = new AStarPathfinder(grid);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Basic Pathfinding
|
||||
// =========================================================================
|
||||
|
||||
describe('basic pathfinding', () => {
|
||||
it('should find path between adjacent nodes', () => {
|
||||
const result = pathfinder.findPath(0, 0, 1, 0);
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.path.length).toBe(2);
|
||||
expect(result.path[0]).toEqual({ x: 0, y: 0 });
|
||||
expect(result.path[1]).toEqual({ x: 1, y: 0 });
|
||||
});
|
||||
|
||||
it('should return start position for same start and end', () => {
|
||||
const result = pathfinder.findPath(5, 5, 5, 5);
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.path.length).toBe(1);
|
||||
expect(result.path[0]).toEqual({ x: 5, y: 5 });
|
||||
expect(result.cost).toBe(0);
|
||||
});
|
||||
|
||||
it('should find diagonal path', () => {
|
||||
const result = pathfinder.findPath(0, 0, 5, 5);
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.path.length).toBeGreaterThan(1);
|
||||
expect(result.path[0]).toEqual({ x: 0, y: 0 });
|
||||
expect(result.path[result.path.length - 1]).toEqual({ x: 5, y: 5 });
|
||||
});
|
||||
|
||||
it('should find path across grid', () => {
|
||||
const result = pathfinder.findPath(0, 0, 9, 9);
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.path[0]).toEqual({ x: 0, y: 0 });
|
||||
expect(result.path[result.path.length - 1]).toEqual({ x: 9, y: 9 });
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Obstacles
|
||||
// =========================================================================
|
||||
|
||||
describe('obstacles', () => {
|
||||
it('should find path around single obstacle', () => {
|
||||
grid.setWalkable(5, 5, false);
|
||||
const result = pathfinder.findPath(4, 5, 6, 5);
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.path.length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it('should find path around wall', () => {
|
||||
// Create vertical wall
|
||||
for (let y = 2; y <= 7; y++) {
|
||||
grid.setWalkable(5, y, false);
|
||||
}
|
||||
|
||||
const result = pathfinder.findPath(3, 5, 7, 5);
|
||||
expect(result.found).toBe(true);
|
||||
// Path should go around the wall
|
||||
expect(result.path.every(p => p.x !== 5 || p.y < 2 || p.y > 7)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty path when blocked', () => {
|
||||
// Block completely around start
|
||||
grid.setWalkable(1, 0, false);
|
||||
grid.setWalkable(0, 1, false);
|
||||
grid.setWalkable(1, 1, false);
|
||||
|
||||
const result = pathfinder.findPath(0, 0, 9, 9);
|
||||
expect(result.found).toBe(false);
|
||||
expect(result.path.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty path when start is blocked', () => {
|
||||
grid.setWalkable(0, 0, false);
|
||||
const result = pathfinder.findPath(0, 0, 5, 5);
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
|
||||
it('should return empty path when end is blocked', () => {
|
||||
grid.setWalkable(5, 5, false);
|
||||
const result = pathfinder.findPath(0, 0, 5, 5);
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Out of Bounds
|
||||
// =========================================================================
|
||||
|
||||
describe('out of bounds', () => {
|
||||
it('should return empty path for out of bounds start', () => {
|
||||
const result = pathfinder.findPath(-1, 0, 5, 5);
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
|
||||
it('should return empty path for out of bounds end', () => {
|
||||
const result = pathfinder.findPath(0, 0, 100, 100);
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Cost Calculation
|
||||
// =========================================================================
|
||||
|
||||
describe('cost calculation', () => {
|
||||
it('should calculate correct cost for straight path', () => {
|
||||
const grid4 = new GridMap(10, 10, { allowDiagonal: false });
|
||||
const pathfinder4 = new AStarPathfinder(grid4);
|
||||
|
||||
const result = pathfinder4.findPath(0, 0, 5, 0);
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.cost).toBe(5);
|
||||
});
|
||||
|
||||
it('should prefer lower cost paths', () => {
|
||||
// Create high cost area
|
||||
for (let y = 0; y < 10; y++) {
|
||||
grid.setCost(5, y, 10);
|
||||
}
|
||||
|
||||
const result = pathfinder.findPath(4, 5, 6, 5);
|
||||
// Should go around the high cost column if possible
|
||||
expect(result.found).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Options
|
||||
// =========================================================================
|
||||
|
||||
describe('options', () => {
|
||||
it('should respect maxNodes limit', () => {
|
||||
// Large grid with path
|
||||
const largeGrid = new GridMap(100, 100);
|
||||
const largePF = new AStarPathfinder(largeGrid);
|
||||
|
||||
const result = largePF.findPath(0, 0, 99, 99, { maxNodes: 10 });
|
||||
// Should fail due to node limit
|
||||
expect(result.nodesSearched).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('should use heuristic weight', () => {
|
||||
const result1 = pathfinder.findPath(0, 0, 9, 9, { heuristicWeight: 1.0 });
|
||||
const result2 = pathfinder.findPath(0, 0, 9, 9, { heuristicWeight: 2.0 });
|
||||
|
||||
expect(result1.found).toBe(true);
|
||||
expect(result2.found).toBe(true);
|
||||
// Higher weight may search fewer nodes but may not be optimal
|
||||
expect(result2.nodesSearched).toBeLessThanOrEqual(result1.nodesSearched);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Clear
|
||||
// =========================================================================
|
||||
|
||||
describe('clear', () => {
|
||||
it('should allow reuse after clear', () => {
|
||||
const result1 = pathfinder.findPath(0, 0, 5, 5);
|
||||
expect(result1.found).toBe(true);
|
||||
|
||||
pathfinder.clear();
|
||||
|
||||
const result2 = pathfinder.findPath(0, 0, 9, 9);
|
||||
expect(result2.found).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Maze
|
||||
// =========================================================================
|
||||
|
||||
describe('maze solving', () => {
|
||||
it('should solve simple maze', () => {
|
||||
const mazeStr = `
|
||||
..........
|
||||
.########.
|
||||
..........
|
||||
.########.
|
||||
..........
|
||||
.########.
|
||||
..........
|
||||
.########.
|
||||
..........
|
||||
..........`.trim();
|
||||
|
||||
grid.loadFromString(mazeStr);
|
||||
|
||||
const result = pathfinder.findPath(0, 0, 9, 9);
|
||||
expect(result.found).toBe(true);
|
||||
// Path should not pass through walls
|
||||
for (const point of result.path) {
|
||||
expect(grid.isWalkable(point.x, point.y)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Path Quality
|
||||
// =========================================================================
|
||||
|
||||
describe('path quality', () => {
|
||||
it('should find shortest path in open area', () => {
|
||||
const result = pathfinder.findPath(0, 0, 3, 0);
|
||||
expect(result.found).toBe(true);
|
||||
// Straight line should be 4 points
|
||||
expect(result.path.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should find optimal diagonal path', () => {
|
||||
const result = pathfinder.findPath(0, 0, 3, 3);
|
||||
expect(result.found).toBe(true);
|
||||
// Pure diagonal should be 4 points
|
||||
expect(result.path.length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Nodes Searched
|
||||
// =========================================================================
|
||||
|
||||
describe('nodesSearched', () => {
|
||||
it('should track nodes searched', () => {
|
||||
const result = pathfinder.findPath(0, 0, 9, 9);
|
||||
expect(result.nodesSearched).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should search only 1 node for same position', () => {
|
||||
const result = pathfinder.findPath(5, 5, 5, 5);
|
||||
expect(result.nodesSearched).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
228
packages/framework/pathfinding/__tests__/core/BinaryHeap.test.ts
Normal file
228
packages/framework/pathfinding/__tests__/core/BinaryHeap.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { BinaryHeap } from '../../src/core/BinaryHeap';
|
||||
|
||||
describe('BinaryHeap', () => {
|
||||
let heap: BinaryHeap<number>;
|
||||
|
||||
beforeEach(() => {
|
||||
heap = new BinaryHeap<number>((a, b) => a - b);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Basic Operations
|
||||
// =========================================================================
|
||||
|
||||
describe('basic operations', () => {
|
||||
it('should start empty', () => {
|
||||
expect(heap.isEmpty).toBe(true);
|
||||
expect(heap.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should push and pop single element', () => {
|
||||
heap.push(5);
|
||||
expect(heap.isEmpty).toBe(false);
|
||||
expect(heap.size).toBe(1);
|
||||
expect(heap.pop()).toBe(5);
|
||||
expect(heap.isEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it('should return undefined when popping empty heap', () => {
|
||||
expect(heap.pop()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should peek without removing', () => {
|
||||
heap.push(5);
|
||||
expect(heap.peek()).toBe(5);
|
||||
expect(heap.size).toBe(1);
|
||||
});
|
||||
|
||||
it('should return undefined when peeking empty heap', () => {
|
||||
expect(heap.peek()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Min-Heap Property
|
||||
// =========================================================================
|
||||
|
||||
describe('min-heap property', () => {
|
||||
it('should always pop minimum element', () => {
|
||||
heap.push(5);
|
||||
heap.push(3);
|
||||
heap.push(7);
|
||||
heap.push(1);
|
||||
heap.push(9);
|
||||
|
||||
expect(heap.pop()).toBe(1);
|
||||
expect(heap.pop()).toBe(3);
|
||||
expect(heap.pop()).toBe(5);
|
||||
expect(heap.pop()).toBe(7);
|
||||
expect(heap.pop()).toBe(9);
|
||||
});
|
||||
|
||||
it('should handle duplicate values', () => {
|
||||
heap.push(3);
|
||||
heap.push(3);
|
||||
heap.push(3);
|
||||
|
||||
expect(heap.pop()).toBe(3);
|
||||
expect(heap.pop()).toBe(3);
|
||||
expect(heap.pop()).toBe(3);
|
||||
expect(heap.isEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle already sorted input', () => {
|
||||
heap.push(1);
|
||||
heap.push(2);
|
||||
heap.push(3);
|
||||
heap.push(4);
|
||||
heap.push(5);
|
||||
|
||||
expect(heap.pop()).toBe(1);
|
||||
expect(heap.pop()).toBe(2);
|
||||
expect(heap.pop()).toBe(3);
|
||||
expect(heap.pop()).toBe(4);
|
||||
expect(heap.pop()).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle reverse sorted input', () => {
|
||||
heap.push(5);
|
||||
heap.push(4);
|
||||
heap.push(3);
|
||||
heap.push(2);
|
||||
heap.push(1);
|
||||
|
||||
expect(heap.pop()).toBe(1);
|
||||
expect(heap.pop()).toBe(2);
|
||||
expect(heap.pop()).toBe(3);
|
||||
expect(heap.pop()).toBe(4);
|
||||
expect(heap.pop()).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Update Operation
|
||||
// =========================================================================
|
||||
|
||||
describe('update operation', () => {
|
||||
it('should update element position after value change', () => {
|
||||
interface Item { value: number }
|
||||
const itemHeap = new BinaryHeap<Item>((a, b) => a.value - b.value);
|
||||
|
||||
const item1 = { value: 5 };
|
||||
const item2 = { value: 3 };
|
||||
const item3 = { value: 7 };
|
||||
|
||||
itemHeap.push(item1);
|
||||
itemHeap.push(item2);
|
||||
itemHeap.push(item3);
|
||||
|
||||
// Change item1 to be smallest
|
||||
item1.value = 1;
|
||||
itemHeap.update(item1);
|
||||
|
||||
expect(itemHeap.pop()).toBe(item1);
|
||||
expect(itemHeap.pop()).toBe(item2);
|
||||
expect(itemHeap.pop()).toBe(item3);
|
||||
});
|
||||
|
||||
it('should handle update of non-existent element gracefully', () => {
|
||||
heap.push(1);
|
||||
heap.push(2);
|
||||
heap.update(999); // Should not throw
|
||||
expect(heap.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Contains Operation
|
||||
// =========================================================================
|
||||
|
||||
describe('contains operation', () => {
|
||||
it('should check if element exists', () => {
|
||||
heap.push(1);
|
||||
heap.push(2);
|
||||
heap.push(3);
|
||||
|
||||
expect(heap.contains(2)).toBe(true);
|
||||
expect(heap.contains(5)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty heap', () => {
|
||||
expect(heap.contains(1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Clear Operation
|
||||
// =========================================================================
|
||||
|
||||
describe('clear operation', () => {
|
||||
it('should clear all elements', () => {
|
||||
heap.push(1);
|
||||
heap.push(2);
|
||||
heap.push(3);
|
||||
|
||||
heap.clear();
|
||||
|
||||
expect(heap.isEmpty).toBe(true);
|
||||
expect(heap.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Custom Comparator
|
||||
// =========================================================================
|
||||
|
||||
describe('custom comparator', () => {
|
||||
it('should work as max-heap with reversed comparator', () => {
|
||||
const maxHeap = new BinaryHeap<number>((a, b) => b - a);
|
||||
|
||||
maxHeap.push(5);
|
||||
maxHeap.push(3);
|
||||
maxHeap.push(7);
|
||||
maxHeap.push(1);
|
||||
maxHeap.push(9);
|
||||
|
||||
expect(maxHeap.pop()).toBe(9);
|
||||
expect(maxHeap.pop()).toBe(7);
|
||||
expect(maxHeap.pop()).toBe(5);
|
||||
expect(maxHeap.pop()).toBe(3);
|
||||
expect(maxHeap.pop()).toBe(1);
|
||||
});
|
||||
|
||||
it('should work with object comparator', () => {
|
||||
interface Task { priority: number; name: string }
|
||||
const taskHeap = new BinaryHeap<Task>((a, b) => a.priority - b.priority);
|
||||
|
||||
taskHeap.push({ priority: 3, name: 'C' });
|
||||
taskHeap.push({ priority: 1, name: 'A' });
|
||||
taskHeap.push({ priority: 2, name: 'B' });
|
||||
|
||||
expect(taskHeap.pop()?.name).toBe('A');
|
||||
expect(taskHeap.pop()?.name).toBe('B');
|
||||
expect(taskHeap.pop()?.name).toBe('C');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Large Dataset
|
||||
// =========================================================================
|
||||
|
||||
describe('large dataset', () => {
|
||||
it('should handle 1000 random elements', () => {
|
||||
const elements: number[] = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const value = Math.floor(Math.random() * 10000);
|
||||
elements.push(value);
|
||||
heap.push(value);
|
||||
}
|
||||
|
||||
elements.sort((a, b) => a - b);
|
||||
|
||||
for (const expected of elements) {
|
||||
expect(heap.pop()).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
219
packages/framework/pathfinding/__tests__/core/Heuristics.test.ts
Normal file
219
packages/framework/pathfinding/__tests__/core/Heuristics.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
manhattanDistance,
|
||||
euclideanDistance,
|
||||
chebyshevDistance,
|
||||
octileDistance,
|
||||
createPoint
|
||||
} from '../../src/core/IPathfinding';
|
||||
|
||||
describe('Heuristic Functions', () => {
|
||||
// =========================================================================
|
||||
// Manhattan Distance
|
||||
// =========================================================================
|
||||
|
||||
describe('manhattanDistance', () => {
|
||||
it('should return 0 for same point', () => {
|
||||
const p = createPoint(5, 5);
|
||||
expect(manhattanDistance(p, p)).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate horizontal distance', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(5, 0);
|
||||
expect(manhattanDistance(a, b)).toBe(5);
|
||||
});
|
||||
|
||||
it('should calculate vertical distance', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(0, 5);
|
||||
expect(manhattanDistance(a, b)).toBe(5);
|
||||
});
|
||||
|
||||
it('should calculate diagonal distance', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(3, 4);
|
||||
expect(manhattanDistance(a, b)).toBe(7); // |3| + |4| = 7
|
||||
});
|
||||
|
||||
it('should handle negative coordinates', () => {
|
||||
const a = createPoint(-2, -3);
|
||||
const b = createPoint(2, 3);
|
||||
expect(manhattanDistance(a, b)).toBe(10); // |4| + |6| = 10
|
||||
});
|
||||
|
||||
it('should be symmetric', () => {
|
||||
const a = createPoint(1, 2);
|
||||
const b = createPoint(4, 6);
|
||||
expect(manhattanDistance(a, b)).toBe(manhattanDistance(b, a));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Euclidean Distance
|
||||
// =========================================================================
|
||||
|
||||
describe('euclideanDistance', () => {
|
||||
it('should return 0 for same point', () => {
|
||||
const p = createPoint(5, 5);
|
||||
expect(euclideanDistance(p, p)).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate horizontal distance', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(5, 0);
|
||||
expect(euclideanDistance(a, b)).toBe(5);
|
||||
});
|
||||
|
||||
it('should calculate vertical distance', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(0, 5);
|
||||
expect(euclideanDistance(a, b)).toBe(5);
|
||||
});
|
||||
|
||||
it('should calculate 3-4-5 triangle', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(3, 4);
|
||||
expect(euclideanDistance(a, b)).toBe(5);
|
||||
});
|
||||
|
||||
it('should calculate diagonal distance', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(1, 1);
|
||||
expect(euclideanDistance(a, b)).toBeCloseTo(Math.SQRT2, 10);
|
||||
});
|
||||
|
||||
it('should handle negative coordinates', () => {
|
||||
const a = createPoint(-3, -4);
|
||||
const b = createPoint(0, 0);
|
||||
expect(euclideanDistance(a, b)).toBe(5);
|
||||
});
|
||||
|
||||
it('should be symmetric', () => {
|
||||
const a = createPoint(1, 2);
|
||||
const b = createPoint(4, 6);
|
||||
expect(euclideanDistance(a, b)).toBeCloseTo(euclideanDistance(b, a), 10);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Chebyshev Distance
|
||||
// =========================================================================
|
||||
|
||||
describe('chebyshevDistance', () => {
|
||||
it('should return 0 for same point', () => {
|
||||
const p = createPoint(5, 5);
|
||||
expect(chebyshevDistance(p, p)).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate horizontal distance', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(5, 0);
|
||||
expect(chebyshevDistance(a, b)).toBe(5);
|
||||
});
|
||||
|
||||
it('should calculate vertical distance', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(0, 5);
|
||||
expect(chebyshevDistance(a, b)).toBe(5);
|
||||
});
|
||||
|
||||
it('should calculate diagonal as max of dx, dy', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(3, 4);
|
||||
expect(chebyshevDistance(a, b)).toBe(4); // max(3, 4) = 4
|
||||
});
|
||||
|
||||
it('should return same value for equal dx and dy', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(5, 5);
|
||||
expect(chebyshevDistance(a, b)).toBe(5);
|
||||
});
|
||||
|
||||
it('should be symmetric', () => {
|
||||
const a = createPoint(1, 2);
|
||||
const b = createPoint(4, 6);
|
||||
expect(chebyshevDistance(a, b)).toBe(chebyshevDistance(b, a));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Octile Distance
|
||||
// =========================================================================
|
||||
|
||||
describe('octileDistance', () => {
|
||||
it('should return 0 for same point', () => {
|
||||
const p = createPoint(5, 5);
|
||||
expect(octileDistance(p, p)).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate horizontal distance', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(5, 0);
|
||||
expect(octileDistance(a, b)).toBe(5);
|
||||
});
|
||||
|
||||
it('should calculate vertical distance', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(0, 5);
|
||||
expect(octileDistance(a, b)).toBe(5);
|
||||
});
|
||||
|
||||
it('should calculate pure diagonal distance', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(5, 5);
|
||||
// 5 diagonal moves = 5 * sqrt(2)
|
||||
expect(octileDistance(a, b)).toBeCloseTo(5 * Math.SQRT2, 10);
|
||||
});
|
||||
|
||||
it('should calculate mixed diagonal and straight', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(3, 5);
|
||||
// 3 diagonal + 2 straight = 3*sqrt(2) + 2
|
||||
const expected = 3 * Math.SQRT2 + 2;
|
||||
expect(octileDistance(a, b)).toBeCloseTo(expected, 10);
|
||||
});
|
||||
|
||||
it('should be symmetric', () => {
|
||||
const a = createPoint(1, 2);
|
||||
const b = createPoint(4, 6);
|
||||
expect(octileDistance(a, b)).toBeCloseTo(octileDistance(b, a), 10);
|
||||
});
|
||||
|
||||
it('should be between Manhattan and Euclidean for diagonal', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(3, 4);
|
||||
|
||||
const manhattan = manhattanDistance(a, b);
|
||||
const euclidean = euclideanDistance(a, b);
|
||||
const octile = octileDistance(a, b);
|
||||
|
||||
expect(octile).toBeLessThan(manhattan);
|
||||
expect(octile).toBeGreaterThan(euclidean);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// createPoint
|
||||
// =========================================================================
|
||||
|
||||
describe('createPoint', () => {
|
||||
it('should create point with correct coordinates', () => {
|
||||
const p = createPoint(3, 4);
|
||||
expect(p.x).toBe(3);
|
||||
expect(p.y).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle negative coordinates', () => {
|
||||
const p = createPoint(-5, -10);
|
||||
expect(p.x).toBe(-5);
|
||||
expect(p.y).toBe(-10);
|
||||
});
|
||||
|
||||
it('should handle decimal coordinates', () => {
|
||||
const p = createPoint(3.5, 4.7);
|
||||
expect(p.x).toBe(3.5);
|
||||
expect(p.y).toBe(4.7);
|
||||
});
|
||||
});
|
||||
});
|
||||
346
packages/framework/pathfinding/__tests__/grid/GridMap.test.ts
Normal file
346
packages/framework/pathfinding/__tests__/grid/GridMap.test.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { GridMap, GridNode, DIRECTIONS_4, DIRECTIONS_8 } from '../../src/grid/GridMap';
|
||||
|
||||
describe('GridMap', () => {
|
||||
let grid: GridMap;
|
||||
|
||||
beforeEach(() => {
|
||||
grid = new GridMap(10, 10);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Construction
|
||||
// =========================================================================
|
||||
|
||||
describe('construction', () => {
|
||||
it('should create grid with correct dimensions', () => {
|
||||
expect(grid.width).toBe(10);
|
||||
expect(grid.height).toBe(10);
|
||||
});
|
||||
|
||||
it('should have all nodes walkable by default', () => {
|
||||
for (let y = 0; y < 10; y++) {
|
||||
for (let x = 0; x < 10; x++) {
|
||||
expect(grid.isWalkable(x, y)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should create small grid', () => {
|
||||
const small = new GridMap(1, 1);
|
||||
expect(small.width).toBe(1);
|
||||
expect(small.height).toBe(1);
|
||||
expect(small.getNodeAt(0, 0)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Node Access
|
||||
// =========================================================================
|
||||
|
||||
describe('getNodeAt', () => {
|
||||
it('should return node at valid position', () => {
|
||||
const node = grid.getNodeAt(5, 5);
|
||||
expect(node).not.toBeNull();
|
||||
expect(node?.position.x).toBe(5);
|
||||
expect(node?.position.y).toBe(5);
|
||||
});
|
||||
|
||||
it('should return null for out of bounds', () => {
|
||||
expect(grid.getNodeAt(-1, 0)).toBeNull();
|
||||
expect(grid.getNodeAt(0, -1)).toBeNull();
|
||||
expect(grid.getNodeAt(10, 0)).toBeNull();
|
||||
expect(grid.getNodeAt(0, 10)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return node with correct id', () => {
|
||||
const node = grid.getNodeAt(3, 4);
|
||||
expect(node?.id).toBe('3,4');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Walkability
|
||||
// =========================================================================
|
||||
|
||||
describe('walkability', () => {
|
||||
it('should set and get walkability', () => {
|
||||
grid.setWalkable(5, 5, false);
|
||||
expect(grid.isWalkable(5, 5)).toBe(false);
|
||||
|
||||
grid.setWalkable(5, 5, true);
|
||||
expect(grid.isWalkable(5, 5)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for out of bounds', () => {
|
||||
expect(grid.isWalkable(-1, 0)).toBe(false);
|
||||
expect(grid.isWalkable(100, 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle setWalkable on invalid position gracefully', () => {
|
||||
grid.setWalkable(-1, -1, false); // Should not throw
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Cost
|
||||
// =========================================================================
|
||||
|
||||
describe('cost', () => {
|
||||
it('should set and get cost', () => {
|
||||
grid.setCost(5, 5, 2.5);
|
||||
const node = grid.getNodeAt(5, 5);
|
||||
expect(node?.cost).toBe(2.5);
|
||||
});
|
||||
|
||||
it('should default cost to 1', () => {
|
||||
const node = grid.getNodeAt(5, 5);
|
||||
expect(node?.cost).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Neighbors (8-direction)
|
||||
// =========================================================================
|
||||
|
||||
describe('getNeighbors (8-direction)', () => {
|
||||
it('should return 8 neighbors for center node', () => {
|
||||
const node = grid.getNodeAt(5, 5)!;
|
||||
const neighbors = grid.getNeighbors(node);
|
||||
expect(neighbors.length).toBe(8);
|
||||
});
|
||||
|
||||
it('should return 3 neighbors for corner', () => {
|
||||
const node = grid.getNodeAt(0, 0)!;
|
||||
const neighbors = grid.getNeighbors(node);
|
||||
expect(neighbors.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should return 5 neighbors for edge', () => {
|
||||
const node = grid.getNodeAt(5, 0)!;
|
||||
const neighbors = grid.getNeighbors(node);
|
||||
expect(neighbors.length).toBe(5);
|
||||
});
|
||||
|
||||
it('should not include blocked neighbors', () => {
|
||||
grid.setWalkable(6, 5, false);
|
||||
const node = grid.getNodeAt(5, 5)!;
|
||||
const neighbors = grid.getNeighbors(node);
|
||||
// 8 - 1 blocked - 2 diagonals (corner cutting) = 5
|
||||
expect(neighbors.length).toBe(5);
|
||||
expect(neighbors.find(n => n.x === 6 && n.y === 5)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should avoid corner cutting by default', () => {
|
||||
// Block horizontal neighbor
|
||||
grid.setWalkable(6, 5, false);
|
||||
const node = grid.getNodeAt(5, 5)!;
|
||||
const neighbors = grid.getNeighbors(node);
|
||||
|
||||
// Should not include diagonal (6,4) and (6,6) due to corner cutting
|
||||
expect(neighbors.find(n => n.x === 6 && n.y === 4)).toBeUndefined();
|
||||
expect(neighbors.find(n => n.x === 6 && n.y === 6)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Neighbors (4-direction)
|
||||
// =========================================================================
|
||||
|
||||
describe('getNeighbors (4-direction)', () => {
|
||||
let grid4: GridMap;
|
||||
|
||||
beforeEach(() => {
|
||||
grid4 = new GridMap(10, 10, { allowDiagonal: false });
|
||||
});
|
||||
|
||||
it('should return 4 neighbors for center node', () => {
|
||||
const node = grid4.getNodeAt(5, 5)!;
|
||||
const neighbors = grid4.getNeighbors(node);
|
||||
expect(neighbors.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should return 2 neighbors for corner', () => {
|
||||
const node = grid4.getNodeAt(0, 0)!;
|
||||
const neighbors = grid4.getNeighbors(node);
|
||||
expect(neighbors.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 3 neighbors for edge', () => {
|
||||
const node = grid4.getNodeAt(5, 0)!;
|
||||
const neighbors = grid4.getNeighbors(node);
|
||||
expect(neighbors.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Movement Cost
|
||||
// =========================================================================
|
||||
|
||||
describe('getMovementCost', () => {
|
||||
it('should return 1 for cardinal movement', () => {
|
||||
const from = grid.getNodeAt(5, 5)!;
|
||||
const to = grid.getNodeAt(6, 5)!;
|
||||
expect(grid.getMovementCost(from, to)).toBe(1);
|
||||
});
|
||||
|
||||
it('should return sqrt(2) for diagonal movement', () => {
|
||||
const from = grid.getNodeAt(5, 5)!;
|
||||
const to = grid.getNodeAt(6, 6)!;
|
||||
expect(grid.getMovementCost(from, to)).toBeCloseTo(Math.SQRT2, 10);
|
||||
});
|
||||
|
||||
it('should factor in destination cost', () => {
|
||||
grid.setCost(6, 5, 2);
|
||||
const from = grid.getNodeAt(5, 5)!;
|
||||
const to = grid.getNodeAt(6, 5)!;
|
||||
expect(grid.getMovementCost(from, to)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Load from Array
|
||||
// =========================================================================
|
||||
|
||||
describe('loadFromArray', () => {
|
||||
it('should load walkability from 2D array', () => {
|
||||
const data = [
|
||||
[0, 0, 1],
|
||||
[0, 1, 0],
|
||||
[1, 0, 0]
|
||||
];
|
||||
const small = new GridMap(3, 3);
|
||||
small.loadFromArray(data);
|
||||
|
||||
expect(small.isWalkable(0, 0)).toBe(true);
|
||||
expect(small.isWalkable(2, 0)).toBe(false);
|
||||
expect(small.isWalkable(1, 1)).toBe(false);
|
||||
expect(small.isWalkable(0, 2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle partial data', () => {
|
||||
const data = [[0, 1]];
|
||||
grid.loadFromArray(data);
|
||||
expect(grid.isWalkable(0, 0)).toBe(true);
|
||||
expect(grid.isWalkable(1, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Load from String
|
||||
// =========================================================================
|
||||
|
||||
describe('loadFromString', () => {
|
||||
it('should load walkability from string', () => {
|
||||
const mapStr = `
|
||||
..#
|
||||
.#.
|
||||
#..`.trim();
|
||||
|
||||
const small = new GridMap(3, 3);
|
||||
small.loadFromString(mapStr);
|
||||
|
||||
expect(small.isWalkable(0, 0)).toBe(true);
|
||||
expect(small.isWalkable(2, 0)).toBe(false);
|
||||
expect(small.isWalkable(1, 1)).toBe(false);
|
||||
expect(small.isWalkable(0, 2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// toString
|
||||
// =========================================================================
|
||||
|
||||
describe('toString', () => {
|
||||
it('should export grid as string', () => {
|
||||
const small = new GridMap(3, 2);
|
||||
small.setWalkable(1, 0, false);
|
||||
|
||||
const expected = '.#.\n...\n';
|
||||
expect(small.toString()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Reset
|
||||
// =========================================================================
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset all nodes to walkable', () => {
|
||||
grid.setWalkable(5, 5, false);
|
||||
grid.setCost(3, 3, 5);
|
||||
grid.reset();
|
||||
|
||||
expect(grid.isWalkable(5, 5)).toBe(true);
|
||||
expect(grid.getNodeAt(3, 3)?.cost).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// setRectWalkable
|
||||
// =========================================================================
|
||||
|
||||
describe('setRectWalkable', () => {
|
||||
it('should set rectangle region walkability', () => {
|
||||
grid.setRectWalkable(2, 2, 3, 3, false);
|
||||
|
||||
for (let y = 2; y < 5; y++) {
|
||||
for (let x = 2; x < 5; x++) {
|
||||
expect(grid.isWalkable(x, y)).toBe(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Outside should still be walkable
|
||||
expect(grid.isWalkable(1, 2)).toBe(true);
|
||||
expect(grid.isWalkable(5, 2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Bounds Checking
|
||||
// =========================================================================
|
||||
|
||||
describe('isInBounds', () => {
|
||||
it('should return true for valid coordinates', () => {
|
||||
expect(grid.isInBounds(0, 0)).toBe(true);
|
||||
expect(grid.isInBounds(9, 9)).toBe(true);
|
||||
expect(grid.isInBounds(5, 5)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid coordinates', () => {
|
||||
expect(grid.isInBounds(-1, 0)).toBe(false);
|
||||
expect(grid.isInBounds(0, -1)).toBe(false);
|
||||
expect(grid.isInBounds(10, 0)).toBe(false);
|
||||
expect(grid.isInBounds(0, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GridNode', () => {
|
||||
it('should create node with correct properties', () => {
|
||||
const node = new GridNode(3, 4, true, 2);
|
||||
expect(node.x).toBe(3);
|
||||
expect(node.y).toBe(4);
|
||||
expect(node.walkable).toBe(true);
|
||||
expect(node.cost).toBe(2);
|
||||
expect(node.id).toBe('3,4');
|
||||
expect(node.position.x).toBe(3);
|
||||
expect(node.position.y).toBe(4);
|
||||
});
|
||||
|
||||
it('should default to walkable with cost 1', () => {
|
||||
const node = new GridNode(0, 0);
|
||||
expect(node.walkable).toBe(true);
|
||||
expect(node.cost).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Direction Constants', () => {
|
||||
it('DIRECTIONS_4 should have 4 cardinal directions', () => {
|
||||
expect(DIRECTIONS_4.length).toBe(4);
|
||||
});
|
||||
|
||||
it('DIRECTIONS_8 should have 8 directions', () => {
|
||||
expect(DIRECTIONS_8.length).toBe(8);
|
||||
});
|
||||
});
|
||||
386
packages/framework/pathfinding/__tests__/navmesh/NavMesh.test.ts
Normal file
386
packages/framework/pathfinding/__tests__/navmesh/NavMesh.test.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { NavMesh, createNavMesh } from '../../src/navmesh/NavMesh';
|
||||
import { createPoint } from '../../src/core/IPathfinding';
|
||||
|
||||
describe('NavMesh', () => {
|
||||
let navmesh: NavMesh;
|
||||
|
||||
beforeEach(() => {
|
||||
navmesh = new NavMesh();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Polygon Management
|
||||
// =========================================================================
|
||||
|
||||
describe('polygon management', () => {
|
||||
it('should add polygon and return id', () => {
|
||||
const id = navmesh.addPolygon([
|
||||
createPoint(0, 0),
|
||||
createPoint(10, 0),
|
||||
createPoint(10, 10),
|
||||
createPoint(0, 10)
|
||||
]);
|
||||
|
||||
expect(id).toBe(0);
|
||||
expect(navmesh.polygonCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should add multiple polygons with incremental ids', () => {
|
||||
const id1 = navmesh.addPolygon([
|
||||
createPoint(0, 0),
|
||||
createPoint(10, 0),
|
||||
createPoint(5, 10)
|
||||
]);
|
||||
|
||||
const id2 = navmesh.addPolygon([
|
||||
createPoint(10, 0),
|
||||
createPoint(20, 0),
|
||||
createPoint(15, 10)
|
||||
]);
|
||||
|
||||
expect(id1).toBe(0);
|
||||
expect(id2).toBe(1);
|
||||
expect(navmesh.polygonCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should get all polygons', () => {
|
||||
navmesh.addPolygon([
|
||||
createPoint(0, 0),
|
||||
createPoint(10, 0),
|
||||
createPoint(5, 10)
|
||||
]);
|
||||
|
||||
navmesh.addPolygon([
|
||||
createPoint(10, 0),
|
||||
createPoint(20, 0),
|
||||
createPoint(15, 10)
|
||||
]);
|
||||
|
||||
const polygons = navmesh.getPolygons();
|
||||
expect(polygons.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should clear all polygons', () => {
|
||||
navmesh.addPolygon([
|
||||
createPoint(0, 0),
|
||||
createPoint(10, 0),
|
||||
createPoint(5, 10)
|
||||
]);
|
||||
|
||||
navmesh.clear();
|
||||
expect(navmesh.polygonCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Point in Polygon
|
||||
// =========================================================================
|
||||
|
||||
describe('findPolygonAt', () => {
|
||||
beforeEach(() => {
|
||||
// Square from (0,0) to (10,10)
|
||||
navmesh.addPolygon([
|
||||
createPoint(0, 0),
|
||||
createPoint(10, 0),
|
||||
createPoint(10, 10),
|
||||
createPoint(0, 10)
|
||||
]);
|
||||
});
|
||||
|
||||
it('should find polygon containing point', () => {
|
||||
const polygon = navmesh.findPolygonAt(5, 5);
|
||||
expect(polygon).not.toBeNull();
|
||||
expect(polygon?.id).toBe(0);
|
||||
});
|
||||
|
||||
it('should return null for point outside', () => {
|
||||
expect(navmesh.findPolygonAt(-1, 5)).toBeNull();
|
||||
expect(navmesh.findPolygonAt(15, 5)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle point on edge', () => {
|
||||
const polygon = navmesh.findPolygonAt(0, 5);
|
||||
// Edge behavior may vary, but should not crash
|
||||
expect(polygon === null || polygon.id === 0).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Walkability
|
||||
// =========================================================================
|
||||
|
||||
describe('isWalkable', () => {
|
||||
beforeEach(() => {
|
||||
navmesh.addPolygon([
|
||||
createPoint(0, 0),
|
||||
createPoint(10, 0),
|
||||
createPoint(10, 10),
|
||||
createPoint(0, 10)
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return true for point in polygon', () => {
|
||||
expect(navmesh.isWalkable(5, 5)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for point outside', () => {
|
||||
expect(navmesh.isWalkable(15, 5)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Connections
|
||||
// =========================================================================
|
||||
|
||||
describe('connections', () => {
|
||||
it('should manually set connection between polygons', () => {
|
||||
// Two adjacent squares
|
||||
const id1 = navmesh.addPolygon([
|
||||
createPoint(0, 0),
|
||||
createPoint(10, 0),
|
||||
createPoint(10, 10),
|
||||
createPoint(0, 10)
|
||||
]);
|
||||
|
||||
const id2 = navmesh.addPolygon([
|
||||
createPoint(10, 0),
|
||||
createPoint(20, 0),
|
||||
createPoint(20, 10),
|
||||
createPoint(10, 10)
|
||||
]);
|
||||
|
||||
navmesh.setConnection(id1, id2, {
|
||||
left: createPoint(10, 0),
|
||||
right: createPoint(10, 10)
|
||||
});
|
||||
|
||||
const polygons = navmesh.getPolygons();
|
||||
const poly1 = polygons.find(p => p.id === id1);
|
||||
|
||||
expect(poly1?.neighbors).toContain(id2);
|
||||
});
|
||||
|
||||
it('should auto-detect shared edges with build()', () => {
|
||||
// Two adjacent squares sharing edge at x=10
|
||||
navmesh.addPolygon([
|
||||
createPoint(0, 0),
|
||||
createPoint(10, 0),
|
||||
createPoint(10, 10),
|
||||
createPoint(0, 10)
|
||||
]);
|
||||
|
||||
navmesh.addPolygon([
|
||||
createPoint(10, 0),
|
||||
createPoint(20, 0),
|
||||
createPoint(20, 10),
|
||||
createPoint(10, 10)
|
||||
]);
|
||||
|
||||
navmesh.build();
|
||||
|
||||
const polygons = navmesh.getPolygons();
|
||||
expect(polygons[0].neighbors).toContain(1);
|
||||
expect(polygons[1].neighbors).toContain(0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Pathfinding
|
||||
// =========================================================================
|
||||
|
||||
describe('findPath', () => {
|
||||
beforeEach(() => {
|
||||
// Create 3 connected squares
|
||||
navmesh.addPolygon([
|
||||
createPoint(0, 0),
|
||||
createPoint(10, 0),
|
||||
createPoint(10, 10),
|
||||
createPoint(0, 10)
|
||||
]);
|
||||
|
||||
navmesh.addPolygon([
|
||||
createPoint(10, 0),
|
||||
createPoint(20, 0),
|
||||
createPoint(20, 10),
|
||||
createPoint(10, 10)
|
||||
]);
|
||||
|
||||
navmesh.addPolygon([
|
||||
createPoint(20, 0),
|
||||
createPoint(30, 0),
|
||||
createPoint(30, 10),
|
||||
createPoint(20, 10)
|
||||
]);
|
||||
|
||||
navmesh.build();
|
||||
});
|
||||
|
||||
it('should find path within same polygon', () => {
|
||||
const result = navmesh.findPath(1, 1, 8, 8);
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.path.length).toBe(2);
|
||||
expect(result.path[0]).toEqual(createPoint(1, 1));
|
||||
expect(result.path[1]).toEqual(createPoint(8, 8));
|
||||
});
|
||||
|
||||
it('should find path across polygons', () => {
|
||||
const result = navmesh.findPath(5, 5, 25, 5);
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.path.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result.path[0]).toEqual(createPoint(5, 5));
|
||||
expect(result.path[result.path.length - 1]).toEqual(createPoint(25, 5));
|
||||
});
|
||||
|
||||
it('should return empty path when start is outside', () => {
|
||||
const result = navmesh.findPath(-5, 5, 15, 5);
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
|
||||
it('should return empty path when end is outside', () => {
|
||||
const result = navmesh.findPath(5, 5, 50, 5);
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
|
||||
it('should calculate path cost', () => {
|
||||
const result = navmesh.findPath(5, 5, 25, 5);
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.cost).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should track nodes searched', () => {
|
||||
const result = navmesh.findPath(5, 5, 25, 5);
|
||||
expect(result.nodesSearched).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// IPathfindingMap Interface
|
||||
// =========================================================================
|
||||
|
||||
describe('IPathfindingMap interface', () => {
|
||||
beforeEach(() => {
|
||||
// Two adjacent squares with shared edge at x=10
|
||||
const id1 = navmesh.addPolygon([
|
||||
createPoint(0, 0),
|
||||
createPoint(10, 0),
|
||||
createPoint(10, 10),
|
||||
createPoint(0, 10)
|
||||
]);
|
||||
|
||||
const id2 = navmesh.addPolygon([
|
||||
createPoint(10, 0),
|
||||
createPoint(20, 0),
|
||||
createPoint(20, 10),
|
||||
createPoint(10, 10)
|
||||
]);
|
||||
|
||||
// Manual connection to ensure proper setup
|
||||
navmesh.setConnection(id1, id2, {
|
||||
left: createPoint(10, 0),
|
||||
right: createPoint(10, 10)
|
||||
});
|
||||
});
|
||||
|
||||
it('should return node at position', () => {
|
||||
const node = navmesh.getNodeAt(5, 5);
|
||||
expect(node).not.toBeNull();
|
||||
expect(node?.id).toBe(0);
|
||||
});
|
||||
|
||||
it('should return null for position outside', () => {
|
||||
const node = navmesh.getNodeAt(50, 50);
|
||||
expect(node).toBeNull();
|
||||
});
|
||||
|
||||
it('should get neighbors from polygon directly', () => {
|
||||
// NavMeshNode holds a reference to the original polygon,
|
||||
// so we check via the polygons map which is updated by setConnection
|
||||
const polygons = navmesh.getPolygons();
|
||||
const poly0 = polygons.find(p => p.id === 0);
|
||||
expect(poly0).toBeDefined();
|
||||
expect(poly0!.neighbors).toContain(1);
|
||||
});
|
||||
|
||||
it('should calculate heuristic', () => {
|
||||
const a = createPoint(0, 0);
|
||||
const b = createPoint(3, 4);
|
||||
expect(navmesh.heuristic(a, b)).toBe(5); // Euclidean
|
||||
});
|
||||
|
||||
it('should calculate movement cost', () => {
|
||||
const node1 = navmesh.getNodeAt(5, 5)!;
|
||||
const node2 = navmesh.getNodeAt(15, 5)!;
|
||||
const cost = navmesh.getMovementCost(node1, node2);
|
||||
expect(cost).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Complex Scenarios
|
||||
// =========================================================================
|
||||
|
||||
describe('complex scenarios', () => {
|
||||
it('should handle L-shaped navmesh with manual connections', () => {
|
||||
// Horizontal part
|
||||
const id1 = navmesh.addPolygon([
|
||||
createPoint(0, 0),
|
||||
createPoint(30, 0),
|
||||
createPoint(30, 10),
|
||||
createPoint(0, 10)
|
||||
]);
|
||||
|
||||
// Vertical part (shares partial edge, needs manual connection)
|
||||
const id2 = navmesh.addPolygon([
|
||||
createPoint(0, 10),
|
||||
createPoint(10, 10),
|
||||
createPoint(10, 30),
|
||||
createPoint(0, 30)
|
||||
]);
|
||||
|
||||
// Manual connection since edges don't match exactly
|
||||
navmesh.setConnection(id1, id2, {
|
||||
left: createPoint(0, 10),
|
||||
right: createPoint(10, 10)
|
||||
});
|
||||
|
||||
const result = navmesh.findPath(25, 5, 5, 25);
|
||||
expect(result.found).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle disconnected areas', () => {
|
||||
// Area 1
|
||||
navmesh.addPolygon([
|
||||
createPoint(0, 0),
|
||||
createPoint(10, 0),
|
||||
createPoint(10, 10),
|
||||
createPoint(0, 10)
|
||||
]);
|
||||
|
||||
// Area 2 (disconnected)
|
||||
navmesh.addPolygon([
|
||||
createPoint(50, 50),
|
||||
createPoint(60, 50),
|
||||
createPoint(60, 60),
|
||||
createPoint(50, 60)
|
||||
]);
|
||||
|
||||
navmesh.build();
|
||||
|
||||
const result = navmesh.findPath(5, 5, 55, 55);
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Factory Function
|
||||
// =========================================================================
|
||||
|
||||
describe('createNavMesh', () => {
|
||||
it('should create empty navmesh', () => {
|
||||
const nm = createNavMesh();
|
||||
expect(nm).toBeInstanceOf(NavMesh);
|
||||
expect(nm.polygonCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
bresenhamLineOfSight,
|
||||
raycastLineOfSight,
|
||||
LineOfSightSmoother,
|
||||
CatmullRomSmoother,
|
||||
CombinedSmoother
|
||||
} from '../../src/smoothing/PathSmoother';
|
||||
import { GridMap } from '../../src/grid/GridMap';
|
||||
import { createPoint, type IPoint } from '../../src/core/IPathfinding';
|
||||
|
||||
describe('Line of Sight Functions', () => {
|
||||
let grid: GridMap;
|
||||
|
||||
beforeEach(() => {
|
||||
grid = new GridMap(10, 10);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// bresenhamLineOfSight
|
||||
// =========================================================================
|
||||
|
||||
describe('bresenhamLineOfSight', () => {
|
||||
it('should return true for clear line', () => {
|
||||
expect(bresenhamLineOfSight(0, 0, 5, 5, grid)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for same point', () => {
|
||||
expect(bresenhamLineOfSight(5, 5, 5, 5, grid)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for horizontal line', () => {
|
||||
expect(bresenhamLineOfSight(0, 5, 9, 5, grid)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for vertical line', () => {
|
||||
expect(bresenhamLineOfSight(5, 0, 5, 9, grid)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when blocked', () => {
|
||||
grid.setWalkable(5, 5, false);
|
||||
expect(bresenhamLineOfSight(0, 0, 9, 9, grid)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when start is blocked', () => {
|
||||
grid.setWalkable(0, 0, false);
|
||||
expect(bresenhamLineOfSight(0, 0, 5, 5, grid)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when end is blocked', () => {
|
||||
grid.setWalkable(5, 5, false);
|
||||
expect(bresenhamLineOfSight(0, 0, 5, 5, grid)).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect obstacle in middle', () => {
|
||||
grid.setWalkable(3, 3, false);
|
||||
expect(bresenhamLineOfSight(0, 0, 6, 6, grid)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// raycastLineOfSight
|
||||
// =========================================================================
|
||||
|
||||
describe('raycastLineOfSight', () => {
|
||||
it('should return true for clear line', () => {
|
||||
expect(raycastLineOfSight(0, 0, 5, 5, grid)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for same point', () => {
|
||||
expect(raycastLineOfSight(5, 5, 5, 5, grid)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when blocked', () => {
|
||||
grid.setWalkable(5, 5, false);
|
||||
expect(raycastLineOfSight(0, 0, 9, 9, grid)).toBe(false);
|
||||
});
|
||||
|
||||
it('should work with custom step size', () => {
|
||||
expect(raycastLineOfSight(0, 0, 5, 5, grid, 0.1)).toBe(true);
|
||||
grid.setWalkable(2, 2, false);
|
||||
expect(raycastLineOfSight(0, 0, 5, 5, grid, 0.1)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LineOfSightSmoother', () => {
|
||||
let grid: GridMap;
|
||||
let smoother: LineOfSightSmoother;
|
||||
|
||||
beforeEach(() => {
|
||||
grid = new GridMap(20, 20);
|
||||
smoother = new LineOfSightSmoother();
|
||||
});
|
||||
|
||||
it('should return same path for 2 or fewer points', () => {
|
||||
const path1: IPoint[] = [createPoint(0, 0)];
|
||||
expect(smoother.smooth(path1, grid)).toEqual(path1);
|
||||
|
||||
const path2: IPoint[] = [createPoint(0, 0), createPoint(5, 5)];
|
||||
expect(smoother.smooth(path2, grid)).toEqual(path2);
|
||||
});
|
||||
|
||||
it('should remove unnecessary waypoints on straight line', () => {
|
||||
const path: IPoint[] = [
|
||||
createPoint(0, 0),
|
||||
createPoint(1, 0),
|
||||
createPoint(2, 0),
|
||||
createPoint(3, 0),
|
||||
createPoint(4, 0),
|
||||
createPoint(5, 0)
|
||||
];
|
||||
|
||||
const result = smoother.smooth(path, grid);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0]).toEqual(createPoint(0, 0));
|
||||
expect(result[1]).toEqual(createPoint(5, 0));
|
||||
});
|
||||
|
||||
it('should remove unnecessary waypoints on diagonal', () => {
|
||||
const path: IPoint[] = [
|
||||
createPoint(0, 0),
|
||||
createPoint(1, 1),
|
||||
createPoint(2, 2),
|
||||
createPoint(3, 3),
|
||||
createPoint(4, 4),
|
||||
createPoint(5, 5)
|
||||
];
|
||||
|
||||
const result = smoother.smooth(path, grid);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0]).toEqual(createPoint(0, 0));
|
||||
expect(result[1]).toEqual(createPoint(5, 5));
|
||||
});
|
||||
|
||||
it('should keep waypoints around obstacles', () => {
|
||||
// Create obstacle
|
||||
grid.setWalkable(5, 5, false);
|
||||
|
||||
const path: IPoint[] = [
|
||||
createPoint(0, 0),
|
||||
createPoint(4, 5),
|
||||
createPoint(6, 5),
|
||||
createPoint(10, 10)
|
||||
];
|
||||
|
||||
const result = smoother.smooth(path, grid);
|
||||
// Should keep at least start, one waypoint near obstacle, and end
|
||||
expect(result.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should use custom line of sight function', () => {
|
||||
const customLOS = (x1: number, y1: number, x2: number, y2: number) => {
|
||||
// Always blocked
|
||||
return false;
|
||||
};
|
||||
|
||||
const customSmoother = new LineOfSightSmoother(customLOS);
|
||||
const path: IPoint[] = [
|
||||
createPoint(0, 0),
|
||||
createPoint(1, 1),
|
||||
createPoint(2, 2)
|
||||
];
|
||||
|
||||
const result = customSmoother.smooth(path, grid);
|
||||
// Should not simplify because LOS always fails
|
||||
expect(result).toEqual(path);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CatmullRomSmoother', () => {
|
||||
let grid: GridMap;
|
||||
let smoother: CatmullRomSmoother;
|
||||
|
||||
beforeEach(() => {
|
||||
grid = new GridMap(20, 20);
|
||||
smoother = new CatmullRomSmoother(5, 0.5);
|
||||
});
|
||||
|
||||
it('should return same path for 2 or fewer points', () => {
|
||||
const path1: IPoint[] = [createPoint(0, 0)];
|
||||
expect(smoother.smooth(path1, grid)).toEqual(path1);
|
||||
|
||||
const path2: IPoint[] = [createPoint(0, 0), createPoint(5, 5)];
|
||||
expect(smoother.smooth(path2, grid)).toEqual(path2);
|
||||
});
|
||||
|
||||
it('should add interpolation points', () => {
|
||||
const path: IPoint[] = [
|
||||
createPoint(0, 0),
|
||||
createPoint(5, 0),
|
||||
createPoint(10, 0)
|
||||
];
|
||||
|
||||
const result = smoother.smooth(path, grid);
|
||||
// Should have more points due to interpolation
|
||||
expect(result.length).toBeGreaterThan(path.length);
|
||||
});
|
||||
|
||||
it('should preserve start and end points', () => {
|
||||
const path: IPoint[] = [
|
||||
createPoint(0, 0),
|
||||
createPoint(5, 5),
|
||||
createPoint(10, 0)
|
||||
];
|
||||
|
||||
const result = smoother.smooth(path, grid);
|
||||
expect(result[0].x).toBeCloseTo(0, 1);
|
||||
expect(result[0].y).toBeCloseTo(0, 1);
|
||||
expect(result[result.length - 1]).toEqual(createPoint(10, 0));
|
||||
});
|
||||
|
||||
it('should create smooth curve', () => {
|
||||
const path: IPoint[] = [
|
||||
createPoint(0, 0),
|
||||
createPoint(5, 5),
|
||||
createPoint(10, 0)
|
||||
];
|
||||
|
||||
const result = smoother.smooth(path, grid);
|
||||
|
||||
// Check that middle points are near the original waypoint
|
||||
const middlePoints = result.filter(p =>
|
||||
Math.abs(p.x - 5) < 2 && Math.abs(p.y - 5) < 2
|
||||
);
|
||||
expect(middlePoints.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should work with different segment counts', () => {
|
||||
const smootherLow = new CatmullRomSmoother(2);
|
||||
const smootherHigh = new CatmullRomSmoother(10);
|
||||
|
||||
const path: IPoint[] = [
|
||||
createPoint(0, 0),
|
||||
createPoint(5, 5),
|
||||
createPoint(10, 0)
|
||||
];
|
||||
|
||||
const resultLow = smootherLow.smooth(path, grid);
|
||||
const resultHigh = smootherHigh.smooth(path, grid);
|
||||
|
||||
expect(resultHigh.length).toBeGreaterThan(resultLow.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CombinedSmoother', () => {
|
||||
let grid: GridMap;
|
||||
let smoother: CombinedSmoother;
|
||||
|
||||
beforeEach(() => {
|
||||
grid = new GridMap(20, 20);
|
||||
smoother = new CombinedSmoother(5, 0.5);
|
||||
});
|
||||
|
||||
it('should first simplify then curve smooth', () => {
|
||||
// Path with redundant points
|
||||
const path: IPoint[] = [
|
||||
createPoint(0, 0),
|
||||
createPoint(1, 0),
|
||||
createPoint(2, 0),
|
||||
createPoint(3, 0),
|
||||
createPoint(4, 0),
|
||||
createPoint(5, 0),
|
||||
createPoint(6, 3),
|
||||
createPoint(7, 6),
|
||||
createPoint(8, 6),
|
||||
createPoint(9, 6),
|
||||
createPoint(10, 6)
|
||||
];
|
||||
|
||||
const result = smoother.smooth(path, grid);
|
||||
|
||||
// Should have smoothed the path
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0].x).toBeCloseTo(0, 1);
|
||||
expect(result[result.length - 1]).toEqual(createPoint(10, 6));
|
||||
});
|
||||
|
||||
it('should handle simple path', () => {
|
||||
const path: IPoint[] = [
|
||||
createPoint(0, 0),
|
||||
createPoint(10, 10)
|
||||
];
|
||||
|
||||
const result = smoother.smooth(path, grid);
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user