package result_test import ( "errors" "fmt" "strings" "testing" "gitea.djmil.dev/go/template/pkg/check" "gitea.djmil.dev/go/template/pkg/result" ) var errFlow = errors.New("flow failure") // TestFlowShortCircuits is the guarantee the whole style rests on: once a chain // fails, no later step runs at all — the value is not silently replaced by a // zero and carried onward — and Or resumes the chain from there. func TestFlowShortCircuits(t *testing.T) { var ran []string track := func(name string) func(int) int { return func(n int) int { ran = append(ran, name); return n } } got := result.Ok(5). Filter(func(int) bool { return false }, "rejected"). // failed from here Map(track("map")). AndThen(func(n int) result.Expect[int] { ran = append(ran, "andThen") return result.Ok(n) }). Or(result.Ok(42)). // recovered Map(track("afterOr")). UnwrapOr(-1) check.Equal(t, got, 42) check.DeepEqual(t, ran, []string{"afterOr"}) } // TestMapMethod covers the same-type transform and pins that f is skipped on a // failed Expect rather than being handed a zero value. func TestMapMethod(t *testing.T) { calls := 0 double := func(n int) int { calls++; return n * 2 } check.Equal(t, result.Ok(5).Map(double).Map(double).UnwrapOr(-1), 20) check.Equal(t, calls, 2) failed := result.Err[int](errFlow).Map(double) check.Equal(t, calls, 2) if !errors.Is(failed.Err(), errFlow) { t.Fatalf("Map should carry the failure through: %v", failed.Err()) } } // TestAndThenMethod covers the same-type fallible step: a failure returned by f // propagates, and f is skipped on a failed input. func TestAndThenMethod(t *testing.T) { calls := 0 halve := func(n int) result.Expect[int] { calls++ if n%2 != 0 { return result.Failf[int]("%d is odd", n) } return result.Ok(n / 2) } check.Equal(t, result.Ok(8).AndThen(halve).AndThen(halve).UnwrapOr(-1), 2) check.ErrorContains(t, result.Ok(5).AndThen(halve), "5 is odd") check.Equal(t, calls, 3) check.NoError(t, result.Ok(8).AndThen(halve)) calls = 0 check.Error(t, result.Err[int](errFlow).AndThen(halve)) check.Equal(t, calls, 0) } // TestFilter verifies that a rejected value becomes a failure carrying the // formatted message and the caller's file:line, and that the predicate never // sees an already-failed Expect. func TestFilter(t *testing.T) { positive := func(n int) bool { return n > 0 } check.Equal(t, check.Ok(t, result.Ok(5).Filter(positive, "must be positive")), 5) rejected := result.Ok(-5).Filter(positive, "must be positive, got sign %d", -1) msg := rejected.Err().Error() if !strings.Contains(msg, "must be positive, got sign -1") { t.Fatalf("formatted message missing: %q", msg) } if !strings.Contains(msg, "flow_test.go:") { t.Fatalf("caller file:line not prepended: %q", msg) } calls := 0 counting := func(int) bool { calls++; return true } check.Error(t, result.Err[int](errFlow).Filter(counting, "unused")) check.Equal(t, calls, 0) } // TestFilterMessageCanNameTheValue pins the documented workaround: given a // binding, the message can report the value pred rejected. The arg is evaluated // eagerly, which is harmless — on the accepting path the message is never // formatted, and on an already-failed Expect the original error survives even // though Value is the zero value. func TestFilterMessageCanNameTheValue(t *testing.T) { positive := func(n int) bool { return n > 0 } rejected := result.Ok(-5) check.ErrorContains(t, rejected.Filter(positive, "%d must be positive", rejected.Value()), "-5 must be positive") accepted := result.Ok(5) check.Equal(t, check.Ok(t, accepted.Filter(positive, "%d must be positive", accepted.Value())), 5) failed := result.Err[int](errFlow) check.ErrorContains(t, failed.Filter(positive, "%d must be positive", failed.Value()), "flow failure") } // TestMapErr verifies that context can be layered onto a failure mid-chain // without breaking it, and that a success passes through untouched. func TestMapErr(t *testing.T) { addContext := func(err error) error { return fmt.Errorf("load config: %w", err) } ok := result.Ok(5).MapErr(addContext) check.Equal(t, check.Ok(t, ok), 5) failed := result.Err[int](errFlow).MapErr(addContext) check.ErrorContains(t, failed, "load config") if !errors.Is(failed.Err(), errFlow) { t.Fatalf("%%w chain not preserved: %v", failed.Err()) } } // TestOrChain verifies left-biased precedence: the first success wins and the // rest are ignored, including their errors. func TestOrChain(t *testing.T) { var ( bad = result.Err[string](errFlow) first = result.Ok("first") second = result.Ok("second") ) check.Equal(t, first.Or(second).UnwrapOr(""), "first") check.Equal(t, bad.Or(second).UnwrapOr(""), "second") check.Equal(t, bad.Or(bad).Or(first).Or(second).UnwrapOr(""), "first") // A failing alternative leaves the chain failed, carrying its own error. last := result.Failf[string]("last resort") check.ErrorContains(t, bad.Or(last), "last resort") } // TestOrElseIsLazy pins the difference from Or: the alternative is produced // only when needed, and it receives the error it is recovering from. func TestOrElseIsLazy(t *testing.T) { calls := 0 var seen error alt := func(err error) result.Expect[string] { calls++ seen = err return result.Ok("alt") } check.Equal(t, result.Ok("v").OrElse(alt).UnwrapOr(""), "v") check.Equal(t, calls, 0) check.Equal(t, result.Err[string](errFlow).OrElse(alt).UnwrapOr(""), "alt") check.Equal(t, calls, 1) if !errors.Is(seen, errFlow) { t.Fatalf("OrElse should receive the failing error, got %v", seen) } } // TestUnwrapOr covers both exits from a chain, and pins that the lazy one skips // its closure on success and sees the error on failure. func TestUnwrapOr(t *testing.T) { check.Equal(t, result.Ok(1).UnwrapOr(9), 1) check.Equal(t, result.Err[int](errFlow).UnwrapOr(9), 9) calls := 0 var seen error fallback := func(err error) int { calls++; seen = err; return 9 } check.Equal(t, result.Ok(1).UnwrapOrElse(fallback), 1) check.Equal(t, calls, 0) check.Equal(t, result.Err[int](errFlow).UnwrapOrElse(fallback), 9) check.Equal(t, calls, 1) if !errors.Is(seen, errFlow) { t.Fatalf("UnwrapOrElse should receive the failing error, got %v", seen) } } // TestMapFunction covers the type-changing transform and pins that a failure // crosses the type boundary intact. func TestMapFunction(t *testing.T) { calls := 0 length := func(s string) int { calls++; return len(s) } check.Equal(t, result.Map(result.Ok("hello"), length).UnwrapOr(-1), 5) check.Equal(t, calls, 1) failed := result.Map(result.Err[string](errFlow), length) check.Equal(t, calls, 1) if !errors.Is(failed.Err(), errFlow) { t.Fatalf("failure lost across the type change: %v", failed.Err()) } } // TestAndThenFunction covers the type-changing fallible step, including that // the chain resumes as methods on the far side of the crossing. func TestAndThenFunction(t *testing.T) { firstRune := func(s string) result.Expect[rune] { if s == "" { return result.Failf[rune]("empty string") } return result.Ok([]rune(s)[0]) } got := result.AndThen(result.Ok("hello"), firstRune). Filter(func(r rune) bool { return r != 0 }, "null rune"). UnwrapOr('?') check.Equal(t, got, 'h') check.ErrorContains(t, result.AndThen(result.Ok(""), firstRune), "empty string") failed := result.AndThen(result.Err[string](errFlow), firstRune) if !errors.Is(failed.Err(), errFlow) { t.Fatalf("failure lost across the type change: %v", failed.Err()) } } // TestFlowIsLibrarySafe pins that combinators never exit the goroutine, so they // are usable inside pkg/ library code where .Expect() and .Must() are not. // A plain function call would not survive a Goexit; reaching the return proves // the chain stayed on the normal control path. func TestFlowIsLibrarySafe(t *testing.T) { libraryFunc := func() (out result.Expect[int]) { out = result.Err[int](errFlow). Map(func(n int) int { return n }). Filter(func(int) bool { return true }, "unused") return out } check.Error(t, libraryFunc()) }