0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 13:25:19 +08:00
discourse/spec/services/admin_dashboard_engagement_spec.rb
chapoi bf0128fa86
UX: Trust level section functional redesign (#41231)
The engagement dashboard's "Trust level pipeline" showed each level's
membership
plus direction-blind "moves in / moves out" counts, colored by
**position**
(in vs. out). That mislabeled healthy movement: a member graduating
*out* of Tiers, especially New,
rendered as a red ↓, reading as negative when it's the best possible
outcome.

This PR reframes the widget as a **directional arrivals funnel** with a
focus on the flow, and specifically looks at promoted/demoted-in
  
  ## Design thinking

- **Direction, not position.** Trust-level movement has a meaningful
axis —
up (promotion) is good, down (demotion) is not. We split every move into
`promoted_in` and `demoted_in`, drop the double bars, and colour by
direction: if more promotions => green, if more demotions => red.
Leaving New is now correctly green.
- **An "arrivals" funnel.** Each rung answers one consistent question:
*how many
members arrived here this period?* For every bar it's tracked the same
way + we add an extra label for the tier that is set as the `Default
trust level`.
- **Sign-ups ≠ promotions.** Signups are excluded from both the trend
and the bar scale. It's volume would otherwise often dwarf the other
bars.

  ## Backend — `reports/trust_level_pipeline.rb`

- **Snapshot** per level: `User.real.group(:trust_level).count` + share.
No date
    filter — it's the current distribution, not a period metric.
- **Directional arrivals** from `user_histories` (`change_trust_level` +
    `auto_trust_level_change`) within the period: `promoted_in`,
    `demoted_in` per level.
- **Sign-ups**: real users created in the period, attributed to the
entry level
(`SiteSetting.default_trust_level`). Not counted as trust-level moves.
- **Trend** (`prev_period`): net = promotions − demotions across the
ladder
    (sign-ups excluded) → `climbing` / `dropping` / `stable`.

## Known limitation
  
The `Default invitee trust level` is not taken into account, which means
invitees can be counted at the wrong rung.
However, accounting for it correctly means distinguishing invited from
organic sign-ups (joining through invites, and handling that a user's
trust level may have moved since they joined), which introduces a second
entry point. That breaks the widget's core simplification and adds
complexity. Overall I'm expecting the % of invitees to usually not
meaningfully muddle the representation of the pipeline flow.
2026-07-02 13:31:28 +08:00

288 lines
10 KiB
Ruby
Vendored

# frozen_string_literal: true
describe AdminDashboardEngagement do
describe ".build" do
before do
freeze_time(Time.zone.local(2026, 4, 28, 12, 0, 0))
Discourse.cache.clear
end
it "returns a kpis array keyed by report type" do
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(result[:kpis]).to be_an(Array)
types = result[:kpis].map { |k| k[:type] }
expect(types).to include(:dau_mau, :daily_engaged_users, :new_signups)
end
it "computes value, previous_value and percent_change for new_signups" do
Fabricate(:user, created_at: Time.zone.local(2026, 4, 10))
Fabricate(:user, created_at: Time.zone.local(2026, 4, 15))
Fabricate(:user, created_at: Time.zone.local(2026, 3, 10))
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
signups = result[:kpis].find { |k| k[:type] == :new_signups }
expect(signups[:value]).to eq(2)
expect(signups[:previous_value]).to eq(1)
expect(signups[:percent_change]).to eq(100.0)
end
it "emits report_type and report_query for drill-down" do
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
engaged = result[:kpis].find { |k| k[:type] == :daily_engaged_users }
expect(engaged[:report_type]).to eq("daily_engaged_users")
expect(engaged[:report_query]).to eq(start_date: "2026-04-01", end_date: "2026-04-28")
end
it "averages daily_engaged_users and reports a decline when daily engagement falls" do
engaged_now = Fabricate(:user, created_at: Time.zone.local(2026, 1, 1))
Fabricate(
:user_action,
user: engaged_now,
action_type: UserAction::LIKE,
created_at: Time.zone.local(2026, 4, 23, 12),
)
Fabricate(
:user_action,
user: engaged_now,
action_type: UserAction::LIKE,
created_at: Time.zone.local(2026, 4, 24, 12),
)
4.times do
engaged_before = Fabricate(:user, created_at: Time.zone.local(2026, 1, 1))
Fabricate(
:user_action,
user: engaged_before,
action_type: UserAction::LIKE,
created_at: Time.zone.local(2026, 4, 16, 12),
)
Fabricate(
:user_action,
user: engaged_before,
action_type: UserAction::LIKE,
created_at: Time.zone.local(2026, 4, 17, 12),
)
end
result = described_class.build(start_date: "2026-04-22", end_date: "2026-04-28")
engaged = result[:kpis].find { |k| k[:type] == :daily_engaged_users }
expect(engaged[:value]).to eq(1.0)
expect(engaged[:previous_value]).to eq(4.0)
expect(engaged[:percent_change]).to eq(-75.0)
expect(result[:headline][:key]).not_to end_with("healthy_growth")
end
it "falls back to a default 30-day window when params are blank" do
result = described_class.build(start_date: nil, end_date: nil)
expect(result[:kpis]).to be_an(Array)
expect(result[:kpis]).not_to be_empty
end
it "falls back to defaults when params are unparseable" do
result = described_class.build(start_date: "garbage", end_date: "also-garbage")
expect(result[:kpis]).to be_an(Array)
expect(result[:kpis]).not_to be_empty
end
it "ignores unicode garbage in date params" do
result = described_class.build(start_date: "字字字", end_date: "字字字")
expect(result[:kpis]).to be_an(Array)
expect(result[:kpis]).not_to be_empty
end
it "skips a KPI when its report errors out" do
original = Report.method(:find)
Report.define_singleton_method(:find) do |type, *args, **kwargs|
if type == "signups"
r = original.call(type, *args, **kwargs)
r.error = :timeout
r
else
original.call(type, *args, **kwargs)
end
end
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(result[:kpis].map { |k| k[:type] }).not_to include(:new_signups)
ensure
Report.define_singleton_method(:find, &original)
end
describe "posters" do
it "includes the posters block with rows and total" do
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
posters = result[:posters]
expect(posters[:rows].map { |r| r[:type] }).to eq(%i[new_members returning staff])
expect(posters).to have_key(:total)
end
it "honours category visibility when current_user is a moderator" do
moderator = Fabricate(:moderator)
returning_poster = Fabricate(:user, created_at: Time.zone.local(2026, 3, 1))
private_group = Fabricate(:group)
private_cat = Fabricate(:private_category, group: private_group, read_restricted: true)
topic = Fabricate(:topic, category: private_cat)
Fabricate(
:post,
user: returning_poster,
topic: topic,
created_at: Time.zone.local(2026, 4, 10),
)
result =
described_class.build(
start_date: "2026-04-01",
end_date: "2026-04-28",
current_user: moderator,
)
expect(result[:posters][:total]).to eq(0)
end
it "lets an admin see posts in restricted categories" do
admin = Fabricate(:admin)
returning_poster = Fabricate(:user, created_at: Time.zone.local(2026, 3, 1))
private_group = Fabricate(:group)
private_cat = Fabricate(:private_category, group: private_group, read_restricted: true)
topic = Fabricate(:topic, category: private_cat)
Fabricate(
:post,
user: returning_poster,
topic: topic,
created_at: Time.zone.local(2026, 4, 10),
)
result =
described_class.build(
start_date: "2026-04-01",
end_date: "2026-04-28",
current_user: admin,
)
expect(result[:posters][:total]).to eq(1)
end
end
describe "activity_by_category" do
it "includes the activity_by_category block with rows and total" do
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
activity = result[:activity_by_category]
expect(activity).to have_key(:rows)
expect(activity).to have_key(:total)
end
it "honours category visibility when current_user is a moderator" do
moderator = Fabricate(:moderator)
private_group = Fabricate(:group)
private_cat = Fabricate(:private_category, group: private_group, read_restricted: true)
Fabricate(:topic, category: private_cat, created_at: Time.zone.local(2026, 4, 10))
result =
described_class.build(
start_date: "2026-04-01",
end_date: "2026-04-28",
current_user: moderator,
)
ids = result[:activity_by_category][:rows].map { |r| r[:category_id] }
expect(ids).not_to include(private_cat.id)
end
it "lets an admin see restricted categories" do
admin = Fabricate(:admin)
private_group = Fabricate(:group)
private_cat = Fabricate(:private_category, group: private_group, read_restricted: true)
Fabricate(:topic, category: private_cat, created_at: Time.zone.local(2026, 4, 10))
result =
described_class.build(
start_date: "2026-04-01",
end_date: "2026-04-28",
current_user: admin,
)
ids = result[:activity_by_category][:rows].map { |r| r[:category_id] }
expect(ids).to include(private_cat.id)
end
end
describe "trust_level_pipeline" do
it "includes per-TL rows, a trend object, and total_members" do
Fabricate(:user, trust_level: TrustLevel[1])
Fabricate(:user, trust_level: TrustLevel[2])
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
pipeline = result[:trust_level_pipeline]
expect(pipeline[:rows].length).to eq(5)
expect(pipeline[:rows].first).to include(
:trust_level,
:count,
:share,
:promoted_in,
:demoted_in,
:signups,
)
expect(pipeline[:trend]).to include(:direction, :net)
expect(pipeline[:total_members]).to be >= 2
end
end
describe "headline" do
def stub_kpis(signups:, dau: 0, engaged: 0)
described_class
.any_instance
.stubs(:build_kpis)
.returns(
[
{ type: :dau_mau, percent_change: dau },
{ type: :new_signups, percent_change: signups },
{ type: :daily_engaged_users, percent_change: engaged },
],
)
end
it "returns healthy_growth when every metric is non-negative and at least one is positive" do
stub_kpis(signups: 12, dau: 3, engaged: 5)
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(result[:headline][:key]).to end_with("healthy_growth")
end
it "returns declining when every metric is non-positive and at least one is negative" do
stub_kpis(signups: -8, dau: -2, engaged: -5)
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(result[:headline][:key]).to end_with("declining")
end
it "returns engaged_but_shrinking when stickiness is up but engagement or signups fell" do
stub_kpis(signups: -5, dau: 2, engaged: -3)
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(result[:headline][:key]).to end_with("engaged_but_shrinking")
end
it "returns growing_but_distracted when sign-ups rose but stickiness slipped" do
stub_kpis(signups: 10, dau: -4, engaged: 0)
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(result[:headline][:key]).to end_with("growing_but_distracted")
end
it "returns no_signal when every metric has no change" do
stub_kpis(signups: 0, dau: 0, engaged: 0)
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(result[:headline][:key]).to end_with("no_signal")
end
it "returns mixed when stickiness fell, sign-ups flat, but engagement rose" do
stub_kpis(signups: 0, dau: -3, engaged: 4)
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(result[:headline][:key]).to end_with("mixed")
end
end
end
end