Add Playwright e2e tests for all 8 spec acceptance scenarios
All checks were successful
Deploy / deploy (push) Successful in 1m49s

Implement browser-based e2e tests covering:
- Tests 1-5: Stats progression (cohort, feed, eggs, moves, backdating)
- Test 6: Event viewing and deletion UI
- Test 7: Harvest outcomes with yield items
- Test 8: Optimistic lock selection validation

Includes page objects for reusable form interactions and fresh_db
fixtures for tests requiring isolated database state.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 17:30:26 +00:00
parent feca97a796
commit 51e502ed10
11 changed files with 1670 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
# ABOUTME: Page object module exports for Playwright e2e tests.
# ABOUTME: Provides clean imports for all page objects.
from .animals import AnimalsPage
from .eggs import EggsPage
from .feed import FeedPage
from .harvest import HarvestPage
from .move import MovePage
__all__ = [
"AnimalsPage",
"EggsPage",
"FeedPage",
"HarvestPage",
"MovePage",
]

View File

@@ -0,0 +1,72 @@
# ABOUTME: Page object for animal-related pages (cohort creation, registry).
# ABOUTME: Encapsulates navigation and form interactions for animal management.
from playwright.sync_api import Page, expect
class AnimalsPage:
"""Page object for animal management pages."""
def __init__(self, page: Page, base_url: str):
self.page = page
self.base_url = base_url
def goto_cohort_form(self):
"""Navigate to the create cohort form."""
self.page.goto(f"{self.base_url}/actions/cohort")
expect(self.page.locator("body")).to_be_visible()
def create_cohort(
self,
*,
species: str,
location_name: str,
count: int,
life_stage: str,
sex: str,
origin: str = "purchased",
notes: str = "",
):
"""Fill and submit the create cohort form.
Args:
species: "duck" or "goose"
location_name: Human-readable location name (e.g., "Strip 1")
count: Number of animals
life_stage: "hatchling", "juvenile", or "adult"
sex: "unknown", "female", or "male"
origin: "hatched", "purchased", "rescued", or "unknown"
notes: Optional notes
"""
self.goto_cohort_form()
# Fill form fields
self.page.select_option("#species", species)
self.page.select_option("#location_id", label=location_name)
self.page.fill("#count", str(count))
self.page.select_option("#life_stage", life_stage)
self.page.select_option("#sex", sex)
self.page.select_option("#origin", origin)
if notes:
self.page.fill("#notes", notes)
# Submit the form
self.page.click('button[type="submit"]')
# Wait for navigation/response
self.page.wait_for_load_state("networkidle")
def goto_registry(self, filter_str: str = ""):
"""Navigate to the animal registry with optional filter."""
url = f"{self.base_url}/registry"
if filter_str:
url += f"?filter={filter_str}"
self.page.goto(url)
expect(self.page.locator("body")).to_be_visible()
def get_animal_count_in_registry(self) -> int:
"""Get the count of animals currently displayed in registry."""
# Registry shows animal rows - count them
rows = self.page.locator("table tbody tr")
return rows.count()

137
tests/e2e/pages/eggs.py Normal file
View File

@@ -0,0 +1,137 @@
# ABOUTME: Page object for egg collection and sales pages.
# ABOUTME: Encapsulates navigation and form interactions for product operations.
from playwright.sync_api import Page, expect
class EggsPage:
"""Page object for egg collection and sales pages."""
def __init__(self, page: Page, base_url: str):
self.page = page
self.base_url = base_url
def goto_eggs_page(self):
"""Navigate to the eggs (home) page."""
self.page.goto(self.base_url)
expect(self.page.locator("body")).to_be_visible()
def collect_eggs(
self,
*,
location_name: str,
quantity: int,
notes: str = "",
):
"""Fill and submit the egg harvest (collect) form.
Args:
location_name: Human-readable location name (e.g., "Strip 1")
quantity: Number of eggs collected
notes: Optional notes
"""
self.goto_eggs_page()
# Fill harvest form
self.page.select_option("#location_id", label=location_name)
self.page.fill("#quantity", str(quantity))
if notes:
self.page.fill("#notes", notes)
# Submit the harvest form
self.page.click('form[hx-post*="product-collected"] button[type="submit"]')
# Wait for HTMX response
self.page.wait_for_load_state("networkidle")
def collect_eggs_backdated(
self,
*,
location_name: str,
quantity: int,
datetime_local: str,
notes: str = "",
):
"""Collect eggs with a backdated timestamp.
Args:
location_name: Human-readable location name
quantity: Number of eggs
datetime_local: Datetime string in format "YYYY-MM-DDTHH:MM"
notes: Optional notes
"""
self.goto_eggs_page()
# Fill harvest form
self.page.select_option("#location_id", label=location_name)
self.page.fill("#quantity", str(quantity))
if notes:
self.page.fill("#notes", notes)
# Expand datetime picker and set backdated time
# Click the datetime toggle to expand
datetime_toggle = self.page.locator("[data-datetime-picker]")
if datetime_toggle.count() > 0:
datetime_toggle.first.click()
# Fill the datetime-local input
self.page.fill('input[type="datetime-local"]', datetime_local)
# Submit the harvest form
self.page.click('form[hx-post*="product-collected"] button[type="submit"]')
# Wait for HTMX response
self.page.wait_for_load_state("networkidle")
def sell_eggs(
self,
*,
product_code: str = "egg.duck",
quantity: int,
total_price_cents: int,
buyer: str = "",
notes: str = "",
):
"""Fill and submit the egg sale form.
Args:
product_code: Product code (e.g., "egg.duck")
quantity: Number of eggs sold
total_price_cents: Total price in cents
buyer: Optional buyer name
notes: Optional notes
"""
self.goto_eggs_page()
# Switch to sell tab if needed
sell_tab = self.page.locator('text="Sell"')
if sell_tab.count() > 0:
sell_tab.click()
self.page.wait_for_load_state("networkidle")
# Fill sell form
self.page.select_option("#product_code", product_code)
self.page.fill("#sell_quantity", str(quantity))
self.page.fill("#total_price_cents", str(total_price_cents))
if buyer:
self.page.fill("#buyer", buyer)
if notes:
self.page.fill("#sell_notes", notes)
# Submit the sell form
self.page.click('form[hx-post*="product-sold"] button[type="submit"]')
# Wait for HTMX response
self.page.wait_for_load_state("networkidle")
def get_egg_stats(self) -> dict:
"""Get egg statistics from the page.
Returns dict with stats like eggs_per_day, cost_per_egg, etc.
"""
# This depends on how stats are displayed on the page
# May need to parse text content from stats section
return {}

100
tests/e2e/pages/feed.py Normal file
View File

@@ -0,0 +1,100 @@
# ABOUTME: Page object for feed management pages (purchase, give feed).
# ABOUTME: Encapsulates navigation and form interactions for feed operations.
from playwright.sync_api import Page, expect
class FeedPage:
"""Page object for feed management pages."""
def __init__(self, page: Page, base_url: str):
self.page = page
self.base_url = base_url
def goto_feed_page(self):
"""Navigate to the feed quick capture page."""
self.page.goto(f"{self.base_url}/feed")
expect(self.page.locator("body")).to_be_visible()
def purchase_feed(
self,
*,
feed_type: str = "layer",
bag_size_kg: int,
bags_count: int,
bag_price_euros: float,
vendor: str = "",
notes: str = "",
):
"""Fill and submit the feed purchase form.
Args:
feed_type: Feed type code (e.g., "layer")
bag_size_kg: Size of each bag in kg
bags_count: Number of bags
bag_price_euros: Price per bag in EUR
vendor: Optional vendor name
notes: Optional notes
"""
self.goto_feed_page()
# The purchase form uses specific IDs
self.page.select_option("#purchase_feed_type_code", feed_type)
self.page.fill("#bag_size_kg", str(bag_size_kg))
self.page.fill("#bags_count", str(bags_count))
self.page.fill("#bag_price_euros", str(bag_price_euros))
if vendor:
self.page.fill("#vendor", vendor)
if notes:
self.page.fill("#purchase_notes", notes)
# Submit the purchase form (second form on page)
self.page.click('form[hx-post*="feed-purchased"] button[type="submit"]')
# Wait for HTMX response
self.page.wait_for_load_state("networkidle")
def give_feed(
self,
*,
location_name: str,
feed_type: str = "layer",
amount_kg: int,
notes: str = "",
):
"""Fill and submit the feed given form.
Args:
location_name: Human-readable location name (e.g., "Strip 1")
feed_type: Feed type code (e.g., "layer")
amount_kg: Amount of feed in kg
notes: Optional notes
"""
self.goto_feed_page()
# The give form uses specific IDs
self.page.select_option("#location_id", label=location_name)
self.page.select_option("#feed_type_code", feed_type)
self.page.fill("#amount_kg", str(amount_kg))
if notes:
self.page.fill("#notes", notes)
# Submit the give form (first form on page)
self.page.click('form[hx-post*="feed-given"] button[type="submit"]')
# Wait for HTMX response
self.page.wait_for_load_state("networkidle")
def get_feed_inventory_balance(self, feed_type: str = "layer") -> dict:
"""Get the current feed inventory from the page stats.
Returns dict with purchased_kg, given_kg, balance_kg if visible,
or empty dict if stats not found.
"""
# This depends on how stats are displayed on the page
# May need to parse text content from stats section
# For now, return empty - can be enhanced based on actual UI
return {}

176
tests/e2e/pages/harvest.py Normal file
View File

@@ -0,0 +1,176 @@
# ABOUTME: Page object for animal outcome (harvest/death) pages.
# ABOUTME: Encapsulates navigation and form interactions for recording outcomes.
from playwright.sync_api import Page, expect
class HarvestPage:
"""Page object for animal outcome (harvest) pages."""
def __init__(self, page: Page, base_url: str):
self.page = page
self.base_url = base_url
def goto_outcome_page(self, filter_str: str = ""):
"""Navigate to the record outcome page.
Args:
filter_str: Optional filter DSL query to pre-populate
"""
url = f"{self.base_url}/actions/outcome"
if filter_str:
url += f"?filter={filter_str}"
self.page.goto(url)
expect(self.page.locator("body")).to_be_visible()
def set_filter(self, filter_str: str):
"""Set the filter field and wait for selection preview.
Args:
filter_str: Filter DSL query (e.g., 'location:"Strip 2" sex:female')
"""
self.page.fill("#filter", filter_str)
# Trigger change event and wait for HTMX preview
self.page.keyboard.press("Tab")
# Wait for selection container to update
self.page.wait_for_selector("#selection-container", state="visible")
self.page.wait_for_load_state("networkidle")
def get_selection_count(self) -> int:
"""Get the count of selected animals from the preview."""
container = self.page.locator("#selection-container")
if container.count() == 0:
return 0
text = container.text_content() or ""
import re
match = re.search(r"(\d+)\s*animal", text.lower())
if match:
return int(match.group(1))
checkboxes = container.locator('input[type="checkbox"]')
return checkboxes.count()
def select_specific_animals(self, animal_ids: list[str]):
"""Select specific animals from checkbox list.
Args:
animal_ids: List of animal IDs to select
"""
for animal_id in animal_ids:
checkbox = self.page.locator(f'input[type="checkbox"][value="{animal_id}"]')
if checkbox.count() > 0:
checkbox.check()
def record_harvest(
self,
*,
filter_str: str = "",
animal_ids: list[str] | None = None,
reason: str = "",
yield_product_code: str = "",
yield_unit: str = "",
yield_quantity: int | None = None,
yield_weight_kg: float | None = None,
notes: str = "",
):
"""Record a harvest outcome.
Args:
filter_str: Filter DSL query (optional if using animal_ids)
animal_ids: Specific animal IDs to select (optional)
reason: Reason for harvest
yield_product_code: Product code for yield (e.g., "meat.part.breast.duck")
yield_unit: Unit for yield (e.g., "kg")
yield_quantity: Quantity of yield items
yield_weight_kg: Weight in kg
notes: Optional notes
"""
self.goto_outcome_page()
if filter_str:
self.set_filter(filter_str)
if animal_ids:
self.select_specific_animals(animal_ids)
# Select harvest outcome
self.page.select_option("#outcome", "harvest")
if reason:
self.page.fill("#reason", reason)
# Fill yield fields if provided
if yield_product_code and yield_product_code != "-":
self.page.select_option("#yield_product_code", yield_product_code)
if yield_unit:
self.page.fill("#yield_unit", yield_unit)
if yield_quantity is not None:
self.page.fill("#yield_quantity", str(yield_quantity))
if yield_weight_kg is not None:
self.page.fill("#yield_weight_kg", str(yield_weight_kg))
if notes:
self.page.fill("#notes", notes)
# Submit
self.page.click('button[type="submit"]')
self.page.wait_for_load_state("networkidle")
def record_death(
self,
*,
filter_str: str = "",
animal_ids: list[str] | None = None,
outcome: str = "died",
reason: str = "",
notes: str = "",
):
"""Record a death/loss outcome.
Args:
filter_str: Filter DSL query (optional)
animal_ids: Specific animal IDs (optional)
outcome: Outcome type (e.g., "died", "escaped", "predated")
reason: Reason for outcome
notes: Optional notes
"""
self.goto_outcome_page()
if filter_str:
self.set_filter(filter_str)
if animal_ids:
self.select_specific_animals(animal_ids)
# Select outcome
self.page.select_option("#outcome", outcome)
if reason:
self.page.fill("#reason", reason)
if notes:
self.page.fill("#notes", notes)
# Submit
self.page.click('button[type="submit"]')
self.page.wait_for_load_state("networkidle")
def has_mismatch_error(self) -> bool:
"""Check if a selection mismatch (409) error is displayed."""
body_text = self.page.locator("body").text_content() or ""
return any(
indicator in body_text.lower()
for indicator in ["mismatch", "conflict", "changed", "removed", "added"]
)
def confirm_mismatch(self):
"""Click confirm button to proceed despite mismatch."""
confirm_btn = self.page.locator('button:has-text("Confirm")')
if confirm_btn.count() > 0:
confirm_btn.click()
self.page.wait_for_load_state("networkidle")

134
tests/e2e/pages/move.py Normal file
View File

@@ -0,0 +1,134 @@
# ABOUTME: Page object for animal move page with selection handling.
# ABOUTME: Encapsulates navigation, filter, selection, and optimistic lock handling.
from playwright.sync_api import Page, expect
class MovePage:
"""Page object for animal move page."""
def __init__(self, page: Page, base_url: str):
self.page = page
self.base_url = base_url
def goto_move_page(self, filter_str: str = ""):
"""Navigate to the move animals page.
Args:
filter_str: Optional filter DSL query to pre-populate
"""
url = f"{self.base_url}/move"
if filter_str:
url += f"?filter={filter_str}"
self.page.goto(url)
expect(self.page.locator("body")).to_be_visible()
def set_filter(self, filter_str: str):
"""Set the filter field and wait for selection preview.
Args:
filter_str: Filter DSL query (e.g., 'location:"Strip 1"')
"""
self.page.fill("#filter", filter_str)
# Trigger change event and wait for HTMX preview
self.page.keyboard.press("Tab")
# Wait for selection container to update
self.page.wait_for_selector("#selection-container", state="visible")
self.page.wait_for_load_state("networkidle")
def get_selection_count(self) -> int:
"""Get the count of selected animals from the preview.
Returns number of animals in selection, or 0 if not found.
"""
container = self.page.locator("#selection-container")
if container.count() == 0:
return 0
# Try to find count text (e.g., "5 animals selected")
text = container.text_content() or ""
import re
match = re.search(r"(\d+)\s*animal", text.lower())
if match:
return int(match.group(1))
# Count checkboxes if present
checkboxes = container.locator('input[type="checkbox"]')
return checkboxes.count()
def move_to_location(self, destination_name: str, notes: str = ""):
"""Select destination and submit move.
Args:
destination_name: Human-readable location name
notes: Optional notes
"""
self.page.select_option("#to_location_id", label=destination_name)
if notes:
self.page.fill("#notes", notes)
self.page.click('button[type="submit"]')
self.page.wait_for_load_state("networkidle")
def move_animals(
self,
*,
filter_str: str,
destination_name: str,
notes: str = "",
):
"""Complete move flow: set filter, select destination, submit.
Args:
filter_str: Filter DSL query
destination_name: Human-readable destination location
notes: Optional notes
"""
self.goto_move_page()
self.set_filter(filter_str)
self.move_to_location(destination_name, notes)
def has_mismatch_error(self) -> bool:
"""Check if a selection mismatch (409) error is displayed."""
# Look for mismatch/conflict panel indicators
body_text = self.page.locator("body").text_content() or ""
return any(
indicator in body_text.lower()
for indicator in ["mismatch", "conflict", "changed", "removed", "added"]
)
def get_mismatch_diff(self) -> dict:
"""Get the diff information from a mismatch panel.
Returns dict with removed/added counts if mismatch found.
"""
# This depends on actual UI structure of mismatch panel
return {}
def confirm_mismatch(self):
"""Click confirm button to proceed despite mismatch."""
# Look for confirm button - text varies
confirm_btn = self.page.locator('button:has-text("Confirm")')
if confirm_btn.count() > 0:
confirm_btn.click()
self.page.wait_for_load_state("networkidle")
return
# Try alternative selectors
confirm_btn = self.page.locator('button:has-text("Proceed")')
if confirm_btn.count() > 0:
confirm_btn.click()
self.page.wait_for_load_state("networkidle")
def select_specific_animals(self, animal_ids: list[str]):
"""Select specific animals from checkbox list.
Args:
animal_ids: List of animal IDs to select
"""
for animal_id in animal_ids:
checkbox = self.page.locator(f'input[type="checkbox"][value="{animal_id}"]')
if checkbox.count() > 0:
checkbox.check()