Skip to content

[QTI] Build the QTI declaration model with XML parsing and serialization #5965

@AlexVelezLl

Description

@AlexVelezLl

This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.

Overview

Build the declaration parsing/serialization layer for the new QTI editor under shared/views/QTIEditor/serialization/qti/. The central piece is a class — QTIDeclaration — that holds one such declaration (identifier, base type, cardinality, and its child declarations such as correct-response). It is a pure structural representation: it carries no inner value and no scoring logic, and it can be (a) built from plain JS data, (b) built from XML, and (c) serialized back to XML.

This work is adapted from Kolibri's QTI declaration handling (see Context and References), but reshaped for authoring rather than runtime — the value/scoring half of the Kolibri code is intentionally left out.

Complexity: Medium
Target branch: unstable

Context

Studio is building a client-side QTI authoring editor. On load, an item's XML is parsed once into structured data; on save, XML is re-derived. Authoring an item requires reading and re-emitting QTI declarations (e.g. <qti-response-declaration> carrying the correct answer).

Reference — the Kolibri code being ported (kolibri/kolibri/plugins/qti_viewer/frontend/ from learningequality/kolibri#14631):

  • utils/qti/variables.js — the QTIVariable class. The model for QTIDeclaration: reads identifier / base-type / cardinality, iterates child elements, looks each up in a registry, and registers capabilities. In Kolibri this class also owns a reactive value, coerceValue, reset, score, and lookup — all of that is what we are dropping.
  • utils/qti/declarations/index.js — the declarationParsers registry (tag → strategy class), ruleHandlers won't be needed.
  • utils/qti/declarations/capabilities.js — the CAPABILITY name constants.
  • utils/qti/declarations/correctResponse.js, defaultValue.js, mapping.js, areaMapping.js, scoringDeclaration.js — the declaration strategy classes.

The fundamental inversion vs. Kolibri. Kolibri is a runtime: its QTIVariable is constructed from an XML element and exists to hold and evaluate a live value. Studio is an author: QTIDeclaration is constructed from plain JS data, parses XML only through a static helper, and serializes back to XML. So the direction is reversed and the value/scoring half is removed entirely.

All new code lives under shared/views/QTIEditor/serialization/qti/, with zero Vuex imports (consistent with the QTI editor being a self-contained, extractable package). All code uses the Composition API conventions of the rest of the QTI editor; the declaration classes themselves are plain ES classes (as in Kolibri).

The Change

Create the QTIDeclaration class and its declarations/ folder of strategy classes under shared/views/QTIEditor/serialization/qti/, plus a shared XML-node builder at shared/views/QTIEditor/serialization/assembleItem.js (one level up — it's a generic serialization helper, not specific to the declaration model).

1. serialization/assembleItem.js — XML node builder

A helper used by every getXML() to construct DOM nodes (not strings):

buildXmlNode({ tag, attrs = {}, children = [] })
  • tag → the element name (e.g. qti-response-declaration, qti-correct-response).
  • attrs → an object of attribute name/value pairs.
  • children → an array whose entries are either other XML nodes (appended as child elements) or strings (treated as innerHTML/text content).
  • Returns an XML node.

2. QTIDeclaration class (port of QTIVariable, value/scoring removed)

  • Constructed from plain JS data, not XML. The constructor receives only the declaration's own scalar values (e.g. identifier, baseType, cardinality) — it does not receive child declarations/capabilities, and it does not receive or parse XML.

  • Capabilities are registered, not passed in. As in Kolibri, child declarations are attached by instantiating the declaration strategy classes against the QTIDeclaration: each one calls registerCapability(name, declarationObject) on it (so QTIDeclaration exposes a registerCapability method). The constructor starts with an empty capability set; capabilities accumulate as declaration objects are instantiated.

  • Static fromXML(xmlNode) does what Kolibri's QTIVariable constructor does: read identifier / base-type / cardinality off the element, construct the QTIDeclaration from those scalars, then iterate child elements, look each up in declarationParsers, and instantiate each child declaration against the new QTIDeclaration (each registering itself as a capability). This is the only XML entry point.

  • Instance getXML() returns the declaration as an XML node (built with buildXmlNode), including the re-serialized child declarations. Returns a node, not a string.

  • Capabilities hold the whole declaration object (not just a function as in Kolibri). Each child declaration object exposes both a .get() (the parsed JS data) and a .getXML() (its XML node). So QTIDeclaration.getXML() can iterate its capabilities and call each .getXML().

  • The convenience getters therefore call .get() on the capability. Where Kolibri has:

    get correctResponse() {
      return this._capabilities[CAPABILITY.CORRECT_RESPONSE]?.() ?? null;
    }

    Studio has:

    get correctResponse() {
      return this._capabilities[CAPABILITY.CORRECT_RESPONSE]?.get() ?? null;
    }
  • Removed from the Kolibri original: the reactive _value / value getter+setter, coerceValue, parseRecordValues-as-runtime-state, reset, score, lookup, and the valueSetCallback. No value state and no scoring of any kind. (Parsing of declared <qti-value> contents — needed to read a correct-response/default-value — stays, but not as a runtime value.)

3. declarations/ folder (strategy classes, ported)

Same folder layout and same declarationParsers registry idea as Kolibri, with these differences:

  • capabilities.jsCAPABILITY constants, minus SCORE and LOOKUP (no scoring, no lookup tables). Keeps CORRECT_RESPONSE, DEFAULT_VALUE, and adds a mapping capability (see below).
  • index.js — the declarationParsers map (tag → class). Includes qti-correct-response, qti-default-value, qti-mapping, qti-area-mapping. Does NOT include qti-interpolation-table or qti-match-table (no lookup-table support). The ruleHandlers map is dropped (it existed only for response processing).
  • Every declaration class follows the same dual-construction contract as QTIDeclaration: constructed with plain JS values by default, plus a static fromXML(xmlNode, declaration) to build from an XML child element, plus instance get() (parsed data) and getXML() (XML node) methods. Instantiating a declaration registers it as a capability on the parent QTIDeclaration it is given (via registerCapability), mirroring Kolibri's strategy-class side effect.
  • correctResponse.js, defaultValue.js — port the parse of <qti-value> children into JS data; expose it via get(); re-serialize via getXML().
  • mapping.js (Mapping) and areaMapping.js (AreaMapping) must NOT extend ScoringDeclaration. They parse their entries and attributes into JS data for round-trip, and register a mapping capability that returns the parsed mapping data via get() (instead of registering a SCORE capability that returns a scoring function). The scoring methods (score, clampScore, _lookup, _key, _normalizeMapKey) and the geometry.js dependency are not ported.
  • scoringDeclaration.js is NOT ported (its only purpose was the scoring base class).

Note The first implementation of the QTI editor only needs correct-response. We port and expose the mapping/area-mapping capabilities, and the default-value capability now anyway — purely because Kolibri already implements this behavior, so carrying the parse/serialize across costs little and keeps the declaration model complete and round-trip-faithful.

Out of Scope

  • serialization/parseItem.js (splitting the item body into interaction blocks) and per-interaction parse.js files — separate, later issues that will consume QTIDeclaration.
  • Any runtime value handling: reactive value, coerceValue, reset, value-set callbacks.
  • Any scoring/evaluation: score, clampScore, response processing, the expression evaluator, geometry.js.
  • Lookup tables (qti-interpolation-table, qti-match-table) and ruleHandlers.
  • assembleItem.js (item envelope assembly) — later.
  • Wiring any of this into UI components.

Acceptance Criteria

General

  • The QTIDeclaration class and declarations/ folder live under shared/views/QTIEditor/serialization/qti/; the XML-node builder lives at shared/views/QTIEditor/serialization/assembleItem.js. No Vuex imports.
  • serialization/assembleItem.js exports buildXmlNode({ tag, attrs = {}, children = [] }) that returns an XML node (not a string); children entries may be XML nodes (appended as elements) or strings (treated as innerHTML/text).
  • QTIDeclaration is constructed from plain JS scalar values (e.g. identifier, baseType, cardinality) — its constructor does not accept child declarations/capabilities and does not parse XML. It exposes a registerCapability(name, declarationObject) method, and capabilities are added by instantiating declaration classes against it.
  • QTIDeclaration.fromXML(xmlNode) is a static method that reads identifier / base-type / cardinality, constructs the QTIDeclaration, then iterates children via declarationParsers and instantiates each child declaration against it (each registering itself as a capability).
  • QTIDeclaration.getXML() returns an XML node built via buildXmlNode, including each capability's re-serialized child node.
  • Capabilities on QTIDeclaration store the whole declaration object (exposing .get() and .getXML()), not a bare function; convenience getters call .get() (e.g. correctResponse returns this._capabilities[CAPABILITY.CORRECT_RESPONSE]?.get() ?? null).
  • QTIDeclaration contains no value state, no coerceValue/reset, no score/lookup, and no value-set callback.
  • declarations/capabilities.js defines CAPABILITY without SCORE or LOOKUP; includes CORRECT_RESPONSE, DEFAULT_VALUE, and a mapping capability.
  • declarations/index.js registers declarationParsers for qti-correct-response, qti-default-value, qti-mapping, qti-area-mapping only — no qti-interpolation-table, qti-match-table, or ruleHandlers.
  • Every declaration class supports the dual-construction contract: plain-JS constructor, static fromXML(xmlNode), instance get() (parsed data), and instance getXML() (XML node).
  • CorrectResponse and DefaultValue parse <qti-value> children into JS data and re-serialize them; DefaultValue is included for round-trip only.
  • Mapping and AreaMapping do not extend ScoringDeclaration; they parse their entries/attributes into JS data and register a mapping capability returning that data (no scoring function); scoringDeclaration.js and geometry.js are not ported.
  • An XML declaration round-trips: QTIDeclaration.fromXML(node).getXML() reproduces an equivalent declaration (same identifier/base-type/cardinality and equivalent child declarations).

Testing

  • Unit tests cover buildXmlNode (attrs, node children, string children).
  • Unit tests cover QTIDeclaration.fromXMLgetXML round-trip for a response declaration with a correct-response.
  • Unit tests cover each declaration class's fromXML / get / getXML (correct-response, default-value, mapping, area-mapping).
  • Existing lint and test suites pass.

References

The Kolibri declaration logic referenced here was introduced in learningequality/kolibri#14631 (learningequality/kolibri#14631). The folder to look at is kolibri/plugins/qti_viewer/frontend/utils/qti.

Specific files (kolibri/kolibri/plugins/qti_viewer/frontend/):

  • utils/qti/variables.jsQTIVariable (model for QTIDeclaration; drop value/scoring).
  • utils/qti/declarations/index.jsdeclarationParsers / ruleHandlers.
  • utils/qti/declarations/capabilities.jsCAPABILITY constants.
  • utils/qti/declarations/correctResponse.js, defaultValue.js, mapping.js, areaMapping.js, scoringDeclaration.js.
  • utils/assembleItem.jsparseXML (DOMParser + parsererror guard) for reference.

AI usage

I used Claude (Claude Code) to draft this issue from my design decisions and a review of the Kolibri qti_viewer source. I specified the architecture (the QTIDeclaration rename, the from-JS/fromXML/getXML contract, the capability-object .get()/.getXML() design, the dropped scoring/value/lookup-table scope, and the assembleItem.js builder); Claude read the referenced Kolibri files and assembled the issue. I reviewed every section.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No fields configured for Task.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions