326 lines
12 KiB
Go
326 lines
12 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package helpers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// fakeWikiFetcher is an in-test wikiFetcher: no DingTalk calls, fully scripted.
|
|
type fakeWikiFetcher struct {
|
|
nodes []wikiNode
|
|
content map[string]string
|
|
listErr error
|
|
readErrFor map[string]error
|
|
}
|
|
|
|
func (f fakeWikiFetcher) listNodes(_ context.Context, _ string) ([]wikiNode, error) {
|
|
if f.listErr != nil {
|
|
return nil, f.listErr
|
|
}
|
|
return f.nodes, nil
|
|
}
|
|
|
|
func (f fakeWikiFetcher) readContent(_ context.Context, nodeID string) (string, error) {
|
|
if f.readErrFor != nil {
|
|
if err, ok := f.readErrFor[nodeID]; ok {
|
|
return "", err
|
|
}
|
|
}
|
|
return f.content[nodeID], nil
|
|
}
|
|
|
|
// ① --knowledge-source syntax parses wiki:<spaceId> / doc:<docId> correctly.
|
|
func TestParseKnowledgeSource(t *testing.T) {
|
|
cases := []struct {
|
|
raw string
|
|
wantKind knowledgeSourceKind
|
|
wantRef string
|
|
wantErr bool
|
|
}{
|
|
{"wiki:SPACE123", knowledgeSourceWiki, "SPACE123", false},
|
|
{" wiki:SPACE123 ", knowledgeSourceWiki, "SPACE123", false},
|
|
{"WIKI:Sp", knowledgeSourceWiki, "Sp", false}, // scheme is case-insensitive
|
|
{"doc:NODE9", knowledgeSourceDoc, "NODE9", false},
|
|
{"/var/knowledge", knowledgeSourceDir, "/var/knowledge", false},
|
|
{"./local", knowledgeSourceDir, "./local", false},
|
|
{"wiki:", 0, "", true},
|
|
{"doc:", 0, "", true},
|
|
{"", 0, "", true},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := parseKnowledgeSource(c.raw)
|
|
if c.wantErr {
|
|
if err == nil {
|
|
t.Errorf("parseKnowledgeSource(%q) expected error, got %+v", c.raw, got)
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
t.Errorf("parseKnowledgeSource(%q) unexpected error: %v", c.raw, err)
|
|
continue
|
|
}
|
|
if got.kind != c.wantKind || got.ref != c.wantRef {
|
|
t.Errorf("parseKnowledgeSource(%q) = {%d,%q}, want {%d,%q}", c.raw, got.kind, got.ref, c.wantKind, c.wantRef)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ③ A pulled wiki space lands in the retriever and the right chunk is retrieved.
|
|
func TestLoadWikiKnowledgeBaseRetrieves(t *testing.T) {
|
|
fetcher := fakeWikiFetcher{
|
|
nodes: []wikiNode{
|
|
{id: "n1", name: "安装指南"},
|
|
{id: "n2", name: "缓存问题"},
|
|
},
|
|
content: map[string]string{
|
|
"n1": "# 安装\n\ndws 通过 brew install dws 安装。",
|
|
"n2": "# 缓存问题\n\n服务发现已下线。出现 endpoint_not_resolved 时,检查静态端点目录并升级到包含该能力的 dws 版本。",
|
|
},
|
|
}
|
|
src := knowledgeSource{kind: knowledgeSourceWiki, ref: "SPACE123"}
|
|
kb := loadWikiKnowledgeBase(context.Background(), fetcher, src, t.TempDir(), io.Discard)
|
|
if kb == nil || len(kb.chunks) == 0 {
|
|
t.Fatalf("expected a non-empty knowledge base, got %+v", kb)
|
|
}
|
|
got := kb.augment("endpoint_not_resolved 是干什么的")
|
|
if !strings.Contains(got, "静态端点目录") {
|
|
t.Fatalf("augment missed the cache chunk pulled from wiki:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// ③ A doc:<docId> source pulls a single node into the retriever.
|
|
func TestLoadWikiKnowledgeBaseSingleDoc(t *testing.T) {
|
|
fetcher := fakeWikiFetcher{
|
|
content: map[string]string{
|
|
"NODE9": "# 机器人建联\n\n卡片停在数据加载中说明回复超过了 AI 助理应答窗口。",
|
|
},
|
|
}
|
|
src := knowledgeSource{kind: knowledgeSourceDoc, ref: "NODE9"}
|
|
kb := loadWikiKnowledgeBase(context.Background(), fetcher, src, t.TempDir(), io.Discard)
|
|
if kb == nil || len(kb.chunks) == 0 {
|
|
t.Fatalf("expected a non-empty knowledge base, got %+v", kb)
|
|
}
|
|
if got := kb.augment("数据加载中怎么办"); !strings.Contains(got, "AI 助理应答窗口") {
|
|
t.Fatalf("augment missed the single doc chunk:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// ② Pull failure with no prior cache degrades to an empty (non-nil) base and
|
|
// does not panic.
|
|
func TestLoadWikiKnowledgeBaseFailNoCacheEmpty(t *testing.T) {
|
|
fetcher := fakeWikiFetcher{listErr: errors.New("network down")}
|
|
src := knowledgeSource{kind: knowledgeSourceWiki, ref: "SPACE123"}
|
|
kb := loadWikiKnowledgeBase(context.Background(), fetcher, src, t.TempDir(), io.Discard)
|
|
if kb == nil {
|
|
t.Fatal("expected a non-nil empty base, got nil")
|
|
}
|
|
if len(kb.chunks) != 0 {
|
|
t.Fatalf("expected empty base on failure, got %d chunks", len(kb.chunks))
|
|
}
|
|
// augment on an empty base must pass the question through unchanged.
|
|
if got := kb.augment("任意问题"); got != "任意问题" {
|
|
t.Fatalf("empty base should pass through, got %q", got)
|
|
}
|
|
}
|
|
|
|
// ② Pull failure falls back to a previously written (stale) cache.
|
|
func TestLoadWikiKnowledgeBaseFailUsesStaleCache(t *testing.T) {
|
|
cacheRoot := t.TempDir()
|
|
src := knowledgeSource{kind: knowledgeSourceWiki, ref: "SPACE123"}
|
|
|
|
// First pull succeeds and populates the cache.
|
|
good := fakeWikiFetcher{
|
|
nodes: []wikiNode{{id: "n1", name: "缓存问题"}},
|
|
content: map[string]string{"n1": "# 缓存问题\n\n服务发现已下线。出现 endpoint_not_resolved 时,检查静态端点目录并升级到包含该能力的 dws 版本。"},
|
|
}
|
|
if kb := loadWikiKnowledgeBase(context.Background(), good, src, cacheRoot, io.Discard); len(kb.chunks) == 0 {
|
|
t.Fatal("warm-up pull should have populated the cache")
|
|
}
|
|
|
|
// Second pull fails; must fall back to the stale cache, not go empty.
|
|
bad := fakeWikiFetcher{listErr: errors.New("permission denied")}
|
|
kb := loadWikiKnowledgeBase(context.Background(), bad, src, cacheRoot, io.Discard)
|
|
if kb == nil || len(kb.chunks) == 0 {
|
|
t.Fatalf("expected stale-cache fallback to keep chunks, got %+v", kb)
|
|
}
|
|
if got := kb.augment("endpoint_not_resolved"); !strings.Contains(got, "静态端点目录") {
|
|
t.Fatalf("stale cache content not retrievable:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// Per-node read failures are skipped without aborting the whole pull.
|
|
func TestPullWikiDocsSkipsUnreadableNodes(t *testing.T) {
|
|
fetcher := fakeWikiFetcher{
|
|
nodes: []wikiNode{{id: "ok", name: "好文档"}, {id: "bad", name: "坏文档"}},
|
|
content: map[string]string{
|
|
"ok": "# 好文档\n\n这是可读内容。",
|
|
"bad": "",
|
|
},
|
|
readErrFor: map[string]error{"bad": errors.New("403")},
|
|
}
|
|
docs, err := pullWikiDocs(context.Background(), fetcher, knowledgeSource{kind: knowledgeSourceWiki, ref: "S"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(docs) != 1 || !strings.Contains(docs[0].text, "可读内容") {
|
|
t.Fatalf("expected only the readable doc, got %+v", docs)
|
|
}
|
|
}
|
|
|
|
// collectWikiNodes is tolerant of wrapper layers and id/name field spellings.
|
|
func TestCollectWikiNodesTolerant(t *testing.T) {
|
|
resp := map[string]any{
|
|
"content": map[string]any{
|
|
"nodes": []any{
|
|
map[string]any{"nodeId": "a", "name": "Alpha"},
|
|
map[string]any{"id": "b", "title": "Beta"},
|
|
},
|
|
},
|
|
}
|
|
nodes := collectWikiNodes(resp)
|
|
if len(nodes) != 2 {
|
|
t.Fatalf("expected 2 nodes, got %d (%+v)", len(nodes), nodes)
|
|
}
|
|
if nodes[0].id != "a" || nodes[0].name != "Alpha" || nodes[1].id != "b" || nodes[1].name != "Beta" {
|
|
t.Fatalf("node extraction wrong: %+v", nodes)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeWikiStem(t *testing.T) {
|
|
cases := map[string]string{
|
|
"SPACE123": "SPACE123",
|
|
"a/b/../c": "a_b_.._c",
|
|
"https://x/nodes/N9?q=1": "https___x_nodes_N9_q_1",
|
|
"": "doc",
|
|
"...": "doc",
|
|
}
|
|
for in, want := range cases {
|
|
if got := sanitizeWikiStem(in); got != want {
|
|
t.Errorf("sanitizeWikiStem(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunnerWikiFetcherCoverage(t *testing.T) {
|
|
runner := &onboardSeqRunner{responses: []map[string]any{
|
|
{
|
|
"result": map[string]any{"nodes": []any{
|
|
map[string]any{"nodeId": "n1", "name": "One"},
|
|
map[string]any{"nodeId": "", "name": "empty"},
|
|
}},
|
|
"nextPageToken": "next",
|
|
},
|
|
{"data": []any{
|
|
map[string]any{"nodeId": "n1", "name": "duplicate"},
|
|
map[string]any{"id": "n2", "title": "Two"},
|
|
}},
|
|
}}
|
|
fetcher := runnerWikiFetcher{runner: runner}
|
|
nodes, err := fetcher.listNodes(context.Background(), "space")
|
|
if err != nil || len(nodes) != 2 || nodes[1].id != "n2" {
|
|
t.Fatalf("listNodes() = %#v, %v", nodes, err)
|
|
}
|
|
reader := runnerWikiFetcher{runner: connectResponseRunner{response: map[string]any{"result": map[string]any{"markdown": "# body"}}}}
|
|
if text, err := reader.readContent(context.Background(), "n1"); err != nil || text != "# body" {
|
|
t.Fatalf("readContent() = %q, %v", text, err)
|
|
}
|
|
|
|
failed := runnerWikiFetcher{runner: connectResponseRunner{err: errors.New("runner failed")}}
|
|
if _, err := failed.listNodes(context.Background(), "space"); err == nil {
|
|
t.Fatal("listNodes runner error was ignored")
|
|
}
|
|
if _, err := failed.readContent(context.Background(), "node"); err == nil {
|
|
t.Fatal("readContent runner error was ignored")
|
|
}
|
|
}
|
|
|
|
func TestWikiKnowledgeUtilityCoverage(t *testing.T) {
|
|
kb := buildKnowledgeBaseFromDocs([]wikiDoc{{stem: "one", text: "# title\nbody"}, {stem: "empty", text: " "}})
|
|
if len(kb.chunks) == 0 {
|
|
t.Fatal("in-memory knowledge base is empty")
|
|
}
|
|
|
|
values := []any{
|
|
map[string]any{"ignored": map[string]any{"content": "nested"}},
|
|
[]any{map[string]any{"text": "array"}},
|
|
map[string]any{"content": " direct "},
|
|
map[string]any{"content": "", "other": "ignored"},
|
|
}
|
|
wants := []string{"nested", "array", " direct ", ""}
|
|
for i, value := range values {
|
|
if got := firstStringForKeys(value, "content", "text"); got != wants[i] {
|
|
t.Fatalf("firstStringForKeys case %d = %q, want %q", i, got, wants[i])
|
|
}
|
|
}
|
|
|
|
cache := filepath.Join(t.TempDir(), "cache")
|
|
if err := writeWikiCache(cache, []wikiDoc{
|
|
{stem: "same", text: "one"},
|
|
{stem: "same", text: "two"},
|
|
{stem: "", text: "three"},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, name := range []string{"same.md", "same-2.md", "doc.md"} {
|
|
if _, err := os.Stat(filepath.Join(cache, name)); err != nil {
|
|
t.Fatalf("missing cached file %s: %v", name, err)
|
|
}
|
|
}
|
|
if leaf := wikiCacheLeaf(knowledgeSource{kind: knowledgeSourceDoc, ref: "a/b"}); leaf != "doc-a_b" {
|
|
t.Fatalf("wikiCacheLeaf() = %q", leaf)
|
|
}
|
|
if got := sanitizeWikiStem(strings.Repeat("a", 100)); len(got) != 80 {
|
|
t.Fatalf("long stem length = %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestLoadConnectKnowledgeSourceCoverage(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "knowledge.md"), []byte("# Local\nlocal body"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd := &cobra.Command{Use: "connect"}
|
|
var stderr bytes.Buffer
|
|
cmd.SetErr(&stderr)
|
|
if kb, err := loadConnectKnowledgeSource(cmd, connectResponseRunner{}, "client", dir); err != nil || len(kb.chunks) == 0 {
|
|
t.Fatalf("local source = %#v, %v", kb, err)
|
|
}
|
|
if _, err := loadConnectKnowledgeSource(cmd, connectResponseRunner{}, "client", t.TempDir()); err == nil {
|
|
t.Fatal("empty local source succeeded")
|
|
}
|
|
if _, err := loadConnectKnowledgeSource(cmd, connectResponseRunner{}, "client", ""); err == nil {
|
|
t.Fatal("empty source succeeded")
|
|
}
|
|
|
|
t.Setenv("HOME", t.TempDir())
|
|
runner := connectResponseRunner{response: map[string]any{"markdown": "# Remote\nremote body"}}
|
|
if kb, err := loadConnectKnowledgeSource(cmd, runner, "../client", "doc:node"); err != nil || len(kb.chunks) == 0 {
|
|
t.Fatalf("remote source = %#v, %v", kb, err)
|
|
}
|
|
root := connectKnowledgeCacheRoot("../client")
|
|
if !strings.Contains(root, filepath.Join("connect", "client", "knowledge")) {
|
|
t.Fatalf("cache root = %q", root)
|
|
}
|
|
}
|