test(simulation): Add tests for polar hex grid neighbor calculations and rendering in simulation module

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-26 00:29:35 -07:00
parent 2d90723253
commit fcd67fff4f

View file

@ -0,0 +1,276 @@
/**
* Tests for the polar hex grid layout and data mapping.
* Verifies that equator and polar views produce consistent tiledata mappings
* and that the geodesic ring structure has correct tile counts and positions.
*/
import {
terrainColorJS,
buildEquatorLayout,
buildPolarLayout,
polarTileCount,
computeHexColors,
} from '@magic-civ/guide-engine/components/climate-sim/polarHexGrid.js'
import { GRID_WIDTH, GRID_HEIGHT, HEX_W, HEX_H } from '@magic-civ/engine-ts'
// ── terrainColorJS ──────────────────────────────────────────────────────────
describe('terrainColorJS', () => {
it('returns ocean color for encoded value 0', () => {
const [r, g, b] = terrainColorJS(0)
expect(r).toBeCloseTo(0.24, 2)
expect(g).toBeCloseTo(0.47, 2)
expect(b).toBeCloseTo(0.82, 2)
})
it('returns ice color for encoded value 4/24', () => {
const [r, g, b] = terrainColorJS(4 / 24)
expect(r).toBeCloseTo(0.88, 2)
expect(g).toBeCloseTo(0.94, 2)
expect(b).toBeCloseTo(1.00, 2)
})
it('returns grassland color for encoded value 9/24', () => {
const [r, g, b] = terrainColorJS(9 / 24)
expect(r).toBeCloseTo(0.38, 2)
expect(g).toBeCloseTo(0.72, 2)
expect(b).toBeCloseTo(0.35, 2)
})
it('clamps out-of-range values to last color', () => {
const [r, g, b] = terrainColorJS(1.5)
// Should clamp to index 24 (last entry)
expect(r).toBeCloseTo(0.30, 2)
expect(g).toBeCloseTo(0.50, 2)
expect(b).toBeCloseTo(0.80, 2)
})
})
// ── polarTileCount ──────────────────────────────────────────────────────────
describe('polarTileCount', () => {
it('returns 1 for ring 0 (single pole tile)', () => {
expect(polarTileCount(0)).toBe(1)
})
it('returns 7 for 1 ring (center + 6)', () => {
expect(polarTileCount(1)).toBe(7)
})
it('returns 19 for 2 rings (1 + 6 + 12)', () => {
expect(polarTileCount(2)).toBe(19)
})
it('returns 37 for 3 rings (1 + 6 + 12 + 18)', () => {
expect(polarTileCount(3)).toBe(37)
})
it('matches formula 1 + 3*N*(N+1)', () => {
for (let n = 0; n <= 12; n++) {
expect(polarTileCount(n)).toBe(1 + 3 * n * (n + 1))
}
})
})
// ── buildEquatorLayout ──────────────────────────────────────────────────────
describe('buildEquatorLayout', () => {
const layout = buildEquatorLayout()
it('produces exactly 40×24 = 960 tiles', () => {
expect(layout.tiles.length).toBe(GRID_WIDTH * GRID_HEIGHT)
})
it('has hexR = HEX_W / 2', () => {
expect(layout.hexR).toBe(HEX_W / 2)
})
it('maps each tile to its own unique (col, row)', () => {
const seen = new Set<string>()
for (const tile of layout.tiles) {
const key = `${tile.col},${tile.row}`
expect(seen.has(key)).toBe(false)
seen.add(key)
}
expect(seen.size).toBe(GRID_WIDTH * GRID_HEIGHT)
})
it('all cols are in [0, 39] and all rows are in [0, 23]', () => {
for (const tile of layout.tiles) {
expect(tile.col).toBeGreaterThanOrEqual(0)
expect(tile.col).toBeLessThan(GRID_WIDTH)
expect(tile.row).toBeGreaterThanOrEqual(0)
expect(tile.row).toBeLessThan(GRID_HEIGHT)
}
})
it('positions first tile at hexCenter(0, 0)', () => {
const first = layout.tiles[0]!
expect(first.x).toBeCloseTo(HEX_W / 2, 1)
expect(first.y).toBeCloseTo(HEX_H / 2, 1)
expect(first.col).toBe(0)
expect(first.row).toBe(0)
})
})
// ── buildPolarLayout ────────────────────────────────────────────────────────
describe('buildPolarLayout', () => {
const canvasW = 726
const canvasH = 490
describe('north pole', () => {
const layout = buildPolarLayout('north', 5, canvasW, canvasH)
it('has correct tile count for 5 rings', () => {
expect(layout.tiles.length).toBe(polarTileCount(5))
})
it('center tile maps to row 0 (north pole)', () => {
const center = layout.tiles[0]!
expect(center.row).toBe(0)
expect(center.x).toBeCloseTo(canvasW / 2, 1)
expect(center.y).toBeCloseTo(canvasH / 2, 1)
})
it('ring 1 tiles map to row 1', () => {
// Tiles 1-6 are ring 1
for (let i = 1; i <= 6; i++) {
expect(layout.tiles[i]!.row).toBe(1)
}
})
it('ring N tiles map to row N', () => {
// Ring 2 starts at index 7 (1 + 6), has 12 tiles
for (let i = 7; i < 7 + 12; i++) {
expect(layout.tiles[i]!.row).toBe(2)
}
})
it('all data coords are within grid bounds', () => {
for (const tile of layout.tiles) {
expect(tile.col).toBeGreaterThanOrEqual(0)
expect(tile.col).toBeLessThan(GRID_WIDTH)
expect(tile.row).toBeGreaterThanOrEqual(0)
expect(tile.row).toBeLessThan(GRID_HEIGHT)
}
})
it('ring 1 columns are evenly distributed across the 40-column grid', () => {
const ring1Cols = layout.tiles.slice(1, 7).map((t) => t.col)
// 6 tiles should cover different parts of the 40-column range
const uniqueCols = new Set(ring1Cols)
expect(uniqueCols.size).toBe(6)
// Columns should span a wide range (not all clustered)
const min = Math.min(...ring1Cols)
const max = Math.max(...ring1Cols)
expect(max - min).toBeGreaterThan(20)
})
})
describe('south pole', () => {
const layout = buildPolarLayout('south', 3, canvasW, canvasH)
it('center tile maps to row 23 (south pole)', () => {
expect(layout.tiles[0]!.row).toBe(GRID_HEIGHT - 1)
})
it('ring 1 tiles map to row 22', () => {
for (let i = 1; i <= 6; i++) {
expect(layout.tiles[i]!.row).toBe(GRID_HEIGHT - 2)
}
})
it('rows decrease as rings increase (moving toward equator)', () => {
const ring2Row = layout.tiles[7]!.row
const ring3Row = layout.tiles[7 + 12]!.row
expect(ring2Row).toBe(GRID_HEIGHT - 3)
expect(ring3Row).toBe(GRID_HEIGHT - 4)
})
})
describe('ring clamping', () => {
it('clamps rows when numRings exceeds grid height', () => {
const layout = buildPolarLayout('north', 30, canvasW, canvasH)
for (const tile of layout.tiles) {
expect(tile.row).toBeGreaterThanOrEqual(0)
expect(tile.row).toBeLessThan(GRID_HEIGHT)
}
})
})
})
// ── computeHexColors ────────────────────────────────────────────────────────
describe('computeHexColors', () => {
it('produces 7 vertices × 3 channels per hex', () => {
const dataCoords = [{ col: 0, row: 0 }, { col: 1, row: 1 }]
const texB = new Float32Array(GRID_WIDTH * GRID_HEIGHT * 4)
const colors = computeHexColors(dataCoords, texB)
expect(colors.length).toBe(2 * 7 * 3)
})
it('all 7 vertices of a hex share the same color', () => {
const dataCoords = [{ col: 5, row: 3 }]
const texB = new Float32Array(GRID_WIDTH * GRID_HEIGHT * 4)
// Set terrain at (5, 3) to grassland (index 9, encoded as 9/24)
const texIdx = (3 * GRID_WIDTH + 5) * 4
texB[texIdx + 2] = 9 / 24
const colors = computeHexColors(dataCoords, texB)
const [r0, g0, b0] = [colors[0]!, colors[1]!, colors[2]!]
for (let v = 1; v < 7; v++) {
expect(colors[v * 3]).toBe(r0)
expect(colors[v * 3 + 1]).toBe(g0)
expect(colors[v * 3 + 2]).toBe(b0)
}
})
it('equator and polar views read the same data for the same (col, row)', () => {
// Create a texB with known terrain at specific positions
const texB = new Float32Array(GRID_WIDTH * GRID_HEIGHT * 4)
// Set row 0 col 20 to ice (index 4)
texB[(0 * GRID_WIDTH + 20) * 4 + 2] = 4 / 24
// Set row 1 col 10 to forest (index 10)
texB[(1 * GRID_WIDTH + 10) * 4 + 2] = 10 / 24
// Equator tile at (20, 0)
const equatorColors = computeHexColors([{ col: 20, row: 0 }], texB)
// Polar center tile also maps to (20, 0) for north pole
const polarColors = computeHexColors([{ col: 20, row: 0 }], texB)
// Same (col, row) → identical colors
expect(equatorColors[0]).toBe(polarColors[0])
expect(equatorColors[1]).toBe(polarColors[1])
expect(equatorColors[2]).toBe(polarColors[2])
})
})
// ── data consistency between equator and polar ──────────────────────────────
describe('equator-polar data consistency', () => {
it('polar center tile reads from the same texB index as equator row-0 tile', () => {
const equator = buildEquatorLayout()
const polar = buildPolarLayout('north', 1, 726, 490)
// Find equator tile at (col=20, row=0) — same col as polar center
const equatorTile = equator.tiles.find((t) => t.col === 20 && t.row === 0)!
const polarCenter = polar.tiles[0]!
// Both should map to the same grid position
expect(polarCenter.col).toBe(equatorTile.col)
expect(polarCenter.row).toBe(equatorTile.row)
})
it('polar ring 1 tiles all reference row 1 data', () => {
const polar = buildPolarLayout('north', 1, 726, 490)
const ring1 = polar.tiles.slice(1, 7)
for (const tile of ring1) {
expect(tile.row).toBe(1)
}
})
it('south pole center maps to last row', () => {
const polar = buildPolarLayout('south', 1, 726, 490)
expect(polar.tiles[0]!.row).toBe(GRID_HEIGHT - 1)
})
})