Skip to content

Commit 749ccd3

Browse files
committed
test: raise cmd/crucible coverage above the release threshold
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent 451b92b commit 749ccd3

3 files changed

Lines changed: 323 additions & 0 deletions

File tree

cmd/crucible/cmd_error_test.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// missingPath is a path under a fresh temp dir guaranteed not to exist, so
11+
// readInput's os.ReadFile fails and exercises every subcommand's load-error
12+
// branch.
13+
func missingPath(t *testing.T) string {
14+
t.Helper()
15+
return filepath.Join(t.TempDir(), "does-not-exist.json")
16+
}
17+
18+
// TestSubcommands_LoadError confirms each IR-loading subcommand returns
19+
// exitError with a command-prefixed message when the IR file is missing.
20+
func TestSubcommands_LoadError(t *testing.T) {
21+
missing := missingPath(t)
22+
cases := []struct {
23+
name string
24+
args []string
25+
prefix string
26+
}{
27+
{"lint", []string{"lint", missing}, "crucible lint:"},
28+
{"render", []string{"render", missing}, "crucible render:"},
29+
{"validate", []string{"validate", missing}, "crucible validate:"},
30+
{"eject", []string{"eject", missing}, "crucible eject:"},
31+
}
32+
for _, tc := range cases {
33+
t.Run(tc.name, func(t *testing.T) {
34+
code, _, errOut := runCmd(tc.args...)
35+
if code != exitError {
36+
t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitError, errOut)
37+
}
38+
if !strings.Contains(errOut, tc.prefix) {
39+
t.Fatalf("stderr missing %q: %s", tc.prefix, errOut)
40+
}
41+
})
42+
}
43+
}
44+
45+
// TestSubcommands_QuenchError confirms the lint, render, and validate commands
46+
// surface a quench failure (an undeclared transition target panics Quench,
47+
// which quench recovers into an error) as exitError on stderr.
48+
func TestSubcommands_QuenchError(t *testing.T) {
49+
cases := []struct {
50+
name string
51+
args []string
52+
}{
53+
{"lint", []string{"lint", "testdata/quench_fail.json"}},
54+
{"render", []string{"render", "testdata/quench_fail.json"}},
55+
{"validate", []string{"validate", "testdata/quench_fail.json"}},
56+
}
57+
for _, tc := range cases {
58+
t.Run(tc.name, func(t *testing.T) {
59+
code, _, errOut := runCmd(tc.args...)
60+
if code != exitError {
61+
t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitError, errOut)
62+
}
63+
if !strings.Contains(errOut, "quench:") {
64+
t.Fatalf("stderr missing quench error: %s", errOut)
65+
}
66+
})
67+
}
68+
}
69+
70+
// TestParseSingleArg_FlagParseError confirms the single-arg commands (lint,
71+
// validate) return a usage exit code when flag parsing fails on an unknown flag.
72+
func TestParseSingleArg_FlagParseError(t *testing.T) {
73+
for _, cmd := range []string{"lint", "validate"} {
74+
t.Run(cmd, func(t *testing.T) {
75+
code, _, _ := runCmd(cmd, "testdata/clean.json", "-nope")
76+
if code != exitUsage {
77+
t.Fatalf("exit = %d, want %d", code, exitUsage)
78+
}
79+
})
80+
}
81+
}
82+
83+
// TestRender_FlagParseError confirms an unknown flag fails flag parsing and
84+
// returns a usage exit code.
85+
func TestRender_FlagParseError(t *testing.T) {
86+
code, _, _ := runCmd("render", "testdata/clean.json", "-nope")
87+
if code != exitUsage {
88+
t.Fatalf("exit = %d, want %d", code, exitUsage)
89+
}
90+
}
91+
92+
// TestRender_WrongArgCount confirms render rejects a missing IR path with a
93+
// usage message.
94+
func TestRender_WrongArgCount(t *testing.T) {
95+
code, _, errOut := runCmd("render")
96+
if code != exitUsage {
97+
t.Fatalf("exit = %d, want %d", code, exitUsage)
98+
}
99+
if !strings.Contains(errOut, "usage: crucible render") {
100+
t.Fatalf("stderr missing usage: %s", errOut)
101+
}
102+
}
103+
104+
// TestEject_FlagParseError confirms eject rejects an unknown flag with a usage
105+
// exit code.
106+
func TestEject_FlagParseError(t *testing.T) {
107+
code, _, _ := runCmd("eject", "testdata/clean.json", "-nope")
108+
if code != exitUsage {
109+
t.Fatalf("exit = %d, want %d", code, exitUsage)
110+
}
111+
}
112+
113+
// TestEject_WrongArgCount confirms eject rejects a missing IR path with a usage
114+
// message.
115+
func TestEject_WrongArgCount(t *testing.T) {
116+
code, _, errOut := runCmd("eject")
117+
if code != exitUsage {
118+
t.Fatalf("exit = %d, want %d", code, exitUsage)
119+
}
120+
if !strings.Contains(errOut, "usage: crucible eject") {
121+
t.Fatalf("stderr missing usage: %s", errOut)
122+
}
123+
}
124+
125+
// TestEject_DefaultPackageToStdout confirms eject with no -package flag writes
126+
// generated source to stdout (the gen-default package-name branch).
127+
func TestEject_DefaultPackageToStdout(t *testing.T) {
128+
code, out, errOut := runCmd("eject", "testdata/clean.json")
129+
if code != exitOK {
130+
t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut)
131+
}
132+
if !strings.Contains(out, "package ") {
133+
t.Fatalf("ejected source missing a package decl:\n%s", out)
134+
}
135+
}
136+
137+
// TestEject_WriteOutputError confirms a write to an unwritable output path is
138+
// reported as exitError. The output path names a regular file as a parent
139+
// directory, so os.WriteFile fails.
140+
func TestEject_WriteOutputError(t *testing.T) {
141+
dir := t.TempDir()
142+
notDir := filepath.Join(dir, "afile")
143+
if err := os.WriteFile(notDir, []byte("x"), 0o644); err != nil {
144+
t.Fatalf("seed file: %v", err)
145+
}
146+
out := filepath.Join(notDir, "gen.go") // parent is a file, not a dir
147+
code, _, errOut := runCmd("eject", "testdata/clean.json", "-o", out)
148+
if code != exitError {
149+
t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitError, errOut)
150+
}
151+
if !strings.Contains(errOut, "write output") {
152+
t.Fatalf("stderr missing write error: %s", errOut)
153+
}
154+
}
155+
156+
// TestDiff_FlagParseError confirms diff rejects an unknown flag with a usage
157+
// exit code.
158+
func TestDiff_FlagParseError(t *testing.T) {
159+
code, _, _ := runCmd("diff", "-nope", "testdata/old.json", "testdata/new_minor.json")
160+
if code != exitUsage {
161+
t.Fatalf("exit = %d, want %d", code, exitUsage)
162+
}
163+
}
164+
165+
// TestDiff_ReadErrors confirms diff reports a missing old or new IR file as
166+
// exitError, each with its own stderr prefix.
167+
func TestDiff_ReadErrors(t *testing.T) {
168+
missing := missingPath(t)
169+
cases := []struct {
170+
name string
171+
args []string
172+
prefix string
173+
}{
174+
{"old missing", []string{"diff", missing, "testdata/new_minor.json"}, "read old:"},
175+
{"new missing", []string{"diff", "testdata/old.json", missing}, "read new:"},
176+
}
177+
for _, tc := range cases {
178+
t.Run(tc.name, func(t *testing.T) {
179+
code, _, errOut := runCmd(tc.args...)
180+
if code != exitError {
181+
t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitError, errOut)
182+
}
183+
if !strings.Contains(errOut, tc.prefix) {
184+
t.Fatalf("stderr missing %q: %s", tc.prefix, errOut)
185+
}
186+
})
187+
}
188+
}
189+
190+
// TestDiff_MalformedInput confirms diff reports a malformed IR document as
191+
// exitError via the DiffJSON decode failure.
192+
func TestDiff_MalformedInput(t *testing.T) {
193+
code, _, errOut := runCmd("diff", "testdata/malformed.json", "testdata/old.json")
194+
if code != exitError {
195+
t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitError, errOut)
196+
}
197+
if !strings.Contains(errOut, "crucible diff:") {
198+
t.Fatalf("stderr missing diff error: %s", errOut)
199+
}
200+
}
201+
202+
// TestHelp confirms the help variants print usage to stdout and exit zero.
203+
func TestHelp(t *testing.T) {
204+
for _, args := range [][]string{{"-h"}, {"--help"}, {"help"}} {
205+
code, out, _ := runCmd(args...)
206+
if code != exitOK {
207+
t.Fatalf("%v exit = %d, want %d", args, code, exitOK)
208+
}
209+
if !strings.Contains(out, "crucible - headless tooling") {
210+
t.Fatalf("%v help output missing banner:\n%s", args, out)
211+
}
212+
}
213+
}
214+
215+
// TestReorderArgs_DoubleDashTerminator confirms a bare "--" terminates flag
216+
// processing so following tokens are treated as positional. render -- <path>
217+
// keeps the default mermaid format and renders the file.
218+
func TestReorderArgs_DoubleDashTerminator(t *testing.T) {
219+
got := reorderArgs([]string{"--", "-format", "ir.json"})
220+
want := []string{"-format", "ir.json"}
221+
if len(got) != len(want) {
222+
t.Fatalf("reorderArgs returned %v, want %v", got, want)
223+
}
224+
for i := range want {
225+
if got[i] != want[i] {
226+
t.Fatalf("reorderArgs[%d] = %q, want %q", i, got[i], want[i])
227+
}
228+
}
229+
}
230+
231+
// TestComposite_ExercisesDeepWalk renders and ejects a compound + parallel IR
232+
// whose states carry entry/exit/done actions, entry/exit assigns, an invoke, a
233+
// composite guard expression, children, and regions. This drives the deep
234+
// branches of walkState/walkTransition (the stub registry must enumerate every
235+
// referenced behavior or Quench panics).
236+
func TestComposite_ExercisesDeepWalk(t *testing.T) {
237+
t.Run("render", func(t *testing.T) {
238+
code, out, errOut := runCmd("render", "testdata/composite.json")
239+
if code != exitOK {
240+
t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut)
241+
}
242+
if !strings.Contains(out, "stateDiagram-v2") {
243+
t.Fatalf("render output missing diagram:\n%s", out)
244+
}
245+
})
246+
t.Run("eject parses", func(t *testing.T) {
247+
code, out, errOut := runCmd("eject", "testdata/composite.json")
248+
if code != exitOK {
249+
t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut)
250+
}
251+
// Behaviors reachable through the deep walk (region guards, invoke
252+
// services, exit/done actions, nested-state effects) must appear in the
253+
// generated stubs.
254+
for _, want := range []string{"fetchWork", "logExit", "logDone", "regionGuard", "notify"} {
255+
if !strings.Contains(out, want) {
256+
t.Errorf("ejected source missing reference %q", want)
257+
}
258+
}
259+
})
260+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{
2+
"schemaVersion": "1.0",
3+
"name": "composite",
4+
"states": [
5+
{
6+
"name": "active",
7+
"onEntry": [{ "name": "logEntry" }],
8+
"onExit": [{ "name": "logExit" }],
9+
"onDone": [{ "name": "logDone" }],
10+
"onEntryAssign": [{ "name": "seedCtx" }],
11+
"onExitAssign": [{ "name": "clearCtx" }],
12+
"initialChild": "working",
13+
"children": [
14+
{
15+
"name": "working",
16+
"transitions": [
17+
{
18+
"from": "working",
19+
"to": "review",
20+
"on": "submit",
21+
"guards": [{ "name": "canSubmit" }],
22+
"effects": [{ "name": "notify" }],
23+
"assigns": [{ "name": "stamp" }],
24+
"guardExpr": {
25+
"op": "and",
26+
"children": [
27+
{ "op": "leaf", "ref": { "name": "exprGuardA" } },
28+
{ "op": "leaf", "ref": { "name": "exprGuardB" } }
29+
]
30+
}
31+
}
32+
],
33+
"invoke": [
34+
{ "src": { "name": "fetchWork" }, "onDone": "submit", "onError": "fail" }
35+
]
36+
},
37+
{ "name": "review", "isFinal": true }
38+
]
39+
},
40+
{
41+
"name": "parallel",
42+
"regions": [
43+
{
44+
"name": "regionA",
45+
"initialChild": "ra1",
46+
"states": [
47+
{
48+
"name": "ra1",
49+
"transitions": [
50+
{ "from": "ra1", "to": "ra2", "on": "tick", "guards": [{ "name": "regionGuard" }] }
51+
]
52+
},
53+
{ "name": "ra2", "isFinal": true }
54+
]
55+
}
56+
]
57+
},
58+
{ "name": "done", "isFinal": true }
59+
],
60+
"initial": "active",
61+
"hasInitial": true
62+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"schemaVersion":"1.0","name":"quenchfail","states":[{"name":"a","transitions":[{"from":"a","to":"nowhere","on":"go"}]},{"name":"b","isFinal":true}],"initial":"a","hasInitial":true}

0 commit comments

Comments
 (0)