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