This commit is contained in:
2026-10-27 12:03:22 +07:00
parent 0879a8946a
commit 0341bfac25
32 changed files with 4119 additions and 1350 deletions
@@ -317,6 +317,11 @@ func validateLocalIDs(data *game_package_service.Data) error {
if playlist.ID > maxID {
return fmt.Errorf("%w: playlist ID", game_package_service.ErrInvalidPackage)
}
for _, group := range playlist.Groups {
if group.ID > maxID || int64(group.Position) > int64(maxID) {
return fmt.Errorf("%w: group ID or position", game_package_service.ErrInvalidPackage)
}
}
for _, track := range playlist.Tracks {
if track.ID > maxID {
return fmt.Errorf("%w: track ID", game_package_service.ErrInvalidPackage)
@@ -0,0 +1,53 @@
package game_package_service
import (
"bytes"
"context"
"errors"
"os"
"reflect"
"testing"
)
func TestPackagePreservesGroupOrderAndMembership(t *testing.T) {
for _, name := range []string{"group roundtrip"} {
t.Run(name, func(t *testing.T) {
snapshot, files := fixture()
snapshot.Data.Playlists[0].Groups = []PlaylistGroup{{ID: 80, Name: "Final", Position: 0, EndMode: "stop"}, {ID: 81, Name: "Intro", Position: 1, EndMode: "next"}, {ID: 82, Name: "Empty", Position: 2, EndMode: "repeat"}}
snapshot.Data.Playlists[0].Tracks[0].GroupID = 81
service := NewGamePackageService("")
raw := buildPackage(t, service, snapshot, files)
bundle, err := service.Unpack(context.Background(), bytes.NewReader(raw), int64(len(raw)), t.TempDir())
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(bundle.Dir) }()
if !reflect.DeepEqual(bundle.Data.Playlists[0].Groups, snapshot.Data.Playlists[0].Groups) || bundle.Data.Playlists[0].Tracks[0].GroupID != 81 {
t.Fatal("group data changed")
}
})
}
}
func TestPackRejectsInvalidGroups(t *testing.T) {
cases := []struct {
name string
modify func(*Playlist)
}{
{"unknown mode", func(p *Playlist) { p.Groups[0].EndMode = "unknown" }},
{"duplicate ID", func(p *Playlist) { p.Groups = append(p.Groups, p.Groups[0]) }},
{"missing group", func(p *Playlist) { p.Tracks[0].GroupID = 999 }},
{"negative position", func(p *Playlist) { p.Groups[0].Position = -1 }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
snapshot, files := fixture()
snapshot.Data.Playlists[0].Groups = []PlaylistGroup{{ID: 80, Name: "Group", EndMode: "repeat"}}
tc.modify(&snapshot.Data.Playlists[0])
err := NewGamePackageService("").Pack(context.Background(), snapshot, localFiles(files, nil), &bytes.Buffer{})
if !errors.Is(err, ErrInvalidPackage) {
t.Fatal(err)
}
})
}
}
@@ -107,9 +107,17 @@ type Ticket struct {
}
type Playlist struct {
ID int `json:"id"`
Name string `json:"name"`
Tracks []Track `json:"tracks"`
ID int `json:"id"`
Name string `json:"name"`
Tracks []Track `json:"tracks"`
Groups []PlaylistGroup `json:"groups,omitempty"`
}
type PlaylistGroup struct {
ID int `json:"id"`
Name string `json:"name"`
Position int `json:"position"`
EndMode string `json:"end_mode"`
}
type Track struct {
@@ -117,6 +125,7 @@ type Track struct {
Name string `json:"name"`
Filename string `json:"filename"`
Position int `json:"position"`
GroupID int `json:"group_id,omitempty"`
}
type ScreenPlan struct {
@@ -103,15 +103,26 @@ func validateData(data *Data) error {
}
playlists := map[int]bool{}
tracks := map[int]bool{}
groups := map[int]bool{}
for _, playlist := range data.Playlists {
if playlist.ID <= 0 || playlists[playlist.ID] || strings.TrimSpace(playlist.Name) == "" {
return invalid("некорректный плейлист")
}
playlists[playlist.ID] = true
localGroups := map[int]bool{}
for _, group := range playlist.Groups {
if group.ID <= 0 || groups[group.ID] || strings.TrimSpace(group.Name) == "" || len(group.Name) > 255 || group.Position < 0 || (group.EndMode != "repeat" && group.EndMode != "stop" && group.EndMode != "next") {
return invalid("некорректная группа плейлиста")
}
groups[group.ID], localGroups[group.ID] = true, true
}
for _, track := range playlist.Tracks {
if track.ID <= 0 || tracks[track.ID] || strings.TrimSpace(track.Name) == "" || track.Filename == "" || track.Position < 0 {
return invalid("некорректный трек")
}
if track.GroupID != 0 && !localGroups[track.GroupID] {
return invalid("группа трека не принадлежит плейлисту")
}
tracks[track.ID] = true
}
}
@@ -0,0 +1,118 @@
package playlist_service
import (
"context"
"strconv"
"strings"
"evening_detective_server/internal/repos/playlists_repo"
)
// SaveGroup validates group metadata before persisting it.
func (s *Service) SaveGroup(ctx context.Context, playlistID, groupID int, name, mode string) (*Playlist, error) {
name = strings.TrimSpace(name)
if playlistID <= 0 || groupID < 0 || name == "" || len(name) > 255 {
return nil, ErrInvalidName
}
if mode != "repeat" && mode != "stop" && mode != "next" {
return nil, ErrInvalidTrack
}
item, err := s.repo.SaveGroup(ctx, playlistID, groupID, name, mode)
return mapPlaylist(item), mapError(err)
}
// DeleteGroup preserves tracks when removing their section.
func (s *Service) DeleteGroup(ctx context.Context, playlistID, groupID int) (*Playlist, error) {
if playlistID <= 0 || groupID <= 0 {
return nil, ErrInvalidTrack
}
item, err := s.repo.DeleteGroup(ctx, playlistID, groupID)
return mapPlaylist(item), mapError(err)
}
// ReorderGroups validates the complete requested group order.
func (s *Service) ReorderGroups(ctx context.Context, playlistID int, ids []int) (*Playlist, error) {
if playlistID <= 0 {
return nil, ErrInvalidTrack
}
seen := make(map[int]bool, len(ids))
for _, id := range ids {
if id <= 0 || seen[id] {
return nil, ErrInvalidTrack
}
seen[id] = true
}
item, err := s.repo.ReorderGroups(ctx, playlistID, ids)
return mapPlaylist(item), mapError(err)
}
// SetTrackGroup validates track and section identifiers.
func (s *Service) SetTrackGroup(ctx context.Context, playlistID, trackID, groupID int) (*Playlist, error) {
if playlistID <= 0 || trackID <= 0 || groupID < 0 {
return nil, ErrInvalidTrack
}
item, err := s.repo.SetTrackGroup(ctx, playlistID, trackID, groupID)
return mapPlaylist(item), mapError(err)
}
// nextTrack calculates the next global index while respecting section boundaries.
func nextTrack(state *MusicState) (index, groupID int, playback int32) {
index, groupID, playback = state.TrackIndex, state.GroupID, state.Playback
if state.Playlist == nil || len(state.Playlist.Tracks) == 0 {
return index, groupID, 1
}
tracks := state.Playlist.Tracks
if groupID == 0 {
if index+1 < len(tracks) {
return index + 1, 0, playback
}
if state.Loop {
return 0, 0, playback
}
return index, 0, 1
}
for i := index + 1; i < len(tracks); i++ {
if tracks[i].GroupID == groupID {
return i, groupID, playback
}
}
for position, group := range state.Playlist.Groups {
if group.ID != groupID {
continue
}
if group.EndMode == "repeat" {
for i, track := range tracks {
if track.GroupID == groupID {
return i, groupID, playback
}
}
}
if group.EndMode == "next" {
for _, next := range state.Playlist.Groups[position+1:] {
for i, track := range tracks {
if track.GroupID == next.ID {
return i, next.ID, playback
}
}
}
}
break
}
return index, groupID, 1
}
// Advance applies a completion event only to the state the screen played.
func (s *Service) Advance(ctx context.Context, gameID int, expectedStateVersion string) (*MusicState, error) {
var expectedVersion int64
if expectedStateVersion != "" {
var err error
expectedVersion, err = strconv.ParseInt(expectedStateVersion, 10, 64)
if err != nil || expectedVersion < 0 {
return nil, ErrInvalidTrack
}
}
updated, err := s.repo.AdvanceMusic(ctx, gameID, expectedVersion, func(state *playlists_repo.MusicState) (int, int, int32) {
return nextTrack(mapMusicState(state))
})
return mapMusicState(updated), mapError(err)
}
@@ -0,0 +1,77 @@
package playlist_service
import (
"context"
"errors"
"testing"
)
func TestNextTrackHonorsGroupCompletion(t *testing.T) {
cases := []struct {
name, mode string
index, groupID int
loop bool
groups []*Group
wantIndex, wantGroup int
wantPlayback int32
}{
{name: "within group", mode: "stop", index: 0, groupID: 1, wantIndex: 2, wantGroup: 1, wantPlayback: 2},
{name: "repeat", mode: "repeat", index: 2, groupID: 1, wantIndex: 0, wantGroup: 1, wantPlayback: 2},
{name: "stop", mode: "stop", index: 2, groupID: 1, wantIndex: 2, wantGroup: 1, wantPlayback: 1},
{name: "next skips empty", mode: "next", index: 2, groupID: 1, wantIndex: 1, wantGroup: 3, wantPlayback: 2},
{name: "last group stops", index: 1, groupID: 3, wantIndex: 1, wantGroup: 3, wantPlayback: 1},
{name: "reordered next", mode: "next", index: 2, groupID: 1, groups: []*Group{{ID: 3, EndMode: "repeat"}, {ID: 1, EndMode: "next"}}, wantIndex: 2, wantGroup: 1, wantPlayback: 1},
{name: "single track repeats", index: 1, groupID: 3, groups: []*Group{{ID: 3, EndMode: "repeat"}}, wantIndex: 1, wantGroup: 3, wantPlayback: 2},
{name: "whole playlist", index: 0, wantIndex: 1, wantPlayback: 2},
{name: "whole playlist repeats", index: 2, loop: true, wantIndex: 0, wantPlayback: 2},
{name: "whole playlist stops", index: 2, wantIndex: 2, wantPlayback: 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
groups := tc.groups
if groups == nil {
groups = []*Group{{ID: 1, EndMode: tc.mode}, {ID: 2, EndMode: "repeat"}, {ID: 3, EndMode: "next"}}
}
state := &MusicState{TrackIndex: tc.index, GroupID: tc.groupID, Playback: 2, Loop: tc.loop, Playlist: &Playlist{Tracks: []*Track{{ID: 10, GroupID: 1}, {ID: 11, GroupID: 3}, {ID: 12, GroupID: 1}}, Groups: groups}}
index, groupID, playback := nextTrack(state)
if index != tc.wantIndex || groupID != tc.wantGroup || playback != tc.wantPlayback {
t.Fatalf("got %d/%d/%d", index, groupID, playback)
}
})
}
}
func TestGroupValidationRejectsInvalidRequestsBeforeRepository(t *testing.T) {
cases := []struct {
name string
run func(*Service) error
want error
}{
{"empty name", func(s *Service) error { _, err := s.SaveGroup(context.Background(), 1, 0, " ", "repeat"); return err }, ErrInvalidName},
{"invalid mode", func(s *Service) error {
_, err := s.SaveGroup(context.Background(), 1, 0, "Name", "unknown")
return err
}, ErrInvalidTrack},
{"invalid group", func(s *Service) error { _, err := s.DeleteGroup(context.Background(), 1, 0); return err }, ErrInvalidTrack},
{"duplicate order", func(s *Service) error { _, err := s.ReorderGroups(context.Background(), 1, []int{1, 1}); return err }, ErrInvalidTrack},
{"invalid assignment", func(s *Service) error { _, err := s.SetTrackGroup(context.Background(), 1, 2, -1); return err }, ErrInvalidTrack},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := tc.run(New(nil, nil)); !errors.Is(err, tc.want) {
t.Fatal(err)
}
})
}
}
func TestAdvanceRejectsInvalidCompletionVersion(t *testing.T) {
for _, value := range []string{"-1", "invalid", "9223372036854775808"} {
t.Run(value, func(t *testing.T) {
_, err := New(nil, nil).Advance(context.Background(), 1, value)
if !errors.Is(err, ErrInvalidTrack) {
t.Fatal(err)
}
})
}
}
@@ -13,8 +13,12 @@ func mapPlaylist(value *playlists_repo.Playlist) *Playlist {
Name: track.Name,
Filename: track.Filename,
Position: track.Position,
GroupID: track.GroupID,
})
}
for _, group := range value.Groups {
result.Groups = append(result.Groups, &Group{ID: group.ID, Name: group.Name, Position: group.Position, EndMode: group.EndMode})
}
return result
}
@@ -25,6 +29,7 @@ func mapMusicState(value *playlists_repo.MusicState) *MusicState {
return &MusicState{
Playlist: mapPlaylist(value.Playlist),
TrackIndex: value.TrackIndex,
GroupID: value.GroupID,
PositionMs: value.PositionMs,
Volume: value.Volume,
Loop: value.Loop,
@@ -8,6 +8,15 @@ type Track struct {
Name string
Filename string
Position int
GroupID int
}
// Group describes an ordered section of a playlist.
type Group struct {
ID int
Name string
Position int
EndMode string
}
// Playlist describes a reusable playlist.
@@ -15,12 +24,14 @@ type Playlist struct {
ID int
Name string
Tracks []*Track
Groups []*Group
}
// MusicState describes the synchronized music state of a game.
type MusicState struct {
Playlist *Playlist
TrackIndex int
GroupID int
PositionMs int64
Volume int32
Loop bool
@@ -27,7 +27,7 @@ func TestReorderRejectsInvalidIDsBeforeRepository(t *testing.T) {
}
func TestOrderConflictMapping(t *testing.T) {
for _, tc := range []struct{ input, expected error }{{playlists_repo.ErrTrackOrderChanged, ErrTrackOrderChanged}} {
for _, tc := range []struct{ input, expected error }{{playlists_repo.ErrTrackOrderChanged, ErrTrackOrderChanged}, {playlists_repo.ErrGroupOrderChanged, ErrGroupOrderChanged}, {playlists_repo.ErrGroupNotFound, ErrGroupNotFound}, {playlists_repo.ErrInvalidTrack, ErrInvalidTrack}} {
if !errors.Is(mapError(tc.input), tc.expected) {
t.Fatal("lost conflict")
}
+10 -1
View File
@@ -11,6 +11,8 @@ import (
)
var (
ErrGroupNotFound = errors.New("группа не найдена")
ErrGroupOrderChanged = errors.New("состав групп изменился; обновите список")
ErrTrackOrderChanged = errors.New("состав треков изменился; обновите список")
ErrInvalidName = errors.New("некорректное имя плейлиста")
ErrInvalidTrack = errors.New("некорректный трек")
@@ -129,11 +131,12 @@ func (s *Service) SetGame(
volume int32,
loop bool,
playback int32,
groupID int,
) (*MusicState, error) {
if trackIndex < 0 || positionMs < 0 || volume < 0 || volume > 100 || playback < 1 || playback > 3 {
return nil, ErrInvalidTrack
}
state, err := s.repo.SetMusic(ctx, gameID, playlistID, trackIndex, positionMs, volume, loop, playback)
state, err := s.repo.SetMusic(ctx, gameID, playlistID, trackIndex, positionMs, volume, loop, playback, groupID)
if err != nil {
return nil, mapError(err)
}
@@ -151,6 +154,12 @@ func (s *Service) GetGame(ctx context.Context, gameID int) (*MusicState, error)
func mapError(err error) error {
switch {
case errors.Is(err, playlists_repo.ErrGroupOrderChanged):
return ErrGroupOrderChanged
case errors.Is(err, playlists_repo.ErrInvalidTrack):
return ErrInvalidTrack
case errors.Is(err, playlists_repo.ErrGroupNotFound):
return ErrGroupNotFound
case errors.Is(err, playlists_repo.ErrTrackOrderChanged):
return ErrTrackOrderChanged
case errors.Is(err, playlists_repo.ErrPlaylistNotFound):