import { describe, expect, it } from "vitest"; import type { Media } from "$lib/Data/AniList/media"; import type { AiringSchedule } from "$lib/Media/Anime/Airing/animeSchedule"; import { getAnimeEpisodeState, hasDueEpisodes, } from "$lib/Media/Anime/Airing/classify"; import { clearInjectAiringTimeCache, injectAiringTime, } from "$lib/Media/Anime/Airing/match"; import { season } from "$lib/Media/Anime/season"; import settings from "$stores/settings"; // A single-show schedule that joins to the given media by title, releasing the // sub at `airingAt`. const subScheduleFor = (media: Media, airingAt: number): AiringSchedule => ({ generatedAt: Math.floor(Date.now() / 1000), sub: [ { route: `fixture-${media.id}`, title: media.title.romaji, romaji: media.title.romaji, english: media.title.english, native: media.title.native, episodeNumber: media.nextAiringEpisode?.episode || 0, airingAt, imageUrl: "", streams: [], }, ], dub: [], }); 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("countdown source parity", () => { for (const id of regressionIds) { it(`keeps media ${id} due across native and sub countdown sources`, () => { const media = baseMedia(id); const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60; media.nextAiringEpisode = { episode: 8, airingAt: nativeAiringAt, }; if (media.mediaListEntry) media.mediaListEntry.progress = 5; const schedule = subScheduleFor(media, subtitledAiringAt); settings.setKey("countdownSource", "native"); const nativeOnly = injectAiringTime(media, schedule); settings.setKey("countdownSource", "sub"); const subtitled = injectAiringTime(media, schedule); expect(hasDueEpisodes(nativeOnly)).toBe(true); expect(hasDueEpisodes(subtitled)).toBe(true); }); } }); describe("countdown source fallback", () => { it("falls back dub → sub when no dub release exists", () => { clearInjectAiringTimeCache(); const media = baseMedia(310001); const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60; media.nextAiringEpisode = { episode: 8, airingAt: nativeAiringAt, }; // Sub-only schedule (the dub feed is empty). const schedule = subScheduleFor(media, subtitledAiringAt); settings.setKey("countdownSource", "dub"); const injected = injectAiringTime(media, schedule); expect(injected.nextAiringEpisode?.airingAt).toBe(subtitledAiringAt); expect(injected.nextAiringEpisode?.nativeAiringAt).toBe(nativeAiringAt); }); it("falls back to native when neither dub nor sub exists", () => { clearInjectAiringTimeCache(); const media = baseMedia(310002); const nativeAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; media.nextAiringEpisode = { episode: 8, airingAt: nativeAiringAt, }; const schedule: AiringSchedule = { generatedAt: Math.floor(Date.now() / 1000), sub: [], dub: [], }; settings.setKey("countdownSource", "dub"); const injected = injectAiringTime(media, schedule); expect(injected.nextAiringEpisode?.airingAt).toBe(nativeAiringAt); }); }); 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; media.nextAiringEpisode = { episode: 8, airingAt: nativeAiringAt, }; const schedule = subScheduleFor(media, subtitledAiringAt); settings.setKey("countdownSource", "sub"); const first = injectAiringTime(media, schedule); first.nextAiringEpisode = { episode: -1 }; const second = injectAiringTime(media, schedule); 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; media.nextAiringEpisode = { episode: 10, airingAt: nativeAiringAt, }; const schedule = subScheduleFor(media, subtitledAiringAt); settings.setKey("countdownSource", "sub"); const caughtUp = { ...media, mediaListEntry: { ...(media.mediaListEntry || {}), progress: 9, }, } as Media; const behind = { ...media, mediaListEntry: { ...(media.mediaListEntry || {}), progress: 7, }, } as Media; const cachedCaughtUp = injectAiringTime(caughtUp, schedule); const updatedBehind = injectAiringTime(behind, schedule); 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 schedule = subScheduleFor(media, subtitledAiringAt); settings.setKey("countdownSource", "sub"); const originalProgress = media.mediaListEntry?.progress || 0; const first = injectAiringTime(media, schedule); if (first.mediaListEntry) first.mediaListEntry.progress = 999; const second = injectAiringTime(media, schedule); expect(media.mediaListEntry?.progress).toBe(originalProgress); expect(second.mediaListEntry?.progress).toBe(originalProgress); }); });