"""Airport pages: the URL shape, the rendered body and the structured data.

The first hub page type (build plan §6, B4). The page is keyed by the IATA code
on the `shops` row; the words after it are for humans and may go stale.
Everything the page shows is counted in the database, so these tests build one
AirportDetail by hand and pin what the renderer must say about it: the H1, the
counts, the product cards with their prices and links, CollectionPage about an
Airport with an ItemList, breadcrumbs, and a canonical that ignores filters.
No database.
"""

import json
import pathlib
import re
from datetime import UTC, datetime

import pytest

from app.models.hubs import AirportDetail, AirportShop
from app.models.schemas import CategoryCount, ProductSummary, ShopPrice, TopAward
from app.services import place_guides, seo, urls

CHECKED = datetime(2026, 8, 25, 23, 6, tzinfo=UTC)
SHELL = (
    "<!doctype html><html><head><title>x</title>"
    '<meta name="description" content="old" /></head>'
    '<body><div id="root"></div></body></html>'
)


def summary(**over) -> ProductSummary:
    base = dict(
        id=1156, name="Santa Teresa 1796 Solera Rum 1L", brand="Santa Teresa", category="Rum",
        quantity_ml=1000, abv=40.0, is_exclusive=False, thumb_url="https://img.example/1156.jpg",
        shop_count=3, best_shop="London Heathrow", best_shop_iata="LHR",
        shop_labels=["LHR", "JFK", "EZE"],
        top_prices=[ShopPrice(label="LHR", usd=38.0, in_stock=True),
                    ShopPrice(label="JFK", usd=45.0, in_stock=None),
                    ShopPrice(label="EZE", usd=55.5, in_stock=True)],
        top_award=TopAward(competition_slug="nyisc", competition="NYISC", medal="Double Gold", year=2024),
        cheapest_usd=38.0, dearest_usd=55.5, award_count=1,
    )
    base.update(over)
    return ProductSummary(**base)


def airport(**over) -> AirportDetail:
    base = dict(
        iata="LHR", path="/airports/heathrow-lhr-london", name="London Heathrow", city="London",
        country="United Kingdom", currency="GBP", product_variants=266, last_collected_at=CHECKED,
        shops=[AirportShop(code="LHR", retailer_name="Avolta", product_variants=266, last_collected_at=CHECKED)],
        comparable=108, cheapest_here=67, exclusives=8,
        categories=[CategoryCount(category="Whisky", count=67), CategoryCount(category="Gin & <Tonic>", count=24)],
        savings=[summary()],
        exclusive_items=[summary(id=7, name="Talisker Dark Storm 1L", is_exclusive=True, award_count=0,
                                 top_award=None, shop_count=1, top_prices=[], thumb_url=None,
                                 cheapest_usd=60.0, dearest_usd=60.0)],
        total=266, limit=24, offset=0,
        items=[summary(), summary(id=8, name="Bell's Blended Scotch 1l", award_count=0, top_award=None)],
    )
    base.update(over)
    return AirportDetail(**base)


class TestAirportUrls:
    def test_declared_airport_leads_with_its_common_name(self):
        """Mark's review of 7 Sep: "heathrow" is overwhelmingly what is
        searched, so the name leads, then the code, then the city; JFK is the
        one airport known by its code. The pages had shipped code-first four
        days earlier; nothing was indexed, so the cost was a rename plus
        redirects."""
        assert urls.airport_path("LHR", "London") == "/airports/heathrow-lhr-london"
        assert urls.airport_path("JFK", "New York", "New York JFK") == "/airports/jfk-new-york"
        # The city is dropped where the name already says it.
        assert urls.airport_path("HKG", "Hong Kong", "Hong Kong International") == "/airports/hong-kong-hkg"
        # Paris is CDG and Orly together, named for Paris.
        assert urls.airport_path("CDG", "Paris", "Paris Charles de Gaulle and Orly") == "/airports/paris-cdg"
        assert urls.airport_path("DUB", "Dublin", "Dublin & Cork") == "/airports/dublin-dub"
        # Case of the code does not matter.
        assert urls.airport_path("lhr", "London") == "/airports/heathrow-lhr-london"

    def test_undeclared_airport_keeps_the_old_shape_so_nothing_404s(self):
        assert urls.airport_path("XYZ", "Nowhere") == "/airports/xyz-nowhere"
        assert urls.airport_path("XYZ", None, "Some Name") == "/airports/xyz-some-name"
        assert urls.airport_path("QQQ", None, None) == "/airports/qqq"

    def test_every_shape_ever_linked_resolves_to_its_airport(self):
        """Every earlier shape must still land, the bare code, the
        pre-review `lhr-london`, a stale new-shape slug, because the route
        301s from whatever resolves."""
        for slug in (
            "heathrow-lhr-london", "lhr", "LHR", "LHR-Heathrow", "lhr-london",
            "lhr-london-airport", "heathrow-lhr-terminal-5", "london-lhr",
        ):
            assert urls.parse_airport_slug(slug) == "LHR", slug
        assert urls.parse_airport_slug("jfk-new-york") == "JFK"
        assert urls.parse_airport_slug("jfk") == "JFK"
        # Undeclared old shape.
        assert urls.parse_airport_slug("xyz-nowhere") == "XYZ"
        for slug in ("nope", "12a-x", "lh", "lhrx", "", "athens", "london"):
            assert urls.parse_airport_slug(slug) is None, slug

    def test_a_three_letter_word_is_only_a_code_when_it_leads_or_is_declared(self):
        """San Salvador's slug starts with a three-letter word that is also
        San Diego's code; it must resolve to SAL, exactly and when stale,
        never to an airport that was never in the table."""
        assert urls.parse_airport_slug("san-salvador-sal") == "SAL"
        assert urls.parse_airport_slug("san-salvador-sal-stale") == "SAL"

    def test_no_declared_slug_collides_with_another_declared_code(self):
        for iata, slug in urls.AIRPORT_SLUGS.items():
            assert re.fullmatch(r"[A-Z]{3}", iata)
            assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", slug)
            assert urls.parse_airport_slug(slug) == iata
            head = slug.split("-")[0]
            # Only a true collision risk when the leading word is itself a
            # declared code (SAL's slug leads with "san", San Diego's code,
            # but SAN is undeclared, so parse_airport_slug never sees it as
            # a match; see test_a_three_letter_word_is_only_a_code...).
            if len(head) == 3 and head.upper() in urls.AIRPORT_SLUGS:
                assert head.upper() == iata, f"{slug} leads with a code that is not its own"
            assert iata.lower() in slug.split("-")
        assert len(set(urls.AIRPORT_SLUGS.values())) == len(urls.AIRPORT_SLUGS)

    def test_query_omits_defaults_so_the_clean_page_has_none(self):
        assert urls.airport_query(None, "featured", 1) == ""
        assert urls.airport_query("Gin & Tonic", "featured", 1) == "?category=Gin%20%26%20Tonic"
        assert urls.airport_query(None, "price", 3) == "?sort=price&page=3"

    def test_feature_flags_ride_between_the_category_and_the_sort_and_imply_the_full_list(self):
        """G5: the storefront's chips as query parameters named as /api/products names them;
        any one of them can only mean the full list, like a category."""
        assert urls.airport_query("Whisky", "price", 2, "all", {"awarded_only": True}) == "?tab=all&category=Whisky&awarded_only=true&sort=price&page=2"
        assert urls.airport_query(None, "featured", 1, "all", urls.feature_flags(multi_only=True, exclusives_only=True)) == "?tab=all&multi_only=true&exclusives_only=true"
        assert urls.airport_query(None, "featured", 1, "all", {}) == "?tab=all"
        assert urls.airport_tab(None, None, "featured", 1, {"multi_only": True}) == "all"
        assert urls.airport_tab(None, None, "featured", 1, {}) == "value"
        assert urls.airport_tab("exclusives", None, "featured", 1, {"awarded_only": True}) == "exclusives"
        assert urls.read_flag("true") and urls.read_flag("1") and urls.read_flag(" Yes ") and not urls.read_flag("bogus") and not urls.read_flag(None)
        assert urls.feature_flags(multi_only=False, awarded_only=True, nonsense=True) == {"awarded_only": True}

    def test_the_ts_mirror_names_the_same_feature_parameters(self):
        src = (pathlib.Path(__file__).resolve().parents[1] / "web" / "src" / "lib" / "urls.ts").read_text()
        block = src[src.index("export const AIRPORT_FEATURE_PARAMS"):src.index("];", src.index("export const AIRPORT_FEATURE_PARAMS"))]
        assert re.findall(r'"(multi_only|awarded_only|exclusives_only)"', block) == list(urls.FEATURE_PARAMS)

    def test_the_comparison_preset_address_and_its_mirror(self):
        """G6: `/savings?from=<IATA>`, read once by the savings page as its view."""
        assert urls.savings_from_path("lhr") == "/savings?from=LHR"
        src = (pathlib.Path(__file__).resolve().parents[1] / "web" / "src" / "lib" / "urls.ts").read_text()
        assert "return `/savings?from=${iata.toUpperCase()}`;" in src
        assert 'params.get("from")' in src

    def test_seo_module_still_exports_the_helpers(self):
        """Routes and older tests import the URL helpers from seo; the move to
        urls.py must not break that."""
        assert seo.product_path(1, "x") == "/products/x-1" and seo.airport_path("LHR", "London") == "/airports/heathrow-lhr-london"


class TestAirportSlugTable:
    def test_ts_mirror_holds_the_same_table(self):
        """The two tables must agree or server canonicals fight client
        links. Read the SPA's own file the way TestRouteInventory reads
        App.tsx."""
        src = (pathlib.Path(__file__).resolve().parents[1] / "web" / "src" / "lib" / "urls.ts").read_text()
        start = src.index("export const AIRPORT_SLUGS")
        block = src[start:src.index("};", start)]
        entries = re.findall(r'^\s+([A-Z]{3}): "([a-z0-9-]+)",$', block, re.M)
        ts_table = dict(entries)
        assert ts_table
        assert ts_table == urls.AIRPORT_SLUGS


class TestAirportHead:
    def test_title_description_and_canonical(self):
        head = seo.head_for_airport(airport())
        assert head.title == "London Heathrow (LHR) duty-free prices | Duty Free Professor"
        assert head.description == (
            "266 duty-free products at London Heathrow (LHR), checked 25 Aug 2026. "
            "108 sold at other airports too: see where London is the better buy."
        )
        assert head.canonical_path == "/airports/heathrow-lhr-london"
        assert head.last_modified == CHECKED
        assert head.seed_key == "__DFP_AIRPORT__" and head.seed["iata"] == "LHR"

    def test_a_filtered_or_paged_view_keeps_the_clean_canonical(self):
        head = seo.head_for_airport(airport(category="Whisky", sort="price", offset=24))
        assert head.canonical_path == "/airports/heathrow-lhr-london"
        assert not head.noindex

    def test_collection_page_about_an_airport_with_an_item_list(self):
        page = seo.head_for_airport(airport()).apply(SHELL, "https://s.example")
        blocks = [json.loads(m) for m in re.findall(r'<script type="application/ld\+json">(.*?)</script>', page)]
        collection = next(b for b in blocks if b["@type"] == "CollectionPage")
        assert collection["@id"] == "https://s.example/airports/heathrow-lhr-london#page"
        assert collection["url"] == "https://s.example/airports/heathrow-lhr-london"
        assert collection["isPartOf"] == {"@id": "https://s.example/#website"}
        about = collection["about"]
        assert about["@type"] == "Airport" and about["iataCode"] == "LHR" and about["name"] == "London Heathrow"
        # Only what the shops row holds: no street, no coordinates.
        assert about["address"] == {"@type": "PostalAddress", "addressLocality": "London", "addressCountry": "United Kingdom"}
        assert "geo" not in about and "streetAddress" not in about["address"]
        items = collection["mainEntity"]
        assert items["@type"] == "ItemList" and items["numberOfItems"] == 266
        assert items["itemListElement"][0] == {
            "@type": "ListItem", "position": 1,
            "url": "https://s.example/products/santa-teresa-1796-solera-rum-1l-1156",
            "name": "Santa Teresa 1796 Solera Rum 1L",
        }
        crumbs = next(b for b in blocks if b["@type"] == "BreadcrumbList")
        assert crumbs["itemListElement"][0]["item"] == "https://s.example/airports"
        assert crumbs["itemListElement"][1] == {"@type": "ListItem", "position": 2, "name": "London Heathrow"}

    def test_positions_continue_across_pages(self):
        head = seo.head_for_airport(airport(offset=24))
        positions = [e["position"] for e in head.jsonld[0]["mainEntity"]["itemListElement"]]
        assert positions == [25, 26]

    def test_an_airport_without_a_city_or_country_has_no_address(self):
        head = seo.head_for_airport(airport(city=None, country=None, path="/airports/lhr"))
        assert "address" not in head.jsonld[0]["about"]


class TestAirportBody:
    def test_the_page_reads_without_javascript(self):
        body = seo.airport_body(airport())
        assert '<h1 class="airports-hero__title">Duty free at London Heathrow</h1>' in body
        assert "266 duty-free products priced by Avolta at London Heathrow (LHR), checked 25 Aug 2026." in body
        for label, value in (("Products priced", "266"), ("Sold elsewhere too", "108"),
                             ("Cheapest here", "67"), ("Travel exclusives", "8")):
            assert f"<dt>{label}</dt><dd>{value}</dd>" in body, label
        assert '<div class="airport-fact airport-fact--wide"><dt>Shops we read</dt><dd>Avolta (266 products)</dd></div>' in body
        assert '<div class="airport-fact"><dt>Prices checked</dt><dd>25 Aug 2026</dd></div>' in body
        assert 'href="/airports">Airports we price</a>' in body

    def test_the_three_shelves_are_tabs_and_the_page_opens_on_the_savings(self):
        """The page used to stack four grids of cards; it now shows one shelf at
        a time, the shelf in the URL, so a shopper scrolls one list, not four."""
        body = seo.airport_body(airport())
        assert '<nav class="shelf-tabs" aria-label="Products at this airport">' in body
        assert ('<a href="/airports/heathrow-lhr-london#products" class="shelf-tab shelf-tab--active" '
                'aria-current="page">Best value here<span class="shelf-tab__count">67</span></a>') in body
        assert ('<a href="/airports/heathrow-lhr-london?tab=exclusives#products" class="shelf-tab">'
                'Travel exclusives<span class="shelf-tab__count">8</span></a>') in body
        assert ('<a href="/airports/heathrow-lhr-london?tab=all#products" class="shelf-tab">'
                'All products<span class="shelf-tab__count">266</span></a>') in body
        assert "LHR is the cheapest of our airports for 67 of the 108 products sold at more than one." in body
        # The savings are the shelf on show; the full list and its sort are not.
        assert "collection-bar__sort" not in body
        assert body.count('class="product-card"') == len(airport().savings)

    def test_another_shelf_shows_its_own_cards_and_its_own_line(self):
        body = seo.airport_body(airport(), tab="exclusives")
        assert 'class="shelf-tab shelf-tab--active" aria-current="page">Travel exclusives' in body
        assert "Bottlings and editions sold only in travel retail, as we find them at London Heathrow." in body
        assert '<a href="/exclusives" class="btn btn--ghost">All exclusives</a>' in body
        assert "Talisker Dark Storm 1L" in body and "Bell's Blended Scotch 1l" not in body

    def test_the_full_list_carries_the_chips_the_sort_and_the_pager(self):
        body = seo.airport_body(airport(), tab="all")
        assert 'class="shelf-tab shelf-tab--active" aria-current="page">All products' in body
        assert "Everything we price at London Heathrow, shop by shop." in body
        assert '<div class="filter-rail">' in body and "collection-bar__sort" in body

    def test_cards_carry_the_name_the_prices_and_the_link(self):
        body = seo.airport_body(airport(), medal_assets={"nyisc-double-gold.png"}) + seo.airport_body(
            airport(), medal_assets={"nyisc-double-gold.png"}, tab="exclusives"
        )
        assert 'href="/products/santa-teresa-1796-solera-rum-1l-1156" class="product-card"' in body
        assert '<span class="product-card__name">Santa Teresa 1796 Solera Rum 1L</span>' in body
        assert '<span class="product-card__meta">In 3 shops · 1L · 40%</span>' in body
        # The per-shop bars, cheapest first, scaled to the dearest.
        assert 'class="mini-compare__row mini-compare__row--best" style="--bar: 68%;"' in body
        assert '<span class="mini-compare__price">$55.50</span>' in body
        assert '<span class="product-card__save">Save $17.50</span><span class="product-card__buyat">buy at LHR</span>' in body
        # Medal artwork where it exists; the text tag where an exclusive has no medal.
        assert 'class="product-card__medal" src="/medals/nyisc-double-gold.png?v=2" alt="Double Gold, NYISC 2024"' in body
        assert '<span class="product-card__tag product-card__tag--excl">Exclusive</span>' in body
        assert '<span class="product-card__meta">Travel-retail exclusive · 1L · 40%</span>' in body

    def test_without_the_artwork_the_award_becomes_a_text_tag(self):
        body = seo.airport_body(airport(exclusive_items=[], items=[summary()]))
        assert '<span class="product-card__tag product-card__tag--medal">Award winner</span>' in body
        assert "product-card__medal" not in body

    def test_category_chips_link_to_the_filtered_view_and_escape(self):
        body = seo.airport_body(airport(), tab="all")
        assert 'href="/airports/heathrow-lhr-london?tab=all&amp;category=Whisky#products">Whisky <span class="chip-count">67</span>' in body
        assert 'href="/airports/heathrow-lhr-london?tab=all&amp;category=Gin%20%26%20%3CTonic%3E#products">Gin &amp; &lt;Tonic&gt;' in body

    def test_the_active_chip_links_back_to_the_unfiltered_list(self):
        """A filter can only mean the full list, so it selects that shelf on its
        own; the active chip clears the filter and keeps the shelf."""
        body = seo.airport_body(airport(category="Whisky", sort="price"), tab="all")
        assert ('class="filter-chip filter-chip--active" '
                'href="/airports/heathrow-lhr-london?tab=all&amp;sort=price#products">Whisky') in body
        assert "Whisky priced at London Heathrow, cheapest first where we can compare." in body

    def test_pager_links_keep_the_filters_and_disable_at_the_ends(self):
        first = seo.airport_body(airport(total=67, category="Whisky"), tab="all")
        assert ('href="/airports/heathrow-lhr-london?tab=all&amp;category=Whisky#products" '
                'aria-disabled="true" tabindex="-1">Previous</a>') in first
        assert 'href="/airports/heathrow-lhr-london?tab=all&amp;category=Whisky&amp;page=2#products">Next</a>' in first
        assert '<span class="pager__status">1-24 of 67</span>' in first
        last = seo.airport_body(airport(total=67, offset=48), tab="all")
        assert 'href="/airports/heathrow-lhr-london?tab=all&amp;page=2#products">Previous</a>' in last
        assert ('href="/airports/heathrow-lhr-london?tab=all&amp;page=4#products" '
                'aria-disabled="true" tabindex="-1">Next</a>') in last
        assert '<span class="pager__status">49-67 of 67</span>' in last
        assert "pager" not in seo.airport_body(airport(total=24), tab="all")

    def test_the_feature_chips_toggle_one_flag_and_keep_the_rest(self):
        """G5: the storefront's own chips on the full list, links back at this page."""
        body = seo.airport_body(airport(awarded_only=True, category="Whisky"), tab="all")
        assert '<div class="filter-rail filter-rail--features">' in body
        assert ('<a class="filter-chip filter-chip--gold filter-chip--active" '
                'href="/airports/heathrow-lhr-london?tab=all&amp;category=Whisky#products" aria-pressed="true">Award winners</a>') in body
        assert ('<a class="filter-chip" href="/airports/heathrow-lhr-london?tab=all&amp;category=Whisky&amp;multi_only=true&amp;awarded_only=true#products" '
                'aria-pressed="false">Comparable</a>') in body
        assert 'awarded_only=true&amp;exclusives_only=true#products" aria-pressed="false">Travel exclusives</a>' in body
        # The category chips, the pager and the full-list tab keep the flag; the other shelves drop it.
        assert 'href="/airports/heathrow-lhr-london?tab=all&amp;category=Gin%20%26%20%3CTonic%3E&amp;awarded_only=true#products">Gin' in body
        assert 'href="/airports/heathrow-lhr-london?tab=all&amp;category=Whisky&amp;awarded_only=true&amp;page=2#products">Next</a>' in body
        assert 'href="/airports/heathrow-lhr-london?tab=all&amp;category=Whisky&amp;awarded_only=true#products" class="shelf-tab shelf-tab--active"' in body
        assert 'href="/airports/heathrow-lhr-london?tab=exclusives#products" class="shelf-tab">' in body
        # Off the full list, no feature chips.
        assert "filter-rail--features" not in seo.airport_body(airport())

    def test_a_filtered_view_keeps_the_clean_canonical(self):
        head = seo.head_for_airport(airport(awarded_only=True, multi_only=True), tab="all")
        assert head.canonical_path == "/airports/heathrow-lhr-london" and not head.noindex

    def test_a_shelf_with_nothing_on_it_is_not_offered(self):
        """An airport with no savings and no exclusives has one shelf, the full
        list, and the page falls back to it rather than showing an empty tab."""
        body = seo.airport_body(airport(savings=[], exclusive_items=[], categories=[], items=[], total=0, comparable=0))
        assert "Best value here" not in body and "Travel exclusives<span" not in body
        assert 'class="shelf-tab shelf-tab--active" aria-current="page">All products' in body
        assert '<div class="empty-state">Nothing here matches that category yet.</div>' in body
        assert "<dt>Sold elsewhere too</dt><dd>0</dd>" in body

    def test_the_add_button_and_the_comparison_link_follow_the_feature_flag(self):
        on = seo.airport_body(airport(), flags={"myAirports": True})
        assert "Add to my airports" in on
        assert '<a href="/savings?from=LHR" class="btn btn--light airport-hero__compare">Compare from here</a>' in on
        off = seo.airport_body(airport(), flags={"myAirports": False})
        assert "Add to my airports" not in off and "/savings?from=" not in off

    def test_untrusted_names_are_escaped_in_markup_and_json(self):
        evil = airport(name='Heathrow <b>"T5"</b>', items=[summary(name="Rum </script><script>alert(1)")])
        page = seo.head_for_airport(evil).apply(SHELL, "https://s.example")
        assert "<b>" not in page.split("<h1")[1].split("</h1>")[0]
        assert "</script><script>alert(1)" not in page
        assert "\\u003c/script" in page

    def test_no_em_dash_in_what_the_page_says(self):
        """House style for client-facing text."""
        assert "—" not in seo.airport_body(airport())
        head = seo.head_for_airport(airport())
        assert "—" not in head.title and "—" not in head.description


class TestAirportFeatured:
    """One card per family that has a comparison here (`seo.airport_featured_html`,
    `components/AirportFeatured.tsx`); no families, no section; a family without items, no card.

    It was four cards a family, which put sixteen of them between the facts and the products and
    made the page a scroll before it said anything worth stopping for."""

    def test_one_card_per_family_and_a_link_to_the_rest(self):
        from app.models.hubs import FeaturedFamily

        detail = airport(featured=[
            FeaturedFamily(key="spirits", label="Spirits", items=[summary(), summary(id=11, name="A second bottle")]),
            FeaturedFamily(key="perfume", label="Perfume", items=[]),
            FeaturedFamily(key="wine", label="Wine, champagne and beer", items=[summary(id=9, name="A fizz")]),
        ])
        body = seo.airport_body(detail)
        assert '<h2 class="section-heading">Best value at London Heathrow</h2>' in body
        assert "One from each family: what LHR is the cheapest of our airports for." in body
        assert body.count('class="featured-pick"') == 2
        assert '<div class="featured-pick" data-family="spirits"><span class="featured-pick__family">Spirits</span>' in body
        # A family with nothing comparable draws no card, and only the best of a family shows.
        assert "Perfume</span>" not in body and "A fizz" in body and "A second bottle" not in body
        # The rest is one link away, on the shelf that holds them all.
        assert f'All {detail.cheapest_here:,}</a>' in body
        # The row sits above the shelves, after the facts and the areas.
        assert body.index('class="airport-featured"') < body.index('id="products"')

    def test_no_families_no_section(self):
        body = seo.airport_body(airport())
        assert "airport-featured" not in body and "Featured at" not in body


class TestFormatMirrors:
    def test_date_is_utc_and_matches_format_ts(self):
        assert seo.fmt_date(CHECKED) == "25 Aug 2026"
        assert seo.fmt_date(datetime(2026, 9, 5, 0, 30, tzinfo=UTC)) == "5 Sep 2026"
        assert seo.fmt_date(None) == "not yet"

    @pytest.mark.parametrize("value,expected", [(0.5, 1), (1.5, 2), (2.5, 3), (67.4, 67), (67.5, 68)])
    def test_bar_widths_round_like_math_round(self, value, expected):
        assert seo._js_round(value) == expected


LHR_GUIDE = place_guides.load_file(place_guides.FILES / "heathrow-lhr-london.json")


def hours(kind="collected", **over):
    from app.models.hubs import AirportHoursOut

    base = dict(kind=kind, text="World Duty Free, 14 stores", observed_at=datetime(2026, 9, 11, 22, tzinfo=UTC),
                source_host="www.heathrow.com" if kind == "collected" else None)
    base.update(over)
    return AirportHoursOut(**base)


class TestAirportGuide:
    """The guide document stored on the place (`services.place_guides`), which the SPA and the
    crawler's body draw from the same fields (`components/AirportGuide.tsx`, `seo.airport_*_html`).
    The document is the author's, section kinds and all; what the page does with it is ours."""

    def test_an_airport_nobody_has_written_has_no_guide_and_no_section(self):
        """Empty beats guessed: an airport with no written guide simply has no
        guide section, rather than a heading over nothing."""
        body = seo.airport_body(airport(guide=None))
        assert "airport-terminals" not in body and "Where to shop at" not in body
        assert "airport-standing" not in body and "Know before you shop" not in body
        # The facts panel stays: the shops and the date are ours, not written.
        assert '<div class="airport-fact airport-fact--wide"><dt>Shops we read</dt>' in body

    def test_the_guide_draws_a_row_per_area_carrying_that_area_s_sections(self):
        body = seo.airport_body(airport(guide=LHR_GUIDE))
        assert '<h2 class="section-heading">Where to shop at London Heathrow</h2>' in body
        assert body.count('<details class="terminal">') == 4
        assert '<span class="terminal__name">Terminal 5</span>' in body
        assert "flagship Harrods near the B Gates" in body
        # Each area carries the author's own sections, labelled in the site's words.
        assert body.count('class="guide-section guide-section--notable-shops"') == 4
        assert "<h3 class=\"guide-section__label\">Interesting things to buy</h3>" in body
        # The operator and the area count are facts, not prose; the word comes from the kind.
        assert '<div class="airport-fact"><dt>Duty free operator</dt><dd>World Duty Free (Avolta)</dd></div>' in body
        assert '<div class="airport-fact"><dt>Terminals</dt><dd>4</dd></div>' in body

    def test_the_place_level_sections_stand_above_the_areas(self):
        """Key facts and what the shops offer decide how a person shops here, so they are high on
        the page rather than inside a terminal row."""
        body = seo.airport_body(airport(guide=LHR_GUIDE))
        assert body.index('class="airport-standing"') < body.index('class="airport-terminals"')
        assert "<h2 class=\"guide-section__label\">Key facts</h2>" in body
        assert "Reserve and Collect" in body.split('class="airport-terminals"')[0]
        assert "Shops checked September 2026" in body
        # Nothing is claimed that nobody wrote: no map for Heathrow yet.
        assert "airport-facts__map" not in body

    def test_the_closing_blocks_sit_below_the_products_with_their_own_titles(self):
        """The allowances are a lookup nobody reads on the way in, and the page exists for the
        prices: they go after the shelf, keeping the title the author gave them."""
        body = seo.airport_body(airport(guide=LHR_GUIDE))
        assert body.index('id="products"') < body.index('class="airport-know"')
        assert '<span class="know__title">Arriving and clearing customs</span>' in body
        assert "42 litres of beer" in body
        assert "<h3 class=\"guide-section__label\">Traveller tips</h3>" in body

    def test_the_hours_print_with_their_date_and_never_a_verdict(self):
        """Decision 10's wording ceiling: a date beside the hours, never "verified", never a
        rate; the owner's provenance (who, which host) stays out of the public body."""
        body = seo.airport_body(airport(hours=hours()))
        assert ('<div class="airport-fact airport-fact--wide"><dt>Opening hours</dt><dd>World Duty Free, 14 stores '
                '<span class="airport-fact__when muted">collected 11 Sep 2026</span></dd></div>') in body
        assert "www.heathrow.com" not in body and "erified" not in body
        body = seo.airport_body(airport(hours=hours(kind="hand", text="T1 04:00-22:00", entered_by_username="rian")))
        assert '<dd>T1 04:00-22:00 <span class="airport-fact__when muted">entered 11 Sep 2026</span></dd>' in body
        assert "rian" not in body.split("<dl")[1]
        # No hours at all is no row, rather than a label over nothing.
        assert "Opening hours" not in seo.airport_body(airport(guide=LHR_GUIDE))

    def test_the_author_s_labels_are_the_site_s_spelling_in_both_renderers(self):
        """The identifiers stay American; what a reader sees does not. `lib/placeGuide.ts` mirrors
        SECTION_LABEL, and a label that differs between the crawler's page and the shopper's is
        drift nobody would notice."""
        import json
        import re

        from app.models.place_guide import SECTION_LABEL

        mirror = (pathlib.Path(__file__).resolve().parents[1] / "web/src/lib/placeGuide.ts").read_text()
        block = mirror.split("SECTION_LABEL: Record<string, string> = {")[1].split("};")[0]
        pairs = dict(re.findall(r"(\w+):\s*\"([^\"]+)\"", block))
        assert pairs == {kind.value: label for kind, label in SECTION_LABEL.items()}
        assert json.dumps(pairs)  # the mirror is plain data, not code the test has to run

    def test_the_placeholder_view_never_reaches_the_server_body(self):
        """The demo's placeholder cards are for us. A crawler must never see a
        box saying what the page is missing, and the structure proposal's rule
        is that there is no filler of any kind, least of all for search."""
        body = seo.airport_body(airport(guide=None))
        assert "missing" not in body and "What we need" not in body
