From 52bd8c3499a82536257c42e810315fa689d738e5 Mon Sep 17 00:00:00 2001 From: disble Date: Sun, 30 Aug 2026 17:22:29 -0500 Subject: [PATCH] test(reporters): put the forwarding checks in the packages they guard The gate passed at 0.56 and still reported four survivors -- the `return -1` in each reporter decorator -- even though assertions covering exactly those lines existed and passed. The reason is a property of the gated path worth writing down: **a gated mutant is compiled and run with the tests of ITS OWN package.** Those assertions lived in internal/consolereporter, so the mutants in gatedreporter.go and verbosereporter.go were never judged by them. Every test green, the code unguarded, and nothing but the gate to say so. They now live beside the code they guard. The two fakes they need move to internal/dittotesting/fakereporter, because three packages want the same pair and the release's own reader wants it too. --- internal/consolereporter/total_test.go | 73 ------------------- .../dittotesting/fakereporter/counting.go | 31 ++++++++ internal/gatedreporter/total_test.go | 28 +++++++ internal/verbosereporter/total_test.go | 28 +++++++ scored_internal_test.go | 23 +----- 5 files changed, 91 insertions(+), 92 deletions(-) create mode 100644 internal/dittotesting/fakereporter/counting.go create mode 100644 internal/gatedreporter/total_test.go create mode 100644 internal/verbosereporter/total_test.go diff --git a/internal/consolereporter/total_test.go b/internal/consolereporter/total_test.go index d4362dc..12c603d 100644 --- a/internal/consolereporter/total_test.go +++ b/internal/consolereporter/total_test.go @@ -9,10 +9,8 @@ import ( "github.com/Disble/ditto/internal/dittotesting/fakescorecalculator" "github.com/Disble/ditto/internal/dittotesting/stubdiffer" "github.com/Disble/ditto/internal/future" - "github.com/Disble/ditto/internal/gatedreporter" "github.com/Disble/ditto/internal/gomutatedfile" "github.com/Disble/ditto/internal/result" - "github.com/Disble/ditto/internal/verbosereporter" "github.com/stretchr/testify/assert" ) @@ -33,74 +31,3 @@ func TestTotalSeparatesAnEmptyRunFromAFailingOne(t *testing.T) { assert.Equal(t, 1, reporter.Total()) } - -// TestTheDecoratorsForwardTheCount is backlog entry 12's class, caught once -// already: a decorator that drops a capability is refused by nothing. Without -// this the count is unreadable exactly when the stack is deepest. -func TestTheDecoratorsForwardTheCount(t *testing.T) { - logger := fakelogger.New() - console := consolereporter.New(logger, stubdiffer.New(""), fakescorecalculator.Always(0), 0) - - console.AddDiagnostic(ditto.NewDiagnostic( - future.Resolved(result.Ok("mutant killed")), - gomutatedfile.New("dummy", "dummy.go", nil, nil), - )) - console.Summarize() - - // Both decorators, and the gated one over the verbose one, which is the - // stack the CI gate actually builds and the one the count was unreadable in. - for name, wrapped := range map[string]ditto.Reporter{ - "verbose": verbosereporter.New(logger, console), - "gated": gatedreporter.New(logger, noGates{}, console), - "gated over verbose": gatedreporter.New(logger, noGates{}, verbosereporter.New(logger, console)), - } { - t.Run(name, func(t *testing.T) { - counted, ok := wrapped.(interface{ Total() int }) - if !ok { - t.Fatalf("the %s reporter does not forward the count", name) - } - - assert.Equal(t, 1, counted.Total()) - }) - } -} - -// noGates is a gated laboratory that gated nothing, which is all this needs: the -// counters are not the subject here, the forwarding is. -type noGates struct{} - -func (noGates) Gated() int { return 0 } -func (noGates) FellBack() int { return 0 } - -// TestADecoratorOverSomethingThatCannotCountSaysUnknown is the other half of the -// forwarding, and the half its mutants found unguarded. -// -// A decorator whose delegate cannot answer must read as UNKNOWN, not as zero. -// Zero means "the scope produced nothing to judge", and a decorator that -// invented it would turn a genuinely failing run into a silent success. The -// sentinel is -1 because a count cannot be negative. -func TestADecoratorOverSomethingThatCannotCountSaysUnknown(t *testing.T) { - logger := fakelogger.New() - - for name, wrapped := range map[string]ditto.Reporter{ - "verbose": verbosereporter.New(logger, countlessReporter{}), - "gated": gatedreporter.New(logger, noGates{}, countlessReporter{}), - } { - t.Run(name, func(t *testing.T) { - counted, ok := wrapped.(interface{ Total() int }) - if !ok { - t.Fatalf("the %s reporter does not forward the count at all", name) - } - - if got := counted.Total(); got != -1 { - t.Fatalf("Total() = %d over a reporter that cannot count, want -1", got) - } - }) - } -} - -// countlessReporter is every reporter that existed before the count did. -type countlessReporter struct{} - -func (countlessReporter) AddDiagnostic(*ditto.Diagnostic) {} -func (countlessReporter) Summarize() result.Result[any] { return result.Err[any]("") } diff --git a/internal/dittotesting/fakereporter/counting.go b/internal/dittotesting/fakereporter/counting.go new file mode 100644 index 0000000..9018e52 --- /dev/null +++ b/internal/dittotesting/fakereporter/counting.go @@ -0,0 +1,31 @@ +package fakereporter + +import ( + "github.com/Disble/ditto/internal/ditto" + "github.com/Disble/ditto/internal/result" +) + +// Counting and Countless are the two answers a reporter can give when asked how +// much it scored, and every decorator has to be checked against both. +// +// They live here because three packages need the same pair: the release that +// reads the count, and each decorator that has to forward it. A decorator that +// drops a capability is refused by nothing — backlog entry 12 — so the check is +// written once and applied where each decorator lives. + +// Counting reports a fixed total. The field is named Scored because Total is +// the method the optional interface asks for, and a struct cannot have both. +type Counting struct{ Scored int } + +func (Counting) AddDiagnostic(*ditto.Diagnostic) {} +func (Counting) Summarize() result.Result[any] { return result.Err[any]("") } + +// Total is the count this reporter was built with. +func (r Counting) Total() int { return r.Scored } + +// Countless is every reporter that existed before the count did, and any a +// caller supplies. +type Countless struct{} + +func (Countless) AddDiagnostic(*ditto.Diagnostic) {} +func (Countless) Summarize() result.Result[any] { return result.Err[any]("") } diff --git a/internal/gatedreporter/total_test.go b/internal/gatedreporter/total_test.go new file mode 100644 index 0000000..8b75031 --- /dev/null +++ b/internal/gatedreporter/total_test.go @@ -0,0 +1,28 @@ +package gatedreporter_test + +import ( + "testing" + + "github.com/Disble/ditto/internal/dittotesting/fakelogger" + "github.com/Disble/ditto/internal/dittotesting/fakereporter" + "github.com/Disble/ditto/internal/gatedreporter" + "github.com/stretchr/testify/assert" +) + +// This lives here rather than beside the reporter it decorates, and the reason is +// a property of the gated path worth knowing: a gated mutant is compiled and run +// with the tests of ITS OWN package. The first version of these assertions sat in +// internal/consolereporter, every one of them passed, and the gate still reported +// four survivors -- the mutants in this file were never judged by them. + +func TestTotalIsForwarded(t *testing.T) { + assert.Equal(t, 47, gatedreporter.New(fakelogger.New(), fakeGates{}, fakereporter.Counting{Scored: 47}).Total(), "a decorator that drops a capability is refused by nothing") +} + +// A delegate that cannot answer must read as UNKNOWN, not as zero. Zero means +// "the scope produced nothing to judge", which RunChanged treats as nothing to +// do -- so answering it for "I could not tell" would turn a genuinely failing +// run into a silent success. The sentinel is negative because a count cannot be. +func TestAnUnreadableCountIsNotZero(t *testing.T) { + assert.Equal(t, -1, gatedreporter.New(fakelogger.New(), fakeGates{}, fakereporter.Countless{}).Total()) +} diff --git a/internal/verbosereporter/total_test.go b/internal/verbosereporter/total_test.go new file mode 100644 index 0000000..e91eb0c --- /dev/null +++ b/internal/verbosereporter/total_test.go @@ -0,0 +1,28 @@ +package verbosereporter_test + +import ( + "testing" + + "github.com/Disble/ditto/internal/dittotesting/fakelogger" + "github.com/Disble/ditto/internal/dittotesting/fakereporter" + "github.com/Disble/ditto/internal/verbosereporter" + "github.com/stretchr/testify/assert" +) + +// This lives here rather than beside the reporter it decorates, and the reason is +// a property of the gated path worth knowing: a gated mutant is compiled and run +// with the tests of ITS OWN package. The first version of these assertions sat in +// internal/consolereporter, every one of them passed, and the gate still reported +// four survivors -- the mutants in this file were never judged by them. + +func TestTotalIsForwarded(t *testing.T) { + assert.Equal(t, 47, verbosereporter.New(fakelogger.New(), fakereporter.Counting{Scored: 47}).Total(), "a decorator that drops a capability is refused by nothing") +} + +// A delegate that cannot answer must read as UNKNOWN, not as zero. Zero means +// "the scope produced nothing to judge", which RunChanged treats as nothing to +// do -- so answering it for "I could not tell" would turn a genuinely failing +// run into a silent success. The sentinel is negative because a count cannot be. +func TestAnUnreadableCountIsNotZero(t *testing.T) { + assert.Equal(t, -1, verbosereporter.New(fakelogger.New(), fakereporter.Countless{}).Total()) +} diff --git a/scored_internal_test.go b/scored_internal_test.go index ec0f280..8a66d4e 100644 --- a/scored_internal_test.go +++ b/scored_internal_test.go @@ -3,24 +3,9 @@ package ditto import ( "testing" - "github.com/Disble/ditto/internal/ditto" - "github.com/Disble/ditto/internal/result" + "github.com/Disble/ditto/internal/dittotesting/fakereporter" ) -// countlessReporter is a reporter that cannot say how much it scored, which is -// every reporter that existed before the count did and any a caller supplies. -type countlessReporter struct{} - -func (countlessReporter) AddDiagnostic(*ditto.Diagnostic) {} -func (countlessReporter) Summarize() result.Result[any] { return result.Err[any]("") } - -// countingReporter can. -type countingReporter struct{ total int } - -func (countingReporter) AddDiagnostic(*ditto.Diagnostic) {} -func (countingReporter) Summarize() result.Result[any] { return result.Err[any]("") } -func (r countingReporter) Total() int { return r.total } - // TestScoredIsUnknownRatherThanZero is the distinction the whole empty-scope fix // rests on, and the one its mutants found unguarded. // @@ -32,13 +17,13 @@ func (r countingReporter) Total() int { return r.total } func TestScoredIsUnknownRatherThanZero(t *testing.T) { t.Parallel() - unreadable := &release{reporter: countlessReporter{}} + unreadable := &release{reporter: fakereporter.Countless{}} if got := unreadable.scored(); got != -1 { t.Fatalf("a reporter that cannot count scored %d, want -1", got) } for _, total := range []int{0, 1, 47} { - readable := &release{reporter: countingReporter{total: total}} + readable := &release{reporter: fakereporter.Counting{Scored: total}} if got := readable.scored(); got != total { t.Fatalf("scored() = %d, want %d", got, total) } @@ -52,7 +37,7 @@ func TestAnUnreadableCountIsNotAnEmptyScope(t *testing.T) { rel := &release{ opts: Options{MinimumThreshold: 0.5}, - reporter: countlessReporter{}, + reporter: fakereporter.Countless{}, } if rel.scored() == 0 {