template/pkg/result/flow_test.go
djmil c9ab7854cb add flow controls to the pkg/result
Combinators for composing Expect-returning calls into a single expression.
2026-08-05 21:18:50 +00:00

246 lines
7.9 KiB
Go

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())
}