question_kit 0.1.0 → 0.3.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 37b2cc86be99b7513693957fd7e67cd9f19209c47ab31678836acfc0a8332661
4
- data.tar.gz: 882bc674404e1cf01084da328595a14a0deff0096c96a775648ba97e54960d7a
3
+ metadata.gz: c5639e96431bb3c4754d3655dee3b8805ff685db6946f720688708a3dea83768
4
+ data.tar.gz: 54d2beff617fcab5f988fe30fdef1680db0e68e9750be6de1455bc158639c9a6
5
5
  SHA512:
6
- metadata.gz: f51ba58ee9eafbeaa9faa1539f617c5bbddb09bf0248a35462537323f5fda0131968c725ec4f867de5f671ee472443067dce5b3ac2b4085cc6f076ee6dd69a46
7
- data.tar.gz: c7be35b2bbe19282322aa576353a6a7ee50f3f1479b42e787e1688f3884d220d01812a2bec116446bc655262346642bb4236c910dd39da2826a1666b9f9eab83
6
+ metadata.gz: 37c3a74f4a42956916a4d8fd9cdeedb62c7e77405cee84b280aca71b097a11505d89f486485a83da333c41719bb94d7c04a60abc7439b4b679b2e9bc2d58a5f4
7
+ data.tar.gz: 2a8adaeeba7b2db1bdbbd9e63683bf33e8e74c9e8b0c2b57cb3c51f9446edcadfb3465a926ce5e24570bc7a309a085eb91090228623454972cd12b935533744e
@@ -0,0 +1,25 @@
1
+ module QuestionKit
2
+ module AnswerExplanations
3
+ TEXT = {
4
+ "question" => {
5
+ "answers.info" => "Extra detail saved with this answer, which the question page does not show to a player.",
6
+ "answers.hint" => "A smaller line a player sees under this answer's text.",
7
+ "answers.label" => "The text a player sees for this answer in place of its value.",
8
+ "answers.value" => "The text saved when a player picks this answer, which the player also sees when the label is blank or the flow shows answer values.",
9
+ "answers.weight" => "The points a player scores by picking this answer, and any weight above zero makes it a right answer."
10
+ },
11
+ "multiple_select" => {
12
+ "answers.label" => "The text a player sees for this answer in place of its value.",
13
+ "answers.value" => "The text saved when a player ticks this answer, which the player also sees when the label is blank.",
14
+ "answers.weight" => "Any weight above zero makes this a right answer, whatever its size, and the question scores one point only when a player ticks every right answer and no others."
15
+ },
16
+ "fill_in_the_blank" => {
17
+ "accepted_answers.value" => "A typed answer that matches this text, ignoring capitals and the spaces around it, is right and scores one point."
18
+ }
19
+ }.freeze
20
+
21
+ def explanation_for(step_type, setting)
22
+ super || TEXT.dig(step_type.to_s, setting.to_s)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ module QuestionKit
2
+ module AnswerKey
3
+ def self.chosen(step)
4
+ case step["type"]
5
+ when "multiple_select" then Score.right_answers(step)
6
+ when "fill_in_the_blank" then Array(step["accepted_answers"]).first&.fetch("value", nil)
7
+ else Score.right_answers(step).first
8
+ end
9
+ end
10
+
11
+ def self.accepted(step)
12
+ Array(step["accepted_answers"]).filter_map { |answer| answer["value"].presence }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,18 @@
1
+ module QuestionKit
2
+ class QuestionImporter < Ixport::Importer
3
+ field :type, label: "Type"
4
+ field :question, label: "Question"
5
+ field :category, label: "Category"
6
+ UploadedQuestion::ANSWER_FIELDS.each.with_index(1) { |key, number| field key, label: "Answer #{number}" }
7
+ field :right_answer, label: "Right answer"
8
+
9
+ def records
10
+ UploadedQuestions.new(target)
11
+ end
12
+
13
+ def self.allows?(account, quiz)
14
+ host = EasyFlow.host_named(QuestionKit.flow_host)
15
+ quiz.is_a?(EasyFlow::Definition) && quiz.host == QuestionKit.flow_host.to_s && (host.owner_method.nil? || quiz.owner == account)
16
+ end
17
+ end
18
+ end
@@ -1,4 +1,11 @@
1
1
  module QuestionKit
2
2
  class Railtie < ::Rails::Railtie
3
+ initializer "question_kit.answer_explanations" do |app|
4
+ app.config.to_prepare { QuestionKit.explain_answer_fields }
5
+ end
6
+
7
+ initializer "question_kit.question_importer" do |app|
8
+ app.config.to_prepare { require "question_kit/question_importer" if defined?(Ixport::Importer) }
9
+ end
3
10
  end
4
11
  end
@@ -0,0 +1,32 @@
1
+ module QuestionKit
2
+ module Score
3
+ def self.total(answers, steps)
4
+ answers.sum { |id, value| worth(steps[id.to_s].to_h, value) }
5
+ end
6
+
7
+ def self.worth(step, value)
8
+ return picked_worth(step, value) if step["type"] == "multiple_select"
9
+ return typed_worth(step, value) if step["type"] == "fill_in_the_blank"
10
+
11
+ chosen = EasyFlow::Steps::Question.answers_of(step).find { |answer| answer.is_a?(Hash) && answer["value"] == value }
12
+
13
+ chosen&.fetch("weight", nil).to_i
14
+ end
15
+
16
+ def self.typed_worth(step, text)
17
+ Array(step["accepted_answers"]).any? { |accepted| comparable(accepted["value"]) == comparable(text) } ? 1 : 0
18
+ end
19
+
20
+ def self.comparable(text)
21
+ text.to_s.strip.downcase
22
+ end
23
+
24
+ def self.picked_worth(step, values)
25
+ Array(values).sort == right_answers(step).sort ? 1 : 0
26
+ end
27
+
28
+ def self.right_answers(step)
29
+ EasyFlow::Steps::Question.answers_of(step).filter_map { |answer| answer["value"] if answer["weight"].to_i.positive? }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,10 @@
1
+ module QuestionKit
2
+ module TimeTaken
3
+ def self.seconds(run)
4
+ return unless run.flow.each_step?
5
+
6
+ last_answered = run.completed_at.values.max
7
+ (last_answered - run.created_at).to_i if last_answered
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,43 @@
1
+ module QuestionKit
2
+ class TitledStart
3
+ FALLBACK_SLUG = "quiz"
4
+ MADE_SLUG_LENGTH = 100
5
+ TRIES_FOR_A_FREE_SLUG = 3
6
+ SLUGS_THE_ROUTES_USE = %w[manage pages runs].freeze
7
+
8
+ def initialize(flow)
9
+ @flow = flow
10
+ end
11
+
12
+ def save
13
+ return refuse_blank_title if @flow.title.blank?
14
+
15
+ tries = 0
16
+ begin
17
+ @flow.slug = free_slug
18
+ @flow.save
19
+ rescue ActiveRecord::RecordNotUnique
20
+ retry if (tries += 1) < TRIES_FOR_A_FREE_SLUG
21
+ raise
22
+ end
23
+ end
24
+
25
+ private
26
+ def refuse_blank_title
27
+ @flow.errors.add(:title, :blank)
28
+ false
29
+ end
30
+
31
+ def free_slug
32
+ made = @flow.title.parameterize.first(MADE_SLUG_LENGTH).chomp("-").presence || FALLBACK_SLUG
33
+ numbered = (2..).lazy.map { |number| "#{made}-#{number}" }
34
+ taken = unavailable
35
+
36
+ [ made ].chain(numbered).find { |candidate| taken.exclude?(candidate) }
37
+ end
38
+
39
+ def unavailable
40
+ EasyFlow::Definition.where(host: @flow.host, owner: @flow.owner).pluck(:slug) + SLUGS_THE_ROUTES_USE
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,84 @@
1
+ module QuestionKit
2
+ class UploadedQuestion
3
+ include ActiveModel::Model
4
+ include ActiveModel::Attributes
5
+
6
+ attr_accessor :flow
7
+
8
+ attribute :type, :string
9
+ attribute :question, :string
10
+ attribute :category, :string
11
+ attribute :right_answer, :string
12
+ ANSWER_FIELDS = (1..6).map { |number| :"answer_#{number}" }
13
+ ANSWER_FIELDS.each { |field| attribute field, :string }
14
+
15
+ KINDS = %w[truefalse multiplechoice].freeze
16
+
17
+ validates :question, presence: true
18
+ validate :kind_is_offered
19
+ validate :choices_are_offered, if: -> { kind == "multiplechoice" }
20
+ validate :true_or_false_is_right, if: -> { kind == "truefalse" }
21
+
22
+ def save!
23
+ before = document.to_h
24
+ flow.edit_history.record(appended, :added, [ step["id"] ], before)
25
+ end
26
+
27
+ private
28
+ def choices_are_offered
29
+ errors.add(:answers, "must be at least two for a multiple choice question") if filled_answers.size < 2
30
+ errors.add(:right_answer, "must be the number of one of its answers") if answers.none? { |answer| answer["weight"] == 1 }
31
+ end
32
+
33
+ def true_or_false_is_right
34
+ errors.add(:right_answer, "must be true or false") if answers.none? { |answer| answer["weight"] == 1 }
35
+ end
36
+
37
+ def kind_is_offered
38
+ errors.add(:type, "must be true/false or multiple choice") unless KINDS.include?(kind)
39
+ end
40
+
41
+ def document
42
+ EasyFlow::Document.new(flow.document || flow.definition || {})
43
+ end
44
+
45
+ def appended
46
+ leaving = document.edges_from(last_question).first
47
+ return document.insert(step, on: [ last_question, leaving.to ]) if leaving
48
+
49
+ document.add(step).connect(from: last_question, to: step["id"])
50
+ end
51
+
52
+ def last_question
53
+ reached = document.reachable
54
+ reached.reverse.find { |id| document.node(id)&.type == "question" } || reached.last
55
+ end
56
+
57
+ def step
58
+ @step ||= { "id" => next_id, "type" => "question", "question" => question, "category" => category.presence, "answers" => answers }.compact
59
+ end
60
+
61
+ def answers
62
+ return true_false_answers if kind == "truefalse"
63
+
64
+ filled_answers.map { |number, text| { "value" => text, "weight" => number.to_s == right_answer.to_s.strip ? 1 : 0 } }
65
+ end
66
+
67
+ def true_false_answers
68
+ %w[True False].map { |text| { "value" => text, "weight" => text.casecmp?(right_answer.to_s.strip) ? 1 : 0 } }
69
+ end
70
+
71
+ def kind
72
+ type.to_s.downcase.gsub(/[^a-z]/, "")
73
+ end
74
+
75
+ def filled_answers
76
+ ANSWER_FIELDS.each_with_index.filter_map { |field, index| [ index + 1, public_send(field).strip ] if public_send(field).present? }
77
+ end
78
+
79
+ def next_id
80
+ taken = document.nodes.map(&:id)
81
+ (1..).lazy.map { |number| "question_#{number}" }.find { |id| !taken.include?(id) }
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,15 @@
1
+ module QuestionKit
2
+ class UploadedQuestions
3
+ def initialize(flow)
4
+ @flow = flow
5
+ end
6
+
7
+ def new(values)
8
+ UploadedQuestion.new(values.merge(flow: @flow))
9
+ end
10
+
11
+ def where(*)
12
+ []
13
+ end
14
+ end
15
+ end
@@ -1,3 +1,3 @@
1
1
  module QuestionKit
2
- VERSION = "0.1.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/question_kit.rb CHANGED
@@ -1,7 +1,25 @@
1
1
  require "easy_flow"
2
2
  require "question_kit/version"
3
3
  require "question_kit/railtie"
4
+ require "question_kit/score"
5
+ require "question_kit/answer_key"
6
+ require "question_kit/time_taken"
7
+ require "question_kit/titled_start"
8
+ require "question_kit/uploaded_question"
9
+ require "question_kit/uploaded_questions"
10
+ require "question_kit/answer_explanations"
4
11
 
5
12
  module QuestionKit
6
- # Your code goes here...
13
+ class << self
14
+ attr_reader :flow_host
15
+
16
+ def flow_host=(name)
17
+ @flow_host = name
18
+ explain_answer_fields
19
+ end
20
+
21
+ def explain_answer_fields
22
+ EasyFlow.host_named(flow_host).extend(AnswerExplanations) if flow_host
23
+ end
24
+ end
7
25
  end
@@ -1,50 +1,180 @@
1
1
  ---
2
2
  name: question_kit-develop
3
- description: Use PROACTIVELY for reading which question_kit version a host app has loaded, and for checking that version before code relies on a question_kit release — MUST BE USED instead of reading Gem.loaded_specs, parsing Gemfile.lock, or comparing version strings by hand.
3
+ description: Use PROACTIVELY for scoring an easy_flow quiz — working out what one answer to a question, multiple-select or fill-in-the-blank step is worth, adding up a run's total score, listing a question's right answers, showing a question's answer key or every accepted spelling of a fill-in-the-blank answer, timing how long a visitor took over a quiz run — and for building quizzes — starting a new quiz from its title with a free address slug, finding which easy_flow host runs the quizzes, letting a builder upload a CSV file of true/false and multiple choice questions into a quiz through ixport, limiting that upload to the builder's own quizzes, adding one uploaded question to a quiz, reading or replacing the explanations an admin sees beside a question's answer settings, and reading which question_kit version is loaded — MUST BE USED instead of summing answer weights by hand, comparing typed answers to accepted answers in host code, picking out right answers from a step's answer list, subtracting run timestamps, making a quiz slug from its title, writing a CSV question importer or a loop that adds question steps to a flow, or writing a permission check for uploading into a quiz.
4
4
  tools: Bash, Read, Write, Edit, Grep
5
5
  scope: shared question and answer rules for easy_flow quizzes — what an answer to a question step is worth, which answers are right, and how a quiz is built
6
6
  ---
7
7
 
8
- This local writes host app code against question_kit's declared interface only, and follows these steps exactly.
8
+ This local writes host app code that scores, keys, times and builds easy_flow quizzes through question_kit's declared interface only, and follows the steps below in order.
9
9
 
10
10
  ## What question_kit is
11
11
 
12
- A Rails gem for apps that run quizzes as easy_flow flows, meant to hold the rules those apps share: what an answer to a question step is worth, which answers are right, and how a quiz is built. Today its only code-facing interface is its version constant. Use this local when host app code needs to know which question_kit release is loaded. Building the quiz flow, its question steps, runs and answers belongs to the easy_flow locals.
12
+ A Rails gem for apps that run quizzes as easy_flow flows on one easy_flow host, the flow host. It holds the rules those apps share: what an answer to a question step is worth, which answers are right, how long a visitor took, how a new quiz gets its slug, how a CSV file of questions becomes question steps, and the explanations an admin sees beside a question's answer settings. The scoring, key and timing methods take plain step hashes and answer values, or an easy_flow run, and write nothing. Use this local whenever host code needs a score, an answer key, a time taken, a new quiz, or a question upload. Flows, step types, runs and answers in general belong to the easy_flow locals, and ixport's own pages and configuration belong to the ixport locals.
13
13
 
14
14
  ## Interface
15
15
 
16
- - `QuestionKit::VERSION` — a string such as `"0.1.0"` naming the question_kit release loaded in the host app.
16
+ - `QuestionKit::VERSION` — a string such as `"0.2.0"` naming the question_kit release loaded in the host app.
17
+ - `QuestionKit::Score.worth(step, value)` — returns the integer one answer `value` to the step hash `step` is worth.
18
+ - `QuestionKit::Score.total(answers, steps)` — returns the integer sum of the worth of every answer in a run.
19
+ - `QuestionKit::Score.right_answers(step)` — returns the list of answer values on a choice step that carry a positive weight.
20
+ - `QuestionKit::AnswerKey.chosen(step)` — returns the answer key to show for a step: one value, a list of values, or `nil`.
21
+ - `QuestionKit::AnswerKey.accepted(step)` — returns every non-blank accepted answer on a fill-in-the-blank step.
22
+ - `QuestionKit::TimeTaken.seconds(run)` — returns the whole seconds from a run's start to its last answer, or `nil`.
23
+ - `QuestionKit.flow_host` — returns the name of the easy_flow host that runs the app's quizzes, exactly as the install set it, or `nil` when none is set.
24
+ - `QuestionKit::TitledStart.new(flow).save` — gives an unsaved quiz a slug made from its title and saves it, returning `true` or `false`.
25
+ - `QuestionKit::UploadedQuestion` — one row of a question upload: validates it and, once valid, adds it to the quiz as a question step.
26
+ - `QuestionKit::UploadedQuestion::ANSWER_FIELDS` — the six answer field names, `:answer_1` to `:answer_6`, a row may fill.
27
+ - `QuestionKit::UploadedQuestions.new(flow)` — the set of uploaded rows for one quiz, which builds an `UploadedQuestion` bound to that quiz from a row's values.
28
+ - `QuestionKit::QuestionImporter` — the ixport importer class that turns each row of a CSV file into a question step in the quiz the upload targets.
29
+ - `QuestionKit::QuestionImporter.allows?(account, quiz)` — returns whether `account` may upload questions into `quiz`.
30
+ - `QuestionKit::AnswerExplanations` — the explanations question_kit adds to the flow host's answer settings, which an admin sees beside each field.
31
+ - `QuestionKit::AnswerExplanations::TEXT` — a frozen hash from step type to setting to the explanation text question_kit shows.
32
+
33
+ ## The step hash
34
+
35
+ Every scoring, key and timing method that takes a `step` takes the step's settings as a hash with string keys, as easy_flow stores them in a flow's document. Pass the hash, not a record.
36
+
37
+ - `"type"` decides how it is read: `"multiple_select"`, `"fill_in_the_blank"`, or any other value (or none) for a single-choice question.
38
+ - A choice step lists its answers under `"answers"` or `"options"`, each a hash with a `"value"` and an optional `"weight"`. A weight may be an integer or a string of digits, and a missing weight counts as 0.
39
+ - A fill-in-the-blank step lists its accepted answers under `"accepted_answers"`, each a hash with a `"value"`.
17
40
 
18
41
  ## How to use it
19
42
 
43
+ ### Before any of it
44
+
20
45
  1. Confirm question_kit is in the host app's `Gemfile.lock`. If it is not, hand the work to `question_kit-install` and stop.
21
- 2. In the file that reads the version, require the gem explicitly at the top:
46
+ 2. At the top of the file that calls question_kit, require it explicitly:
22
47
 
23
48
  ```ruby
24
49
  require "question_kit"
25
50
  ```
26
51
 
27
52
  This also loads easy_flow, so no separate `require "easy_flow"` is needed.
28
- 3. Read the version where it is needed:
53
+ 3. Confirm `QuestionKit.flow_host` returns a host name in the app, for example with `bin/rails runner 'p QuestionKit.flow_host'`. Starting a quiz, uploading questions and the answer explanations all depend on it. If it prints `nil`, hand the work to `question_kit-install` and stop.
29
54
 
30
- ```ruby
31
- QuestionKit::VERSION
32
- ```
55
+ ### Scoring, keying and timing a run
33
56
 
34
- 4. When code depends on a release, compare with `Gem::Version`, never with string comparison:
57
+ 4. Put the calls in host code that reads a finished or stored run, such as a results page's controller, a model method on the app's own quiz record, or a job that records a score. Ask the developer where the score is used and whether it is shown only or also stored; do not pick.
58
+ 5. Build the arguments from the flow version the run was taken on, not from the flow's live version, so a later edit does not change an old run's score:
59
+ - `steps` is a hash from step id (string) to that step's settings hash.
60
+ - `answers` is a hash from step id to the value the visitor gave. Ids may be strings or symbols.
61
+ 6. Score one answer with `QuestionKit::Score.worth(step, value)`:
62
+ - Single-choice: `value` is the chosen answer's value, matched exactly, including letter case, and the result is that answer's weight, or 0 when it carries none or matches no answer.
63
+ - Multiple-select: `value` is the list of ticked values, and the result is 1 when they are exactly the right answers in any order and 0 otherwise. Partial credit is not given.
64
+ - Fill-in-the-blank: `value` is the typed text, and the result is 1 when it matches an accepted answer after removing outer spaces and ignoring letter case, and 0 otherwise.
65
+ 7. Score a whole run with `QuestionKit::Score.total(answers, steps)`. An answer whose step id is not in `steps` counts 0, so steps that are not questions, such as info or branch steps, can be left in `answers` or taken out.
66
+ 8. Ask the developer whether a score of 0 for a single-choice question with no weights set is correct for their quizzes, or whether every quiz must set weights before it is published. question_kit cannot tell an unweighted question from a wrong answer.
67
+ 9. Show what was right:
68
+ - `QuestionKit::Score.right_answers(step)` for the full list of values with a positive weight on a choice step.
69
+ - `QuestionKit::AnswerKey.chosen(step)` for the single thing to display as the key: the list of right values for multiple-select, the first accepted answer for fill-in-the-blank, and the first right value for any other question. With no right or accepted answer it returns an empty list for multiple-select and `nil` for every other type, so render both cases.
70
+ - `QuestionKit::AnswerKey.accepted(step)` for every accepted spelling on a fill-in-the-blank step, with blank entries left out.
71
+ 10. Time a run with `QuestionKit::TimeTaken.seconds(run)`, passing the easy_flow run record. It returns `nil` when the run's flow saves answers only once the flow ends, and when no step has been completed yet; render both cases, for example as "not timed".
35
72
 
36
- ```ruby
37
- Gem::Version.new(QuestionKit::VERSION) >= Gem::Version.new("0.2.0")
38
- ```
73
+ ### Starting a new quiz
74
+
75
+ 11. In the host app's action that creates a quiz, build an unsaved `EasyFlow::Definition` with the flow host, the owner and the title the builder typed, and save it through `TitledStart` instead of calling `save` on it:
76
+
77
+ ```ruby
78
+ quiz = EasyFlow::Definition.new(host: QuestionKit.flow_host.to_s, owner: current_owner, title: params[:title])
79
+
80
+ if QuestionKit::TitledStart.new(quiz).save
81
+ redirect_to ...
82
+ else
83
+ render :new, status: :unprocessable_entity
84
+ end
85
+ ```
86
+
87
+ `current_owner` stands for whatever record owns the host's flows; ask the developer which method returns it, or pass no owner when the host keeps none.
88
+ 12. Read the result:
89
+ - A blank title is refused: `save` returns `false` and `quiz.errors` holds a blank error on `:title`. Render the form with that error.
90
+ - Otherwise the quiz takes a slug made from its title, lower case with hyphens, at most 100 characters and never ending on a hyphen. A title with no usable letters or digits gets `quiz`.
91
+ - When the same host and owner already have that slug, or the slug is `manage`, `pages` or `runs`, the quiz takes the first free numbered slug, such as `morning-routine-2`.
92
+ - `save` then returns what saving the flow returns, so any other validation error on the flow comes back as `false` with its errors.
93
+ - When another quiz takes the same slug at the same moment, it tries the next free slug, and after three lost tries it raises `ActiveRecord::RecordNotUnique`. Ask the developer whether to let that error reach the error page or rescue it and show the form again.
94
+ 13. Never set `slug` yourself when using `TitledStart`; it replaces any slug already on the flow.
95
+
96
+ ### Uploading questions into a quiz
97
+
98
+ `QuestionKit::QuestionImporter` is defined only when ixport is loaded in the app. In an app without ixport, it does not exist, and nothing in this section applies. If the developer asks for question uploads and ixport is missing, hand adding ixport to `ixport-install`.
99
+
100
+ 14. Register the upload with ixport in an initializer that runs after the ixport configuration, naming the importer by its string and limiting it with `allows?`:
101
+
102
+ ```ruby
103
+ Ixport.register_import :quiz_questions, title: "Quiz questions", importer: "QuestionKit::QuestionImporter", refuse_whole_file: true,
104
+ allow: ->(_person, account, quiz) { QuestionKit::QuestionImporter.allows?(account, quiz) }
105
+ ```
106
+
107
+ Ask the developer for the key and the title the builder sees. Ask whether one bad row refuses the whole file (`refuse_whole_file: true`) or only that row; refusing the whole file is the setting that keeps a quiz from gaining half a file of questions.
108
+ 15. `allows?(account, quiz)` returns `true` only when `quiz` is an easy_flow flow on the flow host and, when the flow host keeps owners, `quiz.owner` is `account`. A flow host with no owner method lets any account upload into any of its quizzes. ixport answers a refused upload page with not found.
109
+ 16. Link a builder to the upload page from the quiz's own page, passing the quiz as the target by its global id. Hand the link and ixport's pages to `ixport-develop`:
110
+
111
+ ```ruby
112
+ ixport.new_import_path(key: :quiz_questions, target: quiz.to_global_id)
113
+ ```
114
+
115
+ 17. Tell the developer what the file must hold, since the builder's columns are matched to these fields on ixport's column-matching page:
116
+ - `Type` — `True/false` or `Multiple choice`. Letter case, spaces, slashes and other non-letters are ignored, so `truefalse` and `multiple-choice` also match. Any other type refuses the row.
117
+ - `Question` — the question text. A blank question refuses the row.
118
+ - `Category` — optional; saved as the question's category when filled.
119
+ - `Answer 1` to `Answer 6` — the answers of a multiple choice question, of which at least two must be filled. A true/false question ignores them and always offers `True` and `False`.
120
+ - `Right answer` — for multiple choice, the number of the answer column that is right, such as `2` for `Answer 2`, counted by column even when an earlier column is blank; for true/false, `true` or `false` in any letter case. Outer spaces are ignored. A right answer that names no filled answer refuses the row.
121
+ - Each answer text is saved with its outer spaces removed, and the question text is saved as given.
122
+ 18. Each accepted row becomes a single-choice question step whose right answer has weight 1 and every other answer weight 0, so `Score.worth` scores it 1 or 0. Every row is added as a new question; an upload never matches or updates an existing question.
123
+ 19. Each question is added to the quiz's draft, after the last question a visitor can reach and before whatever step followed it, and is recorded in the quiz's edit history as an added step. Visitors are asked the uploaded questions only once the quiz is published. Tell the developer that the builder must publish after uploading.
124
+
125
+ ### Adding one question outside an upload
126
+
127
+ 20. To add a single question from host code in the same way an upload row does, build it through `UploadedQuestions` and check it before saving, since `save!` does not validate:
128
+
129
+ ```ruby
130
+ question = QuestionKit::UploadedQuestions.new(quiz).new(type: "Multiple choice", question: "Which planet is largest?", category: "Space",
131
+ answer_1: "Mars", answer_2: "Jupiter", right_answer: "2")
132
+
133
+ if question.valid?
134
+ question.save!
135
+ else
136
+ question.errors.full_messages
137
+ end
138
+ ```
139
+
140
+ The accepted keys are `type`, `question`, `category`, `right_answer` and each name in `QuestionKit::UploadedQuestion::ANSWER_FIELDS`. The rules in step 17 and the placement in step 19 apply. A refused question carries its errors on `:question`, `:type`, `:answers` or `:right_answer`. Build the answer keys from `ANSWER_FIELDS` rather than typing `answer_1` to `answer_6`.
141
+
142
+ ### Answer explanations
143
+
144
+ 21. question_kit adds its explanations to the flow host's answer settings by itself once the install sets the flow host, so an admin sees them beside each answer field on the canvas. Write no code to turn them on.
145
+ 22. They cover the `question` step's answer value, label, hint, info and weight, the `multiple_select` step's answer value, label and weight, and the `fill_in_the_blank` step's accepted answer value. Read the exact text from `QuestionKit::AnswerExplanations::TEXT`, for example `QuestionKit::AnswerExplanations::TEXT["question"]["answers.weight"]`.
146
+ 23. To show different words for a field, set it in the flow host's own setting explanations, keyed by step type and then by the same setting name `TEXT` uses. The host app's text replaces question_kit's for that field only, and question_kit's text stays on every other field:
147
+
148
+ ```ruby
149
+ EasyFlow.host_named(QuestionKit.flow_host).setting_explanations = { question: { "answers.weight" => "Points for this answer." } }
150
+ ```
151
+
152
+ Ask the developer which fields to reword and the words to use. Other easy_flow hosts get none of question_kit's explanations.
153
+
154
+ ### Versions and tests
155
+
156
+ 24. When code depends on a release, compare versions with `Gem::Version`, never as strings:
157
+
158
+ ```ruby
159
+ Gem::Version.new(QuestionKit::VERSION) >= Gem::Version.new("0.2.0")
160
+ ```
39
161
 
40
- 5. Ask the developer what the app does when the loaded version is older than the one its code needs: raise at boot, log a warning, or skip the feature. Do not pick one.
41
- 6. If the check runs at boot, put it in an initializer under `config/initializers/` in the host app. Otherwise put it beside the code that depends on the release.
42
- 7. Write a test in the host app that asserts the app's own behaviour for each branch of the check, and run it.
162
+ Ask the developer what the app does when the loaded version is older: raise at boot, log a warning, or skip the feature.
163
+ 25. Write host app tests that assert the app's own behaviour with the score, key, time, new quiz or upload, such as what the results page shows or that a builder cannot reach another owner's upload page, and run them. Do not test question_kit's own scoring, slug or row rules.
43
164
 
44
165
  ## Conventions
45
166
 
46
- - Never hard-code the version string in host app code. Read `QuestionKit::VERSION`.
47
- - Never assign to or redefine `QuestionKit::VERSION`.
48
- - question_kit has no scoring, right-answer or quiz-building methods yet. Do not invent calls for them. If the developer asks for that behaviour, say the gem does not offer it and ask whether to build it in the host app or wait for question_kit to add it.
49
- - Adding the gem, and upgrading it, belongs to `question_kit-install`.
50
- - Flows, step types, runs and answers belong to the easy_flow locals.
167
+ - Never compute a score, a right answer or a time taken in host code. Call question_kit so every app scores the same way.
168
+ - Never compare a typed answer to an accepted answer in host code; `Score.worth` already ignores case and outer spaces.
169
+ - Never mutate the step hash passed in.
170
+ - Never make a quiz slug in host code; save a new quiz through `TitledStart`.
171
+ - Never write a question importer, a CSV parser for questions or a loop that adds question steps; register `QuestionKit::QuestionImporter` with ixport.
172
+ - Always pass `allows?` as the upload's access rule, so a builder cannot upload into another owner's quiz or a flow on another host.
173
+ - Never call `UploadedQuestion#save!` on a row that has not passed `valid?`.
174
+ - Never edit `QuestionKit::AnswerExplanations::TEXT` or extend a host with `AnswerExplanations` yourself; reword a field through the host's setting explanations.
175
+ - Compare `QuestionKit.flow_host` with a flow's host as a string, with `.to_s`.
176
+ - Never hard-code or redefine `QuestionKit::VERSION`.
177
+ - question_kit does not store scores, add step types, or upload multiple-select or fill-in-the-blank questions. If the developer asks for that, say so and ask whether to build it in the host app or wait for question_kit to add it.
178
+ - Adding or upgrading the gem and setting the flow host belong to `question_kit-install`.
179
+ - Flows, step types, runs, publishing and answers belong to the easy_flow locals.
180
+ - Adding ixport and its pages, links and configuration belong to the ixport locals.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: question_kit-info
3
- description: Use to learn what question_kit offers — shared rules for easy_flow quizzes, covering what an answer to a question step is worth, which answers are right, and how a quiz is built.
3
+ description: Use to learn what question_kit offers — shared rules for easy_flow quizzes, covering what an answer to a question step is worth, which answers are right, the answer key a quiz shows for each question, how long a visitor took on a quiz, starting a quiz from its title, adding questions to a quiz from an uploaded CSV file, and the explanations an admin sees beside a question's answer settings.
4
4
  tools: Read
5
5
  scope: shared question and answer rules for easy_flow quizzes — what an answer to a question step is worth, which answers are right, and how a quiz is built
6
6
  ---
@@ -10,40 +10,106 @@ This local explains question_kit and makes no changes.
10
10
  ## What question_kit is
11
11
 
12
12
  question_kit is a Rails gem for apps that run quizzes as easy_flow flows. Each
13
- quiz question is an easy_flow question step. The gem is meant to hold the rules
14
- those apps share: what an answer to a question step is worth, which answers are
15
- right, and how a quiz is built. With the rules in one gem, each app does not
16
- keep its own copy.
13
+ quiz question is an easy_flow question step. The gem holds the rules those apps
14
+ share, so each app does not keep its own copy of them.
17
15
 
18
- Today the gem holds none of those rules yet. It loads easy_flow into the host
19
- app and reports its own version, and nothing more. Reach for it when an app
20
- runs easy_flow quizzes and should follow the shared rules as they are added.
21
- Until then, it is only a dependency to put in place.
16
+ Reach for it when an app runs easy_flow quizzes and needs to:
17
+
18
+ - score a visitor's answer to one question, or add up a whole quiz;
19
+ - say which answers to a question are right;
20
+ - show the answer key for a question once the quiz is over;
21
+ - report how long a visitor took to answer the quiz;
22
+ - start a new quiz from the title an admin types;
23
+ - add questions to a quiz from a CSV file an admin uploads;
24
+ - explain to an admin, beside each answer setting on the canvas, what that
25
+ setting does to the score.
26
+
27
+ It reads and writes the flows, steps and runs easy_flow already stores. It adds
28
+ no tables and no step types of its own. The CSV upload uses ixport's import
29
+ pages, and is only there in an app that has ixport.
22
30
 
23
31
  ## Interface
24
32
 
25
33
  This local documents no commands. The other two locals cover everything the
26
34
  gem exposes:
27
35
 
28
- - The install local, `question_kit-install`, adds the gem to a host app.
29
- - The develop local, `question_kit-develop`, covers the code a host app writes against the gem.
36
+ - The install local, `question_kit-install`, adds the gem to a host app and
37
+ names the easy_flow host whose flows are quizzes.
38
+ - The develop local, `question_kit-develop`, covers scoring, the answer key,
39
+ time taken, starting a quiz from its title, the question upload and the
40
+ answer setting explanations.
30
41
 
31
42
  Anything about building or running the flows themselves, such as the canvas,
32
- step types, runs and answers, belongs to easy_flow. The easy_flow locals cover
33
- that.
43
+ step types, runs and recorded answers, belongs to easy_flow, and the easy_flow
44
+ locals cover it. The import pages themselves belong to ixport, and the ixport
45
+ locals cover them.
34
46
 
35
47
  ## How to use it
36
48
 
37
49
  - To add question_kit to an app, use `question_kit-install`.
38
- - To write code that uses question_kit, use `question_kit-develop`.
39
- - To build the quiz flow or a question step, use the easy_flow locals.
50
+ - To score answers, show an answer key, time a quiz, start a quiz from its
51
+ title or offer the question upload, use `question_kit-develop`.
52
+ - To build the quiz flow or a question step by hand, use the easy_flow locals.
40
53
 
41
54
  ## Conventions
42
55
 
43
56
  - **Quiz** — an easy_flow flow whose steps ask the visitor questions.
44
- - **Question step** — one easy_flow step that asks one question and records the
45
- visitor's answer.
46
- - **Worth** — what an answer is worth toward the quiz's result.
47
- - **Right answer** — an answer the quiz counts as correct for its question.
48
57
  - **Host app** — the Rails app that installs question_kit and easy_flow and runs
49
58
  the quizzes.
59
+ - **Quiz host** — the one easy_flow host in the host app whose flows are
60
+ quizzes. The answer setting explanations and the question upload apply to
61
+ that host only.
62
+ - **Question step** — one easy_flow step that asks one question and records the
63
+ visitor's answer. The rules know three kinds:
64
+ - a **single choice** question, where the visitor picks one of the step's
65
+ answers;
66
+ - a **multiple select** question, where the visitor ticks several;
67
+ - a **fill-in-the-blank** question, where the visitor types the answer.
68
+ - **Weight** — a number an admin sets on each answer of a choice question.
69
+ - **Right answer** — on a choice question, an answer whose weight is above
70
+ zero.
71
+ - **Accepted answer** — on a fill-in-the-blank question, a text the admin lists
72
+ as correct. The list of accepted answers a quiz shows leaves out any left
73
+ blank.
74
+ - **Worth** — what one answer counts toward the quiz's result:
75
+ - a single choice answer is worth the weight of the answer picked, and zero
76
+ when it has none;
77
+ - a multiple select answer is worth one when the ticked answers are exactly
78
+ the right answers, and zero otherwise;
79
+ - a fill-in-the-blank answer is worth one when it matches any accepted answer,
80
+ ignoring capital letters and spaces at either end, and zero otherwise.
81
+ - **Total** — the sum of the worth of every answer in a run.
82
+ - **Answer key** — the answer a quiz shows as correct for a question: the first
83
+ right answer of a single choice question, every right answer of a multiple
84
+ select question, and the first accepted answer of a fill-in-the-blank
85
+ question.
86
+ - **Time taken** — the seconds from the start of a visitor's run to the last
87
+ answer they gave. A run with no answers has none. A flow that saves answers
88
+ only when the visitor finishes, rather than after each step, records no
89
+ time per answer, so its runs have no time taken.
90
+ - **Titled start** — saving a new quiz from its title alone. A blank title is
91
+ refused. The quiz's address is made from the title, cut to 100 characters,
92
+ and is `quiz` when the title holds nothing usable. When another quiz of the
93
+ same host and owner already has that address, a number is added, starting at
94
+ 2. The words `manage`, `pages` and `runs` are never used as an address.
95
+ - **Uploaded question** — one row of a CSV file an admin uploads to a quiz. Its
96
+ columns are Type, Question, Category, Answer 1 to Answer 6, and Right answer.
97
+ - Type is true/false or multiple choice, read without regard to capitals,
98
+ spaces or punctuation.
99
+ - Category is saved on the question when it is filled in.
100
+ - A true/false row gets the answers True and False, and its Right answer is
101
+ true or false.
102
+ - A multiple choice row needs at least two answers, and its Right answer is
103
+ the number of one of them.
104
+ - The right answer gets a weight of one and every other answer zero.
105
+ - A row with no question, an unknown type or no right answer is refused.
106
+ - **Where uploaded questions go** — each one is added as a single choice
107
+ question after the last question a visitor can reach, and is recorded in the
108
+ quiz's edit history as an added step. It is added to the quiz's unpublished
109
+ changes, so visitors are not asked it until the quiz is published.
110
+ - **Who may upload** — only to a quiz of the quiz host, and, when that host's
111
+ flows each belong to an owner, only by the account that owns the quiz.
112
+ - **Answer setting explanation** — the line an admin sees beside a question's
113
+ answer settings on the canvas, saying what the setting shows a player and
114
+ what it does to the score. An explanation the host app gives itself is shown
115
+ in place of question_kit's.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: question_kit-install
3
- description: Use to hook question_kit into a project — adding the gem to the host app's Gemfile and installing it alongside easy_flow.
3
+ description: Use to hook question_kit into a project — adding the gem to the host app's Gemfile alongside easy_flow, and naming the easy_flow host whose flows are the app's quizzes.
4
4
  tools: Bash, Read, Edit
5
5
  scope: shared question and answer rules for easy_flow quizzes — what an answer to a question step is worth, which answers are right, and how a quiz is built
6
6
  ---
@@ -14,6 +14,7 @@ A Rails gem holding the shared quiz rules for apps that run quizzes as easy_flow
14
14
  ## Interface
15
15
 
16
16
  - `gem "question_kit"` — the Gemfile line that adds question_kit, and with it easy_flow, to the host app.
17
+ - `QuestionKit.flow_host = name` — names the easy_flow host whose flows are the app's quizzes, so that host's admin settings for answers carry question_kit's explanations and quiz questions are uploaded only into that host's flows.
17
18
 
18
19
  ## How to use it
19
20
 
@@ -33,12 +34,22 @@ A Rails gem holding the shared quiz rules for apps that run quizzes as easy_flow
33
34
 
34
35
  This edits `Gemfile.lock` and brings in easy_flow if the app did not have it.
35
36
  5. Check whether easy_flow is already hooked into the app: its migrations copied and run, its engine mounted, and its hosts declared. If any of that is missing, hand the work to `easy_flow-install`, and ask the developer which hosts to declare rather than picking them.
37
+ 6. Find the easy_flow host declarations in the app's initializers, the `EasyFlow.host(:name)` lines. Ask the developer which declared host runs the quizzes. Never pick one, even when only one host is declared.
38
+ 7. Set that host as question_kit's flow host, in `config/initializers/question_kit.rb`:
36
39
 
37
- question_kit has no generator, no initializer, no migrations and no routes of its own. Requiring the gem loads easy_flow, so the host app needs no separate `require "easy_flow"`.
40
+ ```ruby
41
+ QuestionKit.flow_host = :quizzes
42
+ ```
43
+
44
+ Use the host's name exactly as its `EasyFlow.host` line spells it. The line must run after that `EasyFlow.host` line. Rails runs initializers in alphabetical order by file name, so when the host is declared in a file that sorts after `question_kit.rb`, put the line in that file, directly below the declaration, instead of in a file of its own.
45
+
46
+ question_kit has no generator, no migrations and no routes of its own. Requiring the gem loads easy_flow, so the host app needs no separate `require "easy_flow"`.
38
47
 
39
48
  ## Conventions
40
49
 
41
50
  - After install, run `bundle list | grep -E "question_kit|easy_flow"` and confirm both gems are listed.
51
+ - After install, boot the app with `bin/rails runner 'puts QuestionKit.flow_host'` and confirm it prints the host name chosen in step 6.
52
+ - One app has one quiz host. To move the quizzes to another host, change the name in the `QuestionKit.flow_host` line and nowhere else.
42
53
  - After a `bundle update question_kit`, check `Gemfile.lock` again for the easy_flow version it brought in, and run `easy_flow-install` for any new easy_flow migrations.
43
54
  - Setting up easy_flow itself, its hosts, step types and admin pages belongs to `easy_flow-install`.
44
- - Writing code against question_kit belongs to `question_kit-develop`.
55
+ - Registering the quiz question upload with ixport, and writing code against question_kit, belong to `question_kit-develop`.
@@ -2,11 +2,36 @@ scope: shared question and answer rules for easy_flow quizzes — what an answer
2
2
 
3
3
  install:
4
4
  - gem "question_kit"
5
+ - QuestionKit.flow_host = name
5
6
 
6
7
  develop:
7
8
  - QuestionKit::VERSION
9
+ - QuestionKit::Score.worth(step, value)
10
+ - QuestionKit::Score.total(answers, steps)
11
+ - QuestionKit::Score.right_answers(step)
12
+ - QuestionKit::AnswerKey.chosen(step)
13
+ - QuestionKit::AnswerKey.accepted(step)
14
+ - QuestionKit::TimeTaken.seconds(run)
15
+ - QuestionKit.flow_host
16
+ - QuestionKit::TitledStart.new(flow).save
17
+ - QuestionKit::UploadedQuestion
18
+ - QuestionKit::UploadedQuestion::ANSWER_FIELDS
19
+ - QuestionKit::UploadedQuestions.new(flow)
20
+ - QuestionKit::QuestionImporter
21
+ - QuestionKit::QuestionImporter.allows?(account, quiz)
22
+ - QuestionKit::AnswerExplanations
23
+ - QuestionKit::AnswerExplanations::TEXT
8
24
 
9
25
  sources:
10
26
  - question_kit.gemspec
11
27
  - lib/question_kit.rb
12
28
  - lib/question_kit/version.rb
29
+ - lib/question_kit/railtie.rb
30
+ - lib/question_kit/score.rb
31
+ - lib/question_kit/answer_key.rb
32
+ - lib/question_kit/time_taken.rb
33
+ - lib/question_kit/titled_start.rb
34
+ - lib/question_kit/uploaded_question.rb
35
+ - lib/question_kit/uploaded_questions.rb
36
+ - lib/question_kit/question_importer.rb
37
+ - lib/question_kit/answer_explanations.rb
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: question_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tylercschneider
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-11-10 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -51,7 +50,15 @@ files:
51
50
  - README.md
52
51
  - Rakefile
53
52
  - lib/question_kit.rb
53
+ - lib/question_kit/answer_explanations.rb
54
+ - lib/question_kit/answer_key.rb
55
+ - lib/question_kit/question_importer.rb
54
56
  - lib/question_kit/railtie.rb
57
+ - lib/question_kit/score.rb
58
+ - lib/question_kit/time_taken.rb
59
+ - lib/question_kit/titled_start.rb
60
+ - lib/question_kit/uploaded_question.rb
61
+ - lib/question_kit/uploaded_questions.rb
55
62
  - lib/question_kit/version.rb
56
63
  - lib/tasks/question_kit_tasks.rake
57
64
  - the_local/agents/question_kit-develop.md
@@ -64,7 +71,6 @@ licenses:
64
71
  metadata:
65
72
  homepage_uri: https://github.com/DYB-Development/question_kit
66
73
  source_code_uri: https://github.com/DYB-Development/question_kit
67
- post_install_message:
68
74
  rdoc_options: []
69
75
  require_paths:
70
76
  - lib
@@ -79,8 +85,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
79
85
  - !ruby/object:Gem::Version
80
86
  version: '0'
81
87
  requirements: []
82
- rubygems_version: 3.5.16
83
- signing_key:
88
+ rubygems_version: 4.0.20
84
89
  specification_version: 4
85
90
  summary: Shared question and answer rules for apps that run easy_flow quizzes.
86
91
  test_files: []