Files
knowledge-governance/internal/librarydiff/wiki_test.go
姚章浩 1f482e4b49
build / build (push) Successful in 1m18s
feat(library): Wiki 撤销按贡献比对,维护窗口在页面如实呈现
为什么:删除一篇与别人共同贡献同一 Wiki 页的文档后,页面还在、名字也在,
只有那篇文档的正文与引用被摘掉。原来的页面级差异会把这种语义重算显示成
"无变化",操作员据此会误判撤销没有生效。

怎么改:librarydiff 新增 CompareWiki,以 slug 为身份把正文/来源文档/引用分块/
出入边/目录分别取集合比较,输出 sources_removed、citations 变化与具体类别;
overview 的 diff.wiki 增加逐页明细(最多 50 页,超出置 pages_truncated),
说明文字明确"重算发生在源侧,本平台只如实比对与保留结果"。

页面:新增目标维护面板(实例级/库级、持有、入口开关、阶段、进度、恢复错误),
只有窗口仍持有且作业失败时才给"按预备份恢复";没有维护记录时写未接入 + fail closed。
变更页把 Wiki 逐页差异列出来源/引用怎么变。kind 补上 restore。

验过:go test ./internal/librarydiff ./internal/httpapi;web vitest 453 项;
make verify 全绿(15 项,无未运行项)。真实撤销语义验收见交付报告。
2026-10-29 04:45:34 +08:00

115 lines
4.3 KiB
Go

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)
}
}