From fcd67fff4f674bd740ff3abac866dd98c142f0c9 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 26 Mar 2026 00:29:35 -0700 Subject: [PATCH] =?UTF-8?q?test(simulation):=20=E2=9C=85=20Add=20tests=20f?= =?UTF-8?q?or=20polar=20hex=20grid=20neighbor=20calculations=20and=20rende?= =?UTF-8?q?ring=20in=20simulation=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../__tests__/polar-hex-grid.test.ts | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 guide/age-of-four/src/simulation/__tests__/polar-hex-grid.test.ts diff --git a/guide/age-of-four/src/simulation/__tests__/polar-hex-grid.test.ts b/guide/age-of-four/src/simulation/__tests__/polar-hex-grid.test.ts new file mode 100644 index 00000000..baed9667 --- /dev/null +++ b/guide/age-of-four/src/simulation/__tests__/polar-hex-grid.test.ts @@ -0,0 +1,276 @@ +/** + * Tests for the polar hex grid layout and data mapping. + * Verifies that equator and polar views produce consistent tile→data 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() + 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) + }) +})