Cover validation command lifecycle and grammar
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# PromptKit request validation implementation plan
|
||||
|
||||
Status: Stages 1–4 complete; implementation is ongoing.
|
||||
Status: Stages 1–5 complete; implementation is ongoing.
|
||||
|
||||
## Authority, scope, and baseline
|
||||
|
||||
@@ -311,6 +311,19 @@ YAML output into a fresh value; avoid output-prefix or key-order assertions.
|
||||
**Complete when:** the new capability obeys the selected standard grammar,
|
||||
availability, capture, and invocation-lifetime contracts.
|
||||
|
||||
**Completed:** 2026-10-12. Added validation-only availability, namespace
|
||||
collision and root-only placement coverage; construction/install/help/version
|
||||
callback timing; eager callback and selector capture; exact standard request,
|
||||
selector, empty-presence, repeated-assignment, first-`=` file, and duplicate
|
||||
last-wins behavior; repeated-invocation isolation; usage preflight; input-check
|
||||
failure ordering; and nil-pointer zero-value factories. A missing required
|
||||
captured selector is a usage outcome before `Suite.CheckInput`, state creation,
|
||||
or provider work. Under the existing `CaptureFlags`/`prepareSuiteInput` flow,
|
||||
`Suite.NewInput` runs before `FlagValues.ApplyTo` reports that missing selector.
|
||||
Focused validation command tests and
|
||||
`GOTOOLCHAIN=local go test -count=1 ./...` passed from
|
||||
`integrations/ckprompt`.
|
||||
|
||||
## Stage 6: Cover native command outcomes and report delivery
|
||||
|
||||
**Primary files:** validation command tests and small real-engine fixtures.
|
||||
|
||||
@@ -3,8 +3,10 @@ package ckprompt
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
@@ -88,3 +90,227 @@ func TestValidationCommandComposesWithRequirementsShow(t *testing.T) {
|
||||
t.Fatalf("validation confirmation = %#v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationCommandIsAvailableIndependentlyAndOwnsItsFamily(t *testing.T) {
|
||||
validationOnly := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ValidateRequest: func(context.Context, ValidationRequest, struct{}, struct{}) (commandkit.Acquired[struct{}], error) {
|
||||
return commandkit.Acquired[struct{}]{}, nil
|
||||
}}))
|
||||
var help bytes.Buffer
|
||||
outcome := validationOnly.Execute(context.Background(), []string{"help", "promptkit", "requirements"}, commandkit.Streams{Out: &help, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(help.String(), "validate") || strings.Contains(help.String(), "show") {
|
||||
t.Fatalf("validation-only availability = (%#v, %q)", outcome, help.String())
|
||||
}
|
||||
|
||||
declaration := Command(Suite[struct{}, struct{}]{ValidateRequest: func(context.Context, ValidationRequest, struct{}, struct{}) (commandkit.Acquired[struct{}], error) {
|
||||
return commandkit.Acquired[struct{}]{}, nil
|
||||
}})
|
||||
other := commandkit.Group("promptkit", "An application command tree.", commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "child", Summary: "A child."}))
|
||||
app := newPromptTestApp(t, other)
|
||||
if err := app.Register(declaration); err == nil {
|
||||
t.Fatal("validation-only command installed over an existing PromptKit namespace")
|
||||
}
|
||||
if err := newPromptTestApp(t, declaration).Register(other); err == nil {
|
||||
t.Fatal("later application command installed over validation-only PromptKit namespace")
|
||||
}
|
||||
if _, err := commandkit.New(commandkit.Identity{Name: "nested"}, commandkit.Group("outer", "Outer group.", declaration)); err == nil {
|
||||
t.Fatal("validation-only root family was accepted beneath another group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationCommandConstructionAndCaptureLifecycle(t *testing.T) {
|
||||
type input struct{ campaign commandkit.Supplied[string] }
|
||||
type state struct{}
|
||||
newInputCalls, newStateCalls, checkCalls := 0, 0, 0
|
||||
buildCalls, loadCalls := 0, 0
|
||||
engine := adapterEngine(t)
|
||||
oldSelector := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Required: true}, (*string)(nil), parseDefinitionString,
|
||||
func(target *input, value commandkit.Supplied[string]) { target.campaign = value })
|
||||
newSelector := commandkit.ValueFlag(commandkit.FlagMeta{Name: "replacement"}, (*string)(nil), parseDefinitionString,
|
||||
func(*input, commandkit.Supplied[string]) {})
|
||||
suite := WithRequestValidation(Suite[input, state]{
|
||||
NewInput: func() input { newInputCalls++; return input{} },
|
||||
NewState: func() state { newStateCalls++; return state{} },
|
||||
Selectors: []commandkit.Flag[input]{oldSelector},
|
||||
CheckInput: func(_ ckconfig.Selection, value input) error {
|
||||
checkCalls++
|
||||
if !value.campaign.Set || value.campaign.Value != "spring" {
|
||||
return errors.New("captured campaign selector was not applied")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}, func(context.Context, ckconfig.Selection, input, state) (*promptkit.Engine, error) {
|
||||
loadCalls++
|
||||
return engine, nil
|
||||
}, func(_ context.Context, request ValidationRequest, _ input, _ state) (promptkit.RunRequest, error) {
|
||||
buildCalls++
|
||||
return request.Request, nil
|
||||
})
|
||||
declaration := Command(suite)
|
||||
if newInputCalls != 0 || newStateCalls != 0 || checkCalls != 0 || buildCalls != 0 || loadCalls != 0 {
|
||||
t.Fatal("command construction invoked application or adapter callbacks")
|
||||
}
|
||||
|
||||
// Command captured the original callback and binding metadata.
|
||||
suite.ValidateRequest = func(context.Context, ValidationRequest, input, state) (commandkit.Acquired[struct{}], error) {
|
||||
return commandkit.Acquired[struct{}]{}, errors.New("replacement callback was captured")
|
||||
}
|
||||
suite.Selectors[0] = newSelector
|
||||
app := newPromptTestApp(t, declaration)
|
||||
for _, args := range [][]string{{"help", "promptkit", "requirements", "validate"}, {"version"}} {
|
||||
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("construction lifecycle command %v = %#v", args, outcome)
|
||||
}
|
||||
}
|
||||
if newInputCalls != 0 || newStateCalls != 0 || checkCalls != 0 || buildCalls != 0 || loadCalls != 0 {
|
||||
t.Fatal("installation, help, or version invoked application or adapter callbacks")
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "empty", "--profile", "default", "--campaign", "spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || newInputCalls != 1 || newStateCalls != 1 || checkCalls != 1 || buildCalls != 1 || loadCalls != 1 {
|
||||
t.Fatalf("captured command = %#v, callback counts input/state/check/build/load=%d/%d/%d/%d/%d", outcome, newInputCalls, newStateCalls, checkCalls, buildCalls, loadCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationCommandPreservesPresenceAndRepeatedAssignmentsPerInvocation(t *testing.T) {
|
||||
type input struct{ campaign commandkit.Supplied[string] }
|
||||
type state struct{ sequence int }
|
||||
campaignFlag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign"}, (*string)(nil), parseDefinitionString,
|
||||
func(target *input, value commandkit.Supplied[string]) { target.campaign = value })
|
||||
var requests []ValidationRequest
|
||||
var selectedInputs []input
|
||||
newInputCalls, checkCalls, newStateCalls := 0, 0, 0
|
||||
suite := Suite[input, state]{
|
||||
NewInput: func() input { newInputCalls++; return input{} },
|
||||
NewState: func() state { newStateCalls++; return state{sequence: newStateCalls} },
|
||||
Selectors: []commandkit.Flag[input]{campaignFlag},
|
||||
CheckInput: func(ckconfig.Selection, input) error { checkCalls++; return nil },
|
||||
ValidateRequest: func(_ context.Context, request ValidationRequest, value input, _ state) (commandkit.Acquired[struct{}], error) {
|
||||
requests = append(requests, request)
|
||||
selectedInputs = append(selectedInputs, value)
|
||||
return commandkit.Acquired[struct{}]{}, nil
|
||||
},
|
||||
}
|
||||
app := newPromptTestApp(t, Command(suite))
|
||||
|
||||
firstArgs := []string{
|
||||
"promptkit", "requirements", "validate", "briefing", "--config", "", "--overlay", "",
|
||||
"--version", "", "--profile", "", "--session-id", "", "--campaign", "spring",
|
||||
"--var", "=", "--var", "phrase= two words, three ", "--var", "duplicate=first", "--var", "duplicate=last=value",
|
||||
"--input", "=", "--input", "file= path, with commas=and=equals ", "--input", "duplicate=first.txt", "--input", "duplicate=last=path.txt",
|
||||
}
|
||||
outcome := app.Execute(context.Background(), firstArgs, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || len(requests) != 1 {
|
||||
t.Fatalf("first repeated assignment invocation = %#v, requests=%d", outcome, len(requests))
|
||||
}
|
||||
first := requests[0]
|
||||
if first.Selection.Config.Value != "" || !first.Selection.Config.Set || first.Selection.Overlay.Value != "" || !first.Selection.Overlay.Set {
|
||||
t.Errorf("explicit empty selection = %#v", first.Selection)
|
||||
}
|
||||
if first.Request.PromptVersion != "" || first.Request.ProfileID != "" || first.Request.SessionID != "" {
|
||||
t.Errorf("empty scalar values = %#v", first.Request)
|
||||
}
|
||||
wantVars := map[string]string{"": "", "phrase": " two words, three ", "duplicate": "last=value"}
|
||||
if !reflect.DeepEqual(first.Request.Vars, wantVars) {
|
||||
t.Errorf("request vars = %#v, want %#v", first.Request.Vars, wantVars)
|
||||
}
|
||||
wantInputs := map[string]promptkit.ArtifactRef{
|
||||
"": promptkit.File(""),
|
||||
"file": promptkit.File(" path, with commas=and=equals "),
|
||||
"duplicate": promptkit.File("last=path.txt"),
|
||||
}
|
||||
if !reflect.DeepEqual(first.Request.Inputs, wantInputs) {
|
||||
t.Errorf("request inputs = %#v, want %#v", first.Request.Inputs, wantInputs)
|
||||
}
|
||||
if !selectedInputs[0].campaign.Set || selectedInputs[0].campaign.Value != "spring" {
|
||||
t.Errorf("first selector input = %#v", selectedInputs[0])
|
||||
}
|
||||
|
||||
outcome = app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || len(requests) != 2 {
|
||||
t.Fatalf("second invocation = %#v, requests=%d", outcome, len(requests))
|
||||
}
|
||||
second := requests[1]
|
||||
if second.Selection.Config.Set || second.Selection.Overlay.Set || second.Request.PromptVersion != "" || second.Request.ProfileID != "" || second.Request.SessionID != "" || len(second.Request.Vars) != 0 || len(second.Request.Inputs) != 0 {
|
||||
t.Errorf("first invocation values leaked into second request: %#v", second)
|
||||
}
|
||||
if selectedInputs[1].campaign.Set || selectedInputs[1].campaign.Value != "" {
|
||||
t.Errorf("first selector leaked into second input: %#v", selectedInputs[1])
|
||||
}
|
||||
if newInputCalls != 2 || checkCalls != 2 || newStateCalls != 2 {
|
||||
t.Errorf("per-invocation counts input/check/state = %d/%d/%d", newInputCalls, checkCalls, newStateCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationCommandUsageAndInputFailuresStopProviderWork(t *testing.T) {
|
||||
type input struct{ campaign commandkit.Supplied[string] }
|
||||
selector := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Required: true}, (*string)(nil), parseDefinitionString,
|
||||
func(target *input, value commandkit.Supplied[string]) { target.campaign = value })
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "missing operand", args: []string{"promptkit", "requirements", "validate"}},
|
||||
{name: "extra operand", args: []string{"promptkit", "requirements", "validate", "briefing", "extra", "--campaign", "spring"}},
|
||||
{name: "unsupported format", args: []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring", "--format", "text"}},
|
||||
{name: "unknown flag", args: []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring", "--unknown", "value"}},
|
||||
{name: "malformed variable before valid assignment", args: []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring", "--var", "broken", "--var", "mode=full"}},
|
||||
{name: "malformed input before valid assignment", args: []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring", "--input", "broken", "--input", "notes=file.txt"}},
|
||||
{name: "missing required selector", args: []string{"promptkit", "requirements", "validate", "briefing"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
newInputCalls, checkCalls, newStateCalls, providerCalls := 0, 0, 0, 0
|
||||
suite := Suite[input, struct{}]{
|
||||
NewInput: func() input { newInputCalls++; return input{} },
|
||||
NewState: func() struct{} { newStateCalls++; return struct{}{} },
|
||||
Selectors: []commandkit.Flag[input]{selector},
|
||||
CheckInput: func(ckconfig.Selection, input) error { checkCalls++; return nil },
|
||||
ValidateRequest: func(context.Context, ValidationRequest, input, struct{}) (commandkit.Acquired[struct{}], error) {
|
||||
providerCalls++
|
||||
return commandkit.Acquired[struct{}]{}, nil
|
||||
},
|
||||
}
|
||||
outcome := newPromptTestApp(t, Command(suite)).Execute(context.Background(), test.args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
wantInputCalls := 0
|
||||
if test.name == "missing required selector" {
|
||||
// prepareSuiteInput initializes the caller input before core validates
|
||||
// captured required bindings; CheckInput and provider work still stop.
|
||||
wantInputCalls = 1
|
||||
}
|
||||
if outcome.Kind != commandkit.OutcomeUsage || outcome.Status != 2 || newInputCalls != wantInputCalls || checkCalls != 0 || newStateCalls != 0 || providerCalls != 0 {
|
||||
t.Fatalf("preflight failure = %#v, input/check/state/provider=%d/%d/%d/%d", outcome, newInputCalls, checkCalls, newStateCalls, providerCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
newStateCalls, providerCalls := 0, 0
|
||||
suite := Suite[input, struct{}]{
|
||||
NewInput: func() input { return input{} },
|
||||
NewState: func() struct{} { newStateCalls++; return struct{}{} },
|
||||
Selectors: []commandkit.Flag[input]{selector},
|
||||
CheckInput: func(ckconfig.Selection, input) error { return errors.New("input rejected") },
|
||||
ValidateRequest: func(context.Context, ValidationRequest, input, struct{}) (commandkit.Acquired[struct{}], error) {
|
||||
providerCalls++
|
||||
return commandkit.Acquired[struct{}]{}, nil
|
||||
},
|
||||
}
|
||||
outcome := newPromptTestApp(t, Command(suite)).Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing", "--campaign", "spring"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage || newStateCalls != 0 || providerCalls != 0 {
|
||||
t.Fatalf("input check failure reached state/provider: %#v state=%d provider=%d", outcome, newStateCalls, providerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationCommandSuppliesZeroValueFactoriesIncludingNilPointers(t *testing.T) {
|
||||
var gotInput *int
|
||||
var gotState *int
|
||||
suite := Suite[*int, *int]{ValidateRequest: func(_ context.Context, _ ValidationRequest, input, state *int) (commandkit.Acquired[struct{}], error) {
|
||||
gotInput, gotState = input, state
|
||||
return commandkit.Acquired[struct{}]{}, nil
|
||||
}}
|
||||
app := newPromptTestApp(t, Command(suite))
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || gotInput != nil || gotState != nil {
|
||||
t.Fatalf("omitted factories = outcome %#v, input=%v state=%v", outcome, gotInput, gotState)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user