- 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>
228 lines
7.4 KiB
TypeScript
228 lines
7.4 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
CARD_H,
|
||
CARD_W,
|
||
LOGO_MAX_H,
|
||
MARGIN,
|
||
PREVIEW_RADIUS,
|
||
WALLET_KEEP_CLEAR,
|
||
fitBrand,
|
||
fitLogo,
|
||
layoutFor,
|
||
type Box,
|
||
} from './geometry';
|
||
import type { TemplateId } from './config';
|
||
|
||
const TEMPLATES: TemplateId[] = ['image', 'minimal', 'classic', 'signature'];
|
||
|
||
const intersects = (a: Box, b: Box) =>
|
||
a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
||
|
||
const insideCanvas = (b: Box) =>
|
||
b.x >= 0 && b.y >= 0 && b.x + b.w <= CARD_W && b.y + b.h <= CARD_H;
|
||
|
||
// Feste Worst-Case-Boxen der PLATINUM-Zeile (Text 360 px breit)
|
||
const PLATINUM_BOX: Partial<Record<TemplateId, Box>> = {
|
||
classic: { x: 1120, y: 77, w: 360, h: 58 },
|
||
signature: { x: 60, y: 182, w: 360, h: 55 },
|
||
};
|
||
const NUMBER_BOX: Box = { x: 157, y: 572, w: 867, h: 78 };
|
||
|
||
describe('Konstanten', () => {
|
||
it('Kartenmaße und Rand', () => {
|
||
expect(CARD_W).toBe(1536);
|
||
expect(CARD_H).toBe(969);
|
||
expect(MARGIN).toBe(56);
|
||
expect(PREVIEW_RADIUS).toBe(57);
|
||
});
|
||
|
||
it('WALLET_KEEP_CLEAR liegt unten links', () => {
|
||
expect(WALLET_KEEP_CLEAR).toEqual({ x: 0, y: 779, w: 620, h: 190 });
|
||
expect(WALLET_KEEP_CLEAR.y + WALLET_KEEP_CLEAR.h).toBe(CARD_H);
|
||
});
|
||
});
|
||
|
||
describe('fitLogo', () => {
|
||
it('LOGO_MAX_H ist 125', () => {
|
||
expect(LOGO_MAX_H).toBe(125);
|
||
});
|
||
|
||
it('Seitenverhältnis 2 bei Höhe 125: 250 × 125, rechte Kante 1480, Unterkante 913', () => {
|
||
expect(fitLogo(2, 125)).toEqual({ x: 1230, y: 788, w: 250, h: 125 });
|
||
});
|
||
|
||
it('ohne maxH gilt LOGO_MAX_H', () => {
|
||
expect(fitLogo(2)).toEqual(fitLogo(2, LOGO_MAX_H));
|
||
expect(fitLogo(1)).toEqual({ x: 1355, y: 788, w: 125, h: 125 });
|
||
});
|
||
|
||
it('sehr breite Logos werden auf 360 begrenzt', () => {
|
||
const b = fitLogo(10, 125);
|
||
expect(b.w).toBeCloseTo(360, 1);
|
||
expect(b.h).toBeCloseTo(36, 1);
|
||
expect(b.x + b.w).toBeCloseTo(1480, 1);
|
||
expect(b.y + b.h).toBeCloseTo(913, 1);
|
||
});
|
||
|
||
it('hohe Logos werden auf die Höhe begrenzt', () => {
|
||
const b = fitLogo(0.5, 125);
|
||
expect(b.h).toBeCloseTo(125, 1);
|
||
expect(b.w).toBeCloseTo(62.5, 1);
|
||
expect(b.x + b.w).toBeCloseTo(1480, 1);
|
||
expect(b.y + b.h).toBeCloseTo(913, 1);
|
||
});
|
||
|
||
it('maxH ist einstellbar', () => {
|
||
const b = fitLogo(1, 100);
|
||
expect(b.w).toBeCloseTo(100, 1);
|
||
expect(b.h).toBeCloseTo(100, 1);
|
||
expect(b.y + b.h).toBeCloseTo(913, 1);
|
||
});
|
||
|
||
it('Seitenverhältnis bleibt erhalten', () => {
|
||
for (const aspect of [0.8, 1, 1.5, 3, 6, 10]) {
|
||
const b = fitLogo(aspect);
|
||
expect(b.w / b.h).toBeCloseTo(aspect, 1);
|
||
expect(b.h).toBeLessThanOrEqual(LOGO_MAX_H + 1e-9);
|
||
expect(b.w).toBeLessThanOrEqual(360 + 1e-9);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('fitBrand', () => {
|
||
it('quadratisches Logo: 100 × 100 links oben auf dem Rand', () => {
|
||
expect(fitBrand(1)).toEqual({ x: 56, y: 56, w: 100, h: 100 });
|
||
});
|
||
|
||
it('breites Logo wird auf 360 × 100 begrenzt', () => {
|
||
const b = fitBrand(3.6);
|
||
expect(b.x).toBe(56);
|
||
expect(b.y).toBe(56);
|
||
expect(b.w).toBeCloseTo(360, 1);
|
||
expect(b.h).toBeCloseTo(100, 1);
|
||
expect(b.x + b.w).toBeCloseTo(416, 1);
|
||
expect(b.y + b.h).toBeCloseTo(156, 1);
|
||
});
|
||
|
||
it('sehr breites Logo ist höchstens 360 breit, Höhe folgt dem Seitenverhältnis', () => {
|
||
const b = fitBrand(8);
|
||
expect(b.w).toBeCloseTo(360, 1);
|
||
expect(b.h).toBeCloseTo(45, 1);
|
||
});
|
||
|
||
it('maxH ist einstellbar', () => {
|
||
const b = fitBrand(1, 60);
|
||
expect(b.w).toBeCloseTo(60, 1);
|
||
expect(b.h).toBeCloseTo(60, 1);
|
||
});
|
||
});
|
||
|
||
describe('layoutFor', () => {
|
||
it('image: kein Logo, keine Marke, keine optionalen Felder', () => {
|
||
const l = layoutFor('image');
|
||
expect(l.logo).toBe(false);
|
||
expect(l.brand).toBe(false);
|
||
expect(Object.keys(l).sort()).toEqual(['brand', 'logo']);
|
||
});
|
||
|
||
it('minimal: nur Logo', () => {
|
||
const l = layoutFor('minimal');
|
||
expect(l.logo).toBe(true);
|
||
expect(l.brand).toBe(false);
|
||
expect(Object.keys(l).sort()).toEqual(['brand', 'logo']);
|
||
});
|
||
|
||
it('classic: alles', () => {
|
||
const l = layoutFor('classic');
|
||
expect(l.logo).toBe(true);
|
||
expect(l.brand).toBe(true);
|
||
expect(l.platinum).toEqual({ x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' });
|
||
expect(l.chip).toEqual({
|
||
x: 157, y: 326, w: 226, h: 205, r: 32,
|
||
hLines: [383, 429, 474], vLines: [234, 307], padY: [383, 474],
|
||
});
|
||
expect(l.contactless).toEqual({
|
||
path:
|
||
'M548.75 379.89a98.21 98.21 0 0 1 0 98.21M527.14 390.7a76.61 76.61 0 0 1 0 76.61' +
|
||
'M506.52 400.61a55.39 55.98 0 0 1 0 55.98M485.89 411.32a35.36 33.39 0 0 1 0 35.36',
|
||
lineWidth: 11.8,
|
||
box: { x: 480, y: 374, w: 88, h: 110 },
|
||
});
|
||
expect(l.number).toEqual({
|
||
startX: 157, pitch: 45.6, baseline: 650, size: 72,
|
||
bulletY: 624, bulletR: 8, text: '•••• •••• •••• 8888',
|
||
});
|
||
});
|
||
|
||
it('signature: Logo, Marke und PLATINUM links', () => {
|
||
const l = layoutFor('signature');
|
||
expect(l.logo).toBe(true);
|
||
expect(l.brand).toBe(true);
|
||
expect(l.platinum).toEqual({ x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' });
|
||
expect(l.chip).toBeUndefined();
|
||
expect(l.contactless).toBeUndefined();
|
||
expect(l.number).toBeUndefined();
|
||
});
|
||
|
||
it('liefert unabhängige Kopien', () => {
|
||
const a = layoutFor('classic');
|
||
a.chip!.hLines.push(999);
|
||
a.number!.text = 'x';
|
||
const b = layoutFor('classic');
|
||
expect(b.chip!.hLines).toEqual([383, 429, 474]);
|
||
expect(b.number!.text).toBe('•••• •••• •••• 8888');
|
||
});
|
||
|
||
it('Kontaktlinien des Chips liegen im Chip', () => {
|
||
const c = layoutFor('classic').chip!;
|
||
for (const y of c.hLines) expect(y > c.y && y < c.y + c.h).toBe(true);
|
||
for (const x of c.vLines) expect(x > c.x && x < c.x + c.w).toBe(true);
|
||
expect(c.padY[0]).toBe(c.hLines[0]);
|
||
expect(c.padY[1]).toBe(c.hLines[c.hLines.length - 1]);
|
||
});
|
||
|
||
it('die festen PLATINUM-Boxen passen zu den Layoutwerten', () => {
|
||
for (const t of ['classic', 'signature'] as const) {
|
||
const p = layoutFor(t).platinum!;
|
||
const box = PLATINUM_BOX[t]!;
|
||
if (p.align === 'right') expect(box.x + box.w).toBe(p.x);
|
||
else expect(box.x).toBe(p.x);
|
||
expect(p.baseline > box.y && p.baseline <= box.y + box.h).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('die Nummernzeile passt in die feste Box', () => {
|
||
const n = layoutFor('classic').number!;
|
||
expect(n.startX).toBe(NUMBER_BOX.x);
|
||
expect(n.baseline).toBe(NUMBER_BOX.y + NUMBER_BOX.h);
|
||
expect(n.startX + n.text.length * n.pitch).toBeLessThanOrEqual(NUMBER_BOX.x + NUMBER_BOX.w);
|
||
});
|
||
});
|
||
|
||
describe.each(TEMPLATES)('Vorlage %s: Wallet-Bereich unten links bleibt frei', (t) => {
|
||
const l = layoutFor(t);
|
||
const boxes: Record<string, Box> = {};
|
||
if (l.chip) boxes.chip = { x: l.chip.x, y: l.chip.y, w: l.chip.w, h: l.chip.h };
|
||
if (l.contactless) boxes.contactless = l.contactless.box;
|
||
if (l.number) boxes.number = NUMBER_BOX;
|
||
if (l.platinum) boxes.platinum = PLATINUM_BOX[t]!;
|
||
if (l.logo) {
|
||
boxes.logoSquare = fitLogo(1);
|
||
boxes.logoWide = fitLogo(10);
|
||
}
|
||
if (l.brand) boxes.brand = fitBrand(3.6);
|
||
|
||
it('hat für jedes Element eine Prüfbox', () => {
|
||
expect(Object.keys(boxes).length).toBe(
|
||
{ image: 0, minimal: 2, classic: 7, signature: 4 }[t],
|
||
);
|
||
});
|
||
|
||
for (const [name, box] of Object.entries(boxes)) {
|
||
it(`${name} liegt im Canvas und schneidet WALLET_KEEP_CLEAR nicht`, () => {
|
||
expect(insideCanvas(box)).toBe(true);
|
||
expect(intersects(box, WALLET_KEEP_CLEAR)).toBe(false);
|
||
});
|
||
}
|
||
});
|