Files
cmos/tests/test_cli.py
T
Mark Eaton 45f51c6341 v2 chunk 2c: numbered-note format support + real-draft test
Extend the v2 parser and CLI to handle [N] text footnote definitions
in addition to pandoc-style [^marker]: text. The numbered format is
what docx-to-text conversion of footnoted Word documents produces.
The user's "Anti-Communist Formations of LIS" draft uses this format
for all 107 of its notes; without this support, v2 was structurally
incapable of running on real-world docx-derived inputs.

src/cmos/parser.py:
- Add NoteDefinition.original_prefix field so reassembly can
  round-trip the source's marker syntax (pandoc input → pandoc
  output, numbered input → numbered output) without consumers
  needing to know which format was matched.
- Update find_notes() to populate original_prefix as "[^N]: ".
- Add find_numbered_notes() targeting "[N] text" definitions.
  Marker must be all digits (rejects [Smith 2020], [foo], etc.);
  caret prefix is rejected (rejects pandoc-style cleanly).

src/cmos/cli.py:
- reformat_notes now auto-detects format: tries find_notes first,
  falls back to find_numbered_notes if no pandoc definitions found.
  Uses definition.original_prefix for reassembly so both formats
  round-trip correctly.

tests/test_parser.py:
- 11 new tests for find_numbered_notes covering: single/multiple
  definitions, multi-digit markers, line number recording, trailing
  whitespace stripping, ignoring pandoc/non-numeric markers, original
  prefix recording, and the actual Anti-Communist draft format.
- 1 new test for find_notes original_prefix population.

tests/test_cli.py:
- 2 new tests for reformat_notes auto-detect: numbered input round-
  trips as numbered output, pandoc input still round-trips as pandoc.

Total suite: 136/136 (was 123, +13 net new). v1 untouched, 96/96
v1 tests still passing.

Real-draft validation: ran cmos format-notes against the 107-note
Anti-Communist Formations of LIS draft (108 calls in parallel via
the existing concurrency=8 thread pool, completed cleanly). Output
saved to /tmp (not committed). All 107 notes preserved through the
pipeline; ~30-40 first-occurrence full notes produced clean CMOS 18
note form; ~30 shortened-form refs correctly left unchanged;
2 real bugs surfaced for the next iteration (empty input → conver-
sational reply, Ibid → empty string), plus several lower-priority
issues (substantive note truncation, retry waste on shortened
forms, lossy month dropping). Not addressed in this chunk per the
"collect signal, don't fix" plan.
2026-04-11 19:16:09 -04:00

293 lines
10 KiB
Python

"""Tests for src/cmos/cli.py — end-to-end draft reformatting.
The CLI orchestrates parser → formatter → reassemble. Tests inject a fake
formatter so no API calls happen; the goal is to pin the glue logic, not the
LLM behavior.
v1: ``reformat_draft`` rewrites the ``## Bibliography`` section.
v2: ``reformat_notes`` rewrites pandoc-style markdown footnote definitions
in place by line number, preserving everything else byte-for-byte.
"""
import subprocess
import sys
from pathlib import Path
import pytest
from cmos.cli import reformat_draft, reformat_notes
from cmos.parser import NoBibliographyError
def _fake_formatter(messy: str) -> str:
# Deterministic fake: return a cleaned-up marker string so the output is
# recognizable.
return f"FORMATTED({messy.strip()})"
def test_reformat_draft_rewrites_only_bibliography_section():
draft = """\
# My Paper
Some prose with citations.
## Bibliography
yu, charles. interior chinatown. 2020.
kwon, hyeyoung. inclusion work. 2022.
"""
output = reformat_draft(draft, formatter=_fake_formatter)
assert "# My Paper" in output
assert "Some prose with citations." in output
assert "FORMATTED(yu, charles. interior chinatown. 2020.)" in output
assert "FORMATTED(kwon, hyeyoung. inclusion work. 2022.)" in output
# Original messy lines must not also survive.
assert "yu, charles. interior chinatown. 2020." not in output.replace(
"FORMATTED(yu, charles. interior chinatown. 2020.)", ""
)
def test_reformat_draft_preserves_sections_after_bibliography():
draft = """\
## Bibliography
one entry.
## Appendix
appendix text.
"""
output = reformat_draft(draft, formatter=_fake_formatter)
assert "FORMATTED(one entry.)" in output
assert "## Appendix" in output
assert "appendix text." in output
def test_reformat_draft_raises_when_no_bibliography():
with pytest.raises(NoBibliographyError):
reformat_draft("## Intro\n\nno bib here.\n", formatter=_fake_formatter)
def test_reformat_draft_bibliography_heading_preserved():
draft = "## Bibliography\n\nentry.\n"
output = reformat_draft(draft, formatter=_fake_formatter)
assert "## Bibliography" in output
def test_python_dash_m_invocation_actually_runs_main():
"""Regression test: `python -m cmos.cli` must actually invoke main().
Without an `if __name__ == "__main__"` guard at the bottom of cli.py,
`python -m cmos.cli` imports the module body but never calls main(),
so the process silently exits 0 with empty stdout. That looks
indistinguishable from a successful run that produced no output —
the worst kind of bug, since callers assume the pipeline ran. This
test forces the guard to exist by invoking the CLI as a subprocess
with --help and asserting argparse actually fired.
Also acts as a smoke test for both subcommands appearing in the
top-level help: catches the case where a future refactor removes
a subcommand registration without anyone noticing.
"""
result = subprocess.run(
[sys.executable, "-m", "cmos.cli", "--help"],
capture_output=True,
text=True,
timeout=10,
)
# argparse --help exits 0 whether or not main() ran, so the
# discriminator is the stdout content. Without main(), stdout is empty.
assert result.returncode == 0, (
f"expected exit 0, got {result.returncode}; stderr={result.stderr!r}"
)
assert result.stdout, (
"stdout was empty — `python -m cmos.cli` likely silently exited "
"without invoking main(). Check that cli.py has an "
'`if __name__ == "__main__": sys.exit(main())` guard at the bottom.'
)
assert "format" in result.stdout
assert "format-notes" in result.stdout
assert "usage" in result.stdout.lower()
# --- reformat_notes (v2 CLI plumbing) ----------------------------------------
def _fake_note_formatter(messy: str) -> str:
# Deterministic fake mirroring the v1 fake_formatter pattern.
return f"NOTE_FORMATTED({messy.strip()})"
def test_reformat_notes_substitutes_definition_in_place():
draft = """\
Some prose with a citation.[^1]
[^1]: yu, charles. interior chinatown. 2020. p 45.
"""
output = reformat_notes(draft, formatter=_fake_note_formatter)
assert "Some prose with a citation.[^1]" in output
assert "[^1]: NOTE_FORMATTED(yu, charles. interior chinatown. 2020. p 45.)" in output
# The original messy definition line must be gone.
assert "[^1]: yu, charles" not in output
def test_reformat_notes_rewrites_multiple_definitions_in_order():
draft = """\
Prose.[^1] More.[^2] Again.[^3]
[^1]: first messy.
[^2]: second messy.
[^3]: third messy.
"""
output = reformat_notes(draft, formatter=_fake_note_formatter)
assert "[^1]: NOTE_FORMATTED(first messy.)" in output
assert "[^2]: NOTE_FORMATTED(second messy.)" in output
assert "[^3]: NOTE_FORMATTED(third messy.)" in output
def test_reformat_notes_preserves_non_definition_lines():
draft = """\
# Title
Some prose with a citation.[^1]
More prose, no citation here.
[^1]: messy definition.
Conclusion paragraph.
"""
output = reformat_notes(draft, formatter=_fake_note_formatter)
assert "# Title" in output
assert "Some prose with a citation.[^1]" in output
assert "More prose, no citation here." in output
assert "Conclusion paragraph." in output
assert "[^1]: NOTE_FORMATTED(messy definition.)" in output
def test_reformat_notes_preserves_reference_markers_in_prose():
"""The [^1] reference inside the prose must NOT be touched — only
the [^1]: definition line should be reformatted."""
draft = """\
Some prose with a citation.[^1] And another.[^2]
[^1]: first.
[^2]: second.
"""
output = reformat_notes(draft, formatter=_fake_note_formatter)
# References in prose stay verbatim
assert "Some prose with a citation.[^1] And another.[^2]" in output
# Definitions are reformatted
assert "[^1]: NOTE_FORMATTED(first.)" in output
assert "[^2]: NOTE_FORMATTED(second.)" in output
def test_reformat_notes_returns_unchanged_when_no_definitions():
draft = "Just prose with no footnote definitions.\n"
output = reformat_notes(draft, formatter=_fake_note_formatter)
assert output == draft
def test_reformat_notes_preserves_trailing_newline():
with_trailing = "[^1]: messy.\n"
without_trailing = "[^1]: messy."
assert reformat_notes(with_trailing, formatter=_fake_note_formatter).endswith("\n")
assert not reformat_notes(without_trailing, formatter=_fake_note_formatter).endswith("\n")
def test_reformat_notes_auto_detects_numbered_format():
"""Real drafts converted from docx use [N] text format, not pandoc.
reformat_notes must auto-detect: try pandoc first, fall back to
numbered. Reassembly must use the original prefix (e.g. "[1] ") so
the output round-trips in the source's marker syntax."""
draft = """\
## Notes
[1] yu, charles. interior chinatown. 2020. p 45.
[2] kwon, hyeyoung. inclusion work. 2022, p 1830.
"""
output = reformat_notes(draft, formatter=_fake_note_formatter)
# Numbered prefix preserved, NOT rewritten as pandoc.
assert "[1] NOTE_FORMATTED(yu, charles. interior chinatown. 2020. p 45.)" in output
assert "[2] NOTE_FORMATTED(kwon, hyeyoung. inclusion work. 2022, p 1830.)" in output
# No pandoc-style markers should appear in the output (we did not
# auto-convert numbered to pandoc).
assert "[^1]" not in output
assert "[^2]" not in output
def test_reformat_notes_pandoc_input_still_round_trips_as_pandoc():
"""Regression: after the auto-detect change, pandoc-format input
must still produce pandoc-format output. The original_prefix path
must work for both formats."""
draft = "[^1]: messy.\n"
output = reformat_notes(draft, formatter=_fake_note_formatter)
assert "[^1]: NOTE_FORMATTED(messy.)" in output
assert "[1] " not in output # numbered prefix must NOT appear
def test_reformat_notes_preserves_order_under_concurrency():
"""With concurrent execution the formatter is called on all definitions
in parallel; the output must reassemble them in input order regardless
of which call finishes first. Mirrors the v1 order-preservation test."""
import time
def slow_fake(messy: str) -> str:
# Earlier definitions sleep longer so they finish last under naive
# completion-order tracking.
n = int(messy.split()[-1])
time.sleep(0.05 * (5 - n))
return f"NOTE_FORMATTED({messy})"
draft = """\
Prose.
[^1]: definition 0
[^2]: definition 1
[^3]: definition 2
[^4]: definition 3
[^5]: definition 4
"""
output = reformat_notes(draft, formatter=slow_fake, concurrency=4)
# Check order: each marker should be paired with its correct definition.
assert "[^1]: NOTE_FORMATTED(definition 0)" in output
assert "[^2]: NOTE_FORMATTED(definition 1)" in output
assert "[^3]: NOTE_FORMATTED(definition 2)" in output
assert "[^4]: NOTE_FORMATTED(definition 3)" in output
assert "[^5]: NOTE_FORMATTED(definition 4)" in output
def test_reformat_draft_preserves_order_under_concurrency():
# With concurrent execution the formatter is called on all entries in
# parallel; the CLI must reassemble them in input order regardless of
# which call finishes first. This test uses a fake formatter that
# sleeps based on the entry content so earlier entries finish AFTER
# later ones if order were naively tied to completion.
import time
def slow_fake(messy: str) -> str:
# Entries with lower index sleep longer so they finish last.
n = int(messy.split()[-1])
time.sleep(0.05 * (5 - n))
return f"FORMATTED({messy})"
draft = """\
## Bibliography
entry 0
entry 1
entry 2
entry 3
entry 4
"""
output = reformat_draft(draft, formatter=slow_fake, concurrency=4)
# Entries must appear in input order.
lines = [l for l in output.splitlines() if l.startswith("FORMATTED(")]
assert lines == [
"FORMATTED(entry 0)",
"FORMATTED(entry 1)",
"FORMATTED(entry 2)",
"FORMATTED(entry 3)",
"FORMATTED(entry 4)",
]