diff --git a/public/neu/ansicht-funktionen.js b/public/neu/ansicht-funktionen.js index 687094f..7004ba2 100644 --- a/public/neu/ansicht-funktionen.js +++ b/public/neu/ansicht-funktionen.js @@ -40,30 +40,39 @@ function aboEndeText(f) { return tag ? `Dein Abo ist beendet. Deine Daten bleiben bis ${tag} gespeichert.` : 'Dein Abo ist beendet.' } -/** Pille und Zeile einer Funktion an einer Website (nur für Websites, bei denen istInMeine gilt). */ +const KURZINFO_MAX = 80 // wie der Server (kurzinfo.js): länger ist kein Detail mehr, sondern ein Fehler + +/** Kurzinfo einer aktiven Funktion aus der Antwort des Servers: nur ein nicht leerer, kurzer Text; sonst '' (die Zeile zeigt dann „Aktiv“). */ +function kurzinfoVon(quelle) { + const text = typeof quelle?.kurzinfo === 'string' ? quelle.kurzinfo.trim() : '' + return text !== '' && text.length <= KURZINFO_MAX ? text : '' +} + +/** + * Pille und Detailzeile einer Funktion an einer Website (nur für Websites, bei denen istInMeine gilt). Läuft die Funktion, gibt es keine + * Pille: die Detailzeile ist die Kurzinfo des Servers („3 Termine heute · nächster 10:30“) oder „Aktiv“. Alles andere trägt eine Pille. + */ function statusVon(eintrag, projekt) { if (eintrag.key === 'analyse') { const a = projekt.analyse - if (a.state === 'active') return { pille: { text: 'Aktiv', ton: 'gruen' }, status: 'Läuft auf deiner Website' } + if (a.state === 'active') return { pille: null, status: kurzinfoVon(a) || 'Aktiv' } return { pille: { text: 'Gesperrt', ton: 'grau' }, status: a.disabledReason === 'abo_ende' ? aboEndeText(a) : 'Schreib uns im Chat' } } const f = projekt.features[eintrag.key] switch (f.state) { case 'setup': - return { pille: { text: 'In Einrichtung', ton: 'blau' }, status: f.ticketWoid ? `Einrichtung läuft · Ticket #${f.ticketWoid}` : 'Einrichtung läuft' } + return { pille: { text: 'In Einrichtung', ton: 'blau' }, status: 'Wir richten sie gerade ein' } case 'ordered': return { pille: { text: 'Zahlung offen', ton: 'orange' }, status: 'Wir warten auf deine Zahlung' } - case 'active': { - const seit = datumDe(f.activatedAt) - return { pille: { text: 'Aktiv', ton: 'gruen' }, status: seit ? `Aktiv seit ${seit}` : 'Läuft auf deiner Website' } - } + case 'active': + return { pille: null, status: kurzinfoVon(f) || 'Aktiv' } case 'disabled': - return { pille: { text: 'Abgeschaltet', ton: 'grau' }, status: f.disabledReason === 'abo_ende' ? aboEndeText(f) : 'Schreib uns im Chat, wenn du die Funktion wieder möchtest.' } + return { pille: { text: 'Abgeschaltet', ton: 'grau' }, status: f.disabledReason === 'abo_ende' ? aboEndeText(f) : 'Schreib uns im Chat, wenn du sie wieder möchtest' } default: { if (eintrag.ablauf === 'fragebogen') return { pille: { text: 'Entwurf', ton: 'grau' }, status: 'Fragebogen fortsetzen' } const anfrage = anfrageStatusVon(projekt, eintrag.key) if (anfrage === 'angenommen') return { pille: { text: 'Angenommen', ton: 'blau' }, status: 'Jetzt einschalten' } - if (anfrage === 'abgelehnt') return { pille: { text: 'Abgelehnt', ton: 'rot' }, status: 'Schreib uns im Chat' } + if (anfrage === 'abgelehnt') return { pille: { text: 'Abgelehnt', ton: 'rot' }, status: 'Anfrage abgelehnt · Im Chat nachfragen' } return { pille: { text: 'Angefragt', ton: 'violett' }, status: 'Wir prüfen deine Anfrage' } } } @@ -71,8 +80,9 @@ function statusVon(eintrag, projekt) { /** * Die Zeilen von „Meine“: je Funktion und Website eine, in Katalogreihenfolge, dann Websitereihenfolge. Funktionen mit - * verfuegbar !== true fehlen (C9). zeile2 ist bei mehreren Websites der Websitename, sonst die Statuszeile. - * @returns {Array<{ key: string, name: string, projectId: string, projektName: string, zustand: string, anfrageStatus: string, pille: { text: string, ton: string }, status: string, zeile2: string }>} + * verfuegbar !== true fehlen (C9). pille ist null, solange die Funktion läuft; status ist die Detailzeile (Kurzinfo oder Zustandstext), + * zeile2 ist bei mehreren Websites „Websitename · status“, sonst der Status allein. + * @returns {Array<{ key: string, name: string, projectId: string, projektName: string, zustand: string, anfrageStatus: string, pille: { text: string, ton: string }|null, status: string, zeile2: string }>} */ export function meineZeilen(daten) { const seiten = websites(daten) @@ -82,16 +92,17 @@ export function meineZeilen(daten) { for (const projekt of seiten) { if (!istInMeine(eintrag, projekt)) continue const { pille, status } = statusVon(eintrag, projekt) + const projektName = String(projekt.projectName ?? '') zeilen.push({ key: eintrag.key, name: String(eintrag.name ?? ''), projectId: projekt.projectId, - projektName: String(projekt.projectName ?? ''), + projektName, zustand: String(zustandVon(projekt, eintrag.key) ?? ''), anfrageStatus: eintrag.key === 'analyse' ? '' : anfrageStatusVon(projekt, eintrag.key), pille, status, - zeile2: mehrere ? String(projekt.projectName ?? '') : status, + zeile2: mehrere && projektName !== '' ? `${projektName} · ${status}` : status, }) } } @@ -260,7 +271,7 @@ export async function zeigeFunktionen(wurzel, { route, me, nav } = {}, abh = {}) }, }, h('span', { class: 'kn-fn-text' }, h('span', { class: 'kn-fn-name' }, z.name), h('span', { class: 'kn-fn-zeile2' }, z.zeile2)), - pille(z.pille))))) + h('span', { class: 'kn-fn-rechts' }, z.pille ? pille(z.pille) : null, h('span', { class: 'kn-fn-pfeil', 'aria-hidden': 'true' }, '›')))))) } function eintragKarte({ eintrag, kandidaten }) { diff --git a/public/neu/funktionen.css b/public/neu/funktionen.css index 70f3fd8..4482482 100644 --- a/public/neu/funktionen.css +++ b/public/neu/funktionen.css @@ -50,7 +50,10 @@ .kn-fn-zeile-knopf:hover .kn-fn-name { color: var(--kn-accent, #111); text-decoration: underline; text-decoration-color: var(--kn-line-strong, #d8dde3); text-underline-offset: 3px; } .kn-fn-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .kn-fn-name { font-weight: 600; color: var(--kn-text, #1a1f36); overflow-wrap: anywhere; } -.kn-fn-zeile2 { font-size: 13px; line-height: 20px; color: var(--kn-text-2, #5e6678); overflow-wrap: anywhere; } +.kn-fn-zeile2 { font-size: 13px; line-height: 20px; color: var(--kn-text-2, #5e6678); overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } +/* rechts: die Pille (nur wenn die Funktion nicht läuft) und der Pfeil, der die Zeile als antippbar zeigt */ +.kn-fn-rechts { display: flex; align-items: center; flex: none; gap: 12px; } +.kn-fn-pfeil { font-size: 20px; line-height: 20px; color: var(--kn-text-2, #5e6678); } /* ------------------------------------------------------------------------------------------ Entdecken --- */ .kn-fn-eintrag { display: flex; flex-direction: column; gap: 16px; padding: 24px 0; border-top: 1px solid var(--kn-line, #ebeef1); } diff --git a/test/knFunktionenAnsicht.test.js b/test/knFunktionenAnsicht.test.js index bc5c543..d9cfa66 100644 --- a/test/knFunktionenAnsicht.test.js +++ b/test/knFunktionenAnsicht.test.js @@ -31,12 +31,23 @@ function umgebung(fx, { d = daten(), abo, api, post = { feature: { state: 'setup } const tab = (fx, u, text) => fx.alle(u.wurzel, '[role="tab"]').find((t) => t.textContent === text) -test('Meine: Liste mit Name, zweiter Zeile und Pille; Tipp öffnet die Etage mit der Website der Zeile, eine angenommene Anfrage die Bestätigung', mit(async (fx) => { - const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { termine: feature('active'), shop: feature('off', { anfrageStatus: 'angenommen' }) }), projekt('p2', 'Café', { termine: feature('active') })] }) }) +test('Meine: Liste mit Name, Detailzeile und Pfeil, Pille nur ohne aktiven Zustand; Tipp öffnet die Etage mit der Website der Zeile, eine angenommene Anfrage die Bestätigung', mit(async (fx) => { + const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { termine: feature('active', { kurzinfo: '3 Termine heute · nächster 10:30' }), shop: feature('off', { anfrageStatus: 'angenommen' }) }), projekt('p2', 'Café', { termine: feature('active') })] }) }) await u.zeige(MEINE) const zeilen = fx.alle(u.wurzel, 'li[data-fn]') assert.deepEqual(zeilen.map((z) => [z.getAttribute('data-fn'), z.getAttribute('data-projekt')]), [['termine', 'p1'], ['termine', 'p2'], ['shop', 'p1']]) - assert.match(fx.text(zeilen[1]), /Terminplaner.*Café.*Aktiv/) + assert.deepEqual(zeilen.map((z) => [fx.finde(z, '.kn-fn-name').textContent, fx.finde(z, '.kn-fn-zeile2').textContent]), [ + ['Terminplaner', 'Bäckerei · 3 Termine heute · nächster 10:30'], + ['Terminplaner', 'Café · Aktiv'], + ['Webshop', 'Bäckerei · Jetzt einschalten'], + ]) + assert.deepEqual(zeilen.map((z) => fx.finde(z, '.kn-fn-pille')?.textContent ?? null), [null, null, 'Angenommen']) + assert.doesNotMatch(fx.text(u.wurzel), /Aktiv seit/) + for (const z of zeilen) { + const pfeil = fx.finde(z, '.kn-fn-pfeil') + assert.deepEqual([pfeil.textContent, pfeil.getAttribute('aria-hidden')], ['›', 'true']) + assert.equal(fx.finde(z, 'button').lastElementChild.lastElementChild, pfeil, 'der Pfeil steht ganz rechts') + } await fx.klick(fx.finde(zeilen[0], 'button')) await fx.klick(fx.finde(zeilen[1], 'button')) assert.deepEqual(u.gegangen, [['#termine?projekt=p1'], ['#termine?projekt=p2']]) @@ -191,13 +202,24 @@ test('Bildadresse: nur /neu/bilder/.webp; alles andere erscheint ohne Bild } })) -test('Meine mit zwei Websites zeigt den Websitenamen in der zweiten Zeile, mit einer den Status', mit(async (fx) => { - const einzel = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active') })] }) }) +test('Meine: bei einer Website steht die Detailzeile allein, bei mehreren der Websitename davor; ohne Kurzinfo "Aktiv"', mit(async (fx) => { + const einzel = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active', { kurzinfo: '4 Beiträge sichtbar' }), zeiten: feature('active', { kurzinfo: null }) })] }) }) await einzel.zeige(MEINE) - assert.match(fx.text(fx.finde(einzel.wurzel, 'li[data-fn]')), /News-Pinnwand.*Läuft auf deiner Website.*Aktiv/) - const zwei = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active') }), projekt('p2', 'Café')] }) }) + assert.deepEqual(fx.alle(einzel.wurzel, 'li[data-fn] .kn-fn-zeile2').map((e) => e.textContent), ['4 Beiträge sichtbar', 'Aktiv']) + const zwei = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active', { kurzinfo: '4 Beiträge sichtbar' }) }), projekt('p2', 'Café')] }) }) await zwei.zeige(MEINE) - assert.match(fx.text(fx.finde(zwei.wurzel, 'li[data-fn]')), /News-Pinnwand.*Bäckerei.*Aktiv/) + assert.deepEqual(fx.alle(zwei.wurzel, 'li[data-fn] .kn-fn-zeile2').map((e) => e.textContent), ['Bäckerei · 4 Beiträge sichtbar']) +})) + +test('Meine: Pille und Pfeil liegen rechts nebeneinander (Pille zuerst); Namen und Kurzinfo werden nur als Text gesetzt', mit(async (fx) => { + const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Café', { news: feature('active', { kurzinfo: '4 Beiträge' }), termine: feature('setup', { ticketWoid: '100301' }) })] }) }) + await u.zeige(MEINE) + assert.equal(fx.alle(u.wurzel, 'b, u').length, 0) + const [news, termine] = fx.alle(u.wurzel, 'li[data-fn]') + assert.equal(fx.finde(news, '.kn-fn-zeile2').textContent, '4 Beiträge') + const rechts = fx.finde(termine, '.kn-fn-rechts') + assert.deepEqual([...rechts.children].map((k) => k.className), ['kn-fn-pille kn-fn-pille--blau', 'kn-fn-pfeil']) + assert.equal(fx.finde(termine, '.kn-fn-zeile2').textContent, 'Wir richten sie gerade ein') })) /* ---------------------------------------------------------------- Teil 2: Overlay, Erfolgsmeldung, Wechsel --- */ diff --git a/test/knFunktionenModell.test.js b/test/knFunktionenModell.test.js index 574fb13..b870f11 100644 --- a/test/knFunktionenModell.test.js +++ b/test/knFunktionenModell.test.js @@ -3,41 +3,68 @@ import assert from 'node:assert/strict' import { meineZeilen, entdeckenEintraege, zeilenWeg, datumDe } from '../public/neu/ansicht-funktionen.js' import { daten, projekt, feature } from './support/knFunktionenDaten.js' -test('Meine: alles außer off, dazu Entwurf und Anfrage; zurückgezogen zählt nicht', () => { +test('Meine: alles außer off, dazu Entwurf und Anfrage; zurückgezogen zählt nicht; aktiv ohne Pille, mit Kurzinfo', () => { const d = daten({ projekte: [projekt('p1', 'Bäckerei', { - news: feature('active', { activatedAt: '2026-10-05T08:00:00.000Z' }), + news: feature('active', { activatedAt: '2026-10-05T08:00:00.000Z', kurzinfo: '4 Beiträge sichtbar' }), termine: feature('off', { siteKey: 'abcdefghijklmnop' }), shop: feature('off', { anfrageStatus: 'offen' }), unterkunft: feature('off', { anfrageStatus: 'zurueckgezogen' }), zeiten: feature('disabled', { disabledReason: 'abo_ende', loeschungAm: '2026-11-20' }), })] }) const z = meineZeilen(d) - assert.deepEqual(z.map((r) => [r.key, r.pille.text, r.pille.ton]), [['news', 'Aktiv', 'gruen'], ['termine', 'Entwurf', 'grau'], ['shop', 'Angefragt', 'violett'], ['zeiten', 'Abgeschaltet', 'grau']]) - assert.deepEqual(z.map((r) => r.status), ['Aktiv seit 05.10.2026', 'Fragebogen fortsetzen', 'Wir prüfen deine Anfrage', 'Dein Abo ist beendet. Deine Daten bleiben bis 20.11.2026 gespeichert.']) + assert.deepEqual(z.map((r) => [r.key, r.pille ? [r.pille.text, r.pille.ton] : null]), [['news', null], ['termine', ['Entwurf', 'grau']], ['shop', ['Angefragt', 'violett']], ['zeiten', ['Abgeschaltet', 'grau']]]) + assert.deepEqual(z.map((r) => r.status), ['4 Beiträge sichtbar', 'Fragebogen fortsetzen', 'Wir prüfen deine Anfrage', 'Dein Abo ist beendet. Deine Daten bleiben bis 20.11.2026 gespeichert.']) assert.equal(z[0].zeile2, z[0].status) + assert.doesNotMatch(JSON.stringify(z), /Aktiv seit|05\.10\.2026/) }) -test('Status je Zustand: Pille, Ton, Zeile', () => { +test('Status je Zustand: Pille nur wenn nicht aktiv, Ton, Zeile', () => { const zeile = (key, f) => meineZeilen(daten({ projekte: [projekt('p1', 'B', { [key]: f })] })).find((r) => r.key === key) const faelle = [ - ['news', feature('setup', { ticketWoid: '100201' }), 'In Einrichtung', 'blau', 'Einrichtung läuft · Ticket #100201'], - ['news', feature('setup'), 'In Einrichtung', 'blau', 'Einrichtung läuft'], + ['news', feature('setup', { ticketWoid: '100201' }), 'In Einrichtung', 'blau', 'Wir richten sie gerade ein'], + ['news', feature('setup'), 'In Einrichtung', 'blau', 'Wir richten sie gerade ein'], ['news', feature('ordered'), 'Zahlung offen', 'orange', 'Wir warten auf deine Zahlung'], - ['news', feature('active'), 'Aktiv', 'gruen', 'Läuft auf deiner Website'], - ['news', feature('disabled'), 'Abgeschaltet', 'grau', 'Schreib uns im Chat, wenn du die Funktion wieder möchtest.'], + ['news', feature('active'), null, null, 'Aktiv'], + ['news', feature('active', { activatedAt: '2026-10-05T08:00:00.000Z' }), null, null, 'Aktiv'], + ['termine', feature('active', { kurzinfo: '3 Termine heute · nächster 10:30' }), null, null, '3 Termine heute · nächster 10:30'], + ['news', feature('disabled'), 'Abgeschaltet', 'grau', 'Schreib uns im Chat, wenn du sie wieder möchtest'], ['news', feature('disabled', { disabledReason: 'abo_ende' }), 'Abgeschaltet', 'grau', 'Dein Abo ist beendet.'], ['shop', feature('off', { anfrageStatus: 'angenommen' }), 'Angenommen', 'blau', 'Jetzt einschalten'], - ['shop', feature('off', { anfrageStatus: 'abgelehnt' }), 'Abgelehnt', 'rot', 'Schreib uns im Chat'], + ['shop', feature('off', { anfrageStatus: 'abgelehnt' }), 'Abgelehnt', 'rot', 'Anfrage abgelehnt · Im Chat nachfragen'], + ['shop', feature('off', { anfrageStatus: 'offen' }), 'Angefragt', 'violett', 'Wir prüfen deine Anfrage'], ] for (const [key, f, pille, ton, status] of faelle) { const r = zeile(key, f) - assert.deepEqual([r.pille.text, r.pille.ton, r.status], [pille, ton, status], `${key} ${f.state}`) + assert.deepEqual([r.pille?.text ?? null, r.pille?.ton ?? null, r.status], [pille, ton, status], `${key} ${f.state}`) } }) -test('mehrere Websites: eine Zeile je Funktion und Website, zweite Zeile ist der Websitename', () => { - const d = daten({ projekte: [projekt('p1', 'Bäckerei', { termine: feature('active') }), projekt('p2', 'Café', { termine: feature('setup'), news: feature('active') })] }) - assert.deepEqual(meineZeilen(d).map((r) => [r.key, r.projectId, r.zeile2]), [['news', 'p2', 'Café'], ['termine', 'p1', 'Bäckerei'], ['termine', 'p2', 'Café']]) +test('Kurzinfo: nur bei aktiver Funktion, nur als kurzer nicht leerer Text; sonst "Aktiv"', () => { + const status = (f) => meineZeilen(daten({ projekte: [projekt('p1', 'B', { news: f })] })).find((r) => r.key === 'news').status + assert.equal(status(feature('active', { kurzinfo: ' 4 Beiträge sichtbar ' })), '4 Beiträge sichtbar') + for (const k of [null, undefined, '', ' ', 5, true, {}, ['x'], 'x'.repeat(81)]) assert.equal(status(feature('active', { kurzinfo: k })), 'Aktiv', String(JSON.stringify(k))) + assert.equal(status(feature('active', { kurzinfo: 'x'.repeat(80) })), 'x'.repeat(80)) + // ein Text an einer Funktion, die nicht läuft, wird nicht gezeigt (der Server schickt dort null) + assert.equal(status(feature('setup', { kurzinfo: '4 Beiträge sichtbar' })), 'Wir richten sie gerade ein') + assert.equal(status(feature('disabled', { kurzinfo: '4 Beiträge sichtbar' })), 'Schreib uns im Chat, wenn du sie wieder möchtest') +}) + +test('Kurzinfo der Analyse: aktiv mit Text oder "Aktiv", gesperrt bleibt mit Pille', () => { + const zeile = (analyse) => meineZeilen(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).find((r) => r.key === 'analyse') + assert.deepEqual([zeile({ state: 'active' }).pille, zeile({ state: 'active' }).status], [null, 'Aktiv']) + assert.equal(zeile({ state: 'active', kurzinfo: '120 Besuche in 7 Tagen' }).status, '120 Besuche in 7 Tagen') + assert.deepEqual([zeile({ state: 'disabled' }).pille.text, zeile({ state: 'disabled' }).status], ['Gesperrt', 'Schreib uns im Chat']) +}) + +test('mehrere Websites: eine Zeile je Funktion und Website, die zweite Zeile beginnt mit dem Websitenamen', () => { + const d = daten({ projekte: [projekt('p1', 'Bäckerei', { termine: feature('active', { kurzinfo: '3 Termine heute · nächster 10:30' }) }), projekt('p2', 'Café', { termine: feature('setup'), news: feature('active') })] }) + assert.deepEqual(meineZeilen(d).map((r) => [r.key, r.projectId, r.zeile2]), [ + ['news', 'p2', 'Café · Aktiv'], + ['termine', 'p1', 'Bäckerei · 3 Termine heute · nächster 10:30'], + ['termine', 'p2', 'Café · Wir richten sie gerade ein'], + ]) + // der Websitename allein steht nie in der Zeile, der Status bleibt eigenes Feld + assert.deepEqual(meineZeilen(d).map((r) => r.status), ['Aktiv', '3 Termine heute · nächster 10:30', 'Wir richten sie gerade ein']) }) test('Entdecken: Kandidaten sind die Websites ohne die Funktion; eine einzelne Website mit aktiver Funktion blendet sie aus', () => { @@ -56,8 +83,8 @@ test('C9: verfuegbar:false fehlt in Meine und Entdecken; ohne Websites zeigt Ent }) test('Analyse: Zeile nur bei active/disabled; unknown und comingSoon sind kein Kandidat', () => { - const zeilen = (analyse) => meineZeilen(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).filter((r) => r.key === 'analyse').map((r) => r.pille.text) - assert.deepEqual([zeilen({ state: 'active' }), zeilen({ state: 'disabled' }), zeilen({ state: 'off' }), zeilen({ state: 'unknown' }), zeilen({ comingSoon: true })], [['Aktiv'], ['Gesperrt'], [], [], []]) + const zeilen = (analyse) => meineZeilen(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).filter((r) => r.key === 'analyse').map((r) => r.pille?.text ?? 'ohne Pille') + assert.deepEqual([zeilen({ state: 'active' }), zeilen({ state: 'disabled' }), zeilen({ state: 'off' }), zeilen({ state: 'unknown' }), zeilen({ comingSoon: true })], [['ohne Pille'], ['Gesperrt'], [], [], []]) const kand = (analyse) => entdeckenEintraege(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).find((e) => e.eintrag.key === 'analyse')?.kandidaten.length ?? -1 assert.deepEqual([kand({ state: 'off' }), kand({ state: 'unknown' }), kand({ comingSoon: true }), kand({ state: 'active' })], [1, 0, 0, -1]) }) @@ -69,7 +96,7 @@ test('zeilenWeg: angenommene Anfrage geht zur Bestätigung, alles andere in die test('Analyse-Zeilen: Texte, Etage; Abo-Ende mit Löschtag; fehlende Felder brechen nichts', () => { const zeile = (analyse) => meineZeilen(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).find((r) => r.key === 'analyse') - assert.deepEqual([zeile({ state: 'active' }).status, zeile({ state: 'disabled' }).status], ['Läuft auf deiner Website', 'Schreib uns im Chat']) + assert.deepEqual([zeile({ state: 'active' }).status, zeile({ state: 'disabled' }).status], ['Aktiv', 'Schreib uns im Chat']) assert.equal(zeile({ state: 'disabled', disabledReason: 'abo_ende', loeschungAm: '2026-12-01' }).status, 'Dein Abo ist beendet. Deine Daten bleiben bis 01.12.2026 gespeichert.') assert.deepEqual(zeilenWeg(zeile({ state: 'active' })), { art: 'etage', etage: 'analyse' }) const kaputt = { modell: 'abo', katalog: [], projects: [{ projectId: 'p1' }] }