diff --git a/actions/class.Runner.php b/actions/class.Runner.php index b29028c452..dcc1d2b7f3 100644 --- a/actions/class.Runner.php +++ b/actions/class.Runner.php @@ -47,6 +47,7 @@ use oat\taoQtiTest\model\Service\TimeoutService; use oat\taoQtiTest\model\Service\ToolsStateAwareInterface; use oat\taoQtiTest\models\cat\CatEngineNotFoundException; +use oat\taoQtiTest\models\classes\runner\QtiRunnerInvalidResponsesException; use oat\taoQtiTest\models\container\QtiTestDeliveryContainer; use oat\taoQtiTest\models\runner\communicator\CommunicationService; use oat\taoQtiTest\models\runner\communicator\QtiCommunicationService; @@ -274,6 +275,7 @@ protected function getStatusCodeFromException(Exception $exception): int case QtiRunnerEmptyResponsesException::class: case QtiRunnerClosedException::class: case QtiRunnerPausedException::class: + case QtiRunnerInvalidResponsesException::class: return 200; case common_exception_NotImplemented::class: diff --git a/composer.json b/composer.json index d372dfb62e..de8b58e894 100644 --- a/composer.json +++ b/composer.json @@ -62,7 +62,7 @@ "ext-zip": "*", "oat-sa/lib-test-cat": "2.4.0", "oat-sa/oatbox-extension-installer": "~1.1||dev-master", - "qtism/qtism": ">=0.28.3", + "qtism/qtism": ">=0.28.7", "oat-sa/generis": ">=16.0.0", "oat-sa/tao-core": ">=54.27.0", "oat-sa/extension-tao-item": ">=12.4.0", diff --git a/model/Container/TestQtiServiceProvider.php b/model/Container/TestQtiServiceProvider.php index f1a482c501..830a3d3c1b 100644 --- a/model/Container/TestQtiServiceProvider.php +++ b/model/Container/TestQtiServiceProvider.php @@ -34,6 +34,7 @@ use oat\taoQtiTest\model\Domain\Model\QtiTestRepositoryInterface; use oat\taoQtiTest\model\Domain\Model\ToolsStateRepositoryInterface; use oat\taoQtiTest\model\Infrastructure\QtiItemResponseRepository; +use oat\taoQtiTest\model\Infrastructure\QtiItemResponseValidator; use oat\taoQtiTest\model\Infrastructure\QtiToolsStateRepository; use oat\taoQtiTest\model\Infrastructure\QtiTestRepository; use oat\taoQtiTest\model\Service\ConcurringSessionService; @@ -41,6 +42,7 @@ use oat\taoQtiTest\model\Service\ListItemsService; use oat\taoQtiTest\model\Service\MoveService; use oat\taoQtiTest\model\Service\PauseService; +use oat\taoQtiTest\model\Service\PluginManagerService; use oat\taoQtiTest\model\Service\SkipService; use oat\taoQtiTest\model\Service\StoreTraceVariablesService; use oat\taoQtiTest\model\Service\TimeoutService; @@ -49,6 +51,7 @@ use oat\taoQtiTest\models\TestModelService; use oat\taoQtiTest\models\TestSessionService; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use common_ext_ExtensionsManager as ExtensionsManager; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; @@ -63,7 +66,9 @@ public function __invoke(ContainerConfigurator $configurator): void ->public() ->args( [ - service(QtiRunnerService::SERVICE_ID) + service(QtiRunnerService::SERVICE_ID), + service(FeatureFlagChecker::class), + service(QtiItemResponseValidator::class), ] ); @@ -176,5 +181,19 @@ public function __invoke(ContainerConfigurator $configurator): void service(TimerAdjustmentServiceInterface::SERVICE_ID), ] ); + + $services + ->set(QtiItemResponseValidator::class, QtiItemResponseValidator::class) + ->public(); + + $services + ->set(PluginManagerService::class, PluginManagerService::class) + ->args( + [ + service(Ontology::SERVICE_ID), + service(ExtensionsManager::SERVICE_ID), + ] + ) + ->public(); } } diff --git a/model/Infrastructure/QtiItemResponseRepository.php b/model/Infrastructure/QtiItemResponseRepository.php index da359db323..5f5d4abd2b 100644 --- a/model/Infrastructure/QtiItemResponseRepository.php +++ b/model/Infrastructure/QtiItemResponseRepository.php @@ -24,22 +24,32 @@ namespace oat\taoQtiTest\model\Infrastructure; +use oat\tao\model\featureFlag\FeatureFlagChecker; use oat\taoQtiTest\model\Domain\Model\ItemResponse; use oat\taoQtiTest\model\Domain\Model\ItemResponseRepositoryInterface; +use oat\taoQtiTest\models\classes\runner\QtiRunnerInvalidResponsesException; use oat\taoQtiTest\models\runner\QtiRunnerEmptyResponsesException; use oat\taoQtiTest\models\runner\QtiRunnerItemResponseException; use oat\taoQtiTest\models\runner\QtiRunnerService; use oat\taoQtiTest\models\runner\RunnerServiceContext; +use qtism\runtime\tests\AssessmentItemSessionException; use taoQtiTest_helpers_TestRunnerUtils as TestRunnerUtils; class QtiItemResponseRepository implements ItemResponseRepositoryInterface { /** @var QtiRunnerService */ private $runnerService; - - public function __construct(QtiRunnerService $runnerService) - { + private FeatureFlagChecker $featureFlagChecker; + private QtiItemResponseValidator $itemResponseValidator; + + public function __construct( + QtiRunnerService $runnerService, + FeatureFlagChecker $featureFlagChecker, + QtiItemResponseValidator $itemResponseValidator + ) { $this->runnerService = $runnerService; + $this->featureFlagChecker = $featureFlagChecker; + $this->itemResponseValidator = $itemResponseValidator; } public function save(ItemResponse $itemResponse, RunnerServiceContext $serviceContext): void @@ -110,6 +120,17 @@ private function saveItemResponses(ItemResponse $itemResponse, RunnerServiceCont $itemResponse->getResponse() ); + if ($this->featureFlagChecker->isEnabled('FEATURE_FLAG_RESPONSE_VALIDATOR')) { + try { + $this->itemResponseValidator->validate($serviceContext->getTestSession(), $responses); + } catch (AssessmentItemSessionException $e) { + throw new QtiRunnerInvalidResponsesException($e->getMessage()); + } + + $this->runnerService->storeItemResponse($serviceContext, $itemDefinition, $responses); + return; + } + if ( $this->runnerService->getTestConfig()->getConfigValue('enableAllowSkipping') && !TestRunnerUtils::doesAllowSkipping($serviceContext->getTestSession()) diff --git a/model/Infrastructure/QtiItemResponseValidator.php b/model/Infrastructure/QtiItemResponseValidator.php new file mode 100644 index 0000000000..3cfb136199 --- /dev/null +++ b/model/Infrastructure/QtiItemResponseValidator.php @@ -0,0 +1,72 @@ +getAllowSkip($testSession) && $responses->containsNullOnly()) { + return; + } + + if (!$this->getAllowSkip($testSession) && $responses->containsNullOnly()) { + throw new QtiRunnerEmptyResponsesException(); + } + + if ($this->getResponseValidation($testSession)) { + $testSession->getCurrentAssessmentItemSession() + ->checkResponseValidityConstraints($responses); + } + } + + private function getResponseValidation(AssessmentTestSession $testSession): bool + { + return $testSession->getRoute() + ->current() + ->getItemSessionControl() + ->getItemSessionControl() + ->mustValidateResponses(); + } + + private function getAllowSkip(AssessmentTestSession $testSession): bool + { + return $testSession->getRoute() + ->current() + ->getItemSessionControl() + ->getItemSessionControl() + ->doesAllowSkipping(); + } +} diff --git a/model/Service/PluginManagerService.php b/model/Service/PluginManagerService.php new file mode 100644 index 0000000000..555f4d0710 --- /dev/null +++ b/model/Service/PluginManagerService.php @@ -0,0 +1,92 @@ + 'enable-allow-skipping', + 'validateResponses' => 'enable-validate-responses', + ]; + private Ontology $ontology; + private ExtensionsManager $extensionsManager; + private common_ext_Extension $extension; + private array $config; + + public function __construct(Ontology $ontology, ExtensionsManager $extensionsManager) + { + $this->ontology = $ontology; + $this->extensionsManager = $extensionsManager; + $this->extension = $this->extensionsManager->getExtensionById('taoQtiTest'); + $this->config = $this->extension->getConfig('testRunner') ?? []; + } + + /** + * @param string[] $disablePlugins + * @param Report $report + * @throws \common_exception_Error + */ + public function disablePlugin(array $disablePlugins, Report $report): void + { + foreach ($disablePlugins as $plugin) { + if (array_key_exists($plugin, self::PLUGIN_MAP)) { + $report->add(new Report(Report::TYPE_INFO, 'Plugin ' . $plugin . ' has been disabled')); + $this->config[self::PLUGIN_MAP[$plugin]] = false; + } + + if (array_key_exists($plugin, $this->config)) { + $report->add(new Report(Report::TYPE_INFO, 'Plugin ' . $plugin . ' has been disabled')); + $this->config[$plugin] = false; + } + } + $this->extension->setConfig('testRunner', $this->config); + } + + public function enablePlugin(array $enablePlugins, Report $report): void + { + $config = $this->getConfig(); + + foreach ($enablePlugins as $plugin) { + if (array_key_exists($plugin, self::PLUGIN_MAP)) { + $report->add(new Report(Report::TYPE_INFO, 'Plugin ' . $plugin . ' has been enabled')); + $config[self::PLUGIN_MAP[$plugin]] = true; + } + + if (array_key_exists($plugin, $config)) { + $report->add(new Report(Report::TYPE_INFO, 'Plugin ' . $plugin . ' has been disabled')); + $config[$plugin] = true; + } + } + $this->extension->setConfig('testRunner', $config); + } + + private function getConfig(): array + { + return $this->extensionsManager->getExtensionById('taoQtiTest')->getConfig('testRunner'); + } +} diff --git a/models/classes/runner/QtiRunnerInvalidResponsesException.php b/models/classes/runner/QtiRunnerInvalidResponsesException.php new file mode 100644 index 0000000000..bf700df7af --- /dev/null +++ b/models/classes/runner/QtiRunnerInvalidResponsesException.php @@ -0,0 +1,37 @@ +getSource(); $timeLimits = $source->getTimeLimits(); @@ -271,6 +271,6 @@ public function jsonSerialize() ]; } } - return null; + return []; } } diff --git a/scripts/tools/TestRunnerPluginManager.php b/scripts/tools/TestRunnerPluginManager.php new file mode 100644 index 0000000000..a45199d72f --- /dev/null +++ b/scripts/tools/TestRunnerPluginManager.php @@ -0,0 +1,85 @@ + [ + 'prefix' => 'd', + 'longPrefix' => 'disable', + 'required' => false, + 'description' => 'List of plugins to be disabled, separated by comma', + ], + 'enablePlugins' => [ + 'prefix' => 'e', + 'longPrefix' => 'enable', + 'required' => false, + 'description' => 'List of plugins to be enabled, separated by comma', + ], + ]; + } + + protected function provideDescription() + { + return 'Manage test runner plugins'; + } + + protected function run() + { + if (empty($this->getOption('disablePlugins')) && empty($this->getOption('enablePlugins'))) { + return new Report(Report::TYPE_ERROR, 'No action provided'); + } + + $report = new Report(Report::TYPE_INFO, 'Plugins have been managed successfully'); + + $disablePlugins = $this->getOption('disablePlugins') + ? explode(',', $this->getOption('disablePlugins')) + : []; + + $enablePlugins = $this->getOption('enablePlugins') !== null + ? explode(',', $this->getOption('enablePlugins')) + : []; + + if (!empty($disablePlugins)) { + $this->getPluiginManagerService()->disablePlugin($disablePlugins, $report); + } + + if (!empty($enablePlugins)) { + $this->getPluiginManagerService()->enablePlugin($enablePlugins, $report); + } + + return $report; + } + + private function getPluiginManagerService(): PluginManagerService + { + return $this->getServiceManager()->getContainer()->get(PluginManagerService::class); + } +} diff --git a/test/unit/model/Infrastructure/QtiItemResponseRepositoryTest.php b/test/unit/model/Infrastructure/QtiItemResponseRepositoryTest.php new file mode 100644 index 0000000000..25bfb67110 --- /dev/null +++ b/test/unit/model/Infrastructure/QtiItemResponseRepositoryTest.php @@ -0,0 +1,171 @@ +runnerServiceMock = $this->createMock(QtiRunnerService::class); + $this->featureFlagCheckerMock = $this->createMock(FeatureFlagChecker::class); + $this->itemResponseValidatorMock = $this->createMock(QtiItemResponseValidator::class); + + $this->subject = new QtiItemResponseRepository( + $this->runnerServiceMock, + $this->featureFlagCheckerMock, + $this->itemResponseValidatorMock + ); + } + + /** + * @dataProvider saveDataProvider + */ + public function testSave( + array $state, + array $response, + float $duration, + float $timestamp, + string $itemHref, + string $responseIdentifier, + int $storeItemResponseCount, + bool $shouldThrowException + ): void { + $itemResponse = new ItemResponse( + 'itemIdentifier', + $state, + $response, + $duration, + $timestamp + ); + + $runnerServiceContextMock = $this->createMock(QtiRunnerServiceContext::class); + $extendedAssessmentItemRefMock = $this->createMock(ExtendedAssessmentItemRef::class); + $assessmentItemSession = $this->createMock(AssessmentItemSession::class); + $assessmentTestSession = $this->createMock(AssessmentTestSession::class); + $stateMock = $this->createMock(State::class); + + $extendedAssessmentItemRefMock->expects($this->once()) + ->method('getIdentifier') + ->willReturn($responseIdentifier); + + $this->runnerServiceMock->expects($this->once()) + ->method('isTerminated') + ->with($runnerServiceContextMock) + ->willReturn(false); + + $this->runnerServiceMock->expects($this->once()) + ->method('endTimer'); + + $this->runnerServiceMock->expects($this->once()) + ->method('getItemHref') + ->willReturn($itemHref); + + $this->runnerServiceMock->expects($this->once()) + ->method('setItemState'); + + $runnerServiceContextMock + ->method('getCurrentAssessmentItemRef') + ->willReturn($extendedAssessmentItemRefMock); + + $this->runnerServiceMock->expects($this->once()) + ->method('setItemState') + ->with($runnerServiceContextMock, $responseIdentifier, $state); + + $this->runnerServiceMock->expects($this->once()) + ->method('parsesItemResponse') + ->willReturn($stateMock); + + $this->featureFlagCheckerMock->expects($this->once()) + ->method('isEnabled') + ->willReturn(true); + + $runnerServiceContextMock + ->method('getTestSession') + ->willReturn($assessmentTestSession); + + $this->itemResponseValidatorMock->expects($this->once()) + ->method('validate'); + + $this->runnerServiceMock->expects($this->exactly($storeItemResponseCount)) + ->method('storeItemResponse'); + + if ($shouldThrowException) { + $this->itemResponseValidatorMock->expects($this->once()) + ->method('validate') + ->with($assessmentTestSession, $stateMock) + ->willThrowException( + new AssessmentItemSessionException( + 'invalid', + $assessmentItemSession, + AssessmentItemSessionException::DURATION_OVERFLOW + ) + ); + $this->expectException(QtiRunnerInvalidResponsesException::class); + } + + $this->subject->save($itemResponse, $runnerServiceContextMock); + } + + public function saveDataProvider() + { + return [ + 'happyPath with feature flag enabled' => [ + 'state' => ['state'], + 'response' => ['response'], + 'duration' => 1.0, + 'timestamp' => 2.0, + 'itemHref' => 'itemHref', + 'responseIdentifier' => 'itemIdentifier', + 'storeItemResponseCount' => 1, + 'shouldThrowException' => false + ], + 'validation throw an error, flag enabled' => [ + 'state' => ['state'], + 'response' => ['response'], + 'duration' => 1.0, + 'timestamp' => 2.0, + 'itemHref' => 'itemHref', + 'responseIdentifier' => 'itemIdentifier', + 'storeItemResponseCount' => 0, + 'shouldThrowException' => true + ] + ]; + } +} diff --git a/test/unit/model/Infrastructure/QtiItemResponseValidatorTest.php b/test/unit/model/Infrastructure/QtiItemResponseValidatorTest.php new file mode 100644 index 0000000000..2941041127 --- /dev/null +++ b/test/unit/model/Infrastructure/QtiItemResponseValidatorTest.php @@ -0,0 +1,143 @@ +subject = new QtiItemResponseValidator(); + $this->routeMock = $this->createMock(Route::class); + $this->routeItem = $this->createMock(RouteItem::class); + $this->routeItemSessionControl = $this->createMock(RouteItemSessionControl::class); + $this->itemSessionControl = $this->createMock(ItemSessionControl::class); + + $this->routeMock + ->method('current') + ->willReturn($this->routeItem); + + $this->routeItem + ->method('getItemSessionControl') + ->willReturn($this->routeItemSessionControl); + + $this->routeItemSessionControl + ->method('getItemSessionControl') + ->willReturn($this->itemSessionControl); + } + + /** + * @throws \common_exception_Error + * @throws \oat\taoQtiTest\models\runner\QtiRunnerEmptyResponsesException + * @throws \qtism\runtime\tests\AssessmentItemSessionException + */ + public function testValidateAllowedToSkip() + { + $assessmentTestSession = $this->createMock(AssessmentTestSession::class); + $responses = $this->createMock(State::class); + + $assessmentTestSession + ->method('getRoute') + ->willReturn($this->routeMock); + + $this->itemSessionControl->expects($this->never()) + ->method('mustValidateResponses'); + + $this->itemSessionControl->expects($this->once()) + ->method('doesAllowSkipping') + ->willReturn(true); + + $responses + ->method('containsNullOnly') + ->willReturn(true); + + $this->subject->validate($assessmentTestSession, $responses); + } + + public function testValidateNotAllowedToSkip(): void + { + $assessmentTestSession = $this->createMock(AssessmentTestSession::class); + $responses = $this->createMock(State::class); + + $assessmentTestSession + ->method('getRoute') + ->willReturn($this->routeMock); + + $this->itemSessionControl->expects($this->never()) + ->method('mustValidateResponses'); + + $this->itemSessionControl->expects($this->exactly(2)) + ->method('doesAllowSkipping') + ->willReturn(false); + + $responses + ->method('containsNullOnly') + ->willReturn(true); + + $this->expectException(QtiRunnerEmptyResponsesException::class); + + $this->subject->validate($assessmentTestSession, $responses); + } + + public function testValidateNotAllowedToSkipValidateResponses(): void + { + $assessmentTestSession = $this->createMock(AssessmentTestSession::class); + $responses = $this->createMock(State::class); + $assessmentItemSession = $this->createMock(AssessmentItemSession::class); + + $assessmentTestSession + ->method('getRoute') + ->willReturn($this->routeMock); + + $this->itemSessionControl->expects($this->once()) + ->method('mustValidateResponses') + ->willReturn(true); + + $this->itemSessionControl->expects($this->exactly(2)) + ->method('doesAllowSkipping') + ->willReturn(false); + + $responses + ->method('containsNullOnly') + ->willReturn(false); + + $assessmentTestSession->expects($this->once()) + ->method('getCurrentAssessmentItemSession') + ->willReturn($assessmentItemSession); + + $assessmentItemSession->expects($this->once()) + ->method('checkResponseValidityConstraints'); + + $this->subject->validate($assessmentTestSession, $responses); + } +}