ruql 0.0.4 → 0.0.8
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/ruql +15 -3
- data/lib/ruql/dropdown.rb +19 -0
- data/lib/ruql/open_assessment/criterion.rb +45 -0
- data/lib/ruql/open_assessment/open_assessment.rb +141 -0
- data/lib/ruql/open_assessment/option.rb +34 -0
- data/lib/ruql/open_assessment/training.rb +21 -0
- data/lib/ruql/open_assessment/training_criterion.rb +15 -0
- data/lib/ruql/question.rb +4 -4
- data/lib/ruql/quiz.rb +42 -10
- data/lib/ruql/renderer.rb +8 -0
- data/lib/ruql/renderers/auto_qcm_renderer.rb +18 -3
- data/lib/ruql/renderers/edxml_renderer.rb +145 -20
- data/lib/ruql/renderers/qualtrics_renderer.rb +63 -0
- data/lib/ruql/tex_output.rb +10 -1
- data/lib/ruql.rb +8 -0
- metadata +33 -32
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 997beabb8740224d399921151286761f842799d9
|
4
|
+
data.tar.gz: ead3b2482c09f2f2b494fff9b5fb19f41e841931
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c681825fc87751977bb73d8fc403860f8591588a11f809b3dc5ddbf8b1cc9699441b1ec3f67e8ca07d54dd5fd0fb493dcbbb794e517206d99c4ec6aba6e35e63
|
7
|
+
data.tar.gz: 08054ec2bb224f2e86be37d8e5e69ce51872150eaa47827f52fa238c8c465415dcccba861339fdd587e36b1357c06dca4f44f79ce830262a7865380c23a9631b
|
data/bin/ruql
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
|
-
require 'ruql'
|
3
|
+
# require 'ruql'
|
4
|
+
require_relative '../lib/ruql'
|
4
5
|
require 'getopt/long'
|
5
6
|
|
6
7
|
def usage
|
@@ -16,6 +17,7 @@ renderer choices are:
|
|
16
17
|
AutoQCM - LaTeX for use with AutoQCM (http://home.gna.org/auto-qcm)
|
17
18
|
Coursera - [obsolete] Coursera HTML/XML format for online auto-grading
|
18
19
|
JSON - [partially implemented] JSON format
|
20
|
+
Qualtrics -[partially implemented] Qualtrics survey (txt) format
|
19
21
|
|
20
22
|
Global options:
|
21
23
|
-l <loglevel>, --log=<loglevel>
|
@@ -36,6 +38,9 @@ The EdXML renderer supports these options:
|
|
36
38
|
NOTE: The 'points' and 'randomize' attributes of questions are not honored by
|
37
39
|
some systems.
|
38
40
|
|
41
|
+
-y <file.yml>, --yaml=<file.yml>
|
42
|
+
Render the questions with a specific yaml file.
|
43
|
+
|
39
44
|
The HTML5 and HTML Forms renderers supports these options:
|
40
45
|
-c <href>, --css=<href>
|
41
46
|
embed <href> for stylesheet into generated HTML5
|
@@ -46,7 +51,7 @@ The HTML5 and HTML Forms renderers supports these options:
|
|
46
51
|
file.html.erb should have <%= yield %> where questions should go.
|
47
52
|
An example is in the templates/ directory of RuQL.
|
48
53
|
The following local variables will be replaced with their values in
|
49
|
-
the template:
|
54
|
+
the template:
|
50
55
|
<%= quiz.title %> - the quiz title
|
51
56
|
<%= quiz.num_questions %> - total number of questions
|
52
57
|
<%= quiz.points %> - total number of points for whole quiz
|
@@ -64,6 +69,10 @@ The AutoQCM renderer supports these options:
|
|
64
69
|
|
65
70
|
The JSON renderer currently supports no options
|
66
71
|
|
72
|
+
The Qualtrics renderer supports these options:
|
73
|
+
-t <file.txt.erb>, --template=<file.txt.erb>
|
74
|
+
The file should have <%= yield %> where questions should go. Since this just creates survey questions, grading information is ignored.Currently supports the same type of questions as the AutoQCM renderer.
|
75
|
+
|
67
76
|
eos
|
68
77
|
exit
|
69
78
|
end
|
@@ -73,6 +82,7 @@ def main
|
|
73
82
|
raise "Cannot read #{filename}" unless File.readable? filename
|
74
83
|
renderer = ARGV.shift
|
75
84
|
raise "Unknown renderer '#{renderer}'" unless Quiz.get_renderer(renderer)
|
85
|
+
|
76
86
|
opts = Getopt::Long.getopts(
|
77
87
|
['-c', '--css', Getopt::REQUIRED],
|
78
88
|
['-j', '--js', Getopt::REQUIRED],
|
@@ -80,7 +90,9 @@ def main
|
|
80
90
|
['-s', '--solutions', Getopt::BOOLEAN],
|
81
91
|
['-n', '--name', Getopt::REQUIRED],
|
82
92
|
['-l', '--log', Getopt::REQUIRED],
|
83
|
-
|
93
|
+
['-y', '--yaml', Getopt::REQUIRED]
|
94
|
+
)
|
95
|
+
Quiz.set_yaml_file opts.delete("y") || opts.delete("yaml")
|
84
96
|
Quiz.instance_eval "#{IO.read(filename)}"
|
85
97
|
Quiz.quizzes.each { |quiz| puts quiz.render_with(renderer, opts) }
|
86
98
|
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
class Dropdown < Question
|
2
|
+
|
3
|
+
DropdownChoice = Struct.new(:correct, :list)
|
4
|
+
DropdownText = Struct.new(:text)
|
5
|
+
|
6
|
+
def choice(correct, list)
|
7
|
+
@choices << DropdownChoice.new(correct, list)
|
8
|
+
end
|
9
|
+
def label(str)
|
10
|
+
@choices << DropdownChoice.new(0, [str])
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_accessor :choices
|
14
|
+
|
15
|
+
def initialize(opts={})
|
16
|
+
super
|
17
|
+
self.choices = []
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
class Criterion
|
2
|
+
|
3
|
+
attr_accessor :options, :feedback, :criterion_name,
|
4
|
+
:criterion_label, :criterion_prompt
|
5
|
+
|
6
|
+
##
|
7
|
+
# Initializes a criterion
|
8
|
+
def initialize(options={})
|
9
|
+
@options = []
|
10
|
+
@feedback = options[:feedback] || "required"
|
11
|
+
end
|
12
|
+
|
13
|
+
##
|
14
|
+
# Sets the criterion name
|
15
|
+
def name(name) ; @criterion_name = name ; end
|
16
|
+
|
17
|
+
##
|
18
|
+
# Sets the criterion label
|
19
|
+
def label(label) ; @criterion_label = label ; end
|
20
|
+
|
21
|
+
##
|
22
|
+
# Sets the criterion prompt
|
23
|
+
def prompt(prompt) ; @criterion_prompt = prompt ; end
|
24
|
+
|
25
|
+
##
|
26
|
+
# Adds an option to the block and evaluates the proc bloc
|
27
|
+
def option(*args, &block)
|
28
|
+
option = Option.new(*args)
|
29
|
+
option.instance_eval(&block)
|
30
|
+
raise "Missing option parameters" if option.missing_parameters?
|
31
|
+
options << option
|
32
|
+
end
|
33
|
+
|
34
|
+
##
|
35
|
+
# Adds an already initialized option
|
36
|
+
def add_option(option)
|
37
|
+
options << option
|
38
|
+
end
|
39
|
+
|
40
|
+
##
|
41
|
+
# Validation to make sure that all the required fields are in
|
42
|
+
def missing_parameters?
|
43
|
+
@criterion_name.nil? || @criterion_label.nil? || @criterion_prompt.nil?
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
class OpenAssessment
|
3
|
+
|
4
|
+
attr_accessor :question_title, :prompts, :criterions, :name,
|
5
|
+
:url_name, :self_assessment, :peer_review,
|
6
|
+
:must_grade, :graded_by
|
7
|
+
attr_accessor :allow_file_upload, :allow_latex
|
8
|
+
attr_accessor :submission_start, :submission_due,
|
9
|
+
:submission_start_time, :submission_due_time,
|
10
|
+
:self_assessment_start, :self_assessment_due,
|
11
|
+
:self_assessment_start_time, :self_assessment_due_time,
|
12
|
+
:peer_review_start, :peer_review_due,
|
13
|
+
:peer_review_start_time, :peer_review_due_time
|
14
|
+
attr_accessor :question_feedback_prompt, :question_feedback_default_text
|
15
|
+
attr_accessor :yaml
|
16
|
+
attr_accessor :trainings
|
17
|
+
attr_accessor :answer, :question_scoring_guideline
|
18
|
+
|
19
|
+
@@single_question_scores =
|
20
|
+
[[5, "Excellent", "You got all of the question correct"],
|
21
|
+
[4, "Great", "You got most of the question correct"],
|
22
|
+
[3, "Good", "You got half of the question correct"],
|
23
|
+
[2, "Fair", "You got parts of the question correct"],
|
24
|
+
[1, "OK", "You got bits of the question correct"],
|
25
|
+
[0, "Poor", "You got none of the question correct"]]
|
26
|
+
|
27
|
+
@@DATE_REGEX = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/
|
28
|
+
|
29
|
+
##
|
30
|
+
# Initializes the open assessment question
|
31
|
+
def initialize(options={}, yaml={})
|
32
|
+
@peer_review = options[:peer_review] || false
|
33
|
+
@self_assessment = options[:self_assessment]
|
34
|
+
@self_assessment = true if @self_assessment.nil?
|
35
|
+
|
36
|
+
@prompts = []
|
37
|
+
@criterions = []
|
38
|
+
@trainings = []
|
39
|
+
|
40
|
+
@url_name = options[:url_name] || SecureRandom.hex
|
41
|
+
@yaml = yaml
|
42
|
+
|
43
|
+
@must_grade = @yaml["must_grade"] || 5
|
44
|
+
@graded_by = @yaml["graded_by"] || 3
|
45
|
+
|
46
|
+
@allow_file_upload = options[:allow_file_upload] || false
|
47
|
+
@allow_latex = options[:allow_latex] || false
|
48
|
+
|
49
|
+
# Parsing start/due dates
|
50
|
+
start_date = @yaml["submission_start"] || Time.now.to_s
|
51
|
+
end_date = @yaml["submission_due"] || (Time.now + 14).to_s
|
52
|
+
|
53
|
+
peer_review_start = @yaml["peer_review_start"] || start_date
|
54
|
+
peer_review_due = @yaml["peer_review_due"] || end_date
|
55
|
+
|
56
|
+
self_assessment_start = @yaml["self_assessment_start"] || start_date
|
57
|
+
self_assessment_due = @yaml["self_assessment_due"] || end_date
|
58
|
+
|
59
|
+
@submission_start = Date.parse(start_date)
|
60
|
+
@submission_due = Date.parse(end_date)
|
61
|
+
@submission_start_time = @yaml["submission_start_time"] || "00:00"
|
62
|
+
@submission_due_time = @yaml["submission_due_time"] || "00:00"
|
63
|
+
|
64
|
+
@peer_review_start = Date.parse(peer_review_start)
|
65
|
+
@peer_review_due = Date.parse(peer_review_due)
|
66
|
+
@peer_review_start_time = @yaml["peer_review_start_time"] || "00:00"
|
67
|
+
@peer_review_due_time = @yaml["peer_review_due_time"] || "00:00"
|
68
|
+
|
69
|
+
@self_assessment_start = Date.parse(self_assessment_start)
|
70
|
+
@self_assessment_due = Date.parse(self_assessment_due)
|
71
|
+
@self_assessment_start_time = @yaml["self_assessment_start_time"] || "00:00"
|
72
|
+
@self_assessment_due_time = @yaml["self_assessment_due_time"] || "00:00"
|
73
|
+
|
74
|
+
# Default feedback settings
|
75
|
+
@question_feedback_prompt = "Leave feedback"
|
76
|
+
@question_feedback_default_text = "Let them know how they did"
|
77
|
+
end
|
78
|
+
|
79
|
+
##
|
80
|
+
# Sets the title of the question
|
81
|
+
def title(title) ; @question_title = title ; end
|
82
|
+
|
83
|
+
##
|
84
|
+
# Adds a prompt to the question - you must have at least one
|
85
|
+
def prompt(prompt) ; @prompts << prompt ; end
|
86
|
+
|
87
|
+
##
|
88
|
+
# Sets the answers for a simple_open_assessment question
|
89
|
+
def answer(answer) ; @question_answer = answer ; end
|
90
|
+
|
91
|
+
##
|
92
|
+
# Sets the scoring guidelines for a simple_open_assesment question
|
93
|
+
def scoring_guideline(score_array) ; @question_scoring_guideline = score_array ; end
|
94
|
+
|
95
|
+
##
|
96
|
+
# Sets the feedback prompt if you want students to leave feedback
|
97
|
+
def feedback_prompt(fb_prompt) ; @question_feedback_prompt = fb_prompt ; end
|
98
|
+
|
99
|
+
##
|
100
|
+
# Sets the default text for the feedback textarea
|
101
|
+
def feedback_default_text(fb_text) ; @question_feedback_default_text = fb_text ; end
|
102
|
+
|
103
|
+
##
|
104
|
+
# Adds a criterion and evaluates its proc block.
|
105
|
+
def criterion(*args, &block)
|
106
|
+
criterion = Criterion.new(*args)
|
107
|
+
criterion.instance_eval(&block)
|
108
|
+
|
109
|
+
raise "Missing criterion parameters" if criterion.missing_parameters?
|
110
|
+
|
111
|
+
@criterions << criterion
|
112
|
+
end
|
113
|
+
|
114
|
+
##
|
115
|
+
# Adds fields for a simple_open_assessment question
|
116
|
+
def add_simple_question
|
117
|
+
criterion = Criterion.new
|
118
|
+
criterion.name("How'd you do?")
|
119
|
+
criterion.label("Scoring Rubric")
|
120
|
+
|
121
|
+
raise "Must have answer for question" if @question_answer.nil?
|
122
|
+
criterion.prompt(@question_answer)
|
123
|
+
|
124
|
+
criterions << criterion
|
125
|
+
scoring_guidelines = @question_scoring_guideline || @@single_question_scores
|
126
|
+
scoring_guidelines.each do |score_array|
|
127
|
+
option = Option.new({ points: score_array[0] })
|
128
|
+
option.add_params(score_array)
|
129
|
+
criterion.add_option(option)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
##
|
134
|
+
# Adds a student training question - only used with peer review enabled questions
|
135
|
+
def student_training(*args, &block)
|
136
|
+
return unless @peer_review
|
137
|
+
training = Training.new(*args)
|
138
|
+
training.instance_eval(&block)
|
139
|
+
@trainings << training
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
class Option
|
2
|
+
attr_accessor :points, :opt_name, :opt_label, :opt_explanation
|
3
|
+
|
4
|
+
##
|
5
|
+
# Initializes an option
|
6
|
+
def initialize(options={})
|
7
|
+
@points = options[:points] || 0
|
8
|
+
end
|
9
|
+
|
10
|
+
##
|
11
|
+
# Adds name to option
|
12
|
+
def name(name) ; @opt_name = name ; end
|
13
|
+
|
14
|
+
##
|
15
|
+
# Adds label to option
|
16
|
+
def label(label) ; @opt_label = label ; end
|
17
|
+
|
18
|
+
##
|
19
|
+
# Adds explanation to option
|
20
|
+
def explanation(explanation) ; @opt_explanation = explanation ; end
|
21
|
+
|
22
|
+
##
|
23
|
+
# Sets preset option paramters
|
24
|
+
def add_params(score_array)
|
25
|
+
_, @opt_label, @opt_explanation = score_array
|
26
|
+
@opt_name = @opt_label
|
27
|
+
end
|
28
|
+
|
29
|
+
##
|
30
|
+
# Validation to make sure that all the required fields are in
|
31
|
+
def missing_parameters?
|
32
|
+
@opt_name.nil? || @opt_label.nil? || @opt_explanation.nil?
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class Training
|
2
|
+
|
3
|
+
attr_accessor :training_criterions, :training_answer
|
4
|
+
|
5
|
+
##
|
6
|
+
# Initializes a training response
|
7
|
+
def initialize(options={})
|
8
|
+
@training_criterions = []
|
9
|
+
end
|
10
|
+
|
11
|
+
##
|
12
|
+
# Sets the answer for the training response
|
13
|
+
def answer(s) ; @training_answer = s ; end
|
14
|
+
|
15
|
+
##
|
16
|
+
# Adds a training criterion and evaluates to proc block
|
17
|
+
def training_criterion(*args)
|
18
|
+
training = TrainingCriterion.new(*args)
|
19
|
+
@training_criterions << training
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
class TrainingCriterion
|
2
|
+
attr_accessor :criterion, :option
|
3
|
+
|
4
|
+
##
|
5
|
+
# Initializes a training criterion for a training response
|
6
|
+
def initialize(options={})
|
7
|
+
@criterion = options[:criterion]
|
8
|
+
@option = options[:option]
|
9
|
+
|
10
|
+
# Validation
|
11
|
+
if @criterion.nil? || @option.nil?
|
12
|
+
raise "Must include criterion and option."
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
data/lib/ruql/question.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
class Question
|
2
2
|
attr_accessor :question_text, :answers, :randomize, :points, :name, :question_tags, :question_comment
|
3
|
-
|
3
|
+
|
4
4
|
def initialize(*args)
|
5
5
|
options = if args[-1].kind_of?(Hash) then args[-1] else {} end
|
6
6
|
@answers = options[:answers] || []
|
@@ -12,9 +12,9 @@ class Question
|
|
12
12
|
end
|
13
13
|
|
14
14
|
def raw? ; !!@raw ; end
|
15
|
-
|
15
|
+
|
16
16
|
def text(s) ; @question_text = s ; end
|
17
|
-
|
17
|
+
|
18
18
|
def explanation(text)
|
19
19
|
@answers.each { |answer| answer.explanation ||= text }
|
20
20
|
end
|
@@ -28,7 +28,7 @@ class Question
|
|
28
28
|
end
|
29
29
|
|
30
30
|
# these are ignored but legal for now:
|
31
|
-
def tags(*args) # string or array of strings
|
31
|
+
def tags(*args) # string or array of strings
|
32
32
|
if args.length > 1
|
33
33
|
@question_tags += args.map(&:to_s)
|
34
34
|
else
|
data/lib/ruql/quiz.rb
CHANGED
@@ -1,8 +1,9 @@
|
|
1
|
-
|
2
1
|
class Quiz
|
3
2
|
@@quizzes = []
|
3
|
+
@@yaml_file = nil
|
4
|
+
@quiz_yaml = {}
|
4
5
|
def self.quizzes ; @@quizzes ; end
|
5
|
-
@@default_options =
|
6
|
+
@@default_options =
|
6
7
|
{
|
7
8
|
:open_time => Time.now,
|
8
9
|
:soft_close_time => Time.now + 24*60*60,
|
@@ -28,7 +29,7 @@ class Quiz
|
|
28
29
|
attr_reader :logger
|
29
30
|
attr_accessor :title
|
30
31
|
|
31
|
-
def initialize(title, options={})
|
32
|
+
def initialize(title, yaml, options={})
|
32
33
|
@output = ''
|
33
34
|
@questions = options[:questions] || []
|
34
35
|
@title = title
|
@@ -36,12 +37,13 @@ class Quiz
|
|
36
37
|
@seed = srand
|
37
38
|
@logger = Logger.new(STDERR)
|
38
39
|
@logger.level = Logger.const_get (options.delete('l') ||
|
39
|
-
|
40
|
+
options.delete('log') || 'warn').upcase
|
41
|
+
@quiz_yaml = yaml
|
40
42
|
end
|
41
43
|
|
42
44
|
def self.get_renderer(renderer)
|
43
45
|
Object.const_get(renderer.to_s + 'Renderer') rescue nil
|
44
|
-
end
|
46
|
+
end
|
45
47
|
|
46
48
|
def render_with(renderer,options={})
|
47
49
|
srand @seed
|
@@ -49,7 +51,11 @@ class Quiz
|
|
49
51
|
@renderer.render_quiz
|
50
52
|
@output = @renderer.output
|
51
53
|
end
|
52
|
-
|
54
|
+
|
55
|
+
def self.set_yaml_file(file)
|
56
|
+
@@yaml_file = file && YAML::load_file(file)
|
57
|
+
end
|
58
|
+
|
53
59
|
def points ; questions.map(&:points).inject { |sum,points| sum + points } ; end
|
54
60
|
|
55
61
|
def num_questions ; questions.length ; end
|
@@ -57,7 +63,7 @@ class Quiz
|
|
57
63
|
def random_seed(num)
|
58
64
|
@seed = num.to_i
|
59
65
|
end
|
60
|
-
|
66
|
+
|
61
67
|
# this should really be done using mixins.
|
62
68
|
def choice_answer(*args, &block)
|
63
69
|
if args.first.is_a?(Hash) # no question text
|
@@ -72,7 +78,7 @@ class Quiz
|
|
72
78
|
|
73
79
|
def select_multiple(*args, &block)
|
74
80
|
if args.first.is_a?(Hash) # no question text
|
75
|
-
q = SelectMultiple.new(''
|
81
|
+
q = SelectMultiple.new('', *args)
|
76
82
|
else
|
77
83
|
text = args.shift
|
78
84
|
q = SelectMultiple.new(text, *args)
|
@@ -86,6 +92,32 @@ class Quiz
|
|
86
92
|
@questions << q
|
87
93
|
end
|
88
94
|
|
95
|
+
def dropdown(*args, &block)
|
96
|
+
q = Dropdown.new(*args)
|
97
|
+
q.instance_eval(&block)
|
98
|
+
@questions << q
|
99
|
+
end
|
100
|
+
|
101
|
+
def open_assessment(*args, &block)
|
102
|
+
q = get_open_assessment(*args, &block)
|
103
|
+
@questions << q
|
104
|
+
end
|
105
|
+
|
106
|
+
def simple_open_assessment(*args, &block)
|
107
|
+
q = get_open_assessment(*args, &block)
|
108
|
+
q.add_simple_question
|
109
|
+
@questions << q
|
110
|
+
end
|
111
|
+
|
112
|
+
def get_open_assessment(*args, &block)
|
113
|
+
y = @quiz_yaml.shift
|
114
|
+
raise "Cannot continue - You must have a yaml block for each peer evaluation question" if y.nil?
|
115
|
+
yaml = y[1][0]
|
116
|
+
q = OpenAssessment.new(*args, yaml)
|
117
|
+
q.instance_eval(&block)
|
118
|
+
q
|
119
|
+
end
|
120
|
+
|
89
121
|
def fill_in(*args, &block)
|
90
122
|
if args.first.is_a?(Hash) # no question text
|
91
123
|
q = FillIn.new('', *args)
|
@@ -97,8 +129,8 @@ class Quiz
|
|
97
129
|
@questions << q
|
98
130
|
end
|
99
131
|
|
100
|
-
def self.quiz(*args
|
101
|
-
quiz = Quiz.new(*args)
|
132
|
+
def self.quiz(*args, &block)
|
133
|
+
quiz = Quiz.new(*args, (@@yaml_file.shift if @@yaml_file))
|
102
134
|
quiz.instance_eval(&block)
|
103
135
|
@@quizzes << quiz
|
104
136
|
end
|
data/lib/ruql/renderer.rb
CHANGED
@@ -17,4 +17,12 @@ class Renderer
|
|
17
17
|
self.instance_eval@@render_handlers[:quiz].call(self.quiz)
|
18
18
|
end
|
19
19
|
|
20
|
+
# Override these methods in each renderer:
|
21
|
+
|
22
|
+
%w(fill_in multiple_choice select_multiple dropdown open_assessment true_false).each do |q|
|
23
|
+
define_method "render_#{q}" do |question|
|
24
|
+
STDERR.puts "Question type '#{q}' not yet implemented for this renderer"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
20
28
|
end
|
@@ -12,6 +12,8 @@ class AutoQCMRenderer
|
|
12
12
|
options.delete('template') ||
|
13
13
|
File.join(Gem.loaded_specs['ruql'].full_gem_path, 'templates/autoqcm.tex.erb')
|
14
14
|
@penalty = (options.delete('p') || options.delete('penalty') || '0').to_f
|
15
|
+
@show_solutions = options.delete('s') || options.delete('solutions')
|
16
|
+
|
15
17
|
end
|
16
18
|
|
17
19
|
def render_quiz
|
@@ -34,8 +36,8 @@ class AutoQCMRenderer
|
|
34
36
|
|
35
37
|
def render_question(q,index)
|
36
38
|
case q
|
39
|
+
when SelectMultiple,TrueFalse then render(q, index, 'mult') # These are subclasses of MultipleChoice, should go first
|
37
40
|
when MultipleChoice then render(q, index)
|
38
|
-
when SelectMultiple,TrueFalse then render(q, index, 'mult')
|
39
41
|
else
|
40
42
|
@quiz.logger.error "Question #{index} (#{q.question_text[0,15]}...): AutoQCM can only handle multiple_choice, truefalse, or select_multiple questions"
|
41
43
|
''
|
@@ -48,10 +50,15 @@ class AutoQCMRenderer
|
|
48
50
|
@output << "\\AMCrandomseed{#{seed}}\n\n"
|
49
51
|
end
|
50
52
|
|
51
|
-
def render(question, index, type='')
|
53
|
+
def render(question, index, type='')
|
52
54
|
output = ''
|
53
55
|
output << "\\begin{question#{type}}{q#{index}}\n"
|
54
56
|
output << " \\scoring{b=#{question.points},m=#{@penalty*question.points}}\n"
|
57
|
+
if type == 'mult'
|
58
|
+
question.question_text = "Select ALL that apply. " + question.question_text
|
59
|
+
elsif type == ''
|
60
|
+
question.question_text = "Choose ONE answer. " + question.question_text
|
61
|
+
end
|
55
62
|
output << " " << to_tex(question.question_text) << "\n"
|
56
63
|
|
57
64
|
# answers - ignore randomization
|
@@ -61,9 +68,17 @@ class AutoQCMRenderer
|
|
61
68
|
answer_text = to_tex(answer.answer_text)
|
62
69
|
answer_type = if answer.correct? then 'correct' else 'wrong' end
|
63
70
|
output << " \\#{answer_type}choice{#{answer_text}}\n"
|
71
|
+
if @show_solutions and answer.explanation
|
72
|
+
explanation = to_tex(answer.explanation)
|
73
|
+
if answer_type == 'wrong'
|
74
|
+
output << "{\\color{red}\\tab #{explanation}}"
|
75
|
+
else
|
76
|
+
output << "{\\color[rgb]{0,.5,0}\\tab #{explanation}}"
|
77
|
+
end
|
78
|
+
end
|
64
79
|
end
|
65
80
|
output << " \\end{choices}\n"
|
66
|
-
output << "\\end{question}\n\n"
|
81
|
+
output << "\\end{question#{type}}\n\n"
|
67
82
|
output
|
68
83
|
end
|
69
84
|
|
@@ -1,7 +1,10 @@
|
|
1
|
+
require 'builder'
|
2
|
+
require 'yaml'
|
3
|
+
|
1
4
|
class EdXmlRenderer
|
2
|
-
require 'builder'
|
3
5
|
|
4
6
|
attr_reader :output
|
7
|
+
attr_accessor :file, :yaml_file
|
5
8
|
def initialize(quiz,options={})
|
6
9
|
@only_question = options.delete('n') || options.delete('name')
|
7
10
|
@output = ''
|
@@ -13,11 +16,13 @@ class EdXmlRenderer
|
|
13
16
|
case thing
|
14
17
|
when MultipleChoice,SelectMultiple,TrueFalse then render_multiple_choice(thing)
|
15
18
|
when FillIn then render_fill_in(thing)
|
19
|
+
when OpenAssessment then render_open_assessment(thing)
|
20
|
+
when Dropdown then render_dropdown(thing)
|
16
21
|
else
|
17
22
|
raise "Unknown question type: #{thing}"
|
18
23
|
end
|
19
24
|
end
|
20
|
-
|
25
|
+
|
21
26
|
def render_quiz
|
22
27
|
# entire quiz can be in one question group, as long as we specify
|
23
28
|
# that ALL question from the group must be used to make the quiz.
|
@@ -29,11 +34,16 @@ class EdXmlRenderer
|
|
29
34
|
@output
|
30
35
|
end
|
31
36
|
|
32
|
-
def render_fill_in(question)
|
33
|
-
raise "Not yet implemented for edXML"
|
34
|
-
end
|
35
|
-
|
36
37
|
def render_multiple_choice(question)
|
38
|
+
# the OLX for select-multiple and select-one are frustratingly different in arbitrary ways
|
39
|
+
# single choice has <multiplechoiceresponse> element containing a <choicegroup> with <choice>s
|
40
|
+
# select-mult has <choiceresponse> element containing a <checkboxgroup> with <choice>s
|
41
|
+
|
42
|
+
question_type, answer_type =
|
43
|
+
if question.kind_of?(SelectMultiple)
|
44
|
+
then ['choiceresponse', 'checkboxgroup']
|
45
|
+
else ['multiplechoiceresponse', 'choicegroup']
|
46
|
+
end
|
37
47
|
@b.problem do
|
38
48
|
# if question text has explicit newlines, use them to separate <p>'s
|
39
49
|
if question.raw?
|
@@ -43,32 +53,147 @@ class EdXmlRenderer
|
|
43
53
|
if question.raw? then @b.p { |p| p << line } else @b.p(line) end
|
44
54
|
end
|
45
55
|
end
|
46
|
-
|
47
|
-
|
56
|
+
|
57
|
+
@b.__send__(question_type) do
|
58
|
+
@b.__send__(answer_type, :type => 'MultipleChoice') do
|
48
59
|
question.answers.each do |answer|
|
49
60
|
if question.raw?
|
50
|
-
@b.choice(:correct => answer.correct?)
|
51
|
-
choice << answer.answer_text
|
52
|
-
choice << "\n"
|
53
|
-
end
|
61
|
+
@b.choice(:correct => answer.correct?) { @b << answer.answer_text.chomp }
|
54
62
|
else
|
55
63
|
@b.choice answer.answer_text, :correct => answer.correct?
|
56
64
|
end
|
57
65
|
end
|
58
66
|
end
|
59
67
|
end
|
60
|
-
|
61
|
-
@b.
|
62
|
-
@b.
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
68
|
+
if (ans = question.correct_answer.explanation)
|
69
|
+
@b.solution do
|
70
|
+
@b.div :class => 'detailed-solution' do
|
71
|
+
@b.p 'Explanation'
|
72
|
+
if question.raw?
|
73
|
+
@b.p { |p| p << ans }
|
74
|
+
else
|
75
|
+
@b.p ans
|
76
|
+
end
|
67
77
|
end
|
68
78
|
end
|
69
79
|
end
|
70
80
|
end
|
71
81
|
end
|
72
82
|
|
73
|
-
|
83
|
+
def render_dropdown(question)
|
84
|
+
@b.problem do
|
85
|
+
if question.raw?
|
86
|
+
@b.p { |p| p << question.question_text }
|
87
|
+
else
|
88
|
+
question.question_text.lines.map(&:chomp).each do |line|
|
89
|
+
if question.raw? then @b.p { |p| p << line } else @b.p(line) end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
question.choices.each do |choice|
|
93
|
+
idx = choice.correct
|
94
|
+
menu_opts = choice.list
|
95
|
+
if menu_opts.length == 1 # this is actually a label
|
96
|
+
@b.span menu_opts[0]
|
97
|
+
else
|
98
|
+
@b.optionresponse do
|
99
|
+
debugger if menu_opts[idx].nil?
|
100
|
+
@b.optioninput :options => options_list_for_attribute(menu_opts),
|
101
|
+
:correct => escape_doublequotes(menu_opts[idx])
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def render_open_assessment(question)
|
109
|
+
@b.openassessment url_name: question.url_name,
|
110
|
+
submission_start: "#{question.submission_start.to_s}T"\
|
111
|
+
"#{question.submission_start_time}:00+00:00",
|
112
|
+
submission_due: "#{question.submission_due.to_s}T"\
|
113
|
+
"#{question.submission_due_time}:00+00:00",
|
114
|
+
allow_file_upload: question.allow_file_upload.to_s.capitalize,
|
115
|
+
allow_latex: question.allow_latex.to_s.capitalize do
|
116
|
+
|
117
|
+
@b.title question.question_title
|
74
118
|
|
119
|
+
# Oh good lord my eyes
|
120
|
+
@b.assessments do
|
121
|
+
if question.trainings.size > 0
|
122
|
+
@b.assessment name: "student-training" do
|
123
|
+
question.trainings.each do |training|
|
124
|
+
@b.example do
|
125
|
+
@b.answer do
|
126
|
+
@b.part training.training_answer
|
127
|
+
end
|
128
|
+
training.training_criterions.each do |criterion|
|
129
|
+
@b.select criterion: criterion.criterion,
|
130
|
+
option: criterion.option
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
if question.peer_review
|
138
|
+
@b.assessment name: "peer-assessment",
|
139
|
+
must_grade: question.must_grade,
|
140
|
+
must_be_graded_by: question.graded_by,
|
141
|
+
start: "#{question.peer_review_start.to_s}T" \
|
142
|
+
"#{question.peer_review_start_time}:00+00:00",
|
143
|
+
due: "#{question.peer_review_due.to_s}T"\
|
144
|
+
"#{question.peer_review_due_time}:00+00:00"
|
145
|
+
|
146
|
+
end
|
147
|
+
|
148
|
+
if question.self_assessment
|
149
|
+
@b.assessment name: "self-assessment",
|
150
|
+
start: "#{question.self_assessment_start.to_s}T"\
|
151
|
+
"#{question.self_assessment_start_time}:00+00:00",
|
152
|
+
due: "#{question.self_assessment_due.to_s}T"\
|
153
|
+
"#{question.self_assessment_due_time}:00+00:00"
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
@b.prompts do
|
158
|
+
question.prompts.each do |description|
|
159
|
+
@b.prompt do
|
160
|
+
@b.description description
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
@b.rubric do
|
166
|
+
question.criterions.each do |criterion|
|
167
|
+
@b.criterion feedback: criterion.feedback do
|
168
|
+
@b.name criterion.criterion_name
|
169
|
+
@b.label criterion.criterion_label
|
170
|
+
@b.prompt criterion.criterion_prompt
|
171
|
+
|
172
|
+
criterion.options.each do |option|
|
173
|
+
@b.option points: option.points do
|
174
|
+
@b.name option.opt_name
|
175
|
+
@b.label option.opt_label
|
176
|
+
@b.explanation option.opt_explanation
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
@b.feedbackprompt question.question_feedback_prompt
|
182
|
+
@b.feedback_default_text question.question_feedback_default_text
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
private
|
188
|
+
|
189
|
+
def escape_doublequotes(str)
|
190
|
+
str.gsub(/"/, '"')
|
191
|
+
end
|
192
|
+
|
193
|
+
def options_list_for_attribute(list)
|
194
|
+
# takes a list of strings, places single quotes around each element
|
195
|
+
# and HTML-escapes doublequotes within any element. Because OLX.
|
196
|
+
attr = list.map { |e| "'" << escape_doublequotes(e) << "'" }.join(',')
|
197
|
+
"(#{attr})"
|
198
|
+
end
|
199
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
class QualtricsRenderer
|
2
|
+
|
3
|
+
attr_reader :output
|
4
|
+
|
5
|
+
def initialize(quiz, options={})
|
6
|
+
@output = ''
|
7
|
+
@quiz = quiz
|
8
|
+
@template = options.delete('t') || options.delete('template')
|
9
|
+
end
|
10
|
+
|
11
|
+
def render_quiz
|
12
|
+
quiz = @quiz # make quiz object available in template's scope
|
13
|
+
with_erb_template(IO.read(File.expand_path @template)) do
|
14
|
+
output = ''
|
15
|
+
@quiz.questions.each_with_index do |q,i|
|
16
|
+
next_question = render_question q,i
|
17
|
+
output << next_question
|
18
|
+
end
|
19
|
+
output
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def with_erb_template(template)
|
24
|
+
# template will 'yield' back to render_quiz to render the questions
|
25
|
+
@output = ERB.new(template).result(binding)
|
26
|
+
end
|
27
|
+
|
28
|
+
def render_question(q,index)
|
29
|
+
case q
|
30
|
+
when SelectMultiple,TrueFalse then render(q, index, 'Multiple') # These are subclasses of MultipleChoice, should go first
|
31
|
+
when MultipleChoice then render(q, index, 'Single')
|
32
|
+
else
|
33
|
+
@quiz.logger.error "Question #{index} (#{q.question_text[0,15]}...): Only written to handle multiple_choice, truefalse, or select_multiple questions at this time."
|
34
|
+
''
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def render(question, index, type='')
|
39
|
+
output = ''
|
40
|
+
output << "[[Question:MC:#{type}Answer]]\n"
|
41
|
+
output << "[[ID:#{index}]]\n"
|
42
|
+
if type == 'Multiple'
|
43
|
+
question.question_text = "Select ALL that apply. " + question.question_text
|
44
|
+
elsif type == 'Single'
|
45
|
+
question.question_text = "Choose ONE answer. " + question.question_text
|
46
|
+
end
|
47
|
+
output << question.question_text << "\n"
|
48
|
+
|
49
|
+
# answers - ignore randomization
|
50
|
+
|
51
|
+
output << "[[AdvancedChoices]]\n"
|
52
|
+
question.answers.each do |answer|
|
53
|
+
output << "[[Choice]]\n"
|
54
|
+
output << "#{answer.answer_text}\n"
|
55
|
+
end
|
56
|
+
if type == 'Multiple'
|
57
|
+
output << "[[Choice]]\n"
|
58
|
+
output << "<i>None of these answers are correct.</i>\n"
|
59
|
+
end
|
60
|
+
output
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
data/lib/ruql/tex_output.rb
CHANGED
@@ -1,15 +1,24 @@
|
|
1
1
|
module TexOutput
|
2
2
|
|
3
|
+
def add_newlines(str)
|
4
|
+
str.gsub(Regexp.new('<pre>(.*?)</pre>', Regexp::MULTILINE | Regexp::IGNORECASE) ) do |code_section|
|
5
|
+
code_section.gsub!("\n"){"\\\\"}
|
6
|
+
code_section.gsub!(" "){"\\hspace*{2em}"}
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
3
10
|
@@tex_replace = {
|
4
11
|
/_/ => '\textunderscore{}',
|
5
12
|
Regexp.new('<tt>([^<]+)</tt>', Regexp::IGNORECASE) => "\\texttt{\\1}",
|
6
|
-
Regexp.new('<pre>(.*?)</pre>', Regexp::MULTILINE | Regexp::IGNORECASE) => "\\
|
13
|
+
Regexp.new('<pre>(.*?)</pre>', Regexp::MULTILINE | Regexp::IGNORECASE) => "\\texttt{\\1}",
|
7
14
|
}
|
8
15
|
|
9
16
|
@@tex_escape = Regexp.new '\$|&|%|#|\{|\}'
|
10
17
|
|
11
18
|
def to_tex(str)
|
12
19
|
str = str.gsub(@@tex_escape) { |match| "\\#{match}" }
|
20
|
+
str = add_newlines(str)
|
21
|
+
|
13
22
|
@@tex_replace.each_pair do |match, replace|
|
14
23
|
str = str.gsub(match, replace)
|
15
24
|
end
|
data/lib/ruql.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# basic gems/libs we rely on
|
2
2
|
require 'builder'
|
3
3
|
require 'logger'
|
4
|
+
require 'date'
|
4
5
|
|
5
6
|
$LOAD_PATH.unshift File.expand_path(File.join(File.dirname(__FILE__)))
|
6
7
|
|
@@ -11,6 +12,7 @@ require 'ruql/renderers/html_form_renderer'
|
|
11
12
|
require 'ruql/renderers/edxml_renderer'
|
12
13
|
require 'ruql/renderers/auto_qcm_renderer'
|
13
14
|
require 'ruql/renderers/json_renderer'
|
15
|
+
require 'ruql/renderers/qualtrics_renderer'
|
14
16
|
|
15
17
|
# question types
|
16
18
|
require 'ruql/quiz'
|
@@ -20,3 +22,9 @@ require 'ruql/multiple_choice'
|
|
20
22
|
require 'ruql/select_multiple'
|
21
23
|
require 'ruql/true_false'
|
22
24
|
require 'ruql/fill_in'
|
25
|
+
require 'ruql/dropdown'
|
26
|
+
require 'ruql/open_assessment/open_assessment'
|
27
|
+
require 'ruql/open_assessment/criterion'
|
28
|
+
require 'ruql/open_assessment/option'
|
29
|
+
require 'ruql/open_assessment/training'
|
30
|
+
require 'ruql/open_assessment/training_criterion'
|
metadata
CHANGED
@@ -1,48 +1,43 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ruql
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
5
|
-
prerelease:
|
4
|
+
version: 0.0.8
|
6
5
|
platform: ruby
|
7
6
|
authors:
|
8
7
|
- Armando Fox
|
9
8
|
autorequire:
|
10
9
|
bindir: bin
|
11
10
|
cert_chain: []
|
12
|
-
date:
|
11
|
+
date: 2016-02-13 00:00:00.000000000 Z
|
13
12
|
dependencies:
|
14
13
|
- !ruby/object:Gem::Dependency
|
15
|
-
name: builder
|
16
14
|
requirement: !ruby/object:Gem::Requirement
|
17
|
-
none: false
|
18
15
|
requirements:
|
19
|
-
- -
|
16
|
+
- - ~>
|
20
17
|
- !ruby/object:Gem::Version
|
21
|
-
version: '0'
|
22
|
-
type: :runtime
|
18
|
+
version: '3.0'
|
23
19
|
prerelease: false
|
20
|
+
name: builder
|
24
21
|
version_requirements: !ruby/object:Gem::Requirement
|
25
|
-
none: false
|
26
22
|
requirements:
|
27
|
-
- -
|
23
|
+
- - ~>
|
28
24
|
- !ruby/object:Gem::Version
|
29
|
-
version: '0'
|
25
|
+
version: '3.0'
|
26
|
+
type: :runtime
|
30
27
|
- !ruby/object:Gem::Dependency
|
31
|
-
name: getopt
|
32
28
|
requirement: !ruby/object:Gem::Requirement
|
33
|
-
none: false
|
34
29
|
requirements:
|
35
|
-
- -
|
30
|
+
- - '='
|
36
31
|
- !ruby/object:Gem::Version
|
37
|
-
version:
|
38
|
-
type: :runtime
|
32
|
+
version: 1.4.2
|
39
33
|
prerelease: false
|
34
|
+
name: getopt
|
40
35
|
version_requirements: !ruby/object:Gem::Requirement
|
41
|
-
none: false
|
42
36
|
requirements:
|
43
|
-
- -
|
37
|
+
- - '='
|
44
38
|
- !ruby/object:Gem::Version
|
45
|
-
version:
|
39
|
+
version: 1.4.2
|
40
|
+
type: :runtime
|
46
41
|
description: Ruby-embedded DSL for creating short-answer quiz questions
|
47
42
|
email: fox@cs.berkeley.edu
|
48
43
|
executables:
|
@@ -50,49 +45,55 @@ executables:
|
|
50
45
|
extensions: []
|
51
46
|
extra_rdoc_files: []
|
52
47
|
files:
|
48
|
+
- bin/ruql
|
53
49
|
- lib/ruql.rb
|
54
|
-
- lib/ruql/quiz.rb
|
55
50
|
- lib/ruql/answer.rb
|
56
|
-
- lib/ruql/
|
57
|
-
- lib/ruql/renderer.rb
|
58
|
-
- lib/ruql/select_multiple.rb
|
51
|
+
- lib/ruql/dropdown.rb
|
59
52
|
- lib/ruql/fill_in.rb
|
60
53
|
- lib/ruql/multiple_choice.rb
|
61
|
-
- lib/ruql/
|
62
|
-
- lib/ruql/
|
54
|
+
- lib/ruql/open_assessment/criterion.rb
|
55
|
+
- lib/ruql/open_assessment/open_assessment.rb
|
56
|
+
- lib/ruql/open_assessment/option.rb
|
57
|
+
- lib/ruql/open_assessment/training.rb
|
58
|
+
- lib/ruql/open_assessment/training_criterion.rb
|
59
|
+
- lib/ruql/question.rb
|
60
|
+
- lib/ruql/quiz.rb
|
61
|
+
- lib/ruql/renderer.rb
|
63
62
|
- lib/ruql/renderers/auto_qcm_renderer.rb
|
64
63
|
- lib/ruql/renderers/edxml_renderer.rb
|
65
64
|
- lib/ruql/renderers/html5_renderer.rb
|
66
65
|
- lib/ruql/renderers/html_form_renderer.rb
|
67
66
|
- lib/ruql/renderers/json_renderer.rb
|
67
|
+
- lib/ruql/renderers/qualtrics_renderer.rb
|
68
68
|
- lib/ruql/renderers/xml_renderer.rb
|
69
|
+
- lib/ruql/select_multiple.rb
|
70
|
+
- lib/ruql/tex_output.rb
|
71
|
+
- lib/ruql/true_false.rb
|
69
72
|
- templates/autoqcm.tex.erb
|
70
73
|
- templates/html5.html.erb
|
71
74
|
- templates/htmlform.html.erb
|
72
|
-
- bin/ruql
|
73
75
|
homepage: http://github.com/saasbook/ruql
|
74
76
|
licenses:
|
75
77
|
- CC By-SA
|
78
|
+
metadata: {}
|
76
79
|
post_install_message:
|
77
80
|
rdoc_options: []
|
78
81
|
require_paths:
|
79
82
|
- lib
|
80
83
|
required_ruby_version: !ruby/object:Gem::Requirement
|
81
|
-
none: false
|
82
84
|
requirements:
|
83
|
-
- -
|
85
|
+
- - '>='
|
84
86
|
- !ruby/object:Gem::Version
|
85
87
|
version: '0'
|
86
88
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
87
|
-
none: false
|
88
89
|
requirements:
|
89
|
-
- -
|
90
|
+
- - '>='
|
90
91
|
- !ruby/object:Gem::Version
|
91
92
|
version: '0'
|
92
93
|
requirements: []
|
93
94
|
rubyforge_project:
|
94
|
-
rubygems_version:
|
95
|
+
rubygems_version: 2.2.5
|
95
96
|
signing_key:
|
96
|
-
specification_version:
|
97
|
+
specification_version: 4
|
97
98
|
summary: Ruby question language
|
98
99
|
test_files: []
|