155 lines
6.6 KiB
TypeScript
155 lines
6.6 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
import { runInNewContext } from 'node:vm';
|
||
import { describe, expect, it } from 'vitest';
|
||
import indexHtml from '../index.html?raw';
|
||
import { initialLang } from './i18n/i18n';
|
||
import { initialTheme } from './shell/theme';
|
||
|
||
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../public', name));
|
||
|
||
/** Breite, Höhe und Farbtyp aus dem IHDR-Block eines PNG (Farbtyp 2 = RGB ohne Alphakanal, 6 = RGBA). */
|
||
function pngInfo(buf: Buffer) {
|
||
expect(buf.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a');
|
||
expect(buf.subarray(12, 16).toString('ascii')).toBe('IHDR');
|
||
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20), colorType: buf[25] };
|
||
}
|
||
|
||
describe('Name und Kopfbereich', () => {
|
||
it('Titel und Meta-Angaben nennen AirCard-IOS', () => {
|
||
// 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="Explore 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="apple-mobile-web-app-title" content="AirCard-IOS" />');
|
||
});
|
||
|
||
it('Icon-Links: SVG, 32-px-PNG, Apple-Touch-Icon', () => {
|
||
expect(indexHtml).toContain('<link rel="icon" href="/favicon.svg?v=2" type="image/svg+xml" />');
|
||
expect(indexHtml).toContain('<link rel="icon" href="/favicon-32.png?v=2" sizes="32x32" type="image/png" />');
|
||
expect(indexHtml).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />');
|
||
});
|
||
|
||
it('Kopfzeile: dekoratives Logo (28 x 28) links vom Namen, beides im Markenlink', () => {
|
||
expect(indexHtml).toContain(
|
||
'<a class="brand" href="/" data-link><img src="/favicon.svg?v=2" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>',
|
||
);
|
||
});
|
||
|
||
it('Fußzeile ist leer: ihren Text setzt mountFooter in der gewählten Sprache', () => {
|
||
expect(indexHtml).toContain('<footer id="app-footer"></footer>');
|
||
expect(indexHtml).not.toContain('Inoffizielles Hobbyprojekt');
|
||
});
|
||
});
|
||
|
||
describe('Icon-Dateien', () => {
|
||
it('favicon-32.png ist 32 x 32', () => {
|
||
expect(pngInfo(publicFile('favicon-32.png'))).toMatchObject({ width: 32, height: 32 });
|
||
});
|
||
|
||
it('apple-touch-icon.png ist 180 x 180 und deckend (RGB ohne Alphakanal, kein transparenter Rand)', () => {
|
||
expect(pngInfo(publicFile('apple-touch-icon.png'))).toEqual({ width: 180, height: 180, colorType: 2 });
|
||
});
|
||
});
|
||
|
||
describe('Inline-Skript für Hell/Dunkel und Sprache in index.html', () => {
|
||
const script = /<script>([\s\S]*?)<\/script>/.exec(indexHtml)?.[1] ?? '';
|
||
|
||
/** Was der Browser zur Sprache hergibt: der gespeicherte Wert von `aircard.lang`, `navigator.languages` und `navigator.language`. */
|
||
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 sandbox: Record<string, unknown> = {
|
||
localStorage: {
|
||
getItem: (key: string) => {
|
||
if (storageThrows) throw new Error('gesperrt');
|
||
return key === 'aircard.lang' ? (browser.stored ?? null) : stored;
|
||
},
|
||
},
|
||
window: {
|
||
matchMedia: () => ({ matches: prefersDark }),
|
||
navigator: { languages: browser.languages, language: browser.language },
|
||
},
|
||
document: { documentElement: { setAttribute: (name: string, value: string) => (attributes[name] = value) } },
|
||
};
|
||
runInNewContext(script, sandbox);
|
||
return { attributes, sandbox };
|
||
}
|
||
|
||
it('ist da und läuft ohne Browser-Umgebung durch', () => {
|
||
expect(script).toContain('data-theme');
|
||
expect(() => run(null, true)).not.toThrow();
|
||
});
|
||
|
||
it('legt keine globale Variable an (Skript steckt in einer sofort aufgerufenen Funktion)', () => {
|
||
const { sandbox } = run('dark', false);
|
||
expect(Object.keys(sandbox).sort()).toEqual(['document', 'localStorage', 'window']);
|
||
expect('theme' in sandbox).toBe(false);
|
||
expect('lang' in sandbox).toBe(false);
|
||
});
|
||
|
||
it.each([
|
||
['dark', false],
|
||
['light', true],
|
||
['dark', true],
|
||
['light', false],
|
||
['blau', true],
|
||
['blau', false],
|
||
['', true],
|
||
[null, true],
|
||
[null, false],
|
||
] as const)('gleiche Regel wie initialTheme(): gespeichert %j, Systemvorgabe dunkel %s', (stored, prefersDark) => {
|
||
expect(run(stored, prefersDark).attributes['data-theme']).toBe(initialTheme(stored, prefersDark));
|
||
});
|
||
|
||
it('gesperrter localStorage: es gilt die Systemvorgabe', () => {
|
||
expect(run(null, true, true).attributes['data-theme']).toBe('dark');
|
||
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');
|
||
});
|
||
});
|