add questions

This commit is contained in:
2026-09-01 01:01:37 +07:00
parent 4431a4d491
commit 69666ee264
23 changed files with 4151 additions and 368 deletions
+30
View File
@@ -0,0 +1,30 @@
package game_service
import "evening_detective_server/internal/modules/storytelling"
// Answer — ответ команды на вопрос дела (для публичного API команды).
type Answer struct {
QuestionCode string
Text string
}
// AnswerScore — ответ команды с баллом организатора (для панели оценки).
type AnswerScore struct {
QuestionCode string
Text string
// Score — балл; nil — ещё не оценено.
Score *int
}
// TeamAnswers — ответы одной команды игры.
type TeamAnswers struct {
Team *Team
Answers []AnswerScore
}
// GameAnswers — полная картина для оценки организатором: вопросы сценария
// (с правильными ответами) и ответы всех команд с баллами.
type GameAnswers struct {
Questions []*storytelling.Question
Teams []TeamAnswers
}
+178
View File
@@ -2,13 +2,16 @@ package game_service
import (
"context"
"errors"
"evening_detective_server/internal/modules/cleaner"
"evening_detective_server/internal/modules/password_generator"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/actions_repo"
"evening_detective_server/internal/repos/applications_repo"
"evening_detective_server/internal/repos/games_repo"
"evening_detective_server/internal/repos/scenarios_repo"
"evening_detective_server/internal/repos/team_answers_repo"
"evening_detective_server/internal/repos/teams_repo"
"evening_detective_server/internal/services/scenarios_service"
"time"
@@ -24,6 +27,7 @@ type GameService struct {
storyteller storytelling.IStory
actionsRepo *actions_repo.ActionsRepo
applicationsRepo *applications_repo.ApplicationsRepo
answersRepo *team_answers_repo.TeamAnswersRepo
cleaner cleaner.ICleaner
}
@@ -37,6 +41,7 @@ func NewGameService(
storyteller storytelling.IStory,
actionsRepo *actions_repo.ActionsRepo,
applicationsRepo *applications_repo.ApplicationsRepo,
answersRepo *team_answers_repo.TeamAnswersRepo,
cleaner cleaner.ICleaner,
) *GameService {
return &GameService{
@@ -49,6 +54,7 @@ func NewGameService(
storyteller: storyteller,
actionsRepo: actionsRepo,
applicationsRepo: applicationsRepo,
answersRepo: answersRepo,
cleaner: cleaner,
}
}
@@ -306,3 +312,175 @@ func (s *GameService) DeleteLastTeamAction(ctx context.Context, teamId int) erro
return s.actionsRepo.DeleteLastActionByTeamId(ctx, teamId)
}
var (
// ErrGameFinished — после завершения игры ответы команд больше нельзя
// изменять: организатор оценивает их в панели игры.
ErrGameFinished = errors.New("Игра завершена, ответы больше нельзя изменять")
// ErrGameNotStarted — игра ещё не начата, отвечать на вопросы нельзя.
ErrGameNotStarted = errors.New("Игра не начата, отвечать на вопросы нельзя")
// ErrQuestionNotFound — вопрос с таким кодом отсутствует в сценарии игры.
ErrQuestionNotFound = errors.New("Вопрос не найден")
)
// getTeamAndGame проверяет команду по паролю и возвращает её вместе с игрой.
func (s *GameService) getTeamAndGame(ctx context.Context, teamId int, password string) (*repos.Team, *repos.Game, error) {
team, err := s.teamsRepo.GetTeamByID(ctx, teamId)
if err != nil {
return nil, nil, err
}
if team.Password != password {
return nil, nil, teams_repo.ErrTeamNotFound
}
game, err := s.gamesRepo.GetGameByID(ctx, team.GameId)
if err != nil {
return nil, nil, err
}
return team, game, nil
}
// getScenarioQuestionsByCode возвращает вопросы сценария игры с индексацией по коду.
func (s *GameService) getScenarioQuestionsByCode(ctx context.Context, scenarioId int) ([]*storytelling.Question, map[string]*storytelling.Question, error) {
scenario, err := s.scenarioService.GetFullScenarioByID(ctx, scenarioId)
if err != nil {
return nil, nil, err
}
byCode := make(map[string]*storytelling.Question, len(scenario.Story.Questions))
for _, question := range scenario.Story.Questions {
byCode[question.Code] = question
}
return scenario.Story.Questions, byCode, nil
}
// GetTeamAnswers возвращает ответы команды на вопросы дела (без баллов:
// баллы видны только организаторам). Ответы на удалённые из сценария
// вопросы не возвращаются.
func (s *GameService) GetTeamAnswers(ctx context.Context, teamId int, password string) ([]Answer, error) {
_, game, err := s.getTeamAndGame(ctx, teamId, password)
if err != nil {
return nil, err
}
_, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId)
if err != nil {
return nil, err
}
teamAnswers, err := s.answersRepo.GetAnswersByTeamID(ctx, teamId)
if err != nil {
return nil, err
}
answers := make([]Answer, 0, len(teamAnswers))
for _, teamAnswer := range teamAnswers {
if _, ok := byCode[teamAnswer.QuestionCode]; !ok {
continue
}
answers = append(answers, Answer{
QuestionCode: teamAnswer.QuestionCode,
Text: teamAnswer.Text,
})
}
return answers, nil
}
// SubmitTeamAnswers сохраняет ответы команды на вопросы дела (upsert).
// Доступно только пока игра идёт (статусы run/pause): после завершения
// ответы фиксируются для оценки организатором. Переданные коды должны
// существовать в сценарии игры; пустая строка очищает ответ.
func (s *GameService) SubmitTeamAnswers(ctx context.Context, teamId int, password string, answers []Answer) error {
_, game, err := s.getTeamAndGame(ctx, teamId, password)
if err != nil {
return err
}
switch game.Status {
case "run", "pause":
// игра идёт — можно отвечать
default:
if game.Status == "finish" {
return ErrGameFinished
}
return ErrGameNotStarted
}
_, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId)
if err != nil {
return err
}
rows := make([]repos.TeamAnswer, 0, len(answers))
for _, answer := range answers {
if _, ok := byCode[answer.QuestionCode]; !ok {
return ErrQuestionNotFound
}
rows = append(rows, repos.TeamAnswer{
QuestionCode: answer.QuestionCode,
Text: answer.Text,
})
}
return s.answersRepo.UpsertAnswers(ctx, teamId, rows)
}
// GetGameAnswers возвращает полную картину для оценки организатором:
// вопросы сценария с правильными ответами и ответы всех команд с баллами.
// Ответы на удалённые из сценария вопросы не возвращаются (как в
// GetTeamAnswers).
func (s *GameService) GetGameAnswers(ctx context.Context, gameId int) (*GameAnswers, error) {
game, err := s.gamesRepo.GetGameByID(ctx, gameId)
if err != nil {
return nil, err
}
questions, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId)
if err != nil {
return nil, err
}
teams, err := s.teamsRepo.GetTeamsByGameID(ctx, gameId)
if err != nil {
return nil, err
}
rows, err := s.answersRepo.GetAnswersByGameID(ctx, gameId)
if err != nil {
return nil, err
}
answersByTeam := make(map[int][]AnswerScore, len(teams))
for _, row := range rows {
if _, ok := byCode[row.QuestionCode]; !ok {
continue
}
score := row.Score
answersByTeam[row.TeamId] = append(answersByTeam[row.TeamId], AnswerScore{
QuestionCode: row.QuestionCode,
Text: row.Text,
Score: score,
})
}
teamAnswers := make([]TeamAnswers, 0, len(teams))
for _, team := range teams {
teamAnswers = append(teamAnswers, TeamAnswers{
Team: mapTeam(team),
Answers: answersByTeam[team.ID],
})
}
return &GameAnswers{
Questions: questions,
Teams: teamAnswers,
}, nil
}
// SetTeamAnswerScore выставляет балл команде за ответ на вопрос дела;
// score == nil сбрасывает балл. Балл может быть любым целым числом
// (в т.ч. отрицательным — штрафные баллы). Вопрос должен существовать
// в сценарии игры.
func (s *GameService) SetTeamAnswerScore(ctx context.Context, teamId int, questionCode string, score *int) error {
team, err := s.teamsRepo.GetTeamByID(ctx, teamId)
if err != nil {
return err
}
game, err := s.gamesRepo.GetGameByID(ctx, team.GameId)
if err != nil {
return err
}
_, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId)
if err != nil {
return err
}
if _, ok := byCode[questionCode]; !ok {
return ErrQuestionNotFound
}
return s.answersRepo.SetAnswerScore(ctx, teamId, questionCode, score)
}
+102 -1
View File
@@ -31,6 +31,7 @@ const defaultCallTimeout = 10 * time.Second
type GamePlayer interface {
GetTeamActions(ctx context.Context, teamId int, password string) (*storytelling.Story, *game_service.Game, error)
AddTeamAction(ctx context.Context, teamId int, password, actionCode string) error
SubmitTeamAnswers(ctx context.Context, teamId int, password string, answers []game_service.Answer) error
}
// MCPService собирает MCP-сервер с игровыми инструментами. Сервис не
@@ -52,7 +53,7 @@ func (s *MCPService) Server() *server.MCPServer {
srv := server.NewMCPServer(
"evening-detective-mcp",
"0.1.0",
server.WithInstructions("Инструменты для игры «Вечерний детектив»: подключитесь к игре по ссылке (connect) — организатор выдаёт ссылку вида /team-story/{id}?password=..., — затем смотрите историю команды (get_team_story) и делайте ходы (make_move) по кодам точек сценария."),
server.WithInstructions("Инструменты для игры «Вечерний детектив»: подключитесь к игре по ссылке (connect) — организатор выдаёт ссылку вида /team-story/{id}?password=..., — затем смотрите историю команды (get_team_story), делайте ходы (make_move) по кодам точек сценария и отвечайте на вопросы дела (submit_answers)."),
)
srv.AddTool(
@@ -85,6 +86,34 @@ func (s *MCPService) Server() *server.MCPServer {
s.handleMakeMove,
)
srv.AddTool(
mcp.NewToolWithRawSchema(
"submit_answers",
"Отправить ответы команды на вопросы дела (список вопросов — в истории команды через get_team_story). Работает, пока игра идёт; после завершения игры ответы изменить нельзя. После успешной отправки возвращает обновлённую историю команды. Команда идентифицируется паролем.",
json.RawMessage(`{
"type": "object",
"properties": {
"team_id": {"type": "number", "description": "ID команды"},
"password": {"type": "string", "description": "Пароль команды"},
"answers": {
"type": "array",
"description": "Ответы команды на вопросы дела; переданные коды перезаписываются, пустая строка очищает ответ",
"items": {
"type": "object",
"properties": {
"questionCode": {"type": "string", "description": "Код вопроса из истории команды"},
"answer": {"type": "string", "description": "Текст ответа команды"}
},
"required": ["questionCode", "answer"]
}
}
},
"required": ["team_id", "password", "answers"]
}`),
),
s.handleSubmitAnswers,
)
return srv
}
@@ -248,6 +277,78 @@ func (s *MCPService) handleMakeMove(ctx context.Context, req mcp.CallToolRequest
return mcp.NewToolResultText(jsonBody), nil
}
// submitAnswersArgs — разобранные аргументы инструмента submit_answers.
type submitAnswersArgs struct {
teamID int64
password string
answers []game_service.Answer
}
// parseSubmitAnswersArgs разбирает аргументы инструмента submit_answers:
// answers — массив объектов {questionCode, answer} (JSON-массив приходит
// как []any с map[string]any элементами). Ключ questionCode совпадает с
// REST JSON (camelCase) — единый формат для клиентов.
func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) {
teamID, ok := argInt64(args["team_id"])
if !ok {
return submitAnswersArgs{}, fmt.Errorf("требуется team_id (число)")
}
password, _ := args["password"].(string)
if password == "" {
return submitAnswersArgs{}, fmt.Errorf("требуется password")
}
rawAnswers, ok := args["answers"].([]any)
if !ok {
return submitAnswersArgs{}, fmt.Errorf("требуется answers (массив ответов)")
}
answers := make([]game_service.Answer, 0, len(rawAnswers))
for i, raw := range rawAnswers {
item, ok := raw.(map[string]any)
if !ok {
return submitAnswersArgs{}, fmt.Errorf("answers[%d]: ожидается объект {questionCode, answer}", i)
}
questionCode, _ := item["questionCode"].(string)
answerText, _ := item["answer"].(string)
if questionCode == "" {
return submitAnswersArgs{}, fmt.Errorf("answers[%d]: требуется questionCode", i)
}
answers = append(answers, game_service.Answer{
QuestionCode: questionCode,
Text: answerText,
})
}
return submitAnswersArgs{
teamID: teamID,
password: password,
answers: answers,
}, nil
}
func (s *MCPService) handleSubmitAnswers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, err := parseSubmitAnswersArgs(req.GetArguments())
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
}
ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout)
defer cancel()
if err := s.game.SubmitTeamAnswers(ctx, int(args.teamID), args.password, args.answers); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
}
story, game, err := s.game.GetTeamActions(ctx, int(args.teamID), args.password)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
}
jsonBody, err := storyJSON(story, game)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
}
return mcp.NewToolResultText(jsonBody), nil
}
// argInt64 достаёт int64 из аргумента инструмента: числа приходят как
// float64 (JSON), строки — как string.
func argInt64(v any) (int64, bool) {
+63 -3
View File
@@ -36,6 +36,13 @@ func (f *fakeGamePlayer) AddTeamAction(_ context.Context, teamID int, password,
return nil
}
func (f *fakeGamePlayer) SubmitTeamAnswers(_ context.Context, teamID int, password string, answers []game_service.Answer) error {
if f.err != nil {
return f.err
}
return nil
}
func defaultStory() *storytelling.Story {
return &storytelling.Story{
Introduction: &storytelling.Introduction{
@@ -136,10 +143,10 @@ func (f *mcpFixture) toolNames(t *testing.T) []string {
func assertExactTools(t *testing.T, names []string) {
t.Helper()
if len(names) != 3 {
t.Fatalf("tools = %v; want ровно 3 инструмента", names)
if len(names) != 4 {
t.Fatalf("tools = %v; want ровно 4 инструмента", names)
}
for _, want := range []string{"connect", "get_team_story", "make_move"} {
for _, want := range []string{"connect", "get_team_story", "make_move", "submit_answers"} {
found := false
for _, name := range names {
if name == want {
@@ -400,3 +407,56 @@ func TestDefaultCallTimeout(t *testing.T) {
t.Fatalf("defaultCallTimeout = %v; want 10s", defaultCallTimeout)
}
}
func TestSubmitAnswers(t *testing.T) {
f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()})
got := f.call(t, "submit_answers", map[string]any{
"team_id": float64(10),
"password": "team-pass-1",
"answers": []any{
map[string]any{"questionCode": "q1", "answer": "Дворецкий"},
map[string]any{"questionCode": "q2", "answer": ""},
},
})
if !strings.Contains(got, "entrance") {
t.Fatalf("submit_answers: %q; want story JSON в ответе", got)
}
}
func TestSubmitAnswersValidation(t *testing.T) {
f := newMCPFixture(t, &fakeGamePlayer{})
cases := []struct {
name string
args map[string]any
want string
}{
{"нет team_id", map[string]any{"password": "x", "answers": []any{}}, "team_id"},
{"нет password", map[string]any{"team_id": float64(1), "answers": []any{}}, "password"},
{"нет answers", map[string]any{"team_id": float64(1), "password": "x"}, "answers"},
{"элемент не объект", map[string]any{"team_id": float64(1), "password": "x", "answers": []any{"str"}}, "answers[0]"},
{"нет questionCode", map[string]any{"team_id": float64(1), "password": "x", "answers": []any{map[string]any{"answer": "x"}}}, "questionCode"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := f.call(t, "submit_answers", tc.args)
if !strings.Contains(got, tc.want) {
t.Fatalf("submit_answers: %q; want подстроку %q", got, tc.want)
}
})
}
}
func TestSubmitAnswersBusinessError(t *testing.T) {
f := newMCPFixture(t, &fakeGamePlayer{err: errors.New("Игра завершена, ответы больше нельзя изменять")})
got := f.call(t, "submit_answers", map[string]any{
"team_id": float64(10),
"password": "wrong",
"answers": []any{map[string]any{"questionCode": "q1", "answer": "x"}},
})
if !strings.Contains(got, "Игра завершена") {
t.Fatalf("submit_answers с ошибкой сервиса: %q; want error text", got)
}
}
+86 -1
View File
@@ -295,6 +295,75 @@ func (s *ScenarioService) UpdateScenarioIntro(
return s.updateStory(ctx, id, story)
}
// AddScenarioQuestion добавляет вопрос дела в конец списка вопросов сценария.
func (s *ScenarioService) AddScenarioQuestion(
ctx context.Context,
id int,
question *storytelling.Question,
actorId int,
isAdmin bool,
) error {
if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil {
return err
}
story, err := s.getStory(ctx, id)
if err != nil {
return err
}
story.Questions = append(story.Questions, question)
return s.updateStory(ctx, id, story)
}
// UpdateScenarioQuestion обновляет вопрос дела по его коду. Смена кода
// оставляет строки team_answers старого кода в БД
func (s *ScenarioService) UpdateScenarioQuestion(
ctx context.Context,
id int,
code string,
question *storytelling.Question,
actorId int,
isAdmin bool,
) error {
if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil {
return err
}
story, err := s.getStory(ctx, id)
if err != nil {
return err
}
for i := range story.Questions {
if story.Questions[i].Code == code {
story.Questions[i] = question
break
}
}
return s.updateStory(ctx, id, story)
}
// DeleteScenarioQuestion удаляет вопрос дела по его коду.
func (s *ScenarioService) DeleteScenarioQuestion(
ctx context.Context,
id int,
code string,
actorId int,
isAdmin bool,
) error {
if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil {
return err
}
story, err := s.getStory(ctx, id)
if err != nil {
return err
}
for i := range story.Questions {
if story.Questions[i].Code == code {
story.Questions = append(story.Questions[:i], story.Questions[i+1:]...)
break
}
}
return s.updateStory(ctx, id, story)
}
func (s *ScenarioService) getStory(ctx context.Context, id int) (*storytelling.Story, error) {
storyString, err := s.scenariosRepo.GetStoryByScenarioID(ctx, id)
if err != nil {
@@ -432,7 +501,6 @@ func (s *ScenarioService) UploadArchive(
return id, nil
}
func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storytelling.Story) error {
for _, place := range story.Places {
place.Image = strings.TrimPrefix(place.Image, s.domain)
@@ -478,6 +546,23 @@ func normalizeStory(story *storytelling.Story) (string, error) {
}
story.Places = cleanPlaces
// Коды вопросов уникальны, пустые коды отбрасываются (как у точек).
questionCodes := map[string]struct{}{}
for _, question := range story.Questions {
questionCodes[question.Code] = struct{}{}
}
if len(questionCodes) != len(story.Questions) {
return "", errors.New("Такой код вопроса уже существует")
}
cleanQuestions := make([]*storytelling.Question, 0, len(story.Questions))
for _, question := range story.Questions {
if question.Code == "" {
continue
}
cleanQuestions = append(cleanQuestions, question)
}
story.Questions = cleanQuestions
return convertStory(story)
}
@@ -686,3 +686,132 @@ func mustJSON(t *testing.T, v scenario_archive.ScenarioJSON) []byte {
}
return b
}
func TestScenarioQuestionsCRUD(t *testing.T) {
repo := newFakeScenariosRepo()
storage := newFakeStorage()
seedScenario(t, repo, storage)
svc := newTestService(repo, storage)
// Добавление вопроса автором.
err := svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{
Code: "q1",
Text: "Кто убийца?",
Answer: "Дворецкий",
}, 7, false)
if err != nil {
t.Fatalf("AddScenarioQuestion: %v", err)
}
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{
Code: "q2",
Text: "Орудие?",
Answer: "Канделябр",
}, 7, false)
if err != nil {
t.Fatalf("AddScenarioQuestion: %v", err)
}
full, err := svc.GetFullScenarioByID(context.Background(), 1)
if err != nil {
t.Fatalf("GetFullScenarioByID: %v", err)
}
if len(full.Story.Questions) != 2 {
t.Fatalf("len(Questions) = %d, want 2", len(full.Story.Questions))
}
// Вопросы добавляются в конец списка: q1 добавлен первым.
if full.Story.Questions[0].Code != "q1" || full.Story.Questions[0].Answer != "Дворецкий" {
t.Errorf("Questions[0] = %+v, want q1 с правильным ответом", full.Story.Questions[0])
}
if full.Story.Questions[1].Code != "q2" || full.Story.Questions[1].Answer != "Канделябр" {
t.Errorf("Questions[1] = %+v, want q2 с правильным ответом", full.Story.Questions[1])
}
// Обновление вопроса по коду.
err = svc.UpdateScenarioQuestion(context.Background(), 1, "q1", &storytelling.Question{
Code: "q1",
Text: "Кто убийца на самом деле?",
Answer: "Экономка",
}, 7, false)
if err != nil {
t.Fatalf("UpdateScenarioQuestion: %v", err)
}
full, err = svc.GetFullScenarioByID(context.Background(), 1)
if err != nil {
t.Fatalf("GetFullScenarioByID: %v", err)
}
found := false
for _, q := range full.Story.Questions {
if q.Code == "q1" {
found = true
if q.Text != "Кто убийца на самом деле?" || q.Answer != "Экономка" {
t.Errorf("q1 = %+v, want обновлённый вопрос", q)
}
}
}
if !found {
t.Error("вопрос q1 не найден после обновления")
}
// Удаление вопроса.
err = svc.DeleteScenarioQuestion(context.Background(), 1, "q2", 7, false)
if err != nil {
t.Fatalf("DeleteScenarioQuestion: %v", err)
}
full, err = svc.GetFullScenarioByID(context.Background(), 1)
if err != nil {
t.Fatalf("GetFullScenarioByID: %v", err)
}
if len(full.Story.Questions) != 1 || full.Story.Questions[0].Code != "q1" {
t.Errorf("Questions = %+v, want только q1", full.Story.Questions)
}
// Чужой автор не может менять вопросы.
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "q3"}, 99, false)
if !errors.Is(err, ErrScenarioNotOwner) {
t.Errorf("AddScenarioQuestion чужой автор: err = %v, want ErrScenarioNotOwner", err)
}
err = svc.DeleteScenarioQuestion(context.Background(), 1, "q1", 99, false)
if !errors.Is(err, ErrScenarioNotOwner) {
t.Errorf("DeleteScenarioQuestion чужой автор: err = %v, want ErrScenarioNotOwner", err)
}
// Админ может менять вопросы чужого сценария.
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{
Code: "q3", Text: "Вопрос админа",
}, 1, true)
if err != nil {
t.Fatalf("AddScenarioQuestion админ: %v", err)
}
}
func TestNormalizeStoryQuestionCodes(t *testing.T) {
repo := newFakeScenariosRepo()
storage := newFakeStorage()
seedScenario(t, repo, storage)
svc := newTestService(repo, storage)
// Дубликат кода вопроса — ошибка.
err := svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "q1"}, 7, false)
if err != nil {
t.Fatalf("AddScenarioQuestion q1: %v", err)
}
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "q1"}, 7, false)
if err == nil || !strings.Contains(err.Error(), "Такой код вопроса уже существует") {
t.Errorf("AddScenarioQuestion с дублем кода: err = %v, want ошибку о дубле", err)
}
// Пустой код — вопрос отбрасывается при сохранении.
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "", Text: "без кода"}, 7, false)
if err != nil {
t.Fatalf("AddScenarioQuestion с пустым кодом: %v", err)
}
full, err := svc.GetFullScenarioByID(context.Background(), 1)
if err != nil {
t.Fatalf("GetFullScenarioByID: %v", err)
}
for _, q := range full.Story.Questions {
if q.Code == "" {
t.Error("вопрос с пустым кодом сохранился")
}
}
}