From f7615eb312725afcec05b3a1de9e4a7b7e710b79 Mon Sep 17 00:00:00 2001 From: Ioannis Rosuochatzakis Date: Thu, 12 Mar 2026 14:54:36 +0100 Subject: [PATCH] TEDEFO-4978 Preferred-language properties, fieldContext indexer, privacy handler fixes --- pom.xml | 11 + .../ted/eforms/sdk/SdkSymbolResolver.java | 10 + .../efx/autocomplete/EfxLinkedProperty.java | 6 +- .../efx/exceptions/TypeMismatchException.java | 6 + .../efx/sdk2/EfxExpressionTranslatorV2.java | 101 +- .../ted/efx/sdk2/EfxTemplateTranslatorV2.java | 20 +- .../ted/efx/xpath/XPathScriptGenerator.java | 2 +- .../java/eu/europa/ted/efx/EfxTestsBase.java | 77 +- .../sdk1/EfxExpressionTranslatorV1Test.java | 28 +- .../sdk2/EfxExpressionTranslatorV2Test.java | 1025 ++++++++++++++++- .../efx/sdk2/EfxTemplateTranslatorV2Test.java | 229 ++++ .../ted/efx/sdk2/SdkSymbolResolverTest.java | 10 +- src/test/resources/json/sdk2-fields.json | 45 +- 13 files changed, 1465 insertions(+), 105 deletions(-) diff --git a/pom.xml b/pom.xml index 53640686..6c69a6ee 100644 --- a/pom.xml +++ b/pom.xml @@ -58,6 +58,7 @@ 2.18.3 2.18.3 5.7.2 + 11.3 2.0.13 @@ -127,6 +128,11 @@ freemarker ${version.freemarker} + + net.sf.saxon + Saxon-HE + ${version.saxon-he} + @@ -147,6 +153,11 @@ junit-jupiter-params test + + net.sf.saxon + Saxon-HE + test + org.antlr antlr4-runtime diff --git a/src/main/java/eu/europa/ted/eforms/sdk/SdkSymbolResolver.java b/src/main/java/eu/europa/ted/eforms/sdk/SdkSymbolResolver.java index 430831d5..8978d8a7 100644 --- a/src/main/java/eu/europa/ted/eforms/sdk/SdkSymbolResolver.java +++ b/src/main/java/eu/europa/ted/eforms/sdk/SdkSymbolResolver.java @@ -442,6 +442,11 @@ private boolean isFieldRepeatableFromContext(final SdkField sdkField, final SdkF return !sdkField.equals(context); } + // Multilingual fields have multiple XML elements (one per language) + if (FieldTypes.TEXT_MULTILINGUAL.getName().equals(sdkField.getType())) { + return true; + } + // Use cached ancestry from node List contextAncestry = context != null ? context.getParentNode().getAncestry() @@ -480,6 +485,11 @@ private boolean isFieldRepeatableFromContext(final SdkField sdkField, final SdkN return true; } + // Multilingual fields have multiple XML elements (one per language) + if (FieldTypes.TEXT_MULTILINGUAL.getName().equals(sdkField.getType())) { + return true; + } + // Use cached ancestry from node List contextAncestry = context != null ? context.getAncestry() diff --git a/src/main/java/eu/europa/ted/efx/autocomplete/EfxLinkedProperty.java b/src/main/java/eu/europa/ted/efx/autocomplete/EfxLinkedProperty.java index 63d9fd06..cb39ca80 100644 --- a/src/main/java/eu/europa/ted/efx/autocomplete/EfxLinkedProperty.java +++ b/src/main/java/eu/europa/ted/efx/autocomplete/EfxLinkedProperty.java @@ -24,7 +24,11 @@ public enum EfxLinkedProperty { IS_MASKED(":isMasked", INDICATOR), // Raw value (textExpression rule) - RAW_VALUE(":rawValue", TEXT); + RAW_VALUE(":rawValue", TEXT), + + // Preferred language properties (stringExpression rule, template-only) + PREFERRED_LANGUAGE(":preferredLanguage", TEXT), + PREFERRED_LANGUAGE_TEXT(":preferredLanguageText", TEXT); private final String label; private final EfxDataType dataType; diff --git a/src/main/java/eu/europa/ted/efx/exceptions/TypeMismatchException.java b/src/main/java/eu/europa/ted/efx/exceptions/TypeMismatchException.java index 38680fc9..b9b8069b 100644 --- a/src/main/java/eu/europa/ted/efx/exceptions/TypeMismatchException.java +++ b/src/main/java/eu/europa/ted/efx/exceptions/TypeMismatchException.java @@ -29,6 +29,7 @@ public enum ErrorCode { CANNOT_COMPARE, INCOMPATIBLE_OPERANDS, FIELD_MAY_REPEAT, + FIELD_IS_MULTILINGUAL, NODE_CONTEXT_AS_VALUE, IDENTIFIER_IS_SEQUENCE, IDENTIFIER_IS_SCALAR, @@ -39,6 +40,7 @@ public enum ErrorCode { private static final String CANNOT_COMPARE = "Type mismatch. Cannot compare values of different types: %s and %s."; private static final String INCOMPATIBLE_OPERANDS = "Type mismatch. Operator '%s' cannot be applied to %s and %s."; private static final String FIELD_MAY_REPEAT = "Type mismatch. Field '%s' may return multiple values from context '%s', but is used as a scalar. Use a sequence expression or change the context."; + private static final String FIELD_IS_MULTILINGUAL = "Type mismatch. Field '%s' is multilingual and has multiple values (one per language), but is used as a scalar. Use :preferredLanguageText to select the preferred language, or an indexer [n] to select a specific element."; private static final String NODE_CONTEXT_AS_VALUE = "Type mismatch. Context variable '$%s' refers to node '%s', but is used as a value. Only field context variables can be used in value expressions."; private static final String IDENTIFIER_IS_SEQUENCE = "Type mismatch. Variable '$%s' is declared as a sequence, but is used as a scalar."; private static final String IDENTIFIER_IS_SCALAR = "Type mismatch. Variable '$%s' is declared as a scalar, but is used where a sequence is expected. To use it as a single-element sequence, wrap it in square brackets: [$%s]."; @@ -90,6 +92,10 @@ public static TypeMismatchException fieldMayRepeat(ParserRuleContext ctx, String contextSymbol != null ? contextSymbol : "root"); } + public static TypeMismatchException fieldIsMultilingual(ParserRuleContext ctx, String fieldId) { + return new TypeMismatchException(ErrorCode.FIELD_IS_MULTILINGUAL, ctx, FIELD_IS_MULTILINGUAL, fieldId); + } + public static TypeMismatchException nodeContextUsedAsValue(ParserRuleContext ctx, String variableName, String nodeId) { return new TypeMismatchException(ErrorCode.NODE_CONTEXT_AS_VALUE, ctx, NODE_CONTEXT_AS_VALUE, variableName, nodeId); } diff --git a/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java b/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java index db8510ba..d6d504b7 100644 --- a/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java +++ b/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java @@ -1435,7 +1435,7 @@ public void exitContextIteratorExpression(ContextIteratorExpressionContext ctx) final String contextFieldId = getFieldId(ctx.fieldContext()); this.efxContext.declareContextVariable(variable.name, new FieldContext(contextFieldId, this.symbols.getAbsolutePathOfField(contextFieldId), - this.symbols.getRelativePathOfField(contextFieldId, this.efxContext.symbol()))); + path)); } else if (ctx.nodeContext() != null) { final String contextNodeId = getNodeId(ctx.nodeContext()); @@ -1793,6 +1793,20 @@ public void exitAbsoluteFieldReference(AbsoluteFieldReferenceContext ctx) { } } + @Override + public void exitFieldContext(FieldContextContext ctx) { + if (ctx.indexer() != null) { + NumericExpression index = this.stack.pop(NumericExpression.class); + final TypedExpression top = this.stack.peekType(); + if (!(top instanceof PathExpression)) { + throw TypeMismatchException.cannotConvert(PathExpression.class, top.getClass()); + } + PathExpression fieldPath = (PathExpression) this.stack.pop(top.getClass()); + this.stack.push(this.script.composeIndexer(fieldPath.asSequence(), index, + ScalarPath.fromEfxDataType.get(EfxTypeLattice.toPrimitive(fieldPath.getDataType())))); + } + } + @Override public void enterAbsoluteNodeReference(AbsoluteNodeReferenceContext ctx) { if (ctx.Slash() != null) { @@ -1956,8 +1970,8 @@ private void resolveAndPushFieldReference(ParserRuleContext ctx, PathExpression this.stack.push(result); break; case RESOLVE_SCALAR: - if (this.isFieldRepeatableFromCurrentContext(fieldId)) { - throw TypeMismatchException.fieldMayRepeat(ctx, fieldId, this.efxContext.symbol()); + if (this.fieldMayReturnMultipleValues(fieldId)) { + throw this.fieldMayReturnMultipleValuesException(ctx, fieldId); } this.stack.push(result); break; @@ -1965,7 +1979,7 @@ private void resolveAndPushFieldReference(ParserRuleContext ctx, PathExpression this.stack.push(result.asSequence()); break; case RESOLVE_EITHER: - if (this.isFieldRepeatableFromCurrentContext(fieldId)) { + if (this.fieldMayReturnMultipleValues(fieldId)) { this.stack.push(result.asSequence()); } else { this.stack.push(result); @@ -1975,11 +1989,13 @@ private void resolveAndPushFieldReference(ParserRuleContext ctx, PathExpression } /** - * Returns true if a field is repeatable from the current EFX context. + * Returns true if a field may return multiple values from the current EFX context. + * This includes fields that are repeatable (from SDK metadata or node hierarchy) + * and multilingual fields (which have multiple XML elements, one per language). * A field is not considered repeatable if it IS the current context (e.g., inside a WITH block * on this field, we're referencing the current element being iterated, not the whole sequence). */ - private boolean isFieldRepeatableFromCurrentContext(String fieldId) { + private boolean fieldMayReturnMultipleValues(String fieldId) { if (!this.efxContext.isEmpty() && this.efxContext.peek() != null && this.efxContext.peek().isFieldContext() @@ -1989,6 +2005,18 @@ private boolean isFieldRepeatableFromCurrentContext(String fieldId) { return this.symbols.isFieldRepeatableFromContext(fieldId, this.getContextNodeId()); } + /** + * Creates the appropriate exception for a field that may return multiple values but is used + * as a scalar. Returns {@link TypeMismatchException#fieldIsMultilingual} for multilingual fields, + * or {@link TypeMismatchException#fieldMayRepeat} for structurally repeatable fields. + */ + private TypeMismatchException fieldMayReturnMultipleValuesException(ParserRuleContext ctx, String fieldId) { + if (FieldTypes.TEXT_MULTILINGUAL.getName().equals(this.symbols.getTypeOfField(fieldId))) { + return TypeMismatchException.fieldIsMultilingual(ctx, fieldId); + } + return TypeMismatchException.fieldMayRepeat(ctx, fieldId, this.efxContext.symbol()); + } + /** * Gets the node ID of the current context for use with * {@link SymbolResolver#isFieldRepeatableFromContext}. @@ -2018,12 +2046,11 @@ private String getContextNodeId() { */ @Override public void exitContextFieldSpecifier(ContextFieldSpecifierContext ctx) { - this.stack.pop(PathExpression.class); // Discard the PathExpression placed in the stack for - // the context field. + final PathExpression contextFieldPath = this.stack.pop(PathExpression.class); final String contextFieldId = getFieldId(ctx.fieldContext()); this.efxContext .push(new FieldContext(contextFieldId, this.symbols.getAbsolutePathOfField(contextFieldId), - this.symbols.getRelativePathOfField(contextFieldId, this.efxContext.symbol()))); + contextFieldPath)); } @@ -2518,10 +2545,6 @@ public void exitEndsWithFunction(EndsWithFunctionContext ctx) { @Override public void exitFieldWasWithheldProperty(FieldWasWithheldPropertyContext ctx) { final String fieldId = getFieldId(ctx.fieldMention()); - if (this.isFieldRepeatableFromCurrentContext(fieldId)) { - throw TypeMismatchException.fieldMayRepeat(ctx, fieldId, this.efxContext.symbol()); - } - final String privacyCode = this.symbols.getPrivacyCodeOfField(fieldId); if (privacyCode == null || privacyCode.isEmpty()) { throw InvalidUsageException.fieldNotWithholdable(ctx, fieldId); @@ -2533,10 +2556,6 @@ public void exitFieldWasWithheldProperty(FieldWasWithheldPropertyContext ctx) { @Override public void exitFieldIsWithheldProperty(FieldIsWithheldPropertyContext ctx) { final String fieldId = getFieldId(ctx.fieldMention()); - if (this.isFieldRepeatableFromCurrentContext(fieldId)) { - throw TypeMismatchException.fieldMayRepeat(ctx, fieldId, this.efxContext.symbol()); - } - final String privacyCode = this.symbols.getPrivacyCodeOfField(fieldId); if (privacyCode == null || privacyCode.isEmpty()) { throw InvalidUsageException.fieldNotWithholdable(ctx, fieldId); @@ -2558,10 +2577,6 @@ public void exitFieldIsWithholdableProperty(FieldIsWithholdablePropertyContext c @Override public void exitFieldIsDisclosedProperty(FieldIsDisclosedPropertyContext ctx) { final String fieldId = getFieldId(ctx.fieldMention()); - if (this.isFieldRepeatableFromCurrentContext(fieldId)) { - throw TypeMismatchException.fieldMayRepeat(ctx, fieldId, this.efxContext.symbol()); - } - final String privacyCode = this.symbols.getPrivacyCodeOfField(fieldId); if (privacyCode == null || privacyCode.isEmpty()) { throw InvalidUsageException.fieldNotWithholdable(ctx, fieldId); @@ -2572,16 +2587,12 @@ public void exitFieldIsDisclosedProperty(FieldIsDisclosedPropertyContext ctx) { this.script.composeLogicalAnd( this.composeWasWithheldCondition(fieldId, privacyCode), this.script.composeLogicalNot(this.composeStillWithheldCondition(fieldId))), - this.script.composeLogicalNot(this.composeIsMaskedCondition(ctx, fieldId)))); + this.script.composeLogicalNot(this.composeIsMaskedCondition(fieldId)))); } @Override public void exitFieldIsMaskedProperty(FieldIsMaskedPropertyContext ctx) { final String fieldId = getFieldId(ctx.fieldMention()); - if (this.isFieldRepeatableFromCurrentContext(fieldId)) { - throw TypeMismatchException.fieldMayRepeat(ctx, fieldId, this.efxContext.symbol()); - } - final String privacyCode = this.symbols.getPrivacyCodeOfField(fieldId); if (privacyCode == null || privacyCode.isEmpty()) { throw InvalidUsageException.fieldNotWithholdable(ctx, fieldId); @@ -2590,7 +2601,7 @@ public void exitFieldIsMaskedProperty(FieldIsMaskedPropertyContext ctx) { // "isMasked" = was withheld AND field value equals the privacy mask this.stack.push(this.script.composeLogicalAnd( this.composeWasWithheldCondition(fieldId, privacyCode), - this.composeIsMaskedCondition(ctx, fieldId))); + this.composeIsMaskedCondition(fieldId))); } @Override @@ -2604,25 +2615,15 @@ public void exitFieldPrivacyCodeProperty(FieldPrivacyCodePropertyContext ctx) { } @Override - public void exitFieldRawValueProperty(FieldRawValuePropertyContext ctx) { + public void exitRawValueReference(RawValueReferenceContext ctx) { final TypedExpression top = this.stack.peekType(); if (!(top instanceof PathExpression)) { throw TypeMismatchException.cannotConvert(PathExpression.class, top.getClass()); } - if (top instanceof SequenceExpression) { - final String fieldId = getFieldId(ctx.fieldReferenceWithVariableContextOverride() - .reference.reference.reference); - throw TypeMismatchException.fieldMayRepeat(ctx, fieldId, this.efxContext.symbol()); - } - final PathExpression fieldPath = (PathExpression) this.stack.pop(top.getClass()); - this.stack.push(this.script.composeFieldRawValueReference(fieldPath)); - } - - @Override - public void exitFieldRawValuePropertySequence(FieldRawValuePropertySequenceContext ctx) { - final TypedExpression top = this.stack.peekType(); - if (!(top instanceof PathExpression)) { - throw TypeMismatchException.cannotConvert(PathExpression.class, top.getClass()); + if (top instanceof SequenceExpression + && this.currentCardinalityResolutionContext() == CardinalityResolutionContext.RESOLVE_SCALAR) { + final String fieldId = getFieldId(ctx.fieldContext()); + throw this.fieldMayReturnMultipleValuesException(ctx, fieldId); } final PathExpression fieldPath = (PathExpression) this.stack.pop(top.getClass()); this.stack.push(this.script.composeFieldRawValueReference(fieldPath)); @@ -2660,14 +2661,10 @@ private BooleanExpression composeStillWithheldCondition(String fieldId) { BooleanExpression.class); } - private BooleanExpression composeIsMaskedCondition(ParserRuleContext ctx, String fieldId) { + private BooleanExpression composeIsMaskedCondition(String fieldId) { final String maskingValue = this.symbols.getPrivacyMask(fieldId); final PathExpression fieldValue = this.script.composeFieldValueReference(this.symbols.getRelativePathOfField(fieldId, this.efxContext.symbol())); - if (!(fieldValue instanceof ScalarExpression)) { - throw TypeMismatchException.fieldMayRepeat(ctx, fieldId, this.efxContext.symbol()); - } - return this.script.composeComparisonOperation( TypedExpression.from(fieldValue, ScalarExpression.class), "==", @@ -3165,6 +3162,16 @@ public void exitPreferredLanguageTextFunction(PreferredLanguageTextFunctionConte throw InvalidUsageException.templateOnlyFunction(ctx, "preferred-language-text"); } + @Override + public void exitFieldPreferredLanguageProperty(FieldPreferredLanguagePropertyContext ctx) { + throw InvalidUsageException.templateOnlyFunction(ctx, ":preferredLanguage"); + } + + @Override + public void exitFieldPreferredLanguageTextProperty(FieldPreferredLanguageTextPropertyContext ctx) { + throw InvalidUsageException.templateOnlyFunction(ctx, ":preferredLanguageText"); + } + @Override public void exitDictionaryLookup(DictionaryLookupContext ctx) { if (this.currentCardinalityResolutionContext() == CardinalityResolutionContext.RESOLVE_SCALAR) { diff --git a/src/main/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2.java b/src/main/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2.java index 5dd4d452..2ac9d0df 100644 --- a/src/main/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2.java +++ b/src/main/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2.java @@ -42,7 +42,6 @@ import eu.europa.ted.efx.exceptions.InvalidIndentationException; import eu.europa.ted.efx.interfaces.Argument; import eu.europa.ted.efx.interfaces.EfxTemplateTranslator; -import eu.europa.ted.efx.interfaces.IncludedFileResolver; import eu.europa.ted.efx.interfaces.MarkupGenerator; import eu.europa.ted.efx.interfaces.ScriptGenerator; import eu.europa.ted.efx.interfaces.SymbolResolver; @@ -60,10 +59,8 @@ import eu.europa.ted.efx.model.expressions.scalar.BooleanExpression; import eu.europa.ted.efx.model.expressions.scalar.DateExpression; import eu.europa.ted.efx.model.expressions.scalar.DurationExpression; -import eu.europa.ted.efx.model.expressions.scalar.NodePath; import eu.europa.ted.efx.model.expressions.scalar.NumericExpression; import eu.europa.ted.efx.model.expressions.scalar.ScalarExpression; -import eu.europa.ted.efx.model.expressions.scalar.ScalarPath; import eu.europa.ted.efx.model.expressions.scalar.StringExpression; import eu.europa.ted.efx.model.expressions.scalar.StringPath; import eu.europa.ted.efx.model.expressions.scalar.TimeExpression; @@ -82,7 +79,6 @@ import eu.europa.ted.efx.model.templates.TemplateInvocation; import eu.europa.ted.efx.model.templates.Markup; import eu.europa.ted.efx.model.types.EfxDataType; -import eu.europa.ted.efx.model.types.FieldTypes; import eu.europa.ted.efx.model.variables.Dictionary; import eu.europa.ted.efx.model.variables.Function; import eu.europa.ted.efx.model.variables.StrictArguments; @@ -92,7 +88,7 @@ import eu.europa.ted.efx.model.variables.Template; import eu.europa.ted.efx.model.variables.Variable; import eu.europa.ted.efx.model.variables.Variables; -import eu.europa.ted.efx.model.expressions.scalar.MultilingualStringPath; +import eu.europa.ted.efx.model.expressions.sequence.MultilingualStringSequencePath; import eu.europa.ted.efx.sdk2.EfxParser.*; /** @@ -978,12 +974,22 @@ public void exitFormatLongDateTimeFunction(FormatLongDateTimeFunctionContext ctx @Override public void exitPreferredLanguageFunction(PreferredLanguageFunctionContext ctx) { - this.stack.push(this.script.getPreferredLanguage(this.stack.pop(MultilingualStringPath.class))); + this.stack.push(this.script.getPreferredLanguage(this.stack.pop(MultilingualStringSequencePath.class))); } @Override public void exitPreferredLanguageTextFunction(PreferredLanguageTextFunctionContext ctx) { - this.stack.push(this.script.getTextInPreferredLanguage(this.stack.pop(MultilingualStringPath.class))); + this.stack.push(this.script.getTextInPreferredLanguage(this.stack.pop(MultilingualStringSequencePath.class))); + } + + @Override + public void exitFieldPreferredLanguageProperty(FieldPreferredLanguagePropertyContext ctx) { + this.stack.push(this.script.getPreferredLanguage(this.stack.pop(MultilingualStringSequencePath.class))); + } + + @Override + public void exitFieldPreferredLanguageTextProperty(FieldPreferredLanguageTextPropertyContext ctx) { + this.stack.push(this.script.getTextInPreferredLanguage(this.stack.pop(MultilingualStringSequencePath.class))); } // #endregion Preferred language functions ------------------------------------- diff --git a/src/main/java/eu/europa/ted/efx/xpath/XPathScriptGenerator.java b/src/main/java/eu/europa/ted/efx/xpath/XPathScriptGenerator.java index 2fb7e01c..e8a7afb9 100644 --- a/src/main/java/eu/europa/ted/efx/xpath/XPathScriptGenerator.java +++ b/src/main/java/eu/europa/ted/efx/xpath/XPathScriptGenerator.java @@ -257,7 +257,7 @@ public BooleanExpression composeAnySatisfies( public T composeConditionalExpression(BooleanExpression condition, T whenTrue, T whenFalse, Class type) { return Expression.instantiate( - "(if " + condition.getScript() + " then " + whenTrue.getScript() + " else " + whenFalse.getScript() + ")", + "(if (" + condition.getScript() + ") then " + whenTrue.getScript() + " else " + whenFalse.getScript() + ")", type); } diff --git a/src/test/java/eu/europa/ted/efx/EfxTestsBase.java b/src/test/java/eu/europa/ted/efx/EfxTestsBase.java index f8bf9af4..d9dcebd5 100644 --- a/src/test/java/eu/europa/ted/efx/EfxTestsBase.java +++ b/src/test/java/eu/europa/ted/efx/EfxTestsBase.java @@ -5,12 +5,72 @@ import eu.europa.ted.efx.interfaces.TranslatorOptions; import eu.europa.ted.efx.mock.DependencyFactoryMock; import eu.europa.ted.efx.model.DecimalFormat; +import net.sf.saxon.s9api.ExtensionFunction; +import net.sf.saxon.s9api.ItemType; +import net.sf.saxon.s9api.OccurrenceIndicator; +import net.sf.saxon.s9api.Processor; +import net.sf.saxon.s9api.QName; +import net.sf.saxon.s9api.SaxonApiException; +import net.sf.saxon.s9api.SequenceType; +import net.sf.saxon.s9api.XPathCompiler; +import net.sf.saxon.s9api.XdmValue; public abstract class EfxTestsBase { protected static final TranslatorOptions DEFAULT_OPTIONS = new EfxTranslatorOptions("udf", DecimalFormat.EFX_DEFAULT); - + + private static final String EFX_NAMESPACE = "http://ted.europa.eu/efx"; + private static final XPathCompiler XPATH_COMPILER; + + static { + Processor processor = new Processor(false); + + // Register custom EFX extension functions so Saxon can validate XPath syntax. + // These are only needed for V1 expression tests, where multilingual field references + // are implicitly wrapped in efx:preferred-language-text() by XPathScriptGeneratorV1. + // V2 bans these functions in expression context (template-only). + processor.registerExtensionFunction(efxFunction("preferred-language")); + processor.registerExtensionFunction(efxFunction("preferred-language-text")); + + XPATH_COMPILER = processor.newXPathCompiler(); + XPATH_COMPILER.setLanguageVersion("3.1"); + XPATH_COMPILER.declareNamespace("fn", "http://www.w3.org/2005/xpath-functions"); + XPATH_COMPILER.declareNamespace("xs", "http://www.w3.org/2001/XMLSchema"); + XPATH_COMPILER.declareNamespace("efx", EFX_NAMESPACE); + XPATH_COMPILER.declareVariable(new QName("urlPrefix")); + } + + /** + * Creates a dummy extension function stub for XPath syntax validation. + * Accepts one argument (node) and returns a string. + */ + private static ExtensionFunction efxFunction(String localName) { + return new ExtensionFunction() { + @Override + public QName getName() { + return new QName(EFX_NAMESPACE, localName); + } + + @Override + public SequenceType getResultType() { + return SequenceType.makeSequenceType(ItemType.STRING, OccurrenceIndicator.ONE); + } + + @Override + public SequenceType[] getArgumentTypes() { + return new SequenceType[] { + SequenceType.makeSequenceType(ItemType.ANY_ITEM, OccurrenceIndicator.ONE_OR_MORE) + }; + } + + @Override + public XdmValue call(XdmValue[] arguments) { + throw new UnsupportedOperationException("Stub for XPath validation only"); + } + }; + } + protected abstract String getSdkVersion(); protected void testExpressionTranslationWithContext(final String expectedTranslation, @@ -29,8 +89,10 @@ protected String translateExpressionWithContext(final String context, final Stri protected String translateExpression(final String expression, final String... params) { try { - return EfxTranslator.translateExpression(DependencyFactoryMock.INSTANCE, getSdkVersion(), - expression, DEFAULT_OPTIONS, params); + String result = EfxTranslator.translateExpression(DependencyFactoryMock.INSTANCE, + getSdkVersion(), expression, DEFAULT_OPTIONS, params); + assertValidXPath(result); + return result; } catch (InstantiationException e) { throw new RuntimeException(e); } @@ -45,6 +107,15 @@ protected String translateTemplate(final String template) { } } + protected static void assertValidXPath(final String xpath) { + try { + XPATH_COMPILER.compile(xpath); + } catch (SaxonApiException e) { + throw new AssertionError( + "Generated XPath is not valid: " + xpath + "\n" + e.getMessage(), e); + } + } + protected String lines(String... lines) { return String.join("\n", lines); } diff --git a/src/test/java/eu/europa/ted/efx/sdk1/EfxExpressionTranslatorV1Test.java b/src/test/java/eu/europa/ted/efx/sdk1/EfxExpressionTranslatorV1Test.java index 8854d325..233adbf1 100644 --- a/src/test/java/eu/europa/ted/efx/sdk1/EfxExpressionTranslatorV1Test.java +++ b/src/test/java/eu/europa/ted/efx/sdk1/EfxExpressionTranslatorV1Test.java @@ -392,42 +392,42 @@ void testDurationQuantifiedExpression_UsingFieldReference() { @Test void testConditionalExpression() { - testExpressionTranslationWithContext("(if 1 > 2 then 'a' else 'b')", "ND-Root", + testExpressionTranslationWithContext("(if (1 > 2) then 'a' else 'b')", "ND-Root", "if 1 > 2 then 'a' else 'b'"); } @Test void testConditionalStringExpression_UsingLiterals() { - testExpressionTranslationWithContext("(if 'a' > 'b' then 'a' else 'b')", "ND-Root", + testExpressionTranslationWithContext("(if ('a' > 'b') then 'a' else 'b')", "ND-Root", "if 'a' > 'b' then 'a' else 'b'"); } @Test void testConditionalStringExpression_UsingFieldReferenceInCondition() { testExpressionTranslationWithContext( - "(if 'a' > PathNode/TextField/normalize-space(text()) then 'a' else 'b')", "ND-Root", + "(if ('a' > PathNode/TextField/normalize-space(text())) then 'a' else 'b')", "ND-Root", "if 'a' > BT-00-Text then 'a' else 'b'"); testExpressionTranslationWithContext( - "(if PathNode/TextField/normalize-space(text()) >= 'a' then 'a' else 'b')", "ND-Root", + "(if (PathNode/TextField/normalize-space(text()) >= 'a') then 'a' else 'b')", "ND-Root", "if BT-00-Text >= 'a' then 'a' else 'b'"); testExpressionTranslationWithContext( - "(if PathNode/TextField/normalize-space(text()) >= PathNode/TextField/normalize-space(text()) then 'a' else 'b')", + "(if (PathNode/TextField/normalize-space(text()) >= PathNode/TextField/normalize-space(text())) then 'a' else 'b')", "ND-Root", "if BT-00-Text >= BT-00-Text then 'a' else 'b'"); testExpressionTranslationWithContext( - "(if PathNode/StartDateField/xs:date(text()) >= PathNode/EndDateField/xs:date(text()) then 'a' else 'b')", + "(if (PathNode/StartDateField/xs:date(text()) >= PathNode/EndDateField/xs:date(text())) then 'a' else 'b')", "ND-Root", "if BT-00-StartDate >= BT-00-EndDate then 'a' else 'b'"); } @Test void testConditionalStringExpression_UsingFieldReference() { testExpressionTranslationWithContext( - "(if 'a' > 'b' then PathNode/TextField/normalize-space(text()) else 'b')", "ND-Root", + "(if ('a' > 'b') then PathNode/TextField/normalize-space(text()) else 'b')", "ND-Root", "if 'a' > 'b' then BT-00-Text else 'b'"); testExpressionTranslationWithContext( - "(if 'a' > 'b' then 'a' else PathNode/TextField/normalize-space(text()))", "ND-Root", + "(if ('a' > 'b') then 'a' else PathNode/TextField/normalize-space(text()))", "ND-Root", "if 'a' > 'b' then 'a' else BT-00-Text"); testExpressionTranslationWithContext( - "(if 'a' > 'b' then PathNode/TextField/normalize-space(text()) else PathNode/TextField/normalize-space(text()))", + "(if ('a' > 'b') then PathNode/TextField/normalize-space(text()) else PathNode/TextField/normalize-space(text()))", "ND-Root", "if 'a' > 'b' then BT-00-Text else BT-00-Text"); } @@ -439,34 +439,34 @@ void testConditionalStringExpression_UsingFieldReferences_TypeMismatch() { @Test void testConditionalBooleanExpression() { - testExpressionTranslationWithContext("(if PathNode/IndicatorField then true() else false())", + testExpressionTranslationWithContext("(if (PathNode/IndicatorField) then true() else false())", "ND-Root", "if BT-00-Indicator then TRUE else FALSE"); } @Test void testConditionalNumericExpression() { - testExpressionTranslationWithContext("(if 1 > 2 then 1 else PathNode/NumberField/number())", + testExpressionTranslationWithContext("(if (1 > 2) then 1 else PathNode/NumberField/number())", "ND-Root", "if 1 > 2 then 1 else BT-00-Number"); } @Test void testConditionalDateExpression() { testExpressionTranslationWithContext( - "(if xs:date('2012-01-01Z') > PathNode/EndDateField/xs:date(text()) then PathNode/StartDateField/xs:date(text()) else xs:date('2012-01-02Z'))", + "(if (xs:date('2012-01-01Z') > PathNode/EndDateField/xs:date(text())) then PathNode/StartDateField/xs:date(text()) else xs:date('2012-01-02Z'))", "ND-Root", "if 2012-01-01Z > BT-00-EndDate then BT-00-StartDate else 2012-01-02Z"); } @Test void testConditionalTimeExpression() { testExpressionTranslationWithContext( - "(if PathNode/EndTimeField/xs:time(text()) > xs:time('00:00:01Z') then PathNode/StartTimeField/xs:time(text()) else xs:time('00:00:01Z'))", + "(if (PathNode/EndTimeField/xs:time(text()) > xs:time('00:00:01Z')) then PathNode/StartTimeField/xs:time(text()) else xs:time('00:00:01Z'))", "ND-Root", "if BT-00-EndTime > 00:00:01Z then BT-00-StartTime else 00:00:01Z"); } @Test void testConditionalDurationExpression() { assertEquals( - "(if boolean(for $T in (current-date()) return ($T + xs:dayTimeDuration('P1D') > $T + (for $F in PathNode/DurationField return (if ($F/@unitCode='WEEK') then xs:dayTimeDuration(concat('P', $F/number() * 7, 'D')) else if ($F/@unitCode='DAY') then xs:dayTimeDuration(concat('P', $F/number(), 'D')) else if ($F/@unitCode='YEAR') then xs:yearMonthDuration(concat('P', $F/number(), 'Y')) else if ($F/@unitCode='MONTH') then xs:yearMonthDuration(concat('P', $F/number(), 'M')) else ())))) then xs:dayTimeDuration('P1D') else xs:dayTimeDuration('P2D'))", + "(if (boolean(for $T in (current-date()) return ($T + xs:dayTimeDuration('P1D') > $T + (for $F in PathNode/DurationField return (if ($F/@unitCode='WEEK') then xs:dayTimeDuration(concat('P', $F/number() * 7, 'D')) else if ($F/@unitCode='DAY') then xs:dayTimeDuration(concat('P', $F/number(), 'D')) else if ($F/@unitCode='YEAR') then xs:yearMonthDuration(concat('P', $F/number(), 'Y')) else if ($F/@unitCode='MONTH') then xs:yearMonthDuration(concat('P', $F/number(), 'M')) else ()))))) then xs:dayTimeDuration('P1D') else xs:dayTimeDuration('P2D'))", translateExpressionWithContext("ND-Root", "if P1D > BT-00-Duration then P1D else P2D")); } diff --git a/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java b/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java index 9bd5066d..6ec00e8f 100644 --- a/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java +++ b/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java @@ -166,11 +166,6 @@ void testLikePatternCondition_WithTextField() { "{ND-Root} ${BT-00-Text like '[0-9]*'}"); } - @Test - void testLikePatternCondition_WithTextMultilingualField() { - testExpressionTranslation("every $lang in PathNode/TextMultilingualField/@languageID satisfies fn:matches(normalize-space(PathNode/TextMultilingualField[./@languageID = $lang]/normalize-space(text())), '[0-9]*')", - "{ND-Root} ${every text:$lang in BT-00-Text-Multilingual/@languageID satisfies BT-00-Text-Multilingual[BT-00-Text-Multilingual/@languageID == $lang] like '[0-9]*'}"); - } @Test void testPreferredLanguage_ThrowsInExpressionContext() { @@ -188,10 +183,24 @@ void testPreferredLanguageText_ThrowsInExpressionContext() { } @Test - void testFieldValueComparison_UsingTextFields() { - testExpressionTranslationWithContext( - "PathNode/TextField/normalize-space(text()) = PathNode/TextMultilingualField/normalize-space(text())", - "Root", "textField == textMultilingualField"); + void testPreferredLanguageProperty_ThrowsInExpressionContext() { + InvalidUsageException exception = assertThrows(InvalidUsageException.class, + () -> translateExpressionWithContext("ND-Root", "BT-00-Text-Multilingual:preferredLanguage")); + assertEquals(InvalidUsageException.ErrorCode.TEMPLATE_ONLY_FUNCTION, exception.getErrorCode()); + } + + @Test + void testPreferredLanguageTextProperty_ThrowsInExpressionContext() { + InvalidUsageException exception = assertThrows(InvalidUsageException.class, + () -> translateExpressionWithContext("ND-Root", "BT-00-Text-Multilingual:preferredLanguageText")); + assertEquals(InvalidUsageException.ErrorCode.TEMPLATE_ONLY_FUNCTION, exception.getErrorCode()); + } + + @Test + void testFieldValueComparison_UsingTextFields_ThrowsBecauseMultilingual() { + TypeMismatchException exception = assertThrows(TypeMismatchException.class, + () -> translateExpressionWithContext("Root", "textField == textMultilingualField")); + assertEquals(TypeMismatchException.ErrorCode.FIELD_IS_MULTILINGUAL, exception.getErrorCode()); } @Test @@ -504,42 +513,42 @@ void testDurationQuantifiedExpression_UsingFieldReference() { @Test void testConditionalExpression() { - testExpressionTranslationWithContext("(if 1 > 2 then 'a' else 'b')", "ND-Root", + testExpressionTranslationWithContext("(if (1 > 2) then 'a' else 'b')", "ND-Root", "if 1 > 2 then 'a' else 'b'"); } @Test void testConditionalStringExpression_UsingLiterals() { - testExpressionTranslationWithContext("(if 'a' > 'b' then 'a' else 'b')", "ND-Root", + testExpressionTranslationWithContext("(if ('a' > 'b') then 'a' else 'b')", "ND-Root", "if 'a' > 'b' then 'a' else 'b'"); } @Test void testConditionalStringExpression_UsingFieldReferenceInCondition() { testExpressionTranslationWithContext( - "(if 'a' > PathNode/TextField/normalize-space(text()) then 'a' else 'b')", "ND-Root", + "(if ('a' > PathNode/TextField/normalize-space(text())) then 'a' else 'b')", "ND-Root", "if 'a' > BT-00-Text then 'a' else 'b'"); testExpressionTranslationWithContext( - "(if PathNode/TextField/normalize-space(text()) >= 'a' then 'a' else 'b')", "ND-Root", + "(if (PathNode/TextField/normalize-space(text()) >= 'a') then 'a' else 'b')", "ND-Root", "if BT-00-Text >= 'a' then 'a' else 'b'"); testExpressionTranslationWithContext( - "(if PathNode/TextField/normalize-space(text()) >= PathNode/TextField/normalize-space(text()) then 'a' else 'b')", + "(if (PathNode/TextField/normalize-space(text()) >= PathNode/TextField/normalize-space(text())) then 'a' else 'b')", "ND-Root", "if BT-00-Text >= BT-00-Text then 'a' else 'b'"); testExpressionTranslationWithContext( - "(if PathNode/StartDateField/xs:date(text()) >= PathNode/EndDateField/xs:date(text()) then 'a' else 'b')", + "(if (PathNode/StartDateField/xs:date(text()) >= PathNode/EndDateField/xs:date(text())) then 'a' else 'b')", "ND-Root", "if BT-00-StartDate >= BT-00-EndDate then 'a' else 'b'"); } @Test void testConditionalStringExpression_UsingFieldReference() { testExpressionTranslationWithContext( - "(if 'a' > 'b' then PathNode/TextField/normalize-space(text()) else 'b')", "ND-Root", + "(if ('a' > 'b') then PathNode/TextField/normalize-space(text()) else 'b')", "ND-Root", "if 'a' > 'b' then BT-00-Text else 'b'"); testExpressionTranslationWithContext( - "(if 'a' > 'b' then 'a' else PathNode/TextField/normalize-space(text()))", "ND-Root", + "(if ('a' > 'b') then 'a' else PathNode/TextField/normalize-space(text()))", "ND-Root", "if 'a' > 'b' then 'a' else BT-00-Text"); testExpressionTranslationWithContext( - "(if 'a' > 'b' then PathNode/TextField/normalize-space(text()) else PathNode/TextField/normalize-space(text()))", + "(if ('a' > 'b') then PathNode/TextField/normalize-space(text()) else PathNode/TextField/normalize-space(text()))", "ND-Root", "if 'a' > 'b' then BT-00-Text else BT-00-Text"); } @@ -551,34 +560,34 @@ void testConditionalStringExpression_UsingFieldReferences_TypeMismatch() { @Test void testConditionalBooleanExpression() { - testExpressionTranslationWithContext("(if PathNode/IndicatorField then true() else false())", + testExpressionTranslationWithContext("(if (PathNode/IndicatorField) then true() else false())", "ND-Root", "if BT-00-Indicator then TRUE else FALSE"); } @Test void testConditionalNumericExpression() { - testExpressionTranslationWithContext("(if 1 > 2 then 1 else PathNode/NumberField/number())", + testExpressionTranslationWithContext("(if (1 > 2) then 1 else PathNode/NumberField/number())", "ND-Root", "if 1 > 2 then 1 else BT-00-Number"); } @Test void testConditionalDateExpression() { testExpressionTranslationWithContext( - "(if xs:date('2012-01-01Z') > PathNode/EndDateField/xs:date(text()) then PathNode/StartDateField/xs:date(text()) else xs:date('2012-01-02Z'))", + "(if (xs:date('2012-01-01Z') > PathNode/EndDateField/xs:date(text())) then PathNode/StartDateField/xs:date(text()) else xs:date('2012-01-02Z'))", "ND-Root", "if 2012-01-01Z > BT-00-EndDate then BT-00-StartDate else 2012-01-02Z"); } @Test void testConditionalTimeExpression() { testExpressionTranslationWithContext( - "(if PathNode/EndTimeField/xs:time(text()) > xs:time('00:00:01Z') then PathNode/StartTimeField/xs:time(text()) else xs:time('00:00:01Z'))", + "(if (PathNode/EndTimeField/xs:time(text()) > xs:time('00:00:01Z')) then PathNode/StartTimeField/xs:time(text()) else xs:time('00:00:01Z'))", "ND-Root", "if BT-00-EndTime > 00:00:01Z then BT-00-StartTime else 00:00:01Z"); } @Test void testConditionalDurationExpression() { assertEquals( - "(if boolean(for $T in (current-date()) return ($T + xs:dayTimeDuration('P1D') > $T + (for $F in PathNode/DurationField return (if ($F/@unitCode='WEEK') then xs:dayTimeDuration(concat('P', $F/number() * 7, 'D')) else if ($F/@unitCode='DAY') then xs:dayTimeDuration(concat('P', $F/number(), 'D')) else if ($F/@unitCode='YEAR') then xs:yearMonthDuration(concat('P', $F/number(), 'Y')) else if ($F/@unitCode='MONTH') then xs:yearMonthDuration(concat('P', $F/number(), 'M')) else ())))) then xs:dayTimeDuration('P1D') else xs:dayTimeDuration('P2D'))", + "(if (boolean(for $T in (current-date()) return ($T + xs:dayTimeDuration('P1D') > $T + (for $F in PathNode/DurationField return (if ($F/@unitCode='WEEK') then xs:dayTimeDuration(concat('P', $F/number() * 7, 'D')) else if ($F/@unitCode='DAY') then xs:dayTimeDuration(concat('P', $F/number(), 'D')) else if ($F/@unitCode='YEAR') then xs:yearMonthDuration(concat('P', $F/number(), 'Y')) else if ($F/@unitCode='MONTH') then xs:yearMonthDuration(concat('P', $F/number(), 'M')) else ()))))) then xs:dayTimeDuration('P1D') else xs:dayTimeDuration('P2D'))", translateExpressionWithContext("ND-Root", "if P1D > BT-00-Duration then P1D else P2D")); } @@ -1305,6 +1314,21 @@ void testMultilingualTextFieldReference_WithLanguagePredicate() { "ND-Root", "BT-00-Text-Multilingual[BT-00-Text-Multilingual/@languageID == 'eng']"); } + @Test + void testMultilingualTextFieldReference_InScalarContext_Throws() { + TypeMismatchException ex = assertThrows(TypeMismatchException.class, + () -> translateExpressionWithContext("ND-Root", + "BT-00-Text-Multilingual == 'test'")); + assertEquals(TypeMismatchException.ErrorCode.FIELD_IS_MULTILINGUAL, ex.getErrorCode()); + } + + @Test + void testMultilingualTextFieldReference_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = PathNode/TextMultilingualField/normalize-space(text())", + "ND-Root", + "'test' in BT-00-Text-Multilingual"); + } // #endregion: References @@ -1507,9 +1531,13 @@ void testComputedProperty_isMasked_numericField() { @Test void testComputedProperty_isMasked_repeatingFieldFromContext() { - assertThrows(TypeMismatchException.class, - () -> translateExpressionWithContext("ND-Root", - "BT-00-Text-In-Repeatable-Node:isMasked")); + final String privacyPath = "RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + testExpressionTranslationWithContext( + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + + " and " + + "RepeatableNode/TextField/normalize-space(text()) = 'unpublished'", + "ND-Root", + "BT-00-Text-In-Repeatable-Node:isMasked"); } @Test @@ -1716,6 +1744,22 @@ void testFieldRawValue_RepeatableFieldInScalarContext_Throws() { assertEquals(TypeMismatchException.ErrorCode.FIELD_MAY_REPEAT, ex.getErrorCode()); } + @Test + void testFieldRawValue_MultilingualFieldInScalarContext_Throws() { + TypeMismatchException ex = assertThrows(TypeMismatchException.class, + () -> translateExpressionWithContext("ND-Root", + "BT-00-Text-Multilingual:rawValue == 'test'")); + assertEquals(TypeMismatchException.ErrorCode.FIELD_IS_MULTILINGUAL, ex.getErrorCode()); + } + + @Test + void testFieldRawValue_MultilingualFieldInSequenceContext() { + testExpressionTranslationWithContext( + "'test' = PathNode/TextMultilingualField/text()", + "ND-Root", + "'test' in BT-00-Text-Multilingual:rawValue"); + } + // #endregion: Boolean functions // #region: Numeric functions ----------------------------------------------- @@ -3549,4 +3593,933 @@ void testStringJoin_ParenthesizedFieldReference() { } // #endregion: B1 Grammar Issue + + // #region: Field Reference Structural Combinations ------------------------- + // Tests covering fieldReference grammar structural forms + // (predicates, absolute refs, context overrides, indexers) + // in different consumer contexts (scalar, sequence, presence). + + // #region: Absolute and predicate combinations + + @Test + void testAbsoluteFieldReference_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = /*/PathNode/TextField/normalize-space(text())", + "BT-00-Text", + "'test' in /BT-00-Text"); + } + + @Test + void testAbsoluteFieldReference_InPresenceCondition() { + testExpressionTranslationWithContext( + "/*/PathNode/TextField", + "BT-00-Text", + "/BT-00-Text is present"); + } + + @Test + void testAbsoluteFieldReference_WithPredicate() { + testExpressionTranslationWithContext( + "/*/PathNode/IndicatorField['a' = 'a']", + "BT-00-Text", + "/BT-00-Indicator['a' == 'a']"); + } + + @Test + void testFieldReferenceWithPredicate_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = PathNode/TextField['a' = 'a']/normalize-space(text())", + "ND-Root", + "'test' in BT-00-Text['a' == 'a']"); + } + + @Test + void testFieldReferenceWithPredicate_InPresenceCondition() { + testExpressionTranslationWithContext( + "PathNode/TextField['a' = 'a']", + "ND-Root", + "BT-00-Text['a' == 'a'] is present"); + } + + @Test + void testLinkedProperty_InScalarComparison() { + final String privacyPath = "../FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + testExpressionTranslationWithContext( + privacyPath + "/PublicationDate/xs:date(text()) = xs:date('2025-01-01Z')", + "BT-00-Text-In-Repeatable-Node", + "BT-00-Text-In-Repeatable-Node:publicationDate == 2025-01-01Z"); + } + + // Row 2 ATTR + @Test + void testFieldReferenceWithPredicate_InAttributeContext() { + testExpressionTranslationWithContext( + "PathNode/CodeField['a' = 'a']/@listName", + "ND-Root", + "BT-00-Code['a' == 'a']/@listName"); + } + + // Row 3 ATTR + @Test + void testAbsoluteFieldReference_InAttributeContext() { + testExpressionTranslationWithContext( + "/*/PathNode/CodeField/@listName", + "BT-00-Text", + "/BT-00-Code/@listName"); + } + + // Row 4 SC + @Test + void testAbsoluteFieldReferenceWithPredicate_InScalarContext() { + testExpressionTranslationWithContext( + "/*/PathNode/TextField['a' = 'a']/normalize-space(text()) = 'test'", + "BT-00-Text", + "/BT-00-Text['a' == 'a'] == 'test'"); + } + + // Row 4 SEQ + @Test + void testAbsoluteFieldReferenceWithPredicate_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = /*/PathNode/TextField['a' = 'a']/normalize-space(text())", + "BT-00-Text", + "'test' in /BT-00-Text['a' == 'a']"); + } + + // Row 4 ATTR + @Test + void testAbsoluteFieldReferenceWithPredicate_InAttributeContext() { + testExpressionTranslationWithContext( + "/*/PathNode/CodeField['a' = 'a']/@listName", + "BT-00-Text", + "/BT-00-Code['a' == 'a']/@listName"); + } + + // Row 5 SEQ + @Test + void testLinkedProperty_InSequenceContext() { + final String privacyPath = "RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + testExpressionTranslationWithContext( + "xs:date('2025-01-01Z') = " + privacyPath + "/PublicationDate/xs:date(text())", + "ND-Root", + "2025-01-01Z in BT-00-Text-In-Repeatable-Node:publicationDate"); + } + + // #endregion: Absolute and predicate combinations + + // #region: Context override combinations (field::, node::, $var::, stacked) + + @Test + void testFieldContextOverride_WithPredicateOnContext() { + // joinPaths simplifies: ../ChildNode/SubLevelTextField['a'='a']/../../TextField → ../TextField + // because navigating UP from a predicated node reaches the parent regardless + testExpressionTranslationWithContext( + "../TextField/normalize-space(text())", + "BT-00-Code", + "BT-01-SubLevel-Text['a' == 'a']::BT-00-Text"); + } + + @Test + void testFieldContextOverride_WithIndexerOnContext() { + // joinPaths simplifies the indexed context path the same way as predicates + testExpressionTranslationWithContext( + "../TextField/normalize-space(text())", + "BT-00-Code", + "BT-01-SubLevel-Text[1]::BT-00-Text"); + } + + @Test + void testFieldContextOverride_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = ../TextField/normalize-space(text())", + "BT-00-Code", + "'test' in BT-01-SubLevel-Text::BT-00-Text"); + } + + @Test + void testNodeContextOverride_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = ../../PathNode/TextField/normalize-space(text())", + "BT-00-Text", + "'test' in ND-Root::BT-00-Text"); + } + + @Test + void testStackedOverride_NodeAndField() { + // joinPaths simplifies: .../ChildNode/SubLevelTextField/../../TextField → .../TextField + testExpressionTranslationWithContext( + "../../PathNode/TextField/normalize-space(text())", + "BT-00-Text", + "ND-Root::BT-01-SubLevel-Text::BT-00-Text"); + } + + // Row 6 PRES + @Test + void testFieldContextOverride_InPresenceCondition() { + testExpressionTranslationWithContext( + "../TextField", + "BT-00-Code", + "BT-01-SubLevel-Text::BT-00-Text is present"); + } + + // Row 6 ATTR + @Test + void testFieldContextOverride_InAttributeContext() { + testExpressionTranslationWithContext( + "../CodeField/@listName", + "BT-00-Code", + "BT-01-SubLevel-Text::BT-00-Code/@listName"); + } + + // Row 7 SEQ + @Test + void testFieldContextOverride_WithPredicate_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = ../TextField/normalize-space(text())", + "BT-00-Code", + "'test' in BT-01-SubLevel-Text['a' == 'a']::BT-00-Text"); + } + + // Row 7 PRES + @Test + void testFieldContextOverride_WithPredicate_InPresenceCondition() { + testExpressionTranslationWithContext( + "../TextField", + "BT-00-Code", + "BT-01-SubLevel-Text['a' == 'a']::BT-00-Text is present"); + } + + // Row 7 ATTR + @Test + void testFieldContextOverride_WithPredicate_InAttributeContext() { + testExpressionTranslationWithContext( + "../CodeField/@listName", + "BT-00-Code", + "BT-01-SubLevel-Text['a' == 'a']::BT-00-Code/@listName"); + } + + // Row 8 SEQ + @Test + void testFieldContextOverride_WithIndexer_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = ../TextField/normalize-space(text())", + "BT-00-Code", + "'test' in BT-01-SubLevel-Text[1]::BT-00-Text"); + } + + // Row 8 PRES + @Test + void testFieldContextOverride_WithIndexer_InPresenceCondition() { + testExpressionTranslationWithContext( + "../TextField", + "BT-00-Code", + "BT-01-SubLevel-Text[1]::BT-00-Text is present"); + } + + // Row 8 ATTR + @Test + void testFieldContextOverride_WithIndexer_InAttributeContext() { + testExpressionTranslationWithContext( + "../CodeField/@listName", + "BT-00-Code", + "BT-01-SubLevel-Text[1]::BT-00-Code/@listName"); + } + + // Row 9 PRES + @Test + void testNodeContextOverride_InPresenceCondition() { + testExpressionTranslationWithContext( + "../../PathNode/TextField", + "BT-00-Text", + "ND-Root::BT-00-Text is present"); + } + + // Row 9 ATTR + @Test + void testNodeContextOverride_InAttributeContext() { + testExpressionTranslationWithContext( + "../../PathNode/CodeField/@listName", + "BT-00-Text", + "ND-Root::BT-00-Code/@listName"); + } + + // Row 10 SEQ + @Test + void testNodeContextOverride_WithPredicate_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = ../../PathNode/TextField/normalize-space(text())", + "BT-00-Text", + "'test' in ND-Root[BT-00-Indicator == TRUE]::BT-00-Text"); + } + + // Row 10 PRES + @Test + void testNodeContextOverride_WithPredicate_InPresenceCondition() { + testExpressionTranslationWithContext( + "../../PathNode/TextField", + "BT-00-Text", + "ND-Root[BT-00-Indicator == TRUE]::BT-00-Text is present"); + } + + // Row 10 ATTR + @Test + void testNodeContextOverride_WithPredicate_InAttributeContext() { + testExpressionTranslationWithContext( + "../../PathNode/CodeField/@listName", + "BT-00-Text", + "ND-Root[BT-00-Indicator == TRUE]::BT-00-Code/@listName"); + } + + // Row 11 SEQ + @Test + void testVariableContextOverride_InSequenceContext() { + testExpressionTranslationWithContext( + "for $n in SubNode return 'test' = $n/SubTextField/normalize-space(text())", + "ND-Root", + "for context:$n in ND-SubNode return 'test' in $n::BT-01-SubNode-Text"); + } + + // Row 11 PRES + @Test + void testVariableContextOverride_InPresenceCondition() { + testExpressionTranslationWithContext( + "for $n in SubNode return $n/SubTextField", + "ND-Root", + "for context:$n in ND-SubNode return $n::BT-01-SubNode-Text is present"); + } + + // Row 11 ATTR + @Test + void testVariableContextOverride_InAttributeContext() { + testExpressionTranslationWithContext( + "for $n in . return $n/PathNode/CodeField/@listName", + "ND-Root", + "for context:$n in ND-Root return $n::BT-00-Code/@listName"); + } + + // Row 12 SEQ + @Test + void testFieldReferenceInOtherNotice_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = fn:doc(concat($urlPrefix, 'da4d46e9-490b-41ff-a2ae-8166d356a619'))/*/PathNode/TextField/normalize-space(text())", + "ND-Root", + "'test' in notice('da4d46e9-490b-41ff-a2ae-8166d356a619')/BT-00-Text"); + } + + // Row 12 PRES + @Test + void testFieldReferenceInOtherNotice_InPresenceCondition() { + testExpressionTranslationWithContext( + "fn:doc(concat($urlPrefix, 'da4d46e9-490b-41ff-a2ae-8166d356a619'))/*/PathNode/TextField", + "ND-Root", + "notice('da4d46e9-490b-41ff-a2ae-8166d356a619')/BT-00-Text is present"); + } + + // Row 12 ATTR + @Test + void testFieldReferenceInOtherNotice_InAttributeContext() { + testExpressionTranslationWithContext( + "fn:doc(concat($urlPrefix, 'da4d46e9-490b-41ff-a2ae-8166d356a619'))/*/PathNode/CodeField/@listName", + "ND-Root", + "notice('da4d46e9-490b-41ff-a2ae-8166d356a619')/BT-00-Code/@listName"); + } + + // Row 13 SEQ + @Test + void testStackedOverride_NodeAndField_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = ../../PathNode/TextField/normalize-space(text())", + "BT-00-Text", + "'test' in ND-Root::BT-01-SubLevel-Text::BT-00-Text"); + } + + // Row 13 PRES + @Test + void testStackedOverride_NodeAndField_InPresenceCondition() { + testExpressionTranslationWithContext( + "../../PathNode/TextField", + "BT-00-Text", + "ND-Root::BT-01-SubLevel-Text::BT-00-Text is present"); + } + + // Row 13 ATTR + @Test + void testStackedOverride_NodeAndField_InAttributeContext() { + testExpressionTranslationWithContext( + "../../PathNode/CodeField/@listName", + "BT-00-Text", + "ND-Root::BT-01-SubLevel-Text::BT-00-Code/@listName"); + } + + // Row 14 SC + @Test + void testVariableAndNodeContextOverride_InScalarContext() { + testExpressionTranslationWithContext( + "for $n in SubNode return $n/../PathNode/IntegerField/number()", + "ND-Root", + "for context:$n in ND-SubNode return $n::ND-Root::integerField"); + } + + // Row 14 SEQ + @Test + void testVariableAndNodeContextOverride_InSequenceContext() { + testExpressionTranslationWithContext( + "for $n in SubNode return 'test' = $n/../PathNode/TextField/normalize-space(text())", + "ND-Root", + "for context:$n in ND-SubNode return 'test' in $n::ND-Root::BT-00-Text"); + } + + // Row 14 PRES + @Test + void testVariableAndNodeContextOverride_InPresenceCondition() { + testExpressionTranslationWithContext( + "for $n in SubNode return $n/../PathNode/TextField", + "ND-Root", + "for context:$n in ND-SubNode return $n::ND-Root::BT-00-Text is present"); + } + + // Row 14 ATTR + @Test + void testVariableAndNodeContextOverride_InAttributeContext() { + testExpressionTranslationWithContext( + "for $n in SubNode return $n/../PathNode/CodeField/@listName", + "ND-Root", + "for context:$n in ND-SubNode return $n::ND-Root::BT-00-Code/@listName"); + } + + // #endregion: Context override combinations + + // #region: Indexer interactions + + @Test + void testIndexer_MultilingualField_InScalarContext() { + // Indexer narrows multilingual field to a single element, allowing scalar use + testExpressionTranslationWithContext( + "(PathNode/TextMultilingualField/normalize-space(text()))[1] = 'test'", + "ND-Root", + "BT-00-Text-Multilingual[1] == 'test'"); + } + + // Note: testIndexer_MultilingualField_InSequenceContext is not possible because + // the 'in' operator uses fieldReference (not fieldContext), and [indexer] is only + // available on fieldContext. This is a grammar-level limitation. + + @Test + void testIndexer_MultilingualFieldWithPredicate_InScalarContext() { + testExpressionTranslationWithContext( + "(PathNode/TextMultilingualField[./@languageID = 'eng']/normalize-space(text()))[1] = 'test'", + "ND-Root", + "BT-00-Text-Multilingual[BT-00-Text-Multilingual/@languageID == 'eng'][1] == 'test'"); + } + + // #endregion: Indexer interactions + + // #endregion: Field Reference Structural Combinations + + // #region: fieldContext Consumer Coverage ----------------------------------- + // Tests covering fieldContext forms (predicate, indexer, absolute) + // in rawValue, context iterator, and template context declaration contexts. + + // #region: rawValue with structural variations + + @Test + void testFieldRawValue_WithIndexer() { + // Indexer applies at node level, then rawValue appends /text() + testExpressionTranslationWithContext( + "PathNode/RepeatableTextField[1]/text() = 'test'", + "ND-Root", + "BT-00-Repeatable-Text[1]:rawValue == 'test'"); + } + + @Test + // @Disabled("BUG: composeIndexer maps MultilingualStringPath back to MultilingualStringPath instead of narrowing to StringPath") + void testFieldRawValue_MultilingualWithIndexer() { + // Indexer should narrow multilingual to scalar, then rawValue appends /text() + testExpressionTranslationWithContext( + "PathNode/TextMultilingualField[1]/text() = 'test'", + "ND-Root", + "BT-00-Text-Multilingual[1]:rawValue == 'test'"); + } + + @Test + void testFieldRawValue_AbsoluteReference() { + testExpressionTranslationWithContext( + "/*/PathNode/TextField/text() = 'test'", + "BT-00-Text", + "/BT-00-Text:rawValue == 'test'"); + } + + @Test + void testFieldRawValue_FieldInRepeatableNode_WithIndexer() { + // Indexer resolves the repeatability, rawValue appends /text() + testExpressionTranslationWithContext( + "RepeatableNode/TextField[1]/text() = 'test'", + "ND-Root", + "BT-00-Text-In-Repeatable-Node[1]:rawValue == 'test'"); + } + + @Test + void testFieldRawValue_WithPredicateAndIndexer() { + // Predicate + indexer at node level, then rawValue appends /text() + testExpressionTranslationWithContext( + "PathNode/RepeatableTextField['a' = 'a'][1]/text() = 'test'", + "ND-Root", + "BT-00-Repeatable-Text['a' == 'a'][1]:rawValue == 'test'"); + } + + @Test + void testFieldRawValue_WithIndexer_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = PathNode/RepeatableTextField[1]/text()", + "ND-Root", + "'test' in BT-00-Repeatable-Text[1]:rawValue"); + } + + @Test + void testFieldRawValue_WithPredicateAndIndexer_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = PathNode/RepeatableTextField['a' = 'a'][1]/text()", + "ND-Root", + "'test' in BT-00-Repeatable-Text['a' == 'a'][1]:rawValue"); + } + + @Test + void testFieldRawValue_AbsoluteReference_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = /*/PathNode/TextField/text()", + "BT-00-Text", + "'test' in /BT-00-Text:rawValue"); + } + + @Test + void testFieldRawValue_AbsoluteReferenceWithPredicate() { + testExpressionTranslationWithContext( + "/*/PathNode/TextField['a' = 'a']/text() = 'test'", + "BT-00-Text", + "/BT-00-Text['a' == 'a']:rawValue == 'test'"); + } + + @Test + void testFieldRawValue_AbsoluteReferenceWithPredicate_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = /*/PathNode/TextField['a' = 'a']/text()", + "BT-00-Text", + "'test' in /BT-00-Text['a' == 'a']:rawValue"); + } + + @Test + void testFieldRawValue_AbsoluteReferenceWithIndexer() { + testExpressionTranslationWithContext( + "/*/PathNode/RepeatableTextField[1]/text() = 'test'", + "BT-00-Repeatable-Text", + "/BT-00-Repeatable-Text[1]:rawValue == 'test'"); + } + + @Test + void testFieldRawValue_AbsoluteReferenceWithIndexer_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = /*/PathNode/RepeatableTextField[1]/text()", + "BT-00-Repeatable-Text", + "'test' in /BT-00-Repeatable-Text[1]:rawValue"); + } + + // #endregion: rawValue with structural variations + + // #region: Context iterator with structural variations + + @Test + void testContextIterator_WithIndexer() { + // Indexer applies at node level in context iterator + testExpressionTranslationWithContext( + "for $x in PathNode/RepeatableTextField[1] return $x = 'test'", + "ND-Root", + "for context:$x in BT-00-Repeatable-Text[1] return $x == 'test'"); + } + + @Test + void testContextIterator_WithPredicateAndIndexer() { + testExpressionTranslationWithContext( + "for $x in PathNode/TextField['a' = 'a'][1] return $x = 'test'", + "ND-Root", + "for context:$x in BT-00-Text['a' == 'a'][1] return $x == 'test'"); + } + + @Test + void testContextIterator_AbsoluteReference() { + testExpressionTranslationWithContext( + "for $x in /*/PathNode/TextField return $x = 'test'", + "BT-00-Text", + "for context:$x in /BT-00-Text return $x == 'test'"); + } + + @Test + void testContextIterator_AbsoluteReferenceWithPredicate() { + testExpressionTranslationWithContext( + "for $x in /*/PathNode/TextField['a' = 'a'] return $x = 'test'", + "BT-00-Text", + "for context:$x in /BT-00-Text['a' == 'a'] return $x == 'test'"); + } + + @Test + void testContextIterator_AbsoluteReferenceWithIndexer() { + testExpressionTranslationWithContext( + "for $x in /*/PathNode/RepeatableTextField[1] return $x = 'test'", + "BT-00-Repeatable-Text", + "for context:$x in /BT-00-Repeatable-Text[1] return $x == 'test'"); + } + + // #endregion: Context iterator with structural variations + + // #endregion: fieldContext Consumer Coverage + + // #region: Cardinality x Consumer Gaps ------------------------------------- + // Tests covering cardinality (repeatable, in-rep-node, multilingual) + // crossed with consumer contexts where coverage was missing. + + // #region: rawValue cardinality + + @Test + void testFieldRawValue_FieldInRepeatableNode_ScalarContext_Throws() { + // Field in repeatable node used as rawValue scalar from root should throw + TypeMismatchException ex = assertThrows(TypeMismatchException.class, + () -> translateExpressionWithContext("ND-Root", + "BT-00-Text-In-Repeatable-Node:rawValue == 'test'")); + assertEquals(TypeMismatchException.ErrorCode.FIELD_MAY_REPEAT, ex.getErrorCode()); + } + + @Test + void testFieldRawValue_FieldInRepeatableNode_SequenceContext() { + testExpressionTranslationWithContext( + "'test' = RepeatableNode/TextField/text()", + "ND-Root", + "'test' in BT-00-Text-In-Repeatable-Node:rawValue"); + } + + @Test + void testFieldRawValue_FieldInRepeatableNode_FromOwnContext() { + testExpressionTranslationWithContext( + "TextField/text() = 'test'", + "ND-RepeatableNode", + "BT-00-Text-In-Repeatable-Node:rawValue == 'test'"); + } + + @Test + void testFieldRawValue_RepeatableField_FromOwnContext_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = ./text()", + "BT-00-Repeatable-Text", + "'test' in BT-00-Repeatable-Text:rawValue"); + } + + // Matrix 3, Row 3 SEQ: Repeatable field from own context in sequence context + @Test + void testSequence_RepeatableFieldFromOwnContext() { + testExpressionTranslationWithContext( + "'test' = ./normalize-space(text())", + "BT-00-Repeatable-Text", + "'test' in BT-00-Repeatable-Text"); + } + + // Matrix 3, Row 4 SEQ: Field in repeatable node from root in sequence context + @Test + void testSequence_FieldInRepeatableNode_FromRoot() { + testExpressionTranslationWithContext( + "'test' = RepeatableNode/TextField/normalize-space(text())", + "ND-Root", + "'test' in BT-00-Text-In-Repeatable-Node"); + } + + // Matrix 3, Row 5 SEQ: Field in repeatable node from own node in sequence context + @Test + void testSequence_FieldInRepeatableNode_FromOwnNode() { + testExpressionTranslationWithContext( + "'test' = TextField/normalize-space(text())", + "ND-RepeatableNode", + "'test' in BT-00-Text-In-Repeatable-Node"); + } + + // Matrix 3, Row 5 RV-SEQ: Field in repeatable node from own node rawValue in sequence context + @Test + void testFieldRawValue_FieldInRepeatableNode_FromOwnNode_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = TextField/text()", + "ND-RepeatableNode", + "'test' in BT-00-Text-In-Repeatable-Node:rawValue"); + } + + // Matrix 3, Row 7 SEQ: Multilingual[pred] in sequence context + @Test + void testSequence_MultilingualWithPredicate() { + testExpressionTranslationWithContext( + "'test' = PathNode/TextMultilingualField['a' = 'a']/normalize-space(text())", + "ND-Root", + "'test' in BT-00-Text-Multilingual['a' == 'a']"); + } + + // Matrix 3, Row 7 RV-SC: Multilingual[pred]:rawValue in scalar context — should throw FIELD_IS_MULTILINGUAL + @Test + void testFieldRawValue_MultilingualWithPredicate_InScalarContext_Throws() { + TypeMismatchException ex = assertThrows(TypeMismatchException.class, + () -> translateExpressionWithContext("ND-Root", + "BT-00-Text-Multilingual['a' == 'a']:rawValue == 'test'")); + assertEquals(TypeMismatchException.ErrorCode.FIELD_IS_MULTILINGUAL, ex.getErrorCode()); + } + + // Matrix 3, Row 7 RV-SEQ: Multilingual[pred]:rawValue in sequence context + @Test + void testFieldRawValue_MultilingualWithPredicate_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = PathNode/TextMultilingualField['a' = 'a']/text()", + "ND-Root", + "'test' in BT-00-Text-Multilingual['a' == 'a']:rawValue"); + } + + // Matrix 3, Row 8 RV-SEQ: Multilingual[idx]:rawValue in sequence context + @Test + void testFieldRawValue_MultilingualWithIndexer_InSequenceContext() { + testExpressionTranslationWithContext( + "'test' = PathNode/TextMultilingualField[1]/text()", + "ND-Root", + "'test' in BT-00-Text-Multilingual[1]:rawValue"); + } + + // #endregion: rawValue cardinality + + // #region: Privacy property cardinality + + @Test + void testComputedProperty_wasWithheld_FieldInRepeatableNode_FromRoot() { + testExpressionTranslationWithContext( + "RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']/FieldIdentifierCode/normalize-space(text()) = 'test-priv'", + "ND-Root", + "BT-00-Text-In-Repeatable-Node:wasWithheld"); + } + + @Test + void testComputedProperty_isWithheld_FieldInRepeatableNode_FromRoot() { + final String privacyPath = "RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + testExpressionTranslationWithContext( + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + + " and " + + "(not(" + privacyPath + "/PublicationDate)" + + " or " + + privacyPath + "/PublicationDate/xs:date(text()) > current-date())", + "ND-Root", + "BT-00-Text-In-Repeatable-Node:isWithheld"); + } + + @Test + void testComputedProperty_isDisclosed_FieldInRepeatableNode_FromRoot() { + final String privacyPath = "RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + testExpressionTranslationWithContext( + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + + " and " + + "not((not(" + privacyPath + "/PublicationDate)" + + " or " + + privacyPath + "/PublicationDate/xs:date(text()) > current-date()))" + + " and " + + "not(RepeatableNode/TextField/normalize-space(text()) = 'unpublished')", + "ND-Root", + "BT-00-Text-In-Repeatable-Node:isDisclosed"); + } + + @Test + void testComputedProperty_isMasked_FieldInRepeatableNode_FromRoot() { + final String privacyPath = "RepeatableNode/FieldsPrivacy[FieldIdentifierCode/text()='test-priv']"; + testExpressionTranslationWithContext( + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'test-priv'" + + " and " + + "RepeatableNode/TextField/normalize-space(text()) = 'unpublished'", + "ND-Root", + "BT-00-Text-In-Repeatable-Node:isMasked"); + } + + // Matrix 4, Row 1 PC: privacyCode on withholdable scalar field + @Test + void testComputedProperty_privacyCode_WithholdableField() { + testExpressionTranslationWithContext( + "'test-priv'", + "BT-00-Text-In-Repeatable-Node", + "BT-00-Text-In-Repeatable-Node:privacyCode"); + } + + // Matrix 4, Row 2 PC: privacyCode on non-withholdable field — should throw FIELD_NOT_WITHHOLDABLE + @Test + void testComputedProperty_privacyCode_NonWithholdableField_Throws() { + InvalidUsageException ex = assertThrows(InvalidUsageException.class, + () -> translateExpressionWithContext("BT-00-Text", + "BT-00-Text:privacyCode")); + assertEquals(InvalidUsageException.ErrorCode.FIELD_NOT_WITHHOLDABLE, ex.getErrorCode()); + } + + // Matrix 4, Row 3 IH: isWithholdable on repeatable field from root + @Test + void testComputedProperty_isWithholdable_RepeatableFromRoot() { + testExpressionTranslationWithContext( + "true()", + "ND-Root", + "BT-00-Repeatable-Text:isWithholdable"); + } + + // Matrix 4, Row 3 ID: isDisclosed on repeatable field from root + @Test + void testComputedProperty_isDisclosed_RepeatableFromRoot() { + final String privacyPath = "PathNode/FieldsPrivacy[FieldIdentifierCode/text()='rep-text-priv']"; + testExpressionTranslationWithContext( + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'rep-text-priv'" + + " and " + + "not((not(" + privacyPath + "/PublicationDate)" + + " or " + + privacyPath + "/PublicationDate/xs:date(text()) > current-date()))" + + " and " + + "not(PathNode/RepeatableTextField/normalize-space(text()) = 'unpublished')", + "ND-Root", + "BT-00-Repeatable-Text:isDisclosed"); + } + + // Matrix 4, Row 4 ID: isDisclosed on repeatable field from own context + @Test + void testComputedProperty_isDisclosed_RepeatableFromOwnContext() { + final String privacyPath = "../FieldsPrivacy[FieldIdentifierCode/text()='rep-text-priv']"; + testExpressionTranslationWithContext( + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'rep-text-priv'" + + " and " + + "not((not(" + privacyPath + "/PublicationDate)" + + " or " + + privacyPath + "/PublicationDate/xs:date(text()) > current-date()))" + + " and " + + "not(./normalize-space(text()) = 'unpublished')", + "BT-00-Repeatable-Text", + "BT-00-Repeatable-Text:isDisclosed"); + } + + // Matrix 4, Row 4 IM: isMasked on repeatable field from own context + @Test + void testComputedProperty_isMasked_RepeatableFromOwnContext() { + final String privacyPath = "../FieldsPrivacy[FieldIdentifierCode/text()='rep-text-priv']"; + testExpressionTranslationWithContext( + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'rep-text-priv'" + + " and " + + "./normalize-space(text()) = 'unpublished'", + "BT-00-Repeatable-Text", + "BT-00-Repeatable-Text:isMasked"); + } + + // Matrix 4, Row 5 IH: isWithholdable on field in repeatable node from root + @Test + void testComputedProperty_isWithholdable_FieldInRepeatableNode_FromRoot() { + testExpressionTranslationWithContext( + "true()", + "ND-Root", + "BT-00-Text-In-Repeatable-Node:isWithholdable"); + } + + // Matrix 4, Row 6 WW: wasWithheld on multilingual field + @Test + void testComputedProperty_wasWithheld_Multilingual() { + testExpressionTranslationWithContext( + "PathNode/FieldsPrivacy[FieldIdentifierCode/text()='ml-text-priv']/FieldIdentifierCode/normalize-space(text()) = 'ml-text-priv'", + "ND-Root", + "BT-00-Text-Multilingual:wasWithheld"); + } + + // Matrix 4, Row 6 IW: isWithheld on multilingual field + @Test + void testComputedProperty_isWithheld_Multilingual() { + final String privacyPath = "PathNode/FieldsPrivacy[FieldIdentifierCode/text()='ml-text-priv']"; + testExpressionTranslationWithContext( + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'ml-text-priv'" + + " and " + + "(not(" + privacyPath + "/PublicationDate)" + + " or " + + privacyPath + "/PublicationDate/xs:date(text()) > current-date())", + "ND-Root", + "BT-00-Text-Multilingual:isWithheld"); + } + + // Matrix 4, Row 6 ID: isDisclosed on multilingual field + @Test + void testComputedProperty_isDisclosed_Multilingual() { + final String privacyPath = "PathNode/FieldsPrivacy[FieldIdentifierCode/text()='ml-text-priv']"; + testExpressionTranslationWithContext( + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'ml-text-priv'" + + " and " + + "not((not(" + privacyPath + "/PublicationDate)" + + " or " + + privacyPath + "/PublicationDate/xs:date(text()) > current-date()))" + + " and " + + "not(PathNode/TextMultilingualField/normalize-space(text()) = 'unpublished')", + "ND-Root", + "BT-00-Text-Multilingual:isDisclosed"); + } + + // Matrix 4, Row 6 IM: isMasked on multilingual field + @Test + void testComputedProperty_isMasked_Multilingual() { + final String privacyPath = "PathNode/FieldsPrivacy[FieldIdentifierCode/text()='ml-text-priv']"; + testExpressionTranslationWithContext( + privacyPath + "/FieldIdentifierCode/normalize-space(text()) = 'ml-text-priv'" + + " and " + + "PathNode/TextMultilingualField/normalize-space(text()) = 'unpublished'", + "ND-Root", + "BT-00-Text-Multilingual:isMasked"); + } + + // Matrix 4, Row 3 PC: privacyCode on repeatable field from root + @Test + void testComputedProperty_privacyCode_RepeatableFromRoot() { + testExpressionTranslationWithContext( + "'rep-text-priv'", + "ND-Root", + "BT-00-Repeatable-Text:privacyCode"); + } + + // Matrix 4, Row 4 IH: isWithholdable on repeatable field from own context + @Test + void testComputedProperty_isWithholdable_RepeatableFromOwnContext() { + testExpressionTranslationWithContext( + "true()", + "BT-00-Repeatable-Text", + "BT-00-Repeatable-Text:isWithholdable"); + } + + // Matrix 4, Row 4 PC: privacyCode on repeatable field from own context + @Test + void testComputedProperty_privacyCode_RepeatableFromOwnContext() { + testExpressionTranslationWithContext( + "'rep-text-priv'", + "BT-00-Repeatable-Text", + "BT-00-Repeatable-Text:privacyCode"); + } + + // Matrix 4, Row 5 PC: privacyCode on field in repeatable node from root + @Test + void testComputedProperty_privacyCode_FieldInRepeatableNode_FromRoot() { + testExpressionTranslationWithContext( + "'test-priv'", + "ND-Root", + "BT-00-Text-In-Repeatable-Node:privacyCode"); + } + + // Matrix 4, Row 6 IH: isWithholdable on multilingual field + @Test + void testComputedProperty_isWithholdable_Multilingual() { + testExpressionTranslationWithContext( + "true()", + "ND-Root", + "BT-00-Text-Multilingual:isWithholdable"); + } + + // Matrix 4, Row 6 PC: privacyCode on multilingual field — succeeds (compile-time constant) + @Test + void testComputedProperty_privacyCode_Multilingual() { + testExpressionTranslationWithContext( + "'ml-text-priv'", + "ND-Root", + "BT-00-Text-Multilingual:privacyCode"); + } + + // #endregion: Privacy property cardinality + + // #endregion: Cardinality x Consumer Gaps } diff --git a/src/test/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2Test.java b/src/test/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2Test.java index c84698fe..39b03738 100644 --- a/src/test/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2Test.java +++ b/src/test/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2Test.java @@ -1558,6 +1558,176 @@ void testPreferredLanguageTextFunction() { translateTemplate("{/} ${preferred-language-text(BT-00-Text-Multilingual)}")); } + @Test + void testPreferredLanguageFunction_WithPredicate() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language(PathNode/TextMultilingualField['a' = 'a'])) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${preferred-language(BT-00-Text-Multilingual['a' == 'a'])}")); + } + + @Test + void testPreferredLanguageTextFunction_WithPredicate() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language-text(PathNode/TextMultilingualField['a' = 'a'])) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${preferred-language-text(BT-00-Text-Multilingual['a' == 'a'])}")); + } + + @Test + void testPreferredLanguageFunction_RepeatableField() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language(SubNode/RepeatableInSubNode/TextMultilingual)) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${preferred-language(BT-13-TextMultilingual)}")); + } + + @Test + void testPreferredLanguageTextFunction_RepeatableField() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language-text(SubNode/RepeatableInSubNode/TextMultilingual)) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${preferred-language-text(BT-13-TextMultilingual)}")); + } + + @Test + void testPreferredLanguageFunction_RepeatableFieldWithIndexer() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language(SubNode/RepeatableInSubNode/TextMultilingual[1])) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${preferred-language(BT-13-TextMultilingual[1])}")); + } + + @Test + void testPreferredLanguageTextFunction_RepeatableFieldWithIndexer() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language-text(SubNode/RepeatableInSubNode/TextMultilingual[1])) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${preferred-language-text(BT-13-TextMultilingual[1])}")); + } + + @Test + void testPreferredLanguageTextFunction_RepeatableFieldWithPredicateAndIndexer() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language-text(SubNode/RepeatableInSubNode/TextMultilingual['a' = 'a'][1])) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${preferred-language-text(BT-13-TextMultilingual['a' == 'a'][1])}")); + } + + @Test + void testPreferredLanguageFunction_NonMultilingualField_Throws() { + assertThrows(TypeMismatchException.class, + () -> translateTemplate("{/} ${preferred-language(BT-00-Text)}")); + } + + @Test + void testPreferredLanguageTextFunction_NonMultilingualField_Throws() { + assertThrows(TypeMismatchException.class, + () -> translateTemplate("{/} ${preferred-language-text(BT-00-Text)}")); + } + + // Property syntax: fieldContext:preferredLanguage / fieldContext:preferredLanguageText + + @Test + void testPreferredLanguageProperty() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language(PathNode/TextMultilingualField)) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${BT-00-Text-Multilingual:preferredLanguage}")); + } + + @Test + void testPreferredLanguageTextProperty() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language-text(PathNode/TextMultilingualField)) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${BT-00-Text-Multilingual:preferredLanguageText}")); + } + + @Test + void testPreferredLanguageProperty_WithPredicate() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language(PathNode/TextMultilingualField['a' = 'a'])) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${BT-00-Text-Multilingual['a' == 'a']:preferredLanguage}")); + } + + @Test + void testPreferredLanguageTextProperty_WithPredicate() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language-text(PathNode/TextMultilingualField['a' = 'a'])) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${BT-00-Text-Multilingual['a' == 'a']:preferredLanguageText}")); + } + + @Test + void testPreferredLanguageProperty_RepeatableField() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language(SubNode/RepeatableInSubNode/TextMultilingual)) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${BT-13-TextMultilingual:preferredLanguage}")); + } + + @Test + void testPreferredLanguageTextProperty_RepeatableField() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language-text(SubNode/RepeatableInSubNode/TextMultilingual)) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${BT-13-TextMultilingual:preferredLanguageText}")); + } + + @Test + void testPreferredLanguageProperty_RepeatableFieldWithIndexer() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language(SubNode/RepeatableInSubNode/TextMultilingual[1])) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${BT-13-TextMultilingual[1]:preferredLanguage}")); + } + + @Test + void testPreferredLanguageTextProperty_RepeatableFieldWithIndexer() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language-text(SubNode/RepeatableInSubNode/TextMultilingual[1])) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${BT-13-TextMultilingual[1]:preferredLanguageText}")); + } + + @Test + void testPreferredLanguageTextProperty_RepeatableFieldWithPredicateAndIndexer() { + assertEquals( + lines("TEMPLATES:", + "let body01() -> { eval(efx:preferred-language-text(SubNode/RepeatableInSubNode/TextMultilingual['a' = 'a'][1])) }", + "MAIN:", "for-each(/*).call(body01())"), + translateTemplate("{/} ${BT-13-TextMultilingual['a' == 'a'][1]:preferredLanguageText}")); + } + + @Test + void testPreferredLanguageProperty_NonMultilingualField_Throws() { + assertThrows(TypeMismatchException.class, + () -> translateTemplate("{/} ${BT-00-Text:preferredLanguage}")); + } + + @Test + void testPreferredLanguageTextProperty_NonMultilingualField_Throws() { + assertThrows(TypeMismatchException.class, + () -> translateTemplate("{/} ${BT-00-Text:preferredLanguageText}")); + } + // #endregion Preferred language functions ------------------------------------- // #region contextDeclarationBlock ------------------------------------------- @@ -2070,4 +2240,63 @@ void testInclude_SingleFile_SameOutputAsInlined() throws Exception { } // #endregion Include directive ------------------------------------------------ + + // #region: fieldContext structural variations in template context declarations + + @Test + void testContextDeclaration_WithFieldContextIndexer() { + assertEquals( + lines( + "TEMPLATES:", + "let body01() -> { eval(./normalize-space(text())) }", + "MAIN:", + "for-each(/*/PathNode/RepeatableTextField[1]).call(body01())"), + translateTemplate("{BT-00-Repeatable-Text[1]} ${BT-00-Repeatable-Text}")); + } + + @Test + void testContextDeclaration_WithFieldContextPredicate() { + assertEquals( + lines( + "TEMPLATES:", + "let body01() -> { eval(./normalize-space(text())) }", + "MAIN:", + "for-each(/*/PathNode/TextField['a' = 'a']).call(body01())"), + translateTemplate("{BT-00-Text['a' == 'a']} ${BT-00-Text}")); + } + + @Test + void testContextDeclaration_WithFieldContextPredicateAndIndexer() { + assertEquals( + lines( + "TEMPLATES:", + "let body01() -> { eval(./normalize-space(text())) }", + "MAIN:", + "for-each(/*/PathNode/RepeatableTextField['a' = 'a'][1]).call(body01())"), + translateTemplate("{BT-00-Repeatable-Text['a' == 'a'][1]} ${BT-00-Repeatable-Text}")); + } + + @Test + void testContextDeclaration_AbsoluteReferenceWithPredicate() { + assertEquals( + lines( + "TEMPLATES:", + "let body01() -> { eval(./normalize-space(text())) }", + "MAIN:", + "for-each(/*/PathNode/TextField['a' = 'a']).call(body01())"), + translateTemplate("{/BT-00-Text['a' == 'a']} ${BT-00-Text}")); + } + + @Test + void testContextDeclaration_AbsoluteReferenceWithIndexer() { + assertEquals( + lines( + "TEMPLATES:", + "let body01() -> { eval(./normalize-space(text())) }", + "MAIN:", + "for-each(/*/PathNode/RepeatableTextField[1]).call(body01())"), + translateTemplate("{/BT-00-Repeatable-Text[1]} ${BT-00-Repeatable-Text}")); + } + + // #endregion: fieldContext structural variations in template context declarations } \ No newline at end of file diff --git a/src/test/java/eu/europa/ted/efx/sdk2/SdkSymbolResolverTest.java b/src/test/java/eu/europa/ted/efx/sdk2/SdkSymbolResolverTest.java index b2cf014c..2dfe0550 100644 --- a/src/test/java/eu/europa/ted/efx/sdk2/SdkSymbolResolverTest.java +++ b/src/test/java/eu/europa/ted/efx/sdk2/SdkSymbolResolverTest.java @@ -35,7 +35,7 @@ import eu.europa.ted.efx.model.expressions.scalar.BooleanPath; import eu.europa.ted.efx.model.expressions.scalar.DatePath; import eu.europa.ted.efx.model.expressions.scalar.DurationPath; -import eu.europa.ted.efx.model.expressions.scalar.MultilingualStringPath; +import eu.europa.ted.efx.model.expressions.sequence.MultilingualStringSequencePath; import eu.europa.ted.efx.model.expressions.scalar.NodePath; import eu.europa.ted.efx.model.expressions.scalar.NumericPath; import eu.europa.ted.efx.model.expressions.scalar.ScalarPath; @@ -365,11 +365,11 @@ void codeField_shouldReturnStringPath() { } @Test - @DisplayName("Multilingual field returns MultilingualStringPath") - void multilingualField_shouldReturnMultilingualStringPath() { + @DisplayName("Multilingual field returns MultilingualStringSequencePath") + void multilingualField_shouldReturnMultilingualStringSequencePath() { PathExpression path = resolver.getAbsolutePathOfField("BT-00-Text-Multilingual"); - assertEquals(MultilingualStringPath.class, path.getClass(), - "Multilingual field should return MultilingualStringPath"); + assertEquals(MultilingualStringSequencePath.class, path.getClass(), + "Multilingual field should return MultilingualStringSequencePath"); } @Test diff --git a/src/test/resources/json/sdk2-fields.json b/src/test/resources/json/sdk2-fields.json index e43e391b..a8e69046 100644 --- a/src/test/resources/json/sdk2-fields.json +++ b/src/test/resources/json/sdk2-fields.json @@ -103,6 +103,13 @@ "xpathRelative": "PathNode/FieldsPrivacy[FieldIdentifierCode/text()='rep-text-priv']", "repeatable": false }, + { + "id": "ND-PrivacyForMultilingualText", + "parentId": "ND-Root", + "xpathAbsolute": "/*/PathNode/FieldsPrivacy[FieldIdentifierCode/text()='ml-text-priv']", + "xpathRelative": "PathNode/FieldsPrivacy[FieldIdentifierCode/text()='ml-text-priv']", + "repeatable": false + }, { "id": "ND-PrivacyInRepeatableNode", "parentId": "ND-RepeatableNode", @@ -194,7 +201,14 @@ "type": "text-multilingual", "parentNodeId": "ND-Root", "xpathAbsolute": "/*/PathNode/TextMultilingualField", - "xpathRelative": "PathNode/TextMultilingualField" + "xpathRelative": "PathNode/TextMultilingualField", + "privacy": { + "code": "ml-text-priv", + "unpublishedFieldId": "BT-195(BT-00)-Text-Multilingual", + "reasonCodeFieldId": "BT-197(BT-00)-Text-Multilingual", + "reasonDescriptionFieldId": "BT-196(BT-00)-Text-Multilingual", + "publicationDateFieldId": "BT-198(BT-00)-Text-Multilingual" + } }, { "id": "BT-00-StartDate", @@ -1085,6 +1099,35 @@ "parentNodeId": "ND-PrivacyForRepeatableText", "xpathAbsolute": "/*/PathNode/FieldsPrivacy[FieldIdentifierCode/text()='rep-text-priv']/PublicationDate", "xpathRelative": "PublicationDate" + }, + { + "id": "BT-195(BT-00)-Text-Multilingual", + "type": "code", + "parentNodeId": "ND-PrivacyForMultilingualText", + "xpathAbsolute": "/*/PathNode/FieldsPrivacy[FieldIdentifierCode/text()='ml-text-priv']/FieldIdentifierCode", + "xpathRelative": "FieldIdentifierCode", + "presetValue": "ml-text-priv" + }, + { + "id": "BT-196(BT-00)-Text-Multilingual", + "type": "text-multilingual", + "parentNodeId": "ND-PrivacyForMultilingualText", + "xpathAbsolute": "/*/PathNode/FieldsPrivacy[FieldIdentifierCode/text()='ml-text-priv']/ReasonDescription", + "xpathRelative": "ReasonDescription" + }, + { + "id": "BT-197(BT-00)-Text-Multilingual", + "type": "code", + "parentNodeId": "ND-PrivacyForMultilingualText", + "xpathAbsolute": "/*/PathNode/FieldsPrivacy[FieldIdentifierCode/text()='ml-text-priv']/ReasonCode", + "xpathRelative": "ReasonCode" + }, + { + "id": "BT-198(BT-00)-Text-Multilingual", + "type": "date", + "parentNodeId": "ND-PrivacyForMultilingualText", + "xpathAbsolute": "/*/PathNode/FieldsPrivacy[FieldIdentifierCode/text()='ml-text-priv']/PublicationDate", + "xpathRelative": "PublicationDate" } ] }