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

+151 -1
View File
@@ -41,6 +41,14 @@ const report = (n: number, reason: string): GalleryReport => ({
createdAt: '2026-10-05T12:00:00.000Z',
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
const settle = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
@@ -70,6 +78,12 @@ const navigate = vi.fn<(path: string) => void>();
const saveBlobAs = vi.fn<(b: Blob, name: string) => void>();
const withLogin = vi.fn(<T>(fn: () => Promise<T>): Promise<T> => fn());
const getUser = vi.fn<() => Promise<User | null>>();
/** Die Benutzernamen (eine Abfrage für die Konten der Seite) und die Hörer des eigenen Umbenennens. */
const usernamesFor = vi.fn<CommunityDeps['usernamesFor']>();
let nameListeners: Set<(userId: string, username: string) => void>;
const emitName = (userId: string, username: string) => {
for (const fn of [...nameListeners]) fn(userId, username);
};
function deps(): CommunityDeps {
return {
@@ -81,6 +95,11 @@ function deps(): CommunityDeps {
setVisibility,
currentUser: () => user,
getUser,
usernamesFor,
onUsernameChange: (fn) => {
nameListeners.add(fn);
return () => nameListeners.delete(fn);
},
onAuthChange: (fn) => {
authListeners.add(fn);
return () => authListeners.delete(fn);
@@ -114,7 +133,7 @@ const errorText = () => q('[role="alert"]')?.textContent ?? '';
const statusText = () => q('.detail-info [role="status"]')?.textContent ?? '';
beforeEach(() => {
for (const fn of [getCard, listReports, loadImage, reportCard, withdrawCard, setVisibility, navigate, saveBlobAs, withLogin, getUser]) {
for (const fn of [getCard, listReports, loadImage, reportCard, withdrawCard, setVisibility, navigate, saveBlobAs, withLogin, getUser, usernamesFor]) {
fn.mockReset();
}
getCard.mockResolvedValue(card());
@@ -123,8 +142,10 @@ beforeEach(() => {
withdrawCard.mockResolvedValue(undefined);
setVisibility.mockResolvedValue(undefined);
getUser.mockResolvedValue(null);
usernamesFor.mockResolvedValue(new Map()); // das Konto des Autors hat kein Profil: es gilt der alte Anzeigename
user = null;
authListeners = new Set();
nameListeners = new Set();
urlCounter = 0;
revoked.length = 0;
URL.revokeObjectURL = vi.fn((url: string) => {
@@ -354,6 +375,135 @@ describe('Detailseite: Karte', () => {
});
});
describe('Detailseite: „von @name“', () => {
const meta = () => q('.detail-meta')?.textContent;
it('fragt den Namen des Autors einmal je geladener Karte, mit seiner Konto-ID', async () => {
await mount();
expect(usernamesFor).toHaveBeenCalledExactlyOnceWith(['u1']);
});
it('zeigt „von @name“ und das Datum; ein Profil geht dem alten Anzeigenamen vor', async () => {
usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso.K']]));
await mount(); // die Karte trägt noch den alten Anzeigenamen „Kenso“
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
});
it('ohne Profil: der alte Anzeigename (ohne @), sonst „von Anonym“', async () => {
await mount();
expect(meta()).toBe('von Kenso · 04.10.2026');
unmount?.();
getCard.mockResolvedValue(card({ displayName: '' }));
await mount();
expect(meta()).toBe('von Anonym · 04.10.2026');
});
it('die Karte steht, ohne auf den Namen zu warten; er erscheint, sobald er da ist', async () => {
const lookup = deferred<Map<string, string>>();
usernamesFor.mockReturnValue(lookup.promise);
await mount();
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
expect(q('.detail-card img')).not.toBeNull();
expect(meta()).toBe('von Kenso · 04.10.2026');
lookup.resolve(new Map([['u1', 'Kenso.K']]));
await settle();
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
});
it('schlägt die Abfrage fehl, bleibt es beim Ersatz: kein Fehlertext, die Seite geht weiter', async () => {
usernamesFor.mockRejectedValue(new TypeError('Failed to fetch'));
await mount();
expect(meta()).toBe('von Kenso · 04.10.2026');
expect(errorText()).toBe('');
expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden']);
});
it('eine Karte ohne Konto-ID (Altbestand): keine Abfrage, der Ersatz', async () => {
getCard.mockResolvedValue(card({ ownerId: '', displayName: '' }));
await mount();
expect(usernamesFor).not.toHaveBeenCalled();
expect(meta()).toBe('von Anonym · 04.10.2026');
});
it('der Name erscheint als Text, nie als HTML', async () => {
usernamesFor.mockResolvedValue(new Map([['u1', '<b>x</b>']]));
await mount();
expect(meta()).toBe('von @<b>x</b> · 04.10.2026');
expect(q('.detail-meta b')).toBeNull();
});
it('eigenes Umbenennen: die Detailseite zeigt sofort den neuen Namen; ein anderes Konto ändert nichts', async () => {
usernamesFor.mockResolvedValue(new Map([['u1', 'Alt']]));
await mount();
expect(meta()).toBe('von @Alt · 04.10.2026');
emitName('u2', 'Fremd');
expect(meta()).toBe('von @Alt · 04.10.2026');
emitName('u1', 'Neu');
expect(meta()).toBe('von @Neu · 04.10.2026');
});
it('der Autor ohne Profil wählt einen Namen (eigene Karte): „von @name“ ersetzt den Ersatz sofort', async () => {
await mount();
expect(meta()).toBe('von Kenso · 04.10.2026');
emitName('u1', 'Kenso.K');
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
});
it('dieselbe Meldung noch einmal schreibt die Zeile nicht neu', async () => {
usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
await mount();
const line = q('.detail-meta')!;
const text = line.firstChild;
emitName('u1', 'Kenso');
expect(q('.detail-meta')).toBe(line);
expect(line.firstChild).toBe(text);
});
it('die Antwort für eine inzwischen verlassene Ansicht (die Karte ist nicht mehr zu sehen) ändert nichts', async () => {
user = owner;
getCard.mockResolvedValue(card({ hidden: true }));
const lookup = deferred<Map<string, string>>();
usernamesFor.mockReturnValue(lookup.promise);
await mount();
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
login(null); // abgemeldet: die ausgeblendete Karte ist für niemand mehr sichtbar
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
const html = root.innerHTML;
lookup.resolve(new Map([['u1', 'Kenso.K']]));
await settle();
expect(root.innerHTML).toBe(html);
});
it('Aufräumen: der Hörer des Umbenennens ist abgemeldet, eine später eintreffende Antwort ändert nichts', async () => {
const lookup = deferred<Map<string, string>>();
usernamesFor.mockReturnValue(lookup.promise);
await mount();
expect(nameListeners.size).toBe(1);
unmount?.();
unmount = null;
expect(nameListeners.size).toBe(0);
const html = root.innerHTML;
lookup.resolve(new Map([['u1', 'Kenso.K']]));
emitName('u1', 'Neu');
await settle();
expect(root.innerHTML).toBe(html);
});
it('eine Karte, die nicht (mehr) verfügbar ist, fragt keinen Namen', async () => {
getCard.mockResolvedValue(null);
await mount();
expect(usernamesFor).not.toHaveBeenCalled();
});
it('Englisch: „by @name“', async () => {
setLang('en', { persist: false });
usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso.K']]));
await mount();
expect(meta()).toBe('by @Kenso.K · Oct 4, 2026');
expect(germanLeft(root)).toEqual([]);
});
});
describe('Detailseite: Rechte beim Ausführen', () => {
it('Admin-Recht verloren, Knopf noch im DOM: Ausblenden sendet nichts', async () => {
user = admin;