From 0bcdfe258d663a090cbfa7db8d24b08cc0cc700c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:31:38 +0000 Subject: [PATCH] =?UTF-8?q?fix(shell):=20geschlossenes=20Spenden-Banner=20?= =?UTF-8?q?kommt=20beim=20Sprachwechsel=20auch=20ohne=20localStorage=20nic?= =?UTF-8?q?ht=20zur=C3=BCck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Banner merkte sich „geschlossen“ nur in localStorage. Bei gesperrtem Speicher (privates Fenster, gesperrte Website-Daten) baute der Neuaufbau des Rahmens beim Sprachwechsel (main.ts) es wieder auf. - donate-banner.ts: zusätzlich ein Merker im Speicher des Moduls (closedThisSession); er gilt neben localStorage. - Tests: donate-banner.test.ts lädt je Test eine frische Modulkopie (der Merker lebt im Modul); neue Fälle: gesperrter Speicher, erneutes Einhängen nach dem Schließen (bleibt weg) und ohne Schließen (kommt wieder); main.test.ts: Sprachwechsel nach dem Schließen bei gesperrtem Speicher. Die beiden schon vorhandenen Fälle zum gesperrten Speicher sperren jetzt wirklich (vi.stubGlobal): vi.spyOn(Storage.prototype, …) greift in happy-dom nicht, die Fälle waren leer. Co-Authored-By: Claude Opus 5.5 --- web/src/main.test.ts | 15 +++++++ web/src/shell/donate-banner.test.ts | 61 ++++++++++++++++++++++++----- web/src/shell/donate-banner.ts | 9 ++++- 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/web/src/main.test.ts b/web/src/main.test.ts index 7009de5..83ac183 100644 --- a/web/src/main.test.ts +++ b/web/src/main.test.ts @@ -133,6 +133,21 @@ describe('Sprachwechsel', () => { expect(storeAfter.get().cardName).toBe('Mein Entwurf'); }); + it('ein geschlossenes Spenden-Banner kommt beim Sprachwechsel nicht zurück, auch wenn der Speicher gesperrt ist', async () => { + await start('/'); + const denied = () => { + throw new DOMException('blockiert', 'SecurityError'); + }; + vi.stubGlobal('localStorage', { getItem: denied, setItem: denied }); + document.querySelector('.donate-banner-close')!.click(); + expect(document.querySelector('.donate-banner')).toBeNull(); + + langButton().click(); + + expect(document.documentElement.lang).toBe('en'); + expect(document.querySelector('.donate-banner')).toBeNull(); + }); + it('das Konto-Menü wird neu gebaut und zeigt sich sofort, wenn der Anmeldestatus schon bekannt ist', async () => { await start('/'); await vi.waitFor(() => expect(signIn()).toBeDefined()); diff --git a/web/src/shell/donate-banner.test.ts b/web/src/shell/donate-banner.test.ts index 12d9c07..580bd6a 100644 --- a/web/src/shell/donate-banner.test.ts +++ b/web/src/shell/donate-banner.test.ts @@ -1,14 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { setLang } from '../i18n/i18n'; -import { mountDonateBanner } from './donate-banner'; const KEY = 'aircard.donateBannerClosed'; const URL_ = 'https://buymeacoffee.com/aircard'; const TEXT = '☕ Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.'; +// Das Modul merkt sich im Speicher, dass das Banner geschlossen wurde (siehe unten): jeder Test bekommt darum eine frische Kopie von ihm +// und von i18n.ts (die Sprache wird an dieser Kopie gesetzt). +let mountDonateBanner: typeof import('./donate-banner').mountDonateBanner; +let setLang: typeof import('../i18n/i18n').setLang; let slot: HTMLElement; -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); + ({ mountDonateBanner } = await import('./donate-banner')); + ({ setLang } = await import('../i18n/i18n')); localStorage.clear(); document.body.innerHTML = ''; slot = document.getElementById('donate-banner') as HTMLElement; @@ -16,8 +21,26 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); +/** + * Ein Speicher, der den Zugriff verweigert (privates Fenster, gesperrte Website-Daten). `vi.spyOn(Storage.prototype, …)` greift in + * happy-dom nicht, darum wird `localStorage` selbst ersetzt. + */ +function blockStorage(block: { read?: boolean; write?: boolean }): void { + const denied = () => new DOMException('blockiert', 'SecurityError'); + vi.stubGlobal('localStorage', { + getItem: () => { + if (block.read) throw denied(); + return null; + }, + setItem: () => { + if (block.write) throw denied(); + }, + }); +} + describe('mountDonateBanner', () => { it('leere URL: nichts wird gerendert', () => { mountDonateBanner(slot, ''); @@ -68,19 +91,39 @@ describe('mountDonateBanner', () => { }); it('localStorage wirft beim Lesen: Banner wird gezeigt', () => { - vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { - throw new DOMException('blockiert', 'SecurityError'); - }); + blockStorage({ read: true }); expect(() => mountDonateBanner(slot, URL_)).not.toThrow(); expect(slot.querySelectorAll('.donate-banner')).toHaveLength(1); }); it('localStorage wirft beim Schreiben: Banner verschwindet trotzdem, kein Fehler', () => { - vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new DOMException('voll', 'QuotaExceededError'); - }); + blockStorage({ write: true }); mountDonateBanner(slot, URL_); expect(() => slot.querySelector('button')!.click()).not.toThrow(); expect(slot.childElementCount).toBe(0); }); + + it('localStorage gesperrt: das geschlossene Banner kommt beim erneuten Einhängen (Sprachwechsel) nicht zurück', () => { + blockStorage({ read: true, write: true }); + mountDonateBanner(slot, URL_); + slot.querySelector('button')!.click(); + expect(slot.childElementCount).toBe(0); + mountDonateBanner(slot, URL_); // so baut main.ts den Rahmen bei einem Sprachwechsel neu auf + expect(slot.childElementCount).toBe(0); + }); + + it('ohne Schließen kommt das Banner beim erneuten Einhängen wieder, auch bei gesperrtem Speicher', () => { + blockStorage({ read: true, write: true }); + mountDonateBanner(slot, URL_); + mountDonateBanner(slot, URL_); // neu aufgebaut, ohne dass jemand es geschlossen hat + expect(slot.querySelectorAll('.donate-banner')).toHaveLength(1); + }); + + it('der Speicher lässt sich schreiben, aber nicht lesen: nach dem Schließen bleibt das Banner ebenfalls weg', () => { + blockStorage({ read: true }); + mountDonateBanner(slot, URL_); + slot.querySelector('button')!.click(); + mountDonateBanner(slot, URL_); + expect(slot.childElementCount).toBe(0); + }); }); diff --git a/web/src/shell/donate-banner.ts b/web/src/shell/donate-banner.ts index 6f7ee07..038fbd2 100644 --- a/web/src/shell/donate-banner.ts +++ b/web/src/shell/donate-banner.ts @@ -5,19 +5,24 @@ import { t } from '../i18n/i18n'; const CLOSED_KEY = 'aircard.donateBannerClosed'; +// Auch im Speicher des Moduls gemerkt: bei gesperrtem localStorage bringt sonst der Neuaufbau des Rahmens (Sprachwechsel) das Banner zurück. +let closedThisSession = false; + function isClosed(): boolean { + if (closedThisSession) return true; try { return localStorage.getItem(CLOSED_KEY) !== null; } catch { - return false; // Speicher gesperrt: das Banner bleibt sichtbar, bis die Seite neu geladen wird + return false; // Speicher gesperrt: das Banner bleibt sichtbar, bis es jemand schließt } } function rememberClosed(): void { + closedThisSession = true; try { localStorage.setItem(CLOSED_KEY, '1'); } catch { - // gilt dann nur für diese Sitzung + // gilt dann nur, solange die Seite offen ist } }