package librarydiff import ( "encoding/json" "testing" ) // 两篇文档共同贡献一页:撤销一篇后,页面保留、只掉撤销那篇的来源与引用, // 并且能说清是哪几类语义变了——这正是"页面级差异"做不到的部分。 func TestCompareWikiReportsContributionRemoval(t *testing.T) { base := []map[string]any{ row(t, "entity/gate", "gate", []string{"doc-a", "doc-b"}, []string{"chunk-a", "chunk-b"}, "闸门:灰度 30 分钟 ZEBRA-A-7731;阈值 5% ZEBRA-B-4492", []string{"entity/only-a"}, []string{"entity/gate"}), row(t, "entity/only-a", "only-a", []string{"doc-a"}, []string{"chunk-a"}, "只属于 A", nil, nil), } after := []map[string]any{ row(t, "entity/gate", "gate", []string{"doc-b"}, []string{"chunk-b"}, "闸门:阈值 5% ZEBRA-B-4492", []string{"entity/gate"}, []string{"entity/gate"}), } result, err := CompareWiki(base, after) if err != nil { t.Fatalf("compare: %v", err) } if result.Updated != 1 || result.Removed != 1 || result.Added != 0 { t.Fatalf("counts: %+v", result) } var kept WikiContribution for _, page := range result.Pages { if page.Slug == "entity/gate" { kept = page } } if kept.Status != "updated" { t.Fatalf("kept page status = %q", kept.Status) } if len(kept.SourcesRemoved) != 1 || kept.SourcesRemoved[0] != "doc-a" { t.Fatalf("sources_removed = %v", kept.SourcesRemoved) } if len(kept.SourcesAdded) != 0 { t.Fatalf("sources_added = %v", kept.SourcesAdded) } want := map[string]bool{"content": true, "sources": true, "citations": true, "out_links": true} got := map[string]bool{} for _, name := range kept.Changed { got[name] = true } for name := range want { if !got[name] { t.Fatalf("changed missing %q: %v", name, kept.Changed) } } if kept.CitationsAfter != 1 || kept.CitationsBefore != 2 { t.Fatalf("citations %d -> %d", kept.CitationsBefore, kept.CitationsAfter) } if len(kept.LinksRemoved) != 1 || kept.LinksRemoved[0] != "entity/only-a" { t.Fatalf("links_removed = %v", kept.LinksRemoved) } // 合计口径:共享页掉 1 个来源 + 整页撤销的 only-a 带走 1 个来源 = 2。 if result.SourcesRemoved != 2 { t.Fatalf("result sources_removed = %d", result.SourcesRemoved) } } func TestCompareWikiIgnoresVolatileFieldsAndUnchangedPages(t *testing.T) { base := []map[string]any{row(t, "concept/x", "x", []string{"doc-a"}, []string{"c1"}, "正文", nil, nil)} after := []map[string]any{row(t, "concept/x", "x", []string{"doc-a"}, []string{"c1"}, "正文", nil, nil)} after[0]["updated_at"] = "2026-10-29T00:00:00Z" after[0]["updated_by"] = "someone" result, err := CompareWiki(base, after) if err != nil { t.Fatalf("compare: %v", err) } if result.Updated+result.Added+result.Removed != 0 || len(result.Pages) != 0 { t.Fatalf("noise produced changes: %+v", result) } if result.State != "computed" || result.BaseCount != 1 || result.TargetCount != 1 { t.Fatalf("result header: %+v", result) } } func TestCompareWikiRejectsRowsWithoutSlug(t *testing.T) { if _, err := CompareWiki([]map[string]any{{"title": "no slug"}}, nil); err == nil { t.Fatal("expected error for row without slug") } } func row(t *testing.T, slug, title string, sources, chunks []string, content string, outLinks, inLinks []string) map[string]any { t.Helper() return map[string]any{ "slug": slug, "title": title, "page_type": "entity", "status": "published", "content": content, "summary": "", "source_refs": sources, "chunk_refs": chunks, "out_links": outLinks, "in_links": inLinks, "category_path": []string{"根"}, "folder_id": "", } } func TestCompareWikiAcceptsJSONEncodedArrays(t *testing.T) { // 归档行来自 to_jsonb,数组是 JSON 数组;但历史归档里也出现过字符串化的数组, // 两种都要能读,读不出就报错,不能静默当成空集合。 base := []map[string]any{{"slug": "entity/y", "source_refs": `["doc-a","doc-b"]`, "chunk_refs": `["c1"]`, "content": "a", "out_links": `["entity/y"]`}} after := []map[string]any{{"slug": "entity/y", "source_refs": `["doc-b"]`, "chunk_refs": `["c1"]`, "content": "a", "out_links": `["entity/y"]`}} result, err := CompareWiki(base, after) if err != nil { t.Fatalf("compare: %v", err) } if len(result.Pages) != 1 || len(result.Pages[0].SourcesRemoved) != 1 { raw, _ := json.Marshal(result) t.Fatalf("string arrays not parsed: %s", raw) } }