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";
};