feat: Warnfenster und Planungs-Hinweis bei möglicher Doppelzählung (A12-T4)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 07:35:16 +02:00
co-authored by Claude Opus 5.5
parent 9e856ec655
commit e665180b1f
8 changed files with 264 additions and 2 deletions
@@ -0,0 +1,75 @@
'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/doppelzaehlung.js'), 'utf8');
const HTML = `
<form id="f" hx-post="/api/recurring"><input name="name" value="Rate Auto"></form>
<dialog id="doppelzaehlung-dialog">
<p class="dz-meldung"></p><ul class="dz-konflikte"></ul>
<button type="button" id="dz-abbrechen">Abbrechen</button>
<button type="button" id="dz-bestaetigen">Trotzdem speichern</button>
</dialog>`;
const BODY = JSON.stringify({ detail: { code: 'doppelzaehlung_kredit', meldung: 'Erklärung',
konflikte: [{ posten: 'Rate Auto', posten_betrag: '-100.00', kredit: 'Auto', kredit_rate: '100.00' }] } });
function load() {
const dom = new JSDOM(`<!doctype html><body>${HTML}</body>`, { runScripts: 'outside-only' });
const w = dom.window;
const calls = [];
w.htmx = { trigger: (el, name) => calls.push([el.id, name]) };
const dlg = w.document.getElementById('doppelzaehlung-dialog');
dlg.showModal = function () { this.setAttribute('open', ''); };
dlg.close = function () { this.removeAttribute('open'); };
w.eval(SRC);
w.FBDoppel.init(w.document);
const form = w.document.getElementById('f');
const evt = { detail: { xhr: { status: 409, responseText: BODY }, elt: form } };
return { w, dlg, form, evt, calls };
}
test('409 öffnet Dialog mit Erklärung und Konfliktliste', { skip: !JSDOM }, () => {
const { w, dlg, evt } = load();
assert.equal(w.FBDoppel.handle(evt), true);
assert.ok(dlg.hasAttribute('open'));
assert.equal(dlg.querySelector('.dz-meldung').textContent, 'Erklärung');
assert.equal(dlg.querySelectorAll('.dz-konflikte li').length, 1);
});
test('andere Fehler werden nicht abgefangen', { skip: !JSDOM }, () => {
const { w, form } = load();
assert.equal(w.FBDoppel.handle({ detail: { xhr: { status: 422, responseText: '{}' }, elt: form } }), false);
});
test('Abbrechen speichert nicht', { skip: !JSDOM }, () => {
const { w, dlg, form, evt, calls } = load();
w.FBDoppel.handle(evt);
w.document.getElementById('dz-abbrechen').click();
assert.ok(!dlg.hasAttribute('open'));
assert.equal(calls.length, 0);
assert.ok(!form.hasAttribute('data-doppelzaehlung-bestaetigt'));
assert.equal(form.querySelector('input').value, 'Rate Auto');
});
test('Trotzdem speichern sendet erneut mit Flag', { skip: !JSDOM }, () => {
const { w, dlg, form, evt, calls } = load();
w.FBDoppel.handle(evt);
w.document.getElementById('dz-bestaetigen').click();
assert.ok(!dlg.hasAttribute('open'));
assert.equal(form.getAttribute('data-doppelzaehlung-bestaetigt'), '1');
assert.deepEqual(calls, [['f', 'submit']]);
});
test('Flag nach Request entfernt', { skip: !JSDOM }, () => {
const { w, form, evt } = load();
w.FBDoppel.handle(evt);
w.document.getElementById('dz-bestaetigen').click();
const e = new w.CustomEvent('htmx:afterRequest', { bubbles: true, detail: { elt: form } });
form.dispatchEvent(e);
assert.ok(!form.hasAttribute('data-doppelzaehlung-bestaetigt'));
});
+20
View File
@@ -0,0 +1,20 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const D = require('../../app/static/doppelzaehlung.js');
const body = { detail: { code: 'doppelzaehlung_kredit', meldung: 'M',
konflikte: [{ posten: 'Rate Auto', posten_betrag: '-1234.50', kredit: 'Auto', kredit_rate: '1234.50' }] } };
test('parseDetail erkennt nur 409 mit Code', () => {
assert.equal(D.parseDetail({ status: 409, responseText: JSON.stringify(body) }).code, 'doppelzaehlung_kredit');
assert.equal(D.parseDetail({ status: 409, responseText: '{"detail":"Kategorie existiert bereits"}' }), null);
assert.equal(D.parseDetail({ status: 422, responseText: JSON.stringify(body) }), null);
assert.equal(D.parseDetail({ status: 409, responseText: 'kein json' }), null);
assert.equal(D.parseDetail(null), null);
});
test('konfliktText deutsch formatiert', () => {
assert.equal(D.konfliktText(body.detail.konflikte[0]),
'Posten „Rate Auto“ (-1.234,50 €) ↔ Kredit „Auto“ (Rate 1.234,50 €)');
});
+43
View File
@@ -588,3 +588,46 @@ def test_planungs_refresh_fehler_bricht_seiten_nicht(client, monkeypatch):
client.post("/login", data={"username": "admin", "password": "geheim"})
for path in ("/", "/planung"):
assert client.get(path).status_code == 200, path
def test_base_hat_doppelzaehlungs_dialog(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
html = client.get("/").text
assert '<script src="/static/doppelzaehlung.js"></script>' in html
assert '<dialog id="doppelzaehlung-dialog">' in html
assert 'id="dz-abbrechen" autofocus' in html
assert ">Trotzdem speichern</button>" in html
assert "FBDoppel.handle(e)" in html
assert "doppelzaehlung_bestaetigt = true" in html
def test_planung_zeigt_doppelzaehlungs_hinweis(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add_all([
Loan(name="Auto", principal=Decimal("1200.00"), annual_rate_pct=Decimal("0"),
term_months=12, payout_date=date(2026, 1, 15), repayment_type="annuity"),
RecurringItem(name="Rate Auto", amount=Decimal("-100.00"), rhythm="monthly", due_day=15),
RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1),
])
db.commit()
html = client.get("/planung").text
assert "⚠ zählt evtl. doppelt mit Kredit „Auto“" in html
assert "⚠ zählt evtl. doppelt mit Posten „Rate Auto“" in html
assert html.count("⚠ zählt evtl. doppelt") == 2
assert '<td data-sort="Rate Auto">' in html
assert '<td data-sort="Auto">' in html
def test_planung_ohne_konflikt_kein_hinweis(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1))
db.commit()
assert "zählt evtl. doppelt" not in client.get("/planung").text
def test_vorschlag_formular_nutzt_json_form_post(client):
# Review Focus 5: „Vorschlag übernehmen" läuft über denselben Warnweg.
import re
from pathlib import Path
src = Path("app/templates/planning.html").read_text(encoding="utf-8")
assert re.search(r'class="inline-form" hx-ext="json-form" hx-post="/api/recurring"', src)