feat(community): „von @name“ statt Anzeigename, Benutzername vor dem Hochladen und Veröffentlichen

Logo-Kacheln, Logo-Infozeile und die Detailseite einer Community-Karte zeigen den Benutzernamen des Kontos (Hilfsfunktion byline: Profil,
sonst alter Anzeigename, sonst „Anonym“). Die Namen kommen je geladener Seite in einer Abfrage nach, ein Fehler dabei bleibt ohne Fehlertext,
ein eigenes Umbenennen aktualisiert die eigenen Einträge sofort.

Die Felder „Anzeigename“ in den Dialogen „Logo hochladen“ und „Veröffentlichen“ fallen weg, ebenso account/display-name.ts und der
localStorage-Schlüssel; der Client schickt displayName: ''. Vor dem Hochladen und Veröffentlichen verlangt requireUsername() einen Namen
(„Abbrechen“: es passiert nichts); antwortet der Dienst mit 403 usernameRequired, öffnet sich der Namensdialog, danach ist ein neuer Versuch möglich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 08:06:44 +00:00
1 parent 3440eb46c3
commit 56cb67be4b
26 files changed
+1247 -407

No files matched your search

+317 -1
View File
@@ -79,10 +79,18 @@ const fx = {
listLogoReports: vi.fn<LogoPickerDeps['listLogoReports']>(),
confirm: vi.fn<LogoPickerDeps['confirm']>(),
reportDialog: vi.fn<LogoPickerDeps['reportDialog']>(),
usernamesFor: vi.fn<LogoPickerDeps['usernamesFor']>(),
requireUsername: vi.fn<LogoPickerDeps['requireUsername']>(),
onSelect: vi.fn<(id: string | null) => void>(),
onLogoChanged: vi.fn<(id: string) => void>(),
};
// Die Benutzernamen: `usernamesFor` liefert die Namen der Konten (eine Abfrage für alle IDs), `onUsernameChange` meldet das eigene Umbenennen.
const nameListeners = new Set<(userId: string, username: string) => void>();
const emitName = (userId: string, username: string) => {
for (const fn of [...nameListeners]) fn(userId, username);
};
const deps: LogoPickerDeps = {
listLogos: fx.listLogos,
getLogo: fx.getLogo,
@@ -103,6 +111,12 @@ const deps: LogoPickerDeps = {
listLogoReports: fx.listLogoReports,
confirm: fx.confirm,
reportDialog: fx.reportDialog,
usernamesFor: fx.usernamesFor,
onUsernameChange(fn) {
nameListeners.add(fn);
return () => nameListeners.delete(fn);
},
requireUsername: fx.requireUsername,
};
// Bildprüfung: `loadLogoBlob` liefert einen Blob, den die Attrappe einem Logo zuordnet; die Object-URL erbt das, und das
@@ -141,6 +155,7 @@ beforeEach(() => {
user = 'u1';
admin = false;
authListeners.clear();
nameListeners.clear();
sizes.clear();
broken.clear();
urlOwner.clear();
@@ -163,6 +178,8 @@ beforeEach(() => {
fx.listLogoReports.mockResolvedValue([]);
fx.confirm.mockResolvedValue(true);
fx.reportDialog.mockResolvedValue('cancelled');
fx.usernamesFor.mockResolvedValue(new Map()); // kein Konto hat ein Profil: es gelten die alten Anzeigenamen
fx.requireUsername.mockResolvedValue(true);
URL.createObjectURL = vi.fn((blob: Blob) => {
const url = `blob:logo-${created.length + 1}`;
created.push(url);
@@ -258,7 +275,7 @@ describe('Aufbau', () => {
expect(pressedSort()).toEqual(['Beliebt']);
});
it('erste Kachel „Kein Logo“, dann je Logo eine Kachel mit Name, „von <Anzeigename>“ bzw. „von Anonym“ und Herz-Knopf mit der Zahl', async () => {
it('erste Kachel „Kein Logo“, dann je Logo eine Kachel mit Name, „von <alter Anzeigename>“ bzw. „von Anonym“ (ohne Profil) und Herz-Knopf mit der Zahl', async () => {
fx.listLogos.mockResolvedValue({
logos: [logo('a', { likeCount: 12 }), logo('b', { displayName: '' }), logo('c', { displayName: 'Anna', likeCount: 3 })],
nextCursor: null,
@@ -328,6 +345,214 @@ describe('Aufbau', () => {
});
});
describe('Benutzernamen („von @name“)', () => {
const by = (name: string) => cell(name).querySelector('.logo-tile-by')!.textContent;
const owned = (id: string, ownerId: string, displayName = '') => logo(id, { ownerId, displayName });
beforeEach(() => {
fx.listLogos.mockResolvedValue({ logos: [owned('a', 'u1'), owned('b', 'u2', 'Anna'), owned('c', 'u3')], nextCursor: null });
});
it('eine einzige Abfrage für alle Hochlader der geladenen Seite, ohne Doppelte und ohne Einträge ohne Konto-ID', async () => {
fx.listLogos.mockResolvedValue({
logos: [owned('a', 'u1'), owned('b', 'u2'), owned('c', 'u1'), owned('d', '')],
nextCursor: null,
});
await mount();
expect(fx.usernamesFor).toHaveBeenCalledExactlyOnceWith(['u1', 'u2']);
});
it('eine leere Seite oder nur Einträge ohne Konto-ID: keine Abfrage', async () => {
fx.listLogos.mockResolvedValue({ logos: [owned('a', '')], nextCursor: null });
await mount();
expect(fx.usernamesFor).not.toHaveBeenCalled();
picker.dispose();
fx.listLogos.mockResolvedValue({ logos: [], nextCursor: null });
await mount();
expect(fx.usernamesFor).not.toHaveBeenCalled();
});
it('die Kachel zeigt „von @name“, wo es ein Profil gibt; sonst den alten Anzeigenamen (ohne @), sonst „von Anonym“', async () => {
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
await mount();
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Kenso', 'von Anna', 'von Anonym']);
});
it('ein Profil geht dem alten Anzeigenamen des Eintrags vor', async () => {
fx.listLogos.mockResolvedValue({ logos: [owned('a', 'u1', 'Alter Name')], nextCursor: null });
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
await mount();
expect(by('Logo A')).toBe('von @Kenso');
});
it('die Liste wartet nicht auf die Namen: bis sie da sind, steht der Ersatz, danach der Name', async () => {
const lookup = deferred<Map<string, string>>();
fx.usernamesFor.mockReturnValue(lookup.promise);
await mount();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von Anonym', 'von Anna', 'von Anonym']);
lookup.resolve(new Map([['u1', 'Kenso'], ['u2', 'Mia']]));
await flush();
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Kenso', 'von @Mia', 'von Anonym']);
});
it('der Name als Text, nie als HTML', async () => {
fx.usernamesFor.mockResolvedValue(new Map([['u1', '<b>fett</b>']]));
await mount();
expect(by('Logo A')).toBe('von @<b>fett</b>');
expect(cell('Logo A').querySelector('b')).toBeNull();
});
it('die Infozeile des gewählten Logos zeigt „von @name“, auch wenn die Namen erst nach der Auswahl eintreffen', async () => {
const lookup = deferred<Map<string, string>>();
fx.usernamesFor.mockReturnValue(lookup.promise);
await mount();
picker.select('a');
expect(infoLine()).toBe('Logo A · von Anonym · ♥ 0');
lookup.resolve(new Map([['u1', 'Kenso']]));
await flush();
expect(infoLine()).toBe('Logo A · von @Kenso · ♥ 0');
picker.select('b');
expect(infoLine()).toBe('Logo B · von Anna · ♥ 0'); // u2 hat kein Profil
});
it('schlägt die Abfrage der Namen fehl, bleibt es beim Ersatz: kein Fehlertext, nichts bricht, Herzen und Auswahl gehen weiter', async () => {
fx.usernamesFor.mockRejectedValue(new TypeError('Failed to fetch'));
await mount();
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von Anonym', 'von Anna', 'von Anonym']);
expect(alertText()).toBe('');
picker.select('a');
expect(infoLine()).toBe('Logo A · von Anonym · ♥ 0');
expect(infoMessage()).toBe('');
expect(fx.myHearts).toHaveBeenCalled();
heart('Logo A').click();
await flush();
expect(fx.setHeart).toHaveBeenCalledWith('a', true);
});
it('„Mehr laden“: eine weitere Abfrage für die Hochlader der neuen Seite', async () => {
fx.listLogos.mockResolvedValueOnce({ logos: [owned('a', 'u1'), owned('b', 'u2')], nextCursor: 'b' });
fx.listLogos.mockResolvedValueOnce({ logos: [owned('c', 'u3'), owned('d', 'u1')], nextCursor: null });
fx.usernamesFor.mockImplementation(async (ids) => new Map(ids.map((id) => [id, `Name-${id}`])));
await mount();
expect(fx.usernamesFor).toHaveBeenCalledTimes(1);
moreButton().click();
await flush();
expect(fx.usernamesFor).toHaveBeenCalledTimes(2);
expect(fx.usernamesFor).toHaveBeenLastCalledWith(['u3', 'u1']);
expect(['Logo A', 'Logo B', 'Logo C', 'Logo D'].map(by)).toEqual(['von @Name-u1', 'von @Name-u2', 'von @Name-u3', 'von @Name-u1']);
});
it('ein Wechsel der Ansicht lädt die Liste neu und fragt die Namen dieser Seite', async () => {
await mount();
sortButton('Neu').click();
await flush();
expect(fx.usernamesFor).toHaveBeenCalledTimes(2);
});
it('das gewählte Logo außerhalb der Liste (angeheftet) bekommt seinen Namen nach', async () => {
fx.getLogo.mockResolvedValue(owned('x', 'u7'));
fx.usernamesFor.mockImplementation(async (ids) => new Map(ids.includes('u7') ? [['u7', 'Zora']] : []));
await mount();
picker.select('x');
await flush();
expect(by('Logo X')).toBe('von @Zora');
expect(infoLine()).toBe('Logo X · von @Zora · ♥ 0');
});
it('ein frisch hochgeladenes Logo zeigt den Namen seines Hochladers (eine eigene Abfrage, wenn er der Liste noch fehlt)', async () => {
fx.usernamesFor.mockImplementation(async (ids) => new Map(ids.includes('u5') ? [['u5', 'Kenso']] : []));
fx.uploadDialog.mockResolvedValue(logo('n', { name: 'Neues Logo', ownerId: 'u5', displayName: '' }));
await mount();
expect(fx.usernamesFor).toHaveBeenCalledTimes(1);
uploadButton().click();
await flush();
expect(fx.usernamesFor).toHaveBeenCalledTimes(2);
expect(fx.usernamesFor).toHaveBeenLastCalledWith(['u5']);
expect(by('Neues Logo')).toBe('von @Kenso');
});
it('eine neue Auswahl oder ein Herz zeichnet die Kachel neu, schreibt die Zeile „von …“ aber nicht neu (derselbe Textknoten)', async () => {
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
await mount();
const span = cell('Logo A').querySelector('.logo-tile-by')!;
const text = span.firstChild;
picker.select('a');
picker.select('b');
heart('Logo A').click();
await flush();
expect(cell('Logo A').querySelector('.logo-tile-by')).toBe(span);
expect(span.firstChild).toBe(text);
expect(by('Logo A')).toBe('von @Kenso');
});
it('eigenes Umbenennen: Kacheln und Infozeile des Kontos zeigen sofort den neuen Namen, andere Einträge bleiben', async () => {
fx.listLogos.mockResolvedValue({ logos: [owned('a', 'u1'), owned('b', 'u2', 'Anna'), owned('c', 'u1')], nextCursor: null });
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Alt']]));
await mount();
picker.select('a');
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Alt', 'von Anna', 'von @Alt']);
expect(infoLine()).toBe('Logo A · von @Alt · ♥ 0');
emitName('u1', 'Neu');
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Neu', 'von Anna', 'von @Neu']);
expect(infoLine()).toBe('Logo A · von @Neu · ♥ 0');
});
it('der Name eines Kontos, das noch kein Profil hatte (Konto ohne Namen wählt einen), ersetzt den Ersatz sofort', async () => {
await mount();
expect(by('Logo A')).toBe('von Anonym');
emitName('u1', 'Kenso');
expect(by('Logo A')).toBe('von @Kenso');
expect(by('Logo B')).toBe('von Anna');
});
it('dieselbe Meldung noch einmal (auch das Lesen des eigenen Profils meldet) ändert nichts: die Kachel wird nicht neu beschrieben', async () => {
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
await mount();
const span = cell('Logo A').querySelector('.logo-tile-by')!;
const text = span.firstChild;
emitName('u1', 'Kenso');
emitName('u1', 'Kenso');
expect(cell('Logo A').querySelector('.logo-tile-by')).toBe(span);
expect(span.firstChild).toBe(text);
expect(by('Logo A')).toBe('von @Kenso');
});
it('eine Umbenennung vor dem Eintreffen einer älteren Antwort der Abfrage bleibt bestehen', async () => {
const lookup = deferred<Map<string, string>>();
fx.usernamesFor.mockReturnValue(lookup.promise);
await mount();
emitName('u1', 'Neu');
lookup.resolve(new Map([['u1', 'Neu']])); // `usernamesFor` selbst liefert nie Älteres als das Umbenannte
await flush();
expect(by('Logo A')).toBe('von @Neu');
});
it('aufgeräumt: der Hörer ist abgemeldet, eine später eintreffende Antwort ändert nichts und wirft nichts', async () => {
const lookup = deferred<Map<string, string>>();
fx.usernamesFor.mockReturnValue(lookup.promise);
await mount();
expect(nameListeners.size).toBe(1);
picker.dispose();
expect(nameListeners.size).toBe(0);
lookup.resolve(new Map([['u1', 'Kenso']]));
await flush();
expect(by('Logo A')).toBe('von Anonym');
emitName('u1', 'Neu');
expect(by('Logo A')).toBe('von Anonym');
});
it('Englisch: „by @name“, „by Anna“, „by Anonymous“', async () => {
setLang('en', { persist: false });
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
await mount();
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['by @Kenso', 'by Anna', 'by Anonymous']);
picker.select('a');
expect(infoLine()).toBe('Logo A · by @Kenso · ♥ 0');
});
});
describe('Sortierung', () => {
it('„Neu“ lädt mit sort:new neu, der Umschalter folgt, die Kacheln der alten Liste fallen weg', async () => {
await mount();
@@ -1221,6 +1446,97 @@ describe('Hochladen', () => {
expect(picker.selected()).toEqual(fresh());
});
it('vor dem Dialog wird der Benutzername verlangt (Spec § 5); das Signal der Seite geht mit, damit das Verlassen der Seite den Namensdialog schließt', async () => {
const order: string[] = [];
fx.requireUsername.mockImplementation(async () => {
order.push('name');
return true;
});
fx.uploadDialog.mockImplementation(async () => {
order.push('dialog');
return null;
});
await mount();
uploadButton().click();
await flush();
expect(order).toEqual(['name', 'dialog']);
const signal = fx.requireUsername.mock.calls[0][0];
expect(signal).toBe(fx.uploadDialog.mock.calls[0][0]);
picker.dispose();
expect(signal.aborted).toBe(true);
});
it('Gast: erst die Anmeldung, dann der Name, dann der Dialog', async () => {
user = null;
const order: string[] = [];
fx.requireLogin.mockImplementation(async () => (order.push('login'), true));
fx.requireUsername.mockImplementation(async () => (order.push('name'), true));
fx.uploadDialog.mockImplementation(async () => (order.push('dialog'), null));
await mount();
uploadButton().click();
await flush();
expect(order).toEqual(['login', 'name', 'dialog']);
});
it('der Nutzer bricht den Namensdialog ab (false): der Upload-Dialog öffnet nicht, nichts ändert sich, kein Fehlertext', async () => {
fx.requireUsername.mockResolvedValue(false);
await mount();
uploadButton().click();
await flush();
expect(fx.requireUsername).toHaveBeenCalledTimes(1);
expect(fx.uploadDialog).not.toHaveBeenCalled();
expect(fx.onSelect).not.toHaveBeenCalled();
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
expect(alertText()).toBe('');
expect(infoMessage()).toBe('');
// der Knopf geht danach wieder, und ein neuer Versuch fragt noch einmal
fx.requireUsername.mockResolvedValue(true);
uploadButton().click();
await flush();
expect(fx.requireUsername).toHaveBeenCalledTimes(2);
expect(fx.uploadDialog).toHaveBeenCalledTimes(1);
});
it('Gast bricht die Anmeldung ab: der Name wird gar nicht erst verlangt', async () => {
user = null;
fx.requireLogin.mockResolvedValue(false);
await mount();
uploadButton().click();
await flush();
expect(fx.requireUsername).not.toHaveBeenCalled();
});
it('lässt sich der Namensdialog nicht öffnen (Fehler), steht der Grund in der Infozeile und es öffnet kein Upload-Dialog', async () => {
fx.requireUsername.mockRejectedValue(new Error('kein <dialog>'));
await mount();
uploadButton().click();
await flush();
expect(infoMessage()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
expect(fx.uploadDialog).not.toHaveBeenCalled();
expect(alertText()).toBe('');
});
it('wird die Seite verlassen, während der Namensdialog offen ist, öffnet danach kein Upload-Dialog mehr', async () => {
const asking = deferred<boolean>();
fx.requireUsername.mockReturnValue(asking.promise);
await mount();
uploadButton().click();
await flush();
picker.dispose();
asking.resolve(true);
await flush();
expect(fx.uploadDialog).not.toHaveBeenCalled();
});
it('ein zweiter Klick, solange der Namensdialog offen ist, öffnet keinen zweiten', async () => {
fx.requireUsername.mockReturnValue(new Promise(() => {}));
await mount();
uploadButton().click();
uploadButton().click();
await flush();
expect(fx.requireUsername).toHaveBeenCalledTimes(1);
});
it('Gast: zuerst requireLogin, dann der Dialog', async () => {
user = null;
const order: string[] = [];