"""The drawn structure a browser pass fixed, pinned where a reader cannot see it go wrong.

The cross-device and accessibility pass (19 to 20 Sep, `.logs/verification/`) drew nine pages
in a real browser at two widths and read the accessibility tree. Three of the four things it
repaired are invisible on the page, so nothing but a person with a screen reader would ever
notice them coming back:

- **Every navigation was labelled but the main one.** The site header's own menu, on all nine
  pages, was an unnamed `navigation` in the accessibility tree: the one landmark a reader
  jumps to first, announced as nothing. The footer's three columns had carried labels since
  they were written, so the gap read as deliberate and survived every earlier pass.
- **The article centre skipped a heading level.** Its ladder went h1 "Articles" straight to
  the cards' h3, because the filter rail between them is a `nav`, not a heading; a reader
  moving by heading left the page's title for a headline.
- **The live page's Start menu did not close on Escape.** Every other pop-out on the site does
  (`MyAirportsButton`, `AccountMenu`, `DecisionMenu`, the vendored `Menu`), so a keyboard
  reader who opened the mode list had learned there was a way out, and there was not.

These read the sources rather than a browser, in the shape of `test_house_style.py`: seconds,
no network, no server. What a browser has to answer (a focus ring, an overflow, a live region)
stays with `scripts/page-probe.py` and the dated report.

The served bodies are `app/services/seo.py`'s, not the SPA's, and the same three gaps live
there separately; they are on the running list against that module's owner, not tested here.
"""

import pathlib
import re

MAIN = pathlib.Path(__file__).resolve().parents[1]
SRC = MAIN / "web" / "src"

#: The vendored packs are byte-identical to their sources and never edited (check.sh enforces
#: it), so a finding inside one is a note to their author, never a change here.
VENDOR = "vendor"

NAV_OPEN = re.compile(r"<nav\b([^>]*)>", re.S)


def spa_sources() -> list[pathlib.Path]:
    return [p for p in sorted(SRC.rglob("*.tsx")) if VENDOR not in p.relative_to(SRC).parts]


class TestLandmarksAreNamed:
    def test_every_nav_carries_a_label(self):
        """An unnamed `nav` is announced as "navigation" and nothing else.

        A page here draws up to seven: the header menu, the crumbs, the jump links, the tag
        rail, the pager and the footer's three columns. The accessibility tree of all nine
        pages in the pass held exactly one unnamed `navigation` at 1440 and none at 390, which
        is how the header's own menu was found: it is the one the phone width hides.
        """
        offenders = []
        for path in spa_sources():
            body = path.read_text()
            for match in NAV_OPEN.finditer(body):
                attributes = match.group(1)
                if "aria-label" in attributes:
                    continue
                line = body.count("\n", 0, match.start()) + 1
                offenders.append(f"{path.relative_to(SRC)}:{line}")
        assert not offenders, f"<nav> with no aria-label or aria-labelledby: {offenders}"


class TestHeadingLadder:
    def test_the_article_centre_names_its_group(self):
        """h1 to h3 with nothing between, because the rail in the middle is a nav.

        The card's heading is h3 wherever it is drawn, which is right under the home page's
        "Latest articles" h2 and wrong directly under the index's h1. The index names the group
        itself, in a heading the page does not draw.
        """
        page = (SRC / "pages" / "ArticlesPage.tsx").read_text()
        assert 'className="articles-page__group"' in page, (
            "ArticlesPage lost the group heading between its h1 and the cards' h3"
        )
        assert re.search(r"<h2[^>]*articles-page__group", page), (
            "the article centre's group heading must be an h2: the cards under it are h3"
        )
        style = (SRC / "pages" / "ArticlesPage.css").read_text()
        assert ".articles-page__group" in style, (
            "the group heading has no rule, so the index would draw it twice"
        )


class TestPopOutsClose:
    def test_the_live_pages_start_menu_closes_on_escape(self):
        """Opened with the caret, and before this there was no way back out but Tab."""
        row = (SRC / "components" / "collectors" / "CollectorRow.tsx").read_text()
        assert '"Escape"' in row, "the Start menu must close on Escape, like every other pop-out"
        assert 'addEventListener("keydown"' in row and 'removeEventListener("keydown"' in row, (
            "the Escape listener must be removed when the menu closes"
        )


class TestMotionIsAsked:
    def test_the_ticking_numbers_snap_under_reduced_motion(self):
        """The live page's figures count up; a reader who asked for stillness gets the value.

        `useCountUp` reads the query itself at the moment a target changes, which is the moment
        it has to decide. The media query's spelling is what this pins: a typo in it fails
        silently and every figure animates for a reader who asked it not to.
        """
        hook = (MAIN / "web" / "src" / "lib" / "useCountUp.ts").read_text()
        assert "(prefers-reduced-motion: reduce)" in hook
        assert "matchMedia" in hook

    def test_the_live_pages_animations_are_answered_in_css(self):
        """The pulse on a running chip and the bars that slide: both stop when asked."""
        for name in ("CollectorRow.css", "LiveBoard.css"):
            style = (SRC / "components" / "collectors" / name).read_text()
            assert "@media (prefers-reduced-motion: reduce)" in style, (
                f"{name} animates without answering prefers-reduced-motion"
            )


class TestControlsAreControls:
    #: The components AW4 and AW5 added, whose controls the pass drove by keyboard.
    NEW_COMPONENTS = (
        "components/collectors/CollectorRow.tsx",
        "components/collectors/LiveBoard.tsx",
        "components/PageStatusBadge.tsx",
        "components/ArticleCard.tsx",
        "pages/ArticlesPage.tsx",
        "pages/ArticlePage.tsx",
    )

    def test_nothing_clickable_is_a_div(self):
        """Enter and Space come free on a button and never on a div.

        Every action on the live page is a native `<button type="button">` and every control in
        the article centre is a link, which is why the keyboard pass found nothing to report.
        A `div` with an `onClick` is reachable by mouse alone and is the usual way that breaks.
        """
        offenders = []
        for name in self.NEW_COMPONENTS:
            body = (SRC / name).read_text()
            for match in re.finditer(r"<(\w+)\b[^>]*?onClick=", body, re.S):
                tag = match.group(1)
                if tag in ("button", "a", "input", "label", "select", "textarea") or tag[0].isupper():
                    continue
                offenders.append(f"{name}:{body.count(chr(10), 0, match.start()) + 1} <{tag}>")
        assert not offenders, f"onClick on an element a keyboard cannot reach: {offenders}"
