feat: add questionnaire definitions and their catalog

This commit is contained in:
fromme committed 2026-10-27 16:41:46 +01:00
1 parent 2ec2a4c93f
commit a84275b40e
12 files changed
+1136

No files matched your search

+369
View File
@@ -0,0 +1,369 @@
# Questionnaire a teamer fills in after an assignment with bus escort.
#
# Replaces the Google forms "RL-Feedback 25-26" and "Bus-Feedback 25-26"; the question-by-question
# spec, with every decision taken, is in .ddev/plans/questionnaires/. Questions whose answer the
# disposition already knows (name, job profile, house, dates, destination, escort pickup) are not
# asked: they are snapshotted into the response's context instead.
#
# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it.
#
# Labels, help texts, section titles and option labels may be edited in place. Renaming or
# removing a question or option key, or changing a type, needs a new version: copy this file to
# teamer_escort.v1.yaml first, then bump `version` here. Submitted answers are keyed by it.
#
# Question fields: key, label, type (text | textarea | choice | multi | date | rating),
# required (default false), help, widget (choice only: radio | dropdown, default radio),
# options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only).
key: teamer_escort
version: 1
title: "Reiseleiter/-in Feedback"
description: |-
Nur durch euer Feedback können wir besser werden! 🙂
Bei Bewertunsskalen: 1= schlecht, 5 = sehr gut
sections:
- key: organisation
title: "Organisation & Ablauf"
questions:
- key: office_briefing_rating
label: "Wie gut hast du dich vorab vom Büro gebrieft gefühlt?"
type: rating
required: true
- key: office_briefing_low_reason
label: "Wenn 1 - 3 Sterne Bewertung, warum?"
type: textarea
required: true
show_if: { question: office_briefing_rating, lte: 3 }
- key: team_week_seminar
label: "War das Wochenteam-Seminar hilfreich für dich? Warst du bzw. wurdest du gut vorbereitet?"
type: choice
required: true
options:
- { key: very_helpful, label: "Ja, die Schulung war sehr hilfreich und ich fühlte mich bestens vorbereitet." }
- { key: mostly_helpful, label: "Ja, größtenteils – einige Themen hätte man noch vertiefen können." }
- { key: partly_helpful, label: "Es ging – die Schulung war teilweise hilfreich, aber nicht ausreichend für alle Aufgaben." }
- { key: rather_not_helpful, label: "Eher nein – ich fühlte mich in vielen Bereichen unvorbereitet." }
- { key: not_helpful, label: "Nein, die Schulung war kaum hilfreich und hat mich nicht auf den Arbeitsalltag vorbereitet." }
- { key: not_attended, label: "Ich habe nicht teilgenommen." }
- key: onsite_organisation_rating
label: "Wie gut lief die Reiseorganisation vor Ort?"
type: rating
required: true
- key: had_organisational_problems
label: "Gab es organisatorische Probleme?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: organisational_problems
label: "Wenn ja, welche?"
type: text
show_if: { question: had_organisational_problems, equals: "yes" }
- key: communication
title: "Kommunikation & Zusammenarbeit"
questions:
- key: teamer_collaboration_rating
label: "Wie lief die Kommunikation & Zusammenarbeit mit den E&P Teamer:innen vor Ort während der Reise?"
type: rating
required: true
- key: support_wished
label: "Welche Unterstützung hättest du dir zusätzlich gewünscht?"
type: textarea
- key: house_manager_problem_handling
label: "Wie ist deine Hausleitung mit Problemen vor Ort umgegangen?"
type: choice
required: true
options:
- { key: very_good, label: "Sehr gut – Probleme wurden schnell, fair und lösungsorientiert behandelt." }
- { key: good, label: "Gut – die meisten Anliegen wurden ernst genommen und bearbeitet." }
- { key: mixed, label: "Teils gut/teils schlecht – manche Probleme wurden gelöst, andere nicht." }
- { key: rather_poor, label: "Eher schlecht – es wurde wenig unternommen oder nicht auf Augenhöhe kommuniziert." }
- { key: very_poor, label: "Sehr schlecht – Probleme wurden ignoriert oder verschlimmert." }
- key: felt_overwhelmed
label: "Gab es Momente, in denen du dich überfordert gefühlt hast?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: overwhelmed_reason
label: "Wenn ja, wodurch?"
type: textarea
show_if: { question: felt_overwhelmed, equals: "yes" }
- key: improvement
title: "Verbesserung"
questions:
- key: wished_differently
label: "Was hättest du dir anders oder besser gewünscht?"
type: textarea
- key: would_work_again
label: "Würdest du wieder als Reiseleitung für E&P arbeiten?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: would_not_work_again_reason
label: "Wenn nein, warum nicht?"
type: textarea
show_if: { question: would_work_again, equals: "no" }
- key: other
title: "Sonstiges"
questions:
- key: anything_else
label: |-
Du hast nun schon vieles beantwortet,
aber nirgendwo konntest du folgende Aspekte loswerden:
type: textarea
- key: bus_trip
title: "Busfahrt"
questions:
- key: direction
label: "Hin- oder Rückfahrt"
type: choice
required: true
options:
- { key: outbound, label: "Hinfahrt" }
- { key: return, label: "Rückfahrt" }
- key: bus_company
label: "Bus-Unternehmen:"
type: choice
required: true
options:
- { key: boonk, label: "Boonk Reisen" }
- { key: piccolonia, label: "Piccolonia" }
- { key: heess, label: "Heeß" }
- { key: finkbeiner, label: "Finkbeiner" }
- { key: other, label: "Anderes Unternehmen" }
- key: bus_company_other
label: "Name des Busunternehmens:"
type: text
required: true
show_if: { question: bus_company, equals: other }
- key: bus_team
title: "Busteam"
questions:
- key: driver_names
label: "Busfahrer-Namen:"
type: text
required: true
- key: bus_first_pickup
title: "1. Abfahrtsort"
questions:
- key: on_time_at_first_pickup
label: "Bus pünktlich am 1. Abfahrtsort?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: delay_at_first_pickup
label: "Wieviel Verspätung hatte der Bus am 1. Abfahrtsort?"
type: text
required: true
show_if: { question: on_time_at_first_pickup, equals: "no" }
- key: bus_rating
title: "Bewertung Bus & Fahrer"
questions:
- key: overall_impression
label: "Gesamteindruck des Busses"
type: choice
required: true
options:
- { key: very_good, label: "Sehr gut" }
- { key: good, label: "Gut" }
- { key: ok, label: "OK" }
- { key: not_so_good, label: "nicht so gut" }
- { key: bad, label: "schlecht" }
- key: vehicle_age
label: "Alter des Fahrzeugs"
type: choice
required: true
options:
- { key: very_new, label: "Sehr neu" }
- { key: new, label: "neu" }
- { key: ok, label: "OK" }
- { key: somewhat_old, label: "etwas älter" }
- { key: very_old, label: "sehr alt" }
- key: seating_comfort
label: "Sitzkomfort im Bus"
type: choice
required: true
options:
- { key: very_good, label: "Sehr gut" }
- { key: good, label: "Gut" }
- { key: ok, label: "OK" }
- { key: not_so_good, label: "nicht so gut" }
- { key: bad, label: "schlecht" }
- key: driver_friendliness
label: "Freundlichkeit der Fahrer"
type: choice
required: true
options:
- { key: very_friendly, label: "Sehr freundlich" }
- { key: friendly, label: "freundlich" }
- { key: ok, label: "OK" }
- { key: not_so_friendly, label: "nicht so freundlich" }
- { key: unfriendly, label: "unfreundlich" }
- key: breaks
label: "Pausenzeiten"
type: choice
required: true
options:
- { key: very_good, label: "Sehr gut" }
- { key: good, label: "Gut" }
- { key: ok, label: "OK" }
- { key: not_so_good, label: "nicht so gut" }
- { key: unacceptable, label: "geht gar nicht" }
- key: temperature
label: "Temperatur im Bus"
type: choice
required: true
options:
- { key: very_good, label: "Sehr gut" }
- { key: good, label: "Gut" }
- { key: ok, label: "OK" }
- { key: not_so_good, label: "nicht so gut" }
- { key: unacceptable, label: "geht gar nicht" }
- key: guest_behaviour
label: "Verhalten der Gäste im Bus"
type: choice
required: true
options:
- { key: very_good, label: "Sehr gut" }
- { key: good, label: "Gut" }
- { key: ok, label: "OK" }
- { key: not_so_good, label: "nicht so gut" }
- { key: unacceptable, label: "geht gar nicht" }
- key: bus_pickups
title: "Ladestellen"
questions:
- key: drivers_knew_pickups
label: "Waren den Fahrern alle Abfahrtsorte bekannt?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: unknown_pickups
label: "Welche Abfahrtsorte waren nicht bekannt? Gab es dadurch Probleme?"
type: textarea
required: true
show_if: { question: drivers_knew_pickups, equals: "no" }
- key: bus_destination
title: "Zielkenntnis"
questions:
- key: drivers_knew_destination
label: "War den Busfahrern der Zielort und die Ausladestelle bekannt?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: bus_equipment
title: "Bus, Technik & Allgemeines"
questions:
- key: video
label: "Video/DVD-Gerät"
type: choice
required: true
options:
- { key: working, label: "funktionstüchtig" }
- { key: broken, label: "defekt" }
- { key: missing, label: "nicht vorhanden" }
- key: wc
label: "WC"
type: choice
required: true
options:
- { key: working, label: "funktionstüchtig" }
- { key: broken, label: "defekt" }
- { key: closed, label: "nicht geöffnet" }
- key: luggage
label: "Gepäck"
type: choice
required: true
options:
- { key: trailer, label: "Anhänger" }
- { key: ski_box, label: "Skikoffer" }
- { key: trailer_and_ski_box, label: "Anhänger und Skikoffer" }
- { key: none, label: "Kein Anhänger oder Skikoffer" }
- key: smoking
label: "Rauchen im Bus"
type: choice
required: true
options:
- { key: driver, label: "Busfahrer hat im Bus geraucht" }
- { key: driver_and_guests, label: "Busfahrer und Gäste haben im Bus geraucht" }
- { key: nobody, label: "Es wurde nicht geraucht" }
- key: snow_chains
label: "Schneeketten"
type: choice
required: true
options:
- { key: fitted, label: "Fahrer hat Ketten aufgezogen" }
- { key: not_needed, label: "Ketten wurden nicht gebraucht" }
- { key: not_fitted, label: "Ketten nicht aufgezogen (trotz starkem Schneefall)" }
- key: bus_damage
title: "Schäden"
questions:
- key: bus_damage
label: "Entstanden während der Fahrt Schäden am Bus?"
type: choice
required: true
options:
- { key: none, label: "Keine Schäden" }
- { key: settled_with_driver, label: "Bereits mit Fahrer abgeklärt" }
- { key: claim_expected, label: "Da wird vom Busunternehmen noch was kommen" }
- key: damage_description
label: "Was ist passiert?"
type: textarea
required: true
show_if: { question: bus_damage, equals: claim_expected }
- key: bus_remarks
title: "Anmerkungen zur Busfahrt"
questions:
- key: bus_remarks
label: "Das ging gar nicht / ist besonders hervorzuheben:"
type: textarea
+2
View File
@@ -151,6 +151,8 @@ services:
# Only the path enters the container - the file itself is parsed on demand by
# App\Config\EmailTextCatalog, since its contents matter on few requests.
$emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml'
# Likewise parsed on demand, by App\Config\QuestionnaireCatalog.
$questionnaireDir: '%kernel.project_dir%/config/questionnaires'
$mailingBusTransport: '%mailing_bus_transport%'
$seasonStart: '%season_start%'
$seasonEnd: '%season_end%'
+344
View File
@@ -0,0 +1,344 @@
<?php
namespace App\Config;
use App\Enum\ChoiceWidget;
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 Symfony\Component\Yaml\Yaml;
/**
* Reads questionnaire definitions from config/questionnaires/.
*
* `<key>.yaml` is the current version of a questionnaire; `<key>.v<n>.yaml` keeps an older one that
* responses were submitted under, so they can still be rendered with the labels they were given.
*
* Files are parsed lazily and memoised, like EmailTextCatalog. A definition is checked strictly
* while it is built - unknown fields, unknown types, duplicate keys and show_if conditions that
* point at a later question or a non-existent option all throw - because a typo in the YAML should
* fail in QuestionnaireCatalogTest, not in front of a teamer.
*/
class QuestionnaireCatalog
{
private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections'];
private const array SECTION_FIELDS = ['key', 'title', 'questions'];
private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if'];
private const array OPTION_FIELDS = ['key', 'label'];
// A question key becomes a form field name and a JSON key in stored answers.
private const string KEY_PATTERN = '/^[a-z][a-z0-9_]*$/';
private const int RATING_MIN = 1;
private const int RATING_MAX = 5;
/**
* @var array<string, Definition> keyed by file path
*/
private array $definitions = [];
public function __construct(
private readonly string $questionnaireDir,
) {
}
/**
* @param int|null $version null for the current version
*/
public function get(string $key, ?int $version = null): Definition
{
$current = $this->load($this->questionnaireDir.'/'.$key.'.yaml');
if (null === $version || $version === $current->version) {
return $current;
}
return $this->load(sprintf('%s/%s.v%d.yaml', $this->questionnaireDir, $key, $version));
}
/**
* Every definition in the directory, archived versions included.
*
* @return list<Definition>
*/
public function all(): array
{
$files = glob($this->questionnaireDir.'/*.yaml') ?: [];
sort($files);
return array_map($this->load(...), $files);
}
private function load(string $file): Definition
{
if (!is_file($file)) {
throw new \InvalidArgumentException(sprintf('Questionnaire definition "%s" does not exist', $file));
}
return $this->definitions[$file] ??= $this->build(Yaml::parseFile($file), basename($file));
}
private function build(mixed $data, string $file): Definition
{
$this->assertFields($data, self::ROOT_FIELDS, ['key', 'version', 'title', 'sections'], $file);
if (1 !== preg_match('/^(?<key>[a-z0-9_]+)(\.v(?<version>\d+))?\.yaml$/', $file, $name)) {
throw $this->error($file, 'file name must be <key>.yaml or <key>.v<version>.yaml');
}
if ($data['key'] !== $name['key']) {
throw $this->error($file, sprintf('key "%s" does not match the file name', $data['key']));
}
if (!is_int($data['version']) || $data['version'] < 1) {
throw $this->error($file, 'version must be a positive integer');
}
if (isset($name['version']) && (int) $name['version'] !== $data['version']) {
throw $this->error($file, sprintf('version %d does not match the file name', $data['version']));
}
if (!is_array($data['sections']) || [] === $data['sections'] || !array_is_list($data['sections'])) {
throw $this->error($file, 'sections must be a non-empty list');
}
$sections = [];
$sectionKeys = [];
/** @var array<string, Question> $questions every question parsed so far, for show_if */
$questions = [];
foreach ($data['sections'] as $index => $sectionData) {
$context = sprintf('%s: section #%d', $file, $index + 1);
$this->assertFields($sectionData, self::SECTION_FIELDS, ['key', 'questions'], $context);
$key = $this->key($sectionData['key'], $context);
if (in_array($key, $sectionKeys, true)) {
throw $this->error($context, sprintf('duplicate section key "%s"', $key));
}
$sectionKeys[] = $key;
$context = sprintf('%s: section "%s"', $file, $key);
if (!is_array($sectionData['questions']) || [] === $sectionData['questions'] || !array_is_list($sectionData['questions'])) {
throw $this->error($context, 'questions must be a non-empty list');
}
$sectionQuestions = [];
foreach ($sectionData['questions'] as $questionData) {
$question = $this->buildQuestion($questionData, $questions, $context);
$questions[$question->key] = $question;
$sectionQuestions[] = $question;
}
$sections[] = new Section($key, $this->optionalString($sectionData['title'] ?? null, 'title', $context), $sectionQuestions);
}
return new Definition(
$data['key'],
$data['version'],
$this->string($data['title'], 'title', $file),
$this->optionalString($data['description'] ?? null, 'description', $file),
$sections,
);
}
/**
* @param array<string, Question> $earlier
*/
private function buildQuestion(mixed $data, array $earlier, string $context): Question
{
$this->assertFields($data, self::QUESTION_FIELDS, ['key', 'label', 'type'], $context.': question');
$key = $this->key($data['key'], $context.': question');
$context = sprintf('%s: question "%s"', $context, $key);
if (isset($earlier[$key])) {
throw $this->error($context, 'duplicate question key');
}
$type = QuestionType::tryFrom((string) $data['type'])
?? throw $this->error($context, sprintf('unknown type "%s"', $data['type']));
$required = $data['required'] ?? false;
if (!is_bool($required)) {
throw $this->error($context, 'required must be true or false');
}
$widget = null;
if (array_key_exists('widget', $data)) {
if (QuestionType::CHOICE !== $type) {
throw $this->error($context, 'widget is only allowed on a choice question');
}
$widget = ChoiceWidget::tryFrom((string) $data['widget'])
?? throw $this->error($context, sprintf('unknown widget "%s"', $data['widget']));
} elseif (QuestionType::CHOICE === $type) {
$widget = ChoiceWidget::RADIO;
}
$options = [];
if ($type->hasOptions()) {
$options = $this->buildOptions($data['options'] ?? null, $context);
} elseif (array_key_exists('options', $data)) {
throw $this->error($context, sprintf('options are not allowed on a %s question', $type->value));
}
$showIf = array_key_exists('show_if', $data)
? $this->buildCondition($data['show_if'], $earlier, $context)
: null;
return new Question(
$key,
$this->string($data['label'], 'label', $context),
$type,
$required,
$this->optionalString($data['help'] ?? null, 'help', $context),
$widget,
$options,
$showIf,
);
}
/**
* @return list<Option>
*/
private function buildOptions(mixed $data, string $context): array
{
if (!is_array($data) || [] === $data || !array_is_list($data)) {
throw $this->error($context, 'options must be a non-empty list');
}
$options = [];
$keys = [];
foreach ($data as $optionData) {
$this->assertFields($optionData, self::OPTION_FIELDS, self::OPTION_FIELDS, $context.': option');
// An option key is stored as the answer; YAML would turn an unquoted 1 into an int.
$key = $this->key((string) $optionData['key'], $context.': option');
if (in_array($key, $keys, true)) {
throw $this->error($context, sprintf('duplicate option key "%s"', $key));
}
$keys[] = $key;
$options[] = new Option($key, $this->string($optionData['label'], 'option label', $context));
}
return $options;
}
/**
* @param array<string, Question> $earlier
*/
private function buildCondition(mixed $data, array $earlier, string $context): Condition
{
$context .= ': show_if';
$operators = array_map(static fn (ConditionOperator $operator): string => $operator->value, ConditionOperator::cases());
$this->assertFields($data, ['question', ...$operators], ['question'], $context);
$given = array_values(array_intersect($operators, array_keys($data)));
if (1 !== count($given)) {
throw $this->error($context, sprintf('needs exactly one of %s', implode(', ', $operators)));
}
$operator = ConditionOperator::from($given[0]);
$value = $data[$given[0]];
// Only an earlier question can be answered by the time this one is reached, and it rules
// out cycles without having to look for them.
$controlling = $earlier[$data['question']]
?? throw $this->error($context, sprintf('question "%s" is not an earlier question', $data['question']));
match ($operator) {
ConditionOperator::EQUALS, ConditionOperator::IN => $this->assertOptionValue($controlling, $operator, $value, $context),
ConditionOperator::LTE => $this->assertRatingValue($controlling, $value, $context),
};
return new Condition($controlling->key, $operator, $value);
}
private function assertOptionValue(Question $controlling, ConditionOperator $operator, mixed $value, string $context): void
{
if (QuestionType::CHOICE !== $controlling->type) {
throw $this->error($context, sprintf('%s needs a choice question, "%s" is %s', $operator->value, $controlling->key, $controlling->type->value));
}
$values = ConditionOperator::IN === $operator ? $value : [$value];
if (!is_array($values) || [] === $values || !array_is_list($values)) {
throw $this->error($context, 'in needs a non-empty list of option keys');
}
foreach ($values as $optionKey) {
if (!is_string($optionKey) || null === $controlling->getOption($optionKey)) {
throw $this->error($context, sprintf('"%s" is not an option of "%s"', is_string($optionKey) ? $optionKey : json_encode($optionKey), $controlling->key));
}
}
}
private function assertRatingValue(Question $controlling, mixed $value, string $context): void
{
if (QuestionType::RATING !== $controlling->type) {
throw $this->error($context, sprintf('lte needs a rating question, "%s" is %s', $controlling->key, $controlling->type->value));
}
if (!is_int($value) || $value < self::RATING_MIN || $value > self::RATING_MAX) {
throw $this->error($context, sprintf('lte needs a rating from %d to %d', self::RATING_MIN, self::RATING_MAX));
}
}
/**
* @param list<string> $allowed
* @param list<string> $required
*/
private function assertFields(mixed $data, array $allowed, array $required, string $context): void
{
if (!is_array($data) || array_is_list($data)) {
throw $this->error($context, 'must be a mapping');
}
if ([] !== $unknown = array_diff(array_keys($data), $allowed)) {
throw $this->error($context, sprintf('unknown field(s) %s', implode(', ', $unknown)));
}
if ([] !== $missing = array_diff($required, array_keys($data))) {
throw $this->error($context, sprintf('missing field(s) %s', implode(', ', $missing)));
}
}
private function key(mixed $value, string $context): string
{
if (!is_string($value) || 1 !== preg_match(self::KEY_PATTERN, $value)) {
throw $this->error($context, sprintf('key %s must be lower snake_case', json_encode($value)));
}
return $value;
}
private function string(mixed $value, string $field, string $context): string
{
if (!is_string($value) || '' === trim($value)) {
throw $this->error($context, sprintf('%s must be a non-empty string', $field));
}
return $value;
}
private function optionalString(mixed $value, string $field, string $context): ?string
{
return null === $value ? null : $this->string($value, $field, $context);
}
private function error(string $context, string $message): \UnexpectedValueException
{
return new \UnexpectedValueException(sprintf('%s: %s', $context, $message));
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Enum;
/**
* How a single-choice question is rendered: a radio list or a select.
*/
enum ChoiceWidget: string
{
case RADIO = 'radio';
case DROPDOWN = 'dropdown';
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Enum;
/**
* How a show_if condition compares the controlling question's answer with its value.
*/
enum ConditionOperator: string
{
case EQUALS = 'equals';
case IN = 'in';
case LTE = 'lte';
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Enum;
/**
* The kinds of question a questionnaire definition may use. Each maps to exactly one form field
* in QuestionnaireType; adding one means a case here and a line there, nothing more.
*/
enum QuestionType: string
{
case TEXT = 'text';
case TEXTAREA = 'textarea';
case CHOICE = 'choice';
case MULTI = 'multi';
case DATE = 'date';
case RATING = 'rating';
public function hasOptions(): bool
{
return self::CHOICE === $this || self::MULTI === $this;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Model\Questionnaire;
use App\Enum\ConditionOperator;
/**
* A question's show_if: it is shown, and can be required, only while this holds.
*/
readonly class Condition
{
/**
* @param string|list<string>|int $value an option key for EQUALS, a list of them for IN,
* a rating for LTE
*/
public function __construct(
public string $question,
public ConditionOperator $operator,
public string|array|int $value,
) {
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Model\Questionnaire;
/**
* One version of a questionnaire as read from config/questionnaires/. Responses store its key and
* version, and their answers are keyed by Question::$key.
*/
readonly class Definition
{
/**
* @param list<Section> $sections
*/
public function __construct(
public string $key,
public int $version,
public string $title,
public ?string $description,
public array $sections,
) {
}
/**
* @return array<string, Question> in the order they are asked, keyed by question key
*/
public function getQuestions(): array
{
$questions = [];
foreach ($this->sections as $section) {
foreach ($section->questions as $question) {
$questions[$question->key] = $question;
}
}
return $questions;
}
public function getQuestion(string $key): ?Question
{
return $this->getQuestions()[$key] ?? null;
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Model\Questionnaire;
readonly class Option
{
public function __construct(
public string $key,
public string $label,
) {
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Model\Questionnaire;
use App\Enum\ChoiceWidget;
use App\Enum\QuestionType;
readonly class Question
{
/**
* @param list<Option> $options only for CHOICE and MULTI
* @param ChoiceWidget|null $widget only for CHOICE
*/
public function __construct(
public string $key,
public string $label,
public QuestionType $type,
public bool $required = false,
public ?string $help = null,
public ?ChoiceWidget $widget = null,
public array $options = [],
public ?Condition $showIf = null,
) {
}
public function getOption(string $key): ?Option
{
foreach ($this->options as $option) {
if ($key === $option->key) {
return $option;
}
}
return null;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Model\Questionnaire;
readonly class Section
{
/**
* @param list<Question> $questions
*/
public function __construct(
public string $key,
public ?string $title,
public array $questions,
) {
}
}
+245
View File
@@ -0,0 +1,245 @@
<?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));
}
}