Spaces:
Running
Running
| """Accessibility (a11y) tests β WCAG 2.1 AA static HTML analysis. | |
| Every HTML-generating component function in ``shopstack.ui`` is parsed with | |
| BeautifulSoup and validated for correct ARIA attributes, roles, labels, and | |
| semantic structure. No browser/DOM required β unit-testing the static HTML. | |
| Coverage: | |
| - shopstack.ui.components.primitives (item_row, stat_card, data_table, ...) | |
| - shopstack.ui.components.cards (card, render_decision_card, ...) | |
| - shopstack.ui.renderers.image_cards (SVG cards) | |
| - shopstack.ui.renderers.decision_cards (market basket, decision panel, ...) | |
| """ | |
| from __future__ import annotations | |
| from bs4 import BeautifulSoup | |
| from shopstack.ui.components.primitives import ( | |
| confirm_dialog, | |
| data_table, | |
| empty_state_enhanced, | |
| item_row, | |
| loading_skeleton, | |
| stat_card, | |
| toast, | |
| ) | |
| from shopstack.ui.components.cards import ( | |
| badge_html, | |
| card, | |
| empty_state, | |
| render_action_grid, | |
| render_action_tile, | |
| render_grouped_cards, | |
| render_decision_card as render_decision_card_html, | |
| render_hero_panel, | |
| render_metric, | |
| render_rows, | |
| render_workflow_rail, | |
| ) | |
| from shopstack.ui.renderers import ( | |
| decision_cards as dc, | |
| image_cards as ic, | |
| shopping_results as sr, | |
| ) | |
| from shopstack.services.results import ( | |
| CompletionItem, | |
| MarkPurchasedResult, | |
| PurchaseResultItem, | |
| ShoppingCompletionResult, | |
| ) | |
| from shopstack.schemas.models import DecisionSet, DecisionResult | |
| from shopstack.ui.screens._utils import ( | |
| render_home_advice, | |
| render_list_summary, | |
| render_low_stock, | |
| render_recent_purchases, | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Helpers | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _parse(html: str) -> BeautifulSoup: | |
| """Parse HTML string with lxml and return a BeautifulSoup tree.""" | |
| return BeautifulSoup(html, "lxml") | |
| def _assert_role(soup: BeautifulSoup, role: str, msg: str = "") -> None: | |
| """Assert that at least one element has the given role.""" | |
| assert soup.find(attrs={"role": role}) is not None, ( | |
| msg or f"Expected role='{role}' not found in HTML" | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # shopstack.ui.components.primitives | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestItemRow: | |
| """WCAG: role='group', aria-label includes name+quantity.""" | |
| def test_role_group_present(self): | |
| html = item_row("Milk", 2.0, "L") | |
| soup = _parse(html) | |
| _assert_role(soup, "group", "item_row missing role='group'") | |
| def test_aria_label_contains_name_and_quantity(self): | |
| html = item_row("Milk", 2.0, "L") | |
| soup = _parse(html) | |
| el = soup.find(attrs={"role": "group"}) | |
| assert el is not None | |
| label = el.get("aria-label", "") | |
| assert "Milk" in label | |
| assert "2.0" in label or "2 L" in label | |
| def test_aria_label_with_decision(self): | |
| html = item_row("Eggs", 12, "pieces", decision="buy") | |
| soup = _parse(html) | |
| el = soup.find(attrs={"role": "group"}) | |
| assert el is not None | |
| assert "Eggs" in el.get("aria-label", "") | |
| def test_escaped_content_does_not_break_html(self): | |
| """html.escape is applied; HTML injection should be escaped.""" | |
| html = item_row('<script>alert("xss")</script>', 1, "unit") | |
| assert "<script>" in html | |
| assert "<script>" not in html | |
| class TestStatCard: | |
| """WCAG: role='region', aria-label includes value+label.""" | |
| def test_role_region_present(self): | |
| html = stat_card("12", "Items") | |
| soup = _parse(html) | |
| _assert_role(soup, "region", "stat_card missing role='region'") | |
| def test_aria_label_contains_value_and_label(self): | |
| html = stat_card("\u20b9340", "Spent", icon="\U0001f4b0") | |
| soup = _parse(html) | |
| el = soup.find(attrs={"role": "region"}) | |
| assert el is not None | |
| label = el.get("aria-label", "") | |
| assert "340" in label or "Spent" in label | |
| def test_icon_rendered_when_provided(self): | |
| html = stat_card("3", "Todos", icon="\U0001f4cb") | |
| assert "\U0001f4cb" in html | |
| class TestDataTable: | |
| """WCAG: role='region', aria-label, <caption class='sr-only'>.""" | |
| def test_role_region_present(self): | |
| html = data_table([{"name": "Milk", "qty": "2"}]) | |
| soup = _parse(html) | |
| _assert_role(soup, "region", "data_table missing role='region'") | |
| def test_aria_label_contains_table_description(self): | |
| html = data_table([{"name": "Milk", "qty": "2"}]) | |
| soup = _parse(html) | |
| el = soup.find(attrs={"role": "region"}) | |
| assert el is not None | |
| label = el.get("aria-label", "") | |
| # When empty_message is default "No data", aria-label becomes "Table: data table" | |
| assert "Table:" in label or "table" in label.lower() | |
| def test_custom_aria_label_uses_empty_message(self): | |
| html = data_table([{"name": "Milk", "qty": "2"}], empty_message="Inventory items") | |
| soup = _parse(html) | |
| el = soup.find(attrs={"role": "region"}) | |
| assert el is not None | |
| label = el.get("aria-label", "") | |
| assert "Inventory" in label | |
| def test_caption_sr_only_present(self): | |
| html = data_table([{"name": "Milk", "qty": "2"}]) | |
| soup = _parse(html) | |
| caption = soup.find("caption") | |
| assert caption is not None, "data_table missing <caption>" | |
| assert "sr-only" in caption.get("class", []), ( | |
| "caption missing sr-only class" | |
| ) | |
| def test_empty_state_returns_home_card(self): | |
| """Empty states return a home-card div (no role='region' β just a | |
| plain <div class='home-card'>).""" | |
| html = data_table([], empty_message="No inventory") | |
| assert "home-card" in html | |
| assert "No inventory" in html | |
| class TestConfirmDialog: | |
| """WCAG: role='alertdialog', aria-label, icon aria-hidden='true'.""" | |
| def test_role_alertdialog_present(self): | |
| html = confirm_dialog("Delete this item?") | |
| soup = _parse(html) | |
| _assert_role(soup, "alertdialog", | |
| "confirm_dialog missing role='alertdialog'") | |
| def test_aria_label_contains_message(self): | |
| html = confirm_dialog("Delete this item?") | |
| soup = _parse(html) | |
| el = soup.find(attrs={"role": "alertdialog"}) | |
| assert el is not None | |
| assert "Delete" in el.get("aria-label", "") | |
| def test_warning_icon_aria_hidden(self): | |
| html = confirm_dialog("Are you sure?") | |
| soup = _parse(html) | |
| icon = soup.find(attrs={"aria-hidden": "true"}) | |
| assert icon is not None, \ | |
| "confirm_dialog icon missing aria-hidden='true'" | |
| def test_danger_variant(self): | |
| html = confirm_dialog("Delete permanently?", variant="danger") | |
| assert "alertdialog" in html | |
| class TestToast: | |
| """WCAG: role='status', aria-live='polite', aria-label, icon aria-hidden.""" | |
| def test_role_status_present(self): | |
| html = toast("Item added", kind="success") | |
| soup = _parse(html) | |
| _assert_role(soup, "status", "toast missing role='status'") | |
| def test_aria_live_polite(self): | |
| html = toast("Item added") | |
| # The HTML uses aria-live='polite' (single quotes inside f-string) | |
| assert "aria-live=" in html and "polite" in html, \ | |
| "toast missing aria-live='polite'" | |
| def test_aria_label_contains_message(self): | |
| html = toast("Item added", kind="success") | |
| soup = _parse(html) | |
| el = soup.find(attrs={"role": "status"}) | |
| assert el is not None | |
| assert "Item added" in el.get("aria-label", "") | |
| def test_icon_aria_hidden(self): | |
| html = toast("Error occurred", kind="error") | |
| soup = _parse(html) | |
| icon = soup.find(attrs={"aria-hidden": "true"}) | |
| assert icon is not None, "toast icon missing aria-hidden='true'" | |
| def test_error_kind_renders(self): | |
| html = toast("Something failed", kind="error") | |
| assert "role=" in html | |
| def test_info_kind_renders(self): | |
| html = toast("FYI", kind="info") | |
| assert "role=" in html | |
| def test_warning_kind_renders(self): | |
| html = toast("Be careful", kind="warning") | |
| assert "role=" in html | |
| class TestEmptyStateEnhanced: | |
| """WCAG: role='status', aria-label, icon aria-hidden.""" | |
| def test_role_status_present(self): | |
| html = empty_state_enhanced("No items found") | |
| soup = _parse(html) | |
| _assert_role(soup, "status", | |
| "empty_state_enhanced missing role='status'") | |
| def test_aria_label_contains_message(self): | |
| html = empty_state_enhanced("No items found") | |
| soup = _parse(html) | |
| el = soup.find(attrs={"role": "status"}) | |
| assert el is not None | |
| assert "No items found" in el.get("aria-label", "") | |
| def test_icon_aria_hidden(self): | |
| html = empty_state_enhanced("Empty!", icon="\U0001f4e6") | |
| soup = _parse(html) | |
| icon = soup.find(attrs={"aria-hidden": "true"}) | |
| assert icon is not None, \ | |
| "empty_state icon missing aria-hidden='true'" | |
| def test_with_cta_button(self): | |
| html = empty_state_enhanced( | |
| "No items", | |
| action_label="Add Item", | |
| on_click_tab="inventory", | |
| ) | |
| assert "Add Item" in html | |
| assert "button" in html | |
| class TestLoadingSkeleton: | |
| """No specific WCAG requirements β decorative placeholder.""" | |
| def test_card_variant_renders(self): | |
| html = loading_skeleton(variant="card") | |
| assert html.strip() | |
| def test_table_variant_renders(self): | |
| html = loading_skeleton(variant="table") | |
| assert html.strip() | |
| def test_metric_variant_renders(self): | |
| html = loading_skeleton(variant="metric") | |
| assert html.strip() | |
| def test_text_variant_renders(self): | |
| html = loading_skeleton(variant="text") | |
| assert html.strip() | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # shopstack.ui.components.cards | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestCard: | |
| """WCAG: role='region', aria-labelledby pointing to a valid id.""" | |
| def test_role_region_present(self): | |
| html = card("My Title", "body content") | |
| soup = _parse(html) | |
| _assert_role(soup, "region", "card missing role='region'") | |
| def test_aria_labelledby_points_to_valid_id(self): | |
| html = card("My Title", "body content") | |
| soup = _parse(html) | |
| el = soup.find(attrs={"role": "region"}) | |
| assert el is not None | |
| labelledby = el.get("aria-labelledby", "") | |
| assert labelledby, "card missing aria-labelledby" | |
| heading = soup.find(id=labelledby) | |
| assert heading is not None, ( | |
| f"card aria-labelledby='{labelledby}' has no matching element" | |
| ) | |
| def test_heading_text_matches_labelledby_id(self): | |
| html = card("Shopping List", "content") | |
| soup = _parse(html) | |
| el = soup.find(attrs={"role": "region"}) | |
| assert el is not None | |
| labelledby = el.get("aria-labelledby", "") | |
| heading = soup.find(id=labelledby) | |
| assert heading is not None | |
| assert "Shopping List" in heading.get_text() | |
| class TestRenderDecisionCardHTML: | |
| """WCAG: role='article', aria-label with name+decision.""" | |
| def test_role_article_present(self): | |
| html = render_decision_card_html("Milk", "buy", "Running low", 0.85) | |
| soup = _parse(html) | |
| _assert_role(soup, "article", | |
| "decision_card missing role='article'") | |
| def test_aria_label_contains_name_and_decision(self): | |
| html = render_decision_card_html("Milk", "buy", "Running low", 0.85) | |
| soup = _parse(html) | |
| el = soup.find(attrs={"role": "article"}) | |
| assert el is not None | |
| label = el.get("aria-label", "") | |
| assert "Milk" in label | |
| assert "BUY" in label | |
| def test_confidence_displayed(self): | |
| html = render_decision_card_html("Milk", "buy", "Running low", 0.85) | |
| assert "85" in html | |
| class TestRenderActionTile: | |
| """WCAG: aria-label with label+subtitle.""" | |
| def test_aria_label_present(self): | |
| html = render_action_tile("Add Item", "Record a purchase", "inventory") | |
| soup = _parse(html) | |
| btn = soup.find("button") | |
| assert btn is not None | |
| label = btn.get("aria-label", "") | |
| assert "Add Item" in label | |
| def test_aria_label_includes_subtitle(self): | |
| html = render_action_tile("Scan", "Scan a receipt", "receipt") | |
| soup = _parse(html) | |
| btn = soup.find("button") | |
| assert btn is not None | |
| assert "Scan" in btn.get("aria-label", "") | |
| assert "receipt" in btn.get("aria-label", "").lower() | |
| def test_button_element(self): | |
| html = render_action_tile("Go", "Do something", "tab") | |
| assert "<button" in html and "</button>" in html | |
| def test_tone_variant_renders(self): | |
| html = render_action_tile("Primary", "CTA", "tab", tone="primary") | |
| assert "primary" in html | |
| class TestRenderActionGrid: | |
| """render_action_grid wraps tiles in action-grid wrapper.""" | |
| def test_empty_actions_returns_empty_string(self): | |
| assert render_action_grid([]) == "" | |
| def test_single_action_renders(self): | |
| html = render_action_grid([ | |
| {"label": "Scan", "subtitle": "Check a shelf item", "tab_id": "market"}, | |
| ]) | |
| assert "action-grid" in html | |
| assert "Scan" in html | |
| assert "Check a shelf item" in html | |
| assert "<button" in html | |
| def test_multiple_actions_all_rendered(self): | |
| html = render_action_grid([ | |
| {"label": "Scan", "subtitle": "Check a shelf item", "tab_id": "market"}, | |
| {"label": "Add", "subtitle": "Record a purchase", "tab_id": "purchase"}, | |
| {"label": "View", "subtitle": "Browse inventory", "tab_id": "inventory"}, | |
| ]) | |
| assert html.count("<button") == 3 | |
| assert "Scan" in html | |
| assert "Add" in html | |
| assert "View" in html | |
| def test_tone_propagated_to_tiles(self): | |
| html = render_action_grid([ | |
| {"label": "Primary", "subtitle": "CTA", "tab_id": "tab", "tone": "primary"}, | |
| {"label": "Default", "subtitle": "No tone", "tab_id": "tab"}, | |
| ]) | |
| assert "action-tile-primary" in html | |
| # Default tone should also render | |
| assert "action-tile-default" in html | |
| def test_wrapper_class_present(self): | |
| html = render_action_grid([ | |
| {"label": "X", "subtitle": "Y", "tab_id": "z"}, | |
| ]) | |
| soup = BeautifulSoup(html, "lxml") | |
| wrapper = soup.find("div", class_="action-grid") | |
| assert wrapper is not None, "action-grid wrapper div missing" | |
| def test_labels_and_subtitles_escaped(self): | |
| html = render_action_grid([ | |
| {"label": "<script>", "subtitle": "<alert>", "tab_id": "tab"}, | |
| ]) | |
| assert "<script>" in html | |
| assert "<alert>" in html | |
| assert "<script>" not in html | |
| class TestRenderHeroPanel: | |
| """Semantic structure: h2 heading, section-kicker.""" | |
| def test_heading_present(self): | |
| html = render_hero_panel("Welcome", "Your dashboard") | |
| soup = _parse(html) | |
| heading = soup.find("h2") | |
| assert heading is not None | |
| assert "Welcome" in heading.get_text() | |
| class TestRenderMetric: | |
| """Semantic structure: metric-label, metric-value.""" | |
| def test_label_and_value_rendered(self): | |
| html = render_metric("Items", "12") | |
| assert "Items" in html | |
| assert "12" in html | |
| def test_hint_renders_when_provided(self): | |
| html = render_metric("Items", "12", hint="3 expiring soon") | |
| assert "3 expiring soon" in html | |
| class TestRenderGroupedCards: | |
| """render_grouped_cards wraps items in decision cards inside a card.""" | |
| def test_empty_items_returns_empty_string(self): | |
| assert render_grouped_cards("Title", []) == "" | |
| def test_single_item_renders(self): | |
| html = render_grouped_cards("Category", [ | |
| {"canonical_name": "milk", "decision": "buy", "reason": "Running low", "confidence": 0.9}, | |
| ]) | |
| assert "Category" in html | |
| assert "MILK" in html or "milk" in html | |
| assert "Running low" in html | |
| def test_multiple_items_all_rendered(self): | |
| html = render_grouped_cards("Produce", [ | |
| {"canonical_name": "tomato", "decision": "buy", "reason": "Need for curry", "confidence": 0.85}, | |
| {"canonical_name": "onion", "decision": "buy", "reason": "Running out", "confidence": 0.80}, | |
| {"canonical_name": "coriander", "decision": "skip", "reason": "Already have", "confidence": 0.75}, | |
| ]) | |
| assert "Produce" in html | |
| assert "tomato" in html or "Tomato" in html | |
| assert "onion" in html or "Onion" in html | |
| assert "coriander" in html or "Coriander" in html | |
| assert "BUY" in html | |
| assert "SKIP" in html | |
| def test_item_with_missing_fields_defaults(self): | |
| """Items missing optional fields should render with defaults.""" | |
| html = render_grouped_cards("Test", [ | |
| {}, | |
| ]) | |
| # Should not crash, should render something | |
| assert html.strip() | |
| def test_html_escaped(self): | |
| html = render_grouped_cards("<hack>", [ | |
| {"canonical_name": "<script>", "decision": "buy", "reason": "<alert>", "confidence": 0.9}, | |
| ]) | |
| assert "<" in html | |
| assert "<script>" not in html | |
| class TestRenderWorkflowRail: | |
| """Semantic structure β steps are uppercased for styling.""" | |
| def test_renders_steps_uppercased(self): | |
| html = render_workflow_rail(["Step 1", "Step 2"], current_step=0) | |
| # Steps are uppercased via escape(str(step).upper()) | |
| assert "STEP 1" in html | |
| assert "STEP 2" in html | |
| class TestRenderRows: | |
| def test_renders_label_value_pairs(self): | |
| html = render_rows([("Name", "Milk"), ("Qty", "2 L")]) | |
| assert "Milk" in html | |
| assert "2 L" in html | |
| def test_empty_returns_fallback(self): | |
| html = render_rows([]) | |
| assert "No entries" in html | |
| class TestEmptyState: | |
| def test_message_rendered(self): | |
| html = empty_state("Nothing here") | |
| assert "Nothing here" in html | |
| class TestBadgeHTML: | |
| def test_all_variants(self): | |
| for variant in ("green", "amber", "red", "blue", "gray", "neutral"): | |
| html = badge_html("Test", variant) | |
| assert "badge" in html | |
| def test_label_escaped(self): | |
| html = badge_html('<script>alert("xss")</script>', "green") | |
| assert "<script>" in html | |
| assert "<script>" not in html | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # shopstack.ui.renderers.image_cards (SVG components) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestSVGItemCard: | |
| """WCAG: SVG role='img' + aria-label.""" | |
| def test_svg_role_img_present(self): | |
| html = ic.render_item_card("Milk", 2.0, "L") | |
| soup = _parse(html) | |
| svg = soup.find("svg") | |
| assert svg is not None | |
| assert svg.get("role") == "img", \ | |
| "Item card SVG missing role='img'" | |
| def test_svg_aria_label_contains_item_name(self): | |
| html = ic.render_item_card("Milk", 2.0, "L") | |
| soup = _parse(html) | |
| svg = soup.find("svg") | |
| assert svg is not None | |
| assert "Milk" in svg.get("aria-label", "") | |
| class TestSVGDecisionCard: | |
| """WCAG: SVG role='img' + aria-label.""" | |
| def test_svg_role_img_present(self): | |
| html = ic.render_decision_card("Milk", "buy", "Running low", 0.85) | |
| soup = _parse(html) | |
| svg = soup.find("svg") | |
| assert svg is not None | |
| assert svg.get("role") == "img", \ | |
| "Decision SVG missing role='img'" | |
| def test_svg_aria_label_contains_item_and_decision(self): | |
| html = ic.render_decision_card("Milk", "buy", "Running low", 0.85) | |
| soup = _parse(html) | |
| svg = soup.find("svg") | |
| assert svg is not None | |
| label = svg.get("aria-label", "") | |
| assert "Milk" in label | |
| assert "buy" in label.lower() | |
| def test_wcag_colors_synced(self): | |
| """SVG decision colors should match WCAG-compliant CSS values.""" | |
| html = ic.render_decision_card("Milk", "buy", "test", 0.9) | |
| assert "#1A9E4A" in html, \ | |
| "WCAG buy color (#1A9E4A) not found in decision SVG" | |
| class TestSVGUseSoonCard: | |
| """WCAG: SVG role='img' + aria-label.""" | |
| def test_svg_role_img_present(self): | |
| html = ic.render_use_soon_card( | |
| [{"display_name": "Milk", "reason": "Expires soon"}] | |
| ) | |
| soup = _parse(html) | |
| svg = soup.find("svg") | |
| assert svg is not None | |
| assert svg.get("role") == "img", \ | |
| "Use-soon SVG missing role='img'" | |
| def test_svg_aria_label_contains_item_count(self): | |
| html = ic.render_use_soon_card( | |
| [{"display_name": "Milk", "reason": "Expires soon"}] | |
| ) | |
| soup = _parse(html) | |
| svg = soup.find("svg") | |
| assert svg is not None | |
| assert "item" in svg.get("aria-label", "").lower() | |
| class TestSVGShoppingSummaryCard: | |
| """WCAG: SVG role='img' + aria-label.""" | |
| def test_svg_role_img_present(self): | |
| html = ic.render_shopping_summary_card(3, 2, 150.0) | |
| soup = _parse(html) | |
| svg = soup.find("svg") | |
| assert svg is not None | |
| assert svg.get("role") == "img", \ | |
| "Shopping summary SVG missing role='img'" | |
| def test_svg_aria_label_contains_counts(self): | |
| html = ic.render_shopping_summary_card(3, 2, 150.0) | |
| soup = _parse(html) | |
| svg = soup.find("svg") | |
| assert svg is not None | |
| label = svg.get("aria-label", "") | |
| assert "3" in label or "150" in label | |
| class TestSVGPriceComparisonCard: | |
| """WCAG: SVG role='img' + aria-label.""" | |
| def test_svg_role_img_present(self): | |
| html = ic.render_price_comparison_card( | |
| "Milk", {"Store A": 100, "Store B": 90} | |
| ) | |
| soup = _parse(html) | |
| svg = soup.find("svg") | |
| assert svg is not None | |
| assert svg.get("role") == "img", \ | |
| "Price comparison SVG missing role='img'" | |
| def test_svg_aria_label_contains_item_name(self): | |
| html = ic.render_price_comparison_card("Milk", {"Store A": 100}) | |
| soup = _parse(html) | |
| svg = soup.find("svg") | |
| assert svg is not None | |
| assert "Milk" in svg.get("aria-label", "") | |
| class TestSVGCardsToGrid: | |
| """WCAG: SVG role='img' + aria-label with card count.""" | |
| def test_svg_role_img_present(self): | |
| cards = [ | |
| ic.render_item_card("Milk", 2.0, "L"), | |
| ic.render_item_card("Eggs", 12, "pieces"), | |
| ] | |
| html = ic.cards_to_grid(cards, columns=2) | |
| soup = _parse(html) | |
| svg = soup.find("svg") | |
| assert svg is not None | |
| assert svg.get("role") == "img", "Grid SVG missing role='img'" | |
| def test_svg_aria_label_contains_card_count(self): | |
| cards = [ic.render_item_card("Milk", 2.0, "L")] | |
| html = ic.cards_to_grid(cards, columns=1) | |
| soup = _parse(html) | |
| svg = soup.find("svg") | |
| assert svg is not None | |
| assert "1" in svg.get("aria-label", "") or "card" in svg.get("aria-label", "") | |
| def test_empty_cards_returns_empty(self): | |
| assert ic.cards_to_grid([]) == "" | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # shopstack.ui.renderers.decision_cards | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _sample_ds() -> DecisionSet: | |
| return DecisionSet( | |
| decisions=[ | |
| DecisionResult( | |
| canonical_name="milk", | |
| display_name="Milk", | |
| action="buy", | |
| reason="Running low", | |
| confidence=0.9, | |
| market_price=50.0, | |
| market_price_per_kg=200.0, | |
| market_source="swiggy", | |
| waste_risk="low", | |
| shelf_life_days=5, | |
| data_freshness="fresh", | |
| data_freshness_label="Snapshot from 10 Jun 2026", | |
| ), | |
| DecisionResult( | |
| canonical_name="eggs", | |
| display_name="Eggs", | |
| action="skip", | |
| reason="Already have", | |
| confidence=0.8, | |
| market_price=0, | |
| market_price_per_kg=0, | |
| market_source="", | |
| waste_risk="unknown", | |
| shelf_life_days=0, | |
| data_freshness="unknown", | |
| data_freshness_label="", | |
| ), | |
| ] | |
| ) | |
| class TestDecisionCardsMarketBasket: | |
| """Semantic: h3 heading, estimated total, decision badges.""" | |
| def test_heading_present(self): | |
| html = dc.render_market_basket(_sample_ds()) | |
| assert "Market Basket" in html | |
| def test_buy_items_rendered(self): | |
| html = dc.render_market_basket(_sample_ds()) | |
| assert "Milk" in html | |
| def test_estimated_total_present(self): | |
| html = dc.render_market_basket(_sample_ds()) | |
| assert "Estimated total" in html | |
| class TestDecisionCardsDecisionPanel: | |
| """Semantic: h3 heading, decision labels.""" | |
| def test_heading_present(self): | |
| html = dc.render_decision_panel(_sample_ds()) | |
| assert "Today's Decisions" in html | |
| def test_buy_items_appear(self): | |
| html = dc.render_decision_panel(_sample_ds()) | |
| assert "Milk" in html | |
| def test_skip_items_appear(self): | |
| html = dc.render_decision_panel(_sample_ds()) | |
| assert "Eggs" in html | |
| class TestDecisionCardsInventoryOverview: | |
| def test_total_items_displayed(self): | |
| html = dc.render_inventory_overview([]) | |
| assert "inventory" in html.lower() | |
| def test_empty_list_shows_zero(self): | |
| html = dc.render_inventory_overview([]) | |
| assert any(t in html for t in ("0", "None", "No", "empty")) | |
| class TestDecisionCardsWhatChanged: | |
| def test_returns_empty_string_for_no_events(self): | |
| assert dc.render_what_changed([], []) == "" | |
| def test_heading_present_with_data(self): | |
| from datetime import datetime | |
| class MockPurchase: | |
| canonical_name = "milk" | |
| timestamp = datetime(2026, 6, 10) | |
| html = dc.render_what_changed([MockPurchase()], []) | |
| assert "What Changed" in html or "milk" in html or "Milk" in html | |
| class TestDecisionCardsWasteWarnings: | |
| def test_returns_empty_string_for_no_signals(self): | |
| assert dc.render_waste_warnings([]) == "" | |
| def test_warning_rendered_with_data(self): | |
| signals = [ | |
| {"display_name": "Milk", "reason": "Expiring in 1 day", "confidence": "high"} | |
| ] | |
| html = dc.render_waste_warnings(signals) | |
| assert "Milk" in html | |
| class TestDecisionCardsSwiggySoldout: | |
| def test_returns_empty_string_when_all_available(self): | |
| html = dc.render_swiggy_soldout_warning({"milk": {"available": True}}) | |
| assert html == "" | |
| def test_warning_rendered_for_sold_out(self): | |
| html = dc.render_swiggy_soldout_warning( | |
| {"milk": {"available": False}, "eggs": {"available": True}} | |
| ) | |
| assert "milk" in html or "Milk" in html | |
| class TestDecisionCardsComparePanel: | |
| """render_compare_panel: h3 heading, compare/watch items, empty state.""" | |
| def test_empty_returns_no_signals_message(self): | |
| ds = _sample_ds() # only has buy and skip | |
| html = dc.render_compare_panel(ds) | |
| assert "Compare" in html or "Market Signals" in html | |
| assert "No comparison signals" in html | |
| def test_heading_present_with_data(self): | |
| ds = DecisionSet(decisions=[ | |
| DecisionResult( | |
| canonical_name="tomato", | |
| display_name="Tomato", | |
| action="compare", | |
| reason="Multiple pack sizes available, 80% price spread", | |
| confidence=0.75, | |
| market_price_per_kg=56.0, | |
| waste_risk="unknown", | |
| shelf_life_days=0, | |
| ), | |
| ]) | |
| html = dc.render_compare_panel(ds) | |
| assert "Compare / Market Signals" in html | |
| assert "Compare" in html # the decision badge label | |
| def test_compare_item_renders_with_reason(self): | |
| ds = DecisionSet(decisions=[ | |
| DecisionResult( | |
| canonical_name="tomato", | |
| display_name="Tomato", | |
| action="compare", | |
| reason="Price spread 80%", | |
| confidence=0.75, | |
| market_price_per_kg=56.0, | |
| waste_risk="unknown", | |
| shelf_life_days=0, | |
| ), | |
| ]) | |
| html = dc.render_compare_panel(ds) | |
| soup = BeautifulSoup(html, "lxml") | |
| heading = soup.find("h3") | |
| assert heading is not None | |
| assert "Compare" in heading.get_text() | |
| def test_wait_item_renders_with_wait_badge(self): | |
| ds = DecisionSet(decisions=[ | |
| DecisionResult( | |
| canonical_name="sold_out_item", | |
| display_name="Premium Broccoli", | |
| action="wait", | |
| reason="Sold out, waiting for restock", | |
| confidence=0.6, | |
| waste_risk="unknown", | |
| shelf_life_days=0, | |
| ), | |
| ]) | |
| html = dc.render_compare_panel(ds) | |
| assert "WAIT" in html | |
| assert "Premium Broccoli" in html | |
| def test_compare_and_wait_both_displayed(self): | |
| ds = DecisionSet(decisions=[ | |
| DecisionResult( | |
| canonical_name="tomato", display_name="Tomato", | |
| action="compare", reason="Price spread 80%", | |
| confidence=0.75, market_price_per_kg=56.0, | |
| waste_risk="unknown", shelf_life_days=0, | |
| ), | |
| DecisionResult( | |
| canonical_name="broccoli", display_name="Broccoli", | |
| action="wait", reason="Sold out", | |
| confidence=0.6, | |
| waste_risk="unknown", shelf_life_days=0, | |
| ), | |
| ]) | |
| html = dc.render_compare_panel(ds) | |
| assert "Compare" in html | |
| assert "WAIT" in html | |
| assert "Tomato" in html | |
| assert "Broccoli" in html | |
| def test_max_items_respected(self): | |
| """Only first 4 compare and first 4 wait items shown.""" | |
| decisions = [ | |
| DecisionResult( | |
| canonical_name=f"item_{i}", display_name=f"Item {i}", | |
| action="compare", reason=f"Reason {i}", | |
| confidence=0.7, market_price_per_kg=50.0, | |
| waste_risk="unknown", shelf_life_days=0, | |
| ) | |
| for i in range(6) | |
| ] | |
| ds = DecisionSet(decisions=decisions) | |
| html = dc.render_compare_panel(ds) | |
| # Should show at most 4 compare + 0 wait = 4 items total | |
| for i in range(4): | |
| assert f"Item {i}" in html | |
| # Items beyond the [:4] limit must not appear | |
| assert "Item 5" not in html | |
| class TestDecisionCardsMyListPanel: | |
| """render_my_list_panel: h3 heading, decision badges, empty state.""" | |
| def test_empty_list_returns_empty_state_html(self): | |
| html = dc.render_my_list_panel(_sample_ds(), None) | |
| assert "My Own List" in html | |
| assert "No active shopping list" in html | |
| def test_empty_list_with_empty_items_returns_state(self): | |
| class MockList: | |
| items = [] | |
| html = dc.render_my_list_panel(_sample_ds(), MockList()) | |
| assert "No active shopping list" in html or "My Own List" in html | |
| def test_heading_present_with_items(self): | |
| from shopstack.schemas.models import ShoppingListItem | |
| class MockList: | |
| items = [ | |
| ShoppingListItem(canonical_name="milk"), | |
| ShoppingListItem(canonical_name="eggs"), | |
| ] | |
| ds = DecisionSet(decisions=[ | |
| DecisionResult( | |
| canonical_name="milk", display_name="Milk", | |
| action="buy", reason="Running low", | |
| confidence=0.9, | |
| waste_risk="low", shelf_life_days=5, | |
| ), | |
| DecisionResult( | |
| canonical_name="eggs", display_name="Eggs", | |
| action="skip", reason="Already have", | |
| confidence=0.8, | |
| waste_risk="unknown", shelf_life_days=0, | |
| ), | |
| ]) | |
| html = dc.render_my_list_panel(ds, MockList()) | |
| assert "My Own List" in html | |
| assert "Milk" in html | |
| assert "Eggs" in html | |
| def test_decision_badge_colors_match_action(self): | |
| from shopstack.schemas.models import ShoppingListItem | |
| class MockList: | |
| items = [ | |
| ShoppingListItem(canonical_name="milk"), | |
| ShoppingListItem(canonical_name="eggs"), | |
| ] | |
| ds = DecisionSet(decisions=[ | |
| DecisionResult( | |
| canonical_name="milk", display_name="Milk", | |
| action="buy", reason="Running low", | |
| confidence=0.9, | |
| waste_risk="low", shelf_life_days=5, | |
| ), | |
| DecisionResult( | |
| canonical_name="eggs", display_name="Eggs", | |
| action="skip", reason="Already have", | |
| confidence=0.8, | |
| waste_risk="unknown", shelf_life_days=0, | |
| ), | |
| ]) | |
| html = dc.render_my_list_panel(ds, MockList()) | |
| soup = BeautifulSoup(html, "lxml") | |
| h3 = soup.find("h3") | |
| assert h3 is not None | |
| assert "My Own List" in h3.get_text() | |
| def test_item_without_decision_shows_unknown(self): | |
| from shopstack.schemas.models import ShoppingListItem | |
| class MockList: | |
| items = [ | |
| ShoppingListItem(canonical_name="unknown_item"), | |
| ] | |
| html = dc.render_my_list_panel(_sample_ds(), MockList()) | |
| assert "Unknown" in html | |
| class TestDecisionCardsCadenceInsights: | |
| """render_cadence_insights: h3 heading, upcoming items, due-now labels.""" | |
| def test_empty_cadence_returns_empty_string(self): | |
| assert dc.render_cadence_insights({}) == "" | |
| def test_items_outside_window_returns_empty(self): | |
| """Items due >3 days away should not appear.""" | |
| from datetime import date, timedelta | |
| today = date(2026, 6, 10) | |
| cadence = { | |
| "onion": { | |
| "next_expected": today + timedelta(days=5), | |
| "typical_qty": 1.0, | |
| "typical_unit": "kg", | |
| "avg_interval_days": 7.0, | |
| } | |
| } | |
| html = dc.render_cadence_insights(cadence, today) | |
| assert html == "" | |
| def test_due_now_item_appears(self): | |
| from datetime import date, timedelta | |
| today = date(2026, 6, 10) | |
| cadence = { | |
| "onion": { | |
| "next_expected": today, | |
| "typical_qty": 1.0, | |
| "typical_unit": "kg", | |
| "avg_interval_days": 7.0, | |
| } | |
| } | |
| html = dc.render_cadence_insights(cadence, today) | |
| assert "Purchase Rhythm" in html | |
| assert "Onion" in html | |
| assert "Due now" in html | |
| def test_due_tomorrow_item_appears(self): | |
| from datetime import date, timedelta | |
| today = date(2026, 6, 10) | |
| cadence = { | |
| "onion": { | |
| "next_expected": today + timedelta(days=1), | |
| "typical_qty": 1.0, | |
| "typical_unit": "kg", | |
| "avg_interval_days": 7.0, | |
| } | |
| } | |
| html = dc.render_cadence_insights(cadence, today) | |
| assert "Due tomorrow" in html | |
| def test_due_in_few_days_item_appears(self): | |
| from datetime import date, timedelta | |
| today = date(2026, 6, 10) | |
| cadence = { | |
| "onion": { | |
| "next_expected": today + timedelta(days=2), | |
| "typical_qty": 1.0, | |
| "typical_unit": "kg", | |
| "avg_interval_days": 7.0, | |
| } | |
| } | |
| html = dc.render_cadence_insights(cadence, today) | |
| assert "Due in 2 days" in html | |
| def test_overdue_item_appears(self): | |
| from datetime import date, timedelta | |
| today = date(2026, 6, 10) | |
| cadence = { | |
| "onion": { | |
| "next_expected": today - timedelta(days=1), | |
| "typical_qty": 1.0, | |
| "typical_unit": "kg", | |
| "avg_interval_days": 7.0, | |
| } | |
| } | |
| html = dc.render_cadence_insights(cadence, today) | |
| assert "Due now" in html | |
| def test_heading_present_when_items_show(self): | |
| from datetime import date | |
| today = date(2026, 6, 10) | |
| cadence = { | |
| "tomato": { | |
| "next_expected": date(2026, 6, 11), | |
| "typical_qty": 0.5, | |
| "typical_unit": "kg", | |
| "avg_interval_days": 4.0, | |
| } | |
| } | |
| html = dc.render_cadence_insights(cadence, today) | |
| soup = BeautifulSoup(html, "lxml") | |
| heading = soup.find("h3") | |
| assert heading is not None | |
| assert "Purchase Rhythm" in heading.get_text() | |
| def test_multiple_items_sorted_by_urgency(self): | |
| from datetime import date | |
| today = date(2026, 6, 10) | |
| cadence = { | |
| "tomato": { | |
| "next_expected": date(2026, 6, 18), # 8 days away β outside window | |
| "typical_qty": 0.5, | |
| "typical_unit": "kg", | |
| "avg_interval_days": 4.0, | |
| }, | |
| "onion": { | |
| "next_expected": date(2026, 6, 11), # 1 day away β inside window | |
| "typical_qty": 1.0, | |
| "typical_unit": "kg", | |
| "avg_interval_days": 7.0, | |
| }, | |
| "milk": { | |
| "next_expected": date(2026, 6, 10), # today β inside window | |
| "typical_qty": 2.0, | |
| "typical_unit": "L", | |
| "avg_interval_days": 3.0, | |
| }, | |
| } | |
| html = dc.render_cadence_insights(cadence, today) | |
| # Only onion and milk should appear (tomato is >3 days away) | |
| assert "Onion" in html | |
| assert "Milk" in html | |
| assert "tomato" not in html or "Tomato" not in html | |
| def test_max_items_respected(self): | |
| """Only first 5 items shown.""" | |
| from datetime import date | |
| today = date(2026, 6, 10) | |
| cadence = { | |
| f"item_{i}": { | |
| "next_expected": today, | |
| "typical_qty": 1.0, | |
| "typical_unit": "unit", | |
| "avg_interval_days": 7.0, | |
| } | |
| for i in range(8) | |
| } | |
| html = dc.render_cadence_insights(cadence, today) | |
| # Should show max 5 items (upcoming[:5]) | |
| count = html.count("Due now") | |
| assert count <= 5 # max 5 items from the [:5] limit | |
| class TestDecisionCardsConfirmation: | |
| def test_returns_empty_string_for_no_lots(self): | |
| assert dc.render_needs_confirmation([]) == "" | |
| def test_heading_present_with_items(self): | |
| from datetime import date | |
| class MockLot: | |
| display_name = "Milk" | |
| purchase_date = date(2026, 6, 5) | |
| html = dc.render_needs_confirmation([MockLot()]) | |
| assert "Needs Confirmation" in html | |
| assert "Milk" in html | |
| assert "Last verified" in html | |
| def test_item_without_purchase_date(self): | |
| class MockLot: | |
| display_name = "Mystery Eggs" | |
| purchase_date = None | |
| html = dc.render_needs_confirmation([MockLot()]) | |
| assert "Mystery Eggs" in html | |
| assert "No purchase date recorded" in html | |
| def test_max_items_respected(self): | |
| from datetime import date | |
| today = date(2026, 6, 10) | |
| class MockLot: | |
| display_name = "" | |
| purchase_date = today | |
| lots = [] | |
| for i in range(8): | |
| lot = MockLot() | |
| lot.display_name = f"Item {i}" | |
| lots.append(lot) | |
| html = dc.render_needs_confirmation(lots) | |
| # Should have max 5 items | |
| assert "Item 0" in html | |
| assert "Item 4" in html | |
| assert "Item 5" not in html | |
| assert "Item 7" not in html | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # shopstack.ui.screens._utils (screen utility renderers) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestRenderHomeAdvice: | |
| """render_home_advice: healthy pantry, buy/skip sections, max items.""" | |
| def test_empty_inventory_shows_healthy_pantry(self): | |
| html = render_home_advice([], [], []) | |
| assert "Today" in html or "healthy" in html or "No immediate action" in html | |
| def test_low_stock_item_appears_in_buy_section(self): | |
| class MockLot: | |
| status = "low" | |
| quantity = 0.3 | |
| display_name = "Milk" | |
| canonical_name = "milk" | |
| html = render_home_advice([MockLot()], [], []) | |
| assert "Buy" in html | |
| assert "Milk" in html | |
| def test_high_quantity_skips_item(self): | |
| class MockLot: | |
| status = "active" | |
| quantity = 5.0 | |
| display_name = "Rice" | |
| canonical_name = "rice" | |
| html = render_home_advice([MockLot()], [], []) | |
| assert "Skip" in html | |
| assert "Rice" in html | |
| def test_use_soon_item_appears_in_buy(self): | |
| class MockLot: | |
| status = "active" | |
| quantity = 0.8 | |
| display_name = "Milk" | |
| canonical_name = "milk" | |
| html = render_home_advice([MockLot()], [], [{"canonical_name": "milk", "display_name": "Milk"}]) | |
| # Milk has quantity > 0.7 but is in use_soon β no skip | |
| assert "Use Milk before buying more" in html or "Use" in html | |
| def test_use_soon_excludes_from_skip(self): | |
| """An item in use_soon should NOT appear in skip even if quantity > 0.7.""" | |
| class MockLot: | |
| status = "active" | |
| quantity = 5.0 | |
| display_name = "Milk" | |
| canonical_name = "milk" | |
| html = render_home_advice([MockLot()], [], [{"canonical_name": "milk"}]) | |
| # Milk is in use_soon β should NOT appear in skip section | |
| assert "Skip" not in html | |
| def test_both_buy_and_skip_sections_rendered(self): | |
| class LowLot: | |
| status = "low" | |
| quantity = 0.2 | |
| display_name = "Milk" | |
| canonical_name = "milk" | |
| class HighLot: | |
| status = "active" | |
| quantity = 8.0 | |
| display_name = "Rice" | |
| canonical_name = "rice" | |
| html = render_home_advice([LowLot(), HighLot()], [], []) | |
| assert "Buy" in html | |
| assert "Skip" in html | |
| assert "Milk" in html | |
| assert "Rice" in html | |
| def test_max_buy_items_respected(self): | |
| """Buy section limited to 3 items + 2 use_soon = up to 5 buy lines.""" | |
| class MockLot: | |
| status = "low" | |
| quantity = 0.1 | |
| display_name = "" | |
| canonical_name = "" | |
| lots = [] | |
| for i in range(6): | |
| lot = MockLot() | |
| lot.display_name = f"Item {i}" | |
| lots.append(lot) | |
| html = render_home_advice(lots, [], []) | |
| # buy[:3] limits to 3 items; count "Buy" occurrences in <li> tags | |
| assert html.count("<li>") <= 3 | |
| def test_html_escaped(self): | |
| class MockLot: | |
| status = "low" | |
| quantity = 0.1 | |
| display_name = "<script>" | |
| canonical_name = "<script>" | |
| html = render_home_advice([MockLot()], [], []) | |
| assert "<script>" in html | |
| assert "<script>" not in html | |
| class TestRenderListSummary: | |
| """render_list_summary: None, empty items, items with badges, max 8.""" | |
| def test_none_shows_no_active_list(self): | |
| html = render_list_summary(None) | |
| assert "No active list" in html | |
| def test_empty_items_shows_list_empty(self): | |
| class MockList: | |
| items = [] | |
| html = render_list_summary(MockList()) | |
| assert "empty" in html.lower() | |
| def test_items_rendered_with_badge(self): | |
| class MockItem: | |
| canonical_name = "milk" | |
| status = "active" | |
| class MockList: | |
| items = [MockItem()] | |
| html = render_list_summary(MockList()) | |
| assert "milk" in html or "Milk" in html | |
| assert "badge" in html | |
| def test_heading_shows_item_count(self): | |
| class MockItem: | |
| canonical_name = "milk" | |
| status = "active" | |
| class MockList: | |
| items = [MockItem(), MockItem()] | |
| html = render_list_summary(MockList()) | |
| assert "Shopping List" in html | |
| assert "2 items" in html | |
| def test_max_eight_items_respected(self): | |
| class MockItem: | |
| canonical_name = "" | |
| status = "" | |
| class MockList: | |
| items = [] | |
| items = [] | |
| for i in range(10): | |
| item = MockItem() | |
| item.canonical_name = f"item_{i}" | |
| items.append(item) | |
| ml = MockList() | |
| ml.items = items | |
| html = render_list_summary(ml) | |
| assert html.count("item_") <= 8 | |
| def test_html_escaped(self): | |
| class MockItem: | |
| canonical_name = "<script>alert(1)</script>" | |
| status = "active" | |
| class MockList: | |
| items = [MockItem()] | |
| html = render_list_summary(MockList()) | |
| assert "<script>" in html | |
| assert "<script>alert" not in html | |
| class TestRenderLowStock: | |
| """render_low_stock: empty, items with quantities, max 5.""" | |
| def test_empty_returns_empty_string(self): | |
| assert render_low_stock([]) == "" | |
| def test_items_rendered_with_quantity(self): | |
| class MockLot: | |
| display_name = "Milk" | |
| quantity = 0.5 | |
| unit = "L" | |
| html = render_low_stock([MockLot()]) | |
| assert "Milk" in html | |
| assert "0.5" in html | |
| assert "L" in html | |
| def test_multiple_items_all_rendered(self): | |
| class MockLot: | |
| display_name = "" | |
| quantity = 0.0 | |
| unit = "unit" | |
| lots = [] | |
| for i in range(3): | |
| lot = MockLot() | |
| lot.display_name = f"Item {i}" | |
| lot.quantity = float(i + 1) * 0.5 | |
| lots.append(lot) | |
| html = render_low_stock(lots) | |
| assert "Item 0" in html | |
| assert "Item 1" in html | |
| assert "Item 2" in html | |
| def test_max_five_items_respected(self): | |
| class MockLot: | |
| display_name = "" | |
| quantity = 0.0 | |
| unit = "unit" | |
| lots = [] | |
| for i in range(8): | |
| lot = MockLot() | |
| lot.display_name = f"Item {i}" | |
| lots.append(lot) | |
| html = render_low_stock(lots) | |
| assert "Item 0" in html | |
| assert "Item 4" in html | |
| assert "Item 5" not in html | |
| def test_quantity_color_red(self): | |
| class MockLot: | |
| display_name = "Milk" | |
| quantity = 0.3 | |
| unit = "L" | |
| html = render_low_stock([MockLot()]) | |
| assert "color:var(--red)" in html or "--red" in html | |
| def test_html_escaped(self): | |
| class MockLot: | |
| display_name = "<script>" | |
| quantity = 0.1 | |
| unit = "<unit>" | |
| html = render_low_stock([MockLot()]) | |
| assert "<script>" in html | |
| assert "<unit>" in html | |
| class TestRenderRecentPurchases: | |
| """render_recent_purchases: empty, items with prices, max 5.""" | |
| def test_empty_returns_empty_string(self): | |
| assert render_recent_purchases([]) == "" | |
| def test_items_rendered_with_price(self): | |
| class MockPurchase: | |
| canonical_name = "Milk" | |
| total_price = 50.0 | |
| html = render_recent_purchases([MockPurchase()]) | |
| assert "Milk" in html | |
| assert "50" in html | |
| def test_multiple_items_all_rendered(self): | |
| class MockPurchase: | |
| canonical_name = "" | |
| total_price = 0.0 | |
| purchases = [] | |
| for i in range(3): | |
| p = MockPurchase() | |
| p.canonical_name = f"Item {i}" | |
| p.total_price = float(i + 1) * 100 | |
| purchases.append(p) | |
| html = render_recent_purchases(purchases) | |
| assert "Item 0" in html | |
| assert "Item 1" in html | |
| assert "Item 2" in html | |
| def test_max_five_items_respected(self): | |
| class MockPurchase: | |
| canonical_name = "" | |
| total_price = 0.0 | |
| purchases = [] | |
| for i in range(8): | |
| p = MockPurchase() | |
| p.canonical_name = f"Item {i}" | |
| purchases.append(p) | |
| html = render_recent_purchases(purchases) | |
| assert "Item 0" in html | |
| assert "Item 4" in html | |
| assert "Item 5" not in html | |
| def test_price_formatted_rupee_symbol(self): | |
| class MockPurchase: | |
| canonical_name = "Milk" | |
| total_price = 50.0 | |
| html = render_recent_purchases([MockPurchase()]) | |
| # Unicode rupee sign \u20b9 should appear | |
| assert "\u20b9" in html | |
| def test_html_escaped(self): | |
| class MockPurchase: | |
| canonical_name = "<script>hack</script>" | |
| total_price = 0.0 | |
| html = render_recent_purchases([MockPurchase()]) | |
| assert "<script>" in html | |
| assert "<script>" not in html | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # shopstack.ui.renderers.shopping_results | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestShoppingResults: | |
| def test_completion_success_shows_list_completed(self): | |
| result = ShoppingCompletionResult( | |
| success=True, | |
| list_id="list-1", | |
| items_added=[CompletionItem("milk", "lot-1", 2.0, "L")], | |
| message="Done", | |
| ) | |
| html = sr.render_shopping_completion(result) | |
| assert "List completed" in html | |
| assert "milk" in html | |
| def test_completion_failure_shows_error(self): | |
| result = ShoppingCompletionResult( | |
| success=False, list_id="", message="Something failed" | |
| ) | |
| html = sr.render_shopping_completion(result) | |
| assert "Something failed" in html | |
| def test_completion_zero_items_shows_message(self): | |
| result = ShoppingCompletionResult( | |
| success=True, list_id="list-1", message="All done, nothing to add" | |
| ) | |
| html = sr.render_shopping_completion(result) | |
| assert "nothing to add" in html | |
| def test_mark_purchased_success(self): | |
| result = MarkPurchasedResult( | |
| success=True, | |
| items_added=[PurchaseResultItem("milk", "lot-1", 2.0, "L")], | |
| message="Marked", | |
| ) | |
| html = sr.render_mark_purchased(result) | |
| assert "Marked" in html | |
| def test_mark_purchased_failure(self): | |
| result = MarkPurchasedResult(success=False, message="Error occurred") | |
| html = sr.render_mark_purchased(result) | |
| assert "Error occurred" in html | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Cross-cutting: all components produce valid-ish HTML | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestCrossCutting: | |
| """Every HTML-generating function should parse without errors.""" | |
| def test_all_primitives_produce_valid_html(self): | |
| for html in [ | |
| item_row("Test", 1, "kg"), | |
| stat_card("10", "Items"), | |
| data_table([{"a": "1"}]), | |
| data_table([], empty_message="Empty"), | |
| confirm_dialog("Sure?"), | |
| toast("OK"), | |
| empty_state_enhanced("Nothing"), | |
| loading_skeleton(), | |
| ]: | |
| soup = BeautifulSoup(html, "lxml") | |
| assert len(soup.contents) > 0 | |
| def test_all_card_functions_produce_valid_html(self): | |
| for html in [ | |
| card("Title", "body"), | |
| render_decision_card_html("X", "buy", "reason", 0.5), | |
| render_action_tile("Label", "sub", "tab"), | |
| render_hero_panel("Title", "sub"), | |
| render_metric("N", "V"), | |
| render_rows([("A", "1")]), | |
| render_workflow_rail(["A"], current_step=0), | |
| ]: | |
| soup = BeautifulSoup(html, "lxml") | |
| assert len(soup.contents) > 0 | |
| def test_all_svg_functions_produce_valid_html(self): | |
| for html in [ | |
| ic.render_item_card("X", 1, "kg"), | |
| ic.render_decision_card("X", "buy", "reason", 0.5), | |
| ic.render_use_soon_card([{"display_name": "X"}]), | |
| ic.render_shopping_summary_card(1, 0, 0), | |
| ic.render_price_comparison_card("X", {"A": 10}), | |
| ]: | |
| soup = BeautifulSoup(html, "lxml") | |
| assert len(soup.contents) > 0 | |