package app import ( "bytes" "context" "io" "path/filepath" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/stage" ) func TestBoundedRunParsingIsSharedByRunAndPlan(t *testing.T) { args := []string{ "2026-05-03", "--from", "extract", "--through=publish", "--force", "--artifacts", "session_recap,player_handout", "--artifacts=session_recap", "--config", "pipeline.yml", } runRequest, err := parseBoundedRunRequest("run", args, io.Discard) if err != nil { t.Fatalf("parse run request: %v", err) } planRequest, err := parseBoundedRunRequest("plan", args, io.Discard) if err != nil { t.Fatalf("parse plan request: %v", err) } if !reflect.DeepEqual(runRequest.Plan.Names(), planRequest.Plan.Names()) || runRequest.Plan.From() != planRequest.Plan.From() || runRequest.Plan.Through() != planRequest.Plan.Through() || runRequest.Force != planRequest.Force || !reflect.DeepEqual(runRequest.SelectedArtifacts, planRequest.SelectedArtifacts) || runRequest.Config != planRequest.Config { t.Fatalf("run request = %#v, plan request = %#v", runRequest, planRequest) } wantArtifacts := []string{"player_handout", "session_recap"} if !reflect.DeepEqual(runRequest.SelectedArtifacts, wantArtifacts) { t.Fatalf("artifacts = %#v, want %#v", runRequest.SelectedArtifacts, wantArtifacts) } } func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) { tests := []struct { name string args []string want string }{ {name: "from separate", args: []string{"session", "--from", "render", "--from", "extract"}, want: "--from may be specified only once"}, {name: "from equals", args: []string{"session", "--from=render", "--from=extract"}, want: "--from may be specified only once"}, {name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"}, {name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"}, {name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, err := parseBoundedRunRequest("run", test.args, io.Discard) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("error = %v, want %q", err, test.want) } }) } } func TestBoundedRunParsingUsesSharedRangeValidation(t *testing.T) { args := []string{"session", "--from", "publish", "--through", "render"} runRequest, runErr := parseBoundedRunRequest("run", args, io.Discard) planRequest, planErr := parseBoundedRunRequest("plan", args, io.Discard) if runErr == nil || planErr == nil { t.Fatalf("run request=%#v error=%v; plan request=%#v error=%v", runRequest, runErr, planRequest, planErr) } runDetail := strings.TrimPrefix(runErr.Error(), "run: ") planDetail := strings.TrimPrefix(planErr.Error(), "plan: ") if runDetail != planDetail || !strings.Contains(runDetail, `from stage "publish" occurs after through stage "render"`) { t.Fatalf("run error = %q, plan error = %q", runErr, planErr) } } func TestBoundedRunParsingGatesArtifactSelectionByRange(t *testing.T) { for _, test := range []struct { name string through string wantErr bool }{ {name: "render only", through: "render", wantErr: true}, {name: "analyze only", through: "analyze"}, {name: "publish only", through: "publish"}, } { t.Run(test.name, func(t *testing.T) { _, err := parseBoundedRunRequest("run", []string{ "session", "--from", test.through, "--through", test.through, "--artifacts", "session_recap", }, io.Discard) if test.wantErr && (err == nil || !strings.Contains(err.Error(), "range containing analyze or publish")) { t.Fatalf("error = %v, want artifact/range error", err) } if !test.wantErr && err != nil { t.Fatalf("error = %v", err) } }) } } func TestRunPassesBoundedPlanToRunner(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var capturedStages []string var capturedOptions RunOptions original := executeStagesFn t.Cleanup(func() { executeStagesFn = original }) executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, options RunOptions) (*RunSummary, error) { capturedStages = stageNames(stages) capturedOptions = options return &RunSummary{SessionID: "2026-05-03", ManifestPath: filepath.Join(workspaceRoot, "manifest.json")}, nil } var out bytes.Buffer err := Run(context.Background(), []string{ "2026-05-03", "--from", "render", "--through", "extract", "--force", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, &out) if err != nil { t.Fatalf("Run() error = %v", err) } want := []string{"render", "extract"} if !reflect.DeepEqual(capturedStages, want) || !reflect.DeepEqual(capturedOptions.Plan.Names(), want) || !capturedOptions.Force { t.Fatalf("stages = %#v options = %#v", capturedStages, capturedOptions) } } func TestPlanPrintsOnlyBoundedRange(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var out bytes.Buffer err := Plan(context.Background(), []string{ "2026-05-03", "--from", "render", "--through", "extract", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, &out) if err != nil { t.Fatalf("Plan() error = %v", err) } got := out.String() if !strings.Contains(got, "render: run\nextract: run\ntotals: run=2 skip=0") { t.Fatalf("output = %q, want bounded decisions", got) } if strings.Contains(got, "trim: ") || strings.Contains(got, "analyze: ") { t.Fatalf("output = %q, contains excluded stages", got) } } func TestBoundedRunCommandHelp(t *testing.T) { for _, test := range []struct { name string args []string want string }{ {name: "run", args: []string{"run", "--help"}, want: "Usage: narratio run [--from ] [--through ]"}, {name: "plan", args: []string{"session", "plan", "--help"}, want: "Usage: narratio session plan [--from ] [--through ]"}, } { t.Run(test.name, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer if code := Execute(test.args, &stdout, &stderr); code != 0 { t.Fatalf("exit code = %d, stderr = %q", code, stderr.String()) } if !strings.Contains(stdout.String(), test.want) || stderr.Len() != 0 { t.Fatalf("stdout = %q stderr = %q, want %q", stdout.String(), stderr.String(), test.want) } }) } }