wapdown/tests/test_deck.py
randogoth 3b728d9d74 Make --- divide cards, and stop drawing ASCII rules
WML has no horizontal rule element -- its entire %layout entity is
`<br>` -- so a thematic break was being rendered as `<p>------------</p>`,
a hardcoded twelve-dash guess at screen width that wraps into nonsense on
a narrow device and is short on a wide one. It was the one construct
where the renderer invented a width out of nothing.

A `---` already looks like a divider and reads as one in every Markdown
document ever written, so it now is one: it starts a new card, by
default. Rules are never drawn, whether or not they divide.

Thematic breaks and `{.card Title}` markers are now a single mechanism --
an explicit divider, optionally carrying a title -- and compose freely in
one document. Both still win over --split-level. A section takes its
title from its own first heading when the divider does not supply one, so
the common shape needs no titles at all:

    Intro on the menu.

    ---

    ## Weather

    Cold and clear.

`--no-split-on-rule` / `split_on_rule: false` opts out of the splitting;
it does not bring the dashes back.

Three edges this opened, each fixed here rather than left to bite:

- Setext headings. `Heading` over `---` is an H2 in Markdown, but the
  parser was ATX-only, so it produced a paragraph plus a rule. Once a
  rule divides cards, a setext document would have split at every heading
  and stranded each heading as body copy on the card before it. Setext
  headings are now parsed, so such a document keeps its structure.
- Empty sections. A trailing `---`, or two in a row, produced an empty
  "Untitled" card and a menu entry leading to it. Sections with no
  renderable content are dropped.
- The multi-card threshold was two sections, so a document with a single
  divider collapsed back into one card. Any explicit divider now makes a
  multi-card deck: `A --- B` asks for two screens.

Also fixes a latent frontmatter bug this made far more likely: a document
opening with `---` and no `key: value` lines had its opening swallowed as
if the break were a frontmatter fence. A fenced block with no keys in it
is not frontmatter.

222 tests. Rendering examples/trail.md still matches md2txt's committed
output byte for byte; no golden file contains an ASCII rule any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 09:31:51 +03:00

318 lines
11 KiB
Python

"""Document topology: sectioning, byte packing, and navigation wiring."""
from __future__ import annotations
import re
import pytest
from wapdown.renderers.wml.deck import greedy_pack, slugify
TWO_SECTIONS = """\
Intro text.
{.card Weather}
Cold and clear.
{.card Status}
North Loop open.
"""
THREE_HEADINGS = """\
# Title
Intro text.
## Alpha
First section.
## Beta
Second section.
## Gamma
Third section.
"""
def card_ids(markup: str) -> list[str]:
return re.findall(r'<card id="([^"]+)"', markup)
def cards(markup: str) -> dict[str, str]:
"""Map each card id to that card's markup."""
chunks = re.split(r"(?=<card id=)", markup)
out = {}
for chunk in chunks:
match = re.match(r'<card id="([^"]+)"', chunk)
if match:
out[match.group(1)] = chunk.split("</card>")[0]
return out
class TestSectioning:
def test_plain_document_stays_one_card(self, render):
markup = render("# Title\n\nJust a paragraph.\n")
assert card_ids(markup) == ["card1"]
def test_headings_alone_do_not_split(self, render):
# Splitting is opt-in: a document full of headings is still one card
# until split_level says otherwise.
assert card_ids(render(THREE_HEADINGS)) == ["card1"]
def test_split_level_splits_at_that_heading(self, render):
markup = render(THREE_HEADINGS, split_level=2)
assert card_ids(markup) == ["menu", "card1", "card2", "card3"]
def test_split_level_that_matches_nothing_stays_one_card(self, render):
assert card_ids(render(THREE_HEADINGS, split_level=5)) == ["card1"]
def test_card_markers_split(self, render):
assert card_ids(render(TWO_SECTIONS)) == ["menu", "card1", "card2"]
def test_card_markers_win_over_split_level(self, render):
# An explicit boundary in the document is a stronger statement of
# intent than a structural rule passed on the command line.
markup = render(
"Intro.\n\n{.card One}\n## Heading\n\nBody.\n\n{.card Two}\nMore.\n",
split_level=2,
)
assert card_ids(markup) == ["menu", "card1", "card2"]
def test_marker_title_is_used(self, render):
assert '<card id="card1" title="Weather">' in render(TWO_SECTIONS)
def test_untitled_marker_falls_back_to_first_heading(self, render):
markup = render("Intro.\n\n{.card}\n## Weather\n\nCold.\n\n{.card Two}\nx\n")
assert '<card id="card1" title="Weather">' in markup
def test_marker_without_any_heading_is_untitled(self, render):
markup = render("Intro.\n\n{.card}\nCold.\n\n{.card Two}\nx\n")
assert '<card id="card1" title="Untitled">' in markup
def test_preamble_lands_on_the_menu_card(self, render):
assert "Intro text." in cards(render(TWO_SECTIONS))["menu"]
class TestHubAndSpoke:
def test_menu_links_to_every_section(self, render):
menu = cards(render(TWO_SECTIONS))["menu"]
assert '<a href="#card1">Weather</a>' in menu
assert '<a href="#card2">Status</a>' in menu
def test_select_style_menu(self, render):
menu = cards(render(TWO_SECTIONS, menu_style="select"))["menu"]
assert '<select title="Menu">' in menu
assert '<option onpick="#card1">Weather</option>' in menu
assert "<a href=" not in menu
def test_spokes_have_no_forward_link(self, render):
# In hub-and-spoke the only way onward is back through the menu.
spoke = cards(render(TWO_SECTIONS))["card1"]
assert 'type="accept"' not in spoke
def test_menu_card_has_no_back_control(self, render):
assert '<do type="prev"><noop/></do>' in cards(render(TWO_SECTIONS))["menu"]
def test_home_softkey_is_opt_in(self, render):
assert 'type="options"' not in render(TWO_SECTIONS)
assert 'type="options"' in render(TWO_SECTIONS, home_label="Home")
class TestLinear:
def test_cards_chain_forward(self, render):
markup = render(TWO_SECTIONS, menu=False)
assert card_ids(markup) == ["card1", "card2"]
assert '<do type="accept" label="More"><go href="#card2"/></do>' in markup
def test_last_card_has_no_forward_link(self, render):
assert 'type="accept"' not in cards(render(TWO_SECTIONS, menu=False))["card2"]
def test_first_card_carries_the_preamble(self, render):
assert "Intro text." in cards(render(TWO_SECTIONS, menu=False))["card1"]
def test_custom_nav_labels(self, render):
markup = render(TWO_SECTIONS, menu=False, nav_next_label="Onward")
assert 'label="Onward"' in markup
class TestTemplateNav:
def test_shared_back_control_is_hoisted(self, render):
markup = render(TWO_SECTIONS)
assert markup.count('<do type="prev" label="Back"><prev/></do>') == 1
assert "<template>" in markup
def test_disabling_repeats_nav_per_card(self, render):
markup = render(TWO_SECTIONS, template_nav=False)
assert "<template>" not in markup
assert markup.count('<do type="prev" label="Back"><prev/></do>') == 2
def test_single_card_deck_needs_no_template(self, render):
assert "<template>" not in render("# Title\n\nBody.\n")
def test_template_precedes_the_first_card(self, render):
markup = render(TWO_SECTIONS)
assert markup.index("<template>") < markup.index("<card id=")
class TestPacking:
def test_budget_of_zero_disables_packing(self):
assert greedy_pack(["a" * 500, "b" * 500], "card1", 0, 160) == [
["a" * 500, "b" * 500]
]
def test_fragments_are_grouped_up_to_the_budget(self):
chunks = greedy_pack(["a" * 40] * 4, "card1", 100, 0)
assert [len(c) for c in chunks] == [2, 2]
def test_oversized_single_block_gets_its_own_card(self, capsys):
chunks = greedy_pack(["x" * 300, "y"], "card1", 100, 0)
assert chunks[0] == ["x" * 300]
assert "over the 100-byte budget" in capsys.readouterr().err
def test_budget_counts_utf8_bytes_not_characters(self):
# Two 3-byte characters exceed a 4-byte budget even though len() is 2.
assert len(greedy_pack(["中", "文"], "card1", 4, 0)) == 2
def test_overflow_cards_are_chained(self, render):
markup = render(TWO_SECTIONS, max_card_bytes=60)
ids = card_ids(markup)
assert "menu-p2" in ids
assert '<go href="#menu-p2"/>' in markup
def test_menu_card_is_subject_to_the_budget(self, render):
# Regression: the menu was built directly and never packed, so a long
# preamble plus many choices could blow past the budget unnoticed.
long_preamble = "\n\n".join(f"Paragraph number {i} with filler." for i in range(12))
markup = render(f"{long_preamble}\n\n{TWO_SECTIONS}", max_card_bytes=200)
assert "menu-p2" in card_ids(markup)
def test_overflow_parts_use_the_prev_label(self, render):
markup = render(TWO_SECTIONS, max_card_bytes=60)
assert 'label="Prev"' in markup
class TestSlugify:
@pytest.mark.parametrize(
("text", "expected"),
[
("Trail Status", "trail-status"),
("Weather!", "weather"),
(" Spaced Out ", "spaced-out"),
("Ünicode", "nicode"),
],
)
def test_slugs(self, text, expected):
assert slugify(text, "fallback") == expected
def test_empty_falls_back(self):
assert slugify("", "card1") == "card1"
assert slugify(None, "card1") == "card1"
assert slugify("!!!", "card1") == "card1"
TWO_BY_RULE = """\
Intro text.
---
## Weather
Cold and clear.
---
## Status
North Loop open.
"""
class TestRuleDividers:
"""A thematic break divides cards; WML has no rule element to draw."""
def test_rules_divide_by_default(self, render):
assert card_ids(render(TWO_BY_RULE)) == ["menu", "card1", "card2"]
def test_sections_take_their_own_heading_as_title(self, render):
# A rule carries no title, so the section's first heading supplies it.
markup = render(TWO_BY_RULE)
assert '<card id="card1" title="Weather">' in markup
assert '<card id="card2" title="Status">' in markup
def test_a_single_rule_makes_two_cards(self, render):
# Regression: the multi-card threshold used to be two sections, so
# one divider collapsed back into a single card.
assert card_ids(render("Intro.\n\n---\n\n## Alpha\n\nBody.\n")) == [
"menu",
"card1",
]
def test_no_rule_is_ever_drawn(self, render):
assert "------" not in render(TWO_BY_RULE)
def test_disabling_keeps_one_card_and_still_draws_nothing(self, render):
markup = render(TWO_BY_RULE, split_on_rule=False)
assert card_ids(markup) == ["card1"]
assert "------" not in markup
@pytest.mark.parametrize("glyph", ["---", "***", "___", "- - -", "*****"])
def test_every_thematic_break_glyph_divides(self, render, glyph):
markup = render(f"Intro.\n\n{glyph}\n\n## Alpha\n\nBody.\n")
assert card_ids(markup) == ["menu", "card1"]
def test_rules_and_card_markers_compose(self, render):
markup = render("Intro.\n\n---\n## A\nx\n\n{.card Named}\ny\n")
assert card_ids(markup) == ["menu", "card1", "card2"]
assert '<card id="card2" title="Named">' in markup
def test_explicit_dividers_win_over_split_level(self, render):
markup = render("Intro.\n\n---\n\n## A\n\n## B\n", split_level=2)
assert card_ids(markup) == ["menu", "card1"]
class TestEmptySections:
def test_trailing_rule_adds_no_empty_card(self, render):
# Ending a document with `---` is common and must not produce an
# "Untitled" card with a menu entry leading nowhere.
markup = render("Intro.\n\n---\n\n## A\n\nBody.\n\n---\n")
assert card_ids(markup) == ["menu", "card1"]
assert "Untitled" not in markup
def test_consecutive_rules_collapse(self, render):
markup = render("Intro.\n\n---\n\n---\n\n## A\n\nBody.\n")
assert card_ids(markup) == ["menu", "card1"]
def test_leading_rule_is_harmless(self, render):
markup = render("---\n\n## A\n\nBody.\n")
assert "Untitled" not in markup
def test_document_of_only_a_rule(self, render):
assert card_ids(render("---\n")) == ["card1"]
class TestSetextHeadings:
"""`---` under text is an H2, not a divider -- Markdown says so, and
getting it wrong would split at every heading of a setext document."""
def test_dashes_under_text_make_a_heading_not_a_split(self, render):
markup = render("My Heading\n---\n\nBody text.\n")
assert card_ids(markup) == ["card1"]
assert "<p><b><big>My Heading</big></b></p>" in markup
def test_equals_make_a_level_one_heading(self, render):
assert "<p><b><big>Big Title</big></b></p>" in render("Big Title\n===\n\nBody.\n")
def test_setext_heading_can_drive_split_level(self, render):
markup = render("# Top\n\nIntro.\n\nAlpha\n-----\n\nA.\n\nBeta\n----\n\nB.\n", split_level=2)
assert card_ids(markup) == ["menu", "card1", "card2"]
def test_a_rule_after_a_blank_line_is_still_a_rule(self, render):
# The paragraph has been flushed, so this underlines nothing.
assert card_ids(render("Text.\n\n---\n\n## A\n\nBody.\n")) == ["menu", "card1"]
def test_two_dashes_underline_but_do_not_divide(self, render):
# `--` is a valid setext underline but not a thematic break.
assert card_ids(render("Heading\n--\n\nBody.\n")) == ["card1"]