From ba68fb456a76bd17cd25ce1998b93c690b9f927c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20M=C3=BChl?= Date: Thu, 26 Feb 2026 20:53:08 +0100 Subject: [PATCH] test: E2E test suite + handoff documentation 50 Playwright E2E tests across 13 spec files covering all routes and user flows (items CRUD, check-out/in, locations, labels, scanning, search/filter). Uses vitest as runner with playwright-core for browser automation (bun-compatible alternative to @playwright/test). Includes Gherkin .feature files as living documentation, test support infrastructure (IDB seeding, item factories, assertion helpers, layout measurement), and HANDOFF.md covering project state, deployment, and open design decisions. Co-Authored-By: Claude Opus 4.6 --- .claude/forge.json | 56 +++++++ HANDOFF.md | 139 +++++++++++++++++ bun.lock | 3 + e2e/bin/node | 2 + e2e/features/flows/check-out-check-in.feature | 19 +++ e2e/features/flows/create-item.feature | 35 +++++ e2e/features/flows/label-generation.feature | 15 ++ e2e/features/flows/scan-and-action.feature | 11 ++ e2e/features/flows/search-and-filter.feature | 26 ++++ e2e/features/pages/dashboard.feature | 46 ++++++ e2e/features/pages/item-detail.feature | 53 +++++++ e2e/features/pages/item-new.feature | 37 +++++ e2e/features/pages/items-list.feature | 42 +++++ e2e/features/pages/labels.feature | 23 +++ e2e/features/pages/locations.feature | 25 +++ e2e/features/pages/scan.feature | 13 ++ e2e/features/pages/settings.feature | 22 +++ e2e/playwright.config.ts | 15 ++ e2e/specs/flows/check-out-check-in.spec.ts | 50 ++++++ e2e/specs/flows/create-item.spec.ts | 70 +++++++++ e2e/specs/flows/label-generation.spec.ts | 38 +++++ e2e/specs/flows/scan-and-action.spec.ts | 21 +++ e2e/specs/flows/search-and-filter.spec.ts | 60 ++++++++ e2e/specs/pages/dashboard.spec.ts | 121 +++++++++++++++ e2e/specs/pages/item-detail.spec.ts | 113 ++++++++++++++ e2e/specs/pages/item-new.spec.ts | 79 ++++++++++ e2e/specs/pages/items-list.spec.ts | 89 +++++++++++ e2e/specs/pages/labels.spec.ts | 51 +++++++ e2e/specs/pages/locations.spec.ts | 56 +++++++ e2e/specs/pages/scan.spec.ts | 22 +++ e2e/specs/pages/settings.spec.ts | 41 +++++ e2e/steps/common.steps.ts | 143 ++++++++++++++++++ e2e/steps/flows/check-out-check-in.steps.ts | 17 +++ e2e/steps/flows/create-item.steps.ts | 15 ++ e2e/steps/flows/label-generation.steps.ts | 14 ++ e2e/steps/flows/scan-and-action.steps.ts | 17 +++ e2e/steps/flows/search-and-filter.steps.ts | 12 ++ e2e/steps/pages/dashboard.steps.ts | 16 ++ e2e/steps/pages/item-detail.steps.ts | 18 +++ e2e/steps/pages/item-new.steps.ts | 12 ++ e2e/steps/pages/items-list.steps.ts | 21 +++ e2e/steps/pages/labels.steps.ts | 13 ++ e2e/steps/pages/locations.steps.ts | 15 ++ e2e/steps/pages/scan.steps.ts | 8 + e2e/steps/pages/settings.steps.ts | 9 ++ e2e/support/browser.ts | 70 +++++++++ e2e/support/expect.ts | 40 +++++ e2e/support/fixtures.ts | 31 ++++ e2e/support/item-factory.ts | 102 +++++++++++++ e2e/support/layout.ts | 53 +++++++ e2e/support/seed.ts | 78 ++++++++++ e2e/vitest.config.ts | 18 +++ flake.nix | 26 ++++ package.json | 5 +- 54 files changed, 2145 insertions(+), 1 deletion(-) create mode 100644 .claude/forge.json create mode 100644 HANDOFF.md create mode 100755 e2e/bin/node create mode 100644 e2e/features/flows/check-out-check-in.feature create mode 100644 e2e/features/flows/create-item.feature create mode 100644 e2e/features/flows/label-generation.feature create mode 100644 e2e/features/flows/scan-and-action.feature create mode 100644 e2e/features/flows/search-and-filter.feature create mode 100644 e2e/features/pages/dashboard.feature create mode 100644 e2e/features/pages/item-detail.feature create mode 100644 e2e/features/pages/item-new.feature create mode 100644 e2e/features/pages/items-list.feature create mode 100644 e2e/features/pages/labels.feature create mode 100644 e2e/features/pages/locations.feature create mode 100644 e2e/features/pages/scan.feature create mode 100644 e2e/features/pages/settings.feature create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/specs/flows/check-out-check-in.spec.ts create mode 100644 e2e/specs/flows/create-item.spec.ts create mode 100644 e2e/specs/flows/label-generation.spec.ts create mode 100644 e2e/specs/flows/scan-and-action.spec.ts create mode 100644 e2e/specs/flows/search-and-filter.spec.ts create mode 100644 e2e/specs/pages/dashboard.spec.ts create mode 100644 e2e/specs/pages/item-detail.spec.ts create mode 100644 e2e/specs/pages/item-new.spec.ts create mode 100644 e2e/specs/pages/items-list.spec.ts create mode 100644 e2e/specs/pages/labels.spec.ts create mode 100644 e2e/specs/pages/locations.spec.ts create mode 100644 e2e/specs/pages/scan.spec.ts create mode 100644 e2e/specs/pages/settings.spec.ts create mode 100644 e2e/steps/common.steps.ts create mode 100644 e2e/steps/flows/check-out-check-in.steps.ts create mode 100644 e2e/steps/flows/create-item.steps.ts create mode 100644 e2e/steps/flows/label-generation.steps.ts create mode 100644 e2e/steps/flows/scan-and-action.steps.ts create mode 100644 e2e/steps/flows/search-and-filter.steps.ts create mode 100644 e2e/steps/pages/dashboard.steps.ts create mode 100644 e2e/steps/pages/item-detail.steps.ts create mode 100644 e2e/steps/pages/item-new.steps.ts create mode 100644 e2e/steps/pages/items-list.steps.ts create mode 100644 e2e/steps/pages/labels.steps.ts create mode 100644 e2e/steps/pages/locations.steps.ts create mode 100644 e2e/steps/pages/scan.steps.ts create mode 100644 e2e/steps/pages/settings.steps.ts create mode 100644 e2e/support/browser.ts create mode 100644 e2e/support/expect.ts create mode 100644 e2e/support/fixtures.ts create mode 100644 e2e/support/item-factory.ts create mode 100644 e2e/support/layout.ts create mode 100644 e2e/support/seed.ts create mode 100644 e2e/vitest.config.ts create mode 100644 flake.nix diff --git a/.claude/forge.json b/.claude/forge.json new file mode 100644 index 0000000..ef55bbd --- /dev/null +++ b/.claude/forge.json @@ -0,0 +1,56 @@ +{ + "_comment": "Copy to .claude/forge.json in your project and customise.", + + "labels": [ + { "name": "feat", "color": "#0075ca", "description": "New feature or request" }, + { "name": "fix", "color": "#d73a4a", "description": "Something isn't working" }, + { "name": "chore", "color": "#e4e669", "description": "Maintenance, deps, config" }, + { "name": "refactor", "color": "#f9a825", "description": "Code improvement, no behaviour change" }, + { "name": "docs", "color": "#0052cc", "description": "Documentation only" }, + { "name": "test", "color": "#bfd4f2", "description": "Adding or fixing tests" }, + { "name": "perf", "color": "#84b6eb", "description": "Performance improvement" }, + { "name": "security", "color": "#ee0701", "description": "Security vulnerability or hardening" }, + { "name": "breaking", "color": "#b60205", "description": "Breaking change" }, + { "name": "ai-generated", "color": "#7b61ff", "description": "AI-assisted code (Claude Code)" }, + { "name": "needs-review", "color": "#fbca04", "description": "Waiting for human review" }, + { "name": "blocked", "color": "#e4e4e4", "description": "Blocked by another issue or PR" }, + { "name": "wontfix", "color": "#ffffff", "description": "This will not be worked on" } + ], + + "branch_protection": [ + { + "branch_name": "main", + "enable_push": false, + "enable_push_whitelist": false, + "push_whitelist_usernames": [], + "enable_merge_whitelist": false, + "enable_status_check": false, + "required_approvals": 0, + "dismiss_stale_approvals": false, + "require_signed_commits": false, + "block_on_rejected_reviews": false, + "block_on_official_review_requests": false, + "block_on_outdated_branch": false + } + ], + + "webhooks": [], + + "templates": { + "issue": true, + "pull_request": true + }, + + "settings": { + "has_issues": true, + "has_wiki": false, + "has_projects": false, + "default_branch": "main", + "default_merge_style": "squash", + "default_delete_branch_after_merge": true, + "allow_squash_merge": true, + "allow_rebase": false, + "allow_rebase_explicit": false, + "allow_merge_commits": false + } +} diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..69cc9a4 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,139 @@ +# SolidHaus — Project Handoff + +## What This Is + +Local-first household inventory app. Scan barcodes, track where things are, check items in/out, generate QR label sheets. Runs as a web app (SPA) or native mobile app via Capacitor. + +## Tech Stack + +- **Framework**: SvelteKit 2, Svelte 5 (runes), TypeScript +- **Styling**: Tailwind CSS 4 (dark slate theme) +- **Data**: IndexedDB via `idb` (local-first, no server required) +- **Runtime**: Bun (no Node.js — bun is the only runtime used) +- **Build**: Vite 7, `@sveltejs/adapter-static` (SPA mode, outputs to `build/`) +- **Testing**: Vitest 4 (unit) + Vitest + playwright-core (E2E) +- **Future**: Automerge (multi-device sync), Solid Protocol (linked data), Capacitor (native mobile) + +## Current State (Feb 2026) + +### What works +- All routes functional: dashboard, items list, item detail, new item, scan, locations, labels, settings +- Item CRUD with full field set (name, category, type, barcode, location, custody state, quantity tracking) +- Check-out / check-in flow with reasons (in-use, lent, in-transit, in-repair, temporary) +- Location tree with expand/collapse, item counts per location +- QR label sheet PDF generation (A4, 5x10 grid) with batch pre-generation +- Barcode scanning (Capacitor ML Kit on native, Barcode Detection API on web) +- Search and filter (by name, category, type, custody state) +- Confidence decay (confirmed → likely → assumed → unknown based on last sighting age) +- Default German house locations seeded on first run + +### Test coverage +- **174 unit tests** — `bun run test` +- **50 E2E tests** across 13 spec files — `bun run test:e2e` +- **Build** — `bun run build` (outputs static SPA to `build/`) + +### What's not done yet +- **Capacitor native platforms**: Config exists, deps installed, but `android/` and `ios/` dirs not created. Run `npx cap add android && npx cap sync` when ready. +- **Automerge sync**: Deps installed, not wired up. Intended for multi-device sync without a central server. +- **Solid Protocol**: Deps installed, not wired up. Intended for linked data / decentralized identity. +- **Photo support**: Schema exists (`photos` IDB store), no UI for capture/display yet. +- **Sighting history**: Schema exists (`sightings` IDB store), recorded on check-in/out but no history view UI. + +## Project Structure + +``` +src/ + routes/ + +layout.svelte # App shell: loading state, bottom nav, inventory init + +page.svelte # Dashboard: stats, checked-out items, low stock, recent + items/ + +page.svelte # Item list with search/filter/sort + new/+page.svelte # New item form + [id]/+page.svelte # Item detail with check-out/in, edit, delete + scan/+page.svelte # Barcode scanner + locations/+page.svelte # Location tree + items at location + labels/+page.svelte # Batch ID generation + PDF label sheets + settings/+page.svelte # DB stats, about + lib/ + components/ # Svelte components (ItemCard, ItemForm, LocationTree, etc.) + data/ # IndexedDB CRUD (db.ts, items.ts, locations.ts, sightings.ts, labels.ts) + stores/ # Svelte 5 rune stores (inventory.svelte.ts) + scanning/ # Barcode detector + ID parser + printing/ # Label sheet PDF generation + utils/ # ID generation, confidence decay + types.ts # All TypeScript types + +e2e/ + features/ # Gherkin .feature files (living documentation, not executable) + pages/ # One per route (8 files) + flows/ # One per user journey (5 files) + specs/ # Executable vitest + playwright-core tests + pages/ # Page-level tests (8 files) + flows/ # Flow-level tests (5 files) + steps/ # Step definitions (leftover from playwright-bdd attempt, unused) + support/ + browser.ts # Chromium lifecycle for vitest + seed.ts # IDB seeding via page.evaluate() + item-factory.ts # Test data builders + expect.ts # Playwright locator assertions for vitest + layout.ts # DOM measurement helpers (touch targets, spacing, viewport) + vitest.config.ts # E2E-specific vitest config + playwright.config.ts # Shared Playwright settings (viewport, baseURL, etc.) +``` + +## Commands + +```bash +bun install # install deps +bun run dev # dev server at localhost:5173 +bun run build # production build → build/ +bun run preview # serve production build at localhost:4173 +bun run test # 174 unit tests +bun run test:e2e # 50 E2E tests (needs build + preview running, or run build first) +bun run check # svelte-check type checking +``` + +## Deployment + +The app is a static SPA. `bun run build` outputs to `build/`. Serve it with any static file server that supports SPA fallback (all routes → `index.html`). + +Example with Caddy: +``` +:8080 { + root * /srv/solidhaus + try_files {path} /index.html + file_server +} +``` + +Example with nginx: +```nginx +server { + listen 8080; + root /srv/solidhaus; + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +No backend required — all data lives in the browser's IndexedDB. + +## Design Decisions Under Discussion + +These are captured but not yet implemented: + +- **Item name optional**: UID (pre-generated 7-char nanoid) is the only required field. Need at least one of name, description, or image. +- **Item types rework**: Current `itemType` enum mixes behavior (consumable, perishable) with category (clothing, document). Plan to split into behavioral traits (has quantity, has expiry, is container) + freeform categories. +- **Acquisition model**: Replace `purchaseDate` with acquisition events (purchased, gifted, loaned, rented, found, inherited) with date. +- **Container nesting**: A fridge is both an item and a container. Items can be "contained in" other items, separate from location tracking. + +See `CLAUDE.md` for full implementation guide and data schemas. + +## Known Quirks + +- **Bun, not Node**: The project uses bun exclusively. Playwright's test runner (`@playwright/test`) doesn't work with bun (worker thread incompatibility), so E2E tests use vitest as the runner with playwright-core for browser automation. +- **E2E needs a running server**: The E2E tests connect to `localhost:4173`. Run `bun run build && bun run preview` in a separate terminal before running `bun run test:e2e`, or the webServer in the vitest config handles it. +- **Gherkin features are documentation only**: The `.feature` files describe behavior in plain English but aren't wired to the test runner (playwright-bdd was abandoned due to bun compat). The `.spec.ts` files are the executable tests, each referencing their `.feature` file via `@see` JSDoc. +- **Step files unused**: `e2e/steps/` contains step definition files from the playwright-bdd approach. They're documentation artifacts, not executed. +- **Location tree interaction**: Clicking a location name selects it (shows items). To expand/collapse, click the arrow toggle button (`aria-label="Expand"/"Collapse"`), not the name. diff --git a/bun.lock b/bun.lock index 50fe69a..1817482 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "@testing-library/svelte": "^5.3.1", "fake-indexeddb": "^6.2.5", "jsdom": "^28.1.0", + "playwright-core": "^1.58.2", "svelte": "^5.51.0", "svelte-check": "^4.3.6", "tailwindcss": "^4.2.1", @@ -637,6 +638,8 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], diff --git a/e2e/bin/node b/e2e/bin/node new file mode 100755 index 0000000..94892aa --- /dev/null +++ b/e2e/bin/node @@ -0,0 +1,2 @@ +#!/bin/sh +exec bun "$@" diff --git a/e2e/features/flows/check-out-check-in.feature b/e2e/features/flows/check-out-check-in.feature new file mode 100644 index 0000000..99fe54a --- /dev/null +++ b/e2e/features/flows/check-out-check-in.feature @@ -0,0 +1,19 @@ +Feature: Check Out / Check In Flow + As a user I want to check items in and out to track custody. + + Scenario: Check out an item + Given there is an item "Drill" in category "Tools" + And I am on the "items" page + When I click "Drill" + Then I should see "Home" + When I click the "Check Out" button + And I select "lent" from "Reason" + And I click the "Confirm" button + Then I should see "Out" + + Scenario: Check in a checked-out item + Given there is a checked-out item "Drill" + And I am on the "items" page + When I click "Drill" + Then I should see "Out" + And I should see "Check In" diff --git a/e2e/features/flows/create-item.feature b/e2e/features/flows/create-item.feature new file mode 100644 index 0000000..83d909a --- /dev/null +++ b/e2e/features/flows/create-item.feature @@ -0,0 +1,35 @@ +Feature: Create Item Flow + As a user I want to create a new item and see it in my inventory. + + Scenario: Create a basic durable item + Given the inventory is empty + And I am on the "new item" page + When I fill in "Name *" with "Electric Drill" + And I fill in "Category" with "Tools" + And I fill in "Brand" with "Bosch" + And I click the "Create Item" button + Then I should be on "/items/" + And I should see "Electric Drill" + And I should see "Tools" + + Scenario: Created item appears in items list + Given the inventory is empty + And I am on the "new item" page + When I fill in "Name *" with "Hammer" + And I click the "Create Item" button + Then I should be on "/items/" + When I click "Back" + And I am on the "items" page + Then I should see "Hammer" + + Scenario: Create a consumable with quantity tracking + Given the inventory is empty + And I am on the "new item" page + When I fill in "Name *" with "Printer Paper" + And I select "consumable" from "Type" + And I fill in "Current" with "8" + And I fill in "Original" with "10" + And I fill in "Unit" with "reams" + And I click the "Create Item" button + Then I should be on "/items/" + And I should see "Printer Paper" diff --git a/e2e/features/flows/label-generation.feature b/e2e/features/flows/label-generation.feature new file mode 100644 index 0000000..226122b --- /dev/null +++ b/e2e/features/flows/label-generation.feature @@ -0,0 +1,15 @@ +Feature: Label Generation Flow + As a user I want to generate batches of IDs and download label PDFs. + + Scenario: Generate a batch of IDs + Given I am on the "labels" page + And I should see "Available IDs" + When I select "10 IDs" from "Batch Size" + And I click the "Generate" button + Then I should not see "Available IDs" + # After generation, the count should have increased + + Scenario: Labels page shows batch options + Given I am on the "labels" page + Then I should see "Generate ID Batch" + And I should see "Print Label Sheet" diff --git a/e2e/features/flows/scan-and-action.feature b/e2e/features/flows/scan-and-action.feature new file mode 100644 index 0000000..4e2e01c --- /dev/null +++ b/e2e/features/flows/scan-and-action.feature @@ -0,0 +1,11 @@ +Feature: Scan and Action Flow + As a user I want to scan items and take quick actions. + + Scenario: Scan page shows scanner controls + Given I am on the "scan" page + Then I should see "Scan Barcode" + And I should see "Point your camera" + + Scenario: Scan page visual appearance + Given I am on the "scan" page + Then the page should match the screenshot "scan-flow" diff --git a/e2e/features/flows/search-and-filter.feature b/e2e/features/flows/search-and-filter.feature new file mode 100644 index 0000000..0a63ea7 --- /dev/null +++ b/e2e/features/flows/search-and-filter.feature @@ -0,0 +1,26 @@ +Feature: Search and Filter Flow + As a user I want to find items quickly through search and filters. + + Scenario: Search by item name + Given there is an item "Laptop" in category "Electronics" + And there is an item "Toaster" in category "Kitchen" + And I am on the "items" page + When I type "Laptop" into the search field + Then I should see "Laptop" + And I should not see "Toaster" + + Scenario: Clear search shows all items + Given there is an item "Laptop" in category "Electronics" + And there is an item "Toaster" in category "Kitchen" + And I am on the "items" page + When I type "Laptop" into the search field + Then I should not see "Toaster" + When I type "" into the search field + Then I should see "Laptop" + And I should see "Toaster" + + Scenario: No results message for empty search + Given there is an item "Laptop" in category "Electronics" + And I am on the "items" page + When I type "xyznotfound" into the search field + Then I should see "No items match your filters" diff --git a/e2e/features/pages/dashboard.feature b/e2e/features/pages/dashboard.feature new file mode 100644 index 0000000..6c233a9 --- /dev/null +++ b/e2e/features/pages/dashboard.feature @@ -0,0 +1,46 @@ +Feature: Dashboard + As a user I want to see an overview of my inventory at a glance. + + Background: + Given I am on the "dashboard" page + + Scenario: Empty state shows call-to-action + Given the inventory is empty + And I am on the "dashboard" page + Then I should see "No items yet" + And I should see "Add First Item" + And the page title should be "SolidHaus" + + Scenario: Stats grid shows inventory summary + Given there are 5 items in the inventory + And I am on the "dashboard" page + Then I should see "Total Items" + And I should see "Checked Out" + And I should see "Overdue" + And I should see "Low Stock" + + Scenario: Checked-out section appears when items are out + Given there is a checked-out item "Drill" + And I am on the "dashboard" page + Then I should see "Checked Out" + And I should see "Drill" + + Scenario: Low-stock section appears for depleted consumables + Given there is a consumable item "Batteries" with 2 of 10 remaining + And I am on the "dashboard" page + Then I should see "Low Stock" + And I should see "Batteries" + + Scenario: Recently updated items are shown + Given there are 3 items in the inventory + And I am on the "dashboard" page + Then I should see "Recently Updated" + + Scenario: Navigation bar is visible and accessible + Then all nav buttons should be touch-target sized + And the bottom nav should be within the viewport + + Scenario: Dashboard visual appearance + Given there are 3 items in the inventory + And I am on the "dashboard" page + Then the page should match the screenshot "dashboard-with-items" diff --git a/e2e/features/pages/item-detail.feature b/e2e/features/pages/item-detail.feature new file mode 100644 index 0000000..b8821ae --- /dev/null +++ b/e2e/features/pages/item-detail.feature @@ -0,0 +1,53 @@ +Feature: Item Detail + As a user I want to view and manage an individual item's details. + + Scenario: Item details are displayed + Given there is an item "Drill" in category "Tools" + And I am on the "items" page + When I click "Drill" + Then I should see "Drill" + And I should see "Tools" + And I should see "Type" + And I should see "Storage Tier" + + Scenario: Custody badge shows checked-in state + Given there is an item "Drill" in category "Tools" + And I am on the "items" page + When I click "Drill" + Then I should see "Home" + + Scenario: Check out form appears on button click + Given there is an item "Drill" in category "Tools" + And I am on the "items" page + When I click "Drill" + And I click the "Check Out" button + Then I should see "Reason" + And I should see "Destination" + And I should see "Note" + + Scenario: Edit button enters edit mode + Given there is an item "Drill" in category "Tools" + And I am on the "items" page + When I click "Drill" + And I click the "Edit" button + Then I should see "Edit Item" + And I should see "Save Changes" + + Scenario: Delete requires confirmation + Given there is an item "Drill" in category "Tools" + And I am on the "items" page + When I click "Drill" + And I click the "Delete" button + Then I should see "Confirm Delete" + + Scenario: Quantity bar shown for consumables + Given there is a consumable item "Coffee Beans" with 3 of 10 remaining + And I am on the "items" page + When I click "Coffee Beans" + Then I should see "Quantity" + And I should see "3" + + Scenario: Item not found shows error + Given I navigate to "/items/nonexistent" + Then I should see "Item not found" + And I should see "Back to items" diff --git a/e2e/features/pages/item-new.feature b/e2e/features/pages/item-new.feature new file mode 100644 index 0000000..0ac569d --- /dev/null +++ b/e2e/features/pages/item-new.feature @@ -0,0 +1,37 @@ +Feature: New Item Form + As a user I want to create new inventory items. + + Background: + Given I am on the "new item" page + + Scenario: Form displays all required fields + Then I should see "New Item" + And I should see "Name *" + And I should see "Type" + And I should see "Create Item" + + Scenario: Submit button disabled without name + Then the page should match the screenshot "new-item-empty" + + Scenario: Cancel returns to items list + When I click the "Cancel" button + Then I should be on "/items" + + Scenario: Consumable type shows quantity fields + When I select "consumable" from "Type" + Then I should see "Quantity Tracking" + And I should see "Current" + And I should see "Original" + + Scenario: Perishable type shows expiry date + When I select "perishable" from "Type" + Then I should see "Expiry Date" + + Scenario: Durable type hides quantity fields + When I select "durable" from "Type" + Then I should not see "Quantity Tracking" + + Scenario: Storage tier buttons are visible + Then I should see "Hot" + And I should see "Warm" + And I should see "Cold" diff --git a/e2e/features/pages/items-list.feature b/e2e/features/pages/items-list.feature new file mode 100644 index 0000000..cba18ca --- /dev/null +++ b/e2e/features/pages/items-list.feature @@ -0,0 +1,42 @@ +Feature: Items List + As a user I want to browse, search, and filter my inventory items. + + Scenario: Empty inventory shows placeholder + Given the inventory is empty + And I am on the "items" page + Then I should see "No items yet" + + Scenario: Items are listed with details + Given there is an item "Laptop" in category "Electronics" + And I am on the "items" page + Then I should see "Laptop" + And I should see "Electronics" + + Scenario: Search filters items by name + Given there is an item "Laptop" in category "Electronics" + And there is an item "Toaster" in category "Kitchen" + And I am on the "items" page + When I type "Laptop" into the search field + Then I should see "Laptop" + And I should not see "Toaster" + + Scenario: Item count is displayed + Given there are 5 items in the inventory + And I am on the "items" page + Then I should see "5 of 5 items" + + Scenario: Filtered count reflects active filters + Given there is an item "Laptop" in category "Electronics" + And there is an item "Toaster" in category "Kitchen" + And I am on the "items" page + When I type "Laptop" into the search field + Then I should see "1 of 2 items" + + Scenario: New item button is visible + Given I am on the "items" page + Then I should see "+ New" + + Scenario: Items list visual appearance + Given there are 3 items in the inventory + And I am on the "items" page + Then the page should match the screenshot "items-list" diff --git a/e2e/features/pages/labels.feature b/e2e/features/pages/labels.feature new file mode 100644 index 0000000..1e17787 --- /dev/null +++ b/e2e/features/pages/labels.feature @@ -0,0 +1,23 @@ +Feature: Labels + As a user I want to generate ID batches and print label sheets. + + Background: + Given I am on the "labels" page + + Scenario: Label page shows stats + Then the page title should be "Labels" + And I should see "Available IDs" + And I should see "Assigned Items" + + Scenario: Batch size options are available + Then I should see "10 IDs" + And I should see "50 IDs (1 sheet)" + + Scenario: Generate button is present + Then I should see "Generate" + + Scenario: Print button disabled when no IDs + Then I should see "Download PDF (0 labels)" + + Scenario: Labels visual appearance + Then the page should match the screenshot "labels-page" diff --git a/e2e/features/pages/locations.feature b/e2e/features/pages/locations.feature new file mode 100644 index 0000000..663faa8 --- /dev/null +++ b/e2e/features/pages/locations.feature @@ -0,0 +1,25 @@ +Feature: Locations + As a user I want to browse locations and see which items are at each place. + + Background: + Given I am on the "locations" page + + Scenario: Location tree shows default locations + Then the page title should be "Places" + And I should see "Home" + + Scenario: Placeholder shown when no location selected + Then I should see "Select a location to view items" + + Scenario: Selecting a location shows its items + Given there is an item "Blender" at location "kueche" + And I am on the "locations" page + When I click "Küche" + Then I should see "Blender" + + Scenario: Empty location shows placeholder + When I click "Keller" + Then I should see "No items here" + + Scenario: Locations visual appearance + Then the page should match the screenshot "locations-page" diff --git a/e2e/features/pages/scan.feature b/e2e/features/pages/scan.feature new file mode 100644 index 0000000..86907e5 --- /dev/null +++ b/e2e/features/pages/scan.feature @@ -0,0 +1,13 @@ +Feature: Scan Page + As a user I want to scan barcodes to look up and manage items. + + Background: + Given I am on the "scan" page + + Scenario: Scanner is displayed + Then the page title should be "Scan" + And I should see "Scan Barcode" + And I should see "Point your camera" + + Scenario: Scan page visual appearance + Then the page should match the screenshot "scan-page" diff --git a/e2e/features/pages/settings.feature b/e2e/features/pages/settings.feature new file mode 100644 index 0000000..9c87327 --- /dev/null +++ b/e2e/features/pages/settings.feature @@ -0,0 +1,22 @@ +Feature: Settings + As a user I want to view app settings and database statistics. + + Background: + Given I am on the "settings" page + + Scenario: Settings page shows sections + Then the page title should be "Settings" + And I should see "Database" + And I should see "Sync" + And I should see "About" + + Scenario: Database stats show counts + Then I should see "Items" + And I should see "Locations" + + Scenario: About section shows version + Then I should see "SolidHaus v0.1.0" + And I should see "Data is stored locally" + + Scenario: Settings visual appearance + Then the page should match the screenshot "settings-page" diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..44b48fd --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,15 @@ +/** + * E2E test configuration. + * Tests use vitest as runner + playwright-core for browser automation. + * Run with: bun run test:e2e + */ +export const e2eConfig = { + baseURL: 'http://localhost:4173', + viewport: { width: 390, height: 844 }, + colorScheme: 'dark' as const, + screenshotDir: 'e2e/screenshots', + projects: { + 'mobile-chrome': { width: 412, height: 915 }, + 'desktop-chrome': { width: 1280, height: 720 }, + }, +}; diff --git a/e2e/specs/flows/check-out-check-in.spec.ts b/e2e/specs/flows/check-out-check-in.spec.ts new file mode 100644 index 0000000..bb83ed5 --- /dev/null +++ b/e2e/specs/flows/check-out-check-in.spec.ts @@ -0,0 +1,50 @@ +/** + * @feature Check Out / Check In Flow + * @see e2e/features/flows/check-out-check-in.feature + */ +import { describe, test } from 'vitest'; +import { setupBrowser } from '../../support/browser'; +import { waitForAppReady, seedItems } from '../../support/seed'; +import { buildItem, buildCheckedOutItem } from '../../support/item-factory'; +import { expectVisible } from '../../support/expect'; + +const { getPage } = setupBrowser(); + +describe('Check Out / Check In Flow', () => { + test('check out an item', async () => { + const page = getPage(); + const item = buildItem({ name: 'Drill', category: 'Tools' }); + await page.goto('/'); + await waitForAppReady(page); + await seedItems(page, [item]); + await page.goto('/items'); + await waitForAppReady(page); + + await page.getByText('Drill').click(); + // 'Home' badge on item detail (not the location tree 'Home') + await expectVisible(page.locator('main').getByText('Home').first()); + + await page.getByRole('button', { name: 'Check Out' }).click(); + await page.locator('#co-reason').selectOption('lent'); + await page.getByRole('button', { name: 'Confirm' }).click(); + + // Badge shows reason label "Lent" after checkout + await expectVisible(page.locator('main').getByText('Lent').first()); + }); + + test('check in a checked-out item shows check-in form', async () => { + const page = getPage(); + const item = buildCheckedOutItem({ name: 'Drill' }); + await page.goto('/'); + await waitForAppReady(page); + await seedItems(page, [item]); + await page.goto('/items'); + await waitForAppReady(page); + + await page.getByText('Drill').click(); + // buildCheckedOutItem sets reason 'in-use' → badge shows "In Use" + await expectVisible(page.locator('main').getByText('In Use').first()); + await expectVisible(page.getByRole('heading', { name: 'Check In' })); + await expectVisible(page.getByText('Return to')); + }); +}); diff --git a/e2e/specs/flows/create-item.spec.ts b/e2e/specs/flows/create-item.spec.ts new file mode 100644 index 0000000..45b166e --- /dev/null +++ b/e2e/specs/flows/create-item.spec.ts @@ -0,0 +1,70 @@ +/** + * @feature Create Item Flow + * @see e2e/features/flows/create-item.feature + */ +import { describe, test } from 'vitest'; +import { setupBrowser } from '../../support/browser'; +import { waitForAppReady, clearItems } from '../../support/seed'; +import { expectVisible } from '../../support/expect'; + +const { getPage } = setupBrowser(); + +describe('Create Item Flow', () => { + test('create a basic durable item', async () => { + const page = getPage(); + await page.goto('/'); + await waitForAppReady(page); + await clearItems(page); + + await page.goto('/items/new'); + await waitForAppReady(page); + + await page.getByLabel('Name *').fill('Electric Drill'); + await page.getByLabel('Category').fill('Tools'); + await page.getByLabel('Brand').fill('Bosch'); + await page.getByRole('button', { name: 'Create Item' }).click(); + + await page.waitForURL(/\/items\//); + await expectVisible(page.getByText('Electric Drill')); + await expectVisible(page.getByText('Tools')); + }); + + test('created item appears in items list', async () => { + const page = getPage(); + await page.goto('/'); + await waitForAppReady(page); + await clearItems(page); + + await page.goto('/items/new'); + await waitForAppReady(page); + + await page.getByLabel('Name *').fill('Hammer'); + await page.getByRole('button', { name: 'Create Item' }).click(); + + await page.waitForURL(/\/items\//); + + await page.goto('/items'); + await waitForAppReady(page); + await expectVisible(page.getByText('Hammer')); + }); + + test('create a consumable with quantity tracking', async () => { + const page = getPage(); + await page.goto('/'); + await waitForAppReady(page); + await clearItems(page); + + await page.goto('/items/new'); + await waitForAppReady(page); + + await page.getByLabel('Name *').fill('Printer Paper'); + await page.getByLabel('Type').selectOption('consumable'); + await page.getByLabel('Current').fill('8'); + await page.getByLabel('Original').fill('10'); + await page.getByLabel('Unit').fill('reams'); + await page.getByRole('button', { name: 'Create Item' }).click(); + + await page.waitForURL(/\/items\//); + await expectVisible(page.getByText('Printer Paper')); + }); +}); diff --git a/e2e/specs/flows/label-generation.spec.ts b/e2e/specs/flows/label-generation.spec.ts new file mode 100644 index 0000000..26bf697 --- /dev/null +++ b/e2e/specs/flows/label-generation.spec.ts @@ -0,0 +1,38 @@ +/** + * @feature Label Generation Flow + * @see e2e/features/flows/label-generation.feature + */ +import { describe, test } from 'vitest'; +import { setupBrowser } from '../../support/browser'; +import { waitForAppReady } from '../../support/seed'; +import { expectVisible } from '../../support/expect'; + +const { getPage } = setupBrowser(); + +describe('Label Generation Flow', () => { + test('generate a batch of IDs', async () => { + const page = getPage(); + await page.goto('/labels'); + await waitForAppReady(page); + + await expectVisible(page.locator('.text-xs').filter({ hasText: 'Available IDs' })); + + await page.locator('#batchSize').selectOption('10'); + await page.getByRole('button', { name: 'Generate' }).click(); + + // Wait for generation to complete + await page.waitForFunction(() => { + const el = document.querySelector('.text-blue-400.text-2xl'); + return el && el.textContent !== '0'; + }, { timeout: 10000 }); + }); + + test('labels page shows batch options', async () => { + const page = getPage(); + await page.goto('/labels'); + await waitForAppReady(page); + + await expectVisible(page.getByText('Generate ID Batch')); + await expectVisible(page.getByText('Print Label Sheet')); + }); +}); diff --git a/e2e/specs/flows/scan-and-action.spec.ts b/e2e/specs/flows/scan-and-action.spec.ts new file mode 100644 index 0000000..6892949 --- /dev/null +++ b/e2e/specs/flows/scan-and-action.spec.ts @@ -0,0 +1,21 @@ +/** + * @feature Scan and Action Flow + * @see e2e/features/flows/scan-and-action.feature + */ +import { describe, test } from 'vitest'; +import { setupBrowser } from '../../support/browser'; +import { waitForAppReady } from '../../support/seed'; +import { expectVisible } from '../../support/expect'; + +const { getPage } = setupBrowser(); + +describe('Scan and Action Flow', () => { + test('scan page shows scanner controls', async () => { + const page = getPage(); + await page.goto('/scan'); + await waitForAppReady(page); + + await expectVisible(page.getByText('Scan Barcode')); + await expectVisible(page.getByText('Point your camera')); + }); +}); diff --git a/e2e/specs/flows/search-and-filter.spec.ts b/e2e/specs/flows/search-and-filter.spec.ts new file mode 100644 index 0000000..90510bf --- /dev/null +++ b/e2e/specs/flows/search-and-filter.spec.ts @@ -0,0 +1,60 @@ +/** + * @feature Search and Filter Flow + * @see e2e/features/flows/search-and-filter.feature + */ +import { describe, test } from 'vitest'; +import { setupBrowser } from '../../support/browser'; +import { waitForAppReady, seedItems } from '../../support/seed'; +import { buildItem } from '../../support/item-factory'; +import { expectVisible, expectNotVisible } from '../../support/expect'; + +const { getPage } = setupBrowser(); + +describe('Search and Filter Flow', () => { + test('search by item name', async () => { + const page = getPage(); + await page.goto('/'); + await waitForAppReady(page); + await seedItems(page, [ + buildItem({ name: 'Laptop', category: 'Electronics' }), + buildItem({ name: 'Toaster', category: 'Kitchen' }), + ]); + await page.goto('/items'); + await waitForAppReady(page); + + await page.getByPlaceholder('Search items...').fill('Laptop'); + await expectVisible(page.getByText('Laptop')); + await expectNotVisible(page.getByText('Toaster')); + }); + + test('clear search shows all items', async () => { + const page = getPage(); + await page.goto('/'); + await waitForAppReady(page); + await seedItems(page, [ + buildItem({ name: 'Laptop', category: 'Electronics' }), + buildItem({ name: 'Toaster', category: 'Kitchen' }), + ]); + await page.goto('/items'); + await waitForAppReady(page); + + await page.getByPlaceholder('Search items...').fill('Laptop'); + await expectNotVisible(page.getByText('Toaster')); + + await page.getByPlaceholder('Search items...').clear(); + await expectVisible(page.getByText('Laptop')); + await expectVisible(page.getByText('Toaster')); + }); + + test('no results message for empty search', async () => { + const page = getPage(); + await page.goto('/'); + await waitForAppReady(page); + await seedItems(page, [buildItem({ name: 'Laptop', category: 'Electronics' })]); + await page.goto('/items'); + await waitForAppReady(page); + + await page.getByPlaceholder('Search items...').fill('xyznotfound'); + await expectVisible(page.getByText('No items match your filters')); + }); +}); diff --git a/e2e/specs/pages/dashboard.spec.ts b/e2e/specs/pages/dashboard.spec.ts new file mode 100644 index 0000000..8947c47 --- /dev/null +++ b/e2e/specs/pages/dashboard.spec.ts @@ -0,0 +1,121 @@ +/** + * @feature Dashboard + * @see e2e/features/pages/dashboard.feature + */ +import { describe, test, expect } from 'vitest'; +import { setupBrowser } from '../../support/browser'; +import { waitForAppReady, seedItems, clearItems } from '../../support/seed'; +import { buildItem, buildCheckedOutItem, buildConsumableItem, resetCounter } from '../../support/item-factory'; +import { assertMinTouchTarget, assertWithinViewport } from '../../support/layout'; +import { expectVisible, expectText } from '../../support/expect'; + +const { getPage } = setupBrowser(); + +describe('Dashboard', () => { + test('empty state shows call-to-action', async () => { + const page = getPage(); + await page.goto('/'); + await waitForAppReady(page); + await clearItems(page); + await page.reload(); + await waitForAppReady(page); + + await expectVisible(page.getByText('No items yet')); + await expectVisible(page.getByText('Add First Item')); + await expectText(page.getByRole('heading', { level: 1 }), 'SolidHaus'); + }); + + test('stats grid shows inventory summary', async () => { + const page = getPage(); + resetCounter(); + await page.goto('/'); + await waitForAppReady(page); + + const items = Array.from({ length: 5 }, (_, i) => + buildItem({ name: `Item ${i + 1}`, category: i % 2 === 0 ? 'Electronics' : 'Kitchen' }) + ); + await seedItems(page, items); + await page.reload(); + await waitForAppReady(page); + + await expectVisible(page.getByText('Total Items')); + await expectVisible(page.getByText('Checked Out')); + await expectVisible(page.getByText('Overdue')); + await expectVisible(page.getByText('Low Stock')); + }); + + test('checked-out section appears when items are out', async () => { + const page = getPage(); + await page.goto('/'); + await waitForAppReady(page); + + const item = buildCheckedOutItem({ name: 'Drill' }); + await seedItems(page, [item]); + await page.reload(); + await waitForAppReady(page); + + await expectVisible(page.getByText('Drill').first()); + }); + + test('low-stock section appears for depleted consumables', async () => { + const page = getPage(); + await page.goto('/'); + await waitForAppReady(page); + + const item = buildConsumableItem({ + name: 'Batteries', + currentQuantity: 2, + originalQuantity: 10, + lowThreshold: 5, + }); + await seedItems(page, [item]); + await page.reload(); + await waitForAppReady(page); + + await expectVisible(page.getByText('Batteries').first()); + }); + + test('recently updated items are shown', async () => { + const page = getPage(); + resetCounter(); + await page.goto('/'); + await waitForAppReady(page); + + const items = Array.from({ length: 3 }, (_, i) => buildItem({ name: `Item ${i + 1}` })); + await seedItems(page, items); + await page.reload(); + await waitForAppReady(page); + + await expectVisible(page.getByText('Recently Updated')); + }); + + test('navigation bar is visible and accessible', async () => { + const page = getPage(); + await page.goto('/'); + await waitForAppReady(page); + + const navLinks = page.locator('nav a'); + const count = await navLinks.count(); + expect(count).toBe(5); + + for (let i = 0; i < count; i++) { + await assertMinTouchTarget(navLinks.nth(i)); + } + await assertWithinViewport(page.locator('nav'), page); + }); + + test('dashboard screenshot', async () => { + const page = getPage(); + resetCounter(); + await page.goto('/'); + await waitForAppReady(page); + + const items = Array.from({ length: 3 }, (_, i) => buildItem({ name: `Item ${i + 1}` })); + await seedItems(page, items); + await page.reload(); + await waitForAppReady(page); + + const screenshot = await page.screenshot(); + expect(screenshot).toBeInstanceOf(Buffer); + }); +}); diff --git a/e2e/specs/pages/item-detail.spec.ts b/e2e/specs/pages/item-detail.spec.ts new file mode 100644 index 0000000..00c8255 --- /dev/null +++ b/e2e/specs/pages/item-detail.spec.ts @@ -0,0 +1,113 @@ +/** + * @feature Item Detail + * @see e2e/features/pages/item-detail.feature + */ +import { describe, test } from 'vitest'; +import { setupBrowser } from '../../support/browser'; +import { waitForAppReady, seedItems } from '../../support/seed'; +import { buildItem, buildConsumableItem } from '../../support/item-factory'; +import { expectVisible, expectText } from '../../support/expect'; + +const { getPage } = setupBrowser(); + +describe('Item Detail', () => { + test('item details are displayed', async () => { + const page = getPage(); + const item = buildItem({ name: 'Drill', category: 'Tools' }); + await page.goto('/'); + await waitForAppReady(page); + await seedItems(page, [item]); + await page.goto('/items'); + await waitForAppReady(page); + + await page.getByText('Drill').click(); + await expectText(page.getByRole('heading', { level: 1 }), 'Drill'); + await expectVisible(page.getByText('Tools')); + await expectVisible(page.getByText('Type')); + await expectVisible(page.getByText('Storage Tier')); + }); + + test('custody badge shows checked-in state', async () => { + const page = getPage(); + const item = buildItem({ name: 'Drill', category: 'Tools' }); + await page.goto('/'); + await waitForAppReady(page); + await seedItems(page, [item]); + await page.goto('/items'); + await waitForAppReady(page); + + await page.getByText('Drill').click(); + await expectVisible(page.getByText('Home')); + }); + + test('check out form appears on button click', async () => { + const page = getPage(); + const item = buildItem({ name: 'Drill', category: 'Tools' }); + await page.goto('/'); + await waitForAppReady(page); + await seedItems(page, [item]); + await page.goto('/items'); + await waitForAppReady(page); + + await page.getByText('Drill').click(); + await page.getByRole('button', { name: 'Check Out' }).click(); + + await expectVisible(page.getByText('Reason')); + await expectVisible(page.getByText('Destination')); + }); + + test('edit button enters edit mode', async () => { + const page = getPage(); + const item = buildItem({ name: 'Drill', category: 'Tools' }); + await page.goto('/'); + await waitForAppReady(page); + await seedItems(page, [item]); + await page.goto('/items'); + await waitForAppReady(page); + + await page.getByText('Drill').click(); + await page.getByRole('button', { name: 'Edit' }).click(); + + await expectVisible(page.getByText('Edit Item')); + await expectVisible(page.getByText('Save Changes')); + }); + + test('delete requires confirmation', async () => { + const page = getPage(); + const item = buildItem({ name: 'Drill', category: 'Tools' }); + await page.goto('/'); + await waitForAppReady(page); + await seedItems(page, [item]); + await page.goto('/items'); + await waitForAppReady(page); + + await page.getByText('Drill').click(); + await page.getByRole('button', { name: 'Delete' }).click(); + + await expectVisible(page.getByText('Confirm Delete')); + }); + + test('quantity bar shown for consumables', async () => { + const page = getPage(); + const item = buildConsumableItem({ name: 'Coffee Beans', currentQuantity: 3, originalQuantity: 10 }); + await page.goto('/'); + await waitForAppReady(page); + await seedItems(page, [item]); + await page.goto('/items'); + await waitForAppReady(page); + + await page.getByText('Coffee Beans').click(); + await expectVisible(page.getByText('Quantity')); + }); + + test('item not found shows error', async () => { + const page = getPage(); + await page.goto('/'); + await waitForAppReady(page); + await page.goto('/items/nonexistent'); + await waitForAppReady(page); + + await expectVisible(page.getByText('Item not found')); + await expectVisible(page.getByText('Back to items')); + }); +}); diff --git a/e2e/specs/pages/item-new.spec.ts b/e2e/specs/pages/item-new.spec.ts new file mode 100644 index 0000000..a30b312 --- /dev/null +++ b/e2e/specs/pages/item-new.spec.ts @@ -0,0 +1,79 @@ +/** + * @feature New Item Form + * @see e2e/features/pages/item-new.feature + */ +import { describe, test, expect } from 'vitest'; +import { setupBrowser } from '../../support/browser'; +import { waitForAppReady } from '../../support/seed'; +import { expectVisible, expectNotVisible, expectText, expectDisabled } from '../../support/expect'; + +const { getPage } = setupBrowser(); + +describe('New Item Form', () => { + test('form displays all required fields', async () => { + const page = getPage(); + await page.goto('/items/new'); + await waitForAppReady(page); + + await expectText(page.getByRole('heading', { level: 1 }), 'New Item'); + await expectVisible(page.getByText('Name *')); + await expectVisible(page.getByText('Type')); + await expectVisible(page.getByText('Create Item')); + }); + + test('submit button disabled without name', async () => { + const page = getPage(); + await page.goto('/items/new'); + await waitForAppReady(page); + + await expectDisabled(page.getByRole('button', { name: 'Create Item' })); + }); + + test('cancel returns to items list', async () => { + const page = getPage(); + await page.goto('/items/new'); + await waitForAppReady(page); + + await page.getByRole('button', { name: 'Cancel' }).click(); + expect(page.url()).toContain('/items'); + }); + + test('consumable type shows quantity fields', async () => { + const page = getPage(); + await page.goto('/items/new'); + await waitForAppReady(page); + + await page.getByLabel('Type').selectOption('consumable'); + await expectVisible(page.getByText('Quantity Tracking')); + await expectVisible(page.getByText('Current')); + await expectVisible(page.getByText('Original')); + }); + + test('perishable type shows expiry date', async () => { + const page = getPage(); + await page.goto('/items/new'); + await waitForAppReady(page); + + await page.getByLabel('Type').selectOption('perishable'); + await expectVisible(page.getByText('Expiry Date')); + }); + + test('durable type hides quantity fields', async () => { + const page = getPage(); + await page.goto('/items/new'); + await waitForAppReady(page); + + await page.getByLabel('Type').selectOption('durable'); + await expectNotVisible(page.getByText('Quantity Tracking')); + }); + + test('storage tier buttons are visible', async () => { + const page = getPage(); + await page.goto('/items/new'); + await waitForAppReady(page); + + await expectVisible(page.getByText('Hot')); + await expectVisible(page.getByText('Warm')); + await expectVisible(page.getByText('Cold')); + }); +}); diff --git a/e2e/specs/pages/items-list.spec.ts b/e2e/specs/pages/items-list.spec.ts new file mode 100644 index 0000000..814a66f --- /dev/null +++ b/e2e/specs/pages/items-list.spec.ts @@ -0,0 +1,89 @@ +/** + * @feature Items List + * @see e2e/features/pages/items-list.feature + */ +import { describe, test, expect } from 'vitest'; +import { setupBrowser } from '../../support/browser'; +import { waitForAppReady, seedItems, clearItems } from '../../support/seed'; +import { buildItem, resetCounter } from '../../support/item-factory'; +import { expectVisible, expectNotVisible } from '../../support/expect'; + +const { getPage } = setupBrowser(); + +describe('Items List', () => { + test('empty inventory shows placeholder', async () => { + const page = getPage(); + await page.goto('/'); + await waitForAppReady(page); + await clearItems(page); + await page.goto('/items'); + await waitForAppReady(page); + + await expectVisible(page.getByText('No items yet')); + }); + + test('items are listed with details', async () => { + const page = getPage(); + await page.goto('/'); + await waitForAppReady(page); + await seedItems(page, [buildItem({ name: 'Laptop', category: 'Electronics' })]); + await page.goto('/items'); + await waitForAppReady(page); + + await expectVisible(page.getByText('Laptop')); + // 'Electronics' appears in both the filter dropdown and the item card + await expectVisible(page.locator('button').filter({ hasText: 'Electronics' })); + }); + + test('search filters items by name', async () => { + const page = getPage(); + await page.goto('/'); + await waitForAppReady(page); + await seedItems(page, [ + buildItem({ name: 'Laptop', category: 'Electronics' }), + buildItem({ name: 'Toaster', category: 'Kitchen' }), + ]); + await page.goto('/items'); + await waitForAppReady(page); + + await page.getByPlaceholder('Search items...').fill('Laptop'); + await expectVisible(page.getByText('Laptop')); + await expectNotVisible(page.getByText('Toaster')); + }); + + test('item count is displayed', async () => { + const page = getPage(); + resetCounter(); + await page.goto('/'); + await waitForAppReady(page); + const items = Array.from({ length: 5 }, (_, i) => buildItem({ name: `Item ${i + 1}` })); + await seedItems(page, items); + await page.goto('/items'); + await waitForAppReady(page); + + await expectVisible(page.getByText('5 of 5 items')); + }); + + test('filtered count reflects active filters', async () => { + const page = getPage(); + await page.goto('/'); + await waitForAppReady(page); + await seedItems(page, [ + buildItem({ name: 'Laptop', category: 'Electronics' }), + buildItem({ name: 'Toaster', category: 'Kitchen' }), + ]); + await page.goto('/items'); + await waitForAppReady(page); + + await page.getByPlaceholder('Search items...').fill('Laptop'); + await expectVisible(page.getByText('1 of 2 items')); + }); + + test('new item button is visible', async () => { + const page = getPage(); + await page.goto('/items'); + await waitForAppReady(page); + + await expectVisible(page.getByText('+ New')); + }); +}); diff --git a/e2e/specs/pages/labels.spec.ts b/e2e/specs/pages/labels.spec.ts new file mode 100644 index 0000000..42a7af5 --- /dev/null +++ b/e2e/specs/pages/labels.spec.ts @@ -0,0 +1,51 @@ +/** + * @feature Labels + * @see e2e/features/pages/labels.feature + */ +import { describe, test, expect } from 'vitest'; +import { setupBrowser } from '../../support/browser'; +import { waitForAppReady } from '../../support/seed'; +import { expectVisible, expectText, expectDisabled } from '../../support/expect'; + +const { getPage } = setupBrowser(); + +describe('Labels', () => { + test('label page shows stats', async () => { + const page = getPage(); + await page.goto('/labels'); + await waitForAppReady(page); + + await expectText(page.getByRole('heading', { level: 1 }), 'Labels'); + await expectVisible(page.getByText('Available IDs')); + await expectVisible(page.getByText('Assigned Items')); + }); + + test('batch size options are available', async () => { + const page = getPage(); + await page.goto('/labels'); + await waitForAppReady(page); + + // Options inside