diff --git a/app/admin/leaderboard.rb b/app/admin/leaderboard.rb new file mode 100644 index 00000000..124954a2 --- /dev/null +++ b/app/admin/leaderboard.rb @@ -0,0 +1,48 @@ +ActiveAdmin.register_page "Leaderboard" do + # Nested under the Money tab, and hidden from the nav for anyone who isn't + # an admin. Menu visibility is presentation, not access control — + # require_admin! below is what actually blocks a hand-typed URL. + menu label: "Leaderboard", + parent: "Money", + priority: 20, + if: proc { current_admin_user&.is_admin? } + + # The global AuthorizationAdapter lets project leads through for most pages, + # so gate this one explicitly: earnings across the whole collective are + # admin-only. Applies to every format, including the CSV download. + controller do + before_action :require_admin! + + # ActiveAdmin's `csv do ... end` DSL is defined on ResourceDSL and needs an + # ActiveRecord-backed collection, so a register_page can't use it. We get + # the same convention by hand: the index responds to .csv, which keeps the + # familiar /admin/leaderboard.csv URL and lets the standard + # "Download: CSV" footer link work with plain url_for. + def index + return super() unless request.format.csv? + + limit = Stacks::Leaderboard.sanitize_limit(params[:limit]) + + send_data Stacks::Leaderboard.to_csv(limit: limit), + type: "text/csv; charset=utf-8", + disposition: %(attachment; filename="leaderboard-top-#{limit}-#{Date.today.iso8601}.csv") + end + + private + + def require_admin! + unless current_admin_user&.is_admin? + redirect_to admin_root_path, alert: "Admins only." + end + end + end + + content title: "Leaderboard" do + limit = Stacks::Leaderboard.sanitize_limit(params[:limit]) + + render(partial: "leaderboard", locals: { + limit: limit, + months: Stacks::Leaderboard.call(limit: limit), + }) + end +end diff --git a/app/views/admin/leaderboard/_leaderboard.html.erb b/app/views/admin/leaderboard/_leaderboard.html.erb new file mode 100644 index 00000000..b819c368 --- /dev/null +++ b/app/views/admin/leaderboard/_leaderboard.html.erb @@ -0,0 +1,90 @@ +<%# Top X earners per month, styled to match the Money (payable QBO bills) + screen: a title_bar per group with the heading on the left and a pill on + the right, over a standard .index_table.index table. %> + +<%# Limit toggles, styled as the same nag-pill tab bar the Money page uses. %> +
+ <% [3, 4, 5, 10, 20].each do |n| %> + <%= link_to admin_leaderboard_path(limit: n), style: "margin-right: 6px" do %> +

+ <%= n %> +

+ <% end %> + <% end %> +
+ +<% if months.empty? %> +
+
+
+

No contributor payouts recorded yet.

+
+
+
+<% else %> + <% months.each do |group| %> +
+
+

<%= group.start_of_month.strftime("%B %Y") %>

+
+
+ + <%= number_to_currency(group.average) %> + avg of top <%= group.entries.size %> + +
+
+ + <%# Same markup ActiveAdmin's Arbre `panel` builder emits, so it picks up + the standard panel styling from an ERB template. %> +
+

MSO Compensation

+
+ + + <% Stacks::Leaderboard.mso_compensation(group.average).each_with_index do |tier, index| %> + "> + + + + <% end %> + +
<%= tier.label %> (<%= tier.multiplier_label %>)<%= number_to_currency(tier.amount) %>
+
+
+ + + + + + + + + + + <% group.entries.each_with_index do |entry, index| %> + "> + + + + + <% end %> + "> + + + + + +
#ContributorEarnings
<%= entry.rank %><%= link_to entry.display_name, admin_contributor_path(entry.contributor_id) %><%= number_to_currency(entry.amount) %>
Total<%= number_to_currency(group.total) %>
+ <% end %> + + <%# Mirrors the #index_footer > .download_links markup ActiveAdmin renders + beneath a standard index table, so the download reads as the convention + it is. Keeps the current ?limit= so you export exactly what you see. %> + +<% end %> diff --git a/lib/stacks/leaderboard.rb b/lib/stacks/leaderboard.rb new file mode 100644 index 00000000..913e78b4 --- /dev/null +++ b/lib/stacks/leaderboard.rb @@ -0,0 +1,174 @@ +# Ranks contributors by how much they actually earned in each month. +# +# "Earnings" here means ContributorPayout only. Trueups are deliberately +# excluded: a Trueup is the mechanism that tops a leadership role up to the +# average of the top earners, so counting it would let the very roles being +# topped up appear at the top of the board they're benchmarked against. +# Because Trueups live in their own table (rather than as a type column on a +# shared ledger table), excluding them is structural — we simply never sum +# them — and no filtering predicate is required. +# +# A contributor has one Ledger per Enterprise, so payouts are grouped by +# ledgers.contributor_id to aggregate "across all their contributor ledgers". +# Months are bucketed on invoice_passes.start_of_month, matching the canonical +# dating used by ContributorPayout#effective_on_for_display. +require "csv" + +module Stacks + class Leaderboard + DEFAULT_LIMIT = 5 + MIN_LIMIT = 1 + MAX_LIMIT = 50 + + CSV_HEADERS = [ + "Month", + "Rank", + "Contributor", + "Earnings", + ].freeze + + # Leadership compensation tiers. Each tier earns the month's average + # top-N earner times its multiplier — which is exactly why Trueups are + # excluded from the earnings above: a Trueup is the payment that lifts + # one of these roles up to the figure derived here, so letting it count + # as earnings would feed the benchmark back into itself. + # A uniform ladder: every tier is +0.15 above the one below it. + MSO_TIERS = [ + ["Heads", BigDecimal("0.7")], + ["SVPs", BigDecimal("0.85")], + ["Chiefs", BigDecimal("1")], + ["Principals", BigDecimal("1.15")], + ].freeze + + MSO_TIER_STEP = BigDecimal("0.15") + + MsoTier = Struct.new(:label, :multiplier, :multiplier_label, :amount, keyword_init: true) + + # What each leadership tier is paid for a month, given that month's + # average top-N earner. + def self.mso_compensation(average) + MSO_TIERS.map do |label, multiplier| + MsoTier.new( + label: label, + multiplier: multiplier, + multiplier_label: "#{multiplier.to_s("F").sub(/\.0+\z/, "")}x", + amount: average * multiplier + ) + end + end + + Entry = Struct.new(:rank, :contributor_id, :display_name, :amount, keyword_init: true) + + # `average` is the mean across the listed entries only (not the whole + # collective) — i.e. the "average top-N earner", which is the figure the + # leadership compensation model multiplies against. + MonthGroup = Struct.new(:start_of_month, :entries, :total, :average, keyword_init: true) + + # Coerces an untrusted ?limit= param into a sane integer. + def self.sanitize_limit(raw) + value = Integer(raw.to_s.strip, exception: false) + return DEFAULT_LIMIT if value.nil? + value.clamp(MIN_LIMIT, MAX_LIMIT) + end + + def self.call(limit: DEFAULT_LIMIT) + new(limit: limit).call + end + + # Flat, spreadsheet-friendly rendering: one row per ranked contributor. + # Month average and total are deliberately omitted — they're derivable from + # these rows, so repeating them would just be denormalised noise in a + # spreadsheet. Amounts are unformatted decimals (no currency symbols or + # thousands separators) so they land as numbers, not text. + def self.to_csv(limit: DEFAULT_LIMIT, months: nil) + groups = months || call(limit: limit) + + CSV.generate do |csv| + csv << CSV_HEADERS + groups.each do |group| + group.entries.each do |entry| + csv << [ + group.start_of_month.strftime("%Y-%m"), + entry.rank, + entry.display_name, + format("%.2f", entry.amount), + ] + end + end + end + end + + def initialize(limit: DEFAULT_LIMIT) + @limit = limit + end + + # Returns [MonthGroup] ordered most-recent month first. + def call + grouped = earnings_by_month_and_contributor + .select { |_key, amount| amount.to_d.positive? } + .group_by { |(month, _contributor_id), _amount| month } + + ranked_by_month = grouped.transform_values do |pairs| + pairs + .sort_by { |(_month, _contributor_id), amount| -amount.to_d } + .first(@limit) + end + + names = display_names_for( + ranked_by_month.values.flatten(1).map { |(_month, contributor_id), _amount| contributor_id }.uniq + ) + + ranked_by_month + .sort_by { |month, _pairs| month } + .reverse + .map do |month, pairs| + entries = pairs.each_with_index.map do |((_month, contributor_id), amount), index| + Entry.new( + rank: index + 1, + contributor_id: contributor_id, + display_name: names.fetch(contributor_id, "Contributor ##{contributor_id}"), + amount: amount.to_d + ) + end + + total = entries.sum(&:amount) + + MonthGroup.new( + start_of_month: month, + entries: entries, + total: total, + average: entries.empty? ? BigDecimal(0) : (total / entries.size) + ) + end + end + + private + + # => { [start_of_month, contributor_id] => summed_amount } + # + # ContributorPayout is acts_as_paranoid, so its default scope already + # excludes soft-deleted rows. + def earnings_by_month_and_contributor + ContributorPayout + .joins(:ledger, invoice_tracker: :invoice_pass) + .group("invoice_passes.start_of_month", "ledgers.contributor_id") + .sum("contributor_payouts.amount") + end + + def display_names_for(contributor_ids) + return {} if contributor_ids.empty? + + # Contributor has a default_scope that force-joins forecast_person and + # orders by email; unscoped keeps this lookup predictable. + Contributor + .unscoped + .where(id: contributor_ids) + .includes(:forecast_person) + .each_with_object({}) do |contributor, memo| + person = contributor.forecast_person + full_name = [person&.first_name, person&.last_name].map(&:presence).compact.join(" ") + memo[contributor.id] = full_name.presence || person&.email || "Contributor ##{contributor.id}" + end + end + end +end diff --git a/test/integration/admin_leaderboard_test.rb b/test/integration/admin_leaderboard_test.rb new file mode 100644 index 00000000..d8bb9705 --- /dev/null +++ b/test/integration/admin_leaderboard_test.rb @@ -0,0 +1,180 @@ +require 'test_helper' + +class AdminLeaderboardTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + + setup do + @admin = AdminUser.create!( + email: "admin-#{SecureRandom.hex(4)}@example.com", + password: 'password12345', + password_confirmation: 'password12345', + roles: ['admin'] + ) + + @month = Date.new(2096, 5, 1) + @pass = InvoicePass.create!(start_of_month: @month) + client = ForecastClient.create!( + forecast_id: rand(1..2_000_000_000), + name: "Leaderboard Client #{SecureRandom.hex(2)}" + ) + @tracker = InvoiceTracker.create!( + forecast_client: client, + invoice_pass: @pass, + qbo_account: qbo_accounts(:one) + ) + end + + def contributor_with_payout(email, amount) + person = ForecastPerson.create!( + forecast_id: rand(1..2_000_000_000), + email: email, + data: {} + ) + contributor = Contributor.create!(forecast_person: person) + ContributorPayout.new( + ledger: Ledger.find_or_create_for(enterprise: enterprises(:sanctuary), contributor: contributor), + invoice_tracker: @tracker, + created_by: @admin, + amount: amount + ).save!(validate: false) + contributor + end + + test 'renders the ledger with the month, ranked earners and amounts' do + alpha = contributor_with_payout('alpha@example.com', 900) + contributor_with_payout('beta@example.com', 400) + sign_in @admin + + get '/admin/leaderboard' + + assert_response :success + assert_includes response.body, 'May 2096', 'shows the month heading' + assert_includes response.body, 'alpha@example.com' + assert_includes response.body, 'beta@example.com' + assert_includes response.body, '$900.00' + assert_includes response.body, '$1,300.00', 'shows the month total' + assert_includes response.body, '$650.00', 'shows the average of the listed earners' + assert_includes response.body, 'avg of top 2' + assert_includes response.body, 'index_table', 'uses the shared ActiveAdmin table styling' + assert_includes response.body, 'nag pill complete', 'marks the active limit toggle' + [3, 4, 5, 10, 20].each do |n| + assert_includes response.body, %(href="/admin/leaderboard?limit=#{n}"), + "offers a limit toggle for #{n}" + end + assert_includes response.body, + %(alpha@example.com), + 'links each contributor through to their contributor page' + assert_includes response.body, '

MSO Compensation

', + 'each month gets an MSO Compensation panel' + assert_includes response.body, 'panel_contents', + 'the panel uses the standard ActiveAdmin panel markup' + # Month average is $650.00 (900 + 400, over 2 listed earners). + assert_includes response.body, 'Heads (0.7x)' + assert_includes response.body, '$455.00', 'Heads = 650 x 0.7' + assert_includes response.body, 'SVPs (0.85x)' + assert_includes response.body, '$552.50', 'SVPs = 650 x 0.85' + assert_includes response.body, 'Chiefs (1x)' + assert_includes response.body, 'Principals (1.15x)' + assert_includes response.body, '$747.50', 'Principals = 650 x 1.15' + assert_includes response.body, 'download_links', + 'renders the standard ActiveAdmin download footer' + assert_includes response.body, %(CSV), + 'download link uses the conventional .csv URL and carries the current limit' + end + + test 'defaults to the top 5 and honors ?limit=' do + 7.times { |i| contributor_with_payout("earner#{i}@example.com", (i + 1) * 100) } + sign_in @admin + + get '/admin/leaderboard' + assert_response :success + # Highest five are 700..300; the 200 and 100 earners fall outside the default. + assert_includes response.body, 'earner6@example.com' + assert_includes response.body, 'earner2@example.com' + refute_includes response.body, 'earner1@example.com', 'defaults to top 5' + + get '/admin/leaderboard?limit=7' + assert_response :success + assert_includes response.body, 'earner0@example.com', 'limit=7 widens the board' + end + + test 'clamps an out-of-range limit instead of erroring' do + contributor_with_payout('solo@example.com', 100) + sign_in @admin + + get '/admin/leaderboard?limit=99999' + assert_response :success + + get '/admin/leaderboard?limit=bogus' + assert_response :success + assert_includes response.body, 'solo@example.com' + end + + test 'exports CSV as an attachment' do + contributor_with_payout('csvalpha@example.com', 900) + contributor_with_payout('csvbeta@example.com', 300) + sign_in @admin + + get '/admin/leaderboard.csv' + + assert_response :success + assert_match %r{text/csv}, response.content_type + assert_match /attachment; filename="leaderboard-top-5-\d{4}-\d{2}-\d{2}\.csv"/, + response.headers['Content-Disposition'] + + rows = CSV.parse(response.body) + assert_equal Stacks::Leaderboard::CSV_HEADERS, rows.first + alpha = rows.find { |r| r[2] == 'csvalpha@example.com' } + assert_equal '900.00', alpha[3] + assert_equal %w[Month Rank Contributor Earnings], rows.first, + 'no month average/total columns' + end + + test 'CSV export honors ?limit=' do + 3.times { |i| contributor_with_payout("csvlim#{i}@example.com", (i + 1) * 100) } + sign_in @admin + + get '/admin/leaderboard.csv?limit=1' + + assert_response :success + body = CSV.parse(response.body).drop(1).select { |r| r[0] == @month.strftime('%Y-%m') } + assert_equal 1, body.size + assert_equal 'csvlim2@example.com', body.first[2] + end + + test 'non-admins cannot export the CSV' do + contributor_with_payout('secret@example.com', 900) + non_admin = AdminUser.create!( + email: "plain-csv-#{SecureRandom.hex(4)}@example.com", + password: 'password12345', + password_confirmation: 'password12345', + roles: [] + ) + sign_in non_admin + + get '/admin/leaderboard.csv' + + # ActiveAdmin's authorize_access! runs first and answers a non-HTML format + # with 401 rather than a redirect. Either way the data must not be served, + # so assert the security property instead of a specific status. + refute_equal 200, response.status, 'must never serve the CSV to a non-admin' + refute_includes response.body.to_s, 'secret@example.com', 'must not leak earnings' + refute_includes response.body.to_s, Stacks::Leaderboard::CSV_HEADERS.join(','), + 'no CSV payload is emitted at all' + end + + test 'non-admins are redirected away' do + non_admin = AdminUser.create!( + email: "plain-#{SecureRandom.hex(4)}@example.com", + password: 'password12345', + password_confirmation: 'password12345', + roles: [] + ) + sign_in non_admin + + get '/admin/leaderboard' + + assert_response :redirect + refute_includes(response.body.to_s, 'alpha@example.com') + end +end diff --git a/test/lib/stacks/leaderboard_test.rb b/test/lib/stacks/leaderboard_test.rb new file mode 100644 index 00000000..3f291f98 --- /dev/null +++ b/test/lib/stacks/leaderboard_test.rb @@ -0,0 +1,224 @@ +require "test_helper" + +class Stacks::LeaderboardTest < ActiveSupport::TestCase + def setup + @admin = AdminUser.create!( + email: "leaderboard-#{SecureRandom.hex(3)}@sanctuary.computer", + password: "password" + ) + @enterprise = enterprises(:sanctuary) + @other_enterprise = enterprises(:one) + @qbo_account = qbo_accounts(:one) + + # Far-future month so this suite never collides with other tests or with + # the unique index on invoice_passes.start_of_month. + @month = Date.new(2097, 3, 1) + @pass = InvoicePass.create!(start_of_month: @month) + @client = ForecastClient.create!( + forecast_id: rand(1..2_000_000_000), + name: "Leaderboard Client #{SecureRandom.hex(2)}" + ) + @tracker = InvoiceTracker.create!( + forecast_client: @client, + invoice_pass: @pass, + qbo_account: @qbo_account + ) + end + + def make_contributor(email) + person = ForecastPerson.create!( + forecast_id: rand(1..2_000_000_000), + email: email, + data: {} + ) + Contributor.create!(forecast_person: person) + end + + def ledger_for(contributor, enterprise = @enterprise) + Ledger.find_or_create_for(enterprise: enterprise, contributor: contributor) + end + + # save(validate: false) sidesteps the 70%-of-invoice and new-deal domain + # validations, which are irrelevant to (and would obstruct) aggregation tests. + def payout!(contributor, amount, enterprise: @enterprise) + ContributorPayout.new( + ledger: ledger_for(contributor, enterprise), + invoice_tracker: @tracker, + created_by: @admin, + amount: amount + ).save!(validate: false) + end + + def month_group + Stacks::Leaderboard.call(limit: 5).find { |g| g.start_of_month == @month } + end + + test "ranks contributors by summed payouts, descending, honoring the limit" do + top = make_contributor("top@example.com") + middle = make_contributor("middle@example.com") + bottom = make_contributor("bottom@example.com") + + payout!(middle, 300) + payout!(top, 500) + payout!(bottom, 100) + + group = Stacks::Leaderboard.call(limit: 2).find { |g| g.start_of_month == @month } + + assert_equal 2, group.entries.size, "limit should cap the number of entries" + assert_equal ["top@example.com", "middle@example.com"], group.entries.map(&:display_name) + assert_equal [1, 2], group.entries.map(&:rank) + assert_equal BigDecimal("500"), group.entries.first.amount + assert_equal BigDecimal("800"), group.total, "total reflects only the listed entries" + assert_equal BigDecimal("400"), group.average, "average is across the listed entries only" + end + + test "average is the mean of the listed entries, not the whole collective" do + make_contributor("a@example.com").tap { |c| payout!(c, 900) } + make_contributor("b@example.com").tap { |c| payout!(c, 300) } + # A third earner that falls outside a limit of 2 must not drag the average. + make_contributor("c@example.com").tap { |c| payout!(c, 30) } + + group = Stacks::Leaderboard.call(limit: 2).find { |g| g.start_of_month == @month } + + assert_equal BigDecimal("1200"), group.total + assert_equal BigDecimal("600"), group.average + end + + test "excludes Trueups from earnings" do + real_earner = make_contributor("earner@example.com") + topped_up = make_contributor("toppedup@example.com") + + payout!(real_earner, 1_000) + payout!(topped_up, 10) + + # A large Trueup must not lift this contributor up the board. + Trueup.new( + ledger: ledger_for(topped_up), + invoice_pass: @pass, + amount: 5_000 + ).save!(validate: false) + + group = month_group + + assert_equal "earner@example.com", group.entries.first.display_name, + "the Trueup recipient must not outrank a genuine earner" + + topped_up_entry = group.entries.find { |e| e.display_name == "toppedup@example.com" } + assert_equal BigDecimal("10"), topped_up_entry.amount, + "only the payout should count, not the Trueup" + assert_equal BigDecimal("1010"), group.total + end + + test "sums a contributor's payouts across all of their ledgers" do + contributor = make_contributor("multi@example.com") + + payout!(contributor, 100, enterprise: @enterprise) + payout!(contributor, 250, enterprise: @other_enterprise) + + group = month_group + entry = group.entries.find { |e| e.display_name == "multi@example.com" } + + assert_equal 1, group.entries.count { |e| e.display_name == "multi@example.com" }, + "a contributor appears once, aggregated across ledgers" + assert_equal BigDecimal("350"), entry.amount + end + + test "omits contributors with no positive earnings" do + earner = make_contributor("positive@example.com") + zeroed = make_contributor("zero@example.com") + + payout!(earner, 100) + payout!(zeroed, 0) + + group = month_group + + assert_equal ["positive@example.com"], group.entries.map(&:display_name) + end + + test "prefers a full name over the email when one is present" do + person = ForecastPerson.create!( + forecast_id: rand(1..2_000_000_000), + email: "named@example.com", + first_name: "Ada", + last_name: "Lovelace", + data: {} + ) + contributor = Contributor.create!(forecast_person: person) + payout!(contributor, 42) + + assert_equal "Ada Lovelace", month_group.entries.first.display_name + end + + test "to_csv emits a header and one row per ranked contributor" do + make_contributor("first@example.com").tap { |c| payout!(c, 900) } + make_contributor("second@example.com").tap { |c| payout!(c, 300) } + + rows = CSV.parse(Stacks::Leaderboard.to_csv(limit: 5)) + + assert_equal Stacks::Leaderboard::CSV_HEADERS, rows.first + row = rows.find { |r| r[2] == "first@example.com" } + assert_equal "2097-03", row[0], "month is ISO-ish for sorting" + assert_equal "1", row[1], "rank" + assert_equal "900.00", row[3], "earnings are plain decimals, not currency strings" + assert_equal 4, row.size, "no month average/total columns" + end + + test "to_csv honors the limit" do + 5.times { |i| make_contributor("csv#{i}@example.com").tap { |c| payout!(c, (i + 1) * 100) } } + + rows = CSV.parse(Stacks::Leaderboard.to_csv(limit: 2)) + body = rows.drop(1).select { |r| r[0] == "2097-03" } + + assert_equal 2, body.size + assert_equal ["csv4@example.com", "csv3@example.com"], body.map { |r| r[2] } + end + + test "to_csv escapes names containing commas" do + person = ForecastPerson.create!( + forecast_id: rand(1..2_000_000_000), + email: "comma@example.com", + first_name: "Lovelace, Ada", + last_name: "", + data: {} + ) + payout!(Contributor.create!(forecast_person: person), 100) + + parsed = CSV.parse(Stacks::Leaderboard.to_csv(limit: 5)) + + assert_includes parsed.map { |r| r[2] }, "Lovelace, Ada", + "a comma in a name must survive a CSV round-trip" + end + + test "mso_compensation scales the month average by each tier multiplier" do + tiers = Stacks::Leaderboard.mso_compensation(BigDecimal("1000")) + + assert_equal ["Heads", "SVPs", "Chiefs", "Principals"], tiers.map(&:label) + assert_equal ["0.7x", "0.85x", "1x", "1.15x"], tiers.map(&:multiplier_label), + "1x renders without a trailing .0" + assert_equal [BigDecimal("700"), BigDecimal("850"), BigDecimal("1000"), BigDecimal("1150")], + tiers.map(&:amount) + end + + test "the tier ladder steps by a uniform +0.15" do + steps = Stacks::Leaderboard::MSO_TIERS.map(&:last).each_cons(2).map { |a, b| b - a } + + assert_equal [Stacks::Leaderboard::MSO_TIER_STEP] * steps.size, steps, + "every tier should sit one even step above the one below it" + end + + test "mso_compensation handles a zero average" do + amounts = Stacks::Leaderboard.mso_compensation(BigDecimal(0)).map { |t| t.amount.to_i } + assert_equal [0] * Stacks::Leaderboard::MSO_TIERS.size, amounts + end + + test "sanitize_limit defaults, clamps, and rejects junk" do + assert_equal 5, Stacks::Leaderboard.sanitize_limit(nil) + assert_equal 5, Stacks::Leaderboard.sanitize_limit("") + assert_equal 5, Stacks::Leaderboard.sanitize_limit("not-a-number") + assert_equal 3, Stacks::Leaderboard.sanitize_limit("3") + assert_equal 10, Stacks::Leaderboard.sanitize_limit(10) + assert_equal 1, Stacks::Leaderboard.sanitize_limit("0"), "clamps below the minimum" + assert_equal 1, Stacks::Leaderboard.sanitize_limit("-4"), "clamps negatives" + assert_equal 50, Stacks::Leaderboard.sanitize_limit("999"), "clamps above the maximum" + end +end