Selkie.git | t/ | 94-tween-group.rakutest


use Test;
use lib 'lib';

use Selkie::Tween;

=begin pod

C<TweenGroup>: the bit that ticks a set of tweens together, drops them
as they finish, and — the part that actually matters — reports the
idle → busy → idle transition.

That report is what drives C<Selkie::App>'s animation refcount, and
therefore whether the render loop bypasses the idle ladder. Two
failure modes are worth naming, because both are silent:

=item An C<on-idle> that never fires leaves the app pinned at 60 Hz forever. Battery bug.
=item An C<on-idle> that fires I<between> two chained tweens drops the loop back to the ladder mid-animation. Visual bug.

The transition subtests below are the guards for both.

=end pod

plan 10;

my constant TOL = 1e-9;

# A tween that records what it was asked to do, so the group's
# behaviour can be asserted without inspecting its private list.
sub probe(Real $duration = 1, *%rest) {
    my @updates;
    my $tween = Tween.new(:$duration, on-update => -> Num $v { @updates.push($v) }, |%rest);
    ($tween, @updates);
}

subtest 'add starts the tween and hands it back' => {
    plan 4;
    my $t0 = Instant.from-posix(1_000);
    my ($tween, $updates) = probe(1);
    my $group = TweenGroup.new;

    nok $tween.running, 'a fresh tween is inert';
    my $returned = $group.add($tween, at => $t0);
    ok $returned === $tween, 'add returns the tween so the caller can keep a handle';
    ok $tween.running, 'add starts it — an unstarted member would pin the loop forever';
    is $group.elems, 1, 'and the group is holding it';
};

subtest 'add does not restart a tween that is already running' => {
    plan 1;
    my $t0 = Instant.from-posix(1_000);
    my ($tween, $updates) = probe(1);
    $tween.start($t0);
    my $group = TweenGroup.new;
    $group.add($tween, at => $t0 + 0.5);
    $group.tick($t0 + 0.5);
    is-approx $updates[0], 0.5e0, TOL,
        'the original zero point survives being added mid-flight';
};

subtest 'tick returns True only when a member actually updated' => {
    plan 4;
    my $t0 = Instant.from-posix(2_000);
    my $group = TweenGroup.new;

    nok $group.tick($t0), 'an empty group never claims to have updated';

    my ($tween, $updates) = probe(0.2);
    $group.add($tween, at => $t0);
    ok $group.tick($t0 + 0.1), 'a live member makes the tick report activity';
    ok $group.tick($t0 + 0.2), 'the final tick still reports activity';
    nok $group.tick($t0 + 0.3), 'once everything has finished, ticks are quiet again';
};

subtest 'completed tweens are dropped' => {
    plan 5;
    my $t0 = Instant.from-posix(3_000);
    my $group = TweenGroup.new;
    my ($short, $short-updates) = probe(0.1);
    my ($long,  $long-updates)  = probe(1);
    $group.add($short, at => $t0);
    $group.add($long,  at => $t0);
    is $group.elems, 2, 'both members are held';

    $group.tick($t0 + 0.1);
    is $group.elems, 1, 'the finished tween is dropped on the tick that finished it';
    ok $group.running, 'the group is still running because of the other one';
    ok $short.done, 'the dropped tween completed properly';

    $group.tick($t0 + 1);
    is $group.elems, 0, 'and the group empties out';
};

subtest 'a cancelled member is dropped on the next tick' => {
    plan 3;
    my $t0 = Instant.from-posix(4_000);
    my $group = TweenGroup.new;
    my ($tween, $updates) = probe(1);
    $group.add($tween, at => $t0);

    $tween.cancel;
    is $group.elems, 1, 'the group has not noticed yet — it only looks when it ticks';
    nok $group.tick($t0 + 0.5), 'the tick reports no activity';
    is $group.elems, 0, 'and the cancelled member is gone';
};

subtest 'running tracks the members' => {
    plan 4;
    my $t0 = Instant.from-posix(5_000);
    my $group = TweenGroup.new;
    nok $group.running, 'an empty group is not running';

    my ($tween, $updates) = probe(0.5);
    $group.add($tween, at => $t0);
    ok $group.running, 'adding makes it run';

    $group.tick($t0 + 0.25);
    ok $group.running, 'still running mid-flight';

    $group.tick($t0 + 0.5);
    nok $group.running, 'and stops when the last member finishes';
};

subtest 'the idle/active transitions fire exactly once each' => {
    plan 8;
    my $t0 = Instant.from-posix(6_000);
    my (@active, @idle);
    my $group = TweenGroup.new(
        on-active => { @active.push(1) },
        on-idle   => { @idle.push(1) },
    );

    is @active.elems, 0, 'constructing a group announces nothing';

    my ($a, $au) = probe(0.5);
    $group.add($a, at => $t0);
    is @active.elems, 1, 'the first tween activates the group';

    my ($b, $bu) = probe(1);
    $group.add($b, at => $t0);
    is @active.elems, 1, 'a second tween does not re-activate it';
    is @idle.elems, 0, 'and nothing has gone idle';

    $group.tick($t0 + 0.5);
    is @idle.elems, 0, 'one member finishing while another runs is not idle';

    $group.tick($t0 + 1);
    is @idle.elems, 1, 'the last member finishing goes idle';
    is @active.elems, 1, 'without a spurious re-activation';

    $group.add($a, at => $t0 + 1);
    is @active.elems, 2, 'and the group can be re-activated afterwards';
};

subtest 'chaining from on-complete never drops the refcount' => {
    plan 5;
    my $t0 = Instant.from-posix(7_000);
    my (@active, @idle, @second);
    my $group = TweenGroup.new(
        on-active => { @active.push(1) },
        on-idle   => { @idle.push(1) },
    );

    my $follow-up = Tween.new(
        duration => 0.2, on-update => -> Num $v { @second.push($v) },
    );
    my $first = Tween.new(
        duration    => 0.1,
        on-update   => -> Num $ { },
        on-complete => { $group.add($follow-up, at => Instant.from-posix(7_000.1)) },
    );

    $group.add($first, at => $t0);
    $group.tick($t0 + 0.1);

    is @idle.elems, 0, 'the group never reported idle between the two tweens';
    is @active.elems, 1, 'so it never had to re-activate either';
    is $group.elems, 1, 'the follow-up replaced the finished tween';
    is @second.elems, 0,
        'a tween added mid-tick does not also tick in the same frame';

    $group.tick($t0 + 0.2);
    is @second.elems, 1, 'it starts on the next frame instead';
};

subtest 'clear abandons everything and releases the group' => {
    plan 6;
    my $t0 = Instant.from-posix(8_000);
    my (@idle, $completed);
    $completed = 0;
    my $group = TweenGroup.new(on-idle => { @idle.push(1) });

    my $tween = Tween.new(
        duration => 1, on-update => -> Num $ { }, on-complete => { $completed++ },
    );
    $group.add($tween, at => $t0);
    $group.tick($t0 + 0.5);

    $group.clear;
    is $group.elems, 0, 'clear drops every member';
    nok $group.running, 'so the group is no longer running';
    nok $tween.running, 'and the members are cancelled, not left live';
    is $completed, 0, 'clearing is an abandonment — on-complete does not fire';
    is @idle.elems, 1, 'but on-idle does, so the refcount is released';

    $group.clear;
    is @idle.elems, 1, 'clearing an already-empty group announces nothing';
};

subtest 'ticking an empty group is cheap and silent' => {
    plan 3;
    # The app registers its frame callback for the whole process
    # lifetime once anything animates, so this is the steady state.
    my (@active, @idle);
    my $group = TweenGroup.new(
        on-active => { @active.push(1) }, on-idle => { @idle.push(1) },
    );
    my $t0 = Instant.from-posix(9_000);
    nok $group.tick($t0), 'no activity reported';
    nok $group.tick($t0 + 1), 'still none';
    is (@active.elems + @idle.elems), 0, 'and no transitions announced';
};