diff --git a/bsc-plugin/.vscode/settings.json b/bsc-plugin/.vscode/settings.json index 5ed2f0fa..b3fc5698 100644 --- a/bsc-plugin/.vscode/settings.json +++ b/bsc-plugin/.vscode/settings.json @@ -18,5 +18,8 @@ "editor.tabSize": 4, "editor.insertSpaces": true, "typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": true, - "files.trimTrailingWhitespace": true + "files.trimTrailingWhitespace": true, + "cSpell.words": [ + "undent" + ] } \ No newline at end of file diff --git a/bsc-plugin/src/lib/rooibos/TestGroup.ts b/bsc-plugin/src/lib/rooibos/TestGroup.ts index d89e3283..73702ea6 100644 --- a/bsc-plugin/src/lib/rooibos/TestGroup.ts +++ b/bsc-plugin/src/lib/rooibos/TestGroup.ts @@ -1,5 +1,5 @@ -import type { AstEditor, CallExpression, DottedGetExpression } from 'brighterscript'; -import { ArrayLiteralExpression, createInvalidLiteral, createStringLiteral, createToken, isDottedGetExpression, TokenKind } from 'brighterscript'; +import type { AstEditor, CallExpression, DottedGetExpression, Expression } from 'brighterscript'; +import { isCallExpression, isCallfuncExpression, isIndexedGetExpression, ArrayLiteralExpression, createInvalidLiteral, createStringLiteral, createToken, isDottedGetExpression, TokenKind, isLiteralExpression, isVariableExpression } from 'brighterscript'; import * as brighterscript from 'brighterscript'; import { BrsTranspileState } from 'brighterscript/dist/parser/BrsTranspileState'; import { TranspileState } from 'brighterscript/dist/parser/TranspileState'; @@ -101,20 +101,26 @@ export class TestGroup extends TestBlock { let arg0 = callExpression.args[0]; if (brighterscript.isCallExpression(arg0) && isDottedGetExpression(arg0.callee)) { let functionName = arg0.callee.name.text; + let fullPath = this.getStringPathFromDottedGet(arg0.callee.obj as DottedGetExpression); editor.removeFromArray(callExpression.args, 0); if (!isNotCalled && !isStubCall) { const expectedArgs = new ArrayLiteralExpression(arg0.args, createToken(TokenKind.LeftSquareBracket), createToken(TokenKind.RightSquareBracket)); editor.addToArray(callExpression.args, 0, expectedArgs); } + editor.addToArray(callExpression.args, 0, fullPath ?? createInvalidLiteral()); + editor.addToArray(callExpression.args, 0, this.getRootObjectFromDottedGet(arg0.callee)); editor.addToArray(callExpression.args, 0, createStringLiteral(functionName)); editor.addToArray(callExpression.args, 0, arg0.callee.obj); } else if (brighterscript.isDottedGetExpression(arg0)) { let functionName = arg0.name.text; + let fullPath = this.getStringPathFromDottedGet(arg0.obj as DottedGetExpression); arg0 = callExpression.args[0] as DottedGetExpression; editor.removeFromArray(callExpression.args, 0); if (!isNotCalled && !isStubCall) { editor.addToArray(callExpression.args, 0, createInvalidLiteral()); } + editor.addToArray(callExpression.args, 0, fullPath ?? createInvalidLiteral()); + editor.addToArray(callExpression.args, 0, this.getRootObjectFromDottedGet(arg0 as DottedGetExpression)); editor.addToArray(callExpression.args, 0, createStringLiteral(functionName)); editor.addToArray(callExpression.args, 0, (arg0 as DottedGetExpression).obj); } else if (brighterscript.isCallfuncExpression(arg0)) { @@ -128,6 +134,9 @@ export class TestGroup extends TestBlock { const expectedArgs = new ArrayLiteralExpression([createStringLiteral(functionName), ...arg0.args], createToken(TokenKind.LeftSquareBracket), createToken(TokenKind.RightSquareBracket)); editor.addToArray(callExpression.args, 0, expectedArgs); } + let fullPath = this.getStringPathFromDottedGet(arg0.callee as DottedGetExpression); + editor.addToArray(callExpression.args, 0, fullPath ?? createInvalidLiteral()); + editor.addToArray(callExpression.args, 0, this.getRootObjectFromDottedGet(arg0.callee as DottedGetExpression)); editor.addToArray(callExpression.args, 0, createStringLiteral('callFunc')); editor.addToArray(callExpression.args, 0, arg0.callee); } @@ -151,4 +160,56 @@ export class TestGroup extends TestBlock { }`; } + private getStringPathFromDottedGet(value: DottedGetExpression) { + let parts = [this.getPathValuePartAsString(value)]; + let root; + root = value.obj; + while (root) { + if (isCallExpression(root) || isCallfuncExpression(root)) { + return undefined; + } + parts.push(`${this.getPathValuePartAsString(root)}`); + root = root.obj; + } + let joinedParts = parts.reverse().join('.'); + return joinedParts === '' ? undefined : createStringLiteral(joinedParts); + } + + + private getPathValuePartAsString(expr: Expression) { + if (isCallExpression(expr) || isCallfuncExpression(expr)) { + return undefined; + } + if (isVariableExpression(expr)) { + return expr.name.text; + } + if (!expr) { + return undefined; + } + if (isDottedGetExpression(expr)) { + return expr.name.text; + } else if (isIndexedGetExpression(expr)) { + if (isLiteralExpression(expr.index)) { + return `${expr.index.token.text.replace(/^"/, '').replace(/"$/, '')}`; + } else if (isVariableExpression(expr.index)) { + return `${expr.index.name.text}`; + } + } + } + + private getRootObjectFromDottedGet(value: DottedGetExpression) { + let root; + if (isDottedGetExpression(value) || isIndexedGetExpression(value)) { + + root = value.obj; + while (root.obj) { + root = root.obj; + } + } else { + root = value; + } + + return root; + } + } diff --git a/bsc-plugin/src/plugin.spec.ts b/bsc-plugin/src/plugin.spec.ts index 2e5824cf..e6cd96e2 100644 --- a/bsc-plugin/src/plugin.spec.ts +++ b/bsc-plugin/src/plugin.spec.ts @@ -498,21 +498,21 @@ describe('RooibosPlugin', () => { getTestFunctionContents(true) ).to.eql(undent` m.currentAssertLineNumber = 6 - m._expectCalled(m.thing, "callFunc", [ + m._expectCalled(m.thing, "callFunc", m, "m.thing", [ "getFunction" ]) if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 7 - m._expectCalled(m.thing, "callFunc", [ + m._expectCalled(m.thing, "callFunc", m, "m.thing", [ "getFunction" ], "return") if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 8 - m._expectCalled(m.thing, "callFunc", [ + m._expectCalled(m.thing, "callFunc", m, "m.thing", [ "getFunction" "a" "b" @@ -521,7 +521,7 @@ describe('RooibosPlugin', () => { m.currentAssertLineNumber = 9 - m._expectCalled(m.thing, "callFunc", [ + m._expectCalled(m.thing, "callFunc", m, "m.thing", [ "getFunction" "a" "b" @@ -550,12 +550,12 @@ describe('RooibosPlugin', () => { getTestFunctionContents() ).to.eql(undent` m.currentAssertLineNumber = 6 - m._expectCalled(m.thing, "getFunctionField", invalid) + m._expectCalled(m.thing, "getFunctionField", m, "m.thing", invalid) if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 7 - m._expectCalled(m.thing, "getFunctionField", invalid, "return") + m._expectCalled(m.thing, "getFunctionField", m, "m.thing", invalid, "return") if m.currentResult.isFail then return invalid `); }); @@ -582,17 +582,17 @@ describe('RooibosPlugin', () => { getTestFunctionContents(true) ).to.eql(undent` m.currentAssertLineNumber = 6 - m._expectCalled(m.thing, "getFunction", []) + m._expectCalled(m.thing, "getFunction", m, "m.thing", []) if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 7 - m._expectCalled(m.thing, "getFunction", [], "return") + m._expectCalled(m.thing, "getFunction", m, "m.thing", [], "return") if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 8 - m._expectCalled(m.thing, "getFunction", [ + m._expectCalled(m.thing, "getFunction", m, "m.thing", [ "arg1" "arg2" ]) @@ -600,7 +600,7 @@ describe('RooibosPlugin', () => { m.currentAssertLineNumber = 9 - m._expectCalled(m.thing, "getFunction", [ + m._expectCalled(m.thing, "getFunction", m, "m.thing", [ "arg1" "arg2" ], "return") @@ -670,17 +670,17 @@ describe('RooibosPlugin', () => { } m.currentAssertLineNumber = 7 - m._expectCalled(item, "getFunction", []) + m._expectCalled(item, "getFunction", item, "item", []) if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 8 - m._expectCalled(item, "getFunction", [], "return") + m._expectCalled(item, "getFunction", item, "item", [], "return") if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 9 - m._expectCalled(item, "getFunction", [ + m._expectCalled(item, "getFunction", item, "item", [ "arg1" "arg2" ]) @@ -688,7 +688,7 @@ describe('RooibosPlugin', () => { m.currentAssertLineNumber = 10 - m._expectCalled(item, "getFunction", [ + m._expectCalled(item, "getFunction", item, "item", [ "arg1" "arg2" ], "return") @@ -719,10 +719,10 @@ describe('RooibosPlugin', () => { expect( getTestFunctionContents() ).to.eql(undent` - m._stubCall(m.thing, "callFunc") - m._stubCall(m.thing, "callFunc", "return") - m._stubCall(m.thing, "callFunc") - m._stubCall(m.thing, "callFunc", "return") + m._stubCall(m.thing, "callFunc", m, "m.thing") + m._stubCall(m.thing, "callFunc", m, "m.thing", "return") + m._stubCall(m.thing, "callFunc", m, "m.thing") + m._stubCall(m.thing, "callFunc", m, "m.thing", "return") `); }); @@ -745,8 +745,8 @@ describe('RooibosPlugin', () => { expect( getTestFunctionContents() ).to.eql(undent` - m._stubCall(m.thing, "getFunctionField") - m._stubCall(m.thing, "getFunctionField", "return") + m._stubCall(m.thing, "getFunctionField", m, "m.thing") + m._stubCall(m.thing, "getFunctionField", m, "m.thing", "return") `); }); @@ -773,8 +773,8 @@ describe('RooibosPlugin', () => { item = { id: "item" } - m._stubCall(item, "getFunctionField") - m._stubCall(item, "getFunctionField", "return") + m._stubCall(item, "getFunctionField", item, "item") + m._stubCall(item, "getFunctionField", item, "item", "return") `); }); @@ -799,10 +799,10 @@ describe('RooibosPlugin', () => { expect( getTestFunctionContents() ).to.eql(undent` - m._stubCall(m.thing, "getFunction") - m._stubCall(m.thing, "getFunction", "return") - m._stubCall(m.thing, "getFunction") - m._stubCall(m.thing, "getFunction", "return") + m._stubCall(m.thing, "getFunction", m, "m.thing") + m._stubCall(m.thing, "getFunction", m, "m.thing", "return") + m._stubCall(m.thing, "getFunction", m, "m.thing") + m._stubCall(m.thing, "getFunction", m, "m.thing", "return") `); }); @@ -831,10 +831,10 @@ describe('RooibosPlugin', () => { item = { id: "item" } - m._stubCall(item, "getFunction") - m._stubCall(item, "getFunction", "return") - m._stubCall(item, "getFunction") - m._stubCall(item, "getFunction", "return") + m._stubCall(item, "getFunction", item, "item") + m._stubCall(item, "getFunction", item, "item", "return") + m._stubCall(item, "getFunction", item, "item") + m._stubCall(item, "getFunction", item, "item", "return") `); }); }); @@ -862,22 +862,22 @@ describe('RooibosPlugin', () => { getTestFunctionContents() ).to.eql(undent` m.currentAssertLineNumber = 6 - m._expectNotCalled(m.thing, "callFunc") + m._expectNotCalled(m.thing, "callFunc", m, "m.thing") if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 7 - m._expectNotCalled(m.thing, "callFunc", "return") + m._expectNotCalled(m.thing, "callFunc", m, "m.thing", "return") if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 8 - m._expectNotCalled(m.thing, "callFunc") + m._expectNotCalled(m.thing, "callFunc", m, "m.thing") if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 9 - m._expectNotCalled(m.thing, "callFunc", "return") + m._expectNotCalled(m.thing, "callFunc", m, "m.thing", "return") if m.currentResult.isFail then return invalid `); }); @@ -902,12 +902,12 @@ describe('RooibosPlugin', () => { getTestFunctionContents() ).to.eql(undent` m.currentAssertLineNumber = 6 - m._expectNotCalled(thing, "callFunc") + m._expectNotCalled(thing, "callFunc", thing, "thing") if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 7 - m._expectNotCalled(thing, "callFunc") + m._expectNotCalled(thing, "callFunc", thing, "thing") if m.currentResult.isFail then return invalid `); //verify original code does not remain modified after the transpile cycle @@ -944,7 +944,7 @@ describe('RooibosPlugin', () => { getTestFunctionContents() ).to.eql(undent` m.currentAssertLineNumber = 6 - m._expectNotCalled(m.thing, "getFunctionField") + m._expectNotCalled(m.thing, "getFunctionField", m, "m.thing") if m.currentResult.isFail then return invalid `); //verify original code does not remain modified after the transpile cycle @@ -978,22 +978,22 @@ describe('RooibosPlugin', () => { getTestFunctionContents() ).to.eql(undent` m.currentAssertLineNumber = 6 - m._expectNotCalled(m.thing, "getFunction") + m._expectNotCalled(m.thing, "getFunction", m, "m.thing") if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 7 - m._expectNotCalled(m.thing, "getFunction", "return") + m._expectNotCalled(m.thing, "getFunction", m, "m.thing", "return") if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 8 - m._expectNotCalled(m.thing, "getFunction") + m._expectNotCalled(m.thing, "getFunction", m, "m.thing") if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 9 - m._expectNotCalled(m.thing, "getFunction", "return") + m._expectNotCalled(m.thing, "getFunction", m, "m.thing", "return") if m.currentResult.isFail then return invalid `); }); @@ -1023,12 +1023,12 @@ describe('RooibosPlugin', () => { } m.currentAssertLineNumber = 7 - m._expectNotCalled(item, "getFunction") + m._expectNotCalled(item, "getFunction", item, "item") if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 8 - m._expectNotCalled(item, "getFunction") + m._expectNotCalled(item, "getFunction", item, "item") if m.currentResult.isFail then return invalid `); }); @@ -1213,12 +1213,12 @@ describe('RooibosPlugin', () => { } m.currentAssertLineNumber = 7 - m._expectNotCalled(item, "getFunction") + m._expectNotCalled(item, "getFunction", item, "item") if m.currentResult.isFail then return invalid m.currentAssertLineNumber = 8 - m._expectNotCalled(item, "getFunction") + m._expectNotCalled(item, "getFunction", item, "item") if m.currentResult.isFail then return invalid `); diff --git a/framework/src/source/BaseTestSuite.bs b/framework/src/source/BaseTestSuite.bs index fce7683e..c96e21b0 100644 --- a/framework/src/source/BaseTestSuite.bs +++ b/framework/src/source/BaseTestSuite.bs @@ -1612,23 +1612,22 @@ namespace rooibos end try end function - function expectCalled(invocation as dynamic, returnValue = invalid as dynamic, thrownError = invalid as dynamic) as object + function expectCalled(invocation as dynamic, returnValue = invalid as dynamic) as object 'mock function body - the plugin replaces this return invalid end function - function _expectCalled(target, methodName, expectedArgs = invalid, returnValue = invalid as dynamic, thrownError = invalid as dynamic) as object + function _expectCalled(target, methodName, rootObject = invalid as dynamic, fullPath = invalid as dynamic, expectedArgs = invalid, returnValue = invalid as dynamic) as object try - mock = m.mock(target, methodName, 1, expectedArgs, returnValue, true) - if thrownError <> invalid - mock.toThrow(thrownError) + if fullPath <> invalid + target = rooibos.Common.makePathStubbable(rootObject, fullPath) end if + return m.mock(target, methodName, 1, expectedArgs, returnValue, true) catch error 'bs:disable-next-line m.currentResult.fail("Setting up mock failed: " + error.message, m.currentAssertLineNumber) - return false end try - return false + return invalid end function function stubCall(invocation as dynamic, returnValue = invalid as dynamic) as object @@ -1636,8 +1635,11 @@ namespace rooibos return invalid end function - function _stubCall(target, methodName, returnValue = invalid as dynamic) as object + function _stubCall(target, methodName, rootObject = invalid as dynamic, fullPath = invalid as dynamic, returnValue = invalid as dynamic) as object try + if fullPath <> invalid + target = rooibos.Common.makePathStubbable(rootObject, fullPath) + end if return m.stub(target, methodName, returnValue, true) catch error 'bs:disable-next-line @@ -1652,8 +1654,11 @@ namespace rooibos return invalid end function - function _expectNotCalled(target, methodName) as object + function _expectNotCalled(target, methodName, rootObject = invalid as dynamic, fullPath = invalid as dynamic) as object try + if fullPath <> invalid + target = rooibos.Common.makePathStubbable(rootObject, fullPath) + end if return m.mock(target, methodName, 0, invalid, invalid, true) catch error 'bs:disable-next-line diff --git a/framework/src/source/CommonUtils.bs b/framework/src/source/CommonUtils.bs index 0b8ac1b7..361a692a 100755 --- a/framework/src/source/CommonUtils.bs +++ b/framework/src/source/CommonUtils.bs @@ -645,7 +645,7 @@ namespace rooibos.Common Value1 = cdbl(Value1) end if - if val1Type <> val2Type and fuzzy <> true + if val1Type <> val2Type and (fuzzy <> true or val1Type = "String" or val2Type = "String") return false else valtype = val1Type @@ -792,4 +792,52 @@ namespace rooibos.Common return text end function + function makePathStubbable(content as dynamic, path as string) + part = invalid + + if path <> invalid + parts = path.split(".") + numParts = parts.count() + i = 0 + + contentName = parts[i] + i++ + if type(content) <> "roAssociativeArray" + content = {id: contentName} + end if + part = content + while i < numParts and part <> invalid + isIndexNumber = parts[i] = "0" or (parts[i].toInt() <> 0 and parts[i].toInt().toStr() = parts[i]) + if isIndexNumber + index = parts[i].toInt() + else + index = parts[i] + end if + + if rooibos.Common.isArray(part) and isIndexNumber + nextPart = part[index] + else if type(part) = "roAssociativeArray" and not isIndexNumber + nextPart = part[index] + else + nextPart = invalid + end if + + if nextPart = invalid or type(nextPart) <> "roAssociativeArray" + if (not isIndexNumber and type(part) = "roAssociativeArray") or (isIndexNumber and (rooibos.Common.isArray(part))) + nextPart = { id: index } + part[index] = nextPart + else + 'index type mismatch, gonna have to bail + return content + end if + end if + part = nextPart + i++ + end while + + end if + return part + end function + + end namespace \ No newline at end of file diff --git a/tests/.vscode/settings.json b/tests/.vscode/settings.json index ef0e8d11..aa35f6fe 100644 --- a/tests/.vscode/settings.json +++ b/tests/.vscode/settings.json @@ -8,5 +8,8 @@ "titleBar.activeBackground": "#1D445B", "titleBar.activeForeground": "#F7FBFD" }, - "brightscript.bsdk": "embedded" + "brightscript.bsdk": "embedded", + "cSpell.words": [ + "stubbable" + ] } \ No newline at end of file diff --git a/tests/src/source/Basic.spec.bs b/tests/src/source/Basic.spec.bs index 4ade40d4..18e51614 100644 --- a/tests/src/source/Basic.spec.bs +++ b/tests/src/source/Basic.spec.bs @@ -178,7 +178,7 @@ namespace tests m.currentResult.Reset() m.assertTrue(isFail) - m.assertEqual(msg, "[one, two, three] != [2one, 2two, 2three]") + m.assertEqual(msg, `["one", "two", "three"] != ["2one", "2two", "2three"]`) end function '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ diff --git a/tests/src/source/Common.spec.bs b/tests/src/source/Common.spec.bs index acfcaf38..a6794ea7 100644 --- a/tests/src/source/Common.spec.bs +++ b/tests/src/source/Common.spec.bs @@ -25,5 +25,65 @@ namespace tests m.assertEqual(result, expected) end function + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @describe("makePathStubbable") + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + @it("makes a simple path stubbable") + function _() + manager = invalid + + newManager = Rooibos.Common.makePathStubbable(manager, "manager") + + m.assertEqual(newManager, { id: "manager" }) + end function + + @it("makes a simple node stubbable") + function _() + manager = createObject("roSGnode", "ContentNode") + + newManager = Rooibos.Common.makePathStubbable(manager, "manager") + + m.assertEqual(newManager, { id: "manager" }) + end function + + @it("makes a path on an existing object stubbable") + function _() + manager = { id: "man" } + + newManager = Rooibos.Common.makePathStubbable(manager, "manager.item.data") + + m.assertEqual(newManager, { + id: "man" + item: { + id: "item" + data: { + id: "data" + } + } + }) + end function + + @it("makes a path including an aa on an existing object stubbable") + function _() + manager = { + id: "man" + item: { + data: createObject("roSGnode", "ContentNode") + } + } + + newManager = Rooibos.Common.makePathStubbable(manager, "manager.item.data") + + m.assertEqual(newManager, { + id: "man" + item: { + data: { + id: "data" + } + } + }) + end function + end class end namespace \ No newline at end of file diff --git a/tests/src/source/Expect.spec.bs b/tests/src/source/Expect.spec.bs index 3bf3bc0f..82bf62c3 100644 --- a/tests/src/source/Expect.spec.bs +++ b/tests/src/source/Expect.spec.bs @@ -80,6 +80,103 @@ namespace tests end for end function + + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @describe("expectCalled: auto-converts non-mockobjects into mocks") + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + @it("converts invalids") + function _() + item = { + service: invalid + } + m.expectCalled(item.service.say("hello"), "hi") + + m.assertEqual(item.service.say("hello"), "hi") + end function + + @it("works for nested invalids") + function _() + item = { + service: { + caller: invalid + } + } + m.expectCalled(item.service.caller.say("hello"), "hi") + + m.assertEqual(item.service.caller.say("hello"), "hi") + end function + + @it("supports callfunc invalid") + function _() + item = { + service: invalid + } + m.expectCalled(item.service@.say("hello"), "hi") + + m.assertEqual(item.service@.say("hello"), "hi") + end function + + @it("works for nested callfunc") + function _() + item = { + service: { + caller: invalid + } + } + m.expectCalled(item.service.caller@.say("hello"), "hi") + + m.assertEqual(item.service.caller@.say("hello"), "hi") + end function + + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @describe("stubCall: auto-converts non-mockobjects into mocks") + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + @it("converts invalids") + function _() + item = { + service: invalid + } + m.stubCall(item.service.say, "hi") + + m.assertEqual(item.service.say("hello"), "hi") + end function + + @it("works for nested invalids") + function _() + item = { + service: { + caller: invalid + } + } + m.stubCall(item.service.caller.say, "hi") + + m.assertEqual(item.service.caller.say("hello"), "hi") + end function + + @it("supports callfunc invalid") + function _() + item = { + service: invalid + } + m.stubCall(item.service@.say(), "hi") + + m.assertEqual(item.service@.say("hello"), "hi") + end function + + @it("works for nested callfunc") + function _() + item = { + service: { + caller: invalid + } + } + m.stubCall(item.service.caller@.say(), "hi") + + m.assertEqual(item.service.caller@.say("hello"), "hi") + end function + end class end namespace \ No newline at end of file