1c06d1d0d1
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Stand up the internal chip/benefit mechanic behind the narrow payments interface: context-aware balances and benefits, an atomic chip spend, admin grants as zero-price value sales, the one-directional store-compliance gate (VK/TG same- origin only, web draws direct→vk→tg, VK-iOS frozen, untrusted fail-closed), and per-origin hint and no-ads application with term stacking. Reads are served from an in-process, account-keyed write-through cache (mirroring the suspension gate), so hot paths issue no query to the payments schema. Flip the online-game hint wallet and the ad-banner suppression from the deprecated accounts.hint_balance / paid_account columns to the payments benefit (a hint balance no longer suppresses the banner — only a no-ads benefit does), and fold chip segments and benefits by origin on account merge, inside the merge tx. Add the GET/POST /api/v1/user/wallet edge chain (REST → Connect → FlatBuffers) plus its codec unit test; no wallet UI yet. Bring the frozen owner decisions log into the repo at docs/PAYMENTS_DECISIONS_ru.md (it was untracked under .vscode) and reference it from PLAN.md; record the read-cache design and the present-sources interface in PLAN.md and docs/PAYMENTS.md (+ RU mirror).
216 lines
6.6 KiB
Go
216 lines
6.6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|