From 7ba5d321f6aaef3b9c5544e86c7f0dbf0455e6af Mon Sep 17 00:00:00 2001 From: Logan Date: Fri, 21 Aug 2026 10:47:13 -0400 Subject: [PATCH] test(queue): pin the breakpoint listener the scrim rule rests on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scrim's existence comes from matchMedia rather than a stylesheet, which only holds if the query is listened to — and the stub's addEventListener was a no-op, so deleting the listener left all 986 tests green. The stub records its listeners now and the new case carries a panel across the breakpoint in both directions. Watched failing with the listener removed. --- .../components/queue-overlay-mode.test.ts | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/frontend/test/components/queue-overlay-mode.test.ts b/frontend/test/components/queue-overlay-mode.test.ts index f0c556d..8bddc2e 100644 --- a/frontend/test/components/queue-overlay-mode.test.ts +++ b/frontend/test/components/queue-overlay-mode.test.ts @@ -39,22 +39,45 @@ let restoreMedia: (() => void) | null = null; * reads `matchMedia` in `connectedCallback` precisely so a test can * answer it first. */ -function stubPhone(phone: boolean): void { +/** + * Answering the phone query is not enough on its own: what decides + * whether the scrim exists is a `change` listener, and a stub whose + * `addEventListener` is a no-op leaves that listener untested — the + * whole suite stays green with it deleted. So the stub records the + * listeners and hands back a way to fire them. + */ +function stubPhone(phone: boolean): (next: boolean) => void { const real = window.matchMedia.bind(window); + const listeners = new Set<(e: MediaQueryListEvent) => void>(); + let matches = phone; window.matchMedia = ((q: string) => q.includes('max-width: 599px') ? { - matches: phone, + get matches() { + return matches; + }, media: q, - addEventListener() {}, - removeEventListener() {}, + addEventListener(_: string, fn: (e: MediaQueryListEvent) => void) { + listeners.add(fn); + }, + removeEventListener(_: string, fn: (e: MediaQueryListEvent) => void) { + listeners.delete(fn); + }, } : real(q)) as typeof window.matchMedia; restoreMedia = () => { window.matchMedia = real; }; + + return (next: boolean) => { + matches = next; + + for (const fn of listeners) { + fn({ matches: next } as MediaQueryListEvent); + } + }; } afterEach(() => { @@ -233,6 +256,32 @@ describe('the queue panel decides whether it can be a column', () => { expect(el.open).toBe(false); }); + /** + * The scrim's existence comes from `matchMedia` rather than a + * stylesheet, which only holds up if the query is *listened* to — a + * panel opened on a desktop and carried across the breakpoint (a + * resized window, an unfolded phone) has to lose its scrim without + * being reopened. Nothing else in this file fires `change`, so + * deleting the listener leaves the whole suite green. + */ + it('drops the scrim when the viewport crosses the breakpoint', async () => { + const setPhone = stubPhone(false); + + const el = await panelIn(700); + + expect(el.shadowRoot?.querySelector('.scrim')).not.toBeNull(); + + setPhone(true); + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('.scrim')).toBeNull(); + + setPhone(false); + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('.scrim')).not.toBeNull(); + }); + /** * Escape belongs to the overlay, not to the queue. An inline panel is * beside the content rather than over it, so there is nothing to