|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "bytes" |
| 5 | + "go/parser" |
| 6 | + "go/token" |
| 7 | + "os" |
| 8 | + "path/filepath" |
| 9 | + "strings" |
| 10 | + "testing" |
| 11 | +) |
| 12 | + |
| 13 | +// runCmd invokes the CLI dispatcher in-process and returns the exit code with |
| 14 | +// captured stdout and stderr, so every command is tested without a subprocess. |
| 15 | +func runCmd(args ...string) (code int, stdout, stderr string) { |
| 16 | + var out, errBuf bytes.Buffer |
| 17 | + code = run(args, &out, &errBuf) |
| 18 | + return code, out.String(), errBuf.String() |
| 19 | +} |
| 20 | + |
| 21 | +func TestRender(t *testing.T) { |
| 22 | + cases := []struct { |
| 23 | + name string |
| 24 | + args []string |
| 25 | + want string |
| 26 | + }{ |
| 27 | + {"mermaid default", []string{"render", "testdata/clean.json"}, "stateDiagram-v2"}, |
| 28 | + {"dot flag", []string{"render", "testdata/clean.json", "-format", "dot"}, "digraph"}, |
| 29 | + {"flag before path", []string{"render", "-format", "dot", "testdata/clean.json"}, "digraph"}, |
| 30 | + } |
| 31 | + for _, tc := range cases { |
| 32 | + t.Run(tc.name, func(t *testing.T) { |
| 33 | + code, out, errOut := runCmd(tc.args...) |
| 34 | + if code != exitOK { |
| 35 | + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) |
| 36 | + } |
| 37 | + if !strings.Contains(out, tc.want) { |
| 38 | + t.Fatalf("render output missing %q:\n%s", tc.want, out) |
| 39 | + } |
| 40 | + }) |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +func TestRender_UnknownFormat(t *testing.T) { |
| 45 | + code, _, errOut := runCmd("render", "testdata/clean.json", "-format", "svg") |
| 46 | + if code != exitUsage { |
| 47 | + t.Fatalf("exit = %d, want %d", code, exitUsage) |
| 48 | + } |
| 49 | + if !strings.Contains(errOut, "unknown -format") { |
| 50 | + t.Fatalf("stderr missing format error: %s", errOut) |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +func TestDiff(t *testing.T) { |
| 55 | + cases := []struct { |
| 56 | + name string |
| 57 | + old, new string |
| 58 | + wantBump string |
| 59 | + }{ |
| 60 | + {"additive change is minor", "testdata/old.json", "testdata/new_minor.json", "bump: minor"}, |
| 61 | + {"breaking change is major", "testdata/old.json", "testdata/new_major.json", "bump: major"}, |
| 62 | + } |
| 63 | + for _, tc := range cases { |
| 64 | + t.Run(tc.name, func(t *testing.T) { |
| 65 | + code, out, errOut := runCmd("diff", tc.old, tc.new) |
| 66 | + if code != exitOK { |
| 67 | + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) |
| 68 | + } |
| 69 | + if !strings.Contains(out, tc.wantBump) { |
| 70 | + t.Fatalf("diff output missing %q:\n%s", tc.wantBump, out) |
| 71 | + } |
| 72 | + }) |
| 73 | + } |
| 74 | +} |
| 75 | + |
| 76 | +func TestDiff_SplitsBreakingAndAdditive(t *testing.T) { |
| 77 | + code, out, _ := runCmd("diff", "testdata/old.json", "testdata/new_major.json") |
| 78 | + if code != exitOK { |
| 79 | + t.Fatalf("exit = %d, want %d", code, exitOK) |
| 80 | + } |
| 81 | + if !strings.Contains(out, "breaking (2)") { |
| 82 | + t.Errorf("want 2 breaking changes:\n%s", out) |
| 83 | + } |
| 84 | + if !strings.Contains(out, "additive (1)") { |
| 85 | + t.Errorf("want 1 additive change:\n%s", out) |
| 86 | + } |
| 87 | +} |
| 88 | + |
| 89 | +func TestValidate(t *testing.T) { |
| 90 | + cases := []struct { |
| 91 | + name string |
| 92 | + path string |
| 93 | + want int |
| 94 | + }{ |
| 95 | + {"clean IR validates", "testdata/clean.json", exitOK}, |
| 96 | + {"malformed JSON fails", "testdata/malformed.json", exitError}, |
| 97 | + } |
| 98 | + for _, tc := range cases { |
| 99 | + t.Run(tc.name, func(t *testing.T) { |
| 100 | + code, _, errOut := runCmd("validate", tc.path) |
| 101 | + if code != tc.want { |
| 102 | + t.Fatalf("exit = %d, want %d (stderr: %s)", code, tc.want, errOut) |
| 103 | + } |
| 104 | + if tc.want == exitError && strings.TrimSpace(errOut) == "" { |
| 105 | + t.Fatalf("expected a clear stderr message on failure") |
| 106 | + } |
| 107 | + }) |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +func TestLint(t *testing.T) { |
| 112 | + cases := []struct { |
| 113 | + name string |
| 114 | + path string |
| 115 | + want int |
| 116 | + }{ |
| 117 | + {"clean IR has no findings", "testdata/clean.json", exitOK}, |
| 118 | + {"defective IR reports findings", "testdata/defect.json", exitFindings}, |
| 119 | + } |
| 120 | + for _, tc := range cases { |
| 121 | + t.Run(tc.name, func(t *testing.T) { |
| 122 | + code, out, errOut := runCmd("lint", tc.path) |
| 123 | + if code != tc.want { |
| 124 | + t.Fatalf("exit = %d, want %d (stderr: %s)", code, tc.want, errOut) |
| 125 | + } |
| 126 | + if tc.want == exitFindings && !strings.Contains(out, "unreachable_state") { |
| 127 | + t.Fatalf("expected an unreachable_state finding:\n%s", out) |
| 128 | + } |
| 129 | + }) |
| 130 | + } |
| 131 | +} |
| 132 | + |
| 133 | +func TestEject_StdoutContainsKeyIdents(t *testing.T) { |
| 134 | + code, out, errOut := runCmd("eject", "testdata/clean.json", "-package", "machine") |
| 135 | + if code != exitOK { |
| 136 | + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) |
| 137 | + } |
| 138 | + for _, want := range []string{ |
| 139 | + "package machine", |
| 140 | + "func Provide(reg *state.Registry[", |
| 141 | + `reg.Guard("hasItems"`, |
| 142 | + `reg.Action("notify"`, |
| 143 | + `reg.Service("charge"`, |
| 144 | + } { |
| 145 | + if !strings.Contains(out, want) { |
| 146 | + t.Errorf("ejected source missing %q:\n%s", want, out) |
| 147 | + } |
| 148 | + } |
| 149 | + // The generated source must be parseable Go. |
| 150 | + if _, err := parser.ParseFile(token.NewFileSet(), "gen.go", out, parser.AllErrors); err != nil { |
| 151 | + t.Fatalf("ejected source does not parse: %v\n%s", err, out) |
| 152 | + } |
| 153 | +} |
| 154 | + |
| 155 | +func TestEject_WritesOutfile(t *testing.T) { |
| 156 | + dir := t.TempDir() |
| 157 | + out := filepath.Join(dir, "gen.go") |
| 158 | + code, _, errOut := runCmd("eject", "testdata/clean.json", "-package", "machine", "-o", out) |
| 159 | + if code != exitOK { |
| 160 | + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) |
| 161 | + } |
| 162 | + b, err := os.ReadFile(out) |
| 163 | + if err != nil { |
| 164 | + t.Fatalf("read output: %v", err) |
| 165 | + } |
| 166 | + if !strings.Contains(string(b), "package machine") { |
| 167 | + t.Fatalf("outfile missing package decl:\n%s", b) |
| 168 | + } |
| 169 | +} |
| 170 | + |
| 171 | +func TestVersion(t *testing.T) { |
| 172 | + for _, args := range [][]string{{"version"}, {"-version"}, {"--version"}} { |
| 173 | + code, out, _ := runCmd(args...) |
| 174 | + if code != exitOK { |
| 175 | + t.Fatalf("%v exit = %d, want %d", args, code, exitOK) |
| 176 | + } |
| 177 | + if strings.TrimSpace(out) != version { |
| 178 | + t.Fatalf("%v printed %q, want %q", args, strings.TrimSpace(out), version) |
| 179 | + } |
| 180 | + } |
| 181 | +} |
| 182 | + |
| 183 | +func TestUsageErrors(t *testing.T) { |
| 184 | + cases := []struct { |
| 185 | + name string |
| 186 | + args []string |
| 187 | + }{ |
| 188 | + {"no args", nil}, |
| 189 | + {"unknown subcommand", []string{"frobnicate"}}, |
| 190 | + {"lint missing path", []string{"lint"}}, |
| 191 | + {"diff one path", []string{"diff", "testdata/old.json"}}, |
| 192 | + } |
| 193 | + for _, tc := range cases { |
| 194 | + t.Run(tc.name, func(t *testing.T) { |
| 195 | + code, _, _ := runCmd(tc.args...) |
| 196 | + if code != exitUsage { |
| 197 | + t.Fatalf("exit = %d, want %d", code, exitUsage) |
| 198 | + } |
| 199 | + }) |
| 200 | + } |
| 201 | +} |
| 202 | + |
| 203 | +func TestStdinInput(t *testing.T) { |
| 204 | + // render reading from "-" exercises the stdin path; swap os.Stdin for the |
| 205 | + // duration so the dispatcher's hard-wired os.Stdin reads the fixture. |
| 206 | + b, err := os.ReadFile("testdata/clean.json") |
| 207 | + if err != nil { |
| 208 | + t.Fatalf("read fixture: %v", err) |
| 209 | + } |
| 210 | + r, w, err := os.Pipe() |
| 211 | + if err != nil { |
| 212 | + t.Fatalf("pipe: %v", err) |
| 213 | + } |
| 214 | + orig := os.Stdin |
| 215 | + os.Stdin = r |
| 216 | + t.Cleanup(func() { os.Stdin = orig }) |
| 217 | + go func() { |
| 218 | + _, _ = w.Write(b) |
| 219 | + _ = w.Close() |
| 220 | + }() |
| 221 | + |
| 222 | + code, out, errOut := runCmd("render", "-") |
| 223 | + if code != exitOK { |
| 224 | + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) |
| 225 | + } |
| 226 | + if !strings.Contains(out, "stateDiagram-v2") { |
| 227 | + t.Fatalf("stdin render missing diagram:\n%s", out) |
| 228 | + } |
| 229 | +} |
0 commit comments