Files
2026-06-22 08:55:57 +08:00

283 lines
9.4 KiB
Go

package brief
import (
"context"
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/project"
)
func newReq(phase project.Phase) *project.Requirement {
return &project.Requirement{
ID: uuid.New(),
Number: 7,
Title: "导出报表",
Description: "支持导出 Excel",
Phase: phase,
}
}
func newIssue() *project.Issue {
return &project.Issue{
ID: uuid.New(),
Number: 12,
Title: "修复导出乱码",
Description: "UTF-8 BOM 缺失",
}
}
func art(phase project.Phase, version int, content string) *project.Artifact {
return &project.Artifact{
ID: uuid.New(),
RequirementID: uuid.New(),
Phase: phase,
Version: version,
Content: content,
}
}
func TestComposeTaskBrief_FullBriefAllSectionsInPriorityOrder(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhaseAuditing),
Artifacts: []*project.Artifact{
art(project.PhasePlanning, 1, "# 规划\n## 验收标准\n- 必须支持 Excel"),
art(project.PhasePrototyping, 1, "# 原型内容"),
art(project.PhaseAuditing, 1, "# 评审内容"),
},
UserPrompt: "请开始实现",
Kind: KindACPSession,
})
require.NoError(t, err)
assert.False(t, res.Truncated)
// 角色(auditing 阶段用阶段角色)+ 交互协议 + 需求核心 + 验收标准 + 三个产物 + 用户指令。
wantOrder := []string{
"role", "interactive_protocol", "requirement_core", "acceptance_criteria",
"artifact_auditing", "artifact_prototyping", "artifact_planning", "user_prompt",
}
assert.Equal(t, wantOrder, res.Sections)
// 验收标准必须出现在 auditing 产物之前(优先级)。
idxAC := strings.Index(res.Text, "验收标准")
idxAudit := strings.Index(res.Text, "评审内容")
require.GreaterOrEqual(t, idxAC, 0)
require.GreaterOrEqual(t, idxAudit, 0)
assert.Less(t, idxAC, idxAudit)
// 用户指令永远在最后。
assert.True(t, strings.HasSuffix(strings.TrimSpace(res.Text), "请开始实现"))
// 角色提示与需求标题都在。
assert.Contains(t, res.Text, PhaseRolePrompts[project.PhaseAuditing])
assert.Contains(t, res.Text, "需求 #7:导出报表")
}
func TestComposeTaskBrief_TightBudgetDropsLowPrioritySections(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{})
bigPlanning := strings.Repeat("规", 4000) // ~1000 tokens
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning),
Artifacts: []*project.Artifact{
art(project.PhasePlanning, 1, bigPlanning),
art(project.PhaseAuditing, 1, strings.Repeat("评", 4000)),
},
UserPrompt: "做",
Kind: KindACPSession,
TokenBudget: 300, // 紧预算
})
require.NoError(t, err)
assert.True(t, res.Truncated)
// 高优先级(角色/需求核心)保留,用户指令永远保留。
assert.Contains(t, res.Sections, "role")
assert.Contains(t, res.Sections, "requirement_core")
assert.Contains(t, res.Sections, "user_prompt")
// 用户指令永不丢。
assert.Contains(t, res.Text, "## 操作者指令\n做")
}
func TestComposeTaskBrief_RequirementOnly_IssueOnly_Both(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
// requirement-only
res1, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning), Kind: KindChatRequirement,
})
require.NoError(t, err)
assert.Contains(t, res1.Sections, "requirement_core")
assert.NotContains(t, res1.Sections, "issue_core")
// issue-only
res2, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Issue: newIssue(), Kind: KindChatIssue,
})
require.NoError(t, err)
assert.Contains(t, res2.Sections, "issue_core")
assert.NotContains(t, res2.Sections, "requirement_core")
assert.Contains(t, res2.Text, "Issue #12:修复导出乱码")
// both
res3, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning), Issue: newIssue(), Kind: KindACPSession,
})
require.NoError(t, err)
assert.Contains(t, res3.Sections, "requirement_core")
assert.Contains(t, res3.Sections, "issue_core")
}
func TestComposeTaskBrief_MissingDataYieldsNoErrorAndOmitsSections(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
// 空描述 + 无产物。
req := newReq(project.PhasePlanning)
req.Description = ""
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: req, Kind: KindChatRequirement,
})
require.NoError(t, err)
assert.NotContains(t, res.Text, "需求描述")
assert.NotContains(t, res.Sections, "artifact_planning")
assert.NotContains(t, res.Sections, "acceptance_criteria")
// 完全空输入(无 PM 上下文,仅 ACP 角色)。
resEmpty, err := c.ComposeTaskBrief(context.Background(), BriefInput{Kind: KindACPSession})
require.NoError(t, err)
// ACP 场景仍给角色 + 交互协议,但无 PM section。
assert.Contains(t, resEmpty.Sections, "role")
assert.NotContains(t, resEmpty.Sections, "requirement_core")
}
func TestComposeTaskBrief_AcceptanceCriteriaExtraction(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
planning := art(project.PhasePlanning, 3, `# 规划文档
## 背景与目标
做导出。
## 验收标准
- 能导出 Excel
- 中文不乱码
## 风险
`)
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning),
Artifacts: []*project.Artifact{planning},
Kind: KindChatRequirement,
})
require.NoError(t, err)
assert.Contains(t, res.Sections, "acceptance_criteria")
assert.Contains(t, res.Text, "能导出 Excel")
assert.Contains(t, res.Text, "中文不乱码")
// 验收标准段落不应吞掉后面的"风险"小节。
acStart := strings.Index(res.Text, "## 验收标准")
require.GreaterOrEqual(t, acStart, 0)
}
func TestComposeTaskBrief_AcceptanceCriteriaFallbackOmitsWhenAbsent(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
planning := art(project.PhasePlanning, 1, "# 规划\n## 背景\n仅有背景,没有验收标准小节")
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning),
Artifacts: []*project.Artifact{planning},
Kind: KindChatRequirement,
})
require.NoError(t, err)
assert.NotContains(t, res.Sections, "acceptance_criteria")
}
func TestComposeTaskBrief_UserPromptNeverTruncatedEvenWhenExceedsBudget(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{})
huge := strings.Repeat("做", 5000) // 远超预算
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning),
UserPrompt: huge,
Kind: KindACPSession,
TokenBudget: 50,
})
require.NoError(t, err)
assert.Contains(t, res.Sections, "user_prompt")
// 用户指令整段保留(不含截断标记)。
assert.Contains(t, res.Text, huge)
assert.NotContains(t, res.Text, "操作者指令\n"+huge+truncateMarker)
}
func TestComposeTaskBrief_LatestArtifactPerPhase(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
// 同阶段多版本,应取 version 最大的。
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePrototyping),
Artifacts: []*project.Artifact{
art(project.PhasePrototyping, 1, "旧原型 v1"),
art(project.PhasePrototyping, 3, "新原型 v3"),
art(project.PhasePrototyping, 2, "中原型 v2"),
},
Kind: KindChatRequirement,
})
require.NoError(t, err)
assert.Contains(t, res.Text, "新原型 v3")
assert.NotContains(t, res.Text, "旧原型 v1")
assert.Contains(t, res.Text, "prototyping 阶段产物 v3")
}
// fakeOrienter 用于验证 ACP 场景纳入仓库定向 section。
type fakeOrienter struct{ ori RepoOrientation }
func (f fakeOrienter) Orient(_ context.Context, _, _ string, _, _ int) RepoOrientation {
return f.ori
}
func TestComposeTaskBrief_RepoOrientationIncludedForACP(t *testing.T) {
t.Parallel()
c := NewComposer(fakeOrienter{ori: RepoOrientation{
Branch: "feat/x",
RecentCommits: []string{"abc1234 init"},
DirtyFiles: []string{"main.go"},
}}, Config{DefaultTokenBudget: 100000})
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning),
RepoCwd: "/tmp/repo",
Branch: "feat/x",
Kind: KindACPSession,
})
require.NoError(t, err)
assert.Contains(t, res.Sections, "repo_orientation")
assert.Contains(t, res.Text, "当前分支:feat/x")
assert.Contains(t, res.Text, "abc1234 init")
assert.Contains(t, res.Text, "main.go")
}
func TestComposeTaskBrief_NoOrientationWhenCwdEmpty(t *testing.T) {
t.Parallel()
c := NewComposer(fakeOrienter{ori: RepoOrientation{Branch: "x"}}, Config{DefaultTokenBudget: 100000})
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning),
Kind: KindACPSession, // RepoCwd 为空 → 跳过定向
})
require.NoError(t, err)
assert.NotContains(t, res.Sections, "repo_orientation")
}
func TestExtractSection_NumberedHeadingAndCaseInsensitive(t *testing.T) {
t.Parallel()
md := "## 6. Acceptance Criteria\n- foo\n## next\nbar"
got := extractSection(md, acceptanceCriteriaHeadings)
assert.Equal(t, "- foo", got)
}