From 55fe1c567faa1bc6eb1a2ac44af861ca08aa65ed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:32:31 +0000 Subject: [PATCH] Shop Review A I3: Artikel-Editor ueberschreibt den Bestand nicht mehr (bestandGeladen, Bestand nur bei echter Aenderung schreiben) Der Editor sendet neben bestand den beim Laden gesehenen Wert (bestandGeladen, je Variantenwert mit id). speichereProdukt behaelt unter der Artikelsperre den aktuell gespeicherten Stand, wenn gesendet gleich geladen ist, und schreibt nur vom Haendler geaenderte Bestaende. bestandGeladen wird nie gespeichert; ohne den Wert (alter Browser) gilt der gesendete Bestand wie bisher. Co-Authored-By: Claude Opus 5.5 --- public/shop-tab-artikel.js | 26 ++++++++++-- server/routes/shop.js | 4 +- server/services/shop/produkte.js | 61 ++++++++++++++++++++++++++-- test/shopProdukte.test.js | 70 ++++++++++++++++++++++++++++++++ test/shopRoutes.test.js | 23 +++++++++++ test/shopTabArtikel.test.js | 41 ++++++++++++++++++- 6 files changed, 215 insertions(+), 10 deletions(-) diff --git a/public/shop-tab-artikel.js b/public/shop-tab-artikel.js index 792ad5a..3c468bb 100644 --- a/public/shop-tab-artikel.js +++ b/public/shop-tab-artikel.js @@ -103,6 +103,12 @@ function bestandAnzeige(p) { return Number.isInteger(p?.bestand) ? stueck(p.bestand) : 'unbegrenzt' } +/** + * Der Bestand, den der Haendler beim Laden gesehen hat (bestandGeladen am Artikel und je Variantenwert mit id). Der Server schreibt + * einen Bestand nur, wenn er sich davon unterscheidet; sonst bleibt der aktuelle Stand (zwischenzeitliche Verkaeufe gehen nicht verloren). + */ +const bestandOderNull = (wert) => (Number.isInteger(wert) ? wert : null) + /** Der Artikel in der Form, die der Server beim Speichern erwartet (aus der Listenansicht). */ function produktKoerper(p, ueberschreibe = {}) { return { @@ -114,8 +120,11 @@ function produktKoerper(p, ueberschreibe = {}) { steuersatz: p.steuersatz, grundpreis: p.grundpreis ?? null, bestand: p.bestand ?? null, + bestandGeladen: bestandOderNull(p.bestand), aktiv: p.aktiv, - varianten: p.varianten ?? null, + varianten: istObjekt(p.varianten) && Array.isArray(p.varianten.werte) + ? { ...p.varianten, werte: p.varianten.werte.map((w) => (typeof w?.id === 'string' ? { ...w, bestandGeladen: bestandOderNull(w.bestand) } : w)) } + : (p.varianten ?? null), bilder: Array.isArray(p.bilder) ? p.bilder : [], lieferart: p.lieferart, widerruf: p.widerruf, @@ -298,7 +307,7 @@ export async function render(panel, ctx) { const variantenAn = h('input', { type: 'checkbox', checked: hatVarianten }) const variantenName = kurzesFeld(hatVarianten ? text(p.varianten.name) : '') const werte = hatVarianten - ? p.varianten.werte.map((w) => ({ id: w.id, name: text(w.name), preis: centZuEuro(w.preisCent), bestand: Number.isInteger(w.bestand) ? String(w.bestand) : '' })) + ? p.varianten.werte.map((w) => ({ id: w.id, name: text(w.name), preis: centZuEuro(w.preisCent), bestand: Number.isInteger(w.bestand) ? String(w.bestand) : '', geladen: bestandOderNull(w.bestand) })) : [] const werteBox = h('div', { class: 'sh-werte' }) const wertNeuKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'wert-neu', onclick: () => { werte.push({ id: undefined, name: '', preis: '', bestand: '' }); zeichneWerte() } }, 'Wert hinzufügen') @@ -450,9 +459,20 @@ export async function render(panel, ctx) { steuersatz: stand.kleinunternehmer ? 0 : Number(steuer.value), grundpreis: gpAn ? { menge: /^\d+([.,]\d+)?$/.test(menge) ? Number(menge.replace(',', '.')) : menge, einheit: gpEinheit.value } : null, bestand: mitVarianten ? null : ganzeZahl(bestand.value), + // Beim Bearbeiten: der beim Laden gesehene Bestand (der Server behaelt den aktuellen Stand, wenn er nicht geaendert wurde) + ...(id ? { bestandGeladen: bestandOderNull(p.bestand) } : {}), aktiv: aktivHaken.checked, varianten: mitVarianten - ? { name: variantenName.value, werte: werte.map((w) => ({ id: w.id, name: w.name, preisCent: euroZuCent(String(w.preis)), bestand: ganzeZahl(w.bestand) })) } + ? { + name: variantenName.value, + werte: werte.map((w) => ({ + id: w.id, + name: w.name, + preisCent: euroZuCent(String(w.preis)), + bestand: ganzeZahl(w.bestand), + ...(id && typeof w.id === 'string' ? { bestandGeladen: w.geladen } : {}), + })), + } : null, bilder: bilder.map((b) => b.id), lieferart: lieferart.value, diff --git a/server/routes/shop.js b/server/routes/shop.js index d2c46c5..3460a0f 100644 --- a/server/routes/shop.js +++ b/server/routes/shop.js @@ -15,7 +15,7 @@ import { uploadSlot } from './chat.js' import { istVollstaendig, speichereEinstellungen } from '../services/shop/einstellungen.js' import { bereitschaft, istGeoeffnet, ladeShop, leereCache } from '../services/shop/kontext.js' import { - bildIdsInGebrauch, holeProdukt, listeProdukte, loescheProdukt, normalisiereProdukt, parseProdukt, sortiere, speichereProdukt, + bildIdsInGebrauch, holeProdukt, leseBestandGeladen, listeProdukte, loescheProdukt, normalisiereProdukt, parseProdukt, sortiere, speichereProdukt, } from '../services/shop/produkte.js' import { BESTAETIGUNG_TEXT, GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, musterAgb, musterWiderrufsbelehrung, musterWiderrufsformular, @@ -333,7 +333,7 @@ async function speichereArtikel(req, res, id) { const meta = await bildMeta(shop.siteKey, bildId) if (!meta || meta.shopId !== shop.id) throw eingabeFehler({ bilder: 'Eines der Bilder gibt es nicht (mehr). Bitte lade es neu hoch.' }) } - const gespeichert = await speichereProdukt({ shopId: shop.id, customerId }, id, produkt) + const gespeichert = await speichereProdukt({ shopId: shop.id, customerId }, id, produkt, { geladen: leseBestandGeladen(req.body?.produkt) }) await beanspruche(shop.siteKey, neueBilder) await raeumeBilderAuf(shop, vorher.filter((b) => !neueBilder.includes(b))) return gespeichert diff --git a/server/services/shop/produkte.js b/server/services/shop/produkte.js index 9ab166e..d6497c3 100644 --- a/server/services/shop/produkte.js +++ b/server/services/shop/produkte.js @@ -424,19 +424,74 @@ export async function holeProdukt(shopId, id) { return doc && doc.shopId === shopId ? doc : null } +const bestandRoh = (wert) => { + if (wert === null || wert === undefined || (typeof wert === 'string' && wert.trim() === '')) return { ok: true, wert: null } + const n = ganzeZahl(wert) + return n !== null && n >= 0 ? { ok: true, wert: n } : { ok: false } +} + +/** + * Liest aus dem rohen Artikel des Browsers, welchen Bestand er beim Laden gesehen hat (bestandGeladen am Artikel, je Variantenwert + * mit id am Wert). hat: false, wenn der Browser nichts (Brauchbares) gesendet hat. Das Ergebnis geht an speichereProdukt. + * @returns {{ hat: boolean, bestand?: number|null, werte: Map }} + */ +export function leseBestandGeladen(roh) { + const werte = new Map() + const liste = hole(hole(roh, 'varianten'), 'werte') + if (Array.isArray(liste)) { + for (const w of liste) { + const id = hole(w, 'id') + if (typeof id !== 'string' || !WERT_ID_RE.test(id) || !Object.hasOwn(w, 'bestandGeladen')) continue + const b = bestandRoh(w.bestandGeladen) + if (b.ok) werte.set(id, b.wert) + } + } + if (!istObjekt(roh) || !Object.hasOwn(roh, 'bestandGeladen')) return { hat: false, werte } + const b = bestandRoh(roh.bestandGeladen) + return b.ok ? { hat: true, bestand: b.wert, werte } : { hat: false, werte } +} + +/** Bestand uebernehmen: hat der Haendler den Wert nicht angefasst (gesendet == geladen), bleibt der aktuell gespeicherte Stand. */ +function uebernehmeBestand(neu, alt, geladen) { + if (!istObjekt(alt) || !istObjekt(geladen)) return neu + const kopie = { ...neu } + if (geladen.hat && !neu.varianten && !alt.varianten && (neu.bestand ?? null) === geladen.bestand) kopie.bestand = alt.bestand ?? null + if (neu.varianten && Array.isArray(neu.varianten.werte) && alt.varianten && Array.isArray(alt.varianten.werte) && geladen.werte instanceof Map) { + const aktuell = new Map(alt.varianten.werte.map((w) => [w.id, w])) + kopie.varianten = { + ...neu.varianten, + werte: neu.varianten.werte.map((w) => { + const vorher = aktuell.get(w.id) + return vorher && geladen.werte.has(w.id) && (w.bestand ?? null) === geladen.werte.get(w.id) ? { ...w, bestand: vorher.bestand ?? null } : w + }), + } + } + return kopie +} + /** * Legt einen Artikel an (id = null, ans Ende sortiert, hoechstens config.shop.maxArtikel) oder aktualisiert ihn (Sortierung bleibt). * produkt ist das Ergebnis von normalisiereProdukt. Fehler tragen .status (400/404/409) und einen deutschen Text. + * opt.geladen (leseBestandGeladen): Beim Aendern behalten Bestaende, die der Haendler nicht angefasst hat (gesendet == geladen), + * den aktuell gespeicherten Stand, damit zwischenzeitliche Verkaeufe nicht ueberschrieben werden. Ohne geladen gilt der gesendete Bestand. */ -export async function speichereProdukt({ shopId, customerId }, id, produkt) { +export async function speichereProdukt({ shopId, customerId }, id, produkt, { geladen } = {}) { if (!istObjekt(produkt) || typeof produkt.name !== 'string' || produkt.name === '') throw fehlerMit(400, 'Der Artikel ist unvollständig.') - const json = JSON.stringify(produkt) - if (json.length > MAX_JSON) throw fehlerMit(400, 'Der Artikel ist zu umfangreich. Bitte kürze die Beschreibung oder lege weniger Varianten an.') + const pruefeGroesse = (obj) => { + const json = JSON.stringify(obj) + if (json.length > MAX_JSON) throw fehlerMit(400, 'Der Artikel ist zu umfangreich. Bitte kürze die Beschreibung oder lege weniger Varianten an.') + return json + } + const json = pruefeGroesse(produkt) const daten = { name: produkt.name, aktiv: produkt.aktiv !== false, produkt: json } return withLocks([sperre(shopId)], async () => { if (id) { const vorhanden = await holeProdukt(shopId, id) if (!vorhanden) throw fehlerMit(404, 'Diesen Artikel gibt es nicht (mehr).') + if (geladen) { + const zusammen = uebernehmeBestand(produkt, parseProdukt(vorhanden), geladen) + return updateDocument(col(), id, { ...daten, produkt: pruefeGroesse(zusammen) }) + } return updateDocument(col(), id, daten) } const alle = await listeProdukte(shopId) diff --git a/test/shopProdukte.test.js b/test/shopProdukte.test.js index 9671070..f07f189 100644 --- a/test/shopProdukte.test.js +++ b/test/shopProdukte.test.js @@ -396,3 +396,73 @@ test('parallele Anlage vergibt verschiedene sort-Werte', async () => { const docs = await Promise.all(Array.from({ length: 5 }, (_, i) => speichereProdukt(shop, null, ok(basis({ name: `P${i}` }))))) assert.deepEqual(docs.map((d) => d.sort).sort(), [0, 1, 2, 3, 4]) }) + +// ---- Bestand beim Bearbeiten (Review A, I3): nur ein vom Haendler geaenderter Bestand wird geschrieben ------------------ + +const bestandVon = async (id) => JSON.parse((await holeProdukt('shop-a', id)).produkt) + +async function mitGeladen(id, roh, normalisiert = ok(roh)) { + return speichereProdukt(SHOP, id, normalisiert, { geladen: P.leseBestandGeladen(roh) }) +} + +test('leseBestandGeladen: Zahl, null und leer werden gelesen, Unbrauchbares gilt als nicht gesendet', () => { + assert.deepEqual(P.leseBestandGeladen({ bestandGeladen: 5 }), { hat: true, bestand: 5, werte: new Map() }) + assert.deepEqual(P.leseBestandGeladen({ bestandGeladen: null }), { hat: true, bestand: null, werte: new Map() }) + assert.equal(P.leseBestandGeladen({ bestandGeladen: '3' }).bestand, 3) + assert.equal(P.leseBestandGeladen({ bestandGeladen: '' }).bestand, null) + assert.equal(P.leseBestandGeladen({ bestandGeladen: 'abc' }).hat, false) + assert.equal(P.leseBestandGeladen({}).hat, false) + assert.equal(P.leseBestandGeladen(null).hat, false) + const w = P.leseBestandGeladen({ varianten: { werte: [{ id: 'aaaaaaaa', bestandGeladen: 2 }, { id: 'bbbbbbbb', bestandGeladen: null }, { id: 'cccccccc' }, { bestandGeladen: 1 }] } }) + assert.deepEqual([...w.werte], [['aaaaaaaa', 2], ['bbbbbbbb', null]]) +}) + +test('speichern I3: unveraenderter Bestand ueberschreibt nicht den inzwischen verkauften Stand', async () => { + const a = await speichereProdukt(SHOP, null, ok(basis({ name: 'Kerze', bestand: 5 }))) + // Es wurden 3 Stueck verkauft + await speichereProdukt(SHOP, a.$id, { ...(await bestandVon(a.$id)), bestand: 2 }) + const g = await mitGeladen(a.$id, basis({ name: 'Kerze neu', bestand: 5, bestandGeladen: 5 })) + assert.equal(JSON.parse(g.produkt).name, 'Kerze neu') + assert.equal((await bestandVon(a.$id)).bestand, 2) +}) + +test('speichern I3: geaenderter Bestand wird geschrieben (auch auf unbegrenzt und auf 0)', async () => { + const a = await speichereProdukt(SHOP, null, ok(basis({ name: 'Kerze', bestand: 5 }))) + await speichereProdukt(SHOP, a.$id, { ...(await bestandVon(a.$id)), bestand: 2 }) + await mitGeladen(a.$id, basis({ name: 'Kerze', bestand: 9, bestandGeladen: 5 })) + assert.equal((await bestandVon(a.$id)).bestand, 9) + await mitGeladen(a.$id, basis({ name: 'Kerze', bestand: '', bestandGeladen: 9 })) + assert.equal((await bestandVon(a.$id)).bestand, null) + await mitGeladen(a.$id, basis({ name: 'Kerze', bestand: 0, bestandGeladen: null })) + assert.equal((await bestandVon(a.$id)).bestand, 0) +}) + +test('speichern I3: ohne bestandGeladen gilt der gesendete Bestand wie bisher', async () => { + const a = await speichereProdukt(SHOP, null, ok(basis({ name: 'Kerze', bestand: 5 }))) + await speichereProdukt(SHOP, a.$id, ok(basis({ name: 'Kerze', bestand: 7 }))) + assert.equal((await bestandVon(a.$id)).bestand, 7) +}) + +test('speichern I3: Variantenbestand je Wert - unveraenderte Werte behalten den aktuellen Stand, geaenderte und neue Werte nicht', async () => { + const roh = (w) => basis({ name: 'Shirt', preisCent: undefined, varianten: { name: 'Größe', werte: w } }) + const a = await speichereProdukt(SHOP, null, ok(roh([{ id: 'aaaaaaaa', name: 'S', preisCent: 800, bestand: 5 }, { id: 'bbbbbbbb', name: 'M', preisCent: 900, bestand: 5 }, { id: 'cccccccc', name: 'L', preisCent: 900, bestand: null }]))) + // S: 2 verkauft, M: 5 verkauft + const stand = await bestandVon(a.$id) + stand.varianten.werte[0].bestand = 3 + stand.varianten.werte[1].bestand = 0 + await speichereProdukt(SHOP, a.$id, stand) + await mitGeladen(a.$id, roh([ + { id: 'aaaaaaaa', name: 'S', preisCent: 800, bestand: 5, bestandGeladen: 5 }, // unberuehrt + { id: 'bbbbbbbb', name: 'M', preisCent: 900, bestand: 20, bestandGeladen: 5 }, // Haendler hat aufgestockt + { id: 'cccccccc', name: 'L', preisCent: 900, bestand: null, bestandGeladen: null }, // unbegrenzt bleibt + { name: 'XL', preisCent: 900, bestand: 4 }, // neuer Wert + ])) + const nachher = (await bestandVon(a.$id)).varianten.werte + assert.deepEqual(nachher.map((w) => [w.name, w.bestand]), [['S', 3], ['M', 20], ['L', null], ['XL', 4]]) +}) + +test('speichern I3: Bestand beim Anlegen bleibt wie gesendet; bestandGeladen landet nie im gespeicherten Artikel', async () => { + const a = await mitGeladen(null, basis({ name: 'Neu', bestand: 4, bestandGeladen: 4 })) + assert.equal((await bestandVon(a.$id)).bestand, 4) + assert.ok(!JSON.stringify(await bestandVon(a.$id)).includes('Geladen')) +}) diff --git a/test/shopRoutes.test.js b/test/shopRoutes.test.js index 514935d..888b441 100644 --- a/test/shopRoutes.test.js +++ b/test/shopRoutes.test.js @@ -241,6 +241,29 @@ test('Einstellungen speichern leert den 30-s-Speicher von shopPerSiteKey', async // ---------------------------------------------------------------- Artikel +test('Artikel I3: Speichern ohne Bestandsaenderung behaelt den inzwischen verkauften Bestand, geaenderter Bestand wird geschrieben', async () => { + await aktiviere() + await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }) + const neu = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bestand: 5, bestandGeladen: 5 } }) + assert.equal(neu.status, 201) + const id = neu.body.produkt.id + assert.equal(neu.body.produkt.bestand, 5) + assert.equal(neu.body.produkt.bestandGeladen, undefined) + // Zwischenzeitlich wurden 3 Stueck verkauft + const doc = aw.docs('shopProducts').get(id) + await updateDocument(config.collections.shopProducts, id, { produkt: JSON.stringify({ ...JSON.parse(doc.produkt), bestand: 2 }) }) + + const nurText = await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, name: 'Tasse 2', bestand: 5, bestandGeladen: 5 } }) + assert.equal(nurText.status, 200) + assert.deepEqual([nurText.body.produkt.name, nurText.body.produkt.bestand], ['Tasse 2', 2]) + assert.ok(!aw.docs('shopProducts').get(id).produkt.includes('bestandGeladen')) + + const aufgestockt = await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, bestand: 12, bestandGeladen: 5 } }) + assert.equal(aufgestockt.body.produkt.bestand, 12) + const ohneMarker = await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, bestand: 7 } }) + assert.equal(ohneMarker.body.produkt.bestand, 7, 'alter Browser ohne bestandGeladen: wie bisher') +}) + test('Artikel: anlegen 201, lesen, aendern, Fehler 400 je Feld, unbekannte ID 404, Kleinunternehmer-Steuer', async () => { await aktiviere() await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }) // Kleinunternehmer => 0 % diff --git a/test/shopTabArtikel.test.js b/test/shopTabArtikel.test.js index 273afbb..f7a7e19 100644 --- a/test/shopTabArtikel.test.js +++ b/test/shopTabArtikel.test.js @@ -38,6 +38,14 @@ const artikel = (id, name, extra = {}) => { return a } +/** Wie koerper, dazu der beim Laden gesehene Bestand (bestandGeladen, je Variantenwert mit id) - so geht ein bestehender Artikel zum Server. */ +const geladenKoerper = (a, extra = {}) => { + const k = koerper(a) + k.bestandGeladen = a.bestand ?? null + if (k.varianten) k.varianten = { ...k.varianten, werte: k.varianten.werte.map((w) => ({ ...w, bestandGeladen: w.bestand ?? null })) } + return { ...k, ...extra } +} + /** Der Artikel so, wie er beim Speichern zum Server geht (ohne id, sort, bildUrls). */ const koerper = (a, extra = {}) => { const { id, sort, bildUrls, ...rest } = a @@ -168,7 +176,7 @@ test('Schalter „aktiv“: PUT mit dem ganzen Artikel und umgedrehtem aktiv, da assert.equal(rest.length, 0) assert.equal(put.method, 'PUT') assert.equal(put.url, '/api/shop/produkte/a1') - assert.deepEqual(put.body, { projectId: P, produkt: koerper(DREI()[0], { aktiv: false }) }) + assert.deepEqual(put.body, { projectId: P, produkt: geladenKoerper(DREI()[0], { aktiv: false }) }) assert.equal(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv').checked, false) assert.match(meldungen.at(-1).text, /Mütze.*inaktiv/) })) @@ -365,7 +373,7 @@ test('Bearbeiten: Werte-IDs der Varianten bleiben im Körper erhalten', mit({ await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'bearbeiten')) await fx.klick(knopf(fx, fx.finde(panel, 'form'), 'speichern')) assert.deepEqual(aenderungen(fx)[0].body.produkt.varianten.werte.map((w) => w.id), ['aaaaaaaa', 'bbbbbbbb']) - assert.deepEqual(aenderungen(fx)[0].body.produkt, koerper(DREI()[2])) + assert.deepEqual(aenderungen(fx)[0].body.produkt, geladenKoerper(DREI()[2])) })) /* --------------------------------------------------------------------- Bilder --- */ @@ -576,3 +584,32 @@ test('Statik: kein innerHTML, outerHTML, insertAdjacentHTML oder document.write; } assert.doesNotMatch(code, /from '\.\/termine/, 'keine Importe aus termine*.js') }) + +test('Bearbeiten I3: der Körper trägt den beim Laden gesehenen Bestand (bestandGeladen) neben dem Feldwert, je Variantenwert; neue Werte und neue Artikel nicht', mit({ + produkte: DREI(), + sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a3', 'Handschuhe') } } : null), +}, async ({ fx, panel }) => { + await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'bearbeiten')) + const form = fx.finde(panel, 'form') + await fx.setze(feldVon(fx, form, 'varianten.werte.0.bestand'), '9') + await fx.klick(knopf(fx, form, 'wert-neu')) + await fx.setze(feldVon(fx, form, 'varianten.werte.2.name'), 'XL') + await fx.setze(feldVon(fx, form, 'varianten.werte.2.preisCent'), '11') + await fx.setze(feldVon(fx, form, 'varianten.werte.2.bestand'), '3') + await fx.klick(knopf(fx, form, 'speichern')) + const { produkt } = aenderungen(fx)[0].body + assert.equal(produkt.bestandGeladen, null) + assert.deepEqual(produkt.varianten.werte.map((w) => [w.id, w.bestand, w.bestandGeladen]), [['aaaaaaaa', 9, 2], ['bbbbbbbb', null, null], [undefined, 3, undefined]]) +})) + +test('Bearbeiten I3: Einzelbestand - geändert wird der neue Wert gesendet, geladen bleibt der alte', mit({ + produkte: DREI(), + sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a1', 'Mütze') } } : null), +}, async ({ fx, panel }) => { + await fx.klick(knopf(fx, zeile(fx, panel, 'a1'), 'bearbeiten')) + const form = fx.finde(panel, 'form') + await fx.setze(feldVon(fx, form, 'bestand'), '8') + await fx.klick(knopf(fx, form, 'speichern')) + const { produkt } = aenderungen(fx)[0].body + assert.deepEqual([produkt.bestand, produkt.bestandGeladen], [8, 5]) +}))