App-karr
view release on metacpan or search on metacpan
t/142-task-mutation-requires.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use Path::Tiny qw( path );
use App::karr::Role::TaskMutation;
# Ticket #141, the third and last module in the run #128 started.
# App::karr::Role::DependencyCheck declared nothing it called on its consumer;
# #128 fixed that and #137 finished it. The role that composes it --
# App::karr::Role::TaskMutation -- still declared nothing at all, while calling
# five methods on whoever composed it:
#
# run_batch $self->json
# update_task_guarded $self->git, $self->save_task
# delete_task_guarded $self->git, $self->log_task_write
# apply_status_change $self->store
#
# Nothing was broken, because every real command on the mutation path composes
# App::karr::Role::BoardAccess and App::karr::Role::Output and so brings all
# five along. That is the accident, not the guarantee: the next command to reach
# for update_task_guarded would have inherited a compare-and-swap loop whose
# collaborators nobody had checked for, and found out from inside the callback
# as a "Can't locate object method".
#
# The ticket proposed six names. It is five: check_claim is not the consumer's
# to supply. It comes from App::karr::Role::ClaimTimeout, which this role
# composes, exactly as check_dependencies comes from DependencyCheck -- see the
# third subtest for why declaring either would be worse than leaving it out.
# What the role declares on its own behalf, and who supplies each: git and store
# from App::karr::Role::BoardDiscovery, save_task and log_task_write from
# App::karr::Role::BoardAccess, json from App::karr::Role::Output.
my @DECLARED = qw( git json log_task_write save_task store );
# What it inherits as a requirement from the role it composes, over and above
# its own list (store and json are on both). Role::Tiny hands an unmet requires
# of a composed role up to whoever composes the composer, so a consumer of
# TaskMutation has to satisfy these too -- t/135-dependency-check-requires.t is
# where they are the subject.
my @INHERITED = qw( find_task quiet );
my @ALL = sort( @DECLARED, @INHERITED );
# A fresh package every time: Moo caches what it has applied to a class, so a
# second composition into the same name would not be the same experiment.
my $seq = 0;
sub compose_with {
my (@supplied) = @_;
my $pkg = 'MutationStub' . ++$seq;
my $subs = join "\n", map {"sub $_ { }"} @supplied;
my $ok = eval "package $pkg; use Moo; $subs;
with 'App::karr::Role::TaskMutation'; 1";
return ( $ok, $@, $pkg );
}
subtest 'the role declares every call it makes on its consumer' => sub {
# A requires list is only as good as its last edit, and this one went four
# tickets without existing at all. So read the calls out of the source and
# hold the declaration against them, rather than trusting the list.
my $file = path( $INC{'App/karr/Role/TaskMutation.pm'} );
ok $file->exists, 'found the source of the role to read' or return;
my $src = $file->slurp_utf8;
# POD and comments carry example code -- `$self->check_claim( $task,
# $self->claim )` among them, and $self->claim is a method no consumer is
# asked for. The declaration is about what actually executes.
$src =~ s/^=\w+.*?^=cut\b.*?$//msg;
$src =~ s/^\s*#.*$//mg;
my %called = map { $_ => 1 } $src =~ /\$self->(\w+)/g;
# Its own methods are whatever the role package can do: the subs defined in
# it, and -- the part a `^sub` scan would miss -- the ones the composed
# roles installed there. check_claim and check_dependencies arrive that way.
my %own = map { $_ => 1 }
grep { App::karr::Role::TaskMutation->can($_) } keys %called;
# Read out of the role rather than restated here, so removing the requires
# line fails this subtest instead of only the composition ones below. json
# appears twice -- once from this role, once handed up by DependencyCheck --
# which is intended and why this is a set.
my %declared =
map { $_ => 1 } @{ $Role::Tiny::INFO{'App::karr::Role::TaskMutation'}
{requires} || [] };
t/142-task-mutation-requires.t view on Meta::CPAN
is_deeply \@undeclared, [], 'no call is left undeclared'
or diag "undeclared: @undeclared";
# And the other direction, so the list cannot grow names the role never
# calls: everything declared is either called here or by the composed role.
my @unused = sort grep { !$called{$_} } @DECLARED;
is_deeply \@unused, [], 'and nothing is declared that the role never calls'
or diag "unused: @unused";
};
subtest 'a consumer that supplies none of them will not compose' => sub {
# The whole point of a requires is that it fails at composition time rather
# than from inside a compare-and-swap callback on the one run where it
# mattered. Before #141 this composed cleanly:
#
# $ perl -Ilib -e 'package Bare; use Moo;
# with "App::karr::Role::TaskMutation"; print "composed\n"'
my ( $ok, $err ) = compose_with();
ok !$ok, 'a bare consumer of TaskMutation refuses to compose';
like $err, qr/\bmissing\b/, '...and says so as a missing-method error';
like $err, qr/\b\Q$_\E\b/, "...naming $_" for @DECLARED;
};
subtest 'every declared name is load-bearing' => sub {
# Drop one name at a time. Each has to be the difference between composing
# and not -- which is what a declaration that cannot fail would flunk, and
# is the guard against putting check_claim or check_dependencies on the
# list. Role::Tiny installs a role's methods into the consumer *before* it
# checks the requires (role_application_steps), so requiring a name the role
# supplies itself passes for every consumer, forever: it reads as a checked
# promise and is not one.
for my $missing (@ALL) {
my @supplied = grep { $_ ne $missing } @ALL;
my ( $ok, $err ) = compose_with(@supplied);
ok !$ok, "a consumer without ->$missing does not compose";
like $err, qr/\b\Q$missing\E\b/, "...and the error names $missing";
}
my ($ok) = compose_with(@ALL);
ok $ok, 'and the full set composes, so the list is satisfiable';
};
subtest 'check_claim and check_dependencies are the role\'s own' => sub {
# The two calls the ticket's list got wrong. Both come from a composed role,
# so a consumer is never asked for them -- and must not be, per the subtest
# above.
my ( $ok, $err, $pkg ) = compose_with(@ALL);
ok $ok, 'a consumer supplying neither still composes' or diag $err;
ok( $pkg->can($_), "...and is handed ->$_ by the composition" )
for qw( check_claim check_dependencies );
ok( App::karr::Role::TaskMutation->can($_),
"the role package itself holds ->$_" )
for qw( check_claim check_dependencies );
ok( Role::Tiny::does_role( 'App::karr::Role::TaskMutation', $_ ),
"because it composes $_" )
for map { "App::karr::Role::$_" } qw( ClaimTimeout DependencyCheck );
};
subtest 'every command on the mutation path supplies all of them' => sub {
# Moo reports an unmet requires when the class is compiled, so loading each
# one is most of the test: a name added to the list that a command does not
# have breaks `karr <cmd>` outright, not just this file. The ->can loop is
# what says which name, instead of leaving it to a compile error.
for my $cmd (qw( Move Edit Handoff Delete Archive )) {
my $pkg = "App::karr::Cmd::$cmd";
require_ok($pkg) or next;
ok( $pkg->can($_), "$cmd supplies ->$_" ) for @ALL;
}
};
done_testing;
( run in 3.992 seconds using v1.01-cache-2.11-cpan-85d3896f969 )