6db9178449
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Non-default campaigns gain an optional colour override (background / text / link) in two sets — one for every theme, one for the dark theme only — and an "urgent" flag. - Colours ride profile.get as six trailing FlatBuffers strings on BannerCampaign (backward-compatible). The client resolves the cascade (dark <- dark ?? all, light <- all) per rendered theme and derives the strip border from the background in JS (no CSS color-mix, for the old Android WebView floor); AdBanner applies them as inline vars scoped to the strip. - Urgent is resolved entirely server-side: while any enabled, in-window urgent campaign exists, computeActiveSet returns only the urgent campaigns and bannerFor skips the eligibility gate — so a system notice reaches every viewer (paid / hint-holding / no_banner included) and preempts the ordinary feed. No wire field; it appears on each viewer's next profile.get. - Admin console (/_gm/banners): native colour pickers + a live light/dark preview of the strip, and an urgent toggle. The default campaign stays plain, enforced by the service and a DB CHECK. Migration 00009 is additive (nullable colour columns + a bool default + all-or-nothing / hex / default-plain CHECKs) — expand-contract, rollback-safe. Docs: ARCHITECTURE §10, UI_DESIGN, FUNCTIONAL (+ru). Tests: ads unit (urgent preempt + colour validation), codec + resolver unit, gateway transcode, and integration (colour round-trip + urgent bypass against real Postgres).
240 lines
7.4 KiB
Go
240 lines
7.4 KiB
Go
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}
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|