App-karr
view release on metacpan or search on metacpan
t/31-foundation-drain.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use Path::Tiny qw( path tempdir );
use App::karr::Foundation;
use App::karr::Git;
use App::karr::BoardStore;
use App::karr::Task;
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
sub make_git_repo {
my $dir = tempdir( CLEANUP => 1 );
system( 'git', '-C', "$dir", 'init', '-q' ) == 0 or die "git init";
system( 'git', '-C', "$dir", 'config', 'user.email', 'a@b.invalid' ) == 0 or die;
system( 'git', '-C', "$dir", 'config', 'user.name', 'T' ) == 0 or die;
return $dir;
}
# Seed a board with a list of tasks: each entry is a hashref of Task args.
sub seed_board {
my ( $repo, @specs ) = @_;
my $git = App::karr::Git->new( dir => "$repo" );
my $store = App::karr::BoardStore->new( git => $git );
for my $spec ( @specs ) {
my $id = $store->allocate_next_id;
my $t = App::karr::Task->new( id => $id, title => "task $id", %$spec );
$store->save_task( $t );
}
return ( $git, $store );
}
sub task_by_id {
my ( $repo, $id ) = @_;
my $git = App::karr::Git->new( dir => "$repo" );
return App::karr::BoardStore->new( git => $git )->find_task( $id );
}
# A fake agent: a perl script driven by $KARR_FAKE_MODE acting on $KARR_REPO.
sub write_fake_agent {
my ( $dir ) = @_;
my $lib = path('lib')->absolute->stringify;
my $script = path($dir)->child('fake-agent.pl');
$script->spew_utf8(<<'PERL');
use strict;
use warnings;
my $repo = $ENV{KARR_REPO} or die "no KARR_REPO\n";
my $mode = $ENV{KARR_FAKE_MODE} // 'progress';
require App::karr::Git;
require App::karr::BoardStore;
my $store = App::karr::BoardStore->new(
git => App::karr::Git->new( dir => $repo ) );
my @open = grep {
$_ && !$_->has_blocked && $_->status ne 'done' && $_->status ne 'archived'
} $store->load_tasks;
if ( $mode eq 'progress' ) {
if ( my $t = $open[0] ) { $t->status('done'); $store->save_task($t); }
}
elsif ( $mode eq 'claim-stall' ) {
if ( my $t = $open[0] ) {
if ( $t->status ne 'in-progress' ) {
$t->status('in-progress');
$t->claimed_by('fake-agent');
$store->save_task($t);
}
# already claimed/in-progress -> do nothing -> no board change -> stall
}
}
elsif ( $mode eq 'error' ) {
print "Error: rate limit exceeded, retry later\n";
exit 0;
}
PERL
return qq{$^X -I"$lib" "$script"};
}
# ---------------------------------------------------------------------------
# Unit: actionability
# ---------------------------------------------------------------------------
subtest '_is_actionable' => sub {
my $f = App::karr::Foundation->new;
ok $f->_is_actionable({ status => 'todo' }), 'todo actionable';
ok $f->_is_actionable({ status => 'in-progress' }), 'in-progress actionable';
ok $f->_is_actionable({ status => 'backlog' }), 'backlog actionable';
ok ! $f->_is_actionable({ status => 'done' }), 'done not actionable';
ok ! $f->_is_actionable({ status => 'archived' }), 'archived not actionable';
ok ! $f->_is_actionable({ status => 'todo', blocked => 1 }), 'blocked not actionable';
ok ! $f->_is_actionable(undef), 'undef not actionable';
};
subtest '_has_actionable_tasks' => sub {
my $repo = make_git_repo();
seed_board( $repo, { status => 'done' }, { status => 'todo', blocked => 'x' } );
my $f = App::karr::Foundation->new;
ok ! $f->_has_actionable_tasks( $repo ), 'done + blocked => none actionable';
seed_board( $repo, { status => 'todo' } );
ok $f->_has_actionable_tasks( $repo ), 'a todo makes it actionable';
};
# ---------------------------------------------------------------------------
# Unit: common-error detection
# ---------------------------------------------------------------------------
subtest 'error patterns' => sub {
my $f = App::karr::Foundation->new;
my $pat = $f->_error_patterns({});
is $f->_match_error( "blah RATE LIMIT hit", $pat ), 'rate limit', 'case-insensitive default';
is $f->_match_error( "all good\n", $pat ), undef, 'clean text => undef';
is $f->_match_error( '', $pat ), undef, 'empty => undef';
my $cp = $f->_error_patterns({ error_patterns => ['kaboom'] });
is $f->_match_error( "something kaboom", $cp ), 'kaboom', 'custom pattern matches';
};
# ---------------------------------------------------------------------------
# Unit: stuck-task detection
# ---------------------------------------------------------------------------
subtest '_stuck_tasks' => sub {
my $f = App::karr::Foundation->new;
my $before = {
1 => { status => 'in-progress', claimed_by => 'a', updated => 'T1' },
2 => { status => 'todo', claimed_by => undef, updated => 'T1' },
3 => { status => 'in-progress', claimed_by => 'a', updated => 'T1' },
};
my $after = {
1 => { status => 'in-progress', claimed_by => 'a', updated => 'T1' }, # unchanged -> stuck
2 => { status => 'todo', claimed_by => undef, updated => 'T1' }, # not claimed -> ignore
3 => { status => 'done', claimed_by => 'a', updated => 'T2' }, # advanced -> not stuck
};
is_deeply [ $f->_stuck_tasks( $before, $after ) ], [1], 'only the unchanged claimed task is stuck';
# blocked task is never stuck
my $b2 = { 1 => { status => 'in-progress', claimed_by => 'a', updated => 'T1' } };
my $a2 = { 1 => { status => 'in-progress', claimed_by => 'a', updated => 'T1', blocked => 1 } };
is_deeply [ $f->_stuck_tasks( $b2, $a2 ) ], [], 'blocked task drops out';
};
# ---------------------------------------------------------------------------
# Unit: attempts counter
# ---------------------------------------------------------------------------
subtest 'attempts counter' => sub {
my $repo = tempdir( CLEANUP => 1 );
my $f = App::karr::Foundation->new;
is $f->_bump_attempts( $repo, 7 ), 1, 'first bump => 1';
is $f->_bump_attempts( $repo, 7 ), 2, 'second bump => 2';
is $f->_state_get( $repo, 'attempts' )->{7}, 2, 'persisted in state';
$f->_reset_attempts( $repo, 7 );
ok ! exists $f->_state_get( $repo, 'attempts' )->{7}, 'reset removes key';
};
# ---------------------------------------------------------------------------
# Unit: exponential cooldown
# ---------------------------------------------------------------------------
subtest 'exponential cooldown' => sub {
my $repo = tempdir( CLEANUP => 1 );
my $f = App::karr::Foundation->new;
my $karr = { cooldown_base => 1, cooldown_max => 4 };
is $f->_set_cooldown( $repo, $karr ), 1, 'level0 => 1m';
ok $f->_cooldown_active( $repo ), 'active right after set';
is $f->_set_cooldown( $repo, $karr ), 2, 'level1 => 2m';
is $f->_set_cooldown( $repo, $karr ), 4, 'level2 => 4m';
is $f->_set_cooldown( $repo, $karr ), 4, 'level3 capped at 4m';
$f->_clear_cooldown( $repo );
is $f->_state_get( $repo, 'cooldown_level' ), 0, 'cleared level back to 0';
$f->_state_set( $repo, cooldown_until => time - 1 );
ok ! $f->_cooldown_active( $repo ), 'past timestamp => inactive';
};
# ---------------------------------------------------------------------------
# Unit: auto-block via BoardStore
# ---------------------------------------------------------------------------
subtest '_autoblock_task' => sub {
my $repo = make_git_repo();
seed_board( $repo, { status => 'in-progress', claimed_by => 'a' } );
my $f = App::karr::Foundation->new;
ok $f->_autoblock_task( $repo, 1, 'auto: nope' ), 'autoblock returns true';
my $t = task_by_id( $repo, 1 );
ok $t->has_blocked, 'task is blocked';
is $t->blocked, 'auto: nope', 'block reason stored';
};
# ---------------------------------------------------------------------------
# Integration: drain to completion on progress
# ---------------------------------------------------------------------------
subtest 'drain completes when agent makes progress' => sub {
my $repo = make_git_repo();
seed_board( $repo, { status => 'todo' }, { status => 'todo' }, { status => 'todo' } );
my $agent = write_fake_agent( $repo );
my $f = App::karr::Foundation->new;
local $ENV{KARR_FAKE_MODE} = 'progress';
my $res = $f->_drain_repo( $repo, { command => $agent, max_runtime => 60 } );
is $res->{outcome}, 'progress', 'outcome progress';
ok ! $f->_has_actionable_tasks( $repo ), 'board fully drained (all done)';
is task_by_id( $repo, 1 )->status, 'done', 'task 1 done';
is task_by_id( $repo, 3 )->status, 'done', 'task 3 done';
};
# ---------------------------------------------------------------------------
# Integration: stuck task gets auto-blocked, drain then terminates
# ---------------------------------------------------------------------------
subtest 'stalled task is auto-blocked' => sub {
my $repo = make_git_repo();
seed_board( $repo, { status => 'todo' } );
my $agent = write_fake_agent( $repo );
my $f = App::karr::Foundation->new;
local $ENV{KARR_FAKE_MODE} = 'claim-stall';
my $res = $f->_drain_repo( $repo,
{ command => $agent, max_runtime => 60, max_attempts => 2 } );
my $t = task_by_id( $repo, 1 );
ok $t->has_blocked, 'stuck task ends up blocked';
like $t->blocked, qr/auto-block: no progress/, 'auto-block reason set';
ok ! $f->_has_actionable_tasks( $repo ), 'no actionable tasks left -> drain done';
};
# ---------------------------------------------------------------------------
# Integration: common error backs off, never blocks a task
# ---------------------------------------------------------------------------
subtest 'common error stops drain without blocking' => sub {
my $repo = make_git_repo();
seed_board( $repo, { status => 'todo' } );
my $agent = write_fake_agent( $repo );
my $f = App::karr::Foundation->new;
local $ENV{KARR_FAKE_MODE} = 'error';
my $res = $f->_drain_repo( $repo, { command => $agent, max_runtime => 60 } );
is $res->{outcome}, 'common-error', 'outcome common-error';
ok ! task_by_id( $repo, 1 )->has_blocked, 'task NOT blocked on infra error';
is $f->_state_get( $repo, 'last_error' ), 'rate limit', 'last_error recorded';
};
# ---------------------------------------------------------------------------
# Integration: drain=false runs the command only once
# ---------------------------------------------------------------------------
subtest 'drain=false is single-shot' => sub {
my $repo = make_git_repo();
seed_board( $repo, { status => 'todo' }, { status => 'todo' }, { status => 'todo' } );
my $agent = write_fake_agent( $repo );
my $f = App::karr::Foundation->new;
local $ENV{KARR_FAKE_MODE} = 'progress';
$f->_drain_repo( $repo, { command => $agent, max_runtime => 60, drain => 0 } );
my @done = grep { task_by_id( $repo, $_ )->status eq 'done' } ( 1, 2, 3 );
is scalar @done, 1, 'only one task advanced (single run)';
};
done_testing;
( run in 0.432 second using v1.01-cache-2.11-cpan-f4a522933cf )