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