aurelia/aurelia

View on GitHub
packages/__tests__/src/3-runtime-html/template-compiler.primary-bindable.spec.ts

Summary

Maintainability
F
5 days
Test Coverage
import {
  Constructable, resolve,
} from '@aurelia/kernel';
import {
  assert,
  TestContext,
} from '@aurelia/testing';
import {
  bindable,
  bindingBehavior,
  valueConverter,
  customAttribute,
  CustomElement,
  INode,
  CustomAttribute,
  Aurelia,
  ValueConverter,
} from '@aurelia/runtime-html';
import { isNode } from '../util.js';

describe('3-runtime-html/template-compiler.primary-bindable.spec.ts', function () {

  interface IPrimaryBindableTestCase {
    title: string;
    template: string | HTMLElement;
    root?: Constructable;
    only?: boolean;
    resources?: any[];
    browserOnly?: boolean;
    testWillThrow?: boolean;
    attrResources?: any[] | (() => any[]);
    assertFn: (ctx: TestContext, host: HTMLElement, comp: any, attrResources: any[]) => void | Promise<void>;
  }

  const testCases: IPrimaryBindableTestCase[] = [
    {
      title: '(1) works in basic scenario',
      template: '<div square="red"></div>',
      attrResources: () => {
        @customAttribute('square')
        class Square {
          @bindable()
          public color: string;

          private readonly el: INode<HTMLElement> = resolve(INode) as INode<HTMLElement>;

          public binding() {
            this.el.style.background = this.color;
          }
        }

        return [Square];
      },
      assertFn: (_ctx, host, _comp, _attrs) => {
        assert.equal(host.querySelector('div').style.backgroundColor, 'red', 'background === red');
      }
    },
    {
      title: '(2) works in basic scenario, with [primary] 1st position',
      template: '<div square="red"></div>',
      attrResources: () => {
        @customAttribute('square')
        class Square {
          @bindable({ primary: true })
          public color: string;

          @bindable()
          public diameter: number;

          private readonly el: INode<HTMLElement> = resolve(INode) as INode<HTMLElement>;

          public binding() {
            this.el.style.background = this.color;
            assert.strictEqual(this.diameter, undefined, 'diameter === undefined');
          }
        }

        return [Square];
      },
      assertFn: (_ctx, host, _comp, _attrs) => {
        assert.equal(host.querySelector('div').style.backgroundColor, 'red', 'background === red');
      }
    },
    {
      title: '(3) works in basic scenario, with [primary] 2nd position',
      template: '<div square="red"></div>',
      attrResources: () => {
        @customAttribute('square')
        class Square {
          @bindable()
          public diameter: number;

          @bindable({ primary: true })
          public color: string;

          private readonly el: INode<HTMLElement> = resolve(INode) as INode<HTMLElement>;

          public binding() {
            this.el.style.background = this.color;
            assert.strictEqual(this.diameter, undefined, 'diameter === undefined');
          }
        }

        return [Square];
      },
      assertFn: (_ctx, host, _comp, _attrs) => {
        assert.equal(host.querySelector('div').style.backgroundColor, 'red', 'background === red');
      }
    },
    {
      title: '(4) works in basic scenario, [dynamic options style]',
      template: '<div square="color: red"></div>',
      attrResources: () => {
        @customAttribute({
          name: 'square'
        })
        class Square {
          @bindable()
          public color: string;

          private readonly el: INode<HTMLElement> = resolve(INode) as INode<HTMLElement>;

          public binding() {
            this.el.style.background = this.color;
          }
        }

        return [Square];
      },
      assertFn: (_ctx, host, _comp, _attrs) => {
        assert.equal(host.querySelector('div').style.backgroundColor, 'red', 'background === red');
      }
    },
    {
      title: '(5) works in basic scenario, [dynamic options style] + [primary] 1st position',
      template: '<div square="color: red"></div>',
      attrResources: () => {
        @customAttribute({
          name: 'square'
        })
        class Square {
          @bindable({ primary: true })
          public color: string;

          @bindable()
          public diameter: string;

          private readonly el: INode<HTMLElement> = resolve(INode) as INode<HTMLElement>;

          public binding() {
            this.el.style.background = this.color;
            assert.strictEqual(this.diameter, undefined);
          }
        }

        return [Square];
      },
      assertFn: (_ctx, host, _comp, _attrs) => {
        assert.equal(host.querySelector('div').style.backgroundColor, 'red', 'background === red');
      }
    },
    {
      title: '(6) works in basic scenario, [dynamic options style] + [primary] 2nd position',
      template: '<div square="color: red"></div>',
      attrResources: () => {
        @customAttribute({
          name: 'square'
        })
        class Square {
          @bindable()
          public diameter: string;

          @bindable({ primary: true })
          public color: string;

          private readonly el: INode<HTMLElement> = resolve(INode) as INode<HTMLElement>;

          public binding() {
            this.el.style.background = this.color;
          }
        }

        return [Square];
      },
      assertFn: (_ctx, host, _comp, _attrs) => {
        assert.equal(host.querySelector('div').style.backgroundColor, 'red', 'background === red');
      }
    },
    {
      title: '(7) works with interpolation',
      template: `<div square="color: \${\`red\`}; diameter: \${5}"></div>`,
      attrResources: () => {
        @customAttribute({
          name: 'square'
        })
        class Square {
          @bindable()
          public diameter: number;

          @bindable()
          public color: string;

          private readonly el: INode<HTMLElement> = resolve(INode) as INode<HTMLElement>;

          public binding() {
            this.el.style.background = this.color;
            this.el.style.width = this.el.style.height = `${this.diameter}px`;
          }
        }

        return [Square];
      },
      assertFn: (_ctx, host, _comp, _attrs) => {
        assert.equal(host.querySelector('div').style.backgroundColor, 'red', 'background === red');
        assert.equal(host.querySelector('div').style.width, '5px');
      }
    },
    {
      title: '(8) default to "value" as primary bindable',
      template: '<div square.bind="color || `red`">',
      attrResources: () => {
        @customAttribute({ name: 'square' })
        class Square {
          public value: string;
          private readonly el: INode<HTMLElement> = resolve(INode) as INode<HTMLElement>;

          public binding() {
            this.el.style.background = this.value;
          }
        }
        return [Square];
      },
      assertFn: (_ctx, host, _comp) => {
        assert.equal(host.querySelector('div').style.backgroundColor, 'red', 'background === red');
      }
    },
    ...[
      'color | identity: value',
      '`literal:literal`',
      'color & bb:value',
    ].map((expression, idx) => {
      return {
        title: `(${8 + idx + 1}) does not get interpreted as multi bindings when there is a binding command with colon in value: ${expression}`,
        template: `<div square.bind="${expression}">`,
        attrResources: () => {
          @customAttribute({ name: 'square' })
          class Square {
            public value: string;
            private readonly el: INode<HTMLElement> = resolve(INode) as INode<HTMLElement>;

            public binding() {
              const value = this.value === 'literal:literal' ? 'red' : this.value;
              this.el.style.background = value;
            }
          }

          @valueConverter('identity')
          class Identity {
            public toView(val: any, alternativeValue: any) {
              return alternativeValue || val;
            }
          }

          @bindingBehavior('bb')
          class BB {
            public bind() {/*  */}
            public unbind() {/*  */}
          }

          return [Square, Identity, BB];
        },
        root: class App {
          public color = 'red';
        },
        assertFn: (_ctx, host, _comp) => {
          assert.equal(host.querySelector('div').style.backgroundColor, 'red', 'background === red');
        }
      };
    }) as IPrimaryBindableTestCase[],
    // unhappy usage
    {
      title: 'throws when combining binding commnd with interpolation',
      template: `<div square="color.bind: \${\`red\`}; diameter: \${5}"></div>`,
      testWillThrow: true,
      attrResources: () => {
        @customAttribute({
          name: 'square'
        })
        class Square {
          @bindable()
          public diameter: number;

          @bindable()
          public color: string;

          private readonly el: INode<HTMLElement> = resolve(INode) as INode<HTMLElement>;
        }
        return [Square];
      },
      assertFn: (_ctx, _host, _comp, _attrs) => {
        throw new Error('Should not have run');
      }
    },
    {
      title: 'throws when there are two primaries',
      template: '<div square="red"></div>',
      testWillThrow: true,
      attrResources: () => {
        @customAttribute({
          name: 'square'
        })
        class Square {
          @bindable({ primary: true })
          public diameter: number;

          @bindable({ primary: true })
          public color: string;

          private readonly el: INode<HTMLElement> = resolve(INode) as INode<HTMLElement>;
        }
        return [Square];
      },
      assertFn: (_ctx, _host, _comp, _attrs) => {
        throw new Error('Should not have run');
      }
    },
    {
      title: 'works with long name, in single binding syntax',
      template: '<div square="5"></div>',
      attrResources: () => {
        @customAttribute({
          name: 'square'
        })
        class Square {

          @bindable({ primary: true })
          public borderRadius: number;

          @bindable()
          public diameter: number;

          @bindable()
          public color: string;

          private readonly el: INode<HTMLElement> = resolve(INode) as INode<HTMLElement>;

          public binding(): void {
            this.el.style.borderRadius = `${this.borderRadius || 0}px`;
          }
        }
        return [Square];
      },
      assertFn: (_ctx, host) => {
        assert.strictEqual(host.querySelector('div').style.borderRadius, '5px');
      }
    },
    {
      title: 'works with long name, in multi binding syntax',
      template: '<div square="border-radius: 5; color: red"></div>',
      attrResources: () => {
        @customAttribute({
          name: 'square'
        })
        class Square {

          @bindable({ primary: true })
          public borderRadius: number;

          @bindable()
          public color: string;

          private readonly el: INode<HTMLElement> = resolve(INode) as INode<HTMLElement>;

          public binding(): void {
            this.el.style.borderRadius = `${this.borderRadius || 0}px`;
            this.el.style.color = this.color;
          }
        }
        return [Square];
      },
      assertFn: (_ctx, host) => {
        const divEl = host.querySelector('div');
        assert.strictEqual(divEl.style.borderRadius, '5px');
        assert.strictEqual(divEl.style.color, 'red');
      }
    },
    {
      title: 'works with long name, in multi binding syntax + with binding command',
      template: '<div square="border-radius.bind: 5; color: red"></div>',
      attrResources: () => {
        @customAttribute({
          name: 'square'
        })
        class Square {

          @bindable({ primary: true })
          public borderRadius: number;

          @bindable()
          public color: string;

          private readonly el: INode<HTMLElement> = resolve(INode) as INode<HTMLElement>;

          public binding(): void {
            this.el.style.borderRadius = `${this.borderRadius || 0}px`;
            this.el.style.color = this.color;
          }
        }
        return [Square];
      },
      assertFn: (_ctx, host) => {
        const divEl = host.querySelector('div');
        assert.strictEqual(divEl.style.borderRadius, '5px');
        assert.strictEqual(divEl.style.color, 'red');
      }
    },
  ];

  for (const testCase of testCases) {
    const {
      title,
      template,
      root,
      attrResources = () => [],
      resources = [],
      only,
      assertFn,
      testWillThrow
    } = testCase;
    // if (!PLATFORM.isBrowserLike && browserOnly) {
    //   continue;
    // }
    const suit = (_title: string, fn: any) => only
      // eslint-disable-next-line mocha/no-exclusive-tests
      ? it.only(_title, fn)
      : it(_title, fn);

    suit(title, async function () {
      let body: HTMLElement;
      let host: HTMLElement;
      try {
        const ctx = TestContext.create();

        const App = CustomElement.define({ name: 'app', template }, root);
        const au = new Aurelia(ctx.container);
        const attrs = typeof attrResources === 'function' ? attrResources() : attrResources;

        body = ctx.doc.body;
        host = body.appendChild(ctx.createElement('app'));
        ctx.container.register(...resources, ...attrs);

        let component: any;
        try {
          au.app({ host, component: App });
          await au.start();
          component = au.root.controller.viewModel;
        } catch (ex) {
          if (testWillThrow) {
            // dont try to assert anything on throw
            // just bails
            try {
              await au.stop();
            } catch {/* and ignore all errors trying to stop */}
            return;
          }
          throw ex;
        }

        if (testWillThrow) {
          throw new Error('Expected test to throw, but did NOT');
        }

        await assertFn(ctx, host, component, attrs);

        await au.stop();
        au.dispose();
        // await assertFn_AfterDestroy(ctx, host, component);
      } finally {
        if (host) {
          host.remove();
        }
        if (body) {
          body.focus();
        }
      }
    });
  }

  describe('mimic vCurrent route-href', function () {
    class $RouteHref$ {

      public static readonly inject = [INode];

      @bindable()
      public params: any;

      @bindable()
      public href: string;

      @bindable({ primary: true })
      public route: string;

      public constructor(
        private readonly el: HTMLAnchorElement,
      ) {
        /*  */
      }

      public binding(): void {
        this.updateAnchor('route');
      }

      public routeChanged(): void {
        this.updateAnchor('route');
      }

      public paramsChanged(): void {
        this.updateAnchor('params');
      }

      private updateAnchor(property: 'route' | 'params'): void {
        this.el.href = `/?${property}=${String(this[property] || '')}`;
      }
    }

    const RouteHref = CustomAttribute.define('route-href', $RouteHref$);

    const DotConverter = ValueConverter.define(
      {
        // should it throw when defining a value converter with dash in name?
        // name: 'dot-converter'
        name: 'dotConverter'
      },
      class $$DotConverter {
        public toView(val: string, replaceWith: string): string {
          return typeof val === 'string' && typeof replaceWith === 'string'
            ? val.replace(/\./g, replaceWith)
            : val;
        }
      }
    );

    it('works correctly when binding only route name', async function () {
      const ctx = TestContext.create();

      const App = CustomElement.define({
        name: 'app',
        template: `<a route-href="home.main">Home page</a>`
      });
      const au = new Aurelia(ctx.container);

      const body = ctx.doc.body;
      const host = body.appendChild(ctx.createElement('app'));
      ctx.container.register(RouteHref);

      au.app({ component: App, host });
      await au.start();

      if (isNode()) {
        assert.strictEqual(host.querySelector('a').href, `/?route=home.main`);
      } else {
        assert.includes(host.querySelector('a').search, `?route=home.main`);
      }

      await au.stop();
      au.dispose();
      host.remove();
    });

    it('works correctly when using with value converter and a colon', async function () {
      const ctx = TestContext.create();

      const App = CustomElement.define({
        name: 'app',
        template: `<a route-href="\${'home.main' | dotConverter:'--'}">Home page</a>`
      });
      const au = new Aurelia(ctx.container);

      const body = ctx.doc.body;
      const host = body.appendChild(ctx.createElement('app'));
      ctx.container.register(RouteHref, DotConverter);

      au.app({ component: App, host });
      await au.start();

      if (isNode()) {
        assert.strictEqual(host.querySelector('a').href, '/?route=home--main');
      } else {
        assert.strictEqual(host.querySelector('a').search, '?route=home--main');
      }

      await au.stop();
      au.dispose();
      host.remove();
    });

    // todo: fix:
    //      + timing issue (change handler is invoked before binding)
    it('works correctly when using multi binding syntax', async function () {
      const ctx = TestContext.create();

      const App = CustomElement.define(
        {
          name: 'app',
          template: `<a route-href="route: home.main; params.bind: { id: appId }">Home page</a>`
        },
        class App {
          public appId: string;
        }
      );
      const au = new Aurelia(ctx.container);

      const body = ctx.doc.body;
      const host = body.appendChild(ctx.createElement('app'));
      ctx.container.register(RouteHref);

      au.app({ component: App, host });
      await au.start();

      const anchorEl = host.querySelector('a');

      if (isNode()) {
        assert.strictEqual(anchorEl.href, '/?route=home.main');
      } else {
        assert.strictEqual(anchorEl.search, '?route=home.main');
      }

      const app = au.root.controller.viewModel as any;

      app.appId = 'appId-appId';
      if (isNode()) {
        assert.strictEqual(anchorEl.href, '/?params=[object Object]');
      } else {
        assert.strictEqual(anchorEl.search, `?params=[object%20Object]`);
      }

      await au.stop();
      au.dispose();
      host.remove();
    });
  });
});