• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (c) 2021-2022 Huawei Device Co., Ltd.
3  * Licensed under the Apache License, Version 2.0 (the "License");
4  * you may not use this file except in compliance with the License.
5  * You may obtain a copy of the License at
6  *
7  *     http://www.apache.org/licenses/LICENSE-2.0
8  *
9  * Unless required by applicable law or agreed to in writing, software
10  * distributed under the License is distributed on an "AS IS" BASIS,
11  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12  * See the License for the specific language governing permissions and
13  * limitations under the License.
14  */
15 #include "gtest/gtest.h"
16 #include "ui_model.h"
17 
18 using namespace OHOS::uitest;
19 using namespace std;
20 
21 static constexpr auto ATTR_TEXT = "text";
22 static constexpr auto ATTR_ID = "id";
23 
TEST(RectTest,testRectBase)24 TEST(RectTest, testRectBase)
25 {
26     Rect rect(100, 200, 300, 400);
27     ASSERT_EQ(100, rect.left_);
28     ASSERT_EQ(200, rect.right_);
29     ASSERT_EQ(300, rect.top_);
30     ASSERT_EQ(400, rect.bottom_);
31 
32     ASSERT_EQ(rect.GetCenterX(), (100 + 200) / 2);
33     ASSERT_EQ(rect.GetCenterY(), (300 + 400) / 2);
34     ASSERT_EQ(rect.GetHeight(), 400 - 300);
35     ASSERT_EQ(rect.GetWidth(), 200 - 100);
36 }
37 
TEST(WidgetTest,testAttributes)38 TEST(WidgetTest, testAttributes)
39 {
40     Widget widget("hierarchy");
41     // get not-exist attribute, should return default value
42     ASSERT_EQ("none", widget.GetAttr(ATTR_TEXT, "none"));
43     // get exist attribute, should return actual value
44     widget.SetAttr(ATTR_TEXT, "wyz");
45     ASSERT_EQ("wyz", widget.GetAttr(ATTR_TEXT, "none"));
46 }
47 
48 /** NOTE:: Widget need to be movable since we need to move a constructed widget to
49  * its hosting tree. We must ensure that the move-created widget be same as the
50  * moved one (No any attribute/filed should be lost during moving).
51  * */
TEST(WidgetTest,testSafeMovable)52 TEST(WidgetTest, testSafeMovable)
53 {
54     Widget widget("hierarchy");
55     widget.SetAttr(ATTR_TEXT, "wyz");
56     widget.SetAttr(ATTR_ID, "100");
57     widget.SetHostTreeId("tree-10086");
58     widget.SetBounds(Rect(1, 2, 3, 4));
59 
60     auto newWidget = move(widget);
61     ASSERT_EQ("hierarchy", newWidget.GetHierarchy());
62     ASSERT_EQ("tree-10086", newWidget.GetHostTreeId());
63     ASSERT_EQ("wyz", newWidget.GetAttr(ATTR_TEXT, ""));
64     ASSERT_EQ("100", newWidget.GetAttr(ATTR_ID, ""));
65     auto bounds = newWidget.GetBounds();
66     ASSERT_EQ(1, bounds.left_);
67     ASSERT_EQ(2, bounds.right_);
68     ASSERT_EQ(3, bounds.top_);
69     ASSERT_EQ(4, bounds.bottom_);
70 }
71 
TEST(WidgetTest,testToStr)72 TEST(WidgetTest, testToStr)
73 {
74     Widget widget("hierarchy");
75 
76     // get exist attribute, should return default value
77     widget.SetAttr(ATTR_TEXT, "wyz");
78     widget.SetAttr(ATTR_ID, "100");
79 
80     auto str0 = widget.ToStr();
81     ASSERT_TRUE(str0.find(ATTR_TEXT) != string::npos);
82     ASSERT_TRUE(str0.find("wyz") != string::npos);
83     ASSERT_TRUE(str0.find(ATTR_ID) != string::npos);
84     ASSERT_TRUE(str0.find("100") != string::npos);
85 
86     // change attribute
87     widget.SetAttr(ATTR_ID, "211");
88     str0 = widget.ToStr();
89     ASSERT_TRUE(str0.find(ATTR_TEXT) != string::npos);
90     ASSERT_TRUE(str0.find("wyz") != string::npos);
91     ASSERT_TRUE(str0.find(ATTR_ID) != string::npos);
92     ASSERT_TRUE(str0.find("211") != string::npos);
93     ASSERT_TRUE(str0.find("100") == string::npos);
94 }
95 
96 // define a widget visitor to visit the specified attribute
97 class WidgetAttrVisitor : public WidgetVisitor {
98 public:
WidgetAttrVisitor(string_view attr)99     explicit WidgetAttrVisitor(string_view attr) : attr_(attr) {}
100 
~WidgetAttrVisitor()101     ~WidgetAttrVisitor() {}
102 
Visit(const Widget & widget)103     void Visit(const Widget &widget) override
104     {
105         if (!widget.IsVisible()) {
106             firstWidget_ = false;
107             return;
108         }
109         if (!firstWidget_) {
110             attrValueSequence_ << ",";
111         } else {
112             firstWidget_ = false;
113         }
114         attrValueSequence_ << widget.GetAttr(attr_, "");
115     }
116 
117     stringstream attrValueSequence_;
118 
119 private:
120     const string attr_;
121     volatile bool firstWidget_ = true;
122 };
123 
124 static constexpr string_view DOM_TEXT = R"({
125 "attributes": {"resource-id": "id0"},
126 "children": [
127 {
128 "attributes": {"resource-id": "id00"},
129 "children": [
130 {
131 "attributes": {"resource-id": "id000"},
132 "children": [
133 {
134 "attributes": {"resource-id": "id0000"},
135 "children": []
136 }]}]},
137 {
138 "attributes": {"resource-id": "id01"},
139 "children": [
140 {
141 "attributes": {"resource-id": "id010"},
142 "children": []
143 }]}]})";
144 
TEST(WidgetTreeTest,testConstructWidgetsFromDomCheckOrder)145 TEST(WidgetTreeTest, testConstructWidgetsFromDomCheckOrder)
146 {
147     auto dom = nlohmann::json::parse(DOM_TEXT);
148     WidgetTree tree("tree");
149     tree.ConstructFromDom(dom, false);
150 
151     // visited the widget tree and check the 'resource-id' attribute
152     WidgetAttrVisitor visitor("resource-id");
153     tree.DfsTraverse(visitor);
154     // should collect correct attribute value sequence (DFS)
155     ASSERT_EQ("id0,id00,id000,id0000,id01,id010", visitor.attrValueSequence_.str()) << "Incorrect node order";
156 }
157 
158 class BoundsVisitor : public WidgetVisitor {
159 public:
Visit(const Widget & widget)160     void Visit(const Widget &widget) override
161     {
162         if (!widget.IsVisible()) {
163             return;
164         }
165         boundsList_.emplace_back(widget.GetBounds());
166     }
167 
168     vector<Rect> boundsList_;
169 };
170 
TEST(WidgetTreeTest,testConstructWidgetsFromDomCheckBounds)171 TEST(WidgetTreeTest, testConstructWidgetsFromDomCheckBounds)
172 {
173     auto dom = nlohmann::json::parse(R"({"attributes":{"bounds":"[0,-50][100,200]"}, "children":[]})");
174     WidgetTree tree("tree");
175     tree.ConstructFromDom(dom, false);
176     BoundsVisitor visitor;
177     tree.DfsTraverse(visitor);
178     auto &bounds = visitor.boundsList_.at(0);
179     ASSERT_EQ(0, bounds.left_);
180     ASSERT_EQ(100, bounds.right_); // check converting negative number
181     ASSERT_EQ(-50, bounds.top_);
182     ASSERT_EQ(200, bounds.bottom_);
183 }
184 
TEST(WidgetTreeTest,testGetRelativeNode)185 TEST(WidgetTreeTest, testGetRelativeNode)
186 {
187     auto dom = nlohmann::json::parse(DOM_TEXT);
188     WidgetTree tree("tree");
189     tree.ConstructFromDom(dom, false);
190     BoundsVisitor visitor;
191     tree.DfsTraverse(visitor);
192 
193     auto rootPtr = tree.GetRootWidget();
194     ASSERT_TRUE(rootPtr != nullptr) << "Failed to get root node";
195     ASSERT_EQ(nullptr, tree.GetParentWidget(*rootPtr)) << "Root node should have no parent";
196     ASSERT_EQ("id0", rootPtr->GetAttr("resource-id", "")) << "Incorrect root node attribute";
197 
198     auto child1Ptr = tree.GetChildWidget(*rootPtr, 1);
199     ASSERT_TRUE(child1Ptr != nullptr) << "Failed to get child widget of root node at index 1";
200     ASSERT_EQ("id01", child1Ptr->GetAttr("resource-id", "")) << "Incorrect child node attribute";
201 
202     ASSERT_TRUE(tree.GetChildWidget(*rootPtr, 2) == nullptr) << "Unexpected child not";
203 }
204 
TEST(WidgetTreeTest,testVisitNodesInGivenRoot)205 TEST(WidgetTreeTest, testVisitNodesInGivenRoot)
206 {
207     auto dom = nlohmann::json::parse(DOM_TEXT);
208     WidgetTree tree("tree");
209     tree.ConstructFromDom(dom, false);
210 
211     auto rootPtr = tree.GetRootWidget();
212     ASSERT_TRUE(rootPtr != nullptr) << "Failed to get root node";
213     ASSERT_EQ(nullptr, tree.GetParentWidget(*rootPtr)) << "Root node should have no parent";
214 
215     auto child0Ptr = tree.GetChildWidget(*rootPtr, 0);
216     ASSERT_TRUE(child0Ptr != nullptr) << "Failed to get child widget of root node at index 0";
217 
218     WidgetAttrVisitor attrVisitor("resource-id");
219     tree.DfsTraverseDescendants(attrVisitor, *child0Ptr);
220     auto visitedTextSequence = attrVisitor.attrValueSequence_.str();
221     ASSERT_EQ("id00,id000,id0000", visitedTextSequence) << "Incorrect text sequence of node descendants";
222 }
223 
TEST(WidgetTreeTest,testVisitFrontNodes)224 TEST(WidgetTreeTest, testVisitFrontNodes)
225 {
226     auto dom = nlohmann::json::parse(DOM_TEXT);
227     WidgetTree tree("tree");
228     tree.ConstructFromDom(dom, false);
229 
230     auto rootPtr = tree.GetRootWidget();
231     ASSERT_TRUE(rootPtr != nullptr) << "Failed to get root node";
232     ASSERT_EQ(nullptr, tree.GetParentWidget(*rootPtr)) << "Root node should have no parent";
233 
234     auto child1Ptr = tree.GetChildWidget(*rootPtr, 1);
235     ASSERT_TRUE(child1Ptr != nullptr) << "Failed to get child widget of root node at index 0";
236 
237     WidgetAttrVisitor attrVisitor("resource-id");
238     tree.DfsTraverseFronts(attrVisitor, *child1Ptr);
239     auto visitedTextSequence = attrVisitor.attrValueSequence_.str();
240     ASSERT_EQ("id0,id00,id000,id0000", visitedTextSequence) << "Incorrect text sequence of front nodes";
241 }
242 
TEST(WidgetTreeTest,testVisitTearNodes)243 TEST(WidgetTreeTest, testVisitTearNodes)
244 {
245     auto dom = nlohmann::json::parse(DOM_TEXT);
246     WidgetTree tree("tree");
247     tree.ConstructFromDom(dom, false);
248 
249     auto rootPtr = tree.GetRootWidget();
250     ASSERT_TRUE(rootPtr != nullptr) << "Failed to get root node";
251     ASSERT_EQ(nullptr, tree.GetParentWidget(*rootPtr)) << "Root node should have no parent";
252 
253     auto child0Ptr = tree.GetChildWidget(*rootPtr, 0);
254     ASSERT_TRUE(child0Ptr != nullptr) << "Failed to get child widget of root node at index 0";
255 
256     WidgetAttrVisitor attrVisitor("resource-id");
257     tree.DfsTraverseRears(attrVisitor, *child0Ptr);
258     auto visitedTextSequence = attrVisitor.attrValueSequence_.str();
259     ASSERT_EQ("id000,id0000,id01,id010", visitedTextSequence) << "Incorrect text sequence of tear nodes";
260 }
261 
TEST(WidgetTreeTest,testBoundsAndVisibilityCorrection)262 TEST(WidgetTreeTest, testBoundsAndVisibilityCorrection)
263 {
264     constexpr string_view domText = R"(
265 {"attributes" : {"resource-id" : "id0","bounds" : "[0,0][100,100]"},
266 "children": [
267 {"attributes" : { "resource-id" : "id00","bounds" : "[0,20][100,80]" },
268 "children": [ {"attributes": {"resource-id": "id000","bounds": "[0,10][100,90]"}, "children": []} ]
269 },
270 {"attributes": {"resource-id": "id01","bounds": "[0,-20][100,100]"},
271 "children": [ {"attributes": {"resource-id": "id010","bounds": "[0,0][100,0]"}, "children": []},
272 {"attributes": {"resource-id": "id011","bounds": "[0,-20][100,20]"}, "children": []}]
273 }
274 ]
275 })";
276     //                  id01 id010 id011
277     //                   |    |     |
278     //                   |    |     |
279     //  id0              |    |     |
280     //   |        id000  |          |
281     //   |   id00  |     |          |
282     //   |    |    |     |
283     //   |    |    |     |
284     //   |    |    |     |
285     //   |         |     |
286     //   |               |
287     auto dom = nlohmann::json::parse(domText);
288     WidgetTree tree("tree");
289     tree.ConstructFromDom(dom, true); // enable bounds amending
290     WidgetAttrVisitor attrVisitor("resource-id");
291     tree.DfsTraverse(attrVisitor);
292     // id010 should be discarded dut to totaly invisible
293     ASSERT_EQ("id0,id00,id000,id01,id011", attrVisitor.attrValueSequence_.str());
294     BoundsVisitor boundsVisitor;
295     tree.DfsTraverse(boundsVisitor);
296     // check revised bounds
297     vector<Rect> expectedBounds = {Rect(0, 100, 0, 100), Rect(0, 100, 20, 80), Rect(0, 100, 10, 90),
298                                    Rect(0, 100, -20, 100), Rect(0, 100, -20, 20)};
299     ASSERT_EQ(expectedBounds.size(), boundsVisitor.boundsList_.size());
300     for (size_t index = 0; index < expectedBounds.size(); index++) {
301         auto &expectedBound = expectedBounds.at(index);
302         auto &actualBound = boundsVisitor.boundsList_.at(index);
303         ASSERT_EQ(expectedBound.left_, actualBound.left_);
304         ASSERT_EQ(expectedBound.right_, actualBound.right_);
305         ASSERT_EQ(expectedBound.top_, actualBound.top_);
306         ASSERT_EQ(expectedBound.bottom_, actualBound.bottom_);
307     }
308 }
309 
TEST(WidgetTreeTest,testBoundsAndVisibilityCorrectionInList)310 TEST(WidgetTreeTest, testBoundsAndVisibilityCorrectionInList)
311 {
312     constexpr string_view domText = R"(
313 {"attributes" : {"resource-id" : "id0","bounds" : "[0,0][100,100]","type" : "List"},
314 "children": [
315 {"attributes" : { "resource-id" : "id00","bounds" : "[0,20][100,120]" },
316 "children": [ {"attributes": {"resource-id": "id000","bounds": "[0,0][100,20]"}, "children": []},
317 {"attributes": {"resource-id": "id001","bounds": "[0,100][100,120]"}, "children": []}]
318 },
319 {"attributes": {"resource-id": "id01","bounds": "[110,110][120,120]"},
320 "children": [ {"attributes": {"resource-id": "id010","bounds": "[0,0][100,80]"}, "children": []},
321 {"attributes": {"resource-id": "id011","bounds": "[0,20][100,80]","type" : "List"},
322 "children": [ {"attributes": {"resource-id": "id0110","bounds": "[0,0][100,20]"}, "children": []} ]}]
323 }
324 ]
325 })";
326     // id0 List0.
327     // id00 Child of List0, is within List0 partially, set it visible and amend bounds.
328     // id000 Child of id00, is not within id00, but is within List0, set it visible.
329     // id001 Child of id00, is within id00, but is not within List0, set it invisible.
330     // id01 Child of List0, is not within List0, but it has visible child id010, set it visible and Rect(0,0,0,0).
331     // id010 Child of id01, is not within id01, but is within List0, set it visible.
332     // id011 List, Child of List0, is within List0, set it visible.
333     // id0110 Child of List1, is within List0, but is not within List1, set it invisible.
334     auto dom = nlohmann::json::parse(domText);
335     WidgetTree tree("tree");
336     tree.ConstructFromDom(dom, true); // enable bounds amending
337     WidgetAttrVisitor attrVisitor("resource-id");
338     tree.DfsTraverse(attrVisitor);
339     // id010 should be discarded dut to totaly invisible
340     ASSERT_EQ("id0,id00,id000,id01,id010,id011", attrVisitor.attrValueSequence_.str());
341     BoundsVisitor boundsVisitor;
342     tree.DfsTraverse(boundsVisitor);
343     // check revised bounds
344     vector<Rect> expectedBounds = {Rect(0, 100, 0, 100), Rect(0, 100, 20, 100), Rect(0, 100, 0, 20),
345                                    Rect(0, 0, 0, 0), Rect(0, 100, 0, 80), Rect(0, 100, 20, 80)};
346     ASSERT_EQ(expectedBounds.size(), boundsVisitor.boundsList_.size());
347     for (size_t index = 0; index < expectedBounds.size(); index++) {
348         auto &expectedBound = expectedBounds.at(index);
349         auto &actualBound = boundsVisitor.boundsList_.at(index);
350         ASSERT_EQ(expectedBound.left_, actualBound.left_);
351         ASSERT_EQ(expectedBound.right_, actualBound.right_);
352         ASSERT_EQ(expectedBound.top_, actualBound.top_);
353         ASSERT_EQ(expectedBound.bottom_, actualBound.bottom_);
354     }
355 }
356 
TEST(WidgetTreeTest,testMarshalIntoDom)357 TEST(WidgetTreeTest, testMarshalIntoDom)
358 {
359     auto dom = nlohmann::json::parse(DOM_TEXT);
360     WidgetTree tree("tree");
361     tree.ConstructFromDom(dom, false);
362     auto dom1 = nlohmann::json();
363     tree.MarshalIntoDom(dom1);
364     ASSERT_FALSE(dom1.empty());
365 }
366 
367 /** Make merged tree from doms and collects the target attribute dfs sequence.*/
CheckMergedTree(const array<string_view,3> & doms,map<string,string> & attrCollector)368 static void CheckMergedTree(const array<string_view, 3> &doms, map<string, string> &attrCollector)
369 {
370     WidgetTree tree("");
371     vector<unique_ptr<WidgetTree>> subTrees;
372     for (auto &dom : doms) {
373         auto tempTree = make_unique<WidgetTree>("");
374         tempTree->ConstructFromDom(nlohmann::json::parse(dom), true);
375         subTrees.push_back(move(tempTree));
376     }
377     vector<int32_t> mergedOrders;
378     WidgetTree::MergeTrees(subTrees, tree, mergedOrders);
379     for (auto &[name, value] : attrCollector) {
380         if (name == "bounds") {
381             BoundsVisitor visitor;
382             tree.DfsTraverse(visitor);
383             stringstream stream;
384             for (auto &rect : visitor.boundsList_) {
385                 stream << "[" << rect.left_ << "," << rect.top_ << "][" << rect.right_ << "," << rect.bottom_ << "]";
386                 stream << ",";
387             }
388             attrCollector[name] = stream.str();
389         } else {
390             WidgetAttrVisitor visitor(name);
391             tree.DfsTraverse(visitor);
392             attrCollector[name] = visitor.attrValueSequence_.str();
393         }
394     }
395 }
396 
TEST(WidgetTreeTest,testMergeTreesNoIntersection)397 TEST(WidgetTreeTest, testMergeTreesNoIntersection)
398 {
399     // 3 tree vertical/horizontal arranged without intersection
400     constexpr string_view domText0 = R"(
401 {
402 "attributes": {"id": "t0-id0", "bounds": "[0,0][2,2]"},
403 "children": [{"attributes": {"id": "t0-id00", "bounds": "[0,0][2,1]"}, "children": []}]
404 })";
405     constexpr string_view domText1 = R"(
406 {
407 "attributes": {"id": "t1-id0", "bounds": "[0,2][2,4]"},
408 "children": [{"attributes": {"id": "t1-id00", "bounds": "[0,2][2,3]"}, "children": []}]
409 })";
410     constexpr string_view domText2 = R"(
411 {
412 "attributes": {"id": "t2-id0", "bounds": "[2,0][4,4]"},
413 "children": [{"attributes": {"id": "t2-id00", "bounds": "[2,0][4,3]"}, "children": []}]
414 })";
415     map<string, string> attrs;
416     attrs["id"] = "";
417     attrs["bounds"] = "";
418     CheckMergedTree( {domText0, domText1, domText2}, attrs);
419     // all widgets should be available (leading ',': separator of virtual-root node attr-value)
420     ASSERT_EQ(",t0-id0,t0-id00,t1-id0,t1-id00,t2-id0,t2-id00", attrs["id"]);
421     // bounds should not be revised (leading '[0,0][4,4]': auto-computed virtual-root node bounds)
422     ASSERT_EQ("[0,0][4,4],[0,0][2,2],[0,0][2,1],[0,2][2,4],[0,2][2,3],[2,0][4,4],[2,0][4,3],", attrs["bounds"]);
423 }
424 
TEST(WidgetTreeTest,testMergeTreesWithFullyCovered)425 TEST(WidgetTreeTest, testMergeTreesWithFullyCovered)
426 {
427     // dom2 is fully covered by dom0 and dom1
428     constexpr string_view domText0 = R"(
429 {
430 "attributes": {"id": "t0-id0", "bounds": "[0,0][2,2]"},
431 "children": [{"attributes": {"id": "t0-id00", "bounds": "[0,0][2,1]"}, "children": []}]
432 })";
433     constexpr string_view domText1 = R"(
434 {
435 "attributes": {"id": "t1-id0", "bounds": "[0,2][2,4]"},
436 "children": [{"attributes": {"id": "t1-id00", "bounds": "[0,2][2,3]"}, "children": []}]
437 })";
438     constexpr string_view domText2 = R"(
439 {
440 "attributes": {"id": "t2-id0", "bounds": "[0,0][2,4]"},
441 "children": [{"attributes": {"id": "t2-id00", "bounds": "[2,0][4,3]"}, "children": []}]
442 })";
443     map<string, string> attrs;
444     attrs["id"] = "";
445     attrs["bounds"] = "";
446     CheckMergedTree( {domText0, domText1, domText2}, attrs);
447     // tree2 widgets should be discarded (leading ',': separator of virtual-root node attr-value)
448     ASSERT_EQ(",t0-id0,t0-id00,t1-id0,t1-id00", attrs["id"]);
449     // bounds should not be revised (leading '[0,0][2,4]': auto-computed virtual-root node bounds)
450     ASSERT_EQ("[0,0][2,4],[0,0][2,2],[0,0][2,1],[0,2][2,4],[0,2][2,3],", attrs["bounds"]);
451 }
452 
TEST(WidgetTreeTest,testMergeTreesWithPartialCovered)453 TEST(WidgetTreeTest, testMergeTreesWithPartialCovered)
454 {
455     constexpr string_view domText0 = R"(
456 {
457 "attributes": {"id": "t0-id0", "bounds": "[0,0][4,4]"},
458 "children": [{"attributes": {"id": "t0-id00", "bounds": "[0,0][4,2]"}, "children": []}]
459 })";
460     // t1-id0 is partial covered by tree0, t1-id00 is fully covered
461     constexpr string_view domText1 = R"(
462 {
463 "attributes": {"id": "t1-id0", "bounds": "[0,2][4,6]"},
464 "children": [{"attributes": {"id": "t1-id00", "bounds": "[0,2][4,4]"}, "children": []}]
465 })";
466     // t2-id0 is partial covered by tree0/tree1, t2-id00 is fully covered by tree0/tree1
467     constexpr string_view domText2 = R"(
468 {
469 "attributes": {"id": "t2-id0", "bounds": "[0,0][4,8]"},
470 "children": [{"attributes": {"id": "t2-id00", "bounds": "[0,0][4,5]"}, "children": []}]
471 })";
472     map<string, string> attrs;
473     attrs["id"] = "";
474     attrs["bounds"] = "";
475     CheckMergedTree( {domText0, domText1, domText2}, attrs);
476     // check visible widgets (leading ',': separator of virtual-root node attr-value)
477     ASSERT_EQ(",t0-id0,t0-id00,t1-id0,t2-id0", attrs["id"]);
478     // bounds should not be revised (leading '[0,0][2,4]': auto-computed virtual-root node bounds)
479     // t1-id0: [0,4][4,6]; t2-id0: [0,6][4,8]
480     ASSERT_EQ("[0,0][4,8],[0,0][4,4],[0,0][4,2],[0,4][4,6],[0,6][4,8],", attrs["bounds"]);
481 }