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;