295 lines
12 KiB
Go
295 lines
12 KiB
Go
// 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(), wsOrigin("測試文件.md"), 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)
|
||
}
|
||
}
|
||
// 🔴 `inkstone/Arcrun#167`:出處寫的是「哪台機器/哪個庫/庫內什麼路徑」,
|
||
// **不再是 `../`**(那是卡片檔相對原檔的內部結構,使用者拿著它走不到任何地方)。
|
||
if !strings.Contains(doc, "`test@Machine \u203a test-lib \u203a 測試文件.md`") {
|
||
t.Fatalf("出處沒寫成「機器 › 庫 › 庫內路徑」:\n%s", doc)
|
||
}
|
||
if !strings.Contains(doc, "`test-lib/測試文件.md`"+triSep+"提及") {
|
||
t.Fatalf("出處三元組主詞不是庫內定位:\n%s", doc)
|
||
}
|
||
if strings.Contains(doc, "../") {
|
||
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(), wsOrigin("測試文件.md"), 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, wsOrigin("文件.md"), 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, wsOrigin("發票.txt"), 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(), wsOrigin("專案/會議/週會.md"), 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(), wsOrigin("文件.md"), 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, wsOrigin("文件.md"), 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(), wsOrigin("文件.md"), 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(), wsOrigin("a.md"), wsNow); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := BuildWikiDoc(root, "b.md", srcB, ex(), wsOrigin("b.md"), 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 每行五樣(連結/摘要/標籤/建立日/更新日),全部照抄 frontmatter(lint 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(), wsOrigin("測試文件.md"), 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)
|
||
}
|