CI / pruefen (pull_request) Canceled after 0s
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
255 lines
8.9 KiB
TypeScript
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 } });
|
|
}
|
|
});
|