|
| 1 | +import React from 'react'; |
| 2 | +import { act, render } from '@testing-library/react-native'; |
| 3 | + |
| 4 | +import { type IShareAttachment } from '../../definitions'; |
| 5 | +import { |
| 6 | + MessageComposerProvider, |
| 7 | + useAlsoSendThreadToChannel, |
| 8 | + useAutocompleteParams, |
| 9 | + useComposerAttachments, |
| 10 | + useFocused, |
| 11 | + useMessageComposerApi, |
| 12 | + useMicOrSend, |
| 13 | + useRecordingAudio, |
| 14 | + useShowMarkdownToolbar |
| 15 | +} from './context'; |
| 16 | + |
| 17 | +type Api = ReturnType<typeof useMessageComposerApi>; |
| 18 | + |
| 19 | +// Tests for the composer store's public hook/provider contract and its per-slice re-render granularity. |
| 20 | +// Non-obvious constraint: the `actions` bag must stay a stable reference — a selector returning a fresh object |
| 21 | +// each render trips zustand v5's snapshot-equality loop. Renders are counted with jest.fn spies, not an outer |
| 22 | +// counter (a spy call in render is pure; mutation isn't). |
| 23 | + |
| 24 | +// One probe per `probes` entry (name -> hook) plus an api probe; returns the stable api and per-probe render/value readers. |
| 25 | +const renderComposer = (probes: Record<string, () => unknown>) => { |
| 26 | + const probeSpies: Record<string, jest.Mock> = {}; |
| 27 | + const apiSpy = jest.fn(); |
| 28 | + |
| 29 | + const probeElements = Object.entries(probes).map(([name, useHook]) => { |
| 30 | + const spy = jest.fn(); |
| 31 | + probeSpies[name] = spy; |
| 32 | + const Probe = () => { |
| 33 | + spy(useHook()); |
| 34 | + return null; |
| 35 | + }; |
| 36 | + return <Probe key={name} />; |
| 37 | + }); |
| 38 | + |
| 39 | + const ApiProbe = () => { |
| 40 | + apiSpy(useMessageComposerApi()); |
| 41 | + return null; |
| 42 | + }; |
| 43 | + |
| 44 | + render( |
| 45 | + <MessageComposerProvider> |
| 46 | + <> |
| 47 | + {probeElements} |
| 48 | + <ApiProbe /> |
| 49 | + </> |
| 50 | + </MessageComposerProvider> |
| 51 | + ); |
| 52 | + |
| 53 | + const renderCount = (name: string) => probeSpies[name].mock.calls.length; |
| 54 | + const latestValue = (name: string) => { |
| 55 | + const { calls } = probeSpies[name].mock; |
| 56 | + return calls[calls.length - 1]?.[0]; |
| 57 | + }; |
| 58 | + |
| 59 | + // api is stable, so the first render's value is the one every test calls setters on. |
| 60 | + const [[api]] = apiSpy.mock.calls as [Api][]; |
| 61 | + return { api, renderCount, latestValue }; |
| 62 | +}; |
| 63 | + |
| 64 | +const attachment = (path: string, extra?: Partial<IShareAttachment>): IShareAttachment => ({ |
| 65 | + filename: `${path}.png`, |
| 66 | + size: 1, |
| 67 | + path, |
| 68 | + ...extra |
| 69 | +}); |
| 70 | + |
| 71 | +// One row per scalar slice; each runs the same set→read + granularity tests. (attachments is a list slice, tested below.) |
| 72 | +type ScalarCase = { |
| 73 | + name: string; |
| 74 | + useHook: () => unknown; |
| 75 | + initial: unknown; |
| 76 | + mutate: (api: Api) => void; |
| 77 | + next: unknown; |
| 78 | +}; |
| 79 | + |
| 80 | +const SCALAR_SLICES: ScalarCase[] = [ |
| 81 | + { name: 'focused', useHook: useFocused, initial: false, mutate: api => api.setFocused(true), next: true }, |
| 82 | + { name: 'micOrSend', useHook: useMicOrSend, initial: 'mic', mutate: api => api.setMicOrSend('send'), next: 'send' }, |
| 83 | + { |
| 84 | + name: 'showMarkdownToolbar', |
| 85 | + useHook: useShowMarkdownToolbar, |
| 86 | + initial: false, |
| 87 | + mutate: api => api.setMarkdownToolbar(true), |
| 88 | + next: true |
| 89 | + }, |
| 90 | + { |
| 91 | + name: 'alsoSendThreadToChannel', |
| 92 | + useHook: useAlsoSendThreadToChannel, |
| 93 | + initial: false, |
| 94 | + mutate: api => api.setAlsoSendThreadToChannel(true), |
| 95 | + next: true |
| 96 | + }, |
| 97 | + { name: 'recordingAudio', useHook: useRecordingAudio, initial: false, mutate: api => api.setRecordingAudio(true), next: true }, |
| 98 | + { |
| 99 | + name: 'autocompleteParams', |
| 100 | + useHook: useAutocompleteParams, |
| 101 | + initial: { text: '', type: null }, |
| 102 | + mutate: api => api.setAutocompleteParams({ text: '@ro', type: '@' }), |
| 103 | + next: { text: '@ro', type: '@' } |
| 104 | + } |
| 105 | +]; |
| 106 | + |
| 107 | +describe('MessageComposer state container', () => { |
| 108 | + describe('per-instance isolation', () => { |
| 109 | + it('keeps state independent across provider instances', () => { |
| 110 | + const spyA = jest.fn(); |
| 111 | + const spyB = jest.fn(); |
| 112 | + |
| 113 | + const Probe = ({ spy }: { spy: jest.Mock }) => { |
| 114 | + spy({ focused: useFocused(), api: useMessageComposerApi() }); |
| 115 | + return null; |
| 116 | + }; |
| 117 | + |
| 118 | + render( |
| 119 | + <> |
| 120 | + <MessageComposerProvider> |
| 121 | + <Probe spy={spyA} /> |
| 122 | + </MessageComposerProvider> |
| 123 | + <MessageComposerProvider> |
| 124 | + <Probe spy={spyB} /> |
| 125 | + </MessageComposerProvider> |
| 126 | + </> |
| 127 | + ); |
| 128 | + |
| 129 | + const latest = (spy: jest.Mock): { focused: boolean; api: Api } => spy.mock.calls[spy.mock.calls.length - 1][0]; |
| 130 | + |
| 131 | + expect(latest(spyA).focused).toBe(false); |
| 132 | + expect(latest(spyB).focused).toBe(false); |
| 133 | + |
| 134 | + // Mutating one instance must not leak into the other |
| 135 | + act(() => latest(spyA).api.setFocused(true)); |
| 136 | + |
| 137 | + expect(latest(spyA).focused).toBe(true); |
| 138 | + expect(latest(spyB).focused).toBe(false); |
| 139 | + |
| 140 | + // And the reverse direction stays isolated too |
| 141 | + act(() => latest(spyB).api.setRecordingAudio(true)); |
| 142 | + |
| 143 | + expect(latest(spyA).focused).toBe(true); |
| 144 | + }); |
| 145 | + }); |
| 146 | + |
| 147 | + describe('scalar slices', () => { |
| 148 | + describe.each(SCALAR_SLICES)('$name', ({ name, useHook, initial, mutate, next }) => { |
| 149 | + it('exposes the initial value, then reflects the update (set→read)', () => { |
| 150 | + const { api, latestValue } = renderComposer({ [name]: useHook }); |
| 151 | + |
| 152 | + expect(latestValue(name)).toEqual(initial); |
| 153 | + |
| 154 | + act(() => mutate(api)); |
| 155 | + |
| 156 | + expect(latestValue(name)).toEqual(next); |
| 157 | + }); |
| 158 | + |
| 159 | + it('re-renders its own consumer but not an unrelated one when it changes (granularity)', () => { |
| 160 | + // attachments is the control slice — it is never the scalar under test |
| 161 | + const { api, renderCount } = renderComposer({ [name]: useHook, attachments: useComposerAttachments }); |
| 162 | + |
| 163 | + const sliceBaseline = renderCount(name); |
| 164 | + const controlBaseline = renderCount('attachments'); |
| 165 | + |
| 166 | + act(() => mutate(api)); |
| 167 | + |
| 168 | + expect(renderCount(name)).toBeGreaterThan(sliceBaseline); |
| 169 | + expect(renderCount('attachments')).toBe(controlBaseline); |
| 170 | + }); |
| 171 | + }); |
| 172 | + }); |
| 173 | + |
| 174 | + describe('attachments', () => { |
| 175 | + it('add appends attachments in order', () => { |
| 176 | + const { api, latestValue } = renderComposer({ attachments: useComposerAttachments }); |
| 177 | + |
| 178 | + expect(latestValue('attachments')).toEqual([]); |
| 179 | + |
| 180 | + act(() => api.addAttachments([attachment('a')])); |
| 181 | + act(() => api.addAttachments([attachment('b')])); |
| 182 | + |
| 183 | + expect((latestValue('attachments') as IShareAttachment[]).map(a => a.path)).toEqual(['a', 'b']); |
| 184 | + }); |
| 185 | + |
| 186 | + it('update merges a patch into the matching attachment by path, leaving others untouched', () => { |
| 187 | + const { api, latestValue } = renderComposer({ attachments: useComposerAttachments }); |
| 188 | + |
| 189 | + act(() => api.addAttachments([attachment('a'), attachment('b')])); |
| 190 | + act(() => api.updateAttachment('a', { description: 'hello' })); |
| 191 | + |
| 192 | + const result = latestValue('attachments') as IShareAttachment[]; |
| 193 | + expect(result.find(a => a.path === 'a')).toMatchObject({ path: 'a', filename: 'a.png', description: 'hello' }); |
| 194 | + expect(result.find(a => a.path === 'b')?.description).toBeUndefined(); |
| 195 | + }); |
| 196 | + |
| 197 | + it('remove filters out the attachment with the matching path', () => { |
| 198 | + const { api, latestValue } = renderComposer({ attachments: useComposerAttachments }); |
| 199 | + |
| 200 | + act(() => api.addAttachments([attachment('a'), attachment('b')])); |
| 201 | + act(() => api.removeAttachment('a')); |
| 202 | + |
| 203 | + expect((latestValue('attachments') as IShareAttachment[]).map(a => a.path)).toEqual(['b']); |
| 204 | + }); |
| 205 | + |
| 206 | + it('clear empties the attachments', () => { |
| 207 | + const { api, latestValue } = renderComposer({ attachments: useComposerAttachments }); |
| 208 | + |
| 209 | + act(() => api.addAttachments([attachment('a'), attachment('b')])); |
| 210 | + act(() => api.clearAttachments()); |
| 211 | + |
| 212 | + expect(latestValue('attachments')).toEqual([]); |
| 213 | + }); |
| 214 | + |
| 215 | + // Reverse of the table's granularity check: a list mutation must not re-render scalar consumers. |
| 216 | + it('does not re-render a scalar consumer when attachments change', () => { |
| 217 | + const { api, renderCount } = renderComposer({ attachments: useComposerAttachments, focused: useFocused }); |
| 218 | + |
| 219 | + const attachmentsBaseline = renderCount('attachments'); |
| 220 | + const focusedBaseline = renderCount('focused'); |
| 221 | + |
| 222 | + act(() => api.addAttachments([attachment('a')])); |
| 223 | + |
| 224 | + expect(renderCount('attachments')).toBeGreaterThan(attachmentsBaseline); |
| 225 | + expect(renderCount('focused')).toBe(focusedBaseline); |
| 226 | + }); |
| 227 | + }); |
| 228 | + |
| 229 | + describe('actions reference stability', () => { |
| 230 | + it('keeps the same api reference across slice updates', () => { |
| 231 | + const probe = jest.fn(); |
| 232 | + |
| 233 | + // Subscribes to a changing slice AND the api, so the re-render is real — the api ref must still be identical. |
| 234 | + const Probe = () => { |
| 235 | + useFocused(); |
| 236 | + probe(useMessageComposerApi()); |
| 237 | + return null; |
| 238 | + }; |
| 239 | + |
| 240 | + render( |
| 241 | + <MessageComposerProvider> |
| 242 | + <Probe /> |
| 243 | + </MessageComposerProvider> |
| 244 | + ); |
| 245 | + |
| 246 | + const callsBefore = probe.mock.calls.length; |
| 247 | + const ref1: Api = probe.mock.calls[callsBefore - 1][0]; |
| 248 | + |
| 249 | + act(() => ref1.setFocused(true)); |
| 250 | + |
| 251 | + expect(probe.mock.calls.length).toBeGreaterThan(callsBefore); |
| 252 | + const ref2: Api = probe.mock.calls[probe.mock.calls.length - 1][0]; |
| 253 | + expect(ref2).toBe(ref1); |
| 254 | + }); |
| 255 | + }); |
| 256 | +}); |
0 commit comments