ruql 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|