Selkie.git | t/ | 22-viewport.rakutest


use Test;
use lib 'lib';

use Selkie::Widget;
use Selkie::Container;
use Selkie::Sizing;

class TestWidget does Selkie::Widget {
    method render() { self.clear-dirty }
}

class TestContainer does Selkie::Container {
    method render() { self!render-children; self.clear-dirty }
}

plan 7;

subtest "widget viewport defaults to zero" => {
    plan 4;
    my $w = TestWidget.new;
    is $w.abs-y, 0, "abs-y default";
    is $w.abs-x, 0, "abs-x default";
    is $w.viewport-rows, 0, "viewport-rows default";
    is $w.viewport-cols, 0, "viewport-cols default";
};

subtest "set-viewport sets values" => {
    plan 4;
    my $w = TestWidget.new;
    $w.set-viewport(abs-y => 10, abs-x => 20, rows => 30, cols => 40);
    is $w.abs-y, 10, "abs-y set";
    is $w.abs-x, 20, "abs-x set";
    is $w.viewport-rows, 30, "viewport-rows set";
    is $w.viewport-cols, 40, "viewport-cols set";
};

subtest "VBox propagates viewport to children" => {
    plan 4;
    use Selkie::Layout::VBox;

    my $vbox = Selkie::Layout::VBox.new(sizing => Sizing.flex);
    $vbox.set-viewport(abs-y => 5, abs-x => 10, rows => 20, cols => 40);

    my $child1 = TestWidget.new(sizing => Sizing.fixed(8));
    my $child2 = TestWidget.new(sizing => Sizing.fixed(12));
    $vbox.add($child1);
    $vbox.add($child2);

    # Simulate layout by giving vbox a plane-like size
    $vbox.resize(20, 40);

    # VBox layout runs during render — we need to trigger it
    # Since we can't render without a plane, check that set-viewport
    # was called on children after a manual layout call would run
    # For now just verify viewport is accessible
    ok $child1.can('abs-y'), "child has viewport accessor";
    ok $child1.can('viewport-rows'), "child has viewport-rows accessor";
    ok $child2.can('abs-y'), "child2 has viewport accessor";
    ok $child2.can('viewport-rows'), "child2 has viewport-rows accessor";
};

subtest "widget-id is unique" => {
    plan 2;
    my $w1 = TestWidget.new;
    my $w2 = TestWidget.new;
    ok $w1.widget-id != $w2.widget-id, "different IDs";
    ok $w1.widget-id > 0, "positive ID";
};

subtest "widget-id on containers" => {
    plan 1;
    my $c = TestContainer.new;
    ok $c.widget-id > 0, "container has ID";
};

subtest "set-viewport marks dirty on absolute-position change" => {
    plan 5;
    # Image sprixels don't follow notcurses plane moves — when a
    # parent layout shifts a card, the host Image's render has to
    # fire so its blit-plane teardown / re-blit logic can run.
    # Marking dirty on a viewport reposition guarantees that.
    my $w = TestWidget.new;

    $w.set-viewport(abs-y => 5, abs-x => 5, rows => 10, cols => 10);
    ok $w.is-dirty, "abs change from default (0,0) to (5,5) marks dirty";

    $w.clear-dirty;
    $w.set-viewport(abs-y => 5, abs-x => 5, rows => 10, cols => 10);
    nok $w.is-dirty, "no-op set-viewport leaves the widget clean";

    $w.clear-dirty;
    $w.set-viewport(abs-y => 7, abs-x => 5, rows => 10, cols => 10);
    ok $w.is-dirty, "abs-y change marks dirty";

    $w.clear-dirty;
    $w.set-viewport(abs-y => 7, abs-x => 9, rows => 10, cols => 10);
    ok $w.is-dirty, "abs-x change marks dirty";

    $w.clear-dirty;
    $w.set-viewport(abs-y => 7, abs-x => 9, rows => 20, cols => 30);
    nok $w.is-dirty,
        "rows/cols change without abs change does not dirty (handle-resize covers that path)";
};

subtest "set-viewport rejects pathological Int values via safe-coord gate" => {
    plan 8;
    # Regression: a MoarVM spesh mis-specialisation upstream can leave
    # the Int slot holding a multi-thousand-bit bigint (see the
    # memoization-revert note in App::Mindmoor::View::TaskRow). The
    # renderer must not crash. Earlier behavior was "store the big
    # value and rely on boxed-Int dispatch to keep the comparison
    # working" — but that left the assignment site (line 427's
    # `$!abs-y = $abs-y`) crashing on Scalar.STORE's inlined unbox.
    #
    # Current behavior: safe-coord uses nqp::isbig_I to detect any value
    # that doesn't fit in int64 and signals corruption via Nil.
    # set-viewport then emits an opt-in diagnostic and skips the
    # update. The widget keeps its previous clean coords, the parent
    # layout re-passes coords on the next render, and recovery is
    # automatic if the upstream spesh slot has cleared.
    my $w = TestWidget.new;
    # Seed a known-good baseline so we can verify the rejected update
    # leaves the slot untouched.
    $w.set-viewport(:abs-y(7), :abs-x(11), :rows(10), :cols(10));
    is $w.abs-y, 7, "baseline abs-y stored cleanly";
    is $w.abs-x, 11, "baseline abs-x stored cleanly";

    my Int $huge = 2 ** 3273;
    my $diag = $*TMPDIR.add("selkie-viewport-diag-{$*PID}.log");
    $diag.unlink if $diag.e;
    {
        temp %*ENV<SELKIE_DIAGNOSTIC_LOG> = $diag.Str;
        lives-ok {
            $w.set-viewport(:abs-y($huge), :abs-x(0), :rows(10), :cols(10));
        }, "huge abs-y is rejected without crashing";
    }
    ok $diag.slurp.contains("skipping corrupt coord"), "rejection diagnostic written when explicitly requested";
    $diag.unlink if $diag.e;
    is $w.abs-y, 7, "rejected coord doesn't overwrite the prior clean abs-y";
    is $w.abs-x, 11, "rejected coord doesn't overwrite the prior clean abs-x";

    # Recovery: the next set-viewport call with sane values must work
    # exactly as if the corrupt call never happened.
    lives-ok {
        $w.set-viewport(:abs-y(42), :abs-x(99), :rows(10), :cols(10));
    }, "follow-up call with sane coords succeeds after a rejected one";
    is-deeply (:y($w.abs-y), :x($w.abs-x)).hash, (:y(42), :x(99)).hash,
        "post-recovery slot holds the sane coords";
};