fix: offer the questionnaire after every placement, the bus part to escorts

This commit is contained in:
fromme committed 2026-10-27 17:12:24 +01:00
1 parent 9fedc5b5d3
commit 2f4f9800dd
30 files changed
+311 -77

No files matched your search

@@ -1,21 +1,24 @@
# Questionnaire a teamer fills in after an assignment with bus escort.
# Questionnaire a teamer fills in after a placement (Disposition, not SeasonAssignment).
#
# 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.
# spec, with every decision taken, is in .ddev/plans/questionnaires/. Every teamer gets the
# sections up to "Sonstiges"; the sections marked `only_for: bus_escort` are added when the
# assignment had a bus escort. 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.
# teamer_assignment.v1.yaml first, then bump `version` here. Submitted answers are keyed by it.
#
# Section fields: key, title, only_for (a App\Enum\QuestionnaireFlag value), questions.
# 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
key: teamer_assignment
version: 1
title: "Reiseleiter/-in Feedback"
description: |-
@@ -135,6 +138,7 @@ sections:
- key: bus_trip
title: "Busfahrt"
only_for: bus_escort
questions:
- key: direction
label: "Hin- oder Rückfahrt"
@@ -163,6 +167,7 @@ sections:
- key: bus_team
title: "Busteam"
only_for: bus_escort
questions:
- key: driver_names
label: "Busfahrer-Namen:"
@@ -171,6 +176,7 @@ sections:
- key: bus_first_pickup
title: "1. Abfahrtsort"
only_for: bus_escort
questions:
- key: on_time_at_first_pickup
label: "Bus pünktlich am 1. Abfahrtsort?"
@@ -188,6 +194,7 @@ sections:
- key: bus_rating
title: "Bewertung Bus & Fahrer"
only_for: bus_escort
questions:
- key: overall_impression
label: "Gesamteindruck des Busses"
@@ -268,6 +275,7 @@ sections:
- key: bus_pickups
title: "Ladestellen"
only_for: bus_escort
questions:
- key: drivers_knew_pickups
label: "Waren den Fahrern alle Abfahrtsorte bekannt?"
@@ -285,6 +293,7 @@ sections:
- key: bus_destination
title: "Zielkenntnis"
only_for: bus_escort
questions:
- key: drivers_knew_destination
label: "War den Busfahrern der Zielort und die Ausladestelle bekannt?"
@@ -296,6 +305,7 @@ sections:
- key: bus_equipment
title: "Bus, Technik & Allgemeines"
only_for: bus_escort
questions:
- key: video
label: "Video/DVD-Gerät"
@@ -345,6 +355,7 @@ sections:
- key: bus_damage
title: "Schäden"
only_for: bus_escort
questions:
- key: bus_damage
label: "Entstanden während der Fahrt Schäden am Bus?"
@@ -363,6 +374,7 @@ sections:
- key: bus_remarks
title: "Anmerkungen zur Busfahrt"
only_for: bus_escort
questions:
- key: bus_remarks
label: "Das ging gar nicht / ist besonders hervorzuheben:"
+3 -3
View File
@@ -90,9 +90,9 @@ parameters:
season_start: '11-01'
season_end: '04-01'
# The teamer questionnaire after a bus escort (config/questionnaires/teamer_escort.yaml) is
# offered for assignments ending on or after this day (Y-m-d). Set it to the deployment day:
# without a cutoff every past escort would show up as pending on the teamers' dashboards.
# The teamer questionnaire after a placement (config/questionnaires/teamer_assignment.yaml) is
# offered for placements ending on or after this day (Y-m-d). Set it to the deployment day:
# without a cutoff every past placement would show up as pending on the teamers' dashboards.
# null offers it to nobody.
questionnaire_go_live: ~
+1 -1
View File
@@ -9,7 +9,7 @@ use Doctrine\Migrations\AbstractMigration;
/**
* Adds questionnaire_response: a respondent's answers to one questionnaire from
* config/questionnaires/, for now the teamer questionnaire after a bus-escort disposition.
* config/questionnaires/, for now the teamer questionnaire after a placement.
*
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
* pre-existing drift of the dev database.
+31 -5
View File
@@ -4,6 +4,7 @@ namespace App\Config;
use App\Enum\ChoiceWidget;
use App\Enum\ConditionOperator;
use App\Enum\QuestionnaireFlag;
use App\Enum\QuestionType;
use App\Model\Questionnaire\Condition;
use App\Model\Questionnaire\Definition;
@@ -26,12 +27,12 @@ use Symfony\Component\Yaml\Yaml;
class QuestionnaireCatalog
{
/**
* The questionnaire a teamer fills in after a bus escort; see DispositionVoter::QUESTIONNAIRE.
* The questionnaire a teamer fills in after a placement; see DispositionVoter::QUESTIONNAIRE.
*/
public const string TEAMER_ESCORT = 'teamer_escort';
public const string TEAMER_ASSIGNMENT = 'teamer_assignment';
private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections'];
private const array SECTION_FIELDS = ['key', 'title', 'questions'];
private const array SECTION_FIELDS = ['key', 'title', 'only_for', 'questions'];
private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if'];
private const array OPTION_FIELDS = ['key', 'label'];
@@ -53,7 +54,7 @@ class QuestionnaireCatalog
/**
* The services.yaml parameter questionnaire_go_live as a date: the teamer questionnaire is
* offered for assignments ending on or after it, and to nobody while it is null.
* offered for placements ending on or after it, and to nobody while it is null.
*/
public static function parseGoLive(?string $goLive): ?\DateTimeImmutable
{
@@ -129,6 +130,8 @@ class QuestionnaireCatalog
$sectionKeys = [];
/** @var array<string, Question> $questions every question parsed so far, for show_if */
$questions = [];
/** @var array<string, QuestionnaireFlag|null> $questionFlags the only_for of each question's section */
$questionFlags = [];
foreach ($data['sections'] as $index => $sectionData) {
$context = sprintf('%s: section #%d', $file, $index + 1);
@@ -146,15 +149,38 @@ class QuestionnaireCatalog
throw $this->error($context, 'questions must be a non-empty list');
}
$onlyFor = null;
if (array_key_exists('only_for', $sectionData)) {
$onlyFor = QuestionnaireFlag::tryFrom((string) $sectionData['only_for'])
?? throw $this->error($context, sprintf('unknown only_for "%s"', $sectionData['only_for']));
}
$sectionQuestions = [];
foreach ($sectionData['questions'] as $questionData) {
$question = $this->buildQuestion($questionData, $questions, $context);
// A question that may be asked without the flag cannot depend on one that is only
// asked with it: it would stay hidden for everyone without the flag.
if (null !== $question->showIf
&& null !== ($controllingFlag = $questionFlags[$question->showIf->question])
&& $controllingFlag !== $onlyFor
) {
throw $this->error(sprintf('%s: question "%s": show_if', $context, $question->key), sprintf('"%s" is only asked for %s', $question->showIf->question, $controllingFlag->value));
}
$questions[$question->key] = $question;
$questionFlags[$question->key] = $onlyFor;
$sectionQuestions[] = $question;
}
$sections[] = new Section($key, $this->optionalString($sectionData['title'] ?? null, 'title', $context), $sectionQuestions);
$sections[] = new Section(
$key,
$this->optionalString($sectionData['title'] ?? null, 'title', $context),
$sectionQuestions,
$onlyFor,
);
}
return new Definition(
@@ -33,7 +33,7 @@ class IndexController extends AbstractController
$filterDto = $this->filterHandler->getFilterSettings();
$pagination = $this->paginator->paginate(
$this->responseRepository->getListQuery(QuestionnaireCatalog::TEAMER_ESCORT, $filterDto),
$this->responseRepository->getListQuery(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $filterDto),
$request->query->getInt('page', 1),
$request->query->getInt('limit', 50),
[
@@ -43,7 +43,7 @@ class IndexController extends AbstractController
);
return $this->render('administrative/questionnaire/index.html.twig', [
'definition' => $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT),
'definition' => $this->catalog->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT),
'pagination' => $pagination,
'filterDto' => $filterDto,
]);
@@ -5,6 +5,7 @@ namespace App\Controller\Administrative\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\Assignment;
use App\Enum\QuestionnaireFlag;
use App\Form\QuestionnaireType;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -14,9 +15,10 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The teamer questionnaire as a teamer gets it, for trying out on any assignment - with or without
* bus escort, before or after the trip, placed or not. Nothing is saved: "Absenden" shows the
* answers as they would be stored, "Entwurf speichern" just keeps them in the form.
* The teamer questionnaire as a teamer gets it, for trying out on any assignment - before or after
* the trip, placed or not. It shows the assignment's own variant, with or without the bus
* sections; ?bus_escort=1 or 0 tries the other one. Nothing is saved: "Absenden" shows the answers
* as they would be stored, "Entwurf speichern" just keeps them in the form.
*/
class TestController extends AbstractController
{
@@ -34,7 +36,13 @@ class TestController extends AbstractController
Assignment $assignment,
Request $request,
): Response {
$definition = $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT);
$busEscort = $request->query->has('bus_escort')
? $request->query->getBoolean('bus_escort')
: $assignment->hasBusEscort();
$definition = $this->catalog
->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT)
->forFlags($busEscort ? [QuestionnaireFlag::BUS_ESCORT] : [])
;
$form = $this->createForm(QuestionnaireType::class, [], ['definition' => $definition]);
$form->handleRequest($request);
@@ -56,6 +64,7 @@ class TestController extends AbstractController
'form' => $form,
'submitted' => $submitted,
'draft' => $draft,
'busEscort' => $busEscort,
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_assignment_detail', ['uuid' => $assignment->getUuid()]),
]);
}
@@ -30,7 +30,7 @@ class DetailController extends AbstractController
#[MapEntity(mapping: ['uuid' => 'uuid'])]
Disposition $disposition,
): Response {
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition);
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition);
if (true !== $response?->isSubmitted()) {
return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]);
@@ -18,8 +18,9 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The teamer's questionnaire after a bus escort, saved as a draft until it is submitted. A
* submitted one is final and only shown read-only (DetailController).
* The teamer's questionnaire after a placement - with the bus sections if it was a bus escort -
* saved as a draft until it is submitted. A submitted one is final and only shown read-only
* (DetailController).
*/
class EditController extends AbstractController
{
@@ -39,13 +40,16 @@ class EditController extends AbstractController
Disposition $disposition,
Request $request,
): Response {
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition);
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition);
if (true === $response?->isSubmitted()) {
return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]);
}
$definition = $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT);
$definition = $this->catalog
->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT)
->forFlags(DispositionContext::flags($disposition->getAssignment()))
;
$form = $this->createForm(QuestionnaireType::class, $response?->getAnswers() ?? [], [
'definition' => $definition,
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Enum;
/**
* Something a questionnaire's subject may or may not have, deciding whether a section marked
* `only_for` it is part of the questionnaire at all. Unlike show_if, it is not answered by the
* respondent but known beforehand - for a disposition, by DispositionContext::flags().
*/
enum QuestionnaireFlag: string
{
case BUS_ESCORT = 'bus_escort';
}
+2 -2
View File
@@ -112,10 +112,10 @@ class AdminMenuBuilder extends AbstractMenuBuilder
$menu->addChild('Fragebögen', [
'route' => 'app_administrative_questionnaire_index',
'linkAttributes' => [
'title' => 'Fragebögen der Busbegleitungen',
'title' => 'Fragebögen nach Einsätzen',
],
'extras' => [
'icon' => 'bus',
'icon' => 'document',
'routes' => [
['pattern' => '/^app_administrative_questionnaire_/'],
],
+2 -2
View File
@@ -121,10 +121,10 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
$menu->addChild('Fragebögen', [
'route' => 'app_administrative_questionnaire_index',
'linkAttributes' => [
'title' => 'Fragebögen der Busbegleitungen',
'title' => 'Fragebögen nach Einsätzen',
],
'extras' => [
'icon' => 'bus',
'icon' => 'document',
'routes' => [
['pattern' => '/^app_administrative_questionnaire_/'],
],
+22
View File
@@ -2,6 +2,8 @@
namespace App\Model\Questionnaire;
use App\Enum\QuestionnaireFlag;
/**
* One version of a questionnaire as read from config/questionnaires/. Responses store its key and
* version, and their answers are keyed by Question::$key.
@@ -20,6 +22,26 @@ readonly class Definition
) {
}
/**
* The questionnaire as one subject gets it: without the sections marked `only_for` a flag the
* subject does not have. Key and version stay, so answers to it are answers to this definition.
*
* @param list<QuestionnaireFlag> $flags
*/
public function forFlags(array $flags): static
{
return new static(
$this->key,
$this->version,
$this->title,
$this->description,
array_values(array_filter(
$this->sections,
static fn (Section $section): bool => null === $section->onlyFor || in_array($section->onlyFor, $flags, true),
)),
);
}
/**
* @return array<string, Question> in the order they are asked, keyed by question key
*/
+5 -1
View File
@@ -2,15 +2,19 @@
namespace App\Model\Questionnaire;
use App\Enum\QuestionnaireFlag;
readonly class Section
{
/**
* @param list<Question> $questions
* @param list<Question> $questions
* @param QuestionnaireFlag|null $onlyFor part of the questionnaire only when the subject has this flag
*/
public function __construct(
public string $key,
public ?string $title,
public array $questions,
public ?QuestionnaireFlag $onlyFor = null,
) {
}
}
+4 -6
View File
@@ -77,10 +77,10 @@ class DispositionRepository extends ServiceEntityRepository
}
/**
* The teamer's placements whose questionnaire (config/questionnaires/teamer_escort.yaml) is
* The teamer's placements whose questionnaire (config/questionnaires/teamer_assignment.yaml) is
* still to be submitted: not started, or saved as a draft. The rules are those of
* DispositionVoter::QUESTIONNAIRE - bus escort, over (today included), on or after go-live,
* neither called off nor skip-formalities - so every row shown on the dashboard can be opened.
* DispositionVoter::QUESTIONNAIRE - over (today included), on or after go-live, neither called
* off nor skip-formalities - so every row shown on the dashboard can be opened.
*
* The response is joined as an arbitrary entity because the association is one-way; the
* questionnaire key has to be part of the JOIN condition, or a response to another
@@ -107,8 +107,6 @@ class DispositionRepository extends ServiceEntityRepository
$qb->expr()->neq('disposition.status', ':dispositionCalledOff'),
$qb->expr()->notIn('assignment.status', ':assignmentStatuses'),
$qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'),
$qb->expr()->isNotNull('assignment.pickup'),
$qb->expr()->neq('assignment.pickup', 0),
$qb->expr()->gte('destination.dateTo', ':goLive'),
$qb->expr()->lte('destination.dateTo', ':today'),
$qb->expr()->orX(
@@ -118,7 +116,7 @@ class DispositionRepository extends ServiceEntityRepository
))
->orderBy('destination.dateTo', \SortDirection::Descending)
->setParameter('teamer', $teamer)
->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ESCORT)
->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ASSIGNMENT)
->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF)
->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
->setParameter('skipFormalities', false)
+6 -5
View File
@@ -118,8 +118,10 @@ class DispositionVoter extends Voter
/**
* Whether the teamer may fill in, and later read, the questionnaire after this placement
* (config/questionnaires/teamer_escort.yaml). Only the teamer themself: the office reads the
* submitted responses through ROLE_ADMINISTRATIVE, not through this attribute.
* (config/questionnaires/teamer_assignment.yaml). Every placement gets it; one with a bus
* escort gets the bus sections as well, which is the form's business, not this voter's. Only
* the teamer themself: the office reads the submitted responses through ROLE_ADMINISTRATIVE,
* not through this attribute.
*
* "Over" is the destination's end date, not Disposition::STATUS_ENDED: the cron sets that a
* run late, and it moves on to checking_invoice and completed while the date stays true. An
@@ -137,12 +139,11 @@ class DispositionVoter extends Voter
}
if (true === $disposition->isSkipFormalities()
|| Disposition::STATUS_CALLED_OFF === $disposition->getStatus()
|| true !== $disposition->getAssignment()?->hasBusEscort()) {
|| Disposition::STATUS_CALLED_OFF === $disposition->getStatus()) {
return false;
}
$dateTo = $disposition->getAssignment()->getDestination()?->getDateTo();
$dateTo = $disposition->getAssignment()?->getDestination()?->getDateTo();
return null !== $dateTo
&& $dateTo >= $this->questionnaireGoLive
@@ -2,7 +2,9 @@
namespace App\Service\Questionnaire;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Enum\QuestionnaireFlag;
/**
* What a disposition already knows, snapshotted into a QuestionnaireResponse so the questionnaire
@@ -13,6 +15,17 @@ use App\Entity\Disposition;
*/
final class DispositionContext
{
/**
* Which `only_for` sections of the questionnaire apply to a placement on this assignment.
* Takes the assignment so the office's test mode can ask before anyone is placed.
*
* @return list<QuestionnaireFlag>
*/
public static function flags(Assignment $assignment): array
{
return $assignment->hasBusEscort() ? [QuestionnaireFlag::BUS_ESCORT] : [];
}
/**
* @return array{
* teamerName: ?string,
@@ -262,7 +262,7 @@
</a>
<a href="{{ path('app_administrative_questionnaire_test', { 'uuid': assignment.uuid, 'r': return_url() }) }}"
class="btn btn--secondary"
title="Den Fragebogen nach einer Busbegleitung für diesen Einsatz ausprobieren, ohne etwas zu speichern">
title="Den Fragebogen für diesen Einsatz ausprobieren, ohne etwas zu speichern">
Fragebogen testen
</a>
</div>
@@ -10,12 +10,20 @@
'type': 'warning',
'message': 'Testmodus: Der Fragebogen verhält sich wie für Teamer:innen, es wird aber nichts gespeichert.'
} %}
{% if not assignment.hasBusEscort %}
{% include '_partials/_alert.html.twig' with {
'type': 'info',
'message': 'Dieser Einsatz hat keine Busbegleitung. Teamer:innen bekommen den Fragebogen hier nicht.'
} %}
{% endif %}
{% set otherVariant = path('app_administrative_questionnaire_test', { 'uuid': assignment.uuid, 'bus_escort': busEscort ? 0 : 1, 'r': app.request.query.get('r') }) %}
{% embed '_partials/_alert.html.twig' with { 'type': 'info' } %}
{% block message %}
{% if busEscort %}
Mit den Fragen zur Busfahrt, wie bei einer Busbegleitung.
{% else %}
Ohne die Fragen zur Busfahrt, wie bei einem Einsatz ohne Busbegleitung.
{% endif %}
{% if busEscort != assignment.hasBusEscort %}
Dieser Einsatz hat {{ assignment.hasBusEscort ? 'eine' : 'keine' }} Busbegleitung.
{% endif %}
<a href="{{ otherVariant }}" class="underline">{{ busEscort ? 'Ohne' : 'Mit' }} Busfahrt testen</a>
{% endblock %}
{% endembed %}
<div class="grid lg:grid-cols-3 gap-8 items-start">
<div class="lg:col-span-2">
{% if submitted is not null %}
@@ -219,10 +219,10 @@
{% endif %}
{% endif %}
{# Questionnaire after a bus escort; the edit page forwards to the read-only view once submitted #}
{# Questionnaire after the placement; the edit page forwards to the read-only view once submitted #}
{% if is_granted('QUESTIONNAIRE', disposition) %}
<h3 class="text-xl font-bold pb-4">
Fragebogen zur Busbegleitung
Fragebogen zu deinem Einsatz
</h3>
<p class="pb-4">
Wie lief dein Einsatz? Dein Feedback hilft uns, besser zu werden.
+1 -1
View File
@@ -18,7 +18,7 @@
{% if pendingQuestionnaires is not empty %}
{% embed '_partials/_alert.html.twig' with { 'type': 'info' } %}
{% block message %}
<p>Bitte fülle den Fragebogen zu deiner Busbegleitung aus:</p>
<p>Bitte fülle den Fragebogen zu deinem Einsatz aus:</p>
<ul class="pt-1">
{% for row in pendingQuestionnaires %}
{% set destination = row.disposition.assignment.destination %}
+65 -6
View File
@@ -7,6 +7,7 @@ namespace App\Tests\Config;
use App\Config\QuestionnaireCatalog;
use App\Enum\ChoiceWidget;
use App\Enum\ConditionOperator;
use App\Enum\QuestionnaireFlag;
use App\Enum\QuestionType;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
@@ -50,18 +51,18 @@ class QuestionnaireCatalogTest extends TestCase
* 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
public function testTeamerAssignmentDoesNotAskWhatTheDispositionKnows(): void
{
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_escort');
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_assignment');
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
public function testTeamerAssignmentLowRatingReasonIsRequiredOnlyForLowRatings(): void
{
$question = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_escort')->getQuestion('office_briefing_low_reason');
$question = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_assignment')->getQuestion('office_briefing_low_reason');
$this->assertTrue($question->required);
$this->assertSame('office_briefing_rating', $question->showIf->question);
@@ -73,9 +74,9 @@ class QuestionnaireCatalogTest extends TestCase
* 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
public function testTeamerAssignmentEquipmentIsOneSingleChoicePerTopic(): void
{
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_escort');
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_assignment');
foreach (['video', 'wc', 'luggage', 'smoking', 'snow_chains'] as $key) {
$question = $definition->getQuestion($key);
@@ -84,6 +85,28 @@ class QuestionnaireCatalogTest extends TestCase
}
}
/**
* Every teamer gets the questionnaire; only a bus escort gets the bus part, and nobody gets a
* bus section without it.
*/
public function testTeamerAssignmentBusPartIsOnlyForBusEscorts(): void
{
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT);
foreach ($definition->sections as $section) {
$this->assertSame(
str_starts_with($section->key, 'bus_') ? QuestionnaireFlag::BUS_ESCORT : null,
$section->onlyFor,
$section->key,
);
}
$withoutBus = $definition->forFlags([]);
$this->assertNull($withoutBus->getQuestion('bus_company'));
$this->assertNotNull($withoutBus->getQuestion('office_briefing_rating'));
$this->assertNotNull($definition->forFlags([QuestionnaireFlag::BUS_ESCORT])->getQuestion('bus_company'));
}
public function testChoiceDefaultsToRadio(): void
{
$this->write('sample', $this->sample());
@@ -181,12 +204,41 @@ class QuestionnaireCatalogTest extends TestCase
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 'unknown only_for' => [
static fn (array $d): array => self::withSection($d, ['only_for' => 'ferry']),
'unknown only_for "ferry"',
];
yield 'key does not match the file name' => [
static fn (array $d): array => ['key' => 'other'] + $d,
'does not match the file name',
];
}
/**
* A question everyone gets cannot depend on a bus-only question: without the bus part it
* would never be shown.
*/
public function testQuestionCannotDependOnAQuestionOnlyAskedWithAFlag(): void
{
$definition = $this->sample();
$definition['sections'][0]['only_for'] = 'bus_escort';
$definition['sections'][] = [
'key' => 'later',
'questions' => [[
'key' => 'other_reason',
'label' => 'Warum?',
'type' => 'text',
'show_if' => ['question' => 'answer', 'equals' => 'yes'],
]],
];
$this->write('sample', $definition);
$this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('"answer" is only asked for bus_escort');
new QuestionnaireCatalog($this->dir)->get('sample');
}
public function testArchivedFileWithTheWrongVersionIsRejected(): void
{
$this->write('sample', ['version' => 3] + $this->sample());
@@ -238,6 +290,13 @@ class QuestionnaireCatalogTest extends TestCase
return $definition;
}
private static function withSection(array $definition, array $fields): array
{
$definition['sections'][0] = $fields + $definition['sections'][0];
return $definition;
}
private function write(string $name, array $definition): void
{
file_put_contents(sprintf('%s/%s.yaml', $this->dir, $name), Yaml::dump($definition, 10));
@@ -71,7 +71,9 @@ class TestControllerTest extends WebTestCase
$this->assertResponseIsSuccessful();
$this->assertStringContainsString('Testmodus', $crawler->text());
$this->assertStringContainsString('keine Busbegleitung', $crawler->text());
// the assignment has no escort, so the questionnaire comes without the bus part
$this->assertStringContainsString('Ohne die Fragen zur Busfahrt', $crawler->text());
$this->assertCount(0, $crawler->filter('[data-question-key="bus_company"]'));
$this->assertCount(1, $crawler->filter('form[data-controller="questionnaire"]'));
}
@@ -102,7 +104,7 @@ class TestControllerTest extends WebTestCase
{
$this->client->loginUser($this->user('ROLE_TEAM_ADMIN'));
$form = $this->open()->selectButton('Absenden')->form();
$definition = self::getContainer()->get(QuestionnaireCatalog::class)->get(QuestionnaireCatalog::TEAMER_ESCORT);
$definition = self::getContainer()->get(QuestionnaireCatalog::class)->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT)->forFlags([]);
foreach ($definition->sections as $section) {
foreach ($section->questions as $question) {
@@ -121,10 +123,19 @@ class TestControllerTest extends WebTestCase
$crawler = $this->client->submit($form);
$this->assertStringContainsString('So würde der abgeschickte Fragebogen gespeichert', $crawler->text());
$this->assertStringContainsString('Boonk Reisen', $crawler->text());
$this->assertStringContainsString('Sehr gut – Probleme wurden schnell', $crawler->text());
$this->assertNothingSaved();
}
public function testTheBusPartCanBeTriedOnAnAssignmentWithoutEscort(): void
{
$this->client->loginUser($this->user('ROLE_TEAM_ADMIN'));
$crawler = $this->client->request('GET', sprintf('/administrative/questionnaire/test/%s?bus_escort=1', $this->assignment->getUuid()));
$this->assertStringContainsString('Mit den Fragen zur Busfahrt', $crawler->text());
$this->assertCount(1, $crawler->filter('[data-question-key="bus_company"]'));
}
/**
* The app answers a teamer in the office area with a redirect to their dashboard, not a 403.
*/
+27 -11
View File
@@ -17,7 +17,7 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
/**
* Runs against the shipped teamer_escort definition, so a definition that parses but cannot be
* Runs against the shipped teamer_assignment definition, so a definition that parses but cannot be
* turned into a working form fails here.
*
* Every submit goes through Form::submit() with clearMissing, as a browser request does: an
@@ -28,7 +28,7 @@ class QuestionnaireTypeTest extends KernelTestCase
{
public function testEmptyDraftIsValid(): void
{
$form = $this->createForm($this->teamerEscort());
$form = $this->createForm($this->teamerAssignment());
$form->submit([QuestionnaireType::DRAFT => '']);
$this->assertTrue($form->isValid());
@@ -40,7 +40,7 @@ class QuestionnaireTypeTest extends KernelTestCase
*/
public function testFormSentWithoutAButtonIsADraft(): void
{
$form = $this->createForm($this->teamerEscort());
$form = $this->createForm($this->teamerAssignment());
$form->submit([]);
$this->assertTrue($form->isValid());
@@ -49,7 +49,7 @@ class QuestionnaireTypeTest extends KernelTestCase
public function testCompleteSubmitIsValidAndFlat(): void
{
$definition = $this->teamerEscort();
$definition = $this->teamerAssignment();
$form = $this->createForm($definition);
$form->submit($this->complete($definition) + [QuestionnaireType::SUBMIT => '']);
@@ -65,7 +65,7 @@ class QuestionnaireTypeTest extends KernelTestCase
public function testEmptySubmitDemandsTheVisibleRequiredQuestionsOnly(): void
{
$definition = $this->teamerEscort();
$definition = $this->teamerAssignment();
$form = $this->createForm($definition);
$form->submit([QuestionnaireType::SUBMIT => '']);
@@ -79,9 +79,25 @@ class QuestionnaireTypeTest extends KernelTestCase
$this->assertCount(0, $this->field($form, 'communication', 'support_wished')->getErrors());
}
/**
* A placement without bus escort gets the questionnaire without the bus sections, and can
* submit it without them.
*/
public function testWithoutBusEscortTheBusPartIsNeitherShownNorDemanded(): void
{
$definition = $this->teamerAssignment()->forFlags([]);
$form = $this->createForm($definition);
$this->assertFalse($form->has('bus_trip'));
$form->submit($this->complete($definition) + [QuestionnaireType::SUBMIT => '']);
$this->assertTrue($form->isValid(), (string) $form->getErrors(true));
}
public function testLowRatingDemandsAReason(): void
{
$definition = $this->teamerEscort();
$definition = $this->teamerAssignment();
$data = $this->complete($definition);
$data['organisation']['office_briefing_rating'] = '3';
@@ -95,7 +111,7 @@ class QuestionnaireTypeTest extends KernelTestCase
public function testOtherBusCompanyDemandsItsName(): void
{
$definition = $this->teamerEscort();
$definition = $this->teamerAssignment();
$data = $this->complete($definition);
$data['bus_trip']['bus_company'] = 'other';
@@ -111,7 +127,7 @@ class QuestionnaireTypeTest extends KernelTestCase
*/
public function testNoFieldIsHtmlRequired(): void
{
$view = $this->createForm($this->teamerEscort())->createView();
$view = $this->createForm($this->teamerAssignment())->createView();
foreach ($view as $section) {
foreach ($section as $field) {
@@ -122,7 +138,7 @@ class QuestionnaireTypeTest extends KernelTestCase
public function testConditionalQuestionCarriesItsConditionForTheBrowser(): void
{
$view = $this->createForm($this->teamerEscort())->createView();
$view = $this->createForm($this->teamerAssignment())->createView();
$row = $this->view($view, 'organisation', 'office_briefing_low_reason');
$this->assertSame(
@@ -159,9 +175,9 @@ class QuestionnaireTypeTest extends KernelTestCase
$this->assertSame('2026-01-10', $form->getData()['travel_date']);
}
private function teamerEscort(): Definition
private function teamerAssignment(): Definition
{
return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get('teamer_escort');
return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get('teamer_assignment');
}
/**
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Model\Questionnaire;
use App\Enum\ConditionOperator;
use App\Enum\QuestionnaireFlag;
use App\Enum\QuestionType;
use App\Model\Questionnaire\Condition;
use App\Model\Questionnaire\Definition;
@@ -44,6 +45,19 @@ class DefinitionTest extends TestCase
$this->assertFalse($definition->isVisible($details, ['damage' => 'no', 'claim' => 'expected']));
}
public function testForFlagsDropsTheSectionsOfFlagsTheSubjectLacks(): void
{
$definition = new Definition('sample', 2, 'Beispiel', null, [
new Section('everyone', null, [new Question('rating', 'Wie war es?', QuestionType::RATING)]),
new Section('bus', null, [new Question('bus', 'Wie war der Bus?', QuestionType::RATING)], QuestionnaireFlag::BUS_ESCORT),
]);
$this->assertSame(['rating'], array_keys($definition->forFlags([])->getQuestions()));
$this->assertSame(['rating', 'bus'], array_keys($definition->forFlags([QuestionnaireFlag::BUS_ESCORT])->getQuestions()));
// still the same questionnaire, so answers to it are answers to this definition
$this->assertSame(2, $definition->forFlags([])->version);
}
public function testWithoutHiddenAnswersDropsHiddenEmptyAndUnknownAnswers(): void
{
$answers = [
@@ -83,6 +83,14 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase
$this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending());
}
public function testAPlacementWithoutBusEscortIsPendingToo(): void
{
$disposition = $this->escort();
$disposition->getAssignment()->setPickup(0);
$this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending());
}
public function testAnAssignmentEndingTodayIsPending(): void
{
$this->escort(dateTo: 'today');
@@ -108,7 +116,6 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase
public static function notPending(): array
{
return [
'no bus escort' => [static fn (Disposition $d) => $d->getAssignment()->setPickup(0)],
'not over yet' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('tomorrow'))],
'ended before go-live' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('2025-12-31'))],
'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)],
@@ -50,7 +50,7 @@ trait QuestionnaireFixturesTrait
return $disposition;
}
private function respond(Disposition $disposition, bool $submit, string $questionnaire = QuestionnaireCatalog::TEAMER_ESCORT): QuestionnaireResponse
private function respond(Disposition $disposition, bool $submit, string $questionnaire = QuestionnaireCatalog::TEAMER_ASSIGNMENT): QuestionnaireResponse
{
$definition = new Definition($questionnaire, 1, 'Beispiel', null, []);
$response = QuestionnaireResponse::forDisposition($definition, $disposition);
@@ -94,6 +94,6 @@ class QuestionnaireResponseRepositoryTest extends KernelTestCase
{
$this->entityManager->flush();
return $this->repository->getListQuery(QuestionnaireCatalog::TEAMER_ESCORT, $filter)->getResult();
return $this->repository->getListQuery(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $filter)->getResult();
}
}
+11 -1
View File
@@ -179,6 +179,17 @@ class DispositionVoterTest extends TestCase
$this->assertQuestionnaire(true, $this->escortDisposition());
}
/**
* Every placement gets the questionnaire; the escort only adds the bus sections.
*/
public function testTheTeamerMayFillInTheQuestionnaireWithoutAnEscortToo(): void
{
$disposition = $this->escortDisposition();
$disposition->getAssignment()->setPickup(0);
$this->assertQuestionnaire(true, $disposition);
}
/**
* Ending today already counts as over, as in DispositionRepository's feedback queries.
*/
@@ -205,7 +216,6 @@ class DispositionVoterTest extends TestCase
public static function questionnaireDenials(): array
{
return [
'no bus escort' => [static fn (Disposition $d) => $d->getAssignment()->setPickup(0)],
'assignment not over yet' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('tomorrow'))],
'ended before go-live' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('2025-12-31'))],
'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)],
@@ -10,6 +10,7 @@ use App\Entity\Destination;
use App\Entity\Disposition;
use App\Entity\JobProfile;
use App\Entity\Teamer;
use App\Enum\QuestionnaireFlag;
use App\Service\Questionnaire\DispositionContext;
use PHPUnit\Framework\TestCase;
@@ -42,6 +43,12 @@ class DispositionContextTest extends TestCase
$this->assertNull($context['escortPickupDate']);
}
public function testOnlyABusEscortGetsTheBusSections(): void
{
$this->assertSame([QuestionnaireFlag::BUS_ESCORT], DispositionContext::flags($this->disposition(pickup: 4711)->getAssignment()));
$this->assertSame([], DispositionContext::flags($this->disposition(pickup: 0)->getAssignment()));
}
private function disposition(int $pickup): Disposition
{
$destination = (new Destination())
+1 -1
View File
@@ -96,6 +96,6 @@ class QuestionnaireTemplateTest extends KernelTestCase
private function definition(): Definition
{
return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get(QuestionnaireCatalog::TEAMER_ESCORT);
return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT);
}
}