wapdown/tests/test_deck.py
randogoth b83e24e2f7 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

213 lines
7.4 KiB
Python

"""Document topology: sectioning, byte packing, and navigation wiring."""
from __future__ import annotations
import re
import pytest
from wapdown.renderers.wml.deck import greedy_pack, slugify
TWO_SECTIONS = """\
Intro text.
{.card Weather}
Cold and clear.
{.card Status}
North Loop open.
"""
THREE_HEADINGS = """\
# Title
Intro text.
## Alpha
First section.
## Beta
Second section.
## Gamma
Third section.
"""
def card_ids(markup: str) -> list[str]:
return re.findall(r'<card id="([^"]+)"', markup)
def cards(markup: str) -> dict[str, str]:
"""Map each card id to that card's markup."""
chunks = re.split(r"(?=<card id=)", markup)
out = {}
for chunk in chunks:
match = re.match(r'<card id="([^"]+)"', chunk)
if match:
out[match.group(1)] = chunk.split("</card>")[0]
return out
class TestSectioning:
def test_plain_document_stays_one_card(self, render):
markup = render("# Title\n\nJust a paragraph.\n")
assert card_ids(markup) == ["card1"]
def test_headings_alone_do_not_split(self, render):
# Splitting is opt-in: a document full of headings is still one card
# until split_level says otherwise.
assert card_ids(render(THREE_HEADINGS)) == ["card1"]
def test_split_level_splits_at_that_heading(self, render):
markup = render(THREE_HEADINGS, split_level=2)
assert card_ids(markup) == ["menu", "card1", "card2", "card3"]
def test_split_level_that_matches_nothing_stays_one_card(self, render):
assert card_ids(render(THREE_HEADINGS, split_level=5)) == ["card1"]
def test_card_markers_split(self, render):
assert card_ids(render(TWO_SECTIONS)) == ["menu", "card1", "card2"]
def test_card_markers_win_over_split_level(self, render):
# An explicit boundary in the document is a stronger statement of
# intent than a structural rule passed on the command line.
markup = render(
"Intro.\n\n{.card One}\n## Heading\n\nBody.\n\n{.card Two}\nMore.\n",
split_level=2,
)
assert card_ids(markup) == ["menu", "card1", "card2"]
def test_marker_title_is_used(self, render):
assert '<card id="card1" title="Weather">' in render(TWO_SECTIONS)
def test_untitled_marker_falls_back_to_first_heading(self, render):
markup = render("Intro.\n\n{.card}\n## Weather\n\nCold.\n\n{.card Two}\nx\n")
assert '<card id="card1" title="Weather">' in markup
def test_marker_without_any_heading_is_untitled(self, render):
markup = render("Intro.\n\n{.card}\nCold.\n\n{.card Two}\nx\n")
assert '<card id="card1" title="Untitled">' in markup
def test_preamble_lands_on_the_menu_card(self, render):
assert "Intro text." in cards(render(TWO_SECTIONS))["menu"]
class TestHubAndSpoke:
def test_menu_links_to_every_section(self, render):
menu = cards(render(TWO_SECTIONS))["menu"]
assert '<a href="#card1">Weather</a>' in menu
assert '<a href="#card2">Status</a>' in menu
def test_select_style_menu(self, render):
menu = cards(render(TWO_SECTIONS, menu_style="select"))["menu"]
assert '<select title="Menu">' in menu
assert '<option onpick="#card1">Weather</option>' in menu
assert "<a href=" not in menu
def test_spokes_have_no_forward_link(self, render):
# In hub-and-spoke the only way onward is back through the menu.
spoke = cards(render(TWO_SECTIONS))["card1"]
assert 'type="accept"' not in spoke
def test_menu_card_has_no_back_control(self, render):
assert '<do type="prev"><noop/></do>' in cards(render(TWO_SECTIONS))["menu"]
def test_home_softkey_is_opt_in(self, render):
assert 'type="options"' not in render(TWO_SECTIONS)
assert 'type="options"' in render(TWO_SECTIONS, home_label="Home")
class TestLinear:
def test_cards_chain_forward(self, render):
markup = render(TWO_SECTIONS, menu=False)
assert card_ids(markup) == ["card1", "card2"]
assert '<do type="accept" label="More"><go href="#card2"/></do>' in markup
def test_last_card_has_no_forward_link(self, render):
assert 'type="accept"' not in cards(render(TWO_SECTIONS, menu=False))["card2"]
def test_first_card_carries_the_preamble(self, render):
assert "Intro text." in cards(render(TWO_SECTIONS, menu=False))["card1"]
def test_custom_nav_labels(self, render):
markup = render(TWO_SECTIONS, menu=False, nav_next_label="Onward")
assert 'label="Onward"' in markup
class TestTemplateNav:
def test_shared_back_control_is_hoisted(self, render):
markup = render(TWO_SECTIONS)
assert markup.count('<do type="prev" label="Back"><prev/></do>') == 1
assert "<template>" in markup
def test_disabling_repeats_nav_per_card(self, render):
markup = render(TWO_SECTIONS, template_nav=False)
assert "<template>" not in markup
assert markup.count('<do type="prev" label="Back"><prev/></do>') == 2
def test_single_card_deck_needs_no_template(self, render):
assert "<template>" not in render("# Title\n\nBody.\n")
def test_template_precedes_the_first_card(self, render):
markup = render(TWO_SECTIONS)
assert markup.index("<template>") < markup.index("<card id=")
class TestPacking:
def test_budget_of_zero_disables_packing(self):
assert greedy_pack(["a" * 500, "b" * 500], "card1", 0, 160) == [
["a" * 500, "b" * 500]
]
def test_fragments_are_grouped_up_to_the_budget(self):
chunks = greedy_pack(["a" * 40] * 4, "card1", 100, 0)
assert [len(c) for c in chunks] == [2, 2]
def test_oversized_single_block_gets_its_own_card(self, capsys):
chunks = greedy_pack(["x" * 300, "y"], "card1", 100, 0)
assert chunks[0] == ["x" * 300]
assert "over the 100-byte budget" in capsys.readouterr().err
def test_budget_counts_utf8_bytes_not_characters(self):
# Two 3-byte characters exceed a 4-byte budget even though len() is 2.
assert len(greedy_pack(["中", "文"], "card1", 4, 0)) == 2
def test_overflow_cards_are_chained(self, render):
markup = render(TWO_SECTIONS, max_card_bytes=60)
ids = card_ids(markup)
assert "menu-p2" in ids
assert '<go href="#menu-p2"/>' in markup
def test_menu_card_is_subject_to_the_budget(self, render):
# Regression: the menu was built directly and never packed, so a long
# preamble plus many choices could blow past the budget unnoticed.
long_preamble = "\n\n".join(f"Paragraph number {i} with filler." for i in range(12))
markup = render(f"{long_preamble}\n\n{TWO_SECTIONS}", max_card_bytes=200)
assert "menu-p2" in card_ids(markup)
def test_overflow_parts_use_the_prev_label(self, render):
markup = render(TWO_SECTIONS, max_card_bytes=60)
assert 'label="Prev"' in markup
class TestSlugify:
@pytest.mark.parametrize(
("text", "expected"),
[
("Trail Status", "trail-status"),
("Weather!", "weather"),
(" Spaced Out ", "spaced-out"),
("Ünicode", "nicode"),
],
)
def test_slugs(self, text, expected):
assert slugify(text, "fallback") == expected
def test_empty_falls_back(self):
assert slugify("", "card1") == "card1"
assert slugify(None, "card1") == "card1"
assert slugify("!!!", "card1") == "card1"