aurelia/aurelia

View on GitHub
packages/__tests__/src/3-runtime-html/decorator-watch.computed.spec.ts

Summary

Maintainability
F
3 wks
Test Coverage
import { ProxyObservable } from '@aurelia/runtime';
import {
  bindable,
  ComputedWatcher,
  customAttribute,
  customElement,
  ICustomElementViewModel,
  IDepCollectionFn,
  watch,
} from '@aurelia/runtime-html';
import { assert, createFixture, TestContext } from '@aurelia/testing';

describe('3-runtime-html/decorator-watch.computed.spec.ts', function () {
  it('typings work', function () {
    const symbolMethod = Symbol();
    @watch<App>(app => app.col.has(Symbol), 5)
    @watch<App>(app => app.col.has(Symbol), 'someMethod')
    @watch<App>(app => app.col.has(Symbol), symbolMethod)
    @watch<App>(app => app.col.has(Symbol), (v, o, a) => a.someMethod(v, o, a))
    @watch((app: App) => app.col.has(Symbol), 5)
    @watch((app: App) => app.col.has(Symbol), 'someMethod')
    @watch((app: App) => app.col.has(Symbol), symbolMethod)
    @watch((app: App) => app.col.has(Symbol), (v, o, a) => a.someMethod(v, o, a))
    @watch<App>('some.expression', 5)
    @watch<App>('some.expression', 'someMethod')
    @watch<App>('some.expression', symbolMethod)
    @watch<App>('some.expression', (v, o, a) => a.someMethod(v, o, a))
    @watch<App>('some.expression', function (v, o, a) { a.someMethod(v, o, a); })
    @watch<App>(Symbol(), 5)
    @watch<App>(Symbol(), 'someMethod')
    @watch<App>(Symbol(), symbolMethod)
    @watch<App>(Symbol(), (v, o, a) => a.someMethod(v, o, a))
    @watch<App>(Symbol(), function (v, o, a) { a.someMethod(v, o, a); })
    class App {
      public col: Map<unknown, unknown>;

      @watch<App>(app => app.col.has(Symbol))
      @watch((app: App) => app.col.has(Symbol))
      @watch('some.expression')
      @watch(Symbol())
      @watch(5)
      public someMethod(_n: unknown, _o: unknown, _app: App) {/* empty */}
      public [symbolMethod](_n: unknown, _o: unknown, _app: App) {/* empty */}
      public 5(_n: unknown, _o: unknown, _app: App) {/* empty */}
    }

    const app = new App();
    assert.strictEqual(app.col, undefined);
  });

  for (const methodName of [Symbol('method'), 'bla', 5]) {
    it(`validates method "${String(methodName)}" not found when decorating on class`, function () {
      assert.throws(
        () => {
          @watch('..', methodName as any)
          class App {}

          return new App();
        },
        // /Invalid change handler config/
        /AUR0773/
      );
    });
  }

  it('works in basic scenario', function () {
    let callCount = 0;
    class App {
      public person = {
        first: 'bi',
        last: 'go',
        phone: '0134',
        address: '1/34'
      };
      public name = '';

      @watch((test: App) => test.person.phone)
      public phoneChanged(phoneValue: string) {
        callCount++;
        this.name = phoneValue;
      }
    }
    const { ctx, component, appHost, tearDown } = createFixture(`\${name}`, App);

    // with TS, initialization of class field are in constructor
    assert.strictEqual(callCount, 0);
    component.person.first = 'bi ';
    assert.strictEqual(callCount, 0);
    ctx.platform.domQueue.flush();
    assert.strictEqual(appHost.textContent, '');
    component.person.phone = '0413';
    assert.strictEqual(callCount, 1);
    assert.strictEqual(appHost.textContent, '');
    ctx.platform.domQueue.flush();
    assert.strictEqual(appHost.textContent, '0413');

    void tearDown();
  });

  it('watches deep', function () {
    let callCount = 0;
    class App {
      public person = {
        first: 'bi',
        last: 'go',
        phone: '0134',
        addresses: [
          {
            primary: false,
            number: 3,
            strName: 'Aus',
            state: 'ACT'
          },
          {
            primary: true,
            number: 3,
            strName: 'Aus',
            state: 'VIC'
          }
        ]
      };
      public name = '';

      @watch((app: App) => app.person.addresses.find(addr => addr.primary).strName)
      public phoneChanged(strName: string) {
        callCount++;
        this.name = strName;
      }
    }
    const { ctx, component, appHost, tearDown } = createFixture(`<div>\${name}</div>`, App);

    const textNode = appHost.querySelector('div');

    // with TS, initialization of class field are in constructor
    assert.strictEqual(callCount, 0);
    component.person.addresses[1].state = 'QLD';
    assert.strictEqual(callCount, 0);
    component.person.addresses[1].strName = '3cp';
    assert.strictEqual(callCount, 1);
    assert.strictEqual(textNode.textContent, '');
    ctx.platform.domQueue.flush();
    assert.strictEqual(textNode.textContent, '3cp');

    void tearDown();

    component.person.addresses[1].strName = 'Chunpeng Huo';
    assert.strictEqual(textNode.textContent, '3cp');
    ctx.platform.domQueue.flush();
    assert.strictEqual(textNode.textContent, '3cp');
  });

  describe('timing', function () {
    it('ensures proper timing with custom elements', async function () {
      let childBindingCallCount = 0;
      let childBoundCallCount = 0;
      let childUnbindingCallCount = 0;
      let appBindingCallCount = 0;
      let appBoundCallCount = 0;
      let appUnbindingCallCount = 0;

      @customElement({ name: 'child', template: `\${prop}` })
      class Child {
        @bindable()
        public prop = 0;
        public logCallCount = 0;

        @watch((child: Child) => child.prop)
        public log(): void {
          this.logCallCount++;
        }

        public binding(): void {
          childBindingCallCount++;
          assert.strictEqual(this.logCallCount, 0);
          this.prop++;
          assert.strictEqual(this.logCallCount, 0);
        }

        public bound(): void {
          childBoundCallCount++;
          assert.strictEqual(this.logCallCount, 0);
          this.prop++;
          assert.strictEqual(this.logCallCount, 1);
        }

        public unbinding(): void {
          childUnbindingCallCount++;
          // test body prop changed, callCount++
          assert.strictEqual(this.logCallCount, 2);
          this.prop++;
          assert.strictEqual(this.logCallCount, 3);
        }
      }

      class App {
        public child: Child;

        @bindable()
        public prop = 1;
        public logCallCount = 0;

        @watch((child: App) => child.prop)
        public log(): void {
          this.logCallCount++;
        }

        public binding(): void {
          appBindingCallCount++;
          assert.strictEqual(this.logCallCount, 0);
          this.prop++;
          assert.strictEqual(this.logCallCount, 0);
        }

        public bound(): void {
          appBoundCallCount++;
          assert.strictEqual(this.logCallCount, 0);
          assert.strictEqual(this.child.logCallCount, 0);
          this.prop++;
          assert.strictEqual(this.logCallCount, 1);
          // child bound hasn't been called yet,
          // so watcher won't be activated and thus, no log call
          assert.strictEqual(this.child.logCallCount, 0);
        }

        public unbinding(): void {
          appUnbindingCallCount++;
          // already got the modification in the code below, so it starts at 2
          assert.strictEqual(this.logCallCount, 2);
          // child unbinding is called before app unbinding
          assert.strictEqual(this.child.logCallCount, 3);
          this.prop++;
          assert.strictEqual(this.logCallCount, 3);
        }
      }

      const { component, startPromise, tearDown } = createFixture('<child component.ref="child" prop.bind=prop>', App, [Child]);

      await startPromise;
      assert.strictEqual(appBindingCallCount, 1);
      assert.strictEqual(appBoundCallCount, 1);
      assert.strictEqual(appUnbindingCallCount, 0);
      assert.strictEqual(childBindingCallCount, 1);
      assert.strictEqual(childBoundCallCount, 1);

      assert.strictEqual(component.logCallCount, 1);
      assert.strictEqual(component.child.logCallCount, 1);
      component.prop++;
      assert.strictEqual(component.logCallCount, 2);
      assert.strictEqual(component.child.logCallCount, 2);

      const bindings = component.$controller!.bindings;
      assert.strictEqual(bindings.length, 3);
      // watcher should be created before all else
      assert.instanceOf(bindings[0], ComputedWatcher);

      const child = component.child;
      const childBindings = (child as ICustomElementViewModel).$controller!.bindings;
      assert.strictEqual(childBindings.length, 2);
      // watcher should be created before all else
      assert.instanceOf(childBindings[0], ComputedWatcher);
      await tearDown();

      assert.strictEqual(appBindingCallCount, 1);
      assert.strictEqual(appBoundCallCount, 1);
      assert.strictEqual(appUnbindingCallCount, 1);
      assert.strictEqual(childBindingCallCount, 1);
      assert.strictEqual(childBoundCallCount, 1);
      assert.strictEqual(childUnbindingCallCount, 1);

      assert.strictEqual(component.logCallCount, 3);
      assert.strictEqual(child.logCallCount, 3);
      component.prop++;
      assert.strictEqual(component.logCallCount, 3);
      assert.strictEqual(child.logCallCount, 3);
    });

    it('ensures proper timing with custom attribute', async function () {
      let childBindingCallCount = 0;
      let childBoundCallCount = 0;
      let childUnbindingCallCount = 0;
      let appBindingCallCount = 0;
      let appBoundCallCount = 0;
      let appUnbindingCallCount = 0;

      @customAttribute({ name: 'child' })
      class Child {
        @bindable()
        public prop = 0;
        public logCallCount = 0;

        @watch((child: Child) => child.prop)
        public log(): void {
          this.logCallCount++;
        }

        public binding(): void {
          childBindingCallCount++;
          assert.strictEqual(this.logCallCount, 0);
          this.prop++;
          assert.strictEqual(this.logCallCount, 0);
        }

        public bound(): void {
          childBoundCallCount++;
          assert.strictEqual(this.logCallCount, 0);
          this.prop++;
          assert.strictEqual(this.logCallCount, 1);
        }

        public unbinding(): void {
          childUnbindingCallCount++;
          // test body prop changed, callCount++
          assert.strictEqual(this.logCallCount, 2);
          this.prop++;
          assert.strictEqual(this.logCallCount, 3);
        }
      }

      class App {
        public child: Child;

        @bindable()
        public prop = 1;
        public logCallCount = 0;

        @watch((child: App) => child.prop)
        public log(): void {
          this.logCallCount++;
        }

        public binding(): void {
          appBindingCallCount++;
          assert.strictEqual(this.logCallCount, 0);
          this.prop++;
          assert.strictEqual(this.logCallCount, 0);
        }

        public bound(): void {
          appBoundCallCount++;
          assert.strictEqual(this.logCallCount, 0);
          assert.strictEqual(this.child.logCallCount, 0);
          this.prop++;
          assert.strictEqual(this.logCallCount, 1);
          // child after bind hasn't been called yet,
          // so watcher won't be activated and thus, no log call
          assert.strictEqual(this.child.logCallCount, 0);
        }

        public unbinding(): void {
          appUnbindingCallCount++;
          // already got the modification in the code below, so it starts at 2
          assert.strictEqual(this.logCallCount, 2);
          // child unbinding is aclled before this unbinding
          assert.strictEqual(this.child.logCallCount, 3);
          this.prop++;
          assert.strictEqual(this.logCallCount, 3);
        }
      }

      const { component, startPromise, tearDown } = createFixture('<div child.bind="prop" child.ref="child">', App, [Child]);

      await startPromise;
      assert.strictEqual(appBindingCallCount, 1);
      assert.strictEqual(appBoundCallCount, 1);
      assert.strictEqual(appUnbindingCallCount, 0);
      assert.strictEqual(childBindingCallCount, 1);
      assert.strictEqual(childBoundCallCount, 1);

      assert.strictEqual(component.logCallCount, 1);
      assert.strictEqual(component.child.logCallCount, 1);
      component.prop++;
      assert.strictEqual(component.logCallCount, 2);
      assert.strictEqual(component.child.logCallCount, 2);

      const bindings = component.$controller!.bindings;
      assert.strictEqual(bindings.length, 3);
      // watcher should be created before all else
      assert.instanceOf(bindings[0], ComputedWatcher);

      const child = component.child;
      const childBindings = (child as ICustomElementViewModel).$controller!.bindings;
      assert.strictEqual(childBindings.length, 1);
      // watcher should be created before all else
      assert.instanceOf(childBindings[0], ComputedWatcher);
      await tearDown();

      assert.strictEqual(appBindingCallCount, 1);
      assert.strictEqual(appBoundCallCount, 1);
      assert.strictEqual(appUnbindingCallCount, 1);
      assert.strictEqual(childBindingCallCount, 1);
      assert.strictEqual(childBoundCallCount, 1);
      assert.strictEqual(childUnbindingCallCount, 1);

      assert.strictEqual(component.logCallCount, 3);
      assert.strictEqual(child.logCallCount, 3);
      component.prop++;
      assert.strictEqual(component.logCallCount, 3);
      assert.strictEqual(child.logCallCount, 3);
    });
  });

  it('observes collection', function () {
    let callCount = 0;

    class PostOffice {
      public storage: IDelivery[] = [
        { id: 1, name: 'box', delivered: false },
        { id: 2, name: 'toy', delivered: true },
        { id: 3, name: 'letter', delivered: false },
      ];

      public deliveries: IDelivery[];

      public constructor() {
        (this.deliveries = [this.storage[1]]).toString = function () {
          return json(this);
        };
      }

      public newDelivery(delivery: IDelivery) {
        this.storage.push(delivery);
      }

      public delivered(id: number): void {
        const delivery = this.storage.find(delivery => delivery.id === id);
        if (delivery != null) {
          delivery.delivered = true;
        }
      }

      @watch((postOffice: PostOffice) => postOffice.storage.filter(d => d.delivered))
      public onDelivered(deliveries: IDelivery[]) {
        callCount++;
        deliveries.toString = function () {
          return json(this);
        };
        this.deliveries = deliveries;
      }
    }

    const { ctx, component, appHost, tearDown } = createFixture(`<div>\${deliveries}</div>`, PostOffice);

    const textNode = appHost.querySelector('div');
    assert.strictEqual(callCount, 0);
    assert.strictEqual(textNode.textContent, json([{ id: 2, name: 'toy', delivered: true }]));

    component.newDelivery({ id: 4, name: 'cookware', delivered: false });
    assert.strictEqual(callCount, 1);
    ctx.platform.domQueue.flush();
    assert.strictEqual(textNode.textContent, json([{ id: 2, name: 'toy', delivered: true }]));

    component.delivered(1);
    assert.strictEqual(callCount, 2);
    assert.strictEqual(textNode.textContent, json([{ id: 2, name: 'toy', delivered: true }]));
    ctx.platform.domQueue.flush();
    assert.strictEqual(
      textNode.textContent,
      json([
        { id: 1, name: 'box', delivered: true },
        { id: 2, name: 'toy', delivered: true }
      ])
    );

    void tearDown();
    assert.strictEqual(appHost.textContent, '');
    component.newDelivery({ id: 5, name: 'gardenware', delivered: true });
    component.delivered(3);
    assert.strictEqual(
      textNode.textContent,
      json([
        { id: 1, name: 'box', delivered: true },
        { id: 2, name: 'toy', delivered: true }
      ])
    );
    ctx.platform.domQueue.flush();
    assert.strictEqual(
      textNode.textContent,
      json([
        { id: 1, name: 'box', delivered: true },
        { id: 2, name: 'toy', delivered: true }
      ])
    );
    assert.strictEqual(appHost.textContent, '');
  });

  it('observes chain lighting', function () {
    let callCount = 0;

    class PostOffice {
      public storage: IDelivery[] = [
        { id: 1, name: 'box', delivered: false },
        { id: 2, name: 'toy', delivered: true },
        { id: 3, name: 'letter', delivered: false },
      ];

      public deliveries: number;

      public constructor() {
        this.deliveries = 0;
      }

      public newDelivery(delivery: IDelivery) {
        this.storage.push(delivery);
      }

      public delivered(id: number): void {
        const delivery = this.storage.find(delivery => delivery.id === id);
        if (delivery != null) {
          delivery.delivered = true;
        }
      }

      @watch((postOffice: PostOffice) =>
        postOffice
          .storage
          .filter(d => d.delivered)
          .filter(d => d.name === 'box')
          .length
      )
      public boxDelivered(deliveries: number) {
        callCount++;
        this.deliveries = deliveries;
      }
    }

    const { ctx, component, appHost, tearDown } = createFixture(`<div>\${deliveries}</div>`, PostOffice);

    const textNode = appHost.querySelector('div');
    assert.strictEqual(callCount, 0);
    assert.strictEqual(textNode.textContent, '0');

    component.newDelivery({ id: 4, name: 'cookware', delivered: false });
    assert.strictEqual(callCount, 0);
    ctx.platform.domQueue.flush();
    assert.strictEqual(textNode.textContent, '0');

    component.delivered(1);
    assert.strictEqual(callCount, 1);
    assert.strictEqual(textNode.textContent, '0');
    ctx.platform.domQueue.flush();
    assert.strictEqual(textNode.textContent, '1');

    void tearDown();

    component.newDelivery({ id: 5, name: 'gardenware', delivered: true });
    component.delivered(3);
    assert.strictEqual(textNode.textContent, '1');
    assert.strictEqual(callCount, 1);
    ctx.platform.domQueue.flush();
    assert.strictEqual(textNode.textContent, '1');
    component.newDelivery({ id: 6, name: 'box', delivered: true });
    ctx.platform.domQueue.flush();
    assert.strictEqual(textNode.textContent, '1');
  });

  describe('Array', function () {
    const testCases: ITestCase[] = [
      ...[
        ['.push()', (post: IPostOffice) => post.packages.push({ id: 10, name: 'box 10', delivered: true })],
        ['.pop()', (post: IPostOffice) => post.packages.pop()],
        ['.shift()', (post: IPostOffice) => post.packages.shift()],
        ['.unshift()', (post: IPostOffice) => post.packages.unshift({ id: 10, name: 'box 10', delivered: true })],
        ['.splice()', (post: IPostOffice) => post.packages.splice(0, 1, { id: 10, name: 'box 10', delivered: true })],
        ['.reverse()', (post: IPostOffice) => post.packages.reverse()],
      ].map(([name, getter]) => ({
        title: `does NOT observe mutation method ${name}`,
        init: () => Array.from(
          { length: 3 },
          (_, idx) => ({ id: idx + 1, name: `box ${idx + 1}`, delivered: false })
        ),
        get: getter as IDepCollectionFn<object>,
        created: post => {
          assert.strictEqual(post.callCount, 0);
          post.newDelivery(4, 'box 4'); assert.strictEqual(post.callCount, 0);
          post.delivered(1);            assert.strictEqual(post.callCount, 0);
          post.delivered(4);            assert.strictEqual(post.callCount, 0);
          post.newDelivery(5, 'box 5'); assert.strictEqual(post.callCount, 0);
        },
        disposed: post => {
          assert.strictEqual(post.callCount, 0);
          post.delivered(2);            assert.strictEqual(post.callCount, 0);
        },
      })),
      {
        title: 'observes .filter()',
        init: () => Array.from(
          { length: 3 },
          (_, idx) => ({ id: idx + 1, name: `box ${idx + 1}`, delivered: false })
        ),
        get: post => post.packages.filter(d => d.delivered).length,
        created: post => {
          const decoratorCount = post.decoratorCount;
          assert.strictEqual(post.callCount, 0);
          post.newDelivery(4, 'box 4'); assert.strictEqual(post.callCount, 0);
          post.delivered(1);            assert.strictEqual(post.callCount, 1 * decoratorCount);
          post.delivered(4);            assert.strictEqual(post.callCount, 2 * decoratorCount);
          post.newDelivery(5, 'box 5'); assert.strictEqual(post.callCount, 2 * decoratorCount);
        },
        disposed: post => {
          const decoratorCount = post.decoratorCount;
          assert.strictEqual(post.callCount, 2 * decoratorCount);
          post.delivered(2);            assert.strictEqual(post.callCount, 2 * decoratorCount);
        },
      },
      {
        title: 'observes .find()',
        init: () => Array.from(
          { length: 3 },
          (_, idx) => ({ id: idx + 1, name: `box ${idx + 1}`, delivered: false })
        ),
        get: post => post.packages.find(d => d.delivered),
        created: post => {
          const decoratorCount = post.decoratorCount;
          assert.strictEqual(post.callCount, 0);
          post.newDelivery(4, 'box 4'); assert.strictEqual(post.callCount, 0);
          post.delivered(1);            assert.strictEqual(post.callCount, 1 * decoratorCount);
          post.delivered(4);            assert.strictEqual(post.callCount, 1 * decoratorCount);
          post.undelivered(4);          assert.strictEqual(post.callCount, 1 * decoratorCount);
          post.undelivered(1);          assert.strictEqual(post.callCount, 2 * decoratorCount);
        },
        disposed: post => {
          const decoratorCount = post.decoratorCount;
          assert.strictEqual(post.callCount, 2 * decoratorCount);
          post.delivered(1);            assert.strictEqual(post.callCount, 2 * decoratorCount);
        },
      },
      ...[
        ['.indexOf()', (post: IPostOffice) => post.packages.indexOf(post.selected)],
        ['.findIndex()', (post: IPostOffice) => post.packages.findIndex(v => v === post.selected)],
        ['.lastIndexOf()', (post: IPostOffice) => post.packages.lastIndexOf(post.selected)],
        ['.includes()', (post: IPostOffice) => post.packages.includes(post.selected)],
      ].map(([name, getter]) => ({
        title: `observes ${name}`,
        init: () => Array.from(
          { length: 3 },
          (_, idx) => ({ id: idx + 1, name: `box ${idx + 1}`, delivered: false })
        ),
        get: getter as IDepCollectionFn<object>,
        created: post => {
          const decoratorCount = post.decoratorCount;
          assert.strictEqual(post.callCount, 0);
          post.selected = post.packages[2];     assert.strictEqual(post.callCount, 1 * decoratorCount);
          post.selected = null;                 assert.strictEqual(post.callCount, 2 * decoratorCount);
          post.selected = post.packages[1];     assert.strictEqual(post.callCount, 3 * decoratorCount);
        },
        disposed: post => {
          const decoratorCount = post.decoratorCount;
          assert.strictEqual(post.callCount, 3 * decoratorCount);
          post.selected = post.packages[1];     assert.strictEqual(post.callCount, 3 * decoratorCount);
        },
      })),
      {
        title: 'observes .some()',
        init: () => Array.from(
          { length: 3 },
          (_, idx) => ({ id: idx + 1, name: `box ${idx + 1}`, delivered: false })
        ),
        get: post => post.packages.some(d => d.delivered),
        created: post => {
          const decoratorCount = post.decoratorCount;
          assert.strictEqual(post.callCount, 0);
          post.newDelivery(4, 'box 4'); assert.strictEqual(post.callCount, 0);
          post.delivered(1);            assert.strictEqual(post.callCount, 1 * decoratorCount);
          post.delivered(4);            assert.strictEqual(post.callCount, 1 * decoratorCount);
          post.undelivered(4);          assert.strictEqual(post.callCount, 1 * decoratorCount);
          post.undelivered(1);          assert.strictEqual(post.callCount, 2 * decoratorCount);
        },
        disposed: post => {
          const decoratorCount = post.decoratorCount;
          assert.strictEqual(post.callCount, 2 * decoratorCount);
          post.delivered(1);            assert.strictEqual(post.callCount, 2 * decoratorCount);
        },
      },
      {
        title: 'observes .every()',
        init: () => Array.from(
          { length: 3 },
          (_, idx) => ({ id: idx + 1, name: `box ${idx + 1}`, delivered: false })
        ),
        get: post => post.packages.every(d => d.delivered),
        created: post => {
          const decoratorCount = post.decoratorCount;
          assert.strictEqual(post.callCount, 0);
          post.delivered(1);            assert.strictEqual(post.callCount, 0);
          post.delivered(2);            assert.strictEqual(post.callCount, 0);
          post.delivered(3);            assert.strictEqual(post.callCount, 1 * decoratorCount);
          post.newDelivery(4, 'box 4'); assert.strictEqual(post.callCount, 2 * decoratorCount);
          post.delivered(4);            assert.strictEqual(post.callCount, 3 * decoratorCount);
        },
        disposed: post => {
          const decoratorCount = post.decoratorCount;
          assert.strictEqual(post.callCount, 3 * decoratorCount);
          post.delivered(1);            assert.strictEqual(post.callCount, 3 * decoratorCount);
        },
      },
      ...[
        ['.slice()', (post: IPostOffice) => post.packages.slice(0).map(d => d.delivered).join(', ')],
        ['.map()', (post: IPostOffice) => post.packages.map(d => d.delivered).join(', ')],
        ['.flat()', (post: IPostOffice) => post.packages.flat().map(d => d.delivered).join(', ')],
        ['.flatMap()', (post: IPostOffice) => post.packages.flatMap(d => [d.id, d.delivered]).join(', ')],
        ['for..in', (post: IPostOffice) => {
          const results = [];
          const packages = post.packages;
          // eslint-disable-next-line
          for (const i in packages) {
            results.push(packages[i].delivered);
          }
          return results.join(', ');
        }],
        ['.reduce()', (post: IPostOffice) => post
          .packages
          .reduce(
            (str, d, idx, arr) => `${str}${idx === arr.length - 1 ? d.delivered : `, ${d.delivered}`}`,
            '',
          ),
        ],
        ['.reduceRight()', (post: IPostOffice) => post
          .packages
          .reduceRight(
            (str, d, idx, arr) => `${str}${idx === arr.length - 1 ? d.delivered : `, ${d.delivered}`}`,
            '',
          ),
        ],
      ].map(([name, getter]) => ({
        title: `observes ${name}`,
        init: () => Array.from(
          { length: 3 },
          (_, idx) => ({ id: idx + 1, name: `box ${idx + 1}`, delivered: false }),
        ),
        get: getter as IDepCollectionFn<object>,
        created: (post: IPostOffice) => {
          const decoratorCount = post.decoratorCount;
          assert.strictEqual(post.callCount, 0);
          post.newDelivery(4, 'box 4'); assert.strictEqual(post.callCount, 1 * decoratorCount);
          post.delivered(1);            assert.strictEqual(post.callCount, 2 * decoratorCount);
          post.delivered(4);            assert.strictEqual(post.callCount, 3 * decoratorCount);
          post.newDelivery(5, 'box 5'); assert.strictEqual(post.callCount, 4 * decoratorCount);
          post.packages[0].name = 'h';  assert.strictEqual(post.callCount, 4 * decoratorCount);
        },
        disposed: post => {
          const decoratorCount = post.decoratorCount;
          assert.strictEqual(post.callCount, 4 * decoratorCount);
          post.delivered(2);            assert.strictEqual(post.callCount, 4 * decoratorCount);
        },
      })),
      ...[
        ['for..of', (post: IPostOffice) => {
          const result = [];
          for (const p of post.packages) {
            result.push(p.delivered);
          }
          return result.join(', ');
        }],
        ['.entries()', (post: IPostOffice) => {
          const result = [];
          for (const p of post.packages.entries()) {
            result.push(p[1].delivered);
          }
          return result.join(', ');
        }],
        ['.values()', (post: IPostOffice) => {
          const result = [];
          for (const p of post.packages.values()) {
            result.push(p.delivered);
          }
          return result.join(', ');
        }],
        ['.keys()', (post: IPostOffice) => {
          const result = [];
          for (const index of post.packages.keys()) {
            result.push(post.packages[index].delivered);
          }
          return result.join(', ');
        }],
      ].map(([name, getter]) => ({
        title: `observers ${name}`,
        init: () => Array.from(
          { length: 3 },
          (_, idx) => ({ id: idx + 1, name: `box ${idx + 1}`, delivered: false }),
        ),
        get: getter as IDepCollectionFn<object>,
        created: (post: IPostOffice) => {
          const decoratorCount = post.decoratorCount;
          assert.strictEqual(post.callCount, 0);
          // mutate                       // assert the effect
          post.newDelivery(4, 'box 4');   assert.strictEqual(post.callCount, 1 * decoratorCount);
          post.delivered(4);              assert.strictEqual(post.callCount, 2 * decoratorCount);
          post.delivered(1);              assert.strictEqual(post.callCount, 3 * decoratorCount);
        },
        disposed: (post: IPostOffice) => {
          const decoratorCount = post.decoratorCount;
          post.newDelivery(5, 'box 5');   assert.strictEqual(post.callCount, 3 * decoratorCount);
          post.delivered(4);              assert.strictEqual(post.callCount, 3 * decoratorCount);
          post.delivered(1);              assert.strictEqual(post.callCount, 3 * decoratorCount);
        },
      })),
    ];

    for (const { title, only, init, get, created, disposed } of testCases) {
      const $it = only ? it.only : it;
      $it(`${title} on class`, async function () {
        @watch(get, 'log')
        class App implements IPostOffice {
          public decoratorCount: number = 1;
          public packages: IDelivery[] = init?.() ?? [];
          public selected: IDelivery;
          public counter: number = 0;
          public callCount: number = 0;

          public delivered(id: number): void {
            const p = this.packages.find(p => p.id === id);
            if (p) {
              p.delivered = true;
            }
          }

          public undelivered(id: number): void {
            const p = this.packages.find(p => p.id === id);
            if (p) {
              p.delivered = false;
            }
          }

          public newDelivery(id: number, name: string, delivered = false): void {
            this.packages.push({ id, name, delivered });
          }

          public log(): void {
            this.callCount++;
          }
        }

        const { component, ctx, startPromise, tearDown } = createFixture('', App);

        await startPromise;
        created(component, ctx, 1);
        await tearDown();
        disposed?.(component, ctx, 1);
      });

      $it(`${title} on method`, async function () {
        class App implements IPostOffice {
          public decoratorCount: number = 1;
          public packages: IDelivery[] = init?.() ?? [];
          public selected: IDelivery;
          public counter: number = 0;
          public callCount: number = 0;

          public delivered(id: number): void {
            const p = this.packages.find(p => p.id === id);
            if (p) {
              p.delivered = true;
            }
          }

          public undelivered(id: number): void {
            const p = this.packages.find(p => p.id === id);
            if (p) {
              p.delivered = false;
            }
          }

          public newDelivery(id: number, name: string, delivered = false): void {
            this.packages.push({ id, name, delivered });
          }

          @watch(get)
          public log(): void {
            this.callCount++;
          }
        }

        const { component, ctx, startPromise, tearDown } = createFixture('', App);

        await startPromise;
        created(component, ctx, 1);
        await tearDown();
        disposed?.(component, ctx, 1);
      });

      $it(`${title} on both class and method`, async function () {
        @watch(get, 'log')
        class App implements IPostOffice {
          public decoratorCount: number = 2;
          public packages: IDelivery[] = init?.() ?? [];
          public selected: IDelivery;
          public counter: number = 0;
          public callCount: number = 0;

          public delivered(id: number): void {
            const p = this.packages.find(p => p.id === id);
            if (p) {
              p.delivered = true;
            }
          }

          public undelivered(id: number): void {
            const p = this.packages.find(p => p.id === id);
            if (p) {
              p.delivered = false;
            }
          }

          public newDelivery(id: number, name: string, delivered = false): void {
            this.packages.push({ id, name, delivered });
          }

          @watch(get)
          public log(): void {
            this.callCount++;
          }
        }

        const { component, ctx, startPromise, tearDown } = createFixture('', App);

        await startPromise;
        created(component, ctx, 1);
        await tearDown();
        disposed?.(component, ctx, 1);
      });
    }

    interface IPostOffice {
      decoratorCount: number;
      packages: IDelivery[];
      selected: IDelivery;
      counter: number;
      callCount: number;

      newDelivery(id: number, name: string, delivered?: boolean): void;
      delivered(id: number): void;
      undelivered(id: number): void;
      log(): void;
    }

    interface ITestCase {
      title: string;
      only?: boolean;
      init?: () => IDelivery[];
      get: IDepCollectionFn<IPostOffice>;
      created: (post: IPostOffice, ctx: TestContext, decoratorCount: number) => any;
      disposed?: (post: IPostOffice, ctx: TestContext, decoratorCount: number) => any;
    }
  });

  describe('Map', function () {
    const symbol = Symbol();

    const testCases: ITestCase[] = [
      {
        title: 'observes .get()',
        get: (app) => app.map.get(symbol),
        created: (app) => {
          app.map.set(symbol, 0);
          assert.strictEqual(app.callCount, 1 * app.decoratorCount);
          app.map.delete(symbol);
          assert.strictEqual(app.callCount, 2 * app.decoratorCount);
        },
        disposed: (app) => {
          app.map.set(symbol, 'a');
          assert.strictEqual(app.callCount, 2 * app.decoratorCount, 'after disposed');
        },
      },
      {
        title: 'observes .has()',
        get: app => app.map.has(symbol) ? ++ProxyObservable.getRaw(app).counter : 0,
        created: (app) => {
          assert.strictEqual(app.counter, 0);
          assert.strictEqual(app.callCount, 0);
          app.map.set(symbol, '');
          assert.strictEqual(app.counter, app.decoratorCount);
          assert.strictEqual(app.callCount, app.decoratorCount);
        },
        disposed: (app) => {
          assert.strictEqual(app.counter,  app.decoratorCount);
          assert.strictEqual(app.callCount,  app.decoratorCount);
          app.map.set(symbol, '');
          assert.strictEqual(app.counter, app.decoratorCount);
          assert.strictEqual(app.callCount, app.decoratorCount);
        },
      },
      {
        title: 'observes .keys()',
        get: app => Array.from(app.map.keys()).filter(k => k === symbol).length,
        created: app => {
          assert.strictEqual(app.callCount, 0);
          app.map.set('a', 2);          assert.strictEqual(app.callCount, 0);
          app.map.set(symbol, '1');     assert.strictEqual(app.callCount, 1 * app.decoratorCount);
        },
      },
      {
        title: 'observers .values()',
        get: app => Array.from(app.map.values()).filter(v => v === symbol).length,
        created: app => {
          assert.strictEqual(app.callCount, 0);
          // mutate                     // assert the effect
          app.map.set('a', 2);          assert.strictEqual(app.callCount, 0);
          app.map.set('a', symbol);     assert.strictEqual(app.callCount, 1 * app.decoratorCount);
          app.map.set('b', symbol);     assert.strictEqual(app.callCount, 2 * app.decoratorCount);
        },
      },
      {
        title: 'observers @@Symbol.iterator',
        get: app => {
          let count = 0;
          for (const [, value] of app.map) {
            if (value === symbol) count++;
          }
          return count;
        },
        created: app => {
          assert.strictEqual(app.callCount, 0);
          // mutate                     // assert the effect
          app.map.set('a', 2);          assert.strictEqual(app.callCount, 0);
          app.map.set('a', symbol);     assert.strictEqual(app.callCount, 1 * app.decoratorCount);
          app.map.set('b', symbol);     assert.strictEqual(app.callCount, 2 * app.decoratorCount);
        },
      },
      {
        title: 'observers .entries()',
        get: app => {
          let count = 0;
          for (const [, value] of app.map.entries()) {
            if (value === symbol) count++;
          }
          return count;
        },
        created: app => {
          assert.strictEqual(app.callCount, 0);
          // mutate                     // assert the effect
          app.map.set('a', 2);          assert.strictEqual(app.callCount, 0);
          app.map.set('a', symbol);     assert.strictEqual(app.callCount, 1 * app.decoratorCount);
          app.map.set('b', symbol);     assert.strictEqual(app.callCount, 2 * app.decoratorCount);
        },
      },
      {
        title: 'observes .size',
        get: app => app.map.size,
        created: app => {
          assert.strictEqual(app.callCount, 0);
          app.map.set(symbol, 2);       assert.strictEqual(app.callCount, 1 * app.decoratorCount);
          app.map.set(symbol, 1);       assert.strictEqual(app.callCount, 1 * app.decoratorCount);
          app.map.set(1, symbol);       assert.strictEqual(app.callCount, 2 * app.decoratorCount);
        },
      },
      {
        title: 'does not observe mutation by .set()',
        get: app => app.map.set(symbol, 1),
        created: app => {
          assert.strictEqual(app.callCount, 0);
          app.map.set(symbol, 2);
          app.map.set(1, symbol);
          assert.strictEqual(app.callCount, 0);
        },
      },
      {
        title: 'does not observe mutation by .delete()',
        get: app => app.map.delete(symbol),
        created: app => {
          assert.strictEqual(app.callCount, 0);
          app.map.set(symbol, 2);       assert.strictEqual(app.callCount, 0);
          app.map.set(1, 2);            assert.strictEqual(app.callCount, 0);
          app.map.set(1, symbol);       assert.strictEqual(app.callCount, 0);
        },
      },
      {
        title: 'does not observe mutation by .clear()',
        get: app => app.map.clear(),
        created: app => {
          assert.strictEqual(app.callCount, 0);
          app.map.set(symbol, 2);       assert.strictEqual(app.callCount, 0);
          app.map.set(1, 2);            assert.strictEqual(app.callCount, 0);
          app.map.set(1, symbol);       assert.strictEqual(app.callCount, 0);
        },
      },
      {
        title: 'watcher callback is not invoked when getter throws error',
        get: app => {
          // track
          // eslint-disable-next-line
          app.counter;
          if (ProxyObservable.getRaw(app).started) {
            throw new Error('err');
          }
        },
        created: app => {
          app.started = true;
          assert.strictEqual(app.callCount, 0);
          let ex: Error;
          try {
            app.counter++;
          } catch (e) {
            ex = e;
          }
          assert.strictEqual(app.callCount, 0);
          assert.instanceOf(ex, Error);
          assert.strictEqual(ex.message, 'err');
        },
      },
      {
        title: 'works with ===',
        get: app => {
          let has = false;
          app.map.forEach(v => {
            if (v === app.selectedItem) {
              has = true;
            }
          });
          return has;
        },
        created: app => {
          assert.strictEqual(app.callCount, 0, '=== #1');
          const item1 = {};
          const item2 = {};
          app.map = new Map([[1, item1], [2, item2]]);
          assert.strictEqual(app.callCount, 0, '=== #2');
          app.selectedItem = item1;
          assert.strictEqual(app.callCount, 1 * app.decoratorCount, '=== #3');
        },
      },
      {
        title: 'works with Object.is()',
        get: app => {
          let has = false;
          app.map.forEach(v => {
            if (Object.is(v, app.selectedItem)) {
              has = true;
            }
          });
          return has;
        },
        created: app => {
          assert.strictEqual(app.callCount, 0);
          const item1 = {};
          const item2 = {};
          app.map = new Map([[1, item1], [2, item2]]);
          assert.strictEqual(app.callCount, 0);
          app.selectedItem = item1;
          assert.strictEqual(app.callCount, 1 * app.decoratorCount);
        },
      }
    ];

    for (const { title, only = false, get, created, disposed } of testCases) {
      const $it = only ? it.only : it;
      $it(`${title} on method`, async function () {
        class App implements IApp {
          public started: boolean = false;
          public decoratorCount: number = 1;
          public map: Map<unknown, unknown> = new Map();
          public selectedItem: unknown = void 0;
          public counter: number = 0;
          public callCount = 0;

          @watch(get)
          public log() {
            this.callCount++;
          }
        }

        const { ctx, component, startPromise, tearDown } = createFixture('', App);
        await startPromise;
        created(component, ctx, 1);
        await tearDown();
        disposed?.(component, ctx, 1);
      });

      $it(`${title} on class`, async function () {
        @watch(get, (_v, _o, a) => a.log())
        class App implements IApp {
          public started: boolean = false;
          public decoratorCount: number = 1;
          public map: Map<unknown, unknown> = new Map();
          public selectedItem: unknown;
          public counter: number = 0;
          public callCount = 0;

          public log() {
            this.callCount++;
          }
        }

        const { ctx, component, tearDown, startPromise } = createFixture('', App);
        await startPromise;
        created(component, ctx, 1);
        await tearDown();
        disposed?.(component, ctx, 1);
      });

      $it(`${title} on both class and method`, async function () {
        @watch(get, (_v, _o, a) => a.log())
        class App implements IApp {
          public started: boolean = false;
          public decoratorCount: number = 2;
          public map: Map<unknown, unknown> = new Map();
          public selectedItem: unknown;
          public counter: number = 0;
          public callCount = 0;

          @watch(get)
          public log(): void {
            this.callCount++;
          }
        }

        const { ctx, component, startPromise, tearDown } = createFixture('', App);
        await startPromise;
        created(component, ctx, 2);
        await tearDown();
        disposed?.(component, ctx, 2);
      });
    }

    interface IApp {
      started: boolean;
      decoratorCount: number;
      map: Map<unknown, unknown>;
      selectedItem: unknown;
      counter: number;
      callCount: number;
      log(): void;
    }

    interface ITestCase {
      title: string;
      only?: boolean;
      get: IDepCollectionFn<IApp>;
      created: (app: IApp, ctx: TestContext, decoratorCount: number) => any;
      disposed?: (app: IApp, ctx: TestContext, decoratorCount: number) => any;
    }
  });

  describe('Set', function () {
    const symbol = Symbol();

    const testCases: ITestCase[] = [
      {
        title: 'observes .has()',
        get: app => app.set.has(symbol),
        created: (app) => {
          assert.strictEqual(app.callCount, 0);
          app.set.add(symbol);      assert.strictEqual(app.callCount, 1 * app.decoratorCount);
          app.set.add(symbol);      assert.strictEqual(app.callCount, 1 * app.decoratorCount);
          app.set.delete(symbol);   assert.strictEqual(app.callCount, 2 * app.decoratorCount);
        },
        disposed: (app) => {
          app.set.add(symbol);      assert.strictEqual(app.callCount, 2 * app.decoratorCount);
          app.set.add(symbol);      assert.strictEqual(app.callCount, 2 * app.decoratorCount);
          app.set.delete(symbol);   assert.strictEqual(app.callCount, 2 * app.decoratorCount);
        },
      },
      {
        title: 'observes .keys()',
        get: app => Array.from(app.set.keys()).filter(k => k === symbol).length,
        created: app => {
          assert.strictEqual(app.callCount, 0);
          app.set.add('a');         assert.strictEqual(app.callCount, 0);
          app.set.add(symbol);      assert.strictEqual(app.callCount, 1 * app.decoratorCount);
        },
      },
      {
        title: 'observers .values()',
        get: app => Array.from(app.set.values()).filter(v => v === symbol).length,
        created: app => {
          assert.strictEqual(app.callCount, 0);
          // mutate                 // assert the effect
          app.set.add('a');         assert.strictEqual(app.callCount, 0);
          app.set.add('b');         assert.strictEqual(app.callCount, 0);
          app.set.add(symbol);      assert.strictEqual(app.callCount, 1 * app.decoratorCount);
        },
      },
      {
        title: 'observers @@Symbol.iterator',
        get: app => {
          let count = 0;
          for (const value of app.set) {
            if (value === symbol) count++;
          }
          return count;
        },
        created: app => {
          assert.strictEqual(app.callCount, 0);
          // mutate                 // assert the effect
          app.set.add('a');         assert.strictEqual(app.callCount, 0);
          app.set.add('b');         assert.strictEqual(app.callCount, 0);
          app.set.add(symbol);      assert.strictEqual(app.callCount, 1 * app.decoratorCount);
        },
      },
      {
        title: 'observers .entries()',
        get: app => {
          let count = 0;
          for (const [, value] of app.set.entries()) {
            if (value === symbol) count++;
          }
          return count;
        },
        created: app => {
          assert.strictEqual(app.callCount, 0);
          // mutate                 // assert the effect
          app.set.add('a');         assert.strictEqual(app.callCount, 0);
          app.set.add('b');         assert.strictEqual(app.callCount, 0);
          app.set.add(symbol);      assert.strictEqual(app.callCount, 1 * app.decoratorCount);
        },
      },
      {
        title: 'observes .size',
        get: app => app.set.size,
        created: app => {
          assert.strictEqual(app.callCount, 0);
          app.set.add(symbol);      assert.strictEqual(app.callCount, 1 * app.decoratorCount);
          app.set.add(2);           assert.strictEqual(app.callCount, 2 * app.decoratorCount);
          app.set.add(1);           assert.strictEqual(app.callCount, 3 * app.decoratorCount);
        },
      },
      {
        title: 'does not observe mutation by .add()',
        get: app => app.set.add(symbol),
        created: app => {
          assert.strictEqual(app.callCount, 0);
          app.set.add(1);
          app.set.add(2);
          assert.strictEqual(app.callCount, 0);
        },
      },
      {
        title: 'does not observe mutation by .delete()',
        get: app => app.set.delete(symbol),
        created: app => {
          assert.strictEqual(app.callCount, 0);
          app.set.add(symbol);      assert.strictEqual(app.callCount, 0);
          app.set.add(1);           assert.strictEqual(app.callCount, 0);
          app.set.add(2);           assert.strictEqual(app.callCount, 0);
        },
      },
      {
        title: 'does not observe mutation by .clear()',
        get: app => app.set.clear(),
        created: app => {
          assert.strictEqual(app.callCount, 0);
          app.set.add(symbol);      assert.strictEqual(app.callCount, 0);
          app.set.add(1);           assert.strictEqual(app.callCount, 0);
          app.set.add(2);           assert.strictEqual(app.callCount, 0);
        },
      },
      {
        title: 'watcher callback is not invoked when getter throws error',
        get: app => {
          // track
          // eslint-disable-next-line
          app.counter;
          if (ProxyObservable.getRaw(app).started) {
            throw new Error('err');
          }
          return 0;
        },
        created: app => {
          app.started = true;
          assert.strictEqual(app.callCount, 0);
          let ex: Error;
          try {
            app.counter++;
          } catch (e) {
            ex = e;
          }
          assert.strictEqual(app.callCount, 0);
          assert.instanceOf(ex, Error);
          assert.strictEqual(ex.message, 'err');
        },
      },
      {
        title: 'works with ===',
        get: app => {
          let has = false;
          app.set.forEach(v => {
            if (v === app.selectedItem) {
              has = true;
            }
          });
          return has;
        },
        created: app => {
          app.started = true;
          assert.strictEqual(app.callCount, 0, 'Set === #1');
          const item1 = {};
          const item2 = {};
          app.set = new Set([item1, item2]);
          assert.strictEqual(app.callCount, 0, 'Set === #2');
          app.selectedItem = item1;
          assert.strictEqual(app.callCount, 1 * app.decoratorCount, 'Set === #3');
        },
      },
      {
        title: 'works with Object.is()',
        get: app => {
          let has = false;
          app.set.forEach(v => {
            if (Object.is(v, app.selectedItem)) {
              has = true;
            }
          });
          return has;
        },
        created: app => {
          assert.strictEqual(app.callCount, 0);
          const item1 = {};
          const item2 = {};
          app.set = new Set([item1, item2]);
          assert.strictEqual(app.callCount, 0);
          app.selectedItem = item1;
          assert.strictEqual(app.callCount, 1 * app.decoratorCount);
        },
      }
    ];

    for (const { title, only = false, get, created, disposed } of testCases) {
      const $it = only ? it.only : it;
      $it(`${title} on method`, async function () {
        class App implements IApp {
          public started: boolean = false;
          public decoratorCount: number = 1;
          public set: Set<unknown> = new Set();
          public selectedItem: unknown = void 0;
          public counter: number = 0;
          public callCount = 0;

          @watch(get)
          public log() {
            this.callCount++;
          }
        }

        const { ctx, component, startPromise, tearDown } = createFixture('', App);
        try {
          await startPromise;
          created(component, ctx, 1);
        } finally {
          await tearDown();
          disposed?.(component, ctx, 1);
        }
      });

      $it(`${title} on class`, async function () {
        @watch(get, (_v, _o, a) => a.log())
        class App implements IApp {
          public started: boolean = false;
          public decoratorCount: number = 1;
          public set: Set<unknown> = new Set();
          public selectedItem: unknown;
          public counter: number = 0;
          public callCount = 0;

          public log() {
            this.callCount++;
          }
        }

        const { ctx, component, tearDown, startPromise } = createFixture('', App);
        try {
          await startPromise;
          created(component, ctx, 1);
        } finally {
          await tearDown();
          disposed?.(component, ctx, 1);
        }
      });

      $it(`${title} on both class and method`, async function () {
        @watch(get, (_v, _o, a) => a.log())
        class App implements IApp {
          public started: boolean = false;
          public decoratorCount: number = 2;
          public set: Set<unknown> = new Set();
          public selectedItem: unknown;
          public counter: number = 0;
          public callCount = 0;

          @watch(get)
          public log(): void {
            this.callCount++;
          }
        }

        const { ctx, component, startPromise, tearDown } = createFixture('', App);
        try {
          await startPromise;
          created(component, ctx, 2);
        } finally {
          await tearDown();
          disposed?.(component, ctx, 2);
        }
      });
    }

    interface IApp {
      started: boolean;
      decoratorCount: number;
      set: Set<unknown>;
      selectedItem: unknown;
      counter: number;
      callCount: number;
      log(): void;
    }

    interface ITestCase {
      title: string;
      only?: boolean;
      get: IDepCollectionFn<IApp>;
      created: (app: IApp, ctx: TestContext, decoratorCount: number) => any;
      disposed?: (app: IApp, ctx: TestContext, decoratorCount: number) => any;
    }
  });

  interface IDelivery {
    id: number; name: string; delivered: boolean;
  }

  function json(d: any) {
    return JSON.stringify(d);
  }
});