diff --git a/src/Model/Questionnaire/Condition.php b/src/Model/Questionnaire/Condition.php index 8fab3c3..1b64ea8 100644 --- a/src/Model/Questionnaire/Condition.php +++ b/src/Model/Questionnaire/Condition.php @@ -19,4 +19,26 @@ readonly class Condition public string|array|int $value, ) { } + + /** + * Whether the controlling question's answer satisfies this condition. An unanswered question + * satisfies none: a follow-up stays hidden until there is something to follow up on. + * + * This says nothing about whether the controlling question is itself shown; that is + * Definition::isVisible(). + * + * @param array $answers keyed by question key + */ + public function isMet(array $answers): bool + { + $answer = $answers[$this->question] ?? null; + + return match ($this->operator) { + ConditionOperator::EQUALS => $answer === $this->value, + ConditionOperator::IN => in_array($answer, $this->value, true), + // A rating arrives as an int from the form and from stored JSON alike; anything else + // is not a rating. + ConditionOperator::LTE => is_int($answer) && $answer <= $this->value, + }; + } } diff --git a/src/Model/Questionnaire/Definition.php b/src/Model/Questionnaire/Definition.php index 0ee3a50..0764cf9 100644 --- a/src/Model/Questionnaire/Definition.php +++ b/src/Model/Questionnaire/Definition.php @@ -40,4 +40,27 @@ readonly class Definition { return $this->getQuestions()[$key] ?? null; } + + /** + * Whether a question is shown for these answers: it has no show_if, or its condition is met + * and the question it depends on is shown in turn. A hidden question's answer counts for + * nothing, even if one was given before it was hidden - otherwise a follow-up could stay open + * on an answer the respondent can no longer see. + * + * Terminates because a show_if may only name an earlier question (QuestionnaireCatalog). + * + * @param array $answers keyed by question key + */ + public function isVisible(Question $question, array $answers): bool + { + if (null === $question->showIf) { + return true; + } + + $controlling = $this->getQuestion($question->showIf->question); + + return null !== $controlling + && $this->isVisible($controlling, $answers) + && $question->showIf->isMet($answers); + } } diff --git a/tests/Model/Questionnaire/ConditionTest.php b/tests/Model/Questionnaire/ConditionTest.php new file mode 100644 index 0000000..427c407 --- /dev/null +++ b/tests/Model/Questionnaire/ConditionTest.php @@ -0,0 +1,55 @@ +assertSame($expected, $condition->isMet(['controlling' => $answer])); + } + + public static function provideAnswers(): iterable + { + yield 'equals, matching' => [ConditionOperator::EQUALS, 'yes', 'yes', true]; + yield 'equals, other option' => [ConditionOperator::EQUALS, 'yes', 'no', false]; + yield 'in, listed' => [ConditionOperator::IN, ['bad', 'very_bad'], 'bad', true]; + yield 'in, not listed' => [ConditionOperator::IN, ['bad', 'very_bad'], 'good', false]; + yield 'lte, below' => [ConditionOperator::LTE, 3, 1, true]; + yield 'lte, at the limit' => [ConditionOperator::LTE, 3, 3, true]; + yield 'lte, above' => [ConditionOperator::LTE, 3, 4, false]; + // A string "2" is not what the form or the stored JSON hands over for a rating, so it + // must not open a follow-up by accident. + yield 'lte, not an int' => [ConditionOperator::LTE, 3, '2', false]; + } + + /** + * An unanswered rating is not "3 or less": the low-rating follow-up must not demand a reason + * before a rating was given. + */ + #[DataProvider('provideOperators')] + public function testUnansweredIsNeverMet(ConditionOperator $operator, string|array|int $value): void + { + $condition = new Condition('controlling', $operator, $value); + + $this->assertFalse($condition->isMet([])); + $this->assertFalse($condition->isMet(['controlling' => null])); + } + + public static function provideOperators(): iterable + { + yield 'equals' => [ConditionOperator::EQUALS, 'yes']; + yield 'in' => [ConditionOperator::IN, ['yes']]; + yield 'lte' => [ConditionOperator::LTE, 3]; + } +} diff --git a/tests/Model/Questionnaire/DefinitionTest.php b/tests/Model/Questionnaire/DefinitionTest.php new file mode 100644 index 0000000..cd1a068 --- /dev/null +++ b/tests/Model/Questionnaire/DefinitionTest.php @@ -0,0 +1,70 @@ +definition(); + + $this->assertTrue($definition->isVisible($definition->getQuestion('damage'), [])); + } + + public function testFollowUpIsVisibleOnlyForItsAnswer(): void + { + $definition = $this->definition(); + $followUp = $definition->getQuestion('claim'); + + $this->assertTrue($definition->isVisible($followUp, ['damage' => 'yes'])); + $this->assertFalse($definition->isVisible($followUp, ['damage' => 'no'])); + } + + /** + * "details" depends on "claim", which depends on "damage". Switching "damage" to "no" hides + * "claim"; its stale answer must not keep "details" open. + */ + public function testFollowUpOfAHiddenQuestionIsHidden(): void + { + $definition = $this->definition(); + $details = $definition->getQuestion('details'); + + $this->assertTrue($definition->isVisible($details, ['damage' => 'yes', 'claim' => 'expected'])); + $this->assertFalse($definition->isVisible($details, ['damage' => 'no', 'claim' => 'expected'])); + } + + private function definition(): Definition + { + $yesNo = [new Option('yes', 'Ja'), new Option('no', 'Nein')]; + + return new Definition('sample', 1, 'Beispiel', null, [ + new Section('main', null, [ + new Question('damage', 'Schäden?', QuestionType::CHOICE, options: $yesNo), + new Question( + 'claim', + 'Kommt noch was?', + QuestionType::CHOICE, + options: [new Option('expected', 'Ja'), new Option('settled', 'Nein')], + showIf: new Condition('damage', ConditionOperator::EQUALS, 'yes'), + ), + new Question( + 'details', + 'Was ist passiert?', + QuestionType::TEXTAREA, + showIf: new Condition('claim', ConditionOperator::EQUALS, 'expected'), + ), + ]), + ]); + } +}