241 lines
9.1 KiB
TypeScript
241 lines
9.1 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import initSqlJs from 'sql.js';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { openGameDb, persist } from './client';
|
|
import * as schema from './schema';
|
|
|
|
// The v0.6.0 DDL — used to build a "legacy" DB file for the migration test.
|
|
const LEGACY_DDL = `
|
|
CREATE TABLE planes (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
model TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
fuel REAL NOT NULL,
|
|
max_fuel REAL NOT NULL,
|
|
location_icao TEXT NOT NULL,
|
|
contract_id TEXT,
|
|
lat REAL NOT NULL,
|
|
lon REAL NOT NULL
|
|
);
|
|
CREATE TABLE contracts (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
origin_icao TEXT NOT NULL,
|
|
dest_icao TEXT NOT NULL,
|
|
reward REAL NOT NULL,
|
|
cargo_type TEXT NOT NULL,
|
|
cargo_count INTEGER NOT NULL,
|
|
deadline_sim_time INTEGER NOT NULL,
|
|
status TEXT NOT NULL,
|
|
plane_id TEXT,
|
|
created_sim_time INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE account (
|
|
cash REAL NOT NULL,
|
|
reputation REAL NOT NULL
|
|
);
|
|
CREATE TABLE sim_state (
|
|
clock_ms INTEGER NOT NULL,
|
|
time_scale REAL NOT NULL,
|
|
paused INTEGER NOT NULL
|
|
);
|
|
`;
|
|
|
|
// The v0.11.0 DDL — the last schema before the packages pivot; used to build a
|
|
// "pre-v0.12.0" DB file for the migration test (has departure_sim_time +
|
|
// contract_id, has contracts, no packages/dest_icao).
|
|
const PRE_V12_DDL = `
|
|
CREATE TABLE planes (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
model TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
fuel REAL NOT NULL,
|
|
max_fuel REAL NOT NULL,
|
|
location_icao TEXT NOT NULL,
|
|
contract_id TEXT,
|
|
lat REAL NOT NULL,
|
|
lon REAL NOT NULL,
|
|
departure_sim_time INTEGER
|
|
);
|
|
CREATE TABLE contracts (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
origin_icao TEXT NOT NULL,
|
|
dest_icao TEXT NOT NULL,
|
|
reward REAL NOT NULL,
|
|
cargo_type TEXT NOT NULL,
|
|
cargo_count INTEGER NOT NULL,
|
|
deadline_sim_time INTEGER NOT NULL,
|
|
status TEXT NOT NULL,
|
|
plane_id TEXT,
|
|
created_sim_time INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE account (
|
|
cash REAL NOT NULL,
|
|
reputation REAL NOT NULL
|
|
);
|
|
CREATE TABLE sim_state (
|
|
clock_ms INTEGER NOT NULL,
|
|
time_scale REAL NOT NULL,
|
|
paused INTEGER NOT NULL,
|
|
last_real_tick_ms INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
`;
|
|
|
|
let dir: string;
|
|
|
|
beforeAll(async () => {
|
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-db-'));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('openGameDb', () => {
|
|
it('seeds a fresh DB file on first open and persists it to disk', async () => {
|
|
const file = path.join(dir, 'fresh.db');
|
|
const inst = await openGameDb(file);
|
|
|
|
const planes = await inst.db.select().from(schema.planes);
|
|
expect(planes).toHaveLength(1);
|
|
expect(planes[0].id).toBe('plane-starter');
|
|
expect(planes[0].status).toBe('available');
|
|
expect(planes[0].locationIcao).toBe('EDDF'); // Frankfurt hub from the CSV
|
|
expect(planes[0].fuel).toBe(planes[0].maxFuel);
|
|
|
|
const packages = await inst.db.select().from(schema.packages);
|
|
expect(packages.length).toBeGreaterThanOrEqual(8);
|
|
expect(packages.every((p) => p.status === 'open')).toBe(true);
|
|
|
|
const account = (await inst.db.select().from(schema.account))[0];
|
|
expect(account?.cash).toBeGreaterThan(0);
|
|
const sim = (await inst.db.select().from(schema.simState))[0];
|
|
expect(sim?.paused).toBe(false);
|
|
expect(sim?.timeScale).toBe(1);
|
|
|
|
// The seed was durably written: the file exists and is non-empty.
|
|
const st = await fs.stat(file);
|
|
expect(st.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('does not re-seed an existing DB — state survives reopen', async () => {
|
|
const file = path.join(dir, 'reopen.db');
|
|
const first = await openGameDb(file);
|
|
const packages1 = (await first.db.select().from(schema.packages)).map((p) => p.id).sort();
|
|
const account1 = (await first.db.select().from(schema.account))[0];
|
|
|
|
// Simulate a process restart: brand-new instance over the same file.
|
|
const second = await openGameDb(file);
|
|
const packages2 = (await second.db.select().from(schema.packages)).map((p) => p.id).sort();
|
|
expect(packages2).toEqual(packages1);
|
|
expect((await second.db.select().from(schema.account))[0]).toEqual(account1);
|
|
});
|
|
|
|
it('persist() durably saves writes made after open', async () => {
|
|
const file = path.join(dir, 'write.db');
|
|
const inst = await openGameDb(file);
|
|
|
|
await inst.db.update(schema.account).set({ cash: 12345 });
|
|
await persist(inst);
|
|
|
|
// A fresh instance over the same file sees the update.
|
|
const reloaded = await openGameDb(file);
|
|
expect((await reloaded.db.select().from(schema.account))[0]?.cash).toBe(12345);
|
|
});
|
|
|
|
it('migrates a pre-v0.7.0 database: adds new columns and fixes last_real_tick_ms', async () => {
|
|
// Build a legacy file with the v0.6.0 schema (no departure_sim_time / last_real_tick_ms).
|
|
const SQL = await initSqlJs();
|
|
const raw = new SQL.Database();
|
|
raw.exec(LEGACY_DDL);
|
|
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`);
|
|
raw.exec(`INSERT INTO account VALUES (20000, 10)`);
|
|
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 1, 0)');
|
|
const file = path.join(dir, 'legacy.db');
|
|
await fs.writeFile(file, Buffer.from(raw.export()));
|
|
|
|
const inst = await openGameDb(file);
|
|
|
|
// Not re-seeded (account row exists)…
|
|
expect((await inst.db.select().from(schema.account))[0]?.cash).toBe(20000);
|
|
// …but the new columns exist and are usable…
|
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
|
expect(plane?.departureSimTime ?? null).toBeNull();
|
|
const sim = (await inst.db.select().from(schema.simState))[0];
|
|
expect(sim?.clockMs).toBe(1_750_000_000_000);
|
|
// …and the ALTER-default 0 was fixed up to a real timestamp so the first
|
|
// tick-on-read resumes cleanly instead of taking one clamped catch-up jump.
|
|
expect(sim?.lastRealTickMs).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('migrates a pre-v0.12.0 database: wipes contracts, grounds planes, preserves account + clock', async () => {
|
|
// Build a v0.11.0 file with an IN-FLIGHT plane (old dispatch state) and live contracts.
|
|
const SQL = await initSqlJs();
|
|
const raw = new SQL.Database();
|
|
raw.exec(PRE_V12_DDL);
|
|
raw.exec(
|
|
`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'flying', 4000, 6300, 'EDDF', 'c-1', 50.03, 8.57, 1789000000000)`,
|
|
);
|
|
raw.exec(
|
|
`INSERT INTO contracts VALUES ('c-1', 'EDDF', 'LEMD', 5000, 'Mail', 4, 1789100000000, 'accepted', 'plane-starter', 1788900000000)`,
|
|
);
|
|
raw.exec(`INSERT INTO account VALUES (54020.34, 62.57)`);
|
|
raw.exec('INSERT INTO sim_state VALUES (1789706016605, 1, 0, 1789718713288)');
|
|
const file = path.join(dir, 'pre-v12.db');
|
|
await fs.writeFile(file, Buffer.from(raw.export()));
|
|
|
|
const inst = await openGameDb(file);
|
|
|
|
// Wallet + sim clock survive byte-identical…
|
|
expect((await inst.db.select().from(schema.account))[0]).toEqual({ cash: 54020.34, reputation: 62.57 });
|
|
expect((await inst.db.select().from(schema.simState))[0]?.clockMs).toBe(1_789_706_016_605);
|
|
|
|
// …the in-flight plane is reset to GROUNDED at its locationIcao (it "lands" back where it took off)…
|
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
|
expect(plane.status).toBe('available');
|
|
expect(plane.locationIcao).toBe('EDDF'); // unchanged — grounded AT the origin
|
|
expect(plane.departureSimTime ?? null).toBeNull();
|
|
expect(plane.destIcao ?? null).toBeNull();
|
|
|
|
// …old contract data is wiped and the packages table exists (empty until spawn reseeds)…
|
|
let contractsGone = false;
|
|
try {
|
|
inst.raw.exec('SELECT COUNT(*) FROM contracts');
|
|
} catch {
|
|
contractsGone = true;
|
|
}
|
|
expect(contractsGone).toBe(true);
|
|
const packages = await inst.db.select().from(schema.packages);
|
|
expect(packages).toHaveLength(0);
|
|
|
|
// …and the orphaned contract_id column is dropped from planes.
|
|
const cols = new Set(inst.raw.exec('PRAGMA table_info(planes)')[0].values.map((r) => String(r[1])));
|
|
expect(cols.has('contract_id')).toBe(false);
|
|
expect(cols.has('dest_icao')).toBe(true);
|
|
});
|
|
|
|
it('does not re-run the v0.12 plane reset on later opens (migration is one-shot)', async () => {
|
|
const file = path.join(dir, 'pre-v12-reopen.db');
|
|
const SQL = await initSqlJs();
|
|
const raw = new SQL.Database();
|
|
raw.exec(PRE_V12_DDL);
|
|
raw.exec(
|
|
`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57, NULL)`,
|
|
);
|
|
raw.exec(`INSERT INTO account VALUES (1000, 1)`);
|
|
raw.exec('INSERT INTO sim_state VALUES (1789000000000, 1, 0, 1789000000000)');
|
|
await fs.writeFile(file, Buffer.from(raw.export()));
|
|
|
|
const first = await openGameDb(file); // migration runs here
|
|
// Simulate post-migration play: the plane flies again.
|
|
await first.db.update(schema.planes).set({ status: 'flying', destIcao: 'LEMD', departureSimTime: 1789000001000 });
|
|
await persist(first);
|
|
|
|
const second = await openGameDb(file); // must NOT ground the plane again
|
|
const plane = (await second.db.select().from(schema.planes))[0];
|
|
expect(plane.status).toBe('flying');
|
|
expect(plane.destIcao).toBe('LEMD');
|
|
});
|
|
});
|