feat: decide when a conditional questionnaire question is shown
This commit is contained in:
1 parent
a84275b40e
commit
d06b647dac
4 files changed
+170
No files matched your search
@@ -19,4 +19,26 @@ readonly class Condition
|
|||||||
public string|array|int $value,
|
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<string, mixed> $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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -40,4 +40,27 @@ readonly class Definition
|
|||||||
{
|
{
|
||||||
return $this->getQuestions()[$key] ?? null;
|
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<string, mixed> $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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Model\Questionnaire;
|
||||||
|
|
||||||
|
use App\Enum\ConditionOperator;
|
||||||
|
use App\Model\Questionnaire\Condition;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ConditionTest extends TestCase
|
||||||
|
{
|
||||||
|
#[DataProvider('provideAnswers')]
|
||||||
|
public function testIsMet(ConditionOperator $operator, string|array|int $value, mixed $answer, bool $expected): void
|
||||||
|
{
|
||||||
|
$condition = new Condition('controlling', $operator, $value);
|
||||||
|
|
||||||
|
$this->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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Model\Questionnaire;
|
||||||
|
|
||||||
|
use App\Enum\ConditionOperator;
|
||||||
|
use App\Enum\QuestionType;
|
||||||
|
use App\Model\Questionnaire\Condition;
|
||||||
|
use App\Model\Questionnaire\Definition;
|
||||||
|
use App\Model\Questionnaire\Option;
|
||||||
|
use App\Model\Questionnaire\Question;
|
||||||
|
use App\Model\Questionnaire\Section;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class DefinitionTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testQuestionWithoutConditionIsAlwaysVisible(): void
|
||||||
|
{
|
||||||
|
$definition = $this->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'),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user