133 lines
5.0 KiB
TypeScript
133 lines
5.0 KiB
TypeScript
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)
|
|
})
|
|
})
|