diff --git a/web/src/editor/state.test.ts b/web/src/editor/state.test.ts index 07dc27c..64ab033 100644 --- a/web/src/editor/state.test.ts +++ b/web/src/editor/state.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG, DEFAULT_CROP } from '../card/config'; import type { CardImage } from '../card/render'; -import { createStore, initialEditorState, setImage } from './state'; +import { setLang } from '../i18n/i18n'; +import { createStore, defaultCardName, followLanguage, initialEditorState, setImage, type SavedCardRef } from './state'; const fakeImage = (w = 2000, h = 1000): CardImage => ({ source: {} as CanvasImageSource, w, h }); const fakeBlob = () => new Blob(['x'], { type: 'image/png' }); @@ -119,3 +120,110 @@ describe('setImage', () => { expect(fn).toHaveBeenCalledTimes(1); }); }); + +describe('defaultCardName', () => { + it('der Standardname in der Sprache der Seite; initialEditorState trägt ihn', () => { + expect(defaultCardName()).toBe('Meine Karte'); + expect(initialEditorState().cardName).toBe('Meine Karte'); + setLang('en', { persist: false }); + expect(defaultCardName()).toBe('My card'); + expect(initialEditorState().cardName).toBe('My card'); + }); +}); + +describe('followLanguage: der Standardname einer neuen Karte folgt der Sprache', () => { + /** Abmeldungen der Hörer dieses Tests; sie werden nach jedem Test aufgehoben (die Hörerliste von i18n.ts lebt so lange wie die Datei). */ + const stops: Array<() => void> = []; + afterEach(() => { + for (const stop of stops.splice(0)) stop(); + }); + + const bound = (patch: Partial> = {}) => { + const store = createStore({ ...initialEditorState(), ...patch }); + stops.push(followLanguage(store)); + return store; + }; + const ref = (name: string): SavedCardRef => ({ id: 'k1', name, imageFileId: 'i', previewFileId: 'p', updatedAt: '2026-10-05T10:00:00.000Z' }); + + it('der unberührte Standardname wechselt mit der Sprache, hin und zurück', () => { + const store = bound(); + expect(store.get().cardName).toBe('Meine Karte'); + setLang('en', { persist: false }); + expect(store.get().cardName).toBe('My card'); + setLang('de', { persist: false }); + expect(store.get().cardName).toBe('Meine Karte'); + }); + + it('auch wenn der Editor auf Englisch beginnt', () => { + setLang('en', { persist: false }); + const store = bound(); + expect(store.get().cardName).toBe('My card'); + setLang('de', { persist: false }); + expect(store.get().cardName).toBe('Meine Karte'); + }); + + it('ein selbst gewählter Name bleibt, in jeder Richtung', () => { + const store = bound({ cardName: 'Mein Urlaub' }); + setLang('en', { persist: false }); + expect(store.get().cardName).toBe('Mein Urlaub'); + store.set({ cardName: 'Holiday' }); + setLang('de', { persist: false }); + expect(store.get().cardName).toBe('Holiday'); + }); + + it('der Name wurde geändert und wieder auf den Standardtext zurückgesetzt: er gilt als unberührt', () => { + const store = bound(); + store.set({ cardName: 'Urlaub' }); + store.set({ cardName: 'Meine Karte' }); + setLang('en', { persist: false }); + expect(store.get().cardName).toBe('My card'); + }); + + it('eine gespeicherte Karte behält ihren Namen, auch wenn er dem Standard gleicht', () => { + const store = bound({ saved: ref('Meine Karte'), cardName: 'Meine Karte' }); + setLang('en', { persist: false }); + expect(store.get().cardName).toBe('Meine Karte'); + }); + + it('wird die Karte gespeichert, bevor die Sprache wechselt, bleibt der Name', () => { + const store = bound(); + store.set({ saved: ref('Meine Karte') }); // Speichern unter dem Standardnamen + setLang('en', { persist: false }); + expect(store.get().cardName).toBe('Meine Karte'); + }); + + it('ein leerer oder fremder Name wird nicht angefasst', () => { + const store = bound({ cardName: '' }); + setLang('en', { persist: false }); + expect(store.get().cardName).toBe(''); + }); + + it('der Rest des Zustands bleibt unberührt (Bild, Konfiguration), und es gibt genau eine Benachrichtigung', () => { + const store = bound(); + const image = fakeImage(); + store.set({ image, imageBlob: fakeBlob(), imageChanged: true }); + const before = store.get(); + const fn = vi.fn(); + store.subscribe(fn); + setLang('en', { persist: false }); + expect(fn).toHaveBeenCalledTimes(1); + expect(store.get()).toEqual({ ...before, cardName: 'My card' }); + expect(store.get().image).toBe(image); + }); + + it('derselbe Wechsel ohne Wirkung: gleiche Sprache noch einmal setzen ändert nichts und benachrichtigt nicht', () => { + const store = bound(); + const fn = vi.fn(); + store.subscribe(fn); + setLang('de', { persist: false }); + expect(fn).not.toHaveBeenCalled(); + }); + + it('nach der Abmeldung des Hörers folgt der Name nicht mehr', () => { + const store = createStore(initialEditorState()); + const stop = followLanguage(store); + stop(); + setLang('en', { persist: false }); + expect(store.get().cardName).toBe('Meine Karte'); + }); +}); diff --git a/web/src/editor/state.ts b/web/src/editor/state.ts index 3504118..711c82b 100644 --- a/web/src/editor/state.ts +++ b/web/src/editor/state.ts @@ -2,7 +2,7 @@ // Der Store wird einmal in main.ts erzeugt und überlebt Seitenwechsel. import { DEFAULT_CONFIG, DEFAULT_CROP, type CardConfig } from '../card/config'; import type { CardImage } from '../card/render'; -import { t } from '../i18n/i18n'; +import { onLangChange, t } from '../i18n/i18n'; export interface SavedCardRef { id: string; @@ -50,6 +50,11 @@ export function createStore(initial: EditorState) { export type EditorStore = ReturnType; +/** Der Standardname einer neuen Karte in der Sprache der Seite. */ +export function defaultCardName(): string { + return t('Meine Karte'); +} + /** Frischer Anfangszustand (der Standardname der Karte in der Sprache der Seite); DEFAULT_CONFIG und DEFAULT_CROP werden kopiert, nie geteilt. */ export function initialEditorState(): EditorState { return { @@ -59,11 +64,26 @@ export function initialEditorState(): EditorState { imageChanged: false, saved: null, savedConfig: null, - cardName: t('Meine Karte'), + cardName: defaultCardName(), saving: false, }; } +/** + * Der Standardname einer neuen Karte folgt der Sprache der Seite, solange ihn niemand geändert hat: Nach einem Sprachwechsel bekommt eine + * Karte, die noch nicht gespeichert ist und den unberührten Standardnamen der bisherigen Sprache trägt, den der neuen. Ein selbst gewählter + * Name bleibt, ebenso der Name einer gespeicherten Karte. Gibt die Abmeldung zurück. + */ +export function followLanguage(store: EditorStore): () => void { + let known = defaultCardName(); // der Standardname in der Sprache, die bis zum nächsten Wechsel gilt + return onLangChange(() => { + const previous = known; + known = defaultCardName(); + const { saved, cardName } = store.get(); + if (saved === null && cardName === previous && previous !== known) store.set({ cardName: known }); + }); +} + /** Übernimmt ein neues Bild und setzt den Ausschnitt zurück (der alte passt nicht zum neuen Bild). */ export function setImage(store: EditorStore, image: CardImage, blob: Blob): void { store.set({ diff --git a/web/src/main.test.ts b/web/src/main.test.ts index 83ac183..85da370 100644 --- a/web/src/main.test.ts +++ b/web/src/main.test.ts @@ -148,6 +148,25 @@ describe('Sprachwechsel', () => { expect(document.querySelector('.donate-banner')).toBeNull(); }); + it('der unberührte Standardname der Karte wechselt mit der Sprache, schon bevor die Seite neu aufgebaut wird; ein eigener Name bleibt', async () => { + const seen: string[] = []; + mocks.pages.editor.mockImplementation((_root: unknown, store: unknown) => { + seen.push((store as { get(): { cardName: string } }).get().cardName); + return vi.fn(); + }); + await start('/gestalten'); + const store = mocks.pages.editor.mock.calls[0][1] as { get(): { cardName: string }; set(patch: object): void }; + expect(store.get().cardName).toBe('Meine Karte'); + + langButton().click(); + expect(store.get().cardName).toBe('My card'); + expect(seen).toEqual(['Meine Karte', 'My card']); // die neu gebaute Seite sieht den Namen der neuen Sprache + + store.set({ cardName: 'Mein Urlaub' }); + langButton().click(); // zurück auf Deutsch + expect(store.get().cardName).toBe('Mein Urlaub'); + }); + 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()); diff --git a/web/src/main.ts b/web/src/main.ts index 4a72d5a..e1c9fca 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -12,7 +12,7 @@ import { mountHomePage } from './pages/home-page'; import { mountResetPage } from './pages/reset-page'; import { mountEditor } from './editor/editor-page'; import { bindEditorToAuth } from './editor/auth-reset'; -import { createStore, initialEditorState } from './editor/state'; +import { createStore, followLanguage, initialEditorState } from './editor/state'; import { mountDonateBanner } from './shell/donate-banner'; import { mountFooter } from './shell/footer'; import { mountHeader } from './shell/header'; @@ -34,6 +34,8 @@ if (legacyTarget) history.replaceState(null, '', legacyTarget); const editorStore = createStore(initialEditorState()); // … und gehört zum angemeldeten Konto: beim Abmelden oder Kontowechsel wird er geleert bzw. entkoppelt. bindEditorToAuth(editorStore); +// … und sein Standardname („Meine Karte“) folgt der Sprache, solange ihn niemand ändert. Der Hörer steht vor dem Neuaufbau der Seite (unten). +followLanguage(editorStore); // Der Rahmen der Seite: Spenden-Banner (ohne DONATE_URL bleibt der Platz leer), Kopfzeile mit Navigation, Suche und Knöpfen, // Konto-Menü im Seitenkopf und Fußzeile. Alle tragen Texte der gewählten Sprache und werden bei einem Sprachwechsel neu aufgebaut.