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 {ScrollingModule} from '@angular/cdk/scrolling'; 18import { 19 ComponentFixture, 20 ComponentFixtureAutoDetect, 21 TestBed, 22} from '@angular/core/testing'; 23import {FormsModule} from '@angular/forms'; 24import {MatButtonModule} from '@angular/material/button'; 25import {MatPseudoCheckboxModule} from '@angular/material/core'; 26import {MatDividerModule} from '@angular/material/divider'; 27import {MatFormFieldModule} from '@angular/material/form-field'; 28import {MatIconModule} from '@angular/material/icon'; 29import {MatInputModule} from '@angular/material/input'; 30import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; 31import {MatSelectModule} from '@angular/material/select'; 32import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; 33import {assertDefined} from 'common/assert_utils'; 34import {TimestampConverterUtils} from 'common/time/test_utils'; 35import {Timestamp} from 'common/time/time'; 36import {TraceBuilder} from 'test/unit/trace_builder'; 37import {TraceEntry} from 'trace/trace'; 38import {TraceType} from 'trace/trace_type'; 39import {PropertyTreeNode} from 'trace/tree_node/property_tree_node'; 40import {LogSelectFilter, LogTextFilter} from 'viewers/common/log_filters'; 41import {TextFilter} from 'viewers/common/text_filter'; 42import { 43 ColumnSpec, 44 LogEntry, 45 LogField, 46 LogHeader, 47} from 'viewers/common/ui_data_log'; 48import { 49 LogFilterChangeDetail, 50 LogTextFilterChangeDetail, 51 TimestampClickDetail, 52 ViewerEvents, 53} from 'viewers/common/viewer_events'; 54import {CollapsedSectionsComponent} from 'viewers/components/collapsed_sections_component'; 55import {CollapsibleSectionTitleComponent} from 'viewers/components/collapsible_section_title_component'; 56import {PropertiesComponent} from 'viewers/components/properties_component'; 57import {SearchBoxComponent} from 'viewers/components/search_box_component'; 58import {SelectWithFilterComponent} from 'viewers/components/select_with_filter_component'; 59import {LogComponent} from './log_component'; 60 61describe('LogComponent', () => { 62 const testColumn1: ColumnSpec = {name: 'test1', cssClass: 'test-1'}; 63 const testColumn2: ColumnSpec = {name: 'test2', cssClass: 'test-2'}; 64 const testColumn3: ColumnSpec = {name: 'test3', cssClass: 'test-3'}; 65 66 let fixture: ComponentFixture<LogComponent>; 67 let component: LogComponent; 68 let htmlElement: HTMLElement; 69 70 beforeEach(async () => { 71 await TestBed.configureTestingModule({ 72 providers: [{provide: ComponentFixtureAutoDetect, useValue: true}], 73 imports: [ 74 ScrollingModule, 75 MatFormFieldModule, 76 FormsModule, 77 MatInputModule, 78 BrowserAnimationsModule, 79 MatSelectModule, 80 MatDividerModule, 81 MatButtonModule, 82 MatIconModule, 83 MatPseudoCheckboxModule, 84 MatProgressSpinnerModule, 85 ], 86 declarations: [ 87 LogComponent, 88 SelectWithFilterComponent, 89 CollapsedSectionsComponent, 90 CollapsibleSectionTitleComponent, 91 PropertiesComponent, 92 SearchBoxComponent, 93 ], 94 }).compileComponents(); 95 96 fixture = TestBed.createComponent(LogComponent); 97 component = fixture.componentInstance; 98 htmlElement = fixture.nativeElement; 99 setComponentInputData(); 100 fixture.detectChanges(); 101 }); 102 103 it('can be created', () => { 104 expect(component).toBeTruthy(); 105 }); 106 107 it('renders filters', () => { 108 const filtersInTable = htmlElement.querySelectorAll('.entries .filter'); 109 expect(filtersInTable.length).toEqual(2); 110 const filtersInTitle = htmlElement.querySelectorAll( 111 '.title-section .filter', 112 ); 113 expect(filtersInTitle.length).toEqual(0); 114 }); 115 116 it('renders filters in title', () => { 117 component.title = 'Test'; 118 component.showFiltersInTitle = true; 119 fixture.detectChanges(); 120 const filtersInTable = htmlElement.querySelectorAll('.entries .filter'); 121 expect(filtersInTable.length).toEqual(0); 122 const filtersInTitle = htmlElement.querySelectorAll( 123 '.title-section .filter', 124 ); 125 expect(filtersInTitle.length).toEqual(2); 126 }); 127 128 it('renders entries', () => { 129 expect(htmlElement.querySelector('.scroll')).toBeTruthy(); 130 131 const entryText = assertDefined( 132 htmlElement.querySelector('.scroll .entry'), 133 ).textContent; 134 expect(entryText).toContain('Test tag'); 135 expect(entryText).toContain('123'); 136 expect(entryText).toContain('2ns'); 137 }); 138 139 it('scrolls to current entry on button click', () => { 140 component.currentIndex = 1; 141 fixture.detectChanges(); 142 const goToCurrentTimeButton = assertDefined( 143 htmlElement.querySelector<HTMLElement>('.go-to-current-time'), 144 ); 145 const spy = spyOn( 146 assertDefined(component.scrollComponent), 147 'scrollToIndex', 148 ); 149 goToCurrentTimeButton.click(); 150 expect(spy).toHaveBeenCalledWith(1); 151 }); 152 153 it('applies select filter correctly', async () => { 154 const allEntries = component.entries.slice(); 155 htmlElement.addEventListener(ViewerEvents.LogFilterChange, (event) => { 156 const detail: LogFilterChangeDetail = (event as CustomEvent).detail; 157 if (detail.value.length === 0) { 158 component.entries = allEntries; 159 return; 160 } 161 component.entries = allEntries.filter((entry) => { 162 const entryValue = assertDefined( 163 entry.fields.find((f) => f.spec === detail.header.spec), 164 ).value.toString(); 165 if (Array.isArray(detail.value)) { 166 return detail.value.includes(entryValue); 167 } 168 return entryValue.includes(detail.value); 169 }); 170 }); 171 expect(htmlElement.querySelectorAll('.entry').length).toEqual(2); 172 const filterTrigger = assertDefined( 173 htmlElement.querySelector<HTMLElement>('.headers .mat-select-trigger'), 174 ); 175 filterTrigger.click(); 176 await fixture.whenStable(); 177 178 const firstOption = assertDefined( 179 document.querySelector<HTMLElement>('.mat-select-panel .mat-option'), 180 ); 181 firstOption.click(); 182 fixture.detectChanges(); 183 expect(htmlElement.querySelectorAll('.entry').length).toEqual(1); 184 185 firstOption.click(); 186 fixture.detectChanges(); 187 expect(htmlElement.querySelectorAll('.entry').length).toEqual(2); 188 }); 189 190 it('applies text filter correctly', async () => { 191 const allEntries = component.entries.slice(); 192 htmlElement.addEventListener(ViewerEvents.LogTextFilterChange, (event) => { 193 const detail: LogTextFilterChangeDetail = (event as CustomEvent).detail; 194 if (detail.filter.filterString.length === 0) { 195 component.entries = allEntries; 196 return; 197 } 198 component.entries = allEntries.filter((entry) => { 199 const entryValue = assertDefined( 200 entry.fields.find((f) => f.spec === detail.header.spec), 201 ).value.toString(); 202 return entryValue.includes(detail.filter.filterString); 203 }); 204 }); 205 expect(htmlElement.querySelectorAll('.entry').length).toEqual(2); 206 207 const inputEl = assertDefined( 208 htmlElement.querySelector<HTMLInputElement>('.headers input'), 209 ); 210 211 inputEl.value = '123'; 212 inputEl.dispatchEvent(new Event('input')); 213 fixture.detectChanges(); 214 expect(htmlElement.querySelectorAll('.entry').length).toEqual(2); 215 216 inputEl.value = '1234'; 217 inputEl.dispatchEvent(new Event('input')); 218 fixture.detectChanges(); 219 expect(htmlElement.querySelectorAll('.entry').length).toEqual(1); 220 221 inputEl.value = '12345'; 222 inputEl.dispatchEvent(new Event('input')); 223 fixture.detectChanges(); 224 expect(htmlElement.querySelectorAll('.entry').length).toEqual(0); 225 226 inputEl.value = ''; 227 inputEl.dispatchEvent(new Event('input')); 228 fixture.detectChanges(); 229 expect(htmlElement.querySelectorAll('.entry').length).toEqual(2); 230 }); 231 232 it('emits event on arrow key press', () => { 233 let downArrowPressedTimes = 0; 234 htmlElement.addEventListener(ViewerEvents.ArrowDownPress, (event) => { 235 downArrowPressedTimes++; 236 }); 237 let upArrowPressedTimes = 0; 238 htmlElement.addEventListener(ViewerEvents.ArrowUpPress, (event) => { 239 upArrowPressedTimes++; 240 }); 241 242 document.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowUp'})); 243 expect(upArrowPressedTimes).toEqual(1); 244 245 document.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowDown'})); 246 expect(downArrowPressedTimes).toEqual(1); 247 248 document.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowUp'})); 249 expect(upArrowPressedTimes).toEqual(2); 250 251 document.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowDown'})); 252 expect(downArrowPressedTimes).toEqual(2); 253 }); 254 255 it('propagates entry on trace entry timestamp click', () => { 256 const logTimestampButton = assertDefined( 257 htmlElement.querySelectorAll<HTMLElement>('.time-button').item(1), 258 ); 259 checkEntryPropagatedOnTimestampClick(logTimestampButton); 260 }); 261 262 it('propagates entry on timestamp click with propagateEntryTimestamp set', () => { 263 const logTimestampButton = assertDefined( 264 htmlElement 265 .querySelectorAll<HTMLElement>(`.${testColumn3.cssClass} button`) 266 .item(1), 267 ); 268 checkEntryPropagatedOnTimestampClick(logTimestampButton); 269 }); 270 271 it('propagates timestamp on raw timestamp click', () => { 272 let timestamp: Timestamp | undefined; 273 htmlElement.addEventListener(ViewerEvents.TimestampClick, (event) => { 274 const detail: TimestampClickDetail = (event as CustomEvent).detail; 275 timestamp = detail.timestamp; 276 }); 277 const logTimestampButton = assertDefined( 278 htmlElement.querySelector<HTMLElement>(`.${testColumn3.cssClass} button`), 279 ); 280 logTimestampButton.click(); 281 282 expect(timestamp).toBeDefined(); 283 }); 284 285 it('does not show button for propagateEntryTimestamp field if entry timestamp invalid', () => { 286 expect( 287 htmlElement.querySelectorAll<HTMLButtonElement>( 288 `.${testColumn3.cssClass} .time-button`, 289 ).length, 290 ).toEqual(2); 291 spyOn(component.entries[1].traceEntry, 'hasValidTimestamp').and.returnValue( 292 false, 293 ); 294 fixture.detectChanges(); 295 expect( 296 htmlElement.querySelectorAll<HTMLButtonElement>( 297 `.${testColumn3.cssClass} .time-button`, 298 ).length, 299 ).toEqual(1); 300 }); 301 302 it('changes css class on entry click and does not scroll', () => { 303 htmlElement.addEventListener(ViewerEvents.LogEntryClick, (event) => { 304 const index = (event as CustomEvent).detail; 305 component.selectedIndex = index; 306 fixture.detectChanges(); 307 }); 308 309 const entry = assertDefined( 310 htmlElement.querySelector<HTMLElement>('.entry[item-id="1"]'), 311 ); 312 expect(entry.className).not.toContain('selected'); 313 const spy = spyOn( 314 assertDefined(component.scrollComponent), 315 'scrollToIndex', 316 ); 317 entry.click(); 318 expect(spy).not.toHaveBeenCalled(); 319 expect(entry.className).toContain('selected'); 320 }); 321 322 it('shows placeholder text', () => { 323 expect(htmlElement.querySelector('.placeholder-text')).toBeNull(); 324 component.entries = []; 325 fixture.detectChanges(); 326 expect(htmlElement.querySelector('.placeholder-text')).toBeTruthy(); 327 component.isFetchingData = true; 328 fixture.detectChanges(); 329 expect(htmlElement.querySelector('.placeholder-text')).toBeNull(); 330 }); 331 332 it('shows fetching data message', () => { 333 expect(htmlElement.querySelector('.fetching-data')).toBeNull(); 334 component.isFetchingData = true; 335 fixture.detectChanges(); 336 expect(htmlElement.querySelector('.fetching-data')).toBeTruthy(); 337 }); 338 339 it('formats timestamp without date unless multiple dates present', () => { 340 const entry = assertDefined(htmlElement.querySelector('.scroll .entry')); 341 expect(entry.textContent?.trim()).toEqual('1ns Test tag 1123 2ns'); 342 343 const spy = spyOn(component, 'areMultipleDatesPresent').and.returnValue( 344 true, 345 ); 346 fixture.detectChanges(); 347 expect(entry.textContent?.trim()).toEqual('1ns Test tag 1123 2ns'); 348 349 setComponentInputData(false); 350 fixture.detectChanges(); 351 expect(entry.textContent?.trim()).toEqual( 352 '1970-01-01, 00:00:00.000 Test tag 21234 N/A', 353 ); 354 355 spy.and.returnValue(false); 356 fixture.detectChanges(); 357 expect(entry.textContent?.trim()).toEqual( 358 '00:00:00.000 Test tag 21234 N/A', 359 ); 360 }); 361 362 function setComponentInputData(elapsed = true) { 363 let entryTime: Timestamp; 364 let fieldTime: Timestamp; 365 if (elapsed) { 366 entryTime = TimestampConverterUtils.makeElapsedTimestamp(1n); 367 fieldTime = TimestampConverterUtils.makeElapsedTimestamp(2n); 368 } else { 369 entryTime = TimestampConverterUtils.makeRealTimestamp(1n); 370 fieldTime = TimestampConverterUtils.makeRealTimestamp(2n); 371 } 372 373 const fields1: LogField[] = [ 374 {spec: testColumn1, value: 'Test tag 1'}, 375 {spec: testColumn2, value: 123}, 376 {spec: testColumn3, value: fieldTime}, 377 ]; 378 const fields2 = [ 379 {spec: testColumn1, value: 'Test tag 2'}, 380 {spec: testColumn2, value: 1234}, 381 {spec: testColumn3, value: 'N/A', propagateEntryTimestamp: true}, 382 ]; 383 384 const trace = new TraceBuilder<PropertyTreeNode>() 385 .setTimestamps([entryTime, entryTime]) 386 .build(); 387 388 const entry1: LogEntry = { 389 traceEntry: trace.getEntry(0), 390 fields: fields1, 391 }; 392 const entry2: LogEntry = { 393 traceEntry: trace.getEntry(1), 394 fields: fields2, 395 }; 396 397 const entries = [entry1, entry2]; 398 399 const headers = [ 400 new LogHeader( 401 testColumn1, 402 new LogSelectFilter(['Test tag 1', 'Test tag 2']), 403 ), 404 new LogHeader(testColumn2, new LogTextFilter(new TextFilter())), 405 ]; 406 407 component.entries = entries; 408 component.headers = headers; 409 component.selectedIndex = 0; 410 component.traceType = TraceType.CUJS; 411 } 412 413 function checkEntryPropagatedOnTimestampClick(button: HTMLElement) { 414 let entry: TraceEntry<object> | undefined; 415 htmlElement.addEventListener(ViewerEvents.TimestampClick, (event) => { 416 const detail: TimestampClickDetail = (event as CustomEvent).detail; 417 entry = detail.entry; 418 }); 419 button.click(); 420 expect(entry).toBeDefined(); 421 } 422}); 423