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
+19
-7
@@ -1,17 +1,19 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="de">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AirCard-IOS – Kartendesigns für Apple Wallet</title>
|
<title>AirCard-IOS – Card designs for Apple Wallet</title>
|
||||||
|
<meta name="description" content="Discover community card designs for AirCard-iOS, create your own and download it as PNG." />
|
||||||
<meta name="theme-color" content="#0F1828" />
|
<meta name="theme-color" content="#0F1828" />
|
||||||
<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />
|
<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />
|
||||||
<link rel="icon" href="/favicon.svg?v=2" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg?v=2" type="image/svg+xml" />
|
||||||
<link rel="icon" href="/favicon-32.png?v=2" sizes="32x32" type="image/png" />
|
<link rel="icon" href="/favicon-32.png?v=2" sizes="32x32" type="image/png" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />
|
||||||
<script>
|
<script>
|
||||||
// Hell/Dunkel vor dem ersten Paint setzen (kein Aufblitzen); dieselbe Regel wie initialTheme() in src/shell/theme.ts.
|
// Hell/Dunkel und Sprache (<html lang>) vor dem ersten Paint setzen (kein Aufblitzen); dieselben Regeln wie
|
||||||
// In einer sofort aufgerufenen Funktion, damit keine globale Variable `theme` entsteht.
|
// initialTheme() in src/shell/theme.ts und initialLang() in src/i18n/i18n.ts.
|
||||||
|
// In einer sofort aufgerufenen Funktion, damit keine globalen Variablen `theme` und `lang` entstehen.
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
var theme = null;
|
var theme = null;
|
||||||
@@ -23,6 +25,18 @@
|
|||||||
}
|
}
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
var lang = null;
|
||||||
|
try {
|
||||||
|
lang = localStorage.getItem('aircard.lang');
|
||||||
|
} catch (e) {}
|
||||||
|
if (lang !== 'de' && lang !== 'en') {
|
||||||
|
var browser = window.navigator;
|
||||||
|
var first = (browser.languages && browser.languages[0]) || browser.language || '';
|
||||||
|
lang = /^de/i.test(first) ? 'de' : 'en';
|
||||||
|
}
|
||||||
|
document.documentElement.setAttribute('lang', lang);
|
||||||
|
} catch (e) {}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
@@ -33,9 +47,7 @@
|
|||||||
<div id="account-slot"></div>
|
<div id="account-slot"></div>
|
||||||
</header>
|
</header>
|
||||||
<main id="app-root"></main>
|
<main id="app-root"></main>
|
||||||
<footer id="app-footer">
|
<footer id="app-footer"></footer>
|
||||||
<p>Inoffizielles Hobbyprojekt. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover. Alle Marken gehören ihren Inhabern.</p>
|
|
||||||
</footer>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+63
-11
@@ -3,6 +3,7 @@ import { resolve } from 'node:path';
|
|||||||
import { runInNewContext } from 'node:vm';
|
import { runInNewContext } from 'node:vm';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import indexHtml from '../index.html?raw';
|
import indexHtml from '../index.html?raw';
|
||||||
|
import { initialLang } from './i18n/i18n';
|
||||||
import { initialTheme } from './shell/theme';
|
import { initialTheme } from './shell/theme';
|
||||||
|
|
||||||
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../public', name));
|
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../public', name));
|
||||||
@@ -16,7 +17,11 @@ function pngInfo(buf: Buffer) {
|
|||||||
|
|
||||||
describe('Name und Kopfbereich', () => {
|
describe('Name und Kopfbereich', () => {
|
||||||
it('Titel und Meta-Angaben nennen AirCard-IOS', () => {
|
it('Titel und Meta-Angaben nennen AirCard-IOS', () => {
|
||||||
expect(indexHtml).toContain('<title>AirCard-IOS – Kartendesigns für Apple Wallet</title>');
|
// Statisch englisch, damit Link-Vorschauen englisch sind; der Router setzt danach den Titel in der gewählten Sprache.
|
||||||
|
expect(indexHtml).toContain('<title>AirCard-IOS – Card designs for Apple Wallet</title>');
|
||||||
|
expect(indexHtml).toContain(
|
||||||
|
'<meta name="description" content="Discover community card designs for AirCard-iOS, create your own and download it as PNG." />',
|
||||||
|
);
|
||||||
expect(indexHtml).toContain('<meta name="theme-color" content="#0F1828" />');
|
expect(indexHtml).toContain('<meta name="theme-color" content="#0F1828" />');
|
||||||
expect(indexHtml).toContain('<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />');
|
expect(indexHtml).toContain('<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />');
|
||||||
});
|
});
|
||||||
@@ -33,10 +38,9 @@ describe('Name und Kopfbereich', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Fußzeilen-Hinweis bleibt unverändert', () => {
|
it('Fußzeile ist leer: ihren Text setzt mountFooter in der gewählten Sprache', () => {
|
||||||
expect(indexHtml).toContain(
|
expect(indexHtml).toContain('<footer id="app-footer"></footer>');
|
||||||
'Inoffizielles Hobbyprojekt. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover. Alle Marken gehören ihren Inhabern.',
|
expect(indexHtml).not.toContain('Inoffizielles Hobbyprojekt');
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,20 +54,33 @@ describe('Icon-Dateien', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Inline-Skript für Hell/Dunkel in index.html', () => {
|
describe('Inline-Skript für Hell/Dunkel und Sprache in index.html', () => {
|
||||||
const script = /<script>([\s\S]*?)<\/script>/.exec(indexHtml)?.[1] ?? '';
|
const script = /<script>([\s\S]*?)<\/script>/.exec(indexHtml)?.[1] ?? '';
|
||||||
|
|
||||||
/** Führt das Skript in einer leeren Umgebung aus (kein Browser) und liefert, was es gesetzt hat. */
|
/** Was der Browser zur Sprache hergibt: der gespeicherte Wert von `aircard.lang`, `navigator.languages` und `navigator.language`. */
|
||||||
function run(stored: string | null, prefersDark: boolean, storageThrows = false) {
|
interface BrowserLang {
|
||||||
|
stored?: string | null;
|
||||||
|
languages?: readonly string[];
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt das Skript in einer leeren Umgebung aus (kein Browser) und liefert, was es gesetzt hat.
|
||||||
|
* `stored` ist der Wert von `aircard.theme`; die Sprache steht in `browser`.
|
||||||
|
*/
|
||||||
|
function run(stored: string | null, prefersDark: boolean, storageThrows = false, browser: BrowserLang = {}) {
|
||||||
const attributes: Record<string, string> = {};
|
const attributes: Record<string, string> = {};
|
||||||
const sandbox: Record<string, unknown> = {
|
const sandbox: Record<string, unknown> = {
|
||||||
localStorage: {
|
localStorage: {
|
||||||
getItem: () => {
|
getItem: (key: string) => {
|
||||||
if (storageThrows) throw new Error('gesperrt');
|
if (storageThrows) throw new Error('gesperrt');
|
||||||
return stored;
|
return key === 'aircard.lang' ? (browser.stored ?? null) : stored;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
window: { matchMedia: () => ({ matches: prefersDark }) },
|
window: {
|
||||||
|
matchMedia: () => ({ matches: prefersDark }),
|
||||||
|
navigator: { languages: browser.languages, language: browser.language },
|
||||||
|
},
|
||||||
document: { documentElement: { setAttribute: (name: string, value: string) => (attributes[name] = value) } },
|
document: { documentElement: { setAttribute: (name: string, value: string) => (attributes[name] = value) } },
|
||||||
};
|
};
|
||||||
runInNewContext(script, sandbox);
|
runInNewContext(script, sandbox);
|
||||||
@@ -79,6 +96,7 @@ describe('Inline-Skript für Hell/Dunkel in index.html', () => {
|
|||||||
const { sandbox } = run('dark', false);
|
const { sandbox } = run('dark', false);
|
||||||
expect(Object.keys(sandbox).sort()).toEqual(['document', 'localStorage', 'window']);
|
expect(Object.keys(sandbox).sort()).toEqual(['document', 'localStorage', 'window']);
|
||||||
expect('theme' in sandbox).toBe(false);
|
expect('theme' in sandbox).toBe(false);
|
||||||
|
expect('lang' in sandbox).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -99,4 +117,38 @@ describe('Inline-Skript für Hell/Dunkel in index.html', () => {
|
|||||||
expect(run(null, true, true).attributes['data-theme']).toBe('dark');
|
expect(run(null, true, true).attributes['data-theme']).toBe('dark');
|
||||||
expect(run(null, false, true).attributes['data-theme']).toBe('light');
|
expect(run(null, false, true).attributes['data-theme']).toBe('light');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[null, ['de-DE', 'en']],
|
||||||
|
[null, ['en-US', 'de']],
|
||||||
|
['en', ['de-DE']],
|
||||||
|
['de', ['en-US']],
|
||||||
|
['xx', ['de-AT']],
|
||||||
|
['xx', ['fr-FR']],
|
||||||
|
['', ['de']],
|
||||||
|
[null, ['DE']],
|
||||||
|
[null, ['de_CH']],
|
||||||
|
[null, []],
|
||||||
|
] as const)('setzt <html lang> nach derselben Regel wie initialLang(): gespeichert %j, Browsersprachen %j', (stored, languages) => {
|
||||||
|
const { attributes } = run(null, true, false, { stored, languages });
|
||||||
|
expect(attributes.lang).toBe(initialLang(stored, languages));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne navigator.languages gilt navigator.language (wie currentLang), ohne beides Englisch', () => {
|
||||||
|
expect(run(null, true, false, { language: 'de-CH' }).attributes.lang).toBe('de');
|
||||||
|
expect(run(null, true, false, { language: 'en-GB' }).attributes.lang).toBe('en');
|
||||||
|
expect(run(null, true, false, { languages: [], language: 'de' }).attributes.lang).toBe('de');
|
||||||
|
expect(run(null, true, false, {}).attributes.lang).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gesperrter localStorage: es gilt die Browsersprache', () => {
|
||||||
|
expect(run(null, true, true, { stored: 'en', languages: ['de-DE'] }).attributes.lang).toBe('de');
|
||||||
|
expect(run(null, true, true, { stored: 'de', languages: ['en-US'] }).attributes.lang).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die gespeicherte Sprache hat mit dem Theme nichts zu tun (und umgekehrt)', () => {
|
||||||
|
const { attributes } = run('light', false, false, { stored: 'en', languages: ['de'] });
|
||||||
|
expect(attributes['data-theme']).toBe('light');
|
||||||
|
expect(attributes.lang).toBe('en');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -5,3 +5,6 @@
|
|||||||
* Solange er leer ist, werden Spenden-Banner und -Knopf nicht angezeigt.
|
* Solange er leer ist, werden Spenden-Banner und -Knopf nicht angezeigt.
|
||||||
*/
|
*/
|
||||||
export const DONATE_URL: string = 'https://buymeacoffee.com/aircard';
|
export const DONATE_URL: string = 'https://buymeacoffee.com/aircard';
|
||||||
|
|
||||||
|
/** Original-Repo der App AirCard-iOS auf GitHub; Ziel des GitHub-Links in der Kopfzeile. */
|
||||||
|
export const GITHUB_URL = 'https://github.com/Mak5er/AirCard-iOS';
|
||||||
@@ -32,6 +32,10 @@ const ICONS = {
|
|||||||
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||||
menu: '<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>',
|
menu: '<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>',
|
||||||
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
||||||
|
github:
|
||||||
|
'<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>' +
|
||||||
|
'<path d="M9 18c-4.51 2-5-2-7-2"/>',
|
||||||
|
globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type IconName = keyof typeof ICONS;
|
export type IconName = keyof typeof ICONS;
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import { join, relative } from 'node:path';
|
||||||
|
import ts from 'typescript';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { EN } from './en';
|
||||||
|
|
||||||
|
const SRC = join(import.meta.dirname, '..');
|
||||||
|
|
||||||
|
/** Alle Quelldateien (ohne Tests) unter src/ als [Pfad relativ zu src, Text]. */
|
||||||
|
function sources(dir = SRC): Array<[string, string]> {
|
||||||
|
const out: Array<[string, string]> = [];
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
const path = join(dir, name);
|
||||||
|
if (statSync(path).isDirectory()) out.push(...sources(path));
|
||||||
|
else if (/\.ts$/.test(name) && !/\.test\.ts$/.test(name)) out.push([relative(SRC, path), readFileSync(path, 'utf8')]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Found {
|
||||||
|
/** Die deutschen Texte aus `t('…')` und `msg('…')` (Literale in '…', "…" oder `…` ohne ${…}). */
|
||||||
|
texts: string[];
|
||||||
|
/** Aufrufe mit einem Template-Literal samt ${…}: der Text steht nicht fest und lässt sich nicht nachschlagen. */
|
||||||
|
dynamic: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sammelt die Texte aller freistehenden Aufrufe `t(…)` und `msg(…)`. Gelesen wird der Syntaxbaum, daher zählen
|
||||||
|
* Kommentare, Zeichenketten und Methodenaufrufe wie `x.t(…)` nicht. Ein Argument, das kein Literal ist
|
||||||
|
* (etwa eine Konstante `t(TITEL)`), wird nicht gesammelt: seinen Text trägt das `msg('…')` der Konstante.
|
||||||
|
*/
|
||||||
|
function findTexts(code: string): Found {
|
||||||
|
const found: Found = { texts: [], dynamic: [] };
|
||||||
|
const file = ts.createSourceFile('quelle.ts', code, ts.ScriptTarget.Latest);
|
||||||
|
const visit = (node: ts.Node): void => {
|
||||||
|
const isTranslation = ts.isCallExpression(node) && ts.isIdentifier(node.expression) && ['t', 'msg'].includes(node.expression.text);
|
||||||
|
if (isTranslation) {
|
||||||
|
const arg = node.arguments[0];
|
||||||
|
if (arg && (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg))) found.texts.push(arg.text);
|
||||||
|
else if (arg && ts.isTemplateExpression(arg)) found.dynamic.push(arg.getText(file));
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
};
|
||||||
|
visit(file);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Platzhalter `{name}` eines Textes, ohne Doppelte und sortiert. */
|
||||||
|
const placeholders = (text: string): string[] => [...new Set(text.match(/\{\w+\}/g) ?? [])].sort();
|
||||||
|
|
||||||
|
// Text → Dateien, in denen er steht; dazu alle dynamischen Aufrufe als „Datei: Quelltext“.
|
||||||
|
const used = new Map<string, string[]>();
|
||||||
|
const dynamic: string[] = [];
|
||||||
|
for (const [path, code] of sources()) {
|
||||||
|
const found = findTexts(code);
|
||||||
|
for (const text of found.texts) used.set(text, [...(used.get(text) ?? []), path]);
|
||||||
|
dynamic.push(...found.dynamic.map((call) => `${path}: ${call}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Abdeckung der Übersetzungen (t/msg ↔ en.ts)', () => {
|
||||||
|
it('der Scanner findet die Texte der Oberfläche (sonst bestünden die Prüfungen leer)', () => {
|
||||||
|
expect(used.size).toBeGreaterThan(10);
|
||||||
|
expect(used.has('Gestalten')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jeder Text aus t()/msg() hat eine (nicht leere) englische Übersetzung', () => {
|
||||||
|
const translated = (text: string) => Object.hasOwn(EN, text) && EN[text].trim() !== '';
|
||||||
|
const missing = [...used].filter(([text]) => !translated(text)).map(([text, files]) => `${files[0]}: ${text}`);
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jeder Eintrag in en.ts wird in t()/msg() benutzt', () => {
|
||||||
|
expect(Object.keys(EN).filter((text) => !used.has(text))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Platzhalter {…} stehen in Text und Übersetzung gleich', () => {
|
||||||
|
const mismatched = Object.entries(EN)
|
||||||
|
.filter(([text, english]) => placeholders(text).join() !== placeholders(english).join())
|
||||||
|
.map(([text]) => text);
|
||||||
|
expect(mismatched).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kein t()/msg() mit Template-Literal und ${…}', () => {
|
||||||
|
expect(dynamic).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Scanner für t() und msg()', () => {
|
||||||
|
it('findet Literale in \'…\', "…" und `…` ohne ${…}, jeweils das erste Argument', () => {
|
||||||
|
const code = [
|
||||||
|
"const a = t('Eins');",
|
||||||
|
'const b = msg("Zwei");',
|
||||||
|
'const c = t(`Drei`);',
|
||||||
|
"const d = t('Vier {n}', { n: 4 });",
|
||||||
|
"const e = t('Mit \\'Escape\\'');",
|
||||||
|
].join('\n');
|
||||||
|
expect(findTexts(code)).toEqual({ texts: ['Eins', 'Zwei', 'Drei', 'Vier {n}', "Mit 'Escape'"], dynamic: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet Template-Literale mit ${…} als dynamisch', () => {
|
||||||
|
const found = findTexts('const a = t(`Hallo ${name}`); const b = msg(`x${1}`);');
|
||||||
|
expect(found.texts).toEqual([]);
|
||||||
|
expect(found.dynamic).toEqual(['`Hallo ${name}`', '`x${1}`']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignoriert Kommentare, Zeichenketten, Methodenaufrufe, ähnliche Namen und Konstanten', () => {
|
||||||
|
const code = [
|
||||||
|
"// t('Kommentar')",
|
||||||
|
"/* msg('Block') */",
|
||||||
|
"const s = \"t('in Zeichenkette')\";",
|
||||||
|
"const m = obj.t('Methode');",
|
||||||
|
"const f = format('Name') + git('x') + t_('y') + $t('z');",
|
||||||
|
'const k = t(TITEL) + t(LABELS[key]);',
|
||||||
|
'const leer = t();',
|
||||||
|
].join('\n');
|
||||||
|
expect(findTexts(code)).toEqual({ texts: [], dynamic: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findet verschachtelte Aufrufe', () => {
|
||||||
|
const code = "el('a', { title: t('Innen') }, t('Außen'), cond ? t('Wenn') : msg('Sonst'));";
|
||||||
|
expect(findTexts(code).texts).toEqual(['Innen', 'Außen', 'Wenn', 'Sonst']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Englisches Wörterbuch: deutscher Text (Schlüssel) → Englisch, gelesen von t() in i18n.ts. Fehlt ein Eintrag, bleibt der deutsche Text.
|
||||||
|
// Jeder Eintrag gehört zu einem t('…') oder msg('…') im Quelltext; coverage.test.ts prüft es in beide Richtungen.
|
||||||
|
// Englisch: kurz, freundlich, US-Schreibweise. „AirCard-IOS“ ist der Name dieser Seite, „AirCard-iOS“ der Name der App.
|
||||||
|
export const EN: Readonly<Record<string, string>> = {
|
||||||
|
// Seitentitel (pages/router.ts)
|
||||||
|
'AirCard-IOS – Kartendesigns für Apple Wallet': 'AirCard-IOS – Card designs for Apple Wallet',
|
||||||
|
'Gestalten – AirCard-IOS': 'Create – AirCard-IOS',
|
||||||
|
'Meine Karten – AirCard-IOS': 'My cards – AirCard-IOS',
|
||||||
|
'Community – AirCard-IOS': 'Community – AirCard-IOS',
|
||||||
|
'Passwort neu setzen – AirCard-IOS': 'Set new password – AirCard-IOS',
|
||||||
|
|
||||||
|
// Kopfzeile (shell/header.ts)
|
||||||
|
'Hauptnavigation': 'Main navigation',
|
||||||
|
'Community': 'Community',
|
||||||
|
'Gestalten': 'Create',
|
||||||
|
'Meine Karten': 'My cards',
|
||||||
|
'Unterstützen': 'Support us',
|
||||||
|
'Titel, Tags oder Herausgeber': 'Title, tags or issuer',
|
||||||
|
'Community-Karten durchsuchen': 'Search community cards',
|
||||||
|
'Suchen': 'Search',
|
||||||
|
'Hell/Dunkel umschalten': 'Toggle light/dark mode',
|
||||||
|
'Menü': 'Menu',
|
||||||
|
'AirCard-iOS auf GitHub': 'AirCard-iOS on GitHub',
|
||||||
|
|
||||||
|
// Spenden-Banner (shell/donate-banner.ts)
|
||||||
|
'Banner schließen': 'Close banner',
|
||||||
|
'Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.':
|
||||||
|
'Enjoying AirCard-IOS? Buy us a coffee – it keeps the project running.',
|
||||||
|
|
||||||
|
// Fußzeile (shell/footer.ts)
|
||||||
|
'Inoffizielle Community-Seite für die App AirCard-iOS – kein Teil des Original-Projekts und nicht mit Apple verbunden. Logos und Motive stammen von Nutzern; alle Marken gehören ihren Inhabern.':
|
||||||
|
'Unofficial community site for the AirCard-iOS app – not part of the original project and not affiliated with Apple. Logos and artwork are uploaded by users; all trademarks belong to their owners.',
|
||||||
|
};
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { EN } from './en';
|
||||||
|
import { currentLang, initialLang, LANG_KEY, msg, onLangChange, setLang, t, type Lang } from './i18n';
|
||||||
|
|
||||||
|
// Ein Wörterbuch-Eintrag mit Platzhalter nur für diese Tests: der Bestand hat (noch) keinen Text mit {…}.
|
||||||
|
// (Ein vi.mock von ./en griffe hier nicht: das Test-Setup hat i18n.ts schon geladen.) Er wird nach jedem Test wieder entfernt.
|
||||||
|
const WITH_PLACEHOLDER = 'Nichts gefunden für „{q}“';
|
||||||
|
const dictionary = EN as Record<string, string>;
|
||||||
|
|
||||||
|
/** Abmeldungen der Hörer dieses Tests (sie werden nach jedem Test aufgehoben, auch wenn eine Prüfung scheitert). */
|
||||||
|
const stops: Array<() => void> = [];
|
||||||
|
|
||||||
|
function listen(): Lang[] {
|
||||||
|
const calls: Lang[] = [];
|
||||||
|
stops.push(onLangChange((lang) => calls.push(lang)));
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
dictionary[WITH_PLACEHOLDER] = 'Nothing found for “{q}”';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete dictionary[WITH_PLACEHOLDER];
|
||||||
|
for (const stop of stops.splice(0)) stop();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initialLang', () => {
|
||||||
|
it.each([
|
||||||
|
[null, ['de-DE', 'en'], 'de'],
|
||||||
|
[null, ['en-US', 'de'], 'en'],
|
||||||
|
['en', ['de-DE'], 'en'],
|
||||||
|
['de', ['en-US'], 'de'],
|
||||||
|
['xx', ['de-AT'], 'de'],
|
||||||
|
['xx', ['en-GB'], 'en'],
|
||||||
|
['', ['de'], 'de'],
|
||||||
|
[null, [], 'en'],
|
||||||
|
] as const)('gespeichert %j, Browsersprachen %j → %s', (stored, languages, expected) => {
|
||||||
|
expect(initialLang(stored, languages)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nur die erste Browsersprache zählt, Groß-/Kleinschreibung ist egal', () => {
|
||||||
|
expect(initialLang(null, ['fr-FR', 'de-DE'])).toBe('en');
|
||||||
|
expect(initialLang(null, ['DE'])).toBe('de');
|
||||||
|
expect(initialLang(null, ['de_CH'])).toBe('de');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('t() und msg()', () => {
|
||||||
|
it('Deutsch: der Text bleibt, wie er ist', () => {
|
||||||
|
expect(t('Gestalten')).toBe('Gestalten');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Englisch: übersetzt über das Wörterbuch', () => {
|
||||||
|
setLang('en');
|
||||||
|
expect(t('Gestalten')).toBe('Create');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Text ohne Eintrag bleibt auch auf Englisch deutsch', () => {
|
||||||
|
setLang('en');
|
||||||
|
expect(t('Dieser Text steht in keinem Wörterbuch')).toBe('Dieser Text steht in keinem Wörterbuch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ersetzt Platzhalter in beiden Sprachen', () => {
|
||||||
|
expect(t('Nichts gefunden für „{q}“', { q: 'x' })).toBe('Nichts gefunden für „x“');
|
||||||
|
setLang('en');
|
||||||
|
expect(t('Nichts gefunden für „{q}“', { q: 'x' })).toBe('Nothing found for “x”');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ersetzt auch Platzhalter in Texten ohne Eintrag, Zahlen inklusive 0', () => {
|
||||||
|
setLang('en');
|
||||||
|
expect(t('{n} Karten von {name}', { n: 0, name: 'Ada' })).toBe('0 Karten von Ada');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein unbekannter Platzhalter bleibt stehen', () => {
|
||||||
|
expect(t('Nichts gefunden für „{q}“', { x: 'y' })).toBe('Nichts gefunden für „{q}“');
|
||||||
|
expect(t('Hallo {name}')).toBe('Hallo {name}');
|
||||||
|
setLang('en');
|
||||||
|
expect(t('Nichts gefunden für „{q}“', {})).toBe('Nothing found for “{q}”');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ersetzt in einem Durchgang: Werte werden nicht erneut ausgewertet und nicht als Muster gelesen', () => {
|
||||||
|
expect(t('{a} {b}', { a: '{b}', b: 'x' })).toBe('{b} x');
|
||||||
|
expect(t('{a}', { a: '$& $1' })).toBe('$& $1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Namen aus Object.prototype sind weder Wörterbucheintrag noch Platzhalter', () => {
|
||||||
|
setLang('en');
|
||||||
|
expect(t('constructor')).toBe('constructor');
|
||||||
|
expect(t('toString')).toBe('toString');
|
||||||
|
expect(t('Wert {toString}', { x: 1 })).toBe('Wert {toString}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('msg() gibt den Text unverändert zurück und übersetzt nicht', () => {
|
||||||
|
setLang('en');
|
||||||
|
expect(msg('Gestalten')).toBe('Gestalten');
|
||||||
|
expect(t(msg('Gestalten'))).toBe('Create');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setLang und currentLang', () => {
|
||||||
|
it('currentLang liefert die zuletzt gesetzte Sprache', () => {
|
||||||
|
setLang('en');
|
||||||
|
expect(currentLang()).toBe('en');
|
||||||
|
setLang('de');
|
||||||
|
expect(currentLang()).toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt <html lang>, auch ohne Wechsel (etwa beim Start)', () => {
|
||||||
|
setLang('en');
|
||||||
|
expect(document.documentElement.lang).toBe('en');
|
||||||
|
document.documentElement.lang = 'fr';
|
||||||
|
setLang('en');
|
||||||
|
expect(document.documentElement.lang).toBe('en');
|
||||||
|
setLang('de');
|
||||||
|
expect(document.documentElement.lang).toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('speichert die Wahl in localStorage', () => {
|
||||||
|
setLang('en');
|
||||||
|
expect(localStorage.getItem(LANG_KEY)).toBe('en');
|
||||||
|
expect(LANG_KEY).toBe('aircard.lang');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit persist: false wird nichts gespeichert, die Sprache gilt trotzdem', () => {
|
||||||
|
setLang('en', { persist: false });
|
||||||
|
expect(localStorage.getItem(LANG_KEY)).toBeNull();
|
||||||
|
expect(currentLang()).toBe('en');
|
||||||
|
expect(t('Gestalten')).toBe('Create');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein gesperrter Speicher ist kein Fehler; die Sprache wechselt trotzdem', () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new DOMException('voll', 'QuotaExceededError');
|
||||||
|
});
|
||||||
|
expect(() => setLang('en')).not.toThrow();
|
||||||
|
expect(currentLang()).toBe('en');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onLangChange', () => {
|
||||||
|
it('meldet jeden echten Wechsel genau einmal, gleiche Sprache nie', () => {
|
||||||
|
const calls = listen();
|
||||||
|
setLang('de'); // schon Deutsch (Test-Setup)
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
setLang('en');
|
||||||
|
setLang('en');
|
||||||
|
expect(calls).toEqual(['en']);
|
||||||
|
setLang('de');
|
||||||
|
expect(calls).toEqual(['en', 'de']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persist: false meldet ebenfalls', () => {
|
||||||
|
const calls = listen();
|
||||||
|
setLang('en', { persist: false });
|
||||||
|
expect(calls).toEqual(['en']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Hörer sieht die neue Sprache schon (t() und currentLang)', () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
stops.push(
|
||||||
|
onLangChange((lang) => {
|
||||||
|
seen.push(`${lang}:${currentLang()}:${t('Gestalten')}:${document.documentElement.lang}`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setLang('en');
|
||||||
|
expect(seen).toEqual(['en:en:Create:en']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Abmeldung wirkt', () => {
|
||||||
|
const calls: Lang[] = [];
|
||||||
|
const stop = onLangChange((lang) => calls.push(lang));
|
||||||
|
setLang('en');
|
||||||
|
stop();
|
||||||
|
setLang('de');
|
||||||
|
expect(calls).toEqual(['en']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Hörer darf sich beim Melden abmelden, die übrigen werden trotzdem gerufen', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const stopA = onLangChange(() => {
|
||||||
|
calls.push('a');
|
||||||
|
stopA();
|
||||||
|
});
|
||||||
|
stops.push(stopA);
|
||||||
|
const second = listen();
|
||||||
|
setLang('en');
|
||||||
|
setLang('de');
|
||||||
|
expect(calls).toEqual(['a']);
|
||||||
|
expect(second).toEqual(['en', 'de']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('currentLang: Startsprache beim ersten Aufruf', () => {
|
||||||
|
/** Eine frische Kopie des Moduls; die Startsprache wird erst beim ersten Aufruf von currentLang() bestimmt. */
|
||||||
|
async function fresh() {
|
||||||
|
vi.resetModules();
|
||||||
|
return import('./i18n');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('die gespeicherte Wahl geht vor der Browsersprache', async () => {
|
||||||
|
localStorage.setItem(LANG_KEY, 'en');
|
||||||
|
vi.stubGlobal('navigator', { languages: ['de-DE'], language: 'de-DE' });
|
||||||
|
expect((await fresh()).currentLang()).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Wahl entscheidet die erste Browsersprache', async () => {
|
||||||
|
vi.stubGlobal('navigator', { languages: ['de-AT', 'en'], language: 'de-AT' });
|
||||||
|
expect((await fresh()).currentLang()).toBe('de');
|
||||||
|
vi.stubGlobal('navigator', { languages: ['en-US', 'de'], language: 'en-US' });
|
||||||
|
expect((await fresh()).currentLang()).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein ungültiger gespeicherter Wert zählt als „nichts gespeichert“', async () => {
|
||||||
|
localStorage.setItem(LANG_KEY, 'klingonisch');
|
||||||
|
vi.stubGlobal('navigator', { languages: ['de-DE'], language: 'de-DE' });
|
||||||
|
expect((await fresh()).currentLang()).toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne navigator.languages gilt navigator.language, ganz ohne Angabe Englisch', async () => {
|
||||||
|
vi.stubGlobal('navigator', { language: 'de' });
|
||||||
|
expect((await fresh()).currentLang()).toBe('de');
|
||||||
|
vi.stubGlobal('navigator', { languages: [], language: 'de-CH' });
|
||||||
|
expect((await fresh()).currentLang()).toBe('de');
|
||||||
|
vi.stubGlobal('navigator', {});
|
||||||
|
expect((await fresh()).currentLang()).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein gesperrter Speicher beim Lesen: es gilt die Browsersprache', async () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new DOMException('blockiert', 'SecurityError');
|
||||||
|
});
|
||||||
|
vi.stubGlobal('navigator', { languages: ['de-DE'], language: 'de-DE' });
|
||||||
|
expect((await fresh()).currentLang()).toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wird nur beim ersten Aufruf bestimmt: spätere Änderungen am Speicher ändern nichts', async () => {
|
||||||
|
vi.stubGlobal('navigator', { languages: ['de-DE'], language: 'de-DE' });
|
||||||
|
const mod = await fresh();
|
||||||
|
expect(mod.currentLang()).toBe('de');
|
||||||
|
localStorage.setItem(LANG_KEY, 'en');
|
||||||
|
expect(mod.currentLang()).toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setLang meldet gegenüber der Startsprache, nicht gegenüber „nichts“', async () => {
|
||||||
|
vi.stubGlobal('navigator', { languages: ['de-DE'], language: 'de-DE' });
|
||||||
|
const mod = await fresh();
|
||||||
|
const calls: Lang[] = [];
|
||||||
|
mod.onLangChange((lang) => calls.push(lang));
|
||||||
|
mod.setLang('de', { persist: false }); // wie die Startsprache: kein Wechsel
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
mod.setLang('en', { persist: false });
|
||||||
|
expect(calls).toEqual(['en']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Sprache der Seite (Deutsch/Englisch) im gettext-Stil: Der deutsche Text ist zugleich der Schlüssel.
|
||||||
|
// `t('Deutscher Text', { name })` schlägt ihn bei Englisch in EN (en.ts) nach und setzt die Platzhalter `{name}` ein;
|
||||||
|
// fehlt der Eintrag, bleibt der deutsche Text. Übersetzt wird beim Aufruf, ein Sprachwechsel baut die Oberfläche neu auf (main.ts).
|
||||||
|
import { EN } from './en';
|
||||||
|
|
||||||
|
export type Lang = 'de' | 'en';
|
||||||
|
|
||||||
|
export const LANG_KEY = 'aircard.lang';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startsprache: die gespeicherte Wahl (`de` oder `en`), sonst Deutsch, wenn die erste Browsersprache mit `de` beginnt,
|
||||||
|
* andernfalls Englisch. Dieselbe Regel steht im Skript in index.html, das <html lang> vor dem ersten Paint setzt.
|
||||||
|
*/
|
||||||
|
export function initialLang(stored: string | null, browserLanguages: readonly string[]): Lang {
|
||||||
|
if (stored === 'de' || stored === 'en') return stored;
|
||||||
|
return (browserLanguages[0] ?? '').toLowerCase().startsWith('de') ? 'de' : 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStored(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(LANG_KEY);
|
||||||
|
} catch {
|
||||||
|
return null; // Speicher gesperrt (z. B. privates Fenster): es gilt die Browsersprache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die bevorzugten Browsersprachen: `navigator.languages`, ersatzweise `[navigator.language]`. */
|
||||||
|
function browserLanguages(): readonly string[] {
|
||||||
|
if (navigator.languages?.length) return navigator.languages;
|
||||||
|
return navigator.language ? [navigator.language] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Sprache der Seite; null, bis sie zum ersten Mal gebraucht wird.
|
||||||
|
let lang: Lang | null = null;
|
||||||
|
const listeners = new Set<(lang: Lang) => void>();
|
||||||
|
|
||||||
|
/** Die Sprache der Seite; beim ersten Aufruf aus der gespeicherten Wahl bzw. der Browsersprache bestimmt. */
|
||||||
|
export function currentLang(): Lang {
|
||||||
|
lang ??= initialLang(readStored(), browserLanguages());
|
||||||
|
return lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt die Sprache um: setzt <html lang>, merkt sie sich (außer mit `persist: false`) und meldet einen echten Wechsel an
|
||||||
|
* die Hörer. Das Neuzeichnen von Kopfzeile und Seite ist Sache der Hörer.
|
||||||
|
*/
|
||||||
|
export function setLang(next: Lang, opts: { persist?: boolean } = {}): void {
|
||||||
|
const changed = next !== currentLang();
|
||||||
|
lang = next;
|
||||||
|
document.documentElement.lang = next;
|
||||||
|
if (opts.persist ?? true) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LANG_KEY, next);
|
||||||
|
} catch {
|
||||||
|
// gilt dann nur für diese Sitzung
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Kopie, damit sich ein Hörer beim Melden abmelden darf.
|
||||||
|
if (changed) for (const fn of [...listeners]) fn(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meldet jeden Sprachwechsel (nicht den bloßen Aufruf mit der aktuellen Sprache). Gibt die Abmeldung zurück. */
|
||||||
|
export function onLangChange(fn: (lang: Lang) => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übersetzt einen deutschen Text in die Sprache der Seite und setzt die Platzhalter `{name}` aus `params` ein;
|
||||||
|
* ein Platzhalter ohne Wert bleibt stehen. Ohne Eintrag in EN bleibt der deutsche Text.
|
||||||
|
*/
|
||||||
|
export function t(text: string, params?: Readonly<Record<string, string | number>>): string {
|
||||||
|
const base = currentLang() === 'en' && Object.hasOwn(EN, text) ? EN[text] : text;
|
||||||
|
if (!params) return base;
|
||||||
|
return base.replace(/\{(\w+)\}/g, (placeholder, name: string) => (Object.hasOwn(params, name) ? String(params[name]) : placeholder));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Markiert einen deutschen Text, der vorab in einer Konstante steht; übersetzt wird erst beim Anzeigen mit `t()`. */
|
||||||
|
export function msg<T extends string>(text: T): T {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+51
-10
@@ -1,9 +1,11 @@
|
|||||||
import '@fontsource-variable/nunito-sans';
|
import '@fontsource-variable/nunito-sans';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
import { mountAccountMenu } from './account/account-menu';
|
import { mountAccountMenu, type AccountMenu } from './account/account-menu';
|
||||||
|
import { client } from './account/appwrite';
|
||||||
import { getCurrentUser } from './account/auth';
|
import { getCurrentUser } from './account/auth';
|
||||||
import { DONATE_URL } from './config';
|
import { DONATE_URL } from './config';
|
||||||
import { legacyRedirect, startRouter } from './pages/router';
|
import { currentLang, onLangChange, setLang } from './i18n/i18n';
|
||||||
|
import { legacyRedirect, rerender, startRouter } from './pages/router';
|
||||||
import { mountCardsPage } from './pages/cards-page';
|
import { mountCardsPage } from './pages/cards-page';
|
||||||
import { mountCommunityPage } from './pages/community-page';
|
import { mountCommunityPage } from './pages/community-page';
|
||||||
import { mountHomePage } from './pages/home-page';
|
import { mountHomePage } from './pages/home-page';
|
||||||
@@ -12,12 +14,18 @@ import { mountEditor } from './editor/editor-page';
|
|||||||
import { bindEditorToAuth } from './editor/auth-reset';
|
import { bindEditorToAuth } from './editor/auth-reset';
|
||||||
import { createStore, initialEditorState } from './editor/state';
|
import { createStore, initialEditorState } from './editor/state';
|
||||||
import { mountDonateBanner } from './shell/donate-banner';
|
import { mountDonateBanner } from './shell/donate-banner';
|
||||||
|
import { mountFooter } from './shell/footer';
|
||||||
import { mountHeader } from './shell/header';
|
import { mountHeader } from './shell/header';
|
||||||
import { initTheme } from './shell/theme';
|
import { initTheme } from './shell/theme';
|
||||||
|
|
||||||
// Hell/Dunkel: das Skript in index.html hat es vor dem ersten Paint gesetzt; hier wird nur die Browserleiste angeglichen.
|
// Hell/Dunkel: das Skript in index.html hat es vor dem ersten Paint gesetzt; hier wird nur die Browserleiste angeglichen.
|
||||||
initTheme();
|
initTheme();
|
||||||
|
|
||||||
|
// Sprache: das Skript in index.html hat <html lang> schon vor dem ersten Paint gesetzt; hier wird sie übernommen (ohne sie zu speichern:
|
||||||
|
// gespeichert wird nur eine Wahl per Sprachknopf) und dem Appwrite-Client mitgegeben, damit dessen Mails (Passwort) in ihr ankommen.
|
||||||
|
setLang(currentLang(), { persist: false });
|
||||||
|
client.setLocale(currentLang());
|
||||||
|
|
||||||
// Alte Editor-Links (`/?karte=<id>`) zeigen jetzt auf `/gestalten?karte=<id>`; der Verlauf bekommt keinen Eintrag dazu.
|
// Alte Editor-Links (`/?karte=<id>`) zeigen jetzt auf `/gestalten?karte=<id>`; der Verlauf bekommt keinen Eintrag dazu.
|
||||||
const legacyTarget = legacyRedirect(new URL(location.href));
|
const legacyTarget = legacyRedirect(new URL(location.href));
|
||||||
if (legacyTarget) history.replaceState(null, '', legacyTarget);
|
if (legacyTarget) history.replaceState(null, '', legacyTarget);
|
||||||
@@ -27,24 +35,57 @@ const editorStore = createStore(initialEditorState());
|
|||||||
// … und gehört zum angemeldeten Konto: beim Abmelden oder Kontowechsel wird er geleert bzw. entkoppelt.
|
// … und gehört zum angemeldeten Konto: beim Abmelden oder Kontowechsel wird er geleert bzw. entkoppelt.
|
||||||
bindEditorToAuth(editorStore);
|
bindEditorToAuth(editorStore);
|
||||||
|
|
||||||
// Spenden-Banner (ohne DONATE_URL bleibt der Platz leer) und Kopfzeile mit Navigation, Suche und Knöpfen.
|
// 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.
|
||||||
const bannerSlot = document.getElementById('donate-banner');
|
const bannerSlot = document.getElementById('donate-banner');
|
||||||
if (bannerSlot) mountDonateBanner(bannerSlot, DONATE_URL);
|
|
||||||
const header = document.getElementById('app-header');
|
const header = document.getElementById('app-header');
|
||||||
if (header) mountHeader(header, { donateUrl: DONATE_URL });
|
|
||||||
|
|
||||||
// Konto-Menü im Seitenkopf. Der Anmeldestatus wird einmal beim Start geholt; schlägt das fehl
|
|
||||||
// (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet), gilt der Nutzer als abgemeldet.
|
|
||||||
const accountSlot = document.getElementById('account-slot');
|
const accountSlot = document.getElementById('account-slot');
|
||||||
|
const footer = document.getElementById('app-footer');
|
||||||
|
|
||||||
|
let unmountHeader: (() => void) | null = null;
|
||||||
|
let accountMenu: AccountMenu | null = null;
|
||||||
|
// Ob der Anmeldestatus schon geholt wurde: ein später neu gebautes Konto-Menü zeigt sich dann sofort (kein Aufblitzen von „Anmelden“).
|
||||||
|
let authKnown = false;
|
||||||
|
|
||||||
|
function mountShell(): void {
|
||||||
|
if (bannerSlot) mountDonateBanner(bannerSlot, DONATE_URL);
|
||||||
|
if (header) {
|
||||||
|
unmountHeader?.();
|
||||||
|
unmountHeader = mountHeader(header, { donateUrl: DONATE_URL });
|
||||||
|
}
|
||||||
|
if (accountSlot) {
|
||||||
|
accountMenu?.dispose();
|
||||||
|
accountMenu = mountAccountMenu(accountSlot);
|
||||||
|
if (authKnown) accountMenu.ready();
|
||||||
|
}
|
||||||
|
if (footer) mountFooter(footer);
|
||||||
|
}
|
||||||
|
mountShell();
|
||||||
|
|
||||||
|
// Der Anmeldestatus wird einmal beim Start geholt; schlägt das fehl (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet),
|
||||||
|
// gilt der Nutzer als abgemeldet.
|
||||||
if (accountSlot) {
|
if (accountSlot) {
|
||||||
const menu = mountAccountMenu(accountSlot);
|
|
||||||
getCurrentUser()
|
getCurrentUser()
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
console.error('Anmeldestatus konnte nicht geprüft werden', error);
|
console.error('Anmeldestatus konnte nicht geprüft werden', error);
|
||||||
})
|
})
|
||||||
.finally(() => menu.ready());
|
.finally(() => {
|
||||||
|
authKnown = true;
|
||||||
|
accountMenu?.ready();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sprachwechsel ohne Neuladen: Der Appwrite-Client bekommt die neue Sprache, der Rahmen wird neu gebaut und die aktuelle Seite baut
|
||||||
|
// sich neu auf (der Router setzt auch den Titel neu). Der Editor-Store bleibt dasselbe Objekt, ein Entwurf geht also nicht verloren.
|
||||||
|
onLangChange((lang) => {
|
||||||
|
client.setLocale(lang);
|
||||||
|
// Die Kopfzeile wird ersetzt, der Sprachknopf darunter ebenso: wer ihn per Tastatur bedient hat, behält den Fokus auf dem neuen.
|
||||||
|
const hadFocus = document.activeElement?.matches('.lang-btn') ?? false;
|
||||||
|
mountShell();
|
||||||
|
if (hadFocus) header?.querySelector<HTMLElement>('.lang-btn')?.focus();
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
|
||||||
// Der Router setzt `document.title` je Seite (titleFor) und ruft für jede Route die passende Mount-Funktion.
|
// Der Router setzt `document.title` je Seite (titleFor) und ruft für jede Route die passende Mount-Funktion.
|
||||||
startRouter((route, root) => {
|
startRouter((route, root) => {
|
||||||
switch (route.id) {
|
switch (route.id) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { legacyRedirect, matchRoute, navigate, onRouteChange, ROUTE_TITLES, startRouter, titleFor } from './router';
|
import { createStore, initialEditorState } from '../editor/state';
|
||||||
|
import { setLang } from '../i18n/i18n';
|
||||||
|
import { legacyRedirect, matchRoute, navigate, onRouteChange, rerender, ROUTE_TITLES, startRouter, titleFor } from './router';
|
||||||
|
|
||||||
describe('matchRoute', () => {
|
describe('matchRoute', () => {
|
||||||
it('ordnet / der Startseite zu', () => {
|
it('ordnet / der Startseite zu', () => {
|
||||||
@@ -80,6 +82,21 @@ describe('Seitentitel', () => {
|
|||||||
it('ein Titel wird nur auf der Detailseite eingesetzt', () => {
|
it('ein Titel wird nur auf der Detailseite eingesetzt', () => {
|
||||||
expect(titleFor({ id: 'cards', params: {} }, 'Fremd')).toBe('Meine Karten – AirCard-IOS');
|
expect(titleFor({ id: 'cards', params: {} }, 'Fremd')).toBe('Meine Karten – AirCard-IOS');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Englisch: übersetzt, der Name der Seite bleibt AirCard-IOS', () => {
|
||||||
|
setLang('en');
|
||||||
|
expect(titleFor({ id: 'home', params: {} })).toBe('AirCard-IOS – Card designs for Apple Wallet');
|
||||||
|
expect(titleFor({ id: 'gestalten', params: {} })).toBe('Create – AirCard-IOS');
|
||||||
|
expect(titleFor({ id: 'cards', params: {} })).toBe('My cards – AirCard-IOS');
|
||||||
|
expect(titleFor({ id: 'community', params: {} })).toBe('Community – AirCard-IOS');
|
||||||
|
expect(titleFor({ id: 'reset', params: {} })).toBe('Set new password – AirCard-IOS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ROUTE_TITLES bleibt der deutsche Grundtext; der Titel einer Karte ist Nutzerinhalt und wird nicht übersetzt', () => {
|
||||||
|
setLang('en');
|
||||||
|
expect(ROUTE_TITLES.gestalten).toBe('Gestalten – AirCard-IOS');
|
||||||
|
expect(titleFor({ id: 'community', params: { id: 'abc' } }, 'Mein Motiv')).toBe('Mein Motiv – AirCard-IOS');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Jeder startRouter-Aufruf baut den vorherigen Router (Listener + Seite) ab,
|
// Jeder startRouter-Aufruf baut den vorherigen Router (Listener + Seite) ab,
|
||||||
@@ -322,6 +339,95 @@ describe('startRouter', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('rerender (Sprachwechsel)', () => {
|
||||||
|
it('räumt die aktuelle Seite auf und baut sie mit derselben Route im selben Container neu auf', () => {
|
||||||
|
history.replaceState(null, '', '/community/abc123');
|
||||||
|
const order: string[] = [];
|
||||||
|
startRouter((route, container) => {
|
||||||
|
order.push(`render:${route.id}:${route.params.id}:${container.id}`);
|
||||||
|
return () => order.push('cleanup');
|
||||||
|
});
|
||||||
|
rerender();
|
||||||
|
expect(order).toEqual(['render:community:abc123:app-root', 'cleanup', 'render:community:abc123:app-root']);
|
||||||
|
rerender();
|
||||||
|
expect(order).toHaveLength(5);
|
||||||
|
expect(order.slice(3)).toEqual(['cleanup', 'render:community:abc123:app-root']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrollt nicht und lässt Adresse und Verlauf unberührt', () => {
|
||||||
|
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
|
const scrollIntoView = vi.fn();
|
||||||
|
try {
|
||||||
|
history.replaceState(null, '', '/karten#unten');
|
||||||
|
const anchor = document.createElement('section');
|
||||||
|
anchor.id = 'unten';
|
||||||
|
anchor.scrollIntoView = scrollIntoView;
|
||||||
|
root.append(anchor);
|
||||||
|
startRouter(vi.fn());
|
||||||
|
const lengthBefore = history.length;
|
||||||
|
rerender();
|
||||||
|
expect(scrollTo).not.toHaveBeenCalled();
|
||||||
|
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||||
|
expect(location.pathname + location.hash).toBe('/karten#unten');
|
||||||
|
expect(history.length).toBe(lengthBefore);
|
||||||
|
} finally {
|
||||||
|
scrollTo.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet die Seite erneut an onRouteChange (die neue Kopfzeile markiert so ihre aktuelle Seite)', () => {
|
||||||
|
history.replaceState(null, '', '/gestalten');
|
||||||
|
const order: string[] = [];
|
||||||
|
const stop = onRouteChange((route) => order.push(`route:${route.id}`));
|
||||||
|
startRouter((route) => {
|
||||||
|
order.push(`render:${route.id}`);
|
||||||
|
});
|
||||||
|
rerender();
|
||||||
|
stop();
|
||||||
|
expect(order).toEqual(['route:gestalten', 'render:gestalten', 'route:gestalten', 'render:gestalten']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt den Dokumenttitel in der dann gewählten Sprache neu', () => {
|
||||||
|
history.replaceState(null, '', '/gestalten');
|
||||||
|
startRouter(vi.fn());
|
||||||
|
expect(document.title).toBe('Gestalten – AirCard-IOS');
|
||||||
|
setLang('en');
|
||||||
|
rerender();
|
||||||
|
expect(document.title).toBe('Create – AirCard-IOS');
|
||||||
|
setLang('de');
|
||||||
|
rerender();
|
||||||
|
expect(document.title).toBe('Gestalten – AirCard-IOS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Zustand außerhalb der Seite (der Editor-Entwurf im Store) bleibt beim Neuaufbau derselbe', () => {
|
||||||
|
const store = createStore(initialEditorState());
|
||||||
|
const stores: unknown[] = [];
|
||||||
|
history.replaceState(null, '', '/gestalten');
|
||||||
|
startRouter((_route, container) => {
|
||||||
|
stores.push(store);
|
||||||
|
container.textContent = store.get().cardName; // die Seite zeigt, was im Store liegt
|
||||||
|
return () => container.replaceChildren();
|
||||||
|
});
|
||||||
|
store.set({ cardName: 'Mein Entwurf' });
|
||||||
|
setLang('en');
|
||||||
|
rerender();
|
||||||
|
expect(stores).toHaveLength(2);
|
||||||
|
expect(stores[1]).toBe(stores[0]);
|
||||||
|
expect(root.textContent).toBe('Mein Entwurf');
|
||||||
|
expect(store.get().cardName).toBe('Mein Entwurf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne laufenden Router passiert nichts', async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const fresh = await import('./router');
|
||||||
|
const before = document.body.innerHTML;
|
||||||
|
document.title = 'unverändert';
|
||||||
|
expect(() => fresh.rerender()).not.toThrow();
|
||||||
|
expect(document.body.innerHTML).toBe(before);
|
||||||
|
expect(document.title).toBe('unverändert');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('rendert bei popstate die dann aktuelle Route', () => {
|
it('rendert bei popstate die dann aktuelle Route', () => {
|
||||||
const render = vi.fn();
|
const render = vi.fn();
|
||||||
startRouter(render);
|
startRouter(render);
|
||||||
|
|||||||
+19
-7
@@ -1,3 +1,5 @@
|
|||||||
|
import { msg, t } from '../i18n/i18n';
|
||||||
|
|
||||||
export type RouteId = 'home' | 'gestalten' | 'cards' | 'community' | 'reset';
|
export type RouteId = 'home' | 'gestalten' | 'cards' | 'community' | 'reset';
|
||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
@@ -31,18 +33,19 @@ export function legacyRedirect(url: URL): string | null {
|
|||||||
return `/gestalten${url.search}${url.hash}`;
|
return `/gestalten${url.search}${url.hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deutsche Grundtexte; die Sprache der Seite bestimmt erst titleFor.
|
||||||
export const ROUTE_TITLES: Record<RouteId, string> = {
|
export const ROUTE_TITLES: Record<RouteId, string> = {
|
||||||
home: 'AirCard-IOS – Kartendesigns für Apple Wallet',
|
home: msg('AirCard-IOS – Kartendesigns für Apple Wallet'),
|
||||||
gestalten: 'Gestalten – AirCard-IOS',
|
gestalten: msg('Gestalten – AirCard-IOS'),
|
||||||
cards: 'Meine Karten – AirCard-IOS',
|
cards: msg('Meine Karten – AirCard-IOS'),
|
||||||
community: 'Community – AirCard-IOS',
|
community: msg('Community – AirCard-IOS'),
|
||||||
reset: 'Passwort neu setzen – AirCard-IOS',
|
reset: msg('Passwort neu setzen – AirCard-IOS'),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Titel der Seite; `detailTitle` (Titel der Community-Karte) gilt nur für die Detailseite. */
|
/** Titel der Seite in der gewählten Sprache; `detailTitle` (Titel der Community-Karte, ein Nutzerinhalt) gilt nur für die Detailseite. */
|
||||||
export function titleFor(route: Route, detailTitle?: string): string {
|
export function titleFor(route: Route, detailTitle?: string): string {
|
||||||
if (route.id === 'community' && detailTitle) return `${detailTitle} – AirCard-IOS`;
|
if (route.id === 'community' && detailTitle) return `${detailTitle} – AirCard-IOS`;
|
||||||
return ROUTE_TITLES[route.id];
|
return t(ROUTE_TITLES[route.id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeListeners = new Set<(route: Route) => void>();
|
const routeListeners = new Set<(route: Route) => void>();
|
||||||
@@ -60,6 +63,7 @@ export function onRouteChange(listener: (route: Route) => void): () => void {
|
|||||||
|
|
||||||
// Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft.
|
// Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft.
|
||||||
let stopCurrent: (() => void) | null = null;
|
let stopCurrent: (() => void) | null = null;
|
||||||
|
// Baut die Seite der aktuellen Adresse auf (räumt zuvor die alte auf); null, solange kein Router läuft.
|
||||||
let renderCurrent: (() => void) | null = null;
|
let renderCurrent: (() => void) | null = null;
|
||||||
|
|
||||||
/** Springt zum Anker `#<id>`; gibt es ihn nicht, an den Seitenanfang. Seiten, die den Anker erst nach dem Laden bekommen, rufen es erneut auf. */
|
/** Springt zum Anker `#<id>`; gibt es ihn nicht, an den Seitenanfang. Seiten, die den Anker erst nach dem Laden bekommen, rufen es erneut auf. */
|
||||||
@@ -91,6 +95,14 @@ export function navigate(path: string): void {
|
|||||||
else window.scrollTo(0, 0);
|
else window.scrollTo(0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Räumt die aktuelle Seite auf und baut sie mit derselben Route neu auf, etwa nach einem Sprachwechsel. Der Titel wird neu gesetzt,
|
||||||
|
* gescrollt wird nicht. Ohne laufenden Router passiert nichts. Zustand außerhalb der Seiten (der Editor-Store) bleibt unberührt.
|
||||||
|
*/
|
||||||
|
export function rerender(): void {
|
||||||
|
renderCurrent?.();
|
||||||
|
}
|
||||||
|
|
||||||
export function startRouter(render: Render): void {
|
export function startRouter(render: Render): void {
|
||||||
stopCurrent?.();
|
stopCurrent?.();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { setLang } from '../i18n/i18n';
|
||||||
import { mountDonateBanner } from './donate-banner';
|
import { mountDonateBanner } from './donate-banner';
|
||||||
|
|
||||||
const KEY = 'aircard.donateBannerClosed';
|
const KEY = 'aircard.donateBannerClosed';
|
||||||
@@ -37,6 +38,16 @@ describe('mountDonateBanner', () => {
|
|||||||
expect(slot.querySelector('button')?.getAttribute('aria-label')).toBe('Banner schließen');
|
expect(slot.querySelector('button')?.getAttribute('aria-label')).toBe('Banner schließen');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Englisch: Text, Link und Schließen-Knopf übersetzt, Zeichen (☕, →) und Link unverändert', () => {
|
||||||
|
setLang('en');
|
||||||
|
mountDonateBanner(slot, URL_);
|
||||||
|
expect(slot.querySelector('.donate-banner-text')?.textContent).toBe('☕ Enjoying AirCard-IOS? Buy us a coffee – it keeps the project running.');
|
||||||
|
const link = slot.querySelector<HTMLAnchorElement>('a.donate-banner-link')!;
|
||||||
|
expect(link.textContent).toBe('Support us →');
|
||||||
|
expect(link.getAttribute('href')).toBe(URL_);
|
||||||
|
expect(slot.querySelector('button')?.getAttribute('aria-label')).toBe('Close banner');
|
||||||
|
});
|
||||||
|
|
||||||
it('Schließen setzt das Flag und entfernt das Banner', () => {
|
it('Schließen setzt das Flag und entfernt das Banner', () => {
|
||||||
mountDonateBanner(slot, URL_);
|
mountDonateBanner(slot, URL_);
|
||||||
slot.querySelector('button')!.click();
|
slot.querySelector('button')!.click();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Schließbares Spenden-Banner ganz oben. Ohne Spenden-Link (config.ts) oder nach dem Schließen wird nichts angezeigt.
|
// Schließbares Spenden-Banner ganz oben. Ohne Spenden-Link (config.ts) oder nach dem Schließen wird nichts angezeigt.
|
||||||
import './shell.css';
|
import './shell.css';
|
||||||
import { el } from '../editor/dom';
|
import { el } from '../editor/dom';
|
||||||
|
import { t } from '../i18n/i18n';
|
||||||
|
|
||||||
const CLOSED_KEY = 'aircard.donateBannerClosed';
|
const CLOSED_KEY = 'aircard.donateBannerClosed';
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export function mountDonateBanner(slot: HTMLElement, url: string): void {
|
|||||||
slot.replaceChildren();
|
slot.replaceChildren();
|
||||||
if (!url.trim() || isClosed()) return;
|
if (!url.trim() || isClosed()) return;
|
||||||
|
|
||||||
const close = el('button', { type: 'button', class: 'donate-banner-close', 'aria-label': 'Banner schließen' }, '×');
|
const close = el('button', { type: 'button', class: 'donate-banner-close', 'aria-label': t('Banner schließen') }, '×');
|
||||||
close.addEventListener('click', () => {
|
close.addEventListener('click', () => {
|
||||||
rememberClosed();
|
rememberClosed();
|
||||||
slot.replaceChildren();
|
slot.replaceChildren();
|
||||||
@@ -38,9 +39,9 @@ export function mountDonateBanner(slot: HTMLElement, url: string): void {
|
|||||||
'p',
|
'p',
|
||||||
{ class: 'donate-banner-text' },
|
{ class: 'donate-banner-text' },
|
||||||
el('span', { 'aria-hidden': 'true' }, '☕'),
|
el('span', { 'aria-hidden': 'true' }, '☕'),
|
||||||
' Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.',
|
` ${t('Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.')}`,
|
||||||
),
|
),
|
||||||
el('a', { class: 'donate-banner-link', href: url, target: '_blank', rel: 'noopener noreferrer' }, 'Unterstützen →'),
|
el('a', { class: 'donate-banner-link', href: url, target: '_blank', rel: 'noopener noreferrer' }, `${t('Unterstützen')} →`),
|
||||||
close,
|
close,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { setLang } from '../i18n/i18n';
|
||||||
|
import { mountFooter } from './footer';
|
||||||
|
|
||||||
|
// Wortlaut aus der Spec (§ 7 „Fußzeile“)
|
||||||
|
const DE =
|
||||||
|
'Inoffizielle Community-Seite für die App AirCard-iOS – kein Teil des Original-Projekts und nicht mit Apple verbunden. Logos und Motive stammen von Nutzern; alle Marken gehören ihren Inhabern.';
|
||||||
|
const EN_TEXT =
|
||||||
|
'Unofficial community site for the AirCard-iOS app – not part of the original project and not affiliated with Apple. Logos and artwork are uploaded by users; all trademarks belong to their owners.';
|
||||||
|
|
||||||
|
let footer: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<footer id="app-footer"></footer>';
|
||||||
|
footer = document.getElementById('app-footer') as HTMLElement;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountFooter', () => {
|
||||||
|
it('Deutsch: ein Absatz mit dem Fußzeilentext der Spec', () => {
|
||||||
|
mountFooter(footer);
|
||||||
|
expect(footer.children).toHaveLength(1);
|
||||||
|
expect(footer.firstElementChild?.tagName).toBe('P');
|
||||||
|
expect(footer.textContent).toBe(DE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Englisch: nach setLang und erneutem Einhängen der englische Text', () => {
|
||||||
|
mountFooter(footer);
|
||||||
|
setLang('en');
|
||||||
|
mountFooter(footer);
|
||||||
|
expect(footer.children).toHaveLength(1);
|
||||||
|
expect(footer.textContent).toBe(EN_TEXT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ersetzt den Inhalt, statt ihn zu ergänzen', () => {
|
||||||
|
footer.innerHTML = '<p>alt</p><span>mehr</span>';
|
||||||
|
mountFooter(footer);
|
||||||
|
mountFooter(footer);
|
||||||
|
expect(footer.children).toHaveLength(1);
|
||||||
|
expect(footer.textContent).toBe(DE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Fußzeilen-Block selbst bleibt stehen (Kennung und Stile hängen daran)', () => {
|
||||||
|
mountFooter(footer);
|
||||||
|
expect(document.getElementById('app-footer')).toBe(footer);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Fußzeile: der Hinweis, dass die Seite inoffiziell ist, in der gewählten Sprache (Wortlaut: Spec § 7).
|
||||||
|
// Das Element steht leer in index.html; main.ts baut den Text beim Start und nach jedem Sprachwechsel neu auf.
|
||||||
|
import { el } from '../editor/dom';
|
||||||
|
import { t } from '../i18n/i18n';
|
||||||
|
|
||||||
|
export function mountFooter(footer: HTMLElement): void {
|
||||||
|
footer.replaceChildren(
|
||||||
|
el(
|
||||||
|
'p',
|
||||||
|
{},
|
||||||
|
t(
|
||||||
|
'Inoffizielle Community-Seite für die App AirCard-iOS – kein Teil des Original-Projekts und nicht mit Apple verbunden. Logos und Motive stammen von Nutzern; alle Marken gehören ihren Inhabern.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ vi.mock('../pages/router', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { GITHUB_URL } from '../config';
|
||||||
|
import { currentLang, setLang } from '../i18n/i18n';
|
||||||
import { mountHeader } from './header';
|
import { mountHeader } from './header';
|
||||||
|
|
||||||
const DONATE = 'https://buymeacoffee.com/aircard';
|
const DONATE = 'https://buymeacoffee.com/aircard';
|
||||||
@@ -29,6 +31,7 @@ const input = () => search().querySelector<HTMLInputElement>('input')!;
|
|||||||
const submit = () => search().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
const submit = () => search().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
const menuButton = () => header.querySelector<HTMLButtonElement>('.menu-btn')!;
|
const menuButton = () => header.querySelector<HTMLButtonElement>('.menu-btn')!;
|
||||||
const nav = () => header.querySelector<HTMLElement>('nav.app-nav')!;
|
const nav = () => header.querySelector<HTMLElement>('nav.app-nav')!;
|
||||||
|
const langButton = () => header.querySelector<HTMLButtonElement>('button.lang-btn')!;
|
||||||
|
|
||||||
/** Lässt den Router eine Seite melden (wie startRouter/navigate es tun). */
|
/** Lässt den Router eine Seite melden (wie startRouter/navigate es tun). */
|
||||||
function route(id: string, search = ''): void {
|
function route(id: string, search = ''): void {
|
||||||
@@ -70,7 +73,9 @@ describe('mountHeader: Aufbau', () => {
|
|||||||
|
|
||||||
it('Navigation: Community, Gestalten, Meine Karten als data-link', () => {
|
it('Navigation: Community, Gestalten, Meine Karten als data-link', () => {
|
||||||
mount();
|
mount();
|
||||||
const links = [...nav().querySelectorAll<HTMLAnchorElement>('a')].filter((a) => !a.classList.contains('nav-donate'));
|
const links = [...nav().querySelectorAll<HTMLAnchorElement>('a')].filter(
|
||||||
|
(a) => !a.classList.contains('nav-donate') && !a.classList.contains('nav-github'),
|
||||||
|
);
|
||||||
expect(links.map((a) => [a.textContent, a.getAttribute('href'), a.hasAttribute('data-link')])).toEqual([
|
expect(links.map((a) => [a.textContent, a.getAttribute('href'), a.hasAttribute('data-link')])).toEqual([
|
||||||
['Community', '/#community', true],
|
['Community', '/#community', true],
|
||||||
['Gestalten', '/gestalten', true],
|
['Gestalten', '/gestalten', true],
|
||||||
@@ -235,6 +240,146 @@ describe('mountHeader: Spenden-Knopf', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mountHeader: GitHub-Link', () => {
|
||||||
|
it('GITHUB_URL zeigt auf das Original-Repo der App', () => {
|
||||||
|
expect(GITHUB_URL).toBe('https://github.com/Mak5er/AirCard-iOS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Link mit GitHub-Zeichen und Text „GitHub“ in neuem Tab', () => {
|
||||||
|
mount();
|
||||||
|
const link = header.querySelector<HTMLAnchorElement>('a.header-github')!;
|
||||||
|
expect(link.getAttribute('href')).toBe(GITHUB_URL);
|
||||||
|
expect(link.target).toBe('_blank');
|
||||||
|
expect(link.rel).toBe('noopener noreferrer');
|
||||||
|
expect(link.getAttribute('title')).toBe('AirCard-iOS auf GitHub');
|
||||||
|
expect(link.hasAttribute('data-link')).toBe(false);
|
||||||
|
expect(link.querySelector('svg.icon')).not.toBeNull();
|
||||||
|
expect(link.querySelector('.header-github-text')?.textContent).toBe('GitHub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('steht unabhängig vom Spenden-Link in der Kopfzeile', () => {
|
||||||
|
mount('');
|
||||||
|
expect(header.querySelector('.header-tools a.header-github')).not.toBeNull();
|
||||||
|
dispose?.();
|
||||||
|
mount(DONATE);
|
||||||
|
expect(header.querySelector('.header-tools a.header-github')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das Menü (unter 600 px statt des Kopfzeilen-Links) enthält einen zweiten GitHub-Link', () => {
|
||||||
|
mount();
|
||||||
|
const link = nav().querySelector<HTMLAnchorElement>('a.nav-github')!;
|
||||||
|
expect(link.textContent).toBe('GitHub');
|
||||||
|
expect(link.getAttribute('href')).toBe(GITHUB_URL);
|
||||||
|
expect(link.target).toBe('_blank');
|
||||||
|
expect(link.rel).toBe('noopener noreferrer');
|
||||||
|
expect(link.hasAttribute('data-link')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('externe Links bekommen nie aria-current', () => {
|
||||||
|
mount();
|
||||||
|
for (const id of ['home', 'gestalten', 'cards', 'community', 'reset']) route(id);
|
||||||
|
expect(header.querySelector('.nav-github')?.hasAttribute('aria-current')).toBe(false);
|
||||||
|
expect(header.querySelector('.header-github')?.hasAttribute('aria-current')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountHeader: Sprachknopf', () => {
|
||||||
|
it('Deutsch: Globus und „EN“, Beschriftung in der Zielsprache, lang="en"', () => {
|
||||||
|
mount();
|
||||||
|
const button = langButton();
|
||||||
|
expect(button.type).toBe('button');
|
||||||
|
expect(button.classList.contains('header-btn')).toBe(true);
|
||||||
|
expect(button.textContent).toBe('EN');
|
||||||
|
expect(button.getAttribute('aria-label')).toBe('Switch to English');
|
||||||
|
expect(button.getAttribute('title')).toBe('Switch to English');
|
||||||
|
expect(button.getAttribute('lang')).toBe('en');
|
||||||
|
expect(button.querySelector('svg.icon')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Klick schaltet auf Englisch um und merkt sich die Wahl', () => {
|
||||||
|
mount();
|
||||||
|
langButton().click();
|
||||||
|
expect(currentLang()).toBe('en');
|
||||||
|
expect(localStorage.getItem('aircard.lang')).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Englisch (neu gemountet): „DE“, „Auf Deutsch umschalten“, lang="de"; Klick schaltet zurück', () => {
|
||||||
|
mount();
|
||||||
|
langButton().click();
|
||||||
|
dispose?.();
|
||||||
|
mount();
|
||||||
|
const button = langButton();
|
||||||
|
expect(button.textContent).toBe('DE');
|
||||||
|
expect(button.getAttribute('aria-label')).toBe('Auf Deutsch umschalten');
|
||||||
|
expect(button.getAttribute('title')).toBe('Auf Deutsch umschalten');
|
||||||
|
expect(button.getAttribute('lang')).toBe('de');
|
||||||
|
button.click();
|
||||||
|
expect(currentLang()).toBe('de');
|
||||||
|
expect(localStorage.getItem('aircard.lang')).toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bleibt ohne Spenden-Link in der Kopfzeile', () => {
|
||||||
|
mount('');
|
||||||
|
expect(header.querySelector('.header-tools button.lang-btn')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountHeader: Knöpfe rechts', () => {
|
||||||
|
const TOOLS = ['a.header-github', 'button.lang-btn', 'a.header-donate', 'button.theme-btn', 'button.menu-btn'];
|
||||||
|
/** Welche der erwarteten Knöpfe in .header-tools stehen, in der Reihenfolge der Kopfzeile. */
|
||||||
|
const tools = () => [...header.querySelector('.header-tools')!.children].map((node) => TOOLS.find((selector) => node.matches(selector)));
|
||||||
|
|
||||||
|
it('Reihenfolge: GitHub, Sprache, Spenden, Hell/Dunkel, Menü', () => {
|
||||||
|
mount(DONATE);
|
||||||
|
expect(tools()).toEqual(TOOLS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Spenden-Link fehlt nur der Spenden-Knopf', () => {
|
||||||
|
mount('');
|
||||||
|
expect(tools()).toEqual(TOOLS.filter((selector) => selector !== 'a.header-donate'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountHeader: Englisch', () => {
|
||||||
|
it('Navigation, Suche und Knöpfe tragen englische Texte', () => {
|
||||||
|
setLang('en');
|
||||||
|
mount(DONATE);
|
||||||
|
expect(nav().getAttribute('aria-label')).toBe('Main navigation');
|
||||||
|
const links = [...nav().querySelectorAll<HTMLAnchorElement>('a[data-link]')];
|
||||||
|
expect(links.map((a) => a.textContent)).toEqual(['Community', 'Create', 'My cards']);
|
||||||
|
expect(links.map((a) => a.getAttribute('href'))).toEqual(['/#community', '/gestalten', '/karten']); // Adressen bleiben deutsch
|
||||||
|
expect(input().placeholder).toBe('Title, tags or issuer');
|
||||||
|
expect(input().getAttribute('aria-label')).toBe('Search community cards');
|
||||||
|
expect(search().querySelector('button')?.getAttribute('aria-label')).toBe('Search');
|
||||||
|
const theme = header.querySelector('button.theme-btn')!;
|
||||||
|
expect(theme.getAttribute('aria-label')).toBe('Toggle light/dark mode');
|
||||||
|
expect(theme.getAttribute('title')).toBe('Toggle light/dark mode');
|
||||||
|
expect(menuButton().getAttribute('aria-label')).toBe('Menu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GitHub-Link, Spenden-Knopf und Sprachknopf auf Englisch', () => {
|
||||||
|
setLang('en');
|
||||||
|
mount(DONATE);
|
||||||
|
expect(header.querySelector('a.header-github')?.getAttribute('title')).toBe('AirCard-iOS on GitHub');
|
||||||
|
expect(header.querySelector('a.header-github .header-github-text')?.textContent).toBe('GitHub'); // der Name wird nicht übersetzt
|
||||||
|
expect(header.querySelector('a.header-donate')?.getAttribute('title')).toBe('Support us');
|
||||||
|
expect(header.querySelector('a.header-donate .header-donate-text')?.textContent).toBe('Support us');
|
||||||
|
expect(header.querySelector('a.nav-donate')?.textContent).toBe('☕ Support us');
|
||||||
|
expect(header.querySelector('a.nav-github')?.textContent).toBe('GitHub');
|
||||||
|
expect(langButton().textContent).toBe('DE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Deutsch bleibt Deutsch, wenn man wieder zurückschaltet', () => {
|
||||||
|
setLang('en');
|
||||||
|
mount(DONATE);
|
||||||
|
dispose?.();
|
||||||
|
setLang('de');
|
||||||
|
mount(DONATE);
|
||||||
|
expect(input().placeholder).toBe('Titel, Tags oder Herausgeber');
|
||||||
|
expect(header.querySelector('a.nav-donate')?.textContent).toBe('☕ Unterstützen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('mountHeader: Hell/Dunkel', () => {
|
describe('mountHeader: Hell/Dunkel', () => {
|
||||||
it('Knopf „Hell/Dunkel umschalten“ wechselt data-theme und speichert', () => {
|
it('Knopf „Hell/Dunkel umschalten“ wechselt data-theme und speichert', () => {
|
||||||
mount();
|
mount();
|
||||||
@@ -248,7 +393,7 @@ describe('mountHeader: Hell/Dunkel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mountHeader: Menü unter 900 px', () => {
|
describe('mountHeader: Menü unter 1000 px', () => {
|
||||||
it('Menü-Knopf öffnet und schließt die Navigation (aria-expanded)', () => {
|
it('Menü-Knopf öffnet und schließt die Navigation (aria-expanded)', () => {
|
||||||
mount();
|
mount();
|
||||||
const button = menuButton();
|
const button = menuButton();
|
||||||
|
|||||||
+48
-18
@@ -1,8 +1,11 @@
|
|||||||
// Kopfzeile: Marke und Konto-Slot stehen in index.html, hier kommen Navigation, Suche und Knöpfe dazu.
|
// Kopfzeile: Marke und Konto-Slot stehen in index.html, hier kommen Navigation, Suche und Knöpfe dazu
|
||||||
// Ab 900 px Breite liegt alles in einer Zeile; darunter steckt die Navigation hinter dem Menü-Knopf
|
// (rechts: GitHub, Sprache, Spenden, Hell/Dunkel, Menü). Ab 1000 px Breite liegt alles in einer Zeile; darunter steckt
|
||||||
// und das Suchfeld bekommt eine eigene Zeile (Stile in shell.css).
|
// die Navigation hinter dem Menü-Knopf und das Suchfeld bekommt eine eigene Zeile (Stile in shell.css).
|
||||||
|
// Alle Texte kommen in der Sprache der Seite; nach einem Sprachwechsel baut main.ts die Kopfzeile neu auf.
|
||||||
import './shell.css';
|
import './shell.css';
|
||||||
|
import { GITHUB_URL } from '../config';
|
||||||
import { el, icon } from '../editor/dom';
|
import { el, icon } from '../editor/dom';
|
||||||
|
import { currentLang, setLang, t, type Lang } from '../i18n/i18n';
|
||||||
import { navigate, onRouteChange, type RouteId } from '../pages/router';
|
import { navigate, onRouteChange, type RouteId } from '../pages/router';
|
||||||
import { toggleTheme } from './theme';
|
import { toggleTheme } from './theme';
|
||||||
|
|
||||||
@@ -24,33 +27,38 @@ export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => voi
|
|||||||
navLinks.push({ anchor, routes });
|
navLinks.push({ anchor, routes });
|
||||||
return anchor;
|
return anchor;
|
||||||
};
|
};
|
||||||
const donateAttrs = { href: opts.donateUrl, target: '_blank', rel: 'noopener noreferrer' };
|
// Externe Links öffnen in neuem Tab (und sind nie „aktuelle Seite“).
|
||||||
|
const external = (href: string) => ({ href, target: '_blank', rel: 'noopener noreferrer' });
|
||||||
|
const donateAttrs = external(opts.donateUrl);
|
||||||
|
const githubAttrs = external(GITHUB_URL);
|
||||||
|
|
||||||
const nav = el(
|
const nav = el(
|
||||||
'nav',
|
'nav',
|
||||||
{ class: 'app-nav', id: 'app-nav', 'aria-label': 'Hauptnavigation' },
|
{ class: 'app-nav', id: 'app-nav', 'aria-label': t('Hauptnavigation') },
|
||||||
navLink('Community', '/#community', ['home', 'community']),
|
navLink(t('Community'), '/#community', ['home', 'community']),
|
||||||
navLink('Gestalten', '/gestalten', ['gestalten']),
|
navLink(t('Gestalten'), '/gestalten', ['gestalten']),
|
||||||
navLink('Meine Karten', '/karten', ['cards']),
|
navLink(t('Meine Karten'), '/karten', ['cards']),
|
||||||
);
|
);
|
||||||
// Unter 600 px ist in der Kopfzeile kein Platz mehr für den Knopf: er steht dann im aufgeklappten Menü.
|
// Unter 600 px ist in der Kopfzeile kein Platz mehr für GitHub- und Spenden-Knopf: sie stehen dann im aufgeklappten Menü.
|
||||||
if (hasDonate) nav.append(el('a', { class: 'nav-donate', ...donateAttrs }, '☕ Unterstützen'));
|
nav.append(el('a', { class: 'nav-github', ...githubAttrs }, 'GitHub'));
|
||||||
|
if (hasDonate) nav.append(el('a', { class: 'nav-donate', ...donateAttrs }, `☕ ${t('Unterstützen')}`));
|
||||||
|
|
||||||
const input = el('input', {
|
const input = el('input', {
|
||||||
class: 'header-search-input',
|
class: 'header-search-input',
|
||||||
type: 'search',
|
type: 'search',
|
||||||
name: 'q',
|
name: 'q',
|
||||||
placeholder: 'Titel, Tags oder Herausgeber',
|
placeholder: t('Titel, Tags oder Herausgeber'),
|
||||||
'aria-label': 'Community-Karten durchsuchen',
|
'aria-label': t('Community-Karten durchsuchen'),
|
||||||
autocomplete: 'off',
|
autocomplete: 'off',
|
||||||
enterkeyhint: 'search',
|
enterkeyhint: 'search',
|
||||||
});
|
});
|
||||||
const searchButton = el('button', { class: 'header-search-btn', type: 'submit', 'aria-label': 'Suchen' }, icon('search'));
|
const searchButton = el('button', { class: 'header-search-btn', type: 'submit', 'aria-label': t('Suchen') }, icon('search'));
|
||||||
const search = el('form', { class: 'header-search', role: 'search' }, input, searchButton);
|
const search = el('form', { class: 'header-search', role: 'search' }, input, searchButton);
|
||||||
|
|
||||||
|
const themeLabel = t('Hell/Dunkel umschalten');
|
||||||
const themeButton = el(
|
const themeButton = el(
|
||||||
'button',
|
'button',
|
||||||
{ class: 'header-btn theme-btn', type: 'button', 'aria-label': 'Hell/Dunkel umschalten', title: 'Hell/Dunkel umschalten' },
|
{ class: 'header-btn theme-btn', type: 'button', 'aria-label': themeLabel, title: themeLabel },
|
||||||
icon('sun', 'theme-icon-sun'),
|
icon('sun', 'theme-icon-sun'),
|
||||||
icon('moon', 'theme-icon-moon'),
|
icon('moon', 'theme-icon-moon'),
|
||||||
);
|
);
|
||||||
@@ -58,25 +66,47 @@ export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => voi
|
|||||||
|
|
||||||
const menuButton = el(
|
const menuButton = el(
|
||||||
'button',
|
'button',
|
||||||
{ class: 'header-btn menu-btn', type: 'button', 'aria-label': 'Menü', 'aria-expanded': 'false', 'aria-controls': nav.id },
|
{ class: 'header-btn menu-btn', type: 'button', 'aria-label': t('Menü'), 'aria-expanded': 'false', 'aria-controls': nav.id },
|
||||||
icon('menu'),
|
icon('menu'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Der Sprachknopf zeigt die Zielsprache: Kürzel, Beschriftung und lang-Attribut stehen in dieser Sprache (kein t()).
|
||||||
|
const targetLang: Lang = currentLang() === 'de' ? 'en' : 'de';
|
||||||
|
const langLabel = targetLang === 'en' ? 'Switch to English' : 'Auf Deutsch umschalten';
|
||||||
|
const langButton = el(
|
||||||
|
'button',
|
||||||
|
{ class: 'header-btn lang-btn', type: 'button', 'aria-label': langLabel, title: langLabel, lang: targetLang },
|
||||||
|
icon('globe'),
|
||||||
|
el('span', {}, targetLang.toUpperCase()),
|
||||||
|
);
|
||||||
|
// Das Neuzeichnen von Kopfzeile und Seite übernimmt der Hörer in main.ts.
|
||||||
|
langButton.addEventListener('click', () => setLang(targetLang));
|
||||||
|
|
||||||
const tools = el('div', { class: 'header-tools' });
|
const tools = el('div', { class: 'header-tools' });
|
||||||
|
tools.append(
|
||||||
|
el(
|
||||||
|
'a',
|
||||||
|
// title: zwischen 600 und 1229 px zeigt der Link nur das Symbol (Stile in shell.css).
|
||||||
|
{ class: 'header-github', title: t('AirCard-iOS auf GitHub'), ...githubAttrs },
|
||||||
|
icon('github'),
|
||||||
|
el('span', { class: 'header-github-text' }, 'GitHub'),
|
||||||
|
),
|
||||||
|
langButton,
|
||||||
|
);
|
||||||
if (hasDonate) {
|
if (hasDonate) {
|
||||||
tools.append(
|
tools.append(
|
||||||
el(
|
el(
|
||||||
'a',
|
'a',
|
||||||
// title: zwischen 600 und 1179 px zeigt der Knopf nur das Symbol (Stile in shell.css).
|
// title: zwischen 600 und 1179 px zeigt der Knopf nur das Symbol (Stile in shell.css).
|
||||||
{ class: 'header-donate', title: 'Unterstützen', ...donateAttrs },
|
{ class: 'header-donate', title: t('Unterstützen'), ...donateAttrs },
|
||||||
el('span', { class: 'header-donate-icon', 'aria-hidden': 'true' }, '☕'),
|
el('span', { class: 'header-donate-icon', 'aria-hidden': 'true' }, '☕'),
|
||||||
el('span', { class: 'header-donate-text' }, 'Unterstützen'),
|
el('span', { class: 'header-donate-text' }, t('Unterstützen')),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
tools.append(themeButton, menuButton);
|
tools.append(themeButton, menuButton);
|
||||||
|
|
||||||
// Menü (nur unter 900 px sichtbar)
|
// Menü (nur unter 1000 px sichtbar)
|
||||||
let open = false;
|
let open = false;
|
||||||
function setOpen(next: boolean): void {
|
function setOpen(next: boolean): void {
|
||||||
open = next;
|
open = next;
|
||||||
|
|||||||
+98
-7
@@ -114,6 +114,8 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* GitHub- und Spenden-Link im Menü: erst unter 600 px sichtbar, wenn die Kopfzeile sie nicht mehr trägt */
|
||||||
|
.nav-github,
|
||||||
.nav-donate {
|
.nav-donate {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -181,7 +183,7 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Knöpfe rechts: Spenden, Hell/Dunkel, Menü */
|
/* Knöpfe rechts: GitHub, Sprache, Spenden, Hell/Dunkel, Menü */
|
||||||
|
|
||||||
.header-tools {
|
.header-tools {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -211,7 +213,8 @@
|
|||||||
background: var(--panel-2);
|
background: var(--panel-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-btn .icon {
|
.header-btn .icon,
|
||||||
|
.header-github .icon {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
fill: none;
|
fill: none;
|
||||||
@@ -241,6 +244,47 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sprachknopf: Globus und Kürzel der Zielsprache (EN bzw. DE); bleibt bei jeder Breite in der Kopfzeile */
|
||||||
|
.header-btn.lang-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
width: auto;
|
||||||
|
padding: 0 9px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn .icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GitHub-Link: Zeichen und Text, im Stil der übrigen Knöpfe (der Spenden-Knopf bleibt der hervorgehobene) */
|
||||||
|
.header-github {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background-color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-github:hover {
|
||||||
|
border-color: var(--sel-line);
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
.header-donate {
|
.header-donate {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -291,8 +335,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop mit wenig Platz (900–1099 px): alles etwas enger, damit die Zeile nicht umbricht */
|
/* Bis 1229 px zeigt auch der GitHub-Link nur das Zeichen: mit seinem Text und dem deutschen „Unterstützen“ braucht die Zeile
|
||||||
@media (min-width: 900px) and (max-width: 1099px) {
|
rund 1210 px, sonst rutscht „Anmelden“ in eine zweite Zeile */
|
||||||
|
@media (max-width: 1229px) {
|
||||||
|
.header-github {
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-github-text {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop mit wenig Platz (1000–1099 px): alles etwas enger, damit die Zeile nicht umbricht */
|
||||||
|
@media (min-width: 1000px) and (max-width: 1099px) {
|
||||||
#app-header {
|
#app-header {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
@@ -308,13 +374,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Unter 900 px: Navigation hinter dem Menü-Knopf, Suchfeld als eigene Zeile darunter */
|
/* Unter 1000 px: Navigation hinter dem Menü-Knopf, Suchfeld als eigene Zeile darunter
|
||||||
@media (max-width: 899px) {
|
(früher unter 900 px; mit GitHub- und Sprachknopf passt die eine Zeile erst ab 1000 px) */
|
||||||
|
@media (max-width: 999px) {
|
||||||
#app-header {
|
#app-header {
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
gap: 10px 8px;
|
gap: 10px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.menu-btn {
|
.menu-btn {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
@@ -356,7 +427,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Unter 600 px: Marke und Knöpfe etwas kleiner, damit „Anmelden“ noch in die erste Zeile passt;
|
/* Unter 600 px: Marke und Knöpfe etwas kleiner, damit „Anmelden“ noch in die erste Zeile passt;
|
||||||
der Spenden-Knopf wandert ins aufgeklappte Menü */
|
GitHub- und Spenden-Knopf wandern ins aufgeklappte Menü, der Sprachknopf bleibt */
|
||||||
@media (max-width: 599px) {
|
@media (max-width: 599px) {
|
||||||
#app-header {
|
#app-header {
|
||||||
gap: 10px 6px;
|
gap: 10px 6px;
|
||||||
@@ -387,16 +458,35 @@
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-github,
|
||||||
.header-donate {
|
.header-donate {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-github,
|
||||||
.nav-donate {
|
.nav-donate {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Schmale Telefone: reicht die erste Zeile nicht für Marke, Knöpfe und „Anmelden“, steht „Anmelden“ neben der Suche
|
||||||
|
statt allein in einer eigenen Zeile (die Suche braucht dafür weniger als die volle Breite); das aufgeklappte Menü
|
||||||
|
erscheint dann direkt unter der Markenzeile */
|
||||||
|
@media (max-width: 449px) {
|
||||||
|
.app-nav {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#account-slot {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search {
|
||||||
|
flex-basis: 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Sehr schmale Telefone */
|
/* Sehr schmale Telefone */
|
||||||
@media (max-width: 379px) {
|
@media (max-width: 379px) {
|
||||||
#app-header {
|
#app-header {
|
||||||
@@ -410,6 +500,7 @@
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.header-btn,
|
.header-btn,
|
||||||
|
.header-github,
|
||||||
.header-donate {
|
.header-donate {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Gemeinsames Test-Setup (vite.config.ts → test.setupFiles): jeder Test beginnt auf Deutsch und merkt sich die Sprache nirgends.
|
||||||
|
import { beforeEach } from 'vitest';
|
||||||
|
import { setLang } from './i18n/i18n';
|
||||||
|
|
||||||
|
beforeEach(() => setLang('de', { persist: false }));
|
||||||
@@ -3,6 +3,8 @@ import { defineConfig } from 'vitest/config';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
|
// Jeder Test beginnt auf Deutsch (siehe src/test-setup.ts).
|
||||||
|
setupFiles: ['src/test-setup.ts'],
|
||||||
// scripts/*.test.mjs laufen mit `node --test` (siehe web/scripts/appwrite-setup.test.mjs).
|
// scripts/*.test.mjs laufen mit `node --test` (siehe web/scripts/appwrite-setup.test.mjs).
|
||||||
include: ['src/**/*.test.ts'],
|
include: ['src/**/*.test.ts'],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user