Files
hexifyer/tests/core/coords.test.ts
Axel Meyer f302932ea8 Phase 1: Core hex engine, Leaflet overlay, terrain painting UI
- core/: Pure TS hex engine (axial coords, hex grid, terrain types,
  edge connectivity with constraint solver, HexMap state model)
- src/map/: Leaflet L.CRS.Simple map init, Canvas-based hex overlay
  layer (L.GridLayer), click/edge interaction detection
- src/ui/: Sidebar with toolbar (Select/Paint/Feature modes),
  terrain picker, hex inspector, map settings (hex size, grid, opacity)
- pipeline/: Tile pyramid generator (sharp, from source image)
- tests/: 32 passing tests for coords, hex-grid, edge-connectivity
- Uses Kiepenkerl tiles (symlinked) for development

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:32:52 +00:00

175 lines
5.3 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
axialToPixel,
pixelToAxial,
axialRound,
getNeighbor,
getNeighbors,
axialDistance,
coordKey,
parseCoordKey,
hexVertices,
hexEdgeMidpoints,
closestEdge,
} from '@core/coords';
import { HexEdge } from '@core/types';
describe('axialToPixel / pixelToAxial roundtrip', () => {
const size = 32;
it('origin hex maps to origin pixel', () => {
const p = axialToPixel({ q: 0, r: 0 }, size);
expect(p.x).toBeCloseTo(0);
expect(p.y).toBeCloseTo(0);
});
it('roundtrips integer coordinates', () => {
const cases = [
{ q: 0, r: 0 },
{ q: 1, r: 0 },
{ q: 0, r: 1 },
{ q: -2, r: 3 },
{ q: 5, r: -3 },
];
for (const coord of cases) {
const pixel = axialToPixel(coord, size);
const back = pixelToAxial(pixel, size);
expect(back.q).toBe(coord.q);
expect(back.r).toBe(coord.r);
}
});
it('respects origin offset', () => {
const origin = { x: 100, y: 200 };
const coord = { q: 2, r: 1 };
const pixel = axialToPixel(coord, size, origin);
const back = pixelToAxial(pixel, size, origin);
expect(back.q).toBe(coord.q);
expect(back.r).toBe(coord.r);
});
});
describe('axialRound', () => {
it('rounds fractional coords to nearest hex', () => {
const result = axialRound({ q: 0.3, r: 0.1 });
expect(result.q).toBe(0);
expect(result.r).toBe(0);
});
it('handles mid-boundary correctly', () => {
const result = axialRound({ q: 0.7, r: -0.2 });
expect(Number.isInteger(result.q)).toBe(true);
expect(Number.isInteger(result.r)).toBe(true);
// Cube constraint: q + r + s = 0
const s = -result.q - result.r;
expect(Number.isInteger(s)).toBe(true);
});
});
describe('neighbors', () => {
it('returns correct neighbor for each edge', () => {
const origin = { q: 3, r: 4 };
expect(getNeighbor(origin, HexEdge.NE)).toEqual({ q: 4, r: 3 });
expect(getNeighbor(origin, HexEdge.E)).toEqual({ q: 4, r: 4 });
expect(getNeighbor(origin, HexEdge.SE)).toEqual({ q: 3, r: 5 });
expect(getNeighbor(origin, HexEdge.SW)).toEqual({ q: 2, r: 5 });
expect(getNeighbor(origin, HexEdge.W)).toEqual({ q: 2, r: 4 });
expect(getNeighbor(origin, HexEdge.NW)).toEqual({ q: 3, r: 3 });
});
it('getNeighbors returns 6 neighbors', () => {
const neighbors = getNeighbors({ q: 0, r: 0 });
expect(neighbors).toHaveLength(6);
// All should be distance 1
for (const n of neighbors) {
expect(axialDistance({ q: 0, r: 0 }, n)).toBe(1);
}
});
});
describe('axialDistance', () => {
it('same hex is distance 0', () => {
expect(axialDistance({ q: 2, r: 3 }, { q: 2, r: 3 })).toBe(0);
});
it('adjacent hexes are distance 1', () => {
expect(axialDistance({ q: 0, r: 0 }, { q: 1, r: 0 })).toBe(1);
});
it('diagonal distance', () => {
expect(axialDistance({ q: 0, r: 0 }, { q: 3, r: -3 })).toBe(3);
});
});
describe('coordKey / parseCoordKey', () => {
it('roundtrips', () => {
const coord = { q: -5, r: 12 };
expect(parseCoordKey(coordKey(coord))).toEqual(coord);
});
});
describe('hexVertices', () => {
it('produces 6 vertices', () => {
const verts = hexVertices(0, 0, 32);
expect(verts).toHaveLength(6);
});
it('vertices are equidistant from center', () => {
const size = 32;
const verts = hexVertices(10, 20, size);
for (const v of verts) {
const dist = Math.hypot(v.x - 10, v.y - 20);
expect(dist).toBeCloseTo(size, 5);
}
});
});
describe('closestEdge', () => {
// Flat-top hex: vertices at 0,60,120,180,240,300 degrees
// Edge midpoints are between consecutive vertices:
// midpoint 0 (v0-v1) at ~30° → NE direction
// midpoint 1 (v1-v2) at ~90° → top (NW in our enum)
// midpoint 2 (v2-v3) at ~150° → upper-left
// midpoint 3 (v3-v4) at ~210° → lower-left
// midpoint 4 (v4-v5) at ~270° → bottom
// midpoint 5 (v5-v0) at ~330° → lower-right
// The mapping between midpoint index and HexEdge enum depends
// on how we defined the enum. Our vertex-based midpoints go:
// index 0 → NE(30°), 1 → NW(90°)... etc
// So a point at x=30,y=0 (0°) is closest to midpoint 0 (NE) or 5 (SE-ish)
it('point to the upper-right → NE edge', () => {
const center = { x: 0, y: 0 };
// NE midpoint is at ~(-30°) in screen coords = upper-right
const point = { x: 24, y: -14 };
expect(closestEdge(center, 32, point)).toBe(HexEdge.NE);
});
it('point to the right → E edge', () => {
const center = { x: 0, y: 0 };
// E midpoint is at ~30° = right-downish
const point = { x: 28, y: 10 };
expect(closestEdge(center, 32, point)).toBe(HexEdge.E);
});
it('point directly below → SE edge', () => {
const center = { x: 0, y: 0 };
const point = { x: 0, y: 30 };
expect(closestEdge(center, 32, point)).toBe(HexEdge.SE);
});
it('point directly above → NW edge', () => {
const center = { x: 0, y: 0 };
const point = { x: 0, y: -30 };
expect(closestEdge(center, 32, point)).toBe(HexEdge.NW);
});
it('returns a valid edge (0-5)', () => {
const center = { x: 100, y: 100 };
const point = { x: 120, y: 95 };
const edge = closestEdge(center, 32, point);
expect(edge).toBeGreaterThanOrEqual(0);
expect(edge).toBeLessThanOrEqual(5);
});
});