Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
6.69% covered (danger)
6.69%
52 / 777
5.63% covered (danger)
5.63%
4 / 71
CRAP
0.00% covered (danger)
0.00%
0 / 1
QtiRunnerService
6.69% covered (danger)
6.69%
52 / 777
5.63% covered (danger)
5.63%
4 / 71
39180.82
0.00% covered (danger)
0.00%
0 / 1
 loadItemData
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
90
 getServiceContext
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 initServiceContext
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 persist
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 init
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 getTestConfig
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTestData
78.26% covered (warning)
78.26%
36 / 46
0.00% covered (danger)
0.00%
0 / 1
5.26
 getTestContext
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
72
 getTestMap
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getRubrics
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getItemHref
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getItemData
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getStateId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildStorageItemKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getItemState
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 setItemState
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 setToolsStates
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 getToolsStates
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 parsesItemResponse
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
56
 emptyResponse
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 storeItemResponse
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
42
 displayFeedbacks
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getFeedbacks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getItemVariableElementsData
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 hasFeedbacks
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 getItemSession
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
12
 move
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 skip
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 timeout
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 exitTest
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 finish
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getResultsStorage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 triggerDeliveryExecutionFinish
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 pause
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 resume
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 check
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isItemCompleted
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
306
 isPaused
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isTerminated
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getItemPublicUrl
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 comment
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 continueInteraction
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 onTimeout
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
90
 buildTimeConstraints
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 storeTraceVariable
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getTraceVariable
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 storeOutcomeVariable
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getOutcomeVariable
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 storeResponseVariable
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getResponseVariable
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 storeVariables
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 storeVariable
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 getTransmissionId
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 assertQtiRunnerServiceContext
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 startTimer
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 endTimer
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 switchClientStoreId
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 getCurrentAssessmentSession
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTimeLimitsFromSession
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 deleteDeliveryExecutionData
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 getItemPortableElements
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 getItemMetadataElements
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 deleteExecutionStates
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 deleteExecutionStatesBasedOnSession
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getItemsRefs
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getStateAfterExit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isThemeSwitcherEnabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getCurrentThemeId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getUpdateItemContentReferencesService
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFeatureFlagChecker
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 assertIsQtiRunnerServiceContext
12.50% covered (danger)
12.50%
1 / 8
0.00% covered (danger)
0.00%
0 / 1
4.68
1<?php
2
3/**
4 * This program is free software; you can redistribute it and/or
5 * modify it under the terms of the GNU General Public License
6 * as published by the Free Software Foundation; under version 2
7 * of the License (non-upgradable).
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program; if not, write to the Free Software
16 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17 *
18 * Copyright (c) 2016-2023 (original work) Open Assessment Technologies SA.
19 */
20
21namespace oat\taoQtiTest\models\runner;
22
23use common_Exception;
24use common_exception_Error;
25use common_exception_InconsistentData;
26use common_exception_InvalidArgumentType as InvalidArgumentTypeException;
27use common_exception_NotImplemented;
28use common_ext_ExtensionException;
29use common_ext_ExtensionsManager;
30use common_Logger;
31use common_persistence_AdvKeyValuePersistence;
32use common_persistence_KeyValuePersistence;
33use common_session_SessionManager;
34use Exception;
35use oat\libCat\result\ItemResult;
36use oat\libCat\result\ResultVariable;
37use oat\oatbox\event\EventManager;
38use oat\oatbox\filesystem\FilesystemException;
39use oat\oatbox\service\ConfigurableService;
40use oat\oatbox\service\exception\InvalidServiceManagerException;
41use oat\tao\model\featureFlag\FeatureFlagChecker;
42use oat\tao\model\featureFlag\FeatureFlagCheckerInterface;
43use oat\tao\model\theme\ThemeService;
44use oat\taoDelivery\model\execution\Delete\DeliveryExecutionDeleteRequest;
45use oat\taoDelivery\model\execution\DeliveryExecution;
46use oat\taoDelivery\model\execution\DeliveryServerService;
47use oat\taoDelivery\model\execution\ServiceProxy as TaoDeliveryServiceProxy;
48use oat\taoDelivery\model\RuntimeService;
49use oat\taoOutcomeRds\model\RdsResultStorage;
50use oat\taoQtiItem\model\portableElement\exception\PortableElementNotFoundException;
51use oat\taoQtiItem\model\portableElement\exception\PortableModelMissing;
52use oat\taoQtiItem\model\portableElement\PortableElementService;
53use oat\taoQtiItem\model\QtiJsonItemCompiler;
54use oat\taoQtiTest\models\cat\CatService;
55use oat\taoQtiTest\models\cat\GetDeliveryExecutionsItems;
56use oat\taoQtiTest\models\event\AfterAssessmentTestSessionClosedEvent;
57use oat\taoQtiTest\models\event\QtiContinueInteractionEvent;
58use oat\taoQtiTest\models\event\TestExitEvent;
59use oat\taoQtiTest\models\event\TestInitEvent;
60use oat\taoQtiTest\models\event\TestTimeoutEvent;
61use oat\taoQtiTest\models\event\DeliveryExecutionFinish;
62use oat\taoQtiTest\models\ExtendedStateService;
63use oat\taoQtiTest\models\files\QtiFlysystemFileManager;
64use oat\taoQtiTest\models\render\UpdateItemContentReferencesService;
65use oat\taoQtiTest\models\runner\config\QtiRunnerConfig;
66use oat\taoQtiTest\models\runner\config\RunnerConfig;
67use oat\taoQtiTest\models\runner\map\QtiRunnerMap;
68use oat\taoQtiTest\models\runner\navigation\QtiRunnerNavigation;
69use oat\taoQtiTest\models\runner\rubric\QtiRunnerRubric;
70use oat\taoQtiTest\models\runner\session\TestSession;
71use oat\taoQtiTest\models\runner\toolsStates\ToolsStateStorage;
72use oat\taoQtiTest\models\TestSessionService;
73use oat\taoResultServer\models\classes\implementation\ResultServerService;
74use qtism\common\datatypes\QtiInteger;
75use qtism\common\datatypes\QtiString as QtismString;
76use qtism\common\enums\BaseType;
77use qtism\common\enums\Cardinality;
78use qtism\data\AssessmentItemRef;
79use qtism\data\NavigationMode;
80use qtism\data\storage\php\PhpStorageException;
81use qtism\data\SubmissionMode;
82use qtism\runtime\common\ResponseVariable;
83use qtism\runtime\common\State;
84use qtism\runtime\common\Utils;
85use qtism\runtime\tests\AssessmentItemSession;
86use qtism\runtime\tests\AssessmentItemSessionException;
87use qtism\runtime\tests\AssessmentItemSessionState;
88use qtism\runtime\tests\AssessmentTestSession;
89use qtism\runtime\tests\AssessmentTestSessionException;
90use qtism\runtime\tests\AssessmentTestSessionState;
91use qtism\runtime\tests\RouteItem;
92use qtism\runtime\tests\SessionManager;
93use tao_models_classes_FileNotFoundException;
94use tao_models_classes_service_StateStorage;
95use taoQtiCommon_helpers_PciStateOutput;
96use taoQtiCommon_helpers_PciVariableFiller;
97use taoQtiCommon_helpers_ResultTransmissionException;
98use taoQtiCommon_helpers_ResultTransmitter;
99use taoQtiTest_helpers_TestRunnerUtils as TestRunnerUtils;
100use taoResultServer_models_classes_TraceVariable;
101use taoResultServer_models_classes_Variable;
102
103/**
104 * Class QtiRunnerService
105 *
106 * QTI implementation service for the test runner
107 *
108 * @package oat\taoQtiTest\models
109 * @author Jean-Sébastien Conan <jean-sebastien.conan@vesperiagroup.com>
110 */
111class QtiRunnerService extends ConfigurableService implements PersistableRunnerServiceInterface, RunnerService
112{
113    public const SERVICE_ID = 'taoQtiTest/QtiRunnerService';
114
115    /**
116     * @deprecated use SERVICE_ID
117     */
118    public const CONFIG_ID = self::SERVICE_ID;
119
120    public const TOOL_ITEM_THEME_SWITCHER     = 'itemThemeSwitcher';
121    public const TOOL_ITEM_THEME_SWITCHER_KEY = 'taoQtiTest/runner/plugins/tools/itemThemeSwitcher/itemThemeSwitcher';
122
123    private const TIMEOUT_EXCEPTION_CODES = [
124        AssessmentTestSessionException::ASSESSMENT_TEST_DURATION_OVERFLOW,
125        AssessmentTestSessionException::TEST_PART_DURATION_OVERFLOW,
126        AssessmentTestSessionException::ASSESSMENT_SECTION_DURATION_OVERFLOW,
127        AssessmentTestSessionException::ASSESSMENT_ITEM_DURATION_OVERFLOW,
128    ];
129
130    /**
131     * The test runner config
132     */
133    protected ?RunnerConfig $testConfig = null;
134
135    /**
136     * Use to store retrieved item data, inside the same request
137     */
138    private array $dataCache = [];
139
140    /**
141     * Get the data folder from a given item definition
142     * @param string $itemRef - formatted as itemURI|publicFolderURI|privateFolderURI
143     * @return array the path
144     * @throws common_Exception
145     */
146    private function loadItemData($itemRef, $path)
147    {
148        $cacheKey = $itemRef . $path;
149        if (! empty($cacheKey) && isset($this->dataCache[$itemRef . $path])) {
150            return $this->dataCache[$itemRef . $path];
151        }
152
153        $directoryIds = explode('|', $itemRef);
154        if (count($directoryIds) < 3) {
155            if (is_scalar($itemRef)) {
156                $itemRefInfo = gettype($itemRef) . ': ' . strval($itemRef);
157            } elseif (is_object($itemRef)) {
158                $itemRefInfo = gettype($itemRef) . ': ' . get_class($itemRef);
159            } else {
160                $itemRefInfo = gettype($itemRef);
161            }
162
163            throw new common_exception_InconsistentData(
164                "The itemRef (value = '${itemRefInfo}') is not formatted correctly."
165            );
166        }
167
168        $itemUri = $directoryIds[0];
169        $userDataLang = common_session_SessionManager::getSession()->getDataLanguage();
170        $directory = \tao_models_classes_service_FileStorage::singleton()->getDirectoryById($directoryIds[2]);
171
172        if ($directory->has($userDataLang)) {
173            $lang = $userDataLang;
174        } elseif ($directory->has(DEFAULT_LANG)) {
175            common_Logger::d(
176                $userDataLang . ' is not part of compilation directory for item : ' . $itemUri . ' use ' . DEFAULT_LANG
177            );
178            $lang = DEFAULT_LANG;
179        } else {
180            throw new common_Exception(
181                'item : ' . $itemUri . 'is neither compiled in ' . $userDataLang . ' nor in ' . DEFAULT_LANG
182            );
183        }
184        try {
185            $content = $directory->read($lang . DIRECTORY_SEPARATOR . $path);
186            $jsonContent = $this->getUpdateItemContentReferencesService()->__invoke(json_decode($content, true));
187
188            $this->dataCache[$cacheKey] = $jsonContent;
189            return $this->dataCache[$cacheKey];
190        } catch (FilesystemException $e) {
191            throw new tao_models_classes_FileNotFoundException(
192                $path . ' for item reference ' . $itemRef
193            );
194        }
195    }
196
197    /**
198     * Gets the test session for a particular delivery execution
199     *
200     * This method is called before each action (moveNext, moveBack, pause, ...) call.
201     *
202     * @param string $testDefinitionUri The URI of the test
203     * @param string $testCompilationUri The URI of the compiled delivery
204     * @param string $testExecutionUri The URI of the delivery execution
205     * @param string $userUri User identifier. If null current user will be used
206     * @return QtiRunnerServiceContext
207     * @throws common_Exception
208     */
209    public function getServiceContext($testDefinitionUri, $testCompilationUri, $testExecutionUri, $userUri = null)
210    {
211        // create a service context based on the provided URI
212        // initialize the test session and related objects
213        $serviceContext = new QtiRunnerServiceContext($testDefinitionUri, $testCompilationUri, $testExecutionUri);
214        $this->propagate($serviceContext);
215        $serviceContext->setTestConfig($this->getTestConfig());
216        $serviceContext->setUserUri($userUri);
217
218        $sessionService = $this->getServiceManager()->get(TestSessionService::SERVICE_ID);
219        $sessionService->registerTestSession(
220            $serviceContext->getTestSession(),
221            $serviceContext->getStorage(),
222            $serviceContext->getCompilationDirectory()
223        );
224
225        return $serviceContext;
226    }
227
228    /**
229     * Checks the created context, then initializes it.
230     * @param RunnerServiceContext $context
231     * @return RunnerServiceContext
232     * @throws common_Exception
233     */
234    public function initServiceContext(RunnerServiceContext $context)
235    {
236        // will throw exception if the test session is not valid
237        $this->check($context);
238
239        // starts the context
240        $context->init();
241
242        return $context;
243    }
244
245    /**
246     * Persists the AssessmentTestSession into binary data.
247     * @param QtiRunnerServiceContext $serviceContext
248     */
249    public function persist(RunnerServiceContext $serviceContext): void
250    {
251        $testSession = $serviceContext->getTestSession();
252        $sessionId = $testSession->getSessionId();
253
254        $this->getLogger()->debug("Persisting QTI Assessment Test Session '${sessionId}'...");
255        $serviceContext->getStorage()->persist($testSession);
256        if ($this->isTerminated($serviceContext)) {
257            /** @var StorageManager $storageManager */
258            $storageManager = $this->getServiceManager()->get(StorageManager::SERVICE_ID);
259            $storageManager->persist();
260
261            $userId = common_session_SessionManager::getSession()->getUser()->getIdentifier();
262            $eventManager = $this->getServiceManager()->get(EventManager::SERVICE_ID);
263            $eventManager->trigger(new AfterAssessmentTestSessionClosedEvent($testSession, $userId));
264        }
265    }
266
267    /**
268     * Initializes the delivery execution session
269     *
270     * This method is called whenever a candidate enters the test. This includes
271     *
272     * * Newly launched/instantiated test session.
273     * * The candidate refreshes the client (F5).
274     * * Resumed test sessions.
275     *
276     * @param RunnerServiceContext $context
277     * @return boolean
278     * @throws common_Exception
279     */
280    public function init(RunnerServiceContext $context)
281    {
282        $this->assertIsQtiRunnerServiceContext($context, 'init');
283
284        /* @var TestSession $session */
285        $session = $context->getTestSession();
286
287        // code borrowed from the previous implementation, but the reset timers option has been discarded
288        if ($session->getState() === AssessmentTestSessionState::INITIAL) {
289            // The test has just been instantiated.
290            $session->beginTestSession();
291            $event = new TestInitEvent($session);
292            $this->getServiceManager()->get(EventManager::SERVICE_ID)->trigger($event);
293
294            $this->getLogger()->info(
295                sprintf('Assessment Test Session begun. Session id: %s', $session->getSessionId())
296            );
297
298            if ($context->isAdaptive()) {
299                $this->getLogger()->debug("Very first item is adaptive.");
300                $nextCatItemId = $context->selectAdaptiveNextItem();
301                $context->persistCurrentCatItemId($nextCatItemId);
302                $context->persistSeenCatItemIds($nextCatItemId);
303            }
304        } elseif ($session->getState() === AssessmentTestSessionState::SUSPENDED) {
305            $session->resume();
306        }
307
308        $session->initItemTimer();
309        if ($session->isTimeout() === false) {
310            TestRunnerUtils::beginCandidateInteraction($session);
311        }
312
313        $this->getServiceManager()->get(ExtendedStateService::SERVICE_ID)->clearEvents($session->getSessionId());
314
315        return true;
316    }
317
318    /**
319     * Gets the test runner config
320     * @return RunnerConfig
321     * @throws common_ext_ExtensionException
322     */
323    public function getTestConfig()
324    {
325        if ($this->testConfig === null) {
326            $this->testConfig = $this->getServiceManager()->get(QtiRunnerConfig::SERVICE_ID);
327        }
328
329        return $this->testConfig;
330    }
331
332    /**
333     * Gets the test definition data
334     *
335     * @deprecated the testData is not necessary anymore
336     * if the config is given directly to the test runner configuration
337     *
338     * @param RunnerServiceContext $context
339     * @return array
340     * @throws common_Exception
341     */
342    public function getTestData(RunnerServiceContext $context)
343    {
344        $this->assertIsQtiRunnerServiceContext($context, 'getTestData');
345
346        $testDefinition = $context->getTestDefinition();
347
348        $response['title'] = $testDefinition->getTitle();
349        $response['identifier'] = $testDefinition->getIdentifier();
350        $response['className'] = $testDefinition->getQtiClassName();
351        $response['toolName'] = $testDefinition->getToolName();
352        $response['exclusivelyLinear'] = $testDefinition->isExclusivelyLinear();
353        $response['hasTimeLimits'] = $testDefinition->hasTimeLimits();
354
355        //states that can be found in the context
356        $response['states'] = [
357            'initial'       => AssessmentTestSessionState::INITIAL,
358            'interacting'   => AssessmentTestSessionState::INTERACTING,
359            'modalFeedback' => AssessmentTestSessionState::MODAL_FEEDBACK,
360            'suspended'     => AssessmentTestSessionState::SUSPENDED,
361            'closed'        => AssessmentTestSessionState::CLOSED
362        ];
363
364        $response['itemStates'] = [
365            'initial'       => AssessmentItemSessionState::INITIAL,
366            'interacting'   => AssessmentItemSessionState::INTERACTING,
367            'modalFeedback' => AssessmentItemSessionState::MODAL_FEEDBACK,
368            'suspended'     => AssessmentItemSessionState::SUSPENDED,
369            'closed'        => AssessmentItemSessionState::CLOSED,
370            'solution'      => AssessmentItemSessionState::SOLUTION,
371            'review'        => AssessmentItemSessionState::REVIEW,
372            'notSelected'   => AssessmentItemSessionState::NOT_SELECTED
373        ];
374
375        $timeLimits = $testDefinition->getTimeLimits();
376        if ($timeLimits) {
377            if ($timeLimits->hasMinTime()) {
378                $response['timeLimits']['minTime'] = [
379                    'duration' => TestRunnerUtils::getDurationWithMicroseconds($timeLimits->getMinTime()),
380                    'iso' => $timeLimits->getMinTime()->__toString(),
381                ];
382            }
383
384            if ($timeLimits->hasMaxTime()) {
385                $response['timeLimits']['maxTime'] = [
386                    'duration' => TestRunnerUtils::getDurationWithMicroseconds($timeLimits->getMaxTime()),
387                    'iso' => $timeLimits->getMaxTime()->__toString(),
388                ];
389            }
390        }
391
392        $response['config'] = $this->getTestConfig()->getConfig();
393
394        if ($this->isThemeSwitcherEnabled()) {
395            $themeSwitcherPlugin = [
396                self::TOOL_ITEM_THEME_SWITCHER => [
397                    "activeNamespace" => $this->getCurrentThemeId(),
398                ],
399            ];
400
401            $response["config"]["plugins"] = array_merge($response["config"]["plugins"], $themeSwitcherPlugin);
402        }
403
404        return $response;
405    }
406
407    /**
408     * Gets the test context object
409     * @param RunnerServiceContext $context
410     * @return array
411     * @throws common_Exception
412     */
413    public function getTestContext(RunnerServiceContext $context)
414    {
415        $this->assertIsQtiRunnerServiceContext($context, 'getTestContext');
416
417        /* @var TestSession $session */
418        $session = $context->getTestSession();
419
420        // The state of the test session.
421        $response['state'] = $session->getState();
422
423        // Default values for the test session context.
424        $response['navigationMode'] = null;
425        $response['submissionMode'] = null;
426        $response['remainingAttempts'] = 0;
427        $response['isAdaptive'] = false;
428
429        // Context of interacting test
430        if ($session->getState() === AssessmentTestSessionState::INTERACTING) {
431            $config = $this->getTestConfig();
432            $route = $session->getRoute();
433            $currentItem = $route->current();
434            $itemSession = $session->getCurrentAssessmentItemSession();
435            $itemRef = $context->getCurrentAssessmentItemRef();
436
437            $reviewConfig = $config->getConfigValue('review');
438            $displaySubsectionTitle = isset($reviewConfig['displaySubsectionTitle'])
439                ? (bool) $reviewConfig['displaySubsectionTitle']
440                : true;
441            $partiallyAnsweredIsAnswered = isset($reviewConfig['partiallyAnsweredIsAnswered'])
442                ? (bool) $reviewConfig['partiallyAnsweredIsAnswered']
443                : true;
444
445            if ($displaySubsectionTitle) {
446                $currentSection = $session->getCurrentAssessmentSection();
447            } else {
448                $sections = $currentItem->getAssessmentSections()->getArrayCopy();
449                $currentSection = $sections[0];
450            }
451
452            $testOptions = $config->getTestOptions($context);
453
454            // The navigation mode.
455            $response['navigationMode'] = $session->getCurrentNavigationMode();
456            $response['isLinear'] = $response['navigationMode'] == NavigationMode::LINEAR;
457
458            // The submission mode.
459            $response['submissionMode'] = $session->getCurrentSubmissionMode();
460
461            // The number of remaining attempts for the current item.
462            $response['remainingAttempts'] = $session->getCurrentRemainingAttempts();
463
464            // Whether or not the current step is timed out.
465            $response['isTimeout'] = $session->isTimeout();
466
467            // The identifier of the current item.
468            $response['itemIdentifier'] = $itemRef->getIdentifier();
469
470            // The number of current attempt (1 for the first time ...)
471            $response['attempt'] = ($context->isAdaptive())
472                ? $context->getCatAttempts($response['itemIdentifier']) + 1
473                : $itemSession['numAttempts']->getValue();
474
475            // The state of the current AssessmentTestSession.
476            $response['itemSessionState'] = $itemSession->getState();
477
478            // Whether the current item is adaptive.
479            $response['isAdaptive'] = $session->isCurrentAssessmentItemAdaptive();
480
481            // Whether the current section is adaptive.
482            $response['isCatAdaptive'] = $context->isAdaptive();
483
484            // Whether the test map must be updated.
485            // TODO: detect if the map need to be updated and set the flag
486            $response['needMapUpdate'] = false;
487
488            // Whether the current item is the very last one of the test.
489            $response['isLast'] = (!$context->isAdaptive()) ? $route->isLast() : false;
490
491            // The current position in the route.
492            $response['itemPosition'] = $context->getCurrentPosition();
493
494            // The current item flagged state
495            $response['itemFlagged'] = TestRunnerUtils::getItemFlag($session, $response['itemPosition'], $context);
496
497            // The current item answered state
498            $response['itemAnswered'] = $this->isItemCompleted(
499                $context,
500                $currentItem,
501                $itemSession,
502                $partiallyAnsweredIsAnswered
503            );
504
505            // Time constraints.
506            $response['timeConstraints'] = $this->buildTimeConstraints($context);
507
508            // Test Part title.
509            $response['testPartId'] = $session->getCurrentTestPart()->getIdentifier();
510
511            // Current Section title.
512            $response['sectionId'] = $currentSection->getIdentifier();
513            $response['sectionTitle'] = $currentSection->getTitle();
514
515            // Number of items composing the test session.
516            $response['numberItems'] = $route->count();
517
518            // Number of items completed during the test session.
519            $response['numberCompleted'] = TestRunnerUtils::testCompletion($session);
520
521            // Number of items presented during the test session.
522            $response['numberPresented'] = $session->numberPresented();
523
524            // Whether or not the progress of the test can be inferred.
525            $response['considerProgress'] = TestRunnerUtils::considerProgress(
526                $session,
527                $context->getTestMeta(),
528                $config->getConfig()
529            );
530
531            // Whether or not the deepest current section is visible.
532            $response['isDeepestSectionVisible'] = $currentSection->isVisible();
533
534            // If the candidate is allowed to move backward e.g. first item of the test.
535            $response['canMoveBackward'] = $context->canMoveBackward();
536
537            //Number of rubric blocks
538            $response['numberRubrics'] = count($currentItem->getRubricBlockRefs());
539
540            //add rubic blocks
541            if ($response['numberRubrics'] > 0) {
542                $response['rubrics'] = $this->getRubrics($context, $session->getCurrentAssessmentItemRef());
543            }
544
545            //prevent the user from submitting empty (i.e. default or null) responses, feature availability
546            $response['enableAllowSkipping'] = $config->getConfigValue('enableAllowSkipping');
547
548            //contextual value
549            $response['allowSkipping'] = $testOptions['allowSkipping'];
550
551            //prevent the user from submitting an invalid response
552            $response['enableValidateResponses'] = $config->getConfigValue('enableValidateResponses');
553
554            //contextual value
555            $response['validateResponses'] = $testOptions['validateResponses'];
556
557            //does the item has modal feedbacks ?
558            $response['hasFeedbacks'] = $this->hasFeedbacks($context, $itemRef->getHref());
559
560            // append dynamic options
561            $response['options'] = $testOptions;
562        }
563
564        return $response;
565    }
566
567    /**
568     * Gets the map of the test items
569     * @param RunnerServiceContext $context
570     * @param bool $partial the full testMap or only the current section
571     * @return array
572     * @throws common_Exception
573     */
574    public function getTestMap(RunnerServiceContext $context, $partial = false)
575    {
576        $this->assertIsQtiRunnerServiceContext($context, 'getTestMap');
577
578        $mapService = $this->getServiceLocator()->get(QtiRunnerMap::SERVICE_ID);
579
580        if ($partial) {
581            return $mapService->getScopedMap($context, $this->getTestConfig());
582        }
583
584        return $mapService->getMap($context, $this->getTestConfig());
585    }
586
587    /**
588     * Gets the rubrics related to the current session state.
589     * @param RunnerServiceContext $context
590     * @param AssessmentItemRef $itemRef (optional) otherwise use the current
591     * @return mixed
592     * @throws common_Exception
593     */
594    public function getRubrics(RunnerServiceContext $context, AssessmentItemRef $itemRef = null)
595    {
596        $this->assertIsQtiRunnerServiceContext($context, 'getRubrics');
597
598        $rubricHelper = $this->getServiceLocator()->get(QtiRunnerRubric::SERVICE_ID);
599        return $rubricHelper->getRubrics($context, $itemRef);
600    }
601
602    /**
603     * Gets AssessmentItemRef's Href by AssessmentItemRef Identifier.
604     * @param RunnerServiceContext $context
605     * @param string $itemRef
606     * @return string
607     */
608    public function getItemHref(RunnerServiceContext $context, string $itemRef): string
609    {
610        $mapService = $this->getServiceLocator()->get(QtiRunnerMap::SERVICE_ID);
611        return (string)$mapService->getItemHref($context, $itemRef);
612    }
613
614    /**
615     * Gets definition data of a particular item
616     * @param RunnerServiceContext $context
617     * @param $itemRef
618     * @return mixed
619     * @throws common_Exception
620     */
621    public function getItemData(RunnerServiceContext $context, $itemRef)
622    {
623        $this->assertIsQtiRunnerServiceContext($context, 'getItemData');
624
625        return $this->loadItemData($itemRef, QtiJsonItemCompiler::ITEM_FILE_NAME);
626    }
627
628    /**
629     * Gets the state identifier for a particular item
630     * @param QtiRunnerServiceContext $context
631     * @param string $itemRef The item identifier
632     * @return string The state identifier
633     */
634    protected function getStateId(QtiRunnerServiceContext $context, $itemRef)
635    {
636        return  $this->buildStorageItemKey($context->getTestExecutionUri(), $itemRef);
637    }
638
639    /**
640     * @param string $deliveryExecutionUri
641     * @param string $itemRef
642     * @return string
643     */
644    private function buildStorageItemKey($deliveryExecutionUri, $itemRef)
645    {
646        return $deliveryExecutionUri . $itemRef;
647    }
648
649    /**
650     * Gets the state of a particular item
651     * @param RunnerServiceContext $context
652     * @param string $itemRef
653     * @return array|null
654     * @throws common_Exception
655     */
656    public function getItemState(RunnerServiceContext $context, $itemRef)
657    {
658        $this->assertIsQtiRunnerServiceContext($context, 'getItemState');
659
660        $serviceService = $this->getServiceManager()->get(StorageManager::SERVICE_ID);
661        $userUri = common_session_SessionManager::getSession()->getUserUri();
662        $stateId = $this->getStateId($context, $itemRef);
663        $state = is_null($userUri) ? null : $serviceService->get($userUri, $stateId);
664
665        if ($state) {
666            $state = json_decode($state, true);
667            if (is_null($state)) {
668                throw new common_exception_InconsistentData('Unable to decode the state for the item ' . $itemRef);
669            }
670        }
671
672        return $state;
673    }
674
675    /**
676     * Sets the state of a particular item
677     * @param RunnerServiceContext $context
678     * @param $itemRef
679     * @param  $state
680     * @return boolean
681     * @throws common_Exception
682     */
683    public function setItemState(RunnerServiceContext $context, $itemRef, $state)
684    {
685        $this->assertIsQtiRunnerServiceContext($context, 'setItemState');
686
687        $serviceService = $this->getServiceManager()->get(StorageManager::SERVICE_ID);
688        $userUri = common_session_SessionManager::getSession()->getUserUri();
689        $stateId = $this->getStateId($context, $itemRef);
690        if (!isset($state)) {
691            $state = '';
692        }
693
694        return is_null($userUri) ? false : $serviceService->set($userUri, $stateId, json_encode($state));
695    }
696
697    /**
698     * @param RunnerServiceContext $context
699     * @param $toolStates
700     * @throws InvalidServiceManagerException
701     */
702    public function setToolsStates(RunnerServiceContext $context, $toolStates)
703    {
704        if ($context instanceof QtiRunnerServiceContext && is_array($toolStates)) {
705            /** @var ToolsStateStorage $toolsStateStorage */
706            $toolsStateStorage = $this->getServiceLocator()->get(ToolsStateStorage::SERVICE_ID);
707
708            $toolsStateStorage->storeStates($context->getTestExecutionUri(), $toolStates);
709        }
710    }
711
712    /**
713     * @param RunnerServiceContext $context
714     * @return array
715     * @throws InvalidServiceManagerException
716     * @throws common_ext_ExtensionException
717     */
718    public function getToolsStates(RunnerServiceContext $context)
719    {
720        $toolsStates = [];
721
722        // add those tools missing from the storage but presented on the config
723        $toolsEnabled = $this->getTestConfig()->getConfigValue('toolStateServerStorage');
724
725        if (count($toolsEnabled) === 0) {
726            return [];
727        }
728
729        if ($context instanceof QtiRunnerServiceContext) {
730            /** @var ToolsStateStorage $toolsStateStorage */
731            $toolsStateStorage = $this->getServiceLocator()->get(ToolsStateStorage::SERVICE_ID);
732            $toolsStates = $toolsStateStorage->getStates($context->getTestExecutionUri());
733        }
734
735        foreach ($toolsEnabled as $toolEnabled) {
736            if (!array_key_exists($toolEnabled, $toolsStates)) {
737                $toolsStates[$toolEnabled] = null;
738            }
739        }
740
741        return $toolsStates;
742    }
743
744    /**
745     * Parses the responses provided for a particular item
746     * @param RunnerServiceContext $context
747     * @param $itemRef
748     * @param $response
749     * @return mixed
750     * @throws common_Exception
751     */
752    public function parsesItemResponse(RunnerServiceContext $context, $itemRef, $response)
753    {
754        $this->assertIsQtiRunnerServiceContext($context, 'storeItemResponse');
755
756        /** @var TestSession $session */
757        $session = $context->getTestSession();
758        $currentItem  = $context->getCurrentAssessmentItemRef();
759        $responses = new State();
760
761        if ($currentItem === false) {
762            $msg = "Trying to store item variables but the state of the test session is INITIAL or CLOSED.\n";
763            $msg .= "Session state value: " . $session->getState() . "\n";
764            $msg .= "Session ID: " . $session->getSessionId() . "\n";
765            $msg .= "JSON Payload: " . mb_substr(json_encode($response), 0, 1000);
766            $this->getLogger()->error($msg);
767        }
768
769        $filler = new taoQtiCommon_helpers_PciVariableFiller(
770            $currentItem,
771            $this->getServiceManager()->get(QtiFlysystemFileManager::SERVICE_ID)
772        );
773
774        if (is_array($response)) {
775            foreach ($response as $id => $responseData) {
776                try {
777                    $var = $filler->fill($id, $responseData);
778                    // Do not take into account QTI File placeholders.
779                    if (\taoQtiCommon_helpers_Utils::isQtiFilePlaceHolder($var) === false) {
780                        $responses->setVariable($var);
781                    }
782                } catch (\OutOfRangeException $e) {
783                    $this->getLogger()->debug("Could not convert client-side value for variable '${id}'.");
784                } catch (\OutOfBoundsException $e) {
785                    $this->getLogger()->debug("Could not find variable with identifier '${id}' in current item.");
786                }
787            }
788        } else {
789            $this->getLogger()->error('Invalid json payload');
790        }
791
792        return $responses;
793    }
794
795    /**
796     * Checks if the provided responses are empty
797     * @param RunnerServiceContext $context
798     * @param $responses
799     * @return mixed
800     * @throws common_Exception
801     */
802    public function emptyResponse(RunnerServiceContext $context, $responses)
803    {
804        $this->assertIsQtiRunnerServiceContext($context, 'storeItemResponse');
805
806        $similar = 0;
807
808        /** @var ResponseVariable $responseVariable */
809        foreach ($responses as $responseVariable) {
810            $value = $responseVariable->getValue();
811            $default = $responseVariable->getDefaultValue();
812
813            // Similar to default ?
814            if (TestRunnerUtils::isQtiValueNull($value) === true) {
815                if (TestRunnerUtils::isQtiValueNull($default) === true) {
816                    $similar++;
817                }
818            } elseif ($value->equals($default) === true) {
819                $similar++;
820            }
821        }
822
823        $respCount = count($responses);
824
825        return $respCount > 0 && $similar === $respCount;
826    }
827
828    /**
829     * Stores the response of a particular item
830     * @param RunnerServiceContext $context
831     * @param $itemRef
832     * @param $responses
833     * @return boolean
834     * @throws InvalidArgumentTypeException
835     * @throws PhpStorageException
836     * @throws AssessmentItemSessionException
837     * @throws taoQtiCommon_helpers_ResultTransmissionException
838     */
839    public function storeItemResponse(RunnerServiceContext $context, $itemRef, $responses)
840    {
841        $this->assertIsQtiRunnerServiceContext($context, 'storeItemResponse');
842
843        $session = $this->getCurrentAssessmentSession($context);
844
845        try {
846            common_Logger::t('Responses sent from the client-side. The Response Processing will take place.');
847
848            if ($context->isAdaptive()) {
849                $session->beginItemSession();
850                $session->beginAttempt();
851                $session->endAttempt($responses);
852
853                $assessmentItem = $session->getAssessmentItem();
854                $assessmentItemIdentifier = $assessmentItem->getIdentifier();
855                $score = $session->getVariable('SCORE');
856                $output = $context->getLastCatItemOutput();
857
858                if ($score !== null) {
859                    $output[$assessmentItemIdentifier] = new ItemResult(
860                        $assessmentItemIdentifier,
861                        new ResultVariable(
862                            $score->getIdentifier(),
863                            BaseType::getNameByConstant($score->getBaseType()),
864                            $score->getValue()->getValue(),
865                            null,
866                            $score->getCardinality()
867                        ),
868                        microtime(true)
869                    );
870                } else {
871                    common_Logger::i(
872                        "No 'SCORE' outcome variable for item '${assessmentItemIdentifier}' involved in an "
873                        . "adaptive section."
874                    );
875                }
876
877                $context->persistLastCatItemOutput($output);
878
879                // Send results to TAO Results.
880                $resultTransmitter = new taoQtiCommon_helpers_ResultTransmitter(
881                    $context->getSessionManager()->getResultServer()
882                );
883
884                $hrefParts = explode('|', $assessmentItem->getHref());
885                $sessionId = $context->getTestSession()->getSessionId();
886                $itemIdentifier = $assessmentItem->getIdentifier();
887
888                // Deal with attempts.
889                $attempt = $context->getCatAttempts($itemIdentifier);
890                $transmissionId = "${sessionId}.${itemIdentifier}.${attempt}";
891
892                $attempt++;
893
894                foreach ($session->getAllVariables() as $var) {
895                    if ($var->getIdentifier() === 'numAttempts') {
896                        $var->setValue(new QtiInteger($attempt));
897                    }
898
899                    $variables[] = $var;
900                }
901
902                $resultTransmitter->transmitItemVariable($variables, $transmissionId, $hrefParts[0], $hrefParts[2]);
903                $context->persistCatAttempts($itemIdentifier, $attempt);
904
905                $context->getTestSession()->endAttempt(new State(), true);
906            } else {
907                // Non adaptive case.
908                $session->endAttempt($responses, true);
909            }
910
911            return true;
912        } catch (AssessmentTestSessionException $e) {
913            common_Logger::w($e->getMessage());
914            return false;
915        }
916    }
917
918    /**
919     * Should we display feedbacks
920     * @param RunnerServiceContext $context
921     * @return boolean
922     * @throws InvalidArgumentTypeException
923     */
924    public function displayFeedbacks(RunnerServiceContext $context)
925    {
926        $this->assertIsQtiRunnerServiceContext($context, 'displayFeedbacks');
927
928        /* @var TestSession $session */
929        $session = $context->getTestSession();
930
931        if ($session->getCurrentSubmissionMode() === SubmissionMode::SIMULTANEOUS) {
932            return false;
933        }
934
935        if ($context->getTestCompilationVersion() > 0) {
936            return $session->getCurrentAssessmentItemSession()->getItemSessionControl()->mustShowFeedback();
937        }
938
939        return $this->getFeatureFlagChecker()->isEnabled('FEATURE_FLAG_FORCE_DISPLAY_TEST_ITEM_FEEDBACK')
940            || $session->getCurrentAssessmentItemSession()->getItemSessionControl()->mustShowFeedback();
941    }
942
943    /**
944     * Get feedback definitions
945     *
946     * @param RunnerServiceContext $context
947     * @param string $itemRef  the item reference
948     * @return array the feedbacks data
949     * @throws common_Exception
950     * @throws InvalidArgumentTypeException
951     * @deprecated since version 30.7.0, to be removed in 31.0.0. Use getItemVariableElementsData() instead
952     */
953    public function getFeedbacks(RunnerServiceContext $context, $itemRef)
954    {
955        return $this->getItemVariableElementsData($context, $itemRef);
956    }
957
958    /**
959     * @param RunnerServiceContext $context
960     * @param $itemRef
961     * @return array
962     * @throws common_Exception
963     * @throws InvalidArgumentTypeException
964     */
965    public function getItemVariableElementsData(RunnerServiceContext $context, $itemRef)
966    {
967        $this->assertQtiRunnerServiceContext($context);
968
969        return $this->loadItemData($itemRef, QtiJsonItemCompiler::VAR_ELT_FILE_NAME);
970    }
971
972    /**
973     * Does the given item has feedbacks
974     *
975     * @param RunnerServiceContext $context
976     * @param string $itemRef  the item reference
977     * @return boolean
978     * @throws common_Exception
979     * @throws common_exception_InconsistentData
980     * @throws InvalidArgumentTypeException
981     * @throws tao_models_classes_FileNotFoundException
982     */
983    public function hasFeedbacks(RunnerServiceContext $context, $itemRef)
984    {
985        $hasFeedbacks     = false;
986        $displayFeedbacks = $this->displayFeedbacks($context);
987        if ($displayFeedbacks) {
988            $feedbacks = $this->getFeedbacks($context, $itemRef);
989            foreach ($feedbacks as $entry) {
990                if (isset($entry['feedbackRules'])) {
991                    if (count($entry['feedbackRules']) > 0) {
992                        $hasFeedbacks = true;
993                    }
994                    break;
995                }
996            }
997        }
998
999        return $hasFeedbacks;
1000    }
1001
1002    /**
1003     * Should we display feedbacks
1004     * @param RunnerServiceContext $context
1005     * @return array the item session
1006     * @throws InvalidArgumentTypeException
1007     */
1008    public function getItemSession(RunnerServiceContext $context)
1009    {
1010        $this->assertIsQtiRunnerServiceContext($context, 'getItemSession');
1011
1012        /* @var TestSession $session */
1013        $session = $context->getTestSession();
1014
1015        $currentItem       = $session->getCurrentAssessmentItemRef();
1016        $currentOccurrence = $session->getCurrentAssessmentItemRefOccurence();
1017
1018        $itemSession = $session->getAssessmentItemSessionStore()->getAssessmentItemSession(
1019            $currentItem,
1020            $currentOccurrence
1021        );
1022
1023        $stateOutput = new taoQtiCommon_helpers_PciStateOutput();
1024
1025        foreach ($itemSession->getAllVariables() as $var) {
1026            $stateOutput->addVariable($var);
1027        }
1028
1029        $output = $stateOutput->getOutput();
1030
1031        // The current item answered state
1032        $route    = $session->getRoute();
1033        $position = $route->getPosition();
1034        $config = $this->getTestConfig();
1035        $reviewConfig = $config->getConfigValue('review');
1036        $partiallyAnsweredIsAnswered = isset($reviewConfig['partiallyAnsweredIsAnswered'])
1037            ? (bool) $reviewConfig['partiallyAnsweredIsAnswered']
1038            : true;
1039
1040        $output['itemAnswered'] = TestRunnerUtils::isItemCompleted(
1041            $route->getRouteItemAt($position),
1042            $itemSession,
1043            $partiallyAnsweredIsAnswered
1044        );
1045
1046        return $output;
1047    }
1048
1049    /**
1050     * Moves the current position to the provided scoped reference.
1051     * @param RunnerServiceContext $context
1052     * @param $direction
1053     * @param $scope
1054     * @param $ref
1055     * @return boolean
1056     * @throws common_Exception
1057     */
1058    public function move(RunnerServiceContext $context, $direction, $scope, $ref)
1059    {
1060        $this->assertIsQtiRunnerServiceContext($context, 'move');
1061
1062        $result = true;
1063
1064        try {
1065            $result = QtiRunnerNavigation::move($direction, $scope, $context, $ref);
1066        } catch (AssessmentTestSessionException $e) {
1067        } finally {
1068            if ($result && (!isset($e) || in_array($e->getCode(), self::TIMEOUT_EXCEPTION_CODES, true))) {
1069                $this->continueInteraction($context);
1070            }
1071        }
1072
1073        return $result;
1074    }
1075
1076    /**
1077     * Skips the current position to the provided scoped reference
1078     * @param RunnerServiceContext $context
1079     * @param $scope
1080     * @param $ref
1081     * @return boolean
1082     * @throws common_Exception
1083     */
1084    public function skip(RunnerServiceContext $context, $scope, $ref)
1085    {
1086        return $this->move($context, 'skip', $scope, $ref);
1087    }
1088
1089    /**
1090     * Handles a test timeout
1091     * @param RunnerServiceContext $context
1092     * @param $scope
1093     * @param $ref
1094     * @param $late
1095     * @return boolean
1096     * @throws common_Exception
1097     */
1098    public function timeout(RunnerServiceContext $context, $scope, $ref, $late = false)
1099    {
1100        $this->assertIsQtiRunnerServiceContext($context, 'timeout');
1101
1102        /* @var TestSession $session */
1103        $session = $context->getTestSession();
1104        if ($context->isAdaptive()) {
1105            $this->getLogger()->debug("Select next item before timeout");
1106            $context->selectAdaptiveNextItem();
1107        }
1108        try {
1109            $session->closeTimer($ref, $scope);
1110            if ($late) {
1111                if ($scope == 'assessmentTest') {
1112                    $code = AssessmentTestSessionException::ASSESSMENT_TEST_DURATION_OVERFLOW;
1113                } elseif ($scope == 'testPart') {
1114                    $code = AssessmentTestSessionException::TEST_PART_DURATION_OVERFLOW;
1115                } elseif ($scope == 'assessmentSection') {
1116                    $code = AssessmentTestSessionException::ASSESSMENT_SECTION_DURATION_OVERFLOW;
1117                } else {
1118                    $code = AssessmentTestSessionException::ASSESSMENT_ITEM_DURATION_OVERFLOW;
1119                }
1120                throw new AssessmentTestSessionException("Maximum duration of ${scope} '${ref}' not respected.", $code);
1121            } else {
1122                $session->checkTimeLimits(false, true, false);
1123            }
1124        } catch (AssessmentTestSessionException $e) {
1125            $this->onTimeout($context, $e);
1126        }
1127
1128        return true;
1129    }
1130
1131    /**
1132     * Exits the test before its end
1133     * @param RunnerServiceContext $context
1134     * @return boolean
1135     * @throws common_Exception
1136     */
1137    public function exitTest(RunnerServiceContext $context)
1138    {
1139        $this->assertIsQtiRunnerServiceContext($context, 'exitTest');
1140
1141        /* @var TestSession $session */
1142        $session = $context->getTestSession();
1143        $sessionId = $session->getSessionId();
1144        $this->getLogger()->info(
1145            "The user has requested termination of the test session '{$sessionId}'"
1146        );
1147
1148        if ($context->isAdaptive()) {
1149            $this->getLogger()->debug('Select next item before test exit');
1150            $context->selectAdaptiveNextItem();
1151        }
1152
1153        $event = new TestExitEvent($session);
1154        $this->getServiceManager()->get(EventManager::SERVICE_ID)->trigger($event);
1155
1156        $session->endTestSession();
1157
1158        $this->finish($context, $this->getStateAfterExit());
1159
1160        return true;
1161    }
1162
1163    /**
1164     * Finishes the test
1165     * @param RunnerServiceContext $context
1166     * @param string $finalState
1167     * @return boolean
1168     * @throws common_Exception
1169     */
1170    public function finish(RunnerServiceContext $context, $finalState = DeliveryExecution::STATE_FINISHED)
1171    {
1172        $this->assertIsQtiRunnerServiceContext($context, 'finish');
1173
1174        $executionUri = $context->getTestExecutionUri();
1175        $userUri = common_session_SessionManager::getSession()->getUserUri();
1176
1177        $executionService = TaoDeliveryServiceProxy::singleton();
1178        $deliveryExecution = $executionService->getDeliveryExecution($executionUri);
1179
1180        if ($deliveryExecution->getUserIdentifier() == $userUri) {
1181            $this->getLogger()->info("Finishing the delivery execution {$executionUri}");
1182            $result = $deliveryExecution->setState($finalState);
1183        } else {
1184            $this->getLogger()->warning(
1185                "Non owner {$userUri} tried to finish deliveryExecution {$executionUri}"
1186            );
1187            $result = false;
1188        }
1189
1190        $this->getServiceManager()->get(ExtendedStateService::SERVICE_ID)->clearEvents($executionUri);
1191        /** @var TestSession $session */
1192        $session = $context->getTestSession();
1193        $this->triggerDeliveryExecutionFinish($deliveryExecution, $session->isManualScored());
1194
1195        return $result;
1196    }
1197
1198    private function getResultsStorage(): RdsResultStorage
1199    {
1200        return $this->getServiceLocator()->get(ResultServerService::SERVICE_ID)->getResultStorage();
1201    }
1202
1203    private function triggerDeliveryExecutionFinish(DeliveryExecution $deliveryExecution, bool $isManualScored): void
1204    {
1205        $outcomeVariables = $this->getResultsStorage()->getDeliveryVariables($deliveryExecution->getIdentifier());
1206        $this->getServiceManager()->get(EventManager::SERVICE_ID)->trigger(
1207            new DeliveryExecutionFinish(
1208                $deliveryExecution,
1209                $outcomeVariables,
1210                $isManualScored
1211            )
1212        );
1213    }
1214
1215    /**
1216     * Sets the test to paused state
1217     * @param RunnerServiceContext $context
1218     * @return boolean
1219     * @throws common_Exception
1220     */
1221    public function pause(RunnerServiceContext $context)
1222    {
1223        $this->assertIsQtiRunnerServiceContext($context, 'pause');
1224
1225        $context->getTestSession()->suspend();
1226        $this->persist($context);
1227
1228        return true;
1229    }
1230
1231    /**
1232     * Resumes the test from paused state
1233     * @param RunnerServiceContext $context
1234     * @return boolean
1235     * @throws common_Exception
1236     */
1237    public function resume(RunnerServiceContext $context)
1238    {
1239        $this->assertIsQtiRunnerServiceContext($context, 'resume');
1240
1241        $context->getTestSession()->resume();
1242        $this->persist($context);
1243
1244        return true;
1245    }
1246
1247    /**
1248     * Checks if the test is still valid
1249     * @param RunnerServiceContext $context
1250     * @return boolean
1251     * @throws common_Exception
1252     * @throws QtiRunnerClosedException
1253     */
1254    public function check(RunnerServiceContext $context)
1255    {
1256        $state = $context->getTestSession()->getState();
1257
1258        if ($state == AssessmentTestSessionState::CLOSED) {
1259            throw new QtiRunnerClosedException();
1260        }
1261
1262        return true;
1263    }
1264
1265    /**
1266     * Checks if an item has been completed
1267     * @param RunnerServiceContext $context
1268     * @param RouteItem $routeItem
1269     * @param AssessmentItemSession $itemSession
1270     * @param bool $partially (optional) Whether or not consider partially responded sessions as responded.
1271     * @return bool
1272     * @throws common_Exception
1273     */
1274    public function isItemCompleted(RunnerServiceContext $context, $routeItem, $itemSession, $partially = true)
1275    {
1276        if ($context instanceof QtiRunnerServiceContext && $context->isAdaptive()) {
1277            $itemIdentifier = $context->getCurrentAssessmentItemRef()->getIdentifier();
1278            $itemState = $this->getItemState($context, $itemIdentifier);
1279            if ($itemState !== null) {
1280                // as the item comes from a CAT section, it is simpler to load the responses from the state
1281                $itemResponse = [];
1282                foreach ($itemState as $key => $value) {
1283                    if (isset($value['response'])) {
1284                        $itemResponse[$key] = $value['response'];
1285                    }
1286                }
1287                $responses = $this->parsesItemResponse($context, $itemIdentifier, $itemResponse);
1288
1289                // fork of AssessmentItemSession::isResponded()
1290                $excludedResponseVariables = ['numAttempts', 'duration'];
1291                foreach ($responses as $var) {
1292                    if (
1293                        $var instanceof ResponseVariable
1294                        && in_array($var->getIdentifier(), $excludedResponseVariables) === false
1295                    ) {
1296                        $value = $var->getValue();
1297                        $defaultValue = $var->getDefaultValue();
1298
1299                        if (Utils::isNull($value) === true) {
1300                            if (Utils::isNull($defaultValue) === (($partially) ? false : true)) {
1301                                return (($partially) ? true : false);
1302                            }
1303                        } else {
1304                            if ($value->equals($defaultValue) === (($partially) ? false : true)) {
1305                                return (($partially) ? true : false);
1306                            }
1307                        }
1308                    }
1309                }
1310            }
1311
1312            return (($partially) ? false : true);
1313        } else {
1314            return TestRunnerUtils::isItemCompleted($routeItem, $itemSession, $partially);
1315        }
1316    }
1317
1318    /**
1319     * Checks if the test is in paused state
1320     * @param RunnerServiceContext $context
1321     * @return boolean
1322     */
1323    public function isPaused(RunnerServiceContext $context)
1324    {
1325        return $context->getTestSession()->getState() == AssessmentTestSessionState::SUSPENDED;
1326    }
1327
1328    /**
1329     * Checks if the test is in terminated state
1330     * @param RunnerServiceContext $context
1331     * @return boolean
1332     */
1333    public function isTerminated(RunnerServiceContext $context)
1334    {
1335        return $context->getTestSession()->getState() == AssessmentTestSessionState::CLOSED;
1336    }
1337
1338    /**
1339     * Get the base url to the item public directory
1340     * @param RunnerServiceContext $context
1341     * @param string $itemRef
1342     * @return string
1343     * @throws common_Exception
1344     * @throws common_exception_Error
1345     * @throws InvalidArgumentTypeException
1346     */
1347    public function getItemPublicUrl(RunnerServiceContext $context, string $itemRef): string
1348    {
1349        if (!$context instanceof QtiRunnerServiceContext) {
1350            throw new InvalidArgumentTypeException(
1351                'QtiRunnerService',
1352                'getItemPublicUrl',
1353                0,
1354                QtiRunnerServiceContext::class,
1355                $context
1356            );
1357        }
1358
1359        $directoryIds = explode('|', $itemRef);
1360
1361        $userDataLang = common_session_SessionManager::getSession()->getDataLanguage();
1362
1363        $directory = \tao_models_classes_service_FileStorage::singleton()->getDirectoryById($directoryIds[1]);
1364        // do fallback in case userlanguage is not default language
1365        if ($userDataLang != DEFAULT_LANG && !$directory->has($userDataLang) && $directory->has(DEFAULT_LANG)) {
1366            $userDataLang = DEFAULT_LANG;
1367        }
1368
1369        return $directory->getPublicAccessUrl() . $userDataLang . '/';
1370    }
1371
1372    /**
1373     * Comment the test
1374     * @param RunnerServiceContext $context
1375     * @param string $comment
1376     * @return bool
1377     */
1378    public function comment(RunnerServiceContext $context, $comment)
1379    {
1380        // prepare transmission Id for result server.
1381        $testSession = $context->getTestSession();
1382        $item = $testSession->getCurrentAssessmentItemRef()->getIdentifier();
1383        $occurrence = $testSession->getCurrentAssessmentItemRefOccurence();
1384        $sessionId = $testSession->getSessionId();
1385        $transmissionId = "${sessionId}.${item}.${occurrence}";
1386
1387        /** @var DeliveryServerService $deliveryServerService */
1388        $deliveryServerService = $this->getServiceManager()->get(DeliveryServerService::SERVICE_ID);
1389        $resultStore = $deliveryServerService->getResultStoreWrapper($sessionId);
1390
1391        $transmitter = new taoQtiCommon_helpers_ResultTransmitter($resultStore);
1392
1393        // build variable and send it.
1394        $itemUri = TestRunnerUtils::getCurrentItemUri($testSession);
1395        $testUri = $testSession->getTest()->getUri();
1396        $variable = new ResponseVariable('comment', Cardinality::SINGLE, BaseType::STRING, new QtismString($comment));
1397        $transmitter->transmitItemVariable($variable, $transmissionId, $itemUri, $testUri);
1398
1399        return true;
1400    }
1401
1402    /**
1403     * Continue the test interaction if possible
1404     * @param RunnerServiceContext $context
1405     * @return bool
1406     */
1407    protected function continueInteraction(RunnerServiceContext $context)
1408    {
1409        $continue = false;
1410
1411        /* @var TestSession $session */
1412        $session = $context->getTestSession();
1413
1414        if ($session->isRunning() === true && $session->isTimeout() === false) {
1415            $event = new QtiContinueInteractionEvent($context, $this);
1416            $this->getServiceManager()->get(EventManager::SERVICE_ID)->trigger($event);
1417
1418            TestRunnerUtils::beginCandidateInteraction($session);
1419            $continue = true;
1420        } else {
1421            $this->finish($context);
1422        }
1423
1424        return $continue;
1425    }
1426
1427    /**
1428     * Stuff to be undertaken when the Assessment Item presented to the candidate
1429     * times out.
1430     *
1431     * @param RunnerServiceContext $context
1432     * @param AssessmentTestSessionException $timeOutException The AssessmentTestSessionException object thrown to
1433     *                                                         indicate the timeout.
1434     */
1435    protected function onTimeout(RunnerServiceContext $context, AssessmentTestSessionException $timeOutException)
1436    {
1437        /* @var TestSession $session */
1438        $session = $context->getTestSession();
1439
1440        $event = new TestTimeoutEvent($session, $timeOutException->getCode(), true);
1441        $this->getServiceManager()->get(EventManager::SERVICE_ID)->trigger($event);
1442
1443        $isLinear = $session->getCurrentNavigationMode() === NavigationMode::LINEAR;
1444        $logContext = [];
1445        if ($context instanceof QtiRunnerServiceContext) {
1446            $logContext['deliveryExecutionId'] = $context->getTestExecutionUri();
1447        }
1448        switch ($timeOutException->getCode()) {
1449            case AssessmentTestSessionException::ASSESSMENT_TEST_DURATION_OVERFLOW:
1450                $this->getLogger()->info('TIMEOUT: closing the assessment test session', $logContext);
1451                $session->moveThroughAndEndTestSession();
1452                break;
1453
1454            case AssessmentTestSessionException::TEST_PART_DURATION_OVERFLOW:
1455                if ($isLinear) {
1456                    $this->getLogger()->info('TIMEOUT: moving to the next test part', $logContext);
1457                    $session->moveNextTestPart();
1458                } else {
1459                    $this->getLogger()->info('TIMEOUT: closing the assessment test part', $logContext);
1460                    $session->closeTestPart();
1461                }
1462                break;
1463
1464            case AssessmentTestSessionException::ASSESSMENT_SECTION_DURATION_OVERFLOW:
1465                if ($isLinear) {
1466                    $this->getLogger()->info('TIMEOUT: moving to the next assessment section', $logContext);
1467                    $session->moveNextAssessmentSection();
1468                } else {
1469                    $this->getLogger()->info('TIMEOUT: closing the assessment section session', $logContext);
1470                    $session->closeAssessmentSection();
1471                }
1472                break;
1473
1474            case AssessmentTestSessionException::ASSESSMENT_ITEM_DURATION_OVERFLOW:
1475                if ($isLinear) {
1476                    $this->getLogger()->info('TIMEOUT: moving to the next item', $logContext);
1477                    $session->moveNextAssessmentItem();
1478                } else {
1479                    $this->getLogger()->info('TIMEOUT: closing the assessment item session', $logContext);
1480                    $session->closeAssessmentItem();
1481                }
1482                break;
1483        }
1484
1485        $event = new TestTimeoutEvent($session, $timeOutException->getCode(), false);
1486        $this->getServiceManager()->get(EventManager::SERVICE_ID)->trigger($event);
1487
1488        $this->continueInteraction($context);
1489    }
1490
1491    /**
1492     * Build an array where each cell represent a time constraint (a.k.a. time limits)
1493     * in force. Each cell is actually an array with two keys:
1494     *
1495     * * 'source': The identifier of the QTI component emitting the constraint
1496     *   (e.g. AssessmentTest, TestPart, AssessmentSection, AssessmentItemRef).
1497     * * 'seconds': The number of remaining seconds until it times out.
1498     *
1499     * @param RunnerServiceContext $context
1500     * @return array
1501     */
1502    protected function buildTimeConstraints(RunnerServiceContext $context)
1503    {
1504        $constraints = [];
1505
1506        $session = $context->getTestSession();
1507        foreach ($session->getRegularTimeConstraints() as $constraint) {
1508            if ($constraint->getMaximumRemainingTime() != false || $constraint->getMinimumRemainingTime() != false) {
1509                $constraints[] = $constraint;
1510            }
1511        }
1512
1513        return $constraints;
1514    }
1515
1516    /**
1517     * Stores trace variable related to an item, a test or a section
1518     *
1519     * @param RunnerServiceContext $context
1520     * @param string|null $itemUri
1521     * @param string $variableIdentifier
1522     * @param mixed $variableValue
1523     * @return boolean
1524     * @throws common_Exception
1525     */
1526    public function storeTraceVariable(
1527        RunnerServiceContext $context,
1528        ?string $itemUri,
1529        string $variableIdentifier,
1530        $variableValue
1531    ): bool {
1532        $this->assertQtiRunnerServiceContext($context);
1533
1534        $metaVariable = $this->getTraceVariable($variableIdentifier, $variableValue);
1535
1536        return $this->storeVariable($context, $itemUri, $metaVariable);
1537    }
1538
1539    /**
1540     * Create a trace variable from variable identifier and value
1541     *
1542     * @param $variableIdentifier
1543     * @param $variableValue
1544     * @return taoResultServer_models_classes_TraceVariable
1545     * @throws InvalidArgumentTypeException
1546     */
1547    public function getTraceVariable($variableIdentifier, $variableValue)
1548    {
1549        if (!is_string($variableValue) && !is_numeric($variableValue)) {
1550            $variableValue = json_encode($variableValue);
1551        }
1552
1553        $metaVariable = new taoResultServer_models_classes_TraceVariable();
1554        $metaVariable->setIdentifier($variableIdentifier);
1555        $metaVariable->setBaseType('string');
1556        $metaVariable->setCardinality(Cardinality::getNameByConstant(Cardinality::SINGLE));
1557        $metaVariable->setTrace($variableValue);
1558
1559        return $metaVariable;
1560    }
1561
1562    /**
1563     * Stores outcome variable related to an item, a test or a section
1564     *
1565     * @param RunnerServiceContext $context
1566     * @param $itemUri
1567     * @param $variableIdentifier
1568     * @param $variableValue
1569     * @return boolean
1570     * @throws common_Exception
1571     */
1572    public function storeOutcomeVariable(RunnerServiceContext $context, $itemUri, $variableIdentifier, $variableValue)
1573    {
1574        $this->assertQtiRunnerServiceContext($context);
1575        $metaVariable = $this->getOutcomeVariable($variableIdentifier, $variableValue);
1576        return $this->storeVariable($context, $itemUri, $metaVariable);
1577    }
1578
1579    /**
1580     * Create an outcome variable from variable identifier and value
1581     *
1582     * @param $variableIdentifier
1583     * @param $variableValue
1584     * @return \taoResultServer_models_classes_OutcomeVariable
1585     * @throws InvalidArgumentTypeException
1586     */
1587    public function getOutcomeVariable($variableIdentifier, $variableValue)
1588    {
1589        if (!is_string($variableValue) && !is_numeric($variableValue)) {
1590            $variableValue = json_encode($variableValue);
1591        }
1592        $metaVariable = new \taoResultServer_models_classes_OutcomeVariable();
1593        $metaVariable->setIdentifier($variableIdentifier);
1594        $metaVariable->setBaseType('string');
1595        $metaVariable->setCardinality(Cardinality::getNameByConstant(Cardinality::SINGLE));
1596        $metaVariable->setValue($variableValue);
1597
1598        return $metaVariable;
1599    }
1600
1601    /**
1602     * Stores response variable related to an item, a test or a section
1603     *
1604     * @param RunnerServiceContext $context
1605     * @param $itemUri
1606     * @param $variableIdentifier
1607     * @param $variableValue
1608     * @return boolean
1609     * @throws common_Exception
1610     */
1611    public function storeResponseVariable(RunnerServiceContext $context, $itemUri, $variableIdentifier, $variableValue)
1612    {
1613        $this->assertQtiRunnerServiceContext($context);
1614        $metaVariable = $this->getResponseVariable($variableIdentifier, $variableValue);
1615        return $this->storeVariable($context, $itemUri, $metaVariable);
1616    }
1617
1618    /**
1619     * Create a response variable from variable identifier and value
1620     *
1621     * @param $variableIdentifier
1622     * @param $variableValue
1623     * @return \taoResultServer_models_classes_ResponseVariable
1624     * @throws InvalidArgumentTypeException
1625     */
1626    public function getResponseVariable($variableIdentifier, $variableValue)
1627    {
1628        if (!is_string($variableValue) && !is_numeric($variableValue)) {
1629            $variableValue = json_encode($variableValue);
1630        }
1631        $metaVariable = new \taoResultServer_models_classes_ResponseVariable();
1632        $metaVariable->setIdentifier($variableIdentifier);
1633        $metaVariable->setBaseType('string');
1634        $metaVariable->setCardinality(Cardinality::getNameByConstant(Cardinality::SINGLE));
1635        $metaVariable->setValue($variableValue);
1636
1637        return $metaVariable;
1638    }
1639
1640    /**
1641     * Store a set of result variables to the result server
1642     *
1643     * @param QtiRunnerServiceContext $context
1644     * @param string $itemUri This is the item uri
1645     * @param taoResultServer_models_classes_Variable[] $metaVariables
1646     * @param null $itemId The assessment item ref id (optional)
1647     * @return bool
1648     * @throws Exception
1649     * @throws common_exception_NotImplemented If the given $itemId is not the current assessment item ref
1650     */
1651    public function storeVariables(
1652        QtiRunnerServiceContext $context,
1653        $itemUri,
1654        $metaVariables,
1655        $itemId = null
1656    ) {
1657        $sessionId = $context->getTestSession()->getSessionId();
1658
1659        /** @var DeliveryServerService $deliveryServerService */
1660        $deliveryServerService = $this->getServiceManager()->get(DeliveryServerService::SERVICE_ID);
1661        $resultStore = $deliveryServerService->getResultStoreWrapper($sessionId);
1662
1663        $testUri = $context->getTestDefinitionUri();
1664
1665        if (!is_null($itemUri)) {
1666            $resultStore->storeItemVariables(
1667                $testUri,
1668                $itemUri,
1669                $metaVariables,
1670                $this->getTransmissionId($context, $itemId)
1671            );
1672        } else {
1673            $resultStore->storeTestVariables($testUri, $metaVariables, $sessionId);
1674        }
1675
1676        return true;
1677    }
1678
1679    /**
1680     * Store a result variable to the result server
1681     *
1682     * @param QtiRunnerServiceContext $context
1683     * @param string $itemUri This is the item identifier
1684     * @param taoResultServer_models_classes_Variable $metaVariable
1685     * @param null $itemId The assessment item ref id (optional)
1686     * @return bool
1687     * @throws common_exception_NotImplemented If the given $itemId is not the current assessment item ref
1688     */
1689    protected function storeVariable(
1690        QtiRunnerServiceContext $context,
1691        $itemUri,
1692        taoResultServer_models_classes_Variable $metaVariable,
1693        $itemId = null
1694    ) {
1695        $sessionId = $context->getTestSession()->getSessionId();
1696
1697        $testUri = $context->getTestDefinitionUri();
1698
1699        /** @var DeliveryServerService $deliveryServerService */
1700        $deliveryServerService = $this->getServiceManager()->get(DeliveryServerService::SERVICE_ID);
1701        $resultStore = $deliveryServerService->getResultStoreWrapper($sessionId);
1702
1703        if (!is_null($itemUri)) {
1704            $resultStore->storeItemVariable(
1705                $testUri,
1706                $itemUri,
1707                $metaVariable,
1708                $this->getTransmissionId($context, $itemId)
1709            );
1710        } else {
1711            $resultStore->storeTestVariable($testUri, $metaVariable, $sessionId);
1712        }
1713
1714        return true;
1715    }
1716
1717    /**
1718     * Build the transmission based on context and item ref id to store Item variables
1719     *
1720     * @param QtiRunnerServiceContext $context
1721     * @param null $itemId The item ref identifier
1722     * @return string The transmission id to store item variables
1723     * @throws common_exception_NotImplemented If the given $itemId is not the current assessment item ref
1724     */
1725    protected function getTransmissionId(QtiRunnerServiceContext $context, $itemId = null)
1726    {
1727        if (is_null($itemId)) {
1728            $itemId = $context->getCurrentAssessmentItemRef();
1729        } elseif ($itemId != $context->getCurrentAssessmentItemRef()) {
1730            throw new common_exception_NotImplemented('Item variables can be stored only for the current item');
1731        }
1732
1733        $sessionId = $context->getTestSession()->getSessionId();
1734        $currentOccurrence = $context->getTestSession()->getCurrentAssessmentItemRefOccurence();
1735
1736        return $sessionId . '.' . $itemId . '.' . $currentOccurrence;
1737    }
1738
1739    /**
1740     * Check if the given RunnerServiceContext is a QtiRunnerServiceContext
1741     *
1742     * @param RunnerServiceContext $context
1743     * @throws InvalidArgumentTypeException
1744     */
1745    public function assertQtiRunnerServiceContext(RunnerServiceContext $context)
1746    {
1747        $this->assertIsQtiRunnerServiceContext($context, __FUNCTION__);
1748    }
1749
1750    /**
1751     * Starts the timer for the current item in the TestSession
1752     * @param RunnerServiceContext $context
1753     * @param float|null $timestamp allow to start the timer at a specific time, or use current when it's null
1754     * @return bool
1755     * @throws InvalidArgumentTypeException
1756     */
1757    public function startTimer(RunnerServiceContext $context, ?float $timestamp = null): bool
1758    {
1759        if (!$context instanceof QtiRunnerServiceContext) {
1760            throw new InvalidArgumentTypeException(
1761                'QtiRunnerService',
1762                'startTimer',
1763                0,
1764                QtiRunnerServiceContext::class,
1765                $context
1766            );
1767        }
1768
1769        /* @var TestSession $session */
1770        $session = $context->getTestSession();
1771        if ($session->getState() === AssessmentTestSessionState::INTERACTING) {
1772            $session->startItemTimer($timestamp);
1773        }
1774
1775        return true;
1776    }
1777
1778    /**
1779     * Ends the timer for the current item in the TestSession
1780     * @param RunnerServiceContext $context
1781     * @param float|null $duration The client side duration to adjust the timer
1782     * @param float|null $timestamp allow to end the timer at a specific time, or use current when it's null
1783     * @return bool
1784     * @throws InvalidArgumentTypeException
1785     */
1786    public function endTimer(RunnerServiceContext $context, ?float $duration = null, ?float $timestamp = null): bool
1787    {
1788        if (!$context instanceof QtiRunnerServiceContext) {
1789            throw new InvalidArgumentTypeException(
1790                'QtiRunnerService',
1791                'endTimer',
1792                0,
1793                QtiRunnerServiceContext::class,
1794                $context
1795            );
1796        }
1797
1798        /* @var TestSession $session */
1799        $session = $context->getTestSession();
1800        $session->endItemTimer($duration, $timestamp);
1801
1802        return true;
1803    }
1804
1805    /**
1806     * Switch the received client store ids. Put the received id if different from the last stored.
1807     * This enables us to check wether the stores has been changed during a test session.
1808     * @param RunnerServiceContext $context
1809     * @param string $receivedStoreId The identifier of the client side store
1810     * @return string the identifier of the LAST saved client side store
1811     * @throws InvalidArgumentTypeException
1812     */
1813    public function switchClientStoreId(RunnerServiceContext $context, $receivedStoreId)
1814    {
1815        if (!$context instanceof QtiRunnerServiceContext) {
1816            throw new InvalidArgumentTypeException(
1817                'QtiRunnerService',
1818                'switchClientStoreId',
1819                0,
1820                QtiRunnerServiceContext::class,
1821                $context
1822            );
1823        }
1824
1825        /* @var TestSession $session */
1826        $session = $context->getTestSession();
1827        $sessionId = $session->getSessionId();
1828
1829        $stateService = $this->getServiceManager()->get(ExtendedStateService::SERVICE_ID);
1830        $lastStoreId = $stateService->getStoreId($sessionId);
1831
1832        if ($lastStoreId == false || $lastStoreId != $receivedStoreId) {
1833            $stateService->setStoreId($sessionId, $receivedStoreId);
1834        }
1835
1836        return $lastStoreId;
1837    }
1838
1839    /**
1840     * Get Current Assessment Session.
1841     *
1842     * Depending on the context (adaptive or not), it will return an appropriate Assessment Object to deal with.
1843     *
1844     * In case of the context is not adaptive, an AssessmentTestSession corresponding to the current test $context
1845     * is returned.
1846     *
1847     * Otherwise, an AssessmentItemSession to deal with is returned.
1848     *
1849     * @param \oat\taoQtiTest\models\runner\RunnerServiceContext $context
1850     * @return \qtism\runtime\tests\AssessmentTestSession|\qtism\runtime\tests\AssessmentItemSession
1851     */
1852    public function getCurrentAssessmentSession(RunnerServiceContext $context)
1853    {
1854        if ($context->isAdaptive()) {
1855            return new AssessmentItemSession($context->getCurrentAssessmentItemRef(), new SessionManager());
1856        } else {
1857            return $context->getTestSession();
1858        }
1859    }
1860
1861    /**
1862     * @param TestSession $session
1863     * @param string $qtiClassName
1864     * @return null|string
1865     */
1866    public function getTimeLimitsFromSession(TestSession $session, $qtiClassName)
1867    {
1868        $maxTimeSeconds = null;
1869        $item = null;
1870        switch ($qtiClassName) {
1871            case 'assessmentTest':
1872                $item = $session->getAssessmentTest();
1873                break;
1874            case 'testPart':
1875                $item = $session->getCurrentTestPart();
1876                break;
1877            case 'assessmentSection':
1878                $item = $session->getCurrentAssessmentSection();
1879                break;
1880            case 'assessmentItemRef':
1881                $item = $session->getCurrentAssessmentItemRef();
1882                break;
1883        }
1884
1885        if ($item && $limits = $item->getTimeLimits()) {
1886            $maxTimeSeconds = $limits->hasMaxTime()
1887                ? $limits->getMaxTime()->getSeconds(true)
1888                : $maxTimeSeconds;
1889        }
1890
1891        return $maxTimeSeconds;
1892    }
1893
1894    /**
1895     * @inheritdoc
1896     */
1897    public function deleteDeliveryExecutionData(DeliveryExecutionDeleteRequest $request)
1898    {
1899        /** @var StorageManager $storage */
1900        $storage = $this->getServiceLocator()->get(StorageManager::SERVICE_ID);
1901        $userUri = $request->getDeliveryExecution()->getUserIdentifier();
1902        /** @var TestSessionService $testSessionService */
1903        $testSessionService = $this->getServiceLocator()->get(TestSessionService::SERVICE_ID);
1904        $session = $testSessionService->getTestSession($request->getDeliveryExecution(), false);
1905        if ($session === null) {
1906            $status = $this->deleteExecutionStates(
1907                $request->getDeliveryExecution()->getIdentifier(),
1908                $userUri,
1909                $storage
1910            );
1911        } else {
1912            $status = $this->deleteExecutionStatesBasedOnSession($request, $storage, $userUri, $session);
1913        }
1914
1915        /** @var ToolsStateStorage $toolsStateStorage */
1916        $toolsStateStorage = $this->getServiceLocator()->get(ToolsStateStorage::SERVICE_ID);
1917        $toolsStateStorage->deleteStates($request->getDeliveryExecution()->getIdentifier());
1918
1919        return $status;
1920    }
1921
1922    /**
1923     * @param RunnerServiceContext $context
1924     * @param $itemRef
1925     * @return array|string
1926     * @throws common_Exception
1927     * @throws common_exception_InconsistentData
1928     */
1929    public function getItemPortableElements(RunnerServiceContext $context, $itemRef)
1930    {
1931        $portableElementService = new PortableElementService();
1932        $portableElementService->setServiceLocator($this->getServiceLocator());
1933
1934        $portableElements = [];
1935        try {
1936            $portableElements = $this->loadItemData($itemRef, QtiJsonItemCompiler::PORTABLE_ELEMENT_FILE_NAME);
1937            foreach ($portableElements as $portableModel => &$elements) {
1938                foreach ($elements as $typeIdentifier => &$versions) {
1939                    foreach ($versions as &$portableData) {
1940                        try {
1941                            $portableElementService->setBaseUrlToPortableData($portableData);
1942                        } catch (PortableElementNotFoundException $e) {
1943                            $this->getLogger()->warning(
1944                                'the portable element version does not exist in delivery server'
1945                            );
1946                        } catch (PortableModelMissing $e) {
1947                            $this->getLogger()->warning(
1948                                'the portable element model does not exist in delivery server'
1949                            );
1950                        }
1951                    }
1952                }
1953            }
1954        } catch (tao_models_classes_FileNotFoundException $e) {
1955            $this->getLogger()->info(
1956                'old delivery that does not contain the compiled portable element data in the item ' . $itemRef
1957            );
1958        }
1959
1960        return $portableElements;
1961    }
1962
1963    /**
1964     * @param $itemRef
1965     * @return array|mixed|string
1966     * @throws common_Exception
1967     */
1968    public function getItemMetadataElements($itemRef)
1969    {
1970        $metadataElements = [];
1971        try {
1972            $metadataElements = $this->loadItemData($itemRef, QtiJsonItemCompiler::METADATA_FILE_NAME);
1973        } catch (tao_models_classes_FileNotFoundException $e) {
1974            $this->getLogger()->info(
1975                'Old delivery that does not contain the compiled portable element data in the item ' . $itemRef
1976                . '. Original message: ' . $e->getMessage()
1977            );
1978        } catch (Exception $e) {
1979            $this->getLogger()->warning(
1980                'An exception caught during fetching item metadata elements. Original message: ' . $e->getMessage()
1981            );
1982        }
1983        return $metadataElements;
1984    }
1985
1986    /**
1987     * @param $deUri
1988     * @param $userUri
1989     * @param StorageManager $storage
1990     * @return mixed
1991     */
1992    protected function deleteExecutionStates($deUri, $userUri, StorageManager $storage)
1993    {
1994        $stateStorage = $storage->getStorage();
1995        $persistence  = common_persistence_KeyValuePersistence::getPersistence(
1996            $stateStorage->getOption(tao_models_classes_service_StateStorage::OPTION_PERSISTENCE)
1997        );
1998
1999        $driver = $persistence->getDriver();
2000        if ($driver instanceof common_persistence_AdvKeyValuePersistence) {
2001            $keys = $driver->keys(tao_models_classes_service_StateStorage::KEY_NAMESPACE . '*' . $deUri . '*');
2002            foreach ($keys as $key) {
2003                $driver->del($key);
2004            }
2005
2006            return $storage->persist($userUri);
2007        }
2008
2009        return false;
2010    }
2011
2012    /**
2013     * @param DeliveryExecutionDeleteRequest $request
2014     * @param StorageManager $storage
2015     * @param $userUri
2016     * @param AssessmentTestSession $session
2017     * @return bool
2018     * @throws \common_exception_NotFound
2019     */
2020    protected function deleteExecutionStatesBasedOnSession(
2021        DeliveryExecutionDeleteRequest $request,
2022        StorageManager $storage,
2023        $userUri,
2024        AssessmentTestSession $session
2025    ) {
2026        $itemsRefs = $this->getItemsRefs($request, $session);
2027        foreach ($itemsRefs as $itemRef) {
2028            $stateId = $this->buildStorageItemKey(
2029                $request->getDeliveryExecution()->getIdentifier(),
2030                $itemRef
2031            );
2032            if ($storage->has($userUri, $stateId)) {
2033                $storage->del($userUri, $stateId);
2034            }
2035        }
2036
2037        return $storage->persist($userUri);
2038    }
2039
2040    /**
2041     * @param DeliveryExecutionDeleteRequest $request
2042     * @param AssessmentTestSession $session
2043     * @return array
2044     */
2045    protected function getItemsRefs(DeliveryExecutionDeleteRequest $request, AssessmentTestSession $session)
2046    {
2047        try {
2048            $itemsRefs = (new GetDeliveryExecutionsItems(
2049                $this->getServiceLocator()->get(RuntimeService::SERVICE_ID),
2050                $this->getServiceLocator()->get(CatService::SERVICE_ID),
2051                \tao_models_classes_service_FileStorage::singleton(),
2052                $request->getDeliveryExecution(),
2053                $session
2054            ))->getItemsRefs();
2055        } catch (Exception $exception) {
2056            $itemsRefs = [];
2057        }
2058
2059        return $itemsRefs;
2060    }
2061
2062    /**
2063     * Get state of delivery execution after exit triggered by test taker
2064     * @return string
2065     */
2066    protected function getStateAfterExit()
2067    {
2068        return DeliveryExecution::STATE_FINISHED;
2069    }
2070
2071    /**
2072     * Returns that the Theme Switcher Plugin is enabled or not
2073     *
2074     * @return bool
2075     * @throws common_ext_ExtensionException
2076     */
2077    private function isThemeSwitcherEnabled()
2078    {
2079        /** @var common_ext_ExtensionsManager $extensionsManager */
2080        $extensionsManager = $this->getServiceLocator()->get(common_ext_ExtensionsManager::SERVICE_ID);
2081        $config = $extensionsManager->getExtensionById("taoTests")->getConfig("test_runner_plugin_registry");
2082
2083        return array_key_exists(self::TOOL_ITEM_THEME_SWITCHER_KEY, $config)
2084            && $config[self::TOOL_ITEM_THEME_SWITCHER_KEY]["active"] === true;
2085    }
2086
2087    /**
2088     * Returns the ID of the current theme
2089     *
2090     * @return string
2091     * @throws common_exception_InconsistentData
2092     */
2093    private function getCurrentThemeId()
2094    {
2095        /** @var ThemeService $themeService */
2096        $themeService = $this->getServiceLocator()->get(ThemeService::SERVICE_ID);
2097
2098        return $themeService->getTheme()->getId();
2099    }
2100
2101    private function getUpdateItemContentReferencesService(): UpdateItemContentReferencesService
2102    {
2103        return $this->getServiceLocator()->getContainer()->get(UpdateItemContentReferencesService::class);
2104    }
2105
2106    private function getFeatureFlagChecker(): FeatureFlagCheckerInterface
2107    {
2108        return $this->getServiceLocator()->getContainer()->get(FeatureFlagChecker::class);
2109    }
2110
2111    /**
2112     * @throws InvalidArgumentTypeException
2113     */
2114    private function assertIsQtiRunnerServiceContext(
2115        RunnerServiceContext $context,
2116        string $action
2117    ): void {
2118        if (!$context instanceof QtiRunnerServiceContext) {
2119            throw new InvalidArgumentTypeException(
2120                'QtiRunnerService',
2121                $action,
2122                0,
2123                QtiRunnerServiceContext::class,
2124                $context
2125            );
2126        }
2127    }
2128}