Files
nanobot-runtime/skills/project/tests/test_project_cli.py
2026-09-11 09:44:13 +02:00

237 lines
8.0 KiB
Python

"""Tests for project_cli.py — deterministic file operations for the /project skill."""
import io
import json
import sys
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import project_cli # noqa: E402
@pytest.fixture
def projects(tmp_path, monkeypatch):
root = tmp_path / "projects"
root.mkdir()
monkeypatch.setenv("PROJECTS_DIR", str(root))
return root
def make_project(projects, slug, prompt="", memory="", state=""):
directory = projects / slug
directory.mkdir()
(directory / "prompt.md").write_text(prompt, encoding="utf-8")
(directory / "memory.md").write_text(memory, encoding="utf-8")
(directory / "state.md").write_text(state, encoding="utf-8")
return directory
def today():
return datetime.now(ZoneInfo("Europe/Prague")).date().isoformat()
@pytest.fixture
def entry_file(tmp_path):
"""Write entry text to a file and return its path, the way the skill does."""
def write(text):
path = tmp_path / "entry.md"
path.write_text(text, encoding="utf-8")
return str(path)
return write
# -- log ---------------------------------------------------------------------
def test_log_appends_with_today_date(projects, entry_file, capsys):
directory = make_project(projects, "chata")
assert project_cli.cmd_log("chata", entry_file("Dřevo objednáno")) == 0
memory = (directory / "memory.md").read_text(encoding="utf-8")
assert memory == f"- {today()}: Dřevo objednáno\n"
assert json.loads(capsys.readouterr().out)["appended"].startswith(f"- {today()}:")
def test_log_does_not_join_when_file_lacks_trailing_newline(projects, entry_file):
directory = make_project(projects, "chata", memory="- 2026-09-01: první")
project_cli.cmd_log("chata", entry_file("druhý"))
lines = (directory / "memory.md").read_text(encoding="utf-8").splitlines()
assert lines == ["- 2026-09-01: první", f"- {today()}: druhý"]
def test_log_creates_missing_memory_file(projects, entry_file):
directory = projects / "chata"
directory.mkdir()
assert project_cli.cmd_log("chata", entry_file("první")) == 0
assert (directory / "memory.md").read_text(encoding="utf-8").endswith("první\n")
def test_log_reads_file_verbatim(projects, entry_file):
directory = make_project(projects, "chata")
text = "Uvozovky „takhle\" a apostrof ' a \"tohle\"\ndruhý řádek"
assert project_cli.cmd_log("chata", entry_file(text)) == 0
memory = (directory / "memory.md").read_text(encoding="utf-8")
assert memory == f"- {today()}: {text}\n"
def test_log_accepts_text_the_exec_guard_would_reject_on_a_command_line(projects, entry_file):
"""Regression: `Cíl:` / `Závěr:` parse as Windows drive paths in the exec guard.
The guard scans the raw command string, so such text must reach the script
through a file, never as an argument, a heredoc or a pipe.
"""
directory = make_project(projects, "chata")
text = "Cíl: srovnat pánev. Diagnóza/směr: mobilita kyčle. Závěr: pokračovat."
assert project_cli.cmd_log("chata", entry_file(text)) == 0
assert text in (directory / "memory.md").read_text(encoding="utf-8")
def test_log_reads_stdin_when_no_file_given(projects, monkeypatch):
directory = make_project(projects, "chata")
monkeypatch.setattr(sys, "stdin", io.StringIO("ze stdinu"))
assert project_cli.cmd_log("chata", None) == 0
assert (directory / "memory.md").read_text(encoding="utf-8") == f"- {today()}: ze stdinu\n"
def test_log_rejects_missing_file_without_touching_memory(projects, tmp_path, capsys):
directory = make_project(projects, "chata", memory="- 2026-09-01: první\n")
assert project_cli.cmd_log("chata", str(tmp_path / "chybi.md")) == 1
assert "No such file" in capsys.readouterr().err
assert (directory / "memory.md").read_text(encoding="utf-8") == "- 2026-09-01: první\n"
def test_log_does_not_shorten_long_entry(projects, entry_file):
directory = make_project(projects, "chata")
text = "x" * 3000
project_cli.cmd_log("chata", entry_file(text))
assert text in (directory / "memory.md").read_text(encoding="utf-8")
def test_log_rejects_empty_input(projects, entry_file):
directory = make_project(projects, "chata", memory="- 2026-09-01: první\n")
assert project_cli.cmd_log("chata", entry_file(" ")) == 1
assert (directory / "memory.md").read_text(encoding="utf-8") == "- 2026-09-01: první\n"
def test_log_rejects_unknown_project(projects, entry_file):
assert project_cli.cmd_log("neznamy", entry_file("text")) == 1
def test_log_no_longer_accepts_text_on_the_command_line(monkeypatch):
"""`--text` is gone for good: prose in argv is what the exec guard blocks."""
monkeypatch.setattr(sys, "argv", ["project_cli.py", "log", "chata", "--text", "něco"])
with pytest.raises(SystemExit) as excinfo:
project_cli.main()
assert excinfo.value.code == 2
# -- activate ----------------------------------------------------------------
def test_activate_prints_all_three_sections(projects, capsys):
make_project(projects, "chata", prompt="# Chata", memory="- 2026-09-01: a\n", state="stav")
assert project_cli.cmd_activate("chata") == 0
out = capsys.readouterr().out
assert "### prompt.md\n# Chata" in out
assert "### memory.md\n- 2026-09-01: a" in out
assert "### state.md\nstav" in out
assert "[!] state.md is empty" not in out
def test_activate_creates_missing_files(projects, capsys):
directory = projects / "chata"
directory.mkdir()
assert project_cli.cmd_activate("chata") == 0
assert all((directory / name).is_file() for name in project_cli.PROJECT_FILES)
def test_activate_flags_empty_state(projects, capsys):
make_project(projects, "chata", memory="- 2026-09-01: a\n")
project_cli.cmd_activate("chata")
assert "[!] state.md is empty" in capsys.readouterr().out
def test_activate_omits_oldest_entries_without_touching_disk(projects, capsys):
memory = "".join(f"- 2026-09-01: entry {i} {'x' * 200}\n" for i in range(200))
directory = make_project(projects, "big", prompt="P", memory=memory, state="S")
size_before = (directory / "memory.md").stat().st_size
assert project_cli.cmd_activate("big") == 0
out = capsys.readouterr().out
assert len(out) <= project_cli.MAX_OUTPUT_CHARS
assert "older entries not shown" in out
assert "entry 199" in out and "entry 0 " not in out
assert "### prompt.md\nP" in out and "### state.md\nS" in out
assert (directory / "memory.md").stat().st_size == size_before
def test_activate_rejects_unknown_project(projects, capsys):
make_project(projects, "chata")
assert project_cli.cmd_activate("neznamy") == 1
assert "chata" in capsys.readouterr().err
# -- list / new --------------------------------------------------------------
def test_list_reports_sizes_and_flags_empty_state(projects, capsys):
make_project(projects, "chata", prompt="x" * 595, memory="y" * 2048)
make_project(projects, "life", prompt="a", memory="b", state="c")
assert project_cli.cmd_list() == 0
lines = capsys.readouterr().out.splitlines()
assert "prompt 595B" in lines[0] and "memory 2.0K" in lines[0]
assert "state 0B (!)" in lines[0]
assert "(!)" not in lines[1]
def test_list_without_projects(projects, capsys):
assert project_cli.cmd_list() == 0
assert capsys.readouterr().out.strip() == "(no projects yet)"
def test_new_creates_empty_files_without_artifacts(projects, capsys):
assert project_cli.cmd_new("novy") == 0
directory = projects / "novy"
assert all((directory / name).read_text(encoding="utf-8") == "" for name in project_cli.PROJECT_FILES)
assert not (directory / "artifacts").exists()
def test_new_refuses_existing_project(projects):
make_project(projects, "chata", memory="- 2026-09-01: a\n")
assert project_cli.cmd_new("chata") == 1
assert (projects / "chata" / "memory.md").read_text(encoding="utf-8") == "- 2026-09-01: a\n"