Files
xinmi/internal/application/service/wiki_page_test.go
T

649 lines
22 KiB
Go
Raw Normal View History

package service
import (
"context"
"fmt"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/types"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestPruneEmptyFolderChainsDeletesOnlyEmptyCandidateAncestors(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&types.WikiFolder{}, &types.WikiPage{}, &types.WikiPageRevision{}))
ctx := context.Background()
repo := repository.NewWikiPageRepository(db)
svc := NewWikiPageService(repo, nil, nil, nil, nil)
now := time.Now()
createFolder := func(id, parentID, name, path string, depth int) {
require.NoError(t, repo.CreateFolder(ctx, &types.WikiFolder{
ID: id, TenantID: 1, KnowledgeBaseID: "kb-prune", ParentID: parentID,
Name: name, Path: path, Depth: depth, CreatedAt: now, UpdatedAt: now,
}))
}
createFolder("topic", "", "Topic", "Topic", 1)
createFolder("empty-leaf", "topic", "Empty", "Topic/Empty", 2)
createFolder("occupied-leaf", "topic", "Occupied", "Topic/Occupied", 2)
createFolder("empty-chain", "", "Empty chain", "Empty chain", 1)
createFolder("empty-chain-leaf", "empty-chain", "Leaf", "Empty chain/Leaf", 2)
createFolder("unrelated-empty", "", "Keep me", "Keep me", 1)
require.NoError(t, repo.Create(ctx, &types.WikiPage{
ID: "page-1", TenantID: 1, KnowledgeBaseID: "kb-prune", Slug: "entity/occupied",
Title: "Occupied", PageType: types.WikiPageTypeEntity, Status: types.WikiPageStatusPublished,
FolderID: "occupied-leaf", Version: 1, CreatedAt: now, UpdatedAt: now,
}))
deleted, err := svc.PruneEmptyFolderChains(ctx, "kb-prune", []string{"empty-leaf", "empty-chain-leaf"})
require.NoError(t, err)
require.ElementsMatch(t, []string{"empty-leaf", "empty-chain-leaf", "empty-chain"}, deleted)
_, err = repo.GetFolderByID(ctx, "kb-prune", "topic")
require.NoError(t, err, "ancestor with another occupied child must remain")
_, err = repo.GetFolderByID(ctx, "kb-prune", "occupied-leaf")
require.NoError(t, err)
_, err = repo.GetFolderByID(ctx, "kb-prune", "unrelated-empty")
require.NoError(t, err, "empty folders outside the affected chains must remain")
}
func TestStripWikiInlineChunkCitations(t *testing.T) {
input := "[**橡皮障夹**](#)**钳**\n\n夹钳是用于夹持橡皮障夹的专用器械[c003]。手柄便于操作 [c003]。多个来源[c003, c1000]。"
want := "[**橡皮障夹**](#)**钳**\n\n夹钳是用于夹持橡皮障夹的专用器械。手柄便于操作。多个来源。"
if got := stripWikiInlineChunkCitations(input); got != want {
t.Fatalf("stripWikiInlineChunkCitations() = %q, want %q", got, want)
}
}
func TestStripWikiInlineChunkCitationsPreservesOrdinaryMarkdown(t *testing.T) {
input := "保留 [citation]、[C003]、[c12] 和 [[concept/c003|页面链接]]。"
if got := stripWikiInlineChunkCitations(input); got != input {
t.Fatalf("stripWikiInlineChunkCitations() changed ordinary Markdown: %q", got)
}
}
func TestUpdateWikiPagePersistsAndClearsAliases(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&types.WikiFolder{}, &types.WikiPage{}, &types.WikiPageRevision{}))
ctx := context.Background()
repo := repository.NewWikiPageRepository(db)
svc := NewWikiPageService(repo, nil, nil, nil, nil)
page, err := svc.CreatePage(ctx, &types.WikiPage{
TenantID: 1, KnowledgeBaseID: "kb-alias", Slug: "concept/alias",
Title: "Alias", Summary: "summary", Content: "content",
PageType: types.WikiPageTypeConcept, Aliases: types.StringArray{"old"},
})
require.NoError(t, err)
page.Aliases = types.StringArray{"new", "alternate"}
updated, err := svc.UpdatePage(ctx, page)
require.NoError(t, err)
require.Equal(t, types.StringArray{"new", "alternate"}, updated.Aliases)
require.Equal(t, 2, updated.Version)
updated.Aliases = types.StringArray{}
cleared, err := svc.UpdatePage(ctx, updated)
require.NoError(t, err)
require.Empty(t, cleared.Aliases)
require.Equal(t, 3, cleared.Version)
stored, err := svc.GetPageBySlug(ctx, "kb-alias", "concept/alias")
require.NoError(t, err)
require.Empty(t, stored.Aliases)
}
func TestParseOutLinks(t *testing.T) {
svc := &wikiPageService{}
tests := []struct {
name string
content string
want []string
}{
{
name: "single link",
content: "See [[entity/acme-corp]] for details.",
want: []string{"entity/acme-corp"},
},
{
name: "multiple links",
content: "See [[entity/acme-corp]] and [[concept/rag]] for details.",
want: []string{"entity/acme-corp", "concept/rag"},
},
{
name: "duplicate links deduplicated",
content: "See [[entity/acme-corp]] and also [[entity/acme-corp]] again.",
want: []string{"entity/acme-corp"},
},
{
name: "pipe syntax: slug|display name",
content: "See [[entity/acme-corp|Acme Corp]] for details.",
want: []string{"entity/acme-corp"},
},
{
name: "mixed: pipe and bare links",
content: "See [[entity/acme-corp|Acme Corp]] and [[concept/rag]] here.",
want: []string{"entity/acme-corp", "concept/rag"},
},
{
name: "no links",
content: "Just plain text without any links.",
want: nil,
},
{
name: "empty content",
content: "",
want: nil,
},
{
name: "link with spaces normalized",
content: "See [[Entity/Acme Corp]] for details.",
want: []string{"entity/acme-corp"},
},
{
name: "nested brackets ignored",
content: "Not a link: [not [a] link]",
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := svc.parseOutLinks(tt.content)
if len(got) != len(tt.want) {
t.Errorf("parseOutLinks() = %v (len %d), want %v (len %d)", got, len(got), tt.want, len(tt.want))
return
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("parseOutLinks()[%d] = %q, want %q", i, got[i], tt.want[i])
}
}
})
}
}
func TestRepairContentLinks(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&types.WikiFolder{}, &types.WikiPage{}, &types.WikiPageRevision{}))
ctx := context.Background()
repo := repository.NewWikiPageRepository(db)
svc := NewWikiPageService(repo, nil, nil, nil, nil)
const kbID = "kb-repair"
now := time.Now()
seed := func(slug, title, pageType string) {
require.NoError(t, repo.Create(ctx, &types.WikiPage{
ID: uuid.New().String(), TenantID: 1, KnowledgeBaseID: kbID,
Slug: slug, Title: title, PageType: pageType,
Status: types.WikiPageStatusPublished, Version: 1,
CreatedAt: now, UpdatedAt: now,
}))
}
// Real summary page (clean UUID slug) + a distractor summary + an entity.
realSummary := "summary/07a20bb1-a662-47cf-9929-06fb5d5b5b5e"
seed(realSummary, "Weknora 试错记录.md - Summary", types.WikiPageTypeSummary)
seed("summary/fcadcaab-e094-4037-b71c-9edfdcdba058", "Another Doc - Summary", types.WikiPageTypeSummary)
seed("entity/mongodb", "MongoDB", types.WikiPageTypeEntity)
t.Run("mangled uuid summary link repaired via bigram", func(t *testing.T) {
// One hex digit inserted into the last group — 404s on exact lookup.
content := "See [[summary/07a20bb1-a662-47cf-9929-06fb14d5b14b14e|Weknora 试错记录.md - Summary]] for context."
got, changed, err := svc.RepairContentLinks(ctx, kbID, "synthesis/notes", content)
require.NoError(t, err)
require.True(t, changed, "expected the mangled summary link to be rewritten")
require.Contains(t, got, "[["+realSummary+"|Weknora 试错记录.md - Summary]]")
require.NotContains(t, got, "06fb14d5b14b14e")
})
t.Run("live links untouched", func(t *testing.T) {
content := "Fine: [[entity/mongodb]] and [[" + realSummary + "]]."
got, changed, err := svc.RepairContentLinks(ctx, kbID, "synthesis/notes", content)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, content, got)
})
t.Run("unresolvable dead link left as-is, never stripped", func(t *testing.T) {
// A future entity page that doesn't exist and isn't similar to anything.
content := "Pending: [[entity/some-brand-new-topic|New Topic]]."
got, changed, err := svc.RepairContentLinks(ctx, kbID, "synthesis/notes", content)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, content, got, "unresolvable links must be preserved verbatim, not stripped")
})
}
func TestNormalizeSlug(t *testing.T) {
tests := []struct {
input string
want string
}{
{"Entity/Acme Corp", "entity/acme-corp"},
{" hello ", "hello"},
{"UPPER-CASE", "upper-case"},
{"already-ok", "already-ok"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := normalizeSlug(tt.input)
if got != tt.want {
t.Errorf("normalizeSlug(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestNormalizeWikiHierarchyCleansModelCategoryNoise(t *testing.T) {
page := &types.WikiPage{
Slug: "concept/ai-knowledge",
Title: "AI时代的知识困境",
PageType: types.WikiPageTypeConcept,
CategoryPath: types.StringArray{"概念/爱护花草", " 实体/标牌 ", "摘要", "生态/平台", "多余层级"},
}
normalizeWikiHierarchy(page)
want := types.StringArray{"爱护花草", "标牌", "生态"}
if len(page.CategoryPath) != len(want) {
t.Fatalf("CategoryPath = %v, want %v", page.CategoryPath, want)
}
for i := range want {
if page.CategoryPath[i] != want[i] {
t.Fatalf("CategoryPath[%d] = %q, want %q; full path=%v", i, page.CategoryPath[i], want[i], page.CategoryPath)
}
}
if page.Depth != 3 {
t.Fatalf("Depth = %d, want 3", page.Depth)
}
if page.WikiPath != "concept/爱护花草/标牌/生态/AI时代的知识困境" {
t.Fatalf("WikiPath = %q", page.WikiPath)
}
}
func TestNormalizeWikiIndexEntryHierarchyCleansModelCategoryNoise(t *testing.T) {
entry := &types.WikiIndexEntry{
Slug: "entity/sign",
Title: "爱护花草标牌",
CategoryPath: types.StringArray{"实体/标牌", "概念", "生态/行为倡导"},
}
normalizeWikiIndexEntryHierarchy(entry, types.WikiPageTypeEntity)
want := types.StringArray{"标牌", "生态", "行为倡导"}
if len(entry.CategoryPath) != len(want) {
t.Fatalf("CategoryPath = %v, want %v", entry.CategoryPath, want)
}
for i := range want {
if entry.CategoryPath[i] != want[i] {
t.Fatalf("CategoryPath[%d] = %q, want %q; full path=%v", i, entry.CategoryPath[i], want[i], entry.CategoryPath)
}
}
if entry.WikiPath != "entity/标牌/生态/行为倡导/爱护花草标牌" {
t.Fatalf("WikiPath = %q", entry.WikiPath)
}
}
func TestContainsString(t *testing.T) {
slice := []string{"a", "b", "c"}
if !containsString(slice, "b") {
t.Error("should contain 'b'")
}
if containsString(slice, "d") {
t.Error("should not contain 'd'")
}
if containsString(nil, "a") {
t.Error("nil slice should not contain anything")
}
}
func TestRemoveString(t *testing.T) {
slice := types.StringArray{"a", "b", "c", "b"}
result := removeString(slice, "b")
if len(result) != 2 {
t.Errorf("Expected 2 items after removing 'b', got %d: %v", len(result), result)
}
if result[0] != "a" || result[1] != "c" {
t.Errorf("Unexpected result: %v", result)
}
// Remove non-existing
result2 := removeString(slice, "z")
if len(result2) != 4 {
t.Errorf("Expected 4 items (nothing removed), got %d", len(result2))
}
}
// makeGraphFixture builds a small synthetic wiki for GetGraph tests.
//
// Edges (directed):
//
// hub -> a, hub -> b, hub -> c, hub -> d
// a -> hub
// b -> hub
// c -> d
// x -> y (isolated 2-node cluster, disconnected from hub)
//
// Page types are chosen so type-filter tests can exclude specific nodes.
func makeGraphFixture() []*types.WikiPage {
return []*types.WikiPage{
{
Slug: "hub",
Title: "Hub",
PageType: types.WikiPageTypeSummary,
OutLinks: types.StringArray{"a", "b", "c", "d"},
InLinks: types.StringArray{"a", "b"},
},
{
Slug: "a",
Title: "A",
PageType: types.WikiPageTypeEntity,
OutLinks: types.StringArray{"hub"},
InLinks: types.StringArray{"hub"},
},
{
Slug: "b",
Title: "B",
PageType: types.WikiPageTypeEntity,
OutLinks: types.StringArray{"hub"},
InLinks: types.StringArray{"hub"},
},
{
Slug: "c",
Title: "C",
PageType: types.WikiPageTypeConcept,
OutLinks: types.StringArray{"d"},
InLinks: types.StringArray{"hub"},
},
{
Slug: "d",
Title: "D",
PageType: types.WikiPageTypeConcept,
OutLinks: types.StringArray{},
InLinks: types.StringArray{"hub", "c"},
},
{
Slug: "x",
Title: "X",
PageType: types.WikiPageTypeEntity,
OutLinks: types.StringArray{"y"},
InLinks: types.StringArray{},
},
{
Slug: "y",
Title: "Y",
PageType: types.WikiPageTypeEntity,
OutLinks: types.StringArray{},
InLinks: types.StringArray{"x"},
},
}
}
func nodeSlugs(data *types.WikiGraphData) map[string]bool {
out := make(map[string]bool, len(data.Nodes))
for _, n := range data.Nodes {
out[n.Slug] = true
}
return out
}
// TestComputeGraphSubset_OverviewTruncatesByLinkCount verifies that overview
// mode returns the most-connected nodes first and reports truncation
// honestly in Meta. At 4万 pages this is the path that must NOT return the
// full graph — the cap is what keeps the response size and frontend
// rendering tractable.
func TestComputeGraphSubset_OverviewTruncatesByLinkCount(t *testing.T) {
pages := makeGraphFixture()
got, err := computeGraphSubset(pages, &types.WikiGraphRequest{
Mode: types.WikiGraphModeOverview,
Limit: 3,
})
if err != nil {
t.Fatalf("computeGraphSubset: %v", err)
}
if len(got.Nodes) != 3 {
t.Errorf("want 3 nodes, got %d (%v)", len(got.Nodes), nodeSlugs(got))
}
slugs := nodeSlugs(got)
// hub has link_count 6 (4 out + 2 in), must survive the cap.
if !slugs["hub"] {
t.Errorf("expected hub to survive the top-3 cap, got %v", slugs)
}
if !got.Meta.Truncated {
t.Errorf("expected Meta.Truncated=true when returned < total")
}
if got.Meta.Total != len(pages) {
t.Errorf("Meta.Total = %d, want %d", got.Meta.Total, len(pages))
}
if got.Meta.Returned != len(got.Nodes) {
t.Errorf("Meta.Returned mismatch: %d vs %d", got.Meta.Returned, len(got.Nodes))
}
// Every returned edge must connect two surviving nodes.
for _, e := range got.Edges {
if !slugs[e.Source] || !slugs[e.Target] {
t.Errorf("edge %s->%s references a non-returned node", e.Source, e.Target)
}
}
}
func TestComputeGraphSubset_MarksFamiliarSourcePages(t *testing.T) {
pages := makeGraphFixture()
pages[0].SourceRefs = types.StringArray{"doc-1|排班手册"}
pages[1].SourceRefs = types.StringArray{"doc-2"}
got, err := computeGraphSubset(pages, &types.WikiGraphRequest{
Mode: types.WikiGraphModeOverview,
Limit: 0,
FamiliarKnowledgeIDs: []string{"doc-1"},
})
if err != nil {
t.Fatalf("computeGraphSubset: %v", err)
}
familiar := map[string]bool{}
for _, n := range got.Nodes {
if n.Familiar {
familiar[n.Slug] = true
}
}
if !familiar["hub"] {
t.Errorf("hub sources doc-1, want familiar, got %v", familiar)
}
if familiar["a"] {
t.Errorf("a sources a different document, must not light up")
}
if got.Meta.FamiliarCount != 1 {
t.Errorf("FamiliarCount = %d, want 1", got.Meta.FamiliarCount)
}
}
// TestComputeGraphSubset_OverviewUncapped ensures the Limit<=0 escape hatch
// still works for internal callers (wiki lint) that need every page.
func TestComputeGraphSubset_OverviewUncapped(t *testing.T) {
pages := makeGraphFixture()
got, err := computeGraphSubset(pages, &types.WikiGraphRequest{
Mode: types.WikiGraphModeOverview,
Limit: 0,
})
if err != nil {
t.Fatalf("computeGraphSubset: %v", err)
}
if len(got.Nodes) != len(pages) {
t.Errorf("want %d nodes (uncapped), got %d", len(pages), len(got.Nodes))
}
if got.Meta.Truncated {
t.Errorf("expected Meta.Truncated=false when nothing was dropped")
}
}
// TestComputeGraphSubset_OverviewTypeFilter ensures the type filter applies
// to the candidate set (not just post-hoc), so "total" reflects the
// filter-aware denominator the frontend shows to the user.
func TestComputeGraphSubset_OverviewTypeFilter(t *testing.T) {
pages := makeGraphFixture()
got, err := computeGraphSubset(pages, &types.WikiGraphRequest{
Mode: types.WikiGraphModeOverview,
Types: []string{types.WikiPageTypeEntity},
Limit: 100,
})
if err != nil {
t.Fatalf("computeGraphSubset: %v", err)
}
slugs := nodeSlugs(got)
// Only entity-typed pages: a, b, x, y.
for _, expected := range []string{"a", "b", "x", "y"} {
if !slugs[expected] {
t.Errorf("expected entity %q to be returned, got %v", expected, slugs)
}
}
for _, forbidden := range []string{"hub", "c", "d"} {
if slugs[forbidden] {
t.Errorf("%q should have been filtered out by type, got %v", forbidden, slugs)
}
}
if got.Meta.Total != 4 {
t.Errorf("Meta.Total should equal entity-typed page count (4), got %d", got.Meta.Total)
}
}
// TestComputeGraphSubset_EgoDepth1 checks that depth=1 returns center +
// immediate neighbors only, with no transitive hops.
func TestComputeGraphSubset_EgoDepth1(t *testing.T) {
pages := makeGraphFixture()
got, err := computeGraphSubset(pages, &types.WikiGraphRequest{
Mode: types.WikiGraphModeEgo,
Center: "hub",
Depth: 1,
Limit: 100,
})
if err != nil {
t.Fatalf("computeGraphSubset: %v", err)
}
slugs := nodeSlugs(got)
// hub's direct neighbors: a, b, c, d (out) ∪ a, b (in) = {a, b, c, d} + hub.
want := []string{"hub", "a", "b", "c", "d"}
for _, s := range want {
if !slugs[s] {
t.Errorf("expected %q in ego depth=1 of hub, got %v", s, slugs)
}
}
// x and y are in a disconnected cluster and must not leak in.
if slugs["x"] || slugs["y"] {
t.Errorf("disconnected nodes leaked into ego graph: %v", slugs)
}
if got.Meta.Mode != types.WikiGraphModeEgo || got.Meta.Center != "hub" || got.Meta.Depth != 1 {
t.Errorf("Meta not populated correctly for ego: %+v", got.Meta)
}
}
// TestComputeGraphSubset_EgoDepth2 checks that an extra hop reaches pages
// only accessible through a one-hop neighbor (c -> d becomes reachable from
// "a" at depth 2 because a -> hub -> c -> d ... wait, that's 3 hops. Use
// a clearer case: from "a", depth=2 should reach hub's neighbors.)
func TestComputeGraphSubset_EgoDepth2ExpandsFrontier(t *testing.T) {
pages := makeGraphFixture()
depth1, err := computeGraphSubset(pages, &types.WikiGraphRequest{
Mode: types.WikiGraphModeEgo,
Center: "a",
Depth: 1,
Limit: 100,
})
if err != nil {
t.Fatalf("depth=1: %v", err)
}
depth2, err := computeGraphSubset(pages, &types.WikiGraphRequest{
Mode: types.WikiGraphModeEgo,
Center: "a",
Depth: 2,
Limit: 100,
})
if err != nil {
t.Fatalf("depth=2: %v", err)
}
if len(depth2.Nodes) <= len(depth1.Nodes) {
t.Errorf("depth=2 should expand the frontier beyond depth=1 (%d <= %d)",
len(depth2.Nodes), len(depth1.Nodes))
}
// At depth 2 from "a": a -> hub -> {b, c, d}. So b, c, d must appear.
slugs := nodeSlugs(depth2)
for _, s := range []string{"a", "hub", "b", "c", "d"} {
if !slugs[s] {
t.Errorf("expected %q at depth=2 from a, got %v", s, slugs)
}
}
}
// TestComputeGraphSubset_EgoRejectsMissingCenter ensures we fail fast
// rather than returning an empty graph when the caller points at a
// non-existent slug — an empty result here would look identical to "your
// wiki has no links to that page" and hide the real bug.
func TestComputeGraphSubset_EgoRejectsMissingCenter(t *testing.T) {
pages := makeGraphFixture()
_, err := computeGraphSubset(pages, &types.WikiGraphRequest{
Mode: types.WikiGraphModeEgo,
Center: "does-not-exist",
Depth: 1,
Limit: 100,
})
if err == nil {
t.Fatalf("expected error for missing center slug")
}
}
func TestFindPagesByNormalizedTitleMatchesWhitespace(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&types.WikiFolder{}, &types.WikiPage{}, &types.WikiPageRevision{}))
ctx := context.Background()
repo := repository.NewWikiPageRepository(db)
svc := NewWikiPageService(repo, nil, nil, nil, nil)
now := time.Now()
require.NoError(t, repo.Create(ctx, &types.WikiPage{
ID: "page-kong", TenantID: 1, KnowledgeBaseID: "kb-id", Slug: "entity/confucius",
Title: "孔 子", PageType: types.WikiPageTypeEntity, Status: types.WikiPageStatusPublished,
Version: 1, CreatedAt: now, UpdatedAt: now,
}))
require.NoError(t, repo.Create(ctx, &types.WikiPage{
ID: "page-fable", TenantID: 1, KnowledgeBaseID: "kb-id", Slug: "concept/yuyan",
Title: "《寓言》", PageType: types.WikiPageTypeConcept, Status: types.WikiPageStatusPublished,
Version: 1, CreatedAt: now, UpdatedAt: now,
}))
pages, err := svc.FindPagesByNormalizedTitle(ctx, "kb-id", types.WikiPageTypeEntity, "孔子")
require.NoError(t, err)
require.Len(t, pages, 1)
require.Equal(t, "entity/confucius", pages[0].Slug)
none, err := svc.FindPagesByNormalizedTitle(ctx, "kb-id", types.WikiPageTypeConcept, "寓言")
require.NoError(t, err)
require.Empty(t, none, "punctuation-significant titles must stay distinct")
require.NoError(t, repo.Create(ctx, &types.WikiPage{
ID: "page-mencius", TenantID: 1, KnowledgeBaseID: "kb-id", Slug: "entity/mencius",
Title: "孟子", PageType: types.WikiPageTypeEntity, Status: types.WikiPageStatusPublished,
Version: 1, CreatedAt: now, UpdatedAt: now,
}))
batched, err := svc.FindPagesByNormalizedTitles(ctx, "kb-id", types.WikiPageTypeEntity, []string{"孔子", "孟子", "孔子"})
require.NoError(t, err)
require.Len(t, batched, 2)
slugs := []string{batched[0].Slug, batched[1].Slug}
require.ElementsMatch(t, []string{"entity/confucius", "entity/mencius"}, slugs)
}