Skip to content

Commit 734dd6d

Browse files
committed
fix(app): clarify generator output and availability
1 parent cee1822 commit 734dd6d

23 files changed

Lines changed: 683 additions & 110 deletions

app/e2e/mut/count-pin.e2e.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
import { DatabaseSync } from "node:sqlite";
12
import { expect, test } from "@playwright/test";
2-
import { addGoal, createBlock } from "./helpers";
3+
import {
4+
activeProjectDbFile,
5+
addGoal,
6+
createBlock,
7+
goto,
8+
uniqueName,
9+
} from "./helpers";
310

411
/**
512
* Inline building-count pin (#121): a recipe row's building count is click-to-
@@ -38,3 +45,99 @@ test("building count: click to fix, tint shows fixed, clear to unpin", async ({
3845
await expect(page.locator('button[title="click to fix the building count"]')).toBeVisible();
3946
await expect(page.locator('button[title^="fixed at"]')).toHaveCount(0);
4047
});
48+
49+
test("variable generator shows its average and min-max output", async ({ page }) => {
50+
const db = new DatabaseSync(activeProjectDbFile());
51+
db.exec("PRAGMA busy_timeout = 5000");
52+
const product = db
53+
.prepare(
54+
`SELECT amount, amount_min, amount_max FROM recipe_products
55+
WHERE recipe = 'generate-multiblade-turbine-mk01' AND name = 'pyops-electricity'`,
56+
)
57+
.get() as { amount: number; amount_min: number | null; amount_max: number | null };
58+
let id: number;
59+
try {
60+
db.prepare(
61+
`UPDATE recipe_products SET amount = 1.2, amount_min = 0.4, amount_max = 2
62+
WHERE recipe = 'generate-multiblade-turbine-mk01' AND name = 'pyops-electricity'`,
63+
).run();
64+
const data = {
65+
recipes: [
66+
"generate-steam-engine-250",
67+
"boil-steam-250",
68+
"generate-multiblade-turbine-mk01",
69+
],
70+
made: ["steam"],
71+
pins: [{ kind: "count", recipe: "generate-multiblade-turbine-mk01", count: 40 }],
72+
machines: {
73+
"generate-steam-engine-250": "steam-engine",
74+
"boil-steam-250": "boiler",
75+
"generate-multiblade-turbine-mk01": "multiblade-turbine-mk01",
76+
},
77+
fuels: { "boil-steam-250": "raw-coal" },
78+
goals: [{ name: "pyops-electricity", rate: 50 }],
79+
};
80+
const inserted = db
81+
.prepare("INSERT INTO blocks (name, data) VALUES (?, ?)")
82+
.run(uniqueName("Variable power"), JSON.stringify(data));
83+
id = Number(inserted.lastInsertRowid);
84+
} finally {
85+
db.close();
86+
}
87+
88+
try {
89+
await goto(page, `/block/${id}`);
90+
const ignore = page.getByRole("button", { name: "Ignore for now" });
91+
if (await ignore.isVisible()) await ignore.click();
92+
await expect(page.locator('[data-rate-range="variable"]')).toHaveText(
93+
"48 MW avg · 16 MW–80 MW",
94+
);
95+
} finally {
96+
const cleanup = new DatabaseSync(activeProjectDbFile());
97+
try {
98+
cleanup.exec("PRAGMA busy_timeout = 5000");
99+
cleanup
100+
.prepare(
101+
`UPDATE recipe_products SET amount = ?, amount_min = ?, amount_max = ?
102+
WHERE recipe = 'generate-multiblade-turbine-mk01' AND name = 'pyops-electricity'`,
103+
)
104+
.run(product.amount, product.amount_min, product.amount_max);
105+
cleanup.prepare("DELETE FROM blocks WHERE id = ?").run(id);
106+
} finally {
107+
cleanup.close();
108+
}
109+
}
110+
});
111+
112+
test("recipe picker groups unlocked choices and disables locked buildings", async ({ page }) => {
113+
await createBlock(page);
114+
await page.locator('button[title="add a goal product"]').click();
115+
const goalDialog = page.getByRole("dialog", { name: "Add a goal product" });
116+
await goalDialog.getByPlaceholder("search an item or fluid…").fill("pyops electricity");
117+
await goalDialog.getByRole("button", { name: "Electricity (MJ)", exact: true }).click();
118+
await expect(goalDialog).toBeHidden();
119+
await page.locator('button[aria-label^="add a recipe that makes "]').click();
120+
121+
const picker = page.getByRole("dialog", { name: "Recipes that make Electricity (MJ)" });
122+
const unlocked = picker.getByText("Unlocked now", { exact: true });
123+
const locked = picker.getByText("Locked or unavailable", { exact: true });
124+
await expect(unlocked).toBeVisible();
125+
await expect(locked).toBeVisible();
126+
expect((await unlocked.boundingBox())!.y).toBeLessThan((await locked.boundingBox())!.y);
127+
128+
const steam = picker.getByRole("button", { name: /Steam engine power \(150°\)/ });
129+
await expect(steam).toHaveAttribute("aria-disabled", "false");
130+
await expect(steam).toContainText("unlocked now · Steam engine");
131+
132+
const solar = picker.getByRole("button", { name: /Solar panel power \(peak\)/ });
133+
await expect(solar).toHaveAttribute("aria-disabled", "true");
134+
await expect(solar).toContainText("building locked · Solar panel · needs Solar energy");
135+
await solar.click({ force: true });
136+
await expect(picker).toBeVisible();
137+
138+
// Py's `-blank` runtime entity uses the same localized name and output. It is
139+
// an alternate state of this building, not a second recipe choice.
140+
await expect(
141+
picker.getByText('Multiblade "fish" turbine power (average)', { exact: true }),
142+
).toHaveCount(1);
143+
});

app/e2e/mut/extract-recipe.e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ test("extract a recipe row into a dedicated block from the row icon menu", async
77

88
await page.locator('button[aria-label^="add a recipe that makes "]').click();
99
const platePicker = page.getByRole("dialog", { name: /Recipes that make/ });
10-
await platePicker.getByRole("button", { name: /Iron plate/ }).first().click();
10+
await platePicker.locator('[data-recipe-candidate="iron-plate"]').click();
1111
await expect(platePicker).toBeHidden();
1212

1313
await page.getByRole("button", { name: /^Iron ore.*(raw input|craftable)/ }).first().click();

app/e2e/mut/fluid-fuel.e2e.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { expect, test } from "@playwright/test";
2-
import { addGoal, blockNameInput, createBlock, expectUndoTop, goto, uniqueName } from "./helpers";
2+
import {
3+
addGoal,
4+
blockNameInput,
5+
createBlock,
6+
expectUndoTop,
7+
goto,
8+
setPlanningHorizon,
9+
uniqueName,
10+
} from "./helpers";
311

412
/**
513
* Fungible fluid fuel (#25): a machine whose energy source burns UNFILTERED
@@ -9,13 +17,15 @@ import { addGoal, blockNameInput, createBlock, expectUndoTop, goto, uniqueName }
917
* per-row fluid pick like the old behavior (which defaulted to petroleum-gas).
1018
*/
1119
test("a fluid-burning drill demands the Fluid fuel pool, not a picked fluid", async ({ page }) => {
20+
await goto(page, "/settings");
21+
await setPlanningHorizon(page, "Future");
1222
await createBlock(page);
1323
await addGoal(page, "antimony ore", "Antimony ore");
1424

1525
// several recipes make Antimony ore → the picker opens; take the drill's one
1626
await page.locator('button[aria-label^="add a recipe that makes "]').click();
1727
const picker = page.getByRole("dialog", { name: /Recipes that make/ });
18-
await picker.getByRole("button", { name: /Mine Antimony field/ }).click();
28+
await picker.locator('[data-recipe-candidate="mine-antimonium"]').click();
1929
await expect(picker).toBeHidden();
2030
await expect(page.getByText("Mine Antimony field")).toBeVisible();
2131

@@ -33,6 +43,7 @@ test("a fluid-burning drill demands the Fluid fuel pool, not a picked fluid", as
3343
await expect(page.getByLabel(/^Fluid fuel \(MJ\) \S+ [MkG]?W/).first()).toBeVisible();
3444
// the pre-#25 behavior defaulted fluid burners to a petroleum-gas import — gone
3545
await expect(page.getByLabel(/^Petroleum gas/)).toHaveCount(0);
46+
await setPlanningHorizon(page, "Now");
3647
});
3748

3849
/**

app/e2e/mut/helpers.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,22 @@ export function goalRateButton(page: Page) {
112112
return page.locator('button[title^="click to edit the goal rate"]');
113113
}
114114

115+
/** Change the global planning horizon through the same header dialog a player
116+
* uses. Advanced-recipe specs opt into Future explicitly now that locked recipe
117+
* picker rows are intentionally disabled in Now mode. */
118+
export async function setPlanningHorizon(
119+
page: Page,
120+
mode: "Now" | "Future" | "Up to target",
121+
): Promise<void> {
122+
await page.getByRole("button", { name: /^Horizon:/ }).click();
123+
const dialog = page.getByRole("dialog", { name: "Planning horizon" });
124+
const choice = dialog.getByRole("button", { name: mode, exact: true });
125+
await choice.click();
126+
await expect(choice).toHaveAttribute("aria-pressed", "true");
127+
await page.keyboard.press("Escape");
128+
await expect(dialog).toBeHidden();
129+
}
130+
115131
/** Click the goal rate, type a new value, commit with Enter. */
116132
export async function setGoalRate(page: Page, value: string): Promise<void> {
117133
await goalRateButton(page).click();

app/e2e/mut/made-links.e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ test("marking an import made without a producer keeps it a silent import", async
2121

2222
await page.locator('button[aria-label^="add a recipe that makes "]').click();
2323
const platePicker = page.getByRole("dialog", { name: /Recipes that make/ });
24-
await platePicker.getByRole("button", { name: /Iron plate/ }).first().click();
24+
await platePicker.locator('[data-recipe-candidate="iron-plate"]').click();
2525
await expect(platePicker).toBeHidden();
2626

2727
const oreImport = page.getByRole("button", { name: /^Iron ore.*(raw input|craftable)/ }).first();

app/e2e/mut/subblock.e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ test("promote a sub-block to a composed module, edit its goal, and persist", asy
2121
// add the plate producer (self-links the goal)
2222
await page.locator('button[aria-label^="add a recipe that makes "]').click();
2323
const platePicker = page.getByRole("dialog", { name: /Recipes that make/ });
24-
await platePicker.getByRole("button", { name: /Iron plate/ }).first().click();
24+
await platePicker.locator('[data-recipe-candidate="iron-plate"]').click();
2525
await expect(platePicker).toBeHidden();
2626

2727
// add an iron-ore producer, so the block has a two-recipe chain to fold

app/e2e/mut/sushi.e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ test("sushi planner sizes one mixed loop for the block's flows", async ({ page }
1313
// plate recipe, then a producer for its ore ingredient → ore becomes internal
1414
await page.locator('button[aria-label^="add a recipe that makes "]').click();
1515
const platePicker = page.getByRole("dialog", { name: /Recipes that make/ });
16-
await platePicker.getByRole("button", { name: /Iron plate/ }).first().click();
16+
await platePicker.locator('[data-recipe-candidate="iron-plate"]').click();
1717
await expect(platePicker).toBeHidden();
1818
await page.getByRole("button", { name: /^Iron ore.*(raw input|craftable)/ }).first().click();
1919
const orePicker = page.getByRole("dialog", { name: /Recipes that make/ });

app/src/components/block/item-chip.test.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,24 @@ vi.mock("#/server/factorio", () => ({
1111
}));
1212

1313
describe("ItemChip spoil time", () => {
14+
it("shows average, minimum, and maximum for variable power generation", () => {
15+
const { getByRole, getByText } = render(
16+
<ItemChip
17+
name="pyops-electricity"
18+
kind="fluid"
19+
display="Electricity (MJ)"
20+
rate={48}
21+
rateMin={16}
22+
rateMax={80}
23+
link="target"
24+
onClick={() => {}}
25+
/>,
26+
);
27+
28+
expect(getByText("48 MW avg · 16 MW–80 MW").getAttribute("data-rate-range")).toBe("variable");
29+
expect(getByRole("button").getAttribute("aria-label")).toContain("48 MW avg · 16 MW–80 MW");
30+
});
31+
1432
it("shows the product's spoil time and includes it in the accessible label", () => {
1533
const { getByRole, getByText } = render(
1634
<ItemChip

app/src/components/block/item-chip.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export function ItemChip({
2525
kind,
2626
display,
2727
rate,
28+
rateMin,
29+
rateMax,
2830
temp,
2931
spoilTicks,
3032
link,
@@ -38,6 +40,9 @@ export function ItemChip({
3840
kind: string;
3941
display?: string | null;
4042
rate?: number;
43+
/** Variable energy production range; `rate` is the planner average. */
44+
rateMin?: number;
45+
rateMax?: number;
4146
/** temperature label for fluids ("125°", "≤101°") — shown after the rate */
4247
temp?: string | null;
4348
/** visible spoil time for a product; ingredient chips leave this unset */
@@ -58,6 +63,19 @@ export function ItemChip({
5863
? "raw input — supply externally"
5964
: link;
6065
const spoilTime = spoilTicks != null ? fmtSpoilTime(spoilTicks) : null;
66+
const variableRate =
67+
rate != null &&
68+
rateMin != null &&
69+
rateMax != null &&
70+
(Math.abs(rateMin - rate) > 1e-9 || Math.abs(rateMax - rate) > 1e-9);
71+
const displayedRate =
72+
rate == null
73+
? ""
74+
: variableRate
75+
? `${rateLabel(name, rate)} avg · ${rateLabel(name, rateMin)}${rateLabel(name, rateMax)}`
76+
: rateLabel(name, rate);
77+
const accessibleRate =
78+
rate == null ? "" : variableRate ? displayedRate : rateLabel(name, rate, { perSec: true });
6179
return (
6280
<span className="inline-flex items-center gap-1">
6381
<ItemHover
@@ -74,7 +92,7 @@ export function ItemChip({
7492
e.preventDefault();
7593
onContext(e);
7694
}}
77-
aria-label={`${display ?? name}${rate != null ? ` ${rateLabel(name, rate, { perSec: true })}` : ""}${spoilTime ? ` · spoils in ${spoilTime}` : ""}${incidental ? " · includes estimated incidental spoilage" : ""} · ${why}`}
95+
aria-label={`${display ?? name}${accessibleRate ? ` ${accessibleRate}` : ""}${spoilTime ? ` · spoils in ${spoilTime}` : ""}${incidental ? " · includes estimated incidental spoilage" : ""} · ${why}`}
7896
className={`flex items-center gap-1 px-1.5 py-1 text-sm hover:brightness-95 ${cls}`}
7997
>
8098
<span className="relative flex">
@@ -87,7 +105,9 @@ export function ItemChip({
87105
/>
88106
)}
89107
</span>
90-
{rate != null && <span>{rateLabel(name, rate)}</span>}
108+
{rate != null && (
109+
<span data-rate-range={variableRate ? "variable" : undefined}>{displayedRate}</span>
110+
)}
91111
{incidental && (
92112
<span
93113
data-incidental-spoilage

0 commit comments

Comments
 (0)