Selkie.git | t/ | 63-deep-equality-check.rakutest
use Test;
use lib 'lib';
use Selkie::Store;
use Selkie::Widget;
# Subscription change-detection semantics. As of Selkie 0.8.0:
# - All four subscribe variants default to structural (:!identity-check-only)
# comparison plus a per-fire digest so deep in-place mutations
# are observed correctly. This is the safe path.
# - :identity-check-only is the opt-out for hot paths where the
# compute is known to return a fresh value each call. It uses an
# === pre-check before the digest path.
#
# The trap that motivated the default flip: assoc-in mutates the live
# nested Hash in place. A sub watching an ancestor path holds a
# reference to that same Hash; an identity-only shortcut returns True
# after the mutation and silently suppresses the fire. The default
# now re-digests so the leaf-token change is detected.
class TestWidget does Selkie::Widget {
method render() { self.clear-dirty }
}
plan 9;
subtest "subscribe — default DETECTS ancestor-fire on in-place deep write" => {
plan 2;
my $s = Selkie::Store.new;
my $w = TestWidget.new;
$s.assoc-in('a', 'b', 'c', value => 1);
$s.subscribe('watch-a', ['a'], $w);
$w.render;
nok $w.is-dirty, "clean after prime";
$s.assoc-in('a', 'b', 'c', value => 42);
$s.tick;
ok $w.is-dirty,
"default (structural compare): deep assoc-in FIRES the ancestor sub";
};
subtest "subscribe — :identity-check-only restores fast-path suppression" => {
plan 2;
my $s = Selkie::Store.new;
my $w = TestWidget.new;
$s.assoc-in('a', 'b', 'c', value => 1);
$s.subscribe('watch-a', ['a'], $w, :identity-check-only);
$w.render;
nok $w.is-dirty, "clean after prime";
$s.assoc-in('a', 'b', 'c', value => 42);
$s.tick;
nok $w.is-dirty,
":identity-check-only: live-ref ancestor mutation is suppressed (=== shortcut)";
};
subtest "subscribe — default suppresses no-op writes" => {
plan 1;
my $s = Selkie::Store.new;
my $w = TestWidget.new;
$s.assoc-in('a', 'b', 'c', value => 7);
$s.subscribe('watch-a', ['a'], $w);
$w.render;
$s.assoc-in('a', 'b', 'c', value => 7);
$s.tick;
nok $w.is-dirty,
"no-op write doesn't spuriously fire (digest matches)";
};
subtest "subscribe — default fires twice across two distinct deep writes" => {
plan 3;
my $s = Selkie::Store.new;
my $w = TestWidget.new;
$s.assoc-in('a', 'b', 'c', value => 1);
$s.subscribe('watch-a', ['a'], $w);
$w.render;
$s.assoc-in('a', 'b', 'c', value => 2);
$s.tick;
ok $w.is-dirty, "first deep write fired";
$w.render;
nok $w.is-dirty, "clean after second render";
$s.assoc-in('a', 'b', 'c', value => 3);
$s.tick;
ok $w.is-dirty,
"second deep write also fires (digest refreshed at each fire)";
};
subtest "subscribe-path-callback — default fires callback on deep write" => {
plan 3;
my $s = Selkie::Store.new;
my $w = TestWidget.new;
$s.assoc-in('a', 'b', 'c', value => 1);
my @callbacks;
$s.subscribe-path-callback(
'watch-a-cb', ['a'],
-> $v { @callbacks.push: $v },
$w,
);
is @callbacks.elems, 1, "primed once";
$s.assoc-in('a', 'b', 'c', value => 42);
$s.tick;
is @callbacks.elems, 2,
"default: deep write triggered the callback";
is @callbacks[1]<b><c>, 42,
"callback received the live current value";
};
subtest "subscribe-path-callback — :identity-check-only suppresses on deep write" => {
plan 2;
my $s = Selkie::Store.new;
my $w = TestWidget.new;
$s.assoc-in('a', 'b', 'c', value => 1);
my @callbacks;
$s.subscribe-path-callback(
'watch-a-cb', ['a'],
-> $v { @callbacks.push: $v },
$w,
:identity-check-only,
);
is @callbacks.elems, 1, "primed once";
$s.assoc-in('a', 'b', 'c', value => 42);
$s.tick;
is @callbacks.elems, 1,
":identity-check-only: deep assoc-in does NOT trigger the path-callback";
};
subtest "subscribe-computed — default detects live-ref compute change" => {
plan 2;
my $s = Selkie::Store.new;
my $w = TestWidget.new;
$s.assoc-in('a', 'b', 'c', value => 1);
$s.subscribe-computed(
'computed-a',
-> $store { $store.get-in('a') },
$w,
);
$s.tick;
$w.render;
$s.register-handler('write', -> $st, %p {
($st.assoc-in('a', 'b', 'c', value => %p<v>),)
});
$s.dispatch('write', v => 99);
$s.tick;
ok $w.is-dirty,
"default: computed sub fires on event-driven deep write";
$w.render;
$s.dispatch('write', v => 99); # no-op — same leaf value
$s.tick;
nok $w.is-dirty, "no-op deep write doesn't spuriously fire";
};
subtest "subscribe-computed — :identity-check-only suppresses live-ref compute" => {
plan 1;
my $s = Selkie::Store.new;
my $w = TestWidget.new;
$s.assoc-in('a', 'b', 'c', value => 1);
$s.subscribe-computed(
'computed-a',
-> $store { $store.get-in('a') },
$w,
:identity-check-only,
);
$s.tick;
$w.render;
$s.register-handler('write', -> $st, %p {
($st.assoc-in('a', 'b', 'c', value => %p<v>),)
});
$s.dispatch('write', v => 99);
$s.tick;
nok $w.is-dirty,
":identity-check-only: live-ref compute returns same Hash post-mutation, no fire";
};
subtest "subscribe-with-callback — default delivers live value to callback" => {
plan 2;
my $s = Selkie::Store.new;
my $w = TestWidget.new;
$s.assoc-in('a', 'b', 'c', value => 1);
my @callbacks;
$s.subscribe-with-callback(
'cb-a',
-> $store { $store.get-in('a') },
-> $v { @callbacks.push: $v.gist },
$w,
);
$s.register-handler('write', -> $st, %p {
($st.assoc-in('a', 'b', 'c', value => %p<v>),)
});
$s.tick;
$s.dispatch('write', v => 7);
$s.tick;
is @callbacks.elems, 2,
"callback fired on event-driven deep write";
like @callbacks[1], /'c => 7'/,
"callback received the live (un-snapshotted) compute result";
};