From 947953641d6685ce1bc86720192f57d86ea1d780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javi=20Mart=C3=ADn?= Date: Mon, 13 May 2024 00:48:54 +0200 Subject: [PATCH 1/3] Add test for between_ages user scope We were already testing it as part of the statisticable concern, but it's easier to understand when we have specific tests for it as well. Note that we're using `eq` insteab of `be` because it's what we use most often in the test. For consistency, we're also changing the tesst in budget stats so it uses `eq`. --- spec/models/budget/stats_spec.rb | 24 ++++++++++++------------ spec/models/user_spec.rb | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/spec/models/budget/stats_spec.rb b/spec/models/budget/stats_spec.rb index e61abf100..5dfdc5c38 100644 --- a/spec/models/budget/stats_spec.rb +++ b/spec/models/budget/stats_spec.rb @@ -166,18 +166,18 @@ describe Budget::Stats do end it "returns the age groups hash" do - expect(stats.participants_by_age["16 - 19"][:count]).to be 0 - expect(stats.participants_by_age["20 - 24"][:count]).to be 4 - expect(stats.participants_by_age["25 - 29"][:count]).to be 0 - expect(stats.participants_by_age["30 - 34"][:count]).to be 1 - expect(stats.participants_by_age["35 - 39"][:count]).to be 0 - expect(stats.participants_by_age["40 - 44"][:count]).to be 3 - expect(stats.participants_by_age["45 - 49"][:count]).to be 0 - expect(stats.participants_by_age["50 - 54"][:count]).to be 2 - expect(stats.participants_by_age["55 - 59"][:count]).to be 0 - expect(stats.participants_by_age["60 - 64"][:count]).to be 0 - expect(stats.participants_by_age["65 - 69"][:count]).to be 0 - expect(stats.participants_by_age["70 - 74"][:count]).to be 0 + expect(stats.participants_by_age["16 - 19"][:count]).to eq 0 + expect(stats.participants_by_age["20 - 24"][:count]).to eq 4 + expect(stats.participants_by_age["25 - 29"][:count]).to eq 0 + expect(stats.participants_by_age["30 - 34"][:count]).to eq 1 + expect(stats.participants_by_age["35 - 39"][:count]).to eq 0 + expect(stats.participants_by_age["40 - 44"][:count]).to eq 3 + expect(stats.participants_by_age["45 - 49"][:count]).to eq 0 + expect(stats.participants_by_age["50 - 54"][:count]).to eq 2 + expect(stats.participants_by_age["55 - 59"][:count]).to eq 0 + expect(stats.participants_by_age["60 - 64"][:count]).to eq 0 + expect(stats.participants_by_age["65 - 69"][:count]).to eq 0 + expect(stats.participants_by_age["70 - 74"][:count]).to eq 0 end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index ed2bf468e..4fba25997 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -423,6 +423,21 @@ describe User do expect(User.by_username_email_or_document_number(" 12345678Z ")).to eq [larry] end end + + describe ".between_ages" do + it "returns users between certain ages, including both" do + [21, 22, 23, 23, 42, 43, 44, 51].each do |age| + create(:user, date_of_birth: age.years.ago) + end + + expect(User.between_ages(0, 20).count).to eq 0 + expect(User.between_ages(0, 21).count).to eq 1 + expect(User.between_ages(21, 23).count).to eq 4 + expect(User.between_ages(24, 41).count).to eq 0 + expect(User.between_ages(41, 45).count).to eq 3 + expect(User.between_ages(51, 100).count).to eq 1 + end + end end describe "self.search" do From 1d85a63e7c5ec32e7af046e5131c1ddea591cd51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javi=20Mart=C3=ADn?= Date: Mon, 13 May 2024 01:28:56 +0200 Subject: [PATCH 2/3] Calculate age stats based on the participation date We were calculating the age stats based on the age of the users who participated... at the moment where we were calculating the stats. That means that, if 20 years ago, 1000 people who were 16 years old participated, they would be shown as having 36 years in the stats. Instead, we want to show the stats at the time when the process took place, so we're implementing a `participation_date` method. Note that, for polls, we could actually use the `age` column in the `poll_voters` table. However, doing so would be harder, would only work for polls but not for budgets, and it wouldn't be statistically very relevant, since the stats are shown by age groups, and only a small percentage of people would change their age group (and only to the nearest one) between the time they participate and the time the process ends. We might use the `poll_voters` table in the future, though, since we have a similar issue with geozones and genders, and using the information in `poll_voters` would solve it as well (only for polls, though). Also note that we're using the `ends_at` dates because some people but be too young to vote when a process starts but old enough to vote when the process ends. Finally, note that we might need to change the way we calculate the participation date for a budget, since some budgets might not enabled every phase. Not sure how stats work in that scenario (even before these changes). --- app/models/budget/stats.rb | 12 ++++++ app/models/concerns/statisticable.rb | 8 +++- app/models/poll/stats.rb | 4 ++ app/models/user.rb | 6 +-- spec/models/budget/stats_spec.rb | 58 ++++++++++++++++++++++++++-- spec/models/poll/stats_spec.rb | 40 +++++++++++++++++++ spec/models/statisticable_spec.rb | 15 +++++++ spec/models/user_spec.rb | 20 ++++++++++ 8 files changed, 155 insertions(+), 8 deletions(-) diff --git a/app/models/budget/stats.rb b/app/models/budget/stats.rb index 7db855aa9..4d62cd6de 100644 --- a/app/models/budget/stats.rb +++ b/app/models/budget/stats.rb @@ -37,6 +37,10 @@ class Budget::Stats budget.finished? end + def participation_date + send("#{phases.last}_phase_participation_date") + end + def total_participants participants.distinct.count end @@ -98,6 +102,14 @@ class Budget::Stats phases.map { |phase| self.class.send("#{phase}_phase_methods") }.flatten end + def support_phase_participation_date + budget.phases.selecting.ends_at + end + + def vote_phase_participation_date + budget.phases.balloting.ends_at + end + def participant_ids phases.map { |phase| send("participant_ids_#{phase}_phase") }.flatten.uniq end diff --git a/app/models/concerns/statisticable.rb b/app/models/concerns/statisticable.rb index 5d3c67a07..53533b4ed 100644 --- a/app/models/concerns/statisticable.rb +++ b/app/models/concerns/statisticable.rb @@ -63,7 +63,11 @@ module Statisticable end def age? - participants.between_ages(age_groups.flatten.min, age_groups.flatten.max).any? + participants.between_ages( + age_groups.flatten.min, + age_groups.flatten.max, + at_time: participation_date + ).any? end def geozone? @@ -96,7 +100,7 @@ module Statisticable def participants_by_age age_groups.to_h do |start, finish| - count = participants.between_ages(start, finish).count + count = participants.between_ages(start, finish, at_time: participation_date).count [ "#{start} - #{finish}", diff --git a/app/models/poll/stats.rb b/app/models/poll/stats.rb index facb27b6d..c449bf17d 100644 --- a/app/models/poll/stats.rb +++ b/app/models/poll/stats.rb @@ -21,6 +21,10 @@ class Poll::Stats total_participants_web + total_participants_booth end + def participation_date + poll.ends_at + end + def channels CHANNELS.select { |channel| send(:"total_participants_#{channel}") > 0 } end diff --git a/app/models/user.rb b/app/models/user.rb index e25162a43..b5a70b075 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -119,11 +119,11 @@ class User < ApplicationRecord search = "%#{search_string.strip}%" where("username ILIKE ? OR email ILIKE ? OR document_number ILIKE ?", search, search, search) end - scope :between_ages, ->(from, to) do + scope :between_ages, ->(from, to, at_time: Time.current) do where( "date_of_birth > ? AND date_of_birth < ?", - to.years.ago.beginning_of_year, - from.years.ago.end_of_year + (at_time - to.years).beginning_of_year, + (at_time - from.years).end_of_year ) end diff --git a/spec/models/budget/stats_spec.rb b/spec/models/budget/stats_spec.rb index 5dfdc5c38..a8a97cc77 100644 --- a/spec/models/budget/stats_spec.rb +++ b/spec/models/budget/stats_spec.rb @@ -157,15 +157,13 @@ describe Budget::Stats do end describe "#participants_by_age" do - before do + it "returns the age groups hash" do [21, 22, 23, 23, 34, 42, 43, 44, 50, 51].each do |age| create(:user, date_of_birth: age.years.ago) end allow(stats).to receive(:participants).and_return(User.all) - end - it "returns the age groups hash" do expect(stats.participants_by_age["16 - 19"][:count]).to eq 0 expect(stats.participants_by_age["20 - 24"][:count]).to eq 4 expect(stats.participants_by_age["25 - 29"][:count]).to eq 0 @@ -179,6 +177,60 @@ describe Budget::Stats do expect(stats.participants_by_age["65 - 69"][:count]).to eq 0 expect(stats.participants_by_age["70 - 74"][:count]).to eq 0 end + + it "returns stats based on what happened when the voting took place" do + travel_to(50.years.ago) do + [21, 22, 23, 23, 34, 42, 43, 44, 50, 51].each do |age| + create(:user, date_of_birth: age.years.ago) + end + + create(:budget, :finished) + end + + stats = Budget::Stats.new(Budget.last) + allow(stats).to receive(:participants).and_return(User.all) + + expect(stats.participants_by_age["16 - 19"][:count]).to eq 0 + expect(stats.participants_by_age["20 - 24"][:count]).to eq 4 + expect(stats.participants_by_age["25 - 29"][:count]).to eq 0 + expect(stats.participants_by_age["30 - 34"][:count]).to eq 1 + expect(stats.participants_by_age["35 - 39"][:count]).to eq 0 + expect(stats.participants_by_age["40 - 44"][:count]).to eq 3 + expect(stats.participants_by_age["45 - 49"][:count]).to eq 0 + expect(stats.participants_by_age["50 - 54"][:count]).to eq 2 + expect(stats.participants_by_age["55 - 59"][:count]).to eq 0 + expect(stats.participants_by_age["60 - 64"][:count]).to eq 0 + expect(stats.participants_by_age["65 - 69"][:count]).to eq 0 + expect(stats.participants_by_age["70 - 74"][:count]).to eq 0 + end + end + + describe "#participation_date", :with_frozen_time do + let(:budget) do + create(:budget).tap do |budget| + budget.phases.informing.update!(starts_at: 10.months.ago, ends_at: 9.months.ago) + budget.phases.accepting.update!(starts_at: 9.months.ago, ends_at: 8.months.ago) + budget.phases.reviewing.update!(starts_at: 8.months.ago, ends_at: 7.months.ago) + budget.phases.selecting.update!(starts_at: 7.months.ago, ends_at: 6.months.ago) + budget.phases.valuating.update!(starts_at: 6.months.ago, ends_at: 5.months.ago) + budget.phases.publishing_prices.update!(starts_at: 5.months.ago, ends_at: 4.months.ago) + budget.phases.balloting.update!(starts_at: 4.months.ago, ends_at: 3.months.ago) + budget.phases.reviewing_ballots.update!(starts_at: 3.months.ago, ends_at: 2.months.ago) + budget.phases.finished.update!(starts_at: 2.months.ago, ends_at: 1.month.ago) + end + end + + it "returns the date when balloting ended on finished budgets" do + budget.update!(phase: "finished") + + expect(stats.participation_date).to eq 3.months.ago + end + + it "returns the date when selecting ended on unfinished budgets" do + budget.update!(phase: "reviewing_ballots") + + expect(stats.participation_date).to eq 6.months.ago + end end describe "#headings" do diff --git a/spec/models/poll/stats_spec.rb b/spec/models/poll/stats_spec.rb index e9c93f1f7..ddb95a361 100644 --- a/spec/models/poll/stats_spec.rb +++ b/spec/models/poll/stats_spec.rb @@ -165,6 +165,46 @@ describe Poll::Stats do end end + describe "#participants_by_age" do + it "returns stats based on what happened when the voting took place" do + travel_to(100.years.ago) do + [16, 18, 32, 32, 33, 34, 64, 65, 71, 73, 90, 99, 105].each do |age| + create(:user, date_of_birth: age.years.ago) + end + + create(:poll, starts_at: 1.minute.from_now, ends_at: 2.minutes.from_now) + end + + stats = Poll::Stats.new(Poll.last) + allow(stats).to receive(:participants).and_return(User.all) + + expect(stats.participants_by_age["16 - 19"][:count]).to eq 2 + expect(stats.participants_by_age["20 - 24"][:count]).to eq 0 + expect(stats.participants_by_age["25 - 29"][:count]).to eq 0 + expect(stats.participants_by_age["30 - 34"][:count]).to eq 4 + expect(stats.participants_by_age["35 - 39"][:count]).to eq 0 + expect(stats.participants_by_age["40 - 44"][:count]).to eq 0 + expect(stats.participants_by_age["45 - 49"][:count]).to eq 0 + expect(stats.participants_by_age["50 - 54"][:count]).to eq 0 + expect(stats.participants_by_age["55 - 59"][:count]).to eq 0 + expect(stats.participants_by_age["60 - 64"][:count]).to eq 1 + expect(stats.participants_by_age["65 - 69"][:count]).to eq 1 + expect(stats.participants_by_age["70 - 74"][:count]).to eq 2 + expect(stats.participants_by_age["75 - 79"][:count]).to eq 0 + expect(stats.participants_by_age["80 - 84"][:count]).to eq 0 + expect(stats.participants_by_age["85 - 89"][:count]).to eq 0 + expect(stats.participants_by_age["90 - 300"][:count]).to eq 3 + end + end + + describe "#participation_date", :with_frozen_time do + let(:poll) { create(:poll, starts_at: 3.years.ago, ends_at: 2.years.ago) } + + it "returns the date when the poll finishes" do + expect(stats.participation_date).to eq 2.years.ago + end + end + describe "#participants_by_geozone" do it "groups by geozones in alphabetic order" do %w[Oceania Eurasia Eastasia].each { |name| create(:geozone, name: name) } diff --git a/spec/models/statisticable_spec.rb b/spec/models/statisticable_spec.rb index f9e4ffc9a..9768b39ee 100644 --- a/spec/models/statisticable_spec.rb +++ b/spec/models/statisticable_spec.rb @@ -8,6 +8,10 @@ describe Statisticable do def participants User.all end + + def participation_date + Time.current + end end stub_const("DummyStats", dummy_stats) @@ -80,6 +84,17 @@ describe Statisticable do expect(stats.age?).to be true end end + + context "Partipation took place a long time ago" do + before do + create(:user, date_of_birth: 2000.years.ago) + allow(stats).to receive(:participation_date).and_return(1900.years.ago) + end + + it "is true" do + expect(stats.age?).to be true + end + end end describe "#geozone?" do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 4fba25997..f3e59ffd6 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -437,6 +437,26 @@ describe User do expect(User.between_ages(41, 45).count).to eq 3 expect(User.between_ages(51, 100).count).to eq 1 end + + it "returns users between certain ages on a reference date" do + reference_date = 20.years.ago + + travel_to(reference_date) do + [21, 22, 23, 23, 34, 42, 43, 44, 50, 51].each do |age| + create(:user, date_of_birth: age.years.ago) + end + end + + expect(User.between_ages(0, 20, at_time: reference_date).count).to eq 0 + expect(User.between_ages(21, 25, at_time: reference_date).count).to eq 4 + expect(User.between_ages(25, 30, at_time: reference_date).count).to eq 0 + expect(User.between_ages(30, 34, at_time: reference_date).count).to eq 1 + expect(User.between_ages(35, 39, at_time: reference_date).count).to eq 0 + expect(User.between_ages(40, 44, at_time: reference_date).count).to eq 3 + expect(User.between_ages(45, 49, at_time: reference_date).count).to eq 0 + expect(User.between_ages(50, 54, at_time: reference_date).count).to eq 2 + expect(User.between_ages(55, 100, at_time: reference_date).count).to eq 0 + end end end From c92cb6bed16fc41e06316b6a7de6733f055075ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javi=20Mart=C3=ADn?= Date: Fri, 17 May 2024 15:36:52 +0200 Subject: [PATCH 3/3] Use a range in the between_ages user scope Using `>` and `<` for dates is a bit confusing because it usually requires mentally translating `<` into "before" and `>` into "after", meaning it takes a few seconds to check which is the starting date and which is the ending date. When using a range, it's easier to see which is which. Note that we were using `>` and `<`, but now we're using the equivalent of `>=` and `<=`. This makes sense IMHO, since the beginning of the year and the end of the year should also be included in this case. --- app/models/user.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index b5a70b075..5421b56ce 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -120,11 +120,7 @@ class User < ApplicationRecord where("username ILIKE ? OR email ILIKE ? OR document_number ILIKE ?", search, search, search) end scope :between_ages, ->(from, to, at_time: Time.current) do - where( - "date_of_birth > ? AND date_of_birth < ?", - (at_time - to.years).beginning_of_year, - (at_time - from.years).end_of_year - ) + where(date_of_birth: (at_time - to.years).beginning_of_year..(at_time - from.years).end_of_year) end before_validation :clean_document_number