Skip to content

Commit

Permalink
test(atomic): add tests to binding decorators (#4911)
Browse files Browse the repository at this point in the history
* Add tests to various binding decorators
* Move `fetchBindings` into separate file
* fix small bugs on the decorators
https://coveord.atlassian.net/browse/KIT-3901

---------

Co-authored-by: Frederic Beaudoin <[email protected]>
  • Loading branch information
y-lakhdar and fbeaudoincoveo authored Feb 3, 2025
1 parent e3599bd commit 12c4aac
Show file tree
Hide file tree
Showing 11 changed files with 545 additions and 41 deletions.
7 changes: 6 additions & 1 deletion packages/atomic/custom-elements-manifest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ export default {
/** Globs to analyze */
globs: ['src/**/*.tsx', 'src/**/*.ts'],
/** Globs to exclude */
exclude: ['**/*.stories.tsx', '**/*.stories.ts', '**/*.stories.js'],
exclude: [
'**/*.stories.tsx',
'**/*.stories.ts',
'**/*.stories.js',
'**/*.spec.ts',
],
stencil: true,
litelement: true,
plugins: [cemPlugin()],
Expand Down
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 log an error to the console when "controller" property is not defined', async () => {
await setupElement<MissingProperty>('missing-property');
element.initialize!();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'invalidProperty property is not defined on component',
element
);
});

it('it should log an error to the console when the "initialize" method is not defined', async () => {
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
89 changes: 89 additions & 0 deletions packages/atomic/src/decorators/binding-guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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 property', () => {
expect(() => {
// @ts-expect-error - unused class
class _ {
// @ts-expect-error - invalid usage
@bindingGuard() myProp?: string;
}
}).toThrow('@bindingGuard decorator can only be used on render method');
});

it('should throw an error if used on a method other than render', () => {
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

0 comments on commit 12c4aac

Please sign in to comment.