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  });