1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "testing"
7
8 "github.com/bnema/zut/packages/core"
9)
10
11// TestPlanToolDecodesEachAction covers the six actions the schema advertises.
12// The tool only parses: state-dependent validation and result text belong to
13// core, so these tests assert exactly what the operation carries.
14func TestPlanToolDecodesEachAction(t *testing.T) {
15 explanation := "why"
16 tests := []struct {
17 name string
18 raw string
19 want core.PlanOperation
20 }{
21 {
22 name: "set",
23 raw: `{"action":"set","explanation":"why","steps":[{"step":"one","status":"pending"},{"step":"two"}]}`,
24 want: core.PlanOperation{
25 Action: "set",
26 Explanation: &explanation,
27 Steps: []core.PlanStep{{Step: "one", Status: core.PlanPending}, {Step: "two"}},
28 },
29 },
30 {
31 name: "add",
32 raw: `{"action":"add","steps":[{"step":"three","status":"in_progress"}]}`,
33 want: core.PlanOperation{
34 Action: "add",
35 Steps: []core.PlanStep{{Step: "three", Status: core.PlanInProgress}},
36 },
37 },
38 {
39 name: "update status",
40 raw: `{"action":"update","index":2,"status":"completed"}`,
41 want: core.PlanOperation{Action: "update", Index: 2, Status: core.PlanCompleted},
42 },
43 {
44 name: "update text",
45 raw: `{"action":"update","index":1,"step":"renamed"}`,
46 want: core.PlanOperation{Action: "update", Index: 1},
47 },
48 {
49 name: "remove",
50 raw: `{"action":"remove","index":3}`,
51 want: core.PlanOperation{Action: "remove", Index: 3},
52 },
53 {
54 name: "clear",
55 raw: `{"action":"clear"}`,
56 want: core.PlanOperation{Action: "clear"},
57 },
58 {
59 name: "show",
60 raw: `{"action":"show"}`,
61 want: core.PlanOperation{Action: "show"},
62 },
63 }
64 for _, tt := range tests {
65 t.Run(tt.name, func(t *testing.T) {
66 result, err := (&PlanTool{}).Execute(context.Background(), json.RawMessage(tt.raw), nil)
67 if err != nil {
68 t.Fatal(err)
69 }
70 if result.IsError {
71 t.Fatalf("result = %#v, want success", result)
72 }
73 op, ok := result.Details.(core.PlanOperation)
74 if !ok {
75 t.Fatalf("details = %T, want core.PlanOperation", result.Details)
76 }
77 if op.Action != tt.want.Action || op.Index != tt.want.Index || op.Status != tt.want.Status {
78 t.Fatalf("operation = %#v, want %#v", op, tt.want)
79 }
80 if (op.Explanation == nil) != (tt.want.Explanation == nil) {
81 t.Fatalf("explanation = %#v, want %#v", op.Explanation, tt.want.Explanation)
82 }
83 if op.Explanation != nil && *op.Explanation != *tt.want.Explanation {
84 t.Fatalf("explanation = %q, want %q", *op.Explanation, *tt.want.Explanation)
85 }
86 if len(op.Steps) != len(tt.want.Steps) {
87 t.Fatalf("steps = %#v, want %#v", op.Steps, tt.want.Steps)
88 }
89 for idx := range op.Steps {
90 if op.Steps[idx] != tt.want.Steps[idx] {
91 t.Fatalf("step %d = %#v, want %#v", idx, op.Steps[idx], tt.want.Steps[idx])
92 }
93 }
94 if tt.name == "update text" {
95 if op.Text == nil || *op.Text != "renamed" {
96 t.Fatalf("text = %#v, want renamed", op.Text)
97 }
98 } else if op.Text != nil {
99 t.Fatalf("text = %#v, want nil", op.Text)
100 }
101 })
102 }
103}
104
105// TestPlanToolLeavesStateDependentValuesToCore guards the ownership split: an
106// unknown status and an out-of-range index decode successfully and reach core,
107// which rejects them against the current plan.
108func TestPlanToolLeavesStateDependentValuesToCore(t *testing.T) {
109 result, err := (&PlanTool{}).Execute(context.Background(), json.RawMessage(
110 `{"action":"set","steps":[{"step":"one","status":"done"}]}`), nil)
111 if err != nil {
112 t.Fatal(err)
113 }
114 if result.IsError {
115 t.Fatalf("result = %#v, want decode to succeed", result)
116 }
117 op := result.Details.(core.PlanOperation)
118 if op.Steps[0].Status != core.PlanStepStatus("done") {
119 t.Fatalf("status = %q, want it passed through unchanged", op.Steps[0].Status)
120 }
121
122 result, err = (&PlanTool{}).Execute(context.Background(), json.RawMessage(`{"action":"remove","index":0}`), nil)
123 if err != nil {
124 t.Fatal(err)
125 }
126 if result.IsError {
127 t.Fatalf("result = %#v, want decode to succeed", result)
128 }
129}
130
131func TestPlanToolRejectsStructuralDecodeFailures(t *testing.T) {
132 tests := []struct {
133 name string
134 raw string
135 }{
136 {name: "missing action", raw: `{}`},
137 {name: "empty action", raw: `{"action":""}`},
138 {name: "unknown top-level field", raw: `{"action":"clear","unexpected":true}`},
139 {name: "unknown step field", raw: `{"action":"set","steps":[{"step":"one","unexpected":true}]}`},
140 {name: "step text missing", raw: `{"action":"set","steps":[{"status":"pending"}]}`},
141 {name: "trailing token", raw: `{"action":"clear"} trailing`},
142 {name: "malformed json", raw: `{"action":`},
143 {name: "wrong index type", raw: `{"action":"remove","index":"one"}`},
144 }
145 for _, tt := range tests {
146 t.Run(tt.name, func(t *testing.T) {
147 result, err := (&PlanTool{}).Execute(context.Background(), json.RawMessage(tt.raw), nil)
148 if err != nil {
149 t.Fatal(err)
150 }
151 if !result.IsError {
152 t.Fatalf("result = %#v, want tool error", result)
153 }
154 if result.Details != nil {
155 t.Fatalf("failed decode exposed details: %#v", result.Details)
156 }
157 })
158 }
159}
160
161func TestPlanToolSchemaShape(t *testing.T) {
162 var schema struct {
163 Properties map[string]struct {
164 Enum []string `json:"enum"`
165 Items *struct {
166 Required []string `json:"required"`
167 AdditionalProperties *bool `json:"additionalProperties"`
168 } `json:"items"`
169 } `json:"properties"`
170 Required []string `json:"required"`
171 AdditionalProperties *bool `json:"additionalProperties"`
172 }
173 if err := json.Unmarshal((&PlanTool{}).Schema(), &schema); err != nil {
174 t.Fatalf("schema is not valid JSON: %v", err)
175 }
176 if len(schema.Required) != 1 || schema.Required[0] != "action" {
177 t.Fatalf("required = %#v, want [action]", schema.Required)
178 }
179 if schema.AdditionalProperties == nil || *schema.AdditionalProperties {
180 t.Fatalf("top-level additionalProperties = %#v, want false", schema.AdditionalProperties)
181 }
182 wantActions := []string{"set", "add", "update", "remove", "clear", "show"}
183 if got := schema.Properties["action"].Enum; len(got) != len(wantActions) {
184 t.Fatalf("action enum = %#v, want %#v", got, wantActions)
185 }
186 items := schema.Properties["steps"].Items
187 if items == nil || len(items.Required) != 1 || items.Required[0] != "step" {
188 t.Fatalf("step schema = %#v, want required [step]", items)
189 }
190 if items.AdditionalProperties == nil || *items.AdditionalProperties {
191 t.Fatalf("step additionalProperties = %#v, want false", items.AdditionalProperties)
192 }
193}
194
195func TestPlanToolKeepsArgumentRewriteDisabled(t *testing.T) {
196 var tool core.ToolArgumentRewritePolicy = &PlanTool{}
197 if tool.AllowArgumentRewrite() {
198 t.Fatal("AllowArgumentRewrite = true, want false so guards cannot rewrite the checklist")
199 }
200}