Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
252 changes: 82 additions & 170 deletions __tests__/panels/recipe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jest.mock("../../src/client/services", () => ({
import { RecipePanel } from "../../src/client/panels/recipe";
import * as services from "../../src/client/services";
import type { PrepRecipeResult } from "../../src/shared/types";
import type { ColumnDef, RecipeDefinition, RecipeParams, NavigationContext } from "../../src/client/types";
import type { RecipeDefinition, NavigationContext } from "../../src/client/types";

const mockPrepRecipe = services.prepRecipe as jest.Mock;

Expand All @@ -20,18 +20,37 @@ function makeNav(): jest.Mocked<NavigationContext> {
};
}

function mount(params: RecipeParams, savedState?: unknown) {
const baseDefinition: RecipeDefinition = {
id: "test-recipe",
name: "Test Recipe",
icon: "🧪",
description: "A test recipe",
inputs: [
{ id: "folder", label: "Drive Folder", required: true, placeholder: "Paste folder URL" },
{ id: "question", label: "What are you looking for?" },
],
prepTemplate: [
{
colTitle: "Drive Link",
fillStrategy: { kind: "list-drive-folder", inputId: "folder" },
role: "file-prompt",
},
{
colTitle: "User Prompt",
fillStrategy: { kind: "template", template: "Summarize. Focus on: {{question}}" },
role: "text-prompt",
},
{ colTitle: "Output", fillStrategy: { kind: "create-empty" }, role: "output" },
],
settings: { tools: ["google_search"] },
};

const mockResult: PrepRecipeResult = { rowRange: { start: 2, end: 11 } };

function mount(definition = baseDefinition, savedState?: unknown) {
const container = document.createElement("div");
const nav = makeNav();
const panel = new RecipePanel();
const definition: RecipeDefinition = {
id: "test",
name: "Test Recipe",
icon: "🧪",
description: "Test",
panelId: "recipe",
params,
};
panel.mount(container, nav, definition, savedState as never);
return { container, nav, panel };
}
Expand All @@ -41,94 +60,32 @@ async function flush() {
await Promise.resolve();
}

// helper: a minimal column set covering all roles
const fullColumns: ColumnDef[] = [
{
label: "Drive Folder",
role: "driveLink",
strategyKind: "list-drive-folder",
colTitle: { value: "Drive Link", locked: true },
url: { value: "", locked: false, placeholder: "Paste folder URL" },
required: true,
},
{
label: "System Prompt",
role: "systemPrompt",
strategyKind: "fill-value",
colTitle: { value: "System Prompt", locked: true },
prompt: { value: "You are helpful.", locked: true },
},
{
label: "User Prompt",
role: "userPrompt",
strategyKind: "fill-value",
colTitle: { value: "User Prompt", locked: true },
prompt: { value: "Summarize.", locked: true },
},
{
label: "Output Column",
role: "output",
strategyKind: "create-empty",
colTitle: { value: "AI_Out", locked: true },
},
];

const mockResult: PrepRecipeResult = {
rowRange: { start: 2, end: 11 },
};

// ── rendering ───────────────────────────────────────────────────
// ── rendering ──────────────────────────────────────────────────

describe("rendering", () => {
it("renders a url input for list-drive-folder columns", () => {
const { container } = mount({ columns: [fullColumns[0]] });
expect(container.querySelector("#col-0-url-input")).not.toBeNull();
});

it("does not render url input for fill-value columns", () => {
const { container } = mount({ columns: [fullColumns[2]] });
expect(container.querySelector("#col-0-url-input")).toBeNull();
it("renders one input field per RecipeInput", () => {
const { container } = mount();
expect(container.querySelectorAll(".recipe-input-field")).toHaveLength(2);
});

it("renders a prompt container for fill-value columns", () => {
const { container } = mount({ columns: [fullColumns[1]] });
expect(container.querySelector("#col-0-prompt-container")).not.toBeNull();
});

it("does not render prompt container for create-empty columns", () => {
const { container } = mount({ columns: [fullColumns[3]] });
expect(container.querySelector("#col-0-prompt-container")).toBeNull();
});

it("renders one section per ColumnDef", () => {
const { container } = mount({ columns: fullColumns });
expect(container.querySelectorAll(".recipe-section-card")).toHaveLength(fullColumns.length);
it("renders the label for each input", () => {
const { container } = mount();
const labels = Array.from(container.querySelectorAll(".recipe-input-label")).map(
(el) => el.textContent,
);
expect(labels).toContain("Drive Folder");
expect(labels).toContain("What are you looking for?");
});

it("renders append field inputs when appendFields present", () => {
const colWithAppend: ColumnDef = {
...fullColumns[2],
appendFields: [{ id: "search", label: "What are you looking for?" }],
};
const { container } = mount({ columns: [colWithAppend] });
expect(container.querySelector("#col-0-append-search")).not.toBeNull();
it("renders placeholder on the input element", () => {
const { container } = mount();
const input = container.querySelector<HTMLInputElement>('[data-input-id="folder"]')!;
expect(input.placeholder).toBe("Paste folder URL");
});
});

// ── LockableField defaults ────────────────────────────────────────

describe("LockableField defaults", () => {
it("initialises locked colTitle field as disabled", () => {
const { container } = mount({ columns: [fullColumns[3]] });
const input = container.querySelector<HTMLInputElement>("#col-0-title-container input")!;
expect(input.value).toBe("AI_Out");
expect(input.disabled).toBe(true);
});

it("initialises unlocked url field as enabled", () => {
const { container } = mount({ columns: [fullColumns[0]] });
const input = container.querySelector<HTMLInputElement>("#col-0-url-input")!;
expect(input.disabled).toBe(false);
it("does not render column section cards", () => {
const { container } = mount();
expect(container.querySelector(".recipe-section-card")).toBeNull();
});
});

Expand All @@ -137,77 +94,44 @@ describe("LockableField defaults", () => {
describe("Prep flow", () => {
beforeEach(() => mockPrepRecipe.mockClear());

it("calls services.prepRecipe with PrepColSpec[] built from resolved field values", async () => {
it("calls prepRecipe with prepTemplate and collected inputValues", async () => {
mockPrepRecipe.mockResolvedValue(mockResult);
const { container } = mount({ columns: fullColumns });
container.querySelector<HTMLInputElement>("#col-0-url-input")!.value =
"https://drive.google.com/drive/folders/abc123";
const { container } = mount();
container.querySelector<HTMLInputElement>('[data-input-id="folder"]')!.value =
"https://drive.google.com/drive/folders/abc";
container.querySelector<HTMLInputElement>('[data-input-id="question"]')!.value =
"fraud patterns";
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await flush();
expect(mockPrepRecipe).toHaveBeenCalledWith({
cols: [
{ colTitle: "Drive Link", strategy: { kind: "list-drive-folder", url: "https://drive.google.com/drive/folders/abc123" } },
{ colTitle: "System Prompt", strategy: { kind: "fill-value", value: "You are helpful." } },
{ colTitle: "User Prompt", strategy: { kind: "fill-value", value: "Summarize." } },
{ colTitle: "AI_Out", strategy: { kind: "create-empty" } },
],
cols: baseDefinition.prepTemplate,
inputValues: {
folder: "https://drive.google.com/drive/folders/abc",
question: "fraud patterns",
},
});
});

it("composes appendFields into the fill-value prompt string", async () => {
mockPrepRecipe.mockResolvedValue(mockResult);
const colWithAppend: ColumnDef = {
...fullColumns[2],
appendFields: [{ id: "search", label: "What?", prefix: "\n\nLooking for:\n\n" }],
};
const { container } = mount({ columns: [colWithAppend] });
container.querySelector<HTMLInputElement>("#col-0-append-search")!.value = "a signature";
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await flush();
expect(mockPrepRecipe).toHaveBeenCalledWith({
cols: [
{
colTitle: "User Prompt",
strategy: { kind: "fill-value", value: "Summarize.\n\nLooking for:\n\na signature" },
},
],
});
});

it("does not append prefix when appendField input is empty", async () => {
mockPrepRecipe.mockResolvedValue(mockResult);
const colWithAppend: ColumnDef = {
...fullColumns[2],
appendFields: [{ id: "search", label: "What?", prefix: "\n\nLooking for:\n\n" }],
};
const { container } = mount({ columns: [colWithAppend] });
// leave #col-0-append-search empty
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await flush();
expect(mockPrepRecipe).toHaveBeenCalledWith({
cols: [{ colTitle: "User Prompt", strategy: { kind: "fill-value", value: "Summarize." } }],
});
});

it("shows alert and does not call prepRecipe when url input is empty for list-drive-folder", async () => {
it("shows alert and does not call prepRecipe when required input is empty", async () => {
const alertMock = jest.fn();
globalThis.alert = alertMock;
const { container } = mount({ columns: [fullColumns[0]] });
const { container } = mount();
// leave 'folder' empty
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await flush();
expect(alertMock).toHaveBeenCalledTimes(1);
expect(mockPrepRecipe).not.toHaveBeenCalled();
});
});

// ── Cook flow ──────────────────────────────────────────────────
// ── cook flow ──────────────────────────────────────────────────

describe("Cook flow", () => {
it("navigates to configure-ai-run with RunConfig assembled from ColumnDef roles + rowRange", async () => {
it("navigates to configure-ai-run with RunConfig derived from roles + settings + rowRange", async () => {
mockPrepRecipe.mockResolvedValue({ rowRange: { start: 2, end: 5 } });
const { container, nav } = mount({ columns: fullColumns });
container.querySelector<HTMLInputElement>("#col-0-url-input")!.value =
"https://drive.google.com/abc";
const { container, nav } = mount();
container.querySelector<HTMLInputElement>('[data-input-id="folder"]')!.value =
"https://drive.google.com/drive/folders/abc";
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await flush();
container.querySelector<HTMLButtonElement>("#cook-btn")!.click();
Expand All @@ -216,57 +140,45 @@ describe("Cook flow", () => {
{ col: "Drive Link", kind: "file" },
{ col: "User Prompt", kind: "text" },
],
systemPromptCol: "System Prompt",
outputCol: "AI_Out",
systemPromptCol: undefined,
outputCol: "Output",
tools: ["google_search"],
rowRange: { start: 2, end: 5 },
});
});

it("spreads RecipeSettings into RunConfig", async () => {
mockPrepRecipe.mockResolvedValue({ rowRange: { start: 2, end: 3 } });
const outputOnly: ColumnDef = fullColumns[3];
const { container, nav } = mount({
columns: [outputOnly],
settings: { tools: ["google_search"], applyMarkdown: true },
});
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await flush();
container.querySelector<HTMLButtonElement>("#cook-btn")!.click();
expect(nav.navigate).toHaveBeenCalledWith("configure-ai-run",
expect.objectContaining({ tools: ["google_search"], applyMarkdown: true }),
);
});
});

// ── saved state ────────────────────────────────────────────────

describe("unmount / saved state", () => {
it("unmount returns colValues array and prepComplete: false when not prepped", () => {
const { container, panel } = mount({ columns: [fullColumns[0]] });
container.querySelector<HTMLInputElement>("#col-0-url-input")!.value = "my-folder-url";
it("unmount returns inputValues and prepComplete: false when not prepped", () => {
const { container, panel } = mount();
container.querySelector<HTMLInputElement>('[data-input-id="folder"]')!.value = "my-url";
const state = panel.unmount();
expect(state).toMatchObject({
colValues: [expect.objectContaining({ url: "my-folder-url" })],
inputValues: { folder: "my-url", question: "" },
prepComplete: false,
});
});

it("restores url value from savedState", () => {
it("restores input values from savedState", () => {
const savedState = {
colValues: [{ url: "restored-url" }],
inputValues: { folder: "restored-url", question: "restored-question" },
prepComplete: false,
};
const { container } = mount({ columns: [fullColumns[0]] }, savedState);
expect(container.querySelector<HTMLInputElement>("#col-0-url-input")!.value).toBe("restored-url");
const { container } = mount(baseDefinition, savedState);
expect(container.querySelector<HTMLInputElement>('[data-input-id="folder"]')!.value).toBe(
"restored-url",
);
});

it("mounts with savedState prepComplete: true — Cook is enabled", () => {
const savedState = {
colValues: [{}],
inputValues: {},
prepComplete: true,
preppedRunConfig: { outputCol: "Out", promptCols: [] },
preppedRunConfig: { outputCol: "Output", promptCols: [] },
};
const { container } = mount({ columns: [fullColumns[3]] }, savedState);
const { container } = mount(baseDefinition, savedState);
expect(container.querySelector<HTMLButtonElement>("#cook-btn")!.disabled).toBe(false);
});
});
7 changes: 4 additions & 3 deletions __tests__/services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,10 @@ describe("prepRecipe", () => {
const handlers = captureHandlers();
const params: import("../src/shared/types").PrepRecipeParams = {
cols: [
{ colTitle: "Drive Link", strategy: { kind: "list-drive-folder", url: "https://drive.google.com/folder/abc" } },
{ colTitle: "AI_Summarization", strategy: { kind: "create-empty" } },
{ colTitle: "Drive Link", fillStrategy: { kind: "list-drive-folder", inputId: "folder" } },
{ colTitle: "AI_Summarization", fillStrategy: { kind: "create-empty" } },
],
inputValues: { folder: "https://drive.google.com/drive/folders/abc123" },
};
const result: import("../src/shared/types").PrepRecipeResult = {
rowRange: { start: 2, end: 5 },
Expand All @@ -123,7 +124,7 @@ describe("prepRecipe", () => {

it("rejects on failure", async () => {
const handlers = captureHandlers();
const promise = services.prepRecipe({ cols: [] });
const promise = services.prepRecipe({ cols: [], inputValues: {} });
handlers.reject(new Error("prep error"));
await expect(promise).rejects.toThrow("prep error");
});
Expand Down
25 changes: 25 additions & 0 deletions __tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
findOrCreateColumn,
writeColumn,
writeJobProgress,
interpolateTemplate,
} from "../src/server/utils";
import type { DriveFileInfo } from "../src/server/types";

Expand Down Expand Up @@ -429,3 +430,27 @@ describe("writeColumn", () => {
expect(setWrapStrategyMock).toHaveBeenCalledWith(wrapStrategy);
});
});

describe("interpolateTemplate", () => {
it("replaces a single {{inputId}} with the corresponding value", () => {
expect(interpolateTemplate("Hello {{name}}", { name: "world" })).toBe("Hello world");
});

it("replaces multiple placeholders in one string", () => {
expect(
interpolateTemplate("{{a}} and {{b}}", { a: "foo", b: "bar" }),
).toBe("foo and bar");
});

it("replaces the same placeholder multiple times", () => {
expect(interpolateTemplate("{{x}} {{x}}", { x: "hi" })).toBe("hi hi");
});

it("leaves unknown placeholders as empty string", () => {
expect(interpolateTemplate("{{missing}}", {})).toBe("");
});

it("returns the string unchanged when no placeholders present", () => {
expect(interpolateTemplate("no placeholders", { x: "y" })).toBe("no placeholders");
});
});
Loading
Loading