Selkie.git | t/ | 19-store.rakutest


use Test;
use lib 'lib';

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

# Concrete stub for testing
class TestWidget does Selkie::Widget {
    method render() { self.clear-dirty }
}

plan 33;

subtest "empty store" => {
    plan 2;
    my $s = Selkie::Store.new;
    is $s.db.elems, 0, "empty db";
    nok $s.get-in('x').defined, "get-in returns Nil for missing";
};

subtest "get-in / assoc-in" => {
    plan 3;
    my $s = Selkie::Store.new;
    $s.assoc-in('a', 'b', 'c', value => 42);
    is $s.get-in('a', 'b', 'c'), 42, "nested set/get";
    is $s.get-in('a', 'b').keys.sort.join(','), 'c', "intermediate hash exists";
    nok $s.get-in('a', 'x').defined, "missing path returns Nil";
};

subtest "assoc-in overwrites" => {
    plan 1;
    my $s = Selkie::Store.new;
    $s.assoc-in('x', value => 1);
    $s.assoc-in('x', value => 2);
    is $s.get-in('x'), 2, "overwrite works";
};

subtest "dispatch + handler + :db fx" => {
    plan 2;
    my $s = Selkie::Store.new;
    $s.register-handler('set-name', -> $store, %ev {
        (db => { user => { name => %ev<name> } },);
    });
    $s.dispatch('set-name', name => 'Matt');
    $s.tick;
    is $s.get-in('user', 'name'), 'Matt', "handler updated db";
    nok $s.get-in('user', 'age').defined, "unset paths still nil";
};

subtest "deep merge preserves existing keys" => {
    plan 2;
    my $s = Selkie::Store.new;
    $s.assoc-in('user', 'name', value => 'Matt');
    $s.assoc-in('user', 'age', value => 30);
    $s.register-handler('update', -> $store, %ev {
        (db => { user => { name => 'Updated' } },);
    });
    $s.dispatch('update');
    $s.tick;
    is $s.get-in('user', 'name'), 'Updated', "name updated";
    is $s.get-in('user', 'age'), 30, "age preserved";
};

subtest "db deep merge with empty hash preserves populated subtree" => {
    plan 2;
    my $s = Selkie::Store.new;
    $s.assoc-in('ui', 'image-gen', value => %(
        state    => 'sampling',
        progress => %(value => 4, max => 10),
    ));
    $s.register-handler('clear', -> $store, %ev {
        (db => { ui => { image-gen => %() } },);
    });
    $s.dispatch('clear');
    $s.tick;
    is $s.get-in('ui', 'image-gen')<state>, 'sampling',
        "empty Hash merge does not clear existing keys";
    is $s.get-in('ui', 'image-gen')<progress><value>, 4,
        "nested keys are preserved too";
};

subtest "db-replace replaces subtree with exact value" => {
    plan 3;
    my $s = Selkie::Store.new;
    $s.assoc-in('ui', 'image-gen', value => %(
        state    => 'sampling',
        progress => %(value => 4, max => 10),
    ));
    $s.assoc-in('ui', 'image-card-status', value => %(42 => %(state => 'generating')));
    $s.register-handler('replace', -> $store, %ev {
        (db-replace => { path => <ui image-gen>, value => %() },);
    });
    $s.dispatch('replace');
    $s.tick;
    my %slot = ($s.get-in('ui', 'image-gen') // %()).Hash;
    is %slot.elems, 0, "subtree replaced with empty Hash";
    nok %slot<state>:exists, "old scalar key removed";
    is $s.get-in('ui', 'image-card-status')<42><state>, 'generating',
        "sibling subtree preserved";
};

subtest "db-delete removes nested key and preserves siblings" => {
    plan 3;
    my $s = Selkie::Store.new;
    $s.assoc-in('ui', 'image-gen', value => %(state => 'sampling'));
    $s.assoc-in('ui', 'image-card-status', value => %(42 => %(state => 'generating')));
    $s.register-handler('delete', -> $store, %ev {
        (db-delete => { path => <ui image-gen> },);
    });
    $s.dispatch('delete');
    $s.tick;
    nok $s.get-in('ui', 'image-gen').defined, "target key deleted";
    ok $s.get-in('ui').keys.grep('image-card-status').elems,
        "sibling key still present";
    is $s.get-in('ui', 'image-card-status')<42><state>, 'generating',
        "sibling value preserved";
};

subtest "db-replace and db-delete notify path subscribers" => {
    plan 4;
    my $s = Selkie::Store.new;
    my $w = TestWidget.new;
    $s.assoc-in('ui', 'image-gen', value => %(state => 'sampling'));
    $s.subscribe('image-gen-slot', ('ui', 'image-gen'), $w);
    $w.clear-dirty;

    $s.register-handler('replace', -> $store, %ev {
        (db-replace => { path => <ui image-gen>, value => %() },);
    });
    $s.dispatch('replace');
    $s.tick;
    ok $w.is-dirty, "replace marks subscribed widget dirty";
    is ($s.get-in('ui', 'image-gen') // %()).Hash.elems, 0,
        "replacement applied before subscription fires";

    $w.clear-dirty;
    $s.register-handler('delete', -> $store, %ev {
        (db-delete => { path => <ui image-gen> },);
    });
    $s.dispatch('delete');
    $s.tick;
    ok $w.is-dirty, "delete marks subscribed widget dirty";
    nok $s.get-in('ui', 'image-gen').defined,
        "delete applied before subscription fires";
};

subtest "event chaining via :dispatch fx" => {
    plan 1;
    my $s = Selkie::Store.new;
    $s.register-handler('first', -> $store, %ev {
        (dispatch => { event => 'second', val => 'from-first' },);
    });
    $s.register-handler('second', -> $store, %ev {
        (db => { result => %ev<val> },);
    });
    $s.dispatch('first');
    $s.tick;
    is $s.get-in('result'), 'from-first', "chained event processed";
};

subtest "multiple handlers on same event" => {
    plan 2;
    my $s = Selkie::Store.new;
    $s.register-handler('multi', -> $store, %ev {
        (db => { a => 1 },);
    });
    $s.register-handler('multi', -> $store, %ev {
        (db => { b => 2 },);
    });
    $s.dispatch('multi');
    $s.tick;
    is $s.get-in('a'), 1, "first handler ran";
    is $s.get-in('b'), 2, "second handler ran";
};

subtest "no handler for event is fine" => {
    plan 1;
    my $s = Selkie::Store.new;
    $s.dispatch('nonexistent');
    lives-ok { $s.tick }, "tick with no handler doesn't die";
};

subtest "path subscription marks dirty on change" => {
    plan 3;
    my $s = Selkie::Store.new;
    my $w = TestWidget.new;
    $s.subscribe('name-sub', ('user', 'name'), $w);
    $w.clear-dirty;
    nok $w.is-dirty, "widget clean before tick";

    $s.register-handler('set', -> $store, %ev {
        (db => { user => { name => 'Changed' } },);
    });
    $s.dispatch('set');
    $s.tick;
    ok $w.is-dirty, "widget dirty after subscribed path changed";

    $w.clear-dirty;
    $s.tick;  # tick again with no changes
    nok $w.is-dirty, "widget NOT dirty when value unchanged";
};

subtest "path subscription ignores unrelated changes" => {
    plan 1;
    my $s = Selkie::Store.new;
    my $w = TestWidget.new;
    $s.subscribe('name-sub', ('user', 'name'), $w);
    # First tick primes the subscription (UNSET → current value)
    $s.tick;
    $w.clear-dirty;

    $s.register-handler('set-age', -> $store, %ev {
        (db => { user => { age => 30 } },);
    });
    $s.dispatch('set-age');
    $s.tick;
    nok $w.is-dirty, "widget NOT dirty when different path changed";
};

subtest "computed subscription" => {
    plan 2;
    my $s = Selkie::Store.new;
    my $w = TestWidget.new;
    $s.assoc-in('first', value => 'Hello');
    $s.assoc-in('second', value => 'World');

    $s.subscribe-computed('full', -> $store {
        ($store.get-in('first') // '') ~ ' ' ~ ($store.get-in('second') // '');
    }, $w);
    $w.clear-dirty;

    $s.register-handler('change', -> $store, %ev {
        (db => { first => 'Hi' },);
    });
    $s.dispatch('change');
    $s.tick;
    ok $w.is-dirty, "computed sub fires when input changes";

    $w.clear-dirty;
    $s.tick;
    nok $w.is-dirty, "computed sub stable when no change";
};

subtest "unsubscribe" => {
    plan 1;
    my $s = Selkie::Store.new;
    my $w = TestWidget.new;
    $s.subscribe('temp', ('x',), $w);
    $s.unsubscribe('temp');
    $w.clear-dirty;

    $s.register-handler('set-x', -> $store, %ev {
        (db => { x => 1 },);
    });
    $s.dispatch('set-x');
    $s.tick;
    nok $w.is-dirty, "unsubscribed widget not notified";
};

subtest "unsubscribe-widget removes all subs for widget" => {
    plan 1;
    my $s = Selkie::Store.new;
    my $w = TestWidget.new;
    $s.subscribe('sub1', ('a',), $w);
    $s.subscribe('sub2', ('b',), $w);
    $s.unsubscribe-widget($w);
    $w.clear-dirty;

    $s.register-handler('set', -> $store, %ev {
        (db => { a => 1, b => 2 },);
    });
    $s.dispatch('set');
    $s.tick;
    nok $w.is-dirty, "all subs removed for widget";
};

subtest "handler receives current db state" => {
    plan 1;
    my $s = Selkie::Store.new;
    $s.assoc-in('count', value => 5);
    $s.register-handler('inc', -> $store, %ev {
        (db => { count => ($store.get-in('count') // 0) + 1 },);
    });
    $s.dispatch('inc');
    $s.tick;
    is $s.get-in('count'), 6, "handler saw existing state";
};

subtest "async fx dispatches on success" => {
    plan 1;
    my $s = Selkie::Store.new;
    $s.register-handler('start', -> $store, %ev {
        (async => {
            work       => -> { 42 },
            on-success => 'done',
        },);
    });
    $s.register-handler('done', -> $store, %ev {
        (db => { result => %ev<result> },);
    });
    $s.dispatch('start');
    $s.tick;
    # async runs on a thread — wait for it
    sleep 0.1;
    $s.tick;  # process the dispatched 'done' event
    is $s.get-in('result'), 42, "async result delivered";
};

subtest "async fx dispatches on failure" => {
    plan 4;
    my $s = Selkie::Store.new;
    $s.register-handler('start', -> $store, %ev {
        (async => {
            work       => -> { die "boom" },
            on-failure => 'failed',
        },);
    });
    my %failed;
    $s.register-handler('failed', -> $store, %ev {
        %failed = %ev;
        (db => { error => %ev<error> },);
    });
    $s.dispatch('start');
    $s.tick;
    $s.drain-async(:timeout(2));
    $s.tick;
    like $s.get-in('error'), /'boom'/, "failure event dispatched error message";
    like %failed<error>, /'boom'/, "failure payload includes error";
    ok %failed<exception>.defined, "failure payload includes exception object";
    ok %failed<backtrace>.defined && %failed<backtrace>.chars > 0,
        "failure payload includes backtrace";
};

subtest "get-in returns Array elements correctly" => {
    plan 4;
    my $s = Selkie::Store.new;
    my @items = <alpha beta gamma>;
    $s.assoc-in('list', value => @items);
    my $got = $s.get-in('list');
    ok $got ~~ Positional, "get-in returns something Positional";
    is $got.elems, 3, "correct number of elements";
    is $got[0], 'alpha', "first element accessible";

    # Critical: assigning get-in result to @ variable must decontainerize
    my @assigned = $s.get-in('list');
    is @assigned[0], 'alpha', "@ assignment extracts first element (not nested array)";
};

subtest "get-in returns objects correctly" => {
    plan 2;
    my $s = Selkie::Store.new;
    class TestObj { has Str $.name; }
    my @objs = TestObj.new(name => 'one'), TestObj.new(name => 'two');
    $s.assoc-in('items', value => @objs);
    $s.assoc-in('current', value => @objs[0]);

    my $current = $s.get-in('current');
    is $current.name, 'one', "get-in returns the object, not array";

    # Store Array then access element by index
    my @from-store = $s.get-in('items');
    is @from-store[0].name, 'one', "array element is the object, not nested";
};

subtest "handler gets decontainerized values via get-in" => {
    plan 2;
    my $s = Selkie::Store.new;
    my @items = <alpha beta gamma>;
    $s.assoc-in('app', 'items', value => @items);
    $s.assoc-in('app', 'current', value => @items[0]);

    my $saw-items-count;
    my $saw-current;
    $s.register-handler('check', -> $store, %ev {
        my @i = $store.get-in('app', 'items') // [];
        $saw-items-count = @i.elems;
        $saw-current = $store.get-in('app', 'current');
        ();
    });
    $s.dispatch('check');
    $s.tick;
    is $saw-items-count, 3, "handler sees array elements directly via get-in";
    is $saw-current, 'alpha', "handler sees scalar value via get-in";
};

subtest "handler array indexing works via get-in" => {
    plan 1;
    my $s = Selkie::Store.new;
    class TestItem { has Str $.label; }
    my @items = TestItem.new(label => 'first'), TestItem.new(label => 'second');
    $s.assoc-in('things', value => @items);

    my $got-label;
    $s.register-handler('pick', -> $store, %ev {
        my @things = $store.get-in('things') // [];
        $got-label = @things[0].label;
        ();
    });
    $s.dispatch('pick');
    $s.tick;
    is $got-label, 'first', "handler can index array and call methods on elements";
};

subtest "infinite dispatch loop protection" => {
    plan 1;
    my $s = Selkie::Store.new;
    my $count = 0;
    $s.register-handler('loop', -> $store, %ev {
        $count++;
        (dispatch => { event => 'loop' },);
    });
    $s.dispatch('loop');
    $s.tick;
    ok $count <= 100, "loop capped at 100 iterations (ran $count)";
};

subtest "idle tick skips subscription compute" => {
    plan 3;
    my $s = Selkie::Store.new;
    my $w = TestWidget.new;
    my $compute-count = 0;
    $s.subscribe-computed('counter', -> $store {
        $compute-count++;
        $store.get-in('x') // 0;
    }, $w);

    $s.tick;  # primes — compute runs once
    is $compute-count, 1, "compute runs on priming tick";

    $s.tick;  # no events queued — compute should NOT run again
    is $compute-count, 1, "compute does NOT run on idle tick";

    $s.register-handler('set-x', -> $store, %ev {
        (db => { x => 42 },);
    });
    $s.dispatch('set-x');
    $s.tick;
    is $compute-count, 2, "compute runs again when an event was processed";
};

subtest "late-registered subscription primes on next tick" => {
    plan 2;
    my $s = Selkie::Store.new;
    $s.assoc-in('count', value => 7);
    $s.tick;  # initial prime with no subs

    my $w = TestWidget.new;
    my $seen;
    $s.subscribe-with-callback('late', -> $store {
        $store.get-in('count') // 0;
    }, -> $v { $seen = $v }, $w);
    $w.clear-dirty;

    # No dispatches, but the new subscription should still be primed.
    $s.tick;
    is $seen, 7, "callback fired with initial value on first tick after registration";
    ok $w.is-dirty, "widget marked dirty on priming tick";
};

subtest "multiple idle ticks do not re-fire primed subscription" => {
    plan 1;
    my $s = Selkie::Store.new;
    my $w = TestWidget.new;
    my $fire-count = 0;
    $s.subscribe-with-callback('steady', -> $store {
        $store.get-in('x') // 0;
    }, -> $v { $fire-count++ }, $w);

    $s.tick;  # primes, callback fires once with 0
    $s.tick;  # idle
    $s.tick;  # idle
    $s.tick;  # idle
    is $fire-count, 1, "callback fires exactly once across idle ticks";
};

# --- Subscription mutation safety ----------------------------------------
#
# Regression coverage for the "Type check failed in binding to parameter
# '%sub'" crash that fired when a subscription callback called
# `unsubscribe` mid-tick. `Hash.kv` iterates lazily; without an `:exists`
# guard the next pair-bind hits Nil and fails the Associative check.
# Fixed in 0.5.3 — these subtests pin the new contract: callbacks may
# unsubscribe themselves or any sibling without crashing the iteration.

subtest "callback unsubscribing itself does not crash mid-tick" => {
    plan 2;
    my $s = Selkie::Store.new;
    my $w = TestWidget.new;
    my $fired = 0;
    $s.register-handler('bump', -> $st, %ev {
        (db => { n => ($st.get-in('n') // 0) + 1 },);
    });

    $s.subscribe-with-callback('self-killer',
        -> $store { $store.get-in('n') // 0 },
        -> $v {
            $fired++;
            $s.unsubscribe('self-killer');
        },
        $w);

    $s.dispatch('bump');
    lives-ok { $s.tick }, "tick survives self-unsubscribing callback";
    is $fired, 1, "callback fired exactly once before unsubscribing";
};

subtest "callback unsubscribing a sibling does not crash mid-tick" => {
    plan 2;
    my $s = Selkie::Store.new;
    my $w = TestWidget.new;
    $s.register-handler('bump', -> $st, %ev {
        (db => { n => ($st.get-in('n') // 0) + 1 },);
    });

    my $sibling-fired = 0;
    $s.subscribe-with-callback('sibling',
        -> $store { $store.get-in('n') // 0 },
        -> $v { $sibling-fired++ },
        $w);

    my $killer-fired = 0;
    $s.subscribe-with-callback('killer',
        -> $store { $store.get-in('n') // 0 },
        -> $v {
            $killer-fired++;
            $s.unsubscribe('sibling');
        },
        $w);

    $s.dispatch('bump');
    lives-ok { $s.tick }, "tick survives sibling-unsubscribing callback";
    ok $killer-fired >= 1, "killer callback fired";
};

subtest "many sibling subs killed in one callback do not crash" => {
    plan 1;
    my $s = Selkie::Store.new;
    my $w = TestWidget.new;
    $s.register-handler('bump', -> $st, %ev {
        (db => { n => ($st.get-in('n') // 0) + 1 },);
    });

    # Register 20 dummy subs and one mass-killer that nukes them all
    # from a single callback firing — exercises the worst case where
    # most of the snapshot-keys list points at deleted entries by the
    # time we get to them.
    for ^20 -> $i {
        $s.subscribe-with-callback("dummy-$i",
            -> $store { $store.get-in('n') // 0 },
            -> $v { },
            $w);
    }
    $s.subscribe-with-callback('mass-killer',
        -> $store { $store.get-in('n') // 0 },
        -> $v { $s.unsubscribe("dummy-$_") for ^20 },
        $w);

    $s.dispatch('bump');
    lives-ok { $s.tick }, "tick survives mass-unsubscribe in one callback";
};

subtest "unsubscribe-widget cascade mid-tick does not corrupt iteration" => {
    # Forward-coverage of the contract: a callback may
    # `unsubscribe-widget` many siblings in one go (each widget
    # carrying its own cluster of subs) without breaking the outer
    # iteration. Multiple trials defeat hash-order luck.
    plan 1;

    my $message-count   = 40;   # roughly one chat-pane's worth
    my $subs-per-widget = 3;    # text-update, scroll, focus
    my $trial-count     = 8;    # defeat hash randomization

    my $all-survived = True;
    for ^$trial-count {
        my $s = Selkie::Store.new;
        my @msg-widgets = TestWidget.new xx $message-count;

        $s.register-handler('rebuild-list', -> $st, %ev {
            (db => { generation => ($st.get-in('generation') // 0) + 1 },);
        });

        for @msg-widgets.kv -> $i, $w {
            for ^$subs-per-widget -> $j {
                $s.subscribe-with-callback("msg-{$i}-sub-{$j}",
                    -> $store { $store.get-in('generation') // 0 },
                    -> $v { },
                    $w);
            }
        }

        my $orchestrator-widget = TestWidget.new;
        $s.subscribe-with-callback('orchestrator',
            -> $store { $store.get-in('generation') // 0 },
            -> $v {
                # The actual Cantina path: destroy all message widgets,
                # each cascading unsubscribe-widget.
                $s.unsubscribe-widget($_) for @msg-widgets;
            },
            $orchestrator-widget);

        $s.tick;                  # priming
        $s.dispatch('rebuild-list');

        my $survived = True;
        try {
            $s.tick;
            CATCH { default { $survived = False } }
        }
        $all-survived = False unless $survived;
    }

    ok $all-survived,
        "$trial-count trials of {$message-count}-widget mid-tick cascade all survived";
};

subtest "path-callback widget cascade from pull-sub callback survives" => {
    # Faithful reproduction of App::Cantina's character-change path:
    # 40 "message" widgets each carrying 3 path-callback subs (the
    # ChatMessage / ImageMessage shape), plus a pull-based
    # 'orchestrator' sub whose callback synchronously tears down
    # every message widget. The teardown cascades unsubscribe-widget
    # → 120 unsubscribes back into the very %!subscriptions hash
    # !check-subscriptions is walking. Pre-defer this crashed with
    # "Hash keys must be concrete strings (got )". Post-defer, the
    # 120 unsubscribes queue into $!pending-unsubscribes and the
    # LEAVE block flushes them after the walk completes.
    plan 3;

    my $s = Selkie::Store.new;
    my $orchestrator-widget = TestWidget.new;
    my @msg-widgets = TestWidget.new xx 40;

    # Seed the data each path-callback reads, so subscribe-path-callback's
    # synchronous priming has something to compute against.
    for ^40 -> $i {
        $s.assoc-in('msgs', "m{$i}", value => { content => '' });
    }

    my $msg-callback-fires = 0;
    for @msg-widgets.kv -> $i, $w {
        for ^3 -> $j {
            $s.subscribe-path-callback(
                "msg-{$i}-sub-{$j}",
                ('msgs', "m{$i}"),
                -> $msg { $msg-callback-fires++ },
                $w,
            );
        }
    }

    my $orchestrator-fired = 0;
    $s.subscribe-with-callback(
        'orchestrator',
        -> $store { $store.get-in('generation') // 0 },
        -> $v {
            $orchestrator-fired++;
            $s.unsubscribe-widget($_) for @msg-widgets;
        },
        $orchestrator-widget,
    );

    $s.register-handler('rebuild', -> $st, %ev {
        (db => { generation => ($st.get-in('generation') // 0) + 1 },);
    });

    $s.tick;                  # primes (orchestrator fires once with 0)
    $msg-callback-fires = 0;  # reset after priming fires

    $s.dispatch('rebuild');
    lives-ok { $s.tick }, "cascading path-callback teardown mid-tick survives";
    ok $orchestrator-fired >= 1, "orchestrator callback fired";

    # Behavioral cleanup check: after the deferred flush, writing to
    # any message's path must NOT invoke a per-widget callback, since
    # they should all have been unsubscribed.
    $msg-callback-fires = 0;
    $s.assoc-in('msgs', 'm0', value => { content => 'after' });
    $s.assoc-in('msgs', 'm17', value => { content => 'after' });
    $s.tick;
    is $msg-callback-fires, 0, "per-widget subs all cleaned up by walk exit";
};