test: add episode transition tests for in-page navigation
This commit is contained in:
@@ -1,13 +1,17 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { before, beforeEach, describe, test } from "node:test";
|
import { before, beforeEach, describe, test } from "node:test";
|
||||||
|
|
||||||
|
/* eslint-disable max-classes-per-file */
|
||||||
|
|
||||||
class FakeElement extends EventTarget {
|
class FakeElement extends EventTarget {
|
||||||
|
readonly dataset: Record<string, string> = {};
|
||||||
className = "";
|
className = "";
|
||||||
classList = fakeClassList();
|
classList = fakeClassList();
|
||||||
disabled = false;
|
disabled = false;
|
||||||
parentElement: FakeElement | null = null;
|
parentElement: FakeElement | null = null;
|
||||||
style: Record<string, string> = {};
|
style: Record<string, string> = {};
|
||||||
textContent = "";
|
textContent = "";
|
||||||
|
tabIndex = 0;
|
||||||
private readonly children: FakeElement[] = [];
|
private readonly children: FakeElement[] = [];
|
||||||
private readonly queries = new Map<string, FakeElement | null>();
|
private readonly queries = new Map<string, FakeElement | null>();
|
||||||
|
|
||||||
@@ -16,10 +20,26 @@ class FakeElement extends EventTarget {
|
|||||||
this.children.push(child);
|
this.children.push(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closest(_selector: string): FakeElement | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(options?: FocusOptions): void {
|
||||||
|
void options;
|
||||||
|
}
|
||||||
|
|
||||||
querySelector(selector: string): FakeElement | null {
|
querySelector(selector: string): FakeElement | null {
|
||||||
return this.queries.get(selector) ?? null;
|
return this.queries.get(selector) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
querySelectorAll(_selector: string): FakeElement[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAttribute(name: string): void {
|
||||||
|
void name;
|
||||||
|
}
|
||||||
|
|
||||||
replaceChildren(...children: FakeElement[]): void {
|
replaceChildren(...children: FakeElement[]): void {
|
||||||
this.children.length = 0;
|
this.children.length = 0;
|
||||||
children.forEach((child) => {
|
children.forEach((child) => {
|
||||||
@@ -30,6 +50,52 @@ class FakeElement extends EventTarget {
|
|||||||
setQuery(selector: string, element: FakeElement | null): void {
|
setQuery(selector: string, element: FakeElement | null): void {
|
||||||
this.queries.set(selector, element);
|
this.queries.set(selector, element);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAttribute(name: string, value: string): void {
|
||||||
|
void name;
|
||||||
|
void value;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollIntoView(options?: ScrollIntoViewOptions): void {
|
||||||
|
void options;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeAnchorElement extends FakeElement {
|
||||||
|
href = "";
|
||||||
|
target = "";
|
||||||
|
|
||||||
|
override closest(selector: string): FakeAnchorElement | null {
|
||||||
|
return selector === "a[data-episode-id]" ? this : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeMouseEvent {
|
||||||
|
readonly altKey: boolean;
|
||||||
|
readonly button: number;
|
||||||
|
readonly ctrlKey: boolean;
|
||||||
|
defaultPrevented = false;
|
||||||
|
readonly metaKey: boolean;
|
||||||
|
readonly shiftKey: boolean;
|
||||||
|
readonly target: FakeElement;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
target: FakeElement,
|
||||||
|
options: Partial<
|
||||||
|
Pick<MouseEvent, "altKey" | "button" | "ctrlKey" | "metaKey" | "shiftKey">
|
||||||
|
> = {},
|
||||||
|
) {
|
||||||
|
this.target = target;
|
||||||
|
this.altKey = options.altKey ?? false;
|
||||||
|
this.button = options.button ?? 0;
|
||||||
|
this.ctrlKey = options.ctrlKey ?? false;
|
||||||
|
this.metaKey = options.metaKey ?? false;
|
||||||
|
this.shiftKey = options.shiftKey ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
preventDefault(): void {
|
||||||
|
this.defaultPrevented = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type FakeVideoElement = FakeElement & {
|
type FakeVideoElement = FakeElement & {
|
||||||
@@ -48,11 +114,17 @@ type FakeVideoElement = FakeElement & {
|
|||||||
|
|
||||||
type PlayerModules = {
|
type PlayerModules = {
|
||||||
completeAnime: (episodeNumber: number) => Promise<void>;
|
completeAnime: (episodeNumber: number) => Promise<void>;
|
||||||
|
handleEpisodeNavigationClick: (event: MouseEvent) => boolean;
|
||||||
refreshCurrentModeSource: (signal?: AbortSignal) => Promise<boolean>;
|
refreshCurrentModeSource: (signal?: AbortSignal) => Promise<boolean>;
|
||||||
saveProgress: (force?: boolean, progressSeconds?: number) => Promise<void>;
|
saveProgress: (force?: boolean, progressSeconds?: number) => Promise<void>;
|
||||||
setupProgress: () => void;
|
setupProgress: () => void;
|
||||||
state: typeof import("./state").state;
|
state: typeof import("./state").state;
|
||||||
streamUrlForMode: (mode: string, quality?: string) => string;
|
streamUrlForMode: (mode: string, quality?: string) => string;
|
||||||
|
setupEpisodeNavigation: (signal: AbortSignal) => void;
|
||||||
|
transitionToEpisode: (
|
||||||
|
episode: number,
|
||||||
|
options?: { fallbackHref?: string; history?: "none" | "push" | "replace" },
|
||||||
|
) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyRanges = (): TimeRanges => ({ length: 0, end: () => 0, start: () => 0 });
|
const emptyRanges = (): TimeRanges => ({ length: 0, end: () => 0, start: () => 0 });
|
||||||
@@ -127,6 +199,28 @@ const fakeDocument = (): Document & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const documentStub = fakeDocument();
|
const documentStub = fakeDocument();
|
||||||
|
const storage = (): Storage => {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
clear: (): void => {
|
||||||
|
values.clear();
|
||||||
|
},
|
||||||
|
getItem: (key: string): string | null => values.get(key) ?? null,
|
||||||
|
key: (index: number): string | null => [...values.keys()][index] ?? null,
|
||||||
|
get length(): number {
|
||||||
|
return values.size;
|
||||||
|
},
|
||||||
|
removeItem: (key: string): void => {
|
||||||
|
values.delete(key);
|
||||||
|
},
|
||||||
|
setItem: (key: string, value: string): void => {
|
||||||
|
values.set(key, value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const historyCalls: string[] = [];
|
||||||
|
const localStorageStub = storage();
|
||||||
|
const sessionStorageStub = storage();
|
||||||
const windowStub = Object.assign(new EventTarget(), {
|
const windowStub = Object.assign(new EventTarget(), {
|
||||||
clearTimeout: (_handle: number | undefined): void => {
|
clearTimeout: (_handle: number | undefined): void => {
|
||||||
void _handle;
|
void _handle;
|
||||||
@@ -136,8 +230,14 @@ const windowStub = Object.assign(new EventTarget(), {
|
|||||||
void _delay;
|
void _delay;
|
||||||
return 1;
|
return 1;
|
||||||
},
|
},
|
||||||
|
localStorage: localStorageStub,
|
||||||
|
location: { href: "http://localhost/anime/42/watch?ep=3", origin: "http://localhost" },
|
||||||
|
showToast: (options: { message: string }): void => {
|
||||||
|
void options;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
let modules: PlayerModules;
|
let modules: PlayerModules;
|
||||||
|
let beaconCalls: string[];
|
||||||
let fetchCalls: Array<{ body?: string; url: string }>;
|
let fetchCalls: Array<{ body?: string; url: string }>;
|
||||||
let timeoutCallbacks: Array<() => void>;
|
let timeoutCallbacks: Array<() => void>;
|
||||||
const originalConsoleError = console.error;
|
const originalConsoleError = console.error;
|
||||||
@@ -145,14 +245,39 @@ const originalConsoleError = console.error;
|
|||||||
const installBrowserGlobals = (): void => {
|
const installBrowserGlobals = (): void => {
|
||||||
Object.assign(globalThis, {
|
Object.assign(globalThis, {
|
||||||
document: documentStub,
|
document: documentStub,
|
||||||
|
Element: FakeElement,
|
||||||
|
HTMLAnchorElement: FakeAnchorElement,
|
||||||
HTMLElement: FakeElement,
|
HTMLElement: FakeElement,
|
||||||
HTMLVideoElement: FakeElement,
|
HTMLVideoElement: FakeElement,
|
||||||
HTMLMediaElement: { HAVE_METADATA: 1, HAVE_CURRENT_DATA: 2 },
|
HTMLMediaElement: { HAVE_METADATA: 1, HAVE_CURRENT_DATA: 2 },
|
||||||
|
MouseEvent: FakeMouseEvent,
|
||||||
window: windowStub,
|
window: windowStub,
|
||||||
});
|
});
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
history: {
|
||||||
|
pushState: (_data: unknown, _unused: string, url?: string | URL | null): void => {
|
||||||
|
const href = String(url);
|
||||||
|
historyCalls.push(href);
|
||||||
|
windowStub.location.href = href;
|
||||||
|
},
|
||||||
|
replaceState: (_data: unknown, _unused: string, url?: string | URL | null): void => {
|
||||||
|
const href = String(url);
|
||||||
|
historyCalls.push(href);
|
||||||
|
windowStub.location.href = href;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sessionStorage: sessionStorageStub,
|
||||||
|
});
|
||||||
Object.defineProperty(globalThis, "navigator", {
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: { sendBeacon: () => true },
|
value: {
|
||||||
|
sendBeacon: (_url: string, data: Blob): boolean => {
|
||||||
|
void data.text().then((value) => {
|
||||||
|
beaconCalls.push(value);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -170,6 +295,7 @@ const resetPlayerState = (): void => {
|
|||||||
state.elements.timeDisplay = asHTMLElement(new FakeElement());
|
state.elements.timeDisplay = asHTMLElement(new FakeElement());
|
||||||
state.elements.durationDisplay = asHTMLElement(new FakeElement());
|
state.elements.durationDisplay = asHTMLElement(new FakeElement());
|
||||||
state.episode.current = "3";
|
state.episode.current = "3";
|
||||||
|
state.episode.total = 10;
|
||||||
state.episode.malID = 42;
|
state.episode.malID = 42;
|
||||||
state.episode.transitionEpisode = null;
|
state.episode.transitionEpisode = null;
|
||||||
state.episode.completionSent = false;
|
state.episode.completionSent = false;
|
||||||
@@ -177,8 +303,13 @@ const resetPlayerState = (): void => {
|
|||||||
state.episode.endedProgressSaved = false;
|
state.episode.endedProgressSaved = false;
|
||||||
state.episode.lastSavedProgress = { episode: "3", seconds: -1 };
|
state.episode.lastSavedProgress = { episode: "3", seconds: -1 };
|
||||||
state.playback.currentMode = "sub";
|
state.playback.currentMode = "sub";
|
||||||
|
state.playback.modeSwitchedFrom = "";
|
||||||
|
state.playback.startTimeSeconds = 0;
|
||||||
state.playback.streamURL = "/watch/proxy/stream";
|
state.playback.streamURL = "/watch/proxy/stream";
|
||||||
state.playback.modeSources = {};
|
state.playback.modeSources = {};
|
||||||
|
state.elements.episodeGrid = null;
|
||||||
|
state.elements.episodeList = null;
|
||||||
|
state.elements.videoOverlay = null;
|
||||||
state.timers.progressSaveTimer = undefined;
|
state.timers.progressSaveTimer = undefined;
|
||||||
documentStub.setDropdownTrigger(null);
|
documentStub.setDropdownTrigger(null);
|
||||||
};
|
};
|
||||||
@@ -186,21 +317,27 @@ const resetPlayerState = (): void => {
|
|||||||
const flushPromises = async (): Promise<void> => {
|
const flushPromises = async (): Promise<void> => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPlayerModules = async (): Promise<PlayerModules> => {
|
const loadPlayerModules = async (): Promise<PlayerModules> => {
|
||||||
const completeModule = await import("./episodes/complete");
|
const completeModule = await import("./episodes/complete");
|
||||||
|
const navModule = await import("./episodes/nav");
|
||||||
const progressModule = await import("./progress");
|
const progressModule = await import("./progress");
|
||||||
const stateModule = await import("./state");
|
const stateModule = await import("./state");
|
||||||
const sourceModule = await import("./source");
|
const sourceModule = await import("./source");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
completeAnime: completeModule.completeAnime,
|
completeAnime: completeModule.completeAnime,
|
||||||
|
handleEpisodeNavigationClick: navModule.handleEpisodeNavigationClick,
|
||||||
refreshCurrentModeSource: sourceModule.refreshCurrentModeSource,
|
refreshCurrentModeSource: sourceModule.refreshCurrentModeSource,
|
||||||
saveProgress: progressModule.saveProgress,
|
saveProgress: progressModule.saveProgress,
|
||||||
setupProgress: progressModule.setupProgress,
|
setupProgress: progressModule.setupProgress,
|
||||||
state: stateModule.state,
|
state: stateModule.state,
|
||||||
streamUrlForMode: sourceModule.streamUrlForMode,
|
streamUrlForMode: sourceModule.streamUrlForMode,
|
||||||
|
setupEpisodeNavigation: navModule.setupEpisodeNavigation,
|
||||||
|
transitionToEpisode: navModule.transitionToEpisode,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -212,6 +349,14 @@ before(async () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
console.error = originalConsoleError;
|
console.error = originalConsoleError;
|
||||||
fetchCalls = [];
|
fetchCalls = [];
|
||||||
|
beaconCalls = [];
|
||||||
|
historyCalls.length = 0;
|
||||||
|
localStorageStub.clear();
|
||||||
|
sessionStorageStub.clear();
|
||||||
|
windowStub.location.href = "http://localhost/anime/42/watch?ep=3";
|
||||||
|
windowStub.showToast = (options: { message: string }): void => {
|
||||||
|
void options;
|
||||||
|
};
|
||||||
timeoutCallbacks = [];
|
timeoutCallbacks = [];
|
||||||
globalThis.fetch = ((url: string, init?: RequestInit) => {
|
globalThis.fetch = ((url: string, init?: RequestInit) => {
|
||||||
fetchCalls.push({ url, body: typeof init?.body === "string" ? init.body : undefined });
|
fetchCalls.push({ url, body: typeof init?.body === "string" ? init.body : undefined });
|
||||||
@@ -225,6 +370,27 @@ beforeEach(() => {
|
|||||||
resetPlayerState();
|
resetPlayerState();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const episodePayload = (
|
||||||
|
episode: number,
|
||||||
|
modes?: Record<string, { subtitles: never[]; token: string; type: string }>,
|
||||||
|
): Record<string, unknown> => {
|
||||||
|
const modeSources = modes ?? {
|
||||||
|
dub: { subtitles: [], token: `dub-${episode}`, type: "mp4" },
|
||||||
|
sub: { subtitles: [], token: `sub-${episode}`, type: "mp4" },
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
episode_title: `Episode ${episode}`,
|
||||||
|
initial_mode: "sub",
|
||||||
|
mode_sources: modeSources,
|
||||||
|
mode_switched_from: "",
|
||||||
|
segments: [],
|
||||||
|
start_time_seconds: 12,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const episodeResponse = (episode: number): Response =>
|
||||||
|
({ json: () => Promise.resolve(episodePayload(episode)), ok: true, status: 200 }) as Response;
|
||||||
|
|
||||||
describe("browser player flow", () => {
|
describe("browser player flow", () => {
|
||||||
test("builds initial stream URLs from mode token, HLS type, and preferred quality", () => {
|
test("builds initial stream URLs from mode token, HLS type, and preferred quality", () => {
|
||||||
modules.state.playback.modeSources = {
|
modules.state.playback.modeSources = {
|
||||||
@@ -322,4 +488,205 @@ describe("browser player flow", () => {
|
|||||||
assert.equal(trigger.textContent, "Completed ");
|
assert.equal(trigger.textContent, "Completed ");
|
||||||
assert.equal(errors.length, 1);
|
assert.equal(errors.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("transitions episodes in place and saves progress for the episode being left", async () => {
|
||||||
|
const loading = new FakeElement();
|
||||||
|
(modules.state.elements.container as unknown as FakeElement).setQuery(
|
||||||
|
"[data-loading]",
|
||||||
|
loading,
|
||||||
|
);
|
||||||
|
modules.state.elements.video.currentTime = 30;
|
||||||
|
globalThis.fetch = ((url: string) => {
|
||||||
|
fetchCalls.push({ url });
|
||||||
|
return Promise.resolve(episodeResponse(4));
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const transitioned = await modules.transitionToEpisode(4);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
assert.equal(transitioned, true);
|
||||||
|
assert.deepEqual(
|
||||||
|
fetchCalls.map((call) => call.url),
|
||||||
|
["/api/watch/episode/42/4?mode=sub"],
|
||||||
|
);
|
||||||
|
assert.equal(modules.state.episode.current, "4");
|
||||||
|
assert.equal(modules.state.playback.startTimeSeconds, 12);
|
||||||
|
assert.equal(modules.state.elements.video.src, "/watch/proxy/stream?mode=sub&token=sub-4");
|
||||||
|
assert.equal(loading.style.display, "flex");
|
||||||
|
assert.match(historyCalls[0] ?? "", /[?&]ep=4(?:&|$)/);
|
||||||
|
assert.deepEqual(JSON.parse(beaconCalls[0] ?? "{}"), {
|
||||||
|
mal_id: 42,
|
||||||
|
episode: 3,
|
||||||
|
time_seconds: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
modules.state.elements.video.dispatchEvent(new Event("loadedmetadata"));
|
||||||
|
assert.equal(modules.state.episode.transitionEpisode, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the same async path for episode links and leaves modified clicks native", async () => {
|
||||||
|
const modifiedLink = new FakeAnchorElement();
|
||||||
|
modifiedLink.href = "http://localhost/anime/42/watch?ep=2";
|
||||||
|
modifiedLink.dataset.episodeId = "2";
|
||||||
|
const modifiedClick = new FakeMouseEvent(modifiedLink, { metaKey: true });
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
modules.handleEpisodeNavigationClick(modifiedClick as unknown as MouseEvent),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(modifiedClick.defaultPrevented, false);
|
||||||
|
assert.equal(fetchCalls.length, 0);
|
||||||
|
|
||||||
|
globalThis.fetch = ((url: string) => {
|
||||||
|
fetchCalls.push({ url });
|
||||||
|
const episode = Number.parseInt(
|
||||||
|
new URL(url, "http://localhost").pathname.split("/").at(-1) ?? "",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
return Promise.resolve(episodeResponse(episode));
|
||||||
|
}) as typeof fetch;
|
||||||
|
const previousLink = new FakeAnchorElement();
|
||||||
|
previousLink.href = "http://localhost/anime/42/watch?ep=2";
|
||||||
|
previousLink.dataset.episodeId = "2";
|
||||||
|
const previousClick = new FakeMouseEvent(previousLink);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
modules.handleEpisodeNavigationClick(previousClick as unknown as MouseEvent),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(previousClick.defaultPrevented, true);
|
||||||
|
await flushPromises();
|
||||||
|
assert.equal(modules.state.episode.current, "2");
|
||||||
|
modules.state.elements.video.dispatchEvent(new Event("loadedmetadata"));
|
||||||
|
|
||||||
|
const nextLink = new FakeAnchorElement();
|
||||||
|
nextLink.href = "http://localhost/anime/42/watch?ep=4";
|
||||||
|
nextLink.dataset.episodeId = "4";
|
||||||
|
assert.equal(
|
||||||
|
modules.handleEpisodeNavigationClick(new FakeMouseEvent(nextLink) as unknown as MouseEvent),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await flushPromises();
|
||||||
|
assert.equal(modules.state.episode.current, "4");
|
||||||
|
modules.state.elements.video.dispatchEvent(new Event("loadedmetadata"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("aborts a stale episode request and commits only the latest selection", async () => {
|
||||||
|
modules.state.episode.current = "1";
|
||||||
|
windowStub.location.href = "http://localhost/anime/42/watch?ep=1";
|
||||||
|
const pending = new Map<
|
||||||
|
number,
|
||||||
|
{ reject: (reason: unknown) => void; resolve: (response: Response) => void }
|
||||||
|
>();
|
||||||
|
globalThis.fetch = ((url: string, init?: RequestInit) => {
|
||||||
|
fetchCalls.push({ url });
|
||||||
|
const episode = Number.parseInt(
|
||||||
|
new URL(url, "http://localhost").pathname.split("/").at(-1) ?? "",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
return new Promise<Response>((resolve, reject) => {
|
||||||
|
pending.set(episode, { reject, resolve });
|
||||||
|
init?.signal?.addEventListener("abort", () => {
|
||||||
|
reject(new DOMException("aborted", "AbortError"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const episode2 = modules.transitionToEpisode(2);
|
||||||
|
const episode3 = modules.transitionToEpisode(3);
|
||||||
|
pending.get(3)?.resolve(episodeResponse(3));
|
||||||
|
|
||||||
|
assert.equal(await episode3, true);
|
||||||
|
assert.equal(await episode2, false);
|
||||||
|
assert.equal(modules.state.episode.current, "3");
|
||||||
|
assert.equal(modules.state.elements.video.src, "/watch/proxy/stream?mode=sub&token=sub-3");
|
||||||
|
assert.equal(historyCalls.length, 1);
|
||||||
|
assert.match(historyCalls[0] ?? "", /[?&]ep=3(?:&|$)/);
|
||||||
|
modules.state.elements.video.dispatchEvent(new Event("loadedmetadata"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to the original link when the episode payload fails", async () => {
|
||||||
|
console.error = (): void => {
|
||||||
|
void 0;
|
||||||
|
};
|
||||||
|
globalThis.fetch = ((url: string) => {
|
||||||
|
fetchCalls.push({ url });
|
||||||
|
return Promise.resolve({ ok: false, status: 503 } as Response);
|
||||||
|
}) as typeof fetch;
|
||||||
|
const fallbackHref = "http://localhost/anime/42/watch?ep=5";
|
||||||
|
|
||||||
|
assert.equal(await modules.transitionToEpisode(5, { fallbackHref }), false);
|
||||||
|
assert.equal(windowStub.location.href, fallbackHref);
|
||||||
|
assert.equal(modules.state.episode.current, "3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retries one failed media source before falling back to navigation", async () => {
|
||||||
|
console.error = (): void => {
|
||||||
|
void 0;
|
||||||
|
};
|
||||||
|
let request = 0;
|
||||||
|
globalThis.fetch = ((url: string) => {
|
||||||
|
fetchCalls.push({ url });
|
||||||
|
request += 1;
|
||||||
|
const payload = episodePayload(4);
|
||||||
|
payload.mode_sources = {
|
||||||
|
dub: { subtitles: [], token: `dub-${request}`, type: "mp4" },
|
||||||
|
sub: { subtitles: [], token: `sub-${request}`, type: "mp4" },
|
||||||
|
};
|
||||||
|
return Promise.resolve({
|
||||||
|
json: () => Promise.resolve(payload),
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
} as Response);
|
||||||
|
}) as typeof fetch;
|
||||||
|
const fallbackHref = "http://localhost/anime/42/watch?ep=4";
|
||||||
|
|
||||||
|
assert.equal(await modules.transitionToEpisode(4, { fallbackHref }), true);
|
||||||
|
assert.equal(modules.state.elements.video.src, "/watch/proxy/stream?mode=sub&token=sub-1");
|
||||||
|
modules.state.elements.video.dispatchEvent(new Event("error"));
|
||||||
|
await flushPromises();
|
||||||
|
assert.equal(modules.state.elements.video.src, "/watch/proxy/stream?mode=sub&token=sub-2");
|
||||||
|
|
||||||
|
modules.state.elements.video.dispatchEvent(new Event("error"));
|
||||||
|
assert.equal(windowStub.location.href, fallbackHref);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the returned fallback mode and restores episodes on popstate", async () => {
|
||||||
|
const notices: string[] = [];
|
||||||
|
windowStub.showToast = ({ message }: { message: string }): void => {
|
||||||
|
notices.push(message);
|
||||||
|
};
|
||||||
|
modules.state.playback.currentMode = "dub";
|
||||||
|
globalThis.fetch = ((url: string) => {
|
||||||
|
fetchCalls.push({ url });
|
||||||
|
const episode = Number.parseInt(
|
||||||
|
new URL(url, "http://localhost").pathname.split("/").at(-1) ?? "",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const payload = episodePayload(episode, {
|
||||||
|
sub: { subtitles: [], token: `sub-${episode}`, type: "mp4" },
|
||||||
|
});
|
||||||
|
payload.mode_switched_from = "dub";
|
||||||
|
return Promise.resolve({
|
||||||
|
json: () => Promise.resolve(payload),
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
} as Response);
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
assert.equal(await modules.transitionToEpisode(4), true);
|
||||||
|
assert.equal(modules.state.playback.currentMode, "sub");
|
||||||
|
assert.deepEqual(notices, ["Episode 4 is only available in sub, switched from dub."]);
|
||||||
|
modules.state.elements.video.dispatchEvent(new Event("loadedmetadata"));
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
modules.setupEpisodeNavigation(controller.signal);
|
||||||
|
windowStub.location.href = "http://localhost/anime/42/watch?ep=2";
|
||||||
|
windowStub.dispatchEvent(new Event("popstate"));
|
||||||
|
await flushPromises();
|
||||||
|
assert.equal(modules.state.episode.current, "2");
|
||||||
|
assert.equal(historyCalls.length, 1);
|
||||||
|
modules.state.elements.video.dispatchEvent(new Event("loadedmetadata"));
|
||||||
|
controller.abort();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user