Ein CC-Paket ist ein Platz fuer einen gleichzeitig aktiven CC - ein Redesign tauscht einen CC aus, statt einen Platz hinzuzufuegen. Beim Gruppen-Beitritt galt das seit dem letzten Stand, an anderer Stelle aber noch nicht: ein redesignter CC liess sich mit einem Redesign-Guthaben zurueckholen, waehrend sein Nachfolger aktiv blieb. Zwei aktive CCs auf ein einziges Paket. Geprueft wird das jetzt an einer Stelle statt an jedem Weg einzeln, fuer Erschaffen, Redesign, Reaktivierung, Rueckkehr und Beitritt. Der Massstab ist die Deckung, nicht der Kontostand: jeder aktive CC braucht eine eigene Buchung im CC-Topf. Ein bewusst ueberzogenes Konto bleibt damit moeglich - die Buchung ist da, nur das Guthaben fehlt, und das Minus steht sichtbar auf der Spielerseite. Beide Abgleiche laufen jetzt von allein: die Kaeufe alle zwei Stunden, bei Spielern ohne Kauf in den letzten drei Jahren alle sechs, das Forum einmal nachts. Das spart den Grossteil der Anfragen, denn ruhende Konten sind mit der Zeit die Mehrheit. Gebucht wird dabei nichts - neue Pakete landen in der Vorschau, neue Antraege in der Liste. Der Stand steht in der Datenbank, damit ein Neustart nicht alles erneut anstoesst. Unter Verwaltung steht, wann zuletzt gelaufen wurde; auf der Uebersicht, wie viele Pakete auf eine Buchung warten und bei wem. Ausserdem: - Die Guthaben-Historie zeigt bei Importen den Kauf im Shop, nicht den Zeitpunkt der Eingabe hier. Bereits gebuchte Importe werden nachgetragen. - Forums-Antraege tragen ihr Datum, im Forum ausgelesen statt geraten. - Die Hinweisbox ueber den Antraegen beim Spieler faellt weg. - Die Aktionsspalte der Nutzerverwaltung war per display:flex aus dem Tabellenlayout gefallen, daher die versetzte Trennlinie. - Die Zeitzone steht jetzt im Compose-Stack, sonst laege "nachts um vier" nach UTC und im Sommer zwei Stunden daneben.
151 lines
5.7 KiB
TypeScript
151 lines
5.7 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { describe, it } from 'node:test';
|
|
import { hasIgnoredLabel, parseBoard, parsePageCount, parseThreadId } from '../src/lib/forum.js';
|
|
import { isRateLimited, retryAfterMs } from '../src/lib/ratelimit.js';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const BOARD = readFileSync(join(here, 'fixtures', 'board.html'), 'utf8');
|
|
|
|
describe('Board-Übersicht lesen', () => {
|
|
const threads = parseBoard(BOARD);
|
|
|
|
it('liest die Threads der Liste', () => {
|
|
assert.equal(threads.length, 6);
|
|
assert.deepEqual(
|
|
threads.map((t) => t.threadId),
|
|
[7001, 7002, 7004, 7005, 7003, 7006],
|
|
);
|
|
});
|
|
|
|
it('liest das Datum des Antrags, nicht das der letzten Antwort', () => {
|
|
const erster = threads.find((t) => t.threadId === 7001);
|
|
assert.equal(erster?.postedAt?.toISOString(), '2025-03-04T18:22:05.000Z');
|
|
});
|
|
|
|
it('kommt ohne Zeitangabe klar', () => {
|
|
const ohneZeit = threads.find((t) => t.threadId === 7002);
|
|
assert.equal(ohneZeit?.postedAt, null);
|
|
});
|
|
|
|
it('liest den Ersteller auch ohne Profil-Link', () => {
|
|
// Wer sein Konto geloescht hat, steht als blosser Text in der Zelle. Ohne
|
|
// Rueckfall haette der Thread keinen Autor und liesse sich niemandem
|
|
// zuordnen - er waere im Abgleich unsichtbar.
|
|
const ohneKonto = threads.find((t) => t.threadId === 7006);
|
|
assert.equal(ohneKonto?.authorName, 'Ehemaliger');
|
|
assert.equal(ohneKonto?.authorForumId, null);
|
|
});
|
|
|
|
it('nimmt den Ersteller aus der Übersicht, nicht den letzten Antwortenden', () => {
|
|
const first = threads.find((t) => t.threadId === 7001);
|
|
assert.equal(first?.authorName, 'Antragsteller');
|
|
assert.equal(first?.authorForumId, 501, 'die Forum-ID überlebt Namensänderungen');
|
|
});
|
|
|
|
it('löst Umlaut- und Anführungszeichen-Entities auf', () => {
|
|
const second = threads.find((t) => t.threadId === 7002);
|
|
assert.equal(second?.title, 'Custom Character Redesign | Alt zu Neu');
|
|
});
|
|
|
|
it('erkennt einen zurückgelassenen Verweis als solchen', () => {
|
|
const stub = threads.find((t) => t.threadId === 7003);
|
|
assert.equal(stub?.isMovedStub, true);
|
|
});
|
|
|
|
it('ignoriert Unterforen und deren letzte Beiträge', () => {
|
|
// 9999 steht im wbbBoardNode-Kasten, gehoert aber nicht in dieses Board.
|
|
assert.equal(threads.some((t) => t.threadId === 9999), false);
|
|
});
|
|
|
|
it('liest die Gesamtzahl der Seiten, nicht nur die verlinkten', () => {
|
|
// Die Blätterleiste verlinkt hier nur Seite 2 und 3, das Board hat aber 25.
|
|
// Wer die Links auswertet, liest den grössten Teil des Archivs nie.
|
|
assert.equal(parsePageCount(BOARD), 25);
|
|
});
|
|
|
|
it('fällt ohne das Element auf die Links zurück', () => {
|
|
assert.equal(parsePageCount('<a href="x&pageNo=7">7</a>'), 7);
|
|
assert.equal(parsePageCount('<html>ohne Blaetterleiste</html>'), 1);
|
|
});
|
|
|
|
it('kommt mit einer leeren Seite klar', () => {
|
|
assert.deepEqual(parseBoard('<html><body>nichts</body></html>'), []);
|
|
});
|
|
});
|
|
|
|
describe('Ankündigungen aussortieren', () => {
|
|
const threads = parseBoard(BOARD);
|
|
|
|
it('liest die Labels eines Threads', () => {
|
|
const regelwerk = threads.find((t) => t.threadId === 7004);
|
|
assert.deepEqual(regelwerk?.labels, ['Wichtig', 'Global']);
|
|
});
|
|
|
|
it('erkennt Wichtig und Information als Ausschluss', () => {
|
|
assert.equal(hasIgnoredLabel(threads.find((t) => t.threadId === 7004)!), true);
|
|
assert.equal(hasIgnoredLabel(threads.find((t) => t.threadId === 7005)!), true);
|
|
});
|
|
|
|
it('lässt echte Anträge durch, auch mit anderen Labels', () => {
|
|
assert.equal(hasIgnoredLabel(threads.find((t) => t.threadId === 7001)!), false);
|
|
});
|
|
|
|
it('wertet den Titel nicht aus', () => {
|
|
// "The Duke Rang Update" war ein echter Antrag, "Regelwerk" keiner. Am Titel
|
|
// ist das nicht zu erkennen - deshalb entscheidet allein das Label.
|
|
const ohneStichwort = parseBoard(BOARD).find((t) => t.threadId === 7005);
|
|
assert.ok(ohneStichwort, 'auch ein Thread ohne CC im Titel wird gelesen');
|
|
});
|
|
});
|
|
|
|
describe('Thread-Verweis lesen', () => {
|
|
it('nimmt den vollen Link', () => {
|
|
assert.equal(
|
|
parseThreadId('https://senseigaming.de/forum/index.php?thread/8041-cc-antrag-feldagent/'),
|
|
8041,
|
|
);
|
|
});
|
|
|
|
it('nimmt auch nur den Pfad oder die blanke Nummer', () => {
|
|
assert.equal(parseThreadId('index.php?thread/7001-irgendwas/'), 7001);
|
|
assert.equal(parseThreadId('7001'), 7001);
|
|
});
|
|
|
|
it('weist Unbrauchbares ab', () => {
|
|
assert.equal(parseThreadId(''), null);
|
|
assert.equal(parseThreadId('kein link'), null);
|
|
});
|
|
});
|
|
|
|
describe('Ruecksicht auf einen bremsenden Server', () => {
|
|
it('erkennt die Abweisungen, die "spaeter nochmal" bedeuten', () => {
|
|
assert.equal(isRateLimited(429), true);
|
|
assert.equal(isRateLimited(503), true);
|
|
assert.equal(isRateLimited(404), false, 'eine fehlende Seite kommt auch spaeter nicht');
|
|
assert.equal(isRateLimited(200), false);
|
|
});
|
|
|
|
it('haelt sich an die vom Forum genannte Wartezeit', () => {
|
|
assert.equal(retryAfterMs('30', 0), 30000);
|
|
});
|
|
|
|
it('deckelt eine unverschaemt lange Angabe', () => {
|
|
assert.equal(retryAfterMs('86400', 0), 60000);
|
|
});
|
|
|
|
it('wartet ohne Angabe mit jedem Versuch laenger', () => {
|
|
assert.equal(retryAfterMs(null, 0), 5000);
|
|
assert.equal(retryAfterMs(null, 1), 10000);
|
|
assert.equal(retryAfterMs(null, 2), 15000);
|
|
});
|
|
|
|
it('ignoriert eine unbrauchbare Angabe', () => {
|
|
// WoltLab darf hier auch ein Datum schicken - dann greift die Staffelung.
|
|
assert.equal(retryAfterMs('Wed, 21 Oct 2026 07:28:00 GMT', 1), 10000);
|
|
assert.equal(retryAfterMs('-5', 0), 5000);
|
|
});
|
|
});
|