Selkie.git | t/ | 72-effect-error-isolation.rakutest


use Test;
use lib 'lib';

use Selkie::Store;
use Selkie::Widget;

# An exception thrown inside an effect handler must NOT tear out of
# the dispatch loop. The framework catches it, logs it, and routes it
# to a `__effect-error` event so apps can register handlers that
# display the failure (toast / modal / telemetry) instead of staring
# at stderr behind notcurses's alt-screen.

class W does Selkie::Widget {
    method render() { self.clear-dirty }
}

plan 5;

subtest "handler-thrown exception is isolated; loop survives" => {
    plan 2;
    my $s = Selkie::Store.new;
    $s.register-fx('bad-fx', -> $store, %params {
        die "intentional handler crash";
    });
    $s.register-handler('fire-bad', -> $store, %payload {
        (('bad-fx' => { trigger => True }),);
    });

    # Dispatch should NOT propagate the exception out of tick.
    $s.dispatch('fire-bad');
    lives-ok { $s.tick }, "tick survived the throwing handler";

    # And the store must still process subsequent dispatches.
    my $seen = False;
    $s.register-handler('ping', -> $store, %payload { $seen = True; () });
    $s.dispatch('ping');
    $s.tick;
    ok $seen, "store still processes dispatches after a handler crashed";
};

subtest "handler crash dispatches __effect-error with diagnostics" => {
    plan 4;
    my $s = Selkie::Store.new;
    my %caught;
    $s.register-handler('__effect-error', -> $store, %payload {
        %caught = %payload;
        ();
    });
    $s.register-fx('crashy', -> $store, %params {
        die "boom from crashy";
    });
    $s.register-handler('fire-crashy', -> $store, %payload {
        (('crashy' => { x => 1 }),);
    });

    $s.dispatch('fire-crashy');
    $s.tick;   # dispatch + run crashy + route to __effect-error
    $s.tick;   # process the __effect-error dispatch

    is %caught<effect-name>, 'crashy', "effect-name routed";
    like %caught<error>, /'boom from crashy'/, "error message routed";
    ok %caught<exception>.defined, "exception object routed";
    is %caught<params><x>, 1, "params snapshot routed";
};

subtest "__effect-error handler that itself throws does not infinite-loop" => {
    plan 1;
    my $s = Selkie::Store.new;
    # Register a __effect-error handler that itself crashes. The
    # re-entrance guard inside !run-effect must prevent another
    # __effect-error dispatch — otherwise we'd loop forever.
    $s.register-handler('__effect-error', -> $store, %payload {
        die "error handler also crashes";
    });
    $s.register-fx('crashy', -> $store, %params {
        die "original crash";
    });
    $s.register-handler('fire', -> $store, %payload {
        (('crashy' => {}),);
    });

    $s.dispatch('fire');
    lives-ok { for ^5 { $s.tick } },
        "5 ticks survive a recursive-error scenario without hanging";
};

subtest "scalar effect payload throws with a helpful message" => {
    plan 2;
    my $s = Selkie::Store.new;
    $s.register-fx('takes-hash', -> $store, %params { });
    $s.register-handler('emit-scalar', -> $store, %payload {
        (('takes-hash' => 42),);   # scalar — was auto-wrapped pre-0.7.0
    });

    my %caught;
    $s.register-handler('__effect-error', -> $store, %payload {
        %caught = %payload;
        ();
    });

    $s.dispatch('emit-scalar');
    $s.tick;   # run handler, hit the die
    $s.tick;   # process __effect-error

    like %caught<error>, /'must be Associative'/,
        "scalar payload triggers a clear Associative-required error";
    like %caught<error>, /'Wrap it'/,
        "error message points at the fix";
};

subtest "unknown effect name is ignored without typecheck crash" => {
    plan 2;
    my $s = Selkie::Store.new;
    $s.register-handler('emit-missing-effect', -> $store, %payload {
        (('missing-fx' => { x => 1 }),);
    });

    lives-ok {
        $s.dispatch('emit-missing-effect');
        $s.tick;
    }, "unknown effect does not throw out of tick";

    my $seen = False;
    $s.register-handler('after-missing-effect', -> $store, %payload {
        $seen = True;
        ();
    });
    $s.dispatch('after-missing-effect');
    $s.tick;
    ok $seen, "store still processes events after unknown effect";
};