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:
@@ -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,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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
"""Single source of truth for the MemPalace package version."""
|
||||
|
||||
__version__ = "3.0.15"
|
||||
__version__ = "3.0.11"
|
||||
|
||||
+2
-2
@@ -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__",
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user