From 77a9d49eb4d983d73b90ccd4b664d6ccb24be566 Mon Sep 17 00:00:00 2001 From: Simon Bernier St-Pierre Date: Sat, 1 Aug 2026 22:40:43 -0400 Subject: [PATCH] add e2e tests for lists and meals --- e2e/helpers.ts | 27 ++++++++++++ e2e/tests/lists.spec.ts | 80 ++++++++++++++++++++++++++++++++++ e2e/tests/meals.spec.ts | 95 +++++++++++++++++++++++++++++++++++------ src/views.rs | 52 +++++++++++----------- 4 files changed, 214 insertions(+), 40 deletions(-) create mode 100644 e2e/tests/lists.spec.ts diff --git a/e2e/helpers.ts b/e2e/helpers.ts index ca91b1f..21ec532 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -18,3 +18,30 @@ export async function createMeal(page: Page, name: string, description: string) await page.click('button:has-text("Save meal")'); await expect(page).toHaveURL(/\/meals\/\d+/); } + +/** Creates a list and lands on its page. */ +export async function createList(page: Page, name: string) { + await page.fill("#list-name", name); + await page.click('button:has-text("Create list")'); + await expect(page).toHaveURL(/\/lists\/\d+/); +} + +/** Adds an item to the current list page. */ +export async function addItem(page: Page, name: string, quantity = "") { + await page.fill("#item-name", name); + if (quantity) { + await page.fill("#item-quantity", quantity); + } + await page.click("#add-item-button"); + await expect(page.locator(".item-row").filter({ hasText: name })).toBeVisible(); +} + +/** Adds an ingredient to the current meal page. */ +export async function addIngredient(page: Page, name: string, quantity = "") { + await page.fill("#ingredient-name", name); + if (quantity) { + await page.fill("#ingredient-quantity", quantity); + } + await page.click("#add-ingredient-button"); + await expect(page.locator(".ingredient-list").filter({ hasText: name })).toBeVisible(); +} diff --git a/e2e/tests/lists.spec.ts b/e2e/tests/lists.spec.ts new file mode 100644 index 0000000..653631d --- /dev/null +++ b/e2e/tests/lists.spec.ts @@ -0,0 +1,80 @@ +import { expect } from "@playwright/test"; +import { test } from "../fixtures"; +import { registerAndLogin, createList, addItem } from "../helpers"; + +test("a user can create a list", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + + await createList(page, "Weekly shop"); + + await expect(page.locator("h1")).toContainText("Weekly shop"); + await expect(page.locator(".empty-items")).toBeVisible(); +}); + +test("a user can add an item with a quantity", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createList(page, "Weekly shop"); + + await addItem(page, "Apple", "2"); + + // Quantity renders in parens to the left of the name. + const row = page.locator(".item-row").filter({ hasText: "Apple" }); + await expect(row.locator(".item-qty")).toHaveText("(2)"); + await expect(row.locator("strong")).toHaveText("Apple"); +}); + +test("a user can check off an item", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createList(page, "Weekly shop"); + await addItem(page, "Apple"); + + const row = page.locator(".item-row").filter({ hasText: "Apple" }); + await row.locator(".check-button").click(); + + await expect(row).toHaveClass(/is-checked/); +}); + +test("a user can edit an item via the actions modal", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createList(page, "Weekly shop"); + await addItem(page, "Apple", "2"); + + const row = page.locator(".item-row").filter({ hasText: "Apple" }); + await row.locator(".item-actions-button").click(); + + const dialog = row.locator("dialog.item-modal"); + await expect(dialog).toBeVisible(); + await dialog.locator('[id^="item-edit-name"]').fill("Banana"); + await dialog.locator('[id^="item-edit-quantity"]').fill("6"); + await dialog.locator('[id^="item-edit-save"]').click(); + + const updated = page.locator(".item-row").filter({ hasText: "Banana" }); + await expect(updated).toBeVisible(); + await expect(updated.locator(".item-qty")).toHaveText("(6)"); +}); + +test("a user can delete an item via the actions modal", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createList(page, "Weekly shop"); + await addItem(page, "Apple"); + + const row = page.locator(".item-row").filter({ hasText: "Apple" }); + await row.locator(".item-actions-button").click(); + + const dialog = row.locator("dialog.item-modal"); + await expect(dialog).toBeVisible(); + await dialog.locator('[id^="item-edit-delete"]').click(); + + await expect(page.locator(".item-row").filter({ hasText: "Apple" })).toHaveCount(0); + await expect(page.locator(".empty-items")).toBeVisible(); +}); + +test("a user can add a category", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createList(page, "Weekly shop"); + + await page.fill("#category-name", "Bakery"); + await page.click("#add-category-button"); + + await expect(page.locator(".category-chip").filter({ hasText: "Bakery" })).toBeVisible(); +}); diff --git a/e2e/tests/meals.spec.ts b/e2e/tests/meals.spec.ts index 43d4681..8303027 100644 --- a/e2e/tests/meals.spec.ts +++ b/e2e/tests/meals.spec.ts @@ -1,21 +1,88 @@ import { expect } from "@playwright/test"; import { test } from "../fixtures"; -import { registerAndLogin } from "../helpers"; +import { registerAndLogin, createMeal, addIngredient } from "../helpers"; -test("a user can create a meal", async ({ page }) => { +test("a user can add an ingredient to a meal", async ({ page }) => { await registerAndLogin(page, "alice@example.com"); + await createMeal(page, "Spaghetti Bolognese", ""); - await page.goto("/meals/new"); - await page.fill("#meal-name", "Spaghetti Bolognese"); - await page.fill("#meal-description", "## Ingredients\n\nA classic weeknight dinner."); - await page.click('button:has-text("Save meal")'); + await addIngredient(page, "Penne", "500g"); - // Lands on the meal detail page and renders the markdown description. - await expect(page).toHaveURL(/\/meals\/\d+/); - await expect(page.locator("h1")).toContainText("Spaghetti Bolognese"); - await expect(page.locator(".markdown h2")).toContainText("Ingredients"); - - // The meal appears on the meals index. - await page.goto("/meals"); - await expect(page.locator(".list-card")).toContainText("Spaghetti Bolognese"); + const row = page.locator(".ingredient-list").filter({ hasText: "Penne" }); + await expect(row.locator(".item-qty")).toHaveText("(500g)"); + await expect(row.locator("strong")).toHaveText("Penne"); +}); + +test("a user can edit a meal name and description via the modal", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createMeal(page, "Spaghetti Bolognese", "A classic."); + + await page.click('button:has-text("Edit")'); + const dialog = page.locator("dialog#meal-edit-modal"); + await expect(dialog).toBeVisible(); + await dialog.locator("#meal-edit-name").fill("Pasta al Pomodoro"); + await dialog.locator("#meal-edit-description").fill("## Ingredients\n\nA simple tomato sauce."); + await dialog.locator("#meal-edit-save").click(); + + await expect(page.locator("h1")).toContainText("Pasta al Pomodoro"); + await expect(page.locator(".markdown h2")).toContainText("Ingredients"); +}); + +test("a user can delete a meal", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createMeal(page, "Spaghetti Bolognese", ""); + + await page.click('button:has-text("Delete")'); + await expect(page).toHaveURL(/\/meals$/); + await expect(page.locator(".list-card").filter({ hasText: "Spaghetti Bolognese" })).toHaveCount(0); +}); + +test("a user can edit an ingredient via the actions modal", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createMeal(page, "Spaghetti Bolognese", ""); + await addIngredient(page, "Penne", "500g"); + + const row = page.locator(".ingredient-list").filter({ hasText: "Penne" }); + await row.locator(".item-actions-button").click(); + + const dialog = row.locator("dialog.item-modal"); + await expect(dialog).toBeVisible(); + await dialog.locator('[id^="ingredient-edit-name"]').fill("Rigatoni"); + await dialog.locator('[id^="ingredient-edit-quantity"]').fill("400g"); + await dialog.locator('[id^="ingredient-edit-save"]').click(); + + const updated = page.locator(".ingredient-list").filter({ hasText: "Rigatoni" }); + await expect(updated).toBeVisible(); + await expect(updated.locator(".item-qty")).toHaveText("(400g)"); +}); + +test("a user can delete an ingredient via the actions modal", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createMeal(page, "Spaghetti Bolognese", ""); + await addIngredient(page, "Penne"); + + const row = page.locator(".ingredient-list").filter({ hasText: "Penne" }); + await row.locator(".item-actions-button").click(); + + const dialog = row.locator("dialog.item-modal"); + await expect(dialog).toBeVisible(); + await dialog.locator('[id^="ingredient-edit-delete"]').click(); + + await expect(page.locator(".ingredient-list").filter({ hasText: "Penne" })).toHaveCount(0); +}); + +test("ingredients are grouped under their categories", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createMeal(page, "Spaghetti Bolognese", ""); + + // Add an ingredient in the default "Produce" category. + await page.fill("#ingredient-name", "Tomato"); + await page.selectOption("#ingredient-category", { label: "Produce" }); + await page.click("#add-ingredient-button"); + + // Add one without a category. + await addIngredient(page, "Penne"); + + await expect(page.locator(".category-heading").filter({ hasText: "Produce" })).toBeVisible(); + await expect(page.locator(".category-heading").filter({ hasText: "Uncategorized" })).toBeVisible(); }); diff --git a/src/views.rs b/src/views.rs index 47f9f11..175cc64 100644 --- a/src/views.rs +++ b/src/views.rs @@ -305,10 +305,10 @@ pub fn meal_page( form method="post" action=(format!("/meals/{}/edit", meal.id)) class="stack" { input type="hidden" name="csrf" value=(csrf_token); label { "Name" } - input name="name" value=(meal.name) maxlength="120" required; + input id="meal-edit-name" name="name" value=(meal.name) maxlength="120" required; label { "Description (markdown)" } - textarea name="description" rows="8" { (meal.description) } - button class="button button-primary" type="submit" { "Save meal" } + textarea id="meal-edit-description" name="description" rows="8" { (meal.description) } + button id="meal-edit-save" class="button button-primary" type="submit" { "Save meal" } } } } @@ -339,19 +339,19 @@ pub fn meal_page( aside class="side-column" { section class="panel" { div class="panel-heading" { h2 { "Add ingredient" } } - form method="post" action=(format!("/meals/{}/ingredients", meal.id)) class="stack" { + form id="add-ingredient-form" method="post" action=(format!("/meals/{}/ingredients", meal.id)) class="stack" { input type="hidden" name="csrf" value=(csrf_token); label { "Name" } - input name="name" type="text" maxlength="120" required; + input id="ingredient-name" name="name" type="text" maxlength="120" required; label { "Quantity" } - input name="quantity" type="text" maxlength="40"; + input id="ingredient-quantity" name="quantity" type="text" maxlength="40"; label { "Note" } - input name="note" type="text" maxlength="120"; + input id="ingredient-note" name="note" type="text" maxlength="120"; label { "Category" } - select name="category_id" { + select id="ingredient-category" name="category_id" { (category_options(categories, None)) } - button class="button button-primary" type="submit" { "Add ingredient" } + button id="add-ingredient-button" class="button button-primary" type="submit" { "Add ingredient" } } } } @@ -391,20 +391,20 @@ fn ingredient_row( { input type="hidden" name="csrf" value=(csrf_token); label { "Name" } - input name="name" value=(ingredient.name) maxlength="120" required; + input id=(format!("ingredient-edit-name-{}", ingredient.id)) name="name" value=(ingredient.name) maxlength="120" required; label { "Quantity" } - input name="quantity" value=(ingredient.quantity) maxlength="40"; + input id=(format!("ingredient-edit-quantity-{}", ingredient.id)) name="quantity" value=(ingredient.quantity) maxlength="40"; label { "Note" } - input name="note" value=(ingredient.note) maxlength="120"; + input id=(format!("ingredient-edit-note-{}", ingredient.id)) name="note" value=(ingredient.note) maxlength="120"; label { "Category" } - select name="category_id" { + select id=(format!("ingredient-edit-category-{}", ingredient.id)) name="category_id" { (category_options(categories, ingredient.category_id)) } - button class="button button-primary" type="submit" { "Save" } + button id=(format!("ingredient-edit-save-{}", ingredient.id)) class="button button-primary" type="submit" { "Save" } } form method="post" action=(format!("/meals/{}/ingredients/{}/delete", meal_id, ingredient.id)) { input type="hidden" name="csrf" value=(csrf_token); - button class="danger-link" type="submit" { "Remove" } + button id=(format!("ingredient-edit-delete-{}", ingredient.id)) class="danger-link" type="submit" { "Remove" } } } } @@ -573,11 +573,11 @@ fn list_content( input type="hidden" name="csrf" value=(csrf_token); label class="sr-only" for="item-name" { "Item name" } input id="item-name" name="name" type="text" maxlength="120" placeholder="Add an item..." autocomplete="off" required; - input name="quantity" type="text" maxlength="40" placeholder="Qty" aria-label="Quantity"; - select name="category_id" aria-label="Category" { + input id="item-quantity" name="quantity" type="text" maxlength="40" placeholder="Qty" aria-label="Quantity"; + select id="item-category" name="category_id" aria-label="Category" { (category_options(categories, None)) } - button class="button button-primary add-button" type="submit" { "+ Add" } + button id="add-item-button" class="button button-primary add-button" type="submit" { "+ Add" } } (list_items_fragment(list, items, categories, csrf_token, false)) } @@ -701,16 +701,16 @@ fn item_row(item: &Item, categories: &[Category], csrf_token: &str) -> Markup { { input type="hidden" name="csrf" value=(csrf_token); label { "Name" } - input name="name" value=(item.name) maxlength="120" required; + input id=(format!("item-edit-name-{}", item.id)) name="name" value=(item.name) maxlength="120" required; label { "Quantity" } - input name="quantity" value=(item.quantity) maxlength="40"; + input id=(format!("item-edit-quantity-{}", item.id)) name="quantity" value=(item.quantity) maxlength="40"; label { "Note" } - input name="note" value=(item.note) maxlength="120"; + input id=(format!("item-edit-note-{}", item.id)) name="note" value=(item.note) maxlength="120"; label { "Category" } - select name="category_id" { + select id=(format!("item-edit-category-{}", item.id)) name="category_id" { (category_options(categories, item.category_id)) } - button class="button button-primary" type="submit" { "Save" } + button id=(format!("item-edit-save-{}", item.id)) class="button button-primary" type="submit" { "Save" } } form hx-post=(format!("/lists/{}/items/{}/delete", item.list_id, item.id)) @@ -718,7 +718,7 @@ fn item_row(item: &Item, categories: &[Category], csrf_token: &str) -> Markup { hx-swap="outerHTML" { input type="hidden" name="csrf" value=(csrf_token); - button class="danger-link" type="submit" { "Remove item" } + button id=(format!("item-edit-delete-{}", item.id)) class="danger-link" type="submit" { "Remove item" } } } } @@ -762,8 +762,8 @@ pub fn categories_panel( class="category-form" { input type="hidden" name="csrf" value=(csrf_token); - input name="name" type="text" maxlength="60" placeholder="Add a category" required; - button class="button button-small button-secondary" type="submit" { "Add" } + input id="category-name" name="name" type="text" maxlength="60" placeholder="Add a category" required; + button id="add-category-button" class="button button-small button-secondary" type="submit" { "Add" } } @if categories.is_empty() { p class="muted category-empty" { "No categories yet." }