wapdown/tests/test_validity.py

116 lines
3.9 KiB
Python
Raw Permalink Normal View History

Extract the WML exporter from md2txt into wapdown md2txt is a suite of minimal text-based markup exporters, and every other renderer in it (text, micron, ama, gemini, nex) is a straight transform of a document into a flat text format. The WML renderer is neither: it emits an XML tree, and it restructures the document rather than transforming it -- splitting one file into many <card>s, inventing navigation between them, building a menu card that exists in no source document, and paginating on a byte budget. That is why it needed levers no sibling renderer needed, and why it alone forced a CARD_BREAK block kind and a {.card} directive into the shared parser that every other renderer ignores. wapdown is that renderer given a repository of its own, where restructuring a document into a navigable deck is the point. Ported: the Markdown parser, block event models, and pipeline core, each trimmed to what a deck compiler actually uses (no FIGlet, no hyphenation, no ASCII-art includes, no text-layout frontmatter), plus the eight inline regexes the renderer had been borrowing from a 1039-line text renderer. The plugin registry comes along so other WAP-era targets can register alongside wml later. Zero runtime dependencies. Config is now first-class: every lever is an argparse flag with validation and a frontmatter equivalent, layered CLI > frontmatter > default, replacing the generic --renderer-option KEY=VALUE passthrough. Five defects found while reading the code, each with a regression test: - Link and image URLs were shredded by the emphasis patterns, which ran before LINK_RE: a path like /v1_2_3/ became /v1<i>2</i>3/ inside the href. Links and images are now resolved and stashed first. - <table> was emitted as a direct child of <card>. The WML 1.3 content model is (onevent*, timer?, (do | p | pre)*), so it must sit in a <p>. - Link labels carried emphasis, but <a> is declared (#PCDATA | br | img)* -- there is nowhere valid for a <b> inside one. Markers are now stripped. - The menu card was built directly and never byte-packed, so the one card most likely to be large was the one card exempt from the budget. - Output was written with CRLF line endings. WML is XML served over HTTP. New, WAP-idiomatic rather than Markdown-idiomatic: - deck-level <template> hoisting nav that would otherwise repeat per card - <select> menus, picked with a keypad digit instead of scrolled to - --deck-per-card, one file per section cross-linked by filename, which is the real answer to per-deck cache limits - <head> metadata: Cache-Control max-age and <access> - a Home options softkey, which <prev/> alone cannot provide - an image policy (keep/alt/drop) for the WBMP-only reality of WAP 1.x browsers -- no conversion is performed 186 tests, where neither repo had any. Golden files pin whole-document output; every deck is validated against the real WML 1.3 DTD, vendored at tests/dtd/wml13.dtd. Rendering examples/trail.md still matches md2txt's committed output byte for byte, modulo the CRLF fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 09:27:49 +03:00
"""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<wml> & $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 <p> wrapper landed.
bad = (
'<wml><card id="c"><table columns="1"><tr><td>x</td></tr></table>'
"</card></wml>"
)
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 = '<wml><card id="c"><p><a href="#x"><b>no</b></a></p></card></wml>'
assert validation_errors(dtd, bad)
def test_a_valid_deck_reports_no_errors(self, dtd):
good = '<wml><card id="c"><p>ok</p></card></wml>'
assert validation_errors(dtd, good) == []