"""Validate generated decks against the real WML 1.3 DTD. The DTD is vendored at `tests/dtd/wml13.dtd` (fetched from http://www.wapforum.org/DTD/wml13.dtd) so the suite never depends on a network round trip to a twenty-year-old URL. Validation needs lxml, which is an optional extra: `uv run --extra dtd pytest`. """ from __future__ import annotations from pathlib import Path import pytest lxml_etree = pytest.importorskip( "lxml.etree", reason="install the 'dtd' extra for DTD validation" ) DTD_PATH = Path(__file__).resolve().parent / "dtd" / "wml13.dtd" @pytest.fixture(scope="module") def dtd(): with DTD_PATH.open("rb") as handle: return lxml_etree.DTD(handle) def validation_errors(dtd, markup: str) -> list[str]: # The document's own DOCTYPE points at a dead URL, so parse with the # external DTD left alone and validate against the vendored copy. parser = lxml_etree.XMLParser(resolve_entities=False, load_dtd=False) tree = lxml_etree.fromstring(markup.encode("utf-8"), parser) if dtd.validate(tree): return [] return [str(error) for error in dtd.error_log.filter_from_errors()] def assert_valid(dtd, markup: str) -> None: errors = validation_errors(dtd, markup) if errors: pytest.fail("\n".join(errors)) SOURCES = { "single_card": "# Title\n\nA paragraph with **bold** and a [link](http://x.test/).\n", "two_sections": "Intro.\n\n{.card One}\nFirst.\n\n{.card Two}\nSecond.\n", "table": "| A | B |\n| --- | --- |\n| 1 | 2 |\n", "code_block": "```\n & $HOME\n```\n", "blockquote_and_lists": "> quoted\n\n- a\n- b\n\n1. one\n2. two\n\n---\n", "image": "![Map](map.wbmp)\n", "entities": 'Fish & chips < > " $5\n', } class TestDeckValidity: @pytest.mark.parametrize("name", sorted(SOURCES)) def test_sources_are_valid(self, dtd, render, name): assert_valid(dtd, render(SOURCES[name])) @pytest.mark.parametrize( "options", [ {}, {"menu": False}, {"menu_style": "select"}, {"template_nav": False}, {"home_label": "Home"}, {"cache_control": 0}, {"access_domain": "example.com", "access_path": "/wap"}, {"max_card_bytes": 80}, {"title": "Deck"}, ], ids=[ "defaults", "linear", "select-menu", "no-template", "home-softkey", "cache-control", "access", "paginated", "titled", ], ) def test_option_combinations_are_valid(self, dtd, render, options): assert_valid(dtd, render(SOURCES["two_sections"], **options)) def test_every_deck_file_is_valid(self, dtd, render_output): output = render_output(SOURCES["two_sections"], deck_per_card=True, home_label="Home") for document in output.documents: assert_valid(dtd, document.text) def test_example_documents_are_valid(self, dtd, example_decks): for name, markup in example_decks.items(): assert_valid(dtd, markup) class TestDetectsInvalidMarkup: """The validator has to actually reject things, or it proves nothing.""" def test_table_outside_a_paragraph_is_rejected(self, dtd): # The shape the renderer used to emit before the

wrapper landed. bad = ( '
x
' "
" ) assert validation_errors(dtd, bad) def test_emphasis_inside_an_anchor_is_rejected(self, dtd): # The shape the renderer used to emit for `[**go**](url)`. bad = '

no

' assert validation_errors(dtd, bad) def test_a_valid_deck_reports_no_errors(self, dtd): good = '

ok

' assert validation_errors(dtd, good) == []