Without `if __name__ == "__main__": sys.exit(main())` at the bottom of cli.py, `python -m cmos.cli format <path>` imports the module but never invokes main(), so the process silently exits 0 with empty stdout — indistinguishable from a successful run that produced no output. Discovered during real-draft testing on 2026-04-11. Adds a regression test that subprocesses the CLI with --help and asserts on stdout content. argparse --help exits 0 in both broken and fixed states; stdout content is the only discriminator. Both invocation paths now work: - uv run cmos format <path> (pyproject script entry) - uv run python -m cmos.cli format <path> (module invocation)
137 lines
4.3 KiB
Python
137 lines
4.3 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.
|
|
"""
|
|
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from cmos.cli import reformat_draft
|
|
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.
|
|
"""
|
|
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 "usage" in result.stdout.lower()
|
|
|
|
|
|
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)",
|
|
]
|