diff --git a/imgui.ini b/imgui.ini index b71d071..b8903b8 100644 --- a/imgui.ini +++ b/imgui.ini @@ -14,7 +14,7 @@ Size=200,200 Collapsed=0 [Window][Connection] -Pos=250,275 +Pos=475,529 Size=640,352 Collapsed=0 diff --git a/internal/playback/session_command.go b/internal/playback/session_command.go new file mode 100644 index 0000000..38f0b5d --- /dev/null +++ b/internal/playback/session_command.go @@ -0,0 +1,110 @@ +package playback + +import ( + "errors" + "fmt" +) + +type SessionCommandKind uint8 + +const ( + CommandSetVideo SessionCommandKind = iota + 1 + CommandSetAudio + CommandStopVideo + CommandStopAudio + CommandStopAll + CommandResumeVideo + CommandResumeAudio + CommandResumeAll + CommandRemoveVideo + CommandRemoveAudio + CommandEnableSync + CommandDisableSync +) + +type SessionCommand struct { + Kind SessionCommandKind + Config FeedConfig // Used only by SetVideo and SetAudio. +} + +var ( + ErrUnknownSessionCommand = errors.New("unknown session command") + ErrVideoNotConfigured = errors.New("video feed is not configured") + ErrAudioNotConfigured = errors.New("audio feed is not configured") +) + +func ApplySessionCommand( + current SessionConfig, + command SessionCommand, +) (SessionConfig, error) { + next := current + switch command.Kind { + case CommandSetVideo: + if !command.Config.IsConfigured() { + return current, ErrVideoNotConfigured + } + if err := command.Config.Validate(); err != nil { + return current, fmt.Errorf("validate video command: %w", err) + } + next.Video = command.Config + + case CommandSetAudio: + if !command.Config.IsConfigured() { + return current, ErrAudioNotConfigured + } + if err := command.Config.Validate(); err != nil { + return current, fmt.Errorf("validate audio command: %w", err) + } + next.Audio = command.Config + + case CommandStopVideo: + next.Video.Active = false + next.SyncRequested = false + + case CommandStopAudio: + next.Audio.Active = false + next.SyncRequested = false + + case CommandStopAll: + next.Video.Active = false + next.Audio.Active = false + + case CommandResumeVideo: + if !next.Video.IsConfigured() { + return current, ErrVideoNotConfigured + } + next.Video.Active = true + + case CommandResumeAudio: + if !next.Audio.IsConfigured() { + return current, ErrAudioNotConfigured + } + next.Audio.Active = true + + case CommandResumeAll: + next.Video.Active = next.Video.IsConfigured() + next.Audio.Active = next.Audio.IsConfigured() + + case CommandRemoveVideo: + next.Video = FeedConfig{} + next.SyncRequested = false + + case CommandRemoveAudio: + next.Audio = FeedConfig{} + next.SyncRequested = false + + case CommandEnableSync: + next.SyncRequested = true + + case CommandDisableSync: + next.SyncRequested = false + + default: + return current, ErrUnknownSessionCommand + } + + if err := next.Validate(); err != nil { + return current, fmt.Errorf("validate desired session: %w", err) + } + return next, nil +} diff --git a/internal/playback/session_command_test.go b/internal/playback/session_command_test.go new file mode 100644 index 0000000..a5bf098 --- /dev/null +++ b/internal/playback/session_command_test.go @@ -0,0 +1,185 @@ +package playback + +import ( + "errors" + "testing" + "time" +) + +func validCommandSession() SessionConfig { + return SessionConfig{ + Video: FeedConfig{Domain: "/video", UUID: "video", Active: true}, + Audio: FeedConfig{Domain: "/audio", UUID: "audio", Active: true}, + SyncRequested: true, + Retry: RetryPolicy{ + MaxAttempts: 3, + InitialDelay: time.Millisecond, + MaxDelay: time.Second, + }, + } +} + +func TestApplySessionCommand(t *testing.T) { + base := validCommandSession() + newVideo := FeedConfig{Domain: "/new-video", UUID: "new-video", Active: false} + newAudio := FeedConfig{Domain: "/new-audio", UUID: "new-audio", Active: true} + + tests := []struct { + name string + current SessionConfig + command SessionCommand + want SessionConfig + }{ + { + name: "set video replaces complete config", + current: base, + command: SessionCommand{Kind: CommandSetVideo, Config: newVideo}, + want: func() SessionConfig { c := base; c.Video = newVideo; return c }(), + }, + { + name: "set audio replaces complete config", + current: base, + command: SessionCommand{Kind: CommandSetAudio, Config: newAudio}, + want: func() SessionConfig { c := base; c.Audio = newAudio; return c }(), + }, + { + name: "stop video disables sync", + current: base, + command: SessionCommand{Kind: CommandStopVideo}, + want: func() SessionConfig { c := base; c.Video.Active = false; c.SyncRequested = false; return c }(), + }, + { + name: "stop audio disables sync", + current: base, + command: SessionCommand{Kind: CommandStopAudio}, + want: func() SessionConfig { c := base; c.Audio.Active = false; c.SyncRequested = false; return c }(), + }, + { + name: "stop all preserves sync request", + current: base, + command: SessionCommand{Kind: CommandStopAll}, + want: func() SessionConfig { c := base; c.Video.Active = false; c.Audio.Active = false; return c }(), + }, + { + name: "resume video", + current: func() SessionConfig { c := base; c.Video.Active = false; return c }(), + command: SessionCommand{Kind: CommandResumeVideo}, + want: base, + }, + { + name: "resume audio", + current: func() SessionConfig { c := base; c.Audio.Active = false; return c }(), + command: SessionCommand{Kind: CommandResumeAudio}, + want: base, + }, + { + name: "resume all activates only configured feeds", + current: func() SessionConfig { c := base; c.Video.Active = false; c.Audio = FeedConfig{}; return c }(), + command: SessionCommand{Kind: CommandResumeAll}, + want: func() SessionConfig { c := base; c.Audio = FeedConfig{}; return c }(), + }, + { + name: "remove video clears config and disables sync", + current: base, + command: SessionCommand{Kind: CommandRemoveVideo}, + want: func() SessionConfig { c := base; c.Video = FeedConfig{}; c.SyncRequested = false; return c }(), + }, + { + name: "remove audio clears config and disables sync", + current: base, + command: SessionCommand{Kind: CommandRemoveAudio}, + want: func() SessionConfig { c := base; c.Audio = FeedConfig{}; c.SyncRequested = false; return c }(), + }, + { + name: "enable sync without feeds records request", + current: func() SessionConfig { + c := base + c.Video = FeedConfig{} + c.Audio = FeedConfig{} + c.SyncRequested = false + return c + }(), + command: SessionCommand{Kind: CommandEnableSync}, + want: func() SessionConfig { + c := base + c.Video = FeedConfig{} + c.Audio = FeedConfig{} + c.SyncRequested = true + return c + }(), + }, + { + name: "disable sync", + current: base, + command: SessionCommand{Kind: CommandDisableSync}, + want: func() SessionConfig { c := base; c.SyncRequested = false; return c }(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ApplySessionCommand(tt.current, tt.command) + if err != nil { + t.Fatalf("ApplySessionCommand() error = %v", err) + } + if got != tt.want { + t.Fatalf("ApplySessionCommand() = %#v, want %#v", got, tt.want) + } + if got.Retry != tt.current.Retry { + t.Fatalf("retry changed from %#v to %#v", tt.current.Retry, got.Retry) + } + }) + } +} + +func TestApplySessionCommandFailurePreservesState(t *testing.T) { + base := validCommandSession() + tests := []struct { + name string + current SessionConfig + command SessionCommand + wantErr error + }{ + {"set empty video", base, SessionCommand{Kind: CommandSetVideo}, ErrVideoNotConfigured}, + {"set empty audio", base, SessionCommand{Kind: CommandSetAudio}, ErrAudioNotConfigured}, + {"invalid video", base, SessionCommand{Kind: CommandSetVideo, Config: FeedConfig{UUID: "video"}}, ErrFeedDomainRequired}, + {"invalid audio", base, SessionCommand{Kind: CommandSetAudio, Config: FeedConfig{UUID: "audio"}}, ErrFeedDomainRequired}, + { + "resume missing video", + func() SessionConfig { c := base; c.Video = FeedConfig{}; return c }(), + SessionCommand{Kind: CommandResumeVideo}, + ErrVideoNotConfigured, + }, + { + "resume missing audio", + func() SessionConfig { c := base; c.Audio = FeedConfig{}; return c }(), + SessionCommand{Kind: CommandResumeAudio}, + ErrAudioNotConfigured, + }, + {"unknown command", base, SessionCommand{Kind: 255}, ErrUnknownSessionCommand}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ApplySessionCommand(tt.current, tt.command) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("ApplySessionCommand() error = %v, want %v", err, tt.wantErr) + } + if got != tt.current { + t.Fatalf("failed command changed state from %#v to %#v", tt.current, got) + } + }) + } +} + +func TestApplySessionCommandRejectsInvalidResult(t *testing.T) { + current := validCommandSession() + current.Retry = RetryPolicy{} + got, err := ApplySessionCommand(current, SessionCommand{Kind: CommandDisableSync}) + if !errors.Is(err, ErrInvalidRetryDelay) { + t.Fatalf("ApplySessionCommand() error = %v, want %v", err, ErrInvalidRetryDelay) + } + if got != current { + t.Fatalf("failed command changed state from %#v to %#v", current, got) + } +}