Fast commands for git, email, calendar, notes, search, reminders, and diagnostics — all execute instantly without Claude CLI. Incremental embeddings indexing in heartbeat (1h cooldown) + inline indexing after /note, /jurnal, /email save. Fix Ollama URL (localhost → 10.0.20.161), fix email_process.py KB path (kb/ → memory/kb/). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
510 lines
16 KiB
Python
510 lines
16 KiB
Python
"""Tests for src/fast_commands.py — fast (non-LLM) commands."""
|
|
|
|
import json
|
|
import pytest
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
from src.fast_commands import (
|
|
dispatch,
|
|
cmd_commit,
|
|
cmd_push,
|
|
cmd_pull,
|
|
cmd_test,
|
|
cmd_email,
|
|
cmd_calendar,
|
|
cmd_note,
|
|
cmd_jurnal,
|
|
cmd_search,
|
|
cmd_kb,
|
|
cmd_remind,
|
|
cmd_logs,
|
|
cmd_doctor,
|
|
cmd_heartbeat,
|
|
cmd_help,
|
|
COMMANDS,
|
|
_auto_commit_message,
|
|
)
|
|
|
|
|
|
# --- Dispatch ---
|
|
|
|
class TestDispatch:
|
|
def test_known_command(self):
|
|
"""Known commands return a string."""
|
|
result = dispatch("help", [])
|
|
assert result is not None
|
|
assert "Comenzi disponibile" in result
|
|
|
|
def test_unknown_command(self):
|
|
"""Unknown commands return None."""
|
|
assert dispatch("nonexistent", []) is None
|
|
|
|
def test_all_commands_registered(self):
|
|
expected = {
|
|
"commit", "push", "pull", "test", "email", "calendar",
|
|
"note", "jurnal", "search", "kb", "remind", "logs",
|
|
"doctor", "heartbeat", "help",
|
|
}
|
|
assert set(COMMANDS.keys()) == expected
|
|
|
|
def test_handler_exception_caught(self):
|
|
"""Exceptions in handlers are caught and returned as error strings."""
|
|
with patch.dict(COMMANDS, {"boom": lambda args: 1 / 0}):
|
|
result = dispatch("boom", [])
|
|
assert result is not None
|
|
assert "Error in /boom" in result
|
|
|
|
|
|
# --- Git ---
|
|
|
|
class TestGitCommit:
|
|
@patch("src.fast_commands._git")
|
|
def test_nothing_to_commit(self, mock_git):
|
|
mock_git.return_value = ""
|
|
assert cmd_commit([]) == "Nothing to commit."
|
|
|
|
@patch("src.fast_commands._git")
|
|
def test_commit_with_custom_message(self, mock_git):
|
|
mock_git.side_effect = [
|
|
" M file.py\n", # status --porcelain
|
|
"", # add -A
|
|
"", # commit -m
|
|
"abc1234", # rev-parse --short HEAD
|
|
]
|
|
result = cmd_commit(["fix", "bug"])
|
|
assert "abc1234" in result
|
|
assert "fix bug" in result
|
|
assert "1 files" in result
|
|
|
|
@patch("src.fast_commands._git")
|
|
def test_commit_auto_message(self, mock_git):
|
|
mock_git.side_effect = [
|
|
" M src/router.py\n?? newfile.txt\n", # status
|
|
"", # add -A
|
|
"", # commit -m
|
|
"def5678", # rev-parse
|
|
]
|
|
result = cmd_commit([])
|
|
assert "def5678" in result
|
|
assert "2 files" in result
|
|
|
|
|
|
class TestGitPush:
|
|
@patch("src.fast_commands._git")
|
|
def test_push_with_uncommitted(self, mock_git):
|
|
mock_git.return_value = " M dirty.py\n"
|
|
result = cmd_push([])
|
|
assert "uncommitted" in result.lower()
|
|
|
|
@patch("src.fast_commands._git")
|
|
def test_push_up_to_date(self, mock_git):
|
|
mock_git.side_effect = [
|
|
"", # status --porcelain
|
|
"Everything up-to-date", # push
|
|
]
|
|
result = cmd_push([])
|
|
assert "up to date" in result.lower()
|
|
|
|
@patch("src.fast_commands._git")
|
|
def test_push_success(self, mock_git):
|
|
mock_git.side_effect = [
|
|
"", # status --porcelain
|
|
"abc..def master -> master", # push
|
|
"master", # branch --show-current
|
|
]
|
|
result = cmd_push([])
|
|
assert "origin/master" in result
|
|
|
|
|
|
class TestGitPull:
|
|
@patch("src.fast_commands._git")
|
|
def test_pull_up_to_date(self, mock_git):
|
|
mock_git.return_value = "Already up to date."
|
|
assert "up to date" in cmd_pull([]).lower()
|
|
|
|
@patch("src.fast_commands._git")
|
|
def test_pull_with_changes(self, mock_git):
|
|
mock_git.return_value = "Fast-forward\n file.py | 2 +-"
|
|
result = cmd_pull([])
|
|
assert "Pulled" in result
|
|
|
|
|
|
# --- Test ---
|
|
|
|
class TestTestCmd:
|
|
@patch("subprocess.run")
|
|
def test_basic_run(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
stdout="5 passed in 1.2s",
|
|
stderr="",
|
|
returncode=0,
|
|
)
|
|
result = cmd_test([])
|
|
assert "passed" in result
|
|
|
|
@patch("subprocess.run")
|
|
def test_with_pattern(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
stdout="2 passed",
|
|
stderr="",
|
|
returncode=0,
|
|
)
|
|
cmd_test(["router"])
|
|
args = mock_run.call_args[0][0]
|
|
assert "-k" in args
|
|
assert "router" in args
|
|
|
|
@patch("subprocess.run")
|
|
def test_timeout(self, mock_run):
|
|
import subprocess as sp
|
|
mock_run.side_effect = sp.TimeoutExpired(cmd="pytest", timeout=120)
|
|
result = cmd_test([])
|
|
assert "timed out" in result.lower()
|
|
|
|
|
|
# --- Email ---
|
|
|
|
class TestEmail:
|
|
@patch("subprocess.run")
|
|
def test_email_check_clean(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
stdout=json.dumps({"ok": True, "unread_count": 0, "emails": []}),
|
|
stderr="",
|
|
returncode=0,
|
|
)
|
|
result = cmd_email([])
|
|
assert "curat" in result.lower()
|
|
|
|
@patch("subprocess.run")
|
|
def test_email_check_unread(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
stdout=json.dumps({
|
|
"ok": True,
|
|
"unread_count": 2,
|
|
"emails": [
|
|
{"subject": "Hello"},
|
|
{"subject": "Meeting"},
|
|
],
|
|
}),
|
|
stderr="",
|
|
returncode=0,
|
|
)
|
|
result = cmd_email([])
|
|
assert "2 necitite" in result
|
|
assert "Hello" in result
|
|
|
|
def test_email_unknown_sub(self):
|
|
result = cmd_email(["foo"])
|
|
assert "Unknown email sub-command" in result
|
|
|
|
@patch("subprocess.run")
|
|
def test_email_send(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
stdout=json.dumps({"ok": True}),
|
|
stderr="",
|
|
returncode=0,
|
|
)
|
|
result = cmd_email(["send", "test@x.com", "Subject", "::", "Body"])
|
|
assert "trimis" in result.lower()
|
|
|
|
def test_email_send_bad_format(self):
|
|
result = cmd_email(["send", "no-separator"])
|
|
assert "Format" in result
|
|
|
|
@patch("subprocess.run")
|
|
def test_email_save_nothing(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
stdout="Niciun email nou de la adrese whitelisted.",
|
|
stderr="",
|
|
returncode=0,
|
|
)
|
|
result = cmd_email(["save"])
|
|
assert "Nimic de salvat" in result
|
|
|
|
|
|
# --- Calendar ---
|
|
|
|
class TestCalendar:
|
|
@patch("subprocess.run")
|
|
def test_calendar_today(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
stdout=json.dumps({
|
|
"today": [{"time": "09:00", "summary": "Standup"}],
|
|
"tomorrow": [],
|
|
}),
|
|
stderr="",
|
|
returncode=0,
|
|
)
|
|
result = cmd_calendar([])
|
|
assert "Standup" in result
|
|
assert "09:00" in result
|
|
|
|
@patch("subprocess.run")
|
|
def test_calendar_week(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
stdout=json.dumps({
|
|
"week_start": "10 Feb",
|
|
"week_end": "16 Feb",
|
|
"events": [{"date": "10 Feb", "time": "10:00", "summary": "Review"}],
|
|
}),
|
|
stderr="",
|
|
returncode=0,
|
|
)
|
|
result = cmd_calendar(["week"])
|
|
assert "Review" in result
|
|
|
|
@patch("subprocess.run")
|
|
def test_calendar_busy_free(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
stdout=json.dumps({"busy": False}),
|
|
stderr="",
|
|
returncode=0,
|
|
)
|
|
result = cmd_calendar(["busy"])
|
|
assert "Liber" in result
|
|
|
|
@patch("subprocess.run")
|
|
def test_calendar_busy_occupied(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
stdout=json.dumps({"busy": True, "event": "Meeting", "ends": "16:30"}),
|
|
stderr="",
|
|
returncode=0,
|
|
)
|
|
result = cmd_calendar(["busy"])
|
|
assert "Ocupat" in result
|
|
assert "Meeting" in result
|
|
|
|
def test_calendar_unknown_sub(self):
|
|
result = cmd_calendar(["foo"])
|
|
assert "Unknown calendar sub-command" in result
|
|
|
|
|
|
# --- Notes ---
|
|
|
|
class TestNote:
|
|
def test_note_empty(self):
|
|
assert "Usage" in cmd_note([])
|
|
|
|
def test_note_creates_file(self, tmp_path):
|
|
with patch("src.fast_commands.MEMORY_DIR", tmp_path):
|
|
result = cmd_note(["test", "note"])
|
|
assert result == "Notat."
|
|
today = date.today().isoformat()
|
|
filepath = tmp_path / f"{today}.md"
|
|
assert filepath.exists()
|
|
content = filepath.read_text()
|
|
assert "test note" in content
|
|
assert f"# {today}" in content
|
|
|
|
def test_note_appends(self, tmp_path):
|
|
today = date.today().isoformat()
|
|
filepath = tmp_path / f"{today}.md"
|
|
filepath.write_text(f"# {today}\n\n- existing\n")
|
|
with patch("src.fast_commands.MEMORY_DIR", tmp_path):
|
|
cmd_note(["new", "note"])
|
|
content = filepath.read_text()
|
|
assert "existing" in content
|
|
assert "new note" in content
|
|
|
|
|
|
class TestJurnal:
|
|
def test_jurnal_empty(self):
|
|
assert "Usage" in cmd_jurnal([])
|
|
|
|
def test_jurnal_creates_file(self, tmp_path):
|
|
with patch("src.fast_commands.MEMORY_DIR", tmp_path):
|
|
result = cmd_jurnal(["my", "entry"])
|
|
assert result == "Jurnal actualizat."
|
|
today = date.today().isoformat()
|
|
filepath = tmp_path / f"{today}.md"
|
|
content = filepath.read_text()
|
|
assert "## Jurnal" in content
|
|
assert "my entry" in content
|
|
|
|
def test_jurnal_adds_section_if_missing(self, tmp_path):
|
|
today = date.today().isoformat()
|
|
filepath = tmp_path / f"{today}.md"
|
|
filepath.write_text(f"# {today}\n\n- some note\n")
|
|
with patch("src.fast_commands.MEMORY_DIR", tmp_path):
|
|
cmd_jurnal(["entry"])
|
|
content = filepath.read_text()
|
|
assert "## Jurnal" in content
|
|
assert "entry" in content
|
|
|
|
|
|
# --- Search ---
|
|
|
|
class TestSearch:
|
|
def test_search_empty(self):
|
|
assert "Usage" in cmd_search([])
|
|
|
|
@patch("src.memory_search.search")
|
|
def test_search_no_results(self, mock_search):
|
|
mock_search.return_value = []
|
|
result = cmd_search(["query"])
|
|
assert "Niciun rezultat" in result
|
|
|
|
@patch("src.memory_search.search")
|
|
def test_search_with_results(self, mock_search):
|
|
mock_search.return_value = [
|
|
{"file": "notes.md", "chunk": "important info here", "score": 0.92},
|
|
]
|
|
result = cmd_search(["important"])
|
|
assert "Rezultate" in result
|
|
assert "notes.md" in result
|
|
assert "0.92" in result
|
|
|
|
|
|
# --- KB ---
|
|
|
|
class TestKB:
|
|
def test_kb_no_index(self, tmp_path):
|
|
with patch("src.fast_commands.MEMORY_DIR", tmp_path):
|
|
result = cmd_kb([])
|
|
assert "not found" in result.lower()
|
|
|
|
def test_kb_list(self, tmp_path):
|
|
kb_dir = tmp_path / "kb"
|
|
kb_dir.mkdir()
|
|
index = {
|
|
"notes": [
|
|
{"title": "Note 1", "date": "2026-02-15", "category": "insights"},
|
|
{"title": "Note 2", "date": "2026-02-14", "category": "projects"},
|
|
]
|
|
}
|
|
(kb_dir / "index.json").write_text(json.dumps(index))
|
|
with patch("src.fast_commands.MEMORY_DIR", tmp_path):
|
|
result = cmd_kb([])
|
|
assert "Note 1" in result
|
|
assert "Note 2" in result
|
|
|
|
def test_kb_filter_category(self, tmp_path):
|
|
kb_dir = tmp_path / "kb"
|
|
kb_dir.mkdir()
|
|
index = {
|
|
"notes": [
|
|
{"title": "Note A", "date": "2026-02-15", "category": "insights"},
|
|
{"title": "Note B", "date": "2026-02-14", "category": "projects"},
|
|
]
|
|
}
|
|
(kb_dir / "index.json").write_text(json.dumps(index))
|
|
with patch("src.fast_commands.MEMORY_DIR", tmp_path):
|
|
result = cmd_kb(["insights"])
|
|
assert "Note A" in result
|
|
assert "Note B" not in result
|
|
|
|
def test_kb_filter_no_match(self, tmp_path):
|
|
kb_dir = tmp_path / "kb"
|
|
kb_dir.mkdir()
|
|
index = {"notes": [{"title": "X", "date": "2026-01-01", "category": "a"}]}
|
|
(kb_dir / "index.json").write_text(json.dumps(index))
|
|
with patch("src.fast_commands.MEMORY_DIR", tmp_path):
|
|
result = cmd_kb(["nonexistent"])
|
|
assert "Nicio nota" in result
|
|
|
|
|
|
# --- Remind ---
|
|
|
|
class TestRemind:
|
|
def test_remind_too_few_args(self):
|
|
assert "Usage" in cmd_remind([])
|
|
assert "Usage" in cmd_remind(["15:00"])
|
|
|
|
def test_remind_bad_time(self):
|
|
result = cmd_remind(["nottime", "test"])
|
|
assert "Invalid time" in result
|
|
|
|
@patch("src.fast_commands.TOOLS_DIR", Path("/tmp/fake_tools"))
|
|
def test_remind_with_date(self):
|
|
with patch.dict("sys.modules", {
|
|
"calendar_check": MagicMock(
|
|
create_event=MagicMock(return_value={"id": "123"})
|
|
),
|
|
}):
|
|
result = cmd_remind(["2026-03-01", "15:00", "Test", "reminder"])
|
|
assert "Reminder setat" in result
|
|
assert "15:00" in result
|
|
|
|
|
|
# --- Logs ---
|
|
|
|
class TestLogs:
|
|
def test_logs_no_file(self, tmp_path):
|
|
with patch("src.fast_commands.LOGS_DIR", tmp_path):
|
|
result = cmd_logs([])
|
|
assert "No log file" in result
|
|
|
|
def test_logs_default(self, tmp_path):
|
|
log_file = tmp_path / "echo-core.log"
|
|
lines = [f"line {i}" for i in range(30)]
|
|
log_file.write_text("\n".join(lines))
|
|
with patch("src.fast_commands.LOGS_DIR", tmp_path):
|
|
result = cmd_logs([])
|
|
assert "line 29" in result
|
|
assert "line 10" in result
|
|
assert "line 0" not in result
|
|
|
|
def test_logs_custom_count(self, tmp_path):
|
|
log_file = tmp_path / "echo-core.log"
|
|
lines = [f"line {i}" for i in range(30)]
|
|
log_file.write_text("\n".join(lines))
|
|
with patch("src.fast_commands.LOGS_DIR", tmp_path):
|
|
result = cmd_logs(["5"])
|
|
assert "line 29" in result
|
|
assert "line 20" not in result
|
|
|
|
|
|
# --- Doctor ---
|
|
|
|
class TestDoctor:
|
|
@patch("shutil.which", return_value="/usr/bin/claude")
|
|
def test_doctor_runs(self, mock_which):
|
|
result = cmd_doctor([])
|
|
assert "Doctor:" in result
|
|
assert "Claude CLI" in result
|
|
|
|
|
|
# --- Help ---
|
|
|
|
class TestHelp:
|
|
def test_help_lists_commands(self):
|
|
result = cmd_help([])
|
|
assert "/commit" in result
|
|
assert "/email" in result
|
|
assert "/calendar" in result
|
|
assert "/search" in result
|
|
assert "/help" in result
|
|
assert "/clear" in result
|
|
|
|
|
|
# --- Auto commit message ---
|
|
|
|
class TestAutoCommitMessage:
|
|
def test_single_modified(self):
|
|
msg = _auto_commit_message([" M src/router.py"])
|
|
assert "src" in msg
|
|
assert "~1" in msg
|
|
|
|
def test_mixed_changes(self):
|
|
files = [
|
|
" M src/a.py",
|
|
"?? tests/new.py",
|
|
" D old.py",
|
|
]
|
|
msg = _auto_commit_message(files)
|
|
assert "+1" in msg
|
|
assert "~1" in msg
|
|
assert "-1" in msg
|
|
|
|
def test_many_areas(self):
|
|
files = [
|
|
" M a/x.py",
|
|
" M b/y.py",
|
|
" M c/z.py",
|
|
" M d/w.py",
|
|
]
|
|
msg = _auto_commit_message(files)
|
|
assert "+1 more" in msg
|