feat: Szenarien-Seite mit zwei Formularen, Zeitraum-Spalte und wiederkehrenden Szenario-Zahlungen (A14-T4)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 13:08:16 +02:00
co-authored by Claude Opus 5.5
parent 8911623c15
commit cd7ba6aa6a
8 changed files with 373 additions and 106 deletions
+116
View File
@@ -0,0 +1,116 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
let JSDOM = null;
try { ({ JSDOM } = require('jsdom')); } catch (e) { /* jsdom fehlt -> skip */ }
const SRC = fs.readFileSync(path.join(__dirname, '../../app/static/szenarien.js'), 'utf8');
const TIP = 'Für diese Eintragsart nicht relevant';
const HTML = `
<form id="a" class="entry-form aenderung-form" hx-post="/api/scenarios/1/modifiers">
<select name="kind"><option value="percent">Prozent</option><option value="absolute">Kürzung (€)</option>
<option value="set">Neuer Betrag</option><option value="remove">Entfällt</option><option value="ende">Ende</option></select>
<select name="target_type"><option value="category">Kategorie</option><option value="recurring">Wiederkehrender Posten</option></select>
<span class="mod-target-category"><select name="target_id"><option value="1">K</option></select></span>
<span class="mod-target-recurring" style="display:none"><select name="target_id" disabled><option value="2">P</option></select></span>
<label><span class="value-label">Prozentsatz (%)</span><input name="value"></label>
<input type="date" name="start_date">
<label><span class="bis-label">Gültig bis</span><input type="date" name="end_date"></label>
</form>
<form id="z" class="entry-form zahlung-form" hx-post="/api/scenarios/1/planned"
data-planned-url="/api/scenarios/1/planned" data-recurring-url="/api/scenarios/1/recurring">
<select class="zahlung-art"><option value="einmal">Einmalzahlung</option><option value="wiederkehrend">Wiederkehrend</option></select>
<input name="name"><input name="amount"><select name="category_id"><option value="">(keine)</option></select>
<input type="date" name="due">
<select name="rhythm"><option value="monthly">monatlich</option></select>
<input name="due_day"><input type="date" name="start_date"><input type="date" name="end_date">
</form>`;
function load() {
const dom = new JSDOM(`<!doctype html><body>${HTML}</body>`, { runScripts: 'outside-only' });
const w = dom.window;
const processed = [];
w.htmx = { process: (el) => processed.push(el.id) };
w.eval(SRC);
w.FBSzenario.init(w.document);
const d = w.document;
return { w, a: d.getElementById('a'), z: d.getElementById('z'), processed };
}
function f(form, name) { return form.querySelector(`[name="${name}"]`); }
function art(w, form, wert) {
const sel = form.querySelector('[name="kind"]');
sel.value = wert;
w.FBSzenario.aenderungArt(sel);
}
test('Prozent: Wert und Zeitraum aktiv', { skip: !JSDOM }, () => {
const { a } = load();
assert.equal(f(a, 'value').disabled, false);
assert.equal(a.querySelector('.value-label').textContent, 'Prozentsatz (%)');
assert.equal(f(a, 'start_date').disabled, false);
assert.equal(f(a, 'end_date').disabled, false);
assert.equal(a.querySelector('.bis-label').textContent, 'Gültig bis');
});
test('Kürzung: Label Kürzung (€)', { skip: !JSDOM }, () => {
const { w, a } = load();
art(w, a, 'absolute');
assert.equal(a.querySelector('.value-label').textContent, 'Kürzung (€)');
});
test('Neuer Betrag: Ziel-Typ fest auf Posten, Kategorie gesperrt', { skip: !JSDOM }, () => {
const { w, a } = load();
art(w, a, 'set');
const typ = f(a, 'target_type');
assert.equal(typ.value, 'recurring');
assert.equal(typ.querySelector('option[value="category"]').disabled, true);
assert.equal(a.querySelector('.mod-target-recurring select').disabled, false);
assert.equal(a.querySelector('.mod-target-category select').disabled, true);
assert.equal(a.querySelector('.value-label').textContent, 'Neuer Betrag (€)');
art(w, a, 'percent');
assert.equal(typ.querySelector('option[value="category"]').disabled, false);
});
test('Ende: Gültig ab und Wert gesperrt, Label Endet am', { skip: !JSDOM }, () => {
const { w, a } = load();
art(w, a, 'ende');
assert.equal(f(a, 'start_date').disabled, true);
assert.equal(f(a, 'start_date').title, TIP);
assert.equal(f(a, 'value').disabled, true);
assert.equal(f(a, 'end_date').disabled, false);
assert.equal(a.querySelector('.bis-label').textContent, 'Endet am');
});
test('Entfällt: Wert gesperrt, Zeitraum aktiv', { skip: !JSDOM }, () => {
const { w, a } = load();
art(w, a, 'remove');
assert.equal(f(a, 'value').disabled, true);
assert.equal(a.querySelector('.value-label').textContent, 'Wert');
assert.equal(f(a, 'start_date').disabled, false);
assert.equal(a.querySelector('.bis-label').textContent, 'Gültig bis');
});
test('Zahlung: Einmal → Wiederkehrend → Einmal', { skip: !JSDOM }, () => {
const { w, z, processed } = load();
const sel = z.querySelector('.zahlung-art');
const wiederkehrend = ['rhythm', 'due_day', 'start_date', 'end_date'];
assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/planned');
assert.equal(f(z, 'due').disabled, false);
wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, true, n));
sel.value = 'wiederkehrend';
w.FBSzenario.zahlungArt(sel);
assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/recurring');
assert.equal(f(z, 'due').disabled, true);
assert.equal(f(z, 'due').title, TIP);
wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, false, n));
sel.value = 'einmal';
w.FBSzenario.zahlungArt(sel);
assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/planned');
wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, true, n));
['name', 'amount', 'category_id'].forEach((n) => assert.equal(f(z, n).disabled, false, n));
assert.ok(processed.includes('z'));
});
+3 -2
View File
@@ -35,9 +35,10 @@ def test_de_label_bekannte_und_unbekannte_werte():
assert de_label("annuity") == "Annuität"
assert de_label("bullet") == "endfällig"
assert de_label("percent") == "Prozent"
assert de_label("absolute") == "Absolut"
assert de_label("absolute") == "Kürzung (€)"
assert de_label("set") == "Neuer Betrag"
assert de_label("remove") == "Entfällt"
assert de_label("ende") == "Ende"
assert de_label("weirdvalue") == "weirdvalue"
assert set(DE_LABELS) == {"monthly", "quarterly", "yearly", "annuity",
"bullet", "percent", "absolute", "remove", "ende"}
"bullet", "percent", "absolute", "set", "remove", "ende"}
+57 -5
View File
@@ -4,8 +4,9 @@ from decimal import Decimal
from app.config import get_settings
from app.formats import eur
from app.models.tables import (Account, Loan, PlannedItem, RecurringItem,
Scenario, ScenarioModifier, ScenarioPlannedItem)
from app.models.tables import (Account, Category, Loan, PlannedItem, RecurringItem,
Scenario, ScenarioModifier, ScenarioPlannedItem,
ScenarioRecurringItem)
H = {"Authorization": "Bearer test-key"}
@@ -432,7 +433,7 @@ def test_szenario_eintraege_tabelle(client, db):
# Anlege-Formular: Eintragsart inkl. Einmalzahlung + Endpunkt-Wechsel-Attribute
assert 'value="einmal"' in r
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
assert "onEntryArtChange" in r and "onModKindChange" not in r
assert "FBSzenario.aenderungArt" in r and "onEntryArtChange" not in r
def test_szenarien_seite_und_nav(client, db):
@@ -459,7 +460,7 @@ def test_neuer_eintrag_formular_struktur(client, db):
db.add(Scenario(name="UX-Test", description=""))
db.commit()
r = client.get("/szenarien").text
assert 'class="entry-form"' in r
assert 'class="entry-form aenderung-form"' in r and 'class="entry-form zahlung-form"' in r
assert 'class="entry-art"' in r # Eintragsart-Zeile
assert 'class="value-label"' in r # dynamisches Wert-Label
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
@@ -650,6 +651,57 @@ def test_statische_dateien_mit_versionskennung(client):
assert re.search(r'/static/[^"?]+"', login) is None
client.post("/login", data={"username": "admin", "password": "geheim"})
html = client.get("/").text
for datei in ("style.css", "htmx.min.js", "sort.js", "doppelzaehlung.js"):
for datei in ("style.css", "htmx.min.js", "sort.js", "doppelzaehlung.js", "szenarien.js"):
assert f'/static/{datei}?v={v}"' in html
assert re.search(r'/static/[^"?]+"', html) is None
def test_szenario_zwei_formulare(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="Formulare-A14", description="")
db.add(sc)
db.commit()
r = client.get("/szenarien").text
assert "Neue Änderung an Bestehendem" in r and "Neue Zahlung nur in diesem Szenario" in r
assert 'class="entry-form aenderung-form"' in r and 'class="entry-form zahlung-form"' in r
assert f'hx-post="/api/scenarios/{sc.id}/modifiers"' in r
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
assert f'data-recurring-url="/api/scenarios/{sc.id}/recurring"' in r
assert 'value="set">Neuer Betrag<' in r and 'value="absolute">Kürzung (€)<' in r
for feld in ('name="start_date"', 'name="end_date"', 'name="rhythm"',
'name="due_day"', 'name="category_id"', 'name="due"'):
assert feld in r, feld
assert 'class="zahlung-art"' in r and '<option value="">(keine)</option>' in r
assert 'class="bis-label"' in r
def test_szenario_eintraege_zeitraum_und_wiederkehrend(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
cat = Category(name="Kat-Anzeige")
sc = Scenario(name="Anzeige-A14", description="")
db.add_all([cat, sc])
db.flush()
db.add_all([
ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1,
kind="percent", value=Decimal("50"), start_date=date(2026, 10, 16)),
ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1,
kind="set", value=Decimal("-80.00"),
start_date=date(2026, 11, 1), end_date=date(2027, 3, 31)),
ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=cat.id,
kind="remove", value=Decimal("0"), end_date=date(2027, 6, 30)),
ScenarioRecurringItem(scenario_id=sc.id, name="Zahlung-W", amount=Decimal("-137.50"),
rhythm="monthly", due_day=14, start_date=date(2026, 10, 16),
category_id=cat.id),
ScenarioPlannedItem(scenario_id=sc.id, name="Zahlung-E", amount=Decimal("-5.00"),
due=date(2026, 12, 1), category_id=cat.id),
])
db.commit()
r = client.get("/szenarien").text
assert "<th>Zeitraum</th>" in r
assert "ab 16.10.2026" in r and "01.11.2026 – 31.03.2027" in r and "bis 30.06.2027" in r
assert ">Neuer Betrag<" in r and "-80,00" in r
assert "Zahlung-W" in r and "Wiederkehrend (monatlich, am 14.)" in r and "-137,50" in r
assert f'hx-patch="/api/scenarios/{sc.id}/recurring/' in r
assert f'hx-delete="/api/scenarios/{sc.id}/recurring/' in r
assert "Kat-Anzeige" in r and "Zahlung-E" in r
assert 'id="sri-row-' in r and 'id="sri-edit-' in r