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) } } // requiredScenarioKeys are the expected output keys each required scenario's // JSONL fixture must carry. Kept as a fallback so existing tests still pass, // but the definitive source of truth is now the handoff matrix itself via // TestHandoffExpectedFixturesWithMatrixPromisedKeys. var requiredScenarioKeys = map[string][]string{ "api_connection_smoke": {"scenario", "status", "type", "action"}, "backtest_result_summary": {"run_id", "run_status", "starting_cash", "ending_equity", "total_return", "trade_count"}, "backtest_run_polling": {"run_id", "run_status", "bar_count", "instrument_count"}, "backtest_run_request": {"run_id", "run_status", "bar_count", "instrument_count", "provider"}, "invalid_request_matrix": {"scenario", "status", "type", "action", "error_code", "error_message"}, "kis_daily_import_smoke": {"provider", "instrument_count", "bar_count"}, "market_data_status_query": {"provider", "instrument_count", "bar_count", "count"}, } // TestHandoffMatrixDocumentsCommandFirstColumns asserts that the handoff // matrix has been updated from the legacy wireframe-only columns to the // command-first columns introduced in the current milestone. The matrix must // contain explicit section text and column headers for expected output keys, // repeatable operation, UI candidate, and UI defer reason. func TestHandoffMatrixDocumentsCommandFirstColumns(t *testing.T) { data, err := os.ReadFile(handoffMatrixPath()) if err != nil { t.Fatalf("read handoff matrix: %v", err) } doc := string(data) requiredHeaders := []string{ "Expected output keys", "Repeatable operation", "UI candidate", "UI defer reason", } for _, header := range requiredHeaders { if !strings.Contains(doc, header) { t.Errorf("handoff matrix missing required column header %q", header) } } // Also verify the new section is present (not just the old header text) if !strings.Contains(doc, "command-first workflow handoff matrix") { t.Error("handoff matrix missing 'command-first workflow handoff matrix' section") } } // TestHandoffExpectedFixturesContainPromisedKeys asserts that every required scenario's // JSONL fixture contains at least one line with each key promised in the // handoff matrix. Kept as a secondary guard using requiredScenarioKeys. func TestHandoffExpectedFixturesContainPromisedKeys(t *testing.T) { // Ensure the requiredScenarios slice matches our expectedScenarioKeys map if len(requiredScenarios) == 0 { t.Fatal("requiredScenarios must not be empty") } for _, s := range requiredScenarios { keys, ok := requiredScenarioKeys[s] if !ok { t.Fatalf("required scenario %q has no promised keys defined in requiredScenarioKeys", s) } if len(keys) == 0 { t.Fatalf("required scenario %q has an empty promised keys list in requiredScenarioKeys", s) } path := expectedFixturePath(s) f, err := os.Open(path) if err != nil { t.Fatalf("open expected fixture for %q: %v", s, err) } found := make(map[string]bool) scanner := bufio.NewScanner(f) for scanner.Scan() { 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 not valid JSON: %v", path, err) continue } for k := range rec { if found[k] { continue } for _, want := range keys { if k == want { found[k] = true } } } } if err := scanner.Err(); err != nil { t.Fatalf("scan %q: %v", path, err) } f.Close() for _, want := range keys { if !found[want] { t.Errorf("%s (%q) missing promised key %q", path, s, want) } } } } // TestHandoffExpectedFixturesWithMatrixPromisedKeys reads the command-first handoff // matrix from the handoff doc, finds the "Expected output keys" cell for each // required scenario row, extracts backtick-wrapped key names, and verifies // each promised key appears at least once in that scenario's expected JSONL // fixture. The test fails loudly when: // - a required scenario row is missing from the matrix, // - the matrix promises zero keys (empty Expected output keys cell), // - a promised key is absent from the expected fixture. // // This is the definitive matrix-driven key contract test introduced by // local-G06 so the test source of truth is the handoff doc, not a hardcoded // map. func TestHandoffExpectedFixturesWithMatrixPromisedKeys(t *testing.T) { data, err := os.ReadFile(handoffMatrixPath()) if err != nil { t.Fatalf("read handoff matrix: %v", err) } lines := strings.Split(string(data), "\n") // Find the table header: first line containing both "Scenario" and "Command" headerLineIdx := -1 for i, line := range lines { if strings.Contains(line, "| Scenario") && strings.Contains(line, "Command") { headerLineIdx = i break } } if headerLineIdx < 0 { t.Fatal("matrix header row not found in handoff doc") } // The next line is the separator row (|---|---|...), skip it. // Data rows start at headerLineIdx+2. // Build a column-index map from the header row. headerCells := splitTableRow(lines[headerLineIdx]) colIndex := mapCol(headerCells, map[string]string{ "scenario": "Scenario", "expectedOutputKeys": "Expected output keys", }) // Iterate the matrix data rows (start after the separator row after header). scenarioKeys := make(map[string]string) for i := headerLineIdx + 2; i < len(lines); i++ { line := strings.TrimSpace(lines[i]) if line == "" { continue } // Skip separator rows (only dashes, pipes, colons - no backticks). if strings.HasPrefix(line, "|") && !strings.Contains(line, "`") && strings.Contains(line, "-") { continue } // Stop when we encounter the next section header. if strings.HasPrefix(line, "##") { break } cells := splitTableRow(line) scenarioName := strings.Trim(cells[colIndex["scenario"]], "`") keysCell := strings.TrimSpace(cells[colIndex["expectedOutputKeys"]]) scenarioKeys[scenarioName] = keysCell // Verify every required scenario row is present. found := false for _, req := range requiredScenarios { if req == scenarioName { found = true break } } if !found { // Not a required scenario; skip. continue } if keysCell == "" { t.Errorf("matrix row for scenario %q has an empty Expected output keys cell", scenarioName) } } // Ensure every required scenario is present in the matrix. for _, req := range requiredScenarios { if _, ok := scenarioKeys[req]; !ok { t.Errorf("required scenario %q is missing from the handoff matrix", req) } } // For each required scenario, parse keys from the matrix and verify fixture. for _, req := range requiredScenarios { keysCell, ok := scenarioKeys[req] if !ok { continue // reported above } promisedKeys := extractBacktickKeys(keysCell) if len(promisedKeys) == 0 { t.Errorf("scenario %q: matrix promises zero keys (empty Expected output keys cell)", req) continue } fixturePath := expectedFixturePath(req) f, err := os.Open(fixturePath) if err != nil { t.Fatalf("open expected fixture for %q: %v", req, err) } found := make(map[string]bool) scanner := bufio.NewScanner(f) for scanner.Scan() { 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 not valid JSON: %v", fixturePath, err) continue } for k := range rec { if !found[k] { for _, wanted := range promisedKeys { if k == wanted { found[k] = true } } } } } if err := scanner.Err(); err != nil { t.Fatalf("scan %q: %v", fixturePath, err) } f.Close() for _, pw := range promisedKeys { if !found[pw] { t.Errorf("%s (%q) missing promised key %q from matrix", fixturePath, req, pw) } } } } // extractBacktickKeys returns the set of backtick-wrapped strings from a // pipe-delimited markdown cell (e.g. "`a`, `b`, `c`"). func extractBacktickKeys(cell string) []string { var keys []string seen := make(map[string]bool) for _, part := range strings.Split(cell, ",") { trimmed := strings.TrimSpace(part) if strings.HasPrefix(trimmed, "`") && strings.HasSuffix(trimmed, "`") { key := trimmed[1 : len(trimmed)-1] if !seen[key] { seen[key] = true keys = append(keys, key) } } } return keys } // splitTableRow splits a markdown table row into its cell strings, trimming // the leading/trailing pipes and splitting on "|". func splitTableRow(line string) []string { line = strings.TrimPrefix(line, "|") line = strings.TrimSuffix(line, "|") var cells []string for _, part := range strings.Split(line, "|") { cells = append(cells, strings.TrimSpace(part)) } return cells } // mapCol accepts a list of column cells and a desired-name → index map, // and returns an int map from alias key to column index. func mapCol(cells []string, aliases map[string]string) map[string]int { out := make(map[string]int) for alias, name := range aliases { for i, cell := range cells { if cell == name { out[alias] = i break } } } return out } // 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) } } }