Repository navigation
[QTI] Implement Associate Interaction editor #6101
Description
❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.
Description
Complete the associate interaction plugin end-to-end: XML parsing, XML assembly,
validation, a useAssociateInteraction composable built on useInteraction, and a
working AssociateInteractionEditor.vue.
This task depends on the choice, text-entry, and ordering interaction plugins
being in place (for useInteraction, generateRandomSlug, and defineInteraction).
Complexity: High
Target branch: unstable
Context
The associate interaction maps to <qti-associate-interaction> and covers a "connect
pairs" question type.
The associate interaction works with a single flat pool of choices. The learner must
draw connections between any two items within the same pool — for example, "match
each character to their rival from this list of names."
The response declaration uses cardinality="multiple" and base-type="pair", listing
unordered pairs of choice identifiers. Because it is an undirected pair,
"A B" and "B A" are considered the same pairing.
State shape
Defined via JSDoc in interactions/associate/parse.js:
/**
* @typedef {object} AssociateChoice
* @property {string} id - QTI identifier, e.g. "assoc_xlqTuVoq"
* @property {string} content - HTML content of the <qti-simple-associable-choice>
*/
/**
* @typedef {object} AssociateState
* @property {string} prompt - HTML content of <qti-prompt>; default ""
* @property {AssociateChoice[]} distractors - Single flat pool of distractors
* @property {Array<[AssociateChoice, AssociateChoice]>} pairs - Array of two-item arrays representing correctly associated pairs.
*/QTI XML reference
Official spec example (§3.2.13, IMS Global BPIG):
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
identifier="QTI3-associate" title="Classic Associate Interaction Example"
time-dependent="false" xml:lang="en-US">
<qti-response-declaration identifier="RESPONSE"
cardinality="multiple" base-type="pair">
<qti-correct-response>
<qti-value>A P</qti-value>
<qti-value>C M</qti-value>
<qti-value>D L</qti-value>
</qti-correct-response>
</qti-response-declaration>
<qti-item-body>
<qti-associate-interaction response-identifier="RESPONSE" max-associations="3">
<qti-prompt>
Hidden in this list of characters from famous Shakespeare plays are three pairs
of rivals. Can you match each character to his adversary?
</qti-prompt>
<qti-simple-associable-choice identifier="A" match-max="1">Antonio</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="C" match-max="1">Capulet</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="D" match-max="1">Demetrius</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="L" match-max="1">Lysander</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="M" match-max="1">Montague</qti-simple-associable-choice>
<qti-simple-associable-choice identifier="P" match-max="1">Prospero</qti-simple-associable-choice>
</qti-associate-interaction>
</qti-item-body>
</qti-assessment-item>Rules:
base-typeis always"pair"(not"directedPair").- A
pairis unordered —"A P"and"P A"are equivalent. Do not modify the saved order; otherwise, pairs in the UI will change order on reload. max-associationscontrols the total number of pairs a learner can make. Always set this to the number of correct pairs inqti-correct-response.match-maxon each choice controls how many pairs that specific choice can be part of.- Items without an identifier must be assigned
generateRandomSlug('choice'). shufflecontrols whether the delivery engine randomizes display order. Should always be set to true.- Choices not present on
qti-correct-responseare considered distractors. - If a choice has a
match-maxgreater than the number of appearances onqti-correct-response, then the difference is the number of distractors that choice accounts for. - If two choices have the exact same content, then those should be transformed into a single choice with a proper
match-max.
The Change
1. interactions/associate/parse.js
Export parseAssociateInteraction(bodyXml, responseDeclarations) → AssociateState:
- Parse
bodyXmlwithparseXML. - Extract
<qti-prompt>inner HTML →promptvia the sharedgetPromptHTMLhelper. - Collect all
<qti-simple-associable-choice>elements into thepairsarray with their correspondingqti-simple-associable-choicecontent. <qti-simple-associable-choice>not present inqti-correct-responsegoes into the flatdistractorsarray.- If
identifieris not present on<qti-simple-associable-choice>, then assign it togenerateRandomSlug('choice').
Export buildAssociateInteractionXML(state, questionType, declarationSchema):
- Serialize
state.promptas<qti-prompt>. - Collect all
<qti-simple-associable-choice>from distractors and correct pairs and normalize choice IDs in the same pool. All choices with the same content should have the same id, and all choices with the same id should have the same content; if they don't, then generate a new id withgenerateRandomSlug('choice'). Try preserving original IDs as much as possible. - Group the pool by IDs, and set a proper
match-maxfor each choice into the . - Emit
max-associationsalways, equal to the number ofpairs. - Build
<qti-response-declaration>withcardinality="multiple",base-type="pair",
and<qti-correct-response>listing space-separated pairs.
_defaultState() — seeds a single correct pair in pairs with two empty choices with generated IDs.
2. interactions/associate/validate.js
Export validateAssociateInteraction(state) → ValidationError[]:
| Rule | Condition |
|---|---|
| Question is required | state.prompt is empty or whitespace-only |
| Choice cannot be blank | Any choice has empty or whitespace-only content |
| 1 or more valid pairs are required | If the list of valid pairs (e.g. pairs where both choices are valid) is less than 1 |
| Answers within a pair cannot be the same | if a pair has the same content for both items |
3. interactions/associate/AssociateInteractionDescriptor.js
Define the descriptor class:
type:QtiInteraction.ASSOCIATE('qti-associate-interaction')placement:'block'questionTypes:[QuestionType.ASSOCIATE]matches(el):el.tagName.toLowerCase() === QtiInteraction.ASSOCIATEgetResponseDeclarationSchema():
returns{ baseType: BaseType.PAIR, cardinality: Cardinality.MULTIPLE }- Delegates parse, build, and validate methods.
4. interactions/associate/index.js & interactions/index.js
- Export via
defineInteractionand register the descriptor alongside existing ones.
5. constants.js & qtiEditorStrings.js
- Add
ASSOCIATE: 'associate'to theQuestionTypefreeze object. - Add an explicit translation key (e.g.,
associateLabel$: 'Connect pairs') to
qtiEditorStrings.js. Do not dynamically concatenate translation keys.
6. composables/useAssociateInteraction.js
Build on useInteraction and expose state-mutation methods:
addPair()- appends a new pair of empty choices inpairwith generatedchoice_<8chars>IDs.removePair(index)— removes the pair frompairs.setPair(index, newPair)— updates the pair at that index, used to update the choice's content field.addDistractor()— appends a new empty choice todistractorswith a generatedchoice_<8chars>ID.removeDistractor(index)— removes a distractor at that index.setDistractorContent(index, html)— updates the choice's content field.
7. interactions/associate/AssociateInteractionEditor.vue
A Vue SFC wiring the composable to the UI.
Props:
props: {
interaction: Object, // { bodyXml, responseDeclarations }
questionType: String, // 'associate'
mode: String, // 'edit' | 'view'
showAnswers: Boolean,
}Emits: 'update:interaction'
UI behaviour:
Acceptance Criteria
parseAssociateInteractioncorrectly parses a flat choice list and unordered
pairs.buildAssociateInteractionXMLroundtrips:parse(buildXML(state))produces
an equivalent state.- Pairs in
<qti-correct-response>are emitted as space-separated
<qti-value>tags. - Descriptor is registered and validated properly.
useAssociateInteractionsafely adds/removes.- A11y: keyboard-only users can add choices and pairs.
- Existing lint and test suites pass.
Testing
- Unit tests for
parseAssociateInteraction: round-trip, pair normalization. - Unit tests for
buildAssociateInteractionXML: pair order in declaration matches
state;shuffleandmax-associationsemitted correctly. - Unit tests for
validateAssociateInteraction: each error condition covered;
valid state returns[]. - Unit tests for
useAssociateInteraction: each mutation method produces the
expected state change;removePairstrips orphaned pairs onremoveChoice. AssociateInteractionEditor.spec.js: edit mode rendering, view mode
(showAnswerson/off), validation display,update:interactionemit on mutation.
References
- QTI 3.0 spec §3.2.13 Associate Interaction: IMS Global BPIG.
- Architecture:
shared/views/QTIEditor/— seeinteractions/choice/as the
nearest reference implementation.
AI usage
I used Claude (Antigravity) to draft this issue from design decisions and the QTI editor architecture.







Activity
rtibblesbot commentedon Aug 31, 2026
Plan: Ship the
qti-associate-interactionplugin end-to-end — parse, build, validate, descriptor, composable, editor SFC — registered alongside choice / text-entry / ordering. (Issue #6101, target branchunstable.)parseAssociateInteractionbuildAssociateInteractionXMLvalidateAssociateInteractionAssociateInteractionDescriptoruseAssociateInteractionAssociateInteractionEditor.vue@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?