feat(i18n): Community, Startseite und Meine Karten auf Englisch

Task 11: alle sichtbaren Texte von community/, pages/home-page.ts, pages/community-page.ts, pages/cards-page.ts und pages/format.ts
(Texte, aria-label, title, Platzhalter, Alt-Texte, Rückfragen, Meldungen) gehen durch t()/msg(); en.ts bekommt 82 Einträge (214 → 296).

- Konstanten bleiben deutsche Strings per msg() und gehen beim Anzeigen durch t() (publish.ts, publish-dialog.ts, gallery.ts und die Seiten);
  geworfene Fehler tragen weiter den deutschen Text, damit die Vergleiche gehen. Funktionen, die Text für Nutzer liefern, übersetzen beim
  Zurückgeben (validatePublishForm, publishFailureText, formatDate, reportsLabel). Template-Literale sind Platzhalter geworden ({max},
  {reason}, {name}, {title}, {query}, {count}); der deutsche Text bleibt Zeichen für Zeichen derselbe.
- API-Fehler: jeder ApiError wird mit apiErrorText() angezeigt (Veröffentlichen-Dialog, Detailseite, Meine Karten), nach dem Code des Dienstes
  statt nach dem deutschen Servertext. Logik, die err.message vergleicht (publishCard, 404 beim Zurückziehen), bleibt unverändert.
- Bekannte Reste aus Task 10 behoben: LOGIN_NEEDED ging in publish-dialog.ts, community-page.ts und cards-page.ts ohne t() an die Oberfläche;
  die Meldungen zu Bild lesen/Pixelgrenze/fehlendem Logo (cards-page.ts, GERMAN_MESSAGES in publish-dialog.ts) erscheinen jetzt übersetzt;
  die Zahl der Meldungen in der Logo-Infozeile („3 reports“).
- pages/format.ts: formatDate liefert Deutsch dd.mm.yyyy und Englisch „Oct 4, 2026“ (Intl.DateTimeFormat en-US), reportsLabel „1 report“ /
  „3 reports“. „Herausgeber“ heißt als Bank „Issuer / bank“.
- logos/prepare-logo.ts: der Helfer problem() ist weg, die sechs Stellen schreiben new LogoError(t(MELDUNG)) selbst (gleiches Verhalten);
  so sieht der neue Prüfer unten jede Verwendung einer Konstante.
- i18n/coverage.test.ts: Die Heuristik für deutsche Literale deckt jetzt die ganze Oberfläche ab (account/, card/, community/, editor/, logos/,
  pages/, shell/, main.ts, i18n/api-messages.ts). Sie erkennt zusätzlich Wörter mit Klammer („Ausgeblendet (gemeldet)“) und nimmt rel und
  Namen in Klammerschreibweise (headers['X-Aircard-Jwt']) aus. Acht begründete Ausnahmen kommen dazu (zwei AppwriteException-Meldungen,
  vier interne Logo-Fehler, der Sprachknopf in der Zielsprache). Neu: ein Prüfer über den Syntaxbaum, der verlangt, dass jede Konstante
  mit msg() nur in t(), new Error(), Vergleichen, case und Listen/Mengen vorkommt (auch über Importe und Umbenennungen); ohne Ausnahmen.
- Tests: format.test.ts (beide Sprachen), Englisch-Blöcke in publish-dialog, gallery-grid, home-page, community-page, cards-page und
  logo-info (Texte, Fehlerwege, „Publishing failed – This card is already published.“, kein deutscher Wörterbuchtext auf der Seite). Die
  Hilfen texts()/germanLeft() liegen jetzt in i18n/english-check.ts (vorher in english-ui.test.ts). Der bestehende Englisch-Test in
  cards-page.test.ts sucht den Knopf „Download“ statt „Herunterladen“.
- Vitest 53 Dateien, 1812 Tests grün (vorher 1779), tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 00:34:10 +00:00
1 parent 1ff1873b27
commit 7e66cd0f0f
20 files changed
+1164 -211

No files matched your search

+176 -4
View File
@@ -4,6 +4,8 @@ import type { SavedCard } from '../account/cards';
import { DEFAULT_CONFIG, type TemplateId } from '../card/config';
import { createStore, initialEditorState } from '../editor/state';
import type { GalleryCard } from '../community/gallery';
import { germanLeft } from '../i18n/english-check';
import { setLang } from '../i18n/i18n';
const mocks = vi.hoisted(() => ({
list: vi.fn(),
@@ -317,11 +319,11 @@ describe('Meine Karten: Herunterladen mit fehlendem Logo', () => {
mocks.exportCard.mockResolvedValue(png);
});
async function download(template: TemplateId): Promise<void> {
async function download(template: TemplateId, label = 'Herunterladen'): Promise<void> {
mocks.list.mockResolvedValue([onTemplate(template)]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Herunterladen').click();
button(tileFor('a'), label).click();
}
it.each(['minimal', 'classic', 'signature'] as const)(
@@ -359,9 +361,8 @@ describe('Meine Karten: Herunterladen mit fehlendem Logo', () => {
});
it('Englisch: dieselbe Meldung, übersetzt', async () => {
const { setLang } = await import('../i18n/i18n');
setLang('en', { persist: false });
await download('signature');
await download('signature', 'Download');
await vi.waitFor(() =>
expect(msg().textContent).toBe('This card’s logo is no longer available. Open the card in the editor and choose another one.'),
);
@@ -1269,3 +1270,174 @@ describe('Meine Karten: Zurückziehen', () => {
expect(mocks.withdrawCard).not.toHaveBeenCalled();
});
});
describe('Meine Karten auf Englisch', () => {
beforeEach(() => setLang('en', { persist: false }));
const withdrawNote =
'The published card stays in the community gallery until you withdraw it – after deleting this card, you can only do that on its community page.';
const maybeNote = 'If this card is published, it stays in the gallery – you can withdraw it on its community page.';
it('Zustände: Laden, abgemeldet, leer, Fehler', async () => {
mocks.getCurrentUser.mockReturnValue(new Promise(() => {}));
mount();
expect(text()).toContain('Loading cards …');
expect(root.querySelector('h1')!.textContent).toBe('My cards');
expect(root.querySelector('section')!.getAttribute('aria-label')).toBe('Saved cards');
unmount?.();
mocks.getCurrentUser.mockResolvedValue(null);
mount();
await vi.waitFor(() => expect(text()).toContain('Sign in to see your cards.'));
expect(button(root, 'Sign in')).toBeTruthy();
expect(germanLeft(root)).toEqual([]);
unmount?.();
mocks.getCurrentUser.mockResolvedValue(user);
mocks.list.mockResolvedValue([]);
mount();
await vi.waitFor(() => expect(text()).toContain('No cards saved yet.'));
expect(root.querySelector('a[data-link]')!.textContent).toBe('Go to editor');
unmount?.();
mocks.list.mockRejectedValue(new TypeError('offline'));
mount();
await vi.waitFor(() => expect(text()).toContain('Can’t reach the server.'));
expect(button(root, 'Try again')).toBeTruthy();
expect(germanLeft(root)).toEqual([]);
});
it('Kacheln: Datum, Vorschau-Texte, Knöpfe, Badges, Umbenennen und der Fehler beim Status', async () => {
const updatedAt = new Date(2026, 9, 4, 12).toISOString();
mocks.list.mockResolvedValue([card('a', 'Alpha', { updatedAt }), card('b', 'Beta'), card('c', 'Gamma'), card('d', 'Delta')]);
mocks.myPublications.mockResolvedValue(
new Map([
['a', published('g1', 'a')],
['c', published('g3', 'c', { hidden: true })],
]),
);
mocks.loadPreview.mockImplementation(async (c: SavedCard) => {
if (c.id === 'b') throw Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' });
if (c.id === 'd') return new Promise<Blob>(() => {});
return new Blob(['p'], { type: 'image/webp' });
});
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(4));
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
expect(tileFor('a').querySelector('.card-date')!.textContent).toBe('Oct 4, 2026');
expect(tileFor('a').querySelector('img')!.getAttribute('alt')).toBe('Preview of “Alpha”');
await vi.waitFor(() => expect(tileFor('b').textContent).toContain('No preview'));
expect(tileFor('d').textContent).toContain('Loading preview …');
await vi.waitFor(() => expect(badge('a')).toBe('Published'));
expect(labels(tileFor('a'))).toEqual(['Open', 'Download', 'Rename', 'View', 'Withdraw', 'Delete']);
expect(labels(tileFor('b'))).toEqual(['Open', 'Download', 'Rename', 'Publish', 'Delete']);
expect(badge('c')).toBe('Hidden (reported)');
expect(labels(tileFor('c'))).toEqual(['Open', 'Download', 'Rename', 'Withdraw', 'Delete']);
button(tileFor('b'), 'Rename').click();
expect(tileFor('b').querySelector('input')!.getAttribute('aria-label')).toBe('New name for “Beta”');
expect([...tileFor('b').querySelectorAll('.card-rename button')].map((b) => b.textContent)).toEqual(['Save', 'Cancel']);
expect(germanLeft(root)).toEqual([]);
unmount?.();
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.myPublications.mockRejectedValue(new TypeError('offline'));
mount();
await vi.waitFor(() => expect(text()).toContain('The publishing status couldn’t be loaded.'));
expect(button(root, 'Try again')).toBeTruthy();
expect(germanLeft(root)).toEqual([]);
});
it('Löschen: Rückfrage mit den Hinweisen zur Veröffentlichung (bekannt, unbekannt, keine)', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
mount();
await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Publish'));
const ask = async (id: string) => {
button(tileFor(id), 'Delete').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
const shown = dialog()!;
return { title: shown.querySelector('h2')!.textContent, text: shown.querySelector('p')!.textContent, buttons: [...shown.querySelectorAll('button')].map((b) => b.textContent) };
};
const cancel = async () => {
button(dialog()!, 'Cancel').click();
await vi.waitFor(() => expect(dialog()).toBeNull());
};
expect(await ask('a')).toEqual({
title: 'Delete card “Alpha”?',
text: `The card and its image will be deleted permanently. ${withdrawNote}`,
buttons: ['Cancel', 'Delete'],
});
await cancel();
expect((await ask('b')).text).toBe('The card and its image will be deleted permanently.');
await cancel();
unmount?.();
// der Status ist noch unbekannt: allgemeiner Hinweis
mocks.myPublications.mockReturnValue(new Promise(() => {}));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
expect((await ask('a')).text).toBe(`The card and its image will be deleted permanently. ${maybeNote}`);
await cancel();
});
it('Zurückziehen: Rückfrage und der Fehler des Dienstes über seinen Code', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
mocks.withdrawCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', undefined, 'unavailable'));
mount();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Withdraw'));
button(tileFor('a'), 'Withdraw').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
expect(dialog()!.querySelector('h2')!.textContent).toBe('Withdraw card “Mein Motiv”?');
expect(dialog()!.querySelector('p')!.textContent).toBe(
'The card disappears from the gallery, and its image and reports are deleted. Your private card is kept.',
);
expect([...dialog()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Cancel', 'Withdraw']);
button(dialog()!, 'Withdraw').click();
await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('The service is currently unavailable.'));
// ohne bekannten Code zeigt Englisch die allgemeine Meldung zum Status
mocks.withdrawCard.mockRejectedValue(new ApiError(403, 'Nur für Administratoren.'));
await vi.waitFor(() => expect(button(tileFor('a'), 'Withdraw').disabled).toBe(false));
button(tileFor('a'), 'Withdraw').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
button(dialog()!, 'Withdraw').click();
await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('You are not allowed to do that.'));
expect(germanLeft(root)).toEqual([]);
});
it('Herunterladen: „Creating …“ unterwegs, die Fehler (Lesen, Pixel, Anmeldung) als Text der Seite', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.loadImage.mockReturnValueOnce(new Promise<Blob>(() => {}));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Download').click();
expect(labels(tileFor('a'))).toContain('Creating …');
unmount?.();
mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/png' }));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
const msg = () => tileFor('a').querySelector('.card-msg')!.textContent;
mocks.decodeImage.mockRejectedValueOnce(new Error('Das Bild konnte nicht gelesen werden.'));
button(tileFor('a'), 'Download').click();
await vi.waitFor(() => expect(msg()).toBe('The image couldn’t be read.'));
mocks.decodeImage.mockRejectedValueOnce(new Error('Das Bild ist zu groß (max. 40 Megapixel).'));
await vi.waitFor(() => expect(button(tileFor('a'), 'Download').disabled).toBe(false));
button(tileFor('a'), 'Download').click();
await vi.waitFor(() => expect(msg()).toBe('The image is too large (max. 40 megapixels).'));
// die Sitzung ist weg und der Login-Dialog wird geschlossen
unmount?.();
mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null);
mocks.requireLogin.mockResolvedValue(false);
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Download').click();
await vi.waitFor(() => expect(msg()).toBe('Please sign in to continue.'));
expect(germanLeft(root)).toEqual([]);
});
});