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