Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.66% covered (danger)
21.66%
47 / 217
23.53% covered (danger)
23.53%
4 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
taoQtiTest_models_classes_QtiTestConverter
21.66% covered (danger)
21.66%
47 / 217
23.53% covered (danger)
23.53%
4 / 17
4072.53
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 toJson
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toArray
14.29% covered (danger)
14.29%
1 / 7
0.00% covered (danger)
0.00%
0 / 1
4.52
 fromJson
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 componentToArray
66.67% covered (warning)
66.67%
22 / 33
0.00% covered (danger)
0.00%
0 / 1
21.26
 getProperties
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getValue
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 setValue
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getPropertyClass
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 arrayToComponent
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
272
 componentValue
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 createComponentCollection
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
90
 createInstance
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
272
 getHint
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 lookupClass
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 transformValue
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 reverseTransformValue
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
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) 2013-2025 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT);
19*/
20
21use qtism\common\datatypes\QtiPair;
22use qtism\data\state\ExternalScored;
23use qtism\data\state\OutcomeDeclaration;
24use qtism\data\storage\xml\XmlDocument;
25use qtism\data\QtiComponent;
26use qtism\data\QtiComponentCollection;
27use qtism\common\datatypes\QtiDuration;
28use oat\taoQtiTest\helpers\QtiTestSanitizer;
29use qtism\common\collections\IntegerCollection;
30use qtism\common\collections\StringCollection;
31use qtism\data\ViewCollection;
32use qtism\data\View;
33
34/**
35 * This class helps you to convert a QTITest from the qtism library.
36 * It supports only JSON conversion, but uses assoc arrays as transitional format.
37 *
38 * This converter will be replaced by a JSON Marshaller from inside the qtism lib.
39 *
40 * @author Bertrand Chevrier <bertrand@taotesting.com>
41 *
42 * @access public
43 * @package taoQtiTest
44 *
45 */
46class taoQtiTest_models_classes_QtiTestConverter
47{
48    /**
49     * Value transformation map for frontend to QTI conversion
50     */
51    private static array $valueTransformMap = [
52        'externalScored' => [
53            'human' => ExternalScored::HUMAN,
54            'externalMachine' => ExternalScored::EXTERNAL_MACHINE
55        ],
56    ];
57    /**
58     * operators for which qtsm classes are postfix
59     *
60     * @var array $operatorClassesOperatorPostfix
61     */
62    public static $operatorClassesPostfix = [
63        'and',
64        'custom',
65        'math',
66        'or',
67        'match',
68        'stats'
69    ];
70
71    /**
72     * The instance of the XmlDocument that represents the QTI Test.
73     *
74     * This is the pivotal class.
75     *
76     * @var XmlDocument
77     */
78    private $doc;
79
80    /** @var QtiTestSanitizer */
81    private $qtiTestSanitizer;
82
83    /**
84     * Instantiate the converter using a QTITest document.
85     */
86    public function __construct(XmlDocument $doc, QtiTestSanitizer $qtiTestSanitizer = null)
87    {
88        $this->doc = $doc;
89        $this->qtiTestSanitizer = $qtiTestSanitizer ?? new QtiTestSanitizer();
90    }
91
92    /**
93     * Converts the test from the document to JSON.
94     *
95     * @return string json
96     */
97    public function toJson()
98    {
99        return json_encode($this->toArray());
100    }
101
102    /**
103     * Converts the test from the document to an array
104     * @return array the test data as array
105     * @throws taoQtiTest_models_classes_QtiTestConverterException
106     */
107    public function toArray()
108    {
109        try {
110            return $this->componentToArray($this->doc->getDocumentComponent());
111        } catch (ReflectionException $re) {
112            common_Logger::e($re->getMessage());
113            common_Logger::d($re->getTraceAsString());
114            throw new taoQtiTest_models_classes_QtiTestConverterException(
115                'Unable to convert the QTI Test to json: ' . $re->getMessage()
116            );
117        }
118    }
119
120    /**
121     * Populate the document using the JSON parameter.
122     *
123     * @param string $json a valid json object (one that comes from the toJson method).
124     *
125     * @throws taoQtiTest_models_classes_QtiTestConverterException
126     */
127    public function fromJson($json)
128    {
129        try {
130            $data = json_decode($json, true);
131            if (is_array($data)) {
132                $this->arrayToComponent($data);
133            }
134        } catch (ReflectionException $re) {
135            common_Logger::e($re->getMessage());
136            common_Logger::d($re->getTraceAsString());
137            throw new taoQtiTest_models_classes_QtiTestConverterException(
138                'Unable to create the QTI Test from json: ' . $re->getMessage()
139            );
140        }
141    }
142
143    /**
144     * Converts a QTIComponent to an assoc array (instances variables to key/val), using reflection.
145     *
146     * @param \qtism\data\QtiComponent $component
147     * @return array
148     */
149    private function componentToArray(QtiComponent $component)
150    {
151        $array = [
152            'qti-type' => $component->getQtiClassName()
153        ];
154
155        $reflector = new ReflectionClass($component);
156
157        foreach ($this->getProperties($reflector) as $property) {
158            $value = $this->getValue($component, $property);
159            if ($value !== null) {
160                $key = $property->getName();
161                $value = $this->reverseTransformValue($key, $value);
162                if ($value instanceof QtiPair) {
163                    $array[$property->getName()] = (string) $value;
164                } elseif ($value instanceof QtiComponentCollection) {
165                    $array[$key] = [];
166                    foreach ($value as $item) {
167                        $array[$key][] = $this->componentToArray($item);
168                    }
169                } elseif ($value instanceof ViewCollection) {
170                    $array[$property->getName()] = [];
171                    foreach ($value as $item) {
172                        $array[$property->getName()][] = View::getNameByConstant($item);
173                    }
174                } elseif ($value instanceof QtiComponent) {
175                    $array[$property->getName()] = $this->componentToArray($value);
176                } elseif ($value instanceof QtiDuration) {
177                    $array[$property->getName()] = taoQtiTest_helpers_TestRunnerUtils::getDurationWithMicroseconds(
178                        $value
179                    );
180                } elseif ($value instanceof IntegerCollection || $value instanceof StringCollection) {
181                    $array[$property->getName()] = [];
182                    foreach ($value as $item) {
183                        $array[$property->getName()][] = $item;
184                    }
185                } else {
186                    $array[$property->getName()] = $value;
187                }
188            }
189        }
190
191        if ($component instanceof OutcomeDeclaration) {
192            $array['serial'] = uniqid();
193        }
194
195        return $array;
196    }
197
198    /**
199     * Get the class properties.
200     *
201     * @param ReflectionClass $reflector
202     * @param array $childrenProperties for recursive usage only
203     * @return ReflectionProperty[] the list of properties
204     */
205    private function getProperties(ReflectionClass $reflector, array $childrenProperties = [])
206    {
207        $properties = array_merge($childrenProperties, $reflector->getProperties());
208        if ($reflector->getParentClass()) {
209            $properties = $this->getProperties($reflector->getParentClass(), $properties);
210        }
211        return $properties;
212    }
213
214    /**
215     * Call the getter from a reflection property, to get the value
216     *
217     * @param \qtism\data\QtiComponent $component
218     * @param ReflectionProperty $property
219     * @return mixed value produced by the getter
220     */
221    private function getValue(QtiComponent $component, ReflectionProperty $property)
222    {
223        $value = null;
224        $getterProps = [
225            'get',
226            'is',
227            'does',
228            'must'
229        ];
230        foreach ($getterProps as $getterProp) {
231            $getterName = $getterProp . ucfirst($property->getName());
232            try {
233                $method = new ReflectionMethod($component, $getterName);
234                if ($method->isPublic()) {
235                    $value = $component->{$getterName}();
236                }
237            } catch (ReflectionException $re) { // this must be ignored
238                continue;
239            }
240            return $value;
241        }
242    }
243
244    /**
245     * Call the setter to assign a value to a component using a reflection property
246     *
247     * @param \qtism\data\QtiComponent $component
248     * @param ReflectionProperty $property
249     * @param mixed $value
250     */
251    private function setValue(QtiComponent $component, ReflectionProperty $property, $value)
252    {
253        $setterName = 'set' . ucfirst($property->getName());
254        try {
255            $method = new ReflectionMethod($component, $setterName);
256            if ($method->isPublic()) {
257                $transformedValue = $this->transformValue($property->getName(), $value);
258                $method->invoke($component, $transformedValue);
259            }
260        } catch (ReflectionException $re) {
261            // this must be ignored
262        }
263    }
264
265    /**
266     * If a class is explicitly defined for a property, we get it (from the setter's parameter...).
267     *
268     * @param \qtism\data\QtiComponent $component
269     * @param ReflectionProperty $property
270     * @return null|ReflectionClass
271     */
272    public function getPropertyClass(QtiComponent $component, ReflectionProperty $property)
273    {
274        $setterName = 'set' . ucfirst($property->getName());
275        try {
276            $method = new ReflectionMethod($component, $setterName);
277            $parameters = $method->getParameters();
278
279            if (count($parameters) === 1) {
280                $param = $parameters[0];
281                return $param->getClass();
282            }
283        } catch (ReflectionException $re) {
284        }
285
286        return null;
287    }
288
289    /**
290     * Converts an assoc array to a QtiComponent using reflection
291     *
292     * @param array $testArray the assoc array
293     * @param \qtism\data\QtiComponent|null $parent for recursive usage only
294     * @param boolean $attach if we want to attach the component to it's parent or return it
295     * @return QtiComponent|void
296     */
297    private function arrayToComponent(array $testArray, QtiComponent $parent = null, $attach = true)
298    {
299        if (isset($testArray['qti-type']) && ! empty($testArray['qti-type'])) {
300            $compName = $this->lookupClass($testArray['qti-type']);
301
302            if (! empty($compName)) {
303                $reflector = new ReflectionClass($compName);
304                $component = $this->createInstance($reflector, $testArray);
305
306                $properties = [];
307                foreach ($this->getProperties($reflector) as $property) {
308                    $properties[$property->getName()] = $property;
309                }
310
311                foreach ($testArray as $key => $value) {
312                    if (array_key_exists($key, $properties)) {
313                        $class = $this->getPropertyClass($component, $properties[$key]);
314
315                        if (is_array($value) && array_key_exists('qti-type', $value)) {
316                            $this->arrayToComponent($value, $component, true);
317                        } else {
318                            $assignableValue = $this->componentValue($value, $class);
319
320                            if ($assignableValue !== null) {
321                                if (is_string($assignableValue) && $key === 'content') {
322                                    $assignableValue = $this->qtiTestSanitizer->sanitizeContent($assignableValue);
323                                }
324
325                                $this->setValue($component, $properties[$key], $assignableValue);
326                            }
327                        }
328                    }
329                }
330
331                if ($attach) {
332                    if (is_null($parent)) {
333                        $this->doc->setDocumentComponent($component);
334                    } else {
335                        $parentReflector = new ReflectionClass($parent);
336                        foreach ($this->getProperties($parentReflector) as $property) {
337                            if ($property->getName() === $testArray['qti-type']) {
338                                $this->setValue($parent, $property, $component);
339                                break;
340                            }
341                        }
342                    }
343                }
344                return $component;
345            }
346        }
347    }
348
349    /**
350     * Get the value according to it's type and class.
351     *
352     * @param mixed $value
353     * @param object|null $class
354     * @return QtiDuration|QtiComponentCollection|mixed|null
355     */
356    private function componentValue($value, $class)
357    {
358        if ($class === null) {
359            return $value;
360        }
361
362        if (is_array($value)) {
363            return $this->createComponentCollection(new ReflectionClass($class->name), $value);
364        }
365        if ($class->name === QtiDuration::class) {
366            return new QtiDuration('PT' . $value . 'S');
367        }
368
369        return $value;
370    }
371
372    /**
373     * Instantiate and fill a QtiComponentCollection
374     *
375     * @param ReflectionClass $class
376     * @param array $values
377     * @return \qtism\data\QtiComponentCollection|null
378     */
379    private function createComponentCollection(ReflectionClass $class, $values)
380    {
381        $collection = $class->newInstance();
382        if ($collection instanceof ViewCollection) {
383            foreach ($values as $value) {
384                $collection[] = View::getConstantByName($value);
385            }
386            return $collection;
387        }
388        if ($collection instanceof QtiComponentCollection) {
389            foreach ($values as $value) {
390                $collection->attach($this->arrayToComponent($value, null, false));
391            }
392            return $collection;
393        }
394        if ($collection instanceof IntegerCollection || $collection instanceof StringCollection) {
395            foreach ($values as $value) {
396                if (!empty($value)) {
397                    $collection[] = $value;
398                }
399            }
400            return $collection;
401        }
402
403        return null;
404    }
405
406    /**
407     * Call the constructor with the required parameters of a QtiComponent.
408     *
409     * @param ReflectionClass $class
410     * @param array|string $properties
411     * @return QtiComponent
412     */
413    private function createInstance(ReflectionClass $class, $properties)
414    {
415        $arguments = [];
416        if (is_string($properties) && $class->implementsInterface('qtism\common\enums\Enumeration')) {
417            $enum = $class->newInstance();
418            return $enum->getConstantByName($properties);
419        }
420        $constructor = $class->getConstructor();
421        if (is_null($constructor)) {
422            return $class->newInstance();
423        }
424        $docComment = $constructor->getDocComment();
425        foreach ($class->getConstructor()->getParameters() as $parameter) {
426            if (! $parameter->isOptional()) {
427                $name = $parameter->getName();
428                $paramClass = $parameter->getClass();
429                if ($paramClass !== null) {
430                    if (is_array($properties[$name])) {
431                        $component = $this->arrayToComponent($properties[$name]);
432                        if (! $component) {
433                            $component = $this->createComponentCollection(
434                                new ReflectionClass($paramClass->name),
435                                $properties[$name]
436                            );
437                        }
438
439                        $arguments[] = $component;
440                    }
441                } elseif (array_key_exists($name, $properties)) {
442                    $arguments[] = $properties[$name];
443                } else {
444                    $hint = $this->getHint($docComment, $name);
445                    switch ($hint) {
446                        case 'int':
447                            $arguments[] = 0;
448                            break;
449                        case 'integer':
450                            $arguments[] = 0;
451                            break;
452                        case 'boolean':
453                            $arguments[] = false;
454                            break;
455                        case 'string':
456                            $arguments[] = '';
457                            break;
458                        case 'array':
459                            $arguments[] = [];
460                            break;
461                        default:
462                            $arguments[] = null;
463                            break;
464                    }
465                }
466            }
467        }
468
469        return $class->newInstanceArgs($arguments);
470    }
471
472    /**
473     * Get the type of parameter from the jsdoc (yes, I know...
474     * but this is temporary ok!)
475     *
476     * @param string $docComment
477     * @param string $varName
478     * @return null|array
479     */
480    private function getHint($docComment, $varName)
481    {
482        $matches = [];
483        $count = preg_match_all(
484            '/@param[\t\s]*(?P<type>[^\t\s]*)[\t\s]*\$(?P<name>[^\t\s]*)/sim',
485            $docComment,
486            $matches
487        );
488        if ($count > 0) {
489            foreach ($matches['name'] as $n => $name) {
490                if ($name === $varName) {
491                    return $matches['type'][$n];
492                }
493            }
494        }
495        return null;
496    }
497
498    /**
499     * get the namespaced class name
500     *
501     * @param string $name the short class name
502     * @return string the long class name
503     */
504    private function lookupClass($name)
505    {
506        $namespaces = [
507            'qtism\\common\\datatypes\\',
508            'qtism\\data\\',
509            'qtism\\data\\content\\',
510            'qtism\\data\\content\\xhtml\\',
511            'qtism\\data\\content\\xhtml\\lists\\',
512            'qtism\\data\\content\\xhtml\\presentation\\',
513            'qtism\\data\\content\\xhtml\\tables\\',
514            'qtism\\data\\content\\xhtml\\text\\',
515            'qtism\\data\\content\\interactions\\',
516            'qtism\\data\\expressions\\',
517            'qtism\\data\\expressions\\operators\\',
518            'qtism\\data\\processing\\',
519            'qtism\\data\\rules\\',
520            'qtism\\data\\state\\'
521        ];
522
523        if (in_array(mb_strtolower($name), self::$operatorClassesPostfix)) {
524            $name .= 'Operator';
525        }
526
527        foreach ($namespaces as $namespace) { // this could be cached
528            $className = $namespace . ucfirst($name);
529            if (class_exists($className, true)) {
530                return $className;
531            }
532        }
533    }
534
535    /**
536     * Transform frontend values to QTI specification values
537     */
538    private function transformValue(string $propertyName, mixed $value): mixed
539    {
540        if (isset(self::$valueTransformMap[$propertyName][$value])) {
541            return self::$valueTransformMap[$propertyName][$value];
542        }
543
544        return $value;
545    }
546
547    /**
548     * Transform QTI values back to frontend format
549     */
550    private function reverseTransformValue($propertyName, $value)
551    {
552        if (isset(self::$valueTransformMap[$propertyName])) {
553            $flippedMap = array_flip(self::$valueTransformMap[$propertyName]);
554            if (isset($flippedMap[$value])) {
555                return $flippedMap[$value];
556            }
557        }
558
559        return $value;
560    }
561}