import { describe, expect, it } from "vitest"; import { GRID_COLS, GRID_ROWS, LAND_NAV_DIFFICULTIES, calculateScore, calculateXp, createCourse, gridRef, isLegCorrect, isOptionCorrect, isLandNavDifficulty, } from "@/lib/land-nav"; function seededRng(seed: number) { let state = seed; return () => { state = (state * 1664525 + 1013904223) % 4294967296; return state / 4294967296; }; } describe("land-nav lib", () => { it("guards the difficulty keys", () => { expect(isLandNavDifficulty("waypoint")).toBe(true); expect(isLandNavDifficulty("route")).toBe(true); expect(isLandNavDifficulty("expedition")).toBe(true); expect(isLandNavDifficulty("patrol")).toBe(false); expect(isLandNavDifficulty(42)).toBe(false); }); it("creates a valid course for every difficulty", () => { for (const difficulty of ["waypoint", "route", "expedition"] as const) { const config = LAND_NAV_DIFFICULTIES[difficulty]; const course = createCourse(difficulty, seededRng(1)); expect(course.legs).toHaveLength(config.legs); expect(course.features).toHaveLength(config.features); expect(course.cols).toBe(GRID_COLS); expect(course.rows).toBe(GRID_ROWS); const featureCells = new Set(course.features.map((f) => `${f.col},${f.row}`)); expect(featureCells.size).toBe(config.features); for (const feature of course.features) { expect(feature.col).toBeGreaterThanOrEqual(0); expect(feature.col).toBeLessThan(GRID_COLS); expect(feature.row).toBeGreaterThanOrEqual(0); expect(feature.row).toBeLessThan(GRID_ROWS); expect(feature.name.length).toBeGreaterThan(0); } for (const leg of course.legs) { expect(leg.prompt.length).toBeGreaterThan(0); expect(leg.answerCell.col).toBeGreaterThanOrEqual(0); expect(leg.answerCell.col).toBeLessThan(GRID_COLS); expect(leg.answerCell.row).toBeGreaterThanOrEqual(0); expect(leg.answerCell.row).toBeLessThan(GRID_ROWS); if (leg.kind === "azimuth") { expect(leg.azimuth).toBeGreaterThanOrEqual(0); expect(leg.azimuth).toBeLessThanOrEqual(359); expect(leg.distanceM).toBeGreaterThan(0); expect(leg.startCell).toBeDefined(); } if (leg.kind === "identify") { expect(leg.options).toHaveLength(4); expect(new Set(leg.options).size).toBe(4); } } } }); it("produces 6-figure grid references", () => { const course = createCourse("waypoint", seededRng(7)); const ref = gridRef(course, { col: 0, row: 0 }); expect(ref).toMatch(/^\d{6}$/); expect(ref).toBe( `${String(course.easting0).padStart(3, "0")}${String(course.northing0).padStart(3, "0")}`, ); }); it("scores passes higher than fails and awards XP only on a pass", () => { const passScore = calculateScore("route", 6, 60, true); const failScore = calculateScore("route", 2, 60, false); expect(passScore).toBeGreaterThan(failScore); const faster = calculateScore("route", 6, 30, true); expect(faster).toBeGreaterThanOrEqual(passScore); expect(calculateXp(passScore, true)).toBeGreaterThanOrEqual(1); expect(calculateXp(passScore, false)).toBe(0); }); it("validates leg answers", () => { const course = createCourse("waypoint", seededRng(3)); const locateLeg = course.legs.find((leg) => leg.kind === "locate"); const identifyLeg = course.legs.find((leg) => leg.kind === "identify"); expect(locateLeg).toBeDefined(); expect(identifyLeg).toBeDefined(); if (locateLeg) { expect(isLegCorrect(locateLeg, locateLeg.answerCell)).toBe(true); expect(isLegCorrect(locateLeg, { col: 0, row: 0 })).toBe( locateLeg.answerCell.col === 0 && locateLeg.answerCell.row === 0, ); } if (identifyLeg) { const answerFeature = course.features.find((f) => f.id === identifyLeg.answerFeatureId); expect(answerFeature).toBeDefined(); expect(isOptionCorrect(identifyLeg, course, answerFeature!.name)).toBe(true); const wrongOption = (identifyLeg.options ?? []).find( (option) => option !== answerFeature!.name, ); expect(isOptionCorrect(identifyLeg, course, wrongOption ?? "")).toBe(false); } }); it("generates declination legs on harder courses with a consistent conversion", () => { let seen = 0; for (let seed = 1; seed <= 40 && seen < 5; seed += 1) { const course = createCourse("expedition", seededRng(seed)); const declLegs = course.legs.filter((leg) => leg.kind === "declination"); seen += declLegs.length; for (const leg of declLegs) { expect(leg.declination).not.toBe(0); expect(leg.startCell).toBeDefined(); const radians = ((leg.azimuth ?? 0) * Math.PI) / 180; const distanceCells = (leg.distanceM ?? 0) / 100; const dx = Math.round(Math.sin(radians) * distanceCells); const dy = -Math.round(Math.cos(radians) * distanceCells) || 0; expect(leg.answerCell.col - leg.startCell!.col).toBe(dx); expect(leg.answerCell.row - leg.startCell!.row).toBe(dy); expect(leg.prompt).toMatch(/declination/i); } } expect(seen).toBeGreaterThan(0); }); it("never generates declination legs on the waypoint course", () => { for (let seed = 1; seed <= 20; seed += 1) { const course = createCourse("waypoint", seededRng(seed)); expect(course.legs.every((leg) => leg.kind !== "declination")).toBe(true); } }); });