Merge pull request 'Fix n.n.: E2E-Läufe sichern Einstellungen, Kippschalter statt Ankreuzfeldern' (#92) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/92
This commit is contained in:
fds2610 2026-09-29 11:38:23 +02:00
commit c05537d1d0
9 changed files with 351 additions and 1 deletions

1
.gitignore vendored
View file

@ -18,3 +18,4 @@ tests/e2e/node_modules/
tests/e2e/playwright-report/
tests/e2e/test-results/
tests/e2e/.auth/
/data/e2e/

View file

@ -1,5 +1,23 @@
# Änderungen
## E2E-Läufe lassen die Einstellungen unverändert (29.09.2026)
- Die Browser-Tests laufen gegen die echte Instanz; der Bargeld-Test hatte dabei die Adresse
des Bargeld-Tools geleert. `tests/e2e/run.sh` sichert jetzt die ganze Tabelle
`einstellungen` vor dem Lauf und stellt sie danach wieder her (auch bei Abbruch mit
Strg+C). Eine nach hartem Abbruch liegengebliebene Sicherung wird vor dem nächsten Lauf
eingespielt.
- Neu: `Services/E2EEinstellungen`, `scripts/e2e-einstellungen.php sichern|wiederherstellen`,
Test 48. Die Sicherung liegt unter `data/e2e/` (nicht im Repository).
## Kippschalter statt Ankreuzfeldern (29.09.2026)
- Alle Ankreuzfelder erscheinen als Kippschalter im blauen Design des Projekts: aus =
graue Fläche mit ✕, an = Akzentfarbe mit Häkchen (helles und dunkles Thema). Reine CSS-
Änderung in `public/static/app.css`; die Elemente bleiben echte Ankreuzfelder.
- Ausgenommen ist die Zeilenauswahl in den Listen (Kontoblatt, Depotbuchungen,
Umsätze inkl. „alle wählen“).
## Regeln: Summe der Anteile in den Splittabellen (29.09.2026)
- Die Spalte „Anteil %“ heißt in den Splittabellen der Regeln nur noch „Anteil“, da die

View file

@ -2314,3 +2314,66 @@ table.liste tr.luecke td {
.protokolleintrag p { margin: .4rem 0; }
.protokolleintrag summary { cursor: pointer; font-size: .88rem; color: var(--text-gedaempft); }
.protokolleintrag .protokolltext { max-height: 22rem; overflow: auto; word-break: break-all; }
/* Kippschalter statt Ankreuzfeld. Gilt für jedes Ankreuzfeld; ausgenommen ist nur die
Zeilenauswahl in den Listen (Kopf-Ankreuzfeld „alle wählen“ und die Auswahl je Buchung
bzw. Umsatz), wo ein Schalter je Zeile die Tabelle aufblähen würde. Das Element bleibt
ein echtes Ankreuzfeld: Tastatur, Formularversand und Skripte ändern sich nicht. */
input[type="checkbox"]:not(.alle-waehlen):not([name="buchungen[]"]):not([name="umsaetze[]"]) {
-webkit-appearance: none;
appearance: none;
position: relative;
flex: none;
width: 2.6rem;
height: 1.4rem;
margin: 0;
border-radius: 999px;
background-color: var(--rand-kraeftig);
background-repeat: no-repeat;
background-position: right .5rem center;
background-size: .6rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpath d='M1.5 1.5l7 7m0-7l-7 7' stroke='%23fff' stroke-width='1.8' stroke-linecap='round' fill='none'/%3E%3C/svg%3E");
box-shadow: inset 0 1px 2px rgba(16, 24, 40, .18);
cursor: pointer;
vertical-align: middle;
transition: background-color .15s ease;
}
input[type="checkbox"]:not(.alle-waehlen):not([name="buchungen[]"]):not([name="umsaetze[]"])::before {
content: "";
position: absolute;
top: .15rem;
left: .15rem;
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background: linear-gradient(#ffffff, #eceff3);
box-shadow: 0 1px 3px rgba(16, 24, 40, .35);
transition: transform .15s ease;
}
input[type="checkbox"]:not(.alle-waehlen):not([name="buchungen[]"]):not([name="umsaetze[]"]):checked {
background-color: var(--akzent);
background-position: left .5rem center;
background-size: .7rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpath d='M1.3 5.4l2.5 2.5 5-5.6' stroke='%23fff' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E");
}
input[type="checkbox"]:not(.alle-waehlen):not([name="buchungen[]"]):not([name="umsaetze[]"]):checked::before {
transform: translateX(1.2rem);
}
input[type="checkbox"]:not(.alle-waehlen):not([name="buchungen[]"]):not([name="umsaetze[]"]):focus-visible {
outline: 2px solid var(--akzent);
outline-offset: 2px;
}
input[type="checkbox"]:not(.alle-waehlen):not([name="buchungen[]"]):not([name="umsaetze[]"]):disabled {
opacity: .5;
cursor: not-allowed;
}
@media (prefers-reduced-motion: reduce) {
input[type="checkbox"],
input[type="checkbox"]::before { transition: none; }
}

View file

@ -0,0 +1,52 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* Sichert die Programmeinstellungen vor einem E2E-Lauf und stellt sie danach
* wieder her.
*
* docker compose exec -T -u www-data php php scripts/e2e-einstellungen.php sichern
* docker compose exec -T -u www-data php php scripts/e2e-einstellungen.php wiederherstellen
*
* `tests/e2e/run.sh` ruft beides selbst auf. `--still` schweigt, solange
* nichts schiefgeht.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use Finanzmanager\Config;
use Finanzmanager\Services\E2EEinstellungen;
Config::load();
$still = in_array('--still', $argv, true);
$aktion = $argv[1] ?? '';
$pfad = E2EEinstellungen::standardPfad();
$sage = static function (string $zeile) use ($still): void {
if (!$still) {
echo $zeile . PHP_EOL;
}
};
switch ($aktion) {
case 'sichern':
$anzahl = E2EEinstellungen::sichern($pfad);
$sage($anzahl === null
? 'Sicherung aus einem früheren Lauf vorhanden – bleibt stehen.'
: sprintf('%d Einstellungen gesichert.', $anzahl));
exit(0);
case 'wiederherstellen':
$anzahl = E2EEinstellungen::wiederherstellen($pfad);
$sage($anzahl === null
? 'Keine Sicherung vorhanden – nichts wiederherzustellen.'
: sprintf('%d Einstellungen wiederhergestellt.', $anzahl));
exit(0);
default:
fwrite(STDERR, "Aufruf: e2e-einstellungen.php sichern|wiederherstellen [--still]\n");
exit(2);
}

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Models\Einstellung;
use RuntimeException;
/**
* Sichert die Programmeinstellungen vor einem E2E-Lauf und stellt sie danach
* wieder her.
*
* Die Browser-Tests laufen gegen die echte Instanz und speichern dabei
* Formulare des Einstellungsdialogs – der Bargeld-Test leerte so die Adresse
* des Bargeld-Tools. Statt jedem Test das Aufräumen einzeln beizubringen,
* gilt: Was in `einstellungen` steht, ist nach dem Lauf wieder so wie davor.
*
* Gesichert wird die ganze Tabelle, nicht eine Liste bekannter Schlüssel –
* ein neuer Test mit einer neuen Einstellung braucht so keine Pflege.
*/
class E2EEinstellungen
{
/** Wohin die Sicherung geschrieben wird. */
public static function standardPfad(): string
{
return dirname(__DIR__, 2) . '/data/e2e/einstellungen.json';
}
/**
* Die Tabelle in eine Datei schreiben.
*
* Eine vorhandene Sicherung bleibt stehen: Sie stammt aus einem
* abgebrochenen Lauf und hält den Stand, der wirklich der echte ist.
*
* @return int|null Anzahl gesicherter Zeilen, null wenn die Sicherung schon da war
*/
public static function sichern(string $pfad): ?int
{
if (is_file($pfad)) {
return null;
}
$zeilen = Database::fetchAll(
'SELECT schluessel, wert, bereich, geaendert_am FROM einstellungen ORDER BY schluessel'
);
$verzeichnis = dirname($pfad);
if (!is_dir($verzeichnis) && !mkdir($verzeichnis, 0775, true) && !is_dir($verzeichnis)) {
throw new RuntimeException('Verzeichnis nicht anlegbar: ' . $verzeichnis);
}
$json = json_encode($zeilen, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
if (file_put_contents($pfad, $json, LOCK_EX) === false) {
throw new RuntimeException('Sicherung nicht schreibbar: ' . $pfad);
}
chmod($pfad, 0600);
return count($zeilen);
}
/**
* Die Tabelle auf den gesicherten Stand zurücksetzen und die Sicherung
* löschen. Zeilen, die der Lauf angelegt hat, verschwinden wieder.
*
* @return int|null Anzahl wiederhergestellter Zeilen, null wenn es keine Sicherung gibt
*/
public static function wiederherstellen(string $pfad): ?int
{
if (!is_file($pfad)) {
return null;
}
$zeilen = json_decode((string)file_get_contents($pfad), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($zeilen)) {
throw new RuntimeException('Sicherung unlesbar: ' . $pfad);
}
Database::transaktion(static function () use ($zeilen): void {
Database::execute('DELETE FROM einstellungen');
foreach ($zeilen as $z) {
Database::execute(
'INSERT INTO einstellungen (schluessel, wert, bereich, geaendert_am) VALUES (?, ?, ?, ?)',
[$z['schluessel'], $z['wert'], $z['bereich'], $z['geaendert_am']]
);
}
});
Einstellung::cacheLeeren();
unlink($pfad);
return count($zeilen);
}
}

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use Finanzmanager\Database;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Services\E2EEinstellungen;
/**
* Die Einstellungen überleben einen E2E-Lauf: Was der Lauf ändert, anlegt oder
* löscht, ist nach dem Wiederherstellen wieder weg. Anlass: Der Bargeld-Test
* leerte die Adresse des Bargeld-Tools in der echten Instanz.
*/
class E2EEinstellungenTest extends TestCase
{
protected string $testName = 'Test 48: E2E-Einstellungen sichern und wiederherstellen';
public function run(): void
{
$pfad = $this->tempPfad('.json');
Einstellung::set('e2e_probe_url', 'https://kasse.example.net', 'bargeld');
Einstellung::set('e2e_probe_tage', '10', 'bargeld');
$vorher = Database::fetchAll('SELECT * FROM einstellungen ORDER BY schluessel');
$anzahl = E2EEinstellungen::sichern($pfad);
$this->assertEquals(count($vorher), $anzahl, 'Alle Zeilen werden gesichert');
// Der Lauf ändert, leert, löscht und legt an.
Einstellung::set('e2e_probe_url', '', 'bargeld');
Einstellung::set('e2e_probe_tage', '99', 'bargeld');
Einstellung::set('e2e_probe_neu', 'x', 'allgemein');
Database::execute("DELETE FROM einstellungen WHERE bereich = 'darstellung'");
$this->assertNull(E2EEinstellungen::sichern($pfad), 'Eine vorhandene Sicherung wird nicht überschrieben');
$wieder = E2EEinstellungen::wiederherstellen($pfad);
$this->assertEquals(count($vorher), $wieder, 'Alle Zeilen kommen zurück');
Einstellung::cacheLeeren();
$this->assertEquals('https://kasse.example.net', Einstellung::get('e2e_probe_url'), 'Die geleerte Adresse ist wieder da');
$this->assertEquals('10', Einstellung::get('e2e_probe_tage'), 'Der geänderte Wert ist zurückgesetzt');
$this->assertNull(Einstellung::get('e2e_probe_neu'), 'Vom Lauf angelegte Einstellungen verschwinden');
$this->assertEquals(
$vorher,
Database::fetchAll('SELECT * FROM einstellungen ORDER BY schluessel'),
'Die Tabelle ist Zeile für Zeile wie vorher, einschließlich Bereich und Änderungszeit'
);
$this->assert(!is_file($pfad), 'Die Sicherung ist danach gelöscht');
$this->assertNull(E2EEinstellungen::wiederherstellen($pfad), 'Ohne Sicherung geschieht nichts');
}
}

View file

@ -51,6 +51,12 @@ Hierher gehört nur, was einen Browser braucht: Formularabläufe, Redirects,
gerenderte Views, JavaScript. Rechen- und Model-Logik gehört in die PHP-Suite
(`tests/NN_*.php`) – schneller und isoliert.
## Einstellungen
`run.sh` sichert die Tabelle `einstellungen` vor dem Lauf und stellt sie danach wieder
her (`scripts/e2e-einstellungen.php`). Specs dürfen Einstellungen also frei ändern, müssen
sie aber nicht selbst zurücksetzen.
## Aufräumen
Jeder Lauf legt Konten, Kategorien, Klassen und Buchungen neu an – mit einer

View file

@ -42,9 +42,23 @@ if [ "${E2E_KEIN_AUFRAEUMEN:-0}" != "1" ]; then
fi
fi
# Die Tests laufen gegen die echte Instanz und speichern dabei Formulare des
# Einstellungsdialogs. Deshalb: Einstellungen sichern, nach dem Lauf – auch nach
# einem Abbruch – wiederherstellen. Bleibt nach einem harten Abbruch (kill -9) eine
# Sicherung liegen, spielt der nächste Lauf sie zuerst ein; sie hält den echten Stand.
PROJEKT=$(CDPATH= cd -- "$TEST_DIR/../.." && pwd)
einstellungen() { (cd "$PROJEKT" && docker compose exec -T -u www-data php php scripts/e2e-einstellungen.php "$1" --still); }
einstellungen wiederherstellen \
|| echo "WARNUNG: Alte Einstellungssicherung nicht einspielbar." >&2
einstellungen sichern \
|| echo "WARNUNG: Einstellungen nicht gesichert — der Lauf kann sie verändern." >&2
trap 'einstellungen wiederherstellen || echo "WARNUNG: Einstellungen nicht wiederhergestellt (scripts/e2e-einstellungen.php wiederherstellen)." >&2' EXIT
trap 'exit 130' INT TERM
# Zugangsdaten des Testnutzers durchreichen; der Runner kennt nur BASE_URL.
cd "$RUNNER_DIR"
TEST_DIR="$TEST_DIR" BASE_URL="$BASE_URL" exec docker compose run --rm \
TEST_DIR="$TEST_DIR" BASE_URL="$BASE_URL" docker compose run --rm \
${E2E_USER:+-e E2E_USER="$E2E_USER"} \
${E2E_PASSWORD:+-e E2E_PASSWORD="$E2E_PASSWORD"} \
playwright

View file

@ -0,0 +1,49 @@
// @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(/\/$/)
}
test.describe('Kippschalter', () => {
test.beforeEach(async ({ page }) => {
await anmelden(page)
})
test('Ankreuzfelder erscheinen als Schalter und lassen sich umschalten', async ({ page }) => {
await page.goto('/konten/neu')
const schalter = page.locator('input[name="in_summe"]')
const aussehen = () => schalter.evaluate((el) => {
const stil = getComputedStyle(el)
return { breite: parseFloat(stil.width), hintergrund: stil.backgroundColor, appearance: stil.appearance }
})
const vorher = await aussehen()
expect(vorher.appearance).toBe('none')
expect(vorher.breite).toBeGreaterThan(30)
const war = await schalter.isChecked()
await schalter.click()
expect(await schalter.isChecked()).toBe(!war)
// Die Farbe läuft über eine kurze Übergangsanimation.
await expect.poll(async () => (await aussehen()).hintergrund).not.toBe(vorher.hintergrund)
})
test('Zeilenauswahl in den Listen bleibt ein gewöhnliches Ankreuzfeld', async ({ page }) => {
await page.goto('/banking/umsaetze')
const auswahl = page.locator('input.alle-waehlen').first()
if (await auswahl.count() === 0) {
test.skip(true, 'keine Umsätze vorhanden')
}
const appearance = await auswahl.evaluate((el) => getComputedStyle(el).appearance)
expect(appearance).not.toBe('none')
})
})