@untemps/react-vocal 1.7.35 → 2.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +2 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.prettierignore +1 -0
- package/.prettierrc +1 -1
- package/CHANGELOG.md +29 -0
- package/CLAUDE.md +12 -8
- package/README.md +23 -16
- package/dev/index.html +24 -0
- package/dev/package.json +12 -8
- package/dev/src/{index.js → index.jsx} +2 -3
- package/dev/vite.config.js +10 -0
- package/dev/yarn.lock +296 -172
- package/dist/index.es.js +2182 -2
- package/dist/index.es.js.map +1 -0
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -0
- package/dist/index.umd.js +9 -2
- package/dist/index.umd.js.map +1 -0
- package/package.json +27 -60
- package/src/components/{Icon.js → Icon.jsx} +1 -14
- package/src/components/Vocal.jsx +206 -0
- package/src/components/__tests__/{Icon.test.js → Icon.test.jsx} +0 -4
- package/src/components/__tests__/{Vocal.test.js → Vocal.test.jsx} +175 -18
- package/src/components/__tests__/{VocalWithMockedUseVocal.test.js → VocalWithMockedUseVocal.test.jsx} +11 -13
- package/src/components/__tests__/__snapshots__/Icon.test.jsx.snap +21 -0
- package/src/components/__tests__/__snapshots__/Vocal.test.jsx.snap +28 -0
- package/src/hooks/__tests__/useCommands.test.js +1 -1
- package/src/hooks/__tests__/useTimeout.test.js +6 -6
- package/src/hooks/__tests__/useVocal.test.js +15 -15
- package/src/hooks/useTimeout.js +0 -2
- package/src/hooks/useVocal.js +0 -2
- package/vite.config.js +35 -0
- package/vitest.setup.js +74 -0
- package/babel.config.js +0 -12
- package/dev/babel.config.js +0 -4
- package/dev/rollup.config.js +0 -29
- package/jest/jest.setup.js +0 -72
- package/rollup.config.js +0 -42
- package/src/components/Vocal.js +0 -235
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@untemps/react-vocal",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-beta.2",
|
|
4
4
|
"author": "Vincent Le Badezet <v.lebadezet@untemps.net>",
|
|
5
5
|
"repository": "git@github.com:untemps/react-vocal.git",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,67 +23,36 @@
|
|
|
23
23
|
"main": "dist/index.js",
|
|
24
24
|
"module": "dist/index.es.js",
|
|
25
25
|
"engines": {
|
|
26
|
-
"node": "
|
|
26
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@
|
|
30
|
-
"@
|
|
31
|
-
"@babel/plugin-proposal-class-properties": "^7.12.1",
|
|
32
|
-
"@babel/plugin-transform-react-jsx": "^7.12.12",
|
|
33
|
-
"@babel/plugin-transform-runtime": "^7.12.10",
|
|
34
|
-
"@babel/preset-env": "^7.12.11",
|
|
35
|
-
"@babel/preset-react": "^7.12.10",
|
|
36
|
-
"@commitlint/cli": "^9.1.1",
|
|
37
|
-
"@commitlint/config-conventional": "^9.1.1",
|
|
38
|
-
"@rollup/plugin-babel": "^5.2.2",
|
|
39
|
-
"@rollup/plugin-commonjs": "^17.0.0",
|
|
40
|
-
"@rollup/plugin-node-resolve": "^11.0.1",
|
|
29
|
+
"@commitlint/cli": "^20.5.3",
|
|
30
|
+
"@commitlint/config-conventional": "^20.5.3",
|
|
41
31
|
"@semantic-release/changelog": "^6.0.3",
|
|
42
32
|
"@semantic-release/git": "^10.0.1",
|
|
43
33
|
"@semantic-release/github": "^12.0.6",
|
|
44
|
-
"@testing-library/dom": "^
|
|
45
|
-
"@testing-library/jest-dom": "^
|
|
46
|
-
"@testing-library/react": "^
|
|
47
|
-
"@
|
|
48
|
-
"@
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"react-test-renderer": "^17.0.1",
|
|
59
|
-
"rimraf": "^3.0.2",
|
|
60
|
-
"rollup": "^2.35.1",
|
|
61
|
-
"rollup-plugin-sizes": "^1.0.4",
|
|
62
|
-
"rollup-plugin-terser": "^7.0.2",
|
|
63
|
-
"rollup-plugin-visualizer": "^5.14.0",
|
|
64
|
-
"semantic-release": "^25.0.3"
|
|
34
|
+
"@testing-library/dom": "^10.4.1",
|
|
35
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
36
|
+
"@testing-library/react": "^16.3.2",
|
|
37
|
+
"@untemps/utils": "^3.2.0",
|
|
38
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
39
|
+
"@vitest/coverage-v8": "^4.1.5",
|
|
40
|
+
"husky": "^9.1.7",
|
|
41
|
+
"jsdom": "^29.1.1",
|
|
42
|
+
"prettier": "^3.8.3",
|
|
43
|
+
"react": "^19.2.5",
|
|
44
|
+
"react-dom": "^19.2.5",
|
|
45
|
+
"semantic-release": "^25.0.3",
|
|
46
|
+
"vite": "^8.0.10",
|
|
47
|
+
"vitest": "^4.1.5"
|
|
65
48
|
},
|
|
66
49
|
"peerDependencies": {
|
|
67
|
-
"react": "
|
|
68
|
-
"react-dom": "
|
|
50
|
+
"react": ">=16.13.1",
|
|
51
|
+
"react-dom": ">=16.13.1"
|
|
69
52
|
},
|
|
70
53
|
"dependencies": {
|
|
71
54
|
"@untemps/vocal": "^1.3.0",
|
|
72
|
-
"fuse.js": "^
|
|
73
|
-
},
|
|
74
|
-
"jest": {
|
|
75
|
-
"coverageDirectory": "./coverage/",
|
|
76
|
-
"collectCoverage": true,
|
|
77
|
-
"setupFilesAfterEnv": [
|
|
78
|
-
"<rootDir>/jest/jest.setup.js"
|
|
79
|
-
],
|
|
80
|
-
"restoreMocks": true
|
|
81
|
-
},
|
|
82
|
-
"husky": {
|
|
83
|
-
"hooks": {
|
|
84
|
-
"pre-commit": "yarn test:ci && yarn prettier",
|
|
85
|
-
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
|
|
86
|
-
}
|
|
55
|
+
"fuse.js": "^7.3.0"
|
|
87
56
|
},
|
|
88
57
|
"release": {
|
|
89
58
|
"branches": [
|
|
@@ -131,13 +100,11 @@
|
|
|
131
100
|
]
|
|
132
101
|
},
|
|
133
102
|
"scripts": {
|
|
134
|
-
"dev": "cd dev &&
|
|
135
|
-
"test": "
|
|
136
|
-
"test:ci": "
|
|
137
|
-
"build": "
|
|
138
|
-
"
|
|
139
|
-
"
|
|
140
|
-
"build:umd": "cross-env NODE_ENV=production BABEL_ENV=umd rollup -c",
|
|
141
|
-
"prettier": "prettier \"*/**/*.js\" --ignore-path ./.prettierignore --write && git add . && git status"
|
|
103
|
+
"dev": "cd dev && yarn && yarn dev",
|
|
104
|
+
"test": "vitest",
|
|
105
|
+
"test:ci": "vitest run --coverage",
|
|
106
|
+
"build": "vite build",
|
|
107
|
+
"prepare": "husky",
|
|
108
|
+
"prettier": "prettier \"*/**/*.js\" --ignore-path ./.prettierignore --write && git add -u && git status"
|
|
142
109
|
}
|
|
143
110
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import PropTypes from 'prop-types'
|
|
3
2
|
|
|
4
|
-
const Icon = ({ color, activeColor, isActive }) => {
|
|
3
|
+
const Icon = ({ color = 'black', activeColor = 'red', isActive = false }) => {
|
|
5
4
|
return (
|
|
6
5
|
<svg
|
|
7
6
|
data-testid="__icon-root__"
|
|
@@ -22,16 +21,4 @@ const Icon = ({ color, activeColor, isActive }) => {
|
|
|
22
21
|
)
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
Icon.propTypes = {
|
|
26
|
-
color: PropTypes.string,
|
|
27
|
-
activeColor: PropTypes.string,
|
|
28
|
-
isActive: PropTypes.bool,
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
Icon.defaultProps = {
|
|
32
|
-
color: 'black',
|
|
33
|
-
activeColor: 'red',
|
|
34
|
-
isActive: false,
|
|
35
|
-
}
|
|
36
|
-
|
|
37
24
|
export default Icon
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import React, { cloneElement, isValidElement, useCallback, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { Vocal as SpeechRecognitionWrapper } from '@untemps/vocal'
|
|
3
|
+
import { isFunction } from '@untemps/utils/function/isFunction'
|
|
4
|
+
|
|
5
|
+
import useVocal from '../hooks/useVocal'
|
|
6
|
+
import useTimeout from '../hooks/useTimeout'
|
|
7
|
+
import useCommands from '../hooks/useCommands'
|
|
8
|
+
|
|
9
|
+
import Icon from './Icon'
|
|
10
|
+
|
|
11
|
+
const Vocal = ({
|
|
12
|
+
children,
|
|
13
|
+
commands = null,
|
|
14
|
+
lang = 'en-US',
|
|
15
|
+
grammars = null,
|
|
16
|
+
timeout = 3000,
|
|
17
|
+
ariaLabel = 'start recognition',
|
|
18
|
+
style = null,
|
|
19
|
+
className = null,
|
|
20
|
+
outlineStyle = '2px solid',
|
|
21
|
+
onStart = null,
|
|
22
|
+
onEnd = null,
|
|
23
|
+
onSpeechStart = null,
|
|
24
|
+
onSpeechEnd = null,
|
|
25
|
+
onResult = null,
|
|
26
|
+
onError = null,
|
|
27
|
+
onNoMatch = null,
|
|
28
|
+
__rsInstance,
|
|
29
|
+
}) => {
|
|
30
|
+
const buttonRef = useRef(null)
|
|
31
|
+
const [isListening, setIsListening] = useState(false)
|
|
32
|
+
|
|
33
|
+
const [, { start, stop, subscribe, unsubscribe }] = useVocal(lang, grammars, __rsInstance)
|
|
34
|
+
const triggerCommand = useCommands(commands)
|
|
35
|
+
|
|
36
|
+
const propsRef = useRef({})
|
|
37
|
+
propsRef.current = { onStart, onEnd, onSpeechStart, onSpeechEnd, onResult, onError, onNoMatch }
|
|
38
|
+
|
|
39
|
+
const triggerCommandRef = useRef(triggerCommand)
|
|
40
|
+
triggerCommandRef.current = triggerCommand
|
|
41
|
+
|
|
42
|
+
const unsubscribeAllRef = useRef(null)
|
|
43
|
+
const onEndRef = useRef(null)
|
|
44
|
+
|
|
45
|
+
// Breaks the circular dep: _onEnd → useTimeout(handler) → startTimer captures _onEnd
|
|
46
|
+
const stableTimerCb = useCallback(() => onEndRef.current?.(), [])
|
|
47
|
+
const [startTimer, stopTimer] = useTimeout(stableTimerCb, timeout)
|
|
48
|
+
|
|
49
|
+
const stopRecognition = useCallback(() => {
|
|
50
|
+
try {
|
|
51
|
+
setIsListening(false)
|
|
52
|
+
stop()
|
|
53
|
+
} catch (error) {
|
|
54
|
+
propsRef.current.onError?.(error)
|
|
55
|
+
} finally {
|
|
56
|
+
unsubscribeAllRef.current?.()
|
|
57
|
+
}
|
|
58
|
+
}, [stop])
|
|
59
|
+
|
|
60
|
+
const _onStart = useCallback(
|
|
61
|
+
(e) => {
|
|
62
|
+
startTimer()
|
|
63
|
+
propsRef.current.onStart?.(e)
|
|
64
|
+
},
|
|
65
|
+
[startTimer]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const _onSpeechStart = useCallback(
|
|
69
|
+
(e) => {
|
|
70
|
+
stopTimer()
|
|
71
|
+
propsRef.current.onSpeechStart?.(e)
|
|
72
|
+
},
|
|
73
|
+
[stopTimer]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const _onSpeechEnd = useCallback(
|
|
77
|
+
(e) => {
|
|
78
|
+
startTimer()
|
|
79
|
+
propsRef.current.onSpeechEnd?.(e)
|
|
80
|
+
},
|
|
81
|
+
[startTimer]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const _onResult = useCallback(
|
|
85
|
+
(event, result) => {
|
|
86
|
+
stopTimer()
|
|
87
|
+
stopRecognition()
|
|
88
|
+
triggerCommandRef.current(result)
|
|
89
|
+
propsRef.current.onResult?.(result, event)
|
|
90
|
+
},
|
|
91
|
+
[stopTimer, stopRecognition]
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const _onError = useCallback(
|
|
95
|
+
(error) => {
|
|
96
|
+
stopRecognition()
|
|
97
|
+
propsRef.current.onError?.(error)
|
|
98
|
+
},
|
|
99
|
+
[stopRecognition]
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const _onNoMatch = useCallback(
|
|
103
|
+
(e) => {
|
|
104
|
+
stopTimer()
|
|
105
|
+
stopRecognition()
|
|
106
|
+
propsRef.current.onNoMatch?.(e)
|
|
107
|
+
},
|
|
108
|
+
[stopTimer, stopRecognition]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const _onEnd = useCallback(
|
|
112
|
+
(e) => {
|
|
113
|
+
stopTimer()
|
|
114
|
+
stopRecognition()
|
|
115
|
+
propsRef.current.onEnd?.(e)
|
|
116
|
+
},
|
|
117
|
+
[stopTimer, stopRecognition]
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
onEndRef.current = _onEnd
|
|
121
|
+
|
|
122
|
+
const HANDLERS = useMemo(
|
|
123
|
+
() => ({
|
|
124
|
+
start: _onStart,
|
|
125
|
+
end: _onEnd,
|
|
126
|
+
speechstart: _onSpeechStart,
|
|
127
|
+
speechend: _onSpeechEnd,
|
|
128
|
+
result: _onResult,
|
|
129
|
+
error: _onError,
|
|
130
|
+
nomatch: _onNoMatch,
|
|
131
|
+
}),
|
|
132
|
+
[_onStart, _onEnd, _onSpeechStart, _onSpeechEnd, _onResult, _onError, _onNoMatch]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
// Assigned inline (not in useEffect) so it's ready before any event fires
|
|
136
|
+
unsubscribeAllRef.current = () => Object.entries(HANDLERS).forEach(([event, fn]) => unsubscribe?.(event, fn))
|
|
137
|
+
|
|
138
|
+
const startRecognition = useCallback(() => {
|
|
139
|
+
try {
|
|
140
|
+
setIsListening(true)
|
|
141
|
+
Object.entries(HANDLERS).forEach(([event, fn]) => subscribe(event, fn))
|
|
142
|
+
start()
|
|
143
|
+
} catch (error) {
|
|
144
|
+
_onError(error)
|
|
145
|
+
}
|
|
146
|
+
}, [HANDLERS, subscribe, start, _onError])
|
|
147
|
+
|
|
148
|
+
const _onFocus = () => {
|
|
149
|
+
if (!className && outlineStyle) {
|
|
150
|
+
buttonRef.current.style.outline = outlineStyle
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const _onBlur = () => {
|
|
155
|
+
if (!className && outlineStyle) {
|
|
156
|
+
buttonRef.current.style.outline = 'none'
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const _renderDefault = () => (
|
|
161
|
+
<button
|
|
162
|
+
data-testid="__vocal-root__"
|
|
163
|
+
ref={buttonRef}
|
|
164
|
+
role="button"
|
|
165
|
+
aria-label={ariaLabel}
|
|
166
|
+
style={
|
|
167
|
+
className
|
|
168
|
+
? null
|
|
169
|
+
: {
|
|
170
|
+
width: 24,
|
|
171
|
+
height: 24,
|
|
172
|
+
backgroundColor: 'transparent', // `background: none` shorthand resets all sub-properties; jsdom 29 + jest-dom v6 don't reflect that correctly via getComputedStyle
|
|
173
|
+
border: 'none',
|
|
174
|
+
padding: 0,
|
|
175
|
+
cursor: !isListening ? 'pointer' : 'default',
|
|
176
|
+
...style,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
className={className}
|
|
180
|
+
onFocus={_onFocus}
|
|
181
|
+
onBlur={_onBlur}
|
|
182
|
+
onClick={startRecognition}
|
|
183
|
+
>
|
|
184
|
+
<Icon isActive={isListening} color="#aaa" />
|
|
185
|
+
</button>
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
const _renderChildren = () => {
|
|
189
|
+
if (SpeechRecognitionWrapper.isSupported) {
|
|
190
|
+
if (isFunction(children)) {
|
|
191
|
+
return children(startRecognition, stopRecognition, isListening)
|
|
192
|
+
} else if (isValidElement(children)) {
|
|
193
|
+
return cloneElement(children, {
|
|
194
|
+
...(!isListening && { onClick: startRecognition }),
|
|
195
|
+
})
|
|
196
|
+
} else {
|
|
197
|
+
return _renderDefault()
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return _renderChildren()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export default Vocal
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @jest-environment jsdom
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
1
|
import React from 'react'
|
|
6
2
|
import { waitFor } from '@testing-library/dom'
|
|
7
3
|
import { act, fireEvent, render } from '@testing-library/react'
|
|
@@ -34,11 +30,11 @@ describe('Vocal', () => {
|
|
|
34
30
|
})
|
|
35
31
|
|
|
36
32
|
it('renders no children element if SpeechRecognition is not supported', () => {
|
|
37
|
-
|
|
33
|
+
vi.spyOn(SpeechRecognitionWrapper, 'isSupported', 'get').mockReturnValueOnce(false)
|
|
38
34
|
const { queryByTestId } = render(getInstance(null, <div data-testid="__vocal-custom-root__" />))
|
|
39
35
|
expect(queryByTestId('__vocal-root__')).not.toBeInTheDocument()
|
|
40
36
|
expect(queryByTestId('__vocal-custom-root__')).not.toBeInTheDocument()
|
|
41
|
-
|
|
37
|
+
vi.clearAllMocks()
|
|
42
38
|
})
|
|
43
39
|
|
|
44
40
|
it('renders custom children function', () => {
|
|
@@ -48,7 +44,7 @@ describe('Vocal', () => {
|
|
|
48
44
|
})
|
|
49
45
|
|
|
50
46
|
it('starts recognition with custom children function', async () => {
|
|
51
|
-
const onStart =
|
|
47
|
+
const onStart = vi.fn()
|
|
52
48
|
const { queryByTestId } = render(
|
|
53
49
|
getInstance({ onStart }, (start) => <div data-testid="__vocal-custom-root__" onClick={start} />)
|
|
54
50
|
)
|
|
@@ -59,7 +55,7 @@ describe('Vocal', () => {
|
|
|
59
55
|
})
|
|
60
56
|
|
|
61
57
|
it('stops recognition with custom children function', async () => {
|
|
62
|
-
const onEnd =
|
|
58
|
+
const onEnd = vi.fn()
|
|
63
59
|
const { queryByText } = render(
|
|
64
60
|
getInstance({ onEnd }, (start, stop) => (
|
|
65
61
|
<div data-testid="__vocal-custom-root__">
|
|
@@ -76,7 +72,7 @@ describe('Vocal', () => {
|
|
|
76
72
|
})
|
|
77
73
|
|
|
78
74
|
it('gets recognition status with custom children function', async () => {
|
|
79
|
-
const onEnd =
|
|
75
|
+
const onEnd = vi.fn()
|
|
80
76
|
const { queryByText } = render(
|
|
81
77
|
getInstance({ onEnd }, (start, stop, isStarted) => (
|
|
82
78
|
<div data-testid="__vocal-custom-root__">
|
|
@@ -128,11 +124,12 @@ describe('Vocal', () => {
|
|
|
128
124
|
|
|
129
125
|
it('uses custom styles', () => {
|
|
130
126
|
const { getByTestId } = render(getInstance({ style: { backgroundColor: 'blue' } }))
|
|
131
|
-
|
|
127
|
+
// jest-dom v6 + jsdom 29: `div.style.color = 'blue'` reads back as 'blue' but getComputedStyle returns RGB; normalisation no longer bridges the gap
|
|
128
|
+
expect(getByTestId('__vocal-root__')).toHaveStyle({ backgroundColor: 'rgb(0, 0, 255)' })
|
|
132
129
|
})
|
|
133
130
|
|
|
134
131
|
it('responds to command', async () => {
|
|
135
|
-
const callback =
|
|
132
|
+
const callback = vi.fn()
|
|
136
133
|
const recognition = new SpeechRecognitionWrapper()
|
|
137
134
|
const commands = { foo: callback }
|
|
138
135
|
const { getByTestId } = render(getInstance({ __rsInstance: recognition, commands }))
|
|
@@ -153,7 +150,7 @@ describe('Vocal', () => {
|
|
|
153
150
|
})
|
|
154
151
|
|
|
155
152
|
it('triggers onStart handler', async () => {
|
|
156
|
-
const onStart =
|
|
153
|
+
const onStart = vi.fn()
|
|
157
154
|
const { queryByTestId } = render(getInstance({ onStart }))
|
|
158
155
|
await act(async () => {
|
|
159
156
|
fireEvent.click(queryByTestId('__vocal-root__'))
|
|
@@ -162,7 +159,7 @@ describe('Vocal', () => {
|
|
|
162
159
|
})
|
|
163
160
|
|
|
164
161
|
it('triggers onResult handler', async () => {
|
|
165
|
-
const onResult =
|
|
162
|
+
const onResult = vi.fn()
|
|
166
163
|
const recognition = new SpeechRecognitionWrapper()
|
|
167
164
|
const { getByTestId } = render(getInstance({ __rsInstance: recognition, onResult }))
|
|
168
165
|
|
|
@@ -182,7 +179,7 @@ describe('Vocal', () => {
|
|
|
182
179
|
})
|
|
183
180
|
|
|
184
181
|
it('triggers onNoMatch handler', async () => {
|
|
185
|
-
const onNoMatch =
|
|
182
|
+
const onNoMatch = vi.fn()
|
|
186
183
|
const recognition = new SpeechRecognitionWrapper()
|
|
187
184
|
const { getByTestId } = render(getInstance({ __rsInstance: recognition, onNoMatch }))
|
|
188
185
|
|
|
@@ -202,7 +199,7 @@ describe('Vocal', () => {
|
|
|
202
199
|
})
|
|
203
200
|
|
|
204
201
|
it('triggers onSpeechStart handler', async () => {
|
|
205
|
-
const onSpeechStart =
|
|
202
|
+
const onSpeechStart = vi.fn()
|
|
206
203
|
const recognition = new SpeechRecognitionWrapper()
|
|
207
204
|
const { getByTestId } = render(getInstance({ __rsInstance: recognition, onSpeechStart }))
|
|
208
205
|
|
|
@@ -222,7 +219,7 @@ describe('Vocal', () => {
|
|
|
222
219
|
})
|
|
223
220
|
|
|
224
221
|
it('triggers onSpeechEnd handler', async () => {
|
|
225
|
-
const onSpeechEnd =
|
|
222
|
+
const onSpeechEnd = vi.fn()
|
|
226
223
|
const recognition = new SpeechRecognitionWrapper()
|
|
227
224
|
const { getByTestId } = render(getInstance({ __rsInstance: recognition, onSpeechEnd }))
|
|
228
225
|
|
|
@@ -243,7 +240,7 @@ describe('Vocal', () => {
|
|
|
243
240
|
|
|
244
241
|
it('triggers onEnd handler after timeout', async () => {
|
|
245
242
|
const timeout = 100
|
|
246
|
-
const onEnd =
|
|
243
|
+
const onEnd = vi.fn()
|
|
247
244
|
const { getByTestId } = render(getInstance({ timeout, onEnd }))
|
|
248
245
|
await act(async () => {
|
|
249
246
|
fireEvent.click(getByTestId('__vocal-root__'))
|
|
@@ -252,7 +249,7 @@ describe('Vocal', () => {
|
|
|
252
249
|
})
|
|
253
250
|
|
|
254
251
|
it('triggers onEnd handler after speech', async () => {
|
|
255
|
-
const onEnd =
|
|
252
|
+
const onEnd = vi.fn()
|
|
256
253
|
const recognition = new SpeechRecognitionWrapper()
|
|
257
254
|
const { getByTestId } = render(getInstance({ __rsInstance: recognition, onEnd }))
|
|
258
255
|
|
|
@@ -270,4 +267,164 @@ describe('Vocal', () => {
|
|
|
270
267
|
await waitFor(() => expect(onEnd).toHaveBeenCalled())
|
|
271
268
|
})
|
|
272
269
|
})
|
|
270
|
+
|
|
271
|
+
it('calls the updated onEnd prop after a re-render during an active session', async () => {
|
|
272
|
+
const onEndV1 = vi.fn()
|
|
273
|
+
const onEndV2 = vi.fn()
|
|
274
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
275
|
+
const { getByTestId, rerender } = render(getInstance({ __rsInstance: recognition, onEnd: onEndV1 }))
|
|
276
|
+
|
|
277
|
+
await act(async () => {
|
|
278
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
279
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'default' }))
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
await act(async () => {
|
|
283
|
+
rerender(getInstance({ __rsInstance: recognition, onEnd: onEndV2 }))
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
await act(async () => {
|
|
287
|
+
recognition.instance.say('Foo')
|
|
288
|
+
await waitFor(() => expect(onEndV2).toHaveBeenCalled())
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
expect(onEndV1).not.toHaveBeenCalled()
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('resets to idle after a re-render during an active session', async () => {
|
|
295
|
+
const onEnd = vi.fn()
|
|
296
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
297
|
+
const { getByTestId, rerender } = render(getInstance({ __rsInstance: recognition, onEnd }))
|
|
298
|
+
|
|
299
|
+
await act(async () => {
|
|
300
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
301
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'default' }))
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
await act(async () => {
|
|
305
|
+
rerender(getInstance({ __rsInstance: recognition, onEnd }))
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
await act(async () => {
|
|
309
|
+
recognition.instance.say('Foo')
|
|
310
|
+
await waitFor(() => expect(onEnd).toHaveBeenCalled())
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'pointer' })
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('calls the updated onResult prop after a re-render during an active session', async () => {
|
|
317
|
+
const onResultV1 = vi.fn()
|
|
318
|
+
const onResultV2 = vi.fn()
|
|
319
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
320
|
+
const { getByTestId, rerender } = render(getInstance({ __rsInstance: recognition, onResult: onResultV1 }))
|
|
321
|
+
|
|
322
|
+
await act(async () => {
|
|
323
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
324
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'default' }))
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
await act(async () => {
|
|
328
|
+
rerender(getInstance({ __rsInstance: recognition, onResult: onResultV2 }))
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
await act(async () => {
|
|
332
|
+
recognition.instance.say('Foo')
|
|
333
|
+
await waitFor(() => expect(onResultV2).toHaveBeenCalledWith('Foo', expect.anything()))
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
expect(onResultV1).not.toHaveBeenCalled()
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('calls the updated onSpeechStart prop after a re-render during an active session', async () => {
|
|
340
|
+
const onSpeechStartV1 = vi.fn()
|
|
341
|
+
const onSpeechStartV2 = vi.fn()
|
|
342
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
343
|
+
const { getByTestId, rerender } = render(getInstance({ __rsInstance: recognition, onSpeechStart: onSpeechStartV1 }))
|
|
344
|
+
|
|
345
|
+
await act(async () => {
|
|
346
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
347
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'default' }))
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
await act(async () => {
|
|
351
|
+
rerender(getInstance({ __rsInstance: recognition, onSpeechStart: onSpeechStartV2 }))
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
await act(async () => {
|
|
355
|
+
recognition.instance.say('Foo')
|
|
356
|
+
await waitFor(() => expect(onSpeechStartV2).toHaveBeenCalled())
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
expect(onSpeechStartV1).not.toHaveBeenCalled()
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('calls the updated onSpeechEnd prop after a re-render during an active session', async () => {
|
|
363
|
+
const onSpeechEndV1 = vi.fn()
|
|
364
|
+
const onSpeechEndV2 = vi.fn()
|
|
365
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
366
|
+
const { getByTestId, rerender } = render(getInstance({ __rsInstance: recognition, onSpeechEnd: onSpeechEndV1 }))
|
|
367
|
+
|
|
368
|
+
await act(async () => {
|
|
369
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
370
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'default' }))
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
await act(async () => {
|
|
374
|
+
rerender(getInstance({ __rsInstance: recognition, onSpeechEnd: onSpeechEndV2 }))
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
await act(async () => {
|
|
378
|
+
recognition.instance.say('Foo')
|
|
379
|
+
await waitFor(() => expect(onSpeechEndV2).toHaveBeenCalled())
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
expect(onSpeechEndV1).not.toHaveBeenCalled()
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('calls the updated onNoMatch prop after a re-render during an active session', async () => {
|
|
386
|
+
const onNoMatchV1 = vi.fn()
|
|
387
|
+
const onNoMatchV2 = vi.fn()
|
|
388
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
389
|
+
const { getByTestId, rerender } = render(getInstance({ __rsInstance: recognition, onNoMatch: onNoMatchV1 }))
|
|
390
|
+
|
|
391
|
+
await act(async () => {
|
|
392
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
393
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'default' }))
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
await act(async () => {
|
|
397
|
+
rerender(getInstance({ __rsInstance: recognition, onNoMatch: onNoMatchV2 }))
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
await act(async () => {
|
|
401
|
+
recognition.instance.say(null)
|
|
402
|
+
await waitFor(() => expect(onNoMatchV2).toHaveBeenCalled())
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
expect(onNoMatchV1).not.toHaveBeenCalled()
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('calls the updated onError prop after a re-render during an active session', async () => {
|
|
409
|
+
const onErrorV1 = vi.fn()
|
|
410
|
+
const onErrorV2 = vi.fn()
|
|
411
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
412
|
+
const { getByTestId, rerender } = render(getInstance({ __rsInstance: recognition, onError: onErrorV1 }))
|
|
413
|
+
|
|
414
|
+
await act(async () => {
|
|
415
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
416
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'default' }))
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
await act(async () => {
|
|
420
|
+
rerender(getInstance({ __rsInstance: recognition, onError: onErrorV2 }))
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
await act(async () => {
|
|
424
|
+
recognition.instance.error(new Error('mic failure'))
|
|
425
|
+
await waitFor(() => expect(onErrorV2).toHaveBeenCalled())
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
expect(onErrorV1).not.toHaveBeenCalled()
|
|
429
|
+
})
|
|
273
430
|
})
|
|
@@ -1,22 +1,20 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @jest-environment jsdom
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
1
|
import React from 'react'
|
|
6
2
|
import { waitFor } from '@testing-library/dom'
|
|
7
3
|
import { act, fireEvent, render } from '@testing-library/react'
|
|
8
4
|
|
|
9
5
|
import Vocal from '../Vocal'
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
return
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
7
|
+
vi.mock('../../hooks/useVocal', () => {
|
|
8
|
+
return {
|
|
9
|
+
default: () => [
|
|
10
|
+
null,
|
|
11
|
+
{
|
|
12
|
+
subscribe: () => {
|
|
13
|
+
throw new Error('Foo')
|
|
14
|
+
},
|
|
17
15
|
},
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
],
|
|
17
|
+
}
|
|
20
18
|
})
|
|
21
19
|
|
|
22
20
|
const defaultProps = {}
|
|
@@ -28,7 +26,7 @@ const getInstance = (props = {}, children = null) => (
|
|
|
28
26
|
|
|
29
27
|
describe('Vocal', () => {
|
|
30
28
|
it('triggers onError handler', async () => {
|
|
31
|
-
const onError =
|
|
29
|
+
const onError = vi.fn()
|
|
32
30
|
const { queryByTestId } = render(getInstance({ onError }))
|
|
33
31
|
await act(async () => {
|
|
34
32
|
fireEvent.click(queryByTestId('__vocal-root__'))
|