feat(i18n): Deutsch/Englisch, Sprachknopf und GitHub-Link in der Kopfzeile
- i18n-Kern im gettext-Stil (t/msg, Wörterbuch en.ts); Startsprache aus localStorage, sonst Browsersprache; <html lang> setzt auch das Inline-Skript in index.html. Ein Test prüft t()/msg() und en.ts in beide Richtungen. - Kopfzeile: GitHub-Link und Sprachknopf (Zielsprache, Beschriftung und lang in der Zielsprache), Fußzeile aus mountFooter, Banner und Seitentitel übersetzt, statisches index.html englisch. - Sprachwechsel ohne Neuladen (main.ts): Kopfzeile, Banner, Konto-Menü und Fußzeile neu aufgebaut, rerender() des Routers baut die Seite neu auf, der Editor-Store bleibt; der Appwrite-Client bekommt die Sprache. - Kopfzeile bleibt in einer Zeile: einzeilig ab 1000 px (vorher 900), GitHub-Text ab 1230 px, auf schmalen Telefonen steht „Anmelden“ neben der Suche. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9455013833
commit
e1fb632cdd
21 files changed
+1321
-66
No files matched your search
@@ -0,0 +1,181 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Der Einstieg läuft mit echtem Router, echter Kopfzeile, Banner, Fußzeile und Konto-Menü; Seiten, Anmeldung und Appwrite sind Attrappen.
|
||||
const mocks = vi.hoisted(() => {
|
||||
const page = () => vi.fn((..._args: unknown[]) => vi.fn()); // baut eine Seite auf und gibt deren Aufräum-Funktion zurück
|
||||
return {
|
||||
setLocale: vi.fn(),
|
||||
getCurrentUser: vi.fn(),
|
||||
authListeners: new Set<(user: null) => void>(),
|
||||
pages: { editor: page(), cards: page(), community: page(), reset: page(), home: page() },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./account/appwrite', () => ({ client: { setLocale: mocks.setLocale } }));
|
||||
vi.mock('./account/auth', () => ({
|
||||
getCurrentUser: mocks.getCurrentUser,
|
||||
currentUser: () => null,
|
||||
logout: vi.fn(),
|
||||
onAuthChange: (fn: (user: null) => void) => {
|
||||
mocks.authListeners.add(fn);
|
||||
return () => mocks.authListeners.delete(fn);
|
||||
},
|
||||
}));
|
||||
vi.mock('./account/login-dialog', () => ({ requireLogin: vi.fn() }));
|
||||
vi.mock('./editor/auth-reset', () => ({ bindEditorToAuth: vi.fn() }));
|
||||
vi.mock('./editor/editor-page', () => ({ mountEditor: mocks.pages.editor }));
|
||||
vi.mock('./pages/cards-page', () => ({ mountCardsPage: mocks.pages.cards }));
|
||||
vi.mock('./pages/community-page', () => ({ mountCommunityPage: mocks.pages.community }));
|
||||
vi.mock('./pages/reset-page', () => ({ mountResetPage: mocks.pages.reset }));
|
||||
vi.mock('./pages/home-page', () => ({ mountHomePage: mocks.pages.home }));
|
||||
|
||||
const DE_FOOTER = 'Inoffizielle Community-Seite für die App AirCard-iOS';
|
||||
const EN_FOOTER = 'Unofficial community site for the AirCard-iOS app';
|
||||
|
||||
const slot = () => document.getElementById('account-slot') as HTMLElement;
|
||||
const langButton = () => document.querySelector<HTMLButtonElement>('button.lang-btn')!;
|
||||
const navTexts = () => [...document.querySelectorAll('nav.app-nav a[data-link]')].map((a) => a.textContent);
|
||||
const signIn = () => [...slot().querySelectorAll('button')].find((b) => b.textContent === 'Anmelden');
|
||||
|
||||
/** Startet main.ts frisch (es läuft beim Import) auf der Adresse `path`; `storedLang` ist die gespeicherte Sprachwahl (null: keine). */
|
||||
async function start(path = '/', storedLang: string | null = 'de') {
|
||||
vi.resetModules();
|
||||
localStorage.clear();
|
||||
if (storedLang) localStorage.setItem('aircard.lang', storedLang);
|
||||
history.replaceState(null, '', path);
|
||||
document.body.innerHTML =
|
||||
'<div id="donate-banner"></div>' +
|
||||
'<header id="app-header"><a class="brand" href="/" data-link><span>AirCard-IOS</span></a><div id="account-slot"></div></header>' +
|
||||
'<main id="app-root"></main>' +
|
||||
'<footer id="app-footer"></footer>';
|
||||
await import('./main');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.setLocale.mockReset();
|
||||
mocks.getCurrentUser.mockReset().mockResolvedValue(null); // Gast: der Anmeldestatus ist „abgemeldet“
|
||||
mocks.authListeners.clear();
|
||||
for (const page of Object.values(mocks.pages)) page.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
describe('Start', () => {
|
||||
it('Rahmen in der gespeicherten Sprache; der Appwrite-Client bekommt sie; die Seite der Adresse wird aufgebaut', async () => {
|
||||
await start('/gestalten');
|
||||
await vi.waitFor(() => expect(signIn()).toBeDefined());
|
||||
expect(document.documentElement.lang).toBe('de');
|
||||
expect(mocks.setLocale).toHaveBeenCalledExactlyOnceWith('de');
|
||||
expect(navTexts()).toEqual(['Community', 'Gestalten', 'Meine Karten']);
|
||||
expect(document.querySelector('.donate-banner-link')?.textContent).toBe('Unterstützen →');
|
||||
expect(document.getElementById('app-footer')?.textContent).toContain(DE_FOOTER);
|
||||
expect(langButton().textContent).toBe('EN');
|
||||
expect(document.title).toBe('Gestalten – AirCard-IOS');
|
||||
expect(mocks.pages.editor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ohne gespeicherte Wahl entscheidet die Browsersprache; der Start speichert nichts', async () => {
|
||||
vi.stubGlobal('navigator', { languages: ['en-US'], language: 'en-US' });
|
||||
await start('/karten', null);
|
||||
expect(document.documentElement.lang).toBe('en');
|
||||
expect(mocks.setLocale).toHaveBeenCalledExactlyOnceWith('en');
|
||||
expect(navTexts()).toEqual(['Community', 'Create', 'My cards']);
|
||||
expect(document.getElementById('app-footer')?.textContent).toContain(EN_FOOTER);
|
||||
expect(document.title).toBe('My cards – AirCard-IOS');
|
||||
expect(langButton().textContent).toBe('DE');
|
||||
expect(localStorage.getItem('aircard.lang')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sprachwechsel', () => {
|
||||
it('Kopfzeile, Banner, Fußzeile und Titel wechseln ohne Neuladen; der Client bekommt die Sprache', async () => {
|
||||
await start('/gestalten');
|
||||
const root = document.getElementById('app-root');
|
||||
|
||||
langButton().click();
|
||||
|
||||
expect(document.documentElement.lang).toBe('en');
|
||||
expect(localStorage.getItem('aircard.lang')).toBe('en');
|
||||
expect(mocks.setLocale).toHaveBeenLastCalledWith('en');
|
||||
expect(navTexts()).toEqual(['Community', 'Create', 'My cards']);
|
||||
expect(document.querySelector('.donate-banner-link')?.textContent).toBe('Support us →');
|
||||
expect(document.getElementById('app-footer')?.textContent).toContain(EN_FOOTER);
|
||||
expect(langButton().textContent).toBe('DE');
|
||||
expect(document.title).toBe('Create – AirCard-IOS');
|
||||
expect(document.getElementById('app-root')).toBe(root); // dasselbe Dokument, die Seite wurde nicht neu geladen
|
||||
expect(location.pathname).toBe('/gestalten'); // die Adresse bleibt deutsch und unverändert
|
||||
|
||||
langButton().click(); // und zurück
|
||||
expect(document.documentElement.lang).toBe('de');
|
||||
expect(mocks.setLocale).toHaveBeenLastCalledWith('de');
|
||||
expect(navTexts()).toEqual(['Community', 'Gestalten', 'Meine Karten']);
|
||||
expect(document.getElementById('app-footer')?.textContent).toContain(DE_FOOTER);
|
||||
});
|
||||
|
||||
it('die aktuelle Seite wird aufgeräumt und im selben Container neu gebaut; der Editor-Store bleibt dasselbe Objekt', async () => {
|
||||
const cleanup = vi.fn();
|
||||
mocks.pages.editor.mockImplementation(() => cleanup);
|
||||
await start('/gestalten');
|
||||
const [root, store] = mocks.pages.editor.mock.calls[0] as [HTMLElement, { get(): { cardName: string }; set(patch: object): void }];
|
||||
store.set({ cardName: 'Mein Entwurf' }); // ein ungespeicherter Entwurf
|
||||
|
||||
langButton().click();
|
||||
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pages.editor).toHaveBeenCalledTimes(2);
|
||||
const [rootAfter, storeAfter] = mocks.pages.editor.mock.calls[1] as [HTMLElement, typeof store];
|
||||
expect(rootAfter).toBe(root);
|
||||
expect(storeAfter).toBe(store);
|
||||
expect(storeAfter.get().cardName).toBe('Mein Entwurf');
|
||||
});
|
||||
|
||||
it('das Konto-Menü wird neu gebaut und zeigt sich sofort, wenn der Anmeldestatus schon bekannt ist', async () => {
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(signIn()).toBeDefined());
|
||||
expect(mocks.authListeners.size).toBe(1);
|
||||
|
||||
langButton().click();
|
||||
|
||||
expect(signIn()).toBeDefined(); // kein leerer Platz bis zur nächsten Änderung
|
||||
expect(mocks.authListeners.size).toBe(1); // das alte Menü hat sich abgemeldet
|
||||
expect(mocks.getCurrentUser).toHaveBeenCalledTimes(1); // der Status wird nicht noch einmal geholt
|
||||
});
|
||||
|
||||
it('kommt der Wechsel vor dem Anmeldestatus, bleibt das neue Konto-Menü leer, bis er da ist', async () => {
|
||||
let answer!: (user: null) => void;
|
||||
mocks.getCurrentUser.mockReturnValue(new Promise<null>((resolve) => (answer = resolve)));
|
||||
await start('/');
|
||||
expect(slot().childElementCount).toBe(0);
|
||||
|
||||
langButton().click();
|
||||
expect(slot().childElementCount).toBe(0);
|
||||
expect(mocks.authListeners.size).toBe(1);
|
||||
|
||||
answer(null);
|
||||
await vi.waitFor(() => expect(signIn()).toBeDefined());
|
||||
});
|
||||
|
||||
it('der Fokus bleibt auf dem Sprachknopf, auch wenn die Kopfzeile neu gebaut wird', async () => {
|
||||
await start('/');
|
||||
const old = langButton();
|
||||
old.focus();
|
||||
expect(document.activeElement).toBe(old);
|
||||
|
||||
old.click();
|
||||
|
||||
expect(langButton()).not.toBe(old);
|
||||
expect(document.activeElement).toBe(langButton());
|
||||
});
|
||||
|
||||
it('ohne Fokus auf dem Sprachknopf wird kein Fokus gesetzt', async () => {
|
||||
await start('/');
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
|
||||
langButton().click();
|
||||
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user