diff --git a/test/unit/helpers/foundation.js b/test/unit/helpers/foundation.js index 9337d695efd..620272c8a50 100644 --- a/test/unit/helpers/foundation.js +++ b/test/unit/helpers/foundation.js @@ -24,44 +24,134 @@ import {assert} from 'chai'; import td from 'testdouble'; -// Sanity tests to ensure the following: -// - Default adapters contain functions -// - All expected adapter functions are accounted for -// - Invoking any of the default methods does not throw an error. -// Every foundation test suite include this verification. -export function verifyDefaultAdapter(FoundationClass, expectedMethods) { +/** + * Sanity tests to ensure the following: + * - Default adapters contain functions + * - All expected adapter functions are accounted for + * - Invoking any of the default methods does not throw an error. + * Every foundation test suite include this verification. + * @param {!F.} FoundationClass + * @param {!Array} expectedMethodNames + * @template F + */ +export function verifyDefaultAdapter(FoundationClass, expectedMethodNames) { const {defaultAdapter} = FoundationClass; - const methods = Object.keys(defaultAdapter).filter((k) => typeof defaultAdapter[k] === 'function'); + const adapterKeys = Object.keys(defaultAdapter); + const actualMethodNames = adapterKeys.filter((key) => typeof defaultAdapter[key] === 'function'); + + assert.equal(actualMethodNames.length, adapterKeys.length, 'Every adapter key must be a function'); - assert.equal(methods.length, Object.keys(defaultAdapter).length, 'Every adapter key must be a function'); // Test for equality without requiring that the array be in a specific order - assert.deepEqual(methods.slice().sort(), expectedMethods.slice().sort()); + const actualArray = actualMethodNames.slice().sort(); + const expectedArray = expectedMethodNames.slice().sort(); + assert.deepEqual(actualArray, expectedArray, getUnequalArrayMessage(actualArray, expectedArray)); + // Test default methods - methods.forEach((m) => assert.doesNotThrow(defaultAdapter[m])); + actualMethodNames.forEach((m) => assert.doesNotThrow(defaultAdapter[m])); } -// Returns an object that intercepts calls to an adapter method used to register event handlers, and adds -// it to that object where the key is the event name and the value is the function being used. This is the -// preferred way of testing interaction handlers. -// -// ```javascript -// test('#init adds a click listener which adds a "foo" class', (t) => { -// const {foundation, mockAdapter} = setupTest(); -// const handlers = captureHandlers(mockAdapter, 'registerInteractionHandler'); -// foundation.init(); -// handlers.click(/* you can pass event info in here */ {type: 'click'}); -// t.doesNotThrow(() => td.verify(mockAdapter.addClass('foo'))); -// t.end(); -// }); -// ``` -// -// Note that `handlerCaptureMethod` _must_ have a signature of `(string, EventListener) => any` in order to -// be effective. -export function captureHandlers(adapter, handlerCaptureMethod) { +/** + * Returns an object that intercepts calls to an adapter method used to register event handlers, and adds + * it to that object where the key is the event name and the value is the function being used. This is the + * preferred way of testing interaction handlers. + * + * ```javascript + * test('#init adds a click listener which adds a "foo" class', (t) => { + * const {foundation, mockAdapter} = setupTest(); + * const handlers = captureHandlers(mockAdapter, 'registerInteractionHandler'); + * foundation.init(); + * handlers.click(/* you can pass event info in here *\/ {type: 'click'}); + * t.doesNotThrow(() => td.verify(mockAdapter.addClass('foo'))); + * t.end(); + * }); + * ``` + * + * Note that `handlerCaptureMethodName` _must_ have a signature of `(string, EventListener) => any` in order to + * be effective. + * + * @param {!A} adapter + * @param {string} handlerCaptureMethodName + * @template A + */ +export function captureHandlers(adapter, handlerCaptureMethodName) { const {isA} = td.matchers; const handlers = {}; - td.when(adapter[handlerCaptureMethod](isA(String), isA(Function))).thenDo((type, handler) => { + td.when(adapter[handlerCaptureMethodName](isA(String), isA(Function))).thenDo((type, handler) => { handlers[type] = (evtInfo = {}) => handler(Object.assign({type}, evtInfo)); }); return handlers; } + +/** + * @param {!Array} actualArray + * @param {!Array} expectedArray + * @return {string} + */ +function getUnequalArrayMessage(actualArray, expectedArray) { + /** + * @param {!Array} values + * @param {string} singularName + * @return {string} + */ + const format = (values, singularName) => { + const count = values.length; + if (count === 0) { + return ''; + } + const plural = count === 1 ? '' : 's'; + const str = values.join(', '); + return `${count} ${singularName}${plural}: ${str}`; + }; + + /** + * @param {!Set} actualSet + * @param {!Set} expectedSet + * @return {string} + */ + const getAddedStr = (actualSet, expectedSet) => { + const addedArray = []; + actualSet.forEach((val) => { + if (!expectedSet.has(val)) { + addedArray.push(val); + } + }); + return format(addedArray, 'unexpected method'); + }; + + /** + * @param {!Set} actualSet + * @param {!Set} expectedSet + * @return {string} + */ + const getRemovedStr = (actualSet, expectedSet) => { + const removedArray = []; + expectedSet.forEach((val) => { + if (!actualSet.has(val)) { + removedArray.push(val); + } + }); + return format(removedArray, 'missing method'); + }; + + /** + * @param {!Array} array + * @return {!Set} + */ + const toSet = (array) => { + const set = new Set(); + array.forEach((value) => set.add(value)); + return set; + }; + + const actualSet = toSet(actualArray); + const expectedSet = toSet(expectedArray); + const addedStr = getAddedStr(actualSet, expectedSet); + const removedStr = getRemovedStr(actualSet, expectedSet); + const messages = [addedStr, removedStr].filter((val) => val.length > 0); + + if (messages.length === 0) { + return ''; + } + + return `Found ${messages.join('; ')}`; +} diff --git a/test/unit/helpers/setup.js b/test/unit/helpers/setup.js index f3bd7d50a42..1be0eedb475 100644 --- a/test/unit/helpers/setup.js +++ b/test/unit/helpers/setup.js @@ -23,8 +23,15 @@ import td from 'testdouble'; -// Returns a foundation configured to use a mock object with the same api as a default adapter, -// as well as that adapter itself. +/** + * Returns a foundation configured to use a mock object with the same API as a default adapter, + * as well as that adapter itself. + * The trailing `.` in the `@param` type below is intentional: It indicates a reference to the class itself instead of + * an instance of the class. + * See https://youtrack.jetbrains.com/issue/WEB-10214#focus=streamItem-27-1305930-0-0 + * @param {!MDCFoundation.} FoundationClass + * @return {{mockAdapter: !Object, foundation: !MDCFoundation}} + */ export function setupFoundationTest(FoundationClass) { const mockAdapter = td.object(FoundationClass.defaultAdapter); const foundation = new FoundationClass(mockAdapter); diff --git a/test/unit/index.js b/test/unit/index.js index a3a4c8519ff..fbef0b91abe 100644 --- a/test/unit/index.js +++ b/test/unit/index.js @@ -23,5 +23,6 @@ /** @fileoverview Bootstraps the test bundle for karma-webpack. */ -const testsContext = require.context('.', true, /\.test\.js$/); +// https://github.com/webpack/docs/wiki/context#requirecontext +const testsContext = require.context(/* directory */ '.', /* useSubdirectories */ true, /\.test\.js$/); testsContext.keys().forEach(testsContext);