diff --git a/web/src/account/display-name.test.ts b/web/src/account/display-name.test.ts deleted file mode 100644 index 41978bb..0000000 --- a/web/src/account/display-name.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DISPLAY_NAME_KEY, rememberDisplayName, rememberedDisplayName } from './display-name'; - -beforeEach(() => { - localStorage.clear(); -}); - -afterEach(() => { - vi.restoreAllMocks(); - localStorage.clear(); -}); - -describe('gemerkter Anzeigename (localStorage)', () => { - it('der Schlüssel heißt aircard.displayName', () => { - expect(DISPLAY_NAME_KEY).toBe('aircard.displayName'); - }); - - it('ohne gespeicherten Namen ist er leer', () => { - expect(rememberedDisplayName()).toBe(''); - }); - - it('liest den gespeicherten Namen', () => { - localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso'); - expect(rememberedDisplayName()).toBe('Kenso'); - }); - - it('speichert den Namen getrimmt unter dem Schlüssel und überschreibt den alten', () => { - rememberDisplayName(' Kenso '); - expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso'); - rememberDisplayName('Zoë'); - expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Zoë'); - expect(rememberedDisplayName()).toBe('Zoë'); - }); - - it.each([[''], [' ']])('ein leerer Name (%j) entfernt den Schlüssel („Anonym“)', (empty) => { - localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso'); - rememberDisplayName(empty); - expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull(); - expect(rememberedDisplayName()).toBe(''); - }); - - it('gesperrter Speicher beim Lesen: leer, kein Fehler', () => { - vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { - throw new DOMException('gesperrt', 'SecurityError'); - }); - expect(rememberedDisplayName()).toBe(''); - }); - - it('gesperrter Speicher beim Schreiben und beim Entfernen: kein Fehler', () => { - vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new DOMException('voll', 'QuotaExceededError'); - }); - vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { - throw new DOMException('gesperrt', 'SecurityError'); - }); - expect(() => rememberDisplayName('Kenso')).not.toThrow(); - expect(() => rememberDisplayName('')).not.toThrow(); - }); -}); diff --git a/web/src/account/display-name.ts b/web/src/account/display-name.ts deleted file mode 100644 index 610a5f2..0000000 --- a/web/src/account/display-name.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Der zuletzt benutzte Anzeigename (Spec § 4): Der Veröffentlichen-Dialog und der Dialog „Logo hochladen“ lesen und schreiben -// denselben Schlüssel in localStorage. Jeder Zugriff steht in try/catch: ist der Speicher gesperrt (z. B. privates Fenster), -// bleibt das Feld leer und der Name wird nicht gemerkt, ohne dass etwas scheitert. - -export const DISPLAY_NAME_KEY = 'aircard.displayName'; - -/** Der gemerkte Anzeigename; leer, wenn keiner gemerkt ist oder der Speicher nicht lesbar ist. */ -export function rememberedDisplayName(): string { - try { - return localStorage.getItem(DISPLAY_NAME_KEY) ?? ''; - } catch { - return ''; - } -} - -/** Merkt den Anzeigenamen (getrimmt); ein leerer Name („Anonym“) entfernt den gemerkten. */ -export function rememberDisplayName(name: string): void { - const trimmed = name.trim(); - try { - if (trimmed === '') localStorage.removeItem(DISPLAY_NAME_KEY); - else localStorage.setItem(DISPLAY_NAME_KEY, trimmed); - } catch { - // Gesperrt oder voll: der Name wird dann nur nicht gemerkt. - } -} diff --git a/web/src/account/profile.test.ts b/web/src/account/profile.test.ts index 28f60e8..33a2ca6 100644 --- a/web/src/account/profile.test.ts +++ b/web/src/account/profile.test.ts @@ -570,6 +570,57 @@ describe('loadMyUsername', () => { }); }); +describe('byline („von @name“)', () => { + const names = new Map([ + ['u1', 'Kenso'], + ['u2', 'Mia.K'], + ]); + + it('mit Profil: „von @name“ (der Name, wie er gespeichert ist), der alte Anzeigename zählt dann nicht', () => { + expect(profile.byline('u1', '', names)).toBe('von @Kenso'); + expect(profile.byline('u2', 'Alter Name', names)).toBe('von @Mia.K'); + }); + + it('ohne Profil: der früher gespeicherte Anzeigename, ohne @', () => { + expect(profile.byline('u9', 'Anna', names)).toBe('von Anna'); + expect(profile.byline('u9', 'Anna', new Map())).toBe('von Anna'); + }); + + it('ohne Profil und ohne Anzeigenamen: „von Anonym“', () => { + expect(profile.byline('u9', '', names)).toBe('von Anonym'); + expect(profile.byline('u9', '', new Map())).toBe('von Anonym'); + }); + + it('ein Eintrag ohne Konto-ID (Altbestand) fällt ebenso zurück, auch wenn die Map einen leeren Schlüssel hätte', () => { + expect(profile.byline('', 'Anna', names)).toBe('von Anna'); + expect(profile.byline('', '', names)).toBe('von Anonym'); + expect(profile.byline('', '', new Map([['', 'Spuk']]))).toBe('von Anonym'); + }); + + it('ein leerer Name in der Map zählt nicht als Profil', () => { + expect(profile.byline('u3', 'Anna', new Map([['u3', '']]))).toBe('von Anna'); + }); + + it('Werte mit geschweiften Klammern oder Markup bleiben unverändert (der eingesetzte Text wird nicht noch einmal ausgewertet)', () => { + expect(profile.byline('u9', '{name}', new Map())).toBe('von {name}'); + expect(profile.byline('u9', 'fett', new Map())).toBe('von fett'); + }); + + it('Englisch: „by @name“, „by Anna“, „by Anonymous“', async () => { + (await import('../i18n/i18n')).setLang('en', { persist: false }); + expect(profile.byline('u1', '', names)).toBe('by @Kenso'); + expect(profile.byline('u9', 'Anna', names)).toBe('by Anna'); + expect(profile.byline('u9', '', names)).toBe('by Anonymous'); + }); + + it('liest nur die übergebene Map: eine Änderung dort zeigt die nächste Zeile sofort (eigene Umbenennung)', () => { + const own = new Map([['u1', 'Alt']]); + expect(profile.byline('u1', '', own)).toBe('von @Alt'); + own.set('u1', 'Neu'); + expect(profile.byline('u1', '', own)).toBe('von @Neu'); + }); +}); + describe('Merker „in dieser Sitzung schon gefragt“', () => { it('beginnt leer; markieren schreibt aircard.usernamePrompted in sessionStorage', () => { expect(profile.usernamePrompted()).toBe(false); diff --git a/web/src/account/profile.ts b/web/src/account/profile.ts index 91b3b19..c940cd4 100644 --- a/web/src/account/profile.ts +++ b/web/src/account/profile.ts @@ -1,7 +1,8 @@ // Benutzernamen (Spec 2026-10-05-aircard-benutzernamen-design.md): die Regeln für den Namen, Profile lesen (Tabelle `profiles`: eine // öffentlich lesbare Zeile je Konto, Zeilen-ID = Konto-ID) und den eigenen Namen speichern (`POST /api/profil` des Dienstes // `aircard-api`). Der Browser prüft nur vor; was gilt, entscheidet der Dienst: Er prüft Muster und Sperrliste noch einmal, und der -// Unique-Index auf `usernameKey` hält einen Namen auch bei zwei gleichzeitigen Anfragen nur einmal. +// Unique-Index auf `usernameKey` hält einen Namen auch bei zwei gleichzeitigen Anfragen nur einmal. Dazu die Zeile „von @name“ für +// Logos und Karten (`byline`). import { Query, type Models } from 'appwrite'; import { ID_PATTERN } from '../card/config'; import { apiPost, unavailableError } from '../community/api'; @@ -75,6 +76,16 @@ export function validateUsername(name: string): string | null { return null; } +/** + * Die Zeile „von @name“ zu einem Eintrag (Logo-Kachel, Infozeile, Detailseite einer Karte): der Benutzername des Kontos `ownerId`, soweit + * `names` (aus `usernamesFor`) ihn kennt. Ohne Profil gilt der früher gespeicherte Anzeigename (ohne @), sonst „Anonym“. In der Sprache der Seite. + */ +export function byline(ownerId: string, legacyDisplayName: string, names: ReadonlyMap): string { + const name = ownerId === '' ? '' : (names.get(ownerId) ?? ''); + if (name !== '') return t('von @{name}', { name }); + return t('von {name}', { name: legacyDisplayName !== '' ? legacyDisplayName : t('Anonym') }); +} + // ---- Namen merken und melden ------------------------------------------------------------------------------ /** Konto-ID → Benutzername, nur gefundene (ein Konto ohne Profil kann sich später einen Namen wählen, also wird es wieder gefragt). */ diff --git a/web/src/account/username-dialog.test.ts b/web/src/account/username-dialog.test.ts index 1336e36..4aa3b12 100644 --- a/web/src/account/username-dialog.test.ts +++ b/web/src/account/username-dialog.test.ts @@ -690,6 +690,30 @@ describe('requireUsername', () => { expect(dialogEl()).toBeNull(); }); + it('das Signal geht an den Dialog: das Verlassen der Seite schließt ihn, das Ergebnis ist false', async () => { + mocks.userId = 'u1'; + mocks.loadMyUsername.mockResolvedValue(null); + const controller = new AbortController(); + const result = dialog.requireUsername(controller.signal); + await settle(); + expect(dialogEl()?.open).toBe(true); + controller.abort(); + await expect(result).resolves.toBe(false); + expect(dialogEl()).toBeNull(); + expect(mocks.saveUsername).not.toHaveBeenCalled(); + }); + + it('ein schon abgebrochenes Signal öffnet keinen Dialog (false); mit bekanntem Namen ist es trotzdem true', async () => { + mocks.userId = 'u1'; + mocks.loadMyUsername.mockResolvedValue(null); + const controller = new AbortController(); + controller.abort(); + await expect(dialog.requireUsername(controller.signal)).resolves.toBe(false); + expect(dialogEl()).toBeNull(); + mocks.name = 'Kenso'; + await expect(dialog.requireUsername(controller.signal)).resolves.toBe(true); + }); + it('läuft schon ein Namensdialog (etwa die einmalige Abfrage), teilt sich requireUsername ihn', async () => { mocks.userId = 'u1'; mocks.loadMyUsername.mockResolvedValue(null); diff --git a/web/src/account/username-dialog.ts b/web/src/account/username-dialog.ts index dd92a76..2b8166b 100644 --- a/web/src/account/username-dialog.ts +++ b/web/src/account/username-dialog.ts @@ -238,9 +238,9 @@ export function usernameDialog(opts: UsernameDialogOptions): Promise { +export async function requireUsername(signal?: AbortSignal): Promise { if (currentUserId() === null) return true; let name = myUsername(); if (name === null) { @@ -251,5 +251,5 @@ export async function requireUsername(): Promise { } } if (name !== null) return true; - return (await usernameDialog({ mode: 'required' })) !== null; + return (await usernameDialog({ mode: 'required', signal })) !== null; } diff --git a/web/src/community/api.test.ts b/web/src/community/api.test.ts index 1f981b0..09a91a3 100644 --- a/web/src/community/api.test.ts +++ b/web/src/community/api.test.ts @@ -373,6 +373,37 @@ describe('apiPost', () => { }); }); +describe('isUsernameRequired: verlangt der Dienst einen Benutzernamen?', () => { + it('ja bei 403 mit dem Schlüssel usernameRequired (Hochladen und Veröffentlichen ohne Profil)', async () => { + const api = await freshApi(); + const err = await (async () => { + fetchMock.mockImplementation(async () => json(403, { error: 'Bitte wähle zuerst einen Benutzernamen.', code: 'usernameRequired' })); + return api.apiPost('/api/veroeffentlichen', {}, { auth: true }).catch((e: unknown) => e); + })(); + expect(err).toMatchObject({ code: 403, serverCode: 'usernameRequired' }); + expect(api.isUsernameRequired(err)).toBe(true); + expect(api.isDefinitiveFailure(err)).toBe(true); // es wurde nichts angelegt: die Dateien dieses Versuchs dürfen aufgeräumt werden + }); + + it('der Schlüssel entscheidet, nicht der Status und nicht der Text', async () => { + const api = await freshApi(); + expect(api.isUsernameRequired(new api.ApiError(403, 'x', undefined, 'usernameRequired'))).toBe(true); + expect(api.isUsernameRequired(new api.ApiError(400, 'x', undefined, 'usernameRequired'))).toBe(true); + expect(api.isUsernameRequired(new api.ApiError(403, 'Bitte wähle zuerst einen Benutzernamen.'))).toBe(false); // ohne Schlüssel + }); + + it('andere Fehler nicht: andere 403, Netz, Dienst nicht erreichbar, kein ApiError', async () => { + const api = await freshApi(); + expect(api.isUsernameRequired(new api.ApiError(403, 'x', undefined, 'forbidden'))).toBe(false); + expect(api.isUsernameRequired(new api.ApiError(409, 'x', undefined, 'usernameTaken'))).toBe(false); + expect(api.isUsernameRequired(api.unavailableError())).toBe(false); + expect(api.isUsernameRequired(new TypeError('Failed to fetch'))).toBe(false); + expect(api.isUsernameRequired(Object.assign(new Error('x'), { code: 403, serverCode: 'usernameRequired' }))).toBe(false); + expect(api.isUsernameRequired(null)).toBe(false); + expect(api.isUsernameRequired(undefined)).toBe(false); + }); +}); + describe('isDefinitiveFailure: hat der Server die Anfrage eindeutig abgelehnt?', () => { it.each([400, 401, 403, 404, 409, 422])('ApiError %i: ja', async (code) => { const api = await freshApi(); diff --git a/web/src/community/api.ts b/web/src/community/api.ts index 8d9d23f..0247fce 100644 --- a/web/src/community/api.ts +++ b/web/src/community/api.ts @@ -218,6 +218,14 @@ export function isAlreadyPublished(err: unknown): boolean { return err instanceof ApiError && err.code === 409; } +/** + * Wahr, wenn der Dienst einen Benutzernamen verlangt (403 `usernameRequired`): das Konto hat noch keinen, und Hochladen und + * Veröffentlichen gehen erst mit Namen (Spec § 4). Der Aufrufer zeigt dann den Namensdialog; angelegt wurde nichts. + */ +export function isUsernameRequired(err: unknown): boolean { + return err instanceof ApiError && err.serverCode === 'usernameRequired'; +} + /** * Veröffentlicht eine Karte. Doppelt veröffentlicht (409) und „Dateien schon belegt oder gesperrt“ (403) kommen * beide als ApiError 409 „Diese Karte ist schon veröffentlicht.“ an. Das Limit des Dienstes (429 mit dem Limit-Text) ist diff --git a/web/src/community/publish-dialog.test.ts b/web/src/community/publish-dialog.test.ts index 5594e89..4cd812e 100644 --- a/web/src/community/publish-dialog.test.ts +++ b/web/src/community/publish-dialog.test.ts @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// publish.ts (normalizeTags) und api.ts hängen am Appwrite-Client; der Dialog bekommt `submit` von außen. +const mocks = vi.hoisted(() => ({ usernameDialog: vi.fn() })); + +// publish.ts (normalizeTags) und api.ts hängen am Appwrite-Client; der Dialog bekommt `submit` von außen. Der Namensdialog ist eine Attrappe. vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} })); +vi.mock('../account/username-dialog', () => ({ usernameDialog: mocks.usernameDialog })); -import { DISPLAY_NAME_KEY } from '../account/display-name'; import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login'; import { germanLeft } from '../i18n/english-check'; import { setLang } from '../i18n/i18n'; @@ -39,11 +41,10 @@ const settle = async () => { }; function fill(values: Partial = {}): void { - const v = { title: 'Meine Karte', issuer: '', tags: '', displayName: '', consent: true, ...values }; + const v = { title: 'Meine Karte', issuer: '', tags: '', consent: true, ...values }; input('title').value = v.title; input('issuer').value = v.issuer; input('tags').value = v.tags; - input('displayName').value = v.displayName; consent().checked = v.consent; } @@ -53,13 +54,13 @@ function open(opts: { cardName?: string; signal?: AbortSignal } = {}): Promise

{ submit.mockReset(); + mocks.usernameDialog.mockReset(); + mocks.usernameDialog.mockResolvedValue(null); document.body.innerHTML = ''; - localStorage.clear(); // der Anzeigename wird gemerkt und im nächsten Test wieder vorbelegt }); afterEach(() => { document.body.innerHTML = ''; - localStorage.clear(); vi.restoreAllMocks(); }); @@ -68,24 +69,23 @@ describe('Veröffentlichen-Dialog: Felder', () => { void open({ cardName: 'Urlaub' }); expect(dialogEl()?.open).toBe(true); expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte veröffentlichen'); - expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['title', 'issuer', 'tags', 'displayName', 'consent']); + expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['title', 'issuer', 'tags', 'consent']); expect([...form().querySelectorAll('.auth-label')].map((l) => l.textContent)).toEqual([ 'Titel (Pflicht)', 'Herausgeber / Bank (optional)', 'Tags (optional)', - 'Anzeigename (optional)', ]); - for (const name of ['title', 'issuer', 'tags', 'displayName']) expect(input(name).value).toBe(''); + for (const name of ['title', 'issuer', 'tags']) expect(input(name).value).toBe(''); expect(consent().type).toBe('checkbox'); expect(consent().checked).toBe(false); expect(buttonByText('Abbrechen')).toBeTruthy(); expect(buttonByText('Veröffentlichen')).toBeTruthy(); }); - it('der Anzeigename ist leer und nennt „Anonym“ als Platzhalter (nie eine E-Mail)', () => { + it('es gibt kein Feld „Anzeigename“ mehr (die Karte zeigt den Benutzernamen, Spec § 5), und nie eine E-Mail im Dialog', () => { void open(); - expect(input('displayName').value).toBe(''); - expect(input('displayName').placeholder).toBe('Anonym'); + expect(dialogEl()!.querySelector('input[name="displayName"]')).toBeNull(); + expect(dialogEl()!.textContent).not.toMatch(/Anzeigename|Anonym/); expect(dialogEl()!.textContent).not.toMatch(/@/); }); @@ -99,7 +99,7 @@ describe('Veröffentlichen-Dialog: Felder', () => { void open(); const labels = [...form().querySelectorAll('label')]; const inputs = [...form().querySelectorAll('input')]; - expect(labels).toHaveLength(5); + expect(labels).toHaveLength(4); expect(labels.map((l) => l.control)).toEqual(inputs); for (const control of inputs) expect(control.labels).toHaveLength(1); const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id); @@ -150,10 +150,6 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => { await invalid({ tags: `visa, ${'x'.repeat(21)}` }, 'Ein Tag darf höchstens 20 Zeichen lang sein.', 'tags'); }); - it('Anzeigename länger als 30 Zeichen', async () => { - await invalid({ displayName: 'x'.repeat(31) }, 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).', 'displayName'); - }); - it('Checkbox fehlt', async () => { await invalid({ consent: false }, 'Bitte bestätige die Erklärung zum Bild.', 'consent'); }); @@ -178,7 +174,7 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => { await invalid({ title: '\u200b\u200d' }, 'Der Titel enthält ungültige Zeichen.', 'title'); }); - it('unsichtbare Zeichen in Herausgeber, Tags und Anzeigename', async () => { + it('unsichtbare Zeichen im Herausgeber', async () => { await invalid({ issuer: 'Bank\u200f' }, 'Herausgeber / Bank enthält ungültige Zeichen.', 'issuer'); }); @@ -186,22 +182,18 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => { await invalid({ tags: 'visa, gol\u200bd' }, 'Tags enthalten ungültige Zeichen.', 'tags'); }); - it('unsichtbares Zeichen im Anzeigenamen', async () => { - await invalid({ displayName: 'Ken\u2060so' }, 'Der Anzeigename enthält ungültige Zeichen.', 'displayName'); - }); - it('normale Sonderzeichen (Umlaute, Emoji, Bindestrich, Zeilenabstand durch Leerzeichen) bleiben erlaubt', async () => { submit.mockResolvedValue({ id: 'g1' }); const outcome = open(); - fill({ title: 'Größe 😀 – Möwe', issuer: 'Café-Bank', displayName: 'Zoë', tags: 'grün, ✨' }); + fill({ title: 'Größe 😀 – Möwe', issuer: 'Café-Bank', tags: 'grün, ✨' }); send(); await expect(outcome).resolves.toBe('published'); }); - it('Grenzen sind inklusive: 60/40/30 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => { + it('Grenzen sind inklusive: 60/40 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => { submit.mockResolvedValue({ id: 'g1' }); const outcome = open(); - fill({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), displayName: 'z'.repeat(30), tags: 'A, a, , b, c, d, e,' }); + fill({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), tags: 'A, a, , b, c, d, e,' }); send(); await expect(outcome).resolves.toBe('published'); expect(submit).toHaveBeenCalledOnce(); @@ -229,7 +221,6 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => { it('ohne Fehler verweist kein Feld auf die Fehlerzeile; die Hinweise bleiben', () => { void open(); expect(describedBy('tags')).toEqual(['publish-tags-hint']); - expect(describedBy('displayName')).toEqual(['publish-displayName-hint']); expect(describedBy('title')).toEqual([]); expect(describedBy('consent')).toEqual([]); }); @@ -245,14 +236,10 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => { expect(describedBy('tags')).toEqual(['publish-tags-hint', 'publish-error']); expect(describedBy('title')).toEqual([]); // nur das Feld mit dem Problem - fill({ displayName: 'x'.repeat(31) }); - send(); - expect(describedBy('displayName')).toEqual(['publish-displayName-hint', 'publish-error']); - expect(describedBy('tags')).toEqual(['publish-tags-hint']); - fill({ consent: false }); send(); expect(describedBy('consent')).toEqual(['publish-error']); + expect(describedBy('tags')).toEqual(['publish-tags-hint']); // nur das Feld mit dem aktuellen Problem }); it('ist das Feld behoben, fällt die Verknüpfung beim nächsten Senden weg und der Hinweis bleibt', () => { @@ -269,7 +256,7 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => { }); it('validatePublishForm meldet das erste Problem in Formularreihenfolge, sonst null', () => { - const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true }; + const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', consent: true }; expect(validatePublishForm(ok)).toBeNull(); expect(validatePublishForm({ ...ok, title: '', consent: false })?.field).toBe('title'); expect(validatePublishForm({ ...ok, tags: 'a,b,c,d,e,f', consent: false })?.field).toBe('tags'); @@ -278,23 +265,23 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => { }); describe('Veröffentlichen-Dialog: Senden', () => { - it('sendet die getrimmten Eingaben (Tags roh, Normalisierung macht publishSavedCard) und schließt bei Erfolg', async () => { + it('sendet die getrimmten Eingaben (Tags roh, Normalisierung macht publishSavedCard) und schließt bei Erfolg; einen Anzeigenamen gibt es nicht mehr', async () => { submit.mockResolvedValue({ id: 'g1' }); const outcome = open(); - fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold', displayName: ' Kenso ' }); + fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold' }); send(); await expect(outcome).resolves.toBe('published'); - expect(submit).toHaveBeenCalledWith({ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' }); + expect(submit).toHaveBeenCalledExactlyOnceWith({ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold' }); expect(dialogEl()).toBeNull(); }); - it('leere optionale Felder werden leer gesendet (Anonym macht der Server)', async () => { + it('leere optionale Felder werden leer gesendet', async () => { submit.mockResolvedValue({ id: 'g1' }); const outcome = open(); fill({ title: 'Nur Titel' }); send(); await outcome; - expect(submit).toHaveBeenCalledWith({ title: 'Nur Titel', issuer: '', tags: '', displayName: '' }); + expect(submit).toHaveBeenCalledExactlyOnceWith({ title: 'Nur Titel', issuer: '', tags: '' }); }); it('während des Sendens: „Veröffentlichen“ und „Abbrechen“ gesperrt, Felder gesperrt, zweites Senden wird ignoriert', async () => { @@ -442,6 +429,119 @@ describe('Veröffentlichen-Dialog: Senden', () => { }); }); +describe('Veröffentlichen-Dialog: Benutzername nötig (403 usernameRequired)', () => { + const required = () => new ApiError(403, 'Bitte wähle zuerst einen Benutzernamen.', true, 'usernameRequired'); + const REASON = 'Veröffentlichen fehlgeschlagen – Bitte wähle zuerst einen Benutzernamen.'; + + it('der Namensdialog „required“ öffnet sich (mit dem Signal der Seite); der Dialog bleibt offen und nennt den Grund', async () => { + mocks.usernameDialog.mockReturnValue(new Promise(() => {})); // der Nutzer überlegt noch + submit.mockRejectedValueOnce(required()); + const controller = new AbortController(); + void open({ signal: controller.signal }); + fill(); + send(); + await settle(); + expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'required', signal: controller.signal }); + expect(dialogEl()?.open).toBe(true); + expect(errorText()).toBe(REASON); + expect(buttonByText('Veröffentlichen')!.disabled).toBe(false); + }); + + it('nach dem gespeicherten Namen ist der Grund weg, die Eingaben bleiben, der Fokus liegt auf „Veröffentlichen“ und ein neuer Versuch geht durch', async () => { + mocks.usernameDialog.mockResolvedValue('Kenso'); + submit.mockRejectedValueOnce(required()); + const outcome = open(); + fill({ title: 'Mein Motiv', issuer: 'Bank', tags: 'visa' }); + send(); + await settle(); + expect(errorText()).toBe(''); + expect(input('title').value).toBe('Mein Motiv'); + expect(input('issuer').value).toBe('Bank'); + expect(consent().checked).toBe(true); + expect(dialogEl()?.open).toBe(true); + expect(document.activeElement).toBe(buttonByText('Veröffentlichen')); + + submit.mockResolvedValueOnce({ id: 'g1' }); + send(); + await expect(outcome).resolves.toBe('published'); + expect(submit).toHaveBeenCalledTimes(2); + expect(mocks.usernameDialog).toHaveBeenCalledTimes(1); + }); + + it('„Abbrechen“ im Namensdialog: der Grund bleibt stehen, nichts ist veröffentlicht, der Dialog bleibt zum Abbrechen oder Wiederholen', async () => { + mocks.usernameDialog.mockResolvedValue(null); + submit.mockRejectedValueOnce(required()); + void open(); + fill(); + send(); + await settle(); + expect(errorText()).toBe(REASON); + expect(dialogEl()?.open).toBe(true); + expect(submit).toHaveBeenCalledOnce(); + // ein neuer Versuch fragt der Dienst wieder, und der Namensdialog erscheint wieder + submit.mockRejectedValueOnce(required()); + send(); + await settle(); + expect(mocks.usernameDialog).toHaveBeenCalledTimes(2); + }); + + it('lässt sich der Namensdialog nicht öffnen, bleibt der Grund stehen und nichts wirft', async () => { + mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt')); + submit.mockRejectedValueOnce(required()); + void open(); + fill(); + send(); + await settle(); + expect(errorText()).toBe(REASON); + expect(dialogEl()?.open).toBe(true); + expect(buttonByText('Veröffentlichen')!.disabled).toBe(false); + }); + + it('wird der Dialog währenddessen geschlossen (Seite verlassen), bleibt die Antwort des Namensdialogs folgenlos', async () => { + let answer!: (name: string | null) => void; + mocks.usernameDialog.mockReturnValue(new Promise((resolve) => (answer = resolve))); + submit.mockRejectedValueOnce(required()); + const controller = new AbortController(); + const outcome = open({ signal: controller.signal }); + fill(); + send(); + await settle(); + controller.abort(); + await expect(outcome).resolves.toBe('cancelled'); + answer('Kenso'); + await settle(); + expect(dialogEl()).toBeNull(); + expect(submit).toHaveBeenCalledOnce(); + }); + + it('andere Fehler (auch andere 403) öffnen den Namensdialog nicht', async () => { + submit.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')); + void open(); + fill(); + send(); + await settle(); + submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable')); + send(); + await settle(); + submit.mockRejectedValueOnce(new Error('boom')); + send(); + await settle(); + expect(mocks.usernameDialog).not.toHaveBeenCalled(); + }); + + it('Englisch: der Grund erscheint auf Englisch', async () => { + setLang('en', { persist: false }); + mocks.usernameDialog.mockResolvedValue(null); + submit.mockRejectedValueOnce(required()); + void open(); + fill(); + send(); + await settle(); + expect(errorText()).toBe('Publishing failed – Please choose a username first.'); + expect(germanLeft(dialogEl()!)).toEqual([]); + }); +}); + describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => { it('der Text zum fehlenden Logo kommt im Wortlaut an (nicht als „Etwas ist schiefgelaufen“)', async () => { submit.mockRejectedValueOnce(new Error(LOGO_MISSING_PUBLISH)); @@ -461,81 +561,6 @@ describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => { }); }); -describe('Veröffentlichen-Dialog: gemerkter Anzeigename', () => { - it('der Anzeigename ist mit dem zuletzt benutzten Namen (aircard.displayName) vorbelegt, die anderen Felder bleiben leer', () => { - expect(DISPLAY_NAME_KEY).toBe('aircard.displayName'); - localStorage.setItem('aircard.displayName', 'Kenso'); - void open(); - expect(input('displayName').value).toBe('Kenso'); - for (const name of ['title', 'issuer', 'tags']) expect(input(name).value).toBe(''); - expect(input('displayName').placeholder).toBe('Anonym'); - }); - - it('gesperrter Speicher: das Feld bleibt leer, der Dialog öffnet sich trotzdem', () => { - vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { - throw new DOMException('gesperrt', 'SecurityError'); - }); - void open(); - expect(dialogEl()?.open).toBe(true); - expect(input('displayName').value).toBe(''); - }); - - it('nach dem Veröffentlichen wird der (getrimmte) Anzeigename gemerkt', async () => { - submit.mockResolvedValue({ id: 'g1' }); - const outcome = open(); - fill({ displayName: ' Kenso ' }); - send(); - await expect(outcome).resolves.toBe('published'); - expect(localStorage.getItem('aircard.displayName')).toBe('Kenso'); - }); - - it('ein geleerter Anzeigename entfernt den gemerkten (Anonym)', async () => { - localStorage.setItem('aircard.displayName', 'Kenso'); - submit.mockResolvedValue({ id: 'g1' }); - const outcome = open(); - fill({ displayName: '' }); - send(); - await outcome; - expect(localStorage.getItem('aircard.displayName')).toBeNull(); - }); - - it('bei einem Fehler wird nichts gemerkt, auch nicht bei „schon veröffentlicht“', async () => { - localStorage.setItem('aircard.displayName', 'Alt'); - submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); - const outcome = open(); - fill({ displayName: 'Neu' }); - send(); - await settle(); - expect(localStorage.getItem('aircard.displayName')).toBe('Alt'); - submit.mockRejectedValueOnce(new ApiError(409, 'Diese Karte ist schon veröffentlicht.')); - send(); - await settle(); - expect(localStorage.getItem('aircard.displayName')).toBe('Alt'); - buttonByText('Schließen')!.click(); - await expect(outcome).resolves.toBe('already'); - expect(localStorage.getItem('aircard.displayName')).toBe('Alt'); - }); - - it('beim Abbrechen und beim Schließen durch das Signal wird nichts gemerkt', async () => { - const controller = new AbortController(); - let finish!: (r: { id: string }) => void; - submit.mockReturnValue(new Promise((r) => (finish = r))); - const outcome = open({ signal: controller.signal }); - fill({ displayName: 'Kenso' }); - send(); - controller.abort(); - await outcome; - finish({ id: 'g1' }); - await settle(); - expect(localStorage.getItem('aircard.displayName')).toBeNull(); - - void open(); - fill({ displayName: 'Kenso' }); - buttonByText('Abbrechen')!.click(); - expect(localStorage.getItem('aircard.displayName')).toBeNull(); - }); -}); - describe('Veröffentlichen-Dialog: Schließen', () => { it('„Abbrechen“ schließt ohne zu senden', async () => { const outcome = open(); @@ -624,11 +649,8 @@ describe('Veröffentlichen-Dialog auf Englisch', () => { 'Title (required)', 'Issuer / bank (optional)', 'Tags (optional)', - 'Display name (optional)', ]); expect(dialog.querySelector('#publish-tags-hint')!.textContent).toBe('Up to 5, separated by commas, e.g. “visa, gold”.'); - expect(dialog.querySelector('#publish-displayName-hint')!.textContent).toBe('Leave empty for “Anonymous”. Your email address is never shown.'); - expect(input('displayName').placeholder).toBe('Anonymous'); expect(consent().closest('label')!.textContent).toBe('The image is mine or I’m allowed to use it, and it contains no real card data.'); expect(buttonByText('Cancel')).toBeTruthy(); expect(buttonByText('Publish')).toBeTruthy(); @@ -642,7 +664,7 @@ describe('Veröffentlichen-Dialog auf Englisch', () => { }); it('die Prüfung vor dem Senden meldet in der Sprache der Seite', () => { - const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true }; + const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', consent: true }; const problem = (values: Partial) => validatePublishForm({ ...ok, ...values })?.message; expect(problem({ title: ' ' })).toBe('Please enter a title.'); expect(problem({ title: 'a\tb' })).toBe('The title contains invalid characters.'); @@ -652,8 +674,6 @@ describe('Veröffentlichen-Dialog auf Englisch', () => { expect(problem({ tags: 'visa, gol\u200bd' })).toBe('Tags contain invalid characters.'); expect(problem({ tags: `visa, ${'x'.repeat(21)}` })).toBe('A tag can be at most 20 characters long.'); expect(problem({ tags: 'a, b, c, d, e, f' })).toBe('Please enter at most 5 tags.'); - expect(problem({ displayName: 'Ken\u2060so' })).toBe('The display name contains invalid characters.'); - expect(problem({ displayName: 'x'.repeat(31) })).toBe('The display name is too long (at most 30 characters).'); expect(problem({ consent: false })).toBe('Please confirm the statement about the image.'); }); diff --git a/web/src/community/publish-dialog.ts b/web/src/community/publish-dialog.ts index 10a2324..d230afa 100644 --- a/web/src/community/publish-dialog.ts +++ b/web/src/community/publish-dialog.ts @@ -1,14 +1,16 @@ -// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags, Anzeigename und die Pflicht-Checkbox. +// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags und die Pflicht-Checkbox. Einen Anzeigenamen gibt +// es nicht mehr: die Detailseite zeigt den Benutzernamen des Kontos (Spec § 5). Hat das Konto keinen, antwortet der Dienst mit 403 +// `usernameRequired`: dann öffnet sich der Namensdialog, und mit dem gespeicherten Namen lässt es sich noch einmal versuchen. // Eigenes

wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. Übersetzt wird beim Aufbau mit t(). import '../account/account.css'; import './community.css'; -import { rememberDisplayName, rememberedDisplayName } from '../account/display-name'; import { errorMessage } from '../account/errors'; +import { usernameDialog } from '../account/username-dialog'; import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login'; import { el, setFieldInvalid } from '../editor/dom'; import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload'; import { msg, t } from '../i18n/i18n'; -import { ApiError, apiErrorText, isAlreadyPublished } from './api'; +import { ApiError, apiErrorText, isAlreadyPublished, isUsernameRequired } from './api'; import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish'; /** Wortlaut aus Spec § 6. */ @@ -20,8 +22,6 @@ const RUNNING = msg('Wird veröffentlicht …'); /** Grenzen wie der Server (deploy/api/app.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */ const TITLE_MAX = 60; const ISSUER_MAX = 40; -/** Auch für den Anzeigenamen im Dialog „Logo hochladen“ (logos/logo-dialog.ts). */ -export const DISPLAY_NAME_MAX = 30; const TAGS_MAX = 5; const TAG_MAX = 20; /** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */ @@ -29,7 +29,7 @@ const ERROR_ID = 'publish-error'; /** * Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF): * unsichtbar oder umordnend. Dasselbe Muster prüft der Server (deploy/api/app.js, CONTROL_CHARACTERS). - * Auch für Name und Anzeigename im Dialog „Logo hochladen“ (logos/logo-dialog.ts). + * Auch für den Namen im Dialog „Logo hochladen“ (logos/logo-dialog.ts). */ export const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/; @@ -37,7 +37,7 @@ export interface PublishFormValues extends PublishForm { consent: boolean; } -export type PublishField = 'title' | 'issuer' | 'tags' | 'displayName' | 'consent'; +export type PublishField = 'title' | 'issuer' | 'tags' | 'consent'; /** Das erste Problem in Formularreihenfolge (mit Text in der Sprache der Seite) oder null, wenn alles passt. Die Grenzen prüft der Server noch einmal. */ export function validatePublishForm(values: PublishFormValues): { field: PublishField; message: string } | null { @@ -61,17 +61,6 @@ export function validatePublishForm(values: PublishFormValues): { field: Publish } if (tags.length > TAGS_MAX) return { field: 'tags', message: t('Bitte gib höchstens {max} Tags an.', { max: TAGS_MAX }) }; - const displayName = values.displayName.trim(); - if (CONTROL_CHARACTERS.test(displayName)) { - return { field: 'displayName', message: t('Der Anzeigename enthält ungültige Zeichen.') }; - } - if (displayName.length > DISPLAY_NAME_MAX) { - return { - field: 'displayName', - message: t('Der Anzeigename ist zu lang (höchstens {max} Zeichen).', { max: DISPLAY_NAME_MAX }), - }; - } - if (!values.consent) return { field: 'consent', message: t('Bitte bestätige die Erklärung zum Bild.') }; return null; } @@ -126,14 +115,11 @@ export function publishDialog(opts: PublishDialogOptions): Promise = { title: titleInput, issuer: issuerInput, tags: tagsInput, - displayName: nameInput, consent: consentInput, }; @@ -159,7 +145,6 @@ export function publishDialog(opts: PublishDialogOptions): Promise { + let saved: string | null = null; + try { + saved = await usernameDialog({ mode: 'required', signal: opts.signal }); + } catch { + // der Namensdialog lässt sich nicht öffnen: der Grund oben steht, der Nutzer kann den Namen im Konto-Menü wählen + } + if (closed || saved === null) return; + errorEl.textContent = ''; + send.focus(); + } + async function submit(): Promise { const values: PublishFormValues = { title: titleInput.value.trim(), issuer: issuerInput.value.trim(), tags: tagsInput.value, - displayName: nameInput.value.trim(), consent: consentInput.checked, }; clearInvalid(); @@ -241,7 +241,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise new Blob(['png'], { type: 'image/png' }); const webp = () => new Blob(['webp'], { type: 'image/webp' }); @@ -114,20 +114,26 @@ describe('publishSavedCard', () => { expect(preview.type).toBe('image/png'); }); - it('schickt der API beide neuen Datei-IDs, die Karten-ID und die bereinigten Felder (Tags als Liste)', async () => { + it('schickt der API beide neuen Datei-IDs, die Karten-ID und die bereinigten Felder (Tags als Liste); der Anzeigename ist immer leer (die Seite zeigt den Benutzernamen)', async () => { const fake = fakeDeps(); - await publishSavedCard(card, { title: ' Meine Karte ', issuer: ' Bank ', tags: 'Visa, Gold , visa,,', displayName: ' K ' }, fake.deps); + await publishSavedCard(card, { title: ' Meine Karte ', issuer: ' Bank ', tags: 'Visa, Gold , visa,,' }, fake.deps); expect(fake.of('publishCard')[0].args[0]).toEqual({ title: 'Meine Karte', issuer: 'Bank', tags: ['visa', 'gold'], - displayName: 'K', + displayName: '', imageFileId: 'f1', previewFileId: 'f2', sourceCardId: 'card1', }); }); + it('ein von einem alten Aufrufer mitgegebener Anzeigename geht nicht mehr mit', async () => { + const fake = fakeDeps(); + await publishSavedCard(card, { ...form, displayName: 'Kenso' } as typeof form, fake.deps); + expect(fake.of('publishCard')[0].args[0]).toMatchObject({ displayName: '' }); + }); + describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => { it.each([400, 401, 403, 404, 409])( 'eindeutig abgelehnt (%i): beide Dateien dieses Versuchs werden gelöscht, der Fehler kommt an', diff --git a/web/src/community/publish.ts b/web/src/community/publish.ts index e6e0efc..68df2b4 100644 --- a/web/src/community/publish.ts +++ b/web/src/community/publish.ts @@ -29,9 +29,14 @@ export interface PublishForm { issuer: string; /** Rohe Eingabe, Komma-getrennt; wird mit `normalizeTags` zur Liste. */ tags: string; - displayName: string; } +/** + * Der Anzeigename, den der Client mitschickt: keiner. Seit den Benutzernamen (Spec § 5) zeigt die Detailseite den Namen des Kontos; + * der Dienst nimmt das Feld weiter an (der Altbestand trägt noch Anzeigenamen). + */ +const NO_DISPLAY_NAME = ''; + /** Alles, was `publishSavedCard` von außen braucht (im Test ersetzbar, ohne Netz und Canvas). */ export interface PublishDeps { /** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */ @@ -105,8 +110,8 @@ async function deleteQuietly(deps: PublishDeps, fileIds: string[]): Promise> = { 'Einfarbig – passt sich Hell/Dunkel an': 'Single color – adapts to light/dark mode', 'Nur wählbar, wenn das Logo transparente Bereiche hat.': 'Only available if the logo has transparent areas.', 'Name (Pflicht)': 'Name (required)', - 'Anzeigename (optional)': 'Display name (optional)', - 'Anonym': 'Anonymous', - 'Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.': 'Leave empty for “Anonymous”. Your email address is never shown.', 'Ich habe die Rechte an diesem Logo oder die Erlaubnis des Inhabers, und es darf öffentlich geteilt werden.': 'I own the rights to this logo or have permission from the owner, and it may be shared publicly.', 'Abbrechen': 'Cancel', @@ -70,11 +67,9 @@ export const EN: Readonly> = { 'Bitte wähle eine Datei.': 'Please choose a file.', 'Der Name enthält ungültige Zeichen.': 'The name contains invalid characters.', 'Der Name ist zu lang (höchstens {max} Zeichen).': 'The name is too long (at most {max} characters).', - 'Der Anzeigename enthält ungültige Zeichen.': 'The display name contains invalid characters.', - 'Der Anzeigename ist zu lang (höchstens {max} Zeichen).': 'The display name is too long (at most {max} characters).', 'Bitte bestätige, dass du das Logo teilen darfst.': 'Please confirm that you’re allowed to share the logo.', - // Bereich „Logo unten rechts“ im Editor (logos/logo-picker.ts). „Community“, „Hochladen“ und „Anonym“ stehen weiter oben. + // Bereich „Logo unten rechts“ im Editor (logos/logo-picker.ts). „Community“ und „Hochladen“ stehen weiter oben, die Zeile „von @name“ bei den Benutzernamen. 'Logo unten rechts': 'Bottom-right logo', 'Logos werden von der Community hochgeladen.': 'Logos are uploaded by the community.', 'Sortierung': 'Sort order', @@ -83,7 +78,6 @@ export const EN: Readonly> = { 'Gemeldet': 'Reported', 'Ausgeblendet': 'Hidden', 'Kein Logo': 'No logo', - 'von {name}': 'by {name}', 'Herz für „{name}“: {count}': 'Heart for “{name}”: {count}', 'Noch keine Community-Logos – lade das erste hoch.': 'No community logos yet – upload the first one.', 'Keine Logos in dieser Ansicht.': 'No logos in this view.', @@ -91,7 +85,7 @@ export const EN: Readonly> = { 'Nochmal versuchen': 'Try again', 'Mehr laden': 'Load more', - // Infozeile zum gewählten Logo (logos/logo-info.ts). „von {name}“, „Anonym“, „Ausgeblendet“ und „Melden“ stehen an anderer Stelle, + // Infozeile zum gewählten Logo (logos/logo-info.ts). „von @name“, „Ausgeblendet“ und „Melden“ stehen an anderer Stelle, // der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ weiter oben (Logo auf der Karte). 'Löschen': 'Delete', 'Ausblenden': 'Hide', @@ -156,6 +150,12 @@ export const EN: Readonly> = { 'Später': 'Later', 'Wird gespeichert …': 'Saving …', + // Zeile „von @name“ bei Logo-Kacheln, Logo-Infozeile und Community-Karten (account/profile.ts, benutzt von logos/logo-picker.ts, + // logos/logo-info.ts, pages/community-page.ts): mit Profil „von @name“, sonst der alte Anzeigename („von {name}“), sonst „Anonym“. + 'von @{name}': 'by @{name}', + 'von {name}': 'by {name}', + 'Anonym': 'Anonymous', + // Fehlermeldungen zu Konto und Karten (account/errors.ts, account/auth.ts) 'Der Link ist ungültig oder abgelaufen.': 'The link is invalid or has expired.', 'E-Mail oder Passwort ist falsch.': 'Email or password is incorrect.', @@ -243,8 +243,8 @@ export const EN: Readonly> = { '1 Meldung': '1 report', '{count} Meldungen': '{count} reports', - // Veröffentlichen-Dialog (community/publish-dialog.ts, community/publish.ts). „Abbrechen“, „Schließen“, „Anonym“, „Anzeigename (optional)“, - // die Erklärung dazu und „Diese Karte ist schon veröffentlicht.“ stehen an anderer Stelle. + // Veröffentlichen-Dialog (community/publish-dialog.ts, community/publish.ts). „Abbrechen“, „Schließen“ und + // „Diese Karte ist schon veröffentlicht.“ stehen an anderer Stelle. 'Karte veröffentlichen': 'Publish card', '„{name}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.': '“{name}” will be shown publicly in the community gallery, including its image.', @@ -298,7 +298,7 @@ export const EN: Readonly> = { 'Nichts gefunden für „{query}“': 'Nothing found for “{query}”', // Community-Detailseite (pages/community-page.ts). „Ausgeblendet“, „Melden“, „Löschen“, „Ausblenden“, „Einblenden“, „Herunterladen“, - // „Nochmal versuchen“, „von {name}“, „Anonym“, „Karte wird geladen …“, „Diese Karte ist nicht (mehr) verfügbar.“ und + // „Nochmal versuchen“, „von @name“, „Karte wird geladen …“, „Diese Karte ist nicht (mehr) verfügbar.“ und // „Die Gründe konnten nicht geladen werden.“ stehen an anderer Stelle. 'Die Karte konnte gerade nicht geladen werden.': 'The card couldn’t be loaded right now.', 'Bild wird geladen …': 'Loading image …', diff --git a/web/src/logos/logo-dialog.test.ts b/web/src/logos/logo-dialog.test.ts index f5fa718..f59b4bc 100644 --- a/web/src/logos/logo-dialog.test.ts +++ b/web/src/logos/logo-dialog.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ prepareLogo: vi.fn(), uploadPreparedLogo: vi.fn(), withLogin: vi.fn(), + usernameDialog: vi.fn(), })); // api.ts und with-login.ts hängen am Appwrite-Client; `prepare` und `upload` kommen meist von außen, nur die Standardwerte @@ -19,8 +20,9 @@ vi.mock('./prepare-logo', async (importOriginal) => ({ prepareLogo: mocks.prepareLogo, })); vi.mock('./upload-logo', () => ({ uploadPreparedLogo: mocks.uploadPreparedLogo })); +// Der Namensdialog ist eine Attrappe (sein eigenes Verhalten steht in account/username-dialog.test.ts). +vi.mock('../account/username-dialog', () => ({ usernameDialog: mocks.usernameDialog })); -import { DISPLAY_NAME_KEY } from '../account/display-name'; import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login'; import { ApiError } from '../community/api'; import { setLang, t } from '../i18n/i18n'; @@ -90,9 +92,8 @@ function typeInto(name: string, value: string): void { input(name).dispatchEvent(new Event('input', { bubbles: true })); } -function fill(values: { name?: string; displayName?: string; consent?: boolean } = {}): void { +function fill(values: { name?: string; consent?: boolean } = {}): void { input('name').value = values.name ?? 'Beispielbank'; - input('displayName').value = values.displayName ?? ''; input('consent').checked = values.consent ?? true; } @@ -109,10 +110,10 @@ async function setup(opts: LogoDialogOptions = {}, file: File = imageFile()): Pr } beforeEach(() => { - localStorage.clear(); document.body.innerHTML = ''; - for (const fn of [prepare, upload, mocks.prepareLogo, mocks.uploadPreparedLogo, mocks.withLogin]) fn.mockReset(); + for (const fn of [prepare, upload, mocks.prepareLogo, mocks.uploadPreparedLogo, mocks.withLogin, mocks.usernameDialog]) fn.mockReset(); mocks.withLogin.mockImplementation((fn: () => Promise) => fn()); + mocks.usernameDialog.mockResolvedValue(null); prepare.mockResolvedValue(prepared()); created.length = 0; revoked.length = 0; @@ -128,15 +129,13 @@ beforeEach(() => { afterEach(() => { document.body.innerHTML = ''; - localStorage.clear(); }); describe('validateLogoForm', () => { - const ok = { hasFile: true, name: 'Beispielbank', displayName: '', consent: true }; + const ok = { hasFile: true, name: 'Beispielbank', consent: true }; it('gültige Werte: kein Problem', () => { expect(validateLogoForm(ok)).toBeNull(); - expect(validateLogoForm({ ...ok, displayName: 'Kenso' })).toBeNull(); }); it('ohne Datei', () => { @@ -169,19 +168,6 @@ describe('validateLogoForm', () => { expect(validateLogoForm({ ...ok, name })).toEqual({ field: 'name', message: 'Der Name enthält ungültige Zeichen.' }); }); - it('Anzeigename: höchstens 30 Zeichen, keine Steuerzeichen, leer ist erlaubt', () => { - expect(validateLogoForm({ ...ok, displayName: 'x'.repeat(31) })).toEqual({ - field: 'displayName', - message: 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).', - }); - expect(validateLogoForm({ ...ok, displayName: 'x'.repeat(30) })).toBeNull(); - expect(validateLogoForm({ ...ok, displayName: ' ' })).toBeNull(); - expect(validateLogoForm({ ...ok, displayName: 'Ken⁠so' })).toEqual({ - field: 'displayName', - message: 'Der Anzeigename enthält ungültige Zeichen.', - }); - }); - it('ohne Häkchen', () => { expect(validateLogoForm({ ...ok, consent: false })).toEqual({ field: 'consent', @@ -189,12 +175,11 @@ describe('validateLogoForm', () => { }); }); - it('meldet das erste Problem in Formularreihenfolge: Datei, Name, Anzeigename, Häkchen', () => { - const bad = { hasFile: false, name: '', displayName: 'x'.repeat(31), consent: false }; + it('meldet das erste Problem in Formularreihenfolge: Datei, Name, Häkchen', () => { + const bad = { hasFile: false, name: '', consent: false }; expect(validateLogoForm(bad)?.field).toBe('file'); expect(validateLogoForm({ ...bad, hasFile: true })?.field).toBe('name'); - expect(validateLogoForm({ ...bad, hasFile: true, name: 'Bank' })?.field).toBe('displayName'); - expect(validateLogoForm({ ...bad, hasFile: true, name: 'Bank', displayName: '' })?.field).toBe('consent'); + expect(validateLogoForm({ ...bad, hasFile: true, name: 'Bank' })?.field).toBe('consent'); }); it('Englisch: die Meldungen kommen in der Sprache der Seite', () => { @@ -202,9 +187,6 @@ describe('validateLogoForm', () => { expect(validateLogoForm({ ...ok, hasFile: false })?.message).toBe('Please choose a file.'); expect(validateLogoForm({ ...ok, name: '' })?.message).toBe('Please enter a name.'); expect(validateLogoForm({ ...ok, name: 'x'.repeat(41) })?.message).toBe('The name is too long (at most 40 characters).'); - expect(validateLogoForm({ ...ok, displayName: 'x'.repeat(31) })?.message).toBe( - 'The display name is too long (at most 30 characters).', - ); expect(validateLogoForm({ ...ok, consent: false })?.message).toBe('Please confirm that you’re allowed to share the logo.'); }); }); @@ -214,12 +196,11 @@ describe('Logo-Dialog: Aufbau', () => { void open(); expect(dialogEl()?.open).toBe(true); expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Logo hochladen'); - expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['file', 'mono', 'name', 'displayName', 'consent']); + expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['file', 'mono', 'name', 'consent']); expect([...form().querySelectorAll('label')].map((l) => l.textContent)).toEqual([ 'Datei (Pflicht)', 'Einfarbig – passt sich Hell/Dunkel an', 'Name (Pflicht)', - 'Anzeigename (optional)', LOGO_CONSENT_TEXT, ]); expect(buttonByText('Abbrechen')).toBeTruthy(); @@ -236,8 +217,6 @@ describe('Logo-Dialog: Aufbau', () => { it('beim Öffnen: Name leer, Häkchen ungesetzt, „Einfarbig“ gesperrt, keine Vorschau, Fokus im Datei-Feld', () => { void open(); expect(input('name').value).toBe(''); - expect(input('displayName').value).toBe(''); - expect(input('displayName').placeholder).toBe('Anonym'); expect(input('consent').checked).toBe(false); expect(input('mono').checked).toBe(false); expect(input('mono').disabled).toBe(true); @@ -246,10 +225,10 @@ describe('Logo-Dialog: Aufbau', () => { expect(dialogEl()!.textContent).not.toMatch(/@/); }); - it('der Anzeigename ist mit dem zuletzt benutzten Namen vorbelegt', () => { - localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso'); + it('es gibt kein Feld „Anzeigename“ mehr (die Logos zeigen den Benutzernamen, Spec § 5)', () => { void open(); - expect(input('displayName').value).toBe('Kenso'); + expect(dialogEl()!.querySelector('input[name="displayName"]')).toBeNull(); + expect(dialogEl()!.textContent).not.toMatch(/Anzeigename|Anonym/); }); it('das Pflicht-Häkchen hat genau den Wortlaut der Spec', () => { @@ -273,7 +252,7 @@ describe('Logo-Dialog: Aufbau', () => { expect(inputs.map((i) => i.id)).not.toContain(heading.id); }); - it('Englisch: Überschrift, Beschriftungen, Platzhalter und Knöpfe', () => { + it('Englisch: Überschrift, Beschriftungen und Knöpfe', () => { setLang('en'); void open(); expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Upload logo'); @@ -281,10 +260,8 @@ describe('Logo-Dialog: Aufbau', () => { 'File (required)', 'Single color – adapts to light/dark mode', 'Name (required)', - 'Display name (optional)', 'I own the rights to this logo or have permission from the owner, and it may be shared publicly.', ]); - expect(input('displayName').placeholder).toBe('Anonymous'); expect(buttonByText('Cancel')).toBeTruthy(); expect(buttonByText('Upload')).toBeTruthy(); }); @@ -332,7 +309,7 @@ describe('Logo-Dialog: Datei wählen', () => { ])('der Name wird aus dem Dateinamen ohne Endung vorbelegt: %s', async (_label, fileName, expected) => { await setup({}, imageFile(fileName)); expect(input('name').value).toBe(expected); - expect(validateLogoForm({ hasFile: true, name: input('name').value, displayName: '', consent: true })).toBeNull(); + expect(validateLogoForm({ hasFile: true, name: input('name').value, consent: true })).toBeNull(); }); it('eine zweite Datei ersetzt den vorbelegten Namen, aber keinen selbst getippten', async () => { @@ -562,10 +539,6 @@ describe('Logo-Dialog: Prüfung vor dem Senden', () => { await invalid({ name: 'a\tb' }, 'Der Name enthält ungültige Zeichen.', 'name'); }); - it('Anzeigename länger als 30 Zeichen', async () => { - await invalid({ displayName: 'x'.repeat(31) }, 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).', 'displayName'); - }); - it('Pflicht-Häkchen fehlt', async () => { await invalid({ consent: false }, 'Bitte bestätige, dass du das Logo teilen darfst.', 'consent'); }); @@ -582,10 +555,10 @@ describe('Logo-Dialog: Prüfung vor dem Senden', () => { expect(errorText()).toBe(''); }); - it('Grenzen sind inklusive: 40 Zeichen Name und 30 Zeichen Anzeigename gehen durch', async () => { + it('die Grenze ist inklusive: 40 Zeichen Name gehen durch', async () => { upload.mockResolvedValue(logo()); const { outcome } = await setup(); - fill({ name: 'x'.repeat(40), displayName: 'y'.repeat(30) }); + fill({ name: 'x'.repeat(40) }); send(); await outcome; expect(upload).toHaveBeenCalledOnce(); @@ -594,7 +567,7 @@ describe('Logo-Dialog: Prüfung vor dem Senden', () => { it('Umlaute, Emoji und Bindestriche bleiben erlaubt', async () => { upload.mockResolvedValue(logo()); const { outcome } = await setup(); - fill({ name: 'Größe 😀 – Möwe-Bank', displayName: 'Zoë' }); + fill({ name: 'Größe 😀 – Möwe-Bank' }); send(); await outcome; expect(upload).toHaveBeenCalledOnce(); @@ -617,7 +590,6 @@ describe('Logo-Dialog: Barrierefreiheit', () => { it('ohne Fehler verweist kein Feld auf die Fehlerzeile; die Hinweise bleiben', () => { void open(); expect(describedBy('file')).toEqual(['logo-upload-file-hint']); - expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint']); expect(describedBy('name')).toEqual([]); expect(describedBy('consent')).toEqual([]); }); @@ -634,27 +606,23 @@ describe('Logo-Dialog: Barrierefreiheit', () => { send(); expect(describedBy('name')).toEqual(['logo-upload-error']); - fill({ displayName: 'x'.repeat(31) }); - send(); - expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint', 'logo-upload-error']); - expect(describedBy('name')).toEqual([]); - fill({ consent: false }); send(); expect(describedBy('consent')).toEqual(['logo-upload-error']); - expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint']); + expect(describedBy('name')).toEqual([]); // nur das Feld mit dem aktuellen Problem }); it('ist das Feld behoben, fällt die Verknüpfung beim nächsten Senden weg und der Hinweis bleibt', async () => { upload.mockResolvedValue(logo()); await setup(); - fill({ displayName: 'x'.repeat(31) }); + fill({ consent: false }); send(); - expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint', 'logo-upload-error']); - fill({ displayName: 'Kenso' }); + expect(describedBy('consent')).toEqual(['logo-upload-error']); + fill({ consent: true }); send(); - expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint']); - expect(input('displayName').hasAttribute('aria-invalid')).toBe(false); + expect(describedBy('consent')).toEqual([]); + expect(input('consent').hasAttribute('aria-invalid')).toBe(false); + expect(describedBy('file')).toEqual(['logo-upload-file-hint']); // der Hinweis bleibt }); it('eine neue Dateiwahl löscht den Fehlertext und damit auch die Markierung der Felder, die darauf verwiesen', async () => { @@ -671,18 +639,17 @@ describe('Logo-Dialog: Barrierefreiheit', () => { }); describe('Logo-Dialog: Senden', () => { - it('sendet getrimmte Eingaben genau einmal, merkt den Anzeigenamen und löst mit dem Logo auf', async () => { + it('sendet den getrimmten Namen genau einmal (ohne Anzeigenamen) und löst mit dem Logo auf', async () => { const p = prepared(); prepare.mockResolvedValue(p); const result = logo({ id: 'L9' }); upload.mockResolvedValue(result); const { outcome } = await setup(); - fill({ name: ' Meine Bank ', displayName: ' Kenso ' }); + fill({ name: ' Meine Bank ' }); send(); await expect(outcome).resolves.toBe(result); expect(upload).toHaveBeenCalledOnce(); - expect(upload).toHaveBeenCalledWith(p, { name: 'Meine Bank', displayName: 'Kenso', mono: false }); - expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso'); + expect(upload).toHaveBeenCalledWith(p, { name: 'Meine Bank', mono: false }); expect(dialogEl()).toBeNull(); }); @@ -693,7 +660,7 @@ describe('Logo-Dialog: Senden', () => { fill(); send(); await outcome; - expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', displayName: '', mono: true }); + expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', mono: true }); }); it('ohne transparente Bereiche geht mono nie als true mit', async () => { @@ -706,18 +673,7 @@ describe('Logo-Dialog: Senden', () => { fill(); send(); await outcome; - expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', displayName: '', mono: false }); - }); - - it('ein geleerter Anzeigename entfernt den gemerkten (Anonym)', async () => { - localStorage.setItem(DISPLAY_NAME_KEY, 'Alt'); - upload.mockResolvedValue(logo()); - const { outcome } = await setup(); - expect(input('displayName').value).toBe('Alt'); - fill({ displayName: '' }); - send(); - await outcome; - expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull(); + expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', mono: false }); }); it('ein Doppelklick auf „Hochladen“ sendet nicht zweimal', async () => { @@ -744,7 +700,7 @@ describe('Logo-Dialog: Senden', () => { send(); expect(buttonByText('Hochladen')!.disabled).toBe(true); expect(buttonByText('Abbrechen')!.disabled).toBe(true); - for (const name of ['file', 'mono', 'name', 'displayName', 'consent']) expect(input(name).disabled).toBe(true); + for (const name of ['file', 'mono', 'name', 'consent']) expect(input(name).disabled).toBe(true); expect(form().getAttribute('aria-busy')).toBe('true'); expect(statusText()).toBe('Wird hochgeladen …'); @@ -752,7 +708,7 @@ describe('Logo-Dialog: Senden', () => { await settle(); expect(buttonByText('Hochladen')!.disabled).toBe(false); expect(buttonByText('Abbrechen')!.disabled).toBe(false); - for (const name of ['file', 'mono', 'name', 'displayName', 'consent']) expect(input(name).disabled).toBe(false); + for (const name of ['file', 'mono', 'name', 'consent']) expect(input(name).disabled).toBe(false); expect(form().getAttribute('aria-busy')).toBe('false'); expect(statusText()).toBe(''); }); @@ -818,18 +774,16 @@ describe('Logo-Dialog: Senden', () => { ), ); const { outcome } = await setup(); - fill({ displayName: 'Kenso' }); + fill({ name: 'Meine Bank' }); send(); await settle(); expect(errorText()).toBe( 'Hochladen fehlgeschlagen – Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.', ); expect(dialogEl()?.open).toBe(true); - // Die Eingaben bleiben, und der Anzeigename wird erst bei Erfolg gemerkt. - expect(input('name').value).toBe('Beispielbank'); - expect(input('displayName').value).toBe('Kenso'); + // Die Eingaben bleiben. + expect(input('name').value).toBe('Meine Bank'); expect(input('consent').checked).toBe(true); - expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull(); upload.mockResolvedValueOnce(logo()); send(); @@ -837,7 +791,6 @@ describe('Logo-Dialog: Senden', () => { expect(upload).toHaveBeenCalledTimes(2); expect(upload.mock.calls[1][0]).toBe(upload.mock.calls[0][0]); // dasselbe fertige Logo, nicht neu verarbeitet expect(prepare).toHaveBeenCalledOnce(); - expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso'); }); it('Fehlertext des Dienstes in der Sprache der Seite (nach Fehlerschlüssel)', async () => { @@ -883,6 +836,118 @@ describe('Logo-Dialog: Senden', () => { }); }); +describe('Logo-Dialog: Benutzername nötig (403 usernameRequired)', () => { + const required = () => new ApiError(403, 'Bitte wähle zuerst einen Benutzernamen.', true, 'usernameRequired'); + const REASON = 'Hochladen fehlgeschlagen – Bitte wähle zuerst einen Benutzernamen.'; + + it('der Namensdialog „required“ öffnet sich (mit dem Signal der Seite); der Dialog bleibt offen und nennt den Grund', async () => { + mocks.usernameDialog.mockReturnValue(new Promise(() => {})); // der Nutzer überlegt noch + upload.mockRejectedValueOnce(required()); + const controller = new AbortController(); + await setup({ signal: controller.signal }); + fill(); + send(); + await settle(); + expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'required', signal: controller.signal }); + expect(dialogEl()?.open).toBe(true); + expect(errorText()).toBe(REASON); + expect(buttonByText('Hochladen')!.disabled).toBe(false); + }); + + it('nach dem gespeicherten Namen ist der Grund weg, die Eingaben und das Logo bleiben, der Fokus liegt auf „Hochladen“ und ein neuer Versuch geht durch', async () => { + mocks.usernameDialog.mockResolvedValue('Kenso'); + upload.mockRejectedValueOnce(required()); + const { outcome } = await setup(); + fill({ name: 'Meine Bank' }); + send(); + await settle(); + expect(errorText()).toBe(''); + expect(input('name').value).toBe('Meine Bank'); + expect(input('consent').checked).toBe(true); + expect(previews()).toHaveLength(2); // das verarbeitete Logo steht noch + expect(dialogEl()?.open).toBe(true); + expect(document.activeElement).toBe(buttonByText('Hochladen')); + + upload.mockResolvedValueOnce(logo()); + send(); + await expect(outcome).resolves.toMatchObject({ id: 'L1' }); + expect(upload).toHaveBeenCalledTimes(2); + expect(upload.mock.calls[1][0]).toBe(upload.mock.calls[0][0]); + expect(prepare).toHaveBeenCalledOnce(); + expect(mocks.usernameDialog).toHaveBeenCalledTimes(1); + }); + + it('„Abbrechen“ im Namensdialog: der Grund bleibt stehen, nichts ist hochgeladen, ein neuer Versuch fragt der Dienst wieder', async () => { + mocks.usernameDialog.mockResolvedValue(null); + upload.mockRejectedValue(required()); + await setup(); + fill(); + send(); + await settle(); + expect(errorText()).toBe(REASON); + expect(dialogEl()?.open).toBe(true); + send(); + await settle(); + expect(upload).toHaveBeenCalledTimes(2); + expect(mocks.usernameDialog).toHaveBeenCalledTimes(2); + }); + + it('lässt sich der Namensdialog nicht öffnen, bleibt der Grund stehen und nichts wirft', async () => { + mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt')); + upload.mockRejectedValueOnce(required()); + await setup(); + fill(); + send(); + await settle(); + expect(errorText()).toBe(REASON); + expect(dialogEl()?.open).toBe(true); + expect(buttonByText('Hochladen')!.disabled).toBe(false); + }); + + it('wird der Dialog währenddessen geschlossen (Seite verlassen), bleibt die Antwort des Namensdialogs folgenlos', async () => { + let answer!: (name: string | null) => void; + mocks.usernameDialog.mockReturnValue(new Promise((resolve) => (answer = resolve))); + upload.mockRejectedValueOnce(required()); + const controller = new AbortController(); + const { outcome } = await setup({ signal: controller.signal }); + fill(); + send(); + await settle(); + controller.abort(); + await expect(outcome).resolves.toBeNull(); + answer('Kenso'); + await settle(); + expect(dialogEl()).toBeNull(); + expect(upload).toHaveBeenCalledOnce(); + }); + + it('andere Fehler (auch andere 403) öffnen den Namensdialog nicht', async () => { + upload.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')); + await setup(); + fill(); + send(); + await settle(); + upload.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable')); + send(); + await settle(); + upload.mockRejectedValueOnce(new TypeError('Failed to fetch')); + send(); + await settle(); + expect(mocks.usernameDialog).not.toHaveBeenCalled(); + }); + + it('Englisch: der Grund erscheint auf Englisch', async () => { + setLang('en'); + mocks.usernameDialog.mockResolvedValue(null); + upload.mockRejectedValueOnce(required()); + await setup(); + fill(); + send(); + await settle(); + expect(errorText()).toBe('Upload failed – Please choose a username first.'); + }); +}); + describe('Logo-Dialog: Standardwerte', () => { it('ohne prepare und upload: prepareLogo, und uploadPreparedLogo über withLogin (abgelaufene Sitzung → Anmeldung)', async () => { const p = prepared(); @@ -894,11 +959,11 @@ describe('Logo-Dialog: Standardwerte', () => { choose(file); await settle(); expect(mocks.prepareLogo).toHaveBeenCalledExactlyOnceWith(file); - fill({ displayName: 'Kenso' }); + fill(); send(); await expect(outcome).resolves.toBe(result); expect(mocks.withLogin).toHaveBeenCalledOnce(); - expect(mocks.uploadPreparedLogo).toHaveBeenCalledExactlyOnceWith(p, { name: 'Beispielbank', displayName: 'Kenso', mono: false }); + expect(mocks.uploadPreparedLogo).toHaveBeenCalledExactlyOnceWith(p, { name: 'Beispielbank', mono: false }); }); }); @@ -911,7 +976,6 @@ describe('Logo-Dialog: Schließen', () => { expect(upload).not.toHaveBeenCalled(); expect(dialogEl()).toBeNull(); expect(revoked).toEqual(['blob:logo-1']); - expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull(); }); it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen; ohne Senden ist Esc nicht verhindert', async () => { @@ -941,7 +1005,7 @@ describe('Logo-Dialog: Schließen', () => { let finish!: (l: CommunityLogo) => void; upload.mockReturnValue(new Promise((resolve) => (finish = resolve))); const { outcome } = await setup({ signal: controller.signal }); - fill({ displayName: 'Kenso' }); + fill(); send(); controller.abort(); await expect(outcome).resolves.toBeNull(); @@ -950,7 +1014,6 @@ describe('Logo-Dialog: Schließen', () => { finish(logo()); await settle(); expect(dialogEl()).toBeNull(); - expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull(); }); it('ein Fehler nach dem Schließen durch das Signal wird verschluckt', async () => { diff --git a/web/src/logos/logo-dialog.ts b/web/src/logos/logo-dialog.ts index d96855e..7683b46 100644 --- a/web/src/logos/logo-dialog.ts +++ b/web/src/logos/logo-dialog.ts @@ -1,15 +1,17 @@ // Dialog „Logo hochladen“ (Spec § 4): Datei wählen, im Browser verarbeiten (prepare-logo.ts), auf dunklem und hellem Grund ansehen, -// Name, Anzeigename und Pflicht-Häkchen angeben, hochladen (upload-logo.ts). Eigenes wie publish-dialog.ts; alle Texte +// Name und Pflicht-Häkchen angeben, hochladen (upload-logo.ts). Einen Anzeigenamen gibt es nicht mehr: Kachel und Infozeile zeigen den +// Benutzernamen des Kontos (Spec § 5). Hat das Konto keinen, antwortet der Dienst mit 403 `usernameRequired`: dann öffnet sich der +// Namensdialog, und mit dem gespeicherten Namen lässt es sich noch einmal versuchen. Eigenes wie publish-dialog.ts; alle Texte // gehen als Text in den DOM, nie als HTML. import '../account/account.css'; import '../community/community.css'; import './logos.css'; -import { rememberDisplayName, rememberedDisplayName } from '../account/display-name'; import { errorMessage } from '../account/errors'; +import { usernameDialog } from '../account/username-dialog'; import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login'; import { TONE_COLOR } from '../card/config'; -import { ApiError, apiErrorText } from '../community/api'; -import { CONTROL_CHARACTERS, DISPLAY_NAME_MAX } from '../community/publish-dialog'; +import { ApiError, apiErrorText, isUsernameRequired } from '../community/api'; +import { CONTROL_CHARACTERS } from '../community/publish-dialog'; import { el, setFieldInvalid } from '../editor/dom'; import { msg, t } from '../i18n/i18n'; import type { CommunityLogo } from './logos'; @@ -32,7 +34,7 @@ export const LOGO_CONSENT_TEXT = msg( 'Ich habe die Rechte an diesem Logo oder die Erlaubnis des Inhabers, und es darf öffentlich geteilt werden.', ); -/** Grenze wie der Dienst (deploy/api/logos.js): Länge in UTF-16-Einheiten, nach dem Trimmen. Der Anzeigename: wie beim Veröffentlichen. */ +/** Grenze wie der Dienst (deploy/api/logos.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */ const NAME_MAX = 40; /** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */ const ERROR_ID = 'logo-upload-error'; @@ -51,11 +53,10 @@ export interface LogoFormValues { /** Gibt es ein fertig verarbeitetes Logo? */ hasFile: boolean; name: string; - displayName: string; consent: boolean; } -export type LogoField = 'file' | 'name' | 'displayName' | 'consent'; +export type LogoField = 'file' | 'name' | 'consent'; /** * Das erste Problem in Formularreihenfolge (mit Text in der Sprache der Seite) oder null, wenn alles passt. @@ -71,17 +72,6 @@ export function validateLogoForm(values: LogoFormValues): { field: LogoField; me return { field: 'name', message: t('Der Name ist zu lang (höchstens {max} Zeichen).', { max: NAME_MAX }) }; } - const displayName = values.displayName.trim(); - if (CONTROL_CHARACTERS.test(displayName)) { - return { field: 'displayName', message: t('Der Anzeigename enthält ungültige Zeichen.') }; - } - if (displayName.length > DISPLAY_NAME_MAX) { - return { - field: 'displayName', - message: t('Der Anzeigename ist zu lang (höchstens {max} Zeichen).', { max: DISPLAY_NAME_MAX }), - }; - } - if (!values.consent) return { field: 'consent', message: t('Bitte bestätige, dass du das Logo teilen darfst.') }; return null; } @@ -189,13 +179,10 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise = { file: fileInput, name: nameInput, - displayName: displayInput, consent: consentInput, }; @@ -222,11 +209,6 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise { + let saved: string | null = null; + try { + saved = await usernameDialog({ mode: 'required', signal: opts.signal }); + } catch { + // der Namensdialog lässt sich nicht öffnen: der Grund oben steht, der Nutzer kann den Namen im Konto-Menü wählen + } + if (closed || saved === null) return; + errorEl.textContent = ''; + send.focus(); + } + async function submit(): Promise { clearInvalid(); errorEl.textContent = ''; const problem = validateLogoForm({ hasFile: prepared !== null, name: nameInput.value, - displayName: displayInput.value, consent: consentInput.checked, }); if (problem) { @@ -366,7 +363,7 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise; let abort: AbortController; let info: LogoInfo; @@ -68,6 +70,7 @@ const host = { selectedId: () => selectedId, selected: () => row, gone: () => gone, + names: () => owners, patchRow: vi.fn((id: string, patch: Partial) => { if (row?.id === id) row = { ...row, ...patch }; }), @@ -95,6 +98,7 @@ beforeEach(() => { gone = false; user = 'u1'; admin = false; + owners = new Map(); abort = new AbortController(); fx.reportLogo.mockResolvedValue({ schonGemeldet: false }); fx.deleteLogo.mockResolvedValue(undefined); @@ -159,24 +163,54 @@ describe('Anzeige', () => { expect(message()).toBe(''); }); - it('gewähltes Logo: „ · von · ♥ “ und „Melden“ – auch für Gäste', async () => { + it('gewähltes Logo: „ · von @ · ♥ “ und „Melden“ – auch für Gäste', async () => { user = null; - await mount(); - expect(line()).toBe('Logo A · von Kenso · ♥ 12'); + owners.set('u9', 'Kenso.K'); + await mount(logo({ displayName: '' })); + expect(line()).toBe('Logo A · von @Kenso.K · ♥ 12'); expect(buttons()).toEqual(['Melden']); expect(button('Melden').type).toBe('button'); }); - it('ohne Anzeigename steht „von Anonym“', async () => { - await mount(logo({ displayName: '' })); + it('der Benutzername geht dem alten Anzeigenamen vor', async () => { + owners.set('u9', 'Kenso.K'); + await mount(logo({ displayName: 'Alter Name' })); + expect(line()).toBe('Logo A · von @Kenso.K · ♥ 12'); + }); + + it('ohne Profil steht der früher gespeicherte Anzeigename (ohne @), sonst „von Anonym“', async () => { + await mount(); + expect(line()).toBe('Logo A · von Kenso · ♥ 12'); + choose(logo({ displayName: '' })); expect(line()).toBe('Logo A · von Anonym · ♥ 12'); }); - it('Name und Anzeigename stehen nur als Text, nie als HTML', async () => { + it('ein Profil, das nicht zum Besitzer des gewählten Logos gehört, zählt nicht', async () => { + owners.set('u1', 'Fremd'); + await mount(logo({ ownerId: 'u9', displayName: '' })); + expect(line()).toBe('Logo A · von Anonym · ♥ 12'); + }); + + it('Name, Anzeigename und Benutzername stehen nur als Text, nie als HTML', async () => { await mount(logo({ name: '', displayName: 'fett' })); expect(line()).toBe(' · von fett · ♥ 12'); expect(info.el.querySelector('img')).toBeNull(); expect(info.el.querySelector('b')).toBeNull(); + owners.set('u9', 'x'); + info.update(); + expect(line()).toBe(' · von @x · ♥ 12'); + expect(info.el.querySelector('i')).toBeNull(); + }); + + it('update() zeigt einen inzwischen bekannten oder geänderten Benutzernamen sofort (eigenes Umbenennen)', async () => { + await mount(logo({ displayName: '' })); + expect(line()).toBe('Logo A · von Anonym · ♥ 12'); + owners.set('u9', 'Alt'); + info.update(); + expect(line()).toBe('Logo A · von @Alt · ♥ 12'); + owners.set('u9', 'Neu'); + info.update(); + expect(line()).toBe('Logo A · von @Neu · ♥ 12'); }); it('update() folgt der Zeile des Pickers: neue Zahl, anderes Logo, danach wieder keins', async () => { @@ -246,6 +280,9 @@ describe('Anzeige', () => { setLang('en', { persist: false }); await mount(logo({ displayName: '' })); expect(line()).toBe('Logo A · by Anonymous · ♥ 12'); + owners.set('u9', 'Kenso'); + info.update(); + expect(line()).toBe('Logo A · by @Kenso · ♥ 12'); expect(buttons()).toEqual(['Report']); choose(null); selectedId = 'x'; diff --git a/web/src/logos/logo-info.ts b/web/src/logos/logo-info.ts index 1b1f448..6349f0b 100644 --- a/web/src/logos/logo-info.ts +++ b/web/src/logos/logo-info.ts @@ -1,4 +1,4 @@ -// Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6): „ · von · ♥ “ und „Melden“. Wer das Logo +// Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6): „ · von @ · ♥ “ und „Melden“. Wer das Logo // hochgeladen hat, sieht dazu „Löschen“ (mit Rückfrage), Admins die Meldungen mit ihren Gründen sowie „Ausblenden“/„Einblenden“ // und „Löschen“. Zugleich die Meldezeile des Bereichs: Fehler von Herz, Hochladen und diesen Aktionen erscheinen hier, so bleibt // der Statusbereich des Pickers den Ladefehlern der Liste vorbehalten. @@ -9,6 +9,7 @@ import '../account/account.css'; import './logos.css'; import { errorMessage } from '../account/errors'; +import { byline } from '../account/profile'; import { ApiError, apiErrorText } from '../community/api'; import { REPORT_REASONS, type ReportDialogOptions, type ReportOutcome } from '../community/report-dialog'; import type { ConfirmOptions } from '../editor/confirm-dialog'; @@ -40,6 +41,8 @@ export interface LogoInfoHost { selected(): CommunityLogo | null; /** Das gewählte Logo gibt es nicht (mehr): die Abfrage danach hat es nicht gefunden. */ gone(): boolean; + /** Die Benutzernamen der Hochlader, die der Picker kennt (Konto-ID → Name); ohne Eintrag gilt der alte Anzeigename bzw. „Anonym“. */ + names(): ReadonlyMap; /** Ändert Felder der bekannten Zeile von `id` (Kachel und Infozeile folgen). */ patchRow(id: string, patch: Partial): void; /** Das Logo ist gelöscht: Kachel und Zeile entfernen, steht es in der Auswahl, wird sie aufgehoben. */ @@ -48,7 +51,7 @@ export interface LogoInfoHost { export interface LogoInfo { el: HTMLElement; - /** Frischt die Anzeige nach dem Stand des Pickers auf (Auswahl, Zeile, Zähler, Anmeldung). */ + /** Frischt die Anzeige nach dem Stand des Pickers auf (Auswahl, Zeile, Zähler, Anmeldung, Benutzernamen). */ update(): void; /** Zeigt einen Fehlertext in der Meldezeile; ein leerer Text löscht ihn. */ setMessage(text: string): void; @@ -164,7 +167,7 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A const admin = deps.isAdmin(); nameEl.textContent = logo.name; - byEl.textContent = t('von {name}', { name: logo.displayName || t('Anonym') }); + byEl.textContent = byline(logo.ownerId, logo.displayName, host.names()); heartsEl.textContent = `♥ ${logo.likeCount}`; hiddenEl.hidden = !logo.hidden; // Ein ausgeblendetes Logo kennt der Dienst für Besucher nicht: es lässt sich nicht melden. diff --git a/web/src/logos/logo-picker.test.ts b/web/src/logos/logo-picker.test.ts index 5d5fc14..c901b50 100644 --- a/web/src/logos/logo-picker.test.ts +++ b/web/src/logos/logo-picker.test.ts @@ -79,10 +79,18 @@ const fx = { listLogoReports: vi.fn(), confirm: vi.fn(), reportDialog: vi.fn(), + usernamesFor: vi.fn(), + requireUsername: vi.fn(), 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 “ bzw. „von Anonym“ und Herz-Knopf mit der Zahl', async () => { + it('erste Kachel „Kein Logo“, dann je Logo eine Kachel mit Name, „von “ 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>(); + 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', 'fett']])); + await mount(); + expect(by('Logo A')).toBe('von @fett'); + 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>(); + 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>(); + 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>(); + 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 ')); + 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(); + 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[] = []; diff --git a/web/src/logos/logo-picker.ts b/web/src/logos/logo-picker.ts index a953ab1..c91700f 100644 --- a/web/src/logos/logo-picker.ts +++ b/web/src/logos/logo-picker.ts @@ -3,10 +3,15 @@ // über `deps` (Standard: logos.ts, logo-api.ts, Anmeldung, Dialoge); alle Texte gehen als Text in den DOM, nie als HTML. Die Auswahl // gehört dem Editor (`config.logo`): ein Klick meldet nur `onSelect`, markiert wird über `select()`. Fehler von Herz und Hochladen // zeigt die Infozeile; der Statusbereich des Pickers ist den Ladefehlern der Liste vorbehalten. +// „von @name“ (Spec § 5): Kacheln und Infozeile zeigen den Benutzernamen des Hochladers. Die Namen kommen nach dem Laden einer Seite in +// einer Abfrage für alle ihre Hochlader nach (bis dahin, und wenn sie scheitert, steht der alte Anzeigename bzw. „Anonym“); ein eigenes +// Umbenennen aktualisiert die eigenen Einträge sofort. Hochladen verlangt vorher einen Benutzernamen. import '../account/account.css'; import './logos.css'; import { currentUser, currentUserId, isAdmin, onAuthChange } from '../account/auth'; import { requireLogin } from '../account/login-dialog'; +import { byline, onUsernameChange, usernamesFor } from '../account/profile'; +import { requireUsername } from '../account/username-dialog'; import { reportDialog } from '../community/report-dialog'; import { confirmDialog } from '../editor/confirm-dialog'; import { el, icon } from '../editor/dom'; @@ -29,6 +34,12 @@ export interface LogoPickerDeps extends LogoInfoDeps { onAuthChange(fn: () => void): () => void; /** Öffnet den Dialog „Logo hochladen“; `signal` schließt ihn (Seite verlassen). Das neue Logo, null bei Abbruch. */ uploadDialog(signal: AbortSignal): Promise; + /** Die Benutzernamen der Konten, eine Abfrage für alle; scheitert sie, fehlen die Namen (kein Fehler). */ + usernamesFor(userIds: readonly string[]): Promise>; + /** Meldet, wenn der eigene Benutzername feststeht oder sich ändert; gibt die Abmeldung zurück. */ + onUsernameChange(fn: (userId: string, username: string) => void): () => void; + /** Verlangt für das Hochladen einen Benutzernamen und fragt danach, wenn das Konto keinen hat; false: abgebrochen. `signal` schließt die Frage (Seite verlassen). */ + requireUsername(signal: AbortSignal): Promise; } export interface LogoPicker { @@ -72,6 +83,8 @@ interface Tile { logo: CommunityLogo; cell: HTMLLIElement; select: HTMLButtonElement; + /** Die Zeile „von @name“. */ + by: HTMLElement; heart: HTMLButtonElement; count: HTMLElement; /** Gibt das Bild frei (Object-URL); eine später eintreffende Datei wird dann nicht mehr angezeigt. */ @@ -90,6 +103,9 @@ function defaultDeps(): LogoPickerDeps { requireLogin: () => requireLogin('login'), onAuthChange, uploadDialog: (signal) => logoUploadDialog({ signal }), + usernamesFor, + onUsernameChange, + requireUsername: (signal) => requireUsername(signal), reportLogo, deleteLogo, setLogoVisibility, @@ -133,6 +149,8 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD /** Jede bekannte Zeile (Liste, angehefteter Eintrag, Upload), je ID die jüngste. */ const known = new Map(); const tiles = new Map(); + /** Die Benutzernamen der Hochlader (Konto-ID → Name), soweit bekannt; wer fehlt, hat kein Profil oder wurde noch nicht gefragt. */ + const ownerNames = new Map(); let selectedId: string | null = null; /** Das gewählte Logo, wenn es nicht in der Liste steht: zweite Kachel direkt nach „Kein Logo“. */ let pinnedId: string | null = null; @@ -162,6 +180,7 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD selectedId: () => selectedId, selected: selectedRow, gone: () => selectedId !== null && goneId === selectedId, + names: () => ownerNames, patchRow(id, patch) { const row = known.get(id); if (row) setRow({ ...row, ...patch }); @@ -214,9 +233,11 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD // ---- Kacheln -------------------------------------------------------------------------------------------------------------- - /** Auswahl, Herz und Zähler einer Kachel nach dem aktuellen Stand. */ + /** Auswahl, „von @name“, Herz und Zähler einer Kachel nach dem aktuellen Stand. */ function renderTile(tile: Tile): void { const { logo } = tile; + const by = byline(logo.ownerId, logo.displayName, ownerNames); + if (tile.by.textContent !== by) tile.by.textContent = by; // unverändert bleibt der Text stehen (nichts wird neu gezeichnet) tile.select.setAttribute('aria-pressed', String(logo.id === selectedId)); tile.heart.setAttribute('aria-pressed', String(hearts.has(logo.id))); tile.heart.setAttribute('aria-label', t('Herz für „{name}“: {count}', { name: logo.name, count: logo.likeCount })); @@ -236,17 +257,18 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD function createTile(logo: CommunityLogo): Tile { const art = el('span', { class: 'logo-tile-art' }); + const by = el('span', { class: 'logo-tile-by' }); // den Text setzt renderTile const selectButton = el( 'button', { type: 'button', class: 'tile logo-tile', 'aria-pressed': 'false', title: logo.name }, art, el('span', { class: 'logo-tile-name' }, logo.name), - el('span', { class: 'logo-tile-by' }, t('von {name}', { name: logo.displayName || t('Anonym') })), + by, ); const count = el('span', { class: 'logo-heart-count' }); const heart = el('button', { type: 'button', class: 'logo-heart', 'aria-pressed': 'false' }, icon('heart'), count); const release = showLogoImage(art, logo, deps.loadLogoBlob); // das PNG auf dem dunklen Kartengrund - const tile: Tile = { logo, cell: el('li', { class: 'logo-cell' }, selectButton, heart), select: selectButton, heart, count, release }; + const tile: Tile = { logo, cell: el('li', { class: 'logo-cell' }, selectButton, heart), select: selectButton, by, heart, count, release }; selectButton.addEventListener('click', () => handlers.onSelect(logo.id)); heart.addEventListener('click', () => void toggleHeart(logo.id)); tiles.set(logo.id, tile); @@ -384,6 +406,7 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD nextCursor = page.nextCursor; addLogos(page.logos); void loadHearts(page.logos.map((logo) => logo.id)); + void loadOwners(page.logos); } catch (err) { if (disposed || token !== loadToken) return; loading = false; @@ -456,6 +479,7 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD setRow(row); syncPin(); void loadHearts([id]); + void loadOwners([row]); } function select(id: string | null): void { @@ -470,6 +494,38 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD if (loaded || failed !== null) void resolveSelection(); } + // ---- Benutzernamen -------------------------------------------------------------------------------------------------------- + + /** Übernimmt Namen und zeichnet die Zeilen „von @name“ der betroffenen Kacheln (und der Infozeile) neu; nur, was sich ändert. */ + function applyNames(found: Iterable): void { + const changed = new Set(); + for (const [userId, name] of found) { + if (name === '' || ownerNames.get(userId) === name) continue; + ownerNames.set(userId, name); + changed.add(userId); + } + if (changed.size === 0) return; + for (const tile of tiles.values()) if (changed.has(tile.logo.ownerId)) renderTile(tile); + const selected = selectedRow(); + if (selected && changed.has(selected.ownerId)) info.update(); + } + + /** + * Holt die Benutzernamen der Hochlader dieser Zeilen, eine Abfrage für alle (jeder nur einmal). Die Anzeige braucht sie nur als + * Beiwerk: schlägt die Abfrage fehl, bleibt es bei dem Ersatz (alter Anzeigename bzw. „Anonym“), ohne Fehlertext. + */ + async function loadOwners(rows: readonly CommunityLogo[]): Promise { + const ids = [...new Set(rows.map((row) => row.ownerId).filter((id) => id !== ''))]; + if (ids.length === 0) return; + let found: Map; + try { + found = await deps.usernamesFor(ids); + } catch { + return; + } + if (!disposed) applyNames(found); + } + // ---- Herzen --------------------------------------------------------------------------------------------------------------- /** Setzt Herz und Zähler eines Logos (optimistisch, Antwort des Dienstes oder Rücksprung). */ @@ -550,10 +606,14 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD try { if (deps.userId() === null && !(await deps.requireLogin())) return; // Gast: zuerst anmelden; abgebrochen: es passiert nichts if (disposed) return; + // Ohne Benutzernamen geht es nicht weiter (Spec § 5): erst der Namensdialog; „Abbrechen“ heißt, es passiert nichts. + if (!(await deps.requireUsername(abort.signal))) return; + if (disposed) return; const logo = await deps.uploadDialog(abort.signal); if (!logo || disposed) return; rememberLogo(logo); addFirst(logo); + void loadOwners([logo]); // der Hochlader ist meist man selbst: sein Name steht schon im Speicher von usernamesFor (dann ohne Anfrage) handlers.onSelect(logo.id); } catch (err) { if (!disposed) info.setMessage(t('Hochladen fehlgeschlagen – {reason}', { reason: failureText(err) })); @@ -569,6 +629,9 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD moreButton.addEventListener('click', () => void load('more')); retryButton.addEventListener('click', () => void load(failed ?? 'reset')); + // Das eigene Umbenennen (und das Lesen des eigenen Namens nach dem Start): die Einträge des Kontos zeigen gleich den neuen Namen. + const offName = deps.onUsernameChange((userId, username) => applyNames([[userId, username]])); + // Anmelden oder Abmelden: Admin-Knöpfe, Herzen. Ohne Admin-Rechte gibt es die Admin-Ansichten nicht mehr. const offAuth = deps.onAuthChange(() => { if (!deps.isAdmin() && VIEWS[view].admin) { @@ -598,6 +661,7 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD disposed = true; abort.abort(); offAuth(); + offName(); for (const tile of tiles.values()) tile.release(); }, }; diff --git a/web/src/logos/upload-logo.test.ts b/web/src/logos/upload-logo.test.ts index 4b85330..561ebb4 100644 --- a/web/src/logos/upload-logo.test.ts +++ b/web/src/logos/upload-logo.test.ts @@ -32,7 +32,7 @@ const prepared = (patch: Partial = {}): PreparedLogo => ({ ...patch, }); -const form: LogoForm = { name: 'Beispielbank', displayName: 'Kenso', mono: false }; +const form: LogoForm = { name: 'Beispielbank', mono: false }; type Call = { name: string; args: unknown[] }; @@ -76,15 +76,15 @@ describe('uploadPreparedLogo', () => { expect(permissions).toEqual(['read("any")', 'delete("user:u1")']); }); - it('schickt der API die neue Datei-ID und genau vier Felder, Name und Anzeigename getrimmt', async () => { + it('schickt der API die neue Datei-ID und genau vier Felder: der Name getrimmt, der Anzeigename immer leer (die Zeile zeigt den Benutzernamen)', async () => { const fake = fakeDeps(); - await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', displayName: ' Kenso\t', mono: false }, fake.deps); - expect(fake.of('uploadLogo')[0].args[0]).toEqual({ name: 'Beispielbank', displayName: 'Kenso', mono: false, fileId: 'f1' }); + await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', mono: false }, fake.deps); + expect(fake.of('uploadLogo')[0].args[0]).toEqual({ name: 'Beispielbank', displayName: '', mono: false, fileId: 'f1' }); }); - it('ein leerer Anzeigename bleibt leer (der Server macht daraus „Anonym“)', async () => { + it('ein von einem alten Aufrufer mitgegebener Anzeigename geht nicht mehr mit', async () => { const fake = fakeDeps(); - await uploadPreparedLogo(prepared(), { ...form, displayName: ' ' }, fake.deps); + await uploadPreparedLogo(prepared(), { ...form, displayName: 'Kenso' } as LogoForm, fake.deps); expect(fake.of('uploadLogo')[0].args[0]).toMatchObject({ displayName: '' }); }); @@ -102,11 +102,11 @@ describe('uploadPreparedLogo', () => { it('Erfolg: das neue Logo (ID des Dienstes, Datei-ID der hochgeladenen Datei, mein Konto, ohne Herzen und Meldungen), nichts wird gelöscht', async () => { const fake = fakeDeps(); - const logo = await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', displayName: 'Kenso', mono: true }, fake.deps); + const logo = await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', mono: true }, fake.deps); expect(logo).toEqual({ id: 'r1', name: 'Beispielbank', - displayName: 'Kenso', + displayName: '', fileId: 'f1', ownerId: 'u1', mono: true, @@ -266,7 +266,7 @@ describe('Standard-Abhängigkeiten (Appwrite-Bucket logo-images)', () => { expect(fileId).toMatch(ID_PATTERN); expect(file.name).toBe('logo.png'); expect(permissions).toEqual(['read("any")', 'delete("user:u1")']); - expect(mocks.uploadLogo).toHaveBeenCalledWith({ name: 'Beispielbank', displayName: 'Kenso', mono: false, fileId }); + expect(mocks.uploadLogo).toHaveBeenCalledWith({ name: 'Beispielbank', displayName: '', mono: false, fileId }); expect(logo.id).toBe(`zeile-${fileId}`); expect(logo.fileId).toBe(fileId); expect(mocks.deleteFile).not.toHaveBeenCalled(); diff --git a/web/src/logos/upload-logo.ts b/web/src/logos/upload-logo.ts index 6d18e43..9f73a30 100644 --- a/web/src/logos/upload-logo.ts +++ b/web/src/logos/upload-logo.ts @@ -11,12 +11,16 @@ import type { PreparedLogo } from './prepare-logo'; export interface LogoForm { name: string; - /** Leer bedeutet „Anonym“ (das setzt der Dienst). */ - displayName: string; /** Einfarbig: nur wirksam, wenn das Logo durchsichtige Stellen hat. */ mono: boolean; } +/** + * Der Anzeigename, den der Client mitschickt: keiner. Seit den Benutzernamen (Spec § 5) zeigen Kachel, Infozeile und Detailseite den Namen + * des Kontos; der Dienst nimmt das Feld weiter an (der Altbestand trägt noch Anzeigenamen). + */ +const NO_DISPLAY_NAME = ''; + /** Alles, was `uploadPreparedLogo` von außen braucht (im Test ersetzbar, ohne Netz und Appwrite). */ export interface UploadLogoDeps { /** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */ @@ -59,8 +63,8 @@ function isLogoTaken(err: unknown): boolean { /** * Lädt das Logo hoch: das PNG in den Logo-Bucket (`read("any")`, `delete("user:")`), dann legt die API das Logo daraus an. * Liefert das neue Logo, so wie die Liste es danach zeigt: `id` ist die Zeilen-ID aus der Antwort des Dienstes (nicht die Datei-ID), - * `fileId` die ID der hochgeladenen Datei. „Einfarbig“ gilt nur für Logos mit durchsichtigen Stellen. Name und Anzeigename werden - * getrimmt, ihre Grenzen prüft der Dienst. + * `fileId` die ID der hochgeladenen Datei. „Einfarbig“ gilt nur für Logos mit durchsichtigen Stellen. Der Name wird getrimmt, seine + * Grenzen prüft der Dienst. Ohne Benutzernamen des Kontos lehnt der Dienst mit 403 `usernameRequired` ab (die Dateien werden dann aufgeräumt). * * Aufräumen (nur die eine Datei dieses Versuchs, nie etwas anderes): * - Scheitert der Upload, wurde die API noch nicht gerufen: es gibt kein Logo, das auf die Datei zeigen könnte. @@ -79,7 +83,6 @@ export async function uploadPreparedLogo( const userId = deps.userId(); const permissions = [Permission.read(Role.any()), Permission.delete(Role.user(userId))]; const name = form.name.trim(); - const displayName = form.displayName.trim(); const mono = form.mono && prepared.hasTransparency; const file = new File([prepared.png], 'logo.png', { type: 'image/png' }); @@ -87,7 +90,7 @@ export async function uploadPreparedLogo( let id: string; try { - ({ id } = await deps.uploadLogo({ name, displayName, mono, fileId })); + ({ id } = await deps.uploadLogo({ name, displayName: NO_DISPLAY_NAME, mono, fileId })); } catch (err) { if (isDefinitiveFailure(err) && !isLogoTaken(err)) await deleteQuietly(deps, fileId); throw err; @@ -96,7 +99,7 @@ export async function uploadPreparedLogo( return { id, name, - displayName, + displayName: NO_DISPLAY_NAME, fileId, ownerId: userId, mono, diff --git a/web/src/pages/cards-page.test.ts b/web/src/pages/cards-page.test.ts index 570b1c7..a13afde 100644 --- a/web/src/pages/cards-page.test.ts +++ b/web/src/pages/cards-page.test.ts @@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({ myPublications: vi.fn(), getLogo: vi.fn(), loadLogoBlob: vi.fn(), + requireUsername: vi.fn(), authListeners: new Set<(user: User | null) => void>(), })); @@ -38,6 +39,9 @@ vi.mock('../account/auth', () => ({ }, })); vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin })); +// Der Namensdialog ist eine Attrappe (sein eigenes Verhalten steht in account/username-dialog.test.ts); das Veröffentlichen-Dialog-Modul +// bekommt dieselbe Attrappe für `usernameDialog` (403 `usernameRequired`, siehe publish-dialog.test.ts). +vi.mock('../account/username-dialog', () => ({ requireUsername: mocks.requireUsername, usernameDialog: vi.fn() })); vi.mock('./router', () => ({ navigate: mocks.navigate })); vi.mock('../editor/upload', () => ({ READ_MESSAGE: 'Das Bild konnte nicht gelesen werden.', @@ -110,6 +114,7 @@ beforeEach(() => { store = createStore(initialEditorState()); mocks.getCurrentUser.mockResolvedValue(user); mocks.requireLogin.mockResolvedValue(true); + mocks.requireUsername.mockResolvedValue(true); // das Konto hat einen Benutzernamen mocks.myPublications.mockResolvedValue(new Map()); mocks.loadPreview.mockImplementation(async () => new Blob(['p'], { type: 'image/webp' })); unmount = null; @@ -1004,12 +1009,11 @@ describe('Meine Karten: Veröffentlichen', () => { await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen')); }; - it('„Veröffentlichen“ öffnet den Dialog: leere Felder, nie die E-Mail im Anzeigenamen', async () => { + it('„Veröffentlichen“ öffnet den Dialog: leere Felder, kein Anzeigename (der Benutzername steht bei der Karte), nie die E-Mail', async () => { await start(); await openPublish('a'); expect(dlgInput('title').value).toBe(''); - expect(dlgInput('displayName').value).toBe(''); - expect(dlgInput('displayName').placeholder).toBe('Anonym'); + expect(publishDialogEl()!.querySelector('input[name="displayName"]')).toBeNull(); expect(publishDialogEl()!.textContent).not.toContain('a@b.de'); expect(publishDialogEl()!.textContent).toContain('„Alpha“'); expect(mocks.publishSavedCard).not.toHaveBeenCalled(); @@ -1024,6 +1028,68 @@ describe('Meine Karten: Veröffentlichen', () => { await vi.waitFor(() => expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen'))); }); + it('vor dem Dialog wird der Benutzername verlangt (Spec § 5): erst requireUsername, dann öffnet der Veröffentlichen-Dialog', async () => { + const order: string[] = []; + mocks.requireUsername.mockImplementation(async () => (order.push('name'), true)); + await start(); + button(tileFor('a'), 'Veröffentlichen').click(); + await vi.waitFor(() => expect(publishDialogEl()?.open).toBe(true)); + expect(order).toEqual(['name']); + expect(mocks.requireUsername).toHaveBeenCalledTimes(1); + }); + + it('der Nutzer bricht den Namensdialog ab (false): es öffnet kein Veröffentlichen-Dialog, nichts passiert, kein Fehlertext, die Knöpfe sind wieder frei', async () => { + mocks.requireUsername.mockResolvedValue(false); + await start(); + button(tileFor('a'), 'Veröffentlichen').click(); + await vi.waitFor(() => expect(mocks.requireUsername).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(button(tileFor('a'), 'Veröffentlichen').disabled).toBe(false)); + expect(publishDialogEl()).toBeNull(); + expect(mocks.publishSavedCard).not.toHaveBeenCalled(); + expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe(''); + expect(tileFor('a').hasAttribute('aria-busy')).toBe(false); + expect(badge('a')).toBe(''); + await vi.waitFor(() => expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen'))); + // ein neuer Versuch fragt noch einmal + mocks.requireUsername.mockResolvedValue(true); + await openPublish('a'); + expect(mocks.requireUsername).toHaveBeenCalledTimes(2); + }); + + it('während der Namensdialog offen ist, sind die Knöpfe der Kachel gesperrt (ein zweiter Klick öffnet keinen zweiten)', async () => { + mocks.requireUsername.mockReturnValue(new Promise(() => {})); + await start(); + button(tileFor('a'), 'Veröffentlichen').click(); + await vi.waitFor(() => expect(mocks.requireUsername).toHaveBeenCalledTimes(1)); + expect(button(tileFor('a'), 'Veröffentlichen').disabled).toBe(true); + expect(tileFor('a').getAttribute('aria-busy')).toBe('true'); + }); + + it('lässt sich der Namensdialog nicht öffnen, steht der Grund an der Kachel und es öffnet kein Veröffentlichen-Dialog', async () => { + mocks.requireUsername.mockRejectedValue(new Error('showModal kaputt')); + await start(); + button(tileFor('a'), 'Veröffentlichen').click(); + await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.')); + expect(publishDialogEl()).toBeNull(); + expect(button(tileFor('a'), 'Veröffentlichen').disabled).toBe(false); + }); + + it('der Namensdialog bekommt das Signal der Seite; wird sie währenddessen verlassen, öffnet danach kein Veröffentlichen-Dialog mehr', async () => { + let answer!: (ok: boolean) => void; + mocks.requireUsername.mockReturnValue(new Promise((resolve) => (answer = resolve))); + await start(); + button(tileFor('a'), 'Veröffentlichen').click(); + await vi.waitFor(() => expect(mocks.requireUsername).toHaveBeenCalledTimes(1)); + const signal = mocks.requireUsername.mock.calls[0][0] as AbortSignal; + expect(signal.aborted).toBe(false); + unmount?.(); + unmount = null; + expect(signal.aborted).toBe(true); + answer(true); + await new Promise((r) => setTimeout(r, 0)); + expect(publishDialogEl()).toBeNull(); + }); + it('ohne Pflicht-Checkbox wird nichts veröffentlicht', async () => { await start(); await openPublish('a'); @@ -1037,12 +1103,12 @@ describe('Meine Karten: Veröffentlichen', () => { mocks.publishSavedCard.mockResolvedValue({ id: 'g9' }); await start(); await openPublish('a'); - fillDialog('Mein Motiv', { issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' }); + fillDialog('Mein Motiv', { issuer: 'Beispielbank', tags: 'Visa, Gold' }); sendForm(); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); expect(mocks.publishSavedCard).toHaveBeenCalledWith( expect.objectContaining({ id: 'a', name: 'Alpha' }), - { title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' }, + { title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold' }, ); expect(publishDialogEl()).toBeNull(); const link = tileFor('a').querySelector('a[data-action="view"]')!; diff --git a/web/src/pages/cards-page.ts b/web/src/pages/cards-page.ts index 5ae3390..d8ec912 100644 --- a/web/src/pages/cards-page.ts +++ b/web/src/pages/cards-page.ts @@ -7,6 +7,7 @@ import { getCurrentUser, onAuthChange } from '../account/auth'; import { cardsService, normalizeName, type SavedCard } from '../account/cards'; import { errorMessage, errorType } from '../account/errors'; import { requireLogin } from '../account/login-dialog'; +import { requireUsername } from '../account/username-dialog'; import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login'; import { clampCrop } from '../card/crop'; import { CARD_W } from '../card/geometry'; @@ -404,6 +405,8 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi function startPublish(): void { void run(async () => { + // Ohne Benutzernamen geht es nicht weiter (Spec § 5): erst der Namensdialog; „Abbrechen“ heißt, es passiert nichts. + if (!(await requireUsername(disposal.signal)) || disposed) return; await publishDialog({ cardName: card.name, signal: disposal.signal, submit: (form) => publishWithCheck(card, form) }); }).then(() => focusControl('[data-action="view"], [data-action="withdraw"], [data-action="publish"]')); } diff --git a/web/src/pages/community-page.test.ts b/web/src/pages/community-page.test.ts index 80fc950..c0e03b0 100644 --- a/web/src/pages/community-page.test.ts +++ b/web/src/pages/community-page.test.ts @@ -41,6 +41,14 @@ const report = (n: number, reason: string): GalleryReport => ({ createdAt: '2026-10-05T12:00:00.000Z', }); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((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((fn: () => Promise): Promise => fn()); const getUser = vi.fn<() => Promise>(); +/** Die Benutzernamen (eine Abfrage für die Konten der Seite) und die Hörer des eigenen Umbenennens. */ +const usernamesFor = vi.fn(); +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>(); + 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', 'x']])); + await mount(); + expect(meta()).toBe('von @x · 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>(); + 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>(); + 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; diff --git a/web/src/pages/community-page.ts b/web/src/pages/community-page.ts index bb523fd..511e2d7 100644 --- a/web/src/pages/community-page.ts +++ b/web/src/pages/community-page.ts @@ -1,11 +1,14 @@ // Detailseite „/community/“: große Karte, Angaben, Herunterladen, „Als Vorlage gestalten“, Melden. // Autor: Zurückziehen. Admin (Label `admin`): Ausblenden/Einblenden, Löschen, Meldungszahl und Gründe. +// „von @name“ (Spec § 5): Der Benutzername des Autors kommt nach dem Laden der Karte in einer Abfrage nach (bis dahin, und wenn sie +// scheitert, steht der alte Anzeigename bzw. „Anonym“); ein eigenes Umbenennen aktualisiert die Zeile sofort. // Ausgeblendete oder fehlende Karten sieht ein Besucher nur als „nicht (mehr) verfügbar“: Appwrite antwortet ihm mit 404, // Autor und Admin dürfen die Zeile lesen und bekommen die Seite mit dem Hinweis „Ausgeblendet“. // Alle Texte kommen in der Sprache der Seite (t()); ein Sprachwechsel baut die Seite neu auf (main.ts). import '../community/community.css'; import { currentUser, getCurrentUser, isAdmin, onAuthChange, type User } from '../account/auth'; import { errorMessage } from '../account/errors'; +import { byline, onUsernameChange, usernamesFor } from '../account/profile'; import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login'; import { ApiError, apiErrorText, reportCard, setVisibility, withdrawCard, type ReportReason } from '../community/api'; import { @@ -37,6 +40,10 @@ export interface CommunityDeps { currentUser(): User | null; /** Fragt den Anmeldestatus beim Server (bei einem Direktaufruf ist er noch nicht bekannt). */ getUser(): Promise; + /** Die Benutzernamen der Konten (eine Abfrage für alle); scheitert sie, fehlen die Namen (kein Fehler). */ + usernamesFor(userIds: readonly string[]): Promise>; + /** Meldet, wenn der eigene Benutzername feststeht oder sich ändert; gibt die Abmeldung zurück. */ + onUsernameChange(fn: (userId: string, username: string) => void): () => void; onAuthChange(fn: (user: User | null) => void): () => void; navigate(path: string): void; saveBlobAs(blob: Blob, filename: string): void; @@ -53,6 +60,8 @@ export const defaultDeps: CommunityDeps = { setVisibility, currentUser, getUser: getCurrentUser, + usernamesFor, + onUsernameChange, onAuthChange, navigate, saveBlobAs, @@ -107,6 +116,8 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit let busy: Busy = null; let imageBlob: Blob | null = null; let imageUrl: string | null = null; + /** Die Benutzernamen, die diese Seite kennt (Konto-ID → Name); wer fehlt, hat kein Profil oder wurde noch nicht gefragt. */ + const names = new Map(); const page = el('div', { class: 'detail-page' }); root.replaceChildren(page); @@ -203,6 +214,8 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit // ---- Karte ---- const infoSlot = el('div', { class: 'detail-info' }); + /** „von @name · Datum“. Eigenes Element, damit ein nachkommender oder geänderter Name nur diese Zeile neu schreibt. */ + const metaEl = el('p', { class: 'detail-meta' }); const noticeSlot = el('div', { class: 'detail-notice-slot' }); const actionsSlot = el('div', { class: 'detail-actions' }); const adminSlot = el('div', { class: 'detail-admin-slot' }); @@ -221,6 +234,38 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit return b; } + /** „von @name · Datum“ der gezeigten Karte; ohne Profil des Autors steht sein alter Anzeigename bzw. „Anonym“. */ + function renderMeta(): void { + if (!card) return; + const date = formatDate(card.createdAt); + const author = byline(card.ownerId, card.displayName, names); + metaEl.textContent = date ? `${author} · ${date}` : author; + } + + /** Übernimmt einen Namen und zeichnet die Zeile neu, wenn er neu ist und zur gezeigten Karte gehört (dieselbe Meldung noch einmal ändert nichts). */ + function applyName(userId: string, username: string): void { + if (username === '' || names.get(userId) === username) return; + names.set(userId, username); + if (card?.ownerId === userId) renderMeta(); + } + + /** + * Holt den Benutzernamen des Autors (eine Abfrage). Die Anzeige braucht ihn nur als Beiwerk: schlägt die Abfrage fehl, bleibt der + * Ersatz stehen, ohne Fehlertext. Ein Name gehört dem Konto, nicht der Ansicht: eine späte Antwort ändert höchstens die Zeile, wenn + * die gezeigte Karte demselben Konto gehört. + */ + async function loadAuthor(c: GalleryCard): Promise { + if (c.ownerId === '' || names.has(c.ownerId)) return; + let found: Map; + try { + found = await deps.usernamesFor([c.ownerId]); + } catch { + return; + } + if (disposed) return; + for (const [userId, username] of found) applyName(userId, username); + } + /** Baut Hinweis, Knöpfe und Admin-Bereich aus dem Zustand neu auf; der Fokus bleibt auf dem gleichen Knopf. */ function renderControls(): void { if (!card) return; @@ -310,12 +355,10 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit showMessage('', 'info'); document.title = titleFor(route, c.title); - const date = formatDate(c.createdAt); - const author = t('von {name}', { name: c.displayName !== '' ? c.displayName : t('Anonym') }); infoSlot.replaceChildren( el('h1', { class: 'detail-title' }, c.title), ...(c.issuer !== '' ? [el('p', { class: 'detail-issuer' }, c.issuer)] : []), - el('p', { class: 'detail-meta' }, date ? `${author} · ${date}` : author), + metaEl, ...(c.tags.length > 0 ? [ el( @@ -346,9 +389,11 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit el('p', { class: 'detail-back' }, el('a', { href: '/#community', 'data-link': true }, t('← Zur Galerie'))), el('div', { class: 'detail-layout' }, imageSlot, infoSlot), ); + renderMeta(); renderControls(); startImage(c); void ensureReports(); + void loadAuthor(c); } // ---- Aktionen ---- @@ -513,12 +558,16 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit void ensureReports(); }); + // Das eigene Umbenennen (und das Lesen des eigenen Namens nach dem Start): die Zeile „von @name“ zeigt gleich den neuen Namen. + const stopName = deps.onUsernameChange(applyName); + void load(); return () => { disposed = true; disposal.abort(); stopAuth(); + stopName(); leaveCard(); }; } diff --git a/web/src/single-sources.test.ts b/web/src/single-sources.test.ts index 3e35b38..3d10574 100644 --- a/web/src/single-sources.test.ts +++ b/web/src/single-sources.test.ts @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -32,4 +32,21 @@ describe('Einzelne Quellen', () => { it('reportsLabel wird nur in pages/format.ts definiert', () => { expect(definedIn(/\bfunction\s+reportsLabel\b/)).toEqual(['pages/format.ts']); }); + + it('isRowMissing („die Zeile gibt es nicht“) wird nur in account/errors.ts definiert', () => { + expect(definedIn(/\bfunction\s+isRowMissing\b/)).toEqual(['account/errors.ts']); + }); +}); + +describe('Der Anzeigename (Spec Benutzernamen § 5) ist weg', () => { + it('account/display-name.ts gibt es nicht mehr', () => { + expect(existsSync(join(import.meta.dirname, 'account', 'display-name.ts'))).toBe(false); + expect(existsSync(join(import.meta.dirname, 'account', 'display-name.test.ts'))).toBe(false); + }); + + it('kein Quelltext liest oder schreibt ihn noch: weder das Modul noch der localStorage-Schlüssel noch seine Funktionen', () => { + for (const pattern of [/display-name/, /aircard\.displayName/, /rememberedDisplayName/, /rememberDisplayName/, /DISPLAY_NAME_(?:KEY|MAX)/]) { + expect(definedIn(pattern), String(pattern)).toEqual([]); + } + }); });