Files
myep-team/tests/Config/QuestionnaireCatalogTest.php
T

246 lines
9.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Config;
use App\Config\QuestionnaireCatalog;
use App\Enum\ChoiceWidget;
use App\Enum\ConditionOperator;
use App\Enum\QuestionType;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Yaml\Yaml;
/**
* The definitions are edited by hand, and a typo in one would otherwise surface as a broken form
* in front of a teamer. Building every shipped definition here is what proves the files are
* valid; the rejection cases pin that the catalog does not quietly accept a malformed one.
*/
class QuestionnaireCatalogTest extends TestCase
{
private const string SHIPPED_DIR = __DIR__.'/../../config/questionnaires';
private string $dir;
protected function setUp(): void
{
$this->dir = sys_get_temp_dir().'/questionnaire_catalog_'.bin2hex(random_bytes(4));
mkdir($this->dir);
}
protected function tearDown(): void
{
array_map(unlink(...), glob($this->dir.'/*') ?: []);
rmdir($this->dir);
}
public function testEveryShippedDefinitionBuilds(): void
{
$definitions = new QuestionnaireCatalog(self::SHIPPED_DIR)->all();
$this->assertNotEmpty($definitions);
foreach ($definitions as $definition) {
$this->assertNotEmpty($definition->getQuestions(), $definition->key);
}
}
/**
* What the disposition already knows was decided not to be asked; it is snapshotted into the
* response instead. These are the Google forms' keys for those questions.
*/
public function testTeamerEscortDoesNotAskWhatTheDispositionKnows(): void
{
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_escort');
foreach (['name', 'job_profile', 'house', 'assignment_start', 'travel_date', 'destination', 'guide_name', 'first_pickup_location'] as $key) {
$this->assertNull($definition->getQuestion($key), $key);
}
}
public function testTeamerEscortLowRatingReasonIsRequiredOnlyForLowRatings(): void
{
$question = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_escort')->getQuestion('office_briefing_low_reason');
$this->assertTrue($question->required);
$this->assertSame('office_briefing_rating', $question->showIf->question);
$this->assertSame(ConditionOperator::LTE, $question->showIf->operator);
$this->assertSame(3, $question->showIf->value);
}
/**
* Google's single "Bitte ankreuzen:" checkbox list allowed "WC defekt" and "WC funktionstüchtig"
* together; it was split into one single choice per topic.
*/
public function testTeamerEscortEquipmentIsOneSingleChoicePerTopic(): void
{
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_escort');
foreach (['video', 'wc', 'luggage', 'smoking', 'snow_chains'] as $key) {
$question = $definition->getQuestion($key);
$this->assertSame(QuestionType::CHOICE, $question->type, $key);
$this->assertTrue($question->required, $key);
}
}
public function testChoiceDefaultsToRadio(): void
{
$this->write('sample', $this->sample());
$question = new QuestionnaireCatalog($this->dir)->get('sample')->getQuestion('answer');
$this->assertSame(ChoiceWidget::RADIO, $question->widget);
$this->assertFalse($question->required);
}
public function testOlderVersionIsReadFromItsArchivedFile(): void
{
$this->write('sample', ['version' => 2] + $this->sample());
$this->write('sample.v1', $this->sample(label: 'Alte Frage'));
$catalog = new QuestionnaireCatalog($this->dir);
$this->assertSame(2, $catalog->get('sample')->version);
$this->assertSame(2, $catalog->get('sample', 2)->version);
$this->assertSame('Alte Frage', $catalog->get('sample', 1)->getQuestion('answer')->label);
}
public function testUnknownQuestionnaireIsRejected(): void
{
$this->expectException(\InvalidArgumentException::class);
new QuestionnaireCatalog($this->dir)->get('missing');
}
/**
* @param \Closure(array): array $mutate
*/
#[DataProvider('provideMalformedDefinitions')]
public function testMalformedDefinitionIsRejected(\Closure $mutate, string $message): void
{
$this->write('sample', $mutate($this->sample()));
$this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage($message);
new QuestionnaireCatalog($this->dir)->get('sample');
}
public static function provideMalformedDefinitions(): iterable
{
yield 'misspelt field' => [
static fn (array $d): array => self::withQuestion($d, 1, ['requried' => true]),
'unknown field(s) requried',
];
yield 'unknown type' => [
static fn (array $d): array => self::withQuestion($d, 1, ['type' => 'slider']),
'unknown type "slider"',
];
yield 'key not snake_case' => [
static fn (array $d): array => self::withQuestion($d, 1, ['key' => 'Reason']),
'must be lower snake_case',
];
yield 'duplicate question key' => [
static fn (array $d): array => self::withQuestion($d, 1, ['key' => 'answer', 'show_if' => null] + $d['sections'][0]['questions'][1]),
'duplicate question key',
];
yield 'options on a text question' => [
static fn (array $d): array => self::withQuestion($d, 1, ['options' => [['key' => 'a', 'label' => 'A']]]),
'options are not allowed on a textarea question',
];
yield 'choice without options' => [
static fn (array $d): array => self::withQuestion($d, 0, ['options' => []]),
'options must be a non-empty list',
];
yield 'duplicate option key' => [
static fn (array $d): array => self::withQuestion($d, 0, ['options' => [['key' => 'yes', 'label' => 'Ja'], ['key' => 'yes', 'label' => 'Jawohl']]]),
'duplicate option key "yes"',
];
yield 'widget on a multi question' => [
static fn (array $d): array => self::withQuestion($d, 0, ['type' => 'multi', 'widget' => 'dropdown']),
'widget is only allowed on a choice question',
];
yield 'show_if on a later question' => [
static fn (array $d): array => self::withQuestion($d, 1, ['show_if' => ['question' => 'later', 'equals' => 'yes']]),
'"later" is not an earlier question',
];
yield 'show_if on a non-existent option' => [
static fn (array $d): array => self::withQuestion($d, 1, ['show_if' => ['question' => 'answer', 'equals' => 'maybe']]),
'"maybe" is not an option of "answer"',
];
yield 'show_if with two operators' => [
static fn (array $d): array => self::withQuestion($d, 1, ['show_if' => ['question' => 'answer', 'equals' => 'yes', 'in' => ['no']]]),
'needs exactly one of',
];
yield 'lte on a choice question' => [
static fn (array $d): array => self::withQuestion($d, 1, ['show_if' => ['question' => 'answer', 'lte' => 3]]),
'lte needs a rating question',
];
yield 'lte outside the rating scale' => [
static fn (array $d): array => self::withQuestion(self::withQuestion($d, 0, ['type' => 'rating', 'options' => null]), 1, ['show_if' => ['question' => 'answer', 'lte' => 6]]),
'lte needs a rating from 1 to 5',
];
yield 'key does not match the file name' => [
static fn (array $d): array => ['key' => 'other'] + $d,
'does not match the file name',
];
}
public function testArchivedFileWithTheWrongVersionIsRejected(): void
{
$this->write('sample', ['version' => 3] + $this->sample());
$this->write('sample.v1', ['version' => 2] + $this->sample());
$this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('version 2 does not match the file name');
new QuestionnaireCatalog($this->dir)->get('sample', 1);
}
/**
* A choice "answer" and a textarea "reason" shown when the answer is "yes".
*/
private function sample(string $label = 'Frage'): array
{
return [
'key' => 'sample',
'version' => 1,
'title' => 'Beispiel',
'sections' => [[
'key' => 'main',
'questions' => [
[
'key' => 'answer',
'label' => $label,
'type' => 'choice',
'options' => [['key' => 'yes', 'label' => 'Ja'], ['key' => 'no', 'label' => 'Nein']],
],
[
'key' => 'reason',
'label' => 'Warum?',
'type' => 'textarea',
'show_if' => ['question' => 'answer', 'equals' => 'yes'],
],
],
]],
];
}
/**
* Merges $fields into the sample's question at $index; a null value removes the field.
*/
private static function withQuestion(array $definition, int $index, array $fields): array
{
$question = array_merge($definition['sections'][0]['questions'][$index], $fields);
$definition['sections'][0]['questions'][$index] = array_filter($question, static fn (mixed $value): bool => null !== $value);
return $definition;
}
private function write(string $name, array $definition): void
{
file_put_contents(sprintf('%s/%s.yaml', $this->dir, $name), Yaml::dump($definition, 10));
}
}