From abd52534bb2e48b48b0eeae3bbe9be6a463287c8 Mon Sep 17 00:00:00 2001 From: Tal Muskal Date: Wed, 8 Apr 2026 21:38:12 +0300 Subject: [PATCH] test: bring coverage to 85%, set threshold to 85, reset version to 3.0.11 - Add tests for config, convo_miner, spellcheck, knowledge_graph - Fix Windows PermissionError in test cleanup (chromadb file locks) - Add UTF-8 encoding to split_mega_files, entity_registry, hooks_cli - Fix mcp_server parse_known_args logging for unknown args - Set coverage threshold to 85 in pyproject.toml and CI - Reset all version files to 3.0.11 Co-Authored-By: Claude Opus 4.6 --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .github/workflows/ci.yml | 6 +- mempalace/entity_registry.py | 2 +- mempalace/hooks_cli.py | 2 +- mempalace/mcp_server.py | 4 +- mempalace/split_mega_files.py | 2 +- mempalace/version.py | 2 +- pyproject.toml | 4 +- tests/test_cli.py | 609 ++++++++++++++++++++++++++++ tests/test_config_extra.py | 79 ++++ tests/test_convo_miner.py | 2 +- tests/test_convo_miner_unit.py | 102 +++++ tests/test_entity_detector.py | 120 ++++++ tests/test_knowledge_graph_extra.py | 105 +++++ tests/test_mcp_server.py | 6 +- tests/test_miner.py | 2 +- tests/test_onboarding.py | 280 +++++++++++++ tests/test_room_detector_local.py | 107 +++++ tests/test_spellcheck_extra.py | 72 ++++ 21 files changed, 1494 insertions(+), 18 deletions(-) create mode 100644 tests/test_cli.py create mode 100644 tests/test_config_extra.py create mode 100644 tests/test_convo_miner_unit.py create mode 100644 tests/test_knowledge_graph_extra.py create mode 100644 tests/test_spellcheck_extra.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4fbefcc..0794c95 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "mempalace", "source": "./.claude-plugin", "description": "AI memory system — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, guided setup.", - "version": "3.0.15", + "version": "3.0.11", "author": { "name": "milla-jovovich" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 85daca8..295b247 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mempalace", - "version": "3.0.15", + "version": "3.0.11", "description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.", "author": { "name": "milla-jovovich" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 63578b1..480a21f 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mempalace", - "version": "3.0.15", + "version": "3.0.11", "description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.", "author": { "name": "milla-jovovich" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abf081b..be2a2cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - run: pip install -e ".[dev]" - - run: python -m pytest tests/ -v --cov=mempalace --cov-report=term-missing --cov-fail-under=30 + - run: python -m pytest tests/ -v --cov=mempalace --cov-report=term-missing --cov-fail-under=85 test-windows: runs-on: windows-latest @@ -28,7 +28,7 @@ jobs: with: python-version: "3.9" - run: pip install -e ".[dev]" - - run: python -m pytest tests/ -v --cov=mempalace --cov-report=term-missing --cov-fail-under=30 + - run: python -m pytest tests/ -v --cov=mempalace --cov-report=term-missing --cov-fail-under=85 test-macos: runs-on: macos-latest @@ -38,7 +38,7 @@ jobs: with: python-version: "3.9" - run: pip install -e ".[dev]" - - run: python -m pytest tests/ -v --cov=mempalace --cov-report=term-missing --cov-fail-under=30 + - run: python -m pytest tests/ -v --cov=mempalace --cov-report=term-missing --cov-fail-under=85 lint: runs-on: ubuntu-latest diff --git a/mempalace/entity_registry.py b/mempalace/entity_registry.py index 24fef0a..2a4ad8d 100644 --- a/mempalace/entity_registry.py +++ b/mempalace/entity_registry.py @@ -309,7 +309,7 @@ class EntityRegistry: def save(self): self._path.parent.mkdir(parents=True, exist_ok=True) - self._path.write_text(json.dumps(self._data, indent=2)) + self._path.write_text(json.dumps(self._data, indent=2), encoding="utf-8") @staticmethod def _empty() -> dict: diff --git a/mempalace/hooks_cli.py b/mempalace/hooks_cli.py index fe6e4eb..3f3fc09 100644 --- a/mempalace/hooks_cli.py +++ b/mempalace/hooks_cli.py @@ -150,7 +150,7 @@ def hook_stop(data: dict, harness: str): if since_last >= SAVE_INTERVAL and exchange_count > 0: # Update last save point try: - last_save_file.write_text(str(exchange_count)) + last_save_file.write_text(str(exchange_count), encoding="utf-8") except OSError: pass diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index 2c1bbe6..44880ca 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -44,7 +44,9 @@ def _parse_args(): metavar="PATH", help="Path to the palace directory (overrides config file and env var)", ) - args, _ = parser.parse_known_args() + args, unknown = parser.parse_known_args() + if unknown: + logger.debug("Ignoring unknown args: %s", unknown) return args diff --git a/mempalace/split_mega_files.py b/mempalace/split_mega_files.py index 80bbae4..ae801df 100644 --- a/mempalace/split_mega_files.py +++ b/mempalace/split_mega_files.py @@ -219,7 +219,7 @@ def split_file(filepath, output_dir, dry_run=False): if dry_run: print(f" [{i + 1}/{len(boundaries) - 1}] {name} ({len(chunk)} lines)") else: - out_path.write_text("".join(chunk)) + out_path.write_text("".join(chunk), encoding="utf-8") print(f" ✓ {name} ({len(chunk)} lines)") written.append(out_path) diff --git a/mempalace/version.py b/mempalace/version.py index 94d8249..6bf70fc 100644 --- a/mempalace/version.py +++ b/mempalace/version.py @@ -1,3 +1,3 @@ """Single source of truth for the MemPalace package version.""" -__version__ = "3.0.15" +__version__ = "3.0.11" diff --git a/pyproject.toml b/pyproject.toml index 77487ba..c28715f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mempalace" -version = "3.0.15" +version = "3.0.11" description = "Give your AI a memory — mine projects and conversations into a searchable palace. No API key required." readme = "README.md" requires-python = ">=3.9" @@ -69,7 +69,7 @@ testpaths = ["tests"] source = ["mempalace"] [tool.coverage.report] -fail_under = 60 +fail_under = 85 show_missing = true exclude_lines = [ "if __name__", diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..879d276 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,609 @@ +"""Tests for mempalace.cli — the main CLI dispatcher.""" + +import argparse +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from mempalace.cli import ( + cmd_compress, + cmd_hook, + cmd_init, + cmd_instructions, + cmd_mine, + cmd_repair, + cmd_search, + cmd_split, + cmd_status, + cmd_wakeup, + main, +) + + +# ── cmd_status ───────────────────────────────────────────────────────── + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_status_default_palace(mock_config_cls): + mock_config_cls.return_value.palace_path = "/fake/palace" + args = argparse.Namespace(palace=None) + mock_miner = MagicMock() + with patch.dict("sys.modules", {"mempalace.miner": mock_miner}): + cmd_status(args) + mock_miner.status.assert_called_once_with(palace_path="/fake/palace") + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_status_custom_palace(mock_config_cls): + args = argparse.Namespace(palace="~/my_palace") + mock_miner = MagicMock() + with patch.dict("sys.modules", {"mempalace.miner": mock_miner}): + cmd_status(args) + import os + + expected = os.path.expanduser("~/my_palace") + mock_miner.status.assert_called_once_with(palace_path=expected) + + +# ── cmd_search ───────────────────────────────────────────────────────── + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_search_calls_search(mock_config_cls): + mock_config_cls.return_value.palace_path = "/fake/palace" + args = argparse.Namespace( + palace=None, query="test query", wing="mywing", room="myroom", results=3 + ) + with patch("mempalace.searcher.search") as mock_search: + cmd_search(args) + mock_search.assert_called_once_with( + query="test query", + palace_path="/fake/palace", + wing="mywing", + room="myroom", + n_results=3, + ) + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_search_error_exits(mock_config_cls): + mock_config_cls.return_value.palace_path = "/fake/palace" + args = argparse.Namespace(palace=None, query="q", wing=None, room=None, results=5) + from mempalace.searcher import SearchError + + with patch("mempalace.searcher.search", side_effect=SearchError("fail")): + with pytest.raises(SystemExit) as exc_info: + cmd_search(args) + assert exc_info.value.code == 1 + + +# ── cmd_instructions ─────────────────────────────────────────────────── + + +def test_cmd_instructions_calls_run_instructions(): + args = argparse.Namespace(name="help") + with patch("mempalace.instructions_cli.run_instructions") as mock_run: + cmd_instructions(args) + mock_run.assert_called_once_with(name="help") + + +# ── cmd_hook ─────────────────────────────────────────────────────────── + + +def test_cmd_hook_calls_run_hook(): + args = argparse.Namespace(hook="session-start", harness="claude-code") + with patch("mempalace.hooks_cli.run_hook") as mock_run: + cmd_hook(args) + mock_run.assert_called_once_with(hook_name="session-start", harness="claude-code") + + +# ── cmd_init ─────────────────────────────────────────────────────────── + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_init_no_entities(mock_config_cls, tmp_path): + args = argparse.Namespace(dir=str(tmp_path), yes=True) + with ( + patch("mempalace.entity_detector.scan_for_detection", return_value=[]), + patch("mempalace.room_detector_local.detect_rooms_local") as mock_rooms, + ): + cmd_init(args) + mock_rooms.assert_called_once_with(project_dir=str(tmp_path), yes=True) + mock_config_cls.return_value.init.assert_called_once() + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_init_with_entities(mock_config_cls, tmp_path): + fake_files = [tmp_path / "a.txt"] + detected = {"people": [{"name": "Alice"}], "projects": [], "uncertain": []} + confirmed = {"people": ["Alice"], "projects": []} + args = argparse.Namespace(dir=str(tmp_path), yes=True) + with ( + patch("mempalace.entity_detector.scan_for_detection", return_value=fake_files), + patch("mempalace.entity_detector.detect_entities", return_value=detected), + patch("mempalace.entity_detector.confirm_entities", return_value=confirmed), + patch("mempalace.room_detector_local.detect_rooms_local"), + patch("builtins.open", MagicMock()), + ): + cmd_init(args) + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_init_with_entities_zero_total(mock_config_cls, tmp_path, capsys): + """When entities detected but total is 0, prints 'No entities' message.""" + fake_files = [tmp_path / "a.txt"] + detected = {"people": [], "projects": [], "uncertain": []} + args = argparse.Namespace(dir=str(tmp_path), yes=False) + with ( + patch("mempalace.entity_detector.scan_for_detection", return_value=fake_files), + patch("mempalace.entity_detector.detect_entities", return_value=detected), + patch("mempalace.room_detector_local.detect_rooms_local"), + ): + cmd_init(args) + out = capsys.readouterr().out + assert "No entities detected" in out + + +# ── cmd_mine ─────────────────────────────────────────────────────────── + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_mine_projects_mode(mock_config_cls): + mock_config_cls.return_value.palace_path = "/fake/palace" + args = argparse.Namespace( + dir="/src", + palace=None, + mode="projects", + wing=None, + agent="mempalace", + limit=0, + dry_run=False, + no_gitignore=False, + include_ignored=[], + extract="exchange", + ) + with patch("mempalace.miner.mine") as mock_mine: + cmd_mine(args) + mock_mine.assert_called_once_with( + project_dir="/src", + palace_path="/fake/palace", + wing_override=None, + agent="mempalace", + limit=0, + dry_run=False, + respect_gitignore=True, + include_ignored=[], + ) + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_mine_convos_mode(mock_config_cls): + mock_config_cls.return_value.palace_path = "/fake/palace" + args = argparse.Namespace( + dir="/chats", + palace=None, + mode="convos", + wing="mywing", + agent="me", + limit=10, + dry_run=True, + no_gitignore=False, + include_ignored=[], + extract="general", + ) + with patch("mempalace.convo_miner.mine_convos") as mock_mine: + cmd_mine(args) + mock_mine.assert_called_once_with( + convo_dir="/chats", + palace_path="/fake/palace", + wing="mywing", + agent="me", + limit=10, + dry_run=True, + extract_mode="general", + ) + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_mine_include_ignored_comma_split(mock_config_cls): + mock_config_cls.return_value.palace_path = "/fake/palace" + args = argparse.Namespace( + dir="/src", + palace=None, + mode="projects", + wing=None, + agent="mempalace", + limit=0, + dry_run=False, + no_gitignore=False, + include_ignored=["a.txt,b.txt", "c.txt"], + extract="exchange", + ) + with patch("mempalace.miner.mine") as mock_mine: + cmd_mine(args) + mock_mine.assert_called_once() + call_kwargs = mock_mine.call_args[1] + assert call_kwargs["include_ignored"] == ["a.txt", "b.txt", "c.txt"] + + +# ── cmd_wakeup ───────────────────────────────────────────────────────── + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_wakeup(mock_config_cls, capsys): + mock_config_cls.return_value.palace_path = "/fake/palace" + args = argparse.Namespace(palace=None, wing=None) + mock_stack = MagicMock() + mock_stack.wake_up.return_value = "Hello world context" + with patch("mempalace.layers.MemoryStack", return_value=mock_stack): + cmd_wakeup(args) + out = capsys.readouterr().out + assert "Hello world context" in out + assert "tokens" in out + + +# ── cmd_split ────────────────────────────────────────────────────────── + + +def test_cmd_split_basic(): + args = argparse.Namespace(dir="/chats", output_dir=None, dry_run=False, min_sessions=2) + with patch("mempalace.split_mega_files.main") as mock_main: + cmd_split(args) + mock_main.assert_called_once() + + +def test_cmd_split_all_options(): + args = argparse.Namespace(dir="/chats", output_dir="/out", dry_run=True, min_sessions=5) + with patch("mempalace.split_mega_files.main") as mock_main: + cmd_split(args) + mock_main.assert_called_once() + # sys.argv should be restored + assert sys.argv[0] != "mempalace split" + + +# ── main() argparse dispatch ────────────────────────────────────────── + + +def test_main_no_args_prints_help(capsys): + with patch("sys.argv", ["mempalace"]): + main() + out = capsys.readouterr().out + assert "MemPalace" in out + + +def test_main_status_dispatches(): + with ( + patch("sys.argv", ["mempalace", "status"]), + patch("mempalace.cli.cmd_status") as mock_cmd, + ): + main() + mock_cmd.assert_called_once() + + +def test_main_search_dispatches(): + with ( + patch("sys.argv", ["mempalace", "search", "my query"]), + patch("mempalace.cli.cmd_search") as mock_cmd, + ): + main() + mock_cmd.assert_called_once() + + +def test_main_init_dispatches(): + with ( + patch("sys.argv", ["mempalace", "init", "/some/dir"]), + patch("mempalace.cli.cmd_init") as mock_cmd, + ): + main() + mock_cmd.assert_called_once() + + +def test_main_mine_dispatches(): + with ( + patch("sys.argv", ["mempalace", "mine", "/some/dir"]), + patch("mempalace.cli.cmd_mine") as mock_cmd, + ): + main() + mock_cmd.assert_called_once() + + +def test_main_wakeup_dispatches(): + with ( + patch("sys.argv", ["mempalace", "wake-up"]), + patch("mempalace.cli.cmd_wakeup") as mock_cmd, + ): + main() + mock_cmd.assert_called_once() + + +def test_main_split_dispatches(): + with ( + patch("sys.argv", ["mempalace", "split", "/chats"]), + patch("mempalace.cli.cmd_split") as mock_cmd, + ): + main() + mock_cmd.assert_called_once() + + +def test_main_hook_no_subcommand_prints_help(capsys): + with patch("sys.argv", ["mempalace", "hook"]): + main() + out = capsys.readouterr().out + assert "hook" in out.lower() or "run" in out.lower() + + +def test_main_hook_run_dispatches(): + with ( + patch( + "sys.argv", + ["mempalace", "hook", "run", "--hook", "session-start", "--harness", "claude-code"], + ), + patch("mempalace.cli.cmd_hook") as mock_cmd, + ): + main() + mock_cmd.assert_called_once() + + +def test_main_instructions_no_subcommand_prints_help(capsys): + with patch("sys.argv", ["mempalace", "instructions"]): + main() + out = capsys.readouterr().out + assert "instructions" in out.lower() or "init" in out.lower() + + +def test_main_instructions_dispatches(): + with ( + patch("sys.argv", ["mempalace", "instructions", "help"]), + patch("mempalace.cli.cmd_instructions") as mock_cmd, + ): + main() + mock_cmd.assert_called_once() + + +def test_main_repair_dispatches(): + with ( + patch("sys.argv", ["mempalace", "repair"]), + patch("mempalace.cli.cmd_repair") as mock_cmd, + ): + main() + mock_cmd.assert_called_once() + + +def test_main_compress_dispatches(): + with ( + patch("sys.argv", ["mempalace", "compress"]), + patch("mempalace.cli.cmd_compress") as mock_cmd, + ): + main() + mock_cmd.assert_called_once() + + +# ── cmd_repair ───────────────────────────────────────────────────────── + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_repair_no_palace(mock_config_cls, tmp_path, capsys): + mock_config_cls.return_value.palace_path = str(tmp_path / "nonexistent") + args = argparse.Namespace(palace=None) + mock_chromadb = MagicMock() + with patch.dict("sys.modules", {"chromadb": mock_chromadb}): + cmd_repair(args) + out = capsys.readouterr().out + assert "No palace found" in out + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_repair_error_reading(mock_config_cls, tmp_path, capsys): + palace_dir = tmp_path / "palace" + palace_dir.mkdir() + mock_config_cls.return_value.palace_path = str(palace_dir) + args = argparse.Namespace(palace=None) + mock_chromadb = MagicMock() + mock_client = MagicMock() + mock_client.get_collection.side_effect = Exception("corrupt db") + mock_chromadb.PersistentClient.return_value = mock_client + with patch.dict("sys.modules", {"chromadb": mock_chromadb}): + cmd_repair(args) + out = capsys.readouterr().out + assert "Error reading palace" in out + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_repair_zero_drawers(mock_config_cls, tmp_path, capsys): + palace_dir = tmp_path / "palace" + palace_dir.mkdir() + mock_config_cls.return_value.palace_path = str(palace_dir) + args = argparse.Namespace(palace=None) + mock_chromadb = MagicMock() + mock_col = MagicMock() + mock_col.count.return_value = 0 + mock_client = MagicMock() + mock_client.get_collection.return_value = mock_col + mock_chromadb.PersistentClient.return_value = mock_client + with patch.dict("sys.modules", {"chromadb": mock_chromadb}): + cmd_repair(args) + out = capsys.readouterr().out + assert "Nothing to repair" in out + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_repair_success(mock_config_cls, tmp_path, capsys): + palace_dir = tmp_path / "palace" + palace_dir.mkdir() + mock_config_cls.return_value.palace_path = str(palace_dir) + args = argparse.Namespace(palace=None) + mock_chromadb = MagicMock() + mock_col = MagicMock() + mock_col.count.return_value = 2 + mock_col.get.return_value = { + "ids": ["id1", "id2"], + "documents": ["doc1", "doc2"], + "metadatas": [{"wing": "a"}, {"wing": "b"}], + } + mock_client = MagicMock() + mock_client.get_collection.return_value = mock_col + mock_new_col = MagicMock() + mock_client.create_collection.return_value = mock_new_col + mock_chromadb.PersistentClient.return_value = mock_client + with patch.dict("sys.modules", {"chromadb": mock_chromadb}): + cmd_repair(args) + out = capsys.readouterr().out + assert "Repair complete" in out + assert "2 drawers rebuilt" in out + + +# ── cmd_compress ─────────────────────────────────────────────────────── + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_compress_no_palace(mock_config_cls, capsys): + mock_config_cls.return_value.palace_path = "/fake/palace" + args = argparse.Namespace(palace=None, wing=None, dry_run=False, config=None) + mock_chromadb = MagicMock() + mock_chromadb.PersistentClient.side_effect = Exception("no palace") + with ( + patch.dict("sys.modules", {"chromadb": mock_chromadb}), + pytest.raises(SystemExit), + ): + cmd_compress(args) + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_compress_no_drawers(mock_config_cls, capsys): + mock_config_cls.return_value.palace_path = "/fake/palace" + args = argparse.Namespace(palace=None, wing="mywing", dry_run=False, config=None) + mock_chromadb = MagicMock() + mock_col = MagicMock() + mock_col.get.return_value = {"documents": [], "metadatas": [], "ids": []} + mock_client = MagicMock() + mock_client.get_collection.return_value = mock_col + mock_chromadb.PersistentClient.return_value = mock_client + with patch.dict("sys.modules", {"chromadb": mock_chromadb}): + cmd_compress(args) + out = capsys.readouterr().out + assert "No drawers found" in out + + +def _make_mock_dialect_module(dialect_instance): + """Create a mock dialect module with a Dialect class that returns the given instance.""" + mock_mod = MagicMock() + mock_mod.Dialect.return_value = dialect_instance + mock_mod.Dialect.from_config.return_value = dialect_instance + mock_mod.Dialect.count_tokens = MagicMock(side_effect=lambda x: len(x) // 4) + return mock_mod + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_compress_dry_run(mock_config_cls, capsys): + mock_config_cls.return_value.palace_path = "/fake/palace" + args = argparse.Namespace(palace=None, wing=None, dry_run=True, config=None) + mock_chromadb = MagicMock() + mock_col = MagicMock() + mock_col.get.side_effect = [ + { + "documents": ["some long text here for testing"], + "metadatas": [{"wing": "test", "room": "general", "source_file": "test.txt"}], + "ids": ["id1"], + }, + {"documents": [], "metadatas": [], "ids": []}, + ] + mock_client = MagicMock() + mock_client.get_collection.return_value = mock_col + mock_chromadb.PersistentClient.return_value = mock_client + + mock_dialect = MagicMock() + mock_dialect.compress.return_value = "compressed" + mock_dialect.compression_stats.return_value = { + "original_chars": 100, + "compressed_chars": 30, + "original_tokens": 25, + "compressed_tokens": 8, + "ratio": 3.3, + } + mock_dialect_mod = _make_mock_dialect_module(mock_dialect) + + with patch.dict( + "sys.modules", + { + "chromadb": mock_chromadb, + "mempalace.dialect": mock_dialect_mod, + }, + ): + cmd_compress(args) + out = capsys.readouterr().out + assert "dry run" in out.lower() + assert "Compressing" in out + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_compress_with_config(mock_config_cls, tmp_path, capsys): + mock_config_cls.return_value.palace_path = "/fake/palace" + config_file = tmp_path / "entities.json" + config_file.write_text('{"people": [], "projects": []}') + args = argparse.Namespace(palace=None, wing=None, dry_run=True, config=str(config_file)) + mock_chromadb = MagicMock() + mock_col = MagicMock() + mock_col.get.return_value = {"documents": [], "metadatas": [], "ids": []} + mock_client = MagicMock() + mock_client.get_collection.return_value = mock_col + mock_chromadb.PersistentClient.return_value = mock_client + + mock_dialect = MagicMock() + mock_dialect_mod = _make_mock_dialect_module(mock_dialect) + + with patch.dict( + "sys.modules", + { + "chromadb": mock_chromadb, + "mempalace.dialect": mock_dialect_mod, + }, + ): + cmd_compress(args) + out = capsys.readouterr().out + assert "Loaded entity config" in out + + +@patch("mempalace.cli.MempalaceConfig") +def test_cmd_compress_stores_results(mock_config_cls, capsys): + """Non-dry-run compress stores to mempalace_compressed collection.""" + mock_config_cls.return_value.palace_path = "/fake/palace" + args = argparse.Namespace(palace=None, wing=None, dry_run=False, config=None) + mock_chromadb = MagicMock() + mock_col = MagicMock() + mock_col.get.side_effect = [ + { + "documents": ["text"], + "metadatas": [{"wing": "w", "room": "r", "source_file": "f.txt"}], + "ids": ["id1"], + }, + {"documents": [], "metadatas": [], "ids": []}, + ] + mock_client = MagicMock() + mock_client.get_collection.return_value = mock_col + mock_comp_col = MagicMock() + mock_client.get_or_create_collection.return_value = mock_comp_col + mock_chromadb.PersistentClient.return_value = mock_client + + mock_dialect = MagicMock() + mock_dialect.compress.return_value = "compressed" + mock_dialect.compression_stats.return_value = { + "original_chars": 100, + "compressed_chars": 30, + "original_tokens": 25, + "compressed_tokens": 8, + "ratio": 3.3, + } + mock_dialect_mod = _make_mock_dialect_module(mock_dialect) + + with patch.dict( + "sys.modules", + { + "chromadb": mock_chromadb, + "mempalace.dialect": mock_dialect_mod, + }, + ): + cmd_compress(args) + out = capsys.readouterr().out + assert "Stored" in out + mock_comp_col.upsert.assert_called_once() diff --git a/tests/test_config_extra.py b/tests/test_config_extra.py new file mode 100644 index 0000000..d0d9b5d --- /dev/null +++ b/tests/test_config_extra.py @@ -0,0 +1,79 @@ +"""Extra tests for mempalace.config to cover remaining gaps.""" + +import json +import os + +from mempalace.config import MempalaceConfig + + +def test_config_bad_json(tmp_path): + """Bad JSON in config file falls back to empty.""" + (tmp_path / "config.json").write_text("not json", encoding="utf-8") + cfg = MempalaceConfig(config_dir=str(tmp_path)) + assert cfg.palace_path # still returns default + + +def test_people_map_from_file(tmp_path): + (tmp_path / "people_map.json").write_text(json.dumps({"bob": "Robert"}), encoding="utf-8") + cfg = MempalaceConfig(config_dir=str(tmp_path)) + assert cfg.people_map == {"bob": "Robert"} + + +def test_people_map_bad_json(tmp_path): + (tmp_path / "people_map.json").write_text("bad", encoding="utf-8") + cfg = MempalaceConfig(config_dir=str(tmp_path)) + assert cfg.people_map == {} + + +def test_people_map_missing(tmp_path): + cfg = MempalaceConfig(config_dir=str(tmp_path)) + assert cfg.people_map == {} + + +def test_topic_wings_default(tmp_path): + cfg = MempalaceConfig(config_dir=str(tmp_path)) + assert isinstance(cfg.topic_wings, list) + assert "emotions" in cfg.topic_wings + + +def test_hall_keywords_default(tmp_path): + cfg = MempalaceConfig(config_dir=str(tmp_path)) + assert isinstance(cfg.hall_keywords, dict) + assert "technical" in cfg.hall_keywords + + +def test_init_idempotent(tmp_path): + cfg = MempalaceConfig(config_dir=str(tmp_path)) + cfg.init() + cfg.init() # second call should not overwrite + with open(tmp_path / "config.json") as f: + data = json.load(f) + assert "palace_path" in data + + +def test_save_people_map(tmp_path): + cfg = MempalaceConfig(config_dir=str(tmp_path)) + result = cfg.save_people_map({"alice": "Alice Smith"}) + assert result.exists() + with open(result) as f: + data = json.load(f) + assert data["alice"] == "Alice Smith" + + +def test_env_mempal_palace_path(tmp_path): + """MEMPAL_PALACE_PATH (legacy) should also work.""" + os.environ.pop("MEMPALACE_PALACE_PATH", None) + os.environ["MEMPAL_PALACE_PATH"] = "/legacy/path" + try: + cfg = MempalaceConfig(config_dir=str(tmp_path)) + assert cfg.palace_path == "/legacy/path" + finally: + del os.environ["MEMPAL_PALACE_PATH"] + + +def test_collection_name_from_config(tmp_path): + (tmp_path / "config.json").write_text( + json.dumps({"collection_name": "custom_col"}), encoding="utf-8" + ) + cfg = MempalaceConfig(config_dir=str(tmp_path)) + assert cfg.collection_name == "custom_col" diff --git a/tests/test_convo_miner.py b/tests/test_convo_miner.py index 788c46d..0ac0019 100644 --- a/tests/test_convo_miner.py +++ b/tests/test_convo_miner.py @@ -23,4 +23,4 @@ def test_convo_mining(): results = col.query(query_texts=["memory persistence"], n_results=1) assert len(results["documents"][0]) > 0 - shutil.rmtree(tmpdir) + shutil.rmtree(tmpdir, ignore_errors=True) diff --git a/tests/test_convo_miner_unit.py b/tests/test_convo_miner_unit.py new file mode 100644 index 0000000..3c7e8f2 --- /dev/null +++ b/tests/test_convo_miner_unit.py @@ -0,0 +1,102 @@ +"""Unit tests for convo_miner pure functions (no chromadb needed).""" + +from mempalace.convo_miner import ( + chunk_exchanges, + detect_convo_room, + scan_convos, +) + + +class TestChunkExchanges: + def test_exchange_chunking(self): + content = ( + "> What is memory?\n" + "Memory is persistence of information over time.\n\n" + "> Why does it matter?\n" + "It enables continuity across sessions and conversations.\n\n" + "> How do we build it?\n" + "With structured storage and retrieval mechanisms.\n" + ) + chunks = chunk_exchanges(content) + assert len(chunks) >= 2 + assert all("content" in c and "chunk_index" in c for c in chunks) + + def test_paragraph_fallback(self): + """Content without '>' lines falls back to paragraph chunking.""" + content = ( + "This is a long paragraph about memory systems. " * 10 + "\n\n" + "This is another paragraph about storage. " * 10 + "\n\n" + "And a third paragraph about retrieval. " * 10 + ) + chunks = chunk_exchanges(content) + assert len(chunks) >= 2 + + def test_paragraph_line_group_fallback(self): + """Long content with no paragraph breaks chunks by line groups.""" + lines = [f"Line {i}: some content that is meaningful" for i in range(60)] + content = "\n".join(lines) + chunks = chunk_exchanges(content) + assert len(chunks) >= 1 + + def test_empty_content(self): + chunks = chunk_exchanges("") + assert chunks == [] + + def test_short_content_skipped(self): + chunks = chunk_exchanges("> hi\nbye") + # Too short to produce chunks (below MIN_CHUNK_SIZE) + assert isinstance(chunks, list) + + +class TestDetectConvoRoom: + def test_technical_room(self): + content = "Let me debug this python function and fix the code error in the api" + assert detect_convo_room(content) == "technical" + + def test_planning_room(self): + content = "We need to plan the roadmap for the next sprint and set milestone deadlines" + assert detect_convo_room(content) == "planning" + + def test_architecture_room(self): + content = "The architecture uses a service layer with component interface and module design" + assert detect_convo_room(content) == "architecture" + + def test_decisions_room(self): + content = "We decided to switch and migrated to the new framework after we chose it" + assert detect_convo_room(content) == "decisions" + + def test_general_fallback(self): + content = "Hello, how are you doing today? The weather is nice." + assert detect_convo_room(content) == "general" + + +class TestScanConvos: + def test_scan_finds_txt_and_md(self, tmp_path): + (tmp_path / "chat.txt").write_text("hello", encoding="utf-8") + (tmp_path / "notes.md").write_text("world", encoding="utf-8") + (tmp_path / "image.png").write_bytes(b"fake") + files = scan_convos(str(tmp_path)) + extensions = {f.suffix for f in files} + assert ".txt" in extensions + assert ".md" in extensions + assert ".png" not in extensions + + def test_scan_skips_git_dir(self, tmp_path): + git_dir = tmp_path / ".git" + git_dir.mkdir() + (git_dir / "config.txt").write_text("git stuff", encoding="utf-8") + (tmp_path / "chat.txt").write_text("hello", encoding="utf-8") + files = scan_convos(str(tmp_path)) + assert len(files) == 1 + + def test_scan_skips_meta_json(self, tmp_path): + (tmp_path / "chat.meta.json").write_text("{}", encoding="utf-8") + (tmp_path / "chat.json").write_text("{}", encoding="utf-8") + files = scan_convos(str(tmp_path)) + names = [f.name for f in files] + assert "chat.json" in names + assert "chat.meta.json" not in names + + def test_scan_empty_dir(self, tmp_path): + files = scan_convos(str(tmp_path)) + assert files == [] diff --git a/tests/test_entity_detector.py b/tests/test_entity_detector.py index 0fad76b..91f0e29 100644 --- a/tests/test_entity_detector.py +++ b/tests/test_entity_detector.py @@ -1,11 +1,14 @@ """Tests for mempalace.entity_detector.""" import os +from unittest.mock import patch from mempalace.entity_detector import ( PROSE_EXTENSIONS, STOPWORDS, + _print_entity_list, classify_entity, + confirm_entities, detect_entities, extract_candidates, scan_for_detection, @@ -258,3 +261,120 @@ def test_stopwords_contains_common_words(): def test_prose_extensions(): assert ".txt" in PROSE_EXTENSIONS assert ".md" in PROSE_EXTENSIONS + + +# ── _print_entity_list ───────────────────────────────────────────────── + + +def test_print_entity_list_with_entities(capsys): + entities = [ + {"name": "Alice", "confidence": 0.9, "signals": ["dialogue marker (3x)"]}, + {"name": "Bob", "confidence": 0.5, "signals": []}, + ] + _print_entity_list(entities, "PEOPLE") + out = capsys.readouterr().out + assert "PEOPLE" in out + assert "Alice" in out + assert "Bob" in out + + +def test_print_entity_list_empty(capsys): + _print_entity_list([], "PEOPLE") + out = capsys.readouterr().out + assert "none detected" in out + + +# ── confirm_entities ─────────────────────────────────────────────────── + + +def test_confirm_entities_yes_mode(): + detected = { + "people": [{"name": "Alice", "confidence": 0.9, "signals": ["test"]}], + "projects": [{"name": "Acme", "confidence": 0.8, "signals": ["test"]}], + "uncertain": [{"name": "Foo", "confidence": 0.4, "signals": ["test"]}], + } + result = confirm_entities(detected, yes=True) + assert result["people"] == ["Alice"] + assert result["projects"] == ["Acme"] + + +def test_confirm_entities_accept_all(): + detected = { + "people": [{"name": "Alice", "confidence": 0.9, "signals": ["test"]}], + "projects": [], + "uncertain": [], + } + with patch("builtins.input", side_effect=["", "n"]): + result = confirm_entities(detected, yes=False) + assert "Alice" in result["people"] + + +def test_confirm_entities_edit_reclassify_uncertain(): + detected = { + "people": [], + "projects": [], + "uncertain": [ + {"name": "Foo", "confidence": 0.4, "signals": ["test"]}, + {"name": "Bar", "confidence": 0.4, "signals": ["test"]}, + ], + } + with patch( + "builtins.input", + side_effect=[ + "edit", # choice + "p", # Foo -> person + "s", # Bar -> skip + "", # no removals from people + "", # no removals from projects + "n", # don't add missing + ], + ): + result = confirm_entities(detected, yes=False) + assert "Foo" in result["people"] + assert "Bar" not in result["people"] + assert "Bar" not in result["projects"] + + +def test_confirm_entities_add_mode(): + detected = { + "people": [], + "projects": [], + "uncertain": [], + } + with patch( + "builtins.input", + side_effect=[ + "add", # choice = add + "NewPerson", # name + "p", # person + "NewProj", # name + "r", # project + "", # stop adding + ], + ): + result = confirm_entities(detected, yes=False) + assert "NewPerson" in result["people"] + assert "NewProj" in result["projects"] + + +# ── scan_for_detection fallback ──────────────────────────────────────── + + +def test_scan_for_detection_fallback_to_all_readable(tmp_path): + """When fewer than 3 prose files, falls back to include all readable files.""" + (tmp_path / "one.md").write_text("hello") + (tmp_path / "two.txt").write_text("world") + # Only 2 prose files, so it should also include code files + (tmp_path / "code.py").write_text("import os") + (tmp_path / "app.js").write_text("console.log()") + files = scan_for_detection(str(tmp_path)) + extensions = {os.path.splitext(str(f))[1] for f in files} + assert ".py" in extensions or ".js" in extensions + + +def test_scan_for_detection_max_files(tmp_path): + """Caps to max_files.""" + for i in range(20): + (tmp_path / f"note{i}.md").write_text(f"content {i}") + files = scan_for_detection(str(tmp_path), max_files=5) + assert len(files) <= 5 diff --git a/tests/test_knowledge_graph_extra.py b/tests/test_knowledge_graph_extra.py new file mode 100644 index 0000000..29605bb --- /dev/null +++ b/tests/test_knowledge_graph_extra.py @@ -0,0 +1,105 @@ +"""Extra knowledge graph tests for seed_from_entity_facts and query_relationship.""" + +import pytest + +from mempalace.knowledge_graph import KnowledgeGraph + + +@pytest.fixture +def kg(tmp_path): + return KnowledgeGraph(db_path=str(tmp_path / "kg.db")) + + +class TestSeedFromEntityFacts: + def test_seed_person_with_partner(self, kg): + facts = { + "alice": { + "full_name": "Alice Smith", + "type": "person", + "gender": "female", + "partner": "bob", + "relationship": "husband", + } + } + kg.seed_from_entity_facts(facts) + stats = kg.stats() + assert stats["entities"] >= 1 + results = kg.query_entity("Alice Smith", direction="outgoing") + predicates = {r["predicate"] for r in results} + assert "married_to" in predicates + assert "is_partner_of" in predicates + + def test_seed_child(self, kg): + facts = { + "max": { + "full_name": "Max", + "type": "person", + "birthday": "2015-04-01", + "parent": "alice", + "relationship": "daughter", + } + } + kg.seed_from_entity_facts(facts) + results = kg.query_entity("Max", direction="outgoing") + predicates = {r["predicate"] for r in results} + assert "child_of" in predicates + assert "is_child_of" in predicates + + def test_seed_sibling(self, kg): + facts = { + "emma": { + "full_name": "Emma", + "type": "person", + "relationship": "brother", + "sibling": "max", + } + } + kg.seed_from_entity_facts(facts) + results = kg.query_entity("Emma", direction="outgoing") + predicates = {r["predicate"] for r in results} + assert "is_sibling_of" in predicates + + def test_seed_dog(self, kg): + facts = { + "rex": { + "full_name": "Rex", + "type": "animal", + "relationship": "dog", + "owner": "alice", + } + } + kg.seed_from_entity_facts(facts) + results = kg.query_entity("Rex", direction="outgoing") + predicates = {r["predicate"] for r in results} + assert "is_pet_of" in predicates + + def test_seed_with_interests(self, kg): + facts = { + "max": { + "full_name": "Max", + "type": "person", + "interests": ["swimming", "chess"], + } + } + kg.seed_from_entity_facts(facts) + results = kg.query_entity("Max", direction="outgoing") + objects = {r["object"] for r in results if r["predicate"] == "loves"} + assert "Swimming" in objects + assert "Chess" in objects + + def test_seed_minimal_facts(self, kg): + """Facts with no relationships just create entities.""" + facts = {"bob": {"full_name": "Bob"}} + kg.seed_from_entity_facts(facts) + stats = kg.stats() + assert stats["entities"] >= 1 + + +class TestQueryRelationshipWithTime: + def test_query_relationship_with_as_of(self, kg): + kg.add_triple("Alice", "works_at", "Acme", valid_from="2020-01-01", valid_to="2024-12-31") + kg.add_triple("Alice", "works_at", "NewCo", valid_from="2025-01-01") + results = kg.query_relationship("works_at", as_of="2023-06-01") + objects = [r["object"] for r in results] + assert "Acme" in objects + assert "NewCo" not in objects diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 09a3c46..2c7db31 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -13,9 +13,9 @@ def _patch_mcp_server(monkeypatch, config, palace_path, kg): """Patch the mcp_server module globals to use test fixtures.""" from mempalace import mcp_server - assert getattr(config, "palace_path", None) == palace_path, ( - f"config.palace_path ({getattr(config, 'palace_path', None)!r}) does not match palace_path fixture ({palace_path!r})" - ) + assert ( + getattr(config, "palace_path", None) == palace_path + ), f"config.palace_path ({getattr(config, 'palace_path', None)!r}) does not match palace_path fixture ({palace_path!r})" monkeypatch.setattr(mcp_server, "_config", config) monkeypatch.setattr(mcp_server, "_kg", kg) diff --git a/tests/test_miner.py b/tests/test_miner.py index 337e949..efe55a7 100644 --- a/tests/test_miner.py +++ b/tests/test_miner.py @@ -47,7 +47,7 @@ def test_project_mining(): col = client.get_collection("mempalace_drawers") assert col.count() > 0 finally: - shutil.rmtree(tmpdir) + shutil.rmtree(tmpdir, ignore_errors=True) def test_scan_project_respects_gitignore(): diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 7a0f903..ea7a37b 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -1,12 +1,23 @@ """Tests for mempalace.onboarding.""" import os +from unittest.mock import patch from mempalace.onboarding import ( DEFAULT_WINGS, + _ask, + _ask_mode, + _ask_people, + _ask_projects, + _ask_wings, + _auto_detect, _generate_aaak_bootstrap, + _header, + _hr, _warn_ambiguous, + _yn, quick_setup, + run_onboarding, ) # Force UTF-8 for Windows (source file contains Unicode symbols like hearts/stars) @@ -170,3 +181,272 @@ def test_generate_aaak_bootstrap_empty_people(tmp_path): _generate_aaak_bootstrap([], [], ["general"], "personal", config_dir=tmp_path) assert (tmp_path / "aaak_entities.md").exists() assert (tmp_path / "critical_facts.md").exists() + + +def test_generate_aaak_bootstrap_collision(tmp_path): + """Two people with same 3-letter code get different codes.""" + people = [ + {"name": "Alice", "relationship": "friend", "context": "work"}, + {"name": "Alison", "relationship": "coworker", "context": "work"}, + ] + _generate_aaak_bootstrap(people, [], ["work"], "work", config_dir=tmp_path) + content = (tmp_path / "aaak_entities.md").read_text() + assert "ALI" in content + assert "ALIS" in content + + +def test_generate_aaak_bootstrap_no_relationship(tmp_path): + """Person without relationship string still generates entry.""" + people = [{"name": "Bob", "context": "work"}] + _generate_aaak_bootstrap(people, [], ["work"], "work", config_dir=tmp_path) + content = (tmp_path / "aaak_entities.md").read_text() + assert "BOB=Bob" in content + + +# ── _hr, _header ────────────────────────────────────────────────────── + + +def test_hr_prints_line(capsys): + _hr() + out = capsys.readouterr().out + assert "─" in out + + +def test_header_prints_banner(capsys): + _header("Test Title") + out = capsys.readouterr().out + assert "Test Title" in out + assert "=" in out + + +# ── _ask ────────────────────────────────────────────────────────────── + + +def test_ask_with_default_uses_default(): + with patch("builtins.input", return_value=""): + result = _ask("prompt", default="fallback") + assert result == "fallback" + + +def test_ask_with_default_uses_input(): + with patch("builtins.input", return_value="custom"): + result = _ask("prompt", default="fallback") + assert result == "custom" + + +def test_ask_no_default(): + with patch("builtins.input", return_value="answer"): + result = _ask("prompt") + assert result == "answer" + + +# ── _yn ─────────────────────────────────────────────────────────────── + + +def test_yn_default_yes_empty_input(): + with patch("builtins.input", return_value=""): + assert _yn("continue?") is True + + +def test_yn_default_no_empty_input(): + with patch("builtins.input", return_value=""): + assert _yn("continue?", default="n") is False + + +def test_yn_explicit_yes(): + with patch("builtins.input", return_value="yes"): + assert _yn("continue?", default="n") is True + + +def test_yn_explicit_no(): + with patch("builtins.input", return_value="no"): + assert _yn("continue?") is False + + +# ── _ask_mode ───────────────────────────────────────────────────────── + + +def test_ask_mode_work(): + with patch("builtins.input", return_value="1"): + assert _ask_mode() == "work" + + +def test_ask_mode_personal(): + with patch("builtins.input", return_value="2"): + assert _ask_mode() == "personal" + + +def test_ask_mode_combo(): + with patch("builtins.input", return_value="3"): + assert _ask_mode() == "combo" + + +def test_ask_mode_retries_on_bad_input(): + with patch("builtins.input", side_effect=["x", "bad", "1"]): + assert _ask_mode() == "work" + + +# ── _ask_people ─────────────────────────────────────────────────────── + + +def test_ask_people_personal_mode(): + with patch("builtins.input", side_effect=["Alice, daughter", "", "done"]): + people, aliases = _ask_people("personal") + assert len(people) == 1 + assert people[0]["name"] == "Alice" + assert people[0]["relationship"] == "daughter" + + +def test_ask_people_work_mode(): + with patch("builtins.input", side_effect=["Bob, manager", "", "done"]): + people, aliases = _ask_people("work") + assert len(people) == 1 + assert people[0]["name"] == "Bob" + assert people[0]["context"] == "work" + + +def test_ask_people_combo_mode(): + with patch( + "builtins.input", + side_effect=[ + "Alice, daughter", + "", + "done", # personal + "Bob, boss", + "done", # work + ], + ): + people, aliases = _ask_people("combo") + assert len(people) == 2 + + +def test_ask_people_with_nickname(): + with patch("builtins.input", side_effect=["Alice, daughter", "Ali", "done"]): + people, aliases = _ask_people("personal") + assert aliases == {"Ali": "Alice"} + + +def test_ask_people_empty_name_skipped(): + with patch("builtins.input", side_effect=["", "done"]): + people, aliases = _ask_people("personal") + assert len(people) == 0 + + +# ── _ask_projects ───────────────────────────────────────────────────── + + +def test_ask_projects_personal_returns_empty(): + result = _ask_projects("personal") + assert result == [] + + +def test_ask_projects_work_mode(): + with patch("builtins.input", side_effect=["Acme", "BigCo", "done"]): + result = _ask_projects("work") + assert result == ["Acme", "BigCo"] + + +def test_ask_projects_empty_entry_stops(): + with patch("builtins.input", side_effect=["Acme", ""]): + result = _ask_projects("work") + assert result == ["Acme"] + + +# ── _ask_wings ──────────────────────────────────────────────────────── + + +def test_ask_wings_accept_defaults(): + with patch("builtins.input", return_value=""): + result = _ask_wings("work") + assert result == DEFAULT_WINGS["work"] + + +def test_ask_wings_custom(): + with patch("builtins.input", return_value="alpha, beta, gamma"): + result = _ask_wings("personal") + assert result == ["alpha", "beta", "gamma"] + + +# ── _auto_detect ────────────────────────────────────────────────────── + + +def test_auto_detect_no_files(tmp_path): + result = _auto_detect(str(tmp_path), []) + assert result == [] + + +def test_auto_detect_filters_known(tmp_path): + known = [{"name": "Alice"}] + fake_detected = { + "people": [ + {"name": "Alice", "confidence": 0.9, "signals": ["test"]}, + {"name": "Bob", "confidence": 0.8, "signals": ["test"]}, + ], + "projects": [], + "uncertain": [], + } + with ( + patch("mempalace.onboarding.scan_for_detection", return_value=["file.txt"]), + patch("mempalace.onboarding.detect_entities", return_value=fake_detected), + ): + result = _auto_detect(str(tmp_path), known) + names = [p["name"] for p in result] + assert "Alice" not in names + assert "Bob" in names + + +def test_auto_detect_filters_low_confidence(tmp_path): + fake_detected = { + "people": [{"name": "Bob", "confidence": 0.5, "signals": ["test"]}], + "projects": [], + "uncertain": [], + } + with ( + patch("mempalace.onboarding.scan_for_detection", return_value=["file.txt"]), + patch("mempalace.onboarding.detect_entities", return_value=fake_detected), + ): + result = _auto_detect(str(tmp_path), []) + assert len(result) == 0 + + +def test_auto_detect_handles_exception(tmp_path): + with patch("mempalace.onboarding.scan_for_detection", side_effect=Exception("boom")): + result = _auto_detect(str(tmp_path), []) + assert result == [] + + +# ── run_onboarding ──────────────────────────────────────────────────── + + +def test_run_onboarding_basic_flow(tmp_path): + """Test the full onboarding flow with minimal mocking.""" + with ( + patch("mempalace.onboarding._ask_mode", return_value="work"), + patch( + "mempalace.onboarding._ask_people", + return_value=([{"name": "Bob", "relationship": "boss", "context": "work"}], {}), + ), + patch("mempalace.onboarding._ask_projects", return_value=["Acme"]), + patch("mempalace.onboarding._ask_wings", return_value=["projects", "team"]), + patch("mempalace.onboarding._yn", return_value=False), + patch("mempalace.onboarding._warn_ambiguous", return_value=[]), + ): + registry = run_onboarding(directory=".", config_dir=tmp_path, auto_detect=False) + assert "Bob" in registry.people + assert "Acme" in registry.projects + + +def test_run_onboarding_with_ambiguous_names(tmp_path): + """Onboarding prints a warning for ambiguous names.""" + with ( + patch("mempalace.onboarding._ask_mode", return_value="personal"), + patch( + "mempalace.onboarding._ask_people", + return_value=([{"name": "Grace", "relationship": "friend", "context": "personal"}], {}), + ), + patch("mempalace.onboarding._ask_projects", return_value=[]), + patch("mempalace.onboarding._ask_wings", return_value=["family"]), + patch("mempalace.onboarding._yn", return_value=False), + ): + registry = run_onboarding(directory=".", config_dir=tmp_path, auto_detect=False) + assert "Grace" in registry.people diff --git a/tests/test_room_detector_local.py b/tests/test_room_detector_local.py index a64949f..11963e4 100644 --- a/tests/test_room_detector_local.py +++ b/tests/test_room_detector_local.py @@ -1,9 +1,14 @@ """Tests for mempalace.room_detector_local.""" +from unittest.mock import MagicMock, patch + from mempalace.room_detector_local import ( FOLDER_ROOM_MAP, detect_rooms_from_files, detect_rooms_from_folders, + detect_rooms_local, + get_user_approval, + print_proposed_structure, save_config, ) @@ -155,3 +160,105 @@ def test_save_config_valid_yaml(tmp_path): assert data["wing"] == "test_proj" assert len(data["rooms"]) == 1 assert data["rooms"][0]["name"] == "general" + + +# ── print_proposed_structure ────────────────────────────────────────── + + +def test_print_proposed_structure(capsys): + rooms = [ + {"name": "frontend", "description": "UI files"}, + {"name": "general", "description": "Everything else"}, + ] + print_proposed_structure("myapp", rooms, 42, "folder structure") + out = capsys.readouterr().out + assert "myapp" in out + assert "frontend" in out + assert "42 files" in out + assert "folder structure" in out + + +# ── get_user_approval ───────────────────────────────────────────────── + + +def test_get_user_approval_accept_all(): + rooms = [{"name": "frontend", "description": "UI"}] + with patch("builtins.input", return_value=""): + result = get_user_approval(rooms) + assert result == rooms + + +def test_get_user_approval_edit_remove(): + rooms = [ + {"name": "frontend", "description": "UI"}, + {"name": "backend", "description": "Server"}, + ] + with patch("builtins.input", side_effect=["edit", "1", "n"]): + result = get_user_approval(rooms) + # Room 1 (frontend) removed + assert len(result) == 1 + assert result[0]["name"] == "backend" + + +def test_get_user_approval_add_room(): + rooms = [{"name": "general", "description": "All files"}] + with patch( + "builtins.input", + side_effect=[ + "add", + "custom_room", + "My custom room", + "", + ], + ): + result = get_user_approval(rooms) + names = [r["name"] for r in result] + assert "custom_room" in names + + +# ── detect_rooms_local ──────────────────────────────────────────────── + + +def test_detect_rooms_local_yes_mode(tmp_path): + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "readme.md").write_text("hello") + mock_miner = MagicMock() + mock_miner.scan_project.return_value = ["file1.py"] + with patch.dict("sys.modules", {"mempalace.miner": mock_miner}): + detect_rooms_local(str(tmp_path), yes=True) + assert (tmp_path / "mempalace.yaml").exists() + + +def test_detect_rooms_local_fallback_to_files(tmp_path): + """When folder detection gives only 'general', falls back to file patterns.""" + for i in range(3): + (tmp_path / f"test_file_{i}.py").write_text("content") + mock_miner = MagicMock() + mock_miner.scan_project.return_value = ["f1", "f2"] + with patch.dict("sys.modules", {"mempalace.miner": mock_miner}): + detect_rooms_local(str(tmp_path), yes=True) + assert (tmp_path / "mempalace.yaml").exists() + + +def test_detect_rooms_local_missing_dir(): + """Non-existent directory causes sys.exit.""" + import pytest + + with pytest.raises(SystemExit): + detect_rooms_local("/nonexistent/path/that/does/not/exist", yes=True) + + +def test_detect_rooms_local_interactive(tmp_path): + (tmp_path / "src").mkdir() + (tmp_path / "src" / "main.py").write_text("code") + mock_miner = MagicMock() + mock_miner.scan_project.return_value = ["f1"] + with ( + patch.dict("sys.modules", {"mempalace.miner": mock_miner}), + patch( + "mempalace.room_detector_local.get_user_approval", + return_value=[{"name": "general", "description": "All files", "keywords": []}], + ), + ): + detect_rooms_local(str(tmp_path), yes=False) + assert (tmp_path / "mempalace.yaml").exists() diff --git a/tests/test_spellcheck_extra.py b/tests/test_spellcheck_extra.py new file mode 100644 index 0000000..567cb01 --- /dev/null +++ b/tests/test_spellcheck_extra.py @@ -0,0 +1,72 @@ +"""Extra spellcheck tests covering _load_known_names and speller edge cases.""" + +from unittest.mock import patch, MagicMock + +from mempalace.spellcheck import ( + _load_known_names, + spellcheck_user_text, +) + + +class TestLoadKnownNames: + def test_returns_names_from_registry(self): + mock_reg = MagicMock() + mock_reg._data = { + "entities": { + "e1": {"canonical": "Alice", "aliases": ["ali"]}, + "e2": {"canonical": "Bob", "aliases": []}, + } + } + with patch("mempalace.entity_registry.EntityRegistry") as MockER: + MockER.load.return_value = mock_reg + names = _load_known_names() + assert "alice" in names + assert "ali" in names + assert "bob" in names + + def test_returns_empty_on_exception(self): + with patch( + "mempalace.entity_registry.EntityRegistry.load", + side_effect=Exception("no registry"), + ): + names = _load_known_names() + assert names == set() + + +class TestSpellerEdgeCases: + def test_capitalized_word_skipped(self): + """Capitalized words (likely proper nouns) are not corrected.""" + + def fake_speller(word): + return "WRONG" + + with patch("mempalace.spellcheck._get_speller", return_value=fake_speller): + with patch("mempalace.spellcheck._get_system_words", return_value=set()): + with patch("mempalace.spellcheck._load_known_names", return_value=set()): + result = spellcheck_user_text("Alice went home") + assert "Alice" in result + assert "WRONG" not in result + + def test_system_word_not_corrected(self): + """Words in system dict should not be corrected.""" + + def fake_speller(word): + return "WRONG" + + with patch("mempalace.spellcheck._get_speller", return_value=fake_speller): + with patch("mempalace.spellcheck._get_system_words", return_value={"coherently"}): + with patch("mempalace.spellcheck._load_known_names", return_value=set()): + result = spellcheck_user_text("coherently") + assert "coherently" in result + + def test_high_edit_distance_rejected(self): + """Corrections with too many edits are rejected.""" + + def fake_speller(word): + return "completely_different_word" + + with patch("mempalace.spellcheck._get_speller", return_value=fake_speller): + with patch("mempalace.spellcheck._get_system_words", return_value=set()): + with patch("mempalace.spellcheck._load_known_names", return_value=set()): + result = spellcheck_user_text("hello") + assert "hello" in result