FILE / ScuroNeko/Laniakea

scene_test.go

Исходный файл и его история в репозитории.
FILE e2444752c22c5b5edb1a191fc333c2455f44b251
Files
Laniakea/scene_test.go
T
ScuroNeko e2444752c2 rename app data API
Align docs, examples, and tests with AppData and NoData

Update handler, scene, runner, and plugin generics to the new naming

Ignore .codex and record the cleanup in the changelog
2026-03-30 16:43:59 +03:00

469 lines
13 KiB
Go

package laniakea
import (
"context"
"errors"
"testing"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
type failingSessionStore struct {
getErr error
setErr error
deleteErr error
}
func (s failingSessionStore) Get(key string) (SceneSession, error) {
return SceneSession{}, s.getErr
}
func (s failingSessionStore) Set(key string, session SceneSession) error {
return s.setErr
}
func (s failingSessionStore) Delete(key string) error {
return s.deleteErr
}
func TestPluginAddSceneRegistersScene(t *testing.T) {
plugin := NewPlugin[NoData]("wizard")
scene := NewScene[NoData]("signup")
plugin.AddScene(scene)
if got, ok := plugin.scenes["signup"]; !ok || got != scene {
t.Fatalf("scene was not registered in plugin: ok=%v got=%p want=%p", ok, got, scene)
}
if scene.PluginName != "wizard" {
t.Fatalf("unexpected plugin name on scene: got %q want %q", scene.PluginName, "wizard")
}
}
func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) {
called := false
plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
called = true
if ctx.Text != "hello there" {
t.Fatalf("unexpected scene text: got %q want %q", ctx.Text, "hello there")
}
return ctx.Exit(), nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
sceneMeta, ok := bot.findScene("signup")
if !ok {
t.Fatal("expected scene metadata to be available after plugin registration")
}
if sceneMeta.Entry != "start" {
t.Fatalf("unexpected scene entry: got %q want %q", sceneMeta.Entry, "start")
}
enterCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
sceneRuntime: bot,
}
if err := enterCtx.EnterScene("signup"); err != nil {
t.Fatalf("EnterScene returned error: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 1,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 7,
Text: "hello there",
Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)},
From: &tgapi.User{ID: 42},
},
})
if !called {
t.Fatal("expected scene step handler to be called")
}
lookupCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
}
if _, session, err := bot.findSceneSession(lookupCtx); err == nil && session.Scene != "" {
t.Fatalf("expected scene session to be removed after exit, got %#v", session)
}
}
func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) {
tests := []struct {
name string
scope SceneScope
ctx *MsgContext
}{
{
name: "nil context",
scope: SceneScopeUserChat,
ctx: nil,
},
{
name: "missing message for chat scope",
scope: SceneScopeChat,
ctx: &MsgContext{},
},
{
name: "missing from id for user scope",
scope: SceneScopeUser,
ctx: &MsgContext{},
},
{
name: "missing from id for user chat scope",
scope: SceneScopeUserChat,
ctx: &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if key, ok := buildSceneKey(tt.scope, tt.ctx); ok || key != "" {
t.Fatalf("expected invalid scene key, got key=%q ok=%v", key, ok)
}
})
}
}
func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) {
t.Run("empty entry", func(t *testing.T) {
plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup")
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
ctx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
sceneRuntime: bot,
}
err := ctx.EnterScene("signup")
if !errors.Is(err, ErrSceneEntryNotSet) {
t.Fatalf("expected ErrSceneEntryNotSet, got %v", err)
}
})
t.Run("missing entry step", func(t *testing.T) {
plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup").SetEntry("start")
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
ctx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
sceneRuntime: bot,
}
err := ctx.EnterScene("signup")
if !errors.Is(err, ErrSceneStepNotFound) {
t.Fatalf("expected ErrSceneStepNotFound, got %v", err)
}
})
}
func TestSceneContextMethodsRequireRuntime(t *testing.T) {
ctx := &MsgContext{}
if err := ctx.EnterScene("signup"); !errors.Is(err, ErrSceneRuntimeNil) {
t.Fatalf("expected ErrSceneRuntimeNil from EnterScene, got %v", err)
}
if err := ctx.EnterSceneStep("signup", "start"); !errors.Is(err, ErrSceneRuntimeNil) {
t.Fatalf("expected ErrSceneRuntimeNil from EnterSceneStep, got %v", err)
}
if err := ctx.ExitScene(); !errors.Is(err, ErrSceneRuntimeNil) {
t.Fatalf("expected ErrSceneRuntimeNil from ExitScene, got %v", err)
}
}
func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
sceneCommandCalled := false
stepCalled := false
plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
stepCalled = true
return ctx.Stay(), nil
}).
OnCommand("cancel", func(ctx *SceneContext, db NoData) (SceneResult, error) {
sceneCommandCalled = true
if ctx.Prefix != "/" {
t.Fatalf("unexpected prefix: got %q want /", ctx.Prefix)
}
if ctx.Text != "right now" {
t.Fatalf("unexpected scene command text: got %q want %q", ctx.Text, "right now")
}
if len(ctx.Args) != 2 || ctx.Args[0] != "right" || ctx.Args[1] != "now" {
t.Fatalf("unexpected scene command args: %#v", ctx.Args)
}
return ctx.Exit(), nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
enterCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
sceneRuntime: bot,
}
if err := enterCtx.EnterScene("signup"); err != nil {
t.Fatalf("EnterScene returned error: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 2,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 8,
Text: "/cancel right now",
Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)},
From: &tgapi.User{ID: 42},
},
})
if !sceneCommandCalled {
t.Fatal("expected scene command handler to be called")
}
if stepCalled {
t.Fatal("expected scene command to short-circuit the scene step")
}
}
func TestScenePassDoesNotPersistSessionData(t *testing.T) {
commandCalled := false
plugin := NewPlugin[NoData]("wizard")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
commandCalled = true
return nil
}, "ping")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
if err := ctx.SaveData(struct {
Value string `json:"value"`
}{Value: "changed"}); err != nil {
t.Fatalf("SaveData returned error: %v", err)
}
return ctx.Pass(), nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
enterCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
sceneRuntime: bot,
}
if err := enterCtx.EnterScene("signup"); err != nil {
t.Fatalf("EnterScene returned error: %v", err)
}
key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
})
if !ok {
t.Fatal("expected scene key to be built")
}
before, err := bot.sessionStore.Get(key)
if err != nil {
t.Fatalf("Get before handle returned error: %v", err)
}
if before.HasData() {
t.Fatalf("expected empty session data before handle, got %#v", before)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 3,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 9,
Text: "/ping",
Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)},
From: &tgapi.User{ID: 42},
},
})
if !commandCalled {
t.Fatal("expected normal command routing to continue after SceneActionPass")
}
after, err := bot.sessionStore.Get(key)
if err != nil {
t.Fatalf("Get after handle returned error: %v", err)
}
if after.Scene != "signup" || after.Step != "start" {
t.Fatalf("unexpected session after pass: %#v", after)
}
if after.HasData() {
t.Fatalf("expected SceneActionPass to leave session data unchanged, got %#v", after)
}
}
func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
fallbackCalled := false
plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil
}).
OnMessage(func(ctx *SceneContext, db NoData) (SceneResult, error) {
fallbackCalled = true
if ctx.Text != "hello fallback" {
t.Fatalf("unexpected fallback text: got %q want %q", ctx.Text, "hello fallback")
}
return ctx.Exit(), nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
enterCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
sceneRuntime: bot,
}
if err := enterCtx.EnterScene("signup"); err != nil {
t.Fatalf("EnterScene returned error: %v", err)
}
key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
})
if !ok {
t.Fatal("expected scene key to be built")
}
if err := bot.sessionStore.Set(key, SceneSession{Scene: "signup", Step: "unknown"}); err != nil {
t.Fatalf("Set returned error: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 4,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 10,
Text: "hello fallback",
Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)},
From: &tgapi.User{ID: 42},
},
})
if !fallbackCalled {
t.Fatal("expected scene fallback handler to be called")
}
}
func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) {
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUser, SceneScopeChat, SceneScopeUserChat},
}
if err := bot.sessionStore.Set("user_id:42", SceneSession{Scene: "signup", Step: "start"}); err != nil {
t.Fatalf("Set returned error: %v", err)
}
key, session, err := bot.findSceneSession(&MsgContext{FromID: 42})
if err != nil {
t.Fatalf("findSceneSession returned error: %v", err)
}
if key != "user_id:42" {
t.Fatalf("unexpected session key: got %q want %q", key, "user_id:42")
}
if session.Scene != "signup" || session.Step != "start" {
t.Fatalf("unexpected session: %#v", session)
}
}
func TestSceneStoreErrorsPropagate(t *testing.T) {
getErr := errors.New("get failed")
setErr := errors.New("set failed")
t.Run("find scene session get error", func(t *testing.T) {
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
sessionStore: failingSessionStore{getErr: getErr},
sceneScopePriority: []SceneScope{SceneScopeUser},
}
_, _, err := bot.findSceneSession(&MsgContext{FromID: 42})
if !errors.Is(err, getErr) {
t.Fatalf("expected getErr, got %v", err)
}
})
t.Run("apply scene result set error", func(t *testing.T) {
scene := NewScene[NoData]("signup").OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
sessionStore: failingSessionStore{setErr: setErr},
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
_, err := bot.applySceneResult(scene, &SceneContext{
MsgContext: &MsgContext{},
sess: SceneSession{Scene: "signup", Step: "start"},
key: "user_id:42:chat_id:100",
}, SceneResult{Action: SceneActionStay})
if !errors.Is(err, setErr) {
t.Fatalf("expected setErr, got %v", err)
}
})
}