|
| 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 | +} |
0 commit comments