ruql 0.1.3 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +5 -5
- data/.gitignore +14 -0
- data/.travis.yml +7 -0
- data/Gemfile +6 -0
- data/LICENSE +6 -0
- data/README.md +308 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/ruql +88 -98
- data/bin/setup +8 -0
- data/examples/estilo.css +1 -0
- data/examples/example.rb +37 -0
- data/examples/file.html +94 -0
- data/examples/help.txt +55 -0
- data/examples/preguntas-TFG-20140224-0050.txt +32 -0
- data/examples/preguntas-TFG-20140224-0050.xml +168 -0
- data/examples/prueba.js +3 -0
- data/lib/ruql.rb +7 -3
- data/lib/ruql/answer.rb +2 -14
- data/lib/ruql/multiple_choice.rb +1 -1
- data/lib/ruql/open_assessment/open_assessment.rb +1 -1
- data/lib/ruql/question.rb +11 -46
- data/lib/ruql/quiz.rb +47 -36
- data/lib/ruql/renderers/edxml_renderer.rb +1 -1
- data/lib/ruql/renderers/html5_renderer.rb +58 -16
- data/lib/ruql/renderers/json_renderer.rb +35 -1
- data/lib/ruql/renderers/xml_renderer.rb +148 -0
- data/lib/ruql/select_multiple.rb +1 -0
- data/lib/ruql/stats.rb +18 -0
- data/lib/ruql/true_false.rb +1 -1
- data/lib/ruql/version.rb +3 -0
- data/ruql.gemspec +43 -0
- metadata +57 -39
- data/templates/autoqcm.tex.erb +0 -1
- data/templates/html5.html.erb +0 -42
- data/templates/htmlform.html.erb +0 -44
data/lib/ruql/multiple_choice.rb
CHANGED
@@ -115,7 +115,7 @@ class OpenAssessment
|
|
115
115
|
# Adds fields for a simple_open_assessment question
|
116
116
|
def add_simple_question
|
117
117
|
criterion = Criterion.new
|
118
|
-
criterion.name("How'd you do?")
|
118
|
+
criterion.name("How'd you do?")
|
119
119
|
criterion.label("Scoring Rubric")
|
120
120
|
|
121
121
|
raise "Must have answer for question" if @question_answer.nil?
|
data/lib/ruql/question.rb
CHANGED
@@ -1,40 +1,27 @@
|
|
1
1
|
class Question
|
2
|
-
attr_accessor :question_text,
|
3
|
-
|
4
|
-
:question_image,
|
5
|
-
:randomize,
|
6
|
-
:points,
|
7
|
-
:name,
|
8
|
-
:question_tags,
|
9
|
-
:question_uid,
|
10
|
-
:question_comment,
|
11
|
-
:raw
|
12
|
-
|
2
|
+
attr_accessor :question_text, :answers, :randomize, :points, :name, :question_tags, :question_group, :question_comment, :uid
|
3
|
+
|
13
4
|
def initialize(*args)
|
14
5
|
options = if args[-1].kind_of?(Hash) then args[-1] else {} end
|
15
6
|
@answers = options[:answers] || []
|
16
7
|
@points = [options[:points].to_i, 1].max
|
17
8
|
@raw = options[:raw]
|
18
9
|
@name = options[:name]
|
19
|
-
@question_image = options[:image]
|
20
10
|
@question_tags = []
|
21
|
-
@
|
11
|
+
@question_group = ''
|
22
12
|
@question_comment = ''
|
23
13
|
end
|
14
|
+
|
15
|
+
def uid(str); @uid = str; end
|
16
|
+
|
24
17
|
def raw? ; !!@raw ; end
|
25
|
-
|
26
|
-
def uid(u) ; @question_uid = u ; end
|
27
|
-
|
18
|
+
|
28
19
|
def text(s) ; @question_text = s ; end
|
29
20
|
|
30
21
|
def explanation(text)
|
31
22
|
@answers.each { |answer| answer.explanation ||= text }
|
32
23
|
end
|
33
24
|
|
34
|
-
def image(url)
|
35
|
-
@question_image = url
|
36
|
-
end
|
37
|
-
|
38
25
|
def answer(text, opts={})
|
39
26
|
@answers << Answer.new(text, correct=true, opts[:explanation])
|
40
27
|
end
|
@@ -43,6 +30,10 @@ class Question
|
|
43
30
|
@answers << Answer.new(text, correct=false, opts[:explanation])
|
44
31
|
end
|
45
32
|
|
33
|
+
def group(str)
|
34
|
+
@question_group = str
|
35
|
+
end
|
36
|
+
|
46
37
|
# these are ignored but legal for now:
|
47
38
|
def tags(*args) # string or array of strings
|
48
39
|
if args.length > 1
|
@@ -60,30 +51,4 @@ class Question
|
|
60
51
|
|
61
52
|
def correct_answers ; @answers.collect(&:correct?) ; end
|
62
53
|
|
63
|
-
def answer_helper(obj)
|
64
|
-
if obj.is_a? Array and obj.size and obj[0].is_a? Answer
|
65
|
-
return obj.map {|answer| answer.to_JSON}
|
66
|
-
end
|
67
|
-
obj
|
68
|
-
end
|
69
|
-
|
70
|
-
#creates a JSON hash of the object with its object name. we should convert this to a mixin for answer and question. aaron
|
71
|
-
def to_JSON
|
72
|
-
h = Hash[instance_variables.collect { |var| [var.to_s.delete('@'), answer_helper(instance_variable_get(var))] }]
|
73
|
-
h['question_type'] = self.class.to_s
|
74
|
-
return h
|
75
|
-
end
|
76
|
-
|
77
|
-
#factory method to return correct type of question
|
78
|
-
def self.from_JSON(hash_str)
|
79
|
-
hash = JSON.parse(hash_str)
|
80
|
-
#create the appropriate class of the object from the hash's class name
|
81
|
-
question = Object.const_get(hash.fetch('question_type')).new()
|
82
|
-
hash.reject{|key| key == 'answers' or key == 'question_type'}.each do |key, value|
|
83
|
-
question.send((key + '=').to_sym, value)
|
84
|
-
end
|
85
|
-
question.answers = hash['answers'].map{|answer_hash| Answer.from_JSON(answer_hash)}
|
86
|
-
question
|
87
|
-
end
|
88
|
-
|
89
54
|
end
|
data/lib/ruql/quiz.rb
CHANGED
@@ -1,24 +1,6 @@
|
|
1
1
|
class Quiz
|
2
2
|
@@quizzes = []
|
3
|
-
|
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
|
-
}
|
3
|
+
@@options = {}
|
22
4
|
|
23
5
|
attr_reader :renderer
|
24
6
|
attr_reader :questions
|
@@ -26,36 +8,65 @@ class Quiz
|
|
26
8
|
attr_reader :output
|
27
9
|
attr_reader :seed
|
28
10
|
attr_reader :logger
|
29
|
-
attr_accessor :title
|
11
|
+
attr_accessor :title
|
12
|
+
|
13
|
+
def self.reset
|
14
|
+
@@quizzes = []
|
15
|
+
@@options = {}
|
16
|
+
end
|
17
|
+
def self.quizzes ; @@quizzes ; end
|
18
|
+
def self.options ; @@options ; end
|
30
19
|
|
31
20
|
def initialize(title, options={})
|
32
21
|
@output = ''
|
33
|
-
@questions = options
|
22
|
+
@questions = options[:questions] || []
|
34
23
|
@title = title
|
35
|
-
@options = @@
|
24
|
+
@options = @@options.merge(options)
|
36
25
|
@seed = srand
|
37
26
|
@logger = Logger.new(STDERR)
|
38
|
-
@logger.level =
|
39
|
-
|
40
|
-
if (yaml = options.delete(:yaml))
|
41
|
-
@quiz_yaml = YAML::load(IO.read yaml)
|
42
|
-
end
|
43
|
-
end
|
44
|
-
|
45
|
-
def self.get_renderer(renderer)
|
46
|
-
Object.const_get(renderer.to_s + 'Renderer') rescue nil
|
27
|
+
@logger.level = (@options['-V'] || @options['--verbose']) ? Logger::INFO : Logger::WARN
|
28
|
+
#@quiz_yaml = yaml
|
47
29
|
end
|
48
30
|
|
49
31
|
def render_with(renderer,options={})
|
50
32
|
srand @seed
|
51
|
-
@renderer =
|
33
|
+
@renderer = renderer.send(:new,self,options)
|
52
34
|
@renderer.render_quiz
|
53
35
|
@output = @renderer.output
|
54
36
|
end
|
55
37
|
|
56
|
-
def
|
38
|
+
def self.set_options(options)
|
39
|
+
@@options = options
|
40
|
+
end
|
41
|
+
|
42
|
+
def ungrouped_questions
|
43
|
+
questions.filter { |q| q.question_group.to_s == '' }
|
44
|
+
end
|
45
|
+
|
46
|
+
def grouped_questions
|
47
|
+
questions.filter { |q| q.question_group.to_s != '' }.sort_by(&:question_group)
|
48
|
+
end
|
57
49
|
|
58
|
-
def
|
50
|
+
def groups ; questions.map(&:question_group).uniq.reject { |g| g.to_s == '' } ; end
|
51
|
+
|
52
|
+
def ungrouped_points
|
53
|
+
ungrouped_questions.map(&:points).sum
|
54
|
+
end
|
55
|
+
|
56
|
+
def grouped_points
|
57
|
+
gq = grouped_questions
|
58
|
+
groups.sum do |g|
|
59
|
+
gq.detect { |q| q.question_group == g }.points
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def points
|
64
|
+
ungrouped_points + grouped_points
|
65
|
+
end
|
66
|
+
|
67
|
+
def num_questions
|
68
|
+
groups.length + ungrouped_questions.length
|
69
|
+
end
|
59
70
|
|
60
71
|
def random_seed(num)
|
61
72
|
@seed = num.to_i
|
@@ -126,8 +137,8 @@ class Quiz
|
|
126
137
|
@questions << q
|
127
138
|
end
|
128
139
|
|
129
|
-
def self.quiz(
|
130
|
-
quiz = Quiz.new(
|
140
|
+
def self.quiz(title, args={}, &block)
|
141
|
+
quiz = Quiz.new(title, args)
|
131
142
|
quiz.instance_eval(&block)
|
132
143
|
@@quizzes << quiz
|
133
144
|
end
|
@@ -5,21 +5,65 @@ class Html5Renderer
|
|
5
5
|
attr_reader :output
|
6
6
|
|
7
7
|
def initialize(quiz,options={})
|
8
|
+
@css = options.delete('c') || options.delete('css')
|
8
9
|
@show_solutions = options.delete('s') || options.delete('solutions')
|
10
|
+
@show_tags = options.delete('T') || options.delete('show-tags')
|
9
11
|
@template = options.delete('t') ||
|
10
12
|
options.delete('template') ||
|
11
|
-
File.join(
|
13
|
+
File.join((Gem.loaded_specs['ruql'].full_gem_path rescue '.'), 'templates/html5.html.erb')
|
12
14
|
@output = ''
|
13
|
-
@list_type = options.delete('o') || options.delete('list-type') || '1'
|
14
|
-
@list_start = options.delete('a') || options.delete('list-start') || '1'
|
15
15
|
@quiz = quiz
|
16
16
|
@h = Builder::XmlMarkup.new(:target => @output, :indent => 2)
|
17
17
|
end
|
18
18
|
|
19
|
+
def allowed_options
|
20
|
+
opts = [
|
21
|
+
['-c', '--css', Getopt::REQUIRED],
|
22
|
+
['-t', '--template', Getopt::REQUIRED],
|
23
|
+
['-s', '--solutions', Getopt::BOOLEAN],
|
24
|
+
['-T', '--show-tags', Getopt::BOOLEAN]
|
25
|
+
]
|
26
|
+
help = <<eos
|
27
|
+
The HTML5 and HTML Forms renderers supports these options:
|
28
|
+
-c <href>, --css=<href>
|
29
|
+
embed <href> for stylesheet into generated HTML5
|
30
|
+
-j <src>, --js=<src>
|
31
|
+
embed <src> for JavaScrips
|
32
|
+
-t <file.html.erb>, --template=<file.html.erb>
|
33
|
+
Use file.html.erb as HTML template rather than generating our own file.
|
34
|
+
file.html.erb should have <%= yield %> where questions should go.
|
35
|
+
An example is in the templates/ directory of RuQL.
|
36
|
+
The following local variables will be replaced with their values in
|
37
|
+
the template:
|
38
|
+
<%= quiz.title %> - the quiz title
|
39
|
+
<%= quiz.num_questions %> - total number of questions
|
40
|
+
<%= quiz.points %> - total number of points for whole quiz
|
41
|
+
-T, --show-tags
|
42
|
+
Show the tag(s) associated with a question within an element <div class="tags">.
|
43
|
+
-s, --solutions
|
44
|
+
generate solutions (showing correct answers and explanations)
|
45
|
+
NOTE: If there is more than one quiz (collection of questions) in the file,
|
46
|
+
a complete <html>...</html> block is produced in the output for EACH quiz.
|
47
|
+
eos
|
48
|
+
return(
|
49
|
+
|
50
|
+
|
19
51
|
def render_quiz
|
20
|
-
|
21
|
-
|
22
|
-
|
52
|
+
if @template
|
53
|
+
render_with_template do
|
54
|
+
render_questions
|
55
|
+
@output
|
56
|
+
end
|
57
|
+
else
|
58
|
+
@h.html do
|
59
|
+
@h.head do
|
60
|
+
@h.title @quiz.title
|
61
|
+
@h.link(:rel => 'stylesheet', :type =>'text/css', :href=>@css) if @css
|
62
|
+
end
|
63
|
+
@h.body do
|
64
|
+
render_questions
|
65
|
+
end
|
66
|
+
end
|
23
67
|
end
|
24
68
|
self
|
25
69
|
end
|
@@ -34,7 +78,7 @@ class Html5Renderer
|
|
34
78
|
|
35
79
|
def render_questions
|
36
80
|
render_random_seed
|
37
|
-
@h.ol :class => 'questions'
|
81
|
+
@h.ol :class => 'questions' do
|
38
82
|
@quiz.questions.each_with_index do |q,i|
|
39
83
|
case q
|
40
84
|
when MultipleChoice, SelectMultiple, TrueFalse then render_multiple_choice(q,i)
|
@@ -111,23 +155,21 @@ class Html5Renderer
|
|
111
155
|
def render_question_text(question,index)
|
112
156
|
html_args = {
|
113
157
|
:id => "question-#{index}",
|
114
|
-
:'
|
115
|
-
|
158
|
+
:class => ['question', question.class.to_s.downcase, (question.multiple ? 'multiple' : '')]
|
159
|
+
.join(' ')
|
116
160
|
}
|
117
|
-
if question.question_image # add CSS class to both <li> and <img>
|
118
|
-
html_args[:class] << 'question-with-image'
|
119
|
-
end
|
120
161
|
@h.li html_args do
|
121
|
-
# if there's an image, render it first
|
122
|
-
if question.question_image
|
123
|
-
@h.img :src => question.question_image, :class => 'question-image'
|
124
|
-
end
|
125
162
|
@h.div :class => 'text' do
|
126
163
|
qtext = "[#{question.points} point#{'s' if question.points>1}] " <<
|
127
164
|
('Select ALL that apply: ' if question.multiple).to_s <<
|
128
165
|
if question.class == FillIn then question.question_text.gsub(/\-+/, '_____________________________')
|
129
166
|
else question.question_text
|
130
167
|
end
|
168
|
+
if @show_tags
|
169
|
+
@h.div(:class => 'text') do
|
170
|
+
question.tags.join(',')
|
171
|
+
end
|
172
|
+
end
|
131
173
|
if question.raw?
|
132
174
|
@h.p { |p| p << qtext }
|
133
175
|
else
|
@@ -10,6 +10,40 @@ class JSONRenderer
|
|
10
10
|
end
|
11
11
|
|
12
12
|
def render_quiz
|
13
|
-
@
|
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)
|
14
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
|
+
|
15
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
|
+
|