package ads import ( "reflect" "testing" "time" ) 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} } urgent := func(name string, weight int, msgs []Message) Campaign { c := timed(name, weight, nil, nil, msgs) c.Urgent = true return c } red := &ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"} tests := []struct { name string campaigns []Campaign lang string want []ActiveCampaign wantUrgent bool }{ { 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"}}}, }, { name: "urgent preempts default and normal timed", campaigns: []Campaign{ def(msg("house")), timed("promo", 40, nil, nil, msg("promo")), urgent("alert", 50, msg("alert")), }, lang: "en", // only the urgent campaign survives; a lone weight reduces to 1. want: []ActiveCampaign{{Weight: 1, Messages: []string{"alert-en"}}}, wantUrgent: true, }, { name: "multiple urgent share the feed by gcd", campaigns: []Campaign{ def(msg("house")), urgent("a", 60, msg("a")), urgent("b", 40, msg("b")), }, lang: "en", // default and any non-urgent dropped; gcd(60,40)=20 -> 3 and 2. want: []ActiveCampaign{ {Weight: 3, Messages: []string{"a-en"}}, {Weight: 2, Messages: []string{"b-en"}}, }, wantUrgent: true, }, { name: "out-of-window urgent does not preempt", campaigns: []Campaign{ def(msg("house")), func() Campaign { c := urgent("future", 50, msg("future")); c.StartsAt = &future; return c }(), }, lang: "en", // the urgent campaign is not yet live, so the normal default feed stands. want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}}, }, { name: "colour overrides ride the active campaign", campaigns: []Campaign{ def(msg("house")), func() Campaign { c := timed("promo", 100, nil, nil, msg("promo")); c.OverrideAll = red; return c }(), }, lang: "en", want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}, OverrideAll: red}}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, gotUrgent := computeActiveSet(tt.campaigns, now, tt.lang) if !reflect.DeepEqual(got, tt.want) { t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want) } if gotUrgent != tt.wantUrgent { t.Errorf("computeActiveSet() urgent = %v, want %v", gotUrgent, tt.wantUrgent) } }) } } func TestComputeActiveSetEmpty(t *testing.T) { // No campaigns at all yields an empty (non-nil-or-nil) feed without panicking. if got, urgent := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 || urgent { t.Errorf("computeActiveSet(nil) = %#v urgent=%v, want empty non-urgent", got, urgent) } } 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) } } }