diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 718319f..87dde5a 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -34,13 +34,7 @@ jobs: docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc " pip install -r /app/requirements-dev.txt && cd /app && - python -m pytest \ - tests/test_csv_parser_core.py \ - tests/test_csv_import_executor.py \ - tests/test_mapping_suggest.py \ - tests/test_placeholder_metadata.py \ - tests/test_placeholder_metadata_v2.py \ - -q --tb=short + python -m pytest tests -m 'not slow' -q --tb=short " lint-backend: diff --git a/backend/pytest.ini b/backend/pytest.ini index cb97653..0ff4d70 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -3,3 +3,7 @@ testpaths = tests python_files = test_*.py python_functions = test_* addopts = -q --tb=short +markers = + smoke: Fast smoke tests for core regression checks. + integration: Integration tests across modules/db/api behavior. + slow: Long-running or heavy tests. diff --git a/frontend/src/pages/AdminCsvTemplateEditorPage.jsx b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx index 2f40a14..e8443bb 100644 --- a/frontend/src/pages/AdminCsvTemplateEditorPage.jsx +++ b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx @@ -13,6 +13,19 @@ const MODULE_LABEL = { vitals_baseline: 'Vitalwerte (Baseline)', } +/** Vorschläge für Freitext „Quelleinheit“ (Volumen, Stück …); Ziel kommt aus dem Datenmodell. */ +const CUSTOM_SOURCE_UNIT_HINTS = [ + 'ml', + 'l', + 'cl', + 'dl', + 'µl', + 'mg', + 'µg', + 'Stück', + 'Portion', +] + /** Erlaubt Eingaben wie 1,03 oder 1.03 während des Tippens; Finale normalisiert bei Blur/Speichern. */ function normalizeDecimalInputString(raw) { let s = String(raw).trim().replace(/\s/g, '') @@ -31,21 +44,70 @@ function normalizeDecimalInputString(raw) { return s } -function applyCustomFactorToTcObject(tc, fieldKey, raw) { +/** + * Semantik: „source_amount [source_unit_label] entspricht target_amount [canonical storage unit]“ + * → CSV-Zahl (in der linken Einheit) × (target_amount / source_amount) → Speicherwert. + * custom_equivalence dient der Dokumentation im JSON; der Importer nutzt nur conversion_factor. + */ +function loadEquivalenceFromTc(tc, fieldKey) { + const row = tc[fieldKey] + if (!row || typeof row !== 'object') { + return { srcAmt: '1', srcUnit: '', tgtAmt: '' } + } + const eq = row.custom_equivalence + if (eq && typeof eq === 'object') { + const sa = eq.source_amount + const ta = eq.target_amount + return { + srcAmt: sa != null && sa !== '' ? String(sa) : '', + srcUnit: eq.source_unit_label != null ? String(eq.source_unit_label) : '', + tgtAmt: ta != null && ta !== '' ? String(ta) : '', + } + } + const f = row.conversion_factor + if (f != null && f !== '') { + return { srcAmt: '1', srcUnit: '', tgtAmt: String(f) } + } + return { srcAmt: '1', srcUnit: '', tgtAmt: '' } +} + +function applyCustomEquivalenceToTcObject(tc, fieldKey, draft, canonicalUnitLabel) { const base = tc[fieldKey] && typeof tc[fieldKey] === 'object' ? { ...tc[fieldKey] } : { type: 'float', decimal_separator: 'auto', flexible: true } - const normalized = normalizeDecimalInputString(raw) - if (normalized === '') { + const sa = normalizeDecimalInputString(draft.srcAmt ?? '') + const ta = normalizeDecimalInputString(draft.tgtAmt ?? '') + const unitLbl = String(draft.srcUnit ?? '').trim() + const bothEmpty = !sa && !ta + + if (bothEmpty) { delete base.conversion_factor - } else { - const num = Number(normalized) - if (Number.isNaN(num)) { - return { ok: false, message: `Konvertierungsfaktor (${fieldKey}): keine gültige Zahl.` } - } - base.conversion_factor = num + delete base.custom_equivalence base.source_unit = 'custom' + delete base.target_unit + tc[fieldKey] = base + return { ok: true } + } + if (!sa || !ta) { + return { ok: true, partial: true } + } + const srcNum = Number(sa) + const tgtNum = Number(ta) + if (Number.isNaN(srcNum) || Number.isNaN(tgtNum)) { + return { ok: false, message: `Umrechnung (${fieldKey}): keine gültigen Zahlen.` } + } + if (srcNum === 0) { + return { ok: false, message: `Umrechnung (${fieldKey}): die linke Menge darf nicht 0 sein.` } + } + const factor = tgtNum / srcNum + base.conversion_factor = factor + base.source_unit = 'custom' + base.custom_equivalence = { + source_amount: srcNum, + source_unit_label: unitLbl || '(Quelleinheit)', + target_amount: tgtNum, + target_unit_label: canonicalUnitLabel || '(Ziel)', } delete base.target_unit tc[fieldKey] = base @@ -121,8 +183,8 @@ export default function AdminCsvTemplateEditorPage() { const [analyzing, setAnalyzing] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) - /** Lokaler Text für Konvertierungsfaktor (nur bei source_unit custom), Commit bei Blur/Speichern — verhindert 1. → 1 beim Tippen. */ - const [customFactorDraftByField, setCustomFactorDraftByField] = useState({}) + /** Entwurf für „Quelle entspricht Ziel“ (nur source_unit custom); Commit bei Blur/Speichern. */ + const [customEquivalenceDraftByField, setCustomEquivalenceDraftByField] = useState({}) const modMeta = useMemo(() => modules.find((m) => m.id === module), [modules, module]) const aggregateSleepImport = modMeta?.import_mode === 'apple_sleep_aggregate' @@ -176,7 +238,7 @@ export default function AdminCsvTemplateEditorPage() { setFieldMappings(fm) setColumns(Object.keys(fm)) setTypeConversionsText(JSON.stringify(t.type_conversions || {}, null, 2)) - setCustomFactorDraftByField({}) + setCustomEquivalenceDraftByField({}) setSampleRows([]) setSeedHint(null) }) @@ -229,6 +291,10 @@ export default function AdminCsvTemplateEditorPage() { const hit = opts.find((o) => o.id === sid) if (hit) return hit.id } + const ce = tc[fieldKey]?.custom_equivalence + if (ce && typeof ce === 'object' && opts.some((o) => o.id === 'custom')) { + return 'custom' + } if (tc[fieldKey]?.conversion_factor != null && tc[fieldKey]?.conversion_factor !== '') { const hasCustomOpt = opts.some((o) => o.id === 'custom') if (hasCustomOpt) return 'custom' @@ -254,16 +320,17 @@ export default function AdminCsvTemplateEditorPage() { if (sourceUnitId === canonical || sourceUnitId === '' || sourceUnitId == null) { delete base.source_unit delete base.conversion_factor + delete base.custom_equivalence } else if (sourceUnitId === 'custom') { base.source_unit = 'custom' - // conversion_factor nur per Eingabefeld setzen/löschen } else { base.source_unit = sourceUnitId delete base.conversion_factor + delete base.custom_equivalence } tc[fieldKey] = base setTypeConversionsText(JSON.stringify(tc, null, 2)) - setCustomFactorDraftByField((prev) => { + setCustomEquivalenceDraftByField((prev) => { const next = { ...prev } delete next[fieldKey] return next @@ -271,28 +338,40 @@ export default function AdminCsvTemplateEditorPage() { setError(null) } - const getCustomConversionFactorInputValue = (fieldKey) => { - let tc + const getCanonicalStorageUnitLabel = (fieldKey) => { + const opts = modMeta?.fields?.[fieldKey]?.source_unit_options || [] + const c = opts.find((o) => o.is_canonical) + return c?.canonical_unit || c?.id || '—' + } + + const getEquivalenceDisplay = (fieldKey) => { + if (customEquivalenceDraftByField[fieldKey]) { + return customEquivalenceDraftByField[fieldKey] + } try { - tc = JSON.parse(typeConversionsText || '{}') + const tc = JSON.parse(typeConversionsText || '{}') + return loadEquivalenceFromTc(tc, fieldKey) } catch { - return '' + return { srcAmt: '1', srcUnit: '', tgtAmt: '' } } - const v = tc[fieldKey]?.conversion_factor - if (v == null || v === '') return '' - return String(v) } - const getCustomFactorFieldDisplay = (fieldKey) => { - if (Object.prototype.hasOwnProperty.call(customFactorDraftByField, fieldKey)) { - return customFactorDraftByField[fieldKey] - } - return getCustomConversionFactorInputValue(fieldKey) + const mergeEquivalenceDraft = (fieldKey, patch) => { + setCustomEquivalenceDraftByField((prev) => { + let baseTc + try { + baseTc = JSON.parse(typeConversionsText || '{}') + } catch { + baseTc = {} + } + const cur = prev[fieldKey] || loadEquivalenceFromTc(baseTc, fieldKey) + return { ...prev, [fieldKey]: { ...cur, ...patch } } + }) } - const commitCustomFactorOnBlur = (fieldKey, raw) => { + const commitCustomEquivalenceOnBlur = (fieldKey) => { if (getSourceUnitSelectValue(fieldKey) !== 'custom') { - setCustomFactorDraftByField((prev) => { + setCustomEquivalenceDraftByField((prev) => { const next = { ...prev } delete next[fieldKey] return next @@ -303,16 +382,23 @@ export default function AdminCsvTemplateEditorPage() { try { tc = JSON.parse(typeConversionsText || '{}') } catch { - setError('type_conversions: ungültiges JSON (Faktor kann nicht gesetzt werden).') + setError('type_conversions: ungültiges JSON (Umrechnung kann nicht gespeichert werden).') + return + } + const draft = + customEquivalenceDraftByField[fieldKey] || loadEquivalenceFromTc(tc, fieldKey) + const canon = getCanonicalStorageUnitLabel(fieldKey) + const result = applyCustomEquivalenceToTcObject(tc, fieldKey, draft, canon) + if (result.partial) { + setError(null) return } - const result = applyCustomFactorToTcObject(tc, fieldKey, raw) if (!result.ok) { - setError(result.message || 'Konvertierungsfaktor ungültig.') + setError(result.message || 'Ungültige Umrechnung.') return } setTypeConversionsText(JSON.stringify(tc, null, 2)) - setCustomFactorDraftByField((prev) => { + setCustomEquivalenceDraftByField((prev) => { const next = { ...prev } delete next[fieldKey] return next @@ -320,6 +406,20 @@ export default function AdminCsvTemplateEditorPage() { setError(null) } + const derivedFactorHintLine = (fieldKey) => { + const d = getEquivalenceDisplay(fieldKey) + const sa = normalizeDecimalInputString(d.srcAmt ?? '') + const ta = normalizeDecimalInputString(d.tgtAmt ?? '') + if (!sa || !ta) return null + const a = Number(sa) + const b = Number(ta) + if (Number.isNaN(a) || Number.isNaN(b) || a === 0) return null + const f = b / a + const fDisp = Number.isFinite(f) ? Math.round(f * 1e9) / 1e9 : f + const canon = getCanonicalStorageUnitLabel(fieldKey) + return `Abgeleitet: CSV-Zahl × ${fDisp} → Wert in ${canon} (Speicher)` + } + const handleAnalyze = async () => { if (!file) { setError('Bitte eine CSV-Datei wählen.') @@ -339,7 +439,7 @@ export default function AdminCsvTemplateEditorPage() { setDelimiter(res.delimiter || ';') setEncoding(res.encoding || 'utf-8') setSeedHint(res.seed_template || null) - setCustomFactorDraftByField({}) + setCustomEquivalenceDraftByField({}) } catch (e) { setError(e.message || 'Analyse fehlgeschlagen') } finally { @@ -354,22 +454,32 @@ export default function AdminCsvTemplateEditorPage() { const handleSave = async () => { setError(null) let textForTc = typeConversionsText - const pendingFactorDrafts = { ...customFactorDraftByField } - if (Object.keys(pendingFactorDrafts).length > 0) { + const pendingEquivalenceDrafts = { ...customEquivalenceDraftByField } + if (Object.keys(pendingEquivalenceDrafts).length > 0) { try { const tco = JSON.parse(textForTc || '{}') - for (const [fk, raw] of Object.entries(pendingFactorDrafts)) { + for (const fk of Object.keys(pendingEquivalenceDrafts)) { const su = String(tco[fk]?.source_unit || '').toLowerCase() if (su !== 'custom') continue - const result = applyCustomFactorToTcObject(tco, fk, raw) + const draft = pendingEquivalenceDrafts[fk] + const opts = modMeta?.fields?.[fk]?.source_unit_options || [] + const c = opts.find((o) => o.is_canonical) + const canon = c?.canonical_unit || c?.id || '—' + const result = applyCustomEquivalenceToTcObject(tco, fk, draft, canon) + if (result.partial) { + setError( + `Benutzerdefinierte Umrechnung (${fk}): beide Mengen ausfüllen oder Entwurf verwerfen (Tab durch alle Felder).`, + ) + return + } if (!result.ok) { - setError(result.message || 'Konvertierungsfaktor ungültig.') + setError(result.message || 'Benutzerdefinierte Umrechnung ungültig.') return } } textForTc = JSON.stringify(tco, null, 2) setTypeConversionsText(textForTc) - setCustomFactorDraftByField({}) + setCustomEquivalenceDraftByField({}) } catch { setError('type_conversions: ungültiges JSON.') return @@ -718,11 +828,10 @@ export default function AdminCsvTemplateEditorPage() {
- Ziel-Einheit kommt aus dem Datenmodell (z. B. kcal, g, kg, km). Standard-Umrechnungen wählen; für
- abweichende Skalen (z. B. Volumen→Masse mit variabler Dichte) "Benutzerdefiniert" und den
- Faktor eintragen (CSV-Wert × Faktor → Speicher-Einheit). Bei Registry-Optionen werden{' '}
- target_unit und ein alter conversion_factor entfernt; bei
- Benutzerdefiniert bleibt der Faktor erhalten, bis du ihn leerst.
+ Ziel-Einheit kommt aus dem Datenmodell. Standard-Umrechnungen im Dropdown; bei "Benutzerdefiniert"
+ die Bezugsgröße eintragen: Menge [Quelleinheit] entspricht Menge [Zieleinheit] (Ziel ist die
+ Speicher-Einheit des Feldes). Im JSON siehst du weiterhin conversion_factor und{' '}
+ custom_equivalence zur Dokumentation.
- Dezimalkomma oder -punkt; mit Tab oder Klick außerhalb übernehmen (oder direkt Speichern). -
- {getCustomFactorFieldDisplay(fkey) === '' && ( -- Ohne Faktor gilt keine zusätzliche Skalierung (wie 1:1 nach dem Parsen). + +
+ {derivedFactorHintLine(fkey)}
- )} + ) : null} ++ Beispiel: 1 ml entspricht 1,03{' '} + {getCanonicalStorageUnitLabel(fkey)}, wenn die CSV Milliliter liefert und die + Dichte etwa 1,03 g/ml ist. Alle drei Felder leer lassen = keine Zusatz-Umrechnung. Tab / + Fokus weg oder Speichern übernimmt. +
- Vom Vorschlag übernommen; bei Dropdowns 3b wird source_unit ergänzt. Zusätzlich manuell
- z. B. Datumsformat. Freie Umrechnung (nicht in der Liste 3b):{' '}
- conversion_factor als Multiplikator nach dem Parsen (und nach Registry-
- source_unit). Optional im JSON source_unit: 'custom', wenn nur{' '}
- conversion_factor gelten soll.
+ Vom Vorschlag übernommen; bei Dropdowns 3b werden source_unit, ggf.{' '}
+ conversion_factor und custom_equivalence gesetzt. Zusätzlich manuell z. B.
+ Datumsformat.