3 Commits
Author SHA1 Message Date
sbstp 4adcfb1377 add meal e2e test
ci/woodpecker/push/e2e Pipeline failed
ci/woodpecker/push/fmt Pipeline failed
ci/woodpecker/push/test Pipeline was successful
2026-08-01 22:47:41 -04:00
sbstp 77a9d49eb4 add e2e tests for lists and meals 2026-08-01 22:40:43 -04:00
sbstp 2e0d76a9c2 fix fmt 2026-08-01 22:14:33 -04:00
6 changed files with 282 additions and 40 deletions
+1
View File
@@ -5,4 +5,5 @@ steps:
fmt: fmt:
image: rust:1 image: rust:1
commands: commands:
- rustup component add rustfmt
- cargo fmt --all -- --check - cargo fmt --all -- --check
+40
View File
@@ -18,3 +18,43 @@ export async function createMeal(page: Page, name: string, description: string)
await page.click('button:has-text("Save meal")'); await page.click('button:has-text("Save meal")');
await expect(page).toHaveURL(/\/meals\/\d+/); await expect(page).toHaveURL(/\/meals\/\d+/);
} }
/** Creates a list and lands on its page. */
export async function createList(page: Page, name: string) {
await page.goto("/lists");
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();
}
/** Creates a meal and adds the given ingredients to it. */
export async function createMealWithIngredients(
page: Page,
name: string,
ingredients: Array<{ name: string; quantity?: string }>,
) {
await createMeal(page, name, "");
for (const ingredient of ingredients) {
await addIngredient(page, ingredient.name, ingredient.quantity ?? "");
}
}
+54
View File
@@ -0,0 +1,54 @@
import { expect } from "@playwright/test";
import { test } from "../fixtures";
import { registerAndLogin, createList, createMealWithIngredients } from "../helpers";
test("a user can add a meal's ingredients to a list via the picker", async ({ page }) => {
await registerAndLogin(page, "alice@example.com");
await createMealWithIngredients(page, "Spaghetti Bolognese", [
{ name: "Penne", quantity: "500g" },
{ name: "Tomato", quantity: "2" },
]);
await createList(page, "Weekly shop");
// Open the add-meal picker.
await page.click(".add-meal-button");
const picker = page.locator(".meal-picker-backdrop");
await expect(picker).toBeVisible();
await expect(picker.locator(".meal-picker-button").filter({ hasText: "Spaghetti Bolognese" })).toBeVisible();
// Select the meal.
await picker.locator(".meal-picker-button").filter({ hasText: "Spaghetti Bolognese" }).click();
// The picker closes and the meal's ingredients appear as items.
await expect(picker).toHaveCount(0);
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
await expect(page.locator(".item-row").filter({ hasText: "Tomato" })).toBeVisible();
await expect(page.locator(".item-row").filter({ hasText: "Penne" }).locator(".item-qty")).toHaveText("(500g)");
});
test("the add-meal picker closes via the close button", async ({ page }) => {
await registerAndLogin(page, "alice@example.com");
await createMealWithIngredients(page, "Spaghetti Bolognese", [{ name: "Penne" }]);
await createList(page, "Weekly shop");
await page.click(".add-meal-button");
const picker = page.locator(".meal-picker-backdrop");
await expect(picker).toBeVisible();
await picker.locator(".meal-picker-close").click();
await expect(picker).toHaveCount(0);
});
test("the add-meal picker closes when clicking outside", async ({ page }) => {
await registerAndLogin(page, "alice@example.com");
await createMealWithIngredients(page, "Spaghetti Bolognese", [{ name: "Penne" }]);
await createList(page, "Weekly shop");
await page.click(".add-meal-button");
const picker = page.locator(".meal-picker-backdrop");
await expect(picker).toBeVisible();
// Click the backdrop itself (outside the modal card).
await picker.click({ position: { x: 5, y: 5 } });
await expect(picker).toHaveCount(0);
});
+80
View File
@@ -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();
});
+81 -14
View File
@@ -1,21 +1,88 @@
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import { test } from "../fixtures"; 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 registerAndLogin(page, "alice@example.com");
await createMeal(page, "Spaghetti Bolognese", "");
await page.goto("/meals/new"); await addIngredient(page, "Penne", "500g");
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")');
// Lands on the meal detail page and renders the markdown description. const row = page.locator(".ingredient-list").filter({ hasText: "Penne" });
await expect(page).toHaveURL(/\/meals\/\d+/); await expect(row.locator(".item-qty")).toHaveText("(500g)");
await expect(page.locator("h1")).toContainText("Spaghetti Bolognese"); await expect(row.locator("strong")).toHaveText("Penne");
await expect(page.locator(".markdown h2")).toContainText("Ingredients"); });
// The meal appears on the meals index. test("a user can edit a meal name and description via the modal", async ({ page }) => {
await page.goto("/meals"); await registerAndLogin(page, "alice@example.com");
await expect(page.locator(".list-card")).toContainText("Spaghetti Bolognese"); 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();
}); });
+26 -26
View File
@@ -305,10 +305,10 @@ pub fn meal_page(
form method="post" action=(format!("/meals/{}/edit", meal.id)) class="stack" { form method="post" action=(format!("/meals/{}/edit", meal.id)) class="stack" {
input type="hidden" name="csrf" value=(csrf_token); input type="hidden" name="csrf" value=(csrf_token);
label { "Name" } 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)" } label { "Description (markdown)" }
textarea name="description" rows="8" { (meal.description) } textarea id="meal-edit-description" name="description" rows="8" { (meal.description) }
button class="button button-primary" type="submit" { "Save meal" } 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" { aside class="side-column" {
section class="panel" { section class="panel" {
div class="panel-heading" { h2 { "Add ingredient" } } 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); input type="hidden" name="csrf" value=(csrf_token);
label { "Name" } label { "Name" }
input name="name" type="text" maxlength="120" required; input id="ingredient-name" name="name" type="text" maxlength="120" required;
label { "Quantity" } label { "Quantity" }
input name="quantity" type="text" maxlength="40"; input id="ingredient-quantity" name="quantity" type="text" maxlength="40";
label { "Note" } label { "Note" }
input name="note" type="text" maxlength="120"; input id="ingredient-note" name="note" type="text" maxlength="120";
label { "Category" } label { "Category" }
select name="category_id" { select id="ingredient-category" name="category_id" {
(category_options(categories, None)) (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); input type="hidden" name="csrf" value=(csrf_token);
label { "Name" } 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" } 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" } 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" } label { "Category" }
select name="category_id" { select id=(format!("ingredient-edit-category-{}", ingredient.id)) name="category_id" {
(category_options(categories, ingredient.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)) { form method="post" action=(format!("/meals/{}/ingredients/{}/delete", meal_id, ingredient.id)) {
input type="hidden" name="csrf" value=(csrf_token); 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); input type="hidden" name="csrf" value=(csrf_token);
label class="sr-only" for="item-name" { "Item name" } 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 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"; input id="item-quantity" name="quantity" type="text" maxlength="40" placeholder="Qty" aria-label="Quantity";
select name="category_id" aria-label="Category" { select id="item-category" name="category_id" aria-label="Category" {
(category_options(categories, None)) (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)) (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); input type="hidden" name="csrf" value=(csrf_token);
label { "Name" } 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" } 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" } 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" } label { "Category" }
select name="category_id" { select id=(format!("item-edit-category-{}", item.id)) name="category_id" {
(category_options(categories, item.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 form
hx-post=(format!("/lists/{}/items/{}/delete", item.list_id, item.id)) 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" hx-swap="outerHTML"
{ {
input type="hidden" name="csrf" value=(csrf_token); 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" class="category-form"
{ {
input type="hidden" name="csrf" value=(csrf_token); input type="hidden" name="csrf" value=(csrf_token);
input name="name" type="text" maxlength="60" placeholder="Add a category" required; input id="category-name" name="name" type="text" maxlength="60" placeholder="Add a category" required;
button class="button button-small button-secondary" type="submit" { "Add" } button id="add-category-button" class="button button-small button-secondary" type="submit" { "Add" }
} }
@if categories.is_empty() { @if categories.is_empty() {
p class="muted category-empty" { "No categories yet." } p class="muted category-empty" { "No categories yet." }