package ask import ( "context" "encoding/json" "errors" "strings" "testing" ) func twoOptions() Question { return Question{ Header: "Storage", Question: "How should sessions be stored?", Options: []Option{{Label: "Postgres"}, {Label: "SQLite"}}, } } // Every rejection is read by the model, which gets to send another call. A // message that does not say what to send instead wastes that turn. func TestValidateRejectsWhatCannotBeDrawn(t *testing.T) { tooMany := Request{} for i := 0; i < MaxQuestions+1; i++ { tooMany.Questions = append(tooMany.Questions, twoOptions()) } oneOption := twoOptions() oneOption.Options = oneOption.Options[:1] fiveOptions := twoOptions() for i := 0; i < 4; i++ { fiveOptions.Options = append(fiveOptions.Options, Option{Label: string(rune('a' + i))}) } noHeader := twoOptions() noHeader.Header = " " noText := twoOptions() noText.Question = "" dup := twoOptions() dup.Options[1].Label = "postgres" blank := twoOptions() blank.Options[1].Label = "" cases := map[string]struct { req Request want string }{ "nothing asked": {Request{}, "1 and 4"}, "too many": {tooMany, "at most 4"}, "one option": {Request{Questions: []Question{oneOption}}, "at least 2"}, "five options": {Request{Questions: []Question{fiveOptions}}, "at most 4"}, "no header": {Request{Questions: []Question{noHeader}}, "header"}, "no question": {Request{Questions: []Question{noText}}, "no text"}, "same label": {Request{Questions: []Question{dup}}, "twice"}, "blank label": {Request{Questions: []Question{blank}}, "no label"}, "a good question": {Request{Questions: []Question{twoOptions()}}, ""}, } for name, c := range cases { err := Validate(c.req) switch { case c.want == "" && err != nil: t.Errorf("%s: rejected a valid request: %v", name, err) case c.want == "": case err == nil: t.Errorf("%s: accepted", name) case !strings.Contains(err.Error(), c.want): t.Errorf("%s: %q does not mention %q", name, err, c.want) } } } // The model reads the result many turns later, by which time a bare label has // stopped meaning anything. The question has to travel with the answer. func TestFormatRepeatsTheQuestion(t *testing.T) { got := Response{Answers: []Answer{ {Header: "Storage", Question: "How should sessions be stored?", Chosen: []string{"Postgres"}}, {Header: "Scope", Question: "What should it cover?", Chosen: []string{"the CLI", "the TUI"}}, {Header: "Naming", Question: "What should it be called?", Other: "call it sessiondir"}, }}.Format() for _, want := range []string{ "How should sessions be stored?", "Postgres", "the CLI, the TUI", "in their own words: call it sessiondir", } { if !strings.Contains(got, want) { t.Errorf("missing %q from:\n%s", want, got) } } } func TestAskerForwardsAndCanBeReplaced(t *testing.T) { a := NewAsker(nil) if _, err := a.Ask(context.Background(), Request{}); err == nil { t.Error("an asker with nothing installed should say so rather than hang") } a.Use(func(context.Context, Request) (Response, error) { return Response{Answers: []Answer{{Chosen: []string{"first"}}}}, nil }) got, err := a.Ask(context.Background(), Request{}) if err != nil || got.Answers[0].Chosen[0] != "first" { t.Fatalf("Ask = %+v, %v", got, err) } // The TUI installs itself over the line front end at startup; the tool is // already built by then and must follow the swap. a.Use(func(context.Context, Request) (Response, error) { return Response{Answers: []Answer{{Chosen: []string{"second"}}}}, nil }) if got, _ := a.Ask(context.Background(), Request{}); got.Answers[0].Chosen[0] != "second" { t.Errorf("Ask = %+v, want the prompt installed last", got) } } func TestToolAnswersTheModel(t *testing.T) { var seen Request tool := Tool(func(_ context.Context, r Request) (Response, error) { seen = r return Response{Answers: []Answer{{Header: "Storage", Question: "Where?", Chosen: []string{"Postgres"}}}}, nil }) out, err := tool.Execute(context.Background(), json.RawMessage(`{"questions":[ {"header":"Storage","question":"Where?","options":[ {"label":"Postgres","description":"already running"}, {"label":"SQLite"}]}]}`)) if err != nil { t.Fatalf("Execute: %v", err) } if !strings.Contains(out, "Postgres") { t.Errorf("result does not carry the answer: %q", out) } if len(seen.Questions) != 1 || seen.Questions[0].Options[0].Description != "already running" { t.Errorf("the prompt saw %+v", seen) } } // A dismissal is not an answer, and reporting it as one would have the model // act on a decision nobody made. func TestToolReportsDismissalAsAnError(t *testing.T) { tool := Tool(func(context.Context, Request) (Response, error) { return Response{Dismissed: true}, nil }) out, err := tool.Execute(context.Background(), json.RawMessage(`{"questions":[ {"header":"Storage","question":"Where?","options":[{"label":"a"},{"label":"b"}]}]}`)) if err == nil { t.Fatalf("a dismissed question returned %q and no error", out) } if !strings.Contains(err.Error(), "pick the option you think best") { t.Errorf("the error should say what to do instead, got %q", err) } } // A call that cannot be drawn never reaches the user: the model is told to fix // it while the screen is still quiet. func TestToolValidatesBeforeAsking(t *testing.T) { called := false tool := Tool(func(context.Context, Request) (Response, error) { called = true return Response{}, nil }) if _, err := tool.Execute(context.Background(), json.RawMessage(`{"questions":[ {"header":"Storage","question":"Where?","options":[{"label":"only one"}]}]}`)); err == nil { t.Fatal("a one-option question was accepted") } if called { t.Error("the user was asked a question that could not be drawn") } } func TestToolWithoutAPromptSaysSoRatherThanPanicking(t *testing.T) { _, err := Tool(nil).Execute(context.Background(), json.RawMessage(`{"questions":[ {"header":"Storage","question":"Where?","options":[{"label":"a"},{"label":"b"}]}]}`)) if err == nil { t.Fatal("expected an error") } } func TestToolPassesTheErrorFromACancelledTurn(t *testing.T) { want := errors.New("turn cancelled") _, err := Tool(func(context.Context, Request) (Response, error) { return Response{}, want }).Execute(context.Background(), json.RawMessage(`{"questions":[ {"header":"Storage","question":"Where?","options":[{"label":"a"},{"label":"b"}]}]}`)) if !errors.Is(err, want) { t.Fatalf("err = %v, want the prompt's own error", err) } } // The schema is the only thing the model sees before it calls. A field // description that stops at the first comma is worse than none: it reads as a // complete instruction and is half of one. func TestSchemaKeepsWholeDescriptions(t *testing.T) { schema := string(Tool(nil).InputSchema()) for _, want := range []string{ "1 to 4 of them", "2 to 4 of them", "the consequence, not the label again", } { if !strings.Contains(schema, want) { t.Errorf("schema lost %q:\n%s", want, schema) } } }