Files
Agent-System/internal/server/chat_document_reader_test.go
T
WeiCHEN 1f37f90b4b feat(chat): show MinerU parse progress while waiting in-turn
When summarizing the latest upload, poll parse state up to MinerU timeout,
emit document_parsing phase events so the bubble stays live and input stays
locked, then summarize or return a clear slow/failed reply.
2026-09-23 09:55:59 +08:00

263 lines
8.7 KiB
Go

package server
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/storage"
"chat-agent-system/internal/stream"
"chat-agent-system/internal/workflowscope"
)
func TestSelectReadableWorkflowDocumentMatchesInitialRequirementsAlias(t *testing.T) {
docs := []docgov.WorkflowDocumentSummary{
{DocID: "doc_proj143_intake", TaskKey: "intake", Title: "用户初始需求收集表"},
{DocID: "doc_proj143_research", TaskKey: "research", Title: "技术与市场调研报告"},
}
doc, err := selectReadableWorkflowDocument(docs, "用户初始需求表", "", nil)
if err != nil {
t.Fatalf("select: %v", err)
}
if doc.DocID != "doc_proj143_intake" {
t.Fatalf("doc=%s", doc.DocID)
}
}
func TestSelectReadableWorkflowDocumentDoesNotGuessWhenTargetMissing(t *testing.T) {
docs := []docgov.WorkflowDocumentSummary{
{DocID: "doc_a", TaskKey: "intake", Title: "用户初始需求收集表"},
{DocID: "doc_b", TaskKey: "research", Title: "调研报告"},
}
if _, err := selectReadableWorkflowDocument(docs, "", "", nil); err == nil {
t.Fatal("expected target-required error")
}
}
func TestSelectReadableWorkflowDocumentScopesToActiveWorkflow(t *testing.T) {
docs := []docgov.WorkflowDocumentSummary{
{DocID: "doc_proj1_research", TaskKey: "bd_research", Title: "调研报告"},
{DocID: "doc_proj1_proposal", TaskKey: "rd_proposal", Title: "项目建议书"},
}
scope := map[string]bool{"bd_research": true, "research": true}
doc, err := selectReadableWorkflowDocument(docs, "调研报告", "main", scope)
if err != nil {
t.Fatalf("in-scope select: %v", err)
}
if doc.TaskKey != "bd_research" {
t.Fatalf("want bd_research, got %s", doc.TaskKey)
}
_, err = selectReadableWorkflowDocument(docs, "项目建议书", "main", scope)
if err == nil {
t.Fatal("expected wrong-workflow error")
}
var wrong *coordinator.ErrWrongWorkflow
if !errors.As(err, &wrong) {
t.Fatalf("want ErrWrongWorkflow, got %T %v", err, err)
}
if !strings.Contains(wrong.Message, "切换") {
t.Fatalf("guidance should mention 切换, got %q", wrong.Message)
}
}
func TestSelectReadableWorkflowDocumentNoScopeKeepsLegacy(t *testing.T) {
docs := []docgov.WorkflowDocumentSummary{
{DocID: "doc_proj1_proposal", TaskKey: "rd_proposal", Title: "项目建议书"},
}
doc, err := selectReadableWorkflowDocument(docs, "项目建议书", "", nil)
if err != nil {
t.Fatalf("legacy passthrough: %v", err)
}
if doc.TaskKey != "rd_proposal" {
t.Fatalf("want rd_proposal, got %s", doc.TaskKey)
}
}
func TestSelectReadableWeightedAllowsExactCrossRead(t *testing.T) {
workflowscope.SetModeForTest(workflowscope.ModeWeighted)
t.Cleanup(workflowscope.ResetModeForTest)
docs := []docgov.WorkflowDocumentSummary{
{DocID: "doc_proj1_research", TaskKey: "bd_research", Title: "调研报告"},
{DocID: "doc_proj1_proposal", TaskKey: "rd_proposal", Title: "项目建议书"},
}
scope := map[string]bool{"bd_research": true}
doc, err := selectReadableWorkflowDocument(docs, "项目建议书", "main", scope)
if err != nil {
t.Fatalf("weighted cross read: %v", err)
}
if doc.TaskKey != "rd_proposal" {
t.Fatalf("want rd_proposal, got %s", doc.TaskKey)
}
inScope, err := selectReadableWorkflowDocument(docs, "调研报告", "main", scope)
if err != nil || inScope.TaskKey != "bd_research" {
t.Fatalf("in-scope still wins: %v %v", inScope, err)
}
}
func TestCrossWorkflowTaskVisible(t *testing.T) {
open := &projectinstance.StageAccess{
Restricted: true,
ViewableTaskKeys: map[string]bool{"bd_research": true},
}
scope := map[string]bool{"bd_research": true, "bd_hidden": true}
if !crossWorkflowTaskVisible(open, scope, "rd_proposal") {
t.Fatal("foreign key should be readable when the caller can view the active instance")
}
if crossWorkflowTaskVisible(open, scope, "bd_hidden") {
t.Fatal("in-scope but not viewable must be denied")
}
closed := &projectinstance.StageAccess{Restricted: true, ViewableTaskKeys: map[string]bool{}}
if crossWorkflowTaskVisible(closed, scope, "rd_proposal") {
t.Fatal("closed instance must deny cross read")
}
if !crossWorkflowTaskVisible(nil, scope, "rd_proposal") {
t.Fatal("unrestricted access must allow")
}
}
func TestPickLatestFileReference(t *testing.T) {
older := &storage.FileReference{ID: 1, FileName: "a.txt", CreatedAt: time.Unix(100, 0)}
newer := &storage.FileReference{ID: 2, FileName: "b.txt", CreatedAt: time.Unix(200, 0)}
got := pickLatestFileReference([]*storage.FileReference{older, newer, nil})
if got == nil || got.ID != 2 {
t.Fatalf("want id=2, got %+v", got)
}
sameTimeHigherID := &storage.FileReference{ID: 9, FileName: "c.txt", CreatedAt: time.Unix(200, 0)}
got = pickLatestFileReference([]*storage.FileReference{newer, sameTimeHigherID})
if got == nil || got.ID != 9 {
t.Fatalf("same created_at should prefer higher id, got %+v", got)
}
}
func TestResolveLatestUploadDocument_TextAsset(t *testing.T) {
cfg := &config.Config{Env: "test", DataDir: t.TempDir()}
svc := bizdev.MustNewService(t, cfg)
srv := NewServer(cfg, svc)
if srv.cas == nil {
t.Fatal("expected CAS")
}
projectID := int64(42)
userID := int64(7)
_, err := srv.cas.StoreFile(context.Background(), projectID, userID, "shockwave.txt", "material",
[]byte("冲击波治疗仪核心适应症:软组织损伤与疼痛管理。\n关键约束:治疗强度需医师确认。"), "text/plain")
if err != nil {
t.Fatalf("store: %v", err)
}
doc, err := srv.resolveLatestUploadDocument(context.Background(), projectID)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if doc == nil || !strings.Contains(doc.Markdown, "冲击波治疗仪") {
t.Fatalf("unexpected doc: %+v", doc)
}
if doc.Title != "shockwave" {
t.Fatalf("title=%q", doc.Title)
}
if doc.LifecycleState != "uploaded" {
t.Fatalf("lifecycle=%q", doc.LifecycleState)
}
}
func TestResolveLatestUploadDocument_NoAsset(t *testing.T) {
cfg := &config.Config{Env: "test", DataDir: t.TempDir()}
svc := bizdev.MustNewService(t, cfg)
srv := NewServer(cfg, svc)
_, err := srv.resolveLatestUploadDocument(context.Background(), 99)
if err == nil {
t.Fatal("expected ErrNoUploadAsset")
}
var noAsset *coordinator.ErrNoUploadAsset
if !errors.As(err, &noAsset) {
t.Fatalf("want ErrNoUploadAsset, got %T %v", err, err)
}
}
func TestWaitForUploadParse_PendingThenDone(t *testing.T) {
prev := uploadParsePollInterval
uploadParsePollInterval = 15 * time.Millisecond
t.Cleanup(func() { uploadParsePollInterval = prev })
var calls atomic.Int32
stateFn := func() string {
n := calls.Add(1)
if n >= 3 {
return "done"
}
return "pending"
}
var phases []string
ctx, st := stream.WithListener(context.Background(), false, func(ev stream.StreamEvent) {
if ev.Type == stream.EventPhase && ev.Phase != nil {
phases = append(phases, ev.Phase.PhaseID+":"+ev.Phase.Message)
}
})
_ = st
got := waitForUploadParse(ctx, stateFn, "冲击波说明书.pdf", 2*time.Second)
if got != "done" {
t.Fatalf("got %q want done", got)
}
if calls.Load() < 3 {
t.Fatalf("expected multiple polls, calls=%d", calls.Load())
}
if len(phases) == 0 {
t.Fatal("expected document_parsing phase events")
}
if !strings.Contains(phases[0], "document_parsing:") {
t.Fatalf("first phase=%q", phases[0])
}
if !strings.Contains(phases[0], "MinerU") && !strings.Contains(phases[0], "解析") {
t.Fatalf("initial phase should mention parse wait: %q", phases[0])
}
}
func TestWaitForUploadParse_TimeoutSlow(t *testing.T) {
prev := uploadParsePollInterval
uploadParsePollInterval = 10 * time.Millisecond
t.Cleanup(func() { uploadParsePollInterval = prev })
got := waitForUploadParse(context.Background(), func() string { return "pending" }, "spec.pdf", 40*time.Millisecond)
if got != "slow" {
t.Fatalf("got %q want slow", got)
}
}
func TestWaitForUploadParse_ContextCancel(t *testing.T) {
prev := uploadParsePollInterval
uploadParsePollInterval = 50 * time.Millisecond
t.Cleanup(func() { uploadParsePollInterval = prev })
ctx, cancel := context.WithCancel(context.Background())
cancel()
got := waitForUploadParse(ctx, func() string { return "running" }, "spec.pdf", time.Second)
if got != "running" && got != "" {
// cancelled before/at select: returns last observed state
t.Fatalf("got %q on cancel", got)
}
}
func TestWaitForUploadParse_Failed(t *testing.T) {
prev := uploadParsePollInterval
uploadParsePollInterval = 10 * time.Millisecond
t.Cleanup(func() { uploadParsePollInterval = prev })
got := waitForUploadParse(context.Background(), func() string { return "failed" }, "spec.pdf", time.Second)
if got != "failed" {
t.Fatalf("got %q want failed", got)
}
}