lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests services bildgrenzen.test.ts

7,3 KB Rohdatei
tests/services/bildgrenzen.test.ts — 183 Zeilen
1 import { afterEach, describe, expect, it, vi } from 'vitest';
2 import { MAX_BYTES_JE_ABRUF, holeKartenbild } from '../../electron/karte';
3 import { MAX_BILD_ZEICHEN, parseProject, passtInProjektdatei } from '@/domain/model/schema';
4 import { KACHEL_KANTE, MAX_MOSAIK_KANTE } from '@/services/kartendienste';
5
6 /**
7 * Abstimmung der beiden Bildgrenzen.
8 *
9 * Es gibt zwei voneinander unabhaengige Stellen, an denen ein Luftbild
10 * verworfen werden kann: der Hauptprozess beim Abruf (MAX_BYTES_JE_ABRUF in
11 * electron/karte.ts) und die Projektpruefung beim Wiedereinlesen
12 * (MAX_BILD_ZEICHEN in src/domain/model/schema.ts). Frueher liefen sie
13 * auseinander - der Abruf liess 20,97 MB durch, das Einlesen nur rund 18,0 MB.
14 * Eine Abrufgrenze oberhalb der Projektgrenze steht falsch: Sie laesst eine
15 * Groesse zu, die die Projektdatei anschliessend nicht mehr fasst.
16 *
17 * Diese Tests halten die Zahlen zusammen. Sie rechnen dieselbe Rechnung noch
18 * einmal, die in den Kommentaren steht - wer eine der Zahlen aendert, ohne die
19 * andere mitzuziehen, faellt hier auf.
20 */
21
22 /** base64 traegt 4 Zeichen je 3 Byte; dazu der Kopf "data:image/jpeg;base64,". */
23 function alsDatenUrlZeichen(bytes: number): number {
24 return Math.ceil(bytes / 3) * 4 + 'data:image/jpeg;base64,'.length;
25 }
26
27 describe('Bildgrenzen von Abruf und Projektdatei', () => {
28 it('laesst kein Bild durch den Abruf, das die Projektdatei verwirft', () => {
29 // Die Zusicherung selbst bleibt und wird hier nachgerechnet; warum sie
30 // gilt, steht im Kopf dieser Datei.
31 //
32 // Die frueher hier stehende Begruendung traegt dagegen nicht - eine
33 // einzelne Kachel wandert NICHT unveraendert weiter. Auch ein Mosaik aus
34 // einer einzigen Kachel laeuft ueber eine Zeichenflaeche und wird von dort
35 // neu herausgeschrieben.
36 expect(alsDatenUrlZeichen(MAX_BYTES_JE_ABRUF)).toBeLessThanOrEqual(MAX_BILD_ZEICHEN);
37 });
38
39 it('fasst ein Mosaik mit voller Kantenlaenge als JPEG', () => {
40 // 0,4 Byte je Bildpunkt ist der obere Rand fuer ein JPEG-Luftbild in
41 // dichter Stadtlage; darunter liegt jedes ruhigere Bild.
42 const bildpunkte = MAX_MOSAIK_KANTE * MAX_MOSAIK_KANTE;
43 const dichteStadtlage = bildpunkte * 0.4;
44
45 expect(alsDatenUrlZeichen(dichteStadtlage)).toBeLessThanOrEqual(MAX_BILD_ZEICHEN);
46 });
47
48 it('fasst je Abruf auch die unguenstigste denkbare Kachel', () => {
49 // Der schlimmste Fall ist kein Luftbild, sondern eine Kartenkachel als PNG,
50 // die sich ueberhaupt nicht komprimieren laesst: 24 bit je Bildpunkt plus
51 // ein Filterbyte je Bildzeile. Groesser kann eine Kachel nicht werden.
52 const unkomprimierbaresPng = KACHEL_KANTE * KACHEL_KANTE * 3 + KACHEL_KANTE;
53
54 expect(unkomprimierbaresPng).toBeLessThanOrEqual(MAX_BYTES_JE_ABRUF);
55 });
56
57 it('bleibt je Abruf deutlich unter der Grenze der Projektdatei', () => {
58 // Die Abrufgrenze schuetzt nur noch eine Kachel und darf deshalb streng
59 // sein. Waere sie so weit wie die Projektgrenze, schluege sie nie an.
60 expect(MAX_BYTES_JE_ABRUF * 4).toBeLessThan(MAX_BILD_ZEICHEN);
61 });
62 });
63
64 describe('Projektpruefung des Hintergrundbilds', () => {
65 const kopf = 'data:image/jpeg;base64,';
66 const anDerGrenze = kopf + 'A'.repeat(MAX_BILD_ZEICHEN - kopf.length);
67
68 function lies(datenUrl: string) {
69 return parseProject({ lageplan: { bild: { datenUrl, breite: 7000, hoehe: 7000 } } });
70 }
71
72 it('uebernimmt ein Bild genau an der Grenze', () => {
73 expect(passtInProjektdatei(anDerGrenze)).toBe(true);
74
75 const { project, issues } = lies(anDerGrenze);
76
77 expect(project.lageplan.bild).not.toBeNull();
78 expect(issues.filter((i) => i.path === 'lageplan.bild')).toHaveLength(0);
79 });
80
81 it('verwirft ein Zeichen darueber und nennt beide Zahlen', () => {
82 const zuGross = anDerGrenze + 'A';
83 expect(passtInProjektdatei(zuGross)).toBe(false);
84
85 const { project, issues } = lies(zuGross);
86 const meldung = issues.find((i) => i.path === 'lageplan.bild');
87
88 expect(project.lageplan.bild).toBeNull();
89 expect(meldung?.severity).toBe('fehler');
90 // Der Anwender muss erfahren, wie gross sein Bild ist und was zulaessig
91 // waere - sonst kann er nichts daran aendern.
92 expect(meldung?.message).toContain('32,0 MB');
93 expect(meldung?.message).toContain('JPEG');
94 });
95
96 it('verwirft ein Bild in einem nicht zugelassenen Format', () => {
97 const { project, issues } = lies('data:image/svg+xml;base64,AAAA');
98
99 expect(project.lageplan.bild).toBeNull();
100 expect(issues.find((i) => i.path === 'lageplan.bild')?.severity).toBe('fehler');
101 });
102 });
103
104 /**
105 * Die Abrufgrenze wirkt nur, wenn sie das Herunterladen ABBRICHT. Wird die
106 * Antwort erst vollstaendig in den Speicher gelesen und danach gemessen, hat
107 * die Grenze nichts verhindert: Der Hauptprozess ist dann bereits voll, und mit
108 * ihm faellt die Anwendung samt ungesichertem Signalzeitenplan.
109 */
110 describe('Abruf bricht eine zu grosse Antwort ab', () => {
111 const echtesFetch = globalThis.fetch;
112 const NRW = 'https://www.wms.nrw.de/geobasis/wms_nw_dop?SERVICE=WMS&REQUEST=GetMap';
113
114 afterEach(() => {
115 globalThis.fetch = echtesFetch;
116 vi.restoreAllMocks();
117 });
118
119 /**
120 * Antwort, die endlos ein Megabyte nach dem anderen nachschiebt.
121 *
122 * Der Wasserstand steht auf 0, damit kein Stueck auf Vorrat erzeugt wird:
123 * `stuecke` zaehlt dadurch genau die Stuecke, die der Abruf wirklich abgeholt
124 * hat, und nicht die, die der Datenstrom von sich aus bereitgelegt haette.
125 */
126 function endloseAntwort(
127 zaehler: { stuecke: number },
128 kopfzeilen: Record<string, string>,
129 ): Response {
130 const koerper = new ReadableStream<Uint8Array>(
131 {
132 pull(steuerung) {
133 zaehler.stuecke += 1;
134 steuerung.enqueue(new Uint8Array(1024 * 1024));
135 },
136 },
137 { highWaterMark: 0 },
138 );
139 return new Response(koerper, {
140 status: 200,
141 headers: { 'content-type': 'image/jpeg', ...kopfzeilen },
142 });
143 }
144
145 it('laedt gar nicht erst, wenn die angekuendigte Laenge zu gross ist', async () => {
146 const zaehler = { stuecke: 0 };
147 globalThis.fetch = vi.fn(async () =>
148 endloseAntwort(zaehler, { 'content-length': String(400 * 1024 * 1024) }),
149 );
150
151 const antwort = await holeKartenbild({ url: NRW });
152
153 expect(antwort.ok).toBe(false);
154 expect(antwort.fehler).toContain('8 MB');
155 expect(zaehler.stuecke).toBe(0);
156 });
157
158 it('bricht ab, sobald die Grenze ueberschritten ist - auch ohne Laengenangabe', async () => {
159 const zaehler = { stuecke: 0 };
160 globalThis.fetch = vi.fn(async () => endloseAntwort(zaehler, {}));
161
162 const antwort = await holeKartenbild({ url: NRW });
163
164 expect(antwort.ok).toBe(false);
165 expect(antwort.fehler).toContain('abgebrochen');
166 // Ein Megabyte je Stueck: Gelesen sein darf nur, was bis zum Ueberschreiten
167 // der Grenze noetig ist, nicht der endlose Rest.
168 expect(zaehler.stuecke).toBeLessThanOrEqual(MAX_BYTES_JE_ABRUF / (1024 * 1024) + 2);
169 });
170
171 it('laesst eine Kachel knapp unter der Grenze durch', async () => {
172 const kachel = new Uint8Array(MAX_BYTES_JE_ABRUF - 1024);
173 kachel.set([0xff, 0xd8, 0xff, 0xdb]);
174 globalThis.fetch = vi.fn(
175 async () => new Response(kachel, { status: 200, headers: { 'content-type': 'image/jpeg' } }),
176 );
177
178 const antwort = await holeKartenbild({ url: NRW });
179
180 expect(antwort.ok).toBe(true);
181 expect(antwort.datenUrl?.startsWith('data:image/jpeg;base64,')).toBe(true);
182 });
183 });