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

+171
View File
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { User } from '../account/auth';
import { ApiError, type ReportReason } from '../community/api';
import type { CheckedImage, GalleryCard, GalleryReport } from '../community/gallery';
import { germanLeft } from '../i18n/english-check';
import { setLang } from '../i18n/i18n';
// gallery.ts, api.ts und auth.ts hängen am Appwrite-Client; die Seite bekommt alles über ihre Abhängigkeiten.
vi.mock('../account/appwrite', () => ({
@@ -817,3 +819,172 @@ describe('Detailseite: Admin', () => {
expect(q('img')).toBeNull();
});
});
describe('Detailseite auf Englisch', () => {
beforeEach(() => setLang('en', { persist: false }));
it('Ansichten ohne Karte: nicht verfügbar, Laden, Ladefehler', async () => {
getCard.mockResolvedValue(null);
await mount();
expect(q('h1')?.textContent).toBe('This card is no longer available.');
expect(q('a[href="/#community"]')?.textContent).toBe('Back to gallery');
expect(document.title).toBe('Community – AirCard-IOS');
expect(germanLeft(root)).toEqual([]);
unmount?.();
getCard.mockReturnValue(new Promise(() => {}));
await mount();
expect(q('[role="status"]')?.textContent).toBe('Loading card …');
unmount?.();
getCard.mockReset();
getCard.mockRejectedValueOnce(new TypeError('offline'));
await mount();
expect(errorText()).toBe('The card couldn’t be loaded right now.');
expect(buttonByText('Try again')).toBeDefined();
expect(germanLeft(root)).toEqual([]);
});
it('Karte eines Gastes: Datum, Verfasser, Alt-Text, Knöpfe, Bildfehler und Herunterladen', async () => {
getCard.mockResolvedValue(card({ displayName: '' }));
loadImage.mockReturnValueOnce(new Promise(() => {}));
await mount();
expect(q('.detail-card')?.textContent).toBe('Loading image …');
unmount?.();
loadImage.mockImplementation(async () => image());
await mount();
expect(q('.detail-meta')?.textContent).toBe('by Anonymous · Oct 4, 2026');
expect(q('.detail-card img')?.getAttribute('alt')).toBe('Community card “Mitternachtsblau”');
expect(q('.detail-tag')?.getAttribute('title')).toBe('Show cards with this tag');
expect(q('.detail-back')?.textContent).toBe('← Back to gallery');
expect(buttonTexts()).toEqual(['Download', 'Use as template', 'Report']);
expect(germanLeft(root)).toEqual([]);
// Bild nicht darstellbar, Herunterladen holt es nach: unterwegs „Downloading …“, danach der Fehler
q('.detail-card img')!.dispatchEvent(new Event('error'));
expect(q('.detail-card')?.textContent).toContain('The image couldn’t be loaded.');
loadImage.mockReturnValueOnce(new Promise(() => {}));
buttonByText('Download')!.click();
expect(buttonTexts()).toContain('Downloading …');
unmount?.();
await mount();
q('.detail-card img')!.dispatchEvent(new Event('error'));
loadImage.mockRejectedValueOnce(new TypeError('offline'));
buttonByText('Download')!.click();
await settle();
expect(errorText()).toBe('The image couldn’t be loaded.');
});
it('Melden-Dialog: Hinweis, wenn er sich nicht öffnen lässt, und der Dialog selbst auf Englisch', async () => {
reportCard.mockRejectedValue(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.', undefined, 'tooManyReports'));
await mount();
buttonByText('Report')!.click();
await settle();
expect([...reportEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
'Inappropriate',
'Real card data',
'Copyright/trademark',
'Spam',
'Other',
]);
const radio = reportEl()!.querySelector<HTMLInputElement>('input[value="spam"]')!;
radio.checked = true;
reportEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await settle();
expect(reportEl()!.querySelector('[role="alert"]')!.textContent).toBe('Too many reports – please try again later.');
unmount?.();
reportEl()?.remove();
const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementation(() => {
throw new Error('nicht darstellbar');
});
try {
await mount();
buttonByText('Report')!.click();
await settle();
expect(errorText()).toBe('The report dialog couldn’t be opened.');
} finally {
spy.mockRestore();
}
});
it('Autor: Zurückziehen mit Rückfrage und die Fehler als Text der Seite (Code, Anmeldung, Netz)', async () => {
user = owner;
await mount();
expect(buttonTexts()).toEqual(['Download', 'Use as template', 'Report', 'Withdraw']);
buttonByText('Withdraw')!.click();
await settle();
expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Withdraw card “Mitternachtsblau”?');
expect(confirmEl()!.querySelector('p')!.textContent).toBe(
'The card disappears from the gallery, and its image and reports are deleted. Your private card is kept.',
);
expect([...confirmEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Cancel', 'Withdraw']);
withdrawCard.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', undefined, 'unavailable'));
confirmButton('Withdraw').click();
await settle();
expect(errorText()).toBe('The service is currently unavailable.');
// ohne bekannten Code zeigt Englisch die allgemeine Meldung zum Status
withdrawCard.mockRejectedValueOnce(new ApiError(403, 'Nur für Administratoren.'));
buttonByText('Withdraw')!.click();
await settle();
confirmButton('Withdraw').click();
await settle();
expect(errorText()).toBe('You are not allowed to do that.');
withLogin.mockRejectedValueOnce(new LoginCancelled());
buttonByText('Withdraw')!.click();
await settle();
confirmButton('Withdraw').click();
await settle();
expect(errorText()).toBe('Please sign in to continue.');
withLogin.mockRejectedValueOnce(new TypeError('offline'));
buttonByText('Withdraw')!.click();
await settle();
confirmButton('Withdraw').click();
await settle();
expect(errorText()).toBe('Can’t reach the server.');
expect(germanLeft(root)).toEqual([]);
});
it('Admin: Moderation, Meldungen mit Grund und Datum, Ausblenden/Einblenden mit Hinweis, Löschen mit Rückfrage', async () => {
user = admin;
getCard.mockResolvedValue(card({ reportCount: 3 }));
listReports.mockResolvedValue([report(1, 'spam'), report(2, 'kartendaten'), report(3, 'unbekannt')]);
await mount();
expect(q('.detail-admin-title')?.textContent).toBe('Moderation');
expect(q('.detail-reports-count')?.textContent).toBe('3 reports');
expect(qa('.detail-reasons li').map((li) => li.textContent)).toEqual([
'Spam · Oct 5, 2026',
'Real card data · Oct 5, 2026',
'unbekannt · Oct 5, 2026', // unbekannte Werte der API bleiben unverändert
]);
expect(buttonTexts()).toEqual(['Download', 'Use as template', 'Report', 'Hide', 'Delete']);
expect(germanLeft(root)).toEqual([]);
buttonByText('Hide')!.click();
await settle();
expect(q('.detail-hidden')?.textContent).toBe('Hidden – This card isn’t visible to others.');
expect(statusText()).toBe('Card hidden.');
buttonByText('Unhide')!.click();
await settle();
expect(statusText()).toBe('Card unhidden.');
buttonByText('Delete')!.click();
await settle();
expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Delete card “Mitternachtsblau”?');
expect(confirmEl()!.querySelector('p')!.textContent).toBe('The card, its image and all reports will be deleted permanently.');
expect([...confirmEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Cancel', 'Delete']);
confirmButton('Cancel').click();
await settle();
// die Gründe lassen sich nicht laden
unmount?.();
listReports.mockRejectedValue(new TypeError('offline'));
await mount();
expect(q('.detail-admin')?.textContent).toContain('The reasons couldn’t be loaded.');
});
});