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