"""Tests for muse/plugins/knowtation/symbols.py — Phase 1.4. Test tiers covered ------------------ 1. Unit — 30+ note shapes: address helpers, section splitter, field extraction, NoteSymbol title resolution, full extract_symbols pipeline, edge cases. 2. Integration — extract_symbols output matches the muse code symbols JSON schema (domain-aware output contract); real-vault notes parsed without error. 3. Data-integrity — every content note in the real vault produces at least one NoteSymbol without raising; section and field counts are non-negative. """ from __future__ import annotations import json import pathlib from typing import Any import pytest from muse.plugins.knowtation.symbols import ( ADDRESS_SEP, FIELD_PREFIX, SECTION_PREFIX, FieldSymbol, NoteSymbol, SectionSymbol, extract_fields, extract_sections, extract_symbols, make_field_address, make_note_address, make_section_address, parse_address, split_sections, symbols_to_json, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _REAL_VAULT = pathlib.Path("/Users/aaronrenecarvajal/knowtation/vault") _SKIP_DIRS: frozenset[str] = frozenset({"templates", "meta"}) def _note(frontmatter: str = "", body: str = "") -> bytes: if frontmatter: return f"---\n{frontmatter}\n---\n{body}".encode() return body.encode() def _load_content_notes() -> dict[str, bytes]: notes: dict[str, bytes] = {} for md_path in _REAL_VAULT.rglob("*.md"): parts = md_path.relative_to(_REAL_VAULT).parts if parts and parts[0] in _SKIP_DIRS: continue try: notes[str(md_path.relative_to(_REAL_VAULT))] = md_path.read_bytes() except OSError: pass return notes # ============================================================================ # TestAddressHelpers # ============================================================================ class TestMakeNoteAddress: def test_returns_path(self) -> None: assert make_note_address("inbox/foo.md") == "inbox/foo.md" def test_nested_path(self) -> None: assert make_note_address("projects/born-free/notes.md") == "projects/born-free/notes.md" def test_root_level_path(self) -> None: assert make_note_address("README.md") == "README.md" class TestMakeFieldAddress: def test_basic(self) -> None: assert make_field_address("inbox/foo.md", "source") == "inbox/foo.md::field:source" def test_separator_present(self) -> None: addr = make_field_address("p/note.md", "tags") assert ADDRESS_SEP in addr assert FIELD_PREFIX in addr def test_key_in_address(self) -> None: addr = make_field_address("p/note.md", "causal_chain_id") assert "causal_chain_id" in addr def test_path_in_address(self) -> None: addr = make_field_address("projects/a/b.md", "date") assert "projects/a/b.md" in addr class TestMakeSectionAddress: def test_basic_h2(self) -> None: assert make_section_address("f.md", 2, "Background") == "f.md::section:2:Background" def test_preamble_level_zero(self) -> None: addr = make_section_address("f.md", 0, "(preamble)") assert "section:0:(preamble)" in addr def test_h1_level(self) -> None: addr = make_section_address("f.md", 1, "Overview") assert "section:1:Overview" in addr def test_h6_level(self) -> None: addr = make_section_address("f.md", 6, "Deep") assert "section:6:Deep" in addr def test_separator_in_address(self) -> None: assert ADDRESS_SEP in make_section_address("f.md", 2, "Title") class TestParseAddress: def test_note_address(self) -> None: result = parse_address("inbox/foo.md") assert result["kind"] == "note" assert result["path"] == "inbox/foo.md" def test_field_address(self) -> None: result = parse_address("inbox/foo.md::field:source") assert result["kind"] == "field" assert result["path"] == "inbox/foo.md" assert result["key"] == "source" def test_section_address(self) -> None: result = parse_address("inbox/foo.md::section:2:Background") assert result["kind"] == "section" assert result["path"] == "inbox/foo.md" assert result["level"] == "2" assert result["title"] == "Background" def test_preamble_section_address(self) -> None: result = parse_address("f.md::section:0:(preamble)") assert result["kind"] == "section" assert result["level"] == "0" assert result["title"] == "(preamble)" def test_section_title_with_colon(self) -> None: # Title contains a colon — only first two colons are separators. result = parse_address("f.md::section:1:My: Title") assert result["kind"] == "section" assert result["title"] == "My: Title" def test_unknown_qualifier(self) -> None: result = parse_address("f.md::custom:foo") assert result["kind"] == "unknown" def test_round_trip_field(self) -> None: addr = make_field_address("inbox/foo.md", "date") parsed = parse_address(addr) assert parsed["kind"] == "field" assert parsed["key"] == "date" def test_round_trip_section(self) -> None: addr = make_section_address("inbox/foo.md", 3, "Details") parsed = parse_address(addr) assert parsed["kind"] == "section" assert parsed["level"] == "3" assert parsed["title"] == "Details" # ============================================================================ # TestSplitSections (30+ note shapes) # ============================================================================ class TestSplitSections: # ── Empty / blank inputs ──────────────────────────────────────────────── def test_empty_string(self) -> None: assert split_sections("") == [] def test_whitespace_only(self) -> None: assert split_sections(" \n ") == [] # ── No headings ───────────────────────────────────────────────────────── def test_plain_text_no_headings(self) -> None: body = "Just plain text.\nNo headings here." secs = split_sections(body) assert len(secs) == 1 level, title, _, _ = secs[0] assert level == 0 assert title == "(preamble)" def test_body_only_preamble_content(self) -> None: body = "First paragraph.\n\nSecond paragraph." secs = split_sections(body) assert len(secs) == 1 assert secs[0][1] == "(preamble)" # ── H1 headings ───────────────────────────────────────────────────────── def test_single_h1(self) -> None: body = "# Introduction\nSome content." secs = split_sections(body) assert len(secs) == 1 level, title, _, _ = secs[0] assert level == 1 assert title == "Introduction" def test_two_h1_sections(self) -> None: body = "# Part A\nContent A.\n# Part B\nContent B." secs = split_sections(body) assert len(secs) == 2 assert secs[0][0] == 1 assert secs[0][1] == "Part A" assert secs[1][1] == "Part B" # ── H2 headings (chunk.mjs canonical split level) ─────────────────────── def test_single_h2(self) -> None: secs = split_sections("## Background\nSome text.") assert len(secs) == 1 assert secs[0][0] == 2 assert secs[0][1] == "Background" def test_two_h2_sections(self) -> None: body = "## Alpha\nContent alpha.\n## Beta\nContent beta." secs = split_sections(body) assert len(secs) == 2 assert secs[0][1] == "Alpha" assert secs[1][1] == "Beta" def test_three_h2_sections(self) -> None: body = "## A\n## B\n## C\n" assert len(split_sections(body)) == 3 # ── H3 headings ───────────────────────────────────────────────────────── def test_h3_section(self) -> None: secs = split_sections("### Details\nFoo.") assert secs[0][0] == 3 # ── Mixed heading levels ───────────────────────────────────────────────── def test_preamble_then_h2(self) -> None: body = "Intro text.\n## Section\nContent." secs = split_sections(body) assert len(secs) == 2 assert secs[0][1] == "(preamble)" assert secs[1][1] == "Section" def test_h1_then_h2(self) -> None: body = "# Title\n## Sub\nContent." secs = split_sections(body) assert len(secs) == 2 assert secs[0][0] == 1 assert secs[1][0] == 2 def test_h2_then_h3_under_it(self) -> None: body = "## Parent\n### Child\nContent." secs = split_sections(body) assert len(secs) == 2 assert secs[0][1] == "Parent" assert secs[1][1] == "Child" def test_mixed_h1_h2_h3(self) -> None: body = "# A\n## B\n### C\n## D\n" secs = split_sections(body) assert len(secs) == 4 levels = [s[0] for s in secs] assert levels == [1, 2, 3, 2] # ── Edge cases ─────────────────────────────────────────────────────────── def test_heading_with_trailing_hashes(self) -> None: # ATX-style headings may have trailing #: "## Title ##" secs = split_sections("## Title ##\nContent.") assert secs[0][1] == "Title" def test_heading_with_inline_emphasis(self) -> None: secs = split_sections("## **Bold** Section\nContent.") assert "Bold" in secs[0][1] def test_heading_not_at_line_start_ignored(self) -> None: # Indented "heading" should not be treated as a heading. body = " ## Not a heading\nPlain text." secs = split_sections(body) assert len(secs) == 1 assert secs[0][1] == "(preamble)" def test_heading_in_code_block_still_matched(self) -> None: # Our regex doesn't know about code blocks; this is an intentional # limitation documented in the module. body = "```\n## In code\n```\n## Real heading\n" secs = split_sections(body) assert len(secs) >= 1 def test_section_body_includes_heading_line(self) -> None: body = "## My Section\nBody text here." secs = split_sections(body) assert secs[0][2].startswith("## My Section") def test_section_body_ends_before_next_heading(self) -> None: body = "## A\nContent A.\n## B\nContent B." secs = split_sections(body) assert "## B" not in secs[0][2] def test_line_number_first_section(self) -> None: body = "## First\nContent." secs = split_sections(body) assert secs[0][3] == 1 # first line def test_line_number_second_section(self) -> None: body = "## First\nLine 2.\n## Second\nLine 4." secs = split_sections(body) assert secs[1][3] == 3 # "## Second" is on line 3 def test_h4_h5_h6_detected(self) -> None: body = "#### Four\n##### Five\n###### Six\n" secs = split_sections(body) levels = [s[0] for s in secs] assert 4 in levels assert 5 in levels assert 6 in levels def test_empty_section_body(self) -> None: body = "## A\n## B\n" secs = split_sections(body) # Section A has no body text (immediately followed by B) assert len(secs) == 2 def test_note_with_only_newlines(self) -> None: assert split_sections("\n\n\n") == [] def test_unicode_heading(self) -> None: body = "## 日本語セクション\nContent." secs = split_sections(body) assert "日本語セクション" in secs[0][1] def test_heading_with_numbers(self) -> None: body = "## Section 1.2.3\nContent." secs = split_sections(body) assert secs[0][1] == "Section 1.2.3" def test_many_sections(self) -> None: headings = "\n".join(f"## Section {i}\nContent {i}." for i in range(20)) secs = split_sections(headings) assert len(secs) == 20 def test_preamble_not_added_when_absent(self) -> None: body = "## Heading\nContent." secs = split_sections(body) assert secs[0][1] != "(preamble)" def test_crlf_body(self) -> None: body = "## Section\r\nContent.\r\n## Next\r\nMore." secs = split_sections(body) assert len(secs) >= 1 # ============================================================================ # TestExtractSymbolsPipeline # ============================================================================ class TestExtractSymbolsNoteOnly: """Notes with no frontmatter and no headings.""" def test_returns_one_note_symbol(self) -> None: syms = extract_symbols("plain.md", b"Just plain text.") assert len([s for s in syms if isinstance(s, NoteSymbol)]) == 1 def test_note_symbol_is_first(self) -> None: syms = extract_symbols("plain.md", b"Plain text.") assert isinstance(syms[0], NoteSymbol) def test_note_address(self) -> None: syms = extract_symbols("inbox/foo.md", b"Body.") note = syms[0] assert isinstance(note, NoteSymbol) assert note.address == "inbox/foo.md" def test_note_title_from_filename(self) -> None: syms = extract_symbols("inbox/my-note.md", b"Body.") note = syms[0] assert isinstance(note, NoteSymbol) assert note.title == "my-note" def test_note_title_from_h1(self) -> None: syms = extract_symbols("inbox/foo.md", b"# My Title\nBody.") note = syms[0] assert isinstance(note, NoteSymbol) assert note.title == "My Title" class TestExtractSymbolsWithFrontmatter: def test_title_from_frontmatter(self) -> None: content = _note("title: Vault Note", "# H1 ignored\nBody.") syms = extract_symbols("note.md", content) note = next(s for s in syms if isinstance(s, NoteSymbol)) assert note.title == "Vault Note" def test_project_from_frontmatter(self) -> None: content = _note("project: born-free", "Body.") syms = extract_symbols("note.md", content) note = next(s for s in syms if isinstance(s, NoteSymbol)) assert note.project == "born-free" def test_tags_from_frontmatter(self) -> None: content = _note("tags:\n - ai\n - memory", "Body.") syms = extract_symbols("note.md", content) note = next(s for s in syms if isinstance(s, NoteSymbol)) assert "ai" in note.tags def test_field_symbols_emitted(self) -> None: content = _note("source: telegram\ndate: 2025-01-01", "Body.") syms = extract_symbols("note.md", content) field_syms = [s for s in syms if isinstance(s, FieldSymbol)] keys = {s.key for s in field_syms} assert "source" in keys assert "date" in keys def test_field_address_format(self) -> None: content = _note("source: telegram", "Body.") syms = extract_symbols("inbox/foo.md", content) source_field = next( s for s in syms if isinstance(s, FieldSymbol) and s.key == "source" ) assert source_field.address == "inbox/foo.md::field:source" def test_empty_tags_not_emitted(self) -> None: content = _note("title: No tags", "Body.") syms = extract_symbols("note.md", content) field_keys = {s.key for s in syms if isinstance(s, FieldSymbol)} assert "tags" not in field_keys def test_false_state_snapshot_not_emitted(self) -> None: content = _note("state_snapshot: false", "Body.") syms = extract_symbols("note.md", content) field_keys = {s.key for s in syms if isinstance(s, FieldSymbol)} assert "state_snapshot" not in field_keys def test_true_state_snapshot_emitted(self) -> None: content = _note("state_snapshot: true", "Body.") syms = extract_symbols("note.md", content) field_keys = {s.key for s in syms if isinstance(s, FieldSymbol)} assert "state_snapshot" in field_keys class TestExtractSymbolsSections: def test_section_symbols_emitted(self) -> None: content = _note("title: Note", "## Background\nInfo.\n## Conclusion\nEnd.") syms = extract_symbols("note.md", content) secs = [s for s in syms if isinstance(s, SectionSymbol)] assert len(secs) == 2 def test_section_address_format(self) -> None: content = _note("", "## Background\nInfo.") syms = extract_symbols("note.md", content) sec = next(s for s in syms if isinstance(s, SectionSymbol)) assert sec.address == "note.md::section:2:Background" def test_section_level_correct(self) -> None: content = _note("", "### Deep\nContent.") syms = extract_symbols("note.md", content) sec = next(s for s in syms if isinstance(s, SectionSymbol)) assert sec.level == 3 def test_section_index_increments(self) -> None: content = _note("", "## A\n## B\n## C\n") syms = extract_symbols("note.md", content) secs = [s for s in syms if isinstance(s, SectionSymbol)] assert [s.index for s in secs] == [0, 1, 2] def test_preamble_section_included(self) -> None: content = _note("", "Intro text.\n## Section\nContent.") syms = extract_symbols("note.md", content) secs = [s for s in syms if isinstance(s, SectionSymbol)] titles = [s.title for s in secs] assert "(preamble)" in titles def test_section_body_not_empty(self) -> None: content = _note("", "## Section\nHas content.") syms = extract_symbols("note.md", content) sec = next(s for s in syms if isinstance(s, SectionSymbol)) assert sec.body.strip() def test_symbol_order_note_fields_sections(self) -> None: content = _note("source: telegram\ndate: 2025-01-01", "## My Section\nContent.") syms = extract_symbols("note.md", content) kinds = [s.kind for s in syms] # NoteSymbol always first assert kinds[0] == "note" # FieldSymbols before SectionSymbols first_field = next(i for i, k in enumerate(kinds) if k == "field") first_section = next(i for i, k in enumerate(kinds) if k == "section") assert first_field < first_section class TestExtractHelpers: def test_extract_sections_returns_only_sections(self) -> None: content = _note("source: t", "## A\n## B\n") secs = extract_sections("note.md", content) assert all(isinstance(s, SectionSymbol) for s in secs) assert len(secs) == 2 def test_extract_fields_returns_only_fields(self) -> None: content = _note("source: telegram\ndate: 2025-01-01", "Body.") fields = extract_fields("note.md", content) assert all(isinstance(f, FieldSymbol) for f in fields) assert len(fields) >= 2 def test_extract_sections_empty_note(self) -> None: assert extract_sections("note.md", b"") == [] def test_extract_fields_no_frontmatter(self) -> None: assert extract_fields("note.md", b"# Just a heading\nBody.") == [] class TestExtractSymbolsEdgeCases: def test_empty_content_returns_note_symbol(self) -> None: syms = extract_symbols("note.md", b"") assert len(syms) == 1 assert isinstance(syms[0], NoteSymbol) def test_binary_content_returns_note_symbol(self) -> None: syms = extract_symbols("note.md", b"\x00\x01\x02") assert len(syms) >= 1 assert isinstance(syms[0], NoteSymbol) def test_never_raises(self) -> None: for content in [b"", b"---\n", b"---\n---\n", b"# H\n", b"\xff\xfe"]: result = extract_symbols("note.md", content) assert isinstance(result, list) def test_crlf_content(self) -> None: content = b"---\r\nsource: slack\r\ndate: 2025-01-01\r\n---\r\n## Sec\r\nBody." syms = extract_symbols("note.md", content) secs = [s for s in syms if isinstance(s, SectionSymbol)] assert len(secs) >= 1 # ============================================================================ # TestSymbolsToJson (integration — muse code symbols --json schema) # ============================================================================ class TestSymbolsToJson: def _build_and_serialize(self, content: bytes) -> dict[str, Any]: syms = extract_symbols("inbox/foo.md", content) return symbols_to_json(syms) def test_domain_key(self) -> None: d = self._build_and_serialize(_note("source: t\ndate: 2025-01-01", "Body.")) assert d["domain"] == "knowtation" def test_total_symbols_count(self) -> None: content = _note("source: telegram\ndate: 2025-01-01", "## A\n## B\n") syms = extract_symbols("inbox/foo.md", content) d = symbols_to_json(syms) assert d["total_symbols"] == len(syms) def test_results_is_list(self) -> None: d = self._build_and_serialize(_note("title: x", "## S\n")) assert isinstance(d["results"], list) def test_every_result_has_kind_and_address(self) -> None: content = _note("source: telegram\ndate: 2025-01-01", "## Sec\nContent.") d = self._build_and_serialize(content) for item in d["results"]: assert "kind" in item assert "address" in item def test_json_serialisable(self) -> None: content = _note("title: x\ntags:\n - ai", "## Sec\nBody.") d = self._build_and_serialize(content) dumped = json.dumps(d) loaded = json.loads(dumped) assert loaded["domain"] == "knowtation" def test_note_symbol_in_results(self) -> None: d = self._build_and_serialize(_note("title: x", "Body.")) kinds = [r["kind"] for r in d["results"]] assert "note" in kinds def test_field_symbol_in_results(self) -> None: content = _note("source: telegram\ndate: 2025-01-01", "Body.") d = self._build_and_serialize(content) kinds = [r["kind"] for r in d["results"]] assert "field" in kinds def test_section_symbol_in_results(self) -> None: content = _note("", "## My Section\nContent.") d = self._build_and_serialize(content) kinds = [r["kind"] for r in d["results"]] assert "section" in kinds def test_real_vault_note_integration(self) -> None: """Integration: real vault note produces valid JSON matching CLI schema.""" note_path = _REAL_VAULT / "inbox" / "foo.md" if not note_path.exists(): pytest.skip("Real vault note not found") content = note_path.read_bytes() syms = extract_symbols("inbox/foo.md", content) d = symbols_to_json(syms) assert d["domain"] == "knowtation" assert d["total_symbols"] >= 1 # Matches the muse code symbols --json schema (domain-aware extension) assert "results" in d note_results = [r for r in d["results"] if r["kind"] == "note"] assert len(note_results) == 1 # ============================================================================ # TestRealVaultDataIntegrity # ============================================================================ @pytest.mark.skipif( not _REAL_VAULT.exists(), reason=f"Real vault not found at {_REAL_VAULT}", ) class TestRealVaultDataIntegrity: def test_all_notes_produce_note_symbol(self) -> None: notes = _load_content_notes() for path, content in notes.items(): syms = extract_symbols(path, content) assert len(syms) >= 1, f"No symbols for '{path}'" assert isinstance(syms[0], NoteSymbol), f"First symbol not NoteSymbol for '{path}'" def test_no_note_raises(self) -> None: notes = _load_content_notes() for path, content in notes.items(): try: extract_symbols(path, content) except Exception as exc: pytest.fail(f"extract_symbols raised on '{path}': {exc}") def test_section_counts_non_negative(self) -> None: notes = _load_content_notes() for path, content in notes.items(): secs = extract_sections(path, content) assert len(secs) >= 0 def test_field_counts_non_negative(self) -> None: notes = _load_content_notes() for path, content in notes.items(): fields = extract_fields(path, content) assert len(fields) >= 0 def test_all_addresses_contain_path(self) -> None: notes = _load_content_notes() for path, content in notes.items(): for sym in extract_symbols(path, content): assert path in sym.address, ( f"Address '{sym.address}' does not contain path '{path}'" ) def test_symbols_to_json_valid_for_all_notes(self) -> None: notes = _load_content_notes() for path, content in notes.items(): syms = extract_symbols(path, content) d = symbols_to_json(syms) # Must be JSON-serialisable. try: json.dumps(d) except (TypeError, ValueError) as exc: pytest.fail(f"symbols_to_json not serialisable for '{path}': {exc}")