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.
293 lines
10 KiB
Python
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)",
|
|
]
|