tao-test/app/taoQtiTest/helpers/class.TestRunnerUtils.php

1286 lines
55 KiB
PHP
Raw Normal View History

2022-08-29 20:14:13 +02:00
<?php
/**
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; under version 2
* of the License (non-upgradable).
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* Copyright (c) 2014 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT);
*
*/
use oat\taoQtiTest\models\runner\RunnerService;
use oat\taoQtiTest\models\runner\time\TimerLabelFormatterService;
use qtism\common\datatypes\QtiDuration;
use qtism\data\NavigationMode;
use qtism\data\SubmissionMode;
use qtism\runtime\common\Container;
use qtism\runtime\tests\AssessmentTestSession;
use qtism\runtime\tests\AssessmentTestSessionException;
use qtism\runtime\tests\AssessmentItemSession;
use qtism\runtime\tests\AssessmentItemSessionState;
use qtism\runtime\tests\AssessmentTestSessionState;
use qtism\runtime\tests\Jump;
use qtism\runtime\tests\RouteItem;
use oat\taoQtiTest\models\ExtendedStateService;
use oat\taoQtiTest\models\QtiTestCompilerIndex;
use oat\taoQtiTest\models\runner\rubric\QtiRunnerRubric;
use qtism\common\datatypes\QtiString;
use oat\oatbox\service\ServiceManager;
use oat\taoQtiTest\models\runner\RunnerServiceContext;
/**
* Utility methods for the QtiTest Test Runner.
*
* @author Jérôme Bogaerts <jerome@taotesting.com>
*
*/
class taoQtiTest_helpers_TestRunnerUtils
{
/**
* Temporary helper until proper ServiceManager integration
* @return ServiceManager
*/
protected static function getServiceManager()
{
return ServiceManager::getServiceManager();
}
/**
* Temporary helper until proper ServiceManager integration
* @return ExtendedStateService
*/
public static function getExtendedStateService()
{
return self::getServiceManager()->get(ExtendedStateService::SERVICE_ID);
}
/**
* Get the ServiceCall object representing how to call the current Assessment Item to be
* presented to a candidate in a given Assessment Test $session.
*
* @param AssessmentTestSession $session An AssessmentTestSession Object.
* @param string $testDefinition URI The URI of the knowledge base resource representing the folder where the QTI Test Definition is stored.
* @param string $testCompilation URI The URI of the knowledge base resource representing the folder where the QTI Test Compilation is stored.
* @return tao_models_classes_service_ServiceCall A ServiceCall object.
*/
public static function buildItemServiceCall(AssessmentTestSession $session, $testDefinitionUri, $testCompilationUri)
{
$href = $session->getCurrentAssessmentItemRef()->getHref();
// retrive itemUri & itemPath.
$parts = explode('|', $href);
$definition = new core_kernel_classes_Resource(RunnerService::INSTANCE_TEST_ITEM_RUNNER_SERVICE);
$serviceCall = new tao_models_classes_service_ServiceCall($definition);
$uriResource = new core_kernel_classes_Resource(taoItems_models_classes_ItemsService::INSTANCE_FORMAL_PARAM_ITEM_URI);
$uriParam = new tao_models_classes_service_ConstantParameter($uriResource, $parts[0]);
$serviceCall->addInParameter($uriParam);
$pathResource = new core_kernel_classes_Resource(taoItems_models_classes_ItemsService::INSTANCE_FORMAL_PARAM_ITEM_PATH);
$pathParam = new tao_models_classes_service_ConstantParameter($pathResource, $parts[1]);
$serviceCall->addInParameter($pathParam);
$dataPathResource = new core_kernel_classes_Resource(taoItems_models_classes_ItemsService::INSTANCE_FORMAL_PARAM_ITEM_DATA_PATH);
$dataPathParam = new tao_models_classes_service_ConstantParameter($dataPathResource, $parts[2]);
$serviceCall->addInParameter($dataPathParam);
$parentServiceCallIdResource = new core_kernel_classes_Resource(RunnerService::INSTANCE_FORMAL_PARAM_TEST_ITEM_RUNNER_PARENT_CALL_ID);
$parentServiceCallIdParam = new tao_models_classes_service_ConstantParameter($parentServiceCallIdResource, $session->getSessionId());
$serviceCall->addInParameter($parentServiceCallIdParam);
$testDefinitionResource = new core_kernel_classes_Resource(taoQtiTest_models_classes_QtiTestService::INSTANCE_FORMAL_PARAM_TEST_DEFINITION);
$testDefinitionParam = new tao_models_classes_service_ConstantParameter($testDefinitionResource, $testDefinitionUri);
$serviceCall->addInParameter($testDefinitionParam);
$testCompilationResource = new core_kernel_classes_Resource(taoQtiTest_models_classes_QtiTestService::INSTANCE_FORMAL_PARAM_TEST_COMPILATION);
$testCompilationParam = new tao_models_classes_service_ConstantParameter($testCompilationResource, $testCompilationUri);
$serviceCall->addInParameter($testCompilationParam);
return $serviceCall;
}
/**
* Build the Service Call ID of the current Assessment Item to be presented to a candidate
* in a given Assessment Test $session.
*
* @return string A service call id composed of the session identifier, the identifier of the item and its occurence number in the route.
*/
public static function buildServiceCallId(AssessmentTestSession $session)
{
$sessionId = $session->getSessionId();
$itemId = $session->getCurrentAssessmentItemRef()->getIdentifier();
$occurence = $session->getCurrentAssessmentItemRefOccurence();
return "${sessionId}.${itemId}.${occurence}";
}
/**
* Set the initial outcomes defined in the rdf outcome map configuration file
*
* @param AssessmentTestSession $session
* @param \oat\oatbox\user\User $testTaker
* @throws common_exception_Error
* @throws common_ext_ExtensionException
*/
public static function setInitialOutcomes(AssessmentTestSession $session, \oat\oatbox\user\User $testTaker)
{
$rdfOutcomeMap = \common_ext_ExtensionsManager::singleton()->getExtensionById('taoQtiTest')->getConfig('rdfOutcomeMap');
if (is_array($rdfOutcomeMap)) {
foreach ($rdfOutcomeMap as $outcomeId => $rdfPropUri) {
//set outcome value
$values = $testTaker->getPropertyValues($rdfPropUri);
$outcome = $session->getVariable($outcomeId);
if (!is_null($outcome) && count($values)) {
$outcome->setValue(new QtiString((string)$values[0]));
}
}
}
}
/**
* Preserve the outcomes variables set in the "rdfOutcomeMap" config
* This is required to prevent those special outcomes from being reset before every outcome processing
*
* @param AssessmentTestSession $session
* @throws common_ext_ExtensionException
*/
public static function preserveOutcomes(AssessmentTestSession $session)
{
//preserve the special outcomes defined in the rdfOutcomeMap config
$rdfOutcomeMap = \common_ext_ExtensionsManager::singleton()->getExtensionById('taoQtiTest')->getConfig('rdfOutcomeMap');
if (is_array($rdfOutcomeMap) === true) {
$session->setPreservedOutcomeVariables(array_keys($rdfOutcomeMap));
}
}
/**
* Whether or not the current Assessment Item to be presented to the candidate is timed-out. By timed-out
* we mean:
*
* * current Assessment Test level time limits are not respected OR,
* * current Test Part level time limits are not respected OR,
* * current Assessment Section level time limits are not respected OR,
* * current Assessment Item level time limits are not respected.
*
* @param AssessmentTestSession $session The AssessmentTestSession object you want to know it is timed-out.
* @return boolean
*/
public static function isTimeout(AssessmentTestSession $session)
{
try {
$session->checkTimeLimits(false, true, false);
} catch (AssessmentTestSessionException $e) {
return true;
}
return false;
}
/**
* Get the URI referencing the current Assessment Item (in the knowledge base)
* to be presented to the candidate.
*
* @param AssessmentTestSession $session An AssessmentTestSession object.
* @return string A URI.
*/
public static function getCurrentItemUri(AssessmentTestSession $session)
{
$href = $session->getCurrentAssessmentItemRef()->getHref();
$parts = explode('|', $href);
return $parts[0];
}
/**
* Build the URL to be called to perform a given action on the Test Runner controller.
*
* @param AssessmentTestSession $session An AssessmentTestSession object.
* @param string $action The action name e.g. 'moveForward', 'moveBackward', 'skip', ...
* @param string $qtiTestDefinitionUri The URI of a reference to an Assessment Test definition in the knowledge base.
* @param string $qtiTestCompilationUri The Uri of a reference to an Assessment Test compilation in the knowledge base.
* @param string $standalone
* @return string A URL to be called to perform an action.
*/
public static function buildActionCallUrl(AssessmentTestSession $session, $action, $qtiTestDefinitionUri, $qtiTestCompilationUri, $standalone)
{
return _url($action, 'TestRunner', null, [
'QtiTestDefinition' => $qtiTestDefinitionUri,
'QtiTestCompilation' => $qtiTestCompilationUri,
'standalone' => $standalone,
'serviceCallId' => $session->getSessionId(),
]);
}
public static function buildServiceApi(AssessmentTestSession $session, $qtiTestDefinitionUri, $qtiTestCompilationUri)
{
$serviceCall = self::buildItemServiceCall($session, $qtiTestDefinitionUri, $qtiTestCompilationUri);
$itemServiceCallId = self::buildServiceCallId($session);
return tao_helpers_ServiceJavascripts::getServiceApi($serviceCall, $itemServiceCallId);
}
/**
* Tell the client to not cache the current request. Supports HTTP 1.0 to 1.1.
*/
public static function noHttpClientCache()
{
// From stackOverflow: http://stackoverflow.com/questions/49547/making-sure-a-web-page-is-not-cached-across-all-browsers
// license is Creative Commons Attribution Share Alike (author Edward Wilde)
header('Cache-Control: no-cache, no-store, must-revalidate'); // HTTP 1.1.
header('Pragma: no-cache'); // HTTP 1.0.
header('Expires: 0'); // Proxies.
}
/**
* Make the candidate interact with the current Assessment Item to be presented. A new attempt
* will begin automatically if the candidate still has available attempts. Otherwise,
* nothing happends.
*
* @param AssessmentTestSession $session The AssessmentTestSession you want to make the candidate interact with.
*/
public static function beginCandidateInteraction(AssessmentTestSession $session)
{
$itemSession = $session->getCurrentAssessmentItemSession();
$itemSessionState = $itemSession->getState();
$initial = $itemSessionState === AssessmentItemSessionState::INITIAL;
$suspended = $itemSessionState === AssessmentItemSessionState::SUSPENDED;
$remainingAttempts = $itemSession->getRemainingAttempts();
$attemptable = $remainingAttempts === -1 || $remainingAttempts > 0;
if ($initial === true || ($suspended === true && $attemptable === true)) {
// Begin the very first attempt.
$session->beginAttempt();
}
// Otherwise, the item is not attemptable bt the candidate.
}
/**
* Whether or not the candidate taking the given $session is allowed
* to skip the presented Assessment Item.
*
* @param AssessmentTestSession $session A given AssessmentTestSession object.
* @return boolean
*/
public static function doesAllowSkipping(AssessmentTestSession $session)
{
$doesAllowSkipping = true;
$submissionMode = $session->getCurrentSubmissionMode();
$routeItem = $session->getRoute()->current();
$routeControl = $routeItem->getItemSessionControl();
if (empty($routeControl) === false) {
$doesAllowSkipping = $routeControl->getItemSessionControl()->doesAllowSkipping();
}
return $doesAllowSkipping && $submissionMode === SubmissionMode::INDIVIDUAL;
}
/**
* Whether or not the candidate's response is validated
*
* @param AssessmentTestSession $session A given AssessmentTestSession object.
* @return boolean
*/
public static function doesValidateResponses(AssessmentTestSession $session)
{
$doesValidateResponses = true;
$submissionMode = $session->getCurrentSubmissionMode();
$routeItem = $session->getRoute()->current();
$routeControl = $routeItem->getItemSessionControl();
if (empty($routeControl) === false) {
$doesValidateResponses = $routeControl->getItemSessionControl()->mustValidateResponses();
}
return $doesValidateResponses && $submissionMode === SubmissionMode::INDIVIDUAL;
}
/**
* Whether or not the candidate taking the given $session is allowed to make
* a comment on the presented Assessment Item.
*
* @param AssessmentTestSession $session A given AssessmentTestSession object.
* @return boolean
*/
public static function doesAllowComment(AssessmentTestSession $session)
{
$doesAllowComment = false;
$routeItem = $session->getRoute()->current();
$routeControl = $routeItem->getItemSessionControl();
if (empty($routeControl) === false) {
$doesAllowComment = $routeControl->getItemSessionControl()->doesAllowComment();
}
return $doesAllowComment;
}
/**
* Build an array where each cell represent a time constraint (a.k.a. time limits)
* in force. Each cell is actually an array with two keys:
*
* * 'source': The identifier of the QTI component emitting the constraint (e.g. AssessmentTest, TestPart, AssessmentSection, AssessmentItemRef).
* * 'seconds': The number of remaining seconds until it times out.
*
* @param AssessmentTestSession $session An AssessmentTestSession object.
* @return array
*/
public static function buildTimeConstraints(AssessmentTestSession $session)
{
$constraints = [];
/** @var TimerLabelFormatterService $timerLabelFormatter */
$timerLabelFormatter = static::getServiceManager()->get(TimerLabelFormatterService::SERVICE_ID);
foreach ($session->getTimeConstraints() as $tc) {
// Only consider time constraints in force.
if ($tc->getMaximumRemainingTime() !== false) {
$label = method_exists($tc->getSource(), 'getTitle') ? $tc->getSource()->getTitle() : $tc->getSource()->getIdentifier();
$constraints[] = [
'label' => $timerLabelFormatter->format($label),
'source' => $tc->getSource()->getIdentifier(),
'seconds' => self::getDurationWithMicroseconds($tc->getMaximumRemainingTime()),
'allowLateSubmission' => $tc->allowLateSubmission(),
'qtiClassName' => $tc->getSource()->getQtiClassName()
];
}
}
return $constraints;
}
/**
* Build an array where each cell represent a possible Assessment Item a candidate
* can jump on during a given $session. Each cell is an array with two keys:
*
* * 'identifier': The identifier of the Assessment Item the candidate is allowed to jump on.
* * 'position': The position in the route of the Assessment Item.
*
* @param AssessmentTestSession $session A given AssessmentTestSession object.
* @return array
*/
public static function buildPossibleJumps(AssessmentTestSession $session)
{
$jumps = [];
foreach ($session->getPossibleJumps() as $jumpObject) {
$jump = [];
$jump['identifier'] = $jumpObject->getTarget()->getAssessmentItemRef()->getIdentifier();
$jump['position'] = $jumpObject->getPosition();
$jumps[] = $jump;
}
return $jumps;
}
/**
* Build the context of the given candidate test $session as an associative array. This array
* is especially usefull to transmit the test context to a view as JSON data.
*
* The returned array contains the following keys:
*
* * state: The state of test session.
* * navigationMode: The current navigation mode.
* * submissionMode: The current submission mode.
* * remainingAttempts: The number of remaining attempts for the current item.
* * isAdaptive: Whether or not the current item is adaptive.
* * itemIdentifier: The identifier of the current item.
* * itemSessionState: The state of the current assessment item session.
* * timeConstraints: The time constraints in force.
* * testTitle: The title of the test.
* * testPartId: The identifier of the current test part.
* * sectionTitle: The title of the current section.
* * numberItems: The total number of items eligible to the candidate.
* * numberCompleted: The total number items considered to be completed by the candidate.
* * moveForwardUrl: The URL to be dereferenced to perform a moveNext on the session.
* * moveBackwardUrl: The URL to be dereferenced to perform a moveBack on the session.
* * skipUrl: The URL to be dereferenced to perform a skip on the session.
* * commentUrl: The URL to be dereferenced to leave a comment about the current item.
* * timeoutUrl: The URL to be dereferenced when the time constraints in force reach their maximum.
* * canMoveBackward: Whether or not the candidate is allowed/able to move backward.
* * jumps: The possible jumpers the candidate is allowed to undertake among eligible items.
* * itemServiceApiCall: The JavaScript code to be executed to instantiate the current item.
* * rubrics: The XHTML compiled content of the rubric blocks to be displayed for the current item if any.
* * allowComment: Whether or not the candidate is allowed to leave a comment about the current item.
* * allowSkipping: Whether or not the candidate is allowed to skip the current item.
* * considerProgress: Whether or not the test driver view must consider to give a test progress feedback.
*
* @param AssessmentTestSession $session A given AssessmentTestSession object.
* @param array $testMeta An associative array containing meta-data about the test definition taken by the candidate.
* @param QtiTestCompilerIndex $itemIndex
* @param string $qtiTestDefinitionUri The URI of a reference to an Assessment Test definition in the knowledge base.
* @param string $qtiTestCompilationUri The Uri of a reference to an Assessment Test compilation in the knowledge base.
* @param string $standalone
* @param array $compilationDirs An array containing respectively the private and public compilation directories.
* @return array The context of the candidate session.
*/
public static function buildAssessmentTestContext(AssessmentTestSession $session, array $testMeta, $itemIndex, $qtiTestDefinitionUri, $qtiTestCompilationUri, $standalone, $compilationDirs)
{
$context = [];
// The state of the test session.
$context['state'] = $session->getState();
// Default values for the test session context.
$context['navigationMode'] = null;
$context['submissionMode'] = null;
$context['remainingAttempts'] = 0;
$context['isAdaptive'] = false;
$hasBeenPaused = false;
if (common_ext_ExtensionsManager::singleton()->isEnabled('taoProctoring')) {
$hasBeenPaused = \oat\taoProctoring\helpers\DeliveryHelper::getHasBeenPaused($session->getSessionId());
}
$context['hasBeenPaused'] = $hasBeenPaused;
if ($session->getState() === AssessmentTestSessionState::INTERACTING) {
$config = common_ext_ExtensionsManager::singleton()->getExtensionById('taoQtiTest')->getConfig('testRunner');
// The navigation mode.
$context['navigationMode'] = $session->getCurrentNavigationMode();
// The submission mode.
$context['submissionMode'] = $session->getCurrentSubmissionMode();
// The number of remaining attempts for the current item.
$context['remainingAttempts'] = $session->getCurrentRemainingAttempts();
// Whether or not the current step is time out.
$context['isTimeout'] = self::isTimeout($session);
// The identifier of the current item.
$context['itemIdentifier'] = $session->getCurrentAssessmentItemRef()->getIdentifier();
// The state of the current AssessmentTestSession.
$context['itemSessionState'] = $session->getCurrentAssessmentItemSession()->getState();
// Whether the current item is adaptive.
$context['isAdaptive'] = $session->isCurrentAssessmentItemAdaptive();
// Whether the current item is the very last one of the test.
$context['isLast'] = $session->getRoute()->isLast();
// The current position in the route.
$context['itemPosition'] = $session->getRoute()->getPosition();
// Time constraints.
$context['timeConstraints'] = self::buildTimeConstraints($session);
// Test title.
$context['testTitle'] = $session->getAssessmentTest()->getTitle();
// Test Part title.
$context['testPartId'] = $session->getCurrentTestPart()->getIdentifier();
// Current Section title.
$context['sectionTitle'] = $session->getCurrentAssessmentSection()->getTitle();
// Number of items composing the test session.
$context['numberItems'] = $session->getRouteCount(AssessmentTestSession::ROUTECOUNT_FLOW);
// Number of items completed during the test session.
$context['numberCompleted'] = self::testCompletion($session);
// Number of items presented during the test session.
$context['numberPresented'] = $session->numberPresented();
// Whether or not the progress of the test can be inferred.
$context['considerProgress'] = self::considerProgress($session, $testMeta, $config);
// Whether or not the deepest current section is visible.
$context['isDeepestSectionVisible'] = $session->getCurrentAssessmentSection()->isVisible();
// The URLs to be called to move forward/backward in the Assessment Test Session or skip or comment.
$context['moveForwardUrl'] = self::buildActionCallUrl($session, 'moveForward', $qtiTestDefinitionUri, $qtiTestCompilationUri, $standalone);
$context['moveBackwardUrl'] = self::buildActionCallUrl($session, 'moveBackward', $qtiTestDefinitionUri, $qtiTestCompilationUri, $standalone);
$context['nextSectionUrl'] = self::buildActionCallUrl($session, 'nextSection', $qtiTestDefinitionUri, $qtiTestCompilationUri, $standalone);
$context['skipUrl'] = self::buildActionCallUrl($session, 'skip', $qtiTestDefinitionUri, $qtiTestCompilationUri, $standalone);
$context['commentUrl'] = self::buildActionCallUrl($session, 'comment', $qtiTestDefinitionUri, $qtiTestCompilationUri, $standalone);
$context['timeoutUrl'] = self::buildActionCallUrl($session, 'timeout', $qtiTestDefinitionUri, $qtiTestCompilationUri, $standalone);
$context['endTestSessionUrl'] = self::buildActionCallUrl($session, 'endTestSession', $qtiTestDefinitionUri, $qtiTestCompilationUri, $standalone);
$context['keepItemTimedUrl'] = self::buildActionCallUrl($session, 'keepItemTimed', $qtiTestDefinitionUri, $qtiTestCompilationUri, $standalone);
// If the candidate is allowed to move backward e.g. first item of the test.
$context['canMoveBackward'] = $session->canMoveBackward();
// The places in the test session where the candidate is allowed to jump to.
$context['jumps'] = self::buildPossibleJumps($session);
// The test review screen setup
if (!empty($config['test-taker-review']) && $context['considerProgress']) {
// The navigation map in order to build the test navigator
$navigator = self::getNavigatorMap($session, $itemIndex);
if ($navigator !== NavigationMode::LINEAR) {
$context['navigatorMap'] = $navigator['map'];
$context['itemFlagged'] = self::getItemFlag($session, $context['itemPosition']);
} else {
$navigator = self::countItems($session);
}
// Extract the progression stats
$context['numberFlagged'] = $navigator['numberItemsFlagged'];
$context['numberItemsPart'] = $navigator['numberItemsPart'];
$context['numberItemsSection'] = $navigator['numberItemsSection'];
$context['numberCompletedPart'] = $navigator['numberCompletedPart'];
$context['numberCompletedSection'] = $navigator['numberCompletedSection'];
$context['numberPresentedPart'] = $navigator['numberPresentedPart'];
$context['numberPresentedSection'] = $navigator['numberPresentedSection'];
$context['numberFlaggedPart'] = $navigator['numberFlaggedPart'];
$context['numberFlaggedSection'] = $navigator['numberFlaggedSection'];
$context['itemPositionPart'] = $navigator['itemPositionPart'];
$context['itemPositionSection'] = $navigator['itemPositionSection'];
// The URLs to be called to move to a particular item in the Assessment Test Session or mark item for later review.
$context['jumpUrl'] = self::buildActionCallUrl($session, 'jumpTo', $qtiTestDefinitionUri, $qtiTestCompilationUri, $standalone);
$context['markForReviewUrl'] = self::buildActionCallUrl($session, 'markForReview', $qtiTestDefinitionUri, $qtiTestCompilationUri, $standalone);
} else {
// Setup data for progress bar when displaying position and timed section exit control
$numberItems = self::countItems($session);
$context['numberCompletedPart'] = $numberItems['numberCompletedPart'];
$context['numberCompletedSection'] = $numberItems['numberCompletedSection'];
$context['numberItemsSection'] = $numberItems['numberItemsSection'];
$context['numberItemsPart'] = $numberItems['numberItemsPart'];
$context['itemPositionPart'] = $numberItems['itemPositionPart'];
$context['itemPositionSection'] = $numberItems['itemPositionSection'];
}
// The code to be executed to build the ServiceApi object to be injected in the QTI Item frame.
$context['itemServiceApiCall'] = self::buildServiceApi($session, $qtiTestDefinitionUri, $qtiTestCompilationUri);
// Rubric Blocks.
/** @var QtiRunnerRubric $rubricBlockHelper */
$rubricBlockHelper = self::getServiceManager()->get(QtiRunnerRubric::SERVICE_ID);
$context['rubrics'] = $rubricBlockHelper->getRubricBlock($session->getRoute()->current(), $session, $compilationDirs);
// Comment allowed? Skipping allowed? Logout or Exit allowed ?
$context['allowComment'] = self::doesAllowComment($session);
$context['allowSkipping'] = self::doesAllowSkipping($session);
$context['exitButton'] = self::doesAllowExit($session);
$context['logoutButton'] = self::doesAllowLogout($session);
$context['categories'] = self::getCategories($session);
// loads the specific config into the context object
$configMap = [
// name in config => name in context object
'timerWarning' => 'timerWarning',
'timerWarningForScreenreader' => 'timerWarningForScreenreader',
'progress-indicator' => 'progressIndicator',
'progress-indicator-scope' => 'progressIndicatorScope',
'test-taker-review' => 'reviewScreen',
'test-taker-review-region' => 'reviewRegion',
'test-taker-review-scope' => 'reviewScope',
'test-taker-review-prevents-unseen' => 'reviewPreventsUnseen',
'test-taker-review-can-collapse' => 'reviewCanCollapse',
'next-section' => 'nextSection',
'keep-timer-up-to-timeout' => 'keepTimerUpToTimeout',
];
foreach ($configMap as $configKey => $contextKey) {
if (isset($config[$configKey])) {
$context[$contextKey] = $config[$configKey];
}
}
// optionally extend the context
if (isset($config['extraContextBuilder']) && class_exists($config['extraContextBuilder'])) {
$builder = new $config['extraContextBuilder']();
if ($builder instanceof \oat\taoQtiTest\models\TestContextBuilder) {
$builder->extendAssessmentTestContext(
$context,
$session,
$testMeta,
$qtiTestDefinitionUri,
$qtiTestCompilationUri,
$standalone,
$compilationDirs
);
} else {
common_Logger::d('Try to use an extra context builder class that is not an instance of \\oat\\taoQtiTest\\models\\TestContextBuilder!');
}
}
}
return $context;
}
/**
* Gets the item reference for a particular item in the test
*
* @param AssessmentTestSession $session
* @param string|Jump|RouteItem $itemPosition
* @return null|string
*/
public static function getItemRef(AssessmentTestSession $session, $itemPosition, RunnerServiceContext $context = null)
{
$sessionId = $session->getSessionId();
$itemRef = null;
$routeItem = null;
if ($itemPosition && is_object($itemPosition)) {
if ($itemPosition instanceof RouteItem) {
$routeItem = $itemPosition;
} elseif ($itemPosition instanceof Jump) {
$routeItem = $itemPosition->getTarget();
}
} elseif ($context) {
$itemId = '';
$itemPosition = $context->getItemPositionInRoute($itemPosition, $itemId);
if ($itemId !== '') {
$itemRef = $itemId;
} else {
$routeItem = $session->getRoute()->getRouteItemAt($itemPosition);
}
} else {
$jumps = $session->getPossibleJumps();
foreach ($jumps as $jump) {
if ($itemPosition == $jump->getPosition()) {
$routeItem = $jump->getTarget();
break;
}
}
}
if ($routeItem) {
$itemRef = (string)$routeItem->getAssessmentItemRef();
}
return $itemRef;
}
/**
* Sets an item to be reviewed
* @param AssessmentTestSession $session
* @param string|Jump|RouteItem $itemPosition
* @param bool $flag
* @return bool
* @throws common_exception_Error
*/
public static function setItemFlag(AssessmentTestSession $session, $itemPosition, $flag, RunnerServiceContext $context = null)
{
$itemRef = self::getItemRef($session, $itemPosition, $context);
$result = self::getExtendedStateService()->setItemFlag($session->getSessionId(), $itemRef, $flag);
return $result;
}
/**
* Gets the marked for review state of an item
* @param AssessmentTestSession $session
* @param string|Jump|RouteItem $itemPosition
* @return bool
* @throws common_exception_Error
*/
public static function getItemFlag(AssessmentTestSession $session, $itemPosition, RunnerServiceContext $context = null)
{
$result = false;
$itemRef = self::getItemRef($session, $itemPosition, $context);
if ($itemRef) {
$result = self::getExtendedStateService()->getItemFlag($session->getSessionId(), $itemRef);
}
return $result;
}
/**
* Gets the usage of an item
* @param RouteItem $routeItem
* @return string Return the usage, can be: default, informational, seeding
*/
public static function getItemUsage(RouteItem $routeItem)
{
$itemRef = $routeItem->getAssessmentItemRef();
$categories = $itemRef->getCategories()->getArrayCopy();
$prefixCategory = 'x-tao-itemusage-';
$prefixCategoryLen = strlen($prefixCategory);
foreach ($categories as $category) {
if (!strncmp($category, $prefixCategory, $prefixCategoryLen)) {
// extract the option name from the category, transform to camelCase if needed
return lcfirst(str_replace(' ', '', ucwords(strtr(substr($category, $prefixCategoryLen), ['-' => ' ', '_' => ' ']))));
}
}
return 'default';
}
/**
* Checks if an item is informational
* @param RouteItem $routeItem
* @param AssessmentItemSession $itemSession
* @return bool
*/
public static function isItemInformational(RouteItem $routeItem, AssessmentItemSession $itemSession)
{
return !count($itemSession->getAssessmentItem()->getResponseDeclarations()) || 'informational' == self::getItemUsage($routeItem);
}
/**
* Checks if an item has been completed
* @param RouteItem $routeItem
* @param AssessmentItemSession $itemSession
* @param bool $partially (optional) Whether or not consider partially responded sessions as responded.
* @return bool
*/
public static function isItemCompleted(RouteItem $routeItem, AssessmentItemSession $itemSession, $partially = true)
{
$completed = false;
if ($routeItem->getTestPart()->getNavigationMode() === NavigationMode::LINEAR) {
// In linear mode, we consider the item completed if it was presented.
if ($itemSession->isPresented() === true) {
$completed = true;
}
} else {
// In nonlinear mode we consider:
// - an adaptive item completed if it's completion status is 'completed'.
// - a non-adaptive item to be completed if it is responded.
$isAdaptive = $itemSession->getAssessmentItem()->isAdaptive();
if ($isAdaptive === true && $itemSession['completionStatus']->getValue() === AssessmentItemSession::COMPLETION_STATUS_COMPLETED) {
$completed = true;
} elseif ($isAdaptive === false && $itemSession->isResponded($partially) === true) {
$completed = true;
}
}
return $completed;
}
/**
* Gets infos about a particular item
* @param AssessmentTestSession $session
* @param Jump $jump
* @return array
*/
private static function getItemInfo(AssessmentTestSession $session, Jump $jump)
{
$itemSession = $jump->getItemSession();
$routeItem = $jump->getTarget();
return [
'remainingAttempts' => $itemSession->getRemainingAttempts(),
'answered' => self::isItemCompleted($routeItem, $itemSession),
'viewed' => $itemSession->isPresented(),
'flagged' => self::getItemFlag($session, $jump),
'position' => $jump->getPosition()
];
}
/**
* Builds a map of available jumps and count the flagged items
* @param AssessmentTestSession $session
* @param array $jumps
* @return array
*/
private static function getJumpsMap(AssessmentTestSession $session, $jumps)
{
$jumpsMap = [];
$numberItemsFlagged = 0;
foreach ($jumps as $jump) {
$routeItem = $jump->getTarget();
$partId = $routeItem->getTestPart()->getIdentifier();
$sections = $routeItem->getAssessmentSections();
$sections->rewind();
$sectionId = key(current($sections));
$itemId = $routeItem->getAssessmentItemRef()->getIdentifier();
$jumpsMap[$partId][$sectionId][$itemId] = self::getItemInfo($session, $jump);
if ($jumpsMap[$partId][$sectionId][$itemId]['flagged']) {
$numberItemsFlagged++;
}
}
return [
'flagged' => $numberItemsFlagged,
'map' => $jumpsMap,
];
}
/**
* Gets the section map for navigation between test parts, sections and items.
*
* @param AssessmentTestSession $session
* @param QtiTestCompilerIndex $itemIndex
* @return array A navigator map (parts, sections, items so on)
*/
private static function getNavigatorMap(AssessmentTestSession $session, $itemIndex)
{
// get jumps
$jumps = $session->getPossibleJumps();
// no jumps, notify linear-mode
if (!$jumps->count()) {
return NavigationMode::LINEAR;
}
$jumpsMapInfo = self::getJumpsMap($session, $jumps);
$jumpsMap = $jumpsMapInfo['map'];
$numberItemsFlagged = $jumpsMapInfo['flagged'];
// the active test-part identifier
$activePart = $session->getCurrentTestPart()->getIdentifier();
// the active section identifier
$activeSection = $session->getCurrentAssessmentSection()->getIdentifier();
$route = $session->getRoute();
$activeItem = $session->getCurrentAssessmentItemRef()->getIdentifier();
if (isset($jumpsMap[$activePart][$activeSection][$activeItem])) {
$jumpsMap[$activePart][$activeSection][$activeItem]['active'] = true;
}
// current position
$oldPosition = $route->getPosition();
$route->setPosition($oldPosition);
// get config for the sequence number option
$config = common_ext_ExtensionsManager::singleton()->getExtensionById('taoQtiTest')->getConfig('testRunner');
$forceTitles = !empty($config['test-taker-review-force-title']);
$uniqueTitle = isset($config['test-taker-review-item-title']) ? $config['test-taker-review-item-title'] : '%d';
$useTitle = !empty($config['test-taker-review-use-title']);
$language = \common_session_SessionManager::getSession()->getInterfaceLanguage();
$returnValue = [];
$testParts = [];
$testPartIdx = 0;
$numberItemsPart = 0;
$numberItemsSection = 0;
$numberCompletedPart = 0;
$numberCompletedSection = 0;
$numberPresentedPart = 0;
$numberPresentedSection = 0;
$numberFlaggedPart = 0;
$numberFlaggedSection = 0;
$itemPositionPart = 0;
$itemPositionSection = 0;
$itemPosition = $session->getRoute()->getPosition();
foreach ($jumps as $jump) {
$testPart = $jump->getTarget()->getTestPart();
$id = $testPart->getIdentifier();
if (isset($testParts[$id])) {
continue;
}
$sections = [];
if ($testPart->getNavigationMode() == NavigationMode::NONLINEAR) {
$firstPositionPart = PHP_INT_MAX;
foreach ($testPart->getAssessmentSections() as $sectionId => $section) {
$completed = 0;
$presented = 0;
$flagged = 0;
$items = [];
$firstPositionSection = PHP_INT_MAX;
$positionInSection = 0;
foreach ($section->getSectionParts() as $itemId => $item) {
if (isset($jumpsMap[$id][$sectionId][$itemId])) {
$jumpInfo = $jumpsMap[$id][$sectionId][$itemId];
$itemUri = strstr($item->getHref(), '|', true);
$resItem = new \core_kernel_classes_Resource($itemUri);
if ($jumpInfo['answered']) {
++$completed;
}
if ($jumpInfo['viewed']) {
++$presented;
}
if ($jumpInfo['flagged']) {
++$flagged;
}
if ($forceTitles) {
$label = sprintf($uniqueTitle, ++$positionInSection);
} else {
if ($useTitle) {
$label = $itemIndex->getItemValue($itemUri, $language, 'title');
} else {
$label = '';
}
if (!$label) {
$label = $itemIndex->getItemValue($itemUri, $language, 'label');
}
if (!$label) {
$label = $resItem->getLabel();
}
}
$items[] = array_merge(
[
'id' => $itemId,
'label' => $label,
],
$jumpInfo
);
$firstPositionPart = min($firstPositionPart, $jumpInfo['position']);
$firstPositionSection = min($firstPositionSection, $jumpInfo['position']);
}
}
$sectionData = [
'id' => $sectionId,
'active' => $sectionId === $activeSection,
'label' => $section->getTitle(),
'answered' => $completed,
'items' => $items
];
$sections[] = $sectionData;
if ($sectionData['active']) {
$numberItemsSection = count($items);
$itemPositionSection = $itemPosition - $firstPositionSection;
$numberCompletedSection = $completed;
$numberPresentedSection = $presented;
$numberFlaggedSection = $flagged;
}
if ($id === $activePart) {
$numberItemsPart += count($items);
$numberCompletedPart += $completed;
$numberPresentedPart += $presented;
$numberFlaggedPart += $flagged;
}
}
if ($id === $activePart) {
$itemPositionPart = $itemPosition - $firstPositionPart;
}
}
$data = [
'id' => $id,
'sections' => $sections,
'active' => $id === $activePart,
'label' => __('Part %d', ++$testPartIdx),
];
if (empty($sections)) {
$item = current(current($jumpsMap[$id]));
$data['position'] = $item['position'];
$data['itemId'] = key(current($jumpsMap[$id]));
}
$returnValue[] = $data;
$testParts[$id] = false;
}
return [
'map' => $returnValue,
'numberItemsFlagged' => $numberItemsFlagged,
'numberItemsPart' => $numberItemsPart,
'numberItemsSection' => $numberItemsSection,
'numberCompletedPart' => $numberCompletedPart,
'numberCompletedSection' => $numberCompletedSection,
'numberPresentedPart' => $numberPresentedPart,
'numberPresentedSection' => $numberPresentedSection,
'numberFlaggedPart' => $numberFlaggedPart,
'numberFlaggedSection' => $numberFlaggedSection,
'itemPositionPart' => $itemPositionPart,
'itemPositionSection' => $itemPositionSection,
];
}
/**
* Gets the number of items within the current section and the current part.
*
* @param AssessmentTestSession $session
* @return array The list of counters (numberItemsSection and numberItemsPart)
*/
private static function countItems(AssessmentTestSession $session)
{
// get jumps
$jumps = self::getTestMap($session);
// the active test-part identifier
$activePart = $session->getCurrentTestPart()->getIdentifier();
// the active section identifier
$activeSection = $session->getCurrentAssessmentSection()->getIdentifier();
$jumpsMapInfo = self::getJumpsMap($session, $jumps);
$jumpsMap = $jumpsMapInfo['map'];
$numberItemsFlagged = $jumpsMapInfo['flagged'];
$testParts = [];
$numberItemsPart = 0;
$numberItemsSection = 0;
$numberCompletedPart = 0;
$numberCompletedSection = 0;
$numberPresentedPart = 0;
$numberPresentedSection = 0;
$numberFlaggedPart = 0;
$numberFlaggedSection = 0;
$itemPositionPart = 0;
$itemPositionSection = 0;
$itemPosition = $session->getRoute()->getPosition();
foreach ($jumps as $jump) {
$testPart = $jump->getTarget()->getTestPart();
$id = $testPart->getIdentifier();
if (isset($testParts[$id])) {
continue;
}
$testParts[$id] = true;
$firstPositionPart = PHP_INT_MAX;
foreach ($testPart->getAssessmentSections() as $sectionId => $section) {
$completed = 0;
$presented = 0;
$flagged = 0;
$numberItems = count($section->getSectionParts());
$firstPositionSection = PHP_INT_MAX;
foreach ($section->getSectionParts() as $itemId => $item) {
if (isset($jumpsMap[$id][$sectionId][$itemId])) {
$jumpInfo = $jumpsMap[$id][$sectionId][$itemId];
if ($jumpInfo['answered']) {
++$completed;
}
if ($jumpInfo['viewed']) {
++$presented;
}
if ($jumpInfo['flagged']) {
++$flagged;
}
$firstPositionPart = min($firstPositionPart, $jumpInfo['position']);
$firstPositionSection = min($firstPositionSection, $jumpInfo['position']);
}
}
if ($sectionId === $activeSection) {
$numberItemsSection = $numberItems;
$itemPositionSection = $itemPosition - $firstPositionSection;
$numberCompletedSection = $completed;
$numberPresentedSection = $presented;
$numberFlaggedSection = $flagged;
}
if ($id === $activePart) {
$numberItemsPart += $numberItems;
$numberCompletedPart += $completed;
$numberPresentedPart += $presented;
$numberFlaggedPart += $flagged;
}
}
if ($id === $activePart) {
$itemPositionPart = $itemPosition - $firstPositionPart;
}
}
return [
'numberItemsFlagged' => $numberItemsFlagged,
'numberItemsPart' => $numberItemsPart,
'numberItemsSection' => $numberItemsSection,
'numberCompletedPart' => $numberCompletedPart,
'numberCompletedSection' => $numberCompletedSection,
'numberPresentedPart' => $numberPresentedPart,
'numberPresentedSection' => $numberPresentedSection,
'numberFlaggedPart' => $numberFlaggedPart,
'numberFlaggedSection' => $numberFlaggedSection,
'itemPositionPart' => $itemPositionPart,
'itemPositionSection' => $itemPositionSection,
];
}
/**
* Gets the map of the reachable items.
* @param AssessmentTestSession $session
* @return array The map of the test
*/
public static function getTestMap($session)
{
$map = [];
if ($session->isRunning() !== false) {
$route = $session->getRoute();
$routeItems = $route->getAllRouteItems();
$offset = $route->getRouteItemPosition($routeItems[0]);
foreach ($routeItems as $routeItem) {
$itemRef = $routeItem->getAssessmentItemRef();
$occurrence = $routeItem->getOccurence();
// get the session related to this route item.
$store = $session->getAssessmentItemSessionStore();
$itemSession = $store->getAssessmentItemSession($itemRef, $occurrence);
$map[] = new Jump($offset, $routeItem, $itemSession);
$offset++;
}
}
return $map;
}
/**
* Compute the the number of completed items during a given
* candidate test $session.
*
* @param AssessmentTestSession $session
* @return integer
*/
public static function testCompletion(AssessmentTestSession $session)
{
$completed = $session->numberCompleted();
if ($session->getCurrentNavigationMode() === NavigationMode::LINEAR && $completed > 0) {
$completed--;
}
return $completed;
}
/**
* Checks if the current test allows the progress bar to be displayed
* @param AssessmentTestSession $session
* @param array $testMeta
* @param array $config
* @return bool
*/
public static function considerProgress(AssessmentTestSession $session, array $testMeta, array $config = [])
{
$considerProgress = true;
if (!empty($config['progress-indicator-forced'])) {
// Caution: this piece of code can introduce a heavy load on very large tests
// The local optimisation made here concerns:
// - only check the part branchRules if the progress indicator must be forced for all tests
// - branchRules check is ignored when the navigation mode is non linear.
//
// TODO: Perform this check at compilation time and store a map of parts options.
// This can be also done for navigation map (see getNavigatorMap and getJumpsMap)
$testPart = $session->getCurrentTestPart();
if ($testPart->getNavigationMode() !== NavigationMode::NONLINEAR) {
$branchings = $testPart->getComponentsByClassName('branchRule');
if (count($branchings) > 0) {
$considerProgress = false;
}
}
} else {
if ($testMeta['preConditions'] === true) {
$considerProgress = false;
} elseif ($testMeta['branchRules'] === true) {
$considerProgress = false;
}
}
return $considerProgress;
}
/**
* Checks if the current session can be exited. If a context is pass we use it over the session
*
* @param AssessmentTestSession $session
* @param RunnerServiceContext $context
* @return bool
*/
public static function doesAllowExit(AssessmentTestSession $session, RunnerServiceContext $context = null)
{
$config = common_ext_ExtensionsManager::singleton()->getExtensionById('taoQtiTest')->getConfig('testRunner');
$exitButton = (isset($config['exitButton']) && $config['exitButton']);
$categories = self::getCategories($session, $context);
return ($exitButton && in_array('x-tao-option-exit', $categories));
}
/**
* Checks if the test taker can logout
*
* @param AssessmentTestSession $session
* @return type
*/
public static function doesAllowLogout(AssessmentTestSession $session)
{
$config = common_ext_ExtensionsManager::singleton()->getExtensionById('taoQtiTest')->getConfig('testRunner');
return !(isset($config['exitButton']) && $config['exitButton']);
}
/**
* Get the array of available categories for the current itemRef
* If we have a non null context we use it over the session
*
* @param \qtism\runtime\tests\AssessmentTestSession $session
* @param RunnerServiceContext $context
* @return array
*/
public static function getCategories(AssessmentTestSession $session, RunnerServiceContext $context = null)
{
if (!is_null($context)) {
return $context->getCurrentAssessmentItemRef()->getCategories()->getArrayCopy();
}
return $session->getCurrentAssessmentItemRef()->getCategories()->getArrayCopy();
}
/**
* Get the array of available categories for the test
*
* @param \qtism\runtime\tests\AssessmentTestSession $session
* @return array
*/
public static function getAllCategories(AssessmentTestSession $session)
{
$prevCategories = null;
$assessmentItemRefs = $session->getAssessmentTest()->getComponentsByClassName('assessmentItemRef');
/** @var \qtism\data\AssessmentItemRef $assessmentItemRef */
foreach ($assessmentItemRefs as $assessmentItemRef) {
$categories = $assessmentItemRef->getCategories();
if (!is_null($prevCategories)) {
$prevCategories->merge($categories);
} else {
$prevCategories = $categories;
}
}
return (!is_null($prevCategories)) ? array_unique($prevCategories->getArrayCopy()) : [];
}
/**
* Whether or not $value is considered as a null QTI value.
*
* @param $value
* @return boolean
*/
public static function isQtiValueNull($value)
{
return is_null($value) === true || ($value instanceof QtiString && $value->getValue() === '') || ($value instanceof Container && count($value) === 0);
}
/**
* Gets the amount of seconds with the microseconds as fractional part from a Duration instance.
* @param QtiDuration $duration
* @return float|null
*/
public static function getDurationWithMicroseconds($duration)
{
if ($duration) {
if (method_exists($duration, 'getMicroseconds')) {
return $duration->getMicroseconds(true) / 1e6;
}
return $duration->getSeconds(true);
}
return null;
}
}