Selkie.git | t/ | 71-sprixel-refresh.rakutest
use Test;
use lib 'lib';
use nqp;
use Selkie::App;
use Selkie::Widget;
use Selkie::Widget::Image;
use Selkie::Sizing;
# sprixel-refresh-idle-threshold defends against terminals (notably
# Kitty) that drop direct-placement graphics on tab switch while
# preserving the cell buffer. The run loop catches both "first input
# after a long-enough idle" and "still idle after the refresh interval"
# so every Image re-emits its sprixel without waiting for a click/key.
# This file exercises the parts that don't need a live notcurses
# instance: the Kitty-detection class method, the tree-walking refresh
# helper's behavior on real Image widgets, and source-level structural
# invariants that wire the threshold/interval into TWEAK and the run
# loop.
plan 7;
# --- detect-sprixel-bug-prone-terminal: Kitty signals --------------------
subtest 'detected via KITTY_WINDOW_ID' => {
plan 1;
temp %*ENV;
%*ENV<KITTY_WINDOW_ID> = '42';
%*ENV<TERM>:delete;
ok Selkie::App.detect-sprixel-bug-prone-terminal,
'KITTY_WINDOW_ID flips detection True even with TERM unset';
};
subtest 'detected via TERM=xterm-kitty' => {
plan 1;
temp %*ENV;
%*ENV<KITTY_WINDOW_ID>:delete;
%*ENV<TERM> = 'xterm-kitty';
ok Selkie::App.detect-sprixel-bug-prone-terminal,
'TERM=xterm-kitty flips detection True';
};
subtest 'plain xterm not detected' => {
plan 2;
temp %*ENV;
%*ENV<KITTY_WINDOW_ID>:delete;
%*ENV<TERM> = 'xterm-256color';
nok Selkie::App.detect-sprixel-bug-prone-terminal,
'TERM=xterm-256color does not match';
%*ENV<TERM>:delete;
nok Selkie::App.detect-sprixel-bug-prone-terminal,
'no env signals at all — not detected';
};
# --- force-refresh-sprixels tree walk -----------------------------------
# Selkie::Widget::Image can be constructed without a live notcurses
# plane (proven by t/58-image-blit-cache). We replicate the helper's
# walk inline (the public method on Selkie::App would need a full App
# instance, which would need notcurses) and assert it does the right
# thing on a small mixed-widget tree.
class NonImage does Selkie::Widget {
method render() { self.clear-dirty }
}
class FakeContainer does Selkie::Widget {
has @.children;
submethod TWEAK(:@children) {
@!children = @children;
$_.parent = self for @children;
}
method render() { self.clear-dirty }
}
sub walk-force-refresh($w) {
return without $w;
if $w ~~ Selkie::Widget::Image {
$w.destroy-blit-plane;
$w.mark-dirty unless $w.is-dirty;
}
if $w.^can('children') {
walk-force-refresh($_) for $w.children;
}
if $w.^can('content') {
my $c = $w.content;
walk-force-refresh($c) if $c.defined;
}
}
subtest 'walk marks Image widgets dirty across containers' => {
plan 3;
my $img-a = Selkie::Widget::Image.new(sizing => Sizing.fixed(5));
my $img-b = Selkie::Widget::Image.new(sizing => Sizing.fixed(5));
my $non = NonImage.new;
my $root = FakeContainer.new(children => [$img-a, $non, $img-b]);
$img-a.clear-dirty;
$img-b.clear-dirty;
$non.clear-dirty;
walk-force-refresh($root);
ok $img-a.is-dirty, 'Image A marked dirty';
ok $img-b.is-dirty, 'Image B marked dirty';
nok $non.is-dirty, 'non-Image widget not marked';
};
subtest 'walk is safe on an empty/undef tree' => {
plan 1;
lives-ok { walk-force-refresh(Selkie::Widget) },
'walking the type object (no live tree) is a no-op, not a crash';
};
# --- Source structural invariants ---------------------------------------
# The full integration — TWEAK picking a threshold from detection, and
# the run loop firing the refresh on idle return — needs a live App
# (and therefore notcurses), so we lock in the contract on the source.
my $app-src-path = $?FILE.IO.parent.parent.add('lib/Selkie/App.rakumod');
my $loop-src-path = $?FILE.IO.parent.parent.add('lib/Selkie/App/Internal/RenderLoop.rakumod');
my $src = $app-src-path.slurp ~ "\n" ~ $loop-src-path.slurp;
subtest 'attribute + helpers are declared in App' => {
plan 4;
like $src, /'has Num $.sprixel-refresh-idle-threshold'/,
'public Num attribute declared';
like $src, /'has Num $.sprixel-refresh-idle-interval'/,
'public refresh interval attribute declared';
like $src, /'method force-refresh-sprixels'/,
'force-refresh-sprixels method exists';
like $src, /'method detect-sprixel-bug-prone-terminal'/,
'detect-sprixel-bug-prone-terminal method exists';
};
subtest 'TWEAK + run loop wire the refresh in' => {
plan 4;
# TWEAK auto-defaults from the detection method when caller didn't pass.
like $src,
/'$!sprixel-refresh-idle-threshold.defined' .*? 'detect-sprixel-bug-prone-terminal'/,
'TWEAK consults the detection method to pick the default';
like $src,
/'$!sprixel-refresh-idle-interval' \s* '//' \s* '=' \s* '0.5e0'/,
'TWEAK supplies the idle refresh interval default';
# Run loop fires force-refresh-sprixels through the helper, gated on
# threshold and interval, rather than waiting only for input dispatch.
like $src,
/'maybe-refresh-sprixels-after-idle' .*? 'force-refresh-sprixels'/,
'run loop helper calls force-refresh-sprixels';
like $src,
/'unless $user-activity'/,
'run loop refreshes sprixels during idle frames';
};
done-testing;