ruql 0.0.2
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.
- data/bin/ruql +86 -0
- data/lib/ruql/answer.rb +16 -0
- data/lib/ruql/fill_in.rb +15 -0
- data/lib/ruql/multiple_choice.rb +12 -0
- data/lib/ruql/question.rb +47 -0
- data/lib/ruql/quiz.rb +105 -0
- data/lib/ruql/renderer.rb +20 -0
- data/lib/ruql/renderers/auto_qcm_renderer.rb +70 -0
- data/lib/ruql/renderers/edxml_renderer.rb +74 -0
- data/lib/ruql/renderers/html5_renderer.rb +162 -0
- data/lib/ruql/renderers/json_renderer.rb +49 -0
- data/lib/ruql/renderers/xml_renderer.rb +148 -0
- data/lib/ruql/select_multiple.rb +8 -0
- data/lib/ruql/tex_output.rb +18 -0
- data/lib/ruql/true_false.rb +17 -0
- data/lib/ruql.rb +21 -0
- data/templates/autoqcm.tex.erb +1 -0
- data/templates/html5.html.erb +44 -0
- metadata +96 -0
data/bin/ruql
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'ruql'
|
4
|
+
require 'getopt/long'
|
5
|
+
|
6
|
+
def usage
|
7
|
+
name = $0.gsub( Regexp.new '^[^/]+/', '')
|
8
|
+
STDERR.puts <<eos
|
9
|
+
Usage: #{name} filename.rb renderer [options]
|
10
|
+
filename.rb contains questions expressed in RuQL
|
11
|
+
|
12
|
+
renderer choices are:
|
13
|
+
Html5 - HTML 5 suitable for Web display/printing
|
14
|
+
EdXml - XML for OpenEdX platform in-course questions
|
15
|
+
AutoQCM - LaTeX for use with AutoQCM (http://home.gna.org/auto-qcm)
|
16
|
+
Coursera- [obsolete] Coursera HTML/XML format for online auto-grading
|
17
|
+
JSON - [partially implemented] JSON format
|
18
|
+
|
19
|
+
Global options:
|
20
|
+
-l <loglevel>, --log=<loglevel>
|
21
|
+
In increasing verbosity, they are 'error' (nonfatal), 'warn', 'info',
|
22
|
+
'debug'; default is 'warn'
|
23
|
+
-p <penalty>, --penalty <penalty>
|
24
|
+
The penalty for WRONG answers, as a fraction of the penalty for
|
25
|
+
RIGHT answers. For a 2-point question, penalty=0.25 means 1/2 point
|
26
|
+
(0.25 * 2) deducted for WRONG answer. Default is 0. Not all
|
27
|
+
output formats are able to do something useful with this.
|
28
|
+
|
29
|
+
The EdXML renderer supports these options:
|
30
|
+
-n <name>, --name=<name>
|
31
|
+
Only render the question(s) that have :name => 'name'.
|
32
|
+
NOTE: Some markup that is legal in RuQL questions will break the EdX parser.
|
33
|
+
Manually check your questions as you enter them into EdX. Code seems to
|
34
|
+
be particularly troublesome.
|
35
|
+
NOTE: The 'points' and 'randomize' attributes of questions are not honored by
|
36
|
+
some systems.
|
37
|
+
|
38
|
+
The HTML5 renderer supports these options:
|
39
|
+
-c <href>, --css=<href>
|
40
|
+
embed <href> for stylesheet into generated HTML5
|
41
|
+
-t <file.html.erb>, --template=<file.html.erb>
|
42
|
+
Use file.html.erb as HTML template rather than generating our own file.
|
43
|
+
file.html.erb should have <%= yield %> where questions should go.
|
44
|
+
An example is in the templates/ directory of RuQL.
|
45
|
+
The following local variables will be replaced with their values in
|
46
|
+
the template:
|
47
|
+
<%= quiz.title %> - the quiz title
|
48
|
+
<%= quiz.num_questions %> - total number of questions
|
49
|
+
<%= quiz.points %> - total number of points for whole quiz
|
50
|
+
-s, --solutions
|
51
|
+
generate solutions (showing correct answers and explanations)
|
52
|
+
NOTE: If there is more than one quiz (collection of questions) in the file,
|
53
|
+
a complete <html>...</html> block is produced in the output for EACH quiz.
|
54
|
+
|
55
|
+
The AutoQCM renderer supports these options:
|
56
|
+
-t <file.tex.erb>, --template=<file.tex.erb>
|
57
|
+
MANDATORY: Use file.tex.erb as LaTeX/AutoQCM template.
|
58
|
+
The file should have <%= yield %> where questions should go.
|
59
|
+
See the description of template under HTML5 renderer for variable
|
60
|
+
substitutions that can occur in the quiz body.
|
61
|
+
|
62
|
+
The JSON renderer currently supports no options
|
63
|
+
|
64
|
+
eos
|
65
|
+
exit
|
66
|
+
end
|
67
|
+
|
68
|
+
def main
|
69
|
+
filename = ARGV.shift
|
70
|
+
raise "Cannot read #{filename}" unless File.readable? filename
|
71
|
+
renderer = ARGV.shift
|
72
|
+
raise "Unknown renderer '#{renderer}'" unless Quiz.get_renderer(renderer)
|
73
|
+
opts = Getopt::Long.getopts(
|
74
|
+
['-c', '--css', Getopt::REQUIRED],
|
75
|
+
['-t', '--template', Getopt::REQUIRED],
|
76
|
+
['-s', '--solutions', Getopt::BOOLEAN],
|
77
|
+
['-n', '--name', Getopt::REQUIRED],
|
78
|
+
['-l', '--log', Getopt::REQUIRED],
|
79
|
+
)
|
80
|
+
Quiz.instance_eval "#{IO.read(filename)}"
|
81
|
+
Quiz.quizzes.each { |quiz| puts quiz.render_with(renderer, opts) }
|
82
|
+
end
|
83
|
+
|
84
|
+
usage if ARGV.length < 2
|
85
|
+
main
|
86
|
+
|
data/lib/ruql/answer.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
class Answer
|
2
|
+
include Comparable
|
3
|
+
attr_accessor :answer_text, :explanation
|
4
|
+
attr_reader :correct
|
5
|
+
attr_reader :builder
|
6
|
+
attr_reader :question
|
7
|
+
|
8
|
+
def <=>(other) ; self.answer_text <=> other.answer_text ; end
|
9
|
+
def correct? ; !!correct ; end
|
10
|
+
def has_explanation? ; !!explanation ; end
|
11
|
+
def initialize(answer_text, correct, explanation=nil)
|
12
|
+
@answer_text = answer_text
|
13
|
+
@correct = !!correct # ensure boolean
|
14
|
+
@explanation = explanation
|
15
|
+
end
|
16
|
+
end
|
data/lib/ruql/fill_in.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
class FillIn < Question
|
2
|
+
|
3
|
+
attr_accessor :order
|
4
|
+
attr_accessor :case_sensitive
|
5
|
+
|
6
|
+
def initialize(text='', opts={})
|
7
|
+
super
|
8
|
+
self.question_text = text
|
9
|
+
self.order = !!opts[:order]
|
10
|
+
self.case_sensitive = !!opts[:case_sensitive]
|
11
|
+
end
|
12
|
+
|
13
|
+
def multiple ; false ; end
|
14
|
+
|
15
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
class Question
|
2
|
+
attr_accessor :question_text, :answers, :randomize, :points, :name, :question_tags, :question_comment
|
3
|
+
|
4
|
+
def initialize(*args)
|
5
|
+
options = if args[-1].kind_of?(Hash) then args[-1] else {} end
|
6
|
+
@answers = options[:answers] || []
|
7
|
+
@points = [options[:points].to_i, 1].max
|
8
|
+
@raw = options[:raw]
|
9
|
+
@name = options[:name]
|
10
|
+
@question_tags = []
|
11
|
+
@question_comment = ''
|
12
|
+
end
|
13
|
+
|
14
|
+
def raw? ; !!@raw ; end
|
15
|
+
|
16
|
+
def text(s) ; @question_text = s ; end
|
17
|
+
|
18
|
+
def explanation(text)
|
19
|
+
@answers.each { |answer| answer.explanation ||= text }
|
20
|
+
end
|
21
|
+
|
22
|
+
def answer(text, opts={})
|
23
|
+
@answers << Answer.new(text, correct=true, opts[:explanation])
|
24
|
+
end
|
25
|
+
|
26
|
+
def distractor(text, opts={})
|
27
|
+
@answers << Answer.new(text, correct=false, opts[:explanation])
|
28
|
+
end
|
29
|
+
|
30
|
+
# these are ignored but legal for now:
|
31
|
+
def tags(*args) # string or array of strings
|
32
|
+
if args.length > 1
|
33
|
+
@question_tags += args.map(&:to_s)
|
34
|
+
else
|
35
|
+
@question_tags << args.first.to_s
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def comment(str = '')
|
40
|
+
@question_comment = str.to_s
|
41
|
+
end
|
42
|
+
|
43
|
+
def correct_answer ; @answers.detect(&:correct?) ; end
|
44
|
+
|
45
|
+
def correct_answers ; @answers.collect(&:correct?) ; end
|
46
|
+
|
47
|
+
end
|
data/lib/ruql/quiz.rb
ADDED
@@ -0,0 +1,105 @@
|
|
1
|
+
|
2
|
+
class Quiz
|
3
|
+
@@quizzes = []
|
4
|
+
def self.quizzes ; @@quizzes ; end
|
5
|
+
@@default_options =
|
6
|
+
{
|
7
|
+
:open_time => Time.now,
|
8
|
+
:soft_close_time => Time.now + 24*60*60,
|
9
|
+
:hard_close_time => Time.now + 24*60*60,
|
10
|
+
:maximum_submissions => 1,
|
11
|
+
:duration => 3600,
|
12
|
+
:retry_delay => 600,
|
13
|
+
:parameters => {
|
14
|
+
:show_explanations => {
|
15
|
+
:question => 'before_soft_close_time',
|
16
|
+
:option => 'before_soft_close_time',
|
17
|
+
:score => 'before_soft_close_time',
|
18
|
+
}
|
19
|
+
},
|
20
|
+
:maximum_score => 1,
|
21
|
+
}
|
22
|
+
|
23
|
+
attr_reader :renderer
|
24
|
+
attr_reader :questions
|
25
|
+
attr_reader :options
|
26
|
+
attr_reader :output
|
27
|
+
attr_reader :seed
|
28
|
+
attr_reader :logger
|
29
|
+
attr_accessor :title
|
30
|
+
|
31
|
+
def initialize(title, options={})
|
32
|
+
@output = ''
|
33
|
+
@questions = options[:questions] || []
|
34
|
+
@title = title
|
35
|
+
@options = @@default_options.merge(options)
|
36
|
+
@seed = srand
|
37
|
+
@logger = Logger.new(STDERR)
|
38
|
+
@logger.level = Logger.const_get (options.delete('l') ||
|
39
|
+
options.delete('log') || 'warn').upcase
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.get_renderer(renderer)
|
43
|
+
Object.const_get(renderer.to_s + 'Renderer') rescue nil
|
44
|
+
end
|
45
|
+
|
46
|
+
def render_with(renderer,options={})
|
47
|
+
srand @seed
|
48
|
+
@renderer = Quiz.get_renderer(renderer).send(:new,self,options)
|
49
|
+
@renderer.render_quiz
|
50
|
+
@output = @renderer.output
|
51
|
+
end
|
52
|
+
|
53
|
+
def points ; questions.map(&:points).inject { |sum,points| sum + points } ; end
|
54
|
+
|
55
|
+
def num_questions ; questions.length ; end
|
56
|
+
|
57
|
+
def random_seed(num)
|
58
|
+
@seed = num.to_i
|
59
|
+
end
|
60
|
+
|
61
|
+
# this should really be done using mixins.
|
62
|
+
def choice_answer(*args, &block)
|
63
|
+
if args.first.is_a?(Hash) # no question text
|
64
|
+
q = MultipleChoice.new('',*args)
|
65
|
+
else
|
66
|
+
text = args.shift
|
67
|
+
q = MultipleChoice.new(text, *args)
|
68
|
+
end
|
69
|
+
q.instance_eval(&block)
|
70
|
+
@questions << q
|
71
|
+
end
|
72
|
+
|
73
|
+
def select_multiple(*args, &block)
|
74
|
+
if args.first.is_a?(Hash) # no question text
|
75
|
+
q = SelectMultiple.new('',*args)
|
76
|
+
else
|
77
|
+
text = args.shift
|
78
|
+
q = SelectMultiple.new(text, *args)
|
79
|
+
end
|
80
|
+
q.instance_eval(&block)
|
81
|
+
@questions << q
|
82
|
+
end
|
83
|
+
|
84
|
+
def truefalse(*args)
|
85
|
+
q = TrueFalse.new(*args)
|
86
|
+
@questions << q
|
87
|
+
end
|
88
|
+
|
89
|
+
def fill_in(*args, &block)
|
90
|
+
if args.first.is_a?(Hash) # no question text
|
91
|
+
q = FillIn.new('', *args)
|
92
|
+
else
|
93
|
+
text = args.shift
|
94
|
+
q = FillIn.new(text, *args)
|
95
|
+
end
|
96
|
+
q.instance_eval(&block)
|
97
|
+
@questions << q
|
98
|
+
end
|
99
|
+
|
100
|
+
def self.quiz(*args,&block)
|
101
|
+
quiz = Quiz.new(*args)
|
102
|
+
quiz.instance_eval(&block)
|
103
|
+
@@quizzes << quiz
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class Renderer
|
2
|
+
|
3
|
+
attr_reader :quiz
|
4
|
+
def initialize(quiz)
|
5
|
+
@quiz = quiz
|
6
|
+
end
|
7
|
+
|
8
|
+
@@render_handlers = {}
|
9
|
+
def self.render(thing, &block)
|
10
|
+
@@render_handlers[thing.to_sym] = block
|
11
|
+
end
|
12
|
+
def self.renderer_for(thing)
|
13
|
+
@@render_handlers[thing.to_sym]
|
14
|
+
end
|
15
|
+
|
16
|
+
def render_quiz!
|
17
|
+
self.instance_eval@@render_handlers[:quiz].call(self.quiz)
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'erb'
|
2
|
+
require 'ruql/tex_output'
|
3
|
+
|
4
|
+
class AutoQCMRenderer
|
5
|
+
include TexOutput
|
6
|
+
attr_reader :output
|
7
|
+
|
8
|
+
def initialize(quiz, options={})
|
9
|
+
@output = ''
|
10
|
+
@quiz = quiz
|
11
|
+
@template = options.delete('t') ||
|
12
|
+
options.delete('template') ||
|
13
|
+
File.join(Gem.loaded_specs['ruql'].full_gem_path, 'templates/autoqcm.tex.erb')
|
14
|
+
@penalty = (options.delete('p') || options.delete('penalty') || '0').to_f
|
15
|
+
end
|
16
|
+
|
17
|
+
def render_quiz
|
18
|
+
quiz = @quiz # make quiz object available in template's scope
|
19
|
+
with_erb_template(IO.read(File.expand_path @template)) do
|
20
|
+
output = ''
|
21
|
+
render_random_seed
|
22
|
+
@quiz.questions.each_with_index do |q,i|
|
23
|
+
next_question = render_question q,i
|
24
|
+
output << next_question
|
25
|
+
end
|
26
|
+
output
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def with_erb_template(template)
|
31
|
+
# template will 'yield' back to render_quiz to render the questions
|
32
|
+
@output = ERB.new(template).result(binding)
|
33
|
+
end
|
34
|
+
|
35
|
+
def render_question(q,index)
|
36
|
+
case q
|
37
|
+
when MultipleChoice then render(q, index)
|
38
|
+
when SelectMultiple,TrueFalse then render(q, index, 'mult')
|
39
|
+
else
|
40
|
+
@quiz.logger.error "Question #{index} (#{q.question_text[0,15]}...): AutoQCM can only handle multiple_choice, truefalse, or select_multiple questions"
|
41
|
+
''
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def render_random_seed
|
46
|
+
seed = @quiz.seed
|
47
|
+
@output << "\n%% Random seed: #{seed}\n"
|
48
|
+
@output << "\\AMCrandomseed{#{seed}}\n\n"
|
49
|
+
end
|
50
|
+
|
51
|
+
def render(question, index, type='')
|
52
|
+
output = ''
|
53
|
+
output << "\\begin{question#{type}}{q#{index}}\n"
|
54
|
+
output << " \\scoring{b=#{question.points},m=#{@penalty*question.points}}\n"
|
55
|
+
output << " " << to_tex(question.question_text) << "\n"
|
56
|
+
|
57
|
+
# answers - ignore randomization
|
58
|
+
|
59
|
+
output << " \\begin{choices}\n"
|
60
|
+
question.answers.each do |answer|
|
61
|
+
answer_text = to_tex(answer.answer_text)
|
62
|
+
answer_type = if answer.correct? then 'correct' else 'wrong' end
|
63
|
+
output << " \\#{answer_type}choice{#{answer_text}}\n"
|
64
|
+
end
|
65
|
+
output << " \\end{choices}\n"
|
66
|
+
output << "\\end{question}\n\n"
|
67
|
+
output
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
class EdXmlRenderer
|
2
|
+
require 'builder'
|
3
|
+
|
4
|
+
attr_reader :output
|
5
|
+
def initialize(quiz,options={})
|
6
|
+
@only_question = options.delete('n') || options.delete('name')
|
7
|
+
@output = ''
|
8
|
+
@b = Builder::XmlMarkup.new(:target => @output, :indent => 2)
|
9
|
+
@quiz = quiz
|
10
|
+
end
|
11
|
+
|
12
|
+
def render(thing)
|
13
|
+
case thing
|
14
|
+
when MultipleChoice,SelectMultiple,TrueFalse then render_multiple_choice(thing)
|
15
|
+
when FillIn then render_fill_in(thing)
|
16
|
+
else
|
17
|
+
raise "Unknown question type: #{thing}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def render_quiz
|
22
|
+
# entire quiz can be in one question group, as long as we specify
|
23
|
+
# that ALL question from the group must be used to make the quiz.
|
24
|
+
question_list = if @only_question
|
25
|
+
then @quiz.questions.select { |q| q.name == @only_question }
|
26
|
+
else @quiz.questions
|
27
|
+
end
|
28
|
+
question_list.each { |question| render(question) }
|
29
|
+
@output
|
30
|
+
end
|
31
|
+
|
32
|
+
def render_fill_in(question)
|
33
|
+
raise "Not yet implemented for edXML"
|
34
|
+
end
|
35
|
+
|
36
|
+
def render_multiple_choice(question)
|
37
|
+
@b.problem do
|
38
|
+
# if question text has explicit newlines, use them to separate <p>'s
|
39
|
+
if question.raw?
|
40
|
+
@b.p { |p| p << question.question_text }
|
41
|
+
else
|
42
|
+
question.question_text.lines.map(&:chomp).each do |line|
|
43
|
+
if question.raw? then @b.p { |p| p << line } else @b.p(line) end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
@b.multiplechoiceresponse do
|
47
|
+
@b.choicegroup :type => 'MultipleChoice' do
|
48
|
+
question.answers.each do |answer|
|
49
|
+
if question.raw?
|
50
|
+
@b.choice(:correct => answer.correct?) do |choice|
|
51
|
+
choice << answer.answer_text
|
52
|
+
choice << "\n"
|
53
|
+
end
|
54
|
+
else
|
55
|
+
@b.choice answer.answer_text, :correct => answer.correct?
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
@b.solution do
|
61
|
+
@b.div :class => 'detailed_solution' do
|
62
|
+
@b.p 'Explanation'
|
63
|
+
if question.raw?
|
64
|
+
@b.p { |p| p << question.correct_answer.explanation }
|
65
|
+
else
|
66
|
+
@b.p question.correct_answer.explanation
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
74
|
+
|
@@ -0,0 +1,162 @@
|
|
1
|
+
class Html5Renderer
|
2
|
+
require 'builder'
|
3
|
+
require 'erb'
|
4
|
+
|
5
|
+
attr_reader :output
|
6
|
+
|
7
|
+
def initialize(quiz,options={})
|
8
|
+
@css = options.delete('c') || options.delete('css')
|
9
|
+
@show_solutions = options.delete('s') || options.delete('solutions')
|
10
|
+
@template = options.delete('t') ||
|
11
|
+
options.delete('template') ||
|
12
|
+
File.join(Gem.loaded_specs['ruql'].full_gem_path, 'templates/html5.html.erb')
|
13
|
+
@output = ''
|
14
|
+
@quiz = quiz
|
15
|
+
@h = Builder::XmlMarkup.new(:target => @output, :indent => 2)
|
16
|
+
end
|
17
|
+
|
18
|
+
def render_quiz
|
19
|
+
if @template
|
20
|
+
render_with_template do
|
21
|
+
render_questions
|
22
|
+
@output
|
23
|
+
end
|
24
|
+
else
|
25
|
+
@h.html do
|
26
|
+
@h.head do
|
27
|
+
@h.title @quiz.title
|
28
|
+
@h.link(:rel => 'stylesheet', :type =>'text/css', :href=>@css) if @css
|
29
|
+
end
|
30
|
+
@h.body do
|
31
|
+
render_questions
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
self
|
36
|
+
end
|
37
|
+
|
38
|
+
def render_with_template
|
39
|
+
# local variables that should be in scope in the template
|
40
|
+
quiz = @quiz
|
41
|
+
# the ERB template includes 'yield' where questions should go:
|
42
|
+
output = ERB.new(IO.read(File.expand_path @template)).result(binding)
|
43
|
+
@output = output
|
44
|
+
end
|
45
|
+
|
46
|
+
def render_questions
|
47
|
+
render_random_seed
|
48
|
+
@h.ol :class => 'questions' do
|
49
|
+
@quiz.questions.each_with_index do |q,i|
|
50
|
+
case q
|
51
|
+
when MultipleChoice, SelectMultiple, TrueFalse then render_multiple_choice(q,i)
|
52
|
+
when FillIn then render_fill_in(q, i)
|
53
|
+
else
|
54
|
+
raise "Unknown question type: #{q}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
|
61
|
+
def render_multiple_choice(q,index)
|
62
|
+
render_question_text(q, index) do
|
63
|
+
answers =
|
64
|
+
if q.class == TrueFalse then q.answers.sort.reverse # True always first
|
65
|
+
elsif q.randomize then q.answers.sort_by { rand }
|
66
|
+
else q.answers
|
67
|
+
end
|
68
|
+
@h.ol :class => 'answers' do
|
69
|
+
answers.each do |answer|
|
70
|
+
if @show_solutions
|
71
|
+
render_answer_for_solutions(answer, q.raw?)
|
72
|
+
else
|
73
|
+
if q.raw? then @h.li { |l| l << answer.answer_text } else @h.li answer.answer_text end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
self
|
79
|
+
end
|
80
|
+
|
81
|
+
def render_fill_in(q, idx)
|
82
|
+
render_question_text(q, idx) do
|
83
|
+
if @show_solutions
|
84
|
+
answer = q.answers[0]
|
85
|
+
if answer.has_explanation?
|
86
|
+
if q.raw? then @h.p(:class => 'explanation') { |p| p << answer.explanation }
|
87
|
+
else @h.p(answer.explanation, :class => 'explanation') end
|
88
|
+
end
|
89
|
+
answers = (answer.answer_text.kind_of?(Array) ? answer.answer_text : [answer.answer_text])
|
90
|
+
@h.ol :class => 'answers' do
|
91
|
+
answers.each do |answer|
|
92
|
+
if answer.kind_of?(Regexp)
|
93
|
+
answer = answer.inspect
|
94
|
+
if !q.case_sensitive
|
95
|
+
answer += 'i'
|
96
|
+
end
|
97
|
+
end
|
98
|
+
@h.li do
|
99
|
+
if q.raw? then @h.p { |p| p << answer } else @h.p answer end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def render_answer_for_solutions(answer,raw)
|
108
|
+
args = {:class => (answer.correct? ? 'correct' : 'incorrect')}
|
109
|
+
@h.li(args) do
|
110
|
+
if raw then @h.p { |p| p << answer.answer_text } else @h.p answer.answer_text end
|
111
|
+
if answer.has_explanation?
|
112
|
+
if raw then @h.p(:class => 'explanation') { |p| p << answer.explanation }
|
113
|
+
else @h.p(answer.explanation, :class => 'explanation') end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def render_question_text(question,index)
|
119
|
+
html_args = {
|
120
|
+
:id => "question-#{index}",
|
121
|
+
:class => ['question', question.class.to_s.downcase, (question.multiple ? 'multiple' : '')]
|
122
|
+
.join(' ')
|
123
|
+
}
|
124
|
+
@h.li html_args do
|
125
|
+
@h.div :class => 'text' do
|
126
|
+
qtext = "[#{question.points} point#{'s' if question.points>1}] " <<
|
127
|
+
('Select ALL that apply: ' if question.multiple).to_s <<
|
128
|
+
if question.class == FillIn then question.question_text.gsub(/\-+/, '_____________________________')
|
129
|
+
else question.question_text
|
130
|
+
end
|
131
|
+
if question.raw?
|
132
|
+
@h.p { |p| p << qtext }
|
133
|
+
else
|
134
|
+
qtext.each_line do |p|
|
135
|
+
@h.p do |par|
|
136
|
+
par << p # preserves HTML markup
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
yield # render answers
|
142
|
+
end
|
143
|
+
self
|
144
|
+
end
|
145
|
+
|
146
|
+
def quiz_header
|
147
|
+
@h.div(:id => 'student-name') do
|
148
|
+
@h.p 'Name:'
|
149
|
+
@h.p 'Student ID:'
|
150
|
+
end
|
151
|
+
if @quiz.options[:instructions]
|
152
|
+
@h.div :id => 'instructions' do
|
153
|
+
@quiz.options[:instructions].each_line { |p| @h.p p }
|
154
|
+
end
|
155
|
+
end
|
156
|
+
self
|
157
|
+
end
|
158
|
+
|
159
|
+
def render_random_seed
|
160
|
+
@h.comment! "Seed: #{@quiz.seed}"
|
161
|
+
end
|
162
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
class JSONRenderer
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
attr_reader :output
|
5
|
+
|
6
|
+
def initialize(quiz,options={})
|
7
|
+
@output = ''
|
8
|
+
@json_array = []
|
9
|
+
@quiz = quiz
|
10
|
+
end
|
11
|
+
|
12
|
+
def render_quiz
|
13
|
+
@quiz.questions.each do |question|
|
14
|
+
case question
|
15
|
+
when MultipleChoice, SelectMultiple, TrueFalse then render_multiple_choice(question)
|
16
|
+
when FillIn then render_fill_in(question) # not currently supported
|
17
|
+
else
|
18
|
+
raise "Unknown question type: #{question}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
@output = JSON.pretty_generate(@json_array)
|
22
|
+
end
|
23
|
+
|
24
|
+
def render_multiple_choice(question)
|
25
|
+
question_hash = {
|
26
|
+
"text" => question.question_text,
|
27
|
+
"answers" => answers_to_json_array(question.answers)
|
28
|
+
}
|
29
|
+
@json_array.push(question_hash)
|
30
|
+
end
|
31
|
+
|
32
|
+
def answers_to_json_array(answers)
|
33
|
+
answers_array = []
|
34
|
+
answers.each do |answer|
|
35
|
+
answer_json = {
|
36
|
+
"text" => answer.answer_text,
|
37
|
+
"explanation" => answer.explanation,
|
38
|
+
"correct" => answer.correct
|
39
|
+
}
|
40
|
+
answers_array.push(answer_json)
|
41
|
+
end
|
42
|
+
answers_array
|
43
|
+
end
|
44
|
+
|
45
|
+
def render_fill_in(q)
|
46
|
+
# fill-in-the-blank questions not currently supported
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
class XMLRenderer
|
2
|
+
require 'builder'
|
3
|
+
|
4
|
+
attr_reader :output
|
5
|
+
def initialize(quiz,options={})
|
6
|
+
@output = ''
|
7
|
+
@b = Builder::XmlMarkup.new(:target => @output, :indent => 2)
|
8
|
+
@quiz = quiz
|
9
|
+
end
|
10
|
+
|
11
|
+
def render(thing)
|
12
|
+
case thing
|
13
|
+
when MultipleChoice,SelectMultiple,TrueFalse then render_multiple_choice(thing)
|
14
|
+
when FillIn then render_fill_in(thing)
|
15
|
+
else
|
16
|
+
raise "Unknown question type: #{thing}"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def render_quiz
|
21
|
+
# entire quiz can be in one question group, as long as we specify
|
22
|
+
# that ALL question from the group must be used to make the quiz.
|
23
|
+
xml_quiz do
|
24
|
+
# after preamble...
|
25
|
+
@b.question_groups do
|
26
|
+
@b.question_group(:select => @quiz.questions.length) do
|
27
|
+
@quiz.questions.each do |question|
|
28
|
+
self.render(question)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
@output
|
34
|
+
end
|
35
|
+
|
36
|
+
def render_fill_in(question)
|
37
|
+
@b.question :type => 'GS_Short_Answer_Question_Simple', :id => question.object_id.to_s(16) do
|
38
|
+
@b.metadata {
|
39
|
+
@b.parameters {
|
40
|
+
@b.rescale_score question.points
|
41
|
+
@b.type 'regexp'
|
42
|
+
}
|
43
|
+
}
|
44
|
+
# since we want all the options to appear, we create N option
|
45
|
+
# groups each containig 1 option, and specify that option to
|
46
|
+
# always be selected for inclusion in the quiz. If the original
|
47
|
+
# question specified 'random', use the 'randomize' attribute on
|
48
|
+
# option_groups to scramble the order in which displayed;
|
49
|
+
# otherwise, display in same order as answers appear in source.
|
50
|
+
@b.data {
|
51
|
+
@b.text { @b.cdata!(question.question_text) }
|
52
|
+
@b.option_groups(:randomize => !!question.randomize) {
|
53
|
+
@b.option_group(:select => 'all') {
|
54
|
+
question.answers.each do |answer|
|
55
|
+
option_args = {}
|
56
|
+
option_args['selected_score'] = answer.correct? ? 1 : 0
|
57
|
+
option_args['unselected_score'] =
|
58
|
+
question.multiple ? 1 - option_args['selected_score'] : 0
|
59
|
+
option_args['id'] = answer.object_id.to_s(16)
|
60
|
+
@b.option(option_args) do
|
61
|
+
answer_text = answer.answer_text
|
62
|
+
if answer_text.kind_of?(Regexp)
|
63
|
+
answer_text = answer_text.inspect
|
64
|
+
if !question.case_sensitive
|
65
|
+
answer_text += 'i'
|
66
|
+
end
|
67
|
+
end
|
68
|
+
@b.text { @b.cdata!(answer_text) }
|
69
|
+
@b.explanation { @b.cdata!(answer.explanation) } if answer.has_explanation?
|
70
|
+
end
|
71
|
+
end
|
72
|
+
}
|
73
|
+
}
|
74
|
+
}
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def render_multiple_choice(question)
|
79
|
+
@b.question :type => 'GS_Choice_Answer_Question', :id => question.object_id.to_s(16) do
|
80
|
+
@b.metadata {
|
81
|
+
@b.parameters {
|
82
|
+
@b.rescale_score question.points
|
83
|
+
@b.choice_type (question.multiple ? 'checkbox' : 'radio')
|
84
|
+
}
|
85
|
+
}
|
86
|
+
# since we want all the options to appear, we create N option
|
87
|
+
# groups each containig 1 option, and specify that option to
|
88
|
+
# always be selected for inclusion in the quiz. If the original
|
89
|
+
# question specified 'random', use the 'randomize' attribute on
|
90
|
+
# option_groups to scramble the order in which displayed;
|
91
|
+
# otherwise, display in same order as answers appear in source.
|
92
|
+
@b.data {
|
93
|
+
@b.text { @b.cdata!(question.question_text) }
|
94
|
+
@b.option_groups(:randomize => !!question.randomize) {
|
95
|
+
question.answers.each do |a|
|
96
|
+
@b.option_group(:select => 'all') {
|
97
|
+
self.render_multiple_choice_answer a, question.multiple
|
98
|
+
}
|
99
|
+
end
|
100
|
+
}
|
101
|
+
}
|
102
|
+
end
|
103
|
+
end
|
104
|
+
alias :render_true_false :render_multiple_choice
|
105
|
+
|
106
|
+
def render_multiple_choice_answer(answer, multiple_allowed)
|
107
|
+
option_args = {}
|
108
|
+
option_args['selected_score'] = answer.correct? ? 1 : 0
|
109
|
+
option_args['unselected_score'] =
|
110
|
+
multiple_allowed ? 1 - option_args['selected_score'] : 0
|
111
|
+
option_args['id'] = answer.object_id.to_s(16)
|
112
|
+
@b.option(option_args) do
|
113
|
+
@b.text { @b.cdata!(answer.answer_text) }
|
114
|
+
@b.explanation { @b.cdata!(answer.explanation) } if answer.has_explanation?
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
private
|
119
|
+
|
120
|
+
def options_to_xml(h)
|
121
|
+
h.each_pair do |k,v|
|
122
|
+
if v.is_a?(Hash)
|
123
|
+
@b.__send__(k.to_sym) do
|
124
|
+
options_to_xml v
|
125
|
+
end
|
126
|
+
else
|
127
|
+
@b.__send__(k.to_sym, v)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
|
133
|
+
def xml_quiz
|
134
|
+
@b.quiz do
|
135
|
+
@b.metadata do
|
136
|
+
@b.type 'quiz'
|
137
|
+
@b.title @quiz.title
|
138
|
+
options_to_xml @quiz.options
|
139
|
+
end
|
140
|
+
@b.preamble
|
141
|
+
@b.data do
|
142
|
+
yield
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
end
|
148
|
+
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module TexOutput
|
2
|
+
|
3
|
+
@@tex_replace = {
|
4
|
+
/_/ => '\textunderscore{}',
|
5
|
+
Regexp.new('<tt>([^<]+)</tt>', Regexp::IGNORECASE) => "\\texttt{\\1}",
|
6
|
+
Regexp.new('<pre>(.*?)</pre>', Regexp::MULTILINE | Regexp::IGNORECASE) => "\\begin{Verbatim}\\1\\end{Verbatim}",
|
7
|
+
}
|
8
|
+
|
9
|
+
@@tex_escape = Regexp.new '\$|&|%|#|\{|\}'
|
10
|
+
|
11
|
+
def to_tex(str)
|
12
|
+
str = str.gsub(@@tex_escape) { |match| "\\#{match}" }
|
13
|
+
@@tex_replace.each_pair do |match, replace|
|
14
|
+
str = str.gsub(match, replace)
|
15
|
+
end
|
16
|
+
str
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
class TrueFalse < Question
|
2
|
+
|
3
|
+
def initialize(text, correct_answer, opts=nil)
|
4
|
+
super
|
5
|
+
opts ||= {}
|
6
|
+
opts[:explanation] ||= ''
|
7
|
+
correct_answer = !!correct_answer # ensure 'true' or 'false' only
|
8
|
+
self.question_text = "True or False: #{text}"
|
9
|
+
self.answer correct_answer.to_s.capitalize
|
10
|
+
self.distractor (!correct_answer).to_s.capitalize, :explanation => opts[:explanation]
|
11
|
+
end
|
12
|
+
|
13
|
+
def multiple ; false ; end
|
14
|
+
def incorrect_answer ; self.answers.reject(&:correct).first ; end
|
15
|
+
def explanation ; incorrect_answer.explanation ; end
|
16
|
+
|
17
|
+
end
|
data/lib/ruql.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# basic gems/libs we rely on
|
2
|
+
require 'builder'
|
3
|
+
require 'logger'
|
4
|
+
|
5
|
+
$LOAD_PATH.unshift File.expand_path(File.join(File.dirname(__FILE__)))
|
6
|
+
|
7
|
+
# renderers
|
8
|
+
require 'ruql/renderers/xml_renderer'
|
9
|
+
require 'ruql/renderers/html5_renderer'
|
10
|
+
require 'ruql/renderers/edxml_renderer'
|
11
|
+
require 'ruql/renderers/auto_qcm_renderer'
|
12
|
+
require 'ruql/renderers/json_renderer'
|
13
|
+
|
14
|
+
# question types
|
15
|
+
require 'ruql/quiz'
|
16
|
+
require 'ruql/question'
|
17
|
+
require 'ruql/answer'
|
18
|
+
require 'ruql/multiple_choice'
|
19
|
+
require 'ruql/select_multiple'
|
20
|
+
require 'ruql/true_false'
|
21
|
+
require 'ruql/fill_in'
|
@@ -0,0 +1 @@
|
|
1
|
+
<%= yield %>
|
@@ -0,0 +1,44 @@
|
|
1
|
+
<html>
|
2
|
+
<head>
|
3
|
+
<title><%= quiz.title %></title>
|
4
|
+
<style type="text/css" media="all">
|
5
|
+
body { font-family: Times, serif; }
|
6
|
+
.header { text-align: right; font-weight: bold; padding-right: 30%; line-height: 300%; }
|
7
|
+
h1 { text-align: center; }
|
8
|
+
ol.questions { list-style-type: number; }
|
9
|
+
li.question { page-break-inside: avoid; border-bottom: 1px solid grey; padding-bottom: 2ex; }
|
10
|
+
li.multiplechoice ol.answers { list-style-type: lower-alpha; }
|
11
|
+
li.multiplechoice ol.answers li { padding-bottom: 0.5ex; }
|
12
|
+
li.selectmultiple ol.answers { vertical-align: center; list-style-type: none; list-style-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUAQMAAAC3R49OAAAABlBMVEUAAAD///+l2Z/dAAAAEklEQVQImWNgAIL6/w+ogoEAAKI4Kp2NVIeDAAAAAElFTkSuQmCC'); }
|
13
|
+
li.truefalse ol.answers { list-style-type: none; }
|
14
|
+
li.truefalse ol.answers li { width: 15%; display: inline-block; }
|
15
|
+
.correct { color: green }
|
16
|
+
.incorrect { color: red }
|
17
|
+
.explanation { font-style: italic }
|
18
|
+
.instructions { clear: both; border: 1px solid black; align: center; padding: 2ex; margin: 2ex; }
|
19
|
+
</style>
|
20
|
+
</head>
|
21
|
+
<body>
|
22
|
+
<div class="header">
|
23
|
+
<div id="name">Name:</div>
|
24
|
+
<div id="sid">SID:</div>
|
25
|
+
</div>
|
26
|
+
<h1><%= quiz.title %></h1>
|
27
|
+
<div class="instructions">
|
28
|
+
<ul>
|
29
|
+
<li>No books, notes, or electronic devices allowed. </li>
|
30
|
+
<li>Time limit is 30 minutes.</li>
|
31
|
+
<li><%= quiz.num_questions %> multiple-choice questions, points indicated per question,
|
32
|
+
<%= quiz.points %> points total. Points per question are intended
|
33
|
+
to reflect approximate times they should take, at about 1 point per minute.</li>
|
34
|
+
<li>For 'select all that apply' questions worth N points,
|
35
|
+
you get 1/N of the points for each RIGHT answer that you check, plus
|
36
|
+
1/N of the points for each WRONG answer that you correctly
|
37
|
+
leave unchecked. That is, equal weight is given to deciding
|
38
|
+
whether each choice is part of the right answer or not.</li>
|
39
|
+
</ul>
|
40
|
+
<b>Good skill!</b>
|
41
|
+
</div>
|
42
|
+
<%= yield %>
|
43
|
+
</body>
|
44
|
+
</html>
|
metadata
ADDED
@@ -0,0 +1,96 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ruql
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.2
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Armando Fox
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-12-16 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: builder
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>='
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '0'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: getopt
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '0'
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '0'
|
46
|
+
description: Ruby-embedded DSL for creating short-answer quiz questions
|
47
|
+
email: fox@cs.berkeley.edu
|
48
|
+
executables:
|
49
|
+
- ruql
|
50
|
+
extensions: []
|
51
|
+
extra_rdoc_files: []
|
52
|
+
files:
|
53
|
+
- lib/ruql.rb
|
54
|
+
- lib/ruql/quiz.rb
|
55
|
+
- lib/ruql/answer.rb
|
56
|
+
- lib/ruql/question.rb
|
57
|
+
- lib/ruql/renderer.rb
|
58
|
+
- lib/ruql/select_multiple.rb
|
59
|
+
- lib/ruql/fill_in.rb
|
60
|
+
- lib/ruql/multiple_choice.rb
|
61
|
+
- lib/ruql/true_false.rb
|
62
|
+
- lib/ruql/tex_output.rb
|
63
|
+
- lib/ruql/renderers/auto_qcm_renderer.rb
|
64
|
+
- lib/ruql/renderers/edxml_renderer.rb
|
65
|
+
- lib/ruql/renderers/html5_renderer.rb
|
66
|
+
- lib/ruql/renderers/json_renderer.rb
|
67
|
+
- lib/ruql/renderers/xml_renderer.rb
|
68
|
+
- templates/autoqcm.tex.erb
|
69
|
+
- templates/html5.html.erb
|
70
|
+
- bin/ruql
|
71
|
+
homepage: http://github.com/saasbook/ruql
|
72
|
+
licenses:
|
73
|
+
- CC By-SA
|
74
|
+
post_install_message:
|
75
|
+
rdoc_options: []
|
76
|
+
require_paths:
|
77
|
+
- lib
|
78
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
79
|
+
none: false
|
80
|
+
requirements:
|
81
|
+
- - ! '>='
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
version: '0'
|
84
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
85
|
+
none: false
|
86
|
+
requirements:
|
87
|
+
- - ! '>='
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
requirements: []
|
91
|
+
rubyforge_project:
|
92
|
+
rubygems_version: 1.8.25
|
93
|
+
signing_key:
|
94
|
+
specification_version: 3
|
95
|
+
summary: Ruby question language
|
96
|
+
test_files: []
|