ReMe/tests4/unit/test_wikilink_utils.py
jinliyl 3cb2579ff7
refactor(steps): update auto-memory (#263)
* 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.
2026-05-29 12:07:44 +08:00

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所有测试通过!")