Files
cgalo5758 2bc0bd1610 Add a tool that applies deliberate breaks one at a time
scripts/mutation-check reads a JSON list of breaks, applies each to the tree
one at a time, restores the file byte for byte, and reports CAUGHT, MISSED,
WRONG-MESSAGE, NOT-FOUND, SKIPPED or CONTROL-FAILED for each.

New tests: TestApplyAndRestoreAreByteExact, TestOldTextMustOccurOnce,
TestSidePicksPatternAndMessage, TestARecordWithASideRunsOnlyThere,
TestOutcomes, TestStatelessListStopsOnAFailingControl,
TestControlRunsOncePerDistinctTest,
TestStatefulListPreparesAndControlsEachBreak,
TestStatefulControlFailureMarksOnlyThatBreak, TestPrepareFailureStopsTheList,
TestCommandRecordsAreClassifiedByExitAndText,
TestCommandRecordReadsTheInspectedFileAndNeverAStaleOne,
TestCommandControlFailsWhenTheTextIsAlreadyThere,
TestSummarizeExitsOneUnlessEveryBreakIsCaught.
2026-10-07 16:21:58 -05:00

360 lines
11 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package main
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// fake stands in for the processes a list starts. test and command answer
// from the file the break edited, so a result depends on whether the break is
// applied.
type fake struct {
root string
test func(pkg, run, file string) result
command func(cmd string) result
ran []string // "prepare", "test <run>", "cmd <cmd>" in call order
prepErr error
}
func (f *fake) Prepare(_ context.Context, cmd string) error {
f.ran = append(f.ran, "prepare")
return f.prepErr
}
func (f *fake) Test(_ context.Context, pkg, run string) (result, error) {
f.ran = append(f.ran, "test "+run)
data, err := os.ReadFile(filepath.Join(f.root, "subject.txt"))
if err != nil {
return result{}, err
}
return f.test(pkg, run, string(data)), nil
}
func (f *fake) Command(_ context.Context, cmd string) (result, error) {
f.ran = append(f.ran, "cmd "+cmd)
return f.command(cmd), nil
}
const pass = "=== RUN TestX\n--- PASS: TestX (0.00s)\nPASS\n"
// failing is a test run that fails with msg.
func failing(msg string) result {
return result{out: "=== RUN TestX\n x_test.go:1: " + msg + "\n--- FAIL: TestX (0.00s)\nFAIL\n", exit: 1}
}
// setup writes subject.txt and returns a runner over a fake whose tests fail
// with "boom" when the file no longer says "good".
func setup(t *testing.T, side string) (*runner, *fake, *bytes.Buffer) {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "subject.txt"), []byte("good good-and-more"), 0o600); err != nil {
t.Fatal(err)
}
f := &fake{root: root}
f.test = func(_, _, file string) result {
if strings.HasPrefix(file, "good ") {
return result{out: pass}
}
return failing("boom")
}
f.command = func(string) result { return result{} }
var out bytes.Buffer
return &runner{ex: f, root: root, side: side, out: &out}, f, &out
}
func subject(t *testing.T, r *runner) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(r.root, "subject.txt"))
if err != nil {
t.Fatal(err)
}
return string(data)
}
func testBreak(name, old string) record {
return record{Name: name, File: "subject.txt", Old: old, New: "bad", Pkg: "./p",
RunOld: "^TestOld$", RunNew: "^TestNew$", WantOld: "boom", WantNew: "boom"}
}
func TestApplyAndRestoreAreByteExact(t *testing.T) {
r, _, _ := setup(t, "new")
before := subject(t, r)
counts, err := r.runList(context.Background(), list{Breaks: []record{testBreak("first", "good ")}})
if err != nil {
t.Fatal(err)
}
if counts[caught] != 1 {
t.Fatalf("counts = %v, want one caught", counts)
}
if got := subject(t, r); got != before {
t.Errorf("file after the run = %q, want %q", got, before)
}
}
func TestOldTextMustOccurOnce(t *testing.T) {
for _, old := range []string{"absent", "good"} { // "good" occurs twice
r, _, _ := setup(t, "new")
before := subject(t, r)
counts, err := r.runList(context.Background(), list{Breaks: []record{testBreak("b", old)}})
if err != nil {
t.Fatal(err)
}
if counts[notFound] != 1 {
t.Errorf("old %q: counts = %v, want one %s", old, counts, notFound)
}
if got := subject(t, r); got != before {
t.Errorf("old %q: the file changed to %q", old, got)
}
}
}
func TestSidePicksPatternAndMessage(t *testing.T) {
r, f, _ := setup(t, "old")
b := testBreak("b", "good ")
b.RunOld, b.WantOld = "^TestOld$", "boom"
b.RunNew, b.WantNew = "^TestNew$", "never printed"
counts, err := r.runList(context.Background(), list{Breaks: []record{b}})
if err != nil {
t.Fatal(err)
}
if counts[caught] != 1 {
t.Errorf("old side counts = %v, want one caught", counts)
}
for _, c := range f.ran {
if c == "test ^TestNew$" {
t.Errorf("the old side ran the new pattern: %v", f.ran)
}
}
r, _, _ = setup(t, "new")
counts, err = r.runList(context.Background(), list{Breaks: []record{b}})
if err != nil {
t.Fatal(err)
}
if counts[wrongMessage] != 1 {
t.Errorf("new side counts = %v, want one %s", counts, wrongMessage)
}
}
func TestARecordWithASideRunsOnlyThere(t *testing.T) {
r, f, out := setup(t, "new")
oldOnly := testBreak("old only", "good ")
oldOnly.Side = "old"
newOnly := testBreak("new only", "good ")
newOnly.Side = "new"
counts, err := r.runList(context.Background(), list{Breaks: []record{oldOnly, newOnly}})
if err != nil {
t.Fatal(err)
}
if counts[caught] != 1 || strings.Contains(out.String(), "old only") {
t.Errorf("counts = %v, output %q: want only the new record", counts, out.String())
}
if len(f.ran) != 2 { // one control, one break
t.Errorf("ran %v, want a control and one break", f.ran)
}
}
func TestOutcomes(t *testing.T) {
tests := []struct {
name string
res result
want string
}{
{"fails with the message", failing("boom"), caught},
{"fails with another message", failing("other"), wrongMessage},
{"passes", result{out: pass}, missed},
{"skips", result{out: "=== RUN TestX\n--- SKIP: TestX (0.00s)\nPASS\n"}, skipped},
{"names no test", result{out: "ok \tp\t0.1s [no tests to run]\n"}, skipped},
{"does not build", result{out: "FAIL\tp [build failed]\nboom\n", exit: 1}, caught},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, _ := classifyTest(tc.res, "boom")
if got != tc.want {
t.Errorf("outcome = %s, want %s", got, tc.want)
}
})
}
}
func TestStatelessListStopsOnAFailingControl(t *testing.T) {
for name, res := range map[string]result{
"fails": failing("boom"),
"skips": {out: "--- SKIP: TestX (0.00s)\n"},
"prints": {out: pass + "boom\n"},
} {
t.Run(name, func(t *testing.T) {
r, f, _ := setup(t, "new")
f.test = func(_, _, _ string) result { return res }
_, err := r.runList(context.Background(), list{Breaks: []record{testBreak("b", "good ")}})
if err == nil || !strings.Contains(err.Error(), "control run") {
t.Fatalf("err = %v, want a control run failure", err)
}
if got := subject(t, r); got != "good good-and-more" {
t.Errorf("a break was applied: %q", got)
}
})
}
}
func TestControlRunsOncePerDistinctTest(t *testing.T) {
r, f, _ := setup(t, "new")
_, err := r.runList(context.Background(), list{Breaks: []record{
testBreak("one", "good "), testBreak("two", "-and-more"),
}})
if err != nil {
t.Fatal(err)
}
if got := strings.Join(f.ran, ","); got != "test ^TestNew$,test ^TestNew$,test ^TestNew$" {
t.Errorf("ran %s, want one control and two breaks", got)
}
}
func TestStatefulListPreparesAndControlsEachBreak(t *testing.T) {
r, f, _ := setup(t, "new")
counts, err := r.runList(context.Background(), list{Prepare: "p", Breaks: []record{
testBreak("one", "good "), testBreak("two", "-and-more"),
}})
if err != nil {
t.Fatal(err)
}
// prepare, control, prepare, break; twice; then prepare on the restored tree.
want := "prepare,test ^TestNew$,prepare,test ^TestNew$,prepare,test ^TestNew$,prepare,test ^TestNew$,prepare"
if got := strings.Join(f.ran, ","); got != want {
t.Errorf("ran %s,\nwant %s", got, want)
}
if counts[caught] != 1 || counts[missed] != 1 {
t.Errorf("counts = %v, want one caught and one missed (the second edit leaves the file good)", counts)
}
}
func TestStatefulControlFailureMarksOnlyThatBreak(t *testing.T) {
controls := map[string]result{
"fails": failing("unrelated"),
"skips": {out: "=== RUN TestX\n--- SKIP: TestX (0.00s)\nPASS\n"},
}
for name, control := range controls {
t.Run(name, func(t *testing.T) {
r, f, _ := setup(t, "new")
calls := 0
f.test = func(_, _, file string) result {
calls++
if calls == 1 { // the first break's control run
return control
}
if strings.HasPrefix(file, "good ") {
return result{out: pass}
}
return failing("boom")
}
counts, err := r.runList(context.Background(), list{Prepare: "p", Breaks: []record{
testBreak("one", "good "), testBreak("two", "good "),
}})
if err != nil {
t.Fatal(err)
}
if counts[controlFailed] != 1 || counts[caught] != 1 {
t.Errorf("counts = %v, want one %s and one caught", counts, controlFailed)
}
if got := subject(t, r); got != "good good-and-more" {
t.Errorf("file = %q, want the original", got)
}
})
}
}
func TestPrepareFailureStopsTheList(t *testing.T) {
r, f, _ := setup(t, "new")
f.prepErr = os.ErrInvalid
_, err := r.runList(context.Background(), list{Prepare: "p", Breaks: []record{testBreak("b", "good ")}})
if err == nil {
t.Fatal("a failing prepare did not stop the list")
}
}
func TestCommandRecordsAreClassifiedByExitAndText(t *testing.T) {
tests := []struct {
name string
rec record
res result
want string
}{
{"exit and text", record{Exit: 1}, result{out: "found: boom", exit: 1}, caught},
{"right exit, no text", record{Exit: 1}, result{out: "other", exit: 1}, wrongMessage},
{"exits 0 when it should fail", record{Exit: 1}, result{out: "boom"}, missed},
{"another failure", record{Exit: 1}, result{out: "boom", exit: 2}, wrongMessage},
{"exit 0 with text", record{Exit: 0}, result{out: "boom"}, caught},
{"exit 0 without text", record{Exit: 0}, result{out: "clean"}, missed},
{"exit 0 wanted, command failed", record{Exit: 0}, result{out: "boom", exit: 1}, wrongMessage},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r, _, _ := setup(t, "new")
tc.rec.Cmd = "make x"
tc.rec.WantNew = "boom"
got, _ := r.classify(tc.rec, tc.res, tc.res.out)
if got != tc.want {
t.Errorf("outcome = %s, want %s", got, tc.want)
}
})
}
}
func TestCommandRecordReadsTheInspectedFileAndNeverAStaleOne(t *testing.T) {
r, f, _ := setup(t, "new")
inspect := filepath.Join(r.root, "changes.txt")
if err := os.WriteFile(inspect, []byte("boom from an earlier run"), 0o600); err != nil {
t.Fatal(err)
}
b := record{Name: "screens", File: "subject.txt", Old: "good ", New: "bad", Cmd: "make screens",
Exit: 0, Inspect: "changes.txt", WantNew: "boom"}
f.command = func(string) result {
data, err := os.ReadFile(inspect)
if err == nil { // the stale file survived: the tool did not remove it
t.Errorf("stale inspected file present: %q", data)
}
if strings.HasPrefix(subject(t, r), "bad") {
if err := os.WriteFile(inspect, []byte("boom"), 0o600); err != nil {
t.Fatal(err)
}
}
return result{}
}
counts, err := r.runList(context.Background(), list{Breaks: []record{b}})
if err != nil {
t.Fatal(err)
}
if counts[caught] != 1 {
t.Errorf("counts = %v, want the file's text to catch the break", counts)
}
}
func TestCommandControlFailsWhenTheTextIsAlreadyThere(t *testing.T) {
r, f, _ := setup(t, "new")
f.command = func(string) result { return result{out: "boom"} }
b := record{Name: "lint", File: "subject.txt", Old: "good ", New: "bad", Cmd: "go run . lint", Exit: 1, WantNew: "boom"}
if _, err := r.runList(context.Background(), list{Breaks: []record{b}}); err == nil {
t.Fatal("a control run that already prints the text did not stop the list")
}
}
func TestSummarizeExitsOneUnlessEveryBreakIsCaught(t *testing.T) {
var out bytes.Buffer
if got := summarize(&out, map[string]int{caught: 3}); got != 0 {
t.Errorf("all caught: exit %d, want 0", got)
}
if got := summarize(&out, map[string]int{caught: 2, missed: 1}); got != 1 {
t.Errorf("one missed: exit %d, want 1", got)
}
if !strings.Contains(out.String(), "3 breaks: 2 CAUGHT, 1 MISSED") {
t.Errorf("total line = %q", out.String())
}
}