ams_migration 0.1.3

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.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +9 -0
  3. data/README.md +33 -0
  4. data/lib/ams_migration/canvas/converter/classic_quiz/calculated.rb +33 -0
  5. data/lib/ams_migration/canvas/converter/classic_quiz/classic_question.rb +114 -0
  6. data/lib/ams_migration/canvas/converter/classic_quiz/essay.rb +36 -0
  7. data/lib/ams_migration/canvas/converter/classic_quiz/file_upload.rb +35 -0
  8. data/lib/ams_migration/canvas/converter/classic_quiz/fill_in_blank.rb +63 -0
  9. data/lib/ams_migration/canvas/converter/classic_quiz/fill_in_multiple_blanks.rb +111 -0
  10. data/lib/ams_migration/canvas/converter/classic_quiz/matching.rb +66 -0
  11. data/lib/ams_migration/canvas/converter/classic_quiz/multiple_answer.rb +27 -0
  12. data/lib/ams_migration/canvas/converter/classic_quiz/multiple_choice.rb +57 -0
  13. data/lib/ams_migration/canvas/converter/classic_quiz/multiple_dropdown.rb +126 -0
  14. data/lib/ams_migration/canvas/converter/classic_quiz/numerical.rb +89 -0
  15. data/lib/ams_migration/canvas/converter/classic_quiz/text.rb +28 -0
  16. data/lib/ams_migration/canvas/converter/classic_quiz/true_false.rb +14 -0
  17. data/lib/ams_migration/canvas/converter/classic_quiz/unknown.rb +31 -0
  18. data/lib/ams_migration/canvas/converter/new_quiz/categorization.rb +108 -0
  19. data/lib/ams_migration/canvas/converter/new_quiz/essay.rb +78 -0
  20. data/lib/ams_migration/canvas/converter/new_quiz/file_upload.rb +33 -0
  21. data/lib/ams_migration/canvas/converter/new_quiz/fill_in_blank.rb +180 -0
  22. data/lib/ams_migration/canvas/converter/new_quiz/formula.rb +33 -0
  23. data/lib/ams_migration/canvas/converter/new_quiz/hotspot.rb +166 -0
  24. data/lib/ams_migration/canvas/converter/new_quiz/matching.rb +85 -0
  25. data/lib/ams_migration/canvas/converter/new_quiz/multiple_answer.rb +28 -0
  26. data/lib/ams_migration/canvas/converter/new_quiz/multiple_choice.rb +70 -0
  27. data/lib/ams_migration/canvas/converter/new_quiz/new_question.rb +133 -0
  28. data/lib/ams_migration/canvas/converter/new_quiz/numerical.rb +117 -0
  29. data/lib/ams_migration/canvas/converter/new_quiz/ordering.rb +68 -0
  30. data/lib/ams_migration/canvas/converter/new_quiz/stimulus.rb +35 -0
  31. data/lib/ams_migration/canvas/converter/new_quiz/true_false.rb +33 -0
  32. data/lib/ams_migration/canvas/converter/new_quiz/unknown.rb +30 -0
  33. data/lib/ams_migration/canvas/converter/question_type.rb +30 -0
  34. data/lib/ams_migration/services/README.md +26 -0
  35. data/lib/ams_migration/services/data_provider.rb +26 -0
  36. data/lib/ams_migration/services/learnosity_bank_cleaner.rb +44 -0
  37. data/lib/ams_migration/services/learnosity_data_api_client.rb +135 -0
  38. data/lib/ams_migration/services/learnosity_sink.rb +190 -0
  39. data/lib/ams_migration/services/migration_service.rb +37 -0
  40. data/lib/ams_migration/services/new_quiz_learnosity_transformer.rb +115 -0
  41. data/lib/ams_migration/version.rb +3 -0
  42. data/lib/ams_migration.rb +45 -0
  43. data/lib/factories/classic_quiz_item_converter_factory.rb +43 -0
  44. data/lib/factories/new_quiz_item_converter_factory.rb +76 -0
  45. data/spec/fixtures/sample_data/classic_quiz/calculated.json +24 -0
  46. data/spec/fixtures/sample_data/classic_quiz/essay.json +24 -0
  47. data/spec/fixtures/sample_data/classic_quiz/file_upload.json +24 -0
  48. data/spec/fixtures/sample_data/classic_quiz/fill_in_bank.json +24 -0
  49. data/spec/fixtures/sample_data/classic_quiz/fill_in_blank.json +53 -0
  50. data/spec/fixtures/sample_data/classic_quiz/fill_in_multiple_blanks.json +57 -0
  51. data/spec/fixtures/sample_data/classic_quiz/matching.json +107 -0
  52. data/spec/fixtures/sample_data/classic_quiz/multiple_answer.json +48 -0
  53. data/spec/fixtures/sample_data/classic_quiz/multiple_choice.json +56 -0
  54. data/spec/fixtures/sample_data/classic_quiz/multiple_dropdown.json +73 -0
  55. data/spec/fixtures/sample_data/classic_quiz/numerical.json +55 -0
  56. data/spec/fixtures/sample_data/classic_quiz/text.json +24 -0
  57. data/spec/fixtures/sample_data/classic_quiz/true_false.json +39 -0
  58. data/spec/fixtures/sample_data/classic_quiz/unknown.json +24 -0
  59. data/spec/fixtures/sample_data/new_quiz/bank_entry_item.json +5 -0
  60. data/spec/fixtures/sample_data/new_quiz/bank_item.json +11 -0
  61. data/spec/fixtures/sample_data/new_quiz/categorization.json +135 -0
  62. data/spec/fixtures/sample_data/new_quiz/essay.json +40 -0
  63. data/spec/fixtures/sample_data/new_quiz/file_upload.json +30 -0
  64. data/spec/fixtures/sample_data/new_quiz/fill_in_blank_association.json +162 -0
  65. data/spec/fixtures/sample_data/new_quiz/fill_in_blank_dropdown.json +180 -0
  66. data/spec/fixtures/sample_data/new_quiz/fill_in_blank_math.json +45 -0
  67. data/spec/fixtures/sample_data/new_quiz/fill_in_blank_mixed.json +188 -0
  68. data/spec/fixtures/sample_data/new_quiz/fill_in_blank_text.json +140 -0
  69. data/spec/fixtures/sample_data/new_quiz/formula.json +51 -0
  70. data/spec/fixtures/sample_data/new_quiz/hotspot_oval.json +38 -0
  71. data/spec/fixtures/sample_data/new_quiz/hotspot_polygon.json +66 -0
  72. data/spec/fixtures/sample_data/new_quiz/hotspot_square.json +38 -0
  73. data/spec/fixtures/sample_data/new_quiz/matching.json +88 -0
  74. data/spec/fixtures/sample_data/new_quiz/multiple_answer.json +57 -0
  75. data/spec/fixtures/sample_data/new_quiz/multiple_choice.json +66 -0
  76. data/spec/fixtures/sample_data/new_quiz/multiple_choice_math.json +55 -0
  77. data/spec/fixtures/sample_data/new_quiz/numerical.json +64 -0
  78. data/spec/fixtures/sample_data/new_quiz/ordering.json +60 -0
  79. data/spec/fixtures/sample_data/new_quiz/stimulus.json +17 -0
  80. data/spec/fixtures/sample_data/new_quiz/true_false.json +27 -0
  81. data/spec/fixtures/sample_data/new_quiz/unknown.json +14 -0
  82. data/spec/lib/ams_migration/services/learnosity_sink_spec.rb +198 -0
  83. data/spec/lib/canvas/converter/classic_quiz/calculated_spec.rb +26 -0
  84. data/spec/lib/canvas/converter/classic_quiz/essay_spec.rb +26 -0
  85. data/spec/lib/canvas/converter/classic_quiz/file_upload_spec.rb +23 -0
  86. data/spec/lib/canvas/converter/classic_quiz/fill_in_blank_spec.rb +30 -0
  87. data/spec/lib/canvas/converter/classic_quiz/fill_in_multiple_blanks_spec.rb +33 -0
  88. data/spec/lib/canvas/converter/classic_quiz/matching_spec.rb +38 -0
  89. data/spec/lib/canvas/converter/classic_quiz/multiple_answer_spec.rb +33 -0
  90. data/spec/lib/canvas/converter/classic_quiz/multiple_choice_spec.rb +31 -0
  91. data/spec/lib/canvas/converter/classic_quiz/multiple_dropdown_spec.rb +39 -0
  92. data/spec/lib/canvas/converter/classic_quiz/numerical_spec.rb +41 -0
  93. data/spec/lib/canvas/converter/classic_quiz/text_spec.rb +20 -0
  94. data/spec/lib/canvas/converter/classic_quiz/true_false_spec.rb +28 -0
  95. data/spec/lib/canvas/converter/classic_quiz/unknown_spec.rb +25 -0
  96. data/spec/lib/canvas/converter/common_error.rb +16 -0
  97. data/spec/lib/canvas/converter/new_quiz/categorization_spec.rb +55 -0
  98. data/spec/lib/canvas/converter/new_quiz/essay_spec.rb +26 -0
  99. data/spec/lib/canvas/converter/new_quiz/file_upload_spec.rb +24 -0
  100. data/spec/lib/canvas/converter/new_quiz/fill_in_blank_spec.rb +111 -0
  101. data/spec/lib/canvas/converter/new_quiz/formula_spec.rb +23 -0
  102. data/spec/lib/canvas/converter/new_quiz/hotspot_spec.rb +182 -0
  103. data/spec/lib/canvas/converter/new_quiz/matching_spec.rb +30 -0
  104. data/spec/lib/canvas/converter/new_quiz/multiple_answer_spec.rb +34 -0
  105. data/spec/lib/canvas/converter/new_quiz/multiple_choice_spec.rb +54 -0
  106. data/spec/lib/canvas/converter/new_quiz/numerical_spec.rb +48 -0
  107. data/spec/lib/canvas/converter/new_quiz/ordering_spec.rb +25 -0
  108. data/spec/lib/canvas/converter/new_quiz/stimulus_spec.rb +20 -0
  109. data/spec/lib/canvas/converter/new_quiz/true_false_spec.rb +24 -0
  110. data/spec/lib/canvas/converter/new_quiz/unknown_spec.rb +36 -0
  111. data/spec/lib/factories/classic_quiz_item_converter_factory_spec.rb +183 -0
  112. data/spec/lib/factories/new_quiz_item_converter_factory_spec.rb +71 -0
  113. data/spec/lib/services/learnosity_data_api_client_spec.rb +130 -0
  114. data/spec/lib/services/new_quiz_learnosity_transformer_spec.rb +196 -0
  115. data/spec/spec_helper.rb +20 -0
  116. metadata +229 -0
@@ -0,0 +1,33 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ module Canvas
7
+ module Converter
8
+ module NewQuiz
9
+ class Formula < NewQuestion
10
+ # This question type will be skipped in the conversion process and the
11
+ # question will not be included in the quiz. The JSON returned will be
12
+ # an essay question with text noting the question has been skipped.
13
+ def convert
14
+ super
15
+ @converted_question = {
16
+ stimulus: "The Formula item was removed from the quiz because there is no equivalent item type in Mastery Connect. Please check the original quiz for this question.",
17
+ type: QuestionType::UNSUPPORTED_CANVAS_TYPE,
18
+ validation: {
19
+ valid_response: {
20
+ score: 0,
21
+ value: ""
22
+ },
23
+ scoring_type: "exactMatch"
24
+ },
25
+ is_math: true
26
+ }
27
+ format
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,166 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ require "fastimage"
6
+
7
+ module AmsMigration
8
+ module Canvas
9
+ module Converter
10
+ module NewQuiz
11
+ class Hotspot < NewQuestion
12
+ DEFAULT_IMAGE_SIZE = [500, 500].freeze
13
+ AREA_ATTRIBUTES = {
14
+ global: {
15
+ fill: "rgba(12, 176, 216, 0.48)",
16
+ stroke: "rgba(25, 90, 107, 0.83)"
17
+ }
18
+ }.freeze
19
+
20
+ def to_h
21
+ @question
22
+ end
23
+
24
+ def image_url
25
+ @question&.dig(:entry, :interaction_data, :image_url)
26
+ end
27
+
28
+ def image_dimensions
29
+ return @image_dimensions if defined? @image_dimensions
30
+
31
+ dimensions = FastImage.size(image_url)
32
+ @image_dimensions = dimensions.nil? ? DEFAULT_IMAGE_SIZE : dimensions
33
+
34
+ @image_dimensions
35
+ end
36
+
37
+ def points_possible
38
+ @question[:points_possible]
39
+ end
40
+
41
+ # We multiply by 100 because the canvas coordinates are a percentage of the image size whereas the
42
+ # MC coordinates are a full integer percentage of the image size
43
+ def canvas_coords(value)
44
+ value[:coordinates].map do |coord|
45
+ {
46
+ x: coord[:x] * 100,
47
+ y: coord[:y] * 100
48
+ }
49
+ end
50
+ end
51
+
52
+ def values
53
+ value = @question.dig(:entry, :scoring_data, :value)
54
+ return value if value.is_a?(Array)
55
+
56
+ [value]
57
+ end
58
+
59
+ def coordinates(value)
60
+ type = value[:type]
61
+
62
+ case type
63
+ when "square"
64
+ square_coordinates(value)
65
+ when "oval"
66
+ oval_coordinates(value)
67
+ when "polygon"
68
+ canvas_coords(value)
69
+ else
70
+ raise "Unknown hotspot type: #{type}"
71
+ end
72
+ end
73
+
74
+ def square_coordinates(value)
75
+ canvas_coordinates = canvas_coords(value)
76
+
77
+ x1 = canvas_coordinates[0][:x]
78
+ y1 = canvas_coordinates[0][:y]
79
+ x2 = canvas_coordinates[1][:x]
80
+ y2 = canvas_coordinates[1][:y]
81
+
82
+ [
83
+ {
84
+ x: x1,
85
+ y: y1
86
+ },
87
+ {
88
+ x: x1,
89
+ y: y2
90
+ },
91
+ {
92
+ x: x2,
93
+ y: y2
94
+ },
95
+ {
96
+ x: x2,
97
+ y: y1
98
+ }
99
+ ]
100
+ end
101
+
102
+ def oval_coordinates(value)
103
+ canvas_coordinates = canvas_coords(value)
104
+ x1 = canvas_coordinates[0][:x]
105
+ y1 = canvas_coordinates[0][:y]
106
+ x2 = canvas_coordinates[1][:x]
107
+ y2 = canvas_coordinates[1][:y]
108
+
109
+ oval_center = [(x1 + x2) / 2, (y1 + y2) / 2]
110
+ horizontal_radius = (x2 - x1) / 2
111
+ vertical_radius = (y2 - y1) / 2
112
+
113
+ # Going to approximate the oval with a polygon with 16 points
114
+ oval_coords = []
115
+
116
+ 16.times do |i|
117
+ angle = (2 * Math::PI) * (i / 16.0)
118
+ x = oval_center[0] + (horizontal_radius * Math.cos(angle))
119
+ y = oval_center[1] + (vertical_radius * Math.sin(angle))
120
+ oval_coords << { x:, y: }
121
+ end
122
+
123
+ oval_coords
124
+ end
125
+
126
+ def areas
127
+ values.map { |value| coordinates(value) }
128
+ end
129
+
130
+ def convert
131
+ super
132
+ begin
133
+ @converted_question = {
134
+ type: "hotspot",
135
+ stimulus:,
136
+ image: {
137
+ source: image_url,
138
+ width: image_dimensions[0],
139
+ height: image_dimensions[1]
140
+ },
141
+ areas:,
142
+ area_attributes: AREA_ATTRIBUTES,
143
+ validation: {
144
+ scoring_type: "exactMatch",
145
+ valid_response: {
146
+ score: points_possible,
147
+ value: ["0"]
148
+ }
149
+ },
150
+ is_math: true
151
+ }
152
+
153
+ @image_sources << image_url
154
+
155
+ @notifications << "Hotspot items will need additional configuration."
156
+ rescue => e
157
+ @conversion_errors << e.message
158
+ end
159
+ post_convert
160
+ format
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,85 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ module Canvas
7
+ module Converter
8
+ module NewQuiz
9
+ class Matching < NewQuestion
10
+ def initialize(question)
11
+ super
12
+ @distractors = []
13
+ end
14
+
15
+ def to_h
16
+ @question
17
+ end
18
+
19
+ def possible_responses
20
+ @question.dig(:entry, :interaction_data, :answers).map do |answer|
21
+ answer
22
+ end
23
+ end
24
+
25
+ def stimulus_list
26
+ @question.dig(:entry, :interaction_data, :questions).map do |question|
27
+ question[:item_body]
28
+ end
29
+ end
30
+
31
+ def answer_value
32
+ @question.dig(:entry, :scoring_data, :edit_data, :matches).map do |answer|
33
+ answer[:answer_body]
34
+ end
35
+ end
36
+
37
+ def scoring_type
38
+ case @question.dig(:entry, :scoring_algorithm)
39
+ when "PartialDeep"
40
+ "partialMatchV2"
41
+ else
42
+ "exactMatch"
43
+ end
44
+ end
45
+
46
+ def distractors
47
+ @distractors = @question.dig(:entry, :scoring_data, :edit_data, :distractors)
48
+ end
49
+
50
+ def convert
51
+ super
52
+ begin
53
+ @converted_question = {
54
+ possible_responses:,
55
+ stimulus_list:,
56
+ stimulus:,
57
+ type: "association",
58
+ validation: {
59
+ valid_response: {
60
+ score: point_value,
61
+ value: answer_value
62
+ },
63
+ scoring_type:
64
+ },
65
+ metadata: {
66
+ distractor_rationale_response_level: distractors
67
+ }
68
+ }
69
+
70
+ if distractors.length.positive?
71
+ @converted_question[:metadata] = {
72
+ distractor_rationale_response_level: @distractors
73
+ }
74
+ end
75
+ rescue => e
76
+ @conversion_errors << e.message
77
+ end
78
+ post_convert
79
+ format
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,28 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ module Canvas
7
+ module Converter
8
+ module NewQuiz
9
+ class MultipleAnswer < MultipleChoice
10
+ def correct_answer
11
+ @question[:entry][:scoring_data][:value]
12
+ end
13
+
14
+ def convert
15
+ begin
16
+ multiple_answer = super
17
+ multiple_answer[:question][:multiple_responses] = true if multiple_answer[:errors].empty?
18
+ rescue => e
19
+ @conversion_errors << e.message
20
+ end
21
+ post_convert
22
+ format
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,70 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ module Canvas
7
+ module Converter
8
+ module NewQuiz
9
+ class MultipleChoice < NewQuestion
10
+ def initialize(question)
11
+ super
12
+ @distractors = []
13
+ end
14
+
15
+ def to_h
16
+ @question
17
+ end
18
+
19
+ def options
20
+ options = []
21
+ @question[:entry][:interaction_data][:choices].each do |answer|
22
+ label = answer[:item_body]
23
+ options << {
24
+ label:,
25
+ value: answer[:id].to_s
26
+ }
27
+ unless @question[:entry][:answer_feedback][answer[:id].to_sym].nil?
28
+ @distractors << @question[:entry][:answer_feedback][answer[:id].to_sym]
29
+ end
30
+ end
31
+ options
32
+ end
33
+
34
+ def correct_answer
35
+ [@question[:entry][:scoring_data][:value]]
36
+ end
37
+
38
+ def convert
39
+ super
40
+ begin
41
+ @converted_question = {
42
+ stimulus:,
43
+ options:,
44
+ type: "mcq",
45
+ validation: {
46
+ valid_response: {
47
+ score: point_value,
48
+ value: correct_answer
49
+ },
50
+ scoring_type: "exactMatch"
51
+ }
52
+ }
53
+
54
+ if @distractors.length.positive?
55
+ @converted_question[:metadata] = {
56
+ distractor_rationale_response_level: @distractors
57
+ }
58
+ end
59
+ rescue => e
60
+ @conversion_errors << e.message
61
+ end
62
+
63
+ post_convert
64
+ format
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,133 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ require "nokogiri"
7
+ module Canvas
8
+ module Converter
9
+ module NewQuiz
10
+ class NewQuestion
11
+ attr_reader :notifications, :image_sources
12
+
13
+ def initialize(question)
14
+ @question_json = question
15
+ @question = nil
16
+ @is_math = nil
17
+ @converted_question = {}
18
+ @conversion_errors = []
19
+ @notifications = []
20
+ @image_sources = []
21
+ @uri_parser = URI::DEFAULT_PARSER
22
+ end
23
+
24
+ def title
25
+ return nil if @question.blank?
26
+
27
+ @question[:question_name] || @question[:title] || @question.dig(:entry, :title) || ""
28
+ end
29
+
30
+ def stimulus
31
+ @question[:entry][:item_body]
32
+ end
33
+
34
+ def convert
35
+ begin
36
+ @question = JSON.parse(@question_json).deep_symbolize_keys
37
+ rescue JSON::ParserError => e
38
+ @question = ""
39
+ @conversion_errors << e.message
40
+ end
41
+ format
42
+ end
43
+
44
+ def point_value
45
+ point_value = @question[:points_possible] || 0
46
+ if point_value % 1 != 0
47
+ @notifications << "Partial points are not supported. After conversion points have been rounded to the nearest whole number."
48
+ end
49
+ point_value.round(0)
50
+ end
51
+
52
+ def post_convert
53
+ process_question_text
54
+ end
55
+
56
+ def process_question_text
57
+ if @converted_question.is_a?(Hash)
58
+ if @converted_question.key?(:stimulus)
59
+ @converted_question[:stimulus] = substitute_mathml(@converted_question[:stimulus])
60
+ get_image_sources(@converted_question[:stimulus])
61
+ end
62
+ if @converted_question.key?(:template)
63
+ @converted_question[:template] = substitute_mathml(@converted_question[:template])
64
+ get_image_sources(@converted_question[:template])
65
+ end
66
+
67
+ if @converted_question.key?(:options)
68
+ @converted_question[:options].each do |option|
69
+ option[:label] = substitute_mathml(option[:label])
70
+ get_image_sources(option[:label])
71
+ option[:value] = substitute_mathml(option[:value])
72
+ get_image_sources(option[:value])
73
+ end
74
+ end
75
+
76
+ if @converted_question.key?(:list)
77
+ @converted_question[:list].each_with_index do |each_list, index|
78
+ @converted_question[:list][index] = substitute_mathml(each_list)
79
+ get_image_sources(@converted_question[:list][index])
80
+ end
81
+ end
82
+
83
+ @converted_question[:is_math] = @converted_question[:is_math] || @is_math
84
+ @converted_question.compact!
85
+ else
86
+ @converted_question = substitute_mathml(@converted_question) unless @converted_question.nil?
87
+ get_image_sources(@converted_question) unless @converted_question.nil?
88
+ end
89
+ end
90
+
91
+ def substitute_mathml(fragment)
92
+ html = Nokogiri::HTML::DocumentFragment.parse(fragment, "UTF-8")
93
+
94
+ if @is_math.nil?
95
+ @is_math = html.css("img.equation_image").empty? ? nil : true
96
+ end
97
+
98
+ html.css("img.equation_image[data-equation-content]").each do |img|
99
+ img.replace("\\(#{img.attr("data-equation-content")}\\)")
100
+ end
101
+
102
+ html.to_xhtml(indent: 0).delete("\n")
103
+ end
104
+
105
+ def get_image_sources(fragment)
106
+ html = Nokogiri::HTML::DocumentFragment.parse(fragment, "UTF-8")
107
+ html.css("img").each { |img| @image_sources << img.attr("src") if img.attr("src") }
108
+ end
109
+
110
+ def tags
111
+ @question.is_a?(Hash) ? @question[:tags] || [] : []
112
+ end
113
+
114
+ def to_h
115
+ @question
116
+ end
117
+
118
+ protected
119
+
120
+ def format
121
+ {
122
+ question: @converted_question,
123
+ title:,
124
+ errors: @conversion_errors,
125
+ tags:,
126
+ image_sources:
127
+ }
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,117 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ module Canvas
7
+ module Converter
8
+ module NewQuiz
9
+ class Numerical < NewQuestion
10
+ def math?
11
+ @question[:entry][:scoring_data][:value].any? do |value|
12
+ %w[withinARange marginOfError].include?(value[:type])
13
+ end
14
+ end
15
+
16
+ def correct_values
17
+ values = []
18
+ @question[:entry][:scoring_data][:value].map do |answer|
19
+ case answer[:type]
20
+ when "exactResponse"
21
+ values << {
22
+ method: "equivLiteral",
23
+ value: answer[:value],
24
+ options: {
25
+ ignoreOrder: false,
26
+ inverseResult: false
27
+ }
28
+ }
29
+ when "marginOfError"
30
+ @notifications << 'Numeric items with "Margin Of Error" may need additional configuration'
31
+
32
+ value = answer[:value].to_f
33
+ margin = answer[:margin].to_f
34
+
35
+ margin = (value * margin / 100).round(2) if answer[:margin_type] == "percent"
36
+
37
+ values << {
38
+ method: "equivValue",
39
+ value: "#{value}\\pm#{margin}",
40
+ options: {
41
+ ignoreOrder: false,
42
+ inverseResult: false
43
+ }
44
+ }
45
+ when "withinARange"
46
+ @notifications << 'Numeric items with "Within A Range" may need additional configuration'
47
+ if answer[:end] && answer[:start]
48
+ mid_point = (answer[:end].to_f + answer[:start].to_f) / 2
49
+ margin_of_error = (answer[:end].to_f - answer[:start].to_f) / 2
50
+ values << {
51
+ method: "equivValue",
52
+ value: "#{mid_point}\\pm#{margin_of_error}",
53
+ options: {
54
+ ignoreOrder: false,
55
+ inverseResult: false
56
+ }
57
+ }
58
+ end
59
+ when "preciseResponse"
60
+ @notifications << 'Numeric items with "Precise Response" may need additional configuration'
61
+
62
+ value = answer[:value].to_f
63
+ precision = answer[:precision].to_i
64
+
65
+ if answer[:precision_type] == "significantDigits"
66
+ whole_num_digits = value.to_i.to_s.length
67
+ decimal_places = [precision - whole_num_digits, 0].max
68
+ else
69
+ decimal_places = precision
70
+ end
71
+
72
+ values << {
73
+ method: "equivValue",
74
+ value: answer[:value].to_s,
75
+ options: {
76
+ decimalPlaces: decimal_places
77
+ }
78
+ }
79
+ else
80
+ raise JSON::ParserError, "Invalid scoring type for Numeric"
81
+ end
82
+ end
83
+ values
84
+ end
85
+
86
+ def convert
87
+ super
88
+ begin
89
+ @converted_question = {
90
+ stimulus:,
91
+ template: "{{response}}",
92
+ response_container: {
93
+ template: ""
94
+ },
95
+ type: "clozeformula",
96
+ validation: {
97
+ scoring_type: "exactMatch",
98
+ valid_response: {
99
+ score: point_value,
100
+ value: [correct_values]
101
+ }
102
+ },
103
+ response_containers: []
104
+ }
105
+
106
+ @converted_question[:is_math] = true if math?
107
+ rescue => e
108
+ @conversion_errors << e.message
109
+ end
110
+ post_convert
111
+ format
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,68 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ module Canvas
7
+ module Converter
8
+ module NewQuiz
9
+ class Ordering < NewQuestion
10
+ def to_h
11
+ @question
12
+ end
13
+
14
+ def list
15
+ list = []
16
+ # already shuffled list (fixed list, not random) coming from API
17
+ @question[:entry][:interaction_data][:choices].each_value do |answer|
18
+ list << answer[:item_body]
19
+ end
20
+ list
21
+ end
22
+
23
+ def correct_answer
24
+ if !@question[:entry][:properties][:top_label].empty? || !@question[:entry][:properties][:bottom_label].empty?
25
+ @notifications << "Please review this item. Top and bottom labels on Ordering questions are not supported."
26
+ end
27
+ answers = @question[:entry][:scoring_data][:value]
28
+ choices = @question[:entry][:interaction_data][:choices]
29
+ answers.map { |id| choices.keys.index(:"#{id}") }
30
+ end
31
+
32
+ def scoring_type
33
+ "exactMatch"
34
+ end
35
+
36
+ def ui_style
37
+ {
38
+ type: @question[:entry][:properties][:display_answers_paragraph] ? "inline" : "list"
39
+ }
40
+ end
41
+
42
+ def convert
43
+ super
44
+ begin
45
+ @converted_question = {
46
+ list:,
47
+ stimulus:,
48
+ type: "orderlist",
49
+ validation: {
50
+ scoring_type:,
51
+ valid_response: {
52
+ score: point_value,
53
+ value: correct_answer
54
+ }
55
+ },
56
+ ui_style:
57
+ }
58
+ rescue => e
59
+ @conversion_errors << e.message
60
+ end
61
+ post_convert
62
+ format
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end