Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test(atomic): add tests to binding decorators #4911

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions packages/atomic/src/decorators/bind-state.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import {type Controller} from '@coveo/headless';
import {LitElement} from 'lit';
import {customElement} from 'lit/decorators.js';
import {vi} from 'vitest';
import type {Bindings} from '../components/search/atomic-search-interface/interfaces';
import {bindStateToController} from './bind-state';
import type {InitializableComponent} from './types';

class MockController implements Controller {
state = {};
subscribe = vi.fn((callback) => {
this.callback = callback;
return () => {};
});

callback?: () => void;

updateState(newState: {}) {
this.state = newState;
this.callback?.();
}
}

describe('@bindStateToController decorator', () => {
const onUpdateCallbackMethodSpy = vi.fn();
let element: InitializableComponent<Bindings> & LitElement;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let controller: MockController;

@customElement('missing-initialize')
class MissingInitialize extends LitElement {
controller!: MockController;
@bindStateToController('controller')
controllerState!: MockController['state'];
}

@customElement('missing-property')
class MissingProperty extends LitElement {
// @ts-expect-error - invalid property
@bindStateToController('invalidProperty')
controllerState!: MockController['state'];
initialize() {}
}

@customElement('test-element')
class TestElement extends LitElement {
controller!: MockController;
@bindStateToController('controller')
controllerState!: MockController['state'];
initialize() {
this.controller = controller;
}
}

@customElement('test-element-callback')
class TestElementCallback extends LitElement {
controller!: MockController;
@bindStateToController('controller', {
onUpdateCallbackMethod: 'onUpdateCallbackMethod',
})
controllerState!: MockController['state'];
onUpdateCallbackMethod = onUpdateCallbackMethodSpy;
initialize() {
this.controller = controller;
}
}

@customElement('test-element-no-callback')
class TestElementNoCallback extends LitElement {
controller!: MockController;
@bindStateToController('controller', {
onUpdateCallbackMethod: 'nonExistentMethod',
})
controllerState!: MockController['state'];

initialize() {
this.controller = controller;
}
}

const setupElement = async <T extends LitElement>(tag = 'test-element') => {
element = document.createElement(tag) as InitializableComponent<Bindings> &
T;
document.body.appendChild(element);
await element.updateComplete;
};

const teardownElement = () => {
document.body.removeChild(element);
};

beforeEach(async () => {
consoleErrorSpy = vi.spyOn(console, 'error');
controller = new MockController();
await await setupElement<TestElement>();
});

afterEach(() => {
teardownElement();
consoleErrorSpy.mockRestore();
});

it('it should not disturb the render life cycle', async () => {
element.initialize!();
expect(element.hasUpdated).toBe(true);
});

it('it should not log an error to the console when the "initialize" method and the "controller" property are defined', () => {
element.initialize!();
expect(consoleErrorSpy).not.toHaveBeenCalled();
});

it('it should not log an error to the console when "controller" property is not defined', async () => {
y-lakhdar marked this conversation as resolved.
Show resolved Hide resolved
await setupElement<MissingProperty>('missing-property');
element.initialize!();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'invalidProperty property is not defined on component',
element
);
});

it('it should not log an error to the console when the "initialize" method is not defined', async () => {
y-lakhdar marked this conversation as resolved.
Show resolved Hide resolved
await setupElement<MissingInitialize>('missing-initialize');
element.initialize!();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'ControllerState: The "initialize" method has to be defined and instantiate a controller for the property controller',
element
);
});

it('should subscribe to the controller', async () => {
element.initialize!();
expect(controller.subscribe).toHaveBeenCalledTimes(1);
});

it('should call the onUpdateCallbackMethod if specified', async () => {
await setupElement<TestElementCallback>('test-element-callback');
element.initialize!();
controller.updateState({value: 'updated state'});
expect(onUpdateCallbackMethodSpy).toHaveBeenCalled();
});

it('should log an error if the onUpdateCallbackMethod is not defined', async () => {
await setupElement<TestElementNoCallback>('test-element-no-callback');
element.initialize!();
controller.updateState({value: 'updated state'});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'ControllerState: The onUpdateCallbackMethod property "nonExistentMethod" is not defined',
element
);
});
});
31 changes: 22 additions & 9 deletions packages/atomic/src/decorators/bind-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,18 @@ type ControllerProperties<T> = {
*/
function overrideShouldUpdate(
component: ReactiveElement,
shouldUpdate: (changedProperties: PropertyValues) => boolean
shouldUpdate: (changedProperties: PropertyValues) => boolean,
stateProperty: string
) {
// @ts-expect-error - shouldUpdate is a protected property
component.shouldUpdate = function (changedProperties: PropertyValues) {
return (
shouldUpdate.call(this, changedProperties) &&
[...changedProperties.values()].some((v) => v !== undefined)
);
for (const [key, value] of changedProperties.entries()) {
if (key === stateProperty && value === undefined) {
return false;
}
}

return shouldUpdate.call(this, changedProperties);
};
}

Expand All @@ -43,9 +47,8 @@ function overrideShouldUpdate(
*
* @param controllerProperty The controller property to subscribe to. The controller has to be created inside of the `initialize` method.
* @param options The configurable `bindStateToController` options.
* TODO: KIT-3822: add unit tests to this decorator
*/
export function bindStateToController<Element extends ReactiveElement>( // TODO: check if can inject @state decorator
export function bindStateToController<Element extends ReactiveElement>(
controllerProperty: ControllerProperties<Element>,
options?: {
/**
Expand All @@ -70,13 +73,23 @@ export function bindStateToController<Element extends ReactiveElement>( // TODO:
// @ts-expect-error - shouldUpdate is a protected property
const {disconnectedCallback, initialize, shouldUpdate} = component;

overrideShouldUpdate(component, shouldUpdate);
overrideShouldUpdate(component, shouldUpdate, stateProperty.toString());

component.initialize = function () {
initialize && initialize.call(this);

if (!initialize) {
return console.error(
`ControllerState: The "initialize" method has to be defined and instantiate a controller for the property ${controllerProperty.toString()}`,
component
);
}

if (!component[controllerProperty]) {
return;
return console.error(
`${controllerProperty.toString()} property is not defined on component`,
component
);
}

if (
Expand Down
79 changes: 79 additions & 0 deletions packages/atomic/src/decorators/binding-guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
buildSearchEngine,
getSampleSearchEngineConfiguration,
} from '@coveo/headless';
import {LitElement, html} from 'lit';
import {customElement, state} from 'lit/decorators.js';
import {vi} from 'vitest';
import type {Bindings} from '../components/search/atomic-search-interface/interfaces';
import {bindingGuard} from './binding-guard';

describe('@bindingGuard decorator', () => {
let element: TestElement;
const renderSpy = vi.fn();
const bindings = {
engine: buildSearchEngine({
configuration: getSampleSearchEngineConfiguration(),
}),
} as Bindings;

@customElement('test-element')
class TestElement extends LitElement {
@state() bindings!: Bindings;
@bindingGuard()
public render() {
renderSpy();
return html`<div>Content to render when bindings are present</div>`;
}
}

const setupElement = async () => {
element = document.createElement('test-element') as TestElement;
document.body.appendChild(element);
await element.updateComplete;
};

const teardownElement = () => {
document.body.removeChild(element);
};

beforeEach(async () => {
await setupElement();
});

afterEach(() => {
teardownElement();
renderSpy.mockRestore();
});

it('should render the original content when bindings are present', async () => {
element.bindings = bindings;
await element.updateComplete;

expect(element.shadowRoot?.textContent).toContain(
'Content to render when bindings are present'
);

expect(renderSpy).toHaveBeenCalled();
});

it('should render nothing when bindings are not present', async () => {
// @ts-expect-error - testing invalid binding
element.bindings = undefined as Bindings;
await element.updateComplete;

expect(element.shadowRoot?.textContent).toBe('');
expect(renderSpy).not.toHaveBeenCalled();
});

it('should throw an error if used on a method other than render', () => {
y-lakhdar marked this conversation as resolved.
Show resolved Hide resolved
expect(() => {
// @ts-expect-error - unused class
class _ {
// @ts-expect-error - invalid usage
@bindingGuard()
public someMethod() {}
}
}).toThrow('@bindingGuard decorator can only be used on render method');
});
});
6 changes: 2 additions & 4 deletions packages/atomic/src/decorators/binding-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,14 @@ export interface LitElementWithBindings extends LitElement {
* return html`<div>Content to render when bindings are present</div>`;
* }
* }
* ```
* TODO: KIT-3822: add unit tests to this decorator
* @throws {Error} If the decorator is used on a method other than the render method.
*/
export function bindingGuard<
Component extends LitElementWithBindings,
T extends TemplateResultType,
>(): RenderGuardDecorator<Component, T> {
return (_, __, descriptor) => {
if (descriptor.value === undefined) {
return (_, propertyKey, descriptor) => {
if (descriptor?.value === undefined || propertyKey !== 'render') {
throw new Error(
'@bindingGuard decorator can only be used on render method'
);
Expand Down
Loading
Loading