package mxladapter import ( "context" "errors" "testing" "time" "mxl-player/internal/playback" "mxl-player/internal/source" ) type localVideoReadResult struct { frame source.Frame err error } type fakeLocalVideoSource struct { results []localVideoReadResult calls int timeouts []time.Duration closeErr error closed bool } func (s *fakeLocalVideoSource) ReadOnceCtx( _ context.Context, timeout time.Duration, ) (source.Frame, error) { s.timeouts = append(s.timeouts, timeout) if s.calls >= len(s.results) { return source.Frame{}, errors.New("unexpected local video read") } result := s.results[s.calls] s.calls++ return result.frame, result.err } func (s *fakeLocalVideoSource) Close() error { s.closed = true return s.closeErr } func temporaryVideoError(cause error) error { return &source.SourceError{ Op: "read video", Kind: source.ErrorKindTemporary, Err: cause, } } func TestVideoFactoryOpenVideoCanceled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() reader, err := (VideoFactory{}).OpenVideo(ctx, playback.FeedConfig{}) if reader != nil { t.Fatal("OpenVideo() reader is not nil after cancellation") } if !errors.Is(err, context.Canceled) { t.Fatalf("OpenVideo() error = %v, want context.Canceled", err) } } func TestVideoFactoryOpenVideoRejectsInvalidConfig(t *testing.T) { tests := []struct { name string config playback.FeedConfig }{ { name: "feed is not configured", config: playback.FeedConfig{}, }, { name: "active feed has no UUID", config: playback.FeedConfig{ Domain: "/dev/shm/mxl", Active: true, }, }, { name: "configured feed has no domain", config: playback.FeedConfig{ UUID: "video-uuid", Active: true, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { reader, err := (VideoFactory{}).OpenVideo( context.Background(), tt.config, ) if reader != nil { t.Fatal("OpenVideo() reader is not nil for invalid config") } if err == nil { t.Fatal("OpenVideo() error is nil for invalid config") } if got := source.KindOf(err); got != source.ErrorKindInvalidConfig { t.Fatalf( "source.KindOf(OpenVideo()) = %v, want %v", got, source.ErrorKindInvalidConfig, ) } if ShouldRetry(err) { t.Fatal("ShouldRetry(OpenVideo()) = true for invalid config") } }) } } func TestVideoReaderTemporaryFailureThenFrame(t *testing.T) { temporaryErr := errors.New("video is early") payload := []byte{1, 2, 3, 4} wantFrame := source.Frame{ Index: 42, Width: 1920, Height: 1080, Stride: 5120, Size: 5120 * 1080, Invalid: false, Payload: payload, } localSource := &fakeLocalVideoSource{ results: []localVideoReadResult{ {err: temporaryVideoError(temporaryErr)}, {frame: wantFrame}, }, } waits := 0 reader := &videoReader{ source: localSource, readTimeout: 250 * time.Millisecond, unavailableAfter: 2 * time.Second, retryDelay: 10 * time.Millisecond, now: func() time.Time { return time.Unix(100, 0) }, wait: func(context.Context, time.Duration) error { waits++ return nil }, } got, err := reader.ReadVideo(context.Background()) if err != nil { t.Fatalf("ReadVideo() error = %v, want nil", err) } if localSource.calls != 2 { t.Errorf("source read calls = %d, want 2", localSource.calls) } if waits != 1 { t.Errorf("temporary wait calls = %d, want 1", waits) } if len(localSource.timeouts) != 2 || localSource.timeouts[0] != 250*time.Millisecond || localSource.timeouts[1] != 250*time.Millisecond { t.Errorf("source timeouts = %v, want [250ms 250ms]", localSource.timeouts) } if got.Index != wantFrame.Index || got.Width != wantFrame.Width || got.Height != wantFrame.Height || got.Stride != wantFrame.Stride || got.Size != wantFrame.Size || got.Invalid != wantFrame.Invalid { t.Errorf("video frame = %+v, want metadata from %+v", got, wantFrame) } if len(got.Payload) == 0 || &got.Payload[0] != &payload[0] { t.Fatal("video payload was copied") } } func TestVideoReaderProlongedTemporaryFailureBecomesUnavailable(t *testing.T) { lastCause := errors.New("video timeout") localSource := &fakeLocalVideoSource{ results: []localVideoReadResult{ {err: temporaryVideoError(errors.New("first timeout"))}, {err: temporaryVideoError(errors.New("second timeout"))}, {err: temporaryVideoError(lastCause)}, }, } times := []time.Time{ time.Unix(100, 0), time.Unix(101, 0), time.Unix(102, 0), } nowCall := 0 waits := 0 reader := &videoReader{ source: localSource, readTimeout: 200 * time.Millisecond, unavailableAfter: 2 * time.Second, retryDelay: 10 * time.Millisecond, now: func() time.Time { result := times[nowCall] nowCall++ return result }, wait: func(context.Context, time.Duration) error { waits++ return nil }, } _, err := reader.ReadVideo(context.Background()) if err == nil { t.Fatal("ReadVideo() error is nil after prolonged unavailability") } if got := source.KindOf(err); got != source.ErrorKindUnavailable { t.Fatalf("source.KindOf(ReadVideo()) = %v, want %v", got, source.ErrorKindUnavailable) } if !errors.Is(err, lastCause) { t.Errorf("ReadVideo() error = %v, want cause %v", err, lastCause) } if !ShouldRetry(err) { t.Error("ShouldRetry(ReadVideo()) = false, want true") } if localSource.calls != 3 { t.Errorf("source read calls = %d, want 3", localSource.calls) } if waits != 2 { t.Errorf("temporary wait calls = %d, want 2", waits) } } func TestVideoReaderReturnsNonTemporaryErrorImmediately(t *testing.T) { unavailableErr := &source.SourceError{ Op: "read video", Kind: source.ErrorKindUnavailable, Err: errors.New("flow invalid"), } localSource := &fakeLocalVideoSource{ results: []localVideoReadResult{{err: unavailableErr}}, } reader := &videoReader{ source: localSource, readTimeout: 200 * time.Millisecond, unavailableAfter: 2 * time.Second, retryDelay: 10 * time.Millisecond, now: func() time.Time { t.Fatal("clock called for non-temporary error") return time.Time{} }, wait: func(context.Context, time.Duration) error { t.Fatal("wait called for non-temporary error") return nil }, } _, err := reader.ReadVideo(context.Background()) if !errors.Is(err, unavailableErr) { t.Fatalf("ReadVideo() error = %v, want %v", err, unavailableErr) } if localSource.calls != 1 { t.Errorf("source read calls = %d, want 1", localSource.calls) } } func TestVideoReaderCancellationDuringTemporaryWait(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) localSource := &fakeLocalVideoSource{ results: []localVideoReadResult{{err: temporaryVideoError(errors.New("early"))}}, } reader := &videoReader{ source: localSource, readTimeout: 200 * time.Millisecond, unavailableAfter: 2 * time.Second, retryDelay: 10 * time.Millisecond, now: func() time.Time { return time.Unix(100, 0) }, wait: func(ctx context.Context, _ time.Duration) error { cancel() return ctx.Err() }, } _, err := reader.ReadVideo(ctx) if !errors.Is(err, context.Canceled) { t.Fatalf("ReadVideo() error = %v, want context.Canceled", err) } if localSource.calls != 1 { t.Errorf("source read calls = %d, want 1", localSource.calls) } } func TestVideoReaderCloseDelegatesToSource(t *testing.T) { closeErr := errors.New("close failed") localSource := &fakeLocalVideoSource{closeErr: closeErr} reader := &videoReader{source: localSource} err := reader.Close() if !errors.Is(err, closeErr) { t.Fatalf("Close() error = %v, want %v", err, closeErr) } if !localSource.closed { t.Fatal("local source was not closed") } }