Ketten mit der Maus ordnen, Verweise an CCs, Umzug per Tabelle

Die Spielerseite zeigt CCs nicht mehr als zwei Listen "aktiv" und "inaktiv",
sondern als das, was sie sind: je Platz eine Kette, von unten nach oben
gelesen. Unten der erste CC, darueber seine Redesigns.

Gefunden wird selten alles auf einmal. Ein Redesign taucht Jahre spaeter auf,
weil sein Antrag in einem anderen Board lag oder weil der Spieler es fuer den
Beitritt zu einem fremden Gruppen-CC benutzt hat. Dann steht in der Kette
schon "3 auf 5", und 4 fehlt. Deshalb laesst sich jeder Eintrag mit der Maus
an seinen Platz ziehen - auch mitten in eine Kette und in jede andere Kette
desselben Spielers. Das Verschieben ordnet nur ein: es bucht nichts und
aendert keinen Status.

Dafuer traegt jeder CC jetzt eine Position in seiner Kette. Sie laesst sich
verschieben, ohne die ganze Kette neu zu haengen; der Vorgaenger-Verweis, an
dem der Rest der Anwendung haengt, wird danach aus den Positionen neu
abgeleitet. So koennen beide Sichten nicht auseinanderlaufen. Der Bestand
bekommt seine Positionen in der Migration aus der vorhandenen Verkettung.

An jedem CC haengen jetzt beliebig viele Verweise: ein Name und eine Adresse,
der Name wird zum Link. Nur http und https - ein "javascript:"-Link in einer
Oberflaeche, die mehrere Leute bedienen, waere ein offenes Scheunentor.

Die SteamID fuehrt auf der Spielerseite zum GExtension-Profil, der Forum-Name
zum Forumsprofil. Dessen Adresse kommt aus dem Abgleich: die Profile liegen
nicht unter dem Forums-Pfad, sondern eine Ebene darueber, und das laesst sich
nicht verlaesslich erraten - also wird uebernommen, worauf das Forum selbst
verweist.

Zuletzt der Umzug: unter Import gibt es fuer Administratoren den gesamten
Bestand als eine Tabelle - Spieler, Guthaben, CCs samt Ketten, frueherer
Konten, Mitgliedschaften und Verweise. Eingespielt wird ergaenzend: was da
ist, bleibt unangetastet, zweimal dieselbe Datei aendert nichts, und krumme
Zeilen werden mit Zeilennummer gemeldet statt den Lauf abzubrechen. Ein Test
spielt den vollen Weg durch - exportieren, Bestand loeschen, zurueckholen.
This commit is contained in:
2026-09-21 13:18:11 +02:00
parent 029d380abf
commit db37d3388a
22 changed files with 2472 additions and 357 deletions
+230
View File
@@ -0,0 +1,230 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Backup = typeof import('../src/services/backup.js');
type Players = typeof import('../src/services/players.js');
type Characters = typeof import('../src/services/characters.js');
type Credits = typeof import('../src/services/credits.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let backup: Backup;
let players: Players;
let characters: Characters;
let credits: Credits;
let db: Db;
let ctx: { actorId: number; actorName: string };
before(async () => {
database = await startTestDatabase(54339);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
process.env.SCHEDULE_ENABLED = 'false';
db = await import('../src/db.js');
backup = await import('../src/services/backup.js');
players = await import('../src/services/players.js');
characters = await import('../src/services/characters.js');
credits = await import('../src/services/credits.js');
const user = await db.prisma.user.create({
data: { username: 'umzug', passwordHash: 'x', role: 'ADMIN' },
});
ctx = { actorId: user.id, actorName: user.username };
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
/** Räumt den Bestand ab, als wäre es eine frische Instanz. */
async function leereInstanz() {
await db.prisma.characterLink.deleteMany();
await db.prisma.characterMember.deleteMany();
await db.prisma.creditLedger.deleteMany();
await db.prisma.character.deleteMany();
await db.prisma.playerAlias.deleteMany();
await db.prisma.player.deleteMany();
}
/** Ein Bestand mit allem, was der Export kennen muss. */
async function bestandAnlegen() {
const chef = await players.createPlayer(
{
displayName: 'Umzugs-Chef',
steamId: '76561198000030001',
forumName: 'UmzugsChef',
notes: 'Führt die Gruppe',
},
ctx,
);
const gast = await players.createPlayer(
{ displayName: 'Umzugs-Gast', steamId: '76561198000030002' },
ctx,
);
const problemfall = await players.createPlayer(
{ displayName: 'Umzugs-Problem', isProblem: true, problemNote: 'Kein Paket auffindbar' },
ctx,
);
await players.addAlias(chef.id, 'STEAM_ID', '76561198000030099', 'Erstes Konto', ctx);
await credits.bookCcPackage(chef.id, ctx);
await credits.bookCcPackage(gast.id, ctx);
await credits.bookRedesignPackage(chef.id, ctx);
const erster = await characters.createCharacter(
chef.id,
{ name: 'Erster Wurf', loreText: 'Ein Anfang' },
ctx,
);
const zweiter = await characters.redesignCharacter(
erster.character.id,
{ name: 'Zweiter Wurf', useKind: 'REDESIGN_PAID' },
ctx,
);
await characters.addMember(zweiter.character.id, gast.id, { useKind: 'CC' }, ctx);
await characters.addLink(
zweiter.character.id,
{ label: 'Ersteller-Addon', url: 'https://steamcommunity.com/sharedfiles/?id=1' },
ctx,
);
return { chef, gast, problemfall, erster, zweiter };
}
describe('Umzug auf eine andere Instanz', () => {
let csv = '';
it('schreibt jede Satzart in die Tabelle', async () => {
await bestandAnlegen();
csv = await backup.exportAll();
for (const satzart of ['SPIELER', 'KONTO', 'GUTHABEN', 'CC', 'MITGLIED', 'VERWEIS']) {
assert.ok(csv.includes(`${satzart};`), `${satzart} fehlt im Export`);
}
assert.ok(csv.startsWith('satzart;'), 'die Kopfzeile nennt die Satzart zuerst');
assert.ok(csv.includes('Ersteller-Addon'), 'Verweise gehören dazu');
assert.ok(csv.includes('Kein Paket auffindbar'), 'Problemfälle auch');
});
it('stellt den Bestand auf einer leeren Instanz wieder her', async () => {
await leereInstanz();
assert.equal(await db.prisma.player.count(), 0);
const bericht = await backup.importAll(csv, ctx);
assert.deepEqual(bericht.fehler, [], 'keine Zeile darf hängen bleiben');
assert.equal(await db.prisma.player.count(), 3);
assert.equal(await db.prisma.character.count(), 2);
assert.equal(await db.prisma.characterLink.count(), 1);
const chef = await db.prisma.player.findFirstOrThrow({
where: { displayName: 'Umzugs-Chef' },
});
assert.equal(chef.steamId, '76561198000030001');
assert.equal(chef.forumName, 'UmzugsChef');
// Guthaben und Verbräuche stehen wieder wie zuvor.
const stand = await credits.getBalances(chef.id);
assert.equal(stand.CC, 0, 'ein Paket gekauft, einen CC daraus gebaut');
assert.equal(stand.REDESIGN_PAID, 1, 'zwei Redesigns erhalten, eines verbraucht');
});
it('hält die Redesign-Kette zusammen', async () => {
const chef = await db.prisma.player.findFirstOrThrow({
where: { displayName: 'Umzugs-Chef' },
});
const kette = await db.prisma.character.findMany({
where: { playerId: chef.id },
orderBy: { chainPosition: 'asc' },
});
assert.deepEqual(kette.map((c) => c.name), ['Erster Wurf', 'Zweiter Wurf']);
assert.equal(
kette[0]?.lineageId,
kette[1]?.lineageId,
'beide gehören zur selben Kette',
);
assert.equal(kette[0]?.status, 'INACTIVE');
assert.equal(kette[1]?.status, 'ACTIVE');
});
it('nimmt den Gruppen-CC samt seinem zweiten Nutzer mit', async () => {
const gast = await db.prisma.player.findFirstOrThrow({
where: { displayName: 'Umzugs-Gast' },
});
const seine = await characters.listCharacters(gast.id);
assert.deepEqual(
seine.map((c) => c.name),
['Zweiter Wurf'],
'er ist Mitglied, nicht Besitzer',
);
});
it('legt beim zweiten Einspielen nichts doppelt an', async () => {
const vorher = {
spieler: await db.prisma.player.count(),
ccs: await db.prisma.character.count(),
buchungen: await db.prisma.creditLedger.count(),
mitglieder: await db.prisma.characterMember.count(),
links: await db.prisma.characterLink.count(),
};
const bericht = await backup.importAll(csv, ctx);
assert.deepEqual(bericht.fehler, []);
assert.equal(
Object.values(bericht.angelegt).reduce((a, b) => a + b, 0),
0,
'alles war schon da',
);
assert.deepEqual(
{
spieler: await db.prisma.player.count(),
ccs: await db.prisma.character.count(),
buchungen: await db.prisma.creditLedger.count(),
mitglieder: await db.prisma.characterMember.count(),
links: await db.prisma.characterLink.count(),
},
vorher,
'ein zweiter Lauf darf den Bestand nicht verdoppeln',
);
});
it('ergänzt einen Spieler, der auf der Zielinstanz fehlt', async () => {
await db.prisma.player.deleteMany({ where: { displayName: 'Umzugs-Problem' } });
assert.equal(await db.prisma.player.count(), 2);
const bericht = await backup.importAll(csv, ctx);
assert.equal(bericht.angelegt.SPIELER, 1, 'nur der fehlende kommt dazu');
assert.equal(await db.prisma.player.count(), 3);
const wieder = await db.prisma.player.findFirstOrThrow({
where: { displayName: 'Umzugs-Problem' },
});
assert.equal(wieder.isProblem, true, 'auch die Markierung kommt mit');
});
it('meldet krumme Zeilen, statt den Lauf abzubrechen', async () => {
const kaputt = [
'satzart;spieler;steam_id;cc_name;bezeichnung;wert',
'VERWEIS;Gibt-Es-Nicht;;Kein CC;Irgendwas;https://example.org/',
'SPIELER;Kommt-Trotzdem-Durch;76561198000030777;;;',
].join('\n');
const bericht = await backup.importAll(kaputt, ctx);
assert.equal(bericht.fehler.length, 1);
assert.match(bericht.fehler[0]?.grund ?? '', /nicht gefunden/);
assert.equal(bericht.angelegt.SPIELER, 1, 'die gute Zeile läuft trotzdem durch');
});
it('weist eine Datei ohne Satzart ab', async () => {
await assert.rejects(
() => backup.importAll('name;wert\nirgendwas;1', ctx),
(error: Error & { code?: string }) => error.code === 'NO_SATZART',
);
});
});
+280
View File
@@ -0,0 +1,280 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Characters = typeof import('../src/services/characters.js');
type Credits = typeof import('../src/services/credits.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let characters: Characters;
let credits: Credits;
let db: Db;
let ctx: { actorId: number; actorName: string };
before(async () => {
database = await startTestDatabase(54340);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
process.env.SCHEDULE_ENABLED = 'false';
db = await import('../src/db.js');
characters = await import('../src/services/characters.js');
credits = await import('../src/services/credits.js');
const user = await db.prisma.user.create({
data: { username: 'ketten', passwordHash: 'x', role: 'ADMIN' },
});
ctx = { actorId: user.id, actorName: user.username };
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
async function spielerMitPaketen(name: string, pakete = 1) {
const player = await db.prisma.player.create({ data: { displayName: name } });
for (let i = 0; i < pakete; i += 1) await credits.bookCcPackage(player.id, ctx);
return player.id;
}
/** Die Kette eines Spielers, wie sie angezeigt würde. */
async function kette(playerId: number, lineageId: string) {
const glieder = await db.prisma.character.findMany({
where: { playerId, lineageId },
orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }],
select: { id: true, name: true, predecessorId: true, chainPosition: true },
});
return glieder;
}
/** Prüft, dass Positionen und Vorgänger-Verweise dasselbe sagen. */
function verkettungStimmt(glieder: { id: number; predecessorId: number | null }[]): void {
let erwartet: number | null = null;
for (const glied of glieder) {
assert.equal(
glied.predecessorId,
erwartet,
'die Verkettung muss der Reihenfolge folgen, sonst laufen beide Sichten auseinander',
);
erwartet = glied.id;
}
}
describe('Lücken in einer Redesign-Kette schliessen', () => {
it('legt Redesigns hintereinander ab', async () => {
const spieler = await spielerMitPaketen('Kettenbauer', 1);
const eins = await characters.createCharacter(spieler, { name: 'CC 1' }, ctx);
await credits.bookRedesignPackage(spieler, ctx);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'Redesign 2', useKind: 'REDESIGN_PAID' },
ctx,
);
const glieder = await kette(spieler, zwei.character.lineageId);
assert.deepEqual(glieder.map((g) => g.name), ['CC 1', 'Redesign 2']);
verkettungStimmt(glieder);
});
it('schiebt ein später gefundenes Redesign mitten hinein', async () => {
const spieler = await spielerMitPaketen('Nachtraeger', 2);
const eins = await characters.createCharacter(spieler, { name: 'CC 1' }, ctx);
await credits.bookRedesignPackage(spieler, ctx);
const drei = await characters.redesignCharacter(
eins.character.id,
{ name: 'Redesign 3', useKind: 'REDESIGN_PAID' },
ctx,
);
// Redesign 2 taucht erst jetzt auf - als eigener CC, noch ohne Kette.
const zwei = await characters.createCharacter(spieler, { name: 'Redesign 2' }, ctx);
assert.notEqual(zwei.character.lineageId, drei.character.lineageId);
await characters.moveInChain(
zwei.character.id,
{ lineageId: drei.character.lineageId, afterId: eins.character.id },
ctx,
);
const glieder = await kette(spieler, drei.character.lineageId);
assert.deepEqual(
glieder.map((g) => g.name),
['CC 1', 'Redesign 2', 'Redesign 3'],
'die Lücke ist geschlossen, ohne die übrigen anzufassen',
);
verkettungStimmt(glieder);
});
it('setzt einen Eintrag auch an den Anfang', async () => {
const spieler = await spielerMitPaketen('Vorspann', 2);
const b = await characters.createCharacter(spieler, { name: 'B' }, ctx);
const a = await characters.createCharacter(spieler, { name: 'A' }, ctx);
await characters.moveInChain(
a.character.id,
{ lineageId: b.character.lineageId, afterId: null },
ctx,
);
const glieder = await kette(spieler, b.character.lineageId);
assert.deepEqual(glieder.map((g) => g.name), ['A', 'B']);
verkettungStimmt(glieder);
assert.equal(glieder[0]?.predecessorId, null, 'der Erste hat keinen Vorgänger');
});
it('holt einen Eintrag aus einer Kette in eine andere', async () => {
const spieler = await spielerMitPaketen('Umsortierer', 3);
const ersteKette = await characters.createCharacter(spieler, { name: 'Strang A' }, ctx);
const zweiteKette = await characters.createCharacter(spieler, { name: 'Strang B' }, ctx);
const wanderer = await characters.createCharacter(spieler, { name: 'Wanderer' }, ctx);
await characters.moveInChain(
wanderer.character.id,
{ lineageId: ersteKette.character.lineageId, afterId: ersteKette.character.id },
ctx,
);
assert.equal((await kette(spieler, ersteKette.character.lineageId)).length, 2);
// Doch die andere Kette.
await characters.moveInChain(
wanderer.character.id,
{ lineageId: zweiteKette.character.lineageId, afterId: zweiteKette.character.id },
ctx,
);
const erste = await kette(spieler, ersteKette.character.lineageId);
const zweite = await kette(spieler, zweiteKette.character.lineageId);
assert.deepEqual(erste.map((g) => g.name), ['Strang A'], 'die alte Kette schliesst sich');
assert.deepEqual(zweite.map((g) => g.name), ['Strang B', 'Wanderer']);
verkettungStimmt(erste);
verkettungStimmt(zweite);
});
it('löst einen Eintrag wieder heraus', async () => {
const spieler = await spielerMitPaketen('Loeser', 2);
const eins = await characters.createCharacter(spieler, { name: 'Bleibt' }, ctx);
await credits.bookRedesignPackage(spieler, ctx);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'Geht wieder', useKind: 'REDESIGN_PAID' },
ctx,
);
const { lineageId } = await characters.detachFromChain(zwei.character.id, ctx);
assert.notEqual(lineageId, eins.character.lineageId);
const alte = await kette(spieler, eins.character.lineageId);
assert.deepEqual(alte.map((g) => g.name), ['Bleibt']);
const allein = await db.prisma.character.findUniqueOrThrow({
where: { id: zwei.character.id },
});
assert.equal(allein.predecessorId, null, 'allein heisst ohne Vorgänger');
});
it('lässt keinen CC in die Kette eines anderen Spielers', async () => {
const einer = await spielerMitPaketen('Einer', 1);
const anderer = await spielerMitPaketen('Anderer', 1);
const seiner = await characters.createCharacter(einer, { name: 'Seiner' }, ctx);
const fremder = await characters.createCharacter(anderer, { name: 'Fremder' }, ctx);
await assert.rejects(
() =>
characters.moveInChain(
fremder.character.id,
{ lineageId: seiner.character.lineageId, afterId: seiner.character.id },
ctx,
),
(error: Error & { code?: string }) => error.code === 'FOREIGN_CHAIN',
);
});
it('lässt einen CC nicht hinter sich selbst stellen', async () => {
const spieler = await spielerMitPaketen('Schlange', 1);
const cc = await characters.createCharacter(spieler, { name: 'Beisst sich' }, ctx);
await assert.rejects(
() =>
characters.moveInChain(
cc.character.id,
{ lineageId: cc.character.lineageId, afterId: cc.character.id },
ctx,
),
(error: Error & { code?: string }) => error.code === 'SELF_REFERENCE',
);
});
it('rührt den Status nicht an', async () => {
const spieler = await spielerMitPaketen('Unberuehrt', 2);
const aktiv = await characters.createCharacter(spieler, { name: 'Aktiv' }, ctx);
const ziel = await characters.createCharacter(spieler, { name: 'Ziel' }, ctx);
await characters.moveInChain(
aktiv.character.id,
{ lineageId: ziel.character.lineageId, afterId: ziel.character.id },
ctx,
);
const danach = await db.prisma.character.findUniqueOrThrow({
where: { id: aktiv.character.id },
});
assert.equal(danach.status, 'ACTIVE', 'verschieben ordnet nur ein, es entscheidet nichts');
});
});
describe('Verweise an einem CC', () => {
it('hängt einen Verweis an und gibt ihn mit aus', async () => {
const spieler = await spielerMitPaketen('Verweiser', 1);
const cc = await characters.createCharacter(spieler, { name: 'Mit Addon' }, ctx);
await characters.addLink(
cc.character.id,
{ label: 'Ersteller-Addon', url: 'https://steamcommunity.com/sharedfiles/?id=42' },
ctx,
);
const liste = await characters.listCharacters(spieler);
const gefunden = liste.find((c) => c.id === cc.character.id);
assert.deepEqual(
gefunden?.links.map((l) => l.label),
['Ersteller-Addon'],
);
});
it('weist eine Adresse ab, die keine ist', async () => {
const spieler = await spielerMitPaketen('Vorsichtig', 1);
const cc = await characters.createCharacter(spieler, { name: 'Sauber' }, ctx);
for (const url of ['javascript:alert(1)', 'nur-text', 'file:///etc/passwd']) {
await assert.rejects(
() => characters.addLink(cc.character.id, { label: 'Test', url }, ctx),
(error: Error & { code?: string }) => error.code === 'INVALID_URL',
`"${url}" hätte abgewiesen werden müssen`,
);
}
});
it('lässt sich ändern und entfernen', async () => {
const spieler = await spielerMitPaketen('Pfleger', 1);
const cc = await characters.createCharacter(spieler, { name: 'Gepflegt' }, ctx);
const link = await characters.addLink(
cc.character.id,
{ label: 'Alt', url: 'https://example.org/alt' },
ctx,
);
const geaendert = await characters.updateLink(
link.id,
{ label: 'Neu', url: 'https://example.org/neu' },
ctx,
);
assert.equal(geaendert.label, 'Neu');
assert.equal(geaendert.url, 'https://example.org/neu');
await characters.removeLink(link.id, ctx);
assert.equal(await db.prisma.characterLink.count({ where: { characterId: cc.character.id } }), 0);
});
});