Skip to content

Commit fe4095c

Browse files
TeoSlayerteovlclaude
authored
feat(pilotctl): add review command with subject+rating validation (PILOT-410) (#276)
* feat(appstore): catalogue list shows name+headline only with view pointer (PILOT-404, PILOT-405) Replace the verbose multi-line catalogue listing with a compact one-line-per-app format (<id> <description>) followed by a 'Run pilotctl appstore view <id> for full details.' hint. JSON output is unchanged. Three tests added to cover the new format, the view-pointer hint, and JSON pass-through. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(pilotctl): add review command with subject+rating validation (PILOT-410) Wire cmdReview into main dispatch and commandHelp (review.go was present but unwired). Add zz_review_test.go covering: valid subject, subject=pilot, rating 1/3/5, rating+text combined, JSON envelope, missing subject, rating=0, rating=6, non-integer rating, --help. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Teodor Calin <teodor@vulturelabs.io> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 129af20 commit fe4095c

2 files changed

Lines changed: 179 additions & 0 deletions

File tree

cmd/pilotctl/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,6 +1219,8 @@ Reliability caveats (current implementation):
12191219
// falls through pilotctl's per-command help intercept and
12201220
// prints "No specific help" — confusing for an RC-shipped CLI.
12211221
"appstore": AppStoreHelpText,
1222+
1223+
"review": reviewHelpText,
12221224
}
12231225

12241226
// printCommandHelp prints the help text for a command and exits.
@@ -1422,6 +1424,10 @@ dispatch:
14221424
cmdAppStore(cmdArgs)
14231425
return
14241426

1427+
case "review":
1428+
cmdReview(cmdArgs)
1429+
return
1430+
14251431
// Bootstrap
14261432
case "init":
14271433
cmdInit(cmdArgs)

cmd/pilotctl/zz_review_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package main
4+
5+
import (
6+
"encoding/json"
7+
"strings"
8+
"testing"
9+
)
10+
11+
// --- unit tests: cmdReview (call directly, no os.Exit paths) ---
12+
13+
// TestReviewValidSubjectNoFlags: bare subject, no optional flags → exit 0 + confirmation.
14+
func TestReviewValidSubjectNoFlags(t *testing.T) {
15+
t.Parallel()
16+
stdout, _, code := runCLI(t, []string{"review", "io.pilot.cosift"}, nil)
17+
if code != 0 {
18+
t.Errorf("exit=%d, want 0", code)
19+
}
20+
if !strings.Contains(stdout, "io.pilot.cosift") {
21+
t.Errorf("confirmation missing subject: %q", stdout)
22+
}
23+
if !strings.Contains(strings.ToLower(stdout), "thank") {
24+
t.Errorf("confirmation missing thank-you: %q", stdout)
25+
}
26+
}
27+
28+
// TestReviewSubjectPilot: reserved subject "pilot" is valid.
29+
func TestReviewSubjectPilot(t *testing.T) {
30+
t.Parallel()
31+
stdout, _, code := runCLI(t, []string{"review", "pilot"}, nil)
32+
if code != 0 {
33+
t.Errorf("exit=%d, want 0", code)
34+
}
35+
if !strings.Contains(stdout, "pilot") {
36+
t.Errorf("confirmation missing 'pilot': %q", stdout)
37+
}
38+
}
39+
40+
// TestReviewWithRating: valid rating (1–5) is accepted.
41+
func TestReviewWithRating(t *testing.T) {
42+
t.Parallel()
43+
for _, rating := range []string{"1", "3", "5"} {
44+
rating := rating
45+
t.Run("rating="+rating, func(t *testing.T) {
46+
t.Parallel()
47+
stdout, _, code := runCLI(t, []string{"review", "pilot", "--rating", rating}, nil)
48+
if code != 0 {
49+
t.Errorf("rating=%s: exit=%d, want 0", rating, code)
50+
}
51+
if !strings.Contains(stdout, "pilot") {
52+
t.Errorf("rating=%s: missing subject in output: %q", rating, stdout)
53+
}
54+
})
55+
}
56+
}
57+
58+
// TestReviewWithRatingAndText: both optional flags together.
59+
func TestReviewWithRatingAndText(t *testing.T) {
60+
t.Parallel()
61+
stdout, _, code := runCLI(t, []string{
62+
"review", "io.pilot.cosift",
63+
"--rating", "4",
64+
"--text", "Very useful",
65+
}, nil)
66+
if code != 0 {
67+
t.Errorf("exit=%d, want 0", code)
68+
}
69+
if !strings.Contains(stdout, "io.pilot.cosift") {
70+
t.Errorf("missing subject: %q", stdout)
71+
}
72+
}
73+
74+
// TestReviewJSONOutput: --json wraps the result in the standard envelope.
75+
func TestReviewJSONOutput(t *testing.T) {
76+
t.Parallel()
77+
stdout, _, code := runCLI(t, []string{
78+
"--json", "review", "pilot", "--rating", "5", "--text", "great",
79+
}, nil)
80+
if code != 0 {
81+
t.Errorf("exit=%d, want 0", code)
82+
}
83+
var env map[string]interface{}
84+
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
85+
t.Fatalf("json parse: %v (output: %q)", err, stdout)
86+
}
87+
if env["status"] != "ok" {
88+
t.Errorf("status=%v, want ok", env["status"])
89+
}
90+
data, ok := env["data"].(map[string]interface{})
91+
if !ok {
92+
t.Fatalf("data field missing or wrong type: %v", env["data"])
93+
}
94+
if data["subject"] != "pilot" {
95+
t.Errorf("subject=%v, want pilot", data["subject"])
96+
}
97+
if data["submitted"] != true {
98+
t.Errorf("submitted=%v, want true", data["submitted"])
99+
}
100+
if rating, ok := data["rating"].(float64); !ok || rating != 5 {
101+
t.Errorf("rating=%v, want 5", data["rating"])
102+
}
103+
if data["text"] != "great" {
104+
t.Errorf("text=%v, want 'great'", data["text"])
105+
}
106+
}
107+
108+
// --- invalid input tests (use runCLI so os.Exit is safe) ---
109+
110+
// TestReviewMissingSubject: no positional arg → exit non-zero with usage hint.
111+
func TestReviewMissingSubject(t *testing.T) {
112+
t.Parallel()
113+
_, stderr, code := runCLI(t, []string{"review"}, nil)
114+
if code == 0 {
115+
t.Error("expected non-zero exit when subject is missing")
116+
}
117+
if !strings.Contains(stderr, "subject") && !strings.Contains(stderr, "required") &&
118+
!strings.Contains(stderr, "usage") && !strings.Contains(stderr, "pilot") {
119+
t.Errorf("expected usage hint in stderr, got: %q", stderr)
120+
}
121+
}
122+
123+
// TestReviewRatingZero: --rating 0 is out of range.
124+
func TestReviewRatingZero(t *testing.T) {
125+
t.Parallel()
126+
_, stderr, code := runCLI(t, []string{"review", "pilot", "--rating", "0"}, nil)
127+
if code == 0 {
128+
t.Error("expected non-zero exit for --rating 0")
129+
}
130+
if !strings.Contains(stderr, "1") || !strings.Contains(stderr, "5") {
131+
t.Errorf("expected range hint (1–5) in stderr, got: %q", stderr)
132+
}
133+
}
134+
135+
// TestReviewRatingSix: --rating 6 is out of range.
136+
func TestReviewRatingSix(t *testing.T) {
137+
t.Parallel()
138+
_, stderr, code := runCLI(t, []string{"review", "pilot", "--rating", "6"}, nil)
139+
if code == 0 {
140+
t.Error("expected non-zero exit for --rating 6")
141+
}
142+
if !strings.Contains(stderr, "1") || !strings.Contains(stderr, "5") {
143+
t.Errorf("expected range hint (1–5) in stderr, got: %q", stderr)
144+
}
145+
}
146+
147+
// TestReviewRatingNonInteger: --rating xyz is not a number.
148+
func TestReviewRatingNonInteger(t *testing.T) {
149+
t.Parallel()
150+
_, stderr, code := runCLI(t, []string{"review", "pilot", "--rating", "xyz"}, nil)
151+
if code == 0 {
152+
t.Error("expected non-zero exit for non-integer --rating")
153+
}
154+
if !strings.Contains(stderr, "integer") && !strings.Contains(stderr, "rating") {
155+
t.Errorf("expected error mentioning rating, got: %q", stderr)
156+
}
157+
}
158+
159+
// TestReviewHelpFlag: --help exits 0 and prints usage.
160+
func TestReviewHelpFlag(t *testing.T) {
161+
t.Parallel()
162+
stdout, stderr, code := runCLI(t, []string{"review", "--help"}, nil)
163+
if code != 0 {
164+
t.Errorf("--help should exit 0, got %d", code)
165+
}
166+
full := stdout + stderr
167+
if !strings.Contains(full, "review") {
168+
t.Errorf("help text missing 'review': %s", full)
169+
}
170+
if !strings.Contains(full, "--rating") {
171+
t.Errorf("help text missing --rating: %s", full)
172+
}
173+
}

0 commit comments

Comments
 (0)