Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
6.45% covered (danger)
6.45%
16 / 248
23.08% covered (danger)
23.08%
3 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
QtiRunnerMap
6.45% covered (danger)
6.45%
16 / 248
23.08% covered (danger)
23.08%
3 / 13
4680.02
0.00% covered (danger)
0.00%
0 / 1
 getItemHrefIndexFile
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 hasItemHrefIndexFile
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getItemHref
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getMap
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getScopedMap
0.00% covered (danger)
0.00%
0 / 151
0.00% covered (danger)
0.00%
0 / 1
1806
 updateStats
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 getRouteItemAssessmentItemRefs
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 getOffsetPosition
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 getTimeConstraint
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getItemLabel
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getAvailableCategories
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getOverriddenOptionsRepository
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeFuzzyMatchedCategories
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
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 (original work) Open Assessment Technologies SA ;
19 */
20
21/**
22 * @author Jean-Sébastien Conan <jean-sebastien.conan@vesperiagroup.com>
23 */
24
25namespace oat\taoQtiTest\models\runner\map;
26
27use oat\oatbox\service\ConfigurableService;
28use oat\taoQtiTest\models\cat\CatService;
29use oat\taoQtiTest\models\cat\CatUtils;
30use oat\taoQtiTest\models\ExtendedStateService;
31use oat\taoQtiTest\models\runner\config\Business\Contract\OverriddenOptionsRepositoryInterface;
32use oat\taoQtiTest\models\runner\config\QtiRunnerConfig;
33use oat\taoQtiTest\models\runner\config\RunnerConfig;
34use oat\taoQtiTest\models\runner\QtiRunnerServiceContext;
35use oat\taoQtiTest\models\runner\RunnerServiceContext;
36use oat\taoQtiTest\models\runner\session\TestSession;
37use oat\taoQtiTest\models\runner\time\QtiTimeConstraint;
38use qtism\data\AssessmentItemRef;
39use qtism\data\NavigationMode;
40use qtism\data\QtiComponent;
41use qtism\runtime\tests\RouteItem;
42use taoQtiTest_helpers_TestRunnerUtils as TestRunnerUtils;
43
44/**
45 * Class QtiRunnerMap
46 * @package oat\taoQtiTest\models\runner\map
47 */
48class QtiRunnerMap extends ConfigurableService implements RunnerMap
49{
50    public const SERVICE_ID = 'taoQtiTest/QtiRunnerMap';
51
52    /**
53     * Fallback index in case of the delivery was compiled without the index of item href
54     * @var array
55     */
56    protected $itemHrefIndex;
57
58    /**
59     * Gets the file that contains the href for the AssessmentItemRef Identifier
60     * @param QtiRunnerServiceContext $context
61     * @param string $itemIdentifier
62     * @return \oat\oatbox\filesystem\File
63     */
64    protected function getItemHrefIndexFile(QtiRunnerServiceContext $context, $itemIdentifier)
65    {
66        $compilationDirectory = $context->getCompilationDirectory()['private'];
67        return $compilationDirectory->getFile(\taoQtiTest_models_classes_QtiTestCompiler::buildHrefIndexPath(
68            $itemIdentifier
69        ));
70    }
71
72    /**
73     * Checks if the AssessmentItemRef Identifier is in the index.
74     *
75     * @param QtiRunnerServiceContext $context
76     * @param string $itemIdentifier
77     * @return boolean
78     */
79    protected function hasItemHrefIndexFile(QtiRunnerServiceContext $context, $itemIdentifier)
80    {
81        // In case the context is adaptive, it means that the delivery was compiled in a version
82        // we are 100% sure it produced Item Href Index Files.
83        if ($context->isAdaptive()) {
84            return true;
85        }
86
87        return $this->getItemHrefIndexFile($context, $itemIdentifier)->exists();
88    }
89
90    /**
91     * Gets AssessmentItemRef's Href by AssessmentItemRef Identifier.
92     *
93     * Returns the AssessmentItemRef href attribute value from a given $identifier.
94     *
95     * @param QtiRunnerServiceContext $context
96     * @param string $itemIdentifier
97     * @return boolean|string The href value corresponding to the given $identifier. If no corresponding href is found,
98     *                        false is returned.
99     */
100    public function getItemHref(QtiRunnerServiceContext $context, $itemIdentifier)
101    {
102        $href = false;
103
104        $indexFile = $this->getItemHrefIndexFile($context, $itemIdentifier);
105
106        if ($indexFile->exists()) {
107            $href = $indexFile->read();
108        } else {
109            if (!isset($this->itemHrefIndex)) {
110                $storage = $this->getServiceLocator()->get(ExtendedStateService::SERVICE_ID);
111                $this->itemHrefIndex = $storage->loadItemHrefIndex($context->getTestExecutionUri());
112            }
113
114            if (isset($this->itemHrefIndex[$itemIdentifier])) {
115                $href = $this->itemHrefIndex[$itemIdentifier];
116            }
117        }
118
119        return $href;
120    }
121
122    /**
123     * Builds the map of an assessment test
124     * @param RunnerServiceContext $context The test context
125     * @param RunnerConfig $config The runner config
126     * @return mixed
127     * @throws \common_exception_InvalidArgumentType
128     */
129    public function getMap(RunnerServiceContext $context, RunnerConfig $config)
130    {
131        return $this->getScopedMap($context, $config, RunnerMap::SCOPE_TEST);
132    }
133
134    /**
135     * Get the testMap for the current context but limited to the given scope
136     * @param RunnerServiceContext $context The test context
137     * @param RunnerConfig $config The runner config
138     * @param string $scope the target scope, section by default
139     * @return mixed
140     * @throws \common_exception_InvalidArgumentType
141     */
142    public function getScopedMap(RunnerServiceContext $context, RunnerConfig $config, $scope = RunnerMap::SCOPE_SECTION)
143    {
144        if (!($context instanceof QtiRunnerServiceContext)) {
145            throw new \common_exception_InvalidArgumentType(
146                'QtiRunnerMap',
147                'getMap',
148                0,
149                'oat\taoQtiTest\models\runner\QtiRunnerServiceContext',
150                $context
151            );
152        }
153
154        $map = [
155            'scope' => $scope,
156            'parts' => []
157        ];
158
159        // get config for the sequence number option
160        $reviewConfig = $config->getConfigValue('review');
161        $checkInformational = $config->getConfigValue('checkInformational');
162        $forceTitles = !empty($reviewConfig['forceTitle']);
163        $forceInformationalTitles = !empty($reviewConfig['forceInformationalTitle']);
164        $useTitle = !empty($reviewConfig['useTitle']);
165        $uniqueTitle = isset($reviewConfig['itemTitle']) ? $reviewConfig['itemTitle'] : '%d';
166        $uniqueInformationalTitle = isset($reviewConfig['informationalItemTitle'])
167            ? $reviewConfig['informationalItemTitle']
168            : 'Instructions';
169        $displaySubsectionTitle = isset($reviewConfig['displaySubsectionTitle'])
170            ? (bool) $reviewConfig['displaySubsectionTitle']
171            : true;
172        $partiallyAnsweredIsAnswered = isset($reviewConfig['partiallyAnsweredIsAnswered'])
173            ? (bool) $reviewConfig['partiallyAnsweredIsAnswered']
174            : true;
175
176        /* @var TestSession $session */
177        $session = $context->getTestSession();
178        $extendedStorage = $this->getServiceLocator()->get(ExtendedStateService::SERVICE_ID);
179        $testDefinition = $context->getTestDefinition();
180
181        if ($session->isRunning() !== false) {
182            $route         = $session->getRoute();
183            $store         = $session->getAssessmentItemSessionStore();
184
185            switch ($scope) {
186                case RunnerMap::SCOPE_SECTION:
187                    $routeItems = $route->getRouteItemsByAssessmentSection($session->getCurrentAssessmentSection());
188                    break;
189                case RunnerMap::SCOPE_PART:
190                    $routeItems = $route->getRouteItemsByTestPart($session->getCurrentTestPart());
191                    break;
192                case RunnerMap::SCOPE_TEST:
193                default:
194                    $routeItems = $route->getAllRouteItems();
195
196                    $map['title'] = $testDefinition->getTitle();
197                    $map['identifier'] = $testDefinition->getIdentifier();
198                    $map['className'] = $testDefinition->getQtiClassName();
199                    $map['toolName'] = $testDefinition->getToolName();
200                    $map['exclusivelyLinear'] = $testDefinition->isExclusivelyLinear();
201                    $map['hasTimeLimits'] = $testDefinition->hasTimeLimits();
202
203                    break;
204            }
205
206            $offset = $this->getOffsetPosition($context, $routeItems[0]);
207            $offsetSection = 0;
208            $lastSection   = null;
209
210            // fallback index in case of the delivery was compiled without the index of item href
211            $this->itemHrefIndex = [];
212            $shouldBuildItemHrefIndex = !$this->hasItemHrefIndexFile(
213                $context,
214                $session->getCurrentAssessmentItemRef()->getIdentifier()
215            );
216            \common_Logger::t(
217                'Store index ' . ($shouldBuildItemHrefIndex ? 'must be built' : 'is part of the package')
218            );
219
220            /** @var \qtism\runtime\tests\RouteItem $routeItem */
221            foreach ($routeItems as $routeItem) {
222                $catSession = false;
223                $itemRefs = $this->getRouteItemAssessmentItemRefs($context, $routeItem, $catSession);
224                $previouslySeenItems = ($catSession) ? $context->getPreviouslySeenCatItemIds($routeItem) : [];
225
226                foreach ($itemRefs as $itemRef) {
227                    $occurrence = ($catSession !== false) ? 0 : $routeItem->getOccurence();
228
229                    // get the jump definition
230                    $itemSession = ($catSession !== false)
231                        ? false
232                        : $store->getAssessmentItemSession($itemRef, $occurrence);
233
234                    // load item infos
235                    $isItemInformational = ($itemSession)
236                        ? TestRunnerUtils::isItemInformational($routeItem, $itemSession)
237                        : false;
238                    $testPart = $routeItem->getTestPart();
239                    $partId = $testPart->getIdentifier();
240                    $navigationMode = $testPart->getNavigationMode();
241
242                    if ($displaySubsectionTitle) {
243                        $section = $routeItem->getAssessmentSection();
244                    } else {
245                        $sections = $routeItem->getAssessmentSections()->getArrayCopy();
246                        $section = $sections[0];
247                    }
248                    $sectionId = $section->getIdentifier();
249                    $itemId = $itemRef->getIdentifier();
250
251                    if ($lastSection != $sectionId) {
252                        $offsetSection = 0;
253                        $lastSection = $sectionId;
254                    }
255
256                    if ($forceInformationalTitles && $isItemInformational) {
257                        $itemUri = strstr($itemRef->getHref(), '|', true);
258                        $label = $uniqueInformationalTitle === false
259                            ? $this->getItemLabel($context, $itemUri, $useTitle)
260                            : $uniqueInformationalTitle;
261                    } elseif ($forceTitles) {
262                        $label = __($uniqueTitle, $offsetSection + 1);
263                    } else {
264                        $itemUri = strstr($itemRef->getHref(), '|', true);
265                        $label = $this->getItemLabel($context, $itemUri, $useTitle);
266                    }
267
268                    // fallback in case of the delivery was compiled without the index of item href
269                    if ($shouldBuildItemHrefIndex) {
270                        $this->itemHrefIndex[$itemId] = $itemRef->getHref();
271                    }
272
273                    $itemInfos = [
274                        'id' => $itemId,
275                        'label' => $label,
276                        'position' => $offset,
277                        'occurrence' => $occurrence,
278                        'remainingAttempts' => ($itemSession) ? $itemSession->getRemainingAttempts() : -1,
279                        'answered' => ($itemSession)
280                            ? TestRunnerUtils::isItemCompleted($routeItem, $itemSession, $partiallyAnsweredIsAnswered)
281                            : in_array($itemId, $previouslySeenItems),
282                        'flagged' => $extendedStorage->getItemFlag($session->getSessionId(), $itemId),
283                        'viewed' => ($itemSession)
284                            ? $itemSession->isPresented()
285                            : in_array($itemId, $previouslySeenItems),
286                        'categories' => array_values($this->getAvailableCategories($itemRef)),
287                    ];
288
289                    if ($checkInformational) {
290                        $itemInfos['informational'] = $isItemInformational;
291                    }
292
293                    if ($itemRef->hasTimeLimits()) {
294                        $itemInfos['timeConstraint'] = $this->getTimeConstraint($session, $itemRef, $navigationMode);
295                    }
296
297                    if (!isset($map['parts'][$partId]) && $scope != RunnerMap::SCOPE_SECTION) {
298                        $map['parts'][$partId]['id'] = $partId;
299                        $map['parts'][$partId]['label'] = $partId;
300                        $map['parts'][$partId]['position'] = $offset;
301                        $map['parts'][$partId]['isLinear'] = $navigationMode == NavigationMode::LINEAR;
302
303                        if ($testPart->hasTimeLimits()) {
304                            $map['parts'][$partId]['timeConstraint'] =  $this->getTimeConstraint(
305                                $session,
306                                $testPart,
307                                $navigationMode
308                            );
309                        }
310                    }
311
312                    if (!isset($map['parts'][$partId]['sections'][$sectionId])) {
313                        $map['parts'][$partId]['sections'][$sectionId]['id'] = $sectionId;
314                        $map['parts'][$partId]['sections'][$sectionId]['label'] = $section->getTitle();
315                        $map['parts'][$partId]['sections'][$sectionId]['visible'] = $section->isVisible();
316                        // phpcs:disable Generic.Files.LineLength
317                        $map['parts'][$partId]['sections'][$sectionId]['isCatAdaptive'] = CatUtils::isAssessmentSectionAdaptive($section);
318                        // phpcs:enable Generic.Files.LineLength
319                        $map['parts'][$partId]['sections'][$sectionId]['position'] = $offset;
320
321                        if ($section->hasTimeLimits()) {
322                            $map['parts'][$partId]['sections'][$sectionId]['timeConstraint'] = $this->getTimeConstraint(
323                                $session,
324                                $section,
325                                $navigationMode
326                            );
327                        }
328                    }
329
330                    $map['parts'][$partId]['sections'][$sectionId]['items'][$itemId] = $itemInfos;
331
332                    // update the stats
333                    if ($scope == RunnerMap::SCOPE_TEST) {
334                        $this->updateStats($map, $itemInfos);
335                        $this->updateStats($map['parts'][$partId], $itemInfos);
336                        $this->updateStats($map['parts'][$partId]['sections'][$sectionId], $itemInfos);
337                    }
338                    if ($scope == RunnerMap::SCOPE_PART) {
339                        $this->updateStats($map['parts'][$partId], $itemInfos);
340                        $this->updateStats($map['parts'][$partId]['sections'][$sectionId], $itemInfos);
341                    }
342                    if ($scope == RunnerMap::SCOPE_SECTION) {
343                        $this->updateStats($map['parts'][$partId]['sections'][$sectionId], $itemInfos);
344                    }
345
346                    $offset++;
347                    if (!$forceInformationalTitles || ($forceInformationalTitles && !$isItemInformational)) {
348                        $offsetSection++;
349                    };
350                }
351            }
352            // fallback in case of the delivery was compiled without the index of item href
353            if ($shouldBuildItemHrefIndex) {
354                \common_Logger::t('Store index of item href into the test state storage');
355                $storage = $this->getServiceLocator()->get(ExtendedStateService::SERVICE_ID);
356                $storage->storeItemHrefIndex($context->getTestExecutionUri(), $this->itemHrefIndex);
357            }
358        }
359
360        return $map;
361    }
362
363    /**
364     * Update the stats inside the target
365     * @param array $target
366     * @param array $itemInfos
367     */
368    protected function updateStats(&$target, $itemInfos)
369    {
370        if (!isset($target['stats'])) {
371            $target['stats'] = [
372                'questions' => 0,
373                'answered' => 0,
374                'flagged' => 0,
375                'viewed' => 0,
376                'total' => 0,
377                'questionsViewed' => 0,
378            ];
379        }
380
381        if (empty($itemInfos['informational'])) {
382            $target['stats']['questions'] ++;
383
384            if (!empty($itemInfos['answered'])) {
385                $target['stats']['answered'] ++;
386            }
387
388            if (!empty($itemInfos['viewed'])) {
389                $target['stats']['questionsViewed'] ++;
390            }
391        }
392
393        if (!empty($itemInfos['flagged'])) {
394            $target['stats']['flagged'] ++;
395        }
396
397        if (!empty($itemInfos['viewed'])) {
398            $target['stats']['viewed'] ++;
399        }
400
401        $target['stats']['total'] ++;
402    }
403
404    /**
405     * Get AssessmentItemRef objects.
406     *
407     * Get the AssessmentItemRef objects bound to a RouteItem object. In most of cases, an array of a single
408     * AssessmentItemRef object will be returned. But in case of the given $routeItem is a CAT Adaptive Placeholder,
409     * multiple AssessmentItemRef objects might be returned.
410     *
411     * @param RunnerServiceContext $context
412     * @param RouteItem $routeItem
413     * @param mixed $catSession A reference to a variable that will be fed with the CatSession object related to the
414     *                          $routeItem. In case the $routeItem is not bound to a CatSession object, $catSession will
415     *                          be set with false.
416     * @return array An array of AssessmentItemRef objects.
417     */
418    protected function getRouteItemAssessmentItemRefs(RunnerServiceContext $context, RouteItem $routeItem, &$catSession)
419    {
420        /* @var CatService */
421        $catService = $this->getServiceManager()->get(CatService::SERVICE_ID);
422        $compilationDirectory = $context->getCompilationDirectory()['private'];
423        $itemRefs = [];
424        $catSession = false;
425
426        if ($context->isAdaptive($routeItem->getAssessmentItemRef())) {
427            $catSession = $context->getCatSession($routeItem);
428
429            $itemRefs = $catService->getAssessmentItemRefByIdentifiers(
430                $compilationDirectory,
431                $context->getShadowTest($routeItem)
432            );
433        } else {
434            $itemRefs[] = $routeItem->getAssessmentItemRef();
435        }
436
437        return $itemRefs;
438    }
439
440     /**
441      * Get the relative position of the given RouteItem within the test.
442      * The position takes into account adaptive sections (and count items instead of placeholders).
443      *
444      * @param RunnerServiceContext $context
445      * @param RouteItem $routeItem
446      * @return int the offset position
447      */
448    protected function getOffsetPosition(RunnerServiceContext $context, RouteItem $currentRouteItem)
449    {
450        $session = $context->getTestSession();
451        $route = $session->getRoute();
452        $routeCount = $route->count();
453
454        $finalPosition = 0;
455
456        for ($i = 0; $i < $routeCount; $i++) {
457            $routeItem = $route->getRouteItemAt($i);
458
459            if ($routeItem !== $currentRouteItem) {
460                if (!$context->isAdaptive($routeItem->getAssessmentItemRef())) {
461                    $finalPosition++;
462                } else {
463                    $finalPosition += count($context->getShadowTest($routeItem));
464                }
465            } else {
466                break;
467            }
468        }
469
470        return $finalPosition;
471    }
472
473    /**
474     * Get the time constraint for the given component
475     * @param TestSession  $session the running test session
476     * @param QtiComponent $source the component with the time limits (testPart, section, itemRef)
477     * @param int          $navigationMode the testPart navigation mode
478     * @return QtiTimeConstraint the constraint
479     */
480    private function getTimeConstraint(TestSession $session, QtiComponent $source, $navigationMode)
481    {
482        $constraint = new QtiTimeConstraint(
483            $source,
484            $session->getTimerDuration($source->getIdentifier()),
485            $navigationMode,
486            true,
487            true,
488            $session->getTimerTarget()
489        );
490        $constraint->setTimer($session->getTimer());
491        return $constraint;
492    }
493
494     /**
495      * Get the label of a Map item
496      *
497      * @param RunnerServiceContext $context
498      * @param string $itemUri
499      * @param int $useTitle
500      * @return string the title
501      */
502    private function getItemLabel(RunnerServiceContext $context, $itemUri, $useTitle = false)
503    {
504        $label = '';
505
506        if ($useTitle) {
507            $label = $context->getItemIndexValue($itemUri, 'title');
508        }
509
510        if (!$label) {
511            $label = $context->getItemIndexValue($itemUri, 'label');
512        }
513
514        if (!$label) {
515            $item = new \core_kernel_classes_Resource($itemUri);
516            $label = $item->getLabel();
517        }
518        return $label;
519    }
520
521    /**
522     * @param AssessmentItemRef $itemRef
523     * @return array
524     */
525    protected function getAvailableCategories(AssessmentItemRef $itemRef)
526    {
527        $categoriesMap = array_flip($itemRef->getCategories()->getArrayCopy());
528
529        foreach ($this->getOverriddenOptionsRepository()->findAll() as $option) {
530            $categoryId = QtiRunnerConfig::CATEGORY_OPTION_PREFIX . $option->getId();
531
532            if ($option->isEnabled()) {
533                $categoriesMap[$categoryId] = true;
534            } else {
535                $categoriesMap = $this->removeFuzzyMatchedCategories($categoriesMap, $categoryId);
536            }
537        }
538
539        return array_keys($categoriesMap);
540    }
541
542    private function getOverriddenOptionsRepository(): OverriddenOptionsRepositoryInterface
543    {
544        /** @noinspection PhpIncompatibleReturnTypeInspection */
545        return $this->getServiceLocator()->get(OverriddenOptionsRepositoryInterface::SERVICE_ID);
546    }
547
548    /**
549     * Tool/option activation status in frontend is checked by fuzzy matched categories. This step was added
550     * in order to have the same behavior for tool/option deactivation through overrides
551     */
552    private function removeFuzzyMatchedCategories(array $categoriesMap, string $categoryId): array
553    {
554        $prefix = QtiRunnerConfig::CATEGORY_OPTION_PREFIX;
555        foreach (array_keys($categoriesMap) as $key) {
556            if (
557                strcasecmp(
558                    preg_replace('/[-_\s]/', '', str_replace($prefix, '', $key)),
559                    preg_replace('/[-_\s]/', '', str_replace($prefix, '', $categoryId))
560                ) === 0
561            ) {
562                unset($categoriesMap[$key]);
563            }
564        }
565
566        return $categoriesMap;
567    }
568}