Files
AirCardIOS/web/src/card/geometry.test.ts
T
ClaudeandClaude Opus 5.5 0be5abd954 feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer
- 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>
2026-10-04 20:32:45 +00:00

228 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
}
});