From 27b6d58632b1210705f6d7188e5410aa5ead505c Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 3 Jun 2026 10:32:00 +0200 Subject: [PATCH] test(notification): make setNotifications authoritative in bell a11y tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI showed the single/many a11y tests failing with count 0: init()'s async fetchUnreadCount resolved to {count:0} AFTER setNotifications() ran, clobbering the seeded count (the flake Sara predicted in review). Stub fetch to never settle so the announced count is driven solely by setNotifications — deterministic, no race. Also rewrites the 'error' test to seed a count then fail the load and assert the count SURVIVES, so it is a meaningful state distinct from 'empty' (was byte-identical, flagged by Felix/Sara/Leonie). Part of #560. Co-Authored-By: Claude Opus 4.8 --- .../NotificationBell.svelte.spec.ts | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/notification/NotificationBell.svelte.spec.ts b/frontend/src/lib/notification/NotificationBell.svelte.spec.ts index 28c09577..6b406258 100644 --- a/frontend/src/lib/notification/NotificationBell.svelte.spec.ts +++ b/frontend/src/lib/notification/NotificationBell.svelte.spec.ts @@ -30,11 +30,12 @@ class NoopEventSource { beforeEach(() => { vi.stubGlobal('EventSource', NoopEventSource); + // init()'s fetchUnreadCount() never settles, so the bell's announced count is + // driven solely by setNotifications() — removes the init-fetch-vs-setNotifications + // race (the real fetch would resolve to {count:0} and clobber the seeded count). vi.stubGlobal( 'fetch', - vi - .fn() - .mockResolvedValue(new Response(JSON.stringify({ count: 0, content: [] }), { status: 200 })) + vi.fn(() => new Promise(() => {})) ); }); @@ -89,7 +90,6 @@ describe('NotificationBell — rendering', () => { describe('NotificationBell — announced unread count across a11y states', () => { it('empty: announces no unread count and hides the live badge', async () => { const { setNotifications } = renderBell(); - await tick(); setNotifications([]); await tick(); @@ -101,7 +101,6 @@ describe('NotificationBell — announced unread count across a11y states', () => it('single: announces one unread notification', async () => { const { setNotifications } = renderBell(); - await tick(); setNotifications([makeNotification({ id: 'n1', read: false })]); await tick(); @@ -114,7 +113,6 @@ describe('NotificationBell — announced unread count across a11y states', () => it('many: announces the exact unread count', async () => { const { setNotifications } = renderBell(); - await tick(); setNotifications([ makeNotification({ id: 'n1', read: false }), makeNotification({ id: 'n2', read: false }), @@ -128,16 +126,21 @@ describe('NotificationBell — announced unread count across a11y states', () => expect(unreadBadge().textContent?.trim()).toBe('3'); }); - it('error: degrades to a zero announced count when the unread-count load fails', async () => { + it('error: a failed unread-count load does not wipe the announced count', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down'))); - renderBell(); + const { setNotifications } = renderBell(); + setNotifications([ + makeNotification({ id: 'n1', read: false }), + makeNotification({ id: 'n2', read: false }) + ]); await tick(); - // A failed load leaves the count at 0; the bell still announces a valid, - // non-urgent state rather than a broken count. + // init()'s fetchUnreadCount rejects; its catch must leave the already-set + // count intact rather than silently zeroing the live region. This is a + // distinct state from "empty" — the count survived a transient load failure. const btn = bellButton(); - expect(btn.getAttribute('title')).toBe(m.notification_bell_label()); + expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 2 })); expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title')); - expect(unreadBadge().classList.contains('hidden')).toBe(true); + expect(unreadBadge().textContent?.trim()).toBe('2'); }); });