Skip to content

Commit f9d69ee

Browse files
committed
refactor(app): move the block doc into an external TanStack Store
Extract the block editor's persisted document (goals, recipes, per-recipe picks, groups, dispositions, spoil plans) out of component useState into components/block/doc-store.ts — a per-editor-instance @tanstack/store with every transition as a plain, unit-tested action (15 tests cover the drop-recipe cascade, group join/leave/prune, stock<->rate conversions, dirty semantics, and the save-doc round-trip). Dirty tracking is now structural: every mutating action marks the doc dirty in one place, replacing the per-handler markEdited() discipline the code documented as CRITICAL. hydrate() loads server state without marking dirty and is callable at any time — the plumbing undo (#90), snapshot restore (#85), and assistant applies (#12) need to push an external write into an open editor. The unmount flush reads the store directly, so it can no longer save a stale snapshot. Refs #90
1 parent e14d82a commit f9d69ee

6 files changed

Lines changed: 682 additions & 330 deletions

File tree

app/.fallow/churn.bin

2.1 KB
Binary file not shown.

app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@tanstack/react-router-devtools": "latest",
3333
"@tanstack/react-router-ssr-query": "latest",
3434
"@tanstack/react-start": "latest",
35+
"@tanstack/react-store": "latest",
3536
"@tanstack/react-table": "latest",
3637
"@tauri-apps/plugin-process": "^2.3.1",
3738
"@tauri-apps/plugin-updater": "^2.10.1",

app/pnpm-lock.yaml

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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

Comments
 (0)