1f482e4b49
build / build (push) Successful in 1m18s
为什么:删除一篇与别人共同贡献同一 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 项,无未运行项)。真实撤销语义验收见交付报告。
115 lines
4.3 KiB
Go
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)
|
|
}
|
|
}
|