feat(community): „von @name“ statt Anzeigename, Benutzername vor dem Hochladen und Veröffentlichen
Logo-Kacheln, Logo-Infozeile und die Detailseite einer Community-Karte zeigen den Benutzernamen des Kontos (Hilfsfunktion byline: Profil, sonst alter Anzeigename, sonst „Anonym“). Die Namen kommen je geladener Seite in einer Abfrage nach, ein Fehler dabei bleibt ohne Fehlertext, ein eigenes Umbenennen aktualisiert die eigenen Einträge sofort. Die Felder „Anzeigename“ in den Dialogen „Logo hochladen“ und „Veröffentlichen“ fallen weg, ebenso account/display-name.ts und der localStorage-Schlüssel; der Client schickt displayName: ''. Vor dem Hochladen und Veröffentlichen verlangt requireUsername() einen Namen („Abbrechen“: es passiert nichts); antwortet der Dienst mit 403 usernameRequired, öffnet sich der Namensdialog, danach ist ein neuer Versuch möglich. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3440eb46c3
commit
56cb67be4b
26 files changed
+1247
-407
No files matched your search
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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', '<b>fett</b>', new Map())).toBe('von <b>fett</b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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“', () => {
|
describe('Merker „in dieser Sitzung schon gefragt“', () => {
|
||||||
it('beginnt leer; markieren schreibt aircard.usernamePrompted in sessionStorage', () => {
|
it('beginnt leer; markieren schreibt aircard.usernamePrompted in sessionStorage', () => {
|
||||||
expect(profile.usernamePrompted()).toBe(false);
|
expect(profile.usernamePrompted()).toBe(false);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Benutzernamen (Spec 2026-10-05-aircard-benutzernamen-design.md): die Regeln für den Namen, Profile lesen (Tabelle `profiles`: eine
|
// 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
|
// ö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
|
// `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 { Query, type Models } from 'appwrite';
|
||||||
import { ID_PATTERN } from '../card/config';
|
import { ID_PATTERN } from '../card/config';
|
||||||
import { apiPost, unavailableError } from '../community/api';
|
import { apiPost, unavailableError } from '../community/api';
|
||||||
@@ -75,6 +76,16 @@ export function validateUsername(name: string): string | null {
|
|||||||
return 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, string>): 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 ------------------------------------------------------------------------------
|
// ---- 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). */
|
/** Konto-ID → Benutzername, nur gefundene (ein Konto ohne Profil kann sich später einen Namen wählen, also wird es wieder gefragt). */
|
||||||
|
|||||||
@@ -690,6 +690,30 @@ describe('requireUsername', () => {
|
|||||||
expect(dialogEl()).toBeNull();
|
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 () => {
|
it('läuft schon ein Namensdialog (etwa die einmalige Abfrage), teilt sich requireUsername ihn', async () => {
|
||||||
mocks.userId = 'u1';
|
mocks.userId = 'u1';
|
||||||
mocks.loadMyUsername.mockResolvedValue(null);
|
mocks.loadMyUsername.mockResolvedValue(null);
|
||||||
|
|||||||
@@ -238,9 +238,9 @@ export function usernameDialog(opts: UsernameDialogOptions): Promise<string | nu
|
|||||||
* vor dem Veröffentlichen). true: es kann weitergehen; false: der Nutzer hat „Abbrechen“ gewählt, es geschieht nichts.
|
* vor dem Veröffentlichen). true: es kann weitergehen; false: der Nutzer hat „Abbrechen“ gewählt, es geschieht nichts.
|
||||||
* Ohne angemeldetes Konto gibt es nichts zu fordern (true): die Anmeldung kommt mit der Aktion selbst, und fehlt dem Konto dann der Name,
|
* Ohne angemeldetes Konto gibt es nichts zu fordern (true): die Anmeldung kommt mit der Aktion selbst, und fehlt dem Konto dann der Name,
|
||||||
* antwortet der Dienst mit 403 `usernameRequired` (dann zeigt der Aufrufer den Dialog). Dasselbe gilt, wenn sich das Profil nicht lesen lässt:
|
* antwortet der Dienst mit 403 `usernameRequired` (dann zeigt der Aufrufer den Dialog). Dasselbe gilt, wenn sich das Profil nicht lesen lässt:
|
||||||
* der Dienst entscheidet.
|
* der Dienst entscheidet. `signal` schließt den Dialog (Seite verlassen); dann ist das Ergebnis false.
|
||||||
*/
|
*/
|
||||||
export async function requireUsername(): Promise<boolean> {
|
export async function requireUsername(signal?: AbortSignal): Promise<boolean> {
|
||||||
if (currentUserId() === null) return true;
|
if (currentUserId() === null) return true;
|
||||||
let name = myUsername();
|
let name = myUsername();
|
||||||
if (name === null) {
|
if (name === null) {
|
||||||
@@ -251,5 +251,5 @@ export async function requireUsername(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (name !== null) return true;
|
if (name !== null) return true;
|
||||||
return (await usernameDialog({ mode: 'required' })) !== null;
|
return (await usernameDialog({ mode: 'required', signal })) !== null;
|
||||||
}
|
}
|
||||||
@@ -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?', () => {
|
describe('isDefinitiveFailure: hat der Server die Anfrage eindeutig abgelehnt?', () => {
|
||||||
it.each([400, 401, 403, 404, 409, 422])('ApiError %i: ja', async (code) => {
|
it.each([400, 401, 403, 404, 409, 422])('ApiError %i: ja', async (code) => {
|
||||||
const api = await freshApi();
|
const api = await freshApi();
|
||||||
|
|||||||
@@ -218,6 +218,14 @@ export function isAlreadyPublished(err: unknown): boolean {
|
|||||||
return err instanceof ApiError && err.code === 409;
|
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
|
* 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
|
* beide als ApiError 409 „Diese Karte ist schon veröffentlicht.“ an. Das Limit des Dienstes (429 mit dem Limit-Text) ist
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
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/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||||
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
|
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 { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
|
||||||
import { germanLeft } from '../i18n/english-check';
|
import { germanLeft } from '../i18n/english-check';
|
||||||
import { setLang } from '../i18n/i18n';
|
import { setLang } from '../i18n/i18n';
|
||||||
@@ -39,11 +41,10 @@ const settle = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function fill(values: Partial<PublishFormValues> = {}): void {
|
function fill(values: Partial<PublishFormValues> = {}): 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('title').value = v.title;
|
||||||
input('issuer').value = v.issuer;
|
input('issuer').value = v.issuer;
|
||||||
input('tags').value = v.tags;
|
input('tags').value = v.tags;
|
||||||
input('displayName').value = v.displayName;
|
|
||||||
consent().checked = v.consent;
|
consent().checked = v.consent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +54,13 @@ function open(opts: { cardName?: string; signal?: AbortSignal } = {}): Promise<P
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
submit.mockReset();
|
submit.mockReset();
|
||||||
|
mocks.usernameDialog.mockReset();
|
||||||
|
mocks.usernameDialog.mockResolvedValue(null);
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
localStorage.clear(); // der Anzeigename wird gemerkt und im nächsten Test wieder vorbelegt
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
localStorage.clear();
|
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,24 +69,23 @@ describe('Veröffentlichen-Dialog: Felder', () => {
|
|||||||
void open({ cardName: 'Urlaub' });
|
void open({ cardName: 'Urlaub' });
|
||||||
expect(dialogEl()?.open).toBe(true);
|
expect(dialogEl()?.open).toBe(true);
|
||||||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte veröffentlichen');
|
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([
|
expect([...form().querySelectorAll('.auth-label')].map((l) => l.textContent)).toEqual([
|
||||||
'Titel (Pflicht)',
|
'Titel (Pflicht)',
|
||||||
'Herausgeber / Bank (optional)',
|
'Herausgeber / Bank (optional)',
|
||||||
'Tags (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().type).toBe('checkbox');
|
||||||
expect(consent().checked).toBe(false);
|
expect(consent().checked).toBe(false);
|
||||||
expect(buttonByText('Abbrechen')).toBeTruthy();
|
expect(buttonByText('Abbrechen')).toBeTruthy();
|
||||||
expect(buttonByText('Veröffentlichen')).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();
|
void open();
|
||||||
expect(input('displayName').value).toBe('');
|
expect(dialogEl()!.querySelector('input[name="displayName"]')).toBeNull();
|
||||||
expect(input('displayName').placeholder).toBe('Anonym');
|
expect(dialogEl()!.textContent).not.toMatch(/Anzeigename|Anonym/);
|
||||||
expect(dialogEl()!.textContent).not.toMatch(/@/);
|
expect(dialogEl()!.textContent).not.toMatch(/@/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ describe('Veröffentlichen-Dialog: Felder', () => {
|
|||||||
void open();
|
void open();
|
||||||
const labels = [...form().querySelectorAll('label')];
|
const labels = [...form().querySelectorAll('label')];
|
||||||
const inputs = [...form().querySelectorAll('input')];
|
const inputs = [...form().querySelectorAll('input')];
|
||||||
expect(labels).toHaveLength(5);
|
expect(labels).toHaveLength(4);
|
||||||
expect(labels.map((l) => l.control)).toEqual(inputs);
|
expect(labels.map((l) => l.control)).toEqual(inputs);
|
||||||
for (const control of inputs) expect(control.labels).toHaveLength(1);
|
for (const control of inputs) expect(control.labels).toHaveLength(1);
|
||||||
const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id);
|
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');
|
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 () => {
|
it('Checkbox fehlt', async () => {
|
||||||
await invalid({ consent: false }, 'Bitte bestätige die Erklärung zum Bild.', 'consent');
|
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');
|
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');
|
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');
|
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 () => {
|
it('normale Sonderzeichen (Umlaute, Emoji, Bindestrich, Zeilenabstand durch Leerzeichen) bleiben erlaubt', async () => {
|
||||||
submit.mockResolvedValue({ id: 'g1' });
|
submit.mockResolvedValue({ id: 'g1' });
|
||||||
const outcome = open();
|
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();
|
send();
|
||||||
await expect(outcome).resolves.toBe('published');
|
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' });
|
submit.mockResolvedValue({ id: 'g1' });
|
||||||
const outcome = open();
|
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();
|
send();
|
||||||
await expect(outcome).resolves.toBe('published');
|
await expect(outcome).resolves.toBe('published');
|
||||||
expect(submit).toHaveBeenCalledOnce();
|
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', () => {
|
it('ohne Fehler verweist kein Feld auf die Fehlerzeile; die Hinweise bleiben', () => {
|
||||||
void open();
|
void open();
|
||||||
expect(describedBy('tags')).toEqual(['publish-tags-hint']);
|
expect(describedBy('tags')).toEqual(['publish-tags-hint']);
|
||||||
expect(describedBy('displayName')).toEqual(['publish-displayName-hint']);
|
|
||||||
expect(describedBy('title')).toEqual([]);
|
expect(describedBy('title')).toEqual([]);
|
||||||
expect(describedBy('consent')).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('tags')).toEqual(['publish-tags-hint', 'publish-error']);
|
||||||
expect(describedBy('title')).toEqual([]); // nur das Feld mit dem Problem
|
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 });
|
fill({ consent: false });
|
||||||
send();
|
send();
|
||||||
expect(describedBy('consent')).toEqual(['publish-error']);
|
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', () => {
|
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', () => {
|
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)).toBeNull();
|
||||||
expect(validatePublishForm({ ...ok, title: '', consent: false })?.field).toBe('title');
|
expect(validatePublishForm({ ...ok, title: '', consent: false })?.field).toBe('title');
|
||||||
expect(validatePublishForm({ ...ok, tags: 'a,b,c,d,e,f', consent: false })?.field).toBe('tags');
|
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', () => {
|
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' });
|
submit.mockResolvedValue({ id: 'g1' });
|
||||||
const outcome = open();
|
const outcome = open();
|
||||||
fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold', displayName: ' Kenso ' });
|
fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold' });
|
||||||
send();
|
send();
|
||||||
await expect(outcome).resolves.toBe('published');
|
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();
|
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' });
|
submit.mockResolvedValue({ id: 'g1' });
|
||||||
const outcome = open();
|
const outcome = open();
|
||||||
fill({ title: 'Nur Titel' });
|
fill({ title: 'Nur Titel' });
|
||||||
send();
|
send();
|
||||||
await outcome;
|
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 () => {
|
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<string | null>((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', () => {
|
describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => {
|
||||||
it('der Text zum fehlenden Logo kommt im Wortlaut an (nicht als „Etwas ist schiefgelaufen“)', async () => {
|
it('der Text zum fehlenden Logo kommt im Wortlaut an (nicht als „Etwas ist schiefgelaufen“)', async () => {
|
||||||
submit.mockRejectedValueOnce(new Error(LOGO_MISSING_PUBLISH));
|
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', () => {
|
describe('Veröffentlichen-Dialog: Schließen', () => {
|
||||||
it('„Abbrechen“ schließt ohne zu senden', async () => {
|
it('„Abbrechen“ schließt ohne zu senden', async () => {
|
||||||
const outcome = open();
|
const outcome = open();
|
||||||
@@ -624,11 +649,8 @@ describe('Veröffentlichen-Dialog auf Englisch', () => {
|
|||||||
'Title (required)',
|
'Title (required)',
|
||||||
'Issuer / bank (optional)',
|
'Issuer / bank (optional)',
|
||||||
'Tags (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-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(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('Cancel')).toBeTruthy();
|
||||||
expect(buttonByText('Publish')).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', () => {
|
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<PublishFormValues>) => validatePublishForm({ ...ok, ...values })?.message;
|
const problem = (values: Partial<PublishFormValues>) => validatePublishForm({ ...ok, ...values })?.message;
|
||||||
expect(problem({ title: ' ' })).toBe('Please enter a title.');
|
expect(problem({ title: ' ' })).toBe('Please enter a title.');
|
||||||
expect(problem({ title: 'a\tb' })).toBe('The title contains invalid characters.');
|
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, 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: `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({ 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.');
|
expect(problem({ consent: false })).toBe('Please confirm the statement about the image.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 <dialog> wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. Übersetzt wird beim Aufbau mit t().
|
// Eigenes <dialog> 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 '../account/account.css';
|
||||||
import './community.css';
|
import './community.css';
|
||||||
import { rememberDisplayName, rememberedDisplayName } from '../account/display-name';
|
|
||||||
import { errorMessage } from '../account/errors';
|
import { errorMessage } from '../account/errors';
|
||||||
|
import { usernameDialog } from '../account/username-dialog';
|
||||||
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
|
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
|
||||||
import { el, setFieldInvalid } from '../editor/dom';
|
import { el, setFieldInvalid } from '../editor/dom';
|
||||||
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
|
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
|
||||||
import { msg, t } from '../i18n/i18n';
|
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';
|
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
|
||||||
|
|
||||||
/** Wortlaut aus Spec § 6. */
|
/** 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. */
|
/** Grenzen wie der Server (deploy/api/app.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */
|
||||||
const TITLE_MAX = 60;
|
const TITLE_MAX = 60;
|
||||||
const ISSUER_MAX = 40;
|
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 TAGS_MAX = 5;
|
||||||
const TAG_MAX = 20;
|
const TAG_MAX = 20;
|
||||||
/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */
|
/** 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):
|
* 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).
|
* 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]/;
|
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;
|
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. */
|
/** 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 {
|
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 }) };
|
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.') };
|
if (!values.consent) return { field: 'consent', message: t('Bitte bestätige die Erklärung zum Bild.') };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -126,14 +115,11 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
|
|||||||
const titleInput = text('title', { 'aria-required': 'true' });
|
const titleInput = text('title', { 'aria-required': 'true' });
|
||||||
const issuerInput = text('issuer');
|
const issuerInput = text('issuer');
|
||||||
const tagsInput = text('tags', { 'aria-describedby': 'publish-tags-hint' });
|
const tagsInput = text('tags', { 'aria-describedby': 'publish-tags-hint' });
|
||||||
const nameInput = text('displayName', { placeholder: t('Anonym'), 'aria-describedby': 'publish-displayName-hint' });
|
|
||||||
nameInput.value = rememberedDisplayName(); // der zuletzt benutzte Name, auch aus dem Dialog „Logo hochladen“
|
|
||||||
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' });
|
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' });
|
||||||
const inputs: Record<PublishField, HTMLInputElement> = {
|
const inputs: Record<PublishField, HTMLInputElement> = {
|
||||||
title: titleInput,
|
title: titleInput,
|
||||||
issuer: issuerInput,
|
issuer: issuerInput,
|
||||||
tags: tagsInput,
|
tags: tagsInput,
|
||||||
displayName: nameInput,
|
|
||||||
consent: consentInput,
|
consent: consentInput,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,7 +145,6 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
|
|||||||
field(t('Titel (Pflicht)'), titleInput),
|
field(t('Titel (Pflicht)'), titleInput),
|
||||||
field(t('Herausgeber / Bank (optional)'), issuerInput),
|
field(t('Herausgeber / Bank (optional)'), issuerInput),
|
||||||
field(t('Tags (optional)'), tagsInput, t('Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.')),
|
field(t('Tags (optional)'), tagsInput, t('Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.')),
|
||||||
field(t('Anzeigename (optional)'), nameInput, t('Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.')),
|
|
||||||
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(CONSENT_TEXT))),
|
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(CONSENT_TEXT))),
|
||||||
);
|
);
|
||||||
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
|
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
|
||||||
@@ -222,12 +207,27 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
|
|||||||
close.focus();
|
close.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Dienst verlangt einen Benutzernamen: der Namensdialog öffnet sich über dem Dialog. Mit gespeichertem Namen ist der Hinweis erledigt
|
||||||
|
* und der Fokus liegt auf „Veröffentlichen“ (erneut versuchen); bei „Abbrechen“ oder wenn der Namensdialog nicht aufgeht, bleibt der Grund stehen.
|
||||||
|
*/
|
||||||
|
async function askForUsername(): Promise<void> {
|
||||||
|
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<void> {
|
async function submit(): Promise<void> {
|
||||||
const values: PublishFormValues = {
|
const values: PublishFormValues = {
|
||||||
title: titleInput.value.trim(),
|
title: titleInput.value.trim(),
|
||||||
issuer: issuerInput.value.trim(),
|
issuer: issuerInput.value.trim(),
|
||||||
tags: tagsInput.value,
|
tags: tagsInput.value,
|
||||||
displayName: nameInput.value.trim(),
|
|
||||||
consent: consentInput.checked,
|
consent: consentInput.checked,
|
||||||
};
|
};
|
||||||
clearInvalid();
|
clearInvalid();
|
||||||
@@ -241,7 +241,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
|
|||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await opts.submit({ title: values.title, issuer: values.issuer, tags: values.tags, displayName: values.displayName });
|
await opts.submit({ title: values.title, issuer: values.issuer, tags: values.tags });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
if (isAlreadyPublished(err)) {
|
if (isAlreadyPublished(err)) {
|
||||||
@@ -253,10 +253,10 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
errorEl.textContent = publishFailureText(err);
|
errorEl.textContent = publishFailureText(err);
|
||||||
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
|
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
|
||||||
|
if (isUsernameRequired(err)) await askForUsername();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
rememberDisplayName(values.displayName);
|
|
||||||
outcome = 'published';
|
outcome = 'published';
|
||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const card: SavedCard = {
|
|||||||
config: DEFAULT_CONFIG,
|
config: DEFAULT_CONFIG,
|
||||||
};
|
};
|
||||||
|
|
||||||
const form = { title: 'Meine Karte', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' };
|
const form = { title: 'Meine Karte', issuer: 'Beispielbank', tags: 'Visa, Gold' };
|
||||||
|
|
||||||
const png = () => new Blob(['png'], { type: 'image/png' });
|
const png = () => new Blob(['png'], { type: 'image/png' });
|
||||||
const webp = () => new Blob(['webp'], { type: 'image/webp' });
|
const webp = () => new Blob(['webp'], { type: 'image/webp' });
|
||||||
@@ -114,20 +114,26 @@ describe('publishSavedCard', () => {
|
|||||||
expect(preview.type).toBe('image/png');
|
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();
|
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({
|
expect(fake.of('publishCard')[0].args[0]).toEqual({
|
||||||
title: 'Meine Karte',
|
title: 'Meine Karte',
|
||||||
issuer: 'Bank',
|
issuer: 'Bank',
|
||||||
tags: ['visa', 'gold'],
|
tags: ['visa', 'gold'],
|
||||||
displayName: 'K',
|
displayName: '',
|
||||||
imageFileId: 'f1',
|
imageFileId: 'f1',
|
||||||
previewFileId: 'f2',
|
previewFileId: 'f2',
|
||||||
sourceCardId: 'card1',
|
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', () => {
|
describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => {
|
||||||
it.each([400, 401, 403, 404, 409])(
|
it.each([400, 401, 403, 404, 409])(
|
||||||
'eindeutig abgelehnt (%i): beide Dateien dieses Versuchs werden gelöscht, der Fehler kommt an',
|
'eindeutig abgelehnt (%i): beide Dateien dieses Versuchs werden gelöscht, der Fehler kommt an',
|
||||||
|
|||||||
@@ -29,9 +29,14 @@ export interface PublishForm {
|
|||||||
issuer: string;
|
issuer: string;
|
||||||
/** Rohe Eingabe, Komma-getrennt; wird mit `normalizeTags` zur Liste. */
|
/** Rohe Eingabe, Komma-getrennt; wird mit `normalizeTags` zur Liste. */
|
||||||
tags: string;
|
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). */
|
/** Alles, was `publishSavedCard` von außen braucht (im Test ersetzbar, ohne Netz und Canvas). */
|
||||||
export interface PublishDeps {
|
export interface PublishDeps {
|
||||||
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */
|
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */
|
||||||
@@ -105,8 +110,8 @@ async function deleteQuietly(deps: PublishDeps, fileIds: string[]): Promise<void
|
|||||||
*
|
*
|
||||||
* Aufräumen (nur die zwei Dateien dieses Versuchs, nie etwas anderes):
|
* Aufräumen (nur die zwei Dateien dieses Versuchs, nie etwas anderes):
|
||||||
* - Scheitert ein Upload, ist noch nichts veröffentlicht: der erste Upload wird gelöscht.
|
* - Scheitert ein Upload, ist noch nichts veröffentlicht: der erste Upload wird gelöscht.
|
||||||
* - Scheitert die API eindeutig (4xx außer 408/429, auch 409, oder schon der JWT-Abruf), wurde nichts angelegt:
|
* - Scheitert die API eindeutig (4xx außer 408/429, auch 409 und 403 `usernameRequired`, oder schon der JWT-Abruf), wurde nichts
|
||||||
* beide Dateien werden gelöscht.
|
* angelegt: beide Dateien werden gelöscht.
|
||||||
* - Bleibt der Ausgang offen (Netzfehler, Zeitlimit, 5xx, 408, 429, Antwort ohne `id`), kann die API die Zeile schon
|
* - Bleibt der Ausgang offen (Netzfehler, Zeitlimit, 5xx, 408, 429, Antwort ohne `id`), kann die API die Zeile schon
|
||||||
* angelegt haben. Dann bleiben die Dateien liegen: sonst zeigte eine öffentliche Karte auf gelöschte Bilder.
|
* angelegt haben. Dann bleiben die Dateien liegen: sonst zeigte eine öffentliche Karte auf gelöschte Bilder.
|
||||||
* (Ein Waisen-Upload ist harmlos: unverlinkt, mit Zufalls-ID, vom Nutzer jederzeit löschbar.)
|
* (Ein Waisen-Upload ist harmlos: unverlinkt, mit Zufalls-ID, vom Nutzer jederzeit löschbar.)
|
||||||
@@ -145,7 +150,7 @@ export async function publishSavedCard(
|
|||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
issuer: form.issuer.trim(),
|
issuer: form.issuer.trim(),
|
||||||
tags: normalizeTags(form.tags),
|
tags: normalizeTags(form.tags),
|
||||||
displayName: form.displayName.trim(),
|
displayName: NO_DISPLAY_NAME,
|
||||||
imageFileId: uploaded[0],
|
imageFileId: uploaded[0],
|
||||||
previewFileId: uploaded[1],
|
previewFileId: uploaded[1],
|
||||||
sourceCardId: card.id,
|
sourceCardId: card.id,
|
||||||
|
|||||||
+11
-11
@@ -57,9 +57,6 @@ export const EN: Readonly<Record<string, string>> = {
|
|||||||
'Einfarbig – passt sich Hell/Dunkel an': 'Single color – adapts to light/dark mode',
|
'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.',
|
'Nur wählbar, wenn das Logo transparente Bereiche hat.': 'Only available if the logo has transparent areas.',
|
||||||
'Name (Pflicht)': 'Name (required)',
|
'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.':
|
'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.',
|
'I own the rights to this logo or have permission from the owner, and it may be shared publicly.',
|
||||||
'Abbrechen': 'Cancel',
|
'Abbrechen': 'Cancel',
|
||||||
@@ -70,11 +67,9 @@ export const EN: Readonly<Record<string, string>> = {
|
|||||||
'Bitte wähle eine Datei.': 'Please choose a file.',
|
'Bitte wähle eine Datei.': 'Please choose a file.',
|
||||||
'Der Name enthält ungültige Zeichen.': 'The name contains invalid characters.',
|
'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 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.',
|
'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',
|
'Logo unten rechts': 'Bottom-right logo',
|
||||||
'Logos werden von der Community hochgeladen.': 'Logos are uploaded by the community.',
|
'Logos werden von der Community hochgeladen.': 'Logos are uploaded by the community.',
|
||||||
'Sortierung': 'Sort order',
|
'Sortierung': 'Sort order',
|
||||||
@@ -83,7 +78,6 @@ export const EN: Readonly<Record<string, string>> = {
|
|||||||
'Gemeldet': 'Reported',
|
'Gemeldet': 'Reported',
|
||||||
'Ausgeblendet': 'Hidden',
|
'Ausgeblendet': 'Hidden',
|
||||||
'Kein Logo': 'No logo',
|
'Kein Logo': 'No logo',
|
||||||
'von {name}': 'by {name}',
|
|
||||||
'Herz für „{name}“: {count}': 'Heart for “{name}”: {count}',
|
'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.',
|
'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.',
|
'Keine Logos in dieser Ansicht.': 'No logos in this view.',
|
||||||
@@ -91,7 +85,7 @@ export const EN: Readonly<Record<string, string>> = {
|
|||||||
'Nochmal versuchen': 'Try again',
|
'Nochmal versuchen': 'Try again',
|
||||||
'Mehr laden': 'Load more',
|
'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).
|
// der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ weiter oben (Logo auf der Karte).
|
||||||
'Löschen': 'Delete',
|
'Löschen': 'Delete',
|
||||||
'Ausblenden': 'Hide',
|
'Ausblenden': 'Hide',
|
||||||
@@ -156,6 +150,12 @@ export const EN: Readonly<Record<string, string>> = {
|
|||||||
'Später': 'Later',
|
'Später': 'Later',
|
||||||
'Wird gespeichert …': 'Saving …',
|
'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)
|
// 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.',
|
'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.',
|
'E-Mail oder Passwort ist falsch.': 'Email or password is incorrect.',
|
||||||
@@ -243,8 +243,8 @@ export const EN: Readonly<Record<string, string>> = {
|
|||||||
'1 Meldung': '1 report',
|
'1 Meldung': '1 report',
|
||||||
'{count} Meldungen': '{count} reports',
|
'{count} Meldungen': '{count} reports',
|
||||||
|
|
||||||
// Veröffentlichen-Dialog (community/publish-dialog.ts, community/publish.ts). „Abbrechen“, „Schließen“, „Anonym“, „Anzeigename (optional)“,
|
// Veröffentlichen-Dialog (community/publish-dialog.ts, community/publish.ts). „Abbrechen“, „Schließen“ und
|
||||||
// die Erklärung dazu und „Diese Karte ist schon veröffentlicht.“ stehen an anderer Stelle.
|
// „Diese Karte ist schon veröffentlicht.“ stehen an anderer Stelle.
|
||||||
'Karte veröffentlichen': 'Publish card',
|
'Karte veröffentlichen': 'Publish card',
|
||||||
'„{name}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.':
|
'„{name}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.':
|
||||||
'“{name}” will be shown publicly in the community gallery, including its image.',
|
'“{name}” will be shown publicly in the community gallery, including its image.',
|
||||||
@@ -298,7 +298,7 @@ export const EN: Readonly<Record<string, string>> = {
|
|||||||
'Nichts gefunden für „{query}“': 'Nothing found for “{query}”',
|
'Nichts gefunden für „{query}“': 'Nothing found for “{query}”',
|
||||||
|
|
||||||
// Community-Detailseite (pages/community-page.ts). „Ausgeblendet“, „Melden“, „Löschen“, „Ausblenden“, „Einblenden“, „Herunterladen“,
|
// 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 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.',
|
'Die Karte konnte gerade nicht geladen werden.': 'The card couldn’t be loaded right now.',
|
||||||
'Bild wird geladen …': 'Loading image …',
|
'Bild wird geladen …': 'Loading image …',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
prepareLogo: vi.fn(),
|
prepareLogo: vi.fn(),
|
||||||
uploadPreparedLogo: vi.fn(),
|
uploadPreparedLogo: vi.fn(),
|
||||||
withLogin: 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
|
// 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,
|
prepareLogo: mocks.prepareLogo,
|
||||||
}));
|
}));
|
||||||
vi.mock('./upload-logo', () => ({ uploadPreparedLogo: mocks.uploadPreparedLogo }));
|
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 { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
|
||||||
import { ApiError } from '../community/api';
|
import { ApiError } from '../community/api';
|
||||||
import { setLang, t } from '../i18n/i18n';
|
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 }));
|
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('name').value = values.name ?? 'Beispielbank';
|
||||||
input('displayName').value = values.displayName ?? '';
|
|
||||||
input('consent').checked = values.consent ?? true;
|
input('consent').checked = values.consent ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,10 +110,10 @@ async function setup(opts: LogoDialogOptions = {}, file: File = imageFile()): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
|
||||||
document.body.innerHTML = '';
|
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<unknown>) => fn());
|
mocks.withLogin.mockImplementation((fn: () => Promise<unknown>) => fn());
|
||||||
|
mocks.usernameDialog.mockResolvedValue(null);
|
||||||
prepare.mockResolvedValue(prepared());
|
prepare.mockResolvedValue(prepared());
|
||||||
created.length = 0;
|
created.length = 0;
|
||||||
revoked.length = 0;
|
revoked.length = 0;
|
||||||
@@ -128,15 +129,13 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
localStorage.clear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validateLogoForm', () => {
|
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', () => {
|
it('gültige Werte: kein Problem', () => {
|
||||||
expect(validateLogoForm(ok)).toBeNull();
|
expect(validateLogoForm(ok)).toBeNull();
|
||||||
expect(validateLogoForm({ ...ok, displayName: 'Kenso' })).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ohne Datei', () => {
|
it('ohne Datei', () => {
|
||||||
@@ -169,19 +168,6 @@ describe('validateLogoForm', () => {
|
|||||||
expect(validateLogoForm({ ...ok, name })).toEqual({ field: 'name', message: 'Der Name enthält ungültige Zeichen.' });
|
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: 'Kenso' })).toEqual({
|
|
||||||
field: 'displayName',
|
|
||||||
message: 'Der Anzeigename enthält ungültige Zeichen.',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ohne Häkchen', () => {
|
it('ohne Häkchen', () => {
|
||||||
expect(validateLogoForm({ ...ok, consent: false })).toEqual({
|
expect(validateLogoForm({ ...ok, consent: false })).toEqual({
|
||||||
field: 'consent',
|
field: 'consent',
|
||||||
@@ -189,12 +175,11 @@ describe('validateLogoForm', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('meldet das erste Problem in Formularreihenfolge: Datei, Name, Anzeigename, Häkchen', () => {
|
it('meldet das erste Problem in Formularreihenfolge: Datei, Name, Häkchen', () => {
|
||||||
const bad = { hasFile: false, name: '', displayName: 'x'.repeat(31), consent: false };
|
const bad = { hasFile: false, name: '', consent: false };
|
||||||
expect(validateLogoForm(bad)?.field).toBe('file');
|
expect(validateLogoForm(bad)?.field).toBe('file');
|
||||||
expect(validateLogoForm({ ...bad, hasFile: true })?.field).toBe('name');
|
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' })?.field).toBe('consent');
|
||||||
expect(validateLogoForm({ ...bad, hasFile: true, name: 'Bank', displayName: '' })?.field).toBe('consent');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Englisch: die Meldungen kommen in der Sprache der Seite', () => {
|
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, hasFile: false })?.message).toBe('Please choose a file.');
|
||||||
expect(validateLogoForm({ ...ok, name: '' })?.message).toBe('Please enter a name.');
|
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, 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.');
|
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();
|
void open();
|
||||||
expect(dialogEl()?.open).toBe(true);
|
expect(dialogEl()?.open).toBe(true);
|
||||||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Logo hochladen');
|
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([
|
expect([...form().querySelectorAll('label')].map((l) => l.textContent)).toEqual([
|
||||||
'Datei (Pflicht)',
|
'Datei (Pflicht)',
|
||||||
'Einfarbig – passt sich Hell/Dunkel an',
|
'Einfarbig – passt sich Hell/Dunkel an',
|
||||||
'Name (Pflicht)',
|
'Name (Pflicht)',
|
||||||
'Anzeigename (optional)',
|
|
||||||
LOGO_CONSENT_TEXT,
|
LOGO_CONSENT_TEXT,
|
||||||
]);
|
]);
|
||||||
expect(buttonByText('Abbrechen')).toBeTruthy();
|
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', () => {
|
it('beim Öffnen: Name leer, Häkchen ungesetzt, „Einfarbig“ gesperrt, keine Vorschau, Fokus im Datei-Feld', () => {
|
||||||
void open();
|
void open();
|
||||||
expect(input('name').value).toBe('');
|
expect(input('name').value).toBe('');
|
||||||
expect(input('displayName').value).toBe('');
|
|
||||||
expect(input('displayName').placeholder).toBe('Anonym');
|
|
||||||
expect(input('consent').checked).toBe(false);
|
expect(input('consent').checked).toBe(false);
|
||||||
expect(input('mono').checked).toBe(false);
|
expect(input('mono').checked).toBe(false);
|
||||||
expect(input('mono').disabled).toBe(true);
|
expect(input('mono').disabled).toBe(true);
|
||||||
@@ -246,10 +225,10 @@ describe('Logo-Dialog: Aufbau', () => {
|
|||||||
expect(dialogEl()!.textContent).not.toMatch(/@/);
|
expect(dialogEl()!.textContent).not.toMatch(/@/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('der Anzeigename ist mit dem zuletzt benutzten Namen vorbelegt', () => {
|
it('es gibt kein Feld „Anzeigename“ mehr (die Logos zeigen den Benutzernamen, Spec § 5)', () => {
|
||||||
localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso');
|
|
||||||
void open();
|
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', () => {
|
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);
|
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');
|
setLang('en');
|
||||||
void open();
|
void open();
|
||||||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Upload logo');
|
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Upload logo');
|
||||||
@@ -281,10 +260,8 @@ describe('Logo-Dialog: Aufbau', () => {
|
|||||||
'File (required)',
|
'File (required)',
|
||||||
'Single color – adapts to light/dark mode',
|
'Single color – adapts to light/dark mode',
|
||||||
'Name (required)',
|
'Name (required)',
|
||||||
'Display name (optional)',
|
|
||||||
'I own the rights to this logo or have permission from the owner, and it may be shared publicly.',
|
'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('Cancel')).toBeTruthy();
|
||||||
expect(buttonByText('Upload')).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) => {
|
])('der Name wird aus dem Dateinamen ohne Endung vorbelegt: %s', async (_label, fileName, expected) => {
|
||||||
await setup({}, imageFile(fileName));
|
await setup({}, imageFile(fileName));
|
||||||
expect(input('name').value).toBe(expected);
|
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 () => {
|
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');
|
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 () => {
|
it('Pflicht-Häkchen fehlt', async () => {
|
||||||
await invalid({ consent: false }, 'Bitte bestätige, dass du das Logo teilen darfst.', 'consent');
|
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('');
|
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());
|
upload.mockResolvedValue(logo());
|
||||||
const { outcome } = await setup();
|
const { outcome } = await setup();
|
||||||
fill({ name: 'x'.repeat(40), displayName: 'y'.repeat(30) });
|
fill({ name: 'x'.repeat(40) });
|
||||||
send();
|
send();
|
||||||
await outcome;
|
await outcome;
|
||||||
expect(upload).toHaveBeenCalledOnce();
|
expect(upload).toHaveBeenCalledOnce();
|
||||||
@@ -594,7 +567,7 @@ describe('Logo-Dialog: Prüfung vor dem Senden', () => {
|
|||||||
it('Umlaute, Emoji und Bindestriche bleiben erlaubt', async () => {
|
it('Umlaute, Emoji und Bindestriche bleiben erlaubt', async () => {
|
||||||
upload.mockResolvedValue(logo());
|
upload.mockResolvedValue(logo());
|
||||||
const { outcome } = await setup();
|
const { outcome } = await setup();
|
||||||
fill({ name: 'Größe 😀 – Möwe-Bank', displayName: 'Zoë' });
|
fill({ name: 'Größe 😀 – Möwe-Bank' });
|
||||||
send();
|
send();
|
||||||
await outcome;
|
await outcome;
|
||||||
expect(upload).toHaveBeenCalledOnce();
|
expect(upload).toHaveBeenCalledOnce();
|
||||||
@@ -617,7 +590,6 @@ describe('Logo-Dialog: Barrierefreiheit', () => {
|
|||||||
it('ohne Fehler verweist kein Feld auf die Fehlerzeile; die Hinweise bleiben', () => {
|
it('ohne Fehler verweist kein Feld auf die Fehlerzeile; die Hinweise bleiben', () => {
|
||||||
void open();
|
void open();
|
||||||
expect(describedBy('file')).toEqual(['logo-upload-file-hint']);
|
expect(describedBy('file')).toEqual(['logo-upload-file-hint']);
|
||||||
expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint']);
|
|
||||||
expect(describedBy('name')).toEqual([]);
|
expect(describedBy('name')).toEqual([]);
|
||||||
expect(describedBy('consent')).toEqual([]);
|
expect(describedBy('consent')).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -634,27 +606,23 @@ describe('Logo-Dialog: Barrierefreiheit', () => {
|
|||||||
send();
|
send();
|
||||||
expect(describedBy('name')).toEqual(['logo-upload-error']);
|
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 });
|
fill({ consent: false });
|
||||||
send();
|
send();
|
||||||
expect(describedBy('consent')).toEqual(['logo-upload-error']);
|
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 () => {
|
it('ist das Feld behoben, fällt die Verknüpfung beim nächsten Senden weg und der Hinweis bleibt', async () => {
|
||||||
upload.mockResolvedValue(logo());
|
upload.mockResolvedValue(logo());
|
||||||
await setup();
|
await setup();
|
||||||
fill({ displayName: 'x'.repeat(31) });
|
fill({ consent: false });
|
||||||
send();
|
send();
|
||||||
expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint', 'logo-upload-error']);
|
expect(describedBy('consent')).toEqual(['logo-upload-error']);
|
||||||
fill({ displayName: 'Kenso' });
|
fill({ consent: true });
|
||||||
send();
|
send();
|
||||||
expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint']);
|
expect(describedBy('consent')).toEqual([]);
|
||||||
expect(input('displayName').hasAttribute('aria-invalid')).toBe(false);
|
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 () => {
|
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', () => {
|
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();
|
const p = prepared();
|
||||||
prepare.mockResolvedValue(p);
|
prepare.mockResolvedValue(p);
|
||||||
const result = logo({ id: 'L9' });
|
const result = logo({ id: 'L9' });
|
||||||
upload.mockResolvedValue(result);
|
upload.mockResolvedValue(result);
|
||||||
const { outcome } = await setup();
|
const { outcome } = await setup();
|
||||||
fill({ name: ' Meine Bank ', displayName: ' Kenso ' });
|
fill({ name: ' Meine Bank ' });
|
||||||
send();
|
send();
|
||||||
await expect(outcome).resolves.toBe(result);
|
await expect(outcome).resolves.toBe(result);
|
||||||
expect(upload).toHaveBeenCalledOnce();
|
expect(upload).toHaveBeenCalledOnce();
|
||||||
expect(upload).toHaveBeenCalledWith(p, { name: 'Meine Bank', displayName: 'Kenso', mono: false });
|
expect(upload).toHaveBeenCalledWith(p, { name: 'Meine Bank', mono: false });
|
||||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso');
|
|
||||||
expect(dialogEl()).toBeNull();
|
expect(dialogEl()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -693,7 +660,7 @@ describe('Logo-Dialog: Senden', () => {
|
|||||||
fill();
|
fill();
|
||||||
send();
|
send();
|
||||||
await outcome;
|
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 () => {
|
it('ohne transparente Bereiche geht mono nie als true mit', async () => {
|
||||||
@@ -706,18 +673,7 @@ describe('Logo-Dialog: Senden', () => {
|
|||||||
fill();
|
fill();
|
||||||
send();
|
send();
|
||||||
await outcome;
|
await outcome;
|
||||||
expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', displayName: '', mono: false });
|
expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', 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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein Doppelklick auf „Hochladen“ sendet nicht zweimal', async () => {
|
it('ein Doppelklick auf „Hochladen“ sendet nicht zweimal', async () => {
|
||||||
@@ -744,7 +700,7 @@ describe('Logo-Dialog: Senden', () => {
|
|||||||
send();
|
send();
|
||||||
expect(buttonByText('Hochladen')!.disabled).toBe(true);
|
expect(buttonByText('Hochladen')!.disabled).toBe(true);
|
||||||
expect(buttonByText('Abbrechen')!.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(form().getAttribute('aria-busy')).toBe('true');
|
||||||
expect(statusText()).toBe('Wird hochgeladen …');
|
expect(statusText()).toBe('Wird hochgeladen …');
|
||||||
|
|
||||||
@@ -752,7 +708,7 @@ describe('Logo-Dialog: Senden', () => {
|
|||||||
await settle();
|
await settle();
|
||||||
expect(buttonByText('Hochladen')!.disabled).toBe(false);
|
expect(buttonByText('Hochladen')!.disabled).toBe(false);
|
||||||
expect(buttonByText('Abbrechen')!.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(form().getAttribute('aria-busy')).toBe('false');
|
||||||
expect(statusText()).toBe('');
|
expect(statusText()).toBe('');
|
||||||
});
|
});
|
||||||
@@ -818,18 +774,16 @@ describe('Logo-Dialog: Senden', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const { outcome } = await setup();
|
const { outcome } = await setup();
|
||||||
fill({ displayName: 'Kenso' });
|
fill({ name: 'Meine Bank' });
|
||||||
send();
|
send();
|
||||||
await settle();
|
await settle();
|
||||||
expect(errorText()).toBe(
|
expect(errorText()).toBe(
|
||||||
'Hochladen fehlgeschlagen – Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.',
|
'Hochladen fehlgeschlagen – Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.',
|
||||||
);
|
);
|
||||||
expect(dialogEl()?.open).toBe(true);
|
expect(dialogEl()?.open).toBe(true);
|
||||||
// Die Eingaben bleiben, und der Anzeigename wird erst bei Erfolg gemerkt.
|
// Die Eingaben bleiben.
|
||||||
expect(input('name').value).toBe('Beispielbank');
|
expect(input('name').value).toBe('Meine Bank');
|
||||||
expect(input('displayName').value).toBe('Kenso');
|
|
||||||
expect(input('consent').checked).toBe(true);
|
expect(input('consent').checked).toBe(true);
|
||||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
|
|
||||||
|
|
||||||
upload.mockResolvedValueOnce(logo());
|
upload.mockResolvedValueOnce(logo());
|
||||||
send();
|
send();
|
||||||
@@ -837,7 +791,6 @@ describe('Logo-Dialog: Senden', () => {
|
|||||||
expect(upload).toHaveBeenCalledTimes(2);
|
expect(upload).toHaveBeenCalledTimes(2);
|
||||||
expect(upload.mock.calls[1][0]).toBe(upload.mock.calls[0][0]); // dasselbe fertige Logo, nicht neu verarbeitet
|
expect(upload.mock.calls[1][0]).toBe(upload.mock.calls[0][0]); // dasselbe fertige Logo, nicht neu verarbeitet
|
||||||
expect(prepare).toHaveBeenCalledOnce();
|
expect(prepare).toHaveBeenCalledOnce();
|
||||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Fehlertext des Dienstes in der Sprache der Seite (nach Fehlerschlüssel)', async () => {
|
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<string | null>((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', () => {
|
describe('Logo-Dialog: Standardwerte', () => {
|
||||||
it('ohne prepare und upload: prepareLogo, und uploadPreparedLogo über withLogin (abgelaufene Sitzung → Anmeldung)', async () => {
|
it('ohne prepare und upload: prepareLogo, und uploadPreparedLogo über withLogin (abgelaufene Sitzung → Anmeldung)', async () => {
|
||||||
const p = prepared();
|
const p = prepared();
|
||||||
@@ -894,11 +959,11 @@ describe('Logo-Dialog: Standardwerte', () => {
|
|||||||
choose(file);
|
choose(file);
|
||||||
await settle();
|
await settle();
|
||||||
expect(mocks.prepareLogo).toHaveBeenCalledExactlyOnceWith(file);
|
expect(mocks.prepareLogo).toHaveBeenCalledExactlyOnceWith(file);
|
||||||
fill({ displayName: 'Kenso' });
|
fill();
|
||||||
send();
|
send();
|
||||||
await expect(outcome).resolves.toBe(result);
|
await expect(outcome).resolves.toBe(result);
|
||||||
expect(mocks.withLogin).toHaveBeenCalledOnce();
|
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(upload).not.toHaveBeenCalled();
|
||||||
expect(dialogEl()).toBeNull();
|
expect(dialogEl()).toBeNull();
|
||||||
expect(revoked).toEqual(['blob:logo-1']);
|
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 () => {
|
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;
|
let finish!: (l: CommunityLogo) => void;
|
||||||
upload.mockReturnValue(new Promise((resolve) => (finish = resolve)));
|
upload.mockReturnValue(new Promise((resolve) => (finish = resolve)));
|
||||||
const { outcome } = await setup({ signal: controller.signal });
|
const { outcome } = await setup({ signal: controller.signal });
|
||||||
fill({ displayName: 'Kenso' });
|
fill();
|
||||||
send();
|
send();
|
||||||
controller.abort();
|
controller.abort();
|
||||||
await expect(outcome).resolves.toBeNull();
|
await expect(outcome).resolves.toBeNull();
|
||||||
@@ -950,7 +1014,6 @@ describe('Logo-Dialog: Schließen', () => {
|
|||||||
finish(logo());
|
finish(logo());
|
||||||
await settle();
|
await settle();
|
||||||
expect(dialogEl()).toBeNull();
|
expect(dialogEl()).toBeNull();
|
||||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein Fehler nach dem Schließen durch das Signal wird verschluckt', async () => {
|
it('ein Fehler nach dem Schließen durch das Signal wird verschluckt', async () => {
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
// Dialog „Logo hochladen“ (Spec § 4): Datei wählen, im Browser verarbeiten (prepare-logo.ts), auf dunklem und hellem Grund ansehen,
|
// 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 <dialog> 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 <dialog> wie publish-dialog.ts; alle Texte
|
||||||
// gehen als Text in den DOM, nie als HTML.
|
// gehen als Text in den DOM, nie als HTML.
|
||||||
import '../account/account.css';
|
import '../account/account.css';
|
||||||
import '../community/community.css';
|
import '../community/community.css';
|
||||||
import './logos.css';
|
import './logos.css';
|
||||||
import { rememberDisplayName, rememberedDisplayName } from '../account/display-name';
|
|
||||||
import { errorMessage } from '../account/errors';
|
import { errorMessage } from '../account/errors';
|
||||||
|
import { usernameDialog } from '../account/username-dialog';
|
||||||
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
|
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
|
||||||
import { TONE_COLOR } from '../card/config';
|
import { TONE_COLOR } from '../card/config';
|
||||||
import { ApiError, apiErrorText } from '../community/api';
|
import { ApiError, apiErrorText, isUsernameRequired } from '../community/api';
|
||||||
import { CONTROL_CHARACTERS, DISPLAY_NAME_MAX } from '../community/publish-dialog';
|
import { CONTROL_CHARACTERS } from '../community/publish-dialog';
|
||||||
import { el, setFieldInvalid } from '../editor/dom';
|
import { el, setFieldInvalid } from '../editor/dom';
|
||||||
import { msg, t } from '../i18n/i18n';
|
import { msg, t } from '../i18n/i18n';
|
||||||
import type { CommunityLogo } from './logos';
|
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.',
|
'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;
|
const NAME_MAX = 40;
|
||||||
/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */
|
/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */
|
||||||
const ERROR_ID = 'logo-upload-error';
|
const ERROR_ID = 'logo-upload-error';
|
||||||
@@ -51,11 +53,10 @@ export interface LogoFormValues {
|
|||||||
/** Gibt es ein fertig verarbeitetes Logo? */
|
/** Gibt es ein fertig verarbeitetes Logo? */
|
||||||
hasFile: boolean;
|
hasFile: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
displayName: string;
|
|
||||||
consent: boolean;
|
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.
|
* 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 }) };
|
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.') };
|
if (!values.consent) return { field: 'consent', message: t('Bitte bestätige, dass du das Logo teilen darfst.') };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -189,13 +179,10 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
|||||||
'aria-describedby': 'logo-upload-mono-hint',
|
'aria-describedby': 'logo-upload-mono-hint',
|
||||||
});
|
});
|
||||||
const nameInput = text('name', { 'aria-required': 'true' });
|
const nameInput = text('name', { 'aria-required': 'true' });
|
||||||
const displayInput = text('displayName', { placeholder: t('Anonym'), 'aria-describedby': 'logo-upload-displayName-hint' });
|
|
||||||
displayInput.value = rememberedDisplayName(); // der zuletzt benutzte Name, auch aus dem Veröffentlichen-Dialog
|
|
||||||
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'logo-upload-consent' });
|
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'logo-upload-consent' });
|
||||||
const inputs: Record<LogoField, HTMLInputElement> = {
|
const inputs: Record<LogoField, HTMLInputElement> = {
|
||||||
file: fileInput,
|
file: fileInput,
|
||||||
name: nameInput,
|
name: nameInput,
|
||||||
displayName: displayInput,
|
|
||||||
consent: consentInput,
|
consent: consentInput,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -222,11 +209,6 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
|||||||
el('p', { class: 'auth-hint', id: 'logo-upload-mono-hint' }, t('Nur wählbar, wenn das Logo transparente Bereiche hat.')),
|
el('p', { class: 'auth-hint', id: 'logo-upload-mono-hint' }, t('Nur wählbar, wenn das Logo transparente Bereiche hat.')),
|
||||||
),
|
),
|
||||||
field(t('Name (Pflicht)'), nameInput),
|
field(t('Name (Pflicht)'), nameInput),
|
||||||
field(
|
|
||||||
t('Anzeigename (optional)'),
|
|
||||||
displayInput,
|
|
||||||
t('Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.'),
|
|
||||||
),
|
|
||||||
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(LOGO_CONSENT_TEXT))),
|
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(LOGO_CONSENT_TEXT))),
|
||||||
);
|
);
|
||||||
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
|
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
|
||||||
@@ -276,7 +258,7 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
|||||||
/** Sperren, Statustext und „Einfarbig“ passend zum Zustand (Hochladen läuft, Datei wird verarbeitet, Logo fertig). */
|
/** Sperren, Statustext und „Einfarbig“ passend zum Zustand (Hochladen läuft, Datei wird verarbeitet, Logo fertig). */
|
||||||
function refresh(): void {
|
function refresh(): void {
|
||||||
form.setAttribute('aria-busy', String(busy));
|
form.setAttribute('aria-busy', String(busy));
|
||||||
for (const control of [fileInput, nameInput, displayInput, consentInput]) control.disabled = busy;
|
for (const control of [fileInput, nameInput, consentInput]) control.disabled = busy;
|
||||||
monoInput.disabled = busy || !prepared?.hasTransparency;
|
monoInput.disabled = busy || !prepared?.hasTransparency;
|
||||||
send.disabled = busy || preparing;
|
send.disabled = busy || preparing;
|
||||||
cancel.disabled = busy;
|
cancel.disabled = busy;
|
||||||
@@ -351,13 +333,28 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
|||||||
suggestName(file.name);
|
suggestName(file.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Dienst verlangt einen Benutzernamen: der Namensdialog öffnet sich über dem Dialog. Mit gespeichertem Namen ist der Hinweis erledigt
|
||||||
|
* und der Fokus liegt auf „Hochladen“ (erneut versuchen); bei „Abbrechen“ oder wenn der Namensdialog nicht aufgeht, bleibt der Grund stehen.
|
||||||
|
*/
|
||||||
|
async function askForUsername(): Promise<void> {
|
||||||
|
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<void> {
|
async function submit(): Promise<void> {
|
||||||
clearInvalid();
|
clearInvalid();
|
||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
const problem = validateLogoForm({
|
const problem = validateLogoForm({
|
||||||
hasFile: prepared !== null,
|
hasFile: prepared !== null,
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
displayName: displayInput.value,
|
|
||||||
consent: consentInput.checked,
|
consent: consentInput.checked,
|
||||||
});
|
});
|
||||||
if (problem) {
|
if (problem) {
|
||||||
@@ -366,7 +363,7 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
|||||||
inputs[problem.field].focus();
|
inputs[problem.field].focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const logoForm: LogoForm = { name: nameInput.value.trim(), displayName: displayInput.value.trim(), mono: isMono() };
|
const logoForm: LogoForm = { name: nameInput.value.trim(), mono: isMono() };
|
||||||
busy = true; // vor dem ersten await: ein zweiter Klick findet den Dialog schon gesperrt
|
busy = true; // vor dem ersten await: ein zweiter Klick findet den Dialog schon gesperrt
|
||||||
refresh();
|
refresh();
|
||||||
let logo: CommunityLogo;
|
let logo: CommunityLogo;
|
||||||
@@ -379,10 +376,10 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
|||||||
errorEl.textContent =
|
errorEl.textContent =
|
||||||
err instanceof LoginCancelled ? t(LOGIN_NEEDED) : t('Hochladen fehlgeschlagen – {reason}', { reason: failureReason(err) });
|
err instanceof LoginCancelled ? t(LOGIN_NEEDED) : t('Hochladen fehlgeschlagen – {reason}', { reason: failureReason(err) });
|
||||||
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
|
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
|
||||||
|
if (isUsernameRequired(err)) await askForUsername();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
rememberDisplayName(logoForm.displayName);
|
|
||||||
result = logo;
|
result = logo;
|
||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ let row: CommunityLogo | null;
|
|||||||
let gone: boolean;
|
let gone: boolean;
|
||||||
let user: string | null;
|
let user: string | null;
|
||||||
let admin: boolean;
|
let admin: boolean;
|
||||||
|
/** Die Benutzernamen, die der Picker kennt (Konto-ID → Name), so wie `host.names()` sie liefert. */
|
||||||
|
let owners: Map<string, string>;
|
||||||
let abort: AbortController;
|
let abort: AbortController;
|
||||||
let info: LogoInfo;
|
let info: LogoInfo;
|
||||||
|
|
||||||
@@ -68,6 +70,7 @@ const host = {
|
|||||||
selectedId: () => selectedId,
|
selectedId: () => selectedId,
|
||||||
selected: () => row,
|
selected: () => row,
|
||||||
gone: () => gone,
|
gone: () => gone,
|
||||||
|
names: () => owners,
|
||||||
patchRow: vi.fn((id: string, patch: Partial<CommunityLogo>) => {
|
patchRow: vi.fn((id: string, patch: Partial<CommunityLogo>) => {
|
||||||
if (row?.id === id) row = { ...row, ...patch };
|
if (row?.id === id) row = { ...row, ...patch };
|
||||||
}),
|
}),
|
||||||
@@ -95,6 +98,7 @@ beforeEach(() => {
|
|||||||
gone = false;
|
gone = false;
|
||||||
user = 'u1';
|
user = 'u1';
|
||||||
admin = false;
|
admin = false;
|
||||||
|
owners = new Map();
|
||||||
abort = new AbortController();
|
abort = new AbortController();
|
||||||
fx.reportLogo.mockResolvedValue({ schonGemeldet: false });
|
fx.reportLogo.mockResolvedValue({ schonGemeldet: false });
|
||||||
fx.deleteLogo.mockResolvedValue(undefined);
|
fx.deleteLogo.mockResolvedValue(undefined);
|
||||||
@@ -159,24 +163,54 @@ describe('Anzeige', () => {
|
|||||||
expect(message()).toBe('');
|
expect(message()).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gewähltes Logo: „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ – auch für Gäste', async () => {
|
it('gewähltes Logo: „<Name> · von @<Benutzername> · ♥ <Zahl>“ und „Melden“ – auch für Gäste', async () => {
|
||||||
user = null;
|
user = null;
|
||||||
await mount();
|
owners.set('u9', 'Kenso.K');
|
||||||
expect(line()).toBe('Logo A · von Kenso · ♥ 12');
|
await mount(logo({ displayName: '' }));
|
||||||
|
expect(line()).toBe('Logo A · von @Kenso.K · ♥ 12');
|
||||||
expect(buttons()).toEqual(['Melden']);
|
expect(buttons()).toEqual(['Melden']);
|
||||||
expect(button('Melden').type).toBe('button');
|
expect(button('Melden').type).toBe('button');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ohne Anzeigename steht „von Anonym“', async () => {
|
it('der Benutzername geht dem alten Anzeigenamen vor', async () => {
|
||||||
await mount(logo({ displayName: '' }));
|
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');
|
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: '<img src=x onerror=alert(1)>', displayName: '<b>fett</b>' }));
|
await mount(logo({ name: '<img src=x onerror=alert(1)>', displayName: '<b>fett</b>' }));
|
||||||
expect(line()).toBe('<img src=x onerror=alert(1)> · von <b>fett</b> · ♥ 12');
|
expect(line()).toBe('<img src=x onerror=alert(1)> · von <b>fett</b> · ♥ 12');
|
||||||
expect(info.el.querySelector('img')).toBeNull();
|
expect(info.el.querySelector('img')).toBeNull();
|
||||||
expect(info.el.querySelector('b')).toBeNull();
|
expect(info.el.querySelector('b')).toBeNull();
|
||||||
|
owners.set('u9', '<i>x</i>');
|
||||||
|
info.update();
|
||||||
|
expect(line()).toBe('<img src=x onerror=alert(1)> · von @<i>x</i> · ♥ 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 () => {
|
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 });
|
setLang('en', { persist: false });
|
||||||
await mount(logo({ displayName: '' }));
|
await mount(logo({ displayName: '' }));
|
||||||
expect(line()).toBe('Logo A · by Anonymous · ♥ 12');
|
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']);
|
expect(buttons()).toEqual(['Report']);
|
||||||
choose(null);
|
choose(null);
|
||||||
selectedId = 'x';
|
selectedId = 'x';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6): „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“. Wer das Logo
|
// Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6): „<Name> · von @<Benutzername> · ♥ <Zahl>“ und „Melden“. Wer das Logo
|
||||||
// hochgeladen hat, sieht dazu „Löschen“ (mit Rückfrage), Admins die Meldungen mit ihren Gründen sowie „Ausblenden“/„Einblenden“
|
// 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
|
// 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.
|
// der Statusbereich des Pickers den Ladefehlern der Liste vorbehalten.
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import '../account/account.css';
|
import '../account/account.css';
|
||||||
import './logos.css';
|
import './logos.css';
|
||||||
import { errorMessage } from '../account/errors';
|
import { errorMessage } from '../account/errors';
|
||||||
|
import { byline } from '../account/profile';
|
||||||
import { ApiError, apiErrorText } from '../community/api';
|
import { ApiError, apiErrorText } from '../community/api';
|
||||||
import { REPORT_REASONS, type ReportDialogOptions, type ReportOutcome } from '../community/report-dialog';
|
import { REPORT_REASONS, type ReportDialogOptions, type ReportOutcome } from '../community/report-dialog';
|
||||||
import type { ConfirmOptions } from '../editor/confirm-dialog';
|
import type { ConfirmOptions } from '../editor/confirm-dialog';
|
||||||
@@ -40,6 +41,8 @@ export interface LogoInfoHost {
|
|||||||
selected(): CommunityLogo | null;
|
selected(): CommunityLogo | null;
|
||||||
/** Das gewählte Logo gibt es nicht (mehr): die Abfrage danach hat es nicht gefunden. */
|
/** Das gewählte Logo gibt es nicht (mehr): die Abfrage danach hat es nicht gefunden. */
|
||||||
gone(): boolean;
|
gone(): boolean;
|
||||||
|
/** Die Benutzernamen der Hochlader, die der Picker kennt (Konto-ID → Name); ohne Eintrag gilt der alte Anzeigename bzw. „Anonym“. */
|
||||||
|
names(): ReadonlyMap<string, string>;
|
||||||
/** Ändert Felder der bekannten Zeile von `id` (Kachel und Infozeile folgen). */
|
/** Ändert Felder der bekannten Zeile von `id` (Kachel und Infozeile folgen). */
|
||||||
patchRow(id: string, patch: Partial<CommunityLogo>): void;
|
patchRow(id: string, patch: Partial<CommunityLogo>): void;
|
||||||
/** Das Logo ist gelöscht: Kachel und Zeile entfernen, steht es in der Auswahl, wird sie aufgehoben. */
|
/** 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 {
|
export interface LogoInfo {
|
||||||
el: HTMLElement;
|
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;
|
update(): void;
|
||||||
/** Zeigt einen Fehlertext in der Meldezeile; ein leerer Text löscht ihn. */
|
/** Zeigt einen Fehlertext in der Meldezeile; ein leerer Text löscht ihn. */
|
||||||
setMessage(text: string): void;
|
setMessage(text: string): void;
|
||||||
@@ -164,7 +167,7 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A
|
|||||||
|
|
||||||
const admin = deps.isAdmin();
|
const admin = deps.isAdmin();
|
||||||
nameEl.textContent = logo.name;
|
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}`;
|
heartsEl.textContent = `♥ ${logo.likeCount}`;
|
||||||
hiddenEl.hidden = !logo.hidden;
|
hiddenEl.hidden = !logo.hidden;
|
||||||
// Ein ausgeblendetes Logo kennt der Dienst für Besucher nicht: es lässt sich nicht melden.
|
// Ein ausgeblendetes Logo kennt der Dienst für Besucher nicht: es lässt sich nicht melden.
|
||||||
|
|||||||
@@ -79,10 +79,18 @@ const fx = {
|
|||||||
listLogoReports: vi.fn<LogoPickerDeps['listLogoReports']>(),
|
listLogoReports: vi.fn<LogoPickerDeps['listLogoReports']>(),
|
||||||
confirm: vi.fn<LogoPickerDeps['confirm']>(),
|
confirm: vi.fn<LogoPickerDeps['confirm']>(),
|
||||||
reportDialog: vi.fn<LogoPickerDeps['reportDialog']>(),
|
reportDialog: vi.fn<LogoPickerDeps['reportDialog']>(),
|
||||||
|
usernamesFor: vi.fn<LogoPickerDeps['usernamesFor']>(),
|
||||||
|
requireUsername: vi.fn<LogoPickerDeps['requireUsername']>(),
|
||||||
onSelect: vi.fn<(id: string | null) => void>(),
|
onSelect: vi.fn<(id: string | null) => void>(),
|
||||||
onLogoChanged: vi.fn<(id: string) => 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 = {
|
const deps: LogoPickerDeps = {
|
||||||
listLogos: fx.listLogos,
|
listLogos: fx.listLogos,
|
||||||
getLogo: fx.getLogo,
|
getLogo: fx.getLogo,
|
||||||
@@ -103,6 +111,12 @@ const deps: LogoPickerDeps = {
|
|||||||
listLogoReports: fx.listLogoReports,
|
listLogoReports: fx.listLogoReports,
|
||||||
confirm: fx.confirm,
|
confirm: fx.confirm,
|
||||||
reportDialog: fx.reportDialog,
|
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
|
// 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';
|
user = 'u1';
|
||||||
admin = false;
|
admin = false;
|
||||||
authListeners.clear();
|
authListeners.clear();
|
||||||
|
nameListeners.clear();
|
||||||
sizes.clear();
|
sizes.clear();
|
||||||
broken.clear();
|
broken.clear();
|
||||||
urlOwner.clear();
|
urlOwner.clear();
|
||||||
@@ -163,6 +178,8 @@ beforeEach(() => {
|
|||||||
fx.listLogoReports.mockResolvedValue([]);
|
fx.listLogoReports.mockResolvedValue([]);
|
||||||
fx.confirm.mockResolvedValue(true);
|
fx.confirm.mockResolvedValue(true);
|
||||||
fx.reportDialog.mockResolvedValue('cancelled');
|
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) => {
|
URL.createObjectURL = vi.fn((blob: Blob) => {
|
||||||
const url = `blob:logo-${created.length + 1}`;
|
const url = `blob:logo-${created.length + 1}`;
|
||||||
created.push(url);
|
created.push(url);
|
||||||
@@ -258,7 +275,7 @@ describe('Aufbau', () => {
|
|||||||
expect(pressedSort()).toEqual(['Beliebt']);
|
expect(pressedSort()).toEqual(['Beliebt']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('erste Kachel „Kein Logo“, dann je Logo eine Kachel mit Name, „von <Anzeigename>“ bzw. „von Anonym“ und Herz-Knopf mit der Zahl', async () => {
|
it('erste Kachel „Kein Logo“, dann je Logo eine Kachel mit Name, „von <alter Anzeigename>“ bzw. „von Anonym“ (ohne Profil) und Herz-Knopf mit der Zahl', async () => {
|
||||||
fx.listLogos.mockResolvedValue({
|
fx.listLogos.mockResolvedValue({
|
||||||
logos: [logo('a', { likeCount: 12 }), logo('b', { displayName: '' }), logo('c', { displayName: 'Anna', likeCount: 3 })],
|
logos: [logo('a', { likeCount: 12 }), logo('b', { displayName: '' }), logo('c', { displayName: 'Anna', likeCount: 3 })],
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
@@ -328,6 +345,214 @@ describe('Aufbau', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Benutzernamen („von @name“)', () => {
|
||||||
|
const by = (name: string) => cell(name).querySelector('.logo-tile-by')!.textContent;
|
||||||
|
const owned = (id: string, ownerId: string, displayName = '') => logo(id, { ownerId, displayName });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [owned('a', 'u1'), owned('b', 'u2', 'Anna'), owned('c', 'u3')], nextCursor: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine einzige Abfrage für alle Hochlader der geladenen Seite, ohne Doppelte und ohne Einträge ohne Konto-ID', async () => {
|
||||||
|
fx.listLogos.mockResolvedValue({
|
||||||
|
logos: [owned('a', 'u1'), owned('b', 'u2'), owned('c', 'u1'), owned('d', '')],
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
expect(fx.usernamesFor).toHaveBeenCalledExactlyOnceWith(['u1', 'u2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine leere Seite oder nur Einträge ohne Konto-ID: keine Abfrage', async () => {
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [owned('a', '')], nextCursor: null });
|
||||||
|
await mount();
|
||||||
|
expect(fx.usernamesFor).not.toHaveBeenCalled();
|
||||||
|
picker.dispose();
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [], nextCursor: null });
|
||||||
|
await mount();
|
||||||
|
expect(fx.usernamesFor).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Kachel zeigt „von @name“, wo es ein Profil gibt; sonst den alten Anzeigenamen (ohne @), sonst „von Anonym“', async () => {
|
||||||
|
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||||||
|
await mount();
|
||||||
|
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Kenso', 'von Anna', 'von Anonym']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Profil geht dem alten Anzeigenamen des Eintrags vor', async () => {
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [owned('a', 'u1', 'Alter Name')], nextCursor: null });
|
||||||
|
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||||||
|
await mount();
|
||||||
|
expect(by('Logo A')).toBe('von @Kenso');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Liste wartet nicht auf die Namen: bis sie da sind, steht der Ersatz, danach der Name', async () => {
|
||||||
|
const lookup = deferred<Map<string, string>>();
|
||||||
|
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||||||
|
await mount();
|
||||||
|
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
|
||||||
|
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von Anonym', 'von Anna', 'von Anonym']);
|
||||||
|
lookup.resolve(new Map([['u1', 'Kenso'], ['u2', 'Mia']]));
|
||||||
|
await flush();
|
||||||
|
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Kenso', 'von @Mia', 'von Anonym']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Name als Text, nie als HTML', async () => {
|
||||||
|
fx.usernamesFor.mockResolvedValue(new Map([['u1', '<b>fett</b>']]));
|
||||||
|
await mount();
|
||||||
|
expect(by('Logo A')).toBe('von @<b>fett</b>');
|
||||||
|
expect(cell('Logo A').querySelector('b')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Infozeile des gewählten Logos zeigt „von @name“, auch wenn die Namen erst nach der Auswahl eintreffen', async () => {
|
||||||
|
const lookup = deferred<Map<string, string>>();
|
||||||
|
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||||||
|
await mount();
|
||||||
|
picker.select('a');
|
||||||
|
expect(infoLine()).toBe('Logo A · von Anonym · ♥ 0');
|
||||||
|
lookup.resolve(new Map([['u1', 'Kenso']]));
|
||||||
|
await flush();
|
||||||
|
expect(infoLine()).toBe('Logo A · von @Kenso · ♥ 0');
|
||||||
|
picker.select('b');
|
||||||
|
expect(infoLine()).toBe('Logo B · von Anna · ♥ 0'); // u2 hat kein Profil
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schlägt die Abfrage der Namen fehl, bleibt es beim Ersatz: kein Fehlertext, nichts bricht, Herzen und Auswahl gehen weiter', async () => {
|
||||||
|
fx.usernamesFor.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
|
await mount();
|
||||||
|
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von Anonym', 'von Anna', 'von Anonym']);
|
||||||
|
expect(alertText()).toBe('');
|
||||||
|
picker.select('a');
|
||||||
|
expect(infoLine()).toBe('Logo A · von Anonym · ♥ 0');
|
||||||
|
expect(infoMessage()).toBe('');
|
||||||
|
expect(fx.myHearts).toHaveBeenCalled();
|
||||||
|
heart('Logo A').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.setHeart).toHaveBeenCalledWith('a', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„Mehr laden“: eine weitere Abfrage für die Hochlader der neuen Seite', async () => {
|
||||||
|
fx.listLogos.mockResolvedValueOnce({ logos: [owned('a', 'u1'), owned('b', 'u2')], nextCursor: 'b' });
|
||||||
|
fx.listLogos.mockResolvedValueOnce({ logos: [owned('c', 'u3'), owned('d', 'u1')], nextCursor: null });
|
||||||
|
fx.usernamesFor.mockImplementation(async (ids) => new Map(ids.map((id) => [id, `Name-${id}`])));
|
||||||
|
await mount();
|
||||||
|
expect(fx.usernamesFor).toHaveBeenCalledTimes(1);
|
||||||
|
moreButton().click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.usernamesFor).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fx.usernamesFor).toHaveBeenLastCalledWith(['u3', 'u1']);
|
||||||
|
expect(['Logo A', 'Logo B', 'Logo C', 'Logo D'].map(by)).toEqual(['von @Name-u1', 'von @Name-u2', 'von @Name-u3', 'von @Name-u1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Wechsel der Ansicht lädt die Liste neu und fragt die Namen dieser Seite', async () => {
|
||||||
|
await mount();
|
||||||
|
sortButton('Neu').click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.usernamesFor).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das gewählte Logo außerhalb der Liste (angeheftet) bekommt seinen Namen nach', async () => {
|
||||||
|
fx.getLogo.mockResolvedValue(owned('x', 'u7'));
|
||||||
|
fx.usernamesFor.mockImplementation(async (ids) => new Map(ids.includes('u7') ? [['u7', 'Zora']] : []));
|
||||||
|
await mount();
|
||||||
|
picker.select('x');
|
||||||
|
await flush();
|
||||||
|
expect(by('Logo X')).toBe('von @Zora');
|
||||||
|
expect(infoLine()).toBe('Logo X · von @Zora · ♥ 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein frisch hochgeladenes Logo zeigt den Namen seines Hochladers (eine eigene Abfrage, wenn er der Liste noch fehlt)', async () => {
|
||||||
|
fx.usernamesFor.mockImplementation(async (ids) => new Map(ids.includes('u5') ? [['u5', 'Kenso']] : []));
|
||||||
|
fx.uploadDialog.mockResolvedValue(logo('n', { name: 'Neues Logo', ownerId: 'u5', displayName: '' }));
|
||||||
|
await mount();
|
||||||
|
expect(fx.usernamesFor).toHaveBeenCalledTimes(1);
|
||||||
|
uploadButton().click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.usernamesFor).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fx.usernamesFor).toHaveBeenLastCalledWith(['u5']);
|
||||||
|
expect(by('Neues Logo')).toBe('von @Kenso');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine neue Auswahl oder ein Herz zeichnet die Kachel neu, schreibt die Zeile „von …“ aber nicht neu (derselbe Textknoten)', async () => {
|
||||||
|
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||||||
|
await mount();
|
||||||
|
const span = cell('Logo A').querySelector('.logo-tile-by')!;
|
||||||
|
const text = span.firstChild;
|
||||||
|
picker.select('a');
|
||||||
|
picker.select('b');
|
||||||
|
heart('Logo A').click();
|
||||||
|
await flush();
|
||||||
|
expect(cell('Logo A').querySelector('.logo-tile-by')).toBe(span);
|
||||||
|
expect(span.firstChild).toBe(text);
|
||||||
|
expect(by('Logo A')).toBe('von @Kenso');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eigenes Umbenennen: Kacheln und Infozeile des Kontos zeigen sofort den neuen Namen, andere Einträge bleiben', async () => {
|
||||||
|
fx.listLogos.mockResolvedValue({ logos: [owned('a', 'u1'), owned('b', 'u2', 'Anna'), owned('c', 'u1')], nextCursor: null });
|
||||||
|
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Alt']]));
|
||||||
|
await mount();
|
||||||
|
picker.select('a');
|
||||||
|
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Alt', 'von Anna', 'von @Alt']);
|
||||||
|
expect(infoLine()).toBe('Logo A · von @Alt · ♥ 0');
|
||||||
|
|
||||||
|
emitName('u1', 'Neu');
|
||||||
|
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Neu', 'von Anna', 'von @Neu']);
|
||||||
|
expect(infoLine()).toBe('Logo A · von @Neu · ♥ 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Name eines Kontos, das noch kein Profil hatte (Konto ohne Namen wählt einen), ersetzt den Ersatz sofort', async () => {
|
||||||
|
await mount();
|
||||||
|
expect(by('Logo A')).toBe('von Anonym');
|
||||||
|
emitName('u1', 'Kenso');
|
||||||
|
expect(by('Logo A')).toBe('von @Kenso');
|
||||||
|
expect(by('Logo B')).toBe('von Anna');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dieselbe Meldung noch einmal (auch das Lesen des eigenen Profils meldet) ändert nichts: die Kachel wird nicht neu beschrieben', async () => {
|
||||||
|
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||||||
|
await mount();
|
||||||
|
const span = cell('Logo A').querySelector('.logo-tile-by')!;
|
||||||
|
const text = span.firstChild;
|
||||||
|
emitName('u1', 'Kenso');
|
||||||
|
emitName('u1', 'Kenso');
|
||||||
|
expect(cell('Logo A').querySelector('.logo-tile-by')).toBe(span);
|
||||||
|
expect(span.firstChild).toBe(text);
|
||||||
|
expect(by('Logo A')).toBe('von @Kenso');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Umbenennung vor dem Eintreffen einer älteren Antwort der Abfrage bleibt bestehen', async () => {
|
||||||
|
const lookup = deferred<Map<string, string>>();
|
||||||
|
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||||||
|
await mount();
|
||||||
|
emitName('u1', 'Neu');
|
||||||
|
lookup.resolve(new Map([['u1', 'Neu']])); // `usernamesFor` selbst liefert nie Älteres als das Umbenannte
|
||||||
|
await flush();
|
||||||
|
expect(by('Logo A')).toBe('von @Neu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aufgeräumt: der Hörer ist abgemeldet, eine später eintreffende Antwort ändert nichts und wirft nichts', async () => {
|
||||||
|
const lookup = deferred<Map<string, string>>();
|
||||||
|
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||||||
|
await mount();
|
||||||
|
expect(nameListeners.size).toBe(1);
|
||||||
|
picker.dispose();
|
||||||
|
expect(nameListeners.size).toBe(0);
|
||||||
|
lookup.resolve(new Map([['u1', 'Kenso']]));
|
||||||
|
await flush();
|
||||||
|
expect(by('Logo A')).toBe('von Anonym');
|
||||||
|
emitName('u1', 'Neu');
|
||||||
|
expect(by('Logo A')).toBe('von Anonym');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Englisch: „by @name“, „by Anna“, „by Anonymous“', async () => {
|
||||||
|
setLang('en', { persist: false });
|
||||||
|
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||||||
|
await mount();
|
||||||
|
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['by @Kenso', 'by Anna', 'by Anonymous']);
|
||||||
|
picker.select('a');
|
||||||
|
expect(infoLine()).toBe('Logo A · by @Kenso · ♥ 0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Sortierung', () => {
|
describe('Sortierung', () => {
|
||||||
it('„Neu“ lädt mit sort:new neu, der Umschalter folgt, die Kacheln der alten Liste fallen weg', async () => {
|
it('„Neu“ lädt mit sort:new neu, der Umschalter folgt, die Kacheln der alten Liste fallen weg', async () => {
|
||||||
await mount();
|
await mount();
|
||||||
@@ -1221,6 +1446,97 @@ describe('Hochladen', () => {
|
|||||||
expect(picker.selected()).toEqual(fresh());
|
expect(picker.selected()).toEqual(fresh());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('vor dem Dialog wird der Benutzername verlangt (Spec § 5); das Signal der Seite geht mit, damit das Verlassen der Seite den Namensdialog schließt', async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
fx.requireUsername.mockImplementation(async () => {
|
||||||
|
order.push('name');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
fx.uploadDialog.mockImplementation(async () => {
|
||||||
|
order.push('dialog');
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
uploadButton().click();
|
||||||
|
await flush();
|
||||||
|
expect(order).toEqual(['name', 'dialog']);
|
||||||
|
const signal = fx.requireUsername.mock.calls[0][0];
|
||||||
|
expect(signal).toBe(fx.uploadDialog.mock.calls[0][0]);
|
||||||
|
picker.dispose();
|
||||||
|
expect(signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Gast: erst die Anmeldung, dann der Name, dann der Dialog', async () => {
|
||||||
|
user = null;
|
||||||
|
const order: string[] = [];
|
||||||
|
fx.requireLogin.mockImplementation(async () => (order.push('login'), true));
|
||||||
|
fx.requireUsername.mockImplementation(async () => (order.push('name'), true));
|
||||||
|
fx.uploadDialog.mockImplementation(async () => (order.push('dialog'), null));
|
||||||
|
await mount();
|
||||||
|
uploadButton().click();
|
||||||
|
await flush();
|
||||||
|
expect(order).toEqual(['login', 'name', 'dialog']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Nutzer bricht den Namensdialog ab (false): der Upload-Dialog öffnet nicht, nichts ändert sich, kein Fehlertext', async () => {
|
||||||
|
fx.requireUsername.mockResolvedValue(false);
|
||||||
|
await mount();
|
||||||
|
uploadButton().click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.requireUsername).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fx.uploadDialog).not.toHaveBeenCalled();
|
||||||
|
expect(fx.onSelect).not.toHaveBeenCalled();
|
||||||
|
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
|
||||||
|
expect(alertText()).toBe('');
|
||||||
|
expect(infoMessage()).toBe('');
|
||||||
|
// der Knopf geht danach wieder, und ein neuer Versuch fragt noch einmal
|
||||||
|
fx.requireUsername.mockResolvedValue(true);
|
||||||
|
uploadButton().click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.requireUsername).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fx.uploadDialog).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Gast bricht die Anmeldung ab: der Name wird gar nicht erst verlangt', async () => {
|
||||||
|
user = null;
|
||||||
|
fx.requireLogin.mockResolvedValue(false);
|
||||||
|
await mount();
|
||||||
|
uploadButton().click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.requireUsername).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt sich der Namensdialog nicht öffnen (Fehler), steht der Grund in der Infozeile und es öffnet kein Upload-Dialog', async () => {
|
||||||
|
fx.requireUsername.mockRejectedValue(new Error('kein <dialog>'));
|
||||||
|
await mount();
|
||||||
|
uploadButton().click();
|
||||||
|
await flush();
|
||||||
|
expect(infoMessage()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||||
|
expect(fx.uploadDialog).not.toHaveBeenCalled();
|
||||||
|
expect(alertText()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wird die Seite verlassen, während der Namensdialog offen ist, öffnet danach kein Upload-Dialog mehr', async () => {
|
||||||
|
const asking = deferred<boolean>();
|
||||||
|
fx.requireUsername.mockReturnValue(asking.promise);
|
||||||
|
await mount();
|
||||||
|
uploadButton().click();
|
||||||
|
await flush();
|
||||||
|
picker.dispose();
|
||||||
|
asking.resolve(true);
|
||||||
|
await flush();
|
||||||
|
expect(fx.uploadDialog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein zweiter Klick, solange der Namensdialog offen ist, öffnet keinen zweiten', async () => {
|
||||||
|
fx.requireUsername.mockReturnValue(new Promise(() => {}));
|
||||||
|
await mount();
|
||||||
|
uploadButton().click();
|
||||||
|
uploadButton().click();
|
||||||
|
await flush();
|
||||||
|
expect(fx.requireUsername).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('Gast: zuerst requireLogin, dann der Dialog', async () => {
|
it('Gast: zuerst requireLogin, dann der Dialog', async () => {
|
||||||
user = null;
|
user = null;
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
|
|||||||
@@ -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
|
// ü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
|
// 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.
|
// 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 '../account/account.css';
|
||||||
import './logos.css';
|
import './logos.css';
|
||||||
import { currentUser, currentUserId, isAdmin, onAuthChange } from '../account/auth';
|
import { currentUser, currentUserId, isAdmin, onAuthChange } from '../account/auth';
|
||||||
import { requireLogin } from '../account/login-dialog';
|
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 { reportDialog } from '../community/report-dialog';
|
||||||
import { confirmDialog } from '../editor/confirm-dialog';
|
import { confirmDialog } from '../editor/confirm-dialog';
|
||||||
import { el, icon } from '../editor/dom';
|
import { el, icon } from '../editor/dom';
|
||||||
@@ -29,6 +34,12 @@ export interface LogoPickerDeps extends LogoInfoDeps {
|
|||||||
onAuthChange(fn: () => void): () => void;
|
onAuthChange(fn: () => void): () => void;
|
||||||
/** Öffnet den Dialog „Logo hochladen“; `signal` schließt ihn (Seite verlassen). Das neue Logo, null bei Abbruch. */
|
/** Öffnet den Dialog „Logo hochladen“; `signal` schließt ihn (Seite verlassen). Das neue Logo, null bei Abbruch. */
|
||||||
uploadDialog(signal: AbortSignal): Promise<CommunityLogo | null>;
|
uploadDialog(signal: AbortSignal): Promise<CommunityLogo | null>;
|
||||||
|
/** Die Benutzernamen der Konten, eine Abfrage für alle; scheitert sie, fehlen die Namen (kein Fehler). */
|
||||||
|
usernamesFor(userIds: readonly string[]): Promise<Map<string, string>>;
|
||||||
|
/** 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<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogoPicker {
|
export interface LogoPicker {
|
||||||
@@ -72,6 +83,8 @@ interface Tile {
|
|||||||
logo: CommunityLogo;
|
logo: CommunityLogo;
|
||||||
cell: HTMLLIElement;
|
cell: HTMLLIElement;
|
||||||
select: HTMLButtonElement;
|
select: HTMLButtonElement;
|
||||||
|
/** Die Zeile „von @name“. */
|
||||||
|
by: HTMLElement;
|
||||||
heart: HTMLButtonElement;
|
heart: HTMLButtonElement;
|
||||||
count: HTMLElement;
|
count: HTMLElement;
|
||||||
/** Gibt das Bild frei (Object-URL); eine später eintreffende Datei wird dann nicht mehr angezeigt. */
|
/** 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'),
|
requireLogin: () => requireLogin('login'),
|
||||||
onAuthChange,
|
onAuthChange,
|
||||||
uploadDialog: (signal) => logoUploadDialog({ signal }),
|
uploadDialog: (signal) => logoUploadDialog({ signal }),
|
||||||
|
usernamesFor,
|
||||||
|
onUsernameChange,
|
||||||
|
requireUsername: (signal) => requireUsername(signal),
|
||||||
reportLogo,
|
reportLogo,
|
||||||
deleteLogo,
|
deleteLogo,
|
||||||
setLogoVisibility,
|
setLogoVisibility,
|
||||||
@@ -133,6 +149,8 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD
|
|||||||
/** Jede bekannte Zeile (Liste, angehefteter Eintrag, Upload), je ID die jüngste. */
|
/** Jede bekannte Zeile (Liste, angehefteter Eintrag, Upload), je ID die jüngste. */
|
||||||
const known = new Map<string, CommunityLogo>();
|
const known = new Map<string, CommunityLogo>();
|
||||||
const tiles = new Map<string, Tile>();
|
const tiles = new Map<string, Tile>();
|
||||||
|
/** Die Benutzernamen der Hochlader (Konto-ID → Name), soweit bekannt; wer fehlt, hat kein Profil oder wurde noch nicht gefragt. */
|
||||||
|
const ownerNames = new Map<string, string>();
|
||||||
let selectedId: string | null = null;
|
let selectedId: string | null = null;
|
||||||
/** Das gewählte Logo, wenn es nicht in der Liste steht: zweite Kachel direkt nach „Kein Logo“. */
|
/** Das gewählte Logo, wenn es nicht in der Liste steht: zweite Kachel direkt nach „Kein Logo“. */
|
||||||
let pinnedId: string | null = null;
|
let pinnedId: string | null = null;
|
||||||
@@ -162,6 +180,7 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD
|
|||||||
selectedId: () => selectedId,
|
selectedId: () => selectedId,
|
||||||
selected: selectedRow,
|
selected: selectedRow,
|
||||||
gone: () => selectedId !== null && goneId === selectedId,
|
gone: () => selectedId !== null && goneId === selectedId,
|
||||||
|
names: () => ownerNames,
|
||||||
patchRow(id, patch) {
|
patchRow(id, patch) {
|
||||||
const row = known.get(id);
|
const row = known.get(id);
|
||||||
if (row) setRow({ ...row, ...patch });
|
if (row) setRow({ ...row, ...patch });
|
||||||
@@ -214,9 +233,11 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD
|
|||||||
|
|
||||||
// ---- Kacheln --------------------------------------------------------------------------------------------------------------
|
// ---- 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 {
|
function renderTile(tile: Tile): void {
|
||||||
const { logo } = tile;
|
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.select.setAttribute('aria-pressed', String(logo.id === selectedId));
|
||||||
tile.heart.setAttribute('aria-pressed', String(hearts.has(logo.id)));
|
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 }));
|
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 {
|
function createTile(logo: CommunityLogo): Tile {
|
||||||
const art = el('span', { class: 'logo-tile-art' });
|
const art = el('span', { class: 'logo-tile-art' });
|
||||||
|
const by = el('span', { class: 'logo-tile-by' }); // den Text setzt renderTile
|
||||||
const selectButton = el(
|
const selectButton = el(
|
||||||
'button',
|
'button',
|
||||||
{ type: 'button', class: 'tile logo-tile', 'aria-pressed': 'false', title: logo.name },
|
{ type: 'button', class: 'tile logo-tile', 'aria-pressed': 'false', title: logo.name },
|
||||||
art,
|
art,
|
||||||
el('span', { class: 'logo-tile-name' }, logo.name),
|
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 count = el('span', { class: 'logo-heart-count' });
|
||||||
const heart = el('button', { type: 'button', class: 'logo-heart', 'aria-pressed': 'false' }, icon('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 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));
|
selectButton.addEventListener('click', () => handlers.onSelect(logo.id));
|
||||||
heart.addEventListener('click', () => void toggleHeart(logo.id));
|
heart.addEventListener('click', () => void toggleHeart(logo.id));
|
||||||
tiles.set(logo.id, tile);
|
tiles.set(logo.id, tile);
|
||||||
@@ -384,6 +406,7 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD
|
|||||||
nextCursor = page.nextCursor;
|
nextCursor = page.nextCursor;
|
||||||
addLogos(page.logos);
|
addLogos(page.logos);
|
||||||
void loadHearts(page.logos.map((logo) => logo.id));
|
void loadHearts(page.logos.map((logo) => logo.id));
|
||||||
|
void loadOwners(page.logos);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (disposed || token !== loadToken) return;
|
if (disposed || token !== loadToken) return;
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -456,6 +479,7 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD
|
|||||||
setRow(row);
|
setRow(row);
|
||||||
syncPin();
|
syncPin();
|
||||||
void loadHearts([id]);
|
void loadHearts([id]);
|
||||||
|
void loadOwners([row]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function select(id: string | null): void {
|
function select(id: string | null): void {
|
||||||
@@ -470,6 +494,38 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD
|
|||||||
if (loaded || failed !== null) void resolveSelection();
|
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<readonly [string, string]>): void {
|
||||||
|
const changed = new Set<string>();
|
||||||
|
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<void> {
|
||||||
|
const ids = [...new Set(rows.map((row) => row.ownerId).filter((id) => id !== ''))];
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
let found: Map<string, string>;
|
||||||
|
try {
|
||||||
|
found = await deps.usernamesFor(ids);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!disposed) applyNames(found);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Herzen ---------------------------------------------------------------------------------------------------------------
|
// ---- Herzen ---------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Setzt Herz und Zähler eines Logos (optimistisch, Antwort des Dienstes oder Rücksprung). */
|
/** 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 {
|
try {
|
||||||
if (deps.userId() === null && !(await deps.requireLogin())) return; // Gast: zuerst anmelden; abgebrochen: es passiert nichts
|
if (deps.userId() === null && !(await deps.requireLogin())) return; // Gast: zuerst anmelden; abgebrochen: es passiert nichts
|
||||||
if (disposed) return;
|
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);
|
const logo = await deps.uploadDialog(abort.signal);
|
||||||
if (!logo || disposed) return;
|
if (!logo || disposed) return;
|
||||||
rememberLogo(logo);
|
rememberLogo(logo);
|
||||||
addFirst(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);
|
handlers.onSelect(logo.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!disposed) info.setMessage(t('Hochladen fehlgeschlagen – {reason}', { reason: failureText(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'));
|
moreButton.addEventListener('click', () => void load('more'));
|
||||||
retryButton.addEventListener('click', () => void load(failed ?? 'reset'));
|
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.
|
// Anmelden oder Abmelden: Admin-Knöpfe, Herzen. Ohne Admin-Rechte gibt es die Admin-Ansichten nicht mehr.
|
||||||
const offAuth = deps.onAuthChange(() => {
|
const offAuth = deps.onAuthChange(() => {
|
||||||
if (!deps.isAdmin() && VIEWS[view].admin) {
|
if (!deps.isAdmin() && VIEWS[view].admin) {
|
||||||
@@ -598,6 +661,7 @@ export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerD
|
|||||||
disposed = true;
|
disposed = true;
|
||||||
abort.abort();
|
abort.abort();
|
||||||
offAuth();
|
offAuth();
|
||||||
|
offName();
|
||||||
for (const tile of tiles.values()) tile.release();
|
for (const tile of tiles.values()) tile.release();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const prepared = (patch: Partial<PreparedLogo> = {}): PreparedLogo => ({
|
|||||||
...patch,
|
...patch,
|
||||||
});
|
});
|
||||||
|
|
||||||
const form: LogoForm = { name: 'Beispielbank', displayName: 'Kenso', mono: false };
|
const form: LogoForm = { name: 'Beispielbank', mono: false };
|
||||||
|
|
||||||
type Call = { name: string; args: unknown[] };
|
type Call = { name: string; args: unknown[] };
|
||||||
|
|
||||||
@@ -76,15 +76,15 @@ describe('uploadPreparedLogo', () => {
|
|||||||
expect(permissions).toEqual(['read("any")', 'delete("user:u1")']);
|
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();
|
const fake = fakeDeps();
|
||||||
await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', displayName: ' Kenso\t', mono: false }, fake.deps);
|
await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', mono: false }, fake.deps);
|
||||||
expect(fake.of('uploadLogo')[0].args[0]).toEqual({ name: 'Beispielbank', displayName: 'Kenso', mono: false, fileId: 'f1' });
|
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();
|
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: '' });
|
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 () => {
|
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 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({
|
expect(logo).toEqual({
|
||||||
id: 'r1',
|
id: 'r1',
|
||||||
name: 'Beispielbank',
|
name: 'Beispielbank',
|
||||||
displayName: 'Kenso',
|
displayName: '',
|
||||||
fileId: 'f1',
|
fileId: 'f1',
|
||||||
ownerId: 'u1',
|
ownerId: 'u1',
|
||||||
mono: true,
|
mono: true,
|
||||||
@@ -266,7 +266,7 @@ describe('Standard-Abhängigkeiten (Appwrite-Bucket logo-images)', () => {
|
|||||||
expect(fileId).toMatch(ID_PATTERN);
|
expect(fileId).toMatch(ID_PATTERN);
|
||||||
expect(file.name).toBe('logo.png');
|
expect(file.name).toBe('logo.png');
|
||||||
expect(permissions).toEqual(['read("any")', 'delete("user:u1")']);
|
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.id).toBe(`zeile-${fileId}`);
|
||||||
expect(logo.fileId).toBe(fileId);
|
expect(logo.fileId).toBe(fileId);
|
||||||
expect(mocks.deleteFile).not.toHaveBeenCalled();
|
expect(mocks.deleteFile).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ import type { PreparedLogo } from './prepare-logo';
|
|||||||
|
|
||||||
export interface LogoForm {
|
export interface LogoForm {
|
||||||
name: string;
|
name: string;
|
||||||
/** Leer bedeutet „Anonym“ (das setzt der Dienst). */
|
|
||||||
displayName: string;
|
|
||||||
/** Einfarbig: nur wirksam, wenn das Logo durchsichtige Stellen hat. */
|
/** Einfarbig: nur wirksam, wenn das Logo durchsichtige Stellen hat. */
|
||||||
mono: boolean;
|
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). */
|
/** Alles, was `uploadPreparedLogo` von außen braucht (im Test ersetzbar, ohne Netz und Appwrite). */
|
||||||
export interface UploadLogoDeps {
|
export interface UploadLogoDeps {
|
||||||
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */
|
/** 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:<ich>")`), dann legt die API das Logo daraus an.
|
* Lädt das Logo hoch: das PNG in den Logo-Bucket (`read("any")`, `delete("user:<ich>")`), 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),
|
* 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
|
* `fileId` die ID der hochgeladenen Datei. „Einfarbig“ gilt nur für Logos mit durchsichtigen Stellen. Der Name wird getrimmt, seine
|
||||||
* getrimmt, ihre Grenzen prüft der Dienst.
|
* 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):
|
* 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.
|
* - 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 userId = deps.userId();
|
||||||
const permissions = [Permission.read(Role.any()), Permission.delete(Role.user(userId))];
|
const permissions = [Permission.read(Role.any()), Permission.delete(Role.user(userId))];
|
||||||
const name = form.name.trim();
|
const name = form.name.trim();
|
||||||
const displayName = form.displayName.trim();
|
|
||||||
const mono = form.mono && prepared.hasTransparency;
|
const mono = form.mono && prepared.hasTransparency;
|
||||||
|
|
||||||
const file = new File([prepared.png], 'logo.png', { type: 'image/png' });
|
const file = new File([prepared.png], 'logo.png', { type: 'image/png' });
|
||||||
@@ -87,7 +90,7 @@ export async function uploadPreparedLogo(
|
|||||||
|
|
||||||
let id: string;
|
let id: string;
|
||||||
try {
|
try {
|
||||||
({ id } = await deps.uploadLogo({ name, displayName, mono, fileId }));
|
({ id } = await deps.uploadLogo({ name, displayName: NO_DISPLAY_NAME, mono, fileId }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isDefinitiveFailure(err) && !isLogoTaken(err)) await deleteQuietly(deps, fileId);
|
if (isDefinitiveFailure(err) && !isLogoTaken(err)) await deleteQuietly(deps, fileId);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -96,7 +99,7 @@ export async function uploadPreparedLogo(
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
displayName,
|
displayName: NO_DISPLAY_NAME,
|
||||||
fileId,
|
fileId,
|
||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
mono,
|
mono,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
myPublications: vi.fn(),
|
myPublications: vi.fn(),
|
||||||
getLogo: vi.fn(),
|
getLogo: vi.fn(),
|
||||||
loadLogoBlob: vi.fn(),
|
loadLogoBlob: vi.fn(),
|
||||||
|
requireUsername: vi.fn(),
|
||||||
authListeners: new Set<(user: User | null) => void>(),
|
authListeners: new Set<(user: User | null) => void>(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -38,6 +39,9 @@ vi.mock('../account/auth', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin }));
|
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('./router', () => ({ navigate: mocks.navigate }));
|
||||||
vi.mock('../editor/upload', () => ({
|
vi.mock('../editor/upload', () => ({
|
||||||
READ_MESSAGE: 'Das Bild konnte nicht gelesen werden.',
|
READ_MESSAGE: 'Das Bild konnte nicht gelesen werden.',
|
||||||
@@ -110,6 +114,7 @@ beforeEach(() => {
|
|||||||
store = createStore(initialEditorState());
|
store = createStore(initialEditorState());
|
||||||
mocks.getCurrentUser.mockResolvedValue(user);
|
mocks.getCurrentUser.mockResolvedValue(user);
|
||||||
mocks.requireLogin.mockResolvedValue(true);
|
mocks.requireLogin.mockResolvedValue(true);
|
||||||
|
mocks.requireUsername.mockResolvedValue(true); // das Konto hat einen Benutzernamen
|
||||||
mocks.myPublications.mockResolvedValue(new Map());
|
mocks.myPublications.mockResolvedValue(new Map());
|
||||||
mocks.loadPreview.mockImplementation(async () => new Blob(['p'], { type: 'image/webp' }));
|
mocks.loadPreview.mockImplementation(async () => new Blob(['p'], { type: 'image/webp' }));
|
||||||
unmount = null;
|
unmount = null;
|
||||||
@@ -1004,12 +1009,11 @@ describe('Meine Karten: Veröffentlichen', () => {
|
|||||||
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('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 start();
|
||||||
await openPublish('a');
|
await openPublish('a');
|
||||||
expect(dlgInput('title').value).toBe('');
|
expect(dlgInput('title').value).toBe('');
|
||||||
expect(dlgInput('displayName').value).toBe('');
|
expect(publishDialogEl()!.querySelector('input[name="displayName"]')).toBeNull();
|
||||||
expect(dlgInput('displayName').placeholder).toBe('Anonym');
|
|
||||||
expect(publishDialogEl()!.textContent).not.toContain('a@b.de');
|
expect(publishDialogEl()!.textContent).not.toContain('a@b.de');
|
||||||
expect(publishDialogEl()!.textContent).toContain('„Alpha“');
|
expect(publishDialogEl()!.textContent).toContain('„Alpha“');
|
||||||
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
|
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')));
|
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<boolean>((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 () => {
|
it('ohne Pflicht-Checkbox wird nichts veröffentlicht', async () => {
|
||||||
await start();
|
await start();
|
||||||
await openPublish('a');
|
await openPublish('a');
|
||||||
@@ -1037,12 +1103,12 @@ describe('Meine Karten: Veröffentlichen', () => {
|
|||||||
mocks.publishSavedCard.mockResolvedValue({ id: 'g9' });
|
mocks.publishSavedCard.mockResolvedValue({ id: 'g9' });
|
||||||
await start();
|
await start();
|
||||||
await openPublish('a');
|
await openPublish('a');
|
||||||
fillDialog('Mein Motiv', { issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' });
|
fillDialog('Mein Motiv', { issuer: 'Beispielbank', tags: 'Visa, Gold' });
|
||||||
sendForm();
|
sendForm();
|
||||||
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
|
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
|
||||||
expect(mocks.publishSavedCard).toHaveBeenCalledWith(
|
expect(mocks.publishSavedCard).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ id: 'a', name: 'Alpha' }),
|
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();
|
expect(publishDialogEl()).toBeNull();
|
||||||
const link = tileFor('a').querySelector<HTMLAnchorElement>('a[data-action="view"]')!;
|
const link = tileFor('a').querySelector<HTMLAnchorElement>('a[data-action="view"]')!;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getCurrentUser, onAuthChange } from '../account/auth';
|
|||||||
import { cardsService, normalizeName, type SavedCard } from '../account/cards';
|
import { cardsService, normalizeName, type SavedCard } from '../account/cards';
|
||||||
import { errorMessage, errorType } from '../account/errors';
|
import { errorMessage, errorType } from '../account/errors';
|
||||||
import { requireLogin } from '../account/login-dialog';
|
import { requireLogin } from '../account/login-dialog';
|
||||||
|
import { requireUsername } from '../account/username-dialog';
|
||||||
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
|
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
|
||||||
import { clampCrop } from '../card/crop';
|
import { clampCrop } from '../card/crop';
|
||||||
import { CARD_W } from '../card/geometry';
|
import { CARD_W } from '../card/geometry';
|
||||||
@@ -404,6 +405,8 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
|
|||||||
|
|
||||||
function startPublish(): void {
|
function startPublish(): void {
|
||||||
void run(async () => {
|
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) });
|
await publishDialog({ cardName: card.name, signal: disposal.signal, submit: (form) => publishWithCheck(card, form) });
|
||||||
}).then(() => focusControl('[data-action="view"], [data-action="withdraw"], [data-action="publish"]'));
|
}).then(() => focusControl('[data-action="view"], [data-action="withdraw"], [data-action="publish"]'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ const report = (n: number, reason: string): GalleryReport => ({
|
|||||||
createdAt: '2026-10-05T12:00:00.000Z',
|
createdAt: '2026-10-05T12:00:00.000Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((res) => {
|
||||||
|
resolve = res;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
const settle = async () => {
|
const settle = async () => {
|
||||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
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 saveBlobAs = vi.fn<(b: Blob, name: string) => void>();
|
||||||
const withLogin = vi.fn(<T>(fn: () => Promise<T>): Promise<T> => fn());
|
const withLogin = vi.fn(<T>(fn: () => Promise<T>): Promise<T> => fn());
|
||||||
const getUser = vi.fn<() => Promise<User | null>>();
|
const getUser = vi.fn<() => Promise<User | null>>();
|
||||||
|
/** Die Benutzernamen (eine Abfrage für die Konten der Seite) und die Hörer des eigenen Umbenennens. */
|
||||||
|
const usernamesFor = vi.fn<CommunityDeps['usernamesFor']>();
|
||||||
|
let nameListeners: Set<(userId: string, username: string) => void>;
|
||||||
|
const emitName = (userId: string, username: string) => {
|
||||||
|
for (const fn of [...nameListeners]) fn(userId, username);
|
||||||
|
};
|
||||||
|
|
||||||
function deps(): CommunityDeps {
|
function deps(): CommunityDeps {
|
||||||
return {
|
return {
|
||||||
@@ -81,6 +95,11 @@ function deps(): CommunityDeps {
|
|||||||
setVisibility,
|
setVisibility,
|
||||||
currentUser: () => user,
|
currentUser: () => user,
|
||||||
getUser,
|
getUser,
|
||||||
|
usernamesFor,
|
||||||
|
onUsernameChange: (fn) => {
|
||||||
|
nameListeners.add(fn);
|
||||||
|
return () => nameListeners.delete(fn);
|
||||||
|
},
|
||||||
onAuthChange: (fn) => {
|
onAuthChange: (fn) => {
|
||||||
authListeners.add(fn);
|
authListeners.add(fn);
|
||||||
return () => authListeners.delete(fn);
|
return () => authListeners.delete(fn);
|
||||||
@@ -114,7 +133,7 @@ const errorText = () => q('[role="alert"]')?.textContent ?? '';
|
|||||||
const statusText = () => q('.detail-info [role="status"]')?.textContent ?? '';
|
const statusText = () => q('.detail-info [role="status"]')?.textContent ?? '';
|
||||||
|
|
||||||
beforeEach(() => {
|
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();
|
fn.mockReset();
|
||||||
}
|
}
|
||||||
getCard.mockResolvedValue(card());
|
getCard.mockResolvedValue(card());
|
||||||
@@ -123,8 +142,10 @@ beforeEach(() => {
|
|||||||
withdrawCard.mockResolvedValue(undefined);
|
withdrawCard.mockResolvedValue(undefined);
|
||||||
setVisibility.mockResolvedValue(undefined);
|
setVisibility.mockResolvedValue(undefined);
|
||||||
getUser.mockResolvedValue(null);
|
getUser.mockResolvedValue(null);
|
||||||
|
usernamesFor.mockResolvedValue(new Map()); // das Konto des Autors hat kein Profil: es gilt der alte Anzeigename
|
||||||
user = null;
|
user = null;
|
||||||
authListeners = new Set();
|
authListeners = new Set();
|
||||||
|
nameListeners = new Set();
|
||||||
urlCounter = 0;
|
urlCounter = 0;
|
||||||
revoked.length = 0;
|
revoked.length = 0;
|
||||||
URL.revokeObjectURL = vi.fn((url: string) => {
|
URL.revokeObjectURL = vi.fn((url: string) => {
|
||||||
@@ -354,6 +375,135 @@ describe('Detailseite: Karte', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Detailseite: „von @name“', () => {
|
||||||
|
const meta = () => q('.detail-meta')?.textContent;
|
||||||
|
|
||||||
|
it('fragt den Namen des Autors einmal je geladener Karte, mit seiner Konto-ID', async () => {
|
||||||
|
await mount();
|
||||||
|
expect(usernamesFor).toHaveBeenCalledExactlyOnceWith(['u1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt „von @name“ und das Datum; ein Profil geht dem alten Anzeigenamen vor', async () => {
|
||||||
|
usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso.K']]));
|
||||||
|
await mount(); // die Karte trägt noch den alten Anzeigenamen „Kenso“
|
||||||
|
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Profil: der alte Anzeigename (ohne @), sonst „von Anonym“', async () => {
|
||||||
|
await mount();
|
||||||
|
expect(meta()).toBe('von Kenso · 04.10.2026');
|
||||||
|
unmount?.();
|
||||||
|
getCard.mockResolvedValue(card({ displayName: '' }));
|
||||||
|
await mount();
|
||||||
|
expect(meta()).toBe('von Anonym · 04.10.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Karte steht, ohne auf den Namen zu warten; er erscheint, sobald er da ist', async () => {
|
||||||
|
const lookup = deferred<Map<string, string>>();
|
||||||
|
usernamesFor.mockReturnValue(lookup.promise);
|
||||||
|
await mount();
|
||||||
|
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
|
||||||
|
expect(q('.detail-card img')).not.toBeNull();
|
||||||
|
expect(meta()).toBe('von Kenso · 04.10.2026');
|
||||||
|
lookup.resolve(new Map([['u1', 'Kenso.K']]));
|
||||||
|
await settle();
|
||||||
|
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schlägt die Abfrage fehl, bleibt es beim Ersatz: kein Fehlertext, die Seite geht weiter', async () => {
|
||||||
|
usernamesFor.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
|
await mount();
|
||||||
|
expect(meta()).toBe('von Kenso · 04.10.2026');
|
||||||
|
expect(errorText()).toBe('');
|
||||||
|
expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Karte ohne Konto-ID (Altbestand): keine Abfrage, der Ersatz', async () => {
|
||||||
|
getCard.mockResolvedValue(card({ ownerId: '', displayName: '' }));
|
||||||
|
await mount();
|
||||||
|
expect(usernamesFor).not.toHaveBeenCalled();
|
||||||
|
expect(meta()).toBe('von Anonym · 04.10.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Name erscheint als Text, nie als HTML', async () => {
|
||||||
|
usernamesFor.mockResolvedValue(new Map([['u1', '<b>x</b>']]));
|
||||||
|
await mount();
|
||||||
|
expect(meta()).toBe('von @<b>x</b> · 04.10.2026');
|
||||||
|
expect(q('.detail-meta b')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eigenes Umbenennen: die Detailseite zeigt sofort den neuen Namen; ein anderes Konto ändert nichts', async () => {
|
||||||
|
usernamesFor.mockResolvedValue(new Map([['u1', 'Alt']]));
|
||||||
|
await mount();
|
||||||
|
expect(meta()).toBe('von @Alt · 04.10.2026');
|
||||||
|
emitName('u2', 'Fremd');
|
||||||
|
expect(meta()).toBe('von @Alt · 04.10.2026');
|
||||||
|
emitName('u1', 'Neu');
|
||||||
|
expect(meta()).toBe('von @Neu · 04.10.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Autor ohne Profil wählt einen Namen (eigene Karte): „von @name“ ersetzt den Ersatz sofort', async () => {
|
||||||
|
await mount();
|
||||||
|
expect(meta()).toBe('von Kenso · 04.10.2026');
|
||||||
|
emitName('u1', 'Kenso.K');
|
||||||
|
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dieselbe Meldung noch einmal schreibt die Zeile nicht neu', async () => {
|
||||||
|
usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||||||
|
await mount();
|
||||||
|
const line = q('.detail-meta')!;
|
||||||
|
const text = line.firstChild;
|
||||||
|
emitName('u1', 'Kenso');
|
||||||
|
expect(q('.detail-meta')).toBe(line);
|
||||||
|
expect(line.firstChild).toBe(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Antwort für eine inzwischen verlassene Ansicht (die Karte ist nicht mehr zu sehen) ändert nichts', async () => {
|
||||||
|
user = owner;
|
||||||
|
getCard.mockResolvedValue(card({ hidden: true }));
|
||||||
|
const lookup = deferred<Map<string, string>>();
|
||||||
|
usernamesFor.mockReturnValue(lookup.promise);
|
||||||
|
await mount();
|
||||||
|
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
|
||||||
|
login(null); // abgemeldet: die ausgeblendete Karte ist für niemand mehr sichtbar
|
||||||
|
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
|
||||||
|
const html = root.innerHTML;
|
||||||
|
lookup.resolve(new Map([['u1', 'Kenso.K']]));
|
||||||
|
await settle();
|
||||||
|
expect(root.innerHTML).toBe(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Aufräumen: der Hörer des Umbenennens ist abgemeldet, eine später eintreffende Antwort ändert nichts', async () => {
|
||||||
|
const lookup = deferred<Map<string, string>>();
|
||||||
|
usernamesFor.mockReturnValue(lookup.promise);
|
||||||
|
await mount();
|
||||||
|
expect(nameListeners.size).toBe(1);
|
||||||
|
unmount?.();
|
||||||
|
unmount = null;
|
||||||
|
expect(nameListeners.size).toBe(0);
|
||||||
|
const html = root.innerHTML;
|
||||||
|
lookup.resolve(new Map([['u1', 'Kenso.K']]));
|
||||||
|
emitName('u1', 'Neu');
|
||||||
|
await settle();
|
||||||
|
expect(root.innerHTML).toBe(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Karte, die nicht (mehr) verfügbar ist, fragt keinen Namen', async () => {
|
||||||
|
getCard.mockResolvedValue(null);
|
||||||
|
await mount();
|
||||||
|
expect(usernamesFor).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Englisch: „by @name“', async () => {
|
||||||
|
setLang('en', { persist: false });
|
||||||
|
usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso.K']]));
|
||||||
|
await mount();
|
||||||
|
expect(meta()).toBe('by @Kenso.K · Oct 4, 2026');
|
||||||
|
expect(germanLeft(root)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Detailseite: Rechte beim Ausführen', () => {
|
describe('Detailseite: Rechte beim Ausführen', () => {
|
||||||
it('Admin-Recht verloren, Knopf noch im DOM: Ausblenden sendet nichts', async () => {
|
it('Admin-Recht verloren, Knopf noch im DOM: Ausblenden sendet nichts', async () => {
|
||||||
user = admin;
|
user = admin;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
// Detailseite „/community/<id>“: große Karte, Angaben, Herunterladen, „Als Vorlage gestalten“, Melden.
|
// Detailseite „/community/<id>“: große Karte, Angaben, Herunterladen, „Als Vorlage gestalten“, Melden.
|
||||||
// Autor: Zurückziehen. Admin (Label `admin`): Ausblenden/Einblenden, Löschen, Meldungszahl und Gründe.
|
// 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,
|
// 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“.
|
// 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).
|
// Alle Texte kommen in der Sprache der Seite (t()); ein Sprachwechsel baut die Seite neu auf (main.ts).
|
||||||
import '../community/community.css';
|
import '../community/community.css';
|
||||||
import { currentUser, getCurrentUser, isAdmin, onAuthChange, type User } from '../account/auth';
|
import { currentUser, getCurrentUser, isAdmin, onAuthChange, type User } from '../account/auth';
|
||||||
import { errorMessage } from '../account/errors';
|
import { errorMessage } from '../account/errors';
|
||||||
|
import { byline, onUsernameChange, usernamesFor } from '../account/profile';
|
||||||
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
|
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
|
||||||
import { ApiError, apiErrorText, reportCard, setVisibility, withdrawCard, type ReportReason } from '../community/api';
|
import { ApiError, apiErrorText, reportCard, setVisibility, withdrawCard, type ReportReason } from '../community/api';
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +40,10 @@ export interface CommunityDeps {
|
|||||||
currentUser(): User | null;
|
currentUser(): User | null;
|
||||||
/** Fragt den Anmeldestatus beim Server (bei einem Direktaufruf ist er noch nicht bekannt). */
|
/** Fragt den Anmeldestatus beim Server (bei einem Direktaufruf ist er noch nicht bekannt). */
|
||||||
getUser(): Promise<User | null>;
|
getUser(): Promise<User | null>;
|
||||||
|
/** Die Benutzernamen der Konten (eine Abfrage für alle); scheitert sie, fehlen die Namen (kein Fehler). */
|
||||||
|
usernamesFor(userIds: readonly string[]): Promise<Map<string, string>>;
|
||||||
|
/** 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;
|
onAuthChange(fn: (user: User | null) => void): () => void;
|
||||||
navigate(path: string): void;
|
navigate(path: string): void;
|
||||||
saveBlobAs(blob: Blob, filename: string): void;
|
saveBlobAs(blob: Blob, filename: string): void;
|
||||||
@@ -53,6 +60,8 @@ export const defaultDeps: CommunityDeps = {
|
|||||||
setVisibility,
|
setVisibility,
|
||||||
currentUser,
|
currentUser,
|
||||||
getUser: getCurrentUser,
|
getUser: getCurrentUser,
|
||||||
|
usernamesFor,
|
||||||
|
onUsernameChange,
|
||||||
onAuthChange,
|
onAuthChange,
|
||||||
navigate,
|
navigate,
|
||||||
saveBlobAs,
|
saveBlobAs,
|
||||||
@@ -107,6 +116,8 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
|||||||
let busy: Busy = null;
|
let busy: Busy = null;
|
||||||
let imageBlob: Blob | null = null;
|
let imageBlob: Blob | null = null;
|
||||||
let imageUrl: string | 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<string, string>();
|
||||||
|
|
||||||
const page = el('div', { class: 'detail-page' });
|
const page = el('div', { class: 'detail-page' });
|
||||||
root.replaceChildren(page);
|
root.replaceChildren(page);
|
||||||
@@ -203,6 +214,8 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
|||||||
// ---- Karte ----
|
// ---- Karte ----
|
||||||
|
|
||||||
const infoSlot = el('div', { class: 'detail-info' });
|
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 noticeSlot = el('div', { class: 'detail-notice-slot' });
|
||||||
const actionsSlot = el('div', { class: 'detail-actions' });
|
const actionsSlot = el('div', { class: 'detail-actions' });
|
||||||
const adminSlot = el('div', { class: 'detail-admin-slot' });
|
const adminSlot = el('div', { class: 'detail-admin-slot' });
|
||||||
@@ -221,6 +234,38 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
|||||||
return b;
|
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<void> {
|
||||||
|
if (c.ownerId === '' || names.has(c.ownerId)) return;
|
||||||
|
let found: Map<string, string>;
|
||||||
|
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. */
|
/** Baut Hinweis, Knöpfe und Admin-Bereich aus dem Zustand neu auf; der Fokus bleibt auf dem gleichen Knopf. */
|
||||||
function renderControls(): void {
|
function renderControls(): void {
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
@@ -310,12 +355,10 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
|||||||
showMessage('', 'info');
|
showMessage('', 'info');
|
||||||
document.title = titleFor(route, c.title);
|
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(
|
infoSlot.replaceChildren(
|
||||||
el('h1', { class: 'detail-title' }, c.title),
|
el('h1', { class: 'detail-title' }, c.title),
|
||||||
...(c.issuer !== '' ? [el('p', { class: 'detail-issuer' }, c.issuer)] : []),
|
...(c.issuer !== '' ? [el('p', { class: 'detail-issuer' }, c.issuer)] : []),
|
||||||
el('p', { class: 'detail-meta' }, date ? `${author} · ${date}` : author),
|
metaEl,
|
||||||
...(c.tags.length > 0
|
...(c.tags.length > 0
|
||||||
? [
|
? [
|
||||||
el(
|
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('p', { class: 'detail-back' }, el('a', { href: '/#community', 'data-link': true }, t('← Zur Galerie'))),
|
||||||
el('div', { class: 'detail-layout' }, imageSlot, infoSlot),
|
el('div', { class: 'detail-layout' }, imageSlot, infoSlot),
|
||||||
);
|
);
|
||||||
|
renderMeta();
|
||||||
renderControls();
|
renderControls();
|
||||||
startImage(c);
|
startImage(c);
|
||||||
void ensureReports();
|
void ensureReports();
|
||||||
|
void loadAuthor(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Aktionen ----
|
// ---- Aktionen ----
|
||||||
@@ -513,12 +558,16 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
|||||||
void ensureReports();
|
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();
|
void load();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
disposal.abort();
|
disposal.abort();
|
||||||
stopAuth();
|
stopAuth();
|
||||||
|
stopName();
|
||||||
leaveCard();
|
leaveCard();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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 { join, relative } from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
@@ -32,4 +32,21 @@ describe('Einzelne Quellen', () => {
|
|||||||
it('reportsLabel wird nur in pages/format.ts definiert', () => {
|
it('reportsLabel wird nur in pages/format.ts definiert', () => {
|
||||||
expect(definedIn(/\bfunction\s+reportsLabel\b/)).toEqual(['pages/format.ts']);
|
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([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user