package orchestrator import ( "context" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/project" ) // fakeApplier: 内存版 DecompositionApplier,记录建出的 issue 与边。 type fakeApplier struct { nextNum int created []string // titles in order edges [][2]int // (blockedNumber, blockerNumber) byNum map[int]*project.Issue // number → issue } func newFakeApplier() *fakeApplier { return &fakeApplier{byNum: map[int]*project.Issue{}} } func (f *fakeApplier) mk(in project.CreateIssueInput, parent *int) *project.Issue { f.nextNum++ prio := 0 if in.Priority != nil { prio = *in.Priority } var pid *uuid.UUID if parent != nil { id := uuid.New() pid = &id } iss := &project.Issue{ID: uuid.New(), Number: f.nextNum, Title: in.Title, Priority: prio, ParentID: pid} f.byNum[iss.Number] = iss f.created = append(f.created, in.Title) return iss } func (f *fakeApplier) Create(_ context.Context, _ project.Caller, _ string, in project.CreateIssueInput) (*project.Issue, error) { return f.mk(in, nil), nil } func (f *fakeApplier) CreateSubtask(_ context.Context, _ project.Caller, _ string, parentNumber int, in project.CreateIssueInput) (*project.Issue, error) { return f.mk(in, &parentNumber), nil } func (f *fakeApplier) AddDependency(_ context.Context, _ project.Caller, _ string, in project.AddDependencyInput) (*project.Dependency, error) { f.edges = append(f.edges, [2]int{in.BlockedNumber, in.BlockerNumber}) return &project.Dependency{ID: uuid.New()}, nil } func TestApplyDecomposition_RejectsCycleBeforeAnyWrite(t *testing.T) { f := newFakeApplier() d := Decomposition{ Subtasks: []SubtaskSpec{{Ref: "a", Title: "A"}, {Ref: "b", Title: "B"}}, Edges: []EdgeSpec{ {BlockedRef: "a", BlockerRef: "b"}, {BlockedRef: "b", BlockerRef: "a"}, // 环 }, } _, err := ApplyDecomposition(context.Background(), f, project.Caller{}, "demo", d) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeInvalidInput, ae.Code) require.Empty(t, f.created, "校验失败前不应有任何写入") require.Empty(t, f.edges) } func TestApplyDecomposition_ValidDAGCreatesIssuesThenEdges(t *testing.T) { f := newFakeApplier() parent := 100 f.byNum[parent] = &project.Issue{Number: parent} d := Decomposition{ ParentNumber: &parent, Subtasks: []SubtaskSpec{ {Ref: "a", Title: "A", Priority: 2}, {Ref: "b", Title: "B"}, {Ref: "c", Title: "C"}, }, Edges: []EdgeSpec{ {BlockedRef: "a", BlockerRef: "b"}, {BlockedRef: "a", BlockerRef: "c"}, }, } res, err := ApplyDecomposition(context.Background(), f, project.Caller{}, "demo", d) require.NoError(t, err) require.Len(t, f.created, 3) require.Len(t, f.edges, 2) // refs 映射到真实 number require.Contains(t, res.RefToNumber, "a") require.Contains(t, res.RefToNumber, "b") require.Contains(t, res.RefToNumber, "c") // 边以真实 number 落库 na, nb := res.RefToNumber["a"], res.RefToNumber["b"] require.Contains(t, f.edges, [2]int{na, nb}) } func TestApplyDecomposition_RejectsEmpty(t *testing.T) { f := newFakeApplier() _, err := ApplyDecomposition(context.Background(), f, project.Caller{}, "demo", Decomposition{}) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeInvalidInput, ae.Code) } func TestApplyDecomposition_RejectsUnknownEdgeRef(t *testing.T) { f := newFakeApplier() d := Decomposition{ Subtasks: []SubtaskSpec{{Ref: "a", Title: "A"}}, Edges: []EdgeSpec{{BlockedRef: "a", BlockerRef: "zzz"}}, } _, err := ApplyDecomposition(context.Background(), f, project.Caller{}, "demo", d) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeInvalidInput, ae.Code) require.Empty(t, f.created) } func TestApplyDecomposition_RejectsDuplicateRef(t *testing.T) { f := newFakeApplier() d := Decomposition{ Subtasks: []SubtaskSpec{{Ref: "a", Title: "A"}, {Ref: "a", Title: "A2"}}, } _, err := ApplyDecomposition(context.Background(), f, project.Caller{}, "demo", d) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeInvalidInput, ae.Code) }