diff --git a/app/assets/stylesheets/sensemaker/sensemaker.scss b/app/assets/stylesheets/sensemaker/sensemaker.scss index c6b572ed747..ff8427561c8 100644 --- a/app/assets/stylesheets/sensemaker/sensemaker.scss +++ b/app/assets/stylesheets/sensemaker/sensemaker.scss @@ -322,6 +322,30 @@ } .sensemaker { + + // Responsive adjustments + @include breakpoint(small only) { + .hero-section { + h1 { + font-size: rem-calc(28); + } + + .lead { + font-size: rem-calc(16); + } + } + + .report-card { + .card-section { + padding: 1rem; + } + } + + .contextual-info-sidebar { + margin-top: 2rem; + } + } + .flex-between { align-items: center; display: flex; @@ -366,4 +390,238 @@ padding: 0.25rem 0.5rem; text-transform: uppercase; } + + // Target header (quiz-header style box) + .target-header { + margin-bottom: 2.2rem; + + .target-header-title { + @include grid-column-gutter; + background: $highlight; + padding: $line-height; + width: 100%; + } + + .target-header-label { + font-size: $small-font-size; + font-weight: 700; + margin-bottom: 0; + text-transform: uppercase; + } + + .target-header-links { + margin-bottom: 0.5rem; + + a { + @include brand-color; + @include header-font-size(h4); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + .target-header-separator { + margin: 0 0.35rem; + opacity: 0.8; + } + + .target-header-description { + font-size: rem-calc(15); + line-height: 1.6; + margin-bottom: 0; + margin-top: 0.75rem; + opacity: 0.9; + } + } + + .hero-section { + margin-bottom: 2rem; + text-align: center; + + @include breakpoint(medium) { + text-align: left; + } + + h1 { + font-size: rem-calc(20); + font-weight: 700; + margin-bottom: 1rem; + + @include breakpoint(medium) { + font-size: rem-calc(28); + } + } + + .lead { + font-size: rem-calc(18); + line-height: 1.6; + margin-bottom: 1rem; + + @include breakpoint(medium) { + font-size: rem-calc(20); + } + } + + .parent-resource-link { + margin-bottom: 1rem; + } + + .intro-text { + margin-top: 1rem; + } + } + + .report-card { + border: 1px solid $light-gray; + border-radius: 4px; + height: 100%; + + .card-section { + padding: 1.5rem; + } + + .report-header { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; + + h4 { + margin: 0; + } + + .report-link { + color: $brand; + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + .analysis-type-badge { + align-self: flex-start; + border-radius: 4px; + display: inline-block; + font-size: rem-calc(12); + font-weight: 600; + padding: 0.25rem 0.75rem; + text-transform: uppercase; + + &.badge-report { + background-color: #1779ba; + color: $white; + } + + &.badge-summary { + background-color: #3adb76; + color: $white; + } + + &.badge-default { + background-color: $medium-gray; + color: $white; + } + } + + .report-meta { + color: $dark-gray; + font-size: rem-calc(14); + margin-bottom: 0.5rem; + + &.small { + font-size: rem-calc(12); + } + + strong { + color: $black; + } + } + } + + .contextual-info-sidebar { + margin-top: 2rem; + + @include breakpoint(medium) { + margin-top: 0; + padding-top: 0; + } + + .callout { + background-color: #f8f9fa; + border: 1px solid $light-gray; + border-radius: 4px; + padding: 1.5rem; + + .contextual-info-details summary { + cursor: pointer; + font-size: rem-calc(16); + font-weight: 700; + list-style: none; + padding-left: 1.25rem; + position: relative; + + @include breakpoint(medium) { + font-size: rem-calc(18); + } + + &::-webkit-details-marker { + display: none; + } + + &::marker { + display: none; + } + + &::before { + content: "▶"; + font-size: rem-calc(12); + left: 0; + position: absolute; + top: 0.25rem; + transition: transform 0.2s ease; + } + + &:hover { + opacity: 0.8; + } + } + + .contextual-info-details[open] summary { + margin-bottom: 1rem; + + &::before { + content: "▼"; + transform: rotate(0deg); + } + } + + .contextual-info-details-content { + p:last-child { + margin-bottom: 0; + } + } + + p { + font-size: rem-calc(14); + line-height: 1.6; + margin-bottom: 1rem; + + &.small { + font-size: rem-calc(13); + } + + strong { + color: $black; + display: block; + margin-bottom: 0.25rem; + } + } + } + } } diff --git a/app/components/admin/sensemaker/job_component_helpers.rb b/app/components/admin/sensemaker/job_component_helpers.rb index b07707dedcc..0a2a73cf98f 100644 --- a/app/components/admin/sensemaker/job_component_helpers.rb +++ b/app/components/admin/sensemaker/job_component_helpers.rb @@ -1,5 +1,6 @@ module Admin::Sensemaker::JobComponentHelpers extend ActiveSupport::Concern + include Sensemaker::ReportComponentHelpers def job_status_class "job-status-#{job.status.downcase}" diff --git a/app/components/admin/sensemaker/job_show_component.html.erb b/app/components/admin/sensemaker/job_show_component.html.erb index a02ba2cf7c9..1b38aba218d 100644 --- a/app/components/admin/sensemaker/job_show_component.html.erb +++ b/app/components/admin/sensemaker/job_show_component.html.erb @@ -11,6 +11,7 @@

<%= t("admin.sensemaker.job_show.details") %>

<%= link_to t("admin.sensemaker.job_show.back_to_jobs"), admin_sensemaker_jobs_path, class: "margin-right" %> + <%= link_to t("admin.sensemaker.job_show.published_reports_link"), jobs_index_path_for(sensemaker_job.analysable), class: "margin-right" if is_published? %> <% if can_publish? %> <% if is_published? %> <%= button_to t("admin.sensemaker.job_row.unpublish"), unpublish_admin_sensemaker_job_path(sensemaker_job), form_class: "inline", class: "button", method: :patch %> diff --git a/app/components/layout/subnavigation_component.html.erb b/app/components/layout/subnavigation_component.html.erb index d21b926e5ba..dd100bfae3e 100644 --- a/app/components/layout/subnavigation_component.html.erb +++ b/app/components/layout/subnavigation_component.html.erb @@ -14,7 +14,7 @@
  • <%= layout_menu_link_to t("layouts.header.proposals"), proposals_path, - controller.class == ProposalsController, + controller.class == ProposalsController || (controller.class == Sensemaker::JobsController && action_name == "all_proposals_index"), accesskey: "2" %>
  • <% end %> diff --git a/app/components/polls/poll_header_component.html.erb b/app/components/polls/poll_header_component.html.erb index 6058b9bcf73..e68bc5b3062 100644 --- a/app/components/polls/poll_header_component.html.erb +++ b/app/components/polls/poll_header_component.html.erb @@ -16,6 +16,8 @@
    diff --git a/app/views/budgets/investments/_sidebar.html.erb b/app/views/budgets/investments/_sidebar.html.erb index a822c2efdc2..e68fb77b03b 100644 --- a/app/views/budgets/investments/_sidebar.html.erb +++ b/app/views/budgets/investments/_sidebar.html.erb @@ -27,3 +27,4 @@ <%= render "shared/tag_cloud", taggable: "Budget::Investment" %> <%= render "budgets/investments/categories" %> <%= render Budgets::Investments::FiltersComponent.new %> +<%= render Sensemaker::ReportLinkComponent.new(@budget) %> diff --git a/app/views/budgets/sensemaking/show.html.erb b/app/views/budgets/sensemaking/show.html.erb new file mode 100644 index 00000000000..da5a121f983 --- /dev/null +++ b/app/views/budgets/sensemaking/show.html.erb @@ -0,0 +1,34 @@ +<% provide :title, t("budgets.sensemaking.page_title", budget: @budget.name) %> +<% content_for :meta_description do %><%= @budget.description_for_phase("finished") %><% end %> +<% provide :social_media_meta_tags do %> + <%= render "shared/social_media_meta_tags", + social_url: budget_sensemaking_url(@budget), + social_title: @budget.name, + social_description: @budget.description_for_phase("finished") %> +<% end %> + +<% content_for :canonical do %> + <%= render "shared/canonical", href: budget_sensemaking_url(@budget) %> +<% end %> + +
    +
    +
    +
    + <%= back_link_to budget_path(@budget) %> +

    + <%= t("budgets.sensemaking.heading") %>
    + <%= @budget.name %> +

    +
    +
    +
    +
    + +<%= render "budgets/subnav", budget: @budget %> + +
    +
    + <%= render Sensemaker::BudgetJobIndexComponent.new(jobs: @sensemaker_jobs, budget: @budget) %> +
    +
    diff --git a/app/views/debates/show.html.erb b/app/views/debates/show.html.erb index d5bbbf63737..7caaea6ee7e 100644 --- a/app/views/debates/show.html.erb +++ b/app/views/debates/show.html.erb @@ -48,6 +48,9 @@
    <%= render Debates::VotesComponent.new(@debate) %>
    + + <%= render Sensemaker::ReportLinkComponent.new(@debate) %> + <%= render "shared/social_share", share_title: t("debates.show.share"), title: @debate.title, diff --git a/app/views/legislation/processes/_header.html.erb b/app/views/legislation/processes/_header.html.erb index 110fd77bc3e..b7f3386edef 100644 --- a/app/views/legislation/processes/_header.html.erb +++ b/app/views/legislation/processes/_header.html.erb @@ -72,6 +72,8 @@ <% end %>

    <% end %> + + <%= render Sensemaker::ReportLinkComponent.new(@process) %> diff --git a/app/views/legislation/proposals/show.html.erb b/app/views/legislation/proposals/show.html.erb index f899de810fb..bfb1721a730 100644 --- a/app/views/legislation/proposals/show.html.erb +++ b/app/views/legislation/proposals/show.html.erb @@ -90,6 +90,7 @@ url: legislation_process_proposal_url(process_id: @process), description: @proposal.summary, mobile: @proposal.title %> + <%= render Sensemaker::ReportLinkComponent.new(@proposal) %> diff --git a/app/views/legislation/questions/show.html.erb b/app/views/legislation/questions/show.html.erb index 5578932f57c..2719b03586a 100644 --- a/app/views/legislation/questions/show.html.erb +++ b/app/views/legislation/questions/show.html.erb @@ -30,6 +30,8 @@ diff --git a/app/views/proposals/show.html.erb b/app/views/proposals/show.html.erb index c00c99f8e22..17e10c93761 100644 --- a/app/views/proposals/show.html.erb +++ b/app/views/proposals/show.html.erb @@ -70,6 +70,8 @@ <%= render "proposals/social_share", proposal: @proposal, share_title: t("proposals.show.share") %> + <%= render Sensemaker::ReportLinkComponent.new(@proposal) %> + <% if current_user %> diff --git a/app/views/sensemaker/jobs/index.html.erb b/app/views/sensemaker/jobs/index.html.erb new file mode 100644 index 00000000000..b116045107d --- /dev/null +++ b/app/views/sensemaker/jobs/index.html.erb @@ -0,0 +1,11 @@ +<% provide :title, "Sensemaker Analysis" %> + +
    +
    + <% if action_name == "processes_index" %> + <%= render Sensemaker::ProcessJobIndexComponent.new(jobs: @sensemaker_jobs, process: @parent_resource) %> + <% else %> + <%= render Sensemaker::JobIndexComponent.new(jobs: @sensemaker_jobs, parent_resource: @parent_resource, resource: @resource) %> + <% end %> +
    +
    diff --git a/app/views/sensemaker/jobs/show.html.erb b/app/views/sensemaker/jobs/show.html.erb new file mode 100644 index 00000000000..316b3cfe56d --- /dev/null +++ b/app/views/sensemaker/jobs/show.html.erb @@ -0,0 +1,7 @@ +<% provide :title, t("sensemaker.report_view.title") %> + +
    +
    + <%= render Sensemaker::ReportViewComponent.new(@sensemaker_job) %> +
    +
    diff --git a/config/locales/en/activerecord.yml b/config/locales/en/activerecord.yml index ec1402b59f3..fd3f7ca6339 100644 --- a/config/locales/en/activerecord.yml +++ b/config/locales/en/activerecord.yml @@ -4,6 +4,7 @@ en: results_enabled: "Show results" stats_enabled: "Show stats" advanced_stats_enabled: "Show advanced stats" + sensemaking_enabled: "Show sensemaker analyses" name: Name email: Email description: Description diff --git a/config/locales/en/admin.yml b/config/locales/en/admin.yml index 3d42b0e84b4..e0b9ea75771 100644 --- a/config/locales/en/admin.yml +++ b/config/locales/en/admin.yml @@ -1821,6 +1821,7 @@ en: output_files: "Output files" select_file_download: "Select a file to download." back_to_jobs: "Back to Jobs" + published_reports_link: "Published report(s)" analysable_all_proposals: "All Proposals" analysable_deleted: "(deleted)" status_completed_at: "Completed at %{time}" diff --git a/config/locales/en/budgets.yml b/config/locales/en/budgets.yml index 9a716bdce86..ae0616a4458 100644 --- a/config/locales/en/budgets.yml +++ b/config/locales/en/budgets.yml @@ -203,6 +203,10 @@ en: investment_proyects: List of all investment projects unfeasible_investment_proyects: List of all unfeasible investment projects not_selected_investment_proyects: List of all investment projects not selected for balloting + sensemaking: + link: Sensemaking + page_title: "%{budget} - Sensemaking" + heading: "Discover what citizens said about this budget" executions: link: "Milestones" page_title: "%{budget} - Milestones" diff --git a/config/locales/en/sensemaker.yml b/config/locales/en/sensemaker.yml index f91b83380f6..047f8513250 100644 --- a/config/locales/en/sensemaker.yml +++ b/config/locales/en/sensemaker.yml @@ -67,6 +67,12 @@ en: filter_note: "Note: This analysis focuses on the poll question \"%{question_title}\"." open_ended: "This is an open-ended question with %{answers_count} text responses." + analysis: + title: "Sensemaking" + description: "Discover what people are saying. We've analysed %{this_resource} to give clarity on everyone's input." + description_all_proposals: "Discover what people are saying. We've analysed these proposals to give clarity on everyone's input." + view_report: "View analysis" + scripts: health_check_runner_ts: title: "Perform health check" @@ -83,3 +89,93 @@ en: single_html_build_js: title: "Report" description: "Analyse and generate a report in HTML format" + + report_index: + title: "Sensemaker Reports" + title_with_parent: "Sensemaker Reports for %{parent_name}" + empty: "No analyses have been produced yet." + group_titles: + legislation/proposal: "Proposals" + legislation/question: "Debates" + legislation/question_option: "Debates segmented by option" + budget: "Budgets" + budget/group: "Budget groups" + proposal: "Proposals" + poll: "Polls" + debate: "Debates" + + job_index: + title: "Sensemaker Analyses" + title_with_parent: "Sensemaker Analysis for %{parent_name}" + title_with_resource: "Sensemaker Analysis: %{resource_name}" + hero_title_with_resource: "Discover what citizens are saying about %{resource_type}" + hero_title_all_proposals: "Discover what citizens are saying about proposals" + page_intro: "Here you can find reports and summaries to better understand everyone's input, produced using %{link}." + hero_resource_types: + proposal: "this proposal" + debate: "this debate" + legislation: "this legislation" + legislation_question: "this question" + legislation_proposal: "this proposal" + poll: "this poll" + poll_question: "this question" + budget: "this budget" + breadcrumb: + all_proposals: "All proposals" + separator: "›" + target_header: + title: "Understanding conversations" + contextual_info: + title: "About Sensemaker Analyses" + resource_intro: + proposal: "Citizens were asked for their input on the proposal \"%{resource_title}\". The comments and discussions from citizens were used as input for Sensemaker analysis." + debate: "Citizens were asked for their input on the debate \"%{resource_title}\". The comments and discussions from citizens were used as input for Sensemaker analysis." + legislation_proposal: "Citizens were asked for their input on the legislation proposal \"%{resource_title}\". The comments and discussions from citizens were used as input for Sensemaker analysis." + legislation_question: "Citizens were asked for their input on the question \"%{resource_title}\". The comments and discussions from citizens were used as input for Sensemaker analysis." + legislation_process: "Citizens were asked for their input on the legislation process \"%{resource_title}\". The comments and discussions from citizens were used as input for Sensemaker analysis." + poll: "Citizens were asked for their input on the poll \"%{resource_title}\". The comments and discussions from citizens were used as input for Sensemaker analysis." + budget: "Citizens were asked for their input on the budget \"%{resource_title}\". The comments and discussions from citizens were used as input for Sensemaker analysis." + all_proposals: "Citizens were asked for their input on proposals. The comments and discussions from citizens were used as input for Sensemaker analysis." + generic: "Citizens were asked for their input on \"%{resource_title}\". The comments and discussions from citizens were used as input for Sensemaker analysis." + how_created: "Sensemaker uses AI to analyze large-scale citizen conversations, helping identify patterns, themes, and areas of agreement and disagreement." + what_to_expect: "Reports provide detailed analysis including topic identification, categorization, and insights. Summaries offer concise overviews of key themes and findings." + more_info: "For more information, see the %{link}." + more_info_link: "Sensemaker homepage" + process: + reports_and_summaries_available: "%{reports} and %{summaries} available" + report_count: + zero: "0 reports" + one: "1 report" + other: "%{count} reports" + summary_count: + zero: "0 summaries" + one: "1 summary" + other: "%{count} summaries" + script_type: + report: "Report" + summary: "Summary" + view_job: "View Analysis" + view_report: "View Report" + view_summary: "View Summary" + completed_at: "Completed" + + report_view: + title: "Sensemaker Report" + intro_text: "Here you can find the output of an analysis produced using %{link}." + sensemaker_tools_link: "Sensemaker tools" + explanatory_text: + poll: "The comments for the poll titled \"%{poll_name}\" were analysed to produce a report." + debate: "The comments for the debate titled \"%{debate_title}\" were analysed to produce a report." + proposal: "The comments for the proposal titled \"%{proposal_title}\" were analysed to produce a report." + legislation_proposal: "The comments for the legislation proposal titled \"%{proposal_title}\" were analysed to produce a report." + legislation_question: "The comments for the debate titled \"%{question_name}\" were analysed to produce a report." + legislation_question_option: "The comments segmented by people who chose the \"%{option_value}\" option were analysed for the debate titled \"%{question_name}\"." + budget: "The proposed investments for the budget titled \"%{budget_name}\" were analysed to produce a report." + budget_group: "The proposed investments for the budget group titled \"%{group_name}\" were analysed to produce a report." + generic: "The text for \"%{resource_name}\" was analysed to produce a report. " + all_proposals: "The text for all citizen proposals were analysed to produce a report." + metadata_title: "Analysis Details" + run_timestamp_label: "Analysis run on" + comments_analysed_label: "Comments analysed" + report_description: "The report contains detailed analysis of the conversation, including topic identification, categorization, and areas of agreement and disagreement." + view_report: "View report" diff --git a/config/locales/es/activerecord.yml b/config/locales/es/activerecord.yml index 44e866faa2d..15bacdacd35 100644 --- a/config/locales/es/activerecord.yml +++ b/config/locales/es/activerecord.yml @@ -4,6 +4,7 @@ es: results_enabled: "Mostrar resultados" stats_enabled: "Mostrar estadísticas" advanced_stats_enabled: "Mostrar estadísticas avanzadas" + sensemaking_enabled: "Mostrar análisis de sensemaking" name: Nombre email: Email description: Descripción diff --git a/config/locales/es/admin.yml b/config/locales/es/admin.yml index 9158eeea2c9..cb5e4767351 100644 --- a/config/locales/es/admin.yml +++ b/config/locales/es/admin.yml @@ -1869,6 +1869,7 @@ es: output_files: "Archivos de salida" select_file_download: "Selecciona un archivo para descargar." back_to_jobs: "Volver a trabajos" + published_reports_link: "Informe(s) publicado(s)" analysable_all_proposals: "Todas las propuestas" analysable_deleted: "(eliminado)" status_completed_at: "Completado el %{time}" diff --git a/config/locales/es/budgets.yml b/config/locales/es/budgets.yml index cde26b56981..3d500d30b65 100644 --- a/config/locales/es/budgets.yml +++ b/config/locales/es/budgets.yml @@ -203,6 +203,10 @@ es: investment_proyects: Ver lista completa de proyectos de gasto unfeasible_investment_proyects: Ver lista de proyectos de gasto inviables not_selected_investment_proyects: Ver lista de proyectos de gasto no seleccionados para la votación final + sensemaking: + link: Interpretación + page_title: "%{budget} - Interpretación" + heading: "Descubre qué dijeron los ciudadanos sobre este presupuesto" executions: link: "Seguimiento" page_title: "%{budget} - Seguimiento de proyectos" diff --git a/config/locales/es/sensemaker.yml b/config/locales/es/sensemaker.yml index 5ba4d146689..4574af6815f 100644 --- a/config/locales/es/sensemaker.yml +++ b/config/locales/es/sensemaker.yml @@ -67,6 +67,12 @@ es: filter_note: "Nota: Este análisis se centra en la pregunta de la encuesta \"%{question_title}\"." open_ended: "Esta es una pregunta de respuesta abierta con %{answers_count} respuestas de texto." + analysis: + title: "Análisis Sensemaking" + description: "Descubre qué dice la gente. Hemos analizado %{this_resource} para dar claridad sobre la opinión de todos." + description_all_proposals: "Descubre qué dice la gente. Hemos analizado estas propuestas para dar claridad sobre la opinión de todos." + view_report: "Ver análisis" + scripts: health_check_runner_ts: title: "Realizar verificación de salud" @@ -83,3 +89,93 @@ es: single_html_build_js: title: "Informe" description: "Analizar y generar un informe en formato HTML" + + report_index: + title: "Informes Sensemaker" + title_with_parent: "Informes Sensemaker para %{parent_name}" + empty: "Aún no se han producido análisis." + group_titles: + legislation/proposal: "Propuestas" + legislation/question: "Debates" + legislation/question_option: "Debates segmentados por opción" + budget: "Presupuestos" + budget/group: "Grupos de presupuesto" + proposal: "Propuestas" + poll: "Encuestas" + debate: "Debates" + + job_index: + title: "Análisis Sensemaker" + title_with_parent: "Análisis Sensemaker para %{parent_name}" + title_with_resource: "Análisis Sensemaker: %{resource_name}" + hero_title_with_resource: "Descubre qué dicen los ciudadanos sobre %{resource_type}" + hero_title_all_proposals: "Descubre qué dicen los ciudadanos sobre las propuestas" + page_intro: "Aquí puedes encontrar informes y resúmenes para entender mejor la opinión de todos, producidos usando %{link}." + hero_resource_types: + proposal: "esta propuesta" + debate: "este debate" + legislation: "esta legislación" + legislation_question: "esta pregunta" + legislation_proposal: "esta propuesta" + poll: "esta encuesta" + poll_question: "esta pregunta de encuesta" + budget: "este presupuesto" + breadcrumb: + all_proposals: "Todas las propuestas" + separator: "›" + target_header: + title: "Entender las conversaciones" + contextual_info: + title: "Sobre los análisis Sensemaker" + resource_intro: + proposal: "Se pidió a los ciudadanos su opinión sobre la propuesta \"%{resource_title}\". Los comentarios y discusiones de los ciudadanos se utilizaron como entrada para el análisis Sensemaker." + debate: "Se pidió a los ciudadanos su opinión sobre el debate \"%{resource_title}\". Los comentarios y discusiones de los ciudadanos se utilizaron como entrada para el análisis Sensemaker." + legislation_proposal: "Se pidió a los ciudadanos su opinión sobre la propuesta legislativa \"%{resource_title}\". Los comentarios y discusiones de los ciudadanos se utilizaron como entrada para el análisis Sensemaker." + legislation_question: "Se pidió a los ciudadanos su opinión sobre la pregunta \"%{resource_title}\". Los comentarios y discusiones de los ciudadanos se utilizaron como entrada para el análisis Sensemaker." + legislation_process: "Se pidió a los ciudadanos su opinión sobre el proceso legislativo \"%{resource_title}\". Los comentarios y discusiones de los ciudadanos se utilizaron como entrada para el análisis Sensemaker." + poll: "Se pidió a los ciudadanos su opinión sobre la encuesta \"%{resource_title}\". Los comentarios y discusiones de los ciudadanos se utilizaron como entrada para el análisis Sensemaker." + budget: "Se pidió a los ciudadanos su opinión sobre el presupuesto \"%{resource_title}\". Los comentarios y discusiones de los ciudadanos se utilizaron como entrada para el análisis Sensemaker." + all_proposals: "Se pidió a los ciudadanos su opinión sobre las propuestas. Los comentarios y discusiones de los ciudadanos se utilizaron como entrada para el análisis Sensemaker." + generic: "Se pidió a los ciudadanos su opinión sobre \"%{resource_title}\". Los comentarios y discusiones de los ciudadanos se utilizaron como entrada para el análisis Sensemaker." + how_created: "Sensemaker utiliza IA para analizar conversaciones ciudadanas a gran escala, ayudando a identificar patrones, temas y áreas de acuerdo y desacuerdo." + what_to_expect: "Los informes ofrecen un análisis detallado que incluye identificación de temas, categorización e ideas. Los resúmenes ofrecen visiones generales concisas de los temas y hallazgos clave." + more_info: "Para más información, consulta la %{link}." + more_info_link: "página principal de Sensemaker" + process: + reports_and_summaries_available: "%{reports} y %{summaries} disponibles" + report_count: + zero: "0 informes" + one: "1 informe" + other: "%{count} informes" + summary_count: + zero: "0 resúmenes" + one: "1 resumen" + other: "%{count} resúmenes" + script_type: + report: "Informe" + summary: "Resumen" + view_job: "Ver Análisis" + view_report: "Ver Informe" + view_summary: "Ver Resumen" + completed_at: "Completado" + + report_view: + title: "Informe Sensemaker" + intro_text: "Aquí puedes encontrar la salida de un análisis producido usando %{link}." + sensemaker_tools_link: "Herramientas Sensemaker" + explanatory_text: + poll: "Los comentarios de la encuesta titulada \"%{poll_name}\" fueron analizados para producir un informe." + debate: "Los comentarios del debate titulado \"%{debate_title}\" fueron analizados para producir un informe." + proposal: "Los comentarios de la propuesta titulada \"%{proposal_title}\" fueron analizados para producir un informe." + legislation_proposal: "Los comentarios de la propuesta legislativa titulada \"%{proposal_title}\" fueron analizados para producir un informe." + legislation_question: "Los comentarios del debate titulado \"%{question_name}\" fueron analizados para producir un informe." + legislation_question_option: "Los comentarios segmentados por personas que eligieron la opción \"%{option_value}\" fueron analizados para el debate titulado \"%{question_name}\"." + budget: "Las inversiones propuestas para el presupuesto titulado \"%{budget_name}\" fueron analizadas para producir un informe." + budget_group: "Las inversiones propuestas para el grupo de presupuesto titulado \"%{group_name}\" fueron analizadas para producir un informe." + generic: "El texto de \"%{resource_name}\" fue analizado para producir un informe. " + all_proposals: "El texto de todas las propuestas ciudadanas fue analizado para producir un informe." + metadata_title: "Detalles del Análisis" + run_timestamp_label: "Análisis ejecutado el" + comments_analysed_label: "Comentarios analizados" + report_description: "El informe contiene un análisis detallado de la conversación, incluyendo identificación de temas, categorización, y áreas de acuerdo y desacuerdo." + view_report: "Ver informe" diff --git a/config/routes.rb b/config/routes.rb index 969150e6ad1..f7434cafacd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -30,6 +30,7 @@ draw :related_content draw :sdg draw :sdg_management + draw :sensemaker draw :tag draw :user draw :valuation diff --git a/config/routes/budget.rb b/config/routes/budget.rb index 5dd733e83b0..0f676322617 100644 --- a/config/routes/budget.rb +++ b/config/routes/budget.rb @@ -16,6 +16,7 @@ end resource :results, only: :show, controller: "budgets/results" + resource :sensemaking, only: :show, controller: "budgets/sensemaking" resource :stats, only: :show, controller: "budgets/stats" resource :executions, only: :show, controller: "budgets/executions" end diff --git a/config/routes/sensemaker.rb b/config/routes/sensemaker.rb new file mode 100644 index 00000000000..fb44bbdd0ac --- /dev/null +++ b/config/routes/sensemaker.rb @@ -0,0 +1,20 @@ +namespace :sensemaker do + get "proposals/jobs", to: "jobs#all_proposals_index", as: :all_proposals_jobs + get "processes/:process_id/jobs", to: "jobs#processes_index", as: :legislation_process_jobs + get "budgets/:budget_id/jobs", to: redirect("/budgets/%{budget_id}/sensemaking", status: 301), + as: :budget_jobs + + get ":resource_type/:resource_id/jobs", + to: "jobs#index", + constraints: { + resource_type: / + debates|proposals|polls|topics|poll_questions|legislation-questions| + legislation_questions|legislation_proposals|legislation_question_options + /x + }, + as: :resource_jobs + + resources :jobs, only: [:show] do + get "artefacts/report", on: :member, to: "jobs#serve_report", as: :serve_report + end +end diff --git a/db/migrate/20250205120000_add_sensemaking_to_reports.rb b/db/migrate/20250205120000_add_sensemaking_to_reports.rb new file mode 100644 index 00000000000..9432877b888 --- /dev/null +++ b/db/migrate/20250205120000_add_sensemaking_to_reports.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddSensemakingToReports < ActiveRecord::Migration[7.0] + def change + add_column :reports, :sensemaking, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index b4a03be32e0..e5a8535c9a2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1365,6 +1365,7 @@ t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.boolean "advanced_stats" + t.boolean "sensemaking", default: false t.index ["process_type", "process_id"], name: "index_reports_on_process_type_and_process_id" end diff --git a/spec/components/sensemaker/budget_job_index_component_spec.rb b/spec/components/sensemaker/budget_job_index_component_spec.rb new file mode 100644 index 00000000000..b84b9df96b6 --- /dev/null +++ b/spec/components/sensemaker/budget_job_index_component_spec.rb @@ -0,0 +1,72 @@ +require "rails_helper" + +describe Sensemaker::BudgetJobIndexComponent do + include Rails.application.routes.url_helpers + + let(:budget) { create(:budget, name: "Test Budget") } + let(:jobs) { [] } + let(:component) { Sensemaker::BudgetJobIndexComponent.new(jobs: jobs, budget: budget) } + + before do + Setting["feature.sensemaker"] = true + end + + describe "#has_jobs?" do + context "when jobs are present" do + let(:jobs) do + [create(:sensemaker_job, analysable_type: "Budget", analysable_id: budget.id)] + end + + it "returns true" do + expect(component.has_jobs?).to be true + end + end + + context "when no jobs are present" do + it "returns false" do + expect(component.has_jobs?).to be false + end + end + end + + describe "#parent_resource_title" do + it "returns the budget name" do + expect(component.parent_resource_title).to eq("Test Budget") + end + end + + describe "#page_title" do + it "returns title with parent name" do + render_inline component + expect(component.page_title).to include("Test Budget") + end + end + + describe "rendering" do + context "when jobs are present" do + let(:job) do + create(:sensemaker_job, + analysable_type: "Budget", + analysable_id: budget.id, + script: "single-html-build.js", + finished_at: Time.current) + end + let(:jobs) { [job] } + + it "renders the jobs as cards in a grouped section" do + render_inline component + + expect(page).to have_link(I18n.t("sensemaker.job_index.view_report"), + href: serve_report_sensemaker_job_path(job)) + end + end + + context "when no jobs are present" do + it "renders empty message" do + render_inline component + + expect(page).to have_content(I18n.t("sensemaker.report_index.empty")) + end + end + end +end diff --git a/spec/components/sensemaker/job_card_component_spec.rb b/spec/components/sensemaker/job_card_component_spec.rb new file mode 100644 index 00000000000..2d5ae7dd569 --- /dev/null +++ b/spec/components/sensemaker/job_card_component_spec.rb @@ -0,0 +1,55 @@ +require "rails_helper" + +describe Sensemaker::JobCardComponent do + include Rails.application.routes.url_helpers + + let(:job) do + create(:sensemaker_job, + analysable_type: "Debate", + analysable_id: create(:debate).id, + script: "single-html-build.js", + finished_at: Time.current, + comments_analysed: 5) + end + let(:component) { Sensemaker::JobCardComponent.new(job) } + + before do + Setting["feature.sensemaker"] = true + end + + describe "rendering" do + it "renders a card with report link" do + render_inline component + + expect(page).to have_link(I18n.t("sensemaker.job_index.view_report"), + href: serve_report_sensemaker_job_path(job), + class: "report-link") + end + + context "when comments_analysed is present" do + it "shows comments analysed count" do + render_inline component + + expect(page).to have_content(I18n.t("sensemaker.report_view.comments_analysed_label")) + expect(page).to have_content("5") + end + end + + context "when job is a summary (runner.ts)" do + let(:job) do + create(:sensemaker_job, + analysable_type: "Debate", + analysable_id: create(:debate).id, + script: "runner.ts", + finished_at: Time.current) + end + + it "renders View Summary link" do + render_inline component + + expect(page).to have_link(I18n.t("sensemaker.job_index.view_summary"), + href: serve_report_sensemaker_job_path(job)) + end + end + end +end diff --git a/spec/components/sensemaker/job_index_component_spec.rb b/spec/components/sensemaker/job_index_component_spec.rb new file mode 100644 index 00000000000..3945bfafa2a --- /dev/null +++ b/spec/components/sensemaker/job_index_component_spec.rb @@ -0,0 +1,181 @@ +require "rails_helper" + +describe Sensemaker::JobIndexComponent do + include Rails.application.routes.url_helpers + + let(:debate) { create(:debate, title: "Test Debate") } + let(:jobs) { [] } + let(:component) { Sensemaker::JobIndexComponent.new(jobs: jobs, parent_resource: parent_resource, resource: resource) } + let(:parent_resource) { nil } + let(:resource) { debate } + + before do + Setting["feature.sensemaker"] = true + end + + describe "#has_jobs?" do + context "when jobs are present" do + let(:jobs) { [create(:sensemaker_job, analysable_type: "Debate", analysable_id: debate.id)] } + + it "returns true" do + expect(component.has_jobs?).to be true + end + end + + context "when no jobs are present" do + it "returns false" do + expect(component.has_jobs?).to be false + end + end + end + + describe "#page_title" do + context "when resource is present (takes precedence over parent)" do + let(:poll) { create(:poll, name: "Test Poll") } + let(:parent_resource) { poll } + let(:question) { create(:poll_question, poll: poll, title: "Test Question") } + let(:resource) { question } + + it "returns title with resource name" do + render_inline component + expect(component.page_title).to include("Test Question") + end + end + + context "when parent_resource is present but no resource" do + let(:poll) { create(:poll, name: "Test Poll") } + let(:parent_resource) { poll } + let(:resource) { nil } + + it "returns title with parent name" do + render_inline component + expect(component.page_title).to include("Test Poll") + end + end + + context "when resource is present but no parent" do + let(:resource) { debate } + + it "returns title with resource name" do + render_inline component + expect(component.page_title).to include("Test Debate") + end + end + + context "when neither parent_resource nor resource is present" do + let(:resource) { nil } + + it "returns default title" do + render_inline component + expect(component.page_title).to eq(I18n.t("sensemaker.job_index.title")) + end + end + end + + describe "#script_type_tag" do + context "when script is single-html-build.js" do + let(:job) { create(:sensemaker_job, script: "single-html-build.js") } + + it "returns Report" do + render_inline component + expect(component.script_type_tag(job)).to eq(I18n.t("sensemaker.job_index.script_type.report")) + end + end + + context "when script is runner.ts" do + let(:job) { create(:sensemaker_job, script: "runner.ts") } + + it "returns Summary" do + render_inline component + expect(component.script_type_tag(job)).to eq(I18n.t("sensemaker.job_index.script_type.summary")) + end + end + + context "when script is something else" do + let(:job) { create(:sensemaker_job, script: "other-script.js") } + + it "returns the script name" do + render_inline component + expect(component.script_type_tag(job)).to eq("other-script.js") + end + end + end + + describe "#resource_title" do + context "when resource has title" do + let(:resource) { debate } + + it "returns the title" do + expect(component.resource_title).to eq("Test Debate") + end + end + + context "when resource has name" do + let(:budget) { create(:budget, name: "Test Budget") } + let(:resource) { budget } + + it "returns the name" do + expect(component.resource_title).to eq("Test Budget") + end + end + + context "when resource is nil" do + let(:resource) { nil } + + it "returns nil" do + expect(component.resource_title).to be(nil) + end + end + end + + describe "rendering" do + context "when jobs are present" do + let(:job) do + create(:sensemaker_job, + analysable_type: "Debate", + analysable_id: debate.id, + script: "single-html-build.js", + finished_at: Time.current, + comments_analysed: 10) + end + let(:jobs) { [job] } + + it "renders the jobs as cards" do + render_inline component + + expected_title = I18n.t("sensemaker.job_index.hero_title_with_resource", + resource_type: I18n.t("sensemaker.job_index.hero_resource_types.debate")) + expect(page).to have_content(expected_title) + expect(page).to have_link(I18n.t("sensemaker.job_index.view_report"), + href: serve_report_sensemaker_job_path(job)) + end + end + + context "when job is a summary (runner.ts)" do + let(:job) do + create(:sensemaker_job, + analysable_type: "Debate", + analysable_id: debate.id, + script: "runner.ts", + finished_at: Time.current, + comments_analysed: 10) + end + let(:jobs) { [job] } + + it "renders the jobs as cards with View Summary link" do + render_inline component + + expect(page).to have_link(I18n.t("sensemaker.job_index.view_summary"), + href: serve_report_sensemaker_job_path(job)) + end + end + + context "when no jobs are present" do + it "renders empty message" do + render_inline component + + expect(page).to have_content(I18n.t("sensemaker.report_index.empty")) + end + end + end +end diff --git a/spec/components/sensemaker/process_job_index_component_spec.rb b/spec/components/sensemaker/process_job_index_component_spec.rb new file mode 100644 index 00000000000..4522a3e3163 --- /dev/null +++ b/spec/components/sensemaker/process_job_index_component_spec.rb @@ -0,0 +1,69 @@ +require "rails_helper" + +describe Sensemaker::ProcessJobIndexComponent do + include Rails.application.routes.url_helpers + + let(:process) { create(:legislation_process, title: "Test Process") } + let(:jobs) { [] } + let(:component) { Sensemaker::ProcessJobIndexComponent.new(jobs: jobs, process: process) } + + before do + Setting["feature.sensemaker"] = true + end + + describe "#has_jobs?" do + context "when jobs are present" do + let(:question) { create(:legislation_question, process: process, title: "Test Question") } + let(:jobs) do + [create(:sensemaker_job, analysable_type: "Legislation::Question", analysable_id: question.id)] + end + + it "returns true" do + expect(component.has_jobs?).to be true + end + end + + context "when no jobs are present" do + it "returns false" do + expect(component.has_jobs?).to be false + end + end + end + + describe "#parent_resource_title" do + it "returns the process title" do + expect(component.parent_resource_title).to eq("Test Process") + end + end + + describe "#page_title" do + it "returns title with parent name" do + render_inline component + expect(component.page_title).to include("Test Process") + end + end + + describe "rendering" do + context "when jobs are present" do + let(:question) { create(:legislation_question, process: process, title: "Test Question") } + let(:jobs) do + [create(:sensemaker_job, analysable_type: "Legislation::Question", analysable_id: question.id)] + end + + it "renders grouped resource links with report/summary counts" do + render_inline component + + expect(page).to have_css("h1", text: /Discover what citizens are saying/) + expect(page).to have_content(I18n.t("sensemaker.report_index.group_titles.legislation/question")) + end + end + + context "when no jobs are present" do + it "renders empty message" do + render_inline component + + expect(page).to have_content(I18n.t("sensemaker.report_index.empty")) + end + end + end +end diff --git a/spec/components/sensemaker/report_component_helpers_spec.rb b/spec/components/sensemaker/report_component_helpers_spec.rb new file mode 100644 index 00000000000..dcdf8fd6278 --- /dev/null +++ b/spec/components/sensemaker/report_component_helpers_spec.rb @@ -0,0 +1,236 @@ +require "rails_helper" + +describe Sensemaker::ReportComponentHelpers do + include Rails.application.routes.url_helpers + + let(:debate) { create(:debate, title: "Test Debate") } + let(:jobs) { [] } + let(:host) { Sensemaker::JobIndexComponent.new(jobs: jobs, parent_resource: nil, resource: nil) } + + before do + Setting["feature.sensemaker"] = true + end + + describe "#display_title_for" do + it "returns nil when record is nil" do + render_inline host + expect(host.display_title_for(nil)).to be(nil) + end + + context "when record has title" do + it "returns the title" do + render_inline host + expect(host.display_title_for(debate)).to eq("Test Debate") + end + end + + context "when record has name" do + let(:budget) { create(:budget, name: "Test Budget") } + + it "returns the name" do + render_inline host + expect(host.display_title_for(budget)).to eq("Test Budget") + end + end + + context "when record has neither title nor name" do + let(:user) { create(:user) } + + it "returns humanized class name" do + render_inline host + result = host.display_title_for(user) + expect(result).to be_a(String) + expect(result).to be_present + end + end + end + + describe "#has_jobs?" do + context "when jobs are present" do + let(:jobs) { [create(:sensemaker_job, analysable_type: "Debate", analysable_id: debate.id)] } + + it "returns true" do + render_inline host + expect(host.has_jobs?).to be true + end + end + + context "when no jobs are present" do + it "returns false" do + render_inline host + expect(host.has_jobs?).to be false + end + end + end + + describe "#script_type_tag" do + before { render_inline host } + + context "when script is single-html-build.js" do + let(:job) { create(:sensemaker_job, script: "single-html-build.js") } + + it "returns Report translation" do + expect(host.script_type_tag(job)).to eq(I18n.t("sensemaker.job_index.script_type.report")) + end + end + + context "when script is runner.ts" do + let(:job) { create(:sensemaker_job, script: "runner.ts") } + + it "returns Summary translation" do + expect(host.script_type_tag(job)).to eq(I18n.t("sensemaker.job_index.script_type.summary")) + end + end + + context "when script is something else" do + let(:job) { create(:sensemaker_job, script: "other-script.js") } + + it "returns the script name" do + expect(host.script_type_tag(job)).to eq("other-script.js") + end + end + end + + describe "#view_job_text" do + before { render_inline host } + + context "when script is single-html-build.js" do + let(:job) { create(:sensemaker_job, script: "single-html-build.js") } + + it "returns view_report translation" do + expect(host.view_job_text(job)).to eq(I18n.t("sensemaker.job_index.view_report")) + end + end + + context "when script is runner.ts" do + let(:job) { create(:sensemaker_job, script: "runner.ts") } + + it "returns view_summary translation" do + expect(host.view_job_text(job)).to eq(I18n.t("sensemaker.job_index.view_summary")) + end + end + end + + describe "#analysis_type_badge_class" do + before { render_inline host } + + it "returns badge-report for single-html-build.js" do + job = create(:sensemaker_job, script: "single-html-build.js") + expect(host.analysis_type_badge_class(job)).to eq("badge-report") + end + + it "returns badge-summary for runner.ts" do + job = create(:sensemaker_job, script: "runner.ts") + expect(host.analysis_type_badge_class(job)).to eq("badge-summary") + end + + it "returns badge-default for other scripts" do + job = create(:sensemaker_job, script: "other-script.js") + expect(host.analysis_type_badge_class(job)).to eq("badge-default") + end + end + + describe "#empty_message" do + it "returns report_index.empty translation" do + render_inline host + expect(host.empty_message).to eq(I18n.t("sensemaker.report_index.empty")) + end + end + + describe "#contextual_info_title" do + it "returns contextual_info.title translation" do + render_inline host + expect(host.contextual_info_title).to eq(I18n.t("sensemaker.job_index.contextual_info.title")) + end + end + + describe "#breadcrumb_separator" do + it "returns breadcrumb separator translation" do + render_inline host + expect(host.breadcrumb_separator).to eq(I18n.t("sensemaker.job_index.breadcrumb.separator")) + end + end + + describe "#path_for_sensemaker_resource" do + before { render_inline host } + + it "returns nil when resource is nil" do + expect(host.path_for_sensemaker_resource(nil)).to be(nil) + end + + it "returns debate path for a Debate" do + expect(host.path_for_sensemaker_resource(debate)).to eq(debate_path(debate)) + end + + it "returns budget path for a Budget" do + budget = create(:budget, name: "Test Budget") + expect(host.path_for_sensemaker_resource(budget)).to eq(budget_path(budget)) + end + end + + describe "#parent_resource_path_for" do + before { render_inline host } + + it "returns nil when parent_resource is nil" do + expect(host.parent_resource_path_for(nil)).to be(nil) + end + + it "returns budget path for a Budget" do + budget = create(:budget, name: "Test Budget") + expect(host.parent_resource_path_for(budget)).to eq(budget_path(budget)) + end + + it "returns legislation process path for a Legislation::Process" do + process = create(:legislation_process, title: "Test Process") + expect(host.parent_resource_path_for(process)).to eq(legislation_process_path(process)) + end + end + + describe "#contextual_info_type_key_for" do + before { render_inline host } + + it "returns nil when record is nil" do + expect(host.contextual_info_type_key_for(nil)).to be(nil) + end + + it "returns 'debate' for a Debate" do + expect(host.contextual_info_type_key_for(debate)).to eq("debate") + end + + it "returns 'budget' for a Budget" do + budget = create(:budget, name: "Test Budget") + expect(host.contextual_info_type_key_for(budget)).to eq("budget") + end + + it "returns 'legislation_process' for a Legislation::Process" do + process = create(:legislation_process, title: "Test Process") + expect(host.contextual_info_type_key_for(process)).to eq("legislation_process") + end + end + + describe "#this_resource_phrase_for" do + before { render_inline host } + + it "returns nil when record is nil" do + expect(host.this_resource_phrase_for(nil)).to be(nil) + end + + it "returns translated phrase for a Debate" do + expect(host.this_resource_phrase_for(debate)).to eq( + I18n.t("sensemaker.job_index.hero_resource_types.debate") + ) + end + + it "returns translated phrase for a Budget" do + budget = create(:budget, name: "Test Budget") + expect(host.this_resource_phrase_for(budget)).to eq( + I18n.t("sensemaker.job_index.hero_resource_types.budget") + ) + end + + it "returns generic fallback for unknown record type" do + user = create(:user) + expect(host.this_resource_phrase_for(user)).to eq("this user") + end + end +end diff --git a/spec/components/sensemaker/report_job_meta_component_spec.rb b/spec/components/sensemaker/report_job_meta_component_spec.rb new file mode 100644 index 00000000000..6eca5450a5b --- /dev/null +++ b/spec/components/sensemaker/report_job_meta_component_spec.rb @@ -0,0 +1,105 @@ +require "rails_helper" + +describe Sensemaker::ReportJobMetaComponent do + include Rails.application.routes.url_helpers + + let(:debate) { create(:debate, title: "Test Debate") } + let(:job) do + create(:sensemaker_job, + analysable_type: "Debate", + analysable_id: debate.id, + finished_at: Time.current, + comments_analysed: 42) + end + let(:component) { Sensemaker::ReportJobMetaComponent.new(job) } + + before do + Setting["feature.sensemaker"] = true + end + + describe "#run_timestamp" do + context "when job has finished_at" do + it "returns formatted timestamp" do + expect(component.run_timestamp).to be_present + expect(component.run_timestamp).to include(Time.current.year.to_s) + end + end + + context "when job has no finished_at" do + before do + job.update(finished_at: nil) + end + + it "returns nil" do + expect(component.run_timestamp).to be(nil) + end + end + end + + describe "#comments_analysed_count" do + it "returns the comments_analysed count" do + expect(component.comments_analysed_count).to eq(42) + end + + context "when comments_analysed is nil" do + before do + job.update(comments_analysed: nil) + end + + it "returns 0" do + expect(component.comments_analysed_count).to eq(0) + end + end + end + + describe "#view_report_text" do + it "returns the translation text when rendered" do + render_inline component + expect(component.view_report_text).to eq(I18n.t("sensemaker.report_view.view_report")) + end + end + + describe "#report_url" do + it "returns the serve report path when rendered" do + render_inline component + expect(component.report_url).to eq(serve_report_sensemaker_job_path(job.id)) + end + end + + describe "rendering" do + it "renders the view report link" do + render_inline component + + expect(page).to have_link(I18n.t("sensemaker.report_view.view_report"), + href: serve_report_sensemaker_job_path(job.id)) + end + + it "renders comments analysed count" do + render_inline component + + expect(page).to have_content("42") + expect(page).to have_content(I18n.t("sensemaker.report_view.comments_analysed_label")) + end + + context "when job has finished_at" do + it "renders run timestamp" do + render_inline component + + expect(page).to have_content(I18n.t("sensemaker.report_view.run_timestamp_label")) + expect(component.run_timestamp).to be_present + end + end + + context "when job has no finished_at" do + before do + job.update(finished_at: nil) + end + + it "does not render run timestamp" do + render_inline component + + expect(page).not_to have_content(I18n.t("sensemaker.report_view.run_timestamp_label")) + end + end + end +end diff --git a/spec/components/sensemaker/report_link_component_spec.rb b/spec/components/sensemaker/report_link_component_spec.rb new file mode 100644 index 00000000000..908797d68b5 --- /dev/null +++ b/spec/components/sensemaker/report_link_component_spec.rb @@ -0,0 +1,175 @@ +require "rails_helper" + +describe Sensemaker::ReportLinkComponent do + include Rails.application.routes.url_helpers + + let(:debate) { create(:debate) } + let(:component) { Sensemaker::ReportLinkComponent.new(debate) } + + before do + Setting["feature.sensemaker"] = true + end + + describe "#render?" do + context "when sensemaker feature is enabled and job exists for a resource" do + let(:persisted_output) { Rails.root.join("tmp", "test-report.html").to_s } + + before do + FileUtils.mkdir_p(File.dirname(persisted_output)) + File.write(persisted_output, "Test Report") + job = create(:sensemaker_job, + analysable_type: "Debate", + analysable_id: debate.id, + script: "single-html-build.js", + finished_at: Time.current, + error: nil, + persisted_output: persisted_output, + published: false) + job.update!(published: true) + end + + after do + FileUtils.rm_f(persisted_output) + end + + it "returns true" do + expect(component.render?).to be true + end + end + + context "when sensemaker feature is enabled and but job is unpublished" do + before do + create(:sensemaker_job, :unpublished, analysable_type: "Debate", analysable_id: debate.id) + end + + it "returns false" do + expect(component.render?).to be false + end + end + + context "when sensemaker feature is disabled" do + before do + Setting["feature.sensemaker"] = nil + create(:sensemaker_job, analysable_type: "Debate", analysable_id: debate.id) + end + + it "returns false" do + expect(component.render?).to be_falsy + end + end + + context "when no job exists" do + it "returns false" do + expect(component.render?).to be false + end + end + + context "when analysable_resource is Proposal and published job(s) exists" do + let(:all_proposals_component) { Sensemaker::ReportLinkComponent.new(Proposal) } + let(:persisted_output) { Rails.root.join("tmp", "test-report-all-proposals.html").to_s } + + before do + FileUtils.mkdir_p(File.dirname(persisted_output)) + File.write(persisted_output, "All Proposals Report") + create(:sensemaker_job, + analysable_type: "Proposal", + analysable_id: nil, + script: "single-html-build.js", + finished_at: Time.current, + error: nil, + persisted_output: persisted_output, + published: false).update!(published: true) + end + + after do + FileUtils.rm_f(persisted_output) + end + + it "returns true" do + expect(all_proposals_component.render?).to be true + end + end + + context "when analysable_resource is Proposal and no published job(s) exist" do + let(:all_proposals_component) { Sensemaker::ReportLinkComponent.new(Proposal) } + + it "returns false" do + expect(all_proposals_component.render?).to be false + end + end + end + + describe "rendering" do + context "when report is available" do + let(:persisted_output) { Rails.root.join("tmp", "test-report.html").to_s } + + before do + FileUtils.mkdir_p(File.dirname(persisted_output)) + File.write(persisted_output, "Test Report") + job = create(:sensemaker_job, + analysable_type: "Debate", + analysable_id: debate.id, + script: "single-html-build.js", + finished_at: Time.current, + error: nil, + persisted_output: persisted_output, + published: false) + job.update!(published: true) + end + + after do + FileUtils.rm_f(persisted_output) + end + + it "renders the view report link" do + render_inline component + + expect(page).to have_link("View analysis", + href: sensemaker_resource_jobs_path(resource_type: "debates", + resource_id: debate.id)) + end + end + + context "when no report is available" do + it "does not render the view report link" do + render_inline component + + expect(page).not_to have_link("View analysis") + end + end + + context "when report is available for All proposals (Proposal class)" do + let(:all_proposals_component) { Sensemaker::ReportLinkComponent.new(Proposal) } + let(:persisted_output) { Rails.root.join("tmp", "test-report-all-proposals-render.html").to_s } + + before do + FileUtils.mkdir_p(File.dirname(persisted_output)) + File.write(persisted_output, "All Proposals Report") + create(:sensemaker_job, + analysable_type: "Proposal", + analysable_id: nil, + script: "single-html-build.js", + finished_at: Time.current, + error: nil, + persisted_output: persisted_output, + published: false).update!(published: true) + end + + after do + FileUtils.rm_f(persisted_output) + end + + it "renders the view report link pointing to all proposals jobs index" do + render_inline all_proposals_component + + expect(page).to have_link("View analysis", href: sensemaker_all_proposals_jobs_path) + end + + it "uses the all-proposals analysis description" do + render_inline all_proposals_component + + expect(page).to have_content("We've analysed these proposals") + end + end + end +end diff --git a/spec/components/sensemaker/report_view_component_spec.rb b/spec/components/sensemaker/report_view_component_spec.rb new file mode 100644 index 00000000000..81c74f02461 --- /dev/null +++ b/spec/components/sensemaker/report_view_component_spec.rb @@ -0,0 +1,216 @@ +require "rails_helper" + +describe Sensemaker::ReportViewComponent do + include Rails.application.routes.url_helpers + + let(:user) { create(:user) } + let(:component) { Sensemaker::ReportViewComponent.new(sensemaker_job) } + + before do + Setting["feature.sensemaker"] = true + end + + def create_publishable_job_with_output(attributes = {}) + job = create(:sensemaker_job, :publishable, attributes) + output_path = job.default_output_path + FileUtils.mkdir_p(File.dirname(output_path)) + File.write(output_path, "Test Report") + job.update!(published: true) + job + end + + after do + if defined?(sensemaker_job) && sensemaker_job&.default_output_path && + File.exist?(sensemaker_job.default_output_path) + FileUtils.rm_f(sensemaker_job.default_output_path) + end + end + + describe "rendering explanatory text" do + context "when analysable is a Poll" do + let(:poll) { create(:poll, name: "Test Poll") } + let(:sensemaker_job) do + create_publishable_job_with_output( + analysable_type: "Poll", + analysable_id: poll.id + ) + end + + it "renders explanatory text with linked poll name" do + render_inline component + + expect(page).to have_content("Test Poll") + expect(page).to have_link("Test Poll", href: poll_path(id: poll.slug || poll.id)) + expect(page).to have_content("The comments for the poll") + end + end + + context "when analysable is a Debate" do + let(:debate) { create(:debate, title: "Test Debate") } + let(:sensemaker_job) do + create_publishable_job_with_output( + analysable_type: "Debate", + analysable_id: debate.id + ) + end + + it "renders explanatory text with linked debate title" do + render_inline component + + expect(page).to have_content("Test Debate") + expect(page).to have_link("Test Debate", href: polymorphic_path(debate)) + expect(page).to have_content("The comments for the debate") + end + end + + context "when analysable is a Proposal" do + let(:proposal) { create(:proposal, title: "Test Proposal") } + let(:sensemaker_job) do + create_publishable_job_with_output( + analysable_type: "Proposal", + analysable_id: proposal.id + ) + end + + it "renders explanatory text with linked proposal title" do + render_inline component + + expect(page).to have_content("Test Proposal") + expect(page).to have_link("Test Proposal", href: polymorphic_path(proposal)) + expect(page).to have_content("The comments for the proposal") + end + end + + context "when analysable is a Legislation::Question" do + let(:process) { create(:legislation_process, title: "Test Process") } + let(:question) { create(:legislation_question, process: process, title: "Test Question") } + let(:sensemaker_job) do + create_publishable_job_with_output( + analysable_type: "Legislation::Question", + analysable_id: question.id + ) + end + + it "renders explanatory text with linked question title" do + render_inline component + + expect(page).to have_content("Test Question") + expect(page).to have_link("Test Question", href: legislation_process_question_path(process, question)) + expect(page).to have_content("The comments for the debate") + end + end + + context "when analysable is a Legislation::QuestionOption" do + let(:process) { create(:legislation_process, title: "Test Process") } + let(:question) { create(:legislation_question, process: process, title: "Test Question") } + let(:question_option) { create(:legislation_question_option, question: question, value: "Option A") } + let(:sensemaker_job) do + create_publishable_job_with_output( + analysable_type: "Legislation::QuestionOption", + analysable_id: question_option.id + ) + end + + it "renders explanatory text with linked question title and option value" do + render_inline component + + expect(question).to be_present + expect(question_option).to be_present + expect(page).to have_content("Test Question") + expect(page).to have_content("Option A") + expect(page).to have_link("Test Question", href: legislation_process_question_path(process, question)) + end + end + + context "when analysable is a Budget" do + let(:budget) { create(:budget, name: "Test Budget") } + let(:sensemaker_job) do + create_publishable_job_with_output( + analysable_type: "Budget", + analysable_id: budget.id + ) + end + + it "renders explanatory text with linked budget name" do + render_inline component + + expect(page).to have_content("Test Budget") + expect(page).to have_link("Test Budget", href: budget_path(budget)) + expect(page).to have_content("The proposed investments for the budget") + end + end + + context "when analysable is a Budget::Group" do + let(:budget) { create(:budget, name: "Test Budget") } + let(:budget_group) { create(:budget_group, budget: budget, name: "Test Group") } + let(:sensemaker_job) do + create_publishable_job_with_output( + analysable_type: "Budget::Group", + analysable_id: budget_group.id + ) + end + + it "renders explanatory text with linked budget name (not group)" do + render_inline component + + expect(page).to have_content("Test Group") + expect(page).to have_link("Test Group", href: budget_path(budget)) + expect(page).to have_content("The proposed investments for the budget group") + end + end + + context "when analysable is a Legislation::Proposal" do + let(:process) { create(:legislation_process, title: "Test Process") } + let(:proposal) { create(:legislation_proposal, process: process, title: "Test Proposal") } + let(:sensemaker_job) do + create_publishable_job_with_output( + analysable_type: "Legislation::Proposal", + analysable_id: proposal.id + ) + end + + it "renders explanatory text with linked proposal title" do + render_inline component + + expect(page).to have_content("Test Proposal") + expect(page).to have_link("Test Proposal", href: legislation_process_proposal_path(process, proposal)) + expect(page).to have_content("The comments for the legislation proposal") + end + end + + context "when analysable is All Proposals (nil analysable_id)" do + let(:sensemaker_job) do + create_publishable_job_with_output( + analysable_type: "Proposal", + analysable_id: nil + ) + end + + it "renders explanatory text without link" do + render_inline component + + expect(page).to have_content("all citizen proposals") + expect(page).not_to have_link("all citizen proposals") + end + end + + context "when analysable is missing" do + let(:sensemaker_job) do + create_publishable_job_with_output( + analysable_type: "Debate", + analysable_id: 99999 + ) + end + + before do + allow(sensemaker_job).to receive(:analysable).and_return(nil) + end + + it "renders generic explanatory text" do + render_inline component + + expect(page).to have_content("unknown") + end + end + end +end diff --git a/spec/controllers/sensemaker/jobs_controller_spec.rb b/spec/controllers/sensemaker/jobs_controller_spec.rb new file mode 100644 index 00000000000..102cd3cd507 --- /dev/null +++ b/spec/controllers/sensemaker/jobs_controller_spec.rb @@ -0,0 +1,360 @@ +require "rails_helper" + +describe Sensemaker::JobsController do + include Rails.application.routes.url_helpers + + let(:user) { create(:user) } + + def create_publishable_job_with_output(attributes = {}) + job = create(:sensemaker_job, :publishable, attributes) + output_path = job.default_output_path + FileUtils.mkdir_p(File.dirname(output_path)) + File.write(output_path, "Test Report") + job.update!(published: true) + job + end + + after do + Sensemaker::Job.find_each do |job| + if job.default_output_path && File.exist?(job.default_output_path) + FileUtils.rm_f(job.default_output_path) + end + end + end + + describe "GET #show" do + let(:debate) { create(:debate) } + let(:job) { create_publishable_job_with_output(analysable_type: "Debate", analysable_id: debate.id) } + + context "when job is unpublished" do + before do + job.update!(published: false) + end + + it "returns 302 and redirects to root path" do + get :show, params: { id: job.id } + expect(response).to have_http_status(:redirect) + expect(response).to redirect_to(root_path) + end + end + + context "when job exists and has output" do + it "renders the report view page" do + get :show, params: { id: job.id } + + expect(response).to have_http_status(:ok) + end + end + + context "when job exists but has no output" do + before do + FileUtils.rm_f(job.default_output_path) + end + + it "returns 404" do + get :show, params: { id: job.id } + + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET #index" do + context "when resource_type and resource_id are provided" do + context "for a Debate" do + let(:debate) { create(:debate) } + let!(:job1) do + create_publishable_job_with_output(analysable_type: "Debate", analysable_id: debate.id, + finished_at: 2.days.ago) + end + let!(:job2) do + create_publishable_job_with_output(analysable_type: "Debate", analysable_id: debate.id, + finished_at: 1.day.ago) + end + let!(:other_job) do + create_publishable_job_with_output(analysable_type: "Debate", analysable_id: create(:debate).id) + end + + it "returns published jobs for the resource ordered by finished_at desc" do + get :index, params: { resource_type: "debates", resource_id: debate.id } + + expect(response).to have_http_status(:ok) + jobs = controller.instance_variable_get(:@sensemaker_jobs) + expect(jobs.to_a).to eq([job2, job1]) + expect(jobs).not_to include(other_job) + expect(controller.instance_variable_get(:@parent_resource)).to be(nil) + end + end + + context "for a Proposal" do + let(:proposal) { create(:proposal) } + let!(:job) do + create_publishable_job_with_output(analysable_type: "Proposal", analysable_id: proposal.id) + end + + it "returns published jobs for the proposal" do + get :index, params: { resource_type: "proposals", resource_id: proposal.id } + + expect(response).to have_http_status(:ok) + jobs = controller.instance_variable_get(:@sensemaker_jobs) + expect(jobs).to include(job) + expect(controller.instance_variable_get(:@parent_resource)).to be(nil) + end + end + + context "for a Poll" do + let(:poll) { create(:poll) } + let!(:job) { create_publishable_job_with_output(analysable_type: "Poll", analysable_id: poll.id) } + + it "returns published jobs for the poll" do + get :index, params: { resource_type: "polls", resource_id: poll.id } + + expect(response).to have_http_status(:ok) + jobs = controller.instance_variable_get(:@sensemaker_jobs) + expect(jobs).to include(job) + expect(controller.instance_variable_get(:@parent_resource)).to be(nil) + end + end + + context "for a Poll::Question" do + let(:poll) { create(:poll) } + let(:question) { create(:poll_question, poll: poll) } + let!(:job) { create_publishable_job_with_output(analysable_type: "Poll::Question", analysable_id: question.id) } + + it "returns published jobs and loads parent poll" do + get :index, params: { resource_type: "poll_questions", resource_id: question.id } + + expect(response).to have_http_status(:ok) + jobs = controller.instance_variable_get(:@sensemaker_jobs) + expect(jobs).to include(job) + expect(controller.instance_variable_get(:@parent_resource)).to eq(poll) + end + end + + context "for a Legislation::Question" do + let(:other_process) { create(:legislation_process) } + let(:other_question) { create(:legislation_question, process: other_process) } + let!(:other_job) { create_publishable_job_with_output(analysable_type: "Legislation::Question", analysable_id: other_question.id) } + + it "returns published jobs and loads parent poll" do + get :index, params: { resource_type: "legislation_questions", resource_id: other_question.id } + + expect(response).to have_http_status(:ok) + jobs = controller.instance_variable_get(:@sensemaker_jobs) + expect(jobs).to include(other_job) + expect(controller.instance_variable_get(:@parent_resource)).to eq(other_process) + end + end + + context "for a Legislation::Proposal" do + let(:other_process) { create(:legislation_process) } + let(:other_proposal) { create(:legislation_proposal, process: other_process) } + let!(:other_job) { create_publishable_job_with_output(analysable_type: "Legislation::Proposal", analysable_id: other_proposal.id) } + + it "returns published jobs and loads parent process" do + get :index, params: { resource_type: "legislation_proposals", resource_id: other_proposal.id } + + expect(response).to have_http_status(:ok) + jobs = controller.instance_variable_get(:@sensemaker_jobs) + expect(jobs).to include(other_job) + expect(controller.instance_variable_get(:@parent_resource)).to eq(other_process) + end + end + + context "for a Legislation::QuestionOption" do + let(:l_process) { create(:legislation_process) } + let(:l_question) { create(:legislation_question, process: l_process) } + let(:l_option) { create(:legislation_question_option, question: l_question) } + let!(:l_job) { create_publishable_job_with_output(analysable_type: "Legislation::QuestionOption", analysable_id: l_option.id) } + + it "returns published jobs and loads parent process" do + get :index, params: { resource_type: "legislation_question_options", resource_id: l_option.id } + + expect(response).to have_http_status(:ok) + jobs = controller.instance_variable_get(:@sensemaker_jobs) + expect(jobs).to include(l_job) + expect(controller.instance_variable_get(:@parent_resource)).to eq(l_process) + end + end + + context "when resource is not found" do + it "returns 404" do + get :index, params: { resource_type: "debates", resource_id: 99999 } + + expect(response).to have_http_status(:not_found) + end + end + end + + context "when only unpublished jobs exist" do + let(:debate) { create(:debate) } + let!(:unpublished_job) do + job = create(:sensemaker_job, :publishable, analysable_type: "Debate", analysable_id: debate.id, + published: false) + output_path = job.default_output_path + FileUtils.mkdir_p(File.dirname(output_path)) + File.write(output_path, "Test Report") + job + end + + it "returns empty collection" do + get :index, params: { resource_type: "debates", resource_id: debate.id } + + expect(response).to have_http_status(:ok) + jobs = controller.instance_variable_get(:@sensemaker_jobs) + expect(jobs).to be_empty + expect(jobs).not_to include(unpublished_job) + end + end + end + + describe "GET #all_proposals_index" do + let!(:all_proposals_job) do + create_publishable_job_with_output(analysable_type: "Proposal", analysable_id: nil) + end + let!(:specific_proposal_job) do + create_publishable_job_with_output(analysable_type: "Proposal", analysable_id: create(:proposal).id) + end + + it "returns published jobs for all proposals (nil analysable_id)" do + get :all_proposals_index + + expect(response).to have_http_status(:ok) + jobs = controller.instance_variable_get(:@sensemaker_jobs) + expect(jobs).to include(all_proposals_job) + expect(jobs).not_to include(specific_proposal_job) + expect(controller.instance_variable_get(:@parent_resource)).to be(nil) + end + end + + describe "GET #processes_index" do + let(:legislation_process) { create(:legislation_process) } + let(:question) { create(:legislation_question, process: legislation_process) } + let(:first_proposal) { create(:legislation_proposal, process: legislation_process) } + let(:option) { create(:legislation_question_option, question: question) } + let!(:question_job) { create_publishable_job_with_output(analysable_type: "Legislation::Question", analysable_id: question.id) } + let!(:proposal_job) { create_publishable_job_with_output(analysable_type: "Legislation::Proposal", analysable_id: first_proposal.id) } + let!(:option_job) { create_publishable_job_with_output(analysable_type: "Legislation::QuestionOption", analysable_id: option.id) } + let!(:other_process_job) do + other_process = create(:legislation_process) + create_publishable_job_with_output(analysable_type: "Legislation::Question", + analysable_id: create( +:legislation_question, process: other_process +).id) + end + + it "returns published jobs for the process and its related resources" do + get :processes_index, params: { process_id: legislation_process.id } + + expect(response).to have_http_status(:ok) + jobs = controller.instance_variable_get(:@sensemaker_jobs) + expect(jobs).to include(question_job) + expect(jobs).to include(proposal_job) + expect(jobs).to include(option_job) + expect(jobs).not_to include(other_process_job) + expect(controller.instance_variable_get(:@parent_resource)).to eq(legislation_process) + end + end + + describe "GET #serve_report" do + let(:debate) { create(:debate) } + let(:job) { create_publishable_job_with_output(analysable_type: "Debate", analysable_id: debate.id) } + + context "when job is unpublished" do + before do + job.update!(published: false) + end + + it "returns 302 and redirects to root path" do + get :serve_report, params: { id: job.id } + expect(response).to have_http_status(:redirect) + expect(response).to redirect_to(root_path) + end + end + + context "when job exists and has output" do + it "sends the file with correct headers" do + get :serve_report, params: { id: job.id } + + expect(response).to have_http_status(:ok) + expect(response.headers["Content-Type"]).to eq("text/html") + expect(response.headers["Content-Disposition"]).to include("inline") + expect(response.body).to include("Test Report") + end + + it "determines correct content type for HTML files" do + get :serve_report, params: { id: job.id } + + expect(response.headers["Content-Type"]).to eq("text/html") + end + + it "determines correct content type for CSV files" do + csv_path = Rails.root.join("tmp", "test-report-#{job.id}.csv").to_s + FileUtils.mkdir_p(File.dirname(csv_path)) + File.write(csv_path, "col1,col2\nval1,val2") + job.update!(persisted_output: csv_path) + + get :serve_report, params: { id: job.id } + + expect(response.headers["Content-Type"]).to eq("text/csv") + FileUtils.rm_f(csv_path) + end + + it "determines correct content type for JSON files" do + json_path = Rails.root.join("tmp", "test-report-#{job.id}.json").to_s + FileUtils.mkdir_p(File.dirname(json_path)) + File.write(json_path, '{"test": "data"}') + job.update!(persisted_output: json_path) + + get :serve_report, params: { id: job.id } + + expect(response.headers["Content-Type"]).to eq("application/json") + FileUtils.rm_f(json_path) + end + + it "determines correct content type for TXT files" do + txt_path = Rails.root.join("tmp", "test-report-#{job.id}.txt").to_s + FileUtils.mkdir_p(File.dirname(txt_path)) + File.write(txt_path, "Plain text content") + job.update!(persisted_output: txt_path) + + get :serve_report, params: { id: job.id } + + expect(response.headers["Content-Type"]).to eq("text/plain") + FileUtils.rm_f(txt_path) + end + + it "uses application/octet-stream for unknown file types" do + unknown_path = Rails.root.join("tmp", "test-report-#{job.id}.unknown").to_s + FileUtils.mkdir_p(File.dirname(unknown_path)) + File.write(unknown_path, "Unknown content") + job.update!(persisted_output: unknown_path) + + get :serve_report, params: { id: job.id } + + expect(response.headers["Content-Type"]).to eq("application/octet-stream") + FileUtils.rm_f(unknown_path) + end + end + + context "when job does not exist" do + it "returns 404" do + get :serve_report, params: { id: 99999 } + + expect(response).to have_http_status(:not_found) + end + end + + context "when job exists but has no output" do + before do + FileUtils.rm_f(job.default_output_path) + end + + it "returns 404" do + get :serve_report, params: { id: job.id } + + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/factories/sensemaker/jobs.rb b/spec/factories/sensemaker/jobs.rb index c9b7e381edf..76da12819e9 100644 --- a/spec/factories/sensemaker/jobs.rb +++ b/spec/factories/sensemaker/jobs.rb @@ -19,5 +19,12 @@ script { "runner.ts" } published { true } end + + trait :publishable do + script { "single-html-build.js" } + finished_at { Time.current } + error { nil } + published { false } + end end end diff --git a/spec/models/abilities/everyone_spec.rb b/spec/models/abilities/everyone_spec.rb index ac011c94792..f5aee64712b 100644 --- a/spec/models/abilities/everyone_spec.rb +++ b/spec/models/abilities/everyone_spec.rb @@ -52,6 +52,25 @@ it { should_not be_able_to(:read_results, create(:budget, :finished, results_enabled: false)) } it { should_not be_able_to(:read_results, create(:budget, :reviewing_ballots, results_enabled: true)) } + describe "read_sensemaking (when sensemaker feature is enabled)" do + before { Setting["feature.sensemaker"] = true } + after { Setting["feature.sensemaker"] = nil } + + it { should be_able_to(:read_sensemaking, create(:budget, :finished, sensemaking_enabled: true)) } + it { should_not be_able_to(:read_sensemaking, create(:budget, :finished, sensemaking_enabled: false)) } + + it { + should_not be_able_to(:read_sensemaking, create(:budget, :reviewing_ballots, sensemaking_enabled: true)) + } + end + + it "does not allow read_sensemaking when sensemaker feature is disabled" do + Setting["feature.sensemaker"] = nil + budget = create(:budget, :finished, sensemaking_enabled: true) + + expect(Ability.new(nil)).not_to be_able_to(:read_sensemaking, budget) + end + it { should be_able_to(:read_stats, create(:budget, :valuating, stats_enabled: true)) } it { should_not be_able_to(:read_stats, create(:budget, :valuating, stats_enabled: false)) } it { should_not be_able_to(:read_stats, create(:budget, :selecting, stats_enabled: true)) } diff --git a/spec/models/concerns/reportable.rb b/spec/models/concerns/reportable.rb index ef28edef23a..ce7895b3704 100644 --- a/spec/models/concerns/reportable.rb +++ b/spec/models/concerns/reportable.rb @@ -29,6 +29,20 @@ expect(described_class.stats_enabled).to be_empty end end + + describe ".sensemaking_enabled" do + it "includes records with sensemaking enabled" do + reportable.update!(sensemaking_enabled: true) + + expect(described_class.sensemaking_enabled).to eq [reportable] + end + + it "does not include records without sensemaking enabled" do + reportable.update!(sensemaking_enabled: false) + + expect(described_class.sensemaking_enabled).to be_empty + end + end end describe "#results_enabled" do @@ -86,4 +100,32 @@ expect(saved_reportable.stats_enabled).to be false end end + + describe "#sensemaking_enabled" do + it "can write and read the attribute" do + reportable.sensemaking_enabled = true + + expect(reportable.sensemaking_enabled?).to be true + expect(reportable.sensemaking_enabled).to be true + + reportable.sensemaking_enabled = false + + expect(reportable.sensemaking_enabled?).to be false + expect(reportable.sensemaking_enabled).to be false + end + + it "can save the value to the database" do + reportable.update!(sensemaking_enabled: true) + saved_reportable = described_class.last + + expect(saved_reportable.sensemaking_enabled?).to be true + expect(saved_reportable.sensemaking_enabled).to be true + + reportable.update!(sensemaking_enabled: false) + saved_reportable = described_class.last + + expect(saved_reportable.sensemaking_enabled?).to be false + expect(saved_reportable.sensemaking_enabled).to be false + end + end end diff --git a/spec/requests/sensemaker_redirects_spec.rb b/spec/requests/sensemaker_redirects_spec.rb new file mode 100644 index 00000000000..77434d1dbec --- /dev/null +++ b/spec/requests/sensemaker_redirects_spec.rb @@ -0,0 +1,14 @@ +require "rails_helper" + +describe "Sensemaker budget jobs redirect" do + include Rails.application.routes.url_helpers + + let(:budget) { create(:budget) } + + it "redirects to budget sensemaking path with 301" do + get sensemaker_budget_jobs_path(budget) + + expect(response).to have_http_status(:moved_permanently) + expect(response).to redirect_to(budget_sensemaking_path(budget)) + end +end