diff --git a/tests/deps.mocha.js b/tests/deps.mocha.js index 055a989db..3c46f7dfc 100644 --- a/tests/deps.mocha.js +++ b/tests/deps.mocha.js @@ -44,6 +44,9 @@ goog.addDependency('../../tests/mocha/registry_test.js', ['Blockly.test.registry goog.addDependency('../../tests/mocha/run_mocha_tests_in_browser.js', [], [], {'lang': 'es8'}); goog.addDependency('../../tests/mocha/serializer_test.js', ['Blockly.test.serialization'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../tests/mocha/shortcut_registry_test.js', ['Blockly.test.shortcutRegistry'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../tests/mocha/test_helpers/block_test_helpers.mocha.js', ['Blockly.test.blockHelpers'], ['Blockly.test.commonHelpers'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../tests/mocha/test_helpers/common_test_helpers.mocha.js', ['Blockly.test.commonHelpers'], [], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../tests/mocha/test_helpers/field_test_helpers.mocha.js', ['Blockly.test.fieldHelpers'], ['Blockly.test.commonHelpers'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../tests/mocha/test_helpers/procedures_test_helpers.js', ['Blockly.test.procedureHelpers'], ['Blockly.ConnectionType'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../tests/mocha/test_helpers/test_helpers.js', ['Blockly.test.helpers'], ['Blockly.Events.utils', 'Blockly.blocks', 'Blockly.utils.KeyCodes'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../tests/mocha/test_helpers/toolbox_helper.js', ['Blockly.test.toolboxHelpers'], [], {'lang': 'es6', 'module': 'goog'}); diff --git a/tests/mocha/test_helpers/block_test_helpers.mocha.js b/tests/mocha/test_helpers/block_test_helpers.mocha.js new file mode 100644 index 000000000..73a52c986 --- /dev/null +++ b/tests/mocha/test_helpers/block_test_helpers.mocha.js @@ -0,0 +1,240 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('Blockly.test.blockHelpers'); + +const {runTestCases, runTestSuites, TestCase, TestSuite} = goog.require('Blockly.test.commonHelpers'); + + +/** + * Code generation test case configuration. + * @implements {TestCase} + * @record + */ +class CodeGenerationTestCase { + /** + * Class for a code generation test case. + */ + constructor() { + /** + * @type {string} The expected code. + */ + this.expectedCode; + /** + * @type {boolean|undefined} Whether to use workspaceToCode instead of + * blockToCode for test. + */ + this.useWorkspaceToCode; + /** + * @type {number|undefined} The expected inner order. + */ + this.expectedInnerOrder; + } + + /** + * Creates the block to use for this test case. + * @param {!Blockly.Workspace} workspace The workspace context for this + * test. + * @return {!Blockly.Block} The block to use for the test case. + */ + createBlock(workspace) {} +} +exports.CodeGenerationTestCase = CodeGenerationTestCase; + +/** + * Code generation test suite. + * @extends {TestSuite} + * @record + */ +class CodeGenerationTestSuite { + /** + * Class for a code generation test suite. + */ + constructor() { + /** + * @type {!Blockly.Generator} The generator to use for running test cases. + */ + this.generator; + } +} +exports.CodeGenerationTestSuite = CodeGenerationTestSuite; + +/** + * Serialization test case. + * @implements {TestCase} + * @record + */ +class SerializationTestCase { + /** + * Class for a block serialization test case. + */ + constructor() { + /** + * @type {string} The block xml to use for test. Do not provide if json is + * provided. + */ + this.xml; + /** + * @type {string|undefined} The expected xml after round trip. Provided if + * it different from xml that was passed in. + */ + this.expectedXml; + /** + * @type {string} The block json to use for test. Do not provide if xml is + * provided. + */ + this.json; + /** + * @type {string|undefined} The expected json after round trip. Provided if + * it is different from json that was passed in. + */ + this.expectedJson; + } + /** + * Asserts that the block created from xml has the expected structure. + * @param {!Blockly.Block} block The block to check. + */ + assertBlockStructure(block) {} +} +exports.SerializationTestCase = SerializationTestCase; + +/** + * Returns mocha test callback for code generation based on provided + * generator. + * @param {!Blockly.Generator} generator The generator to use in test. + * @return {function(!CodeGenerationTestCase):!Function} Function that + * returns mocha test callback based on test case. + * @private + */ +const createCodeGenerationTestFn_ = (generator) => { + return (testCase) => { + return function() { + const block = testCase.createBlock(this.workspace); + let code; + let innerOrder; + if (testCase.useWorkspaceToCode) { + code = generator.workspaceToCode(this.workspace); + } else { + generator.init(this.workspace); + code = generator.blockToCode(block); + if (Array.isArray(code)) { + innerOrder = code[1]; + code = code[0]; + } + } + const assertFunc = (typeof testCase.expectedCode === 'string') ? + assert.equal : assert.match; + assertFunc(code, testCase.expectedCode); + if (!testCase.useWorkspaceToCode && + testCase.expectedInnerOrder !== undefined) { + assert.equal(innerOrder, testCase.expectedInnerOrder); + } + }; + }; +}; + +/** + * Runs blockToCode test suites. + * @param {!Array} testSuites The test suites to run. + */ +const runCodeGenerationTestSuites = (testSuites) => { + /** + * Creates function used to generate mocha test callback. + * @param {!CodeGenerationTestSuite} suiteInfo The test suite information. + * @return {function(!CodeGenerationTestCase):!Function} Function that + * creates mocha test callback. + */ + const createTestFn = (suiteInfo) => { + return createCodeGenerationTestFn_(suiteInfo.generator); + }; + + runTestSuites(testSuites, createTestFn); +}; +exports.runCodeGenerationTestSuites = runCodeGenerationTestSuites; + +/** + * Runs serialization test suite. + * @param {!Array} testCases The test cases to run. + */ +const runSerializationTestSuite = (testCases) => { + /** + * Creates test callback for xmlToBlock test. + * @param {!SerializationTestCase} testCase The test case information. + * @return {!Function} The test callback. + */ + const createSerializedDataToBlockTestCallback = (testCase) => { + return function() { + let block; + if (testCase.json) { + block = Blockly.serialization.blocks.append( + testCase.json, this.workspace); + } else { + block = Blockly.Xml.domToBlock(Blockly.Xml.textToDom( + testCase.xml), this.workspace); + } + testCase.assertBlockStructure(block); + }; + }; + /** + * Creates test callback for xml round trip test. + * @param {!SerializationTestCase} testCase The test case information. + * @return {!Function} The test callback. + */ + const createRoundTripTestCallback = (testCase) => { + return function() { + if (testCase.json) { + const block = Blockly.serialization.blocks.append( + testCase.json, this.workspace); + const generatedJson = Blockly.serialization.blocks.save(block); + const expectedJson = testCase.expectedJson || testCase.json; + assert.deepEqual(generatedJson, expectedJson); + } else { + const block = Blockly.Xml.domToBlock(Blockly.Xml.textToDom( + testCase.xml), this.workspace); + const generatedXml = + Blockly.Xml.domToPrettyText( + Blockly.Xml.blockToDom(block)); + const expectedXml = testCase.expectedXml || testCase.xml; + assert.equal(generatedXml, expectedXml); + } + }; + }; + suite('Serialization', function() { + suite('xmlToBlock', function() { + runTestCases(testCases, createSerializedDataToBlockTestCallback); + }); + suite('xml round-trip', function() { + setup(function() { + // The genUid is undergoing change as part of the 2021Q3 + // goog.module migration: + // + // - It is being moved from Blockly.utils to + // Blockly.utils.idGenerator (which itself is being renamed + // from IdGenerator). + // - For compatibility with changes to the module system (from + // goog.provide to goog.module and in future to ES modules), + // .genUid is now a wrapper around .TEST_ONLY.genUid, which + // can be safely stubbed by sinon or other similar + // frameworks in a way that will continue to work. + if (Blockly.utils.idGenerator && + Blockly.utils.idGenerator.TEST_ONLY) { + sinon.stub(Blockly.utils.idGenerator.TEST_ONLY, 'genUid') + .returns('1'); + } else { + // Fall back to stubbing original version on Blockly.utils. + sinon.stub(Blockly.utils, 'genUid').returns('1'); + } + }); + + teardown(function() { + sinon.restore(); + }); + + runTestCases(testCases, createRoundTripTestCallback); + }); + }); +}; +exports.runSerializationTestSuite = runSerializationTestSuite; diff --git a/tests/mocha/test_helpers/common_test_helpers.mocha.js b/tests/mocha/test_helpers/common_test_helpers.mocha.js new file mode 100644 index 000000000..08e7cd4c5 --- /dev/null +++ b/tests/mocha/test_helpers/common_test_helpers.mocha.js @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('Blockly.test.commonHelpers'); + +/** + * Test case configuration. + * @record + */ +class TestCase { + /** + * Class for a test case configuration. + */ + constructor() { + /** + * @type {string} The title for the test case. + */ + this.title; + /** + * @type {boolean|undefined} Whether this test case should be skipped. + * Used to skip buggy test case and should have an associated bug. + */ + this.skip; + /** + * @type {boolean|undefined} Whether this test case should be called as + * only. Used for debugging. + */ + this.only; + } +} +exports.TestCase = TestCase; + +/** + * Test suite configuration. + * @record + * @template {TestCase} T + * @template {TestSuite} U + */ +class TestSuite { + /** + * Class for a test suite configuration. + */ + constructor() { + /** + * @type {string} The title for the test case. + */ + this.title; + /** + * @type {?Array} The associated test cases. + */ + this.testCases; + /** + * @type {?Array} List of nested inner test suites. + */ + this.testSuites; + /** + * @type {boolean|undefined} Whether this test suite should be skipped. + * Used to skip buggy test case and should have an associated bug. + */ + this.skip; + /** + * @type {boolean|undefined} Whether this test suite should be called as + * only. Used for debugging. + */ + this.only; + } +} +exports.TestSuite = TestSuite; + +/** + * Runs provided test cases. + * @template {TestCase} T + * @param {!Array} testCases The test cases to run. + * @param {function(T):Function} createTestCallback Creates test + * callback using given test case. + */ +function runTestCases(testCases, createTestCallback) { + testCases.forEach((testCase) => { + let testCall = (testCase.skip ? test.skip : test); + testCall = (testCase.only ? test.only : testCall); + testCall(testCase.title, createTestCallback(testCase)); + }); +} +exports.runTestCases = runTestCases; + +/** + * Runs provided test suite. + * @template {TestCase} T + * @template {TestSuite} U + * @param {Array} testSuites The test suites to run. + * @param {function(!U):(function(T):!Function) + * } createTestCaseCallback Creates test case callback using given test + * suite. + */ +function runTestSuites(testSuites, createTestCaseCallback) { + testSuites.forEach((testSuite) => { + let suiteCall = (testSuite.skip ? suite.skip : suite); + suiteCall = (testSuite.only ? suite.only : suiteCall); + suiteCall(testSuite.title, function() { + if (testSuite.testSuites && testSuite.testSuites.length) { + runTestSuites(testSuite.testSuites, createTestCaseCallback); + } + if (testSuite.testCases && testSuite.testCases.length) { + runTestCases(testSuite.testCases, createTestCaseCallback(testSuite)); + } + }); + }); +} +exports.runTestSuites = runTestSuites; + +/** + * Captures the strings sent to console.warn() when calling a function. + * Copies from core. + * @param {Function} innerFunc The function where warnings may called. + * @return {Array} The warning messages (only the first arguments). + */ +function captureWarnings(innerFunc) { + const msgs = []; + const nativeConsoleWarn = console.warn; + try { + console.warn = function(msg) { + msgs.push(msg); + }; + innerFunc(); + } finally { + console.warn = nativeConsoleWarn; + } + return msgs; +} +exports.captureWarnings = captureWarnings; diff --git a/tests/mocha/test_helpers/field_test_helpers.mocha.js b/tests/mocha/test_helpers/field_test_helpers.mocha.js new file mode 100644 index 000000000..f99a30b8c --- /dev/null +++ b/tests/mocha/test_helpers/field_test_helpers.mocha.js @@ -0,0 +1,281 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('Blockly.test.fieldHelpers'); + +const {runTestCases, TestCase} = goog.require('Blockly.test.commonHelpers'); + + +/** + * Field value test case. + * @implements {TestCase} + * @record + */ +class FieldValueTestCase { + /** + * Class for a a field value test case. + */ + constructor() { + /** + * @type {*} The value to use in test. + */ + this.value; + /** + * @type {*} The expected value. + */ + this.expectedValue; + /** + * @type {string|undefined} The expected text value. Provided if different + * from String(expectedValue). + */ + this.expectedText; + /** + * @type {!RegExp|string|undefined} The optional error message matcher. + * Provided if test case is expected to throw. + */ + this.errMsgMatcher; + } +} +exports.FieldValueTestCase = FieldValueTestCase; + +/** + * Field creation test case. + * @extends {FieldValueTestCase} + * @record + */ +class FieldCreationTestCase { + /** + * Class for a field creation test case. + */ + constructor() { + /** + * @type {Array<*>} The arguments to pass to field constructor. + */ + this.args; + /** + * @type {string} The json to use in field creation. + */ + this.json; + } +} +exports.FieldCreationTestCase = FieldCreationTestCase; + +/** + * Assert a field's value is the same as the expected value. + * @param {!Blockly.Field} field The field. + * @param {*} expectedValue The expected value. + * @param {string=} expectedText The expected text. + */ +function assertFieldValue(field, expectedValue, expectedText = undefined) { + const actualValue = field.getValue(); + const actualText = field.getText(); + if (expectedText === undefined) { + expectedText = String(expectedValue); + } + assert.equal(actualValue, expectedValue, 'Value'); + assert.equal(actualText, expectedText, 'Text'); +} + +/** + * Runs provided creation test cases. + * @param {!Array} testCases The test cases to run. + * @param {function(!Blockly.Field, !FieldCreationTestCase)} assertion The + * assertion to use. + * @param {function(new:Blockly.Field,!FieldCreationTestCase):Blockly.Field + * } creation A function that returns an instance of the field based on the + * provided test case. + * @private + */ +function runCreationTests_(testCases, assertion, creation) { + /** + * Creates test callback for creation test. + * @param {FieldCreationTestCase} testCase The test case to use. + * @return {Function} The test callback. + */ + const createTestFn = (testCase) => { + return function() { + const field = creation.call(this, testCase); + assertion(field, testCase); + }; + }; + runTestCases(testCases, createTestFn); +} + +/** + * Runs provided creation test cases. + * @param {!Array} testCases The test cases to run. + * @param {function(new:Blockly.Field,!FieldCreationTestCase):Blockly.Field + * } creation A function that returns an instance of the field based on the + * provided test case. + * @private + */ +function runCreationTestsAssertThrows_(testCases, creation) { + /** + * Creates test callback for creation test. + * @param {!FieldCreationTestCase} testCase The test case to use. + * @return {!Function} The test callback. + */ + const createTestFn = (testCase) => { + return function() { + assert.throws(function() { + creation.call(this, testCase); + }, testCase.errMsgMatcher); + }; + }; + runTestCases(testCases, createTestFn); +} + +/** + * Runs suite of tests for constructor for the specified field. + * @param {function(new:Blockly.Field, *=)} TestedField The class of the field + * being tested. + * @param {Array} validValueTestCases Test cases with + * valid values for given field. + * @param {Array} invalidValueTestCases Test cases with + * invalid values for given field. + * @param {function(!Blockly.Field, !FieldCreationTestCase) + * } validRunAssertField Asserts that field has expected values. + * @param {function(!Blockly.Field)=} assertFieldDefault Asserts that field has + * default values. If undefined, tests will check that field throws when + * invalid value is passed rather than asserting default. + * @param {function(!FieldCreationTestCase=)=} customCreateWithJs Custom + * creation function to use in tests. + */ +function runConstructorSuiteTests(TestedField, validValueTestCases, + invalidValueTestCases, validRunAssertField, assertFieldDefault, + customCreateWithJs) { + suite('Constructor', function() { + if (assertFieldDefault) { + test('Empty', function() { + const field = customCreateWithJs ? customCreateWithJs.call(this) : + new TestedField(); + assertFieldDefault(field); + }); + } else { + test('Empty', function() { + assert.throws(function() { + customCreateWithJs ? customCreateWithJs.call(this) : + new TestedField(); + }); + }); + } + + /** + * Creates a field using its constructor and the provided test case. + * @param {!FieldCreationTestCase} testCase The test case information. + * @return {!Blockly.Field} The instantiated field. + */ + const createWithJs = function(testCase) { + return customCreateWithJs ? customCreateWithJs.call(this, testCase) : + new TestedField(...testCase.args); + }; + if (assertFieldDefault) { + runCreationTests_( + invalidValueTestCases, assertFieldDefault, createWithJs); + } else { + runCreationTestsAssertThrows_(invalidValueTestCases, createWithJs); + } + runCreationTests_(validValueTestCases, validRunAssertField, createWithJs); + }); +} +exports.runConstructorSuiteTests = runConstructorSuiteTests; + +/** + * Runs suite of tests for fromJson creation of specified field. + * @param {function(new:Blockly.Field, *=)} TestedField The class of the field + * being tested. + * @param {!Array} validValueTestCases Test cases with + * valid values for given field. + * @param {!Array} invalidValueTestCases Test cases with + * invalid values for given field. + * @param {function(!Blockly.Field, !FieldValueTestCase) + * } validRunAssertField Asserts that field has expected values. + * @param {function(!Blockly.Field)=} assertFieldDefault Asserts that field has + * default values. If undefined, tests will check that field throws when + * invalid value is passed rather than asserting default. + * @param {function(!FieldCreationTestCase=)=} customCreateWithJson Custom + * creation function to use in tests. + */ +function runFromJsonSuiteTests(TestedField, validValueTestCases, + invalidValueTestCases, validRunAssertField, assertFieldDefault, + customCreateWithJson) { + suite('fromJson', function() { + if (assertFieldDefault) { + test('Empty', function() { + const field = customCreateWithJson ? customCreateWithJson.call(this) : + TestedField.fromJson({}); + assertFieldDefault(field); + }); + } else { + test('Empty', function() { + assert.throws(function() { + customCreateWithJson ? customCreateWithJson.call(this) : + TestedField.fromJson({}); + }); + }); + } + + /** + * Creates a field using fromJson and the provided test case. + * @param {!FieldCreationTestCase} testCase The test case information. + * @return {!Blockly.Field} The instantiated field. + */ + const createWithJson = function(testCase) { + return customCreateWithJson ? customCreateWithJson.call(this, testCase) : + TestedField.fromJson(testCase.json); + }; + if (assertFieldDefault) { + runCreationTests_( + invalidValueTestCases, assertFieldDefault, createWithJson); + } else { + runCreationTestsAssertThrows_(invalidValueTestCases, createWithJson); + } + runCreationTests_(validValueTestCases, validRunAssertField, createWithJson); + }); +} +exports.runFromJsonSuiteTests = runFromJsonSuiteTests; + +/** + * Runs tests for setValue calls. + * @param {!Array} validValueTestCases Test cases with + * valid values. + * @param {!Array} invalidValueTestCases Test cases with + * invalid values. + * @param {*} invalidRunExpectedValue Expected value for field after invalid + * call to setValue. + * @param {string=} invalidRunExpectedText Expected text for field after invalid + * call to setValue. + */ +function runSetValueTests(validValueTestCases, invalidValueTestCases, + invalidRunExpectedValue, invalidRunExpectedText) { + /** + * Creates test callback for invalid setValue test. + * @param {!FieldValueTestCase} testCase The test case information. + * @return {!Function} The test callback. + */ + const createInvalidSetValueTestCallback = (testCase) => { + return function() { + this.field.setValue(testCase.value); + assertFieldValue( + this.field, invalidRunExpectedValue, invalidRunExpectedText); + }; + }; + /** + * Creates test callback for valid setValue test. + * @param {!FieldValueTestCase} testCase The test case information. + * @return {!Function} The test callback. + */ + const createValidSetValueTestCallback = (testCase) => { + return function() { + this.field.setValue(testCase.value); + assertFieldValue( + this.field, testCase.expectedValue, testCase.expectedText); + }; + }; + runTestCases(invalidValueTestCases, createInvalidSetValueTestCallback); + runTestCases(validValueTestCases, createValidSetValueTestCallback); +} +exports.runSetValueTests = runSetValueTests;