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>
This commit is contained in:
randogoth 2026-09-23 09:27:49 +03:00
commit b83e24e2f7
51 changed files with 4016 additions and 0 deletions

59
tests/conftest.py Normal file
View file

@ -0,0 +1,59 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
import pytest
from wapdown.cli import convert
from wapdown.config import DeckConfig
from wapdown.conversion.core import strip_frontmatter, read_lines
from wapdown.renderers.wml.renderer import DeckOutput
PROJECT_ROOT = Path(__file__).resolve().parent.parent
EXAMPLES = PROJECT_ROOT / "examples"
GOLDEN = Path(__file__).resolve().parent / "golden"
def render_source(text: str, **options: Any) -> str:
"""Render Markdown given as a string, returning the single deck's markup."""
lines = text.splitlines(keepends=True)
frontmatter, body = strip_frontmatter(lines)
config = DeckConfig.resolve(frontmatter, options)
return convert(body, config=config, base_path=EXAMPLES).text
def render_file(path: Path, **options: Any) -> DeckOutput:
lines = read_lines(path)
frontmatter, body = strip_frontmatter(lines)
config = DeckConfig.resolve(frontmatter, options)
return convert(body, config=config, base_path=path.parent)
@pytest.fixture
def render():
return render_source
@pytest.fixture
def render_output():
"""Like `render`, but exposes every output file (deck-per-card mode)."""
def _render(text: str, **options: Any) -> DeckOutput:
lines = text.splitlines(keepends=True)
frontmatter, body = strip_frontmatter(lines)
config = DeckConfig.resolve(frontmatter, options)
return convert(body, config=config, base_path=EXAMPLES)
return _render
@pytest.fixture(scope="session")
def example_decks() -> dict[str, str]:
"""Every examples/*.md rendered with its own frontmatter settings."""
return {
path.name: render_file(path).documents[0].text
for path in sorted(EXAMPLES.glob("*.md"))
}

4
tests/dtd/wml13.dtd Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,30 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
</template>
<card id="menu" title="Kitchen Sink">
<do type="prev"><noop/></do>
<p><b><big>Kitchen Sink</big></b></p>
<p>A deck exercising every block and inline construct wapdown understands.</p>
<p><select title="Kitchen Sink"><option onpick="#card1">Links and emphasis</option><option onpick="#card2">A table</option><option onpick="#card3">Other blocks</option></select></p>
</card>
<card id="card1" title="Links and emphasis">
<p><b><big>Links and emphasis</big></b></p>
<p>Tricky URL: <a href="https://example.com/docs/v1_2_3/notes.html">release notes</a> and a starred path <a href="https://ci.example.com/job/a*b*c/log">build log</a>.</p>
<p><b>Bold</b>, <i>italic</i>, inline code, struck, and a literal $$5 fee.</p>
</card>
<card id="card2" title="A table">
<p><b><big>A table</big></b></p>
<p><table columns="3"><tr><td><b>Trail</b></td><td><b>Status</b></td><td><b>Fee</b></td></tr><tr><td>North Loop</td><td>open</td><td>$$2</td></tr><tr><td>Summit Spur</td><td>icy</td><td>$$5</td></tr></table></p>
</card>
<card id="card3" title="Other blocks">
<p><b><big>Other blocks</big></b></p>
<p><i>A quoted warning.</i></p>
<p>1. First</p>
<p>2. Second</p>
<p>------------</p>
<p>indented code $$HOME</p>
</card>
</wml>

View file

@ -0,0 +1,12 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
<do type="options" label="Home"><go href="index.wml#menu"/></do>
</template>
<card id="card3" title="Contact">
<p>Ranger station: <a href="tel:+15555550123">call dispatch</a></p>
<p><img src="https://example.com/map.png" alt="Trail map"/></p>
</card>
</wml>

View file

@ -0,0 +1,16 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
<do type="options" label="Home"><go href="index.wml#menu"/></do>
</template>
<card id="menu">
<do type="prev"><noop/></do>
<do type="options"><noop/></do>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><a href="weather.wml#card1">Weather</a></p>
<p><a href="trail-status.wml#card2">Trail Status</a></p>
<p><a href="contact.wml#card3">Contact</a></p>
</card>
</wml>

View file

@ -0,0 +1,13 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
<do type="options" label="Home"><go href="index.wml#menu"/></do>
</template>
<card id="card2" title="Trail Status">
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
</wml>

View file

@ -0,0 +1,11 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
<do type="options" label="Home"><go href="index.wml#menu"/></do>
</template>
<card id="card1" title="Weather">
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
</wml>

View file

@ -0,0 +1,30 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<head>
<access domain="example.com"/>
<meta http-equiv="Cache-Control" content="max-age=0"/>
</head>
<template>
<do type="prev" label="Back"><prev/></do>
</template>
<card id="menu">
<do type="prev"><noop/></do>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><a href="#card1">Weather</a></p>
<p><a href="#card2">Trail Status</a></p>
<p><a href="#card3">Contact</a></p>
</card>
<card id="card1" title="Weather">
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
<card id="card2" title="Trail Status">
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<p>Ranger station: <a href="tel:+15555550123">call dispatch</a></p>
<p><img src="https://example.com/map.png" alt="Trail map"/></p>
</card>
</wml>

View file

@ -0,0 +1,28 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
<do type="options" label="Home"><go href="#menu"/></do>
</template>
<card id="menu">
<do type="prev"><noop/></do>
<do type="options"><noop/></do>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><a href="#card1">Weather</a></p>
<p><a href="#card2">Trail Status</a></p>
<p><a href="#card3">Contact</a></p>
</card>
<card id="card1" title="Weather">
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
<card id="card2" title="Trail Status">
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<p>Ranger station: <a href="tel:+15555550123">call dispatch</a></p>
<p><img src="https://example.com/map.png" alt="Trail map"/></p>
</card>
</wml>

View file

@ -0,0 +1,26 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
</template>
<card id="menu">
<do type="prev"><noop/></do>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><a href="#card1">Weather</a></p>
<p><a href="#card2">Trail Status</a></p>
<p><a href="#card3">Contact</a></p>
</card>
<card id="card1" title="Weather">
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
<card id="card2" title="Trail Status">
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<p>Ranger station: <a href="tel:+15555550123">call dispatch</a></p>
<p>Trail map</p>
</card>
</wml>

View file

@ -0,0 +1,27 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
</template>
<card id="card1" title="Trail Conditions">
<do type="prev"><noop/></do>
<do type="accept" label="More"><go href="#card2"/></do>
<p><b><big>Trail Conditions</big></b></p>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><b>Weather</b></p>
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
<card id="card2" title="Trail Status">
<do type="accept" label="More"><go href="#card3"/></do>
<p><b><big>Trail Status</big></b></p>
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<p><b><big>Contact</big></b></p>
<p>Ranger station: <a href="tel:+15555550123">call dispatch</a></p>
<p><img src="https://example.com/map.png" alt="Trail map"/></p>
</card>
</wml>

View file

@ -0,0 +1,26 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
</template>
<card id="menu">
<do type="prev"><noop/></do>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><a href="#card1">Weather</a></p>
<p><a href="#card2">Trail Status</a></p>
<p><a href="#card3">Contact</a></p>
</card>
<card id="card1" title="Weather">
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
<card id="card2" title="Trail Status">
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<p>Ranger station: <a href="tel:+15555550123">call dispatch</a></p>
<p><img src="https://example.com/map.png" alt="Trail map"/></p>
</card>
</wml>

View file

@ -0,0 +1,25 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<card id="menu">
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><a href="#card1">Weather</a></p>
<p><a href="#card2">Trail Status</a></p>
<p><a href="#card3">Contact</a></p>
</card>
<card id="card1" title="Weather">
<do type="prev" label="Back"><prev/></do>
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
<card id="card2" title="Trail Status">
<do type="prev" label="Back"><prev/></do>
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<do type="prev" label="Back"><prev/></do>
<p>Ranger station: <a href="tel:+15555550123">call dispatch</a></p>
<p><img src="https://example.com/map.png" alt="Trail map"/></p>
</card>
</wml>

View file

@ -0,0 +1,38 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
</template>
<card id="menu">
<do type="prev"><noop/></do>
<do type="accept" label="More"><go href="#menu-p2"/></do>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
</card>
<card id="menu-p2">
<do type="prev" label="Prev"><prev/></do>
<do type="accept" label="More"><go href="#menu-p3"/></do>
<p><a href="#card1">Weather</a></p>
<p><a href="#card2">Trail Status</a></p>
</card>
<card id="menu-p3">
<do type="prev" label="Prev"><prev/></do>
<p><a href="#card3">Contact</a></p>
</card>
<card id="card1" title="Weather">
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
<card id="card2" title="Trail Status">
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<do type="accept" label="More"><go href="#card3-p2"/></do>
<p>Ranger station: <a href="tel:+15555550123">call dispatch</a></p>
</card>
<card id="card3-p2">
<do type="prev" label="Prev"><prev/></do>
<p><img src="https://example.com/map.png" alt="Trail map"/></p>
</card>
</wml>

View file

@ -0,0 +1,24 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
</template>
<card id="menu">
<do type="prev"><noop/></do>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><select title="Menu"><option onpick="#card1">Weather</option><option onpick="#card2">Trail Status</option><option onpick="#card3">Contact</option></select></p>
</card>
<card id="card1" title="Weather">
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
<card id="card2" title="Trail Status">
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<p>Ranger station: <a href="tel:+15555550123">call dispatch</a></p>
<p><img src="https://example.com/map.png" alt="Trail map"/></p>
</card>
</wml>

View file

@ -0,0 +1,30 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
</template>
<card id="menu" title="Trail Conditions">
<do type="prev"><noop/></do>
<p><b><big>Trail Conditions</big></b></p>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><a href="#card1">Weather</a></p>
<p><a href="#card2">Trail Status</a></p>
<p><a href="#card3">Contact</a></p>
</card>
<card id="card1" title="Weather">
<p><b><big>Weather</big></b></p>
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
<card id="card2" title="Trail Status">
<p><b><big>Trail Status</big></b></p>
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<p><b><big>Contact</big></b></p>
<p>Ranger station: <a href="tel:+15555550123">call dispatch</a></p>
<p><img src="https://example.com/map.png" alt="Trail map"/></p>
</card>
</wml>

17
tests/golden/trail.wml Normal file
View file

@ -0,0 +1,17 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<card id="card1">
<p><b><big>Trail Conditions</big></b></p>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><b>Weather</b></p>
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
<p><b>Trail Status</b></p>
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
<p><b>Contact</b></p>
<p>Ranger station: <a href="tel:+15555550123">call dispatch</a></p>
<p><img src="https://example.com/map.png" alt="Trail map"/></p>
</card>
</wml>

29
tests/golden_cases.py Normal file
View file

@ -0,0 +1,29 @@
"""The set of (source, options) -> golden file cases.
Shared by `test_golden.py` and `regenerate_golden.py` so the suite and the
regeneration script can never disagree about what should exist.
"""
from __future__ import annotations
from typing import Any, Dict, Tuple
# name -> (example filename, renderer options)
CASES: Dict[str, Tuple[str, Dict[str, Any]]] = {
"trail": ("trail.md", {}),
"trail-split": ("trail.md", {"split_level": 2}),
"trail-linear": ("trail.md", {"split_level": 2, "menu": False}),
"trail-manual": ("trail-manual.md", {}),
"trail-select": ("trail-manual.md", {"menu_style": "select"}),
"trail-home": ("trail-manual.md", {"home_label": "Home"}),
"trail-no-template": ("trail-manual.md", {"template_nav": False}),
"trail-paginated": ("trail-manual.md", {"max_card_bytes": 120}),
"trail-images-alt": ("trail-manual.md", {"images": "alt"}),
"trail-head": ("trail-manual.md", {"cache_control": 0, "access_domain": "example.com"}),
"kitchen-sink": ("kitchen-sink.md", {}),
}
# Cases that produce several files; each is written to its own directory.
MULTI_CASES: Dict[str, Tuple[str, Dict[str, Any]]] = {
"trail-decks": ("trail-manual.md", {"deck_per_card": True, "home_label": "Home"}),
}

View file

@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Rewrite every golden file under tests/golden/.
Run after an intentional change to the renderer, then read the diff --
that diff is the change, stated in output rather than in code:
uv run python tests/regenerate_golden.py
jj diff tests/golden
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from conftest import EXAMPLES, GOLDEN, render_file # noqa: E402
from golden_cases import CASES, MULTI_CASES # noqa: E402
def main() -> int:
GOLDEN.mkdir(parents=True, exist_ok=True)
for name, (source, options) in CASES.items():
output = render_file(EXAMPLES / source, **options)
(GOLDEN / f"{name}.wml").write_text(output.text, encoding="utf-8")
print(f"wrote {name}.wml")
for name, (source, options) in MULTI_CASES.items():
directory = GOLDEN / name
if directory.exists():
shutil.rmtree(directory)
directory.mkdir(parents=True)
for document in render_file(EXAMPLES / source, **options).documents:
(directory / (document.name or "index.wml")).write_text(
document.text, encoding="utf-8"
)
print(f"wrote {name}/ ({len(list(directory.iterdir()))} files)")
return 0
if __name__ == "__main__":
raise SystemExit(main())

143
tests/test_cli.py Normal file
View file

@ -0,0 +1,143 @@
"""End-to-end behaviour of the `wapdown` command."""
from __future__ import annotations
import pytest
from conftest import EXAMPLES
from wapdown.cli import main
TWO_SECTIONS = "Intro.\n\n{.card One}\nFirst.\n\n{.card Two}\nSecond.\n"
@pytest.fixture
def source(tmp_path):
def _write(text: str = TWO_SECTIONS, name: str = "doc.md"):
path = tmp_path / name
path.write_text(text, encoding="utf-8")
return path
return _write
class TestOutput:
def test_writes_to_stdout_by_default(self, source, capsys):
assert main([str(source())]) == 0
assert capsys.readouterr().out.startswith('<?xml version="1.0"?>')
def test_writes_to_a_file(self, source, tmp_path):
out = tmp_path / "deck.wml"
assert main([str(source()), "-o", str(out)]) == 0
assert out.read_text(encoding="utf-8").endswith("</wml>\n")
def test_creates_missing_parent_directories(self, source, tmp_path):
out = tmp_path / "nested" / "deep" / "deck.wml"
assert main([str(source()), "-o", str(out)]) == 0
assert out.exists()
def test_uses_lf_line_endings(self, source, tmp_path):
out = tmp_path / "deck.wml"
main([str(source()), "-o", str(out)])
assert b"\r" not in out.read_bytes()
def test_deck_per_card_writes_a_directory(self, source, tmp_path):
out = tmp_path / "decks"
assert main([str(source()), "--deck-per-card", "-o", str(out)]) == 0
assert sorted(p.name for p in out.iterdir()) == [
"index.wml",
"one.wml",
"two.wml",
]
def test_deck_per_card_without_output_is_refused(self, source):
# Several files cannot be written to stdout, so say so rather than
# silently emitting only one of them.
with pytest.raises(SystemExit, match="pass -o DIRECTORY"):
main([str(source()), "--deck-per-card"])
class TestOptions:
def test_flags_reach_the_renderer(self, source, capsys):
main([str(source()), "--menu-style", "select", "--title", "Deck"])
out = capsys.readouterr().out
assert "<select" in out
assert 'title="Deck"' in out
def test_flag_overrides_frontmatter(self, source, capsys):
path = source("---\ntitle: FromFile\n---\n" + TWO_SECTIONS)
main([str(path), "--title", "FromFlag"])
assert 'title="FromFlag"' in capsys.readouterr().out
def test_frontmatter_applies_without_a_flag(self, source, capsys):
path = source("---\nmenu_style: select\n---\n" + TWO_SECTIONS)
main([str(path)])
assert "<select" in capsys.readouterr().out
def test_no_menu_switches_to_linear(self, source, capsys):
main([str(source()), "--no-menu"])
assert '<card id="menu"' not in capsys.readouterr().out
def test_frontmatter_is_not_rendered_as_content(self, source, capsys):
path = source("---\ntitle: Deck\n---\n" + TWO_SECTIONS)
main([str(path)])
assert "title: Deck" not in capsys.readouterr().out
class TestErrors:
def test_missing_input_file(self, tmp_path, capsys):
assert main([str(tmp_path / "nope.md")]) == 2
assert "wapdown:" in capsys.readouterr().err
def test_invalid_option_value(self, source, capsys):
assert main([str(source()), "--split-level", "9"]) == 2
assert "split_level" in capsys.readouterr().err
def test_invalid_frontmatter_value(self, source, capsys):
path = source("---\nsplit_level: deep\n---\n" + TWO_SECTIONS)
assert main([str(path)]) == 2
assert "must be a number" in capsys.readouterr().err
def test_unknown_menu_style_is_rejected_by_argparse(self, source):
with pytest.raises(SystemExit) as exc:
main([str(source()), "--menu-style", "carousel"])
assert exc.value.code == 2
def test_missing_include_is_reported(self, source, capsys):
path = source("Intro.\n\n{.include nowhere.md}\n")
assert main([str(path)]) == 2
assert "was not found" in capsys.readouterr().err
class TestIncludes:
def test_included_file_contributes_content(self, tmp_path, capsys):
(tmp_path / "part.md").write_text("Included body.\n", encoding="utf-8")
main([str(_write(tmp_path, "Intro.\n\n{.include part.md}\n"))])
assert "Included body." in capsys.readouterr().out
def test_included_frontmatter_is_discarded(self, tmp_path, capsys):
(tmp_path / "part.md").write_text(
"---\ntitle: Ignored\n---\nIncluded body.\n", encoding="utf-8"
)
main([str(_write(tmp_path, "Intro.\n\n{.include part.md}\n"))])
out = capsys.readouterr().out
assert "Included body." in out
assert "Ignored" not in out
def test_circular_include_is_reported(self, tmp_path, capsys):
(tmp_path / "a.md").write_text("{.include doc.md}\n", encoding="utf-8")
assert main([str(_write(tmp_path, "{.include a.md}\n"))]) == 2
assert "Circular include" in capsys.readouterr().err
def _write(directory, text: str, name: str = "doc.md"):
path = directory / name
path.write_text(text, encoding="utf-8")
return path
class TestExamples:
@pytest.mark.parametrize(
"name", sorted(p.name for p in EXAMPLES.glob("*.md"))
)
def test_every_example_converts(self, name, tmp_path, capsys):
assert main([str(EXAMPLES / name), "-o", str(tmp_path / "out.wml")]) == 0

84
tests/test_config.py Normal file
View file

@ -0,0 +1,84 @@
"""Configuration layering: CLI flag > frontmatter > default."""
from __future__ import annotations
import pytest
from wapdown.config import DEFAULT_MAX_CARD_BYTES, ConfigError, DeckConfig
class TestPrecedence:
def test_defaults_when_nothing_is_given(self):
config = DeckConfig.resolve()
assert config.split_level == 0
assert config.max_card_bytes == DEFAULT_MAX_CARD_BYTES
assert config.menu is True
assert config.menu_style == "links"
assert config.title is None
def test_frontmatter_beats_default(self):
config = DeckConfig.resolve({"split_level": "2", "title": "Trail"})
assert config.split_level == 2
assert config.title == "Trail"
def test_cli_beats_frontmatter(self):
config = DeckConfig.resolve({"split_level": "2"}, {"split_level": 3})
assert config.split_level == 3
def test_absent_cli_option_does_not_clobber_frontmatter(self):
# argparse hands every unset option through as None; that must mean
# "not given" rather than "set to nothing".
config = DeckConfig.resolve({"title": "Trail"}, {"title": None, "menu": None})
assert config.title == "Trail"
assert config.menu is True
def test_unknown_frontmatter_key_warns_but_does_not_fail(self, capsys):
config = DeckConfig.resolve({"hyphenate": "true", "title": "T"})
assert config.title == "T"
assert "unknown frontmatter key 'hyphenate'" in capsys.readouterr().err
class TestCoercion:
@pytest.mark.parametrize("raw", ["true", "yes", "1", "on", "TRUE"])
def test_truthy_strings(self, raw):
assert DeckConfig.resolve({"menu": raw}).menu is True
@pytest.mark.parametrize("raw", ["false", "no", "0", "off", "FALSE"])
def test_falsy_strings(self, raw):
assert DeckConfig.resolve({"menu": raw}).menu is False
def test_menu_style_is_case_insensitive(self):
assert DeckConfig.resolve({"menu_style": "SELECT"}).menu_style == "select"
def test_hyphenated_frontmatter_key_is_accepted(self):
assert DeckConfig.resolve({"split-level": "2"}).split_level == 2
def test_empty_title_becomes_none(self):
assert DeckConfig.resolve({"title": ""}).title is None
class TestValidation:
@pytest.mark.parametrize("value", [-1, 7])
def test_split_level_out_of_range(self, value):
with pytest.raises(ConfigError, match="split_level"):
DeckConfig.resolve(overrides={"split_level": value})
def test_negative_byte_budget(self):
with pytest.raises(ConfigError, match="max_card_bytes"):
DeckConfig.resolve(overrides={"max_card_bytes": -1})
def test_unknown_menu_style(self):
with pytest.raises(ConfigError, match="menu_style"):
DeckConfig.resolve(overrides={"menu_style": "carousel"})
def test_unknown_image_policy(self):
with pytest.raises(ConfigError, match="images"):
DeckConfig.resolve(overrides={"images": "wbmp"})
def test_non_numeric_split_level_in_frontmatter(self):
# Previously an uncaught ValueError escaping as a traceback.
with pytest.raises(ConfigError, match="split_level must be a number"):
DeckConfig.resolve({"split_level": "deep"})
def test_non_boolean_menu_in_frontmatter(self):
with pytest.raises(ConfigError, match="menu must be a boolean"):
DeckConfig.resolve({"menu": "sometimes"})

213
tests/test_deck.py Normal file
View file

@ -0,0 +1,213 @@
"""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"

55
tests/test_golden.py Normal file
View file

@ -0,0 +1,55 @@
"""Byte-for-byte comparison against committed reference output.
These catch unintended drift in whole-document output, which unit tests on
individual constructs cannot. When a change is deliberate, regenerate with
`uv run python tests/regenerate_golden.py` and review the resulting diff.
"""
from __future__ import annotations
from xml.etree import ElementTree
import pytest
from conftest import EXAMPLES, GOLDEN, render_file
from golden_cases import CASES, MULTI_CASES
REGENERATE_HINT = (
"Output changed. If that was intended, run "
"`uv run python tests/regenerate_golden.py` and review the diff."
)
@pytest.mark.parametrize("name", sorted(CASES))
def test_matches_golden(name):
source, options = CASES[name]
golden = GOLDEN / f"{name}.wml"
assert golden.exists(), f"missing golden for '{name}'; {REGENERATE_HINT}"
actual = render_file(EXAMPLES / source, **options).text
assert actual == golden.read_text(encoding="utf-8"), REGENERATE_HINT
@pytest.mark.parametrize("name", sorted(MULTI_CASES))
def test_matches_golden_directory(name):
source, options = MULTI_CASES[name]
directory = GOLDEN / name
assert directory.is_dir(), f"missing golden dir for '{name}'; {REGENERATE_HINT}"
documents = {
doc.name or "index.wml": doc.text
for doc in render_file(EXAMPLES / source, **options).documents
}
expected = {path.name: path.read_text(encoding="utf-8") for path in directory.iterdir()}
assert sorted(documents) == sorted(expected), REGENERATE_HINT
for filename, text in documents.items():
assert text == expected[filename], f"{filename}: {REGENERATE_HINT}"
@pytest.mark.parametrize("path", sorted(GOLDEN.glob("**/*.wml")), ids=lambda p: p.name)
def test_golden_files_are_well_formed(path):
ElementTree.fromstring(path.read_text(encoding="utf-8"))
def test_every_example_has_a_case():
"""A new examples/*.md without a golden case would go unverified."""
covered = {source for source, _ in list(CASES.values()) + list(MULTI_CASES.values())}
assert {path.name for path in EXAMPLES.glob("*.md")} == covered

94
tests/test_inline.py Normal file
View file

@ -0,0 +1,94 @@
"""Inline markup mapping and WML's escaping rules."""
from __future__ import annotations
import pytest
from wapdown.renderers.wml.inline import escape_wml, process_inline
class TestEscaping:
def test_xml_significant_characters(self):
assert escape_wml('a & b < c > d "e"') == "a &amp; b &lt; c &gt; d &quot;e&quot;"
def test_dollar_is_doubled(self):
# WML uses $ to introduce a variable substitution, so a literal $
# must be written $$ or the device eats the following word.
assert escape_wml("Permit fee: $5") == "Permit fee: $$5"
def test_ampersand_escaped_before_other_entities(self):
assert escape_wml("<") == "&lt;"
assert "&amp;lt;" not in escape_wml("a & b")
class TestEmphasis:
@pytest.mark.parametrize(
("source", "expected"),
[
("**bold**", "<b>bold</b>"),
("__bold__", "<b>bold</b>"),
("*italic*", "<i>italic</i>"),
("_italic_", "<i>italic</i>"),
("~~struck~~", "struck"), # no WML equivalent, markers dropped
("`code`", "code"),
],
)
def test_mapping(self, source, expected):
assert process_inline(source) == expected
def test_code_span_contents_are_not_reinterpreted(self):
assert process_inline("`a *b* c`") == "a *b* c"
class TestLinks:
def test_basic_link(self):
assert process_inline("[call](tel:+15555550123)") == (
'<a href="tel:+15555550123">call</a>'
)
def test_underscores_in_url_survive(self):
# Regression: emphasis patterns used to run before LINK_RE, so
# UNDERLINE_EM_RE shredded `v1_2_3` into `v1<i>2</i>3` inside the href.
result = process_inline("[notes](https://example.com/v1_2_3/x.html)")
assert result == '<a href="https://example.com/v1_2_3/x.html">notes</a>'
assert "<i>" not in result
def test_asterisks_in_url_survive(self):
result = process_inline("[log](https://ci.example.com/job/a*b*c/log)")
assert "a*b*c" in result
assert "<i>" not in result
def test_label_emphasis_is_stripped_not_translated(self):
# WML 1.3 declares <a> as (#PCDATA | br | img)*: there is nowhere
# valid to put a <b> inside a link label.
assert process_inline("[**go** now](http://x.test/)") == (
'<a href="http://x.test/">go now</a>'
)
def test_url_is_escaped_for_attribute_use(self):
assert process_inline("[q](http://x.test/?a=1&b=2)") == (
'<a href="http://x.test/?a=1&amp;b=2">q</a>'
)
class TestImages:
def test_keep_is_the_default(self):
assert process_inline("![Map](map.png)") == '<img src="map.png" alt="Map"/>'
def test_alt_policy_replaces_non_wbmp(self, capsys):
assert process_inline("![Map](map.png)", images="alt") == "Map"
assert "non-WBMP" in capsys.readouterr().err
def test_drop_policy_removes_non_wbmp(self, capsys):
assert process_inline("![Map](map.png)", images="drop") == ""
assert "dropped" in capsys.readouterr().err
@pytest.mark.parametrize("policy", ["alt", "drop"])
def test_wbmp_always_survives(self, policy):
result = process_inline("![Icon](icon.wbmp)", images=policy)
assert result == '<img src="icon.wbmp" alt="Icon"/>'
def test_wbmp_detected_past_a_query_string(self):
assert '<img' in process_inline("![I](i.wbmp?v=2)", images="drop")
def test_image_is_not_parsed_as_a_link(self):
assert process_inline("![Map](map.png)").startswith("<img")

115
tests/test_validity.py Normal file
View file

@ -0,0 +1,115 @@
"""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) == []

178
tests/test_wml.py Normal file
View file

@ -0,0 +1,178 @@
"""Deck-level WML emission: prolog, head, block mapping, and validity."""
from __future__ import annotations
import re
from xml.etree import ElementTree
import pytest
from wapdown.renderers.wml.renderer import WML_PROLOG
TWO_SECTIONS = "Intro.\n\n{.card One}\nFirst.\n\n{.card Two}\nSecond.\n"
def parse(markup: str) -> ElementTree.Element:
"""Parse the deck, proving it is well-formed XML.
ElementTree honours the DOCTYPE declaration without trying to fetch the
external DTD, which matters because wapforum.org has been gone for years.
"""
return ElementTree.fromstring(markup)
class TestDocumentShape:
def test_prolog_and_doctype(self, render):
markup = render("Body.\n")
assert markup.startswith(WML_PROLOG)
assert 'PUBLIC "-//WAPFORUM//DTD WML 1.3//EN"' in markup
def test_root_element_is_wml(self, render):
assert parse(render("Body.\n")).tag == "wml"
def test_output_is_well_formed(self, render):
parse(render(TWO_SECTIONS))
def test_no_carriage_returns(self, render):
# WML is XML served over HTTP, not a DOS text file.
assert "\r" not in render(TWO_SECTIONS)
def test_ends_with_a_single_newline(self, render):
markup = render("Body.\n")
assert markup.endswith("</wml>\n")
class TestCardContentModel:
"""WML 1.3 declares <card> as (onevent*, timer?, (do | p)*)."""
def test_card_children_are_only_do_and_p(self, render):
for card in parse(render(TWO_SECTIONS)).findall("card"):
assert {child.tag for child in card} <= {"do", "p"}
def test_table_is_wrapped_in_a_paragraph(self, render):
# Regression: <table> was emitted as a direct child of <card>, which
# the DTD does not permit -- it is flow content and belongs in a <p>.
markup = render("| A | B |\n| --- | --- |\n| 1 | 2 |\n")
assert "<p><table" in markup
card = parse(markup).find("card")
assert [child.tag for child in card] == ["p"]
assert card.find("p/table") is not None
def test_do_elements_precede_content(self, render):
card = parse(render(TWO_SECTIONS, menu=False)).find("card")
tags = [child.tag for child in card]
assert tags.index("do") < tags.index("p")
class TestBlockMapping:
@pytest.mark.parametrize(
("source", "expected"),
[
("Just text.\n", "<p>Just text.</p>"),
("- item\n", "<p>- item</p>"),
("> quoted\n", "<p><i>quoted</i></p>"),
("---\n", "<p>------------</p>"),
],
)
def test_blocks(self, render, source, expected):
assert expected in render(source)
def test_first_heading_is_emphasised_larger(self, render):
markup = render("# Title\n\n## Sub\n")
assert "<p><b><big>Title</big></b></p>" in markup
assert "<p><b>Sub</b></p>" in markup
def test_ordered_list_is_numbered_manually(self, render):
# WML has no list element, so the numbers become literal text.
markup = render("1. first\n2. second\n")
assert "<p>1. first</p>" in markup
assert "<p>2. second</p>" in markup
def test_ordered_list_numbering_restarts_after_a_paragraph(self, render):
markup = render("1. a\n\nBreak.\n\n1. b\n")
assert markup.count("<p>1. ") == 2
def test_code_block_lines_join_with_breaks(self, render):
markup = render("```\none\ntwo\n```\n")
assert "<p>one<br/>two</p>" in markup
def test_code_block_content_is_escaped(self, render):
assert "&lt;wml&gt;" in render("```\n<wml>\n```\n")
def test_table_header_row_is_bold(self, render):
markup = render("| A | B |\n| --- | --- |\n| 1 | 2 |\n")
assert "<td><b>A</b></td>" in markup
assert "<td>1</td>" in markup
def test_ragged_table_rows_are_padded(self, render):
markup = render("| A | B | C |\n| --- | --- | --- |\n| 1 |\n")
assert '<table columns="3">' in markup
assert markup.count("<td>") % 3 == 0
class TestHead:
def test_no_head_by_default(self, render):
assert "<head>" not in render("Body.\n")
def test_cache_control(self, render):
markup = render("Body.\n", cache_control=0)
assert '<meta http-equiv="Cache-Control" content="max-age=0"/>' in markup
parse(markup)
def test_access_control(self, render):
markup = render("Body.\n", access_domain="example.com", access_path="/wap")
assert '<access domain="example.com" path="/wap"/>' in markup
parse(markup)
def test_head_precedes_template_and_cards(self, render):
markup = render(TWO_SECTIONS, cache_control=60)
assert [child.tag for child in parse(markup)][:2] == ["head", "template"]
class TestTitles:
def test_explicit_title_reaches_the_menu_card(self, render):
assert '<card id="menu" title="Deck">' in render(TWO_SECTIONS, title="Deck")
def test_single_card_is_untitled_without_an_explicit_title(self, render):
# The document's own heading already renders into the card body.
assert '<card id="card1">' in render("# Heading\n\nBody.\n")
def test_title_is_escaped(self, render):
markup = render(TWO_SECTIONS, title='Fish & "Chips"')
assert 'title="Fish &amp; &quot;Chips&quot;"' in markup
parse(markup)
def test_dollar_in_a_title_is_doubled(self, render):
assert 'title="$$5"' in render(TWO_SECTIONS, title="$5")
class TestMultiDeck:
def test_one_file_per_section(self, render_output):
output = render_output(TWO_SECTIONS, deck_per_card=True)
assert sorted(doc.name for doc in output.documents) == [
"index.wml",
"one.wml",
"two.wml",
]
def test_menu_links_across_files(self, render_output):
docs = {d.name: d.text for d in render_output(TWO_SECTIONS, deck_per_card=True).documents}
assert '<a href="one.wml#card1">One</a>' in docs["index.wml"]
def test_links_within_the_same_file_stay_local(self, render_output):
docs = {d.name: d.text for d in render_output(TWO_SECTIONS, deck_per_card=True).documents}
assert "index.wml#" not in docs["one.wml"].split("</template>")[-1]
def test_every_file_is_well_formed(self, render_output):
for doc in render_output(TWO_SECTIONS, deck_per_card=True).documents:
parse(doc.text)
def test_single_section_document_stays_one_file(self, render_output):
output = render_output("# Title\n\nBody.\n", deck_per_card=True)
assert len(output.documents) == 1
assert output.documents[0].name is None
def test_duplicate_section_titles_get_distinct_filenames(self, render_output):
source = "Intro.\n\n{.card Same}\nA.\n\n{.card Same}\nB.\n"
names = [d.name for d in render_output(source, deck_per_card=True).documents]
assert len(set(names)) == len(names)