diff --git a/.changeset/proud-years-chew.md b/.changeset/proud-years-chew.md new file mode 100644 index 000000000..b90a5fa53 --- /dev/null +++ b/.changeset/proud-years-chew.md @@ -0,0 +1,6 @@ +--- +'@getodk/xforms-engine': patch +'@getodk/common': patch +--- + +Fixed handling of ranges with negative step values diff --git a/packages/common/test-utils/xform-dsl/index.ts b/packages/common/test-utils/xform-dsl/index.ts index e108991fe..e9491f277 100644 --- a/packages/common/test-utils/xform-dsl/index.ts +++ b/packages/common/test-utils/xform-dsl/index.ts @@ -126,6 +126,22 @@ export const select1 = (ref: string, ...children: XFormsElement[]): XFormsElemen return t(`select1 ref="${ref}"`, ...children); }; +interface RangeAttributes { + start: number; + end: number; + step: number; +} +export const range = ( + ref: string, + attributes: RangeAttributes, + ...children: XFormsElement[] +): XFormsElement => { + return t( + `range ref="${ref}" start="${attributes.start}" end="${attributes.end}" step="${attributes.step}"`, + ...children + ); +}; + type Select1DynamicParameters = // eslint-disable-next-line @typescript-eslint/sort-type-constituents | readonly [ref: string, nodesetRef: string] diff --git a/packages/xforms-engine/src/parse/body/control/RangeControlDefinition.ts b/packages/xforms-engine/src/parse/body/control/RangeControlDefinition.ts index 5d611c40f..165096b5c 100644 --- a/packages/xforms-engine/src/parse/body/control/RangeControlDefinition.ts +++ b/packages/xforms-engine/src/parse/body/control/RangeControlDefinition.ts @@ -54,6 +54,16 @@ const parseNumericStringAttribute = (element: Element, localName: string): Numer return value; }; +const abs = (value: string | null) => (value?.startsWith('-') ? value.substring(1) : value); + +const parseNumericStringAttributeAbs = (element: Element, localName: string): NumericString => { + const value = abs(element.getAttribute(localName)); + + assertNumericStringAttribute(localName, value); + + return value; +}; + /** * Per * {@link https://getodk.github.io/xforms-spec/#body-elements | ODK XForms spec}, @@ -69,15 +79,13 @@ const parseNumericStringAttribute = (element: Element, localName: string): Numer * checking only that they appear to be numeric values. We also preserve the * attributes' names here, for consistency with the spec. * - * Downstream, we parse these to their appropriate numeric runtime types, and - * alias them to their more conventional names (i.e. "start" -> "min", "end" -> - * "max"). + * Downstream, we parse these to their appropriate numeric runtime types. */ export class RangeControlBoundsDefinition { static from(element: Element) { const start = parseNumericStringAttribute(element, 'start'); const end = parseNumericStringAttribute(element, 'end'); - const step = parseNumericStringAttribute(element, 'step'); + const step = parseNumericStringAttributeAbs(element, 'step'); return new this(start, end, step); } diff --git a/packages/xforms-engine/test/parse/body/control/RangeControlDefinition.test.ts b/packages/xforms-engine/test/parse/body/control/RangeControlDefinition.test.ts new file mode 100644 index 000000000..a0317153e --- /dev/null +++ b/packages/xforms-engine/test/parse/body/control/RangeControlDefinition.test.ts @@ -0,0 +1,70 @@ +import { + bind, + body, + head, + html, + mainInstance, + model, + range, + t, + title, +} from '@getodk/common/test-utils/xform-dsl/index.ts'; +import { describe, expect, it } from 'vitest'; +import { RangeControlDefinition } from '../../../../src/parse/body/control/RangeControlDefinition.ts'; +import { XFormDefinition } from '../../../../src/parse/XFormDefinition.ts'; +import { XFormDOM } from '../../../../src/parse/XFormDOM.ts'; + +describe('RangeControlDefinition', () => { + const create = (type: string, start: number, end: number, step: number) => { + const xform = html( + head( + title('Range definition'), + model( + mainInstance(t('root id="body-definition"', t('range'))), + bind('/root/range').type(type) + ) + ), + body(range('/root/range', { start, end, step })) + ); + + const xformDOM = XFormDOM.from(xform.asXml()); + const xformDefinition = new XFormDefinition(xformDOM); + const rangeElement = xformDefinition.body.element.children[0]; + + return new RangeControlDefinition(xformDefinition, xformDefinition.body, rangeElement!); + }; + + describe('bounds', () => { + describe('int', () => { + it('parses', () => { + const definition = create('int', -2, 10, 2); + expect(definition.bounds.start).to.equal('-2'); + expect(definition.bounds.step).to.equal('2'); + expect(definition.bounds.end).to.equal('10'); + }); + + it('takes the absolute value of step', () => { + const definition = create('int', 0, 10, -2); + expect(definition.bounds.start).to.equal('0'); + expect(definition.bounds.step).to.equal('2'); + expect(definition.bounds.end).to.equal('10'); + }); + }); + + describe('decimal', () => { + it('parses', () => { + const definition = create('decimal', -2.5, 10.5, 2.5); + expect(definition.bounds.start).to.equal('-2.5'); + expect(definition.bounds.step).to.equal('2.5'); + expect(definition.bounds.end).to.equal('10.5'); + }); + + it('takes the absolute value of step', () => { + const definition = create('decimal', 0, 10, -2.5); + expect(definition.bounds.start).to.equal('0'); + expect(definition.bounds.step).to.equal('2.5'); + expect(definition.bounds.end).to.equal('10'); + }); + }); + }); +});