wapdown/tests/test_cli.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

143 lines
5.3 KiB
Python

"""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