1 | import { createReducer, CaseReducer } from './createReducer'
|
2 | import { PayloadAction, createAction } from './createAction'
|
3 | import { createNextState, Draft } from './'
|
4 | import { Reducer, AnyAction } from 'redux'
|
5 |
|
6 | interface Todo {
|
7 | text: string
|
8 | completed?: boolean
|
9 | }
|
10 |
|
11 | interface AddTodoPayload {
|
12 | newTodo: Todo
|
13 | }
|
14 |
|
15 | interface ToggleTodoPayload {
|
16 | index: number
|
17 | }
|
18 |
|
19 | type TodoState = Todo[]
|
20 | type TodosReducer = Reducer<TodoState, PayloadAction<any>>
|
21 | type AddTodoReducer = CaseReducer<TodoState, PayloadAction<AddTodoPayload>>
|
22 |
|
23 | type ToggleTodoReducer = CaseReducer<
|
24 | TodoState,
|
25 | PayloadAction<ToggleTodoPayload>
|
26 | >
|
27 |
|
28 | describe('createReducer', () => {
|
29 | describe('given impure reducers with immer', () => {
|
30 | const addTodo: AddTodoReducer = (state, action) => {
|
31 | const { newTodo } = action.payload
|
32 |
|
33 |
|
34 | state.push({ ...newTodo, completed: false })
|
35 | }
|
36 |
|
37 | const toggleTodo: ToggleTodoReducer = (state, action) => {
|
38 | const { index } = action.payload
|
39 |
|
40 | const todo = state[index]
|
41 |
|
42 | todo.completed = !todo.completed
|
43 | }
|
44 |
|
45 | const todosReducer = createReducer([] as TodoState, {
|
46 | ADD_TODO: addTodo,
|
47 | TOGGLE_TODO: toggleTodo
|
48 | })
|
49 |
|
50 | behavesLikeReducer(todosReducer)
|
51 | })
|
52 |
|
53 | describe('Immer in a production environment', () => {
|
54 | let originalNodeEnv = process.env.NODE_ENV
|
55 |
|
56 | beforeEach(() => {
|
57 | jest.resetModules()
|
58 | process.env.NODE_ENV = 'production'
|
59 | })
|
60 |
|
61 | afterEach(() => {
|
62 | process.env.NODE_ENV = originalNodeEnv
|
63 | })
|
64 |
|
65 | test('Freezes data in production', () => {
|
66 | const { createReducer } = require('./createReducer')
|
67 | const addTodo: AddTodoReducer = (state, action) => {
|
68 | const { newTodo } = action.payload
|
69 | state.push({ ...newTodo, completed: false })
|
70 | }
|
71 |
|
72 | const toggleTodo: ToggleTodoReducer = (state, action) => {
|
73 | const { index } = action.payload
|
74 | const todo = state[index]
|
75 | todo.completed = !todo.completed
|
76 | }
|
77 |
|
78 | const todosReducer = createReducer([] as TodoState, {
|
79 | ADD_TODO: addTodo,
|
80 | TOGGLE_TODO: toggleTodo
|
81 | })
|
82 |
|
83 | const result = todosReducer([], {
|
84 | type: 'ADD_TODO',
|
85 | payload: { text: 'Buy milk' }
|
86 | })
|
87 |
|
88 | const mutateStateOutsideReducer = () => (result[0].text = 'edited')
|
89 | expect(mutateStateOutsideReducer).toThrowError(
|
90 | 'Cannot add property text, object is not extensible'
|
91 | )
|
92 | })
|
93 |
|
94 | test('Freezes initial state', () => {
|
95 | const initialState = [{ text: 'Buy milk' }]
|
96 | const todosReducer = createReducer(initialState, {})
|
97 |
|
98 | const mutateStateOutsideReducer = () => (initialState[0].text = 'edited')
|
99 | expect(mutateStateOutsideReducer).toThrowError(
|
100 | /Cannot assign to read only property/
|
101 | )
|
102 | })
|
103 | })
|
104 |
|
105 | describe('given pure reducers with immutable updates', () => {
|
106 | const addTodo: AddTodoReducer = (state, action) => {
|
107 | const { newTodo } = action.payload
|
108 |
|
109 |
|
110 | return state.concat({ ...newTodo, completed: false })
|
111 | }
|
112 |
|
113 | const toggleTodo: ToggleTodoReducer = (state, action) => {
|
114 | const { index } = action.payload
|
115 |
|
116 |
|
117 | return state.map((todo, i) => {
|
118 | if (i !== index) return todo
|
119 | return { ...todo, completed: !todo.completed }
|
120 | })
|
121 | }
|
122 |
|
123 | const todosReducer = createReducer([] as TodoState, {
|
124 | ADD_TODO: addTodo,
|
125 | TOGGLE_TODO: toggleTodo
|
126 | })
|
127 |
|
128 | behavesLikeReducer(todosReducer)
|
129 | })
|
130 |
|
131 | describe('given draft state from immer', () => {
|
132 | const addTodo: AddTodoReducer = (state, action) => {
|
133 | const { newTodo } = action.payload
|
134 |
|
135 |
|
136 | state.push({ ...newTodo, completed: false })
|
137 | }
|
138 |
|
139 | const toggleTodo: ToggleTodoReducer = (state, action) => {
|
140 | const { index } = action.payload
|
141 |
|
142 | const todo = state[index]
|
143 |
|
144 | todo.completed = !todo.completed
|
145 | }
|
146 |
|
147 | const todosReducer = createReducer([] as TodoState, {
|
148 | ADD_TODO: addTodo,
|
149 | TOGGLE_TODO: toggleTodo
|
150 | })
|
151 |
|
152 | const wrappedReducer: TodosReducer = (state = [], action) => {
|
153 | return createNextState(state, (draft: Draft<TodoState>) => {
|
154 | todosReducer(draft, action)
|
155 | })
|
156 | }
|
157 |
|
158 | behavesLikeReducer(wrappedReducer)
|
159 | })
|
160 |
|
161 | describe('actionMatchers argument', () => {
|
162 | const prepareNumberAction = (payload: number) => ({
|
163 | payload,
|
164 | meta: { type: 'number_action' }
|
165 | })
|
166 | const prepareStringAction = (payload: string) => ({
|
167 | payload,
|
168 | meta: { type: 'string_action' }
|
169 | })
|
170 |
|
171 | const numberActionMatcher = (a: AnyAction): a is PayloadAction<number> =>
|
172 | a.meta && a.meta.type === 'number_action'
|
173 | const stringActionMatcher = (a: AnyAction): a is PayloadAction<string> =>
|
174 | a.meta && a.meta.type === 'string_action'
|
175 |
|
176 | const incrementBy = createAction('increment', prepareNumberAction)
|
177 | const decrementBy = createAction('decrement', prepareNumberAction)
|
178 | const concatWith = createAction('concat', prepareStringAction)
|
179 |
|
180 | const initialState = { numberActions: 0, stringActions: 0 }
|
181 | const numberActionsCounter = {
|
182 | matcher: numberActionMatcher,
|
183 | reducer(state: typeof initialState) {
|
184 | state.numberActions = state.numberActions * 10 + 1
|
185 | }
|
186 | }
|
187 | const stringActionsCounter = {
|
188 | matcher: stringActionMatcher,
|
189 | reducer(state: typeof initialState) {
|
190 | state.stringActions = state.stringActions * 10 + 1
|
191 | }
|
192 | }
|
193 |
|
194 | test('uses the reducer of matching actionMatchers', () => {
|
195 | const reducer = createReducer(initialState, {}, [
|
196 | numberActionsCounter,
|
197 | stringActionsCounter
|
198 | ])
|
199 | expect(reducer(undefined, incrementBy(1))).toEqual({
|
200 | numberActions: 1,
|
201 | stringActions: 0
|
202 | })
|
203 | expect(reducer(undefined, decrementBy(1))).toEqual({
|
204 | numberActions: 1,
|
205 | stringActions: 0
|
206 | })
|
207 | expect(reducer(undefined, concatWith('foo'))).toEqual({
|
208 | numberActions: 0,
|
209 | stringActions: 1
|
210 | })
|
211 | })
|
212 | test('fallback to default case', () => {
|
213 | const reducer = createReducer(
|
214 | initialState,
|
215 | {},
|
216 | [numberActionsCounter, stringActionsCounter],
|
217 | state => {
|
218 | state.numberActions = -1
|
219 | state.stringActions = -1
|
220 | }
|
221 | )
|
222 | expect(reducer(undefined, { type: 'somethingElse' })).toEqual({
|
223 | numberActions: -1,
|
224 | stringActions: -1
|
225 | })
|
226 | })
|
227 | test('runs reducer cases followed by all matching actionMatchers', () => {
|
228 | const reducer = createReducer(
|
229 | initialState,
|
230 | {
|
231 | [incrementBy.type](state) {
|
232 | state.numberActions = state.numberActions * 10 + 2
|
233 | }
|
234 | },
|
235 | [
|
236 | {
|
237 | matcher: numberActionMatcher,
|
238 | reducer(state) {
|
239 | state.numberActions = state.numberActions * 10 + 3
|
240 | }
|
241 | },
|
242 | numberActionsCounter,
|
243 | stringActionsCounter
|
244 | ]
|
245 | )
|
246 | expect(reducer(undefined, incrementBy(1))).toEqual({
|
247 | numberActions: 231,
|
248 | stringActions: 0
|
249 | })
|
250 | expect(reducer(undefined, decrementBy(1))).toEqual({
|
251 | numberActions: 31,
|
252 | stringActions: 0
|
253 | })
|
254 | expect(reducer(undefined, concatWith('foo'))).toEqual({
|
255 | numberActions: 0,
|
256 | stringActions: 1
|
257 | })
|
258 | })
|
259 | test('works with `actionCreator.match`', () => {
|
260 | const reducer = createReducer(initialState, {}, [
|
261 | {
|
262 | matcher: incrementBy.match,
|
263 | reducer(state) {
|
264 | state.numberActions += 100
|
265 | }
|
266 | }
|
267 | ])
|
268 | expect(reducer(undefined, incrementBy(1))).toEqual({
|
269 | numberActions: 100,
|
270 | stringActions: 0
|
271 | })
|
272 | })
|
273 | })
|
274 |
|
275 | describe('alternative builder callback for actionMap', () => {
|
276 | const increment = createAction<number, 'increment'>('increment')
|
277 | const decrement = createAction<number, 'decrement'>('decrement')
|
278 |
|
279 | test('can be used with ActionCreators', () => {
|
280 | const reducer = createReducer(0, builder =>
|
281 | builder
|
282 | .addCase(increment, (state, action) => state + action.payload)
|
283 | .addCase(decrement, (state, action) => state - action.payload)
|
284 | )
|
285 | expect(reducer(0, increment(5))).toBe(5)
|
286 | expect(reducer(5, decrement(5))).toBe(0)
|
287 | })
|
288 | test('can be used with string types', () => {
|
289 | const reducer = createReducer(0, builder =>
|
290 | builder
|
291 | .addCase(
|
292 | 'increment',
|
293 | (state, action: { type: 'increment'; payload: number }) =>
|
294 | state + action.payload
|
295 | )
|
296 | .addCase(
|
297 | 'decrement',
|
298 | (state, action: { type: 'decrement'; payload: number }) =>
|
299 | state - action.payload
|
300 | )
|
301 | )
|
302 | expect(reducer(0, increment(5))).toBe(5)
|
303 | expect(reducer(5, decrement(5))).toBe(0)
|
304 | })
|
305 | test('can be used with ActionCreators and string types combined', () => {
|
306 | const reducer = createReducer(0, builder =>
|
307 | builder
|
308 | .addCase(increment, (state, action) => state + action.payload)
|
309 | .addCase(
|
310 | 'decrement',
|
311 | (state, action: { type: 'decrement'; payload: number }) =>
|
312 | state - action.payload
|
313 | )
|
314 | )
|
315 | expect(reducer(0, increment(5))).toBe(5)
|
316 | expect(reducer(5, decrement(5))).toBe(0)
|
317 | })
|
318 | test('will throw an error when returning undefined from a non-draftable state', () => {
|
319 | const reducer = createReducer(0, builder =>
|
320 | builder.addCase(
|
321 | 'decrement',
|
322 | (state, action: { type: 'decrement'; payload: number }) => {}
|
323 | )
|
324 | )
|
325 | expect(() => reducer(5, decrement(5))).toThrowErrorMatchingInlineSnapshot(
|
326 | `"A case reducer on a non-draftable value must not return undefined"`
|
327 | )
|
328 | })
|
329 | test('allows you to return undefined if the state was null, thus skipping an update', () => {
|
330 | const reducer = createReducer(null as number | null, builder =>
|
331 | builder.addCase(
|
332 | 'decrement',
|
333 | (state, action: { type: 'decrement'; payload: number }) => {
|
334 | if (typeof state === 'number') {
|
335 | return state - action.payload
|
336 | }
|
337 | }
|
338 | )
|
339 | )
|
340 | expect(reducer(0, decrement(5))).toBe(-5)
|
341 | expect(reducer(null, decrement(5))).toBe(null)
|
342 | })
|
343 | test('allows you to return null', () => {
|
344 | const reducer = createReducer(0 as number | null, builder =>
|
345 | builder.addCase(
|
346 | 'decrement',
|
347 | (state, action: { type: 'decrement'; payload: number }) => {
|
348 | return null
|
349 | }
|
350 | )
|
351 | )
|
352 | expect(reducer(5, decrement(5))).toBe(null)
|
353 | })
|
354 | test('allows you to return 0', () => {
|
355 | const reducer = createReducer(0, builder =>
|
356 | builder.addCase(
|
357 | 'decrement',
|
358 | (state, action: { type: 'decrement'; payload: number }) =>
|
359 | state - action.payload
|
360 | )
|
361 | )
|
362 | expect(reducer(5, decrement(5))).toBe(0)
|
363 | })
|
364 | test('will throw if the same type is used twice', () => {
|
365 | expect(() =>
|
366 | createReducer(0, builder =>
|
367 | builder
|
368 | .addCase(increment, (state, action) => state + action.payload)
|
369 | .addCase(increment, (state, action) => state + action.payload)
|
370 | .addCase(decrement, (state, action) => state - action.payload)
|
371 | )
|
372 | ).toThrowErrorMatchingInlineSnapshot(
|
373 | `"addCase cannot be called with two reducers for the same action type"`
|
374 | )
|
375 | expect(() =>
|
376 | createReducer(0, builder =>
|
377 | builder
|
378 | .addCase(increment, (state, action) => state + action.payload)
|
379 | .addCase('increment', state => state + 1)
|
380 | .addCase(decrement, (state, action) => state - action.payload)
|
381 | )
|
382 | ).toThrowErrorMatchingInlineSnapshot(
|
383 | `"addCase cannot be called with two reducers for the same action type"`
|
384 | )
|
385 | })
|
386 | })
|
387 |
|
388 | describe('builder "addMatcher" method', () => {
|
389 | const prepareNumberAction = (payload: number) => ({
|
390 | payload,
|
391 | meta: { type: 'number_action' }
|
392 | })
|
393 | const prepareStringAction = (payload: string) => ({
|
394 | payload,
|
395 | meta: { type: 'string_action' }
|
396 | })
|
397 |
|
398 | const numberActionMatcher = (a: AnyAction): a is PayloadAction<number> =>
|
399 | a.meta && a.meta.type === 'number_action'
|
400 | const stringActionMatcher = (a: AnyAction): a is PayloadAction<string> =>
|
401 | a.meta && a.meta.type === 'string_action'
|
402 |
|
403 | const incrementBy = createAction('increment', prepareNumberAction)
|
404 | const decrementBy = createAction('decrement', prepareNumberAction)
|
405 | const concatWith = createAction('concat', prepareStringAction)
|
406 |
|
407 | const initialState = { numberActions: 0, stringActions: 0 }
|
408 |
|
409 | test('uses the reducer of matching actionMatchers', () => {
|
410 | const reducer = createReducer(initialState, builder =>
|
411 | builder
|
412 | .addMatcher(numberActionMatcher, state => {
|
413 | state.numberActions += 1
|
414 | })
|
415 | .addMatcher(stringActionMatcher, state => {
|
416 | state.stringActions += 1
|
417 | })
|
418 | )
|
419 | expect(reducer(undefined, incrementBy(1))).toEqual({
|
420 | numberActions: 1,
|
421 | stringActions: 0
|
422 | })
|
423 | expect(reducer(undefined, decrementBy(1))).toEqual({
|
424 | numberActions: 1,
|
425 | stringActions: 0
|
426 | })
|
427 | expect(reducer(undefined, concatWith('foo'))).toEqual({
|
428 | numberActions: 0,
|
429 | stringActions: 1
|
430 | })
|
431 | })
|
432 | test('falls back to defaultCase', () => {
|
433 | const reducer = createReducer(initialState, builder =>
|
434 | builder
|
435 | .addCase(concatWith, state => {
|
436 | state.stringActions += 1
|
437 | })
|
438 | .addMatcher(numberActionMatcher, state => {
|
439 | state.numberActions += 1
|
440 | })
|
441 | .addDefaultCase(state => {
|
442 | state.numberActions = -1
|
443 | state.stringActions = -1
|
444 | })
|
445 | )
|
446 | expect(reducer(undefined, { type: 'somethingElse' })).toEqual({
|
447 | numberActions: -1,
|
448 | stringActions: -1
|
449 | })
|
450 | })
|
451 | test('runs reducer cases followed by all matching actionMatchers', () => {
|
452 | const reducer = createReducer(initialState, builder =>
|
453 | builder
|
454 | .addCase(incrementBy, state => {
|
455 | state.numberActions = state.numberActions * 10 + 1
|
456 | })
|
457 | .addMatcher(numberActionMatcher, state => {
|
458 | state.numberActions = state.numberActions * 10 + 2
|
459 | })
|
460 | .addMatcher(stringActionMatcher, state => {
|
461 | state.stringActions = state.stringActions * 10 + 1
|
462 | })
|
463 | .addMatcher(numberActionMatcher, state => {
|
464 | state.numberActions = state.numberActions * 10 + 3
|
465 | })
|
466 | )
|
467 | expect(reducer(undefined, incrementBy(1))).toEqual({
|
468 | numberActions: 123,
|
469 | stringActions: 0
|
470 | })
|
471 | expect(reducer(undefined, decrementBy(1))).toEqual({
|
472 | numberActions: 23,
|
473 | stringActions: 0
|
474 | })
|
475 | expect(reducer(undefined, concatWith('foo'))).toEqual({
|
476 | numberActions: 0,
|
477 | stringActions: 1
|
478 | })
|
479 | })
|
480 | test('works with `actionCreator.match`', () => {
|
481 | const reducer = createReducer(initialState, builder =>
|
482 | builder.addMatcher(incrementBy.match, state => {
|
483 | state.numberActions += 100
|
484 | })
|
485 | )
|
486 | expect(reducer(undefined, incrementBy(1))).toEqual({
|
487 | numberActions: 100,
|
488 | stringActions: 0
|
489 | })
|
490 | })
|
491 | test('calling addCase, addMatcher and addDefaultCase in a nonsensical order should result in an error in development mode', () => {
|
492 | expect(() =>
|
493 | createReducer(initialState, (builder: any) =>
|
494 | builder
|
495 | .addMatcher(numberActionMatcher, () => {})
|
496 | .addCase(incrementBy, () => {})
|
497 | )
|
498 | ).toThrowErrorMatchingInlineSnapshot(
|
499 | `"\`builder.addCase\` should only be called before calling \`builder.addMatcher\`"`
|
500 | )
|
501 | expect(() =>
|
502 | createReducer(initialState, (builder: any) =>
|
503 | builder.addDefaultCase(() => {}).addCase(incrementBy, () => {})
|
504 | )
|
505 | ).toThrowErrorMatchingInlineSnapshot(
|
506 | `"\`builder.addCase\` should only be called before calling \`builder.addDefaultCase\`"`
|
507 | )
|
508 | expect(() =>
|
509 | createReducer(initialState, (builder: any) =>
|
510 | builder
|
511 | .addDefaultCase(() => {})
|
512 | .addMatcher(numberActionMatcher, () => {})
|
513 | )
|
514 | ).toThrowErrorMatchingInlineSnapshot(
|
515 | `"\`builder.addMatcher\` should only be called before calling \`builder.addDefaultCase\`"`
|
516 | )
|
517 | expect(() =>
|
518 | createReducer(initialState, (builder: any) =>
|
519 | builder.addDefaultCase(() => {}).addDefaultCase(() => {})
|
520 | )
|
521 | ).toThrowErrorMatchingInlineSnapshot(
|
522 | `"\`builder.addDefaultCase\` can only be called once"`
|
523 | )
|
524 | })
|
525 | })
|
526 | })
|
527 |
|
528 | function behavesLikeReducer(todosReducer: TodosReducer) {
|
529 | it('should handle initial state', () => {
|
530 | const initialAction = { type: '', payload: undefined }
|
531 | expect(todosReducer(undefined, initialAction)).toEqual([])
|
532 | })
|
533 |
|
534 | it('should handle ADD_TODO', () => {
|
535 | expect(
|
536 | todosReducer([], {
|
537 | type: 'ADD_TODO',
|
538 | payload: { newTodo: { text: 'Run the tests' } }
|
539 | })
|
540 | ).toEqual([
|
541 | {
|
542 | text: 'Run the tests',
|
543 | completed: false
|
544 | }
|
545 | ])
|
546 |
|
547 | expect(
|
548 | todosReducer(
|
549 | [
|
550 | {
|
551 | text: 'Run the tests',
|
552 | completed: false
|
553 | }
|
554 | ],
|
555 | {
|
556 | type: 'ADD_TODO',
|
557 | payload: { newTodo: { text: 'Use Redux' } }
|
558 | }
|
559 | )
|
560 | ).toEqual([
|
561 | {
|
562 | text: 'Run the tests',
|
563 | completed: false
|
564 | },
|
565 | {
|
566 | text: 'Use Redux',
|
567 | completed: false
|
568 | }
|
569 | ])
|
570 |
|
571 | expect(
|
572 | todosReducer(
|
573 | [
|
574 | {
|
575 | text: 'Run the tests',
|
576 | completed: false
|
577 | },
|
578 | {
|
579 | text: 'Use Redux',
|
580 | completed: false
|
581 | }
|
582 | ],
|
583 | {
|
584 | type: 'ADD_TODO',
|
585 | payload: { newTodo: { text: 'Fix the tests' } }
|
586 | }
|
587 | )
|
588 | ).toEqual([
|
589 | {
|
590 | text: 'Run the tests',
|
591 | completed: false
|
592 | },
|
593 | {
|
594 | text: 'Use Redux',
|
595 | completed: false
|
596 | },
|
597 | {
|
598 | text: 'Fix the tests',
|
599 | completed: false
|
600 | }
|
601 | ])
|
602 | })
|
603 |
|
604 | it('should handle TOGGLE_TODO', () => {
|
605 | expect(
|
606 | todosReducer(
|
607 | [
|
608 | {
|
609 | text: 'Run the tests',
|
610 | completed: false
|
611 | },
|
612 | {
|
613 | text: 'Use Redux',
|
614 | completed: false
|
615 | }
|
616 | ],
|
617 | {
|
618 | type: 'TOGGLE_TODO',
|
619 | payload: { index: 0 }
|
620 | }
|
621 | )
|
622 | ).toEqual([
|
623 | {
|
624 | text: 'Run the tests',
|
625 | completed: true
|
626 | },
|
627 | {
|
628 | text: 'Use Redux',
|
629 | completed: false
|
630 | }
|
631 | ])
|
632 | })
|
633 | }
|