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.
This commit is contained in:
John Nesky
2023-08-11 12:41:49 -07:00
committed by GitHub
parent 1a41891bbe
commit f246adbd26
17 changed files with 310 additions and 55 deletions

View File

@@ -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<string | number>,
args: Array<AnyDuringMigration | string>,
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)
);
}

View File

@@ -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,
};

View File

@@ -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);
}
}

View File

@@ -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,
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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.

View File

@@ -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]) : '';
}

View File

@@ -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();

View File

@@ -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(

View File

@@ -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() {

View File

@@ -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'));
}

View File

@@ -422,6 +422,7 @@
</value>
</block>
<block type="input_dummy"></block>
<block type="input_end_row"></block>
</category>
<category name="Field">
<block type="field_static"></block>

View File

@@ -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 () {

View File

@@ -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');
});
});
});

View File

@@ -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');