From be18e6de2da74d22c63e1959adac9cb7bbb44745 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Fri, 31 Jul 2026 18:58:13 -0700 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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 f85fee984ce2cab6aec1a1179973c427406eee99 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:18:28 -0500 Subject: [PATCH 06/14] fix: make QTI serialization match what the schema and API accept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ways a question this editor produced was refused on its way to the server, none of them visible in the editor itself. 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. Capabilities now return null when they have nothing to serialize, and the declaration drops them instead of emitting an empty element. Authored markup lost the item's namespace. 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 schema expects inline content in the namespace the item root declares, so any question whose prompt or answers carried markup was rejected. 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. Keeping theirs also means not deleting them on the way in. parseXML removed an xmlns textually, and the pattern was not anchored, so on a body whose own root carries no declaration — every interaction this editor serializes — the first match was the MathML namespace on a nested . assembleItemXml re-parses the body through there, so a question containing $$…$$ assembled to a inheriting the QTI namespace, which the schema rejects. Nothing needed the declarations gone: element lookups here are by local name, and a type selector with no namespace prefix matches in any namespace. 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. The language went the same way. QTI declares `xml:lang` for it; the legacy conversion writes a `language` attribute the schema does not define, which the XSD tolerates only through a lax extension wildcard (#6098). parseItem read `xml:lang` alone, so a converted item's language arrived empty and was written back as a fabricated 'en' — relabelling a Spanish exercise on its first edit, including a hint-only one, with nothing downstream to put it right, since publishing ships raw_data verbatim for QTI. It now reads either attribute and always writes `xml:lang`; the fallback goes when the conversion is fixed. An item with no language at all carries none, rather than a guess. Which leaves a new item with no language, since the editor has none of its own to offer, and publishing shipped raw_data verbatim for a native QTI item while stamping the node's language on every other kind — so one package would declare a language for some items and not others, decided by which editor wrote them. Publishing now stamps it here too, which keeps the node the single source rather than freezing whatever the browser knew when the question was written. Nothing carried the grading rules through either. The conversion emits a SCORE outcome declaration, and a match_correct response processing template for an item that has something to answer, so an item Studio converted as gradable came back ungradable. Both are now written on assembly rather than preserved: an edit can invalidate whatever a previous tool recorded, and match_correct is the one template this editor knows how to keep true. Last, the stored type. It is 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) --- .../shared/views/QTIEditor/constants.js | 7 +- .../textEntry/__tests__/parse.spec.js | 22 ++++ .../QTIEditor/interactions/textEntry/parse.js | 13 +- .../__tests__/assembleItem.spec.js | 108 +++++++++++++++++ .../__tests__/convertedItem.spec.js | 112 ++++++++++++++++++ .../QTIEditor/serialization/assembleItem.js | 102 +++++++++++++++- .../QTIEditor/serialization/parseItem.js | 19 +-- .../serialization/qti/QTIDeclaration.js | 6 +- .../declarations/correctResponse.spec.js | 8 +- .../qti/declarations/correctResponse.js | 9 +- .../qti/declarations/defaultValue.js | 8 +- .../tests/test_exportchannel.py | 25 ++++ .../tests/utils/qti/test_media.py | 42 +++++++ .../tests/utils/test_exercise_creation.py | 17 ++- .../utils/assessment/qti/archive.py | 6 + .../utils/assessment/qti/media.py | 25 ++++ 16 files changed, 493 insertions(+), 36 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/convertedItem.spec.js 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', }); /** 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..5f75943b6c 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 @@ -2,6 +2,7 @@ // HTML and do not work reliably on strict XML elements generated by serialization. /* eslint-disable jest-dom/prefer-to-have-attribute, jest-dom/prefer-to-have-text-content */ import { buildXmlNode, assembleItemXml } from '../assembleItem.js'; +import { parseItem } from '../parseItem.js'; const serializer = new XMLSerializer(); @@ -142,6 +143,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)', () => { @@ -248,4 +281,79 @@ describe('assembleItemXml', () => { expect(doc.querySelector('parsererror')).toBeNull(); expect(doc.querySelector('qti-assessment-item').getAttribute('title')).toBe('Plain Title'); }); + + describe('namespaces', () => { + const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + const QTI_NS = 'http://www.imsglobal.org/xsd/imsqtiasi_v3p0'; + + // buildXmlNode gets this right on its own, but the body is handed here as a string + // and parsed again, so the declaration has to survive that too. A that ends + // up inheriting the QTI namespace makes the server reject the whole item. + it('keeps a foreign namespace declared inside the body', () => { + const prompt = buildXmlNode({ + tag: 'qti-prompt', + innerHTML: `

What is x?

`, + }); + const interaction = buildXmlNode({ + tag: 'qti-choice-interaction', + attrs: { 'response-identifier': 'RESPONSE' }, + children: [prompt], + }); + + const xml = assembleItemXml({ + identifier: 'item-math', + title: 'T', + language: 'en', + bodyXml: serializer.serializeToString(interaction), + responseDeclarations: [], + }); + + expect( + new DOMParser().parseFromString(xml, 'text/xml').querySelector('math').namespaceURI, + ).toBe(MATHML_NS); + }); + + // An interaction the author did not touch is handed back exactly as parseItem read + // it, still carrying the item's own declaration. Re-declaring the same namespace is + // redundant but valid, and it must not cost the foreign one nested inside. + it('assembles a body whose root already declares the QTI namespace', () => { + const xml = assembleItemXml({ + identifier: 'item-math', + title: 'T', + language: 'en', + bodyXml: + `` + + `

What is x?

` + + `
`, + responseDeclarations: [], + }); + + const doc = new DOMParser().parseFromString(xml, 'text/xml'); + expect(doc.querySelector('parsererror')).toBeNull(); + expect(doc.querySelector('qti-choice-interaction').namespaceURI).toBe(QTI_NS); + expect(doc.querySelector('math').namespaceURI).toBe(MATHML_NS); + }); + + it('preserves MathML through a parseItem round trip', () => { + const original = + '\n' + + `` + + '' + + `

What is x?

` + + '
'; + + const item = parseItem(original); + const xml = assembleItemXml({ + identifier: item.identifier, + title: item.title, + language: item.language, + bodyXml: item.interactions[0].bodyXml, + responseDeclarations: item.interactions[0].responseDeclarations, + }); + + expect( + new DOMParser().parseFromString(xml, 'text/xml').querySelector('math').namespaceURI, + ).toBe(MATHML_NS); + }); + }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/convertedItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/convertedItem.spec.js new file mode 100644 index 0000000000..6213805df9 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/convertedItem.spec.js @@ -0,0 +1,112 @@ +/** + * The editor's read/write pair against what the converter actually emits. + * + * These read the converter's own fixtures rather than a copy, because the bug this file + * exists to catch was a disagreement between the two: `testingFixtures.js` is hand-written + * with `xml:lang` and no grading declarations, so nothing noticed that a converted item + * arrives with `language` and loses its scoring rules on the first save. + */ +// Disabled because jest-dom's matchers are built for HTML elements and reject the strict +// XML nodes this serialization produces — same reason as assembleItem.spec.js. +/* eslint-disable jest-dom/prefer-to-have-attribute */ +import fs from 'fs'; +import path from 'path'; +import { parseItem } from '../parseItem'; +import { assembleItemXml } from '../assembleItem'; + +const FIXTURES = path.join(__dirname, '../../../../../../tests/utils/qti/fixtures'); + +const read = name => fs.readFileSync(path.join(FIXTURES, `${name}.xml`), 'utf8'); + +const rebuild = item => + assembleItemXml({ + identifier: item.identifier, + title: item.title, + language: item.language, + bodyXml: item.interactions[0].bodyXml, + responseDeclarations: item.interactions[0].responseDeclarations, + hints: item.hints, + }); + +describe('a converted single-selection item', () => { + const original = read('single_selection'); + + // The converter writes the language as `language`, which QTI does not define — see + // #6098. Read anyway, so migrating a question does not lose it. + it('is read with the language the converter wrote', () => { + expect(parseItem(original).language).toBe('en-US'); + }); + + it('writes that language back as xml:lang, the attribute QTI declares', () => { + const xml = rebuild(parseItem(original)); + expect(xml).toContain('xml:lang="en-US"'); + expect(xml).not.toContain(' language="'); + }); + + it('keeps its scoring outcome and response processing', () => { + const xml = rebuild(parseItem(original)); + const doc = new DOMParser().parseFromString(xml, 'text/xml'); + expect(doc.querySelector('parsererror')).toBeNull(); + expect(doc.querySelector('qti-outcome-declaration').getAttribute('identifier')).toBe('SCORE'); + expect(doc.querySelector('qti-response-processing').getAttribute('template')).toBe( + 'https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct', + ); + }); + + it('puts the children in the order the schema fixes', () => { + const xml = rebuild(parseItem(original)); + const order = [...xml.matchAll(/<(qti-[a-z-]+)/g)] + .map(m => m[1]) + .filter(tag => + [ + 'qti-response-declaration', + 'qti-outcome-declaration', + 'qti-item-body', + 'qti-response-processing', + ].includes(tag), + ); + expect(order).toEqual([ + 'qti-response-declaration', + 'qti-outcome-declaration', + 'qti-item-body', + 'qti-response-processing', + ]); + }); +}); + +describe('an item this editor wrote', () => { + it('keeps xml:lang, the spelling this editor uses', () => { + const xml = assembleItemXml({ + identifier: 'i', + title: 't', + language: 'es', + bodyXml: '', + responseDeclarations: [''], + }); + expect(xml).toContain('xml:lang="es"'); + }); + + it('omits the language rather than inventing one', () => { + const xml = assembleItemXml({ + identifier: 'i', + title: 't', + language: '', + bodyXml: '', + responseDeclarations: [], + }); + expect(xml).not.toContain('xml:lang'); + expect(xml).not.toContain('language='); + }); + + it('scores nothing when there is nothing to answer', () => { + const xml = assembleItemXml({ + identifier: 'i', + title: 't', + language: 'en', + bodyXml: '

Just text.

', + responseDeclarations: [], + }); + expect(xml).toContain('qti-outcome-declaration'); + expect(xml).not.toContain('qti-response-processing'); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js index 48295a8889..de1e79370b 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 ?? []) { @@ -62,6 +110,34 @@ export function buildXmlNode({ tag, attrs = {}, children, innerHTML }) { return el; } +/** The scoring outcome every item carries, matching what the legacy conversion emits. */ +function buildOutcomeDeclarationNode() { + return buildXmlNode({ + tag: 'qti-outcome-declaration', + attrs: { identifier: 'SCORE', cardinality: 'single', 'base-type': 'float' }, + }); +} + +/** + * How the item is scored, or null when there is nothing to score against. + * + * Written rather than carried over from whatever the item arrived with: an author's edit + * can invalidate the rules a previous tool recorded, and match_correct is the one template + * this editor knows how to keep true. An item with no response declaration — a question + * with nothing to answer — gets no processing at all, which is what the converter does too. + */ +function buildResponseProcessingNode(declarationCount) { + if (!declarationCount) { + return null; + } + return buildXmlNode({ + tag: 'qti-response-processing', + attrs: { + template: 'https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct', + }, + }); +} + /** * Assembles a full QTI assessment-item XML string from its constituent parts. * @@ -73,12 +149,18 @@ export function buildXmlNode({ tag, attrs = {}, children, innerHTML }) { * @param {object} params * @param {string} params.identifier - Item identifier attribute * @param {string} params.title - Item title attribute - * @param {string} params.language - xml:lang attribute value + * @param {string} params.language - Language tag, or '' to omit it * @param {string} params.bodyXml - Serialized interaction element XML string * @param {string[]} params.responseDeclarations - Array of serialized declaration XML strings * @returns {string} Full QTI XML string */ -export function assembleItemXml({ identifier, title, language, bodyXml, responseDeclarations }) { +export function assembleItemXml({ + identifier, + title, + language, + bodyXml, + responseDeclarations, +}) { // Parse each serialized declaration string back into a DOM node so it can be // adopted into the assessment item tree via buildXmlNode's importNode logic. const declNodes = (responseDeclarations || []).map(declXml => { @@ -96,6 +178,8 @@ export function assembleItemXml({ identifier, title, language, bodyXml, response children: [bodyRoot], }); + const responseProcessingNode = buildResponseProcessingNode(declNodes.length); + const assessmentItemNode = buildXmlNode({ tag: 'qti-assessment-item', attrs: { @@ -107,9 +191,17 @@ export function assembleItemXml({ identifier, title, language, bodyXml, response title: title || '', adaptive: 'false', 'time-dependent': 'false', - 'xml:lang': language || 'en', + // Omitted rather than guessed when the item has no language: the schema allows an + // item without one. + 'xml:lang': language || null, }, - children: [...declNodes, itemBodyNode], + // The schema fixes this order: declarations, the body, then the processing. + children: [ + ...declNodes, + buildOutcomeDeclarationNode(), + itemBodyNode, + ...(responseProcessingNode ? [responseProcessingNode] : []), + ], }); return `\n${serializer.serializeToString(assessmentItemNode)}`; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js index d0bccb018c..fc11fa1934 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js @@ -14,12 +14,11 @@ const parser = new DOMParser(); * 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); + // Namespace declarations are left in place. Everything here looks elements up by local + // name, which matches in any namespace, so removing them buys nothing — while a foreign + // namespace a nested subtree does need (MathML from the formula button, SVG) would be + // lost with them, and inheriting the QTI namespace instead makes the item invalid. + const doc = parser.parseFromString(xmlString, mimeType); // DOMParser never throws — it signals failure via a node. This // only applies to XML: the HTML parser recovers silently and never emits one, @@ -73,7 +72,13 @@ export function parseItem(rawData) { const root = doc.querySelector('qti-assessment-item'); const identifier = root?.getAttribute('identifier') ?? ''; const title = root?.getAttribute('title') ?? ''; - const language = root?.getAttribute('xml:lang') ?? ''; + /** + * `xml:lang` is the attribute QTI declares for this; `language` is one Studio's own + * legacy conversion emits by mistake (#6098), and is read here only so that migrating a + * question does not throw its language away. Whatever it is read from, it is written + * back as `xml:lang`, and this fallback goes once the conversion is fixed. + */ + const language = root?.getAttribute('xml:lang') ?? root?.getAttribute('language') ?? ''; const body = doc.querySelector('qti-item-body'); 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 diff --git a/contentcuration/contentcuration/tests/test_exportchannel.py b/contentcuration/contentcuration/tests/test_exportchannel.py index d4ff340e6f..6516117a3e 100644 --- a/contentcuration/contentcuration/tests/test_exportchannel.py +++ b/contentcuration/contentcuration/tests/test_exportchannel.py @@ -44,6 +44,7 @@ from contentcuration import models as cc from contentcuration.models import CustomTaskMetadata from contentcuration.utils.assessment.qti.archive import hex_to_qti_id +from contentcuration.utils.assessment.qti.validation import parse_qti_xml from contentcuration.utils.celery.tasks import generate_task_signature from contentcuration.utils.publish import ChannelIncompleteError from contentcuration.utils.publish import convert_channel_thumbnail @@ -985,6 +986,30 @@ def test_native_qti_perseus_ids_match_assessment_metadata(self): self.assertTrue(item_stems) self.assertEqual(item_stems, assessment_item_ids) + def test_native_qti_item_declares_the_node_language(self): + """The editor has no language of its own to write, so publishing supplies it. + + The other two paths through the same generator stamp the node's language as they + build their items; this asserts the raw_data path does not ship items that declare + no language, which a package would otherwise mix with ones that do. + """ + node = cc.ContentNode.objects.get(title="Native QTI Exercise") + qti_file = node.files.get(preset_id=format_presets.QTI_ZIP) + with qti_file.file_on_disk.open("rb") as file_handle: + archive = zipfile.ZipFile(file_handle) + item_names = [ + name + for name in archive.namelist() + if name.startswith("items/") and name.endswith(".xml") + ] + self.assertTrue(item_names) + for name in item_names: + root = parse_qti_xml(archive.read(name)).getroot() + self.assertEqual( + root.get("{http://www.w3.org/XML/1998/namespace}lang"), + node.language.lang_code if node.language else "en", + ) + def test_native_qti_unsupported_interaction_publishes_qti_only(self): node = cc.ContentNode.objects.get(title="Native QTI Unsupported Exercise") self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists()) diff --git a/contentcuration/contentcuration/tests/utils/qti/test_media.py b/contentcuration/contentcuration/tests/utils/qti/test_media.py index 430c1db9f5..57dd697e63 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_media.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_media.py @@ -1,5 +1,8 @@ +from contentcuration.tests.utils.qti.test_validation import VALID_CHOICE_ITEM from contentcuration.utils.assessment.qti.media import get_qti_media_references from contentcuration.utils.assessment.qti.media import rewrite_qti_media_paths +from contentcuration.utils.assessment.qti.media import set_qti_item_language +from contentcuration.utils.assessment.qti.validation import validate_qti_item CHECKSUM_A = "a" * 32 CHECKSUM_B = "b" * 32 @@ -80,3 +83,42 @@ def test_rewrite_ignores_values_not_in_mapping(): assert result == ( f'x' ) + + +ITEM_WITHOUT_LANGUAGE = ( + '' + "

Body

" + "" +) + + +def test_set_language_adds_it_when_the_item_has_none(): + result = set_qti_item_language(ITEM_WITHOUT_LANGUAGE, "es") + assert 'xml:lang="es"' in result + assert "

Body

" in result + + +def test_set_language_replaces_a_language_the_item_already_had(): + already = ITEM_WITHOUT_LANGUAGE.replace('title="t"', 'title="t" xml:lang="en"') + result = set_qti_item_language(already, "sw") + assert 'xml:lang="sw"' in result + assert 'xml:lang="en"' not in result + + +def test_set_language_leaves_the_item_alone_without_a_language_to_set(): + assert set_qti_item_language(ITEM_WITHOUT_LANGUAGE, "") == ITEM_WITHOUT_LANGUAGE + assert set_qti_item_language(ITEM_WITHOUT_LANGUAGE, None) == ITEM_WITHOUT_LANGUAGE + + +def test_set_language_only_touches_the_root(): + nested = ITEM_WITHOUT_LANGUAGE.replace("

Body

", '

Body

') + result = set_qti_item_language(nested, "es") + assert '

Body

' in result + assert result.count('xml:lang="es"') == 1 + + +def test_set_language_keeps_the_item_schema_valid(): + result = set_qti_item_language(VALID_CHOICE_ITEM, "es") + validation = validate_qti_item(result) + assert validation.is_valid, validation.errors diff --git a/contentcuration/contentcuration/tests/utils/test_exercise_creation.py b/contentcuration/contentcuration/tests/utils/test_exercise_creation.py index 17541d8ba3..4f0343b515 100644 --- a/contentcuration/contentcuration/tests/utils/test_exercise_creation.py +++ b/contentcuration/contentcuration/tests/utils/test_exercise_creation.py @@ -1922,7 +1922,11 @@ def test_manifest_structure_single_item(self): ) def test_native_qti_item_written_verbatim(self): - """The item XML in the zip must byte-match the authored raw_data.""" + """The item XML in the zip is the authored raw_data, not a rebuild of it. + + Publishing stamps the node's language on the root, since the editor has none of its + own to write; every other byte, formatting included, has to survive untouched. + """ raw_data = _item_xml( "native_item_1", "Native Item", @@ -1948,9 +1952,9 @@ def test_native_qti_item_written_verbatim(self): exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP) zip_file = self._validate_qti_zip_structure(exercise_file) self.assertIn("items/native_item_1.xml", zip_file.namelist()) - self.assertEqual( - zip_file.read("items/native_item_1.xml").decode("utf-8"), raw_data - ) + item_xml = zip_file.read("items/native_item_1.xml").decode("utf-8") + self.assertIn('xml:lang="en-US"', item_xml) + self.assertEqual(item_xml.replace(' xml:lang="en-US"', ""), raw_data) def test_native_qti_item_media_included_and_addressed(self): # fileobj_exercise_image() writes real bytes to storage keyed by their @@ -1984,8 +1988,11 @@ def test_native_qti_item_media_included_and_addressed(self): self.assertIn(f"images/{media_filename}", manifest) item_xml = zip_file.read("items/native_item_1.xml").decode("utf-8") + # The media path is remapped and the node's language stamped; nothing else changes. + self.assertIn('xml:lang="en-US"', item_xml) self.assertEqual( - item_xml, raw_data.replace(media_filename, f"images/{media_filename}") + item_xml.replace(' xml:lang="en-US"', ""), + raw_data.replace(media_filename, f"images/{media_filename}"), ) def test_native_qti_item_invalid_raw_data_is_skipped(self): diff --git a/contentcuration/contentcuration/utils/assessment/qti/archive.py b/contentcuration/contentcuration/utils/assessment/qti/archive.py index a420953e77..71e05d2900 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/archive.py +++ b/contentcuration/contentcuration/utils/assessment/qti/archive.py @@ -26,6 +26,7 @@ from contentcuration.utils.assessment.qti.imsmanifest import Resources from contentcuration.utils.assessment.qti.media import get_qti_media_references from contentcuration.utils.assessment.qti.media import rewrite_qti_media_paths +from contentcuration.utils.assessment.qti.media import set_qti_item_language from contentcuration.utils.assessment.qti.validation import parse_qti_xml from contentcuration.utils.assessment.qti.validation import validate_qti_item @@ -106,6 +107,11 @@ def _create_native_qti_item(self, assessment_item) -> Optional[Tuple[str, bytes] filepath = f"items/{identifier}.xml" item_xml, file_dependencies = self._write_qti_media_files(assessment_item) + # The other two paths through this generator build their items here and stamp the + # node's language as they go; this one is handed raw_data, so it stamps it too. + # Otherwise one package declares a language for some items and not others, + # depending only on which editor wrote them. + item_xml = set_qti_item_language(item_xml, self._node_language()) self._add_resource( QTIResource( diff --git a/contentcuration/contentcuration/utils/assessment/qti/media.py b/contentcuration/contentcuration/utils/assessment/qti/media.py index 660bd10a6d..2ce0795c4f 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/media.py +++ b/contentcuration/contentcuration/utils/assessment/qti/media.py @@ -14,6 +14,9 @@ + " or ".join(f"@{attribute}" for attribute in QTI_REFERENCE_ATTRIBUTES) + " or @srcset]" ) +ITEM_ROOT_START_TAG_REGEX = re.compile(r"]*>") +XML_LANG_ATTRIBUTE_REGEX = re.compile(r'\s+xml:lang="[^"]*"') + QTI_MEDIA_ATTRIBUTE_VALUE_REGEX = re.compile( r"(?P" + "|".join(QTI_REFERENCE_ATTRIBUTES + ("srcset",)) + r")" r'(?P\s*=\s*)(?P["\'])(?P[^"\']*)(?P=quote)' @@ -76,3 +79,25 @@ def _replace_attribute(match): return f"{attribute}{eq}{quote}{value}{quote}" return QTI_MEDIA_ATTRIBUTE_VALUE_REGEX.sub(_replace_attribute, raw_data) + + +def set_qti_item_language(raw_data, language): + """ + Set ``xml:lang`` on the item root, replacing any value already there. + + The node's language is the one Studio knows to be current, so it wins over whatever an + item recorded when it was written — which for an item authored in the QTI editor is + nothing at all, since the editor has no language of its own to offer. + + Operates as a targeted text substitution on the root start tag, for the same reason + ``rewrite_qti_media_paths`` does: every other byte of ``raw_data``, formatting included, + is left as the author's editor produced it. + """ + if not language: + return raw_data + + def _replace_root(match): + tag = XML_LANG_ATTRIBUTE_REGEX.sub("", match.group(0)) + return f'{tag[:-1].rstrip()} xml:lang="{language}">' + + return ITEM_ROOT_START_TAG_REGEX.sub(_replace_root, raw_data, count=1) From cf72a441d8d3ff6137c947e3ea6c002e93141a5c Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:19:29 -0500 Subject: [PATCH 07/14] 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. The modules that were already there join that convention: three interactions had three spellings of the same pair, so each one now has validation.js beside its parse.js, with a spec named after the module it covers. 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 | 81 ++++++++++++++ .../__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 +- .../{validate.spec.js => validation.spec.js} | 2 +- .../QTIEditor/interactions/choice/index.js | 5 - .../QTIEditor/interactions/choice/parse.js | 2 +- .../interactions/defineInteraction.js | 47 -------- .../QTIEditor/interactions/descriptors.js | 56 ++++++++++ .../views/QTIEditor/interactions/index.js | 47 ++++---- ...InteractionDescriptor.js => Descriptor.js} | 20 ++-- ...deringInteractionEditor.vue => Editor.vue} | 0 ...teractionEditor.spec.js => Editor.spec.js} | 6 +- .../ordering/__tests__/parse.spec.js | 2 +- .../{validate.spec.js => validation.spec.js} | 2 +- .../QTIEditor/interactions/ordering/index.js | 5 - .../QTIEditor/interactions/ordering/parse.js | 2 +- .../ordering/{validate.js => validation.js} | 0 .../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 | 83 ++++++++++++++ .../QTIEditor/serialization/assembleItem.js | 2 +- .../QTIEditor/serialization/parseItem.js | 57 ++-------- .../serialization/qti/QTISanitizer.js | 2 +- .../serialization/qti/__tests__/testUtils.js | 2 +- .../views/QTIEditor/serialization/xml.js | 49 +++++++++ 43 files changed, 552 insertions(+), 352 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%) rename contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/{validate.spec.js => validation.spec.js} (98%) 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} (81%) 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%) rename contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/{validate.spec.js => validation.spec.js} (98%) delete mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/index.js rename contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/{validate.js => validation.js} (100%) 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..271e0b5ead --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js @@ -0,0 +1,81 @@ +/** + * 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. + */ + +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__/validation.spec.js similarity index 98% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validation.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__/validation.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..aefa9c944a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js @@ -0,0 +1,56 @@ +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 so that headless validation can be done withou + * the bundle size cost of the editors. + * + * Registering a new interaction means adding its descriptor here and its editor in index.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, +]; + +/** + * @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 81% 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..58bce294de 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'; +import { validateOrderingInteraction } from './validation'; /** * 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/__tests__/validate.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validation.spec.js similarity index 98% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validate.spec.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validation.spec.js index f480cd7fcd..2222e8afd1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validate.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validation.spec.js @@ -1,4 +1,4 @@ -import { validateOrderingInteraction } from '../validate'; +import { validateOrderingInteraction } from '../validation'; import { ValidationError, Orientation } from '../../../constants'; function makeItem(overrides = {}) { 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/ordering/validate.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validation.js similarity index 100% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validate.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validation.js 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..e531a37a66 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/xml.spec.js @@ -0,0 +1,83 @@ +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(); + }); + + it('leaves the namespace declared on the document element in place', () => { + const doc = parseXML( + '', + ); + expect(doc.documentElement.namespaceURI).toBe('http://www.imsglobal.org/xsd/imsqtiasi_v3p0'); + }); + + it('leaves a foreign namespace declared on a nested element in place', () => { + // MathML has to keep its own namespace: without it, serializing the subtree back + // into a QTI-namespaced item makes inherit the QTI namespace, which the + // schema rejects. + const doc = parseXML( + '

x x

', + ); + expect(doc.querySelector('math').namespaceURI).toBe('http://www.w3.org/1998/Math/MathML'); + }); + + it('finds an element by local name regardless of the namespace it is in', () => { + // Which is why nothing needs the declarations removed to look elements up. + const doc = parseXML( + '', + ); + expect(doc.querySelector('qti-item-body')).not.toBeNull(); + }); +}); + +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 de1e79370b..3b0d117bf7 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 fc11fa1934..f0522366ff 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js @@ -1,51 +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') { - // Namespace declarations are left in place. Everything here looks elements up by local - // name, which matches in any namespace, so removing them buys nothing — while a foreign - // namespace a nested subtree does need (MathML from the formula button, SVG) would be - // lost with them, and inheriting the QTI namespace instead makes the item invalid. - const doc = parser.parseFromString(xmlString, 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. @@ -54,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 {{ @@ -98,7 +55,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..c4740c2021 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js @@ -0,0 +1,49 @@ +/** + * 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') { + // Namespace declarations are left in place. Everything here looks elements up by local + // name, which matches in any namespace, so removing them buys nothing — while a foreign + // namespace a nested subtree does need (MathML from the formula button, SVG) would be + // lost with them, and inheriting the QTI namespace instead makes the item invalid. + const doc = parser.parseFromString(xmlString, 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 a0a1b3ace51d678847901dc9ae04ab631d233562 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:19:54 -0500 Subject: [PATCH 08/14] feat: validate and create QTI items without rendering them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things Studio needs from an item it is not showing. 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. The other is a new question. It 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) --- .../QTIEditor/__tests__/validateItem.spec.js | 45 ++++++++++ .../components/QTIItemEditor/index.vue | 2 +- .../frontend/shared/views/QTIEditor/index.vue | 2 + .../__tests__/createBlankItem.spec.js | 32 +++++++ .../QTIEditor/serialization/assembleItem.js | 5 +- .../serialization/createBlankItem.js | 36 ++++++++ .../views/QTIEditor/utils/testingFixtures.js | 86 +++++++++++++++++++ .../shared/views/QTIEditor/validateItem.js | 44 ++++++++++ .../tests/utils/qti/test_validation.py | 30 +++++++ 9 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js 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 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/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue index 40711ad6f9..0ed939a59c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -172,7 +172,7 @@ props: { /** - * Assessment item: { assessment_id, type, raw_data? } + * Assessment item: { assessment_id, type, raw_data } * raw_data is the full QTI XML string; absent on blank newly-created items. */ item: { 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 3b0d117bf7..79d0cc59c9 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js @@ -184,9 +184,8 @@ export function assembleItemXml({ 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/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; +} diff --git a/contentcuration/contentcuration/tests/utils/qti/test_validation.py b/contentcuration/contentcuration/tests/utils/qti/test_validation.py index c18ccb277d..7e6142ff29 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_validation.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_validation.py @@ -152,6 +152,36 @@ 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. It carries the +# scoring outcome and the match_correct template, so a question authored here is gradable +# in the same way as one the legacy conversion produces. +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 03e5b8ab5b10ab7953db5d64b5db7e89fb61c66a Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:21:38 -0500 Subject: [PATCH 09/14] feat: show each question's state in its card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A question card gave no sign of what was wrong with the question inside it, or that this editor could not edit it at all — both of which the exercise editor it replaces did show. An incomplete question is now marked in the card header. Rather than validate the item a second time, each interaction editor reports the errors useInteraction already computes for the inline messages. What is wrong with the item itself an interaction editor structurally cannot report — an item with nothing to answer mounts no editor, and whether a free response is acceptable is the consumer's policy — so the card asks the item for that part, through a helper the headless validator shares, from what it has already parsed. A question this editor cannot read renders as read-only instead. 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. Reporting one as incomplete would ask the author to fix something they cannot reach, so it does not. A question with no correct answer is only acceptable where questions are not scored, so the type selector stops offering it on an exercise rather than letting an author choose it and then marking the question incomplete for it. A question that already is a free response keeps the option: removing it would leave the select showing some other type as though that were the question, and one nudge of the control would convert it. That one says underneath why it cannot stay. Finally, only the card being edited reports its XML. Every card re-assembles it 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. Whether the change came from an edit here is recorded as it happens rather than read when the watcher flushes: closing a card sets the parent's active item to none, and that re-render lands first, so a change made just before the close would otherwise look like it came from a card nobody was editing. Co-Authored-By: Claude Opus 5 (1M context) --- .../QTIEditor/__tests__/validateItem.spec.js | 40 ++++- .../components/InteractionSection/index.vue | 12 +- .../__tests__/QTIItemEditor.spec.js | 144 +++++++++++++++++- .../components/QTIItemEditor/index.vue | 97 +++++++++++- .../__tests__/QuestionTypeSelector.spec.js | 48 ++++++ .../components/QuestionTypeSelector/index.vue | 50 +++++- .../frontend/shared/views/QTIEditor/index.vue | 12 ++ .../QTIEditor/interactions/choice/Editor.vue | 5 +- .../interactions/ordering/Editor.vue | 5 +- .../interactions/textEntry/Editor.vue | 5 +- .../views/QTIEditor/qtiEditorStrings.js | 14 ++ .../views/QTIEditor/useQTIEditorActions.js | 4 +- .../QTIEditor/utils/__tests__/math.spec.js | 23 +++ .../views/QTIEditor/utils/testingFixtures.js | 28 ++++ .../shared/views/QTIEditor/validateItem.js | 52 ++++++- 15 files changed, 516 insertions(+), 23 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/math.spec.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 index 01bf33420e..0b072d7b38 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js @@ -1,5 +1,5 @@ -import { validateQtiItem } from '../validateItem'; -import { ValidationError } from '../constants'; +import { validateItemShape, validateQtiItem } from '../validateItem'; +import { QuestionType, ValidationError } from '../constants'; import { VALID_CHOICE_ITEM_DOCUMENT, CHOICE_ITEM_DOCUMENT_NO_PROMPT, @@ -43,3 +43,39 @@ describe('validateQtiItem', () => { ]); }); }); + +// What the editor asks about an item it is already showing, which is everything an +// interaction cannot answer for itself. +describe('validateItemShape', () => { + it('accepts an item with something to answer', () => { + expect( + validateItemShape({ interactions: [{}], questionTypes: [QuestionType.SINGLE_SELECT] }), + ).toEqual([]); + }); + + it('reports an item with nothing to answer', () => { + expect(validateItemShape({ interactions: [] })).toEqual([ + { code: ValidationError.NO_INTERACTION }, + ]); + }); + + it('accepts a free-response question when the consumer allows it', () => { + expect( + validateItemShape({ + interactions: [{}], + questionTypes: [QuestionType.FREE_RESPONSE], + allowFreeResponse: true, + }), + ).toEqual([]); + }); + + it('reports a free-response question when the consumer scores its questions', () => { + expect( + validateItemShape({ + interactions: [{}], + questionTypes: [QuestionType.FREE_RESPONSE], + allowFreeResponse: false, + }), + ).toEqual([{ code: ValidationError.FREE_RESPONSE_NOT_ALLOWED }]); + }); +}); 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 c7fc3492e9..080bc5bc7a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue @@ -12,6 +12,7 @@ v-if="mode === 'edit'" :questionType="questionType" :settingsTargetId="settingsTargetId" + :allowFreeResponse="allowFreeResponse" @update:questionType="onUpdateQuestionType" /> @@ -24,6 +25,7 @@ :showAnswers="showAnswers" :teleportTargetId="settingsTargetId" @update:interaction="onUpdateInteraction" + @update:errors="errors => $emit('update:errors', errors)" />
@@ -115,9 +117,17 @@ type: Boolean, default: false, }, + /** + * Whether a question with no correct answer is acceptable here. Passed straight to the + * type selector, which is the only part of an interaction this concerns. + */ + allowFreeResponse: { + type: Boolean, + default: true, + }, }, - emits: ['update:questionType', 'update:interaction'], + emits: ['update:questionType', 'update:interaction', 'update:errors'], }; 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..be5194ae37 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,12 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { }; }); -const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings; +const { + closeBtnLabel$, + questionContentPlaceholder$, + unsupportedItemMessage$, + incompleteItemIndicatorLabel$, +} = qtiEditorStrings; const defaultProps = { item: { @@ -77,6 +90,135 @@ 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(); + 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(); + }); + 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('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 () => { + 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 0ed939a59c..6a450dade2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -22,18 +22,39 @@
+ + + {{ incompleteItemIndicatorLabel$() }} +
+

+ {{ 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; @@ -144,29 +176,69 @@ }), ); + /** + * 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') { + // debug to help devs understand what the editor is sending to the parent // eslint-disable-next-line no-console - console.log('[QTIItemEditor] assembled XML:\n', newVal); + console.debug('[QTIItemEditor] assembled XML:\n', newVal); } emit('update:rawData', newVal); }); function onUpdateInteraction({ bodyXml, responseDeclarations }) { + editedHere = props.mode === 'edit'; currentBodyXml.value = bodyXml; currentResponseDeclarations.value = responseDeclarations; } + /** Errors the interaction editor reports about the state it holds. */ + const errors = ref([]); + + function onUpdateErrors(newErrors) { + errors.value = newErrors; + } + + /** + * Whether the question is missing something an author still has to supply. + */ + const isIncomplete = computed(() => { + if (isUnsupported.value) { + return false; + } + const itemErrors = validateItemShape({ + interactions: interactions.value, + questionTypes: [currentQuestionType.value], + allowFreeResponse: props.allowFreeResponse, + }); + return itemErrors.length > 0 || errors.value.length > 0; + }); + return { currentQuestionType, interactions, currentInteraction, + isUnsupported, + isIncomplete, questionNumberLabel, questionNumberAndTypeLabel, closeBtnLabel$, questionContentPlaceholder$, + incompleteItemIndicatorLabel$, + unsupportedItemMessage$, onUpdateInteraction, + onUpdateErrors, }; }, @@ -200,6 +272,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 +315,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/components/QuestionTypeSelector/__tests__/QuestionTypeSelector.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/__tests__/QuestionTypeSelector.spec.js index 1a1fd991d5..d3d5309a4b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/__tests__/QuestionTypeSelector.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/__tests__/QuestionTypeSelector.spec.js @@ -66,4 +66,52 @@ describe('QuestionTypeSelector', () => { expect(emitted()['update:questionType']).toBeTruthy(); expect(emitted()['update:questionType'][0]).toEqual([QuestionType.MULTI_SELECT]); }); + + describe('free response', () => { + const openDropdown = async () => fireEvent.click(screen.getByText(tr.$tr('singleSelectLabel'))); + + it('is offered where a question need not be scored', async () => { + renderHeader({ allowFreeResponse: true }); + await openDropdown(); + expect(screen.getByText(tr.$tr('freeResponseLabel'))).toBeInTheDocument(); + }); + + it('is not offered where the questions are scored', async () => { + renderHeader({ allowFreeResponse: false }); + await openDropdown(); + expect(screen.queryByText(tr.$tr('freeResponseLabel'))).not.toBeInTheDocument(); + }); + + it('leaves the other types alone', async () => { + renderHeader({ allowFreeResponse: false }); + await openDropdown(); + expect(screen.getByText(tr.$tr('multiSelectLabel'))).toBeInTheDocument(); + expect(screen.getByText(tr.$tr('numericLabel'))).toBeInTheDocument(); + expect(screen.getByText(tr.$tr('textEntryLabel'))).toBeInTheDocument(); + }); + + it('stays available to a question that already is one, so it can be changed', async () => { + renderHeader({ allowFreeResponse: false, questionType: QuestionType.FREE_RESPONSE }); + // Shown as the selection, rather than the select falling back to another type + expect(screen.getByText(tr.$tr('freeResponseLabel'))).toBeInTheDocument(); + + await fireEvent.click(screen.getByText(tr.$tr('freeResponseLabel'))); + expect(screen.getByText(tr.$tr('multiSelectLabel'))).toBeInTheDocument(); + }); + + it('says why it cannot stay, for a question that already is one', () => { + renderHeader({ allowFreeResponse: false, questionType: QuestionType.FREE_RESPONSE }); + expect(screen.getByRole('alert')).toHaveTextContent(tr.$tr('errorFreeResponseNotAllowed')); + }); + + it('says nothing when the type is allowed', () => { + renderHeader({ allowFreeResponse: true, questionType: QuestionType.FREE_RESPONSE }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('says nothing about a question that is not a free response', () => { + renderHeader({ allowFreeResponse: false }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue index 1cb89f6cf5..648881b723 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue @@ -48,6 +48,10 @@ @click="showTypeInfoModal = true" />

+ + + {{ errorFreeResponseNotAllowed$() }} +
props.questionType === QuestionType.FREE_RESPONSE); + const questionTypeOptions = computed(() => { - return descriptors.flatMap(d => d.getTypeOptions?.(qtiEditorStrings) ?? []); + const options = descriptors.flatMap(d => d.getTypeOptions?.(qtiEditorStrings) ?? []); + if (props.allowFreeResponse) { + return options; + } + /** + * A free response has no correct answer, so it is not offered where the questions are + * scored. A question that already is one keeps the option: dropping it would leave the + * select showing some other type as if that were the question, and the author with no + * way to see what they have — or to deliberately change it. + */ + return options.filter( + option => option.value !== QuestionType.FREE_RESPONSE || isFreeResponse.value, + ); }); + /** Shown under the select for a question that is a free response where it cannot be. */ + const freeResponseNotAllowed = computed( + () => !props.allowFreeResponse && isFreeResponse.value, + ); + const selectedOption = computed( () => questionTypeOptions.value.find(o => o.value === props.questionType) || @@ -127,6 +160,8 @@ labelId, questionTypeOptions, selectedOption, + freeResponseNotAllowed, + errorFreeResponseNotAllowed$, }; }, @@ -140,6 +175,15 @@ type: String, required: true, }, + + /** + * Whether a question with no correct answer is acceptable here. False on an exercise, + * whose questions are scored, and true on a survey. + */ + allowFreeResponse: { + type: Boolean, + default: true, + }, }, emits: ['update:questionType'], diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue index ca159c682c..f6ab305f08 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" @@ -102,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; } @@ -198,6 +202,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/interactions/choice/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Editor.vue index bc1edad73d..2bcce21a02 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Editor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Editor.vue @@ -332,6 +332,9 @@ })); watch(workingInteraction, newVal => emit('update:interaction', newVal), { immediate: true }); + // Errors are reported the same way, for the card to show that the question needs work. + watch(errors, newVal => emit('update:errors', newVal), { immediate: true }); + const answersDescription = computed(() => isSingleSelect.value ? answersDescriptionSingleChoice$() @@ -553,7 +556,7 @@ }, }, - emits: ['update:interaction'], + emits: ['update:interaction', 'update:errors'], }; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Editor.vue index cc1a74d44f..2e6e345e51 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Editor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Editor.vue @@ -277,6 +277,9 @@ emit('update:interaction', newVal); }); + // Errors are reported the same way, for the card to show that the question needs work. + watch(errors, newVal => emit('update:errors', newVal), { immediate: true }); + const errorCodes = computed(() => errors.value.map(e => e.code)); const promptHasError = computed(() => @@ -428,7 +431,7 @@ }, }, - emits: ['update:interaction'], + emits: ['update:interaction', 'update:errors'], }; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue index 3f4967574a..6545f4643a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue @@ -348,6 +348,9 @@ { immediate: true }, ); + // Errors are reported the same way, for the card to show that the question needs work. + watch(errors, newVal => emit('update:errors', newVal), { immediate: true }); + return { state, windowIsSmall, @@ -409,7 +412,7 @@ }, }, - emits: ['update:interaction'], + emits: ['update:interaction', 'update:errors'], }; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index 2100820126..36e6a7cf72 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -29,6 +29,15 @@ 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', + }, + 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', @@ -203,6 +212,11 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Free response', context: 'Display name for a free-response text-entry question type', }, + errorFreeResponseNotAllowed: { + message: 'Free response is only available on surveys. Choose another type.', + context: + 'Validation error shown under the type selector when a question is a free response but the exercise scores its questions', + }, freeResponseDescription: { message: 'Learners can write an open-ended response. No correct answer is enforced.', context: 'Description for the free response question type in the info modal', 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)); + }); +}); 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. diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/validateItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/validateItem.js index bab8c49933..39200af0f5 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/validateItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/validateItem.js @@ -2,6 +2,37 @@ import { QuestionType, ValidationError } from './constants'; import { parseItem } from './serialization/parseItem'; import { resolveDescriptor } from './interactions/resolveDescriptor'; +/** + * Validate what is wrong with an item as a whole, rather than with one of its interactions: + * whether there is anything to answer, and whether the kind of question it asks is one the + * consumer accepts. + * + * These are the only errors an interaction's editor cannot report. An item with nothing to + * answer mounts no editor at all, and whether free responses are acceptable is the + * consumer's policy rather than anything the interaction knows — so an editor that is + * showing an item still asks this about it. + * + * Takes what the caller has already read out of the item, so neither the editor nor + * validateQtiItem has to parse the XML again to ask. + * + * @param {object} item + * @param {Array} item.interactions - The item's interaction blocks + * @param {Array} [item.questionTypes] - The question type of each interaction, + * as resolved by the caller + * @param {boolean} [item.allowFreeResponse] - Whether a question with no correct answer + * counts as valid. Consumers that score their questions pass false. + * @returns {Array<{ code: string }>} Empty when there is nothing wrong with the item itself + */ +export function validateItemShape({ interactions, questionTypes = [], allowFreeResponse = true }) { + if (!interactions.length) { + return [{ code: ValidationError.NO_INTERACTION }]; + } + if (!allowFreeResponse && questionTypes.includes(QuestionType.FREE_RESPONSE)) { + return [{ code: ValidationError.FREE_RESPONSE_NOT_ALLOWED }]; + } + return []; +} + /** * Validate a QTI assessment item from its raw XML, without rendering it. * @@ -23,20 +54,25 @@ export function validateQtiItem(rawData, { allowFreeResponse = true } = {}) { return [{ code: ValidationError.PARSE_ERROR }]; } - if (!item.interactions.length) { - return [{ code: ValidationError.NO_INTERACTION }]; + const resolved = item.interactions.map(interaction => ({ + ...interaction, + ...resolveDescriptor(interaction.bodyXml, interaction.responseDeclarations), + })); + + const errors = validateItemShape({ + interactions: item.interactions, + questionTypes: resolved.map(({ questionType }) => questionType), + allowFreeResponse, + }); + if (errors.length) { + return errors; } - const errors = []; - for (const { bodyXml, responseDeclarations } of item.interactions) { - const { descriptor, questionType, error } = resolveDescriptor(bodyXml, responseDeclarations); + for (const { descriptor, questionType, error, bodyXml, responseDeclarations } of resolved) { 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)); } From 4b6e8cb6dd00b9a1c81fcd985c0a897ddc533557 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Mon, 17 Aug 2026 10:42:20 -0500 Subject: [PATCH 10/14] 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 be5194ae37..84d12d86c8 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 e0c1d6f0b0612739625d995aaaf9d823c800a57e Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:22:55 -0500 Subject: [PATCH 11/14] feat: author and preview 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 test asserts the dispatched payload whole, so a marker asking for validation to be delayed cannot creep back in unnoticed — toEqual compares symbol-keyed properties too. The vuex actions stop stringifying answers and hints — the API rejects those fields on a QTI item, whose content lives in raw_data. 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) --- .../AssessmentTab/AssessmentTab.vue | 195 ++++-------------- .../channelEdit/components/ResourcePanel.vue | 59 +++--- .../__tests__/useAssessmentItems.spec.js | 159 ++++++++++++++ .../composables/useAssessmentItems.js | 112 ++++++++++ .../vuex/assessmentItem/actions.js | 22 +- 5 files changed, 345 insertions(+), 202 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/components/ResourcePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue index e8616b3c1e..0750653f47 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,12 +493,15 @@ 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 { - getAssessmentItemErrors, getNodeLicenseErrors, getNodeCopyrightHolderErrors, getNodeLicenseDescriptionErrors, @@ -520,6 +509,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 +534,7 @@ DetailsRow, FilePreview, ExpandableList, - AssessmentItemPreview, + QTIItemEditor, Checkbox, ContentNodeValidator, Banner, @@ -579,7 +569,7 @@ 'getImmediatePreviousStepsList', ]), ...mapGetters('file', ['getContentNodeFiles', 'contentNodesTotalSize']), - ...mapGetters('assessmentItem', ['getAssessmentItems']), + ...mapGetters('assessmentItem', ['getAssessmentItems', 'getInvalidAssessmentItemsCount']), node() { return this.getContentNode(this.nodeId); }, @@ -626,6 +616,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]); }, @@ -724,8 +718,7 @@ }, invalidQuestionCount() { return ( - this.isExercise && - this.assessmentItems.filter(ai => getAssessmentItemErrors(ai).length).length + this.isExercise && this.getInvalidAssessmentItemsCount({ contentNodeId: this.nodeId }) ); }, invalidDetails() { @@ -918,6 +911,10 @@ padding: 0; } + .question-preview { + margin-bottom: 8px; + } + .preview-error { padding: 24% 0; 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..66dced531c --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js @@ -0,0 +1,159 @@ +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, and nothing else', 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('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..97bb1e4d53 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js @@ -0,0 +1,112 @@ +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)), + ); + + /** + * Currently free responses are only allowed in surveys + */ + 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 1ee2ff540d00cdfa77307a1f83aafb3658f23fc0 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:25:46 -0500 Subject: [PATCH 12/14] refactor: remove the legacy assessment editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. The store stops reshaping what the API no longer sends. The mutation parsed and sorted the answers and hints that used to arrive as JSON strings; nothing reads them, 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. Validation follows the same move. getAssessmentItemErrors judged every question by empty legacy fields, so it 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 caller 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. Whether a question's errors are shown yet is the editor's business now — the item always has them, and the card decides when they surface — so Studio's delayed validation goes too. Answering "is this question complete" with "unless it was created recently" made the tab icon and the incomplete-questions banner disagree with the card they describe. 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/__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 -------------- .../channelEdit/components/edit/EditModal.vue | 10 +- .../channelEdit/components/edit/EditView.vue | 3 +- .../composables/useAssessmentItems.js | 1 - .../frontend/channelEdit/constants.js | 20 - .../frontend/channelEdit/translator.js | 13 - .../frontend/channelEdit/utils.js | 136 ---- .../assessmentItem/__tests__/getters.spec.js | 212 +++--- .../__tests__/mutations.spec.js | 238 ++----- .../vuex/assessmentItem/getters.js | 41 +- .../vuex/assessmentItem/mutations.js | 24 - .../channelEdit/vuex/contentNode/getters.js | 9 +- .../frontend/shared/constants.js | 14 +- .../frontend/shared/data/resources.js | 6 +- .../frontend/shared/utils/helpers.js | 43 -- .../frontend/shared/utils/helpers.spec.js | 35 +- .../frontend/shared/utils/validation.js | 169 +---- .../frontend/shared/utils/validation.spec.js | 391 ++-------- .../QTIEditor/interactions/descriptors.js | 10 - .../views/QTIEditor/interactions/index.js | 7 +- 31 files changed, 235 insertions(+), 6178 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/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 759f27e821..527f598fc3 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -215,7 +215,6 @@ import BottomBar from 'shared/views/BottomBar'; import FileDropzone from 'shared/views/files/FileDropzone'; import { isNodeComplete } from 'shared/utils/validation'; - import { DELAYED_VALIDATION } from 'shared/constants'; const CHECK_STORAGE_INTERVAL = 10000; @@ -272,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']), @@ -445,7 +446,7 @@ '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' }), closeModal(changed = false) { @@ -488,11 +489,6 @@ 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); // 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 97bb1e4d53..08c418439c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js +++ b/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js @@ -69,7 +69,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/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/channelEdit/vuex/assessmentItem/__tests__/getters.spec.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/__tests__/getters.spec.js index 3155fdbc00..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,22 @@ import { getInvalidAssessmentItemsCount, getAssessmentItemsAreValid, } from '../getters'; -import { AssessmentItemTypes, DELAYED_VALIDATION, ValidationErrors } from 'shared/constants'; +import { AssessmentItemTypes, ContentModalities } 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,44 @@ 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 }, + ), }, '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, + ), + 'assessment-id-5': item( + 'assessment-id-5', + 'content-node-id-3', + CHOICE_ITEM_DOCUMENT_NO_PROMPT, + ), + }, + 'content-node-id-survey': { + 'assessment-id-6': item( + 'assessment-id-6', + 'content-node-id-survey', + FREE_RESPONSE_ITEM_DOCUMENT, + ), }, }, }; @@ -89,45 +76,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 +112,24 @@ 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, - ], - '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', () => { @@ -190,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); }); }); @@ -228,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-4', - ignoreDelayed: true, - }), - ).toBe(true); + )({ contentNodeId: 'content-node-id-3' }), + ).toBe(false); }); }); }); 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/getters.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/assessmentItem/getters.js index 5c454113f7..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 { AssessmentItemTypes, 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); @@ -37,20 +36,15 @@ 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; }; @@ -58,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) { @@ -85,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/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, 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 d08a6d803c..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', @@ -153,12 +149,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 +166,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/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; } 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 diff --git a/contentcuration/contentcuration/frontend/shared/utils/validation.js b/contentcuration/contentcuration/frontend/shared/utils/validation.js index e576d678e5..ff613a2079 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,44 @@ 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. Keyed by the item, + * + * @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); }); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js index aefa9c944a..2b9fadd2e1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js @@ -33,16 +33,6 @@ export const descriptors = [ */ 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 diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js index 190615f5cb..1bad224177 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js @@ -17,9 +17,4 @@ export const editors = Object.freeze({ [QtiInteraction.ORDER]: OrderingEditor, }); -export { - DEFAULT_INTERACTION, - descriptors, - registry, - getDescriptorForQuestionType, -} from './descriptors'; +export { DEFAULT_INTERACTION, descriptors, registry } from './descriptors'; From 62d7d65511b6699a4ca93bc1abd2108d5b22cca5 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 14 Aug 2026 17:26:22 -0500 Subject: [PATCH 13/14] fix: read and write converted legacy questions correctly 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 two things about that round trip were wrong. 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. The other is images. A legacy question stores them 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 | 70 ++++++++++++++++++ .../TipTapEditor/__tests__/imageSrc.spec.js | 64 +++++++++++++++++ .../tests/utils/test_markdown.py | 72 +++++++++++++++++++ .../tests/viewsets/test_assessmentitem.py | 25 ++++++- .../utils/assessment/markdown.py | 62 +++++++++++++++- .../viewsets/assessmentitem.py | 17 ++++- 7 files changed, 312 insertions(+), 6 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..b2ade17999 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/imageSrc.js @@ -0,0 +1,70 @@ +/** + * 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. + */ +import { storageUrl } from 'shared/vuex/file/utils'; + +// Kept identical to QTI_CHECKSUM_FILEaNAME_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('![ + + + 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 84d12d86c8..4e84bf71cb 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 @@ -10,6 +10,8 @@ import { ORDERING_ITEM_DOCUMENT_NO_PROMPT, FREE_RESPONSE_ITEM_DOCUMENT, NO_INTERACTION_ITEM_DOCUMENT, + CHOICE_ITEM_DOCUMENT_WITH_HINTS, + NO_INTERACTION_ITEM_WITH_HINTS, } from '../../../utils/testingFixtures'; jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); @@ -26,6 +28,7 @@ const { questionContentPlaceholder$, unsupportedItemMessage$, incompleteItemIndicatorLabel$, + hintsLabel$, } = qtiEditorStrings; const defaultProps = { @@ -214,6 +217,80 @@ describe('QTIItemEditor', () => { }); }); + describe('hints', () => { + // Adaptation of the hints previously used in Studio. New hints are not + // supported, only the display of existing ones. + test('offers no hints section on a question that arrived without any', () => { + renderComponent({ + item: { ...defaultProps.item, raw_data: VALID_CHOICE_ITEM_DOCUMENT }, + mode: 'edit', + }); + expect(screen.queryByText(hintsLabel$())).not.toBeInTheDocument(); + }); + + test('offers no hints section on a newly created question', () => { + renderComponent({ mode: 'edit' }); + expect(screen.queryByText(hintsLabel$())).not.toBeInTheDocument(); + }); + + test('shows the hints section on a question that arrived with hints', () => { + renderComponent({ + item: { ...defaultProps.item, raw_data: CHOICE_ITEM_DOCUMENT_WITH_HINTS }, + mode: 'edit', + }); + expect(screen.getByText(hintsLabel$())).toBeInTheDocument(); + }); + + test('keeps the hints of a closed question out of the way until answers are shown', () => { + renderComponent({ + item: { ...defaultProps.item, raw_data: CHOICE_ITEM_DOCUMENT_WITH_HINTS }, + mode: 'view', + showAnswers: false, + }); + expect(screen.queryByText(hintsLabel$())).not.toBeInTheDocument(); + }); + + test('shows the hints of a closed question when answers are shown', () => { + renderComponent({ + item: { ...defaultProps.item, raw_data: CHOICE_ITEM_DOCUMENT_WITH_HINTS }, + mode: 'view', + showAnswers: true, + }); + expect(screen.getByText(hintsLabel$())).toBeInTheDocument(); + }); + + test('keeps the body of a question that has no interaction when a hint changes', async () => { + // Nothing mounts an interaction editor here, so the editor holds no body of its own. + // Assembling from that empty state would replace the question's text with an empty + // — a hint edit silently deleting the question. + const { emitted } = renderComponent({ + item: { ...defaultProps.item, raw_data: NO_INTERACTION_ITEM_WITH_HINTS }, + mode: 'edit', + }); + await fireEvent.click(screen.getByRole('button', { name: hintsLabel$() })); + await fireEvent.click(screen.getAllByRole('button', { name: 'Delete hint' })[0]); + await nextTick(); + + const [xml] = emitted()['update:rawData'].at(-1); + expect(xml).toContain('What is the capital of France?'); + expect(xml).not.toContain(''); + }); + + test('reports the item XML when a hint changes', async () => { + const { emitted } = renderComponent({ + item: { ...defaultProps.item, raw_data: CHOICE_ITEM_DOCUMENT_WITH_HINTS }, + mode: 'edit', + }); + await fireEvent.click(screen.getByRole('button', { name: hintsLabel$() })); + await fireEvent.click(screen.getAllByRole('button', { name: 'Delete hint' })[0]); + await nextTick(); + + const [xml] = emitted()['update:rawData'].at(-1); + expect(xml).toContain('

test2 2

'); + expect(xml).not.toContain('

test

'); + }); + }); + 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 6a450dade2..7214f4bdf2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -62,6 +62,13 @@ > {{ questionContentPlaceholder$() }}

+ +
props.item.type !== AssessmentItemTypes.QTI || Boolean(parseError.value), ); - // Seed the editor refs from the parsed interactions (first interaction only). + /* + * Seed the editor refs from the parsed item (first interaction only). + * + * The body is seeded even when there is no interaction to edit. Such an item still has + * content — a converted question with nothing to answer carries its text there — and + * anything else the author can change, a hint, reassembles the whole item. Leaving the + * body unseeded would write an empty over that text. + */ + currentBodyXml.value = interactions.value[0]?.bodyXml ?? itemBodyXml.value; if (interactions.value.length > 0) { - currentBodyXml.value = interactions.value[0].bodyXml; currentResponseDeclarations.value = interactions.value[0].responseDeclarations; } @@ -203,6 +221,20 @@ currentResponseDeclarations.value = responseDeclarations; } + /** + * Whether this question offers hints at all, which is settled by what the item arrived + * with: only a question that already has them shows the section (product decision). + * + * Read once from the parsed item rather than from the live list, so removing the last + * hint does not take the section away while the author is still working in it. + */ + const hasHints = hints.value.length > 0; + + function onUpdateHints(newHints) { + editedHere = props.mode === 'edit'; + hints.value = newHints; + } + /** Errors the interaction editor reports about the state it holds. */ const errors = ref([]); @@ -239,6 +271,9 @@ unsupportedItemMessage$, onUpdateInteraction, onUpdateErrors, + hints, + hasHints, + onUpdateHints, }; }, @@ -325,6 +360,9 @@ } .question-card-body { + display: flex; + flex-direction: column; + gap: 16px; min-width: 0; padding: 10px var(--question-card-horizontal-padding) 16px; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue index 648881b723..68d4fcd265 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QuestionTypeSelector/index.vue @@ -3,7 +3,6 @@
, * title: import('vue').Ref, * language: import('vue').Ref, + * itemBodyXml: import('vue').Ref, * interactions: import('vue').Ref>, + * hints: import('vue').Ref>, * parseError: import('vue').Ref, * rawData: import('vue').ComputedRef, * }} @@ -27,6 +29,13 @@ export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = { const title = ref(''); const language = ref(''); const interactions = ref([]); + /** The item's `` as parsed, whether or not it holds an interaction. */ + const itemBodyXml = ref(''); + /** + * Hints belong to the item, not to any one interaction, so they live here beside + * identifier and title — mutable, and read back by the rawData computed below. + */ + const hints = ref([]); const parseError = ref(null); if (rawXml) { @@ -36,6 +45,8 @@ export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = { title.value = model.title; language.value = model.language; interactions.value = model.interactions; + itemBodyXml.value = model.itemBodyXml; + hints.value = model.hints; } catch (e) { parseError.value = e.message; } @@ -43,8 +54,8 @@ export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = { /** * Re-assembles the full QTI item XML whenever identifier, title, language, - * bodyXml, or responseDeclarations change. Only available when the caller - * passes in bodyXml and responseDeclarations refs. + * hints, bodyXml, or responseDeclarations change. The interaction parts are + * only present when the caller passes in bodyXml and responseDeclarations refs. */ const rawData = computed(() => assembleItemXml({ @@ -53,8 +64,18 @@ export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = { language: language.value, bodyXml: bodyXml?.value ?? '', responseDeclarations: responseDeclarations?.value ?? [], + hints: hints.value, }), ); - return { identifier, title, language, interactions, parseError, rawData }; + return { + identifier, + title, + language, + itemBodyXml, + interactions, + hints, + parseError, + rawData, + }; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Editor.vue index 2bcce21a02..dcdd239ba5 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Editor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Editor.vue @@ -19,12 +19,12 @@ {{ errorPromptRequired$() }} -
{{ questionLabel$() }} -
+
{{ errorTooManyCorrectAnswers$() }} -
{{ answersLabel$() }} -
+
{{ errorPromptRequired$() }} -
{{ questionLabel$() }} -
+
-
{{ correctOrderLabel$() }} -
+
{{ errorPromptRequired$() }} -
{{ questionLabel$() }} -
+
{{ acceptableAnswersLabel$() }} -
+
{ }); }); +describe('a converted question with nothing to answer', () => { + // The conversion gives an answerless legacy question a body and no interaction, so there is + // no interaction editor to hold that body. Reassembling the item has to take it from the + // item itself, or the only edit available — a hint — writes an empty body over the question. + const original = read('single_selection_no_answers'); + + it('is read with no interaction but with its body', () => { + const item = parseItem(original); + expect(item.interactions).toHaveLength(0); + expect(item.itemBodyXml).toContain('What is 2+2?'); + }); + + it('keeps that body when reassembled', () => { + const item = parseItem(original); + const xml = assembleItemXml({ + identifier: item.identifier, + title: item.title, + language: item.language, + bodyXml: item.interactions[0]?.bodyXml ?? item.itemBodyXml, + responseDeclarations: [], + hints: item.hints, + }); + expect(xml).toContain('What is 2+2?'); + expect(xml).not.toContain(''); + }); +}); + describe('an item this editor wrote', () => { it('keeps xml:lang, the spelling this editor uses', () => { const xml = assembleItemXml({ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/hints.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/hints.spec.js new file mode 100644 index 0000000000..e69d3e8810 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/hints.spec.js @@ -0,0 +1,187 @@ +// Disabled because jest-dom's matchers are built for HTML elements and misbehave on the +// strict XML nodes this serialization produces - same reason as assembleItem.spec.js. +/* eslint-disable jest-dom/prefer-to-have-attribute */ +import { parseItem } from '../parseItem'; +import { assembleItemXml } from '../assembleItem'; +import { parseHints, hintHasContent, HINT_SUPPORT } from '../hints'; +import { parseXML } from '../xml'; +import { + CHOICE_ITEM_DOCUMENT_WITH_HINTS, + VALID_CHOICE_ITEM_DOCUMENT, +} from '../../utils/testingFixtures'; + +const hintContents = doc => parseHints(parseXML(doc)).map(h => h.content); + +describe('parseHints', () => { + it('reads every hint card, in document order', () => { + expect(hintContents(CHOICE_ITEM_DOCUMENT_WITH_HINTS)).toEqual([ + '

test

', + '

test2 2

', + '

test3 3

', + ]); + }); + + it('returns nothing for an item with no catalog', () => { + expect(hintContents(VALID_CHOICE_ITEM_DOCUMENT)).toEqual([]); + }); + + it('ignores cards that carry some other support value', () => { + const doc = ` +

no

+
`; + expect(hintContents(doc)).toEqual([]); + }); + + it('reads a hint card that carries no content as empty', () => { + const doc = ` + `; + expect(hintContents(doc)).toEqual(['']); + }); + + it('gives each hint an id so a list can key on it', () => { + const ids = parseHints(parseXML(CHOICE_ITEM_DOCUMENT_WITH_HINTS)).map(h => h.id); + expect(new Set(ids).size).toBe(3); + }); +}); + +describe('hintHasContent', () => { + it.each([ + ['

text

', true], + ['plain text', true], + ['', false], + [' ', false], + ['

', false], + ['

 

', false], + // A hint can be entirely an image or a formula — from a converted Perseus hint, or + // from the editor's own image and formula buttons. Reading only the text would drop it. + ['

', true], + ['

', true], + ['

x

', true], + ['

', true], + ['

see

', true], + ])('%s -> %s', (content, expected) => { + expect(hintHasContent({ content })).toBe(expected); + }); +}); + +describe('parseItem', () => { + it('returns hints beside the interactions rather than inside them', () => { + const item = parseItem(CHOICE_ITEM_DOCUMENT_WITH_HINTS); + expect(item.hints.map(h => h.content)).toEqual([ + '

test

', + '

test2 2

', + '

test3 3

', + ]); + expect(item.interactions[0]).not.toHaveProperty('hints'); + }); + + it('returns an empty hint list for an item without any', () => { + expect(parseItem(VALID_CHOICE_ITEM_DOCUMENT).hints).toEqual([]); + }); +}); + +describe('assembleItemXml with hints', () => { + const BASE = { + identifier: 'item-1', + title: 'T', + language: 'en', + bodyXml: '', + responseDeclarations: [], + }; + + it('writes a catalog of hint cards', () => { + const xml = assembleItemXml({ + ...BASE, + hints: [ + { id: 'a', content: '

first

' }, + { id: 'b', content: '

second

' }, + ], + }); + const doc = parseXML(xml); + expect(doc.querySelector('parsererror')).toBeNull(); + expect(doc.querySelector('qti-catalog').getAttribute('id')).toBe('kolibri-hints'); + const cards = [...doc.querySelectorAll('qti-card')]; + expect(cards.map(c => c.getAttribute('support'))).toEqual([HINT_SUPPORT, HINT_SUPPORT]); + expect(parseHints(doc).map(h => h.content)).toEqual(['

first

', '

second

']); + }); + + it('puts the catalog after the item body, which the schema requires', () => { + const xml = assembleItemXml({ ...BASE, hints: [{ id: 'a', content: '

x

' }] }); + expect(xml.indexOf(' { + expect(assembleItemXml({ ...BASE, hints: [] })).not.toContain('qti-catalog-info'); + expect(assembleItemXml(BASE)).not.toContain('qti-catalog-info'); + }); + + it('keeps a hint that is only an image', () => { + const xml = assembleItemXml({ + ...BASE, + hints: [{ id: 'a', content: '

' }], + }); + expect(parseHints(parseXML(xml)).map(h => h.content)).toEqual([ + '

', + ]); + }); + + it('leaves out a hint the author has not written yet', () => { + const xml = assembleItemXml({ + ...BASE, + hints: [ + { id: 'a', content: '

kept

' }, + { id: 'b', content: '' }, + ], + }); + expect([...parseXML(xml).querySelectorAll('qti-card')]).toHaveLength(1); + }); + + it('writes no catalog at all when every hint is empty', () => { + const xml = assembleItemXml({ ...BASE, hints: [{ id: 'a', content: '' }] }); + expect(xml).not.toContain('qti-catalog-info'); + }); + + it('leaves no xhtml namespace on hint markup', () => { + const xml = assembleItemXml({ ...BASE, hints: [{ id: 'a', content: '

x

' }] }); + expect(xml).toContain('

x

'); + }); +}); + +describe('hint round trip', () => { + it('survives parseItem -> assembleItemXml unchanged', () => { + const item = parseItem(CHOICE_ITEM_DOCUMENT_WITH_HINTS); + const xml = assembleItemXml({ + identifier: item.identifier, + title: item.title, + language: item.language, + bodyXml: item.interactions[0].bodyXml, + responseDeclarations: item.interactions[0].responseDeclarations, + hints: item.hints, + }); + + expect(parseItem(xml).hints.map(h => h.content)).toEqual([ + '

test

', + '

test2 2

', + '

test3 3

', + ]); + }); + + it('keeps an edited hint', () => { + const item = parseItem(CHOICE_ITEM_DOCUMENT_WITH_HINTS); + const hints = item.hints.map((h, i) => (i === 1 ? { ...h, content: '

rewritten

' } : h)); + const xml = assembleItemXml({ + identifier: item.identifier, + title: item.title, + language: item.language, + bodyXml: item.interactions[0].bodyXml, + responseDeclarations: item.interactions[0].responseDeclarations, + hints, + }); + + expect(parseItem(xml).hints.map(h => h.content)).toEqual([ + '

test

', + '

rewritten

', + '

test3 3

', + ]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js index 79d0cc59c9..91406eb61b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js @@ -8,6 +8,7 @@ * (e.g. XMLSerializer.serializeToString). */ +import { HINT_CATALOG_ID, HINT_SUPPORT, hintHasContent } from './hints'; import { parseXML } from './xml'; const xmlDoc = new DOMParser().parseFromString('', 'text/xml'); @@ -110,6 +111,35 @@ export function buildXmlNode({ tag, attrs = {}, children, innerHTML }) { return el; } +/** + * Build the `` holding the item's hints, or null when there is nothing + * to write. A catalog has to hold at least one card, so an item whose hints are all empty + * carries no catalog at all rather than an empty one. + * + * @param {Array<{ content: string }>} hints + * @returns {Element|null} + */ +function buildHintCatalogNode(hints) { + const cards = hints.filter(hintHasContent).map(hint => + buildXmlNode({ + tag: 'qti-card', + attrs: { support: HINT_SUPPORT }, + children: [buildXmlNode({ tag: 'qti-html-content', innerHTML: hint.content })], + }), + ); + + if (!cards.length) { + return null; + } + + return buildXmlNode({ + tag: 'qti-catalog-info', + children: [ + buildXmlNode({ tag: 'qti-catalog', attrs: { id: HINT_CATALOG_ID }, children: cards }), + ], + }); +} + /** The scoring outcome every item carries, matching what the legacy conversion emits. */ function buildOutcomeDeclarationNode() { return buildXmlNode({ @@ -152,6 +182,7 @@ function buildResponseProcessingNode(declarationCount) { * @param {string} params.language - Language tag, or '' to omit it * @param {string} params.bodyXml - Serialized interaction element XML string * @param {string[]} params.responseDeclarations - Array of serialized declaration XML strings + * @param {Array<{ content: string }>} [params.hints] - Item hints, in order * @returns {string} Full QTI XML string */ export function assembleItemXml({ @@ -160,6 +191,7 @@ export function assembleItemXml({ language, bodyXml, responseDeclarations, + hints = [], }) { // Parse each serialized declaration string back into a DOM node so it can be // adopted into the assessment item tree via buildXmlNode's importNode logic. @@ -178,6 +210,7 @@ export function assembleItemXml({ children: [bodyRoot], }); + const catalogInfoNode = buildHintCatalogNode(hints); const responseProcessingNode = buildResponseProcessingNode(declNodes.length); const assessmentItemNode = buildXmlNode({ @@ -194,11 +227,12 @@ export function assembleItemXml({ // item without one. 'xml:lang': language || null, }, - // The schema fixes this order: declarations, the body, then the processing. + // The schema fixes this order: declarations, the body, the catalog, the processing. children: [ ...declNodes, buildOutcomeDeclarationNode(), itemBodyNode, + ...(catalogInfoNode ? [catalogInfoNode] : []), ...(responseProcessingNode ? [responseProcessingNode] : []), ], }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/hints.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/hints.js new file mode 100644 index 0000000000..a1be30ea32 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/hints.js @@ -0,0 +1,83 @@ +/** + * Hints, which QTI has no element of its own for. + * + * A legacy question's hints are carried in the item's `` — dormant + * content the delivery engine never renders on its own — as cards tagged with a Kolibri + * support value. + * + * + * + * + *

Try halving it first

+ *
+ *
+ *
+ */ + +import { generateRandomSlug } from '../utils/generateRandomSlug'; +import { stripTags } from '../utils/stripTags'; + +/** The catalog this editor writes hints into. */ +export const HINT_CATALOG_ID = 'kolibri-hints'; + +/** The support value that marks a card as a hint. Mirrors qti/catalog.py. */ +export const HINT_SUPPORT = 'ext:kolibri-hint'; + +/** + * The item's own namespace, which its content inherits from the root and therefore does + * not declare. Serializing a subtree on its own re-declares it on every top-level + * element, so reading a card's markup back out of the document reintroduces a + * declaration that was never in the stored XML. Dropped by value rather than by pattern, + * so a foreign namespace a hint legitimately carries — MathML from the formula button — + * is left alone. + */ +const QTI_NAMESPACE_DECLARATION = / xmlns="http:\/\/www\.imsglobal\.org\/xsd\/imsqtiasi_v3p0"/g; + +/** + * Read the item's hints, in document order. + * + * Cards are matched on their support value rather than the catalog they sit in, the same + * way the publish-side derivation does — a catalog id is a name, the support value is the + * contract. `id` is generated here for list keys and is not part of the XML. + * + * @param {Document} doc - Parsed assessment item document + * @returns {Array<{ id: string, content: string }>} + */ +export function parseHints(doc) { + const cards = [...doc.querySelectorAll(`qti-card[support="${HINT_SUPPORT}"]`)]; + + return cards.map(card => { + const htmlContent = card.querySelector('qti-html-content'); + return { + id: generateRandomSlug('hint'), + // Pretty-printed XML puts the card's indentation inside the element, and the + // editor would otherwise open on a stray blank line. + content: htmlContent + ? htmlContent.innerHTML.replace(QTI_NAMESPACE_DECLARATION, '').trim() + : '', + }; + }); +} + +/** Markup that is content in its own right, with no text to find. */ +const EMBEDDED_MEDIA = /<(img|math|svg)\b/i; + +/** + * Whether a hint holds anything worth writing. + * + * An empty card is schema-valid but says nothing, so a hint the author has not written + * yet stays in the editor without reaching the item — the same log-and-skip rule the + * legacy conversion applies to a hint with no text. + * + * A hint can say something without saying it in words: an image, or a formula from the + * editor's own formula button, is the whole hint. Reading only the text would drop those + * on the next save, and show them as the empty-hint placeholder in the meantime. + * + * @param {{ content: string }} hint + * @returns {boolean} + */ +export function hintHasContent(hint) { + const content = hint.content || ''; + const text = stripTags(content).replace(/ /g, ' '); + return text.trim().length > 0 || EMBEDDED_MEDIA.test(content); +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js index f0522366ff..f856eec160 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js @@ -1,5 +1,6 @@ import { QTI_INTERACTION_TAGS } from '../constants'; import { isInlineInteraction } from '../interactions/descriptors'; +import { parseHints } from './hints'; import { parseXML } from './xml'; const serializer = new XMLSerializer(); @@ -15,12 +16,19 @@ const serializer = new XMLSerializer(); * `` as its `bodyXml` rather than the interaction element alone, * so its parse() can recover prompt content from body siblings. * + * Hints belong to the item rather than to any one interaction, so they come back + * alongside `interactions` rather than inside them. So does the body: an item can have + * content and no interaction — a converted question with nothing to answer keeps its text + * there — and a caller that reassembles the item needs it whether or not it found one. + * * @param {string} rawData - Raw QTI XML string (the full assessment item XML) * @returns {{ * identifier: string, * title: string, * language: string, - * interactions: Array<{ bodyXml: string, responseDeclarations: string[] }> + * itemBodyXml: string, + * interactions: Array<{ bodyXml: string, responseDeclarations: string[] }>, + * hints: Array<{ id: string, content: string }> * }} */ export function parseItem(rawData) { @@ -64,5 +72,12 @@ export function parseItem(rawData) { } } - return { identifier, title, language, interactions }; + return { + identifier, + title, + language, + itemBodyXml: body ? serializer.serializeToString(body) : '', + interactions, + hints: parseHints(doc), + }; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js index c59c696dfe..0cd93d610d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js @@ -205,6 +205,82 @@ export const NO_INTERACTION_ITEM_DOCUMENT = ` `; +/** + * A converted legacy question, whose hints the backend carries in a catalog. Indented as + * the API serves it, so the parse has to cope with the whitespace inside each card. + */ +export const CHOICE_ITEM_DOCUMENT_WITH_HINTS = ` + + + + choice-a + + + + + + Pick one. + A + B + + + + + + +

test

+
+
+ + +

test2 2

+
+
+ + +

test3 3

+
+
+
+
+
`; + +/** + * A converted question with nothing to answer, which still carries its text in the body and + * hints in its catalog. No interaction means no interaction editor, so the only thing an + * author can edit here is a hint — and that must not cost the body. + */ +export const NO_INTERACTION_ITEM_WITH_HINTS = ` + + +

What is the capital of France?

+
+ + + +

It is on the Seine

+
+ +

Starts with a P

+
+
+
+
`; + export const TWO_INTERACTIONS_DOCUMENT = ` \n' + '' + '' + "choice-a" + "" +) + +_HINTED_ITEM_BODY = ( + "" + '' + "Pick one." + 'A' + 'B' + "" + "" +) + +_HINTED_ITEM_CATALOG = ( + '' + '' + "

test

" + '' + "

test2 2

" + '' + "

test3 3

" + "
" +) + +HINTED_EDITOR_ITEM = ( + _HINTED_ITEM_HEAD + + _HINTED_ITEM_BODY + + _HINTED_ITEM_CATALOG + + "
" +) + +CATALOG_BEFORE_BODY_ITEM = ( + _HINTED_ITEM_HEAD + + _HINTED_ITEM_CATALOG + + _HINTED_ITEM_BODY + + "
" +) + + +class HintedEditorItemTests(unittest.TestCase): + def test_accepts_hinted_item_from_editor(self): + result = validate_qti_item(HINTED_EDITOR_ITEM) + self.assertTrue(result.is_valid) + self.assertEqual(result.errors, []) + + def test_rejects_catalog_before_item_body(self): + result = validate_qti_item(CATALOG_BEFORE_BODY_ITEM) + self.assertFalse(result.is_valid) + self.assertIn("qti-item-body", result.errors[0].message) + + class SchemaReuseTests(unittest.TestCase): def test_schema_compiled_once_across_multiple_validate_calls(self): _compiled_schema.cache_clear()