Files
arcrun-collector/wikishape_test.go
T
Leo 12c2d41886 feat(collector): daemon 產出改成規範形 wiki 卡——1 文件卡+N 原子卡落 .wiki/(InkStoneCo#44 ④)
LLM 只回 JSON 判斷,格式/落點/連結閉合/索引/manifest 全由 wikishape.go 機械組裝:
- 卡形=frontmatter(tags/gloss/created/updated)+← 上層+摘要/重點/實體(帶類型)/
  關聯(內文知識關係/卡片關係/出處)——差距表 #6
- 落點 <節點>/.wiki/、檔名=H1(.wiki 隱藏目錄自身即機器標記,machinemark 例外③)——#7
- 00-INDEX 機械維護:每行五樣照抄 frontmatter;每份原稿必列——#8
- 萃取端就切 1 hub+N 原子卡(gemma 路 prompt 改 JSON 契約)——#9
- 無可萃概念標「空」+理由,上索引不產卡——#10
機械保證對著 wiki-lint.py 寫:斷連結拆殼、index 式句改寫、三元組恰三項、
雙向邊自動補反向、佔用不覆蓋、原稿永不動;同名概念跨文件先消歧(merge 歸第⑤環)。
scan/convert 白名單補 .feature/.yaml/.yml/.org/.rst(規範洞 6);
lint.go 新舊格式雙軌(workers-ai 雲端 prompt 屬 matrix/arcrun 核心,待開票);
direct.go 只送文件卡上雲(takedown 配對鍵不變)、下架收走 .wiki 產物。

實測:真資料夾+真 Gemini → 2 節點 12 卡,wiki-lint 17/17 exit 0,
原稿 sha256 前後不變;go test collector+supervisor+arcrun-app 全綠(新增 9 案)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:03:45 +08:00

288 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// wikishape_test.go — 塑形層的格式保證(InkStoneCo#44 ④,逐項對著 wiki-lint.py 寫)。
package collector
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
var wsNow = time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)
func wsExtract() *DocExtract {
return &DocExtract{
Gloss: "測試文件的一句話",
Tags: []string{"測試", "規範"},
Summary: "一段給只需要知道大概的人看的摘要。",
Points: []string{"整份文件的重心其實在 [[概念甲]],其餘是背景", "而 [[概念乙]] 是它的反例"},
Concepts: []WikiConcept{
{
Name: "概念甲", Gloss: "甲的一句話", Tags: []string{"測試"},
Summary: "甲的摘要。", Points: []string{"甲的第一個判斷"},
Entities: []WikiEntity{{Name: "甲", Type: "概念", Desc: "主角"}},
Facts: [][]string{{"甲", "屬於", "測試域"}},
Relations: []WikiRelation{{To: "概念乙", Pred: "對照"}},
},
{
Name: "概念乙", Gloss: "乙的一句話",
Summary: "乙的摘要。", Points: []string{"乙的判斷"},
},
},
}
}
// 格式逐項:frontmatter 四欄/← 上層/四段/實體帶類型/關聯三小節/出處指得到原稿。
func TestBuildWikiDoc_CardShapeMatchesSpec(t *testing.T) {
root := t.TempDir()
src := "# 測試文件\n\n內文若干"
mustWrite(t, filepath.Join(root, "測試文件.md"), src)
cards, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsNow)
if err != nil {
t.Fatal(err)
}
if len(cards) != 3 || cards[0] != ".wiki/測試文件.md" {
t.Fatalf("cards=%v", cards)
}
doc := mustRead(t, filepath.Join(root, ".wiki", "測試文件.md"))
for _, want := range []string{
"tags: [測試, 規範]", "gloss: 測試文件的一句話", "created: 2026-08-15", "updated: 2026-08-15",
"# 測試文件", "← [[00-INDEX]]", "## 摘要", "## 重點", "## 實體", "## 關聯",
"### 內文知識關係", "### 卡片關係", "### 出處",
"[[概念甲]]", "[[概念乙]]",
} {
if !strings.Contains(doc, want) {
t.Fatalf("文件卡缺「%s」:\n%s", want, doc)
}
}
// 出處路徑相對 .wiki,真的指得到原稿(lint「出處路徑指不到原稿」項)
if !strings.Contains(doc, "`../測試文件.md`") {
t.Fatalf("出處路徑不對:\n%s", doc)
}
ca := mustRead(t, filepath.Join(root, ".wiki", "概念甲.md"))
for _, want := range []string{
"← [[測試文件]]",
"- **甲**(概念)— 主角", // 實體帶類型(規範:類型不能省)
} {
if !strings.Contains(ca, want) {
t.Fatalf("概念卡缺「%s」:\n%s", want, ca)
}
}
// 乙沒給實體 → 自指 fallback(每張卡至少一個帶類型的實體)
cb := mustRead(t, filepath.Join(root, ".wiki", "概念乙.md"))
if !strings.Contains(cb, "- **概念乙**(概念)—") {
t.Fatalf("概念乙缺實體 fallback\n%s", cb)
}
}
// 雙向連結保證:甲→乙有邊、乙沒回邊 → 機械補「相關」反向邊(lint「單向連結」項)。
// 出自/整理出 這對也天然雙向(lint 孤島卡項)。
func TestBuildWikiDoc_EdgesAreBidirectional(t *testing.T) {
root := t.TempDir()
src := "# 測試文件\n內文"
mustWrite(t, filepath.Join(root, "測試文件.md"), src)
if _, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsNow); err != nil {
t.Fatal(err)
}
cb := mustRead(t, filepath.Join(root, ".wiki", "概念乙.md"))
if !strings.Contains(cb, "[[概念乙]]"+triSep+"相關"+triSep+"[[概念甲]]") {
t.Fatalf("缺機械補上的反向邊:\n%s", cb)
}
}
// 連結閉合:指不到的 [[連結]] 拆殼;index 式重點行被改寫(lint 斷連結/hub index 式兩項)。
func TestBuildWikiDoc_LinksClosedAndIndexishFixed(t *testing.T) {
root := t.TempDir()
src := "# 文件\n內文"
mustWrite(t, filepath.Join(root, "文件.md"), src)
ex := wsExtract()
ex.Points = []string{"[[概念甲]] — 這行是 index 式,要被改寫", "這裡提到 [[不存在的卡]] 應拆殼"}
if _, err := BuildWikiDoc(root, "文件.md", src, ex, wsNow); err != nil {
t.Fatal(err)
}
doc := mustRead(t, filepath.Join(root, ".wiki", "文件.md"))
if strings.Contains(doc, "[[不存在的卡]]") {
t.Fatalf("留下了斷連結:\n%s", doc)
}
if !strings.Contains(doc, "- 關於 [[概念甲]]") {
t.Fatalf("index 式重點行沒被改寫:\n%s", doc)
}
}
// 標空(差距 #10):no_concept 不產卡,但 00-INDEX 的「## 文件」一定列它+理由。
func TestMarkDocNoConcept_ListedOnIndex(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "發票.txt"), "金額 100")
ex := &DocExtract{NoConcept: true, Reason: "純紀錄(發票),沒有可獨立成立的判斷"}
cards, err := BuildWikiDoc(root, "發票.txt", "金額 100", ex, wsNow)
if err != nil {
t.Fatal(err)
}
if len(cards) != 0 {
t.Fatalf("標空不該產卡:%v", cards)
}
idx := mustRead(t, filepath.Join(root, ".wiki", "00-INDEX.md"))
if !strings.Contains(idx, "## 文件") || !strings.Contains(idx, "- `發票.txt` — 空:純紀錄(發票)") {
t.Fatalf("00-INDEX 沒把空檔列出來:\n%s", idx)
}
}
// 巢狀節點:卡落在 `<節點>/.wiki/`;祖先鏈的 00-INDEX 有「## 子節點」;
// manifest 的 node/path 用 wiki-lint 讀得懂的鍵。
func TestBuildWikiDoc_NestedNodeAndAncestors(t *testing.T) {
root := t.TempDir()
rel := filepath.Join("專案", "會議")
mustMkdir(t, filepath.Join(root, rel))
src := "# 週會決議\n內容"
mustWrite(t, filepath.Join(root, rel, "週會.md"), src)
if _, err := BuildWikiDoc(root, "專案/會議/週會.md", src, wsExtract(), wsNow); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "專案", "會議", ".wiki", "週會決議.md")); err != nil {
t.Fatalf("卡沒落在節點的 .wiki%v", err)
}
for _, p := range []string{
filepath.Join(root, ".wiki", "00-INDEX.md"),
filepath.Join(root, "專案", ".wiki", "00-INDEX.md"),
filepath.Join(root, "專案", "會議", ".wiki", "00-INDEX.md"),
} {
if _, err := os.Stat(p); err != nil {
t.Fatalf("祖先索引缺:%s%v", p, err)
}
}
rootIdx := mustRead(t, filepath.Join(root, ".wiki", "00-INDEX.md"))
if !strings.Contains(rootIdx, "## 子節點") || !strings.Contains(rootIdx, "`專案/`") {
t.Fatalf("根索引缺子節點導覽:\n%s", rootIdx)
}
var man wikiManifest
data := mustRead(t, filepath.Join(root, ".wiki", "manifest.json"))
if err := json.Unmarshal([]byte(data), &man); err != nil {
t.Fatal(err)
}
if len(man.Docs) != 1 || man.Docs[0].Node != "專案/會議" || man.Docs[0].Path != "週會.md" {
t.Fatalf("manifest 鍵不對:%+v", man.Docs)
}
}
// 重萃同一份文件:舊卡被收走、索引不重複、卡名變了也不留孤兒。
func TestBuildWikiDoc_ReextractCleansOldCards(t *testing.T) {
root := t.TempDir()
src := "# 文件\n內文"
mustWrite(t, filepath.Join(root, "文件.md"), src)
if _, err := BuildWikiDoc(root, "文件.md", src, wsExtract(), wsNow); err != nil {
t.Fatal(err)
}
// 第二輪:概念換名
ex2 := wsExtract()
ex2.Concepts = []WikiConcept{{Name: "全新概念", Gloss: "新的一句話", Summary: "新摘要", Points: []string{"新判斷"}}}
ex2.Points = []string{"重點換成 [[全新概念]] 了"}
if _, err := BuildWikiDoc(root, "文件.md", src, ex2, wsNow.Add(24*time.Hour)); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, ".wiki", "概念甲.md")); !os.IsNotExist(err) {
t.Fatal("上一輪的舊概念卡沒被收走")
}
if _, err := os.Stat(filepath.Join(root, ".wiki", "全新概念.md")); err != nil {
t.Fatal("新概念卡沒落地")
}
// created 保留第一輪、updated 是第二輪
doc := mustRead(t, filepath.Join(root, ".wiki", "文件.md"))
if !strings.Contains(doc, "created: 2026-08-15") || !strings.Contains(doc, "updated: 2026-08-16") {
t.Fatalf("created/updated 不對:\n%.200s", doc)
}
}
// 下架:原稿消失 → 卡、索引條目、manifest 條目一起收走。
func TestRemoveWikiDoc_CleansEverything(t *testing.T) {
root := t.TempDir()
src := "# 文件\n內文"
mustWrite(t, filepath.Join(root, "文件.md"), src)
if _, err := BuildWikiDoc(root, "文件.md", src, wsExtract(), wsNow); err != nil {
t.Fatal(err)
}
if err := RemoveWikiDoc(root, "文件.md"); err != nil {
t.Fatal(err)
}
for _, name := range []string{"文件.md", "概念甲.md", "概念乙.md"} {
if _, err := os.Stat(filepath.Join(root, ".wiki", name)); !os.IsNotExist(err) {
t.Fatalf("%s 沒被收走", name)
}
}
idx := mustRead(t, filepath.Join(root, ".wiki", "00-INDEX.md"))
if strings.Contains(idx, "文件.md") {
t.Fatalf("索引還列著已下架的文件:\n%s", idx)
}
}
// 不同文件、同名概念:後到者消歧(+文件卡名),不覆蓋先到者(merge 是第⑤環的題目)。
func TestBuildWikiDoc_SameConceptFromTwoDocsDisambiguated(t *testing.T) {
root := t.TempDir()
srcA := "# 甲文\n內文"
srcB := "# 乙文\n內文"
mustWrite(t, filepath.Join(root, "a.md"), srcA)
mustWrite(t, filepath.Join(root, "b.md"), srcB)
ex := func() *DocExtract {
return &DocExtract{Gloss: "一句話", Summary: "摘要",
Points: []string{"重點連到 [[迭代]]"},
Concepts: []WikiConcept{{Name: "迭代", Gloss: "同名概念", Summary: "摘要", Points: []string{"判斷"}}}}
}
if _, err := BuildWikiDoc(root, "a.md", srcA, ex(), wsNow); err != nil {
t.Fatal(err)
}
if _, err := BuildWikiDoc(root, "b.md", srcB, ex(), wsNow); err != nil {
t.Fatal(err)
}
first := mustRead(t, filepath.Join(root, ".wiki", "迭代.md"))
if !strings.Contains(first, "← [[甲文]]") {
t.Fatalf("先到者被動過:\n%.200s", first)
}
if _, err := os.Stat(filepath.Join(root, ".wiki", "迭代(乙文).md")); err != nil {
t.Fatalf("後到者沒有消歧落地:%v", err)
}
// 乙文的重點連結要指向消歧後的名字(不留斷連結、不指錯人)
docB := mustRead(t, filepath.Join(root, ".wiki", "乙文.md"))
if strings.Contains(docB, "[[迭代]]") || !strings.Contains(docB, "[[迭代(乙文)]]") {
t.Fatalf("乙文的連結沒有跟著消歧改名:\n%s", docB)
}
}
// index 每行五樣(連結/摘要/標籤/建立日/更新日),全部照抄 frontmatterlint idx_thin 項)。
func TestNodeIndex_FiveFieldsPerDocLine(t *testing.T) {
root := t.TempDir()
src := "# 測試文件\n內文"
mustWrite(t, filepath.Join(root, "測試文件.md"), src)
if _, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsNow); err != nil {
t.Fatal(err)
}
idx := mustRead(t, filepath.Join(root, ".wiki", "00-INDEX.md"))
if !strings.Contains(idx, "- [[測試文件]] — 測試文件的一句話(原稿 `測試文件.md`)") {
t.Fatalf("index 行缺連結/摘要/原稿名:\n%s", idx)
}
if !strings.Contains(idx, "#測試 #規範 建 2026-08-15 更 2026-08-15") {
t.Fatalf("index 行缺標籤/日期:\n%s", idx)
}
}
func mustWrite(t *testing.T, p, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func mustRead(t *testing.T, p string) string {
t.Helper()
data, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
return string(data)
}