Files
siftlode/frontend/src/lib/layerStack.integration.dom.test.tsx
T
peter 5d7be687d3 test(views,layer): close the two polish-tail deep-review test gaps
UX-8 layerStack: add an integration test wired through the real useLayer
hook (popover document-listener + stamp) and a PlayerModal-shaped window
listener, driven by a real dispatched keydown. The popover close is wrapped
in flushSync so its layer is popped synchronously between the two listeners
-- the production mid-dispatch race -- making the escapeHandled stamp the
only thing that keeps the player from also closing. Mutation-checked:
removing the guard fails exactly this case.

SavedViews: cover feedCanSeedDefault's "yield to a bookmarked filter URL"
branch (a filter-bearing search string -> no seed) and the per-source
seededRef guard against the 60s refetch (a changed-list refetch re-runs the
seed effect; the guard blocks re-seed). The refetch test waits for the
changed list to reach the component so the effect has its chance to wrongly
re-fire. Both mutation-checked.

Frontend gate green: 464 vitest (+4). Backend untouched.
2026-08-24 23:41:49 +02:00

94 lines
4.2 KiB
TypeScript

// @vitest-environment jsdom
//
// Integration guard for the UX-8 single-Escape fix — the sibling of layerStack.dom.test.ts, but wired
// through the REAL `useLayer` hook and a REAL dispatched keydown instead of hand-rolled listeners. It
// mounts the two production listener shapes as React components:
// - the POPOVER exactly as AnchoredPopover uses it: `useLayer(open, onEscape)` installs the real
// `document` keydown listener that stamps the event and closes on topmost;
// - the PLAYER exactly as PlayerModal reads it: a `window` keydown listener that bails on
// `escapeHandled(e)` first, then on `anyAbove()`.
// The load-bearing subtlety the primitives test can only fake: the popover's close is wrapped in
// `flushSync`, so React tears the popover down (and `useLayer` cleanup pops its layer) SYNCHRONOUSLY
// between the `document` and `window` listeners — the real mid-dispatch flush. That makes the stack read
// "nothing above the player" by the time the player's listener runs, so ONLY the event stamp can stop
// the player from also closing. If the stamp regressed, test 1 would see the player close on the first
// Escape. floating-ui isn't involved here (we drive `useLayer` directly), so no observer stubs needed.
import { describe, it, expect, vi, afterEach } from "vitest";
import { useEffect, useState } from "react";
import { flushSync } from "react-dom";
import { render, act, cleanup } from "@testing-library/react";
import { useLayer, escapeHandled } from "./layerStack";
afterEach(cleanup);
// The player: registers a layer (no onEscape — it owns a richer handler) and a `window` listener that
// mirrors PlayerModal's guard order (consumed-stamp first, then anyAbove).
function Player({ onClose }: { onClose: () => void }) {
const { anyAbove } = useLayer(true);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (escapeHandled(e)) return; // the UX-8 fix
if (anyAbove()) return; // the old, race-prone guard
onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [anyAbove, onClose]);
return <div data-testid="player" />;
}
// The popover: the real AnchoredPopover usage. flushSync makes the unmount (and its layer removal) run
// synchronously inside the `document` listener — the production mid-dispatch flush the fix must survive.
function Popover({ onClosed }: { onClosed: () => void }) {
const [open, setOpen] = useState(true);
useLayer(open, () => {
flushSync(() => setOpen(false));
onClosed();
});
return open ? <div data-testid="popover" /> : null;
}
function dispatchEscape(): KeyboardEvent {
const e = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
act(() => {
document.dispatchEvent(e);
});
return e;
}
describe("layerStack Escape coordination — live useLayer + real dispatch (UX-8)", () => {
it("one Escape closes only the topmost popover; a second closes the player", () => {
const playerClose = vi.fn();
const popoverClosed = vi.fn();
const { queryByTestId } = render(
<>
<Player onClose={playerClose} />
<Popover onClosed={popoverClosed} />
</>,
);
// Player registers first, popover second → popover is topmost.
expect(queryByTestId("popover")).not.toBeNull();
// First Escape: popover acts (stamps + flushSync-closes, removing its layer synchronously), player
// defers on the stamp even though the stack now shows nothing above it.
const first = dispatchEscape();
expect(escapeHandled(first)).toBe(true);
expect(popoverClosed).toHaveBeenCalledOnce();
expect(queryByTestId("popover")).toBeNull();
expect(playerClose).not.toHaveBeenCalled();
// Second Escape: nothing above the player, unstamped → the player closes.
const second = dispatchEscape();
expect(escapeHandled(second)).toBe(false);
expect(playerClose).toHaveBeenCalledOnce();
});
it("with no layer above, a lone player closes on the first Escape", () => {
const playerClose = vi.fn();
render(<Player onClose={playerClose} />);
dispatchEscape();
expect(playerClose).toHaveBeenCalledOnce();
});
});