diff --git a/lib/WebGUI/Asset/Wobject/Survey.pm b/lib/WebGUI/Asset/Wobject/Survey.pm index 7397d0edf..ae50df320 100644 --- a/lib/WebGUI/Asset/Wobject/Survey.pm +++ b/lib/WebGUI/Asset/Wobject/Survey.pm @@ -458,9 +458,7 @@ sub www_loadSurvey { } # Generate the list of valid goto targets - my @section_vars = map {$_->{variable}} @{$self->survey->sections}; - my @question_vars = map {$_->{variable}} @{$self->survey->questions}; - my @gotoTargets = grep {$_ ne ''} (@section_vars, @question_vars); + my @gotoTargets = $self->survey->getGotoTargets; my %buttons; $buttons{question} = $$address[0]; diff --git a/lib/WebGUI/Asset/Wobject/Survey/ResponseJSON.pm b/lib/WebGUI/Asset/Wobject/Survey/ResponseJSON.pm index c939d2809..9210235c1 100644 --- a/lib/WebGUI/Asset/Wobject/Survey/ResponseJSON.pm +++ b/lib/WebGUI/Asset/Wobject/Survey/ResponseJSON.pm @@ -400,6 +400,7 @@ sub recordResponses { my $terminal = 0; my $terminalUrl; my $goto; + my $gotoExpression; my $section = $self->nextSection();#which gets the current section for the just submitted questions. IE, current response pointer has not moved forward for these questions @@ -444,6 +445,9 @@ sub recordResponses { elsif ( $answer->{goto} =~ /\w/ ) { $goto = $answer->{goto}; } + elsif ( $answer->{gotoExpression} =~ /\w/ ) { + $gotoExpression = $answer->{gotoExpression}; + } } ## end if ( defined( $responses... } ## end for my $answer ( @{ $question... $qAnswered = 0 if ( !$aAnswered and $question->{required} ); @@ -456,6 +460,7 @@ sub recordResponses { if ($qAnswered) { $self->lastResponse( $self->lastResponse + @$questions ); $self->goto($goto) if ( defined $goto ); + $self->gotoExpression($gotoExpression) if ( defined $gotoExpression ); } else { $terminal = 0; @@ -501,6 +506,164 @@ sub goto { #------------------------------------------------------------------- +=head2 gotoExpression ( $gotoExpression ) + +=head3 $gotoExpression + +The gotoExpression (one expression per line) + +=head3 Explanation + +A gotoExpression is a list of expressions (one per line) of the form: + target: expression + target: expression + +This subroutine iterates through the list, processing each line and, all things being +well, evaluates the expression. The first expression to evaluate to true triggers a +call to goto($target). + +The expression is a simple subset of the formula language used in spreadsheet programs such as Excel, OpenOffice, Google Docs etc.. + +Here is an example using section variables S1 and S2 as jump targets and question variables Q1-3 in the expression. +It jumps to S1 if the user's answer to Q1 has a value of 3, jumps to S2 if Q2 + Q3 < 10, and otherwise doesn't branch at all (the default). +S1: Q1 = 3 +S2: Q2 + Q3 < 10 + +=head3 Arguments are evaluated as follows: + +Numeric arguments evaluate as numbers +* No support for strings (and hence no string matching) +* Question variable names (e.g. Q1) evaluate to the numeric value associated with user's answer to that question, or undefined if the user has not answered that question + +Binary comparisons operators: = != < <= >= > +* return boolean values based on perl's equivalent numeric comparison operators + +Simple math operators: + - * / +* return numeric values + +Later we may add Boolean operators: AND( x; y; z; ... ), OR( x; y; z; ... ), NOT( x ) +* args separated by semicolons (presumably because spreadsheet formulas use commas to indicate cell ranges) + +Later still you may be able to say AVG(section1) or SUM(section3) and have those functions automatically compute their result over the set of all questions in the given section. +But for now those things can be done manually using the limited subset defined. + +=cut + +sub gotoExpression { + my $self = shift; + my $expression = shift; + + my %responses = ( + # questionName => response answer value + ); + + # Populate %responses with the user's data.. + foreach my $q (@{ $self->returnResponseForReporting() }) { + if ($q->{questionName} =~ /\w/) { + my $value = $q->{answers}[0]{value}; + $responses{$q->{questionName}} = $value if defined $value; + } + } + + # Process gotoExpressions one after the other (first one that's true wins) + foreach my $line (split '\n', $expression) { + my $processed = $self->processGotoExpression($line, \%responses); + + next unless $processed; + + # (ab)use perl's eval to evaluate the processed expression + my $result = eval "$processed->{expression}"; + $self->warn($@) if $@; + + if ($result) { + $self->debug("Truthy, goto [$processed->{target}]"); + $self->goto($processed->{target}); + return $processed; + } else { + $self->debug("Falsy, not branching"); + next; + } + } + return; +} + +=head2 processGotoExpression ( $expression, $responses) + +Parses a single gotoExpression. Returns undef if processing fails, or the following hashref +if things work out well: + { target => $target, expression => $expression } + +=head3 $expression + +The expression to process + +=head3 $responses + +Hashref that maps questionNames to response values + +=head3 Explanation: + +Uses the following simple strategy: + +First, parse the expression as: + target: expression + +Replace each questionName with its response value (from the $responses hashref) + +Massage the expression into valid perl + +Check that only valid tokens remain. This last step ensures that any invalid questionNames in +the expression generate an error because our list of valid tokens doesn't include a-z + +=cut + +sub processGotoExpression { + my $self = shift; + my $expression = shift; + my $responses = shift; + + $self->debug("Processing gotoExpression: $expression"); + + # Valid gotoExpression tokens are.. + my $tokens = qr{\s|[-0-9=!<>+*/.()]}; + + my ( $target, $rest ) = $expression =~ /\s* ([^:]+?) \s* : \s* (.*)/x; + + $self->debug("Parsed as Target: [$target], Expression: [$rest]"); + + if ( !defined $target ) { + $self->warn('Target undefined'); + return; + } + + if ( !defined $rest || $rest eq '' ) { + $self->warn('Expression undefined'); + return; + } + + # Replace each questionName with its response value + while ( my ( $questionName, $response ) = each %$responses ) { + $rest =~ s/$questionName/$response/g; + } + + # convert '=' to '==' but don't touch '!=', '<=' or '>=' + $rest =~ s/(?])=(?!=)/==/g; + + if ( $rest !~ /^$tokens+$/ ) { + $self->warn("Contains invalid tokens: $rest"); + return; + } + + $self->debug("Processed as: $rest"); + + return { + target => $target, + expression => $rest, + }; +} + +#------------------------------------------------------------------- + =head2 getPreviousAnswer =cut @@ -691,9 +854,23 @@ Logs an error to the webgui log file, using the session logger. =cut sub log { - my ( $self, $message ) = @_; + my ( $self, $message) = @_; if ( defined $self->{log} ) { $self->{log}->error($message); } } + +sub debug { + my ( $self, $message) = @_; + if ( defined $self->{log} ) { + $self->{log}->debug($message); + } +} + +sub warn { + my ( $self, $message) = @_; + if ( defined $self->{log} ) { + $self->{log}->warn($message); + } +} 1; diff --git a/lib/WebGUI/Asset/Wobject/Survey/SurveyJSON.pm b/lib/WebGUI/Asset/Wobject/Survey/SurveyJSON.pm index dc024ff54..4cbf78029 100644 --- a/lib/WebGUI/Asset/Wobject/Survey/SurveyJSON.pm +++ b/lib/WebGUI/Asset/Wobject/Survey/SurveyJSON.pm @@ -318,6 +318,14 @@ sub getSectionEditVars { return \%var; } ## end sub getSectionEditVars +sub getGotoTargets { + my $self = shift; + + my @section_vars = map {$_->{variable}} @{$self->sections}; + my @question_vars = map {$_->{variable}} @{$self->questions}; + return grep {$_ ne ''} (@section_vars, @question_vars); +} + =head2 getQuestionEditVars ( $address ) Get a safe copy of the variables for this question, to use for editing purposes. Adds @@ -707,6 +715,7 @@ sub newAnswer { textCols => 10, textRows => 5, goto => '', + gotoExpression => '', recordedAnswer => '', isCorrect => 1, min => 1, diff --git a/lib/WebGUI/i18n/English/Asset_Survey.pm b/lib/WebGUI/i18n/English/Asset_Survey.pm index af3180db9..c1480265f 100644 --- a/lib/WebGUI/i18n/English/Asset_Survey.pm +++ b/lib/WebGUI/i18n/English/Asset_Survey.pm @@ -195,6 +195,10 @@ our $I18N = { message => q|Jump to:|, lastUpdated => 1224686319 }, + 'jump expression' => { + message => q|Jump expression:|, + lastUpdated => 1229318805 + }, 'text answer' => { message => q|Text answer|, lastUpdated => 1224686319 diff --git a/t/Asset/Wobject/Survey/ResponseJSON.t b/t/Asset/Wobject/Survey/ResponseJSON.t index f8f424d52..a75f5b47c 100644 --- a/t/Asset/Wobject/Survey/ResponseJSON.t +++ b/t/Asset/Wobject/Survey/ResponseJSON.t @@ -20,7 +20,7 @@ my $session = WebGUI::Test->session; #---------------------------------------------------------------------------- # Tests -my $tests = 52; +my $tests = 77; plan tests => $tests + 1; #---------------------------------------------------------------------------- @@ -319,6 +319,80 @@ is($rJSON->lastResponse(), 0, 'goto: works on existing question'); $rJSON->goto('goto 3-0'); is($rJSON->lastResponse(), 5, 'goto: finds first if there are duplicates'); +#################################################### +# +# processGotoExpression +# +#################################################### +is($rJSON->processGotoExpression(), + undef, 'processGotoExpression undef with empty arguments'); +is($rJSON->processGotoExpression('blah-dee-blah-blah'), + undef, '.. and undef with duff expression'); +is($rJSON->processGotoExpression(':'), + undef, '.. and undef with missing target'); +is($rJSON->processGotoExpression('t1:'), + undef, '.. and undef with missing expression'); +cmp_deeply($rJSON->processGotoExpression('t1: 1'), + { target => 't1', expression => '1'}, 'works for simple numeric expression'); +cmp_deeply($rJSON->processGotoExpression('t1: 1 - 23 + 456 * (78 / 9.0)'), + { target => 't1', expression => '1 - 23 + 456 * (78 / 9.0)'}, 'works for expression using all algebraic tokens'); +is($rJSON->processGotoExpression('t1: 1 + &'), undef, '.. but disallows expression containing non-whitelisted token'); +cmp_deeply($rJSON->processGotoExpression('t1: 1 = 3'), + { target => 't1', expression => '1 == 3'}, 'converts single = to =='); +cmp_deeply($rJSON->processGotoExpression('t1: 1 != 3 <= 4 >= 5'), + { target => 't1', expression => '1 != 3 <= 4 >= 5'}, q{..but doesn't mess with other ops containing =}); +cmp_deeply($rJSON->processGotoExpression('t1: q1 + q2 * q3 - 4', { q1 => 11, q2 => 22, q3 => 33}), + { target => 't1', expression => '11 + 22 * 33 - 4'}, 'substitues q for value'); +cmp_deeply($rJSON->processGotoExpression('t1: a silly var name * 10 + another var name', { 'a silly var name' => 345, 'another var name' => 456}), + { target => 't1', expression => '345 * 10 + 456'}, '..it even works for vars with spaces in their names'); +is($rJSON->processGotoExpression('t1: qX + 3', { q1 => '7'}), + undef, q{..but doesn't like invalid var names}); + +#################################################### +# +# gotoExpression +# +#################################################### + +$rJSON->survey->section([0])->{variable} = 's0'; +$rJSON->survey->section([2])->{variable} = 's2'; +$rJSON->survey->question([1,0])->{variable} = 's1q0'; +$rJSON->survey->answer([1,0,0])->{value} = 3; + +$rJSON->lastResponse(2); +$rJSON->recordResponses($session, { + '1-0comment' => 'Section 1, question 0 comment', + '1-0-0' => 'First answer', + '1-0-0comment' => 'Section 1, question 0, answer 0 comment', +}); +is($rJSON->gotoExpression('blah-dee-blah-blah'), undef, 'invalid gotoExpression is false'); +ok($rJSON->gotoExpression('s0: s1q0 = 3'), '3 == 3 is true'); +ok(!$rJSON->gotoExpression('s0: s1q0 = 4'), '3 == 4 is false'); +ok($rJSON->gotoExpression('s0: s1q0 != 2'), '3 != 2 is true'); +ok(!$rJSON->gotoExpression('s0: s1q0 != 3'), '3 != 3 is false'); +ok($rJSON->gotoExpression('s0: s1q0 > 2'), '3 > 2 is true'); +ok($rJSON->gotoExpression('s0: s1q0 < 4'), '3 < 2 is true'); +ok(!$rJSON->gotoExpression('s0: s1q0 >= 4'), '3 >= 4 is false'); +ok(!$rJSON->gotoExpression('s0: s1q0 <= 2'), '3 >= 4 is false'); + +cmp_deeply($rJSON->gotoExpression(<<"END_EXPRESSION"), {target => 's2', expression => '3 == 3'}, 'first true expression wins'); +s0: s1q0 <= 2 +s2: s1q0 = 3 +END_EXPRESSION + +ok(!$rJSON->gotoExpression(<<"END_EXPRESSION"), 'but multiple false expressions still false'); +s0: s1q0 <= 2 +s2: s1q0 = 345 +END_EXPRESSION + +$rJSON->gotoExpression('s0: s1q0 = 3'); +is($rJSON->lastResponse(), -1, '.. lastResponse changed to -1 due to goto(s0)'); +$rJSON->gotoExpression('s2: s1q0 = 3'); +is($rJSON->lastResponse(), 4, '.. lastResponse changed to 4 due to goto(s2)'); + +$rJSON->{responses} = {}; +$rJSON->questionsAnswered(-1 * $rJSON->questionsAnswered); + #################################################### # # recordResponses diff --git a/t/Asset/Wobject/Survey/SurveyJSON.t b/t/Asset/Wobject/Survey/SurveyJSON.t index d308388b2..7208ea8c8 100644 --- a/t/Asset/Wobject/Survey/SurveyJSON.t +++ b/t/Asset/Wobject/Survey/SurveyJSON.t @@ -2118,6 +2118,7 @@ sub getBareSkeletons { textCols => 10, textRows => 5, goto => '', + gotoExpression => '', recordedAnswer => '', isCorrect => 1, min => 1,