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