abd52534bb
- 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>
453 lines
15 KiB
Python
453 lines
15 KiB
Python
"""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)
|
|
os.environ["PYTHONUTF8"] = "1"
|
|
|
|
|
|
# ── DEFAULT_WINGS ───────────────────────────────────────────────────────
|
|
|
|
|
|
def test_default_wings_has_expected_keys():
|
|
assert "work" in DEFAULT_WINGS
|
|
assert "personal" in DEFAULT_WINGS
|
|
assert "combo" in DEFAULT_WINGS
|
|
|
|
|
|
def test_default_wings_work_has_projects():
|
|
assert "projects" in DEFAULT_WINGS["work"]
|
|
|
|
|
|
def test_default_wings_personal_has_family():
|
|
assert "family" in DEFAULT_WINGS["personal"]
|
|
|
|
|
|
def test_default_wings_combo_has_both():
|
|
wings = DEFAULT_WINGS["combo"]
|
|
assert "family" in wings
|
|
assert "work" in wings
|
|
|
|
|
|
def test_default_wings_values_are_lists():
|
|
for mode, wings in DEFAULT_WINGS.items():
|
|
assert isinstance(wings, list), f"{mode} wings should be a list"
|
|
assert len(wings) >= 3, f"{mode} should have at least 3 wings"
|
|
|
|
|
|
# ── _warn_ambiguous ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_warn_ambiguous_flags_common_words():
|
|
people = [
|
|
{"name": "Grace", "relationship": "friend"},
|
|
{"name": "Riley", "relationship": "daughter"},
|
|
]
|
|
result = _warn_ambiguous(people)
|
|
assert "Grace" in result
|
|
# Riley is not a common English word
|
|
assert "Riley" not in result
|
|
|
|
|
|
def test_warn_ambiguous_empty_list():
|
|
result = _warn_ambiguous([])
|
|
assert result == []
|
|
|
|
|
|
def test_warn_ambiguous_no_ambiguous_names():
|
|
people = [
|
|
{"name": "Riley", "relationship": "daughter"},
|
|
{"name": "Devon", "relationship": "friend"},
|
|
]
|
|
result = _warn_ambiguous(people)
|
|
assert result == []
|
|
|
|
|
|
def test_warn_ambiguous_multiple_hits():
|
|
people = [
|
|
{"name": "Grace", "relationship": "friend"},
|
|
{"name": "May", "relationship": "aunt"},
|
|
{"name": "Joy", "relationship": "sister"},
|
|
]
|
|
result = _warn_ambiguous(people)
|
|
assert "Grace" in result
|
|
assert "May" in result
|
|
assert "Joy" in result
|
|
|
|
|
|
# ── quick_setup ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_quick_setup_creates_registry(tmp_path):
|
|
registry = quick_setup(
|
|
mode="personal",
|
|
people=[{"name": "Riley", "relationship": "daughter", "context": "personal"}],
|
|
projects=["MemPalace"],
|
|
config_dir=tmp_path,
|
|
)
|
|
assert "Riley" in registry.people
|
|
assert "MemPalace" in registry.projects
|
|
assert registry.mode == "personal"
|
|
|
|
|
|
def test_quick_setup_work_mode(tmp_path):
|
|
registry = quick_setup(
|
|
mode="work",
|
|
people=[{"name": "Alice", "relationship": "colleague", "context": "work"}],
|
|
projects=["Acme"],
|
|
config_dir=tmp_path,
|
|
)
|
|
assert registry.mode == "work"
|
|
assert "Alice" in registry.people
|
|
assert "Acme" in registry.projects
|
|
|
|
|
|
def test_quick_setup_empty(tmp_path):
|
|
registry = quick_setup(mode="personal", people=[], config_dir=tmp_path)
|
|
assert len(registry.people) == 0
|
|
assert len(registry.projects) == 0
|
|
|
|
|
|
def test_quick_setup_saves_to_disk(tmp_path):
|
|
quick_setup(
|
|
mode="personal",
|
|
people=[{"name": "Riley", "relationship": "daughter", "context": "personal"}],
|
|
config_dir=tmp_path,
|
|
)
|
|
assert (tmp_path / "entity_registry.json").exists()
|
|
|
|
|
|
# ── _generate_aaak_bootstrap ───────────────────────────────────────────
|
|
|
|
|
|
def test_generate_aaak_bootstrap_creates_files(tmp_path):
|
|
people = [
|
|
{"name": "Riley", "relationship": "daughter", "context": "personal"},
|
|
{"name": "Devon", "relationship": "friend", "context": "personal"},
|
|
]
|
|
projects = ["MemPalace"]
|
|
wings = ["family", "creative"]
|
|
_generate_aaak_bootstrap(people, projects, wings, "personal", config_dir=tmp_path)
|
|
|
|
assert (tmp_path / "aaak_entities.md").exists()
|
|
assert (tmp_path / "critical_facts.md").exists()
|
|
|
|
|
|
def test_generate_aaak_bootstrap_entities_content(tmp_path):
|
|
people = [{"name": "Riley", "relationship": "daughter", "context": "personal"}]
|
|
projects = ["MemPalace"]
|
|
wings = ["family"]
|
|
_generate_aaak_bootstrap(people, projects, wings, "personal", config_dir=tmp_path)
|
|
|
|
content = (tmp_path / "aaak_entities.md").read_text()
|
|
assert "Riley" in content
|
|
assert "RIL" in content # entity code
|
|
assert "MemPalace" in content
|
|
|
|
|
|
def test_generate_aaak_bootstrap_facts_content(tmp_path):
|
|
people = [
|
|
{"name": "Alice", "relationship": "colleague", "context": "work"},
|
|
]
|
|
projects = ["Acme"]
|
|
wings = ["projects"]
|
|
_generate_aaak_bootstrap(people, projects, wings, "work", config_dir=tmp_path)
|
|
|
|
content = (tmp_path / "critical_facts.md").read_text()
|
|
assert "Alice" in content
|
|
assert "Acme" in content
|
|
assert "work" in content.lower()
|
|
|
|
|
|
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
|