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 {assertDefined} from 'common/assert_utils'; 18import {InMemoryStorage} from 'common/store/in_memory_storage'; 19import {TimestampConverterUtils} from 'common/time/test_utils'; 20import {TimeUtils} from 'common/time/time_utils'; 21import { 22 ActiveTraceChanged, 23 DarkModeToggled, 24 TracePositionUpdate, 25} from 'messaging/winscope_event'; 26import {MockPresenter} from 'test/unit/mock_log_viewer_presenter'; 27import {PropertyTreeBuilder} from 'test/unit/property_tree_builder'; 28import {TraceBuilder} from 'test/unit/trace_builder'; 29import {UnitTestUtils} from 'test/unit/utils'; 30import {Trace} from 'trace/trace'; 31import {TracePosition} from 'trace/trace_position'; 32import {TraceType} from 'trace/trace_type'; 33import {DEFAULT_PROPERTY_FORMATTER} from 'trace/tree_node/formatters'; 34import { 35 PropertySource, 36 PropertyTreeNode, 37} from 'trace/tree_node/property_tree_node'; 38import {TextFilter} from 'viewers/common/text_filter'; 39import {LogSelectFilter, LogTextFilter} from './log_filters'; 40import {LogHeader, UiDataLog} from './ui_data_log'; 41import {UserOptions} from './user_options'; 42import { 43 LogFilterChangeDetail, 44 LogTextFilterChangeDetail, 45 TimestampClickDetail, 46 ViewerEvents, 47} from './viewer_events'; 48 49describe('AbstractLogViewerPresenter', () => { 50 let uiData: UiDataLog; 51 let presenter: MockPresenter; 52 let trace: Trace<PropertyTreeNode>; 53 let positionUpdate: TracePositionUpdate; 54 let secondPositionUpdate: TracePositionUpdate; 55 let lastEntryPositionUpdate: TracePositionUpdate; 56 57 beforeAll(async () => { 58 const timestamp1 = TimestampConverterUtils.makeElapsedTimestamp(1n); 59 const timestamp2 = TimestampConverterUtils.makeElapsedTimestamp(2n); 60 const timestamp3 = TimestampConverterUtils.makeElapsedTimestamp(3n); 61 const timestamp4 = TimestampConverterUtils.makeElapsedTimestamp(4n); 62 trace = new TraceBuilder<PropertyTreeNode>() 63 .setType(TraceType.TRANSACTIONS) 64 .setEntries([ 65 new PropertyTreeBuilder() 66 .setRootId('Test Trace') 67 .setName('entry 1') 68 .setChildren([ 69 { 70 name: 'pass1', 71 value: 'pass', 72 formatter: DEFAULT_PROPERTY_FORMATTER, 73 }, 74 { 75 name: 'pass2', 76 value: 'fail', 77 formatter: DEFAULT_PROPERTY_FORMATTER, 78 source: PropertySource.DEFAULT, 79 }, 80 { 81 name: 'fail1', 82 value: 'pass', 83 formatter: DEFAULT_PROPERTY_FORMATTER, 84 }, 85 { 86 name: 'fail2', 87 value: 'fail', 88 formatter: DEFAULT_PROPERTY_FORMATTER, 89 }, 90 ]) 91 .build(), 92 new PropertyTreeBuilder() 93 .setRootId('Test Trace') 94 .setName('entry 2') 95 .build(), 96 new PropertyTreeBuilder() 97 .setRootId('Test Trace') 98 .setName('entry 3') 99 .build(), 100 new PropertyTreeBuilder() 101 .setRootId('Test Trace') 102 .setName('entry 4') 103 .build(), 104 ]) 105 .setTimestamps([timestamp1, timestamp2, timestamp3, timestamp4]) 106 .build(); 107 positionUpdate = TracePositionUpdate.fromTraceEntry(trace.getEntry(0)); 108 secondPositionUpdate = TracePositionUpdate.fromTraceEntry( 109 trace.getEntry(1), 110 ); 111 lastEntryPositionUpdate = TracePositionUpdate.fromTraceEntry( 112 trace.getEntry(3), 113 ); 114 }); 115 116 beforeEach(() => { 117 presenter = new MockPresenter(trace, new InMemoryStorage(), (newData) => { 118 uiData = newData; 119 }); 120 }); 121 122 it('adds event listeners', async () => { 123 const element = makeElement(); 124 presenter.addEventListeners(element); 125 126 const testHeader = new LogHeader( 127 {name: 'Test Column', cssClass: 'test-class'}, 128 new LogSelectFilter([]), 129 ); 130 131 let spy: jasmine.Spy = spyOn(presenter, 'onSelectFilterChange'); 132 const filterDetail = new LogFilterChangeDetail(testHeader, ['']); 133 element.dispatchEvent( 134 new CustomEvent(ViewerEvents.LogFilterChange, { 135 detail: filterDetail, 136 }), 137 ); 138 expect(spy).toHaveBeenCalledWith(testHeader, filterDetail.value); 139 140 spy = spyOn(presenter, 'onTextFilterChange'); 141 const textFilterDetail = new LogTextFilterChangeDetail( 142 testHeader, 143 new TextFilter(), 144 ); 145 element.dispatchEvent( 146 new CustomEvent(ViewerEvents.LogTextFilterChange, { 147 detail: textFilterDetail, 148 }), 149 ); 150 expect(spy).toHaveBeenCalledWith(testHeader, textFilterDetail.filter); 151 152 spy = spyOn(presenter, 'onLogEntryClick'); 153 element.dispatchEvent( 154 new CustomEvent(ViewerEvents.LogEntryClick, { 155 detail: 0, 156 }), 157 ); 158 expect(spy).toHaveBeenCalledWith(0); 159 160 spy = spyOn(presenter, 'onArrowDownPress'); 161 element.dispatchEvent(new CustomEvent(ViewerEvents.ArrowDownPress)); 162 expect(spy).toHaveBeenCalled(); 163 164 spy = spyOn(presenter, 'onArrowUpPress'); 165 element.dispatchEvent(new CustomEvent(ViewerEvents.ArrowUpPress)); 166 expect(spy).toHaveBeenCalled(); 167 168 await sendPositionUpdate(positionUpdate, true); 169 spy = spyOn(presenter, 'onLogTimestampClick'); 170 element.dispatchEvent( 171 new CustomEvent(ViewerEvents.TimestampClick, { 172 detail: new TimestampClickDetail(uiData.entries[0].traceEntry), 173 }), 174 ); 175 expect(spy).toHaveBeenCalledWith(uiData.entries[0].traceEntry); 176 177 spy = spyOn(presenter, 'onRawTimestampClick'); 178 const ts = TimestampConverterUtils.makeZeroTimestamp(); 179 element.dispatchEvent( 180 new CustomEvent(ViewerEvents.TimestampClick, { 181 detail: new TimestampClickDetail(undefined, ts), 182 }), 183 ); 184 expect(spy).toHaveBeenCalledWith(ts); 185 186 spy = spyOn(presenter, 'onPropertiesUserOptionsChange'); 187 element.dispatchEvent( 188 new CustomEvent(ViewerEvents.PropertiesUserOptionsChange, { 189 detail: {userOptions: {}}, 190 }), 191 ); 192 expect(spy).toHaveBeenCalledWith({}); 193 194 spy = spyOn(presenter, 'onPropertiesFilterChange'); 195 const filter = new TextFilter(); 196 element.dispatchEvent( 197 new CustomEvent(ViewerEvents.PropertiesFilterChange, { 198 detail: filter, 199 }), 200 ); 201 expect(spy).toHaveBeenCalledWith(filter); 202 203 spy = spyOn(presenter, 'onPositionChangeByKeyPress'); 204 document.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowLeft'})); 205 pressRightArrowKey(); 206 document.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowUp'})); 207 expect(spy).not.toHaveBeenCalled(); 208 209 document.body.append(element); 210 document.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowLeft'})); 211 pressRightArrowKey(); 212 document.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowUp'})); 213 expect(spy).toHaveBeenCalledTimes(2); 214 }); 215 216 it('initializes entries and filters with options', async () => { 217 expect(uiData.scrollToIndex).toBeUndefined(); 218 expect(uiData.currentIndex).toBeUndefined(); 219 expect(uiData.selectedIndex).toBeUndefined(); 220 expect(uiData.entries.length).toEqual(0); 221 expect(uiData.propertiesTree).toBeUndefined(); 222 expect(uiData.headers).toEqual([]); 223 224 await sendPositionUpdate(positionUpdate, true); 225 226 expect(uiData.scrollToIndex).toBeDefined(); 227 expect(uiData.currentIndex).toBeDefined(); 228 expect(uiData.selectedIndex).toBeUndefined(); 229 expect(uiData.entries.length).toEqual(4); 230 expect(assertDefined(uiData.propertiesTree).id).toEqual( 231 assertDefined(uiData.entries[0].propertiesTree).id, 232 ); 233 expect(uiData.headers.length).toEqual(3); 234 expect((uiData.headers[0].filter as LogSelectFilter).options).toEqual([ 235 'stringValue', 236 'differentValue', 237 ]); 238 }); 239 240 it('processes trace position update and updates ui data', async () => { 241 await sendPositionUpdate(secondPositionUpdate, true); 242 expect(uiData.currentIndex).toEqual(1); 243 expect(assertDefined(uiData.propertiesTree).id).toEqual( 244 assertDefined(uiData.entries[1].propertiesTree).id, 245 ); 246 }); 247 248 it('allows arrow keydown event to propagate if presenter trace not active or current index not defined', async () => { 249 const element = makeElement(); 250 document.body.append(element); 251 presenter.addEventListeners(element); 252 const listenerSpy = jasmine.createSpy(); 253 document.addEventListener('keydown', listenerSpy); 254 255 await sendPositionUpdate( 256 new TracePositionUpdate( 257 TracePosition.fromTimestamp( 258 TimestampConverterUtils.makeElapsedTimestamp(-1n), 259 ), 260 ), 261 true, 262 ); 263 expect(uiData.currentIndex).toBeUndefined(); 264 265 pressRightArrowKey(); 266 expect(listenerSpy).toHaveBeenCalledTimes(1); 267 268 await presenter.onAppEvent( 269 new ActiveTraceChanged( 270 assertDefined(positionUpdate.position.entry).getFullTrace(), 271 ), 272 ); 273 pressRightArrowKey(); 274 expect(listenerSpy).toHaveBeenCalledTimes(2); 275 276 await sendPositionUpdate(positionUpdate); 277 pressRightArrowKey(); 278 expect(listenerSpy).toHaveBeenCalledTimes(2); 279 280 await presenter.onAppEvent( 281 new ActiveTraceChanged( 282 UnitTestUtils.makeEmptyTrace(TraceType.TRANSACTIONS), 283 ), 284 ); 285 pressRightArrowKey(); 286 expect(listenerSpy).toHaveBeenCalledTimes(3); 287 288 document.removeEventListener('keydown', listenerSpy); 289 }); 290 291 it('propagates position with next trace entry of different timestamp on right arrow key press', async () => { 292 const positionUpdateEntry = assertDefined(positionUpdate.position.entry); 293 const trace = positionUpdateEntry.getFullTrace(); 294 await presenter.onAppEvent(new ActiveTraceChanged(trace)); 295 296 const emitEventSpy = jasmine.createSpy(); 297 presenter.setEmitEvent(emitEventSpy); 298 await sendPositionUpdate(positionUpdate, true); 299 300 await presenter.onPositionChangeByKeyPress( 301 new KeyboardEvent('keydown', {key: 'ArrowRight'}), 302 ); 303 const nextEntry = assertDefined( 304 uiData.entries.find( 305 (entry) => 306 entry.traceEntry.getTimestamp() > positionUpdateEntry.getTimestamp(), 307 ), 308 ); 309 expect(emitEventSpy).toHaveBeenCalledWith( 310 new TracePositionUpdate( 311 TracePosition.fromTraceEntry(nextEntry.traceEntry), 312 true, 313 ), 314 ); 315 }); 316 317 it('does not propagate any position on right arrow key press if on last entry', async () => { 318 const trace = assertDefined( 319 lastEntryPositionUpdate.position.entry, 320 ).getFullTrace(); 321 await presenter.onAppEvent(new ActiveTraceChanged(trace)); 322 323 const emitEventSpy = jasmine.createSpy(); 324 presenter.setEmitEvent(emitEventSpy); 325 await sendPositionUpdate(lastEntryPositionUpdate, true); 326 327 await presenter.onPositionChangeByKeyPress( 328 new KeyboardEvent('keydown', {key: 'ArrowRight'}), 329 ); 330 expect(emitEventSpy).not.toHaveBeenCalled(); 331 }); 332 333 it('propagates position with first prev trace entry with valid timestamp on left arrow key press', async () => { 334 const trace = assertDefined( 335 lastEntryPositionUpdate.position.entry, 336 ).getFullTrace(); 337 await presenter.onAppEvent(new ActiveTraceChanged(trace)); 338 339 const emitEventSpy = jasmine.createSpy(); 340 presenter.setEmitEvent(emitEventSpy); 341 await sendPositionUpdate(lastEntryPositionUpdate, true); 342 343 const prevIndex = assertDefined(uiData.currentIndex) - 1; 344 spyOn( 345 uiData.entries[prevIndex].traceEntry, 346 'hasValidTimestamp', 347 ).and.returnValue(false); 348 await presenter.onPositionChangeByKeyPress( 349 new KeyboardEvent('keydown', {key: 'ArrowLeft'}), 350 ); 351 expect(emitEventSpy).toHaveBeenCalledWith( 352 new TracePositionUpdate( 353 TracePosition.fromTraceEntry(uiData.entries[prevIndex - 1].traceEntry), 354 true, 355 ), 356 ); 357 }); 358 359 it('does not propagate any position on left arrow key press if on first entry', async () => { 360 const trace = assertDefined(positionUpdate.position.entry).getFullTrace(); 361 await presenter.onAppEvent(new ActiveTraceChanged(trace)); 362 363 const emitEventSpy = jasmine.createSpy(); 364 presenter.setEmitEvent(emitEventSpy); 365 await sendPositionUpdate(positionUpdate, true); 366 367 await presenter.onPositionChangeByKeyPress( 368 new KeyboardEvent('keydown', {key: 'ArrowLeft'}), 369 ); 370 expect(emitEventSpy).not.toHaveBeenCalled(); 371 }); 372 373 it('filters entries on select filter change', async () => { 374 await sendPositionUpdate(positionUpdate, true); 375 const header = uiData.headers[1]; 376 377 await presenter.onSelectFilterChange(header, ['0']); 378 expect( 379 new Set(uiData.entries.map((entry) => entry.fields[1].value)), 380 ).toEqual(new Set([0])); 381 382 await presenter.onSelectFilterChange(header, ['0', '2', '3']); 383 expect( 384 new Set(uiData.entries.map((entry) => entry.fields[1].value)), 385 ).toEqual(new Set([0, 2, 3])); 386 387 await presenter.onSelectFilterChange(header, []); 388 expect( 389 new Set(uiData.entries.map((entry) => entry.fields[1].value)), 390 ).toEqual(new Set([0, 1, 2, 3])); 391 }); 392 393 it('filters entries on text filter change', async () => { 394 await sendPositionUpdate(positionUpdate, true); 395 const header = uiData.headers[0]; 396 const filter = header.filter as LogTextFilter; 397 398 filter.updateFilterValue(['stringValue']); 399 await presenter.onTextFilterChange(header, filter.textFilter); 400 expect( 401 new Set(uiData.entries.map((entry) => entry.fields[0].value)), 402 ).toEqual(new Set(['stringValue'])); 403 404 filter.updateFilterValue(['value']); 405 await presenter.onTextFilterChange(header, filter.textFilter); 406 expect( 407 new Set(uiData.entries.map((entry) => entry.fields[0].value)), 408 ).toEqual(new Set(['stringValue', 'differentValue'])); 409 410 filter.updateFilterValue(['']); 411 await presenter.onTextFilterChange(header, filter.textFilter); 412 expect( 413 new Set(uiData.entries.map((entry) => entry.fields[0].value)), 414 ).toEqual(new Set(['stringValue', 'differentValue'])); 415 }); 416 417 it('updates indices when filters change', async () => { 418 await sendPositionUpdate(lastEntryPositionUpdate, true); 419 presenter.onLogEntryClick(1); 420 expect(uiData.currentIndex).toEqual(3); 421 expect(uiData.selectedIndex).toEqual(1); 422 423 const header = uiData.headers[1]; 424 await presenter.onSelectFilterChange(header, ['0']); 425 expect(uiData.currentIndex).toEqual(0); 426 expect(uiData.selectedIndex).toEqual(0); 427 428 await presenter.onSelectFilterChange(header, ['0', '2']); 429 expect(uiData.currentIndex).toEqual(1); 430 expect(uiData.selectedIndex).toEqual(0); 431 432 await presenter.onSelectFilterChange(header, []); 433 expect(uiData.currentIndex).toEqual(3); 434 expect(uiData.selectedIndex).toEqual(0); 435 }); 436 437 it('updates properties tree when entry clicked', async () => { 438 await sendPositionUpdate(positionUpdate, true); 439 440 await presenter.onLogEntryClick(2); 441 expect(assertDefined(uiData.propertiesTree).id).toEqual( 442 assertDefined(uiData.entries[2].propertiesTree).id, 443 ); 444 445 // does not remove selection when entry clicked again 446 await presenter.onLogEntryClick(2); 447 expect(assertDefined(uiData.propertiesTree).id).toEqual( 448 assertDefined(uiData.entries[2].propertiesTree).id, 449 ); 450 }); 451 452 it('updates properties tree when changed by key press', async () => { 453 await sendPositionUpdate(positionUpdate, true); 454 await presenter.onLogEntryClick(0); 455 456 await presenter.onArrowDownPress(); 457 expect(uiData.selectedIndex).toEqual(1); 458 expect(assertDefined(uiData.propertiesTree).id).toEqual( 459 assertDefined(uiData.entries[1].propertiesTree).id, 460 ); 461 462 await presenter.onArrowUpPress(); 463 expect(uiData.selectedIndex).toEqual(0); 464 expect(assertDefined(uiData.propertiesTree).id).toEqual( 465 assertDefined(uiData.entries[0].propertiesTree).id, 466 ); 467 468 // does not remove selection if index out of range 469 await presenter.onArrowUpPress(); 470 expect(uiData.selectedIndex).toEqual(0); 471 expect(assertDefined(uiData.propertiesTree).id).toEqual( 472 assertDefined(uiData.entries[0].propertiesTree).id, 473 ); 474 475 // does not remove selection if index out of range 476 await presenter.onLogEntryClick(3); 477 await presenter.onArrowDownPress(); 478 expect(uiData.selectedIndex).toEqual(3); 479 expect(assertDefined(uiData.propertiesTree).id).toEqual( 480 assertDefined(uiData.entries[3].propertiesTree).id, 481 ); 482 }); 483 484 it('emits event on log timestamp click', async () => { 485 await sendPositionUpdate(positionUpdate, true); 486 const spy = jasmine.createSpy(); 487 presenter.setEmitEvent(spy); 488 489 await presenter.onLogTimestampClick(uiData.entries[0].traceEntry); 490 expect(spy).toHaveBeenCalledWith( 491 TracePositionUpdate.fromTraceEntry(uiData.entries[0].traceEntry, true), 492 ); 493 }); 494 495 it('emits event on raw timestamp click', async () => { 496 await sendPositionUpdate(positionUpdate, true); 497 const spy = jasmine.createSpy(); 498 presenter.setEmitEvent(spy); 499 500 const ts = TimestampConverterUtils.makeZeroTimestamp(); 501 await presenter.onRawTimestampClick(ts); 502 expect(spy).toHaveBeenCalledWith( 503 TracePositionUpdate.fromTimestamp(ts, true), 504 ); 505 }); 506 507 it('filters properties tree', async () => { 508 await sendPositionUpdate(positionUpdate, true); 509 expect( 510 assertDefined(uiData.propertiesTree).getAllChildren().length, 511 ).toEqual(3); 512 await presenter.onPropertiesFilterChange(new TextFilter('pass')); 513 expect( 514 assertDefined(uiData.propertiesTree).getAllChildren().length, 515 ).toEqual(2); 516 }); 517 518 it('shows/hides defaults', async () => { 519 await sendPositionUpdate(positionUpdate, true); 520 expect( 521 assertDefined(uiData.propertiesTree).getAllChildren().length, 522 ).toEqual(3); 523 const userOptions: UserOptions = { 524 showDefaults: { 525 name: 'Show defaults', 526 enabled: true, 527 }, 528 }; 529 await presenter.onPropertiesUserOptionsChange(userOptions); 530 expect(uiData.propertiesUserOptions).toEqual(userOptions); 531 expect( 532 assertDefined(uiData.propertiesTree).getAllChildren().length, 533 ).toEqual(4); 534 }); 535 536 it('updates dark mode', async () => { 537 expect(uiData.isDarkMode).toBeFalse(); 538 await presenter.onAppEvent(new DarkModeToggled(true)); 539 expect(uiData.isDarkMode).toBeTrue(); 540 }); 541 542 it('is robust to empty trace', async () => { 543 const trace = UnitTestUtils.makeEmptyTrace(TraceType.TRANSACTIONS); 544 const presenter = new MockPresenter( 545 trace, 546 new InMemoryStorage(), 547 (newData) => (uiData = newData), 548 ); 549 550 await sendPositionUpdate( 551 TracePositionUpdate.fromTimestamp( 552 TimestampConverterUtils.makeRealTimestamp(0n), 553 ), 554 true, 555 presenter, 556 ); 557 558 expect(uiData.entries).toEqual([]); 559 expect(uiData.selectedIndex).toBeUndefined(); 560 expect(uiData.scrollToIndex).toBeUndefined(); 561 expect(uiData.currentIndex).toBeUndefined(); 562 expect(uiData.headers.length).toEqual(3); 563 expect(uiData.propertiesTree).toBeUndefined(); 564 expect(uiData.propertiesUserOptions).toBeDefined(); 565 expect(uiData.propertiesFilter).toBeDefined(); 566 }); 567 568 function makeElement(): HTMLElement { 569 const element = document.createElement('div'); 570 element.style.height = '5px'; 571 element.style.width = '5px'; 572 return element; 573 } 574 575 function pressRightArrowKey() { 576 document.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowRight'})); 577 } 578 579 async function sendPositionUpdate( 580 update: TracePositionUpdate, 581 isFirst = false, 582 p = presenter, 583 ) { 584 await assertDefined(p).onAppEvent(update); 585 if (isFirst) { 586 expect(uiData.isFetchingData).toBeTrue(); // fetches data asynchronously 587 await TimeUtils.wait(() => !uiData.isFetchingData); 588 } 589 expect(uiData.isFetchingData).toBeFalse(); 590 } 591}); 592