v0.51.0 a9d674e

plan_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close