From 8bb48d51eabc57efa419b0df7fcd2fc8fd8fcd29 Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Thu, 27 Aug 2026 09:34:21 +0300 Subject: [PATCH] add video playback attempt --- internal/playback/video_attempt.go | 55 ++++++ internal/playback/video_attempt_test.go | 213 ++++++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 internal/playback/video_attempt.go create mode 100644 internal/playback/video_attempt_test.go diff --git a/internal/playback/video_attempt.go b/internal/playback/video_attempt.go new file mode 100644 index 0000000..798f3c3 --- /dev/null +++ b/internal/playback/video_attempt.go @@ -0,0 +1,55 @@ +package playback + +import ( + "context" + "errors" + "fmt" +) + +type videoSinkError struct { + err error +} + +func (e *videoSinkError) Error() string { + return fmt.Sprintf("consume video: %v", e.err) +} + +func (e *videoSinkError) Unwrap() error { + return e.err +} + +func runVideoAttempt( + ctx context.Context, + factory VideoReaderFactory, + sink VideoSink, + config FeedConfig, +) (resultErr error) { + reader, err := factory.OpenVideo(ctx, config) + if err != nil { + return fmt.Errorf("open video: %w", err) + } + + defer func() { + if closeErr := reader.Close(); closeErr != nil { + closeErr = fmt.Errorf("close video: %w", closeErr) + resultErr = errors.Join(resultErr, closeErr) + } + }() + + for { + frame, err := reader.ReadVideo(ctx) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("read video: %w", err) + } + + if err := sink.ConsumeVideo(ctx, frame); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return &videoSinkError{err: err} + } + } +} diff --git a/internal/playback/video_attempt_test.go b/internal/playback/video_attempt_test.go new file mode 100644 index 0000000..b0ac608 --- /dev/null +++ b/internal/playback/video_attempt_test.go @@ -0,0 +1,213 @@ +package playback + +import ( + "context" + "errors" + "testing" +) + +type fakeVideoFactory struct { + reader VideoReader + err error + calls int +} + +func (f *fakeVideoFactory) OpenVideo( + context.Context, + FeedConfig, +) (VideoReader, error) { + f.calls++ + return f.reader, f.err +} + +type fakeVideoReader struct { + frames []VideoFrame + readErr error + closeErr error + readCalls int + closed bool + read func(context.Context) (VideoFrame, error) +} + +func (r *fakeVideoReader) ReadVideo(ctx context.Context) (VideoFrame, error) { + r.readCalls++ + if r.read != nil { + return r.read(ctx) + } + if len(r.frames) == 0 { + return VideoFrame{}, r.readErr + } + frame := r.frames[0] + r.frames = r.frames[1:] + return frame, nil +} + +func (r *fakeVideoReader) Close() error { + r.closed = true + return r.closeErr +} + +type fakeVideoSink struct { + frames []VideoFrame + err error +} + +func (s *fakeVideoSink) ConsumeVideo(_ context.Context, frame VideoFrame) error { + s.frames = append(s.frames, frame) + return s.err +} + +func TestRunVideoAttemptOpenFailure(t *testing.T) { + openErr := errors.New("open failed") + factory := &fakeVideoFactory{err: openErr} + sink := &fakeVideoSink{} + + err := runVideoAttempt( + context.Background(), + factory, + sink, + FeedConfig{}, + ) + + if !errors.Is(err, openErr) { + t.Fatalf("runVideoAttempt() error = %v, want %v", err, openErr) + } + if factory.calls != 1 { + t.Errorf("factory calls = %d, want 1", factory.calls) + } + if len(sink.frames) != 0 { + t.Fatalf("consumed frame count = %d, want 0", len(sink.frames)) + } +} + +func TestRunVideoAttemptConsumesFrameThenReturnsReadFailure(t *testing.T) { + readErr := errors.New("read failed") + wantFrame := VideoFrame{ + Index: 42, + Width: 1920, + Height: 1080, + Stride: 5120, + Size: 5120 * 1080, + Invalid: false, + Payload: []byte{1, 2, 3, 4}, + } + reader := &fakeVideoReader{ + frames: []VideoFrame{wantFrame}, + readErr: readErr, + } + sink := &fakeVideoSink{} + + err := runVideoAttempt( + context.Background(), + &fakeVideoFactory{reader: reader}, + sink, + FeedConfig{}, + ) + + if !errors.Is(err, readErr) { + t.Fatalf("runVideoAttempt() error = %v, want %v", err, readErr) + } + if !reader.closed { + t.Fatal("reader was not closed") + } + if reader.readCalls != 2 { + t.Errorf("read calls = %d, want 2", reader.readCalls) + } + if len(sink.frames) != 1 { + t.Fatalf("consumed frame count = %d, want 1", len(sink.frames)) + } + gotFrame := sink.frames[0] + if gotFrame.Index != wantFrame.Index || + gotFrame.Width != wantFrame.Width || + gotFrame.Height != wantFrame.Height || + gotFrame.Stride != wantFrame.Stride || + gotFrame.Size != wantFrame.Size || + gotFrame.Invalid != wantFrame.Invalid { + t.Errorf("consumed frame metadata = %+v, want %+v", gotFrame, wantFrame) + } + if len(gotFrame.Payload) == 0 { + t.Fatal("consumed payload is empty") + } + if &gotFrame.Payload[0] != &wantFrame.Payload[0] { + t.Fatal("video payload was copied") + } +} + +func TestRunVideoAttemptSinkFailureStopsReadingAndCloses(t *testing.T) { + sinkErr := errors.New("renderer unavailable") + reader := &fakeVideoReader{ + frames: []VideoFrame{ + {Index: 1, Payload: []byte{1}}, + {Index: 2, Payload: []byte{2}}, + }, + } + sink := &fakeVideoSink{err: sinkErr} + + err := runVideoAttempt( + context.Background(), + &fakeVideoFactory{reader: reader}, + sink, + FeedConfig{}, + ) + + if !errors.Is(err, sinkErr) { + t.Fatalf("runVideoAttempt() error = %v, want %v", err, sinkErr) + } + var typedErr *videoSinkError + if !errors.As(err, &typedErr) { + t.Fatalf("runVideoAttempt() error type = %T, want *videoSinkError", err) + } + if reader.readCalls != 1 { + t.Errorf("read calls = %d, want 1", reader.readCalls) + } + if !reader.closed { + t.Fatal("reader was not closed") + } +} + +func TestRunVideoAttemptCanceledRead(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + reader := &fakeVideoReader{ + read: func(ctx context.Context) (VideoFrame, error) { + cancel() + return VideoFrame{}, ctx.Err() + }, + } + + err := runVideoAttempt( + ctx, + &fakeVideoFactory{reader: reader}, + &fakeVideoSink{}, + FeedConfig{}, + ) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("runVideoAttempt() error = %v, want context.Canceled", err) + } + if !reader.closed { + t.Fatal("reader was not closed") + } +} + +func TestRunVideoAttemptJoinsReadAndCloseErrors(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + reader := &fakeVideoReader{ + readErr: readErr, + closeErr: closeErr, + } + + err := runVideoAttempt( + context.Background(), + &fakeVideoFactory{reader: reader}, + &fakeVideoSink{}, + FeedConfig{}, + ) + + if !errors.Is(err, readErr) { + t.Errorf("runVideoAttempt() error does not contain read error: %v", err) + } + if !errors.Is(err, closeErr) { + t.Errorf("runVideoAttempt() error does not contain close error: %v", err) + } +}