Selkie.git | t/ | 78-subscription-digest.rakutest
use Test;
use lib 'lib';
use Selkie::Store;
use Selkie::Widget;
# Change detection is digest-based (see Store !value-digest / !sub-changed):
# Hashes / Arrays are folded structurally and every leaf — value type OR
# object — is keyed by its .WHICH. For value types .WHICH is content-
# derived (two equal Strs share it); for references it is identity (a
# wholesale-replaced record reads as changed, the same instance as
# unchanged). Object attributes are NOT folded: the store holds things
# like the focused Selkie widget, whose attribute graph has native plane
# handles (which read back as the NQP null) and parent<->child cycles —
# folding those crashed / exploded the walk. Identity is the right (and
# for Cantina's immutable, replace-wholesale records, exact) comparison.
#
# This file pins that contract.
class TestWidget does Selkie::Widget {
has Int $.fires = 0;
method render() { self.clear-dirty }
method mark-dirty() { $!fires++; nextsame; }
}
class Inner { has Str $.note; has Int $.n; }
class Heavy { has Int $.id; has Str $.body; has Inner $.inner; }
sub heavy(Int $id, Str $body, Str $note, Int $n) {
Heavy.new(:$id, :$body, inner => Inner.new(:$note, :$n));
}
# A cyclic, native-attr-laden graph like a focused widget subtree.
class Node { has Node $.parent is rw; has @.children; has Str $.name; }
plan 7;
subtest 'value-typed leaves compare by content, not identity' => {
plan 3;
# The fingerprint-Str pattern: a computed sub returns a FRESH Str each
# tick and must fire only when the string content changes.
my $s = Selkie::Store.new;
my $w = TestWidget.new;
$s.register-handler('set', -> $st, %ev { (db => { n => %ev<v> },); });
$s.dispatch('set', v => 1); $s.tick;
$s.subscribe-with-callback('fp',
-> $st { "v={$st.get-in('n')}" }, # fresh Str instance each call
-> $ { }, $w);
$s.tick;
my $base = $w.fires;
$s.dispatch('set', v => 1); $s.tick;
is $w.fires, $base, 'identical fingerprint string does not fire (content compare)';
$s.dispatch('set', v => 2); $s.tick;
is $w.fires, $base + 1, 'changed fingerprint string fires';
$s.dispatch('set', v => 2); $s.tick;
is $w.fires, $base + 1, 'no spurious fire when content is unchanged';
};
subtest 'object leaves compare by identity (replace fires, same is silent)' => {
plan 2;
my $s = Selkie::Store.new;
my $w = TestWidget.new;
$s.register-handler('put', -> $st, %ev { (db => { slot => %ev<v> },); });
my $h = heavy(1, 'hello', 'calm', 0);
$s.dispatch('put', v => $h); $s.tick;
$s.subscribe('slot-sub', ['slot'], $w);
my $base = $w.fires;
# Same instance again → identity unchanged → silent.
$s.dispatch('put', v => $h); $s.tick;
is $w.fires, $base, 'writing the same record instance does not fire';
# A fresh record (Cantina replaces wholesale on any real change) →
# identity changed → fires.
$s.dispatch('put', v => heavy(1, 'hello', 'EDITED', 0)); $s.tick;
is $w.fires, $base + 1, 'a replaced record fires';
};
subtest 'in-place assoc-in on a nested scalar leaf fires (structural)' => {
plan 2;
my $s = Selkie::Store.new;
my $w = TestWidget.new;
$s.assoc-in('prefs', 'theme', value => 'dark');
$s.subscribe('prefs-sub', ['prefs'], $w); # default structural sub
$s.tick;
my $base = $w.fires;
# assoc-in mutates the leaf inside the live prefs Hash; the Hash's
# identity is unchanged but the leaf's .WHICH moves, so it's caught.
$s.assoc-in('prefs', 'theme', value => 'light');
$s.tick;
is $w.fires, $base + 1, 'deep assoc-in mutation is detected';
$s.assoc-in('prefs', 'theme', value => 'light'); # no-op
$s.tick;
is $w.fires, $base + 1, 'a no-op write does not fire';
};
subtest 'unrelated sibling write with undefined leaf does not fire' => {
plan 1;
# Regression: get-in returns Nil when an intermediate is missing but
# the Any leaf when only the final key is absent. Both are "no value"
# and must digest identically, or a write to user.age spuriously
# fires a subscription on user.name.
my $s = Selkie::Store.new;
my $w = TestWidget.new;
$s.subscribe('name-sub', ('user', 'name'), $w);
$s.tick;
my $base = $w.fires;
$s.register-handler('age', -> $st, %ev { (db => { user => { age => 30 } },); });
$s.dispatch('age'); $s.tick;
is $w.fires, $base, 'sibling user.age write leaves user.name subscription silent';
};
subtest 'cyclic / native-attr object graph: no crash, compares by identity' => {
plan 2;
# Regression for the launch crash: the digest used to fold object
# attributes, which recursed into a focused widget's native plane
# handles (NQPMu) and parent<->child cycles. Identity sidesteps both.
my $a = Node.new(name => 'a');
my $b = Node.new(name => 'b');
$a.children.push($b);
$b.parent = $a; # cycle
my $s = Selkie::Store.new;
my $w = TestWidget.new;
$s.register-handler('focus',
-> $st, %ev { (db => { ui => { focused => %ev<v> } },); });
$s.dispatch('focus', v => $a); $s.tick;
lives-ok {
$s.subscribe-path-callback('foc', ['ui', 'focused'], -> $ { }, $w);
$s.tick;
$s.dispatch('focus', v => $a); $s.tick; # same instance
$s.dispatch('focus', v => $b); $s.tick; # different instance
}, 'digesting a cyclic widget-like object never crashes or hangs';
pass 'cyclic-graph subscription handled by identity';
};
subtest 'regression guard: comparison stays cheap on large structures' => {
plan 1;
# 30 records (~2 KB body each), 100 reload ticks. Identity-keyed leaves
# do this in single-digit ms; the old per-field eqv took multiple
# seconds (~2.5 ms × 30 × 100 ≈ 7.5 s). A 3 s ceiling has wide margin
# over the digest path while still tripping on an eqv regression.
my $s = Selkie::Store.new;
my $w = TestWidget.new;
$s.register-handler('put', -> $st, %ev { (db => { big => %ev<v> },); });
my $body = 'x' x 2000;
sub batch(Int $salt) { (^30).map({ heavy($_, $body, "n-{$_}-$salt", $_) }).Array }
$s.dispatch('put', v => batch(0)); $s.tick;
$s.subscribe('big-sub', ['big'], $w);
my $start = now;
for ^100 -> $i { $s.dispatch('put', v => batch($i + 1)); $s.tick; }
my $elapsed = (now - $start).Num;
ok $elapsed < 3, "100 reload ticks over 30 records in {$elapsed.fmt('%.3f')}s (< 3 s)";
};
subtest 'Blob and Buf leaves are compact digest tokens' => {
plan 2;
my $s = Selkie::Store.new;
my $w = TestWidget.new;
my $buf = Buf[uint8].allocate(4 * 1024 * 1024);
$s.register-handler('put-upload', -> $st, %ev {
(db => { upload => { blob => %ev<blob>, sha => %ev<sha> } },);
});
$s.dispatch('put-upload', blob => $buf, sha => 'same'); $s.tick;
$s.subscribe('upload-sub', ['upload'], $w);
$s.tick;
my $base = $w.fires;
my $start = now;
for ^25 {
$s.dispatch('put-upload', blob => $buf, sha => 'same');
$s.tick;
}
my $elapsed = (now - $start).Num;
is $w.fires, $base, 'same Buf leaf under a parent hash stays silent';
ok $elapsed < 2,
"25 parent digests with a 4 MB Buf in {$elapsed.fmt('%.3f')}s (< 2 s)";
};
done-testing;