generated from VLADIMIR/template
up
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user