Selkie.git | t/ | 105-screen-subscription-lifecycle.rakutest


use Test;
use lib 'lib';

use Selkie::Store;
use Selkie::Trace;
use Selkie::Widget;
use Selkie::Container;
use Selkie::ScreenManager;
use Selkie::Theme;
use Selkie::App::Internal::ScreenModalLifecycle;
use Selkie::App::Internal::FocusTree;
use Selkie::Widget::Border;
use Selkie::Widget::CardList;
use Selkie::Widget::Text;
use Selkie::Sizing;

=begin pod
C<ScreenManager.remove-screen> used to call plain C<Container.destroy>
with no tree-wide unsubscribe. C<destroy> only reaches what each
container's own C<destroy> reaches, and the containers that hold
children outside C<@!children> are exactly the ones it misses —
measured at ~7 leaked subscriptions per editor open/close cycle in
App::Cantina. Every leaked subscription then pays pull-check cost on
every subsequent store tick, forever.

The regression test is the cycle: add + switch + remove must return the
subscription count to its baseline, for every container shape that has
historically been missed.
=end pod

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

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

# The screen-swap half of the lifecycle lives on Selkie::App, which
# needs a live notcurses context to construct. Compose the two internal
# roles directly instead — the same technique the roles were split out
# for — and stub the terminal so no plane is ever allocated.
class FakeApp
    does Selkie::App::Internal::ScreenModalLifecycle
    does Selkie::App::Internal::FocusTree
{
    has $.store  = Selkie::Store.new;
    has $.theme  = Selkie::Theme.default;
    method !terminal-size(--> List) { (24, 80) }
    method screen-manager() { self!screen-manager }
    method register(Str:D $name, $root) {
        $root.set-store($.store);
        self!screen-manager.add-screen($name, $root);
    }
    method switch-screen(Str:D $name) { self!switch-screen($name) }
}

plan 7;

subtest 'remove-screen unsubscribes a plain container tree' => {
    plan 3;
    my $s = Selkie::Store.new;
    my $sm = Selkie::ScreenManager.new;

    my $main = Screen.new;
    $main.set-store($s);
    $sm.add-screen('main', $main);

    my $editor = Screen.new;
    $editor.set-store($s);
    my $a = $editor.add(W.new);
    my $b = $editor.add(W.new);
    $sm.add-screen('editor', $editor);

    $s.subscribe('a', ['x'], $a);
    $s.subscribe('b', ['y'], $b);
    $s.subscribe('e', ['z'], $editor);
    is $s.subscription-count, 3, 'three subscriptions live';

    $sm.switch-to('editor');
    $sm.switch-to('main');
    $sm.remove-screen('editor');
    is $s.subscription-count, 0, 'remove-screen unsubscribed the tree';
    nok $sm.has-screen('editor'), 'and dropped the registration';
};

subtest 'children held outside @!children are reached' => {
    plan 4;
    # Border keeps its child under .content; CardList keeps its cards in
    # @!items and has no destroy override at all. Both are invisible to
    # a naive .children cascade, and both are common on a real screen.
    #
    # Counts are compared against a baseline rather than absolute
    # numbers: Border registers a focus subscription of its own when a
    # store is attached, and that is exactly the sort of framework-owned
    # sub the leak used to strand.
    my $s = Selkie::Store.new;
    my $sm = Selkie::ScreenManager.new;
    $sm.add-screen('main', Screen.new);
    my $baseline = $s.subscription-count;

    my $root = Screen.new;
    $root.set-store($s);

    my $inner = W.new;
    my $border = Selkie::Widget::Border.new(sizing => Sizing.flex);
    $border.set-content($inner);
    $root.add($border);

    my $list = Selkie::Widget::CardList.new(sizing => Sizing.flex);
    my $card-body = Selkie::Widget::Text.new(text => 'card', sizing => Sizing.fixed(1));
    my $card-root = Selkie::Widget::Border.new(sizing => Sizing.fixed(3));
    $card-root.set-content($card-body);
    $list.add-item($card-body, root => $card-root, height => 3);
    $root.add($list);

    $root.set-store($s);   # re-propagate to everything added since
    $sm.add-screen('editor', $root);

    $s.subscribe('border-content', ['x'], $inner);
    $s.subscribe('card-root', ['x'], $card-root);
    $s.subscribe('card-body', ['x'], $card-body);
    $s.subscribe('list', ['x'], $list);
    ok $s.subscription-count >= $baseline + 4,
        'the screen contributed at least the four explicit subscriptions';

    $sm.remove-screen('editor');
    is $s.subscription-count, $baseline,
        'Border .content and CardList cards were both unsubscribed';

    # The same coverage through the Container-level cascade, which now
    # shares the walk.
    my $root2 = Screen.new;
    $root2.set-store($s);
    my $inner2 = W.new;
    my $border2 = Selkie::Widget::Border.new(sizing => Sizing.flex);
    $border2.set-content($inner2);
    $root2.add($border2);
    $root2.set-store($s);
    $s.subscribe('nested', ['x'], $inner2);
    ok $s.subscription-count >= $baseline + 1,
        'nested subscription registered';
    $root2.clear;
    is $s.subscription-count, $baseline,
        'Container.clear reaches .content too';
};

subtest 'repeat add/switch/remove cycles do not accumulate' => {
    plan 1;
    # The actual leak signature: the count creeps up per cycle.
    my $s = Selkie::Store.new;
    my $sm = Selkie::ScreenManager.new;
    my $main = Screen.new;
    $main.set-store($s);
    $sm.add-screen('main', $main);
    $s.subscribe('main', ['m'], $main);

    my $baseline = $s.subscription-count;
    my @counts;
    for ^8 -> $i {
        my $editor = Screen.new;
        $editor.set-store($s);
        my $body = $editor.add(W.new);
        my $border = Selkie::Widget::Border.new(sizing => Sizing.flex);
        my $deep = W.new;
        $border.set-content($deep);
        $editor.add($border);

        # A CardList in the tree is what turns this from a formality
        # into a regression test: it holds its cards in @!items and
        # inherits no destroy override, so the destroy cascade alone
        # never reaches them.
        my $list = Selkie::Widget::CardList.new(sizing => Sizing.flex);
        my $card = Selkie::Widget::Text.new(text => "card $i",
                                            sizing => Sizing.fixed(1));
        $list.add-item($card, root => $card, height => 1);
        $editor.add($list);
        $editor.set-store($s);

        $sm.add-screen('editor', $editor);
        $s.subscribe("ed-body-{$i}", ['x'], $body);
        $s.subscribe("ed-deep-{$i}", ['y'], $deep);
        $s.subscribe("ed-card-{$i}", ['c'], $card);
        $s.subscribe("ed-root-{$i}", ['z'], $editor);

        $sm.switch-to('editor');
        $sm.switch-to('main');
        $sm.remove-screen('editor');
        $s.tick;
        @counts.push: $s.subscription-count;
    }
    is-deeply @counts, [$baseline xx 8].Array,
        'subscription count returns to baseline after every cycle';
};

subtest 'ScreenManager.destroy unsubscribes every screen' => {
    plan 2;
    my $s = Selkie::Store.new;
    my $sm = Selkie::ScreenManager.new;
    for <a b c> -> $name {
        my $root = Screen.new;
        $root.set-store($s);
        my $kid = $root.add(W.new);
        $sm.add-screen($name, $root);
        $s.subscribe("{$name}-kid", ['x'], $kid);
    }
    is $s.subscription-count, 3, 'one subscription per screen';
    $sm.destroy;
    is $s.subscription-count, 0, 'shutdown left nothing behind';
};

subtest 'remove-screen still refuses the active screen' => {
    plan 3;
    my $s = Selkie::Store.new;
    my $sm = Selkie::ScreenManager.new;
    my $main = Screen.new;
    $main.set-store($s);
    $sm.add-screen('main', $main);
    $s.subscribe('main', ['x'], $main);

    my $res = $sm.remove-screen('main');
    ok $res ~~ Failure, 'removing the active screen fails';
    $res.so;   # defuse
    is $s.subscription-count, 1,
        'and does not unsubscribe anything on the way out';
    ok $sm.has-screen('main'), 'the screen is still registered';
};

subtest 'the swap path is instrumented' => {
    plan 3;
    # `screen.switch` / `screen.remove` spans: the 295ms close frame in
    # the 2026-08 capture was a black box inside ui.input.dispatch.
    my $path = $*TMPDIR.add("selkie-screen-spans-{$*PID}-{now.Int}.json").Str;
    Selkie::Trace.init(mode => 'trace', trace-path => $path);

    my $sm = Selkie::ScreenManager.new;
    $sm.add-screen('main', Screen.new);
    $sm.add-screen('editor', Screen.new);
    $sm.switch-to('editor');
    $sm.switch-to('main');
    $sm.remove-screen('editor');

    Selkie::Trace.shutdown;
    my $json = $path.IO.slurp;
    $path.IO.unlink if $path.IO.e;

    like $json, /'"screen.remove"'/, 'screen.remove span recorded';
    like $json, /'"screen"' \s* ':' \s* '"editor"'/,
        'the span names the screen it removed';
    like $json, /'trace/shutdown'/, 'the trace file is otherwise intact';
};

subtest 'screen.switch spans the whole swap' => {
    plan 4;
    my $path = $*TMPDIR.add("selkie-switch-span-{$*PID}-{now.Int}.json").Str;
    my $app = FakeApp.new;
    $app.register('main', Screen.new);
    $app.register('editor', Screen.new);

    Selkie::Trace.init(mode => 'trace', trace-path => $path);
    $app.switch-screen('editor');
    $app.switch-screen('main');
    Selkie::Trace.shutdown;

    my $json = $path.IO.slurp;
    $path.IO.unlink if $path.IO.e;

    like $json, /'"screen.switch"'/, 'screen.switch span recorded';
    is +$json.comb(/'"screen.switch"'/), 2, 'one span per swap';
    like $json, /'"to"' \s* ':' \s* '"editor"'/,
        'the span names the incoming screen';
    like $json, /'"subscriptions"'/,
        'and reports the live subscription count, so the leak is visible '
        ~ 'in the capture itself';
};

done-testing;