import { afterEach, describe, expect, it, vi } from 'vitest'; import { MAX_BYTES_JE_ABRUF, holeKartenbild } from '../../electron/karte'; import { MAX_BILD_ZEICHEN, parseProject, passtInProjektdatei } from '@/domain/model/schema'; import { KACHEL_KANTE, MAX_MOSAIK_KANTE } from '@/services/kartendienste'; /** * Abstimmung der beiden Bildgrenzen. * * Es gibt zwei voneinander unabhaengige Stellen, an denen ein Luftbild * verworfen werden kann: der Hauptprozess beim Abruf (MAX_BYTES_JE_ABRUF in * electron/karte.ts) und die Projektpruefung beim Wiedereinlesen * (MAX_BILD_ZEICHEN in src/domain/model/schema.ts). Frueher liefen sie * auseinander - der Abruf liess 20,97 MB durch, das Einlesen nur rund 18,0 MB. * Eine Abrufgrenze oberhalb der Projektgrenze steht falsch: Sie laesst eine * Groesse zu, die die Projektdatei anschliessend nicht mehr fasst. * * Diese Tests halten die Zahlen zusammen. Sie rechnen dieselbe Rechnung noch * einmal, die in den Kommentaren steht - wer eine der Zahlen aendert, ohne die * andere mitzuziehen, faellt hier auf. */ /** base64 traegt 4 Zeichen je 3 Byte; dazu der Kopf "data:image/jpeg;base64,". */ function alsDatenUrlZeichen(bytes: number): number { return Math.ceil(bytes / 3) * 4 + 'data:image/jpeg;base64,'.length; } describe('Bildgrenzen von Abruf und Projektdatei', () => { it('laesst kein Bild durch den Abruf, das die Projektdatei verwirft', () => { // Die Zusicherung selbst bleibt und wird hier nachgerechnet; warum sie // gilt, steht im Kopf dieser Datei. // // Die frueher hier stehende Begruendung traegt dagegen nicht - eine // einzelne Kachel wandert NICHT unveraendert weiter. Auch ein Mosaik aus // einer einzigen Kachel laeuft ueber eine Zeichenflaeche und wird von dort // neu herausgeschrieben. expect(alsDatenUrlZeichen(MAX_BYTES_JE_ABRUF)).toBeLessThanOrEqual(MAX_BILD_ZEICHEN); }); it('fasst ein Mosaik mit voller Kantenlaenge als JPEG', () => { // 0,4 Byte je Bildpunkt ist der obere Rand fuer ein JPEG-Luftbild in // dichter Stadtlage; darunter liegt jedes ruhigere Bild. const bildpunkte = MAX_MOSAIK_KANTE * MAX_MOSAIK_KANTE; const dichteStadtlage = bildpunkte * 0.4; expect(alsDatenUrlZeichen(dichteStadtlage)).toBeLessThanOrEqual(MAX_BILD_ZEICHEN); }); it('fasst je Abruf auch die unguenstigste denkbare Kachel', () => { // Der schlimmste Fall ist kein Luftbild, sondern eine Kartenkachel als PNG, // die sich ueberhaupt nicht komprimieren laesst: 24 bit je Bildpunkt plus // ein Filterbyte je Bildzeile. Groesser kann eine Kachel nicht werden. const unkomprimierbaresPng = KACHEL_KANTE * KACHEL_KANTE * 3 + KACHEL_KANTE; expect(unkomprimierbaresPng).toBeLessThanOrEqual(MAX_BYTES_JE_ABRUF); }); it('bleibt je Abruf deutlich unter der Grenze der Projektdatei', () => { // Die Abrufgrenze schuetzt nur noch eine Kachel und darf deshalb streng // sein. Waere sie so weit wie die Projektgrenze, schluege sie nie an. expect(MAX_BYTES_JE_ABRUF * 4).toBeLessThan(MAX_BILD_ZEICHEN); }); }); describe('Projektpruefung des Hintergrundbilds', () => { const kopf = 'data:image/jpeg;base64,'; const anDerGrenze = kopf + 'A'.repeat(MAX_BILD_ZEICHEN - kopf.length); function lies(datenUrl: string) { return parseProject({ lageplan: { bild: { datenUrl, breite: 7000, hoehe: 7000 } } }); } it('uebernimmt ein Bild genau an der Grenze', () => { expect(passtInProjektdatei(anDerGrenze)).toBe(true); const { project, issues } = lies(anDerGrenze); expect(project.lageplan.bild).not.toBeNull(); expect(issues.filter((i) => i.path === 'lageplan.bild')).toHaveLength(0); }); it('verwirft ein Zeichen darueber und nennt beide Zahlen', () => { const zuGross = anDerGrenze + 'A'; expect(passtInProjektdatei(zuGross)).toBe(false); const { project, issues } = lies(zuGross); const meldung = issues.find((i) => i.path === 'lageplan.bild'); expect(project.lageplan.bild).toBeNull(); expect(meldung?.severity).toBe('fehler'); // Der Anwender muss erfahren, wie gross sein Bild ist und was zulaessig // waere - sonst kann er nichts daran aendern. expect(meldung?.message).toContain('32,0 MB'); expect(meldung?.message).toContain('JPEG'); }); it('verwirft ein Bild in einem nicht zugelassenen Format', () => { const { project, issues } = lies('data:image/svg+xml;base64,AAAA'); expect(project.lageplan.bild).toBeNull(); expect(issues.find((i) => i.path === 'lageplan.bild')?.severity).toBe('fehler'); }); }); /** * Die Abrufgrenze wirkt nur, wenn sie das Herunterladen ABBRICHT. Wird die * Antwort erst vollstaendig in den Speicher gelesen und danach gemessen, hat * die Grenze nichts verhindert: Der Hauptprozess ist dann bereits voll, und mit * ihm faellt die Anwendung samt ungesichertem Signalzeitenplan. */ describe('Abruf bricht eine zu grosse Antwort ab', () => { const echtesFetch = globalThis.fetch; const NRW = 'https://www.wms.nrw.de/geobasis/wms_nw_dop?SERVICE=WMS&REQUEST=GetMap'; afterEach(() => { globalThis.fetch = echtesFetch; vi.restoreAllMocks(); }); /** * Antwort, die endlos ein Megabyte nach dem anderen nachschiebt. * * Der Wasserstand steht auf 0, damit kein Stueck auf Vorrat erzeugt wird: * `stuecke` zaehlt dadurch genau die Stuecke, die der Abruf wirklich abgeholt * hat, und nicht die, die der Datenstrom von sich aus bereitgelegt haette. */ function endloseAntwort( zaehler: { stuecke: number }, kopfzeilen: Record, ): Response { const koerper = new ReadableStream( { pull(steuerung) { zaehler.stuecke += 1; steuerung.enqueue(new Uint8Array(1024 * 1024)); }, }, { highWaterMark: 0 }, ); return new Response(koerper, { status: 200, headers: { 'content-type': 'image/jpeg', ...kopfzeilen }, }); } it('laedt gar nicht erst, wenn die angekuendigte Laenge zu gross ist', async () => { const zaehler = { stuecke: 0 }; globalThis.fetch = vi.fn(async () => endloseAntwort(zaehler, { 'content-length': String(400 * 1024 * 1024) }), ); const antwort = await holeKartenbild({ url: NRW }); expect(antwort.ok).toBe(false); expect(antwort.fehler).toContain('8 MB'); expect(zaehler.stuecke).toBe(0); }); it('bricht ab, sobald die Grenze ueberschritten ist - auch ohne Laengenangabe', async () => { const zaehler = { stuecke: 0 }; globalThis.fetch = vi.fn(async () => endloseAntwort(zaehler, {})); const antwort = await holeKartenbild({ url: NRW }); expect(antwort.ok).toBe(false); expect(antwort.fehler).toContain('abgebrochen'); // Ein Megabyte je Stueck: Gelesen sein darf nur, was bis zum Ueberschreiten // der Grenze noetig ist, nicht der endlose Rest. expect(zaehler.stuecke).toBeLessThanOrEqual(MAX_BYTES_JE_ABRUF / (1024 * 1024) + 2); }); it('laesst eine Kachel knapp unter der Grenze durch', async () => { const kachel = new Uint8Array(MAX_BYTES_JE_ABRUF - 1024); kachel.set([0xff, 0xd8, 0xff, 0xdb]); globalThis.fetch = vi.fn( async () => new Response(kachel, { status: 200, headers: { 'content-type': 'image/jpeg' } }), ); const antwort = await holeKartenbild({ url: NRW }); expect(antwort.ok).toBe(true); expect(antwort.datenUrl?.startsWith('data:image/jpeg;base64,')).toBe(true); }); });