diff --git a/internal/consolereporter/total_test.go b/internal/consolereporter/total_test.go index c159338..d4362dc 100644 --- a/internal/consolereporter/total_test.go +++ b/internal/consolereporter/total_test.go @@ -71,3 +71,36 @@ 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/scored_internal_test.go b/scored_internal_test.go new file mode 100644 index 0000000..ec0f280 --- /dev/null +++ b/scored_internal_test.go @@ -0,0 +1,61 @@ +package ditto + +import ( + "testing" + + "github.com/Disble/ditto/internal/ditto" + "github.com/Disble/ditto/internal/result" +) + +// 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. +// +// A reporter that cannot answer must read as UNKNOWN, not as zero. Zero means +// "the scope produced nothing to judge", and returning it for "I could not tell" +// would turn every genuinely failing run behind such a reporter into a silent +// success. The sentinel is -1 because a count cannot be negative, so no real +// answer collides with it. +func TestScoredIsUnknownRatherThanZero(t *testing.T) { + t.Parallel() + + unreadable := &release{reporter: countlessReporter{}} + 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}} + if got := readable.scored(); got != total { + t.Fatalf("scored() = %d, want %d", got, total) + } + } +} + +// TestAnUnreadableCountIsNotAnEmptyScope is the behaviour that distinction +// protects: a failing run whose count cannot be read is still a failing run. +func TestAnUnreadableCountIsNotAnEmptyScope(t *testing.T) { + t.Parallel() + + rel := &release{ + opts: Options{MinimumThreshold: 0.5}, + reporter: countlessReporter{}, + } + + if rel.scored() == 0 { + t.Fatal("an unreadable count read as an empty scope, which would report a failing run as nothing to do") + } +}