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>
177 lines
5.6 KiB
Python
177 lines
5.6 KiB
Python
# 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")
|