qti 2.30.0 → 2.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5faafa20e505881d4e305e9346f24060883b8cf86b1861f08021f6def9329ea9
4
- data.tar.gz: 69bbc14fecc3909cfc905102433e2ae1631261cf56fcda2c13035a35583a9682
3
+ metadata.gz: 6e8fff7e4ff119789cc3fe6191731a48d6bcbf96939e2dec388284ab3701fd66
4
+ data.tar.gz: b0fd5f24cc43d4fb62c67a25320b03e7be63e95fa0a2fca78c840942ee8be44a
5
5
  SHA512:
6
- metadata.gz: 43f8a769c16f2a2671219c7f2f3b4e25a3c8321dfed55d17035d1b3eab2238dcbae93f8b669e4393971a31c9607cfb3f2ca78ba454538bcfd279673f2f271eae
7
- data.tar.gz: d51c5c66f01164d227c38ebb3098787096ce1b1257737ffe42ae70db5af39c8e09157b87c6469e7b66599803547c3bf92b6c0c9cacb667ef490e15bec86e27df
6
+ metadata.gz: 7d42f847359a65a74f030d45fdebc0de54fda395f8962f92ac98c37acb83a381c305e902d94c9d01a3f216403b73f8fb413f8cb079c273821a5c630b7045f780
7
+ data.tar.gz: c6ce3d5b3dc05f7315ef8011b0766d54d197b9fce52618bfe1809e959d853b363c2906e835299c289bede21816883f622c5b0b40c40b5ef48a06d7b90cf3936b
@@ -3,6 +3,13 @@ module Qti
3
3
  module Models
4
4
  module Interactions
5
5
  class BaseFillBlankInteraction < BaseInteraction
6
+ # A per-answer comment is carried by a respcondition of the same shape
7
+ # as a scoring one, so only the conditions that award points can be
8
+ # read as correct answers.
9
+ SCORING_VAREQUAL_XPATH =
10
+ './/xmlns:resprocessing/xmlns:respcondition[xmlns:setvar]' \
11
+ '/xmlns:conditionvar/xmlns:varequal'.freeze
12
+
6
13
  def canvas_stem_items(item_prompt)
7
14
  item_prompt = sanitize_attributes(item_prompt)
8
15
  item_prompt.split(CANVAS_BLANK_REGEX).map.with_index do |stem_item, index|
@@ -112,7 +119,7 @@ module Qti
112
119
  def correct_answers
113
120
  correct_answers = {}
114
121
 
115
- node.xpath('.//xmlns:varequal').each do |correct_answer|
122
+ node.xpath(SCORING_VAREQUAL_XPATH).each do |correct_answer|
116
123
  correct_answers[correct_answer.attributes['respident']&.value] = correct_answer.text
117
124
  end
118
125
 
@@ -120,7 +127,7 @@ module Qti
120
127
  end
121
128
 
122
129
  def correct_choice_id(blank)
123
- node.xpath('.//xmlns:resprocessing/xmlns:respcondition/xmlns:conditionvar/xmlns:varequal')
130
+ node.xpath(SCORING_VAREQUAL_XPATH)
124
131
  .find { |varequal| varequal.attribute('respident').value == blank[:id] }&.text
125
132
  end
126
133
 
@@ -76,9 +76,7 @@ module Qti
76
76
  end
77
77
 
78
78
  def answer_feedback
79
- path = './/xmlns:respcondition//xmlns:displayfeedback/../' \
80
- 'xmlns:conditionvar/xmlns:varequal[@respident]/../../' \
81
- 'xmlns:displayfeedback/..'
79
+ path = './/xmlns:respcondition[xmlns:displayfeedback][.//xmlns:varequal[@respident]]'
82
80
  answers = node.xpath(path).map do |entry|
83
81
  answer_feedback_entry(entry)
84
82
  end.compact
@@ -88,17 +86,30 @@ module Qti
88
86
  private
89
87
 
90
88
  def answer_feedback_entry(entry)
91
- ve = entry.xpath('.//xmlns:varequal').first
89
+ ve = entry.xpath('.//xmlns:varequal[@respident]').first
90
+ return nil unless ve
92
91
  refid = entry.xpath('./xmlns:displayfeedback[not (@linkrefid="correct_fb" or ' \
93
92
  '@linkrefid="general_incorrect_fb" or @linkrefid="general_fb")]').first&.[](:linkrefid)
94
93
  feedback = get_feedback(refid)
95
94
  return nil unless feedback
96
- {
95
+ answer = {
97
96
  response_id: ve[:respident],
98
97
  response_value: ve.text,
99
98
  texttype: feedback&.[](:texttype),
100
99
  feedback: feedback&.text
101
100
  }
101
+ answer[:negated] = true if negated_condition?(ve)
102
+ answer
103
+ end
104
+
105
+ # Matching is the one Canvas type whose comment is shown when the
106
+ # student did *not* pick that match, so its `varequal` sits inside a
107
+ # `not`. Report that, rather than making the consumer infer it from
108
+ # the question type -- third party QTI may emit either form for any
109
+ # type. Only set when true, so every other type keeps its old shape.
110
+ def negated_condition?(varequal)
111
+ varequal.ancestors.take_while { |a| a.name != 'respcondition' }
112
+ .count { |a| a.name == 'not' }.odd?
102
113
  end
103
114
 
104
115
  def get_feedback(ident)
data/lib/qti/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Qti
2
- VERSION = '2.30.0'.freeze
2
+ VERSION = '2.32.0'.freeze
3
3
  end
@@ -74,12 +74,6 @@
74
74
  </conditionvar>
75
75
  <displayfeedback feedbacktype="Response" linkrefid="general_fb"/>
76
76
  </respcondition>
77
- <respcondition continue="Yes">
78
- <conditionvar>
79
- <varequal respident="response_color1">6548</varequal>
80
- </conditionvar>
81
- <displayfeedback feedbacktype="Response" linkrefid="6548_fb"/>
82
- </respcondition>
83
77
  <respcondition continue="Yes">
84
78
  <conditionvar>
85
79
  <varequal respident="response_color1">5550</varequal>
@@ -88,9 +82,9 @@
88
82
  </respcondition>
89
83
  <respcondition continue="Yes">
90
84
  <conditionvar>
91
- <varequal respident="response_color2">6951</varequal>
85
+ <varequal respident="response_color1">6548</varequal>
92
86
  </conditionvar>
93
- <displayfeedback feedbacktype="Response" linkrefid="6951_fb"/>
87
+ <displayfeedback feedbacktype="Response" linkrefid="6548_fb"/>
94
88
  </respcondition>
95
89
  <respcondition continue="Yes">
96
90
  <conditionvar>
@@ -98,6 +92,12 @@
98
92
  </conditionvar>
99
93
  <displayfeedback feedbacktype="Response" linkrefid="4500_fb"/>
100
94
  </respcondition>
95
+ <respcondition continue="Yes">
96
+ <conditionvar>
97
+ <varequal respident="response_color2">6951</varequal>
98
+ </conditionvar>
99
+ <displayfeedback feedbacktype="Response" linkrefid="6951_fb"/>
100
+ </respcondition>
101
101
  <respcondition>
102
102
  <conditionvar>
103
103
  <varequal respident="response_color1">6548</varequal>
@@ -0,0 +1,156 @@
1
+ <?xml version="1.0" encoding="ISO-8859-1"?>
2
+ <questestinterop xmlns="http://www.imsglobal.org/xsd/ims_qtiasiv1p2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/ims_qtiasiv1p2 http://www.imsglobal.org/xsd/ims_qtiasiv1p2p1.xsd">
3
+ <item ident="267f458a6d7d3f8aad007d415587ec4f" title="NQ Multiple Dropdowns Answer Feedback">
4
+ <itemmetadata>
5
+ <qtimetadata>
6
+ <qtimetadatafield>
7
+ <fieldlabel>question_type</fieldlabel>
8
+ <fieldentry>fill_in_multiple_blanks_question</fieldentry>
9
+ </qtimetadatafield>
10
+ <qtimetadatafield>
11
+ <fieldlabel>points_possible</fieldlabel>
12
+ <fieldentry>1.0</fieldentry>
13
+ </qtimetadatafield>
14
+ <qtimetadatafield>
15
+ <fieldlabel>original_answer_ids</fieldlabel>
16
+ <fieldentry>["7c011c63-8e76-42eb-86ab-531cd03f98a4", "087e23e8-e032-43b2-958b-3eae421c3e49", "f1046a5d-c476-450f-98bb-33997446d0b0", "18cebc2e-c102-4b51-ac52-72412ecf4619", "ac8c8556-2eb3-45ac-8994-9a04eff74614", "625d984e-d1c3-45f1-bcab-f3f8a847d1f0"]</fieldentry>
17
+ </qtimetadatafield>
18
+ <qtimetadatafield>
19
+ <fieldlabel>assessment_question_identifierref</fieldlabel>
20
+ <fieldentry>cd0dd757adb72497669129244b26ae71</fieldentry>
21
+ </qtimetadatafield>
22
+ </qtimetadata>
23
+ </itemmetadata>
24
+ <presentation>
25
+ <material>
26
+ <mattext texttype="text/html">&lt;div&gt;&lt;p&gt;&lt;span&gt;Roses are [68857282-90bc-4881-83b0-e811ab0502a6], violets are [6702826b-84db-422a-bdd5-f14e759fcb5e] mega [4fc61c7b-83f8-4eba-ae21-747fb76f6ae9]&lt;/span&gt;&lt;/p&gt;&lt;/div&gt;</mattext>
27
+ </material>
28
+ <response_lid ident="response_68857282-90bc-4881-83b0-e811ab0502a6">
29
+ <material>
30
+ <mattext>aaa1</mattext>
31
+ </material>
32
+ <render_choice>
33
+ <response_label scoring_algorithm="Equivalence" answer_type="dropdown" ident="7c011c63-8e76-42eb-86ab-531cd03f98a4" position="1">
34
+ <material>
35
+ <mattext texttype="text/plain">aaa1</mattext>
36
+ </material>
37
+ </response_label>
38
+ <response_label scoring_algorithm="Equivalence" answer_type="dropdown" ident="087e23e8-e032-43b2-958b-3eae421c3e49" position="2">
39
+ <material>
40
+ <mattext texttype="text/plain">bbb1</mattext>
41
+ </material>
42
+ </response_label>
43
+ </render_choice>
44
+ </response_lid>
45
+ <response_lid ident="response_6702826b-84db-422a-bdd5-f14e759fcb5e">
46
+ <material>
47
+ <mattext>aaa2</mattext>
48
+ </material>
49
+ <render_choice>
50
+ <response_label scoring_algorithm="Equivalence" answer_type="dropdown" ident="f1046a5d-c476-450f-98bb-33997446d0b0" position="1">
51
+ <material>
52
+ <mattext texttype="text/plain">aaa2</mattext>
53
+ </material>
54
+ </response_label>
55
+ <response_label scoring_algorithm="Equivalence" answer_type="dropdown" ident="18cebc2e-c102-4b51-ac52-72412ecf4619" position="2">
56
+ <material>
57
+ <mattext texttype="text/plain">bbb2</mattext>
58
+ </material>
59
+ </response_label>
60
+ </render_choice>
61
+ </response_lid>
62
+ <response_lid ident="response_4fc61c7b-83f8-4eba-ae21-747fb76f6ae9">
63
+ <material>
64
+ <mattext>aaa3</mattext>
65
+ </material>
66
+ <render_choice>
67
+ <response_label scoring_algorithm="Equivalence" answer_type="dropdown" ident="ac8c8556-2eb3-45ac-8994-9a04eff74614" position="1">
68
+ <material>
69
+ <mattext texttype="text/plain">aaa3</mattext>
70
+ </material>
71
+ </response_label>
72
+ <response_label scoring_algorithm="Equivalence" answer_type="dropdown" ident="625d984e-d1c3-45f1-bcab-f3f8a847d1f0" position="2">
73
+ <material>
74
+ <mattext texttype="text/plain">bbb3</mattext>
75
+ </material>
76
+ </response_label>
77
+ </render_choice>
78
+ </response_lid>
79
+ </presentation>
80
+ <resprocessing>
81
+ <outcomes>
82
+ <decvar maxvalue="100" minvalue="0" varname="SCORE" vartype="Decimal"/>
83
+ </outcomes>
84
+ <respcondition>
85
+ <conditionvar>
86
+ <varequal respident="response_68857282-90bc-4881-83b0-e811ab0502a6">7c011c63-8e76-42eb-86ab-531cd03f98a4</varequal>
87
+ </conditionvar>
88
+ <setvar action="Add" varname="SCORE">33.33</setvar>
89
+ </respcondition>
90
+ <respcondition>
91
+ <conditionvar>
92
+ <varequal respident="response_6702826b-84db-422a-bdd5-f14e759fcb5e">f1046a5d-c476-450f-98bb-33997446d0b0</varequal>
93
+ </conditionvar>
94
+ <setvar action="Add" varname="SCORE">33.33</setvar>
95
+ </respcondition>
96
+ <respcondition>
97
+ <conditionvar>
98
+ <varequal respident="response_4fc61c7b-83f8-4eba-ae21-747fb76f6ae9">ac8c8556-2eb3-45ac-8994-9a04eff74614</varequal>
99
+ </conditionvar>
100
+ <setvar action="Add" varname="SCORE">33.33</setvar>
101
+ </respcondition>
102
+ <respcondition continue="Yes">
103
+ <conditionvar>
104
+ <varequal respident="response_6702826b-84db-422a-bdd5-f14e759fcb5e">f1046a5d-c476-450f-98bb-33997446d0b0</varequal>
105
+ </conditionvar>
106
+ <displayfeedback feedbacktype="Response" linkrefid="f1046a5d-c476-450f-98bb-33997446d0b0_fb"/>
107
+ </respcondition>
108
+ <respcondition continue="Yes">
109
+ <conditionvar>
110
+ <varequal respident="response_4fc61c7b-83f8-4eba-ae21-747fb76f6ae9">ac8c8556-2eb3-45ac-8994-9a04eff74614</varequal>
111
+ </conditionvar>
112
+ <displayfeedback feedbacktype="Response" linkrefid="ac8c8556-2eb3-45ac-8994-9a04eff74614_fb"/>
113
+ </respcondition>
114
+ <respcondition continue="Yes">
115
+ <conditionvar>
116
+ <varequal respident="response_6702826b-84db-422a-bdd5-f14e759fcb5e">18cebc2e-c102-4b51-ac52-72412ecf4619</varequal>
117
+ </conditionvar>
118
+ <displayfeedback feedbacktype="Response" linkrefid="18cebc2e-c102-4b51-ac52-72412ecf4619_fb"/>
119
+ </respcondition>
120
+ <respcondition continue="Yes">
121
+ <conditionvar>
122
+ <varequal respident="response_4fc61c7b-83f8-4eba-ae21-747fb76f6ae9">625d984e-d1c3-45f1-bcab-f3f8a847d1f0</varequal>
123
+ </conditionvar>
124
+ <displayfeedback feedbacktype="Response" linkrefid="625d984e-d1c3-45f1-bcab-f3f8a847d1f0_fb"/>
125
+ </respcondition>
126
+ </resprocessing>
127
+ <itemfeedback ident="f1046a5d-c476-450f-98bb-33997446d0b0_fb">
128
+ <flow_mat>
129
+ <material>
130
+ <mattext texttype="text/html">&lt;p&gt;feedback for aaa2&lt;/p&gt;</mattext>
131
+ </material>
132
+ </flow_mat>
133
+ </itemfeedback>
134
+ <itemfeedback ident="ac8c8556-2eb3-45ac-8994-9a04eff74614_fb">
135
+ <flow_mat>
136
+ <material>
137
+ <mattext texttype="text/html">&lt;p&gt;feedback for blank3 aaa&lt;/p&gt;</mattext>
138
+ </material>
139
+ </flow_mat>
140
+ </itemfeedback>
141
+ <itemfeedback ident="18cebc2e-c102-4b51-ac52-72412ecf4619_fb">
142
+ <flow_mat>
143
+ <material>
144
+ <mattext texttype="text/html">&lt;p&gt;feedback for bbb2&lt;/p&gt;</mattext>
145
+ </material>
146
+ </flow_mat>
147
+ </itemfeedback>
148
+ <itemfeedback ident="625d984e-d1c3-45f1-bcab-f3f8a847d1f0_fb">
149
+ <flow_mat>
150
+ <material>
151
+ <mattext texttype="text/html">&lt;p&gt;feedback for blank3 bbb&lt;/p&gt;</mattext>
152
+ </material>
153
+ </flow_mat>
154
+ </itemfeedback>
155
+ </item>
156
+ </questestinterop>
@@ -59,6 +59,33 @@ describe Qti::V1::Models::Interactions::BaseFillBlankInteraction do
59
59
  end
60
60
 
61
61
  include_examples 'canvas_fib_responses'
62
+
63
+ # The item comments a wrong choice before the correct one, and canvas emits
64
+ # those comments ahead of the scoring conditions.
65
+ it 'names each blank after its correct choice, not a commented one' do
66
+ expect(loaded_class.blank_value('response_color1')).to eq('red')
67
+ expect(loaded_class.blank_value('response_color2')).to eq('blue')
68
+ end
69
+ end
70
+
71
+ context 'nq_multiple_dropdowns_answer_feedback.xml' do
72
+ let(:file_path) { File.join(fixtures_path, 'nq_multiple_dropdowns_answer_feedback.xml') }
73
+
74
+ # The item comments a wrong choice in the second and third blank after the
75
+ # correct one, and new quizzes emits those comments after the scoring
76
+ # conditions.
77
+ it 'maps each blank to its correct choice, not to a commented one' do
78
+ expect(loaded_class.correct_answer_map).to eq(
79
+ 'response_68857282-90bc-4881-83b0-e811ab0502a6' => '7c011c63-8e76-42eb-86ab-531cd03f98a4',
80
+ 'response_6702826b-84db-422a-bdd5-f14e759fcb5e' => 'f1046a5d-c476-450f-98bb-33997446d0b0',
81
+ 'response_4fc61c7b-83f8-4eba-ae21-747fb76f6ae9' => 'ac8c8556-2eb3-45ac-8994-9a04eff74614'
82
+ )
83
+ end
84
+
85
+ it 'names each blank after its correct choice' do
86
+ expect(loaded_class.blank_value('response_6702826b-84db-422a-bdd5-f14e759fcb5e')).to eq('aaa2')
87
+ expect(loaded_class.blank_value('response_4fc61c7b-83f8-4eba-ae21-747fb76f6ae9')).to eq('aaa3')
88
+ end
62
89
  end
63
90
 
64
91
  context 'canvas_multiple_fib.xml' do
@@ -110,4 +110,266 @@ describe Qti::V1::Models::Interactions::BaseInteraction do
110
110
  expect(Qti::V1::Models::Interactions::BaseInteraction.new_quizzes_fib?(assessment_item_refs.first)).to eq false
111
111
  end
112
112
  end
113
+
114
+ # The respcondition lookup in #answer_feedback is shared by every v1
115
+ # interaction, so relaxing it to reach matching's negated varequal has to
116
+ # leave the other types untouched. This fixture carries one question of each
117
+ # Canvas type, all of them fully commented.
118
+ context 'all_canvas_simple_1.2.xml' do
119
+ let(:file_path) { File.join('spec', 'fixtures', 'all_canvas_simple_1.2.xml') }
120
+
121
+ def answer_feedback_for(index)
122
+ described_class.new(assessment_item_refs[index], test_object).answer_feedback&.map do |fb|
123
+ [fb[:response_id], fb[:response_value], fb[:negated], fb[:feedback]]
124
+ end
125
+ end
126
+
127
+ it 'extracts multiple choice feedback per chosen answer' do
128
+ expect(answer_feedback_for(0)).to eq(
129
+ [
130
+ ['response1', '2799', nil, '<p>Answer feedback for C, correct</p>'],
131
+ ['response1', '9607', nil, '<p>Answer feedback for A, incorrect</p>'],
132
+ ['response1', '599', nil, '<p>Answer feedback for B, incorrect</p>'],
133
+ ['response1', '5711', nil, '<p>Answer feedback for D, incorrect</p>']
134
+ ]
135
+ )
136
+ end
137
+
138
+ it 'extracts true/false feedback per chosen answer' do
139
+ expect(answer_feedback_for(1)).to eq(
140
+ [
141
+ ['response1', '919', nil, '<p>Answer Feedback, True, Correct</p>'],
142
+ ['response1', '2680', nil, '<p>Answer Feedback, False, Incorrect</p>']
143
+ ]
144
+ )
145
+ end
146
+
147
+ it 'extracts short answer feedback per chosen answer' do
148
+ expect(answer_feedback_for(2)).to eq(
149
+ [
150
+ ['response1', 'Blank', nil, '<p>Answer Feedback, Correct, Blank</p>'],
151
+ ['response1', 'blank', nil, '<p>Answer Feedback, Correct, blank</p>']
152
+ ]
153
+ )
154
+ end
155
+
156
+ it 'extracts fill in multiple blanks feedback per blank and chosen answer' do
157
+ expect(answer_feedback_for(3)).to eq(
158
+ [
159
+ ['response_Multiple', '832', nil, '<p>Answer Feedback, correct, Multiple</p>'],
160
+ ['response_Multiple', '2173', nil, '<p>Answer Feedback, correct, multiple</p>'],
161
+ ['response_Blanks', '9226', nil, '<p>Answer Feedback, correct, Blanks</p>'],
162
+ ['response_Blanks', '1700', nil, '<p>Answer Feedback, correct, blanks</p>']
163
+ ]
164
+ )
165
+ end
166
+
167
+ it 'extracts multiple answers feedback per chosen answer' do
168
+ expect(answer_feedback_for(4)).to eq(
169
+ [
170
+ ['response1', '4094', nil, '<p>Answer Feedback, correct, A</p>'],
171
+ ['response1', '8128', nil, '<p>Answer Feedback, correct, B</p>'],
172
+ ['response1', '1582', nil, '<p>Answer Feedback, correct, C</p>'],
173
+ ['response1', '7498', nil, '<p>Answer Feedback, incorrect, D</p>'],
174
+ ['response1', '141', nil, '<p>Answer Feedback, incorrect, E</p>'],
175
+ ['response1', '3202', nil, '<p>Answer Feedback, incorrect, F</p>']
176
+ ]
177
+ )
178
+ end
179
+
180
+ it 'extracts multiple dropdowns feedback per chosen answer' do
181
+ expect(answer_feedback_for(5)).to eq(
182
+ [
183
+ ['response_C', '520', nil, '<p>Answer Feedback, correct, C</p>'],
184
+ ['response_C', '9701', nil, '<p>Answer Feedback, incorrect, D</p>']
185
+ ]
186
+ )
187
+ end
188
+
189
+ it 'extracts matching feedback per left hand item, marked as negated' do
190
+ expect(answer_feedback_for(6)).to eq(
191
+ [
192
+ ['response_2030', '6823', true, '<p>Answer Feedback, A:1 Chosen</p>'],
193
+ ['response_1293', '4469', true, '<p>Answer Feedback, B:2 Chosen</p>']
194
+ ]
195
+ )
196
+ end
197
+
198
+ it 'returns nil for the types that carry no answer level feedback' do
199
+ expect(answer_feedback_for(8)).to be_nil
200
+ expect(answer_feedback_for(9)).to be_nil
201
+ end
202
+
203
+ # The flag is additive: an entry that is not negated keeps exactly the keys
204
+ # it had before matching was supported, so a consumer pinned to an older
205
+ # gem sees no change.
206
+ it 'only adds the negated key to the negated entries' do
207
+ keys_by_item = (0..7).map do |index|
208
+ entries = described_class.new(assessment_item_refs[index], test_object).answer_feedback || []
209
+ entries.map(&:keys).uniq
210
+ end
211
+ expect(keys_by_item).to eq(
212
+ [
213
+ [%i[response_id response_value texttype feedback]],
214
+ [%i[response_id response_value texttype feedback]],
215
+ [%i[response_id response_value texttype feedback]],
216
+ [%i[response_id response_value texttype feedback]],
217
+ [%i[response_id response_value texttype feedback]],
218
+ [%i[response_id response_value texttype feedback]],
219
+ [%i[response_id response_value texttype feedback negated]],
220
+ [%i[response_id response_value texttype feedback]]
221
+ ]
222
+ )
223
+ end
224
+ end
225
+
226
+ # Question 4 has no question_type metadata and more than one response_lid, so
227
+ # it dispatches to MatchInteraction while actually being fill in multiple
228
+ # blanks -- and its feedback is on the chosen answer, not a wrong pairing.
229
+ # Deriving negated per entry rather than per question type is what keeps this
230
+ # one, and third party packages like it, correct.
231
+ context 'interaction_checks_1.2.xml' do
232
+ let(:file_path) { File.join('spec', 'fixtures', 'interaction_checks_1.2.xml') }
233
+
234
+ it 'does not mark a non-negated question as negated, whatever it dispatches to' do
235
+ item = assessment_item_refs[3]
236
+ expect(Qti::V1::Models::AssessmentItem.new(item).interaction_model)
237
+ .to be_a(Qti::V1::Models::Interactions::MatchInteraction)
238
+ expect(described_class.new(item, test_object).answer_feedback.map { |fb| fb[:negated] })
239
+ .to all(be_nil)
240
+ end
241
+
242
+ it 'still marks the genuine matching question as negated' do
243
+ expect(described_class.new(assessment_item_refs[6], test_object).answer_feedback.map { |fb| fb[:negated] })
244
+ .to all(be true)
245
+ end
246
+ end
247
+
248
+ # NumericInteraction overrides #answer_feedback entirely (it pairs answers by
249
+ # setvar SCORE, not by varequal), so it never reaches the negated flag. Its
250
+ # entries are keyed on response_index and carry no negated key at all, which
251
+ # reads the same as not negated.
252
+ context 'numeric_answer_feedback.xml' do
253
+ let(:file_path) { File.join(fixtures_path, 'numeric_answer_feedback.xml') }
254
+
255
+ it 'does not report negated for numeric, which pairs answers differently' do
256
+ entries = Qti::V1::Models::AssessmentItem.new(assessment_item_refs.first).answer_feedback
257
+ expect(entries.map(&:keys).uniq).to eq([%i[response_index texttype feedback]])
258
+ expect(entries.map { |fb| fb[:negated] }).to all(be_nil)
259
+ end
260
+ end
261
+
262
+ # Ordering emits one respcondition holding a varequal per position, with a
263
+ # single comment for the whole correct sequence. It has always been reported
264
+ # through the first varequal and no spec covered it, so the relaxed xpath
265
+ # could have changed it unnoticed.
266
+ context 'ordering.xml' do
267
+ let(:file_path) { File.join(fixtures_path, 'ordering.xml') }
268
+
269
+ it 'keeps reporting the comment on the correct sequence through its first position' do
270
+ expect(loaded_class.answer_feedback).to eq(
271
+ [{ response_id: 'OB01', response_value: 'A',
272
+ texttype: nil, feedback: 'Yes, the correct order.' }]
273
+ )
274
+ end
275
+ end
276
+
277
+ # Codifies the corpus wide check behind the xpath relaxation: matching is the
278
+ # only shape in the whole fixture corpus that yields a negated entry. Guards
279
+ # against a future xpath change quietly pulling in another type.
280
+ context 'the whole fixture corpus' do
281
+ it 'only ever reports negated entries for matching questions' do
282
+ negated = Dir.glob(File.join('spec', 'fixtures', '**', '*.xml')).sort.filter_map do |path|
283
+ assessment = begin
284
+ Qti::V1::Models::Assessment.from_path!(path)
285
+ rescue StandardError
286
+ next
287
+ end
288
+ items = assessment.assessment_items
289
+ next if items.nil?
290
+
291
+ found = items.each_with_index.select do |item, _index|
292
+ entries = begin
293
+ described_class.new(item, assessment).answer_feedback
294
+ rescue StandardError
295
+ nil
296
+ end
297
+ entries&.any? { |fb| fb[:negated] }
298
+ end
299
+ next if found.empty?
300
+
301
+ [path.sub("spec#{File::SEPARATOR}fixtures#{File::SEPARATOR}", ''), found.map(&:last)]
302
+ end
303
+
304
+ expect(negated.to_h).to eq(
305
+ 'all_canvas_simple_1.2.xml' => [6],
306
+ 'interaction_checks_1.2.xml' => [6],
307
+ "items_1.2#{File::SEPARATOR}matching_feedback.xml" => [0]
308
+ )
309
+ end
310
+ end
311
+
312
+ # Shapes the relaxed xpath newly reaches. None occur in a Canvas export, but
313
+ # third party QTI 1.2 imports through the same code path.
314
+ context 'unusual respcondition shapes' do
315
+ let(:assessment) { double(path: 'dummy/blah', package_root: 'dummy') }
316
+
317
+ def interaction_for(conditionvar)
318
+ item = <<~XML
319
+ <questestinterop xmlns="http://www.imsglobal.org/xsd/ims_qtiasiv1p2">
320
+ <item ident="i1" title="Question">
321
+ <resprocessing>
322
+ <respcondition>
323
+ <conditionvar>#{conditionvar}</conditionvar>
324
+ <displayfeedback feedbacktype="Response" linkrefid="99_fb"/>
325
+ </respcondition>
326
+ </resprocessing>
327
+ <itemfeedback ident="99_fb">
328
+ <flow_mat>
329
+ <material><mattext texttype="text/html">&lt;p&gt;Comment&lt;/p&gt;</mattext></material>
330
+ </flow_mat>
331
+ </itemfeedback>
332
+ </item>
333
+ </questestinterop>
334
+ XML
335
+ described_class.new(Nokogiri.XML(item, &:noblanks).at_xpath('//xmlns:item'), assessment)
336
+ end
337
+
338
+ # A compound condition is reported through its first answer only. That is
339
+ # pre-existing behaviour -- ordering questions comment a whole correct
340
+ # sequence this way -- so it is pinned here rather than changed, and the
341
+ # negated flag follows the same first varequal.
342
+ it 'reports a compound condition through its first answer' do
343
+ interaction = interaction_for(
344
+ '<and><varequal respident="response1">111</varequal>' \
345
+ '<not><varequal respident="response1">222</varequal></not></and>'
346
+ )
347
+ expect(interaction.answer_feedback).to eq(
348
+ [{ response_id: 'response1', response_value: '111',
349
+ texttype: 'text/html', feedback: '<p>Comment</p>' }]
350
+ )
351
+ end
352
+
353
+ it 'ignores a varequal without a respident when picking the answer' do
354
+ interaction = interaction_for(
355
+ '<or><varequal>raw</varequal><varequal respident="response1">111</varequal></or>'
356
+ )
357
+ expect(interaction.answer_feedback).to eq(
358
+ [{ response_id: 'response1', response_value: '111',
359
+ texttype: 'text/html', feedback: '<p>Comment</p>' }]
360
+ )
361
+ end
362
+
363
+ it 'treats a doubly negated condition as not negated' do
364
+ interaction = interaction_for(
365
+ '<not><not><varequal respident="response1">111</varequal></not></not>'
366
+ )
367
+ expect(interaction.answer_feedback.first).not_to have_key(:negated)
368
+ end
369
+
370
+ it 'marks a singly negated condition as negated' do
371
+ interaction = interaction_for('<not><varequal respident="response1">111</varequal></not>')
372
+ expect(interaction.answer_feedback.first[:negated]).to be true
373
+ end
374
+ end
113
375
  end
@@ -96,5 +96,45 @@ describe Qti::V1::Models::Interactions::MatchInteraction do
96
96
  it 'does not have any scoring algorithm' do
97
97
  expect(subject.scoring_algorithm).to eq nil
98
98
  end
99
+
100
+ describe '#answer_feedback' do
101
+ it 'returns one entry per commented left hand item, keyed on its response id' do
102
+ expect(subject.answer_feedback).to eq(
103
+ [
104
+ { response_id: 'response_6831', response_value: '6753', negated: true,
105
+ texttype: 'text/html', feedback: '<p>A:1</p>' },
106
+ { response_id: 'response_6259', response_value: '7138', negated: true,
107
+ texttype: 'text/html', feedback: '<p>B:2</p>' }
108
+ ]
109
+ )
110
+ end
111
+
112
+ it 'marks the entries as negated, since Classic shows them on a wrong pairing' do
113
+ expect(subject.answer_feedback.map { |fb| fb[:negated] }).to all(be true)
114
+ end
115
+
116
+ it 'keys every entry on a question that exists' do
117
+ expect(subject.answer_feedback.map { |fb| fb[:response_id] })
118
+ .to all(be_in(subject.questions.map { |q| q[:id] }))
119
+ end
120
+
121
+ it 'skips the left hand items without a comment' do
122
+ expect(subject.answer_feedback.map { |fb| fb[:response_id] })
123
+ .not_to include('response_743', 'response_1943')
124
+ end
125
+
126
+ it 'ignores the item level feedback' do
127
+ expect(subject.answer_feedback.map { |fb| fb[:feedback] })
128
+ .not_to include('<p>Neutral</p>', '<p>Correct</p>', '<p>Incorrect</p>')
129
+ end
130
+ end
131
+ end
132
+
133
+ context 'matching.xml without answer feedback' do
134
+ let(:file) { File.join(path, 'matching.xml') }
135
+
136
+ it 'returns nil' do
137
+ expect(subject.answer_feedback).to be_nil
138
+ end
99
139
  end
100
140
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: qti
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.30.0
4
+ version: 2.32.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Diaz
@@ -12,7 +12,7 @@ authors:
12
12
  autorequire:
13
13
  bindir: bin
14
14
  cert_chain: []
15
- date: 2026-09-07 00:00:00.000000000 Z
15
+ date: 2026-09-23 00:00:00.000000000 Z
16
16
  dependencies:
17
17
  - !ruby/object:Gem::Dependency
18
18
  name: actionview
@@ -409,6 +409,7 @@ files:
409
409
  - spec/fixtures/items_1.2/multiple_answer.xml
410
410
  - spec/fixtures/items_1.2/multiple_answer_canvas.xml
411
411
  - spec/fixtures/items_1.2/multiple_choice.xml
412
+ - spec/fixtures/items_1.2/nq_multiple_dropdowns_answer_feedback.xml
412
413
  - spec/fixtures/items_1.2/nq_multiple_fib_answer_types.xml
413
414
  - spec/fixtures/items_1.2/nq_multiple_fib_case_sensitive.xml
414
415
  - spec/fixtures/items_1.2/nq_multiple_fib_scoring_algorithms.xml
@@ -795,6 +796,7 @@ test_files:
795
796
  - spec/fixtures/items_1.2/multiple_answer.xml
796
797
  - spec/fixtures/items_1.2/multiple_answer_canvas.xml
797
798
  - spec/fixtures/items_1.2/multiple_choice.xml
799
+ - spec/fixtures/items_1.2/nq_multiple_dropdowns_answer_feedback.xml
798
800
  - spec/fixtures/items_1.2/nq_multiple_fib_answer_types.xml
799
801
  - spec/fixtures/items_1.2/nq_multiple_fib_case_sensitive.xml
800
802
  - spec/fixtures/items_1.2/nq_multiple_fib_scoring_algorithms.xml