diff --git a/core/blockly.ts b/core/blockly.ts index 9def99fdf..71db6e3aa 100644 --- a/core/blockly.ts +++ b/core/blockly.ts @@ -50,6 +50,12 @@ import { FieldValidator, UnattachedFieldError, } from './field.js'; +import { + FieldAngle, + FieldAngleConfig, + FieldAngleFromJsonConfig, + FieldAngleValidator, +} from './field_angle.js'; import { FieldCheckbox, FieldCheckboxConfig, @@ -503,6 +509,12 @@ export {DeleteArea}; export {DragTarget}; export const DropDownDiv = dropDownDiv; export {Field, FieldConfig, FieldValidator, UnattachedFieldError}; +export { + FieldAngle, + FieldAngleConfig, + FieldAngleFromJsonConfig, + FieldAngleValidator, +}; export { FieldCheckbox, FieldCheckboxConfig, diff --git a/core/field_angle.ts b/core/field_angle.ts new file mode 100644 index 000000000..2069881ed --- /dev/null +++ b/core/field_angle.ts @@ -0,0 +1,595 @@ +/** + * @license + * Copyright 2013 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Angle input field. + * + * @class + */ +import * as goog from '../closure/goog/goog.js'; +goog.declareModuleId('Blockly.FieldAngle'); + +import {BlockSvg} from './block_svg.js'; +import * as browserEvents from './browser_events.js'; +import * as Css from './css.js'; +import * as dropDownDiv from './dropdowndiv.js'; +import {Field, UnattachedFieldError} from './field.js'; +import * as fieldRegistry from './field_registry.js'; +import { + FieldInput, + FieldInputConfig, + FieldInputValidator, +} from './field_input.js'; +import * as dom from './utils/dom.js'; +import * as math from './utils/math.js'; +import {Svg} from './utils/svg.js'; +import * as userAgent from './utils/useragent.js'; +import * as WidgetDiv from './widgetdiv.js'; + +/** + * Class for an editable angle field. + */ +export class FieldAngle extends FieldInput { + /** Half the width of protractor image. */ + static readonly HALF = 100 / 2; + + /** + * Radius of protractor circle. Slightly smaller than protractor size since + * otherwise SVG crops off half the border at the edges. + */ + static readonly RADIUS: number = FieldAngle.HALF - 1; + + /** + * Default property describing which direction makes an angle field's value + * increase. Angle increases clockwise (true) or counterclockwise (false). + */ + static readonly CLOCKWISE = false; + + /** + * The default offset of 0 degrees (and all angles). Always offsets in the + * counterclockwise direction, regardless of the field's clockwise property. + * Usually either 0 (0 = right) or 90 (0 = up). + */ + static readonly OFFSET = 0; + + /** + * The default maximum angle to allow before wrapping. + * Usually either 360 (for 0 to 359.9) or 180 (for -179.9 to 180). + */ + static readonly WRAP = 360; + + /** + * The default amount to round angles to when using a mouse or keyboard nav + * input. Must be a positive integer to support keyboard navigation. + */ + static readonly ROUND = 15; + + /** + * Whether the angle should increase as the angle picker is moved clockwise + * (true) or counterclockwise (false). + */ + private clockwise = FieldAngle.CLOCKWISE; + + /** + * The offset of zero degrees (and all other angles). + */ + private offset = FieldAngle.OFFSET; + + /** + * The maximum angle to allow before wrapping. + */ + private wrap = FieldAngle.WRAP; + + /** + * The amount to round angles to when using a mouse or keyboard nav input. + */ + private round = FieldAngle.ROUND; + + /** + * Array holding info needed to unbind events. + * Used for disposing. + * Ex: [[node, name, func], [node, name, func]]. + */ + private boundEvents: browserEvents.Data[] = []; + + /** Dynamic red line pointing at the value's angle. */ + private line: SVGLineElement | null = null; + + /** Dynamic pink area extending from 0 to the value's angle. */ + private gauge: SVGPathElement | null = null; + + /** The degree symbol for this field. */ + protected symbol_: SVGTSpanElement | null = null; + + /** + * @param value The initial value of the field. Should cast to a number. + * Defaults to 0. Also accepts Field.SKIP_SETUP if you wish to skip setup + * (only used by subclasses that want to handle configuration and setting + * the field value after their own constructors have run). + * @param validator A function that is called to validate changes to the + * field's value. Takes in a number & returns a validated number, or null + * to abort the change. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/angle#creation} + * for a list of properties this parameter supports. + */ + constructor( + value?: string | number | typeof Field.SKIP_SETUP, + validator?: FieldAngleValidator, + config?: FieldAngleConfig + ) { + super(Field.SKIP_SETUP); + + if (value === Field.SKIP_SETUP) return; + if (config) { + this.configure_(config); + } + this.setValue(value); + if (validator) { + this.setValidator(validator); + } + } + + /** + * Configure the field based on the given map of options. + * + * @param config A map of options to configure the field based on. + */ + protected override configure_(config: FieldAngleConfig) { + super.configure_(config); + + switch (config.mode) { + case Mode.COMPASS: + this.clockwise = true; + this.offset = 90; + break; + case Mode.PROTRACTOR: + // This is the default mode, so we could do nothing. But just to + // future-proof, we'll set it anyway. + this.clockwise = false; + this.offset = 0; + break; + } + + // Allow individual settings to override the mode setting. + if (config.clockwise) this.clockwise = config.clockwise; + if (config.offset) this.offset = config.offset; + if (config.wrap) this.wrap = config.wrap; + if (config.round) this.round = config.round; + } + + /** + * Create the block UI for this field. + * + * @internal + */ + override initView() { + super.initView(); + // Add the degree symbol to the left of the number, + // even in RTL (issue #2380). + this.symbol_ = dom.createSvgElement(Svg.TSPAN, {}); + this.symbol_.appendChild(document.createTextNode('°')); + this.getTextElement().appendChild(this.symbol_); + } + + /** Updates the angle when the field rerenders. */ + protected override render_() { + super.render_(); + this.updateGraph(); + } + + /** + * Create and show the angle field's editor. + * + * @param e Optional mouse event that triggered the field to open, + * or undefined if triggered programmatically. + */ + protected override showEditor_(e?: Event) { + // Mobile browsers have issues with in-line textareas (focus & keyboards). + const noFocus = userAgent.MOBILE || userAgent.ANDROID || userAgent.IPAD; + super.showEditor_(e, noFocus); + + const editor = this.dropdownCreate(); + dropDownDiv.getContentDiv().appendChild(editor); + + if (this.sourceBlock_ instanceof BlockSvg) { + dropDownDiv.setColour( + this.sourceBlock_.style.colourPrimary, + this.sourceBlock_.style.colourTertiary + ); + } + + dropDownDiv.showPositionedByField(this, this.dropdownDispose.bind(this)); + + this.updateGraph(); + } + + /** + * Creates the angle dropdown editor. + * + * @returns The newly created slider. + */ + private dropdownCreate(): SVGSVGElement { + const svg = dom.createSvgElement(Svg.SVG, { + 'xmlns': dom.SVG_NS, + 'xmlns:html': dom.HTML_NS, + 'xmlns:xlink': dom.XLINK_NS, + 'version': '1.1', + 'height': FieldAngle.HALF * 2 + 'px', + 'width': FieldAngle.HALF * 2 + 'px', + 'style': 'touch-action: none', + }); + const circle = dom.createSvgElement( + Svg.CIRCLE, + { + 'cx': FieldAngle.HALF, + 'cy': FieldAngle.HALF, + 'r': FieldAngle.RADIUS, + 'class': 'blocklyAngleCircle', + }, + svg + ); + this.gauge = dom.createSvgElement( + Svg.PATH, + {'class': 'blocklyAngleGauge'}, + svg + ); + this.line = dom.createSvgElement( + Svg.LINE, + { + 'x1': FieldAngle.HALF, + 'y1': FieldAngle.HALF, + 'class': 'blocklyAngleLine', + }, + svg + ); + // Draw markers around the edge. + for (let angle = 0; angle < 360; angle += 15) { + dom.createSvgElement( + Svg.LINE, + { + 'x1': FieldAngle.HALF + FieldAngle.RADIUS, + 'y1': FieldAngle.HALF, + 'x2': + FieldAngle.HALF + FieldAngle.RADIUS - (angle % 45 === 0 ? 10 : 5), + 'y2': FieldAngle.HALF, + 'class': 'blocklyAngleMarks', + 'transform': + 'rotate(' + + angle + + ',' + + FieldAngle.HALF + + ',' + + FieldAngle.HALF + + ')', + }, + svg + ); + } + + // The angle picker is different from other fields in that it updates on + // mousemove even if it's not in the middle of a drag. In future we may + // change this behaviour. + this.boundEvents.push( + browserEvents.conditionalBind(svg, 'click', this, this.hide) + ); + // On touch devices, the picker's value is only updated with a drag. Add + // a click handler on the drag surface to update the value if the surface + // is clicked. + this.boundEvents.push( + browserEvents.conditionalBind( + circle, + 'pointerdown', + this, + this.onMouseMove_, + true + ) + ); + this.boundEvents.push( + browserEvents.conditionalBind( + circle, + 'pointermove', + this, + this.onMouseMove_, + true + ) + ); + return svg; + } + + /** Disposes of events and DOM-references belonging to the angle editor. */ + private dropdownDispose() { + for (const event of this.boundEvents) { + browserEvents.unbind(event); + } + this.boundEvents.length = 0; + this.gauge = null; + this.line = null; + } + + /** Hide the editor. */ + private hide() { + dropDownDiv.hideIfOwner(this); + WidgetDiv.hide(); + } + + /** + * Set the angle to match the mouse's position. + * + * @param e Mouse move event. + */ + protected onMouseMove_(e: PointerEvent) { + // Calculate angle. + const bBox = this.gauge!.ownerSVGElement!.getBoundingClientRect(); + const dx = e.clientX - bBox.left - FieldAngle.HALF; + const dy = e.clientY - bBox.top - FieldAngle.HALF; + let angle = Math.atan(-dy / dx); + if (isNaN(angle)) { + // This shouldn't happen, but let's not let this error propagate further. + return; + } + angle = math.toDegrees(angle); + // 0: East, 90: North, 180: West, 270: South. + if (dx < 0) { + angle += 180; + } else if (dy > 0) { + angle += 360; + } + + // Do offsetting. + if (this.clockwise) { + angle = this.offset + 360 - angle; + } else { + angle = 360 - (this.offset - angle); + } + + this.displayMouseOrKeyboardValue(angle); + } + + /** + * Handles and displays values that are input via mouse or arrow key input. + * These values need to be rounded and wrapped before being displayed so + * that the text input's value is appropriate. + * + * @param angle New angle. + */ + private displayMouseOrKeyboardValue(angle: number) { + if (this.round) { + angle = Math.round(angle / this.round) * this.round; + } + angle = this.wrapValue(angle); + if (angle !== this.value_) { + this.setEditorValue_(angle); + } + } + + /** Redraw the graph with the current angle. */ + private updateGraph() { + if (!this.gauge || !this.line) { + return; + } + // Always display the input (i.e. getText) even if it is invalid. + let angleDegrees = Number(this.getText()) + this.offset; + angleDegrees %= 360; + let angleRadians = math.toRadians(angleDegrees); + const path = ['M ', FieldAngle.HALF, ',', FieldAngle.HALF]; + let x2 = FieldAngle.HALF; + let y2 = FieldAngle.HALF; + if (!isNaN(angleRadians)) { + const clockwiseFlag = Number(this.clockwise); + const angle1 = math.toRadians(this.offset); + const x1 = Math.cos(angle1) * FieldAngle.RADIUS; + const y1 = Math.sin(angle1) * -FieldAngle.RADIUS; + if (clockwiseFlag) { + angleRadians = 2 * angle1 - angleRadians; + } + x2 += Math.cos(angleRadians) * FieldAngle.RADIUS; + y2 -= Math.sin(angleRadians) * FieldAngle.RADIUS; + // Don't ask how the flag calculations work. They just do. + let largeFlag = Math.abs( + Math.floor((angleRadians - angle1) / Math.PI) % 2 + ); + if (clockwiseFlag) { + largeFlag = 1 - largeFlag; + } + path.push( + ' l ', + x1, + ',', + y1, + ' A ', + FieldAngle.RADIUS, + ',', + FieldAngle.RADIUS, + ' 0 ', + largeFlag, + ' ', + clockwiseFlag, + ' ', + x2, + ',', + y2, + ' z' + ); + } + this.gauge.setAttribute('d', path.join('')); + this.line.setAttribute('x2', `${x2}`); + this.line.setAttribute('y2', `${y2}`); + } + + /** + * Handle key down to the editor. + * + * @param e Keyboard event. + */ + protected override onHtmlInputKeyDown_(e: KeyboardEvent) { + super.onHtmlInputKeyDown_(e); + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + + let multiplier = 0; + switch (e.key) { + case 'ArrowLeft': + // decrement (increment in RTL) + multiplier = block.RTL ? 1 : -1; + break; + case 'ArrowRight': + // increment (decrement in RTL) + multiplier = block.RTL ? -1 : 1; + break; + case 'ArrowDown': + // decrement + multiplier = -1; + break; + case 'ArrowUp': + // increment + multiplier = 1; + break; + } + if (multiplier) { + const value = this.getValue() as number; + this.displayMouseOrKeyboardValue(value + multiplier * this.round); + e.preventDefault(); + e.stopPropagation(); + } + } + + /** + * Ensure that the input value is a valid angle. + * + * @param newValue The input value. + * @returns A valid angle, or null if invalid. + */ + protected override doClassValidation_(newValue?: any): number | null { + const value = Number(newValue); + if (isNaN(value) || !isFinite(value)) { + return null; + } + return this.wrapValue(value); + } + + /** + * Wraps the value so that it is in the range (-360 + wrap, wrap). + * + * @param value The value to wrap. + * @returns The wrapped value. + */ + private wrapValue(value: number): number { + value %= 360; + if (value < 0) { + value += 360; + } + if (value > this.wrap) { + value -= 360; + } + return value; + } + + /** + * Construct a FieldAngle from a JSON arg object. + * + * @param options A JSON object with options (angle). + * @returns The new field instance. + * @nocollapse + * @internal + */ + static fromJson(options: FieldAngleFromJsonConfig): FieldAngle { + // `this` might be a subclass of FieldAngle if that class doesn't override + // the static fromJson method. + return new this(options.angle, undefined, options); + } +} + +fieldRegistry.register('field_angle', FieldAngle); + +FieldAngle.prototype.DEFAULT_VALUE = 0; + +/** + * CSS for angle field. + */ +Css.register(` +.blocklyAngleCircle { + stroke: #444; + stroke-width: 1; + fill: #ddd; + fill-opacity: 0.8; +} + +.blocklyAngleMarks { + stroke: #444; + stroke-width: 1; +} + +.blocklyAngleGauge { + fill: #f88; + fill-opacity: 0.8; + pointer-events: none; +} + +.blocklyAngleLine { + stroke: #f00; + stroke-width: 2; + stroke-linecap: round; + pointer-events: none; +} +`); + +/** + * The two main modes of the angle field. + * Compass specifies: + * - clockwise: true + * - offset: 90 + * - wrap: 0 + * - round: 15 + * + * Protractor specifies: + * - clockwise: false + * - offset: 0 + * - wrap: 0 + * - round: 15 + */ +export enum Mode { + COMPASS = 'compass', + PROTRACTOR = 'protractor', +} + +/** + * Extra configuration options for the angle field. + */ +export interface FieldAngleConfig extends FieldInputConfig { + mode?: Mode; + clockwise?: boolean; + offset?: number; + wrap?: number; + round?: number; +} + +/** + * fromJson configuration options for the angle field. + */ +export interface FieldAngleFromJsonConfig extends FieldAngleConfig { + angle?: number; +} + +/** + * A function that is called to validate changes to the field's value before + * they are set. + * + * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values} + * @param newValue The value to be validated. + * @returns One of three instructions for setting the new value: `T`, `null`, + * or `undefined`. + * + * - `T` to set this function's returned value instead of `newValue`. + * + * - `null` to invoke `doValueInvalid_` and not set a value. + * + * - `undefined` to set `newValue` as is. + */ +export type FieldAngleValidator = FieldInputValidator; diff --git a/tests/mocha/field_angle_test.js b/tests/mocha/field_angle_test.js new file mode 100644 index 000000000..067a91c3c --- /dev/null +++ b/tests/mocha/field_angle_test.js @@ -0,0 +1,391 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.declareModuleId('Blockly.test.fieldAngle'); + +import * as Blockly from '../../build/src/core/blockly.js'; +import { + assertFieldValue, + runConstructorSuiteTests, + runFromJsonSuiteTests, + runSetValueTests, +} from './test_helpers/fields.js'; +import { + createTestBlock, + defineRowBlock, +} from './test_helpers/block_definitions.js'; +import { + sharedTestSetup, + sharedTestTeardown, + workspaceTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('Angle Fields', function () { + setup(function () { + sharedTestSetup.call(this); + }); + teardown(function () { + sharedTestTeardown.call(this); + }); + /** + * Configuration for field tests with invalid values. + * @type {!Array} + */ + const invalidValueTestCases = [ + {title: 'Undefined', value: undefined}, + {title: 'Null', value: null}, + {title: 'NaN', value: NaN}, + {title: 'Non-Parsable String', value: 'bad'}, + {title: 'Infinity', value: Infinity, expectedValue: Infinity}, + {title: 'Negative Infinity', value: -Infinity, expectedValue: -Infinity}, + {title: 'Infinity String', value: 'Infinity', expectedValue: Infinity}, + { + title: 'Negative Infinity String', + value: '-Infinity', + expectedValue: -Infinity, + }, + ]; + /** + * Configuration for field tests with valid values. + * @type {!Array} + */ + + const validValueTestCases = [ + {title: 'Integer', value: 1, expectedValue: 1}, + {title: 'Float', value: 1.5, expectedValue: 1.5}, + {title: 'Integer String', value: '1', expectedValue: 1}, + {title: 'Float String', value: '1.5', expectedValue: 1.5}, + {title: '> 360°', value: 362, expectedValue: 2}, + ]; + const addArgsAndJson = function (testCase) { + testCase.args = [testCase.value]; + testCase.json = {'angle': testCase.value}; + }; + invalidValueTestCases.forEach(addArgsAndJson); + validValueTestCases.forEach(addArgsAndJson); + + /** + * The expected default value for the field being tested. + * @type {*} + */ + const defaultFieldValue = 0; + /** + * Asserts that the field property values are set to default. + * @param {FieldTemplate} field The field to check. + */ + const assertFieldDefault = function (field) { + assertFieldValue(field, defaultFieldValue); + }; + /** + * Asserts that the field properties are correct based on the test case. + * @param {!Blockly.FieldAngle} field The field to check. + * @param {!FieldValueTestCase} testCase The test case. + */ + const validTestCaseAssertField = function (field, testCase) { + assertFieldValue(field, testCase.expectedValue); + }; + + runConstructorSuiteTests( + Blockly.FieldAngle, + validValueTestCases, + invalidValueTestCases, + validTestCaseAssertField, + assertFieldDefault + ); + + runFromJsonSuiteTests( + Blockly.FieldAngle, + validValueTestCases, + invalidValueTestCases, + validTestCaseAssertField, + assertFieldDefault + ); + + suite('setValue', function () { + suite('Empty -> New Value', function () { + setup(function () { + this.field = new Blockly.FieldAngle(); + }); + runSetValueTests( + validValueTestCases, + invalidValueTestCases, + defaultFieldValue + ); + test('With source block', function () { + this.field.setSourceBlock(createTestBlock()); + this.field.setValue(2.5); + assertFieldValue(this.field, 2.5); + }); + }); + suite('Value -> New Value', function () { + const initialValue = 1; + setup(function () { + this.field = new Blockly.FieldAngle(initialValue); + }); + runSetValueTests( + validValueTestCases, + invalidValueTestCases, + initialValue + ); + test('With source block', function () { + this.field.setSourceBlock(createTestBlock()); + this.field.setValue(2.5); + assertFieldValue(this.field, 2.5); + }); + }); + }); + suite('Validators', function () { + setup(function () { + this.field = new Blockly.FieldAngle(1); + this.field.htmlInput_ = document.createElement('input'); + this.field.htmlInput_.setAttribute('data-old-value', '1'); + this.field.htmlInput_.setAttribute('data-untyped-default-value', '1'); + this.stub = sinon.stub(this.field, 'resizeEditor_'); + }); + teardown(function () { + sinon.restore(); + }); + const testSuites = [ + { + title: 'Null Validator', + validator: function () { + return null; + }, + value: 2, + expectedValue: '1', + }, + { + title: 'Force Mult of 30 Validator', + validator: function (newValue) { + return Math.round(newValue / 30) * 30; + }, + value: 25, + expectedValue: 30, + }, + { + title: 'Returns Undefined Validator', + validator: function () {}, + value: 2, + expectedValue: 2, + }, + ]; + testSuites.forEach(function (suiteInfo) { + suite(suiteInfo.title, function () { + setup(function () { + this.field.setValidator(suiteInfo.validator); + }); + test('When Editing', function () { + this.field.isBeingEdited_ = true; + this.field.htmlInput_.value = String(suiteInfo.value); + this.field.onHtmlInputChange_(null); + assertFieldValue( + this.field, + suiteInfo.expectedValue, + String(suiteInfo.value) + ); + }); + test('When Not Editing', function () { + this.field.setValue(suiteInfo.value); + assertFieldValue(this.field, +suiteInfo.expectedValue); + }); + }); + }); + }); + suite('Customizations', function () { + suite('Clockwise', function () { + test('JS Configuration', function () { + const field = new Blockly.FieldAngle(0, null, { + clockwise: true, + }); + chai.assert.isTrue(field.clockwise); + }); + test('JSON Definition', function () { + const field = Blockly.FieldAngle.fromJson({ + value: 0, + clockwise: true, + }); + chai.assert.isTrue(field.clockwise); + }); + test('Constant', function () { + // Note: Generally constants should be set at compile time, not + // runtime (since they are constants) but for testing purposes we + // can do this. + Blockly.FieldAngle.CLOCKWISE = true; + const field = new Blockly.FieldAngle(); + chai.assert.isTrue(field.clockwise); + }); + }); + suite('Offset', function () { + test('JS Configuration', function () { + const field = new Blockly.FieldAngle(0, null, { + offset: 90, + }); + chai.assert.equal(field.offset, 90); + }); + test('JSON Definition', function () { + const field = Blockly.FieldAngle.fromJson({ + value: 0, + offset: 90, + }); + chai.assert.equal(field.offset, 90); + }); + test('Constant', function () { + // Note: Generally constants should be set at compile time, not + // runtime (since they are constants) but for testing purposes we + // can do this. + Blockly.FieldAngle.OFFSET = 90; + const field = new Blockly.FieldAngle(); + chai.assert.equal(field.offset, 90); + }); + test('Null', function () { + // Note: Generally constants should be set at compile time, not + // runtime (since they are constants) but for testing purposes we + // can do this. + Blockly.FieldAngle.OFFSET = 90; + const field = Blockly.FieldAngle.fromJson({ + value: 0, + offset: null, + }); + chai.assert.equal(field.offset, 90); + }); + }); + suite('Wrap', function () { + test('JS Configuration', function () { + const field = new Blockly.FieldAngle(0, null, { + wrap: 180, + }); + chai.assert.equal(field.wrap, 180); + }); + test('JSON Definition', function () { + const field = Blockly.FieldAngle.fromJson({ + value: 0, + wrap: 180, + }); + chai.assert.equal(field.wrap, 180); + }); + test('Constant', function () { + // Note: Generally constants should be set at compile time, not + // runtime (since they are constants) but for testing purposes we + // can do this. + Blockly.FieldAngle.WRAP = 180; + const field = new Blockly.FieldAngle(); + chai.assert.equal(field.wrap, 180); + }); + test('Null', function () { + // Note: Generally constants should be set at compile time, not + // runtime (since they are constants) but for testing purposes we + // can do this. + Blockly.FieldAngle.WRAP = 180; + const field = Blockly.FieldAngle.fromJson({ + value: 0, + wrap: null, + }); + chai.assert.equal(field.wrap, 180); + }); + }); + suite('Round', function () { + test('JS Configuration', function () { + const field = new Blockly.FieldAngle(0, null, { + round: 30, + }); + chai.assert.equal(field.round, 30); + }); + test('JSON Definition', function () { + const field = Blockly.FieldAngle.fromJson({ + value: 0, + round: 30, + }); + chai.assert.equal(field.round, 30); + }); + test('Constant', function () { + // Note: Generally constants should be set at compile time, not + // runtime (since they are constants) but for testing purposes we + // can do this. + Blockly.FieldAngle.ROUND = 30; + const field = new Blockly.FieldAngle(); + chai.assert.equal(field.round, 30); + }); + test('Null', function () { + // Note: Generally constants should be set at compile time, not + // runtime (since they are constants) but for testing purposes we + // can do this. + Blockly.FieldAngle.ROUND = 30; + const field = Blockly.FieldAngle.fromJson({ + value: 0, + round: null, + }); + chai.assert.equal(field.round, 30); + }); + }); + suite('Mode', function () { + suite('Compass', function () { + test('JS Configuration', function () { + const field = new Blockly.FieldAngle(0, null, { + mode: 'compass', + }); + chai.assert.equal(field.offset, 90); + chai.assert.isTrue(field.clockwise); + }); + test('JS Configuration', function () { + const field = Blockly.FieldAngle.fromJson({ + value: 0, + mode: 'compass', + }); + chai.assert.equal(field.offset, 90); + chai.assert.isTrue(field.clockwise); + }); + }); + suite('Protractor', function () { + test('JS Configuration', function () { + const field = new Blockly.FieldAngle(0, null, { + mode: 'protractor', + }); + chai.assert.equal(field.offset, 0); + chai.assert.isFalse(field.clockwise); + }); + test('JS Configuration', function () { + const field = Blockly.FieldAngle.fromJson({ + value: 0, + mode: 'protractor', + }); + chai.assert.equal(field.offset, 0); + chai.assert.isFalse(field.clockwise); + }); + }); + }); + }); + + suite('Serialization', function () { + setup(function () { + this.workspace = new Blockly.Workspace(); + defineRowBlock(); + + this.assertValue = (value) => { + const block = this.workspace.newBlock('row_block'); + const field = new Blockly.FieldAngle(value); + block.getInput('INPUT').appendField(field, 'ANGLE'); + const jso = Blockly.serialization.blocks.save(block); + chai.assert.deepEqual(jso['fields'], {'ANGLE': value}); + }; + }); + + teardown(function () { + workspaceTeardown.call(this, this.workspace); + }); + + test('Simple', function () { + this.assertValue(90); + }); + + test('Max precision', function () { + this.assertValue(1.000000000000001); + }); + + test('Smallest number', function () { + this.assertValue(5e-324); + }); + }); +}); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index e3583cd5f..2d83837e1 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -82,6 +82,7 @@ 'Blockly.test.eventVarRename', 'Blockly.test.eventViewportChange', 'Blockly.test.extensions', + 'Blockly.test.fieldAngle', 'Blockly.test.fieldCheckbox', 'Blockly.test.fieldColour', 'Blockly.test.fieldDropdown', diff --git a/tests/mocha/serializer_test.js b/tests/mocha/serializer_test.js index b7b91b63e..9f9f545fe 100644 --- a/tests/mocha/serializer_test.js +++ b/tests/mocha/serializer_test.js @@ -225,6 +225,55 @@ Serializer.Attributes.testSuites = [ Serializer.Fields = new SerializerTestSuite('Fields'); +Serializer.Fields.Angle = new SerializerTestSuite('Angle'); +Serializer.Fields.Angle.Simple = new SerializerTestCase( + 'Simple', + '' + + '' + + '90' + + '' + + '' +); +Serializer.Fields.Angle.Negative = new SerializerTestCase( + 'Negative', + '' + + '' + + '-90' + + '' + + '' +); +Serializer.Fields.Angle.Decimals = new SerializerTestCase( + 'Decimals', + '' + + '' + + '1.5' + + '' + + '' +); +Serializer.Fields.Angle.MaxPrecision = new SerializerTestCase( + 'MaxPrecision', + '' + + '' + + '1.000000000000001' + + '' + + '' +); +Serializer.Fields.Angle.SmallestNumber = new SerializerTestCase( + 'SmallestNumber', + '' + + '' + + '5e-324' + + '' + + '' +); +Serializer.Fields.Angle.testCases = [ + Serializer.Fields.Angle.Simple, + Serializer.Fields.Angle.Negative, + Serializer.Fields.Angle.Decimals, + Serializer.Fields.Angle.MaxPrecision, + Serializer.Fields.Angle.SmallestNumber, +]; + Serializer.Fields.Checkbox = new SerializerTestSuite('Checkbox'); Serializer.Fields.Checkbox.True = new SerializerTestCase( 'True', @@ -1023,6 +1072,7 @@ Serializer.Fields.Variable.Id.testSuites = [ Serializer.Fields.Variable.testSuites = [Serializer.Fields.Variable.Id]; Serializer.Fields.testSuites = [ + Serializer.Fields.Angle, Serializer.Fields.Checkbox, Serializer.Fields.Colour, Serializer.Fields.Dropdown, diff --git a/tests/mocha/xml_test.js b/tests/mocha/xml_test.js index 325649798..085984ed7 100644 --- a/tests/mocha/xml_test.js +++ b/tests/mocha/xml_test.js @@ -129,6 +129,27 @@ suite('XML', function () { workspaceTeardown.call(this, this.workspace); }); suite('Fields', function () { + test('Angle', function () { + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'field_angle_test_block', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_angle', + 'name': 'ANGLE', + 'angle': 90, + }, + ], + }, + ]); + const block = new Blockly.Block( + this.workspace, + 'field_angle_test_block' + ); + const resultFieldDom = Blockly.Xml.blockToDom(block).childNodes[0]; + assertNonVariableField(resultFieldDom, 'ANGLE', '90'); + }); test('Checkbox', function () { Blockly.defineBlocksWithJsonArray([ {