diff --git a/.changeset/four-bats-kick.md b/.changeset/four-bats-kick.md new file mode 100644 index 00000000..7d2cd32f --- /dev/null +++ b/.changeset/four-bats-kick.md @@ -0,0 +1,7 @@ +--- +"@getodk/scenario": patch +"@getodk/xforms-engine": patch +"@getodk/xpath": patch +--- + +xpath: support for `indexed-repeat` function diff --git a/packages/scenario/src/jr/Scenario.ts b/packages/scenario/src/jr/Scenario.ts index 61cccb65..ea1de49e 100644 --- a/packages/scenario/src/jr/Scenario.ts +++ b/packages/scenario/src/jr/Scenario.ts @@ -64,6 +64,10 @@ interface CreateNewRepeatAssertedReferenceOptions { readonly assertCurrentReference: string; } +interface SetPositionalStateOptions { + readonly createMissingRepeatInstances?: boolean; +} + // prettier-ignore type GetQuestionAtIndexParameters< ExpectedQuestionType extends QuestionNodeType @@ -140,7 +144,8 @@ 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; @@ -151,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) { @@ -288,13 +294,73 @@ export class Scenario { return this.setNonTerminalEventPosition(increment, expectReference); } - private setPositionalStateToReference(reference: string): AnyPositionalEvent { + private createMissingRepeatInstances(reference: string): void { + let tempReference = reference; + let indexedReference: string | null = null; + + const trailingPositionalPredicatePattern = /\[\d+\]$/; + + do { + if (trailingPositionalPredicatePattern.test(tempReference)) { + indexedReference = tempReference; + } else { + tempReference = tempReference.replace(/\/[^/]+$/, ''); + + if (tempReference === '') { + break; + } + } + } while (indexedReference == null); + + if (indexedReference == null) { + return; + } + + const repeatRangeReference = indexedReference.replace(trailingPositionalPredicatePattern, ''); + + const positionalPredicate = indexedReference.replace(/^.*\[(\d+)\]$/, '$1'); + const count = parseInt(positionalPredicate, 10); + + if (count < 1) { + return; + } + + const repeatRange = this.getInstanceNode(repeatRangeReference); + + if (repeatRange.nodeType !== 'repeat-range') { + throw 'todo'; + } + + const repeatBodyDefinition = repeatRange.definition.bodyElement; + + if (repeatBodyDefinition.countExpression != null || repeatBodyDefinition.isFixedCount) { + return; + } + + const instances = repeatRange.currentState.children.length; + const delta = count - instances; + + for (let i = 0; i < delta; i += 1) { + this.createNewRepeat(repeatRangeReference); + } + } + + private setPositionalStateToReference( + reference: string, + options: SetPositionalStateOptions = {} + ): AnyPositionalEvent { const events = this.getPositionalEvents(); const index = events.findIndex(({ node }) => { return node?.currentState.reference === reference; }); if (index === -1) { + if (options?.createMissingRepeatInstances) { + this.createMissingRepeatInstances(reference); + + return this.setPositionalStateToReference(reference); + } + throw new Error( `Setting answer to ${reference} failed: could not locate question/positional event with that reference.` ); @@ -304,7 +370,9 @@ export class Scenario { } private answerSelect(reference: string, ...selectionValues: string[]): ComparableAnswer { - const event = this.setPositionalStateToReference(reference); + const event = this.setPositionalStateToReference(reference, { + createMissingRepeatInstances: true, + }); if (!isQuestionEventOfType(event, 'select')) { throw new Error( @@ -315,6 +383,15 @@ export class Scenario { return event.answerQuestion(new SelectValuesAnswer(selectionValues)); } + /** + * **PORTING NOTES** + * + * Per JavaRosa: + * + * > This method has side effects: + * > - It will create all the required middle and end repeat group instances + * > - It changes the current form index + */ answer(...args: AnswerParameters): unknown { if (isAnswerSelectParams(args)) { return this.answerSelect(...args); @@ -335,7 +412,9 @@ export class Scenario { } else if (typeof arg0 === 'string') { const reference = arg0; - event = this.setPositionalStateToReference(reference); + event = this.setPositionalStateToReference(reference, { + createMissingRepeatInstances: true, + }); value = arg1; } else { throw new Error('Unsupported `answer` overload call'); @@ -371,7 +450,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 b512eea5..e5adecb0 100644 --- a/packages/scenario/test/repeat.test.ts +++ b/packages/scenario/test/repeat.test.ts @@ -2262,15 +2262,18 @@ describe('Tests ported from JavaRosa - repeats', () => { /** * **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! + * + * - `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.fails.each(parameters)('$testName', async (options) => { + it.each(parameters)('$testName', async (options) => { const scenario = await Scenario.init( 'Some form', html( @@ -2311,9 +2314,14 @@ describe('Tests ported from JavaRosa - repeats', () => { ) ); - 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); + // scenario.answer('/data/some-group[1]/item[1]/value', 11); + scenario.answer('/data/some-group/item[1]/value', 11); + + // scenario.answer('/data/some-group[1]/item[2]/value', 22); + scenario.answer('/data/some-group/item[2]/value', 22); + + // 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)); diff --git a/packages/scenario/test/select.test.ts b/packages/scenario/test/select.test.ts index 0adf62f1..2c8d6ae1 100644 --- a/packages/scenario/test/select.test.ts +++ b/packages/scenario/test/select.test.ts @@ -376,16 +376,8 @@ describe('DynamicSelectUpdateTest.java', () => { }); }); - /** - * **PORTING NOTES** - * - * This currently fails because repeat-based itemsets are broken more - * generally. As with the above sub-suite, the last assertion is a reference - * check and will always pass. Once repeat-based itemsets are fixed, we'll - * want to consider whether this test should be implemented differently too. - */ describe('select with repeat as trigger', () => { - it.fails('recomputes [the] choice list at every request', async () => { + it('recomputes [the] choice list at every request', async () => { const scenario = await Scenario.init( 'Select with repeat trigger', html( @@ -417,7 +409,8 @@ describe('DynamicSelectUpdateTest.java', () => { expect(choices.size()).toBe(2); - // Because of the repeat trigger in the count expression, choices should be recomputed every time they're requested + // JR: Because of the repeat trigger in the count expression, choices + // should be recomputed every time they're requested expect(scenario.choicesOf('/data/select')).not.toBe(choices); }); }); diff --git a/packages/scenario/test/smoketests/child-vaccination.test.ts b/packages/scenario/test/smoketests/child-vaccination.test.ts index 692a05a2..066a071a 100644 --- a/packages/scenario/test/smoketests/child-vaccination.test.ts +++ b/packages/scenario/test/smoketests/child-vaccination.test.ts @@ -12,6 +12,22 @@ import type { } from '../../src/jr/event/getPositionalEvents.ts'; import { JRTreeReference as BaseJRTreeReference } from '../../src/jr/xpath/JRTreeReference.ts'; +/** + * Most naive approach first: strip numeric positional predicates, ignoring any + * syntax ambiguities. + * + * If we need to do anything more complex than that, we'd likely benefit from + * pulling in the XPath parser to introduce syntax-based analysis and + * serialization. That's not the best investment for this smoke test, but it + * will have broad applicability for a variety of engine work that we've + * deferred, putting another figurative bird in that figurative stone's + * trajectory. (Plus there is considerable WIP that we could draw from if we + * need/wish to do that.) + */ +const naiveStripPositionalPredicates = (expression: string): string => { + return expression.replaceAll(/\[\d+\]/g, ''); +}; + /** * **PORTING NOTES** * @@ -54,7 +70,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 +141,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 +178,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 +591,32 @@ describe('ChildVaccinationTest.java', () => { }); it.fails('[smoke test]', async () => { - let scenario: Scenario | null = null; - - try { - scenario = await Scenario.init('child_vaccination_VOL_tool_v12.xml'); + const 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 +625,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/xpath/README.md b/packages/xpath/README.md index f924ea1a..aa10d051 100644 --- a/packages/xpath/README.md +++ b/packages/xpath/README.md @@ -149,7 +149,6 @@ Both evaluator classes provide the following convenience methods: We intend to support the full ODK XForms function library, but support is currently incomplete. The following functions are not yet supported (the `jr:` prefix is used by convention to refer to the JavaRosa namespace): -- `indexed-repeat` - `pulldata` - `jr:choice-name` diff --git a/packages/xpath/src/evaluations/LocationPathEvaluation.ts b/packages/xpath/src/evaluations/LocationPathEvaluation.ts index e4067a52..7e75dc55 100644 --- a/packages/xpath/src/evaluations/LocationPathEvaluation.ts +++ b/packages/xpath/src/evaluations/LocationPathEvaluation.ts @@ -7,9 +7,11 @@ import type { EvaluationContextTreeWalkers, } from '../context/EvaluationContext.ts'; import type { Evaluator } from '../evaluator/Evaluator.ts'; +import type { ExpressionEvaluator } from '../evaluator/expression/ExpressionEvaluator.ts'; import type { FilterPathExpressionEvaluator } from '../evaluator/expression/FilterPathExpressionEvaluator.ts'; import type { LocationPathEvaluator } from '../evaluator/expression/LocationPathEvaluator.ts'; import type { LocationPathExpressionEvaluator } from '../evaluator/expression/LocationPathExpressionEvaluator.ts'; +import type { EvaluableArgument } from '../evaluator/functions/FunctionImplementation.ts'; import type { FunctionLibraryCollection } from '../evaluator/functions/FunctionLibraryCollection.ts'; import type { NodeSetFunction } from '../evaluator/functions/NodeSetFunction.ts'; import type { AnyStep } from '../evaluator/step/Step.ts'; @@ -103,6 +105,8 @@ type ArbitraryNodesTemporaryCallee = // eslint-disable-next-line @typescript-eslint/no-explicit-any | NodeSetFunction; +type PositionPredicateEvaluator = EvaluableArgument | ExpressionEvaluator; + // TODO: naming, general design/approach. This class has multiple, overlapping // purposes: // @@ -338,6 +342,32 @@ export class LocationPathEvaluation return result; } + evaluatePositionPredicate(predicate: PositionPredicateEvaluator): LocationPathEvaluation { + let currentPosition = 0; + let positioned: LocationPathEvaluation | null = null; + + const contextPosition = predicate.evaluate(this).toNumber(); + + if (contextPosition >= 1) { + for (const item of this) { + currentPosition += 1; + + if (currentPosition === contextPosition) { + positioned = item; + break; + } + } + } + + return ( + positioned ?? + new LocationPathEvaluation(this, [], { + contextPosition, + contextSize: () => this.contextSize(), + }) + ); + } + protected _isEmpty: boolean | null = null; protected isEmpty(): boolean { diff --git a/packages/xpath/src/evaluator/expression/LocationPathEvaluator.ts b/packages/xpath/src/evaluator/expression/LocationPathEvaluator.ts index d838c224..97664015 100644 --- a/packages/xpath/src/evaluator/expression/LocationPathEvaluator.ts +++ b/packages/xpath/src/evaluator/expression/LocationPathEvaluator.ts @@ -76,29 +76,26 @@ export class LocationPathEvaluator for (const step of rest) { currentContext = currentContext.step(step); - // TODO: predicate *logic* feels like it nicely belongs here (so long as it continues to pertain directly to syntax nodes), but application of predicates is definitely a concern that feels it better belongs in `LocationPathEvaluation` + // TODO: a previous observation here had noted that it may be appropriate + // to move predicate logic to `LocationPathEvaluation`. With some + // hindsight, and now with a subset of position predicate logic moved + // there to suppport equivalent logic from a function implementation, it + // seems quite reasonable that predicate logic would move there entirely. + // In fact it almost certainly should. for (const predicateNode of step.predicates) { const [predicateExpressionNode] = predicateNode.children; const predicateExpression = createExpression(predicateExpressionNode); - let positionPredicate: number | null = null; - + // Static/explicit position predicate if (predicateExpression instanceof NumberExpressionEvaluator) { - positionPredicate = predicateExpression.evaluate(currentContext).toNumber(); + currentContext = currentContext.evaluatePositionPredicate(predicateExpression); + + continue; } const filteredNodes: Node[] = []; for (const self of currentContext) { - if (positionPredicate != null) { - if (self.contextPosition() === positionPredicate) { - filteredNodes.push(...self.contextNodes); - break; - } else { - continue; - } - } - const predicateResult = predicateExpression.evaluate(self); // TODO: it's surprising there aren't tests exercising this, but it diff --git a/packages/xpath/src/functions/xforms/node-set.ts b/packages/xpath/src/functions/xforms/node-set.ts index 066360b4..d5cfeea8 100644 --- a/packages/xpath/src/functions/xforms/node-set.ts +++ b/packages/xpath/src/functions/xforms/node-set.ts @@ -2,6 +2,7 @@ import { UpsertableWeakMap } from '@getodk/common/lib/collections/UpsertableWeak import { ScopedElementLookup } from '@getodk/common/lib/dom/compatibility.ts'; import type { LocalNamedElement } from '@getodk/common/types/dom.ts'; import { LocationPathEvaluation } from '../../evaluations/LocationPathEvaluation.ts'; +import { FunctionImplementation } 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'; @@ -32,6 +33,206 @@ export const countNonEmpty = new NumberFunction( } ); +/** + * Filters a node-set {@link evaluation} to derive a node-set (and evaluation + * context) which only includes nodes descending from one or more nodes in the + * {@link ancestorContext} node-set/context. + * + * @todo This is general enough we could theoretically use it elsewhere. But + * it's unclear if we **should**. There is a clear expectation of this behavior + * from ported JavaRosa tests of `indexed-repeat`, which is specialized + * shorthand with clear intent. Broader application could break with some of our + * assumptions about absolute/relative expressions otherwise. + */ +const filterContextDescendants = ( + ancestorContext: LocationPathEvaluation, + evaluation: LocationPathEvaluation +): LocationPathEvaluation => { + const resultNodes = Array.from(evaluation.nodes).filter((node) => { + return ancestorContext.some((context) => { + return context.value.contains(node); + }); + }); + + return LocationPathEvaluation.fromArbitraryNodes(ancestorContext, resultNodes, indexedRepeat); +}; + +/** + * Per {@link https://getodk.github.io/xforms-spec/#fn:indexed-repeat}: + * + * > `indexed-repeat(//node, /path/to/repeat, //index1, + * > /path/to/repeat/nested-repeat, //index2)` is meant to be a shortcut for + * > `//repeat[position()=//index1]/nested-repeat[position()=index2]/node` + * + * **FOR DISCUSSION** + * + * This implementation currently makes the following judgement calls: + * + * 1. None of the logic is repeat specific, and the function will behave the + * same way when specifying node-sets that do not correspond to repeats (or + * even to an XForm document structure). While the naming and spec language + * are clearly repeat-specific, the spec's explanation shows how the function + * maps to a more general LocationPath expression. The intent (at least for + * now) is to support that generalization, and only add repeat-specific + * restrictions if there's a compelling reason. (Besides being simpler this + * way, it will almost certainly perform better as well.) + * + * 2. Node-set results produced by the first `target` argument are + * contextualized to their containing "repeats" node-set (i.e. whichever node + * is resolved by the last `repeatN`/`indexN` pair). This is directly tested + * by `scenario` tests ported from JavaRosa. Notably, **this deviates** from + * any other handling of absolute paths by this package. A case can be made + * that it's consistent with the underlying XPath specification, if a bit + * surprising. + * + * 3. The node-set produced by `repeat1` is **not contextualized** to its + * containing node-set (i.e. the evaluation context), and this is also tested + * directly by `scenario` tests ported from JavaRosa. This should be + * consistent with expectations; it's noted here mostly because it differs + * from the next point... + * + * 4. The respective node-sets produced by `repeat2`, `repeat3` (and beyond\*) + * **are contextualized**, respectively, to the previous node-set context + * (i.e. `repeat2` is filtered to include only descendants of + * `repeat1[index1]`, `repeat3` is in turn filtered to include only + * descendants of `repeat2[index2]`). This behavior is not tested directly by + * JavaRosa, but the intent is inferred by behavior of the first `target` + * argument, and assumed from the spec example. + * + * 5. **!!!** None of the `indexN` arguments are contextualized in any special + * way, they're evaluated just as if they were written as a position + * predicate. At present, it's not clear whether there's a reliable way to + * infer intent otherwise: + * + * - The spec's example uses only relative references (and uses them in a + * fairly confusing and inconsistent way) + * + * - The ported JavaRosa tests do use an absolute `index1` expression, but it + * references a single static node, outside of the repeat hierarchy. + * + * - It seems likely (although I haven't tested the hunch) that JavaRosa + * **would contextualize** the `indexN` arguments. But this wouldn't be a + * strong signal of intent, because JavaRosa contextualizes absolute + * expressions in a much broader set of scenarios that we now know are out + * of scope. + * + * 6. The `indexed-repeat` function as specified should return a string. We + * currently return a node-set, because the function's logic is clearly + * designed to deal with node-sets. We do, however, filter the final (first + * `target` argument) result to the first node in that node-set (if any). In + * any case, wherever usage expects a string, it will go through the normal + * type casting logic consistent with spec, so returning a single node + * node-set seems safe. In theory, by returning a node-set, we could also use + * this implementation in a `FilterExpr` (e.g. + * `indexed-repeat(...)/even-deeper/steps[also="predicates"]). + * + * \* Bonus judgement call: the function is specified to a maximum depth of 3, + * but this implementation is variadic to any depth. We discussed this + * briefly, and it seemed like the consensus at the time was that it should + * be fine. + * + * - - - + * + * @todo Other parts of the ODK XForms spec suggest that `//` syntax is not + * actually expected to be supported. Specifically + * {@link https://getodk.github.io/xforms-spec/#xpath-axes | XPath Axes } says + * that "only the _parent_, _child_ and _self_ axes are supported" (whereas `//` + * is shorthand for `/descendant-or-self::node()/`). However, that is presumably + * a JavaRosa limitation. This package supports all of the XPath 1.0 axes, as + * well as that shorthand syntax. At time of writing, as far as I can see, the + * quote above describing `indexed-repeat` is the only remaining part of the + * spec referencing that axis. There are `scenario` tests ported from JavaRosa + * exercising some of this function's behavior already, but they do not exercise + * the syntax as referenced in the description. We should add unit tests in this + * package to test that case, but this was deferred for now as usage in the spec + * example is confusing. + */ +export const indexedRepeat = new FunctionImplementation( + 'indexed-repeat', + [ + { arityType: 'required', typeHint: 'node' }, // arg + { arityType: 'required', typeHint: 'node' }, // repeat1 + { arityType: 'required', typeHint: 'number' }, // index1 + { arityType: 'optional', typeHint: 'node' }, // repeatN=0 -> repeat2 + { arityType: 'optional', typeHint: 'number' }, // indexN=0 -> index2 + { arityType: 'optional', typeHint: 'node' }, // repeatN=1 -> repeat3 + { arityType: 'optional', typeHint: 'number' }, // indexN=1 -> index3 + + // Go beyond spec? Why the heck not! It's clearly a variadic design. + { arityType: 'variadic', typeHint: 'any' }, + ], + (context, [target /* arg */, ...rest /* repeat1, index1[, repeatN, indexN]* */]) => { + let currentContext = context; + + for (let i = 0; i < rest.length; i += 2) { + const repeatN = rest[i]; + const indexN = rest[i + 1]; + + if (repeatN == null) { + break; + } + + if (indexN == null) { + throw 'todo: repeat/index pairs are spec for signature'; + } + + const evaluation = repeatN.evaluate(currentContext); + + if (!(evaluation instanceof LocationPathEvaluation)) { + throw 'todo: not a node-set'; + } + + let repeats = evaluation; + + // repeat1 is inherently filtered by the initial context, while repeatN > + // where N > 1 must (implicitly) be filtered to include only descendants + // of the first iteration: + // + // - if the repeat1 expression is relative, evaluating it with the + // expression context will be filtered automatically + // + // - if it is absolute, it is expected to resolve absolute (to the + // context root); this way computations can call `indexed-repeat` from + // other arbitrary context nodes (as is the case in ported JR tests) + // + // - if repeat2 (and so on) is absolute, it **must** be implicitly scoped + // to the context established by the previous iteration (otherwise the + // function signature makes no sense! Only the last indexN would apply) + if (i > 0) { + repeats = filterContextDescendants(currentContext, repeats); + } + + currentContext = repeats.evaluatePositionPredicate(indexN); + } + + // Non-null assertion here should be safe because the required parameter + // is checked by `FunctionImplementation`. + const targetsResult = target!.evaluate(currentContext); + + if (!(targetsResult instanceof LocationPathEvaluation)) { + // Not fond of adding more throw string statements, but this will make it + // easier to find along with all of the other cases of this exact same + // assertion. We have a broader story around error propagation which will + // implicate all of these. We should also consider internal APIs which + // will do checks like this where appropriate without them being scattered + // ad-hoc throughout the function implementations concerned with them. + throw 'todo: not a node-set'; + } + + const results = filterContextDescendants(currentContext, targetsResult); + + // Awkward bit of internal API. This returns either: + // + // - The first node in the resulting node-set, or + // - An empty node-set in the result's context + // + // It would be nice to reuse `evaluatePositionPredicate` here, but we'd + // need to fake up a compatible "evaluator" interface and then fake an + // `EvaluationResult` for it to produce. This is considerably simpler. + return results.first() ?? results; + } +); + 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..bb4044a3 --- /dev/null +++ b/packages/xpath/test/xforms/indexed-repeat.test.ts @@ -0,0 +1,505 @@ +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(/, \.\/d2,/, ', /data/d1/d2,'); + }; + + const absoluteDepth3Arg = (baseExpression: string) => { + return baseExpression.replace(/, \.\/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(./v, /data/d1, 1)', + expected: '1', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 2)', + expected: '2', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 3)', + expected: '', + }, + + // Depth 1, absolute node-set position + { + expression: 'indexed-repeat(./v, /data/d1, /data/d0pos1)', + expected: '1', + }, + { + expression: 'indexed-repeat(./v, /data/d1, /data/d0pos2)', + expected: '2', + }, + { + expression: 'indexed-repeat(./v, /data/d1, /data/d0pos3)', + expected: '', + }, + + // Depth 2, relative to depth 1, numeric position + { + expression: 'indexed-repeat(./v, /data/d1, 1, ./d2, 1)', + expected: '1.1', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 1, ./d2, 2)', + expected: '1.2', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 1, ./d2, 3)', + expected: '', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 2, ./d2, 2)', + expected: '2.2', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 2, ./d2, 3)', + expected: '', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 3, ./d2, 2)', + expected: '', + }, + + // Depth 2, mixed numeric string/absolute node-set positions + { + expression: 'indexed-repeat(./v, /data/d1, "1", ./d2, /data/d0pos1)', + expected: '1.1', + }, + { + expression: 'indexed-repeat(./v, /data/d1, /data/d0pos1, ./d2, "2")', + expected: '1.2', + }, + { + expression: 'indexed-repeat(./v, /data/d1, "1", ./d2, /data/d0pos3)', + expected: '', + }, + { + expression: 'indexed-repeat(./v, /data/d1, /data/d0pos2, ./d2, "2")', + expected: '2.2', + }, + { + expression: 'indexed-repeat(./v, /data/d1, "2", ./d2, /data/d0pos3)', + expected: '', + }, + { + expression: 'indexed-repeat(./v, /data/d1, /data/d0pos3, ./d2, "2")', + expected: '', + }, + + // Depth 3, relative depth2+, numeric positions + { + expression: 'indexed-repeat(./v, /data/d1, 1, ./d2, 1, ./d3, 1)', + expected: '1.1.1', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 1, ./d2, 1, ./d3, 2)', + expected: '1.1.2', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 1, ./d2, 1, ./d3, 3)', + expected: '1.1.3', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 1, ./d2, 1, ./d3, 4)', + expected: '', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 1, ./d2, 2, ./d3, 2)', + expected: '1.2.2', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 1, ./d2, 2, ./d3, 4)', + expected: '', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 2, ./d2, 1, ./d3, 3)', + expected: '2.1.3', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 3, ./d2, 1, ./d3, 2)', + expected: '', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 1, ./d2, 3, ./d3, 1)', + expected: '', + }, + + // Depth 3, relative depth2+, mixed number/numeric string/absolute node-set positions + { + expression: 'indexed-repeat(./v, /data/d1, /data/d0pos1, ./d2, "1", ./d3, 1)', + expected: '1.1.1', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 1, ./d2, /data/d0pos1, ./d3, "2")', + expected: '1.1.2', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 1, ./d2, "1", ./d3, /data/d0pos3)', + expected: '1.1.3', + }, + { + expression: 'indexed-repeat(./v, /data/d1, /data/d0pos1, ./d2, "1", ./d3, /data/d0pos4)', + expected: '', + }, + { + expression: 'indexed-repeat(./v, /data/d1, "1", ./d2, /data/d0pos2, ./d3, /data/d0pos2)', + expected: '1.2.2', + }, + { + expression: 'indexed-repeat(./v, /data/d1, /data/d0pos1, ./d2, /data/d0pos2, ./d3, "4")', + expected: '', + }, + { + expression: 'indexed-repeat(./v, /data/d1, /data/d0pos2, ./d2, 1, ./d3, "3")', + expected: '2.1.3', + }, + { + expression: 'indexed-repeat(./v, /data/d1, 3, ./d2, /data/d0pos1, ./d3, "2")', + expected: '', + }, + { + expression: 'indexed-repeat(./v, /data/d1, "1", ./d2, 3, ./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 7622d676..2ac798a7 100644 --- a/packages/xpath/vite.config.ts +++ b/packages/xpath/vite.config.ts @@ -124,7 +124,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', }, };