rx-player
Version:
Canal+ HTML5 Video Player
543 lines (476 loc) • 16.1 kB
text/typescript
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import type SegmentSinksStore from "../../segment_sinks";
import type { IBufferedChunk } from "../../segment_sinks";
import FreezeResolver from "../FreezeResolver";
import type { IFreezeResolverObservation } from "../FreezeResolver";
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/unbound-method */
const {
mockGetCurrent,
mockLogInfo,
mockLogWarn,
mockLogDebug,
mockGetMonotonicTimeStamp,
} = vi.hoisted(() => ({
mockGetCurrent: vi.fn(() => ({
UNFREEZING_SEEK_DELAY: 3000,
UNFREEZING_DELTA_POSITION: 0.001,
FREEZING_FLUSH_FAILURE_DELAY: {
MINIMUM: 1000,
MAXIMUM: 10000,
POSITION_DELTA: 0.1,
},
})),
mockLogInfo: vi.fn(),
mockLogWarn: vi.fn(),
mockLogDebug: vi.fn(),
mockGetMonotonicTimeStamp: vi.fn(),
}));
vi.mock("../../../config", () => ({
default: {
getCurrent: mockGetCurrent,
},
}));
vi.mock("../../../log", () => ({
default: {
info: mockLogInfo,
warn: mockLogWarn,
debug: mockLogDebug,
},
}));
vi.mock("../../../utils/monotonic_timestamp", () => ({
default: mockGetMonotonicTimeStamp,
}));
describe("FreezeResolver", () => {
let freezeResolver: FreezeResolver;
let mockSegmentSinksStore: SegmentSinksStore;
let mockTimestamp: number;
const createMockObservation = (
overrides: Partial<IFreezeResolverObservation> = {},
): IFreezeResolverObservation => ({
readyState: 4,
rebuffering: null,
freezing: null,
bufferGap: 10,
position: {
getPolled: () => 100,
} as any,
fullyLoaded: false,
...overrides,
});
const createMockSegmentSinksStore = (): SegmentSinksStore =>
({
getStatus: vi.fn(() => ({
type: "initialized",
value: {
getLastKnownInventory: vi.fn(() => []),
},
})),
}) as any;
beforeEach(() => {
mockTimestamp = 10000;
mockGetMonotonicTimeStamp.mockImplementation(() => mockTimestamp);
mockSegmentSinksStore = createMockSegmentSinksStore();
freezeResolver = new FreezeResolver(mockSegmentSinksStore);
});
afterEach(() => {
vi.resetAllMocks();
});
describe("onNewObservation - No freeze scenarios", () => {
it("should return null when playback is not frozen", () => {
const observation = createMockObservation({
readyState: 4,
freezing: null,
rebuffering: null,
});
const result = freezeResolver.onNewObservation(observation);
expect(result).toBeNull();
});
it("should return null when readyState is 1 but buffer gap is too small", () => {
const observation = createMockObservation({
readyState: 1,
bufferGap: 3, // Less than MINIMUM_BUFFER_GAP_AT_READY_STATE_1_BEFORE_FREEZING (6)
freezing: null,
rebuffering: null,
fullyLoaded: false,
});
const result = freezeResolver.onNewObservation(observation);
expect(result).toBeNull();
});
it("should ignore freeze when within ignore period", () => {
const observation1 = createMockObservation({
freezing: { timestamp: mockTimestamp },
});
// First observation triggers a flush
mockTimestamp = 10000;
freezeResolver.onNewObservation(observation1);
mockTimestamp = 13100; // Within UNFREEZING_SEEK_DELAY
const result = freezeResolver.onNewObservation(observation1);
expect(result).not.toBeNull();
// Second observation within ignore period
mockTimestamp = 13200; // Still within MINIMUM_TIME_BETWEEN_FREEZE_HANDLING (6000ms from flush)
const observation2 = createMockObservation({
freezing: { timestamp: 13200 },
});
const result2 = freezeResolver.onNewObservation(observation2);
expect(result2).toBeNull();
});
});
describe("onNewObservation - Freeze detection", () => {
it("should detect freeze when freezing status is set", () => {
const observation = createMockObservation({
freezing: { timestamp: mockTimestamp - 4000 },
bufferGap: 10,
});
const result = freezeResolver.onNewObservation(observation);
expect(result).not.toBeNull();
expect(mockLogInfo).toHaveBeenCalledWith(
"Freeze",
"Freeze detected",
expect.any(Object),
);
});
it("should detect freeze when readyState is 1 with sufficient buffer", () => {
const observation = createMockObservation({
readyState: 1,
bufferGap: 8, // Greater than MINIMUM_BUFFER_GAP_AT_READY_STATE_1_BEFORE_FREEZING
rebuffering: {
timestamp: mockTimestamp - 4000,
reason: "not-ready",
position: undefined,
},
});
const result = freezeResolver.onNewObservation(observation);
expect(result).not.toBeNull();
});
it("should detect freeze when fully loaded with readyState 1", () => {
const observation = createMockObservation({
readyState: 1,
bufferGap: 2,
fullyLoaded: true,
rebuffering: {
timestamp: mockTimestamp - 4000,
reason: "not-ready",
position: undefined,
},
});
const result = freezeResolver.onNewObservation(observation);
expect(result).not.toBeNull();
});
});
describe("onNewObservation - Flush strategy", () => {
it("should return flush resolution after freeze delay", () => {
const freezeTimestamp = mockTimestamp - 4000;
const observation = createMockObservation({
freezing: { timestamp: freezeTimestamp },
position: { getPolled: () => 100 } as any,
});
const result = freezeResolver.onNewObservation(observation);
expect(result).toEqual({
type: "flush",
value: { relativeSeek: 0.001 },
});
expect(mockLogDebug).toHaveBeenCalledWith("Freeze", "Trying to flush to un-freeze");
});
it("should not flush before UNFREEZING_SEEK_DELAY has passed", () => {
const freezeTimestamp = mockTimestamp - 2000; // Less than UNFREEZING_SEEK_DELAY (3000)
const observation = createMockObservation({
freezing: { timestamp: freezeTimestamp },
});
const result = freezeResolver.onNewObservation(observation);
expect(result).toBeNull();
});
});
describe("onNewObservation - Decipherability checks", () => {
it("should reload when buffer has undecipherable data", () => {
const mockSegment: IBufferedChunk = {
infos: {
representation: {
decipherable: false,
uniqueId: "rep1",
} as any,
},
start: 90,
end: 110,
} as any;
mockSegmentSinksStore.getStatus = vi.fn(
() =>
({
type: "initialized",
value: {
getLastKnownInventory: vi.fn(() => [mockSegment]),
},
}) as any,
);
const observation = createMockObservation({
rebuffering: {
timestamp: mockTimestamp - 5000,
reason: "not-ready",
position: undefined,
},
readyState: 1,
bufferGap: 8,
});
const result = freezeResolver.onNewObservation(observation);
expect(result).toEqual({
type: "reload",
value: null,
});
expect(mockLogWarn).toHaveBeenCalledWith(
"Freeze",
expect.stringContaining("undecipherable segments"),
);
});
it("should first flush when frozen with only decipherable encrypted data", () => {
const mockSegment: IBufferedChunk = {
infos: {
representation: {
decipherable: true,
uniqueId: "rep1",
} as any,
},
start: 90,
end: 110,
} as any;
mockSegmentSinksStore.getStatus = vi.fn(
() =>
({
type: "initialized",
value: {
getLastKnownInventory: vi.fn(() => [mockSegment]),
},
}) as any,
);
const observation = createMockObservation({
rebuffering: {
timestamp: mockTimestamp - 5000,
reason: "not-ready",
position: undefined,
},
readyState: 1,
bufferGap: 8,
});
const result = freezeResolver.onNewObservation(observation);
expect(result).toEqual({
type: "flush",
value: {
relativeSeek: 0.001,
},
});
});
});
describe("onNewObservation - Failed flush handling", () => {
it("should reload when flush fails and no representation change detected", () => {
// First: trigger a flush
mockTimestamp = 10000;
const observation1 = createMockObservation({
freezing: { timestamp: 6000 },
position: { getPolled: () => 100 } as any,
});
const flushResult = freezeResolver.onNewObservation(observation1);
expect(flushResult?.type).toBe("flush");
// Second: still frozen after flush, need to wait beyond the ignore period
// MINIMUM_TIME_BETWEEN_FREEZE_HANDLING is 6000ms
mockTimestamp = 17500; // 7500ms after flush, beyond ignore period (16000 + 1500)
const observation2 = createMockObservation({
freezing: { timestamp: 17500 },
position: { getPolled: () => 100.001 } as any, // Very close to flush position
});
const result = freezeResolver.onNewObservation(observation2);
expect(result).toEqual({
type: "reload",
value: null,
});
});
it("should avoid representation when freeze happens on representation switch", () => {
const mockSegment1: IBufferedChunk = {
infos: {
representation: { uniqueId: "rep1", bitrate: 1000000 } as any,
adaptation: { id: "video-ada" } as any,
period: { id: "period1" } as any,
},
start: 90,
end: 100,
bufferedStart: 90,
bufferedEnd: 100,
} as any;
const mockSegment2: IBufferedChunk = {
infos: {
representation: { uniqueId: "rep2", bitrate: 2000000 } as any,
adaptation: { id: "video-ada" } as any,
period: { id: "period1" } as any,
},
start: 100,
end: 110,
bufferedStart: 100,
bufferedEnd: 110,
} as any;
// Build history with representation switch
mockTimestamp = 10000;
mockSegmentSinksStore.getStatus = vi.fn(
() =>
({
type: "initialized",
value: {
getLastKnownInventory: vi.fn(() => [mockSegment1]),
},
}) as any,
);
freezeResolver.onNewObservation(
createMockObservation({ position: { getPolled: () => 95 } as any }),
);
mockTimestamp = 11000;
mockSegmentSinksStore.getStatus = vi.fn(
() =>
({
type: "initialized",
value: {
getLastKnownInventory: vi.fn(() => [mockSegment2]),
},
}) as any,
);
freezeResolver.onNewObservation(
createMockObservation({ position: { getPolled: () => 105 } as any }),
);
// Trigger flush
mockTimestamp = 15000;
const observation1 = createMockObservation({
freezing: { timestamp: 11000 }, // Frozen for 4000ms
position: { getPolled: () => 105 } as any,
});
const flushResult = freezeResolver.onNewObservation(observation1);
expect(flushResult?.type).toBe("flush");
// Still frozen after flush, need to wait beyond ignore period (6000ms from flush at 15000 = 21000)
mockTimestamp = 22000;
const observation2 = createMockObservation({
freezing: { timestamp: 22000 },
position: { getPolled: () => 105.001 } as any,
});
const result = freezeResolver.onNewObservation(observation2);
expect(result).toEqual({
type: "avoid-representations",
value: expect.arrayContaining([
expect.objectContaining({
representation: expect.objectContaining({ uniqueId: "rep2" }),
}),
]),
});
});
it("should reload when freeze happens on period switch", () => {
const mockSegment1: IBufferedChunk = {
infos: {
representation: { uniqueId: "rep1" } as any,
adaptation: { id: "video-ada" } as any,
period: { id: "period1" } as any,
},
start: 90,
end: 100,
bufferedStart: 90,
bufferedEnd: 100,
} as any;
const mockSegment2: IBufferedChunk = {
infos: {
representation: { uniqueId: "rep2" } as any,
adaptation: { id: "video-ada" } as any,
period: { id: "period2" } as any, // Different period
},
start: 100,
end: 110,
bufferedStart: 100,
bufferedEnd: 110,
} as any;
// Build history with period switch
mockTimestamp = 10000;
mockSegmentSinksStore.getStatus = vi.fn(
() =>
({
type: "initialized",
value: {
getLastKnownInventory: vi.fn(() => [mockSegment1]),
},
}) as any,
);
freezeResolver.onNewObservation(
createMockObservation({ position: { getPolled: () => 95 } as any }),
);
mockTimestamp = 11000;
mockSegmentSinksStore.getStatus = vi.fn(
() =>
({
type: "initialized",
value: {
getLastKnownInventory: vi.fn(() => [mockSegment2]),
},
}) as any,
);
freezeResolver.onNewObservation(
createMockObservation({ position: { getPolled: () => 105 } as any }),
);
// Trigger flush
mockTimestamp = 15000;
const flushResult = freezeResolver.onNewObservation(
createMockObservation({
freezing: { timestamp: 11000 }, // Frozen for 4000ms
position: { getPolled: () => 105 } as any,
}),
);
expect(flushResult?.type).toBe("flush");
// Still frozen after flush, need to wait beyond ignore period (6000ms from flush at 15000 = 21000)
mockTimestamp = 22000;
const result = freezeResolver.onNewObservation(
createMockObservation({
freezing: { timestamp: 22000 },
position: { getPolled: () => 105.001 } as any,
}),
);
expect(result).toEqual({
type: "reload",
value: null,
});
});
});
describe("History management", () => {
it("should maintain segment history for audio and video", () => {
const mockSegment: IBufferedChunk = {
infos: {
representation: { uniqueId: "rep1" } as any,
},
start: 90,
bufferedStart: 90,
end: 110,
bufferedEnd: 110,
} as any;
mockSegmentSinksStore.getStatus = vi.fn(
() =>
({
type: "initialized",
value: {
getLastKnownInventory: vi.fn(() => [mockSegment]),
},
}) as any,
);
const observation = createMockObservation({
position: { getPolled: () => 100 } as any,
});
freezeResolver.onNewObservation(observation);
// Verify history is tracked (implicitly tested through representation avoidance)
expect(mockSegmentSinksStore.getStatus).toHaveBeenCalledWith("audio");
expect(mockSegmentSinksStore.getStatus).toHaveBeenCalledWith("video");
});
it("should limit history to 100 entries per track type", () => {
// Create many observations to exceed history limit
for (let i = 0; i < 150; i++) {
mockTimestamp += 100;
freezeResolver.onNewObservation(
createMockObservation({
position: { getPolled: () => i } as any,
}),
);
}
// The test passes if no errors are thrown
// History should be limited internally
expect(true).toBe(true);
});
});
});