feat: build the questionnaire form from its definition

This commit is contained in:
fromme committed 2026-10-27 16:47:38 +01:00
1 parent d06b647dac
commit 8070fda64d
4 files changed
+435

No files matched your search

+220
View File
@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form;
use App\Config\QuestionnaireCatalog;
use App\Enum\QuestionType;
use App\Form\QuestionnaireType;
use App\Model\Questionnaire\Definition;
use App\Model\Questionnaire\Option;
use App\Model\Questionnaire\Question;
use App\Model\Questionnaire\Section;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Form\FormFactoryInterface;
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
* turned into a working form fails here.
*
* Every submit goes through Form::submit() with clearMissing, as a browser request does: an
* unchecked radio group is simply absent from the request, and without clearMissing it would keep
* its old value and conditional validation would appear to pass.
*/
class QuestionnaireTypeTest extends KernelTestCase
{
public function testEmptyDraftIsValid(): void
{
$form = $this->createForm($this->teamerEscort());
$form->submit([QuestionnaireType::DRAFT => '']);
$this->assertTrue($form->isValid());
$this->assertFalse(QuestionnaireType::isFinal($form));
}
/**
* Enter in a text field sends the form without any button; that must not submit it for good.
*/
public function testFormSentWithoutAButtonIsADraft(): void
{
$form = $this->createForm($this->teamerEscort());
$form->submit([]);
$this->assertTrue($form->isValid());
$this->assertFalse(QuestionnaireType::isFinal($form));
}
public function testCompleteSubmitIsValidAndFlat(): void
{
$definition = $this->teamerEscort();
$form = $this->createForm($definition);
$form->submit($this->complete($definition) + [QuestionnaireType::SUBMIT => '']);
$this->assertTrue($form->isValid(), (string) $form->getErrors(true));
$this->assertTrue(QuestionnaireType::isFinal($form));
$answers = $form->getData();
// keyed by question, not by section, and a rating comes back as an int
$this->assertSame(5, $answers['office_briefing_rating']);
$this->assertSame('very_helpful', $answers['team_week_seminar']);
$this->assertArrayNotHasKey('organisation', $answers);
}
public function testEmptySubmitDemandsTheVisibleRequiredQuestionsOnly(): void
{
$definition = $this->teamerEscort();
$form = $this->createForm($definition);
$form->submit([QuestionnaireType::SUBMIT => '']);
$this->assertFalse($form->isValid());
$this->assertCount(1, $this->field($form, 'organisation', 'office_briefing_rating')->getErrors());
$this->assertCount(1, $this->field($form, 'bus_equipment', 'wc')->getErrors());
// required, but shown only after a rating of 3 or less, or for another bus company
$this->assertCount(0, $this->field($form, 'organisation', 'office_briefing_low_reason')->getErrors());
$this->assertCount(0, $this->field($form, 'bus_trip', 'bus_company_other')->getErrors());
// optional
$this->assertCount(0, $this->field($form, 'communication', 'support_wished')->getErrors());
}
public function testLowRatingDemandsAReason(): void
{
$definition = $this->teamerEscort();
$data = $this->complete($definition);
$data['organisation']['office_briefing_rating'] = '3';
$form = $this->createForm($definition);
$form->submit($data + [QuestionnaireType::SUBMIT => '']);
$this->assertFalse($form->isValid());
$this->assertCount(1, $this->field($form, 'organisation', 'office_briefing_low_reason')->getErrors());
$this->assertCount(1, $form->getErrors(true));
}
public function testOtherBusCompanyDemandsItsName(): void
{
$definition = $this->teamerEscort();
$data = $this->complete($definition);
$data['bus_trip']['bus_company'] = 'other';
$form = $this->createForm($definition);
$form->submit($data + [QuestionnaireType::SUBMIT => '']);
$this->assertCount(1, $this->field($form, 'bus_trip', 'bus_company_other')->getErrors());
}
/**
* A draft must be sendable from the browser at any stage, so nothing may carry the HTML
* required attribute.
*/
public function testNoFieldIsHtmlRequired(): void
{
$view = $this->createForm($this->teamerEscort())->createView();
foreach ($view as $section) {
foreach ($section as $field) {
$this->assertFalse($field->vars['required'], $field->vars['full_name']);
}
}
}
public function testConditionalQuestionCarriesItsConditionForTheBrowser(): void
{
$view = $this->createForm($this->teamerEscort())->createView();
$row = $this->view($view, 'organisation', 'office_briefing_low_reason');
$this->assertSame(
['question' => 'office_briefing_rating', 'operator' => 'lte', 'value' => 3],
json_decode($row->vars['row_attr']['data-show-if'], true),
);
$this->assertArrayNotHasKey('data-show-if', $this->view($view, 'organisation', 'office_briefing_rating')->vars['row_attr']);
}
public function testMultiAndDateAnswersRoundTrip(): void
{
$definition = new Definition('sample', 1, 'Beispiel', null, [
new Section('main', null, [
new Question('extras', 'Extras', QuestionType::MULTI, options: [
new Option('trailer', 'Anhänger'),
new Option('ski_box', 'Skikoffer'),
]),
new Question('travel_date', 'Reisedatum', QuestionType::DATE),
]),
]);
$form = $this->createForm($definition, ['extras' => ['ski_box'], 'travel_date' => '2026-01-10']);
$view = $form->createView();
$this->assertTrue($this->view($view, 'main', 'extras')->children[1]->vars['checked']);
$form = $this->createForm($definition);
$form->submit([
'main' => ['extras' => ['trailer', 'ski_box'], 'travel_date' => $this->view($view, 'main', 'travel_date')->vars['value']],
QuestionnaireType::SUBMIT => '',
]);
$this->assertTrue($form->isValid(), (string) $form->getErrors(true));
$this->assertSame(['trailer', 'ski_box'], $form->getData()['extras']);
$this->assertSame('2026-01-10', $form->getData()['travel_date']);
}
private function teamerEscort(): Definition
{
return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get('teamer_escort');
}
/**
* A submission answering every question that is shown when each choice takes its first option
* and each rating is 5.
*
* @return array<string, array<string, mixed>>
*/
private function complete(Definition $definition): array
{
$data = [];
$answers = [];
foreach ($definition->sections as $section) {
foreach ($section->questions as $question) {
$answer = match ($question->type) {
QuestionType::CHOICE => $question->options[0]->key,
QuestionType::MULTI => [$question->options[0]->key],
QuestionType::RATING => 5,
QuestionType::DATE => '01.01.2026',
QuestionType::TEXT, QuestionType::TEXTAREA => 'Antwort',
};
$answers[$question->key] = $answer;
if ($definition->isVisible($question, $answers)) {
$data[$section->key][$question->key] = is_int($answer) ? (string) $answer : $answer;
}
}
}
return $data;
}
private function createForm(Definition $definition, array $answers = []): FormInterface
{
self::bootKernel();
/** @var FormFactoryInterface $formFactory */
$formFactory = self::getContainer()->get(FormFactoryInterface::class);
return $formFactory->create(QuestionnaireType::class, $answers, [
'definition' => $definition,
'csrf_protection' => false,
]);
}
private function field(FormInterface $form, string $section, string $question): FormInterface
{
return $form->get($section)->get($question);
}
private function view(FormView $view, string $section, string $question): FormView
{
return $view->children[$section]->children[$question];
}
}