mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
0b335476d6
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""Tests for markdown plugins."""
|
|
|
|
from textwrap import dedent
|
|
from markdown_it import MarkdownIt
|
|
from markdown_it.token import Token
|
|
|
|
from basic_memory.markdown.plugins import (
|
|
observation_plugin,
|
|
relation_plugin,
|
|
is_observation,
|
|
is_explicit_relation,
|
|
parse_relation,
|
|
parse_inline_relations,
|
|
)
|
|
|
|
|
|
def test_observation_plugin():
|
|
"""Test observation plugin."""
|
|
# Set up markdown-it instance
|
|
md = MarkdownIt().use(observation_plugin)
|
|
|
|
# Test basic observation with all features
|
|
content = dedent("""
|
|
- [design] Basic observation #tag1 #tag2 (with context)
|
|
""")
|
|
|
|
tokens = md.parse(content)
|
|
token = [t for t in tokens if t.type == "inline"][0]
|
|
assert "observation" in token.meta
|
|
obs = token.meta["observation"]
|
|
assert obs["category"] == "design"
|
|
assert obs["content"] == "Basic observation #tag1 #tag2"
|
|
assert set(obs["tags"]) == {"tag1", "tag2"}
|
|
assert obs["context"] == "with context"
|
|
|
|
# Test without category
|
|
content = "- Basic observation #tag1 (context)"
|
|
token = [t for t in md.parse(content) if t.type == "inline"][0]
|
|
obs = token.meta["observation"]
|
|
assert obs["category"] is None
|
|
assert obs["content"] == "Basic observation #tag1"
|
|
assert obs["tags"] == ["tag1"]
|
|
assert obs["context"] == "context"
|
|
|
|
# Test without tags
|
|
content = "- [note] Basic observation (context)"
|
|
token = [t for t in md.parse(content) if t.type == "inline"][0]
|
|
obs = token.meta["observation"]
|
|
assert obs["category"] == "note"
|
|
assert obs["content"] == "Basic observation"
|
|
assert obs["tags"] is None
|
|
assert obs["context"] == "context"
|
|
|
|
|
|
def test_observation_edge_cases():
|
|
"""Test observation parsing edge cases."""
|
|
# Test non-inline token
|
|
token = Token("paragraph", "", 0)
|
|
assert not is_observation(token)
|
|
|
|
# Test empty content
|
|
token = Token("inline", "", 0)
|
|
assert not is_observation(token)
|
|
|
|
# Test markdown task
|
|
token = Token("inline", "[ ] Task item", 0)
|
|
assert not is_observation(token)
|
|
|
|
# Test completed task
|
|
token = Token("inline", "[x] Done task", 0)
|
|
assert not is_observation(token)
|
|
|
|
# Test in-progress task
|
|
token = Token("inline", "[-] Ongoing task", 0)
|
|
assert not is_observation(token)
|
|
|
|
|
|
def test_observation_excludes_markdown_and_wiki_links():
|
|
"""Test that markdown links and wiki links are NOT parsed as observations.
|
|
|
|
This test validates the fix for issue #247 where:
|
|
- [text](url) markdown links were incorrectly parsed as observations
|
|
- [[text]] wiki links were incorrectly parsed as observations
|
|
"""
|
|
# Test markdown links are NOT observations
|
|
token = Token("inline", "[Click here](https://example.com)", 0)
|
|
assert not is_observation(token), "Markdown links should not be parsed as observations"
|
|
|
|
token = Token("inline", "[Documentation](./docs/readme.md)", 0)
|
|
assert not is_observation(token), "Relative markdown links should not be parsed as observations"
|
|
|
|
token = Token("inline", "[Empty link]()", 0)
|
|
assert not is_observation(token), "Empty markdown links should not be parsed as observations"
|
|
|
|
# Test wiki links are NOT observations
|
|
token = Token("inline", "[[SomeWikiPage]]", 0)
|
|
assert not is_observation(token), "Wiki links should not be parsed as observations"
|
|
|
|
token = Token("inline", "[[Multi Word Page]]", 0)
|
|
assert not is_observation(token), "Multi-word wiki links should not be parsed as observations"
|
|
|
|
# Test nested brackets are NOT observations
|
|
token = Token("inline", "[[Nested [[Inner]] Link]]", 0)
|
|
assert not is_observation(token), "Nested wiki links should not be parsed as observations"
|
|
|
|
# Test valid observations still work (should return True)
|
|
token = Token("inline", "[category] This is a valid observation", 0)
|
|
assert is_observation(token), "Valid observations should still be parsed correctly"
|
|
|
|
token = Token("inline", "[design] Valid observation #tag", 0)
|
|
assert is_observation(token), "Valid observations with tags should still work"
|
|
|
|
token = Token("inline", "Just some text #tag", 0)
|
|
assert is_observation(token), "Tag-only observations should still work"
|
|
|
|
# Test edge cases that should NOT be observations
|
|
token = Token("inline", "[]Empty brackets", 0)
|
|
assert not is_observation(token), "Empty category brackets should not be observations"
|
|
|
|
token = Token("inline", "[category]No space after category", 0)
|
|
assert not is_observation(token), "No space after category should not be valid observation"
|
|
|
|
|
|
def test_observation_excludes_html_color_codes():
|
|
"""Test that HTML color codes are NOT interpreted as hashtags.
|
|
|
|
This test validates the fix for issue #446 where:
|
|
- HTML color codes like #4285F4 in attributes were incorrectly
|
|
causing lines to be parsed as observations.
|
|
"""
|
|
# HTML color code in font tag should NOT be an observation
|
|
token = Token("inline", '**<font color="#4285F4">Jane:</font>** Welcome to the show', 0)
|
|
assert not is_observation(token), "HTML color codes should not trigger hashtag detection"
|
|
|
|
# Color code in style attribute
|
|
token = Token("inline", '<span style="color:#FF5733">Styled text</span>', 0)
|
|
assert not is_observation(token), "Color codes in style should not be observations"
|
|
|
|
# Multiple color codes
|
|
token = Token(
|
|
"inline", '<font color="#4285F4">Blue</font> and <font color="#EA4335">Red</font>', 0
|
|
)
|
|
assert not is_observation(token), "Multiple color codes should not be observations"
|
|
|
|
# Hex color without quotes (edge case)
|
|
token = Token("inline", "background-color:#FFFFFF is white", 0)
|
|
assert not is_observation(token), "Inline hex colors should not be observations"
|
|
|
|
# But standalone hashtags SHOULD still work
|
|
token = Token("inline", "This has a #realtag in it", 0)
|
|
assert is_observation(token), "Standalone hashtags should still work"
|
|
|
|
# Multiple real hashtags
|
|
token = Token("inline", "Tags: #design #feature #important", 0)
|
|
assert is_observation(token), "Multiple standalone hashtags should work"
|
|
|
|
# Mix of color code and real tag - should be observation because of real tag
|
|
token = Token("inline", '<font color="#4285F4">Text</font> #actualtag', 0)
|
|
assert is_observation(token), "Real hashtag with color code should still be observation"
|
|
|
|
|
|
def test_observation_skips_obsidian_callouts():
|
|
"""Test that Obsidian callout syntax is NOT parsed as observations (issue #738).
|
|
|
|
Callouts use `> [!type] title` which previously matched the bracket-content
|
|
regex and produced spurious observations with categories like `!info`,
|
|
`!warning`, `!quote`. The blockquote prefix (`>`) is the distinguishing
|
|
feature — Basic Memory observations are list items, not blockquote lines.
|
|
"""
|
|
md = MarkdownIt().use(observation_plugin)
|
|
|
|
callout_doc = dedent("""
|
|
> [!info] Information title
|
|
> Body of the info callout.
|
|
|
|
> [!warning] Heads up
|
|
> Body of the warning.
|
|
|
|
> [!quote] Citation
|
|
> Quoted text here.
|
|
|
|
- [design] Real observation outside the callout
|
|
""")
|
|
|
|
tokens = md.parse(callout_doc)
|
|
|
|
# No callout content should produce an observation
|
|
callout_observations = [
|
|
t.meta.get("observation")
|
|
for t in tokens
|
|
if t.type == "inline" and t.meta and t.meta.get("observation")
|
|
if t.meta["observation"]["category"] and t.meta["observation"]["category"].startswith("!")
|
|
]
|
|
assert callout_observations == [], (
|
|
f"Obsidian callouts should not produce observations, got: {callout_observations}"
|
|
)
|
|
|
|
# Real observation outside the blockquote must still parse
|
|
real_observations = [
|
|
t.meta["observation"]
|
|
for t in tokens
|
|
if t.type == "inline" and t.meta and t.meta.get("observation")
|
|
]
|
|
assert len(real_observations) == 1
|
|
assert real_observations[0]["category"] == "design"
|
|
assert real_observations[0]["content"] == "Real observation outside the callout"
|
|
|
|
|
|
def test_observation_skipped_inside_any_blockquote():
|
|
"""Even non-callout `[bracket]` content in a blockquote should not be
|
|
treated as an observation — blockquote contents are quoted/aside text,
|
|
not knowledge graph observations.
|
|
"""
|
|
md = MarkdownIt().use(observation_plugin)
|
|
|
|
quoted_doc = dedent("""
|
|
> [design] This looks like an observation but lives in a blockquote.
|
|
|
|
- [design] Real observation
|
|
""")
|
|
|
|
tokens = md.parse(quoted_doc)
|
|
observations = [
|
|
t.meta["observation"]
|
|
for t in tokens
|
|
if t.type == "inline" and t.meta and t.meta.get("observation")
|
|
]
|
|
# Only the bullet observation should be picked up.
|
|
assert len(observations) == 1
|
|
assert observations[0]["content"] == "Real observation"
|
|
|
|
|
|
def test_relation_plugin():
|
|
"""Test relation plugin."""
|
|
md = MarkdownIt().use(relation_plugin)
|
|
|
|
# Test explicit relation with all features
|
|
content = dedent("""
|
|
- implements [[Component]] (with context)
|
|
""")
|
|
|
|
tokens = md.parse(content)
|
|
token = [t for t in tokens if t.type == "inline"][0]
|
|
assert "relations" in token.meta
|
|
rel = token.meta["relations"][0]
|
|
assert rel["type"] == "implements"
|
|
assert rel["target"] == "Component"
|
|
assert rel["context"] == "with context"
|
|
|
|
# Test project namespace normalization
|
|
content = dedent("""
|
|
- relates_to [[other-project::Component]]
|
|
""")
|
|
token = [t for t in md.parse(content) if t.type == "inline"][0]
|
|
rel = token.meta["relations"][0]
|
|
assert rel["target"] == "other-project/Component"
|
|
|
|
# Test implicit relations in text
|
|
content = "Some text with a [[Link]] and [[Another Link]]"
|
|
token = [t for t in md.parse(content) if t.type == "inline"][0]
|
|
rels = token.meta["relations"]
|
|
assert len(rels) == 2
|
|
assert rels[0]["type"] == "links_to"
|
|
assert rels[0]["target"] == "Link"
|
|
assert rels[1]["target"] == "Another Link"
|
|
|
|
|
|
def test_relation_edge_cases():
|
|
"""Test relation parsing edge cases."""
|
|
# Test non-inline token
|
|
token = Token("paragraph", "", 0)
|
|
assert not is_explicit_relation(token)
|
|
|
|
# Test empty content
|
|
token = Token("inline", "", 0)
|
|
assert not is_explicit_relation(token)
|
|
|
|
# Test incomplete relation (missing target)
|
|
token = Token("inline", "relates_to [[]]", 0)
|
|
result = parse_relation(token)
|
|
assert result is None
|
|
|
|
# Test non-relation content
|
|
token = Token("inline", "Just some text", 0)
|
|
result = parse_relation(token)
|
|
assert result is None
|
|
|
|
# Test invalid inline link (empty target)
|
|
assert not parse_inline_relations("Text with [[]] empty link")
|
|
|
|
# Test nested links (avoid duplicates)
|
|
result = parse_inline_relations("Text with [[Outer [[Inner]] Link]]")
|
|
assert len(result) == 1
|
|
assert result[0]["target"] == "Outer [[Inner]] Link"
|
|
|
|
|
|
def test_combined_plugins():
|
|
"""Test both plugins working together."""
|
|
md = MarkdownIt().use(observation_plugin).use(relation_plugin)
|
|
|
|
content = dedent("""
|
|
# Section
|
|
- [design] Observation with [[Link]] #tag (context)
|
|
- implements [[Component]] (details)
|
|
- Just a [[Reference]] in text
|
|
|
|
Some text with a [[Link]] reference.
|
|
""")
|
|
|
|
tokens = md.parse(content)
|
|
inline_tokens = [t for t in tokens if t.type == "inline"]
|
|
|
|
# First token has both observation and relation
|
|
obs_token = inline_tokens[1]
|
|
assert "observation" in obs_token.meta
|
|
assert "relations" in obs_token.meta
|
|
|
|
# Second token has explicit relation
|
|
rel_token = inline_tokens[2]
|
|
assert "relations" in rel_token.meta
|
|
rel = rel_token.meta["relations"][0]
|
|
assert rel["type"] == "implements"
|
|
|
|
# Third token has implicit relation
|
|
text_token = inline_tokens[4]
|
|
assert "relations" in text_token.meta
|
|
link = text_token.meta["relations"][0]
|
|
assert link["type"] == "links_to"
|