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

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