Skip to content

Commit 0f4a1e7

Browse files
committed
feat(scenario): cache results and report solve progress
Persist completed factory scenarios by their effective inputs so route visits can reuse current results. Make recalculation explicit after edits and surface each long-running solver phase without hiding the previous preview.\n\nCloses #156
1 parent 97e88cc commit 0f4a1e7

15 files changed

Lines changed: 880 additions & 100 deletions

app/e2e/mut/factory-debug.e2e.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,22 @@ test("Advanced settings captures a structured Scenario solver trace", async ({ p
1515

1616
await page.setViewportSize({ width: 3840, height: 1080 });
1717
await goto(page, "/factory/scenario");
18+
const status = page.getByTestId("scenario-status");
19+
await expect(status).toBeVisible();
20+
await expect(status).toHaveAttribute("data-state", /^(current|stale|empty|calculating)$/);
21+
if (["empty", "calculating"].includes((await status.getAttribute("data-state")) ?? ""))
22+
await expect(status).toHaveAttribute("data-state", "current", { timeout: 90_000 });
23+
await status.getByRole("button", { name: "Recalculate" }).click();
24+
await expect(status).toHaveAttribute("data-state", "current", { timeout: 90_000 });
1825
await expect(page.getByText("Goal changes", { exact: false }).first()).toBeVisible({
1926
timeout: 30_000,
2027
});
2128
const workspace = await page.getByTestId("scenario-workspace").boundingBox();
2229
expect(workspace?.width).toBeLessThanOrEqual(1600);
2330
expect(workspace?.x).toBeGreaterThan(1_000);
24-
await expect(page.getByText("Factory use/s", { exact: true })).toBeVisible();
25-
await expect(page.getByText("Block output/s", { exact: true })).toBeVisible();
26-
await expect(page.getByText("Surplus/s", { exact: true })).toBeVisible();
31+
await expect(page.getByText("Factory use/s", { exact: true }).first()).toBeVisible();
32+
await expect(page.getByText("Block output/s", { exact: true }).first()).toBeVisible();
33+
await expect(page.getByText("Surplus/s", { exact: true }).first()).toBeVisible();
2734

2835
await goto(page, "/settings?tab=advanced");
2936
await page.getByRole("button", { name: "Refresh" }).click();

app/e2e/mut/rebalance.e2e.ts

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ async function dismissDataDriftPrompt(page: Page) {
1212
}
1313
}
1414

15+
async function recalculateScenario(page: Page) {
16+
const status = page.getByTestId("scenario-status");
17+
await expect(status).toBeVisible({ timeout: 30_000 });
18+
await expect(status).toHaveAttribute("data-state", /^(current|stale|empty|calculating)$/, {
19+
timeout: 30_000,
20+
});
21+
const state = await status.getAttribute("data-state");
22+
if (state === "stale")
23+
await status.getByRole("button", { name: "Recalculate" }).click();
24+
await expect(status).toHaveAttribute("data-state", "current", { timeout: 90_000 });
25+
}
26+
1527
test("factory pins can add and persist a consumption target", async ({ page }) => {
1628
await goto(page, "/factory/scenario");
1729
await dismissDataDriftPrompt(page);
@@ -24,8 +36,11 @@ test("factory pins can add and persist a consumption target", async ({ page }) =
2436
const input = page.getByRole("spinbutton", { name: "Acetaldehyde factory pin" });
2537
await expect(input).toBeVisible();
2638
await input.fill("-2");
27-
await input.blur();
2839
await expect(input).toHaveValue("-2");
40+
await page.getByTestId("scenario-status").getByRole("button", { name: "Recalculate" }).click();
41+
await expect(page.getByTestId("scenario-status")).toHaveAttribute("data-state", "current", {
42+
timeout: 90_000,
43+
});
2944
await expect
3045
.poll(() => {
3146
const db = new DatabaseSync(activeProjectDbFile(), { readOnly: true });
@@ -47,24 +62,55 @@ test("factory pins can add and persist a consumption target", async ({ page }) =
4762
await expect(persisted).toBeHidden();
4863
});
4964

50-
test("what-if target stays editable while the factory re-solves", async ({ page }) => {
65+
test("Scenario target waits for explicit recalculation and reports progress", async ({ page }) => {
66+
test.setTimeout(120_000);
5167
await goto(page, "/factory/scenario");
5268
await dismissDataDriftPrompt(page);
69+
await recalculateScenario(page);
5370

5471
const firstDemand = page.getByRole("spinbutton").first();
5572
await expect(firstDemand).toBeVisible();
73+
const originalTarget = await firstDemand.inputValue();
5674
await firstDemand.selectText();
5775
await firstDemand.pressSequentially("12.34", { delay: 100 });
5876

59-
// Each character starts another solve. The demand list must remain mounted,
60-
// preserving both the in-progress value and keyboard focus throughout them.
61-
await expect(firstDemand).toBeFocused();
62-
await expect(firstDemand).toHaveValue("12.34");
63-
await expect(page.getByText("Goal changes", { exact: false }).first()).toBeVisible({
64-
timeout: 15_000,
65-
});
77+
// Typing is now a cheap draft: it preserves focus and marks the previous
78+
// result stale instead of launching a whole-factory solve per character.
6679
await expect(firstDemand).toBeFocused();
6780
await expect(firstDemand).toHaveValue("12.34");
81+
const status = page.getByTestId("scenario-status");
82+
await expect(status).toHaveAttribute("data-state", "stale");
83+
await expect(status).toContainText("Targets or factory inputs changed");
84+
85+
await status.getByRole("button", { name: "Recalculate" }).click();
86+
await expect(status).toHaveAttribute("data-state", "calculating");
87+
await expect(status).toContainText(/Preparing|response models|Solving factory|Validating blocks/);
88+
await expect(status).toHaveAttribute("data-state", "current", { timeout: 90_000 });
89+
await expect(status).toContainText("Scenario is up to date");
90+
91+
// Restore the shared scratch project's target and its cached result for the
92+
// remaining tests in this serial mutating suite.
93+
await firstDemand.fill(originalTarget);
94+
await expect(status).toHaveAttribute("data-state", "stale");
95+
await status.getByRole("button", { name: "Recalculate" }).click();
96+
await expect(status).toHaveAttribute("data-state", "current", { timeout: 90_000 });
97+
});
98+
99+
test("revisiting Scenario reuses the current persisted result", async ({ page }) => {
100+
test.setTimeout(120_000);
101+
await goto(page, "/factory/scenario");
102+
await dismissDataDriftPrompt(page);
103+
await recalculateScenario(page);
104+
const status = page.getByTestId("scenario-status");
105+
const calculatedAt = await status.getAttribute("data-calculated-at");
106+
expect(calculatedAt).toBeTruthy();
107+
108+
await page.getByRole("link", { name: "Connections", exact: true }).click();
109+
await expect(page).toHaveURL(/\/factory\/connections$/);
110+
await page.getByRole("link", { name: "Scenario", exact: true }).click();
111+
await expect(page).toHaveURL(/\/factory\/scenario$/);
112+
await expect(status).toHaveAttribute("data-state", "current");
113+
await expect(status).toHaveAttribute("data-calculated-at", calculatedAt!);
68114
});
69115

70116
/**
@@ -84,6 +130,7 @@ test("applying a scenario re-balances the factory in one undoable step", async (
84130
test.setTimeout(120_000);
85131
await goto(page, "/factory/scenario");
86132
await dismissDataDriftPrompt(page);
133+
await recalculateScenario(page);
87134

88135
// the Factory pins card is the first place with numeric (spinbutton) inputs
89136
const firstDemand = page.getByRole("spinbutton").first();
@@ -92,6 +139,11 @@ test("applying a scenario re-balances the factory in one undoable step", async (
92139
// 2× + 1 guarantees a real increase even if the current target reads 0
93140
await firstDemand.fill(String(current * 2 + 1));
94141

142+
await page.getByTestId("scenario-status").getByRole("button", { name: "Recalculate" }).click();
143+
await expect(page.getByTestId("scenario-status")).toHaveAttribute("data-state", "current", {
144+
timeout: 90_000,
145+
});
146+
95147
// the doubled demand cascades into real per-block changes → Apply scenario enables
96148
const applyAll = page.getByRole("button", { name: "Apply scenario" });
97149
await expect(applyAll).toBeEnabled({ timeout: 15_000 });
@@ -171,6 +223,7 @@ test("Scenario zeros an unpinned consume goal without saving the preview", async
171223

172224
try {
173225
await goto(page, "/factory/scenario");
226+
await recalculateScenario(page);
174227
const change = page.getByRole("link", { name: /^Acetaldehyde / });
175228
await expect(change).toContainText("-2");
176229
await expect(change).toContainText("Next goal/s0");
@@ -223,6 +276,7 @@ test("Scenario scales a goal beyond recovered coproduct supply", async ({ page }
223276
try {
224277
await goto(page, "/factory/scenario");
225278
await dismissDataDriftPrompt(page);
279+
await recalculateScenario(page);
226280

227281
await expect(page.getByRole("link", { name: /^Creosote / })).toBeVisible({
228282
timeout: 60_000,
@@ -264,6 +318,7 @@ test("Scenario explains which proposed block goals failed validation", async ({
264318
try {
265319
await goto(page, "/factory/scenario");
266320
await dismissDataDriftPrompt(page);
321+
await recalculateScenario(page);
267322

268323
const diagnostic = page.getByTestId("scenario-validation");
269324
await expect(diagnostic.getByText("Scenario validation failed")).toBeVisible({

app/src/components/whatif/factory-pins-card.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export function FactoryPinsCard({
3535
) => setFactoryPinsFn({ data: rows }),
3636
onSuccess: async () => {
3737
await qc.invalidateQueries({ queryKey: ["factoryPins"] });
38-
await qc.invalidateQueries({ queryKey: ["whatif"] });
38+
await qc.invalidateQueries({ queryKey: ["factoryScenarioSnapshot"] });
3939
},
4040
});
4141
const rows = pins.data ?? [];
@@ -90,12 +90,6 @@ export function FactoryPinsCard({
9090
step="0.01"
9191
value={value}
9292
onChange={(event) => onOverride(pin.good, Number(event.target.value) || 0)}
93-
onBlur={() => {
94-
if (Math.abs(value) <= 1e-9) return;
95-
persist(
96-
rows.map((row) => (row.good === pin.good ? { ...row, rate: value } : row)),
97-
);
98-
}}
9993
className="w-24 text-right"
10094
aria-label={`${pin.display} factory pin`}
10195
/>

app/src/components/whatif/rebalance-all-button.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ type BlockChange = {
3535
// Query families the apply touches — everything a block rate change feeds. Mirrors
3636
// undo-client's UNDO_QUERY_KEYS (this is a bulk block write) plus the what-if solve.
3737
const REFRESH_KEYS = [
38-
["whatif"],
38+
["factoryScenarioSnapshot"],
3939
["blocks"],
4040
["block"],
4141
["blocksForGood"],
@@ -59,11 +59,17 @@ export function RebalanceAllButton({
5959
changed,
6060
overrides,
6161
status,
62+
previewStale,
63+
onProgressStart,
64+
onProgressEnd,
6265
onApplied,
6366
}: {
6467
changed: BlockChange[];
6568
overrides: Record<string, number>;
6669
status: string | undefined;
70+
previewStale: boolean;
71+
onProgressStart: (requestId: string) => void;
72+
onProgressEnd: () => void;
6773
onApplied: () => void;
6874
}) {
6975
const qc = useQueryClient();
@@ -72,8 +78,9 @@ export function RebalanceAllButton({
7278

7379
const notOptimal = status != null && status !== "Optimal";
7480
const applyingScenario = Object.keys(overrides).length > 0;
75-
const disabledReason =
76-
changed.length === 0
81+
const disabledReason = previewStale
82+
? "Recalculate the Scenario before applying these targets"
83+
: changed.length === 0
7784
? "Nothing to re-balance — every block already meets demand"
7885
: notOptimal
7986
? `The solve is ${status} — the target can't be met, so there's nothing safe to apply`
@@ -84,8 +91,11 @@ export function RebalanceAllButton({
8491

8592
const apply = async () => {
8693
setApplying(true);
94+
const requestId = globalThis.crypto.randomUUID();
95+
onProgressStart(requestId);
96+
let refreshScenario = false;
8797
try {
88-
const res = await applyPinnedFactoryFn({ data: { demands: overrides } });
98+
const res = await applyPinnedFactoryFn({ data: { demands: overrides, requestId } });
8999
// a non-Optimal solve is left unapplied server-side — say so and stop
90100
if (res.status !== "Optimal") {
91101
toast({
@@ -97,7 +107,7 @@ export function RebalanceAllButton({
97107
await Promise.all(
98108
REFRESH_KEYS.map((queryKey) => qc.invalidateQueries({ queryKey: [...queryKey] })),
99109
);
100-
onApplied();
110+
refreshScenario = true;
101111
if (res.applied.length) {
102112
const n = res.applied.length;
103113
undoToast(qc, `Balanced ${n} block${n === 1 ? "" : "s"} from the factory pins`);
@@ -110,9 +120,11 @@ export function RebalanceAllButton({
110120
if (!res.applied.length && !res.broken.length)
111121
toast({ message: "Already balanced — nothing to apply", tone: "success" });
112122
} finally {
123+
onProgressEnd();
113124
setApplying(false);
114125
setOpen(false);
115126
}
127+
if (refreshScenario) onApplied();
116128
};
117129

118130
const button = (

0 commit comments

Comments
 (0)