"""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)", ]