Files
wehub-resource-sync 75c67150d0
build / build (3.13) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build wheels (macos-aarch64) (push) Has been cancelled
release-please / build wheels (macos-x86_64) (push) Has been cancelled
release-please / build wheels (windows-x86_64) (push) Has been cancelled
release-please / build wheels (linux-aarch64) (push) Has been cancelled
release-please / build wheels (linux-x86_64) (push) Has been cancelled
release-please / build sdist (push) Has been cancelled
release-please / publish release artifacts (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:36:10 +08:00

165 lines
6.3 KiB
Python

from __future__ import annotations
import json
from pathlib import Path
import pytest
from memu.app import MemoryService
from memu.memory_fs import MemoryFileExporter
from memu.memory_fs.exporter import MANIFEST_NAME
# With synthesize=False (the default) the whole tree is LLM-free: MEMORY.md/SKILL.md
# are rendered as link indexes, and the memory/ and skill/ trees are rendered one file
# per RecallFile of their track (ADR 0006).
def _build_service(output_dir: Path) -> MemoryService:
return MemoryService(
llm_profiles={"default": {"api_key": "test-key"}},
database_config={"metadata_store": {"provider": "inmemory"}},
memory_files_config={"enabled": True, "output_dir": str(output_dir)},
)
def _seed(service: MemoryService, *, user: dict[str, str]) -> dict[str, str]:
store = service.database
resource = store.resource_repo.create_resource(
url="docs/coffee.txt",
modality="document",
local_path="coffee.txt",
caption="Notes about the user's coffee preferences.",
embedding=None,
user_data=dict(user),
)
category = store.recall_file_repo.get_or_create_category(
name="Preferences",
description="User preferences, likes and dislikes",
embedding=[0.1, 0.2],
user_data=dict(user),
)
store.recall_file_repo.update_category(category_id=category.id, content="The user likes pour-over coffee.")
skill = store.recall_file_repo.get_or_create_category(
name="pour-over",
description="Pour-over brewing",
embedding=[0.1, 0.2],
user_data=dict(user),
track="skill",
)
store.recall_file_repo.update_category(category_id=skill.id, content="# Pour-over brewing\nUse a 1:16 ratio.")
return {"category_id": category.id, "resource_id": resource.id, "skill_id": skill.id}
async def test_export_writes_readme_layout(tmp_path: Path) -> None:
service = _build_service(tmp_path)
_seed(service, user={"user_id": "u1"})
result = await service.export_memory_files(user={"user_id": "u1"})
assert result["changed"] is True
assert "INDEX.md" in result["written"]
assert "MEMORY.md" in result["written"]
assert "SKILL.md" in result["written"]
assert "memory/preferences.md" in result["written"]
assert "skill/pour-over.md" in result["written"]
# MEMORY.md is now an overview that links to each memory/<slug>.md file; the
# category summary itself lives in memory/preferences.md.
memory_text = (tmp_path / "MEMORY.md").read_text(encoding="utf-8")
assert "**Preferences**" in memory_text
assert "memory/preferences.md" in memory_text
category_text = (tmp_path / "memory" / "preferences.md").read_text(encoding="utf-8")
assert "The user likes pour-over coffee." in category_text
# INDEX.md indexes the raw source files under resource/.
index_text = (tmp_path / "INDEX.md").read_text(encoding="utf-8")
assert "coffee.txt" in index_text
assert "coffee preferences" in index_text
# The root SKILL.md indexes the skill/ tree, mirroring MEMORY.md over memory/.
skill_index = (tmp_path / "SKILL.md").read_text(encoding="utf-8")
assert "**pour-over**" in skill_index
assert "skill/pour-over.md" in skill_index
skill_text = (tmp_path / "skill" / "pour-over.md").read_text(encoding="utf-8")
assert "Pour-over brewing" in skill_text
async def test_export_is_idempotent_until_data_changes(tmp_path: Path) -> None:
service = _build_service(tmp_path)
ids = _seed(service, user={"user_id": "u1"})
first = await service.export_memory_files(user={"user_id": "u1"})
assert first["changed"] is True
second = await service.export_memory_files(user={"user_id": "u1"})
assert second["changed"] is False
assert second["written"] == []
assert "MEMORY.md" in second["unchanged"]
# Changing only a folder summary rewrites that category's memory/<slug>.md but
# not MEMORY.md (an overview of links) nor INDEX.md (a file TOC).
service.database.recall_file_repo.update_category(
category_id=ids["category_id"],
content="The user now prefers espresso.",
)
third = await service.export_memory_files(user={"user_id": "u1"})
assert third["changed"] is True
assert "memory/preferences.md" in third["written"]
assert "MEMORY.md" in third["unchanged"]
assert "INDEX.md" in third["unchanged"]
async def test_export_removes_stale_skill_and_prunes_dirs(tmp_path: Path) -> None:
service = _build_service(tmp_path)
_seed(service, user={"user_id": "u1"})
await service.export_memory_files(user={"user_id": "u1"})
assert (tmp_path / "skill" / "pour-over.md").exists()
# Dropping the skill-track file removes its doc; the empty skill/ dir is pruned.
service.database.recall_file_repo.clear_categories(where={"user_id": "u1", "track": "skill"})
result = await service.export_memory_files(user={"user_id": "u1"})
assert "skill/pour-over.md" in result["removed"]
assert not (tmp_path / "skill").exists()
async def test_export_respects_user_scope(tmp_path: Path) -> None:
service = _build_service(tmp_path)
_seed(service, user={"user_id": "u1"})
service.database.recall_file_repo.get_or_create_category(
name="Secret",
description="Other user's folder",
embedding=[0.3, 0.4],
user_data={"user_id": "u2"},
)
await service.export_memory_files(user={"user_id": "u1"})
memory_text = (tmp_path / "MEMORY.md").read_text(encoding="utf-8")
assert "Preferences" in memory_text
assert "Secret" not in memory_text
async def test_export_disabled_raises(tmp_path: Path) -> None:
service = MemoryService(
llm_profiles={"default": {"api_key": "test-key"}},
database_config={"metadata_store": {"provider": "inmemory"}},
)
with pytest.raises(RuntimeError, match="disabled"):
await service.export_memory_files(user={"user_id": "u1"})
def test_exporter_manifest_roundtrip(tmp_path: Path) -> None:
exporter = MemoryFileExporter(str(tmp_path))
exporter._save_manifest({"MEMORY.md": "abc"})
assert exporter._load_manifest() == {"MEMORY.md": "abc"}
(tmp_path / MANIFEST_NAME).write_text("not json", encoding="utf-8")
assert exporter._load_manifest() == {}
(tmp_path / MANIFEST_NAME).write_text(json.dumps([1, 2, 3]), encoding="utf-8")
assert exporter._load_manifest() == {}