Compare commits

...
4 Commits
Author SHA1 Message Date
TanerUslu 5a055923fe Trenne Dialogtexte fuer echte Mehrdeutigkeit und nichts erkannt
Der rote Dialog zeigte bisher immer "Nicht eindeutig", auch wenn die
Erkennung schlicht nichts Verwertbares geliefert hat (kein Barcode, kein
brauchbarer Klartext). Das verwirrt: es klingt nach mehreren Kandidaten,
obwohl gar keine Erkennung stattfand.

askForStack() bekommt einen expliziten Parameter nothingRecognized, da
sich die beiden Lagen nicht zweifelsfrei aus spec/candidates ableiten
lassen (main.js reicht bei "nichts erkannt" als Rueckfall alle
vorhandenen Stapel als candidates durch). main.js setzt ihn anhand von
plan.kind === 'ambiguous' (echte Mehrdeutigkeit) vs. allem anderen
(nur wegen roter Konfidenz im Dialog).

Signatur, Rueckgabeform, Schaltflaechen, Klassennamen, die
Mehrfachausloese-Sperre und die inert-Handhabung bleiben unveraendert.
2026-07-29 13:05:37 +02:00
TanerUsluandClaude Opus 5 b94639ccd1 Laufende Barcode-Suche statt Einzelbild beim Knopfdruck
Solange Kamera laeuft und keine Erkennung/kein Dialog/keine
Sitzungsliste im Weg steht, sucht die App ~5x/s im Zielrahmen nach
Barcodes. Ein gefundener Code durchlaeuft denselben Weg wie der
Knopfdruck (processCapture). Sperrzeit von 2s plus Codevergleich
verhindert, dass ein laenger vor die Kamera gehaltenes Modul mehrfach
gebucht wird, ohne einen zuegigen Modulwechsel auszubremsen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:59:23 +02:00
TanerUsluandClaude Opus 5 abb70e8b23 Zielrahmen aus FRAME_INSET setzen, Erkennungs-Hervorhebung ergaenzen
scan-view.js setzt die Rahmenlage jetzt aus camera.js' FRAME_INSET
statt aus einem im Stilblatt duplizierten Wert. Neue Methode
setFrameDetected() faerbt den Rahmen gruen, sobald die Dauersuche
einen Barcode im Ausschnitt findet - schlichte Farbaenderung, keine
Animation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:59:14 +02:00
TanerUsluandClaude Opus 5 44526e7d3b Ausschnitt in nativer Aufloesung fuer Barcode-Erkennung
FRAME_INSET und frameRect() legen die Rahmenmasse einmalig fest, statt
sie nur im Stilblatt zu duplizieren. grabFrameRegion() liefert den
Zielrahmen unskaliert aus dem Kamerabild - grabFrame() (fuer OCR)
bleibt unveraendert. Ein Code-128 auf kleinem Etikett braucht die
volle Aufloesung, sonst verschmieren die Striche unlesbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:59:08 +02:00
6 changed files with 317 additions and 13 deletions
+57
View File
@@ -32,6 +32,39 @@ export async function startCamera(videoElement) {
}; };
} }
/**
* Rahmenmasse als Anteil der Bildkanten - einzige Quelle fuer die Lage des
* Zielrahmens. scan-view.js uebernimmt daraus den sichtbaren Rahmen,
* grabFrameRegion() weiter unten den nativen Ausschnitt - beide koennen so
* nicht mehr auseinanderlaufen (frueher stand das, unabhaengig davon, nur
* im Stilblatt).
*/
export const FRAME_INSET = { top: 0.2, right: 0.08, bottom: 0.2, left: 0.08 };
/**
* Rechnet die Rahmenmasse (Anteile der Kantenlaenge) in Bildpunkte um.
* Reine Funktion ohne Bezug zu einem konkreten Bild oder einer Browser-API -
* damit auch ohne DOM testbar.
*
* Breite und Hoehe werden als Differenz aus der Gesamtkante und den beiden
* gerundeten Raendern gebildet, nicht unabhaengig aus dem Anteil selbst
* gerundet - so bleibt der Ausschnitt bei krummen Bildgroessen garantiert
* innerhalb des Bildes, auch wenn die Anteile keine ganzzahligen Ergebnisse
* liefern.
*/
export function frameRect(width, height, inset = FRAME_INSET) {
const left = Math.round(width * inset.left);
const right = Math.round(width * inset.right);
const top = Math.round(height * inset.top);
const bottom = Math.round(height * inset.bottom);
return {
x: left,
y: top,
width: Math.max(0, width - left - right),
height: Math.max(0, height - top - bottom),
};
}
function drawScaled(source, sourceWidth, sourceHeight, maxEdge) { function drawScaled(source, sourceWidth, sourceHeight, maxEdge) {
const scale = Math.min(1, maxEdge / Math.max(sourceWidth, sourceHeight)); const scale = Math.min(1, maxEdge / Math.max(sourceWidth, sourceHeight));
const width = Math.round(sourceWidth * scale); const width = Math.round(sourceWidth * scale);
@@ -64,6 +97,30 @@ export function grabFrame(videoElement, maxEdge = 1280) {
); );
} }
/**
* Schneidet den Zielrahmen (siehe FRAME_INSET) aus dem laufenden Kamerabild -
* in unveraenderter Aufloesung, ohne jede Verkleinerung. Anders als
* grabFrame(): fuer die Texterkennung genuegt ein herunterskaliertes
* Vollbild, aber ein Code-128 auf einem kleinen Etikett braucht seine
* feinen Striche in voller Schaerfe - beim Skalieren auf die fuer OCR
* ausreichende Kantenlaenge verschmieren sie unlesbar.
*/
export function grabFrameRegion(videoElement, inset = FRAME_INSET) {
assertHasSize(videoElement.videoWidth, videoElement.videoHeight);
const rect = frameRect(videoElement.videoWidth, videoElement.videoHeight, inset);
const canvas = document.createElement('canvas');
canvas.width = rect.width;
canvas.height = rect.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(
videoElement,
rect.x, rect.y, rect.width, rect.height,
0, 0, rect.width, rect.height,
);
return context.getImageData(0, 0, rect.width, rect.height);
}
/** Ersatzweg ohne Kamera: Bilddatei auswaehlen (z. B. am Rechner). */ /** Ersatzweg ohne Kamera: Bilddatei auswaehlen (z. B. am Rechner). */
export async function imageDataFromFile(file, maxEdge = 1280) { export async function imageDataFromFile(file, maxEdge = 1280) {
const bitmap = await createImageBitmap(file); const bitmap = await createImageBitmap(file);
+140 -2
View File
@@ -1,6 +1,6 @@
// Vollstaendige Verdrahtung der App: Kamera, Barcode/OCR-Erkennung, Sitzung // Vollstaendige Verdrahtung der App: Kamera, Barcode/OCR-Erkennung, Sitzung
// und Oberflaeche. Ersetzt das fruehere Sichtpruefungs-Geruest vollstaendig. // und Oberflaeche. Ersetzt das fruehere Sichtpruefungs-Geruest vollstaendig.
import { startCamera, grabFrame, imageDataFromFile } from './camera.js'; import { startCamera, grabFrame, grabFrameRegion, imageDataFromFile } from './camera.js';
import { decodeBarcodes } from './barcode.js'; import { decodeBarcodes } from './barcode.js';
import { runOcr, isOcrAvailable } from './ocr.js'; import { runOcr, isOcrAvailable } from './ocr.js';
import { recognize } from './pipeline.js'; import { recognize } from './pipeline.js';
@@ -112,6 +112,10 @@ async function processCapture(getFrame) {
candidates: plan.kind === 'ambiguous' candidates: plan.kind === 'ambiguous'
? plan.candidates ? plan.candidates
: targetSession.stacks.map((stack) => stack.id), : targetSession.stacks.map((stack) => stack.id),
// Nur eine echte Stapel-Mehrdeutigkeit (plan.kind === 'ambiguous')
// bedeutet "mehrere passen" - alles andere landet nur wegen roter
// Konfidenz hier, also weil nichts Verwertbares erkannt wurde.
nothingRecognized: plan.kind !== 'ambiguous',
}); });
// "nochmal scannen": abbrechen, ohne zu buchen. Der fruehe return liegt // "nochmal scannen": abbrechen, ohne zu buchen. Der fruehe return liegt
@@ -162,6 +166,134 @@ async function processCapture(getFrame) {
} }
} }
// --- Dauersuche nach Barcodes -----------------------------------------
//
// Solange die Kamera laeuft, sucht die App fortwaehrend im Zielrahmen nach
// Barcodes, statt auf den Knopfdruck zu warten - ein Modul in Freihand vor
// die Kamera zu halten und zu treffen, gelingt selten beim ersten Bild.
// Etwa fuenf Versuche pro Sekunde: haeufig genug, dass ein kurzes Hinhalten
// genuegt, aber selten genug, dass ein Handy dabei nicht dauerhaft ausgelastet
// ist. Die tatsaechliche Dauer eines Versuchs wird unten gemessen und von
// dieser Zielspanne abgezogen - dauert ein Versuch laenger (z. B. weil er
// eine Buchung samt kurzer Rueckmeldung ausgeloest hat), startet der naechste
// sofort, ohne zusaetzlich zu warten.
const SEARCH_INTERVAL_MS = 200;
// Sperrzeit gegen Doppelerfassung: haelt man ein Modul mehrere Sekunden vor
// die Kamera, darf es nicht mehrfach gebucht werden. Zwei Sekunden reichen,
// um eine kurze Verdeckung oder ein Wackeln zu ueberbruecken, waehrend
// derselbe Barcode noch im Bild ist - ein zuegiger Wechsel zu einem anderen
// Modul wird davon nicht ausgebremst, denn ein *anderer* Code hebt die Sperre
// sofort auf (siehe runSearchAttempt). Bewusst kurz gehalten, damit ein
// tatsaechlich zweites Exemplar desselben Teils (z. B. beim Sortieren vieler
// gleicher Module) nach dem Wegnehmen ohne spuerbare Wartezeit erneut gezaehlt
// werden kann, sobald kein Code mehr im Bild ist.
const REBOOK_COOLDOWN_MS = 2000;
// Zuletzt automatisch (per Dauersuche) gebuchte Codemenge, waehrend die Suche
// wegen der Sperrzeit ruht - null, solange keine Sperre aktiv ist.
let restingCodes = null;
// Zeitpunkt (performance.now()), ab dem die Sperrzeit abgelaufen ist.
let restUntil = 0;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Dieselben Bedingungen, unter denen heute schon die Bedienelemente gesperrt
* sind: eine Erkennung laeuft (busy) oder ein Dialog/die Sitzungsliste liegt
* als Vollbild-Overlay ueber der Ansicht.
*/
function searchPaused() {
return busy || app.querySelector('.overlay') !== null;
}
/**
* Ein einzelner Durchlauf der Dauersuche: Ausschnitt in nativer Aufloesung
* holen, auf Barcodes pruefen, Rahmen entsprechend hervorheben. Wird ein noch
* nicht gesperrter Code gefunden, laeuft ab hier derselbe Weg wie beim
* Antippen von "Modul scannen" (siehe processCapture) - der Nutzer muss dafuer
* nichts tun.
*/
async function runSearchAttempt() {
if (searchPaused()) {
view.setFrameDetected(false);
return;
}
let region;
try {
region = grabFrameRegion(view.video);
} catch {
// Kamera liefert noch keine brauchbare Bildgroesse - naechster Durchlauf
// versucht es erneut, kein Grund die Dauersuche abzubrechen.
view.setFrameDetected(false);
return;
}
let codes;
try {
codes = await decodeBarcodes(region);
} catch {
view.setFrameDetected(false);
return;
}
view.setFrameDetected(codes.length > 0);
if (codes.length === 0) {
// Kein Code mehr im Bild: Die Sperrzeit darf jetzt ablaufen (siehe
// REBOOK_COOLDOWN_MS oben), aber nur wenn sie tatsaechlich verstrichen ist.
if (restingCodes !== null && performance.now() >= restUntil) {
restingCodes = null;
}
return;
}
const normalizedCodes = codes.map((code) => normalizeToken(code));
if (restingCodes !== null) {
const stillSameModule = normalizedCodes.some((code) => restingCodes.has(code));
if (stillSameModule) {
// Dasselbe Modul haengt noch im Bild - nicht erneut buchen, egal ob die
// Sperrzeit schon verstrichen ist.
return;
}
// Ein anderer Code ist erschienen: Modulwechsel, die Sperre faellt sofort.
restingCodes = null;
}
// Waehrend decodeBarcodes lief, koennte sich der Zustand veraendert haben
// (z. B. Knopfdruck des Nutzers) - unmittelbar vor dem Ausloesen erneut pruefen.
if (searchPaused()) return;
restingCodes = new Set(normalizedCodes);
restUntil = performance.now() + REBOOK_COOLDOWN_MS;
// Derselbe Ausschnitt, der den Code gerade geliefert hat, geht direkt in
// denselben Ablauf wie beim Knopfdruck - kein zweiter, zeitversetzter Griff
// zur Kamera noetig.
await processCapture(() => Promise.resolve(region));
}
/**
* Laeuft, solange die Kamera aktiv ist. Ein neuer Versuch beginnt erst, wenn
* der vorherige vollstaendig fertig ist - nie ueberlappend.
*/
async function continuousSearchLoop() {
for (;;) {
const started = performance.now();
try {
await runSearchAttempt();
} catch {
// Ein einzelner fehlgeschlagener Versuch darf die Dauersuche nicht
// dauerhaft abbrechen.
}
const elapsed = performance.now() - started;
await sleep(Math.max(0, SEARCH_INTERVAL_MS - elapsed));
}
}
/** Entfernt alle offenen Vollbild-Overlays innerhalb der App. */ /** Entfernt alle offenen Vollbild-Overlays innerhalb der App. */
function closeOverlays() { function closeOverlays() {
app.querySelectorAll('.overlay').forEach((element) => element.remove()); app.querySelectorAll('.overlay').forEach((element) => element.remove());
@@ -199,7 +331,13 @@ function openSessionList() {
}); });
} }
startCamera(view.video).catch((error) => { startCamera(view.video)
.then(() => {
// Erst ab hier liefert die Kamera brauchbare Bilder - die Dauersuche
// laeuft fortan im Hintergrund, ohne dass der Nutzer etwas tun muss.
continuousSearchLoop();
})
.catch((error) => {
// Kein automatischer Aufruf der Dateiauswahl ohne Nutzergeste - Handy- // Kein automatischer Aufruf der Dateiauswahl ohne Nutzergeste - Handy-
// Browser blockieren das regelmaessig. Stattdessen bleibt der Ersatzweg // Browser blockieren das regelmaessig. Stattdessen bleibt der Ersatzweg
// ueber eine sichtbare, dauerhaft eingeblendete Schaltflaeche erreichbar. // ueber eine sichtbare, dauerhaft eingeblendete Schaltflaeche erreichbar.
+8 -1
View File
@@ -25,13 +25,20 @@ body {
.camera video { width: 100%; height: 100%; object-fit: cover; } .camera video { width: 100%; height: 100%; object-fit: cover; }
.frame { .frame {
/* Lage (inset) wird von scan-view.js aus FRAME_INSET (camera.js) gesetzt -
einzige Quelle, die sich auch der native Ausschnitt teilt. */
position: absolute; position: absolute;
inset: 20% 8%;
border: 3px solid rgba(255, 255, 255, 0.7); border: 3px solid rgba(255, 255, 255, 0.7);
border-radius: 8px; border-radius: 8px;
pointer-events: none; pointer-events: none;
} }
/* Dauersuche hat einen Barcode im Ausschnitt gefunden - schlichte
Farbaenderung, keine Animation. */
.frame.detected {
border-color: var(--green);
}
.stacks { .stacks {
display: flex; display: flex;
gap: 8px; gap: 8px;
+24 -3
View File
@@ -19,21 +19,42 @@ function describeReading(spec) {
* Reine Darstellung: kennt weder Stapel-Logik noch Erkennung, * Reine Darstellung: kennt weder Stapel-Logik noch Erkennung,
* bekommt die zur Wahl stehenden Stapel als Liste herein. * bekommt die zur Wahl stehenden Stapel als Liste herein.
* *
* Der Dialog deckt zwei grundverschiedene Lagen ab, die main.js nicht
* auseinanderhaelt, wenn man nur spec/candidates ansieht: mehrere Stapel
* passen (echte Mehrdeutigkeit) oder die Erkennung hat schlicht nichts
* Verwertbares geliefert. Da beide Lagen mit leerem spec und/oder nicht-
* leeren candidates auftreten koennen (main.js reicht bei "nichts erkannt"
* als Rueckfall alle vorhandenen Stapel als candidates durch, damit der
* Dialog trotzdem Schaltflaechen hat), laesst sich die Unterscheidung nicht
* zweifelsfrei aus spec/candidates ableiten - deshalb der ausdrueckliche
* Parameter nothingRecognized.
*
* @param {HTMLElement} root * @param {HTMLElement} root
* @param {{spec: object, candidates: string[]}} args * @param {{spec: object, candidates: string[], nothingRecognized?: boolean}} args
* nothingRecognized: true, wenn weder Barcode noch Texterkennung etwas
* Verwertbares geliefert haben (main.js: confidence 'red' ausserhalb
* einer echten Stapel-Mehrdeutigkeit) - dann ist der Dialog eine
* Rueckfallebene und kein Angebot mehrerer passender Stapel.
* @returns {Promise<{action: 'stack', stackId: string}|{action: 'new'}|{action: 'retry'}>} * @returns {Promise<{action: 'stack', stackId: string}|{action: 'new'}|{action: 'retry'}>}
*/ */
export function askForStack(root, { spec, candidates }) { export function askForStack(root, { spec, candidates, nothingRecognized = false }) {
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'overlay red'; overlay.className = 'overlay red';
overlay.setAttribute('role', 'dialog'); overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'ambiguous-dialog-heading'); overlay.setAttribute('aria-labelledby', 'ambiguous-dialog-heading');
const heading = nothingRecognized ? 'Nichts erkannt' : 'Nicht eindeutig';
const hint = nothingRecognized
? 'Etikett näher heranhalten, auf gleichmäßiges Licht achten und Spiegelungen vermeiden. Sonst hier den Stapel selbst wählen.'
: 'Mehrere Stapel passen zu diesem Modul bitte auswählen.';
overlay.innerHTML = ` overlay.innerHTML = `
<div class="stack-id" id="ambiguous-dialog-heading" style="font-size:34px">Nicht eindeutig</div> <div class="stack-id" id="ambiguous-dialog-heading" style="font-size:34px">${heading}</div>
<div class="detail" id="hint"></div>
<div class="detail">Gelesen: <span id="read"></span></div> <div class="detail">Gelesen: <span id="read"></span></div>
<div class="choices" id="choices"></div> <div class="choices" id="choices"></div>
`; `;
// Der Hinweistext ist fest vorgegeben (kein Fremdinhalt), textContent genuegt trotzdem als sichere Wahl.
overlay.querySelector('#hint').textContent = hint;
// Der gelesene Wert stammt aus dem Etikett (Fremdeingabe) - als Text setzen, nicht als Auszeichnung. // Der gelesene Wert stammt aus dem Etikett (Fremdeingabe) - als Text setzen, nicht als Auszeichnung.
overlay.querySelector('#read').textContent = describeReading(spec); overlay.querySelector('#read').textContent = describeReading(spec);
+21
View File
@@ -1,3 +1,5 @@
import { FRAME_INSET } from '../camera.js';
/** /**
* Baut die Hauptansicht auf und liefert Aktualisierungsfunktionen zurueck. * Baut die Hauptansicht auf und liefert Aktualisierungsfunktionen zurueck.
* Kennt weder Kamera noch Erkennung - alles kommt ueber die Rueckrufe. * Kennt weder Kamera noch Erkennung - alles kommt ueber die Rueckrufe.
@@ -24,6 +26,15 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
const statusEl = root.querySelector('#status'); const statusEl = root.querySelector('#status');
const fileEl = root.querySelector('#file'); const fileEl = root.querySelector('#file');
const pickFileButtonEl = root.querySelector('#pick-file-button'); const pickFileButtonEl = root.querySelector('#pick-file-button');
const frameEl = root.querySelector('.frame');
// FRAME_INSET (camera.js) ist die einzige Quelle der Rahmenmasse - sowohl
// fuer diesen sichtbaren Rahmen als auch fuer den nativen Ausschnitt
// (grabFrameRegion). Damit koennen beide nicht mehr auseinanderlaufen;
// das Stilblatt selbst legt keinen Abstand mehr fest.
frameEl.style.inset =
`${FRAME_INSET.top * 100}% ${FRAME_INSET.right * 100}% ` +
`${FRAME_INSET.bottom * 100}% ${FRAME_INSET.left * 100}%`;
root.querySelector('#capture').addEventListener('click', onCapture); root.querySelector('#capture').addEventListener('click', onCapture);
root.querySelector('#undo').addEventListener('click', onUndo); root.querySelector('#undo').addEventListener('click', onUndo);
@@ -82,5 +93,15 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
setFilePickerVisible(visible) { setFilePickerVisible(visible) {
pickFileButtonEl.hidden = !visible; pickFileButtonEl.hidden = !visible;
}, },
/**
* Hebt den Zielrahmen hervor, solange die Dauersuche einen Barcode im
* Ausschnitt erkennt - schlichte Farbaenderung, keine Animation. Der
* Nutzer sieht damit beim Hinhalten sofort, ob es klappt.
* @param {boolean} detected
*/
setFrameDetected(detected) {
frameEl.classList.toggle('detected', detected);
},
}; };
} }
+60
View File
@@ -0,0 +1,60 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { frameRect, FRAME_INSET } from '../src/camera.js';
// frameRect() ist eine reine Funktion (kein window/document noetig) - sie
// laesst sich direkt unter Node pruefen, obwohl camera.js an anderer Stelle
// Browser-APIs verwendet (die dort nur beim Aufruf, nicht beim Import
// ausgewertet werden).
test('glatte Standardaufloesung: Rahmenmasse exakt aus den Anteilen', () => {
const rect = frameRect(1920, 1080, FRAME_INSET);
assert.deepEqual(rect, { x: 154, y: 216, width: 1612, height: 648 });
});
test('krumme Bildgroesse: Ausschnitt bleibt vollstaendig innerhalb des Bildes', () => {
const rect = frameRect(1921, 1079, FRAME_INSET);
assert.deepEqual(rect, { x: 154, y: 216, width: 1613, height: 647 });
assert.ok(rect.x >= 0 && rect.y >= 0);
assert.ok(rect.x + rect.width <= 1921);
assert.ok(rect.y + rect.height <= 1079);
});
test('nicht-ganzzahliges Zwischenergebnis wird gerundet, nicht abgeschnitten', () => {
// 999 * 0.08 = 79.92 -> rundet auf 80, nicht auf 79.
const rect = frameRect(999, 333, FRAME_INSET);
assert.equal(rect.x, 80);
assert.equal(rect.width, 999 - 80 - 80);
// 333 * 0.2 = 66.6 -> rundet auf 67.
assert.equal(rect.y, 67);
assert.equal(rect.height, 333 - 67 - 67);
});
test('Breite/Hoehe werden als Differenz gebildet, nicht unabhaengig gerundet', () => {
// Wuerde man Breite unabhaengig aus dem Anteil runden, koennte sie um ein
// Pixel von "Gesamtbreite minus beide Raender" abweichen. Das darf nicht
// passieren - hier ueber viele krumme Groessen hinweg geprueft.
for (let width = 1; width <= 200; width++) {
for (const inset of [FRAME_INSET, { top: 0.2, right: 0.08, bottom: 0.2, left: 0.08 }]) {
const rect = frameRect(width, width, inset);
const left = Math.round(width * inset.left);
const right = Math.round(width * inset.right);
assert.equal(rect.width, width - left - right);
}
}
});
test('eigene Rahmenmasse werden statt der Voreinstellung verwendet', () => {
const rect = frameRect(1000, 1000, { top: 0.1, right: 0.1, bottom: 0.1, left: 0.1 });
assert.deepEqual(rect, { x: 100, y: 100, width: 800, height: 800 });
});
test('ueberlappende Raender ergeben nie eine negative Breite/Hoehe', () => {
const rect = frameRect(100, 100, { top: 0.6, bottom: 0.6, left: 0.6, right: 0.6 });
assert.equal(rect.width, 0);
assert.equal(rect.height, 0);
});
test('Standard-Rahmenmasse ohne Argument identisch zu FRAME_INSET', () => {
assert.deepEqual(frameRect(1280, 720), frameRect(1280, 720, FRAME_INSET));
});