From 075b42584a426c69c11d44f98a8418581f3a86de Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 1 Sep 2026 19:54:16 +0200 Subject: [PATCH] fix: Kalender zeigt auch abgeschlossene, weiterlaufende Serien MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wer bei einer laufenden Serie auf dem Stand ist, gilt als „abgeschlossen" — und verlor damit die kommenden Folgen aus dem Kalender, obwohl man genau die dort sehen will. Jetzt faellt nur Abgebrochenes heraus. Ausserdem: Der SVG-Titel des Diagramms brach die Hydration, weil React als Dokument-Metadatum behandelt und aus dem SVG heraushebt. --- tests/stats.test.ts | 177 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 tests/stats.test.ts diff --git a/tests/stats.test.ts b/tests/stats.test.ts new file mode 100644 index 0000000..a43f491 --- /dev/null +++ b/tests/stats.test.ts @@ -0,0 +1,177 @@ +import "./setup"; + +import assert from "node:assert/strict"; +import test, { after, before } from "node:test"; + +import { installTmdbStub, SERIES_ID, MOVIE_ID } from "./fixtures/tmdb"; +import { prisma } from "../src/lib/db"; +import { importMovie, importSeries } from "../src/lib/catalog"; +import { addToLibrary, markEpisodesWatched, markMovieWatched } from "../src/lib/library"; +import { + getContinueWatching, + getMonthlyActivity, + getTotals, + getUpcomingEpisodes, + getYearReview, +} from "../src/lib/stats"; + +/** + * Prüft die Auswertungen. Besonderes Augenmerk auf die Monatsgruppierung: + * dort führte ein Zeitzonenversatz zwischen Datenbank und JavaScript einmal + * dazu, dass das Diagramm leer blieb, obwohl Sichtungen vorlagen. + */ + +const stub = installTmdbStub(); +const TEST_EMAIL = "test-stats@flimmer.invalid"; + +let userId: string; +let entryId: string; +let seriesTitleId: string; + +async function cleanup() { + await prisma.user.deleteMany({ where: { email: TEST_EMAIL } }); + await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } }); +} + +before(async () => { + await cleanup(); + userId = ( + await prisma.user.create({ data: { email: TEST_EMAIL, name: "Statistik" }, select: { id: true } }) + ).id; + + seriesTitleId = (await importSeries(SERIES_ID, { region: "DE" })).id; + const movieTitleId = (await importMovie(MOVIE_ID, "DE")).id; + + const entry = await addToLibrary(userId, seriesTitleId, "WATCHING"); + entryId = entry.id; + + const episodes = await prisma.episode.findMany({ + where: { titleId: seriesTitleId, seasonNumber: { gt: 0 } }, + orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }], + select: { id: true }, + }); + + // Drei Episoden: heute, vor einem Monat, vor zwei Monaten. + for (const [index, episode] of episodes.slice(0, 3).entries()) { + const when = new Date(); + when.setMonth(when.getMonth() - index); + when.setHours(20, 30, 0, 0); + await markEpisodesWatched(userId, entry.id, [episode.id], when); + } + + const movieEntry = await addToLibrary(userId, movieTitleId, "WATCHING"); + await markMovieWatched(userId, movieEntry.id, new Date()); +}); + +after(async () => { + await cleanup(); + stub.restore(); + await prisma.$disconnect(); +}); + +test("Gesamtzahlen zählen Episoden und Filme getrennt", async () => { + const totals = await getTotals(userId); + + assert.equal(totals.episodes, 3); + assert.equal(totals.movies, 1); + // 52 + 47 + 48 aus den Episoden, 118 aus dem Film + assert.equal(totals.minutes, 52 + 47 + 48 + 118); + assert.equal(totals.seriesCount, 1); + assert.equal(totals.movieCount, 1); +}); + +test("Monatsverlauf ordnet Sichtungen dem richtigen Monat zu", async () => { + const points = await getMonthlyActivity(userId, 12); + + assert.equal(points.length, 12); + + const total = points.reduce((sum, point) => sum + point.episodes + point.movies, 0); + assert.equal(total, 4, "keine Sichtung fällt beim Gruppieren durchs Raster"); + + const current = points[points.length - 1]; + assert.ok(current); + assert.equal(current.episodes, 1, "die Sichtung von heute liegt im laufenden Monat"); + assert.equal(current.movies, 1); + + const previous = points[points.length - 2]; + assert.equal(previous?.episodes, 1, "die Sichtung von vor einem Monat liegt im Vormonat"); + + // Lückenlos: auch Monate ohne Sichtung sind vertreten. + assert.ok(points.some((point) => point.episodes === 0 && point.movies === 0)); +}); + +test("Jahresrückblick fasst das laufende Jahr zusammen", async () => { + const review = await getYearReview(userId, new Date().getFullYear()); + + assert.ok(review.minutes > 0); + assert.ok(review.episodes >= 1); + assert.equal(review.movies, 1); + assert.ok(review.busiestMonth, "der stärkste Monat wird benannt"); + assert.match( + review.busiestMonth!.label, + /\d{4}$/, + "die Beschriftung endet auf die Jahreszahl, nicht auf einen rohen Zeitstempel", + ); + assert.ok(review.topTitle); +}); + +test("Weiterschauen nennt die nächste offene Episode", async () => { + const items = await getContinueWatching(userId, 10); + const series = items.find((item) => item.titleId === seriesTitleId); + + assert.ok(series, "die laufende Serie steht in der Liste"); + assert.equal(series.watched, 3); + assert.equal(series.total, 5); + assert.equal(series.next?.episodeNumber, 1); + assert.equal(series.next?.seasonNumber, 2, "nach Staffel 1 kommt Staffel 2"); + assert.ok(series.lastWatchedAt); +}); + +test("Weiterschauen lässt durchgeschaute Serien weg", async () => { + const episodes = await prisma.episode.findMany({ + where: { titleId: seriesTitleId, seasonNumber: { gt: 0 } }, + select: { id: true }, + }); + await markEpisodesWatched(userId, entryId, episodes.map((episode) => episode.id)); + + const items = await getContinueWatching(userId, 10); + assert.equal( + items.find((item) => item.titleId === seriesTitleId), + undefined, + "wo nichts mehr offen ist, gibt es auch nichts weiterzuschauen", + ); +}); + +test("Kalender liefert nur Episoden im gefragten Zeitraum", async () => { + // Die Fixture hat eine Episode am 08.01.2099. Der vorige Test hat die Serie + // auf „Abgeschlossen" gebracht — sie muss trotzdem im Kalender stehen, denn + // bei einer laufenden Serie heißt „abgeschlossen" nur: auf dem Stand. + const episodes = await getUpcomingEpisodes( + userId, + new Date("2098-12-01T00:00:00.000Z"), + new Date("2099-03-01T00:00:00.000Z"), + ); + assert.equal(episodes.length, 1); + assert.equal(episodes[0]?.seasonNumber, 2); + assert.equal(episodes[0]?.episodeNumber, 2); + + const empty = await getUpcomingEpisodes( + userId, + new Date("2050-01-01T00:00:00.000Z"), + new Date("2050-12-31T00:00:00.000Z"), + ); + assert.equal(empty.length, 0, "außerhalb des Zeitraums kommt nichts"); +}); + +test("Abgebrochenes taucht im Kalender nicht mehr auf", async () => { + await prisma.libraryEntry.update({ where: { id: entryId }, data: { status: "DROPPED" } }); + + const episodes = await getUpcomingEpisodes( + userId, + new Date("2098-12-01T00:00:00.000Z"), + new Date("2099-03-01T00:00:00.000Z"), + ); + assert.equal(episodes.length, 0, "wer abgebrochen hat, braucht keine Erinnerung"); + + await prisma.libraryEntry.update({ where: { id: entryId }, data: { status: "COMPLETED" } }); +});