aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-15 16:16:18 -0500
committerCraig Jennings <c@cjennings.net>2026-05-15 16:16:18 -0500
commitee721ee96f984ccd38233309f0dfe6362057e644 (patch)
treef84a3b21ae846c82a2677a59f54947ee5b557174 /.ai/scripts/tests
parent421b17a15219c7061ee92c07451993965fad88ea (diff)
downloadrulesets-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.py157
-rw-r--r--.ai/scripts/tests/test_cj_scan.py250
-rw-r--r--.ai/scripts/tests/test_inbox_send.py329
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 == []