聊天入口只把项目 ID 放在授权字段,协调器仍读空的 UserCtx.Project.ID,受控任务因此以 project_id=0 跳过账本投影,回复却称正文已保存。
198 lines
6.8 KiB
Go
198 lines
6.8 KiB
Go
package bizdev
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/config"
|
|
"chat-agent-system/internal/docgov"
|
|
"chat-agent-system/internal/docrevision"
|
|
"chat-agent-system/internal/squad"
|
|
)
|
|
|
|
func TestAutoRepairForControlledSquad(t *testing.T) {
|
|
if autoRepairForControlledSquad(false, "", nil) {
|
|
t.Fatal("uncontrolled must respect AutoRepair=false")
|
|
}
|
|
if !autoRepairForControlledSquad(true, "", nil) {
|
|
t.Fatal("uncontrolled AutoRepair=true must stay true")
|
|
}
|
|
if !autoRepairForControlledSquad(false, "doc_proj1_intake", &docgov.DocumentRecord{DocID: "doc_proj1_intake"}) {
|
|
t.Fatal("resolved controlled doc must force AutoRepair")
|
|
}
|
|
if autoRepairForControlledSquad(false, "doc_proj1_intake", nil) {
|
|
t.Fatal("docID without docRec must not force AutoRepair")
|
|
}
|
|
}
|
|
|
|
func TestQualityBlocksControlledProjection(t *testing.T) {
|
|
if qualityBlocksControlledProjection(nil) {
|
|
t.Fatal("nil report must not block")
|
|
}
|
|
if qualityBlocksControlledProjection(&squad.QualityReport{Passed: true}) {
|
|
t.Fatal("passed report must not block")
|
|
}
|
|
if !qualityBlocksControlledProjection(&squad.QualityReport{Passed: false, Score: 40}) {
|
|
t.Fatal("failed report must block")
|
|
}
|
|
}
|
|
|
|
// 敌意输入:Success=true 但 QualityReport.Passed=false —— 仍不得投影到正式受控事实源。
|
|
func TestFinalizeSquadDeliverable_QualityFailed_NoDocProjectionSideEffects(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
svc := MustNewService(t, &config.Config{DataDir: dataDir, AgentBackend: "stub"})
|
|
ctx := context.Background()
|
|
projectID := int64(6098)
|
|
docID := "doc_proj6098_intake"
|
|
|
|
officialWD := filepath.Join(dataDir, "official-head")
|
|
if err := os.MkdirAll(officialWD, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
base := "# 正式受控 head\n\n本正文为账本事实源,质量失败后不得被未过门禁稿覆盖。\n"
|
|
if _, err := svc.docRev.OpenTemplateVersion(ctx, docID, base, docrevision.ActorSnapshot{DisplayName: "baseline"}, projectID, officialWD); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
docRec, err := svc.docGov.CreateOrGetDocument(ctx, docID, projectID, "intake", "用户初始需求收集表", "V0.5", officialWD, 1, "a", 2, "r", 3, "p")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stBefore, err := svc.docRev.GetRevisionState(ctx, docID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
versBefore, err := svc.docRev.ListVersions(ctx, docID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
beforeSHA := docRec.ContentSHA256
|
|
beforeWD := docRec.WorkDir
|
|
beforeHead := stBefore.HeadSHA256
|
|
beforeRev := stBefore.HeadRevisionNo
|
|
beforeVer := stBefore.CurrentVersionNo
|
|
if stBefore.PendingCandidateID != nil {
|
|
t.Fatalf("baseline must not already have pending candidate: %v", *stBefore.PendingCandidateID)
|
|
}
|
|
isolatedWD := filepath.Join(dataDir, "isolated-quality-fail")
|
|
if err := os.MkdirAll(isolatedWD, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
failBody := "# 未过门禁草稿\n\n此处篇幅有限,不做展开。\n"
|
|
|
|
out, err := svc.finalizeSquadDeliverable(ctx, TaskRunRequest{
|
|
TaskKey: "intake", ProjectID: projectID, ProjectName: "P",
|
|
CustomerName: "C", DeviceType: "医疗器械", UserID: 1, DocID: docID,
|
|
}, &squad.SquadRunResult{
|
|
TaskKey: "intake",
|
|
ArtifactTitle: "用户初始需求收集表",
|
|
MarkdownBody: failBody,
|
|
WorkDir: isolatedWD,
|
|
BackendUsed: "stub",
|
|
Duration: time.Second,
|
|
// 敌意:Success 误为 true,旧路径若只看 Success 会 Materialize
|
|
Success: true,
|
|
QualityReport: &squad.QualityReport{
|
|
Passed: false,
|
|
Score: 40,
|
|
ProhibitedPhrases: []string{"不做展开", "篇幅有限"},
|
|
Violations: []string{"检测到正文中的省略/敷衍写法(须实质展开;自检勿复述禁用原词)"},
|
|
},
|
|
}, docID, docRec, "", nil)
|
|
if err != nil {
|
|
t.Fatalf("quality fail must return nil error, got %v", err)
|
|
}
|
|
if out == nil || out.Success {
|
|
t.Fatalf("want Success=false, got %+v", out)
|
|
}
|
|
if out.TerminalStatus() != TaskStatusQualityFailed {
|
|
t.Fatalf("status=%q want quality_failed", out.TerminalStatus())
|
|
}
|
|
if out.WorkDir != isolatedWD {
|
|
t.Fatalf("must keep isolated workspace, got %q", out.WorkDir)
|
|
}
|
|
if !strings.Contains(out.ErrorReason, "质量门禁未通过") {
|
|
t.Fatalf("ErrorReason=%q", out.ErrorReason)
|
|
}
|
|
if out.MarkdownBody != failBody {
|
|
t.Fatal("failure summary body must remain visible")
|
|
}
|
|
|
|
afterDoc, err := svc.docGov.GetDocument(ctx, docID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if afterDoc.WorkDir != beforeWD {
|
|
t.Fatalf("formal work_dir mutated: before=%q after=%q", beforeWD, afterDoc.WorkDir)
|
|
}
|
|
if afterDoc.ContentSHA256 != beforeSHA {
|
|
t.Fatalf("formal content hash mutated: before=%q after=%q", beforeSHA, afterDoc.ContentSHA256)
|
|
}
|
|
stAfter, err := svc.docRev.GetRevisionState(ctx, docID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stAfter.HeadSHA256 != beforeHead || stAfter.HeadRevisionNo != beforeRev || stAfter.CurrentVersionNo != beforeVer {
|
|
t.Fatalf("ledger head mutated: before=%s rev=%d ver=%d after=%s rev=%d ver=%d",
|
|
beforeHead, beforeRev, beforeVer, stAfter.HeadSHA256, stAfter.HeadRevisionNo, stAfter.CurrentVersionNo)
|
|
}
|
|
if stAfter.PendingCandidateID != nil {
|
|
t.Fatalf("quality_failed must not create pending candidate: %v", *stAfter.PendingCandidateID)
|
|
}
|
|
versAfter, err := svc.docRev.ListVersions(ctx, docID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(versAfter) != len(versBefore) {
|
|
t.Fatalf("version count changed: before=%d after=%d", len(versBefore), len(versAfter))
|
|
}
|
|
for _, title := range activityTitles(t, svc, projectID) {
|
|
if strings.Contains(title, "完成交付物") || strings.Contains(title, "待编制人确认") {
|
|
t.Fatalf("quality_failed must not record delivery activity: %q", title)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecuteTask_Uncontrolled_DoesNotForceAutoRepair(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
svc := MustNewService(t, &config.Config{DataDir: dataDir, AgentBackend: "stub"})
|
|
res, err := svc.ExecuteTask(context.Background(), TaskRunRequest{
|
|
TaskKey: "adhoc_notes",
|
|
ProjectID: 0,
|
|
DocID: "",
|
|
AutoRepair: false,
|
|
ProjectName: "CLI非受控",
|
|
CustomerName: "C",
|
|
DeviceType: "医疗器械",
|
|
Backend: "stub",
|
|
Timeout: 10 * time.Second,
|
|
})
|
|
if err != nil || res == nil || !res.Success {
|
|
t.Fatalf("uncontrolled ExecuteTask should succeed, err=%v res=%+v", err, res)
|
|
}
|
|
if res.DocRecord != nil {
|
|
t.Fatal("uncontrolled path must not create DocRecord")
|
|
}
|
|
raw, readErr := os.ReadFile(filepath.Join(res.WorkDir, "workflow.json"))
|
|
if readErr != nil {
|
|
t.Fatalf("read workflow.json: %v", readErr)
|
|
}
|
|
var wf struct {
|
|
Steps []struct {
|
|
ID string `json:"id"`
|
|
} `json:"steps"`
|
|
}
|
|
if err := json.Unmarshal(raw, &wf); err != nil {
|
|
t.Fatalf("workflow.json: %v", err)
|
|
}
|
|
for _, s := range wf.Steps {
|
|
if strings.HasPrefix(s.ID, "step_repair_") {
|
|
t.Fatalf("AutoRepair=false uncontrolled must not append repair step %q", s.ID)
|
|
}
|
|
}
|
|
}
|