diff --git a/.changeset/rotten-turtles-tease.md b/.changeset/rotten-turtles-tease.md new file mode 100644 index 00000000..d6b8256d --- /dev/null +++ b/.changeset/rotten-turtles-tease.md @@ -0,0 +1,6 @@ +--- +'@getodk/scenario': minor +'@getodk/xpath': minor +--- + +Support for `indexed-repeat` XPath function diff --git a/packages/scenario/src/jr/Scenario.ts b/packages/scenario/src/jr/Scenario.ts index 0bbfeb51..a3dcfc89 100644 --- a/packages/scenario/src/jr/Scenario.ts +++ b/packages/scenario/src/jr/Scenario.ts @@ -143,7 +143,9 @@ export class Scenario { readonly formName: string; readonly instanceRoot: RootNode; - private readonly getPositionalEvents: Accessor; + protected readonly getPositionalEvents: Accessor; + + protected readonly getEventPosition: Accessor; private readonly setEventPosition: Setter; protected readonly getSelectedPositionalEvent: Accessor; @@ -154,14 +156,15 @@ export class Scenario { this.formName = formName; this.instanceRoot = instanceRoot; - const [eventPosition, setEventPosition] = createSignal(0); + const [getEventPosition, setEventPosition] = createSignal(0); this.getPositionalEvents = () => getPositionalEvents(instanceRoot); + this.getEventPosition = getEventPosition; this.setEventPosition = setEventPosition; this.getSelectedPositionalEvent = createMemo(() => { const events = getPositionalEvents(instanceRoot); - const position = eventPosition(); + const position = getEventPosition(); const event = events[position]; if (event == null) { @@ -386,7 +389,7 @@ export class Scenario { const node = getNodeForReference(this.instanceRoot, reference); if (node == null) { - throw new Error(`No "answer" node for reference: ${reference}`); + throw new Error(`No instance node for reference: ${reference}`); } return node; diff --git a/packages/scenario/test/repeat.test.ts b/packages/scenario/test/repeat.test.ts index d5b76a1c..fa867a23 100644 --- a/packages/scenario/test/repeat.test.ts +++ b/packages/scenario/test/repeat.test.ts @@ -2068,133 +2068,6 @@ describe('Tests ported from JavaRosa - repeats', () => { }); }); - describe('IndexedRepeatRelativeRefsTest.java', () => { - const ABSOLUTE_TARGET = '/data/some-group/item/value'; - const RELATIVE_TARGET = '../item/value'; - const ABSOLUTE_GROUP = '/data/some-group/item'; - const RELATIVE_GROUP = '../item'; - const ABSOLUTE_INDEX = '/data/total-items'; - const RELATIVE_INDEX = '../../total-items'; - - interface IndexedRepeatRelativeRefsOptions { - readonly testName: string; - readonly target: string; - readonly group: string; - readonly index: string; - } - - const parameters: readonly IndexedRepeatRelativeRefsOptions[] = [ - { - testName: 'Target: absolute, group: absolute, index: absolute', - target: ABSOLUTE_TARGET, - group: ABSOLUTE_GROUP, - index: ABSOLUTE_INDEX, - }, - { - testName: 'Target: absolute, group: absolute, index: relative', - target: ABSOLUTE_TARGET, - group: ABSOLUTE_GROUP, - index: RELATIVE_INDEX, - }, - { - testName: 'Target: absolute, group: relative, index: absolute', - target: ABSOLUTE_TARGET, - group: RELATIVE_GROUP, - index: ABSOLUTE_INDEX, - }, - { - testName: 'Target: absolute, group: relative, index: relative', - target: ABSOLUTE_TARGET, - group: RELATIVE_GROUP, - index: RELATIVE_INDEX, - }, - { - testName: 'Target: relative, group: absolute, index: absolute', - target: RELATIVE_TARGET, - group: ABSOLUTE_GROUP, - index: ABSOLUTE_INDEX, - }, - { - testName: 'Target: relative, group: absolute, index: relative', - target: RELATIVE_TARGET, - group: ABSOLUTE_GROUP, - index: RELATIVE_INDEX, - }, - { - testName: 'Target: relative, group: relative, index: absolute', - target: RELATIVE_TARGET, - group: RELATIVE_GROUP, - index: ABSOLUTE_INDEX, - }, - { - testName: 'Target: relative, group: relative, index: relative', - target: RELATIVE_TARGET, - group: RELATIVE_GROUP, - index: RELATIVE_INDEX, - }, - ]; - - /** - * **PORTING NOTES** - * - * - Fails pending implementation of `indexed-repeat` XPath function. - * - * - Parameters adapted to match values in JavaRosa. Note that the - * parameters are passed as {@link options} rather than destructured. Java - * lets you reference `group` (the class property) and `group` (the - * imported static method) in the same scope. TypeScript/JavaScript don't - * let you do that... which is fine, because doing that is really weird! - */ - it.fails.each(parameters)('$testName', async (options) => { - const scenario = await Scenario.init( - 'Some form', - html( - head( - title('Some form'), - model( - mainInstance( - t( - 'data id="some-form"', - t('some-group', t('item jr:template=""', t('value')), t('last-value')), - t('total-items') - ) - ), - bind(ABSOLUTE_TARGET).type('int'), - bind('/data/total-items').type('int').calculate('count(/data/some-group/item)'), - bind('/data/some-group/last-value') - .type('int') - .calculate( - 'indexed-repeat(' + - options.target + - ', ' + - options.group + - ', ' + - options.index + - ')' - ) - ) - ), - body( - group( - '/data/some-group', - group( - '/data/some-group/item', - repeat('/data/some-group/item', input('/data/some-group/item/value')) - ) - ) - ) - ) - ); - - scenario.answer('/data/some-group[1]/item[1]/value', 11); - scenario.answer('/data/some-group[1]/item[2]/value', 22); - scenario.answer('/data/some-group[1]/item[3]/value', 33); - - expect(scenario.answerOf('/data/total-items')).toEqualAnswer(intAnswer(3)); - expect(scenario.answerOf('/data/some-group/last-value')).toEqualAnswer(intAnswer(33)); - }); - }); - describe('TriggersForRelativeRefsTest.java (regression tests)', () => { describe('indefinite repeat `jr:count` expression', () => { /** diff --git a/packages/scenario/test/smoketests/child-vaccination.test.ts b/packages/scenario/test/smoketests/child-vaccination.test.ts index 692a05a2..b5f6c2dd 100644 --- a/packages/scenario/test/smoketests/child-vaccination.test.ts +++ b/packages/scenario/test/smoketests/child-vaccination.test.ts @@ -12,6 +12,17 @@ import type { } from '../../src/jr/event/getPositionalEvents.ts'; import { JRTreeReference as BaseJRTreeReference } from '../../src/jr/xpath/JRTreeReference.ts'; +/** + * Intentionally naive. The scope of expressions we expect to handle in this + * test suite is narrow enough that we don't need to do more robust static + * analysis and serialization. There is more complete logic in + * `@getodk/xforms-engine` to do this, but it's intentionally project-private + * (at least it is at time of writing). + */ +const naiveStripPositionalPredicates = (expression: string): string => { + return expression.replaceAll(/\[\d+\]/g, ''); +}; + /** * **PORTING NOTES** * @@ -54,7 +65,9 @@ const refSingletons = new UpsertableMap(); class JRTreeReference extends BaseJRTreeReference { genericize(): JRTreeReference { - throw new IncompleteTestPortError('TreeReference.genericize'); + const reference = naiveStripPositionalPredicates(this.xpathReference); + + return new JRTreeReference(reference); } equals(other: JRTreeReference): boolean { @@ -123,6 +136,24 @@ class Scenario extends BaseScenario { /* eslint-enable no-console */ } + private getNextEventPosition(): AnyPositionalEvent { + const currentPosition = this.getSelectedPositionalEvent(); + + if (currentPosition.eventType === 'END_OF_FORM') { + throw 'todo'; + } + + const events = this.getPositionalEvents(); + const position = this.getEventPosition(); + const next = events[position + 1]; + + if (next == null) { + throw new Error(`No question at position: ${position}`); + } + + return next; + } + /** * @deprecated * @@ -142,7 +173,13 @@ class Scenario extends BaseScenario { * increased clarity of intent. */ nextRef(): JRTreeReference { - throw new IncompleteTestPortError('Extended Scenario.nextRef'); + const next = this.getNextEventPosition(); + + if (next.eventType === 'END_OF_FORM') { + throw 'todo'; + } + + return new JRTreeReference(next.node.currentState.reference); } /** @@ -549,42 +586,32 @@ describe('ChildVaccinationTest.java', () => { }); it.fails('[smoke test]', async () => { - let scenario: Scenario | null = null; + const scenario = await Scenario.init('child_vaccination_VOL_tool_v12.xml'); - try { - scenario = await Scenario.init('child_vaccination_VOL_tool_v12.xml'); - - expect.fail('Update `child-vaccination.test.ts`, known failure mode has changed'); - } catch (error) { - expect(error).toBeInstanceOf(Error); + scenario.next('/data/building_type'); + scenario.answer('multi'); + scenario.next('/data/not_single'); + scenario.next('/data/not_single/gps'); + scenario.answer('1.234 5.678'); + scenario.next('/data/building_name'); + scenario.answer('Some building'); + scenario.next('/data/full_address1'); + scenario.answer('Some address, some location'); - // Failure of this assertion likely means that we've implemented the - // `indexed-repeat` XPath function. When that occurs, these error - // condition assertions should be removed, and the `Scenario.init` call - // should be treated normally. - // - // If a new failure occurs after that point, and that failure cannot be - // addressed by updating the test to be more complete (e.g. by specifying - // more of the expected references in `scenario.next` calls, or - // implementing other aspects of the test which are currently deferred), - // consider adding a similar function/assertion/comment to this effect, - // asserting that new known failure condition and prompting the test to be - // updated again once it is resolved. - expect((error as Error).message).toContain('function not defined: indexed-repeat'); - } + // endregion - if (scenario == null) { - return; - } + const currentExpectedPointOfFailure = () => { + expect(scenario.answerOf('/data/household_count').toString()).toBe('2'); - scenario.next('/data/building_type'); + const secondHouseholdControlledByRepeatCount = scenario.getInstanceNode('/data/household[2]'); - const currentExpectedPointOfFailure = () => { - scenario.answer('multi'); + expect(secondHouseholdControlledByRepeatCount).not.toBeNull(); }; try { - expect(currentExpectedPointOfFailure).toThrowError('function not defined: indexed-repeat'); + expect(currentExpectedPointOfFailure).toThrowError( + 'No instance node for reference: /data/household[2]' + ); if (typeof currentExpectedPointOfFailure === 'function') { scenario.trace( @@ -593,20 +620,21 @@ describe('ChildVaccinationTest.java', () => { return; } - } catch { - throw new Error(); - } + } catch (error) { + // Failure of this assertion likely means that we've implemented + // `jr:count`. When that occurs, these error condition assertions should + // be removed, and the test should be updated to add expected node-set + // reference assertions in the `scenario.next` calls, and so on, either + // until the test passes or a new known point of failure is identified. - scenario.answer('multi'); - scenario.next('/data/not_single'); - scenario.next('/data/not_single/gps'); - scenario.answer('1.234 5.678'); - scenario.next('/data/building_name'); - scenario.answer('Some building'); - scenario.next('/data/full_address1'); - scenario.answer('Some address, some location'); + expect(error).toBeInstanceOf(Error); - // endregion + const { message } = error as Error; + + expect.fail( + `Update \`child-vaccination.test.ts\`, known failure mode has changed: ${message}` + ); + } // region Answer all household repeats diff --git a/packages/scenario/test/xpath/functions/indexed-repeat.test.ts b/packages/scenario/test/xpath/functions/indexed-repeat.test.ts new file mode 100644 index 00000000..f1cce9cd --- /dev/null +++ b/packages/scenario/test/xpath/functions/indexed-repeat.test.ts @@ -0,0 +1,631 @@ +import { + bind, + body, + group, + head, + html, + input, + mainInstance, + model, + repeat, + t, + title, +} from '@getodk/common/test/fixtures/xform-dsl/index.ts'; +import { assert, beforeEach, describe, expect, it } from 'vitest'; +import { intAnswer } from '../../../src/answer/ExpectedIntAnswer.ts'; +import { stringAnswer } from '../../../src/answer/ExpectedStringAnswer.ts'; +import { Scenario } from '../../../src/jr/Scenario.ts'; + +describe('Tests ported from JavaRosa', () => { + describe('IndexedRepeatRelativeRefsTest.java', () => { + const ABSOLUTE_TARGET = '/data/some-group/item/value'; + const RELATIVE_TARGET = '../item/value'; + const ABSOLUTE_GROUP = '/data/some-group/item'; + const RELATIVE_GROUP = '../item'; + const ABSOLUTE_INDEX = '/data/total-items'; + const RELATIVE_INDEX = '../../total-items'; + + interface IndexedRepeatRelativeRefsOptions { + readonly testName: string; + readonly target: string; + readonly group: string; + readonly index: string; + } + + const parameters: readonly IndexedRepeatRelativeRefsOptions[] = [ + { + testName: 'Target: absolute, group: absolute, index: absolute', + target: ABSOLUTE_TARGET, + group: ABSOLUTE_GROUP, + index: ABSOLUTE_INDEX, + }, + { + testName: 'Target: absolute, group: absolute, index: relative', + target: ABSOLUTE_TARGET, + group: ABSOLUTE_GROUP, + index: RELATIVE_INDEX, + }, + { + testName: 'Target: absolute, group: relative, index: absolute', + target: ABSOLUTE_TARGET, + group: RELATIVE_GROUP, + index: ABSOLUTE_INDEX, + }, + { + testName: 'Target: absolute, group: relative, index: relative', + target: ABSOLUTE_TARGET, + group: RELATIVE_GROUP, + index: RELATIVE_INDEX, + }, + { + testName: 'Target: relative, group: absolute, index: absolute', + target: RELATIVE_TARGET, + group: ABSOLUTE_GROUP, + index: ABSOLUTE_INDEX, + }, + { + testName: 'Target: relative, group: absolute, index: relative', + target: RELATIVE_TARGET, + group: ABSOLUTE_GROUP, + index: RELATIVE_INDEX, + }, + { + testName: 'Target: relative, group: relative, index: absolute', + target: RELATIVE_TARGET, + group: RELATIVE_GROUP, + index: ABSOLUTE_INDEX, + }, + { + testName: 'Target: relative, group: relative, index: relative', + target: RELATIVE_TARGET, + group: RELATIVE_GROUP, + index: RELATIVE_INDEX, + }, + ]; + + /** + * **PORTING NOTES** + * + * - Fails pending implementation of `indexed-repeat` XPath function. + * + * - Parameters adapted to match values in JavaRosa. Note that the + * parameters are passed as {@link options} rather than destructured. Java + * lets you reference `group` (the class property) and `group` (the + * imported static method) in the same scope. TypeScript/JavaScript don't + * let you do that... which is fine, because doing that is really weird! + * + * - Includes proposed explicit repeat creation. + * + * - `answer` calls updated to omit superfluous position predicate on + * the non-repeat `some-group` step (we do this lookup by `reference`, + * not evaluating arbitrary XPath expressions to identify the question + * being answered). + */ + it.each(parameters)('$testName', async (options) => { + const scenario = await Scenario.init( + 'Some form', + html( + head( + title('Some form'), + model( + mainInstance( + t( + 'data id="some-form"', + t('some-group', t('item jr:template=""', t('value')), t('last-value')), + t('total-items') + ) + ), + bind(ABSOLUTE_TARGET).type('int'), + bind('/data/total-items').type('int').calculate('count(/data/some-group/item)'), + bind('/data/some-group/last-value') + .type('int') + .calculate( + 'indexed-repeat(' + + options.target + + ', ' + + options.group + + ', ' + + options.index + + ')' + ) + ) + ), + body( + group( + '/data/some-group', + group( + '/data/some-group/item', + repeat('/data/some-group/item', input('/data/some-group/item/value')) + ) + ) + ) + ) + ); + + scenario.proposed_addExplicitCreateNewRepeatCallHere('/data/some-group/item', { + explicitRepeatCreation: true, + }); + // scenario.answer('/data/some-group[1]/item[1]/value', 11); + scenario.answer('/data/some-group/item[1]/value', 11); + + scenario.proposed_addExplicitCreateNewRepeatCallHere('/data/some-group/item', { + explicitRepeatCreation: true, + }); + // scenario.answer('/data/some-group[1]/item[2]/value', 22); + scenario.answer('/data/some-group/item[2]/value', 22); + + scenario.proposed_addExplicitCreateNewRepeatCallHere('/data/some-group/item', { + explicitRepeatCreation: true, + }); + // scenario.answer('/data/some-group[1]/item[3]/value', 33); + scenario.answer('/data/some-group/item[3]/value', 33); + + expect(scenario.answerOf('/data/total-items')).toEqualAnswer(intAnswer(3)); + expect(scenario.answerOf('/data/some-group/last-value')).toEqualAnswer(intAnswer(33)); + }); + }); + + // https://github.com/getodk/javarosa/pull/776 + describe('JavaRosa draft PR: "Add indexed-repeat tests"', () => { + /** + * **PORTING NOTES** + * + * - Test is ported to use a more general error check, and assertion + * messaging is updated accordingly. Error message is asserted **not to + * be** the error thrown by `@getodk/xpath` for missing function support, + * so we start with an expected failure throughout the suite. + * + * - Bind for `calc` is updated to reference `/data/calc`, as is clearly + * intended in the original draft PR. + * + * - Failure is due to function implementation in `@getodk/xpath`, which has + * little if any concept of repeats or what "is" a repeat. Unclear if this + * failure is meaningful for users, or if the test is mostly demonstrating + * that aspect of JavaRosa implementation detail. + * + * JR: firstArgNotChildOfRepeat_throwsException + */ + describe('first argument not child of a repeat', () => { + it.fails('produces an error', async () => { + let caught: unknown; + + try { + // prettier-ignore + await Scenario.init('indexed-repeat', html( + head( + title('indexed-repeat'), + model( + mainInstance(t('data id="indexed-repeat"', + t('outside'), + t('repeat', + t('inside')), + t('calc') + )), + bind('/data/calc') + .calculate('indexed-repeat(/data/outside, /data/repeat, 1)') + ) + ), + body( + input('/data/outside'), + repeat('/data/repeat', + input('/data/repeat/inside')) + )) + ); + } catch (error) { + caught = error; + } + + assert(caught instanceof Error); + expect(caught.message).not.toContain('function not defined: indexed-repeat'); + }); + }); + + /** + * **PORTING NOTES** + * + * - Removes superfluous position predicates on `outer_group`. + * + * JR: getsIndexedValueInSingleRepeat + */ + it('gets an indexed value in a single repeat instance', async () => { + // prettier-ignore + const scenario = await Scenario.init('indexed-repeat', html( + head( + title('indexed-repeat'), + model( + mainInstance(t('data id="indexed-repeat"', + t('index'), + t('outer_group', // included to clarify intended evaluation context for index references + t('repeat', + t('inside'))), + t('calc') + )), + bind('/data/calc').calculate('indexed-repeat(/data/outer_group/repeat/inside, /data/outer_group/repeat, ../index)') + ) + ), + body( + input('/data/index'), + group('/data/outer_group', + repeat('/data/outer_group/repeat', + input('/data/outer_group/repeat/inside'))) + )) + ); + + // scenario.createNewRepeat('/data/outer_group[1]/repeat'); + scenario.createNewRepeat('/data/outer_group/repeat'); + // scenario.answer('/data/outer_group[1]/repeat[1]/inside', 'index1'); + scenario.answer('/data/outer_group/repeat[1]/inside', 'index1'); + + // scenario.createNewRepeat('/data/outer_group[1]/repeat'); + scenario.createNewRepeat('/data/outer_group/repeat'); + // scenario.answer('/data/outer_group[1]/repeat[2]/inside', 'index2'); + scenario.answer('/data/outer_group/repeat[2]/inside', 'index2'); + + // scenario.createNewRepeat('/data/outer_group[1]/repeat'); + scenario.createNewRepeat('/data/outer_group/repeat'); + // scenario.answer('/data/outer_group[1]/repeat[3]/inside', 'index3'); + scenario.answer('/data/outer_group/repeat[3]/inside', 'index3'); + + scenario.answer('/data/index', '2'); + + expect(scenario.answerOf('/data/calc')).toEqualAnswer(stringAnswer('index2')); + + scenario.answer('/data/index', '1'); + + expect(scenario.answerOf('/data/calc')).toEqualAnswer(stringAnswer('index1')); + }); + + /** + * **PORTING NOTES** + * + * - Updated body reference to `/data/repeat2/inside2`, as seems the likely + * intent for that input. (`xforms-engine` otherwise fails to parse the + * form when it encounters an input with the same reference as its parent + * repeat element) + * + * JR: getsIndexedValueUsingParallelRepeatPosition + */ + it('gets an indexed value using parallel repeat position', async () => { + // prettier-ignore + const scenario = await Scenario.init('indexed-repeat', html( + head( + title('indexed-repeat'), + model( + mainInstance( + t('data id="indexed-repeat"', + t('repeat1', + t('inside1')), + + t('repeat2', + t('inside2'), + t('from_repeat1')) + ) + ), + + bind('/data/repeat2/from_repeat1') + .calculate('indexed-repeat(/data/repeat1/inside1, /data/repeat1, position(..))') + ) + ), + body( + repeat('/data/repeat1', + input('/data/repeat1/inside1')), + + repeat('/data/repeat2', + input('/data/repeat2/inside2')) + )) + ); + + scenario.createNewRepeat('/data/repeat1'); + scenario.createNewRepeat('/data/repeat2'); + scenario.answer('/data/repeat1[1]/inside1', 'index1'); + + scenario.createNewRepeat('/data/repeat1'); + scenario.createNewRepeat('/data/repeat2'); + scenario.answer('/data/repeat1[2]/inside1', 'index2'); + + scenario.createNewRepeat('/data/repeat1'); + scenario.createNewRepeat('/data/repeat2'); + scenario.answer('/data/repeat1[3]/inside1', 'index3'); + + expect(scenario.answerOf('/data/repeat2[1]/from_repeat1')).toEqualAnswer( + stringAnswer('index1') + ); + expect(scenario.answerOf('/data/repeat2[2]/from_repeat1')).toEqualAnswer( + stringAnswer('index2') + ); + }); + }); +}); + +describe('Nested repeats', () => { + let scenario: Scenario; + + beforeEach(async () => { + // prettier-ignore + scenario = await Scenario.init('indexed-repeat', html( + head( + title('indexed-repeat'), + model( + mainInstance(t('data id="indexed-repeat"', + t('r1-d1 jr:template=""', + t('inside-r1-d1'), + t('r1-d2 jr:template=""', + t('inside-r1-d2'), + t('r1-d3 jr:template=""', + t('inside-r1-d3')))), + + t('r2-d1 jr:template=""', + t('inside-r2-d1'), + t('from-r1-d1'), + t('r2-d2 jr:template=""', + t('inside-r2-d2'), + t('from-r1-d2-a'), + t('from-r1-d2-b'), + t('r2-d3 jr:template=""', + t('inside-r2-d3'), + t('from-r1-d3-a'), + t('from-r1-d3-b')))) + )), + bind('/data/r1-d1/inside-r1-d1') + .calculate("concat('[', position(..), ']')"), + bind('/data/r1-d1/r1-d2/inside-r1-d2') + .calculate("concat('[', position(../..), ']', '[', position(..), ']')"), + bind('/data/r1-d1/r1-d2/r1-d3/inside-r1-d3') + .calculate("concat('[', position(../../..), ']', '[', position(../..), ']', '[', position(..), ']')"), + bind('/data/r2-d1/from-r1-d1') + .calculate('indexed-repeat(/data/r1-d1/inside-r1-d1, /data/r1-d1, position(..))'), + bind('/data/r2-d1/r2-d2/from-r1-d2-a') + .calculate('indexed-repeat(/data/r1-d1/r1-d2/inside-r1-d2, /data/r1-d1, position(../..), /data/r1-d1/r1-d2, position(..))'), + bind('/data/r2-d1/r2-d2/from-r1-d2-b') + // Same as from-r1-d2-a with the repeatN/indexN pairs swapped + .calculate('indexed-repeat(/data/r1-d1/r1-d2/inside-r1-d2, /data/r1-d1/r1-d2, position(..), /data/r1-d1, position(../..))'), + bind('/data/r2-d1/r2-d2/r2-d3/from-r1-d3-a') + .calculate('indexed-repeat(/data/r1-d1/r1-d2/r1-d3/inside-r1-d3, /data/r1-d1, position(../../..), /data/r1-d1/r1-d2, position(../..), /data/r1-d1/r1-d2/r1-d3, position(..))'), + bind('/data/r2-d1/r2-d2/r2-d3/from-r1-d3-b') + // Same as from-r1-d3-a with the repeatN/indexN pairs reordered + .calculate('indexed-repeat(/data/r1-d1/r1-d2/r1-d3/inside-r1-d3, /data/r1-d1/r1-d2, position(../..), /data/r1-d1, position(../../..), /data/r1-d1/r1-d2/r1-d3, position(..))') + ) + ), + body( + repeat('/data/r1-d1', + input('/data/r1-d1/inside-r1-d1'), + repeat('/data/r1-d1/r1-d2', + input('/data/r1-d1/r1-d2/inside-r1-d2'), + repeat('/data/r1-d1/r1-d2/r1-d3', + input('/data/r1-d1/r1-d2/r1-d3/inside-r1-d3')))), + + repeat('/data/r2-d1', + input('/data/r2-d1/inside-r2-d1'), + repeat('/data/r2-d1/r2-d2', + input('/data/r2-d1/r2-d2/inside-r2-d2'), + repeat('/data/r2-d1/r2-d2/r2-d3', + input('/data/r2-d1/r2-d2/r2-d3/inside-r2-d3')))) + ))); + + // Create two r1-d1 + scenario.createNewRepeat('/data/r1-d1'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r1-d1[1]'); + scenario.createNewRepeat('/data/r1-d1'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r1-d1[2]'); + + // For each r1-d1, create two r1-d2 + scenario.createNewRepeat('/data/r1-d1[1]/r1-d2'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r1-d1[1]/r1-d2[1]'); + scenario.createNewRepeat('/data/r1-d1[1]/r1-d2'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r1-d1[1]/r1-d2[2]'); + + scenario.createNewRepeat('/data/r1-d1[2]/r1-d2'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r1-d1[2]/r1-d2[1]'); + scenario.createNewRepeat('/data/r1-d1[2]/r1-d2'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r1-d1[2]/r1-d2[2]'); + + // For each r1-d2, create two r1-d3 + scenario.createNewRepeat('/data/r1-d1[1]/r1-d2[1]/r1-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r1-d1[1]/r1-d2[1]/r1-d3[1]'); + scenario.createNewRepeat('/data/r1-d1[1]/r1-d2[1]/r1-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r1-d1[1]/r1-d2[1]/r1-d3[2]'); + + scenario.createNewRepeat('/data/r1-d1[1]/r1-d2[2]/r1-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r1-d1[1]/r1-d2[2]/r1-d3[1]'); + scenario.createNewRepeat('/data/r1-d1[1]/r1-d2[2]/r1-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r1-d1[1]/r1-d2[2]/r1-d3[2]'); + + scenario.createNewRepeat('/data/r1-d1[2]/r1-d2[1]/r1-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r1-d1[2]/r1-d2[1]/r1-d3[1]'); + scenario.createNewRepeat('/data/r1-d1[2]/r1-d2[1]/r1-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r1-d1[2]/r1-d2[1]/r1-d3[2]'); + + scenario.createNewRepeat('/data/r1-d1[2]/r1-d2[2]/r1-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r1-d1[2]/r1-d2[2]/r1-d3[1]'); + scenario.createNewRepeat('/data/r1-d1[2]/r1-d2[2]/r1-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r1-d1[2]/r1-d2[2]/r1-d3[2]'); + + // Create two r2-d1 + scenario.createNewRepeat('/data/r2-d1'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r2-d1[1]'); + scenario.createNewRepeat('/data/r2-d1'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r2-d1[2]'); + + // For each r2-d1, create two r1-d2 + scenario.createNewRepeat('/data/r2-d1[1]/r2-d2'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r2-d1[1]/r2-d2[1]'); + scenario.createNewRepeat('/data/r2-d1[1]/r2-d2'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r2-d1[1]/r2-d2[2]'); + + scenario.createNewRepeat('/data/r2-d1[2]/r2-d2'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r2-d1[2]/r2-d2[1]'); + scenario.createNewRepeat('/data/r2-d1[2]/r2-d2'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r2-d1[2]/r2-d2[2]'); + + // For each r2-d2, create two r2-d3 + scenario.createNewRepeat('/data/r2-d1[1]/r2-d2[1]/r2-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r2-d1[1]/r2-d2[1]/r2-d3[1]'); + scenario.createNewRepeat('/data/r2-d1[1]/r2-d2[1]/r2-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r2-d1[1]/r2-d2[1]/r2-d3[2]'); + + scenario.createNewRepeat('/data/r2-d1[1]/r2-d2[2]/r2-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r2-d1[1]/r2-d2[2]/r2-d3[1]'); + scenario.createNewRepeat('/data/r2-d1[1]/r2-d2[2]/r2-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r2-d1[1]/r2-d2[2]/r2-d3[2]'); + + scenario.createNewRepeat('/data/r2-d1[2]/r2-d2[1]/r2-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r2-d1[2]/r2-d2[1]/r2-d3[1]'); + scenario.createNewRepeat('/data/r2-d1[2]/r2-d2[1]/r2-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r2-d1[2]/r2-d2[1]/r2-d3[2]'); + + scenario.createNewRepeat('/data/r2-d1[2]/r2-d2[2]/r2-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r2-d1[2]/r2-d2[2]/r2-d3[1]'); + scenario.createNewRepeat('/data/r2-d1[2]/r2-d2[2]/r2-d3'); + expect(scenario.refAtIndex().xpathReference).toBe('/data/r2-d1[2]/r2-d2[2]/r2-d3[2]'); + }); + + it('handles top-level repeats', () => { + expect(scenario.answerOf('/data/r2-d1[1]/from-r1-d1')).toEqualAnswer(stringAnswer('[1]')); + expect(scenario.answerOf('/data/r2-d1[2]/from-r1-d1')).toEqualAnswer(stringAnswer('[2]')); + }); + + it.each([{ calculatedField: 'from-r1-d2-a' }, { calculatedField: 'from-r1-d2-b' }])( + 'handles repeats two deep (field: $calculatedField)', + ({ calculatedField }) => { + expect(scenario.answerOf(`/data/r2-d1[1]/r2-d2[1]/${calculatedField}`)).toEqualAnswer( + stringAnswer('[1][1]') + ); + expect(scenario.answerOf(`/data/r2-d1[1]/r2-d2[2]/${calculatedField}`)).toEqualAnswer( + stringAnswer('[1][2]') + ); + expect(scenario.answerOf(`/data/r2-d1[2]/r2-d2[1]/${calculatedField}`)).toEqualAnswer( + stringAnswer('[2][1]') + ); + expect(scenario.answerOf(`/data/r2-d1[2]/r2-d2[2]/${calculatedField}`)).toEqualAnswer( + stringAnswer('[2][2]') + ); + } + ); + + it.each([{ calculatedField: 'from-r1-d3-a' }, { calculatedField: 'from-r1-d3-b' }])( + 'handles repeats three deep (field: $calculatedField)', + ({ calculatedField }) => { + expect( + scenario.answerOf(`/data/r2-d1[1]/r2-d2[1]/r2-d3[1]/${calculatedField}`) + ).toEqualAnswer(stringAnswer('[1][1][1]')); + expect( + scenario.answerOf(`/data/r2-d1[1]/r2-d2[1]/r2-d3[2]/${calculatedField}`) + ).toEqualAnswer(stringAnswer('[1][1][2]')); + expect( + scenario.answerOf(`/data/r2-d1[1]/r2-d2[2]/r2-d3[1]/${calculatedField}`) + ).toEqualAnswer(stringAnswer('[1][2][1]')); + expect( + scenario.answerOf(`/data/r2-d1[1]/r2-d2[2]/r2-d3[2]/${calculatedField}`) + ).toEqualAnswer(stringAnswer('[1][2][2]')); + expect( + scenario.answerOf(`/data/r2-d1[2]/r2-d2[1]/r2-d3[1]/${calculatedField}`) + ).toEqualAnswer(stringAnswer('[2][1][1]')); + expect( + scenario.answerOf(`/data/r2-d1[2]/r2-d2[1]/r2-d3[2]/${calculatedField}`) + ).toEqualAnswer(stringAnswer('[2][1][2]')); + expect( + scenario.answerOf(`/data/r2-d1[2]/r2-d2[2]/r2-d3[1]/${calculatedField}`) + ).toEqualAnswer(stringAnswer('[2][2][1]')); + expect( + scenario.answerOf(`/data/r2-d1[2]/r2-d2[2]/r2-d3[2]/${calculatedField}`) + ).toEqualAnswer(stringAnswer('[2][2][2]')); + } + ); +}); + +/** + * **SPEC NOTES** + * + * This suite explicitly tests the fact that the `indexed-repeat` implementation + * returns a node-set. In review, we discussed the fact that this goes beyond + * the + * {@link https://getodk.github.io/xforms-spec/#fn:indexed-repeat | ODK XForms spec language}, + * which currently says that `indexed-repeat`... + * + * > Returns a single node value from a node-set by selecting the 1-based index + * > of a repeat node-set that this node is a child of. + * + * In the review discussion, a case was made that the mechanism for producing + * this "single node value" would be casting the expression's LocationPath + * equivalent to string. There was some doubt about whether this is expected, + * based on the behavior of other XPath functions operating on a node-set (such + * as `count`) or with distinct behavior for node-sets and strings respectively + * (such as `concat`). + * + * These same tests have been run in JavaRosa, confirming that the behavior is + * consistent between both implementations. As such, we've decided to keep this + * consistent behavior, and to update the spec language accordingly. These tests + * further capture the fact that this decision is intentional. + */ +describe('Behavior of node-set results', () => { + let scenario: Scenario; + + beforeEach(async () => { + // prettier-ignore + scenario = await Scenario.init('indexed-repeat', html( + head( + title('indexed-repeat'), + model( + mainInstance( + t('data id="indexed-repeat"', + t('repeat1 jr:template=""', + t('repeat2 jr:template=""', + t('value'))), + t('indexed-repeat-concat'), + t('indexed-repeat-count'), + t('meta', + t('instanceID')))), + + bind('/data/repeat1/repeat2/value').type('string'), + bind('/data/indexed-repeat-concat') + .readonly('true()') + .type('string') + .calculate('concat(indexed-repeat( /data/repeat1/repeat2/value , /data/repeat1 , 1))'), + bind('/data/indexed-repeat-count') + .readonly('true()') + .type('string') + .calculate('count(indexed-repeat( /data/repeat1/repeat2/value , /data/repeat1 , 1))') + ) + ), + body( + group('/data/repeat1', + repeat('/data/repeat1', + group('/data/repeat1/repeat2', + repeat('/data/repeat1/repeat2', + input('/data/repeat1/repeat2/value'))))), + input('/data/indexed-repeat-concat'), + input('/data/indexed-repeat-count') + ))); + }); + + it('includes multiple target nodes in a node-set', () => { + scenario.createNewRepeat('/data/repeat1'); + scenario.createNewRepeat('/data/repeat1[1]/repeat2'); + scenario.answer('/data/repeat1[1]/repeat2[1]/value', 'a'); + scenario.createNewRepeat('/data/repeat1[1]/repeat2'); + scenario.answer('/data/repeat1[1]/repeat2[2]/value', 'b'); + + expect(scenario.answerOf('/data/indexed-repeat-concat')).toEqualAnswer(stringAnswer('ab')); + expect(scenario.answerOf('/data/indexed-repeat-count')).toEqualAnswer(intAnswer(2)); + }); + + it('excludes target nodes outside the indexed-repeat subtree', () => { + scenario.createNewRepeat('/data/repeat1'); + scenario.createNewRepeat('/data/repeat1[1]/repeat2'); + scenario.answer('/data/repeat1[1]/repeat2[1]/value', 'a'); + scenario.createNewRepeat('/data/repeat1[1]/repeat2'); + scenario.answer('/data/repeat1[1]/repeat2[2]/value', 'b'); + + // Add second repeat1 and subtree, structurally matching the first. We'll + // check that nothing added here is considered (since the `indexed-repeat` + // calls are hardcoded to index 1). + scenario.createNewRepeat('/data/repeat1'); + scenario.createNewRepeat('/data/repeat1[2]/repeat2'); + scenario.answer('/data/repeat1[2]/repeat2[1]/value', 'a'); + scenario.createNewRepeat('/data/repeat1[2]/repeat2'); + scenario.answer('/data/repeat1[2]/repeat2[2]/value', 'b'); + + expect(scenario.answerOf('/data/indexed-repeat-concat')).toEqualAnswer(stringAnswer('ab')); + expect(scenario.answerOf('/data/indexed-repeat-count')).toEqualAnswer(intAnswer(2)); + }); +}); diff --git a/packages/xpath/src/functions/xforms/node-set.ts b/packages/xpath/src/functions/xforms/node-set.ts index 066360b4..464489b0 100644 --- a/packages/xpath/src/functions/xforms/node-set.ts +++ b/packages/xpath/src/functions/xforms/node-set.ts @@ -1,13 +1,15 @@ import { UpsertableWeakMap } from '@getodk/common/lib/collections/UpsertableWeakMap.ts'; import { ScopedElementLookup } from '@getodk/common/lib/dom/compatibility.ts'; import type { LocalNamedElement } from '@getodk/common/types/dom.ts'; +import type { Evaluation } from '../../evaluations/Evaluation.ts'; import { LocationPathEvaluation } from '../../evaluations/LocationPathEvaluation.ts'; +import type { EvaluableArgument } from '../../evaluator/functions/FunctionImplementation.ts'; import { NodeSetFunction } from '../../evaluator/functions/NodeSetFunction.ts'; import { NumberFunction } from '../../evaluator/functions/NumberFunction.ts'; import { StringFunction } from '../../evaluator/functions/StringFunction.ts'; import { XFormsXPathEvaluator } from '../../index.ts'; import { seededRandomize } from '../../lib/collections/sort.ts'; -import type { MaybeElementNode } from '../../lib/dom/types.ts'; +import type { ContextNode, MaybeElementNode } from '../../lib/dom/types.ts'; import type { ModelElement } from '../../xforms/XFormsXPathEvaluator.ts'; export const countNonEmpty = new NumberFunction( @@ -32,6 +34,227 @@ export const countNonEmpty = new NumberFunction( } ); +type AssertArgument = (index: number, arg?: EvaluableArgument) => asserts arg is EvaluableArgument; + +const assertArgument: AssertArgument = (index, arg) => { + if (arg == null) { + throw new Error(`Argument ${index + 1} expected`); + } +}; + +type AssertIsLocationPathEvaluation = ( + evaluation?: Evaluation +) => asserts evaluation is LocationPathEvaluation; + +/** + * @todo This is a concern in several `FunctionImplementation`s. It would be + * much nicer if it were handled as part of the signature, then inferred in the + * types and validated automatically at runtime. It would also make sense, as a + * minor stopgap improvement, to generalize checks like this in a single place + * (e.g. as a static method on {@link LocationPathEvaluation} itself). Deferred + * here because there is exploratory work on both, but both are out of scope for + * work in progress to support {@link indexedRepeat}. + */ +const assertIsLocationPathEvaluation: AssertIsLocationPathEvaluation = (evaluation) => { + if (!(evaluation instanceof LocationPathEvaluation)) { + throw new Error('Expected a node-set result'); + } +}; + +/** + * Note: this function is not intended to be general outside of usage by + * {@link indexedRepeat}. + * + * Evaluation of the provided argument is eager—i.e. materializing the complete + * array of results, rather than the typical `Iterable` produced in + * most cases—because it is expected that in most cases the eagerness will not + * be terribly expensive, and all results will usually be consumed, either to be + * indexed or filtered in other ways applicable at call sites. + * + * Function is named to reflect that expectation. + */ +const evaluateArgumentToFilterableNodes = ( + context: LocationPathEvaluation, + arg: EvaluableArgument +): readonly ContextNode[] => { + const evaluation = arg.evaluate(context); + + assertIsLocationPathEvaluation(evaluation); + + return Array.from(evaluation.contextNodes); +}; + +interface EvaluatedIndexedRepeatArgumentPair { + readonly repeats: readonly ContextNode[]; + readonly position: number; +} + +type DepthSortResult = -1 | 0 | 1; + +/** + * @todo This is **obviously cacheable**, but it would make most sense to cache + * it at the expression level (or at the expression + bound context node level). + * All of the expression analysis machinery is further up the stack (as it + * generally ought to be with current designs), but it would be nice to consider + * how we'd address caching with these kinds of dynamics at play. + */ +const compareContainmentDepth = ( + { repeats: a }: EvaluatedIndexedRepeatArgumentPair, + { repeats: b }: EvaluatedIndexedRepeatArgumentPair +): DepthSortResult => { + for (const repeatA of a) { + for (const repeatB of b) { + if (repeatA.contains(repeatB)) { + return -1; + } + + if (repeatB.contains(repeatA)) { + return 1; + } + } + } + + if (a.length === 0 || b.length === 0) { + return 0; + } + + // TODO: if we reach this point, there is no hierarchical relationship between + // the repeats in `repeatN` and `repeatN + M`. This seems to violate **at + // least the intent** of the spec. We should probably produce an error here? + return 0; +}; + +export const indexedRepeat = new NodeSetFunction( + 'indexed-repeat', + [ + // spec: arg + { arityType: 'required', typeHint: 'node' }, + // spec: repeat1 + { arityType: 'required', typeHint: 'node' }, + // spec: index1 + { arityType: 'required', typeHint: 'number' }, + // spec: repeatN=0 -> repeat2 + { arityType: 'optional', typeHint: 'node' }, + // spec: indexN=0 -> index2 + { arityType: 'optional', typeHint: 'number' }, + // spec: repeatN=1 -> repeat3 + { arityType: 'optional', typeHint: 'node' }, + // spec: indexN=1 -> index3 + { arityType: 'optional', typeHint: 'number' }, + + // Go beyond spec? Why the heck not! It's clearly a variadic design. + { arityType: 'variadic', typeHint: 'any' }, + ], + (context, args) => { + // First argument is `target` (per spec) of the deepest resolved repeat + const target = args[0]!; + + let pairs: EvaluatedIndexedRepeatArgumentPair[] = []; + + // Iterate through rest of arguments, collecting pairs of: + // + // - `repeats`: **all** nodes matching the supplied node-set for the + // `repeatN` argument in this pair + // - `position`: the resolved number value for the `indexN` (per spec) + // argument at in this pair + // + // For **all `repeatN`/`indexN` pairs**, arguments are evaluated in the + // calling context (in typical XForms usage, this will be the context of the + // bound node). This is the core difference between this approach and the + // original in https://github.com/getodk/web-forms/pull/150. That + // understanding was clarified in review of that orignal effort, and is + // borne out by new tests exercising depth > 1, which demonstrate the same + // behavior in JavaRosa. + // + // Note: we start iterating here at index 1 so assertions related to + // positional argument index are clear. + for (let i = 1; i < args.length; i += 2) { + const repeatsArg = args[i]; + const positionArg = args[i + 1]; + + assertArgument(i, repeatsArg); + assertArgument(i + 1, positionArg); + + // Evaluate position first, because... + const position = positionArg.evaluate(context).toNumber(); + + // ... if any "index" (position) is `NaN`, we short-circuit. This is + // expected behavior because the equivalent `/data/repN[posN]/target` + // expression would do the same. + if (Number.isNaN(position)) { + return []; + } + + // Reiterating the point made describing this loop for future clarity: + // this collects **all** of the nodes matching the `repeatN` expression. + // We filter them in a later step. + const repeats = evaluateArgumentToFilterableNodes(context, repeatsArg); + + // No repeats = nothing to "index" = short circuit + if (repeats.length === 0) { + return []; + } + + pairs.push({ + repeats, + position, + }); + } + + // Sort the results of each `repeatN`/`indexN` pair, by containment order. + // + // Note: the `repeatN`/`indexN` pairs can be supplied in any order (this is + // consistent with behavior in JavaRosa, likely as a side effect of the + // function being implemented there by transforming the expression to its + // LocationPath equivalent). + pairs = pairs.sort(compareContainmentDepth); + + // Resolve repeats at the specified/evaluated position, in document depth + // order by: + // + // 1. Filtering each set of repeats to include **only** the nodes contained + // by the previously resolved repeat (where one was resolved for a + // previous pair). + // + // 2. Selecting the repeat at the specified/evaluated position (of those + // filtered in 1). + let repeatContextNode: ContextNode; + + for (const [index, pair] of pairs.entries()) { + const { position } = pair; + + let { repeats } = pair; + + if (index > 0) { + repeats = pair.repeats.filter((repeat) => { + return repeatContextNode.contains(repeat); + }); + } + + // Select next repeat context at the current `repeatN`/`indexN` position. + // + // Note: despite terminology used in the spec, `indexN` is treated as + // equivalent to an XPath position predicate: it is 1-based. + const positionedRepeat = repeats[position - 1]; + + // No repeat context is found = nothing to target = short-circuit + if (positionedRepeat == null) { + return []; + } + + repeatContextNode = positionedRepeat; + } + + // Resolve **all** target nodes. + const targetNodes = evaluateArgumentToFilterableNodes(context, target); + + // Filter only the target nodes contained by the deepest repeat context node. + return targetNodes.filter((targetNode) => { + return repeatContextNode.contains(targetNode); + }); + } +); + interface InstanceElement extends LocalNamedElement<'instance'> {} const identifiedInstanceLookup = new ScopedElementLookup(':scope > instance[id]', 'instance[id]'); diff --git a/packages/xpath/test/native/attributes.spec.ts b/packages/xpath/test/native/attributes.test.ts similarity index 100% rename from packages/xpath/test/native/attributes.spec.ts rename to packages/xpath/test/native/attributes.test.ts diff --git a/packages/xpath/test/native/axis.spec.ts b/packages/xpath/test/native/axis.test.ts similarity index 100% rename from packages/xpath/test/native/axis.spec.ts rename to packages/xpath/test/native/axis.test.ts diff --git a/packages/xpath/test/native/basic-xpath.spec.ts b/packages/xpath/test/native/basic-xpath.test.ts similarity index 100% rename from packages/xpath/test/native/basic-xpath.spec.ts rename to packages/xpath/test/native/basic-xpath.test.ts diff --git a/packages/xpath/test/native/boolean.spec.ts b/packages/xpath/test/native/boolean.test.ts similarity index 100% rename from packages/xpath/test/native/boolean.spec.ts rename to packages/xpath/test/native/boolean.test.ts diff --git a/packages/xpath/test/native/ceiling.spec.ts b/packages/xpath/test/native/ceiling.test.ts similarity index 100% rename from packages/xpath/test/native/ceiling.spec.ts rename to packages/xpath/test/native/ceiling.test.ts diff --git a/packages/xpath/test/native/comparison-operator.spec.ts b/packages/xpath/test/native/comparison-operator.test.ts similarity index 100% rename from packages/xpath/test/native/comparison-operator.spec.ts rename to packages/xpath/test/native/comparison-operator.test.ts diff --git a/packages/xpath/test/native/comparison-operator2.spec.ts b/packages/xpath/test/native/comparison-operator2.test.ts similarity index 100% rename from packages/xpath/test/native/comparison-operator2.spec.ts rename to packages/xpath/test/native/comparison-operator2.test.ts diff --git a/packages/xpath/test/native/current.spec.ts b/packages/xpath/test/native/current.test.ts similarity index 100% rename from packages/xpath/test/native/current.spec.ts rename to packages/xpath/test/native/current.test.ts diff --git a/packages/xpath/test/native/expression-evaluation.spec.ts b/packages/xpath/test/native/expression-evaluation.test.ts similarity index 100% rename from packages/xpath/test/native/expression-evaluation.spec.ts rename to packages/xpath/test/native/expression-evaluation.test.ts diff --git a/packages/xpath/test/native/false.spec.ts b/packages/xpath/test/native/false.test.ts similarity index 100% rename from packages/xpath/test/native/false.spec.ts rename to packages/xpath/test/native/false.test.ts diff --git a/packages/xpath/test/native/floor.spec.ts b/packages/xpath/test/native/floor.test.ts similarity index 100% rename from packages/xpath/test/native/floor.spec.ts rename to packages/xpath/test/native/floor.test.ts diff --git a/packages/xpath/test/native/index.ts b/packages/xpath/test/native/index.ts deleted file mode 100644 index cd3336f3..00000000 --- a/packages/xpath/test/native/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -const importers = Object.values(import.meta.glob('./**/*.spec.ts')); - -for await (const importer of importers) { - await importer(); -} diff --git a/packages/xpath/test/native/infix-operators.spec.ts b/packages/xpath/test/native/infix-operators.test.ts similarity index 100% rename from packages/xpath/test/native/infix-operators.spec.ts rename to packages/xpath/test/native/infix-operators.test.ts diff --git a/packages/xpath/test/native/lang.spec.ts b/packages/xpath/test/native/lang.test.ts similarity index 100% rename from packages/xpath/test/native/lang.spec.ts rename to packages/xpath/test/native/lang.test.ts diff --git a/packages/xpath/test/native/namespace-resolver.spec.ts b/packages/xpath/test/native/namespace-resolver.test.ts similarity index 100% rename from packages/xpath/test/native/namespace-resolver.spec.ts rename to packages/xpath/test/native/namespace-resolver.test.ts diff --git a/packages/xpath/test/native/node-name.spec.ts b/packages/xpath/test/native/node-name.test.ts similarity index 100% rename from packages/xpath/test/native/node-name.spec.ts rename to packages/xpath/test/native/node-name.test.ts diff --git a/packages/xpath/test/native/node-type.spec.ts b/packages/xpath/test/native/node-type.test.ts similarity index 100% rename from packages/xpath/test/native/node-type.spec.ts rename to packages/xpath/test/native/node-type.test.ts diff --git a/packages/xpath/test/native/nodeset-id.spec.ts b/packages/xpath/test/native/nodeset-id.test.ts similarity index 100% rename from packages/xpath/test/native/nodeset-id.spec.ts rename to packages/xpath/test/native/nodeset-id.test.ts diff --git a/packages/xpath/test/native/nodeset.spec.ts b/packages/xpath/test/native/nodeset.test.ts similarity index 100% rename from packages/xpath/test/native/nodeset.spec.ts rename to packages/xpath/test/native/nodeset.test.ts diff --git a/packages/xpath/test/native/number-operator.spec.ts b/packages/xpath/test/native/number-operator.test.ts similarity index 100% rename from packages/xpath/test/native/number-operator.spec.ts rename to packages/xpath/test/native/number-operator.test.ts diff --git a/packages/xpath/test/native/number.spec.ts b/packages/xpath/test/native/number.test.ts similarity index 100% rename from packages/xpath/test/native/number.spec.ts rename to packages/xpath/test/native/number.test.ts diff --git a/packages/xpath/test/native/path.spec.ts b/packages/xpath/test/native/path.test.ts similarity index 100% rename from packages/xpath/test/native/path.spec.ts rename to packages/xpath/test/native/path.test.ts diff --git a/packages/xpath/test/native/predicates.spec.ts b/packages/xpath/test/native/predicates.test.ts similarity index 100% rename from packages/xpath/test/native/predicates.spec.ts rename to packages/xpath/test/native/predicates.test.ts diff --git a/packages/xpath/test/native/string.spec.ts b/packages/xpath/test/native/string.test.ts similarity index 100% rename from packages/xpath/test/native/string.spec.ts rename to packages/xpath/test/native/string.test.ts diff --git a/packages/xpath/test/native/true.spec.ts b/packages/xpath/test/native/true.test.ts similarity index 100% rename from packages/xpath/test/native/true.spec.ts rename to packages/xpath/test/native/true.test.ts diff --git a/packages/xpath/test/native/union-operator.spec.ts b/packages/xpath/test/native/union-operator.test.ts similarity index 100% rename from packages/xpath/test/native/union-operator.spec.ts rename to packages/xpath/test/native/union-operator.test.ts diff --git a/packages/xpath/test/xforms/abs.spec.ts b/packages/xpath/test/xforms/abs.test.ts similarity index 100% rename from packages/xpath/test/xforms/abs.spec.ts rename to packages/xpath/test/xforms/abs.test.ts diff --git a/packages/xpath/test/xforms/and-or.spec.ts b/packages/xpath/test/xforms/and-or.test.ts similarity index 100% rename from packages/xpath/test/xforms/and-or.spec.ts rename to packages/xpath/test/xforms/and-or.test.ts diff --git a/packages/xpath/test/xforms/area.spec.ts b/packages/xpath/test/xforms/area.test.ts similarity index 100% rename from packages/xpath/test/xforms/area.spec.ts rename to packages/xpath/test/xforms/area.test.ts diff --git a/packages/xpath/test/xforms/boolean-from-string.spec.ts b/packages/xpath/test/xforms/boolean-from-string.test.ts similarity index 100% rename from packages/xpath/test/xforms/boolean-from-string.spec.ts rename to packages/xpath/test/xforms/boolean-from-string.test.ts diff --git a/packages/xpath/test/xforms/checklist.spec.ts b/packages/xpath/test/xforms/checklist.test.ts similarity index 100% rename from packages/xpath/test/xforms/checklist.spec.ts rename to packages/xpath/test/xforms/checklist.test.ts diff --git a/packages/xpath/test/xforms/coalesce.spec.ts b/packages/xpath/test/xforms/coalesce.test.ts similarity index 100% rename from packages/xpath/test/xforms/coalesce.spec.ts rename to packages/xpath/test/xforms/coalesce.test.ts diff --git a/packages/xpath/test/xforms/complex.spec.ts b/packages/xpath/test/xforms/complex.test.ts similarity index 100% rename from packages/xpath/test/xforms/complex.spec.ts rename to packages/xpath/test/xforms/complex.test.ts diff --git a/packages/xpath/test/xforms/concat.spec.ts b/packages/xpath/test/xforms/concat.test.ts similarity index 100% rename from packages/xpath/test/xforms/concat.spec.ts rename to packages/xpath/test/xforms/concat.test.ts diff --git a/packages/xpath/test/xforms/count-non-empty.spec.ts b/packages/xpath/test/xforms/count-non-empty.test.ts similarity index 100% rename from packages/xpath/test/xforms/count-non-empty.spec.ts rename to packages/xpath/test/xforms/count-non-empty.test.ts diff --git a/packages/xpath/test/xforms/count-selected.spec.ts b/packages/xpath/test/xforms/count-selected.test.ts similarity index 100% rename from packages/xpath/test/xforms/count-selected.spec.ts rename to packages/xpath/test/xforms/count-selected.test.ts diff --git a/packages/xpath/test/xforms/custom.spec.ts b/packages/xpath/test/xforms/custom.test.ts similarity index 100% rename from packages/xpath/test/xforms/custom.spec.ts rename to packages/xpath/test/xforms/custom.test.ts diff --git a/packages/xpath/test/xforms/date-comparison.spec.ts b/packages/xpath/test/xforms/date-comparison.test.ts similarity index 100% rename from packages/xpath/test/xforms/date-comparison.spec.ts rename to packages/xpath/test/xforms/date-comparison.test.ts diff --git a/packages/xpath/test/xforms/date-time.spec.ts b/packages/xpath/test/xforms/date-time.test.ts similarity index 100% rename from packages/xpath/test/xforms/date-time.spec.ts rename to packages/xpath/test/xforms/date-time.test.ts diff --git a/packages/xpath/test/xforms/date.spec.ts b/packages/xpath/test/xforms/date.test.ts similarity index 100% rename from packages/xpath/test/xforms/date.spec.ts rename to packages/xpath/test/xforms/date.test.ts diff --git a/packages/xpath/test/xforms/decimal-date-time.spec.ts b/packages/xpath/test/xforms/decimal-date-time.test.ts similarity index 100% rename from packages/xpath/test/xforms/decimal-date-time.spec.ts rename to packages/xpath/test/xforms/decimal-date-time.test.ts diff --git a/packages/xpath/test/xforms/decimal-time.spec.ts b/packages/xpath/test/xforms/decimal-time.test.ts similarity index 100% rename from packages/xpath/test/xforms/decimal-time.spec.ts rename to packages/xpath/test/xforms/decimal-time.test.ts diff --git a/packages/xpath/test/xforms/digest.spec.ts b/packages/xpath/test/xforms/digest.test.ts similarity index 100% rename from packages/xpath/test/xforms/digest.spec.ts rename to packages/xpath/test/xforms/digest.test.ts diff --git a/packages/xpath/test/xforms/ends-with.spec.ts b/packages/xpath/test/xforms/ends-with.test.ts similarity index 100% rename from packages/xpath/test/xforms/ends-with.spec.ts rename to packages/xpath/test/xforms/ends-with.test.ts diff --git a/packages/xpath/test/xforms/format-date-time.spec.ts b/packages/xpath/test/xforms/format-date-time.test.ts similarity index 100% rename from packages/xpath/test/xforms/format-date-time.spec.ts rename to packages/xpath/test/xforms/format-date-time.test.ts diff --git a/packages/xpath/test/xforms/format-date.spec.ts b/packages/xpath/test/xforms/format-date.test.ts similarity index 100% rename from packages/xpath/test/xforms/format-date.spec.ts rename to packages/xpath/test/xforms/format-date.test.ts diff --git a/packages/xpath/test/xforms/if.spec.ts b/packages/xpath/test/xforms/if.test.ts similarity index 100% rename from packages/xpath/test/xforms/if.spec.ts rename to packages/xpath/test/xforms/if.test.ts diff --git a/packages/xpath/test/xforms/index.ts b/packages/xpath/test/xforms/index.ts deleted file mode 100644 index cd3336f3..00000000 --- a/packages/xpath/test/xforms/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -const importers = Object.values(import.meta.glob('./**/*.spec.ts')); - -for await (const importer of importers) { - await importer(); -} diff --git a/packages/xpath/test/xforms/indexed-repeat.test.ts b/packages/xpath/test/xforms/indexed-repeat.test.ts new file mode 100644 index 00000000..04b0d568 --- /dev/null +++ b/packages/xpath/test/xforms/indexed-repeat.test.ts @@ -0,0 +1,514 @@ +import { + body, + group, + head, + html, + input, + mainInstance, + model, + repeat, + t, + title, +} from '@getodk/common/test/fixtures/xform-dsl/index.ts'; +import { beforeEach, describe, it } from 'vitest'; +import type { indexedRepeat } from '../../src/functions/xforms/node-set.ts'; +import type { TestContext } from '../helpers.ts'; +import { createXFormsTestContext } from '../helpers.ts'; + +describe('indexed-repeat(...)', () => { + let testContext: TestContext; + + interface IndexedRepeatArgsDepth1 { + readonly target: string; + readonly repeat1: string; + readonly index1: string; + readonly repeat2?: never; + readonly index2?: never; + readonly repeat3?: never; + readonly index3?: never; + } + + interface IndexedRepeatArgsDepth2 { + readonly target: string; + readonly repeat1: string; + readonly index1: string; + readonly repeat2: string; + readonly index2: string; + readonly repeat3?: never; + readonly index3?: never; + } + + interface IndexedRepeatArgsDepth3 { + readonly target: string; + readonly repeat1: string; + readonly index1: string; + readonly repeat2: string; + readonly index2: string; + readonly repeat3: string; + readonly index3: string; + } + + // prettier-ignore + type IndexedRepeatArgs = + | IndexedRepeatArgsDepth1 + | IndexedRepeatArgsDepth2 + | IndexedRepeatArgsDepth3; + + interface IndexedRepeatParameterCase { + readonly description: string; + readonly parameters: IndexedRepeatArgs; + } + + describe('depth: 1', () => { + describe('partially derived from JavaRosa/scenario', () => { + const ABSOLUTE_TARGET = '/data/some-group/item/value'; + const RELATIVE_TARGET = '../item/value'; + const ABSOLUTE_GROUP = '/data/some-group/item'; + const RELATIVE_GROUP = '../item'; + const ABSOLUTE_INDEX = '/data/pos'; + const RELATIVE_INDEX = '../../pos'; + + const cases: readonly IndexedRepeatParameterCase[] = [ + { + description: 'Target: absolute, group: absolute, index: absolute', + parameters: { + target: ABSOLUTE_TARGET, + repeat1: ABSOLUTE_GROUP, + index1: ABSOLUTE_INDEX, + }, + }, + { + description: 'Target: absolute, group: absolute, index: relative', + parameters: { + target: ABSOLUTE_TARGET, + repeat1: ABSOLUTE_GROUP, + index1: RELATIVE_INDEX, + }, + }, + { + description: 'Target: absolute, group: relative, index: absolute', + parameters: { + target: ABSOLUTE_TARGET, + repeat1: RELATIVE_GROUP, + index1: ABSOLUTE_INDEX, + }, + }, + { + description: 'Target: absolute, group: relative, index: relative', + parameters: { + target: ABSOLUTE_TARGET, + repeat1: RELATIVE_GROUP, + index1: RELATIVE_INDEX, + }, + }, + { + description: 'Target: relative, group: absolute, index: absolute', + parameters: { + target: RELATIVE_TARGET, + repeat1: ABSOLUTE_GROUP, + index1: ABSOLUTE_INDEX, + }, + }, + { + description: 'Target: relative, group: absolute, index: relative', + parameters: { + target: RELATIVE_TARGET, + repeat1: ABSOLUTE_GROUP, + index1: RELATIVE_INDEX, + }, + }, + { + description: 'Target: relative, group: relative, index: absolute', + parameters: { + target: RELATIVE_TARGET, + repeat1: RELATIVE_GROUP, + index1: ABSOLUTE_INDEX, + }, + }, + { + description: 'Target: relative, group: relative, index: relative', + parameters: { + target: RELATIVE_TARGET, + repeat1: RELATIVE_GROUP, + index1: RELATIVE_INDEX, + }, + }, + ]; + + describe.each(cases)('$description', ({ parameters }) => { + const randomUInt = (max: number): number => { + return Math.floor(Math.random() * max); + }; + + const itemsCount = randomUInt(10) + 1; + const values = Array(itemsCount) + .fill(null) + .map((_, i) => { + return Math.random() * i * 10; + }); + const index = randomUInt(itemsCount); + const expected = values[index]!; + const position = index + 1; + const { target, repeat1, index1, repeat2, index2, repeat3, index3 } = parameters; + const args = [target, repeat1, index1]; + + if (repeat2 != null) { + args.push(repeat2, index2); + } + + if (repeat3 != null) { + args.push(repeat3, index3); + } + + const expression = `indexed-repeat(${args.join(', ')})`; + + beforeEach(() => { + const items = values.map((value) => { + return t('item', t('value', `${value}`)); + }); + + const xml = html( + head( + title('indexed-repeat form'), + model( + mainInstance( + t( + 'data id="indexed-repeat-form"', + t('some-group', ...items, t('ctx')), + t('pos', `${position}`) + ) + ) + ) + ), + body( + group( + '/data/some-group', + group( + '/data/some-group/item', + repeat('/data/some-group/item', input('/data/some-group/item/value')) + ) + ) + ) + ).asXml(); + + testContext = createXFormsTestContext(xml, { + getRootNode: (doc) => doc.querySelector('instance')!, + }); + }); + + it(`evaluates ${expression} to ${expected}`, () => { + const contextNode = testContext.document.querySelector('ctx'); + + testContext.assertNumberValue(expression, expected, { contextNode }); + }); + + const explicitPositionArgumentCases = values.map((value, i) => { + const explicitPosition = i + 1; + const explicitPositionExpression = `indexed-repeat(${target}, ${repeat1}, ${explicitPosition})`; + + return { + explicitPositionExpression, + value, + }; + }); + + it.each(explicitPositionArgumentCases)( + 'evaluates $explicitPositionExpression to $value', + ({ explicitPositionExpression, value }) => { + const contextNode = testContext.document.querySelector('ctx'); + + testContext.assertNumberValue(explicitPositionExpression, value, { contextNode }); + } + ); + }); + }); + }); + + describe('depth: up to 3', () => { + beforeEach(() => { + // prettier-ignore + const xml = html( + head( + title('indexed-repeat form'), + model( + mainInstance( + t('data id="indexed-repeat-form"', + t('d0pos1', '1'), + t('d0pos2', '2'), + t('d0pos3', '3'), + + t('d1', + t('v', '1'), + t('d2', + t('v', '1.1'), + t('d3', + t('v', '1.1.1')), + t('d3', + t('v', '1.1.2')), + t('d3', + t('v', '1.1.3'))), + t('d2', + t('v', '1.2'), + t('d3', + t('v', '1.2.1')), + t('d3', + t('v', '1.2.2')), + t('d3', + t('v', '1.2.3')))), + t('d1', + t('v', '2'), + t('d2', + t('v', '2.1'), + t('d3', + t('v', '2.1.1')), + t('d3', + t('v', '2.1.2')), + t('d3', + t('v', '2.1.3'))), + t('d2', + t('v', '2.2'), + t('d3', + t('v', '2.2.1')), + t('d3', + t('v', '2.2.2')), + t('d3', + t('v', '2.2.3')))))))), + body( + repeat('/data/d1', + input('/data/d1/v'), + repeat('/data/d1/d2', + input('/data/d1/d2/v'), + repeat('/data/d1/d2/d3', + input('/data/d1/d2/d3/v'))))) + ).asXml(); + + testContext = createXFormsTestContext(xml, { + getRootNode: (doc) => doc.querySelector('instance')!, + }); + }); + + interface DepthCase { + readonly expression: string; + readonly expected: string; + } + + const absoluteDepth2Arg = (baseExpression: string) => { + return baseExpression.replace(/, \.\/d1\/d2,/, ', /data/d1/d2,'); + }; + + const absoluteDepth3Arg = (baseExpression: string) => { + return baseExpression.replace(/, \.\/d1\/d2\/d3,/, ', /data/d1/d2/d3,'); + }; + + const absoluteDepthArgs = (baseExpression: string) => { + return absoluteDepth3Arg(absoluteDepth2Arg(baseExpression)); + }; + + /** + * @todo while these represent a pretty large matrix of possible cases, it + * could be more thorough still. Among the things not tested: + * + * - A wide variety of context-dependent position arguments. Omitting these + * cases, for now, is meant as an opportunity to discuss intent. + * {@link indexedRepeat} goes into more detail about the ambiguities we + * might want to discuss. + * + * - Varying initial context, with relative expressions at depth 1. This + * doesn't feel particularly important to test: it's partially tested in + * the above tests derived from JavaRosa; if it is broken, a lot of other + * things will be too. That said, it could instill a bit more confidence + * to chain `indexed-repeat` calls from prior `indexed-repeat` results. + * + * - Most type coercion cases are covered for positions, but coercion from + * boolean felt silly to address directly. There's no specific coercion + * logic in the `indexed-repeat` implementation, and it would be + * concerning if there ever is. + */ + const cases: readonly DepthCase[] = [ + // Depth 1, numeric position + { + expression: 'indexed-repeat(./d1/v, /data/d1, 1)', + expected: '1', + }, + { + expression: 'indexed-repeat(./d1/v, /data/d1, 2)', + expected: '2', + }, + { + expression: 'indexed-repeat(./d1/v, /data/d1, 3)', + expected: '', + }, + + // Depth 1, absolute node-set position + { + expression: 'indexed-repeat(./d1/v, /data/d1, /data/d0pos1)', + expected: '1', + }, + { + expression: 'indexed-repeat(./d1/v, /data/d1, /data/d0pos2)', + expected: '2', + }, + { + expression: 'indexed-repeat(./d1/v, /data/d1, /data/d0pos3)', + expected: '', + }, + + // Depth 2, numeric position + { + expression: 'indexed-repeat(./d1/d2/v, /data/d1, 1, ./d1/d2, 1)', + expected: '1.1', + }, + { + expression: 'indexed-repeat(./d1/d2/v, /data/d1, 1, ./d1/d2, 2)', + expected: '1.2', + }, + { + expression: 'indexed-repeat(./d1/d2/v, /data/d1, 1, ./d1/d2, 3)', + expected: '', + }, + { + expression: 'indexed-repeat(./d1/d2/v, /data/d1, 2, ./d1/d2, 2)', + expected: '2.2', + }, + { + expression: 'indexed-repeat(./d1/d2/v, /data/d1, 2, ./d1/d2, 3)', + expected: '', + }, + { + expression: 'indexed-repeat(./d1/d2/v, /data/d1, 3, ./d1/d2, 2)', + expected: '', + }, + + // Depth 2, mixed numeric string/absolute node-set positions + { + expression: 'indexed-repeat(./d1/d2/v, /data/d1, "1", ./d1/d2, /data/d0pos1)', + expected: '1.1', + }, + { + expression: 'indexed-repeat(./d1/d2/v, /data/d1, /data/d0pos1, ./d1/d2, "2")', + expected: '1.2', + }, + { + expression: 'indexed-repeat(./d1/d2/v, /data/d1, "1", ./d1/d2, /data/d0pos3)', + expected: '', + }, + { + expression: 'indexed-repeat(./d1/d2/v, /data/d1, /data/d0pos2, ./d1/d2, "2")', + expected: '2.2', + }, + { + expression: 'indexed-repeat(./d1/d2/v, /data/d1, "2", ./d1/d2, /data/d0pos3)', + expected: '', + }, + { + expression: 'indexed-repeat(./d1/d2/v, /data/d1, /data/d0pos3, ./d1/d2, "2")', + expected: '', + }, + + // Depth 3, relative depth2+, numeric positions + { + expression: 'indexed-repeat(./d1/d2/d3/v, /data/d1, 1, ./d1/d2, 1, ./d1/d2/d3, 1)', + expected: '1.1.1', + }, + { + expression: 'indexed-repeat(./d1/d2/d3/v, /data/d1, 1, ./d1/d2, 1, ./d1/d2/d3, 2)', + expected: '1.1.2', + }, + { + expression: 'indexed-repeat(./d1/d2/d3/v, /data/d1, 1, ./d1/d2, 1, ./d1/d2/d3, 3)', + expected: '1.1.3', + }, + { + expression: 'indexed-repeat(./d1/d2/d3/v, /data/d1, 1, ./d1/d2, 1, ./d1/d2/d3, 4)', + expected: '', + }, + { + expression: 'indexed-repeat(./d1/d2/d3/v, /data/d1, 1, ./d1/d2, 2, ./d1/d2/d3, 2)', + expected: '1.2.2', + }, + { + expression: 'indexed-repeat(./d1/d2/d3/v, /data/d1, 1, ./d1/d2, 2, ./d1/d2/d3, 4)', + expected: '', + }, + { + expression: 'indexed-repeat(./d1/d2/d3/v, /data/d1, 2, ./d1/d2, 1, ./d1/d2/d3, 3)', + expected: '2.1.3', + }, + { + expression: 'indexed-repeat(./d1/d2/d3/v, /data/d1, 3, ./d1/d2, 1, ./d1/d2/d3, 2)', + expected: '', + }, + { + expression: 'indexed-repeat(./d1/d2/d3/v, /data/d1, 1, ./d1/d2, 3, ./d1/d2/d3, 1)', + expected: '', + }, + + // Depth 3, relative depth2+, mixed number/numeric string/absolute node-set positions + { + expression: + 'indexed-repeat(./d1/d2/d3/v, /data/d1, /data/d0pos1, ./d1/d2, "1", ./d1/d2/d3, 1)', + expected: '1.1.1', + }, + { + expression: + 'indexed-repeat(./d1/d2/d3/v, /data/d1, 1, ./d1/d2, /data/d0pos1, ./d1/d2/d3, "2")', + expected: '1.1.2', + }, + { + expression: + 'indexed-repeat(./d1/d2/d3/v, /data/d1, 1, ./d1/d2, "1", ./d1/d2/d3, /data/d0pos3)', + expected: '1.1.3', + }, + { + expression: + 'indexed-repeat(./d1/d2/d3/v, /data/d1, /data/d0pos1, ./d1/d2, "1", ./d1/d2/d3, /data/d0pos4)', + expected: '', + }, + { + expression: + 'indexed-repeat(./d1/d2/d3/v, /data/d1, "1", ./d1/d2, /data/d0pos2, ./d1/d2/d3, /data/d0pos2)', + expected: '1.2.2', + }, + { + expression: + 'indexed-repeat(./d1/d2/d3/v, /data/d1, /data/d0pos1, ./d1/d2, /data/d0pos2, ./d1/d2/d3, "4")', + expected: '', + }, + { + expression: + 'indexed-repeat(./d1/d2/d3/v, /data/d1, /data/d0pos2, ./d1/d2, 1, ./d1/d2/d3, "3")', + expected: '2.1.3', + }, + { + expression: + 'indexed-repeat(./d1/d2/d3/v, /data/d1, 3, ./d1/d2, /data/d0pos1, ./d1/d2/d3, "2")', + expected: '', + }, + { + expression: + 'indexed-repeat(./d1/d2/d3/v, /data/d1, "1", ./d1/d2, 3, ./d1/d2/d3, /data/d0pos1)', + expected: '', + }, + ].flatMap((baseCase) => { + const { expression: baseExpression, expected } = baseCase; + + const expressions = [ + baseExpression, + absoluteDepth2Arg(baseExpression), + absoluteDepth3Arg(baseExpression), + absoluteDepthArgs(baseExpression), + ]; + + return expressions.map((expression) => ({ + expression, + expected, + })); + }); + + it.each(cases)('evaluates $expression to $expected', ({ expression, expected }) => { + const contextNode = testContext.evaluator.evaluateNonNullElement('/data'); + + testContext.assertStringValue(expression, expected, { contextNode }); + }); + }); +}); diff --git a/packages/xpath/test/xforms/instance.spec.ts b/packages/xpath/test/xforms/instance.test.ts similarity index 100% rename from packages/xpath/test/xforms/instance.spec.ts rename to packages/xpath/test/xforms/instance.test.ts diff --git a/packages/xpath/test/xforms/int.spec.ts b/packages/xpath/test/xforms/int.test.ts similarity index 100% rename from packages/xpath/test/xforms/int.spec.ts rename to packages/xpath/test/xforms/int.test.ts diff --git a/packages/xpath/test/xforms/join.spec.ts b/packages/xpath/test/xforms/join.test.ts similarity index 100% rename from packages/xpath/test/xforms/join.spec.ts rename to packages/xpath/test/xforms/join.test.ts diff --git a/packages/xpath/test/xforms/jr-itext.spec.ts b/packages/xpath/test/xforms/jr-itext.test.ts similarity index 100% rename from packages/xpath/test/xforms/jr-itext.spec.ts rename to packages/xpath/test/xforms/jr-itext.test.ts diff --git a/packages/xpath/test/xforms/max.spec.ts b/packages/xpath/test/xforms/max.test.ts similarity index 100% rename from packages/xpath/test/xforms/max.spec.ts rename to packages/xpath/test/xforms/max.test.ts diff --git a/packages/xpath/test/xforms/min.spec.ts b/packages/xpath/test/xforms/min.test.ts similarity index 100% rename from packages/xpath/test/xforms/min.spec.ts rename to packages/xpath/test/xforms/min.test.ts diff --git a/packages/xpath/test/xforms/not.spec.ts b/packages/xpath/test/xforms/not.test.ts similarity index 100% rename from packages/xpath/test/xforms/not.spec.ts rename to packages/xpath/test/xforms/not.test.ts diff --git a/packages/xpath/test/xforms/now.spec.ts b/packages/xpath/test/xforms/now.test.ts similarity index 100% rename from packages/xpath/test/xforms/now.spec.ts rename to packages/xpath/test/xforms/now.test.ts diff --git a/packages/xpath/test/xforms/number.spec.ts b/packages/xpath/test/xforms/number.test.ts similarity index 100% rename from packages/xpath/test/xforms/number.spec.ts rename to packages/xpath/test/xforms/number.test.ts diff --git a/packages/xpath/test/xforms/once.spec.ts b/packages/xpath/test/xforms/once.test.ts similarity index 100% rename from packages/xpath/test/xforms/once.spec.ts rename to packages/xpath/test/xforms/once.test.ts diff --git a/packages/xpath/test/xforms/position.spec.ts b/packages/xpath/test/xforms/position.test.ts similarity index 100% rename from packages/xpath/test/xforms/position.spec.ts rename to packages/xpath/test/xforms/position.test.ts diff --git a/packages/xpath/test/xforms/pow.spec.ts b/packages/xpath/test/xforms/pow.test.ts similarity index 100% rename from packages/xpath/test/xforms/pow.spec.ts rename to packages/xpath/test/xforms/pow.test.ts diff --git a/packages/xpath/test/xforms/predicates.spec.ts b/packages/xpath/test/xforms/predicates.test.ts similarity index 100% rename from packages/xpath/test/xforms/predicates.spec.ts rename to packages/xpath/test/xforms/predicates.test.ts diff --git a/packages/xpath/test/xforms/random.spec.ts b/packages/xpath/test/xforms/random.test.ts similarity index 100% rename from packages/xpath/test/xforms/random.spec.ts rename to packages/xpath/test/xforms/random.test.ts diff --git a/packages/xpath/test/xforms/randomize.spec.ts b/packages/xpath/test/xforms/randomize.test.ts similarity index 100% rename from packages/xpath/test/xforms/randomize.spec.ts rename to packages/xpath/test/xforms/randomize.test.ts diff --git a/packages/xpath/test/xforms/regex.spec.ts b/packages/xpath/test/xforms/regex.test.ts similarity index 100% rename from packages/xpath/test/xforms/regex.spec.ts rename to packages/xpath/test/xforms/regex.test.ts diff --git a/packages/xpath/test/xforms/root-node-context.spec.ts b/packages/xpath/test/xforms/root-node-context.test.ts similarity index 100% rename from packages/xpath/test/xforms/root-node-context.spec.ts rename to packages/xpath/test/xforms/root-node-context.test.ts diff --git a/packages/xpath/test/xforms/round.spec.ts b/packages/xpath/test/xforms/round.test.ts similarity index 100% rename from packages/xpath/test/xforms/round.spec.ts rename to packages/xpath/test/xforms/round.test.ts diff --git a/packages/xpath/test/xforms/selected-at.spec.ts b/packages/xpath/test/xforms/selected-at.test.ts similarity index 100% rename from packages/xpath/test/xforms/selected-at.spec.ts rename to packages/xpath/test/xforms/selected-at.test.ts diff --git a/packages/xpath/test/xforms/selected.spec.ts b/packages/xpath/test/xforms/selected.test.ts similarity index 100% rename from packages/xpath/test/xforms/selected.spec.ts rename to packages/xpath/test/xforms/selected.test.ts diff --git a/packages/xpath/test/xforms/simple-xpath.spec.ts b/packages/xpath/test/xforms/simple-xpath.test.ts similarity index 100% rename from packages/xpath/test/xforms/simple-xpath.spec.ts rename to packages/xpath/test/xforms/simple-xpath.test.ts diff --git a/packages/xpath/test/xforms/subs.spec.ts b/packages/xpath/test/xforms/subs.test.ts similarity index 100% rename from packages/xpath/test/xforms/subs.spec.ts rename to packages/xpath/test/xforms/subs.test.ts diff --git a/packages/xpath/test/xforms/sum.spec.ts b/packages/xpath/test/xforms/sum.test.ts similarity index 100% rename from packages/xpath/test/xforms/sum.spec.ts rename to packages/xpath/test/xforms/sum.test.ts diff --git a/packages/xpath/test/xforms/today.spec.ts b/packages/xpath/test/xforms/today.test.ts similarity index 100% rename from packages/xpath/test/xforms/today.spec.ts rename to packages/xpath/test/xforms/today.test.ts diff --git a/packages/xpath/test/xforms/trigo.spec.ts b/packages/xpath/test/xforms/trigo.test.ts similarity index 100% rename from packages/xpath/test/xforms/trigo.spec.ts rename to packages/xpath/test/xforms/trigo.test.ts diff --git a/packages/xpath/test/xforms/uuid.spec.ts b/packages/xpath/test/xforms/uuid.test.ts similarity index 100% rename from packages/xpath/test/xforms/uuid.spec.ts rename to packages/xpath/test/xforms/uuid.test.ts diff --git a/packages/xpath/test/xforms/weighted-checklist.spec.ts b/packages/xpath/test/xforms/weighted-checklist.test.ts similarity index 100% rename from packages/xpath/test/xforms/weighted-checklist.spec.ts rename to packages/xpath/test/xforms/weighted-checklist.test.ts diff --git a/packages/xpath/vite.config.ts b/packages/xpath/vite.config.ts index 897eb4fc..7ed552af 100644 --- a/packages/xpath/vite.config.ts +++ b/packages/xpath/vite.config.ts @@ -126,7 +126,7 @@ export default defineConfig(({ mode }) => { environment: TEST_ENVIRONMENT, globals: false, - include: ['test/**/*.test.ts', 'test/native/index.ts', 'test/xforms/index.ts'], + include: ['test/**/*.test.ts'], reporters: process.env.GITHUB_ACTIONS ? ['default', 'github-actions'] : 'default', } satisfies VitestTestConfig, };