import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { afterAll, describe, expect, it } from 'vitest' import { openGameDb, type GameDb } from '@/lib/db/client' import { getWorldState, jumpToSystem, type WorldState } from './save' const dirs: string[] = [] async function freshFile(): Promise<{ db: GameDb; file: string }> { const dir = await mkdtemp(path.join(tmpdir(), 'sw-save-test-')) dirs.push(dir) const file = path.join(dir, 'test.db') return { db: await openGameDb(file), file } } afterAll(async () => { for (const d of dirs) await rm(d, { recursive: true, force: true }) }) function currentOf(world: WorldState): WorldState['systems'][number] { const current = world.systems.filter((s) => s.current) expect(current).toHaveLength(1) return current[0] } describe('ensureSave / getWorldState', () => { it('auto-initializes a save on first read: start + 6 neighbours, turn 0, new_game event', async () => { const { db } = await freshFile() const world = await getWorldState(db) expect(world.turn).toBe(0) expect(world.systems).toHaveLength(7) expect(currentOf(world).discovered).toBe(true) expect(world.systems.filter((s) => !s.discovered)).toHaveLength(6) expect(world.edges).toHaveLength(12) // start↔each neighbour + ring cycle const ids = new Set(world.systems.map((s) => s.id)) for (const [a, b] of world.edges) { expect(ids.has(a)).toBe(true) expect(ids.has(b)).toBe(true) } const rows = db.raw.exec('SELECT type, turn FROM events ORDER BY id') expect(rows[0].values).toEqual([['new_game', 0]]) }) it('is idempotent — a second read does not regenerate the world', async () => { const { db } = await freshFile() const first = await getWorldState(db) const second = await getWorldState(db) expect(second).toEqual(first) }) }) describe('jumpToSystem validation', () => { it('rejects unknown systems with 404 and jumping to the current system with 409', async () => { const { db } = await freshFile() const world = await getWorldState(db) const start = currentOf(world) await expect(jumpToSystem(db, 999_999)).rejects.toMatchObject({ status: 404 }) await expect(jumpToSystem(db, start.id)).rejects.toMatchObject({ status: 409 }) const after = await getWorldState(db) expect(after.turn).toBe(0) // rejected jumps mutate nothing expect(currentOf(after).id).toBe(start.id) }) it('rejects non-adjacent systems with 409 and leaves state untouched', async () => { const { db } = await freshFile() const w0 = await getWorldState(db) const start = currentOf(w0) // Every initial neighbour is adjacent to the start, so jump once first. const n0 = w0.systems.find((s) => !s.current)! await jumpToSystem(db, n0.id) const w1 = await getWorldState(db) expect(currentOf(w1).id).toBe(n0.id) // A system with no route to the current one (ring members two steps away qualify). const adjacent = new Set( w1.edges.filter(([a, b]) => a === n0.id || b === n0.id).flatMap(([a, b]) => [a, b]), ) const far = w1.systems.find((s) => !adjacent.has(s.id))! await expect(jumpToSystem(db, far.id)).rejects.toMatchObject({ status: 409 }) const after = await getWorldState(db) expect(after.turn).toBe(1) expect(currentOf(after).id).toBe(n0.id) }) }) describe('jumpToSystem success path', () => { it('sets current, increments turn, discovers the target and grows the chart', async () => { const { db } = await freshFile() const w0 = await getWorldState(db) const start = currentOf(w0) const target = w0.systems.find((s) => !s.current)! // all initial neighbours are adjacent to the start const after = await jumpToSystem(db, target.id) expect(after.turn).toBe(1) expect(currentOf(after).id).toBe(target.id) expect(after.systems.find((s) => s.id === target.id)!.discovered).toBe(true) // Expansion: 4 new undiscovered neighbours around the target, each routed to it. const fresh = after.systems.filter((s) => !w0.systems.some((p) => p.id === s.id)) expect(fresh).toHaveLength(4) expect(fresh.every((s) => !s.discovered)).toBe(true) for (const n of fresh) { expect(after.edges.some(([a, b]) => (a === target.id && b === n.id) || (a === n.id && b === target.id))).toBe(true) } const rows = db.raw.exec('SELECT type, turn FROM events ORDER BY id') expect(rows[0].values).toEqual([['new_game', 0], ['jump', 1]]) }) it('persists the current system and turn across a DB reopen (container restart)', async () => { const { db, file } = await freshFile() const w0 = await getWorldState(db) const target = w0.systems.find((s) => !s.current)! const after = await jumpToSystem(db, target.id) const reopened = await openGameDb(file) // a new process would see the same file const again = await getWorldState(reopened) expect(again.turn).toBe(after.turn) expect(currentOf(again).id).toBe(target.id) expect(again.systems).toHaveLength(after.systems.length) }) })