Test dashboard: full menu system, all-service tests, filtering
- Run tests for all 10 services via per-service pytest subprocesses - Group results by service with section headers - Clickable summary cards filter by outcome (passed/failed/errors/skipped) - Service filter nav using ~nav-link buttons in menu bar - Full menu integration: ~header-row + ~header-child + ~menu-row - Show logo image via cart-mini rendering - Mount full service directories in docker-compose for test access - Add 24 unit test files across 9 services Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
market/tests/__init__.py
Normal file
0
market/tests/__init__.py
Normal file
21
market/tests/conftest.py
Normal file
21
market/tests/conftest.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Market test fixtures — direct module loading to avoid bp __init__ chains."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
|
||||
|
||||
def _load(name: str, path: str):
|
||||
"""Import a .py file directly, bypassing package __init__ chains."""
|
||||
if name in sys.modules:
|
||||
return sys.modules[name]
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
sys.path.insert(0, "/app/market")
|
||||
|
||||
_load("market.scrape.listings", "/app/market/scrape/listings.py")
|
||||
78
market/tests/test_listings.py
Normal file
78
market/tests/test_listings.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Unit tests for listings parsing utilities."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from market.scrape.listings import (
|
||||
parse_total_pages_from_text,
|
||||
_first_from_srcset,
|
||||
_dedupe_preserve_order_by,
|
||||
_filename_key,
|
||||
)
|
||||
|
||||
|
||||
class TestParseTotalPages:
|
||||
def test_standard(self):
|
||||
assert parse_total_pages_from_text("Showing 36 of 120") == 4
|
||||
|
||||
def test_exact_fit(self):
|
||||
assert parse_total_pages_from_text("Showing 36 of 36") == 1
|
||||
|
||||
def test_partial_page(self):
|
||||
assert parse_total_pages_from_text("Showing 36 of 37") == 2
|
||||
|
||||
def test_no_match(self):
|
||||
assert parse_total_pages_from_text("no data") is None
|
||||
|
||||
def test_case_insensitive(self):
|
||||
# shown=12 is in {12,24,36} so per_page=36, ceil(60/36)=2
|
||||
assert parse_total_pages_from_text("showing 12 of 60") == 2
|
||||
|
||||
def test_small_shown(self):
|
||||
# shown=10, not in {12,24,36}, so per_page=10
|
||||
assert parse_total_pages_from_text("Showing 10 of 100") == 10
|
||||
|
||||
|
||||
class TestFirstFromSrcset:
|
||||
def test_single_entry(self):
|
||||
assert _first_from_srcset("img.jpg 1x") == "img.jpg"
|
||||
|
||||
def test_multiple_entries(self):
|
||||
assert _first_from_srcset("a.jpg 1x, b.jpg 2x") == "a.jpg"
|
||||
|
||||
def test_empty(self):
|
||||
assert _first_from_srcset("") is None
|
||||
|
||||
def test_none(self):
|
||||
assert _first_from_srcset(None) is None
|
||||
|
||||
|
||||
class TestDedupePreserveOrder:
|
||||
def test_no_dupes(self):
|
||||
result = _dedupe_preserve_order_by(["a", "b", "c"], key=str)
|
||||
assert result == ["a", "b", "c"]
|
||||
|
||||
def test_removes_dupes(self):
|
||||
result = _dedupe_preserve_order_by(["a", "b", "a"], key=str)
|
||||
assert result == ["a", "b"]
|
||||
|
||||
def test_empty_strings_skipped(self):
|
||||
result = _dedupe_preserve_order_by(["a", "", "b"], key=str)
|
||||
assert result == ["a", "b"]
|
||||
|
||||
def test_preserves_order(self):
|
||||
result = _dedupe_preserve_order_by(["c", "b", "a", "b"], key=str)
|
||||
assert result == ["c", "b", "a"]
|
||||
|
||||
|
||||
class TestFilenameKey:
|
||||
def test_basic(self):
|
||||
assert _filename_key("https://img.com/photos/pic.jpg") == "img.com:pic.jpg"
|
||||
|
||||
def test_trailing_slash(self):
|
||||
k = _filename_key("https://img.com/photos/")
|
||||
assert k == "img.com:photos"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
k = _filename_key("https://IMG.COM/PIC.JPG")
|
||||
assert k == "img.com:pic.jpg"
|
||||
96
market/tests/test_price.py
Normal file
96
market/tests/test_price.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Unit tests for price parsing utilities."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from market.scrape.product.helpers.price import parse_price, parse_case_size
|
||||
|
||||
|
||||
class TestParsePrice:
|
||||
def test_gbp(self):
|
||||
val, cur, raw = parse_price("£30.50")
|
||||
assert val == 30.5
|
||||
assert cur == "GBP"
|
||||
|
||||
def test_eur(self):
|
||||
val, cur, raw = parse_price("€1,234.00")
|
||||
assert val == 1234.0
|
||||
assert cur == "EUR"
|
||||
|
||||
def test_usd(self):
|
||||
val, cur, raw = parse_price("$9.99")
|
||||
assert val == 9.99
|
||||
assert cur == "USD"
|
||||
|
||||
def test_no_symbol(self):
|
||||
val, cur, raw = parse_price("42.50")
|
||||
assert val == 42.5
|
||||
assert cur is None
|
||||
|
||||
def test_no_match(self):
|
||||
val, cur, raw = parse_price("no price here")
|
||||
assert val is None
|
||||
assert cur is None
|
||||
|
||||
def test_empty_string(self):
|
||||
val, cur, raw = parse_price("")
|
||||
assert val is None
|
||||
assert raw == ""
|
||||
|
||||
def test_none_input(self):
|
||||
val, cur, raw = parse_price(None)
|
||||
assert val is None
|
||||
assert raw == ""
|
||||
|
||||
def test_thousands_comma_stripped(self):
|
||||
val, cur, raw = parse_price("£1,000.50")
|
||||
assert val == 1000.5
|
||||
|
||||
def test_whitespace_around_symbol(self):
|
||||
val, cur, raw = parse_price("£ 5.00")
|
||||
assert val == 5.0
|
||||
assert cur == "GBP"
|
||||
|
||||
def test_raw_preserved(self):
|
||||
_, _, raw = parse_price(" £10.00 ")
|
||||
assert raw == "£10.00"
|
||||
|
||||
|
||||
class TestParseCaseSize:
|
||||
def test_standard(self):
|
||||
count, qty, unit, _ = parse_case_size("6 x 500g")
|
||||
assert count == 6
|
||||
assert qty == 500.0
|
||||
assert unit == "g"
|
||||
|
||||
def test_no_space(self):
|
||||
count, qty, unit, _ = parse_case_size("12x1L")
|
||||
assert count == 12
|
||||
assert qty == 1.0
|
||||
assert unit == "L"
|
||||
|
||||
def test_multiplication_sign(self):
|
||||
count, qty, unit, _ = parse_case_size("24 × 330 ml")
|
||||
assert count == 24
|
||||
assert qty == 330.0
|
||||
assert unit == "ml"
|
||||
|
||||
def test_uppercase_x(self):
|
||||
count, qty, unit, _ = parse_case_size("6X500g")
|
||||
assert count == 6
|
||||
|
||||
def test_no_match(self):
|
||||
count, qty, unit, raw = parse_case_size("just text")
|
||||
assert count is None
|
||||
assert qty is None
|
||||
assert unit is None
|
||||
|
||||
def test_empty(self):
|
||||
count, qty, unit, raw = parse_case_size("")
|
||||
assert count is None
|
||||
assert raw == ""
|
||||
|
||||
def test_none_input(self):
|
||||
count, _, _, raw = parse_case_size(None)
|
||||
assert count is None
|
||||
assert raw == ""
|
||||
58
market/tests/test_registry.py
Normal file
58
market/tests/test_registry.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Unit tests for product registry utilities."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from market.scrape.product.registry import merge_missing
|
||||
|
||||
|
||||
class TestMergeMissing:
|
||||
def test_fills_empty_dict(self):
|
||||
dst = {}
|
||||
merge_missing(dst, {"a": 1, "b": "hello"})
|
||||
assert dst == {"a": 1, "b": "hello"}
|
||||
|
||||
def test_existing_value_not_overwritten(self):
|
||||
dst = {"a": "original"}
|
||||
merge_missing(dst, {"a": "new"})
|
||||
assert dst["a"] == "original"
|
||||
|
||||
def test_none_overwritten(self):
|
||||
dst = {"a": None}
|
||||
merge_missing(dst, {"a": "filled"})
|
||||
assert dst["a"] == "filled"
|
||||
|
||||
def test_empty_string_overwritten(self):
|
||||
dst = {"a": ""}
|
||||
merge_missing(dst, {"a": "filled"})
|
||||
assert dst["a"] == "filled"
|
||||
|
||||
def test_empty_list_overwritten(self):
|
||||
dst = {"a": []}
|
||||
merge_missing(dst, {"a": [1, 2]})
|
||||
assert dst["a"] == [1, 2]
|
||||
|
||||
def test_empty_dict_overwritten(self):
|
||||
dst = {"a": {}}
|
||||
merge_missing(dst, {"a": {"key": "val"}})
|
||||
assert dst["a"] == {"key": "val"}
|
||||
|
||||
def test_zero_not_overwritten(self):
|
||||
dst = {"a": 0}
|
||||
merge_missing(dst, {"a": 42})
|
||||
assert dst["a"] == 0
|
||||
|
||||
def test_false_not_overwritten(self):
|
||||
dst = {"a": False}
|
||||
merge_missing(dst, {"a": True})
|
||||
assert dst["a"] is False
|
||||
|
||||
def test_none_src(self):
|
||||
dst = {"a": 1}
|
||||
merge_missing(dst, None)
|
||||
assert dst == {"a": 1}
|
||||
|
||||
def test_new_keys_added(self):
|
||||
dst = {"a": 1}
|
||||
merge_missing(dst, {"b": 2})
|
||||
assert dst == {"a": 1, "b": 2}
|
||||
45
market/tests/test_slugs.py
Normal file
45
market/tests/test_slugs.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Unit tests for market slug helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from market.bp.browse.services.slugs import (
|
||||
product_slug_from_href, canonical_html_slug,
|
||||
)
|
||||
|
||||
|
||||
class TestProductSlugFromHref:
|
||||
def test_html_extension(self):
|
||||
result = product_slug_from_href("https://site.com/foo/bar-thing.html")
|
||||
assert result == "bar-thing-html"
|
||||
|
||||
def test_htm_extension(self):
|
||||
result = product_slug_from_href("https://site.com/products/widget.htm")
|
||||
assert result == "widget-html"
|
||||
|
||||
def test_no_extension(self):
|
||||
result = product_slug_from_href("https://site.com/item/cool-product")
|
||||
assert result == "cool-product-html"
|
||||
|
||||
def test_empty_path(self):
|
||||
result = product_slug_from_href("https://site.com/")
|
||||
assert result == ""
|
||||
|
||||
def test_already_has_html_suffix_in_slug(self):
|
||||
result = product_slug_from_href("https://site.com/prod/item-html.html")
|
||||
assert result == "item-html"
|
||||
|
||||
|
||||
class TestCanonicalHtmlSlug:
|
||||
def test_adds_html_suffix(self):
|
||||
assert canonical_html_slug("product-name") == "product-name-html"
|
||||
|
||||
def test_idempotent(self):
|
||||
assert canonical_html_slug("product-name-html") == "product-name-html"
|
||||
|
||||
def test_double_html_kept(self):
|
||||
# canonical_html_slug only appends -html if not already present
|
||||
assert canonical_html_slug("product-name-html-html") == "product-name-html-html"
|
||||
|
||||
def test_strips_htm(self):
|
||||
assert canonical_html_slug("product-name-htm") == "product-name-html"
|
||||
Reference in New Issue
Block a user