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 <noreply@anthropic.com>
This commit is contained in:
Tal Muskal
2026-04-08 21:38:12 +03:00
parent f47ca8a122
commit abd52534bb
21 changed files with 1494 additions and 18 deletions
+1 -1
View File
@@ -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"
}
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the MemPalace package version."""
__version__ = "3.0.15"
__version__ = "3.0.11"
+2 -2
View File
@@ -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__",
+609
View File
@@ -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()
+79
View File
@@ -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"
+1 -1
View File
@@ -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)
+102
View File
@@ -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 == []
+120
View File
@@ -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
+105
View File
@@ -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
+3 -3
View File
@@ -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)
+1 -1
View File
@@ -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():
+280
View File
@@ -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
+107
View File
@@ -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()
+72
View File
@@ -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