import { describe, expect, it } from "vitest"; import settings from "$stores/settings"; import type { Media } from "$lib/Data/AniList/media"; import { season } from "$lib/Media/Anime/season"; import { hasDueEpisodes, getAnimeEpisodeState, } from "$lib/Media/Anime/Airing/classify"; import { clearInjectAiringTimeCache, injectAiringTime, } from "$lib/Media/Anime/Airing/Subtitled/match"; import type { SubsPlease } from "$lib/Media/Anime/Airing/Subtitled/subsPlease"; const toScheduleTime = (epochSeconds: number) => { const date = new Date(epochSeconds * 1000); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); return `${hours}:${minutes}`; }; const baseMedia = (id: number): Media => ({ id, idMal: id, status: "RELEASING", type: "ANIME", episodes: 12, chapters: 0, volumes: 0, duration: 24, format: "TV", title: { romaji: `Fixture Show ${id}`, english: `Fixture Show ${id}`, native: `Fixture Show ${id}`, }, nextAiringEpisode: { episode: 8, airingAt: Math.floor(Date.now() / 1000) + 24 * 60 * 60, }, synonyms: [], mediaListEntry: { progress: 6, progressVolumes: 0, status: "CURRENT", score: 0, repeat: 0, startedAt: { year: 2025, month: 1, day: 1, }, completedAt: { year: 0, month: 0, day: 0, }, createdAt: 0, updatedAt: 0, customLists: {}, }, startDate: { year: 2025, month: 1, }, endDate: { year: 2025, month: 12, }, coverImage: { extraLarge: "https://example.com/cover-xl.jpg", medium: "https://example.com/cover-md.jpg", }, tags: [], genres: [], season: season(), isAdult: false, relations: { edges: [], }, }) as Media; const regressionIds = [192507, 189259, 198767]; describe("anime episode classification", () => { it("prefers nativeEpisode for due/upcoming classification", () => { const media = baseMedia(192507); media.nextAiringEpisode = { episode: 7, nativeEpisode: 8, airingAt: Math.floor(Date.now() / 1000) + 6 * 60 * 60, nativeAiringAt: Math.floor(Date.now() / 1000) + 18 * 60 * 60, }; const state = getAnimeEpisodeState(media); expect(state.airedEpisodes).toBe(7); expect(hasDueEpisodes(media)).toBe(true); }); it("treats stale native release data as aired for due detection", () => { const media = baseMedia(189259); media.nextAiringEpisode = { episode: 9, airingAt: Math.floor(Date.now() / 1000) + 72 * 60 * 60, nativeAiringAt: Math.floor(Date.now() / 1000) - 60 * 60, }; const state = getAnimeEpisodeState(media); expect(state.airedEpisodes).toBe(9); expect(hasDueEpisodes(media)).toBe(true); }); }); describe("native countdown toggle parity", () => { for (const id of regressionIds) { it(`keeps media ${id} due with native countdown on/off`, () => { const media = baseMedia(id); const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60; const nativeAiringDate = new Date(nativeAiringAt * 1000); const airingDay = nativeAiringDate.toLocaleString("en-US", { weekday: "long", }); const subsPlease = { tz: "America/Los_Angeles", schedule: { [airingDay]: [ { title: media.title.romaji, page: "", image_url: "", time: toScheduleTime(subtitledAiringAt), }, ], }, } as unknown as SubsPlease; media.nextAiringEpisode = { episode: 8, airingAt: nativeAiringAt, }; settings.setKey("displayNativeCountdown", true); const nativeOnly = injectAiringTime(media, subsPlease); settings.setKey("displayNativeCountdown", false); const subtitled = injectAiringTime(media, subsPlease); expect(hasDueEpisodes(nativeOnly)).toBe(true); expect(hasDueEpisodes(subtitled)).toBe(true); }); } }); describe("injectAiringTime cache safety", () => { it("does not let caller mutation poison cached injected media", () => { const media = baseMedia(444444); const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60; const nativeAiringDate = new Date(nativeAiringAt * 1000); const airingDay = nativeAiringDate.toLocaleString("en-US", { weekday: "long", }); const subsPlease = { tz: "America/Los_Angeles", schedule: { [airingDay]: [ { title: media.title.romaji, page: "", image_url: "", time: toScheduleTime(subtitledAiringAt), }, ], }, } as unknown as SubsPlease; media.nextAiringEpisode = { episode: 8, airingAt: nativeAiringAt, }; settings.setKey("displayNativeCountdown", false); const first = injectAiringTime(media, subsPlease); first.nextAiringEpisode = { episode: -1 }; const second = injectAiringTime(media, subsPlease); expect(second.nextAiringEpisode?.episode).not.toBe(-1); expect(typeof second.nextAiringEpisode?.airingAt).toBe("number"); expect(typeof second.nextAiringEpisode?.nativeAiringAt).toBe("number"); }); it("does not reuse stale progress across cache hits for media 194028", () => { clearInjectAiringTimeCache(); const media = baseMedia(194028); const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60; const nativeAiringDate = new Date(nativeAiringAt * 1000); const airingDay = nativeAiringDate.toLocaleString("en-US", { weekday: "long", }); const subsPlease = { tz: "America/Los_Angeles", schedule: { [airingDay]: [ { title: media.title.romaji, page: "", image_url: "", time: toScheduleTime(subtitledAiringAt), }, ], }, } as unknown as SubsPlease; media.nextAiringEpisode = { episode: 10, airingAt: nativeAiringAt, }; settings.setKey("displayNativeCountdown", false); const caughtUp = { ...media, mediaListEntry: { ...(media.mediaListEntry || {}), progress: 9, }, } as Media; const behind = { ...media, mediaListEntry: { ...(media.mediaListEntry || {}), progress: 8, }, } as Media; const cachedCaughtUp = injectAiringTime(caughtUp, subsPlease); const updatedBehind = injectAiringTime(behind, subsPlease); expect(hasDueEpisodes(cachedCaughtUp)).toBe(false); expect(hasDueEpisodes(updatedBehind)).toBe(true); }); it("does not let caller mutate cached mediaListEntry progress", () => { clearInjectAiringTimeCache(); const media = baseMedia(194028); const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60; const nativeAiringDate = new Date(nativeAiringAt * 1000); const airingDay = nativeAiringDate.toLocaleString("en-US", { weekday: "long", }); const subsPlease = { tz: "America/Los_Angeles", schedule: { [airingDay]: [ { title: media.title.romaji, page: "", image_url: "", time: toScheduleTime(subtitledAiringAt), }, ], }, } as unknown as SubsPlease; settings.setKey("displayNativeCountdown", false); const originalProgress = media.mediaListEntry?.progress || 0; const first = injectAiringTime(media, subsPlease); if (first.mediaListEntry) first.mediaListEntry.progress = 999; const second = injectAiringTime(media, subsPlease); expect(media.mediaListEntry?.progress).toBe(originalProgress); expect(second.mediaListEntry?.progress).toBe(originalProgress); }); });