Files
LaodingBot/tools/piplan/piplan_test.go
Ding, Shuo 8dc5354fa4 feat: implement streaming chat, skill routing, and SAFe PI planning tools
- Add /api/chat/stream endpoint with Server-Sent Events (SSE) for real-time message streaming
  * Implement StreamEvent types (thought, tool_call, tool_result, final, error)
  * Add StreamEventCallback mechanism for event propagation
  * Create StreamChatHandler in webui/bot with proper HTTP headers and flushing

- Implement LLM-based skill router for intelligent capability selection
  * Add optional routerLLM client for semantic routing
  * Implement routeSkillsWithLLM() to match user intent to available skills
  * Add matchSkillsByName() for fuzzy skill matching
  * Update buildUnifiedSystemPrompt() to use routed skills

- Add streaming support to ReAct pipeline
  * Implement runUnifiedReActStream() for streaming thought/action/observation
  * Emit StreamEvent at each ReAct step
  * Support callback error handling in streaming mode

- Integrate three new DevOps tools
  * tools/filedoc: Extract document content from file_id via OpenAI
  * tools/giteaticket: Create Gitea issues from PI plan items with SAFe metadata
  * tools/piplan: Publish PI planning blueprints with dependency tracking

- Add SAFe PI Planning skill
  * Implement PM/SA/RTE (iron triangle) workflow
  * Support for Feature, Enabler, and Dependency definition
  * Automatic task decomposition and Gitea integration

- Create frontend integration documentation
  * Complete SSE protocol specification
  * TypeScript fetch + ReadableStream example
  * LLM-ready refactoring template for other projects

- Simplify file handling
  * Remove legacy file context structures and dual-mode processing
  * Consolidate file operations into UploadAndCacheFiles()
  * Remove FilePromptMode configuration and related complexity

- Update configuration
  * Add Router model support (LLM_ROUTER_MODEL)
  * Add Gitea configuration (BaseURL, Token, Owner, Repo)
  * WebSearch and additional tool infrastructure

Tests: All 22 test packages passing, 8/8 webui tests including 3 new stream tests
2026-03-11 17:58:19 +08:00

335 lines
8.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package piplan
import (
"context"
"encoding/json"
"strings"
"testing"
)
func validInput() PIPlanInput {
return PIPlanInput{
PIVision: "实现车云一体化 OTA 系统,支撑百万级终端设备的安全固件升级",
Features: []Feature{
{
FeatureID: "FEAT_OTA_001",
Title: "云端固件版本依赖检查",
BenefitHypothesis: "上线后减少 30% 的固件回退率",
AcceptanceCriteria: []string{
"上传固件时自动解析并记录版本依赖关系",
"下发升级任务时自动校验设备当前版本是否满足依赖",
"不满足依赖时返回明确的错误提示及所需前置版本",
},
},
{
FeatureID: "FEAT_OTA_002",
Title: "端侧断点续传",
BenefitHypothesis: "弱网环境下固件下载成功率提升至 99.5%",
AcceptanceCriteria: []string{
"支持分片下载与本地缓存校验",
"网络恢复后自动续传,无需用户干预",
},
},
},
Enablers: []Enabler{
{
EnablerID: "ENAB_KAFKA_001",
Title: "搭建跨可用区的高可用 Kafka 集群",
ArchitecturalPurpose: "为高并发 OTA 状态机提供可靠消息管道",
},
{
EnablerID: "ENAB_S3_001",
Title: "对象存储多区域同步",
ArchitecturalPurpose: "保证固件文件在多区域的低延迟分发",
},
},
NFRs: NFRs{
Performance: "API 响应时间 P99 < 200ms吞吐量 > 10000 QPS",
Security: "车云通信必须使用 TLS 1.3,敏感数据必须脱敏",
},
Dependencies: []Dependency{
{
SourceID: "ENAB_KAFKA_001",
TargetID: "FEAT_OTA_001",
Reason: "版本检查服务依赖 Kafka 进行异步事件通知",
},
{
SourceID: "ENAB_S3_001",
TargetID: "FEAT_OTA_002",
Reason: "断点续传需要对象存储支持 Range 请求",
},
},
}
}
func TestParseInputValid(t *testing.T) {
in := validInput()
data, _ := json.Marshal(in)
plan, err := parseInput(string(data))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if plan.PIVision != in.PIVision {
t.Errorf("pi_vision mismatch: got %q", plan.PIVision)
}
if len(plan.Features) != 2 {
t.Errorf("expected 2 features, got %d", len(plan.Features))
}
if len(plan.Enablers) != 2 {
t.Errorf("expected 2 enablers, got %d", len(plan.Enablers))
}
if len(plan.Dependencies) != 2 {
t.Errorf("expected 2 dependencies, got %d", len(plan.Dependencies))
}
}
func TestParseInputEmpty(t *testing.T) {
_, err := parseInput("")
if err == nil {
t.Fatal("expected error for empty input")
}
}
func TestParseInputInvalidJSON(t *testing.T) {
_, err := parseInput("{not json}")
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestValidateMissingVision(t *testing.T) {
in := validInput()
in.PIVision = ""
err := validate(&in)
if err == nil || !strings.Contains(err.Error(), "pi_vision") {
t.Fatalf("expected pi_vision error, got: %v", err)
}
}
func TestValidateNoFeatures(t *testing.T) {
in := validInput()
in.Features = nil
err := validate(&in)
if err == nil || !strings.Contains(err.Error(), "feature") {
t.Fatalf("expected feature error, got: %v", err)
}
}
func TestValidateFeatureMissingID(t *testing.T) {
in := validInput()
in.Features[0].FeatureID = ""
err := validate(&in)
if err == nil || !strings.Contains(err.Error(), "feature_id") {
t.Fatalf("expected feature_id error, got: %v", err)
}
}
func TestValidateFeatureMissingAC(t *testing.T) {
in := validInput()
in.Features[0].AcceptanceCriteria = nil
err := validate(&in)
if err == nil || !strings.Contains(err.Error(), "acceptance_criteria") {
t.Fatalf("expected acceptance_criteria error, got: %v", err)
}
}
func TestValidateEnablerMissingPurpose(t *testing.T) {
in := validInput()
in.Enablers[0].ArchitecturalPurpose = ""
err := validate(&in)
if err == nil || !strings.Contains(err.Error(), "architectural_purpose") {
t.Fatalf("expected architectural_purpose error, got: %v", err)
}
}
func TestValidateNFRsMissingPerformance(t *testing.T) {
in := validInput()
in.NFRs.Performance = ""
err := validate(&in)
if err == nil || !strings.Contains(err.Error(), "performance") {
t.Fatalf("expected performance error, got: %v", err)
}
}
func TestValidateNFRsMissingSecurity(t *testing.T) {
in := validInput()
in.NFRs.Security = ""
err := validate(&in)
if err == nil || !strings.Contains(err.Error(), "security") {
t.Fatalf("expected security error, got: %v", err)
}
}
func TestValidateDepMissingSourceID(t *testing.T) {
in := validInput()
in.Dependencies[0].SourceID = ""
err := validate(&in)
if err == nil || !strings.Contains(err.Error(), "source_id") {
t.Fatalf("expected source_id error, got: %v", err)
}
}
func TestRenderContainsSections(t *testing.T) {
in := validInput()
out := render(&in)
sections := []string{
"# PI 规划架构蓝图与任务清单",
"## 1. PI 愿景",
"## 2. 业务特性清单 (Features)",
"## 3. 技术赋能特性 (Enablers / 架构跑道)",
"## 4. 非功能性需求 (NFRs)",
"## 5. 依赖关系",
"## 6. 建议执行顺序",
"## 7. 质量门禁检查清单",
}
for _, s := range sections {
if !strings.Contains(out, s) {
t.Errorf("output missing section: %s", s)
}
}
}
func TestRenderContainsFeatureDetails(t *testing.T) {
in := validInput()
out := render(&in)
if !strings.Contains(out, "FEAT_OTA_001") {
t.Error("output missing FEAT_OTA_001")
}
if !strings.Contains(out, "云端固件版本依赖检查") {
t.Error("output missing feature title")
}
if !strings.Contains(out, "AC-1") {
t.Error("output missing acceptance criteria numbering")
}
}
func TestRenderContainsEnablerTable(t *testing.T) {
in := validInput()
out := render(&in)
if !strings.Contains(out, "ENAB_KAFKA_001") {
t.Error("output missing ENAB_KAFKA_001")
}
if !strings.Contains(out, "ENAB_S3_001") {
t.Error("output missing ENAB_S3_001")
}
}
func TestRenderContainsNFRs(t *testing.T) {
in := validInput()
out := render(&in)
if !strings.Contains(out, "P99 < 200ms") {
t.Error("output missing performance NFR")
}
if !strings.Contains(out, "TLS 1.3") {
t.Error("output missing security NFR")
}
}
func TestRenderContainsDependencies(t *testing.T) {
in := validInput()
out := render(&in)
if !strings.Contains(out, "ENAB_KAFKA_001") || !strings.Contains(out, "FEAT_OTA_001") {
t.Error("output missing dependency pair")
}
}
func TestComputeExecutionOrder(t *testing.T) {
in := validInput()
order := computeExecutionOrder(&in)
// Enablers should come before their dependent Features
enablerIdx := map[string]int{}
featureIdx := map[string]int{}
for i, id := range order {
if strings.HasPrefix(id, "ENAB_") {
enablerIdx[id] = i
} else if strings.HasPrefix(id, "FEAT_") {
featureIdx[id] = i
}
}
if enablerIdx["ENAB_KAFKA_001"] >= featureIdx["FEAT_OTA_001"] {
t.Error("ENAB_KAFKA_001 should come before FEAT_OTA_001")
}
if enablerIdx["ENAB_S3_001"] >= featureIdx["FEAT_OTA_002"] {
t.Error("ENAB_S3_001 should come before FEAT_OTA_002")
}
}
func TestComputeExecutionOrderNoDeps(t *testing.T) {
in := validInput()
in.Dependencies = nil
order := computeExecutionOrder(&in)
if len(order) != 4 {
t.Errorf("expected 4 items, got %d", len(order))
}
// Enablers first, then Features (stable order)
if order[0] != "ENAB_KAFKA_001" || order[1] != "ENAB_S3_001" {
t.Errorf("enablers should come first, got: %v", order)
}
}
func TestCallEndToEnd(t *testing.T) {
tool := New(0, nil)
in := validInput()
data, _ := json.Marshal(in)
result, err := tool.Call(context.Background(), string(data))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, "PI 规划架构蓝图") {
t.Error("output missing title")
}
}
func TestCallInvalidInput(t *testing.T) {
tool := New(0, nil)
_, err := tool.Call(context.Background(), "not json")
if err == nil {
t.Fatal("expected error for invalid input")
}
}
func TestCallMissingRequiredField(t *testing.T) {
tool := New(0, nil)
in := validInput()
in.PIVision = ""
data, _ := json.Marshal(in)
_, err := tool.Call(context.Background(), string(data))
if err == nil {
t.Fatal("expected validation error")
}
}
func TestNameAndDescription(t *testing.T) {
tool := New(0, nil)
if tool.Name() != "publish_pi_plan" {
t.Errorf("unexpected name: %s", tool.Name())
}
if tool.Description() == "" {
t.Error("description should not be empty")
}
}
func TestMaxOutputTruncation(t *testing.T) {
tool := New(100, nil)
in := validInput()
data, _ := json.Marshal(in)
result, err := tool.Call(context.Background(), string(data))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) > 100 {
t.Errorf("output should be truncated to 100 chars, got %d", len(result))
}
}