Fix n.n.: E2E-Läufe sichern Einstellungen, Kippschalter statt Ankreuzfeldern
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
054d0d29cb
commit
5695e2e3df
9 changed files with 351 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,3 +18,4 @@ tests/e2e/node_modules/
|
|||
tests/e2e/playwright-report/
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/.auth/
|
||||
/data/e2e/
|
||||
|
|
|
|||
18
Changes.md
18
Changes.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
52
scripts/e2e-einstellungen.php
Normal file
52
scripts/e2e-einstellungen.php
Normal 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);
|
||||
}
|
||||
95
src/Services/E2EEinstellungen.php
Normal file
95
src/Services/E2EEinstellungen.php
Normal 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);
|
||||
}
|
||||
}
|
||||
52
tests/48_E2EEinstellungenTest.php
Normal file
52
tests/48_E2EEinstellungenTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
49
tests/e2e/specs/kippschalter.spec.js
Normal file
49
tests/e2e/specs/kippschalter.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue