Selkie.git | t/ | 01-widget.rakutest
use Test;
use lib 'lib';
use Selkie::Widget;
use Selkie::Widget::FocusableByDefault;
use Selkie::Widget::Button;
use Selkie::Widget::Checkbox;
use Selkie::Event;
use Selkie::Style;
use Selkie::Theme;
use Selkie::Sizing;
use Notcurses::Native::Types;
# Concrete stub for testing the Widget role
class TestWidget does Selkie::Widget {
method render() { self.clear-dirty }
}
plan 19;
subtest "construction defaults" => {
plan 7;
my $w = TestWidget.new;
ok $w.is-dirty, "starts dirty";
is $w.rows, 0, "rows default 0";
is $w.cols, 0, "cols default 0";
is $w.y, 0, "y default 0";
is $w.x, 0, "x default 0";
nok $w.focusable, "not focusable by default";
nok $w.plane.defined, "no plane initially";
};
subtest "construction with sizing" => {
plan 2;
my $w = TestWidget.new(sizing => Sizing.fixed(5));
is $w.sizing.mode, SizeFixed, "sizing mode set";
is $w.sizing.value, 5, "sizing value set";
};
subtest "construction with focusable" => {
plan 1;
my $w = TestWidget.new(focusable => True);
ok $w.focusable, "focusable when set";
};
subtest "dirty tracking - mark and clear" => {
plan 3;
my $w = TestWidget.new;
ok $w.is-dirty, "starts dirty";
$w.clear-dirty;
nok $w.is-dirty, "clean after clear-dirty";
$w.mark-dirty;
ok $w.is-dirty, "dirty after mark-dirty";
};
subtest "dirty tracking - mark is idempotent" => {
plan 1;
my $w = TestWidget.new;
$w.clear-dirty;
$w.mark-dirty;
$w.mark-dirty;
ok $w.is-dirty, "still dirty after double mark";
};
subtest "dirty tracking - propagates to parent" => {
plan 2;
my $parent = TestWidget.new;
my $child = TestWidget.new;
$child.parent = $parent;
$parent.clear-dirty;
$child.clear-dirty;
nok $parent.is-dirty, "parent starts clean";
$child.mark-dirty;
ok $parent.is-dirty, "parent dirty after child marked";
};
subtest "theme - default when no theme set" => {
plan 1;
my $w = TestWidget.new;
my $theme = $w.theme;
ok $theme.defined, "gets default theme when none set";
};
subtest "theme - inherits from parent" => {
plan 1;
my $custom = Selkie::Theme.default;
my $parent = TestWidget.new;
$parent.set-theme($custom);
my $child = TestWidget.new;
$child.parent = $parent;
ok $child.theme === $custom, "child inherits parent theme";
};
subtest "theme - own theme overrides parent" => {
plan 1;
my $parent-theme = Selkie::Theme.default;
my $child-theme = Selkie::Theme.default;
my $parent = TestWidget.new;
$parent.set-theme($parent-theme);
my $child = TestWidget.new;
$child.parent = $parent;
$child.set-theme($child-theme);
ok $child.theme === $child-theme, "own theme takes precedence";
};
subtest "set-theme marks dirty" => {
plan 1;
my $w = TestWidget.new;
$w.clear-dirty;
$w.set-theme(Selkie::Theme.default);
ok $w.is-dirty, "dirty after set-theme";
};
subtest "resize without plane" => {
plan 4;
my $w = TestWidget.new;
$w.clear-dirty;
$w.resize(10, 80);
is $w.rows, 10, "rows updated";
is $w.cols, 80, "cols updated";
ok $w.is-dirty, "marked dirty";
# No-op when same dimensions
$w.clear-dirty;
$w.resize(10, 80);
nok $w.is-dirty, "not dirty when size unchanged";
};
subtest "reposition without plane" => {
plan 4;
my $w = TestWidget.new;
$w.reposition(5, 10);
is $w.y, 5, "y updated";
is $w.x, 10, "x updated";
# No-op when same position
$w.reposition(5, 10);
is $w.y, 5, "y unchanged";
is $w.x, 10, "x unchanged";
};
subtest "keybind registration and dispatch" => {
plan 2;
my $w = TestWidget.new;
my $called = False;
$w.on-key('ctrl+q', -> $ { $called = True });
my $ev = Selkie::Event.new(
id => 'q'.ord,
modifiers => Set(Mod-Ctrl),
input-type => NCTYPE_PRESS,
event-type => KeyEvent,
);
my $consumed = $w.handle-event($ev);
ok $called, "handler invoked";
ok $consumed, "event consumed";
};
subtest "keybind - non-matching event not consumed" => {
plan 2;
my $w = TestWidget.new;
my $called = False;
$w.on-key('ctrl+q', -> $ { $called = True });
my $ev = Selkie::Event.new(
id => 'x'.ord,
modifiers => Set(),
input-type => NCTYPE_PRESS,
event-type => KeyEvent,
);
my $consumed = $w.handle-event($ev);
nok $called, "handler not invoked";
nok $consumed, "event not consumed";
};
subtest "keybind-chain collects from leaf upward, deduped, no undescribed" => {
plan 6;
my $root = TestWidget.new;
my $mid = TestWidget.new;
my $leaf = TestWidget.new;
$mid.parent = $root;
$leaf.parent = $mid;
# Leaf has 'n' (described) and 'esc' (no description — internal).
# Mid re-binds 'n' (described) — must lose to leaf's 'n' on dedupe.
# Root contributes 'ctrl+h' (described).
$leaf.on-key('n', -> $ {}, :description('New character'));
$leaf.on-key('esc', -> $ {}); # no description: skipped
$mid.on-key('n', -> $ {}, :description('Mid-level new'));
$mid.on-key('a', -> $ {}, :description('Add item'));
$root.on-key('ctrl+h', -> $ {}, :description('Show help'));
my @chain = $leaf.keybind-chain;
is @chain.elems, 3, "three deduped, described binds";
is @chain[0]<spec>, 'n', "leaf's 'n' wins position 0";
is @chain[0]<description>, 'New character', "leaf's description wins on dedupe";
is @chain[1]<spec>, 'a', "mid contributes 'a'";
is @chain[2]<spec>, 'ctrl+h', "root contributes 'ctrl+h'";
ok @chain.grep({ .<spec> eq 'esc' }).elems == 0, "undescribed binds excluded";
};
# --- mark-dirty-tree / mark-screen-dirty ---
use Selkie::Container;
class TestContainer does Selkie::Container {
method render() { self!render-children; self.clear-dirty }
}
subtest "mark-dirty-tree cascades to children + content" => {
plan 4;
my $parent = TestContainer.new;
my $child-a = TestWidget.new;
my $child-b = TestWidget.new;
$parent.add($child-a);
$parent.add($child-b);
# Start from a clean tree so we can prove the cascade did the work
$parent.clear-dirty;
$child-a.clear-dirty;
$child-b.clear-dirty;
$parent.mark-dirty-tree;
ok $parent.is-dirty, "container itself marked dirty";
ok $child-a.is-dirty, "first child marked dirty";
ok $child-b.is-dirty, "second child marked dirty";
# Idempotent
lives-ok { $parent.mark-dirty-tree },
"calling mark-dirty-tree again is safe";
};
subtest "mark-screen-dirty walks up to root then cascades" => {
plan 4;
# root → mid → leaf. mark-screen-dirty from `leaf` should flag
# root, mid, and leaf — even though the default mark-dirty
# already propagates up, mark-dirty-tree adds the downward
# cascade that default mark-dirty doesn't provide.
my $root = TestContainer.new;
my $mid = TestContainer.new;
my $sibling-of-mid = TestWidget.new;
my $leaf = TestWidget.new;
$root.add($mid);
$root.add($sibling-of-mid);
$mid.add($leaf);
$root.clear-dirty;
$mid.clear-dirty;
$sibling-of-mid.clear-dirty;
$leaf.clear-dirty;
$leaf.mark-screen-dirty;
ok $leaf.is-dirty, "caller is dirty";
ok $mid.is-dirty, "direct ancestor is dirty";
ok $root.is-dirty, "root is dirty";
ok $sibling-of-mid.is-dirty,
"sibling branch reached via downward cascade from root";
};
subtest "FocusableByDefault role applies to focusable input widgets" => {
plan 4;
# The role is composed into 10 widgets to remove a duplicated
# %args<focusable> //= True; callwith(...) opener. Verify it
# actually applies, that the explicit override still wins, and
# that more than one widget benefits (cross-widget composition).
my $btn = Selkie::Widget::Button.new(label => 'OK');
ok $btn.focusable, "Button is focusable by default";
my $cb = Selkie::Widget::Checkbox.new(label => 'Yes');
ok $cb.focusable, "Checkbox is focusable by default";
my $btn-explicit = Selkie::Widget::Button.new(
label => 'X', focusable => False
);
nok $btn-explicit.focusable,
"explicit focusable=>False is honoured (Button)";
my $cb-explicit = Selkie::Widget::Checkbox.new(
label => 'No', focusable => False
);
nok $cb-explicit.focusable,
"explicit focusable=>False is honoured (Checkbox)";
};
subtest "park-y returns the canonical off-screen Y" => {
plan 3;
# The framework standardises on a single off-screen Y so Container,
# CardList, and any custom container override all park to the same
# spot — making parked widgets greppable in snapshot tests and
# easy to reason about in debugger output. `park-y` is a method
# (not an `our` constant) because Raku doesn't allow our-scoped
# symbols inside a role.
my $w = TestWidget.new;
ok $w.park-y.defined,
"park-y is callable on every Widget consumer";
ok $w.park-y >= 1000,
"park-y is comfortably larger than any plausible terminal height";
is $w.park-y, 10_000,
"park-y is the documented value (regression pin)";
};