- card/config.ts: network und das Apple-Logo entfallen. CardConfig.logo ist die ID eines Community-Logos (Muster ID_PATTERN,
sonst null), DEFAULT_CONFIG.logo = null, serializeConfig schreibt {v, template, logo, details, brand, crop}. Ein altes network
wird ignoriert, brand 'apple' fällt auf den Standard. ID_PATTERN steht jetzt hier (die reine Konfiguration prüft die Logo-ID,
ohne Appwrite zu laden), account/cards.ts reicht sie weiter; single-sources.test.ts sorgt für genau eine Definition.
- card/geometry.ts: fitLogo und LOGO_MAX_H (125) ersetzen fitNetwork, Layout.network heißt Layout.logo.
- card/marks.ts: nur noch die AirCard-IOS-Marken (BRAND_MARKS, getBrandMark, svgAspect, tintSvg, loadMarkImage). Die 19 SVGs
der Netzwerke und von Apple sind per git rm gelöscht, ein Test hält assets/marks auf die zwei AirCard-Dateien.
- card/render.ts: LogoAsset, CardAssets.logo statt network, LoadedAssets.logoMissing. loadCardAssets(config, image, loadLogo =
loadLogoAsset): ohne Logo kein Aufruf, null vom Lader ergibt logoMissing, ein Ladefehler wirft. Der Renderer zeichnet das
Logo mit fitLogo(w / h) unten rechts, wenn Vorlage und Bilder eines haben.
- logos/logo-asset.ts (neu): loadLogoAsset(id, tone) holt Zeile (getLogo) und PNG (loadLogoBlob) und dekodiert mit
createImageBitmap, sonst über ein Image. Einfarbige Logos werden in einem eigenen Canvas mit source-in in der Details-Farbe
gefüllt, mehrfarbige bleiben. Fehlende Zeile, ausgeblendetes Logo (auch für Hochlader und Admins), Datei 404, unlesbare
Datei und mehr als 1 000 000 Pixel ergeben null. Je (id, tone) ein Ladevorgang, ein Fehler bleibt nicht im Cache.
rememberLogo, forgetLogo und LOGO_MISSING nach der Schnittstelle des Plans.
- editor/options-panel.ts: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit Symbol, Text und Kein Logo (ohne
Apple), dazu logoSlot als leerer Platz oben für Task 8 (editor.css: Details rückt bei leerem Platz wie bisher nach oben).
- editor/editor-page.ts: logoKey aus Logo, Details und Marke, der Logo-Zustand hält missing. Fehlt das Logo, wird die Karte
ohne es gezeichnet und der Hinweis LOGO_MISSING gezeigt, solange das so bleibt; ein Wechsel auf ein vorhandenes Logo oder
auf kein Logo nimmt nur diesen Hinweis weg (PNG-Größe und „Bild fehlt“ stehen getrennt und bleiben). Kein LOGO_ERROR bei
fehlendem Logo, nur bei echtem Ladefehler, auch beim Download.
- community/publish.ts: LOGO_MISSING_PUBLISH, renderForGallery (jetzt exportiert) bricht bei logoMissing vor dem Rendern ab,
es wird nichts hochgeladen. publish-dialog.ts lässt den Text im Wortlaut durch.
- i18n/en.ts: englische Texte zu LOGO_MISSING, LOGO_MISSING_PUBLISH und „Logo oben links“.
- Tests: config, geometry, marks, render und die Fixtures von cards, theme, cards-page auf das neue Modell; neu
logo-asset.test.ts (28) und options-panel.test.ts (10); editor-page (14 neue), publish und publish-dialog erweitert.
Vitest 45 Dateien, 1319 Tests grün, tsc und Build sauber.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
5.8 KiB
TypeScript
163 lines
5.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { DEFAULT_CONFIG } from '../card/config';
|
|
import { renderCard, type CardAssets, type Ctx } from '../card/render';
|
|
import { applyTheme, currentTheme, initialTheme, initTheme, toggleTheme } from './theme';
|
|
|
|
const KEY = 'aircard.theme';
|
|
const html = document.documentElement;
|
|
|
|
function mockMatchMedia(prefersDark: boolean) {
|
|
window.matchMedia = vi.fn((query: string) => ({
|
|
matches: query === '(prefers-color-scheme: dark)' ? prefersDark : false,
|
|
media: query,
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
})) as unknown as typeof window.matchMedia;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
html.removeAttribute('data-theme');
|
|
document.head.innerHTML = '<meta name="theme-color" content="#0F1828" />';
|
|
mockMatchMedia(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('initialTheme', () => {
|
|
it('ohne gespeicherten Wert gilt das System', () => {
|
|
expect(initialTheme(null, true)).toBe('dark');
|
|
expect(initialTheme(null, false)).toBe('light');
|
|
});
|
|
|
|
it('ein gespeicherter Wert schlägt das System', () => {
|
|
expect(initialTheme('light', true)).toBe('light');
|
|
expect(initialTheme('dark', false)).toBe('dark');
|
|
});
|
|
|
|
it('ein ungültiger Wert wird ignoriert (System)', () => {
|
|
expect(initialTheme('blau', true)).toBe('dark');
|
|
expect(initialTheme('blau', false)).toBe('light');
|
|
expect(initialTheme('', false)).toBe('light');
|
|
expect(initialTheme('LIGHT', true)).toBe('dark');
|
|
});
|
|
});
|
|
|
|
describe('applyTheme', () => {
|
|
it('setzt data-theme auf <html> und die Browserfarbe, speichert aber nichts', () => {
|
|
applyTheme('light');
|
|
expect(html.getAttribute('data-theme')).toBe('light');
|
|
expect(document.querySelector('meta[name="theme-color"]')?.getAttribute('content')).toBe('#F4F6FB');
|
|
applyTheme('dark');
|
|
expect(html.getAttribute('data-theme')).toBe('dark');
|
|
expect(document.querySelector('meta[name="theme-color"]')?.getAttribute('content')).toBe('#0F1828');
|
|
expect(localStorage.getItem(KEY)).toBeNull();
|
|
});
|
|
|
|
it('kommt auch ohne theme-color-Tag klar', () => {
|
|
document.head.innerHTML = '';
|
|
expect(() => applyTheme('light')).not.toThrow();
|
|
expect(html.getAttribute('data-theme')).toBe('light');
|
|
});
|
|
});
|
|
|
|
describe('initTheme', () => {
|
|
it('nimmt den gespeicherten Wert', () => {
|
|
localStorage.setItem(KEY, 'light');
|
|
expect(initTheme()).toBe('light');
|
|
expect(html.getAttribute('data-theme')).toBe('light');
|
|
});
|
|
|
|
it('ohne gespeicherten Wert das System (dunkel bzw. hell)', () => {
|
|
expect(initTheme()).toBe('dark');
|
|
mockMatchMedia(false);
|
|
expect(initTheme()).toBe('light');
|
|
});
|
|
|
|
it('wirft localStorage beim Lesen, gilt das System', () => {
|
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
throw new DOMException('blockiert', 'SecurityError');
|
|
});
|
|
mockMatchMedia(false);
|
|
expect(initTheme()).toBe('light');
|
|
expect(html.getAttribute('data-theme')).toBe('light');
|
|
});
|
|
|
|
it('fehlt matchMedia, bleibt es dunkel', () => {
|
|
window.matchMedia = undefined as unknown as typeof window.matchMedia;
|
|
expect(initTheme()).toBe('dark');
|
|
});
|
|
});
|
|
|
|
describe('toggleTheme', () => {
|
|
it('wechselt zwischen dunkel und hell, setzt data-theme und speichert', () => {
|
|
applyTheme('dark');
|
|
expect(toggleTheme()).toBe('light');
|
|
expect(html.getAttribute('data-theme')).toBe('light');
|
|
expect(localStorage.getItem(KEY)).toBe('light');
|
|
expect(toggleTheme()).toBe('dark');
|
|
expect(html.getAttribute('data-theme')).toBe('dark');
|
|
expect(localStorage.getItem(KEY)).toBe('dark');
|
|
});
|
|
|
|
it('ohne data-theme geht es vom Systemwert aus', () => {
|
|
mockMatchMedia(false); // System hell → Wechsel auf dunkel
|
|
expect(toggleTheme()).toBe('dark');
|
|
});
|
|
|
|
it('wirft localStorage beim Schreiben, gibt es keinen Fehler und der Wechsel gilt trotzdem', () => {
|
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
throw new DOMException('voll', 'QuotaExceededError');
|
|
});
|
|
applyTheme('dark');
|
|
expect(() => toggleTheme()).not.toThrow();
|
|
expect(html.getAttribute('data-theme')).toBe('light');
|
|
expect(currentTheme()).toBe('light');
|
|
});
|
|
});
|
|
|
|
describe('Theme und Kartenausgabe', () => {
|
|
// Umschalten verändert nur UI-Tokens: der Renderer liest weder data-theme noch CSS-Variablen.
|
|
function drawLog(): string[] {
|
|
const log: string[] = [];
|
|
const state: Record<string, unknown> = {};
|
|
const ctx = new Proxy(state, {
|
|
get(target, prop: string) {
|
|
if (prop in target) return target[prop];
|
|
if (prop === 'measureText') return (text: string) => ({ width: text.length * 20 });
|
|
if (prop === 'createLinearGradient') {
|
|
return (...args: number[]) => {
|
|
log.push(`gradient(${args.join(',')})`);
|
|
return { addColorStop: (offset: number, color: string) => log.push(`stop(${offset},${color})`) };
|
|
};
|
|
}
|
|
return (...args: unknown[]) => void log.push(`${prop}(${args.map((a) => (typeof a === 'object' ? 'obj' : String(a))).join(',')})`);
|
|
},
|
|
set(target, prop: string, value) {
|
|
target[prop] = value;
|
|
log.push(`${prop}=${String(value)}`);
|
|
return true;
|
|
},
|
|
});
|
|
const assets: CardAssets = {
|
|
image: null,
|
|
logo: { source: {} as CanvasImageSource, w: 200, h: 100 },
|
|
brand: {} as CanvasImageSource,
|
|
};
|
|
renderCard(ctx as unknown as Ctx, { ...DEFAULT_CONFIG, template: 'classic' }, assets, () => log.push('strokePath'));
|
|
return log;
|
|
}
|
|
|
|
it('hell und dunkel zeichnen dieselbe Karte', () => {
|
|
applyTheme('dark');
|
|
const dark = drawLog();
|
|
applyTheme('light');
|
|
const light = drawLog();
|
|
expect(dark.length).toBeGreaterThan(10);
|
|
expect(light).toEqual(dark);
|
|
expect(dark.join('\n')).toContain('fillStyle=#1B2738');
|
|
});
|
|
});
|