Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions tests/python/test_cli_coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"""Coverage for documented Python CLI paths not previously exercised (#103).

These tests close the gap in tests/python/test_cli.py:

- `--docs-only` routing on `index`/`map`/`update` short-circuits to docs render.
- `--json-only` / `--changed-only` aliases map to `--no-docs` / incremental.
- `--stack` / `--product-name` flow into `product.stack` / `product.name` in the
emitted project-map when `.starter-meta.json` is absent.
- `index` exception boundary returns exit code 1 with a `status="failed"` JSON
payload that carries the original `error`.
- A bounded `_watch` test confirms the loop exits cleanly on `KeyboardInterrupt`
without crashing the process.
"""

from __future__ import annotations

import contextlib
import io
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock

ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))

from simplicio_mapper import cli as cli_module # noqa: E402
from simplicio_mapper.cli import main # noqa: E402


def _write(base: Path, rel: str, content: str) -> None:
target = base / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")


def _run(argv: list[str]) -> tuple[int, str, str]:
out = io.StringIO()
err = io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = main(argv)
return code, out.getvalue(), err.getvalue()


class StackAndProductHintInjectionTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.dir = Path(self._tmp.name)
_write(self.dir, "package.json", json.dumps({"name": "hint-host"}))
_write(self.dir, "src/index.py", "def run() -> int:\n return 1\n")

def tearDown(self) -> None:
self._tmp.cleanup()

def test_stack_and_product_name_flow_into_project_map(self) -> None:
code, _, _ = _run([
"map",
"--root", str(self.dir),
"--stack", "python-fastapi",
"--product-name", "Hint Host",
"--silent",
])
self.assertEqual(code, 0)
project_map = json.loads(
(self.dir / ".simplicio" / "project-map.json").read_text()
)
self.assertEqual(project_map["product"]["stack"], "python-fastapi")
self.assertEqual(project_map["product"]["name"], "Hint Host")


class JsonOnlyAndChangedOnlyAliasesTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.dir = Path(self._tmp.name)
_write(self.dir, "package.json", json.dumps({"name": "alias-host"}))
_write(self.dir, "src/index.js", "export function run(){}\n")

def tearDown(self) -> None:
self._tmp.cleanup()

def test_json_only_keeps_docs_disabled(self) -> None:
code, _, _ = _run([
"index", str(self.dir), "--json", "--json-only",
])
Comment on lines +83 to +86
self.assertEqual(code, 0)
# docs should not have been rendered as a side effect.
self.assertFalse((self.dir / ".simplicio" / "docs").exists())

def test_changed_only_triggers_incremental_refresh(self) -> None:
# First run primes the cache.
_run(["map", "--root", str(self.dir), "--silent"])
_write(self.dir, "src/index.js", "export function run(){return 1;}\n")
code, _, _ = _run([
"map", "--root", str(self.dir), "--changed-only", "--silent",
])
self.assertEqual(code, 0)
project_map = json.loads(
(self.dir / ".simplicio" / "project-map.json").read_text()
)
self.assertEqual(project_map["update_mode"], "incremental")


class DocsOnlyShortCircuitTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.dir = Path(self._tmp.name)
_write(self.dir, "package.json", json.dumps({"name": "docs-only-host"}))
_write(self.dir, "src/index.js", "export function run(){}\n")
# Seed JSON artifacts so docs-only has something to render from.
_run(["map", "--root", str(self.dir), "--silent"])

def tearDown(self) -> None:
self._tmp.cleanup()

def test_docs_only_on_index_renders_docs_without_rewriting_json(self) -> None:
project_map_path = self.dir / ".simplicio" / "project-map.json"
before = project_map_path.read_text()
code, _, _ = _run([
"index", str(self.dir), "--docs-only", "--json",
])
self.assertEqual(code, 0)
after = project_map_path.read_text()
# JSON payload is not rewritten by --docs-only.
self.assertEqual(before, after)
Comment on lines +118 to +126
# At least one markdown file is rendered.
docs_dir = self.dir / ".simplicio" / "docs"
self.assertTrue(docs_dir.exists())
self.assertTrue(any(docs_dir.rglob("*.md")))


class IndexFailureBoundaryTest(unittest.TestCase):
def test_index_failure_returns_exit_1_with_failed_status_json(self) -> None:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
target = Path(tmp.name)
_write(target, "package.json", json.dumps({"name": "fail-host"}))

boom = RuntimeError("boom")
with mock.patch.object(cli_module, "_run_index", side_effect=boom):
code, stdout, _ = _run([
"index", str(target), "--json",
])
self.assertEqual(code, 1)
payload = json.loads(stdout.strip())
self.assertEqual(payload["status"], "failed")
self.assertIn("boom", payload.get("error", ""))


class WatchLoopExitsOnInterruptTest(unittest.TestCase):
def test_watch_loop_handles_keyboard_interrupt_cleanly(self) -> None:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
target = Path(tmp.name)
_write(target, "package.json", json.dumps({"name": "watch-host"}))
_write(target, "src/index.js", "export function run(){}\n")

opts = {
"command": "map",
"root": str(target),
"out": ".simplicio",
"stack": "",
"product_name": "",
"incremental": True,
"watch": True,
"silent": True,
"json": False,
"verbose": False,
"docs": False,
"docs_only": False,
"background": False,
"against": "",
"target": "",
}

# Replace time.sleep so the watch loop hits a single iteration then exits
# via the documented KeyboardInterrupt branch.
with mock.patch.object(cli_module.time, "sleep", side_effect=KeyboardInterrupt):
# Should return cleanly without raising — that is the contract.
with contextlib.redirect_stdout(io.StringIO()):
cli_module._watch(opts)


if __name__ == "__main__":
unittest.main()
Loading