package ads import ( "reflect" "testing" "time" ) func TestEligible(t *testing.T) { tests := []struct { name string paidAccount bool hintBalance int hasNoBanner bool want bool }{ {name: "free, empty wallet, no role", want: true}, {name: "paid", paidAccount: true, want: false}, {name: "has hints", hintBalance: 3, want: false}, {name: "no_banner role", hasNoBanner: true, want: false}, {name: "paid and has hints", paidAccount: true, hintBalance: 5, want: false}, {name: "no_banner overrides everything", hasNoBanner: true, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := Eligible(tt.paidAccount, tt.hintBalance, tt.hasNoBanner); got != tt.want { t.Errorf("Eligible(%v,%d,%v) = %v, want %v", tt.paidAccount, tt.hintBalance, tt.hasNoBanner, got, tt.want) } }) } } func TestComputeActiveSet(t *testing.T) { now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) past := now.Add(-24 * time.Hour) future := now.Add(24 * time.Hour) // msg builds a one-message slice with distinct bodies so resolution and // campaign identity are visible in assertions. msg := func(tag string) []Message { return []Message{{BodyEn: tag + "-en", BodyRu: tag + "-ru"}} } def := func(msgs []Message) Campaign { return Campaign{Name: "default", Weight: 100, IsDefault: true, Enabled: true, Messages: msgs} } timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign { return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs} } tests := []struct { name string campaigns []Campaign lang string want []ActiveCampaign }{ { name: "default only reduces to weight 1", campaigns: []Campaign{def(msg("house"))}, lang: "en", want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}}, }, { name: "default fills the remainder", campaigns: []Campaign{def(msg("house")), timed("promo", 30, nil, nil, msg("promo"))}, lang: "en", // timed 30, default 100-30=70; gcd 10 -> 3 and 7; timed first, default last. want: []ActiveCampaign{ {Weight: 3, Messages: []string{"promo-en"}}, {Weight: 7, Messages: []string{"house-en"}}, }, }, { name: "timed fills 100, default dropped", campaigns: []Campaign{def(msg("house")), timed("promo", 100, nil, nil, msg("promo"))}, lang: "en", want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}}}, }, { name: "timed over 100, default dropped, proportional", campaigns: []Campaign{ def(msg("house")), timed("a", 60, nil, nil, msg("a")), timed("b", 80, nil, nil, msg("b")), }, lang: "en", // default dropped (sum 140 >= 100); gcd(60,80)=20 -> 3 and 4. want: []ActiveCampaign{ {Weight: 3, Messages: []string{"a-en"}}, {Weight: 4, Messages: []string{"b-en"}}, }, }, { name: "future and past windows exclude timed", campaigns: []Campaign{ def(msg("house")), timed("future", 50, &future, nil, msg("future")), timed("past", 50, nil, &past, msg("past")), }, lang: "en", want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}}, }, { name: "active window included", campaigns: []Campaign{ def(msg("house")), timed("live", 25, &past, &future, msg("live")), }, lang: "en", // timed 25, default 75; gcd 25 -> 1 and 3. want: []ActiveCampaign{ {Weight: 1, Messages: []string{"live-en"}}, {Weight: 3, Messages: []string{"house-en"}}, }, }, { name: "campaign without messages is skipped and not counted", campaigns: []Campaign{ def(msg("house")), timed("empty", 40, nil, nil, nil), }, lang: "en", // empty campaign skipped; default fills full 100 -> reduced to 1. want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}}, }, { name: "russian bodies resolved", campaigns: []Campaign{def(msg("house"))}, lang: "ru", want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-ru"}}}, }, { name: "three timed split by gcd", campaigns: []Campaign{ def(msg("house")), timed("a", 50, nil, nil, msg("a")), timed("b", 30, nil, nil, msg("b")), timed("c", 20, nil, nil, msg("c")), }, lang: "en", // sum 100, default dropped; gcd 10 -> 5,3,2. want: []ActiveCampaign{ {Weight: 5, Messages: []string{"a-en"}}, {Weight: 3, Messages: []string{"b-en"}}, {Weight: 2, Messages: []string{"c-en"}}, }, }, { name: "campaign with multiple messages keeps order", campaigns: []Campaign{ def([]Message{{BodyEn: "one-en", BodyRu: "one-ru"}, {BodyEn: "two-en", BodyRu: "two-ru"}}), }, lang: "en", want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := computeActiveSet(tt.campaigns, now, tt.lang) if !reflect.DeepEqual(got, tt.want) { t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want) } }) } } func TestComputeActiveSetEmpty(t *testing.T) { // No campaigns at all yields an empty (non-nil-or-nil) feed without panicking. if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 { t.Errorf("computeActiveSet(nil) = %#v, want empty", got) } } func TestGCD(t *testing.T) { tests := []struct{ a, b, want int }{ {0, 5, 5}, {5, 0, 5}, {12, 18, 6}, {100, 100, 100}, {7, 13, 1}, } for _, tt := range tests { if got := gcd(tt.a, tt.b); got != tt.want { t.Errorf("gcd(%d,%d) = %d, want %d", tt.a, tt.b, got, tt.want) } } }