You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
❌ 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:
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.)
Same folder layout and same declarationParsers registry idea as Kolibri, with these differences:
capabilities.js — CAPABILITY 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 consumeQTIDeclaration.
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 novalue 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 — noqti-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.fromXML → getXML 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).
utils/assembleItem.js — parseXML (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.
❌ 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 ascorrect-response). It is a pure structural representation: it carries no innervalueand 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:
unstableContext
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— theQTIVariableclass. The model forQTIDeclaration: readsidentifier/base-type/cardinality, iterates child elements, looks each up in a registry, and registers capabilities. In Kolibri this class also owns a reactivevalue,coerceValue,reset,score, andlookup— all of that is what we are dropping.utils/qti/declarations/index.js— thedeclarationParsersregistry (tag → strategy class),ruleHandlerswon't be needed.utils/qti/declarations/capabilities.js— theCAPABILITYname 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
QTIVariableis constructed from an XML element and exists to hold and evaluate a live value. Studio is an author:QTIDeclarationis 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
QTIDeclarationclass and itsdeclarations/folder of strategy classes undershared/views/QTIEditor/serialization/qti/, plus a shared XML-node builder atshared/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 builderA helper used by every
getXML()to construct DOM nodes (not strings):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 asinnerHTML/text content).2.
QTIDeclarationclass (port ofQTIVariable, 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 callsregisterCapability(name, declarationObject)on it (soQTIDeclarationexposes aregisterCapabilitymethod). The constructor starts with an empty capability set; capabilities accumulate as declaration objects are instantiated.Static
fromXML(xmlNode)does what Kolibri'sQTIVariableconstructor does: readidentifier/base-type/cardinalityoff the element, construct theQTIDeclarationfrom those scalars, then iterate child elements, look each up indeclarationParsers, and instantiate each child declaration against the newQTIDeclaration(each registering itself as a capability). This is the only XML entry point.Instance
getXML()returns the declaration as an XML node (built withbuildXmlNode), 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). SoQTIDeclaration.getXML()can iterate its capabilities and call each.getXML().The convenience getters therefore call
.get()on the capability. Where Kolibri has:Studio has:
Removed from the Kolibri original: the reactive
_value/valuegetter+setter,coerceValue,parseRecordValues-as-runtime-state,reset,score,lookup, and thevalueSetCallback. 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
declarationParsersregistry idea as Kolibri, with these differences:capabilities.js—CAPABILITYconstants, minusSCOREandLOOKUP(no scoring, no lookup tables). KeepsCORRECT_RESPONSE,DEFAULT_VALUE, and adds a mapping capability (see below).index.js— thedeclarationParsersmap (tag → class). Includesqti-correct-response,qti-default-value,qti-mapping,qti-area-mapping. Does NOT includeqti-interpolation-tableorqti-match-table(no lookup-table support). TheruleHandlersmap is dropped (it existed only for response processing).QTIDeclaration: constructed with plain JS values by default, plus a staticfromXML(xmlNode, declaration)to build from an XML child element, plus instanceget()(parsed data) andgetXML()(XML node) methods. Instantiating a declaration registers it as a capability on the parentQTIDeclarationit is given (viaregisterCapability), mirroring Kolibri's strategy-class side effect.correctResponse.js,defaultValue.js— port the parse of<qti-value>children into JS data; expose it viaget(); re-serialize viagetXML().mapping.js(Mapping) andareaMapping.js(AreaMapping) must NOT extendScoringDeclaration. They parse their entries and attributes into JS data for round-trip, and register a mapping capability that returns the parsed mapping data viaget()(instead of registering aSCOREcapability that returns a scoring function). The scoring methods (score,clampScore,_lookup,_key,_normalizeMapKey) and thegeometry.jsdependency are not ported.scoringDeclaration.jsis NOT ported (its only purpose was the scoring base class).Out of Scope
serialization/parseItem.js(splitting the item body into interaction blocks) and per-interactionparse.jsfiles — separate, later issues that will consumeQTIDeclaration.value,coerceValue,reset, value-set callbacks.score,clampScore, response processing, the expression evaluator,geometry.js.qti-interpolation-table,qti-match-table) andruleHandlers.assembleItem.js(item envelope assembly) — later.Acceptance Criteria
General
QTIDeclarationclass anddeclarations/folder live undershared/views/QTIEditor/serialization/qti/; the XML-node builder lives atshared/views/QTIEditor/serialization/assembleItem.js. No Vuex imports.serialization/assembleItem.jsexportsbuildXmlNode({ tag, attrs = {}, children = [] })that returns an XML node (not a string);childrenentries may be XML nodes (appended as elements) or strings (treated as innerHTML/text).QTIDeclarationis 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 aregisterCapability(name, declarationObject)method, and capabilities are added by instantiating declaration classes against it.QTIDeclaration.fromXML(xmlNode)is a static method that readsidentifier/base-type/cardinality, constructs theQTIDeclaration, then iterates children viadeclarationParsersand instantiates each child declaration against it (each registering itself as a capability).QTIDeclaration.getXML()returns an XML node built viabuildXmlNode, including each capability's re-serialized child node.QTIDeclarationstore the whole declaration object (exposing.get()and.getXML()), not a bare function; convenience getters call.get()(e.g.correctResponsereturnsthis._capabilities[CAPABILITY.CORRECT_RESPONSE]?.get() ?? null).QTIDeclarationcontains novaluestate, nocoerceValue/reset, noscore/lookup, and no value-set callback.declarations/capabilities.jsdefinesCAPABILITYwithoutSCOREorLOOKUP; includesCORRECT_RESPONSE,DEFAULT_VALUE, and a mapping capability.declarations/index.jsregistersdeclarationParsersforqti-correct-response,qti-default-value,qti-mapping,qti-area-mappingonly — noqti-interpolation-table,qti-match-table, orruleHandlers.fromXML(xmlNode), instanceget()(parsed data), and instancegetXML()(XML node).CorrectResponseandDefaultValueparse<qti-value>children into JS data and re-serialize them;DefaultValueis included for round-trip only.MappingandAreaMappingdo not extendScoringDeclaration; they parse their entries/attributes into JS data and register a mapping capability returning that data (no scoring function);scoringDeclaration.jsandgeometry.jsare not ported.QTIDeclaration.fromXML(node).getXML()reproduces an equivalent declaration (same identifier/base-type/cardinality and equivalent child declarations).Testing
buildXmlNode(attrs, node children, string children).QTIDeclaration.fromXML→getXMLround-trip for a response declaration with acorrect-response.fromXML/get/getXML(correct-response, default-value, mapping, area-mapping).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.js—QTIVariable(model forQTIDeclaration; drop value/scoring).utils/qti/declarations/index.js—declarationParsers/ruleHandlers.utils/qti/declarations/capabilities.js—CAPABILITYconstants.utils/qti/declarations/correctResponse.js,defaultValue.js,mapping.js,areaMapping.js,scoringDeclaration.js.utils/assembleItem.js—parseXML(DOMParser +parsererrorguard) for reference.AI usage
I used Claude (Claude Code) to draft this issue from my design decisions and a review of the Kolibri
qti_viewersource. I specified the architecture (theQTIDeclarationrename, the from-JS/fromXML/getXMLcontract, the capability-object.get()/.getXML()design, the dropped scoring/value/lookup-table scope, and theassembleItem.jsbuilder); Claude read the referenced Kolibri files and assembled the issue. I reviewed every section.