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)); } }