Selkie.git | t/ | 95-app-animation-budget.rakutest
use Test;
use lib 'lib';
use Selkie::App;
use Selkie::App::Internal::Animation;
use Selkie::Tween;
=begin pod
The animation refcount and the lazy tween group that drives it.
C<Selkie::App.new> calls C<notcurses_init>, so an App instance needs a
terminal and cannot be built in a test. The refcount and the group
wiring therefore live in C<Selkie::App::Internal::Animation>, composed
into App exactly like C<RenderLoop> and C<Dispatch> are, and are
exercised here through a stub that composes the same role and supplies
the one thing the role needs from its host: C<on-frame>. The facade
subtest at the end pins that App still exposes all of it.
What is being guarded:
=item B<The clamp at zero.> An C<end-animation> without a matching C<begin-animation> must not push the count negative — a negative count would make the next legitimate C<begin-animation> a no-op, and the app would render an animation at 4 Hz with no way to recover.
=item B<Exactly one frame callback.> C<tweens> is called from every animation site in an app; registering the ticker more than once would tick every tween twice per frame (halving every duration) and is invisible until someone counts.
=item B<The group balances the refcount by itself.> Nobody calling C<tweens.add> should have to remember to pair begin/end.
=end pod
plan 7;
# The host contract the Animation role depends on, and nothing else.
class StubApp does Selkie::App::Internal::Animation {
has @.frame-callbacks;
method on-frame(&callback, Str :$name = '') {
@!frame-callbacks.push({ callback => &callback, name => $name });
}
# The role keeps these private (App is the public facade); re-expose
# them so the test can drive them the way App's methods do.
method begin-animation(--> Int) { self!begin-animation }
method end-animation(--> Int) { self!end-animation }
method animating(--> Bool) { self!animating }
method tweens(--> TweenGroup) { self!tween-group }
}
subtest 'the refcount flips animating and clamps at zero' => {
plan 10;
my $app = StubApp.new;
nok $app.animating, 'a fresh app is not animating';
is $app.begin-animation, 1, 'begin returns the new count';
ok $app.animating, 'and animating flips on';
is $app.begin-animation, 2, 'a second begin nests';
ok $app.animating, 'still animating';
is $app.end-animation, 1, 'one end leaves the outer one holding';
ok $app.animating, 'so it is still animating';
is $app.end-animation, 0, 'the matching end drops it to zero';
nok $app.animating, 'and animating flips off';
# The clamp: an unbalanced end must not make the next begin a
# no-op by leaving the count negative.
$app.end-animation;
$app.end-animation;
$app.end-animation;
is $app.begin-animation, 1,
'a stray end cannot drive the count negative';
};
subtest 'the tween group is created once, with one frame callback' => {
plan 5;
my $app = StubApp.new;
is $app.frame-callbacks.elems, 0,
'an app that never animates registers nothing';
my $group = $app.tweens;
isa-ok $group, TweenGroup, 'tweens hands back a TweenGroup';
is $app.frame-callbacks.elems, 1, 'and registers exactly one frame callback';
is $app.frame-callbacks[0]<name>, 'selkie-tweens',
'under the documented name, so it can be found in a trace';
$app.tweens for ^5;
is $app.frame-callbacks.elems, 1,
'repeated calls reuse the group rather than stacking tickers';
};
subtest 'the group drives the refcount without the caller doing anything' => {
plan 6;
my $app = StubApp.new;
my $t0 = Instant.from-posix(1_000);
my $group = $app.tweens;
nok $app.animating, 'holding a group does not itself animate';
$group.add(Tween.new(duration => 0.2, on-update => -> Num $ { }), at => $t0);
ok $app.animating, 'adding a tween pins the render loop to the hot budget';
$group.tick($t0 + 0.1);
ok $app.animating, 'still pinned mid-animation';
$group.tick($t0 + 0.2);
nok $app.animating, 'and released on the tick that finishes the last tween';
# Two overlapping tweens must not double-release.
$group.add(Tween.new(duration => 0.2, on-update => -> Num $ { }), at => $t0);
$group.add(Tween.new(duration => 0.4, on-update => -> Num $ { }), at => $t0);
$group.tick($t0 + 0.2);
ok $app.animating, 'one of two finishing keeps the loop hot';
$group.tick($t0 + 0.4);
nok $app.animating, 'and the second release lands the count back on zero';
};
subtest 'the frame callback is what actually ticks the group' => {
plan 3;
my $app = StubApp.new;
my @seen;
my $t0 = Instant.from-posix(2_000);
# The registered callback ticks at `now`, so drive a tween that is
# already finished by any real clock: one callback invocation must
# deliver the end value and release the refcount.
$app.tweens.add(
Tween.new(duration => 0, on-update => -> Num $v { @seen.push($v) }),
at => $t0,
);
ok $app.animating, 'refcount is up before the frame runs';
$app.frame-callbacks[0]<callback>();
is @seen.elems, 1, 'the frame callback ticked the group';
nok $app.animating, 'and the completed tween released the refcount';
};
subtest 'clearing the group releases the refcount' => {
plan 2;
my $app = StubApp.new;
my $t0 = Instant.from-posix(3_000);
$app.tweens.add(Tween.new(duration => 5, on-update => -> Num $ { }), at => $t0);
ok $app.animating, 'a long tween is holding the loop hot';
$app.tweens.clear;
nok $app.animating,
'clear releases it — a screen switch must not strand the refcount';
};
subtest 'animating overrides every rung of the idle ladder' => {
plan 4;
my constant HOT60 = 1e0 / 60e0;
my constant TOL = 1e-9;
# This is the contract the render loop relies on: an animation is
# sampled, not triggered, so idleness is irrelevant while it runs.
for 0e0, 45e0, 90e0, 100_000e0 -> $idle {
is-approx Selkie::App::pick-frame-budget(HOT60, $idle, $idle, :animating),
HOT60, TOL, "idle for {$idle}s but animating → hot budget";
}
};
subtest 'App exposes the animation facade' => {
plan 4;
for <tweens begin-animation end-animation animating> -> $name {
ok Selkie::App.^can($name).elems > 0, "$name is available on Selkie::App";
}
};