Selkie.git | t/ | 104-store-widget-index.rakutest


use Test;
use lib 'lib';

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

=begin pod
C<Store.unsubscribe-widget> is called once per widget of a torn-down
subtree — hundreds of times per modal or screen teardown — and used to
grep the entire subscription hash on each call (38-40ms per dialog
close in App::Cantina at ~850 live subs). It now reads a
C<widget → sub-ids> reverse index.

The risk an index introduces is desync, and the sharp edge is Selkie's
defer-mutations-during-walks rule: an C<unsubscribe> issued from inside
a subscription callback is queued and only applied when the walk exits.
The index therefore has to be maintained where entries B<actually>
leave the hash, not where their removal is requested. These tests pin
both the fast path and every way the two structures could drift.
=end pod

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

class C does Selkie::Container {
    method render() { self.clear-dirty }
}

plan 10;

subtest 'unsubscribe-widget removes exactly that widget subscriptions' => {
    plan 4;
    my $s = Selkie::Store.new;
    my $a = W.new;
    my $b = W.new;

    $s.subscribe('a1', ['x'], $a);
    $s.subscribe('a2', ['y'], $a);
    $s.subscribe-computed('a3', -> $st { $st.get-in('x') }, $a);
    $s.subscribe-with-callback('a4', -> $st { $st.get-in('y') }, -> $v { }, $a);
    $s.subscribe('b1', ['x'], $b);
    is $s.subscription-count, 5, 'five subscriptions registered';

    $s.unsubscribe-widget($a);
    is $s.subscription-count, 1, 'all four of a-s subscriptions removed';

    # The survivor still fires, so its push index survived too.
    my $fired = False;
    $s.subscribe-path-callback('b2', ['x'], -> $v { $fired = True }, $b);
    $s.assoc-in('x', value => 42);
    $s.tick;
    ok $fired, 'the untouched widget-s path subscription still fires';

    $s.unsubscribe-widget($b);
    is $s.subscription-count, 0, 'and it can be torn down in turn';
};

subtest 'a widget with no subscriptions is a silent no-op' => {
    plan 3;
    my $s = Selkie::Store.new;
    my $w = W.new;
    my $other = W.new;
    $s.subscribe('keep', ['x'], $other);

    lives-ok { $s.unsubscribe-widget($w) },
        'unsubscribing a widget that never subscribed is safe';
    is $s.subscription-count, 1, 'nothing else was removed';
    lives-ok { $s.unsubscribe-widget($w) for ^5 },
        'and it stays safe when repeated';
};

subtest 'repeat unsubscribe-widget on the same widget is idempotent' => {
    plan 2;
    my $s = Selkie::Store.new;
    my $w = W.new;
    $s.subscribe('a', ['x'], $w);
    $s.subscribe('b', ['y'], $w);

    $s.unsubscribe-widget($w);
    is $s.subscription-count, 0, 'first call clears them';
    lives-ok { $s.unsubscribe-widget($w); $s.unsubscribe-widget($w) },
        'later calls find an empty bucket and no-op';
};

subtest 'unsubscribe($id) keeps the index in step' => {
    plan 2;
    my $s = Selkie::Store.new;
    my $w = W.new;
    $s.subscribe('a', ['x'], $w);
    $s.subscribe('b', ['y'], $w);

    $s.unsubscribe('a');
    is $s.subscription-count, 1, 'single-id removal took effect';

    # If the index still listed 'a', this would try to remove a
    # subscription that is already gone — harmless, but it would also
    # mean a stale id could outlive a re-subscribe of the same name.
    $s.unsubscribe-widget($w);
    is $s.subscription-count, 0, 'the widget-s remaining sub is removed';
};

subtest 're-subscribing an id to a different widget re-homes it' => {
    plan 3;
    my $s = Selkie::Store.new;
    my $a = W.new;
    my $b = W.new;

    $s.subscribe('shared', ['x'], $a);
    $s.subscribe('shared', ['x'], $b);   # same id, new owner
    is $s.subscription-count, 1, 'the id still names one subscription';

    $s.unsubscribe-widget($a);
    is $s.subscription-count, 1,
        'the previous owner no longer controls the id';

    $s.unsubscribe-widget($b);
    is $s.subscription-count, 0, 'the current owner does';
};

subtest 'subscriptions with no widget share one bucket' => {
    plan 3;
    # The signature allows an undefined widget, and the old grep matched
    # them with ===. Keep that behaviour rather than silently orphaning
    # such entries in the index.
    my $s = Selkie::Store.new;
    my $w = W.new;
    $s.subscribe-computed('anon', -> $st { 1 }, Selkie::Widget);
    $s.subscribe('owned', ['x'], $w);
    is $s.subscription-count, 2, 'both registered';

    $s.unsubscribe-widget($w);
    is $s.subscription-count, 1, 'the owned one goes';

    $s.unsubscribe-widget(Selkie::Widget);
    is $s.subscription-count, 0, 'the widget-less one is still reachable';
};

subtest 'unsubscribe-widget during a walk defers, then settles' => {
    plan 3;
    my $s = Selkie::Store.new;
    my $victim = W.new;
    my $trigger = W.new;

    $s.subscribe('v1', ['data'], $victim);
    $s.subscribe('v2', ['data'], $victim);
    $s.subscribe-path-callback('kill', ['data'], -> $v {
        # Fired mid-walk: the removals queue and the index must not be
        # touched until the flush, or index and hash disagree for the
        # rest of the walk.
        $s.unsubscribe-widget($victim);
    }, $trigger);

    $s.assoc-in('data', value => 1);
    $s.tick;

    is $s.subscription-count, 1,
        'the victim-s subs are gone after the walk (only the trigger left)';

    # A second write must not resurrect or double-remove anything.
    $s.assoc-in('data', value => 2);
    $s.tick;
    is $s.subscription-count, 1, 'still consistent on the next tick';

    lives-ok { $s.unsubscribe-widget($victim) },
        'the emptied bucket is gone, so a repeat call no-ops';
};

subtest 'unsubscribe-widget then re-subscribe inside one walk' => {
    plan 2;
    my $s = Selkie::Store.new;
    my $w = W.new;
    my $trigger = W.new;
    my $fires = 0;

    $s.subscribe('sub', ['data'], $w);
    $s.subscribe-path-callback('swap', ['data'], -> $v {
        $s.unsubscribe-widget($w);
        # Re-registering the same id during the walk must cancel the
        # queued removal AND leave the index pointing at the new entry.
        $s.subscribe-path-callback('sub', ['data'], -> $x { $fires++ }, $w);
    }, $trigger);

    $s.assoc-in('data', value => 1);
    $s.tick;
    is $s.subscription-count, 2,
        'the re-subscribed id survives the deferred flush';

    $s.unsubscribe-widget($w);
    is $s.subscription-count, 1,
        'and the index resolves it to the right widget afterwards';
};

subtest 'a cascade of many widget teardowns in one callback' => {
    plan 2;
    # The shape that produced the original 38-40ms measurement: one
    # store change tears down a whole message list.
    my $s = Selkie::Store.new;
    my $trigger = W.new;
    my @kids = W.new xx 50;
    for @kids.kv -> $i, $w {
        $s.subscribe("kid-{$i}-a", ['msgs'], $w);
        $s.subscribe("kid-{$i}-b", ['msgs', $i], $w);
    }
    $s.subscribe-path-callback('rebuild', ['msgs'], -> $v {
        $s.unsubscribe-widget($_) for @kids;
    }, $trigger);

    $s.assoc-in('msgs', value => [1, 2, 3]);
    $s.tick;
    is $s.subscription-count, 1, 'all 100 child subscriptions removed';

    # Push-index integrity: the surviving sub must still fire.
    my $fired = 0;
    $s.subscribe-path-callback('after', ['msgs'], -> $v { $fired++ }, $trigger);
    $fired = 0;   # discard the synchronous prime fire
    $s.assoc-in('msgs', value => [4]);
    $s.tick;
    is $fired, 1, 'the push index survived the mass removal';
};

subtest 'container teardown paths still clean up through the index' => {
    plan 3;
    my $s = Selkie::Store.new;
    my $root = C.new;
    my $a = W.new;
    my $b = W.new;
    $root.set-store($s);
    $root.add($a);
    $root.add($b);

    $s.subscribe('a', ['x'], $a);
    $s.subscribe('b', ['x'], $b);
    $s.subscribe('r', ['x'], $root);
    is $s.subscription-count, 3, 'three registered';

    $root.remove($a);
    is $s.subscription-count, 2, 'Container.remove unsubscribed the child';

    $root.clear;
    is $s.subscription-count, 1, 'Container.clear unsubscribed the rest';
};

done-testing;