|
| 1 | +import { describe, expect, it } from "vite-plus/test"; |
| 2 | +import { createBlockDocStore, solveInputOf } from "./doc-store.ts"; |
| 3 | + |
| 4 | +const seeded = () => { |
| 5 | + const doc = createBlockDocStore(); |
| 6 | + doc.hydrate( |
| 7 | + { |
| 8 | + goals: [ |
| 9 | + { name: "iron-plate", rate: 2 }, |
| 10 | + { name: "copper-plate", rate: 0.5 }, |
| 11 | + ], |
| 12 | + recipes: ["iron-plate", "copper-plate", "coke"], |
| 13 | + machines: { "iron-plate": "stone-furnace" }, |
| 14 | + fuels: { "iron-plate": "coal" }, |
| 15 | + modules: { "iron-plate": ["speed-module"] }, |
| 16 | + beacons: { "iron-plate": [{ beacon: "beacon", count: 2, modules: ["speed-module"] }] }, |
| 17 | + disabledRecipes: ["coke"], |
| 18 | + rowGroups: [{ id: 1, name: "Smelting" }], |
| 19 | + recipeGroups: { "iron-plate": 1, "copper-plate": 1 }, |
| 20 | + }, |
| 21 | + "Iron", |
| 22 | + ); |
| 23 | + return doc; |
| 24 | +}; |
| 25 | + |
| 26 | +describe("dirty tracking", () => { |
| 27 | + it("hydrate loads clean; mutations mark dirty; markClean/markDirty round-trip", () => { |
| 28 | + const doc = seeded(); |
| 29 | + expect(doc.store.state.hydrated).toBe(true); |
| 30 | + expect(doc.store.state.dirty).toBe(false); |
| 31 | + |
| 32 | + doc.setGoalRate("iron-plate", 3); |
| 33 | + expect(doc.store.state.dirty).toBe(true); |
| 34 | + |
| 35 | + doc.markClean(); // save started |
| 36 | + expect(doc.store.state.dirty).toBe(false); |
| 37 | + doc.markDirty(); // save failed — retry on next edit |
| 38 | + expect(doc.store.state.dirty).toBe(true); |
| 39 | + |
| 40 | + // re-hydration (undo / snapshot restore) resets clean, replacing the doc |
| 41 | + doc.hydrate({ goals: [{ name: "iron-plate", rate: 1 }], recipes: [] }, "Iron"); |
| 42 | + expect(doc.store.state.dirty).toBe(false); |
| 43 | + expect(doc.store.state.recipes).toEqual([]); |
| 44 | + }); |
| 45 | +}); |
| 46 | + |
| 47 | +describe("goals", () => { |
| 48 | + it("addGoal pins 1/s and ignores duplicates", () => { |
| 49 | + const doc = seeded(); |
| 50 | + doc.addGoal("coke"); |
| 51 | + doc.addGoal("iron-plate"); // duplicate |
| 52 | + expect(doc.store.state.goals.map((g) => g.name)).toEqual([ |
| 53 | + "iron-plate", |
| 54 | + "copper-plate", |
| 55 | + "coke", |
| 56 | + ]); |
| 57 | + expect(doc.store.state.goals[2]).toEqual({ name: "coke", rate: 1 }); |
| 58 | + }); |
| 59 | + |
| 60 | + it("makePrimary moves a goal to the front; changeGoalItem swaps in place and drops dupes", () => { |
| 61 | + const doc = seeded(); |
| 62 | + doc.makePrimary("copper-plate"); |
| 63 | + expect(doc.store.state.goals[0].name).toBe("copper-plate"); |
| 64 | + |
| 65 | + doc.changeGoalItem("iron-plate", "steel-plate"); // swap keeps position + rate |
| 66 | + expect(doc.store.state.goals[1]).toMatchObject({ name: "steel-plate", rate: 2 }); |
| 67 | + |
| 68 | + doc.changeGoalItem("steel-plate", "copper-plate"); // target already a goal → dropped |
| 69 | + expect(doc.store.state.goals.map((g) => g.name)).toEqual(["copper-plate"]); |
| 70 | + }); |
| 71 | + |
| 72 | + it("stock goals derive rate = stock / window and convert back losslessly", () => { |
| 73 | + const doc = seeded(); |
| 74 | + doc.makeStockGoal("iron-plate"); // rate 2/s × default window |
| 75 | + const g = doc.store.state.goals[0]; |
| 76 | + expect(g.stock).toBeGreaterThan(0); |
| 77 | + expect(g.rate).toBeCloseTo(g.stock! / g.window!, 10); |
| 78 | + |
| 79 | + doc.setGoalWindow("iron-plate", 600); |
| 80 | + expect(doc.store.state.goals[0].rate).toBeCloseTo(doc.store.state.goals[0].stock! / 600, 10); |
| 81 | + |
| 82 | + doc.setGoalStock("iron-plate", 900); |
| 83 | + expect(doc.store.state.goals[0].rate).toBeCloseTo(900 / 600, 10); |
| 84 | + |
| 85 | + doc.makeRateGoal("iron-plate"); // keeps the derived rate, drops the intent |
| 86 | + expect(doc.store.state.goals[0].stock).toBeUndefined(); |
| 87 | + expect(doc.store.state.goals[0].rate).toBeCloseTo(1.5, 10); |
| 88 | + }); |
| 89 | + |
| 90 | + it("setPrimaryRate touches only goals[0]", () => { |
| 91 | + const doc = seeded(); |
| 92 | + doc.setPrimaryRate(7); |
| 93 | + expect(doc.store.state.goals[0].rate).toBe(7); |
| 94 | + expect(doc.store.state.goals[1].rate).toBe(0.5); |
| 95 | + }); |
| 96 | +}); |
| 97 | + |
| 98 | +describe("recipes", () => { |
| 99 | + it("dropRecipe cascades: picks, modules, beacons, disabled, group membership + prune", () => { |
| 100 | + const doc = seeded(); |
| 101 | + doc.dropRecipe("iron-plate"); |
| 102 | + const s = doc.store.state; |
| 103 | + expect(s.recipes).toEqual(["copper-plate", "coke"]); |
| 104 | + expect(s.machines).toEqual({}); |
| 105 | + expect(s.fuels).toEqual({}); |
| 106 | + expect(s.modules).toEqual({}); |
| 107 | + expect(s.beacons).toEqual({}); |
| 108 | + expect(s.recipeGroups).toEqual({ "copper-plate": 1 }); |
| 109 | + expect(s.rowGroups).toHaveLength(1); // copper-plate still holds the group open |
| 110 | + |
| 111 | + doc.dropRecipe("copper-plate"); |
| 112 | + expect(doc.store.state.rowGroups).toEqual([]); // last member gone → group pruned |
| 113 | + }); |
| 114 | + |
| 115 | + it("dropRecipe clears the disabled flag so re-adding starts enabled", () => { |
| 116 | + const doc = seeded(); |
| 117 | + doc.dropRecipe("coke"); |
| 118 | + expect([...doc.store.state.disabled]).toEqual([]); |
| 119 | + }); |
| 120 | + |
| 121 | + it("applyRecipeDefaults never overwrites an existing pick", () => { |
| 122 | + const doc = seeded(); |
| 123 | + doc.applyRecipeDefaults("iron-plate", { machine: "electric-furnace", fuel: "wood" }); |
| 124 | + expect(doc.store.state.machines["iron-plate"]).toBe("stone-furnace"); // kept |
| 125 | + doc.applyRecipeDefaults("coke", { machine: "coke-oven" }); |
| 126 | + expect(doc.store.state.machines.coke).toBe("coke-oven"); // new row → applied |
| 127 | + }); |
| 128 | +}); |
| 129 | + |
| 130 | +describe("dispositions & spoil plans", () => { |
| 131 | + it("cycleDisposition walks auto → import → export → balance → auto (key removed)", () => { |
| 132 | + const doc = seeded(); |
| 133 | + doc.cycleDisposition("tar"); |
| 134 | + expect(doc.store.state.dispositions.tar).toBe("import"); |
| 135 | + doc.cycleDisposition("tar"); |
| 136 | + expect(doc.store.state.dispositions.tar).toBe("export"); |
| 137 | + doc.cycleDisposition("tar"); |
| 138 | + expect(doc.store.state.dispositions.tar).toBe("balance"); |
| 139 | + doc.cycleDisposition("tar"); |
| 140 | + expect("tar" in doc.store.state.dispositions).toBe(false); |
| 141 | + }); |
| 142 | + |
| 143 | + it("setSpoilRate clears on null or non-positive rates", () => { |
| 144 | + const doc = seeded(); |
| 145 | + doc.setSpoilRate("vrauk", 0.5); |
| 146 | + expect(doc.store.state.spoilRates.vrauk).toBe(0.5); |
| 147 | + doc.setSpoilRate("vrauk", 0); |
| 148 | + expect("vrauk" in doc.store.state.spoilRates).toBe(false); |
| 149 | + doc.setSpoilRate("vrauk", null); |
| 150 | + expect(doc.store.state.spoilRates).toEqual({}); |
| 151 | + }); |
| 152 | +}); |
| 153 | + |
| 154 | +describe("sub-blocks", () => { |
| 155 | + it("createGroupFromRow allocates the next id; removeFromGroup prunes emptied groups", () => { |
| 156 | + const doc = seeded(); |
| 157 | + const id = doc.createGroupFromRow("coke"); |
| 158 | + expect(id).toBe(2); |
| 159 | + expect(doc.store.state.recipeGroups.coke).toBe(2); |
| 160 | + |
| 161 | + doc.removeFromGroup("coke"); |
| 162 | + expect(doc.store.state.rowGroups.map((g) => g.id)).toEqual([1]); // group 2 pruned |
| 163 | + }); |
| 164 | + |
| 165 | + it("ungroupRows dissolves the group but keeps the rows", () => { |
| 166 | + const doc = seeded(); |
| 167 | + doc.ungroupRows(1); |
| 168 | + expect(doc.store.state.rowGroups).toEqual([]); |
| 169 | + expect(doc.store.state.recipeGroups).toEqual({}); |
| 170 | + expect(doc.store.state.recipes).toHaveLength(3); |
| 171 | + }); |
| 172 | + |
| 173 | + it("joinRecipeToGroup makes members contiguous", () => { |
| 174 | + const doc = seeded(); |
| 175 | + doc.joinRecipeToGroup("coke", 1); |
| 176 | + expect(doc.store.state.recipeGroups.coke).toBe(1); |
| 177 | + // members of group 1 are contiguous in recipe order |
| 178 | + const members = doc.store.state.recipes.filter((r) => doc.store.state.recipeGroups[r] === 1); |
| 179 | + const first = doc.store.state.recipes.findIndex((r) => doc.store.state.recipeGroups[r] === 1); |
| 180 | + expect(doc.store.state.recipes.slice(first, first + members.length)).toEqual(members); |
| 181 | + }); |
| 182 | +}); |
| 183 | + |
| 184 | +describe("solveInputOf", () => { |
| 185 | + it("omits empty maps, sorts disabled recipes, and keeps explicit empty module lists", () => { |
| 186 | + const doc = createBlockDocStore(); |
| 187 | + doc.hydrate({ goals: [{ name: "iron-plate", rate: 1 }], recipes: ["iron-plate"] }, "Iron"); |
| 188 | + expect(solveInputOf(doc.store.state)).toEqual({ |
| 189 | + goals: [{ name: "iron-plate", rate: 1 }], |
| 190 | + recipes: ["iron-plate"], |
| 191 | + }); |
| 192 | + |
| 193 | + // explicit [] modules = "no modules" (suppresses auto-fill) — must persist |
| 194 | + doc.setModules("iron-plate", [], []); |
| 195 | + const si = solveInputOf(doc.store.state); |
| 196 | + expect(si.modules).toEqual({ "iron-plate": [] }); |
| 197 | + expect(si.beacons).toBeUndefined(); // empty beacon list pruned |
| 198 | + |
| 199 | + const seededDoc = seeded(); |
| 200 | + const full = solveInputOf(seededDoc.store.state); |
| 201 | + expect(full.disabledRecipes).toEqual(["coke"]); |
| 202 | + expect(full.rowGroups).toHaveLength(1); |
| 203 | + }); |
| 204 | + |
| 205 | + it("round-trips through hydrate (save → load is lossless)", () => { |
| 206 | + const doc = seeded(); |
| 207 | + doc.setSpoilRate("iron-plate", 0.25); |
| 208 | + doc.setCustomIcon({ kind: "fluid", name: "crude-oil" }); |
| 209 | + const saved = solveInputOf(doc.store.state); |
| 210 | + |
| 211 | + const doc2 = createBlockDocStore(); |
| 212 | + doc2.hydrate(saved, "Iron"); |
| 213 | + expect(solveInputOf(doc2.store.state)).toEqual(saved); |
| 214 | + }); |
| 215 | +}); |
0 commit comments