Files
flimmer/tests/achievements.test.ts
T
Florian Wilde b560ee9c51
CI / pruefen (pull_request) Canceled after 0s
Errungenschaften: Abzeichen fuer erreichte Meilensteine
Zwanzig feste Abzeichen plus dynamische fuer vollstaendig gesehene Filmreihen
und abgeschlossene Universen. Sie tauchen auf, wenn etwas erreicht ist, und
bleiben — auch wenn die Bedingung spaeter wegfaellt. Eine zurueckgenommene
Sichtung soll kein Abzeichen kosten.

Alle Regeln arbeiten auf *einem* Datensatz, der einmal geholt wird. Sonst
stellte jede Regel ihre eigene Abfrage, und aus zwanzig Abzeichen wuerden
zwanzig Abfragen bei jedem Abhaken. Ausgewertet wird nach Sichtungsaktionen
und einmal taeglich; der erste Lauf eines Kontos ist stumm, weil eine
gewachsene Bibliothek sonst zwei Dutzend Meldungen auf einmal ausloest.

Fuer Filmreihen braucht es keine gepflegte Liste: TMDB gruppiert sie selbst,
und tmdbCollectionId liegt beim Import schon am Titel. Eigennamen wie
"Spider Bro" kommen aus einer kleinen Datendatei obendrauf; fehlt ein Eintrag,
greift der generische Name.

Die kuratierten Eintraege tragen jeweils ein Stueck des erwarteten
TMDB-Namens. Ich habe die Collection-Kennungen aus dem Gedaechtnis
geschrieben und kann sie hier nicht gegen TMDB pruefen — passt der Name
nicht, wird der Eintrag ignoriert und der generische greift. Eine falsch
abgeschriebene Kennung bleibt damit folgenlos statt jemandem das falsche
Abzeichen zu geben.

Die Uhrzeit-Regel liest die Stunde ueber formatToParts statt ueber die
formatierte Zeichenkette: auf Deutsch liefert Intl fuer eine einzelne Stunde
"03 Uhr", und daraus wird beim Umwandeln NaN. Ein Test hat das aufgedeckt.

Beim ersten Oeffnen der Uebersicht wird einmal ausgewertet — sonst bliebe sie
bei einem Bestandskonto leer, bis nachts der Hintergrundlauf kommt.

Zehn Tests, Anleitung mit Screenshot. Symbole als eigene SVG auf einem
gemeinsamen Raster; erreicht und offen unterscheiden sich nicht nur durch
Farbe, sondern auch im Text.

Refs #15
2026-09-02 12:09:51 +02:00

255 lines
8.9 KiB
TypeScript

import "./setup";
import assert from "node:assert/strict";
import test, { after, before, beforeEach } from "node:test";
import { installTmdbStub, SERIES_ID, MOVIE_ID, MOVIE_2_ID } from "./fixtures/tmdb";
import { prisma } from "../src/lib/db";
import { importMovie, importSeries } from "../src/lib/catalog";
import { addToLibrary, markMovieWatched, markEpisodesWatched } from "../src/lib/library";
import {
achievementName,
evaluateAchievements,
listAchievements,
listUnlockedAchievements,
} from "../src/lib/achievements";
import { kuratierteReihe } from "../src/lib/achievement-names";
/**
* Prüft die Errungenschaften: Vergabe, Entdopplung, die Stummschaltung beim
* ersten Lauf und die Absicherung gegen falsch abgeschriebene Reihen.
*/
const tmdb = installTmdbStub();
const TEST_EMAIL = "test-achievements@flimmer.invalid";
let userId: string;
let seriesTitleId: string;
let movieTitleId: string;
async function aufraeumen() {
await prisma.user.deleteMany({ where: { email: TEST_EMAIL } });
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
}
before(async () => {
await aufraeumen();
userId = (
await prisma.user.create({
data: { email: TEST_EMAIL, name: "Sammlerin" },
select: { id: true },
})
).id;
seriesTitleId = (await importSeries(SERIES_ID, { region: "DE" })).id;
movieTitleId = (await importMovie(MOVIE_ID, "DE")).id;
await importMovie(MOVIE_2_ID, "DE");
});
beforeEach(async () => {
await prisma.achievement.deleteMany({ where: { userId } });
await prisma.watch.deleteMany({ where: { userId } });
await prisma.libraryEntry.deleteMany({ where: { userId } });
});
after(async () => {
await aufraeumen();
tmdb.restore();
await prisma.$disconnect();
});
// ---------------------------------------------------------------------------
test("der erste Lauf eines Kontos ist stumm", async () => {
// Bestandskonten bringen Jahre an Daten mit. Zwei Dutzend Meldungen auf
// einmal waeren Laerm, kein Erfolgserlebnis.
await addToLibrary(userId, movieTitleId, "WATCHLIST");
const erster = await evaluateAchievements(userId);
assert.equal(erster.stumm, true, "der erste Lauf meldet nichts");
assert.deepEqual(erster.neu, [], "auch wenn er Abzeichen vergibt");
// Vergeben wurde trotzdem.
const vergeben = await prisma.achievement.count({ where: { userId } });
assert.ok(vergeben > 0, "Erstkontakt ist erreicht");
});
test("ab dem zweiten Lauf wird gemeldet", async () => {
await addToLibrary(userId, movieTitleId, "WATCHLIST");
await evaluateAchievements(userId); // stummer Erstlauf
const eintrag = await prisma.libraryEntry.findFirstOrThrow({
where: { userId, titleId: movieTitleId },
select: { id: true },
});
await markMovieWatched(userId, eintrag.id);
await prisma.libraryEntry.update({ where: { id: eintrag.id }, data: { currentRun: 2 } });
const zweiter = await evaluateAchievements(userId);
assert.equal(zweiter.stumm, false);
assert.ok(
zweiter.neu.some((abzeichen) => abzeichen.key === "wiederholungstaeter"),
"der dritte Durchlauf wird gemeldet",
);
});
test("ein zweiter Lauf vergibt nichts doppelt", async () => {
await addToLibrary(userId, movieTitleId, "WATCHLIST");
await evaluateAchievements(userId);
const nachErstem = await prisma.achievement.count({ where: { userId } });
const zweiter = await evaluateAchievements(userId);
assert.deepEqual(zweiter.neu, []);
assert.equal(await prisma.achievement.count({ where: { userId } }), nachErstem);
});
test("einmal erreicht bleibt erreicht — auch wenn die Bedingung wegfällt", async () => {
const eintrag = await addToLibrary(userId, movieTitleId, "WATCHLIST");
await evaluateAchievements(userId);
assert.ok(
(await prisma.achievement.findFirst({ where: { userId, key: "erstkontakt" } })) != null,
);
// Titel wieder entfernen: die Bedingung ist nicht mehr erfüllt.
await prisma.libraryEntry.delete({ where: { id: eintrag.id } });
await evaluateAchievements(userId);
assert.ok(
(await prisma.achievement.findFirst({ where: { userId, key: "erstkontakt" } })) != null,
"das Abzeichen bleibt stehen",
);
});
test("Durchgemacht rechnet in der Zeitzone der Instanz", async () => {
const eintrag = await addToLibrary(userId, seriesTitleId, "WATCHING");
const episoden = await prisma.episode.findMany({
where: { titleId: seriesTitleId },
take: 1,
select: { id: true },
});
await markEpisodesWatched(userId, eintrag.id, [episoden[0]!.id]);
// 03:30 Uhr in Europe/Berlin ist im Sommer 01:30 UTC. Wer in UTC rechnet,
// verpasst das Abzeichen — genau der Fehler, der schon einmal das
// Monatsdiagramm leergefegt hat.
await prisma.watch.updateMany({
where: { userId },
data: { watchedAt: new Date("2026-07-15T01:30:00.000Z") },
});
await evaluateAchievements(userId);
const nachts = await prisma.achievement.findFirst({ where: { userId, key: "durchgemacht" } });
assert.ok(nachts, "03:30 Ortszeit zählt als Nacht");
});
test("Tagsueber gesehen loest Durchgemacht nicht aus", async () => {
const eintrag = await addToLibrary(userId, seriesTitleId, "WATCHING");
const episoden = await prisma.episode.findMany({
where: { titleId: seriesTitleId },
take: 1,
select: { id: true },
});
await markEpisodesWatched(userId, eintrag.id, [episoden[0]!.id]);
await prisma.watch.updateMany({
where: { userId },
data: { watchedAt: new Date("2026-07-15T12:00:00.000Z") },
});
await evaluateAchievements(userId);
assert.equal(await prisma.achievement.count({ where: { userId, key: "durchgemacht" } }), 0);
});
test("die Übersicht zeigt Erreichtes und Offenes", async () => {
await addToLibrary(userId, movieTitleId, "WATCHLIST");
await evaluateAchievements(userId);
const alle = await listAchievements(userId);
assert.ok(alle.length > 15, "der Katalog ist vollständig sichtbar");
const erreicht = alle.filter((abzeichen) => abzeichen.unlockedAt !== null);
const offen = alle.filter((abzeichen) => abzeichen.unlockedAt === null);
assert.ok(erreicht.length >= 1);
assert.ok(offen.length >= 1, "auch das noch nicht Erreichte steht da");
assert.deepEqual(
await listUnlockedAchievements(userId),
erreicht,
"das öffentliche Profil zeigt nur Erreichtes",
);
// Jedes Abzeichen trägt Name und Beschreibung — Farbe allein reicht nicht.
assert.ok(alle.every((abzeichen) => abzeichen.name && abzeichen.beschreibung));
});
test("ein kuratierter Name gilt nur, wenn er zur Reihe passt", async () => {
// Richtige Kennung mit passendem Namen: greift.
assert.equal(kuratierteReihe(556, "Spider-Man Filmreihe")?.name, "Spider Bro");
// Dieselbe Kennung, aber TMDB führt etwas ganz anderes darunter — dann darf
// der Eintrag nicht greifen. Genau so bleibt eine falsch abgeschriebene
// Kennung folgenlos.
assert.equal(kuratierteReihe(556, "Ocean's Filmreihe"), undefined);
// Unbekannte Kennung: nichts.
assert.equal(kuratierteReihe(999999, "Irgendwas"), undefined);
// Ohne Namen lässt sich nichts prüfen — im Zweifel generisch.
assert.equal(kuratierteReihe(556, null), undefined);
});
test("Anzeigenamen entstehen aus Schlüssel und eingefrorenem Namen", () => {
assert.equal(achievementName("marathon", null), "Marathon");
assert.equal(achievementName("reihe:556", "Spider-Man Filmreihe"), "Spider Bro");
assert.equal(
achievementName("reihe:12345", "Nordlicht Filmreihe"),
"Reihe komplett — Nordlicht Filmreihe",
"ohne kuratierten Eintrag der generische Name",
);
assert.equal(
achievementName("universum:abc", "Sternenpfad"),
"Universum vollendet — Sternenpfad",
);
});
test("ein abgeschlossenes Universum wird zum Abzeichen", async () => {
const eintrag = await addToLibrary(userId, movieTitleId, "WATCHLIST");
await markMovieWatched(userId, eintrag.id);
const universum = await prisma.universe.create({
data: {
ownerId: userId,
name: "Testuniversum",
slug: `test-achievements-${Date.now()}`,
items: { create: [{ titleId: movieTitleId }] },
orders: { create: [{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true }] },
},
select: { id: true },
});
try {
await evaluateAchievements(userId);
const abzeichen = await prisma.achievement.findFirst({
where: { userId, key: `universum:${universum.id}` },
select: { label: true },
});
assert.ok(abzeichen, "das vollendete Universum zählt");
assert.equal(abzeichen.label, "Testuniversum", "der Name wird eingefroren");
// Umbenennen ändert das Abzeichen nicht mehr.
await prisma.universe.update({
where: { id: universum.id },
data: { name: "Anders benannt" },
});
await evaluateAchievements(userId);
const danach = await prisma.achievement.findFirstOrThrow({
where: { userId, key: `universum:${universum.id}` },
select: { label: true },
});
assert.equal(danach.label, "Testuniversum");
} finally {
await prisma.universe.delete({ where: { id: universum.id } });
}
});