246 lines
7.9 KiB
Go
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())
|
|
}
|