Skip to content

Commit f59ac7e

Browse files
committed
test: improve mutation test coverage for boundary conditions
Add targeted tests to kill LIVED mutations identified by Gremlins: - clock_test.go: Add tests for heapIndex sentinel value (-1), timer removal after pop, and RemoveTimer boundary conditions - heap_test.go: Add tests for RemoveAt boundary where idx == len - cron_test.go: Add tests for index compaction boundary conditions (currentSize == 0 and deletions == currentSize cases) - max_entries_test.go: Add tests for entry count decrement on error, nextID revert on duplicate name, and heap index initialization - logger_test.go: Add tests for format string boundary condition - chain_test.go: Add tests for chain parsing edge cases - constantdelay_test.go: Add tests for delay calculation boundaries Mutations killed: - clock.go:274:16 ARITHMETIC_BASE - cron.go:383:15 ARITHMETIC_BASE - cron.go:820:17 CONDITIONALS_BOUNDARY/NEGATION - cron.go:820:41 CONDITIONALS_BOUNDARY - heap.go:81:20 CONDITIONALS_BOUNDARY
1 parent 7d70e3a commit f59ac7e

File tree

7 files changed

+1211
-0
lines changed

7 files changed

+1211
-0
lines changed

chain_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cron
22

33
import (
4+
"context"
45
"io"
56
"log"
67
"reflect"
@@ -523,3 +524,162 @@ func TestChainTimeout(t *testing.T) {
523524
}
524525
})
525526
}
527+
528+
// TestTimeoutWithContextCancellation tests that onTimeout callback is NOT called
529+
// when context is canceled (vs when it times out). This kills the mutation at
530+
// chain.go:418 where ctx.Err() == context.DeadlineExceeded could be negated.
531+
func TestTimeoutWithContextCancellation(t *testing.T) {
532+
t.Run("callback not invoked on context cancellation", func(t *testing.T) {
533+
var callbackCalled int32
534+
var errorLogged int32
535+
536+
callback := func(timeout time.Duration) {
537+
atomic.AddInt32(&callbackCalled, 1)
538+
}
539+
540+
logger := &testLogCapture{}
541+
542+
// Create a long-running job
543+
jobStarted := make(chan struct{})
544+
jobCanFinish := make(chan struct{})
545+
job := FuncJob(func() {
546+
close(jobStarted)
547+
<-jobCanFinish
548+
})
549+
550+
// Use TimeoutWithContext with a long timeout
551+
wrapper := TimeoutWithContext(logger, 10*time.Second, WithTimeoutCallback(callback))
552+
wrappedJob := wrapper(job)
553+
554+
// Get the timeoutContextJob to call RunWithContext
555+
tcj := wrappedJob.(*timeoutContextJob)
556+
557+
// Create a cancellable context (NOT a timeout context)
558+
ctx, cancel := context.WithCancel(context.Background())
559+
560+
done := make(chan struct{})
561+
go func() {
562+
tcj.RunWithContext(ctx)
563+
close(done)
564+
}()
565+
566+
// Wait for job to start
567+
<-jobStarted
568+
569+
// Cancel the context (simulates parent cancellation, NOT timeout)
570+
cancel()
571+
572+
// Allow job to finish
573+
close(jobCanFinish)
574+
575+
// Wait for wrapper to complete
576+
select {
577+
case <-done:
578+
case <-time.After(time.Second):
579+
t.Fatal("timeout waiting for job wrapper to complete")
580+
}
581+
582+
// Callback should NOT be called because context was canceled, not timed out
583+
if c := atomic.LoadInt32(&callbackCalled); c != 0 {
584+
t.Errorf("expected callback NOT called on cancellation, got %d calls", c)
585+
}
586+
587+
// Error log should NOT be called (only called on DeadlineExceeded)
588+
if c := logger.ErrorCount(); c != 0 {
589+
t.Errorf("expected no error log on cancellation, got %d", c)
590+
}
591+
592+
_ = errorLogged // silence unused variable
593+
})
594+
595+
t.Run("callback invoked on actual timeout", func(t *testing.T) {
596+
var callbackCalled int32
597+
598+
callback := func(timeout time.Duration) {
599+
atomic.AddInt32(&callbackCalled, 1)
600+
}
601+
602+
logger := &testLogCapture{}
603+
604+
// Create a job that takes longer than timeout
605+
job := FuncJob(func() {
606+
time.Sleep(100 * time.Millisecond)
607+
})
608+
609+
wrapper := TimeoutWithContext(logger, 10*time.Millisecond, WithTimeoutCallback(callback))
610+
wrappedJob := wrapper(job)
611+
612+
wrappedJob.Run()
613+
614+
// Wait for any cleanup
615+
time.Sleep(50 * time.Millisecond)
616+
617+
// Callback SHOULD be called because job actually timed out
618+
if c := atomic.LoadInt32(&callbackCalled); c != 1 {
619+
t.Errorf("expected callback called once on timeout, got %d calls", c)
620+
}
621+
622+
// Error log SHOULD be called
623+
if c := logger.ErrorCount(); c != 1 {
624+
t.Errorf("expected error log on timeout, got %d", c)
625+
}
626+
})
627+
}
628+
629+
// TestTimeoutZeroBoundary tests the boundary condition at chain.go:361
630+
// where timeout <= 0 returns the original job unchanged.
631+
// This kills mutations that change <= to < (boundary mutation).
632+
func TestTimeoutZeroBoundary(t *testing.T) {
633+
tests := []struct {
634+
name string
635+
timeout time.Duration
636+
shouldBeWrapped bool
637+
}{
638+
{"negative timeout returns original", -1 * time.Second, false},
639+
{"zero timeout returns original", 0, false},
640+
{"positive timeout wraps job", 1 * time.Millisecond, true},
641+
}
642+
643+
// Test Timeout wrapper using a marker job to detect wrapping
644+
for _, tt := range tests {
645+
t.Run(tt.name, func(t *testing.T) {
646+
originalJob := FuncJob(func() {})
647+
648+
wrapper := Timeout(DiscardLogger, tt.timeout)
649+
result := wrapper(originalJob)
650+
651+
// For FuncJob, we can't easily detect wrapping by type.
652+
// Instead, check that when timeout <= 0, the returned job
653+
// is literally the same FuncJob (reflect.ValueOf comparison)
654+
originalVal := reflect.ValueOf(originalJob)
655+
resultVal := reflect.ValueOf(result)
656+
657+
// If not wrapped, the pointers should be equal
658+
// If wrapped, the result is a new FuncJob
659+
isSameJob := originalVal.Pointer() == resultVal.Pointer()
660+
isWrapped := !isSameJob
661+
662+
if isWrapped != tt.shouldBeWrapped {
663+
t.Errorf("Timeout(%v): wrapped=%v, want wrapped=%v",
664+
tt.timeout, isWrapped, tt.shouldBeWrapped)
665+
}
666+
})
667+
}
668+
669+
// Test TimeoutWithContext wrapper
670+
for _, tt := range tests {
671+
t.Run("WithContext_"+tt.name, func(t *testing.T) {
672+
originalJob := FuncJob(func() {})
673+
wrapper := TimeoutWithContext(DiscardLogger, tt.timeout)
674+
result := wrapper(originalJob)
675+
676+
// TimeoutWithContext returns *timeoutContextJob when wrapping
677+
_, isWrapped := result.(*timeoutContextJob)
678+
679+
if isWrapped != tt.shouldBeWrapped {
680+
t.Errorf("TimeoutWithContext(%v): wrapped=%v, want wrapped=%v",
681+
tt.timeout, isWrapped, tt.shouldBeWrapped)
682+
}
683+
})
684+
}
685+
}

0 commit comments

Comments
 (0)