Merge pull request 'Fix #126: Taschenrechner an den Betragsfeldern' (#140) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/140
This commit is contained in:
commit
67d0b80612
14 changed files with 981 additions and 10 deletions
|
|
@ -8,6 +8,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
|
|||
|
||||
### Added
|
||||
|
||||
- Taschenrechner an allen Betragsfeldern: Symbol rechts im Feld bzw. Alt+R öffnet einen
|
||||
Rechner mit den Grundrechenarten und Klammern, komplett per Tastatur bedienbar (C
|
||||
löscht, Enter überträgt, Escape schließt); das Ergebnis erscheint nach jedem
|
||||
Tastendruck (#126)
|
||||
- Buchungsbericht: Spalte „✓“ (abgeglichen) vor der Spalte „Bar“ des Bargeld-Tools und
|
||||
Filter „Abgeglichen“ (alle / abgeglichen / nicht abgeglichen); „Buchungen nicht
|
||||
abgeglichen“ unter den offenen Vorgängen im Cockpit öffnet den Buchungsbericht mit
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
# Änderungen
|
||||
|
||||
## Taschenrechner an den Betragsfeldern (13.10.2026)
|
||||
|
||||
- Anlass (#126): In Betragsfeldern sollte sich rechnen lassen, komplett mit der Tastatur bedienbar.
|
||||
- Jedes Zahlenfeld (`inputmode="decimal"`) zeigt rechts ein Rechnersymbol; Klick darauf oder Alt+R öffnet den Taschenrechner (`public/static/rechner.js`, im Seitenrahmen eingebunden, ebenso `betrag.js`). Anzeigezeile mit Rechnung und darunter dem Ergebnis nach jedem Tastendruck; Ziffern, `+ - * / ( )`, Backspace, Entf, Pfeiltasten, `C` (alles löschen), `=` (Ergebnis weiterverwenden), Enter (übertragen), Escape (schließen, Fokus ins Feld). Tastenfeld zusätzlich zum Anklicken, Farben aus dem Thema.
|
||||
- Das Symbol ist ein Hintergrundbild – kein Element um das Feld, kein Eingriff in die Layouts. Schmale Inline-Felder (`.schmal`) und die Budgetplaner-Felder sind dafür etwas breiter.
|
||||
- Stück und Kurs übernehmen 6, Zinssätze 3 Nachkommastellen (`data-nachkommastellen`), alle anderen Felder 2.
|
||||
- Regelmäßige Buchungen: Das Inline-Feld klappt nicht mehr zu, solange der Rechner offen ist (`reihenliste.js`).
|
||||
- Tests: `tests/66_TaschenrechnerTest.php`, `tests/e2e/specs/rechner.spec.js`.
|
||||
|
||||
## Buchungsbericht: Spalte und Filter „abgeglichen“ (12.10.2026)
|
||||
|
||||
- Anlass (#137): Der Buchungsbericht zeigte nicht, ob eine Buchung abgeglichen ist, und ließ sich nicht danach filtern.
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ Haushalt wird beides parallel genutzt.
|
|||
Adresse, Zugangsdaten und die abzugleichenden Kategorien stehen unter
|
||||
*Verwaltung → Bargeld-Tool*. Sammelbuchungen und Differenzausgleiche lassen sich in der
|
||||
Buchungsmaske bewusst ohne Bargeld-Abgleich buchen.
|
||||
* **Taschenrechner** – in jedem Betragsfeld über das Symbol rechts im Feld (oder Alt+R),
|
||||
komplett mit der Tastatur bedienbar; Enter überträgt das Ergebnis ins Feld.
|
||||
|
||||
Einzelheiten je Modul in `docs/`, die Entwicklung in `Changes.md`.
|
||||
|
||||
|
|
|
|||
|
|
@ -258,6 +258,37 @@ Eingegeben wird immer ein **positiver** Betrag; die Richtung bestimmt die Auswah
|
|||
entscheidet bei einem Punkt ohne Komma nach der Stellenzahl: drei Ziffern dahinter heißt
|
||||
Tausendertrenner (`1.500`), sonst Dezimaltrenner (`1234.56`).
|
||||
|
||||
### Taschenrechner (Feature #126)
|
||||
|
||||
Jedes Zahlenfeld (`<input type="text" inputmode="decimal">`) trägt rechts ein
|
||||
Rechnersymbol. Ein Klick darauf – oder **Alt+R** im Feld – öffnet unter dem Feld (fehlt
|
||||
dort der Platz, darüber) einen Taschenrechner mit den Grundrechenarten. In der
|
||||
Anzeigezeile steht oben die Rechnung, darunter nach jedem Tastendruck ihr Ergebnis; der
|
||||
bisherige Feldwert ist die erste Zahl der Rechnung.
|
||||
|
||||
| Taste | Wirkung |
|
||||
|---|---|
|
||||
| `0`–`9`, `,` bzw. `.` | Ziffer bzw. Dezimalkomma |
|
||||
| `+` `-` `*` `/` `(` `)` | Grundrechenart, Klammern (Punkt vor Strich) |
|
||||
| Backspace / Entf | Zeichen links / rechts der Schreibmarke löschen |
|
||||
| Pfeil links / rechts | Schreibmarke bewegen |
|
||||
| `C` | gesamte Eingabe löschen |
|
||||
| `=` | Rechnung durch ihr Ergebnis ersetzen |
|
||||
| Enter | Ergebnis ins Feld übertragen, Rechner schließen |
|
||||
| Escape | schließen ohne Übernahme, Fokus zurück ins Feld |
|
||||
|
||||
Übertragen wird mit zwei Nachkommastellen; Felder mit `data-nachkommastellen` (Stück und
|
||||
Kurs: 6, Zinssatz: 3) übernehmen genauer. Das Feld bekommt `input`- und
|
||||
`change`-Ereignisse, als wäre der Wert getippt – Restbeträge der Splitbuchung, die
|
||||
Depotberechnung und die Speichern-Knöpfe der Listen ziehen mit. `data-ohne-rechner`
|
||||
nimmt ein Feld aus.
|
||||
|
||||
Umgesetzt in `public/static/rechner.js` (im Seitenrahmen eingebunden), ohne Element um
|
||||
das Feld: Das Symbol ist ein Hintergrundbild aus `app.css`, den Klick darauf erkennt das
|
||||
Skript an der Position. Gerechnet wird mit einem eigenen kleinen Parser statt `eval` (die
|
||||
CSP verböte es ohnehin). Steht das Feld in einem modalen `<dialog>`, wandert der Rechner
|
||||
mit hinein; Escape schließt dann nur den Rechner, nicht den Dialog.
|
||||
|
||||
## Bestätigungen und die Content-Security-Policy
|
||||
|
||||
Die CSP erlaubt keine Inline-Skripte (`script-src 'self'`). Rückfragen vor dem Löschen
|
||||
|
|
|
|||
|
|
@ -963,9 +963,10 @@ table.budgetplaner .laufend { background: color-mix(in srgb, var(--akzent) 8%, t
|
|||
|
||||
table.budgetplaner tr.summenzeile-kategorie td { font-weight: 600; }
|
||||
|
||||
/* Breit genug für „123.456,78“ – eine Stelle mehr, als der Haushalt braucht. */
|
||||
/* Breit genug für „123.456,78“ – eine Stelle mehr, als der Haushalt braucht –
|
||||
neben dem Rechnersymbol. */
|
||||
table.budgetplaner input.betragsfeld {
|
||||
width: 7rem;
|
||||
width: 8.5rem;
|
||||
text-align: right;
|
||||
font-family: var(--schrift-zahl);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
|
@ -2538,3 +2539,111 @@ table.liste.reihen [hidden] { display: none; }
|
|||
background: color-mix(in srgb, var(--akzent) 8%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Taschenrechner an den Zahlenfeldern (rechner.js, Fix #126). Das Symbol ist
|
||||
ein Hintergrundbild im rechten Innenabstand – ein Element um das Feld griffe
|
||||
in jedes Layout ein, in dem ein Betragsfeld steht. Die hohe Spezifität
|
||||
überstimmt die Abstände aus .formular und table.liste. */
|
||||
input[type="text"][inputmode="decimal"]:not([data-ohne-rechner]):not([readonly]):not(:disabled) {
|
||||
padding-right: 1.7rem;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%237b8794' stroke-width='1.3'%3E%3Crect x='2.5' y='1.5' width='11' height='13' rx='1.5'/%3E%3Crect x='4.5' y='3.5' width='7' height='3' rx='.5'/%3E%3Cpath d='M5 9h.01M8 9h.01M11 9h.01M5 12h.01M8 12h.01M11 12h.01' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right .4rem center;
|
||||
background-size: 1rem 1rem;
|
||||
}
|
||||
|
||||
input[type="text"][inputmode="decimal"].rechner-zeiger { cursor: pointer; }
|
||||
|
||||
/* Schmale Inline-Felder behielten neben dem Symbol kaum Platz für „1.234,56“. */
|
||||
input.schmal[inputmode="decimal"]:not([data-ohne-rechner]) { width: 6.5rem; }
|
||||
|
||||
.rechner {
|
||||
position: fixed;
|
||||
inset: auto;
|
||||
margin: 0;
|
||||
z-index: 1000;
|
||||
width: 15rem;
|
||||
padding: .6rem;
|
||||
border: var(--bereichsrand);
|
||||
border-radius: var(--radius);
|
||||
background: var(--flaeche);
|
||||
color: var(--text);
|
||||
box-shadow: var(--bereichsschatten);
|
||||
}
|
||||
|
||||
.rechner[hidden] { display: none; }
|
||||
|
||||
/* Die „Display“-Zeile: oben die Rechnung, darunter ihr Ergebnis. */
|
||||
.rechner-anzeige {
|
||||
margin-bottom: .5rem;
|
||||
padding: .35rem .5rem;
|
||||
border: 1px solid var(--rand-kraeftig);
|
||||
border-radius: 4px;
|
||||
background: var(--flaeche-gedaempft);
|
||||
font-family: var(--schrift-zahl);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rechner-anzeige:focus-within {
|
||||
outline: 2px solid var(--akzent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.rechner-eingabe {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text-gedaempft);
|
||||
font: inherit;
|
||||
font-size: .95rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rechner-ergebnis {
|
||||
display: block;
|
||||
min-height: 1.9rem;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.rechner-ergebnis.veraltet { color: var(--text-gedaempft); font-weight: 400; }
|
||||
.rechner-ergebnis.fehler { color: var(--negativ); font-size: .9rem; font-weight: 500; }
|
||||
|
||||
.rechner-tasten {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: .3rem;
|
||||
}
|
||||
|
||||
.rechner-tasten button {
|
||||
padding: .45rem 0;
|
||||
border: 1px solid var(--rand-kraeftig);
|
||||
border-radius: 4px;
|
||||
background: var(--flaeche);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-family: var(--schrift-zahl);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rechner-tasten button:hover { background: var(--flaeche-gedaempft); }
|
||||
|
||||
.rechner-tasten button.operator {
|
||||
background: var(--akzent-schwach);
|
||||
color: var(--akzent-schrift);
|
||||
}
|
||||
|
||||
.rechner-tasten button.loeschen { color: var(--negativ); }
|
||||
|
||||
.rechner-tasten button.uebernehmen {
|
||||
grid-column: 1 / -1;
|
||||
border-color: var(--akzent);
|
||||
background: var(--akzent);
|
||||
color: var(--akzent-text);
|
||||
font-family: var(--schrift);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
|||
552
public/static/rechner.js
Normal file
552
public/static/rechner.js
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
/* Taschenrechner an den Betragsfeldern (Fix #126).
|
||||
*
|
||||
* Jedes Zahlenfeld (`inputmode="decimal"`) trägt rechts ein Rechnersymbol. Das
|
||||
* Symbol ist nur ein Hintergrundbild aus app.css: Ein eigenes Element um das
|
||||
* Feld griffe in Dutzende Layouts ein – Grids, Tabellenzellen, schmale
|
||||
* Inline-Felder. Den Klick darauf erkennt dieses Skript an der Position.
|
||||
* Alt+R öffnet den Rechner auch von der Tastatur aus.
|
||||
*
|
||||
* Der Rechner ist mit der Tastatur bedienbar: Ziffern und + - * / ( ) tippen,
|
||||
* Backspace/Entf/Pfeiltasten wie gewohnt, C löscht alles, = setzt das Ergebnis
|
||||
* als neue Rechnung ein, Enter überträgt es ins Feld, Escape schließt. Das
|
||||
* Ergebnis steht nach jedem Tastendruck unter der Rechnung.
|
||||
*
|
||||
* Gerechnet wird mit einem eigenen kleinen Parser – `eval` verböte die
|
||||
* Content-Security-Policy, und auf Eingaben gehört es ohnehin nicht.
|
||||
*
|
||||
* Felder mit `data-ohne-rechner` bleiben außen vor. `data-nachkommastellen`
|
||||
* legt fest, wie genau das Ergebnis übertragen wird (Vorgabe: 2).
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const AUSWAHL = 'input[type="text"][inputmode="decimal"]:not([data-ohne-rechner])';
|
||||
|
||||
/* Was die Tastatur liefert, wie es in der Anzeige erscheint. */
|
||||
const ZEICHEN = {
|
||||
'0': '0', '1': '1', '2': '2', '3': '3', '4': '4',
|
||||
'5': '5', '6': '6', '7': '7', '8': '8', '9': '9',
|
||||
'+': '+', '-': '−', '*': '×', '/': '÷', '(': '(', ')': ')',
|
||||
',': ',', '.': ',',
|
||||
'−': '−', '×': '×', '÷': '÷', ':': '÷', 'x': '×'
|
||||
};
|
||||
|
||||
/* Das Tastenfeld, zeilenweise. */
|
||||
const TASTEN = [
|
||||
['C', '(', ')', '⌫'],
|
||||
['7', '8', '9', '÷'],
|
||||
['4', '5', '6', '×'],
|
||||
['1', '2', '3', '−'],
|
||||
['0', ',', '=', '+']
|
||||
];
|
||||
|
||||
const OPERATOREN = ['÷', '×', '−', '+', '(', ')'];
|
||||
|
||||
/* ── Rechnen ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function RechenFehler(art) {
|
||||
this.art = art;
|
||||
}
|
||||
|
||||
/* Rechnung → Zahl. Wirft RechenFehler mit der Art „unvollstaendig“ (die
|
||||
* Eingabe hört mitten in der Rechnung auf), „ungueltig“ oder „null“
|
||||
* (Division durch null). */
|
||||
function auswerten(text) {
|
||||
const s = text
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/×/g, '*')
|
||||
.replace(/÷/g, '/')
|
||||
.replace(/−/g, '-')
|
||||
.replace(/,/g, '.');
|
||||
let pos = 0;
|
||||
|
||||
function ende() {
|
||||
return pos >= s.length;
|
||||
}
|
||||
|
||||
function zahl() {
|
||||
const treffer = /^(\d+\.?\d*|\.\d+|\.)/.exec(s.slice(pos));
|
||||
if (!treffer) {
|
||||
throw new RechenFehler(ende() ? 'unvollstaendig' : 'ungueltig');
|
||||
}
|
||||
pos += treffer[0].length;
|
||||
if (treffer[0] === '.') {
|
||||
throw new RechenFehler(ende() ? 'unvollstaendig' : 'ungueltig');
|
||||
}
|
||||
return parseFloat(treffer[0]);
|
||||
}
|
||||
|
||||
function faktor() {
|
||||
if (ende()) {
|
||||
throw new RechenFehler('unvollstaendig');
|
||||
}
|
||||
const z = s[pos];
|
||||
if (z === '+' || z === '-') {
|
||||
pos++;
|
||||
const wert = faktor();
|
||||
return z === '-' ? -wert : wert;
|
||||
}
|
||||
if (z === '(') {
|
||||
pos++;
|
||||
const wert = ausdruck();
|
||||
if (ende()) {
|
||||
throw new RechenFehler('unvollstaendig');
|
||||
}
|
||||
if (s[pos] !== ')') {
|
||||
throw new RechenFehler('ungueltig');
|
||||
}
|
||||
pos++;
|
||||
return wert;
|
||||
}
|
||||
return zahl();
|
||||
}
|
||||
|
||||
function term() {
|
||||
let wert = faktor();
|
||||
while (!ende() && (s[pos] === '*' || s[pos] === '/')) {
|
||||
const op = s[pos++];
|
||||
const rechts = faktor();
|
||||
if (op === '/' && rechts === 0) {
|
||||
throw new RechenFehler('null');
|
||||
}
|
||||
wert = op === '*' ? wert * rechts : wert / rechts;
|
||||
}
|
||||
return wert;
|
||||
}
|
||||
|
||||
function ausdruck() {
|
||||
let wert = term();
|
||||
while (!ende() && (s[pos] === '+' || s[pos] === '-')) {
|
||||
const op = s[pos++];
|
||||
const rechts = term();
|
||||
wert = op === '+' ? wert + rechts : wert - rechts;
|
||||
}
|
||||
return wert;
|
||||
}
|
||||
|
||||
const wert = ausdruck();
|
||||
if (!ende()) {
|
||||
throw new RechenFehler('ungueltig');
|
||||
}
|
||||
// Gleitkommareste wie 0,1 + 0,2 = 0,30000000000000004 abschneiden.
|
||||
return Math.round(wert * 1e10) / 1e10;
|
||||
}
|
||||
|
||||
/* Für die Anzeige im Rechner: so viele Stellen wie nötig, mit Tausenderpunkt. */
|
||||
function anzeigeText(wert) {
|
||||
return wert.toLocaleString('de-DE', { maximumFractionDigits: 10 });
|
||||
}
|
||||
|
||||
/* Für das Feld: ohne Tausenderpunkt, wie die Felder ihre Werte vom Server
|
||||
* bekommen (number_format(…, 2, ',', '')). */
|
||||
function feldText(wert, stellen) {
|
||||
return wert.toLocaleString('de-DE', {
|
||||
minimumFractionDigits: Math.min(2, stellen),
|
||||
maximumFractionDigits: stellen,
|
||||
useGrouping: false
|
||||
});
|
||||
}
|
||||
|
||||
/* Der bisherige Feldinhalt als Startwert der Rechnung. */
|
||||
function startwert(feld) {
|
||||
const text = feld.value.trim();
|
||||
if (text === '') {
|
||||
return '';
|
||||
}
|
||||
const wert = window.FmBetrag
|
||||
? window.FmBetrag.zahl(text)
|
||||
: parseFloat(text.replace(/\./g, '').replace(',', '.')) || 0;
|
||||
if (wert === 0) {
|
||||
return '';
|
||||
}
|
||||
return wert
|
||||
.toLocaleString('de-DE', { maximumFractionDigits: 10, useGrouping: false })
|
||||
.replace('-', '−');
|
||||
}
|
||||
|
||||
/* ── Oberfläche ──────────────────────────────────────────────────────── */
|
||||
|
||||
const mitPopover = typeof HTMLElement.prototype.showPopover === 'function';
|
||||
let rechner = null;
|
||||
let eingabe = null;
|
||||
let ergebnis = null;
|
||||
let offenesFeld = null;
|
||||
let letzterWert = null;
|
||||
|
||||
function bauen() {
|
||||
rechner = document.createElement('div');
|
||||
rechner.className = 'rechner';
|
||||
rechner.setAttribute('role', 'dialog');
|
||||
rechner.setAttribute('aria-label', 'Taschenrechner');
|
||||
if (mitPopover) {
|
||||
rechner.popover = 'manual';
|
||||
} else {
|
||||
rechner.hidden = true;
|
||||
}
|
||||
|
||||
const anzeige = document.createElement('div');
|
||||
anzeige.className = 'rechner-anzeige';
|
||||
|
||||
eingabe = document.createElement('input');
|
||||
eingabe.type = 'text';
|
||||
eingabe.className = 'rechner-eingabe';
|
||||
eingabe.autocomplete = 'off';
|
||||
eingabe.spellcheck = false;
|
||||
eingabe.setAttribute('inputmode', 'none');
|
||||
eingabe.setAttribute('aria-label', 'Rechnung');
|
||||
|
||||
ergebnis = document.createElement('output');
|
||||
ergebnis.className = 'rechner-ergebnis';
|
||||
ergebnis.setAttribute('aria-live', 'polite');
|
||||
|
||||
anzeige.append(eingabe, ergebnis);
|
||||
|
||||
const tasten = document.createElement('div');
|
||||
tasten.className = 'rechner-tasten';
|
||||
TASTEN.flat().forEach(function (taste) {
|
||||
tasten.appendChild(knopf(taste, taste));
|
||||
});
|
||||
const uebernehmen = knopf('Übernehmen', 'Enter');
|
||||
uebernehmen.classList.add('uebernehmen');
|
||||
uebernehmen.title = 'Ergebnis ins Feld übernehmen (Enter)';
|
||||
tasten.appendChild(uebernehmen);
|
||||
|
||||
rechner.append(anzeige, tasten);
|
||||
|
||||
eingabe.addEventListener('keydown', tastendruck);
|
||||
eingabe.addEventListener('input', bereinigen);
|
||||
rechner.addEventListener('focusout', function (ereignis) {
|
||||
// Mit Tab hinaus: zu. Ohne Ziel (Fensterwechsel) bleibt er offen.
|
||||
if (ereignis.relatedTarget && !rechner.contains(ereignis.relatedTarget)) {
|
||||
schliessen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function knopf(beschriftung, taste) {
|
||||
const k = document.createElement('button');
|
||||
k.type = 'button';
|
||||
k.tabIndex = -1;
|
||||
k.textContent = beschriftung;
|
||||
k.dataset.taste = taste;
|
||||
if (OPERATOREN.indexOf(taste) !== -1) {
|
||||
k.classList.add('operator');
|
||||
}
|
||||
if (taste === 'C' || taste === '⌫') {
|
||||
k.classList.add('loeschen');
|
||||
}
|
||||
if (taste === '⌫') {
|
||||
k.setAttribute('aria-label', 'Zeichen löschen');
|
||||
}
|
||||
// Der Fokus bleibt in der Rechnung, sonst stünde die Schreibmarke
|
||||
// nach jedem Klick woanders.
|
||||
k.addEventListener('mousedown', function (ereignis) {
|
||||
ereignis.preventDefault();
|
||||
});
|
||||
k.addEventListener('click', function () {
|
||||
taste === 'Enter' ? uebernehmen() : drueckeTaste(taste);
|
||||
eingabe.focus();
|
||||
});
|
||||
return k;
|
||||
}
|
||||
|
||||
function einfuegen(text) {
|
||||
eingabe.setRangeText(text, eingabe.selectionStart, eingabe.selectionEnd, 'end');
|
||||
rechnen();
|
||||
}
|
||||
|
||||
function drueckeTaste(taste) {
|
||||
if (taste === 'C') {
|
||||
eingabe.value = '';
|
||||
rechnen();
|
||||
} else if (taste === '⌫') {
|
||||
const start = eingabe.selectionStart;
|
||||
const ende = eingabe.selectionEnd;
|
||||
if (start !== ende) {
|
||||
eingabe.setRangeText('', start, ende, 'end');
|
||||
} else if (start > 0) {
|
||||
eingabe.setRangeText('', start - 1, start, 'end');
|
||||
}
|
||||
rechnen();
|
||||
} else if (taste === '=') {
|
||||
ergebnisEinsetzen();
|
||||
} else {
|
||||
einfuegen(taste);
|
||||
}
|
||||
}
|
||||
|
||||
/* „=“: Die Rechnung weicht ihrem Ergebnis, weitergerechnet wird damit. */
|
||||
function ergebnisEinsetzen() {
|
||||
if (letzterWert === null || eingabe.value.trim() === '') {
|
||||
return;
|
||||
}
|
||||
eingabe.value = feldText(letzterWert, 10).replace('-', '−');
|
||||
eingabe.setSelectionRange(eingabe.value.length, eingabe.value.length);
|
||||
rechnen();
|
||||
}
|
||||
|
||||
function tastendruck(ereignis) {
|
||||
const taste = ereignis.key;
|
||||
|
||||
// Kopieren, Einfügen, Alles markieren bleiben, wie sie sind.
|
||||
if (ereignis.ctrlKey || ereignis.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (taste === 'Enter') {
|
||||
ereignis.preventDefault();
|
||||
ereignis.stopPropagation();
|
||||
uebernehmen();
|
||||
return;
|
||||
}
|
||||
if (taste === 'Escape') {
|
||||
// Nicht weiterreichen: Sonst schlösse Escape auch einen Dialog,
|
||||
// in dem das Feld steht, oder das Kontoblatt verwürfe eine Auswahl.
|
||||
ereignis.preventDefault();
|
||||
ereignis.stopPropagation();
|
||||
schliessen(true);
|
||||
return;
|
||||
}
|
||||
if (taste === 'c' || taste === 'C') {
|
||||
ereignis.preventDefault();
|
||||
drueckeTaste('C');
|
||||
return;
|
||||
}
|
||||
if (taste === '=') {
|
||||
ereignis.preventDefault();
|
||||
ergebnisEinsetzen();
|
||||
return;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(ZEICHEN, taste)) {
|
||||
ereignis.preventDefault();
|
||||
einfuegen(ZEICHEN[taste]);
|
||||
return;
|
||||
}
|
||||
// Backspace, Entf, Pfeile, Pos1/Ende, Tab: wie in jedem Textfeld.
|
||||
// Jedes andere druckbare Zeichen gehört nicht in eine Rechnung.
|
||||
if (taste.length === 1 && !ereignis.altKey) {
|
||||
ereignis.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/* Nach Einfügen aus der Zwischenablage: nur übernehmen, was rechnen kann. */
|
||||
function bereinigen() {
|
||||
const vorher = eingabe.value;
|
||||
let marke = eingabe.selectionStart;
|
||||
let sauber = '';
|
||||
for (let i = 0; i < vorher.length; i++) {
|
||||
const z = vorher[i];
|
||||
if (Object.prototype.hasOwnProperty.call(ZEICHEN, z)) {
|
||||
sauber += ZEICHEN[z];
|
||||
} else if (i < marke) {
|
||||
marke--;
|
||||
}
|
||||
}
|
||||
if (sauber !== vorher) {
|
||||
eingabe.value = sauber;
|
||||
eingabe.setSelectionRange(marke, marke);
|
||||
}
|
||||
rechnen();
|
||||
}
|
||||
|
||||
function rechnen() {
|
||||
const text = eingabe.value;
|
||||
ergebnis.classList.remove('veraltet', 'fehler');
|
||||
|
||||
if (text.trim() === '') {
|
||||
letzterWert = null;
|
||||
ergebnis.textContent = '0';
|
||||
ergebnis.classList.add('veraltet');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
letzterWert = auswerten(text);
|
||||
ergebnis.textContent = anzeigeText(letzterWert);
|
||||
} catch (fehler) {
|
||||
if (!(fehler instanceof RechenFehler)) {
|
||||
throw fehler;
|
||||
}
|
||||
letzterWert = null;
|
||||
if (fehler.art === 'unvollstaendig') {
|
||||
// Mitten in der Rechnung: das zuletzt gültige Ergebnis
|
||||
// gedämpft stehen lassen, statt bei jedem Operator zu flackern.
|
||||
ergebnis.classList.add('veraltet');
|
||||
} else {
|
||||
ergebnis.classList.add('fehler');
|
||||
ergebnis.textContent = fehler.art === 'null'
|
||||
? 'Division durch 0'
|
||||
: 'Ungültige Rechnung';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function uebernehmen() {
|
||||
const feld = offenesFeld;
|
||||
if (!feld) {
|
||||
return;
|
||||
}
|
||||
if (eingabe.value.trim() !== '') {
|
||||
if (letzterWert === null) {
|
||||
// Unvollständig oder fehlerhaft: nichts übertragen.
|
||||
if (!ergebnis.classList.contains('fehler')) {
|
||||
ergebnis.textContent = 'Rechnung unvollständig';
|
||||
}
|
||||
ergebnis.classList.remove('veraltet');
|
||||
ergebnis.classList.add('fehler');
|
||||
return;
|
||||
}
|
||||
const stellen = parseInt(feld.dataset.nachkommastellen || '2', 10);
|
||||
feld.value = feldText(letzterWert, isNaN(stellen) ? 2 : stellen);
|
||||
// Restbeträge, Depotberechnung, „Speichern“-Knöpfe hängen an diesen
|
||||
// Ereignissen – sie sollen den neuen Wert sehen wie einen getippten.
|
||||
feld.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
feld.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
schliessen(true);
|
||||
}
|
||||
|
||||
function positionieren() {
|
||||
if (!offenesFeld) {
|
||||
return;
|
||||
}
|
||||
const r = offenesFeld.getBoundingClientRect();
|
||||
const breite = rechner.offsetWidth;
|
||||
const hoehe = rechner.offsetHeight;
|
||||
const rand = 8;
|
||||
|
||||
let links = Math.min(r.right - breite, window.innerWidth - breite - rand);
|
||||
links = Math.max(rand, links);
|
||||
let oben = r.bottom + 4;
|
||||
if (oben + hoehe > window.innerHeight - rand && r.top - hoehe - 4 >= rand) {
|
||||
oben = r.top - hoehe - 4;
|
||||
}
|
||||
rechner.style.left = links + 'px';
|
||||
rechner.style.top = oben + 'px';
|
||||
}
|
||||
|
||||
function istOffen() {
|
||||
return offenesFeld !== null;
|
||||
}
|
||||
|
||||
function oeffnen(feld) {
|
||||
if (!rechner) {
|
||||
bauen();
|
||||
}
|
||||
if (istOffen()) {
|
||||
schliessen(false);
|
||||
}
|
||||
|
||||
// In einem modalen Dialog wäre alles außerhalb inaktiv – der Rechner
|
||||
// muss deshalb mit in den Dialog.
|
||||
const heimat = feld.closest('dialog') || document.body;
|
||||
if (rechner.parentNode !== heimat) {
|
||||
heimat.appendChild(rechner);
|
||||
}
|
||||
|
||||
offenesFeld = feld;
|
||||
eingabe.value = startwert(feld);
|
||||
ergebnis.textContent = '';
|
||||
rechnen();
|
||||
|
||||
if (mitPopover) {
|
||||
rechner.showPopover();
|
||||
} else {
|
||||
rechner.hidden = false;
|
||||
}
|
||||
positionieren();
|
||||
eingabe.focus();
|
||||
eingabe.setSelectionRange(eingabe.value.length, eingabe.value.length);
|
||||
}
|
||||
|
||||
/* `fokus`: das Feld wieder in den Fokus nehmen (Enter, Escape). */
|
||||
function schliessen(fokus) {
|
||||
const feld = offenesFeld;
|
||||
if (!feld) {
|
||||
return;
|
||||
}
|
||||
offenesFeld = null;
|
||||
if (mitPopover) {
|
||||
if (rechner.matches(':popover-open')) {
|
||||
rechner.hidePopover();
|
||||
}
|
||||
} else {
|
||||
rechner.hidden = true;
|
||||
}
|
||||
if (fokus) {
|
||||
feld.focus();
|
||||
}
|
||||
feld.dispatchEvent(new CustomEvent('rechner:zu', { detail: { fokus: fokus } }));
|
||||
}
|
||||
|
||||
/* ── Anbindung an die Felder ─────────────────────────────────────────── */
|
||||
|
||||
function bedienbar(feld) {
|
||||
return feld instanceof HTMLInputElement
|
||||
&& feld.matches(AUSWAHL)
|
||||
&& !feld.readOnly
|
||||
&& !feld.disabled
|
||||
&& !feld.hidden;
|
||||
}
|
||||
|
||||
/* Liegt der Zeiger über dem Symbol? Die Fläche ist der rechte Innenabstand,
|
||||
* den app.css für das Symbol freihält. */
|
||||
function aufSymbol(feld, ereignis) {
|
||||
const stil = getComputedStyle(feld);
|
||||
const zone = parseFloat(stil.paddingRight) + parseFloat(stil.borderRightWidth);
|
||||
const r = feld.getBoundingClientRect();
|
||||
return ereignis.clientX >= r.right - zone && ereignis.clientX <= r.right
|
||||
&& ereignis.clientY >= r.top && ereignis.clientY <= r.bottom;
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', function (ereignis) {
|
||||
const feld = ereignis.target;
|
||||
if (feld instanceof HTMLInputElement && bedienbar(feld)) {
|
||||
const drauf = aufSymbol(feld, ereignis);
|
||||
feld.classList.toggle('rechner-zeiger', drauf);
|
||||
// Der Hinweis nur über dem Symbol – und nie anstelle eines eigenen.
|
||||
if (drauf && !feld.title) {
|
||||
feld.title = 'Taschenrechner (Alt+R)';
|
||||
feld.dataset.rechnerTitel = '';
|
||||
} else if (!drauf && feld.dataset.rechnerTitel !== undefined) {
|
||||
feld.removeAttribute('title');
|
||||
delete feld.dataset.rechnerTitel;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mousedown', function (ereignis) {
|
||||
const ziel = ereignis.target;
|
||||
|
||||
if (ziel instanceof HTMLInputElement && bedienbar(ziel) && aufSymbol(ziel, ereignis)) {
|
||||
// Kein Fokus, keine Schreibmarke im Feld – der Klick gilt dem Symbol.
|
||||
ereignis.preventDefault();
|
||||
if (offenesFeld === ziel) {
|
||||
schliessen(true);
|
||||
} else {
|
||||
oeffnen(ziel);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Klick daneben: zu, ohne dem Klick den Fokus streitig zu machen.
|
||||
if (istOffen() && ziel instanceof Node && !rechner.contains(ziel)) {
|
||||
schliessen(false);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (ereignis) {
|
||||
if (ereignis.altKey && !ereignis.ctrlKey && !ereignis.metaKey && ereignis.code === 'KeyR'
|
||||
&& ereignis.target instanceof HTMLInputElement && bedienbar(ereignis.target)) {
|
||||
ereignis.preventDefault();
|
||||
oeffnen(ereignis.target);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', positionieren);
|
||||
// Mitlaufen, wenn die Seite oder ein Dialog darunter scrollt.
|
||||
document.addEventListener('scroll', positionieren, true);
|
||||
|
||||
window.FmRechner = { auswerten: auswerten, oeffnen: oeffnen };
|
||||
})();
|
||||
|
|
@ -93,7 +93,19 @@
|
|||
bearbeiten();
|
||||
}
|
||||
});
|
||||
steuer.addEventListener('blur', function () { zeige(feld); });
|
||||
// Der Taschenrechner (rechner.js) leiht sich den Fokus nur: Das Feld
|
||||
// bleibt offen, bis er wieder zu ist.
|
||||
steuer.addEventListener('blur', function (e) {
|
||||
if (e.relatedTarget && e.relatedTarget.closest('.rechner')) {
|
||||
return;
|
||||
}
|
||||
zeige(feld);
|
||||
});
|
||||
steuer.addEventListener('rechner:zu', function (e) {
|
||||
if (!e.detail.fokus) {
|
||||
zeige(feld);
|
||||
}
|
||||
});
|
||||
steuer.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
steuer.blur();
|
||||
|
|
|
|||
|
|
@ -92,11 +92,11 @@ use Finanzmanager\View;
|
|||
<div class="feldpaar">
|
||||
<div>
|
||||
<label for="wp_stueck">Stück</label>
|
||||
<input type="text" id="wp_stueck" name="stueck" inputmode="decimal">
|
||||
<input type="text" id="wp_stueck" name="stueck" inputmode="decimal" data-nachkommastellen="6">
|
||||
</div>
|
||||
<div>
|
||||
<label for="wp_kurs">Kurs <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="wp_kurs" name="kurs" inputmode="decimal">
|
||||
<input type="text" id="wp_kurs" name="kurs" inputmode="decimal" data-nachkommastellen="6">
|
||||
</div>
|
||||
</div>
|
||||
<p class="feldhinweis">
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ $istAktiv = static function (string $pfad, array $auch = []) use ($aktuellerPfad
|
|||
<!-- Nur hier eingebunden: Ein zweites Mal im Fuß ließe jeden Handler doppelt
|
||||
laufen – Rückfragen kämen zweimal, Menüs entstünden zweimal. -->
|
||||
<script src="/static/app.js" defer></script>
|
||||
<!-- Der Taschenrechner hängt an jedem Zahlenfeld jeder Seite; betrag.js
|
||||
liefert ihm das Lesen deutscher Beträge. -->
|
||||
<script src="/static/betrag.js" defer></script>
|
||||
<script src="/static/rechner.js" defer></script>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%231d6fb8'/><text x='8' y='12.5' font-size='12' font-weight='bold' text-anchor='middle' fill='white' font-family='sans-serif'>€</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</div>
|
||||
<div>
|
||||
<label for="b_zins">Zinssatz (%)</label>
|
||||
<input type="text" id="b_zins" name="zinssatz" inputmode="decimal"
|
||||
<input type="text" id="b_zins" name="zinssatz" inputmode="decimal" data-nachkommastellen="3"
|
||||
value="<?= View::h(number_format((float)$kredit['zinssatz'], 3, ',', '')) ?>">
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -224,7 +224,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</div>
|
||||
<div>
|
||||
<label for="zinssatz">Zinssatz (%)</label>
|
||||
<input type="text" id="zinssatz" name="zinssatz" inputmode="decimal" required>
|
||||
<input type="text" id="zinssatz" name="zinssatz" inputmode="decimal" data-nachkommastellen="3" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="rate">Rate</label>
|
||||
|
|
|
|||
|
|
@ -92,11 +92,11 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<div class="feldpaar">
|
||||
<div>
|
||||
<label for="stueck">Stück</label>
|
||||
<input type="text" id="stueck" name="stueck" inputmode="decimal">
|
||||
<input type="text" id="stueck" name="stueck" inputmode="decimal" data-nachkommastellen="6">
|
||||
</div>
|
||||
<div>
|
||||
<label for="kurs">Kurs</label>
|
||||
<input type="text" id="kurs" name="kurs" inputmode="decimal">
|
||||
<input type="text" id="kurs" name="kurs" inputmode="decimal" data-nachkommastellen="6">
|
||||
</div>
|
||||
<div>
|
||||
<label for="betrag">Betrag</label>
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<td class="nowrap">
|
||||
<input type="hidden" form="<?= $k ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="hidden" form="<?= $k ?>" name="datum" value="<?= View::h(date('Y-m-d')) ?>">
|
||||
<input type="text" form="<?= $k ?>" name="kurs" class="schmal" inputmode="decimal"
|
||||
<input type="text" form="<?= $k ?>" name="kurs" class="schmal" inputmode="decimal" data-nachkommastellen="6"
|
||||
placeholder="Kurs">
|
||||
<button type="submit" form="<?= $k ?>" class="knopf klein">Setzen</button>
|
||||
</td>
|
||||
|
|
|
|||
102
tests/66_TaschenrechnerTest.php
Normal file
102
tests/66_TaschenrechnerTest.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Taschenrechner an den Zahlenfeldern (Fix #126). Das Verhalten im Browser
|
||||
* prüft tests/e2e/specs/rechner.spec.js; hier nur, dass er überall ankommt:
|
||||
* eingebunden im Seitenrahmen, Symbol im Stilblatt, genaue Übernahme bei
|
||||
* Stückzahl, Kurs und Zinssatz.
|
||||
*/
|
||||
class TaschenrechnerTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 66: Taschenrechner an Betragsfeldern';
|
||||
|
||||
private string $wurzel;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->wurzel = dirname(__DIR__);
|
||||
|
||||
$this->seitenrahmenBindetRechnerEin();
|
||||
$this->stilblattZeigtSymbol();
|
||||
$this->skriptRechnetOhneEval();
|
||||
$this->genaueFelderTragenNachkommastellen();
|
||||
}
|
||||
|
||||
private function seitenrahmenBindetRechnerEin(): void
|
||||
{
|
||||
$kopf = $this->lies('src/Views/layout/kopf.php');
|
||||
|
||||
$this->assert(
|
||||
str_contains($kopf, '<script src="/static/rechner.js" defer></script>'),
|
||||
'Der Seitenrahmen bindet rechner.js ein'
|
||||
);
|
||||
$this->assert(
|
||||
strpos($kopf, '/static/betrag.js') !== false
|
||||
&& strpos($kopf, '/static/betrag.js') < strpos($kopf, '/static/rechner.js'),
|
||||
'betrag.js steht vor rechner.js – der Rechner liest den Feldwert damit'
|
||||
);
|
||||
$this->assert(is_file($this->wurzel . '/public/static/rechner.js'), 'rechner.js liegt unter public/static');
|
||||
}
|
||||
|
||||
private function stilblattZeigtSymbol(): void
|
||||
{
|
||||
$css = $this->lies('public/static/app.css');
|
||||
|
||||
$this->assert(
|
||||
(bool)preg_match(
|
||||
'/input\[type="text"\]\[inputmode="decimal"\]:not\(\[data-ohne-rechner\]\)[^{]*\{[^}]*padding-right:[^}]*background-image:\s*url\("data:image\/svg\+xml/',
|
||||
$css
|
||||
),
|
||||
'Zahlenfelder tragen rechts das Rechnersymbol und halten den Platz dafür frei'
|
||||
);
|
||||
$this->assert(
|
||||
(bool)preg_match('/\.rechner-tasten\s*\{[^}]*grid-template-columns:\s*repeat\(4, 1fr\)/', $css),
|
||||
'Das Tastenfeld steht in vier Spalten'
|
||||
);
|
||||
$this->assert(
|
||||
!preg_match('/\.rechner[^{]*\{[^}]*#[0-9a-f]{3,6}\b/i', $css),
|
||||
'Der Rechner nimmt seine Farben aus den Custom Properties des Themas'
|
||||
);
|
||||
}
|
||||
|
||||
private function skriptRechnetOhneEval(): void
|
||||
{
|
||||
$js = $this->lies('public/static/rechner.js');
|
||||
|
||||
$this->assert(!preg_match('/\beval\s*\(|new Function\s*\(/', $js), 'Gerechnet wird ohne eval');
|
||||
$this->assert(str_contains($js, "'input[type=\"text\"][inputmode=\"decimal\"]"), 'Der Rechner hängt an allen Zahlenfeldern');
|
||||
foreach (["'Enter'", "'Escape'", "'C'", "'='"] as $taste) {
|
||||
$this->assert(str_contains($js, $taste), "Taste $taste ist belegt");
|
||||
}
|
||||
}
|
||||
|
||||
private function genaueFelderTragenNachkommastellen(): void
|
||||
{
|
||||
$felder = [
|
||||
'src/Views/wertpapiere/formular.php' => ['stueck' => 6, 'kurs' => 6],
|
||||
'src/Views/buchungen/wertpapierfelder.php' => ['stueck' => 6, 'kurs' => 6],
|
||||
'src/Views/wertpapiere/stammdaten.php' => ['kurs' => 6],
|
||||
'src/Views/vertraege/kredite.php' => ['zinssatz' => 3],
|
||||
];
|
||||
|
||||
foreach ($felder as $datei => $namen) {
|
||||
$html = $this->lies($datei);
|
||||
foreach ($namen as $name => $stellen) {
|
||||
$this->assert(
|
||||
(bool)preg_match(
|
||||
'/name="' . $name . '"[^>]*data-nachkommastellen="' . $stellen . '"/',
|
||||
$html
|
||||
),
|
||||
"$datei: „$name“ übernimmt $stellen Nachkommastellen"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function lies(string $pfad): string
|
||||
{
|
||||
return (string)file_get_contents($this->wurzel . '/' . $pfad);
|
||||
}
|
||||
}
|
||||
146
tests/e2e/specs/rechner.spec.js
Normal file
146
tests/e2e/specs/rechner.spec.js
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test')
|
||||
|
||||
const BENUTZER = process.env.E2E_USER || 'e2e'
|
||||
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
|
||||
|
||||
async function anmelden(page) {
|
||||
await page.goto('/login')
|
||||
await page.fill('#benutzername', BENUTZER)
|
||||
await page.fill('#passwort', PASSWORT)
|
||||
await page.click('button[type="submit"]')
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
}
|
||||
|
||||
/* Klick auf das Rechnersymbol – es sitzt rechts im Feld. */
|
||||
async function symbolKlicken(feld) {
|
||||
const box = await feld.boundingBox()
|
||||
if (!box) throw new Error('Feld nicht sichtbar')
|
||||
await feld.page().mouse.click(box.x + box.width - 12, box.y + box.height / 2)
|
||||
}
|
||||
|
||||
test.describe('Taschenrechner an Betragsfeldern (#126)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await anmelden(page)
|
||||
})
|
||||
|
||||
test('Symbol im Feld öffnet den Rechner, Enter überträgt das Ergebnis', async ({ page }) => {
|
||||
await page.goto('/konten/neu')
|
||||
const feld = page.locator('#anfangssaldo')
|
||||
await feld.fill('')
|
||||
|
||||
const bild = await feld.evaluate((el) => getComputedStyle(el).backgroundImage)
|
||||
expect(bild).toContain('data:image/svg+xml')
|
||||
|
||||
await symbolKlicken(feld)
|
||||
const rechner = page.locator('.rechner')
|
||||
await expect(rechner).toBeVisible()
|
||||
const eingabe = rechner.locator('.rechner-eingabe')
|
||||
const ergebnis = rechner.locator('.rechner-ergebnis')
|
||||
await expect(eingabe).toBeFocused()
|
||||
|
||||
// Am Feld, rechtsbündig – darunter oder, fehlt dort der Platz, darüber.
|
||||
const feldBox = await feld.boundingBox()
|
||||
const rechnerBox = await rechner.boundingBox()
|
||||
if (!feldBox || !rechnerBox) throw new Error('nicht sichtbar')
|
||||
const darunter = rechnerBox.y - (feldBox.y + feldBox.height)
|
||||
const darueber = feldBox.y - (rechnerBox.y + rechnerBox.height)
|
||||
expect((darunter >= 0 && darunter < 20) || (darueber >= 0 && darueber < 20)).toBe(true)
|
||||
expect(Math.abs(rechnerBox.x + rechnerBox.width - (feldBox.x + feldBox.width))).toBeLessThan(2)
|
||||
|
||||
// Ergebnis nach jedem Tastendruck, Punkt vor Strich, Klammern.
|
||||
await page.keyboard.type('12,5*2')
|
||||
await expect(ergebnis).toHaveText('25')
|
||||
await page.keyboard.type('+(3-1)/4')
|
||||
await expect(eingabe).toHaveValue('12,5×2+(3−1)÷4')
|
||||
await expect(ergebnis).toHaveText('25,5')
|
||||
|
||||
// Unvollständig: das letzte Ergebnis bleibt gedämpft stehen.
|
||||
await page.keyboard.type('+')
|
||||
await expect(ergebnis).toHaveClass(/veraltet/)
|
||||
await expect(ergebnis).toHaveText('25,5')
|
||||
|
||||
// Buchstaben gehören nicht in eine Rechnung.
|
||||
await page.keyboard.type('q')
|
||||
await expect(eingabe).toHaveValue('12,5×2+(3−1)÷4+')
|
||||
|
||||
// Backspace links, Entf rechts der Schreibmarke, Pfeile bewegen sie.
|
||||
await page.keyboard.press('Backspace')
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
await page.keyboard.press('Delete')
|
||||
await expect(eingabe).toHaveValue('12,5×2+(3−1)4')
|
||||
await expect(ergebnis).toHaveClass(/fehler/)
|
||||
await page.keyboard.type('*')
|
||||
await expect(eingabe).toHaveValue('12,5×2+(3−1)×4')
|
||||
await expect(ergebnis).toHaveText('33')
|
||||
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(rechner).toBeHidden()
|
||||
await expect(feld).toHaveValue('33,00')
|
||||
await expect(feld).toBeFocused()
|
||||
})
|
||||
|
||||
test('C löscht, Escape schließt ohne Übernahme, Alt+R öffnet mit dem Feldwert', async ({ page }) => {
|
||||
await page.goto('/konten/neu')
|
||||
const feld = page.locator('#anfangssaldo')
|
||||
await feld.fill('1.234,50')
|
||||
|
||||
await feld.focus()
|
||||
await page.keyboard.press('Alt+r')
|
||||
const rechner = page.locator('.rechner')
|
||||
const eingabe = rechner.locator('.rechner-eingabe')
|
||||
await expect(rechner).toBeVisible()
|
||||
await expect(eingabe).toHaveValue('1234,5')
|
||||
|
||||
await page.keyboard.press('c')
|
||||
await expect(eingabe).toHaveValue('')
|
||||
await page.keyboard.type('7/0')
|
||||
await expect(rechner.locator('.rechner-ergebnis')).toHaveText('Division durch 0')
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(rechner).toBeHidden()
|
||||
await expect(feld).toBeFocused()
|
||||
await expect(feld).toHaveValue('1.234,50')
|
||||
})
|
||||
|
||||
test('Tasten lassen sich auch anklicken', async ({ page }) => {
|
||||
await page.goto('/konten/neu')
|
||||
const feld = page.locator('#kreditlimit')
|
||||
await feld.fill('')
|
||||
await symbolKlicken(feld)
|
||||
|
||||
const rechner = page.locator('.rechner')
|
||||
for (const taste of ['9', '÷', '4', '=']) {
|
||||
await rechner.locator(`button[data-taste="${taste}"]`).click()
|
||||
}
|
||||
await expect(rechner.locator('.rechner-eingabe')).toHaveValue('2,25')
|
||||
await expect(rechner.locator('.rechner-eingabe')).toBeFocused()
|
||||
await rechner.getByRole('button', { name: 'Übernehmen' }).click()
|
||||
await expect(feld).toHaveValue('2,25')
|
||||
})
|
||||
|
||||
test('Im modalen Dialog: Escape schließt nur den Rechner', async ({ page }) => {
|
||||
await page.goto('/vorlagen')
|
||||
await page.click('#neu-knopf')
|
||||
const dialog = page.locator('#neudialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const feld = dialog.locator('#betrag')
|
||||
await feld.fill('')
|
||||
await symbolKlicken(feld)
|
||||
const rechner = dialog.locator('.rechner')
|
||||
await expect(rechner).toBeVisible()
|
||||
|
||||
await page.keyboard.type('19,99*3')
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(rechner).toBeHidden()
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await symbolKlicken(feld)
|
||||
await page.keyboard.type('19,99*3')
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(feld).toHaveValue('59,97')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue