package docrevision_test import ( "context" "encoding/json" "errors" "strings" "testing" "chat-agent-system/internal/docrevision" ) func TestMaterializeAgentOutput_FirstGenerationProducesVisibleFallbackReport(t *testing.T) { svc := docrevision.NewService(nil) ctx := context.Background() docID := "doc_first_gen" out := "# 系统需求\n\n## 范围\n\n首轮小队交付正文\n" res, err := svc.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{ DocID: docID, RunID: "run-first-1", OutputMarkdown: out, ProjectID: 1, WorkDir: "ws/first", ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: "test"}, AutoCommitUserID: 42, }) if err != nil { t.Fatalf("materialize: %v", err) } if !res.FirstGeneration { t.Fatal("expected FirstGeneration") } if res.Candidate == nil || res.Report == nil { t.Fatalf("expected candidate+report, got %+v", res) } if res.Report.AnalysisStatus != docrevision.AnalysisFallback { t.Fatalf("analysis_status want fallback got %q (must not claim full PI)", res.Report.AnalysisStatus) } if len(res.Report.Summary) == 0 { t.Fatal("summary must be visible on first generation") } joined := strings.Join(res.Report.Summary, "\n") if strings.Contains(joined, "智能体交付物已生成") { t.Fatalf("must not use hardcoded fiction summary: %s", joined) } if !strings.Contains(joined, "确定性差异") && !strings.Contains(joined, "insert") && !strings.Contains(joined, "Insert") { // deterministic builder uses 确定性差异 + hunk kinds if !strings.Contains(joined, "块级变更") { t.Fatalf("summary should reflect real diff, got %q", joined) } } if len(res.Report.OpenQuestions) == 0 { t.Fatal("open_questions should mark PI narrative as pending") } if res.State == nil || res.State.CurrentVersionNo < 1 { t.Fatalf("expected committed/open state after auto-commit: %+v", res.State) } versions, err := svc.ListVersions(ctx, docID) if err != nil { t.Fatal(err) } var withReport int for _, v := range versions { if v.ChangeReportID != nil && *v.ChangeReportID > 0 { withReport++ } } if withReport < 1 { t.Fatalf("first squad materialize must leave a version with change_report_id for UI entry, got %+v", versions) } var foundV2 bool for _, v := range versions { if v.VersionNo == 2 && v.ChangeReportID != nil && *v.ChangeReportID > 0 { foundV2 = true break } } if !foundV2 { t.Fatalf("first successful materialize must produce V2 with change_report_id (not only CurrentVersionNo>=1), got %+v", versions) } if res.State.CurrentVersionNo != 2 { t.Fatalf("after first auto-commit want CurrentVersionNo=2, got %+v", res.State) } // Second run against head: still produces candidate+report (not OpenTemplate-only). out2 := out + "\n## 变更\n\n二轮补充\n" res2, err := svc.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{ DocID: docID, RunID: "run-second-1", OutputMarkdown: out2, ProjectID: 1, WorkDir: "ws/first", ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: docrevision.ActorSnapshot{SquadName: "bizdev-squad"}, AutoCommitUserID: 42, }) if err != nil { t.Fatalf("second materialize: %v", err) } if res2.FirstGeneration { t.Fatal("second run must not be FirstGeneration") } if res2.Candidate == nil || res2.Report == nil || len(res2.Report.Summary) == 0 { t.Fatalf("second run must still yield visible report: %+v", res2) } if res2.Report.AnalysisStatus != docrevision.AnalysisFallback { t.Fatalf("second analysis_status want fallback got %q", res2.Report.AnalysisStatus) } versions2, err := svc.ListVersions(ctx, docID) if err != nil { t.Fatal(err) } withReport = 0 for _, v := range versions2 { if v.ChangeReportID != nil && *v.ChangeReportID > 0 { withReport++ } } if withReport < 2 { t.Fatalf("after two squad runs expect >=2 versions with change_report_id, got %d in %+v", withReport, versions2) } } func TestRevisionState_ExposesPendingNeedsConfirmationCandidate(t *testing.T) { svc := docrevision.NewService(nil) ctx := context.Background() docID := "doc_pending_high" st, err := svc.OpenTemplateVersion(ctx, docID, "# base\n\n法规引用 GB9706\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, "") if err != nil { t.Fatal(err) } cand, rep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{ DocID: docID, RunID: "run-high-2", InputSnapshotID: st.HeadSnapshotID, OutputMarkdown: "# base\n\n", // deletes 法规 → rule floor high ReportJSON: "", ActorKind: docrevision.ActorAgentSquad, }) if err != nil && cand == nil { t.Fatal(err) } if cand.Status != docrevision.CandidateNeedsConfirmation { t.Fatalf("want needs_confirmation got %s risk=%v", cand.Status, rep.RiskLevel) } got, err := svc.GetRevisionState(ctx, docID) if err != nil { t.Fatal(err) } if got.PendingCandidateID == nil || *got.PendingCandidateID != cand.ID { t.Fatalf("revision-state must expose pending candidate id, got %+v", got) } if got.PendingChangeReportID == nil || cand.ChangeReportID == nil || *got.PendingChangeReportID != *cand.ChangeReportID { t.Fatalf("pending change_report_id mismatch: %+v cand=%+v", got, cand) } if got.PendingRiskLevel != docrevision.RiskHigh && got.PendingRiskLevel != docrevision.RiskCritical { t.Fatalf("pending risk want high/critical got %q", got.PendingRiskLevel) } if got.PendingCandidateStatus != docrevision.CandidateNeedsConfirmation { t.Fatalf("pending status want needs_confirmation got %q", got.PendingCandidateStatus) } if got.CurrentVersionNo != 1 { t.Fatalf("pending must not advance version, got V%d", got.CurrentVersionNo) } versions, _ := svc.ListVersions(ctx, docID) for _, v := range versions { if v.ChangeReportID != nil && cand.ChangeReportID != nil && *v.ChangeReportID == *cand.ChangeReportID { t.Fatal("needs_confirmation report must not appear as committed version yet") } } st2, err := svc.CommitAgentVersion(ctx, docID, cand.ID, docrevision.CommitAgentDecision{ Decision: "commit", Confirmed: true, ActorUserID: 1, ExpectedInputSHA: st.HeadSHA256, }) if err != nil { t.Fatal(err) } if st2.CurrentVersionNo != 2 { t.Fatalf("after confirm want V2 got %+v", st2) } if st2.PendingCandidateID != nil { t.Fatalf("pending must clear after commit, got %+v", st2) } } func TestMaterializeAgentOutput_EmptyOutputRejected(t *testing.T) { svc := docrevision.NewService(nil) _, err := svc.MaterializeAgentOutput(context.Background(), docrevision.AgentMaterializeRequest{ DocID: "doc_empty", OutputMarkdown: " \n", }) if err != docrevision.ErrNeedsContent { t.Fatalf("want ErrNeedsContent got %v", err) } } func TestMaterializeAgentOutput_InvalidReportDoesNotAutoCommit(t *testing.T) { svc := docrevision.NewService(nil) ctx := context.Background() docID := "doc_invalid_report" out := "# V\n\nbody after agent\n" res, err := svc.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{ DocID: docID, RunID: "run-bad-report", OutputMarkdown: out, ReportJSON: `{"schema_version":99,"summary":["x"]}`, ProjectID: 1, ActorKind: docrevision.ActorAgentSquad, AutoCommitUserID: 7, }) if err == nil { t.Fatal("expected error for invalid report") } if !errors.Is(err, docrevision.ErrReportInvalid) { t.Fatalf("want ErrReportInvalid, got %v", err) } if res == nil || res.Candidate == nil { t.Fatalf("candidate should still be returned for visibility, got %+v", res) } if res.Candidate.Status == docrevision.CandidateCommitted { t.Fatal("invalid/unassessed report must not auto-commit") } if res.Candidate.Status != docrevision.CandidateNeedsConfirmation { t.Fatalf("status want needs_confirmation got %q", res.Candidate.Status) } if res.Report != nil && res.Report.AnalysisStatus != docrevision.AnalysisUnassessed { t.Fatalf("analysis want unassessed got %q", res.Report.AnalysisStatus) } st, stErr := svc.GetRevisionState(ctx, docID) if stErr != nil { t.Fatal(stErr) } // Still on template/open baseline version — no agent version sealed in. if st.CurrentVersionNo != 1 || st.HeadRevisionNo != 0 { t.Fatalf("head must remain pre-commit baseline, got %+v", st) } } func TestMaterializeAgentOutput_FlagOnFetcherNarrativeComplete(t *testing.T) { docrevision.SetPIChangeReportNarrativeForTest(true) t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest) svc := docrevision.NewService(nil) svc.NarrativeFetcher = func(_ context.Context, spec docrevision.NarrativeFetchSpec) (string, error) { b, _ := json.Marshal(map[string]any{ "schema_version": 1, "schema_kind": docrevision.NarrativeSchemaKind, "input_digest": spec.InputDigest, "from_sha": spec.FromSHA, "to_sha": spec.ToSHA, "summary": []string{"PI 叙事:电气安全需复核"}, }) return string(b), nil } ctx := context.Background() res, err := svc.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{ DocID: "doc_narr_live", RunID: "run-narr", OutputMarkdown: "# 需求\n\n新增电气安全条款\n", ProjectID: 1, ActorKind: docrevision.ActorAgent, ActorSnapshot: docrevision.ActorSnapshot{DisplayName: "pi"}, AutoCommitUserID: 1, }) if err != nil { t.Fatalf("materialize: %v", err) } if res.Report == nil || res.Report.AnalysisStatus != docrevision.AnalysisComplete { t.Fatalf("want complete PI narrative, got %+v", res.Report) } joined := strings.Join(res.Report.Summary, "\n") if !strings.Contains(joined, "PI 叙事") { t.Fatalf("summary missing PI prose: %q", joined) } }