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

84 lines
3.4 KiB
Python

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