From be18e6de2da74d22c63e1959adac9cb7bbb44745 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Fri, 31 Jul 2026 18:58:13 -0700 Subject: [PATCH 01/24] fix: convert an answerless legacy choice question instead of raising A choice question with no answers is what the editor writes for every newly added question, and is the model's own default shape - but the QTI XSD requires qti-choice-interaction to carry at least one qti-simple-choice, so conversion raised a ValidationError. Emit the question text alone, with no interaction, response declaration or response processing. The item body wraps that text in a div because rendered markdown can start with a top level , which qti-item-body does not accept directly, and falls back to an empty paragraph because the container cannot be empty and a newly added question has no text yet. Publish and ricecooker upload reach the same converter, so both stop raising on these items too. --- .../fixtures/single_selection_no_answers.xml | 9 +++ .../tests/utils/qti/test_convert.py | 68 +++++++++++++++++++ .../utils/assessment/qti/convert.py | 34 ++++++++-- 3 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 contentcuration/contentcuration/tests/utils/qti/fixtures/single_selection_no_answers.xml diff --git a/contentcuration/contentcuration/tests/utils/qti/fixtures/single_selection_no_answers.xml b/contentcuration/contentcuration/tests/utils/qti/fixtures/single_selection_no_answers.xml new file mode 100644 index 0000000000..c480798ac6 --- /dev/null +++ b/contentcuration/contentcuration/tests/utils/qti/fixtures/single_selection_no_answers.xml @@ -0,0 +1,9 @@ + + + + +
+

What is 2+2?

+
+ + diff --git a/contentcuration/contentcuration/tests/utils/qti/test_convert.py b/contentcuration/contentcuration/tests/utils/qti/test_convert.py index 63d5ec32e2..327647324a 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_convert.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_convert.py @@ -117,6 +117,74 @@ def test_true_false(self): ) self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + def test_single_selection_no_answers(self): + item = _make_item( + type=exercises.SINGLE_SELECTION, + question="What is 2+2?", + answers=[], + randomize=True, + assessment_id="abcdef1234567890abcdef1234567890", + ) + + result = convert_legacy_assessment_item_to_qti(item) + + self.assertEqual(result.identifier, "Kq83vEjRWeJCrze8SNFZ4kA") + self.assertEqual( + _normalize_xml(_load_fixture("single_selection_no_answers.xml")), + _normalize_xml(result.xml), + ) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_choice_types_with_no_answers_omit_the_interaction(self): + # The guard is on the choice types as a group, not just SINGLE_SELECTION, + # which test_single_selection_no_answers already pins against the fixture. + for question_type in (exercises.MULTIPLE_SELECTION, "true_false"): + with self.subTest(question_type=question_type): + item = _make_item( + type=question_type, + question="What is 2+2?", + answers=[], + assessment_id="abcdef1234567890abcdef1234567890", + ) + + result = convert_legacy_assessment_item_to_qti(item) + + self.assertNotIn("qti-choice-interaction", result.xml) + self.assertNotIn("qti-response-declaration", result.xml) + self.assertNotIn("qti-response-processing", result.xml) + self.assertIn("

What is 2+2?

", result.xml) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_choice_type_with_no_answers_and_no_question(self): + # The model's own defaults, and qti-item-body cannot be empty - so an + # untyped question carries an empty paragraph. + item = _make_item( + type=exercises.MULTIPLE_SELECTION, + question="", + answers=[], + assessment_id="abcdef1234567890abcdef1234567890", + ) + + result = convert_legacy_assessment_item_to_qti(item) + + self.assertIn("

", result.xml) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_choice_type_with_no_answers_and_block_maths(self): + # Block maths renders as a top level , which qti-item-body does not + # accept directly. Validity is not asserted: the MathML namespace gap + # test_free_response_with_maths lives with is unrelated here. + item = _make_item( + type=exercises.SINGLE_SELECTION, + question="$$\\sum_n^sxa^n$$", + answers=[], + assessment_id="abcdef1234567890abcdef1234567890", + ) + + result = convert_legacy_assessment_item_to_qti(item) + + self.assertIn('', result.xml) + def test_media_reference_survives(self): item = _make_item( type=exercises.SINGLE_SELECTION, diff --git a/contentcuration/contentcuration/utils/assessment/qti/convert.py b/contentcuration/contentcuration/utils/assessment/qti/convert.py index 2a64cd251f..cbe00d486a 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/convert.py +++ b/contentcuration/contentcuration/utils/assessment/qti/convert.py @@ -142,8 +142,15 @@ def _response_declaration( def _create_choice_interaction_and_response( item: LegacyAssessmentItem, -) -> Tuple[ChoiceInteraction, ResponseDeclaration]: +) -> Tuple[Optional[ChoiceInteraction], Optional[ResponseDeclaration]]: """Create a QTI choice interaction for multiple choice questions.""" + if not item.answers: + # An answerless choice question is ordinary in-progress authoring state - + # it is what the editor writes for every newly added question - but the + # XSD requires a qti-choice-interaction to carry at least one + # qti-simple-choice, and there is nothing to bind a response to. + return None, None + multiple_select = item.type == exercises.MULTIPLE_SELECTION prompt = Prompt(children=_create_html_content_from_text(item.question)) @@ -312,16 +319,29 @@ def convert_legacy_assessment_item_to_qti( else: raise ValueError(f"Unsupported question type: {item.type}") - item_body = ItemBody(children=[interaction]) + if interaction is None: + # Emit the question text alone, ungraded. Div because rendered markdown + # can start with a top level , which qti-item-body does not accept + # directly; P() because the container cannot be empty and a newly added + # question has no text yet. + item_body = ItemBody( + children=[ + Div(children=_create_html_content_from_text(item.question) or [P()]) + ] + ) + response_declarations = [] + response_processing = None + else: + item_body = ItemBody(children=[interaction]) + response_declarations = [response_declaration] + response_processing = ResponseProcessing( + template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" + ) outcome_declaration = OutcomeDeclaration( identifier="SCORE", cardinality=Cardinality.SINGLE, base_type=BaseType.FLOAT ) - response_processing = ResponseProcessing( - template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" - ) - qti_item_id = hex_to_qti_id(item.assessment_id) qti_item = AssessmentItem( @@ -330,7 +350,7 @@ def convert_legacy_assessment_item_to_qti( language=item.language, adaptive=False, time_dependent=False, - response_declaration=[response_declaration], + response_declaration=response_declarations, outcome_declaration=[outcome_declaration], item_body=item_body, catalog_info=_create_catalog_info(item), From ece17a2f4d854f42d5e1baabdd6a44afa45a304f Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Fri, 31 Jul 2026 18:58:22 -0700 Subject: [PATCH 02/24] feat: convert still-legacy assessment items to QTI on read AssessmentItemViewSet.consolidate() replaces each still-legacy row's type and raw_data with the converter's output, so the frontend only ever receives type='QTI' with item XML in raw_data. QTI and perseus_question rows pass through as stored. The converted item is tagged with the bare lang_code of its content node's language, matching publish, so the XML the API hands out is the XML the channel publishes. A conversion failure surfaces as LegacyConversionError rather than the underlying ValueError, which serialize_object() would turn into a 404 - reporting a corrupt row as a missing one. Every read is already scoped to one content node by the required filter, so the cost of raising is that exercise, not the channel. This whole path goes away with the global backfill (#6007). --- .../tests/viewsets/test_assessmentitem.py | 191 ++++++++++++++++++ .../viewsets/assessmentitem.py | 38 ++++ 2 files changed, 229 insertions(+) diff --git a/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py b/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py index 1f3d1330f8..e4885fafdd 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py +++ b/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py @@ -14,6 +14,8 @@ from contentcuration.tests.viewsets.base import generate_delete_event from contentcuration.tests.viewsets.base import generate_update_event from contentcuration.tests.viewsets.base import SyncTestMixin +from contentcuration.utils.assessment.qti.validation import validate_qti_item +from contentcuration.viewsets.assessmentitem import LegacyConversionError from contentcuration.viewsets.sync.constants import ASSESSMENTITEM @@ -35,6 +37,14 @@ "", ) +CHOICE_ANSWERS = json.dumps( + [ + {"answer": "4", "correct": True, "order": 1}, + {"answer": "5", "correct": False, "order": 2}, + ] +) +TEXT_ANSWERS = json.dumps([{"answer": "4", "correct": True, "order": 1}]) + class SyncTestCase(SyncTestMixin, StudioAPITestCase): @property @@ -1177,6 +1187,187 @@ def test_delete_assessmentitem(self): self.assertEqual(response.status_code, 405, response.content) +class DualReadTestCase(StudioAPITestCase): + def setUp(self): + super(DualReadTestCase, self).setUp() + self.channel = testdata.channel() + self.user = testdata.user() + self.channel.editors.add(self.user) + self.node = ( + self.channel.main_tree.get_descendants() + .filter(kind_id=content_kinds.EXERCISE) + .first() + ) + self.client.force_authenticate(user=self.user) + + def _create_item(self, node=None, **kwargs): + return models.AssessmentItem.objects.create( + contentnode=node or self.node, assessment_id=uuid.uuid4().hex, **kwargs + ) + + def _list_items(self, **query): + # The fixture node carries its own assessment items, so key the response + # by assessment_id rather than indexing it. + response = self.client.get(reverse("assessmentitem-list"), query) + self.assertEqual(response.status_code, 200, response.content) + return {item["assessment_id"]: item for item in response.json()} + + def _get_item(self, assessment_id): + return self._list_items(contentnode=self.node.id)[assessment_id] + + def test_supported_legacy_types_returned_as_qti(self): + cases = [ + (exercises.SINGLE_SELECTION, CHOICE_ANSWERS, "qti-choice-interaction"), + (exercises.MULTIPLE_SELECTION, CHOICE_ANSWERS, "qti-choice-interaction"), + ("true_false", CHOICE_ANSWERS, "qti-choice-interaction"), + (exercises.INPUT_QUESTION, TEXT_ANSWERS, "qti-text-entry-interaction"), + (exercises.FREE_RESPONSE, TEXT_ANSWERS, "qti-text-entry-interaction"), + ] + created = [] + for item_type, answers, interaction in cases: + assessmentitem = self._create_item( + type=item_type, + question="What is 2+2?", + answers=answers, + hints=json.dumps([{"hint": "Count.", "order": 1}]), + ) + created.append((assessmentitem.assessment_id, item_type, interaction)) + + items = self._list_items(contentnode=self.node.id) + + for assessment_id, item_type, interaction in created: + with self.subTest(type=item_type): + item = items[assessment_id] + self.assertEqual(item["type"], exercises.QTI) + self.assertTrue(validate_qti_item(item["raw_data"]).is_valid) + self.assertIn(interaction, item["raw_data"]) + + def test_answerless_choice_item_is_returned_as_valid_qti(self): + # The shape the editor writes for every newly added question: a choice + # type with no answers and nothing typed into it yet. + assessment_id = self._create_item(type=exercises.SINGLE_SELECTION).assessment_id + + item = self._get_item(assessment_id) + + self.assertEqual(item["type"], exercises.QTI) + self.assertTrue(validate_qti_item(item["raw_data"]).is_valid) + + def test_converted_item_has_no_legacy_field_content(self): + assessment_id = self._create_item( + type=exercises.SINGLE_SELECTION, + question="What is 2+2?", + answers=CHOICE_ANSWERS, + hints=json.dumps([{"hint": "Count.", "order": 1}]), + ).assessment_id + + item = self._get_item(assessment_id) + + self.assertEqual(item["question"], "") + self.assertEqual(item["answers"], "[]") + self.assertEqual(item["hints"], "[]") + + def test_converted_items_are_tagged_with_their_own_node_language(self): + # A contentnode__in read spans several nodes, so each item has to pick + # up its own node's language rather than one language for the batch. + # pt-BR has a subcode, so the bare lang_code publish tags items with is + # distinguishable from the Language primary key. + self.node.language = models.Language.objects.get(id="pt-BR") + self.node.save() + other_node = models.ContentNode.objects.create( + id=uuid.uuid4().hex, + title="Exercise 2", + kind_id=content_kinds.EXERCISE, + parent=self.node.parent, + language=models.Language.objects.get(id="fr"), + ) + first = self._create_item( + type=exercises.SINGLE_SELECTION, + question="What is 2+2?", + answers=CHOICE_ANSWERS, + ) + second = self._create_item( + node=other_node, + type=exercises.SINGLE_SELECTION, + question="What is 3+3?", + answers=CHOICE_ANSWERS, + ) + + items = self._list_items( + contentnode__in=f"{self.node.id},{other_node.id}", + ) + + self.assertIn('language="pt"', items[first.assessment_id]["raw_data"]) + self.assertNotIn("pt-BR", items[first.assessment_id]["raw_data"]) + self.assertIn('language="fr"', items[second.assessment_id]["raw_data"]) + + def test_converted_item_defaults_to_english_without_node_language(self): + assessment_id = self._create_item( + type=exercises.SINGLE_SELECTION, + question="What is 2+2?", + answers=CHOICE_ANSWERS, + ).assessment_id + + item = self._get_item(assessment_id) + + self.assertIn('language="en"', item["raw_data"]) + + def test_perseus_question_returned_unchanged(self): + raw_data = '{"question": {"content": "raw perseus"}}' + assessment_id = self._create_item( + type=exercises.PERSEUS_QUESTION, raw_data=raw_data + ).assessment_id + + item = self._get_item(assessment_id) + + self.assertEqual(item["type"], exercises.PERSEUS_QUESTION) + self.assertEqual(item["raw_data"], raw_data) + + def test_native_qti_item_returned_unchanged(self): + assessment_id = self._create_item( + type=exercises.QTI, raw_data=VALID_CHOICE_ITEM + ).assessment_id + + item = self._get_item(assessment_id) + + self.assertEqual(item["type"], exercises.QTI) + self.assertEqual(item["raw_data"], VALID_CHOICE_ITEM) + + def test_detail_route_converts(self): + assessmentitem = self._create_item( + type=exercises.SINGLE_SELECTION, + question="What is 2+2?", + answers=CHOICE_ANSWERS, + ) + + response = self.client.get( + reverse("assessmentitem-detail", kwargs={"pk": assessmentitem.id}) + ) + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.json()["type"], exercises.QTI) + self.assertTrue(validate_qti_item(response.json()["raw_data"]).is_valid) + + def test_unconvertible_type_raises_on_list_route(self): + self._create_item(type="not_a_real_type", question="What is 2+2?") + + with self.assertRaises(LegacyConversionError): + self.client.get( + reverse("assessmentitem-list"), {"contentnode": self.node.id} + ) + + def test_unconvertible_type_is_not_a_404_on_detail_route(self): + # serialize_object() turns ValueError into a 404, which would report a + # corrupt row as a missing one - the failure must surface instead. + assessmentitem = self._create_item( + type="not_a_real_type", question="What is 2+2?" + ) + + with self.assertRaises(LegacyConversionError): + self.client.get( + reverse("assessmentitem-detail", kwargs={"pk": assessmentitem.id}) + ) + + class ContentIDTestCase(SyncTestMixin, StudioAPITestCase): def setUp(self): super(ContentIDTestCase, self).setUp() diff --git a/contentcuration/contentcuration/viewsets/assessmentitem.py b/contentcuration/contentcuration/viewsets/assessmentitem.py index e000c67371..0c6298464a 100644 --- a/contentcuration/contentcuration/viewsets/assessmentitem.py +++ b/contentcuration/contentcuration/viewsets/assessmentitem.py @@ -12,6 +12,7 @@ from contentcuration.models import ContentNode from contentcuration.models import File from contentcuration.models import generate_object_storage_name +from contentcuration.utils.assessment.qti.ingest import convert_legacy_question_to_qti from contentcuration.utils.assessment.qti.media import get_qti_media_references from contentcuration.utils.assessment.qti.validation import validate_qti_item from contentcuration.viewsets.base import BulkCreateMixin @@ -31,6 +32,14 @@ ) ) +# Everything else is converted to QTI on read until the global backfill (#6007) +# makes the conversion permanent and AssessmentItemViewSet.consolidate goes away. +PASSTHROUGH_TYPES = (exercises.QTI, exercises.PERSEUS_QUESTION) + + +class LegacyConversionError(Exception): + """A still-legacy assessment item could not be converted to QTI on read.""" + class AssessmentItemFilter(RequiredFilterSet): contentnode__in = UUIDInFilter(field_name="contentnode") @@ -332,8 +341,37 @@ class AssessmentItemViewSet(BulkCreateMixin, BulkUpdateMixin, ValuesViewset): "source_url", "randomize", "deleted", + # Only consumed by consolidate(), which pops it back off - publish tags + # an item with the bare lang_code of its content node's language + # (utils/assessment/qti/archive.py), so the read path matches. + "contentnode__language__lang_code", ) field_map = { "contentnode": "contentnode_id", } + + def consolidate(self, items, queryset): + for item in items: + language = item.pop("contentnode__language__lang_code", None) + if item["type"] in PASSTHROUGH_TYPES: + continue + try: + # A new dict, so the language does not leak into the response. + result = convert_legacy_question_to_qti(dict(item, language=language)) + except (ValueError, TypeError) as e: + # serialize_object() turns ValueError/TypeError into a 404 + # (base.py), reporting a corrupt row as a missing one; re-raise + # as a type it does not catch (pydantic and json errors both + # subclass ValueError). + raise LegacyConversionError( + f"Could not convert assessment item {item['assessment_id']} to QTI" + ) from e + item.update( + type=exercises.QTI, + raw_data=result.xml, + question="", + answers="[]", + hints="[]", + ) + return items From 1641714737c3f129709bec88b7400efaf543e86c Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Fri, 31 Jul 2026 19:24:07 -0700 Subject: [PATCH 03/24] fix: pin the item-body div wrapper and clarify dual-read comments The block-maths converter test asserted only that the rendered survived, which passes with or without the wrapping
that the test exists to justify - assert the wrapper it documents. Restore the lead sentence on PASSTHROUGH_TYPES so "everything else" has a referent, and note the answerless return in the choice-interaction helper's docstring now that it can return (None, None). --- .../contentcuration/tests/utils/qti/test_convert.py | 11 ++++++----- .../contentcuration/utils/assessment/qti/convert.py | 5 ++++- .../contentcuration/viewsets/assessmentitem.py | 5 +++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/contentcuration/contentcuration/tests/utils/qti/test_convert.py b/contentcuration/contentcuration/tests/utils/qti/test_convert.py index 327647324a..2c0aa4a406 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_convert.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_convert.py @@ -156,8 +156,8 @@ def test_choice_types_with_no_answers_omit_the_interaction(self): self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) def test_choice_type_with_no_answers_and_no_question(self): - # The model's own defaults, and qti-item-body cannot be empty - so an - # untyped question carries an empty paragraph. + # The model's own defaults, and qti-item-body cannot be empty - so a + # question with nothing typed into it yet carries an empty paragraph. item = _make_item( type=exercises.MULTIPLE_SELECTION, question="", @@ -172,8 +172,9 @@ def test_choice_type_with_no_answers_and_no_question(self): def test_choice_type_with_no_answers_and_block_maths(self): # Block maths renders as a top level , which qti-item-body does not - # accept directly. Validity is not asserted: the MathML namespace gap - # test_free_response_with_maths lives with is unrelated here. + # accept directly - hence the wrapping div. XSD validity is not asserted + # here: rendered MathML does not carry its namespace, the same gap + # test_free_response_with_maths lives with. item = _make_item( type=exercises.SINGLE_SELECTION, question="$$\\sum_n^sxa^n$$", @@ -183,7 +184,7 @@ def test_choice_type_with_no_answers_and_block_maths(self): result = convert_legacy_assessment_item_to_qti(item) - self.assertIn('', result.xml) + self.assertIn('
', result.xml) def test_media_reference_survives(self): item = _make_item( diff --git a/contentcuration/contentcuration/utils/assessment/qti/convert.py b/contentcuration/contentcuration/utils/assessment/qti/convert.py index cbe00d486a..9a88b4ded5 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/convert.py +++ b/contentcuration/contentcuration/utils/assessment/qti/convert.py @@ -143,7 +143,10 @@ def _response_declaration( def _create_choice_interaction_and_response( item: LegacyAssessmentItem, ) -> Tuple[Optional[ChoiceInteraction], Optional[ResponseDeclaration]]: - """Create a QTI choice interaction for multiple choice questions.""" + """ + Create a QTI choice interaction for multiple choice questions, or + ``(None, None)`` if the question has no answers to choose between. + """ if not item.answers: # An answerless choice question is ordinary in-progress authoring state - # it is what the editor writes for every newly added question - but the diff --git a/contentcuration/contentcuration/viewsets/assessmentitem.py b/contentcuration/contentcuration/viewsets/assessmentitem.py index 0c6298464a..be813c6d69 100644 --- a/contentcuration/contentcuration/viewsets/assessmentitem.py +++ b/contentcuration/contentcuration/viewsets/assessmentitem.py @@ -32,8 +32,9 @@ ) ) -# Everything else is converted to QTI on read until the global backfill (#6007) -# makes the conversion permanent and AssessmentItemViewSet.consolidate goes away. +# Types the read path returns as stored. Everything else is a legacy type that is +# converted to QTI on read until the global backfill (#6007) makes the conversion +# permanent, at which point AssessmentItemViewSet.consolidate goes away. PASSTHROUGH_TYPES = (exercises.QTI, exercises.PERSEUS_QUESTION) From 16642a3c57b8a3f4931870afdcbca3a6636fa4e6 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Fri, 31 Jul 2026 19:33:40 -0700 Subject: [PATCH 04/24] fix: surface IndexError from conversion and pin the response shape serialize_object() swallows IndexError alongside ValueError and TypeError into a 404, so an IndexError raised during conversion would report a corrupt row as a missing one - the failure mode consolidate()'s re-raise exists to prevent. Widen the caught tuple to match. Assert the node language the values tuple carries for the conversion does not leak into the response, on both the converted and passed-through branches; without the latter a pop placed after the passthrough check would ship an internal join key to the client. Correct the comment on the answerless item body: an empty
inside qti-item-body is XSD-valid, so the empty

is there to give the body a paragraph to render and edit, not to satisfy the schema. Co-Authored-By: Claude Opus 5 (1M context) --- .../contentcuration/tests/utils/qti/test_convert.py | 4 ++-- .../tests/viewsets/test_assessmentitem.py | 4 ++++ .../contentcuration/utils/assessment/qti/convert.py | 5 +++-- .../contentcuration/viewsets/assessmentitem.py | 10 +++++----- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/contentcuration/contentcuration/tests/utils/qti/test_convert.py b/contentcuration/contentcuration/tests/utils/qti/test_convert.py index 2c0aa4a406..aff8c8b7c4 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_convert.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_convert.py @@ -156,8 +156,8 @@ def test_choice_types_with_no_answers_omit_the_interaction(self): self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) def test_choice_type_with_no_answers_and_no_question(self): - # The model's own defaults, and qti-item-body cannot be empty - so a - # question with nothing typed into it yet carries an empty paragraph. + # The model's own defaults - a question with nothing typed into it yet + # still carries an empty paragraph to render and edit. item = _make_item( type=exercises.MULTIPLE_SELECTION, question="", diff --git a/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py b/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py index e4885fafdd..e35673639f 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py +++ b/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py @@ -1265,6 +1265,8 @@ def test_converted_item_has_no_legacy_field_content(self): self.assertEqual(item["question"], "") self.assertEqual(item["answers"], "[]") self.assertEqual(item["hints"], "[]") + # The node language is only in the values tuple to feed the conversion. + self.assertNotIn("contentnode__language__lang_code", item) def test_converted_items_are_tagged_with_their_own_node_language(self): # A contentnode__in read spans several nodes, so each item has to pick @@ -1321,6 +1323,8 @@ def test_perseus_question_returned_unchanged(self): self.assertEqual(item["type"], exercises.PERSEUS_QUESTION) self.assertEqual(item["raw_data"], raw_data) + # A passed-through row must shed the node language too. + self.assertNotIn("contentnode__language__lang_code", item) def test_native_qti_item_returned_unchanged(self): assessment_id = self._create_item( diff --git a/contentcuration/contentcuration/utils/assessment/qti/convert.py b/contentcuration/contentcuration/utils/assessment/qti/convert.py index 9a88b4ded5..928f3d22e0 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/convert.py +++ b/contentcuration/contentcuration/utils/assessment/qti/convert.py @@ -325,8 +325,9 @@ def convert_legacy_assessment_item_to_qti( if interaction is None: # Emit the question text alone, ungraded. Div because rendered markdown # can start with a top level , which qti-item-body does not accept - # directly; P() because the container cannot be empty and a newly added - # question has no text yet. + # directly; the empty P stands in for the text a newly added question + # does not have yet, so the body is a paragraph to render and edit + # rather than a bare empty div. item_body = ItemBody( children=[ Div(children=_create_html_content_from_text(item.question) or [P()]) diff --git a/contentcuration/contentcuration/viewsets/assessmentitem.py b/contentcuration/contentcuration/viewsets/assessmentitem.py index be813c6d69..8cf986511e 100644 --- a/contentcuration/contentcuration/viewsets/assessmentitem.py +++ b/contentcuration/contentcuration/viewsets/assessmentitem.py @@ -360,11 +360,11 @@ def consolidate(self, items, queryset): try: # A new dict, so the language does not leak into the response. result = convert_legacy_question_to_qti(dict(item, language=language)) - except (ValueError, TypeError) as e: - # serialize_object() turns ValueError/TypeError into a 404 - # (base.py), reporting a corrupt row as a missing one; re-raise - # as a type it does not catch (pydantic and json errors both - # subclass ValueError). + except (IndexError, ValueError, TypeError) as e: + # serialize_object() turns IndexError/ValueError/TypeError into + # a 404 (base.py), reporting a corrupt row as a missing one; + # re-raise as a type it does not catch (pydantic and json errors + # both subclass ValueError). raise LegacyConversionError( f"Could not convert assessment item {item['assessment_id']} to QTI" ) from e From d793676bb6ea95832f24e4a529897233f0c4996f Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Fri, 31 Jul 2026 20:28:45 -0700 Subject: [PATCH 05/24] refactor: build the ungraded body in the choice converter The answerless-choice case returned (None, None) and left convert_legacy_assessment_item_to_qti to branch on it and rebuild the body, splitting one decision across two functions. Return the Div directly instead, so the caller always has an item body and only the response declaration is optional. Drop the defensive default on the contentnode__language__lang_code pop: the key is in values, so a missing one is a bug, not a case to absorb. Trim comments that restated the code they sat above. --- .../tests/utils/qti/test_convert.py | 12 ++--- .../tests/viewsets/test_assessmentitem.py | 14 +++--- .../utils/assessment/qti/convert.py | 48 ++++++++----------- .../viewsets/assessmentitem.py | 18 ++++--- 4 files changed, 39 insertions(+), 53 deletions(-) diff --git a/contentcuration/contentcuration/tests/utils/qti/test_convert.py b/contentcuration/contentcuration/tests/utils/qti/test_convert.py index aff8c8b7c4..c5f5e7c649 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_convert.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_convert.py @@ -136,8 +136,8 @@ def test_single_selection_no_answers(self): self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) def test_choice_types_with_no_answers_omit_the_interaction(self): - # The guard is on the choice types as a group, not just SINGLE_SELECTION, - # which test_single_selection_no_answers already pins against the fixture. + # The guard covers the choice types as a group; test_single_selection_no_answers + # pins SINGLE_SELECTION against the fixture. for question_type in (exercises.MULTIPLE_SELECTION, "true_false"): with self.subTest(question_type=question_type): item = _make_item( @@ -156,8 +156,6 @@ def test_choice_types_with_no_answers_omit_the_interaction(self): self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) def test_choice_type_with_no_answers_and_no_question(self): - # The model's own defaults - a question with nothing typed into it yet - # still carries an empty paragraph to render and edit. item = _make_item( type=exercises.MULTIPLE_SELECTION, question="", @@ -171,10 +169,8 @@ def test_choice_type_with_no_answers_and_no_question(self): self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) def test_choice_type_with_no_answers_and_block_maths(self): - # Block maths renders as a top level , which qti-item-body does not - # accept directly - hence the wrapping div. XSD validity is not asserted - # here: rendered MathML does not carry its namespace, the same gap - # test_free_response_with_maths lives with. + # Validity is not asserted: rendered MathML carries no namespace, the same + # gap test_free_response_with_maths lives with. item = _make_item( type=exercises.SINGLE_SELECTION, question="$$\\sum_n^sxa^n$$", diff --git a/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py b/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py index e35673639f..2b95b9833b 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py +++ b/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py @@ -1243,8 +1243,7 @@ def test_supported_legacy_types_returned_as_qti(self): self.assertIn(interaction, item["raw_data"]) def test_answerless_choice_item_is_returned_as_valid_qti(self): - # The shape the editor writes for every newly added question: a choice - # type with no answers and nothing typed into it yet. + # The shape the editor writes for every newly added question. assessment_id = self._create_item(type=exercises.SINGLE_SELECTION).assessment_id item = self._get_item(assessment_id) @@ -1269,10 +1268,9 @@ def test_converted_item_has_no_legacy_field_content(self): self.assertNotIn("contentnode__language__lang_code", item) def test_converted_items_are_tagged_with_their_own_node_language(self): - # A contentnode__in read spans several nodes, so each item has to pick - # up its own node's language rather than one language for the batch. - # pt-BR has a subcode, so the bare lang_code publish tags items with is - # distinguishable from the Language primary key. + # A contentnode__in read spans several nodes, so each item must pick up its + # own node's language; pt-BR has a subcode, so the bare lang_code publish + # tags items with is distinguishable from the Language primary key. self.node.language = models.Language.objects.get(id="pt-BR") self.node.save() other_node = models.ContentNode.objects.create( @@ -1360,8 +1358,8 @@ def test_unconvertible_type_raises_on_list_route(self): ) def test_unconvertible_type_is_not_a_404_on_detail_route(self): - # serialize_object() turns ValueError into a 404, which would report a - # corrupt row as a missing one - the failure must surface instead. + # Only this route goes through serialize_object(), which is what would + # otherwise swallow the failure into a 404. assessmentitem = self._create_item( type="not_a_real_type", question="What is 2+2?" ) diff --git a/contentcuration/contentcuration/utils/assessment/qti/convert.py b/contentcuration/contentcuration/utils/assessment/qti/convert.py index 928f3d22e0..b9aa01b61f 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/convert.py +++ b/contentcuration/contentcuration/utils/assessment/qti/convert.py @@ -7,6 +7,7 @@ from typing import List from typing import Optional from typing import Tuple +from typing import Union from le_utils.constants import exercises @@ -142,17 +143,17 @@ def _response_declaration( def _create_choice_interaction_and_response( item: LegacyAssessmentItem, -) -> Tuple[Optional[ChoiceInteraction], Optional[ResponseDeclaration]]: - """ - Create a QTI choice interaction for multiple choice questions, or - ``(None, None)`` if the question has no answers to choose between. - """ +) -> Tuple[Union[ChoiceInteraction, Div], Optional[ResponseDeclaration]]: + """Create a QTI choice interaction for multiple choice questions.""" if not item.answers: - # An answerless choice question is ordinary in-progress authoring state - - # it is what the editor writes for every newly added question - but the - # XSD requires a qti-choice-interaction to carry at least one - # qti-simple-choice, and there is nothing to bind a response to. - return None, None + # An answerless choice question is ordinary in-progress authoring state, + # but the XSD requires at least one qti-simple-choice and there is no + # response to bind, so emit the question alone, ungraded. Div because + # rendered markdown can start with a top level , which + # qti-item-body does not accept; empty P so an untyped question still + # renders as an editable paragraph. + body = _create_html_content_from_text(item.question) or [P()] + return Div(children=body), None multiple_select = item.type == exercises.MULTIPLE_SELECTION @@ -322,25 +323,18 @@ def convert_legacy_assessment_item_to_qti( else: raise ValueError(f"Unsupported question type: {item.type}") - if interaction is None: - # Emit the question text alone, ungraded. Div because rendered markdown - # can start with a top level , which qti-item-body does not accept - # directly; the empty P stands in for the text a newly added question - # does not have yet, so the body is a paragraph to render and edit - # rather than a bare empty div. - item_body = ItemBody( - children=[ - Div(children=_create_html_content_from_text(item.question) or [P()]) - ] - ) - response_declarations = [] - response_processing = None - else: - item_body = ItemBody(children=[interaction]) - response_declarations = [response_declaration] - response_processing = ResponseProcessing( + item_body = ItemBody(children=[interaction]) + + response_declarations = ( + [response_declaration] if response_declaration is not None else [] + ) + response_processing = ( + ResponseProcessing( template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" ) + if response_declarations + else None + ) outcome_declaration = OutcomeDeclaration( identifier="SCORE", cardinality=Cardinality.SINGLE, base_type=BaseType.FLOAT diff --git a/contentcuration/contentcuration/viewsets/assessmentitem.py b/contentcuration/contentcuration/viewsets/assessmentitem.py index 8cf986511e..5b42dab213 100644 --- a/contentcuration/contentcuration/viewsets/assessmentitem.py +++ b/contentcuration/contentcuration/viewsets/assessmentitem.py @@ -32,9 +32,8 @@ ) ) -# Types the read path returns as stored. Everything else is a legacy type that is -# converted to QTI on read until the global backfill (#6007) makes the conversion -# permanent, at which point AssessmentItemViewSet.consolidate goes away. +# Everything else is a legacy type, converted to QTI on read until the global +# backfill (#6007) makes that permanent and consolidate() goes away. PASSTHROUGH_TYPES = (exercises.QTI, exercises.PERSEUS_QUESTION) @@ -342,8 +341,8 @@ class AssessmentItemViewSet(BulkCreateMixin, BulkUpdateMixin, ValuesViewset): "source_url", "randomize", "deleted", - # Only consumed by consolidate(), which pops it back off - publish tags - # an item with the bare lang_code of its content node's language + # Only consumed by consolidate(), which pops it back off. Publish tags an + # item with the bare lang_code of its content node's language # (utils/assessment/qti/archive.py), so the read path matches. "contentnode__language__lang_code", ) @@ -354,17 +353,16 @@ class AssessmentItemViewSet(BulkCreateMixin, BulkUpdateMixin, ValuesViewset): def consolidate(self, items, queryset): for item in items: - language = item.pop("contentnode__language__lang_code", None) + language = item.pop("contentnode__language__lang_code") if item["type"] in PASSTHROUGH_TYPES: continue try: # A new dict, so the language does not leak into the response. result = convert_legacy_question_to_qti(dict(item, language=language)) except (IndexError, ValueError, TypeError) as e: - # serialize_object() turns IndexError/ValueError/TypeError into - # a 404 (base.py), reporting a corrupt row as a missing one; - # re-raise as a type it does not catch (pydantic and json errors - # both subclass ValueError). + # serialize_object() turns these into a 404 (base.py), reporting + # a corrupt row as a missing one; re-raise as a type it does not + # catch. pydantic and json errors both subclass ValueError. raise LegacyConversionError( f"Could not convert assessment item {item['assessment_id']} to QTI" ) from e From abc945e305d04773367082b3c07348c054c63a3a Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:18:28 -0500 Subject: [PATCH 06/24] fix: omit empty value containers from QTI declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A declaration with no values serialized as an empty , which the QTI schema rejects: the element is optional, but must hold at least one when present. Every save of a question with no correct answer yet — including every newly created one — was refused by the server. Capabilities now return null when they have nothing to serialize, and the declaration drops them instead of emitting an empty element. Co-Authored-By: Claude Opus 5 (1M context) --- .../views/QTIEditor/serialization/qti/QTIDeclaration.js | 6 +++++- .../qti/__tests__/declarations/correctResponse.spec.js | 8 ++++---- .../serialization/qti/declarations/correctResponse.js | 9 ++++++++- .../serialization/qti/declarations/defaultValue.js | 8 +++++++- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js index 9941b80309..ac2ef196d1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js @@ -330,7 +330,11 @@ export class QTIDeclaration { attrs['base-type'] = this.baseType; } - const children = Object.values(this._capabilities).map(cap => cap.getXML()); + // A capability returns null when it has nothing valid to serialize (e.g. a correct + // response with no values); those are dropped rather than emitted empty. + const children = Object.values(this._capabilities) + .map(cap => cap.getXML()) + .filter(Boolean); return buildXmlNode({ tag: this.tag, attrs, children }); } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js index 7aa7c250fc..3b9f87fe08 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js @@ -110,10 +110,10 @@ describe('CorrectResponse', () => { expect(values).toEqual(['ChoiceA', 'ChoiceC']); }); - it('produces an empty qti-correct-response when values is empty', () => { - expect( - new CorrectResponse([], makeDeclaration()).getXML().querySelectorAll('qti-value').length, - ).toBe(0); + it('produces no element at all when values is empty', () => { + // The schema requires at least one qti-value inside qti-correct-response, so an + // answer-less declaration omits the element instead of emitting an empty one. + expect(new CorrectResponse([], makeDeclaration()).getXML()).toBeNull(); }); it('round-trips: qti-value child carries correct text', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js index e492ea8c9b..c492e4712d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js @@ -42,9 +42,16 @@ export default class CorrectResponse { } /** - * @returns {Element} + * `qti-correct-response` is optional but must hold at least one `qti-value` when + * present, so an answer-less declaration omits the element rather than emitting an + * empty one the schema would reject. + * + * @returns {Element|null} */ getXML() { + if (!this._values.length) { + return null; + } return buildXmlNode({ tag: 'qti-correct-response', children: this._declaration diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js index 00432f8e0b..0712c94f42 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js @@ -40,9 +40,15 @@ export default class DefaultValue { } /** - * @returns {Element} + * Like `qti-correct-response`, the element is optional but must hold at least one + * `qti-value`, so an empty one is omitted rather than emitted. + * + * @returns {Element|null} */ getXML() { + if (!this._values.length) { + return null; + } return buildXmlNode({ tag: 'qti-default-value', children: this._declaration From a6f87c4f8dcbc648aa14bd845f5b277522aa8d57 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:18:51 -0500 Subject: [PATCH 07/24] fix: keep authored markup in the item's QTI namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTML parser puts fragments in the XHTML namespace, and importing those nodes into the XML document made XMLSerializer write an explicit xmlns on every element —

Lima

. The QTI schema expects inline content in the namespace the item root declares, so the server rejected any question whose prompt or answers carried markup. HTML-parsed nodes are now re-created in the XML document without a namespace, so they inherit the item's. Foreign subtrees (MathML from the formula button, SVG) keep theirs, which QTI does expect declared. The text-entry builder reached the same trap from the other side: it parsed the prompt itself and passed the nodes as `children`, which still go through importNode. It hands the prompt to buildXmlNode as innerHTML now, and appends the interaction paragraph afterwards, so there is one adoption path rather than two ways in. Co-Authored-By: Claude Opus 5 (1M context) --- .../textEntry/__tests__/parse.spec.js | 22 ++++++++ .../QTIEditor/interactions/textEntry/parse.js | 13 ++--- .../__tests__/assembleItem.spec.js | 32 ++++++++++++ .../QTIEditor/serialization/assembleItem.js | 50 ++++++++++++++++++- 4 files changed, 107 insertions(+), 10 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js index 9d308bfe41..f507069708 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js @@ -243,6 +243,28 @@ describe('buildTextEntryInteractionXML', () => { expect(doc.querySelector('qti-item-body')).not.toBeNull(); }); + it('leaves no xhtml namespace on the prompt markup', () => { + // The prompt comes from the HTML parser; an explicit xmlns on it makes the whole + // item fail schema validation on the server. + const { bodyXml } = buildTextEntryInteractionXML( + { prompt: '

What is H2O?

', answers: [], expectedLength: 0 }, + QuestionType.FREE_RESPONSE, + FREE_SCHEMA, + ); + expect(bodyXml).not.toContain('http://www.w3.org/1999/xhtml'); + }); + + it('keeps the prompt before the interaction', () => { + const { bodyXml } = buildTextEntryInteractionXML( + { prompt: '

Question

', answers: [], expectedLength: 0 }, + QuestionType.FREE_RESPONSE, + FREE_SCHEMA, + ); + expect(bodyXml.indexOf('Question')).toBeLessThan( + bodyXml.indexOf('qti-text-entry-interaction'), + ); + }); + it('contains a element', () => { const { bodyXml } = buildTextEntryInteractionXML( { prompt: '', answers: [], expectedLength: 0 }, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js index 921bd47091..2fd397905f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -199,15 +199,10 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch children: [interactionEl], }); - // Build body children: prompt HTML nodes (if any) followed by the interaction paragraph. - const bodyChildren = []; - if (prompt) { - const promptDoc = parseXML(`${prompt}`, 'text/html'); - bodyChildren.push(...promptDoc.body.childNodes); - } - bodyChildren.push(interactionParagraph); - - const bodyEl = buildXmlNode({ tag: 'qti-item-body', children: bodyChildren }); + // The prompt is authored HTML, so it goes in through innerHTML: buildXmlNode parses it + // and adopts the result into the item's namespace. + const bodyEl = buildXmlNode({ tag: 'qti-item-body', innerHTML: prompt || '' }); + bodyEl.appendChild(interactionParagraph); const bodyXml = serializer.serializeToString(bodyEl); // Build the response declaration. diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js index 47192f2ddc..555ea86f03 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js @@ -142,6 +142,38 @@ describe('assembleItem', () => { buildXmlNode({ tag: 'qti-simple-choice', children: ['x'], innerHTML: '

y

' }), ).toThrow('mutually exclusive'); }); + + it('leaves no xhtml namespace on the markup it appends', () => { + // The QTI schema expects inline content in the namespace the item root declares, so + // an explicit xmlns from the HTML parser makes the whole item invalid on the server. + const node = buildXmlNode({ + tag: 'qti-simple-choice', + innerHTML: '

Lima

', + }); + expect(new XMLSerializer().serializeToString(node)).toBe( + '

Lima

', + ); + }); + + it('drops an xhtml namespace already carried by stored content', () => { + const node = buildXmlNode({ + tag: 'qti-simple-choice', + innerHTML: '

Lima

', + }); + expect(new XMLSerializer().serializeToString(node)).toBe( + '

Lima

', + ); + }); + + it('keeps a foreign namespace, which QTI expects declared', () => { + const node = buildXmlNode({ + tag: 'qti-prompt', + innerHTML: 'x', + }); + expect(new XMLSerializer().serializeToString(node)).toContain( + '', + ); + }); }); describe('innerHTML — HTML5 void elements (TipTap regression)', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js index 48295a8889..a100ffed3f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js @@ -13,6 +13,51 @@ import { parseXML } from './parseItem'; const xmlDoc = new DOMParser().parseFromString('', 'text/xml'); const serializer = new XMLSerializer(); +const XHTML_NS = 'http://www.w3.org/1999/xhtml'; + +/** + * Re-create a node parsed from HTML inside the XML document. + * + * The HTML parser puts elements in the XHTML namespace, and XMLSerializer then writes + * that out as an explicit `xmlns` on every element it produces — `

`. + * The QTI schema rejects that: inline content belongs to the QTI namespace the item root + * declares, so these elements have to be namespace-less in order to inherit it. Foreign + * subtrees (MathML, SVG) keep their own namespace, which QTI does expect declared. + * + * @param {Node} node + * @returns {Node|null} null for node types that carry no content (comments, etc.) + */ +function adoptHtmlNode(node) { + if (node.nodeType === Node.TEXT_NODE) { + return xmlDoc.createTextNode(node.nodeValue); + } + if (node.nodeType !== Node.ELEMENT_NODE) { + return null; + } + + const namespace = node.namespaceURI; + const el = + !namespace || namespace === XHTML_NS + ? xmlDoc.createElement(node.localName) + : xmlDoc.createElementNS(namespace, node.tagName); + + for (const attr of node.attributes) { + // A literal xmlns attribute would re-introduce the namespace we just dropped. + if (attr.name !== 'xmlns') { + el.setAttribute(attr.name, attr.value); + } + } + + for (const child of node.childNodes) { + const adopted = adoptHtmlNode(child); + if (adopted) { + el.appendChild(adopted); + } + } + + return el; +} + /** * Build an XML element node. * @@ -42,7 +87,10 @@ export function buildXmlNode({ tag, attrs = {}, children, innerHTML }) { if (innerHTML !== undefined) { const htmlDoc = parseXML(`${innerHTML}`, 'text/html'); for (const child of [...htmlDoc.body.childNodes]) { - el.appendChild(xmlDoc.importNode(child, true)); + const adopted = adoptHtmlNode(child); + if (adopted) { + el.appendChild(adopted); + } } } else { for (const child of children ?? []) { From 5d55a27187b17b8493121d5113f14c4e1bbd0dfa Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:19:10 -0500 Subject: [PATCH 08/24] fix: use the assessment item type value the API returns The type is stored and served as "QTI" (le_utils exercises.QTI), not "qti". With the lowercase value nothing matched: every question rendered as "Unknown type" with editing disabled, and a newly created item would have failed the model's type choices on the way to the server. Co-Authored-By: Claude Opus 5 (1M context) --- .../frontend/shared/views/QTIEditor/constants.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index 3fa2bcd6a4..d6e33a1c6b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -34,14 +34,14 @@ export const Orientation = Object.freeze({ * 2. QuestionType -> The type editors will select per assessment item. * It's different from AssessmentItemType because we will extend this for all * new question types without confusing it with values stored in the database - * (all of these will be assessment item type: "qti"). Value is related to how + * (all of these will be assessment item type: "QTI"). Value is related to how * Studio presents different question options to users in the UI. * * 3. InteractionType (QtiInteraction) -> The actual interactions defined by QTI, * and the ones that dictate how to parse and what descriptor we will use. * Each QTI interaction can have multiple related question types (e.g., choice * can be singleSelect or multiSelect), but all of them will have assessment - * item type "qti". + * item type "QTI". */ /** @@ -66,7 +66,8 @@ export const QTI_INTERACTION_TAGS = Object.freeze(Object.values(QtiInteraction)) * by the broader Studio assessment system, not by this editor. */ export const AssessmentItemTypes = Object.freeze({ - QTI: 'qti', + // Matches the value the API stores and returns (le_utils exercises.QTI). + QTI: 'QTI', }); /** From 140cde7e234f0c73c846b6bd35c2fd776a31c50c Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:19:29 -0500 Subject: [PATCH 09/24] refactor: split the pure interaction registry from the editor components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio needs to know whether a question is complete without rendering it, and shared/utils/validation.js — where that check lives — is imported by shared views on every webpack entry. Reaching the descriptors through a registry that also holds the interaction editors would have pulled them, and TipTap with them, into every bundle. So an interaction is now registered in two places, each obvious from what it imports: descriptors.js imports Descriptor.js files and nothing else, index.js imports the Editor.vue files and re-exports the descriptors. A descriptor no longer carries its own editor component, which means nothing has to reach in and attach one — defineInteraction and the per-interaction index modules are gone, and InteractionSection resolves the component from the editors map by interaction type. The two lists have to agree, so a test asserts they do. Descriptors extend an InteractionDescriptor base class that checks the contract as the singleton is constructed, replacing defineInteraction's key check, and supplies the defaults that were repeated in each descriptor: matching by tag name, and contributing no question type options. Files are named for their role — choice/Descriptor.js, choice/Editor.vue — so a new interaction is two conventionally-named files and one line in each registry. Placement joins that contract rather than being assigned by hand afterwards, which lets it become the single source of truth for something constants.js used to restate: INLINE_INTERACTION_TAGS existed because parseItem could not ask the registry without a cycle, since the descriptors import parseItem for parseXML. That cycle was only there because one module held two layers, so the leaf DOM helpers move to serialization/xml.js — leaving parseItem free to ask the registry through isInlineInteraction, and leaving an inline interaction with nothing to declare beyond its own placement. Descriptor resolution moves out of useInteractionDescriptor into a pure resolveDescriptor, so the editor and the headless validator share one path, and reports its parse failure as a ValidationError code the caller presents. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/InteractionSection/index.vue | 7 +- .../composables/useChoiceInteraction.js | 2 +- .../QTIEditor/composables/useInteraction.js | 2 +- .../composables/useInteractionDescriptor.js | 36 +------ .../composables/useOrderingInteraction.js | 2 +- .../composables/useTextEntryInteraction.js | 2 +- .../shared/views/QTIEditor/constants.js | 15 +-- .../interactions/InteractionDescriptor.js | 82 ++++++++++++++ .../__tests__/InteractionDescriptor.spec.js | 102 ++++++++++++++++++ .../__tests__/defineInteraction.spec.js | 72 ------------- .../interactions/__tests__/registry.spec.js | 48 +++++++++ ...InteractionDescriptor.js => Descriptor.js} | 20 ++-- ...ChoiceInteractionEditor.vue => Editor.vue} | 0 ...nDescriptor.spec.js => Descriptor.spec.js} | 2 +- ...teractionEditor.spec.js => Editor.spec.js} | 2 +- .../choice/__tests__/parse.spec.js | 2 +- .../choice/__tests__/validate.spec.js | 2 +- .../QTIEditor/interactions/choice/index.js | 5 - .../QTIEditor/interactions/choice/parse.js | 2 +- .../interactions/defineInteraction.js | 47 -------- .../QTIEditor/interactions/descriptors.js | 62 +++++++++++ .../views/QTIEditor/interactions/index.js | 47 ++++---- ...InteractionDescriptor.js => Descriptor.js} | 18 ++-- ...deringInteractionEditor.vue => Editor.vue} | 0 ...teractionEditor.spec.js => Editor.spec.js} | 6 +- .../ordering/__tests__/parse.spec.js | 2 +- .../QTIEditor/interactions/ordering/index.js | 5 - .../QTIEditor/interactions/ordering/parse.js | 2 +- .../interactions/resolveDescriptor.js | 42 ++++++++ ...InteractionDescriptor.js => Descriptor.js} | 25 ++--- .../{TextEntryEditor.vue => Editor.vue} | 0 ...TextEntryEditor.spec.js => Editor.spec.js} | 2 +- .../QTIEditor/interactions/textEntry/index.js | 5 - .../QTIEditor/interactions/textEntry/parse.js | 2 +- .../serialization/__tests__/parseItem.spec.js | 42 +------- .../serialization/__tests__/xml.spec.js | 58 ++++++++++ .../QTIEditor/serialization/assembleItem.js | 2 +- .../QTIEditor/serialization/parseItem.js | 58 ++-------- .../serialization/qti/QTISanitizer.js | 2 +- .../serialization/qti/__tests__/testUtils.js | 2 +- .../views/QTIEditor/serialization/xml.js | 50 +++++++++ 41 files changed, 533 insertions(+), 351 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/InteractionDescriptor.spec.js delete mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js rename contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/{ChoiceInteractionDescriptor.js => Descriptor.js} (86%) rename contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/{ChoiceInteractionEditor.vue => Editor.vue} (100%) rename contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/{ChoiceInteractionDescriptor.spec.js => Descriptor.spec.js} (96%) rename contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/{ChoiceInteractionEditor.spec.js => Editor.spec.js} (99%) delete mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js delete mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js rename contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/{OrderingInteractionDescriptor.js => Descriptor.js} (84%) rename contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/{OrderingInteractionEditor.vue => Editor.vue} (100%) rename contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/{OrderingInteractionEditor.spec.js => Editor.spec.js} (98%) delete mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/index.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js rename contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/{TextEntryInteractionDescriptor.js => Descriptor.js} (85%) rename contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/{TextEntryEditor.vue => Editor.vue} (100%) rename contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/{TextEntryEditor.spec.js => Editor.spec.js} (99%) delete mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/xml.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue index 060b191f58..c7fc3492e9 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue @@ -16,7 +16,7 @@ /> editors[descriptor.value.type]); + return { descriptor, + editorComponent, questionType, parseError, onUpdateQuestionType, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js index d030f693d2..4b30e20ed3 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js @@ -1,7 +1,7 @@ import { computed, readonly } from 'vue'; import { QuestionType } from '../constants'; import { generateRandomSlug } from '../utils/generateRandomSlug'; -import { choiceInteractionDescriptor } from '../interactions/choice/ChoiceInteractionDescriptor'; +import { choiceInteractionDescriptor } from '../interactions/choice/Descriptor'; import { useInteraction } from './useInteraction'; /** diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js index f9c9d64691..b0d0b25e62 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js @@ -13,7 +13,7 @@ import debounce from 'lodash/debounce'; * only appear after the user pauses typing (400 ms), avoiding noisy * inline error flicker on every keystroke. * - * @param {import('../interactions/defineInteraction').InteractionDescriptor} descriptor + * @param {import('../interactions/InteractionDescriptor').InteractionDescriptor} descriptor * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock * @param {import('vue').Ref} questionType * @returns {{ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js index 76b40f311b..2bba518481 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js @@ -1,6 +1,6 @@ import { computed, ref } from 'vue'; -import { parseXML } from '../serialization/parseItem'; import { descriptors, registry, DEFAULT_INTERACTION } from '../interactions/index'; +import { resolveDescriptor } from '../interactions/resolveDescriptor'; import { qtiEditorStrings } from '../qtiEditorStrings'; const { errorParsingQuestion$ } = qtiEditorStrings; @@ -14,48 +14,20 @@ const { errorParsingQuestion$ } = qtiEditorStrings; */ export default function useInteractionDescriptor(interactionRef) { /** - * Parses bodyXml and returns the matching descriptor, resolved - * question type, and any parse error without touching reactive state. - */ - function inferFromXml(xml, declarations) { - if (!xml) { - return { descriptor: registry[DEFAULT_INTERACTION], questionType: null, error: null }; - } - try { - const doc = parseXML(xml); - const interactionEl = doc.documentElement; - const desc = descriptors.find(d => d.matches(interactionEl)) ?? registry[DEFAULT_INTERACTION]; - return { - descriptor: desc, - questionType: desc.getQuestionType(interactionEl, declarations) ?? null, - error: null, - }; - } catch (e) { - // eslint-disable-next-line no-console - console.error('[QTI] Failed to parse interaction XML:', e.message); - return { - descriptor: registry[DEFAULT_INTERACTION], - questionType: null, - error: errorParsingQuestion$(), - }; - } - } - - /** - * Parse the initial XML synchronously during component setup. + * Resolve the initial XML synchronously during component setup. * * This ensures `questionType` is immediately available for downstream components * on first render, avoiding prop validation warnings that would occur if * initialization was deferred to a lifecycle hook. */ - const initial = inferFromXml( + const initial = resolveDescriptor( interactionRef.value?.bodyXml, interactionRef.value?.responseDeclarations, ); /** Writable ref driven by UI selections after initial parse. */ const questionType = ref(initial.questionType); - const parseError = ref(initial.error); + const parseError = ref(initial.error ? errorParsingQuestion$() : null); /** * Derived from questionType so the descriptor updates when the user switches diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js index 9590207c3b..d38a0fa1a2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js @@ -1,6 +1,6 @@ import { readonly } from 'vue'; import { generateRandomSlug } from '../utils/generateRandomSlug'; -import { orderingInteractionDescriptor } from '../interactions/ordering/OrderingInteractionDescriptor'; +import { orderingInteractionDescriptor } from '../interactions/ordering/Descriptor'; import { useInteraction } from './useInteraction'; /** diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js index e607fc206a..43d9546312 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js @@ -1,6 +1,6 @@ import { readonly } from 'vue'; import { generateRandomSlug } from '../utils/generateRandomSlug'; -import { textEntryInteractionDescriptor } from '../interactions/textEntry/TextEntryInteractionDescriptor'; +import { textEntryInteractionDescriptor } from '../interactions/textEntry/Descriptor'; import { useInteraction } from './useInteraction'; /** diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index d6e33a1c6b..1ebd3f003e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -90,6 +90,10 @@ export const QuestionType = Object.freeze({ * this set in their own validate.js module. */ export const ValidationError = Object.freeze({ + // Item-level codes, produced by validateItem.js rather than an interaction + PARSE_ERROR: 'PARSE_ERROR', + NO_INTERACTION: 'NO_INTERACTION', + FREE_RESPONSE_NOT_ALLOWED: 'FREE_RESPONSE_NOT_ALLOWED', PROMPT_REQUIRED: 'PROMPT_REQUIRED', NO_CORRECT_ANSWER: 'NO_CORRECT_ANSWER', TOO_MANY_CORRECT_ANSWERS: 'TOO_MANY_CORRECT_ANSWERS', @@ -103,10 +107,7 @@ export const ValidationError = Object.freeze({ export const RESPONSE_IDENTIFIER = 'RESPONSE'; -/** - * Set of QTI interaction tag names that have `placement: 'inline'`. - * Used by parseItem to decide whether to serialize the full `` - * (inline) or just the interaction element (block). - * Kept here to avoid a circular dependency with the descriptor registry. - */ -export const INLINE_INTERACTION_TAGS = new Set([QtiInteraction.TEXT_ENTRY]); +export const Placement = Object.freeze({ + BLOCK: 'block', + INLINE: 'inline', +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js new file mode 100644 index 0000000000..8a2c603ad7 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js @@ -0,0 +1,82 @@ +/** + * Base class for every interaction descriptor. + * + * A descriptor owns everything about one QTI interaction except how it looks: recognising + * its element, resolving which question type an element represents, parsing XML to state, + * building XML back, and validating that state. The Vue editor is deliberately not part of + * it, so that headless parse/validation does not import any .vue components. + * + * The contract is checked as the descriptor is constructed, which happens at import time + * for the module singletons, so an incomplete descriptor fails while its author is looking + * at it rather than when an author opens a question. + */ + +import { Placement } from '../constants'; + +/** + * Methods a subclass has to implement. `matches` and `getTypeOptions` are not listed + * because this class provides usable defaults for them. + */ +const REQUIRED_METHODS = [ + 'getQuestionType', + 'getResponseDeclarationSchema', + 'parse', + 'buildXML', + 'validate', +]; + +export class InteractionDescriptor { + /** + * @param {object} options + * @param {string} options.type - The interaction's XML tag name, e.g. 'qti-choice-interaction' + * @param {string[]} options.questionTypes - QuestionType values this interaction can author + * @param {string} [options.placement] - Placement.BLOCK (default) or Placement.INLINE. + * Inline interactions are handed the whole item body to parse, since their prompt lives + * in the body around them rather than in a `` child. + */ + constructor({ type, questionTypes, placement = Placement.BLOCK } = {}) { + const name = this.constructor.name; + + if (!type) { + throw new Error(`${name}: type is required`); + } + if (!Array.isArray(questionTypes) || !questionTypes.length) { + throw new Error(`${name}: questionTypes must list at least one question type`); + } + + if (!Object.values(Placement).includes(placement)) { + throw new Error(`${name}: placement must be one of ${Object.values(Placement).join(', ')}`); + } + + const missing = REQUIRED_METHODS.filter(method => typeof this[method] !== 'function'); + if (missing.length) { + throw new Error(`${name}: missing required method(s) ${missing.join(', ')}`); + } + + this.type = type; + this.questionTypes = questionTypes; + this.placement = placement; + } + + /** + * Whether this descriptor handles the given interaction element. Defaults to matching the + * element whose tag name is this interaction's type; interactions that can appear nested + * in the item body (inline ones) override this. + * + * @param {Element} el + * @returns {boolean} + */ + matches(el) { + return el.tagName.toLowerCase() === this.type; + } + + /** + * Options this interaction contributes to the question type selector. An interaction that + * authors are not meant to pick directly contributes none. + * + * @returns {Array<{ value: string, label: string, description: string }>} + */ + getTypeOptions() { + return []; + } +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/InteractionDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/InteractionDescriptor.spec.js new file mode 100644 index 0000000000..0144f108f8 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/InteractionDescriptor.spec.js @@ -0,0 +1,102 @@ +import { InteractionDescriptor } from '../InteractionDescriptor'; +import { Placement, QtiInteraction, QuestionType } from '../../constants'; + +const IMPLEMENTED = { + getQuestionType: () => QuestionType.SINGLE_SELECT, + getResponseDeclarationSchema: () => ({}), + parse: () => ({}), + buildXML: () => ({ bodyXml: '', responseDeclarations: [] }), + validate: () => [], +}; + +/** Builds a subclass implementing everything except the listed methods. */ +function makeDescriptorClass({ omit = [], options } = {}) { + class TestDescriptor extends InteractionDescriptor { + constructor() { + super( + options ?? { + type: QtiInteraction.CHOICE, + questionTypes: [QuestionType.SINGLE_SELECT], + }, + ); + } + } + for (const [name, fn] of Object.entries(IMPLEMENTED)) { + if (!omit.includes(name)) { + TestDescriptor.prototype[name] = fn; + } + } + return TestDescriptor; +} + +describe('InteractionDescriptor', () => { + it('constructs when the subclass implements the contract', () => { + const Descriptor = makeDescriptorClass(); + const descriptor = new Descriptor(); + + expect(descriptor.type).toBe(QtiInteraction.CHOICE); + expect(descriptor.questionTypes).toEqual([QuestionType.SINGLE_SELECT]); + }); + + it('names every method the subclass failed to implement', () => { + const Descriptor = makeDescriptorClass({ omit: ['parse', 'validate'] }); + + expect(() => new Descriptor()).toThrow(/missing required method\(s\) parse, validate/); + }); + + it('requires a type', () => { + const Descriptor = makeDescriptorClass({ + options: { questionTypes: [QuestionType.SINGLE_SELECT] }, + }); + + expect(() => new Descriptor()).toThrow(/type is required/); + }); + + it('places an interaction in the body as a block unless told otherwise', () => { + expect(new (makeDescriptorClass())().placement).toBe(Placement.BLOCK); + + const Inline = makeDescriptorClass({ + options: { + type: QtiInteraction.TEXT_ENTRY, + questionTypes: [QuestionType.TEXT_ENTRY], + placement: Placement.INLINE, + }, + }); + expect(new Inline().placement).toBe(Placement.INLINE); + }); + + it('rejects a placement it does not know', () => { + const Descriptor = makeDescriptorClass({ + options: { + type: QtiInteraction.CHOICE, + questionTypes: [QuestionType.SINGLE_SELECT], + placement: 'floating', + }, + }); + + expect(() => new Descriptor()).toThrow(/placement must be one of/); + }); + + it('requires at least one question type', () => { + const Descriptor = makeDescriptorClass({ + options: { type: QtiInteraction.CHOICE, questionTypes: [] }, + }); + + expect(() => new Descriptor()).toThrow(/at least one question type/); + }); + + describe('defaults', () => { + it('matches the element whose tag name is the interaction type', () => { + const descriptor = new (makeDescriptorClass())(); + const matching = { tagName: 'QTI-CHOICE-INTERACTION' }; + const other = { tagName: 'QTI-ORDER-INTERACTION' }; + + expect(descriptor.matches(matching)).toBe(true); + expect(descriptor.matches(other)).toBe(false); + }); + + it('contributes no question type options', () => { + expect(new (makeDescriptorClass())().getTypeOptions()).toEqual([]); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js deleted file mode 100644 index f56266a565..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js +++ /dev/null @@ -1,72 +0,0 @@ -import defineInteraction from '../defineInteraction'; - -// A minimal valid descriptor with all required keys except editorComponent, -// which is now always supplied as the second argument to defineInteraction. -const makeValidDescriptor = (overrides = {}) => ({ - type: 'test', - placement: 'block', - questionTypes: [], - convertsFrom: [], - matches: () => false, - getQuestionType: () => null, - getResponseDeclarationSchema: () => ({ baseType: 'string', cardinality: 'single' }), - parse: () => ({}), - buildXML: () => ({ bodyXml: '', responseDeclarations: [] }), - validate: () => [], - ...overrides, -}); - -const STUB_COMPONENT = {}; - -describe('defineInteraction', () => { - it('returns the descriptor unchanged when all required keys are present', () => { - const descriptor = makeValidDescriptor(); - expect(defineInteraction(descriptor, STUB_COMPONENT)).toBe(descriptor); - }); - - it('attaches the editorComponent from the second argument onto the descriptor', () => { - const descriptor = makeValidDescriptor(); - const component = { name: 'MyEditor' }; - defineInteraction(descriptor, component); - expect(descriptor.editorComponent).toBe(component); - }); - - const REQUIRED_DESCRIPTOR_KEYS = [ - 'type', - 'placement', - 'questionTypes', - 'convertsFrom', - 'matches', - 'getQuestionType', - 'getResponseDeclarationSchema', - 'parse', - 'buildXML', - 'validate', - ]; - - it.each(REQUIRED_DESCRIPTOR_KEYS)('throws when the required key "%s" is missing', key => { - const descriptor = makeValidDescriptor(); - delete descriptor[key]; - expect(() => defineInteraction(descriptor, STUB_COMPONENT)).toThrow( - new RegExp(`missing required key "${key}"`, 'i'), - ); - }); - - it('throws when editorComponent is not passed as the second argument', () => { - const descriptor = makeValidDescriptor(); - expect(() => defineInteraction(descriptor)).toThrow(/missing required key "editorComponent"/i); - }); - - it('includes the descriptor type in the error message when type is present', () => { - const descriptor = makeValidDescriptor({ type: 'myPlugin' }); - delete descriptor.buildXML; // delete a different key to trigger the error - expect(() => defineInteraction(descriptor, STUB_COMPONENT)).toThrow(/myPlugin/); - }); - - it('uses "(unknown)" in the error message when type is also missing', () => { - const descriptor = makeValidDescriptor(); - delete descriptor.type; - delete descriptor.buildXML; - expect(() => defineInteraction(descriptor, STUB_COMPONENT)).toThrow(/\(unknown\)/); - }); -}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js new file mode 100644 index 0000000000..1f753c67cd --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js @@ -0,0 +1,48 @@ +import { descriptors, editors, registry, DEFAULT_INTERACTION } from '../index'; +import { isInlineInteraction } from '../descriptors'; +import { Placement } from '../../constants'; + +/** + * An interaction is registered in two places: its descriptor in `descriptors.js` and its + * editor in `index.js`. That split keeps the editors out of the parse/validate import + * graph, at the cost of two lists that have to agree — so these assert they do. A new + * interaction that only got half-registered fails here rather than at the moment an author + * opens the question. + */ +describe('interaction registry', () => { + it('registers an editor for every descriptor', () => { + const missing = descriptors.filter(d => !editors[d.type]).map(d => d.type); + expect(missing).toEqual([]); + }); + + it('registers a descriptor for every editor', () => { + const orphans = Object.keys(editors).filter(type => !registry[type]); + expect(orphans).toEqual([]); + }); + + it('holds the same number of descriptors and editors', () => { + expect(Object.keys(editors)).toHaveLength(descriptors.length); + }); + + it('keys the registry by every descriptor type', () => { + expect(Object.keys(registry).sort()).toEqual(descriptors.map(d => d.type).sort()); + }); + + it('has a descriptor for the fallback interaction', () => { + expect(registry[DEFAULT_INTERACTION]).toBeDefined(); + }); + + describe('isInlineInteraction', () => { + it('reports the placement each descriptor declares', () => { + for (const descriptor of descriptors) { + expect(isInlineInteraction(descriptor.type)).toBe( + descriptor.placement === Placement.INLINE, + ); + } + }); + + it('reports an interaction with no descriptor as not inline', () => { + expect(isInlineInteraction('qti-match-interaction')).toBe(false); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Descriptor.js similarity index 86% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Descriptor.js index 26b04163f6..354d0bbc5a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Descriptor.js @@ -1,17 +1,18 @@ import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; -import { parseXML } from '../../serialization/parseItem'; +import { parseXML } from '../../serialization/xml'; +import { InteractionDescriptor } from '../InteractionDescriptor'; import { parseChoiceInteraction, buildChoiceInteractionXML } from './parse'; import { validateChoiceInteraction } from './validation'; /** * Owns all choice-specific interaction logic: schema, parse, buildXML, and validate. */ -export class ChoiceInteractionDescriptor { - constructor({ editorComponent = null } = {}) { - this.type = QtiInteraction.CHOICE; - this.placement = 'block'; - this.questionTypes = [QuestionType.SINGLE_SELECT, QuestionType.MULTI_SELECT]; - this.editorComponent = editorComponent; +export class ChoiceInteractionDescriptor extends InteractionDescriptor { + constructor() { + super({ + type: QtiInteraction.CHOICE, + questionTypes: [QuestionType.SINGLE_SELECT, QuestionType.MULTI_SELECT], + }); this.convertsFrom = []; } @@ -30,11 +31,6 @@ export class ChoiceInteractionDescriptor { ]; } - /** @param {Element} el */ - matches(el) { - return el.tagName.toLowerCase() === QtiInteraction.CHOICE; - } - /** * Reads cardinality from the response declaration to determine question type. * diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Editor.vue similarity index 100% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Editor.vue diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Descriptor.spec.js similarity index 96% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionDescriptor.spec.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Descriptor.spec.js index bd321d465d..60dc3a9bca 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionDescriptor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Descriptor.spec.js @@ -1,4 +1,4 @@ -import { ChoiceInteractionDescriptor } from '../ChoiceInteractionDescriptor'; +import { ChoiceInteractionDescriptor } from '../Descriptor'; import { BaseType, Cardinality, QtiInteraction, QuestionType } from '../../../constants'; describe('ChoiceInteractionDescriptor', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Editor.spec.js similarity index 99% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Editor.spec.js index 7cf59d9378..4246586728 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Editor.spec.js @@ -1,7 +1,7 @@ import { render, screen, fireEvent, within } from '@testing-library/vue'; import { nextTick } from 'vue'; import VueRouter from 'vue-router'; -import ChoiceInteractionEditor from '../ChoiceInteractionEditor.vue'; +import ChoiceInteractionEditor from '../Editor.vue'; import { CHOICE_SINGLE_SELECT_XML, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js index 53f5f286bf..0ba74d36ed 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js @@ -2,7 +2,7 @@ // The eslint-dom matchers reject XML nodes produced by DOMParser(..., 'text/xml'). // Native DOM APIs (getAttribute, textContent) work correctly on XML elements. -import { choiceInteractionDescriptor } from '../ChoiceInteractionDescriptor'; +import { choiceInteractionDescriptor } from '../Descriptor'; import { CHOICE_SINGLE_SELECT_XML, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js index b333a3bee3..bcf9119cc5 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js @@ -1,4 +1,4 @@ -import { choiceInteractionDescriptor } from '../ChoiceInteractionDescriptor'; +import { choiceInteractionDescriptor } from '../Descriptor'; import { ValidationError, QuestionType, Orientation } from '../../../constants'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js deleted file mode 100644 index 966cc2dd7e..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import defineInteraction from '../defineInteraction'; -import ChoiceInteractionEditor from './ChoiceInteractionEditor.vue'; -import { choiceInteractionDescriptor } from './ChoiceInteractionDescriptor'; - -export default defineInteraction(choiceInteractionDescriptor, ChoiceInteractionEditor); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js index d9c83d4872..4293ef5d40 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js @@ -1,5 +1,5 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; -import { getPromptHTML, parseXML } from '../../serialization/parseItem'; +import { getPromptHTML, parseXML } from '../../serialization/xml'; import { buildXmlNode } from '../../serialization/assembleItem'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js deleted file mode 100644 index f08d273877..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Required keys every interaction descriptor must provide. - * Validated at import time so missing fields surface immediately during development. - */ -const REQUIRED_KEYS = [ - 'type', - 'placement', - 'questionTypes', - 'editorComponent', - 'convertsFrom', - 'matches', - 'getQuestionType', - 'getResponseDeclarationSchema', - 'parse', - 'buildXML', - 'validate', -]; - -/** - * Validates that a descriptor has every required key and returns it unchanged. - * Throws at call-time (i.e. module import time) if any key is absent. - * - * Pass the Vue editor component as the second argument to attach it to the - * descriptor here rather than mutating the descriptor after construction. - * - * @template {object} T - * @param {T} descriptor - The interaction descriptor to validate - * @param {object} editorComponent - The Vue component that edits this interaction - * @returns {T} The same descriptor, with editorComponent attached - * @throws {Error} If any required key is missing from the descriptor - */ -export default function defineInteraction(descriptor, editorComponent) { - // Attach editorComponent before validation so the required-key check can - // confirm it is present even when the descriptor class does not set it. - descriptor.editorComponent = editorComponent; - - for (const key of REQUIRED_KEYS) { - // Use a truthiness check for editorComponent (a Vue component object) so - // that passing `undefined` as the second argument is caught as missing. - const isMissing = key === 'editorComponent' ? !descriptor[key] : !(key in descriptor); - if (isMissing) { - const name = descriptor.type ?? '(unknown)'; - throw new Error(`defineInteraction: missing required key "${key}" on descriptor "${name}"`); - } - } - return descriptor; -} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js new file mode 100644 index 0000000000..4e639ca3c7 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js @@ -0,0 +1,62 @@ +import { Placement, QtiInteraction } from '../constants'; +import { choiceInteractionDescriptor } from './choice/Descriptor'; +import { textEntryInteractionDescriptor } from './textEntry/Descriptor'; +import { orderingInteractionDescriptor } from './ordering/Descriptor'; + +/** + * Every interaction's descriptor: matching, parsing, building and validating XML. + * + * This module imports `Descriptor.js` files only, never an `Editor.vue`, so anything that + * just reads or writes QTI — validateItem.js, which Studio calls to decide whether a node + * is complete without rendering anything — can import it without pulling the editors and + * TipTap into its bundle. The editors are registered in `./index` instead. + * + * Registering a new interaction means adding its descriptor here and its editor there; the + * two lists are asserted to agree in __tests__/registry.spec.js. + */ + +/** + * The default interaction type used as fallback when no descriptor matches + * the interaction element found in the XML body. + */ +export const DEFAULT_INTERACTION = QtiInteraction.CHOICE; + +/** + * Ordered list of all registered interaction descriptors. + * Searched in order; the first whose `matches(el)` returns true wins. + */ +export const descriptors = [ + choiceInteractionDescriptor, + textEntryInteractionDescriptor, + orderingInteractionDescriptor, +]; + +/** + * Registry map keyed by descriptor.type for O(1) direct lookup. + * Built from the descriptors array — do not populate manually. + * + * @type {Object.} + */ +export const registry = Object.fromEntries(descriptors.map(d => [d.type, d])); + +/** + * Find the interaction descriptor that supports a given question type. + * + * @param {string} questionType + * @returns {import('./InteractionDescriptor').InteractionDescriptor|undefined} + */ +export function getDescriptorForQuestionType(questionType) { + return descriptors.find(d => d.questionTypes.includes(questionType)); +} + +/** + * Whether an interaction is authored inline, and so needs the whole item body to parse + * rather than its own element. Read off the descriptor's placement, so declaring it there + * is all a new inline interaction has to do. + * + * @param {string} tagName - The interaction's XML tag name, lower-cased + * @returns {boolean} + */ +export function isInlineInteraction(tagName) { + return registry[tagName]?.placement === Placement.INLINE; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js index 107a549a6b..190615f5cb 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js @@ -1,34 +1,25 @@ import { QtiInteraction } from '../constants'; -import choiceDescriptor from './choice/index'; -import textEntryDescriptor from './textEntry/index'; -import orderingDescriptor from './ordering/index'; +import ChoiceEditor from './choice/Editor.vue'; +import TextEntryEditor from './textEntry/Editor.vue'; +import OrderingEditor from './ordering/Editor.vue'; /** - * The default interaction type used as fallback when no descriptor matches - * the interaction element found in the XML body. - */ -export const DEFAULT_INTERACTION = QtiInteraction.CHOICE; - -/** - * Ordered list of all registered interaction descriptors. - * Searched in order; the first whose `matches(el)` returns true wins. - */ -export const descriptors = [choiceDescriptor, textEntryDescriptor, orderingDescriptor]; - -/** - * Registry map keyed by descriptor.type for O(1) direct lookup. - * Built from the descriptors array — do not populate manually. + * Entry point for the editor tree: the descriptors, plus the Vue component that edits each + * interaction. * - * @type {Object.} + * The editors live here rather than on the descriptors themselves so that `./descriptors` + * stays free of `.vue` files — see the note there. Import this module when something is + * going to be rendered, and `./descriptors` when it is not. */ -export const registry = Object.fromEntries(descriptors.map(d => [d.type, d])); +export const editors = Object.freeze({ + [QtiInteraction.CHOICE]: ChoiceEditor, + [QtiInteraction.TEXT_ENTRY]: TextEntryEditor, + [QtiInteraction.ORDER]: OrderingEditor, +}); -/** - * Find the interaction descriptor that supports a given question type. - * - * @param {string} questionType - * @returns {import('./defineInteraction').InteractionDescriptor|undefined} - */ -export function getDescriptorForQuestionType(questionType) { - return descriptors.find(d => d.questionTypes.includes(questionType)); -} +export { + DEFAULT_INTERACTION, + descriptors, + registry, + getDescriptorForQuestionType, +} from './descriptors'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js similarity index 84% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionDescriptor.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js index d6f281780d..fcc7abdd77 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js @@ -1,16 +1,17 @@ import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; +import { InteractionDescriptor } from '../InteractionDescriptor'; import { parseOrderingInteraction, buildOrderingInteractionXML } from './parse'; import { validateOrderingInteraction } from './validate'; /** * Owns all ordering-specific interaction logic: schema, parse, buildXML, and validate. */ -export class OrderingInteractionDescriptor { - constructor({ editorComponent = null } = {}) { - this.type = QtiInteraction.ORDER; - this.placement = 'block'; - this.questionTypes = [QuestionType.ORDERING]; - this.editorComponent = editorComponent; +export class OrderingInteractionDescriptor extends InteractionDescriptor { + constructor() { + super({ + type: QtiInteraction.ORDER, + questionTypes: [QuestionType.ORDERING], + }); this.convertsFrom = []; } @@ -24,11 +25,6 @@ export class OrderingInteractionDescriptor { ]; } - /** @param {Element} el */ - matches(el) { - return el.tagName.toLowerCase() === QtiInteraction.ORDER; - } - /** * Ordering always has exactly one question type. * diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Editor.vue similarity index 100% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Editor.vue diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/Editor.spec.js similarity index 98% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/Editor.spec.js index 6e082e94cf..0bd4de0e5a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/Editor.spec.js @@ -1,7 +1,7 @@ import { render, screen, fireEvent } from '@testing-library/vue'; import { nextTick } from 'vue'; import VueRouter from 'vue-router'; -import OrderingInteractionEditor from '../OrderingInteractionEditor.vue'; +import OrderingEditor from '../Editor.vue'; import { ORDERING_XML, @@ -22,12 +22,12 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { }); const renderEditor = (props = {}) => - render(OrderingInteractionEditor, { + render(OrderingEditor, { props: { mode: 'edit', ...props }, routes: new VueRouter(), }); -describe('OrderingInteractionEditor', () => { +describe('OrderingEditor', () => { describe('edit mode rendering', () => { it('renders the prompt text from the XML', () => { renderEditor({ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/parse.spec.js index 0cafc5b252..4fc6d09766 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/parse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/parse.spec.js @@ -1,7 +1,7 @@ /* eslint-disable jest-dom/prefer-to-have-attribute, jest-dom/prefer-to-have-text-content */ // The eslint-dom matchers reject XML nodes produced by DOMParser(..., 'text/xml'). -import { orderingInteractionDescriptor } from '../OrderingInteractionDescriptor'; +import { orderingInteractionDescriptor } from '../Descriptor'; import { ORDERING_XML, ORDERING_DECL_XML } from '../../../utils/testingFixtures'; import { QuestionType, Orientation } from '../../../constants'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/index.js deleted file mode 100644 index 2a16ab7fcc..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import defineInteraction from '../defineInteraction'; -import OrderingInteractionEditor from './OrderingInteractionEditor.vue'; -import { orderingInteractionDescriptor } from './OrderingInteractionDescriptor'; - -export default defineInteraction(orderingInteractionDescriptor, OrderingInteractionEditor); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js index 7b30cc8472..aab176caf0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js @@ -1,5 +1,5 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; -import { getPromptHTML, parseXML } from '../../serialization/parseItem'; +import { getPromptHTML, parseXML } from '../../serialization/xml'; import { buildXmlNode } from '../../serialization/assembleItem'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js new file mode 100644 index 0000000000..c02f778346 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js @@ -0,0 +1,42 @@ +import { parseXML } from '../serialization/xml'; +import { ValidationError } from '../constants'; +import { descriptors, registry, DEFAULT_INTERACTION } from './descriptors'; + +/** + * Resolve the interaction descriptor and question type for a single interaction block. + * + * Pure and component-free, so both the editor (via useInteractionDescriptor) and the + * headless validator (validateItem.js) can share one resolution path. + * + * @param {string} bodyXml - Serialized interaction element (or item body, for inline + * interactions) + * @param {string[]} [responseDeclarations] + * @returns {{ + * descriptor: object, + * questionType: string|null, + * error: string|null, + * }} `error` is a ValidationError code; callers own how it is presented. + */ +export function resolveDescriptor(bodyXml, responseDeclarations) { + if (!bodyXml) { + return { descriptor: registry[DEFAULT_INTERACTION], questionType: null, error: null }; + } + try { + const interactionEl = parseXML(bodyXml).documentElement; + const descriptor = + descriptors.find(d => d.matches(interactionEl)) ?? registry[DEFAULT_INTERACTION]; + return { + descriptor, + questionType: descriptor.getQuestionType(interactionEl, responseDeclarations) ?? null, + error: null, + }; + } catch (e) { + // eslint-disable-next-line no-console + console.error('[QTI] Failed to parse interaction XML:', e.message); + return { + descriptor: registry[DEFAULT_INTERACTION], + questionType: null, + error: ValidationError.PARSE_ERROR, + }; + } +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js similarity index 85% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js index 9972eb691f..934189eea7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js @@ -1,25 +1,22 @@ -import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; -import { parseXML } from '../../serialization/parseItem'; +import { QtiInteraction, QuestionType, BaseType, Cardinality, Placement } from '../../constants'; +import { parseXML } from '../../serialization/xml'; +import { InteractionDescriptor } from '../InteractionDescriptor'; import { parseTextEntryInteraction, buildTextEntryInteractionXML } from './parse'; import { validateTextEntryInteraction } from './validation'; /** * Owns all text-entry-specific interaction logic: schema, parse, buildXML, validate. * - * placement: 'inline' — signals to parseItem that the whole - * should be passed as bodyXml rather than just the interaction element, so - * parse() can recover the prompt from body siblings. + * Inline placement means parse() is handed the whole rather than just the + * interaction element, so it can recover the prompt from the body siblings. */ -class TextEntryInteractionDescriptor { +class TextEntryInteractionDescriptor extends InteractionDescriptor { constructor() { - this.type = QtiInteraction.TEXT_ENTRY; - this.placement = 'inline'; - this.questionTypes = [ - QuestionType.NUMERIC, - QuestionType.TEXT_ENTRY, - QuestionType.FREE_RESPONSE, - ]; - this.editorComponent = null; + super({ + type: QtiInteraction.TEXT_ENTRY, + questionTypes: [QuestionType.NUMERIC, QuestionType.TEXT_ENTRY, QuestionType.FREE_RESPONSE], + placement: Placement.INLINE, + }); this.convertsFrom = []; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue similarity index 100% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/TextEntryEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js similarity index 99% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/TextEntryEditor.spec.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js index a804d84e81..7d2bf1ef3b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/TextEntryEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js @@ -1,7 +1,7 @@ import { render, screen, fireEvent } from '@testing-library/vue'; import { nextTick } from 'vue'; import VueRouter from 'vue-router'; -import TextEntryEditor from '../TextEntryEditor.vue'; +import TextEntryEditor from '../Editor.vue'; import { TEXT_ENTRY_BODY_XML, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js deleted file mode 100644 index d587280220..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import defineInteraction from '../defineInteraction'; -import TextEntryEditor from './TextEntryEditor.vue'; -import { textEntryInteractionDescriptor } from './TextEntryInteractionDescriptor'; - -export default defineInteraction(textEntryInteractionDescriptor, TextEntryEditor); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js index 2fd397905f..d42669475d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -1,5 +1,5 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; -import { parseXML } from '../../serialization/parseItem'; +import { parseXML } from '../../serialization/xml'; import { buildXmlNode } from '../../serialization/assembleItem'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import Mapping from '../../serialization/qti/declarations/mapping'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js index 48b8941460..711b63bb2f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js @@ -1,5 +1,5 @@ /* eslint-disable jest-dom/prefer-to-have-attribute, jest-dom/prefer-to-have-text-content */ -import { parseXML, parseItem } from '../parseItem'; +import { parseItem } from '../parseItem'; import { VALID_CHOICE_ITEM_DOCUMENT, TWO_INTERACTIONS_DOCUMENT } from '../../utils/testingFixtures'; // Fixtures @@ -15,46 +15,6 @@ const ITEM_NO_INTERACTIONS = ` `; -// parseXML -describe('parseXML', () => { - it('parses valid XML into a Document', () => { - const doc = parseXML(VALID_CHOICE_ITEM_DOCUMENT); - expect(doc).toBeInstanceOf(Document); - expect(doc.querySelector('qti-assessment-item')).not.toBeNull(); - }); - - it('throws for malformed XML', () => { - expect(() => parseXML(' { - // An extra closing tag causes a parsererror in jsdom - expect(() => parseXML('')).toThrow(/QTI XML parse error/i); - }); - - it('parses valid XML when text/xml is passed explicitly', () => { - const doc = parseXML(VALID_CHOICE_ITEM_DOCUMENT, 'text/xml'); - expect(doc.querySelector('qti-assessment-item')).not.toBeNull(); - }); - - it('parses HTML leniently into a Document when text/html is passed', () => { - const doc = parseXML('bold', 'text/html'); - expect(doc).toBeInstanceOf(Document); - // doc.body is a DOMParser-realm node, not a testing-library node, so - // toHaveTextContent rejects it; assert on textContent directly. - // eslint-disable-next-line jest-dom/prefer-to-have-text-content - expect(doc.body.textContent).toBe('bold'); - }); - - it('does not throw for malformed HTML', () => { - expect(() => parseXML(' { - expect(() => parseXML('x', 'text/html')).not.toThrow(); - }); -}); - // parseItem — meta extraction describe('parseItem — meta', () => { it('returns an object with the top-level item attributes', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/xml.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/xml.spec.js new file mode 100644 index 0000000000..7d08c3f7f5 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/xml.spec.js @@ -0,0 +1,58 @@ +import { parseXML, getPromptHTML } from '../xml'; +import { VALID_CHOICE_ITEM_DOCUMENT } from '../../utils/testingFixtures'; + +// parseXML +describe('parseXML', () => { + it('parses valid XML into a Document', () => { + const doc = parseXML(VALID_CHOICE_ITEM_DOCUMENT); + expect(doc).toBeInstanceOf(Document); + expect(doc.querySelector('qti-assessment-item')).not.toBeNull(); + }); + + it('throws for malformed XML', () => { + expect(() => parseXML(' { + // An extra closing tag causes a parsererror in jsdom + expect(() => parseXML('')).toThrow(/QTI XML parse error/i); + }); + + it('parses valid XML when text/xml is passed explicitly', () => { + const doc = parseXML(VALID_CHOICE_ITEM_DOCUMENT, 'text/xml'); + expect(doc.querySelector('qti-assessment-item')).not.toBeNull(); + }); + + it('parses HTML leniently into a Document when text/html is passed', () => { + const doc = parseXML('bold', 'text/html'); + expect(doc).toBeInstanceOf(Document); + // doc.body is a DOMParser-realm node, not a testing-library node, so + // toHaveTextContent rejects it; assert on textContent directly. + // eslint-disable-next-line jest-dom/prefer-to-have-text-content + expect(doc.body.textContent).toBe('bold'); + }); + + it('does not throw for malformed HTML', () => { + expect(() => parseXML(' { + expect(() => parseXML('x', 'text/html')).not.toThrow(); + }); +}); + +describe('getPromptHTML', () => { + it('returns the prompt markup of an interaction', () => { + const el = parseXML( + 'Pick one', + ).documentElement; + + expect(getPromptHTML(el)).toBe('Pick one'); + }); + + it('returns an empty string when the interaction has no prompt', () => { + const el = parseXML('').documentElement; + + expect(getPromptHTML(el)).toBe(''); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js index a100ffed3f..41a69ee2a1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js @@ -8,7 +8,7 @@ * (e.g. XMLSerializer.serializeToString). */ -import { parseXML } from './parseItem'; +import { parseXML } from './xml'; const xmlDoc = new DOMParser().parseFromString('', 'text/xml'); const serializer = new XMLSerializer(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js index d0bccb018c..0374dcbfc4 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js @@ -1,52 +1,8 @@ -import { QTI_INTERACTION_TAGS, INLINE_INTERACTION_TAGS } from '../constants'; +import { QTI_INTERACTION_TAGS } from '../constants'; +import { isInlineInteraction } from '../interactions/descriptors'; +import { parseXML } from './xml'; const serializer = new XMLSerializer(); -const parser = new DOMParser(); - -/** - * Parses a QTI XML or HTML string into a Document. - * - * @param {string} xmlString - Raw QTI XML (or HTML fragment) string - * @param {string} [mimeType='text/xml'] - Parse mode. `'text/xml'` runs the - * `parsererror` check; `'text/html'` parses leniently and never throws. - * @returns {Document} Parsed XML or HTML Document - * @throws {Error} If parsing as `'text/xml'` and the input is malformed or - * contains a parsererror. HTML parsing never throws. - */ -export function parseXML(xmlString, mimeType = 'text/xml') { - let input = xmlString; - if (mimeType === 'text/xml') { - input = xmlString.replace(/ xmlns="[^"]*"/, ''); - } - - const doc = parser.parseFromString(input, mimeType); - - // DOMParser never throws — it signals failure via a node. This - // only applies to XML: the HTML parser recovers silently and never emits one, - // so an HTML document literally containing a must not trip it. - if (mimeType === 'text/xml') { - const error = doc.querySelector('parsererror'); - if (error) { - throw new Error(`QTI XML parse error: ${error.textContent.trim()}`); - } - } - - return doc; -} - -/** - * Extract the inner HTML of the first child of an interaction element. - * Returns an empty string when no prompt element is present. - * Using innerHTML (not textContent) preserves rich inline markup (

, , etc.) - * for round-trip fidelity. - * - * @param {Element} interactionEl - The root element - * @returns {string} - */ -export function getPromptHTML(interactionEl) { - const promptEl = interactionEl.querySelector('qti-prompt'); - return promptEl ? promptEl.innerHTML : ''; -} /** * Parses a raw QTI XML string into the structured ItemModel. @@ -55,9 +11,9 @@ export function getPromptHTML(interactionEl) { * A response declaration belongs to an interaction when the declaration's * `identifier` matches the interaction's `response-identifier` attribute. * - * For descriptors with `placement: 'inline'`, `bodyXml` is the serialized - * `` rather than the interaction element alone, so the - * interaction's parse() function can recover prompt content from body siblings. + * An interaction its descriptor declares as inline gets the serialized + * `` as its `bodyXml` rather than the interaction element alone, + * so its parse() can recover prompt content from body siblings. * * @param {string} rawData - Raw QTI XML string (the full assessment item XML) * @returns {{ @@ -93,7 +49,7 @@ export function parseItem(rawData) { .filter(d => d.getAttribute('identifier') === responseId) .map(d => serializer.serializeToString(d)); - const isInline = INLINE_INTERACTION_TAGS.has(el.tagName.toLowerCase()); + const isInline = isInlineInteraction(el.tagName.toLowerCase()); interactions.push({ bodyXml: isInline ? serializer.serializeToString(body) : serializer.serializeToString(el), diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js index 7c50c8b240..97736127db 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js @@ -4,7 +4,7 @@ * @module serialization/qti/QTISanitizer */ -import { parseXML } from '../parseItem'; +import { parseXML } from '../xml'; // Valid QTI 3.0 base-type values — https://www.imsglobal.org/spec/qti/v3p0/impl/#h.wq4e8lbs4wa9 const VALID_BASE_TYPES = new Set([ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js index 0d95a0b659..d1d9b50adb 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js @@ -1,7 +1,7 @@ /** * Shared XML parse helper for declaration tests. */ -import { parseXML as parseXMLDocument } from '../../parseItem'; +import { parseXML as parseXMLDocument } from '../../xml'; const serializer = new XMLSerializer(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js new file mode 100644 index 0000000000..a25a948d47 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js @@ -0,0 +1,50 @@ +/** + * DOM helpers for reading QTI XML. + */ + +const parser = new DOMParser(); + +/** + * Parses a QTI XML or HTML string into a Document. + * + * @param {string} xmlString - Raw QTI XML (or HTML fragment) string + * @param {string} [mimeType='text/xml'] - Parse mode. `'text/xml'` runs the + * `parsererror` check; `'text/html'` parses leniently and never throws. + * @returns {Document} Parsed XML or HTML Document + * @throws {Error} If parsing as `'text/xml'` and the input is malformed or + * contains a parsererror. HTML parsing never throws. + */ +export function parseXML(xmlString, mimeType = 'text/xml') { + let input = xmlString; + if (mimeType === 'text/xml') { + input = xmlString.replace(/ xmlns="[^"]*"/, ''); + } + + const doc = parser.parseFromString(input, mimeType); + + // DOMParser never throws — it signals failure via a node. This + // only applies to XML: the HTML parser recovers silently and never emits one, + // so an HTML document literally containing a must not trip it. + if (mimeType === 'text/xml') { + const error = doc.querySelector('parsererror'); + if (error) { + throw new Error(`QTI XML parse error: ${error.textContent.trim()}`); + } + } + + return doc; +} + +/** + * Extract the inner HTML of the first child of an interaction element. + * Returns an empty string when no prompt element is present. + * Using innerHTML (not textContent) preserves rich inline markup (

, , etc.) + * for round-trip fidelity. + * + * @param {Element} interactionEl - The root element + * @returns {string} + */ +export function getPromptHTML(interactionEl) { + const promptEl = interactionEl.querySelector('qti-prompt'); + return promptEl ? promptEl.innerHTML : ''; +} From fc51b11a1753329f32164641b0b85cb63f8d4260 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:19:54 -0500 Subject: [PATCH 10/24] feat: validate a QTI item without rendering it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor surfaces errors through useInteraction, which already holds the parsed interaction state, but Studio has to know whether every question of a node is complete while none of them are on screen. validateQtiItem walks the same descriptor parse/validate pair from raw XML, and reports an unreadable or interaction-less item as an error of its own. It also takes allowFreeResponse, for the caller that only accepts scorable questions — free response is only meaningful on a survey. Co-Authored-By: Claude Opus 5 (1M context) --- .../QTIEditor/__tests__/validateItem.spec.js | 45 ++++++++++ .../views/QTIEditor/utils/testingFixtures.js | 86 +++++++++++++++++++ .../shared/views/QTIEditor/validateItem.js | 44 ++++++++++ 3 files changed, 175 insertions(+) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/validateItem.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js new file mode 100644 index 0000000000..01bf33420e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js @@ -0,0 +1,45 @@ +import { validateQtiItem } from '../validateItem'; +import { ValidationError } from '../constants'; +import { + VALID_CHOICE_ITEM_DOCUMENT, + CHOICE_ITEM_DOCUMENT_NO_PROMPT, + CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, + NO_INTERACTION_ITEM_DOCUMENT, +} from '../utils/testingFixtures'; + +const codesOf = errors => errors.map(error => error.code); + +describe('validateQtiItem', () => { + it('returns no errors for a complete item', () => { + expect(validateQtiItem(VALID_CHOICE_ITEM_DOCUMENT)).toEqual([]); + }); + + it('reports a missing prompt', () => { + expect(codesOf(validateQtiItem(CHOICE_ITEM_DOCUMENT_NO_PROMPT))).toContain( + ValidationError.PROMPT_REQUIRED, + ); + }); + + it('reports a missing correct answer', () => { + expect(codesOf(validateQtiItem(CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER))).toContain( + ValidationError.NO_CORRECT_ANSWER, + ); + }); + + it('reports an item whose body holds no interaction', () => { + expect(validateQtiItem(NO_INTERACTION_ITEM_DOCUMENT)).toEqual([ + { code: ValidationError.NO_INTERACTION }, + ]); + }); + + it('reports an item with no raw data at all', () => { + expect(validateQtiItem('')).toEqual([{ code: ValidationError.NO_INTERACTION }]); + expect(validateQtiItem(undefined)).toEqual([{ code: ValidationError.NO_INTERACTION }]); + }); + + it('reports unparseable XML', () => { + expect(validateQtiItem('')).toEqual([ + { code: ValidationError.PARSE_ERROR }, + ]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js index 24c12a77c2..a7df662153 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js @@ -91,6 +91,92 @@ export const VALID_CHOICE_ITEM_DOCUMENT = ` `; +export const CHOICE_ITEM_DOCUMENT_NO_PROMPT = ` + + + + choice-a + + + + + + A + B + + +`; + +export const CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER = ` + + + + + + Pick one. + A + B + + +`; + +/** + * A text-entry item whose declaration carries no correct response — an open-ended + * question, which only surveys accept. + */ +export const FREE_RESPONSE_ITEM_DOCUMENT = ` + + + + +

Tell us what you think.

+

+ +`; + +export const NO_INTERACTION_ITEM_DOCUMENT = ` + + +

Just some text.

+
+
`; + export const TWO_INTERACTIONS_DOCUMENT = ` } Empty when the item is valid + */ +export function validateQtiItem(rawData, { allowFreeResponse = true } = {}) { + if (!rawData) { + return [{ code: ValidationError.NO_INTERACTION }]; + } + + let item; + try { + item = parseItem(rawData); + } catch { + return [{ code: ValidationError.PARSE_ERROR }]; + } + + if (!item.interactions.length) { + return [{ code: ValidationError.NO_INTERACTION }]; + } + + const errors = []; + for (const { bodyXml, responseDeclarations } of item.interactions) { + const { descriptor, questionType, error } = resolveDescriptor(bodyXml, responseDeclarations); + if (error) { + errors.push({ code: error }); + continue; + } + if (!allowFreeResponse && questionType === QuestionType.FREE_RESPONSE) { + errors.push({ code: ValidationError.FREE_RESPONSE_NOT_ALLOWED }); + } + const state = descriptor.parse(bodyXml, responseDeclarations); + errors.push(...descriptor.validate(state, questionType)); + } + return errors; +} From a4f0b8f1796a53f5381261e55ac32b8f8e91bcac Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:20:16 -0500 Subject: [PATCH 11/24] feat: start new questions from a valid QTI item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new item had no raw_data at all, which left it unauthorable — the card only renders an interaction when the body holds one — and the server rejects an empty document outright, so "New question" could never have been saved. New items are now seeded with the default interaction's empty state, wrapped in an item that carries a generated identifier and a fixed title. A test asserts the skeleton round-trips to exactly one choice interaction, so a change to the default descriptor surfaces there, and a matching backend test validates the same document against the XSD to keep the two in step. Co-Authored-By: Claude Opus 5 (1M context) --- .../frontend/shared/views/QTIEditor/index.vue | 2 ++ .../__tests__/createBlankItem.spec.js | 32 +++++++++++++++++ .../QTIEditor/serialization/assembleItem.js | 5 ++- .../serialization/createBlankItem.js | 36 +++++++++++++++++++ .../tests/utils/qti/test_validation.py | 26 ++++++++++++++ 5 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/createBlankItem.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/createBlankItem.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue index cc9b2af339..ca159c682c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue @@ -66,6 +66,7 @@ import QTIItemEditor from './components/QTIItemEditor/index'; import CollapsibleToolbar from './components/CollapsibleToolbar/index.vue'; import useQTIEditorActions from './useQTIEditorActions'; + import { createBlankItemXml } from './serialization/createBlankItem'; // Custom uuid4 function to match our dashless uuids on the server side function uuid4() { @@ -77,6 +78,7 @@ return { assessment_id: uuid4(), type: AssessmentItemTypes.QTI, + raw_data: createBlankItemXml(), }; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/createBlankItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/createBlankItem.spec.js new file mode 100644 index 0000000000..f1c2200213 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/createBlankItem.spec.js @@ -0,0 +1,32 @@ +import { createBlankItemXml, DEFAULT_ITEM_TITLE } from '../createBlankItem'; +import { parseItem } from '../parseItem'; +import { parseXML } from '../xml'; +import { QtiInteraction } from '../../constants'; +import { validateQtiItem } from '../../validateItem'; + +describe('createBlankItemXml', () => { + it('produces an item holding exactly one default interaction', () => { + const { interactions } = parseItem(createBlankItemXml()); + + expect(interactions).toHaveLength(1); + expect(parseXML(interactions[0].bodyXml).documentElement.tagName.toLowerCase()).toBe( + QtiInteraction.CHOICE, + ); + }); + + it('stamps a unique identifier and the default title', () => { + const first = parseItem(createBlankItemXml()); + const second = parseItem(createBlankItemXml()); + + expect(first.title).toBe(DEFAULT_ITEM_TITLE); + // The identifier is an XML NCName: a letter or underscore, then name characters. + expect(first.identifier).toMatch(/^[A-Za-z_][\w.-]*$/); + expect(first.identifier).not.toBe(second.identifier); + }); + + it('is renderable but not yet complete, so the author has something to fill in', () => { + // A blank item must parse into an interaction — otherwise the editor has nothing to + // render — while still reporting as invalid until the author fills it in. + expect(validateQtiItem(createBlankItemXml()).length).toBeGreaterThan(0); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js index 41a69ee2a1..68d7386376 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js @@ -148,9 +148,8 @@ export function assembleItemXml({ identifier, title, language, bodyXml, response tag: 'qti-assessment-item', attrs: { xmlns: 'http://www.imsglobal.org/xsd/imsqtiasi_v3p0', - // TODO: We will need to properly generate the identifier and title - // on the useQtiItem composable when we integrate the question type selector - // and have the add question button working. + // New items get their identifier and title from createBlankItem.js; these fallbacks + // only cover items assembled from XML that never carried them. identifier: identifier || 'item', title: title || '', adaptive: 'false', diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/createBlankItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/createBlankItem.js new file mode 100644 index 0000000000..39f9737825 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/createBlankItem.js @@ -0,0 +1,36 @@ +import { QuestionType } from '../constants'; +import { choiceInteractionDescriptor } from '../interactions/choice/Descriptor'; +import { _defaultState } from '../interactions/choice/parse'; +import { generateRandomSlug } from '../utils/generateRandomSlug'; +import { assembleItemXml } from './assembleItem'; + +/** + * Title stamped on newly created items. Deliberately fixed rather than derived from the + * item's position, which would go stale on the next reorder. + */ +export const DEFAULT_ITEM_TITLE = 'Question'; + +/** + * Build the QTI XML for a brand new, empty assessment item. + * + * A new item cannot start with empty `raw_data`: the editor only renders an interaction + * when one is present in the body, and the server validates every item against the QTI + * schema before storing it. So a new item starts as the default interaction's empty + * state, which the author then fills in. + * + * @returns {string} Full QTI assessment item XML + */ +export function createBlankItemXml() { + const { bodyXml, responseDeclarations } = choiceInteractionDescriptor.buildXML( + _defaultState(), + QuestionType.SINGLE_SELECT, + ); + + return assembleItemXml({ + identifier: generateRandomSlug('item'), + title: DEFAULT_ITEM_TITLE, + language: '', + bodyXml, + responseDeclarations, + }); +} diff --git a/contentcuration/contentcuration/tests/utils/qti/test_validation.py b/contentcuration/contentcuration/tests/utils/qti/test_validation.py index c18ccb277d..467cafedf4 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_validation.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_validation.py @@ -152,6 +152,32 @@ def test_rejects_item_with_unknown_root_element(self): self.assertTrue(result.errors) +# Mirrors what the QTI editor emits for a brand new question, before the author has +# written anything — see createBlankItem.js. Every "New question" click sends this to the +# sync endpoint, which validates it, so the two have to stay in lockstep. +BLANK_EDITOR_ITEM = ( + '\n' + '' + '' + "" + '' + '' + "" + "" + "" +) + + +class BlankEditorItemTests(unittest.TestCase): + def test_accepts_blank_item_from_editor(self): + result = validate_qti_item(BLANK_EDITOR_ITEM) + self.assertTrue(result.is_valid) + self.assertEqual(result.errors, []) + + class SchemaReuseTests(unittest.TestCase): def test_schema_compiled_once_across_multiple_validate_calls(self): _compiled_schema.cache_clear() From 50d5d07c3c554b40d0f8e9de52459f0c04d88993 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:21:38 -0500 Subject: [PATCH 12/24] feat: mark incomplete questions in the card header A question card gave no sign that the question inside it was unfinished, which the exercise editor it is replacing did show. Rather than validate the item a second time, each interaction editor reports the errors useInteraction already computes for the inline messages, and the card renders an indicator while there are any. Validation stays debounced, so the indicator appears once the author pauses rather than on every keystroke. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/QTIItemEditor.spec.js | 66 ++++++++++++++++++- .../components/QTIItemEditor/index.vue | 47 +++++++++++++ .../frontend/shared/views/QTIEditor/index.vue | 9 +++ .../views/QTIEditor/qtiEditorStrings.js | 4 ++ .../views/QTIEditor/utils/testingFixtures.js | 28 ++++++++ 5 files changed, 153 insertions(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js index 8a9d19fe02..655eef0c58 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js @@ -1,8 +1,16 @@ import { render, screen, fireEvent } from '@testing-library/vue'; +import { nextTick } from 'vue'; import VueRouter from 'vue-router'; import QTIItemEditor from '../index.vue'; import { qtiEditorStrings } from '../../../qtiEditorStrings'; import { AssessmentItemTypes } from '../../../constants'; +import { + VALID_CHOICE_ITEM_DOCUMENT, + CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, + ORDERING_ITEM_DOCUMENT_NO_PROMPT, + FREE_RESPONSE_ITEM_DOCUMENT, + NO_INTERACTION_ITEM_DOCUMENT, +} from '../../../utils/testingFixtures'; jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { @@ -13,7 +21,8 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { }; }); -const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings; +const { closeBtnLabel$, questionContentPlaceholder$, incompleteItemIndicatorLabel$ } = + qtiEditorStrings; const defaultProps = { item: { @@ -77,6 +86,61 @@ describe('QTIItemEditor', () => { }); }); + describe('incomplete indicator', () => { + const renderAndValidate = async raw_data => { + jest.useFakeTimers(); + renderComponent({ + item: { assessment_id: 'item-id', type: AssessmentItemTypes.QTI, raw_data }, + }); + await nextTick(); + // Validation is debounced inside the interaction editor. + jest.advanceTimersByTime(400); + await nextTick(); + jest.useRealTimers(); + }; + + test('is shown for a question missing something the author has to supply', async () => { + await renderAndValidate(CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER); + expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument(); + }); + + test('is not shown for a complete question', async () => { + await renderAndValidate(VALID_CHOICE_ITEM_DOCUMENT); + expect(screen.queryByText(incompleteItemIndicatorLabel$())).not.toBeInTheDocument(); + }); + + // The card reads the item's XML rather than errors an interaction editor reports, so an + // interaction that reports nothing is covered like any other. + test('is shown for an incomplete question of any interaction type', async () => { + await renderAndValidate(ORDERING_ITEM_DOCUMENT_NO_PROMPT); + expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument(); + }); + + test('is shown for an item with no interaction at all', async () => { + await renderAndValidate(NO_INTERACTION_ITEM_DOCUMENT); + expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument(); + }); + + test('is shown for a free-response question where those are not accepted', async () => { + renderComponent({ + allowFreeResponse: false, + item: { + assessment_id: 'item-id', + type: AssessmentItemTypes.QTI, + raw_data: FREE_RESPONSE_ITEM_DOCUMENT, + }, + }); + await nextTick(); + + expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument(); + }); + + test('is not shown for a free-response question where those are accepted', async () => { + await renderAndValidate(FREE_RESPONSE_ITEM_DOCUMENT); + expect(screen.queryByText(incompleteItemIndicatorLabel$())).not.toBeInTheDocument(); + }); + }); + describe('toolbarActions slot', () => { test('renders content injected into the toolbarActions slot', () => { renderComponent({}, { toolbarActions: '' }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue index 40711ad6f9..b730dc6dc0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -22,6 +22,18 @@
+ + + {{ incompleteItemIndicatorLabel$() }} +
@@ -64,6 +76,7 @@ import { qtiEditorStrings } from '../../qtiEditorStrings'; import { QuestionType } from '../../constants'; import useQtiItem from '../../composables/useQtiItem'; + import { validateQtiItem } from '../../validateItem'; import InteractionSection from '../InteractionSection/index.vue'; export default { @@ -78,6 +91,7 @@ closeBtnLabel$, questionContentPlaceholder$, unknownTypeLabel$, + incompleteItemIndicatorLabel$, } = qtiEditorStrings; /** @@ -158,14 +172,30 @@ currentResponseDeclarations.value = responseDeclarations; } + /** + * Whether the question is missing something an author still has to supply. + * + * Read from the item's own XML rather than from errors the interaction editor + * reports, so that it covers what is wrong with the item as a whole — no interaction + * at all, or a free-response question where those are not accepted — and so that + * every interaction is included without having to report anything. It follows the + * assembled XML, so it keeps up with the question being edited. + */ + const isIncomplete = computed( + () => + validateQtiItem(rawData.value, { allowFreeResponse: props.allowFreeResponse }).length > 0, + ); + return { currentQuestionType, interactions, currentInteraction, + isIncomplete, questionNumberLabel, questionNumberAndTypeLabel, closeBtnLabel$, questionContentPlaceholder$, + incompleteItemIndicatorLabel$, onUpdateInteraction, }; }, @@ -200,6 +230,14 @@ type: Boolean, default: false, }, + /** + * Whether a question with no correct answer counts as complete. Only a survey + * accepts those, so a consumer that scores its questions passes false. + */ + allowFreeResponse: { + type: Boolean, + default: true, + }, }, emits: ['close', 'update:rawData'], @@ -235,6 +273,15 @@ align-items: center; } + .incomplete-indicator { + display: flex; + gap: 4px; + align-items: center; + font-size: 14px; + font-weight: 600; + white-space: nowrap; + } + .question-card-body { min-width: 0; padding: 10px var(--question-card-horizontal-padding) 16px; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue index ca159c682c..0e8e99c528 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue @@ -26,6 +26,7 @@ :index="idx" :total="items.length" :mode="activeId === item.assessment_id ? 'edit' : 'view'" + :allowFreeResponse="allowFreeResponse" :showAnswers="showAnswers" data-testid="item" @close="closeItem" @@ -198,6 +199,14 @@ type: Array, default: () => [], }, + /** + * Whether a question with no correct answer counts as complete. Only a survey + * accepts those, so a consumer that scores its questions passes false. + */ + allowFreeResponse: { + type: Boolean, + default: true, + }, }, emits: ['update'], diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index 2100820126..341b96e0d6 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -29,6 +29,10 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Show answers', context: 'Checkbox label to toggle displaying answers/previews', }, + incompleteItemIndicatorLabel: { + message: 'Incomplete', + context: 'Shown in a question card header when the question is missing something', + }, singleSelectLabel: { message: 'Single Choice', context: 'Display name for a single-select question type', diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js index a7df662153..c59c696dfe 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js @@ -142,6 +142,34 @@ export const CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER = ` `; +/** + * An ordering item missing its prompt — used to check that an interaction which reports + * nothing to the card is still reported as incomplete. + */ +export const ORDERING_ITEM_DOCUMENT_NO_PROMPT = ` + + + + order_aaa11111 + order_bbb22222 + + + + + + Mercury + Venus + + +`; + /** * A text-entry item whose declaration carries no correct response — an open-ended * question, which only surveys accept. From 7246dac92513ea10f7727cd9237b6c28aa4254eb Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:22:16 -0500 Subject: [PATCH 13/24] feat: show questions this editor cannot edit as read-only Perseus questions are passed through by the API rather than converted, and an item whose XML cannot be read has no interaction model to hand an editor. Both used to fall through to the "content editor coming soon" placeholder, which invites an author to edit something that would be overwritten. They now render a card that says so, with the edit action disabled and the card refusing to open, while move, add and remove keep working. Validation already leaves Perseus items alone, so they never count as incomplete. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/QTIItemEditor.spec.js | 40 ++++++++++++++++++- .../components/QTIItemEditor/index.vue | 28 +++++++++++-- .../frontend/shared/views/QTIEditor/index.vue | 3 ++ .../views/QTIEditor/qtiEditorStrings.js | 5 +++ .../views/QTIEditor/useQTIEditorActions.js | 4 +- .../QTIEditor/utils/__tests__/math.spec.js | 23 +++++++++++ 6 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/math.spec.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js index 655eef0c58..fd10c7d2c1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js @@ -21,8 +21,12 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { }; }); -const { closeBtnLabel$, questionContentPlaceholder$, incompleteItemIndicatorLabel$ } = - qtiEditorStrings; +const { + closeBtnLabel$, + questionContentPlaceholder$, + unsupportedItemMessage$, + incompleteItemIndicatorLabel$, +} = qtiEditorStrings; const defaultProps = { item: { @@ -86,6 +90,26 @@ describe('QTIItemEditor', () => { }); }); + describe('items this editor cannot edit', () => { + test('shows a read-only message for an item authored elsewhere', () => { + renderComponent({ + item: { assessment_id: 'perseus-item', type: 'perseus_question', raw_data: '{}' }, + }); + expect(screen.getByText(unsupportedItemMessage$())).toBeInTheDocument(); + }); + + test('shows a read-only message when the item XML cannot be read', () => { + renderComponent({ + item: { + assessment_id: 'broken-item', + type: AssessmentItemTypes.QTI, + raw_data: '', + }, + }); + expect(screen.getByText(unsupportedItemMessage$())).toBeInTheDocument(); + }); + }); + describe('incomplete indicator', () => { const renderAndValidate = async raw_data => { jest.useFakeTimers(); @@ -139,6 +163,18 @@ describe('QTIItemEditor', () => { await renderAndValidate(FREE_RESPONSE_ITEM_DOCUMENT); expect(screen.queryByText(incompleteItemIndicatorLabel$())).not.toBeInTheDocument(); }); + test('is not shown for a question this editor cannot read', async () => { + renderComponent({ + item: { + assessment_id: 'item-id', + type: AssessmentItemTypes.PERSEUS_QUESTION, + raw_data: '{"not":"qti"}', + }, + }); + await nextTick(); + + expect(screen.queryByText(incompleteItemIndicatorLabel$())).not.toBeInTheDocument(); + }); }); describe('toolbarActions slot', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue index b730dc6dc0..20eb532ec7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -39,8 +39,15 @@
+

+ {{ unsupportedItemMessage$() }} +

props.item.type !== AssessmentItemTypes.QTI || Boolean(parseError.value), + ); + // Seed the editor refs from the parsed interactions (first interaction only). if (interactions.value.length > 0) { currentBodyXml.value = interactions.value[0].bodyXml; @@ -180,9 +196,13 @@ * at all, or a free-response question where those are not accepted — and so that * every interaction is included without having to report anything. It follows the * assembled XML, so it keeps up with the question being edited. + * + * A question this editor cannot read is shown as read-only instead, and reporting it + * as incomplete would ask the author to fix something they cannot reach. */ const isIncomplete = computed( () => + !isUnsupported.value && validateQtiItem(rawData.value, { allowFreeResponse: props.allowFreeResponse }).length > 0, ); @@ -190,12 +210,14 @@ currentQuestionType, interactions, currentInteraction, + isUnsupported, isIncomplete, questionNumberLabel, questionNumberAndTypeLabel, closeBtnLabel$, questionContentPlaceholder$, incompleteItemIndicatorLabel$, + unsupportedItemMessage$, onUpdateInteraction, }; }, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue index 0e8e99c528..f6ab305f08 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue @@ -103,6 +103,9 @@ const showAnswers = ref(false); function openItem(id) { + const item = props.assessments.find(i => i.assessment_id === id); + // Items authored elsewhere (e.g. Perseus) are read-only here. + if (!item || item.type !== AssessmentItemTypes.QTI) return; activeId.value = id; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index 341b96e0d6..119685454a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -33,6 +33,11 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Incomplete', context: 'Shown in a question card header when the question is missing something', }, + unsupportedItemMessage: { + message: 'This question cannot be edited here', + context: + 'Shown in place of the editor for questions authored elsewhere, or whose content could not be read', + }, singleSelectLabel: { message: 'Single Choice', context: 'Display name for a single-select question type', diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/useQTIEditorActions.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/useQTIEditorActions.js index cd3b77c29a..62fbbb40d1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/useQTIEditorActions.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/useQTIEditorActions.js @@ -1,4 +1,5 @@ import { qtiEditorStrings } from './qtiEditorStrings'; +import { AssessmentItemTypes } from './constants'; /** * Generates the toolbar actions array for a specific QTI item in the list. @@ -32,7 +33,8 @@ export default function useQTIEditorActions({ label: toolbarLabelEdit$(), handler: () => openItem(item.assessment_id), collapsed: false, - disabled: isEditMode, + // Items authored elsewhere (e.g. Perseus) can be moved or removed, but not opened. + disabled: isEditMode || item.type !== AssessmentItemTypes.QTI, }); result.push({ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/math.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/math.spec.js new file mode 100644 index 0000000000..346742fa4e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/math.spec.js @@ -0,0 +1,23 @@ +import { floatOrIntRegex } from '../math'; + +describe('floatOrIntRegex', () => { + it('tests true for valid values', () => { + [ + '1.5', // Float + '-4.5', // Signed Float + '+1', // Signed Int + '10e5', // Exponentiation + '-15.3e5', // Combo + '-12345.67890e98', // Combo 2 + ].forEach(v => expect(floatOrIntRegex.test(v)).toBe(true)); + }); + + it('tests false for invalid values', () => { + [ + 'i * 1.5', // Math + 'one.point.five', // Text + '10 5 0 100', // Spaces + '1.2.3.4', // IP + ].forEach(v => expect(floatOrIntRegex.test(v)).toBe(false)); + }); +}); From ef1eebe10ec491befe3770df962eade3af5ed052 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:22:40 -0500 Subject: [PATCH 14/24] fix: report content changes only from the question being edited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every card re-assembles its XML on mount, and the serialized form rarely matches the stored one byte for byte, so simply opening a list of questions reported all of them as changed — which, once the editor is wired to the sync layer, would rewrite every question in an exercise just for being looked at. Only the card in edit mode reports its XML now. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/QTIItemEditor.spec.js | 44 +++++++++++++++++++ .../components/QTIItemEditor/index.vue | 12 +++++ 2 files changed, 56 insertions(+) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js index fd10c7d2c1..7005c86725 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js @@ -177,6 +177,50 @@ describe('QTIItemEditor', () => { }); }); + describe('reporting content changes', () => { + const renderWithContent = mode => + renderComponent({ + mode, + item: { + assessment_id: 'item-id', + type: AssessmentItemTypes.QTI, + raw_data: VALID_CHOICE_ITEM_DOCUMENT, + }, + }); + + test('a card that is only being viewed reports nothing', async () => { + // A closed card re-assembles its XML too; reporting that would rewrite every + // question in the list just for being on screen. + const { emitted } = renderWithContent('view'); + await nextTick(); + + expect(emitted()['update:rawData']).toBeUndefined(); + }); + + test('a change made while editing is still reported once the card closes', async () => { + // Closing sets the parent's active item to none, which re-renders this card as a + // viewed one before the watcher for the change runs. The change was still authored. + const { emitted, updateProps } = renderWithContent('edit'); + // Deliberately not awaited: the change and the close land in the same flush, which is + // what happens when a click closes the card the author was just typing in. + fireEvent.click(screen.getByRole('button', { name: /add choice/i })); + await updateProps({ mode: 'view' }); + await nextTick(); + + expect(emitted()['update:rawData']).toBeDefined(); + }); + + test('the card being edited reports the new XML when the author changes it', async () => { + const { emitted } = renderWithContent('edit'); + // The fixture starts with two choices. + await fireEvent.click(screen.getByRole('button', { name: /add choice/i })); + await nextTick(); + + const reported = emitted()['update:rawData'].pop()[0]; + expect(reported.match(/ { test('renders content injected into the toolbarActions slot', () => { renderComponent({}, { toolbarActions: '' }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue index 20eb532ec7..60555fe5c2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -174,8 +174,19 @@ }), ); + /** + * Whether the change the watcher below is about to report came from an edit in this + * card. Recorded as the change happens rather than read from `mode` when the watcher + * flushes: closing the card sets the parent's active item to none, and that re-render + * lands first, so a change made just before the close would look like it came from a + * card nobody was editing. + */ + let editedHere = false; + // Emit only when the assembled XML actually changes after initial mount. watch(rawData, newVal => { + if (!editedHere) return; + editedHere = false; if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console console.log('[QTIItemEditor] assembled XML:\n', newVal); @@ -184,6 +195,7 @@ }); function onUpdateInteraction({ bodyXml, responseDeclarations }) { + editedHere = props.mode === 'edit'; currentBodyXml.value = bodyXml; currentResponseDeclarations.value = responseDeclarations; } From 29931f2d58b6df29eb9288ffef03572b4f1970e2 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:22:55 -0500 Subject: [PATCH 15/24] feat: author exercise questions with the QTI editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The questions tab now renders QTIEditor instead of the legacy AssessmentEditor. The editor stays a controlled list component that hands back the whole array, so useAssessmentItems does the translating: it diffs that array against what the store holds and dispatches one write per item, reordering before adding or removing so no two questions briefly claim the same position. A question the author adds counts as incomplete straight away, rather than being marked for delayed validation: the card already says so as soon as it renders, so the tab icon and the "N incomplete questions" banner would otherwise disagree with it until the next reload. The vuex actions stop stringifying answers and hints — the API rejects those fields on a QTI item, whose content lives in raw_data. Co-Authored-By: Claude Opus 5 (1M context) --- .../AssessmentTab/AssessmentTab.vue | 195 ++++-------------- .../__tests__/useAssessmentItems.spec.js | 171 +++++++++++++++ .../composables/useAssessmentItems.js | 113 ++++++++++ .../vuex/assessmentItem/actions.js | 22 +- 4 files changed, 330 insertions(+), 171 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js create mode 100644 contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue index a8fbc846e2..e2511272c0 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue @@ -1,46 +1,26 @@ @@ -48,19 +28,28 @@ + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js b/contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js new file mode 100644 index 0000000000..deed36749e --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js @@ -0,0 +1,171 @@ +import { Store } from 'vuex'; +import VueRouter from 'vue-router'; +import { render } from '@testing-library/vue'; +import useAssessmentItems from '../useAssessmentItems'; +import { AssessmentItemTypes, ContentModalities } from 'shared/constants'; + +const NODE_ID = 'node-1'; + +const item = (assessment_id, order, raw_data = `${assessment_id}`) => ({ + assessment_id, + contentnode: NODE_ID, + type: AssessmentItemTypes.QTI, + order, + raw_data, +}); + +/** + * Renders a component that does nothing but run the composable, and returns it alongside + * the actions the composable dispatched, in the order it dispatched them. + */ +function setup(storedItems, { modality = null } = {}) { + const dispatched = []; + const record = name => (context, payload) => dispatched.push([name, payload]); + + const store = new Store({ + modules: { + contentNode: { + namespaced: true, + getters: { + getContentNode: () => () => ({ extra_fields: { options: { modality } } }), + }, + }, + assessmentItem: { + namespaced: true, + getters: { + getAssessmentItems: () => () => storedItems, + getInvalidAssessmentItemsCount: () => () => 0, + }, + actions: { + updateAssessmentItems: record('updateAssessmentItems'), + updateAssessmentItem: record('updateAssessmentItem'), + addAssessmentItem: record('addAssessmentItem'), + deleteAssessmentItem: record('deleteAssessmentItem'), + }, + }, + }, + }); + + let composable; + render( + { + template: '
', + setup() { + composable = useAssessmentItems(NODE_ID); + }, + }, + { store, routes: new VueRouter() }, + ); + + return { composable, dispatched }; +} + +describe('useAssessmentItems', () => { + describe('allowFreeResponse', () => { + it('accepts a question with no correct answer on a survey', () => { + const { composable } = setup([], { modality: ContentModalities.SURVEY }); + + expect(composable.allowFreeResponse.value).toBe(true); + }); + + it('does not accept one on an exercise, whose questions are scored', () => { + const { composable } = setup([]); + + expect(composable.allowFreeResponse.value).toBe(false); + }); + }); + + it('dispatches nothing when the list is unchanged', async () => { + const items = [item('a', 0), item('b', 1)]; + const { composable, dispatched } = setup(items); + + await composable.applyUpdate([...items]); + + expect(dispatched).toEqual([]); + }); + + it('updates only the question whose content changed', async () => { + const { composable, dispatched } = setup([item('a', 0), item('b', 1)]); + + await composable.applyUpdate([item('a', 0), item('b', 1, 'edited')]); + + expect(dispatched).toEqual([ + [ + 'updateAssessmentItem', + { contentnode: NODE_ID, assessment_id: 'b', raw_data: 'edited' }, + ], + ]); + }); + + it('adds a new question with its position as order', async () => { + const { composable, dispatched } = setup([item('a', 0)]); + const added = { + assessment_id: 'new', + type: AssessmentItemTypes.QTI, + raw_data: 'new', + }; + + await composable.applyUpdate([item('a', 0), added]); + + expect(dispatched).toEqual([ + ['addAssessmentItem', { contentnode: NODE_ID, ...added, order: 1 }], + ]); + }); + + it('does not delay validation of a new question, so it counts as incomplete at once', async () => { + // The card it renders reports being incomplete immediately, so the tab icon and the + // "N incomplete questions" banner have to agree rather than staying quiet until reload. + const { composable, dispatched } = setup([item('a', 0)]); + const added = { assessment_id: 'new', type: AssessmentItemTypes.QTI, raw_data: '' }; + + await composable.applyUpdate([item('a', 0), added]); + + const [, payload] = dispatched[0]; + expect(Object.getOwnPropertySymbols(payload)).toEqual([]); + }); + + it('reorders the questions that moved before adding a new one between them', async () => { + const { composable, dispatched } = setup([item('a', 0), item('b', 1)]); + const added = { + assessment_id: 'new', + type: AssessmentItemTypes.QTI, + raw_data: 'new', + }; + + await composable.applyUpdate([item('a', 0), added, item('b', 1)]); + + expect(dispatched.map(([name]) => name)).toEqual([ + 'updateAssessmentItems', + 'addAssessmentItem', + ]); + expect(dispatched[0][1]).toEqual([{ contentnode: NODE_ID, assessment_id: 'b', order: 2 }]); + expect(dispatched[1][1].order).toBe(1); + }); + + it('reorders the remaining questions before deleting one', async () => { + const { composable, dispatched } = setup([item('a', 0), item('b', 1), item('c', 2)]); + + await composable.applyUpdate([item('a', 0), item('c', 2)]); + + expect(dispatched).toEqual([ + ['updateAssessmentItems', [{ contentnode: NODE_ID, assessment_id: 'c', order: 1 }]], + ['deleteAssessmentItem', { contentnode: NODE_ID, assessment_id: 'b' }], + ]); + }); + + it('reorders swapped questions', async () => { + const { composable, dispatched } = setup([item('a', 0), item('b', 1)]); + + await composable.applyUpdate([item('b', 1), item('a', 0)]); + + expect(dispatched).toEqual([ + [ + 'updateAssessmentItems', + [ + { contentnode: NODE_ID, assessment_id: 'b', order: 0 }, + { contentnode: NODE_ID, assessment_id: 'a', order: 1 }, + ], + ], + ]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js b/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js new file mode 100644 index 0000000000..b37ab89516 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js @@ -0,0 +1,113 @@ +import { computed, unref } from 'vue'; +import useStore from 'shared/composables/useStore'; +import { ContentModalities } from 'shared/constants'; + +/** + * Work out what changed between the list Studio holds and the list the editor produced. + * + * The QTI editor is a controlled list component: it hands back the whole array and knows + * nothing about how questions are stored. Studio, on the other hand, syncs one change + * record per assessment item, so the array has to be translated back into per-item writes. + * + * Position in the array is the question's order, and `raw_data` is the only field the + * editor ever rewrites. + * + * @param {Array} prevItems - The items currently in the store + * @param {Array} nextItems - The items the editor emitted + * @returns {{ orders: Array, added: Array, updated: Array, deleted: Array }} + */ +function diffAssessmentItems(prevItems, nextItems) { + const prevById = new Map(prevItems.map(item => [item.assessment_id, item])); + const nextIds = new Set(nextItems.map(item => item.assessment_id)); + + const orders = []; + const added = []; + const updated = []; + const deleted = prevItems.filter(item => !nextIds.has(item.assessment_id)); + + nextItems.forEach((item, order) => { + const previous = prevById.get(item.assessment_id); + + if (!previous) { + added.push({ ...item, order }); + return; + } + if (previous.order !== order) { + orders.push({ assessment_id: item.assessment_id, order }); + } + if (previous.raw_data !== item.raw_data) { + updated.push({ assessment_id: item.assessment_id, raw_data: item.raw_data }); + } + }); + + return { orders, added, updated, deleted }; +} + +/** + * Everything the questions tab needs about one content node's assessment items: the + * ordered list to render, how many of them are incomplete, and a way to save an edited + * list back through the change-sync layer. + * + * @param {string|import('vue').Ref} nodeId + */ +export default function useAssessmentItems(nodeId) { + const store = useStore(); + + const assessmentItems = computed(() => + store.getters['assessmentItem/getAssessmentItems'](unref(nodeId)), + ); + + /** + * A question with no correct answer cannot be scored, so it only counts as complete on a + * survey. The editor takes this as a plain flag; the modality lives out here. + */ + const allowFreeResponse = computed( + () => + store.getters['contentNode/getContentNode'](unref(nodeId))?.extra_fields?.options + ?.modality === ContentModalities.SURVEY, + ); + + const invalidItemsCount = computed(() => + store.getters['assessmentItem/getInvalidAssessmentItemsCount']({ + contentNodeId: unref(nodeId), + ignoreDelayed: true, + }), + ); + + /** + * Persist an edited list of items. + * + * Reordering runs first so that added and removed questions never leave two items + * claiming the same position, even briefly. + * + * @param {Array} nextItems - The full ordered list emitted by the editor + */ + async function applyUpdate(nextItems) { + const contentnode = unref(nodeId); + const { orders, added, updated, deleted } = diffAssessmentItems( + assessmentItems.value, + nextItems, + ); + + if (orders.length) { + await store.dispatch( + 'assessmentItem/updateAssessmentItems', + orders.map(order => ({ contentnode, ...order })), + ); + } + for (const item of added) { + await store.dispatch('assessmentItem/addAssessmentItem', { contentnode, ...item }); + } + for (const item of updated) { + await store.dispatch('assessmentItem/updateAssessmentItem', { contentnode, ...item }); + } + for (const item of deleted) { + await store.dispatch('assessmentItem/deleteAssessmentItem', { + contentnode, + assessment_id: item.assessment_id, + }); + } + } + + return { assessmentItems, invalidItemsCount, allowFreeResponse, applyUpdate }; +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/actions.js index e26d2b8763..07a0fdc9d6 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/actions.js @@ -50,18 +50,12 @@ export function loadAssessmentItems(context, params = {}) { } export function addAssessmentItem(context, assessmentItem) { - // API accepts answers and hints as strings - const stringifiedAssessmentItem = { - ...assessmentItem, - answers: JSON.stringify(assessmentItem.answers || []), - hints: JSON.stringify(assessmentItem.hints || []), - }; - + // Questions are authored as QTI, whose content lives in raw_data. return db.transaction( 'rw', [TABLE_NAMES.CONTENTNODE, TABLE_NAMES.ASSESSMENTITEM, TABLE_NAMES.CHANGES_TABLE], () => { - return AssessmentItem.add(stringifiedAssessmentItem).then(([contentnode, assessment_id]) => { + return AssessmentItem.add(assessmentItem).then(([contentnode, assessment_id]) => { context.commit('UPDATE_ASSESSMENTITEM', { ...assessmentItem, contentnode, @@ -91,19 +85,9 @@ export function updateAssessmentItems(context, assessmentItems) { () => { return Promise.all( assessmentItems.map(assessmentItem => { - // API accepts answers and hints as strings - const stringifiedAssessmentItem = { - ...assessmentItem, - }; - if (assessmentItem.answers) { - stringifiedAssessmentItem.answers = JSON.stringify(assessmentItem.answers); - } - if (assessmentItem.hints) { - stringifiedAssessmentItem.hints = JSON.stringify(assessmentItem.hints); - } return AssessmentItem.update( [assessmentItem.contentnode, assessmentItem.assessment_id], - stringifiedAssessmentItem, + assessmentItem, ).then(() => { updateNodeComplete(assessmentItem.contentnode, context); }); From 74db6540b3def0bc64f7917c6815ae6b5fc074f3 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:25:30 -0500 Subject: [PATCH 16/24] feat: preview exercise questions with the QTI editor The resource panel's question preview read question, answers and hints, which the API no longer returns, so it rendered empty cards for every exercise. It now shows each question through the QTI card in view mode, which brings its own numbering and type label, so the panel drops the numbering column it wrapped around the old preview. Co-Authored-By: Claude Opus 5 (1M context) --- .../channelEdit/components/ResourcePanel.vue | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue index e8616b3c1e..662f8b7e5c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue @@ -138,31 +138,17 @@ {{ $tr('questionCount', { value: assessmentItems.length }) }} - - - - -
- {{ index + 1 }} -
-
- - - -
-
- -
+ :key="item.assessment_id" + :item="item" + :index="index" + :total="assessmentItems.length" + mode="view" + :allowFreeResponse="allowFreeResponse" + :showAnswers="showAnswers" + class="question-preview" + /> @@ -507,8 +493,12 @@ import camelCase from 'lodash/camelCase'; import { isImportedContent, importedChannelLink, getCompletionCriteriaLabels } from '../utils'; import FilePreview from '../views/files/FilePreview'; - import { ContentLevels, Categories, AccessibilityCategories } from '../../shared/constants'; - import AssessmentItemPreview from './AssessmentItemPreview/AssessmentItemPreview'; + import { + ContentLevels, + Categories, + AccessibilityCategories, + ContentModalities, + } from '../../shared/constants'; import ContentNodeValidator from './ContentNodeValidator'; import { @@ -520,6 +510,7 @@ getNodeMasteryModelMErrors, getNodeMasteryModelNErrors, } from 'shared/utils/validation'; + import QTIItemEditor from 'shared/views/QTIEditor/components/QTIItemEditor/index'; import ContentNodeLearningActivityIcon from 'shared/views/ContentNodeLearningActivityIcon'; import LoadingText from 'shared/views/LoadingText'; import DetailsRow from 'shared/views/details/DetailsRow'; @@ -544,7 +535,7 @@ DetailsRow, FilePreview, ExpandableList, - AssessmentItemPreview, + QTIItemEditor, Checkbox, ContentNodeValidator, Banner, @@ -626,6 +617,10 @@ assessmentItems() { return this.getAssessmentItems(this.nodeId); }, + // Free-response questions cannot be scored, so they only count as complete on a survey. + allowFreeResponse() { + return this.node?.extra_fields?.options?.modality === ContentModalities.SURVEY; + }, fileSize() { return this.contentNodesTotalSize([this.nodeId]); }, @@ -918,6 +913,10 @@ padding: 0; } + .question-preview { + margin-bottom: 8px; + } + .preview-error { padding: 24% 0; From 15ca9b398c1063394aa093195dc0373c5de2e523 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:25:46 -0500 Subject: [PATCH 17/24] refactor: remove the legacy assessment editor Nothing renders AssessmentEditor or the components underneath it now that the questions tab and the resource panel both go through the QTI editor, and the question shapes they were built around no longer reach the client. Gone with them: the toolbar action and question type label constants, the answer-mapping helpers in channelEdit/utils, the array helpers in shared/utils/helpers that only those editors used, and the strings for all of it. The regex behind numeric answers is exercised by the QTI editor now, so its tests move there rather than disappearing. Co-Authored-By: Claude Opus 5 (1M context) --- .../channelEdit/__tests__/utils.spec.js | 416 +---------- .../AnswersEditor/AnswersEditor.spec.js | 621 ---------------- .../AnswersEditor/AnswersEditor.vue | 668 ------------------ .../AssessmentEditor/AssessmentEditor.spec.js | 437 ------------ .../AssessmentEditor/AssessmentEditor.vue | 550 -------------- .../AssessmentItemEditor.spec.js | 220 ------ .../AssessmentItemEditor.vue | 534 -------------- .../AssessmentItemPreview.spec.js | 105 --- .../AssessmentItemPreview.vue | 314 -------- .../components/AssessmentItemToolbar.vue | 312 -------- .../HintsEditor/HintsEditor.spec.js | 320 --------- .../components/HintsEditor/HintsEditor.vue | 534 -------------- .../frontend/channelEdit/constants.js | 20 - .../frontend/channelEdit/translator.js | 13 - .../frontend/channelEdit/utils.js | 136 ---- .../frontend/shared/utils/helpers.js | 43 -- .../frontend/shared/utils/helpers.spec.js | 35 +- 17 files changed, 2 insertions(+), 5276 deletions(-) delete mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.spec.js delete mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue delete mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.spec.js delete mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.vue delete mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.spec.js delete mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.vue delete mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemPreview/AssessmentItemPreview.spec.js delete mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemPreview/AssessmentItemPreview.vue delete mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemToolbar.vue delete mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.spec.js delete mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.vue diff --git a/contentcuration/contentcuration/frontend/channelEdit/__tests__/utils.spec.js b/contentcuration/contentcuration/frontend/channelEdit/__tests__/utils.spec.js index 1c19d7fadb..5678516418 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/__tests__/utils.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/__tests__/utils.spec.js @@ -1,9 +1,5 @@ import each from 'jest-each'; import { - floatOrIntRegex, - getCorrectAnswersIndices, - mapCorrectAnswers, - updateAnswersToQuestionType, isImportedContent, importedChannelLink, secondsToHms, @@ -13,7 +9,7 @@ import { import router from '../router'; import { RouteNames } from '../constants'; import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; -import { AssessmentItemTypes, CompletionCriteriaModels } from 'shared/constants'; +import { CompletionCriteriaModels } from 'shared/constants'; describe('channelEdit utils', () => { describe('imported content', () => { @@ -47,416 +43,6 @@ describe('channelEdit utils', () => { expect(importedChannelLink(notImportedContent, router)).toBe(null); }); }); - describe('getCorrectAnswersIndices', () => { - let questionKind; - - describe('for a single selection question', () => { - beforeEach(() => { - questionKind = AssessmentItemTypes.SINGLE_SELECTION; - }); - - it('returns null if there is no correct answer', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'Answer 1', correct: false }, - { answer: 'Answer 2', correct: false }, - ]), - ).toBeNull(); - }); - - it('returns a correct answer index', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'Answer 1', correct: false }, - { answer: 'Answer 2', correct: true }, - ]), - ).toBe(1); - }); - }); - - describe('for a true/false question', () => { - beforeEach(() => { - questionKind = AssessmentItemTypes.TRUE_FALSE; - }); - - it('returns null if there is no correct answer', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'True', correct: false }, - { answer: 'False', correct: false }, - ]), - ).toBeNull(); - }); - - it('returns a correct answer index', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'True', correct: false }, - { answer: 'False', correct: true }, - ]), - ).toBe(1); - }); - }); - - describe('for a multiple selection question', () => { - beforeEach(() => { - questionKind = AssessmentItemTypes.MULTIPLE_SELECTION; - }); - - it('returns an empty array if there is no correct answer', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'Answer 1', correct: false }, - { answer: 'Answer 2', correct: false }, - { answer: 'Answer 3', correct: false }, - ]), - ).toEqual([]); - }); - - it('returns an array of correct answer indices', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'Answer 1', correct: true }, - { answer: 'Answer 2', correct: false }, - { answer: 'Answer 3', correct: true }, - ]), - ).toEqual([0, 2]); - }); - }); - - describe('for an input question', () => { - beforeEach(() => { - questionKind = AssessmentItemTypes.INPUT_QUESTION; - }); - - it('returns an empty array if there is no correct answer', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'Answer 1', correct: false }, - { answer: 'Answer 2', correct: false }, - { answer: 'Answer 3', correct: false }, - ]), - ).toEqual([]); - }); - - it('returns an array of correct answer indices', () => { - expect( - getCorrectAnswersIndices(questionKind, [ - { answer: 'Answer 1', correct: true }, - { answer: 'Answer 2', correct: true }, - { answer: 'Answer 3', correct: true }, - ]), - ).toEqual([0, 1, 2]); - }); - }); - }); - - describe('mapCorrectAnswers', () => { - describe('for a single correct answer index', () => { - it('returns updated answers', () => { - expect( - mapCorrectAnswers( - [ - { answer: 'Answer 1', correct: true }, - { answer: 'Answer 2', correct: false }, - { answer: 'Answer 3', correct: true }, - ], - 1, - ), - ).toEqual([ - { answer: 'Answer 1', correct: false }, - { answer: 'Answer 2', correct: true }, - { answer: 'Answer 3', correct: false }, - ]); - }); - }); - - describe('for an array of correct answers indices', () => { - it('returns updated answers', () => { - expect( - mapCorrectAnswers( - [ - { answer: 'Answer 1', correct: true }, - { answer: 'Answer 2', correct: false }, - { answer: 'Answer 3', correct: true }, - ], - [1, 2], - ), - ).toEqual([ - { answer: 'Answer 1', correct: false }, - { answer: 'Answer 2', correct: true }, - { answer: 'Answer 3', correct: true }, - ]); - }); - }); - }); - - describe('updateAnswersToQuestionType', () => { - let answers; - - describe('when converting originally empty answers to true/false', () => { - it('returns true/false answers', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.TRUE_FALSE, [])).toEqual([ - { answer: 'True', correct: true, order: 1 }, - { answer: 'False', correct: false, order: 2 }, - ]); - }); - }); - - describe('for originally single selection answers', () => { - beforeEach(() => { - answers = [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: false, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - { answer: 'Jelly', correct: false, order: 3 }, - ]; - }); - - describe('conversion to single selection', () => { - it('returns the same answers', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.SINGLE_SELECTION, answers), - ).toEqual(answers); - }); - }); - - describe('conversion to multiple selection', () => { - it('returns the same answers', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.MULTIPLE_SELECTION, answers), - ).toEqual(answers); - }); - }); - - describe('conversion to input question', () => { - beforeEach(() => { - answers = [ - { answer: '1500', correct: false, order: 1 }, - { answer: '1500.00', correct: false, order: 2 }, - { answer: '-1500.00', correct: true, order: 3 }, - { answer: '1500 with alphabetical', correct: false, order: 4 }, - { answer: '$1500.00', correct: false, order: 5 }, - ]; - }); - - it('makes all answers correct and removes any answers with non-numeric characters', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.INPUT_QUESTION, answers)).toEqual([ - { answer: '1500', correct: true, order: 1 }, - { answer: '1500.00', correct: true, order: 2 }, - { answer: '-1500.00', correct: true, order: 3 }, - ]); - }); - }); - - describe('conversion to true/false', () => { - it('returns true/false answers only', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.TRUE_FALSE, answers)).toEqual([ - { answer: 'True', correct: true, order: 1 }, - { answer: 'False', correct: false, order: 2 }, - ]); - }); - }); - }); - - describe('for originally input question', () => { - beforeEach(() => { - answers = [ - { answer: '8', correct: true, order: 1 }, - { answer: '8.0', correct: true, order: 2 }, - { answer: '-400.19090', correct: true, order: 3 }, - { answer: '-140140104', correct: true, order: 4 }, - ]; - }); - - describe('conversion to input question', () => { - it('returns the same answers', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.INPUT_QUESTION, answers)).toEqual( - answers, - ); - }); - }); - - describe('conversion to multiple selection', () => { - it('returns the same answers', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.MULTIPLE_SELECTION, answers), - ).toEqual(answers); - }); - }); - - describe('conversion to single selection', () => { - it('keeps only first answer as correct', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.SINGLE_SELECTION, answers), - ).toEqual([ - { answer: '8', correct: true, order: 1 }, - { answer: '8.0', correct: false, order: 2 }, - { answer: '-400.19090', correct: false, order: 3 }, - { answer: '-140140104', correct: false, order: 4 }, - ]); - }); - }); - - describe('conversion to true/false', () => { - it('returns true/false answers only', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.TRUE_FALSE, answers)).toEqual([ - { answer: 'True', correct: true, order: 1 }, - { answer: 'False', correct: false, order: 2 }, - ]); - }); - }); - }); - - describe('for originally true/false question', () => { - beforeEach(() => { - answers = [ - { answer: 'True', correct: false, order: 1 }, - { answer: 'False', correct: true, order: 2 }, - ]; - }); - - describe('conversion to true/false question', () => { - it('returns the same answers', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.SINGLE_SELECTION, answers), - ).toEqual(answers); - }); - }); - - describe('conversion to multiple selection', () => { - it('returns the same answers', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.MULTIPLE_SELECTION, answers), - ).toEqual(answers); - }); - }); - - describe('conversion to single selection', () => { - it('returns the same answers', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.SINGLE_SELECTION, answers), - ).toEqual(answers); - }); - }); - - describe('conversion to input question', () => { - it('remove all answers', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.INPUT_QUESTION, answers)).toEqual( - [], - ); - }); - }); - }); - - describe('for originally multiple selection answers', () => { - describe('conversion to multiple selection', () => { - it('returns the same answers', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.SINGLE_SELECTION, answers), - ).toEqual(answers); - }); - }); - - describe('conversion to single selection', () => { - describe('if there are some correct answers', () => { - beforeEach(() => { - answers = [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: false, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - { answer: 'Jelly', correct: true, order: 3 }, - ]; - }); - - it('keeps only first correct answer', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.SINGLE_SELECTION, answers), - ).toEqual([ - { answer: 'Mayonnaise (I mean you can, but...)', correct: false, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - { answer: 'Jelly', correct: false, order: 3 }, - ]); - }); - }); - - describe('if there is no correct answer', () => { - beforeEach(() => { - answers = [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: false, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - { answer: 'Jelly', correct: false, order: 3 }, - ]; - }); - - it('makes a first answer correct', () => { - expect( - updateAnswersToQuestionType(AssessmentItemTypes.SINGLE_SELECTION, answers), - ).toEqual([ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - { answer: 'Jelly', correct: false, order: 3 }, - ]); - }); - }); - }); - - describe('conversion to input question', () => { - beforeEach(() => { - answers = [ - { answer: '1500', correct: false, order: 1 }, - { answer: '1500 00', correct: false, order: 2 }, - { answer: '1500 with alphabetical', correct: false, order: 3 }, - ]; - }); - - it('makes all answers correct and removes any answers with non-numeric characters', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.INPUT_QUESTION, answers)).toEqual([ - { answer: '1500', correct: true, order: 1 }, - ]); - }); - }); - - describe('conversion to true/false', () => { - beforeEach(() => { - answers = [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - { answer: 'Jelly', correct: true, order: 3 }, - ]; - }); - - it('returns true/false answers only', () => { - expect(updateAnswersToQuestionType(AssessmentItemTypes.TRUE_FALSE, answers)).toEqual([ - { answer: 'True', correct: true, order: 1 }, - { answer: 'False', correct: false, order: 2 }, - ]); - }); - }); - }); - }); - - // At least we know that these will work - describe('floatOrIntRegex', () => { - it('tests true for valid values', () => { - [ - '1.5', // Float - '-4.5', // Signed Float - '+1', // Signed Int - '10e5', // Exponentiation - '-15.3e5', // Combo - '-12345.67890e98', // Combo 2 - ].forEach(v => expect(floatOrIntRegex.test(v)).toBe(true)); - }); - - it('tests false for invalid values', () => { - [ - 'i * 1.5', // Math - 'one.point.five', // Text - '10 5 0 100', // Spaces - '1.2.3.4', // IP - ].forEach(v => expect(floatOrIntRegex.test(v)).toBe(false)); - }); - }); - describe(`secondsToHms`, () => { it(`converts 0 seconds to '00:00'`, () => { expect(secondsToHms(0)).toBe('00:00'); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.spec.js deleted file mode 100644 index 5d07f3296f..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.spec.js +++ /dev/null @@ -1,621 +0,0 @@ -import { shallowMount, mount } from '@vue/test-utils'; - -import { AssessmentItemToolbarActions } from '../../constants'; -import AnswersEditor from './AnswersEditor'; -import { AssessmentItemTypes } from 'shared/constants'; -import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'; - -jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'); - -jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { - return function useKResponsiveWindow() { - const { ref } = require('vue'); - return { windowIsSmall: ref(false) }; - }; -}); - -const clickNewAnswerBtn = async wrapper => { - await wrapper.findComponent('[data-test="newAnswerBtn"]').trigger('click'); -}; - -const rendersNewAnswerBtn = wrapper => { - return wrapper.findComponent('[data-test="newAnswerBtn"]').exists(); -}; - -const clickAnswer = async (wrapper, answerIdx) => { - await wrapper.findAll('[data-test="answer"]').at(answerIdx).trigger('click'); -}; - -const clickMoveAnswerUp = async (wrapper, answerIdx) => { - await wrapper - .findAllComponents(`[data-test="toolbarIcon-${AssessmentItemToolbarActions.MOVE_ITEM_UP}"]`) - .at(answerIdx) - .trigger('click'); -}; - -const clickMoveAnswerDown = async (wrapper, answerIdx) => { - await wrapper - .findAllComponents(`[data-test="toolbarIcon-${AssessmentItemToolbarActions.MOVE_ITEM_DOWN}"]`) - .at(answerIdx) - .trigger('click'); -}; - -const clickDeleteAnswer = async (wrapper, answerIdx) => { - await wrapper - .findAllComponents(`[data-test="toolbarIcon-${AssessmentItemToolbarActions.DELETE_ITEM}"]`) - .at(answerIdx) - .trigger('click'); -}; - -describe('AnswersEditor', () => { - let wrapper; - - it('smoke test', () => { - const wrapper = shallowMount(AnswersEditor); - - expect(wrapper.exists()).toBe(true); - }); - - it('renders a placeholder when there are no answers', () => { - wrapper = mount(AnswersEditor, { - propsData: { - answers: [], - }, - }); - - expect(wrapper.html()).toContain('Question has no answer options'); - }); - - describe('answers label', () => { - it.each([ - [AssessmentItemTypes.SINGLE_SELECTION, AnswersEditor.$trs.answersLabelSingleChoice], - [AssessmentItemTypes.TRUE_FALSE, AnswersEditor.$trs.answersLabelSingleChoice], - [AssessmentItemTypes.MULTIPLE_SELECTION, AnswersEditor.$trs.answersLabelMultipleChoice], - [AssessmentItemTypes.INPUT_QUESTION, AnswersEditor.$trs.answersLabelNumeric], - ])('renders the correct label for %s questions', (questionKind, expectedLabel) => { - wrapper = shallowMount(AnswersEditor, { - propsData: { - questionKind, - answers: [], - }, - }); - - expect(wrapper.text()).toContain(expectedLabel); - }); - }); - - describe('for a single selection question', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }, - }); - }); - - it('renders answers as radio controls', () => { - const inputs = wrapper.findAll('input'); - - expect(inputs.length).toBe(2); - for (const n in [0, 1]) { - expect(inputs.at(n).attributes()['type']).toBe('radio'); - } - }); - - it('renders only correct answers as checked', () => { - const inputs = wrapper.findAll('input'); - - expect(inputs.at(0).element.checked).toBe(true); - expect(inputs.at(1).element.checked).toBe(false); - }); - - it('marks correct answer rows with the selected visual state', () => { - const answerRows = wrapper.findAll('[data-test="answer"]'); - - // Correct row has both border-color and background-color applied - expect(answerRows.at(0).attributes('style')).toContain('border-color'); - expect(answerRows.at(0).attributes('style')).toContain('background-color'); - // Incorrect row has border-color but no inline background-color (null omits it) - expect(answerRows.at(1).attributes('style')).toContain('border-color'); - expect(answerRows.at(1).attributes('style')).not.toContain('background-color'); - }); - - it('renders all possible answers', () => { - // First answer is open by default (openAnswerIdx=0) — edit mode TipTapEditor - // Second answer is closed — view mode TipTapEditor - const editors = wrapper.findAllComponents(TipTapEditor); - - // Closed answer uses view mode to safely render rich text - const viewEditor = editors.filter(e => e.props('mode') === 'view').at(0); - expect(viewEditor.exists()).toBe(true); - expect(viewEditor.props('value')).toBe('Peanut butter'); - - // Open answer uses edit mode - const editEditor = editors.filter(e => e.props('mode') === 'edit').at(0); - expect(editEditor.exists()).toBe(true); - expect(editEditor.props('value')).toBe('Mayonnaise (I mean you can, but...)'); - }); - - it('renders new answer button', () => { - expect(rendersNewAnswerBtn(wrapper)).toBe(true); - expect(wrapper.findComponent('[data-test="newAnswerBtn"]').text()).toContain( - AnswersEditor.$trs.addOptionBtnLabel, - ); - }); - - describe('on new answer button click', () => { - beforeEach(async () => { - await clickNewAnswerBtn(wrapper); - }); - - it('emits update event with a payload containing all answers + new answer which is wrong by default', () => { - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - { answer: '', correct: false, order: 3 }, - ]); - }); - }); - }); - - describe('for a multiple selection question', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.MULTIPLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - { answer: 'Jelly', correct: true, order: 3 }, - ], - }, - }); - }); - - it('renders answers as checkboxes', () => { - const inputs = wrapper.findAll('input'); - - expect(inputs.length).toBe(3); - for (const n in [0, 1, 2]) { - expect(inputs.at(n).attributes()['type']).toBe('checkbox'); - } - }); - - it('renders only correct answers as checked', () => { - const inputs = wrapper.findAll('input'); - - expect(inputs.at(0).element.checked).toBe(true); - expect(inputs.at(1).element.checked).toBe(false); - expect(inputs.at(2).element.checked).toBe(true); - }); - - it('renders all possible answers', () => { - // First answer is open by default (openAnswerIdx=0) — edit mode TipTapEditor - // Remaining answers are closed — each gets a view mode TipTapEditor - const editors = wrapper.findAllComponents(TipTapEditor); - - const viewEditors = editors.filter(e => e.props('mode') === 'view'); - expect(viewEditors.length).toBe(2); - expect(viewEditors.at(0).props('value')).toBe('Peanut butter'); - expect(viewEditors.at(1).props('value')).toBe('Jelly'); - - const editEditor = editors.filter(e => e.props('mode') === 'edit').at(0); - expect(editEditor.exists()).toBe(true); - expect(editEditor.props('value')).toBe('Mayonnaise (I mean you can, but...)'); - }); - - it('renders new answer button', () => { - expect(rendersNewAnswerBtn(wrapper)).toBe(true); - expect(wrapper.findComponent('[data-test="newAnswerBtn"]').text()).toContain( - AnswersEditor.$trs.addOptionBtnLabel, - ); - }); - - describe('on new answer button click', () => { - beforeEach(async () => { - await clickNewAnswerBtn(wrapper); - }); - - it('emits update event with a payload containing all answers + new answer which is wrong by default', () => { - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - { answer: 'Jelly', correct: true, order: 3 }, - { answer: '', correct: false, order: 4 }, - ]); - }); - }); - }); - - describe('for a true/false question', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.TRUE_FALSE, - answers: [ - { answer: 'True', correct: false, order: 1 }, - { answer: 'False', correct: true, order: 2 }, - ], - }, - }); - }); - - it('renders answers as radio controls', () => { - const inputs = wrapper.findAll('input'); - - expect(inputs.length).toBe(2); - for (const n in [0, 1]) { - expect(inputs.at(n).attributes()['type']).toBe('radio'); - } - }); - - it('renders only correct answers as checked', () => { - const inputs = wrapper.findAll('input'); - - expect(inputs.at(0).element.checked).toBe(false); - expect(inputs.at(1).element.checked).toBe(true); - }); - - it('does not render new answer button', () => { - expect(rendersNewAnswerBtn(wrapper)).toBe(false); - }); - }); - - describe('for an input question', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.INPUT_QUESTION, - answers: [ - { answer: '1.5', correct: true, order: 1 }, - { answer: '2', correct: true, order: 2 }, - ], - }, - }); - }); - - it('renders open answer as a number input and closed answer as plain text', () => { - expect(wrapper.find('input[type="number"]').element.value).toBe('1.5'); - - expect(wrapper.html()).toContain('2'); - }); - - it('renders new answer button', () => { - expect(rendersNewAnswerBtn(wrapper)).toBe(true); - expect(wrapper.findComponent('[data-test="newAnswerBtn"]').text()).toContain( - AnswersEditor.$trs.newAnswerBtnLabel, - ); - }); - - describe('on new answer button click', () => { - beforeEach(async () => { - await clickNewAnswerBtn(wrapper); - }); - - it('emits update event with a payload containing all answers + new answer which is correct', () => { - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: '1.5', correct: true, order: 1 }, - { answer: '2', correct: true, order: 2 }, - { answer: '', correct: true, order: 3 }, - ]); - }); - }); - }); - - describe('autofocus on the open answer editor', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - openAnswerIdx: 1, - }, - }); - }); - - it('passes autofocus=true to the open (edit-mode) answer editor', () => { - // A single TipTapEditor per answer switches mode reactively. - // The editor for openAnswerIdx has mode='edit' and autofocus=true. - const editors = wrapper.findAllComponents(TipTapEditor); - const editModeEditor = editors.filter(e => e.props('mode') === 'edit').at(0); - expect(editModeEditor.props('autofocus')).toBe(true); - }); - }); - - describe('on an answer click', () => { - beforeEach(async () => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }, - }); - - await clickAnswer(wrapper, 1); - }); - - it('emits open event with a correct answer idx', () => { - expect(wrapper.emitted().open).toBeTruthy(); - expect(wrapper.emitted().open.length).toBe(1); - expect(wrapper.emitted().open[0][0]).toBe(1); - }); - }); - - describe('on new answer button click', () => { - beforeEach(async () => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: ' ', correct: true, order: 2 }, - { answer: 'Peanut butter', correct: false, order: 3 }, - ], - }, - }); - - await clickNewAnswerBtn(wrapper); - }); - - it('emits update event with a payload containing all answers and one new empty answer', () => { - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: ' ', correct: true, order: 2 }, - { answer: 'Peanut butter', correct: false, order: 3 }, - { answer: '', correct: false, order: 4 }, - ]); - }); - - it('emits open event with a new answer idx', () => { - expect(wrapper.emitted().open).toBeTruthy(); - expect(wrapper.emitted().open.length).toBe(1); - expect(wrapper.emitted().open[0][0]).toBe(3); - }); - }); - - describe('on answer text update', () => { - beforeEach(async () => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - openAnswerIdx: 1, - }, - }); - - const editors = wrapper.findAllComponents(TipTapEditor); - editors.at(1).vm.$emit('update', 'European butter'); - - await wrapper.vm.$nextTick(); - }); - - it('emits update event with a payload containing updated answers', () => { - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - - const emittedAnswers = JSON.parse(JSON.stringify(wrapper.emitted().update[0][0])); - - expect(emittedAnswers).toEqual([ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'European butter', correct: false, order: 2 }, - ]); - }); - }); - - describe('on correct answer change', () => { - beforeEach(async () => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - openAnswerIdx: 1, - }, - }); - - await wrapper.vm.$nextTick(); - await wrapper.findAll('.answer-selection input[type="radio"]').at(1).trigger('click'); - }); - - it('emits update event with a payload containing updated answers', () => { - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: 'Mayonnaise (I mean you can, but...)', correct: false, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - ]); - }); - }); - - describe('on move answer up click', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }, - }); - }); - - it('emits update event with a payload containing updated and properly ordered answers', async () => { - await clickMoveAnswerUp(wrapper, 1); - - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: 'Peanut butter', correct: false, order: 1 }, - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 2 }, - ]); - }); - - describe('if moved answer was open', () => { - beforeEach(async () => { - await wrapper.setProps({ - openAnswerIdx: 1, - }); - }); - - it('emits open event with updated answer index', async () => { - await clickMoveAnswerUp(wrapper, 1); - - expect(wrapper.emitted().open).toBeTruthy(); - expect(wrapper.emitted().open.length).toBe(1); - expect(wrapper.emitted().open[0][0]).toBe(0); - }); - }); - - describe('if an answer above a moved answer was open', () => { - beforeEach(async () => { - await wrapper.setProps({ - openAnswerIdx: 0, - }); - - await clickMoveAnswerUp(wrapper, 1); - }); - - it('emits open event with updated, originally open, answer index', () => { - expect(wrapper.emitted().open).toBeTruthy(); - expect(wrapper.emitted().open.length).toBe(1); - expect(wrapper.emitted().open[0][0]).toBe(1); - }); - }); - }); - - describe('on move answer down click', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }, - }); - }); - - it('emits update event with a payload containing updated and properly ordered answers', async () => { - await clickMoveAnswerDown(wrapper, 0); - - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: 'Peanut butter', correct: false, order: 1 }, - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 2 }, - ]); - }); - - describe('if moved answer was open', () => { - beforeEach(async () => { - await wrapper.setProps({ - openAnswerIdx: 0, - }); - }); - - it('emits open event with updated answer index', async () => { - await clickMoveAnswerDown(wrapper, 0); - - expect(wrapper.emitted().open).toBeTruthy(); - expect(wrapper.emitted().open.length).toBe(1); - expect(wrapper.emitted().open[0][0]).toBe(1); - }); - }); - - describe('if an answer below a moved answer was open', () => { - beforeEach(async () => { - await wrapper.setProps({ - openAnswerIdx: 1, - }); - - await clickMoveAnswerDown(wrapper, 0); - }); - - it('emits open event with updated, originally open, answer index', () => { - expect(wrapper.emitted().open).toBeTruthy(); - expect(wrapper.emitted().open.length).toBe(1); - expect(wrapper.emitted().open[0][0]).toBe(0); - }); - }); - }); - - describe('on delete answer click', () => { - beforeEach(() => { - wrapper = mount(AnswersEditor, { - propsData: { - questionKind: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }, - }); - }); - - it('emits update event with a payload containing updated and properly ordered answers', async () => { - await clickDeleteAnswer(wrapper, 0); - - expect(wrapper.emitted().update).toBeTruthy(); - expect(wrapper.emitted().update.length).toBe(1); - expect(wrapper.emitted().update[0][0]).toEqual([ - { answer: 'Peanut butter', correct: false, order: 1 }, - ]); - }); - - describe('if deleted answer was open', () => { - beforeEach(async () => { - await wrapper.setProps({ - openAnswerIdx: 0, - }); - }); - - it('emits close event', async () => { - await clickDeleteAnswer(wrapper, 0); - - expect(wrapper.emitted().close).toBeTruthy(); - expect(wrapper.emitted().close.length).toBe(1); - }); - }); - - describe('if an answer below a deleted answer was open', () => { - beforeEach(async () => { - await wrapper.setProps({ - openAnswerIdx: 1, - }); - - await clickDeleteAnswer(wrapper, 0); - }); - - it('emits open event with updated, originally open, answer index', () => { - expect(wrapper.emitted().open).toBeTruthy(); - expect(wrapper.emitted().open.length).toBe(1); - expect(wrapper.emitted().open[0][0]).toBe(0); - }); - }); - }); -}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue deleted file mode 100644 index 44ec3df510..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue +++ /dev/null @@ -1,668 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.spec.js deleted file mode 100644 index e908768f04..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.spec.js +++ /dev/null @@ -1,437 +0,0 @@ -import { shallowMount, mount } from '@vue/test-utils'; - -import { AssessmentItemToolbarActions } from '../../constants'; -import { assessmentItemKey } from '../../utils'; -import AssessmentEditor from './AssessmentEditor'; -import { AssessmentItemTypes, ValidationErrors, DELAYED_VALIDATION } from 'shared/constants'; - -jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'); - -const NODE_ID = 'node-id'; -const ITEM1 = { - contentnode: NODE_ID, - assessment_id: 'question-1', - question: 'Question 1', - type: AssessmentItemTypes.INPUT_QUESTION, - order: 0, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - ], - hints: [], -}; -const ITEM2 = { - contentnode: NODE_ID, - assessment_id: 'question-2', - question: 'Question 2', - type: AssessmentItemTypes.SINGLE_SELECTION, - order: 1, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - ], - hints: [ - { hint: "It's not healthy", order: 1 }, - { hint: 'Tasty!', order: 2 }, - ], -}; -const ITEM3 = { - contentnode: NODE_ID, - assessment_id: 'question-3', - question: 'Question 3', - type: AssessmentItemTypes.MULTIPLE_SELECTION, - order: 2, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - { answer: 'Jelly', correct: true, order: 3 }, - ], - hints: [], -}; -const ITEM4 = { - contentnode: NODE_ID, - assessment_id: 'question-4', - question: 'Question 4', - type: AssessmentItemTypes.TRUE_FALSE, - order: 3, - answers: [ - { answer: 'True', correct: false, order: 1 }, - { answer: 'False', correct: true, order: 2 }, - ], - hints: [], -}; - -const ITEMS = [ITEM1, ITEM2, ITEM3, ITEM4]; -const ITEMS_VALIDATION = [ - [], - [ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS], - [ValidationErrors.QUESTION_REQUIRED], -]; - -const checkShowAnswers = async wrapper => { - await wrapper.findComponent('[data-test="showAnswersCheckbox"]').trigger('click'); -}; - -const getItems = wrapper => { - return wrapper.findAllComponents('[data-test="item"]'); -}; - -const isItemOpen = assessmentItemWrapper => { - return assessmentItemWrapper.findComponent('[data-test="editor"]').exists(); -}; - -const isAnswersPreviewVisible = assessmentItemWrapper => { - return assessmentItemWrapper.findComponent('[data-test="item-answers-preview"]').exists(); -}; - -const clickNewQuestionBtn = async wrapper => { - await wrapper.findComponent('[data-test="newQuestionBtn"]').trigger('click'); -}; - -const clickClose = async assessmentItemWrapper => { - await assessmentItemWrapper.findComponent('[data-test="closeBtn"]').trigger('click'); -}; - -const clickDelete = async assessmentItemWrapper => { - await assessmentItemWrapper - .findComponent(`[data-test="toolbarMenuItem-${AssessmentItemToolbarActions.DELETE_ITEM}"]`) - .trigger('click'); -}; - -const clickAddQuestionAbove = async assessmentItemWrapper => { - await assessmentItemWrapper - .findComponent(`[data-test="toolbarMenuItem-${AssessmentItemToolbarActions.ADD_ITEM_ABOVE}"]`) - .trigger('click'); -}; - -const clickAddQuestionBelow = async assessmentItemWrapper => { - await assessmentItemWrapper - .findComponent(`[data-test="toolbarMenuItem-${AssessmentItemToolbarActions.ADD_ITEM_BELOW}"]`) - .trigger('click'); -}; - -const clickMoveUp = async assessmentItemWrapper => { - await assessmentItemWrapper - .findComponent(`[data-test="toolbarIcon-${AssessmentItemToolbarActions.MOVE_ITEM_UP}"]`) - .trigger('click'); -}; - -const clickMoveDown = async assessmentItemWrapper => { - await assessmentItemWrapper - .findComponent(`[data-test="toolbarIcon-${AssessmentItemToolbarActions.MOVE_ITEM_DOWN}"]`) - .trigger('click'); -}; - -describe('AssessmentEditor', () => { - let wrapper; - const listeners = { - deleteItem: jest.fn(), - addItem: jest.fn(), - updateItem: jest.fn(), - updateItems: jest.fn(), - }; - - beforeEach(() => { - wrapper = mount(AssessmentEditor, { - propsData: { - nodeId: NODE_ID, - items: ITEMS, - itemsValidation: ITEMS_VALIDATION, - }, - stubs: { - AssessmentItemEditor: true, - }, - listeners, - }); - }); - - it('smoke test', () => { - const wrapper = shallowMount(AssessmentEditor); - - expect(wrapper.exists()).toBe(true); - }); - - describe('for an exercise with no questions', () => { - let wrapper; - - beforeEach(() => { - wrapper = mount(AssessmentEditor, { - propsData: { - nodeId: NODE_ID, - items: [], - }, - }); - }); - - it('renders placeholder text if exercise has no questions', () => { - expect(wrapper.html()).toContain('Exercise has no questions'); - }); - - it("doesn't render 'Show answers' checkbox", () => { - expect(wrapper.findComponent('[data-test="showAnswersCheckbox"]').exists()).toBe(false); - }); - }); - - it('renders all items', () => { - const items = getItems(wrapper); - - expect(items.length).toBe(4); - - expect(items.at(0).findComponent({ name: 'RichTextEditor' }).props('value')).toBe( - ITEM1.question, - ); - expect(items.at(1).findComponent({ name: 'RichTextEditor' }).props('value')).toBe( - ITEM2.question, - ); - expect(items.at(2).findComponent({ name: 'RichTextEditor' }).props('value')).toBe( - ITEM3.question, - ); - expect(items.at(3).findComponent({ name: 'RichTextEditor' }).props('value')).toBe( - ITEM4.question, - ); - }); - - it('renders items as closed', () => { - const items = getItems(wrapper); - - expect(isItemOpen(items.at(0))).toBe(false); - expect(isItemOpen(items.at(1))).toBe(false); - expect(isItemOpen(items.at(2))).toBe(false); - expect(isItemOpen(items.at(3))).toBe(false); - }); - - it("renders 'Show answers' checkbox", () => { - expect(wrapper.findComponent('[data-test="showAnswersCheckbox"]').exists()).toBe(true); - }); - - it("wraps 'Show answers' checkbox in a page container", () => { - expect(wrapper.find('.show-answers-container').exists()).toBe(true); - }); - - it('renders question card headers', () => { - expect(wrapper.html()).toContain('Question 1 of 4 — Numeric input'); - expect(wrapper.html()).toContain('Question 2 of 4 — Single choice'); - }); - - it("doesn't render answers preview by default", () => { - const items = getItems(wrapper); - - expect(isAnswersPreviewVisible(items.at(0))).toBe(false); - expect(isAnswersPreviewVisible(items.at(1))).toBe(false); - expect(isAnswersPreviewVisible(items.at(2))).toBe(false); - expect(isAnswersPreviewVisible(items.at(3))).toBe(false); - }); - - it('renders answers preview on show answers click', async () => { - await checkShowAnswers(wrapper); - - const items = getItems(wrapper); - - expect(isAnswersPreviewVisible(items.at(0))).toBe(true); - expect(isAnswersPreviewVisible(items.at(1))).toBe(true); - expect(isAnswersPreviewVisible(items.at(2))).toBe(true); - expect(isAnswersPreviewVisible(items.at(3))).toBe(true); - }); - - it('opens an item on item click', async () => { - const items = getItems(wrapper); - await items.at(1).trigger('click'); - const updatedItems = getItems(wrapper); - - expect(isItemOpen(updatedItems.at(0))).toBe(false); - expect(isItemOpen(updatedItems.at(1))).toBe(true); - expect(isItemOpen(updatedItems.at(2))).toBe(false); - expect(isItemOpen(updatedItems.at(3))).toBe(false); - }); - - it('closes an item on close button click', async () => { - // open an item at first - const items = getItems(wrapper); - await items.at(1).trigger('click'); - let updatedItems = getItems(wrapper); - expect(isItemOpen(updatedItems.at(1))).toBe(true); - - // now close it - await clickClose(updatedItems.at(1)); - updatedItems = getItems(wrapper); - expect(isItemOpen(updatedItems.at(1))).toBe(false); - }); - - describe('on "Delete" click', () => { - beforeEach(async () => { - jest.clearAllMocks(); - const items = getItems(wrapper); - await clickDelete(items.at(1)); - }); - - it('emits delete item event with a correct key', () => { - expect(listeners.deleteItem).toHaveBeenCalledWith(ITEM2); - expect(listeners.updateItems).toHaveBeenCalledTimes(1); - }); - - it('emits update item events with updated order of items after the deleted item', () => { - expect(listeners.updateItems).toHaveBeenCalledWith([ - { - ...assessmentItemKey(ITEM1), - order: 0, - }, - { - ...assessmentItemKey(ITEM3), - order: 1, - }, - { - ...assessmentItemKey(ITEM4), - order: 2, - }, - ]); - expect(listeners.updateItems).toHaveBeenCalledTimes(1); - }); - }); - - describe('on "Add question above" click', () => { - beforeEach(async () => { - jest.clearAllMocks(); - const items = getItems(wrapper); - await clickAddQuestionAbove(items.at(1)); - }); - - it('emits add item event with a new item with a correct order', () => { - expect(listeners.addItem).toHaveBeenCalledWith({ - contentnode: NODE_ID, - question: '', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [], - hints: [], - order: 1, - [DELAYED_VALIDATION]: true, - }); - }); - - it('emits update item events with updated order of items below the new item', () => { - expect(listeners.updateItems).toHaveBeenCalledWith([ - { - ...assessmentItemKey(ITEM1), - order: 0, - }, - { - ...assessmentItemKey(ITEM2), - order: 2, - }, - { - ...assessmentItemKey(ITEM3), - order: 3, - }, - { - ...assessmentItemKey(ITEM4), - order: 4, - }, - ]); - expect(listeners.updateItems).toHaveBeenCalledTimes(1); - }); - }); - - describe('on "Add question below" click', () => { - beforeEach(async () => { - jest.clearAllMocks(); - const items = getItems(wrapper); - await clickAddQuestionBelow(items.at(1)); - }); - - it('emits add item event with a new item with a correct order', () => { - expect(listeners.addItem).toHaveBeenCalledWith({ - contentnode: NODE_ID, - question: '', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [], - hints: [], - order: 2, - [DELAYED_VALIDATION]: true, - }); - expect(listeners.addItem).toHaveBeenCalledTimes(1); - }); - - it('emits update item events with updated order of items below the new item', () => { - expect(listeners.updateItems).toHaveBeenCalledWith([ - { - ...assessmentItemKey(ITEM1), - order: 0, - }, - { - ...assessmentItemKey(ITEM2), - order: 1, - }, - { - ...assessmentItemKey(ITEM3), - order: 3, - }, - { - ...assessmentItemKey(ITEM4), - order: 4, - }, - ]); - expect(listeners.updateItems).toHaveBeenCalledTimes(1); - }); - }); - - describe('on "Move up" click', () => { - beforeEach(async () => { - jest.clearAllMocks(); - const items = getItems(wrapper); - await clickMoveUp(items.at(1)); - }); - - it('emits update item events with updated order of affected items', () => { - expect(listeners.updateItems).toHaveBeenCalledWith([ - { - ...assessmentItemKey(ITEM2), - order: 0, - }, - { - ...assessmentItemKey(ITEM1), - order: 1, - }, - ]); - expect(listeners.updateItems).toHaveBeenCalledTimes(1); - }); - }); - - describe('on "Move down" click', () => { - beforeEach(async () => { - jest.clearAllMocks(); - const items = getItems(wrapper); - await clickMoveDown(items.at(1)); - }); - - it('emits update item events with updated order of affected items', () => { - expect(listeners.updateItems).toHaveBeenCalledWith([ - { - ...assessmentItemKey(ITEM2), - order: 2, - }, - { - ...assessmentItemKey(ITEM3), - order: 1, - }, - ]); - expect(listeners.updateItems).toHaveBeenCalledTimes(1); - }); - }); - - describe('on "Add new question" click', () => { - beforeEach(async () => { - await clickNewQuestionBtn(wrapper); - }); - - it('emits add item event with a new item with a correct order', () => { - expect(listeners.addItem).toHaveBeenCalledWith({ - contentnode: NODE_ID, - question: '', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [], - hints: [], - order: 4, - [DELAYED_VALIDATION]: true, - }); - }); - }); -}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.vue deleted file mode 100644 index f6ccb6c2b0..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.vue +++ /dev/null @@ -1,550 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.spec.js deleted file mode 100644 index f5d55b88d8..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.spec.js +++ /dev/null @@ -1,220 +0,0 @@ -import { render, screen, fireEvent, within, configure } from '@testing-library/vue'; -import userEvent from '@testing-library/user-event'; - -import { factory } from '../../store'; -import { assessmentItemKey } from '../../utils'; -import AssessmentItemEditor from './AssessmentItemEditor'; -import { AssessmentItemTypes, ValidationErrors } from 'shared/constants'; - -jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'); - -configure({ - testIdAttribute: 'data-test', -}); - -const store = factory(); - -const ITEM = { - contentnode: 'Exercise 2', - assessment_id: 'Question 2', - question: 'Exercise 2 - Question 2', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - hints: [ - { hint: "It's not healthy", order: 1 }, - { hint: 'Tasty!', order: 2 }, - ], -}; - -const renderComponent = (props = {}) => { - return render(AssessmentItemEditor, { - store, - routes: [], - props: { - nodeId: 'node-id', - item: ITEM, - ...props, - }, - }); -}; - -// Returns the payload of the most recent `update` event. -const lastUpdatePayload = emitted => { - const updates = emitted().update; - return updates[updates.length - 1][0]; -}; - -// Opens the question editor (question starts collapsed in view mode) and returns its textbox. -const openQuestionEditor = async user => { - await user.click(screen.getByTestId('questionText')); - // Both the type dropdown and the answers expose textboxes, so target the question editor's. - return screen.getAllByRole('textbox').find(el => el.tagName === 'TEXTAREA'); -}; - -// Opens the response-type dropdown (by clicking its current value) and picks a new type. -const changeQuestionType = async (user, currentLabel, newLabel) => { - const select = screen.getByTestId('kindSelect'); - await user.click(within(select).getByText(currentLabel)); - await user.click(await screen.findByText(newLabel)); -}; - -describe('AssessmentItemEditor', () => { - it('shows the response type, question, answers, and hints of the item', () => { - renderComponent(); - - expect(screen.getByText('Type')).toBeInTheDocument(); - expect(screen.getByText('Exercise 2 - Question 2')).toBeInTheDocument(); - expect(screen.getByText('Peanut butter')).toBeInTheDocument(); - expect(screen.getByText('Mayonnaise (I mean you can, but...)')).toBeInTheDocument(); - }); - - it('lets the user edit the question and emits the updated question text', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent(); - - const questionEditor = await openQuestionEditor(user); - await fireEvent.update(questionEditor, 'My new question'); - - expect(lastUpdatePayload(emitted)).toEqual({ - ...assessmentItemKey(ITEM), - question: 'My new question', - }); - }); - - describe('changing the question type', () => { - it('keeps a single correct answer when switching to single choice', async () => { - const item = { - ...ITEM, - type: AssessmentItemTypes.MULTIPLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - ], - }; - const user = userEvent.setup(); - const { emitted } = renderComponent({ item }); - - await changeQuestionType(user, 'Multiple choice', 'Single choice'); - - expect(lastUpdatePayload(emitted)).toEqual({ - ...assessmentItemKey(item), - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }); - }); - - it('replaces the answers with True and False when switching to true or false', async () => { - const item = { - ...ITEM, - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }; - const user = userEvent.setup(); - const { emitted } = renderComponent({ item }); - - await changeQuestionType(user, 'Single choice', 'True/False'); - - expect(lastUpdatePayload(emitted)).toEqual({ - ...assessmentItemKey(item), - type: AssessmentItemTypes.TRUE_FALSE, - answers: [ - { answer: 'True', order: 1, correct: true }, - { answer: 'False', order: 2, correct: false }, - ], - }); - }); - - it('marks every numeric answer as correct when switching to numeric input', async () => { - const item = { - ...ITEM, - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: '8', correct: true, order: 1 }, - { answer: '8.0', correct: false, order: 2 }, - { answer: '-400.19090', correct: false, order: 3 }, - ], - }; - const user = userEvent.setup(); - const { emitted } = renderComponent({ item }); - - await changeQuestionType(user, 'Single choice', 'Numeric input'); - - expect(lastUpdatePayload(emitted)).toEqual({ - ...assessmentItemKey(item), - type: AssessmentItemTypes.INPUT_QUESTION, - answers: [ - { answer: '8', correct: true, order: 1 }, - { answer: '8.0', correct: true, order: 2 }, - { answer: '-400.19090', correct: true, order: 3 }, - ], - }); - }); - }); - - it('emits the updated answers when the user changes which answer is correct', async () => { - const item = { - ...ITEM, - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], - }; - const { emitted } = renderComponent({ item }); - - // Selecting the second answer's correctness control makes it the correct one. - const radios = screen.getAllByRole('radio'); - await fireEvent.click(radios[1]); - - expect(lastUpdatePayload(emitted)).toEqual({ - ...assessmentItemKey(item), - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: false, order: 1 }, - { answer: 'Peanut butter', correct: true, order: 2 }, - ], - }); - }); - - it('emits the updated hints when the user edits a hint', async () => { - const user = userEvent.setup(); - const item = { - ...ITEM, - hints: [{ hint: 'Hint 1', order: 1 }], - }; - const { emitted } = renderComponent({ item }); - - // Open the collapsible hints section, then open the hint to edit it. - await user.click(screen.getByRole('button', { name: /hints/i })); - const hintCard = screen.getByTestId('hint'); - await user.click(hintCard); - - const hintEditor = within(screen.getByTestId('hint')).getByRole('textbox'); - await fireEvent.update(hintEditor, 'Updated hint'); - - expect(lastUpdatePayload(emitted)).toEqual({ - ...assessmentItemKey(item), - hints: [{ hint: 'Updated hint', order: 1 }], - }); - }); - - it('shows validation messages for an invalid item', () => { - renderComponent({ - errors: [ - ValidationErrors.QUESTION_REQUIRED, - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ], - }); - - expect(screen.getByText('Question is required')).toBeInTheDocument(); - expect(screen.getByText('Choose a correct answer')).toBeInTheDocument(); - }); -}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.vue deleted file mode 100644 index 79acbfc85a..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.vue +++ /dev/null @@ -1,534 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemPreview/AssessmentItemPreview.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemPreview/AssessmentItemPreview.spec.js deleted file mode 100644 index 4022762e3d..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemPreview/AssessmentItemPreview.spec.js +++ /dev/null @@ -1,105 +0,0 @@ -import { mount } from '@vue/test-utils'; - -import AssessmentItemPreview from './AssessmentItemPreview'; -import { AssessmentItemTypes } from 'shared/constants'; - -jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'); - -describe('AssessmentItemPreview', () => { - let wrapper; - - beforeEach(() => { - wrapper = mount(AssessmentItemPreview, { - propsData: { - item: { - question: 'Question', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Answer 1', correct: false, order: 1 }, - { answer: 'Answer 2', correct: true, order: 2 }, - { answer: 'Answer 3', correct: false, order: 3 }, - ], - hints: [ - { hint: 'Hint 1', order: 1 }, - { hint: 'Hint 2', order: 2 }, - ], - }, - }, - }); - }); - - it('smoke test', () => { - expect(wrapper.exists()).toBe(true); - }); - - it('renders question', () => { - // Find the RichTextEditor for the question and check its value prop. - const questionEditor = wrapper.findComponent({ name: 'RichTextEditor' }); - expect(questionEditor.props('value')).toBe('Question'); - }); - - it("doesn't render answers by default", () => { - expect(wrapper.html()).not.toContain('Answer 1'); - expect(wrapper.html()).not.toContain('Answer 2'); - expect(wrapper.html()).not.toContain('Answer 3'); - }); - - it("doesn't render hints and hints toggle by default", () => { - expect(wrapper.findComponent('[data-test="hintsToggle"]').exists()).toBe(false); - - expect(wrapper.html()).not.toContain('Hint 1'); - expect(wrapper.html()).not.toContain('Hint 2'); - }); - - describe('if detailed true', () => { - beforeEach(async () => { - await wrapper.setProps({ - detailed: true, - }); - }); - - it('renders answers', () => { - const editors = wrapper.findAllComponents({ name: 'RichTextEditor' }); - // We expect 1 for the question + 3 for the answers = 4 total editors. - expect(editors.length).toBe(4); - - expect(editors.at(1).props('value')).toBe('Answer 1'); - expect(editors.at(2).props('value')).toBe('Answer 2'); - expect(editors.at(3).props('value')).toBe('Answer 3'); - }); - - it("doesn't render hints", () => { - expect(wrapper.html()).not.toContain('Hint 1'); - expect(wrapper.html()).not.toContain('Hint 2'); - }); - - it('renders hints toggle', () => { - expect(wrapper.find('[data-test="hintsToggle"]').exists()).toBe(true); - }); - - it('renders hints on hints toggle click', async () => { - await wrapper.find('[data-test="hintsToggle"]').trigger('click'); - - // After clicking, there should be more editors for the hints. - // 1 (question) + 3 (answers) + 2 (hints) = 6 total editors. - const editors = wrapper.findAllComponents({ name: 'RichTextEditor' }); - expect(editors.length).toBe(6); - - expect(editors.at(4).props('value')).toBe('Hint 1'); - expect(editors.at(5).props('value')).toBe('Hint 2'); - }); - }); - - describe('showTypeLabel property', () => { - it('should render type label by default', () => { - expect(wrapper.find('[data-test="type-label"]').exists()).toBe(true); - }); - - it('should hide type label when showTypeLabel is false', async () => { - await wrapper.setProps({ - showTypeLabel: false, - }); - expect(wrapper.find('[data-test="type-label"]').exists()).toBe(false); - }); - }); -}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemPreview/AssessmentItemPreview.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemPreview/AssessmentItemPreview.vue deleted file mode 100644 index 67e3b01b48..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemPreview/AssessmentItemPreview.vue +++ /dev/null @@ -1,314 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemToolbar.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemToolbar.vue deleted file mode 100644 index 63c7e354ff..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemToolbar.vue +++ /dev/null @@ -1,312 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.spec.js deleted file mode 100644 index 3efedcb4c8..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.spec.js +++ /dev/null @@ -1,320 +0,0 @@ -import { render, screen, within, configure } from '@testing-library/vue'; -import userEvent from '@testing-library/user-event'; - -import { AssessmentItemToolbarActions } from '../../constants'; -import HintsEditor from './HintsEditor'; - -jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'); -jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { - return function useKResponsiveWindow() { - const { ref } = require('vue'); - return { windowIsSmall: ref(false) }; - }; -}); - -configure({ - testIdAttribute: 'data-test', -}); - -const renderComponent = props => { - return render(HintsEditor, { - routes: [], - props: { - hints: [], - ...props, - }, - }); -}; - -const openHintsSection = async user => { - await user.click(screen.getByText(HintsEditor.$trs.hintsLabel)); -}; - -const getHintCards = () => { - return screen.getAllByTestId('hint'); -}; - -const clickToolbarAction = async ({ action, hintIdx, user }) => { - const buttons = screen.getAllByTestId(`toolbarIcon-${action}`); - expect(buttons[hintIdx]).toBeInTheDocument(); - await user.click(buttons[hintIdx]); -}; - -describe('HintsEditor', () => { - it('smoke test', async () => { - const user = userEvent.setup(); - renderComponent(); - await openHintsSection(user); - - expect( - screen.getByRole('button', { name: HintsEditor.$trs.newHintBtnLabel }), - ).toBeInTheDocument(); - }); - - it('shows an empty-state message when a question has no hints', async () => { - const user = userEvent.setup(); - renderComponent({ - hints: [], - }); - await openHintsSection(user); - - expect(screen.getByText(HintsEditor.$trs.noHintsPlaceholder)).toBeInTheDocument(); - }); - - it('shows hints in the same order as the question', async () => { - const user = userEvent.setup(); - renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - }); - await openHintsSection(user); - - const hintCards = getHintCards(); - expect(within(hintCards[0]).getByText('First hint')).toBeInTheDocument(); - expect(within(hintCards[1]).getByText('Second hint')).toBeInTheDocument(); - }); - - it('lets the user update the text of the currently open hint', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 1, - }); - await openHintsSection(user); - - const hintCards = getHintCards(); - const hintTextField = within(hintCards[1]).getByRole('textbox'); - - await user.clear(hintTextField); - await user.type(hintTextField, 'Updated hint'); - - const updateEvents = emitted().update; - expect(updateEvents[updateEvents.length - 1][0]).toEqual([ - { hint: 'First hint', order: 1 }, - { hint: 'Updated hint', order: 2 }, - ]); - }); - - it('autofocuses the editor of the open hint', async () => { - const user = userEvent.setup(); - renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 0, - }); - await openHintsSection(user); - - const hintCards = getHintCards(); - // The open hint renders an editable textbox that should request autofocus. - expect(within(hintCards[0]).getByRole('textbox')).toHaveAttribute('data-autofocus', 'true'); - // Closed hints render in view mode, so they have no editable textbox to focus. - expect(within(hintCards[1]).queryByRole('textbox')).not.toBeInTheDocument(); - }); - - it('adds a new hint and removes existing empty hints when the user clicks New hint', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: '', order: 2 }, - { hint: 'Third hint', order: 3 }, - ], - }); - await openHintsSection(user); - - await user.click(screen.getByRole('button', { name: HintsEditor.$trs.newHintBtnLabel })); - - expect(emitted().update).toHaveLength(1); - expect(emitted().update[0][0]).toEqual([ - { hint: 'First hint', order: 1 }, - { hint: 'Third hint', order: 2 }, - { hint: '', order: 3 }, - ]); - expect(emitted().open).toHaveLength(1); - expect(emitted().open[0][0]).toBe(2); - }); - - it('opens a different hint when the user clicks that hint card', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 0, - }); - await openHintsSection(user); - - const hintCards = getHintCards(); - await user.click(hintCards[1]); - - expect(emitted().open).toHaveLength(1); - expect(emitted().open[0][0]).toBe(1); - }); - - it('moves a hint up and keeps the same hint open after moving', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 1, - }); - await openHintsSection(user); - - await clickToolbarAction({ - action: AssessmentItemToolbarActions.MOVE_ITEM_UP, - hintIdx: 1, - user, - }); - - expect(emitted().update).toHaveLength(1); - expect(emitted().update[0][0]).toEqual([ - { hint: 'Second hint', order: 1 }, - { hint: 'First hint', order: 2 }, - ]); - expect(emitted().open).toHaveLength(1); - expect(emitted().open[0][0]).toBe(0); - }); - - it('keeps track of the open hint when the user moves the hint below it upward', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 0, - }); - await openHintsSection(user); - - await clickToolbarAction({ - action: AssessmentItemToolbarActions.MOVE_ITEM_UP, - hintIdx: 1, - user, - }); - - expect(emitted().open).toHaveLength(1); - expect(emitted().open[0][0]).toBe(1); - }); - - it('moves a hint down and keeps the same hint open after moving', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 0, - }); - await openHintsSection(user); - - await clickToolbarAction({ - action: AssessmentItemToolbarActions.MOVE_ITEM_DOWN, - hintIdx: 0, - user, - }); - - expect(emitted().update).toHaveLength(1); - expect(emitted().update[0][0]).toEqual([ - { hint: 'Second hint', order: 1 }, - { hint: 'First hint', order: 2 }, - ]); - expect(emitted().open).toHaveLength(1); - expect(emitted().open[0][0]).toBe(1); - }); - - it('keeps track of the open hint when the user moves the hint above it downward', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 1, - }); - await openHintsSection(user); - - await clickToolbarAction({ - action: AssessmentItemToolbarActions.MOVE_ITEM_DOWN, - hintIdx: 0, - user, - }); - - expect(emitted().open).toHaveLength(1); - expect(emitted().open[0][0]).toBe(0); - }); - - it('deletes a hint and closes the editor when that hint was open', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 0, - }); - await openHintsSection(user); - - await clickToolbarAction({ - action: AssessmentItemToolbarActions.DELETE_ITEM, - hintIdx: 0, - user, - }); - - expect(emitted().update).toHaveLength(1); - expect(emitted().update[0][0]).toEqual([{ hint: 'Second hint', order: 1 }]); - expect(emitted().close).toHaveLength(1); - }); - - it('keeps track of the open hint when the user deletes a hint above it', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ - hints: [ - { hint: 'First hint', order: 1 }, - { hint: 'Second hint', order: 2 }, - ], - openHintIdx: 1, - }); - await openHintsSection(user); - - await clickToolbarAction({ - action: AssessmentItemToolbarActions.DELETE_ITEM, - hintIdx: 0, - user, - }); - - expect(emitted().open).toHaveLength(1); - expect(emitted().open[0][0]).toBe(0); - }); - - it('toggles the hints section open and closed when clicking the header button', async () => { - const user = userEvent.setup(); - renderComponent({ - hints: [{ hint: 'First hint', order: 1 }], - }); - - // The header button acts as an accordion trigger with correct initial attributes - const headerButton = screen.getByRole('button', { name: HintsEditor.$trs.hintsLabel }); - expect(headerButton).toHaveAttribute('aria-expanded', 'false'); - expect(screen.queryByTestId('hint')).not.toBeInTheDocument(); - - // Click to open the section - await user.click(headerButton); - expect(headerButton).toHaveAttribute('aria-expanded', 'true'); - expect(screen.getByTestId('hint')).toBeInTheDocument(); - - // Click to close the section - await user.click(headerButton); - expect(headerButton).toHaveAttribute('aria-expanded', 'false'); - expect(screen.queryByTestId('hint')).not.toBeInTheDocument(); - }); -}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.vue deleted file mode 100644 index 2acdb20eba..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.vue +++ /dev/null @@ -1,534 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/constants.js b/contentcuration/contentcuration/frontend/channelEdit/constants.js index 8932058ecf..cb34622eaf 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/constants.js +++ b/contentcuration/contentcuration/frontend/channelEdit/constants.js @@ -1,5 +1,3 @@ -import { AssessmentItemTypes } from 'shared/constants'; - export const RouteNames = { TREE_ROOT_VIEW: 'TREE_ROOT_VIEW', TREE_VIEW: 'TREE_VIEW', @@ -32,24 +30,6 @@ export const ChannelEditPageErrors = Object.freeze({ CHANNEL_DELETED: 'CHANNEL_EDIT_ERROR_CHANNEL_DELETED', }); -export const AssessmentItemToolbarActions = { - EDIT_ITEM: 'EDIT_ITEM', - MOVE_ITEM_UP: 'MOVE_ITEM_UP', - MOVE_ITEM_DOWN: 'MOVE_ITEM_DOWN', - DELETE_ITEM: 'DELETE_ITEM', - ADD_ITEM_ABOVE: 'ADD_ITEM_ABOVE', - ADD_ITEM_BELOW: 'ADD_ITEM_BELOW', -}; - -export const AssessmentItemTypeLabels = { - [AssessmentItemTypes.SINGLE_SELECTION]: 'questionTypeSingleSelection', - [AssessmentItemTypes.MULTIPLE_SELECTION]: 'questionTypeMultipleSelection', - [AssessmentItemTypes.TRUE_FALSE]: 'questionTypeTrueFalse', - [AssessmentItemTypes.INPUT_QUESTION]: 'questionTypeInput', - [AssessmentItemTypes.PERSEUS_QUESTION]: 'questionTypePerseus', - [AssessmentItemTypes.FREE_RESPONSE]: 'questionTypeFreeResponse', -}; - export const TabNames = { DETAILS: 'details', PREVIEW: 'preview', diff --git a/contentcuration/contentcuration/frontend/channelEdit/translator.js b/contentcuration/contentcuration/frontend/channelEdit/translator.js index 17d9c684af..6493330e64 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/translator.js +++ b/contentcuration/contentcuration/frontend/channelEdit/translator.js @@ -3,19 +3,6 @@ import { createTranslator } from 'shared/i18n'; const NAMESPACE = 'channelEditVue'; const MESSAGES = { - true: 'True', - false: 'False', - questionTypeSingleSelection: 'Single choice', - questionTypeMultipleSelection: 'Multiple choice', - questionTypeTrueFalse: 'True/False', - questionTypeInput: 'Numeric input', - questionTypePerseus: 'Perseus', - questionTypeFreeResponse: 'Free response', - errorQuestionRequired: 'Question is required', - errorInvalidQuestionType: 'Invalid question type', - errorMissingAnswer: 'Choose a correct answer', - errorChooseAtLeastOneCorrectAnswer: 'Choose at least one correct answer', - errorProvideAtLeastOneCorrectAnswer: 'Provide at least one correct answer', selectionCount: '{topicCount, plural, =0 {} one {# folder, } other {# folders, }}{resourceCount, plural, one {# resource} other {# resources}}', }; diff --git a/contentcuration/contentcuration/frontend/channelEdit/utils.js b/contentcuration/contentcuration/frontend/channelEdit/utils.js index 0d734e558f..9c4f5d081a 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -1,4 +1,3 @@ -import translator from './translator'; import { RouteNames } from './constants'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; @@ -6,139 +5,12 @@ import { metadataStrings } from 'shared/strings/metadataStrings'; import { constantStrings } from 'shared/mixins'; import { ContentModalities, - AssessmentItemTypes, CompletionCriteriaModels, SHORT_LONG_ACTIVITY_MIDPOINT, defaultCompletionCriteriaModels, defaultCompletionCriteriaThresholds, } from 'shared/constants'; -/** - * Get correct answer index/indices out of an array of answer objects. - * @param {String} questionType single/multiple selection, true/false, input question - * @param {Array} answers An array of answer objects { answer: ..., correct: ..., ...} - * @returns {Number|null|Array} Returns a correct answer index or null for single selection - * or true/false question. Returns an array of correct answers indices for multiple selection - * or input question. - */ -export function getCorrectAnswersIndices(questionType, answers) { - if (!questionType || !answers || !answers.length) { - return null; - } - - if ( - questionType === AssessmentItemTypes.SINGLE_SELECTION || - questionType === AssessmentItemTypes.TRUE_FALSE - ) { - const idx = answers.findIndex(answer => answer.correct); - return idx === -1 ? null : idx; - } - - return answers - .map((answer, idx) => { - return answer.correct ? idx : undefined; - }) - .filter(idx => idx !== undefined); -} - -/** - * Updates `correct` fields of answers based on index/indexes stored in `correctAnswersIndices`. - * @param {Array} answers An array of answer objects { answer: ..., correct: ..., ...} - * @param {Number|null|Array} correctAnswersIndices A correct answer index or an array - * of correct answers indexes. - * @returns {Array} An array of answer objects with updated `correct` fields. - */ -export function mapCorrectAnswers(answers, correctAnswersIndices) { - if (!answers || !answers.length) { - return null; - } - - return answers.map((answer, idx) => { - const isAnswerCorrect = - correctAnswersIndices === idx || - (Array.isArray(correctAnswersIndices) && correctAnswersIndices.includes(idx)); - - return { - ...answer, - correct: isAnswerCorrect, - }; - }); -} - -// RegEx to test for signed floats or ints. Also allows the letter e -// to comply with what Chrome permits in their type="number" fields -export const floatOrIntRegex = /^(?=.)([+-]?([0-9e]*)(\.([0-9e]+))?)$/; - -/** - * Update answers to correspond to a question type: - * - multiple selection: No answers updates needed. - * - input question: Make all answers correct and remove non-numerics altogether - * - true/false: Remove answers in favour of new true/false values. - * - single selection: Keep first correct choice only if there is any. - * Otherwise mark first choice as correct. - * @param {String} newQuestionType single/multiple selection, true/false, input question - * @param {Array} answers An array of answer objects. - * @returns {Array} An array of updated answer objects. - */ -export function updateAnswersToQuestionType(questionType, answers) { - const NEW_TRUE_FALSE_ANSWERS = [ - { answer: translator.$tr('true'), correct: true, order: 1 }, - { answer: translator.$tr('false'), correct: false, order: 2 }, - ]; - - if (!answers || !answers.length) { - if (questionType === AssessmentItemTypes.TRUE_FALSE) { - return NEW_TRUE_FALSE_ANSWERS; - } else { - return []; - } - } - - if (questionType === AssessmentItemTypes.FREE_RESPONSE) { - return []; - } - - const answersCopy = JSON.parse(JSON.stringify(answers)); - - switch (questionType) { - case AssessmentItemTypes.MULTIPLE_SELECTION: - return answersCopy; - - case AssessmentItemTypes.INPUT_QUESTION: - return answersCopy.reduce((obj, answer) => { - // If there is anything other than a number in the answer - // we'll just skip it - removing non-numeric answers - if (floatOrIntRegex.test(answer.answer) === false) { - return obj; - } - - // Otherwise, set the answer to correct and push it to our obj - answer.correct = true; - obj.push(answer); - return obj; - }, []); - - case AssessmentItemTypes.TRUE_FALSE: - return NEW_TRUE_FALSE_ANSWERS; - - case AssessmentItemTypes.SINGLE_SELECTION: { - let firstCorrectAnswerIdx = answers.findIndex(answer => answer.correct === true); - if (firstCorrectAnswerIdx === -1) { - firstCorrectAnswerIdx = 0; - } - - const newAnswers = answersCopy.map(answer => { - answer.correct = false; - return answer; - }); - - newAnswers[firstCorrectAnswerIdx].correct = true; - - return newAnswers; - } - } -} - export function isImportedContent(node) { return Boolean( node && node.original_source_node_id && node.node_id !== node.original_source_node_id, @@ -162,14 +34,6 @@ export function importedChannelLink(node, router) { } } -// AssessmentItems are referenced by `[contentnode, assessment_id]` -export function assessmentItemKey(assessmentItem) { - return { - contentnode: assessmentItem.contentnode, - assessment_id: assessmentItem.assessment_id, - }; -} - /** * Converts a value in seconds to a human-readable format. * If the value is greater than or equal to one hour, the format will be hh:mm:ss. diff --git a/contentcuration/contentcuration/frontend/shared/utils/helpers.js b/contentcuration/contentcuration/frontend/shared/utils/helpers.js index 2545b91c85..8e1c4c0053 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/helpers.js +++ b/contentcuration/contentcuration/frontend/shared/utils/helpers.js @@ -19,49 +19,6 @@ function safeParseInt(str) { const EXTENDED_SLOT = '__extendedSlot'; -/** - * Insert an item into an array before another item. - * @param {Array} arr - * @param {Number} idx An index of an item before which - * a new item will be inserted. - * @param {*} item A new item to be inserted into an array. - */ -export function insertBefore(arr, idx, item) { - const newArr = JSON.parse(JSON.stringify(arr)); - const insertAt = Math.max(0, idx); - newArr.splice(insertAt, 0, item); - - return newArr; -} - -/** - * Insert an item into an array after another item. - * @param {Array} arr - * @param {Number} idx An index of an item after which - * a new item will be inserted. - * @param {*} item A new item to be inserted into an array. - */ -export function insertAfter(arr, idx, item) { - const newArr = JSON.parse(JSON.stringify(arr)); - const insertAt = Math.min(arr.length, idx + 1); - newArr.splice(insertAt, 0, item); - - return newArr; -} - -/** - * Swap two elements of an array - * @param {Array} arr - * @param {Number} idx1 - * @param {Number} idx2 - */ -export function swapElements(arr, idx1, idx2) { - const newArr = JSON.parse(JSON.stringify(arr)); - [newArr[idx1], newArr[idx2]] = [newArr[idx2], newArr[idx1]]; - - return newArr; -} - /** * Chunks an array of `things`, calling `callback` with `chunkSize` amount of items, * expecting callback to return `Promise` that when resolved will allow next chunk to be processed. diff --git a/contentcuration/contentcuration/frontend/shared/utils/helpers.spec.js b/contentcuration/contentcuration/frontend/shared/utils/helpers.spec.js index 5d01b4026a..d16a1399e7 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/helpers.spec.js +++ b/contentcuration/contentcuration/frontend/shared/utils/helpers.spec.js @@ -2,40 +2,7 @@ import Vue from 'vue'; import { mount } from '@vue/test-utils'; -import each from 'jest-each'; - -import { insertBefore, insertAfter, swapElements, extendSlot } from './helpers'; - -describe('insertBefore', () => { - each([ - [[], 0, 'pink', ['pink']], - [['blue', 'yellow', 'violet'], -1, 'pink', ['pink', 'blue', 'yellow', 'violet']], - [['blue', 'yellow', 'violet'], 0, 'pink', ['pink', 'blue', 'yellow', 'violet']], - [['blue', 'yellow', 'violet'], 1, 'pink', ['blue', 'pink', 'yellow', 'violet']], - ]).it('inserts a new item before another item', (arr, idx, item, expected) => { - expect(insertBefore(arr, idx, item)).toEqual(expected); - }); -}); - -describe('insertAfter', () => { - each([ - [[], 2, 'pink', ['pink']], - [['blue', 'yellow', 'violet'], 3, 'pink', ['blue', 'yellow', 'violet', 'pink']], - [['blue', 'yellow', 'violet'], 2, 'pink', ['blue', 'yellow', 'violet', 'pink']], - [['blue', 'yellow', 'violet'], 1, 'pink', ['blue', 'yellow', 'pink', 'violet']], - ]).it('inserts a new item after another item', (arr, idx, item, expected) => { - expect(insertAfter(arr, idx, item)).toEqual(expected); - }); -}); - -describe('swapElements', () => { - each([ - [['blue', 'yellow', 'violet'], 0, 0, ['blue', 'yellow', 'violet']], - [['blue', 'yellow', 'violet'], 0, 2, ['violet', 'yellow', 'blue']], - ]).it('swaps two elements', (arr, idx1, idx2, expected) => { - expect(swapElements(arr, idx1, idx2)).toEqual(expected); - }); -}); +import { extendSlot } from './helpers'; describe('extendSlot', () => { // Component that implements extendSlot functionality From 04f17b7c2e6ce8634a9320ad91d198d67d4b5920 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:26:04 -0500 Subject: [PATCH 18/24] refactor: stop reshaping legacy answers and hints in the store The mutation parsed and sorted the answers and hints the API used to send as JSON strings. Nothing reads them now that questions are QTI, and leaving the parsed arrays on the stored item invites them back into an update payload, which the API rejects for a QTI item. The mutation just merges what it is given. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/mutations.spec.js | 238 ++++-------------- .../vuex/assessmentItem/mutations.js | 24 -- 2 files changed, 45 insertions(+), 217 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/mutations.spec.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/mutations.spec.js index 07c5ab28cb..77786a6082 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/mutations.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/mutations.spec.js @@ -1,6 +1,14 @@ import { UPDATE_ASSESSMENTITEM, DELETE_ASSESSMENTITEM } from '../mutations'; import { AssessmentItemTypes } from 'shared/constants'; +const item = (assessment_id, contentnode, extra = {}) => ({ + assessment_id, + contentnode, + type: AssessmentItemTypes.QTI, + raw_data: `${assessment_id}`, + ...extra, +}); + describe('assessmentItem mutations', () => { let state; @@ -8,223 +16,67 @@ describe('assessmentItem mutations', () => { state = { assessmentItemsMap: { 'content-node-id-1': { - 'assessment-id-1': { - assessment_id: 'assessment-id-1', - contentnode: 'content-node-id-1', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '1+1=?', - answers: [ - { - answer: '2', - correct: false, - order: 1, - }, - { - answer: '11', - correct: true, - order: 2, - }, - ], - hints: [], - }, + 'assessment-id-1': item('assessment-id-1', 'content-node-id-1'), }, 'content-node-id-2': { - 'assessment-id-2': { - assessment_id: 'assessment-id-2', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '', - answers: [], - hints: [], - }, - 'assessment-id-3': { - assessment_id: 'assessment-id-3', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'What color are minions?', - answers: [ - { - answer: 'Blue', - correct: false, - order: 1, - }, - { - answer: 'Yellow', - correct: true, - order: 2, - }, - ], - hints: [], - }, + 'assessment-id-2': item('assessment-id-2', 'content-node-id-2'), }, }, }; }); describe('UPDATE_ASSESSMENTITEM', () => { - it('adds a new assessment item, parses and sorts answers and hints', () => { - UPDATE_ASSESSMENTITEM(state, { - assessment_id: 'assessment-id-4', - contentnode: 'content-node-id-1', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'Question', - answers: JSON.stringify([ - { - answer: 'Answer 2', - correct: false, - order: 2, - }, - { - answer: 'Answer 1', - correct: true, - order: 1, - }, - ]), - hints: JSON.stringify([ - { - answer: 'Hint 2', - order: 2, - }, - { - answer: 'Hint 1', - order: 1, - }, - ]), + it('throws if the item cannot be identified', () => { + expect(() => UPDATE_ASSESSMENTITEM(state, { contentnode: 'content-node-id-1' })).toThrow( + ReferenceError, + ); + expect(() => UPDATE_ASSESSMENTITEM(state, { assessment_id: 'assessment-id-9' })).toThrow( + ReferenceError, + ); + }); + + it('adds an assessment item to a content node that has some already', () => { + const newItem = item('assessment-id-3', 'content-node-id-1'); + + UPDATE_ASSESSMENTITEM(state, newItem); + + expect(state.assessmentItemsMap['content-node-id-1']).toEqual({ + 'assessment-id-1': item('assessment-id-1', 'content-node-id-1'), + 'assessment-id-3': newItem, }); + }); - expect(state.assessmentItemsMap['content-node-id-1']['assessment-id-4']).toEqual({ - assessment_id: 'assessment-id-4', - contentnode: 'content-node-id-1', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'Question', - answers: [ - { - answer: 'Answer 1', - correct: true, - order: 1, - }, - { - answer: 'Answer 2', - correct: false, - order: 2, - }, - ], - hints: [ - { - answer: 'Hint 1', - order: 1, - }, - { - answer: 'Hint 2', - order: 2, - }, - ], + it('adds an assessment item to a content node with none yet', () => { + const newItem = item('assessment-id-4', 'content-node-id-3'); + + UPDATE_ASSESSMENTITEM(state, newItem); + + expect(state.assessmentItemsMap['content-node-id-3']).toEqual({ + 'assessment-id-4': newItem, }); }); - it('updates an assessment item, parses and sorts answers and hints', () => { + it('merges the given fields into an existing assessment item', () => { UPDATE_ASSESSMENTITEM(state, { - assessment_id: 'assessment-id-3', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'What color are minions?', - answers: JSON.stringify([ - { - answer: 'Blue', - correct: false, - order: 3, - }, - { - answer: 'Yellow', - correct: true, - order: 1, - }, - { - answer: 'Red', - correct: false, - order: 2, - }, - ]), - hints: JSON.stringify([ - { - answer: 'Not red', - order: 2, - }, - { - answer: 'Not blue', - order: 1, - }, - ]), + assessment_id: 'assessment-id-1', + contentnode: 'content-node-id-1', + raw_data: 'edited', }); - expect(state.assessmentItemsMap['content-node-id-2']['assessment-id-3']).toEqual({ - assessment_id: 'assessment-id-3', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'What color are minions?', - answers: [ - { - answer: 'Yellow', - correct: true, - order: 1, - }, - { - answer: 'Red', - correct: false, - order: 2, - }, - { - answer: 'Blue', - correct: false, - order: 3, - }, - ], - hints: [ - { - answer: 'Not blue', - order: 1, - }, - { - answer: 'Not red', - order: 2, - }, - ], - }); + expect(state.assessmentItemsMap['content-node-id-1']['assessment-id-1']).toEqual( + item('assessment-id-1', 'content-node-id-1', { raw_data: 'edited' }), + ); }); }); describe('DELETE_ASSESSMENTITEM', () => { it('removes an assessment item', () => { DELETE_ASSESSMENTITEM(state, { - assessment_id: 'assessment-id-3', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'What color are minions?', - answers: [ - { - answer: 'Blue', - correct: false, - order: 1, - }, - { - answer: 'Yellow', - correct: true, - order: 2, - }, - ], - hints: [], + assessment_id: 'assessment-id-1', + contentnode: 'content-node-id-1', }); - expect(state.assessmentItemsMap['content-node-id-2']).toEqual({ - 'assessment-id-2': { - assessment_id: 'assessment-id-2', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '', - answers: [], - hints: [], - }, - }); + expect(state.assessmentItemsMap['content-node-id-1']).toEqual({}); }); }); }); diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/mutations.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/mutations.js index 3962c00a3b..10a325b39f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/mutations.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/mutations.js @@ -10,30 +10,6 @@ export function UPDATE_ASSESSMENTITEM(state, assessmentItem) { throw ReferenceError('contentnode must be defined to update an assessment item'); } - // data can come from API that returns answers and hints as string - let answers, hints; - if (typeof assessmentItem.answers === 'string') { - answers = JSON.parse(assessmentItem.answers); - } else { - answers = assessmentItem.answers ? assessmentItem.answers : null; - } - - if (answers) { - answers.sort((answer1, answer2) => (answer1.order > answer2.order ? 1 : -1)); - assessmentItem.answers = answers; - } - - if (typeof assessmentItem.hints === 'string') { - hints = JSON.parse(assessmentItem.hints); - } else { - hints = assessmentItem.hints ? assessmentItem.hints : null; - } - - if (hints) { - hints.sort((hint1, hint2) => (hint1.order > hint2.order ? 1 : -1)); - assessmentItem.hints = hints; - } - set( state.assessmentItemsMap, assessmentItem.contentnode, From f5ad0fc2eeeb6d670ca57d2d8b2a1dc5b8565381 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:26:09 -0500 Subject: [PATCH 19/24] refactor: validate assessment items from their QTI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Questions no longer arrive with question, answers and hints — the API serves every item as QTI — so validating those fields judged every question by empty data. getAssessmentItemErrors now asks the QTI editor's validator about raw_data, and the sanitize helpers that only existed to tidy legacy answers before validating them are gone, along with the legacy question types and error codes nothing can produce any more. Studio keeps its own rule on top: a free-response question only counts as valid on a survey, which the getter derives from the node's modality and passes down. isNodeComplete keeps its previous, laxer treatment of free response so node completeness does not silently change. Closing the edit modal still stops delaying validation for questions the author has started writing, but the check reads the prompt out of the QTI, and commits to the store rather than dispatching a save — the flag is a display concern that never reaches the server. Co-Authored-By: Claude Opus 5 (1M context) --- .../channelEdit/components/edit/EditModal.vue | 27 +- .../assessmentItem/__tests__/getters.spec.js | 199 ++++----- .../vuex/assessmentItem/getters.js | 15 +- .../frontend/shared/constants.js | 10 +- .../frontend/shared/utils/validation.js | 175 ++------ .../frontend/shared/utils/validation.spec.js | 391 +++--------------- 6 files changed, 199 insertions(+), 618 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 759f27e821..06257dc3ce 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -214,7 +214,8 @@ import ToolBar from 'shared/views/ToolBar'; import BottomBar from 'shared/views/BottomBar'; import FileDropzone from 'shared/views/files/FileDropzone'; - import { isNodeComplete } from 'shared/utils/validation'; + import { getAssessmentItemErrors, isNodeComplete } from 'shared/utils/validation'; + import { ValidationError } from 'shared/views/QTIEditor/constants'; import { DELAYED_VALIDATION } from 'shared/constants'; const CHECK_STORAGE_INTERVAL = 10000; @@ -445,9 +446,10 @@ 'createContentNode', ]), ...mapActions('file', ['loadFiles', 'updateFile']), - ...mapActions('assessmentItem', ['loadAssessmentItems', 'updateAssessmentItems']), + ...mapActions('assessmentItem', ['loadAssessmentItems']), /* eslint-enable vue/no-unused-properties */ ...mapMutations('contentNode', { enableValidation: 'ENABLE_VALIDATION_ON_NODES' }), + ...mapMutations('assessmentItem', { stopDelayingValidation: 'UPDATE_ASSESSMENTITEM' }), closeModal(changed = false) { if (!this.uploadMode) { const eventAction = changed ? 'Save' : 'Close'; @@ -488,11 +490,22 @@ this.selected = this.nodeIds; this.$nextTick(() => { this.enableValidation(this.nodeIds); - const assessmentItems = this.getAssessmentItems(this.nodeIds); - assessmentItems.forEach(item => - item.question ? (item[DELAYED_VALIDATION] = false) : '', - ); - this.updateAssessmentItems(assessmentItems); + // Questions the author has actually started writing begin reporting as + // incomplete once the modal closes; ones still left blank stay quiet. + this.getAssessmentItems(this.nodeIds) + .filter( + item => + !getAssessmentItemErrors(item).some( + error => error.code === ValidationError.PROMPT_REQUIRED, + ), + ) + .forEach(item => + this.stopDelayingValidation({ + contentnode: item.contentnode, + assessment_id: item.assessment_id, + [DELAYED_VALIDATION]: false, + }), + ); // reaches into Details Tab to run save of diffTracker // before the validation pop up is executed diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/getters.spec.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/getters.spec.js index 3155fdbc00..9aacb2df6c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/getters.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/getters.spec.js @@ -5,7 +5,22 @@ import { getInvalidAssessmentItemsCount, getAssessmentItemsAreValid, } from '../getters'; -import { AssessmentItemTypes, DELAYED_VALIDATION, ValidationErrors } from 'shared/constants'; +import { AssessmentItemTypes, ContentModalities, DELAYED_VALIDATION } from 'shared/constants'; +import { ValidationError } from 'shared/views/QTIEditor/constants'; +import { + VALID_CHOICE_ITEM_DOCUMENT, + CHOICE_ITEM_DOCUMENT_NO_PROMPT, + CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, + FREE_RESPONSE_ITEM_DOCUMENT, +} from 'shared/views/QTIEditor/utils/testingFixtures'; + +const item = (assessment_id, contentnode, raw_data, extra = {}) => ({ + assessment_id, + contentnode, + type: AssessmentItemTypes.QTI, + raw_data, + ...extra, +}); describe('assessmentItem getters', () => { let state; @@ -15,72 +30,46 @@ describe('assessmentItem getters', () => { state = { assessmentItemsMap: { 'content-node-id-1': { - 'assessment-id-1': { - assessment_id: 'assessment-id-1', - contentnode: 'content-node-id-1', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '1+1=?', - answers: [ - { - answer: '2', - correct: false, - order: 1, - }, - { - answer: '11', - correct: true, - order: 2, - }, - ], - }, + 'assessment-id-1': item( + 'assessment-id-1', + 'content-node-id-1', + VALID_CHOICE_ITEM_DOCUMENT, + ), }, 'content-node-id-2': { - 'assessment-id-2': { - assessment_id: 'assessment-id-2', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '', - answers: [], - order: 1, - }, - 'assessment-id-3': { - assessment_id: 'assessment-id-3', - contentnode: 'content-node-id-2', - [DELAYED_VALIDATION]: true, - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'What color are minions?', - answers: [ - { - answer: 'Blue', - correct: false, - order: 1, - }, - { - answer: 'Yellow', - correct: false, - order: 2, - }, - ], - order: 2, - }, + 'assessment-id-2': item( + 'assessment-id-2', + 'content-node-id-2', + CHOICE_ITEM_DOCUMENT_NO_PROMPT, + { order: 1 }, + ), + 'assessment-id-3': item( + 'assessment-id-3', + 'content-node-id-2', + CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, + { order: 2, [DELAYED_VALIDATION]: true }, + ), }, 'content-node-id-3': { - 'assessment-id-4': { - assessment_id: 'assessment-id-4', - contentnode: 'content-node-id-3', - [DELAYED_VALIDATION]: true, - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '', - answers: [], - }, - 'assessment-id-5': { - assessment_id: 'assessment-id-5', - contentnode: 'content-node-id-3', - [DELAYED_VALIDATION]: true, - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '', - answers: [], - }, + 'assessment-id-4': item( + 'assessment-id-4', + 'content-node-id-3', + CHOICE_ITEM_DOCUMENT_NO_PROMPT, + { [DELAYED_VALIDATION]: true }, + ), + 'assessment-id-5': item( + 'assessment-id-5', + 'content-node-id-3', + CHOICE_ITEM_DOCUMENT_NO_PROMPT, + { [DELAYED_VALIDATION]: true }, + ), + }, + 'content-node-id-survey': { + 'assessment-id-6': item( + 'assessment-id-6', + 'content-node-id-survey', + FREE_RESPONSE_ITEM_DOCUMENT, + ), }, }, }; @@ -89,45 +78,26 @@ describe('assessmentItem getters', () => { 'contentNode/getContentNode': id => ({ id, kind: 'exercise', + extra_fields: + id === 'content-node-id-survey' + ? { options: { modality: ContentModalities.SURVEY } } + : {}, }), }; }); + const errorsFor = (contentNodeId, options = {}) => + getAssessmentItemsErrors(state, {}, {}, rootGetters)({ contentNodeId, ...options }); + describe('getAssessmentItems', () => { it('returns an empty array if a content node not found', () => { expect(getAssessmentItems(state)('content-node-id-4')).toEqual([]); }); it('returns an array of assessment items belonging to a content node', () => { - expect(getAssessmentItems(state)('content-node-id-2')).toEqual([ - { - assessment_id: 'assessment-id-2', - contentnode: 'content-node-id-2', - type: AssessmentItemTypes.SINGLE_SELECTION, - question: '', - answers: [], - order: 1, - }, - { - assessment_id: 'assessment-id-3', - contentnode: 'content-node-id-2', - [DELAYED_VALIDATION]: true, - type: AssessmentItemTypes.SINGLE_SELECTION, - question: 'What color are minions?', - answers: [ - { - answer: 'Blue', - correct: false, - order: 1, - }, - { - answer: 'Yellow', - correct: false, - order: 2, - }, - ], - order: 2, - }, + expect(getAssessmentItems(state)('content-node-id-2').map(i => i.assessment_id)).toEqual([ + 'assessment-id-2', + 'assessment-id-3', ]); }); }); @@ -144,38 +114,31 @@ describe('assessmentItem getters', () => { describe('getAssessmentItemsErrors', () => { it('returns validation codes corresponding to invalid assessment items of a content node', () => { - expect( - getAssessmentItemsErrors( - state, - {}, - {}, - rootGetters, - )({ contentNodeId: 'content-node-id-2' }), - ).toEqual({ - 'assessment-id-2': [ - ValidationErrors.QUESTION_REQUIRED, - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ], - 'assessment-id-3': [ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS], + expect(errorsFor('content-node-id-2')).toEqual({ + 'assessment-id-2': [{ code: ValidationError.PROMPT_REQUIRED }], + 'assessment-id-3': [{ code: ValidationError.NO_CORRECT_ANSWER }], }); }); it("doesn't include invalid nodes errors that are new if `ignoreDelayed` set to true", () => { - expect( - getAssessmentItemsErrors( - state, - {}, - {}, - rootGetters, - )({ contentNodeId: 'content-node-id-2', ignoreDelayed: true }), - ).toEqual({ - 'assessment-id-2': [ - ValidationErrors.QUESTION_REQUIRED, - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ], + expect(errorsFor('content-node-id-2', { ignoreDelayed: true })).toEqual({ + 'assessment-id-2': [{ code: ValidationError.PROMPT_REQUIRED }], 'assessment-id-3': [], }); }); + + it('rejects a free-response question on a node that is not a survey', () => { + state.assessmentItemsMap['content-node-id-1']['assessment-id-1'].raw_data = + FREE_RESPONSE_ITEM_DOCUMENT; + + expect(errorsFor('content-node-id-1')['assessment-id-1']).toContainEqual({ + code: ValidationError.FREE_RESPONSE_NOT_ALLOWED, + }); + }); + + it('accepts a free-response question on a survey', () => { + expect(errorsFor('content-node-id-survey')).toEqual({ 'assessment-id-6': [] }); + }); }); describe('getInvalidAssessmentItemsCount', () => { @@ -236,7 +199,7 @@ describe('assessmentItem getters', () => { {}, rootGetters, )({ - contentNodeId: 'content-node-id-4', + contentNodeId: 'content-node-id-3', ignoreDelayed: true, }), ).toBe(true); diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/getters.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/getters.js index 5c454113f7..3e336ff85c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/getters.js @@ -1,4 +1,4 @@ -import { AssessmentItemTypes, ContentModalities, DELAYED_VALIDATION } from 'shared/constants'; +import { ContentModalities, DELAYED_VALIDATION } from 'shared/constants'; import { getAssessmentItemErrors } from 'shared/utils/validation'; /** * Get assessment items of a node. @@ -37,19 +37,18 @@ export function getAssessmentItemsErrors(state, getters, rootState, rootGetters) if (!state.assessmentItemsMap || !state.assessmentItemsMap[contentNodeId]) { return assessmentItemsErrors; } + // Free-response questions cannot be scored, so they only make sense on a survey. + const allowFreeResponse = modality === ContentModalities.SURVEY; + Object.keys(state.assessmentItemsMap[contentNodeId]).forEach(assessmentItemId => { const assessmentItem = state.assessmentItemsMap[contentNodeId][assessmentItemId]; - const freeResponseInvalid = - modality !== ContentModalities.SURVEY && - assessmentItem.type === AssessmentItemTypes.FREE_RESPONSE; if (ignoreDelayed && assessmentItem[DELAYED_VALIDATION]) { assessmentItemsErrors[assessmentItemId] = []; } else { - assessmentItemsErrors[assessmentItemId] = getAssessmentItemErrors( - assessmentItem, - freeResponseInvalid, - ); + assessmentItemsErrors[assessmentItemId] = getAssessmentItemErrors(assessmentItem, { + allowFreeResponse, + }); } }); return assessmentItemsErrors; diff --git a/contentcuration/contentcuration/frontend/shared/constants.js b/contentcuration/contentcuration/frontend/shared/constants.js index d08a6d803c..f697b6b974 100644 --- a/contentcuration/contentcuration/frontend/shared/constants.js +++ b/contentcuration/contentcuration/frontend/shared/constants.js @@ -153,12 +153,8 @@ export const ErrorTypes = Object.freeze({ // should correspond to backend types export const AssessmentItemTypes = { - SINGLE_SELECTION: 'single_selection', - MULTIPLE_SELECTION: 'multiple_selection', - TRUE_FALSE: 'true_false', - INPUT_QUESTION: 'input_question', + QTI: 'QTI', PERSEUS_QUESTION: 'perseus_question', - FREE_RESPONSE: 'free_response', }; export const ValidationErrors = { @@ -174,10 +170,6 @@ export const ValidationErrors = { MASTERY_MODEL_N_REQUIRED: 'MASTERY_MODEL_N_REQUIRED', MASTERY_MODEL_N_WHOLE_NUMBER: 'MASTERY_MODEL_N_WHOLE_NUMBER', MASTERY_MODEL_N_GT_ZERO: 'MASTERY_MODEL_N_GT_ZERO', - QUESTION_REQUIRED: 'QUESTION_REQUIRED', - INVALID_NUMBER_OF_CORRECT_ANSWERS: 'INVALID_NUMBER_OF_CORRECT_ANSWERS', - INVALID_COMPLETION_TYPE_FOR_FREE_RESPONSE_QUESTION: - 'INVALID_COMPLETION_TYPE_FOR_FREE_RESPONSE_QUESTION', NO_VALID_PRIMARY_FILES: 'NO_VALID_PRIMARY_FILES', INVALID_COMPLETION_CRITERIA_MODEL: 'INVALID_COMPLETION_CRITERIA_MODEL', COMPLETION_REQUIRED: 'COMPLETION_REQUIRED', diff --git a/contentcuration/contentcuration/frontend/shared/utils/validation.js b/contentcuration/contentcuration/frontend/shared/utils/validation.js index e576d678e5..5675841d52 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/validation.js +++ b/contentcuration/contentcuration/frontend/shared/utils/validation.js @@ -2,6 +2,7 @@ import get from 'lodash/get'; import CompletionCriteriaModels from 'kolibri-constants/CompletionCriteria'; import translator from '../translator'; import { AssessmentItemTypes, ValidationErrors, ContentModalities } from '../constants'; +import { validateQtiItem } from 'shared/views/QTIEditor/validateItem'; import Licenses from 'shared/leUtils/Licenses'; import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -90,10 +91,7 @@ export function isNodeComplete({ nodeDetails, assessmentItems, files }) { return false; } - const isInvalid = assessmentItem => { - const sanitizedAssessmentItem = sanitizeAssessmentItem(assessmentItem, true); - return getAssessmentItemErrors(sanitizedAssessmentItem).length; - }; + const isInvalid = assessmentItem => getAssessmentItemErrors(assessmentItem).length; if (assessmentItems.some(isInvalid)) { if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') { // eslint-disable-next-line no-console @@ -432,151 +430,50 @@ export function getNodeFilesErrors(files) { } /** - * Sanitize assesment item answers - * - trim answers - * - (optional) remove empty answers - * @param {Array} answers Assessment item answers - * @param {Boolean} removeEmpty Remove all empty answers? - * @returns {Array} Cleaned answers - */ -export function sanitizeAssessmentItemAnswers(answers, removeEmpty = false) { - if (!answers || !answers.length) { - return []; - } - - let sanitizedAnswers = answers.map(answer => { - let answerText = answer.answer; - if (typeof answerText !== 'number') { - answerText = answerText ? answerText.trim() : ''; - } - - return { - ...answer, - answer: answerText, - }; - }); - - if (removeEmpty) { - sanitizedAnswers = sanitizedAnswers.filter(answer => answer.answer.length > 0); - } - - sanitizedAnswers = sanitizedAnswers.map((answer, answerIdx) => { - return { - ...answer, - order: answerIdx + 1, - }; - }); - - return sanitizedAnswers; -} - -/** - * Sanitize assesment item hints - * - trim hints - * - (optional) remove empty hints - * @param {Array} hints Assessment item hints - * @param {Boolean} removeEmpty Remove all empty hints? - * @returns {Array} Cleaned hints - */ -export function sanitizeAssessmentItemHints(hints, removeEmpty = false) { - if (!hints || !hints.length) { - return []; - } - - let sanitizedHints = hints.map(hint => { - const hintText = hint.hint ? hint.hint.trim() : ''; - - return { - ...hint, - hint: hintText, - }; - }); - - if (removeEmpty) { - sanitizedHints = sanitizedHints.filter(hint => hint.hint.length > 0); - } - - sanitizedHints = sanitizedHints.map((hint, hintIdx) => { - return { - ...hint, - order: hintIdx + 1, - }; - }); - - return sanitizedHints; -} - -/** - * Sanitize an assesment item - * - trim question text - * - sanitize answers and hints - * @param {Array} assessmentItem An assessment item - * @param {Boolean} removeEmpty Remove empty answers and hints? - * @returns {Array} Cleaned assessment item + * The last verdict reached for an item, so that the several places asking whether a node is + * complete — the incomplete-questions banner, the tab icon, the node list, the preview — + * parse each question's XML once between edits rather than once each. + * + * Keyed by the item, so an entry is collected along with the item it describes and there is + * no cache to keep or invalidate by hand. The XML it was read from is kept alongside the + * verdict so that an item edited in place is not answered from a stale reading. + * + * @type {WeakMap} */ -export function sanitizeAssessmentItem(assessmentItem, removeEmpty = false) { - const question = assessmentItem.question ? assessmentItem.question.trim() : ''; - const answers = assessmentItem.answers - ? sanitizeAssessmentItemAnswers(assessmentItem.answers, removeEmpty) - : []; - const hints = assessmentItem.hints - ? sanitizeAssessmentItemHints(assessmentItem.hints, removeEmpty) - : []; - - return { - ...assessmentItem, - question, - answers, - hints, - }; -} +const errorsByAssessmentItem = new WeakMap(); /** * Validate an assessment item. + * + * Questions are authored and stored as QTI, so the QTI editor owns what makes one valid; + * this reads its verdict without rendering anything. Perseus questions come from other + * tools and are not validated here. + * * @param {Object} assessmentItem An assessment item. - * @returns {Array} An array of error codes. + * @param {Object} [options] + * @param {Boolean} [options.allowFreeResponse] Whether free-response questions are + * permitted — they are only meaningful on surveys. + * @returns {Array} An array of errors. */ -export function getAssessmentItemErrors(assessmentItem, freeResponseInvalid = false) { - const errors = []; - - // Don't validate perseus questions +export function getAssessmentItemErrors(assessmentItem, { allowFreeResponse = true } = {}) { if (assessmentItem.type === AssessmentItemTypes.PERSEUS_QUESTION) { - return errors; - } - // Convert answers to string to handle numeric responses - const hasOneCorrectAnswer = - assessmentItem.answers && - assessmentItem.answers.filter( - answer => answer.answer && String(answer.answer).trim() && answer.correct === true, - ).length === 1; - const hasAtLeatOneCorrectAnswer = - assessmentItem.answers && - assessmentItem.answers.filter( - answer => answer.answer && String(answer.answer).trim() && answer.correct === true, - ).length > 0; - - if (!assessmentItem.question || !assessmentItem.question.trim()) { - errors.push(ValidationErrors.QUESTION_REQUIRED); - } - if (freeResponseInvalid) { - errors.push(ValidationErrors.INVALID_COMPLETION_TYPE_FOR_FREE_RESPONSE_QUESTION); + return []; } - switch (assessmentItem.type) { - case AssessmentItemTypes.MULTIPLE_SELECTION: - case AssessmentItemTypes.INPUT_QUESTION: - if (!hasAtLeatOneCorrectAnswer) { - errors.push(ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS); - } - break; - - case AssessmentItemTypes.TRUE_FALSE: - case AssessmentItemTypes.SINGLE_SELECTION: - if (!hasOneCorrectAnswer) { - errors.push(ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS); - } - break; + const cached = errorsByAssessmentItem.get(assessmentItem); + if ( + cached && + cached.rawData === assessmentItem.raw_data && + cached.allowFreeResponse === allowFreeResponse + ) { + return cached.errors; } + const errors = validateQtiItem(assessmentItem.raw_data, { allowFreeResponse }); + errorsByAssessmentItem.set(assessmentItem, { + rawData: assessmentItem.raw_data, + allowFreeResponse, + errors, + }); return errors; } diff --git a/contentcuration/contentcuration/frontend/shared/utils/validation.spec.js b/contentcuration/contentcuration/frontend/shared/utils/validation.spec.js index 11e2fe367f..05b0dadf27 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/validation.spec.js +++ b/contentcuration/contentcuration/frontend/shared/utils/validation.spec.js @@ -14,12 +14,16 @@ import { isNodeComplete, getNodeDetailsErrors, getNodeFilesErrors, - sanitizeAssessmentItemAnswers, - sanitizeAssessmentItemHints, - sanitizeAssessmentItem, getAssessmentItemErrors, getNodeLearningActivityErrors, } from './validation'; +import { ValidationError } from 'shared/views/QTIEditor/constants'; +import { + VALID_CHOICE_ITEM_DOCUMENT, + CHOICE_ITEM_DOCUMENT_NO_PROMPT, + CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, + FREE_RESPONSE_ITEM_DOCUMENT, +} from 'shared/views/QTIEditor/utils/testingFixtures'; import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -403,12 +407,8 @@ describe('channelEdit utils', () => { }; assessmentItems = [ { - question: 'Question', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Mayonnaise (I mean you can, but...)', correct: true, order: 1 }, - { answer: 'Peanut butter', correct: false, order: 2 }, - ], + type: AssessmentItemTypes.QTI, + raw_data: VALID_CHOICE_ITEM_DOCUMENT, }, ]; }); @@ -436,17 +436,13 @@ describe('channelEdit utils', () => { it('returns false if there is at least one invalid assessment item', () => { const invalidAssessmentItem = { - question: 'A question with missing answers', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [], + type: AssessmentItemTypes.QTI, + raw_data: CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, }; expect( isNodeComplete({ nodeDetails, - assessmentItems: { - ...assessmentItems, - invalidAssessmentItem, - }, + assessmentItems: [...assessmentItems, invalidAssessmentItem], }), ).toBe(false); }); @@ -808,348 +804,69 @@ describe('channelEdit utils', () => { }); }); - describe('sanitizeAssessmentItemAnswers', () => { - it('trims answers', () => { - const answers = [ - { answer: '', order: 1, correct: true }, - { answer: ' 3 ', order: 2, correct: false }, - { answer: ' ', order: 3, correct: true }, - ]; - - expect(sanitizeAssessmentItemAnswers(answers)).toEqual([ - { answer: '', order: 1, correct: true }, - { answer: '3', order: 2, correct: false }, - { answer: '', order: 3, correct: true }, - ]); - }); - - it('removes all empty answers and reorders remaining answers if removeEmpty true', () => { - const answers = [ - { answer: '', order: 1, correct: true }, - { answer: ' 3 ', order: 2, correct: false }, - { answer: ' ', order: 3, correct: true }, - ]; - - expect(sanitizeAssessmentItemAnswers(answers, true)).toEqual([ - { answer: '3', order: 1, correct: false }, - ]); - }); - }); - - describe('sanitizeAssessmentItemHints', () => { - it('trims hints', () => { - const hints = [ - { hint: '', order: 1 }, - { hint: ' Hint 1 ', order: 2 }, - { hint: ' ', order: 3 }, - ]; - - expect(sanitizeAssessmentItemHints(hints)).toEqual([ - { hint: '', order: 1 }, - { hint: 'Hint 1', order: 2 }, - { hint: '', order: 3 }, - ]); - }); - - it('removes all empty hints and reorders remaining hints if removeEmpty true', () => { - const hints = [ - { hint: '', order: 1 }, - { hint: ' Hint 1 ', order: 2 }, - { hint: ' ', order: 3 }, - ]; - - expect(sanitizeAssessmentItemHints(hints, true)).toEqual([{ hint: 'Hint 1', order: 1 }]); - }); - }); - - describe('sanitizeAssessmentItem', () => { - it('trims question, hints and answers', () => { + describe('getAssessmentItemErrors', () => { + it('reports no errors for a complete question', () => { const assessmentItem = { - order: 1, - question: ' Question text ', - answers: [ - { answer: ' Answer 1', order: 1, correct: false }, - { answer: '', order: 2, correct: true }, - { answer: 'Answer 3 ', order: 3, correct: true }, - ], - hints: [ - { hint: ' ', order: 1 }, - { hint: '', order: 2 }, - { hint: ' Hint 3', order: 3 }, - ], + type: AssessmentItemTypes.QTI, + raw_data: VALID_CHOICE_ITEM_DOCUMENT, }; - expect(sanitizeAssessmentItem(assessmentItem)).toEqual({ - order: 1, - question: 'Question text', - answers: [ - { answer: 'Answer 1', order: 1, correct: false }, - { answer: '', order: 2, correct: true }, - { answer: 'Answer 3', order: 3, correct: true }, - ], - hints: [ - { hint: '', order: 1 }, - { hint: '', order: 2 }, - { hint: 'Hint 3', order: 3 }, - ], - }); + expect(getAssessmentItemErrors(assessmentItem)).toEqual([]); }); - it('removes all empty hints and answers if removeEmpty true', () => { + it('reports the errors of the question it holds', () => { const assessmentItem = { - order: 1, - question: ' Question text ', - answers: [ - { answer: ' Answer 1', order: 1, correct: false }, - { answer: '', order: 2, correct: true }, - { answer: 'Answer 3 ', order: 3, correct: true }, - ], - hints: [ - { hint: ' ', order: 1 }, - { hint: '', order: 2 }, - { hint: ' Hint 3', order: 3 }, - ], + type: AssessmentItemTypes.QTI, + raw_data: CHOICE_ITEM_DOCUMENT_NO_PROMPT, }; - expect(sanitizeAssessmentItem(assessmentItem, true)).toEqual({ - order: 1, - question: 'Question text', - answers: [ - { answer: 'Answer 1', order: 1, correct: false }, - { answer: 'Answer 3', order: 2, correct: true }, - ], - hints: [{ hint: 'Hint 3', order: 1 }], - }); - }); - }); - - describe('getAssessmentItemErrors', () => { - describe('when question text is missing', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: '', - answers: [{ answer: 'Answer', correct: true, order: 1 }], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.QUESTION_REQUIRED, - ]); - }); - }); - - describe('for single selection with no answers', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); - }); - - describe('for single selection with no correct answer', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [{ answer: 'Answer', correct: false, order: 1 }], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); - }); - - describe('for single selection with more correct answers', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Answer 1', correct: true, order: 1 }, - { answer: 'Answer 2', correct: true, order: 2 }, - ], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); + expect(getAssessmentItemErrors(assessmentItem).map(error => error.code)).toContain( + ValidationError.PROMPT_REQUIRED, + ); }); - describe('for single selection with one correct answer', () => { - it('returns positive validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.SINGLE_SELECTION, - answers: [ - { answer: 'Answer 1', correct: false, order: 1 }, - { answer: 'Answer 2', correct: true, order: 2 }, - ], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([]); - }); - }); - - describe('for multiple selection with no answers', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.MULTIPLE_SELECTION, - answers: [], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); - }); - - describe('for multiple selection with no correct answer', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.MULTIPLE_SELECTION, - answers: [ - { answer: 'Answer 1', correct: false, order: 1 }, - { answer: 'Answer 2', correct: false, order: 2 }, - ], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); - }); - - describe('for multiple selection with at least one correct answer', () => { - it('returns positive validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.MULTIPLE_SELECTION, - answers: [ - { answer: 'Answer 1', correct: true, order: 1 }, - { answer: 'Answer 2', correct: false, order: 2 }, - ], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([]); - }); - }); - - describe('for input question with no answers', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.INPUT_QUESTION, - answers: [], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); - }); - - describe('for input question with no correct answer', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.INPUT_QUESTION, - answers: [ - { answer: 'Answer 1', correct: false, order: 1 }, - { answer: 'Answer 2', correct: false, order: 2 }, - ], - }; + it('reports no errors for a Perseus question, which is authored elsewhere', () => { + const assessmentItem = { + type: AssessmentItemTypes.PERSEUS_QUESTION, + raw_data: 'not qti at all', + }; - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); + expect(getAssessmentItemErrors(assessmentItem)).toEqual([]); }); - describe('for input question with at least one correct answer', () => { - it('returns positive validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.INPUT_QUESTION, - answers: [ - { answer: 'Answer 1', correct: true, order: 1 }, - { answer: 'Answer 2', correct: true, order: 2 }, - ], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([]); - }); - }); - - describe('for true/false with no answers', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.TRUE_FALSE, - answers: [], - }; + it('reports the same errors when asked about the same question again', () => { + const assessmentItem = { + type: AssessmentItemTypes.QTI, + raw_data: CHOICE_ITEM_DOCUMENT_NO_PROMPT, + }; - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); + expect(getAssessmentItemErrors(assessmentItem)).toEqual( + getAssessmentItemErrors(assessmentItem), + ); }); - describe('for true/false with no correct answer', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.TRUE_FALSE, - answers: [ - { answer: 'True', correct: false, order: 1 }, - { answer: 'False', correct: false, order: 2 }, - ], - }; - - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); - }); + it('reports the errors of the question as it is now, not as it was', () => { + const assessmentItem = { + type: AssessmentItemTypes.QTI, + raw_data: CHOICE_ITEM_DOCUMENT_NO_PROMPT, + }; + getAssessmentItemErrors(assessmentItem); - describe('for true/false with more correct answers', () => { - it('returns negative validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.TRUE_FALSE, - answers: [ - { answer: 'True', correct: true, order: 1 }, - { answer: 'False', correct: true, order: 2 }, - ], - }; + assessmentItem.raw_data = VALID_CHOICE_ITEM_DOCUMENT; - expect(getAssessmentItemErrors(assessmentItem)).toEqual([ - ValidationErrors.INVALID_NUMBER_OF_CORRECT_ANSWERS, - ]); - }); + expect(getAssessmentItemErrors(assessmentItem)).toEqual([]); }); - describe('for true/false with one correct answer', () => { - it('returns positive validation results', () => { - const assessmentItem = { - question: 'Question', - type: AssessmentItemTypes.TRUE_FALSE, - answers: [ - { answer: 'True', correct: false, order: 1 }, - { answer: 'False', correct: true, order: 2 }, - ], - }; + it('reports a free-response question differently depending on whether it is allowed', () => { + const assessmentItem = { + type: AssessmentItemTypes.QTI, + raw_data: FREE_RESPONSE_ITEM_DOCUMENT, + }; - expect(getAssessmentItemErrors(assessmentItem)).toEqual([]); - }); + expect(getAssessmentItemErrors(assessmentItem, { allowFreeResponse: true })).toEqual([]); + expect( + getAssessmentItemErrors(assessmentItem, { allowFreeResponse: false }).map(e => e.code), + ).toContain(ValidationError.FREE_RESPONSE_NOT_ALLOWED); }); }); }); From 183307f6434f011c7c63780b80a779be3ac6a1bb Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:26:22 -0500 Subject: [PATCH 20/24] fix: accept QTI edits on questions the API converts on read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every legacy item is served to the client as QTI, but the serializer refused raw_data unless the row itself already said QTI — so editing any question authored before the QTI editor failed, and the client cannot say otherwise: its change records carry only fields that differ from its local copy, which already reads QTI. An existing row that receives raw_data is now converted, which is the same migration the global backfill (#6007) will apply to every item, done one item at a time as authors touch them. Creates keep the old guard, and invalid QTI is still refused, leaving the row untouched. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/viewsets/test_assessmentitem.py | 25 ++++++++++++++++++- .../viewsets/assessmentitem.py | 17 +++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py b/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py index 2b95b9833b..eff35513b8 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py +++ b/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py @@ -608,7 +608,9 @@ def test_create_non_qti_assessmentitem_rejects_raw_data_edit(self): assessment_id=assessmentitem["assessment_id"] ) - def test_update_non_qti_assessmentitem_rejects_raw_data_edit(self): + def test_update_legacy_assessmentitem_converts_it_to_qti(self): + # The read path serves legacy items as QTI (see consolidate), so an edit comes + # back as QTI raw_data — and the row is converted to match. assessmentitem = models.AssessmentItem.objects.create( **self.assessmentitem_db_metadata ) @@ -623,8 +625,29 @@ def test_update_non_qti_assessmentitem_rejects_raw_data_edit(self): ) ], ) + self.assertEqual(response.json()["errors"], []) + updated = models.AssessmentItem.objects.get(id=assessmentitem.id) + self.assertEqual(updated.type, exercises.QTI) + self.assertEqual(updated.raw_data, VALID_CHOICE_ITEM) + + def test_update_legacy_assessmentitem_rejects_invalid_qti(self): + assessmentitem = models.AssessmentItem.objects.create( + **self.assessmentitem_db_metadata + ) + self.client.force_authenticate(user=self.user) + response = self.sync_changes( + [ + generate_update_event( + [assessmentitem.contentnode_id, assessmentitem.assessment_id], + ASSESSMENTITEM, + {"raw_data": ""}, + channel_id=self.channel.id, + ) + ], + ) self.assertTrue(response.json()["errors"][0]["errors"]["raw_data"]) updated = models.AssessmentItem.objects.get(id=assessmentitem.id) + self.assertEqual(updated.type, assessmentitem.type) self.assertEqual(updated.raw_data, "") def _create_qti_referenced_files(self, checksums): diff --git a/contentcuration/contentcuration/viewsets/assessmentitem.py b/contentcuration/contentcuration/viewsets/assessmentitem.py index 5b42dab213..12494275b4 100644 --- a/contentcuration/contentcuration/viewsets/assessmentitem.py +++ b/contentcuration/contentcuration/viewsets/assessmentitem.py @@ -134,7 +134,20 @@ def validate(self, data): # except Exception in create_from_changes/update_from_changes and # reported as "Internal server error" for the whole batch. data = super(AssessmentItemSerializer, self).validate(data) - if self._item_type == exercises.QTI: + item_type = self._item_type + if ( + self.instance is not None + and "raw_data" in data + and item_type not in PASSTHROUGH_TYPES + ): + # consolidate() hands every still-legacy item to the client as QTI, so an edit + # comes back as QTI raw_data. Accept it and let the row catch up with what the + # client was told — the same conversion the global backfill (#6007) will apply + # to every item, done one item at a time as authors touch them. The legacy + # question/answers/hints columns are left alone; they stop being read as soon + # as the type changes, and the backfill clears them. + item_type = data["type"] = exercises.QTI + if item_type == exercises.QTI: legacy_fields = {"question", "answers", "hints"}.intersection(data) if legacy_fields: raise ValidationError( @@ -153,7 +166,7 @@ def validate(self, data): raise ValidationError( {"raw_data": [error.message for error in result.errors]} ) - elif self._item_type != exercises.PERSEUS_QUESTION and "raw_data" in data: + elif item_type != exercises.PERSEUS_QUESTION and "raw_data" in data: raise ValidationError( { "raw_data": [ From 2689668e8b1a60e6563eaccbd1fbd38c113a6309 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Mon, 17 Aug 2026 10:42:20 -0500 Subject: [PATCH 21/24] refactor: validate interactions without debouncing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validation waited 400 ms after the last state change before updating errors, so for that window the messages on screen described a state the editor had already left — and the card indicator built on those errors lagged with them. Nothing about validating is expensive: it reads the state the editor already holds. The watcher now calls runValidation directly. runValidation stays exposed for the explicit triggers the text-entry editor uses when closing a panel. The tests that asserted the debounce rather than the behaviour now say what the editor does: an incomplete question reports as soon as it renders, and a complete one reports nothing. The rest just lose their fake timers. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/QTIItemEditor.spec.js | 5 --- .../__tests__/useInteraction.spec.js | 8 ----- .../__tests__/useTextEntryInteraction.spec.js | 11 +++++-- .../QTIEditor/composables/useInteraction.js | 23 ++++--------- .../choice/__tests__/Editor.spec.js | 33 ++++++++++--------- .../ordering/__tests__/Editor.spec.js | 12 +++---- .../textEntry/__tests__/Editor.spec.js | 19 ++++------- 7 files changed, 43 insertions(+), 68 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js index 7005c86725..aecaa776d4 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js @@ -112,15 +112,10 @@ describe('QTIItemEditor', () => { describe('incomplete indicator', () => { const renderAndValidate = async raw_data => { - jest.useFakeTimers(); renderComponent({ item: { assessment_id: 'item-id', type: AssessmentItemTypes.QTI, raw_data }, }); await nextTick(); - // Validation is debounced inside the interaction editor. - jest.advanceTimersByTime(400); - await nextTick(); - jest.useRealTimers(); }; test('is shown for a question missing something the author has to supply', async () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js index 57dcd875de..4b768951fe 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js @@ -1,14 +1,6 @@ import { ref, nextTick } from 'vue'; import { useInteraction } from '../useInteraction'; -jest.mock('lodash/debounce', () => { - return jest.fn(fn => { - const mocked = jest.fn((...args) => fn(...args)); - mocked.cancel = jest.fn(); - return mocked; - }); -}); - function makeDescriptor({ parseReturn = {}, buildReturn = null, validateReturn = [] } = {}) { return { parse: jest.fn(() => parseReturn), diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js index 9da18c9334..3cb643866e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js @@ -36,12 +36,17 @@ describe('useTextEntryInteraction', () => { expect(state.value.answers[0].value).toBe('12'); }); - it('starts with empty errors', () => { + it('starts with no errors when the parsed state is already valid', () => { const { errors } = setupNumeric(); - // errors populates asynchronously via debounced watcher; - // immediately after setup it is still empty. + expect(errors.value).toEqual([]); }); + + it('reports errors for an invalid parsed state without waiting', () => { + const { errors } = setupNumeric([]); + + expect(errors.value.map(e => e.code)).toContain(ValidationError.NO_CORRECT_ANSWER); + }); }); describe('addAnswer()', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js index b0d0b25e62..316ffe0b9f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js @@ -1,5 +1,4 @@ -import { ref, computed, watch, onUnmounted } from 'vue'; -import debounce from 'lodash/debounce'; +import { ref, computed, watch } from 'vue'; /** * Base composable for all interaction editors. @@ -8,10 +7,9 @@ import debounce from 'lodash/debounce'; * interaction plugin must go through. Individual interaction composables * (e.g. useChoiceInteraction) call this and add mutation methods on top. * - * Validation runs immediately when called explicitly (e.g. when closing a - * panel), but is debounced when triggered by state changes so that errors - * only appear after the user pauses typing (400 ms), avoiding noisy - * inline error flicker on every keystroke. + * Validation runs on every state or questionType change, so errors always describe the + * state the editor is showing. runValidation is exposed for explicit triggers, such as + * closing a panel. * * @param {import('../interactions/InteractionDescriptor').InteractionDescriptor} descriptor * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock @@ -43,21 +41,12 @@ export function useInteraction(descriptor, interactionBlock, questionType) { const errors = ref([]); - /** Immediately validates and updates errors. Use this for explicit triggers (e.g. close). */ + /** Validates and updates errors. Exposed for explicit triggers (e.g. close). */ function runValidation() { errors.value = descriptor.validate(state.value, questionType.value); } - /** - * Debounced version used by the state watcher — waits 400 ms after the user - * stops typing before showing inline errors. - */ - const debouncedValidation = debounce(runValidation, 400); - - // Cancel any pending debounce when the component is torn down. - onUnmounted(() => debouncedValidation.cancel()); - - watch([state, questionType], debouncedValidation, { deep: true, immediate: true }); + watch([state, questionType], runValidation, { deep: true, immediate: true }); return { state, bodyXml, responseDeclarations, errors, runValidation }; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Editor.spec.js index 4246586728..5a85fbafee 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Editor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/Editor.spec.js @@ -282,46 +282,49 @@ describe('ChoiceInteractionEditor', () => { }); describe('validation', () => { - it('does not show errors before any field is touched', () => { + it('reports what is missing as soon as it renders', () => { + // Validation is not debounced, so errors describe the state on screen from the start: + // this fixture has no declaration, so no choice is marked correct. renderEditor({ interaction: block(CHOICE_SINGLE_SELECT_XML), questionType: QuestionType.SINGLE_SELECT, }); + + expect(screen.getByText(tr.errorNoCorrectAnswer$())).toBeInTheDocument(); + }); + + it('shows no errors for a question that is already complete', () => { + renderEditor({ + interaction: blockWithDecl(CHOICE_SINGLE_SELECT_XML, SINGLE_DECL), + questionType: QuestionType.SINGLE_SELECT, + }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); it('shows global errors (no correct choice) after a structural mutation', async () => { - jest.useFakeTimers(); // Add a choice so we have 2+ choices — then the only error is no correct choice. renderEditor({ interaction: block(CHOICE_SINGLE_SELECT_XML), questionType: QuestionType.SINGLE_SELECT, }); - // Clicking Add choice mutates state → debounced validate fires. + // Clicking Add choice mutates state, which validates straight away. await fireEvent.click(screen.getByRole('button', { name: /add choice/i })); - // Flush Vue watcher queue. - await nextTick(); - // Advance past the 400ms debounce, then flush the resulting DOM update. - jest.advanceTimersByTime(400); await nextTick(); - jest.useRealTimers(); + // NO_CORRECT_ANSWER (and potentially others) should be shown after validation runs. expect(screen.getAllByRole('alert').length).toBeGreaterThan(0); }); - it('shows no-correct-choice error after toggling and running validation', async () => { - jest.useFakeTimers(); + it('shows the empty-choice error as soon as a choice is added', async () => { renderEditor({ interaction: blockWithDecl(CHOICE_SINGLE_SELECT_XML, SINGLE_DECL), questionType: QuestionType.SINGLE_SELECT, }); - // Trigger validation via add-choice which mutates state → debounced validate fires. await fireEvent.click(screen.getByRole('button', { name: /add choice/i })); await nextTick(); - jest.advanceTimersByTime(400); - await nextTick(); - jest.useRealTimers(); - // Validate fires; errors should appear (e.g. empty choice content). + + expect(screen.getByText(tr.errorEmptyChoiceContent$())).toBeInTheDocument(); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/Editor.spec.js index 0bd4de0e5a..09f4114eca 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/Editor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/Editor.spec.js @@ -214,7 +214,7 @@ describe('OrderingEditor', () => { }); describe('validation', () => { - it('does not show errors before any field is touched', () => { + it('shows no errors for a question that is already complete', () => { renderEditor({ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), questionType: QuestionType.ORDERING, @@ -222,19 +222,15 @@ describe('OrderingEditor', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); - it('shows errors after runValidation is triggered by state mutation', async () => { - jest.useFakeTimers(); + it('reports what is missing as soon as the state changes', async () => { renderEditor({ interaction: block(''), questionType: QuestionType.ORDERING, }); await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addItemBtn') })); await nextTick(); - jest.advanceTimersByTime(400); - await nextTick(); - jest.useRealTimers(); - // Prompt is empty → should show prompt required error - expect(screen.getAllByRole('alert').length).toBeGreaterThan(0); + + expect(screen.getByText(tr.errorPromptRequired$())).toBeInTheDocument(); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js index 7d2bf1ef3b..e060c07846 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js @@ -126,10 +126,6 @@ describe('TextEntryEditor — numeric', () => { }); describe('validation', () => { - afterEach(() => { - jest.useRealTimers(); - }); - it('does not show errors before any field is touched', () => { renderEditor({ interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), @@ -139,7 +135,6 @@ describe('TextEntryEditor — numeric', () => { }); it('shows an error after typing a non-numeric value and blurring', async () => { - jest.useFakeTimers(); renderEditor({ interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), questionType: QuestionType.NUMERIC, @@ -147,22 +142,19 @@ describe('TextEntryEditor — numeric', () => { const input = answerInputs()[0]; await fireEvent.input(input, { target: { value: 'not-a-number' } }); await fireEvent.blur(input); - jest.useRealTimers(); await nextTick(); + expect(screen.getByRole('alert')).toBeInTheDocument(); }); - it('shows validation errors after a state mutation and debounce', async () => { - jest.useFakeTimers(); + it('shows validation errors as soon as the state changes', async () => { renderEditor({ interaction: block(TEXT_ENTRY_BODY_XML), questionType: QuestionType.NUMERIC, }); await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addAnswerBtn') })); await nextTick(); - jest.advanceTimersByTime(400); - jest.useRealTimers(); - await nextTick(); + expect(screen.getAllByRole('alert').length).toBeGreaterThan(0); }); }); @@ -260,7 +252,10 @@ describe('TextEntryEditor — accessibility', () => { describe('TextEntryEditor — graceful fallback', () => { it('does not crash with empty bodyXml for numeric', () => { renderEditor({ interaction: block(''), questionType: QuestionType.NUMERIC }); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + // An empty interaction is incomplete, and validation is not debounced, so it says so + // right away rather than rendering nothing. + expect(screen.getByText(tr.errorPromptRequired$())).toBeInTheDocument(); }); it('does not crash with empty bodyXml for freeResponse', () => { From 78b6736a2c088ffed3f24b5c60e813206a164f73 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Mon, 17 Aug 2026 11:12:34 -0500 Subject: [PATCH 22/24] refactor: drop Studio's delayed validation of assessment items Whether a question's errors are shown yet is the QTI editor's business now: the item always has them, and the editor decides when they surface. Studio only needs to know whether a question is complete, and answering that with "unless it was created recently" made the tab icon and the incomplete-questions banner disagree with the card they describe. So the DELAYED_VALIDATION symbol and the ignoreDelayed argument threaded through the assessmentItem getters are gone, along with the pass over the items on modal close that used to clear the flag, and the stripping of the symbol on the way to IndexedDB. Co-Authored-By: Claude Opus 5 (1M context) --- .../channelEdit/components/edit/EditModal.vue | 23 ++----------- .../channelEdit/components/edit/EditView.vue | 3 +- .../composables/useAssessmentItems.js | 1 - .../assessmentItem/__tests__/getters.spec.js | 33 ++++++------------ .../vuex/assessmentItem/getters.js | 34 ++++++------------- .../channelEdit/vuex/contentNode/getters.js | 9 +---- .../frontend/shared/constants.js | 4 --- .../frontend/shared/data/resources.js | 6 ++-- 8 files changed, 28 insertions(+), 85 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 06257dc3ce..527f598fc3 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -214,9 +214,7 @@ import ToolBar from 'shared/views/ToolBar'; import BottomBar from 'shared/views/BottomBar'; import FileDropzone from 'shared/views/files/FileDropzone'; - import { getAssessmentItemErrors, isNodeComplete } from 'shared/utils/validation'; - import { ValidationError } from 'shared/views/QTIEditor/constants'; - import { DELAYED_VALIDATION } from 'shared/constants'; + import { isNodeComplete } from 'shared/utils/validation'; const CHECK_STORAGE_INTERVAL = 10000; @@ -273,6 +271,8 @@ }, computed: { ...mapGetters('contentNode', ['getContentNode', 'getContentNodeIsValid']), + // Read through `vm` in the route guard below, which the lint rule cannot see. + // eslint-disable-next-line vue/no-unused-properties ...mapGetters('assessmentItem', ['getAssessmentItems']), // eslint-disable-next-line vue/no-unused-properties ...mapGetters('currentChannel', ['currentChannel', 'canEdit']), @@ -449,7 +449,6 @@ ...mapActions('assessmentItem', ['loadAssessmentItems']), /* eslint-enable vue/no-unused-properties */ ...mapMutations('contentNode', { enableValidation: 'ENABLE_VALIDATION_ON_NODES' }), - ...mapMutations('assessmentItem', { stopDelayingValidation: 'UPDATE_ASSESSMENTITEM' }), closeModal(changed = false) { if (!this.uploadMode) { const eventAction = changed ? 'Save' : 'Close'; @@ -490,22 +489,6 @@ this.selected = this.nodeIds; this.$nextTick(() => { this.enableValidation(this.nodeIds); - // Questions the author has actually started writing begin reporting as - // incomplete once the modal closes; ones still left blank stay quiet. - this.getAssessmentItems(this.nodeIds) - .filter( - item => - !getAssessmentItemErrors(item).some( - error => error.code === ValidationError.PROMPT_REQUIRED, - ), - ) - .forEach(item => - this.stopDelayingValidation({ - contentnode: item.contentnode, - assessment_id: item.assessment_id, - [DELAYED_VALIDATION]: false, - }), - ); // reaches into Details Tab to run save of diffTracker // before the validation pop up is executed diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue index 398e2c2031..a56b779bed 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue @@ -281,8 +281,7 @@ }, areAssessmentItemsValid() { return ( - !this.oneSelected || - this.getAssessmentItemsAreValid({ contentNodeId: this.nodeIds[0], ignoreDelayed: true }) + !this.oneSelected || this.getAssessmentItemsAreValid({ contentNodeId: this.nodeIds[0] }) ); }, areFilesValid() { diff --git a/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js b/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js index b37ab89516..983ca88b45 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js +++ b/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js @@ -70,7 +70,6 @@ export default function useAssessmentItems(nodeId) { const invalidItemsCount = computed(() => store.getters['assessmentItem/getInvalidAssessmentItemsCount']({ contentNodeId: unref(nodeId), - ignoreDelayed: true, }), ); diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/getters.spec.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/getters.spec.js index 9aacb2df6c..7222701222 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/getters.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/getters.spec.js @@ -5,7 +5,7 @@ import { getInvalidAssessmentItemsCount, getAssessmentItemsAreValid, } from '../getters'; -import { AssessmentItemTypes, ContentModalities, DELAYED_VALIDATION } from 'shared/constants'; +import { AssessmentItemTypes, ContentModalities } from 'shared/constants'; import { ValidationError } from 'shared/views/QTIEditor/constants'; import { VALID_CHOICE_ITEM_DOCUMENT, @@ -47,7 +47,7 @@ describe('assessmentItem getters', () => { 'assessment-id-3', 'content-node-id-2', CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, - { order: 2, [DELAYED_VALIDATION]: true }, + { order: 2 }, ), }, 'content-node-id-3': { @@ -55,13 +55,11 @@ describe('assessmentItem getters', () => { 'assessment-id-4', 'content-node-id-3', CHOICE_ITEM_DOCUMENT_NO_PROMPT, - { [DELAYED_VALIDATION]: true }, ), 'assessment-id-5': item( 'assessment-id-5', 'content-node-id-3', CHOICE_ITEM_DOCUMENT_NO_PROMPT, - { [DELAYED_VALIDATION]: true }, ), }, 'content-node-id-survey': { @@ -120,13 +118,6 @@ describe('assessmentItem getters', () => { }); }); - it("doesn't include invalid nodes errors that are new if `ignoreDelayed` set to true", () => { - expect(errorsFor('content-node-id-2', { ignoreDelayed: true })).toEqual({ - 'assessment-id-2': [{ code: ValidationError.PROMPT_REQUIRED }], - 'assessment-id-3': [], - }); - }); - it('rejects a free-response question on a node that is not a survey', () => { state.assessmentItemsMap['content-node-id-1']['assessment-id-1'].raw_data = FREE_RESPONSE_ITEM_DOCUMENT; @@ -153,17 +144,18 @@ describe('assessmentItem getters', () => { ).toBe(2); }); - it("doesn't count invalid nodes that are new if `ignoreDelayed` set to true", () => { + it('counts an item the author has only just added like any other', () => { + state.assessmentItemsMap['content-node-id-3'] = { + 'assessment-id-7': item('assessment-id-7', 'content-node-id-3', ''), + }; + expect( getInvalidAssessmentItemsCount( state, {}, {}, rootGetters, - )({ - contentNodeId: 'content-node-id-2', - ignoreDelayed: true, - }), + )({ contentNodeId: 'content-node-id-3' }), ).toBe(1); }); }); @@ -191,18 +183,15 @@ describe('assessmentItem getters', () => { ).toBe(false); }); - it('returns true if all assessment items are not valid and marked as new if `ignoreDelayed` set to true', () => { + it('returns false when every assessment item of a content node is invalid', () => { expect( getAssessmentItemsAreValid( state, {}, {}, rootGetters, - )({ - contentNodeId: 'content-node-id-3', - ignoreDelayed: true, - }), - ).toBe(true); + )({ contentNodeId: 'content-node-id-3' }), + ).toBe(false); }); }); }); diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/getters.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/getters.js index 3e336ff85c..63b74e0641 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/getters.js @@ -1,4 +1,4 @@ -import { ContentModalities, DELAYED_VALIDATION } from 'shared/constants'; +import { ContentModalities } from 'shared/constants'; import { getAssessmentItemErrors } from 'shared/utils/validation'; /** * Get assessment items of a node. @@ -25,10 +25,9 @@ export function getAssessmentItemsCount(state) { /** * Get a map of assessment items errors where keys are assessment ids. - * Consider new assessment items as valid if `ignoreDelayed` is true. */ export function getAssessmentItemsErrors(state, getters, rootState, rootGetters) { - return function ({ contentNodeId, ignoreDelayed = false }) { + return function ({ contentNodeId }) { const assessmentItemsErrors = {}; const contentNode = rootGetters['contentNode/getContentNode'](contentNodeId); @@ -43,13 +42,9 @@ export function getAssessmentItemsErrors(state, getters, rootState, rootGetters) Object.keys(state.assessmentItemsMap[contentNodeId]).forEach(assessmentItemId => { const assessmentItem = state.assessmentItemsMap[contentNodeId][assessmentItemId]; - if (ignoreDelayed && assessmentItem[DELAYED_VALIDATION]) { - assessmentItemsErrors[assessmentItemId] = []; - } else { - assessmentItemsErrors[assessmentItemId] = getAssessmentItemErrors(assessmentItem, { - allowFreeResponse, - }); - } + assessmentItemsErrors[assessmentItemId] = getAssessmentItemErrors(assessmentItem, { + allowFreeResponse, + }); }); return assessmentItemsErrors; }; @@ -57,20 +52,16 @@ export function getAssessmentItemsErrors(state, getters, rootState, rootGetters) /** * Get total number of invalid assessment items of a node. - * Consider new assessment items as valid if `ignoreDelayed` is true. */ export function getInvalidAssessmentItemsCount(state, getters, rootState, rootGetters) { - return function ({ contentNodeId, ignoreDelayed = false }) { + return function ({ contentNodeId }) { let count = 0; const assessmentItemsErrors = getAssessmentItemsErrors( state, getters, rootState, rootGetters, - )({ - contentNodeId, - ignoreDelayed, - }); + )({ contentNodeId }); for (const assessmentItemId in assessmentItemsErrors) { if (assessmentItemsErrors[assessmentItemId].length) { @@ -84,17 +75,12 @@ export function getInvalidAssessmentItemsCount(state, getters, rootState, rootGe /** * Are all assessment items of a node valid? - * Consider new assessment items as valid if `ignoreDelayed` is true. */ export function getAssessmentItemsAreValid(state, getters, rootState, rootGetters) { - return function ({ contentNodeId, ignoreDelayed = false }) { + return function ({ contentNodeId }) { return ( - getInvalidAssessmentItemsCount( - state, - getters, - rootState, - rootGetters, - )({ contentNodeId, ignoreDelayed }) === 0 + getInvalidAssessmentItemsCount(state, getters, rootState, rootGetters)({ contentNodeId }) === + 0 ); }; } diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/getters.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/getters.js index 3e31ce6ed3..6c754cf4cb 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/getters.js @@ -144,14 +144,7 @@ export function getContentNodeIsValid(state, getters, rootState, rootGetters) { (contentNode[NEW_OBJECT] || (getContentNodeDetailsAreValid(state)(contentNodeId) && getContentNodeFilesAreValid(state, getters, rootState, rootGetters)(contentNodeId) && - rootGetters['assessmentItem/getAssessmentItemsAreValid']({ - contentNodeId, - // Because this is called after items have been created, - // and it is not used within a form to run field validations, - // it's okay to set this to false. This also accounts for - // any async delays with the node creation - ignoreDelayed: false, - }))) + rootGetters['assessmentItem/getAssessmentItemsAreValid']({ contentNodeId }))) ); }; } diff --git a/contentcuration/contentcuration/frontend/shared/constants.js b/contentcuration/contentcuration/frontend/shared/constants.js index f697b6b974..909f79d99e 100644 --- a/contentcuration/contentcuration/frontend/shared/constants.js +++ b/contentcuration/contentcuration/frontend/shared/constants.js @@ -52,10 +52,6 @@ export const NOVALUE = Symbol('No value default'); // that they have not yet been committed to our IndexedDB layer. export const NEW_OBJECT = Symbol('New object'); -// This symbol is used as a key on new objects used to denote when -// validation should be delayed -export const DELAYED_VALIDATION = Symbol('Delayed validation'); - export const kindToIconMap = { audio: 'headset', channel: 'apps', diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 2a458cd2a3..0f4388bcbc 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -47,7 +47,7 @@ import { import urls from 'shared/urls'; import { currentLanguage } from 'shared/i18n'; import client, { paramsSerializer } from 'shared/client'; -import { DELAYED_VALIDATION, fileErrors, NEW_OBJECT } from 'shared/constants'; +import { fileErrors, NEW_OBJECT } from 'shared/constants'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; import { getMergedMapFields } from 'shared/utils/helpers'; @@ -606,8 +606,7 @@ class IndexedDBResource { } /** - * Method to remove the NEW_OBJECT and DELAYED_VALIDATION symbols - * property so we don't commit it to IndexedDB + * Method to remove the NEW_OBJECT symbol property so we don't commit it to IndexedDB * @param {Object} obj * @return {Object} */ @@ -616,7 +615,6 @@ class IndexedDBResource { ...obj, }; delete out[NEW_OBJECT]; - delete out[DELAYED_VALIDATION]; return out; } From 5f4f6b796039684f3cb7bd494af1c0a3f401f2c5 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Mon, 17 Aug 2026 12:13:59 -0500 Subject: [PATCH 23/24] fix: render images in questions converted from the legacy editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A legacy question stores its images as Perseus markdown, which extends the CommonMark image with a size and alignment suffix: ![Test](${☣ CONTENTSTORAGE}/.jpg =550x364 align=center) Neither suffix is valid CommonMark, so the destination fails to parse, the construct is not recognised as an image at all, and render_markdown emits it as literal text — which is what the QTI editor then showed, verbatim, in place of every pre-migration image. The old editor never hit this because it read the markdown on the frontend, where IMAGE_REGEX does understand both suffixes. An inline rule now claims the construct before markdown-it's image rule, but only when a suffix is actually present, leaving plain images to the built-in rule. The size becomes width/height, rounded because Perseus allows fractions where the Img model wants integers. The alignment is consumed and dropped: QTI's Img has no attribute to carry it, and the reverse conversion does not emit one either. That leaves the src, which QTI stores as a bare . — the form publishing rewrites into a package's images/ directory, and the only form Img accepts, since it rejects absolute paths. A browser cannot load it, so images were resolved to a storage URL on the way into the editor and stored bare on the way out. The markdown format already did this through preprocessMarkdown; the html format, which the QTI editors use, did no resolution at all and worked only because TipTap writes an absolute src at insert time. Co-Authored-By: Claude Opus 5 (1M context) --- .../TipTapEditor/TipTapEditor.vue | 8 ++- .../TipTapEditor/utils/imageSrc.js | 71 ++++++++++++++++++ .../TipTapEditor/__tests__/imageSrc.spec.js | 64 +++++++++++++++++ .../tests/utils/test_markdown.py | 72 +++++++++++++++++++ .../utils/assessment/markdown.py | 62 +++++++++++++++- 5 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/imageSrc.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/imageSrc.spec.js diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue index 0e114c49ea..5af52542bb 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue @@ -126,6 +126,7 @@ import { useMathHandling } from './composables/useMathHandling'; import FormulasMenu from './components/math/FormulasMenu.vue'; import { preprocessMarkdown } from './utils/markdown'; + import { resolveImageSrcs, toStoredImageSrcs } from './utils/imageSrc'; import MobileTopBar from './components/toolbar/MobileTopBar.vue'; import MobileFormattingBar from './components/toolbar/MobileFormattingBar.vue'; import { getTipTapEditorStrings } from './TipTapEditorStrings'; @@ -195,7 +196,10 @@ const getContent = () => { if (!editor.value || !isReady.value) return ''; - if (props.format === 'html') return editor.value.getHTML(); + // Image srcs are resolved for display on the way in, so they are reduced + // back to their stored form here — leaving this the one place that reads + // content out, whichever form the editor happens to be holding. + if (props.format === 'html') return toStoredImageSrcs(editor.value.getHTML()); if (!editor.value.storage?.markdown) return ''; return editor.value.storage.markdown.getMarkdown(); }; @@ -231,7 +235,7 @@ } const processedContent = - props.format === 'html' ? newValue : preprocessMarkdown(newValue); + props.format === 'html' ? resolveImageSrcs(newValue) : preprocessMarkdown(newValue); if (!editor.value) { initializeEditor(processedContent, props.mode, { diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/imageSrc.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/imageSrc.js new file mode 100644 index 0000000000..38c28c2855 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/imageSrc.js @@ -0,0 +1,71 @@ +// Translates image sources between the two forms they take in HTML content. +// +// Stored content references an image by bare `.` filename, which is +// what publishing rewrites into a package's images/ directory (see the backend's +// utils/assessment/qti/media.py) and what the QTI Img model accepts — it rejects +// absolute paths outright. The browser, though, needs a URL it can load, so the +// filename is resolved to its storage URL on the way into the editor and reduced +// back to the filename on the way out. +// +// The markdown format does the same thing through preprocessMarkdown/paramsToImageMd; +// these are its counterparts for content that is already HTML. +import { storageUrl } from 'shared/vuex/file/utils'; + +// Kept identical to QTI_CHECKSUM_FILENAME_REGEX in media.py, which decides on the +// backend which references publishing is able to resolve. +const CHECKSUM_FILENAME = /^([a-f0-9]{32})\.([0-9a-z]+)$/; + +const IMG_TAG = /]*>/gi; +const SRC_ATTRIBUTE = /\bsrc\s*=\s*(["'])(.*?)\1/i; + +/** + * Rewrite the src of every in an HTML string. + * + * A targeted substitution rather than a parse-and-serialize round trip, so + * everything else about the markup — attribute order, self-closing style, + * whitespace — survives untouched. + * + * @param {string} html + * @param {function(string): string} mapSrc + * @returns {string} + */ +function mapImageSrcs(html, mapSrc) { + if (!html) { + return html; + } + return html.replace(IMG_TAG, tag => + tag.replace(SRC_ATTRIBUTE, (attribute, quote, src) => { + const mapped = mapSrc(src); + return mapped === src ? attribute : `src=${quote}${mapped}${quote}`; + }), + ); +} + +/** + * Turn stored `.` sources into loadable storage URLs. + * + * @param {string} html + * @returns {string} + */ +export function resolveImageSrcs(html) { + return mapImageSrcs(html, src => { + const match = CHECKSUM_FILENAME.exec(src); + return match ? storageUrl(match[1], match[2]) : src; + }); +} + +/** + * Reduce storage URLs back to the `.` filename that gets stored. + * + * Sources that are not a checksum filename — a data URI, a remote image — are left + * as they are. + * + * @param {string} html + * @returns {string} + */ +export function toStoredImageSrcs(html) { + return mapImageSrcs(html, src => { + const filename = src.split('/').pop(); + return CHECKSUM_FILENAME.test(filename) ? filename : src; + }); +} diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/imageSrc.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/imageSrc.spec.js new file mode 100644 index 0000000000..54b949c355 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/imageSrc.spec.js @@ -0,0 +1,64 @@ +import { resolveImageSrcs, toStoredImageSrcs } from '../TipTapEditor/utils/imageSrc'; + +const CHECKSUM = '83ab37e959e03fec7be3e1bf834cb169'; +const FILENAME = `${CHECKSUM}.jpg`; +const STORAGE_URL = `/content/storage/8/3/${FILENAME}`; + +describe('resolveImageSrcs', () => { + it('turns a stored filename into its storage URL', () => { + expect(resolveImageSrcs(`a`)).toBe( + `a`, + ); + }); + + it('keeps the rest of the tag as it was', () => { + expect(resolveImageSrcs(`

text more

`)).toBe( + `

text more

`, + ); + }); + + it('resolves every image in the content', () => { + const html = ``; + expect(resolveImageSrcs(html)).toBe(``); + }); + + it('leaves an already resolved src alone', () => { + expect(resolveImageSrcs(``)).toBe(``); + }); + + it('leaves a src that is not a checksum filename alone', () => { + const html = ''; + expect(resolveImageSrcs(html)).toBe(html); + }); + + it('ignores a src outside an img tag', () => { + const html = ``; + expect(resolveImageSrcs(html)).toBe(html); + }); + + it('returns empty content unchanged', () => { + expect(resolveImageSrcs('')).toBe(''); + }); +}); + +describe('toStoredImageSrcs', () => { + it('reduces a storage URL to the filename that gets stored', () => { + expect(toStoredImageSrcs(`a`)).toBe( + `a`, + ); + }); + + it('leaves an already stored src alone', () => { + expect(toStoredImageSrcs(``)).toBe(``); + }); + + it('leaves a src that is not a checksum filename alone', () => { + const html = ''; + expect(toStoredImageSrcs(html)).toBe(html); + }); + + it('is the inverse of resolveImageSrcs', () => { + const html = `

a

`; + expect(toStoredImageSrcs(resolveImageSrcs(html))).toBe(html); + }); +}); diff --git a/contentcuration/contentcuration/tests/utils/test_markdown.py b/contentcuration/contentcuration/tests/utils/test_markdown.py index 0088d4a09e..655f44dbf5 100644 --- a/contentcuration/contentcuration/tests/utils/test_markdown.py +++ b/contentcuration/contentcuration/tests/utils/test_markdown.py @@ -267,3 +267,75 @@ def _assert_conversion(self, markdown_text: str, expected: str): roundtrip_result.replace("\n", "").strip(), expected.replace("\n", "").strip(), ) + + +class SizedImageTests(unittest.TestCase): + """Perseus images, whose size and alignment suffixes are not valid CommonMark.""" + + def test_size_suffix_becomes_width_and_height(self): + self.assertEqual( + render_markdown("![Test](83ab37e959e03fec7be3e1bf834cb169.jpg =550x364)"), + '

Test

\n', + ) + + def test_image_without_alt_text(self): + self.assertEqual( + render_markdown("![](cs.png =12x34)"), + '

\n', + ) + + def test_align_suffix_is_consumed_but_dropped(self): + # Consumed so the image parses at all; dropped because QTI's Img has no + # attribute to carry it. + self.assertEqual( + render_markdown("![a](cs.png align=center)"), + '

a

\n', + ) + + def test_size_and_align_together(self): + self.assertEqual( + render_markdown("![a](cs.png =12x34 align=right)"), + '

a

\n', + ) + + def test_fractional_size_is_rounded(self): + self.assertEqual( + render_markdown("![a](cs.png =229.5x287.2)"), + '

a

\n', + ) + + def test_src_is_reduced_to_the_bare_filename(self): + self.assertEqual( + render_markdown("![a](images/cs.png =12x34)"), + '

a

\n', + ) + + def test_image_keeps_its_surrounding_text(self): + self.assertEqual( + render_markdown("before ![a](cs.png =1x2) after"), + '

before a after

\n', + ) + + def test_alt_text_is_escaped(self): + self.assertEqual( + render_markdown('![