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:
commit
b83e24e2f7
51 changed files with 4016 additions and 0 deletions
59
tests/conftest.py
Normal file
59
tests/conftest.py
Normal 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
4
tests/dtd/wml13.dtd
Normal file
File diff suppressed because one or more lines are too long
30
tests/golden/kitchen-sink.wml
Normal file
30
tests/golden/kitchen-sink.wml
Normal 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>
|
||||
12
tests/golden/trail-decks/contact.wml
Normal file
12
tests/golden/trail-decks/contact.wml
Normal 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>
|
||||
16
tests/golden/trail-decks/index.wml
Normal file
16
tests/golden/trail-decks/index.wml
Normal 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>
|
||||
13
tests/golden/trail-decks/trail-status.wml
Normal file
13
tests/golden/trail-decks/trail-status.wml
Normal 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>
|
||||
11
tests/golden/trail-decks/weather.wml
Normal file
11
tests/golden/trail-decks/weather.wml
Normal 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>
|
||||
30
tests/golden/trail-head.wml
Normal file
30
tests/golden/trail-head.wml
Normal 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>
|
||||
28
tests/golden/trail-home.wml
Normal file
28
tests/golden/trail-home.wml
Normal 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>
|
||||
26
tests/golden/trail-images-alt.wml
Normal file
26
tests/golden/trail-images-alt.wml
Normal 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>
|
||||
27
tests/golden/trail-linear.wml
Normal file
27
tests/golden/trail-linear.wml
Normal 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>
|
||||
26
tests/golden/trail-manual.wml
Normal file
26
tests/golden/trail-manual.wml
Normal 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>
|
||||
25
tests/golden/trail-no-template.wml
Normal file
25
tests/golden/trail-no-template.wml
Normal 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>
|
||||
38
tests/golden/trail-paginated.wml
Normal file
38
tests/golden/trail-paginated.wml
Normal 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>
|
||||
24
tests/golden/trail-select.wml
Normal file
24
tests/golden/trail-select.wml
Normal 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>
|
||||
30
tests/golden/trail-split.wml
Normal file
30
tests/golden/trail-split.wml
Normal 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
17
tests/golden/trail.wml
Normal 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
29
tests/golden_cases.py
Normal 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"}),
|
||||
}
|
||||
44
tests/regenerate_golden.py
Normal file
44
tests/regenerate_golden.py
Normal 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
143
tests/test_cli.py
Normal 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
84
tests/test_config.py
Normal 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
213
tests/test_deck.py
Normal 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
55
tests/test_golden.py
Normal 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
94
tests/test_inline.py
Normal 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 & b < c > d "e""
|
||||
|
||||
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("<") == "<"
|
||||
assert "&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&b=2">q</a>'
|
||||
)
|
||||
|
||||
|
||||
class TestImages:
|
||||
def test_keep_is_the_default(self):
|
||||
assert process_inline("") == '<img src="map.png" alt="Map"/>'
|
||||
|
||||
def test_alt_policy_replaces_non_wbmp(self, capsys):
|
||||
assert process_inline("", images="alt") == "Map"
|
||||
assert "non-WBMP" in capsys.readouterr().err
|
||||
|
||||
def test_drop_policy_removes_non_wbmp(self, capsys):
|
||||
assert process_inline("", images="drop") == ""
|
||||
assert "dropped" in capsys.readouterr().err
|
||||
|
||||
@pytest.mark.parametrize("policy", ["alt", "drop"])
|
||||
def test_wbmp_always_survives(self, policy):
|
||||
result = process_inline("", images=policy)
|
||||
assert result == '<img src="icon.wbmp" alt="Icon"/>'
|
||||
|
||||
def test_wbmp_detected_past_a_query_string(self):
|
||||
assert '<img' in process_inline("", images="drop")
|
||||
|
||||
def test_image_is_not_parsed_as_a_link(self):
|
||||
assert process_inline("").startswith("<img")
|
||||
115
tests/test_validity.py
Normal file
115
tests/test_validity.py
Normal 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": "\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
178
tests/test_wml.py
Normal 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 "<wml>" 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 & "Chips""' 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue