import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { IRepresentation } from "../../../manifest"; import { DummyRepresentation, DummyAdaptation, DummyPeriod, DummyManifest, createSegment, } from "../../../manifest/classes/__tests__/mocks"; import noop from "../../../utils/noop"; import { makeMockedClass } from "../../../utils/test-utils"; import GuessBasedChooser from "../guess_based_chooser"; import type LastEstimateStorage from "../utils/last_estimate_storage"; import { ABRAlgorithmType } from "../utils/last_estimate_storage"; import type { IRequestInfo } from "../utils/pending_requests_store"; import type { IRepresentationMaintainabilityScore } from "../utils/representation_score_calculator"; import type RepresentationScoreCalculator from "../utils/representation_score_calculator"; import { ScoreConfidenceLevel } from "../utils/representation_score_calculator"; const mocks = vi.hoisted(() => ({ estimateRequestBandwidth: vi.fn(), getMonotonicTimeStamp: vi.fn(), })); vi.mock("../../../log", () => ({ default: { debug: vi.fn(), error: vi.fn(), }, })); vi.mock("../../../utils/monotonic_timestamp", () => ({ default: mocks.getMonotonicTimeStamp, })); vi.mock("../network_analyzer", () => ({ estimateRequestBandwidth: mocks.estimateRequestBandwidth, })); function createRepresentation(id: string, bitrate: number): IRepresentation { return new DummyRepresentation({ id, bitrate, }); } function createScoreData( score: number, confidenceLevel: ScoreConfidenceLevel, ): IRepresentationMaintainabilityScore { return { score, confidenceLevel }; } function createRequest( representationId: string, segmentDuration: number, requestTimestamp: number, isInit = false, ): IRequestInfo { return { content: { representation: new DummyRepresentation({ id: representationId, }), segment: createSegment({ duration: segmentDuration, isInit, }), adaptation: new DummyAdaptation(), period: new DummyPeriod(), manifest: new DummyManifest(), }, requestTimestamp, progress: [], }; } const DummyScoreCalculator = makeMockedClass( { addSample: noop, getEstimate: () => createScoreData(1, ScoreConfidenceLevel.LOW), getLastStableRepresentation: () => null, }, {}, ); const DummyLastEstimateStorage = makeMockedClass( { update: noop, }, { bandwidth: undefined, representation: null, algorithmType: ABRAlgorithmType.None }, ); describe("GuessBasedChooser", () => { let mockScoreCalculator: RepresentationScoreCalculator; let mockLastEstimateStorage: LastEstimateStorage; const representations = [ createRepresentation("rep-1", 100000), createRepresentation("rep-2", 500000), createRepresentation("rep-3", 1000000), createRepresentation("rep-4", 2000000), createRepresentation("rep-5", 3000000), ]; beforeEach(() => { mocks.getMonotonicTimeStamp.mockReturnValue(10000); mockScoreCalculator = new DummyScoreCalculator(); mockLastEstimateStorage = new DummyLastEstimateStorage(); }); afterEach(() => { vi.resetAllMocks(); }); describe("constructor", () => { it("should initialize with correct default values", () => { const chooser = new GuessBasedChooser(mockScoreCalculator, mockLastEstimateStorage); expect(chooser).toBeDefined(); }); }); describe("getGuess", () => { describe("when last chosen representation is null", () => { it("should return null - nothing to base guess on", () => { mockLastEstimateStorage.representation = null; const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 5, speed: 1 }, representations[2], 1500000, [], ); expect(result).toBeNull(); }); }); describe("when incomingBestBitrate > lastChosenRep.bitrate", () => { it("should return null - ABR estimates are already superior", () => { mockLastEstimateStorage.representation = representations[1]; // 500000 mockLastEstimateStorage.algorithmType = ABRAlgorithmType.GuessBased; const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 5, speed: 1 }, representations[1], 1000000, // Higher than last chosen [], ); expect(result).toBeNull(); }); it("should reset consecutive wrong guesses when algorithm was GuessBased", () => { mockLastEstimateStorage.representation = representations[1]; mockLastEstimateStorage.algorithmType = ABRAlgorithmType.GuessBased; const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); // First call should reset counter chooser.getGuess( representations, { bufferGap: 5, speed: 1 }, representations[1], 1000000, [], ); // Subsequent calls should still work (counter was reset) const result = chooser.getGuess( representations, { bufferGap: 5, speed: 1 }, representations[1], 1000000, [], ); expect(result).toBeNull(); }); }); describe("when not in guessing mode yet", () => { beforeEach(() => { mockLastEstimateStorage.representation = representations[1]; mockLastEstimateStorage.algorithmType = ABRAlgorithmType.BufferBased; }); it("should return null when score data is undefined", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue(undefined); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 5, speed: 1 }, representations[1], 100000, [], ); expect(result).toBeNull(); }); it("should return next representation when conditions allow guessing higher", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.5, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[1], 100000, [], ); expect(result).toBe(representations[2]); }); it("should return null when buffer gap is too low to guess higher", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.5, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 1.5, speed: 1 }, // Below 2.5 threshold representations[1], 100000, [], ); expect(result).toBeNull(); }); it("should return null when confidence level is not HIGH", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.5, ScoreConfidenceLevel.LOW), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[1], 100000, [], ); expect(result).toBeNull(); }); it("should return null when score/speed ratio is too low", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.005, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[1], 100000, [], ); expect(result).toBeNull(); }); it("should account for playback speed when checking score", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(2.0, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 2.0 }, // Speed 2x requires higher score representations[1], 100000, [], ); expect(result).toBeNull(); // 2.0 / 2.0 = 1.0, not > 1.01 }); it("should return null when already at highest representation", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.5, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[4], // Highest representation 100000, [], ); expect(result).toBeNull(); }); }); describe("when already in guessing mode", () => { beforeEach(() => { mockLastEstimateStorage.representation = representations[2]; // 1000000 mockLastEstimateStorage.algorithmType = ABRAlgorithmType.GuessBased; }); it("should take higher guess when conditions are met", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(2.0, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 1000000, [], ); expect(result).toBe(representations[3]); }); it("should take same guess when conditions are met with highest guess", () => { mockLastEstimateStorage.representation = representations[4]; vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(2.0, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 1000000, [], ); expect(result).toBe(representations[4]); }); it("should return last chosen representation when current differs", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.2, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[1], // Different from last chosen 100000, [], ); expect(result).toBe(representations[2]); }); it("should stop guess and downgrade when score is too low", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(0.9, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 100000, [], ); expect(result).toBe(representations[1]); // Previous representation }); it("should stop guess when buffer gap is low and score is marginal", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.1, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 0.5, speed: 1 }, // Below 0.6 threshold representations[2], 100000, [], ); expect(result).toBe(representations[1]); }); it("should stop guess when score is undefined and buffer gap is low", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue(undefined); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 0.5, speed: 1 }, representations[2], 100000, [], ); expect(result).toBe(representations[1]); }); it("should block guesses for increasing duration after consecutive wrong guesses", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(0.9, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); // First wrong guess chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 100000, [], ); // Try to guess again immediately mocks.getMonotonicTimeStamp.mockReturnValue(10000 + 5000); // 5 seconds later mockLastEstimateStorage.algorithmType = ABRAlgorithmType.BufferBased; const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[1], 100000, [], ); // Should be blocked (15 seconds block) expect(result).toBeNull(); }); it("should allow guessing after block period expires", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(0.9, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); // First wrong guess chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 100000, [], ); // Wait for block to expire mocks.getMonotonicTimeStamp.mockReturnValue(10000 + 20000); // 20 seconds later mockLastEstimateStorage.algorithmType = ABRAlgorithmType.BufferBased; vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.5, ScoreConfidenceLevel.HIGH), ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[1], 100000, [], ); // Should be allowed now expect(result).toBe(representations[2]); }); it("should guess higher when conditions allow in guessing mode", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.5, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 100000, [], ); expect(result).toBe(representations[3]); // Next higher representation }); it("should return current representation when score data is undefined and not stopping", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue(undefined); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 100000, [], ); expect(result).toBe(representations[2]); }); }); describe("request-based guess stopping", () => { beforeEach(() => { mockLastEstimateStorage.representation = representations[2]; mockLastEstimateStorage.algorithmType = ABRAlgorithmType.GuessBased; vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.2, ScoreConfidenceLevel.HIGH), ); }); it("should stop guess when init segment takes too long (>1000ms)", () => { const requests = [ createRequest("rep-3", 0, 8000, true), // 2000ms elapsed (10000 - 8000) ]; const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 100000, requests, ); expect(result).toBe(representations[1]); }); it("should not stop guess when init segment is fast enough", () => { const requests = [ createRequest("rep-3", 0, 9500, true), // 500ms elapsed ]; const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 100000, requests, ); expect(result).toBe(representations[3]); }); it("should stop guess when regular segment takes too long", () => { const requests = [ createRequest("rep-3", 2, 5000, false), // 5000ms elapsed, duration is 2s ]; const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 100000, requests, ); expect(result).toBe(representations[1]); // Should downgrade }); it("should not stop guess when regular segment is within acceptable time", () => { const requests = [ createRequest("rep-3", 2, 8500, false), // 1500ms elapsed, duration is 2s (threshold: 2200ms) ]; const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 100000, requests, ); expect(result).toBe(representations[3]); }); it("should stop guess when estimated bandwidth is too low", () => { mocks.estimateRequestBandwidth.mockReturnValue(700000); // 70% of bitrate const requests = [createRequest("rep-3", 2, 9000, false)]; const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 100000, requests, ); expect(result).toBe(representations[1]); }); it("should not consider requests for other representations", () => { const requests = [createRequest("rep-4", 2, 5000, false)]; vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.1, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 2.0, speed: 1 }, representations[2], 100000, requests, ); expect(result).toBe(representations[2]); }); it("should handle multiple requests and stop on any failing condition", () => { const requests = [ createRequest("rep-3", 2, 9500, false), // OK createRequest("rep-3", 2, 5000, false), // TOO SLOW - should trigger stop createRequest("rep-3", 2, 9000, false), // OK ]; const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 100000, requests, ); expect(result).toBe(representations[1]); }); }); describe("guess validation", () => { beforeEach(() => { mockLastEstimateStorage.representation = representations[2]; mockLastEstimateStorage.algorithmType = ABRAlgorithmType.GuessBased; }); it("should validate guess with high score and high confidence", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.6, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 100000, [], ); // If validated, should allow guessing higher next time const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 100000, [], ); expect(result).toBe(representations[3]); }); it("should validate guess when incoming bitrate matches and it's an improvement", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.2, ScoreConfidenceLevel.LOW), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 1000000, // Matches current bitrate [], ); // Should be validated and allow progression expect(result).toBe(representations[2]); }); }); describe("edge cases", () => { it("should handle infinite buffer gap correctly", () => { mockLastEstimateStorage.representation = representations[1]; mockLastEstimateStorage.algorithmType = ABRAlgorithmType.BufferBased; vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.5, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: Infinity, speed: 1 }, representations[1], 100000, [], ); expect(result).toBe(null); }); it("should handle NaN buffer gap by not guessing", () => { mockLastEstimateStorage.representation = representations[1]; mockLastEstimateStorage.algorithmType = ABRAlgorithmType.BufferBased; vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.5, ScoreConfidenceLevel.HIGH), ); const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); const result = chooser.getGuess( representations, { bufferGap: NaN, speed: 1 }, representations[1], 100000, [], ); expect(result).toBeNull(); }); it("should cap block duration at 120 seconds", () => { vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(0.9, ScoreConfidenceLevel.HIGH), ); mockLastEstimateStorage.algorithmType = ABRAlgorithmType.GuessBased; mockLastEstimateStorage.representation = representations[2]; const chooser = new GuessBasedChooser( mockScoreCalculator, mockLastEstimateStorage, ); // Trigger many consecutive wrong guesses for (let i = 0; i < 20; i++) { chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[2], 100000, [], ); mocks.getMonotonicTimeStamp.mockReturnValue(10000 + 1000 * (i + 1)); } // The block should be capped at 120 seconds max // After 120 seconds, should be able to guess again mocks.getMonotonicTimeStamp.mockReturnValue(10000 + 20000 + 120100); mockLastEstimateStorage.algorithmType = ABRAlgorithmType.BufferBased; mockLastEstimateStorage.representation = representations[1]; // Update to reflect actual downgraded state vi.spyOn(mockScoreCalculator, "getEstimate").mockReturnValue( createScoreData(1.5, ScoreConfidenceLevel.HIGH), ); const result = chooser.getGuess( representations, { bufferGap: 3, speed: 1 }, representations[1], 100000, [], ); expect(result).toBe(representations[2]); }); }); }); });