mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-29 01:41:38 +00:00
* refactor(steps): update naming conventions in components and configuration Updated naming conventions across multiple files, changing colon-separated names to underscore-separated format, and added new step definitions along with documentation updates. Key changes: - Replaced `Synchronizer` with `AutoMemory` as the counterpart component for cold-write operations - Updated naming conventions in all related configuration files (e.g., `frontmatter:read` → `frontmatter_read`) - Added new step definitions such as `submit_slug_updates` and `auto_memory` - Updated relevant documentation - Modified log output format for improved readability * refactor(evolve): Refactor the auto-memory module and update related configurations - Remove the old slug update commit step file - Add new auto-memory planner and writer steps - Update __init__.py to export the new step classes - Modify the auto_memory configuration structure in default.yaml - Update the slug field description for clearer explanation of its purpose * up * up * refactor(tests): Move unit test directory from `tests4/unittest` to `tests4/unit` Additionally, the assertion logic in test files has been updated: direct comparisons of `payload["notes"]` have been replaced with checks verifying the presence of paths and metadata within the response content. Furthermore, some test expectations have been simplified—for example, using `count` instead of asserting against specific note lists. Specific changes include: - Updating workflow configurations to align with the new test directory structure - Modifying assertions across multiple test methods to make them more flexible and maintainable - Cleaning up and optimizing parts of the test code structure This is a comprehensive test refactoring effort aimed at improving test readability and robustness. * Refactor(steps): Update memory writing logic and optimize JSON schema structure Improved the write strategy description in `auto_memory_writer.yaml` to emphasize using `edit` over `write`. Adjusted the `json_schema` structure in `base_step.py` to support the new function definition format. Also corrected grammatical issues in the related documentation. * Fix: Improve frontend data parsing error handling and update test files Added capture and handling logic for YAML parsing exceptions, providing more detailed error messages when frontend data format issues occur. Also corrected the description text in a test file.
399 lines
16 KiB
Python
399 lines
16 KiB
Python
"""Tests for the wikilink helpers in ``reme4.utils.wikilink_handler``.
|
|
|
|
Two pure async helpers used by file_move / file_delete:
|
|
|
|
* ``retarget_links(src, dst, scope?, dry_run?)`` — rewrite wikilink
|
|
targets across the vault, using the file_graph's reverse index to
|
|
find inbound sources (no fs scan).
|
|
* ``find_inbound(target, scope?)`` — report inbound count without
|
|
rewriting.
|
|
|
|
Retarget only matches the literal full-path form ``[[topics/x.md]]``;
|
|
short ``[[x]]`` and no-ext ``[[topics/x]]`` are left alone by design.
|
|
"""
|
|
|
|
# pylint: disable=protected-access
|
|
|
|
import asyncio
|
|
import os
|
|
import tempfile
|
|
import warnings
|
|
from pathlib import Path
|
|
|
|
from reme4.components.file_store import LocalFileStore
|
|
from reme4.schema import FileNode
|
|
from reme4.utils.wikilink_handler import WikilinkHandler
|
|
|
|
warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
|
|
warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
|
|
|
|
|
|
class temp_chdir:
|
|
"""Test helper: chdir to ``path`` on enter, restore previous cwd on exit."""
|
|
|
|
def __init__(self, path):
|
|
self.path = path
|
|
self.old = None
|
|
|
|
def __enter__(self):
|
|
self.old = os.getcwd()
|
|
os.chdir(self.path)
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
os.chdir(self.old)
|
|
|
|
|
|
async def _store_with(files: dict[str, str]) -> LocalFileStore:
|
|
"""LocalFileStore seeded with (rel → content) files: written to disk
|
|
AND registered in the file_graph with wikilinks parsed from the body.
|
|
|
|
Without the parsed links the reverse-index lookup yields nothing and
|
|
retarget becomes a no-op.
|
|
"""
|
|
store = LocalFileStore(name="t", embedding_model="")
|
|
await store.start()
|
|
nodes: list[FileNode] = []
|
|
root = Path.cwd()
|
|
for rel, content in files.items():
|
|
abs_path = root / rel
|
|
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
abs_path.write_text(content, encoding="utf-8")
|
|
nodes.append(
|
|
FileNode(
|
|
path=rel,
|
|
st_mtime=abs_path.stat().st_mtime,
|
|
links=WikilinkHandler.extract_links(content, rel),
|
|
),
|
|
)
|
|
if nodes:
|
|
await store.file_graph.upsert_nodes(nodes)
|
|
return store
|
|
|
|
|
|
async def _empty_store() -> LocalFileStore:
|
|
store = LocalFileStore(name="t", embedding_model="")
|
|
await store.start()
|
|
return store
|
|
|
|
|
|
def test_retarget_exact_full_path_match():
|
|
"""[[topics/Alice.md]] → [[people/Alice.md]]; non-matching links untouched."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
root = Path(tmp)
|
|
store = await _store_with(
|
|
{
|
|
"note.md": "See [[topics/Alice.md]] and [[topics/Bob.md]].",
|
|
"people/Alice.md": "# Alice",
|
|
},
|
|
)
|
|
payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
|
|
assert "error" not in payload
|
|
assert payload["links_changed"] == 1
|
|
assert payload["files_touched"] == 1
|
|
body = (root / "note.md").read_text(encoding="utf-8")
|
|
assert "[[people/Alice.md]]" in body
|
|
assert "[[topics/Alice.md]]" not in body
|
|
assert "[[topics/Bob.md]]" in body
|
|
await store.close()
|
|
print("✓ test_retarget_exact_full_path_match passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_retarget_short_and_no_ext_forms_ignored():
|
|
"""Short-form [[Alice]] and no-ext [[topics/Alice]] are NOT matched."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
root = Path(tmp)
|
|
body_in = "See [[Alice]] and [[topics/Alice]] but also [[topics/Alice.md]]."
|
|
store = await _store_with(
|
|
{
|
|
"note.md": body_in,
|
|
"people/Alice.md": "# Alice",
|
|
},
|
|
)
|
|
payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
|
|
assert payload["links_changed"] == 1 # only the full-path form
|
|
body = (root / "note.md").read_text(encoding="utf-8")
|
|
assert "[[people/Alice.md]]" in body
|
|
assert "[[Alice]]" in body # short form untouched
|
|
assert "[[topics/Alice]]" in body # no-ext form untouched
|
|
await store.close()
|
|
print("✓ test_retarget_short_and_no_ext_forms_ignored passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_retarget_anchor_preserved():
|
|
"""`[[topics/Alice.md#intro]]` → `[[people/Alice.md#intro]]` (anchor kept verbatim)."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
root = Path(tmp)
|
|
store = await _store_with({"note.md": "Jump to [[topics/Alice.md#intro]] please."})
|
|
payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
|
|
assert payload["links_changed"] == 1
|
|
body = (root / "note.md").read_text(encoding="utf-8")
|
|
assert "[[people/Alice.md#intro]]" in body
|
|
await store.close()
|
|
print("✓ test_retarget_anchor_preserved passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_retarget_alias_preserved():
|
|
"""`[[topics/Alice.md|Display Name]]` → `[[people/Alice.md|Display Name]]`."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
root = Path(tmp)
|
|
store = await _store_with({"note.md": "Meet [[topics/Alice.md|Alice the Architect]]."})
|
|
payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
|
|
assert payload["links_changed"] == 1
|
|
body = (root / "note.md").read_text(encoding="utf-8")
|
|
assert "[[people/Alice.md|Alice the Architect]]" in body
|
|
await store.close()
|
|
print("✓ test_retarget_alias_preserved passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_retarget_anchor_and_alias_together():
|
|
"""`[[A.md#h|disp]]` → `[[B.md#h|disp]]` keeps both suffixes."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
root = Path(tmp)
|
|
store = await _store_with({"note.md": "See [[topics/Alice.md#bio|her bio]] now."})
|
|
payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
|
|
assert payload["links_changed"] == 1
|
|
body = (root / "note.md").read_text(encoding="utf-8")
|
|
assert "[[people/Alice.md#bio|her bio]]" in body
|
|
await store.close()
|
|
print("✓ test_retarget_anchor_and_alias_together passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_retarget_image_marker_preserved():
|
|
"""`![[topics/diagram.md]]` (embed) keeps its `!` prefix on rewrite."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
root = Path(tmp)
|
|
store = await _store_with({"note.md": "Inline embed: ![[topics/diagram.md]] here."})
|
|
payload = await WikilinkHandler.retarget_links(store, src="topics/diagram.md", dst="diagrams/diagram.md")
|
|
assert payload["links_changed"] == 1
|
|
body = (root / "note.md").read_text(encoding="utf-8")
|
|
assert "![[diagrams/diagram.md]]" in body
|
|
await store.close()
|
|
print("✓ test_retarget_image_marker_preserved passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_retarget_dataview_predicate_preserved():
|
|
"""Line-level + inline-bracketed Dataview predicates pass through outside ``[[..]]``."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
root = Path(tmp)
|
|
body_in = (
|
|
"colleague:: [[topics/Alice.md]]\n" + "She is the [负责:: [[topics/Alice.md]]] for the migration.\n"
|
|
)
|
|
store = await _store_with({"note.md": body_in})
|
|
payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
|
|
assert payload["links_changed"] == 2
|
|
body = (root / "note.md").read_text(encoding="utf-8")
|
|
assert "colleague:: [[people/Alice.md]]" in body
|
|
assert "[负责:: [[people/Alice.md]]]" in body
|
|
await store.close()
|
|
print("✓ test_retarget_dataview_predicate_preserved passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_retarget_multiple_files_aggregate_counts():
|
|
"""links_changed sums across files; by_file lists per-file counts."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
store = await _store_with(
|
|
{
|
|
"a.md": "[[topics/Alice.md]] then [[topics/Alice.md]]",
|
|
"sub/b.md": "[[topics/Alice.md]] and [[topics/Bob.md]]",
|
|
},
|
|
)
|
|
payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
|
|
assert payload["links_changed"] == 3
|
|
assert payload["files_touched"] == 2
|
|
by_file = {row["path"]: row["count"] for row in payload["by_file"]}
|
|
assert by_file == {"a.md": 2, "sub/b.md": 1}
|
|
await store.close()
|
|
print("✓ test_retarget_multiple_files_aggregate_counts passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_retarget_dry_run_does_not_write():
|
|
"""dry_run=True reports counts but leaves files on disk unchanged."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
root = Path(tmp)
|
|
original = "See [[topics/Alice.md]]."
|
|
store = await _store_with({"note.md": original})
|
|
payload = await WikilinkHandler.retarget_links(
|
|
store,
|
|
src="topics/Alice.md",
|
|
dst="people/Alice.md",
|
|
dry_run=True,
|
|
)
|
|
assert payload["dry_run"] is True
|
|
assert payload["links_changed"] == 1
|
|
assert payload["files_touched"] == 1
|
|
assert (root / "note.md").read_text(encoding="utf-8") == original
|
|
await store.close()
|
|
print("✓ test_retarget_dry_run_does_not_write passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_retarget_scope_limits_sweep():
|
|
"""Files outside `scope` are not visited even if they contain matches."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
root = Path(tmp)
|
|
store = await _store_with(
|
|
{
|
|
"in_scope/note.md": "See [[topics/Alice.md]].",
|
|
"outside/note.md": "Also [[topics/Alice.md]].",
|
|
},
|
|
)
|
|
payload = await WikilinkHandler.retarget_links(
|
|
store,
|
|
src="topics/Alice.md",
|
|
dst="people/Alice.md",
|
|
scope="in_scope",
|
|
)
|
|
assert payload["links_changed"] == 1
|
|
assert payload["files_touched"] == 1
|
|
in_body = (root / "in_scope/note.md").read_text(encoding="utf-8")
|
|
out_body = (root / "outside/note.md").read_text(encoding="utf-8")
|
|
assert "[[people/Alice.md]]" in in_body
|
|
assert "[[topics/Alice.md]]" in out_body # untouched
|
|
await store.close()
|
|
print("✓ test_retarget_scope_limits_sweep passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_retarget_src_eq_dst_is_noop():
|
|
"""src == dst: no scan needed, returns zero counts."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
store = await _store_with({"note.md": "See [[topics/Alice.md]]."})
|
|
payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="topics/Alice.md")
|
|
assert payload["links_changed"] == 0
|
|
assert payload["files_touched"] == 0
|
|
await store.close()
|
|
print("✓ test_retarget_src_eq_dst_is_noop passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_retarget_empty_src_or_dst_errors():
|
|
"""Empty src or dst produces an error payload."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
store = await _empty_store()
|
|
for kwargs in ({"src": "", "dst": "people/Alice.md"}, {"src": "topics/Alice.md", "dst": ""}):
|
|
payload = await WikilinkHandler.retarget_links(store, **kwargs)
|
|
assert "error" in payload, f"expected error for {kwargs}"
|
|
await store.close()
|
|
print("✓ test_retarget_empty_src_or_dst_errors passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_retarget_dst_with_forbidden_chars_errors():
|
|
"""``dst`` containing ``[ ] # |`` or newline lands as an error payload."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
store = await _empty_store()
|
|
for bad in ["people/Alice#anchor", "people/Alice|alias", "p/[A].md", "p/A]]"]:
|
|
payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst=bad)
|
|
assert "error" in payload, f"expected error for dst={bad!r}, got {payload}"
|
|
await store.close()
|
|
print("✓ test_retarget_dst_with_forbidden_chars_errors passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_retarget_absolute_path_rejected():
|
|
"""Absolute paths in src or dst are rejected (must be relative to the vault)."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
store = await _empty_store()
|
|
for kwargs in (
|
|
{"src": "/abs/old.md", "dst": "people/Alice.md"},
|
|
{"src": "topics/Alice.md", "dst": "/abs/new.md"},
|
|
):
|
|
payload = await WikilinkHandler.retarget_links(store, **kwargs)
|
|
assert "error" in payload, f"expected error for {kwargs}"
|
|
await store.close()
|
|
print("✓ test_retarget_absolute_path_rejected passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_find_inbound_counts_references():
|
|
"""find_inbound reports per-file counts and excludes self-references."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
store = await _store_with(
|
|
{
|
|
"a.md": "[[topics/Alice.md]] and [[topics/Alice.md]] again",
|
|
"b.md": "see [[topics/Alice.md]]",
|
|
"topics/Alice.md": "self-ref [[topics/Alice.md]] should not count",
|
|
},
|
|
)
|
|
payload = await WikilinkHandler.find_inbound(store, target="topics/Alice.md")
|
|
assert payload["files_touched"] == 2
|
|
assert payload["links_total"] == 3
|
|
by_file = {row["path"]: row["count"] for row in payload["by_file"]}
|
|
assert by_file == {"a.md": 2, "b.md": 1}
|
|
await store.close()
|
|
print("✓ test_find_inbound_counts_references passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("\n=== wikilink helper tests ===")
|
|
test_retarget_exact_full_path_match()
|
|
test_retarget_short_and_no_ext_forms_ignored()
|
|
test_retarget_anchor_preserved()
|
|
test_retarget_alias_preserved()
|
|
test_retarget_anchor_and_alias_together()
|
|
test_retarget_image_marker_preserved()
|
|
test_retarget_dataview_predicate_preserved()
|
|
test_retarget_multiple_files_aggregate_counts()
|
|
test_retarget_dry_run_does_not_write()
|
|
test_retarget_scope_limits_sweep()
|
|
test_retarget_src_eq_dst_is_noop()
|
|
test_retarget_empty_src_or_dst_errors()
|
|
test_retarget_dst_with_forbidden_chars_errors()
|
|
test_retarget_absolute_path_rejected()
|
|
test_find_inbound_counts_references()
|
|
print("\n所有测试通过!")
|