From 85b5f3cedb95c4042bb5f90ef06e58d16da8e650 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:51:40 +0000 Subject: [PATCH] =?UTF-8?q?Workers=20P2:=20Formularlogik=20der=20Einstellu?= =?UTF-8?q?ngen=20(Pr=C3=BCfung,=20Patch=20nur=20Ge=C3=A4ndertes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/lib/workersSettingsForm.js | 184 ++++++++++++++++++++++++ tests/unit/workersSettingsForm.test.mjs | 84 +++++++++++ 2 files changed, 268 insertions(+) create mode 100644 src/lib/workersSettingsForm.js create mode 100644 tests/unit/workersSettingsForm.test.mjs diff --git a/src/lib/workersSettingsForm.js b/src/lib/workersSettingsForm.js new file mode 100644 index 0000000..8bdec9c --- /dev/null +++ b/src/lib/workersSettingsForm.js @@ -0,0 +1,184 @@ +// Formularlogik der Hustler-Einstellungen: Umwandlung Einstellungen <-> Formular, Prüfung mit den Grenzen des +// Dienstes (P1 settings.js, gleich oder enger) und ein Patch mit nur den geänderten, getypten Feldern. +// Reine Funktionen: kein React, kein Appwrite, kein window/document; unter Node testbar (loadModule). + +import { ROOM_TITLES, SEARCH_ROOMS } from './workersRoster.js' + +// Die acht Such-Räume (Etagen anfragen und ohne_website, Rollenreihenfolge) für die Raumauswahl +export const ROOM_CHOICES = Object.freeze(SEARCH_ROOMS.map((id) => Object.freeze({ id, title: ROOM_TITLES[id] }))) + +// Nie bearbeitbar, nie im Patch (Übersicht §7): paused hat den eigenen Schalter, connectedRooms setzt der Dienst, +// privacyInteressentenLive der Datenschutz-Schalter (A5), sendingHalted/sendingHaltedReason die Notbremse (H4; +// aufgehoben wird sie nur über den Knopf des Dialogs), offerProductKey erlaubt der Dienst nur als 'hosting'. +// previewsSent, privacyInteressentenLive, sendingHalted und sendingHaltedReason sind optional (alte Dienstantwort). +export const READONLY_KEYS = Object.freeze(['paused', 'previewsSent', 'updatedAt', 'updatedBy', 'offerProductKey', 'connectedRooms', 'privacyInteressentenLive', 'sendingHalted', 'sendingHaltedReason']) + +const ROOM_IDS = ROOM_CHOICES.map((c) => c.id) + +// Feldregeln als Daten: Typ, Grenzen, Fehlertext. Nur bearbeitbare Felder; die Reihenfolge ist die der Fehlerausgabe. +// betrag Dezimalzahl > gt (optional <= max), Komma oder Punkt +// ganz ganze Zahl min..max +// zeit genau zweistellig HH:MM (00:00 bis 23:59), wie im Dienst +// schalter Ja/Nein raeume Id-Liste aus ROOM_CHOICES zeilen Textfeld, eine Region je Zeile +// wahl feste Werte text getrimmt, min..max Zeichen mail einfache Adresse +const ZEIT_FEHLER = 'Uhrzeit als HH:MM, Beginn vor Ende.' +const RULES = [ + { key: 'dailyBudgetUsd', typ: 'betrag', gt: 0, fehler: 'Tagesgrenze muss größer als 0 sein.' }, + { key: 'monthlyBudgetUsd', typ: 'betrag', gt: 0, max: 150, fehler: 'Monatsgrenze muss größer als 0 sein.', fehlerMax: 'Monatsgrenze höchstens 150 $ (Vorgabe: max. 150 € im Monat).' }, + { key: 'warnPct', typ: 'ganz', min: 1, max: 100, fehler: 'Warnschwelle: ganze Zahl von 1 bis 100.' }, + { key: 'mailColdPerDay', typ: 'ganz', min: 0, max: 3, fehler: 'Höchstens 3 unverlangte Mails pro Tag.' }, + { key: 'mailReplyCapPerDay', typ: 'ganz', min: 0, max: 15, fehler: 'Höchstens 15 Antwort-Mails pro Tag.' }, + { key: 'sendWindowStart', typ: 'zeit', fehler: ZEIT_FEHLER }, + { key: 'sendWindowEnd', typ: 'zeit', fehler: ZEIT_FEHLER }, + { key: 'sendWeekdaysOnly', typ: 'schalter', fehler: 'Bitte Ja oder Nein wählen.' }, + { key: 'runsPerNight', typ: 'ganz', min: 1, max: 2, fehler: 'Läufe pro Nacht: 1 oder 2.' }, + { key: 'candidatesPerRun', typ: 'ganz', min: 1, max: 20, fehler: 'Kandidaten pro Lauf: 1 bis 20.' }, + { key: 'activeRooms', typ: 'raeume', fehler: 'Ungültige Raumauswahl.' }, + { key: 'regions', typ: 'zeilen', min: 1, max: 30, maxLaenge: 64, fehler: 'Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.' }, + { key: 'previewGate', typ: 'wahl', werte: ['erste_n', 'immer', 'nie'], fehler: 'Vorschau-Freigabe: bitte eine Auswahl treffen.' }, + { key: 'previewGateN', typ: 'ganz', min: 0, max: 1000, fehler: 'Anzahl Vorschauen: ganze Zahl von 0 bis 1000.' }, + // 400 Zeichen halten eine Kaltmail unter 180 Wörtern (H3/A11) + { key: 'offerText', typ: 'text', min: 1, max: 400, fehler: 'Angebotstext: 1 bis 400 Zeichen.' }, + { key: 'pultStaleDays', typ: 'ganz', min: 1, max: 30, fehler: 'Pult-Erinnerung: 1 bis 30 Tage.' }, + { key: 'followupDays', typ: 'ganz', min: 1, max: 60, fehler: 'Nachfassen: 1 bis 60 Tage.' }, + { key: 'previewReminderDays', typ: 'ganz', min: 1, max: 60, fehler: 'Vorschau-Erinnerung: 1 bis 60 Tage.' }, + // der Datenschutztext verspricht höchstens 90 Tage (A11) + { key: 'retentionDays', typ: 'ganz', min: 7, max: 90, fehler: 'Aufbewahrung: 7 bis 90 Tage.' }, + { key: 'previewDeleteDays', typ: 'ganz', min: 7, max: 365, fehler: 'Vorschau löschen nach: 7 bis 365 Tage.' }, + { key: 'reportEmail', typ: 'mail', max: 256, fehler: 'Bitte eine gültige E-Mail-Adresse eintragen.' }, +] + +const DEZIMAL = /^-?\d+(?:\.\d+)?$/ +const GANZ = /^-?\d+$/ +const ZEIT = /^(?:[01]\d|2[0-3]):[0-5]\d$/ +const MAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +// Text oder Zahl -> endliche Zahl, sonst null. Dezimalkomma gilt wie Punkt; ganze Zahlen ohne Nachkommateil +// (so wird aus „1.000“ nie stillschweigend 1), keine Exponentschreibweise, kein leerer Text. +function alsZahl(roh, ganz) { + if (typeof roh === 'number') return Number.isFinite(roh) && (!ganz || Number.isInteger(roh)) ? roh : null + if (typeof roh !== 'string') return null + const t = ganz ? roh.trim() : roh.trim().replace(',', '.') + if (!(ganz ? GANZ : DEZIMAL).test(t)) return null + const n = Number(t) + return Number.isFinite(n) ? n : null +} + +// Id-Liste in ROOM_CHOICES-Reihenfolge, nur bekannte Räume +function raumListe(liste) { + return ROOM_IDS.filter((id) => liste.includes(id)) +} + +// Formularwert -> { wert } getypt oder { fehler } mit dem Text der Regel +function lesen(r, roh) { + switch (r.typ) { + case 'betrag': { + const n = alsZahl(roh, false) + if (n === null || n <= r.gt) return { fehler: r.fehler } + if (r.max !== undefined && n > r.max) return { fehler: r.fehlerMax } + return { wert: n } + } + case 'ganz': { + const n = alsZahl(roh, true) + return n === null || n < r.min || n > r.max ? { fehler: r.fehler } : { wert: n } + } + case 'zeit': { + const t = typeof roh === 'string' ? roh.trim() : '' + return ZEIT.test(t) ? { wert: t } : { fehler: r.fehler } + } + case 'schalter': + return typeof roh === 'boolean' ? { wert: roh } : { fehler: r.fehler } + case 'raeume': + return Array.isArray(roh) ? { wert: raumListe(roh) } : { fehler: r.fehler } + case 'zeilen': { + const zeilen = Array.isArray(roh) ? roh : typeof roh === 'string' ? roh.split(/\r?\n/) : null + if (!zeilen) return { fehler: r.fehler } + const wert = zeilen.map((z) => (typeof z === 'string' ? z.trim() : '')).filter((z) => z !== '') + return wert.length < r.min || wert.length > r.max || wert.some((z) => z.length > r.maxLaenge) ? { fehler: r.fehler } : { wert } + } + case 'wahl': + return r.werte.includes(roh) ? { wert: roh } : { fehler: r.fehler } + case 'text': { + const t = typeof roh === 'string' ? roh.trim() : '' + return t.length < r.min || t.length > r.max ? { fehler: r.fehler } : { wert: t } + } + case 'mail': { + const t = typeof roh === 'string' ? roh.trim() : '' + return t.length <= r.max && MAIL.test(t) ? { wert: t } : { fehler: r.fehler } + } + default: + return { fehler: r.fehler } + } +} + +// Gespeicherter Wert -> Formularwert: Zahlen als Text, regions als Zeilen, Räume als Id-Liste +function anzeige(r, wert) { + switch (r.typ) { + case 'betrag': + case 'ganz': + return typeof wert === 'number' ? String(wert) : '' + case 'schalter': + return wert === true + case 'raeume': + return Array.isArray(wert) ? [...wert] : [] + case 'zeilen': + return Array.isArray(wert) ? wert.join('\n') : '' + default: + return typeof wert === 'string' ? wert : '' + } +} + +function gleich(a, b) { + if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x, i) => x === b[i]) + return a === b +} + +/** Einstellungen des Dienstes -> Formular. Schreibgeschützte Felder, die da sind, stehen unverändert zur Anzeige drin. */ +export function settingsToForm(settings) { + const s = settings ?? {} + const form = {} + for (const r of RULES) form[r.key] = anzeige(r, s[r.key]) + for (const k of READONLY_KEYS) { + if (k in s) form[k] = Array.isArray(s[k]) ? [...s[k]] : s[k] + } + return form +} + +/** Prüfung mit den Grenzen des Dienstes: { ok, errors: { [feld]: 'Text' } }. Schreibgeschützte Felder werden nie geprüft. */ +export function validateForm(form) { + const f = form ?? {} + const errors = {} + const gelesen = {} + for (const r of RULES) { + gelesen[r.key] = lesen(r, f[r.key]) + if (gelesen[r.key].fehler) errors[r.key] = gelesen[r.key].fehler + } + // Tagesgrenze nicht über der Monatsgrenze (Vergleich mit dem Zahlenwert, auch wenn die Monatsgrenze selbst zu hoch ist) + const tag = gelesen.dailyBudgetUsd.wert + const monat = alsZahl(f.monthlyBudgetUsd, false) + if (tag !== undefined && monat !== null && monat > 0 && tag > monat) errors.dailyBudgetUsd = 'Die Tagesgrenze darf nicht über der Monatsgrenze liegen.' + // Beginn vor Ende (beide HH:MM, also reicht der Textvergleich); gleich ist nicht „vor“ + const von = gelesen.sendWindowStart.wert + const bis = gelesen.sendWindowEnd.wert + if (von !== undefined && bis !== undefined && von >= bis) errors.sendWindowStart = ZEIT_FEHLER + return { ok: Object.keys(errors).length === 0, errors } +} + +/** + * Formular -> Patch mit nur den geänderten, getypten Feldern gegenüber `original`. Nie READONLY_KEYS (es werden + * nur die Felder aus RULES gelesen). Ein Feld, das seine eigene Regel verletzt, bleibt draußen (nie NaN oder + * Text statt Zahl im Patch); die Meldung dazu kommt aus validateForm, das vor dem Senden läuft. + * Räume: reihenfolgeunabhängig verglichen, in ROOM_CHOICES-Reihenfolge ausgegeben; Regionen: reihenfolgetreu. + */ +export function formToPatch(form, original) { + const f = form ?? {} + const o = original ?? {} + const patch = {} + for (const r of RULES) { + const g = lesen(r, f[r.key]) + if (g.fehler) continue + const alt = r.typ === 'raeume' && Array.isArray(o[r.key]) ? raumListe(o[r.key]) : o[r.key] + if (!gleich(g.wert, alt)) patch[r.key] = g.wert + } + return patch +} diff --git a/tests/unit/workersSettingsForm.test.mjs b/tests/unit/workersSettingsForm.test.mjs new file mode 100644 index 0000000..068921a --- /dev/null +++ b/tests/unit/workersSettingsForm.test.mjs @@ -0,0 +1,84 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' +const F = await loadModule('src/lib/workersSettingsForm.js') + +const S0 = { paused: true, dailyBudgetUsd: 6, monthlyBudgetUsd: 150, warnPct: 80, mailColdPerDay: 3, mailReplyCapPerDay: 15, sendWindowStart: '09:00', sendWindowEnd: '16:00', sendWeekdaysOnly: true, runsPerNight: 1, candidatesPerRun: 10, + activeRooms: ['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden'], connectedRooms: [], regions: ['Rheinland-Pfalz'], previewGate: 'erste_n', previewGateN: 10, previewsSent: 4, offerText: 'Gratis-Vorschau …', offerProductKey: 'hosting', + pultStaleDays: 3, retentionDays: 90, followupDays: 7, previewReminderDays: 5, previewDeleteDays: 60, reportEmail: 'kenso.gri@gmail.com', + privacyInteressentenLive: false, sendingHalted: false, sendingHaltedReason: '', updatedAt: '2026-10-17T10:00:00Z', updatedBy: 'x' } + +test('unverändertes Formular ⇒ leerer Patch; schreibgeschützte Felder (auch H4/A5) nie im Patch', () => { + const f = F.settingsToForm(S0); assert.deepEqual(F.formToPatch(f, S0), {}); assert.equal(F.validateForm(f).ok, true) + assert.deepEqual(F.formToPatch({ ...f, paused: false, previewsSent: 99, updatedBy: 'y', offerProductKey: 'x', connectedRooms: ['netz'] }, S0), {}) + assert.deepEqual(F.formToPatch({ ...f, privacyInteressentenLive: true, sendingHalted: true, sendingHaltedReason: 'x' }, S0), {}) // die drei neuen Felder: Änderungen ergeben {} + assert.deepEqual([...F.READONLY_KEYS], ['paused', 'previewsSent', 'updatedAt', 'updatedBy', 'offerProductKey', 'connectedRooms', 'privacyInteressentenLive', 'sendingHalted', 'sendingHaltedReason']) + const halted = { ...S0, sendingHalted: true, sendingHaltedReason: 'Zwei harte Bounces in den letzten 10 Mails.' } // Notbremse gesetzt: Formular bleibt gültig, Patch leer + assert.deepEqual(F.formToPatch(F.settingsToForm(halted), halted), {}); assert.equal(F.validateForm(F.settingsToForm(halted)).ok, true) + assert.deepEqual(F.formToPatch({ ...f, warnPct: '70', sendingHalted: true }, S0), { warnPct: 70 }) // ein echtes Feld nimmt die schreibgeschützten nicht mit + // optionale Felder fehlen (alte Dienstantwort): Formular gültig, Patch leer, und nichts Schreibgeschütztes im Patch + const { previewsSent, privacyInteressentenLive, sendingHalted, sendingHaltedReason, ...schmal } = S0 + assert.deepEqual(F.formToPatch(F.settingsToForm(schmal), schmal), {}); assert.equal(F.validateForm(F.settingsToForm(schmal)).ok, true) + // das Formular zeigt die schreibgeschützten Werte unverändert an (Dialog, P2-T18) + assert.equal(F.settingsToForm(halted).sendingHaltedReason, 'Zwei harte Bounces in den letzten 10 Mails.'); assert.equal(F.settingsToForm(S0).previewsSent, 4) +}) +test('Patch enthält nur Geändertes, getypt; Dezimalkomma, Zeilen, Räume', () => { + const f = { ...F.settingsToForm(S0), dailyBudgetUsd: '12,5', regions: 'Rheinland-Pfalz\n\n Hessen ', activeRooms: S0.activeRooms.filter((r) => r !== 'kleinanzeigen'), previewGate: 'immer' } + assert.deepEqual(F.formToPatch(f, S0), { dailyBudgetUsd: 12.5, regions: ['Rheinland-Pfalz', 'Hessen'], activeRooms: ['netz', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden'], previewGate: 'immer' }) + // Formular zeigt Zahlen als Text, Regionen als Zeilen, Räume als Id-Liste + const g = F.settingsToForm({ ...S0, regions: ['Rheinland-Pfalz', 'Hessen'] }) + assert.equal(g.dailyBudgetUsd, '6'); assert.equal(g.runsPerNight, '1'); assert.equal(g.regions, 'Rheinland-Pfalz\nHessen'); assert.deepEqual(g.activeRooms, S0.activeRooms); assert.equal(g.sendWeekdaysOnly, true) + // Räume: Reihenfolge der Eingabe egal, Ausgabe in ROOM_CHOICES-Reihenfolge; nur Umsortieren ist keine Änderung + assert.deepEqual(F.formToPatch({ ...g, regions: 'Rheinland-Pfalz\nHessen', activeRooms: [...S0.activeRooms].reverse() }, { ...S0, regions: ['Rheinland-Pfalz', 'Hessen'] }), {}) + assert.deepEqual(F.formToPatch({ ...F.settingsToForm(S0), activeRooms: ['laden', 'netz'] }, S0), { activeRooms: ['netz', 'laden'] }) + // Regionen: reihenfolgetreu, Umsortieren ist eine Änderung + assert.deepEqual(F.formToPatch({ ...g, regions: 'Hessen\nRheinland-Pfalz' }, { ...S0, regions: ['Rheinland-Pfalz', 'Hessen'] }), { regions: ['Hessen', 'Rheinland-Pfalz'] }) + // Schalter, Zeit (mit Leerraum), Text, Adresse: getypt und getrimmt + assert.deepEqual(F.formToPatch({ ...F.settingsToForm(S0), sendWeekdaysOnly: false, sendWindowEnd: ' 17:30 ', offerText: ' Neuer Satz ', reportEmail: ' a@b.de ', monthlyBudgetUsd: '99.5', mailColdPerDay: '0' }, S0), + { sendWeekdaysOnly: false, sendWindowEnd: '17:30', offerText: 'Neuer Satz', reportEmail: 'a@b.de', monthlyBudgetUsd: 99.5, mailColdPerDay: 0 }) + // ungültige Felder gelangen nie in den Patch (kein NaN, kein Text statt Zahl), gültige daneben schon + assert.deepEqual(F.formToPatch({ ...F.settingsToForm(S0), warnPct: 'x', mailColdPerDay: '4', sendWindowStart: '9:00', regions: ' \n', runsPerNight: '2' }, S0), { runsPerNight: 2 }) +}) +test('Prüfung mit den Spec-Grenzen', () => { + const bad = (over) => F.validateForm({ ...F.settingsToForm(S0), ...over }).errors + assert.equal(bad({ mailColdPerDay: '4' }).mailColdPerDay, 'Höchstens 3 unverlangte Mails pro Tag.'); assert.equal(bad({ mailReplyCapPerDay: '16' }).mailReplyCapPerDay, 'Höchstens 15 Antwort-Mails pro Tag.') + assert.equal(bad({ dailyBudgetUsd: '151' }).dailyBudgetUsd, 'Die Tagesgrenze darf nicht über der Monatsgrenze liegen.'); assert.equal(bad({ monthlyBudgetUsd: '151' }).monthlyBudgetUsd, 'Monatsgrenze höchstens 150 $ (Vorgabe: max. 150 € im Monat).'); assert.equal(bad({ dailyBudgetUsd: '150' }).dailyBudgetUsd, undefined) // Monatsgrenze 150 aus S0 + assert.equal(bad({ dailyBudgetUsd: '0' }).dailyBudgetUsd, 'Tagesgrenze muss größer als 0 sein.') + assert.equal(bad({ sendWindowStart: '16:00', sendWindowEnd: '09:00' }).sendWindowStart, 'Uhrzeit als HH:MM, Beginn vor Ende.'); assert.equal(bad({ regions: ' \n' }).regions, 'Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.') + assert.equal(bad({ offerText: '' }).offerText, 'Angebotstext: 1 bis 400 Zeichen.'); assert.equal(bad({ offerText: 'x'.repeat(401) }).offerText, 'Angebotstext: 1 bis 400 Zeichen.'); assert.equal(bad({ offerText: 'x'.repeat(400) }).offerText, undefined) + assert.equal(bad({ retentionDays: '5' }).retentionDays, 'Aufbewahrung: 7 bis 90 Tage.'); assert.equal(bad({ retentionDays: '6' }).retentionDays, 'Aufbewahrung: 7 bis 90 Tage.'); assert.equal(bad({ retentionDays: '91' }).retentionDays, 'Aufbewahrung: 7 bis 90 Tage.') + assert.equal(bad({ retentionDays: '7' }).retentionDays, undefined); assert.equal(bad({ retentionDays: '90' }).retentionDays, undefined); assert.equal(bad({ warnPct: 'x' }).warnPct, 'Warnschwelle: ganze Zahl von 1 bis 100.') + assert.equal(bad({ runsPerNight: '3' }).runsPerNight, 'Läufe pro Nacht: 1 oder 2.'); assert.equal(bad({ candidatesPerRun: '21' }).candidatesPerRun, 'Kandidaten pro Lauf: 1 bis 20.') + assert.equal(F.validateForm(F.settingsToForm(S0)).ok, true); assert.deepEqual(F.ROOM_CHOICES.map((r) => r.id), S0.activeRooms) + // Uhrzeiten wie im Dienst (P1): genau zweistellig HH:MM, 00:00 bis 23:59 + const T = 'Uhrzeit als HH:MM, Beginn vor Ende.' + assert.equal(bad({ sendWindowStart: '9:00' }).sendWindowStart, T); assert.equal(bad({ sendWindowEnd: '24:00' }).sendWindowEnd, T) + assert.equal(bad({ sendWindowEnd: '16:60' }).sendWindowEnd, T); assert.equal(bad({ sendWindowStart: '09:00', sendWindowEnd: '9:30' }).sendWindowEnd, T); assert.equal(bad({ sendWindowStart: '' }).sendWindowStart, T) + assert.equal(bad({ sendWindowStart: '09:00', sendWindowEnd: '09:00' }).sendWindowStart, T) // gleich ist nicht „vor“ + assert.deepEqual(bad({ sendWindowStart: '00:00', sendWindowEnd: '23:59' }), {}) + // Grenzen der übrigen Zahlen (innen gültig, außen ungültig), Dezimalkomma und Schreibweisen + assert.equal(bad({ mailColdPerDay: '0' }).mailColdPerDay, undefined); assert.equal(bad({ mailColdPerDay: '-1' }).mailColdPerDay, 'Höchstens 3 unverlangte Mails pro Tag.'); assert.equal(bad({ mailReplyCapPerDay: '15' }).mailReplyCapPerDay, undefined) + assert.equal(bad({ warnPct: '0' }).warnPct, 'Warnschwelle: ganze Zahl von 1 bis 100.'); assert.equal(bad({ warnPct: '100' }).warnPct, undefined); assert.equal(bad({ warnPct: '101' }).warnPct, 'Warnschwelle: ganze Zahl von 1 bis 100.'); assert.equal(bad({ warnPct: '80,5' }).warnPct, 'Warnschwelle: ganze Zahl von 1 bis 100.') + assert.equal(bad({ warnPct: '' }).warnPct, 'Warnschwelle: ganze Zahl von 1 bis 100.'); assert.equal(bad({ warnPct: '1e1' }).warnPct, 'Warnschwelle: ganze Zahl von 1 bis 100.'); assert.equal(bad({ runsPerNight: '0' }).runsPerNight, 'Läufe pro Nacht: 1 oder 2.'); assert.equal(bad({ runsPerNight: '2' }).runsPerNight, undefined) + assert.equal(bad({ candidatesPerRun: '0' }).candidatesPerRun, 'Kandidaten pro Lauf: 1 bis 20.'); assert.equal(bad({ candidatesPerRun: '20' }).candidatesPerRun, undefined) + assert.ok(bad({ previewGateN: '1001' }).previewGateN); assert.equal(bad({ previewGateN: '1000' }).previewGateN, undefined); assert.equal(bad({ previewGateN: '0' }).previewGateN, undefined) + assert.ok(bad({ pultStaleDays: '0' }).pultStaleDays); assert.ok(bad({ pultStaleDays: '31' }).pultStaleDays); assert.equal(bad({ pultStaleDays: '30' }).pultStaleDays, undefined) + assert.ok(bad({ followupDays: '61' }).followupDays); assert.ok(bad({ previewReminderDays: '0' }).previewReminderDays); assert.equal(bad({ previewReminderDays: '60' }).previewReminderDays, undefined) + assert.ok(bad({ previewDeleteDays: '6' }).previewDeleteDays); assert.ok(bad({ previewDeleteDays: '366' }).previewDeleteDays); assert.equal(bad({ previewDeleteDays: '365' }).previewDeleteDays, undefined); assert.equal(bad({ previewDeleteDays: '7' }).previewDeleteDays, undefined) + // Budget: beide größer als 0, Monat höchstens 150 (auch bei Komma), Tag nicht über Monat + assert.equal(bad({ monthlyBudgetUsd: '0' }).monthlyBudgetUsd, 'Monatsgrenze muss größer als 0 sein.'); assert.equal(bad({ monthlyBudgetUsd: 'viel' }).monthlyBudgetUsd, 'Monatsgrenze muss größer als 0 sein.') + assert.equal(bad({ monthlyBudgetUsd: '150,01' }).monthlyBudgetUsd, 'Monatsgrenze höchstens 150 $ (Vorgabe: max. 150 € im Monat).'); assert.equal(bad({ monthlyBudgetUsd: '150' }).monthlyBudgetUsd, undefined) + assert.equal(bad({ monthlyBudgetUsd: '5', dailyBudgetUsd: '6' }).dailyBudgetUsd, 'Die Tagesgrenze darf nicht über der Monatsgrenze liegen.'); assert.equal(bad({ monthlyBudgetUsd: '6', dailyBudgetUsd: '6' }).dailyBudgetUsd, undefined) + assert.equal(bad({ monthlyBudgetUsd: 'x', dailyBudgetUsd: '6' }).dailyBudgetUsd, undefined) // Vergleich nur mit gültiger Monatsgrenze, sonst nur die Meldung am Monatsfeld + assert.equal(bad({ dailyBudgetUsd: '-1' }).dailyBudgetUsd, 'Tagesgrenze muss größer als 0 sein.'); assert.equal(bad({ dailyBudgetUsd: '0,5' }).dailyBudgetUsd, undefined) + // Regionen: höchstens 30 Zeilen, je höchstens 64 Zeichen + const R = 'Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.' + assert.equal(bad({ regions: Array.from({ length: 30 }, (_, i) => `R${i}`).join('\n') }).regions, undefined); assert.equal(bad({ regions: Array.from({ length: 31 }, (_, i) => `R${i}`).join('\n') }).regions, R) + assert.equal(bad({ regions: 'x'.repeat(64) }).regions, undefined); assert.equal(bad({ regions: 'x'.repeat(65) }).regions, R) + // Auswahl und Adresse + assert.ok(bad({ previewGate: 'oft' }).previewGate); assert.equal(bad({ previewGate: 'nie' }).previewGate, undefined); assert.ok(bad({ reportEmail: 'kein-at' }).reportEmail); assert.ok(bad({ reportEmail: '' }).reportEmail); assert.ok(bad({ reportEmail: 'a b@c.de' }).reportEmail) + // schreibgeschützte Felder werden nie geprüft, auch wenn sie unsinnig sind + assert.deepEqual(bad({ paused: 'x', previewsSent: 'x', updatedBy: 7, sendingHalted: 'ja', connectedRooms: 'x' }), {}) + // ROOM_CHOICES: die acht Suchräume mit Titel aus ROOM_TITLES, Rollenreihenfolge + assert.equal(F.ROOM_CHOICES.length, 8); assert.deepEqual(F.ROOM_CHOICES[0], { id: 'netz', title: 'Netz-Radar' }); assert.deepEqual(F.ROOM_CHOICES[7], { id: 'laden', title: 'Läden & Gastro' }) +})