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:
@@ -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'));
|
||||
});
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user