rx-player
Version:
Canal+ HTML5 Video Player
900 lines (750 loc) • 26.6 kB
text/typescript
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<RepresentationScoreCalculator>(
{
addSample: noop,
getEstimate: () => createScoreData(1, ScoreConfidenceLevel.LOW),
getLastStableRepresentation: () => null,
},
{},
);
const DummyLastEstimateStorage = makeMockedClass<LastEstimateStorage>(
{
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]);
});
});
});
});