1 import ExpoModulesTestCore
2 
3 @testable import ExpoModulesCore
4 
5 class FunctionSpec: ExpoSpec {
6   override func spec() {
7     let appContext = AppContext.create()
8     let functionName = "test function name"
9 
10     context("native") {
11       func testFunctionReturning<T: Equatable>(value returnValue: T) {
12         waitUntil { done in
13           mockModuleHolder(appContext) {
14             AsyncFunction(functionName) {
15               return returnValue
16             }
17           }
18           .call(function: functionName, args: []) { result in
19             let value = try! result.get()
20 
21             expect(value).notTo(beNil())
22             expect(value).to(beAKindOf(T.self))
23             expect(value as? T).to(equal(returnValue))
24             done()
25           }
26         }
27       }
28 
29       it("is called") {
30         waitUntil { done in
31           mockModuleHolder(appContext) {
32             AsyncFunction(functionName) {
33               done()
34             }
35           }
36           .call(function: functionName, args: [])
37         }
38       }
39 
40       it("returns bool values") {
41         testFunctionReturning(value: true)
42         testFunctionReturning(value: false)
43         testFunctionReturning(value: [true, false])
44       }
45 
46       it("returns int values") {
47         testFunctionReturning(value: 1_234)
48         testFunctionReturning(value: [2, 1, 3, 7])
49       }
50 
51       it("returns double values") {
52         testFunctionReturning(value: 3.14)
53         testFunctionReturning(value: [0, 1.1, 2.2])
54       }
55 
56       it("returns string values") {
57         testFunctionReturning(value: "a string")
58         testFunctionReturning(value: ["expo", "modules", "core"])
59       }
60 
61       it("is called with nil value") {
62         let str: String? = nil
63 
64         mockModuleHolder(appContext) {
65           AsyncFunction(functionName) { (a: String?) in
66             expect(a == nil) == true
67           }
68         }
69         .callSync(function: functionName, args: [str as Any])
70       }
71 
72       it("is called with an array of arrays") {
73         let array: [[String]] = [["expo"]]
74 
75         mockModuleHolder(appContext) {
76           AsyncFunction(functionName) { (a: [[String]]) in
77             expect(a.first!.first) == array.first!.first
78           }
79         }
80         .callSync(function: functionName, args: [array])
81       }
82 
83       describe("converting records") {
84         struct TestRecord: Record {
85           @Field var property: String = "expo"
86           @Field var optionalProperty: Int?
87           @Field("propertyWithCustomKey") var customKeyProperty: String = "expo"
88         }
89         let dict = [
90           "property": "Hello",
91           "propertyWithCustomKey": "Expo!"
92         ]
93 
94         it("converts to simple record when passed as an argument") {
95           waitUntil { done in
96             mockModuleHolder(appContext) {
97               AsyncFunction(functionName) { (a: TestRecord) in
98                 return a.property
99               }
100             }
101             .call(function: functionName, args: [dict]) { result in
102               let value = try! result.get()
103 
104               expect(value).notTo(beNil())
105               expect(value).to(beAKindOf(String.self))
106               expect(value).to(be(dict["property"]))
107               done()
108             }
109           }
110         }
111 
112         it("converts to record with custom key") {
113           waitUntil { done in
114             mockModuleHolder(appContext) {
115               AsyncFunction(functionName) { (a: TestRecord) in
116                 return a.customKeyProperty
117               }
118             }
119             .call(function: functionName, args: [dict]) { result in
120               let value = try! result.get()
121               expect(value).notTo(beNil())
122               expect(value).to(beAKindOf(String.self))
123               expect(value).to(be(dict["propertyWithCustomKey"]))
124               done()
125             }
126           }
127         }
128 
129         it("returns the record back (sync)") {
130           let result = try Function(functionName) { (record: TestRecord) in record }
131             .call(by: nil, withArguments: [dict], appContext: appContext) as? TestRecord.Dict
132 
133           expect(result).notTo(beNil())
134           expect(result?["property"] as? String).to(equal(dict["property"]))
135           expect(result?["propertyWithCustomKey"] as? String).to(equal(dict["propertyWithCustomKey"]))
136         }
137 
138         it("returns the record back (async)") {
139           waitUntil { done in
140             mockModuleHolder(appContext) {
141               AsyncFunction(functionName) { (a: TestRecord) in
142                 return a
143               }
144             }
145             .call(function: functionName, args: [dict]) { result in
146               let value = try! result.get()
147 
148               expect(value).notTo(beNil())
149               expect(value).to(beAKindOf(Record.Dict.self))
150 
151               let valueAsDict = value as! Record.Dict
152 
153               expect(valueAsDict["property"] as? String).to(equal(dict["property"]))
154               expect(valueAsDict["propertyWithCustomKey"] as? String).to(equal(dict["propertyWithCustomKey"]))
155               done()
156             }
157           }
158         }
159       }
160 
161       it("throws when called with more arguments than expected") {
162         waitUntil { done in
163           mockModuleHolder(appContext) {
164             AsyncFunction(functionName) { (_: Int) in
165               return "something"
166             }
167           }
168           // Function expects one argument, let's give it more.
169           .call(function: functionName, args: [1, 2]) { result in
170             switch result {
171             case .failure(let error):
172               expect(error).notTo(beNil())
173               expect(error).to(beAKindOf(InvalidArgsNumberException.self))
174             case .success(_):
175               fail()
176             }
177             done()
178           }
179         }
180       }
181 
182       it("allows to skip trailing optional arguments") {
183         let returnedValue = "something"
184         let fn = Function(functionName) { (a: String, b: Int?, c: Bool?) in
185           expect(c).to(beNil())
186           return returnedValue
187         }
188 
189         expect({ try fn.call(by: nil, withArguments: ["test"], appContext: appContext) })
190           .notTo(throwError())
191           .to(be(returnedValue))
192 
193         expect({ try fn.call(by: nil, withArguments: ["test", 3], appContext: appContext) })
194           .notTo(throwError())
195           .to(be(returnedValue))
196       }
197 
198       it("throws when called without required arguments") {
199         let fn = Function(functionName) { (requiredArgument: String, optionalArgument: Int?) in
200           return "something"
201         }
202 
203         expect({ try fn.call(by: nil, withArguments: [], appContext: appContext) })
204           .to(throwError(errorType: FunctionCallException.self) { error in
205             expect(error.rootCause).to(beAKindOf(InvalidArgsNumberException.self))
206             let exception = error.rootCause as! InvalidArgsNumberException
207             expect(exception.param.received) == 0
208             expect(exception.param.required) == 1
209             expect(exception.param.expected) == 2
210           })
211       }
212 
213       it("throws when called with arguments of incompatible types") {
214         waitUntil { done in
215           mockModuleHolder(appContext) {
216             AsyncFunction(functionName) { (_: String) in
217               return "something"
218             }
219           }
220           // Function expects a string, let's give it a number.
221           .call(function: functionName, args: [1]) { result in
222             switch result {
223             case .failure(let error):
224               expect(error).notTo(beNil())
225               expect(error).to(beAKindOf(FunctionCallException.self))
226               expect(error.isCausedBy(ArgumentCastException.self)) == true
227               expect(error.isCausedBy(Conversions.CastingException<String>.self)) == true
228             case .success(_):
229               fail()
230             }
231             done()
232           }
233         }
234       }
235     }
236 
237     context("JavaScript") {
238       let runtime = try! appContext.runtime
239 
240       beforeSuite {
241         appContext.moduleRegistry.register(holder: mockModuleHolder(appContext) {
242           Name("TestModule")
243 
244           Function("returnPi") { Double.pi }
245 
246           Function("returnNull") { () -> Double? in
247             return nil
248           }
249 
250           Function("isArgNull") { (arg: Double?) -> Bool in
251             return arg == nil
252           }
253 
254           Function("returnObjectDefinition") { (initial: Int) -> ObjectDefinition in
255             var foo = initial
256 
257             return Object {
258               Function("increment") { () -> Int in
259                 foo += 1
260                 return foo
261               }
262             }
263           }
264         })
265       }
266 
267       it("returns values") {
268         expect(try runtime.eval("expo.modules.TestModule.returnPi()").asDouble()) == Double.pi
269         expect(try runtime.eval("expo.modules.TestModule.returnNull()").isNull()) == true
270       }
271 
272       it("accepts optional arguments") {
273         expect(try runtime.eval("expo.modules.TestModule.isArgNull(3.14)").asBool()) == false
274         expect(try runtime.eval("expo.modules.TestModule.isArgNull(null)").asBool()) == true
275       }
276 
277       it("returns object made from definition") {
278         let initialValue = Int.random(in: 1..<100)
279         let object = try runtime.eval("object = expo.modules.TestModule.returnObjectDefinition(\(initialValue))")
280 
281         expect(object.kind) == .object
282         expect(object.getObject().hasProperty("increment")) == true
283 
284         let result = try runtime.eval("object.increment()")
285 
286         expect(result.kind) == .number
287         expect(result.getInt()) == initialValue + 1
288       }
289     }
290   }
291 }
292