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 +4 -4
- data/lib/qti/v1/models/interactions/base_fill_blank_interaction.rb +9 -2
- data/lib/qti/v1/models/interactions/base_interaction.rb +16 -5
- data/lib/qti/version.rb +1 -1
- data/spec/fixtures/items_1.2/canvas_multiple_dropdowns.xml +8 -8
- data/spec/fixtures/items_1.2/nq_multiple_dropdowns_answer_feedback.xml +156 -0
- data/spec/lib/qti/v1/models/interactions/base_fill_blank_interaction_spec.rb +27 -0
- data/spec/lib/qti/v1/models/interactions/base_interaction_spec.rb +262 -0
- data/spec/lib/qti/v1/models/interactions/match_interaction_spec.rb +40 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e8fff7e4ff119789cc3fe6191731a48d6bcbf96939e2dec388284ab3701fd66
|
|
4
|
+
data.tar.gz: b0fd5f24cc43d4fb62c67a25320b03e7be63e95fa0a2fca78c840942ee8be44a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
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(
|
|
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
|
|
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
|
@@ -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="
|
|
85
|
+
<varequal respident="response_color1">6548</varequal>
|
|
92
86
|
</conditionvar>
|
|
93
|
-
<displayfeedback feedbacktype="Response" linkrefid="
|
|
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"><div><p><span>Roses are [68857282-90bc-4881-83b0-e811ab0502a6], violets are [6702826b-84db-422a-bdd5-f14e759fcb5e] mega [4fc61c7b-83f8-4eba-ae21-747fb76f6ae9]</span></p></div></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"><p>feedback for aaa2</p></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"><p>feedback for blank3 aaa</p></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"><p>feedback for bbb2</p></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"><p>feedback for blank3 bbb</p></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"><p>Comment</p></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.
|
|
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-
|
|
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
|