Compare commits
4
Commits
69dbeeb062
...
5a055923fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a055923fe | ||
|
|
b94639ccd1 | ||
|
|
abb70e8b23 | ||
|
|
44526e7d3b |
@@ -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) {
|
||||
const scale = Math.min(1, maxEdge / Math.max(sourceWidth, sourceHeight));
|
||||
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). */
|
||||
export async function imageDataFromFile(file, maxEdge = 1280) {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
|
||||
+147
-9
@@ -1,6 +1,6 @@
|
||||
// Vollstaendige Verdrahtung der App: Kamera, Barcode/OCR-Erkennung, Sitzung
|
||||
// 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 { runOcr, isOcrAvailable } from './ocr.js';
|
||||
import { recognize } from './pipeline.js';
|
||||
@@ -112,6 +112,10 @@ async function processCapture(getFrame) {
|
||||
candidates: plan.kind === 'ambiguous'
|
||||
? plan.candidates
|
||||
: 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
|
||||
@@ -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. */
|
||||
function closeOverlays() {
|
||||
app.querySelectorAll('.overlay').forEach((element) => element.remove());
|
||||
@@ -199,14 +331,20 @@ function openSessionList() {
|
||||
});
|
||||
}
|
||||
|
||||
startCamera(view.video).catch((error) => {
|
||||
// Kein automatischer Aufruf der Dateiauswahl ohne Nutzergeste - Handy-
|
||||
// Browser blockieren das regelmaessig. Stattdessen bleibt der Ersatzweg
|
||||
// ueber eine sichtbare, dauerhaft eingeblendete Schaltflaeche erreichbar.
|
||||
cameraUnavailableMessage = `Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`;
|
||||
view.setStatus(cameraUnavailableMessage, true);
|
||||
view.setFilePickerVisible(true);
|
||||
});
|
||||
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-
|
||||
// Browser blockieren das regelmaessig. Stattdessen bleibt der Ersatzweg
|
||||
// ueber eine sichtbare, dauerhaft eingeblendete Schaltflaeche erreichbar.
|
||||
cameraUnavailableMessage = `Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`;
|
||||
view.setStatus(cameraUnavailableMessage, true);
|
||||
view.setFilePickerVisible(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Bietet eine beim Start gefundene, gesicherte Sitzung zur Fortsetzung an,
|
||||
|
||||
+8
-1
@@ -25,13 +25,20 @@ body {
|
||||
.camera video { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.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;
|
||||
inset: 20% 8%;
|
||||
border: 3px solid rgba(255, 255, 255, 0.7);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Dauersuche hat einen Barcode im Ausschnitt gefunden - schlichte
|
||||
Farbaenderung, keine Animation. */
|
||||
.frame.detected {
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.stacks {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -19,21 +19,42 @@ function describeReading(spec) {
|
||||
* Reine Darstellung: kennt weder Stapel-Logik noch Erkennung,
|
||||
* 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 {{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'}>}
|
||||
*/
|
||||
export function askForStack(root, { spec, candidates }) {
|
||||
export function askForStack(root, { spec, candidates, nothingRecognized = false }) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'overlay red';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
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 = `
|
||||
<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="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.
|
||||
overlay.querySelector('#read').textContent = describeReading(spec);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { FRAME_INSET } from '../camera.js';
|
||||
|
||||
/**
|
||||
* Baut die Hauptansicht auf und liefert Aktualisierungsfunktionen zurueck.
|
||||
* 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 fileEl = root.querySelector('#file');
|
||||
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('#undo').addEventListener('click', onUndo);
|
||||
@@ -82,5 +93,15 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
|
||||
setFilePickerVisible(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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user