1/* 2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17import {Clipboard, ClipboardModule} from '@angular/cdk/clipboard'; 18import {Component, ViewChild} from '@angular/core'; 19import {ComponentFixture, TestBed} from '@angular/core/testing'; 20import {MatIconModule} from '@angular/material/icon'; 21import {MatTooltipModule} from '@angular/material/tooltip'; 22import {assertDefined} from 'common/assert_utils'; 23import {HierarchyTreeBuilder} from 'test/unit/hierarchy_tree_builder'; 24import {TreeNodeUtils} from 'test/unit/tree_node_utils'; 25import {RectShowState} from 'viewers/common/rect_show_state'; 26import {UiHierarchyTreeNode} from 'viewers/common/ui_hierarchy_tree_node'; 27import {UiPropertyTreeNode} from 'viewers/common/ui_property_tree_node'; 28import {ViewerEvents} from 'viewers/common/viewer_events'; 29import {HierarchyTreeNodeDataViewComponent} from './hierarchy_tree_node_data_view_component'; 30import {PropertyTreeNodeDataViewComponent} from './property_tree_node_data_view_component'; 31import {TreeComponent} from './tree_component'; 32import {TreeNodeComponent} from './tree_node_component'; 33 34describe('TreeComponent', () => { 35 let fixture: ComponentFixture<TestHostComponent>; 36 let component: TestHostComponent; 37 let htmlElement: HTMLElement; 38 let mockCopyText: jasmine.Spy; 39 40 beforeEach(async () => { 41 mockCopyText = jasmine.createSpy(); 42 await TestBed.configureTestingModule({ 43 providers: [{provide: Clipboard, useValue: {copy: mockCopyText}}], 44 declarations: [ 45 TreeComponent, 46 TestHostComponent, 47 TreeNodeComponent, 48 HierarchyTreeNodeDataViewComponent, 49 PropertyTreeNodeDataViewComponent, 50 ], 51 imports: [MatTooltipModule, MatIconModule, ClipboardModule], 52 }).compileComponents(); 53 fixture = TestBed.createComponent(TestHostComponent); 54 component = fixture.componentInstance; 55 htmlElement = fixture.nativeElement; 56 }); 57 58 it('can be created', () => { 59 fixture.detectChanges(); 60 expect(component).toBeTruthy(); 61 }); 62 63 it('shows node', () => { 64 fixture.detectChanges(); 65 expect(htmlElement.querySelector('tree-node')).toBeTruthy(); 66 }); 67 68 it('can identify if a parent node has a selected child', () => { 69 fixture.detectChanges(); 70 const treeNode = assertDefined( 71 htmlElement.querySelector<HTMLElement>('tree-node'), 72 ); 73 expect(treeNode.className.includes('child-selected')).toBeFalse(); 74 component.highlightedItem = '3 Child3'; 75 fixture.detectChanges(); 76 expect(treeNode.className.includes('child-selected')).toBeTrue(); 77 }); 78 79 it('highlights node and inner node upon click', () => { 80 fixture.detectChanges(); 81 const treeNodes = assertDefined( 82 htmlElement.querySelectorAll<HTMLElement>('tree-node'), 83 ); 84 85 const spy = spyOn( 86 assertDefined(component.treeComponent).highlightedChange, 87 'emit', 88 ); 89 treeNodes.item(0).dispatchEvent(new MouseEvent('click', {detail: 1})); 90 fixture.detectChanges(); 91 expect(spy).toHaveBeenCalledTimes(1); 92 93 treeNodes.item(1).click(); 94 fixture.detectChanges(); 95 expect(spy).toHaveBeenCalledTimes(2); 96 }); 97 98 it('toggles tree upon node double click', () => { 99 fixture.detectChanges(); 100 const toggleButton = assertDefined( 101 htmlElement.querySelector('.toggle-tree-btn'), 102 ); 103 expect(toggleButton.textContent?.trim()).toEqual('arrow_drop_down'); 104 checkIsExpanded(true); 105 106 doubleClickFirstNode(); 107 expect(toggleButton.textContent?.trim()).toEqual('chevron_right'); 108 checkIsExpanded(false); 109 }); 110 111 it('does not toggle tree in flat mode on double click', () => { 112 fixture.detectChanges(); 113 component.isFlattened = true; 114 fixture.detectChanges(); 115 doubleClickFirstNode(); 116 checkIsExpanded(true); 117 }); 118 119 it('pins node on click', () => { 120 fixture.detectChanges(); 121 const pinNodeButton = assertDefined( 122 htmlElement.querySelector<HTMLElement>('.pin-node-btn'), 123 ); 124 const spy = spyOn( 125 assertDefined(component.treeComponent).pinnedItemChange, 126 'emit', 127 ); 128 pinNodeButton.click(); 129 fixture.detectChanges(); 130 expect(spy).toHaveBeenCalled(); 131 }); 132 133 it('expands tree on expand tree button click', () => { 134 fixture.detectChanges(); 135 doubleClickFirstNode(); 136 checkIsExpanded(false); 137 138 assertDefined( 139 htmlElement.querySelector<HTMLElement>('.expand-tree-btn'), 140 ).click(); 141 fixture.detectChanges(); 142 checkIsExpanded(true); 143 }); 144 145 it('expands tree recursively on node selection', () => { 146 fixture.detectChanges(); 147 doubleClickFirstNode(); 148 checkIsExpanded(false); 149 component.highlightedItem = '79 Child79'; 150 fixture.detectChanges(); 151 checkIsExpanded(true); 152 }); 153 154 it('scrolls selected node only if not in view', () => { 155 fixture.detectChanges(); 156 checkNodeScrolling(); 157 }); 158 159 it('scrolls selected node if not in view even if pinned', () => { 160 component.pinnedItems = [ 161 assertDefined(component.tree.getChildByName('Child78')), 162 assertDefined(component.tree.getChildByName('Child79')), 163 ]; 164 fixture.detectChanges(); 165 checkNodeScrolling(); 166 }); 167 168 it('sets initial expanded state to true by default for leaf', () => { 169 fixture.detectChanges(); 170 checkIsExpanded(true); 171 }); 172 173 it('sets initial expanded state to true by default for non root', () => { 174 const child = component.tree.getAllChildren()[0] as UiHierarchyTreeNode; 175 const innerChild = UiHierarchyTreeNode.from( 176 new HierarchyTreeBuilder() 177 .setId('InnerChild') 178 .setName('child') 179 .setChildren([]) 180 .build(), 181 ); 182 child.addOrReplaceChild(innerChild); 183 component.tree = child; 184 fixture.detectChanges(); 185 checkIsExpanded(true); 186 }); 187 188 it('sets initial expanded state to false if collapse state exists in store', () => { 189 component.useStoredExpandedState = true; 190 fixture.detectChanges(); 191 // tree expanded by default 192 checkIsExpanded(true); 193 194 // tree collapsed 195 doubleClickFirstNode(); 196 checkIsExpanded(false); 197 198 // tree collapsed state retained 199 component.tree = makeTree(); 200 fixture.detectChanges(); 201 checkIsExpanded(false); 202 }); 203 204 it('renders show state button if applicable', () => { 205 fixture.detectChanges(); 206 expect(htmlElement.querySelector('.toggle-rect-show-state-btn')).toBeNull(); 207 expect(htmlElement.querySelector('.children.with-gutter')).toBeNull(); 208 209 component.rectIdToShowState = new Map([ 210 [component.tree.id, RectShowState.HIDE], 211 ]); 212 fixture.detectChanges(); 213 expect(htmlElement.querySelector('.children.with-gutter')).toBeTruthy(); 214 expect( 215 assertDefined(htmlElement.querySelector('.toggle-rect-show-state-btn')) 216 .textContent, 217 ).toContain('visibility_off'); 218 219 component.rectIdToShowState.set(component.tree.id, RectShowState.SHOW); 220 fixture.detectChanges(); 221 expect( 222 assertDefined(htmlElement.querySelector('.toggle-rect-show-state-btn')) 223 .textContent, 224 ).toContain('visibility'); 225 }); 226 227 it('handles show state button click', () => { 228 component.rectIdToShowState = new Map([ 229 [component.tree.id, RectShowState.HIDE], 230 ]); 231 fixture.detectChanges(); 232 const button = assertDefined( 233 htmlElement.querySelector<HTMLElement>('.toggle-rect-show-state-btn'), 234 ); 235 expect(button.textContent).toContain('visibility_off'); 236 237 let id = ''; 238 htmlElement.addEventListener(ViewerEvents.RectShowStateChange, (event) => { 239 const detail = (event as CustomEvent).detail; 240 id = detail.rectId; 241 component.rectIdToShowState?.set(detail.rectId, detail.state); 242 }); 243 button.click(); 244 fixture.detectChanges(); 245 expect(component.rectIdToShowState.get(id)).toEqual(RectShowState.SHOW); 246 247 button.click(); 248 fixture.detectChanges(); 249 expect(component.rectIdToShowState.get(id)).toEqual(RectShowState.HIDE); 250 }); 251 252 it('shows node at full opacity when applicable', () => { 253 fixture.detectChanges(); 254 expect(htmlElement.querySelector('.node.full-opacity')).toBeTruthy(); 255 256 component.rectIdToShowState = new Map([ 257 [component.tree.id, RectShowState.SHOW], 258 ]); 259 fixture.detectChanges(); 260 expect(htmlElement.querySelector('.node.full-opacity')).toBeTruthy(); 261 262 component.tree = TreeNodeUtils.makeUiPropertyNode( 263 component.tree.id, 264 component.tree.name, 265 0, 266 ); 267 fixture.detectChanges(); 268 expect(htmlElement.querySelector('.node.full-opacity')).toBeTruthy(); 269 }); 270 271 it('shows node at non-full opacity when applicable', () => { 272 component.rectIdToShowState = new Map([]); 273 fixture.detectChanges(); 274 expect(htmlElement.querySelector('.node.full-opacity')).toBeNull(); 275 276 component.rectIdToShowState = new Map([ 277 [component.tree.id, RectShowState.HIDE], 278 ]); 279 fixture.detectChanges(); 280 expect(htmlElement.querySelector('.node.full-opacity')).toBeNull(); 281 }); 282 283 it('copies text via copy button without selecting node', () => { 284 fixture.detectChanges(); 285 286 component.tree = TreeNodeUtils.makeUiPropertyNode( 287 component.tree.id, 288 component.tree.name, 289 0, 290 ); 291 fixture.detectChanges(); 292 293 const spy = spyOn(assertDefined(component.treeComponent), 'onNodeClick'); 294 const copyButton = assertDefined( 295 htmlElement.querySelector<HTMLElement>('.icon-wrapper-copy button'), 296 ); 297 copyButton.click(); 298 fixture.detectChanges(); 299 expect(mockCopyText).toHaveBeenCalled(); 300 expect(spy).not.toHaveBeenCalled(); 301 }); 302 303 function makeTree() { 304 const children = []; 305 for (let i = 0; i < 80; i++) { 306 children.push({id: i, name: `Child${i}`}); 307 } 308 return UiHierarchyTreeNode.from( 309 new HierarchyTreeBuilder() 310 .setId('RootNode') 311 .setName('Root node') 312 .setChildren(children) 313 .build(), 314 ); 315 } 316 317 function doubleClickFirstNode() { 318 assertDefined( 319 htmlElement.querySelector<HTMLElement>('tree-node'), 320 ).dispatchEvent(new MouseEvent('click', {detail: 2})); 321 fixture.detectChanges(); 322 } 323 324 function checkIsExpanded(isExpanded: boolean) { 325 expect(htmlElement.querySelector<HTMLElement>('.children')?.hidden).toEqual( 326 !isExpanded, 327 ); 328 } 329 330 function checkNodeScrolling() { 331 const treeNode = assertDefined(htmlElement.querySelector(`#nodeChild79`)); 332 const spy = spyOn(treeNode, 'scrollIntoView').and.callThrough(); 333 334 component.highlightedItem = 'Root node'; 335 fixture.detectChanges(); 336 337 component.highlightedItem = '79 Child79'; 338 fixture.detectChanges(); 339 expect(spy).toHaveBeenCalledTimes(1); 340 341 component.highlightedItem = '78 Child78'; 342 fixture.detectChanges(); 343 expect(spy).toHaveBeenCalledTimes(1); 344 } 345 346 @Component({ 347 selector: 'host-component', 348 template: ` 349 <div class="tree-wrapper"> 350 <tree-view 351 [node]="tree" 352 [isFlattened]="isFlattened" 353 [pinnedItems]="pinnedItems" 354 [highlightedItem]="highlightedItem" 355 [useStoredExpandedState]="useStoredExpandedState" 356 [itemsClickable]="true" 357 [rectIdToShowState]="rectIdToShowState"></tree-view> 358 </div> 359 `, 360 styles: [ 361 ` 362 .tree-wrapper { 363 height: 500px; 364 overflow: auto; 365 } 366 `, 367 ], 368 }) 369 class TestHostComponent { 370 tree: UiHierarchyTreeNode | UiPropertyTreeNode; 371 highlightedItem = ''; 372 isFlattened = false; 373 useStoredExpandedState = false; 374 rectIdToShowState: Map<string, RectShowState> | undefined; 375 pinnedItems: Array<UiHierarchyTreeNode | UiPropertyTreeNode> = []; 376 377 constructor() { 378 this.tree = makeTree(); 379 } 380 381 @ViewChild(TreeComponent) 382 treeComponent: TreeComponent | undefined; 383 } 384}); 385