ReMe/tests/test_file_link.py
huangsen 6a5561f86a feat: add file_graph component and integrate with LinkedFileParser
- Add file_graph import to component registry
- Register FILE_GRAPH enum in ComponentEnum
- Implement BaseFileGraph integration in LinkedFileParser
- Replace FileEdge with FileLink for better semantic clarity
- Add lazy resolution of file_graph from app_context
- Update file watcher logging to reflect links instead of edges

refactor: streamline MCP transport layer architecture

- Remove redundant step shells from reme2/mcp/steps/
- Consolidate all @R.register components to reme2.memory package
- Update server.py to import reme2.memory directly
- Revise README.md to document new architecture
- Simplify module dependencies and import structure
2026-05-14 11:35:02 +08:00

345 lines
12 KiB
Python

"""FileLink unit tests — body-only wikilink extraction.
Covers:
* three legal inline forms (bare / line-level Dataview / inline-bracketed
Dataview) and multi-target expansion within each
* dedup against typed wrappers (no double-emit when a wikilink lives
inside a typed envelope)
* open-vocabulary predicate pass-through and the identifier-shape gate
* the explicit decision that frontmatter is no longer walked for links
(extraction takes body text only)
* the 3-field schema: ``(path, anchor, predicate)`` — all real fields,
extra='forbid'
Pre-resolution form: ``iter_links(text)`` returns ``FileLink`` records with
``path`` set to the raw wikilink target as written. The resolver
(``utils.wikilink_resolver``) rewrites ``path`` to the vault-relative
resolved path; here we only test the extractor.
"""
import inspect
import pytest
from pydantic import ValidationError
from reme2.schema import FileLink
from reme2.schema.file_link import extract_wikilinks, iter_links
# --------------------------------------------------------------------------
# Bare wikilinks
# --------------------------------------------------------------------------
def test_bare_wikilink():
links = iter_links("see [[X]]")
assert len(links) == 1
assert links[0].path == "X"
assert links[0].predicate is None
assert links[0].anchor is None
def test_anchor_split_from_target():
"""``[[X#sec]]`` → path='X', anchor='sec' (separate field, not derived)."""
links = iter_links("![[X#sec|Alias]]")
assert len(links) == 1
assert links[0].path == "X"
assert links[0].anchor == "sec"
def test_alias_only_dropped():
links = iter_links("[[X|Alias]]")
assert len(links) == 1
assert links[0].path == "X"
assert links[0].anchor is None
def test_embed_only_dropped():
links = iter_links("![[X]]")
assert len(links) == 1
assert links[0].path == "X"
# --------------------------------------------------------------------------
# Line-level Dataview
# --------------------------------------------------------------------------
def test_line_level_field():
links = iter_links("extends:: [[Source Topic]]")
assert len(links) == 1
assert links[0].path == "Source Topic"
assert links[0].predicate == "extends"
def test_line_level_multi_target():
links = iter_links("concerns:: [[A]], [[B]], [[C]]")
assert [(link.path, link.predicate) for link in links] == [
("A", "concerns"),
("B", "concerns"),
("C", "concerns"),
]
def test_line_level_with_bullet():
links = iter_links("- extends:: [[X]]\n * concerns:: [[Y]]")
assert [(link.path, link.predicate) for link in links] == [
("X", "extends"),
("Y", "concerns"),
]
# --------------------------------------------------------------------------
# Multi-link cases — many wikilinks under one or several typed contexts
# --------------------------------------------------------------------------
def test_multi_target_with_anchors_preserves_each():
"""Each comma-separated target keeps its own anchor as a separate field."""
links = iter_links("extends:: [[A#sec1]], [[B#sec2]], [[C]]")
assert [(link.path, link.anchor, link.predicate) for link in links] == [
("A", "sec1", "extends"),
("B", "sec2", "extends"),
("C", None, "extends"),
]
def test_multi_target_non_comma_separator_still_typed():
"""Wikilinks anywhere in the value range (not just comma-separated)
inherit the line's predicate. Useful for prose-style fields."""
links = iter_links("extends:: [[A]] and also [[B]]")
assert [(link.path, link.predicate) for link in links] == [
("A", "extends"),
("B", "extends"),
]
def test_multi_dataview_lines_each_multi_target():
"""Multiple Dataview lines each with multi-target → all links typed
by their respective line's predicate."""
links = iter_links(
"extends:: [[A]], [[B]]\nrelates:: [[C]], [[D]]",
)
assert [(link.path, link.predicate) for link in links] == [
("A", "extends"),
("B", "extends"),
("C", "relates"),
("D", "relates"),
]
def test_inline_bracketed_then_bare_on_same_line():
"""Inline-bracketed governs only the wikilinks inside its brackets;
a trailing bare wikilink on the same line stays bare."""
links = iter_links("[ext:: [[A]]] then [[B]]")
assert [(link.path, link.predicate) for link in links] == [
("A", "ext"),
("B", None),
]
def test_mid_line_dataview_like_not_typed():
"""``predicate::`` only counts at line start (modulo bullet) —
a ``predicate::`` mid-line is just prose, so its wikilinks are bare."""
links = iter_links("[ext:: [[A]]] and concerns:: [[B]]")
assert [(link.path, link.predicate) for link in links] == [
("A", "ext"),
("B", None), # `concerns::` mid-line is not Dataview
]
# --------------------------------------------------------------------------
# Inline-bracketed Dataview
# --------------------------------------------------------------------------
def test_inline_bracketed():
links = iter_links("This [extends:: [[Y]]] something else.")
assert len(links) == 1
assert links[0].path == "Y"
assert links[0].predicate == "extends"
def test_inline_bracketed_multi_target():
links = iter_links("[concerns:: [[A]], [[B]]]")
assert [(link.path, link.predicate) for link in links] == [
("A", "concerns"),
("B", "concerns"),
]
def test_inline_bracketed_skips_cross_line():
# A `[predicate:: ...]` that spans a newline is malformed → the inner
# wikilink falls back to bare; the unmatched `[` does not eat tail text.
links = iter_links("[extends:: [[X]]\nbad]")
assert len(links) == 1
assert links[0].path == "X"
assert links[0].predicate is None
# --------------------------------------------------------------------------
# Dedup: a wikilink inside a typed wrapper should not double-emit
# --------------------------------------------------------------------------
def test_inline_bracketed_does_not_double_emit():
links = iter_links("see [extends:: [[X]]] again.")
assert len(links) == 1
assert links[0].predicate == "extends"
def test_line_level_value_does_not_double_emit():
links = iter_links("extends:: [[X]]")
assert len(links) == 1
def test_typed_and_bare_coexist_for_same_target():
links = iter_links("extends:: [[X]]\nFree text mentioning [[X]] again.")
paths_preds = sorted(((link.path, link.predicate or "") for link in links))
assert paths_preds == [("X", ""), ("X", "extends")]
# --------------------------------------------------------------------------
# Open-vocabulary predicates — any identifier-shaped token passes through
# --------------------------------------------------------------------------
def test_arbitrary_predicate_preserved():
links = iter_links("anything_goes:: [[X]]")
assert len(links) == 1
assert links[0].path == "X"
assert links[0].predicate == "anything_goes"
def test_inline_arbitrary_predicate_preserved():
links = iter_links("[wat:: [[X]]]")
assert len(links) == 1
assert links[0].predicate == "wat"
def test_predicate_must_be_identifier_shaped():
# A leading digit fails the regex `[A-Za-z][A-Za-z0-9_]*` so the line is
# not recognised as a Dataview field — the wikilink falls back to bare.
links = iter_links("123bad:: [[X]]")
assert len(links) == 1
assert links[0].path == "X"
assert links[0].predicate is None
def test_file_link_accepts_any_predicate_string():
link = FileLink(path="X", predicate="totally_made_up")
assert link.predicate == "totally_made_up"
# --------------------------------------------------------------------------
# Frontmatter is NOT walked for links — explicit regression
# --------------------------------------------------------------------------
def test_iter_links_takes_body_text_only():
"""``iter_links`` operates on body text — no frontmatter walking."""
sig = inspect.signature(iter_links)
assert list(sig.parameters.keys()) == ["text"], "iter_links should accept body text only — frontmatter walk removed"
def test_no_frontmatter_walker_exported():
from reme2.schema import file_link as fl
for removed in (
"parse_wikilinks_from_metadata",
"extract_wikilinks_from_metadata",
"extract_inline_fields",
"extract_typed_edges",
"InlineField",
):
assert not hasattr(fl, removed), f"{removed} should have been removed when YAML links were dropped"
# --------------------------------------------------------------------------
# FileLink schema
# --------------------------------------------------------------------------
def test_file_link_extra_forbid():
with pytest.raises(ValidationError):
FileLink(path="X", target="X") # type: ignore[call-arg]
def test_file_link_field_set():
"""Stored fields are ``(path, anchor, predicate)`` — no others."""
link = FileLink(path="X")
dumped = link.model_dump()
assert set(dumped.keys()) == {"path", "anchor", "predicate"}
assert dumped == {"path": "X", "anchor": None, "predicate": None}
def test_file_link_dump_excludes_none_when_asked():
link = FileLink(path="X")
assert link.model_dump(exclude_none=True) == {"path": "X"}
def test_file_link_full_construction():
link = FileLink(path="topics/Foo.md", anchor="sec", predicate="extends")
assert link.path == "topics/Foo.md"
assert link.anchor == "sec"
assert link.predicate == "extends"
def test_file_link_path_required():
with pytest.raises(ValidationError):
FileLink() # type: ignore[call-arg]
# --------------------------------------------------------------------------
# Anchor extraction edge cases — anchor is a real field, captured by the
# regex's named group (not derived from path string-splitting)
# --------------------------------------------------------------------------
def test_anchor_extraction_edge_cases():
"""``[[X]]`` → no anchor; ``[[X#sec]]`` → anchor='sec'.
The wikilink regex requires the anchor capture to be one or more
chars, so a literal ``[[X#]]`` doesn't match the regex at all (the
trailing ``#`` makes it invalid syntax). Whitespace-only anchors
are treated as no anchor (stripped to empty → None)."""
assert iter_links("[[X]]")[0].anchor is None
assert iter_links("[[X#sec]]")[0].anchor == "sec"
# `[[X#]]` is not a valid wikilink — anchor group requires 1+ chars.
assert iter_links("[[X#]]") == []
# `[[X# ]]` matches but strips to empty → anchor=None.
assert iter_links("[[X# ]]")[0].anchor is None
def test_anchor_inside_pipe_alias_still_extracted():
"""Anchor is captured before the alias pipe."""
links = iter_links("[[topics/Foo#sec|Display]]")
assert links[0].path == "topics/Foo"
assert links[0].anchor == "sec"
# --------------------------------------------------------------------------
# Back-compat: extract_wikilinks returns flat target list (used by retriever)
# --------------------------------------------------------------------------
def test_extract_wikilinks_flat_targets():
targets = extract_wikilinks("see [[X]] and extends:: [[Y]] and [extends:: [[Z]]]")
assert targets == ["X", "Y", "Z"]
def test_extract_wikilinks_strips_anchor():
"""``extract_wikilinks`` returns just the file part — anchor stripped
so callers can feed it to ``resolve``."""
targets = extract_wikilinks("see [[X#sec]] and ![[Y#a|alias]]")
assert targets == ["X", "Y"]
# --------------------------------------------------------------------------
# Source ordering stability
# --------------------------------------------------------------------------
def test_links_sorted_by_source_position():
body = "intro [[First]] then\n" "extends:: [[Second]]\n" "tail [[Third]]\n"
links = iter_links(body)
assert [link.path for link in links] == ["First", "Second", "Third"]