Ready for 7.10.29 development.

This commit is contained in:
Colin Kuskie 2013-03-20 21:38:23 -07:00
commit c806f99b7b
4236 changed files with 1217679 additions and 0 deletions

View file

@ -0,0 +1,568 @@
package WebGUI::Asset::Wobject::Survey::ExpressionEngine;
=head1 NAME
Package WebGUI::Asset::Wobject::Survey::ExpressionEngine
=head1 DESCRIPTION
This class is used to process Survey gotoExpressions.
If you want to allow the expression engine to run you need to turn on the enableSurveyExpressionEngine flag
in your site config file. This is because no matter how 'Safe' the Safe.pm compartment is, it still has
caveats. For example, it doesn't protect you from infinite loops.
See L<run> for more details.
=cut
use strict;
use Params::Validate qw(:all);
use Safe;
use List::Util qw/sum/;
use WebGUI::Asset;
use WebGUI::Asset::Wobject::Survey;
Params::Validate::validation_options( on_fail => sub { WebGUI::Error::InvalidParam->throw( error => shift ) } );
# We need these as file-scoped lexicals so that our utility subs (which are shared with the safe compartment)
# can access them.
# N.B. If you add any new ones, make sure you initialize them in L<run> otherwise they will be cached across
# unrelated engine runs, which leads to bugs that are hairy to track down
my $session;
my $values;
my $scores;
my $jumpCount;
my $validate;
my $validTargets;
my $otherInstances;
my $tags;
=head2 value
Utility sub that gives expressions access to recorded response values
Returns the recorded response value for the answer to question_variable
=cut
sub value {
my $key = shift;
# Verbatim values are valid variable + _verbatim
if (my ($verbatimKey) = $key =~ m/(.+)_verbatim/) {
_validateVariable($verbatimKey, 'value');
} else {
_validateVariable($key, 'value');
}
my $value = $tags->{$key} || $values->{$key};
if (ref $value eq 'ARRAY') {
my $joined = join ', ', @$value;
if (wantarray) {
$session->log->debug("value($key) in list context resolves to ($joined)");
return @$value;
} else {
$session->log->debug("value($key) in scalar|void context resolves to \"$joined\"");
return $joined;
}
} else {
$session->log->debug("value($key) resolves to [$value]");
return $value;
}
}
=head2 valueX
Same as L<value>, except that first argument is an asset spec (assetId or url), which must resolve
to a valid survey instance. The sub is applied to the most recent completed response for the user
on the survey instance given by asset_spec.
=cut
sub valueX {
my ( $asset_spec, $key ) = @_;
# See if $otherInstances already contains the external survey
if (my $otherInstance = $otherInstances->{$asset_spec}) {
my $values = $otherInstance->{values};
my $value = $values->{$key};
if (ref $value eq 'ARRAY') {
my $joined = join ', ', @$value;
if (wantarray) {
$session->log->debug("valueX($asset_spec, $key) in list context resolves to ($joined)");
return @$value;
} else {
$session->log->debug("valueX($asset_spec, $key) in scalar|void context resolves to \"$joined\"");
return $joined;
}
} else {
$session->log->debug("valueX($asset_spec, $key) resolves to [$value]");
return $value;
}
} else {
# Throw an exception, triggering run() to resolve the external reference and re-run
die( { otherInstance => $asset_spec } );
}
}
=head2 score
Utility sub that gives expressions access to recorded response scores.
If the argument is a question variable, returns the score for the answer selected for question_variable.
If the argument is a section variable, returns the summed score for the answers to all the questions in section_variable
If two arguments are provided, the first argument is assumed to be an asset spec (assetId or url). In this
case the sub is applied to the most recent completed response for the user on the survey instance given by asset_spec.
=cut
sub score {
my $key = shift;
_validateVariable($key, 'score');
my $score = $scores->{$key};
$session->log->debug("score($key) resolves to [$score]");
return $score;
}
=head2 scoreX
Same as L<score>, except that first argument is an asset spec (assetId or url), which must resolve
to a valid survey instance. The sub is applied to the most recent completed response for the user
on the survey instance given by asset_spec.
=cut
sub scoreX {
my ( $asset_spec, $key ) = @_;
# See if $otherInstances already contains the external survey
if (my $otherInstance = $otherInstances->{$asset_spec}) {
my $scores = $otherInstance->{scores};
my $score = $scores->{$key};
$session->log->debug("scoreX($asset_spec, $key) resolves to [$score]");
return $score;
} else {
# Throw an exception, triggering run() to resolve the external reference and re-run
die( { otherInstance => $asset_spec } );
}
}
=head2 _validateVariable ($key, $fn)
Convenience sub to do optional validation of variable names
=head3 key
Variable name to validate
=head3 fn
Function name of caller (for diagnostic output)
=cut
sub _validateVariable {
my $key = shift;
my $fn = shift || 'unspecified function';
if ( $validTargets && !exists $validTargets->{$key} ) {
my $error = "Param [$key] to $fn is not a valid variable name";
$session->log->debug($error);
die($error) if $validate;
return;
}
return 1;
}
=head2 answered
Returns true/false depending on whether use has actually reached and responded to the given question
If two arguments are provided, the first argument is assumed to be an asset spec (assetId or url). In this
case the sub is applied to the most recent completed response for the user on the survey instance given by asset_spec.
=cut
sub answered {
my $key = shift;
_validateVariable($key, 'answered');
my $answered = exists $values->{$key};
$session->log->debug("answered($key) returns [$answered]");
return $answered;
}
=head2 answeredX
Same as L<answered>, except that first argument is an asset spec (assetId or url), which must resolve
to a valid survey instance. The sub is applied to the most recent completed response for the user
on the survey instance given by asset_spec.
=cut
sub answeredX {
my ( $asset_spec, $key ) = @_;
# See if $otherInstances already contains the external survey
if (my $otherInstance = $otherInstances->{$asset_spec}) {
my $values = $otherInstance->{values};
my $answered = exists $values->{$key};
$session->log->debug("answeredX($asset_spec, $key) returns [$answered]");
return $answered;
} else {
# Throw an exception, triggering run() to resolve the external reference and re-run
die( { otherInstance => $asset_spec } );
}
}
=head2 tag ($name, [$value])
Utility sub that allows expressions to tag data
=head3 $name
Name of tag to set
=head3 $value (optional)
Value of tag to set. Defaults to 1 (boolean true flag).
=cut
sub tag {
my ($name, $value) = @_;
$value = 1 if !defined $value;
$session->log->debug("Setting tag [$name] to [$value]");
$tags->{$name} = $value;
}
=head2 tagged ($name)
Utility sub that gives expressions access to tagged data
=head3 $name
Name of tag whose value is returned.
=cut
sub tagged {
my ($name) = @_;
if (@_ == 2) {
my $args = join ',', @_;
$session->log->warn("Two arguments passed to tagged($args). Did you mean tag($args)?");
}
my $value = $tags->{$name};
$session->log->debug("tagged($name) resolves to [$value]");
return $value;
}
=head2 taggedX
Same as L<tagged>, except that first argument is an asset spec (assetId or url), which must resolve
to a valid survey instance. The sub is applied to the most recent completed response for the user
on the survey instance given by asset_spec.
=cut
sub taggedX {
my ( $asset_spec, $name ) = @_;
if (@_ == 3) {
my $args = join ',', @_;
$session->log->warn("Three arguments passed to taggedX($args). Did you mean tag($args)?");
}
# See if $otherInstances already contains the external survey
if (my $otherInstance = $otherInstances->{$asset_spec}) {
my $tags = $otherInstance->{tags};
my $value = $tags->{$name};
$session->log->debug("taggedX($asset_spec, $name) returns [$value]");
return $value;
} else {
# Throw an exception, triggering run() to resolve the external reference and re-run
die( { otherInstance => $asset_spec } );
}
}
=head2 jump
Utility sub shared with Safe compartment so that expressions can call individual jump tests.
Throws an exception containing the jump target when a jump matches, thus allowing L<run> to
catch the first successful jump.
=cut
sub jump(&$) {
my ( $sub, $target ) = @_;
$jumpCount++;
# If $validTargets known, make sure target is valid
if (!_validateVariable($target, 'jump')) {
return; # skip jump but continue with expression
}
if ( $sub->() ) {
$session->log->debug("jump call #$jumpCount is truthy");
die( { jump => $target } );
}
else {
$session->log->debug("jump call #$jumpCount is falsey");
}
}
=head2 exitUrl ( [$url] )
Same as L<jump> except that instead of a jump, triggers survey exit
=head3 url (optional)
Url to exit to. If not given, the Survey instance's exitUrl property will be used.
=cut
sub exitUrl {
my ( $url ) = @_;
$session->log->debug("exitUrl($url)");
die( { exitUrl => $url } );
}
=head2 restart ( $sub )
Same as L<jump> except that instead of a jump, triggers the Survey to restart
=cut
sub restart {
$session->log->debug("restart()");
die( { restart => 1 } );
}
=head2 avg
Utility sub shared with Safe compartment to allows expressions to easily compute the average of a list
=cut
sub avg {
my @vals = @_;
return sum(@vals) / @vals;
}
=head2 round
Utility sub shared with Safe compartment to allows expressions to easily round numbers
=cut
sub round {
my ($number, $precision) = @_;
$precision ||= 0;
return sprintf("%.${precision}f", $number);
}
=head2 run ( $session, $expression, $opts )
Class method.
Evaluates the given expression in a Safe compartment.
=head3 session
A WebGUI::Session
=head3 expression
The expression to run.
A gotoExpression is essentially a perl expression that gets evaluated in a Safe compartment.
To access Section/Question recorded response values, the expression calls L<value>.
To access Section/Question recorded response scores, the expression calls L<score>.
To determine if a user reached and answered a question, the expression calls L<answered>.
All of these subs allow you to resolve values and scores from other completed survey
instances.
To trigger a jump, the expression calls L<jump>. The first truthy jump succeeds.
Expressions also have access to some useful utility subs such as avg(), and all of the
handy subs from List::Util (min, max, sum, etc..).
A very simple expression that checks if the response to s1q1 is 0 might look like:
jump { value(s1q1) == 0 } target
A more complicated gotoExpression with two possible jumps might look like:
jump { value(q1) > 5 and value(s2q1) =~ m/textmatch/ } target1;
jump { avg(value(q1), value(q2), value(home/anotherSurvey, q3)) > 10 } target2;
=head3 opts (optional)
Supported options are:
=over 3
=item * values
Hashref of values to make available to the expression via the L<value> utility sub
=item * scores
Hashref of scores to make available to the expression via the L<score> utility sub
=item * validTargets
A hashref of valid jump targets. If this is provided, all L<jump> calls will fail unless
the specified target is a key in the hashref.
=item * validate
Return errors rather than just logging them (useful for displaying survey validation errors to users)
=back
=cut
sub run {
my $class = shift;
my ( $s, $expression, $opts )
= validate_pos( @_, { isa => 'WebGUI::Session' }, { type => SCALAR }, { type => HASHREF, default => {} } );
# Initialize all file-scoped lexicals that our Safe utility subs have access to
$session = $s;
$values = $opts->{values} || {};
$scores = $opts->{scores} || {};
$jumpCount = 0;
$validate = $opts->{validate};
$validTargets = $opts->{validTargets};
$tags = $opts->{tags} || {};
$otherInstances = {};
if ( !$session->config->get('enableSurveyExpressionEngine') ) {
$session->log->debug('enableSurveyExpressionEngine config option disabled, skipping');
return;
}
REVAL: {
# Create the Safe compartment
my $compartment = Safe->new();
$compartment->permit(qw(sort));
# Share our utility subs with the compartment
$compartment->share('&value');
$compartment->share('&valueX');
$compartment->share('&score');
$compartment->share('&scoreX');
$compartment->share('&answered');
$compartment->share('&answeredX');
$compartment->share('&tag');
$compartment->share('&tagged');
$compartment->share('&taggedX');
$compartment->share('&jump');
$compartment->share('&exitUrl');
$compartment->share('&restart');
$compartment->share('&avg');
$compartment->share('&round');
# Give them all of List::Util too
$compartment->share_from( 'List::Util',
[ '&first', '&max', '&maxstr', '&min', '&minstr', '&reduce', '&shuffle', '&sum', ] );
# $session->log->debug("Expression is: \"$expression\"");
$compartment->reval($expression);
# See if we ran the engine just to check for errors
if ( $opts->{validate} ) {
if ( $@ && ref $@ ne 'HASH' ) {
my $error = $@;
$error =~ s/(.*?) at .*/$1/s; # don't reveal too much
return $error;
}
return; # no validation errors
}
# A successful jump triggers a hashref containing the jump target to be thrown
if ( ref $@ && ref $@ eq 'HASH') {
if (my $target = $@->{jump}) {
return { jump => $target, tags => $tags };
}
if (exists $@->{exitUrl}) { # url might be undefined
return { exitUrl => $@->{exitUrl}, tags => $tags };
}
if (my $restart = $@->{restart}) {
return { restart => $restart, tags => $tags };
}
}
# See if an unresolved external reference was encountered
if ( ref $@ && ref $@ eq 'HASH' && $@->{otherInstance} ) {
my $asset_spec = $@->{otherInstance};
$session->log->debug("Resolving external reference: $asset_spec");
my $asset;
# Instantiate the asset to check it is a Survey instance, and to grab its assetId
if ( $session->id->valid($asset_spec) ) {
$asset = WebGUI::Asset->new( $session, $asset_spec );
}
if ( !$asset ) {
$asset = WebGUI::Asset->newByUrl( $session, $asset_spec );
}
if ( ref $asset ne 'WebGUI::Asset::Wobject::Survey' ) {
$session->log->warn("Not a survey instance: $asset_spec");
return;
}
if ( !$asset ) {
$session->log->warn("Unable to find asset: $asset_spec");
return;
}
my $assetId = $asset->getId;
# Get the responseId of the most recently completed survey response for the user
my $userId = $opts->{userId} || $session->user->userId;
my $mostRecentlyCompletedResponseId = $session->db->quickScalar(
"select Survey_responseId from Survey_response where userId = ? and assetId = ? and isComplete = 1 order by endDate desc limit 1",
[ $userId, $assetId ]
);
if ( !$mostRecentlyCompletedResponseId ) {
$session->log->debug("User $userId has not completed Survey");
return;
}
$session->log->debug("Using responseId: $mostRecentlyCompletedResponseId");
# (re)Instantiate the survey instance using the responseId
$asset = WebGUI::Asset::Wobject::Survey->newByResponseId( $session, $mostRecentlyCompletedResponseId );
if ( !$asset ) {
$session->log->warn("Unable to instantiate asset by responseId: $mostRecentlyCompletedResponseId");
return;
}
my $rJSON = $asset->responseJSON( undef, $mostRecentlyCompletedResponseId );
$otherInstances->{$asset_spec} = {
values => $rJSON->responseValues( indexBy => 'variable' ),
scores => $rJSON->responseScores( indexBy => 'variable' ),
tags => $rJSON->tags,
};
$session->log->debug("Successfully looked up asset: $assetId. Repeating reval.");
redo REVAL;
}
# Log all other errors (for example compile errors from bad expressions)
if ($@) {
$session->log->error($@);
return; # Return undef on failure
}
# If we reach here, no jump was issued, meaning that we probably just processed an expression that did some tagging
return { jump => undef, tags => $tags };
}
}
1;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,798 @@
package WebGUI::Asset::Wobject::Survey::Test;
use strict;
use base qw/WebGUI::Crud/;
use WebGUI::International;
use Test::Deep::NoTest;
use JSON::PP;
use Data::Dumper;
use Params::Validate qw(:all);
Params::Validate::validation_options( on_fail => sub { WebGUI::Error::InvalidParam->throw( error => shift ) } );
=head1 NAME
Package WebGUI::Asset::Wobject::Survey::Test;
=head1 DESCRIPTION
Base class for Survey tests
=head1 METHODS
These methods are available from this class:
=cut
#-------------------------------------------------------------------
=head2 crud_definition ( )
WebGUI::Crud definition for this class.
=head3 tableName
Survey_test
=head3 tableKey
testId
=head3 sequenceKey
assetId, e.g. each Survey instance has its own sequence of tests.
=head3 properties
=head4 assetId
Identifies the Survey instance.
=head4 name
A name for the test
=head4 test
The test spec
=cut
sub crud_definition {
my ( $class, $session ) = @_;
my $definition = $class->SUPER::crud_definition($session);
$definition->{tableName} = 'Survey_test';
$definition->{tableKey} = 'testId';
$definition->{sequenceKey} = 'assetId';
my $properties = $definition->{properties};
my $i18n = WebGUI::International->new($session);
$properties->{assetId} = {
fieldType => 'hidden',
defaultValue => undef,
};
$properties->{name} = {
fieldType => 'text',
label => $i18n->get( 'test name', 'Asset_Survey' ),
hoverHelp => $i18n->get( 'test name help', 'Asset_Survey' ),
defaultValue => '',
};
$properties->{test} = {
fieldType => 'codearea',
label => $i18n->get( 'test spec', 'Asset_Survey' ),
hoverHelp => $i18n->get( 'test spec help', 'Asset_Survey' ),
syntax => 'js',
defaultValue => <<END_SPEC,
[
{
"name": "My Test",
"test": {
"variable1": "yes",
"next": "section2",
},
},
]
END_SPEC
};
return $definition;
}
=head2 run
Run this test. Returns TAP in a hashref.
=cut
sub run {
my $self = shift;
my %opts = validate(@_, { responseId => 0 });
my $session = $self->session;
if ( !$session->config->get('enableSurveyExpressionEngine') ) {
return { tap => 'Bail Out! enableSurveyExpressionEngine config option disabled' };
}
my $spec = $self->get('test')
or return { tap => "Bail Out! Test spec undefined" };
# Use JSON::PP rather than JSON::XS so that we can use things like allow_barekey
my $json = JSON::PP->new->relaxed->allow_barekey->allow_singlequote;
eval {
$spec = $json->decode($spec); # N.B. This will change to from_json when JSON upgraded to >=2.14
# $spec = from_json($spec, { relaxed => 1} );
};
if ($@) {
my $error = $@;
# $error =~ s/(.*?) at .*/$1/s; # don't reveal too much
return { tap => "Bail Out! Invalid test spec: $error" };
}
my $assetId = $self->get('assetId');
my $survey = WebGUI::Asset::Wobject::Survey->new($session, $assetId);
if (!$survey || !$survey->isa('WebGUI::Asset::Wobject::Survey') ) {
return { tap => "Bail Out! Unable to instantiate Survey using assetId: $assetId" };
}
my $responseId = $opts{responseId};
# Remove existing responses for current user
if (!$responseId) {
$self->session->db->write( 'delete from Survey_response where assetId = ? and userId = ?',
[ $self->getId, $self->session->user->userId() ] );
# Start a response as current user
$responseId = $survey->responseId( { userId => $self->session->user->userId } )
or return { tap => "Bail Out! Unable to start survey response" };
}
# Prepare the ingredients..
my $rJSON = $survey->responseJSON
or return { tap => "Bail Out! Unable to get responseJSON" };
# Run the tests
my $testCount = 0;
my @tap;
for my $item (@$spec) {
$rJSON->reset( {preserveSurveyOrder => 1});
my $name = $item->{name};
my $setup = $item->{setup};
# N.B. we pass setup to individual test rather than running it for test, because
# some test subs reset rJSON between sub-tests
my $args;
if ($args = $item->{test} ) {
push @tap, $self->_test( {
responseJSON => $rJSON,
args => $args,
testCount_ref => \$testCount,
name => $name,
setup => $setup,
} );
}
elsif ($args = $item->{test_mc} ) {
push @tap, $self->_test_mc( {
responseJSON => $rJSON,
args => $args,
testCount_ref => \$testCount,
name => $name,
setup => $setup,
} );
}
elsif ($args = $item->{sequence} ) {
push @tap, $self->_sequence( {
responseJSON => $rJSON,
args => $args,
testCount_ref => \$testCount,
name => $name,
} );
}
elsif ($args = $item->{defined} ) {
push @tap, $self->_defined( {
responseJSON => $rJSON,
args => $args,
testCount_ref => \$testCount,
name => $name,
} );
}
else {
push @tap, "Bail Out! Invalid test definition";
}
}
$survey->persistResponseJSON;
my $tap = "1..$testCount\n";
$tap .= join "\n", @tap;
return { tap => "$tap" };
}
=head2 _test
Private sub. Triggered when a test spec requests "test".
In the test spec, keys without special meaning are assumed to be question/section vars.
The "next" key is special, indicating what section/question you expect the survey to
end up at after responses have been submitted.
=cut
sub _test {
my $self = shift;
my %opts = validate(@_, {
responseJSON => { isa => 'WebGUI::Asset::Wobject::Survey::ResponseJSON' },
testCount_ref => { type => SCALARREF },
args => { type => HASHREF },
name => 0,
setup => 1,
});
# assemble the top-level ingredients..
my $rJSON = $opts{responseJSON};
my $args = $opts{args};
my $name = $opts{name};
my $setup = $opts{setup} || $args->{setup}; # Setup option can also appear inside of test definition
my $testCount = ++${$opts{testCount_ref}};
# ..and the test-specific arguments
my ($next, $tagged, $score, $page) = @{$args}{qw(next tagged score page)};
delete $args->{next};
delete $args->{tagged};
delete $args->{score};
delete $args->{page};
delete $args->{setup};
# n.b. everything left in %args assumed to be variable => answer_spec
if (!$next && !$tagged && !$score && !$page && !$setup && scalar(%$args) == 0 ) {
return fail($testCount, "Nothing to do");
}
if ($page) {
# Recursively call ourselves (ignoring the returned TAP), so that rJSON gets
# updated with responses, simulating the page spec happening in the past
my $fakeTestCount = 0;
$self->_test( {
responseJSON => $rJSON,
testCount_ref => \$fakeTestCount,
args => $page,
setup => $setup,
} );
}
# Run setup
$self->_setup( { responseJSON => $rJSON, setup => $setup } );
# Record responses
my $responses = {};
my $lowestIndex;
my $surveyOrder = $rJSON->surveyOrder;
my $multipleChoiceTypes = $rJSON->survey->multipleChoiceTypes;
delete $multipleChoiceTypes->{Tagged}; # Don't treat Tagged as mc question type
while ( my ( $variable, $spec ) = each %$args ) {
my $index = $rJSON->surveyOrderIndex($variable);
return fail($testCount, "Invalid question variable (1): $variable") if !defined $index;
my $address = $surveyOrder->[$index];
my $question = $rJSON->survey->question($address);
return fail($testCount, "Invalid question variable (2): $variable") if !defined $question;
my $questionType = $question->{questionType};
# Keep track of lowest index (to work out what survey page we should test on)
$lowestIndex = $index if (!defined $lowestIndex || $index < $lowestIndex);
# Goal now is to figure out what answer(s) we are supposed to record
if (!defined $spec) {
$self->session->log->debug("Spec undefined, assuming that means ignore answer value");
} elsif (exists $multipleChoiceTypes->{$questionType}) {
# Multi-choice question, so spec is the raw text of the answer we want
my $answer;
my $aIndex = 0;
my $answerAddress;
# Iterate over all answers to find the matching
for my $a (@{$question->{answers}}) {
if ($a->{text} =~ m/\Q$spec\E/i) {
$answerAddress = "$address->[0]-$address->[1]-$aIndex";
$answer = $a;
last;
}
$aIndex++;
}
if (!$answer) {
return fail($testCount, "determine answer for $variable", "No answers matched text: '$spec'");
}
$self->session->log->debug("Recording $variable ($answerAddress) => $answer->{recordedAnswer}");
$responses->{$answerAddress} = 1;
} elsif ( $questionType eq 'Year Month' ) {
# Handle YearMonth delicately
if ($spec !~ m/\d{4} \w+/) {
return fail($testCount, "Invalid input for Year Month question type", "got: $spec\nExpected: YYYY Month");
}
$responses->{"$address->[0]-$address->[1]-0"} = $spec;
} else {
# Assume spec is raw value to record in the 0th answer
$responses->{"$address->[0]-$address->[1]-0"} = $spec;
}
}
my ($pageSection, $pageQuestion);
if (defined $lowestIndex) {
my $address = $surveyOrder->[$lowestIndex] or return fail($testCount, "Unable to determine address from lowest index: $lowestIndex");
$rJSON->nextResponse($lowestIndex);
$pageSection = $rJSON->survey->section($surveyOrder->[$lowestIndex]);
$pageQuestion = $rJSON->survey->question($surveyOrder->[$lowestIndex]);
}
if (!$name) {
$name = "Checking ";
my %what = ( next => $next, tagged => $tagged, score => $score );
$name .= join ' and ', (grep {$what{$_}} qw(next tagged score));
$name .= " on page containing Section $pageSection->{variable}" if $pageSection;
$name .= " Question $pageQuestion->{variable}" if $pageQuestion;
}
return $self->_recordResponses( {
responseJSON => $rJSON,
responses => $responses,
next => $next,
tagged => $tagged,
score => $score,
testCount => $testCount,
name => $name,
});
}
=head2 _setup
Private sub. Used to setup tags etc.. on a ResponseJSON instance prior to tests being run.
=cut
sub _setup {
my $self = shift;
my %opts = validate(@_, {
responseJSON => { isa => 'WebGUI::Asset::Wobject::Survey::ResponseJSON' },
setup => 1,
});
my ($rJSON, $setup) = @opts{'responseJSON', 'setup'};
# Setup any fake data the user wants prior to the test
if ($setup && ref $setup eq 'HASH') {
# Process tags
my %tags;
if (ref $setup->{tag} eq 'HASH') {
%tags = %{$setup->{tag}};
} elsif (ref $setup->{tag} eq 'ARRAY') {
for my $tag (@{$setup->{tag}}) {
if (ref $tag eq 'HASH') {
# Individual item is a single key/value hash
my ($key, $value) = %$tag;
$tags{$key} = $value;
} else {
# Individual item is a string, default to boolean truth flag
$tags{$tag} = 1; # default to 1
}
}
}
# N.B. Make sure we add to existing tags instead of overwriting
@{$rJSON->tags}{keys %tags} = values %tags;
}
}
=head2 _test_mc
Private sub. Triggered when a test spec requests "test_mc".
In the test spec, the first item is a section/question, and all remaining items are definitions
of what you expect to happen next.
=cut
sub _test_mc {
my $self = shift;
my %opts = validate(@_, {
responseJSON => { isa => 'WebGUI::Asset::Wobject::Survey::ResponseJSON' },
testCount_ref => { type => SCALARREF },
args => { type => ARRAYREF },
name => 0,
setup => 1,
});
# assemble the top-level ingredients..
my $rJSON = $opts{responseJSON};
my $args = $opts{args};
my $setup = $opts{setup};
# the first item is the section/question
my $variable = shift @$args;
# ..and all remaining items are the specs
my @specs = @$args;
my $surveyOrder = $rJSON->surveyOrder;
my $index = $rJSON->surveyOrderIndex($variable);
return fail(-1, "Invalid question variable (3): $variable") if !defined $index;
my $address = $surveyOrder->[$index];
my $question = $rJSON->survey->question($address);
return fail(-1, "Invalid question variable (4): $variable") if !defined $question;
my $answers = $question->{answers};
# Each spec is a sub-test, one per answer in the question
my @tap;
my $aIndex = 0;
for my $spec (@specs) {
# Reset responses between sub-tests
$rJSON->reset( {preserveSurveyOrder => 1});
# Run setup (per-sub-test)
$self->_setup( { responseJSON => $rJSON, setup => $setup } );
# Test runs from $variable
$rJSON->nextResponse($index);
my $responses = {};
my $testCount = ++${$opts{testCount_ref}};
my ($next, $tagged, $score);
if (ref $spec eq 'HASH') {
($next, $tagged, $score) = @{$spec}{qw(next tagged score)};
} else {
$next = $spec;
}
my $answerAddress = "$address->[0]-$address->[1]-$aIndex";
my $answer = $answers->[$aIndex];
$responses->{$answerAddress} = 1;
my $name = $opts{name}; # get this fresh for every subtest
if ($name) {
# Add some extra diagnostic text since single test_mc generates multiple sub-tests
$name .= " mc answer " . ($aIndex + 1);
} else {
$name = "Checking ";
my %what = ( next => $next, tagged => $tagged, score => $score );
$name .= join ' and ', (grep {$what{$_}} qw(next tagged score));
$name .= " for $variable mc answer " . ($aIndex + 1);
}
$self->session->log->debug("Choosing mc question $variable answer index $aIndex ($answerAddress)");
push @tap, $self->_recordResponses( {
responseJSON => $rJSON,
responses => $responses,
next => $next,
testCount => $testCount,
name => $name,
tagged => $tagged,
score => $score,
});
$aIndex++;
}
return @tap;
}
=head2 _test
Private sub. Triggered when a test spec requests "sequence".
=cut
sub _sequence {
my $self = shift;
my %opts = validate(@_, {
responseJSON => { isa => 'WebGUI::Asset::Wobject::Survey::ResponseJSON' },
testCount_ref => { type => SCALARREF },
args => { type => HASHREF },
name => 0,
});
# assemble the top-level ingredients..
my $rJSON = $opts{responseJSON};
my $args = $opts{args};
my $name = $opts{name} || 'Valid sequences';
my $testCount = ++${$opts{testCount_ref}};
# n.b. everything in %args assumed to be variable => spec
my $surveyOrder = $rJSON->surveyOrder;
while ( my ( $variable, $spec ) = each %$args ) {
my $index = $rJSON->surveyOrderIndex($variable);
return fail($testCount, "Invalid question variable (5): $variable") if !defined $index;
my $address = $surveyOrder->[$index];
my $question = $rJSON->survey->question($address);
return fail($testCount, "Invalid question variable (6): $variable") if !defined $question;
my $questionType = $question->{questionType};
# Iterate over all answers
my ($recordedAnswer, $score);
my $recordedAnswerDelta
= $spec->{recordedAnswer} =~ m/desc/ ? -1
: $spec->{recordedAnswer} =~ m/asc/ ? 1
: $spec->{recordedAnswer} =~ m/cons/ ? 0
: undef;
my $scoreDelta
= $spec->{score} =~ m/desc/ ? -1
: $spec->{score} =~ m/asc/ ? 1
: $spec->{score} =~ m/cons/ ? 0
: undef;
my $aNum = 0;
for my $a (@{$question->{answers}}) {
$aNum++;
if (defined $recordedAnswerDelta && defined $recordedAnswer) {
my $expect = $recordedAnswer + $recordedAnswerDelta;
if ( $expect != $a->{recordedAnswer}) {
return fail($testCount, "$variable answer index $aNum recordedAnswer not in sequence", "got: $a->{recordedAnswer}\nExpected: $expect");
}
}
if (defined $scoreDelta && defined $score) {
my $expect = $score + $scoreDelta;
if ( $expect != $a->{value}) {
return fail($testCount, "$variable answer index $aNum score not in sequence", "got: $a->{value}\nExpected: $expect");
}
}
$recordedAnswer = $a->{recordedAnswer};
$score = $a->{value};
}
}
return pass($testCount, $name);
}
=head2 _defined
Private sub. Triggered when a test spec requests "defined".
=cut
sub _defined {
my $self = shift;
my %opts = validate(@_, {
responseJSON => { isa => 'WebGUI::Asset::Wobject::Survey::ResponseJSON' },
testCount_ref => { type => SCALARREF },
args => { type => HASHREF },
name => 0,
});
# assemble the top-level ingredients..
my $rJSON = $opts{responseJSON};
my $args = $opts{args};
my $name = $opts{name} || 'Defined';
my $testCount = ++${$opts{testCount_ref}};
# n.b. everything in %args assumed to be regex => spec
while ( my ( $regex, $spec ) = each %$args ) {
my $r = qr/$regex/;
for my $question (@{$rJSON->survey->questions}) {
my $variable = $question->{variable};
if ($variable =~ $r) {
# Currently only supports answer specs
my $answerSpec = $spec->{answer};
my $aNum = 0;
for my $answer (@{$question->{answers}}) {
$aNum++;
for my $property (@$answerSpec) {
if (!defined $answer->{$property} || $answer->{$property} =~ m/^\s*$/) {
return fail($testCount, "$variable answer number $aNum property $property not defined", "got: '$answer->{$property}'");
}
}
}
}
}
}
return pass($testCount, $name);
}
=head2 _recordResponses
Private sub. Records responses and checks that you end up where you expect
=cut
sub _recordResponses {
my $self = shift;
my %opts = validate(@_, {
responseJSON => { isa => 'WebGUI::Asset::Wobject::Survey::ResponseJSON' },
responses => { type => HASHREF },
next => 0,
testCount => 1,
name => 0,
tagged => 0,
score => 0,
});
# assemble the top-level ingredients..
my $rJSON = $opts{responseJSON};
my $responses = $opts{responses};
my $next = $opts{next};
my $testCount = $opts{testCount};
my $name = $opts{name};
my $tagged = $opts{tagged};
my $score = $opts{score};
$rJSON->recordResponses($responses);
my $surveyOrder = $rJSON->surveyOrder;
# Check where we end up, if asked
if ($next) {
my $nextResponse = $rJSON->nextResponse;
my $nextAddress = $surveyOrder->[$nextResponse];
if ($next ne 'SURVEY_END' && !defined $nextAddress) {
return fail($testCount, $name, <<END_WHY);
Compared next section/question
got : Survey finished
expect : '$next'
END_WHY
}
if ($next eq 'SURVEY_END' && !defined $nextAddress) {
$self->session->log->debug("SURVEY_END matched correctly");
} else {
my $nextSection = $rJSON->survey->section($nextAddress);
my $nextQuestion = $rJSON->survey->question($nextAddress);
# Get the lowest section surveyOrderIndex from lookup
my $got;
my $svar = $nextSection->{variable};
my $qvar = $nextQuestion->{variable};
if ($rJSON->surveyOrderIndex($svar) == $nextResponse) {
$got = "'$svar' (<-- a section)";
$got .= " and '$qvar' (<-- a question)" if $qvar;
} elsif ($qvar) {
$got = "'$qvar' (<-- a question)";
} else {
$got = 'Unknown!';
}
my $expectedNextResponse = $rJSON->surveyOrderIndex($next);
if ($nextResponse != $expectedNextResponse) {
return fail($testCount, $name, <<END_WHY);
Compared next section/question
got : $got
expect : '$next'
END_WHY
}
}
}
# Check tagged, if asked
local $Data::Dumper::Sortkeys = 1;
# Since tags are often boolean flags, allow them to optionally be specified as an array
if ($tagged && ref $tagged eq 'ARRAY') {
my $currentTags = $rJSON->tags;
for my $tag (@$tagged) {
my ($tagKey, $tagValue);
if (ref $tag eq 'HASH') {
($tagKey, $tagValue) = %$tag; # individual tag spec only has one key and one value
} else {
($tagKey, $tagValue) = ($tag, 1); # defaults to 1 (boolean truth flag)
}
if (!exists $currentTags->{$tagKey}) {
$self->session->log->debug("Tag not found: $tagKey");
return fail($testCount, $name, "Tag not found: $tagKey");
}
my $currentTagValue = $currentTags->{$tagKey};
if (!eq_deeply($currentTagValue, $tagValue)) {
my $reason = "Compared tag: $tagKey\n" . get_differences($currentTagValue, $tagValue);
$reason .= "\nIn..\ngot: " . Dumper($currentTagValue);
$reason .= "\nexpect: " . Dumper($tagValue);
$self->session->log->debug("Incorrect tag value: $reason");
return fail($testCount, $name, $reason);
}
}
}
# Alternatively, tags can be a hash
if ($tagged && ref $tagged eq 'HASH') {
my $currentTags = $rJSON->tags;
while (my ($tagKey, $tagValue) = each %$tagged) {
my $currentTagValue = $currentTags->{$tagKey};
if (!eq_deeply($currentTagValue, $tagValue)) {
my $reason = "Compared tag: $tagKey\n" . get_differences($currentTagValue, $tagValue);
$reason .= "\nIn..\ngot: " . Dumper($currentTagValue);
$reason .= "\nexpect: " . Dumper($tagValue);
$self->session->log->debug("Incorrect tag value: $reason");
return fail($testCount, $name, $reason);
}
}
}
# Check score, if asked
if ($score && ref $score eq 'HASH') {
my $currentScores = $rJSON->responseScores( indexBy => 'variable' );
while (my ($scoreKey, $scoreValue) = each %$score) {
my $currentScore = $currentScores->{$scoreKey};
if ($currentScore != $scoreValue) {
$self->session->log->debug("Incorrect score: $currentScore != $scoreValue");
return fail($testCount, $name, <<END_WHY);
Compared score '$scoreKey'
got : '$currentScore'
expect : '$scoreValue'
END_WHY
}
}
}
return pass($testCount, $name);
}
=head2 get_differences
Once L<Test::Deep::NoTest> > 0.1 is in the WRE, this sub can be replaced with
L<cmp_deeply> and L<deep_diag>.
=cut
sub get_differences {
my ($a, $b) = @_;
if (!ref $a && !ref $b) {
return <<END_WHY;
got : '$a'
expect : '$b'
END_WHY
}
if (ref $a ne ref $b) {
return ref $a . ' does not match ' . ref $b;
}
if (ref $a eq 'ARRAY') {
return "Array lengths differ" if @$a != @$b;
for my $i (0 .. $#$a) {
if (!eq_deeply($a->[$i], $b->[$i])) {
return "Array item at index $i differs\n" . get_differences($a->[$i], $b->[$i]);
}
}
}
if (ref $a eq 'HASH') {
for my $key (keys %$a, keys %$b) {
if (!eq_deeply($a->{$key}, $b->{$key})) {
return <<END_WHY
Hashes differ on element: $key
got : '$a->{$key}'
expect : '$b->{$key}'
END_WHY
}
}
}
}
#-------------------------------------------------------------------
=head2 pass
Output TAP for a passing test.
=cut
sub pass {
my ($testCount, $name, $extra) = @_;
my $out = $name ? "ok $testCount - $name" : "ok $testCount";
if ($extra) {
$extra =~ s/^/# /gm;
$out .= "\n$extra";
}
return $out;
}
#-------------------------------------------------------------------
=head2 fail
Output TAP for a failing test, along with diagnostics.
=cut
sub fail {
my ($testCount, $name, $extra) = @_;
my $out = $name ? "not ok $testCount - $name" : "not ok $testCount";
if ($extra) {
chomp($extra);
$extra =~ s/^/# /gm;
$out .= "\n$extra";
}
return $out;
}
1;