test_knowtation_symbols.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
27 days ago
| 1 | """Tests for muse/plugins/knowtation/symbols.py — Phase 1.4. |
| 2 | |
| 3 | Test tiers covered |
| 4 | ------------------ |
| 5 | 1. Unit — 30+ note shapes: address helpers, section splitter, field extraction, |
| 6 | NoteSymbol title resolution, full extract_symbols pipeline, edge cases. |
| 7 | 2. Integration — extract_symbols output matches the muse code symbols JSON schema |
| 8 | (domain-aware output contract); real-vault notes parsed without error. |
| 9 | 3. Data-integrity — every content note in the real vault produces at least one |
| 10 | NoteSymbol without raising; section and field counts are non-negative. |
| 11 | """ |
| 12 | |
| 13 | from __future__ import annotations |
| 14 | |
| 15 | import json |
| 16 | import pathlib |
| 17 | from typing import Any |
| 18 | |
| 19 | import pytest |
| 20 | |
| 21 | from muse.plugins.knowtation.symbols import ( |
| 22 | ADDRESS_SEP, |
| 23 | FIELD_PREFIX, |
| 24 | SECTION_PREFIX, |
| 25 | FieldSymbol, |
| 26 | NoteSymbol, |
| 27 | SectionSymbol, |
| 28 | extract_fields, |
| 29 | extract_sections, |
| 30 | extract_symbols, |
| 31 | make_field_address, |
| 32 | make_note_address, |
| 33 | make_section_address, |
| 34 | parse_address, |
| 35 | split_sections, |
| 36 | symbols_to_json, |
| 37 | ) |
| 38 | |
| 39 | # --------------------------------------------------------------------------- |
| 40 | # Helpers |
| 41 | # --------------------------------------------------------------------------- |
| 42 | |
| 43 | _REAL_VAULT = pathlib.Path("/Users/aaronrenecarvajal/knowtation/vault") |
| 44 | _SKIP_DIRS: frozenset[str] = frozenset({"templates", "meta"}) |
| 45 | |
| 46 | |
| 47 | def _note(frontmatter: str = "", body: str = "") -> bytes: |
| 48 | if frontmatter: |
| 49 | return f"---\n{frontmatter}\n---\n{body}".encode() |
| 50 | return body.encode() |
| 51 | |
| 52 | |
| 53 | def _load_content_notes() -> dict[str, bytes]: |
| 54 | notes: dict[str, bytes] = {} |
| 55 | for md_path in _REAL_VAULT.rglob("*.md"): |
| 56 | parts = md_path.relative_to(_REAL_VAULT).parts |
| 57 | if parts and parts[0] in _SKIP_DIRS: |
| 58 | continue |
| 59 | try: |
| 60 | notes[str(md_path.relative_to(_REAL_VAULT))] = md_path.read_bytes() |
| 61 | except OSError: |
| 62 | pass |
| 63 | return notes |
| 64 | |
| 65 | |
| 66 | # ============================================================================ |
| 67 | # TestAddressHelpers |
| 68 | # ============================================================================ |
| 69 | |
| 70 | |
| 71 | class TestMakeNoteAddress: |
| 72 | def test_returns_path(self) -> None: |
| 73 | assert make_note_address("inbox/foo.md") == "inbox/foo.md" |
| 74 | |
| 75 | def test_nested_path(self) -> None: |
| 76 | assert make_note_address("projects/born-free/notes.md") == "projects/born-free/notes.md" |
| 77 | |
| 78 | def test_root_level_path(self) -> None: |
| 79 | assert make_note_address("README.md") == "README.md" |
| 80 | |
| 81 | |
| 82 | class TestMakeFieldAddress: |
| 83 | def test_basic(self) -> None: |
| 84 | assert make_field_address("inbox/foo.md", "source") == "inbox/foo.md::field:source" |
| 85 | |
| 86 | def test_separator_present(self) -> None: |
| 87 | addr = make_field_address("p/note.md", "tags") |
| 88 | assert ADDRESS_SEP in addr |
| 89 | assert FIELD_PREFIX in addr |
| 90 | |
| 91 | def test_key_in_address(self) -> None: |
| 92 | addr = make_field_address("p/note.md", "causal_chain_id") |
| 93 | assert "causal_chain_id" in addr |
| 94 | |
| 95 | def test_path_in_address(self) -> None: |
| 96 | addr = make_field_address("projects/a/b.md", "date") |
| 97 | assert "projects/a/b.md" in addr |
| 98 | |
| 99 | |
| 100 | class TestMakeSectionAddress: |
| 101 | def test_basic_h2(self) -> None: |
| 102 | assert make_section_address("f.md", 2, "Background") == "f.md::section:2:Background" |
| 103 | |
| 104 | def test_preamble_level_zero(self) -> None: |
| 105 | addr = make_section_address("f.md", 0, "(preamble)") |
| 106 | assert "section:0:(preamble)" in addr |
| 107 | |
| 108 | def test_h1_level(self) -> None: |
| 109 | addr = make_section_address("f.md", 1, "Overview") |
| 110 | assert "section:1:Overview" in addr |
| 111 | |
| 112 | def test_h6_level(self) -> None: |
| 113 | addr = make_section_address("f.md", 6, "Deep") |
| 114 | assert "section:6:Deep" in addr |
| 115 | |
| 116 | def test_separator_in_address(self) -> None: |
| 117 | assert ADDRESS_SEP in make_section_address("f.md", 2, "Title") |
| 118 | |
| 119 | |
| 120 | class TestParseAddress: |
| 121 | def test_note_address(self) -> None: |
| 122 | result = parse_address("inbox/foo.md") |
| 123 | assert result["kind"] == "note" |
| 124 | assert result["path"] == "inbox/foo.md" |
| 125 | |
| 126 | def test_field_address(self) -> None: |
| 127 | result = parse_address("inbox/foo.md::field:source") |
| 128 | assert result["kind"] == "field" |
| 129 | assert result["path"] == "inbox/foo.md" |
| 130 | assert result["key"] == "source" |
| 131 | |
| 132 | def test_section_address(self) -> None: |
| 133 | result = parse_address("inbox/foo.md::section:2:Background") |
| 134 | assert result["kind"] == "section" |
| 135 | assert result["path"] == "inbox/foo.md" |
| 136 | assert result["level"] == "2" |
| 137 | assert result["title"] == "Background" |
| 138 | |
| 139 | def test_preamble_section_address(self) -> None: |
| 140 | result = parse_address("f.md::section:0:(preamble)") |
| 141 | assert result["kind"] == "section" |
| 142 | assert result["level"] == "0" |
| 143 | assert result["title"] == "(preamble)" |
| 144 | |
| 145 | def test_section_title_with_colon(self) -> None: |
| 146 | # Title contains a colon — only first two colons are separators. |
| 147 | result = parse_address("f.md::section:1:My: Title") |
| 148 | assert result["kind"] == "section" |
| 149 | assert result["title"] == "My: Title" |
| 150 | |
| 151 | def test_unknown_qualifier(self) -> None: |
| 152 | result = parse_address("f.md::custom:foo") |
| 153 | assert result["kind"] == "unknown" |
| 154 | |
| 155 | def test_round_trip_field(self) -> None: |
| 156 | addr = make_field_address("inbox/foo.md", "date") |
| 157 | parsed = parse_address(addr) |
| 158 | assert parsed["kind"] == "field" |
| 159 | assert parsed["key"] == "date" |
| 160 | |
| 161 | def test_round_trip_section(self) -> None: |
| 162 | addr = make_section_address("inbox/foo.md", 3, "Details") |
| 163 | parsed = parse_address(addr) |
| 164 | assert parsed["kind"] == "section" |
| 165 | assert parsed["level"] == "3" |
| 166 | assert parsed["title"] == "Details" |
| 167 | |
| 168 | |
| 169 | # ============================================================================ |
| 170 | # TestSplitSections (30+ note shapes) |
| 171 | # ============================================================================ |
| 172 | |
| 173 | |
| 174 | class TestSplitSections: |
| 175 | # ── Empty / blank inputs ──────────────────────────────────────────────── |
| 176 | |
| 177 | def test_empty_string(self) -> None: |
| 178 | assert split_sections("") == [] |
| 179 | |
| 180 | def test_whitespace_only(self) -> None: |
| 181 | assert split_sections(" \n ") == [] |
| 182 | |
| 183 | # ── No headings ───────────────────────────────────────────────────────── |
| 184 | |
| 185 | def test_plain_text_no_headings(self) -> None: |
| 186 | body = "Just plain text.\nNo headings here." |
| 187 | secs = split_sections(body) |
| 188 | assert len(secs) == 1 |
| 189 | level, title, _, _ = secs[0] |
| 190 | assert level == 0 |
| 191 | assert title == "(preamble)" |
| 192 | |
| 193 | def test_body_only_preamble_content(self) -> None: |
| 194 | body = "First paragraph.\n\nSecond paragraph." |
| 195 | secs = split_sections(body) |
| 196 | assert len(secs) == 1 |
| 197 | assert secs[0][1] == "(preamble)" |
| 198 | |
| 199 | # ── H1 headings ───────────────────────────────────────────────────────── |
| 200 | |
| 201 | def test_single_h1(self) -> None: |
| 202 | body = "# Introduction\nSome content." |
| 203 | secs = split_sections(body) |
| 204 | assert len(secs) == 1 |
| 205 | level, title, _, _ = secs[0] |
| 206 | assert level == 1 |
| 207 | assert title == "Introduction" |
| 208 | |
| 209 | def test_two_h1_sections(self) -> None: |
| 210 | body = "# Part A\nContent A.\n# Part B\nContent B." |
| 211 | secs = split_sections(body) |
| 212 | assert len(secs) == 2 |
| 213 | assert secs[0][0] == 1 |
| 214 | assert secs[0][1] == "Part A" |
| 215 | assert secs[1][1] == "Part B" |
| 216 | |
| 217 | # ── H2 headings (chunk.mjs canonical split level) ─────────────────────── |
| 218 | |
| 219 | def test_single_h2(self) -> None: |
| 220 | secs = split_sections("## Background\nSome text.") |
| 221 | assert len(secs) == 1 |
| 222 | assert secs[0][0] == 2 |
| 223 | assert secs[0][1] == "Background" |
| 224 | |
| 225 | def test_two_h2_sections(self) -> None: |
| 226 | body = "## Alpha\nContent alpha.\n## Beta\nContent beta." |
| 227 | secs = split_sections(body) |
| 228 | assert len(secs) == 2 |
| 229 | assert secs[0][1] == "Alpha" |
| 230 | assert secs[1][1] == "Beta" |
| 231 | |
| 232 | def test_three_h2_sections(self) -> None: |
| 233 | body = "## A\n## B\n## C\n" |
| 234 | assert len(split_sections(body)) == 3 |
| 235 | |
| 236 | # ── H3 headings ───────────────────────────────────────────────────────── |
| 237 | |
| 238 | def test_h3_section(self) -> None: |
| 239 | secs = split_sections("### Details\nFoo.") |
| 240 | assert secs[0][0] == 3 |
| 241 | |
| 242 | # ── Mixed heading levels ───────────────────────────────────────────────── |
| 243 | |
| 244 | def test_preamble_then_h2(self) -> None: |
| 245 | body = "Intro text.\n## Section\nContent." |
| 246 | secs = split_sections(body) |
| 247 | assert len(secs) == 2 |
| 248 | assert secs[0][1] == "(preamble)" |
| 249 | assert secs[1][1] == "Section" |
| 250 | |
| 251 | def test_h1_then_h2(self) -> None: |
| 252 | body = "# Title\n## Sub\nContent." |
| 253 | secs = split_sections(body) |
| 254 | assert len(secs) == 2 |
| 255 | assert secs[0][0] == 1 |
| 256 | assert secs[1][0] == 2 |
| 257 | |
| 258 | def test_h2_then_h3_under_it(self) -> None: |
| 259 | body = "## Parent\n### Child\nContent." |
| 260 | secs = split_sections(body) |
| 261 | assert len(secs) == 2 |
| 262 | assert secs[0][1] == "Parent" |
| 263 | assert secs[1][1] == "Child" |
| 264 | |
| 265 | def test_mixed_h1_h2_h3(self) -> None: |
| 266 | body = "# A\n## B\n### C\n## D\n" |
| 267 | secs = split_sections(body) |
| 268 | assert len(secs) == 4 |
| 269 | levels = [s[0] for s in secs] |
| 270 | assert levels == [1, 2, 3, 2] |
| 271 | |
| 272 | # ── Edge cases ─────────────────────────────────────────────────────────── |
| 273 | |
| 274 | def test_heading_with_trailing_hashes(self) -> None: |
| 275 | # ATX-style headings may have trailing #: "## Title ##" |
| 276 | secs = split_sections("## Title ##\nContent.") |
| 277 | assert secs[0][1] == "Title" |
| 278 | |
| 279 | def test_heading_with_inline_emphasis(self) -> None: |
| 280 | secs = split_sections("## **Bold** Section\nContent.") |
| 281 | assert "Bold" in secs[0][1] |
| 282 | |
| 283 | def test_heading_not_at_line_start_ignored(self) -> None: |
| 284 | # Indented "heading" should not be treated as a heading. |
| 285 | body = " ## Not a heading\nPlain text." |
| 286 | secs = split_sections(body) |
| 287 | assert len(secs) == 1 |
| 288 | assert secs[0][1] == "(preamble)" |
| 289 | |
| 290 | def test_heading_in_code_block_still_matched(self) -> None: |
| 291 | # Our regex doesn't know about code blocks; this is an intentional |
| 292 | # limitation documented in the module. |
| 293 | body = "```\n## In code\n```\n## Real heading\n" |
| 294 | secs = split_sections(body) |
| 295 | assert len(secs) >= 1 |
| 296 | |
| 297 | def test_section_body_includes_heading_line(self) -> None: |
| 298 | body = "## My Section\nBody text here." |
| 299 | secs = split_sections(body) |
| 300 | assert secs[0][2].startswith("## My Section") |
| 301 | |
| 302 | def test_section_body_ends_before_next_heading(self) -> None: |
| 303 | body = "## A\nContent A.\n## B\nContent B." |
| 304 | secs = split_sections(body) |
| 305 | assert "## B" not in secs[0][2] |
| 306 | |
| 307 | def test_line_number_first_section(self) -> None: |
| 308 | body = "## First\nContent." |
| 309 | secs = split_sections(body) |
| 310 | assert secs[0][3] == 1 # first line |
| 311 | |
| 312 | def test_line_number_second_section(self) -> None: |
| 313 | body = "## First\nLine 2.\n## Second\nLine 4." |
| 314 | secs = split_sections(body) |
| 315 | assert secs[1][3] == 3 # "## Second" is on line 3 |
| 316 | |
| 317 | def test_h4_h5_h6_detected(self) -> None: |
| 318 | body = "#### Four\n##### Five\n###### Six\n" |
| 319 | secs = split_sections(body) |
| 320 | levels = [s[0] for s in secs] |
| 321 | assert 4 in levels |
| 322 | assert 5 in levels |
| 323 | assert 6 in levels |
| 324 | |
| 325 | def test_empty_section_body(self) -> None: |
| 326 | body = "## A\n## B\n" |
| 327 | secs = split_sections(body) |
| 328 | # Section A has no body text (immediately followed by B) |
| 329 | assert len(secs) == 2 |
| 330 | |
| 331 | def test_note_with_only_newlines(self) -> None: |
| 332 | assert split_sections("\n\n\n") == [] |
| 333 | |
| 334 | def test_unicode_heading(self) -> None: |
| 335 | body = "## 日本語セクション\nContent." |
| 336 | secs = split_sections(body) |
| 337 | assert "日本語セクション" in secs[0][1] |
| 338 | |
| 339 | def test_heading_with_numbers(self) -> None: |
| 340 | body = "## Section 1.2.3\nContent." |
| 341 | secs = split_sections(body) |
| 342 | assert secs[0][1] == "Section 1.2.3" |
| 343 | |
| 344 | def test_many_sections(self) -> None: |
| 345 | headings = "\n".join(f"## Section {i}\nContent {i}." for i in range(20)) |
| 346 | secs = split_sections(headings) |
| 347 | assert len(secs) == 20 |
| 348 | |
| 349 | def test_preamble_not_added_when_absent(self) -> None: |
| 350 | body = "## Heading\nContent." |
| 351 | secs = split_sections(body) |
| 352 | assert secs[0][1] != "(preamble)" |
| 353 | |
| 354 | def test_crlf_body(self) -> None: |
| 355 | body = "## Section\r\nContent.\r\n## Next\r\nMore." |
| 356 | secs = split_sections(body) |
| 357 | assert len(secs) >= 1 |
| 358 | |
| 359 | |
| 360 | # ============================================================================ |
| 361 | # TestExtractSymbolsPipeline |
| 362 | # ============================================================================ |
| 363 | |
| 364 | |
| 365 | class TestExtractSymbolsNoteOnly: |
| 366 | """Notes with no frontmatter and no headings.""" |
| 367 | |
| 368 | def test_returns_one_note_symbol(self) -> None: |
| 369 | syms = extract_symbols("plain.md", b"Just plain text.") |
| 370 | assert len([s for s in syms if isinstance(s, NoteSymbol)]) == 1 |
| 371 | |
| 372 | def test_note_symbol_is_first(self) -> None: |
| 373 | syms = extract_symbols("plain.md", b"Plain text.") |
| 374 | assert isinstance(syms[0], NoteSymbol) |
| 375 | |
| 376 | def test_note_address(self) -> None: |
| 377 | syms = extract_symbols("inbox/foo.md", b"Body.") |
| 378 | note = syms[0] |
| 379 | assert isinstance(note, NoteSymbol) |
| 380 | assert note.address == "inbox/foo.md" |
| 381 | |
| 382 | def test_note_title_from_filename(self) -> None: |
| 383 | syms = extract_symbols("inbox/my-note.md", b"Body.") |
| 384 | note = syms[0] |
| 385 | assert isinstance(note, NoteSymbol) |
| 386 | assert note.title == "my-note" |
| 387 | |
| 388 | def test_note_title_from_h1(self) -> None: |
| 389 | syms = extract_symbols("inbox/foo.md", b"# My Title\nBody.") |
| 390 | note = syms[0] |
| 391 | assert isinstance(note, NoteSymbol) |
| 392 | assert note.title == "My Title" |
| 393 | |
| 394 | |
| 395 | class TestExtractSymbolsWithFrontmatter: |
| 396 | def test_title_from_frontmatter(self) -> None: |
| 397 | content = _note("title: Vault Note", "# H1 ignored\nBody.") |
| 398 | syms = extract_symbols("note.md", content) |
| 399 | note = next(s for s in syms if isinstance(s, NoteSymbol)) |
| 400 | assert note.title == "Vault Note" |
| 401 | |
| 402 | def test_project_from_frontmatter(self) -> None: |
| 403 | content = _note("project: born-free", "Body.") |
| 404 | syms = extract_symbols("note.md", content) |
| 405 | note = next(s for s in syms if isinstance(s, NoteSymbol)) |
| 406 | assert note.project == "born-free" |
| 407 | |
| 408 | def test_tags_from_frontmatter(self) -> None: |
| 409 | content = _note("tags:\n - ai\n - memory", "Body.") |
| 410 | syms = extract_symbols("note.md", content) |
| 411 | note = next(s for s in syms if isinstance(s, NoteSymbol)) |
| 412 | assert "ai" in note.tags |
| 413 | |
| 414 | def test_field_symbols_emitted(self) -> None: |
| 415 | content = _note("source: telegram\ndate: 2025-01-01", "Body.") |
| 416 | syms = extract_symbols("note.md", content) |
| 417 | field_syms = [s for s in syms if isinstance(s, FieldSymbol)] |
| 418 | keys = {s.key for s in field_syms} |
| 419 | assert "source" in keys |
| 420 | assert "date" in keys |
| 421 | |
| 422 | def test_field_address_format(self) -> None: |
| 423 | content = _note("source: telegram", "Body.") |
| 424 | syms = extract_symbols("inbox/foo.md", content) |
| 425 | source_field = next( |
| 426 | s for s in syms if isinstance(s, FieldSymbol) and s.key == "source" |
| 427 | ) |
| 428 | assert source_field.address == "inbox/foo.md::field:source" |
| 429 | |
| 430 | def test_empty_tags_not_emitted(self) -> None: |
| 431 | content = _note("title: No tags", "Body.") |
| 432 | syms = extract_symbols("note.md", content) |
| 433 | field_keys = {s.key for s in syms if isinstance(s, FieldSymbol)} |
| 434 | assert "tags" not in field_keys |
| 435 | |
| 436 | def test_false_state_snapshot_not_emitted(self) -> None: |
| 437 | content = _note("state_snapshot: false", "Body.") |
| 438 | syms = extract_symbols("note.md", content) |
| 439 | field_keys = {s.key for s in syms if isinstance(s, FieldSymbol)} |
| 440 | assert "state_snapshot" not in field_keys |
| 441 | |
| 442 | def test_true_state_snapshot_emitted(self) -> None: |
| 443 | content = _note("state_snapshot: true", "Body.") |
| 444 | syms = extract_symbols("note.md", content) |
| 445 | field_keys = {s.key for s in syms if isinstance(s, FieldSymbol)} |
| 446 | assert "state_snapshot" in field_keys |
| 447 | |
| 448 | |
| 449 | class TestExtractSymbolsSections: |
| 450 | def test_section_symbols_emitted(self) -> None: |
| 451 | content = _note("title: Note", "## Background\nInfo.\n## Conclusion\nEnd.") |
| 452 | syms = extract_symbols("note.md", content) |
| 453 | secs = [s for s in syms if isinstance(s, SectionSymbol)] |
| 454 | assert len(secs) == 2 |
| 455 | |
| 456 | def test_section_address_format(self) -> None: |
| 457 | content = _note("", "## Background\nInfo.") |
| 458 | syms = extract_symbols("note.md", content) |
| 459 | sec = next(s for s in syms if isinstance(s, SectionSymbol)) |
| 460 | assert sec.address == "note.md::section:2:Background" |
| 461 | |
| 462 | def test_section_level_correct(self) -> None: |
| 463 | content = _note("", "### Deep\nContent.") |
| 464 | syms = extract_symbols("note.md", content) |
| 465 | sec = next(s for s in syms if isinstance(s, SectionSymbol)) |
| 466 | assert sec.level == 3 |
| 467 | |
| 468 | def test_section_index_increments(self) -> None: |
| 469 | content = _note("", "## A\n## B\n## C\n") |
| 470 | syms = extract_symbols("note.md", content) |
| 471 | secs = [s for s in syms if isinstance(s, SectionSymbol)] |
| 472 | assert [s.index for s in secs] == [0, 1, 2] |
| 473 | |
| 474 | def test_preamble_section_included(self) -> None: |
| 475 | content = _note("", "Intro text.\n## Section\nContent.") |
| 476 | syms = extract_symbols("note.md", content) |
| 477 | secs = [s for s in syms if isinstance(s, SectionSymbol)] |
| 478 | titles = [s.title for s in secs] |
| 479 | assert "(preamble)" in titles |
| 480 | |
| 481 | def test_section_body_not_empty(self) -> None: |
| 482 | content = _note("", "## Section\nHas content.") |
| 483 | syms = extract_symbols("note.md", content) |
| 484 | sec = next(s for s in syms if isinstance(s, SectionSymbol)) |
| 485 | assert sec.body.strip() |
| 486 | |
| 487 | def test_symbol_order_note_fields_sections(self) -> None: |
| 488 | content = _note("source: telegram\ndate: 2025-01-01", "## My Section\nContent.") |
| 489 | syms = extract_symbols("note.md", content) |
| 490 | kinds = [s.kind for s in syms] |
| 491 | # NoteSymbol always first |
| 492 | assert kinds[0] == "note" |
| 493 | # FieldSymbols before SectionSymbols |
| 494 | first_field = next(i for i, k in enumerate(kinds) if k == "field") |
| 495 | first_section = next(i for i, k in enumerate(kinds) if k == "section") |
| 496 | assert first_field < first_section |
| 497 | |
| 498 | |
| 499 | class TestExtractHelpers: |
| 500 | def test_extract_sections_returns_only_sections(self) -> None: |
| 501 | content = _note("source: t", "## A\n## B\n") |
| 502 | secs = extract_sections("note.md", content) |
| 503 | assert all(isinstance(s, SectionSymbol) for s in secs) |
| 504 | assert len(secs) == 2 |
| 505 | |
| 506 | def test_extract_fields_returns_only_fields(self) -> None: |
| 507 | content = _note("source: telegram\ndate: 2025-01-01", "Body.") |
| 508 | fields = extract_fields("note.md", content) |
| 509 | assert all(isinstance(f, FieldSymbol) for f in fields) |
| 510 | assert len(fields) >= 2 |
| 511 | |
| 512 | def test_extract_sections_empty_note(self) -> None: |
| 513 | assert extract_sections("note.md", b"") == [] |
| 514 | |
| 515 | def test_extract_fields_no_frontmatter(self) -> None: |
| 516 | assert extract_fields("note.md", b"# Just a heading\nBody.") == [] |
| 517 | |
| 518 | |
| 519 | class TestExtractSymbolsEdgeCases: |
| 520 | def test_empty_content_returns_note_symbol(self) -> None: |
| 521 | syms = extract_symbols("note.md", b"") |
| 522 | assert len(syms) == 1 |
| 523 | assert isinstance(syms[0], NoteSymbol) |
| 524 | |
| 525 | def test_binary_content_returns_note_symbol(self) -> None: |
| 526 | syms = extract_symbols("note.md", b"\x00\x01\x02") |
| 527 | assert len(syms) >= 1 |
| 528 | assert isinstance(syms[0], NoteSymbol) |
| 529 | |
| 530 | def test_never_raises(self) -> None: |
| 531 | for content in [b"", b"---\n", b"---\n---\n", b"# H\n", b"\xff\xfe"]: |
| 532 | result = extract_symbols("note.md", content) |
| 533 | assert isinstance(result, list) |
| 534 | |
| 535 | def test_crlf_content(self) -> None: |
| 536 | content = b"---\r\nsource: slack\r\ndate: 2025-01-01\r\n---\r\n## Sec\r\nBody." |
| 537 | syms = extract_symbols("note.md", content) |
| 538 | secs = [s for s in syms if isinstance(s, SectionSymbol)] |
| 539 | assert len(secs) >= 1 |
| 540 | |
| 541 | |
| 542 | # ============================================================================ |
| 543 | # TestSymbolsToJson (integration — muse code symbols --json schema) |
| 544 | # ============================================================================ |
| 545 | |
| 546 | |
| 547 | class TestSymbolsToJson: |
| 548 | def _build_and_serialize(self, content: bytes) -> dict[str, Any]: |
| 549 | syms = extract_symbols("inbox/foo.md", content) |
| 550 | return symbols_to_json(syms) |
| 551 | |
| 552 | def test_domain_key(self) -> None: |
| 553 | d = self._build_and_serialize(_note("source: t\ndate: 2025-01-01", "Body.")) |
| 554 | assert d["domain"] == "knowtation" |
| 555 | |
| 556 | def test_total_symbols_count(self) -> None: |
| 557 | content = _note("source: telegram\ndate: 2025-01-01", "## A\n## B\n") |
| 558 | syms = extract_symbols("inbox/foo.md", content) |
| 559 | d = symbols_to_json(syms) |
| 560 | assert d["total_symbols"] == len(syms) |
| 561 | |
| 562 | def test_results_is_list(self) -> None: |
| 563 | d = self._build_and_serialize(_note("title: x", "## S\n")) |
| 564 | assert isinstance(d["results"], list) |
| 565 | |
| 566 | def test_every_result_has_kind_and_address(self) -> None: |
| 567 | content = _note("source: telegram\ndate: 2025-01-01", "## Sec\nContent.") |
| 568 | d = self._build_and_serialize(content) |
| 569 | for item in d["results"]: |
| 570 | assert "kind" in item |
| 571 | assert "address" in item |
| 572 | |
| 573 | def test_json_serialisable(self) -> None: |
| 574 | content = _note("title: x\ntags:\n - ai", "## Sec\nBody.") |
| 575 | d = self._build_and_serialize(content) |
| 576 | dumped = json.dumps(d) |
| 577 | loaded = json.loads(dumped) |
| 578 | assert loaded["domain"] == "knowtation" |
| 579 | |
| 580 | def test_note_symbol_in_results(self) -> None: |
| 581 | d = self._build_and_serialize(_note("title: x", "Body.")) |
| 582 | kinds = [r["kind"] for r in d["results"]] |
| 583 | assert "note" in kinds |
| 584 | |
| 585 | def test_field_symbol_in_results(self) -> None: |
| 586 | content = _note("source: telegram\ndate: 2025-01-01", "Body.") |
| 587 | d = self._build_and_serialize(content) |
| 588 | kinds = [r["kind"] for r in d["results"]] |
| 589 | assert "field" in kinds |
| 590 | |
| 591 | def test_section_symbol_in_results(self) -> None: |
| 592 | content = _note("", "## My Section\nContent.") |
| 593 | d = self._build_and_serialize(content) |
| 594 | kinds = [r["kind"] for r in d["results"]] |
| 595 | assert "section" in kinds |
| 596 | |
| 597 | def test_real_vault_note_integration(self) -> None: |
| 598 | """Integration: real vault note produces valid JSON matching CLI schema.""" |
| 599 | note_path = _REAL_VAULT / "inbox" / "foo.md" |
| 600 | if not note_path.exists(): |
| 601 | pytest.skip("Real vault note not found") |
| 602 | content = note_path.read_bytes() |
| 603 | syms = extract_symbols("inbox/foo.md", content) |
| 604 | d = symbols_to_json(syms) |
| 605 | assert d["domain"] == "knowtation" |
| 606 | assert d["total_symbols"] >= 1 |
| 607 | # Matches the muse code symbols --json schema (domain-aware extension) |
| 608 | assert "results" in d |
| 609 | note_results = [r for r in d["results"] if r["kind"] == "note"] |
| 610 | assert len(note_results) == 1 |
| 611 | |
| 612 | |
| 613 | # ============================================================================ |
| 614 | # TestRealVaultDataIntegrity |
| 615 | # ============================================================================ |
| 616 | |
| 617 | |
| 618 | @pytest.mark.skipif( |
| 619 | not _REAL_VAULT.exists(), |
| 620 | reason=f"Real vault not found at {_REAL_VAULT}", |
| 621 | ) |
| 622 | class TestRealVaultDataIntegrity: |
| 623 | def test_all_notes_produce_note_symbol(self) -> None: |
| 624 | notes = _load_content_notes() |
| 625 | for path, content in notes.items(): |
| 626 | syms = extract_symbols(path, content) |
| 627 | assert len(syms) >= 1, f"No symbols for '{path}'" |
| 628 | assert isinstance(syms[0], NoteSymbol), f"First symbol not NoteSymbol for '{path}'" |
| 629 | |
| 630 | def test_no_note_raises(self) -> None: |
| 631 | notes = _load_content_notes() |
| 632 | for path, content in notes.items(): |
| 633 | try: |
| 634 | extract_symbols(path, content) |
| 635 | except Exception as exc: |
| 636 | pytest.fail(f"extract_symbols raised on '{path}': {exc}") |
| 637 | |
| 638 | def test_section_counts_non_negative(self) -> None: |
| 639 | notes = _load_content_notes() |
| 640 | for path, content in notes.items(): |
| 641 | secs = extract_sections(path, content) |
| 642 | assert len(secs) >= 0 |
| 643 | |
| 644 | def test_field_counts_non_negative(self) -> None: |
| 645 | notes = _load_content_notes() |
| 646 | for path, content in notes.items(): |
| 647 | fields = extract_fields(path, content) |
| 648 | assert len(fields) >= 0 |
| 649 | |
| 650 | def test_all_addresses_contain_path(self) -> None: |
| 651 | notes = _load_content_notes() |
| 652 | for path, content in notes.items(): |
| 653 | for sym in extract_symbols(path, content): |
| 654 | assert path in sym.address, ( |
| 655 | f"Address '{sym.address}' does not contain path '{path}'" |
| 656 | ) |
| 657 | |
| 658 | def test_symbols_to_json_valid_for_all_notes(self) -> None: |
| 659 | notes = _load_content_notes() |
| 660 | for path, content in notes.items(): |
| 661 | syms = extract_symbols(path, content) |
| 662 | d = symbols_to_json(syms) |
| 663 | # Must be JSON-serialisable. |
| 664 | try: |
| 665 | json.dumps(d) |
| 666 | except (TypeError, ValueError) as exc: |
| 667 | pytest.fail(f"symbols_to_json not serialisable for '{path}': {exc}") |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
27 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
27 days ago