[Console] Allow passing Validator constraints to QuestionHelper and #[Ask]

This commit is contained in:
Robin Chalas
2026-02-11 20:15:28 +01:00
parent 08e6d2a6e7
commit 2b468472ec
18 changed files with 399 additions and 146 deletions

View File

@@ -112,6 +112,11 @@ class Application implements ResetInterface
$this->dispatcher = $dispatcher;
}
public function getDispatcher(): ?EventDispatcherInterface
{
return $this->dispatcher;
}
/**
* @final
*/

View File

@@ -20,6 +20,7 @@ use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\FileQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
class Ask implements InteractiveAttributeInterface
@@ -49,6 +50,8 @@ class Ask implements InteractiveAttributeInterface
?callable $normalizer = null,
?callable $validator = null,
public ?int $maxAttempts = null,
/** @var Constraint[] */
public array $constraints = [],
) {
$this->normalizer = $normalizer ? $normalizer(...) : null;
$this->validator = $validator ? $validator(...) : null;
@@ -86,6 +89,7 @@ class Ask implements InteractiveAttributeInterface
$question = new FileQuestion($self->question);
$question->setValidator($self->validator);
$question->setMaxAttempts($self->maxAttempts);
$question->setConstraints($self->constraints);
$value = $io->askQuestion($question);
if (null === $value && !$reflection->isNullable()) {
@@ -123,6 +127,7 @@ class Ask implements InteractiveAttributeInterface
$question->setValidator($self->validator);
$question->setMaxAttempts($self->maxAttempts);
$question->setConstraints($self->constraints);
if ($self->normalizer) {
$question->setNormalizer($self->normalizer);

View File

@@ -5,8 +5,9 @@ CHANGELOG
---
* [BC BREAK] Add `object` support to input options and arguments' default by changing the `$default` type to `mixed` in `InputArgument`, `InputOption`, `#[Argument]` and `#[Option]`
* Support pasting images with `#[Ask]` on `InputFile` types, supporting Kitty Graphics and iTerm2 protocols
* Add support for pasting images with `#[Ask]` on `InputFile` types, supporting Kitty Graphics and iTerm2 protocols
* Add `FileQuestion`, `InputFile`, `InputFileValueResolver`, and `SymfonyStyle::askFile()` for file input handling
* Add `Question::setConstraints()` and `ValidateQuestionInputListener` to validate question input using Validator constraints
* Add `#[AskChoice]` attribute for interactive choice questions in invokable commands
* Add support for method-based commands with `#[AsCommand]` attribute
* Add argument resolver support

View File

@@ -142,7 +142,7 @@ class InvokableCommand implements SignalableCommandInterface
$argument = match ($type->getName()) {
InputInterface::class => $input,
OutputInterface::class => $output,
SymfonyStyle::class => new SymfonyStyle($input, $output),
SymfonyStyle::class => new SymfonyStyle($input, $output, $this->command->getApplication()?->getDispatcher()),
Cursor::class => new Cursor($output),
Application::class => $this->command->getApplication(),
Command::class, self::class => $this->command,

View File

@@ -15,6 +15,7 @@ use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\Console\Event\ConsoleErrorEvent;
use Symfony\Component\Console\Event\ConsoleSignalEvent;
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
use Symfony\Component\Console\Event\QuestionAnsweredEvent;
/**
* Contains all events dispatched by an Application.
@@ -58,6 +59,14 @@ final class ConsoleEvents
*/
public const ERROR = 'console.error';
/**
* The QUESTION_ANSWERED event allows you to validate user input
* using Symfony Validator constraints.
*
* @Event("Symfony\Component\Console\Event\QuestionAnsweredEvent")
*/
public const QUESTION_ANSWERED = 'console.question_answered';
/**
* Event aliases.
*
@@ -68,5 +77,6 @@ final class ConsoleEvents
ConsoleErrorEvent::class => self::ERROR,
ConsoleSignalEvent::class => self::SIGNAL,
ConsoleTerminateEvent::class => self::TERMINATE,
QuestionAnsweredEvent::class => self::QUESTION_ANSWERED,
];
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Event;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Event dispatched when constraint validation is needed for a question.
*
* @author Robin Chalas <robin.chalas@gmail.com>
*/
class QuestionAnsweredEvent extends Event
{
private array $violations = [];
public function __construct(
public readonly mixed $value,
public readonly array $constraints,
) {
}
public function addViolation(string $message): void
{
$this->violations[] = $message;
}
public function getViolations(): array
{
return $this->violations;
}
public function hasViolations(): bool
{
return (bool) $this->violations;
}
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\EventListener;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\QuestionAnsweredEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Validates Question answers (user input) using the Validator component.
*
* @author Robin Chalas <robin.chalas@gmail.com>
*/
final class ValidateQuestionInputListener implements EventSubscriberInterface
{
public function __construct(
private readonly ValidatorInterface $validator,
) {
}
public function onQuestionAnswered(QuestionAnsweredEvent $event): void
{
$violations = $this->validator->validate($event->value, $event->constraints);
foreach ($violations as $violation) {
$event->addViolation($violation->getMessage());
}
}
public static function getSubscribedEvents(): array
{
return [ConsoleEvents::QUESTION_ANSWERED => 'onQuestionAnswered'];
}
}

View File

@@ -57,7 +57,7 @@ final class FileInputHelper
$file = $this->readWithPasteDetection($inputStream, $output, $question, $inputHelper);
} elseif ($question->isPathAllowed()) {
$file = $this->readPathInput($inputStream, $output, $question);
$file = $this->readPathInput($inputStream);
} else {
throw new MissingInputException('Terminal does not support image paste and path input is disabled.');
}
@@ -68,7 +68,10 @@ final class FileInputHelper
}
}
$this->validateFile($file, $question);
if (!$file->isValid()) {
throw new InvalidFileException(\sprintf('File "%s" is not valid or readable.', $file->getPathname()));
}
$this->displayFile($output, $file);
return $file;
@@ -153,7 +156,7 @@ final class FileInputHelper
/**
* @param resource $inputStream
*/
private function readPathInput($inputStream, OutputInterface $output, FileQuestion $question): InputFile
private function readPathInput($inputStream): InputFile
{
if (!$isBlocked = stream_get_meta_data($inputStream)['blocked'] ?? true) {
stream_set_blocking($inputStream, true);
@@ -176,27 +179,6 @@ final class FileInputHelper
return InputFile::fromPath($path);
}
private function validateFile(InputFile $file, FileQuestion $question): void
{
if (!$file->isValid()) {
throw new InvalidFileException(\sprintf('File "%s" is not valid or readable.', $file->getPathname()));
}
if (null !== $question->getMaxFileSize() && $file->getSize() > $question->getMaxFileSize()) {
throw new InvalidFileException(\sprintf('File "%s" is too large (%s). Maximum allowed size is %s.', $file->getFilename(), $file->getHumanReadableSize(), $this->formatBytes($question->getMaxFileSize())));
}
if (!$question->getAllowedMimeTypes()) {
return;
}
$mimeType = $file->getMimeType();
if (null === $mimeType || !$question->isMimeTypeAllowed($mimeType)) {
throw new InvalidFileException(\sprintf('File "%s" has MIME type "%s" which is not allowed. Allowed types: %s.', $file->getFilename(), $mimeType ?? 'unknown', implode(', ', $question->getAllowedMimeTypes())));
}
}
private function detectProtocol(): ?ImageProtocolInterface
{
if (Terminal::supportsKittyGraphics()) {
@@ -232,13 +214,4 @@ final class FileInputHelper
$output->write($protocol->encode($contents, 16));
$output->writeln('');
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$power = $bytes > 0 ? floor(log($bytes, 1024)) : 0;
$power = min($power, \count($units) - 1);
return \sprintf('%.1f %s', $bytes / (1024 ** $power), $units[$power]);
}
}

View File

@@ -11,7 +11,10 @@
namespace Symfony\Component\Console\Helper;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Cursor;
use Symfony\Component\Console\Event\QuestionAnsweredEvent;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\MissingInputException;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Formatter\OutputFormatter;
@@ -25,6 +28,8 @@ use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\FileQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Terminal;
use Symfony\Component\Validator\Validation;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use function Symfony\Component\String\s;
@@ -38,6 +43,11 @@ class QuestionHelper extends Helper
private static bool $stty = true;
private static bool $stdinIsInteractive;
public function __construct(
private ?EventDispatcherInterface $dispatcher = null,
) {
}
/**
* Asks a question to the user.
*
@@ -59,7 +69,7 @@ class QuestionHelper extends Helper
$inputStream ??= \STDIN;
try {
if (!$question->getValidator()) {
if (!$question->getValidator() && !$question->getConstraints()) {
return $this->doAsk($inputStream, $output, $question);
}
@@ -471,7 +481,17 @@ class QuestionHelper extends Helper
}
try {
return $question->getValidator()($interviewer());
$value = $interviewer();
if ($constraints = $question->getConstraints()) {
$this->validateConstraints($value, $constraints);
}
if ($validator = $question->getValidator()) {
return $validator($value);
}
return $value;
} catch (RuntimeException $e) {
throw $e;
} catch (\Exception $error) {
@@ -481,6 +501,27 @@ class QuestionHelper extends Helper
throw $error;
}
private function validateConstraints(mixed $value, array $constraints): void
{
if ($this->dispatcher) {
$event = new QuestionAnsweredEvent($value, $constraints);
$this->dispatcher->dispatch($event, ConsoleEvents::QUESTION_ANSWERED);
if ($event->hasViolations()) {
throw new InvalidArgumentException($event->getViolations()[0]);
}
return;
}
$validator = Validation::createValidator();
$violations = $validator->validate($value, $constraints);
if (\count($violations) > 0) {
throw new InvalidArgumentException($violations[0]->getMessage());
}
}
private function isInteractiveInput($inputStream): bool
{
if ('php://stdin' !== (stream_get_meta_data($inputStream)['uri'] ?? null)) {

View File

@@ -20,18 +20,10 @@ use Symfony\Component\Console\Exception\InvalidArgumentException;
*/
class FileQuestion extends Question
{
/** @var string[] */
private array $allowedMimeTypes;
private ?int $maxFileSize;
private bool $allowPaste;
private bool $allowPath;
public function __construct(
string $question,
array $allowedMimeTypes = [],
?int $maxFileSize = 5 * 1024 * 1024,
bool $allowPaste = true,
bool $allowPath = true,
private bool $allowPaste = true,
private bool $allowPath = true,
) {
parent::__construct($question);
@@ -39,27 +31,9 @@ class FileQuestion extends Question
throw new InvalidArgumentException('At least one of allowPaste or allowPath must be true.');
}
$this->allowedMimeTypes = $allowedMimeTypes;
$this->maxFileSize = $maxFileSize;
$this->allowPaste = $allowPaste;
$this->allowPath = $allowPath;
$this->setTrimmable(false);
}
/**
* @return string[]
*/
public function getAllowedMimeTypes(): array
{
return $this->allowedMimeTypes;
}
public function getMaxFileSize(): ?int
{
return $this->maxFileSize;
}
public function isPasteAllowed(): bool
{
return $this->allowPaste;
@@ -69,26 +43,4 @@ class FileQuestion extends Question
{
return $this->allowPath;
}
public function isMimeTypeAllowed(string $mimeType): bool
{
if (!$this->allowedMimeTypes) {
return true;
}
foreach ($this->allowedMimeTypes as $allowedType) {
if ($mimeType === $allowedType) {
return true;
}
if (str_ends_with($allowedType, '/*')) {
$prefix = substr($allowedType, 0, -1);
if (str_starts_with($mimeType, $prefix)) {
return true;
}
}
}
return false;
}
}

View File

@@ -13,6 +13,7 @@ namespace Symfony\Component\Console\Question;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Validator\Constraint;
/**
* Represents a Question.
@@ -39,6 +40,10 @@ class Question
private bool $trimmable = true;
private bool $multiline = false;
private ?int $timeout = null;
/**
* @var Constraint[]
*/
private array $constraints = [];
/**
* @param string $question The question to ask to the user
@@ -316,4 +321,24 @@ class Question
return $this;
}
/**
* @param Constraint[] $constraints
*
* @return $this
*/
public function setConstraints(array $constraints): static
{
$this->constraints = $constraints;
return $this;
}
/**
* @return Constraint[]
*/
public function getConstraints(): array
{
return $this->constraints;
}
}

View File

@@ -35,6 +35,7 @@ use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\FileQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Terminal;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Output decorator helpers for the Symfony Style Guide.
@@ -53,6 +54,7 @@ class SymfonyStyle extends OutputStyle
public function __construct(
private InputInterface $input,
private OutputInterface $output,
private ?EventDispatcherInterface $dispatcher = null,
) {
$this->bufferedOutput = new TrimmedBufferOutput(\DIRECTORY_SEPARATOR === '\\' ? 4 : 2, $output->getVerbosity(), false, clone $output->getFormatter());
// Windows cmd wraps lines as soon as the terminal width is reached, whether there are following chars or not.
@@ -249,9 +251,9 @@ class SymfonyStyle extends OutputStyle
return $this->askQuestion($questionChoice);
}
public function askFile(string $question, array $allowedMimeTypes = []): ?InputFile
public function askFile(string $question): ?InputFile
{
return $this->askQuestion(new FileQuestion($question, $allowedMimeTypes));
return $this->askQuestion(new FileQuestion($question));
}
public function progressStart(int $max = 0): void
@@ -309,7 +311,7 @@ class SymfonyStyle extends OutputStyle
$this->autoPrependBlock();
}
$this->questionHelper ??= new SymfonyQuestionHelper();
$this->questionHelper ??= new SymfonyQuestionHelper($this->dispatcher);
$answer = $this->questionHelper->ask($this->input, $this, $question);

View File

@@ -35,6 +35,8 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Console\Tests\Fixtures\InvokableTestCommand;
use Symfony\Component\Console\Tests\Fixtures\InvokableWithCustomValidatorTestCommand;
use Symfony\Component\Console\Tests\Fixtures\InvokableWithInputFileAndConstraintsTestCommand;
class InvokableCommandTest extends TestCase
{
@@ -555,6 +557,42 @@ class InvokableCommandTest extends TestCase
{
return ['ROLE_ADMIN', 'ROLE_USER'];
}
public function testAskWithInputFileAndConstraints()
{
if (!\extension_loaded('fileinfo')) {
$this->markTestSkipped('The "fileinfo" extension is required for this test.');
}
$pngData = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
$tempFile = sys_get_temp_dir().'/test_image_'.uniqid().'.png';
file_put_contents($tempFile, $pngData);
try {
$tester = new CommandTester(new InvokableWithInputFileAndConstraintsTestCommand());
$tester->setInputs([$tempFile]);
$tester->execute([], ['interactive' => true]);
$tester->assertCommandIsSuccessful();
self::assertStringContainsString('Provide an image file:', $tester->getDisplay());
self::assertStringContainsString('Filename:', $tester->getDisplay());
self::assertStringContainsString('Valid: yes', $tester->getDisplay());
} finally {
@unlink($tempFile);
}
}
public function testAskWithCustomValidatorIsNotOverwritten()
{
$tester = new CommandTester(new InvokableWithCustomValidatorTestCommand());
$tester->setInputs(['invalid', 'valid']);
$tester->execute([], ['interactive' => true]);
$tester->assertCommandIsSuccessful();
self::assertStringContainsString('Enter a value:', $tester->getDisplay());
self::assertStringContainsString('Value must be "valid"', $tester->getDisplay());
self::assertStringContainsString('Value: valid', $tester->getDisplay());
}
}
enum StringEnum: string

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Tests\Fixtures;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:custom-validator')]
class InvokableWithCustomValidatorTestCommand
{
public function __invoke(
OutputInterface $output,
#[Argument]
#[Ask('Enter a value:', validator: [self::class, 'validate'])]
string $value,
): int {
$output->writeln('Value: '.$value);
return Command::SUCCESS;
}
public static function validate(string $value): string
{
if ('valid' !== $value) {
throw new \RuntimeException('Value must be "valid"');
}
return $value;
}
}

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Tests\Fixtures;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\File\InputFile;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Validator\Constraints\File;
#[AsCommand(name: 'app:input-file-with-constraints')]
class InvokableWithInputFileAndConstraintsTestCommand
{
public function __invoke(
OutputInterface $output,
#[Argument]
#[Ask('Provide an image file:', constraints: [new File(mimeTypes: ['image/png', 'image/jpeg'])])]
InputFile $file,
): int {
$output->writeln('Filename: '.$file->getFilename());
$output->writeln('Valid: '.($file->isValid() ? 'yes' : 'no'));
return Command::SUCCESS;
}
}

View File

@@ -29,8 +29,11 @@ use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Terminal;
use Symfony\Component\Console\Tester\ApplicationTester;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Process\Exception\ProcessSignaledException;
use Symfony\Component\Process\Process;
use Symfony\Component\Validator\Constraints\Url;
use Symfony\Component\Validator\Validation;
#[Group('tty')]
class QuestionHelperTest extends AbstractQuestionHelperTestCase
@@ -1022,6 +1025,73 @@ class QuestionHelperTest extends AbstractQuestionHelperTestCase
return $mock;
}
public function testAskWithConstraintsWithoutDispatcherValidValue()
{
$dialog = new QuestionHelper();
$question = new Question('Enter a URL');
$question->setConstraints([new Url()]);
$inputStream = $this->getInputStream("https://symfony.com\n");
$this->assertEquals('https://symfony.com', $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question));
}
public function testAskWithConstraintsWithoutDispatcherInvalidThenValid()
{
$dialog = new QuestionHelper();
$question = new Question('Enter a URL');
$question->setConstraints([new Url()]);
$inputStream = $this->getInputStream("not-a-url\nhttps://symfony.com\n");
$output = $this->createOutputInterface();
$this->assertEquals('https://symfony.com', $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $output, $question));
rewind($output->getStream());
$outputContent = stream_get_contents($output->getStream());
$this->assertStringContainsString('This value is not a valid URL.', $outputContent);
}
public function testAskWithConstraintsWithoutDispatcherExceedsMaxAttempts()
{
$dialog = new QuestionHelper();
$question = new Question('Enter a URL');
$question->setConstraints([new Url()]);
$question->setMaxAttempts(1);
$inputStream = $this->getInputStream("not-a-url\n");
$this->expectException(\Exception::class);
$this->expectExceptionMessage('This value is not a valid URL.');
$dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question);
}
public function testAskWithConstraintsWithDispatcher()
{
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new \Symfony\Component\Console\EventListener\ValidateQuestionInputListener(
Validation::createValidator()
));
$dialog = new QuestionHelper($dispatcher);
$question = new Question('Enter a URL');
$question->setConstraints([new Url()]);
$inputStream = $this->getInputStream("not-a-url\nhttps://symfony.com\n");
$output = $this->createOutputInterface();
$this->assertEquals('https://symfony.com', $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $output, $question));
rewind($output->getStream());
$outputContent = stream_get_contents($output->getStream());
$this->assertStringContainsString('This value is not a valid URL.', $outputContent);
}
}
class AutocompleteValues implements \IteratorAggregate

View File

@@ -11,10 +11,10 @@
namespace Symfony\Component\Console\Tests\Question;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Question\FileQuestion;
use Symfony\Component\Validator\Constraints\File;
class FileQuestionTest extends TestCase
{
@@ -29,37 +29,21 @@ class FileQuestionTest extends TestCase
{
$question = new FileQuestion('Provide a file:');
$this->assertSame([], $question->getAllowedMimeTypes());
$this->assertSame(5 * 1024 * 1024, $question->getMaxFileSize());
$this->assertTrue($question->isPasteAllowed());
$this->assertTrue($question->isPathAllowed());
$this->assertFalse($question->isTrimmable());
}
public function testWithAllowedMimeTypes()
{
$question = new FileQuestion('Provide a file:', ['image/png', 'image/jpeg']);
$this->assertSame(['image/png', 'image/jpeg'], $question->getAllowedMimeTypes());
}
public function testWithMaxFileSize()
{
$question = new FileQuestion('Provide a file:', [], 1024 * 1024);
$this->assertSame(1024 * 1024, $question->getMaxFileSize());
}
public function testWithAllowPasteFalse()
{
$question = new FileQuestion('Provide a file:', [], null, false);
$question = new FileQuestion('Provide a file:', false);
$this->assertFalse($question->isPasteAllowed());
}
public function testWithAllowPathFalse()
{
$question = new FileQuestion('Provide a file:', [], null, true, false);
$question = new FileQuestion('Provide a file:', true, false);
$this->assertFalse($question->isPathAllowed());
}
@@ -69,43 +53,7 @@ class FileQuestionTest extends TestCase
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('At least one of allowPaste or allowPath must be true.');
new FileQuestion('Provide a file:', [], null, false, false);
}
#[DataProvider('provideMimeTypeMatches')]
public function testIsMimeTypeAllowed(array $allowedTypes, string $mimeType, bool $expected)
{
$question = new FileQuestion('Provide a file:', $allowedTypes);
$this->assertSame($expected, $question->isMimeTypeAllowed($mimeType));
}
public static function provideMimeTypeMatches(): iterable
{
// Empty allowed types allows all
yield 'empty allows all' => [[], 'image/png', true];
yield 'empty allows all - text' => [[], 'text/plain', true];
// Exact matches
yield 'exact match png' => [['image/png'], 'image/png', true];
yield 'exact match jpeg' => [['image/jpeg'], 'image/jpeg', true];
yield 'no match' => [['image/png'], 'image/jpeg', false];
// Multiple allowed types
yield 'multiple - first match' => [['image/png', 'image/jpeg'], 'image/png', true];
yield 'multiple - second match' => [['image/png', 'image/jpeg'], 'image/jpeg', true];
yield 'multiple - no match' => [['image/png', 'image/jpeg'], 'image/gif', false];
// Wildcard matches
yield 'wildcard image/*' => [['image/*'], 'image/png', true];
yield 'wildcard image/* - jpeg' => [['image/*'], 'image/jpeg', true];
yield 'wildcard image/* - gif' => [['image/*'], 'image/gif', true];
yield 'wildcard no match' => [['image/*'], 'application/pdf', false];
// Mixed exact and wildcard
yield 'mixed - exact match' => [['application/pdf', 'image/*'], 'application/pdf', true];
yield 'mixed - wildcard match' => [['application/pdf', 'image/*'], 'image/png', true];
yield 'mixed - no match' => [['application/pdf', 'image/*'], 'text/plain', false];
new FileQuestion('Provide a file:', false, false);
}
public function testIsNotTrimmableByDefault()
@@ -114,4 +62,18 @@ class FileQuestionTest extends TestCase
$this->assertFalse($question->isTrimmable());
}
public function testSupportsConstraints()
{
if (!class_exists(File::class)) {
$this->markTestSkipped('Validator component not available.');
}
$question = new FileQuestion('Provide a file:');
$constraint = new File(maxSize: '5M', mimeTypes: ['image/png']);
$question->setConstraints([$constraint]);
$this->assertSame([$constraint], $question->getConstraints());
}
}

View File

@@ -31,9 +31,11 @@
"symfony/http-kernel": "^7.4|^8.0",
"symfony/lock": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/mime": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0",
"symfony/uid": "^7.4|^8.0",
"symfony/validator": "^7.4|^8.0",
"symfony/var-dumper": "^7.4|^8.0"
},
"provide": {