diff --git a/imgui.ini b/imgui.ini index b8903b8..6a67a49 100644 --- a/imgui.ini +++ b/imgui.ini @@ -14,7 +14,7 @@ Size=200,200 Collapsed=0 [Window][Connection] -Pos=475,529 -Size=640,352 +Pos=628,539 +Size=613,164 Collapsed=0 diff --git a/internal/playback/session_plan.go b/internal/playback/session_plan.go new file mode 100644 index 0000000..4a80de7 --- /dev/null +++ b/internal/playback/session_plan.go @@ -0,0 +1,76 @@ +package playback + +import "fmt" + +type SessionTopology uint8 + +const ( + TopologyIdle SessionTopology = iota + TopologyIndependent + TopologySynchronized +) + +type SyncPredicate func(video, audio FeedConfig) bool + +type SessionPlan struct { + Topology SessionTopology + + // Desired states for independent slots. + Video FeedConfig + Audio FeedConfig + + // Desired state for the synchronized slot. + Sync SyncPairConfig +} + +func BuildSessionPlan( + desired SessionConfig, + canSync SyncPredicate, +) (SessionPlan, error) { + if err := desired.Validate(); err != nil { + return SessionPlan{}, fmt.Errorf("validate desired session: %w", err) + } + + if desired.SyncRequested && + desired.Video.Active && + desired.Audio.Active && + canSync != nil && + canSync(desired.Video, desired.Audio) { + return SessionPlan{ + Topology: TopologySynchronized, + Video: stoppedFeed(desired.Video), + Audio: stoppedFeed(desired.Audio), + Sync: SyncPairConfig{ + Video: desired.Video, + Audio: desired.Audio, + }, + }, nil + } + + if desired.Video.Active || desired.Audio.Active { + return SessionPlan{ + Topology: TopologyIndependent, + Video: desired.Video, + Audio: desired.Audio, + Sync: SyncPairConfig{ + Video: stoppedFeed(desired.Video), + Audio: stoppedFeed(desired.Audio), + }, + }, nil + } + + return SessionPlan{ + Topology: TopologyIdle, + Video: stoppedFeed(desired.Video), + Audio: stoppedFeed(desired.Audio), + Sync: SyncPairConfig{ + Video: stoppedFeed(desired.Video), + Audio: stoppedFeed(desired.Audio), + }, + }, nil +} + +func stoppedFeed(config FeedConfig) FeedConfig { + config.Active = false + return config +} diff --git a/internal/playback/session_plan_test.go b/internal/playback/session_plan_test.go new file mode 100644 index 0000000..b548e0f --- /dev/null +++ b/internal/playback/session_plan_test.go @@ -0,0 +1,130 @@ +package playback + +import ( + "errors" + "testing" +) + +func TestBuildSessionPlan(t *testing.T) { + base := validCommandSession() + stoppedVideo := stoppedFeed(base.Video) + stoppedAudio := stoppedFeed(base.Audio) + independent := func(video, audio FeedConfig) SessionPlan { + return SessionPlan{ + Topology: TopologyIndependent, + Video: video, + Audio: audio, + Sync: SyncPairConfig{Video: stoppedFeed(video), Audio: stoppedFeed(audio)}, + } + } + idle := func(video, audio FeedConfig) SessionPlan { + return SessionPlan{ + Topology: TopologyIdle, + Video: stoppedFeed(video), + Audio: stoppedFeed(audio), + Sync: SyncPairConfig{Video: stoppedFeed(video), Audio: stoppedFeed(audio)}, + } + } + + tests := []struct { + name string + desired SessionConfig + canSync SyncPredicate + want SessionPlan + }{ + { + name: "both active with sync disabled stay independent", + desired: func() SessionConfig { c := base; c.SyncRequested = false; return c }(), + canSync: func(FeedConfig, FeedConfig) bool { return true }, + want: independent(base.Video, base.Audio), + }, + { + name: "eligible requested pair is synchronized", + desired: base, + canSync: func(FeedConfig, FeedConfig) bool { return true }, + want: SessionPlan{ + Topology: TopologySynchronized, + Video: stoppedVideo, + Audio: stoppedAudio, + Sync: SyncPairConfig{Video: base.Video, Audio: base.Audio}, + }, + }, + { + name: "ineligible requested pair remains independent", + desired: base, + canSync: func(FeedConfig, FeedConfig) bool { return false }, + want: independent(base.Video, base.Audio), + }, + { + name: "nil capability remains independent", + desired: base, + want: independent(base.Video, base.Audio), + }, + { + name: "video only", + desired: func() SessionConfig { c := base; c.Audio.Active = false; return c }(), + canSync: func(FeedConfig, FeedConfig) bool { return true }, + want: independent(base.Video, stoppedAudio), + }, + { + name: "audio only", + desired: func() SessionConfig { c := base; c.Video.Active = false; return c }(), + canSync: func(FeedConfig, FeedConfig) bool { return true }, + want: independent(stoppedVideo, base.Audio), + }, + { + name: "stopped feeds are idle and retain configuration", + desired: func() SessionConfig { c := base; c.Video.Active = false; c.Audio.Active = false; return c }(), + canSync: func(FeedConfig, FeedConfig) bool { return true }, + want: idle(stoppedVideo, stoppedAudio), + }, + { + name: "empty session is idle", + desired: func() SessionConfig { c := base; c.Video = FeedConfig{}; c.Audio = FeedConfig{}; return c }(), + canSync: func(FeedConfig, FeedConfig) bool { return true }, + want: idle(FeedConfig{}, FeedConfig{}), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := BuildSessionPlan(tt.desired, tt.canSync) + if err != nil { + t.Fatalf("BuildSessionPlan() error = %v", err) + } + if got != tt.want { + t.Fatalf("BuildSessionPlan() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestBuildSessionPlanRejectsInvalidSession(t *testing.T) { + desired := validCommandSession() + desired.Video = FeedConfig{UUID: "video", Active: true} + got, err := BuildSessionPlan(desired, nil) + if !errors.Is(err, ErrFeedDomainRequired) { + t.Fatalf("BuildSessionPlan() error = %v, want %v", err, ErrFeedDomainRequired) + } + if got != (SessionPlan{}) { + t.Fatalf("BuildSessionPlan() = %#v, want zero plan", got) + } +} + +func TestBuildSessionPlanPassesCompleteFeedsToPredicate(t *testing.T) { + desired := validCommandSession() + called := false + _, err := BuildSessionPlan(desired, func(video, audio FeedConfig) bool { + called = true + if video != desired.Video || audio != desired.Audio { + t.Fatalf("predicate feeds = %#v %#v", video, audio) + } + return true + }) + if err != nil { + t.Fatal(err) + } + if !called { + t.Fatal("sync predicate was not called") + } +}