/**
 * @file
 * @brief Unit tests for VideoCacheThread helper methods
 * @author Jonathan Thomas
 *
 * @ref License
 */

// Copyright (c) 2008-2025 OpenShot Studios, LLC
//
// SPDX-License-Identifier: LGPL-3.0-or-later

#include <memory>
#include "openshot_catch.h"

#include "Qt/VideoCacheThread.h"
#include "CacheMemory.h"
#include "ReaderBase.h"
#include "Frame.h"
#include "Settings.h"
#include "FFmpegReader.h"
#include "Timeline.h"

using namespace openshot;

// ----------------------------------------------------------------------------
// TestableVideoCacheThread: expose protected/internal members for testing
//
class TestableVideoCacheThread : public VideoCacheThread {
public:
    using VideoCacheThread::computeDirection;
    using VideoCacheThread::computeWindowBounds;
    using VideoCacheThread::clearCacheIfPaused;
    using VideoCacheThread::prefetchWindow;
    using VideoCacheThread::handleUserSeek;
    using VideoCacheThread::handleUserSeekWithPreroll;
    using VideoCacheThread::computePrerollFrames;

    int64_t getLastCachedIndex() const { return last_cached_index.load(); }
    void    setLastCachedIndex(int64_t v) { last_cached_index.store(v); }
    void    setPlayhead(int64_t v) { requested_display_frame.store(v); }
    void    setMinFramesAhead(int64_t v) { min_frames_ahead.store(v); }
    void    setLastDir(int d) { last_dir.store(d); }
    void    forceUserSeekFlag() { userSeeked.store(true); }
    bool    isScrubbing() const { return scrub_active.load(); }
    bool    getUserSeekedFlag() const { return userSeeked.load(); }
    bool    getPrerollOnNextFill() const { return preroll_on_next_fill.load(); }
    bool    getClearCacheOnNextFill() const { return clear_cache_on_next_fill.load(); }
    int64_t getRequestedDisplayFrame() const { return requested_display_frame.load(); }
};

// ----------------------------------------------------------------------------
// TESTS
// ----------------------------------------------------------------------------

TEST_CASE("computeDirection: respects speed and last_dir", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;

    // Default: speed=0, last_dir initialized to +1
    CHECK(thread.computeDirection() == 1);

    // Positive speed
    thread.setSpeed(3);
    CHECK(thread.computeDirection() == 1);
    CHECK(thread.getSpeed() == 3);

    // Negative speed
    thread.setSpeed(-2);
    CHECK(thread.computeDirection() == -1);
    CHECK(thread.getSpeed() == -2);

    // Pause should preserve last_dir = -1
    thread.setSpeed(0);
    CHECK(thread.computeDirection() == -1);

    // Manually override last_dir to +1, then pause
    thread.setLastDir(1);
    thread.setSpeed(0);
    CHECK(thread.computeDirection() == 1);
}

TEST_CASE("computeWindowBounds: forward and backward bounds, clamped", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    int64_t wb, we;

    // Forward direction, normal case
    thread.computeWindowBounds(/*playhead=*/10, /*dir=*/1, /*ahead_count=*/5, /*timeline_end=*/50, wb, we);
    CHECK(wb == 10);
    CHECK(we == 15);

    // Forward direction, at timeline edge
    thread.computeWindowBounds(/*playhead=*/47, /*dir=*/1, /*ahead_count=*/10, /*timeline_end=*/50, wb, we);
    CHECK(wb == 47);
    CHECK(we == 50);  // clamped to 50

    // Backward direction, normal
    thread.computeWindowBounds(/*playhead=*/20, /*dir=*/-1, /*ahead_count=*/7, /*timeline_end=*/100, wb, we);
    CHECK(wb == 13);
    CHECK(we == 20);

    // Backward direction, window_begin < 1
    thread.computeWindowBounds(/*playhead=*/3, /*dir=*/-1, /*ahead_count=*/10, /*timeline_end=*/100, wb, we);
    CHECK(wb == 1);   // clamped
    CHECK(we == 3);
}

TEST_CASE("isReady: requires cached frames ahead of playhead", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;

    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(60,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    thread.Reader(&timeline);

    thread.setMinFramesAhead(30);
    thread.setPlayhead(200);
    thread.setSpeed(1);

    thread.setLastCachedIndex(200);
    CHECK(!thread.isReady());

    thread.setLastCachedIndex(229);
    CHECK(!thread.isReady());

    thread.setLastCachedIndex(230);
    CHECK(thread.isReady());

    thread.setSpeed(-1);
    thread.setLastCachedIndex(200);
    CHECK(!thread.isReady());

    thread.setLastCachedIndex(171);
    CHECK(!thread.isReady());

    thread.setLastCachedIndex(170);
    CHECK(thread.isReady());
}

TEST_CASE("isReady: clamps preroll requirement at timeline boundaries", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;

    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(30,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    thread.Reader(&timeline);

    const int64_t end = timeline.info.video_length;
    REQUIRE(end > 10);

    // Forward near end: only a few frames remain, so don't require full preroll.
    thread.setMinFramesAhead(30);
    thread.setSpeed(1);
    thread.setPlayhead(end - 5);
    thread.setLastCachedIndex(end - 4);
    CHECK(!thread.isReady());
    thread.setLastCachedIndex(end);
    CHECK(thread.isReady());

    // Backward near start: only a few frames exist behind playhead.
    thread.setMinFramesAhead(30);
    thread.setSpeed(-1);
    thread.setPlayhead(3);
    thread.setLastCachedIndex(2);
    CHECK(!thread.isReady());
    thread.setLastCachedIndex(1);
    CHECK(thread.isReady());
}

TEST_CASE("isReady: treats out-of-range playhead as timeline edge for readiness", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;

    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(30,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    thread.Reader(&timeline);

    const int64_t end = timeline.info.video_length;
    REQUIRE(end > 10);

    thread.setMinFramesAhead(30);
    thread.setSpeed(1);
    thread.setPlayhead(end + 100);

    thread.setLastCachedIndex(end - 1);
    CHECK(!thread.isReady());
    thread.setLastCachedIndex(end);
    CHECK(thread.isReady());
}

TEST_CASE("clearCacheIfPaused: clears only when paused and not in cache", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);

    // Create a Timeline so that clearCacheIfPaused can call ClearAllCache safely
    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(24,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    timeline.SetCache(&cache);
    thread.Reader(&timeline);

    // Add a frame so Contains returns true for 5 and 10
    cache.Add(std::make_shared<Frame>(5, 0, 0));
    cache.Add(std::make_shared<Frame>(10, 0, 0));

    // Paused, playhead not in cache → should clear all cache
    bool didClear = thread.clearCacheIfPaused(/*playhead=*/42, /*paused=*/true, &cache);
    CHECK(didClear);
    CHECK(cache.Count() == 0);

    // Re-add a frame for next checks
    cache.Add(std::make_shared<Frame>(5, 0, 0));

    // Paused, but playhead IS in cache → no clear
    didClear = thread.clearCacheIfPaused(/*playhead=*/5, /*paused=*/true, &cache);
    CHECK(!didClear);
    CHECK(cache.Contains(5));

    // Not paused → should not clear even if playhead missing
    didClear = thread.clearCacheIfPaused(/*playhead=*/99, /*paused=*/false, &cache);
    CHECK(!didClear);
    CHECK(cache.Contains(5));
}

TEST_CASE("clearCacheIfPaused: clears when paused past timeline end and playhead frame is missing", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);

    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(24,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    timeline.SetCache(&cache);
    thread.Reader(&timeline);

    const int64_t end = timeline.info.video_length;
    REQUIRE(end > 1);

    cache.Add(std::make_shared<Frame>(end - 1, 0, 0));
    const int64_t initial_count = cache.Count();
    REQUIRE(initial_count > 0);

    const bool didClear = thread.clearCacheIfPaused(/*playhead=*/end + 12, /*paused=*/true, &cache);
    CHECK(didClear);
    CHECK(cache.Count() == 0);
}

TEST_CASE("clearCacheIfPaused: does not clear when paused past timeline end and end frame is cached", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);

    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(24,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    timeline.SetCache(&cache);
    thread.Reader(&timeline);

    const int64_t end = timeline.info.video_length;
    REQUIRE(end > 1);

    cache.Add(std::make_shared<Frame>(end, 0, 0));
    const int64_t initial_count = cache.Count();
    REQUIRE(initial_count > 0);

    const bool didClear = thread.clearCacheIfPaused(/*playhead=*/end + 12, /*paused=*/true, &cache);
    CHECK(!didClear);
    CHECK(cache.Count() == initial_count);
}

TEST_CASE("handleUserSeek: sets last_cached_index to playhead - dir", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;

    thread.setLastCachedIndex(100);
    thread.handleUserSeek(/*playhead=*/50, /*dir=*/1);
    CHECK(thread.getLastCachedIndex() == 49);

    thread.handleUserSeek(/*playhead=*/50, /*dir=*/-1);
    CHECK(thread.getLastCachedIndex() == 51);
}

TEST_CASE("handleUserSeekWithPreroll: offsets start by preroll frames", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;

    thread.handleUserSeekWithPreroll(/*playhead=*/60, /*dir=*/1, /*timeline_end=*/200, /*preroll_frames=*/30);
    CHECK(thread.getLastCachedIndex() == 29);

    thread.handleUserSeekWithPreroll(/*playhead=*/10, /*dir=*/1, /*timeline_end=*/200, /*preroll_frames=*/30);
    CHECK(thread.getLastCachedIndex() == 0);

    thread.handleUserSeekWithPreroll(/*playhead=*/1, /*dir=*/1, /*timeline_end=*/200, /*preroll_frames=*/30);
    CHECK(thread.getLastCachedIndex() == 0);

    thread.handleUserSeekWithPreroll(/*playhead=*/60, /*dir=*/-1, /*timeline_end=*/200, /*preroll_frames=*/30);
    CHECK(thread.getLastCachedIndex() == 91);
}

TEST_CASE("prefetchWindow: forward caching with FFmpegReader & CacheMemory", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);

    // Use a real test file via FFmpegReader
    std::string path = std::string(TEST_MEDIA_PATH) + "sintel_trailer-720p.mp4";
    FFmpegReader reader(path);
    reader.Open();

    // Setup: window [1..5], dir=1, last_cached_index=0
    thread.setLastCachedIndex(0);
    int64_t window_begin = 1, window_end = 5;

    bool wasFull = thread.prefetchWindow(&cache, window_begin, window_end, /*dir=*/1, &reader);
    CHECK(!wasFull);

    // Should have cached frames 1..5
    CHECK(thread.getLastCachedIndex() == window_end);
    for (int64_t f = 1; f <= 5; ++f) {
        CHECK(cache.Contains(f));
    }

    // Now window is full; next prefetch should return true
    wasFull = thread.prefetchWindow(&cache, window_begin, window_end, /*dir=*/1, &reader);
    CHECK(wasFull);
    CHECK(thread.getLastCachedIndex() == window_end);
}

TEST_CASE("prefetchWindow: backward caching with FFmpegReader & CacheMemory", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);

    // Use a real test file via FFmpegReader
    std::string path = std::string(TEST_MEDIA_PATH) + "sintel_trailer-720p.mp4";
    FFmpegReader reader(path);
    reader.Open();

    // Setup: window [10..15], dir=-1, last_cached_index=16
    thread.setLastCachedIndex(16);
    int64_t window_begin = 10, window_end = 15;

    bool wasFull = thread.prefetchWindow(&cache, window_begin, window_end, /*dir=*/-1, &reader);
    CHECK(!wasFull);

    // Should have cached frames 15..10
    CHECK(thread.getLastCachedIndex() == window_begin);
    for (int64_t f = 10; f <= 15; ++f) {
        CHECK(cache.Contains(f));
    }

    // Next call should return true
    wasFull = thread.prefetchWindow(&cache, window_begin, window_end, /*dir=*/-1, &reader);
    CHECK(wasFull);
    CHECK(thread.getLastCachedIndex() == window_begin);
}

TEST_CASE("prefetchWindow: interrupt on userSeeked flag", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);

    // Use a real test file via FFmpegReader
    std::string path = std::string(TEST_MEDIA_PATH) + "sintel_trailer-720p.mp4";
    FFmpegReader reader(path);
    reader.Open();

    // Window [20..30], dir=1, last_cached_index=19
    thread.setLastCachedIndex(19);
    int64_t window_begin = 20, window_end = 30;

    // Subclass CacheMemory to interrupt on frame 23
    class InterruptingCache : public CacheMemory {
    public:
        TestableVideoCacheThread* tcb;
        InterruptingCache(int64_t maxb, TestableVideoCacheThread* t)
            : CacheMemory(maxb), tcb(t) {}
        void Add(std::shared_ptr<openshot::Frame> frame) override {
            int64_t idx = frame->number;  // use public member 'number'
            CacheMemory::Add(frame);
            if (idx == 23) {
                tcb->forceUserSeekFlag();
            }
        }
    } interruptingCache(/*max_bytes=*/100000000, &thread);

    bool wasFull = thread.prefetchWindow(&interruptingCache,
                                          window_begin,
                                          window_end,
                                          /*dir=*/1,
                                          &reader);

    // Should stop at 23
    CHECK(thread.getLastCachedIndex() == 23);
    CHECK(!wasFull);
}

TEST_CASE("Seek preview: preserves playhead frame when paused and inside cache", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);
    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(24,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    timeline.SetCache(&cache);
    thread.Reader(&timeline);

    cache.Add(std::make_shared<Frame>(100, 0, 0));
    cache.Add(std::make_shared<Frame>(101, 0, 0));
    REQUIRE(cache.Count() >= 2);

    thread.Seek(/*new_position=*/100, /*start_preroll=*/false);

    CHECK(thread.isScrubbing());
    CHECK(!thread.getUserSeekedFlag());
    CHECK(!thread.getPrerollOnNextFill());
    CHECK(cache.Contains(100));
    CHECK(cache.Count() >= 2);
}

TEST_CASE("Seek preview: outside cache marks uncached without preroll", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);
    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(24,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    timeline.SetCache(&cache);
    thread.Reader(&timeline);

    cache.Add(std::make_shared<Frame>(10, 0, 0));
    cache.Add(std::make_shared<Frame>(11, 0, 0));
    REQUIRE(cache.Count() >= 2);

    thread.Seek(/*new_position=*/300, /*start_preroll=*/false);

    CHECK(thread.isScrubbing());
    CHECK(thread.getUserSeekedFlag());
    CHECK(!thread.getPrerollOnNextFill());
    CHECK(cache.Count() >= 2);
}

TEST_CASE("Seek commit: exits scrub mode and enables preroll when uncached", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);
    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(24,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    timeline.SetCache(&cache);
    thread.Reader(&timeline);

    thread.Seek(/*new_position=*/200, /*start_preroll=*/false);
    CHECK(thread.isScrubbing());

    thread.Seek(/*new_position=*/200, /*start_preroll=*/true);

    CHECK(!thread.isScrubbing());
    CHECK(thread.getUserSeekedFlag());
    CHECK(thread.getPrerollOnNextFill());
}

TEST_CASE("Seek commit: paused in-range seek preserves cached window state", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);
    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(24,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    timeline.SetCache(&cache);
    thread.Reader(&timeline);

    cache.Add(std::make_shared<Frame>(120, 0, 0));
    cache.Add(std::make_shared<Frame>(121, 0, 0));
    REQUIRE(cache.Count() >= 2);

    // Simulate existing cache progress so we can verify no baseline reset.
    thread.setLastCachedIndex(180);

    thread.Seek(/*new_position=*/120, /*start_preroll=*/true);

    CHECK(!thread.isScrubbing());
    CHECK(!thread.getUserSeekedFlag());
    CHECK(!thread.getPrerollOnNextFill());
    CHECK(thread.getLastCachedIndex() == 180);
    CHECK(cache.Contains(120));
}

TEST_CASE("Seek commit: paused scrub preview then same-frame commit preserves cache", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);
    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(24,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    timeline.SetCache(&cache);
    thread.Reader(&timeline);

    cache.Add(std::make_shared<Frame>(140, 0, 0));
    cache.Add(std::make_shared<Frame>(141, 0, 0));
    REQUIRE(cache.Count() >= 2);

    thread.setLastCachedIndex(210);

    // Typical paused seek flow: preview move, then commit same frame.
    thread.Seek(/*new_position=*/140, /*start_preroll=*/false);
    REQUIRE(thread.isScrubbing());
    REQUIRE(thread.getRequestedDisplayFrame() == 140);

    thread.Seek(/*new_position=*/140, /*start_preroll=*/true);

    CHECK(!thread.isScrubbing());
    CHECK(!thread.getUserSeekedFlag());
    CHECK(!thread.getPrerollOnNextFill());
    CHECK(thread.getLastCachedIndex() == 210);
    CHECK(cache.Contains(140));
    CHECK(cache.Count() >= 2);
}

TEST_CASE("Seek preview: paused out-of-range seek clamps to end and preserves cache", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);
    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(24,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    timeline.SetCache(&cache);
    thread.Reader(&timeline);

    const int64_t end = timeline.info.video_length;
    REQUIRE(end > 1);

    cache.Add(std::make_shared<Frame>(end, 0, 0));
    cache.Add(std::make_shared<Frame>(end - 1, 0, 0));
    thread.setLastCachedIndex(end - 1);

    thread.Seek(/*new_position=*/end + 24, /*start_preroll=*/false);

    CHECK(thread.isScrubbing());
    CHECK(!thread.getUserSeekedFlag());
    CHECK(thread.getRequestedDisplayFrame() == end + 24);
    CHECK(thread.getLastCachedIndex() == end - 1);
    CHECK(cache.Contains(end));
}

TEST_CASE("Seek commit: paused out-of-range seek past end enables cache rebuild when end is uncached", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);
    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(24,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    timeline.SetCache(&cache);
    thread.Reader(&timeline);

    const int64_t end = timeline.info.video_length;
    REQUIRE(end > 1);

    cache.Add(std::make_shared<Frame>(1, 0, 0));
    cache.Add(std::make_shared<Frame>(2, 0, 0));
    thread.setLastCachedIndex(2);

    thread.Seek(/*new_position=*/end + 24, /*start_preroll=*/true);

    CHECK(!thread.isScrubbing());
    CHECK(thread.getUserSeekedFlag());
    CHECK(thread.getPrerollOnNextFill());
    CHECK(thread.getRequestedDisplayFrame() == end + 24);
    CHECK(thread.getLastCachedIndex() == end - 1);
    CHECK(!cache.Contains(end));
}

TEST_CASE("Seek commit: playback jump to cached frame preserves cache", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);
    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(24,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    timeline.SetCache(&cache);
    thread.Reader(&timeline);

    cache.Add(std::make_shared<Frame>(150, 0, 0));
    cache.Add(std::make_shared<Frame>(220, 0, 0));
    thread.setPlayhead(220);
    thread.setSpeed(1);
    thread.setLastCachedIndex(230);

    thread.Seek(/*new_position=*/150, /*start_preroll=*/true);

    CHECK(!thread.isScrubbing());
    CHECK(!thread.getUserSeekedFlag());
    CHECK(!thread.getPrerollOnNextFill());
    CHECK(!thread.getClearCacheOnNextFill());
    CHECK(thread.getRequestedDisplayFrame() == 150);
    CHECK(thread.getLastCachedIndex() == 230);
}

TEST_CASE("Seek commit: playback click inside active cached window preserves cache", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);
    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(24,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    timeline.SetCache(&cache);
    thread.Reader(&timeline);

    cache.Add(std::make_shared<Frame>(220, 0, 0));
    cache.Add(std::make_shared<Frame>(230, 0, 0));
    cache.Add(std::make_shared<Frame>(260, 0, 0));
    thread.setPlayhead(220);
    thread.setSpeed(1);
    thread.setLastCachedIndex(260);

    thread.Seek(/*new_position=*/230, /*start_preroll=*/true);

    CHECK(!thread.isScrubbing());
    CHECK(!thread.getUserSeekedFlag());
    CHECK(!thread.getPrerollOnNextFill());
    CHECK(!thread.getClearCacheOnNextFill());
    CHECK(thread.getRequestedDisplayFrame() == 230);
    CHECK(thread.getLastCachedIndex() == 260);
}

TEST_CASE("NotifyPlaybackPosition: ignored while scrubbing, applied after commit", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);
    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(24,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    timeline.SetCache(&cache);
    thread.Reader(&timeline);

    thread.Seek(/*new_position=*/120, /*start_preroll=*/false);
    REQUIRE(thread.isScrubbing());
    CHECK(thread.getRequestedDisplayFrame() == 120);

    thread.NotifyPlaybackPosition(/*new_position=*/25);
    CHECK(thread.getRequestedDisplayFrame() == 120);

    thread.Seek(/*new_position=*/120, /*start_preroll=*/true);
    REQUIRE(!thread.isScrubbing());

    thread.NotifyPlaybackPosition(/*new_position=*/25);
    CHECK(thread.getRequestedDisplayFrame() == 25);
}

TEST_CASE("Seek non-preroll: playback uncached target does not force cache rebuild", "[VideoCacheThread]") {
    TestableVideoCacheThread thread;
    CacheMemory cache(/*max_bytes=*/100000000);
    Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(24,1),
                      /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO);
    timeline.SetCache(&cache);
    thread.Reader(&timeline);

    cache.Add(std::make_shared<Frame>(220, 0, 0));
    cache.Add(std::make_shared<Frame>(221, 0, 0));
    thread.setPlayhead(220);
    thread.setSpeed(1);
    thread.setLastCachedIndex(230);

    thread.Seek(/*new_position=*/120, /*start_preroll=*/false);

    CHECK(!thread.isScrubbing());
    CHECK(!thread.getUserSeekedFlag());
    CHECK(!thread.getPrerollOnNextFill());
    CHECK(!thread.getClearCacheOnNextFill());
    CHECK(thread.getRequestedDisplayFrame() == 120);
    CHECK(thread.getLastCachedIndex() == 230);
}
