|
| 1 | +/** |
| 2 | + * @jest-environment jsdom |
| 3 | + */ |
| 4 | +/* eslint-disable testing-library/no-unnecessary-act -- this file renders with react-dom, not testing-library */ |
| 5 | +import { |
| 6 | + afterEach, |
| 7 | + beforeEach, |
| 8 | + describe, |
| 9 | + expect, |
| 10 | + it, |
| 11 | + jest, |
| 12 | +} from '@jest/globals'; |
| 13 | +import * as React from 'react'; |
| 14 | +import { act } from 'react'; |
| 15 | +import { createRoot, type Root } from 'react-dom/client'; |
| 16 | +import type { InsetChangeNativeCallback, Metrics } from '../SafeArea.types'; |
| 17 | +import { NativeSafeAreaProvider } from '../NativeSafeAreaProvider.web'; |
| 18 | + |
| 19 | +jest.mock('react-native', () => { |
| 20 | + const ReactActual = jest.requireActual<typeof React>('react'); |
| 21 | + return { |
| 22 | + View: ReactActual.forwardRef< |
| 23 | + HTMLDivElement, |
| 24 | + { children?: React.ReactNode } |
| 25 | + >(function View({ children }, ref) { |
| 26 | + return ReactActual.createElement('div', { ref }, children); |
| 27 | + }), |
| 28 | + }; |
| 29 | +}); |
| 30 | + |
| 31 | +( |
| 32 | + globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean } |
| 33 | +).IS_REACT_ACT_ENVIRONMENT = true; |
| 34 | + |
| 35 | +const WINDOW_WIDTH = 1024; |
| 36 | +const WINDOW_HEIGHT = 768; |
| 37 | +const WINDOW_INSETS = { top: 44, bottom: 34, left: 10, right: 20 }; |
| 38 | + |
| 39 | +class ResizeObserverMock { |
| 40 | + static instances: ResizeObserverMock[] = []; |
| 41 | + callback: () => void; |
| 42 | + constructor(callback: () => void) { |
| 43 | + this.callback = callback; |
| 44 | + ResizeObserverMock.instances.push(this); |
| 45 | + } |
| 46 | + observe() {} |
| 47 | + unobserve() {} |
| 48 | + disconnect() {} |
| 49 | +} |
| 50 | + |
| 51 | +function triggerResizeObservers() { |
| 52 | + act(() => { |
| 53 | + ResizeObserverMock.instances.forEach((instance) => instance.callback()); |
| 54 | + }); |
| 55 | +} |
| 56 | + |
| 57 | +function makeRect( |
| 58 | + x: number, |
| 59 | + y: number, |
| 60 | + width: number, |
| 61 | + height: number, |
| 62 | +): DOMRect { |
| 63 | + return { |
| 64 | + x, |
| 65 | + y, |
| 66 | + width, |
| 67 | + height, |
| 68 | + top: y, |
| 69 | + left: x, |
| 70 | + right: x + width, |
| 71 | + bottom: y + height, |
| 72 | + toJSON: () => null, |
| 73 | + } as DOMRect; |
| 74 | +} |
| 75 | + |
| 76 | +type OnInsetsChangeMock = ReturnType<typeof jest.fn<InsetChangeNativeCallback>>; |
| 77 | + |
| 78 | +function lastMetrics(onInsetsChange: OnInsetsChangeMock): Metrics { |
| 79 | + const lastCall = onInsetsChange.mock.lastCall; |
| 80 | + if (lastCall == null) { |
| 81 | + throw new Error('onInsetsChange was not called'); |
| 82 | + } |
| 83 | + return lastCall[0].nativeEvent; |
| 84 | +} |
| 85 | + |
| 86 | +function spyOnBoundingClientRect() { |
| 87 | + return jest.spyOn(Element.prototype, 'getBoundingClientRect'); |
| 88 | +} |
| 89 | + |
| 90 | +let root: Root | null = null; |
| 91 | +let host: HTMLElement | null = null; |
| 92 | +let rectMock: ReturnType<typeof spyOnBoundingClientRect>; |
| 93 | + |
| 94 | +function mountProvider(onInsetsChange: InsetChangeNativeCallback) { |
| 95 | + const newHost = document.createElement('div'); |
| 96 | + document.body.appendChild(newHost); |
| 97 | + const newRoot = createRoot(newHost); |
| 98 | + act(() => { |
| 99 | + newRoot.render(<NativeSafeAreaProvider onInsetsChange={onInsetsChange} />); |
| 100 | + }); |
| 101 | + root = newRoot; |
| 102 | + host = newHost; |
| 103 | +} |
| 104 | + |
| 105 | +describe('NativeSafeAreaProvider.web', () => { |
| 106 | + beforeEach(() => { |
| 107 | + ResizeObserverMock.instances = []; |
| 108 | + (globalThis as { ResizeObserver?: unknown }).ResizeObserver = |
| 109 | + ResizeObserverMock; |
| 110 | + Object.defineProperty(window, 'innerWidth', { |
| 111 | + value: WINDOW_WIDTH, |
| 112 | + configurable: true, |
| 113 | + }); |
| 114 | + Object.defineProperty(window, 'innerHeight', { |
| 115 | + value: WINDOW_HEIGHT, |
| 116 | + configurable: true, |
| 117 | + }); |
| 118 | + Object.defineProperty(document.documentElement, 'offsetWidth', { |
| 119 | + value: WINDOW_WIDTH, |
| 120 | + configurable: true, |
| 121 | + }); |
| 122 | + Object.defineProperty(document.documentElement, 'offsetHeight', { |
| 123 | + value: WINDOW_HEIGHT, |
| 124 | + configurable: true, |
| 125 | + }); |
| 126 | + jest.spyOn(window, 'getComputedStyle').mockReturnValue({ |
| 127 | + paddingTop: `${WINDOW_INSETS.top}px`, |
| 128 | + paddingBottom: `${WINDOW_INSETS.bottom}px`, |
| 129 | + paddingLeft: `${WINDOW_INSETS.left}px`, |
| 130 | + paddingRight: `${WINDOW_INSETS.right}px`, |
| 131 | + } as CSSStyleDeclaration); |
| 132 | + rectMock = spyOnBoundingClientRect().mockReturnValue( |
| 133 | + makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT), |
| 134 | + ); |
| 135 | + }); |
| 136 | + |
| 137 | + afterEach(() => { |
| 138 | + const currentRoot = root; |
| 139 | + if (currentRoot != null) { |
| 140 | + act(() => { |
| 141 | + currentRoot.unmount(); |
| 142 | + }); |
| 143 | + root = null; |
| 144 | + } |
| 145 | + host?.remove(); |
| 146 | + host = null; |
| 147 | + delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver; |
| 148 | + jest.restoreAllMocks(); |
| 149 | + }); |
| 150 | + |
| 151 | + it('reports the provider element rect as the frame', () => { |
| 152 | + rectMock.mockReturnValue(makeRect(20, 30, 200, 300)); |
| 153 | + const onInsetsChange = jest.fn<InsetChangeNativeCallback>(); |
| 154 | + mountProvider(onInsetsChange); |
| 155 | + expect(lastMetrics(onInsetsChange).frame).toEqual({ |
| 156 | + x: 20, |
| 157 | + y: 30, |
| 158 | + width: 200, |
| 159 | + height: 300, |
| 160 | + }); |
| 161 | + }); |
| 162 | + |
| 163 | + it('reports zero insets when the provider element does not overlap the safe area', () => { |
| 164 | + // Element is 100px from the top edge, 168px from the bottom edge, 50px |
| 165 | + // from the left edge and 74px from the right edge, all larger than the |
| 166 | + // window insets. |
| 167 | + rectMock.mockReturnValue(makeRect(50, 100, 900, 500)); |
| 168 | + const onInsetsChange = jest.fn<InsetChangeNativeCallback>(); |
| 169 | + mountProvider(onInsetsChange); |
| 170 | + expect(lastMetrics(onInsetsChange).insets).toEqual({ |
| 171 | + top: 0, |
| 172 | + bottom: 0, |
| 173 | + left: 0, |
| 174 | + right: 0, |
| 175 | + }); |
| 176 | + }); |
| 177 | + |
| 178 | + it('clamps insets to the part of the safe area overlapping the provider element', () => { |
| 179 | + // Element is 20px from the top edge, 14px from the bottom edge, 4px from |
| 180 | + // the left edge and 8px from the right edge. |
| 181 | + rectMock.mockReturnValue(makeRect(4, 20, 1012, 734)); |
| 182 | + const onInsetsChange = jest.fn<InsetChangeNativeCallback>(); |
| 183 | + mountProvider(onInsetsChange); |
| 184 | + expect(lastMetrics(onInsetsChange).insets).toEqual({ |
| 185 | + top: WINDOW_INSETS.top - 20, |
| 186 | + bottom: WINDOW_INSETS.bottom - 14, |
| 187 | + left: WINDOW_INSETS.left - 4, |
| 188 | + right: WINDOW_INSETS.right - 8, |
| 189 | + }); |
| 190 | + }); |
| 191 | + |
| 192 | + it('reports window insets and frame for a full-viewport provider', () => { |
| 193 | + rectMock.mockReturnValue(makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT)); |
| 194 | + const onInsetsChange = jest.fn<InsetChangeNativeCallback>(); |
| 195 | + mountProvider(onInsetsChange); |
| 196 | + expect(lastMetrics(onInsetsChange)).toEqual({ |
| 197 | + insets: WINDOW_INSETS, |
| 198 | + frame: { x: 0, y: 0, width: WINDOW_WIDTH, height: WINDOW_HEIGHT }, |
| 199 | + }); |
| 200 | + }); |
| 201 | + |
| 202 | + it('updates metrics when the provider element resizes', () => { |
| 203 | + rectMock.mockReturnValue(makeRect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT)); |
| 204 | + const onInsetsChange = jest.fn<InsetChangeNativeCallback>(); |
| 205 | + mountProvider(onInsetsChange); |
| 206 | + rectMock.mockReturnValue(makeRect(0, 100, WINDOW_WIDTH, 668)); |
| 207 | + triggerResizeObservers(); |
| 208 | + expect(lastMetrics(onInsetsChange)).toEqual({ |
| 209 | + insets: { |
| 210 | + top: 0, |
| 211 | + bottom: WINDOW_INSETS.bottom, |
| 212 | + left: WINDOW_INSETS.left, |
| 213 | + right: WINDOW_INSETS.right, |
| 214 | + }, |
| 215 | + frame: { x: 0, y: 100, width: WINDOW_WIDTH, height: 668 }, |
| 216 | + }); |
| 217 | + }); |
| 218 | + |
| 219 | + it('falls back to window metrics when ResizeObserver is not available', () => { |
| 220 | + delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver; |
| 221 | + rectMock.mockReturnValue(makeRect(20, 30, 200, 300)); |
| 222 | + const onInsetsChange = jest.fn<InsetChangeNativeCallback>(); |
| 223 | + mountProvider(onInsetsChange); |
| 224 | + expect(lastMetrics(onInsetsChange)).toEqual({ |
| 225 | + insets: WINDOW_INSETS, |
| 226 | + frame: { x: 0, y: 0, width: WINDOW_WIDTH, height: WINDOW_HEIGHT }, |
| 227 | + }); |
| 228 | + }); |
| 229 | +}); |
0 commit comments