Repository: sjbarag/brs (js)

Issue:
`focusedChild` of Group/LayoutGroup doesn't propagate up the chain
I have a `LayoutGroup` , and have called `setFocus(true)` on one of its child nodes in the code. Supposedly the `focusedChild` field of the group would have been updated to the child node now and I want to test it out, but it seems like it's not doing it.


Pull request:
feat(stdlib): focusedChild responds to focus changes
# Change Summary

Closes #550 

This implements `focusedChild` changes through the tree whenever `node.setFocus` is called. All of the e2e test code has been run on RBI, and the output is identical.

Patch:
diff --git a/src/brsTypes/components/RoRegex.ts b/src/brsTypes/components/RoRegex.ts
index 9eb98ff..31abba0 100644
--- a/src/brsTypes/components/RoRegex.ts
+++ b/src/brsTypes/components/RoRegex.ts
@@ -124,7 +124,10 @@ export class RoRegex extends BrsComponent implements BrsValue {
         },
         impl: (interpreter: Interpreter, str: BrsString, replacement: BrsString) => {
             const source = this.jsRegex.source;
-            const flags = this.jsRegex.flags + "g";
+            let flags = this.jsRegex.flags;
+            if (!flags.includes("g")) {
+                flags += "g";
+            }
             this.jsRegex = new RegExp(source, flags);
             const newStr = this.jsRegex[Symbol.replace](str.value, replacement.value);
 
@@ -153,14 +156,22 @@ export class RoRegex extends BrsComponent implements BrsValue {
         },
         impl: (interpreter: Interpreter, str: BrsString) => {
             const source = this.jsRegex.source;
-            const flags = this.jsRegex.flags + "g";
+            let flags = this.jsRegex.flags;
+            if (!flags.includes("g")) {
+                flags += "g";
+            }
             this.jsRegex = new RegExp(source, flags);
             let arr = [];
-            let matches: string[] | null;
+            let matches: RegExpExecArray | null;
 
             while ((matches = this.jsRegex.exec(str.value)) !== null) {
-                let item = new BrsString(matches[0] || "");
-                arr.push(new RoArray([item]));
+                arr.push(
+                    new RoArray(
+                        matches
+                            .filter((match) => match !== undefined)
+                            .map((match) => new BrsString(match))
+                    )
+                );
             }
             return new RoArray(arr);
         },
diff --git a/src/brsTypes/components/RoSGNode.ts b/src/brsTypes/components/RoSGNode.ts
index ccbdc9e..b35440c 100644
--- a/src/brsTypes/components/RoSGNode.ts
+++ b/src/brsTypes/components/RoSGNode.ts
@@ -110,7 +110,13 @@ namespace FieldKind {
 }
 
 /** This is used to define a field (usually a default/built-in field in a component definition). */
-export type FieldModel = { name: string; type: string; value?: string; hidden?: boolean };
+export type FieldModel = {
+    name: string;
+    type: string;
+    value?: string;
+    hidden?: boolean;
+    alwaysNotify?: boolean;
+};
 
 export class Field {
     private permanentObservers: BrsCallback[] = [];
@@ -270,7 +276,7 @@ export class RoSGNode extends BrsComponent implements BrsValue, BrsIterable {
     readonly defaultFields: FieldModel[] = [
         { name: "change", type: "roAssociativeArray" },
         { name: "focusable", type: "boolean" },
-        { name: "focusedChild", type: "node" },
+        { name: "focusedChild", type: "node", alwaysNotify: true },
         { name: "id", type: "string" },
     ];
     m: RoAssociativeArray = new RoAssociativeArray([]);
@@ -1409,10 +1415,30 @@ export class RoSGNode extends BrsComponent implements BrsValue, BrsIterable {
         },
     });
 
+    /**
+     * Starting with a leaf node, traverses upward through the parents until it reaches
+     * a node without a parent (root node).
+     * @param {RoSGNode} node The leaf node to create the tree with
+     * @returns RoSGNode[] The parent chain starting with root-most parent
+     */
+    private createPath(node: RoSGNode): RoSGNode[] {
+        let path: RoSGNode[] = [node];
+
+        while (node.parent instanceof RoSGNode) {
+            path.push(node.parent);
+            node = node.parent;
+        }
+
+        return path.reverse();
+    }
+
     /**
      *  If on is set to true, sets the current remote control focus to the subject node,
      *  also automatically removing it from the node on which it was previously set.
-     *  If on is set to false, removes focus from the subject node if it had it
+     *  If on is set to false, removes focus from the subject node if it had it.
+     *
+     *  It also runs through all of the ancestors of the node that was focused prior to this call,
+     *  and the newly focused node, and sets the `focusedChild` field of each to reflect the new state.
      */
     private setfocus = new Callable("setfocus", {
         signature: {
@@ -1420,7 +1446,60 @@ export class RoSGNode extends BrsComponent implements BrsValue, BrsIterable {
             returns: ValueKind.Boolean,
         },
         impl: (interpreter: Interpreter, on: BrsBoolean) => {
-            interpreter.environment.setFocusedNode(on.toBoolean() ? this : BrsInvalid.Instance);
+            let focusedChildString = new BrsString("focusedchild");
+            let currFocusedNode = interpreter.environment.getFocusedNode();
+
+            if (on.toBoolean()) {
+                interpreter.environment.setFocusedNode(this);
+
+                // Get the focus chain, with lowest ancestor first.
+                let newFocusChain = this.createPath(this);
+
+                // If there's already a focused node somewhere, we need to remove focus
+                // from it and its ancestors.
+                if (currFocusedNode instanceof RoSGNode) {
+                    // Get the focus chain, with root-most ancestor first.
+                    let currFocusChain = this.createPath(currFocusedNode);
+
+                    // Find the lowest common ancestor (LCA) between the newly focused node
+                    // and the current focused node.
+                    let lcaIndex = 0;
+                    while (lcaIndex < newFocusChain.length && lcaIndex < currFocusChain.length) {
+                        if (currFocusChain[lcaIndex] !== newFocusChain[lcaIndex]) break;
+                        lcaIndex++;
+                    }
+
+                    // Unset all of the not-common ancestors of the current focused node.
+                    for (let i = lcaIndex; i < currFocusChain.length; i++) {
+                        currFocusChain[i].set(focusedChildString, BrsInvalid.Instance);
+                    }
+                }
+
+                // Set the focusedChild for each ancestor to the next node in the chain,
+                // which is the current node's child.
+                for (let i = 0; i < newFocusChain.length - 1; i++) {
+                    newFocusChain[i].set(focusedChildString, newFocusChain[i + 1]);
+                }
+
+                // Finally, set the focusedChild of the newly focused node to itself (to mimic RBI behavior).
+                this.set(focusedChildString, this);
+            } else {
+                interpreter.environment.setFocusedNode(BrsInvalid.Instance);
+
+                // If we're unsetting focus on ourself, we need to unset it on all ancestors as well.
+                if (currFocusedNode === this) {
+                    // Get the focus chain, with root-most ancestor first.
+                    let currFocusChain = this.createPath(currFocusedNode);
+                    currFocusChain.forEach((node) => {
+                        node.set(focusedChildString, BrsInvalid.Instance);
+                    });
+                } else {
+                    // If the node doesn't have focus already, and it's not gaining focus,
+                    // we don't need to notify any ancestors.
+                    this.set(focusedChildString, BrsInvalid.Instance);
+                }
+            }
+
             return BrsBoolean.False; //brightscript always returns false for some reason
         },
     });
@@ -1498,7 +1577,7 @@ export class RoSGNode extends BrsComponent implements BrsValue, BrsIterable {
             if (fieldType) {
                 this.fields.set(
                     field.name.toLowerCase(),
-                    new Field(value, fieldType, false, field.hidden)
+                    new Field(value, fieldType, !!field.alwaysNotify, field.hidden)
                 );
             }
         });
diff --git a/src/interpreter/index.ts b/src/interpreter/index.ts
index e1b1784..8478a90 100644
--- a/src/interpreter/index.ts
+++ b/src/interpreter/index.ts
@@ -200,6 +200,9 @@ export class Interpreter implements Expr.Visitor<BrsType>, Stmt.Visitor<BrsType>
         let originalEnvironment = this._environment;
         let newEnv = environment || this._environment.createSubEnvironment();
 
+        // Set the focused node of the sub env, because our current env has the most up-to-date reference.
+        newEnv.setFocusedNode(this._environment.getFocusedNode());
+
         try {
             this._environment = newEnv;
             return func(this);


Test patch:
diff --git a/test/brsTypes/components/RoSGNode.test.js b/test/brsTypes/components/RoSGNode.test.js
index 48052ac..5d2658c 100644
--- a/test/brsTypes/components/RoSGNode.test.js
+++ b/test/brsTypes/components/RoSGNode.test.js
@@ -2161,6 +2161,7 @@ describe("RoSGNode", () => {
         });
 
         describe("setfocus", () => {
+            let focusedChildString = new BrsString("focusedchild");
             it("sets focus on a node", () => {
                 let hasFocus = parent.getMethod("hasfocus");
                 let setFocus = parent.getMethod("setfocus");
@@ -2171,6 +2172,7 @@ describe("RoSGNode", () => {
                 setFocus.call(interpreter, BrsBoolean.True);
                 result = hasFocus.call(interpreter);
                 expect(result).toEqual(BrsBoolean.True);
+                expect(parent.get(focusedChildString)).toEqual(parent);
             });
 
             it("sets focus on a node should disable focus on another", () => {
@@ -2185,12 +2187,18 @@ describe("RoSGNode", () => {
                 result = child2HasFocus.call(interpreter);
                 expect(result).toEqual(BrsBoolean.False);
 
+                // by default their focusedChild fields should be invalid
+                expect(child1.get(focusedChildString)).toEqual(BrsInvalid.Instance);
+                expect(child2.get(focusedChildString)).toEqual(BrsInvalid.Instance);
+
                 //focus on child 1
                 child1SetFocus.call(interpreter, BrsBoolean.True);
                 result = child1HasFocus.call(interpreter);
                 expect(result).toEqual(BrsBoolean.True);
                 result = child2HasFocus.call(interpreter);
                 expect(result).toEqual(BrsBoolean.False);
+                expect(child1.get(focusedChildString)).toEqual(child1);
+                expect(child2.get(focusedChildString)).toEqual(BrsInvalid.Instance);
 
                 //focus on child 2 should remove focus from child 1
                 child2SetFocus.call(interpreter, BrsBoolean.True);
@@ -2198,6 +2206,8 @@ describe("RoSGNode", () => {
                 expect(result).toEqual(BrsBoolean.False);
                 result = child2HasFocus.call(interpreter);
                 expect(result).toEqual(BrsBoolean.True);
+                expect(child2.get(focusedChildString)).toEqual(child2);
+                expect(child1.get(focusedChildString)).toEqual(BrsInvalid.Instance);
             });
 
             it("set focus to false", () => {
@@ -2212,6 +2222,8 @@ describe("RoSGNode", () => {
                 expect(result).toEqual(BrsBoolean.True);
                 result = child2HasFocus.call(interpreter);
                 expect(result).toEqual(BrsBoolean.False);
+                expect(child1.get(focusedChildString)).toEqual(child1);
+                expect(child2.get(focusedChildString)).toEqual(BrsInvalid.Instance);
 
                 //set focus to false on child 1
                 child1SetFocus.call(interpreter, BrsBoolean.False);
@@ -2219,6 +2231,8 @@ describe("RoSGNode", () => {
                 expect(result).toEqual(BrsBoolean.False);
                 result = child2HasFocus.call(interpreter);
                 expect(result).toEqual(BrsBoolean.False);
+                expect(child1.get(focusedChildString)).toEqual(BrsInvalid.Instance);
+                expect(child2.get(focusedChildString)).toEqual(BrsInvalid.Instance);
             });
         });
 
diff --git a/test/e2e/BrsComponents.test.js b/test/e2e/BrsComponents.test.js
index 3445453..d7c0974 100644
--- a/test/e2e/BrsComponents.test.js
+++ b/test/e2e/BrsComponents.test.js
@@ -123,138 +123,6 @@ describe("end to end brightscript functions", () => {
         ]);
     });
 
-    test("components/roSGNode/main.brs", async () => {
-        await execute([resourceFile("components", "roSGNode", "main.brs")], outputStreams);
-
-        expect(allArgs(outputStreams.stdout.write).filter((arg) => arg !== "\n")).toEqual([
-            "node size: ",
-            "7",
-            "node keys size: ",
-            "7",
-            "node items size: ",
-            "7",
-            "can delete elements: ",
-            "true",
-            "can look up elements: ",
-            "true",
-            "can look up elements (brackets): ",
-            "true",
-            "can check for existence: ",
-            "true",
-            "can empty itself: ",
-            "true",
-            //ifNodeField tests
-            "node size: ",
-            "3",
-            "node size: ",
-            "2",
-            "field3 in node is: ",
-            "false",
-            "field3 in node now is: ",
-            "true",
-            "number of fields, via getFields().count(): ",
-            "2",
-            "field1 in node now is: ",
-            "hello",
-            "field3 in node now is: ",
-            "false",
-            "field3 present? ",
-            "true",
-            "fieldほ present? ",
-            "false",
-            "callback 1 called",
-            "callback 2 called",
-            "field 3 updated",
-            //ifNodeChildren tests
-            "parent child count: ",
-            "0",
-            "get same parent from child: ",
-            "true",
-            "parent child count: ",
-            "1",
-            "parent child count: ",
-            "2",
-            "parent child count: ",
-            "3",
-            "parent child count: ",
-            "2",
-            "children size: ",
-            "2",
-            "first child id after replacing: ",
-            "new node",
-            "parent child count: ",
-            "2",
-            "parent child count: ",
-            "2",
-            "parent child count: ",
-            "0",
-            "parent child count: ",
-            "3",
-            "parent child count: ",
-            "0",
-            "parent child count: ",
-            "2",
-            "parent child count: ",
-            "3",
-            "parent child count: ",
-            "4",
-            "parent child count: ",
-            "4",
-            "parent child count: ",
-            "6",
-            "parent child count: ",
-            "4",
-            "inserted child id: ",
-            "new node",
-            "parent child count: ",
-            "4",
-            "new parent id: ",
-            "new node",
-            //ifSGNodeFocus tests
-            "is parent in focus chain: ",
-            "false",
-            "is parent in focus chain: ",
-            "true",
-            "does grand child1 have focus: ",
-            "true",
-            "does grand child1 still have focus: ",
-            "false",
-            "does child2 have focus: ",
-            "true",
-            //ifNodeDict tests
-            "find node that does not exist: ",
-            "invalid",
-            "node finds itself: ",
-            "current",
-            "node finds one of its children: ",
-            "Child",
-            "node finds its grandchild: ",
-            "Grandchild",
-            "node finds its sibling: ",
-            "sibling-c7",
-            "node finds a cousin node: ",
-            "Cousin-2",
-            "node finds its grandparent: ",
-            "root-node",
-            "returns invalid on empty:",
-            "invalid",
-            "is same node returns true:",
-            "true",
-            "is same node returns false:",
-            "false",
-            "Node subtype is returned:",
-            "Node",
-            "updatedId",
-            "invalid",
-            "updatedId",
-            "newValue",
-            "updatedId",
-            "invalid",
-            "33",
-            "37",
-        ]);
-    });
-
     test("components/roRegex.brs", async () => {
         await execute([resourceFile("components", "roRegex.brs")], outputStreams);
 
@@ -288,6 +156,18 @@ describe("end to end brightscript functions", () => {
             "789",
             " ]",
             " ]",
+            "Matches with groups: [ ",
+            "[ ",
+            "abx",
+            ", ",
+            "bx",
+            " ]",
+            "[ ",
+            "aby",
+            ", ",
+            "by",
+            " ]",
+            " ]",
         ]);
     });
 
diff --git a/test/e2e/RoSGNode.test.js b/test/e2e/RoSGNode.test.js
new file mode 100644
index 0000000..6eb0882
--- /dev/null
+++ b/test/e2e/RoSGNode.test.js
@@ -0,0 +1,210 @@
+const { execute } = require("../../lib");
+const { createMockStreams, resourceFile, allArgs } = require("./E2ETests");
+
+describe("components/roSGNode", () => {
+    let outputStreams;
+
+    beforeAll(() => {
+        outputStreams = createMockStreams();
+        outputStreams.root = __dirname + "/resources";
+    });
+
+    afterEach(() => {
+        jest.resetAllMocks();
+    });
+
+    afterAll(() => {
+        jest.restoreAllMocks();
+    });
+
+    describe("ifSGNodeFocus", () => {
+        test("main.brs", async () => {
+            await execute(
+                [resourceFile("components", "roSGNode", "ifSGNodeFocus", "main.brs")],
+                outputStreams
+            );
+
+            expect(allArgs(outputStreams.stdout.write).filter((arg) => arg !== "\n")).toEqual([
+                // FORMAT: *<node id>* <focused child id> <event id> <isInFocusChain>
+                "*top* child1 child1 true",
+                "*child1* child2 child2 true",
+                "*child2* child3 child3 true",
+                "*child3* child3 child3 true",
+                "*top* invalid invalid false",
+                "*child1* invalid invalid false",
+                "*child2* invalid invalid false",
+                "*child3* invalid invalid false",
+                "----",
+                "*top* child1 child1 true",
+                "*child1* child2 child2 true",
+                "*child2* child3 child3 true",
+                "*child3* child3 child3 true",
+                "*child3* invalid invalid false",
+                "*top* child1 child1 true",
+                "*child1* child2 child2 true",
+                "*child2* child4 child4 true",
+                "*child4* child4 child4 true",
+                "*top* invalid invalid false",
+                "*child1* invalid invalid false",
+                "*child2* invalid invalid false",
+                "*child4* invalid invalid false",
+                "----",
+                "*top* child1 child1 true",
+                "*child1* child2 child2 true",
+                "*child2* child3 child3 true",
+                "*child3* child3 child3 true",
+                "*child1* invalid invalid false",
+                "*child2* invalid invalid false",
+                "*child3* invalid invalid false",
+                "*top* child5 child5 true",
+                "*child5* child6 child6 true",
+                "*child6* child6 child6 true",
+                "*top* invalid invalid false",
+                "*child5* invalid invalid false",
+                "*child6* invalid invalid false",
+                "----",
+                "*top* child1 child1 true",
+                "*child1* child1 child1 true",
+                "*top* child1 child1 true",
+                "*child1* child2 child2 true",
+                "*child2* child3 child3 true",
+                "*child3* child3 child3 true",
+                "*top* invalid invalid false",
+                "*child1* invalid invalid false",
+                "*child2* invalid invalid false",
+                "*child3* invalid invalid false",
+                "----",
+                "*top* child1 child1 true",
+                "*child1* child1 child1 true",
+                "*child1* invalid invalid false",
+                "*top* child5 child5 true",
+                "*child5* child6 child6 true",
+                "*child6* child6 child6 true",
+                "*top* invalid invalid false",
+                "*child5* invalid invalid false",
+                "*child6* invalid invalid false",
+            ]);
+        });
+    });
+
+    test("main.brs", async () => {
+        await execute([resourceFile("components", "roSGNode", "main.brs")], outputStreams);
+
+        expect(allArgs(outputStreams.stdout.write).filter((arg) => arg !== "\n")).toEqual([
+            "node size: ",
+            "7",
+            "node keys size: ",
+            "7",
+            "node items size: ",
+            "7",
+            "can delete elements: ",
+            "true",
+            "can look up elements: ",
+            "true",
+            "can look up elements (brackets): ",
+            "true",
+            "can check for existence: ",
+            "true",
+            "can empty itself: ",
+            "true",
+            //ifNodeField tests
+            "node size: ",
+            "3",
+            "node size: ",
+            "2",
+            "field3 in node is: ",
+            "false",
+            "field3 in node now is: ",
+            "true",
+            "number of fields, via getFields().count(): ",
+            "2",
+            "field1 in node now is: ",
+            "hello",
+            "field3 in node now is: ",
+            "false",
+            "field3 present? ",
+            "true",
+            "fieldほ present? ",
+            "false",
+            "callback 1 called",
+            "callback 2 called",
+            "field 3 updated",
+            //ifNodeChildren tests
+            "parent child count: ",
+            "0",
+            "get same parent from child: ",
+            "true",
+            "parent child count: ",
+            "1",
+            "parent child count: ",
+            "2",
+            "parent child count: ",
+            "3",
+            "parent child count: ",
+            "2",
+            "children size: ",
+            "2",
+            "first child id after replacing: ",
+            "new node",
+            "parent child count: ",
+            "2",
+            "parent child count: ",
+            "2",
+            "parent child count: ",
+            "0",
+            "parent child count: ",
+            "3",
+            "parent child count: ",
+            "0",
+            "parent child count: ",
+            "2",
+            "parent child count: ",
+            "3",
+            "parent child count: ",
+            "4",
+            "parent child count: ",
+            "4",
+            "parent child count: ",
+            "6",
+            "parent child count: ",
+            "4",
+            "inserted child id: ",
+            "new node",
+            "parent child count: ",
+            "4",
+            "new parent id: ",
+            "new node",
+            //ifNodeDict tests
+            "find node that does not exist: ",
+            "invalid",
+            "node finds itself: ",
+            "current",
+            "node finds one of its children: ",
+            "Child",
+            "node finds its grandchild: ",
+            "Grandchild",
+            "node finds its sibling: ",
+            "sibling-c7",
+            "node finds a cousin node: ",
+            "Cousin-2",
+            "node finds its grandparent: ",
+            "root-node",
+            "returns invalid on empty:",
+            "invalid",
+            "is same node returns true:",
+            "true",
+            "is same node returns false:",
+            "false",
+            "Node subtype is returned:",
+            "Node",
+            "updatedId",
+            "invalid",
+            "updatedId",
+            "newValue",
+            "updatedId",
+            "invalid",
+            "33",
+            "37",
+        ]);
+    });
+});
diff --git a/test/e2e/resources/components/roRegex.brs b/test/e2e/resources/components/roRegex.brs
index c7e9ab8..6dbdea7 100644
--- a/test/e2e/resources/components/roRegex.brs
+++ b/test/e2e/resources/components/roRegex.brs
@@ -8,13 +8,15 @@ sub main()
 
     print "Replacing ',' in 2019,03,26 by '-' on first occurrence: " regex.replace("2019,03,26", "-")
     print "Replacing ',' in 2019,03,26 by '-' on all occurrences: " regex.replaceall("2019,03,26", "-")
+    ' make sure we can call replaceall multiple times without error
+    regex.replaceall("2019,03,26", "-")
     parts = regex.split("2019,03,26")
     print "Split by ',': [ " parts[0] " " parts[1] " " parts[2] " ]"
 
     regex = createObject("roRegex", "\d+", "i")
 
     matches = regex.match("123 456 789")
-    print "First match: [ " matches[0] " ]" 
+    print "First match: [ " matches[0] " ]"
 
     matches = regex.matchall("123 456 789")
     print "All matches: [ "
@@ -22,4 +24,14 @@ sub main()
     print "[ " matches[1][0] " ]"
     print "[ " matches[2][0] " ]"
     print " ]"
+
+    ' make sure we can call matchall multiple times without error.
+    regex.matchall("123 456 789")
+
+    regex = createObject("roRegex", "a(b.)", "i")
+    matches = regex.matchall("abx abyz")
+    print "Matches with groups: [ "
+    print "[ " matches[0][0] ", " matches[0][1] " ]"
+    print "[ " matches[1][0] ", " matches[1][1] " ]"
+    print " ]"
 end sub
diff --git a/test/e2e/resources/components/roSGNode/ifSGNodeFocus/ifSGNodeFocus.brs b/test/e2e/resources/components/roSGNode/ifSGNodeFocus/ifSGNodeFocus.brs
new file mode 100644
index 0000000..7b2b4ea
--- /dev/null
+++ b/test/e2e/resources/components/roSGNode/ifSGNodeFocus/ifSGNodeFocus.brs
@@ -0,0 +1,85 @@
+sub init()
+    ' assume parent node will be attached to the rootscene node tree, otherwise
+    ' node focus is meaningless and won't work as expected.
+
+    m.top.id = "top"
+
+    m.child1 = m.top.findNode("child1")
+    m.child2 = m.top.findNode("child2")
+    m.child3 = m.top.findNode("child3")
+    m.child4 = m.top.findNode("child4")
+    m.child5 = m.top.findNode("child5")
+    m.child6 = m.top.findNode("child6")
+
+    m.child1.observeField("focusedChild", "onFocusChild1")
+    m.child2.observeField("focusedChild", "onFocusChild2")
+    m.child3.observeField("focusedChild", "onFocusChild3")
+    m.child4.observeField("focusedChild", "onFocusChild4")
+    m.child5.observeField("focusedChild", "onFocusChild5")
+    m.child6.observeField("focusedChild", "onFocusChild6")
+    m.top.observeField("focusedChild", "onFocusTop")
+
+    ' focus on a leaf node, then unset
+    m.child3.setFocus(true)
+    m.child3.setFocus(false)
+
+    print "----"
+
+    ' focus on a leaf node, then a sibling leaf node
+    m.child3.setFocus(true)
+    m.child4.setFocus(true)
+    m.child4.setFocus(false)
+
+    print "----"
+
+    ' focus on a leaf node, then a leaf node in a different subtree
+    m.child3.setFocus(true)
+    m.child6.setFocus(true)
+    m.child6.setFocus(false)
+
+    print "----"
+
+    ' focus on a parent node, then a leaf node in its tree
+    m.child1.setFocus(true)
+    m.child3.setFocus(true)
+    m.child3.setFocus(false)
+
+    print "----"
+
+    ' focus on a parent node, then a leaf node in a separate subtree
+    m.child1.setFocus(true)
+    m.child6.setFocus(true)
+    m.child6.setFocus(false)
+end sub
+
+function getMessage(node as object, event as object) as string
+    print "*" + node.id + "* " + node.focusedChild.id.toStr() + " " + event.getData().id.toStr() + " " + node.isInFocusChain().toStr()
+end function
+
+sub onFocusTop(event as object)
+    getMessage(m.top, event)
+end sub
+
+sub onFocusChild1(event as object)
+    getMessage(m.child1, event)
+end sub
+
+sub onFocusChild2(event as object)
+    getMessage(m.child2, event)
+end sub
+
+sub onFocusChild3(event as object)
+    getMessage(m.child3, event)
+end sub
+
+sub onFocusChild4(event as object)
+    getMessage(m.child4, event)
+end sub
+
+sub onFocusChild5(event as object)
+    getMessage(m.child5, event)
+end sub
+
+sub onFocusChild6(event as object)
+    getMessage(m.child6, event)
+end sub
diff --git a/test/e2e/resources/components/roSGNode/ifSGNodeFocus/ifSGNodeFocusTestbed.xml b/test/e2e/resources/components/roSGNode/ifSGNodeFocus/ifSGNodeFocusTestbed.xml
new file mode 100644
index 0000000..03e3f1f
--- /dev/null
+++ b/test/e2e/resources/components/roSGNode/ifSGNodeFocus/ifSGNodeFocusTestbed.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<component name="IfSGNodeFocusTestbed">
+    <script type="text/brightscript" uri="./ifSGNodeFocus.brs" />
+    <children>
+        <Group id="child1">
+            <Group id="child2">
+                <Node id="child3" />
+                <Node id="child4" />
+            </Group>
+        </Group>
+        <Group id="child5">
+            <Node id="child6" />
+        </Group>
+    </children>
+</component>
diff --git a/test/e2e/resources/components/roSGNode/ifSGNodeFocus/main.brs b/test/e2e/resources/components/roSGNode/ifSGNodeFocus/main.brs
new file mode 100644
index 0000000..b19deff
--- /dev/null
+++ b/test/e2e/resources/components/roSGNode/ifSGNodeFocus/main.brs
@@ -0,0 +1,3 @@
+sub main()
+    createObject("roSGNode", "IfSGNodeFocusTestbed")
+end sub
diff --git a/test/e2e/resources/components/roSGNode/roSGNode.brs b/test/e2e/resources/components/roSGNode/roSGNode.brs
index b7ac97c..7dd22c2 100644
--- a/test/e2e/resources/components/roSGNode/roSGNode.brs
+++ b/test/e2e/resources/components/roSGNode/roSGNode.brs
@@ -101,22 +101,6 @@ sub init()
     childNode3.reparent(childNode4, false)
     print "new parent id: " childNode3.getParent().id         ' => new node
 
-    ' ifSGNodeFocus tests
-    ' assume parent node will be attached to the rootscene node tree, otherwise
-    ' node focus is meaningless and won't work as expected.
-    parentNode = createObject("roSGNode", "Node")
-    childNode1 = parentNode.createChild("Node")
-    childNode2 = parentNode.createChild("Node")
-    grandChild1 = childNode1.createChild("Node")
-    grandChild2 = childNode2.createChild("Node")
-    print "is parent in focus chain: " parentNode.isInFocusChain()     ' => false
-    grandChild1.setFocus(true)
-    print "is parent in focus chain: " parentNode.isInFocusChain()     ' => true
-    print "does grand child1 have focus: " grandChild1.hasFocus()  ' => true
-    childNode2.setFocus(true)
-    print "does grand child1 still have focus: " grandChild1.hasFocus() ' => false
-    print "does child2 have focus: " childNode2.hasFocus()  ' => true
-
     'ifNodeDict tests
     ' no node exists
     currentNode = createObject("roSGNode", "Node")