alt/apps/cli/internal/operator/handoff_test.go

200 lines
5.9 KiB
Go

package operator
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// requiredScenarios are the six headless validation scenarios the milestone
// handoff table requires evidence for. The handoff matrix and the expected
// output fixtures must cover all of them so the Flutter Operator Console MVP
// can validate each workflow before building any screen.
var requiredScenarios = []string{
"api_connection_smoke",
"market_data_status_query",
"backtest_run_request",
"backtest_run_polling",
"backtest_result_summary",
"invalid_request_matrix",
"kis_daily_import_smoke",
}
func handoffMatrixPath() string {
return filepath.Join("..", "..", "testdata", "operator", "headless_validation.md")
}
func expectedFixturePath(scenario string) string {
return filepath.Join("..", "..", "testdata", "operator", "expected", scenario+".jsonl")
}
// TestHeadlessValidationHandoffCoversRequiredScenarios fails if the handoff
// matrix drops any required scenario, which would leave the Flutter MVP without
// command/field evidence for that workflow.
func TestHeadlessValidationHandoffCoversRequiredScenarios(t *testing.T) {
data, err := os.ReadFile(handoffMatrixPath())
if err != nil {
t.Fatalf("read handoff matrix: %v", err)
}
doc := string(data)
for _, s := range requiredScenarios {
if !strings.Contains(doc, "`"+s+"`") {
t.Errorf("handoff matrix missing scenario %q", s)
}
}
}
// TestExpectedOutputFixturesAreValidJSONLines checks that every expected output
// fixture parses as JSON lines and that each record carries the machine-readable
// keys the handoff contract promises: scenario/status on every line, action on
// step lines, plus at least one step and one summary line.
func TestExpectedOutputFixturesAreValidJSONLines(t *testing.T) {
for _, s := range requiredScenarios {
path := expectedFixturePath(s)
f, err := os.Open(path)
if err != nil {
t.Errorf("open expected fixture %q: %v", path, err)
continue
}
scanner := bufio.NewScanner(f)
lineNo := 0
sawStep := false
sawSummary := false
for scanner.Scan() {
lineNo++
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Errorf("%s:%d not valid JSON: %v", path, lineNo, err)
continue
}
if rec["scenario"] != s {
t.Errorf("%s:%d scenario = %v, want %q", path, lineNo, rec["scenario"], s)
}
if _, ok := rec["status"]; !ok {
t.Errorf("%s:%d missing status field", path, lineNo)
}
switch rec["type"] {
case "step":
sawStep = true
if _, ok := rec["action"]; !ok {
t.Errorf("%s:%d step line missing action field", path, lineNo)
}
case "summary":
sawSummary = true
default:
t.Errorf("%s:%d unexpected type %v", path, lineNo, rec["type"])
}
}
if err := scanner.Err(); err != nil {
t.Errorf("scan %q: %v", path, err)
}
f.Close()
if !sawStep {
t.Errorf("%s has no step line", path)
}
if !sawSummary {
t.Errorf("%s has no summary line", path)
}
}
}
// TestStatusScenarioFixtureImportsBeforeReads guards the command-first status
// scenario: its expected fixture must carry an import_daily_bars step line with
// instrument_count and bar_count keys before the instrument/bar read lines, so
// the handoff evidence shows imported counts rather than a bare status read.
func TestStatusScenarioFixtureImportsBeforeReads(t *testing.T) {
const scenario = "market_data_status_query"
f, err := os.Open(expectedFixturePath(scenario))
if err != nil {
t.Fatalf("open expected fixture: %v", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
importLine := -1
instLine := -1
barLine := -1
lineNo := 0
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
lineNo++
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("line %d not valid JSON: %v", lineNo, err)
}
switch rec["action"] {
case "import_daily_bars":
importLine = lineNo
if _, ok := rec["instrument_count"]; !ok {
t.Errorf("import step line %d missing instrument_count", lineNo)
}
if _, ok := rec["bar_count"]; !ok {
t.Errorf("import step line %d missing bar_count", lineNo)
}
case "list_instruments":
instLine = lineNo
case "list_bars":
barLine = lineNo
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan fixture: %v", err)
}
if importLine < 0 {
t.Fatal("status fixture has no import_daily_bars step line")
}
if instLine < 0 || barLine < 0 {
t.Fatal("status fixture missing instrument/bar read step lines")
}
if importLine > instLine || importLine > barLine {
t.Errorf("import line %d must precede read lines (instruments=%d, bars=%d)", importLine, instLine, barLine)
}
}
// TestHandoffDocumentedScenarioFixturesExist guards the handoff matrix against
// referencing fixture paths that do not exist. Each required scenario must have
// an input YAML fixture and an expected output fixture on disk, and the matrix
// must reference both relative paths so the documented commands stay runnable.
func TestHandoffDocumentedScenarioFixturesExist(t *testing.T) {
matrix, err := os.ReadFile(handoffMatrixPath())
if err != nil {
t.Fatalf("read handoff matrix: %v", err)
}
doc := string(matrix)
for _, s := range requiredScenarios {
inputPath := filepath.Join("..", "..", "testdata", "operator", s+".yaml")
if _, err := os.Stat(inputPath); err != nil {
t.Errorf("input fixture for %q missing: %v", s, err)
}
if _, err := os.Stat(expectedFixturePath(s)); err != nil {
t.Errorf("expected fixture for %q missing: %v", s, err)
}
inputRel := "testdata/operator/" + s + ".yaml"
expectedRel := "testdata/operator/expected/" + s + ".jsonl"
if !strings.Contains(doc, inputRel) {
t.Errorf("handoff matrix does not reference input path %q", inputRel)
}
if !strings.Contains(doc, expectedRel) {
t.Errorf("handoff matrix does not reference expected path %q", expectedRel)
}
}
}