diff --git a/src/components/workers/BotLeadList.jsx b/src/components/workers/BotLeadList.jsx index c8d3165..2effb3c 100644 --- a/src/components/workers/BotLeadList.jsx +++ b/src/components/workers/BotLeadList.jsx @@ -1,16 +1,19 @@ import { useEffect, useRef, useState } from 'react' import EmptyState from '../ui/EmptyState' import { ROOM_FILTERS, botleadRow, pageInfo } from '../../lib/botleadsView' +import { restoreFocus } from '../../lib/workersFocus' const SEARCH_DELAY_MS = 300 // so lange wartet die Suche nach dem letzten Tastendruck, bevor sie den Dienst fragt const SEARCH_MAX = 100 // der Dienst kürzt die Suche auf 100 Zeichen const UNKNOWN_ERROR = 'Unbekannter Fehler' const COLUMNS = ['Betrieb', 'Raum', 'Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden'] const START_VIEW = Object.freeze({ room: '', q: '', page: 1 }) // Raumfilter, geltende Suche, Seite: ein Wert, damit eine Änderung genau eine Anfrage auslöst +const browserDocument = () => (typeof document === 'undefined' ? null : document) // unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet // Reiter „Bot-Leads“: Betriebe, die nicht angeschrieben werden (z. B. keine veröffentlichte Mail), mit Grund. Reine Liste, nichts wird gesendet oder angerufen. // Die Liste holt sich ihre Daten bei jeder Änderung von Raum, Suche, Seite oder bot selbst; es gibt kein Polling. Nur die jüngste Anfrage zählt: eine späte -// Antwort (auch ein später Fehler) einer älteren Anfrage oder nach dem Abbau wird verworfen. +// Antwort (auch ein später Fehler) einer älteren Anfrage oder nach dem Abbau wird verworfen. Scheitert eine Anfrage, steht der Satz des Dienstes mit „Erneut versuchen“, +// das genau diese Anfrage (Raum, Suche, Seite oder Eintrag) noch einmal stellt; Raum und Suche gehen weiterhin. // bot (aus ?bot= der Adresse, Link aus dem Ereignisstrom): zeigt genau diesen Eintrag, hervorgehoben, mit „Alle anzeigen“ (onClearBot nimmt bot aus der Adresse). // Alle Texte der Zeilen sind Textknoten; Links entstehen nur aus row.sourceHref (botleadRow prüft die Adresse mit safeHref: nur http und https, keine Zugangsdaten). export default function BotLeadList({ api, bot, onClearBot }) { @@ -19,6 +22,7 @@ export default function BotLeadList({ api, bot, onClearBot }) { const [view, setView] = useState(START_VIEW) const [draft, setDraft] = useState('') // Inhalt des Suchfelds; in view.q steht erst die Suche, die nach der Verzögerung gilt const [result, setResult] = useState({ key: null, items: [], total: 0, pageSize: undefined, error: null }) + const [attempt, setAttempt] = useState(0) // zählt „Erneut versuchen“: gehört zum Schlüssel, damit dieselbe Anfrage noch einmal läuft und „Wird geladen“ zeigt const timerRef = useRef(null) const seqRef = useRef(0) // Tastaturfokus: Wer „Alle anzeigen“ oder „Zurück“/„Weiter“ drückt, verliert den Knopf mit dem Fokus (Einzelansicht und Seitenzeile werden ausgetauscht oder @@ -27,9 +31,9 @@ export default function BotLeadList({ api, bot, onClearBot }) { const clearedRef = useRef(false) // „Alle anzeigen“ gedrückt: erst dann (nie schon beim Einhängen, das öffnete am Handy die Auswahl) bekommt der Raumfilter den Fokus const prevRef = useRef(null) const nextRef = useRef(null) - const pagedRef = useRef('') // 'prev' oder 'next': welcher Blätterknopf zuletzt gedrückt wurde; nach dem Laden bekommt die Seitenzeile den Fokus zurück + const pagedRef = useRef('') // 'prev' oder 'next': welcher Blätterknopf zuletzt gedrückt wurde; nach dem Laden bekommt die Seitenzeile den Fokus zurück (auch nach „Erneut versuchen“) - const key = JSON.stringify(single ? [botId] : [view.room, view.q, view.page]) + const key = JSON.stringify(single ? [botId, attempt] : [view.room, view.q, view.page, attempt]) useEffect(() => { seqRef.current += 1 @@ -60,6 +64,7 @@ export default function BotLeadList({ api, bot, onClearBot }) { useEffect(() => () => window.clearTimeout(timerRef.current), []) + const onRetry = () => setAttempt((n) => n + 1) const onClear = () => { clearedRef.current = true onClearBot() @@ -92,16 +97,16 @@ export default function BotLeadList({ api, bot, onClearBot }) { }, [single]) // Die neue Seite ist da und die Seitenzeile wieder eingehängt: der Fokus geht an den gedrückten Knopf, ist er gesperrt (erste oder letzte Seite) an den anderen. - // Scheiterte die Anfrage, gibt es keine Seitenzeile (die Refs sind null) und nichts geschieht. + // Nur wenn er wirklich verloren ging (body, nirgends, der alte Knopf ist ausgehängt): wer inzwischen selbst woandershin gegangen ist (Suchfeld, Raumfilter), wird + // nicht gestört. Ohne preventScroll spränge die Seite ans Ende der neuen Seite, denn die Seitenzeile steht unter der Tabelle (restoreFocus in workersFocus.js). + // Scheiterte die Anfrage, gibt es keine Seitenzeile; die Merkung bleibt dann für „Erneut versuchen“ stehen (Raum, Suche und neues Blättern setzen sie neu oder zurück). useEffect(() => { - if (loading) return + if (loading || result.error) return const dir = pagedRef.current pagedRef.current = '' if (dir === '') return - const [first, second] = dir === 'next' ? [nextRef.current, prevRef.current] : [prevRef.current, nextRef.current] - const target = first && !first.disabled ? first : second - target?.focus() - }, [loading]) + restoreFocus(browserDocument(), dir === 'next' ? [nextRef.current, prevRef.current] : [prevRef.current, nextRef.current]) + }, [loading, result.error]) const rows = result.items.map((item) => botleadRow(item)) const info = pageInfo({ total: result.total, page: view.page, pageSize: result.pageSize }) @@ -111,7 +116,14 @@ export default function BotLeadList({ api, bot, onClearBot }) { if (loading) { body =
Wird geladen …
} else if (result.error) { - body =
{result.error}
+ body = ( + <> +
{result.error}
+
+ +
+ + ) } else if (rows.length === 0) { if (single) body = else if (filtered) body = diff --git a/src/styles/workers.css b/src/styles/workers.css index 00546bd..79c555f 100644 --- a/src/styles/workers.css +++ b/src/styles/workers.css @@ -264,6 +264,8 @@ color: var(--grey-fg); } .wk-bl-pager { display: flex; gap: 8px; } +/* „Erneut versuchen“ unter der Fehlermeldung (die Meldung selbst hat keinen Abstand nach unten) */ +.wk-bl-retry { margin-top: 8px; } /* ---------- Schmale Bildschirme ---------- */ @@ -293,10 +295,11 @@ Der Dialog hängt nicht unter einer wk-Klasse, die Regel beginnt deshalb mit der Klasse des Knopfs. Mit (0,3,0) (Klasse, Klasse, Attribut) gewinnt sie gegen (0,2,0), gleich in welcher Reihenfolge die Dateien geladen werden. */ .wk-prospect-close.btn[type="button"] { width: 100%; min-height: 40px; justify-content: center; } - /* Bot-Leads: Blätterknöpfe und „Alle anzeigen“ der Einzelansicht mit 40 px Tippfläche wie die Knöpfe des Pults; min-height schlägt height: 32px von .btn, und mit + /* Bot-Leads: Blätterknöpfe, „Alle anzeigen“ der Einzelansicht und „Erneut versuchen“ mit 40 px Tippfläche wie die Knöpfe des Pults; min-height schlägt height: 32px von .btn, und mit zwei Klassen (0,2,0) gegen (0,1,0) braucht es kein !important. Keine globale .btn-Regel: Dialoge und andere Seiten behalten ihre Höhe. Die Zeile darf umbrechen */ .wk-bl-pager .btn { min-height: 40px; } .wk-bl-single .btn { min-height: 40px; } + .wk-bl-retry .btn { min-height: 40px; } .wk-bl .table-foot { flex-wrap: wrap; gap: 8px 16px; } } diff --git a/tests/unit/botleadList.test.mjs b/tests/unit/botleadList.test.mjs index d1814db..8fe865e 100644 --- a/tests/unit/botleadList.test.mjs +++ b/tests/unit/botleadList.test.mjs @@ -36,20 +36,47 @@ function fakeApi() { return { calls, botleads: (params) => new Promise((resolve, reject) => { calls.push({ params, resolve, reject }) }) } } -// DOM-Attrappe für den Fokus (Muster workersPultUi.test.mjs): ref an select und den Blätterknöpfen liefert __fakeRef. Ein Element zählt den Aufruf von focus() nur, -// solange es im Baum steht; React setzt ref.current beim Abbau auf null, dann wäre ref.current?.focus() wirkungslos. Der Schlüssel ist „select“ oder die Beschriftung des Knopfs. +// DOM-Attrappe für den Fokus (Muster workersPultUi.test.mjs): document mit body und activeElement, ref an select und den Blätterknöpfen liefert __fakeRef. +// Der Schlüssel ist „select“ oder die Beschriftung des Knopfs. Ein Element gilt nur, solange es im Baum steht (isConnected, wie im Browser); steht es beim +// nächsten Render nicht im Baum der Runde davor (Seitenzeile während des Ladens), ist es danach ein neues Element, so wie React die Knöpfe neu einhängt. +// focus() zählt nur für ein eingehängtes Element, merkt sich Schlüssel und Argument und setzt activeElement (Fokus liegt dann auf dem Element). function fakeFocus() { - const f = { focused: [], tree: () => null, made: new Map() } + const body = { name: 'body' } + const doc = { body, documentElement: { name: 'html' }, activeElement: body } + const f = { doc, body, focused: [], options: [], tree: () => null, view: null, made: new Map() } const keyOf = (tag, props) => (tag === 'button' ? String(props.children) : tag) const live = (tag, key) => nodes(f.tree(), (n) => n.tag === tag && keyOf(tag, n.props) === key).length > 0 + const had = Object.hasOwn(globalThis, 'document') + const saved = globalThis.document + globalThis.document = doc globalThis.__fakeRef = (tag, props) => { const key = keyOf(tag, props) - if (!f.made.has(key)) f.made.set(key, { key, props, get disabled() { return this.props.disabled === true }, focus() { if (live(tag, key)) f.focused.push(key) } }) + const known = f.made.get(key) + if (!known || !live(tag, key)) { // während dieser Runde zeigt f.tree() noch die Runde davor + f.made.set(key, { + key, props, + get disabled() { return this.props.disabled === true }, + get isConnected() { return f.made.get(key) === this && live(tag, key) }, + focus(options) { if (this.isConnected) { f.focused.push(key); f.options.push(options); doc.activeElement = this } }, + }) + } f.made.get(key).props = props return f.made.get(key) } - f.attach = (view) => { f.tree = () => view.tree() } - f.restore = () => { delete globalThis.__fakeRef } + // ein Element, das der Nutzer von Hand ansteuert (Suchfeld, Link …): eingehängt, bedienbar + f.other = (name) => ({ name, isConnected: true, disabled: false, focus() { doc.activeElement = this } }) + f.attach = (view) => { f.view = view; f.tree = () => view.tree() } + // Der Nutzer drückt einen Knopf: der Browser gibt ihm den Fokus, dann läuft der Klick + // (Knöpfe ohne ref, z. B. „Erneut versuchen“, sind nur so lange Element, wie ein Knopf dieser Beschriftung im Baum steht) + f.press = async (label) => { + doc.activeElement = f.made.get(label) ?? { name: label, disabled: false, get isConnected() { return buttons(f.tree(), label).length > 0 }, focus() { doc.activeElement = this } } + await f.view.click(label) + } + f.restore = () => { + if (had) globalThis.document = saved + else delete globalThis.document + delete globalThis.__fakeRef + } return f } @@ -344,7 +371,7 @@ test('nach dem Abbau setzt eine späte Antwort nichts mehr, ein offener Zeitgebe assert.equal(ui.lateUpdates(), 0) }) -test('Blättern (C5): nach dem Laden der neuen Seite bekommt die Seitenzeile den Tastaturfokus zurück; ist der Knopf gesperrt, der andere', async () => { +test('Blättern (C5): nach dem Laden der neuen Seite bekommt die Seitenzeile den Tastaturfokus zurück, ohne dass die Seite scrollt (N5); ist der Knopf gesperrt, der andere', async () => { const api = fakeApi() const dom = fakeFocus() const ui = await start(api) @@ -352,19 +379,63 @@ test('Blättern (C5): nach dem Laden der neuen Seite bekommt die Seitenzeile den try { await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) assert.deepEqual(dom.focused, []) // nicht schon beim ersten Laden - await ui.click('Weiter') + await dom.press('Weiter') assert.deepEqual(dom.focused, []) // während der Anfrage ist die Seitenzeile weg + assert.equal(dom.doc.activeElement.isConnected, false) // und mit ihr der Knopf, der den Fokus hatte: der Fokus ist verloren await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 2 von 3 assert.deepEqual(dom.focused, ['Weiter']) - await ui.click('Weiter') + await dom.press('Weiter') await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 3 von 3: „Weiter“ ist gesperrt ⇒ „Zurück“ assert.deepEqual(dom.focused, ['Weiter', 'Zurück']) - await ui.click('Zurück') + await dom.press('Zurück') await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 2 assert.deepEqual(dom.focused, ['Weiter', 'Zurück', 'Zurück']) - await ui.click('Zurück') + await dom.press('Zurück') await ui.answer(4, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 1: „Zurück“ ist gesperrt ⇒ „Weiter“ assert.deepEqual(dom.focused, ['Weiter', 'Zurück', 'Zurück', 'Weiter']) + // N5: ohne { preventScroll: true } sprängen Chromium und Firefox ans Ende der neuen Seite (der Knopf steht unter der Tabelle) + assert.deepEqual(dom.options, [{ preventScroll: true }, { preventScroll: true }, { preventScroll: true }, { preventScroll: true }]) + } finally { dom.restore(); ui.end() } +}) + +test('Blättern (N5): hat der Nutzer den Fokus inzwischen selbst woandershin gesetzt, wird er nicht gestohlen; liegt er auf body oder nirgends, kommt er zurück', async () => { + const api = fakeApi() + const dom = fakeFocus() + const ui = await start(api) + dom.attach(ui) + try { + await ui.answer(0, { items: [ITEM], total: 400, pageSize: 50 }) + // 1. Der Nutzer klickt während des Ladens ins Suchfeld (Fokus dort, aber die Zeitgeber der Suche laufen noch nicht) + await dom.press('Weiter') + const search = dom.other('Suchfeld') + search.focus() + await ui.answer(1, { items: [ITEM], total: 400, pageSize: 50 }) + assert.deepEqual(dom.focused, []) + assert.equal(dom.doc.activeElement, search) + // 2. … oder er wählt den Raumfilter mit der Tastatur an, ohne etwas zu ändern + await dom.press('Weiter') + dom.made.get('select').focus() + assert.deepEqual(dom.focused, ['select']) + await ui.answer(2, { items: [ITEM], total: 400, pageSize: 50 }) + assert.deepEqual(dom.focused, ['select']) // kein weiterer Fokus auf die Seitenzeile + assert.equal(dom.doc.activeElement.key, 'select') + // 3. Fokus auf body: zurück an die Seitenzeile + dom.focused.length = 0; dom.options.length = 0 + await dom.press('Weiter') + dom.doc.activeElement = dom.body + await ui.answer(3, { items: [ITEM], total: 400, pageSize: 50 }) + assert.deepEqual(dom.focused, ['Weiter']) + assert.deepEqual(dom.options, [{ preventScroll: true }]) + // 4. Fokus auf nichts (activeElement null, etwa ein Dokument ohne Fokus): ebenfalls zurück + await dom.press('Weiter') + dom.doc.activeElement = null + await ui.answer(4, { items: [ITEM], total: 400, pageSize: 50 }) + assert.deepEqual(dom.focused, ['Weiter', 'Weiter']) + // 5. ohne document (Server, Node): nichts geschieht, nichts bricht + delete globalThis.document + await ui.click('Weiter') + await ui.answer(5, { items: [ITEM], total: 400, pageSize: 50 }) + assert.deepEqual(dom.focused, ['Weiter', 'Weiter']) } finally { dom.restore(); ui.end() } }) @@ -398,6 +469,103 @@ test('Blättern (C5): der Fokus springt nicht, wenn der Nutzer inzwischen Raum o } finally { dom.restore(); ui.end() } }) +test('Fehler beim Blättern (N6): „Erneut versuchen“ wiederholt genau die Seitenanfrage; Fehler und Knopf verschwinden, die Seitenzeile ist wieder da', async () => { + const api = fakeApi() + const ui = await start(api) + try { + await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) + await ui.click('Weiter') + assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 }) + assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0) // beim Laden gibt es keinen Knopf + await ui.fail(1, Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })) + assert.deepEqual(ui.errors(), [{ text: 'Der Hustler-Dienst ist gerade nicht erreichbar.', role: 'alert' }]) // der Satz bleibt unverändert im Alarm + const retry = buttons(ui.tree(), 'Erneut versuchen') + assert.equal(retry.length, 1) + assert.equal(retry[0].props.type, 'button') + assert.equal(retry[0].props.disabled, undefined) + assert.equal(api.calls.length, 2) + await ui.click('Erneut versuchen') + assert.equal(api.calls.length, 3) // genau eine neue Anfrage, dieselbe Seite + assert.deepEqual(api.calls[2].params, { room: '', q: '', page: 2 }) + assert.ok(ui.text().includes('Wird geladen')) + assert.deepEqual(ui.errors(), []) + assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0) + await ui.answer(2, { items: [PRAXIS], total: 120, pageSize: 50 }) + assert.equal(api.calls.length, 3) + assert.deepEqual(ui.errors(), []) + assert.deepEqual(ui.rows().map((r) => ui.cells(r)[0]), ['Praxis Dr. Muster67655 Kaiserslautern']) + assert.ok(ui.text().includes('Seite 2 von 3 · 120 Einträge')) + assert.deepEqual([ui.disabled('Zurück'), ui.disabled('Weiter')], [[false], [false]]) + assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0) + } finally { ui.end() } +}) + +test('Fehler beim Blättern (N6): scheitert auch der zweite Versuch, bleibt der Knopf; Raumfilter und Suche gehen weiterhin, mit Seite 1', async () => { + const api = fakeApi() + const ui = await start(api) + try { + await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) + await ui.click('Weiter') + await ui.fail(1, new Error('zu langsam')) + await ui.click('Erneut versuchen') + await ui.fail(2, new Error('immer noch nicht')) + assert.deepEqual(ui.errors().map((e) => e.text), ['immer noch nicht']) + assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 1) + await ui.click('Erneut versuchen') + assert.deepEqual(api.calls[3].params, { room: '', q: '', page: 2 }) + // statt eines weiteren Versuchs ändert der Nutzer den Raum: neue Anfrage mit Seite 1, die späte Antwort des Versuchs zählt nicht + await ui.pick('handwerk') + assert.deepEqual(api.calls[4].params, { room: 'handwerk', q: '', page: 1 }) + await ui.answer(3, { items: [PRAXIS], total: 120, pageSize: 50 }) + assert.equal(ui.rows().length, 0) + await ui.answer(4, { items: [ITEM], total: 1, pageSize: 50 }) + assert.deepEqual(ui.errors(), []) + assert.deepEqual(ui.rows().map((r) => ui.cells(r)[0]), ['Elektro Beispiel GmbH67655 Kaiserslautern']) + assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0) + } finally { ui.end() } +}) + +test('Fehler beim Blättern (N6): „Erneut versuchen“ gibt es auch beim ersten Laden und in der Einzelansicht und wiederholt dort die jeweilige Anfrage', async () => { + const api = fakeApi() + const ui = await start(api) + try { + await ui.fail(0, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.')) + await ui.click('Erneut versuchen') + assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 1 }) + await ui.answer(1, { items: [ITEM], total: 1, pageSize: 50 }) + assert.equal(ui.rows().length, 1) + } finally { ui.end() } + const api2 = fakeApi() + const one = await start(api2, { bot: 'p1', onClearBot: () => {} }) + try { + await one.fail(0, Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })) + assert.equal(buttons(one.tree(), 'Alle anzeigen').length, 1) // der Weg zurück bleibt offen + await one.click('Erneut versuchen') + assert.deepEqual(api2.calls.map((c) => c.params), [{ id: 'p1' }, { id: 'p1' }]) + await one.answer(1, { items: [ITEM], total: 1 }) + assert.equal(one.rows().length, 1) + assert.equal(one.rows()[0].props['aria-current'], 'true') + } finally { one.end() } +}) + +test('Fehler beim Blättern (N6): nach einem gelungenen Wiederholen bekommt die Seitenzeile den Fokus zurück (ohne Scrollen), wenn zuvor geblättert wurde', async () => { + const api = fakeApi() + const dom = fakeFocus() + const ui = await start(api) + dom.attach(ui) + try { + await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 }) + await dom.press('Weiter') + await ui.fail(1, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.')) + assert.deepEqual(dom.focused, []) // keine Seitenzeile, nichts zu fokussieren + await dom.press('Erneut versuchen') // der Knopf verschwindet beim Laden wie vorher die Seitenzeile + assert.deepEqual(dom.focused, []) + await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 }) + assert.deepEqual(dom.focused, ['Weiter']) + assert.deepEqual(dom.options, [{ preventScroll: true }]) + } finally { dom.restore(); ui.end() } +}) + test('Alle anzeigen (W1): nach dem Verlassen der Einzelansicht bekommt der Raumfilter den Fokus, nicht beim Einhängen und nicht bei einem anderen Weg zur Liste', async () => { const api = fakeApi() const dom = fakeFocus() diff --git a/tests/unit/workersCss.test.mjs b/tests/unit/workersCss.test.mjs index 987a806..59109a3 100644 --- a/tests/unit/workersCss.test.mjs +++ b/tests/unit/workersCss.test.mjs @@ -72,10 +72,10 @@ test('workers.css: gekürzte Zeilen der Bot-Leads bleiben lesbar (Text >= 4,5:1 } }) -test('workers.css: am Handy sind die Blätterknöpfe und „Alle anzeigen“ der Bot-Leads 40 px hoch, ohne die globale .btn-Regel zu ändern (C5, W1)', () => { +test('workers.css: am Handy sind die Blätterknöpfe, „Alle anzeigen“ und „Erneut versuchen“ der Bot-Leads 40 px hoch, ohne die globale .btn-Regel zu ändern (C5, W1)', () => { const css = stripped('workers.css') const phone = css.slice(css.indexOf('@media (max-width: 860px)')) - for (const selector of ['.wk-bl-pager .btn', '.wk-bl-single .btn']) { + for (const selector of ['.wk-bl-pager .btn', '.wk-bl-single .btn', '.wk-bl-retry .btn']) { assert.equal(decl(body(phone, selector), 'min-height'), '40px', selector) // min-height schlägt height: 32px der globalen .btn-Regel, und die Spezifität (0,2,0) schlägt .btn (0,1,0): kein !important nötig assert.ok(beats(specificity(selector), specificity('.btn')), selector)