From b4a013d34cc90913a3adc8f1c8834ebf7f22ae84 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 15 Jun 2026 12:27:55 -0400 Subject: [PATCH] test: raise cmd/crucible coverage above the release threshold Signed-off-by: Joshua Temple --- cmd/crucible/cmd_error_test.go | 260 +++++++++++++++++++++++++ cmd/crucible/testdata/composite.json | 62 ++++++ cmd/crucible/testdata/quench_fail.json | 1 + 3 files changed, 323 insertions(+) create mode 100644 cmd/crucible/cmd_error_test.go create mode 100644 cmd/crucible/testdata/composite.json create mode 100644 cmd/crucible/testdata/quench_fail.json diff --git a/cmd/crucible/cmd_error_test.go b/cmd/crucible/cmd_error_test.go new file mode 100644 index 0000000..159395a --- /dev/null +++ b/cmd/crucible/cmd_error_test.go @@ -0,0 +1,260 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// missingPath is a path under a fresh temp dir guaranteed not to exist, so +// readInput's os.ReadFile fails and exercises every subcommand's load-error +// branch. +func missingPath(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "does-not-exist.json") +} + +// TestSubcommands_LoadError confirms each IR-loading subcommand returns +// exitError with a command-prefixed message when the IR file is missing. +func TestSubcommands_LoadError(t *testing.T) { + missing := missingPath(t) + cases := []struct { + name string + args []string + prefix string + }{ + {"lint", []string{"lint", missing}, "crucible lint:"}, + {"render", []string{"render", missing}, "crucible render:"}, + {"validate", []string{"validate", missing}, "crucible validate:"}, + {"eject", []string{"eject", missing}, "crucible eject:"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, _, errOut := runCmd(tc.args...) + if code != exitError { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitError, errOut) + } + if !strings.Contains(errOut, tc.prefix) { + t.Fatalf("stderr missing %q: %s", tc.prefix, errOut) + } + }) + } +} + +// TestSubcommands_QuenchError confirms the lint, render, and validate commands +// surface a quench failure (an undeclared transition target panics Quench, +// which quench recovers into an error) as exitError on stderr. +func TestSubcommands_QuenchError(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {"lint", []string{"lint", "testdata/quench_fail.json"}}, + {"render", []string{"render", "testdata/quench_fail.json"}}, + {"validate", []string{"validate", "testdata/quench_fail.json"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, _, errOut := runCmd(tc.args...) + if code != exitError { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitError, errOut) + } + if !strings.Contains(errOut, "quench:") { + t.Fatalf("stderr missing quench error: %s", errOut) + } + }) + } +} + +// TestParseSingleArg_FlagParseError confirms the single-arg commands (lint, +// validate) return a usage exit code when flag parsing fails on an unknown flag. +func TestParseSingleArg_FlagParseError(t *testing.T) { + for _, cmd := range []string{"lint", "validate"} { + t.Run(cmd, func(t *testing.T) { + code, _, _ := runCmd(cmd, "testdata/clean.json", "-nope") + if code != exitUsage { + t.Fatalf("exit = %d, want %d", code, exitUsage) + } + }) + } +} + +// TestRender_FlagParseError confirms an unknown flag fails flag parsing and +// returns a usage exit code. +func TestRender_FlagParseError(t *testing.T) { + code, _, _ := runCmd("render", "testdata/clean.json", "-nope") + if code != exitUsage { + t.Fatalf("exit = %d, want %d", code, exitUsage) + } +} + +// TestRender_WrongArgCount confirms render rejects a missing IR path with a +// usage message. +func TestRender_WrongArgCount(t *testing.T) { + code, _, errOut := runCmd("render") + if code != exitUsage { + t.Fatalf("exit = %d, want %d", code, exitUsage) + } + if !strings.Contains(errOut, "usage: crucible render") { + t.Fatalf("stderr missing usage: %s", errOut) + } +} + +// TestEject_FlagParseError confirms eject rejects an unknown flag with a usage +// exit code. +func TestEject_FlagParseError(t *testing.T) { + code, _, _ := runCmd("eject", "testdata/clean.json", "-nope") + if code != exitUsage { + t.Fatalf("exit = %d, want %d", code, exitUsage) + } +} + +// TestEject_WrongArgCount confirms eject rejects a missing IR path with a usage +// message. +func TestEject_WrongArgCount(t *testing.T) { + code, _, errOut := runCmd("eject") + if code != exitUsage { + t.Fatalf("exit = %d, want %d", code, exitUsage) + } + if !strings.Contains(errOut, "usage: crucible eject") { + t.Fatalf("stderr missing usage: %s", errOut) + } +} + +// TestEject_DefaultPackageToStdout confirms eject with no -package flag writes +// generated source to stdout (the gen-default package-name branch). +func TestEject_DefaultPackageToStdout(t *testing.T) { + code, out, errOut := runCmd("eject", "testdata/clean.json") + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + if !strings.Contains(out, "package ") { + t.Fatalf("ejected source missing a package decl:\n%s", out) + } +} + +// TestEject_WriteOutputError confirms a write to an unwritable output path is +// reported as exitError. The output path names a regular file as a parent +// directory, so os.WriteFile fails. +func TestEject_WriteOutputError(t *testing.T) { + dir := t.TempDir() + notDir := filepath.Join(dir, "afile") + if err := os.WriteFile(notDir, []byte("x"), 0o644); err != nil { + t.Fatalf("seed file: %v", err) + } + out := filepath.Join(notDir, "gen.go") // parent is a file, not a dir + code, _, errOut := runCmd("eject", "testdata/clean.json", "-o", out) + if code != exitError { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitError, errOut) + } + if !strings.Contains(errOut, "write output") { + t.Fatalf("stderr missing write error: %s", errOut) + } +} + +// TestDiff_FlagParseError confirms diff rejects an unknown flag with a usage +// exit code. +func TestDiff_FlagParseError(t *testing.T) { + code, _, _ := runCmd("diff", "-nope", "testdata/old.json", "testdata/new_minor.json") + if code != exitUsage { + t.Fatalf("exit = %d, want %d", code, exitUsage) + } +} + +// TestDiff_ReadErrors confirms diff reports a missing old or new IR file as +// exitError, each with its own stderr prefix. +func TestDiff_ReadErrors(t *testing.T) { + missing := missingPath(t) + cases := []struct { + name string + args []string + prefix string + }{ + {"old missing", []string{"diff", missing, "testdata/new_minor.json"}, "read old:"}, + {"new missing", []string{"diff", "testdata/old.json", missing}, "read new:"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, _, errOut := runCmd(tc.args...) + if code != exitError { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitError, errOut) + } + if !strings.Contains(errOut, tc.prefix) { + t.Fatalf("stderr missing %q: %s", tc.prefix, errOut) + } + }) + } +} + +// TestDiff_MalformedInput confirms diff reports a malformed IR document as +// exitError via the DiffJSON decode failure. +func TestDiff_MalformedInput(t *testing.T) { + code, _, errOut := runCmd("diff", "testdata/malformed.json", "testdata/old.json") + if code != exitError { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitError, errOut) + } + if !strings.Contains(errOut, "crucible diff:") { + t.Fatalf("stderr missing diff error: %s", errOut) + } +} + +// TestHelp confirms the help variants print usage to stdout and exit zero. +func TestHelp(t *testing.T) { + for _, args := range [][]string{{"-h"}, {"--help"}, {"help"}} { + code, out, _ := runCmd(args...) + if code != exitOK { + t.Fatalf("%v exit = %d, want %d", args, code, exitOK) + } + if !strings.Contains(out, "crucible - headless tooling") { + t.Fatalf("%v help output missing banner:\n%s", args, out) + } + } +} + +// TestReorderArgs_DoubleDashTerminator confirms a bare "--" terminates flag +// processing so following tokens are treated as positional. render -- +// keeps the default mermaid format and renders the file. +func TestReorderArgs_DoubleDashTerminator(t *testing.T) { + got := reorderArgs([]string{"--", "-format", "ir.json"}) + want := []string{"-format", "ir.json"} + if len(got) != len(want) { + t.Fatalf("reorderArgs returned %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("reorderArgs[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +// TestComposite_ExercisesDeepWalk renders and ejects a compound + parallel IR +// whose states carry entry/exit/done actions, entry/exit assigns, an invoke, a +// composite guard expression, children, and regions. This drives the deep +// branches of walkState/walkTransition (the stub registry must enumerate every +// referenced behavior or Quench panics). +func TestComposite_ExercisesDeepWalk(t *testing.T) { + t.Run("render", func(t *testing.T) { + code, out, errOut := runCmd("render", "testdata/composite.json") + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + if !strings.Contains(out, "stateDiagram-v2") { + t.Fatalf("render output missing diagram:\n%s", out) + } + }) + t.Run("eject parses", func(t *testing.T) { + code, out, errOut := runCmd("eject", "testdata/composite.json") + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + // Behaviors reachable through the deep walk (region guards, invoke + // services, exit/done actions, nested-state effects) must appear in the + // generated stubs. + for _, want := range []string{"fetchWork", "logExit", "logDone", "regionGuard", "notify"} { + if !strings.Contains(out, want) { + t.Errorf("ejected source missing reference %q", want) + } + } + }) +} diff --git a/cmd/crucible/testdata/composite.json b/cmd/crucible/testdata/composite.json new file mode 100644 index 0000000..27c4aa9 --- /dev/null +++ b/cmd/crucible/testdata/composite.json @@ -0,0 +1,62 @@ +{ + "schemaVersion": "1.0", + "name": "composite", + "states": [ + { + "name": "active", + "onEntry": [{ "name": "logEntry" }], + "onExit": [{ "name": "logExit" }], + "onDone": [{ "name": "logDone" }], + "onEntryAssign": [{ "name": "seedCtx" }], + "onExitAssign": [{ "name": "clearCtx" }], + "initialChild": "working", + "children": [ + { + "name": "working", + "transitions": [ + { + "from": "working", + "to": "review", + "on": "submit", + "guards": [{ "name": "canSubmit" }], + "effects": [{ "name": "notify" }], + "assigns": [{ "name": "stamp" }], + "guardExpr": { + "op": "and", + "children": [ + { "op": "leaf", "ref": { "name": "exprGuardA" } }, + { "op": "leaf", "ref": { "name": "exprGuardB" } } + ] + } + } + ], + "invoke": [ + { "src": { "name": "fetchWork" }, "onDone": "submit", "onError": "fail" } + ] + }, + { "name": "review", "isFinal": true } + ] + }, + { + "name": "parallel", + "regions": [ + { + "name": "regionA", + "initialChild": "ra1", + "states": [ + { + "name": "ra1", + "transitions": [ + { "from": "ra1", "to": "ra2", "on": "tick", "guards": [{ "name": "regionGuard" }] } + ] + }, + { "name": "ra2", "isFinal": true } + ] + } + ] + }, + { "name": "done", "isFinal": true } + ], + "initial": "active", + "hasInitial": true +} diff --git a/cmd/crucible/testdata/quench_fail.json b/cmd/crucible/testdata/quench_fail.json new file mode 100644 index 0000000..fbaaf2b --- /dev/null +++ b/cmd/crucible/testdata/quench_fail.json @@ -0,0 +1 @@ +{"schemaVersion":"1.0","name":"quenchfail","states":[{"name":"a","transitions":[{"from":"a","to":"nowhere","on":"go"}]},{"name":"b","isFinal":true}],"initial":"a","hasInitial":true}