internal/chat: мультиканальный Router (UserID-сессии, routing, pending Ask/ответ) + Channel интерфейс
All checks were successful
build-test / build (push) Successful in 41s

This commit is contained in:
Hermes
2026-08-14 20:33:04 +05:00
parent 4ebafc48d0
commit c9d69594cc
6 changed files with 452 additions and 0 deletions

View File

@@ -0,0 +1,156 @@
package chat
import (
"context"
"errors"
"testing"
)
const (
uidA UserID = "u-a"
uidB UserID = "u-b"
tg Address = "tg://123"
tui Address = "tui://local"
)
// fakeOnMsg — тест-колбэк, копящий входящие.
type fakeOnMsg struct{ got []Incoming }
func (f *fakeOnMsg) h(inc Incoming) { f.got = append(f.got, inc) }
func TestRouter_AttachAndIncoming(t *testing.T) {
cb := &fakeOnMsg{}
r := NewRouter(cb.h)
tgCh := newFakeChannel(tg)
if err := r.Attach(tgCh); err != nil {
t.Fatalf("Attach: %v", err)
}
tgCh.emit(uidA, tg, "привет")
if len(cb.got) != 1 {
t.Fatalf("handler got %d, want 1", len(cb.got))
}
got := cb.got[0]
if got.UserID != uidA || got.Address != tg || got.Msg.Text != "привет" {
t.Errorf("incoming = %+v", got)
}
}
func TestRouter_AttachNil(t *testing.T) {
r := NewRouter(nil)
if err := r.Attach(nil); err == nil {
t.Fatal("Attach(nil) должен вернуть ошибку")
}
}
func TestRouter_Send_NoRoute(t *testing.T) {
r := NewRouter(nil)
// M1: нет маршрута → Send no-op, nil
if err := r.Send(context.Background(), uidA, Message{Text: "x"}); err != nil {
t.Fatalf("Send без маршрута: %v", err)
}
}
func TestRouter_Send_UsesCurrentRoute(t *testing.T) {
cb := &fakeOnMsg{}
r := NewRouter(cb.h)
tgCh := newFakeChannel(tg)
_ = r.Attach(tgCh)
tgCh.emit(uidA, tg, "hi") // устанавливает маршрут
if err := r.Send(context.Background(), uidA, Message{Text: "отв"}); err != nil {
t.Fatalf("Send: %v", err)
}
if tgCh.sentCount() != 1 {
t.Fatalf("sent = %d, want 1", tgCh.sentCount())
}
if tgCh.sent[0].Msg.Text != "отв" {
t.Errorf("msg = %q", tgCh.sent[0].Msg.Text)
}
}
func TestRouter_SwitchChannel_Continues(t *testing.T) {
cb := &fakeOnMsg{}
r := NewRouter(cb.h)
tgCh := newFakeChannel(tg)
tuiCh := newFakeChannel(tui)
_ = r.Attach(tgCh)
_ = r.Attach(tuiCh)
// начал в TG
tgCh.emit(uidA, tg, "hi")
// продолжил в GUI
tuiCh.emit(uidA, tui, "продолжаю тут")
if tgCh.sentCount() != 0 || tuiCh.sentCount() != 0 {
t.Fatal("до Send ничего не шлём")
}
// ответ должен уйти в последний канал (GUI)
_ = r.Send(context.Background(), uidA, Message{Text: "отв"})
if tuiCh.sentCount() != 1 {
t.Errorf("tui sent = %d, want 1 (последний маршрут)", tuiCh.sentCount())
}
if tgCh.sentCount() != 0 {
t.Errorf("tg sent = %d, want 0", tgCh.sentCount())
}
}
func TestRouter_Ask_PendingThenAnswer(t *testing.T) {
cb := &fakeOnMsg{}
r := NewRouter(cb.h)
tgCh := newFakeChannel(tg)
_ = r.Attach(tgCh)
tgCh.emit(uidA, tg, "hi")
prompt := Message{Text: "Как зовут?", Options: []Option{{ID: "a", Label: "Анна"}}}
if err := r.Ask(context.Background(), uidA, prompt); err != nil {
t.Fatalf("Ask: %v", err)
}
if _, ok := r.Pending(uidA); !ok {
t.Fatal("pending не открыт")
}
// повторный Ask → M3
if err := r.Ask(context.Background(), uidA, prompt); !errors.Is(err, ErrWaitingAnswer) {
t.Fatalf("второй Ask err = %v, want ErrWaitingAnswer", err)
}
// ответ с того же адреса потребляет pending
tgCh.emit(uidA, tg, "Анна")
if _, ok := r.Pending(uidA); ok {
t.Fatal("pending должен быть закрыт после ответа")
}
if len(cb.got) != 2 {
t.Fatalf("handler got %d, want 2 (hi + ответ)", len(cb.got))
}
if cb.got[1].Msg.QuestionID == "" {
t.Error("ответ должен нести QuestionID вопроса")
}
}
func TestRouter_Ask_NoRoute(t *testing.T) {
r := NewRouter(nil)
if err := r.Ask(context.Background(), uidB, Message{Text: "q"}); !errors.Is(err, ErrRouteNotFound) {
t.Fatalf("Ask без маршрута err = %v, want ErrRouteNotFound", err)
}
}
func TestRouter_Ask_PendingNotConsumedFromOtherAddr(t *testing.T) {
cb := &fakeOnMsg{}
r := NewRouter(cb.h)
tgCh := newFakeChannel(tg)
tuiCh := newFakeChannel(tui)
_ = r.Attach(tgCh)
_ = r.Attach(tuiCh)
tgCh.emit(uidA, tg, "hi")
if err := r.Ask(context.Background(), uidA, Message{Text: "q"}); err != nil {
t.Fatalf("Ask: %v", err)
}
// ответ из ДРУГОГО канала → это новый message, pending НЕ потребляется
tuiCh.emit(uidA, tui, "ответ из gui")
if _, ok := r.Pending(uidA); !ok {
t.Fatal("pending должен остаться (ответ из другого адреса)")
}
}