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/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/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..91fd7f2874c
--- /dev/null
+++ b/spec/components/sensemaker/budget_job_index_component_spec.rb
@@ -0,0 +1,73 @@
+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_css("h1", text: /Discover what citizens are saying/)
+ 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/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
From 71bc2289944623ac76c6e2a93abf1530ec0e154d Mon Sep 17 00:00:00 2001
From: Rory Gianni
Date: Thu, 5 Feb 2026 16:45:56 +0000
Subject: [PATCH 2/3] fix spec and makes budget sensemaker tab controllable
from admin
---
app/models/abilities/everyone.rb | 4 +-
app/models/report.rb | 2 +-
.../shared/_show_results_fields.html.erb | 3 ++
config/locales/en/activerecord.yml | 1 +
config/locales/es/activerecord.yml | 1 +
...250205120000_add_sensemaking_to_reports.rb | 7 ++++
db/schema.rb | 1 +
.../budget_job_index_component_spec.rb | 1 -
spec/models/abilities/everyone_spec.rb | 19 +++++++++
spec/models/concerns/reportable.rb | 42 +++++++++++++++++++
10 files changed, 78 insertions(+), 3 deletions(-)
create mode 100644 db/migrate/20250205120000_add_sensemaking_to_reports.rb
diff --git a/app/models/abilities/everyone.rb b/app/models/abilities/everyone.rb
index a9559f5a926..1be9282ae18 100644
--- a/app/models/abilities/everyone.rb
+++ b/app/models/abilities/everyone.rb
@@ -16,7 +16,9 @@ def initialize(user)
can [:read], Budget::Group
can [:read, :print], Budget::Investment
can :read_results, Budget, id: Budget.finished.results_enabled.ids
- can :read_sensemaking, Budget, id: Budget.finished.results_enabled.ids
+ if Setting["feature.sensemaker"].present?
+ can :read_sensemaking, Budget, id: Budget.finished.sensemaking_enabled.ids
+ end
can :read_stats, Budget, id: Budget.valuating_or_later.stats_enabled.ids
can :read_executions, Budget, phase: "finished"
can [:read, :debate, :draft_publication, :allegations, :result_publication,
diff --git a/app/models/report.rb b/app/models/report.rb
index e8d26a829db..100f5c0976d 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -1,5 +1,5 @@
class Report < ApplicationRecord
- KINDS = %i[results stats advanced_stats].freeze
+ KINDS = %i[results stats advanced_stats sensemaking].freeze
belongs_to :process, polymorphic: true
end
diff --git a/app/views/admin/shared/_show_results_fields.html.erb b/app/views/admin/shared/_show_results_fields.html.erb
index 3b0d8353086..9baff1d480b 100644
--- a/app/views/admin/shared/_show_results_fields.html.erb
+++ b/app/views/admin/shared/_show_results_fields.html.erb
@@ -8,4 +8,7 @@
<%= form.check_box :results_enabled %>
<%= form.check_box :stats_enabled %>
<%= form.check_box :advanced_stats_enabled %>
+ <% if feature?(:sensemaker) %>
+ <%= form.check_box :sensemaking_enabled %>
+ <% end %>
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/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/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
index 91fd7f2874c..b84b9df96b6 100644
--- a/spec/components/sensemaker/budget_job_index_component_spec.rb
+++ b/spec/components/sensemaker/budget_job_index_component_spec.rb
@@ -56,7 +56,6 @@
it "renders the jobs as cards in a grouped section" do
render_inline component
- expect(page).to have_css("h1", text: /Discover what citizens are saying/)
expect(page).to have_link(I18n.t("sensemaker.job_index.view_report"),
href: serve_report_sensemaker_job_path(job))
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
From b6e9211d348b9001adff288aad171d7a2e346bc1 Mon Sep 17 00:00:00 2001
From: Rory Gianni
Date: Thu, 5 Feb 2026 16:58:23 +0000
Subject: [PATCH 3/3] make report accessible from l proposal page
---
app/views/legislation/proposals/show.html.erb | 1 +
1 file changed, 1 insertion(+)
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) %>