chore(field): introduction test suites (for InteractionStateMixin)

This commit is contained in:
Thijs Louisse 2019-08-05 17:50:00 +02:00
parent 93ce388403
commit 22bb75a868
6 changed files with 260 additions and 190 deletions

View file

@ -1,3 +1,26 @@
module.exports = { module.exports = {
extends: ['@open-wc/eslint-config', 'eslint-config-prettier'].map(require.resolve), extends: ['@open-wc/eslint-config', 'eslint-config-prettier'].map(require.resolve),
rules: {
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: [
'**/test-suites/**/*.js',
'**/test/**/*.js',
'**/stories/**/*.js',
'**/*.config.js',
],
},
],
},
overrides: [
{
files: ['**/test-suites/**/*.js', '**/test/**/*.js', '**/stories/**/*.js', '**/*.config.js'],
rules: {
'no-console': 'off',
'no-unused-expressions': 'off',
'class-methods-use-this': 'off',
},
},
],
}; };

View file

@ -28,6 +28,7 @@
"src", "src",
"stories", "stories",
"test", "test",
"test-suites",
"translations", "translations",
"*.js" "*.js"
], ],

View file

@ -3,13 +3,14 @@ import { ObserverMixin } from '@lion/core/src/ObserverMixin.js';
import { Unparseable } from '@lion/validate'; import { Unparseable } from '@lion/validate';
/** /**
* `InteractionStateMixin` adds meta information about touched and dirty states, that can * @desc `InteractionStateMixin` adds meta information about touched and dirty states, that can
* be read by other form components (ing-uic-input-error for instance, uses the touched state * be read by other form components (ing-uic-input-error for instance, uses the touched state
* to determine whether an error message needs to be shown). * to determine whether an error message needs to be shown).
* Interaction states will be set when a user: * Interaction states will be set when a user:
* - leaves a form field(blur) -> 'touched' will be set to true. 'prefilled' when a * - leaves a form field(blur) -> 'touched' will be set to true. 'prefilled' when a
* field is left non-empty * field is left non-empty
* - on keyup (actually, on the model-value-changed event) -> 'dirty' will be set to true * - on keyup (actually, on the model-value-changed event) -> 'dirty' will be set to true
* @param {HTMLElement} superclass
*/ */
export const InteractionStateMixin = dedupeMixin( export const InteractionStateMixin = dedupeMixin(
superclass => superclass =>
@ -58,13 +59,15 @@ export const InteractionStateMixin = dedupeMixin(
if (modelValue instanceof Unparseable) { if (modelValue instanceof Unparseable) {
value = modelValue.viewValue; value = modelValue.viewValue;
} }
// Checks for empty objects and arrays // Checks for empty platform types: Objects, Arrays, Dates
if (typeof value === 'object' && value !== null) { if (typeof value === 'object' && value !== null) {
return !!Object.keys(value).length; return !!Object.keys(value).length;
} }
// eslint-disable-next-line no-mixed-operators // eslint-disable-next-line no-mixed-operators
// Checks for empty platform types: Numbers, Booleans
const isNumberValue = typeof value === 'number' && (value === 0 || Number.isNaN(value)); const isNumberValue = typeof value === 'number' && (value === 0 || Number.isNaN(value));
const isBooleanValue = typeof value === 'boolean' && value === false; const isBooleanValue = typeof value === 'boolean' && value === false;
return !!value || isNumberValue || isBooleanValue; return !!value || isNumberValue || isBooleanValue;
} }

View file

@ -0,0 +1,207 @@
import {
expect,
fixture,
unsafeStatic,
html,
defineCE,
triggerFocusFor,
triggerBlurFor,
} from '@open-wc/testing';
import sinon from 'sinon';
import { LitElement } from '@lion/core';
import { InteractionStateMixin } from '../src/InteractionStateMixin.js';
export function runInteractionStateMixinSuite(customConfig) {
const cfg = {
tagString: null,
allowedModelValueTypes: [Array, Object, Number, Boolean, String],
suffix: '',
...customConfig,
};
describe(`InteractionStateMixin ${cfg.suffix ? `(${cfg.suffix})` : ''}`, async () => {
let tag;
before(() => {
if (!cfg.tagString) {
cfg.tagString = defineCE(
class IState extends InteractionStateMixin(LitElement) {
connectedCallback() {
super.connectedCallback();
this.tabIndex = 0;
}
set modelValue(v) {
this._modelValue = v;
this.dispatchEvent(
new CustomEvent('model-value-changed', { bubbles: true, composed: true }),
);
}
get modelValue() {
return this._modelValue;
}
},
);
}
tag = unsafeStatic(cfg.tagString);
});
it('sets states to false on init', async () => {
const el = await fixture(html`<${tag}></${tag}>`);
expect(el.dirty).to.be.false;
expect(el.touched).to.be.false;
expect(el.prefilled).to.be.false;
});
it('sets dirty when value changed', async () => {
const el = await fixture(html`<${tag}></${tag}>`);
expect(el.dirty).to.be.false;
el.modelValue = 'foobar';
expect(el.dirty).to.be.true;
});
it('sets touched to true when field left after focus', async () => {
const el = await fixture(html`<${tag}></${tag}>`);
await triggerFocusFor(el);
await triggerBlurFor(el);
expect(el.touched).to.be.true;
});
// classes are added only for backward compatibility - they are deprecated
it('sets a class "state-(touched|dirty)"', async () => {
const el = await fixture(html`<${tag}></${tag}>`);
el.touched = true;
await el.updateComplete;
expect(el.classList.contains('state-touched')).to.equal(true, 'has class "state-touched"');
el.dirty = true;
await el.updateComplete;
expect(el.classList.contains('state-dirty')).to.equal(true, 'has class "state-dirty"');
});
it('sets an attribute "touched', async () => {
const el = await fixture(html`<${tag}></${tag}>`);
el.touched = true;
await el.updateComplete;
expect(el.hasAttribute('touched')).to.be.true;
});
it('sets an attribute "dirty', async () => {
const el = await fixture(html`<${tag}></${tag}>`);
el.dirty = true;
await el.updateComplete;
expect(el.hasAttribute('dirty')).to.be.true;
});
it('fires "(touched|dirty)-state-changed" event when state changes', async () => {
const touchedSpy = sinon.spy();
const dirtySpy = sinon.spy();
const el = await fixture(
html`<${tag} @touched-changed=${touchedSpy} @dirty-changed=${dirtySpy}></${tag}>`,
);
el.touched = true;
expect(touchedSpy.callCount).to.equal(1);
el.dirty = true;
expect(dirtySpy.callCount).to.equal(1);
});
it('sets prefilled once instantiated', async () => {
const el = await fixture(html`
<${tag} .modelValue=${'prefilled'}></${tag}>
`);
expect(el.prefilled).to.be.true;
const nonPrefilled = await fixture(html`
<${tag} .modelValue=${''}></${tag}>
`);
expect(nonPrefilled.prefilled).to.be.false;
});
// This method actually tests the implementation of the _isPrefilled method.
it(`can determine "prefilled" based on different modelValue types
(${cfg.allowedModelValueTypes.map(t => t.name).join(', ')})`, async () => {
const el = await fixture(html`<${tag}></${tag}>`);
const changeModelValueAndLeave = modelValue => {
const targetEl = el.inputElement || el;
targetEl.dispatchEvent(new Event('focus', { bubbles: true }));
el.modelValue = modelValue;
targetEl.dispatchEvent(new Event(el._leaveEvent, { bubbles: true }));
};
// Prefilled
if (cfg.allowedModelValueTypes.includes(Array)) {
changeModelValueAndLeave(['not-empty']);
expect(el.prefilled, 'not empty array should be "prefilled"').to.be.true;
changeModelValueAndLeave([]);
expect(el.prefilled, 'empty array should not be "prefilled"').to.be.false;
}
if (cfg.allowedModelValueTypes.includes(Object)) {
changeModelValueAndLeave({ not: 'empty' });
expect(el.prefilled, 'not empty object should be "prefilled"').to.be.true;
changeModelValueAndLeave({});
expect(el.prefilled, 'empty object should not be "prefilled"').to.be.false;
}
if (cfg.allowedModelValueTypes.includes(Number)) {
changeModelValueAndLeave(0);
expect(el.prefilled, 'numbers should be "prefilled"').to.be.true;
}
if (cfg.allowedModelValueTypes.includes(String)) {
changeModelValueAndLeave(false);
expect(el.prefilled, 'booleans should be "prefilled"').to.be.true;
changeModelValueAndLeave('');
expect(el.prefilled, 'empty string should not be "prefilled"').to.be.false;
}
// Not prefilled
changeModelValueAndLeave(null);
expect(el.prefilled, 'null should not be "prefilled"').to.be.false;
changeModelValueAndLeave(undefined);
expect(el.prefilled, 'undefined should not be "prefilled"').to.be.false;
});
it('has a method resetInteractionState()', async () => {
const el = await fixture(html`<${tag}></${tag}>`);
el.dirty = true;
el.touched = true;
el.prefilled = true;
el.resetInteractionState();
expect(el.dirty).to.be.false;
expect(el.touched).to.be.false;
expect(el.prefilled).to.be.false;
el.dirty = true;
el.touched = true;
el.prefilled = false;
el.modelValue = 'Some value';
el.resetInteractionState();
expect(el.dirty).to.be.false;
expect(el.touched).to.be.false;
expect(el.prefilled).to.be.true;
});
describe('SubClassers', () => {
it('can override the `_leaveEvent`', async () => {
const tagLeaveString = defineCE(
class IState extends InteractionStateMixin(LitElement) {
constructor() {
super();
this._leaveEvent = 'custom-blur';
}
},
);
const tagLeave = unsafeStatic(tagLeaveString);
const el = await fixture(html`<${tagLeave}></${tagLeave}>`);
el.dispatchEvent(new Event('custom-blur'));
expect(el.touched).to.be.true;
});
it('can override the deprecated `leaveEvent`', async () => {
const el = await fixture(html`<${tag} .leaveEvent=${'custom-blur'}></${tag}>`);
expect(el._leaveEvent).to.equal('custom-blur');
});
});
});
}

View file

@ -1,189 +1,3 @@
import { import { runInteractionStateMixinSuite } from '../test-suites/InteractionStateMixin.suite.js';
expect,
fixture,
unsafeStatic,
html,
defineCE,
triggerFocusFor,
triggerBlurFor,
} from '@open-wc/testing';
import sinon from 'sinon';
import { LitElement } from '@lion/core';
import { InteractionStateMixin } from '../src/InteractionStateMixin.js'; runInteractionStateMixinSuite();
describe('InteractionStateMixin', async () => {
let tagString;
let tag;
before(() => {
tagString = defineCE(
class IState extends InteractionStateMixin(LitElement) {
connectedCallback() {
super.connectedCallback();
this.tabIndex = 0;
}
set modelValue(v) {
this._modelValue = v;
this.dispatchEvent(
new CustomEvent('model-value-changed', { bubbles: true, composed: true }),
);
}
get modelValue() {
return this._modelValue;
}
},
);
tag = unsafeStatic(tagString);
});
it('sets states to false on init', async () => {
const el = await fixture(html`<${tag}></${tag}>`);
expect(el.dirty).to.be.false;
expect(el.touched).to.be.false;
expect(el.prefilled).to.be.false;
});
it('sets dirty when value changed', async () => {
const el = await fixture(html`<${tag}></${tag}>`);
expect(el.dirty).to.be.false;
el.modelValue = 'foobar';
expect(el.dirty).to.be.true;
});
it('sets touched to true when field left after focus', async () => {
const el = await fixture(html`<${tag}></${tag}>`);
await triggerFocusFor(el);
await triggerBlurFor(el);
expect(el.touched).to.be.true;
});
// classes are added only for backward compatibility - they are deprecated
it('sets a class "state-(touched|dirty)"', async () => {
const el = await fixture(html`<${tag}></${tag}>`);
el.touched = true;
await el.updateComplete;
expect(el.classList.contains('state-touched')).to.equal(true, 'has class "state-touched"');
el.dirty = true;
await el.updateComplete;
expect(el.classList.contains('state-dirty')).to.equal(true, 'has class "state-dirty"');
});
it('sets an attribute "touched', async () => {
const el = await fixture(html`<${tag}></${tag}>`);
el.touched = true;
await el.updateComplete;
expect(el.hasAttribute('touched')).to.be.true;
});
it('sets an attribute "dirty', async () => {
const el = await fixture(html`<${tag}></${tag}>`);
el.dirty = true;
await el.updateComplete;
expect(el.hasAttribute('dirty')).to.be.true;
});
it('fires "(touched|dirty)-state-changed" event when state changes', async () => {
const touchedSpy = sinon.spy();
const dirtySpy = sinon.spy();
const el = await fixture(
html`<${tag} @touched-changed=${touchedSpy} @dirty-changed=${dirtySpy}></${tag}>`,
);
el.touched = true;
expect(touchedSpy.callCount).to.equal(1);
el.dirty = true;
expect(dirtySpy.callCount).to.equal(1);
});
it('sets prefilled once instantiated', async () => {
const el = await fixture(html`
<${tag} .modelValue=${'prefilled'}></${tag}>
`);
expect(el.prefilled).to.be.true;
const nonPrefilled = await fixture(html`
<${tag} .modelValue=${''}></${tag}>
`);
expect(nonPrefilled.prefilled).to.be.false;
});
// This method actually tests the implementation of the _isPrefilled method.
it(`can determine "prefilled" based on different modelValue types (Arrays, Objects, Numbers,
Booleans, Strings)`, async () => {
const el = await fixture(html`<${tag}></${tag}>`);
const changeModelValueAndLeave = modelValue => {
el.dispatchEvent(new Event('focus', { bubbles: true }));
el.modelValue = modelValue;
el.dispatchEvent(new Event('blur', { bubbles: true }));
};
// Prefilled
changeModelValueAndLeave(['not-empty']);
expect(el.prefilled, 'not empty array should be "prefilled"').to.be.true;
changeModelValueAndLeave({ not: 'empty' });
expect(el.prefilled, 'not empty object should be "prefilled"').to.be.true;
changeModelValueAndLeave(0);
expect(el.prefilled, 'numbers should be "prefilled"').to.be.true;
changeModelValueAndLeave(false);
expect(el.prefilled, 'booleans should be "prefilled"').to.be.true;
// Not prefilled
changeModelValueAndLeave([]);
expect(el.prefilled, 'empty array should not be "prefilled"').to.be.false;
changeModelValueAndLeave({});
expect(el.prefilled, 'empty object should not be "prefilled"').to.be.false;
changeModelValueAndLeave('');
expect(el.prefilled, 'empty string should not be "prefilled"').to.be.false;
changeModelValueAndLeave(null);
expect(el.prefilled, 'null should not be "prefilled"').to.be.false;
changeModelValueAndLeave(undefined);
expect(el.prefilled, 'undefined should not be "prefilled"').to.be.false;
});
it('has a method resetInteractionState()', async () => {
const el = await fixture(html`<${tag}></${tag}>`);
el.dirty = true;
el.touched = true;
el.prefilled = true;
el.resetInteractionState();
expect(el.dirty).to.be.false;
expect(el.touched).to.be.false;
expect(el.prefilled).to.be.false;
el.dirty = true;
el.touched = true;
el.prefilled = false;
el.modelValue = 'Some value';
el.resetInteractionState();
expect(el.dirty).to.be.false;
expect(el.touched).to.be.false;
expect(el.prefilled).to.be.true;
});
describe('SubClassers', () => {
it('can override the `_leaveEvent`', async () => {
const tagLeaveString = defineCE(
class IState extends InteractionStateMixin(LitElement) {
constructor() {
super();
this._leaveEvent = 'custom-blur';
}
},
);
const tagLeave = unsafeStatic(tagLeaveString);
const el = await fixture(html`<${tagLeave}></${tagLeave}>`);
el.dispatchEvent(new Event('custom-blur'));
expect(el.touched).to.be.true;
});
it('can override the deprecated `leaveEvent`', async () => {
const el = await fixture(html`<${tag} .leaveEvent=${'custom-blur'}></${tag}>`);
expect(el._leaveEvent).to.equal('custom-blur');
});
});
});

View file

@ -0,0 +1,22 @@
import { defineCE } from '@open-wc/testing';
import { runInteractionStateMixinSuite } from '../test-suites/InteractionStateMixin.suite.js';
import '../lion-field.js';
const fieldTagString = defineCE(
class extends customElements.get('lion-field') {
get slots() {
return {
...super.slots,
// LionField needs to have an inputElement defined in order to work...
input: () => document.createElement('input'),
};
}
},
);
describe('<lion-field> integrations', () => {
runInteractionStateMixinSuite({
tagString: fieldTagString,
suffix: 'lion-field',
});
});