diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-15 16:16:18 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-15 16:16:18 -0500 |
| commit | ee721ee96f984ccd38233309f0dfe6362057e644 (patch) | |
| tree | f84a3b21ae846c82a2677a59f54947ee5b557174 /.ai/scripts/tests | |
| parent | 421b17a15219c7061ee92c07451993965fad88ea (diff) | |
| download | rulesets-ee721ee96f984ccd38233309f0dfe6362057e644.tar.gz rulesets-ee721ee96f984ccd38233309f0dfe6362057e644.zip | |
chore(ai): sync scripts and workflows from claude-templates
- todo-cleanup.el: :no-sync: tag now inherits down the outline tree
- task-review.org: completion procedure scoped to top-level entries
- cj-scan.py + cj-remove-block.py: helpers for cj-comment block handling
- inbox-send.py: cross-project messaging via inbox directories
Diffstat (limited to '.ai/scripts/tests')
| -rw-r--r-- | .ai/scripts/tests/test_cj_remove_block.py | 157 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_cj_scan.py | 250 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_inbox_send.py | 329 |
3 files changed, 736 insertions, 0 deletions
diff --git a/.ai/scripts/tests/test_cj_remove_block.py b/.ai/scripts/tests/test_cj_remove_block.py new file mode 100644 index 0000000..2c8dade --- /dev/null +++ b/.ai/scripts/tests/test_cj_remove_block.py @@ -0,0 +1,157 @@ +"""Tests for cj-remove-block.py — idempotent removal of cj annotations by line range. + +The script removes lines [start, end] (1-indexed, inclusive) from an org file but +validates first that those lines actually look like a cj annotation. Refusing on +mismatch protects against accidentally trimming the wrong block when line numbers +drift between scan and remove calls. +""" + +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parent.parent / "cj-remove-block.py" + + +@pytest.fixture +def run_remove(tmp_path): + """Write content to a temp org file, run cj-remove-block, return new contents.""" + def _run(content: str, start: int, end: int) -> str: + f = tmp_path / "test.org" + f.write_text(content) + subprocess.run( + ["python3", str(SCRIPT), + "--file", str(f), + "--start", str(start), + "--end", str(end)], + check=True, + capture_output=True, + ) + return f.read_text() + return _run + + +@pytest.fixture +def run_remove_expecting_failure(tmp_path): + """Write content, run cj-remove-block expecting non-zero exit; return CalledProcessError.""" + def _run(content: str, start: int, end: int): + f = tmp_path / "test.org" + f.write_text(content) + with pytest.raises(subprocess.CalledProcessError) as excinfo: + subprocess.run( + ["python3", str(SCRIPT), + "--file", str(f), + "--start", str(start), + "--end", str(end)], + check=True, + capture_output=True, + ) + return excinfo.value, f.read_text() # file should be unchanged on failure + return _run + + +# ---------------------------------------------------------------------- +# Source-block removal +# ---------------------------------------------------------------------- + +class TestCjRemoveBlockSourceBlock: + """Removing #+begin_src cj: ... #+end_src blocks.""" + + def test_cj_remove_block_minimal_three_line_source_block(self, run_remove): + """Normal: the three lines of a minimal source-block are removed.""" + content = "* S\n#+begin_src cj: comment\nbody\n#+end_src\nafter\n" + result = run_remove(content, start=2, end=4) + assert result == "* S\nafter\n" + + def test_cj_remove_block_source_block_multiline_body(self, run_remove): + """Normal: source-block with multi-line body removed cleanly.""" + content = "* S\n#+begin_src cj: comment\nline 1\nline 2\nline 3\n#+end_src\nafter\n" + result = run_remove(content, start=2, end=6) + assert result == "* S\nafter\n" + + def test_cj_remove_block_preserves_lines_before_and_after(self, run_remove): + """Normal: surrounding lines outside the range stay intact.""" + content = "before\n#+begin_src cj: comment\nx\n#+end_src\nafter\n" + result = run_remove(content, start=2, end=4) + assert result == "before\nafter\n" + + def test_cj_remove_block_source_block_with_label_variant(self, run_remove): + """Boundary: source-block with no trailing label (#+begin_src cj:) also removable.""" + content = "* S\n#+begin_src cj:\nbody\n#+end_src\nafter\n" + result = run_remove(content, start=2, end=4) + assert result == "* S\nafter\n" + + def test_cj_remove_block_case_insensitive_fence(self, run_remove): + """Boundary: case-variant fences (#+BEGIN_SRC / #+END_SRC) also removable.""" + content = "* S\n#+BEGIN_SRC cj: comment\nbody\n#+END_SRC\nafter\n" + result = run_remove(content, start=2, end=4) + assert result == "* S\nafter\n" + + +# ---------------------------------------------------------------------- +# Legacy-inline removal +# ---------------------------------------------------------------------- + +class TestCjRemoveBlockLegacyInline: + """Removing single-line legacy `cj: ...` annotations.""" + + def test_cj_remove_block_legacy_inline_single_line(self, run_remove): + """Normal: single legacy-inline cj line removed.""" + content = "* S\ncj: legacy note\nafter\n" + result = run_remove(content, start=2, end=2) + assert result == "* S\nafter\n" + + def test_cj_remove_block_legacy_inline_at_eof(self, run_remove): + """Boundary: legacy-inline cj at last line; file ends cleanly.""" + content = "* S\ncj: at end\n" + result = run_remove(content, start=2, end=2) + assert result == "* S\n" + + +# ---------------------------------------------------------------------- +# Refusal-on-mismatch safety +# ---------------------------------------------------------------------- + +class TestCjRemoveBlockSafety: + """Refuses to remove if the specified range doesn't look like a cj annotation.""" + + def test_cj_remove_block_refuses_non_cj_single_line(self, run_remove_expecting_failure): + """Error: a single non-cj line is rejected.""" + err, post_content = run_remove_expecting_failure( + "* S\nthis is not a cj line\nafter\n", start=2, end=2, + ) + assert err.returncode != 0 + # File must be unchanged + assert post_content == "* S\nthis is not a cj line\nafter\n" + + def test_cj_remove_block_refuses_mismatched_fence(self, run_remove_expecting_failure): + """Error: multi-line range where line N isn't an opening fence is rejected.""" + err, post_content = run_remove_expecting_failure( + "* S\nbody1\nbody2\n#+end_src\nafter\n", start=2, end=4, + ) + assert err.returncode != 0 + assert "body1" in post_content # file unchanged + + def test_cj_remove_block_refuses_missing_closing_fence(self, run_remove_expecting_failure): + """Error: multi-line range where line M isn't a closing fence is rejected.""" + err, post_content = run_remove_expecting_failure( + "* S\n#+begin_src cj: comment\nbody\nnot-a-close\nafter\n", start=2, end=4, + ) + assert err.returncode != 0 + assert "not-a-close" in post_content + + def test_cj_remove_block_refuses_out_of_bounds(self, run_remove_expecting_failure): + """Error: range outside the file is rejected, file unchanged.""" + err, post_content = run_remove_expecting_failure( + "* S\nafter\n", start=5, end=7, + ) + assert err.returncode != 0 + assert post_content == "* S\nafter\n" + + def test_cj_remove_block_refuses_inverted_range(self, run_remove_expecting_failure): + """Error: end < start is rejected, file unchanged.""" + original = "* S\n#+begin_src cj: comment\nbody\n#+end_src\n" + err, post_content = run_remove_expecting_failure(original, start=4, end=2) + assert err.returncode != 0 + assert post_content == original diff --git a/.ai/scripts/tests/test_cj_scan.py b/.ai/scripts/tests/test_cj_scan.py new file mode 100644 index 0000000..7844474 --- /dev/null +++ b/.ai/scripts/tests/test_cj_scan.py @@ -0,0 +1,250 @@ +"""Tests for cj-scan.py — org-file cj-annotation scanner. + +The script parses an org file and emits JSON describing: +- cj_blocks: every cj annotation found (source-block or legacy-inline form) +- verify_tasks: every VERIFY heading + placement validity (top-level or first-level child only) +- unclosed_blocks: any source-block fence that opened but never closed +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parent.parent / "cj-scan.py" + + +@pytest.fixture +def run_scan(tmp_path): + """Write content to a temp org file and run cj-scan; return parsed JSON output.""" + def _run(content: str) -> dict: + f = tmp_path / "test.org" + f.write_text(content) + result = subprocess.run( + ["python3", str(SCRIPT), str(f)], + capture_output=True, + text=True, + check=True, + ) + return json.loads(result.stdout) + return _run + + +# ---------------------------------------------------------------------- +# cj-block detection +# ---------------------------------------------------------------------- + +class TestCjScanCjBlockDetection: + """Detection of cj annotations — source-block and legacy-inline forms.""" + + def test_cj_scan_source_block_single_detected(self, run_scan): + """Normal: a single source-block cj is detected with correct line range and body.""" + content = "* Section\n#+begin_src cj: comment\nplease check this\n#+end_src\n" + result = run_scan(content) + assert len(result["cj_blocks"]) == 1 + b = result["cj_blocks"][0] + assert b["form"] == "source-block" + assert b["body"] == "please check this" + assert b["start_line"] == 2 + assert b["end_line"] == 4 + + def test_cj_scan_source_block_multiline_body_preserved(self, run_scan): + """Normal: multi-line body is preserved with embedded newlines.""" + content = "* S\n#+begin_src cj: comment\nline 1\nline 2\nline 3\n#+end_src\n" + result = run_scan(content) + assert result["cj_blocks"][0]["body"] == "line 1\nline 2\nline 3" + + def test_cj_scan_multiple_source_blocks_each_detected(self, run_scan): + """Normal: multiple source-blocks in a file are detected as separate items.""" + content = ( + "* A\n#+begin_src cj: comment\nfirst\n#+end_src\n" + "* B\n#+begin_src cj: comment\nsecond\n#+end_src\n" + ) + result = run_scan(content) + assert len(result["cj_blocks"]) == 2 + bodies = [b["body"] for b in result["cj_blocks"]] + assert bodies == ["first", "second"] + + def test_cj_scan_legacy_inline_single_line_detected(self, run_scan): + """Normal: a legacy inline cj line is detected with form=legacy-inline.""" + content = "* Section\ncj: please check this\n" + result = run_scan(content) + assert len(result["cj_blocks"]) == 1 + b = result["cj_blocks"][0] + assert b["form"] == "legacy-inline" + assert b["body"] == "please check this" + assert b["start_line"] == 2 + assert b["end_line"] == 2 + + def test_cj_scan_mixed_forms_in_same_file(self, run_scan): + """Normal: source-block + legacy inline coexist; both detected as separate items.""" + content = ( + "* A\ncj: legacy form\n" + "* B\n#+begin_src cj: comment\nnew form\n#+end_src\n" + ) + result = run_scan(content) + assert len(result["cj_blocks"]) == 2 + forms = sorted(b["form"] for b in result["cj_blocks"]) + assert forms == ["legacy-inline", "source-block"] + + def test_cj_scan_empty_file_returns_empty_lists(self, run_scan): + """Boundary: empty file → empty cj_blocks and verify_tasks lists.""" + result = run_scan("") + assert result["cj_blocks"] == [] + assert result["verify_tasks"] == [] + assert result["unclosed_blocks"] == [] + + def test_cj_scan_no_cj_content_returns_empty_blocks(self, run_scan): + """Boundary: org file with no cj content → empty cj_blocks.""" + content = "* Section\n** TODO Task\nbody text\n** TODO Another\n" + result = run_scan(content) + assert result["cj_blocks"] == [] + + def test_cj_scan_block_before_any_heading_empty_chain(self, run_scan): + """Boundary: cj block at top of file (before any heading) → empty parent chain.""" + content = "#+begin_src cj: comment\ntop-level note\n#+end_src\n" + result = run_scan(content) + assert result["cj_blocks"][0]["parent_heading_chain"] == [] + assert result["cj_blocks"][0]["parent_depth"] == 0 + + @pytest.mark.parametrize("fence", [ + "#+begin_src cj: comment", + "#+begin_src cj:", + "#+begin_src cj: anything", + "#+BEGIN_SRC cj: comment", # case-insensitive + ]) + def test_cj_scan_source_block_fence_variants_all_recognized(self, run_scan, fence): + """Boundary: fence label and case variants are all valid forms.""" + content = f"* S\n{fence}\nbody\n#+end_src\n" + result = run_scan(content) + assert len(result["cj_blocks"]) == 1 + assert result["cj_blocks"][0]["body"] == "body" + + def test_cj_scan_unclosed_source_block_reported(self, run_scan): + """Error: a source-block that opens but never closes → reported in unclosed_blocks.""" + content = "* S\n#+begin_src cj: comment\nbody that never ends\n" + result = run_scan(content) + assert result["cj_blocks"] == [] + assert len(result["unclosed_blocks"]) == 1 + assert result["unclosed_blocks"][0]["start_line"] == 2 + + +# ---------------------------------------------------------------------- +# Parent heading chain reconstruction +# ---------------------------------------------------------------------- + +class TestCjScanParentChain: + """Parent heading chain construction — walking the org tree backward.""" + + def test_cj_scan_nested_parent_chain_three_levels(self, run_scan): + """Normal: cj block inside three nested headings → chain reflects all three.""" + content = ( + "* Work\n" + "** DOING [#A] Kostya's contract\n" + "*** VERIFY Question?\n" + "#+begin_src cj: comment\nanswer\n#+end_src\n" + ) + result = run_scan(content) + chain = result["cj_blocks"][0]["parent_heading_chain"] + assert len(chain) == 3 + assert chain[0] == {"depth": 1, "heading": "Work"} + assert chain[1] == {"depth": 2, "heading": "DOING [#A] Kostya's contract"} + assert chain[2] == {"depth": 3, "heading": "VERIFY Question?"} + assert result["cj_blocks"][0]["parent_depth"] == 3 + + def test_cj_scan_depth_skip_only_actual_ancestors(self, run_scan): + """Normal: heading depth skip (e.g., * then ***) → chain captures only present headings.""" + content = "* Section\n*** Deep child\n#+begin_src cj: comment\nbody\n#+end_src\n" + result = run_scan(content) + chain = result["cj_blocks"][0]["parent_heading_chain"] + assert [h["depth"] for h in chain] == [1, 3] + + def test_cj_scan_shallower_sibling_pops_deeper_frames(self, run_scan): + """Normal: when a shallower heading appears, deeper frames pop off the stack.""" + content = ( + "* A\n** A.1\n*** A.1.1\n" + "** B\n" + "#+begin_src cj: comment\nunder B\n#+end_src\n" + ) + result = run_scan(content) + chain = result["cj_blocks"][0]["parent_heading_chain"] + assert len(chain) == 2 + assert chain[0]["heading"] == "A" + assert chain[1]["heading"] == "B" + + +# ---------------------------------------------------------------------- +# VERIFY task detection + placement audit +# ---------------------------------------------------------------------- + +class TestCjScanVerifyPlacement: + """VERIFY task detection and placement audit per the canonical rule.""" + + def test_cj_scan_verify_at_depth_2_is_valid(self, run_scan): + """Normal: ** VERIFY (top-level) is valid placement.""" + content = "* Work\n** VERIFY [#C] Hayk's Farearth Evaluation :research:hayk:\n" + result = run_scan(content) + assert len(result["verify_tasks"]) == 1 + v = result["verify_tasks"][0] + assert v["depth"] == 2 + assert v["valid_depth"] is True + assert v["promotion_target"] is None + + def test_cj_scan_verify_at_depth_3_is_valid(self, run_scan): + """Normal: *** VERIFY (first-level child) is valid placement.""" + content = "* Work\n** TODO Parent\n*** VERIFY Question?\n" + result = run_scan(content) + v = result["verify_tasks"][0] + assert v["depth"] == 3 + assert v["valid_depth"] is True + + def test_cj_scan_verify_at_depth_4_invalid_promote_to_3(self, run_scan): + """Normal: **** VERIFY is buried; suggests promotion to depth 3.""" + content = "* W\n** P\n*** Q\n**** VERIFY Buried?\n" + result = run_scan(content) + v = result["verify_tasks"][0] + assert v["depth"] == 4 + assert v["valid_depth"] is False + assert v["promotion_target"] == 3 + + def test_cj_scan_verify_at_depth_6_invalid_promote_to_3(self, run_scan): + """Normal: ****** VERIFY at any deep level → promotion target is still 3.""" + content = "* W\n** P\n*** Q\n**** Q2\n***** Q3\n****** VERIFY Very buried?\n" + result = run_scan(content) + v = result["verify_tasks"][0] + assert v["depth"] == 6 + assert v["promotion_target"] == 3 + + def test_cj_scan_verify_at_depth_1_invalid_promote_to_2(self, run_scan): + """Boundary: * VERIFY at top-section depth → promotion target is 2 (top-level under section).""" + content = "* VERIFY Should-be-deeper\n" + result = run_scan(content) + v = result["verify_tasks"][0] + assert v["depth"] == 1 + assert v["valid_depth"] is False + assert v["promotion_target"] == 2 + + def test_cj_scan_verify_heading_with_priority_and_tags(self, run_scan): + """Boundary: VERIFY heading with priority cookie + tags → heading text captured fully.""" + content = "* W\n** VERIFY [#C] Hayk's Farearth Evaluation :research:hayk:\n" + result = run_scan(content) + v = result["verify_tasks"][0] + assert "Hayk's Farearth Evaluation" in v["heading"] + assert ":research:" in v["heading"] + + def test_cj_scan_no_verify_tasks_empty_list(self, run_scan): + """Boundary: file with only TODO/DOING headings → empty verify_tasks list.""" + content = "* W\n** TODO X\n*** DOING Y\n" + result = run_scan(content) + assert result["verify_tasks"] == [] + + def test_cj_scan_verify_word_in_body_is_not_a_task(self, run_scan): + """Error: the word VERIFY appearing in body prose is not detected as a task.""" + content = ( + "* Work\n" + "** TODO Important task\n" + "Body line mentioning VERIFY in prose.\n" + ) + result = run_scan(content) + assert result["verify_tasks"] == [] diff --git a/.ai/scripts/tests/test_inbox_send.py b/.ai/scripts/tests/test_inbox_send.py new file mode 100644 index 0000000..597a7e9 --- /dev/null +++ b/.ai/scripts/tests/test_inbox_send.py @@ -0,0 +1,329 @@ +"""Tests for inbox-send.py — universal cross-project inbox messaging tool. + +The script: +- discovers .ai projects with an inbox/ subdirectory under known roots, +- writes a text message as a dated .org file in the target's inbox/, or +- copies a file into the target's inbox/ with a dated, source-tagged name. + +All discovery is roots-driven (env var INBOX_SEND_ROOTS overrides the +defaults) so tests can sandbox everything inside tmp_path. +""" + +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parent.parent / "inbox-send.py" + + +@pytest.fixture +def project_root(tmp_path): + """Build a fake project under tmp_path/projects/<name>/ with .ai/ + top-level inbox/.""" + def _make(name: str, has_inbox: bool = True) -> Path: + proj = tmp_path / "projects" / name + proj.mkdir(parents=True, exist_ok=True) + (proj / ".ai").mkdir(exist_ok=True) + if has_inbox: + (proj / "inbox").mkdir(exist_ok=True) + return proj + return _make + + +@pytest.fixture +def run_script(tmp_path): + """Invoke inbox-send with sandboxed roots via INBOX_SEND_ROOTS env var.""" + def _run(args, cwd=None, roots=None, expect_failure=False): + env = {} + # Preserve PATH and a few essentials for python3 to launch. + import os as _os + env["PATH"] = _os.environ.get("PATH", "") + env["HOME"] = _os.environ.get("HOME", "/tmp") + if roots: + env["INBOX_SEND_ROOTS"] = ":".join(str(r) for r in roots) + cmd = ["python3", str(SCRIPT)] + args + result = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=cwd or tmp_path, + env=env, + check=not expect_failure, + ) + return result + return _run + + +# ---------------------------------------------------------------------- +# Discovery (--list) +# ---------------------------------------------------------------------- + +class TestInboxSendDiscovery: + """Discovering available .ai projects under the configured roots.""" + + def test_inbox_send_list_detects_projects_with_ai_inbox(self, project_root, run_script, tmp_path): + """Normal: --list shows projects that have .ai/inbox/.""" + project_root("foo") + project_root("bar") + result = run_script(["--list"], roots=[tmp_path / "projects"]) + assert "foo" in result.stdout + assert "bar" in result.stdout + + def test_inbox_send_list_skips_projects_without_inbox(self, project_root, run_script, tmp_path): + """Boundary: project with .ai/ but no inbox/ is not surfaced.""" + project_root("withinbox", has_inbox=True) + project_root("noinbox", has_inbox=False) + result = run_script(["--list"], roots=[tmp_path / "projects"]) + assert "withinbox" in result.stdout + assert "noinbox" not in result.stdout + + def test_inbox_send_list_skips_current_project(self, project_root, run_script, tmp_path): + """Normal: --list excludes the project the user is currently in.""" + cwd_project = project_root("current") + project_root("other") + result = run_script(["--list"], cwd=cwd_project, roots=[tmp_path / "projects"]) + assert "other" in result.stdout + assert "current" not in result.stdout + + def test_inbox_send_list_empty_when_no_projects(self, run_script, tmp_path): + """Boundary: no projects under roots → friendly informational message.""" + (tmp_path / "projects").mkdir() + result = run_script(["--list"], roots=[tmp_path / "projects"]) + assert result.returncode == 0 + assert "No projects" in result.stdout + + def test_inbox_send_list_handles_missing_root(self, run_script, tmp_path): + """Boundary: configured root doesn't exist → skip silently.""" + result = run_script(["--list"], roots=[tmp_path / "does-not-exist"]) + assert result.returncode == 0 + + +# ---------------------------------------------------------------------- +# Slug derivation from text and from filenames +# ---------------------------------------------------------------------- + +def _slug_from(inbox_files, source_name): + """Helper: extract the slug from a deposited file's basename.""" + assert len(inbox_files) == 1 + name = inbox_files[0].stem + marker = f"from-{source_name}-" + return name.split(marker, 1)[1] + + +class TestInboxSendNaming: + """Slug derivation from --text (and override via --name).""" + + def test_inbox_send_text_slug_hyphenated_lowercase(self, project_root, run_script, tmp_path): + """Normal: 'ATM cash reminder' → slug 'atm-cash-reminder'.""" + project_root("target") + cwd = project_root("source") + run_script( + ["target", "--text", "ATM cash reminder"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert _slug_from(files, "source") == "atm-cash-reminder" + + def test_inbox_send_text_slug_truncated_at_word_boundary(self, project_root, run_script, tmp_path): + """Normal: long text truncated under 40 chars at the nearest word boundary.""" + project_root("target") + cwd = project_root("source") + long_text = ( + "Please review the SOFWeek prep doc and confirm the AirBnB kitchen details" + ) + run_script( + ["target", "--text", long_text], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + slug = _slug_from(files, "source") + assert slug.startswith("please-review-the-sofweek") + assert len(slug) <= 40 + # Truncation should land on a word boundary (last char is a letter/digit, not mid-word). + assert "-" not in slug[-1] + + def test_inbox_send_text_slug_strips_punctuation(self, project_root, run_script, tmp_path): + """Normal: punctuation stripped, lowercased.""" + project_root("target") + cwd = project_root("source") + run_script( + ["target", "--text", "Hey! What's the plan? See you @ 5PM."], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + slug = _slug_from(files, "source") + for ch in "!?'@.": + assert ch not in slug + assert slug == slug.lower() + + def test_inbox_send_name_override_overrides_slug(self, project_root, run_script, tmp_path): + """Normal: --name wins over derived slug.""" + project_root("target") + cwd = project_root("source") + run_script( + ["target", "--text", "ok", "--name", "pre-call-ack"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert _slug_from(files, "source") == "pre-call-ack" + + +# ---------------------------------------------------------------------- +# --text mode end-to-end +# ---------------------------------------------------------------------- + +class TestInboxSendText: + """--text mode writes a .org file with the message body.""" + + def test_inbox_send_text_writes_org_file_with_message(self, project_root, run_script, tmp_path): + """Normal: produces a .org file whose body contains the message.""" + project_root("target") + cwd = project_root("source") + run_script( + ["target", "--text", "Remember the ATM run"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert len(files) == 1 + assert files[0].suffix == ".org" + body = files[0].read_text() + assert "Remember the ATM run" in body + + def test_inbox_send_text_filename_includes_source_project_name(self, project_root, run_script, tmp_path): + """Normal: filename includes 'from-<source>-' so the target knows where it came from.""" + project_root("target") + cwd = project_root("emacs") + run_script( + ["target", "--text", "hello"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert "from-emacs-" in files[0].name + + +# ---------------------------------------------------------------------- +# --file mode end-to-end +# ---------------------------------------------------------------------- + +class TestInboxSendFile: + """--file mode copies the source file into the target inbox.""" + + def test_inbox_send_file_copies_text_file(self, project_root, run_script, tmp_path): + """Normal: copies a text file to the target inbox, preserving content.""" + project_root("target") + cwd = project_root("source") + src = tmp_path / "doc.org" + src.write_text("file content") + run_script( + ["target", "--file", str(src)], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert len(files) == 1 + assert files[0].read_text() == "file content" + + def test_inbox_send_file_preserves_extension(self, project_root, run_script, tmp_path): + """Normal: extension carried from source file.""" + project_root("target") + cwd = project_root("source") + src = tmp_path / "image.png" + src.write_bytes(b"\x89PNG\r\n...") + run_script( + ["target", "--file", str(src)], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert files[0].suffix == ".png" + + def test_inbox_send_file_slug_from_source_basename(self, project_root, run_script, tmp_path): + """Normal: filename slug derived from the source file's basename when --name omitted.""" + project_root("target") + cwd = project_root("source") + src = tmp_path / "branching-strategy-notes.md" + src.write_text("notes") + run_script( + ["target", "--file", str(src)], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert "branching-strategy-notes" in files[0].name + + def test_inbox_send_file_name_override(self, project_root, run_script, tmp_path): + """Normal: --name overrides the basename-derived slug; extension preserved.""" + project_root("target") + cwd = project_root("source") + src = tmp_path / "random.pdf" + src.write_bytes(b"%PDF-1.4...") + run_script( + ["target", "--file", str(src), "--name", "branching-strategy"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert "branching-strategy" in files[0].name + assert files[0].suffix == ".pdf" + + +# ---------------------------------------------------------------------- +# Errors and refusal cases +# ---------------------------------------------------------------------- + +class TestInboxSendErrors: + """Refusal cases — surface clearly, exit non-zero, leave filesystem untouched.""" + + def test_inbox_send_refuses_unknown_target(self, project_root, run_script, tmp_path): + """Error: target project not found in discovery → refuse.""" + cwd = project_root("source") + result = run_script( + ["nonexistent", "--text", "hi"], + cwd=cwd, roots=[tmp_path / "projects"], + expect_failure=True, + ) + assert result.returncode != 0 + + def test_inbox_send_refuses_no_text_and_no_file(self, project_root, run_script, tmp_path): + """Error: must provide one of --text / --file.""" + project_root("target") + cwd = project_root("source") + result = run_script( + ["target"], + cwd=cwd, roots=[tmp_path / "projects"], + expect_failure=True, + ) + assert result.returncode != 0 + + def test_inbox_send_refuses_both_text_and_file(self, project_root, run_script, tmp_path): + """Error: --text and --file are mutually exclusive.""" + project_root("target") + cwd = project_root("source") + src = tmp_path / "doc.org" + src.write_text("x") + result = run_script( + ["target", "--text", "hi", "--file", str(src)], + cwd=cwd, roots=[tmp_path / "projects"], + expect_failure=True, + ) + assert result.returncode != 0 + + def test_inbox_send_refuses_missing_source_file(self, project_root, run_script, tmp_path): + """Error: --file path doesn't exist → refuse.""" + project_root("target") + cwd = project_root("source") + result = run_script( + ["target", "--file", str(tmp_path / "definitely-missing.org")], + cwd=cwd, roots=[tmp_path / "projects"], + expect_failure=True, + ) + assert result.returncode != 0 + + def test_inbox_send_refuses_empty_text(self, project_root, run_script, tmp_path): + """Error: empty --text refused; nothing written to target inbox.""" + project_root("target") + cwd = project_root("source") + result = run_script( + ["target", "--text", " "], + cwd=cwd, roots=[tmp_path / "projects"], + expect_failure=True, + ) + assert result.returncode != 0 + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert files == [] |
