Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
df64a1e
docs: add recipe panels design document
aaronbrezel Feb 25, 2026
f059068
docs: add recipe panels implementation plan
aaronbrezel Feb 25, 2026
56716c8
feat: add RecipeFieldConfig, RecipeParams, PrepRecipeParams, PrepReci…
aaronbrezel Feb 26, 2026
b641d1a
feat: add 'recipe' to PanelId, add RecipeDefinition interface
aaronbrezel Feb 26, 2026
c85e202
feat: add findOrCreateColumn and writeColumn helpers to utils
aaronbrezel Feb 26, 2026
16edd38
fix: update utils.ts header comment; guard writeColumn against empty …
aaronbrezel Feb 26, 2026
c3bba1a
feat: add prepRecipe server function and rollup global stub
aaronbrezel Feb 26, 2026
7d1c0dc
fix: revert out-of-scope document-summarization PanelId addition
aaronbrezel Feb 26, 2026
a91406a
feat: add prepRecipe service wrapper
aaronbrezel Feb 26, 2026
f869324
fix: use PrepRecipeParams type in google.d.ts stub
aaronbrezel Feb 26, 2026
464693a
feat: add onUnlock callback to LockableField
aaronbrezel Feb 26, 2026
5d475e4
feat: add RecipePrepCook component with prep/cook state machine
aaronbrezel Feb 26, 2026
71da1aa
feat: add RECIPES registry with Document Summarization entry
aaronbrezel Feb 26, 2026
75d91f1
feat: add generic RecipePanel driven by RecipeParams
aaronbrezel Feb 26, 2026
0886855
feat: replace RecipesListPanel stub with data-driven RECIPES implemen…
aaronbrezel Feb 26, 2026
a433bb3
fix: suppress double-alert on silent validation cancellation
aaronbrezel Feb 26, 2026
539e658
feat: register RecipePanel in router; remove DocumentSummarizationPan…
aaronbrezel Feb 26, 2026
9d48e77
test: add coverage thresholds for recipe-prep-cook, recipe, recipes-list
aaronbrezel Feb 26, 2026
d704fe6
fix: use single tsconfig in jest transform to avoid ts-jest ConfigSet…
aaronbrezel Feb 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions __tests__/components/lockable-field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,26 @@ describe("LockableField", () => {
expect(c.querySelector("input")).toBeNull();
});
});

describe("onUnlock callback", () => {
it("calls onUnlock when the unlock button is clicked", () => {
const container = document.createElement("div");
const onUnlock = jest.fn();
new LockableField(container, {
label: "Test",
defaultValue: "hello",
locked: true,
onUnlock,
});
const btn = container.querySelector<HTMLButtonElement>(".unlock-btn")!;
btn.click();
expect(onUnlock).toHaveBeenCalledTimes(1);
});

it("does not error when onUnlock is not provided", () => {
const container = document.createElement("div");
new LockableField(container, { label: "Test", defaultValue: "hello" });
const btn = container.querySelector<HTMLButtonElement>(".unlock-btn")!;
expect(() => btn.click()).not.toThrow();
});
});
148 changes: 148 additions & 0 deletions __tests__/components/recipe-prep-cook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* @jest-environment jsdom
*/
import { RecipePrepCook } from "../../src/client/components/recipe-prep-cook";

function mount(config: ConstructorParameters<typeof RecipePrepCook>[1]) {
const container = document.createElement("div");
const component = new RecipePrepCook(container, config);
return { container, component };
}

describe("idle state", () => {
it("renders Prep enabled and Cook disabled", () => {
const { container } = mount({ onPrep: jest.fn(), onCook: jest.fn() });
const prep = container.querySelector<HTMLButtonElement>("#prep-btn")!;
const cook = container.querySelector<HTMLButtonElement>("#cook-btn")!;
expect(prep.disabled).toBe(false);
expect(prep.textContent).toBe("Prep Recipe");
expect(cook.disabled).toBe(true);
});
});

describe("prepping state", () => {
it("disables Prep and shows Prepping... while onPrep is pending", async () => {
let resolvePrep!: () => void;
const onPrep = jest.fn(
() =>
new Promise<void>((res) => {
resolvePrep = res;
}),
);
const { container } = mount({ onPrep, onCook: jest.fn() });
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
const prep = container.querySelector<HTMLButtonElement>("#prep-btn")!;
expect(prep.disabled).toBe(true);
expect(prep.textContent).toBe("Prepping...");
resolvePrep();
});
});

describe("prep-complete state", () => {
async function mountPrepped(onCook = jest.fn()) {
const onPrep = jest.fn().mockResolvedValue(undefined);
const { container, component } = mount({ onPrep, onCook });
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await Promise.resolve();
await Promise.resolve();
return { container, component, onCook };
}

it("enables Cook and shows Re-prep after onPrep resolves", async () => {
const { container } = await mountPrepped();
const prep = container.querySelector<HTMLButtonElement>("#prep-btn")!;
const cook = container.querySelector<HTMLButtonElement>("#cook-btn")!;
expect(prep.disabled).toBe(false);
expect(prep.textContent).toBe("Re-prep");
expect(cook.disabled).toBe(false);
});

it("isPrepComplete returns true", async () => {
const { component } = await mountPrepped();
expect(component.isPrepComplete()).toBe(true);
});

it("calls onCook when Cook is clicked (sync)", async () => {
const { container, onCook } = await mountPrepped();
container.querySelector<HTMLButtonElement>("#cook-btn")!.click();
expect(onCook).toHaveBeenCalledTimes(1);
});

it("does not enter cooking state when onCook is synchronous", async () => {
const { container } = await mountPrepped(jest.fn(() => undefined));
container.querySelector<HTMLButtonElement>("#cook-btn")!.click();
const cook = container.querySelector<HTMLButtonElement>("#cook-btn")!;
// remains in prep-complete (cook is still enabled)
expect(cook.disabled).toBe(false);
});
});

describe("cooking state", () => {
it("disables both buttons when onCook returns a Promise", async () => {
const onPrep = jest.fn().mockResolvedValue(undefined);
let resolveCook!: () => void;
const onCook = jest.fn(
() =>
new Promise<void>((res) => {
resolveCook = res;
}),
);
const { container } = mount({ onPrep, onCook });
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await Promise.resolve();
await Promise.resolve();
container.querySelector<HTMLButtonElement>("#cook-btn")!.click();
const prep = container.querySelector<HTMLButtonElement>("#prep-btn")!;
const cook = container.querySelector<HTMLButtonElement>("#cook-btn")!;
expect(prep.disabled).toBe(true);
expect(cook.disabled).toBe(true);
expect(cook.textContent).toBe("Cooking...");
resolveCook();
});
});

describe("error handling", () => {
it("returns to idle and shows alert when onPrep rejects", async () => {
const alertMock = jest.fn();
globalThis.alert = alertMock;
const onPrep = jest.fn().mockRejectedValue(new Error("prep failed"));
const { container, component } = mount({ onPrep, onCook: jest.fn() });
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await Promise.resolve();
await Promise.resolve();
expect(alertMock).toHaveBeenCalledWith("Error: prep failed");
expect(component.isPrepComplete()).toBe(false);
const prep = container.querySelector<HTMLButtonElement>("#prep-btn")!;
expect(prep.disabled).toBe(false);
expect(prep.textContent).toBe("Prep Recipe");
});
});

describe("reset()", () => {
it("returns to idle and disables Cook", async () => {
const onPrep = jest.fn().mockResolvedValue(undefined);
const { container, component } = mount({ onPrep, onCook: jest.fn() });
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await Promise.resolve();
await Promise.resolve();
component.reset();
const prep = container.querySelector<HTMLButtonElement>("#prep-btn")!;
const cook = container.querySelector<HTMLButtonElement>("#cook-btn")!;
expect(prep.textContent).toBe("Prep Recipe");
expect(cook.disabled).toBe(true);
expect(component.isPrepComplete()).toBe(false);
});
});

describe("initialState restoration", () => {
it("mounts in prep-complete state when prepComplete: true", () => {
const { container, component } = mount({
onPrep: jest.fn(),
onCook: jest.fn(),
prepComplete: true,
});
const cook = container.querySelector<HTMLButtonElement>("#cook-btn")!;
expect(cook.disabled).toBe(false);
expect(component.isPrepComplete()).toBe(true);
});
});
Loading