diff --git a/__tests__/01_basic.spec.tsx b/__tests__/01_basic.spec.tsx new file mode 100644 index 0000000..ba6054f --- /dev/null +++ b/__tests__/01_basic.spec.tsx @@ -0,0 +1,58 @@ +import { create } from 'zustand'; +import { createSlice, withSlices } from '../src/index'; + +describe('basic spec', () => { + it('should export functions', () => { + expect(createSlice).toBeDefined(); + expect(withSlices).toBeDefined(); + }); + describe('createSlice', () => { + it('should return a slice config', () => { + const slice = createSlice({ + name: 'counter', + value: 0, + actions: { + increment: () => (prev) => prev + 1, + }, + }); + // returns the input + expect(createSlice(slice)).toBe(slice); + }); + }); + describe('withSlices', () => { + it('should combine slices and nest state', () => { + const countSlice = createSlice({ + name: 'count', + value: 0, + actions: { + inc: () => (prev) => prev + 1, + reset: () => () => 0, + }, + }); + + const textSlice = createSlice({ + name: 'text', + value: 'Hello', + actions: { + updateText: (newText: string) => () => newText, + reset: () => () => 'Hello', + }, + }); + + const combinedConfig = withSlices(countSlice, textSlice); + + expect(combinedConfig).toBeInstanceOf(Function); + + const store = create(combinedConfig); + + const state = store.getState(); + + expect(state.count).toBe(countSlice.value); + expect(state.text).toBe(textSlice.value); + + expect(state.inc).toBeInstanceOf(Function); + expect(state.reset).toBeInstanceOf(Function); + expect(state.updateText).toBeInstanceOf(Function); + }); + }); +}); diff --git a/__tests__/01_basic_spec.tsx b/__tests__/01_basic_spec.tsx deleted file mode 100644 index a64f3c1..0000000 --- a/__tests__/01_basic_spec.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { createSlice, withSlices } from '../src/index'; - -describe('basic spec', () => { - it('should export functions', () => { - expect(createSlice).toBeDefined(); - expect(withSlices).toBeDefined(); - }); -}); diff --git a/__tests__/02_type_spec.tsx b/__tests__/02_type.spec.tsx similarity index 100% rename from __tests__/02_type_spec.tsx rename to __tests__/02_type.spec.tsx diff --git a/__tests__/03_component.spec.tsx b/__tests__/03_component.spec.tsx new file mode 100644 index 0000000..832b740 --- /dev/null +++ b/__tests__/03_component.spec.tsx @@ -0,0 +1,153 @@ +import { ReactNode, createContext, useContext, useState } from 'react'; +import { StoreApi, create, useStore as useZustandStore } from 'zustand'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { render, RenderOptions } from '@testing-library/react'; +// eslint-disable-next-line import/no-extraneous-dependencies +import userEvent from '@testing-library/user-event'; +import { createSlice, withSlices } from '../src/index'; + +type ExtractState = S extends { + getState: () => infer State; +} + ? State + : never; + +function createZustandContext>( + makeStore: () => Store, +) { + const Context = createContext(undefined); + const StoreProvider = ({ children }: { children: ReactNode }) => { + const [store] = useState(makeStore); + return {children}; + }; + function useStore() { + const store = useContext(Context); + if (!store) { + throw new Error('useStore must be used within a StoreProvider'); + } + return store; + } + function useSelector( + selector: (state: ExtractState) => Selected, + ) { + const store = useStore(); + return useZustandStore(store, selector); + } + return { StoreProvider, useStore, useSelector }; +} + +const countSlice = createSlice({ + name: 'count', + value: 0, + actions: { + inc: () => (state) => state + 1, + reset: () => () => 0, + }, +}); + +const textSlice = createSlice({ + name: 'text', + value: 'Hello', + actions: { + updateText: (text: string) => () => text, + reset: () => () => 'Hello', + }, +}); + +const makeStore = () => create(withSlices(countSlice, textSlice)); + +const { StoreProvider, useStore, useSelector } = + createZustandContext(makeStore); + +const renderWithProvider = ( + ui: ReactNode, + options?: Omit, +) => render(ui, { wrapper: StoreProvider, ...options }); + +const Counter = () => { + const count = useSelector((state) => state.count); + const { inc } = useStore().getState(); + return ( +
+

{count}

+ +
+ ); +}; + +const Text = () => { + const text = useSelector((state) => state.text); + const { updateText } = useStore().getState(); + return ( +
+ updateText(e.target.value)} /> +
+ ); +}; + +const App = () => { + const { reset } = useStore().getState(); + return ( +
+ + + +
+ ); +}; + +describe('component spec', () => { + const user = userEvent.setup(); + it('should render the app', () => { + const { getByRole, getByTestId } = renderWithProvider(); + expect(getByTestId('count')).toHaveTextContent('0'); + expect(getByRole('textbox')).toBeInTheDocument(); + }); + it('should increment the count when the button is pressed', async () => { + const { getByRole, getByTestId } = renderWithProvider(); + + const count = getByTestId('count'); + expect(count).toHaveTextContent('0'); + + const button = getByRole('button', { name: 'Increment' }); + await user.click(button); + + expect(count).toHaveTextContent('1'); + }); + it('should update the text when the input is changed', async () => { + const { getByRole } = renderWithProvider(); + + const input = getByRole('textbox'); + expect(input).toHaveValue('Hello'); + + await user.type(input, ' World'); + + expect(input).toHaveValue('Hello World'); + }); + it('should reset the state when the reset button is pressed', async () => { + const { getByRole, getByTestId } = renderWithProvider(); + + const resetButton = getByRole('button', { name: 'Reset' }); + + const count = getByTestId('count'); + expect(count).toHaveTextContent('0'); + + const incrementButton = getByRole('button', { name: 'Increment' }); + await user.click(incrementButton); + expect(count).toHaveTextContent('1'); + + const input = getByRole('textbox'); + await user.type(input, ' World'); + expect(input).toHaveValue('Hello World'); + + await user.click(resetButton); + + // both slices reset because the action name is the same + expect(count).toHaveTextContent('0'); + expect(input).toHaveValue('Hello'); + }); +}); diff --git a/package.json b/package.json index 9467a38..2b7076f 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,10 @@ }, "jest": { "testEnvironment": "jsdom", - "preset": "ts-jest/presets/js-with-ts" + "preset": "ts-jest/presets/js-with-ts", + "setupFilesAfterEnv": [ + "./test-setup.ts" + ] }, "keywords": [ "react", @@ -44,6 +47,9 @@ ], "license": "MIT", "devDependencies": { + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^15.0.2", + "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", "@types/node": "^20.12.7", "@types/react": "^18.2.79", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a868d76..354889d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,15 @@ settings: excludeLinksFromLockfile: false devDependencies: + '@testing-library/jest-dom': + specifier: ^6.4.2 + version: 6.4.2(@types/jest@29.5.12)(jest@29.7.0) + '@testing-library/react': + specifier: ^15.0.2 + version: 15.0.2(react-dom@18.2.0)(react@18.2.0) + '@testing-library/user-event': + specifier: ^14.5.2 + version: 14.5.2(@testing-library/dom@10.0.0) '@types/jest': specifier: ^29.5.12 version: 29.5.12 @@ -100,6 +109,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /@adobe/css-tools@4.3.3: + resolution: {integrity: sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==} + dev: true + /@ampproject/remapping@2.3.0: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -1912,6 +1925,76 @@ packages: string.prototype.matchall: 4.0.11 dev: true + /@testing-library/dom@10.0.0: + resolution: {integrity: sha512-PmJPnogldqoVFf+EwbHvbBJ98MmqASV8kLrBYgsDNxQcFMeIS7JFL48sfyXvuMtgmWO/wMhh25odr+8VhDmn4g==} + engines: {node: '>=18'} + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/runtime': 7.24.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + dev: true + + /@testing-library/jest-dom@6.4.2(@types/jest@29.5.12)(jest@29.7.0): + resolution: {integrity: sha512-CzqH0AFymEMG48CpzXFriYYkOjk6ZGPCLMhW9e9jg3KMCn5OfJecF8GtGW7yGfR/IgCe3SX8BSwjdzI6BBbZLw==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@jest/globals': '>= 28' + '@types/bun': latest + '@types/jest': '>= 28' + jest: '>= 28' + vitest: '>= 0.32' + peerDependenciesMeta: + '@jest/globals': + optional: true + '@types/bun': + optional: true + '@types/jest': + optional: true + jest: + optional: true + vitest: + optional: true + dependencies: + '@adobe/css-tools': 4.3.3 + '@babel/runtime': 7.24.4 + '@types/jest': 29.5.12 + aria-query: 5.3.0 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + jest: 29.7.0(@types/node@20.12.7) + lodash: 4.17.21 + redent: 3.0.0 + dev: true + + /@testing-library/react@15.0.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-5mzIpuytB1ctpyywvyaY2TAAUQVCZIGqwiqFQf6u9lvj/SJQepGUzNV18Xpk+NLCaCE2j7CWrZE0tEf9xLZYiQ==} + engines: {node: '>=18'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + '@babel/runtime': 7.24.4 + '@testing-library/dom': 10.0.0 + '@types/react-dom': 18.2.25 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: true + + /@testing-library/user-event@14.5.2(@testing-library/dom@10.0.0): + resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + dependencies: + '@testing-library/dom': 10.0.0 + dev: true + /@tootallnate/once@2.0.0: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} @@ -1922,6 +2005,10 @@ packages: engines: {node: '>=10.13.0'} dev: true + /@types/aria-query@5.0.4: + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + dev: true + /@types/babel__core@7.20.5: resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} dependencies: @@ -3130,6 +3217,14 @@ packages: supports-color: 5.5.0 dev: true + /chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: true + /chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3423,6 +3518,10 @@ packages: engines: {node: '>= 6'} dev: true + /css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + dev: true + /cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3725,6 +3824,14 @@ packages: esutils: 2.0.3 dev: true + /dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dev: true + + /dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dev: true + /dom-converter@0.2.0: resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} dependencies: @@ -5077,6 +5184,11 @@ packages: engines: {node: '>=0.8.19'} dev: true + /indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + dev: true + /inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} dependencies: @@ -6206,6 +6318,11 @@ packages: yallist: 4.0.0 dev: true + /lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + dev: true + /magic-string@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} dependencies: @@ -6368,6 +6485,11 @@ packages: engines: {node: '>=6'} dev: true + /min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + /minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} dev: true @@ -7277,6 +7399,15 @@ packages: renderkid: 3.0.0 dev: true + /pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + dev: true + /pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7382,6 +7513,10 @@ packages: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: true + /react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + dev: true + /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: true @@ -7437,6 +7572,14 @@ packages: resolve: 1.22.8 dev: true + /redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + dev: true + /reflect.getprototypeof@1.0.6: resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} engines: {node: '>= 0.4'} @@ -8201,6 +8344,13 @@ packages: engines: {node: '>=6'} dev: true + /strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + /strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} diff --git a/test-setup.ts b/test-setup.ts new file mode 100644 index 0000000..660ea51 --- /dev/null +++ b/test-setup.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import '@testing-library/jest-dom';