Selkie.git | t/ | 80-idle-ladder-budget.rakutest
use Test;
use lib 'lib';
use Selkie::App;
# pick-frame-budget is the main loop's tick-rate picker: it maps two
# idle clocks (seconds since last USER activity, seconds since last
# STORE activity) to a frame budget in seconds (1/Hz). The contract:
#
# - user activity drives the full ladder: hot for < 30 s of idle,
# then 30 Hz, 12 Hz, 4 Hz after 120 s;
# - store-only activity never drives the loop faster than 30 Hz,
# so a background worker dispatching progress events can't pin
# the loop at the hot rate for its whole lifetime;
# - whichever source wants the faster rate wins;
# - the result never exceeds the configured hot rate (apps with a
# slow hot-hz must not accelerate at idle);
# - a running animation bypasses the ladder entirely.
my constant HOT60 = 1e0 / 60e0;
my constant HZ30 = 1e0 / 30e0;
my constant HZ12 = 1e0 / 12e0;
my constant HZ4 = 1e0 / 4e0;
my constant TOL = 1e-9;
sub budget(Num() $hot, Num() $user, Num() $store, Bool :$animating = False --> Num) {
Selkie::App::pick-frame-budget($hot, $user, $store, :$animating);
}
plan 6;
subtest "user activity drives the full ladder" => {
plan 8;
# Store clock pinned to deep idle so only the user clock matters
# (store can only make things faster, and at deep idle it doesn't).
is-approx budget(HOT60, 0e0, 999e0), HOT60, TOL, "active now → hot";
is-approx budget(HOT60, 29.9e0, 999e0), HOT60, TOL, "idle 29.9 s → still hot";
is-approx budget(HOT60, 30e0, 999e0), HZ30, TOL, "idle 30 s → 30 Hz";
is-approx budget(HOT60, 59.9e0, 999e0), HZ30, TOL, "idle 59.9 s → 30 Hz";
is-approx budget(HOT60, 60e0, 999e0), HZ12, TOL, "idle 60 s → 12 Hz";
is-approx budget(HOT60, 119.9e0, 999e0), HZ12, TOL, "idle 119.9 s → 12 Hz";
is-approx budget(HOT60, 120e0, 999e0), HZ4, TOL, "idle 120 s → 4 Hz";
is-approx budget(HOT60, 3600e0, 999e0), HZ4, TOL, "idle 1 h → ladder caps at 4 Hz";
};
subtest "store-only activity is floored at 30 Hz" => {
plan 4;
# User deep idle, store active right now: the loop must keep
# draining the queue — but at 30 Hz, not the hot rate. This is
# the "image-gen progress pinned the loop at 60 Hz for minutes"
# fix.
is-approx budget(HOT60, 999e0, 0e0), HZ30, TOL,
"user idle + store active → 30 Hz, not hot";
is-approx budget(HOT60, 999e0, 29.9e0), HZ30, TOL,
"store active 29.9 s ago → still 30 Hz";
# The store clock decays through the same tiers once traffic stops.
is-approx budget(HOT60, 999e0, 100e0), HZ12, TOL,
"store idle 100 s → 12 Hz";
is-approx budget(HOT60, 999e0, 200e0), HZ4, TOL,
"store idle 200 s → deep idle";
};
subtest "faster source wins" => {
plan 3;
is-approx budget(HOT60, 0e0, 0e0), HOT60, TOL,
"both active → hot (user wins over store floor)";
is-approx budget(HOT60, 0e0, 999e0), HOT60, TOL,
"user active, store idle → hot";
is-approx budget(HOT60, 45e0, 45e0), HZ30, TOL,
"both at the 30 Hz tier → 30 Hz";
};
subtest "never faster than the configured hot rate" => {
plan 3;
my $slow-hot = 1e0 / 10e0; # slower than every idle tier
is-approx budget($slow-hot, 0e0, 0e0), $slow-hot, TOL,
"10 Hz app: active → 10 Hz, not 60";
is-approx budget($slow-hot, 45e0, 999e0), $slow-hot, TOL,
"10 Hz app: 30 Hz tier clamps to 10 Hz (idle may not accelerate)";
is-approx budget($slow-hot, 999e0, 0e0), $slow-hot, TOL,
"10 Hz app: store floor clamps to 10 Hz";
};
subtest "deep idle is the slowest tier" => {
plan 1;
# 4 Hz / 250 ms is the cap — snap-back from deep idle must stay
# near-instant, so the ladder never sleeps longer than one 250 ms
# frame no matter how long the app sits.
is-approx budget(HOT60, 1e6, 1e6), HZ4, TOL,
"arbitrarily long idle still ticks at 4 Hz";
};
subtest "while animating, the budget is hot regardless of idle-for" => {
plan 7;
# An animation is sampled rather than triggered: it needs frames
# for its whole duration whether or not anything else has happened
# lately. Sampling a 150 ms tween at the 4 Hz deep-idle rung would
# give it one single frame.
#
# This is deliberately NOT implemented by poking the user-activity
# clock, which would leave a 30-second hot tail behind every
# animation — the exact battery cost the ladder exists to avoid.
# The override is checked here, and released the instant the last
# tween finishes (see t/95).
is-approx budget(HOT60, 0e0, 0e0, :animating), HOT60, TOL,
"active user + animating → hot";
is-approx budget(HOT60, 45e0, 999e0, :animating), HOT60, TOL,
"30 Hz rung is overridden";
is-approx budget(HOT60, 90e0, 999e0, :animating), HOT60, TOL,
"12 Hz rung is overridden";
is-approx budget(HOT60, 999e0, 999e0, :animating), HOT60, TOL,
"4 Hz deep-idle rung is overridden";
is-approx budget(HOT60, 1e6, 1e6, :animating), HOT60, TOL,
"arbitrarily long idle is overridden";
# Still never faster than the app asked for: an app that pinned
# itself to 10 Hz does not get 60 Hz because it animated.
my $slow-hot = 1e0 / 10e0;
is-approx budget($slow-hot, 999e0, 999e0, :animating), $slow-hot, TOL,
"10 Hz app animating → 10 Hz, not 60";
# And the flag defaults off, so nothing changes for apps that
# never animate.
is-approx budget(HOT60, 999e0, 999e0), HZ4, TOL,
"not animating → the ladder is untouched";
};