Files
klarbild/tests/naming.test.ts
T
tillandClaude Opus 5 f695554e5d fix: unique, chronologically sortable result filenames
Two badges of the same person, same format, same day produced the same
filename -- `2026-07-23_lea_the-frame.png` -- and the second upload silently
overwrote the first at Picdrop. The name now carries the time down to the
second and a short tag derived from the item id:

  2026-08-20_143207_lea-badge_the-frame_a3f9.jpg

The timestamp leads, so any file listing sorts chronologically on its own.
The tag is derived, not random, so re-running the backfill is idempotent.
Timestamps use Europe/Berlin (KLARBILD_TZ) rather than UTC -- a picture taken
at 00:30 should not be dated to the previous day.

Naming moved into its own dependency-free module so the rules are testable and
identical everywhere. The generator strips its own stamp when a result is fed
back in as a source, so names cannot accumulate.

Backfill for the existing library in Admin -> Dateinamen: preview first, then
apply. It re-stamps from `created_at` and the item id, keeping the motif and
format already chosen, and leaves the stored files alone -- they live under
their id, not their name. A separate button re-delivers everything under the
new name; the old copies stay put at the destination, since deleting a
customer's files automatically is the wrong reflex.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-09-01 02:15:29 +00:00

131 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildResultFilename, timeStamp, shortTag, slugify, KLARBILD_NAME, splitOldName, newNameFor } from '../src/lib/naming.ts';
const AT = new Date('2026-08-20T12:32:07Z'); // = 14:32:07 in Berlin (Sommerzeit)
const ID1 = 'a3f9c1de-1111-2222-3333-444455556666';
const ID2 = '77b21e00-1111-2222-3333-444455556666';
test('Der gemeldete Fehler: zwei Badges am selben Tag überschreiben sich nicht mehr', () => {
// Gleiches Motiv, gleiches Format, gleiche Sekunde — früher zweimal derselbe Name.
const a = buildResultFilename('Lea Badge.png', 'the-frame', 'jpg', { at: AT, id: ID1 });
const b = buildResultFilename('Lea Badge.png', 'the-frame', 'jpg', { at: AT, id: ID2 });
assert.notEqual(a, b, 'zwei Bilder dürfen nie denselben Namen tragen');
assert.equal(a, '2026-08-20_143207_lea-badge_the-frame_a3f9.jpg');
assert.equal(b, '2026-08-20_143207_lea-badge_the-frame_77b2.jpg');
});
test('Namen sortieren lexikografisch = chronologisch', () => {
const zeiten = [
new Date('2026-08-19T23:59:59Z'),
new Date('2026-08-20T00:00:01Z'),
new Date('2026-08-20T12:32:07Z'),
new Date('2026-12-24T08:00:00Z'), // Winterzeit — anderer UTC-Versatz
new Date('2027-01-01T00:00:00Z'),
];
const namen = zeiten.map((at, i) => buildResultFilename('foto', '30x40', 'jpg', { at, id: `${i}${i}${i}${i}0000-0000-0000-0000-000000000000` }));
const sortiert = [...namen].sort();
assert.deepEqual(sortiert, namen, `Sortierung weicht ab:\n${sortiert.join('\n')}`);
});
test('Zeitstempel steht in Berliner Zeit, nicht in UTC', () => {
// 00:30 Uhr Berliner Sommerzeit ist 22:30 UTC am Vortag — der Name muss den
// Tag zeigen, an dem Till das Bild gemacht hat.
assert.equal(timeStamp(new Date('2026-08-19T22:30:00Z')), '2026-08-20_003000');
assert.equal(timeStamp(new Date('2026-12-24T07:05:09Z')), '2026-12-24_080509'); // Winterzeit: +1
});
test('Mitternacht wird als 00 geschrieben, nicht als 24', () => {
assert.match(timeStamp(new Date('2026-08-19T22:00:00Z')), /^2026-08-20_00000/);
});
test('Kürzel ist stabil — ein zweiter Lauf ergibt denselben Namen', () => {
assert.equal(shortTag(ID1), shortTag(ID1));
assert.equal(shortTag(ID1), 'a3f9');
const it = { id: ID1, filename: '2026-07-23_lea_the-frame.png', created_at: AT };
assert.equal(newNameFor(it), newNameFor(it));
});
test('Erneutes Verarbeiten stapelt keine Stempel', () => {
const einmal = buildResultFilename('Lea Badge.png', 'the-frame', 'jpg', { at: AT, id: ID1 });
const zweimal = buildResultFilename(einmal, '30x40', 'jpg', { at: AT, id: ID2 });
assert.equal(zweimal, '2026-08-20_143207_lea-badge_30x40_77b2.jpg');
const dreimal = buildResultFilename(zweimal, 'a4', 'jpg', { at: AT, id: ID1 });
assert.equal(dreimal, '2026-08-20_143207_lea-badge_a4_a3f9.jpg');
});
test('Das eigene Muster erkennt eigene Namen und nur die', () => {
assert.ok(KLARBILD_NAME.test('2026-08-20_143207_lea-badge_the-frame_a3f9'));
assert.ok(KLARBILD_NAME.test('2026-08-20_143207_lea_30x40_a3f9-2'));
assert.ok(!KLARBILD_NAME.test('2026-07-23_lea_the-frame')); // altes Schema
assert.ok(!KLARBILD_NAME.test('urlaub-2026_strand')); // fremder Name
});
test('Alte Namen werden korrekt zerlegt', () => {
assert.deepEqual(splitOldName('2026-07-23_lea_the-frame.png'), { motif: 'lea', format: 'the-frame', ext: 'png' });
assert.deepEqual(splitOldName('2026-07-23_klarbild_30x40.jpg'), { motif: 'klarbild', format: '30x40', ext: 'jpg' });
// Fremde Datei ohne unser Schema: alles wird zum Motiv, Format „original".
assert.deepEqual(splitOldName('Urlaub Kreta.JPEG'), { motif: 'urlaub-kreta', format: 'original', ext: 'jpeg' });
// Schon neues Schema: Motiv und Format bleiben, damit die Nachbenennung idempotent ist.
assert.deepEqual(splitOldName('2026-08-20_143207_lea-badge_the-frame_a3f9.jpg'),
{ motif: 'lea-badge', format: 'the-frame', ext: 'jpg' });
});
test('Nachbenennung ist idempotent — zweiter Lauf ändert nichts', () => {
const alt = { id: ID1, filename: '2026-07-23_lea_the-frame.png', created_at: new Date('2026-07-23T09:15:42Z') };
const neu = newNameFor(alt);
assert.equal(neu, '2026-07-23_111542_lea_the-frame_a3f9.png');
assert.equal(newNameFor({ ...alt, filename: neu }), neu, 'zweiter Lauf muss denselben Namen liefern');
});
test('Nachbenennung nimmt das Erstelldatum, nicht das Datum aus dem alten Namen', () => {
// Falls ein Name mal ein falsches Datum trug, gewinnt created_at aus der Datenbank.
const it = { id: ID2, filename: '2020-01-01_lea_a4.png', created_at: new Date('2026-03-05T06:00:00Z') };
assert.match(newNameFor(it), /^2026-03-05_070000_lea_a4_77b2\.png$/);
});
test('Zähler hängt sich an, wenn ein Name wirklich vergeben ist', () => {
const a = buildResultFilename('lea', 'a4', 'png', { at: AT, id: ID1 });
const b = buildResultFilename('lea', 'a4', 'png', { at: AT, id: ID1, seq: 2 });
assert.equal(b, a.replace('.png', '-2.png'));
assert.ok(KLARBILD_NAME.test(b.replace('.png', '')));
});
test('Motive bleiben lesbar und dateisystemtauglich', () => {
const n = buildResultFilename('Größe & Maß – Übersicht!.png', '13x18', 'jpg', { at: AT, id: ID1 });
assert.equal(n, '2026-08-20_143207_groesse-mass-uebersicht_13x18_a3f9.jpg');
assert.ok(!/[^a-z0-9._-]/.test(n), `unerwartete Zeichen in ${n}`);
});
test('Generische Namen werden zu „klarbild", statt Unsinn zu erben', () => {
for (const n of ['IMG.png', 'foto.jpg', 'telegram.jpg', null]) {
const out = buildResultFilename(n as any, 'a4', 'png', { at: AT, id: ID1 });
assert.match(out, /_klarbild_a4_a3f9\.png$/, `${n} → ${out}`);
}
});
test('slugify bleibt unverändert nutzbar', () => {
assert.equal(slugify('Grüße aus Köln'), 'gruesse-aus-koeln');
});
// --- Kollisionsschleife (der Gürtel zum Hosenträger) --------------------------
// Die Schleife wird mit einer eingesetzten „ist vergeben"-Prüfung getestet, damit
// dafür keine Datenbank laufen muss.
import { uniqueFilename } from '../src/lib/renames.ts';
test('Ist der Wunschname wider Erwarten vergeben, hängt sich ein Zähler an', async () => {
const belegt = new Set(['2026-08-20_143207_lea_a4_a3f9.png']);
const n = await uniqueFilename(ID1, 'lea', 'a4', 'png', AT, async (x) => belegt.has(x));
assert.equal(n, '2026-08-20_143207_lea_a4_a3f9-2.png');
});
test('Freier Name wird unverändert vergeben', async () => {
const n = await uniqueFilename(ID1, 'lea', 'a4', 'png', AT, async () => false);
assert.equal(n, '2026-08-20_143207_lea_a4_a3f9.png');
});
test('Die Schleife gibt auch bei dauerhaft belegten Namen etwas zurück', async () => {
const n = await uniqueFilename(ID1, 'lea', 'a4', 'png', AT, async () => true);
assert.ok(n.endsWith('.png') && n.length > 10, `unbrauchbarer Rückfall: ${n}`);
});