package events // 本文件钉住**账号在线状态迁移**的判定、归一化与落行(design/26 §2.6;切片 E7)。 // // 断言的是**库里的事实**(多了一行没有、那一行的 tier/category/platform_type/summary // 是不是预期的),不按日志文本断言 —— 与 store_test.go 同一口径。 // // 覆盖的三类口径,每一类都对应一条设计约束: // - 九种 (前态 → 后态) 组合**逐个落行**,包括涉及「未知」的那四种(§2.6 的 // `known` 三态;§1.1「绝不静默」); // - **没变就不写**(§4.3:账号状态不是逐帧写库的高频遥测,只有迁移是事实); // - **缺平台是调用方的编程错误**(ErrNoPlatform),而**缺通道码只是归一化** // (不报错、不丢类)。 import ( "context" "errors" "sync" "testing" "time" ) // accountStatusStoreStub 是只记「落了什么」的测试替身:它让「判定」与「落库」两面 // 可以分别断言(真库的端到端断言在下面用 newTestStore)。 type accountStatusStoreStub struct { mu sync.Mutex rows []Event err error calls int } func (s *accountStatusStoreStub) Record(_ context.Context, ev Event) error { s.mu.Lock() defer s.mu.Unlock() s.calls++ if s.err != nil { return s.err } s.rows = append(s.rows, ev) return nil } func (s *accountStatusStoreStub) Count(context.Context, Filter) (int64, error) { return 0, nil } func (s *accountStatusStoreStub) List(context.Context, Filter) ([]Event, error) { return nil, nil } func (s *accountStatusStoreStub) Counters(context.Context, string) ([]Counter, error) { return nil, nil } func (s *accountStatusStoreStub) Flush(context.Context) error { return nil } func (s *accountStatusStoreStub) Sweep(context.Context) (SweepStats, error) { return SweepStats{}, nil } // recorded 返回已记录的行的副本(避免调用方在锁外读被并发改写)。 func (s *accountStatusStoreStub) recorded() []Event { s.mu.Lock() defer s.mu.Unlock() return append([]Event(nil), s.rows...) } // sample 构造一条观测样本(平台固定 qq-obv11,账号 id 由用例决定)。 func sample(account string, online, known bool) StatusSample { return StatusSample{ Platform: PlatformQQ, AccountID: account, Online: online, Known: known, Source: StatusSourceProbe, } } // TestStatusTracker_九种迁移逐个落行:把 (前态 → 后态) 的**全部**组合跑一遍。 // // 为什么逐个列而不是抽样:§2.6 的价值全在「她自己的账号状态**变了**」这一件事上, // 而实现里最容易漏的恰恰是「未知 ↔ 确知」那四条边 —— 一个 `if !known { continue }` // 就会把它们整类丢掉(这正是 gateway 的 liveAccountOnline 必须缺席、而留存面 // 必须留痕的那条分界线)。 func TestStatusTracker_九种迁移逐个落行(t *testing.T) { t.Parallel() const acct = "bot-1" type step struct { online bool known bool } for _, tc := range []struct { name string seq []step prev AccountState next AccountState summary string }{ {"首次→在线", []step{{true, true}}, "", AccountStateOnline, "账号在线(首次确知)"}, {"首次→离线", []step{{false, true}}, "", AccountStateOffline, "账号离线(首次确知)"}, {"首次→未知", []step{{false, false}}, "", AccountStateUnknown, "账号状态未知(尚未探到)"}, {"在线→离线", []step{{true, true}, {false, true}}, AccountStateOnline, AccountStateOffline, "账号离线(在线→离线)"}, {"离线→在线", []step{{false, true}, {true, true}}, AccountStateOffline, AccountStateOnline, "账号上线(离线→在线)"}, {"未知→在线", []step{{false, false}, {true, true}}, AccountStateUnknown, AccountStateOnline, "账号上线(未知→在线)"}, {"未知→离线", []step{{false, false}, {false, true}}, AccountStateUnknown, AccountStateOffline, "账号离线(未知→离线)"}, {"在线→未知", []step{{true, true}, {false, false}}, AccountStateOnline, AccountStateUnknown, "账号状态未知(在线→未知)"}, {"离线→未知", []step{{false, true}, {false, false}}, AccountStateOffline, AccountStateUnknown, "账号状态未知(离线→未知)"}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() store, _ := newTestStore(t, nil) tr := NewStatusTracker(store, testNow) ctx := context.Background() // 前几步只负责把状态推到 tc.prev(它们自己也是迁移,照常落行)。 for i := 0; i < len(tc.seq)-1; i++ { if _, err := tr.Observe(ctx, sample(acct, tc.seq[i].online, tc.seq[i].known)); err != nil { t.Fatalf("铺垫第 %d 步: %v", i, err) } } last := tc.seq[len(tc.seq)-1] res, err := tr.Observe(ctx, sample(acct, last.online, last.known)) if err != nil { t.Fatalf("Observe: %v", err) } if !res.Changed || res.State != tc.next || res.Previous != tc.prev { t.Fatalf("res = {changed:%v state:%s prev:%s}, want {true %s %s}", res.Changed, res.State, res.Previous, tc.next, tc.prev) } if !res.Recorded { t.Fatalf("最后一步未落库(Recorded=false)") } if res.Event.Summary != tc.summary { t.Errorf("summary = %q, want %q", res.Event.Summary, tc.summary) } // 落行进库的字段逐列核对:T2(§2.6 高价值)+ lifecycle(§3.1 的派生关系) // + 探活通道码 + 合成 id system 的角色(无当事人的事件)。 ev := res.Event if ev.Tier != TierT2 || ev.Category != CategoryLifecycle { t.Errorf("tier/category = %s/%s, want t2/lifecycle", ev.Tier, ev.Category) } if ev.PlatformType != StatusSourceProbe { t.Errorf("platform_type = %q, want %q", ev.PlatformType, StatusSourceProbe) } if !ev.SelfRelated { t.Error("self_related = false, want true(这是她自己的账号)") } if ev.ActorID != "" || ev.ActorRole != RoleSystem { t.Errorf("actor = %q/%q, want \"\"/system(状态由平台侧上报,没有人的当事人)", ev.ActorID, ev.ActorRole) } if ev.ChannelID != "" || ev.MessageID != "" { t.Errorf("channel/message = %q/%q, want 空(账号状态不属任何会话或消息)", ev.ChannelID, ev.MessageID) } // 读回库:最后一步的那一行必须在,且 summary 逐字一致。 rows, err := store.List(ctx, Filter{Platform: PlatformQQ}) if err != nil { t.Fatalf("List: %v", err) } if len(rows) != len(tc.seq) { t.Fatalf("库里有 %d 行, want %d(每一步迁移一行)", len(rows), len(tc.seq)) } if rows[0].Summary != tc.summary { t.Errorf("库里最新一行 summary = %q, want %q", rows[0].Summary, tc.summary) } if rows[0].Tier != TierT2 || !rows[0].SelfRelated { t.Errorf("读回的行 = %+v, want t2 + self_related", rows[0]) } }) } } // TestStatusTracker_未变化不落行:同一状态连采多次只留**首行**。 // 这是 §4.3 与 §2.6 的分界线:逐帧写库是纯浪费,而迁移是事实。 func TestStatusTracker_未变化不落行(t *testing.T) { t.Parallel() store, _ := newTestStore(t, nil) tr := NewStatusTracker(store, testNow) ctx := context.Background() for i := 0; i < 5; i++ { res, err := tr.Observe(ctx, sample("bot-1", true, true)) if err != nil { t.Fatalf("第 %d 次 Observe: %v", i, err) } if want := i == 0; res.Changed != want { t.Fatalf("第 %d 次 Changed = %v, want %v", i, res.Changed, want) } } rows, err := store.List(ctx, Filter{}) if err != nil { t.Fatalf("List: %v", err) } if len(rows) != 1 { t.Fatalf("库里有 %d 行, want 1(只有首次观测是迁移)", len(rows)) } } // TestStatusTracker_缺平台是编程错误:样本没有平台 ⇒ 包装的 ErrNoPlatform, // 且**什么都不写**(既落不进行,也不在迁移表里留一个空键的假状态)。 func TestStatusTracker_缺平台是编程错误(t *testing.T) { t.Parallel() store, _ := newTestStore(t, nil) tr := NewStatusTracker(store, testNow) res, err := tr.Observe(context.Background(), StatusSample{Online: true, Known: true}) if !errors.Is(err, ErrNoPlatform) { t.Fatalf("err = %v, want wrap of ErrNoPlatform", err) } if res.Changed || res.Recorded { t.Errorf("res = %+v, want 未判定也未落库", res) } if rows, _ := store.List(context.Background(), Filter{}); len(rows) != 0 { t.Fatalf("库里 %d 行, want 0", len(rows)) } if states := tr.States(); len(states) != 0 { t.Errorf("States() = %v, want 空(坏样本不该污染迁移表)", states) } } // TestStatusTracker_通道码缺失只是归一化:空通道码不报错、不丢类, // 归一成固定兜底码写进 platform_type(§4.1:排障要能分辨「这条是谁记的」)。 func TestStatusTracker_通道码缺失只是归一化(t *testing.T) { t.Parallel() store, _ := newTestStore(t, nil) tr := NewStatusTracker(store, testNow) res, err := tr.Observe(context.Background(), StatusSample{ Platform: PlatformQQ, AccountID: "bot-1", Online: true, Known: true, }) if err != nil { t.Fatalf("Observe: %v", err) } if !res.Recorded { t.Fatal("Recorded = false, want true(缺通道码不该阻止落行)") } if res.Event.PlatformType != statusSourceFallback { t.Errorf("platform_type = %q, want %q", res.Event.PlatformType, statusSourceFallback) } } // TestStatusTracker_未接线只判定不落库:store 为 nil 时照常判迁移(调用方仍能据此 // 知道「状态变了」),但 Recorded=false —— 绝不假装写过(§4.3 的 fail-visible)。 func TestStatusTracker_未接线只判定不落库(t *testing.T) { t.Parallel() tr := NewStatusTracker(nil, testNow) res, err := tr.Observe(context.Background(), sample("bot-1", false, true)) if err != nil { t.Fatalf("Observe: %v", err) } if !res.Changed || res.State != AccountStateOffline { t.Errorf("res = %+v, want changed + offline", res) } if res.Recorded { t.Error("Recorded = true, want false(未接线就没有可查的行)") } } // TestStatusTracker_落库失败_错误上抛且不声称:§4.3 要求逐条留存的失败**必须**让 // 调用方看见(E3 据此记 WARN)。此时 Recorded 必须为 false —— 否则面板会收到一条 // 查询面永远查不到的幽灵帧。 func TestStatusTracker_落库失败_错误上抛且不声称(t *testing.T) { t.Parallel() boom := errors.New("db 挂了") stub := &accountStatusStoreStub{err: boom} tr := NewStatusTracker(stub, testNow) res, err := tr.Observe(context.Background(), sample("bot-1", true, true)) if err == nil || !errors.Is(err, boom) { t.Fatalf("err = %v, want wrap of %v", err, boom) } if res.Recorded { t.Error("Recorded = true, want false(落库失败了)") } if !res.Changed { t.Error("Changed = false, want true(判定与落库是两件事)") } if n := len(stub.recorded()); n != 0 { t.Errorf("替身收到 %d 行, want 0", n) } } // TestStatusTracker_并发观测同一迁移只落一行:8 路并发观测同一个账号的同一次迁移。 // 判定表在临界区内读写,因此只能有一个赢家 —— 多落几行就等于「一次掉线写了 N 行」。 // (刻意只起 8 个 goroutine:AGENTS §10 的压测纪律要求有界,不堆进程。) func TestStatusTracker_并发观测同一迁移只落一行(t *testing.T) { t.Parallel() store, _ := newTestStore(t, nil) tr := NewStatusTracker(store, testNow) const n = 8 var wg sync.WaitGroup changed := make([]bool, n) wg.Add(n) for i := 0; i < n; i++ { go func(i int) { defer wg.Done() res, err := tr.Observe(context.Background(), sample("bot-1", true, true)) if err != nil { t.Errorf("Observe(%d): %v", i, err) return } changed[i] = res.Changed }(i) } wg.Wait() winners := 0 for _, c := range changed { if c { winners++ } } if winners != 1 { t.Errorf("Changed 为真的路数 = %d, want 1", winners) } rows, err := store.List(context.Background(), Filter{}) if err != nil { t.Fatalf("List: %v", err) } if len(rows) != 1 { t.Fatalf("库里有 %d 行, want 1(同一迁移只该落一行)", len(rows)) } } // TestStatusTracker_多账号互不影响:两个账号各自迁移,行数与账号对应, // 绝不因为「同平台」把它们糊成一行。 func TestStatusTracker_多账号互不影响(t *testing.T) { t.Parallel() store, _ := newTestStore(t, nil) tr := NewStatusTracker(store, testNow) ctx := context.Background() mustObserve(t, tr, sample("bot-1", true, true)) mustObserve(t, tr, sample("bot-2", false, true)) // 第二轮的三个观测都不改变状态 ⇒ 不落行。 mustObserve(t, tr, sample("bot-1", true, true)) mustObserve(t, tr, sample("bot-2", false, true)) rows, err := store.List(ctx, Filter{}) if err != nil { t.Fatalf("List: %v", err) } if len(rows) != 2 { t.Fatalf("库里有 %d 行, want 2", len(rows)) } ids := map[string]bool{rows[0].AccountID: true, rows[1].AccountID: true} if !ids["bot-1"] || !ids["bot-2"] { t.Errorf("account_id = %v, want bot-1 与 bot-2 各一行", ids) } if states := tr.States(); states[PlatformQQ+"/bot-1"] != AccountStateOnline || states[PlatformQQ+"/bot-2"] != AccountStateOffline { t.Errorf("States() = %v, want bot-1 在线 / bot-2 离线", states) } } // TestStatusSummaries_九种组合逐条有文案:文案表必须覆盖全部 (前态, 后态) 组合且每一条 // 非空 —— 「漏了一种」在这里变红,而不是悄悄落一行空 summary 进库(空摘要等于没留痕)。 func TestStatusSummaries_九种组合逐条有文案(t *testing.T) { t.Parallel() table := statusSummaries() if n := len(table); n != 9 { t.Errorf("文案表 %d 条, want 9(3 种首次观测 + 3×2 种迁移)", n) } prevStates := []AccountState{"", AccountStateOnline, AccountStateOffline, AccountStateUnknown} nextStates := []AccountState{AccountStateOnline, AccountStateOffline, AccountStateUnknown} for _, prev := range prevStates { for _, next := range nextStates { if prev == next { continue // 同态不构成迁移 } if s := table[statusSummaryKey(prev, next)]; s == "" { t.Errorf("(%q → %q) 没有固定文案,want 一句话常量", prev, next) } } } } // mustObserve 是「这一步必须成功」的简写(失败直接结束用例)。 func mustObserve(t *testing.T, tr *StatusTracker, s StatusSample) StatusResult { t.Helper() res, err := tr.Observe(context.Background(), s) if err != nil { t.Fatalf("Observe(%s/%s): %v", s.Platform, s.AccountID, err) } return res } // TestStatusTracker_时钟零值时由存储层补当前时刻:CreatedAt 留零值(任何真实写入方 // 都不该自己造时刻),落库后读回来必须是一个**非零**时刻,而不是 1970(1970 会被 // 保留期立刻扫掉 —— 那正是「刚发生的证据反被丢掉」)。 func TestStatusTracker_时钟零值时由存储层补当前时刻(t *testing.T) { t.Parallel() store, _ := newTestStore(t, nil) tr := NewStatusTracker(store, func() time.Time { return time.Time{} }) res := mustObserve(t, tr, sample("bot-1", true, true)) if !res.Event.CreatedAt.IsZero() { t.Fatalf("CreatedAt = %v, want 零值交给存储层填", res.Event.CreatedAt) } rows, err := store.List(context.Background(), Filter{}) if err != nil { t.Fatalf("List: %v", err) } if len(rows) != 1 || rows[0].CreatedAt.IsZero() { t.Fatalf("读回的行 = %+v, want 一个非零 created_at", rows) } }