tests/test_empty_frontmatter_field.py
Rama main · réplica de lectura del repositorio original.
"""空的 frontmatter 欄位不可以把下一行吞進來。
2026-09-10:品質檢查說有兩張卡該被排除,理由是 source_url 不是合法網址。
查下去,那兩張卡的 source_url 在索引裡是 `category: 02-seo-geo`——而檔案裡
那一行其實是空的:
source_url:
category: 02-seo-geo
builder 的正則是 `^source_url:\\s*"?([^"\\n]+)"?\\s*$`,而 `\\s` 含換行。欄位
空的時候 `\\s*` 跨過換行,`[^"\\n]+` 就吃到了下一行整行。
一個解析 bug 一路走成「把好卡片藏起來」:欄位被讀成一段不是網址的文字 →
品質規則判定 invalid_source_url → 排除 → 那張卡從此不出現在召回裡。其中一
張是「Obsidian + Claude Code:用AI重建你的第二大脑」,當天稍早的召回還引用
過它的內容。
這個模式在 builder 裡用在每一個欄位上,所以這裡把每一個都測一次。
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BUILDER = ROOT / "scripts" / "build_search_index.sh"
# 每個欄位都留空,下一行都是一段「看起來像值」的文字。
CARD_WITH_EMPTY_FIELDS = """---
id: empty-fields
type: knowledge-card
title:
category: 02-seo-geo
source_url:
tags: [seo, mcp]
source_type:
sensitivity: public
tweet_id:
confidence: medium
excluded:
excluded_reason: 不應該被讀成排除
---
# 真正的標題
## 📌 一句話摘要
真正的摘要
"""
def _bash() -> str:
if os.name != "nt":
return "bash"
for candidate in ["C:/Program Files/Git/bin/bash.exe",
"C:/Program Files (x86)/Git/bin/bash.exe"]:
if Path(candidate).exists():
return candidate
raise unittest.SkipTest("no POSIX bash available")
def _require_python3() -> None:
try:
proc = subprocess.run(["python3", "-c", "pass"], capture_output=True, timeout=30)
except (OSError, subprocess.SubprocessError):
raise unittest.SkipTest("這個環境沒有可執行的 python3")
if proc.returncode != 0:
raise unittest.SkipTest("python3 不是真的直譯器(Windows Store 轉接器)")
class EmptyFieldsDoNotSwallowTheNextLine(unittest.TestCase):
def _build(self) -> dict:
bash = _bash()
_require_python3()
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
cards = root / "memory" / "cards"
bookmarks = root / "memory" / "bookmarks"
cards.mkdir(parents=True)
bookmarks.mkdir(parents=True)
index = bookmarks / "search_index.json"
(cards / "empty-fields.md").write_text(CARD_WITH_EMPTY_FIELDS,
encoding="utf-8")
env = {**os.environ,
"WORKSPACE_DIR": str(root),
"BOOKMARKS_DIR": str(bookmarks),
"CARDS_DIR": str(cards),
"INDEX_FILE": str(index),
"PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
proc = subprocess.run([bash, str(BUILDER)], env=env,
capture_output=True, text=True, encoding="utf-8", errors="replace")
self.assertEqual(proc.returncode, 0, proc.stderr)
rows = json.loads(index.read_text(encoding="utf-8"))["items"]
self.assertEqual(len(rows), 1)
return rows[0]
def test_an_empty_source_url_stays_empty(self):
"""這就是那兩張卡被標成該排除的原因。"""
row = self._build()
self.assertNotIn("category:", row["source_url"])
self.assertEqual(row["source_url"], "")
def test_an_empty_title_falls_back_to_the_heading_not_the_next_line(self):
row = self._build()
self.assertEqual(row["title"], "真正的標題")
def test_an_empty_source_type_is_inferred_not_swallowed(self):
row = self._build()
self.assertNotIn("sensitivity", row["source_type"])
def test_an_empty_excluded_field_does_not_mean_excluded(self):
"""`excluded:` 空著就是沒有排除。讀成排除等於憑空藏掉一張卡。"""
row = self._build()
self.assertFalse(row.get("excluded"))
def test_the_real_category_and_tags_still_parse(self):
"""修法不能把有值的情況一起弄壞。"""
row = self._build()
self.assertEqual(row["category"], "02-seo-geo")
self.assertEqual(row["tags"], ["seo", "mcp"])
self.assertEqual(row["summary"], "真正的摘要")
if __name__ == "__main__":
unittest.main()