- Bibliography heading now accepts bold markers (## **BIBLIOGRAPHY**), alternative names (Works Cited, References), and numbered sub-headings within the section. - Original heading text preserved in output instead of hardcoded "## Bibliography". - New find_blockquote_notes() parser for PDF-to-markdown footnote format (> N text), wired into CLI as third fallback after pandoc and numbered. - PDF junk filtered from bibliography entries: bare page numbers, blockquote footnotes, download banners, CC license URLs, and short running headers.
326 lines
12 KiB
Python
326 lines
12 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
|
|
|
|
Yu, Charles. *Interior Chinatown* (New York: Pantheon, 2020).
|
|
|
|
## Appendix
|
|
|
|
appendix text.
|
|
"""
|
|
output = reformat_draft(draft, formatter=_fake_formatter)
|
|
assert "FORMATTED(Yu, Charles. *Interior Chinatown* (New York: Pantheon, 2020).)" 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\nYu, Charles. *Interior Chinatown* (Pantheon, 2020).\n'
|
|
output = reformat_draft(draft, formatter=_fake_formatter)
|
|
assert "## Bibliography" in output
|
|
|
|
|
|
def test_reformat_draft_preserves_works_cited_heading():
|
|
"""When the input heading is ``## Works Cited``, the output must
|
|
keep that heading — not replace it with ``## Bibliography``."""
|
|
draft = '## Works Cited\n\nDavidson, Donald. "On the Very Idea." (1974).\n'
|
|
output = reformat_draft(draft, formatter=_fake_formatter)
|
|
assert "## Works Cited" in output
|
|
assert "## Bibliography" not in output
|
|
|
|
|
|
def test_reformat_draft_preserves_bold_wrapped_heading():
|
|
"""Bold markers in the heading should be preserved in the output."""
|
|
draft = '## **Works Cited**\n\nDavidson, Donald. "On the Very Idea." (1974).\n'
|
|
output = reformat_draft(draft, formatter=_fake_formatter)
|
|
assert "## **Works Cited**" 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_auto_detects_blockquote_format():
|
|
"""PDF-to-markdown drafts use ``> N text`` blockquote footnotes.
|
|
reformat_notes must auto-detect after pandoc and numbered fail."""
|
|
draft = """\
|
|
Some prose.
|
|
|
|
> 1 Davidson, "On the Very Idea of a Conceptual Scheme."
|
|
> 2 Frankenberry 1999, 526.
|
|
"""
|
|
output = reformat_notes(draft, formatter=_fake_note_formatter)
|
|
assert '> 1 NOTE_FORMATTED(Davidson, "On the Very Idea of a Conceptual Scheme.")' in output
|
|
assert "> 2 NOTE_FORMATTED(Frankenberry 1999, 526.)" in output
|
|
# No pandoc or numbered markers should appear.
|
|
assert "[^1]" not in output
|
|
assert "[1] " 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
|
|
|
|
# Map each entry to a sleep duration so earlier entries finish last.
|
|
order = {"2020": 4, "2021": 3, "2022": 2, "2023": 1, "2024": 0}
|
|
|
|
def slow_fake(messy: str) -> str:
|
|
year = messy.strip()[-5:-1] # extract "2020" etc.
|
|
time.sleep(0.05 * order.get(year, 0))
|
|
return f"FORMATTED({messy})"
|
|
|
|
draft = """\
|
|
## Bibliography
|
|
|
|
Author, A. *Title Zero* (Publisher, 2020).
|
|
Author, B. *Title One* (Publisher, 2021).
|
|
Author, C. *Title Two* (Publisher, 2022).
|
|
Author, D. *Title Three* (Publisher, 2023).
|
|
Author, E. *Title Four* (Publisher, 2024).
|
|
"""
|
|
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[0].startswith("FORMATTED(Author, A.")
|
|
assert lines[1].startswith("FORMATTED(Author, B.")
|
|
assert lines[2].startswith("FORMATTED(Author, C.")
|
|
assert lines[3].startswith("FORMATTED(Author, D.")
|
|
assert lines[4].startswith("FORMATTED(Author, E.")
|