import { describe, expect, it } from "vitest"; import { applyMigrations, CURRENT_SAVE_VERSION, migrateSave, saveDocumentSchema, type SaveMigration, } from "../src/index"; import { fullSave } from "./save-fixture"; describe("save document (Step 14.2)", () => { it("validates a fully populated save", () => { expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true); expect(CURRENT_SAVE_VERSION).toBe(6); }); it("rejects an invalid save with a readable error naming the field", () => { const broken = fullSave() as unknown as { company: { credits: unknown } }; broken.company.credits = "lots"; const result = migrateSave(broken); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain("The save is damaged"); expect(result.error).toContain("company.credits"); } }); it("rejects things that aren't saves, and saves from a newer build", () => { expect(migrateSave("hello")).toEqual({ ok: false, error: "The save isn't a save document." }); expect(migrateSave({ company: {} })).toEqual({ ok: false, error: "The save has no valid saveVersion.", }); const future = migrateSave({ ...fullSave(), saveVersion: CURRENT_SAVE_VERSION + 1 }); expect(future.ok).toBe(false); if (!future.ok) expect(future.error).toContain("newer version"); }); it("reads a current save as it is", () => { const result = migrateSave(JSON.parse(JSON.stringify(fullSave()))); expect(result).toEqual({ ok: true, save: fullSave() }); }); it("chains migrations in order, one version at a time", () => { const seen: number[] = []; const migrations: SaveMigration[] = [ { from: 2, migrate: (s) => (seen.push(2), { ...s, extra: [...(s.extra as number[]), 2] }) }, { from: 1, migrate: (s) => (seen.push(1), { ...s, extra: [1] }) }, ]; const result = applyMigrations({ saveVersion: 1 }, migrations, 3); expect(result).toEqual({ ok: true, save: { saveVersion: 3, extra: [1, 2] } }); expect(seen).toEqual([1, 2]); expect(applyMigrations({ saveVersion: 1 }, [], 2)).toEqual({ ok: false, error: "No migration from save version 1.", }); }); }); /** The fixture as a version-1 save: before fuel tanks (CR-015). */ type Loose = Record & { ships: Record[]; dispatches: { record: Record; resolution: Record | null }[]; }; function versionOne(): Record { const save = JSON.parse(JSON.stringify(fullSave())) as Loose; save.saveVersion = 1; for (const ship of save.ships) { delete ship.fuelFu; delete ship.fuelAvgPrice; delete ship.refuelPolicy; } for (const dispatch of save.dispatches) { delete dispatch.record.fuelBurnedFu; if (dispatch.resolution) delete dispatch.resolution.topUp; } return save; } describe("save migration 1 → 2: fuel tanks (CR-015 §5)", () => { it("fills every ship's tank and changes nothing else (then 2 → 3 puts it on Manual)", () => { const result = migrateSave(versionOne()); if (!result.ok) throw new Error(result.error); const { save } = result; const before = fullSave(); expect(save.saveVersion).toBe(6); // The Courier has Extended Fuel Tanks 1 (40 fu × 4.8) and is in flight: the base // price, 5 cr/fu since CR-018 (decision 0.5.110). expect(save.ships[0]).toMatchObject({ fuelFu: 192, fuelAvgPrice: 5, refuelPolicy: "manual" }); // The Clipper is at Earth: a full 1,170 fu at Earth's 8 cr/fu. expect(save.ships[1]).toMatchObject({ fuelFu: 1170, fuelAvgPrice: 8, refuelPolicy: "manual" }); // Earlier trips paid fuel in credits: nothing came out of a tank, nothing topped up. for (const dispatch of save.dispatches) { expect(dispatch.record.fuelBurnedFu).toBe(0); if (dispatch.resolution) expect(dispatch.resolution.topUp).toBeNull(); } // In-flight dispatches keep their arrival times; everything else is as it was. expect(save.dispatches.map((d) => d.arrivalAt)).toEqual( before.dispatches.map((d) => d.arrivalAt), ); expect(save.company).toEqual(before.company); expect(save.pilots).toEqual(before.pilots); }); }); describe("save migration 2 → 3: Manual refueling (CR-018 §3)", () => { it("puts every ship on Manual and leaves fuel and credits as they were", () => { const before = fullSave(); const old = JSON.parse(JSON.stringify(before)) as Record & { ships: Record[]; }; old.saveVersion = 2; old.ships[0]!.refuelPolicy = "auto"; old.ships[1]!.refuelPolicy = "auto"; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); const { save } = result; expect(save.saveVersion).toBe(6); expect(save.ships.map((s) => s.refuelPolicy)).toEqual(save.ships.map(() => "manual")); expect(save.ships.map((s) => [s.fuelFu, s.fuelAvgPrice])).toEqual( before.ships.map((s) => [s.fuelFu, s.fuelAvgPrice]), ); expect(save.company).toEqual(before.company); // Boards go in 3 → 4 (CR-019); everything else is as it was. expect({ ...save, ships: [], contractBoards: [] }).toEqual({ ...before, ships: [], contractBoards: [], }); }); }); describe("save migration 3 → 4: completable boards (CR-019 §3)", () => { it("drops stored boards and leaves everything else as it was", () => { const before = fullSave(); const old = JSON.parse(JSON.stringify(before)) as Record & { contractBoards: Record[]; }; old.saveVersion = 3; // A version-3 board had no fleet snapshot. for (const board of old.contractBoards) delete board.fleet; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); expect(result.save.saveVersion).toBe(6); expect(result.save.contractBoards).toEqual([]); expect({ ...result.save, contractBoards: [] }).toEqual({ ...before, contractBoards: [] }); }); }); describe("save migration 4 → 5: independent pilots (CR-020 §3)", () => { it("puts every pilot on Manual with default orders, with no Dispatch Office", () => { const before = fullSave(); const old = JSON.parse(JSON.stringify(before)) as Record & { company: Record; pilots: Record[]; }; old.saveVersion = 4; delete old.company.dispatchOffice; delete old.company.npcSpendingFloor; for (const pilot of old.pilots) { delete pilot.mode; delete pilot.orders; } const result = migrateSave(old); if (!result.ok) throw new Error(result.error); expect(result.save.saveVersion).toBe(6); expect(result.save.company).toMatchObject({ dispatchOffice: false, npcSpendingFloor: 500 }); for (const pilot of result.save.pilots) { expect(pilot.mode).toBe("manual"); expect(pilot.orders).toEqual({ destinationIds: null, cargoClassIds: ["standard", "heavy", "high-value", "dangerous"], takePriority: true, minProfit: 0, homeLocationId: "earth", }); } // The fixture already holds these defaults, so nothing else changes. expect(result.save).toEqual(before); }); });