264 lines
10 KiB
PHP
264 lines
10 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);
|
|
}
|
|
}
|
|
|
|
public function testTheTwoQuestionnairesAreTheCurrentKeys(): void
|
|
{
|
|
$this->assertSame(
|
|
[QuestionnaireCatalog::BUS_FEEDBACK, QuestionnaireCatalog::TEAMER_FEEDBACK],
|
|
new QuestionnaireCatalog(self::SHIPPED_DIR)->keys(),
|
|
);
|
|
}
|
|
|
|
public function testLowRatingReasonIsRequiredOnlyForLowRatings(): void
|
|
{
|
|
$question = new QuestionnaireCatalog(self::SHIPPED_DIR)
|
|
->get(QuestionnaireCatalog::TEAMER_FEEDBACK)
|
|
->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 testBusEquipmentIsOneSingleChoicePerTopic(): void
|
|
{
|
|
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get(QuestionnaireCatalog::BUS_FEEDBACK);
|
|
|
|
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 testArchivedVersionsAreNotKeys(): void
|
|
{
|
|
$this->write('sample', ['version' => 2] + $this->sample());
|
|
$this->write('sample.v1', $this->sample());
|
|
|
|
$this->assertSame(['sample'], new QuestionnaireCatalog($this->dir)->keys());
|
|
}
|
|
|
|
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 testTheWindowStartsTheGivenDaysBeforeToday(): void
|
|
{
|
|
$this->assertEquals(new \DateTimeImmutable('today -2 days'), QuestionnaireCatalog::windowStart(2));
|
|
}
|
|
|
|
public function testANegativeWindowFailsLoudly(): void
|
|
{
|
|
$this->expectException(\InvalidArgumentException::class);
|
|
|
|
QuestionnaireCatalog::windowStart(-1);
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|