From f246adbd2620b8fbb11514f60a1e2330008454a2 Mon Sep 17 00:00:00 2001 From: John Nesky Date: Fri, 11 Aug 2023 12:41:49 -0700 Subject: [PATCH] feat: Parse newlines in JSON message as row separators. (#6944) * feat: Parse message newlines as endOfRow dummies. * Fix the multilineinput field test. * Addressing PR feedback. * Addressing PR feedback. * Newline parsing now uses a new custom input. * npm run format * Added input_end_row to block factory. * Addres feedback, fix endrow after external value. --- core/block.ts | 67 +++++++++++++----- core/inputs.ts | 11 ++- core/inputs/end_row_input.ts | 31 +++++++++ core/inputs/input_types.ts | 4 ++ core/renderers/common/info.ts | 21 ++++-- core/renderers/geras/info.ts | 13 ++-- core/renderers/measurables/row.ts | 2 +- core/renderers/zelos/info.ts | 20 ++++-- core/utils/parsing.ts | 40 ++++++++--- core/xml.ts | 2 +- .../block_definition_extractor.js | 12 ++-- demos/blockfactory/blocks.js | 25 ++++++- demos/blockfactory/factory_utils.js | 11 +-- demos/blockfactory/index.html | 1 + tests/mocha/block_json_test.js | 68 +++++++++++++++++++ tests/mocha/block_test.js | 22 ++++++ tests/mocha/utils_test.js | 15 ++++ 17 files changed, 310 insertions(+), 55 deletions(-) create mode 100644 core/inputs/end_row_input.ts diff --git a/core/block.ts b/core/block.ts index 3e279f79885..ea85edaf8c3 100644 --- a/core/block.ts +++ b/core/block.ts @@ -48,6 +48,7 @@ import {Size} from './utils/size.js'; import type {VariableModel} from './variable_model.js'; import type {Workspace} from './workspace.js'; import {DummyInput} from './inputs/dummy_input.js'; +import {EndRowInput} from './inputs/end_row_input.js'; import {ValueInput} from './inputs/value_input.js'; import {StatementInput} from './inputs/statement_input.js'; import {IconType} from './icons/icon_types.js'; @@ -1339,6 +1340,12 @@ export class Block implements IASTNodeLocation, IDeletable { return true; } } + for (let i = 0; i < this.inputList.length; i++) { + if (this.inputList[i] instanceof EndRowInput) { + // A row-end input is present. Inline value inputs. + return true; + } + } return false; } @@ -1560,6 +1567,17 @@ export class Block implements IASTNodeLocation, IDeletable { return this.appendInput(new DummyInput(name, this)); } + /** + * Appends an input that ends the row. + * + * @param name Optional language-neutral identifier which may used to find + * this input again. Should be unique to this block. + * @returns The input object created. + */ + appendEndRowInput(name = ''): Input { + return this.appendInput(new EndRowInput(name, this)); + } + /** * Appends the given input row. * @@ -1628,7 +1646,8 @@ export class Block implements IASTNodeLocation, IDeletable { this.interpolate_( json['message' + i], json['args' + i] || [], - json['lastDummyAlign' + i], + // Backwards compatibility: lastDummyAlign aliases implicitAlign. + json['implicitAlign' + i] || json['lastDummyAlign' + i], warningPrefix, ); i++; @@ -1765,19 +1784,19 @@ export class Block implements IASTNodeLocation, IDeletable { * @param message Text contains interpolation tokens (%1, %2, ...) that match * with fields or inputs defined in the args array. * @param args Array of arguments to be interpolated. - * @param lastDummyAlign If a dummy input is added at the end, how should it - * be aligned? + * @param implicitAlign If an implicit input is added at the end or in place + * of newline tokens, how should it be aligned? * @param warningPrefix Warning prefix string identifying block. */ private interpolate_( message: string, args: AnyDuringMigration[], - lastDummyAlign: string | undefined, + implicitAlign: string | undefined, warningPrefix: string, ) { const tokens = parsing.tokenizeInterpolation(message); this.validateTokens_(tokens, args.length); - const elements = this.interpolateArguments_(tokens, args, lastDummyAlign); + const elements = this.interpolateArguments_(tokens, args, implicitAlign); // An array of [field, fieldName] tuples. const fieldStack = []; @@ -1855,19 +1874,20 @@ export class Block implements IASTNodeLocation, IDeletable { /** * 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. + * JSON that defines a label field. Newline characters are converted to + * end-row inputs, and if necessary an extra dummy input is added to the end + * of the elements. * * @param tokens The tokens to interpolate * @param args The arguments to insert. - * @param lastDummyAlign The alignment the added dummy input should have, if - * we are required to add one. + * @param implicitAlign The alignment to use for any implicitly added end-row + * or dummy inputs, if necessary. * @returns The JSON definitions of field and inputs to add to the block. */ private interpolateArguments_( tokens: Array, args: Array, - lastDummyAlign: string | undefined, + implicitAlign: string | undefined, ): AnyDuringMigration[] { const elements = []; for (let i = 0; i < tokens.length; i++) { @@ -1877,11 +1897,20 @@ export class Block implements IASTNodeLocation, IDeletable { } // Args can be strings, which is why this isn't elseif. if (typeof element === 'string') { - // AnyDuringMigration because: Type '{ text: string; type: string; } | - // null' is not assignable to type 'string | number'. - element = this.stringToFieldJson_(element) as AnyDuringMigration; - if (!element) { - continue; + if (element === '\n') { + // Convert newline tokens to end-row inputs. + const newlineInput = {'type': 'input_end_row'}; + if (implicitAlign) { + (newlineInput as AnyDuringMigration)['align'] = implicitAlign; + } + element = newlineInput as AnyDuringMigration; + } else { + // AnyDuringMigration because: Type '{ text: string; type: string; } + // | null' is not assignable to type 'string | number'. + element = this.stringToFieldJson_(element) as AnyDuringMigration; + if (!element) { + continue; + } } } elements.push(element); @@ -1895,8 +1924,8 @@ export class Block implements IASTNodeLocation, IDeletable { ) ) { const dummyInput = {'type': 'input_dummy'}; - if (lastDummyAlign) { - (dummyInput as AnyDuringMigration)['align'] = lastDummyAlign; + if (implicitAlign) { + (dummyInput as AnyDuringMigration)['align'] = implicitAlign; } elements.push(dummyInput); } @@ -1960,6 +1989,9 @@ export class Block implements IASTNodeLocation, IDeletable { case 'input_dummy': input = this.appendDummyInput(element['name']); break; + case 'input_end_row': + input = this.appendEndRowInput(element['name']); + break; default: { input = this.appendInputFromRegistry(element['type'], element['name']); break; @@ -1998,6 +2030,7 @@ export class Block implements IASTNodeLocation, IDeletable { str === 'input_value' || str === 'input_statement' || str === 'input_dummy' || + str === 'input_end_row' || registry.hasItem(registry.Type.INPUT, str) ); } diff --git a/core/inputs.ts b/core/inputs.ts index 8bd23d7908e..4b7bfa89750 100644 --- a/core/inputs.ts +++ b/core/inputs.ts @@ -7,8 +7,17 @@ import {Align} from './inputs/align.js'; import {Input} from './inputs/input.js'; import {DummyInput} from './inputs/dummy_input.js'; +import {EndRowInput} from './inputs/end_row_input.js'; import {StatementInput} from './inputs/statement_input.js'; import {ValueInput} from './inputs/value_input.js'; import {inputTypes} from './inputs/input_types.js'; -export {Align, Input, DummyInput, StatementInput, ValueInput, inputTypes}; +export { + Align, + Input, + DummyInput, + EndRowInput, + StatementInput, + ValueInput, + inputTypes, +}; diff --git a/core/inputs/end_row_input.ts b/core/inputs/end_row_input.ts new file mode 100644 index 00000000000..58227a09457 --- /dev/null +++ b/core/inputs/end_row_input.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Block} from '../block.js'; +import {Input} from './input.js'; +import {inputTypes} from './input_types.js'; + +/** + * Represents an input on a block that is always the last input in the row. Any + * following input will be rendered on the next row even if the block has inline + * inputs. Any newline character in a JSON block definition's message will + * automatically be parsed as an end-row input. + */ +export class EndRowInput extends Input { + readonly type = inputTypes.END_ROW; + + /** + * @param name Language-neutral identifier which may used to find this input + * again. + * @param block The block containing this input. + */ + constructor( + public name: string, + block: Block, + ) { + super(name, block); + } +} diff --git a/core/inputs/input_types.ts b/core/inputs/input_types.ts index ff768deb266..a51537be792 100644 --- a/core/inputs/input_types.ts +++ b/core/inputs/input_types.ts @@ -21,4 +21,8 @@ export enum inputTypes { DUMMY = 5, // An unknown type of input defined by an external developer. CUSTOM = 6, + // An input with no connections that is always the last input of a row. Any + // subsequent input will be rendered on the next row. Any newline character in + // a JSON block definition's message will be parsed as an end-row input. + END_ROW = 7, } diff --git a/core/renderers/common/info.ts b/core/renderers/common/info.ts index 6818c608c30..c035d0e52e0 100644 --- a/core/renderers/common/info.ts +++ b/core/renderers/common/info.ts @@ -14,6 +14,7 @@ import type {RenderedConnection} from '../../rendered_connection.js'; import type {Measurable} from '../measurables/base.js'; import {BottomRow} from '../measurables/bottom_row.js'; import {DummyInput} from '../../inputs/dummy_input.js'; +import {EndRowInput} from '../../inputs/end_row_input.js'; import {ExternalValueInput} from '../measurables/external_value_input.js'; import {Field} from '../measurables/field.js'; import {Hat} from '../measurables/hat.js'; @@ -326,9 +327,9 @@ export class RenderInfo { } else if (input instanceof ValueInput) { activeRow.elements.push(new ExternalValueInput(this.constants_, input)); activeRow.hasExternalInput = true; - } else if (input instanceof DummyInput) { - // Dummy inputs have no visual representation, but the information is - // still important. + } else if (input instanceof DummyInput || input instanceof EndRowInput) { + // Dummy and end-row inputs have no visual representation, but the + // information is still important. activeRow.minHeight = Math.max( activeRow.minHeight, input.getSourceBlock() && input.getSourceBlock()!.isShadow() @@ -355,6 +356,11 @@ export class RenderInfo { if (!lastInput) { return false; } + // If the previous input was an end-row input, then any following input + // should always be rendered on the next row. + if (lastInput instanceof EndRowInput) { + return true; + } // A statement input or an input following one always gets a new row. if ( input instanceof StatementInput || @@ -362,8 +368,13 @@ export class RenderInfo { ) { return true; } - // Value and dummy inputs get new row if inputs are not inlined. - if (input instanceof ValueInput || input instanceof DummyInput) { + // Value inputs, dummy inputs, and any input following an external value + // input get a new row if inputs are not inlined. + if ( + input instanceof ValueInput || + input instanceof DummyInput || + lastInput instanceof ValueInput + ) { return !this.isInline; } return false; diff --git a/core/renderers/geras/info.ts b/core/renderers/geras/info.ts index eb22bce09cd..b3a681bbf1d 100644 --- a/core/renderers/geras/info.ts +++ b/core/renderers/geras/info.ts @@ -13,6 +13,7 @@ import {RenderInfo as BaseRenderInfo} from '../common/info.js'; import type {Measurable} from '../measurables/base.js'; import type {BottomRow} from '../measurables/bottom_row.js'; import {DummyInput} from '../../inputs/dummy_input.js'; +import {EndRowInput} from '../../inputs/end_row_input.js'; import {ExternalValueInput} from '../measurables/external_value_input.js'; import type {Field} from '../measurables/field.js'; import {InRowSpacer} from '../measurables/in_row_spacer.js'; @@ -90,9 +91,9 @@ export class RenderInfo extends BaseRenderInfo { } else if (input instanceof ValueInput) { activeRow.elements.push(new ExternalValueInput(this.constants_, input)); activeRow.hasExternalInput = true; - } else if (input instanceof DummyInput) { - // Dummy inputs have no visual representation, but the information is - // still important. + } else if (input instanceof DummyInput || input instanceof EndRowInput) { + // Dummy and end-row inputs have no visual representation, but the + // information is still important. activeRow.minHeight = Math.max( activeRow.minHeight, this.constants_.DUMMY_INPUT_MIN_HEIGHT, @@ -379,8 +380,12 @@ export class RenderInfo extends BaseRenderInfo { row.width < prevInput.width ) { rowNextRightEdges.set(row, prevInput.width); - } else { + } else if (row.hasStatement) { nextRightEdge = row.width; + } else { + // To keep right edges of consecutive non-statement rows aligned, use + // the maximum width. + nextRightEdge = Math.max(nextRightEdge, row.width); } prevInput = row; } diff --git a/core/renderers/measurables/row.ts b/core/renderers/measurables/row.ts index c8116a2fc88..e698688de87 100644 --- a/core/renderers/measurables/row.ts +++ b/core/renderers/measurables/row.ts @@ -89,7 +89,7 @@ export class Row { hasInlineInput = false; /** - * Whether the row has any dummy inputs. + * Whether the row has any dummy inputs or end-row inputs. */ hasDummyInput = false; diff --git a/core/renderers/zelos/info.ts b/core/renderers/zelos/info.ts index d806fb0b669..e2d491c90d8 100644 --- a/core/renderers/zelos/info.ts +++ b/core/renderers/zelos/info.ts @@ -9,6 +9,7 @@ goog.declareModuleId('Blockly.zelos.RenderInfo'); import type {BlockSvg} from '../../block_svg.js'; import {DummyInput} from '../../inputs/dummy_input.js'; +import {EndRowInput} from '../../inputs/end_row_input.js'; import {FieldImage} from '../../field_image.js'; import {FieldLabel} from '../../field_label.js'; import {FieldTextInput} from '../../field_textinput.js'; @@ -124,6 +125,11 @@ export class RenderInfo extends BaseRenderInfo { if (!lastInput) { return false; } + // If the previous input was an end-row input, then any following input + // should always be rendered on the next row. + if (lastInput instanceof EndRowInput) { + return true; + } // A statement input or an input following one always gets a new row. if ( input instanceof StatementInput || @@ -131,8 +137,12 @@ export class RenderInfo extends BaseRenderInfo { ) { return true; } - // Value and dummy inputs get new row if inputs are not inlined. - if (input instanceof ValueInput || input instanceof DummyInput) { + // Value, dummy, and end-row inputs get new row if inputs are not inlined. + if ( + input instanceof ValueInput || + input instanceof DummyInput || + input instanceof EndRowInput + ) { return !this.isInline || this.isMultiRow; } return false; @@ -267,9 +277,9 @@ export class RenderInfo extends BaseRenderInfo { override addInput_(input: Input, activeRow: Row) { // If we have two dummy inputs on the same row, one aligned left and the // other right, keep track of the right aligned dummy input so we can add - // padding later. + // padding later. An end-row input after a dummy input also counts. if ( - input instanceof DummyInput && + (input instanceof DummyInput || input instanceof EndRowInput) && activeRow.hasDummyInput && activeRow.align === Align.LEFT && input.align === Align.RIGHT @@ -502,7 +512,7 @@ export class RenderInfo extends BaseRenderInfo { const connectionWidth = this.outputConnection.width; const outerShape = this.outputConnection.shape.type; const constants = this.constants_; - if (this.isMultiRow && this.inputRows.length > 1) { + if (this.inputRows.length > 1) { switch (outerShape) { case constants.SHAPES.ROUND: { // Special case for multi-row round reporter blocks. diff --git a/core/utils/parsing.ts b/core/utils/parsing.ts index ee6695db44b..5d2c65dc1b3 100644 --- a/core/utils/parsing.ts +++ b/core/utils/parsing.ts @@ -17,13 +17,16 @@ import * as colourUtils from './colour.js'; * * @param message Text which might contain string table references and * interpolation tokens. - * @param parseInterpolationTokens Option to parse numeric - * interpolation tokens (%1, %2, ...) when true. + * @param parseInterpolationTokens Option to parse numeric interpolation + * tokens (%1, %2, ...) when true. + * @param tokenizeNewlines Split individual newline characters into separate + * tokens when true. * @returns Array of strings and numbers. */ function tokenizeInterpolationInternal( message: string, parseInterpolationTokens: boolean, + tokenizeNewlines: boolean, ): (string | number)[] { const tokens = []; const chars = message.split(''); @@ -47,6 +50,15 @@ function tokenizeInterpolationInternal( } buffer.length = 0; state = 1; + } else if (tokenizeNewlines && c === '\n') { + // Output newline characters as single-character tokens, to be replaced + // with endOfRow dummies during interpolation. + const text = buffer.join(''); + if (text) { + tokens.push(text); + } + buffer.length = 0; + tokens.push(c); } else { buffer.push(c); // Regular char. } @@ -108,6 +120,7 @@ function tokenizeInterpolationInternal( tokenizeInterpolationInternal( rawValue, parseInterpolationTokens, + tokenizeNewlines, ), ); } else if (parseInterpolationTokens) { @@ -137,11 +150,15 @@ function tokenizeInterpolationInternal( tokens.push(text); } - // Merge adjacent text tokens into a single string. + // Merge adjacent text tokens into a single string (but if newlines should be + // tokenized, don't merge those with adjacent text). const mergedTokens = []; buffer.length = 0; for (let i = 0; i < tokens.length; i++) { - if (typeof tokens[i] === 'string') { + if ( + typeof tokens[i] === 'string' && + !(tokenizeNewlines && tokens[i] === '\n') + ) { buffer.push(tokens[i] as string); } else { text = buffer.join(''); @@ -166,14 +183,15 @@ function tokenizeInterpolationInternal( * It will also replace string table references (e.g., %{bky_my_msg} and * %{BKY_MY_MSG} will both be replaced with the value in * Msg['MY_MSG']). Percentage sign characters '%' may be self-escaped - * (e.g., '%%'). + * (e.g., '%%'). Newline characters will also be output as string tokens + * containing a single newline character. * * @param message Text which might contain string table references and * interpolation tokens. * @returns Array of strings and numbers. */ export function tokenizeInterpolation(message: string): (string | number)[] { - return tokenizeInterpolationInternal(message, true); + return tokenizeInterpolationInternal(message, true, true); } /** @@ -189,9 +207,13 @@ export function replaceMessageReferences(message: string | any): string { if (typeof message !== 'string') { return message; } - const interpolatedResult = tokenizeInterpolationInternal(message, false); - // When parseInterpolationTokens === false, interpolatedResult should be at - // most length 1. + const interpolatedResult = tokenizeInterpolationInternal( + message, + false, + false, + ); + // When parseInterpolationTokens and tokenizeNewlines are false, + // interpolatedResult should be at most length 1. return interpolatedResult.length ? String(interpolatedResult[0]) : ''; } diff --git a/core/xml.ts b/core/xml.ts index b74e6e71964..469f6cc450e 100644 --- a/core/xml.ts +++ b/core/xml.ts @@ -213,7 +213,7 @@ export function blockToDom( const input = block.inputList[i]; let container: Element; let empty = true; - if (input.type === inputTypes.DUMMY) { + if (input.type === inputTypes.DUMMY || input.type === inputTypes.END_ROW) { continue; } else { const childBlock = input.connection!.targetBlock(); diff --git a/demos/blockfactory/block_definition_extractor.js b/demos/blockfactory/block_definition_extractor.js index 35186ac845c..e6b1cedd619 100644 --- a/demos/blockfactory/block_definition_extractor.js +++ b/demos/blockfactory/block_definition_extractor.js @@ -287,14 +287,16 @@ BlockDefinitionExtractor.parseInputs_ = function(block) { * @private */ BlockDefinitionExtractor.input_ = function(input, align) { - var isDummy = (input.type === Blockly.DUMMY_INPUT); + var hasConnector = (input.type === Blockly.inputs.inputTypes.VALUE || input.type === Blockly.inputs.inputTypes.STATEMENT); var inputTypeAttr = - isDummy ? 'input_dummy' : - (input.type === Blockly.INPUT_VALUE) ? 'input_value' : 'input_statement'; + input.type === Blockly.inputs.inputTypes.DUMMY ? 'input_dummy' : + input.type === Blockly.inputs.inputTypes.END_ROW ? 'input_end_row' : + input.type === Blockly.inputs.inputTypes.VALUE ? 'input_value' : + 'input_statement'; var inputDefBlock = BlockDefinitionExtractor.newDomElement_('block', {type: inputTypeAttr}); - if (!isDummy) { + if (hasConnector) { inputDefBlock.append(BlockDefinitionExtractor.newDomElement_( 'field', {name: 'INPUTNAME'}, input.name)); } @@ -307,7 +309,7 @@ BlockDefinitionExtractor.input_ = function(input, align) { fieldsDef.append(fieldsXml); inputDefBlock.append(fieldsDef); - if (!isDummy) { + if (hasConnector) { var typeValue = BlockDefinitionExtractor.newDomElement_( 'value', {name: 'TYPE'}); typeValue.append( diff --git a/demos/blockfactory/blocks.js b/demos/blockfactory/blocks.js index 8927e64531b..70691e2649e 100644 --- a/demos/blockfactory/blocks.js +++ b/demos/blockfactory/blocks.js @@ -220,14 +220,33 @@ Blockly.Blocks['input_dummy'] = { "previousStatement": "Input", "nextStatement": "Input", "colour": 210, - "tooltip": "For adding fields on a separate row with no " + - "connections. Alignment options (left, right, centre) " + - "apply only to multi-line fields.", + "tooltip": "For adding fields without any block connections." + + "Alignment options (left, right, centre) only affect " + + "multi-row blocks.", "helpUrl": "https://www.youtube.com/watch?v=s2_xaEvcVI0#t=293" }); } }; +Blockly.Blocks['input_end_row'] = { + // End-row input. + init: function() { + this.jsonInit({ + "message0": "end-row input", + "message1": FIELD_MESSAGE, + "args1": FIELD_ARGS, + "previousStatement": "Input", + "nextStatement": "Input", + "colour": 210, + "tooltip": "For adding fields without any block connections that will " + + "be rendered on a separate row from any following inputs. " + + "Alignment options (left, right, centre) only affect " + + "multi-row blocks.", + "helpUrl": "https://developers.google.com/blockly/guides/create-custom-blocks/define-blocks#block_inputs" + }); + } +}; + Blockly.Blocks['field_static'] = { // Text value. init: function() { diff --git a/demos/blockfactory/factory_utils.js b/demos/blockfactory/factory_utils.js index 0caf4b5aa5e..164b3357be0 100644 --- a/demos/blockfactory/factory_utils.js +++ b/demos/blockfactory/factory_utils.js @@ -177,7 +177,8 @@ FactoryUtils.formatJson_ = function(blockType, rootBlock) { var input = {type: contentsBlock.type}; // Dummy inputs don't have names. Other inputs do. - if (contentsBlock.type !== 'input_dummy') { + if (contentsBlock.type !== 'input_dummy' && + contentsBlock.type !== 'input_end_row') { input.name = contentsBlock.getFieldValue('INPUTNAME'); } var check = JSON.parse( @@ -202,7 +203,7 @@ FactoryUtils.formatJson_ = function(blockType, rootBlock) { if (fields && FactoryUtils.getFieldsJson_(fields).join('').trim() !== '') { var align = lastInput.getFieldValue('ALIGN'); if (align !== 'LEFT') { - JS.lastDummyAlign0 = align; + JS.implicitAlign0 = align; } args.pop(); message.pop(); @@ -272,13 +273,15 @@ FactoryUtils.formatJavaScript_ = function(blockType, rootBlock, workspace) { // Generate inputs. var TYPES = {'input_value': 'appendValueInput', 'input_statement': 'appendStatementInput', - 'input_dummy': 'appendDummyInput'}; + 'input_dummy': 'appendDummyInput', + 'input_end_row': 'appendEndRowInput'}; var contentsBlock = rootBlock.getInputTargetBlock('INPUTS'); while (contentsBlock) { if (!contentsBlock.disabled && !contentsBlock.getInheritedDisabled()) { var name = ''; // Dummy inputs don't have names. Other inputs do. - if (contentsBlock.type !== 'input_dummy') { + if (contentsBlock.type !== 'input_dummy' && + contentsBlock.type !== 'input_end_row') { name = JSON.stringify(contentsBlock.getFieldValue('INPUTNAME')); } diff --git a/demos/blockfactory/index.html b/demos/blockfactory/index.html index 37c75236d78..14d84d73f7e 100644 --- a/demos/blockfactory/index.html +++ b/demos/blockfactory/index.html @@ -422,6 +422,7 @@

Generator stub: + diff --git a/tests/mocha/block_json_test.js b/tests/mocha/block_json_test.js index 538995f0b51..cd4337ef03f 100644 --- a/tests/mocha/block_json_test.js +++ b/tests/mocha/block_json_test.js @@ -92,6 +92,10 @@ suite('Block JSON initialization', function () { 'Block "test": Message index %2 out of range.', ); }); + + test('Newline tokens are valid', function () { + this.assertNoError(['test', '\n', 'test'], 0); + }); }); suite('interpolateArguments_', function () { @@ -312,6 +316,70 @@ suite('Block JSON initialization', function () { }, ]); }); + + test('interpolation output includes end-row inputs', function () { + this.assertInterpolation( + ['test1', {'type': 'input_end_row'}, 'test2'], + [], + undefined, + [ + { + 'type': 'field_label', + 'text': 'test1', + }, + { + 'type': 'input_end_row', + }, + { + 'type': 'field_label', + 'text': 'test2', + }, + { + 'type': 'input_dummy', + }, + ], + ); + }); + + test('Newline is converted to end-row input', function () { + this.assertInterpolation(['test1', '\n', 'test2'], [], undefined, [ + { + 'type': 'field_label', + 'text': 'test1', + }, + { + 'type': 'input_end_row', + }, + { + 'type': 'field_label', + 'text': 'test2', + }, + { + 'type': 'input_dummy', + }, + ]); + }); + + test('Newline converted to end-row aligned like last dummy', function () { + this.assertInterpolation(['test1', '\n', 'test2'], [], 'CENTER', [ + { + 'type': 'field_label', + 'text': 'test1', + }, + { + 'type': 'input_end_row', + 'align': 'CENTER', + }, + { + 'type': 'field_label', + 'text': 'test2', + }, + { + 'type': 'input_dummy', + 'align': 'CENTER', + }, + ]); + }); }); suite('fieldFromJson_', function () { diff --git a/tests/mocha/block_test.js b/tests/mocha/block_test.js index f32d04a1cb2..7308ce6075d 100644 --- a/tests/mocha/block_test.js +++ b/tests/mocha/block_test.js @@ -10,6 +10,7 @@ import {ConnectionType} from '../../build/src/core/connection_type.js'; import {createDeprecationWarningStub} from './test_helpers/warnings.js'; import {createRenderedBlock} from './test_helpers/block_definitions.js'; import * as eventUtils from '../../build/src/core/events/utils.js'; +import {EndRowInput} from '../../build/src/core/inputs/end_row_input.js'; import { sharedTestSetup, sharedTestTeardown, @@ -2494,4 +2495,25 @@ suite('Blocks', function () { chai.assert.isTrue(initCalled, 'expected init function to be called'); }); }); + + suite('EndOfRow', function () { + setup(function () { + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'end_row_test_block', + 'message0': 'Row1\nRow2', + 'inputsInline': true, + }, + ]); + }); + test('Newline is converted to an end-row input', function () { + const block = this.workspace.newBlock('end_row_test_block'); + chai.assert.equal(block.inputList[0].fieldRow[0].getValue(), 'Row1'); + chai.assert.isTrue( + block.inputList[0] instanceof EndRowInput, + 'newline should be converted to an end-row input', + ); + chai.assert.equal(block.inputList[1].fieldRow[0].getValue(), 'Row2'); + }); + }); }); diff --git a/tests/mocha/utils_test.js b/tests/mocha/utils_test.js index af5df8790f7..0d2b96a6010 100644 --- a/tests/mocha/utils_test.js +++ b/tests/mocha/utils_test.js @@ -58,6 +58,13 @@ suite('Utils', function () { ['Hello%World'], ); }); + + test('Newlines are tokenized', function () { + chai.assert.deepEqual( + Blockly.utils.parsing.tokenizeInterpolation('Hello\nWorld'), + ['Hello', '\n', 'World'], + ); + }); }); suite('Number interpolation', function () { @@ -231,6 +238,14 @@ suite('Utils', function () { 'Unrecognized % escape code treated as literal', ); + resultString = + Blockly.utils.parsing.replaceMessageReferences('Hello\nWorld'); + chai.assert.equal( + resultString, + 'Hello\nWorld', + 'Newlines are not tokenized', + ); + resultString = Blockly.utils.parsing.replaceMessageReferences('%1'); chai.assert.equal(resultString, '%1', 'Interpolation tokens ignored.'); resultString = Blockly.utils.parsing.replaceMessageReferences('%1 %2');