diff --git a/README.md b/README.md
index 321cc922..db00ac0d 100644
--- a/README.md
+++ b/README.md
@@ -284,7 +284,7 @@ The Search Filters section shows your chosen search criteria.
- Click “Export Questions” and select “XML”
-This will download an `.xml` file (e.g. `questions-2023-12-13_15_38_01_508.classic-question-canvas.qti.xml`). To upload that file into Canvass you will need to convert the downloaded XML file into a `.zip` file. The way to do this varies by operating system.
+This will download an `.xml` file (e.g. `questions-2023-12-13_15_38_01_508.classic-question-canvas.qti.xml`). To upload that file into Canvas you will need to convert the downloaded XML file into a `.zip` file. The way to do this varies by operating system.
As of <2023-12-13 Wed> we export into a “classic question” format; the rationalization being two fold:
@@ -305,7 +305,7 @@ In the `Rails` console run the following:
User.create!(email: "", password: "")
```
-### Reseting Questions and Data
+### Resetting Questions and Data
Over the course of testing and experimenting, you might want to reset the questions.
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 4697c96f..a9b17747 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -22,7 +22,7 @@ def index
# conversations with the client, we're looking to only export classic (as you can migrate a
# classic question to new format). This filename is another "helpful clue" and introduces
# later considerations for what the file format might be.
- filename = "questions-#{now.strftime('%Y-%m-%d_%H:%M:%S:%L')}.classic-question-canvas.qti.xml"
+ filename = "#{export_filename(now)}.classic-question-canvas.qti.xml"
@questions = Question.filter(**filter_values)
if any_question_has_images?
@@ -35,8 +35,35 @@ def index
end
end
+ # download bookmarked questions
+ def download
+ @questions = Question.where(id: Bookmark.select(:question_id))
+ case params[:format]
+ when 'md'
+ md_download
+ when 'txt'
+ text_download
+ else
+ redirect_to authenticated_root_path, alert: t('.alert')
+ end
+ end
+
private
+ def export_filename(now = Time.current)
+ "questions-#{now.strftime('%Y-%m-%d_%H:%M:%S:%L')}"
+ end
+
+ def text_download
+ content = @questions.map { |question| QuestionFormatter::PlainTextService.new(question).format_content }.join('')
+ send_data content, filename: "#{export_filename}.txt", type: 'text/plain'
+ end
+
+ def md_download
+ content = @questions.map { |question| QuestionFormatter::MarkdownService.new(question).format_content }.join('')
+ send_data content, filename: "#{export_filename}.md", type: 'text/plain'
+ end
+
def any_question_has_images?
@questions.any? { |question| question.images.any? }
end
diff --git a/app/javascript/components/ui/Footer/index.jsx b/app/javascript/components/ui/Footer/index.jsx
index 7c64ed6e..01baa6f6 100644
--- a/app/javascript/components/ui/Footer/index.jsx
+++ b/app/javascript/components/ui/Footer/index.jsx
@@ -11,7 +11,7 @@ const Footer = () => {
Settings
- © 2023 VIVA
+ © { new Date().getFullYear() } VIVA
diff --git a/app/javascript/components/ui/Search/SearchBar.jsx b/app/javascript/components/ui/Search/SearchBar.jsx
index 5cd72abf..b1380db3 100644
--- a/app/javascript/components/ui/Search/SearchBar.jsx
+++ b/app/javascript/components/ui/Search/SearchBar.jsx
@@ -134,7 +134,7 @@ const SearchBar = (props) => {
{query && (
@@ -170,7 +170,9 @@ const SearchBar = (props) => {
onChange={(event) => handleFilterChange(event, `selected${key.charAt(0).toUpperCase() + key.slice(1)}`)}
checked={filterState[`selected${key.charAt(0).toUpperCase() + key.slice(1)}`].includes(item)}
/>
- {item}
+
+ {key === 'types' && item.startsWith('question_') ? item.substring(9) : item}
+
))}
@@ -194,12 +196,28 @@ const SearchBar = (props) => {
View Bookmarks
- Export Bookmarks
+ Export as Plain Text
+
+
+ Export as Markdown
+
+ `bookmarked_question_ids[]=${encodeURIComponent(id)}`).join('&')}`}
+ className={`btn btn-primary p-2 m-2 ${!hasBookmarks ? 'disabled' : ''}`}
+ role='button'
+ aria-disabled={!hasBookmarks}
+ >
+ Export as XML
true
class Question::BowTie < Question
self.type_name = "Bow Tie"
+ self.model_exporter = 'bowtie_type'
# NOTE: We're not storing this in a JSONB data type, but instead favoring a text field. The need
# for the data to be used in the application, beyond export of data, is minimal.
diff --git a/app/models/question/categorization.rb b/app/models/question/categorization.rb
index ce3b620f..49e879a7 100644
--- a/app/models/question/categorization.rb
+++ b/app/models/question/categorization.rb
@@ -8,6 +8,7 @@ class Question::Categorization < Question
include MatchingQuestionBehavior
self.type_name = "Categorization"
+ self.model_exporter = 'categorization_type'
self.export_as_xml = true
self.choice_cardinality_is_multiple = true
diff --git a/app/models/question/drag_and_drop.rb b/app/models/question/drag_and_drop.rb
index ae6f4f35..25d90084 100644
--- a/app/models/question/drag_and_drop.rb
+++ b/app/models/question/drag_and_drop.rb
@@ -21,6 +21,7 @@
# data: [{ answer: "Aardvark", correct: true }, { answer: "Blue", correct: false }, { answer: "Yellow", correct:false }, { answer: "Cat", correct: true }])
class Question::DragAndDrop < Question
self.type_name = "Drag and Drop"
+ self.model_exporter = 'traditional_type'
##
# Represents the mapping process of a CSV Row to the underlying {Question::DragAndDrop}.
diff --git a/app/models/question/essay.rb b/app/models/question/essay.rb
index f382c5bc..1298859f 100644
--- a/app/models/question/essay.rb
+++ b/app/models/question/essay.rb
@@ -9,6 +9,7 @@ class Question::Essay < Question
include MarkdownQuestionBehavior
self.type_name = "Essay"
+ self.model_exporter = 'essay_type'
self.export_as_xml = true
class ImportCsvRow < MarkdownQuestionBehavior::ImportCsvRow
diff --git a/app/models/question/matching.rb b/app/models/question/matching.rb
index eb8fbb3e..de158afb 100644
--- a/app/models/question/matching.rb
+++ b/app/models/question/matching.rb
@@ -8,6 +8,7 @@ class Question::Matching < Question
include MatchingQuestionBehavior
self.type_name = "Matching"
+ self.model_exporter = 'matching_type'
self.export_as_xml = true
self.choice_cardinality_is_multiple = false
diff --git a/app/models/question/scenario.rb b/app/models/question/scenario.rb
index 6621fa9f..b1b8c3d9 100644
--- a/app/models/question/scenario.rb
+++ b/app/models/question/scenario.rb
@@ -7,6 +7,7 @@
class Question::Scenario < Question
self.type_label = "Scenario"
self.type_name = "Scenario"
+ self.model_exporter = 'scenario'
self.included_in_filterable_type = false
class ImportCsvRow < Question::ImportCsvRow
diff --git a/app/models/question/select_all_that_apply.rb b/app/models/question/select_all_that_apply.rb
index 5796f653..40f9312d 100644
--- a/app/models/question/select_all_that_apply.rb
+++ b/app/models/question/select_all_that_apply.rb
@@ -7,6 +7,7 @@
# duplication than more complicated inheritance.
class Question::SelectAllThatApply < Question
self.type_name = "Select All That Apply"
+ self.model_exporter = 'traditional_type'
##
# Represents the mapping process of a CSV Row to the underlying {Question::SelectAllThatApply}.
diff --git a/app/models/question/stimulus_case_study.rb b/app/models/question/stimulus_case_study.rb
index 13d73ef5..cf008d6b 100644
--- a/app/models/question/stimulus_case_study.rb
+++ b/app/models/question/stimulus_case_study.rb
@@ -5,6 +5,7 @@
class Question::StimulusCaseStudy < Question
self.type_label = "Case Study"
self.type_name = "Stimulus Case Study"
+ self.model_exporter = 'stimulus_type'
self.has_parts = true
has_many :as_parent_question_aggregations,
diff --git a/app/models/question/traditional.rb b/app/models/question/traditional.rb
index 9412023a..9e4f6566 100644
--- a/app/models/question/traditional.rb
+++ b/app/models/question/traditional.rb
@@ -10,6 +10,7 @@ class Question::Traditional < Question
#
# @see https://github.com/notch8/viva/issues/261
self.type_name = "Multiple Choice"
+ self.model_exporter = 'traditional_type'
##
# Represents the mapping process of a CSV Row to the underlying {Question::Traditional}.
diff --git a/app/models/question/upload.rb b/app/models/question/upload.rb
index 3d4cddb4..6a4aedde 100644
--- a/app/models/question/upload.rb
+++ b/app/models/question/upload.rb
@@ -10,6 +10,7 @@ class Question::Upload < Question
include MarkdownQuestionBehavior
self.type_name = "Upload"
+ self.model_exporter = 'essay_type'
self.export_as_xml = true
class ImportCsvRow < MarkdownQuestionBehavior::ImportCsvRow
diff --git a/app/services/question_formatter/base_service.rb b/app/services/question_formatter/base_service.rb
new file mode 100644
index 00000000..4e19dc2d
--- /dev/null
+++ b/app/services/question_formatter/base_service.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+require 'nokogiri'
+
+module QuestionFormatter
+ class BaseService
+ ##
+ # Service to handle formatting questions for downloads
+ class_attribute :output_format, default: nil
+ attr_reader :question, :subq
+
+ def initialize(question, subq = false)
+ @question = question
+ @subq = subq
+ end
+
+ def format_content
+ format_by_type + divider_line
+ end
+
+ # These methods are protected instead of private so they can be called by other instances
+ protected
+
+ def essay_type
+ format_question_header + format_essay_content
+ end
+
+ def traditional_type
+ format_question_header + format_answers(@question.data) { |answer, index| format_traditional_answer(answer, index) }
+ end
+
+ def matching_type
+ format_question_header + format_answers(@question.data) { |answer, index| format_matching_answer(answer, index) }
+ end
+
+ def categorization_type
+ format_question_header + format_categories(@question.data)
+ end
+
+ def bowtie_type
+ format_question_header + format_bowtie_sections
+ end
+
+ def stimulus_type
+ output = @question.child_questions.map { |sub_question| format_sub_question(sub_question) }
+ # remove extra line breaks
+ output[-1] = output[-1].chomp if output.any?
+ "#{format_question_header}#{output.join('')}"
+ end
+
+ def format_sub_question(sub_question)
+ case sub_question.type
+ when "Question::Scenario"
+ format_scenario(sub_question)
+ else
+ "#{self.class.new(sub_question, true).format_by_type}\n"
+ end
+ end
+
+ def format_by_type
+ method = @question.class.model_exporter
+ begin
+ send(method)
+ rescue
+ "Question type: #{question_type} requires a valid export format method"
+ end
+ end
+
+ private
+
+ def divider_line
+ raise NotImplementedError, "Subclasses must implement divider_line"
+ end
+
+ def format_scenario(question)
+ raise NotImplementedError, "Subclasses must implement format_scenario"
+ end
+
+ def format_question_header
+ raise NotImplementedError, "Subclasses must implement format_question_header"
+ end
+
+ def format_essay_content
+ plain_text = format_html(@question.data['html'])
+ "Text: #{plain_text}\n"
+ end
+
+ def format_answers(data)
+ data.map.with_index { |answer, index| yield(answer, index) }.join('')
+ end
+
+ def format_traditional_answer(answer, index)
+ "#{index + 1}) #{answer['correct'] ? 'Correct' : 'Incorrect'}: #{answer['answer']}\n"
+ end
+
+ def format_matching_answer(answer, index)
+ "#{index + 1}) #{answer['answer']}\n Correct Match: #{answer['correct'].first}\n"
+ end
+
+ def format_categories(data)
+ raise NotImplementedError, "Subclasses must implement format_categories"
+ end
+
+ def format_bowtie_sections
+ sections = ['center', 'left', 'right'].map do |section|
+ answers = @question.data[section]['answers'].map.with_index do |answer, index|
+ format_traditional_answer(answer, index)
+ end.join('')
+ "#{section.capitalize}\n#{answers}"
+ end
+ sections.join("\n")
+ end
+
+ def question_type
+ @question.class.type_name
+ end
+
+ def format_html(html)
+ rich_text = Nokogiri::HTML(html)
+ rich_text.css('a').each { |link| link.replace("#{link.text} (#{link['href']})") }
+ rich_text.css('p').each { |p| p.replace("#{p.text}\n") }
+ rich_text.css('li').each { |li| li.replace("- #{li.text}\n") }
+ rich_text.text.strip
+ end
+ end
+end
diff --git a/app/services/question_formatter/markdown_service.rb b/app/services/question_formatter/markdown_service.rb
new file mode 100644
index 00000000..9b889238
--- /dev/null
+++ b/app/services/question_formatter/markdown_service.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+##
+# Service to handle formatting questions into markdown
+module QuestionFormatter
+ class MarkdownService < BaseService
+ self.output_format = 'md'
+
+ private
+
+ def divider_line
+ "\n---\n\n"
+ end
+
+ def format_scenario(sub_question)
+ "**Scenario:** #{sub_question.text}\n\n"
+ end
+
+ def format_question_header
+ headers = case @subq
+ when true
+ "### Subquestion Type: #{question_type}\n**Subquestion:** #{@question.text}\n\n"
+ else
+ "## QUESTION TYPE: #{question_type}\n**QUESTION:** #{@question.text}\n\n"
+ end
+ headers
+ end
+
+ def format_essay_content
+ plain_text = format_html(@question.data['html'])
+ "**Text:** #{plain_text}\n"
+ end
+
+ def format_categories(data)
+ data.map do |category|
+ items = category['correct'].map.with_index { |item, index| "#{index + 1}) #{item}\n" }.join('')
+ "**Category:** #{category['answer']}\n#{items}"
+ end.join("\n")
+ end
+ end
+end
diff --git a/app/services/question_formatter/plain_text_service.rb b/app/services/question_formatter/plain_text_service.rb
new file mode 100644
index 00000000..e1742ea3
--- /dev/null
+++ b/app/services/question_formatter/plain_text_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+##
+# Service to handle formatting questions into plain text
+module QuestionFormatter
+ class PlainTextService < BaseService
+ self.output_format = 'txt'
+
+ private
+
+ def divider_line
+ "\n==========\n\n"
+ end
+
+ def format_scenario(sub_question)
+ "Scenario: #{sub_question.text}\n\n"
+ end
+
+ def format_question_header
+ return "QUESTION TYPE: #{question_type}\nQUESTION: #{@question.text}\n\n" unless @subq
+ "Subquestion Type: #{question_type}\nSubquestion: #{@question.text}\n\n"
+ end
+
+ def format_categories(data)
+ data.map do |category|
+ items = category['correct'].map.with_index { |item, index| "#{index + 1}) #{item}\n" }.join('')
+ "Category: #{category['answer']}\n#{items}"
+ end.join("\n")
+ end
+ end
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index e50aaeb8..4d6ebb82 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -49,3 +49,6 @@ en:
success: "Settings updated successfully."
update_password:
success: "Password updated successfully."
+ search:
+ download:
+ alert: "Invalid format. Please select a valid format."
diff --git a/config/routes.rb b/config/routes.rb
index 22f08e93..228b89b5 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -13,6 +13,9 @@
# Necessary for downloading the XML file from the search result.
get '/(.:format)', to: 'search#index'
+ # download the bookmarked questions in a text file
+ get 'questions/download', to: 'search#download', as: 'download_questions'
+
# settings page routes
get '/settings', to: 'settings#index', as: 'settings'
patch '/settings/update', to: 'settings#update'
diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb
index 22251d53..5c83bd76 100644
--- a/spec/controllers/search_controller_spec.rb
+++ b/spec/controllers/search_controller_spec.rb
@@ -141,5 +141,39 @@
expect(response.headers['Content-Disposition']).to match(/.zip/)
end
end
+
+ context 'downloading question text' do
+ let(:question) { FactoryBot.build_stubbed(:question_traditional) }
+
+ before do
+ allow(Question).to receive(:where).and_return([question])
+ end
+
+ context 'downloading as plain text' do
+ it 'returns a txt file' do
+ get :download, format: :txt
+ expect(response.content_type).to eq('text/plain')
+ expect(response.headers['Content-Disposition']).to match(/questions-.*\.txt/)
+ end
+
+ it 'includes bookmarked questions in the response' do
+ get :download, format: :txt
+ expect(response.body).to include(question.text)
+ end
+ end
+
+ context 'downloading as markdown' do
+ it 'returns a md file' do
+ get :download, format: :md
+ expect(response.content_type).to eq('text/plain')
+ expect(response.headers['Content-Disposition']).to match(/questions-.*\.md/)
+ end
+
+ it 'includes bookmarked questions in the response' do
+ get :download, format: :md
+ expect(response.body).to include(question.text)
+ end
+ end
+ end
end
end
diff --git a/spec/services/question_formatter/base_service_spec.rb b/spec/services/question_formatter/base_service_spec.rb
new file mode 100644
index 00000000..a532fd71
--- /dev/null
+++ b/spec/services/question_formatter/base_service_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe QuestionFormatter::BaseService do
+ let(:subject) { described_class.new(question) }
+ let(:question) do
+ build(:question_essay,
+ text: 'Sample essay question',
+ data: { 'html' => 'Essay prompt
Link ' })
+ end
+
+ it 'has the correct public methods' do
+ expect(subject).to respond_to(:format_content)
+ end
+
+ context 'when calling #format_content' do
+ describe 'when question model has no valid model_exporter method defined' do
+ before do
+ allow(question.class).to receive(:model_exporter).and_return('abc')
+ allow(subject).to receive(:divider_line).and_return("\n---\n\n")
+ end
+
+ it 'handles errors gracefully' do
+ expect(subject.format_content).to eq("Question type: Essay requires a valid export format method\n---\n\n")
+ end
+ end
+ end
+end
diff --git a/spec/services/question_formatter/markdown_service_spec.rb b/spec/services/question_formatter/markdown_service_spec.rb
new file mode 100644
index 00000000..c6fe6731
--- /dev/null
+++ b/spec/services/question_formatter/markdown_service_spec.rb
@@ -0,0 +1,299 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe QuestionFormatter::MarkdownService do
+ let(:service) { described_class.new(question) }
+
+ describe '#format' do
+ subject { service.format_content }
+
+ context 'with an essay question' do
+ let(:question) do
+ build(:question_essay,
+ text: 'Sample essay question',
+ data: { 'html' => 'Essay prompt
Link ' })
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ ## QUESTION TYPE: Essay
+ **QUESTION:** Sample essay question
+
+ **Text:** Essay prompt
+ - Point 1
+ Link (https://example.com)
+
+ ---
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with an upload question' do
+ let(:question) do
+ build(:question_upload,
+ text: 'Sample upload question',
+ data: { 'html' => 'Upload instructions
Guidelines ' })
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ ## QUESTION TYPE: Upload
+ **QUESTION:** Sample upload question
+
+ **Text:** Upload instructions
+ - File type: PDF
+ Guidelines (https://example.com)
+
+ ---
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with a multiple choice question' do
+ let(:question) do
+ build(:question_traditional,
+ text: 'Sample multiple choice',
+ data: [
+ { 'answer' => 'Option A', 'correct' => true },
+ { 'answer' => 'Option B', 'correct' => false }
+ ])
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ ## QUESTION TYPE: Multiple Choice
+ **QUESTION:** Sample multiple choice
+
+ 1) Correct: Option A
+ 2) Incorrect: Option B
+
+ ---
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with a select all that apply question' do
+ let(:question) do
+ build(:question_select_all_that_apply,
+ text: 'Sample select all question',
+ data: [
+ { 'answer' => 'Option A', 'correct' => true },
+ { 'answer' => 'Option B', 'correct' => true },
+ { 'answer' => 'Option C', 'correct' => false }
+ ])
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ ## QUESTION TYPE: Select All That Apply
+ **QUESTION:** Sample select all question
+
+ 1) Correct: Option A
+ 2) Correct: Option B
+ 3) Incorrect: Option C
+
+ ---
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with a drag and drop question' do
+ let(:question) do
+ build(:question_drag_and_drop,
+ text: 'Sample drag and drop question',
+ data: [
+ { 'answer' => 'Item 1', 'correct' => true },
+ { 'answer' => 'Item 2', 'correct' => false },
+ { 'answer' => 'Item 3', 'correct' => true }
+ ])
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ ## QUESTION TYPE: Drag and Drop
+ **QUESTION:** Sample drag and drop question
+
+ 1) Correct: Item 1
+ 2) Incorrect: Item 2
+ 3) Correct: Item 3
+
+ ---
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with a matching question' do
+ let(:question) do
+ build(:question_matching,
+ text: 'Sample matching question',
+ data: [
+ {
+ 'answer' => 'Term 1',
+ 'correct' => ['Definition 1']
+ },
+ {
+ 'answer' => 'Term 2',
+ 'correct' => ['Definition 2']
+ },
+ {
+ 'answer' => 'Term 3',
+ 'correct' => ['Definition 3']
+ }
+ ])
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ ## QUESTION TYPE: Matching
+ **QUESTION:** Sample matching question
+
+ 1) Term 1
+ Correct Match: Definition 1
+ 2) Term 2
+ Correct Match: Definition 2
+ 3) Term 3
+ Correct Match: Definition 3
+
+ ---
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with a categorization question' do
+ let(:question) do
+ build(:question_categorization,
+ text: 'Sample categorization',
+ data: [
+ { 'answer' => 'Category 1', 'correct' => ['Item 1', 'Item 2'] },
+ { 'answer' => 'Category 2', 'correct' => ['Item 3'] }
+ ])
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ ## QUESTION TYPE: Categorization
+ **QUESTION:** Sample categorization
+
+ **Category:** Category 1
+ 1) Item 1
+ 2) Item 2
+
+ **Category:** Category 2
+ 1) Item 3
+
+ ---
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with a bow tie question' do
+ let(:question) do
+ build(:question_bow_tie,
+ text: 'Sample bow tie question',
+ data: {
+ 'center' => {
+ 'label' => 'Center Label',
+ 'answers' => [
+ { 'answer' => 'Center Answer 1', 'correct' => true },
+ { 'answer' => 'Center Answer 2', 'correct' => false }
+ ]
+ },
+ 'left' => {
+ 'label' => 'Left Label',
+ 'answers' => [
+ { 'answer' => 'Left Answer 1', 'correct' => true },
+ { 'answer' => 'Left Answer 2', 'correct' => false }
+ ]
+ },
+ 'right' => {
+ 'label' => 'Right Label',
+ 'answers' => [
+ { 'answer' => 'Right Answer 1', 'correct' => true },
+ { 'answer' => 'Right Answer 2', 'correct' => false }
+ ]
+ }
+ })
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ ## QUESTION TYPE: Bow Tie
+ **QUESTION:** Sample bow tie question
+
+ Center
+ 1) Correct: Center Answer 1
+ 2) Incorrect: Center Answer 2
+
+ Left
+ 1) Correct: Left Answer 1
+ 2) Incorrect: Left Answer 2
+
+ Right
+ 1) Correct: Right Answer 1
+ 2) Incorrect: Right Answer 2
+
+ ---
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with a stimulus case study question' do
+ let(:scenario) { build(:question_scenario, text: 'Sample scenario') }
+ let(:sub_question_essay) { build(:question_essay, text: 'Sub question', data: { 'html' => 'Essay prompt
' }) }
+ let(:sub_question_mc) do
+ build(:question_traditional,
+ text: 'Multiple choice sub question',
+ data: [
+ { 'answer' => 'Option A', 'correct' => true },
+ { 'answer' => 'Option B', 'correct' => false }
+ ])
+ end
+ let(:question) do
+ build(:question_stimulus_case_study,
+ text: 'Main question',
+ child_questions: [scenario, sub_question_essay, sub_question_mc])
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ ## QUESTION TYPE: Stimulus Case Study
+ **QUESTION:** Main question
+
+ **Scenario:** Sample scenario
+
+ ### Subquestion Type: Essay
+ **Subquestion:** Sub question
+
+ **Text:** Essay prompt
+
+ ### Subquestion Type: Multiple Choice
+ **Subquestion:** Multiple choice sub question
+
+ 1) Correct: Option A
+ 2) Incorrect: Option B
+
+ ---
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+ end
+end
diff --git a/spec/services/question_formatter/plain_text_service_spec.rb b/spec/services/question_formatter/plain_text_service_spec.rb
new file mode 100644
index 00000000..b9005f02
--- /dev/null
+++ b/spec/services/question_formatter/plain_text_service_spec.rb
@@ -0,0 +1,299 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe QuestionFormatter::PlainTextService do
+ let(:service) { described_class.new(question) }
+
+ describe '#format' do
+ subject { service.format_content }
+
+ context 'with an essay question' do
+ let(:question) do
+ build(:question_essay,
+ text: 'Sample essay question',
+ data: { 'html' => 'Essay prompt
Link ' })
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ QUESTION TYPE: Essay
+ QUESTION: Sample essay question
+
+ Text: Essay prompt
+ - Point 1
+ Link (https://example.com)
+
+ ==========
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with an upload question' do
+ let(:question) do
+ build(:question_upload,
+ text: 'Sample upload question',
+ data: { 'html' => 'Upload instructions
Guidelines ' })
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ QUESTION TYPE: Upload
+ QUESTION: Sample upload question
+
+ Text: Upload instructions
+ - File type: PDF
+ Guidelines (https://example.com)
+
+ ==========
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with a multiple choice question' do
+ let(:question) do
+ build(:question_traditional,
+ text: 'Sample multiple choice',
+ data: [
+ { 'answer' => 'Option A', 'correct' => true },
+ { 'answer' => 'Option B', 'correct' => false }
+ ])
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ QUESTION TYPE: Multiple Choice
+ QUESTION: Sample multiple choice
+
+ 1) Correct: Option A
+ 2) Incorrect: Option B
+
+ ==========
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with a select all that apply question' do
+ let(:question) do
+ build(:question_select_all_that_apply,
+ text: 'Sample select all question',
+ data: [
+ { 'answer' => 'Option A', 'correct' => true },
+ { 'answer' => 'Option B', 'correct' => true },
+ { 'answer' => 'Option C', 'correct' => false }
+ ])
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ QUESTION TYPE: Select All That Apply
+ QUESTION: Sample select all question
+
+ 1) Correct: Option A
+ 2) Correct: Option B
+ 3) Incorrect: Option C
+
+ ==========
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with a drag and drop question' do
+ let(:question) do
+ build(:question_drag_and_drop,
+ text: 'Sample drag and drop question',
+ data: [
+ { 'answer' => 'Item 1', 'correct' => true },
+ { 'answer' => 'Item 2', 'correct' => false },
+ { 'answer' => 'Item 3', 'correct' => true }
+ ])
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ QUESTION TYPE: Drag and Drop
+ QUESTION: Sample drag and drop question
+
+ 1) Correct: Item 1
+ 2) Incorrect: Item 2
+ 3) Correct: Item 3
+
+ ==========
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with a matching question' do
+ let(:question) do
+ build(:question_matching,
+ text: 'Sample matching question',
+ data: [
+ {
+ 'answer' => 'Term 1',
+ 'correct' => ['Definition 1']
+ },
+ {
+ 'answer' => 'Term 2',
+ 'correct' => ['Definition 2']
+ },
+ {
+ 'answer' => 'Term 3',
+ 'correct' => ['Definition 3']
+ }
+ ])
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ QUESTION TYPE: Matching
+ QUESTION: Sample matching question
+
+ 1) Term 1
+ Correct Match: Definition 1
+ 2) Term 2
+ Correct Match: Definition 2
+ 3) Term 3
+ Correct Match: Definition 3
+
+ ==========
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with a categorization question' do
+ let(:question) do
+ build(:question_categorization,
+ text: 'Sample categorization',
+ data: [
+ { 'answer' => 'Category 1', 'correct' => ['Item 1', 'Item 2'] },
+ { 'answer' => 'Category 2', 'correct' => ['Item 3'] }
+ ])
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ QUESTION TYPE: Categorization
+ QUESTION: Sample categorization
+
+ Category: Category 1
+ 1) Item 1
+ 2) Item 2
+
+ Category: Category 2
+ 1) Item 3
+
+ ==========
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with a bow tie question' do
+ let(:question) do
+ build(:question_bow_tie,
+ text: 'Sample bow tie question',
+ data: {
+ 'center' => {
+ 'label' => 'Center Label',
+ 'answers' => [
+ { 'answer' => 'Center Answer 1', 'correct' => true },
+ { 'answer' => 'Center Answer 2', 'correct' => false }
+ ]
+ },
+ 'left' => {
+ 'label' => 'Left Label',
+ 'answers' => [
+ { 'answer' => 'Left Answer 1', 'correct' => true },
+ { 'answer' => 'Left Answer 2', 'correct' => false }
+ ]
+ },
+ 'right' => {
+ 'label' => 'Right Label',
+ 'answers' => [
+ { 'answer' => 'Right Answer 1', 'correct' => true },
+ { 'answer' => 'Right Answer 2', 'correct' => false }
+ ]
+ }
+ })
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ QUESTION TYPE: Bow Tie
+ QUESTION: Sample bow tie question
+
+ Center
+ 1) Correct: Center Answer 1
+ 2) Incorrect: Center Answer 2
+
+ Left
+ 1) Correct: Left Answer 1
+ 2) Incorrect: Left Answer 2
+
+ Right
+ 1) Correct: Right Answer 1
+ 2) Incorrect: Right Answer 2
+
+ ==========
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+
+ context 'with a stimulus case study question' do
+ let(:scenario) { build(:question_scenario, text: 'Sample scenario') }
+ let(:sub_question_essay) { build(:question_essay, text: 'Sub question', data: { 'html' => 'Essay prompt
' }) }
+ let(:sub_question_mc) do
+ build(:question_traditional,
+ text: 'Multiple choice sub question',
+ data: [
+ { 'answer' => 'Option A', 'correct' => true },
+ { 'answer' => 'Option B', 'correct' => false }
+ ])
+ end
+ let(:question) do
+ build(:question_stimulus_case_study,
+ text: 'Main question',
+ child_questions: [scenario, sub_question_essay, sub_question_mc])
+ end
+
+ it 'formats the question correctly' do
+ expected_output = <<~TEXT
+ QUESTION TYPE: Stimulus Case Study
+ QUESTION: Main question
+
+ Scenario: Sample scenario
+
+ Subquestion Type: Essay
+ Subquestion: Sub question
+
+ Text: Essay prompt
+
+ Subquestion Type: Multiple Choice
+ Subquestion: Multiple choice sub question
+
+ 1) Correct: Option A
+ 2) Incorrect: Option B
+
+ ==========
+
+ TEXT
+ expect(subject).to eq(expected_output)
+ end
+ end
+ end
+end