diff --git a/app/helpers/application_formatters_helper.rb b/app/helpers/application_formatters_helper.rb index 7415cb4d388..b9a2a70cfc7 100644 --- a/app/helpers/application_formatters_helper.rb +++ b/app/helpers/application_formatters_helper.rb @@ -82,4 +82,19 @@ def format_duration(total_seconds) hours = total_seconds / (60 * 60) format('%02dH%02dM%02dS', hours: hours, minutes: minutes, seconds: seconds) end + + # Formats rich text fields for CSV export by stripping HTML tags and decoding HTML entities. + # Rich text fields are saved as HTML in the database (from WYSIWYG editors), so this helper + # converts them to plain text suitable for CSV files by removing HTML markup and decoding + # entities like  , &, etc. + # + # @param [String] text The rich text (HTML) to format + # @param [Boolean] preserve_newlines Whether to preserve paragraph/line breaks (default: true) + # @return [String] Plain text with HTML tags removed and entities decoded + def format_rich_text_for_csv(text) + return '' unless text + + cleaned_text = text.gsub('

', "

\n").gsub('
', "
\n") + HTMLEntities.new.decode(ActionController::Base.helpers.strip_tags(cleaned_text)).strip + end end diff --git a/app/helpers/course/statistics/aggregate_helper.rb b/app/helpers/course/statistics/aggregate_helper.rb deleted file mode 100644 index 20f14fc20b5..00000000000 --- a/app/helpers/course/statistics/aggregate_helper.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true -module Course::Statistics::AggregateHelper - # Convert time in seconds to HH:MM:SS format. - def seconds_to_str(total_seconds) - return '--:--:--' unless total_seconds - - seconds = total_seconds % 60 - minutes = (total_seconds / 60) % 60 - hours = (total_seconds / (60 * 60)) % 24 - days = total_seconds / (60 * 60 * 24) - - format("%2d #{t('time.day')} %02d:%02d:%02d", days, hours, minutes, seconds) - end -end diff --git a/app/models/course/assessment/answer/forum_post_response.rb b/app/models/course/assessment/answer/forum_post_response.rb index 1921cb2ce6e..25474184efd 100644 --- a/app/models/course/assessment/answer/forum_post_response.rb +++ b/app/models/course/assessment/answer/forum_post_response.rb @@ -77,8 +77,7 @@ def stripped_answer_to_array def readable_string_of(text) return nil unless text - cleaned_text = text.gsub('

', "

\n").gsub('
', "
\n").squish - ActionController::Base.helpers.strip_tags(cleaned_text) + ApplicationController.helpers.format_rich_text_for_csv(text).squish end def destroy_previous_selection diff --git a/app/models/course/assessment/answer/multiple_response.rb b/app/models/course/assessment/answer/multiple_response.rb index c0758e9ae68..975d69686e9 100644 --- a/app/models/course/assessment/answer/multiple_response.rb +++ b/app/models/course/assessment/answer/multiple_response.rb @@ -34,6 +34,6 @@ def compare_answer(other_answer) end def csv_download - ActionController::Base.helpers.strip_tags(options.map(&:option).join(';')) + ApplicationController.helpers.format_rich_text_for_csv(options.map(&:option).join(';')) end end diff --git a/app/models/course/assessment/answer/rubric_based_response.rb b/app/models/course/assessment/answer/rubric_based_response.rb index 5354ec5a74a..dcb4b17d38b 100644 --- a/app/models/course/assessment/answer/rubric_based_response.rb +++ b/app/models/course/assessment/answer/rubric_based_response.rb @@ -42,7 +42,7 @@ def grade_inline? end def csv_download - ActionController::Base.helpers.strip_tags(answer_text) + ApplicationController.helpers.format_rich_text_for_csv(answer_text) end def compare_answer(other_answer) diff --git a/app/models/course/assessment/answer/text_response.rb b/app/models/course/assessment/answer/text_response.rb index 29a8c70242b..e0c84b232f7 100644 --- a/app/models/course/assessment/answer/text_response.rb +++ b/app/models/course/assessment/answer/text_response.rb @@ -25,7 +25,7 @@ def download(dir) end def csv_download - ActionController::Base.helpers.strip_tags(answer_text) + ApplicationController.helpers.format_rich_text_for_csv(answer_text) end def download_answer(dir) diff --git a/app/services/course/survey/survey_download_service.rb b/app/services/course/survey/survey_download_service.rb index f6545c57215..a3f8c83d6b9 100644 --- a/app/services/course/survey/survey_download_service.rb +++ b/app/services/course/survey/survey_download_service.rb @@ -3,6 +3,7 @@ class Course::Survey::SurveyDownloadService include TmpCleanupHelper + include ApplicationFormattersHelper def initialize(survey) @survey = survey @@ -57,7 +58,7 @@ def generate_header(questions) I18n.t('course.surveys.survey_download_service.course_user_id'), I18n.t('course.surveys.survey_download_service.name'), I18n.t('course.surveys.survey_download_service.role') - ] + questions.map(&:description) + ] + questions.map { |q| format_rich_text_for_csv(q.description) } end def generate_row(response, questions) diff --git a/app/views/course/statistics/aggregate/all_assessments.json.jbuilder b/app/views/course/statistics/aggregate/all_assessments.json.jbuilder index 7356b08fa4b..9cf70793fa5 100644 --- a/app/views/course/statistics/aggregate/all_assessments.json.jbuilder +++ b/app/views/course/statistics/aggregate/all_assessments.json.jbuilder @@ -24,8 +24,8 @@ json.assessments @assessments do |assessment| json.averageGrade grade_stats ? grade_stats[0] : 0 json.stdevGrade grade_stats ? grade_stats[1] : 0 - json.averageTimeTaken seconds_to_str(duration_stats[0]) - json.stdevTimeTaken seconds_to_str(duration_stats[1]) + json.averageTimeTaken duration_stats[0] + json.stdevTimeTaken duration_stats[1] json.numSubmitted @num_submitted_students_hash[assessment.id] || 0 json.numAttempted @num_attempted_students_hash[assessment.id] || 0 diff --git a/app/views/course/statistics/aggregate/all_staff.json.jbuilder b/app/views/course/statistics/aggregate/all_staff.json.jbuilder index d863a04e577..35e4f2d4ef5 100644 --- a/app/views/course/statistics/aggregate/all_staff.json.jbuilder +++ b/app/views/course/statistics/aggregate/all_staff.json.jbuilder @@ -6,6 +6,6 @@ json.staff graded_staff do |staff| json.name staff.name json.numGraded staff.published_submissions.size json.numStudents staff.my_students.count - json.averageMarkingTime seconds_to_str(staff.average_marking_time) - json.stddev seconds_to_str(staff.marking_time_stddev) + json.averageMarkingTime staff.average_marking_time + json.stddev staff.marking_time_stddev end diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackMessageHistory.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackMessageHistory.tsx index 3f36c1b3225..ad3d7e0df71 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackMessageHistory.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackMessageHistory.tsx @@ -10,7 +10,7 @@ import { } from 'course/assessment/submission/components/GetHelpChatPage/utils'; import MarkdownText from 'course/assessment/submission/components/MarkdownText'; import useTranslation from 'lib/hooks/useTranslation'; -import moment, { SHORT_DATE_TIME_FORMAT } from 'lib/moment'; +import { formatShortDateTime } from 'lib/moment'; interface Props { messages: LiveFeedbackChatMessage[]; @@ -110,9 +110,7 @@ const LiveFeedbackMessageHistory: FC = (props) => { const message = messages[messageIndex]; const isStudent = message.creatorId !== 0; const isError = message.isError; - const createdAt = moment(new Date(message.createdAt)).format( - SHORT_DATE_TIME_FORMAT, - ); + const createdAt = formatShortDateTime(message.createdAt); return (
{ - const createdAt = moment(new Date(message.createdAt)).format( - SHORT_TIME_FORMAT, - ); + const createdAt = formatShortTime(new Date(message.createdAt)); return { sender: message.creatorId === 0 @@ -267,7 +265,7 @@ export const liveFeedbackChatSlice = createSlice({ const { answerId, message } = action.payload; const liveFeedbackChats = state.liveFeedbackChatPerAnswer.entities[answerId]; - const currentTime = moment(new Date()).format(SHORT_TIME_FORMAT); + const currentTime = formatShortTime(new Date()); if (liveFeedbackChats) { const changes: Partial = { @@ -342,7 +340,7 @@ export const liveFeedbackChatSlice = createSlice({ { sender: ChatSender.codaveri, message: overallContent, - createdAt: moment(new Date()).format(SHORT_TIME_FORMAT), + createdAt: formatShortTime(new Date()), isError: false, }, ] @@ -384,7 +382,7 @@ export const liveFeedbackChatSlice = createSlice({ const newChat: ChatShape = { sender: ChatSender.codaveri, message: errorMessage, - createdAt: moment(new Date()).format(SHORT_TIME_FORMAT), + createdAt: formatShortTime(new Date()), isError: true, }; diff --git a/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/index.jsx b/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/index.jsx index 157726cae87..a3ea5d0649b 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/index.jsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/index.jsx @@ -8,7 +8,7 @@ import { duplicationModes } from 'course/duplication/constants'; import { duplicateCourse } from 'course/duplication/operations'; import { courseShape, sourceCourseShape } from 'course/duplication/propTypes'; import { actions } from 'course/duplication/store'; -import moment, { SHORT_DATE_TIME_FORMAT } from 'lib/moment'; +import moment, { formatShortDateTime } from 'lib/moment'; import NewCourseForm from './NewCourseForm'; @@ -85,7 +85,7 @@ class DestinationCourseSelector extends Component { date: tomorrow.date(), }); - const timeNow = moment().format(SHORT_DATE_TIME_FORMAT); + const timeNow = formatShortDateTime(new Date()); const newTitleValues = { title: sourceCourse.title, timestamp: timeNow }; const initialValues = { destination_instance_id: currentInstanceId, diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/Details/Chips.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/Details/Chips.jsx index 399b62846cc..64f69595180 100644 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/Details/Chips.jsx +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/Details/Chips.jsx @@ -8,7 +8,7 @@ import { Avatar, Chip } from '@mui/material'; import { red } from '@mui/material/colors'; import PropTypes from 'prop-types'; -import moment, { SHORT_DATE_TIME_FORMAT, SHORT_TIME_FORMAT } from 'lib/moment'; +import moment, { formatShortDateTime, formatShortTime } from 'lib/moment'; const translations = defineMessages({ notPublished: { @@ -58,17 +58,13 @@ export const formatDateRange = (startAt, endAt) => { const end = moment(endAt); if (!end.isValid()) { - return start.format(SHORT_DATE_TIME_FORMAT); + return formatShortDateTime(start); } if (end.isSame(start, 'day')) { - return `${start.format(SHORT_DATE_TIME_FORMAT)} - ${end.format( - SHORT_TIME_FORMAT, - )}`; + return `${formatShortDateTime(start)} - ${formatShortTime(end)}`; } - return `${start.format(SHORT_DATE_TIME_FORMAT)} - ${end.format( - SHORT_DATE_TIME_FORMAT, - )}`; + return `${formatShortDateTime(start)} - ${formatShortDateTime(end)}`; }; class Chips extends Component { diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx index e288b0bcb7c..07d1a144fc5 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx @@ -17,7 +17,7 @@ import { } from 'lib/helpers/url-builders'; import { getCourseId } from 'lib/helpers/url-helpers'; import useTranslation from 'lib/hooks/useTranslation'; -import { formatMiniDateTime } from 'lib/moment'; +import { formatMiniDateTime, formatSecondsDuration } from 'lib/moment'; import AssessmentsScoreSummaryDownload from './AssessmentsScoreSummaryDownload'; @@ -226,14 +226,14 @@ const AssessmentsStatisticsTable: FC = (props) => { of: 'averageTimeTaken', title: t(translations.averageTimeTaken), sortable: true, - cell: (assessment) => assessment.averageTimeTaken, + cell: (assessment) => formatSecondsDuration(assessment.averageTimeTaken), csvDownloadable: true, }, { of: 'stdevTimeTaken', title: t(translations.stdevTimeTaken), sortable: true, - cell: (assessment) => assessment.stdevTimeTaken, + cell: (assessment) => formatSecondsDuration(assessment.stdevTimeTaken), csvDownloadable: true, }, ]; diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/staff/StaffStatisticsTable.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/staff/StaffStatisticsTable.tsx index 0e63f04e89f..64ac9ea868c 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/staff/StaffStatisticsTable.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/staff/StaffStatisticsTable.tsx @@ -7,6 +7,7 @@ import { processStaff } from 'course/statistics/utils/parseStaffResponse'; import Table, { ColumnTemplate } from 'lib/components/table'; import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; import useTranslation from 'lib/hooks/useTranslation'; +import { formatSecondsDuration } from 'lib/moment'; const translations = defineMessages({ name: { @@ -82,14 +83,14 @@ const StaffStatisticsTable: FC = (props) => { title: t(translations.averageMarkingTime), sortable: true, csvDownloadable: true, - cell: (staff) => staff.averageMarkingTime, + cell: (staff) => formatSecondsDuration(staff.averageMarkingTime), }, { of: 'stddev', title: t(translations.stddev), sortable: true, csvDownloadable: true, - cell: (staff) => staff.stddev, + cell: (staff) => formatSecondsDuration(staff.stddev), }, ]; diff --git a/client/app/bundles/course/statistics/types.ts b/client/app/bundles/course/statistics/types.ts index 253710ead8a..627587339f4 100644 --- a/client/app/bundles/course/statistics/types.ts +++ b/client/app/bundles/course/statistics/types.ts @@ -63,8 +63,8 @@ export interface Staff { name: string; numGraded: number; numStudents: number; - averageMarkingTime: string; - stddev: string; + averageMarkingTime: number; + stddev: number; } export interface StaffStatistics { @@ -125,8 +125,8 @@ export interface CourseAssessment { maximumGrade: number; averageGrade: number; stdevGrade: number; - averageTimeTaken: string; - stdevTimeTaken: string; + averageTimeTaken: number; + stdevTimeTaken: number; numSubmitted: number; numAttempted: number; numLate: number; diff --git a/client/app/lib/moment.ts b/client/app/lib/moment.ts index d63b9cd3c3f..734189f44b2 100644 --- a/client/app/lib/moment.ts +++ b/client/app/lib/moment.ts @@ -1,27 +1,41 @@ import moment from 'moment-timezone'; +moment.updateLocale('en', { + relativeTime: { + m: '1 minute', + h: '1 hour', + d: '1 day', + w: '1 week', + M: '1 month', + y: '1 year', + }, +}); + const LONG_DATE_FORMAT = 'DD MMM YYYY' as const; const LONG_TIME_FORMAT = 'h:mma' as const; const LONG_DATE_TIME_FORMAT = `${LONG_DATE_FORMAT}, ${LONG_TIME_FORMAT}` as const; const SHORT_DATE_FORMAT = 'DD-MM-YYYY' as const; +const PRECISE_SECONDS_TIME_FORMAT = 'HH:mm:ss' as const; const PRECISE_TIME_FORMAT = 'HH:mm:ss.SS' as const; const PRECISE_DATE_TIME_FORMAT = `DD-MM-YY ${PRECISE_TIME_FORMAT}` as const; -// TODO: Do not export these and remove all of their imports -export const SHORT_TIME_FORMAT = 'HH:mm' as const; -export const SHORT_DATE_TIME_FORMAT = +const SHORT_TIME_FORMAT = 'HH:mm' as const; +const SHORT_DATE_TIME_FORMAT = `${SHORT_DATE_FORMAT} ${SHORT_TIME_FORMAT}` as const; const FULL_DATE_TIME_FORMAT = 'dddd, MMMM D YYYY, HH:mm' as const; const MINI_DATE_TIME_FORMAT = 'D MMM YYYY HH:mm' as const; const MINI_DATE_TIME_YEARLESS_FORMAT = 'D MMM HH:mm' as const; +const SECONDS_IN_A_DAY = 86400 as const; + // TODO: Do not export moment and create the helpers here export default moment; type DateTimeFormatter = (input?: string | Date | null | number) => string; +type DurationFormatter = (input?: string | null | number) => string; const formatterWith = (format: string): DateTimeFormatter => @@ -36,6 +50,7 @@ export const formatLongDate = formatterWith(LONG_DATE_FORMAT); export const formatLongDateTime = formatterWith(LONG_DATE_TIME_FORMAT); export const formatShortDateTime = formatterWith(SHORT_DATE_TIME_FORMAT); export const formatFullDateTime = formatterWith(FULL_DATE_TIME_FORMAT); +export const formatShortTime = formatterWith(SHORT_TIME_FORMAT); export const formatPreciseTime = formatterWith(PRECISE_TIME_FORMAT); export const formatPreciseDateTime = formatterWith(PRECISE_DATE_TIME_FORMAT); @@ -48,3 +63,23 @@ export const formatMiniDateTime: DateTimeFormatter = (input) => { return dateTime.format(MINI_DATE_TIME_FORMAT); }; + +export const formatSecondsDuration: DurationFormatter = (input) => { + if (!input) return '--:--:--'; + if (typeof input === 'string') { + input = parseInt(input, 10); + } + + const durationDays = + input >= SECONDS_IN_A_DAY + ? // Always display days for greater precision, otherwise "40 days" gets rounded to "a month" + moment + .duration(Math.floor(input / SECONDS_IN_A_DAY), 'd') + .humanize(false, { d: 1_000_000_000 }) + : ''; + + const durationTime = moment + .utc((input % SECONDS_IN_A_DAY) * 1000) + .format(PRECISE_SECONDS_TIME_FORMAT); + return [durationDays, durationTime].join(' ').trim(); +}; diff --git a/config/locales/en/time.yml b/config/locales/en/time.yml index c88b42b72ea..58e43d52980 100644 --- a/config/locales/en/time.yml +++ b/config/locales/en/time.yml @@ -1,5 +1,4 @@ en: time: - day: 'day' formats: default: '%a, %d %b %Y %I:%M:%S %p' diff --git a/config/locales/ko/time.yml b/config/locales/ko/time.yml index d6b2fd3d5dc..1ee43cc0eb2 100644 --- a/config/locales/ko/time.yml +++ b/config/locales/ko/time.yml @@ -1,5 +1,4 @@ ko: time: - day: '일' formats: default: '%a, %Y %b %d %p %I:%M:%S' diff --git a/config/locales/zh/time.yml b/config/locales/zh/time.yml index 5881711a853..a3d222966aa 100644 --- a/config/locales/zh/time.yml +++ b/config/locales/zh/time.yml @@ -1,5 +1,4 @@ zh: time: - day: '日期' formats: default: '%a, %Y %b %d %H:%M:%S' diff --git a/spec/features/course/staff_statistics_spec.rb b/spec/features/course/staff_statistics_spec.rb index c9e380f7b14..a2b1bba5374 100644 --- a/spec/features/course/staff_statistics_spec.rb +++ b/spec/features/course/staff_statistics_spec.rb @@ -24,7 +24,7 @@ # Create submissions for tutors, with given submitted at and published_at let!(:tutor1_submissions) do submitted_at = 1.day.ago - published_at = submitted_at + 1.day + 1.hour + 1.minute + 1.second + published_at = submitted_at + 1.hour assessment = create(:assessment, :with_mcq_question, course: course) submission = create(:submission, :published, assessment: assessment, course: course, publisher: tutor1.user, @@ -36,7 +36,7 @@ let!(:tutor2_submissions) do submitted_at = 2.days.ago - published_at = submitted_at + 2.days + published_at = submitted_at + 1.day + 1.hour + 1.minute + 1.second assessment = create(:assessment, :with_mcq_question, course: course) submission = create(:submission, :published, assessment: assessment, course: course, publisher: tutor2.user, @@ -48,7 +48,7 @@ let!(:tutor3_submissions) do submitted_at = 2.days.ago - published_at = submitted_at + 3.days + published_at = submitted_at + 2.days assessment = create(:assessment, :with_mcq_question, course: course) staff_submission = create(:submission, :published, assessment: assessment, course: course, publisher: tutor3.user, @@ -85,14 +85,14 @@ expect(row).to have_selector('td', text: '1') # S/N expect(row).to have_selector('td', text: tutor1.name) expect(row).to have_selector('td', text: tutor1_submissions.size) - expect(row).to have_selector('td', text: "1 #{I18n.t('time.day')} 01:01:01") + expect(row).to have_selector('td', text: '01:00:00') end within find('tr', text: tutor2.name) do |row| expect(row).to have_selector('td', text: '2') expect(row).to have_selector('td', text: tutor2.name) expect(row).to have_selector('td', text: tutor2_submissions.size) - expect(row).to have_selector('td', text: "2 #{I18n.t('time.day')} 00:00:00") + expect(row).to have_selector('td', text: '1 day 01:01:01') end # Do not reflect staff submissions as part of staff statistics. @@ -100,7 +100,7 @@ expect(row).to have_selector('td', text: '3') expect(row).to have_selector('td', text: tutor3.name) expect(row).to have_selector('td', text: '1') - expect(row).to have_selector('td', text: "3 #{I18n.t('time.day')} 00:00:00") + expect(row).to have_selector('td', text: '2 days 00:00:00') end expect(page).not_to have_text(tutor4.name) diff --git a/spec/helpers/application_formatters_helper_spec.rb b/spec/helpers/application_formatters_helper_spec.rb index d86191a0cac..b9506cd46f8 100644 --- a/spec/helpers/application_formatters_helper_spec.rb +++ b/spec/helpers/application_formatters_helper_spec.rb @@ -314,4 +314,104 @@ def hello: end end end + + describe '#format_rich_text_for_csv' do + context 'when text is nil' do + it 'returns empty string' do + expect(helper.format_rich_text_for_csv(nil)).to eq('') + end + end + + context 'when text is empty' do + it 'returns empty string' do + expect(helper.format_rich_text_for_csv('')).to eq('') + end + end + + context 'when text contains HTML tags' do + it 'strips all HTML tags' do + html = '

Hello World

' + expect(helper.format_rich_text_for_csv(html)).to eq('Hello World') + end + + it 'strips nested HTML tags' do + html = '

Test

' + expect(helper.format_rich_text_for_csv(html)).to eq('Test') + end + end + + context 'when text contains HTML entities' do + it 'decodes   to space' do + html = '

Hello World

' + expect(helper.format_rich_text_for_csv(html)).to eq('Hello World') + end + + it 'decodes & to &' do + html = '

Test&Example

' + expect(helper.format_rich_text_for_csv(html)).to eq('Test&Example') + end + + it 'decodes multiple entities' do + html = '

Hello World&Test<Example>

' + expect(helper.format_rich_text_for_csv(html)).to eq('Hello World&Test') + end + + it 'decodes numeric entities' do + html = '

"Quote"

' + expect(helper.format_rich_text_for_csv(html)).to eq('"Quote"') + end + end + + context 'when text contains paragraph and line breaks' do + it 'preserves paragraph breaks as newlines' do + html = '

First paragraph

Second paragraph

' + result = helper.format_rich_text_for_csv(html) + expect(result).to eq("First paragraph\nSecond paragraph") + end + + it 'preserves line breaks as newlines' do + html = '

Line 1
Line 2

' + result = helper.format_rich_text_for_csv(html) + expect(result).to eq("Line 1\nLine 2") + end + + it 'handles complex HTML with mixed content' do + html = '

First

Second
Third

Fourth

' + result = helper.format_rich_text_for_csv(html) + expect(result).to eq("First\nSecond\nThird\nFourth") + end + end + + context 'edge cases' do + it 'handles malformed HTML gracefully' do + html = '

Unclosed paragraph' + result = helper.format_rich_text_for_csv(html) + expect(result).to eq('Unclosed paragraph') + end + + it 'handles text without any HTML' do + text = 'Plain text' + result = helper.format_rich_text_for_csv(text) + expect(result).to eq('Plain text') + end + + it 'handles mixed plain text and HTML' do + html = 'Plain bold text' + result = helper.format_rich_text_for_csv(html) + expect(result).to eq('Plain bold text') + end + + it 'handles script tags (should be stripped)' do + html = '

Safe text

' + result = helper.format_rich_text_for_csv(html) + expect(result).to eq("Safe text\nalert(\"xss\")") + end + + it 'handles literal & character' do + html = 'Peanut Butter & Jelly' + result = helper.format_rich_text_for_csv(html) + expect(result).to eq('Peanut Butter & Jelly') + end + end + end end