Selkie.git | lib/Selkie/Test/ | Store.rakumod
=begin pod
=head1 NAME
Selkie::Test::Store - Conveniences for testing handlers and subscriptions
=head1 SYNOPSIS
=begin code :lang<raku>
use Test;
use Selkie::Test::Store;
my $store = mock-store(state => {
user => { name => 'Alice', roles => <admin> },
count => 0,
});
$store.register-handler('inc', -> $st, % {
(db => { count => ($st.get-in('count') // 0) + 1 },);
});
dispatch-and-tick($store, 'inc');
dispatch-and-tick($store, 'inc');
is state-at($store, 'count'), 2, 'count incremented twice';
is state-at($store, 'user', 'name'), 'Alice', 'nested state accessible';
done-testing;
=end code
=head1 DESCRIPTION
Four helpers for the common test patterns against L<Selkie::Store>:
=item C<mock-store> — build a fresh store, optionally pre-populated with nested initial state
=item C<dispatch-and-tick> — the dispatch + tick boilerplate in one call
=item C<state-at> — splat-style read that's more readable than C<$store.get-in(|@path)>
=item C<tick-until> — tick repeatedly until a condition holds, for asynchronous completion flows
These aren't assertions themselves — use plain C<is>, C<is-deeply>, etc.
around the returned values. That keeps the helpers composable and lets
you use whatever Test:: style fits.
=head1 EXAMPLES
=head2 Handler testing without a live App
=begin code :lang<raku>
my $store = mock-store;
my $app-handlers = My::App::StoreHandlers.new(:$db);
$app-handlers.register($store);
dispatch-and-tick($store, 'app/init');
is state-at($store, 'active-tab'), 'servers', 'init picks a default tab';
dispatch-and-tick($store, 'tab/select', name => 'logs');
is state-at($store, 'active-tab'), 'logs', 'dispatch changed tab';
=end code
=head2 Waiting for an asynchronous completion
Handlers that hand work to another thread (a C<start> block, an
C<async> effect, a database writer's C<.then>) dispatch their
completion event from that thread; it lands on a I<later> tick.
C<tick-until> ticks and polls until the observable state change
arrives:
=begin code :lang<raku>
dispatch-and-tick($store, 'thing/save-requested', thing => $thing);
ok tick-until($store, {
!(state-at($store, 'ui', 'thing', 'saving') // False)
}), 'save completed asynchronously';
=end code
=head2 Subscribing a widget in a test
=begin code :lang<raku>
my $text = Selkie::Widget::Text.new(text => '-', sizing => Sizing.fixed(1));
my $store = mock-store;
$store.subscribe-with-callback(
'mirror',
-> $s { $s.get-in('count') // 0 },
-> $n { $text.set-text("count: $n") },
$text,
);
dispatch-and-tick($store, 'inc'); # (assuming handler registered elsewhere)
is $text.text, 'count: 1', 'subscription callback fired';
=end code
=head1 SEE ALSO
=item L<Selkie::Store> — the reactive store being tested
=item L<Selkie::Test::Keys>, L<Selkie::Test::Supply> — for UI-level testing
=end pod
unit module Selkie::Test::Store;
use Selkie::Store;
#|( Create a fresh store, optionally pre-populated with nested state.
The C<:state> hash is deep-merged into the store's C<db> in one
shot — nested hashes become nested paths.
my $store = mock-store(state => {
app => { count => 0, user => { name => 'Alice' } },
flag => True,
});
For a typed store (see "TYPED STORE" in L<Selkie::Store>), pass
C<:state-class> or C<:initial-state> through:
my $store = mock-store(state-class => MyApp::Db);
my $store = mock-store(initial-state => MyApp::Db.new(count => 3));
C<:state> works there too — it seeds via C<assoc-in>, so the keys
must name schema slots.
)
sub mock-store(:%state, :$state-class, :$initial-state --> Selkie::Store) is export {
my $s = Selkie::Store.new(:$state-class, :$initial-state);
seed-state($s, %state) if %state;
$s;
}
sub seed-state(Selkie::Store $s, %state, *@prefix) {
for %state.kv -> $k, $v {
if $v ~~ Associative {
seed-state($s, $v, |@prefix, $k);
} else {
$s.assoc-in(|@prefix, $k, value => $v);
}
}
}
#|( Dispatch an event and immediately tick the store, replacing the
common two-liner in tests.
dispatch-and-tick($store, 'counter/inc');
dispatch-and-tick($store, 'user/set', name => 'Bob');
Equivalent to:
$store.dispatch($event, |%payload);
$store.tick;
)
sub dispatch-and-tick(Selkie::Store $store, Str:D $event, *%payload) is export {
$store.dispatch($event, |%payload);
$store.tick;
}
#|( Splat-style deep read. Same semantics as C<$store.get-in(|@path)>
but easier on the eyes in tests:
is state-at($store, 'app', 'count'), 5, 'incremented';
is state-at($store, 'counter'), 0, 'reset';
)
sub state-at(Selkie::Store $store, *@path) is export {
$store.get-in(|@path);
}
#|( Tick the store until C<&done> returns true or C<:tries> runs out.
For flows whose completion event arrives from another thread (a
C<start> block, an C<async> effect, a DB writer's C<.then>): each
round ticks the store — picking up any cross-thread dispatches —
checks the condition, then sleeps C<:delay> seconds. Returns the
final truth of C<&done>, so a timeout reports as a false assertion
at the call site rather than a throw:
dispatch-and-tick($store, 'thing/save-requested', ...);
ok tick-until($store, {
!(state-at($store, 'ui', 'thing', 'saving') // False)
}), 'save completed';
)
sub tick-until(
Selkie::Store $store,
&done,
Int :$tries = 80,
Real :$delay = 0.025,
--> Bool
) is export {
for ^$tries {
$store.tick;
return True if done();
await Promise.in($delay);
}
$store.tick;
so done();
}