Files
Agent-System/internal/controlcenter/importer/importer_test.go
T
weichen f79956c498 feat(control-center): P4 .doc importer, agent CLI, task doc export
- internal/controlcenter/importer: scan .doc/ops/{short-term,long-term}
  and .doc/backlog for task docs, upsert cc_tasks keyed by slug; maps doc
  status/stage, auto-creates the three plans, follows doc status only
  while a task is planned/in_progress (terminal statuses never regress),
  skips unchanged docs, and resolves cross-directory slug collisions by
  fixed directory precedence with a warning
- cmd/control-center: import-docs subcommand (also runs at serve startup
  and on the doc-sync ticker) plus the agent CLI: next / instruction /
  claim / heartbeat / release / event / artifact / advance-request /
  decision / report, thin net/http client, compact JSON output
- console API POST /api/v1/tasks/{id}/export-doc renders a task per
  .doc/ops/templates/short-term-task.md into .doc/ops/short-term/, refuses
  overwrite (409) and unsafe slugs, then backfills doc_path
- fix pre-existing TestAgentHasNoAdvanceRoute: SPA catch-all GET / makes
  the mux answer 405 for unmatched POST paths; accept 404 or 405
- archtest allowlist: controlcenter/importer -> pipeline, store
2026-09-17 11:57:47 +00:00

181 lines
6.6 KiB
Go

package importer
import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/controlcenter/pipeline"
"chat-agent-system/internal/controlcenter/store"
)
func TestMapStatus(t *testing.T) {
assert.Equal(t, pipeline.StatusPlanned, mapStatus("planned"))
assert.Equal(t, pipeline.StatusInProgress, mapStatus("in_progress"))
assert.Equal(t, pipeline.StatusBlockedDecision, mapStatus("blocked"))
assert.Equal(t, pipeline.StatusDone, mapStatus("done"))
assert.Equal(t, pipeline.StatusCancelled, mapStatus("cancelled"))
assert.Equal(t, pipeline.StatusPlanned, mapStatus(""))
assert.Equal(t, pipeline.StatusPlanned, mapStatus("weird"))
}
func TestInferStage(t *testing.T) {
st, ss := inferStage(pipeline.StatusDone)
assert.Equal(t, pipeline.StageConfirmation, st)
assert.Equal(t, pipeline.StateApproved, ss)
st, ss = inferStage(pipeline.StatusInProgress)
assert.Equal(t, pipeline.StageImplementation, st)
assert.Equal(t, pipeline.StateInProgress, ss)
st, ss = inferStage(pipeline.StatusBlockedDecision)
assert.Equal(t, pipeline.StageImplementation, st)
assert.Equal(t, pipeline.StateChangesRequested, ss)
for _, s := range []string{pipeline.StatusPlanned, pipeline.StatusCancelled} {
st, ss = inferStage(s)
assert.Equal(t, pipeline.StageProposal, st)
assert.Equal(t, pipeline.StateIdle, ss)
}
}
func writeDoc(t *testing.T, repoRoot, rel, content string) {
t.Helper()
full := filepath.Join(repoRoot, filepath.FromSlash(rel))
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
}
func TestParseDoc(t *testing.T) {
repoRoot := t.TempDir()
writeDoc(t, repoRoot, ".doc/ops/short-term/2026-09-17-demo-task.md", `---
title: "示例任务"
status: blocked
owner: kimi
branch: feature/demo
worktree: "-"
mutex: docgov
---
# 示例任务
## 目标
做点什么。
`)
im := New(nil, repoRoot)
dt, err := im.parseDoc(filepath.Join(repoRoot, ".doc", "ops", "short-term", "2026-09-17-demo-task.md"), "short-term")
require.NoError(t, err)
assert.Equal(t, "2026-09-17-demo-task", dt.slug)
assert.Equal(t, "示例任务", dt.title)
assert.Equal(t, pipeline.StatusBlockedDecision, dt.status)
assert.Equal(t, "kimi", dt.owner)
assert.Equal(t, "feature/demo", dt.branch)
assert.Equal(t, "-", dt.worktree)
assert.Equal(t, "docgov", dt.mutex)
assert.Equal(t, ".doc/ops/short-term/2026-09-17-demo-task.md", dt.docPath)
assert.Contains(t, dt.raw, "## 目标")
}
func TestParseDocTitleFallback(t *testing.T) {
repoRoot := t.TempDir()
writeDoc(t, repoRoot, ".doc/backlog/no-frontmatter.md", "# 标题来自正文\n\n内容。\n")
im := New(nil, repoRoot)
dt, err := im.parseDoc(filepath.Join(repoRoot, ".doc", "backlog", "no-frontmatter.md"), "backlog")
require.NoError(t, err)
assert.Equal(t, "标题来自正文", dt.title)
assert.Equal(t, pipeline.StatusPlanned, dt.status)
assert.Equal(t, pipeline.StageProposal, dt.stage)
writeDoc(t, repoRoot, ".doc/backlog/plain.md", "没有任何标题。\n")
dt, err = im.parseDoc(filepath.Join(repoRoot, ".doc", "backlog", "plain.md"), "backlog")
require.NoError(t, err)
assert.Equal(t, "plain", dt.title)
}
// --- DB integration (TEST_CC_DB_DSN gated) ---
func newTestStore(t *testing.T) *store.Store {
t.Helper()
dsn := os.Getenv("TEST_CC_DB_DSN")
if dsn == "" {
t.Skip("TEST_CC_DB_DSN not set")
}
_, thisFile, _, _ := runtime.Caller(0)
migrationsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "deploy", "control-center", "goose")
ctx := t.Context()
require.NoError(t, store.Migrate(ctx, dsn, migrationsDir))
st, err := store.Connect(ctx, dsn)
require.NoError(t, err)
t.Cleanup(st.Close)
return st
}
func TestImportIntegration(t *testing.T) {
st := newTestStore(t)
tag := fmt.Sprintf("imp%d", time.Now().UnixNano())
repoRoot := t.TempDir()
slugDone := tag + "-done"
slugPlanned := tag + "-planned"
writeDoc(t, repoRoot, ".doc/ops/short-term/"+slugDone+".md",
"---\ntitle: \"已完成任务 "+tag+"\"\nstatus: done\nowner: cursor\nbranch: fix/x\nworktree: \"-\"\nmutex: docgov\n---\n\n# 已完成任务\n\n正文。\n")
writeDoc(t, repoRoot, ".doc/ops/long-term/"+slugPlanned+".md",
"---\ntitle: \"计划中任务 "+tag+"\"\nstatus: planned\n---\n\n# 计划中任务\n\n正文。\n")
im := New(st, repoRoot)
stats, err := im.Import(t.Context())
require.NoError(t, err)
assert.Equal(t, 2, stats.Added)
assert.Equal(t, 0, stats.Updated)
var stage, stageState, status, planSlug, owner string
require.NoError(t, st.Pool.QueryRow(t.Context(),
`SELECT t.stage, t.stage_state, t.status, p.slug, t.owner
FROM cc_tasks t JOIN cc_plans p ON p.id = t.plan_id WHERE t.slug = $1`, slugDone).
Scan(&stage, &stageState, &status, &planSlug, &owner))
assert.Equal(t, pipeline.StageConfirmation, stage)
assert.Equal(t, pipeline.StateApproved, stageState)
assert.Equal(t, pipeline.StatusDone, status)
assert.Equal(t, "short-term", planSlug)
assert.Equal(t, "cursor", owner)
require.NoError(t, st.Pool.QueryRow(t.Context(),
`SELECT p.slug FROM cc_tasks t JOIN cc_plans p ON p.id = t.plan_id WHERE t.slug = $1`, slugPlanned).
Scan(&planSlug))
assert.Equal(t, "long-term", planSlug)
// Idempotent: second run skips everything.
stats, err = im.Import(t.Context())
require.NoError(t, err)
assert.Equal(t, 0, stats.Added+stats.Updated, "unchanged docs must be skipped")
assert.Equal(t, 2, stats.Skipped)
// Doc moves planned -> in_progress: status follows while task is planned.
writeDoc(t, repoRoot, ".doc/ops/long-term/"+slugPlanned+".md",
"---\ntitle: \"计划中任务 "+tag+"\"\nstatus: in_progress\n---\n\n# 计划中任务\n\n正文改。\n")
stats, err = im.Import(t.Context())
require.NoError(t, err)
assert.Equal(t, 1, stats.Updated)
require.NoError(t, st.Pool.QueryRow(t.Context(),
`SELECT status, stage FROM cc_tasks WHERE slug = $1`, slugPlanned).Scan(&status, &stage))
assert.Equal(t, pipeline.StatusInProgress, status)
assert.Equal(t, pipeline.StageProposal, stage, "stage must not move after insert")
// Terminal status never regresses: doc edited to in_progress, task stays done.
writeDoc(t, repoRoot, ".doc/ops/short-term/"+slugDone+".md",
"---\ntitle: \"已完成任务 "+tag+"\"\nstatus: in_progress\nowner: cursor\nbranch: fix/x\nworktree: \"-\"\nmutex: docgov\n---\n\n# 已完成任务\n\n正文改。\n")
stats, err = im.Import(t.Context())
require.NoError(t, err)
assert.Equal(t, 1, stats.Updated, "content changed but status must not regress")
require.NoError(t, st.Pool.QueryRow(t.Context(),
`SELECT status FROM cc_tasks WHERE slug = $1`, slugDone).Scan(&status))
assert.Equal(t, pipeline.StatusDone, status)
}