go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/generic_libs/tools/observer_element/observer_element.test.ts (about) 1 // Copyright 2021 The LUCI Authors. 2 // 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 import { 16 aTimeout, 17 fixture, 18 fixtureCleanup, 19 html, 20 } from '@open-wc/testing-helpers'; 21 import { css } from 'lit'; 22 import { customElement } from 'lit/decorators.js'; 23 import { makeObservable, observable, reaction } from 'mobx'; 24 25 import { MobxExtLitElement } from '@/generic_libs/components/lit_mobx_ext'; 26 import { provider } from '@/generic_libs/tools/lit_context'; 27 28 import { 29 IntersectionNotifier, 30 lazyRendering, 31 observer, 32 ObserverElement, 33 ProgressiveNotifier, 34 provideNotifier, 35 RenderPlaceHolder, 36 } from './observer_element'; 37 38 @customElement('milo-enter-view-observer-notifier-provider-test') 39 @provider 40 class EnterViewObserverNotifierProviderElement extends MobxExtLitElement { 41 @observable.ref 42 @provideNotifier() 43 notifier = new IntersectionNotifier({ root: this }); 44 45 constructor() { 46 super(); 47 makeObservable(this); 48 } 49 50 connectedCallback() { 51 super.connectedCallback(); 52 this.addDisposer( 53 reaction( 54 () => this.notifier, 55 (notifier) => { 56 // Emulate @property() update. 57 this.updated(new Map([['notifier', notifier]])); 58 }, 59 { fireImmediately: true }, 60 ), 61 ); 62 } 63 64 protected render() { 65 return html`<slot></slot>`; 66 } 67 68 static styles = css` 69 :host { 70 display: block; 71 height: 100px; 72 overflow-y: auto; 73 } 74 `; 75 } 76 77 @customElement('milo-enter-view-observer-test-entry') 78 @observer 79 class EnterViewObserverTestEntryElement 80 extends MobxExtLitElement 81 implements ObserverElement 82 { 83 @observable.ref onEnterCallCount = 0; 84 85 constructor() { 86 super(); 87 makeObservable(this); 88 } 89 90 notify() { 91 this.onEnterCallCount++; 92 } 93 94 protected render() { 95 return html`content`; 96 } 97 98 static styles = css` 99 :host { 100 display: block; 101 height: 10px; 102 } 103 `; 104 } 105 106 // jest doesn't support a fully featured intersection observer. 107 // TODO(weiweilin): change the test to rely on a mocked intersection observer. 108 // eslint-disable-next-line jest/no-disabled-tests 109 describe.skip('enterViewObserver', () => { 110 let listView: EnterViewObserverNotifierProviderElement; 111 let entries: NodeListOf<EnterViewObserverTestEntryElement>; 112 113 beforeEach(async () => { 114 listView = await fixture<EnterViewObserverNotifierProviderElement>(html` 115 <milo-enter-view-observer-notifier-provider-test> 116 ${new Array(100) 117 .fill(0) 118 .map( 119 () => 120 html`<milo-enter-view-observer-test-entry></milo-enter-view-observer-test-entry>`, 121 )} 122 </milo-enter-view-observer-notifier-provider-test> 123 `); 124 entries = listView.querySelectorAll<EnterViewObserverTestEntryElement>( 125 'milo-enter-view-observer-test-entry', 126 ); 127 }); 128 129 test('should notify entries in the view.', async () => { 130 await aTimeout(20); 131 entries.forEach((entry, i) => { 132 expect(entry.onEnterCallCount).toStrictEqual(i <= 10 ? 1 : 0); 133 }); 134 }); 135 136 test('should notify new entries scrolls into the view.', async () => { 137 await aTimeout(20); 138 listView.scrollBy(0, 50); 139 await aTimeout(20); 140 141 entries.forEach((entry, i) => { 142 expect(entry.onEnterCallCount).toStrictEqual(i <= 15 ? 1 : 0); 143 }); 144 }); 145 146 test('should re-notify old entries when scrolling back and forth.', async () => { 147 await aTimeout(20); 148 listView.scrollBy(0, 50); 149 await aTimeout(20); 150 listView.scrollBy(0, -50); 151 await aTimeout(20); 152 153 entries.forEach((entry, i) => { 154 expect(entry.onEnterCallCount).toStrictEqual(i <= 15 ? 1 : 0); 155 }); 156 }); 157 158 test('different instances can have different notifiers', async () => { 159 const notifier1 = new IntersectionNotifier(); 160 const notifier2 = new IntersectionNotifier(); 161 const notifier1SubscribeSpy = jest.spyOn(notifier1, 'subscribe'); 162 const notifier1UnsubscribeSpy = jest.spyOn(notifier1, 'unsubscribe'); 163 const notifier2SubscribeSpy = jest.spyOn(notifier2, 'subscribe'); 164 const notifier2UnsubscribeSpy = jest.spyOn(notifier2, 'unsubscribe'); 165 const provider1 = await fixture(html` 166 <milo-enter-view-observer-notifier-provider-test .notifier=${notifier1}> 167 <milo-enter-view-observer-test-entry></milo-enter-view-observer-test-entry> 168 </milo-enter-view-observer-notifier-provider-test> 169 `); 170 const provider2 = await fixture(html` 171 <milo-enter-view-observer-notifier-provider-test .notifier=${notifier2}> 172 <milo-enter-view-observer-test-entry></milo-enter-view-observer-test-entry> 173 </milo-enter-view-observer-notifier-provider-test> 174 `); 175 176 const entry1 = provider1.querySelector( 177 'milo-enter-view-observer-test-entry', 178 ) as EnterViewObserverTestEntryElement; 179 const entry2 = provider2.querySelector( 180 'milo-enter-view-observer-test-entry', 181 ) as EnterViewObserverTestEntryElement; 182 183 expect(notifier1SubscribeSpy.mock.calls.length).toStrictEqual(1); 184 expect(notifier1SubscribeSpy.mock.lastCall?.[0]).toStrictEqual(entry1); 185 expect(notifier2SubscribeSpy.mock.calls.length).toStrictEqual(1); 186 expect(notifier2SubscribeSpy.mock.lastCall?.[0]).toStrictEqual(entry2); 187 188 fixtureCleanup(); 189 190 expect(notifier2UnsubscribeSpy.mock.calls.length).toStrictEqual(1); 191 expect(notifier2UnsubscribeSpy.mock.lastCall?.[0]).toStrictEqual(entry2); 192 expect(notifier1UnsubscribeSpy.mock.calls.length).toStrictEqual(1); 193 expect(notifier1UnsubscribeSpy.mock.lastCall?.[0]).toStrictEqual(entry1); 194 }); 195 196 test('updating observer should works correctly', async () => { 197 const notifier1 = new IntersectionNotifier(); 198 const notifier2 = new IntersectionNotifier(); 199 const notifier1SubscribeSpy = jest.spyOn(notifier1, 'subscribe'); 200 const notifier1UnsubscribeSpy = jest.spyOn(notifier1, 'unsubscribe'); 201 const notifier2SubscribeSpy = jest.spyOn(notifier2, 'subscribe'); 202 const notifier2UnsubscribeSpy = jest.spyOn(notifier2, 'unsubscribe'); 203 204 const provider = await fixture<EnterViewObserverNotifierProviderElement>( 205 html` 206 <milo-enter-view-observer-notifier-provider-test .notifier=${notifier1}> 207 <milo-enter-view-observer-test-entry></milo-enter-view-observer-test-entry> 208 </milo-enter-view-observer-notifier-provider-test> 209 `, 210 ); 211 const entry = provider.querySelector( 212 'milo-enter-view-observer-test-entry', 213 ) as EnterViewObserverTestEntryElement; 214 215 expect(notifier1SubscribeSpy.mock.calls.length).toStrictEqual(1); 216 expect(notifier1SubscribeSpy.mock.lastCall?.[0]).toStrictEqual(entry); 217 218 provider.notifier = notifier2; 219 await aTimeout(20); 220 expect(notifier2SubscribeSpy.mock.calls.length).toStrictEqual(1); 221 expect(notifier2SubscribeSpy.mock.lastCall?.[0]).toStrictEqual(entry); 222 expect(notifier1UnsubscribeSpy.mock.calls.length).toStrictEqual(1); 223 expect(notifier1UnsubscribeSpy.mock.lastCall?.[0]).toStrictEqual(entry); 224 225 expect(notifier2UnsubscribeSpy.mock.calls.length).toStrictEqual(1); 226 expect(notifier2UnsubscribeSpy.mock.lastCall?.[0]).toStrictEqual(entry); 227 }); 228 }); 229 230 @customElement('milo-lazy-rendering-test-entry') 231 @lazyRendering 232 class LazyRenderingElement 233 extends MobxExtLitElement 234 implements RenderPlaceHolder 235 { 236 constructor() { 237 super(); 238 makeObservable(this); 239 } 240 241 renderPlaceHolder() { 242 return html`placeholder`; 243 } 244 245 protected render() { 246 return html`content`; 247 } 248 249 static styles = css` 250 :host { 251 display: block; 252 height: 10px; 253 } 254 `; 255 } 256 257 // jest doesn't support a fully featured intersection observer. 258 // TODO(weiweilin): change the test to rely on a mocked intersection observer. 259 // eslint-disable-next-line jest/no-disabled-tests 260 describe.skip('lazyRendering', () => { 261 let listView: EnterViewObserverNotifierProviderElement; 262 let entries: NodeListOf<LazyRenderingElement>; 263 264 beforeEach(async () => { 265 listView = await fixture<EnterViewObserverNotifierProviderElement>(html` 266 <milo-enter-view-observer-notifier-provider-test> 267 ${new Array(100) 268 .fill(0) 269 .map( 270 () => 271 html`<milo-lazy-rendering-test-entry></milo-lazy-rendering-test-entry>`, 272 )} 273 </milo-enter-view-observer-notifier-provider-test> 274 `); 275 entries = listView.querySelectorAll<LazyRenderingElement>( 276 'milo-lazy-rendering-test-entry', 277 ); 278 }); 279 280 test('should only render content for elements entered the view.', async () => { 281 await aTimeout(20); 282 entries.forEach((entry, i) => { 283 expect(entry.shadowRoot!.textContent).toStrictEqual( 284 i <= 10 ? 'content' : 'placeholder', 285 ); 286 }); 287 }); 288 289 test('should work with scrolling', async () => { 290 await aTimeout(20); 291 listView.scrollBy(0, 50); 292 await aTimeout(20); 293 294 entries.forEach((entry, i) => { 295 expect(entry.shadowRoot!.textContent).toStrictEqual( 296 i <= 15 ? 'content' : 'placeholder', 297 ); 298 }); 299 }); 300 }); 301 302 @customElement('milo-progressive-rendering-test-entry') 303 @lazyRendering 304 class ProgressiveRenderingElement 305 extends MobxExtLitElement 306 implements RenderPlaceHolder 307 { 308 constructor() { 309 super(); 310 makeObservable(this); 311 } 312 313 renderPlaceHolder() { 314 return html`placeholder`; 315 } 316 317 protected render() { 318 return html`content`; 319 } 320 321 static styles = css` 322 :host { 323 display: block; 324 height: 10px; 325 } 326 `; 327 } 328 329 @customElement('milo-progressive-rendering-notifier-provider-test') 330 @provider 331 class ProgressiveNotifierProviderElement extends MobxExtLitElement { 332 @provideNotifier() notifier = new ProgressiveNotifier({ 333 batchInterval: 100, 334 batchSize: 10, 335 root: this, 336 }); 337 338 protected render() { 339 return html`<slot></slot>`; 340 } 341 342 static styles = css` 343 :host { 344 display: block; 345 height: 100px; 346 overflow-y: auto; 347 } 348 `; 349 } 350 351 // jest doesn't support a fully featured intersection observer. 352 // TODO(weiweilin): change the test to rely on a mocked intersection observer. 353 // eslint-disable-next-line jest/no-disabled-tests 354 describe.skip('progressiveNotifier', () => { 355 let listView: ProgressiveNotifierProviderElement; 356 let entries: NodeListOf<ProgressiveRenderingElement>; 357 358 beforeEach(async () => { 359 listView = await fixture<ProgressiveNotifierProviderElement>(html` 360 <milo-progressive-rendering-notifier-provider-test> 361 ${new Array(100) 362 .fill(0) 363 .map( 364 () => 365 html`<milo-progressive-rendering-test-entry></milo-progressive-rendering-test-entry>`, 366 )} 367 </milo-progressive-rendering-notifier-provider-test> 368 `); 369 entries = listView.querySelectorAll<ProgressiveRenderingElement>( 370 'milo-progressive-rendering-test-entry', 371 ); 372 }); 373 374 test('should only render content for elements entered the view.', async () => { 375 await aTimeout(20); 376 entries.forEach((entry, i) => { 377 expect(entry.shadowRoot!.textContent).toStrictEqual( 378 i <= 10 ? 'content' : 'placeholder', 379 ); 380 }); 381 }); 382 383 test('should work with scrolling', async () => { 384 await aTimeout(20); 385 listView.scrollBy(0, 50); 386 await aTimeout(20); 387 388 entries.forEach((entry, i) => { 389 expect(entry.shadowRoot!.textContent).toStrictEqual( 390 i <= 15 ? 'content' : 'placeholder', 391 ); 392 }); 393 }); 394 395 test('should notify some of the remaining entries after certain interval', async () => { 396 await aTimeout(20); 397 entries.forEach((entry, i) => { 398 expect(entry.shadowRoot!.textContent).toStrictEqual( 399 i <= 10 ? 'content' : 'placeholder', 400 ); 401 }); 402 403 await aTimeout(150); 404 entries.forEach((entry, i) => { 405 expect(entry.shadowRoot!.textContent).toStrictEqual( 406 i <= 20 ? 'content' : 'placeholder', 407 ); 408 }); 409 }); 410 411 test('new notification should reset interval', async () => { 412 await aTimeout(20); 413 entries.forEach((entry, i) => { 414 expect(entry.shadowRoot!.textContent).toStrictEqual( 415 i <= 10 ? 'content' : 'placeholder', 416 ); 417 }); 418 419 await aTimeout(60); 420 listView.scrollBy(0, 50); 421 422 await aTimeout(60); 423 entries.forEach((entry, i) => { 424 expect(entry.shadowRoot!.textContent).toStrictEqual( 425 i <= 15 ? 'content' : 'placeholder', 426 ); 427 }); 428 429 await aTimeout(50); 430 entries.forEach((entry, i) => { 431 expect(entry.shadowRoot!.textContent).toStrictEqual( 432 i <= 25 ? 'content' : 'placeholder', 433 ); 434 }); 435 }); 436 });