diff --git a/src/server.py b/src/server.py index 6cb2dc7..8cbda3e 100644 --- a/src/server.py +++ b/src/server.py @@ -39,7 +39,7 @@ Small, Text, ) -from prefab_ui.components.charts import AreaChart, ChartSeries, PieChart +from prefab_ui.components.charts import AreaChart, BarChart, ChartSeries, PieChart from prefab_ui.rx import STATE, Rx from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware @@ -553,6 +553,37 @@ def build_dashboard_app(data: dict[str, Any]) -> PrefabApp: return app +# --------------------------------------------------------------------------- +# Currency helpers +# --------------------------------------------------------------------------- + +_CURRENCY_SYMBOLS: dict[str, str] = { + "USD": "$", + "EUR": "€", + "GBP": "£", + "JPY": "¥", + "CNY": "¥", + "INR": "₹", + "KRW": "₩", + "BRL": "R$", + "AUD": "A$", + "CAD": "C$", +} + + +def _currency_symbol(data: dict[str, Any]) -> str: + """Extract currency symbol from a SerpApi response's search_parameters.""" + code = (data.get("search_parameters") or {}).get("currency", "USD") + return _CURRENCY_SYMBOLS.get(code, code + " ") + + +def _fmt_price(amount: int | float | None, symbol: str) -> str: + """Format a numeric price with the given currency symbol.""" + if not amount: + return "—" + return f"{symbol}{amount:,.0f}" + + # --------------------------------------------------------------------------- # Flights-specific App builder # --------------------------------------------------------------------------- @@ -560,6 +591,7 @@ def build_dashboard_app(data: dict[str, Any]) -> PrefabApp: def flights_rows(data: dict[str, Any]) -> list[dict[str, Any]]: """Flatten best_flights + other_flights into table-ready rows.""" + symbol = _currency_symbol(data) rows: list[dict[str, Any]] = [] for section in ("best_flights", "other_flights"): for itinerary in data.get(section) or []: @@ -583,9 +615,7 @@ def flights_rows(data: dict[str, Any]) -> list[dict[str, Any]]: if stops == 0 else f"{stops} stop{'s' if stops > 1 else ''}", "price": itinerary.get("price") or 0, - "price_fmt": f"${itinerary['price']:,}" - if itinerary.get("price") - else "—", + "price_fmt": _fmt_price(itinerary.get("price"), symbol), "carbon_delta": carbon_pct, "carbon_fmt": f"{carbon_pct:+d}% vs typical" if isinstance(carbon_pct, int) @@ -655,6 +685,7 @@ def build_flights_app(data: dict[str, Any]) -> PrefabApp: insights = flights_price_insights(data) rows = flights_rows(data) history = price_history_points(data) + symbol = _currency_symbol(data) lowest = insights["lowest_price"] level = insights["price_level"] @@ -674,12 +705,12 @@ def build_flights_app(data: dict[str, Any]) -> PrefabApp: with Grid(columns=[1, 1, 1, 1], gap=4): Metric( label="Lowest price", - value=f"${lowest:,}" if lowest else "—", + value=_fmt_price(lowest, symbol), ) Metric( label="Typical range", value=( - f"${typical_low:,}–${typical_high:,}" + f"{_fmt_price(typical_low, symbol)}–{_fmt_price(typical_high, symbol)}" if typical_low and typical_high else "—" ), @@ -752,10 +783,340 @@ def build_flights_app(data: dict[str, Any]) -> PrefabApp: return app +# --------------------------------------------------------------------------- +# Jobs-specific App builder +# --------------------------------------------------------------------------- + +# Benefits detected from extensions that get badge treatment. +_JOB_BENEFIT_LABELS = { + "Health insurance", + "Dental insurance", + "Paid time off", + "401(k)", + "Vision insurance", + "Life insurance", + "Disability insurance", + "Commuter benefits", + "Tuition reimbursement", +} + + +def jobs_rows(data: dict[str, Any]) -> list[dict[str, Any]]: + """Flatten jobs_results into table-ready rows with structured metadata.""" + rows: list[dict[str, Any]] = [] + for job in data.get("jobs_results") or []: + ext = job.get("detected_extensions") or {} + extensions = job.get("extensions") or [] + benefits = [e for e in extensions if e in _JOB_BENEFIT_LABELS] + rows.append( + { + "title": job.get("title", ""), + "company": job.get("company_name", ""), + "location": job.get("location", ""), + "salary": ext.get("salary", ""), + "schedule": ext.get("schedule_type", ""), + "posted": ext.get("posted_at", ""), + "qualifications": ext.get("qualifications", ""), + "work_from_home": ext.get("work_from_home", False), + "benefits": benefits, + "benefits_fmt": ", ".join(benefits) if benefits else "—", + "via": job.get("via", ""), + "description": (job.get("description") or "")[:300], + "highlights": job.get("job_highlights") or [], + "apply_options": job.get("apply_options") or [], + "source_link": job.get("source_link", ""), + } + ) + return rows + + +def jobs_summary(data: dict[str, Any]) -> dict[str, Any]: + """Derive summary metrics from a jobs response.""" + rows = jobs_rows(data) + total = len(rows) + with_salary = sum(1 for r in rows if r["salary"]) + remote = sum(1 for r in rows if r["work_from_home"]) + return { + "total": total, + "with_salary": with_salary, + "remote": remote, + "salary_pct": f"{with_salary * 100 // total}%" if total else "—", + "remote_pct": f"{remote * 100 // total}%" if total else "—", + "rows": rows, + "schedule_breakdown": jobs_schedule_breakdown(rows), + } + + +def jobs_schedule_breakdown(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Count jobs per schedule type for the pie chart.""" + counts = Counter(r["schedule"] or "Unspecified" for r in rows) + return [ + {"schedule": schedule, "count": count} + for schedule, count in counts.most_common() + ] + + +_JOBS_COLUMNS = [ + DataTableColumn(key="title", header="Title", sortable=True), + DataTableColumn(key="company", header="Company", sortable=True), + DataTableColumn(key="location", header="Location", sortable=True), + DataTableColumn(key="salary", header="Salary", sortable=True), + DataTableColumn(key="schedule", header="Type", sortable=True), + DataTableColumn(key="posted", header="Posted", sortable=True), + DataTableColumn(key="benefits_fmt", header="Benefits"), +] + + +def build_jobs_app(data: dict[str, Any]) -> PrefabApp: + """Compose the jobs explorer dashboard.""" + summary = jobs_summary(data) + rows = summary["rows"] + params = data.get("search_parameters") or {} + query = params.get("q", "") + + title = f"Jobs: {query}" if query else "Jobs dashboard" + + with PrefabApp(title=title, state={"selected": None}) as app: + with Column(gap=4, css_class="p-4"): + # Metrics row + with Grid(columns=[1, 1, 1, 1], gap=4): + Metric(label="Jobs found", value=str(summary["total"])) + Metric( + label="With salary", + value=str(summary["with_salary"]), + description=summary["salary_pct"], + ) + Metric( + label="Remote", + value=str(summary["remote"]), + description=summary["remote_pct"], + ) + Metric( + label="Query", + value=query or "—", + ) + + # Schedule type breakdown + if summary["schedule_breakdown"]: + PieChart( + data=summary["schedule_breakdown"], + data_key="count", + name_key="schedule", + show_legend=True, + height=220, + ) + + # Jobs table + DataTable( + columns=_JOBS_COLUMNS, + rows=rows, + search=True, + paginated=True, + page_size=10, + on_row_click=SetState("selected", Rx("$event")), + ) + + # Detail panel + with If(STATE.selected): + with Card(): + with CardHeader(): + H3(Rx("selected.title")) + with Row(gap=2): + Small(content=Rx("selected.company")) + Text(content="·") + Small(content=Rx("selected.location")) + with Row(gap=2, css_class="mt-2"): + with If(Rx("selected.salary")): + Badge(label=Rx("selected.salary"), variant="default") + with If(Rx("selected.schedule")): + Badge( + label=Rx("selected.schedule"), + variant="secondary", + ) + with If(Rx("selected.work_from_home")): + Badge(label="Remote", variant="success") + with CardContent(): + with Column(gap=3): + Text(content=Rx("selected.description")) + with If(Rx("selected.source_link")): + Link( + content="View full listing →", + href=Rx("selected.source_link"), + target="_blank", + ) + + return app + + +# --------------------------------------------------------------------------- +# Shopping-specific App builder +# --------------------------------------------------------------------------- + + +def shopping_rows(data: dict[str, Any]) -> list[dict[str, Any]]: + """Flatten shopping_results into table-ready rows.""" + rows: list[dict[str, Any]] = [] + for item in data.get("shopping_results") or []: + price = item.get("extracted_price") + old_price = item.get("extracted_old_price") + extensions = item.get("extensions") or [] + discount_tag = next((e for e in extensions if "OFF" in e), "") + rows.append( + { + "title": item.get("title", ""), + "source": item.get("source", ""), + "price": price or 0, + "price_fmt": item.get("price", "—"), + "old_price_fmt": item.get("old_price", ""), + "discount": discount_tag, + "rating": item.get("rating") or 0, + "reviews": item.get("reviews") or 0, + "snippet": item.get("snippet", ""), + "product_link": item.get("product_link", ""), + } + ) + return rows + + +def _extract_currency_prefix(data: dict[str, Any]) -> str: + """Extract the currency symbol from the first shopping result's price string.""" + for item in data.get("shopping_results") or []: + price_str = item.get("price", "") + if price_str: + prefix = "" + for ch in price_str: + if ch.isdigit() or ch in ".,": + break + prefix += ch + if prefix: + return prefix + return "$" + + +def shopping_summary(data: dict[str, Any]) -> dict[str, Any]: + """Derive summary metrics and price-by-source chart data.""" + rows = shopping_rows(data) + prices = [r["price"] for r in rows if r["price"] > 0] + on_sale = sum(1 for r in rows if r["old_price_fmt"]) + avg_rating = ( + sum(r["rating"] for r in rows if r["rating"]) + / max(1, sum(1 for r in rows if r["rating"])) + if rows + else 0 + ) + + # Price by source (top 10 cheapest for the bar chart) + priced = sorted([r for r in rows if r["price"] > 0], key=lambda r: r["price"]) + price_chart = [{"source": r["source"], "price": r["price"]} for r in priced[:10]] + + symbol = _extract_currency_prefix(data) + + return { + "total": len(rows), + "price_min": min(prices) if prices else 0, + "price_max": max(prices) if prices else 0, + "on_sale": on_sale, + "avg_rating": round(avg_rating, 1), + "rows": rows, + "price_chart": price_chart, + "currency_symbol": symbol, + } + + +_SHOPPING_COLUMNS = [ + DataTableColumn(key="title", header="Product", sortable=True), + DataTableColumn(key="source", header="Seller", sortable=True), + DataTableColumn(key="price", header="Price", sortable=True, format="currency"), + DataTableColumn(key="old_price_fmt", header="Was"), + DataTableColumn(key="discount", header="Discount"), + DataTableColumn(key="rating", header="Rating", sortable=True), + DataTableColumn(key="reviews", header="Reviews", sortable=True), +] + + +def build_shopping_app(data: dict[str, Any]) -> PrefabApp: + """Compose the shopping price comparison dashboard.""" + summary = shopping_summary(data) + rows = summary["rows"] + params = data.get("search_parameters") or {} + query = params.get("q", "") + sym = summary["currency_symbol"] + + title = f"Shopping: {query}" if query else "Shopping dashboard" + + with PrefabApp(title=title, state={"selected": None}) as app: + with Column(gap=4, css_class="p-4"): + # Metrics row + with Grid(columns=[1, 1, 1, 1], gap=4): + Metric(label="Products", value=str(summary["total"])) + Metric( + label="Price range", + value=f"{sym}{summary['price_min']:,.0f}–{sym}{summary['price_max']:,.0f}" + if summary["price_min"] + else "—", + ) + Metric(label="On sale", value=str(summary["on_sale"])) + Metric( + label="Avg rating", + value=str(summary["avg_rating"]) if summary["avg_rating"] else "—", + ) + + # Price comparison bar chart (top 10 cheapest sellers) + if summary["price_chart"]: + BarChart( + data=summary["price_chart"], + series=[ChartSeries(data_key="price", label="Price ($)")], + x_axis="source", + height=240, + horizontal=True, + ) + + # Products table + DataTable( + columns=_SHOPPING_COLUMNS, + rows=rows, + search=True, + paginated=True, + page_size=15, + on_row_click=SetState("selected", Rx("$event")), + ) + + # Detail panel + with If(STATE.selected): + with Card(): + with CardHeader(): + H3(Rx("selected.title")) + with Row(gap=2): + Small(content=Rx("selected.source")) + with If(Rx("selected.discount")): + Badge( + label=Rx("selected.discount"), + variant="destructive", + ) + with CardContent(): + with Column(gap=2): + with Row(gap=4): + Text(content=Rx("selected.price_fmt")) + with If(Rx("selected.old_price_fmt")): + Small(content=Rx("selected.old_price_fmt")) + with If(Rx("selected.snippet")): + Text(content=Rx("selected.snippet")) + with If(Rx("selected.product_link")): + Link( + content="View on Google Shopping →", + href=Rx("selected.product_link"), + target="_blank", + ) + + return app + + # Engine-specific app dispatch: maps engine names to their dedicated builders. # Falls back to the generic dashboard for unregistered engines. ENGINE_APP_BUILDERS: dict[str, Any] = { "google_flights": build_flights_app, + "google_jobs": build_jobs_app, + "google_shopping": build_shopping_app, } diff --git a/tests/test_server.py b/tests/test_server.py index 6153b4f..f899905 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -395,7 +395,7 @@ def test_dashboard_summary_shape(): def ui_json(app): """Serialize a Prefab app via its canonical serializer for assertions.""" - return json.dumps(app.to_json()) + return json.dumps(app.to_json(), ensure_ascii=False) _SAMPLE_PAYLOAD = { @@ -723,6 +723,47 @@ def test_build_flights_app_generic_title_without_route(): assert app.title == "Flights dashboard" +def test_flights_currency_inr(): + """Flights with currency=INR should use ₹ not $.""" + data = { + "search_parameters": { + "engine": "google_flights", + "currency": "INR", + "departure_id": "COK", + "arrival_id": "DXB", + }, + "best_flights": [ + { + "flights": [ + { + "departure_airport": {"id": "COK", "time": "10:00"}, + "arrival_airport": {"id": "DXB", "time": "13:00"}, + "airline": "IndiGo", + } + ], + "layovers": [], + "total_duration": 240, + "price": 35906, + } + ], + "price_insights": { + "lowest_price": 35758, + "typical_price_range": [20500, 44000], + "price_level": "typical", + }, + } + rows = server.flights_rows(data) + assert rows[0]["price_fmt"] == "₹35,906" + app = server.build_flights_app(data) + body = ui_json(app) + assert "₹35,758" in body + assert "₹20,500" in body + # No dollar-prefixed prices ($ appears in $prefab/$event but not before digits) + import re + + assert not re.search(r"\$\d", body) + + async def test_search_dashboard_dispatches_to_flights(monkeypatch): use_request(monkeypatch, real_request(state={"api_key": "KEY"})) use_search(monkeypatch, lambda params: serp_results(_SAMPLE_FLIGHTS_PAYLOAD)) @@ -739,3 +780,436 @@ async def test_search_dashboard_falls_back_to_generic(monkeypatch): use_search(monkeypatch, lambda params: serp_results(_SAMPLE_PAYLOAD)) app = await server.search_dashboard(params={"q": "coffee"}) assert app.title == "Search dashboard" + + +# --- MCP Apps: Jobs-specific helpers and builder ---------------------------- + +_SAMPLE_JOBS_PAYLOAD = { + "search_parameters": { + "engine": "google_jobs", + "q": "software engineer", + }, + "jobs_results": [ + { + "title": "Senior Software Engineer", + "company_name": "Acme Corp", + "location": "Austin, TX", + "via": "LinkedIn", + "extensions": [ + "3 days ago", + "120K–160K a year", + "Full-time", + "Health insurance", + "Dental insurance", + "Paid time off", + ], + "detected_extensions": { + "posted_at": "3 days ago", + "salary": "120K–160K a year", + "schedule_type": "Full-time", + }, + "description": "We are looking for a senior engineer to join our platform team and build scalable distributed systems.", + "job_highlights": [ + { + "title": "Qualifications", + "items": ["5+ years experience", "Python or Go proficiency"], + }, + { + "title": "Benefits", + "items": ["Health insurance", "401(k) matching", "Remote-friendly"], + }, + ], + "apply_options": [ + {"title": "LinkedIn", "link": "https://linkedin.com/jobs/123"}, + {"title": "Indeed", "link": "https://indeed.com/jobs/456"}, + ], + "source_link": "https://acme.com/careers/senior-swe", + }, + { + "title": "Frontend Developer", + "company_name": "StartupCo", + "location": "Remote", + "via": "Indeed", + "extensions": ["1 day ago", "Work from home", "Contract"], + "detected_extensions": { + "posted_at": "1 day ago", + "schedule_type": "Contract", + "work_from_home": True, + }, + "description": "Build beautiful user interfaces with React and TypeScript.", + "job_highlights": [], + "apply_options": [], + "source_link": "", + }, + { + "title": "Junior Developer", + "company_name": "BigTech", + "location": "San Francisco, CA", + "via": "Glassdoor", + "extensions": ["5 days ago", "Full-time", "No degree mentioned"], + "detected_extensions": { + "posted_at": "5 days ago", + "schedule_type": "Full-time", + "qualifications": "No degree mentioned", + }, + "description": "Entry-level position for new graduates.", + }, + ], +} + + +def test_jobs_rows_extracts_all_jobs(): + rows = server.jobs_rows(_SAMPLE_JOBS_PAYLOAD) + assert len(rows) == 3 + + # Rich job with salary and benefits + assert rows[0]["title"] == "Senior Software Engineer" + assert rows[0]["company"] == "Acme Corp" + assert rows[0]["location"] == "Austin, TX" + assert rows[0]["salary"] == "120K–160K a year" + assert rows[0]["schedule"] == "Full-time" + assert rows[0]["posted"] == "3 days ago" + assert rows[0]["work_from_home"] is False + assert "Health insurance" in rows[0]["benefits"] + assert "Dental insurance" in rows[0]["benefits"] + assert "Paid time off" in rows[0]["benefits"] + assert ( + rows[0]["benefits_fmt"] == "Health insurance, Dental insurance, Paid time off" + ) + assert rows[0]["source_link"] == "https://acme.com/careers/senior-swe" + assert len(rows[0]["highlights"]) == 2 + assert len(rows[0]["apply_options"]) == 2 + + # Remote job + assert rows[1]["work_from_home"] is True + assert rows[1]["salary"] == "" + assert rows[1]["benefits_fmt"] == "—" + + # Minimal job (no highlights, no apply_options keys) + assert rows[2]["qualifications"] == "No degree mentioned" + assert rows[2]["highlights"] == [] + assert rows[2]["apply_options"] == [] + + +def test_jobs_rows_handles_empty_data(): + assert server.jobs_rows({}) == [] + assert server.jobs_rows({"jobs_results": None}) == [] + + +def test_jobs_rows_handles_missing_extensions(): + data = { + "jobs_results": [ + { + "title": "Intern", + "company_name": "Small Co", + "location": "Remote", + } + ] + } + rows = server.jobs_rows(data) + assert len(rows) == 1 + assert rows[0]["salary"] == "" + assert rows[0]["schedule"] == "" + assert rows[0]["posted"] == "" + assert rows[0]["benefits"] == [] + assert rows[0]["description"] == "" + + +def test_jobs_summary_computes_metrics(): + summary = server.jobs_summary(_SAMPLE_JOBS_PAYLOAD) + assert summary["total"] == 3 + assert summary["with_salary"] == 1 + assert summary["remote"] == 1 + assert summary["salary_pct"] == "33%" + assert summary["remote_pct"] == "33%" + assert len(summary["rows"]) == 3 + # Schedule breakdown for pie chart + breakdown = summary["schedule_breakdown"] + assert len(breakdown) == 2 + assert {"schedule": "Full-time", "count": 2} in breakdown + assert {"schedule": "Contract", "count": 1} in breakdown + + +def test_jobs_summary_handles_empty(): + summary = server.jobs_summary({}) + assert summary["total"] == 0 + assert summary["salary_pct"] == "—" + assert summary["remote_pct"] == "—" + assert summary["schedule_breakdown"] == [] + + +def test_jobs_schedule_breakdown_groups_unspecified(): + rows = [{"schedule": ""}, {"schedule": ""}, {"schedule": "Full-time"}] + breakdown = server.jobs_schedule_breakdown(rows) + assert {"schedule": "Unspecified", "count": 2} in breakdown + assert {"schedule": "Full-time", "count": 1} in breakdown + + +def test_build_jobs_app_produces_valid_app(): + app = server.build_jobs_app(_SAMPLE_JOBS_PAYLOAD) + assert app.title == "Jobs: software engineer" + assert app.state == {"selected": None} + body = ui_json(app) + assert "PieChart" in body + assert "DataTable" in body + assert "Senior Software Engineer" in body + assert "Acme Corp" in body + assert "120K" in body + # Detail panel elements + assert "selected.salary" in body + assert "selected.title" in body + assert "source_link" in body + + +def test_build_jobs_app_without_query(): + data = {"search_parameters": {"engine": "google_jobs"}, "jobs_results": []} + app = server.build_jobs_app(data) + assert app.title == "Jobs dashboard" + + +def test_build_jobs_app_description_truncated(): + long_desc = "x" * 500 + data = { + "search_parameters": {"q": "test"}, + "jobs_results": [ + { + "title": "Role", + "company_name": "Co", + "location": "NYC", + "description": long_desc, + } + ], + } + app = server.build_jobs_app(data) + rows = server.jobs_rows(data) + assert len(rows[0]["description"]) == 300 + + +async def test_search_dashboard_dispatches_to_jobs(monkeypatch): + use_request(monkeypatch, real_request(state={"api_key": "KEY"})) + use_search(monkeypatch, lambda params: serp_results(_SAMPLE_JOBS_PAYLOAD)) + app = await server.search_dashboard( + params={"engine": "google_jobs", "q": "software engineer"} + ) + assert "software engineer" in app.title + body = ui_json(app) + assert "Senior Software Engineer" in body + assert "DataTable" in body + + +# --- MCP Apps: Shopping-specific helpers and builder ------------------------ + +_SAMPLE_SHOPPING_PAYLOAD = { + "search_parameters": { + "engine": "google_shopping", + "q": "Sony WH-1000XM5", + }, + "shopping_results": [ + { + "position": 1, + "title": "Sony WH-1000XM5 Wireless Headphones", + "source": "Best Buy", + "price": "$278.00", + "extracted_price": 278.0, + "old_price": "$398", + "extracted_old_price": 398, + "rating": 4.6, + "reviews": 26000, + "snippet": "Good sound quality", + "extensions": ["30% OFF", "Nearby, 11 mi"], + "product_link": "https://google.com/shopping/product/123", + }, + { + "position": 2, + "title": "Sony WH-1000XM5 Wireless Headphones", + "source": "Amazon", + "price": "$298.00", + "extracted_price": 298.0, + "rating": 4.7, + "reviews": 45000, + "snippet": "Comfortable fit", + "product_link": "https://google.com/shopping/product/456", + }, + { + "position": 3, + "title": "Sony WH-1000XM5 Wireless Headphones - Black", + "source": "Walmart", + "price": "$249.99", + "extracted_price": 249.99, + "old_price": "$349.99", + "extracted_old_price": 349.99, + "rating": 4.5, + "reviews": 8200, + "extensions": ["29% OFF"], + "product_link": "", + }, + { + "position": 4, + "title": "Sony WH-1000XM5 Refurbished", + "source": "eBay", + "price": "$189.00", + "extracted_price": 189.0, + "rating": 0, + "reviews": 0, + "product_link": "https://google.com/shopping/product/789", + }, + ], +} + + +def test_shopping_rows_extracts_all_products(): + rows = server.shopping_rows(_SAMPLE_SHOPPING_PAYLOAD) + assert len(rows) == 4 + + # Product with discount + assert rows[0]["title"] == "Sony WH-1000XM5 Wireless Headphones" + assert rows[0]["source"] == "Best Buy" + assert rows[0]["price"] == 278.0 + assert rows[0]["price_fmt"] == "$278.00" + assert rows[0]["old_price_fmt"] == "$398" + assert rows[0]["discount"] == "30% OFF" + assert rows[0]["rating"] == 4.6 + assert rows[0]["reviews"] == 26000 + assert rows[0]["snippet"] == "Good sound quality" + + # Product without discount + assert rows[1]["source"] == "Amazon" + assert rows[1]["old_price_fmt"] == "" + assert rows[1]["discount"] == "" + + # Product with no rating + assert rows[3]["rating"] == 0 + assert rows[3]["reviews"] == 0 + + +def test_shopping_rows_handles_empty(): + assert server.shopping_rows({}) == [] + assert server.shopping_rows({"shopping_results": None}) == [] + + +def test_shopping_rows_handles_missing_fields(): + data = {"shopping_results": [{"title": "Widget", "source": "Store"}]} + rows = server.shopping_rows(data) + assert rows[0]["price"] == 0 + assert rows[0]["price_fmt"] == "—" + assert rows[0]["rating"] == 0 + assert rows[0]["discount"] == "" + + +def test_shopping_summary_computes_metrics(): + summary = server.shopping_summary(_SAMPLE_SHOPPING_PAYLOAD) + assert summary["total"] == 4 + assert summary["price_min"] == 189.0 + assert summary["price_max"] == 298.0 + assert summary["on_sale"] == 2 + assert summary["avg_rating"] == 4.6 # (4.6+4.7+4.5)/3 rounded + assert len(summary["price_chart"]) == 4 + # Chart sorted by cheapest first + assert summary["price_chart"][0]["source"] == "eBay" + assert summary["price_chart"][0]["price"] == 189.0 + + +def test_shopping_summary_handles_empty(): + summary = server.shopping_summary({}) + assert summary["total"] == 0 + assert summary["price_min"] == 0 + assert summary["price_max"] == 0 + assert summary["price_chart"] == [] + + +def test_build_shopping_app_produces_valid_app(): + app = server.build_shopping_app(_SAMPLE_SHOPPING_PAYLOAD) + assert app.title == "Shopping: Sony WH-1000XM5" + assert app.state == {"selected": None} + body = ui_json(app) + assert "BarChart" in body + assert "DataTable" in body + assert "Best Buy" in body + assert "278" in body + # Detail panel + assert "selected.discount" in body + assert "selected.product_link" in body + + +def test_build_shopping_app_without_query(): + data = {"search_parameters": {"engine": "google_shopping"}, "shopping_results": []} + app = server.build_shopping_app(data) + assert app.title == "Shopping dashboard" + + +def test_build_shopping_app_no_chart_without_prices(): + data = { + "search_parameters": {"q": "test"}, + "shopping_results": [{"title": "Free thing", "source": "Store"}], + } + app = server.build_shopping_app(data) + body = ui_json(app) + assert "BarChart" not in body + assert "DataTable" in body + + +def test_shopping_currency_inr(): + """Shopping with INR prices should show ₹ in metrics, not $.""" + data = { + "search_parameters": {"q": "headphones", "gl": "in"}, + "shopping_results": [ + { + "title": "Sony WH-1000XM5", + "source": "Amazon India", + "price": "₹24,990", + "extracted_price": 24990, + "rating": 4.5, + "reviews": 100, + }, + { + "title": "Sony WH-1000XM4", + "source": "Flipkart", + "price": "₹19,990", + "extracted_price": 19990, + "rating": 4.4, + "reviews": 200, + }, + ], + } + summary = server.shopping_summary(data) + assert summary["currency_symbol"] == "₹" + + app = server.build_shopping_app(data) + body = ui_json(app) + assert "₹19,990" in body + assert "₹24,990" in body + import re + + assert not re.search(r"\$\d", body) + + +def test_extract_currency_prefix_various(): + assert ( + server._extract_currency_prefix({"shopping_results": [{"price": "$99.00"}]}) + == "$" + ) + assert ( + server._extract_currency_prefix({"shopping_results": [{"price": "₹6,999"}]}) + == "₹" + ) + assert ( + server._extract_currency_prefix({"shopping_results": [{"price": "€49.99"}]}) + == "€" + ) + assert ( + server._extract_currency_prefix({"shopping_results": [{"price": "R$150"}]}) + == "R$" + ) + assert server._extract_currency_prefix({}) == "$" + + +async def test_search_dashboard_dispatches_to_shopping(monkeypatch): + use_request(monkeypatch, real_request(state={"api_key": "KEY"})) + use_search(monkeypatch, lambda params: serp_results(_SAMPLE_SHOPPING_PAYLOAD)) + app = await server.search_dashboard( + params={"engine": "google_shopping", "q": "Sony WH-1000XM5"} + ) + assert "Sony WH-1000XM5" in app.title + body = ui_json(app) + assert "BarChart" in body + assert "Best Buy" in body