From 75f118b4f9b3a4ccce4a55cc9d2305399f966d7a Mon Sep 17 00:00:00 2001 From: Beka Westberg Date: Fri, 22 Jan 2021 12:44:50 -0800 Subject: [PATCH] Refactor interpolate_ into multiple functions & remove direct new Field call (#4585) * Refactor interpolation * Added docs * Add tests * Add test for dummy input after field not prefixed with field_ * Fix typings * Pr comments --- core/block.js | 266 ++++++++++----- package-lock.json | 7 +- tests/mocha/block_json_test.js | 582 +++++++++++++++++++++++++++++++++ tests/mocha/index.html | 1 + tests/mocha/utils_test.js | 190 ++++++++--- 5 files changed, 905 insertions(+), 141 deletions(-) create mode 100644 tests/mocha/block_json_test.js diff --git a/core/block.js b/core/block.js index 9b6d0031a..162d87368 100644 --- a/core/block.js +++ b/core/block.js @@ -1629,111 +1629,201 @@ Blockly.Block.prototype.mixin = function(mixinObj, opt_disableCheck) { Blockly.Block.prototype.interpolate_ = function(message, args, lastDummyAlign, warningPrefix) { var tokens = Blockly.utils.tokenizeInterpolation(message); - // Interpolate the arguments. Build a list of elements. - var indexDup = []; - var indexCount = 0; - var elements = []; + this.validateTokens_(tokens, args.length); + var elements = this.interpolateArguments_(tokens, args, lastDummyAlign); + + // An array of [field, fieldName] tuples. + var fieldStack = []; + for (var i = 0, element; (element = elements[i]); i++) { + switch (element['type']) { + case 'input_value': + case 'input_statement': + case 'input_dummy': + var input = this.inputFromJson_(element, warningPrefix); + // Should never be null, but just in case. + if (input) { + for (var j = 0, tuple; (tuple = fieldStack[j]); j++) { + input.appendField(tuple[0], tuple[1]); + } + fieldStack.length = 0; + } + break; + // All other types, including ones starting with 'input_' get routed here. + default: + var field = this.fieldFromJson_(element); + if (field) { + fieldStack.push([field, element['name']]); + } + } + } +}; + +/** + * Validates that the tokens are within the correct bounds, with no duplicates, + * and that all of the arguments are referred to. Throws errors if any of these + * things are not true. + * @param {!Array} tokens An array of tokens to validate + * @param {number} argsCount The number of args that need to be referred to. + * @private + */ +Blockly.Block.prototype.validateTokens_ = function(tokens, argsCount) { + var visitedArgsHash = []; + var visitedArgsCount = 0; for (var i = 0; i < tokens.length; i++) { var token = tokens[i]; - if (typeof token == 'number') { - if (token <= 0 || token > args.length) { - throw Error('Block "' + this.type + '": ' + - 'Message index %' + token + ' out of range.'); - } - if (indexDup[token]) { - throw Error('Block "' + this.type + '": ' + - 'Message index %' + token + ' duplicated.'); - } - indexDup[token] = true; - indexCount++; - elements.push(args[token - 1]); - } else { - token = token.trim(); - if (token) { - elements.push(token); - } + if (typeof token != 'number') { + continue; } + if (token < 1 || token > argsCount) { + throw Error('Block "' + this.type + '": ' + + 'Message index %' + token + ' out of range.'); + } + if (visitedArgsHash[token]) { + throw Error('Block "' + this.type + '": ' + + 'Message index %' + token + ' duplicated.'); + } + visitedArgsHash[token] = true; + visitedArgsCount++; } - if (indexCount != args.length) { + if (visitedArgsCount != argsCount) { throw Error('Block "' + this.type + '": ' + - 'Message does not reference all ' + args.length + ' arg(s).'); + 'Message does not reference all ' + argsCount + ' arg(s).'); } - // Add last dummy input if needed. - if (elements.length && (typeof elements[elements.length - 1] == 'string' || - Blockly.utils.string.startsWith( - elements[elements.length - 1]['type'], 'field_'))) { - var dummyInput = {type: 'input_dummy'}; - if (lastDummyAlign) { - dummyInput['align'] = lastDummyAlign; +}; + +/** + * Inserts args in place of numerical tokens. String args are converted to json + * that defines a label field. If necessary an extra dummy input is added to + * the end of the elements. + * @param {!Array} tokens The tokens to interpolate + * @param {!Array} args The arguments to insert. + * @param {string|undefined} lastDummyAlign The alignment the added dummy input + * should have, if we are required to add one. + * @return {!Array} The JSON definitions of field and inputs to add + * to the block. + * @private + */ +Blockly.Block.prototype.interpolateArguments_ = + function(tokens, args, lastDummyAlign) { + var elements = []; + for (var i = 0; i < tokens.length; i++) { + var element = tokens[i]; + if (typeof element == 'number') { + element = args[element - 1]; + } + // Args can be strings, which is why this isn't elseif. + if (typeof element == 'string') { + element = this.stringToFieldJson_(element); + if (!element) { + continue; + } + } + elements.push(element); + } + + var length = elements.length; + var startsWith = Blockly.utils.string.startsWith; + // TODO: This matches the old behavior, but it doesn't work for fields + // that don't start with 'field_'. + if (length && startsWith(elements[length - 1]['type'], 'field_')) { + var dummyInput = {'type': 'input_dummy'}; + if (lastDummyAlign) { + dummyInput['align'] = lastDummyAlign; + } + elements.push(dummyInput); + } + + return elements; + }; + +/** + * Creates a field from the json definition of a field. If a field with the + * given type cannot be found, this attempts to create a different field using + * the 'alt' property of the json definition (if it exists). + * @param {{alt:(string|undefined)}} element The element to try to turn into a + * field. + * @return {?Blockly.Field} The field defined by the JSON, or null if one + * couldn't be created. + * @private + */ +Blockly.Block.prototype.fieldFromJson_ = function(element) { + var field = Blockly.fieldRegistry.fromJson(element); + if (!field && element['alt']) { + if (typeof element['alt'] == 'string') { + var json = this.stringToFieldJson_(element['alt']); + return json ? this.fieldFromJson_(json) : null; } - elements.push(dummyInput); + return this.fieldFromJson_(element['alt']); } - // Lookup of alignment constants. + return field; +}; + +/** + * Creates an input from the json definition of an input. Sets the input's check + * and alignment if they are provided. + * @param {!Object} element The JSON to turn into an input. + * @param {string} warningPrefix The prefix to add to warnings to help the + * developer debug. + * @return {?Blockly.Input} The input that has been created, or null if one + * could not be created for some reason (should never happen). + * @private + */ +Blockly.Block.prototype.inputFromJson_ = function(element, warningPrefix) { var alignmentLookup = { 'LEFT': Blockly.ALIGN_LEFT, 'RIGHT': Blockly.ALIGN_RIGHT, 'CENTRE': Blockly.ALIGN_CENTRE, 'CENTER': Blockly.ALIGN_CENTRE }; - // Populate block with inputs and fields. - var fieldStack = []; - for (var i = 0; i < elements.length; i++) { - var element = elements[i]; - if (typeof element == 'string') { - fieldStack.push([element, undefined]); - } else { - var field = null; - var input = null; - do { - var altRepeat = false; - if (typeof element == 'string') { - field = new Blockly.FieldLabel(element); - } else { - switch (element['type']) { - case 'input_value': - input = this.appendValueInput(element['name']); - break; - case 'input_statement': - input = this.appendStatementInput(element['name']); - break; - case 'input_dummy': - input = this.appendDummyInput(element['name']); - break; - default: - // This should handle all field JSON parsing, including - // options that can be applied to any field type. - field = Blockly.fieldRegistry.fromJson(element); - // Unknown field. - if (!field && element['alt']) { - element = element['alt']; - altRepeat = true; - } - } - } - } while (altRepeat); - if (field) { - fieldStack.push([field, element['name']]); - } else if (input) { - if (element['check']) { - input.setCheck(element['check']); - } - if (element['align']) { - var alignment = alignmentLookup[element['align'].toUpperCase()]; - if (alignment === undefined) { - console.warn(warningPrefix + 'Illegal align value: ', - element['align']); - } else { - input.setAlign(alignment); - } - } - for (var j = 0; j < fieldStack.length; j++) { - input.appendField(fieldStack[j][0], fieldStack[j][1]); - } - fieldStack.length = 0; - } + var input = null; + switch (element['type']) { + case 'input_value': + input = this.appendValueInput(element['name']); + break; + case 'input_statement': + input = this.appendStatementInput(element['name']); + break; + case 'input_dummy': + input = this.appendDummyInput(element['name']); + break; + } + // Should never be hit because of interpolate_'s checks, but just in case. + if (!input) { + return null; + } + + if (element['check']) { + input.setCheck(element['check']); + } + if (element['align']) { + var alignment = alignmentLookup[element['align'].toUpperCase()]; + if (alignment === undefined) { + console.warn(warningPrefix + 'Illegal align value: ', + element['align']); + } else { + input.setAlign(alignment); } } + return input; +}; + +/** + * Turns a string into the JSON definition of a label field. If the string + * becomes an empty string when trimmed, this returns null. + * @param {string} str String to turn into the JSON definition of a label field. + * @return {?{text: string, type: string}} The JSON definition or null. + * @private + */ +Blockly.Block.prototype.stringToFieldJson_ = function(str) { + str = str.trim(); + if (str) { + return { + 'type': 'field_label', + 'text': str, + }; + } + return null; }; /** diff --git a/package-lock.json b/package-lock.json index a969fa8fe..a808641c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8173,7 +8173,7 @@ }, "through": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "dev": true }, @@ -8397,6 +8397,11 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", "dev": true }, + "typescript": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", + "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==" + }, "typescript-closure-tools": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/typescript-closure-tools/-/typescript-closure-tools-0.0.7.tgz", diff --git a/tests/mocha/block_json_test.js b/tests/mocha/block_json_test.js new file mode 100644 index 000000000..ed5301116 --- /dev/null +++ b/tests/mocha/block_json_test.js @@ -0,0 +1,582 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +suite('Block JSON initialization', function() { + suite('validateTokens_', function() { + setup(function() { + this.assertError = function(tokens, count, error) { + var block = { + type: 'test', + validateTokens_: Blockly.Block.prototype.validateTokens_, + }; + chai.assert.throws(function() { + block.validateTokens_(tokens, count); + }, error); + }; + + this.assertNoError = function(tokens, count) { + var block = { + type: 'test', + validateTokens_: Blockly.Block.prototype.validateTokens_, + }; + chai.assert.doesNotThrow(function() { + block.validateTokens_(tokens, count); + }); + }; + }); + + test('0 args, 0 tokens', function() { + this.assertNoError(['test', 'test'], 0); + }); + + test('0 args, 1 token', function() { + this.assertError(['test', 1, 'test'], 0, + 'Block "test": Message index %1 out of range.'); + }); + + test('1 arg, 0 tokens', function() { + this.assertError(['test', 'test'], 1, + 'Block "test": Message does not reference all 1 arg(s).'); + }); + + test('1 arg, 1 token', function() { + this.assertNoError(['test', 1, 'test'], 1); + }); + + test('1 arg, 2 tokens', function() { + this.assertError(['test', 1, 1, 'test'], 1, + 'Block "test": Message index %1 duplicated.'); + }); + + test('Token out of lower bound', function() { + this.assertError(['test', 0, 'test'], 1, + 'Block "test": Message index %0 out of range.'); + }); + + test('Token out of upper bound', function() { + this.assertError(['test', 2, 'test'], 1, + 'Block "test": Message index %2 out of range.'); + }); + }); + + suite('interpolateArguments_', function() { + setup(function() { + this.assertInterpolation = function(tokens, args, lastAlign, elements) { + var block = { + type: 'test', + interpolateArguments_: Blockly.Block.prototype.interpolateArguments_, + stringToFieldJson_: Blockly.Block.prototype.stringToFieldJson_, + }; + chai.assert.deepEqual( + block.interpolateArguments_(tokens, args, lastAlign), + elements); + }; + }); + + test('Strings to labels', function() { + this.assertInterpolation( + ['test1', 'test2', 'test3', { 'type': 'input_dummy'}], + [], + undefined, + [ + { + 'type': 'field_label', + 'text': 'test1', + }, + { + 'type': 'field_label', + 'text': 'test2', + }, + { + 'type': 'field_label', + 'text': 'test3', + }, + { + 'type': 'input_dummy', + } + ]); + }); + + test('Ignore empty strings', function() { + this.assertInterpolation( + ['test1', '', ' ', { 'type': 'input_dummy'}], + [], + undefined, + [ + { + 'type': 'field_label', + 'text': 'test1', + }, + { + 'type': 'input_dummy', + } + ]); + }); + + test('Insert args', function() { + this.assertInterpolation( + [1, 2, 3, { 'type': 'input_dummy'}], + [ + { + 'type': 'field_number', + 'name': 'test1', + }, + { + 'type': 'field_number', + 'name': 'test2', + }, + { + 'type': 'field_number', + 'name': 'test3', + }, + ], + undefined, + [ + { + 'type': 'field_number', + 'name': 'test1', + }, + { + 'type': 'field_number', + 'name': 'test2', + }, + { + 'type': 'field_number', + 'name': 'test3', + }, + { + 'type': 'input_dummy', + } + ]); + }); + + test('String args to labels', function() { + this.assertInterpolation( + [1, 2, 3, { 'type': 'input_dummy'}], + ['test1', 'test2', 'test3'], + undefined, + [ + { + 'type': 'field_label', + 'text': 'test1', + }, + { + 'type': 'field_label', + 'text': 'test2', + }, + { + 'type': 'field_label', + 'text': 'test3', + }, + { + 'type': 'input_dummy', + } + ]); + }); + + test('Ignore empty string args', function() { + this.assertInterpolation( + [1, 2, 3, { 'type': 'input_dummy'}], + ['test1', ' ', ' '], + undefined, + [ + { + 'type': 'field_label', + 'text': 'test1', + }, + { + 'type': 'input_dummy', + } + ]); + }); + + test('Add last dummy', function() { + this.assertInterpolation( + ['test1', 'test2', 'test3'], + [], + undefined, + [ + { + 'type': 'field_label', + 'text': 'test1', + }, + { + 'type': 'field_label', + 'text': 'test2', + }, + { + 'type': 'field_label', + 'text': 'test3', + }, + { + 'type': 'input_dummy', + } + ]); + }); + + test.skip('Add last dummy for no_field_prefix_field', function() { + this.assertInterpolation( + [ + { + 'type': 'no_field_prefix_field', + } + ], + [], + undefined, + [ + { + 'type': 'no_field_prefix_field', + }, + { + 'type': 'input_dummy', + } + ]); + }); + + test('Set last dummy alignment', function() { + this.assertInterpolation( + ['test1', 'test2', 'test3'], + [], + 'CENTER', + [ + { + 'type': 'field_label', + 'text': 'test1', + }, + { + 'type': 'field_label', + 'text': 'test2', + }, + { + 'type': 'field_label', + 'text': 'test3', + }, + { + 'type': 'input_dummy', + 'align': 'CENTER', + } + ]); + }); + }); + + suite('fieldFromJson_', function() { + setup(function() { + this.stub = sinon.stub(Blockly.fieldRegistry, 'fromJson') + .callsFake(function(elem) { + switch (elem['type']) { + case 'field_label': + return 'field_label'; + case 'field_number': + return 'field_number'; + case 'no_field_prefix_field': + return 'no_field_prefix_field'; + case 'input_prefix_field': + return 'input_prefix_field'; + default: + return null; + } + }); + + this.assertField = function(json, expectedType) { + var block = { + type: 'test', + fieldFromJson_: Blockly.Block.prototype.fieldFromJson_, + stringToFieldJson_: Blockly.Block.prototype.stringToFieldJson_, + }; + chai.assert.strictEqual(block.fieldFromJson_(json), expectedType); + }; + }); + + teardown(function() { + this.stub.restore(); + }); + + test('Simple field', function() { + this.assertField({ + 'type': 'field_label', + 'text': 'text', + }, 'field_label'); + }); + + test('Bad field', function() { + this.assertField({ + 'type': 'field_bad', + }, null); + }); + + test('no_field_prefix_field', function() { + this.assertField({ + 'type': 'no_field_prefix_field', + }, 'no_field_prefix_field'); + }); + + test('input_prefix_field', function() { + this.assertField({ + 'type': 'input_prefix_field', + }, 'input_prefix_field'); + }); + + test('Alt string', function() { + this.assertField({ + 'type': 'field_undefined', + 'alt': 'alt text', + }, 'field_label'); + }); + + test('input_prefix_bad w/ alt string', function() { + this.assertField({ + 'type': 'input_prefix_bad', + 'alt': 'alt string', + }, 'field_label'); + }); + + test('Alt other field', function() { + this.assertField({ + 'type': 'field_undefined', + 'alt': { + 'type': 'field_number', + 'name': 'FIELDNAME' + }, + }, 'field_number'); + }); + + test('Deep alt string', function() { + this.assertField({ + 'type': 'field_undefined1', + 'alt': { + 'type': 'field_undefined2', + 'alt': { + 'type': 'field_undefined3', + 'alt': { + 'type': 'field_undefined4', + 'alt': { + 'type': 'field_undefined5', + 'alt': 'text', + }, + }, + }, + }, + }, 'field_label'); + }); + + test('Deep alt other field', function() { + this.assertField({ + 'type': 'field_undefined1', + 'alt': { + 'type': 'field_undefined2', + 'alt': { + 'type': 'field_undefined3', + 'alt': { + 'type': 'field_undefined4', + 'alt': { + 'type': 'field_undefined5', + 'alt': { + 'type': 'field_number', + 'name': 'FIELDNAME' + }, + }, + }, + }, + }, + }, 'field_number'); + }); + + test('No alt', function() { + this.assertField({ + 'type': 'field_undefined' + }, null); + }); + + test('Bad alt', function() { + this.assertField({ + 'type': 'field_undefined', + 'alt': { + 'type': 'field_undefined', + } + }, null); + }); + + test('Spaces string alt', function() { + this.assertField({ + 'type': 'field_undefined', + 'alt': ' ', + }, null); + }); + }); + + suite('inputFromJson_', function() { + setup(function() { + var Input = function(type) { + this.type = type; + this.setCheck = sinon.fake(); + this.setAlign = sinon.fake(); + }; + var Block = function() { + this.type = 'test'; + this.appendDummyInput = sinon.fake.returns(new Input()); + this.appendValueInput = sinon.fake.returns(new Input()); + this.appendStatementInput = sinon.fake.returns(new Input()); + this.inputFromJson_ = Blockly.Block.prototype.inputFromJson_; + }; + + this.assertInput = function(json, type, check, align) { + var block = new Block(); + var input = block.inputFromJson_(json); + switch (type) { + case 'input_dummy': + chai.assert.isTrue(block.appendDummyInput.calledOnce, + 'Expected a dummy input to be created.'); + break; + case 'input_value': + chai.assert.isTrue(block.appendValueInput.calledOnce, + 'Expected a value input to be created.'); + break; + case 'input_statement': + chai.assert.isTrue(block.appendStatementInput.calledOnce, + 'Expected a statement input to be created.'); + break; + default: + chai.assert.isNull(input, 'Expected input to be null'); + chai.assert.isTrue(block.appendDummyInput.notCalled, + 'Expected no input to be created'); + chai.assert.isTrue(block.appendValueInput.notCalled, + 'Expected no input to be created'); + chai.assert.isTrue(block.appendStatementInput.notCalled, + 'Expected no input to be created'); + return; + } + if (check) { + chai.assert.isTrue(input.setCheck.calledWith(check), + 'Expected setCheck to be called with', check); + } else { + chai.assert.isTrue(input.setCheck.notCalled, + 'Expected setCheck to not be called'); + } + if (align !== undefined) { + chai.assert.isTrue(input.setAlign.calledWith(align), + 'Expected setAlign to be called with', align); + } else { + chai.assert.isTrue(input.setAlign.notCalled, + 'Expected setAlign to not be called'); + } + }; + }); + + test('Dummy', function() { + this.assertInput( + { + 'type': 'input_dummy', + }, + 'input_dummy'); + }); + + test('Value', function() { + this.assertInput( + { + 'type': 'input_value', + }, + 'input_value'); + }); + + test('Statement', function() { + this.assertInput( + { + 'type': 'input_statement', + }, + 'input_statement'); + }); + + test('Bad input type', function() { + this.assertInput( + { + 'type': 'input_bad', + }, + 'input_bad'); + }); + + test('String Check', function() { + this.assertInput( + { + 'type': 'input_dummy', + 'check': 'Integer' + }, + 'input_dummy', + 'Integer'); + }); + + test('Array check', function() { + this.assertInput( + { + 'type': 'input_dummy', + 'check': ['Integer', 'Number'] + }, + 'input_dummy', + ['Integer', 'Number']); + }); + + test('Empty check', function() { + this.assertInput( + { + 'type': 'input_dummy', + 'check': '' + }, + 'input_dummy'); + }); + + test('Null check', function() { + this.assertInput( + { + 'type': 'input_dummy', + 'check': null, + }, + 'input_dummy'); + }); + + test('"Left" align', function() { + this.assertInput( + { + 'type': 'input_dummy', + 'align': 'LEFT', + }, + 'input_dummy', + undefined, + Blockly.ALIGN_LEFT); + }); + + test('"Right" align', function() { + this.assertInput( + { + 'type': 'input_dummy', + 'align': 'RIGHT', + }, + 'input_dummy', + undefined, + Blockly.ALIGN_RIGHT); + }); + + test('"Center" align', function() { + this.assertInput( + { + 'type': 'input_dummy', + 'align': 'CENTER', + }, + 'input_dummy', + undefined, + Blockly.ALIGN_CENTRE); + }); + + test('"Centre" align', function() { + this.assertInput( + { + 'type': 'input_dummy', + 'align': 'CENTRE', + }, + 'input_dummy', + undefined, + Blockly.ALIGN_CENTRE); + }); + }); +}); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index 8bd37aab2..71b7de6b4 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -52,6 +52,7 @@ + diff --git a/tests/mocha/utils_test.js b/tests/mocha/utils_test.js index 11c6fdb2d..e176eac96 100644 --- a/tests/mocha/utils_test.js +++ b/tests/mocha/utils_test.js @@ -23,73 +23,159 @@ suite('Utils', function() { }); suite('tokenizeInterpolation', function() { - test('Basic', function() { - var tokens = Blockly.utils.tokenizeInterpolation(''); - chai.assert.deepEqual(tokens, [], 'Null interpolation'); + suite('Basic', function() { + test('Empty string', function() { + chai.assert.deepEqual(Blockly.utils.tokenizeInterpolation(''), []); + }); - tokens = Blockly.utils.tokenizeInterpolation('Hello'); - chai.assert.deepEqual(tokens, ['Hello'], 'No interpolation'); + test('No interpolation', function() { + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('Hello'), ['Hello']); + }); - tokens = Blockly.utils.tokenizeInterpolation('Hello%World'); - chai.assert.deepEqual(tokens, ['Hello%World'], 'Unescaped %.'); + test('Unescaped %', function() { + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('Hello%World'), + ['Hello%World']); + }); - tokens = Blockly.utils.tokenizeInterpolation('Hello%%World'); - chai.assert.deepEqual(tokens, ['Hello%World'], 'Escaped %.'); - - tokens = Blockly.utils.tokenizeInterpolation('Hello %1 World'); - chai.assert.deepEqual(tokens, ['Hello ', 1, ' World'], 'Interpolation.'); - - tokens = Blockly.utils.tokenizeInterpolation('%123Hello%456World%789'); - chai.assert.deepEqual(tokens, [123, 'Hello', 456, 'World', 789], 'Interpolations.'); - - tokens = Blockly.utils.tokenizeInterpolation('%%%x%%0%00%01%'); - chai.assert.deepEqual(tokens, ['%%x%0', 0, 1, '%'], 'Torture interpolations.'); + test('Escaped %', function() { + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('Hello%%World'), + ['Hello%World']); + }); }); - test('String table', function() { - Blockly.Msg = Blockly.Msg || {}; - Blockly.Msg.STRING_REF = 'test string'; - var tokens = Blockly.utils.tokenizeInterpolation('%{bky_string_ref}'); - chai.assert.deepEqual(tokens, ['test string'], 'String table reference, lowercase'); - tokens = Blockly.utils.tokenizeInterpolation('%{BKY_STRING_REF}'); - chai.assert.deepEqual(tokens, ['test string'], 'String table reference, uppercase'); + suite('Number interpolation', function() { + test('Single-digit number interpolation', function() { + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('Hello%1World'), + ['Hello', 1, 'World']); + }); - Blockly.Msg.WITH_PARAM = 'before %1 after'; - tokens = Blockly.utils.tokenizeInterpolation('%{bky_with_param}'); - chai.assert.deepEqual(tokens, ['before ', 1, ' after'], 'String table reference, with parameter'); + test('Multi-digit number interpolation', function() { + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('%123Hello%456World%789'), + [123, 'Hello', 456, 'World', 789]); + }); - Blockly.Msg.RECURSE = 'before %{bky_string_ref} after'; - tokens = Blockly.utils.tokenizeInterpolation('%{bky_recurse}'); - chai.assert.deepEqual(tokens, ['before test string after'], 'String table reference, with subreference'); + test('Escaped number', function() { + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('Hello %%1 World'), + ['Hello %1 World']); + }); + + test('Crazy interpolation', function() { + // No idea what this is supposed to tell you if it breaks. But might + // as well keep it. + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('%%%x%%0%00%01%'), + ['%%x%0', 0, 1, '%']); + }); }); - test('Error cases', function() { - var tokens = Blockly.utils.tokenizeInterpolation('%{bky_undefined}'); - chai.assert.deepEqual(tokens, ['%{bky_undefined}'], 'Undefined string table reference'); + suite('String table interpolation', function() { + setup(function() { + Blockly.Msg = Blockly.Msg || { }; + }); - Blockly.Msg['1'] = 'Will not match'; - tokens = Blockly.utils.tokenizeInterpolation('before %{1} after'); - chai.assert.deepEqual(tokens, ['before %{1} after'], 'Invalid initial digit in string table reference'); + test('Simple interpolation', function() { + Blockly.Msg.STRING_REF = 'test string'; + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('%{bky_string_ref}'), + ['test string']); + }); - Blockly.Msg['TWO WORDS'] = 'Will not match'; - tokens = Blockly.utils.tokenizeInterpolation('before %{two words} after'); - chai.assert.deepEqual(tokens, ['before %{two words} after'], 'Invalid character in string table reference: space'); + test('Case', function() { + Blockly.Msg.STRING_REF = 'test string'; + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('%{BkY_StRiNg_ReF}'), + ['test string']); + }); - Blockly.Msg['TWO-WORDS'] = 'Will not match'; - tokens = Blockly.utils.tokenizeInterpolation('before %{two-words} after'); - chai.assert.deepEqual(tokens, ['before %{two-words} after'], 'Invalid character in string table reference: dash'); + test('Surrounding text', function() { + Blockly.Msg.STRING_REF = 'test string'; + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation( + 'before %{bky_string_ref} after'), + ['before test string after']); + }); - Blockly.Msg['TWO.WORDS'] = 'Will not match'; - tokens = Blockly.utils.tokenizeInterpolation('before %{two.words} after'); - chai.assert.deepEqual(tokens, ['before %{two.words} after'], 'Invalid character in string table reference: period'); + test('With param', function() { + Blockly.Msg.WITH_PARAM = 'before %1 after'; + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('%{bky_with_param}'), + ['before ', 1, ' after']); + }); - Blockly.Msg['AB&C'] = 'Will not match'; - tokens = Blockly.utils.tokenizeInterpolation('before %{ab&c} after'); - chai.assert.deepEqual(tokens, ['before %{ab&c} after'], 'Invalid character in string table reference: &'); + test('Recursive reference', function() { + Blockly.Msg.STRING_REF = 'test string'; + Blockly.Msg.RECURSE = 'before %{bky_string_ref} after'; + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('%{bky_recurse}'), + ['before test string after']); + }); - Blockly.Msg['UNCLOSED'] = 'Will not match'; - tokens = Blockly.utils.tokenizeInterpolation('before %{unclosed'); - chai.assert.deepEqual(tokens, ['before %{unclosed'], 'String table reference, with parameter'); + test('Number reference', function() { + Blockly.Msg['1'] = 'test string'; + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('%{bky_1}'), ['test string']); + }); + + test('Undefined reference', function() { + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('%{bky_undefined}'), + ['%{bky_undefined}']); + }); + + test('Not prefixed', function() { + Blockly.Msg.STRING_REF = 'test string'; + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('%{string_ref}'), + ['%{string_ref}']); + }); + + test('Not prefixed, number', function() { + Blockly.Msg['1'] = 'test string'; + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('%{1}'), + ['%{1}']); + }); + + test('Space in ref', function() { + Blockly.Msg['string ref'] = 'test string'; + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('%{bky_string ref}'), + ['%{bky_string ref}']); + }); + + test('Dash in ref', function() { + Blockly.Msg['string-ref'] = 'test string'; + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('%{bky_string-ref}'), + ['%{bky_string-ref}']); + }); + + test('Period in ref', function() { + Blockly.Msg['string.ref'] = 'test string'; + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('%{bky_string.ref}'), + ['%{bky_string.ref}']); + }); + + test('Ampersand in ref', function() { + Blockly.Msg['string&ref'] = 'test string'; + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('%{bky_string&ref}'), + ['%{bky_string&ref}']); + }); + + test('Unclosed reference', function() { + Blockly.Msg.UNCLOSED = 'test string'; + chai.assert.deepEqual( + Blockly.utils.tokenizeInterpolation('%{bky_unclosed'), + ['%{bky_unclosed']); + }); }); });