11import { afterEach , expect , spyOn , test } from "bun:test" ;
2- import { openMicrophone } from "./dictation.tsx" ;
2+ import {
3+ createElement ,
4+ Fragment ,
5+ type ReactElement ,
6+ type ReactNode ,
7+ } from "react" ;
8+ import { renderToString } from "react-dom/server" ;
9+ import { Dictated , openMicrophone } from "./dictation.tsx" ;
310
411const realNavigator = globalThis . navigator ;
512const realAudioContext = globalThis . AudioContext ;
13+ const realWindow = globalThis . window ;
614
715afterEach ( ( ) => {
816 Object . defineProperty ( globalThis , "navigator" , {
@@ -13,6 +21,10 @@ afterEach(() => {
1321 value : realAudioContext ,
1422 configurable : true ,
1523 } ) ;
24+ Object . defineProperty ( globalThis , "window" , {
25+ value : realWindow ,
26+ configurable : true ,
27+ } ) ;
1628} ) ;
1729
1830test ( "a level meter that fails after the microphone opens stops the microphone" , async ( ) => {
@@ -36,3 +48,106 @@ test("a level meter that fails after the microphone opens stops the microphone",
3648 expect ( stopped ) . toBe ( 1 ) ;
3749 warn . mockRestore ( ) ;
3850} ) ;
51+
52+ test ( "a dictation started while another waits for its microphone closes the first microphone" , async ( ) => {
53+ const microphones : Array < ( stream : MediaStream ) => void > = [ ] ;
54+ let firstMicrophoneStops = 0 ;
55+ let firstMicrophoneStopped ! : ( ) => void ;
56+ const firstMicrophoneStop = new Promise < void > ( ( resolve ) => {
57+ firstMicrophoneStopped = resolve ;
58+ } ) ;
59+ const recognitions : Array < { started : boolean } > = [ ] ;
60+ let recognitionStarted ! : ( ) => void ;
61+ const recognitionStart = new Promise < void > ( ( resolve ) => {
62+ recognitionStarted = resolve ;
63+ } ) ;
64+
65+ class FakeRecognition {
66+ started = false ;
67+ start ( ) {
68+ this . started = true ;
69+ recognitionStarted ( ) ;
70+ }
71+ stop ( ) { }
72+ abort ( ) { }
73+ constructor ( ) {
74+ recognitions . push ( this ) ;
75+ }
76+ }
77+ class FakeAudioContext {
78+ createAnalyser ( ) {
79+ return { fftSize : 0 , getFloatTimeDomainData ( ) { } } ;
80+ }
81+ createMediaStreamSource ( ) {
82+ return { connect ( ) { } } ;
83+ }
84+ close ( ) {
85+ return Promise . resolve ( ) ;
86+ }
87+ }
88+ Object . defineProperty ( globalThis , "window" , {
89+ value : {
90+ SpeechRecognition : FakeRecognition ,
91+ setInterval : ( ) => 0 ,
92+ clearInterval ( ) { } ,
93+ } ,
94+ configurable : true ,
95+ } ) ;
96+ Object . defineProperty ( globalThis , "navigator" , {
97+ value : {
98+ language : "en-US" ,
99+ mediaDevices : {
100+ getUserMedia : ( ) => new Promise ( ( resolve ) => microphones . push ( resolve ) ) ,
101+ } ,
102+ } ,
103+ configurable : true ,
104+ } ) ;
105+ Object . defineProperty ( globalThis , "AudioContext" , {
106+ value : FakeAudioContext ,
107+ configurable : true ,
108+ } ) ;
109+
110+ const starts : Array < ( ) => void > = [ ] ;
111+ const box = ( control : ReactNode ) => {
112+ starts . push (
113+ ( control as ReactElement < { onToggle : ( ) => void } > ) . props . onToggle ,
114+ ) ;
115+ return null ;
116+ } ;
117+ renderToString (
118+ createElement (
119+ Fragment ,
120+ null ,
121+ createElement ( Dictated , {
122+ value : "" ,
123+ onValueChange : ( ) => { } ,
124+ children : box ,
125+ } ) ,
126+ createElement ( Dictated , {
127+ value : "" ,
128+ onValueChange : ( ) => { } ,
129+ children : box ,
130+ } ) ,
131+ ) ,
132+ ) ;
133+
134+ starts [ 0 ] ! ( ) ;
135+ starts [ 1 ] ! ( ) ;
136+ microphones [ 0 ] ! ( {
137+ getTracks : ( ) => [
138+ {
139+ stop : ( ) => {
140+ firstMicrophoneStops += 1 ;
141+ firstMicrophoneStopped ( ) ;
142+ } ,
143+ } ,
144+ ] ,
145+ } as unknown as MediaStream ) ;
146+ microphones [ 1 ] ! ( { getTracks : ( ) => [ ] } as unknown as MediaStream ) ;
147+ await Promise . all ( [ firstMicrophoneStop , recognitionStart ] ) ;
148+
149+ expect ( firstMicrophoneStops ) . toBe ( 1 ) ;
150+ expect (
151+ recognitions . filter ( ( recognition ) => recognition . started ) ,
152+ ) . toHaveLength ( 1 ) ;
153+ } ) ;
0 commit comments