From d6c04f192b6f627ade54068f65f0bbcdacf5d6ea Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Mon, 27 Jul 2026 11:39:07 -0400 Subject: [PATCH 01/36] make reflection cache updates atomic --- .../hlaxapi/HlaInterfaceImpl.java | 8 +- .../hlaxapi/cache/JdbcObjectCacheStore.java | 33 ++-- .../hlaxapi/cache/ObjectCache.java | 39 ++++- .../hlaxapi/cache/ObjectCacheStore.java | 5 +- .../cache/ReflectedAttributeValues.java | 12 ++ .../cache/ObjectCachePersistenceTest.java | 155 ++++++++++++++++++ 6 files changed, 221 insertions(+), 31 deletions(-) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/cache/ReflectedAttributeValues.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index 5498505..b70696a 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -316,14 +316,12 @@ private void reflectAttributeValues(ObjectInstanceHandle theObject, AttributeHan try { ObjectClassHandle classHandle = ambassador.getKnownObjectClassHandle(theObject); String className = StringUtils.substringAfterLast(ambassador.getObjectClassName(classHandle), "."); + Map attributes = new HashMap<>(); for (AttributeHandle attributeHandle : theAttributes.keySet()) { String attributeName = ambassador.getAttributeName(classHandle, attributeHandle); - objectCache.reflectAttributeValue( - theObject.toString(), - className, - attributeName, - theAttributes.get(attributeHandle)); + attributes.put(attributeName, theAttributes.get(attributeHandle)); } + objectCache.reflectAttributeValues(theObject.toString(), className, attributes); } catch (AttributeNotDefined | InvalidAttributeHandle | InvalidObjectClassHandle | ObjectInstanceNotKnown | FederateNotExecutionMember | NotConnected | RTIinternalError | RuntimeException e) { logger.error("Error caching reflected object attributes", e); diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java b/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java index 45d9304..6c86e98 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java @@ -117,32 +117,37 @@ public List currentObjects(FomCatalog.ObjectClassDef clazz) { @Override public void replaceCurrentValues( - long instanceId, + String objectHandle, FomCatalog.ObjectClassDef clazz, - String attributeName, - List values, + List attributes, String observedAt, long observedSequence) { + if (attributes == null || attributes.isEmpty()) { + return; + } boolean autoCommit = currentAutoCommit(); try { connection.setAutoCommit(false); - deleteCurrentValues(instanceId, clazz.id(), attributeName); - for (DecodedAttributeValue value : values) { - Optional attributeId = attributeIdForPath(clazz, value.pathKey()); - if (attributeId.isPresent()) { - upsertCurrentValue( - instanceId, - attributeId.orElseThrow(), - value, - observedAt, - observedSequence); + CachedObject object = ensureObject(objectHandle, null, clazz); + for (ReflectedAttributeValues attribute : attributes) { + deleteCurrentValues(object.id(), clazz.id(), attribute.attributeName()); + for (DecodedAttributeValue value : attribute.values()) { + Optional attributeId = attributeIdForPath(clazz, value.pathKey()); + if (attributeId.isPresent()) { + upsertCurrentValue( + object.id(), + attributeId.orElseThrow(), + value, + observedAt, + observedSequence); + } } } connection.commit(); } catch (SQLException | RuntimeException e) { rollbackAfterReplacementFailure(e); throw new IllegalStateException( - "Could not replace current object attribute " + attributeName, e); + "Could not replace reflected object attributes", e); } finally { restoreAutoCommit(autoCommit); } diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java index 57b622f..b42cfb9 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java @@ -8,6 +8,7 @@ import com.yetanalytics.hlaxapi.config.model.TrackedObject; import java.sql.Connection; import java.time.Instant; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -121,27 +122,43 @@ public synchronized void discoverObject(String objectHandle, String objectName, } } - public synchronized void reflectAttributeValue( + public void reflectAttributeValue( String objectHandle, String className, String attributeName, byte[] bytes) { + reflectAttributeValues(objectHandle, className, Map.of(attributeName, bytes)); + } + + public synchronized void reflectAttributeValues( + String objectHandle, + String className, + Map attributes) { if (!isEnabled()) { return; } + if (attributes == null || attributes.isEmpty()) { + return; + } FomCatalog.ObjectClassDef clazz = requireClass(className); - CachedObject object = store.ensureObject(objectHandle, null, clazz); - FomCatalog.FomAttribute topAttribute = clazz.attribute(attributeName) - .orElseThrow(() -> new IllegalArgumentException( - "No FOM attribute " + attributeName + " on object class " + className)); - List values = valueFlattener.flatten(attributeName, topAttribute.dataType(), bytes); + List reflectedAttributes = new ArrayList<>(attributes.size()); + for (Map.Entry attribute : attributes.entrySet()) { + String attributeName = attribute.getKey(); + FomCatalog.FomAttribute topAttribute = clazz.attribute(attributeName) + .orElseThrow(() -> new IllegalArgumentException( + "No FOM attribute " + attributeName + " on object class " + className)); + List values = valueFlattener.flatten( + attributeName, + topAttribute.dataType(), + attribute.getValue()); + reflectedAttributes.add(new ReflectedAttributeValues(attributeName, values)); + } String observedAt = Instant.now().toString(); long observedSequence = sequence.incrementAndGet(); store.replaceCurrentValues( - object.id(), + objectHandle, clazz, - attributeName, - values, + reflectedAttributes, observedAt, observedSequence); } @@ -179,6 +196,10 @@ Connection connection() { return store == null ? null : store.connection(); } + ObjectCacheStore store() { + return store; + } + @Override public synchronized void close() { if (store == null) { diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheStore.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheStore.java index 94a5219..a129947 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheStore.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheStore.java @@ -18,10 +18,9 @@ interface ObjectCacheStore extends AutoCloseable { List currentObjects(FomCatalog.ObjectClassDef clazz); void replaceCurrentValues( - long instanceId, + String objectHandle, FomCatalog.ObjectClassDef clazz, - String attributeName, - List values, + List attributes, String observedAt, long observedSequence); diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ReflectedAttributeValues.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ReflectedAttributeValues.java new file mode 100644 index 0000000..32184d2 --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ReflectedAttributeValues.java @@ -0,0 +1,12 @@ +package com.yetanalytics.hlaxapi.cache; + +import java.util.List; +import java.util.Objects; + +record ReflectedAttributeValues(String attributeName, List values) { + + ReflectedAttributeValues { + Objects.requireNonNull(attributeName, "attributeName"); + values = List.copyOf(Objects.requireNonNull(values, "values")); + } +} diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java index 6c1ed37..fdedd64 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.yetanalytics.hlaxapi.FOMXML; @@ -27,6 +28,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; @@ -73,6 +75,159 @@ SELECT COUNT(*) } } + @Test + void storesOneMultiAttributeReflectionWithSharedObservationMetadata() throws SQLException { + byte[] entityId = encoded(encoderFactory.createHLAASCIIstring("rabbit-one")); + byte[] hunger = encoded(encoderFactory.createHLAinteger32BE(75)); + byte[] position = position(12, 8); + + try (ObjectCache cache = newCache()) { + cache.reflectAttributeValues( + "object-1", + "Rabbit", + Map.of( + "EntityId", entityId, + "Hunger", hunger, + "Position", position)); + + assertEquals("rabbit-one", cache.findCurrentValue("object-1", "EntityId").orElseThrow().value()); + assertEquals(75, cache.findCurrentValue("object-1", "Hunger").orElseThrow().value()); + assertEquals(12, cache.findCurrentValue("object-1", "Position.X").orElseThrow().value()); + assertEquals(8, cache.findCurrentValue("object-1", "Position.Y").orElseThrow().value()); + assertEquals(1, count(cache, "SELECT COUNT(DISTINCT observed_at) FROM object_attribute_current")); + assertEquals(1, count(cache, "SELECT COUNT(DISTINCT sequence) FROM object_attribute_current")); + assertEquals(1, count(cache, "SELECT COUNT(*) FROM object_instance WHERE object_handle = 'object-1'")); + } + } + + @Test + void validatesTheCompleteReflectionBeforeWriting() { + byte[] oldHunger = encoded(encoderFactory.createHLAinteger32BE(40)); + byte[] newHunger = encoded(encoderFactory.createHLAinteger32BE(75)); + + try (ObjectCache cache = newCache()) { + cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", oldHunger); + + assertThrows( + IllegalArgumentException.class, + () -> cache.reflectAttributeValues( + "object-1", + "Rabbit", + Map.of( + "Hunger", newHunger, + "NotInTheFom", new byte[] { 1 }))); + + assertEquals(40, cache.findCurrentValue("object-1", "Hunger").orElseThrow().value()); + } + } + + @Test + void ignoresEmptyReflections() throws SQLException { + try (ObjectCache cache = newCache()) { + cache.reflectAttributeValues("object-1", "Rabbit", Map.of()); + + assertEquals(0, count(cache, "SELECT COUNT(*) FROM object_instance")); + assertEquals(0, count(cache, "SELECT COUNT(*) FROM object_attribute_current")); + } + } + + @Test + void rollsBackEveryExistingValueWhenAReflectionFails() { + byte[] oldEntityId = encoded(encoderFactory.createHLAASCIIstring("rabbit-old")); + byte[] oldHunger = encoded(encoderFactory.createHLAinteger32BE(40)); + byte[] newHunger = encoded(encoderFactory.createHLAinteger32BE(75)); + + try (ObjectCache cache = newCache()) { + cache.reflectAttributeValues( + "object-1", + "Rabbit", + Map.of( + "EntityId", oldEntityId, + "Hunger", oldHunger)); + FomCatalog.ObjectClassDef rabbit = catalog.objectClass("Rabbit").orElseThrow(); + ReflectedAttributeValues hunger = new ReflectedAttributeValues( + "Hunger", + List.of(new DecodedAttributeValue( + "Hunger", + "HLAinteger32BE", + "HLAinteger32BE", + 75, + newHunger, + true))); + ReflectedAttributeValues entityId = new ReflectedAttributeValues( + "EntityId", + List.of(new DecodedAttributeValue( + "EntityId", + "HLAASCIIstring", + "HLAASCIIstring", + new Object(), + oldEntityId, + true))); + + assertThrows( + IllegalStateException.class, + () -> cache.store().replaceCurrentValues( + "object-1", + rabbit, + List.of(hunger, entityId), + "2026-07-27T00:00:00Z", + 2)); + + assertEquals("rabbit-old", cache.findCurrentValue("object-1", "EntityId").orElseThrow().value()); + assertEquals(40, cache.findCurrentValue("object-1", "Hunger").orElseThrow().value()); + } + } + + @Test + void rollsBackObjectValuesAndDynamicMetadataWhenAReflectionFails() throws SQLException { + try (ObjectCache cache = newCache( + "reflection-rollback", + enabledConfig(), + dynamicArrayCatalog, + dynamicArrayFomXml)) { + FomCatalog.ObjectClassDef rabbit = dynamicArrayCatalog.objectClass("Rabbit").orElseThrow(); + byte[] encodedValue = encoded(encoderFactory.createHLAinteger32BE(1)); + ReflectedAttributeValues positionHistory = new ReflectedAttributeValues( + "PositionHistory", + List.of( + new DecodedAttributeValue( + "PositionHistory[0].X", + "HLAinteger32BE", + "HLAinteger32BE", + 1, + encodedValue, + true), + new DecodedAttributeValue( + "PositionHistory[0].Y", + "HLAinteger32BE", + "HLAinteger32BE", + new Object(), + encodedValue, + true))); + + assertThrows( + IllegalStateException.class, + () -> cache.store().replaceCurrentValues( + "object-rollback", + rabbit, + List.of(positionHistory), + "2026-07-27T00:00:00Z", + 1)); + + assertEquals( + 0, + count(cache, "SELECT COUNT(*) FROM object_instance WHERE object_handle = 'object-rollback'")); + assertEquals( + 0, + count(cache, """ + SELECT COUNT(*) + FROM fom_attribute + WHERE path_key LIKE 'PositionHistory[0].%' + """)); + assertEquals(0, count(cache, "SELECT COUNT(*) FROM object_attribute_current")); + } + } + @Test void flattensFixedRecordValuesToNestedCurrentRows() { byte[] position = position(12, 8); From d151cf770e6621de16506b742ed6dd8e4ba859b9 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Mon, 27 Jul 2026 13:14:57 -0400 Subject: [PATCH 02/36] add class, obj handle, attributes to ObjectInjectionContext --- .../hlaxapi/HlaInterfaceImpl.java | 2 +- .../hlaxapi/InjectionHandler.java | 124 ++++++++++++--- .../injection/ObjectInjectionContext.java | 32 ++++ .../injection/TestInjectionContext.java | 16 ++ .../hlaxapi/ObjectInjectionHandlerTest.java | 147 ++++++++++++++++++ src/test/resources/object-update-fom.xml | 62 ++++++++ 6 files changed, 358 insertions(+), 25 deletions(-) create mode 100644 src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java create mode 100644 src/test/resources/object-update-fom.xml diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index b70696a..8b5bc96 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -196,7 +196,7 @@ public void validateConfig() throws XapiConfigurationException { if (st.skipValidation) continue; TriggerProcessingResult tpr = triggerProcessor.renderTemplateForValidation( st, - new TestInjectionContext(st.clazz)); + new TestInjectionContext(st.type, st.clazz)); if (tpr.success()) { StatementValidationResult svr = validator.validateStatement(tpr.statement()); if (!svr.isValid()){ diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java index 77dca75..fe967da 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -13,11 +13,13 @@ import com.yetanalytics.hlaxapi.FOMXML.PathCheckResult; import com.yetanalytics.hlaxapi.cache.CachedObject; +import com.yetanalytics.hlaxapi.cache.FomCatalog; import com.yetanalytics.hlaxapi.cache.ObjectCache; import com.yetanalytics.hlaxapi.cache.ValueResolution; import com.yetanalytics.hlaxapi.config.model.Expression; import com.yetanalytics.hlaxapi.config.model.ExpressionWalker; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TriggerExpression; import com.yetanalytics.hlaxapi.config.model.ValueExpression; @@ -49,6 +51,9 @@ public class InjectionHandler { @Autowired private HLADecoderRegistry hlaDecoderRegistry; + @Autowired + private FomCatalog fomCatalog; + public InjectionHandler() { } @@ -65,31 +70,45 @@ public ValueResolution handleTrigger(Target t, InjectionContext context) { } public ValueResolution handleTrigger(Target t, TestInjectionContext context) { - PathCheckResult pcr = fomXml.checkInteractionParameterPath(context.getHlaClass(), t.parts); - Class hlaJavaType = (pcr.exists) ? hlaDecoderRegistry.getClassForType(pcr.primitiveType) : null; + EventTargetDefinition target = targetDefinition( + context.getHlaClass(), + t, + context.getTriggerType() == StatementTrigger.Type.OBJECT_UPDATE); + Class hlaJavaType = + target.exists() ? hlaDecoderRegistry.getClassForType(target.primitiveType()) : null; Object result = XapiValueGenerator.getTestValue(context, t, hlaJavaType); return ValueResolution.present(result); } public ValueResolution handleTrigger(Target t, InteractionInjectionContext context) { + return decodeEventTarget( + t, + context.getHlaClass(), + context.getParameterMap(), + false); + } - PathCheckResult pcr = fomXml.checkInteractionParameterPath(context.getHlaClass(), t.parts); + private ValueResolution decodeEventTarget( + Target target, + String hlaClass, + Map values, + boolean objectEvent) { + EventTargetDefinition definition = targetDefinition(hlaClass, target, objectEvent); Object result = null; - //Actual Injection - byte[] value = interrogateParameters(context.getHlaClass(), true, t.parts, context.getParameterMap()); - if (value == null) + byte[] value = interrogateParameters(target.parts, values, definition.topLevelType()); + if (value == null) { return ValueResolution.missingValue(); + } - if (pcr.exists) { + if (definition.exists()) { try { - result = hlaDecoderRegistry.decode(pcr.primitiveType, value); + result = hlaDecoderRegistry.decode(definition.primitiveType(), value); } catch (DecoderException e) { logger.warn("Problem decoding value:", e); } } else { - // TODO: Properly log context of unfound target - logger.warn("Target does not exist in FOM.", t); + logger.warn("Target does not exist in FOM: {}", target); } if (result == null) { @@ -98,8 +117,56 @@ public ValueResolution handleTrigger(Target t, InteractionInjectionContext conte return ValueResolution.present(result); } - private byte[] interrogateParameters(String entityName, boolean isInteraction, - List targetParts, Map paramMap) { + private EventTargetDefinition targetDefinition( + String hlaClass, + Target target, + boolean objectEvent) { + return objectEvent + ? objectTargetDefinition(hlaClass, target) + : interactionTargetDefinition(hlaClass, target); + } + + private EventTargetDefinition interactionTargetDefinition(String hlaClass, Target target) { + PathCheckResult path = fomXml.checkInteractionParameterPath(hlaClass, target.parts); + String topLevelType = null; + String topLevelName = FomCatalog.topLevelTargetPart(target.parts); + if (topLevelName != null) { + try { + topLevelType = fomXml.getParameterType(hlaClass, topLevelName, true); + } catch (XPathExpressionException e) { + logger.warn("Unable to resolve interaction parameter type for {}.{}", hlaClass, topLevelName, e); + } + } + return new EventTargetDefinition(path.exists, path.primitiveType, topLevelType); + } + + private EventTargetDefinition objectTargetDefinition(String hlaClass, Target target) { + if (fomCatalog == null) { + throw new IllegalStateException("FOM object catalog is not configured"); + } + Optional objectClass = fomCatalog.objectClass(hlaClass); + if (objectClass.isEmpty()) { + return EventTargetDefinition.missing(); + } + String pathKey = FomCatalog.targetPath(target.parts); + String topLevelName = FomCatalog.topLevelTargetPart(target.parts); + Optional targetAttribute = + objectClass.orElseThrow().attribute(pathKey); + Optional topLevelAttribute = + objectClass.orElseThrow().attribute(topLevelName); + if (targetAttribute.isEmpty() || topLevelAttribute.isEmpty()) { + return EventTargetDefinition.missing(); + } + return new EventTargetDefinition( + true, + targetAttribute.orElseThrow().primitiveType(), + topLevelAttribute.orElseThrow().dataType()); + } + + private byte[] interrogateParameters( + List targetParts, + Map paramMap, + String topLevelType) { if (targetParts == null || targetParts.isEmpty()) { return null; } @@ -115,19 +182,11 @@ private byte[] interrogateParameters(String entityName, boolean isInteraction, return bytes; } - String currentType; - try { - currentType = fomXml.getParameterType(entityName, parameterName, isInteraction); - } catch (XPathExpressionException e) { - logger.warn("Unable to resolve parameter type for {}.{}", entityName, parameterName, e); - return null; - } - - if (currentType == null || currentType.isEmpty()) { + if (topLevelType == null || topLevelType.isEmpty()) { return null; } - return extractBytesForPath(currentType, targetParts.subList(1, targetParts.size()), bytes); + return extractBytesForPath(topLevelType, targetParts.subList(1, targetParts.size()), bytes); } private byte[] extractBytesForPath(String currentType, List remainingPath, byte[] bytes) { @@ -232,8 +291,11 @@ private byte[] extractFixedRecordFieldBytes(String recordType, String fieldName, } public ValueResolution handleTrigger(Target t, ObjectInjectionContext context) { - // placeholder: return a demo string showing the target and interaction context - return ValueResolution.present("[TRIGGER(object):" + t.toString() + ":CONTEXT:" + context.getHlaClass() + "]"); + return decodeEventTarget( + t, + context.getHlaClass(), + context.getAttributeMap(), + true); } public ValueResolution handleQuery( @@ -304,7 +366,21 @@ public void setHLADecoderRegistry(HLADecoderRegistry hdr) { this.hlaDecoderRegistry = hdr; } + public void setFomCatalog(FomCatalog fomCatalog) { + this.fomCatalog = fomCatalog; + } + ObjectCache objectCache() { return objectCache; } + + private record EventTargetDefinition( + boolean exists, + String primitiveType, + String topLevelType) { + + private static EventTargetDefinition missing() { + return new EventTargetDefinition(false, null, null); + } + } } diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectInjectionContext.java b/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectInjectionContext.java index a4900bb..00db4aa 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectInjectionContext.java +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectInjectionContext.java @@ -1,5 +1,37 @@ package com.yetanalytics.hlaxapi.injection; +import java.util.Map; + public class ObjectInjectionContext extends InjectionContext { + private String objectHandle; + private Map attributeMap = Map.of(); + + public ObjectInjectionContext() { + } + + public ObjectInjectionContext( + String hlaClass, + String objectHandle, + Map attributeMap) { + setHlaClass(hlaClass); + this.objectHandle = objectHandle; + setAttributeMap(attributeMap); + } + + public String getObjectHandle() { + return objectHandle; + } + + public void setObjectHandle(String objectHandle) { + this.objectHandle = objectHandle; + } + + public Map getAttributeMap() { + return attributeMap; + } + + public void setAttributeMap(Map attributeMap) { + this.attributeMap = attributeMap == null ? Map.of() : Map.copyOf(attributeMap); + } } diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/TestInjectionContext.java b/src/main/java/com/yetanalytics/hlaxapi/injection/TestInjectionContext.java index ce5a648..e98f0f7 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/injection/TestInjectionContext.java +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/TestInjectionContext.java @@ -1,7 +1,11 @@ package com.yetanalytics.hlaxapi.injection; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; + public class TestInjectionContext extends InjectionContext { + private StatementTrigger.Type triggerType = StatementTrigger.Type.INTERACTION; + public TestInjectionContext() { } @@ -9,4 +13,16 @@ public TestInjectionContext(String hlaClass) { setHlaClass(hlaClass); } + public TestInjectionContext(StatementTrigger.Type triggerType, String hlaClass) { + this.triggerType = triggerType; + setHlaClass(hlaClass); + } + + public StatementTrigger.Type getTriggerType() { + return triggerType; + } + + public void setTriggerType(StatementTrigger.Type triggerType) { + this.triggerType = triggerType; + } } diff --git a/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java new file mode 100644 index 0000000..7702cdf --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java @@ -0,0 +1,147 @@ +package com.yetanalytics.hlaxapi; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.yetanalytics.extension.SuppressTestLogging; +import com.yetanalytics.hlaxapi.cache.FomCatalog; +import com.yetanalytics.hlaxapi.cache.ValueResolution; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import com.yetanalytics.hlaxapi.config.model.Target; +import com.yetanalytics.hlaxapi.injection.ObjectInjectionContext; +import com.yetanalytics.hlaxapi.injection.TestInjectionContext; +import java.nio.ByteOrder; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; + +class ObjectInjectionHandlerTest { + + private static final String OBJECT_FOM = "src/test/resources/object-update-fom.xml"; + private static final String SIMULATION_FOM = "config/HlaFedereplFOM.xml"; + + @Test + void objectContextCarriesClassHandleAndIncomingAttributes() { + byte[] count = HLAEncodingTestSupport.int32(4, ByteOrder.BIG_ENDIAN); + ObjectInjectionContext context = + new ObjectInjectionContext("TrackedEntity", "object-17", Map.of("Count", count)); + + assertEquals("TrackedEntity", context.getHlaClass()); + assertEquals("object-17", context.getObjectHandle()); + assertSame(count, context.getAttributeMap().get("Count")); + } + + @Test + void decodesInheritedPrimitiveFixedRecordAndArrayPaths() { + InjectionHandler handler = handler(OBJECT_FOM); + byte[] position = position(12, 18); + byte[] history = HLAEncodingTestSupport.variableArray(position(1, 2), position(3, 4)); + ObjectInjectionContext context = new ObjectInjectionContext( + "TrackedEntity", + "object-17", + Map.of( + "EntityId", HLAEncodingTestSupport.asciiString("entity-17"), + "Position", position, + "PositionHistory", history)); + + assertEquals( + "entity-17", + handler.handleTrigger(target("EntityId"), context).value()); + assertEquals( + 18, + handler.handleTrigger(target("Position", "Y"), context).value()); + assertEquals( + 3, + handler.handleTrigger(target("PositionHistory", 1, "X"), context).value()); + } + + @Test + @SuppressTestLogging({"com.yetanalytics.hlaxapi.InjectionHandler"}) + void reportsAbsentAndMalformedObjectAttributesAsMissingValues() { + InjectionHandler handler = handler(OBJECT_FOM); + + ValueResolution absent = handler.handleTrigger( + target("Count"), + new ObjectInjectionContext("TrackedEntity", "object-17", Map.of())); + ValueResolution malformed = handler.handleTrigger( + target("Count"), + new ObjectInjectionContext( + "TrackedEntity", + "object-17", + Map.of("Count", new byte[] {1}))); + + assertEquals(ValueResolution.Status.MISSING_VALUE, absent.status()); + assertEquals(ValueResolution.Status.MISSING_VALUE, malformed.status()); + } + + @Test + @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) + void validatesObjectUpdateTargetsAgainstInheritedObjectAttributes() { + TriggerProcessor processor = new TriggerProcessor(handler(OBJECT_FOM)); + StatementTrigger valid = trigger(""" + {"object":{"id":["trigger",["EntityId"]]}} + """); + StatementTrigger wrongType = trigger(""" + {"object":{"id":["trigger",["Count"]]}} + """); + TestInjectionContext context = + new TestInjectionContext(StatementTrigger.Type.OBJECT_UPDATE, "TrackedEntity"); + + TriggerProcessor.TriggerProcessingResult validResult = + processor.renderTemplateForValidation(valid, context); + TriggerProcessor.TriggerProcessingResult wrongTypeResult = + processor.renderTemplateForValidation(wrongType, context); + + assertTrue(validResult.success()); + assertTrue(validResult.statement().contains("https://example.com/object")); + assertFalse(wrongTypeResult.success()); + } + + @Test + void interactionValidationRemainsTheDefault() { + TriggerProcessor processor = new TriggerProcessor(handler(SIMULATION_FOM)); + StatementTrigger interaction = trigger(""" + {"result":{"score":{"raw":["trigger",["StepNumber"]]}}} + """); + + TriggerProcessor.TriggerProcessingResult result = processor.renderTemplateForValidation( + interaction, + new TestInjectionContext("StepCompleted")); + + assertTrue(result.success()); + assertTrue(result.statement().contains("\"raw\":0.5")); + } + + private InjectionHandler handler(String fomPath) { + HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); + FOMXML fomXml = new FOMXML( + new SimulationConfig(null, null, null, null, fomPath), + decoderRegistry); + InjectionHandler handler = new InjectionHandler(); + handler.setFomXml(fomXml); + handler.setHLADecoderRegistry(decoderRegistry); + handler.setFomCatalog(new FomCatalog(fomXml)); + return handler; + } + + private StatementTrigger trigger(String statement) { + StatementTrigger trigger = new StatementTrigger(); + trigger.type = StatementTrigger.Type.OBJECT_UPDATE; + trigger.clazz = "TrackedEntity"; + trigger.statement = statement; + return trigger; + } + + private Target target(Object... parts) { + return new Target(List.of(parts)); + } + + private byte[] position(int x, int y) { + return HLAEncodingTestSupport.fixedRecord( + HLAEncodingTestSupport.int32(x, ByteOrder.BIG_ENDIAN), + HLAEncodingTestSupport.int32(y, ByteOrder.BIG_ENDIAN)); + } +} diff --git a/src/test/resources/object-update-fom.xml b/src/test/resources/object-update-fom.xml new file mode 100644 index 0000000..1ff3e22 --- /dev/null +++ b/src/test/resources/object-update-fom.xml @@ -0,0 +1,62 @@ + + + + + HLAobjectRoot + + BaseEntity + + EntityId + HLAASCIIstring + + + Position + GridPosition + + + TrackedEntity + + Count + HLAinteger32BE + + + PositionHistory + GridPositionHistory + + + + + + + + + + + + HLAinteger32BE + + + + + GridPositionHistory + GridPosition + Dynamic + HLAvariableArray + + + + + GridPosition + HLAfixedRecord + + X + HLAinteger32BE + + + Y + HLAinteger32BE + + + + + From 9642a507e2cb043e1d8db2ffab916336a8910a3e Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Mon, 27 Jul 2026 13:25:26 -0400 Subject: [PATCH 03/36] separate cache and event subscriptions --- .../hlaxapi/HlaInterfaceImpl.java | 54 +++- .../hlaxapi/cache/ObjectCache.java | 55 +++- .../cache/HlaObjectSubscriptionTest.java | 268 ++++++++++++++++++ .../hlaxapi/cache/ObjectCacheTest.java | 68 +++++ 4 files changed, 430 insertions(+), 15 deletions(-) create mode 100644 src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index 8b5bc96..c6a9cfb 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -221,7 +221,7 @@ public void connectionLost(String faultDescription) throws FederateInternalError private void subscribeObjectClasses() throws FederateNotExecutionMember, RestoreInProgress, SaveInProgress, NotConnected, RTIinternalError { - if (!objectCache.isEnabled()) { + if (!objectCache.hasSubscriptions()) { return; } for (Map.Entry> subscription : objectCache.subscriptions().entrySet()) { @@ -229,10 +229,8 @@ private void subscribeObjectClasses() FomCatalog.ObjectClassDef clazz = objectCache.catalog().objectClass(subscription.getKey()).orElseThrow( () -> new IllegalArgumentException("No FOM object class " + subscription.getKey())); ObjectClassHandle classHandle = ambassador.getObjectClassHandle(clazz.localName()); - AttributeHandleSet attributeHandles = ambassador.getAttributeHandleSetFactory().create(); - for (String attributeName : subscription.getValue()) { - attributeHandles.add(ambassador.getAttributeHandle(classHandle, attributeName)); - } + AttributeHandleSet attributeHandles = + attributeHandles(classHandle, subscription.getValue()); if (attributeHandles.isEmpty()) { continue; } @@ -258,19 +256,51 @@ public void discoverObjectInstance( ObjectClassHandle theObjectClass, String objectName, hla.rti1516e.FederateHandle producingFederate) throws FederateInternalError { - if (!objectCache.isEnabled()) { + if (!objectCache.hasSubscriptions()) { + return; + } + String className; + try { + className = StringUtils.substringAfterLast(ambassador.getObjectClassName(theObjectClass), "."); + } catch (InvalidObjectClassHandle | FederateNotExecutionMember | NotConnected | RTIinternalError e) { + logger.error("Error resolving discovered object {}", objectName, e); + return; + } + Set subscribedAttributes = objectCache.subscriptions().get(className); + if (subscribedAttributes == null || subscribedAttributes.isEmpty()) { return; } + if (objectCache.isEnabled()) { + try { + objectCache.discoverObject(theObject.toString(), objectName, className); + } catch (RuntimeException e) { + logger.error("Error caching discovered object {}", objectName, e); + } + } try { - String className = StringUtils.substringAfterLast(ambassador.getObjectClassName(theObjectClass), "."); - objectCache.discoverObject(theObject.toString(), objectName, className); + AttributeHandleSet attributeHandles = attributeHandles(theObjectClass, subscribedAttributes); + if (!attributeHandles.isEmpty()) { + ambassador.requestAttributeValueUpdate(theObject, attributeHandles, new byte[0]); + } logger.info("Discovered object {} as {}", objectName, className); - } catch (InvalidObjectClassHandle | FederateNotExecutionMember | NotConnected | RTIinternalError + } catch (AttributeNotDefined | InvalidObjectClassHandle | NameNotFound | ObjectInstanceNotKnown + | FederateNotExecutionMember | SaveInProgress | RestoreInProgress | NotConnected | RTIinternalError | RuntimeException e) { - logger.error("Error caching discovered object {}", objectName, e); + logger.error("Error requesting values for discovered object {}", objectName, e); } } + private AttributeHandleSet attributeHandles( + ObjectClassHandle classHandle, + Iterable attributeNames) + throws InvalidObjectClassHandle, NameNotFound, FederateNotExecutionMember, NotConnected, RTIinternalError { + AttributeHandleSet attributeHandles = ambassador.getAttributeHandleSetFactory().create(); + for (String attributeName : attributeNames) { + attributeHandles.add(ambassador.getAttributeHandle(classHandle, attributeName)); + } + return attributeHandles; + } + @Override public void reflectAttributeValues( ObjectInstanceHandle theObject, @@ -310,7 +340,7 @@ public void reflectAttributeValues( } private void reflectAttributeValues(ObjectInstanceHandle theObject, AttributeHandleValueMap theAttributes) { - if (!objectCache.isEnabled()) { + if (!objectCache.hasSubscriptions()) { return; } try { @@ -324,7 +354,7 @@ private void reflectAttributeValues(ObjectInstanceHandle theObject, AttributeHan objectCache.reflectAttributeValues(theObject.toString(), className, attributes); } catch (AttributeNotDefined | InvalidAttributeHandle | InvalidObjectClassHandle | ObjectInstanceNotKnown | FederateNotExecutionMember | NotConnected | RTIinternalError | RuntimeException e) { - logger.error("Error caching reflected object attributes", e); + logger.error("Error processing reflected object attributes", e); } } diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java index b42cfb9..fd70295 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java @@ -4,6 +4,7 @@ import com.yetanalytics.hlaxapi.HLADecoderRegistry; import com.yetanalytics.hlaxapi.config.XapiConfig; import com.yetanalytics.hlaxapi.config.model.Expression; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TrackedObject; import java.sql.Connection; @@ -21,6 +22,8 @@ public class ObjectCache implements AutoCloseable { private final FomCatalog catalog; + private final Map> cacheSubscriptions; + private final Map> eventSubscriptions; private final Map> subscriptions; private final HlaValueFlattener valueFlattener; private final CacheQueryService queryService; @@ -53,10 +56,12 @@ public ObjectCache(XapiConfig xapiConfig, FomCatalog catalog, FOMXML fomXml, HLA HLADecoderRegistry decoderRegistry, ObjectCacheConnectionSettings settings) { this.catalog = Objects.requireNonNull(catalog, "catalog"); - this.subscriptions = collectSubscriptions(xapiConfig); + this.cacheSubscriptions = collectCacheSubscriptions(xapiConfig); + this.eventSubscriptions = collectEventSubscriptions(xapiConfig); + this.subscriptions = mergeSubscriptions(cacheSubscriptions, eventSubscriptions); this.valueFlattener = new HlaValueFlattener(fomXml, decoderRegistry); this.queryService = new CacheQueryService(this); - if (!subscriptions.isEmpty()) { + if (!cacheSubscriptions.isEmpty()) { ObjectCacheConnectionSettings effectiveSettings = settings == null ? ObjectCacheConnectionSettings.from(System.getenv()) : settings; @@ -72,6 +77,18 @@ public Map> subscriptions() { return subscriptions; } + public Map> cacheSubscriptions() { + return cacheSubscriptions; + } + + public Map> eventSubscriptions() { + return eventSubscriptions; + } + + public boolean hasSubscriptions() { + return !subscriptions.isEmpty(); + } + public FomCatalog catalog() { return catalog; } @@ -214,7 +231,7 @@ private FomCatalog.ObjectClassDef requireClass(String className) { .orElseThrow(() -> new IllegalArgumentException("No FOM object class " + className)); } - private Map> collectSubscriptions(XapiConfig xapiConfig) { + private Map> collectCacheSubscriptions(XapiConfig xapiConfig) { Map> merged = new LinkedHashMap<>(); QueryReferenceCollector.collect(xapiConfig.statementTriggers) .forEach((className, attributes) -> addAttributes(merged, className, attributes)); @@ -222,6 +239,38 @@ private Map> collectSubscriptions(XapiConfig xapiConfig) { return copySubscriptions(merged); } + private Map> collectEventSubscriptions(XapiConfig xapiConfig) { + Map> events = new LinkedHashMap<>(); + if (xapiConfig.statementTriggers == null) { + return Map.of(); + } + for (StatementTrigger trigger : xapiConfig.statementTriggers) { + if (trigger == null + || trigger.type != StatementTrigger.Type.OBJECT_UPDATE + || trigger.clazz == null + || trigger.clazz.isBlank()) { + continue; + } + Optional clazz = catalog.objectClass(trigger.clazz); + if (clazz.isPresent()) { + FomCatalog.ObjectClassDef objectClass = clazz.orElseThrow(); + addAttributes(events, objectClass.localName(), objectClass.topLevelAttributeNames()); + } else { + addAttributes(events, trigger.clazz, Set.of("*")); + } + } + return copySubscriptions(events); + } + + @SafeVarargs + private final Map> mergeSubscriptions(Map>... plans) { + Map> merged = new LinkedHashMap<>(); + for (Map> plan : plans) { + plan.forEach((className, attributes) -> addAttributes(merged, className, attributes)); + } + return copySubscriptions(merged); + } + private void addTrackedObjects(Map> merged, XapiConfig xapiConfig) { if (xapiConfig.objectCacheConfig == null || xapiConfig.objectCacheConfig.trackedObjects == null) { return; diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java new file mode 100644 index 0000000..02e83f2 --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java @@ -0,0 +1,268 @@ +package com.yetanalytics.hlaxapi.cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.yetanalytics.extension.SuppressTestLogging; +import com.yetanalytics.hlaxapi.FOMXML; +import com.yetanalytics.hlaxapi.HLADecoderRegistry; +import com.yetanalytics.hlaxapi.HLAEncodingTestSupport; +import com.yetanalytics.hlaxapi.HlaInterfaceImpl; +import com.yetanalytics.hlaxapi.SimulationConfig; +import com.yetanalytics.hlaxapi.config.XapiConfig; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import hla.rti1516e.AttributeHandle; +import hla.rti1516e.AttributeHandleSet; +import hla.rti1516e.AttributeHandleValueMap; +import hla.rti1516e.ObjectClassHandle; +import hla.rti1516e.ObjectInstanceHandle; +import hla.rti1516e.RTIambassador; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.ByteOrder; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.portico.impl.hla1516e.types.HLA1516eAttributeHandleSetFactory; +import org.portico.impl.hla1516e.types.HLA1516eAttributeHandleValueMap; +import org.portico.impl.hla1516e.types.HLA1516eHandle; +import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; + +class HlaObjectSubscriptionTest { + + private final HLADecoderRegistry decoderRegistry = + new HLADecoderRegistry(new HLA1516eEncoderFactory()); + private final FOMXML fomXml = new FOMXML( + new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"), + decoderRegistry); + private final FomCatalog catalog = new FomCatalog(fomXml); + + @Test + void eventOnlyConfigurationSubscribesRequestsAndProcessesReflections() throws Exception { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(objectUpdateTrigger("Rabbit")); + Set expectedAttributes = + Set.copyOf(catalog.objectClass("Rabbit").orElseThrow().topLevelAttributeNames()); + + try (ObjectCache cache = new ObjectCache(config, catalog, fomXml, decoderRegistry)) { + RecordingRti rti = new RecordingRti(); + HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy()); + + subscribeObjectClasses(hlaInterface); + + assertFalse(cache.isEnabled()); + assertEquals(List.of(new ObjectSubscription("Rabbit", expectedAttributes)), rti.subscriptions); + + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(91); + hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit One"); + + assertEquals(1, rti.requests.size()); + assertEquals(rabbit, rti.requests.get(0).objectHandle()); + assertEquals(expectedAttributes, rti.requests.get(0).attributes()); + + AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); + AttributeHandleValueMap reflection = new HLA1516eAttributeHandleValueMap(); + reflection.put(hunger, HLAEncodingTestSupport.int32(12, ByteOrder.BIG_ENDIAN)); + hlaInterface.reflectAttributeValues(rabbit, reflection, null, null, null, null); + + assertEquals(1, rti.knownClassResolutions); + assertEquals(1, rti.attributeNameResolutions); + assertTrue(cache.currentObjects("Rabbit").isEmpty()); + } + } + + @Test + void discoveryCachesMetadataAndRequestsMergedAttributes(@TempDir Path tempDir) throws Exception { + XapiConfig config = configWithQueryAndObjectUpdate(); + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("discovery.sqlite"))) { + RecordingRti rti = new RecordingRti(); + HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy()); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(92); + + hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Two"); + + assertTrue(cache.isEnabled()); + assertEquals(1, cache.currentObjects("Rabbit").size()); + assertEquals("Rabbit Two", cache.currentObjects("Rabbit").get(0).objectName()); + assertEquals(cache.subscriptions().get("Rabbit"), rti.requests.get(0).attributes()); + } + } + + @Test + @SuppressTestLogging({"com.yetanalytics.hlaxapi.HlaInterfaceImpl"}) + void unknownObjectUpdateClassIsSkippedDuringSubscription() throws Exception { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(objectUpdateTrigger("MissingObject")); + + try (ObjectCache cache = new ObjectCache(config, catalog, fomXml, decoderRegistry)) { + RecordingRti rti = new RecordingRti(); + + subscribeObjectClasses(hlaInterface(cache, rti.proxy())); + + assertTrue(rti.subscriptions.isEmpty()); + } + } + + private XapiConfig configWithQueryAndObjectUpdate() { + StatementTrigger query = new StatementTrigger(); + query.statement = """ + {"actor":{"name":["query","Rabbit",["EntityId"],[["Hunger"],">",50]]}} + """; + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(query, objectUpdateTrigger("Rabbit")); + return config; + } + + private StatementTrigger objectUpdateTrigger(String className) { + StatementTrigger trigger = new StatementTrigger(); + trigger.type = StatementTrigger.Type.OBJECT_UPDATE; + trigger.clazz = className; + trigger.statement = "{}"; + return trigger; + } + + private HlaInterfaceImpl hlaInterface(ObjectCache cache, RTIambassador ambassador) throws Exception { + HlaInterfaceImpl hlaInterface = new HlaInterfaceImpl(); + setField(hlaInterface, "objectCache", cache); + setField(hlaInterface, "ambassador", ambassador); + return hlaInterface; + } + + private void subscribeObjectClasses(HlaInterfaceImpl hlaInterface) throws Exception { + Method method = HlaInterfaceImpl.class.getDeclaredMethod("subscribeObjectClasses"); + method.setAccessible(true); + method.invoke(hlaInterface); + } + + private void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private record ObjectSubscription(String className, Set attributes) { + } + + private record AttributeRequest(ObjectInstanceHandle objectHandle, Set attributes) { + } + + private static final class RecordingRti implements InvocationHandler { + + private final Map classes = new LinkedHashMap<>(); + private final Map classNames = new LinkedHashMap<>(); + private final Map attributes = new LinkedHashMap<>(); + private final Map attributeNames = new LinkedHashMap<>(); + private final List subscriptions = new ArrayList<>(); + private final List requests = new ArrayList<>(); + private int nextClassHandle = 1; + private int nextAttributeHandle = 1_000; + private int knownClassResolutions; + private int attributeNameResolutions; + private ObjectClassHandle knownClass; + + private RTIambassador proxy() { + return (RTIambassador) Proxy.newProxyInstance( + RTIambassador.class.getClassLoader(), + new Class[] {RTIambassador.class}, + this); + } + + private ObjectClassHandle classHandle(String className) { + ObjectClassHandle handle = classes.computeIfAbsent( + className, + ignored -> (ObjectClassHandle) new HLA1516eHandle(nextClassHandle++)); + classNames.put(handle, className); + knownClass = handle; + return handle; + } + + private ObjectInstanceHandle objectHandle(int value) { + return (ObjectInstanceHandle) new HLA1516eHandle(value); + } + + private AttributeHandle attributeHandle(ObjectClassHandle classHandle, String attributeName) { + String key = classNames.get(classHandle) + "." + attributeName; + AttributeHandle handle = attributes.computeIfAbsent( + key, + ignored -> (AttributeHandle) new HLA1516eHandle(nextAttributeHandle++)); + attributeNames.put(handle, attributeName); + return handle; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + return switch (method.getName()) { + case "getObjectClassHandle" -> classHandle((String) args[0]); + case "getObjectClassName" -> qualifiedClassName(classNames.get(args[0])); + case "getAttributeHandleSetFactory" -> new HLA1516eAttributeHandleSetFactory(); + case "getAttributeHandle" -> attributeHandle((ObjectClassHandle) args[0], (String) args[1]); + case "subscribeObjectClassAttributes" -> { + subscriptions.add(new ObjectSubscription( + classNames.get(args[0]), + names((AttributeHandleSet) args[1]))); + yield null; + } + case "requestAttributeValueUpdate" -> { + requests.add(new AttributeRequest( + (ObjectInstanceHandle) args[0], + names((AttributeHandleSet) args[1]))); + yield null; + } + case "getKnownObjectClassHandle" -> { + knownClassResolutions++; + yield knownClass; + } + case "getAttributeName" -> { + attributeNameResolutions++; + yield attributeNames.get(args[1]); + } + default -> defaultValue(method.getReturnType()); + }; + } + + private String qualifiedClassName(String className) { + return switch (className) { + case "Carrot", "Rabbit", "Wolf" -> "HLAobjectRoot.SimEntity." + className; + case "SimEntity", "World" -> "HLAobjectRoot." + className; + default -> className; + }; + } + + private Set names(AttributeHandleSet handles) { + Set names = new LinkedHashSet<>(); + for (AttributeHandle handle : handles) { + names.add(attributeNames.get(handle)); + } + return Set.copyOf(names); + } + + private Object defaultValue(Class returnType) { + if (!returnType.isPrimitive() || returnType == void.class) { + return null; + } + if (returnType == boolean.class) { + return false; + } + if (returnType == char.class) { + return '\0'; + } + return 0; + } + } +} diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java index 78b3962..5312578 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java @@ -65,6 +65,66 @@ void disabledCacheDoesNotRequireConnectionSettings() { } } + @Test + void objectUpdateSubscriptionsDoNotEnableCacheAndIncludeInheritedAttributes(@TempDir Path tempDir) { + Path databasePath = tempDir.resolve("object-update-only.sqlite"); + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(objectUpdateTrigger("Rabbit")); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + databasePath)) { + Set rabbitAttributes = + Set.copyOf(catalog.objectClass("Rabbit").orElseThrow().topLevelAttributeNames()); + + assertFalse(cache.isEnabled()); + assertTrue(cache.cacheSubscriptions().isEmpty()); + assertEquals(rabbitAttributes, cache.eventSubscriptions().get("Rabbit")); + assertEquals(rabbitAttributes, cache.subscriptions().get("Rabbit")); + assertTrue(cache.hasSubscriptions()); + assertFalse(Files.exists(databasePath)); + } + } + + @Test + void objectUpdateSubscriptionsMergeWithoutChangingCacheRequirements(@TempDir Path tempDir) { + XapiConfig config = configWithQuery(); + config.statementTriggers = List.of( + config.statementTriggers.get(0), + objectUpdateTrigger("Rabbit"), + objectUpdateTrigger("Rabbit")); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("object-update-merged.sqlite"))) { + Set rabbitAttributes = + Set.copyOf(catalog.objectClass("Rabbit").orElseThrow().topLevelAttributeNames()); + + assertTrue(cache.isEnabled()); + assertEquals(Set.of("EntityId", "Hunger"), cache.cacheSubscriptions().get("Rabbit")); + assertEquals(rabbitAttributes, cache.eventSubscriptions().get("Rabbit")); + assertEquals(rabbitAttributes, cache.subscriptions().get("Rabbit")); + } + } + + @Test + void retainsUnknownObjectUpdateClassForSubscriptionErrorHandling() { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(objectUpdateTrigger("MissingObject")); + + try (ObjectCache cache = new ObjectCache(config, catalog, fomXml, decoderRegistry)) { + assertFalse(cache.isEnabled()); + assertEquals(Set.of("*"), cache.eventSubscriptions().get("MissingObject")); + assertEquals(Set.of("*"), cache.subscriptions().get("MissingObject")); + } + } + @Test void enabledWhenQueryInjectionsExistAndCanQueryReflectedValues(@TempDir Path tempDir) { Path databasePath = tempDir.resolve("enabled.sqlite"); @@ -225,6 +285,14 @@ private XapiConfig configWithTrackedObject(String className, List attrib return config; } + private StatementTrigger objectUpdateTrigger(String className) { + StatementTrigger trigger = new StatementTrigger(); + trigger.type = StatementTrigger.Type.OBJECT_UPDATE; + trigger.clazz = className; + trigger.statement = "{}"; + return trigger; + } + private ObjectCacheConfig objectCacheConfig(TrackedObject... trackedObjects) { ObjectCacheConfig objectCacheConfig = new ObjectCacheConfig(); objectCacheConfig.trackedObjects = List.of(trackedObjects); From 6d48e6072c3d10299fd195310e6eaefcf1de8edb Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Mon, 27 Jul 2026 13:50:43 -0400 Subject: [PATCH 04/36] shared trigger dispatcher --- .../hlaxapi/HlaInterfaceImpl.java | 34 +- .../hlaxapi/StatementTriggerDispatcher.java | 85 +++++ .../hlaxapi/HlaInteractionDispatchTest.java | 116 +++++++ .../StatementTriggerDispatcherTest.java | 102 ++++++ .../cache/HlaObjectSubscriptionTest.java | 291 +++++++++++++++++- 5 files changed, 604 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java create mode 100644 src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java create mode 100644 src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index c6a9cfb..5f813a5 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -4,6 +4,7 @@ import java.net.MalformedURLException; import java.net.URL; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Set; @@ -20,6 +21,7 @@ import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.exception.XapiConfigurationException; import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext; +import com.yetanalytics.hlaxapi.injection.ObjectInjectionContext; import com.yetanalytics.hlaxapi.injection.TestInjectionContext; import com.yetanalytics.xapi.util.StatementValidator; import com.yetanalytics.xapi.util.StatementValidator.StatementValidationResult; @@ -92,6 +94,9 @@ public class HlaInterfaceImpl extends NullFederateAmbassador implements HlaInter @Autowired private TriggerProcessor triggerProcessor; + @Autowired + private StatementTriggerDispatcher triggerDispatcher; + @Autowired private StatementValidator validator; @@ -351,7 +356,12 @@ private void reflectAttributeValues(ObjectInstanceHandle theObject, AttributeHan String attributeName = ambassador.getAttributeName(classHandle, attributeHandle); attributes.put(attributeName, theAttributes.get(attributeHandle)); } + ObjectInjectionContext context = + new ObjectInjectionContext(className, theObject.toString(), attributes); + List statements = + triggerDispatcher.stage(StatementTrigger.Type.OBJECT_UPDATE, className, context); objectCache.reflectAttributeValues(theObject.toString(), className, attributes); + triggerDispatcher.enqueue(statements, xapiClient::sendStatement); } catch (AttributeNotDefined | InvalidAttributeHandle | InvalidObjectClassHandle | ObjectInstanceNotKnown | FederateNotExecutionMember | NotConnected | RTIinternalError | RuntimeException e) { logger.error("Error processing reflected object attributes", e); @@ -458,25 +468,11 @@ private void receiveInteraction(InteractionClassHandle interactionClass, Paramet InteractionInjectionContext context = new InteractionInjectionContext(interactionKey, getMapWithParameterNames(interactionClass, theParameters)); - // pass each matching interaction trigger to trigger processor - xapiConfig.statementTriggers.stream() - .filter(trigger -> trigger.clazz.equals(interactionKey) - && trigger.type.equals(StatementTrigger.Type.INTERACTION)) - .forEach(trigger -> { - logger.trace("Processing trigger for interaction {}", trigger.clazz); - TriggerProcessingResult result = triggerProcessor.processTrigger(trigger, context); - if (result.success() && result.matched()){ - try { - xapiClient.sendStatement(result.statement()); - } catch (Exception e) { - logger.error("Error parsing or posting statement {}", result.statement(), e); - } - } else if (!result.success()) { - // TODO: DLQ - logger.error("Error processing Interaction: {}", result.error().getMessage(), - result.error()); - } - }); + triggerDispatcher.dispatch( + StatementTrigger.Type.INTERACTION, + interactionKey, + context, + xapiClient::sendStatement); } catch (InvalidInteractionClassHandle | FederateNotExecutionMember | NotConnected | RTIinternalError e) { logger.error("Error ascertaining interaction details!", e); } diff --git a/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java b/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java new file mode 100644 index 0000000..152f898 --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java @@ -0,0 +1,85 @@ +package com.yetanalytics.hlaxapi; + +import com.yetanalytics.hlaxapi.TriggerProcessor.TriggerProcessingResult; +import com.yetanalytics.hlaxapi.config.XapiConfig; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import com.yetanalytics.hlaxapi.injection.InjectionContext; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class StatementTriggerDispatcher { + + private static final Logger logger = LogManager.getLogger(StatementTriggerDispatcher.class); + + private final XapiConfig xapiConfig; + private final TriggerProcessor triggerProcessor; + + @Autowired + public StatementTriggerDispatcher(XapiConfig xapiConfig, TriggerProcessor triggerProcessor) { + this.xapiConfig = xapiConfig; + this.triggerProcessor = triggerProcessor; + } + + public List stage( + StatementTrigger.Type eventType, + String hlaClass, + InjectionContext context) { + if (xapiConfig.statementTriggers == null) { + return List.of(); + } + List statements = new ArrayList<>(); + for (StatementTrigger trigger : xapiConfig.statementTriggers) { + if (trigger == null + || trigger.type != eventType + || !Objects.equals(trigger.clazz, hlaClass)) { + continue; + } + try { + logger.trace("Processing {} trigger for {}", eventType, hlaClass); + TriggerProcessingResult result = triggerProcessor.processTrigger(trigger, context); + if (result == null) { + logger.error("Trigger {}.{} did not produce a processing result", eventType, hlaClass); + } else if (result.success() && result.matched()) { + statements.add(new StagedStatement(trigger, result.statement())); + } else if (!result.success()) { + logger.error("Error processing trigger {}.{}", eventType, hlaClass, result.error()); + } + } catch (RuntimeException e) { + logger.error("Error processing trigger {}.{}", eventType, hlaClass, e); + } + } + return List.copyOf(statements); + } + + public void enqueue(List statements, Consumer statementSink) { + for (StagedStatement statement : statements) { + try { + statementSink.accept(statement.statement()); + } catch (RuntimeException e) { + StatementTrigger trigger = statement.trigger(); + logger.error("Error enqueueing statement for trigger {}.{}", + trigger.type, + trigger.clazz, + e); + } + } + } + + public void dispatch( + StatementTrigger.Type eventType, + String hlaClass, + InjectionContext context, + Consumer statementSink) { + enqueue(stage(eventType, hlaClass, context), statementSink); + } + + public record StagedStatement(StatementTrigger trigger, String statement) { + } +} diff --git a/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java b/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java new file mode 100644 index 0000000..28907e5 --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java @@ -0,0 +1,116 @@ +package com.yetanalytics.hlaxapi; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.yetanalytics.hlaxapi.cache.FomCatalog; +import com.yetanalytics.hlaxapi.config.XapiConfig; +import com.yetanalytics.hlaxapi.config.model.LrsConfig; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import com.yetanalytics.xapi.util.StatementValidator; +import hla.rti1516e.InteractionClassHandle; +import hla.rti1516e.ParameterHandle; +import hla.rti1516e.ParameterHandleValueMap; +import hla.rti1516e.RTIambassador; +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.portico.impl.hla1516e.types.HLA1516eHandle; +import org.portico.impl.hla1516e.types.HLA1516eParameterHandleValueMap; +import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; + +class HlaInteractionDispatchTest { + + @Test + void interactionCallbackStillRendersAndEnqueuesThroughTheSharedDispatcher() throws Exception { + HLADecoderRegistry decoderRegistry = + new HLADecoderRegistry(new HLA1516eEncoderFactory()); + FOMXML fomXml = new FOMXML( + new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"), + decoderRegistry); + InjectionHandler injectionHandler = new InjectionHandler(); + injectionHandler.setFomXml(fomXml); + injectionHandler.setHLADecoderRegistry(decoderRegistry); + injectionHandler.setFomCatalog(new FomCatalog(fomXml)); + StatementTrigger trigger = new StatementTrigger(); + trigger.type = StatementTrigger.Type.INTERACTION; + trigger.clazz = "StepCompleted"; + trigger.statement = """ + {"step":["trigger",["StepNumber"]]} + """; + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(trigger); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + InteractionClassHandle interactionClass = + (InteractionClassHandle) new HLA1516eHandle(101); + ParameterHandle stepNumber = (ParameterHandle) new HLA1516eHandle(102); + RTIambassador ambassador = (RTIambassador) Proxy.newProxyInstance( + RTIambassador.class.getClassLoader(), + new Class[] {RTIambassador.class}, + (proxy, method, args) -> switch (method.getName()) { + case "getInteractionClassName" -> "HLAinteractionRoot.StepCompleted"; + case "getParameterName" -> "StepNumber"; + default -> defaultValue(method.getReturnType()); + }); + HlaInterfaceImpl hlaInterface = new HlaInterfaceImpl(); + setField(hlaInterface, "ambassador", ambassador); + setField( + hlaInterface, + "triggerDispatcher", + new StatementTriggerDispatcher(config, new TriggerProcessor(injectionHandler))); + setField(hlaInterface, "xapiClient", xapiClient); + ParameterHandleValueMap parameters = new HLA1516eParameterHandleValueMap(); + parameters.put(stepNumber, HLAEncodingTestSupport.int32(42, ByteOrder.BIG_ENDIAN)); + + hlaInterface.receiveInteraction(interactionClass, parameters, null, null, null, null); + + assertEquals(List.of("{\"step\":42}"), xapiClient.statements); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private static Object defaultValue(Class returnType) { + if (!returnType.isPrimitive() || returnType == void.class) { + return null; + } + if (returnType == boolean.class) { + return false; + } + if (returnType == char.class) { + return '\0'; + } + return 0; + } + + private static final class RecordingXapiClient extends XapiClient { + + private final List statements = new ArrayList<>(); + + private RecordingXapiClient() { + super(clientConfig(), new StatementValidator()); + } + + @Override + public void sendStatement(String statement) { + statements.add(statement); + } + + private static XapiConfig clientConfig() { + LrsConfig lrs = new LrsConfig(); + lrs.host = "https://example.com/xapi/"; + lrs.key = "key"; + lrs.secret = "secret"; + lrs.batch = 10; + lrs.maxRetries = 1; + XapiConfig config = new XapiConfig(); + config.lrsConfig = lrs; + return config; + } + } +} diff --git a/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java b/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java new file mode 100644 index 0000000..7747fb1 --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java @@ -0,0 +1,102 @@ +package com.yetanalytics.hlaxapi; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.yetanalytics.extension.SuppressTestLogging; +import com.yetanalytics.hlaxapi.TriggerProcessor.TriggerProcessingResult; +import com.yetanalytics.hlaxapi.config.XapiConfig; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext; +import com.yetanalytics.hlaxapi.injection.ObjectInjectionContext; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class StatementTriggerDispatcherTest { + + @Test + @SuppressTestLogging({"com.yetanalytics.hlaxapi.StatementTriggerDispatcher"}) + void matchesExactlyStagesOnceAndIsolatesProcessingAndEnqueueFailures() { + StatementTrigger first = trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "first"); + StatementTrigger wrongType = trigger(StatementTrigger.Type.INTERACTION, "Rabbit", "wrong-type"); + StatementTrigger wrongClass = trigger(StatementTrigger.Type.OBJECT_UPDATE, "Wolf", "wrong-class"); + StatementTrigger skipped = trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "skip"); + StatementTrigger failed = trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "fail"); + StatementTrigger throwsException = trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "throw"); + StatementTrigger second = trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "second"); + XapiConfig config = new XapiConfig(); + config.statementTriggers = + List.of(first, wrongType, wrongClass, skipped, failed, throwsException, second); + ControlledTriggerProcessor processor = new ControlledTriggerProcessor(); + StatementTriggerDispatcher dispatcher = new StatementTriggerDispatcher(config, processor); + + List staged = dispatcher.stage( + StatementTrigger.Type.OBJECT_UPDATE, + "Rabbit", + new ObjectInjectionContext("Rabbit", "object-1", Map.of())); + + assertEquals(List.of("first", "second"), + staged.stream().map(StatementTriggerDispatcher.StagedStatement::statement).toList()); + assertEquals(List.of("first", "skip", "fail", "throw", "second"), processor.processed); + + List enqueued = new ArrayList<>(); + dispatcher.enqueue(staged, statement -> { + if ("first".equals(statement)) { + throw new IllegalStateException("first enqueue failed"); + } + enqueued.add(statement); + }); + + assertEquals(List.of("second"), enqueued); + } + + @Test + void interactionEventsUseTheSameDispatcherWithoutMatchingObjectTriggers() { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of( + trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "object"), + trigger(StatementTrigger.Type.INTERACTION, "Rabbit", "interaction")); + StatementTriggerDispatcher dispatcher = + new StatementTriggerDispatcher(config, new ControlledTriggerProcessor()); + List enqueued = new ArrayList<>(); + + dispatcher.dispatch( + StatementTrigger.Type.INTERACTION, + "Rabbit", + new InteractionInjectionContext("Rabbit", Map.of()), + enqueued::add); + + assertEquals(List.of("interaction"), enqueued); + } + + private StatementTrigger trigger(StatementTrigger.Type type, String className, String statement) { + StatementTrigger trigger = new StatementTrigger(); + trigger.type = type; + trigger.clazz = className; + trigger.statement = statement; + return trigger; + } + + private static final class ControlledTriggerProcessor extends TriggerProcessor { + + private final List processed = new ArrayList<>(); + + @Override + public TriggerProcessingResult processTrigger( + StatementTrigger trigger, + com.yetanalytics.hlaxapi.injection.InjectionContext context) { + processed.add(trigger.statement); + return switch (trigger.statement) { + case "skip" -> new TriggerProcessingResult(null, false, true, null); + case "fail" -> new TriggerProcessingResult( + null, + false, + false, + new IllegalArgumentException("failed")); + case "throw" -> throw new IllegalStateException("thrown"); + default -> new TriggerProcessingResult(trigger.statement, true, true, null); + }; + } + } +} diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java index 02e83f2..2bc51ba 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java @@ -9,9 +9,23 @@ import com.yetanalytics.hlaxapi.HLADecoderRegistry; import com.yetanalytics.hlaxapi.HLAEncodingTestSupport; import com.yetanalytics.hlaxapi.HlaInterfaceImpl; +import com.yetanalytics.hlaxapi.InjectionHandler; import com.yetanalytics.hlaxapi.SimulationConfig; +import com.yetanalytics.hlaxapi.StatementTriggerDispatcher; +import com.yetanalytics.hlaxapi.TriggerProcessor; +import com.yetanalytics.hlaxapi.XapiClient; import com.yetanalytics.hlaxapi.config.XapiConfig; +import com.yetanalytics.hlaxapi.config.model.ComparisonOperator; +import com.yetanalytics.hlaxapi.config.model.Criterion; +import com.yetanalytics.hlaxapi.config.model.LrsConfig; +import com.yetanalytics.hlaxapi.config.model.ObjectCacheConfig; +import com.yetanalytics.hlaxapi.config.model.ObjectLookup; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import com.yetanalytics.hlaxapi.config.model.Target; +import com.yetanalytics.hlaxapi.config.model.TrackedObject; +import com.yetanalytics.hlaxapi.config.model.TriggerExpression; +import com.yetanalytics.hlaxapi.config.model.ValueExpression; +import com.yetanalytics.xapi.util.StatementValidator; import hla.rti1516e.AttributeHandle; import hla.rti1516e.AttributeHandleSet; import hla.rti1516e.AttributeHandleValueMap; @@ -30,6 +44,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.portico.impl.hla1516e.types.HLA1516eAttributeHandleSetFactory; @@ -55,7 +71,8 @@ void eventOnlyConfigurationSubscribesRequestsAndProcessesReflections() throws Ex try (ObjectCache cache = new ObjectCache(config, catalog, fomXml, decoderRegistry)) { RecordingRti rti = new RecordingRti(); - HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy()); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy(), config, xapiClient); subscribeObjectClasses(hlaInterface); @@ -78,6 +95,176 @@ void eventOnlyConfigurationSubscribesRequestsAndProcessesReflections() throws Ex assertEquals(1, rti.knownClassResolutions); assertEquals(1, rti.attributeNameResolutions); assertTrue(cache.currentObjects("Rabbit").isEmpty()); + assertEquals(List.of("{}"), xapiClient.statements); + } + } + + @Test + @SuppressTestLogging({ + "com.yetanalytics.hlaxapi.TriggerProcessor", + "com.yetanalytics.hlaxapi.StatementTriggerDispatcher" + }) + void eventOnlyReflectionDispatchesMatchingTriggersOnceFromTheCompletePayload() throws Exception { + StatementTrigger passing = objectUpdateTrigger( + "Rabbit", + """ + {"incomingHunger":["trigger",["Hunger"]]} + """); + passing.criteria = comparison("Hunger", ComparisonOperator.GT, 10); + StatementTrigger requiredMissing = objectUpdateTrigger( + "Rabbit", + """ + {"entityId":["trigger",["EntityId"]]} + """); + StatementTrigger optionalMissing = objectUpdateTrigger( + "Rabbit", + """ + {"entityId":["trigger",["EntityId"],{"required":false}]} + """); + StatementTrigger skipped = objectUpdateTrigger( + "Rabbit", + """ + {"skipped":true} + """); + skipped.criteria = comparison("Hunger", ComparisonOperator.GT, 20); + StatementTrigger wrongClass = objectUpdateTrigger("Wolf", """ + {"wrongClass":true} + """); + StatementTrigger wrongType = objectUpdateTrigger("Rabbit", """ + {"wrongType":true} + """); + wrongType.type = StatementTrigger.Type.INTERACTION; + + XapiConfig config = new XapiConfig(); + config.statementTriggers = + List.of(passing, requiredMissing, optionalMissing, skipped, wrongClass, wrongType); + + try (ObjectCache cache = new ObjectCache(config, catalog, fomXml, decoderRegistry)) { + RecordingRti rti = new RecordingRti(); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = hlaInterface( + cache, + rti.proxy(), + config, + xapiClient, + injectionHandler(cache)); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(93); + AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); + AttributeHandle position = rti.attributeHandle(rabbitClass, "Position"); + AttributeHandleValueMap reflection = new HLA1516eAttributeHandleValueMap(); + reflection.put(hunger, HLAEncodingTestSupport.int32(12, ByteOrder.BIG_ENDIAN)); + reflection.put(position, HLAEncodingTestSupport.fixedRecord( + HLAEncodingTestSupport.int32(4, ByteOrder.BIG_ENDIAN), + HLAEncodingTestSupport.int32(7, ByteOrder.BIG_ENDIAN))); + + hlaInterface.reflectAttributeValues(rabbit, reflection, null, null, null, null); + + assertFalse(cache.isEnabled()); + assertEquals(2, rti.attributeNameResolutions); + assertEquals( + List.of( + "{\"incomingHunger\":12}", + "{\"entityId\":null}"), + xapiClient.statements); + } + } + + @Test + void cachedQueriesAndLookupsRenderBeforeTheReflectionCommitsAndEnqueueAfterItCommits( + @TempDir Path tempDir) throws Exception { + StatementTrigger trigger = objectUpdateTrigger( + "Rabbit", + """ + { + "incoming":["trigger",["Hunger"]], + "queried":["query","Rabbit",["Hunger"],[["EntityId"],"=","rabbit-one"]], + "lookedUp":["lookup","rabbit",["Hunger"]] + } + """); + ObjectLookup lookup = new ObjectLookup(); + lookup.clazz = "Rabbit"; + lookup.criteria = new Criterion( + new Target(List.of("EntityId")), + ComparisonOperator.EQ, + new ValueExpression("rabbit-one")); + trigger.lookups = Map.of("rabbit", lookup); + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(trigger); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("object-update-query.sqlite"))) { + RecordingRti rti = new RecordingRti(); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(94); + cache.reflectAttributeValues( + rabbit.toString(), + "Rabbit", + Map.of( + "EntityId", HLAEncodingTestSupport.asciiString("rabbit-one"), + "Hunger", HLAEncodingTestSupport.int32(5, ByteOrder.BIG_ENDIAN))); + AtomicReference hungerAtEnqueue = new AtomicReference<>(); + RecordingXapiClient xapiClient = new RecordingXapiClient(statement -> hungerAtEnqueue.set( + cache.findCurrentValue(rabbit.toString(), "Hunger").orElseThrow().value())); + HlaInterfaceImpl hlaInterface = hlaInterface( + cache, + rti.proxy(), + config, + xapiClient, + injectionHandler(cache)); + AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); + AttributeHandleValueMap reflection = new HLA1516eAttributeHandleValueMap(); + reflection.put(hunger, HLAEncodingTestSupport.int32(19, ByteOrder.BIG_ENDIAN)); + + hlaInterface.reflectAttributeValues(rabbit, reflection, null, null, null, null); + + assertTrue(cache.isEnabled()); + assertEquals(19, hungerAtEnqueue.get()); + assertEquals( + List.of("{\"incoming\":19,\"queried\":5,\"lookedUp\":5}"), + xapiClient.statements); + } + } + + @Test + @SuppressTestLogging({"com.yetanalytics.hlaxapi.HlaInterfaceImpl"}) + void cacheFailureSuppressesAllStatementsStagedForTheReflection(@TempDir Path tempDir) throws Exception { + XapiConfig config = trackedRabbitConfig(objectUpdateTrigger("Rabbit")); + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("object-update-cache-failure.sqlite"))) { + RecordingRti rti = new RecordingRti(); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(95); + cache.reflectAttributeValue( + rabbit.toString(), + "Rabbit", + "Hunger", + HLAEncodingTestSupport.int32(5, ByteOrder.BIG_ENDIAN)); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = hlaInterface( + cache, + rti.proxy(), + config, + xapiClient, + injectionHandler(cache)); + AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); + AttributeHandle unknown = rti.attributeHandle(rabbitClass, "NotInTheFom"); + AttributeHandleValueMap reflection = new HLA1516eAttributeHandleValueMap(); + reflection.put(hunger, HLAEncodingTestSupport.int32(20, ByteOrder.BIG_ENDIAN)); + reflection.put(unknown, HLAEncodingTestSupport.int32(1, ByteOrder.BIG_ENDIAN)); + + hlaInterface.reflectAttributeValues(rabbit, reflection, null, null, null, null); + + assertEquals(5, cache.findCurrentValue(rabbit.toString(), "Hunger").orElseThrow().value()); + assertTrue(xapiClient.statements.isEmpty()); } } @@ -91,7 +278,8 @@ void discoveryCachesMetadataAndRequestsMergedAttributes(@TempDir Path tempDir) t decoderRegistry, "jdbc:sqlite:" + tempDir.resolve("discovery.sqlite"))) { RecordingRti rti = new RecordingRti(); - HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy()); + HlaInterfaceImpl hlaInterface = + hlaInterface(cache, rti.proxy(), config, new RecordingXapiClient()); ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(92); @@ -113,7 +301,11 @@ void unknownObjectUpdateClassIsSkippedDuringSubscription() throws Exception { try (ObjectCache cache = new ObjectCache(config, catalog, fomXml, decoderRegistry)) { RecordingRti rti = new RecordingRti(); - subscribeObjectClasses(hlaInterface(cache, rti.proxy())); + subscribeObjectClasses(hlaInterface( + cache, + rti.proxy(), + config, + new RecordingXapiClient())); assertTrue(rti.subscriptions.isEmpty()); } @@ -130,17 +322,72 @@ private XapiConfig configWithQueryAndObjectUpdate() { } private StatementTrigger objectUpdateTrigger(String className) { + return objectUpdateTrigger(className, "{}"); + } + + private StatementTrigger objectUpdateTrigger(String className, String statement) { StatementTrigger trigger = new StatementTrigger(); trigger.type = StatementTrigger.Type.OBJECT_UPDATE; trigger.clazz = className; - trigger.statement = "{}"; + trigger.statement = statement; return trigger; } - private HlaInterfaceImpl hlaInterface(ObjectCache cache, RTIambassador ambassador) throws Exception { + private Criterion comparison(String attribute, ComparisonOperator operator, Object value) { + return new Criterion( + new TriggerExpression(new Target(List.of(attribute))), + operator, + new ValueExpression(value)); + } + + private XapiConfig trackedRabbitConfig(StatementTrigger trigger) { + TrackedObject trackedRabbit = new TrackedObject(); + trackedRabbit.clazz = "Rabbit"; + trackedRabbit.attributes = List.of("Hunger"); + ObjectCacheConfig cacheConfig = new ObjectCacheConfig(); + cacheConfig.trackedObjects = List.of(trackedRabbit); + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(trigger); + config.objectCacheConfig = cacheConfig; + return config; + } + + private InjectionHandler injectionHandler(ObjectCache cache) throws Exception { + InjectionHandler handler = new InjectionHandler(); + handler.setFomXml(fomXml); + handler.setHLADecoderRegistry(decoderRegistry); + handler.setFomCatalog(catalog); + setField(handler, "objectCache", cache); + return handler; + } + + private HlaInterfaceImpl hlaInterface( + ObjectCache cache, + RTIambassador ambassador, + XapiConfig config, + XapiClient xapiClient) throws Exception { + return hlaInterface( + cache, + ambassador, + config, + xapiClient, + new InjectionHandler()); + } + + private HlaInterfaceImpl hlaInterface( + ObjectCache cache, + RTIambassador ambassador, + XapiConfig config, + XapiClient xapiClient, + InjectionHandler injectionHandler) throws Exception { HlaInterfaceImpl hlaInterface = new HlaInterfaceImpl(); setField(hlaInterface, "objectCache", cache); setField(hlaInterface, "ambassador", ambassador); + setField( + hlaInterface, + "triggerDispatcher", + new StatementTriggerDispatcher(config, new TriggerProcessor(injectionHandler))); + setField(hlaInterface, "xapiClient", xapiClient); return hlaInterface; } @@ -162,6 +409,40 @@ private record ObjectSubscription(String className, Set attributes) { private record AttributeRequest(ObjectInstanceHandle objectHandle, Set attributes) { } + private static final class RecordingXapiClient extends XapiClient { + + private final List statements = new ArrayList<>(); + private final Consumer onStatement; + + private RecordingXapiClient() { + this(statement -> { + }); + } + + private RecordingXapiClient(Consumer onStatement) { + super(clientConfig(), new StatementValidator()); + this.onStatement = onStatement; + } + + @Override + public void sendStatement(String statement) { + onStatement.accept(statement); + statements.add(statement); + } + + private static XapiConfig clientConfig() { + LrsConfig lrs = new LrsConfig(); + lrs.host = "https://example.com/xapi/"; + lrs.key = "key"; + lrs.secret = "secret"; + lrs.batch = 10; + lrs.maxRetries = 1; + XapiConfig config = new XapiConfig(); + config.lrsConfig = lrs; + return config; + } + } + private static final class RecordingRti implements InvocationHandler { private final Map classes = new LinkedHashMap<>(); From 13c7cbea39827347393c2321928eb579903db48a Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Mon, 27 Jul 2026 14:21:03 -0400 Subject: [PATCH 05/36] add working objectupdate to xapi config --- config/xapi-config.json | 44 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/config/xapi-config.json b/config/xapi-config.json index 55fbf6b..b13e46b 100644 --- a/config/xapi-config.json +++ b/config/xapi-config.json @@ -40,12 +40,52 @@ } } } + }, + { + "type": "ObjectUpdate", + "class": "World", + "statement": { + "actor": { + "objectType": "Agent", + "name": "HLA xAPI Adapter", + "account": { + "homePage": "https://hla-federepl.example/adapters", + "name": "world-monitor" + } + }, + "verb": { + "id": "https://hla-federepl.example/verbs/advanced", + "display": {"en-US": "advanced"} + }, + "object": { + "objectType": "Activity", + "id": "https://hla-federepl.example/simulation/world", + "definition": { + "name": {"en-US": "HLA Federepl world"} + } + }, + "context": { + "extensions": { + "https://hla-federepl.example/extensions/previous-step-number": [ + "query", + "World", + ["StepNumber"], + null, + {"required": false} + ], + "https://hla-federepl.example/extensions/current-step-number": [ + "trigger", + ["StepNumber"] + ] + } + } + } } ], "lrs": { "host": "http://localhost:8080/xapi", - "key": "my_key", - "secret": "my_secret", + "key": "username", + "secret": "password", "batch": 35, "maxRetries": 3 } From 7f451b3cbfdeec8c38a57747cc1b986dced85a20 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 11:09:57 -0400 Subject: [PATCH 06/36] object snapshot holds attributes of deleted object --- .../yetanalytics/hlaxapi/cache/ObjectSnapshot.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSnapshot.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSnapshot.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSnapshot.java new file mode 100644 index 0000000..6581baa --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSnapshot.java @@ -0,0 +1,14 @@ +package com.yetanalytics.hlaxapi.cache; + +import java.util.Map; + +public record ObjectSnapshot( + String objectHandle, + String objectName, + String className, + Map attributes) { + + public ObjectSnapshot { + attributes = attributes == null ? Map.of() : Map.copyOf(attributes); + } +} From 047d5d860e628f7ae15b8a0121e7ff666fc092d7 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 11:12:40 -0400 Subject: [PATCH 07/36] add new trigger types to model --- .../hlaxapi/config/model/StatementTrigger.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/StatementTrigger.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/StatementTrigger.java index d343c07..b97d0c7 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/model/StatementTrigger.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/StatementTrigger.java @@ -21,13 +21,19 @@ public String toString() { public enum Type { - INTERACTION, OBJECT_UPDATE; + INTERACTION, OBJECT_CREATE, OBJECT_UPDATE, OBJECT_DELETE; + + public boolean isObjectEvent() { + return this == OBJECT_CREATE || this == OBJECT_UPDATE || this == OBJECT_DELETE; + } public static Type fromString(String s) { if (s == null) return null; switch (s.trim().toLowerCase()) { case "interaction": return INTERACTION; + case "objectcreate": return OBJECT_CREATE; case "objectupdate": return OBJECT_UPDATE; + case "objectdelete": return OBJECT_DELETE; default: return null; } } From 99c043c1cc294acf999e634c0cabfec2c6e015c5 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 11:16:41 -0400 Subject: [PATCH 08/36] config tests for new trigger types --- .../com/yetanalytics/ConfigParserTest.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/test/java/com/yetanalytics/ConfigParserTest.java b/src/test/java/com/yetanalytics/ConfigParserTest.java index 5998679..71c3828 100644 --- a/src/test/java/com/yetanalytics/ConfigParserTest.java +++ b/src/test/java/com/yetanalytics/ConfigParserTest.java @@ -142,6 +142,32 @@ public void parsesTriggerLookups(@TempDir Path tempDir) throws IOException { assertTrue(criterion.right instanceof TriggerExpression); } + @Test + public void parsesObjectLifecycleTriggerTypes(@TempDir Path tempDir) throws IOException { + Path configPath = tempDir.resolve("object-lifecycle-config.json"); + Files.writeString(configPath, """ + { + "statementTriggers": [ + {"type":"ObjectCreate","class":"Rabbit","statement":{}}, + {"type":"objectUpdate","class":"Rabbit","statement":{}}, + {"type":"OBJECTDELETE","class":"Rabbit","statement":{}} + ] + } + """); + + List types = ConfigParser.fromFile(configPath.toString()).parse() + .statementTriggers.stream() + .map(trigger -> trigger.type) + .toList(); + + assertEquals( + List.of( + StatementTrigger.Type.OBJECT_CREATE, + StatementTrigger.Type.OBJECT_UPDATE, + StatementTrigger.Type.OBJECT_DELETE), + types); + } + @Test public void parsesQueriesAndLookupsInTriggerCriteria(@TempDir Path tempDir) throws IOException { Path configPath = tempDir.resolve("xapi-config.json"); From e80af9d900599e986270e211ee2a4cbc40ac768e Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 11:18:00 -0400 Subject: [PATCH 09/36] implement create and delete trigger types and handle short lifespan --- .../hlaxapi/HlaInterfaceImpl.java | 57 ++- .../hlaxapi/InjectionHandler.java | 3 +- .../hlaxapi/cache/JdbcObjectCacheStore.java | 30 ++ .../hlaxapi/cache/ObjectCache.java | 27 +- .../hlaxapi/cache/ObjectCacheQueries.java | 2 + .../hlaxapi/cache/ObjectCacheStore.java | 2 + .../cache/PostgresqlObjectCacheQueries.java | 20 + .../cache/SqliteObjectCacheQueries.java | 20 + .../hlaxapi/ObjectInjectionHandlerTest.java | 42 +- .../StatementTriggerDispatcherTest.java | 28 ++ .../cache/HlaObjectSubscriptionTest.java | 426 +++++++++++++++++- .../cache/ObjectCachePersistenceTest.java | 39 ++ .../hlaxapi/cache/ObjectCacheTest.java | 49 +- 13 files changed, 715 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index 5f813a5..f0e4e84 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -3,6 +3,7 @@ import java.io.File; import java.net.MalformedURLException; import java.net.URL; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -17,6 +18,7 @@ import com.yetanalytics.hlaxapi.TriggerProcessor.TriggerProcessingResult; import com.yetanalytics.hlaxapi.cache.FomCatalog; import com.yetanalytics.hlaxapi.cache.ObjectCache; +import com.yetanalytics.hlaxapi.cache.ObjectSnapshot; import com.yetanalytics.hlaxapi.config.XapiConfig; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.exception.XapiConfigurationException; @@ -85,6 +87,8 @@ public class HlaInterfaceImpl extends NullFederateAmbassador implements HlaInter private RTIambassador ambassador; + private final Map pendingObjectCreates = new HashMap<>(); + @Autowired private XapiConfig xapiConfig; @@ -275,6 +279,9 @@ public void discoverObjectInstance( if (subscribedAttributes == null || subscribedAttributes.isEmpty()) { return; } + if (hasObjectCreateTrigger(className)) { + pendingObjectCreates.put(theObject.toString(), className); + } if (objectCache.isEnabled()) { try { objectCache.discoverObject(theObject.toString(), objectName, className); @@ -288,13 +295,24 @@ public void discoverObjectInstance( ambassador.requestAttributeValueUpdate(theObject, attributeHandles, new byte[0]); } logger.info("Discovered object {} as {}", objectName, className); - } catch (AttributeNotDefined | InvalidObjectClassHandle | NameNotFound | ObjectInstanceNotKnown - | FederateNotExecutionMember | SaveInProgress | RestoreInProgress | NotConnected | RTIinternalError - | RuntimeException e) { + } catch (ObjectInstanceNotKnown e) { + logger.debug("Discovered object {} was removed before its attributes could be requested", objectName); + } catch (AttributeNotDefined | InvalidObjectClassHandle | NameNotFound | FederateNotExecutionMember + | SaveInProgress | RestoreInProgress | NotConnected | RTIinternalError | RuntimeException e) { logger.error("Error requesting values for discovered object {}", objectName, e); } } + private boolean hasObjectCreateTrigger(String className) { + if (xapiConfig == null || xapiConfig.statementTriggers == null) { + return false; + } + return xapiConfig.statementTriggers.stream() + .anyMatch(trigger -> trigger != null + && trigger.type == StatementTrigger.Type.OBJECT_CREATE + && className.equals(trigger.clazz)); + } + private AttributeHandleSet attributeHandles( ObjectClassHandle classHandle, Iterable attributeNames) @@ -358,9 +376,18 @@ private void reflectAttributeValues(ObjectInstanceHandle theObject, AttributeHan } ObjectInjectionContext context = new ObjectInjectionContext(className, theObject.toString(), attributes); - List statements = - triggerDispatcher.stage(StatementTrigger.Type.OBJECT_UPDATE, className, context); + boolean createPending = className.equals(pendingObjectCreates.get(theObject.toString())); + List statements = new ArrayList<>(); + if (createPending) { + statements.addAll( + triggerDispatcher.stage(StatementTrigger.Type.OBJECT_CREATE, className, context)); + } + statements.addAll( + triggerDispatcher.stage(StatementTrigger.Type.OBJECT_UPDATE, className, context)); objectCache.reflectAttributeValues(theObject.toString(), className, attributes); + if (createPending) { + pendingObjectCreates.remove(theObject.toString(), className); + } triggerDispatcher.enqueue(statements, xapiClient::sendStatement); } catch (AttributeNotDefined | InvalidAttributeHandle | InvalidObjectClassHandle | ObjectInstanceNotKnown | FederateNotExecutionMember | NotConnected | RTIinternalError | RuntimeException e) { @@ -401,11 +428,29 @@ public void removeObjectInstance( } private void removeCachedObject(ObjectInstanceHandle theObject) { + String objectHandle = theObject.toString(); + pendingObjectCreates.remove(objectHandle); if (!objectCache.isEnabled()) { return; } try { - objectCache.removeObject(theObject.toString()); + ObjectSnapshot snapshot = objectCache.findCurrentObjectSnapshot(objectHandle).orElse(null); + List statements = List.of(); + if (snapshot != null) { + ObjectInjectionContext context = new ObjectInjectionContext( + snapshot.className(), + snapshot.objectHandle(), + snapshot.attributes()); + statements = triggerDispatcher.stage( + StatementTrigger.Type.OBJECT_DELETE, + snapshot.className(), + context); + } else { + logger.debug("Skipping ObjectDelete triggers for unknown or removed object {}", theObject); + return; + } + objectCache.removeObject(objectHandle); + triggerDispatcher.enqueue(statements, xapiClient::sendStatement); } catch (RuntimeException e) { logger.error("Error removing cached object {}", theObject, e); } diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java index fe967da..8946864 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -19,7 +19,6 @@ import com.yetanalytics.hlaxapi.config.model.Expression; import com.yetanalytics.hlaxapi.config.model.ExpressionWalker; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; -import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TriggerExpression; import com.yetanalytics.hlaxapi.config.model.ValueExpression; @@ -73,7 +72,7 @@ public ValueResolution handleTrigger(Target t, TestInjectionContext context) { EventTargetDefinition target = targetDefinition( context.getHlaClass(), t, - context.getTriggerType() == StatementTrigger.Type.OBJECT_UPDATE); + context.getTriggerType() != null && context.getTriggerType().isObjectEvent()); Class hlaJavaType = target.exists() ? hlaDecoderRegistry.getClassForType(target.primitiveType()) : null; Object result = XapiValueGenerator.getTestValue(context, t, hlaJavaType); diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java b/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java index 6c86e98..5409477 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java @@ -9,7 +9,9 @@ import java.sql.Statement; import java.sql.Types; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -49,6 +51,34 @@ public CachedObject ensureObject( } } + @Override + public Optional findCurrentObjectSnapshot(String objectHandle) { + try (PreparedStatement statement = connection.prepareStatement(queries.loadCurrentObjectSnapshot())) { + statement.setString(1, objectHandle); + try (ResultSet resultSet = statement.executeQuery()) { + String objectName = null; + String className = null; + Map attributes = new LinkedHashMap<>(); + boolean found = false; + while (resultSet.next()) { + found = true; + objectName = resultSet.getString("object_name"); + className = resultSet.getString("local_name"); + String attributeName = resultSet.getString("attribute_name"); + byte[] rawBytes = resultSet.getBytes("raw_bytes"); + if (attributeName != null && rawBytes != null) { + attributes.put(attributeName, rawBytes); + } + } + return found + ? Optional.of(new ObjectSnapshot(objectHandle, objectName, className, attributes)) + : Optional.empty(); + } + } catch (SQLException e) { + throw new IllegalStateException("Could not load current object snapshot: " + objectHandle, e); + } + } + @Override public void removeObject(String objectHandle, String removedAt) { try (PreparedStatement statement = connection.prepareStatement(queries.removeObject())) { diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java index fd70295..7782628 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java @@ -187,6 +187,13 @@ public synchronized void removeObject(String objectHandle) { store.removeObject(objectHandle, Instant.now().toString()); } + public synchronized Optional findCurrentObjectSnapshot(String objectHandle) { + if (!isEnabled()) { + return Optional.empty(); + } + return store.findCurrentObjectSnapshot(objectHandle); + } + public synchronized Optional findCurrentValue(long instanceId, String pathKey) { if (!isEnabled()) { return Optional.empty(); @@ -235,6 +242,7 @@ private Map> collectCacheSubscriptions(XapiConfig xapiConfig Map> merged = new LinkedHashMap<>(); QueryReferenceCollector.collect(xapiConfig.statementTriggers) .forEach((className, attributes) -> addAttributes(merged, className, attributes)); + addObjectDeleteTriggers(merged, xapiConfig); addTrackedObjects(merged, xapiConfig); return copySubscriptions(merged); } @@ -246,7 +254,8 @@ private Map> collectEventSubscriptions(XapiConfig xapiConfig } for (StatementTrigger trigger : xapiConfig.statementTriggers) { if (trigger == null - || trigger.type != StatementTrigger.Type.OBJECT_UPDATE + || trigger.type == null + || !trigger.type.isObjectEvent() || trigger.clazz == null || trigger.clazz.isBlank()) { continue; @@ -262,6 +271,22 @@ private Map> collectEventSubscriptions(XapiConfig xapiConfig return copySubscriptions(events); } + private void addObjectDeleteTriggers(Map> merged, XapiConfig xapiConfig) { + if (xapiConfig.statementTriggers == null) { + return; + } + for (StatementTrigger trigger : xapiConfig.statementTriggers) { + if (trigger == null + || trigger.type != StatementTrigger.Type.OBJECT_DELETE + || trigger.clazz == null + || trigger.clazz.isBlank()) { + continue; + } + catalog.objectClass(trigger.clazz).ifPresent(clazz -> + addAttributes(merged, clazz.localName(), clazz.topLevelAttributeNames())); + } + } + @SafeVarargs private final Map> mergeSubscriptions(Map>... plans) { Map> merged = new LinkedHashMap<>(); diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheQueries.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheQueries.java index 6e46ff4..a21878e 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheQueries.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheQueries.java @@ -24,6 +24,8 @@ interface ObjectCacheQueries { String loadObject(); + String loadCurrentObjectSnapshot(); + String removeObject(); String findCurrentValue(); diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheStore.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheStore.java index a129947..d3697a8 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheStore.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheStore.java @@ -9,6 +9,8 @@ interface ObjectCacheStore extends AutoCloseable { CachedObject ensureObject(String objectHandle, String objectName, FomCatalog.ObjectClassDef clazz); + Optional findCurrentObjectSnapshot(String objectHandle); + void removeObject(String objectHandle, String removedAt); Optional findCurrentValue(long instanceId, String pathKey); diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java b/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java index 2859121..012b2c6 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java @@ -137,6 +137,26 @@ public String loadObject() { return "SELECT id, object_name FROM object_instance WHERE object_handle = ?"; } + @Override + public String loadCurrentObjectSnapshot() { + return """ + SELECT i.object_handle, i.object_name, c.local_name, a.attribute_name, v.raw_bytes + FROM object_instance i + JOIN fom_object_class c ON c.id = i.class_id + LEFT JOIN object_attribute_current v + ON v.instance_id = i.id + AND v.attribute_id IN ( + SELECT top_level.id + FROM fom_attribute top_level + WHERE top_level.class_id = i.class_id + AND top_level.path_key = top_level.attribute_name + ) + LEFT JOIN fom_attribute a ON a.id = v.attribute_id + WHERE i.object_handle = ? AND i.removed_at IS NULL + ORDER BY a.id + """; + } + @Override public String removeObject() { return "UPDATE object_instance SET removed_at = ? WHERE object_handle = ?"; diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java b/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java index 08e883d..f701845 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java @@ -123,6 +123,26 @@ public String loadObject() { return "SELECT id, object_name FROM object_instance WHERE object_handle = ?"; } + @Override + public String loadCurrentObjectSnapshot() { + return """ + SELECT i.object_handle, i.object_name, c.local_name, a.attribute_name, v.raw_bytes + FROM object_instance i + JOIN fom_object_class c ON c.id = i.class_id + LEFT JOIN object_attribute_current v + ON v.instance_id = i.id + AND v.attribute_id IN ( + SELECT top_level.id + FROM fom_attribute top_level + WHERE top_level.class_id = i.class_id + AND top_level.path_key = top_level.attribute_name + ) + LEFT JOIN fom_attribute a ON a.id = v.attribute_id + WHERE i.object_handle = ? AND i.removed_at IS NULL + ORDER BY a.id + """; + } + @Override public String removeObject() { return "UPDATE object_instance SET removed_at = ? WHERE object_handle = ?"; diff --git a/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java index 7702cdf..2f0226d 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java @@ -79,25 +79,31 @@ void reportsAbsentAndMalformedObjectAttributesAsMissingValues() { @Test @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) - void validatesObjectUpdateTargetsAgainstInheritedObjectAttributes() { + void validatesEveryObjectEventTargetAgainstInheritedObjectAttributes() { TriggerProcessor processor = new TriggerProcessor(handler(OBJECT_FOM)); - StatementTrigger valid = trigger(""" - {"object":{"id":["trigger",["EntityId"]]}} - """); - StatementTrigger wrongType = trigger(""" - {"object":{"id":["trigger",["Count"]]}} - """); - TestInjectionContext context = - new TestInjectionContext(StatementTrigger.Type.OBJECT_UPDATE, "TrackedEntity"); - - TriggerProcessor.TriggerProcessingResult validResult = - processor.renderTemplateForValidation(valid, context); - TriggerProcessor.TriggerProcessingResult wrongTypeResult = - processor.renderTemplateForValidation(wrongType, context); - - assertTrue(validResult.success()); - assertTrue(validResult.statement().contains("https://example.com/object")); - assertFalse(wrongTypeResult.success()); + for (StatementTrigger.Type type : List.of( + StatementTrigger.Type.OBJECT_CREATE, + StatementTrigger.Type.OBJECT_UPDATE, + StatementTrigger.Type.OBJECT_DELETE)) { + StatementTrigger valid = trigger(""" + {"object":{"id":["trigger",["EntityId"]]}} + """); + valid.type = type; + StatementTrigger wrongType = trigger(""" + {"object":{"id":["trigger",["Count"]]}} + """); + wrongType.type = type; + TestInjectionContext context = new TestInjectionContext(type, "TrackedEntity"); + + TriggerProcessor.TriggerProcessingResult validResult = + processor.renderTemplateForValidation(valid, context); + TriggerProcessor.TriggerProcessingResult wrongTypeResult = + processor.renderTemplateForValidation(wrongType, context); + + assertTrue(validResult.success()); + assertTrue(validResult.statement().contains("https://example.com/object")); + assertFalse(wrongTypeResult.success()); + } } @Test diff --git a/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java b/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java index 7747fb1..a79c9a2 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java @@ -70,6 +70,34 @@ void interactionEventsUseTheSameDispatcherWithoutMatchingObjectTriggers() { assertEquals(List.of("interaction"), enqueued); } + @Test + void lifecycleEventsMatchTheirExactTypeAndClass() { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of( + trigger(StatementTrigger.Type.OBJECT_CREATE, "Rabbit", "rabbit-create"), + trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "rabbit-update"), + trigger(StatementTrigger.Type.OBJECT_DELETE, "Rabbit", "rabbit-delete"), + trigger(StatementTrigger.Type.OBJECT_DELETE, "Wolf", "wolf-delete")); + StatementTriggerDispatcher dispatcher = + new StatementTriggerDispatcher(config, new ControlledTriggerProcessor()); + ObjectInjectionContext rabbit = + new ObjectInjectionContext("Rabbit", "object-1", Map.of()); + + List createStatements = dispatcher + .stage(StatementTrigger.Type.OBJECT_CREATE, "Rabbit", rabbit) + .stream() + .map(StatementTriggerDispatcher.StagedStatement::statement) + .toList(); + List deleteStatements = dispatcher + .stage(StatementTrigger.Type.OBJECT_DELETE, "Rabbit", rabbit) + .stream() + .map(StatementTriggerDispatcher.StagedStatement::statement) + .toList(); + + assertEquals(List.of("rabbit-create"), createStatements); + assertEquals(List.of("rabbit-delete"), deleteStatements); + } + private StatementTrigger trigger(StatementTrigger.Type type, String className, String statement) { StatementTrigger trigger = new StatementTrigger(); trigger.type = type; diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java index 2bc51ba..f9bebca 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java @@ -32,6 +32,7 @@ import hla.rti1516e.ObjectClassHandle; import hla.rti1516e.ObjectInstanceHandle; import hla.rti1516e.RTIambassador; +import hla.rti1516e.exceptions.ObjectInstanceNotKnown; import java.lang.reflect.Field; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; @@ -99,6 +100,359 @@ void eventOnlyConfigurationSubscribesRequestsAndProcessesReflections() throws Ex } } + @Test + void firstReflectionDispatchesObjectCreateAndObjectUpdateThenOnlyUpdates() throws Exception { + StatementTrigger create = objectTrigger( + StatementTrigger.Type.OBJECT_CREATE, + "Rabbit", + """ + {"event":"create","hunger":["trigger",["Hunger"]]} + """); + StatementTrigger update = objectTrigger( + StatementTrigger.Type.OBJECT_UPDATE, + "Rabbit", + """ + {"event":"update","hunger":["trigger",["Hunger"]]} + """); + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(create, update); + + try (ObjectCache cache = new ObjectCache(config, catalog, fomXml, decoderRegistry)) { + RecordingRti rti = new RecordingRti(); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = hlaInterface( + cache, + rti.proxy(), + config, + xapiClient, + injectionHandler(cache)); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(96); + AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); + + hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Create"); + reflect(hlaInterface, rabbit, hunger, 12); + reflect(hlaInterface, rabbit, hunger, 13); + + assertFalse(cache.isEnabled()); + assertEquals( + List.of( + "{\"event\":\"create\",\"hunger\":12}", + "{\"event\":\"update\",\"hunger\":12}", + "{\"event\":\"update\",\"hunger\":13}"), + xapiClient.statements); + } + } + + @Test + @SuppressTestLogging({"com.yetanalytics.hlaxapi.HlaInterfaceImpl"}) + void failedCacheProcessingRetainsPendingCreateForTheNextReflection() throws Exception { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(objectTrigger( + StatementTrigger.Type.OBJECT_CREATE, + "Rabbit", + """ + {"hunger":["trigger",["Hunger"]]} + """)); + + try (ObjectCache cache = + new FailOnceReflectionCache(config, catalog, fomXml, decoderRegistry)) { + RecordingRti rti = new RecordingRti(); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = hlaInterface( + cache, + rti.proxy(), + config, + xapiClient, + injectionHandler(cache)); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(97); + AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); + + hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Retry"); + reflect(hlaInterface, rabbit, hunger, 12); + reflect(hlaInterface, rabbit, hunger, 13); + reflect(hlaInterface, rabbit, hunger, 14); + + assertEquals(List.of("{\"hunger\":13}"), xapiClient.statements); + } + } + + @Test + void deletionBeforeFirstReflectionClearsPendingCreate() throws Exception { + XapiConfig config = new XapiConfig(); + config.statementTriggers = + List.of(objectTrigger(StatementTrigger.Type.OBJECT_CREATE, "Rabbit", "{}")); + + try (ObjectCache cache = new ObjectCache(config, catalog, fomXml, decoderRegistry)) { + RecordingRti rti = new RecordingRti(); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = + hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(98); + AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); + + hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Gone"); + hlaInterface.removeObjectInstance(rabbit, null, null, null); + reflect(hlaInterface, rabbit, hunger, 12); + + assertTrue(xapiClient.statements.isEmpty()); + } + } + + @Test + @SuppressTestLogging({ + "com.yetanalytics.hlaxapi.TriggerProcessor", + "com.yetanalytics.hlaxapi.StatementTriggerDispatcher" + }) + void firstReflectionConsumesCreateEvenWhenRequiredValuesAreMissing() throws Exception { + StatementTrigger required = objectTrigger( + StatementTrigger.Type.OBJECT_CREATE, + "Rabbit", + "{\"entityId\":[\"trigger\",[\"EntityId\"]]}"); + StatementTrigger optional = objectTrigger( + StatementTrigger.Type.OBJECT_CREATE, + "Rabbit", + "{\"entityId\":[\"trigger\",[\"EntityId\"],{\"required\":false}]}"); + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(required, optional); + + try (ObjectCache cache = new ObjectCache(config, catalog, fomXml, decoderRegistry)) { + RecordingRti rti = new RecordingRti(); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = + hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(105); + + hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Partial"); + reflect(hlaInterface, rabbit, rti.attributeHandle(rabbitClass, "Hunger"), 12); + AttributeHandleValueMap secondReflection = new HLA1516eAttributeHandleValueMap(); + secondReflection.put( + rti.attributeHandle(rabbitClass, "EntityId"), + HLAEncodingTestSupport.asciiString("rabbit-partial")); + hlaInterface.reflectAttributeValues(rabbit, secondReflection, null, null, null, null); + + assertEquals(List.of("{\"entityId\":null}"), xapiClient.statements); + } + } + + @Test + void objectDeleteUsesFinalSnapshotAndEnqueuesAfterRemoval(@TempDir Path tempDir) throws Exception { + StatementTrigger delete = objectTrigger( + StatementTrigger.Type.OBJECT_DELETE, + "Rabbit", + """ + { + "entityId":["trigger",["EntityId"]], + "hunger":["trigger",["Hunger"]], + "x":["trigger",["Position","X"]], + "queried":["query","Rabbit",["Hunger"],null], + "lookedUp":["lookup","rabbit",["Hunger"]] + } + """); + delete.criteria = comparison("Hunger", ComparisonOperator.GT, 10); + ObjectLookup lookup = new ObjectLookup(); + lookup.clazz = "Rabbit"; + lookup.criteria = new Criterion( + new Target(List.of("EntityId")), + ComparisonOperator.EQ, + new ValueExpression("rabbit-delete")); + delete.lookups = Map.of("rabbit", lookup); + StatementTrigger skipped = objectTrigger( + StatementTrigger.Type.OBJECT_DELETE, + "Rabbit", + "{\"skipped\":true}"); + skipped.criteria = comparison("Hunger", ComparisonOperator.GT, 20); + StatementTrigger wrongClass = objectTrigger( + StatementTrigger.Type.OBJECT_DELETE, + "Wolf", + "{\"wrongClass\":true}"); + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(delete, skipped, wrongClass); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("object-delete-dispatch.sqlite"))) { + RecordingRti rti = new RecordingRti(); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(99); + AtomicReference removedAtEnqueue = new AtomicReference<>(false); + RecordingXapiClient xapiClient = new RecordingXapiClient(statement -> + removedAtEnqueue.set(cache.currentObjects("Rabbit").isEmpty())); + HlaInterfaceImpl hlaInterface = + hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); + hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Delete"); + AttributeHandleValueMap reflection = new HLA1516eAttributeHandleValueMap(); + reflection.put( + rti.attributeHandle(rabbitClass, "EntityId"), + HLAEncodingTestSupport.asciiString("rabbit-delete")); + reflection.put( + rti.attributeHandle(rabbitClass, "Hunger"), + HLAEncodingTestSupport.int32(17, ByteOrder.BIG_ENDIAN)); + reflection.put( + rti.attributeHandle(rabbitClass, "Position"), + HLAEncodingTestSupport.fixedRecord( + HLAEncodingTestSupport.int32(4, ByteOrder.BIG_ENDIAN), + HLAEncodingTestSupport.int32(7, ByteOrder.BIG_ENDIAN))); + hlaInterface.reflectAttributeValues(rabbit, reflection, null, null, null, null); + + hlaInterface.removeObjectInstance(rabbit, null, null, null); + hlaInterface.removeObjectInstance(rabbit, null, null, null); + + assertTrue(removedAtEnqueue.get()); + assertEquals( + List.of( + "{\"entityId\":\"rabbit-delete\",\"hunger\":17,\"x\":4," + + "\"queried\":17,\"lookedUp\":17}"), + xapiClient.statements); + assertTrue(cache.findCurrentObjectSnapshot(rabbit.toString()).isEmpty()); + } + } + + @Test + @SuppressTestLogging({ + "com.yetanalytics.hlaxapi.HlaInterfaceImpl", + "com.yetanalytics.hlaxapi.TriggerProcessor", + "com.yetanalytics.hlaxapi.StatementTriggerDispatcher" + }) + void discoveryRemovalRaceStillDispatchesStaticAndOptionalDeletes(@TempDir Path tempDir) throws Exception { + StatementTrigger staticDelete = + objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "Rabbit", "{\"deleted\":true}"); + StatementTrigger requiredMissing = objectTrigger( + StatementTrigger.Type.OBJECT_DELETE, + "Rabbit", + "{\"entityId\":[\"trigger\",[\"EntityId\"]]}"); + StatementTrigger optionalMissing = objectTrigger( + StatementTrigger.Type.OBJECT_DELETE, + "Rabbit", + "{\"entityId\":[\"trigger\",[\"EntityId\"],{\"required\":false}]}"); + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(staticDelete, requiredMissing, optionalMissing); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("object-delete-race.sqlite"))) { + RecordingRti rti = new RecordingRti(); + rti.failAttributeRequestsForUnknownObjects = true; + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = + hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(100); + + hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Brief"); + hlaInterface.removeObjectInstance(rabbit, null, null, null); + + assertEquals( + List.of("{\"deleted\":true}", "{\"entityId\":null}"), + xapiClient.statements); + assertTrue(cache.currentObjects("Rabbit").isEmpty()); + } + } + + @Test + @SuppressTestLogging({"com.yetanalytics.hlaxapi.HlaInterfaceImpl"}) + void removalFailureSuppressesStagedDeleteStatements(@TempDir Path tempDir) throws Exception { + XapiConfig config = new XapiConfig(); + config.statementTriggers = + List.of(objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "Rabbit", "{}")); + + try (ObjectCache cache = new FailingRemovalCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("object-delete-failure.sqlite"))) { + RecordingRti rti = new RecordingRti(); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = + hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(101); + hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Failure"); + + hlaInterface.removeObjectInstance(rabbit, null, null, null); + + assertTrue(xapiClient.statements.isEmpty()); + assertEquals(1, cache.currentObjects("Rabbit").size()); + } + } + + @Test + void everyLifecycleCallbackOverloadUsesTheCommonPipelines(@TempDir Path tempDir) throws Exception { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of( + objectTrigger(StatementTrigger.Type.OBJECT_CREATE, "Rabbit", "{\"event\":\"create\"}"), + objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "Rabbit", "{\"event\":\"delete\"}")); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("lifecycle-overloads.sqlite"))) { + RecordingRti rti = new RecordingRti(); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = + hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); + ObjectInstanceHandle first = rti.objectHandle(102); + ObjectInstanceHandle second = rti.objectHandle(103); + ObjectInstanceHandle third = rti.objectHandle(104); + hlaInterface.discoverObjectInstance(first, rabbitClass, "Rabbit First"); + hlaInterface.discoverObjectInstance(second, rabbitClass, "Rabbit Second", null); + hlaInterface.discoverObjectInstance(third, rabbitClass, "Rabbit Third"); + AttributeHandleValueMap firstReflection = reflection(hunger, 1); + AttributeHandleValueMap secondReflection = reflection(hunger, 2); + AttributeHandleValueMap thirdReflection = reflection(hunger, 3); + + hlaInterface.reflectAttributeValues(first, firstReflection, null, null, null, null); + hlaInterface.reflectAttributeValues( + second, + secondReflection, + null, + null, + null, + null, + null, + null); + hlaInterface.reflectAttributeValues( + third, + thirdReflection, + null, + null, + null, + null, + null, + null, + null); + + hlaInterface.removeObjectInstance(first, null, null, null); + hlaInterface.removeObjectInstance(second, null, null, null, null, null); + hlaInterface.removeObjectInstance(third, null, null, null, null, null, null); + + assertEquals( + List.of( + "{\"event\":\"create\"}", + "{\"event\":\"create\"}", + "{\"event\":\"create\"}", + "{\"event\":\"delete\"}", + "{\"event\":\"delete\"}", + "{\"event\":\"delete\"}"), + xapiClient.statements); + assertTrue(cache.currentObjects("Rabbit").isEmpty()); + } + } + @Test @SuppressTestLogging({ "com.yetanalytics.hlaxapi.TriggerProcessor", @@ -326,13 +680,34 @@ private StatementTrigger objectUpdateTrigger(String className) { } private StatementTrigger objectUpdateTrigger(String className, String statement) { + return objectTrigger(StatementTrigger.Type.OBJECT_UPDATE, className, statement); + } + + private StatementTrigger objectTrigger( + StatementTrigger.Type type, + String className, + String statement) { StatementTrigger trigger = new StatementTrigger(); - trigger.type = StatementTrigger.Type.OBJECT_UPDATE; + trigger.type = type; trigger.clazz = className; trigger.statement = statement; return trigger; } + private void reflect( + HlaInterfaceImpl hlaInterface, + ObjectInstanceHandle object, + AttributeHandle attribute, + int value) throws Exception { + hlaInterface.reflectAttributeValues(object, reflection(attribute, value), null, null, null, null); + } + + private AttributeHandleValueMap reflection(AttributeHandle attribute, int value) { + AttributeHandleValueMap reflectedValues = new HLA1516eAttributeHandleValueMap(); + reflectedValues.put(attribute, HLAEncodingTestSupport.int32(value, ByteOrder.BIG_ENDIAN)); + return reflectedValues; + } + private Criterion comparison(String attribute, ComparisonOperator operator, Object value) { return new Criterion( new TriggerExpression(new Target(List.of(attribute))), @@ -383,6 +758,7 @@ private HlaInterfaceImpl hlaInterface( HlaInterfaceImpl hlaInterface = new HlaInterfaceImpl(); setField(hlaInterface, "objectCache", cache); setField(hlaInterface, "ambassador", ambassador); + setField(hlaInterface, "xapiConfig", config); setField( hlaInterface, "triggerDispatcher", @@ -409,6 +785,48 @@ private record ObjectSubscription(String className, Set attributes) { private record AttributeRequest(ObjectInstanceHandle objectHandle, Set attributes) { } + private static final class FailOnceReflectionCache extends ObjectCache { + + private boolean failNextReflection = true; + + private FailOnceReflectionCache( + XapiConfig config, + FomCatalog catalog, + FOMXML fomXml, + HLADecoderRegistry decoderRegistry) { + super(config, catalog, fomXml, decoderRegistry); + } + + @Override + public synchronized void reflectAttributeValues( + String objectHandle, + String className, + Map attributes) { + if (failNextReflection) { + failNextReflection = false; + throw new IllegalStateException("injected reflection failure"); + } + super.reflectAttributeValues(objectHandle, className, attributes); + } + } + + private static final class FailingRemovalCache extends ObjectCache { + + private FailingRemovalCache( + XapiConfig config, + FomCatalog catalog, + FOMXML fomXml, + HLADecoderRegistry decoderRegistry, + String jdbcUrl) { + super(config, catalog, fomXml, decoderRegistry, jdbcUrl); + } + + @Override + public synchronized void removeObject(String objectHandle) { + throw new IllegalStateException("injected removal failure"); + } + } + private static final class RecordingXapiClient extends XapiClient { private final List statements = new ArrayList<>(); @@ -456,6 +874,7 @@ private static final class RecordingRti implements InvocationHandler { private int knownClassResolutions; private int attributeNameResolutions; private ObjectClassHandle knownClass; + private boolean failAttributeRequestsForUnknownObjects; private RTIambassador proxy() { return (RTIambassador) Proxy.newProxyInstance( @@ -487,7 +906,7 @@ private AttributeHandle attributeHandle(ObjectClassHandle classHandle, String at } @Override - public Object invoke(Object proxy, Method method, Object[] args) { + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { return switch (method.getName()) { case "getObjectClassHandle" -> classHandle((String) args[0]); case "getObjectClassName" -> qualifiedClassName(classNames.get(args[0])); @@ -500,6 +919,9 @@ public Object invoke(Object proxy, Method method, Object[] args) { yield null; } case "requestAttributeValueUpdate" -> { + if (failAttributeRequestsForUnknownObjects) { + throw new ObjectInstanceNotKnown("object disappeared"); + } requests.add(new AttributeRequest( (ObjectInstanceHandle) args[0], names((AttributeHandleSet) args[1]))); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java index fdedd64..c3537de 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java @@ -100,6 +100,45 @@ void storesOneMultiAttributeReflectionWithSharedObservationMetadata() throws SQL } } + @Test + void loadsCurrentObjectSnapshotWithTopLevelRawValuesAndMetadata() { + byte[] entityId = encoded(encoderFactory.createHLAASCIIstring("rabbit-one")); + byte[] hunger = encoded(encoderFactory.createHLAinteger32BE(75)); + byte[] position = position(12, 8); + byte[] history = positionHistory(position(1, 2), position(3, 4)); + + try (ObjectCache cache = newCache( + "object-snapshot", + enabledConfig(), + dynamicArrayCatalog, + dynamicArrayFomXml)) { + cache.discoverObject("object-1", "Rabbit One", "Rabbit"); + cache.reflectAttributeValues( + "object-1", + "Rabbit", + Map.of( + "EntityId", entityId, + "Hunger", hunger, + "Position", position, + "PositionHistory", history)); + + ObjectSnapshot snapshot = cache.findCurrentObjectSnapshot("object-1").orElseThrow(); + + assertEquals("object-1", snapshot.objectHandle()); + assertEquals("Rabbit One", snapshot.objectName()); + assertEquals("Rabbit", snapshot.className()); + assertEquals(4, snapshot.attributes().size()); + assertArrayEquals(entityId, snapshot.attributes().get("EntityId")); + assertArrayEquals(hunger, snapshot.attributes().get("Hunger")); + assertArrayEquals(position, snapshot.attributes().get("Position")); + assertArrayEquals(history, snapshot.attributes().get("PositionHistory")); + + cache.removeObject("object-1"); + + assertTrue(cache.findCurrentObjectSnapshot("object-1").isEmpty()); + } + } + @Test void validatesTheCompleteReflectionBeforeWriting() { byte[] oldHunger = encoded(encoderFactory.createHLAinteger32BE(40)); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java index 5312578..7b1d4fe 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java @@ -89,6 +89,49 @@ void objectUpdateSubscriptionsDoNotEnableCacheAndIncludeInheritedAttributes(@Tem } } + @Test + void objectCreateSubscriptionsDoNotEnableCacheAndIncludeInheritedAttributes(@TempDir Path tempDir) { + Path databasePath = tempDir.resolve("object-create-only.sqlite"); + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(objectTrigger(StatementTrigger.Type.OBJECT_CREATE, "Rabbit")); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + databasePath)) { + Set rabbitAttributes = + Set.copyOf(catalog.objectClass("Rabbit").orElseThrow().topLevelAttributeNames()); + + assertFalse(cache.isEnabled()); + assertTrue(cache.cacheSubscriptions().isEmpty()); + assertEquals(rabbitAttributes, cache.eventSubscriptions().get("Rabbit")); + assertFalse(Files.exists(databasePath)); + } + } + + @Test + void objectDeleteSubscriptionsEnableCacheForAllInheritedAttributes(@TempDir Path tempDir) { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "Rabbit")); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("object-delete.sqlite"))) { + Set rabbitAttributes = + Set.copyOf(catalog.objectClass("Rabbit").orElseThrow().topLevelAttributeNames()); + + assertTrue(cache.isEnabled()); + assertEquals(rabbitAttributes, cache.cacheSubscriptions().get("Rabbit")); + assertEquals(rabbitAttributes, cache.eventSubscriptions().get("Rabbit")); + assertEquals(rabbitAttributes, cache.subscriptions().get("Rabbit")); + } + } + @Test void objectUpdateSubscriptionsMergeWithoutChangingCacheRequirements(@TempDir Path tempDir) { XapiConfig config = configWithQuery(); @@ -286,8 +329,12 @@ private XapiConfig configWithTrackedObject(String className, List attrib } private StatementTrigger objectUpdateTrigger(String className) { + return objectTrigger(StatementTrigger.Type.OBJECT_UPDATE, className); + } + + private StatementTrigger objectTrigger(StatementTrigger.Type type, String className) { StatementTrigger trigger = new StatementTrigger(); - trigger.type = StatementTrigger.Type.OBJECT_UPDATE; + trigger.type = type; trigger.clazz = className; trigger.statement = "{}"; return trigger; From 7e0fb7dc4cea81897d2ffa0f395555346b18e5b4 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 11:46:29 -0400 Subject: [PATCH 10/36] properly search child classes --- config/xapi-config.json | 120 ++++++++++++++++++ .../hlaxapi/cache/FomCatalog.java | 21 +++ .../hlaxapi/cache/JdbcObjectCacheStore.java | 17 ++- .../hlaxapi/cache/ObjectCache.java | 5 +- .../hlaxapi/cache/ObjectCacheQueries.java | 2 +- .../hlaxapi/cache/ObjectCacheStore.java | 2 +- .../cache/PostgresqlObjectCacheQueries.java | 14 +- .../cache/SqliteObjectCacheQueries.java | 14 +- .../hlaxapi/cache/FomCatalogTest.java | 17 +++ .../cache/ObjectCachePersistenceTest.java | 100 +++++++++++++++ 10 files changed, 290 insertions(+), 22 deletions(-) diff --git a/config/xapi-config.json b/config/xapi-config.json index b13e46b..5bd2acc 100644 --- a/config/xapi-config.json +++ b/config/xapi-config.json @@ -80,6 +80,126 @@ } } } + }, + { + "type": "ObjectCreate", + "class": "Rabbit", + "statement": { + "actor": { + "objectType": "Agent", + "name": "HLA xAPI Adapter", + "account": { + "homePage": "https://hla-federepl.example/adapters", + "name": "rabbit-lifecycle-monitor" + } + }, + "verb": { + "id": "https://hla-federepl.example/verbs/appeared", + "display": {"en-US": "appeared"} + }, + "object": { + "objectType": "Activity", + "id": "https://hla-federepl.example/simulation/entities/rabbit", + "definition": { + "name": {"en-US": "Rabbit"} + } + }, + "context": { + "extensions": { + "https://hla-federepl.example/extensions/object-event": "ObjectCreate", + "https://hla-federepl.example/extensions/entity-id": [ + "trigger", + ["EntityId"], + {"required": false} + ], + "https://hla-federepl.example/extensions/first-name": [ + "trigger", + ["FirstName"], + {"required": false} + ], + "https://hla-federepl.example/extensions/last-name": [ + "trigger", + ["LastName"], + {"required": false} + ], + "https://hla-federepl.example/extensions/position-x": [ + "trigger", + ["Position", "X"], + {"required": false} + ], + "https://hla-federepl.example/extensions/position-y": [ + "trigger", + ["Position", "Y"], + {"required": false} + ], + "https://hla-federepl.example/extensions/hunger": [ + "trigger", + ["Hunger"], + {"required": false} + ] + } + } + } + }, + { + "type": "ObjectDelete", + "class": "Rabbit", + "statement": { + "actor": { + "objectType": "Agent", + "name": "HLA xAPI Adapter", + "account": { + "homePage": "https://hla-federepl.example/adapters", + "name": "rabbit-lifecycle-monitor" + } + }, + "verb": { + "id": "https://hla-federepl.example/verbs/disappeared", + "display": {"en-US": "disappeared"} + }, + "object": { + "objectType": "Activity", + "id": "https://hla-federepl.example/simulation/entities/rabbit", + "definition": { + "name": {"en-US": "Rabbit"} + } + }, + "context": { + "extensions": { + "https://hla-federepl.example/extensions/object-event": "ObjectDelete", + "https://hla-federepl.example/extensions/entity-id": [ + "trigger", + ["EntityId"], + {"required": false} + ], + "https://hla-federepl.example/extensions/final-first-name": [ + "trigger", + ["FirstName"], + {"required": false} + ], + "https://hla-federepl.example/extensions/final-last-name": [ + "trigger", + ["LastName"], + {"required": false} + ], + "https://hla-federepl.example/extensions/final-position-x": [ + "trigger", + ["Position", "X"], + {"required": false} + ], + "https://hla-federepl.example/extensions/final-position-y": [ + "trigger", + ["Position", "Y"], + {"required": false} + ], + "https://hla-federepl.example/extensions/final-hunger": [ + "trigger", + ["Hunger"], + {"required": false} + ] + } + } + } } ], "lrs": { diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java index abf6543..602dbd8 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java @@ -55,6 +55,16 @@ public Optional objectClass(int id) { return Optional.ofNullable(classesById.get(id)); } + public List objectClassAndDescendants(String name) { + ObjectClassDef requestedClass = objectClass(name).orElse(null); + if (requestedClass == null) { + return List.of(); + } + return classesByName.values().stream() + .filter(candidate -> isSameOrDescendant(candidate, requestedClass)) + .toList(); + } + public Optional attribute(int id) { return Optional.ofNullable(attributesById.get(id)); } @@ -108,6 +118,17 @@ static String localName(String hlaName) { return index >= 0 ? trimmed.substring(index + 1) : trimmed; } + private boolean isSameOrDescendant(ObjectClassDef candidate, ObjectClassDef requestedClass) { + ObjectClassDef current = candidate; + while (current != null) { + if (current.localName().equals(requestedClass.localName())) { + return true; + } + current = classesByName.get(current.parentName()); + } + return false; + } + public record ObjectClassDef( int id, String hlaName, diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java b/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java index 5409477..4025f92 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java @@ -125,22 +125,27 @@ public Optional findCurrentValue(String objectHandle, String pathKe } @Override - public List currentObjects(FomCatalog.ObjectClassDef clazz) { + public List currentObjects(List classes) { + if (classes == null || classes.isEmpty()) { + return List.of(); + } List objects = new ArrayList<>(); - try (PreparedStatement statement = connection.prepareStatement(queries.listCurrentObjects())) { - statement.setInt(1, clazz.id()); + try (PreparedStatement statement = + connection.prepareStatement(queries.listCurrentObjects(classes.size()))) { + for (int i = 0; i < classes.size(); i++) { + statement.setInt(i + 1, classes.get(i).id()); + } try (ResultSet resultSet = statement.executeQuery()) { while (resultSet.next()) { objects.add(new CachedObject( resultSet.getLong("id"), resultSet.getString("object_handle"), resultSet.getString("object_name"), - clazz.localName())); + resultSet.getString("local_name"))); } } } catch (SQLException e) { - throw new IllegalStateException( - "Could not list cached objects for class " + clazz.localName(), e); + throw new IllegalStateException("Could not list current cached objects", e); } return objects; } diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java index 7782628..d08b1c5 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java @@ -212,8 +212,9 @@ public synchronized List currentObjects(String className) { if (!isEnabled()) { return List.of(); } - FomCatalog.ObjectClassDef clazz = requireClass(className); - return store.currentObjects(clazz); + FomCatalog.ObjectClassDef requestedClass = requireClass(className); + return store.currentObjects( + catalog.objectClassAndDescendants(requestedClass.localName())); } Connection connection() { diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheQueries.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheQueries.java index a21878e..23c860b 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheQueries.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheQueries.java @@ -32,7 +32,7 @@ interface ObjectCacheQueries { String findObjectId(); - String listCurrentObjects(); + String listCurrentObjects(int classCount); String deleteCurrentValues(); diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheStore.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheStore.java index d3697a8..18cfb47 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheStore.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheStore.java @@ -17,7 +17,7 @@ interface ObjectCacheStore extends AutoCloseable { Optional findCurrentValue(String objectHandle, String pathKey); - List currentObjects(FomCatalog.ObjectClassDef clazz); + List currentObjects(List classes); void replaceCurrentValues( String objectHandle, diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java b/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java index 012b2c6..a745d0a 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java @@ -180,13 +180,15 @@ public String findObjectId() { } @Override - public String listCurrentObjects() { + public String listCurrentObjects(int classCount) { + String placeholders = String.join(", ", java.util.Collections.nCopies(classCount, "?")); return """ - SELECT id, object_handle, object_name - FROM object_instance - WHERE class_id = ? AND removed_at IS NULL - ORDER BY id - """; + SELECT i.id, i.object_handle, i.object_name, c.local_name + FROM object_instance i + JOIN fom_object_class c ON c.id = i.class_id + WHERE i.class_id IN (%s) AND i.removed_at IS NULL + ORDER BY i.id + """.formatted(placeholders); } @Override diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java b/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java index f701845..de3db93 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java @@ -166,13 +166,15 @@ public String findObjectId() { } @Override - public String listCurrentObjects() { + public String listCurrentObjects(int classCount) { + String placeholders = String.join(", ", java.util.Collections.nCopies(classCount, "?")); return """ - SELECT id, object_handle, object_name - FROM object_instance - WHERE class_id = ? AND removed_at IS NULL - ORDER BY id - """; + SELECT i.id, i.object_handle, i.object_name, c.local_name + FROM object_instance i + JOIN fom_object_class c ON c.id = i.class_id + WHERE i.class_id IN (%s) AND i.removed_at IS NULL + ORDER BY i.id + """.formatted(placeholders); } @Override diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java index 4d31b4f..0943125 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java @@ -43,6 +43,23 @@ void includesInheritedObjectAttributes() { assertEquals("HLAinteger32BE", rabbit.attribute("Hunger").orElseThrow().primitiveType()); } + @Test + void resolvesObjectClassesWithTheirDescendants() { + FomCatalog catalog = catalog("config/HlaFedereplFOM.xml"); + + assertEquals( + List.of("SimEntity", "Carrot", "Rabbit", "Wolf"), + catalog.objectClassAndDescendants("SimEntity").stream() + .map(FomCatalog.ObjectClassDef::localName) + .toList()); + assertEquals( + List.of("Rabbit"), + catalog.objectClassAndDescendants("Rabbit").stream() + .map(FomCatalog.ObjectClassDef::localName) + .toList()); + assertEquals(List.of(), catalog.objectClassAndDescendants("MissingObject")); + } + @Test void fomXmlReturnsHierarchyWithDeclaredAttributes() { FOMXML fomXml = fomXml("config/HlaFedereplFOM.xml"); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java index c3537de..e3bad3a 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java @@ -10,20 +10,27 @@ import com.yetanalytics.hlaxapi.FOMXML; import com.yetanalytics.hlaxapi.HLAEncodingTestSupport; import com.yetanalytics.hlaxapi.HLADecoderRegistry; +import com.yetanalytics.hlaxapi.InjectionHandler; import com.yetanalytics.hlaxapi.SimulationConfig; +import com.yetanalytics.hlaxapi.TriggerProcessor; import com.yetanalytics.hlaxapi.config.XapiConfig; import com.yetanalytics.hlaxapi.config.model.ComparisonOperator; import com.yetanalytics.hlaxapi.config.model.Criterion; import com.yetanalytics.hlaxapi.config.model.LogicalExpression; import com.yetanalytics.hlaxapi.config.model.LogicalOperator; import com.yetanalytics.hlaxapi.config.model.ObjectCacheConfig; +import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TrackedObject; +import com.yetanalytics.hlaxapi.config.model.TriggerExpression; import com.yetanalytics.hlaxapi.config.model.ValueExpression; +import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext; import hla.rti1516e.encoding.DataElement; import hla.rti1516e.encoding.EncoderException; import hla.rti1516e.encoding.EncoderFactory; import hla.rti1516e.encoding.HLAfixedRecord; +import java.lang.reflect.Field; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; @@ -388,6 +395,93 @@ void queryServiceEvaluatesCriteriaAndExcludesRemovedObjects() { } } + @Test + void baseClassQueryFindsObjectsCachedAsDescendantClasses() { + try (ObjectCache cache = newCache()) { + cache.discoverObject("entity-1", "Entity One", "SimEntity"); + cache.discoverObject("rabbit-1", "Rabbit One", "Rabbit"); + cache.reflectAttributeValues( + "rabbit-1", + "Rabbit", + Map.of( + "EntityId", encoded(encoderFactory.createHLAASCIIstring("rabbit-one")), + "FirstName", encoded(encoderFactory.createHLAunicodeString("Alice")))); + cache.discoverObject("wolf-1", "Wolf One", "Wolf"); + Criterion entityId = new Criterion( + new Target(List.of("EntityId")), + ComparisonOperator.EQ, + new ValueExpression("rabbit-one")); + + CachedObject matched = + cache.queryService().findFirstObject("SimEntity", entityId).orElseThrow(); + + assertEquals("Rabbit", matched.className()); + assertEquals( + "Alice", + cache.queryService() + .findValue(matched, new Target(List.of("FirstName"))) + .orElseThrow()); + assertEquals( + List.of("entity-1", "rabbit-1", "wolf-1"), + cache.currentObjects("SimEntity").stream() + .map(CachedObject::objectHandle) + .toList()); + assertEquals( + List.of("rabbit-1"), + cache.currentObjects("Rabbit").stream() + .map(CachedObject::objectHandle) + .toList()); + + cache.removeObject("wolf-1"); + + assertEquals( + List.of("entity-1", "rabbit-1"), + cache.currentObjects("SimEntity").stream() + .map(CachedObject::objectHandle) + .toList()); + } + } + + @Test + void entityAteLookupFindsRabbitThroughSimEntityBaseClass() throws Exception { + try (ObjectCache cache = newCache()) { + cache.reflectAttributeValues( + "rabbit-1", + "Rabbit", + Map.of( + "EntityId", encoded(encoderFactory.createHLAASCIIstring("rabbit-one")), + "FirstName", encoded(encoderFactory.createHLAunicodeString("Alice")))); + ObjectLookup predator = new ObjectLookup(); + predator.clazz = "SimEntity"; + predator.criteria = new Criterion( + new Target(List.of("EntityId")), + ComparisonOperator.EQ, + new TriggerExpression(new Target(List.of("PredatorId")))); + StatementTrigger trigger = new StatementTrigger(); + trigger.type = StatementTrigger.Type.INTERACTION; + trigger.clazz = "EntityAte"; + trigger.lookups = Map.of("predator", predator); + trigger.statement = "{\"predator\":[\"lookup\",\"predator\",[\"FirstName\"]]}"; + InjectionHandler injectionHandler = new InjectionHandler(); + injectionHandler.setFomXml(fomXml); + injectionHandler.setHLADecoderRegistry(decoderRegistry); + injectionHandler.setFomCatalog(catalog); + setField(injectionHandler, "objectCache", cache); + + TriggerProcessor.TriggerProcessingResult result = + new TriggerProcessor(injectionHandler).processTrigger( + trigger, + new InteractionInjectionContext( + "EntityAte", + Map.of( + "PredatorId", + encoded(encoderFactory.createHLAASCIIstring("rabbit-one"))))); + + assertTrue(result.success()); + assertEquals("{\"predator\":\"Alice\"}", result.statement()); + } + } + @Test void queryServiceDistinguishesPresentNullFromMissingValue() { try (ObjectCache cache = newCache()) { @@ -481,6 +575,12 @@ protected long scalarLong(ObjectCache cache, String sql) throws SQLException { } } + private void setField(Object target, String fieldName, Object value) throws ReflectiveOperationException { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + protected byte[] rawBytes(ObjectCache cache, String pathKey) throws SQLException { String sql = """ SELECT c.raw_bytes From 28dab8fc28eb5b8dc1cbd5a12da227d23abbdf0f Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 12:14:20 -0400 Subject: [PATCH 11/36] previous keyword is only available in ObjectUpdate --- .../hlaxapi/InjectionHandler.java | 22 +++ .../hlaxapi/TriggerCriteriaMatcher.java | 3 + .../hlaxapi/TriggerProcessor.java | 12 ++ .../hlaxapi/cache/ObjectCache.java | 15 ++ .../cache/PostgresqlObjectCacheQueries.java | 2 +- .../cache/QueryReferenceCollector.java | 71 ++++++-- .../cache/SqliteObjectCacheQueries.java | 2 +- .../hlaxapi/config/ConfigParser.java | 2 +- .../config/CriteriaExpressionParser.java | 5 + .../config/CriteriaExpressionValidator.java | 28 ++- .../hlaxapi/config/model/Expression.java | 1 + .../config/model/ExpressionWalker.java | 2 + .../hlaxapi/config/model/InjectionType.java | 3 +- .../config/model/PreviousExpression.java | 16 ++ .../hlaxapi/injection/InjectionContext.java | 10 ++ .../injection/StatementInjectionParser.java | 15 +- .../injection/TestInjectionContext.java | 14 +- .../com/yetanalytics/ConfigParserTest.java | 37 ++++ .../hlaxapi/ObjectInjectionHandlerTest.java | 23 +++ .../cache/HlaObjectSubscriptionTest.java | 170 ++++++++++++++++++ .../cache/ObjectCachePersistenceTest.java | 69 +++++++ .../hlaxapi/cache/ObjectCacheTest.java | 28 +++ .../cache/QueryReferenceCollectorTest.java | 28 +++ .../config/CriteriaExpressionParserTest.java | 26 +++ .../config/model/ExpressionWalkerTest.java | 13 ++ .../StatementInjectionParserTest.java | 22 ++- 26 files changed, 602 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/config/model/PreviousExpression.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java index 8946864..44ae0c7 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -19,6 +19,7 @@ import com.yetanalytics.hlaxapi.config.model.Expression; import com.yetanalytics.hlaxapi.config.model.ExpressionWalker; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TriggerExpression; import com.yetanalytics.hlaxapi.config.model.ValueExpression; @@ -297,6 +298,27 @@ public ValueResolution handleTrigger(Target t, ObjectInjectionContext context) { true); } + public ValueResolution handlePrevious(Target target, InjectionContext context) { + if (context == null + || context.getTriggerType() != StatementTrigger.Type.OBJECT_UPDATE) { + throw new IllegalArgumentException( + "previous values are only available to ObjectUpdate triggers"); + } + if (context instanceof TestInjectionContext testContext) { + return handleTrigger(target, testContext); + } + if (!(context instanceof ObjectInjectionContext objectContext)) { + throw new IllegalArgumentException( + "previous values require an object update context"); + } + if (objectCache == null) { + return ValueResolution.missingObject(); + } + return objectCache.findCurrentValueResolution( + objectContext.getObjectHandle(), + target); + } + public ValueResolution handleQuery( String clazz, Target attrTarget, diff --git a/src/main/java/com/yetanalytics/hlaxapi/TriggerCriteriaMatcher.java b/src/main/java/com/yetanalytics/hlaxapi/TriggerCriteriaMatcher.java index 45eedbb..eaae932 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/TriggerCriteriaMatcher.java +++ b/src/main/java/com/yetanalytics/hlaxapi/TriggerCriteriaMatcher.java @@ -3,6 +3,7 @@ import com.yetanalytics.hlaxapi.cache.ValueResolution; import com.yetanalytics.hlaxapi.config.model.Expression; import com.yetanalytics.hlaxapi.config.model.LookupExpression; +import com.yetanalytics.hlaxapi.config.model.PreviousExpression; import com.yetanalytics.hlaxapi.config.model.QueryExpression; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TriggerExpression; @@ -27,6 +28,8 @@ private Object resolve(Expression expression, InjectionContext context, LazyLook ValueResolution resolution; if (expression instanceof TriggerExpression trigger) { resolution = handler.handleTrigger(trigger.target, context); + } else if (expression instanceof PreviousExpression previous) { + resolution = handler.handlePrevious(previous.target, context); } else if (expression instanceof QueryExpression query) { resolution = handler.handleQuery(query.clazz, query.target, query.criteria, context); } else if (expression instanceof LookupExpression lookup) { diff --git a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java index 1170159..c0b6009 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java +++ b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java @@ -25,6 +25,7 @@ import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.InlineInjection; import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.LookupInjection; import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.ParseResult; +import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.PreviousInjection; import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.QueryInjection; import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.StatementInjection; import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.TriggerInjection; @@ -76,6 +77,8 @@ private TriggerProcessingResult processTrigger( return null; } ObjectMapper mapper = new ObjectMapper(); + StatementTrigger.Type previousTriggerType = context.getTriggerType(); + context.setTriggerType(trigger.type); try { LazyLookupContext lookups = new LazyLookupContext(injectionHandler, context, trigger.lookups); if (evaluateCriteria @@ -93,6 +96,8 @@ private TriggerProcessingResult processTrigger( } catch (Exception e) { logger.error("Could not process trigger {}.{}: {}", trigger.type, trigger.clazz, e.getMessage(), e); return TriggerProcessingResult.failed(e); + } finally { + context.setTriggerType(previousTriggerType); } } @@ -218,6 +223,13 @@ private JsonNode handleInjection( injectionDescription(triggerInjection, null), embedded, mapper); + } else if (injection instanceof PreviousInjection previousInjection) { + return renderResolution( + injectionHandler.handlePrevious(previousInjection.target(), context), + previousInjection.options(), + injectionDescription(previousInjection, null), + embedded, + mapper); } else if (injection instanceof QueryInjection queryInjection) { return renderResolution( injectionHandler.handleQuery( diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java index d08b1c5..2150e1f 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java @@ -208,6 +208,21 @@ public synchronized Optional findCurrentValue(String objectHandle, return store.findCurrentValue(objectHandle, pathKey); } + public synchronized ValueResolution findCurrentValueResolution( + String objectHandle, + Target target) { + if (!isEnabled()) { + return ValueResolution.missingObject(); + } + String pathKey = FomCatalog.targetPath(target == null ? null : target.parts); + if (pathKey == null) { + return ValueResolution.missingValue(); + } + return store.findCurrentValue(objectHandle, pathKey) + .map(value -> ValueResolution.present(value.value())) + .orElseGet(ValueResolution::missingValue); + } + public synchronized List currentObjects(String className) { if (!isEnabled()) { return List.of(); diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java b/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java index a745d0a..ce5c0b0 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java @@ -176,7 +176,7 @@ public String findCurrentValue() { @Override public String findObjectId() { - return "SELECT id FROM object_instance WHERE object_handle = ?"; + return "SELECT id FROM object_instance WHERE object_handle = ? AND removed_at IS NULL"; } @Override diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java b/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java index 054b983..dc1ce92 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java @@ -7,6 +7,7 @@ import com.yetanalytics.hlaxapi.config.model.ExpressionWalker; import com.yetanalytics.hlaxapi.config.model.LogicalExpression; import com.yetanalytics.hlaxapi.config.model.LookupExpression; +import com.yetanalytics.hlaxapi.config.model.PreviousExpression; import com.yetanalytics.hlaxapi.config.model.QueryExpression; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; @@ -16,6 +17,7 @@ import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.InlineInjection; import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.LookupInjection; import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.ParseResult; +import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.PreviousInjection; import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.QueryInjection; import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.StatementInjection; import java.io.IOException; @@ -32,7 +34,8 @@ public final class QueryReferenceCollector { private record ReferenceState( Map> references, Map lookupClasses, - String activeCacheClass) { + String activeCacheClass, + String previousClass) { } private static final ExpressionWalker.Visitor REFERENCE_VISITOR = @@ -48,6 +51,8 @@ public void visit(Expression expression, ReferenceState state) { state.references, state.lookupClasses.get(lookup.alias), lookup.target); + case PreviousExpression previous -> + addTarget(state.references, state.previousClass, previous.target); case QueryExpression query -> addTarget(state.references, query.clazz, query.target); case Target target -> addTarget(state.references, state.activeCacheClass, target); case TriggerExpression ignored -> { @@ -66,7 +71,11 @@ public ReferenceState stateForChild( case QUERY_FILTER -> ((QueryExpression) parent).clazz; case LEFT, RIGHT, OPERAND -> state.activeCacheClass; }; - return new ReferenceState(state.references, state.lookupClasses, activeCacheClass); + return new ReferenceState( + state.references, + state.lookupClasses, + activeCacheClass, + state.previousClass); } }; @@ -83,12 +92,24 @@ public static Map> collect(List triggers) continue; } Map lookupClasses = collectLookupDefinitions(trigger, references); - collectExpressionReferences(trigger.criteria, references, lookupClasses, null); + String previousClass = trigger.type == StatementTrigger.Type.OBJECT_UPDATE + ? trigger.clazz + : null; + collectExpressionReferences( + trigger.criteria, + references, + lookupClasses, + null, + previousClass); if (trigger.statement == null) { continue; } try { - collectFromNode(MAPPER.readTree(trigger.statement), references, lookupClasses); + collectFromNode( + MAPPER.readTree(trigger.statement), + references, + lookupClasses, + previousClass); } catch (IOException ignored) { // Bad statement JSON is handled by TriggerProcessor at runtime. } @@ -108,7 +129,12 @@ private static Map collectLookupDefinitions( return; } lookupClasses.put(alias, lookup.clazz); - collectExpressionReferences(lookup.criteria, references, lookupClasses, lookup.clazz); + collectExpressionReferences( + lookup.criteria, + references, + lookupClasses, + lookup.clazz, + null); }); return lookupClasses; } @@ -117,41 +143,55 @@ private static void collectExpressionReferences( Expression expression, Map> references, Map lookupClasses, - String activeCacheClass) { + String activeCacheClass, + String previousClass) { ExpressionWalker.walk( expression, - new ReferenceState(references, lookupClasses, activeCacheClass), + new ReferenceState( + references, + lookupClasses, + activeCacheClass, + previousClass), REFERENCE_VISITOR); } private static void collectFromNode( JsonNode node, Map> references, - Map lookupClasses) { + Map lookupClasses, + String previousClass) { if (node == null || node.isNull()) { return; } if (node.isObject()) { for (JsonNode child : node) { - collectFromNode(child, references, lookupClasses); + collectFromNode(child, references, lookupClasses, previousClass); } return; } if (node.isArray()) { ParseResult parsed = StatementInjectionParser.parse(node); if (parsed.valid()) { - collectInjection(parsed.injection(), references, lookupClasses); + collectInjection( + parsed.injection(), + references, + lookupClasses, + previousClass); return; } for (JsonNode child : node) { - collectFromNode(child, references, lookupClasses); + collectFromNode(child, references, lookupClasses, previousClass); } return; } if (node.isTextual()) { for (InlineInjection inline : StatementInjectionParser.findInline(node.asText())) { if (inline.result().valid()) { - collectInjection(inline.result().injection(), references, lookupClasses); + collectInjection( + inline.result().injection(), + references, + lookupClasses, + previousClass); } } } @@ -160,11 +200,14 @@ private static void collectFromNode( private static void collectInjection( StatementInjection injection, Map> references, - Map lookupClasses) { + Map lookupClasses, + String previousClass) { if (injection instanceof QueryInjection queryInjection) { collectQuery(queryInjection, references); } else if (injection instanceof LookupInjection lookupInjection) { collectLookup(lookupInjection, references, lookupClasses); + } else if (injection instanceof PreviousInjection previousInjection) { + addTarget(references, previousClass, previousInjection.target()); } } @@ -182,7 +225,7 @@ private static void collectQueryReference( } addTarget(references, className, target); - collectExpressionReferences(criteria, references, Map.of(), className); + collectExpressionReferences(criteria, references, Map.of(), className, null); } private static void collectLookup( diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java b/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java index de3db93..0910441 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java @@ -162,7 +162,7 @@ public String findCurrentValue() { @Override public String findObjectId() { - return "SELECT id FROM object_instance WHERE object_handle = ?"; + return "SELECT id FROM object_instance WHERE object_handle = ? AND removed_at IS NULL"; } @Override diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java b/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java index 03b24d3..2935002 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java @@ -58,7 +58,7 @@ public XapiConfig parse() { stt.lookups = parseLookups(tnode.get("lookups")); try { stt.criteria = CriteriaExpressionParser.parseNullable(tnode.get("criteria")); - CriteriaExpressionValidator.validateTrigger(stt.criteria, stt.lookups); + CriteriaExpressionValidator.validateTrigger(stt.criteria, stt.lookups, stt.type); } catch (IllegalArgumentException e) { throw new IllegalArgumentException( "statementTriggers[" + triggerIndex + "].criteria: " + e.getMessage(), diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParser.java b/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParser.java index 592ea22..24903d2 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParser.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParser.java @@ -9,6 +9,7 @@ import com.yetanalytics.hlaxapi.config.model.LogicalExpression; import com.yetanalytics.hlaxapi.config.model.LogicalOperator; import com.yetanalytics.hlaxapi.config.model.LookupExpression; +import com.yetanalytics.hlaxapi.config.model.PreviousExpression; import com.yetanalytics.hlaxapi.config.model.QueryExpression; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TriggerExpression; @@ -62,6 +63,10 @@ private static Expression parseInjection(JsonNode node, InjectionType type) { requireArity(node, 2, type); yield new TriggerExpression(parseTarget(node.get(1))); } + case PREVIOUS -> { + requireArity(node, 2, type); + yield new PreviousExpression(parseTarget(node.get(1))); + } case QUERY -> { requireArity(node, 4, type); String className = requireText(node.get(1), "query class"); diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionValidator.java b/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionValidator.java index 309478a..984d90f 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionValidator.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionValidator.java @@ -6,10 +6,12 @@ import com.yetanalytics.hlaxapi.config.model.LogicalExpression; import com.yetanalytics.hlaxapi.config.model.LookupExpression; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.PreviousExpression; import com.yetanalytics.hlaxapi.config.model.QueryExpression; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TriggerExpression; import com.yetanalytics.hlaxapi.config.model.ValueExpression; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import java.util.Map; /** Enforces the value sources permitted by each criteria evaluation context. */ @@ -23,6 +25,7 @@ private enum Context { private record ValidationState( Context context, Map lookupDefinitions, + StatementTrigger.Type triggerType, String location) { } @@ -36,6 +39,7 @@ public void visit(Expression expression, ValidationState state) { case LogicalExpression ignored -> { } case LookupExpression lookup -> validateLookup(lookup, state); + case PreviousExpression previous -> validatePrevious(previous, state); case QueryExpression query -> validateQuery(query, state); case Target target -> validateTarget(target, state); case TriggerExpression ignored -> { @@ -59,7 +63,11 @@ public ValidationState stateForChild( case OPERAND -> state.location + "[" + child.index() + "]"; case QUERY_FILTER -> state.location + ".queryFilter"; }; - return new ValidationState(childContext, state.lookupDefinitions, childLocation); + return new ValidationState( + childContext, + state.lookupDefinitions, + state.triggerType, + childLocation); } }; @@ -67,17 +75,24 @@ private CriteriaExpressionValidator() { } public static void validateTrigger(Expression criteria, Map lookupDefinitions) { + validateTrigger(criteria, lookupDefinitions, StatementTrigger.Type.INTERACTION); + } + + public static void validateTrigger( + Expression criteria, + Map lookupDefinitions, + StatementTrigger.Type triggerType) { Map definitions = lookupDefinitions == null ? Map.of() : lookupDefinitions; ExpressionWalker.walk( criteria, - new ValidationState(Context.TRIGGER, definitions, "criteria"), + new ValidationState(Context.TRIGGER, definitions, triggerType, "criteria"), VALIDATION_VISITOR); } public static void validateCacheFilter(Expression criteria) { ExpressionWalker.walk( criteria, - new ValidationState(Context.CACHE_FILTER, Map.of(), "criteria"), + new ValidationState(Context.CACHE_FILTER, Map.of(), null, "criteria"), VALIDATION_VISITOR); } @@ -95,6 +110,13 @@ private static void validateQuery(QueryExpression query, ValidationState state) } } + private static void validatePrevious(PreviousExpression previous, ValidationState state) { + if (state.context != Context.TRIGGER + || state.triggerType != StatementTrigger.Type.OBJECT_UPDATE) { + throw unsupported(previous, state); + } + } + private static void validateLookup(LookupExpression lookup, ValidationState state) { if (state.context != Context.TRIGGER) { throw unsupported(lookup, state); diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/Expression.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/Expression.java index 98d3d6a..19e6165 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/model/Expression.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/Expression.java @@ -4,6 +4,7 @@ public sealed interface Expression permits Criterion, LogicalExpression, LookupExpression, + PreviousExpression, QueryExpression, Target, TriggerExpression, diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalker.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalker.java index a96f87f..0453ff7 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalker.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalker.java @@ -113,6 +113,7 @@ private static List children(Expression expression) { } case QueryExpression query -> List.of(new Child(query.criteria, ChildRole.QUERY_FILTER, -1)); case LookupExpression ignored -> List.of(); + case PreviousExpression ignored -> List.of(); case Target ignored -> List.of(); case TriggerExpression ignored -> List.of(); case ValueExpression ignored -> List.of(); @@ -125,6 +126,7 @@ private static Expression rebuild(Expression expression, List childr case LogicalExpression logical -> new LogicalExpression(logical.operator, List.copyOf(children)); case QueryExpression query -> new QueryExpression(query.clazz, query.target, children.get(0)); case LookupExpression lookup -> lookup; + case PreviousExpression previous -> previous; case Target target -> target; case TriggerExpression trigger -> trigger; case ValueExpression value -> value; diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/InjectionType.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/InjectionType.java index 5e3fe4b..0395310 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/model/InjectionType.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/InjectionType.java @@ -1,7 +1,7 @@ package com.yetanalytics.hlaxapi.config.model; public enum InjectionType { - TRIGGER("trigger"), QUERY("query"), LOOKUP("lookup"); + TRIGGER("trigger"), PREVIOUS("previous"), QUERY("query"), LOOKUP("lookup"); public final String token; @@ -14,6 +14,7 @@ public static InjectionType fromString(String s) { if (s == null) return null; switch (s.trim().toLowerCase()) { case "trigger": return TRIGGER; + case "previous": return PREVIOUS; case "query": return QUERY; case "lookup": return LOOKUP; default: return null; diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/PreviousExpression.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/PreviousExpression.java new file mode 100644 index 0000000..8a54f6e --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/PreviousExpression.java @@ -0,0 +1,16 @@ +package com.yetanalytics.hlaxapi.config.model; + +/** Reads an object attribute value from the cache before the current reflection. */ +public final class PreviousExpression implements Expression { + + public final Target target; + + public PreviousExpression(Target target) { + this.target = target; + } + + @Override + public String toString() { + return "Previous(" + (target == null ? "null" : target.toString()) + ")"; + } +} diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/InjectionContext.java b/src/main/java/com/yetanalytics/hlaxapi/injection/InjectionContext.java index 21b5df5..2a3fb2e 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/injection/InjectionContext.java +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/InjectionContext.java @@ -1,5 +1,6 @@ package com.yetanalytics.hlaxapi.injection; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import java.util.List; public abstract class InjectionContext { @@ -8,6 +9,7 @@ public abstract class InjectionContext { private List statementPath = List.of(); private boolean embedded = false; private String objectType; + private StatementTrigger.Type triggerType; public String getHlaClass() { return hlaClass; @@ -45,4 +47,12 @@ public String getObjectType() { public void setObjectType(String objectType) { this.objectType = objectType; } + + public StatementTrigger.Type getTriggerType() { + return triggerType; + } + + public void setTriggerType(StatementTrigger.Type triggerType) { + this.triggerType = triggerType; + } } diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java b/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java index 8d36949..55d6449 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java @@ -44,6 +44,11 @@ public static ParseResult parse(JsonNode node) { : ParseResult.valid(new TriggerInjection( CriteriaExpressionParser.parseTarget(node.get(1)), options(node, 2))); + case PREVIOUS -> node.size() < 2 + ? ParseResult.malformed(type) + : ParseResult.valid(new PreviousInjection( + CriteriaExpressionParser.parseTarget(node.get(1)), + options(node, 2))); case QUERY -> node.size() < 4 ? ParseResult.malformed(type) : ParseResult.valid(new QueryInjection( @@ -108,7 +113,7 @@ private static InjectionOptions options(JsonNode node, int index) { } public sealed interface StatementInjection - permits TriggerInjection, QueryInjection, LookupInjection { + permits TriggerInjection, PreviousInjection, QueryInjection, LookupInjection { InjectionType type(); @@ -125,6 +130,14 @@ public InjectionType type() { } } + public record PreviousInjection(Target target, InjectionOptions options) implements StatementInjection { + + @Override + public InjectionType type() { + return InjectionType.PREVIOUS; + } + } + public record QueryInjection( String className, Target target, diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/TestInjectionContext.java b/src/main/java/com/yetanalytics/hlaxapi/injection/TestInjectionContext.java index e98f0f7..9558b62 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/injection/TestInjectionContext.java +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/TestInjectionContext.java @@ -4,25 +4,17 @@ public class TestInjectionContext extends InjectionContext { - private StatementTrigger.Type triggerType = StatementTrigger.Type.INTERACTION; - public TestInjectionContext() { + setTriggerType(StatementTrigger.Type.INTERACTION); } public TestInjectionContext(String hlaClass) { + this(); setHlaClass(hlaClass); } public TestInjectionContext(StatementTrigger.Type triggerType, String hlaClass) { - this.triggerType = triggerType; + setTriggerType(triggerType); setHlaClass(hlaClass); } - - public StatementTrigger.Type getTriggerType() { - return triggerType; - } - - public void setTriggerType(StatementTrigger.Type triggerType) { - this.triggerType = triggerType; - } } diff --git a/src/test/java/com/yetanalytics/ConfigParserTest.java b/src/test/java/com/yetanalytics/ConfigParserTest.java index 71c3828..ad03850 100644 --- a/src/test/java/com/yetanalytics/ConfigParserTest.java +++ b/src/test/java/com/yetanalytics/ConfigParserTest.java @@ -194,6 +194,43 @@ public void parsesQueriesAndLookupsInTriggerCriteria(@TempDir Path tempDir) thro assertTrue(criteria.right instanceof LookupExpression); } + @Test + public void parsesPreviousCriteriaOnlyForObjectUpdate(@TempDir Path tempDir) throws IOException { + Path validPath = tempDir.resolve("valid-previous.json"); + Files.writeString(validPath, """ + { + "statementTriggers": [{ + "type": "ObjectUpdate", + "class": "Rabbit", + "criteria": [["previous", ["Hunger"]], "<", ["trigger", ["Hunger"]]], + "statement": {} + }] + } + """); + + assertNotNull(ConfigParser.fromFile(validPath.toString()).parse() + .statementTriggers.get(0).criteria); + + for (String type : List.of("Interaction", "ObjectCreate", "ObjectDelete")) { + Path invalidPath = tempDir.resolve(type + "-previous.json"); + Files.writeString(invalidPath, """ + { + "statementTriggers": [{ + "type": "%s", + "class": "Rabbit", + "criteria": [["previous", ["Hunger"]], "<", 10], + "statement": {} + }] + } + """.formatted(type)); + + assertThrows( + IllegalArgumentException.class, + () -> ConfigParser.fromFile(invalidPath.toString()).parse(), + type); + } + } + @Test public void rejectsBareEventTargetsInTriggerCriteria(@TempDir Path tempDir) throws IOException { Path configPath = tempDir.resolve("xapi-config.json"); diff --git a/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java index 2f0226d..764538a 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java @@ -121,6 +121,29 @@ void interactionValidationRemainsTheDefault() { assertTrue(result.statement().contains("\"raw\":0.5")); } + @Test + @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) + void validatesPreviousOnlyForObjectUpdateTemplates() { + TriggerProcessor processor = new TriggerProcessor(handler(OBJECT_FOM)); + for (StatementTrigger.Type type : List.of( + StatementTrigger.Type.OBJECT_UPDATE, + StatementTrigger.Type.INTERACTION, + StatementTrigger.Type.OBJECT_CREATE, + StatementTrigger.Type.OBJECT_DELETE)) { + StatementTrigger trigger = trigger(""" + {"oldCount":["previous",["Count"]]} + """); + trigger.type = type; + + TriggerProcessor.TriggerProcessingResult result = + processor.renderTemplateForValidation( + trigger, + new TestInjectionContext(type, "TrackedEntity")); + + assertEquals(type == StatementTrigger.Type.OBJECT_UPDATE, result.success(), type.toString()); + } + } + private InjectionHandler handler(String fomPath) { HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); FOMXML fomXml = new FOMXML( diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java index f9bebca..0f56438 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java @@ -18,8 +18,11 @@ import com.yetanalytics.hlaxapi.config.model.ComparisonOperator; import com.yetanalytics.hlaxapi.config.model.Criterion; import com.yetanalytics.hlaxapi.config.model.LrsConfig; +import com.yetanalytics.hlaxapi.config.model.LogicalExpression; +import com.yetanalytics.hlaxapi.config.model.LogicalOperator; import com.yetanalytics.hlaxapi.config.model.ObjectCacheConfig; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.PreviousExpression; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TrackedObject; @@ -584,6 +587,173 @@ void cachedQueriesAndLookupsRenderBeforeTheReflectionCommitsAndEnqueueAfterItCom } } + @Test + void previousCriteriaDetectChangesAndThresholdCrossings(@TempDir Path tempDir) throws Exception { + StatementTrigger changed = objectUpdateTrigger( + "Rabbit", + """ + {"event":"changed","old":["previous",["Hunger"]],"new":["trigger",["Hunger"]]} + """); + changed.criteria = new Criterion( + new PreviousExpression(new Target(List.of("Hunger"))), + ComparisonOperator.NEQ, + new TriggerExpression(new Target(List.of("Hunger")))); + StatementTrigger crossed = objectUpdateTrigger( + "Rabbit", + """ + {"event":"crossed","old":["previous",["Hunger"]],"new":["trigger",["Hunger"]]} + """); + crossed.criteria = new LogicalExpression( + LogicalOperator.AND, + List.of( + new Criterion( + new PreviousExpression(new Target(List.of("Hunger"))), + ComparisonOperator.LT, + new ValueExpression(20)), + new Criterion( + new TriggerExpression(new Target(List.of("Hunger"))), + ComparisonOperator.GTE, + new ValueExpression(20)))); + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(changed, crossed); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("previous-crossing.sqlite"))) { + RecordingRti rti = new RecordingRti(); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = + hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(106); + AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); + cache.reflectAttributeValue( + rabbit.toString(), + "Rabbit", + "Hunger", + HLAEncodingTestSupport.int32(10, ByteOrder.BIG_ENDIAN)); + + reflect(hlaInterface, rabbit, hunger, 10); + reflect(hlaInterface, rabbit, hunger, 21); + reflect(hlaInterface, rabbit, hunger, 22); + + assertEquals( + List.of( + "{\"event\":\"changed\",\"old\":10,\"new\":21}", + "{\"event\":\"crossed\",\"old\":10,\"new\":21}", + "{\"event\":\"changed\",\"old\":21,\"new\":22}"), + xapiClient.statements); + } + } + + @Test + @SuppressTestLogging({ + "com.yetanalytics.hlaxapi.TriggerProcessor", + "com.yetanalytics.hlaxapi.StatementTriggerDispatcher" + }) + void firstObservationSupportsOptionalPreviousWithoutRetryingRequiredInjections( + @TempDir Path tempDir) throws Exception { + StatementTrigger required = objectUpdateTrigger( + "Rabbit", + """ + {"event":"required","old":["previous",["Hunger"]],"new":["trigger",["Hunger"]]} + """); + StatementTrigger optional = objectUpdateTrigger( + "Rabbit", + """ + { + "event":"optional", + "old":["previous",["Hunger"],{"required":false}], + "new":["trigger",["Hunger"]] + } + """); + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(required, optional); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("previous-first-observation.sqlite"))) { + RecordingRti rti = new RecordingRti(); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = + hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(107); + AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); + hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Previous"); + + reflect(hlaInterface, rabbit, hunger, 5); + reflect(hlaInterface, rabbit, hunger, 6); + + assertEquals( + List.of( + "{\"event\":\"optional\",\"old\":null,\"new\":5}", + "{\"event\":\"required\",\"old\":5,\"new\":6}", + "{\"event\":\"optional\",\"old\":5,\"new\":6}"), + xapiClient.statements); + } + } + + @Test + @SuppressTestLogging({"com.yetanalytics.hlaxapi.HlaInterfaceImpl"}) + void failedReflectionRetainsOnePreviousStateForEveryTrigger(@TempDir Path tempDir) throws Exception { + StatementTrigger first = objectUpdateTrigger( + "Rabbit", + """ + {"trigger":1,"old":["previous",["Hunger"]],"new":["trigger",["Hunger"]]} + """); + StatementTrigger second = objectUpdateTrigger( + "Rabbit", + """ + {"trigger":2,"old":["previous",["Hunger"]],"new":["trigger",["Hunger"]]} + """); + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(first, second); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("previous-rollback.sqlite"))) { + RecordingRti rti = new RecordingRti(); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = + hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(108); + AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); + AttributeHandle unknown = rti.attributeHandle(rabbitClass, "NotInTheFom"); + cache.reflectAttributeValue( + rabbit.toString(), + "Rabbit", + "Hunger", + HLAEncodingTestSupport.int32(5, ByteOrder.BIG_ENDIAN)); + AttributeHandleValueMap failedReflection = new HLA1516eAttributeHandleValueMap(); + failedReflection.put(hunger, HLAEncodingTestSupport.int32(20, ByteOrder.BIG_ENDIAN)); + failedReflection.put(unknown, HLAEncodingTestSupport.int32(1, ByteOrder.BIG_ENDIAN)); + + hlaInterface.reflectAttributeValues(rabbit, failedReflection, null, null, null, null); + + assertEquals(5, cache.findCurrentValue(rabbit.toString(), "Hunger").orElseThrow().value()); + assertTrue(xapiClient.statements.isEmpty()); + + reflect(hlaInterface, rabbit, hunger, 30); + + assertEquals( + List.of( + "{\"trigger\":1,\"old\":5,\"new\":30}", + "{\"trigger\":2,\"old\":5,\"new\":30}"), + xapiClient.statements); + } + } + @Test @SuppressTestLogging({"com.yetanalytics.hlaxapi.HlaInterfaceImpl"}) void cacheFailureSuppressesAllStatementsStagedForTheReflection(@TempDir Path tempDir) throws Exception { diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java index e3bad3a..db339f7 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java @@ -26,6 +26,7 @@ import com.yetanalytics.hlaxapi.config.model.TriggerExpression; import com.yetanalytics.hlaxapi.config.model.ValueExpression; import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext; +import com.yetanalytics.hlaxapi.injection.ObjectInjectionContext; import hla.rti1516e.encoding.DataElement; import hla.rti1516e.encoding.EncoderException; import hla.rti1516e.encoding.EncoderFactory; @@ -482,6 +483,74 @@ void entityAteLookupFindsRabbitThroughSimEntityBaseClass() throws Exception { } } + @Test + void previousResolutionSupportsNestedArraysCachedNullAndMissingValues() throws Exception { + try (ObjectCache cache = newCache( + "previous-resolution", + enabledConfig(), + dynamicArrayCatalog, + dynamicArrayFomXml)) { + cache.reflectAttributeValues( + "rabbit-1", + "Rabbit", + Map.of( + "Position", position(12, 8), + "PositionHistory", positionHistory(position(1, 2), position(3, 4)), + "Hunger", new byte[] {1})); + InjectionHandler injectionHandler = new InjectionHandler(); + injectionHandler.setFomXml(dynamicArrayFomXml); + injectionHandler.setHLADecoderRegistry(decoderRegistry); + injectionHandler.setFomCatalog(dynamicArrayCatalog); + setField(injectionHandler, "objectCache", cache); + ObjectInjectionContext context = + new ObjectInjectionContext("Rabbit", "rabbit-1", Map.of()); + context.setTriggerType(StatementTrigger.Type.OBJECT_UPDATE); + + ValueResolution nested = injectionHandler.handlePrevious( + new Target(List.of("Position", "X")), + context); + ValueResolution array = injectionHandler.handlePrevious( + new Target(List.of("PositionHistory", 1, "Y")), + context); + ValueResolution cachedNull = injectionHandler.handlePrevious( + new Target(List.of("Hunger")), + context); + ValueResolution missing = injectionHandler.handlePrevious( + new Target(List.of("EntityId")), + context); + + assertEquals(ValueResolution.Status.PRESENT, nested.status()); + assertEquals(12, nested.value()); + assertEquals(ValueResolution.Status.PRESENT, array.status()); + assertEquals(4, array.value()); + assertEquals(ValueResolution.Status.PRESENT, cachedNull.status()); + assertNull(cachedNull.value()); + assertEquals(ValueResolution.Status.MISSING_VALUE, missing.status()); + + StatementTrigger nullablePrevious = new StatementTrigger(); + nullablePrevious.type = StatementTrigger.Type.OBJECT_UPDATE; + nullablePrevious.clazz = "Rabbit"; + nullablePrevious.statement = + "{\"oldHunger\":[\"previous\",[\"Hunger\"],{\"nullable\":true}]}"; + TriggerProcessor.TriggerProcessingResult rendered = + new TriggerProcessor(injectionHandler).processTrigger( + nullablePrevious, + context); + + assertTrue(rendered.success()); + assertEquals("{\"oldHunger\":null}", rendered.statement()); + + cache.removeObject("rabbit-1"); + + assertEquals( + ValueResolution.Status.MISSING_VALUE, + injectionHandler.handlePrevious( + new Target(List.of("Position", "X")), + context) + .status()); + } + } + @Test void queryServiceDistinguishesPresentNullFromMissingValue() { try (ObjectCache cache = newCache()) { diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java index 7b1d4fe..1e8352c 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java @@ -89,6 +89,34 @@ void objectUpdateSubscriptionsDoNotEnableCacheAndIncludeInheritedAttributes(@Tem } } + @Test + void objectUpdatePreviousReferencesEnableOnlyTheirCacheAttributes(@TempDir Path tempDir) { + StatementTrigger trigger = objectUpdateTrigger("Rabbit"); + trigger.statement = """ + { + "oldHunger":["previous",["Hunger"]], + "oldX":"<<[\\"previous\\",[\\"Position\\",\\"X\\"]]>>" + } + """; + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(trigger); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("object-update-previous.sqlite"))) { + Set rabbitAttributes = + Set.copyOf(catalog.objectClass("Rabbit").orElseThrow().topLevelAttributeNames()); + + assertTrue(cache.isEnabled()); + assertEquals(Set.of("Hunger", "Position"), cache.cacheSubscriptions().get("Rabbit")); + assertEquals(rabbitAttributes, cache.eventSubscriptions().get("Rabbit")); + assertEquals(rabbitAttributes, cache.subscriptions().get("Rabbit")); + } + } + @Test void objectCreateSubscriptionsDoNotEnableCacheAndIncludeInheritedAttributes(@TempDir Path tempDir) { Path databasePath = tempDir.resolve("object-create-only.sqlite"); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java index 0d0baa0..eafbc8d 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java @@ -10,6 +10,7 @@ import com.yetanalytics.hlaxapi.config.model.LogicalOperator; import com.yetanalytics.hlaxapi.config.model.LookupExpression; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.PreviousExpression; import com.yetanalytics.hlaxapi.config.model.QueryExpression; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TriggerExpression; @@ -106,6 +107,33 @@ void findsCacheReferencesUsedOnlyByTriggerCriteria() { assertFalse(references.get("World").contains("DesiredWorldId")); } + @Test + void findsObjectUpdatePreviousReferencesOnly() { + StatementTrigger update = trigger(""" + { + "oldX":["previous",["Position","X"]], + "description":"old hunger <<[\\"previous\\",[\\"Hunger\\"]]>>" + } + """); + update.type = StatementTrigger.Type.OBJECT_UPDATE; + update.clazz = "Rabbit"; + update.criteria = new Criterion( + new PreviousExpression(new Target(List.of("EntityId"))), + ComparisonOperator.NEQ, + new TriggerExpression(new Target(List.of("EntityId")))); + StatementTrigger create = trigger(""" + {"invalid":["previous",["Hunger"]]} + """); + create.type = StatementTrigger.Type.OBJECT_CREATE; + create.clazz = "Wolf"; + + Map> references = + QueryReferenceCollector.collect(List.of(update, create)); + + assertEquals(Set.of("EntityId", "Position", "Hunger"), references.get("Rabbit")); + assertFalse(references.containsKey("Wolf")); + } + private StatementTrigger trigger(String statement) { StatementTrigger trigger = new StatementTrigger(); trigger.statement = statement; diff --git a/src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java b/src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java index dd5c09c..7246bb5 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java @@ -12,10 +12,12 @@ import com.yetanalytics.hlaxapi.config.model.LogicalExpression; import com.yetanalytics.hlaxapi.config.model.LookupExpression; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.PreviousExpression; import com.yetanalytics.hlaxapi.config.model.QueryExpression; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TriggerExpression; import com.yetanalytics.hlaxapi.config.model.ValueExpression; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -85,6 +87,30 @@ void preservesNullComparisonOperands() throws Exception { assertTrue(right.value == null); } + @Test + void previousCriteriaAreObjectUpdateOnly() throws Exception { + Criterion criterion = assertInstanceOf( + Criterion.class, + CriteriaExpressionParser.parse(MAPPER.readTree(""" + [["previous", ["Hunger"]], "<", ["trigger", ["Hunger"]]] + """))); + + assertInstanceOf(PreviousExpression.class, criterion.left); + assertInstanceOf(TriggerExpression.class, criterion.right); + assertDoesNotThrow(() -> CriteriaExpressionValidator.validateTrigger( + criterion, + Map.of(), + StatementTrigger.Type.OBJECT_UPDATE)); + for (StatementTrigger.Type type : List.of( + StatementTrigger.Type.INTERACTION, + StatementTrigger.Type.OBJECT_CREATE, + StatementTrigger.Type.OBJECT_DELETE)) { + assertThrows( + IllegalArgumentException.class, + () -> CriteriaExpressionValidator.validateTrigger(criterion, Map.of(), type)); + } + } + @Test void triggerValidationAppliesCacheFilterRulesInsideQueries() throws Exception { Expression expression = CriteriaExpressionParser.parse(MAPPER.readTree(""" diff --git a/src/test/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalkerTest.java b/src/test/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalkerTest.java index 2c16114..c2cb959 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalkerTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalkerTest.java @@ -124,6 +124,19 @@ void handlesNullRootsAndRejectsNullRewriteResults() { () -> ExpressionWalker.rewrite(new ValueExpression(true), ignored -> null)); } + @Test + void treatsPreviousAsAValueSourceWithoutWalkingItsTarget() { + Target target = target("Hunger"); + PreviousExpression previous = new PreviousExpression(target); + List visited = new ArrayList<>(); + + ExpressionWalker.walk(previous, visited::add); + + assertEquals(List.of(previous), visited); + assertFalse(visited.contains(target)); + assertSame(previous, ExpressionWalker.rewrite(previous, UnaryOperator.identity())); + } + private static Fixture fixture() { Target triggerTarget = target("Score"); TriggerExpression trigger = new TriggerExpression(triggerTarget); diff --git a/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java b/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java index c015e59..cc66b93 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java @@ -11,6 +11,7 @@ import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.InlineInjection; import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.LookupInjection; import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.ParseResult; +import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.PreviousInjection; import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.QueryInjection; import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.TriggerInjection; import java.util.List; @@ -28,6 +29,13 @@ void parsesTypedWholeNodeInjections() throws Exception { assertEquals(List.of("EntityId"), triggerInjection.target().parts); assertTrue(triggerInjection.options().required()); + ParseResult previousResult = StatementInjectionParser.parse( + MAPPER.readTree("[\"previous\",[\"Hunger\"]]")); + PreviousInjection previousInjection = + assertInstanceOf(PreviousInjection.class, previousResult.injection()); + assertEquals(List.of("Hunger"), previousInjection.target().parts); + assertTrue(previousInjection.options().required()); + ParseResult queryResult = StatementInjectionParser.parse(MAPPER.readTree( "[\"QUERY\",\"Rabbit\",[\"Position\",\"X\"],[[\"Hunger\"],\">\",50]]")); QueryInjection query = assertInstanceOf(QueryInjection.class, queryResult.injection()); @@ -49,6 +57,8 @@ void parsesTypedWholeNodeInjections() throws Exception { void parsesOptionalInjectionsForEveryType() throws Exception { TriggerInjection triggerInjection = assertInstanceOf(TriggerInjection.class, StatementInjectionParser.parse( MAPPER.readTree("[\"trigger\",[\"EntityId\"],{\"required\":false}]")).injection()); + PreviousInjection previousInjection = assertInstanceOf(PreviousInjection.class, StatementInjectionParser.parse( + MAPPER.readTree("[\"previous\",[\"Hunger\"],{\"required\":false}]")).injection()); QueryInjection queryInjection = assertInstanceOf(QueryInjection.class, StatementInjectionParser.parse( MAPPER.readTree( "[\"query\",\"Rabbit\",[\"EntityId\"],[[\"Hunger\"],\">\",50],{\"required\":false}]")).injection()); @@ -57,6 +67,7 @@ void parsesOptionalInjectionsForEveryType() throws Exception { "[\"lookup\",\"predator\",[\"EntityType\"],{\"nullable\":true,\"required\":false}]")).injection()); assertFalse(triggerInjection.options().required()); + assertFalse(previousInjection.options().required()); assertFalse(queryInjection.options().required()); assertFalse(lookupInjection.options().required()); assertTrue(lookupInjection.options().nullable()); @@ -65,15 +76,17 @@ void parsesOptionalInjectionsForEveryType() throws Exception { @Test void findsInlineInjectionsAndPreservesNonInjectionCandidates() { String text = "before <<[\"trigger\",[\"EntityId\"]]>> and " + + "<<[\"previous\",[\"Hunger\"]]>> and " + "<<[\"lookup\",\"predator\",[\"EntityType\"]]>> after <>"; List inline = StatementInjectionParser.findInline(text); - assertEquals(3, inline.size()); + assertEquals(4, inline.size()); assertInstanceOf(TriggerInjection.class, inline.get(0).result().injection()); - assertInstanceOf(LookupInjection.class, inline.get(1).result().injection()); - assertFalse(inline.get(2).result().recognized()); - assertEquals("<>", inline.get(2).source()); + assertInstanceOf(PreviousInjection.class, inline.get(1).result().injection()); + assertInstanceOf(LookupInjection.class, inline.get(2).result().injection()); + assertFalse(inline.get(3).result().recognized()); + assertEquals("<>", inline.get(3).source()); assertEquals("before ", text.substring(0, inline.get(0).start())); } @@ -93,6 +106,7 @@ void distinguishesMalformedKnownTagsFromUnknownArrays() throws Exception { void marksKnownInjectionsWithInvalidTargetsAsMalformed() throws Exception { for (String source : List.of( "[\"trigger\",[]]", + "[\"previous\",[]]", "[\"trigger\",[\"PositionHistory\",-1]]", "[\"query\",\"Rabbit\",\"EntityId\",null]", "[\"lookup\",\"predator\",[\"EntityId\",{}]]")) { From 72319a651861c4f1b40e34b68a1ff2a48e01577b Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 12:21:14 -0400 Subject: [PATCH 12/36] use previous in demo config --- config/xapi-config.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/config/xapi-config.json b/config/xapi-config.json index 5bd2acc..24ce6d0 100644 --- a/config/xapi-config.json +++ b/config/xapi-config.json @@ -67,10 +67,8 @@ "context": { "extensions": { "https://hla-federepl.example/extensions/previous-step-number": [ - "query", - "World", + "previous", ["StepNumber"], - null, {"required": false} ], "https://hla-federepl.example/extensions/current-step-number": [ From de77cdf0f7edd6d248bc1f0e51fda55f3f103958 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 15:13:39 -0400 Subject: [PATCH 13/36] increased validation to ensure previous only used in correct cases --- .../hlaxapi/InjectionHandler.java | 190 ++++++++++++++++-- .../hlaxapi/LazyLookupContext.java | 4 +- .../hlaxapi/TriggerProcessor.java | 3 + .../hlaxapi/ObjectInjectionHandlerTest.java | 104 ++++++++++ .../hlaxapi/TriggerProcessorCriteriaTest.java | 8 + 5 files changed, 293 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java index 44ae0c7..cabacb7 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -18,7 +18,10 @@ import com.yetanalytics.hlaxapi.cache.ValueResolution; import com.yetanalytics.hlaxapi.config.model.Expression; import com.yetanalytics.hlaxapi.config.model.ExpressionWalker; +import com.yetanalytics.hlaxapi.config.model.LookupExpression; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.PreviousExpression; +import com.yetanalytics.hlaxapi.config.model.QueryExpression; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TriggerExpression; @@ -70,13 +73,37 @@ public ValueResolution handleTrigger(Target t, InjectionContext context) { } public ValueResolution handleTrigger(Target t, TestInjectionContext context) { - EventTargetDefinition target = targetDefinition( + EventTargetDefinition target = requireEventTargetDefinition( context.getHlaClass(), t, - context.getTriggerType() != null && context.getTriggerType().isObjectEvent()); + context.getTriggerType() != null && context.getTriggerType().isObjectEvent(), + "trigger"); + return testValue(target, t, context); + } + + public void validateCriteriaSources( + StatementTrigger trigger, + TestInjectionContext context) { + Map lookups = + trigger.lookups == null ? Map.of() : trigger.lookups; + validateExpressionSources( + trigger.criteria, + new ValidationSource(context, lookups, null)); + lookups.forEach((alias, lookup) -> { + ObjectLookup definition = requireLookupClass(alias, lookup); + validateExpressionSources( + definition.criteria, + new ValidationSource(context, lookups, definition.clazz)); + }); + } + + private ValueResolution testValue( + EventTargetDefinition target, + Target injectionTarget, + TestInjectionContext context) { Class hlaJavaType = target.exists() ? hlaDecoderRegistry.getClassForType(target.primitiveType()) : null; - Object result = XapiValueGenerator.getTestValue(context, t, hlaJavaType); + Object result = XapiValueGenerator.getTestValue(context, injectionTarget, hlaJavaType); return ValueResolution.present(result); } @@ -126,6 +153,40 @@ private EventTargetDefinition targetDefinition( : interactionTargetDefinition(hlaClass, target); } + private EventTargetDefinition requireEventTargetDefinition( + String hlaClass, + Target target, + boolean objectEvent, + String source) { + EventTargetDefinition definition = targetDefinition(hlaClass, target, objectEvent); + if (!definition.exists()) { + throw missingTarget(source, hlaClass, target); + } + return definition; + } + + private EventTargetDefinition requireObjectTargetDefinition( + String hlaClass, + Target target, + String source) { + EventTargetDefinition definition = objectTargetDefinition(hlaClass, target); + if (!definition.exists()) { + throw missingTarget(source, hlaClass, target); + } + return definition; + } + + private IllegalArgumentException missingTarget( + String source, + String hlaClass, + Target target) { + return new IllegalArgumentException( + source + " target " + + (target == null ? "" : target.parts) + + " does not exist on FOM class " + + hlaClass); + } + private EventTargetDefinition interactionTargetDefinition(String hlaClass, Target target) { PathCheckResult path = fomXml.checkInteractionParameterPath(hlaClass, target.parts); String topLevelType = null; @@ -305,7 +366,11 @@ public ValueResolution handlePrevious(Target target, InjectionContext context) { "previous values are only available to ObjectUpdate triggers"); } if (context instanceof TestInjectionContext testContext) { - return handleTrigger(target, testContext); + EventTargetDefinition definition = requireObjectTargetDefinition( + testContext.getHlaClass(), + target, + "previous"); + return testValue(definition, target, testContext); } if (!(context instanceof ObjectInjectionContext objectContext)) { throw new IllegalArgumentException( @@ -326,11 +391,13 @@ public ValueResolution handleQuery( InjectionContext context) { // Validation Test-Injection - if (context instanceof TestInjectionContext){ - PathCheckResult pcr = fomXml.checkInteractionParameterPath(context.getHlaClass(), attrTarget.parts); - Class hlaJavaType = (pcr.exists) ? hlaDecoderRegistry.getClassForType(pcr.primitiveType) : null; - Object result = XapiValueGenerator.getTestValue(context, attrTarget, hlaJavaType); - return ValueResolution.present(result); + if (context instanceof TestInjectionContext testContext) { + EventTargetDefinition target = + requireObjectTargetDefinition(clazz, attrTarget, "query"); + validateExpressionSources( + criteria, + new ValidationSource(testContext, Map.of(), clazz)); + return testValue(target, attrTarget, testContext); } if (objectCache == null) { @@ -352,11 +419,9 @@ public Optional resolveLookup(ObjectLookup lookup, InjectionContex public ValueResolution handleLookup(CachedObject object, Target attrTarget, InjectionContext context) { // Validation Test-Injection - if (context instanceof TestInjectionContext){ - PathCheckResult pcr = fomXml.checkInteractionParameterPath(context.getHlaClass(), attrTarget.parts); - Class hlaJavaType = (pcr.exists) ? hlaDecoderRegistry.getClassForType(pcr.primitiveType) : null; - Object result = XapiValueGenerator.getTestValue(context, attrTarget, hlaJavaType); - return ValueResolution.present(result); + if (context instanceof TestInjectionContext) { + throw new IllegalArgumentException( + "lookup validation requires its lookup definition"); } if (objectCache == null || object == null) { @@ -365,6 +430,97 @@ public ValueResolution handleLookup(CachedObject object, Target attrTarget, Inje return objectCache.findValueResolution(object, attrTarget); } + public ValueResolution handleLookup( + String alias, + ObjectLookup lookup, + Target attrTarget, + TestInjectionContext context) { + requireLookupClass(alias, lookup); + EventTargetDefinition target = + requireObjectTargetDefinition(lookup.clazz, attrTarget, "lookup(" + alias + ")"); + return testValue(target, attrTarget, context); + } + + private void validateExpressionSources( + Expression expression, + ValidationSource initialState) { + ExpressionWalker.walk( + expression, + initialState, + new ExpressionWalker.Visitor<>() { + @Override + public void visit(Expression candidate, ValidationSource state) { + if (candidate instanceof TriggerExpression trigger) { + TestInjectionContext event = state.eventContext(); + requireEventTargetDefinition( + event.getHlaClass(), + trigger.target, + event.getTriggerType() != null + && event.getTriggerType().isObjectEvent(), + "trigger"); + } else if (candidate instanceof PreviousExpression previous) { + TestInjectionContext event = state.eventContext(); + if (event.getTriggerType() != StatementTrigger.Type.OBJECT_UPDATE) { + throw new IllegalArgumentException( + "previous values are only available to ObjectUpdate triggers"); + } + requireObjectTargetDefinition( + event.getHlaClass(), + previous.target, + "previous"); + } else if (candidate instanceof QueryExpression query) { + requireObjectTargetDefinition( + query.clazz, + query.target, + "query"); + } else if (candidate instanceof LookupExpression lookup) { + ObjectLookup definition = + requireLookupClass(lookup.alias, state.lookups().get(lookup.alias)); + requireObjectTargetDefinition( + definition.clazz, + lookup.target, + "lookup(" + lookup.alias + ")"); + } else if (candidate instanceof Target target) { + if (state.cacheClass() == null) { + throw new IllegalArgumentException( + "bare target " + target.parts + + " is not scoped to a cache class"); + } + requireObjectTargetDefinition( + state.cacheClass(), + target, + "cache"); + } + } + + @Override + public ValidationSource stateForChild( + Expression parent, + ExpressionWalker.Child child, + ValidationSource state) { + String cacheClass = child.role() == ExpressionWalker.ChildRole.QUERY_FILTER + ? ((QueryExpression) parent).clazz + : state.cacheClass(); + return new ValidationSource( + state.eventContext(), + state.lookups(), + cacheClass); + } + }); + } + + private ObjectLookup requireLookupClass(String alias, ObjectLookup lookup) { + if (lookup == null || lookup.clazz == null || lookup.clazz.isBlank()) { + throw new IllegalArgumentException( + "lookup alias '" + alias + "' does not define an object class"); + } + if (fomCatalog.objectClass(lookup.clazz).isEmpty()) { + throw new IllegalArgumentException( + "lookup alias '" + alias + "' references unknown FOM class " + lookup.clazz); + } + return lookup; + } + private Expression resolveTriggerExpressions(Expression expression, InjectionContext context) { if (expression == null || context == null) { return expression; @@ -404,4 +560,10 @@ private static EventTargetDefinition missing() { return new EventTargetDefinition(false, null, null); } } + + private record ValidationSource( + TestInjectionContext eventContext, + Map lookups, + String cacheClass) { + } } diff --git a/src/main/java/com/yetanalytics/hlaxapi/LazyLookupContext.java b/src/main/java/com/yetanalytics/hlaxapi/LazyLookupContext.java index b339d3b..dc1c625 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/LazyLookupContext.java +++ b/src/main/java/com/yetanalytics/hlaxapi/LazyLookupContext.java @@ -28,8 +28,8 @@ final class LazyLookupContext { } ValueResolution value(String alias, Target target) { - if (injectionContext instanceof TestInjectionContext) { - return handler.handleLookup(null, target, injectionContext); + if (injectionContext instanceof TestInjectionContext testContext) { + return handler.handleLookup(alias, definitions.get(alias), target, testContext); } return handler.handleLookup(object(alias), target, injectionContext); } diff --git a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java index c0b6009..106fb50 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java +++ b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java @@ -80,6 +80,9 @@ private TriggerProcessingResult processTrigger( StatementTrigger.Type previousTriggerType = context.getTriggerType(); context.setTriggerType(trigger.type); try { + if (!evaluateCriteria && context instanceof TestInjectionContext testContext) { + injectionHandler.validateCriteriaSources(trigger, testContext); + } LazyLookupContext lookups = new LazyLookupContext(injectionHandler, context, trigger.lookups); if (evaluateCriteria && !new TriggerCriteriaMatcher(injectionHandler).matches(trigger.criteria, context, lookups)) { diff --git a/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java index 764538a..23272aa 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java @@ -8,8 +8,15 @@ import com.yetanalytics.extension.SuppressTestLogging; import com.yetanalytics.hlaxapi.cache.FomCatalog; import com.yetanalytics.hlaxapi.cache.ValueResolution; +import com.yetanalytics.hlaxapi.config.model.ComparisonOperator; +import com.yetanalytics.hlaxapi.config.model.Criterion; +import com.yetanalytics.hlaxapi.config.model.Expression; +import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.PreviousExpression; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; +import com.yetanalytics.hlaxapi.config.model.TriggerExpression; +import com.yetanalytics.hlaxapi.config.model.ValueExpression; import com.yetanalytics.hlaxapi.injection.ObjectInjectionContext; import com.yetanalytics.hlaxapi.injection.TestInjectionContext; import java.nio.ByteOrder; @@ -107,18 +114,99 @@ void validatesEveryObjectEventTargetAgainstInheritedObjectAttributes() { } @Test + @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) + void rejectsMissingObjectTargetsInStatementsAndCriteriaEvenWhenOptional() { + TriggerProcessor processor = new TriggerProcessor(handler(OBJECT_FOM)); + TestInjectionContext context = + new TestInjectionContext(StatementTrigger.Type.OBJECT_UPDATE, "TrackedEntity"); + StatementTrigger missingTrigger = trigger(""" + {"missing":["trigger",["NotAnAttribute"],{"required":false}]} + """); + StatementTrigger missingPrevious = trigger(""" + {"missing":["previous",["NotAnAttribute"],{"required":false}]} + """); + StatementTrigger missingTriggerCriterion = trigger("{}", new Criterion( + new TriggerExpression(target("NotAnAttribute")), + ComparisonOperator.EQ, + new ValueExpression(1))); + StatementTrigger missingPreviousCriterion = trigger("{}", new Criterion( + new PreviousExpression(target("NotAnAttribute")), + ComparisonOperator.EQ, + new ValueExpression(1))); + + assertFalse(processor.renderTemplateForValidation(missingTrigger, context).success()); + assertFalse(processor.renderTemplateForValidation(missingPrevious, context).success()); + assertFalse(processor.renderTemplateForValidation(missingTriggerCriterion, context).success()); + assertFalse(processor.renderTemplateForValidation(missingPreviousCriterion, context).success()); + } + + @Test + @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) + void validatesQueryAndLookupPathsAgainstTheirReferencedObjectClasses() { + TriggerProcessor processor = new TriggerProcessor(handler(OBJECT_FOM)); + TestInjectionContext context = + new TestInjectionContext(StatementTrigger.Type.OBJECT_UPDATE, "TrackedEntity"); + StatementTrigger valid = trigger(""" + { + "result":{"score":{"raw":["query","BaseEntity",["Position","X"],null]}}, + "lookup":["lookup","base",["EntityId"]] + } + """); + valid.lookups = Map.of("base", lookup("BaseEntity", new Criterion( + target("Position", "Y"), + ComparisonOperator.GT, + new ValueExpression(0)))); + StatementTrigger wrongQueryDatatype = trigger(""" + {"result":{"score":{"raw":["query","BaseEntity",["EntityId"],null]}}} + """); + StatementTrigger missingQueryTarget = trigger(""" + {"value":["query","BaseEntity",["NotAnAttribute"],null,{"required":false}]} + """); + StatementTrigger missingQueryCriterion = trigger(""" + {"value":["query","BaseEntity",["EntityId"],[["NotAnAttribute"],"=",1]]} + """); + StatementTrigger missingLookupTarget = trigger(""" + {"value":["lookup","base",["NotAnAttribute"],{"required":false}]} + """); + missingLookupTarget.lookups = Map.of("base", lookup("BaseEntity", null)); + StatementTrigger missingLookupCriterion = trigger("{}"); + missingLookupCriterion.lookups = Map.of("base", lookup("BaseEntity", new Criterion( + target("NotAnAttribute"), + ComparisonOperator.EQ, + new ValueExpression(1)))); + + assertTrue(processor.renderTemplateForValidation(valid, context).success()); + assertFalse(processor.renderTemplateForValidation(wrongQueryDatatype, context).success()); + assertFalse(processor.renderTemplateForValidation(missingQueryTarget, context).success()); + assertFalse(processor.renderTemplateForValidation(missingQueryCriterion, context).success()); + assertFalse(processor.renderTemplateForValidation(missingLookupTarget, context).success()); + assertFalse(processor.renderTemplateForValidation(missingLookupCriterion, context).success()); + } + + @Test + @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) void interactionValidationRemainsTheDefault() { TriggerProcessor processor = new TriggerProcessor(handler(SIMULATION_FOM)); StatementTrigger interaction = trigger(""" {"result":{"score":{"raw":["trigger",["StepNumber"]]}}} """); + interaction.type = StatementTrigger.Type.INTERACTION; + interaction.clazz = "StepCompleted"; TriggerProcessor.TriggerProcessingResult result = processor.renderTemplateForValidation( interaction, new TestInjectionContext("StepCompleted")); + StatementTrigger missing = trigger(""" + {"missing":["trigger",["NotAParameter"],{"required":false}]} + """); + missing.type = StatementTrigger.Type.INTERACTION; + missing.clazz = "StepCompleted"; assertTrue(result.success()); assertTrue(result.statement().contains("\"raw\":0.5")); + assertFalse(processor.renderTemplateForValidation( + missing, + new TestInjectionContext("StepCompleted")).success()); } @Test @@ -157,13 +245,29 @@ private InjectionHandler handler(String fomPath) { } private StatementTrigger trigger(String statement) { + return trigger(statement, null); + } + + private StatementTrigger trigger( + String statement, + Expression criteria) { StatementTrigger trigger = new StatementTrigger(); trigger.type = StatementTrigger.Type.OBJECT_UPDATE; trigger.clazz = "TrackedEntity"; + trigger.criteria = criteria; trigger.statement = statement; return trigger; } + private ObjectLookup lookup( + String className, + Expression criteria) { + ObjectLookup lookup = new ObjectLookup(); + lookup.clazz = className; + lookup.criteria = criteria; + return lookup; + } + private Target target(Object... parts) { return new Target(List.of(parts)); } diff --git a/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java index 3a400e6..f5b928c 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java @@ -165,6 +165,14 @@ void criteriaFailuresAreReportedAndValidationRenderingDoesNotEvaluateCriteria() public ValueResolution handleTrigger(Target target, InjectionContext context) { throw new IllegalStateException("cannot decode event value"); } + + @Override + public void validateCriteriaSources( + StatementTrigger trigger, + TestInjectionContext context) { + // This test isolates runtime criteria evaluation from structural + // FOM validation. + } }; StatementTrigger trigger = trigger( new TriggerExpression(target("Broken")), From 8f801a8a23348d0279d29c90d7d366b0aaab823c Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 15:28:20 -0400 Subject: [PATCH 14/36] don't emit statements for empty update callbacks --- .../hlaxapi/HlaInterfaceImpl.java | 4 ++ .../cache/HlaObjectSubscriptionTest.java | 66 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index f0e4e84..e376268 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -374,6 +374,10 @@ private void reflectAttributeValues(ObjectInstanceHandle theObject, AttributeHan String attributeName = ambassador.getAttributeName(classHandle, attributeHandle); attributes.put(attributeName, theAttributes.get(attributeHandle)); } + if (attributes.isEmpty()) { + logger.debug("Ignoring empty reflection for object {}", theObject); + return; + } ObjectInjectionContext context = new ObjectInjectionContext(className, theObject.toString(), attributes); boolean createPending = className.equals(pendingObjectCreates.get(theObject.toString())); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java index 0f56438..1687f80 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java @@ -147,6 +147,50 @@ void firstReflectionDispatchesObjectCreateAndObjectUpdateThenOnlyUpdates() throw } } + @Test + void emptyReflectionDoesNotDispatchCacheOrConsumePendingCreate() throws Exception { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of( + objectTrigger( + StatementTrigger.Type.OBJECT_CREATE, + "Rabbit", + "{\"event\":\"create\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_UPDATE, + "Rabbit", + "{\"event\":\"update\"}")); + + try (RecordingReflectionCache cache = + new RecordingReflectionCache(config, catalog, fomXml, decoderRegistry)) { + RecordingRti rti = new RecordingRti(); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = + hlaInterface(cache, rti.proxy(), config, xapiClient); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(106); + AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); + + hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Empty"); + hlaInterface.reflectAttributeValues( + rabbit, + new HLA1516eAttributeHandleValueMap(), + null, + null, + null, + null); + + assertTrue(xapiClient.statements.isEmpty()); + assertEquals(0, cache.reflectionCalls); + + reflect(hlaInterface, rabbit, hunger, 12); + + assertEquals(1, cache.reflectionCalls); + assertEquals( + List.of("{\"event\":\"create\"}", "{\"event\":\"update\"}"), + xapiClient.statements); + } + } + @Test @SuppressTestLogging({"com.yetanalytics.hlaxapi.HlaInterfaceImpl"}) void failedCacheProcessingRetainsPendingCreateForTheNextReflection() throws Exception { @@ -980,6 +1024,28 @@ public synchronized void reflectAttributeValues( } } + private static final class RecordingReflectionCache extends ObjectCache { + + private int reflectionCalls; + + private RecordingReflectionCache( + XapiConfig config, + FomCatalog catalog, + FOMXML fomXml, + HLADecoderRegistry decoderRegistry) { + super(config, catalog, fomXml, decoderRegistry); + } + + @Override + public synchronized void reflectAttributeValues( + String objectHandle, + String className, + Map attributes) { + reflectionCalls++; + super.reflectAttributeValues(objectHandle, className, attributes); + } + } + private static final class FailingRemovalCache extends ObjectCache { private FailingRemovalCache( From ceae791e48e77333b3319cceab88569a44991e7f Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 15:43:47 -0400 Subject: [PATCH 15/36] object subscription plan --- .../hlaxapi/cache/ObjectSubscriptionPlan.java | 227 ++++++++++++++++++ .../cache/ObjectSubscriptionPlanTest.java | 56 +++++ 2 files changed, 283 insertions(+) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlan.java create mode 100644 src/test/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlanTest.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlan.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlan.java new file mode 100644 index 0000000..7426d08 --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlan.java @@ -0,0 +1,227 @@ +package com.yetanalytics.hlaxapi.cache; + +import com.yetanalytics.hlaxapi.config.XapiConfig; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import com.yetanalytics.hlaxapi.config.model.TrackedObject; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** Immutable cache and event subscription requirements derived from configuration. */ +final class ObjectSubscriptionPlan { + + private final FomCatalog catalog; + private final Map> cacheSubscriptions; + private final Map> eventSubscriptions; + private final Map> subscriptions; + + private ObjectSubscriptionPlan( + FomCatalog catalog, + Map> cacheSubscriptions, + Map> eventSubscriptions) { + this.catalog = catalog; + this.cacheSubscriptions = copySubscriptions(cacheSubscriptions); + this.eventSubscriptions = copySubscriptions(eventSubscriptions); + this.subscriptions = mergeSubscriptions( + this.cacheSubscriptions, + this.eventSubscriptions); + } + + static ObjectSubscriptionPlan from(XapiConfig xapiConfig, FomCatalog catalog) { + Objects.requireNonNull(xapiConfig, "xapiConfig"); + Objects.requireNonNull(catalog, "catalog"); + Map> cacheSubscriptions = + collectCacheSubscriptions(xapiConfig, catalog); + Map> eventSubscriptions = + collectEventSubscriptions(xapiConfig, catalog); + return new ObjectSubscriptionPlan(catalog, cacheSubscriptions, eventSubscriptions); + } + + Map> cacheSubscriptions() { + return cacheSubscriptions; + } + + Map> eventSubscriptions() { + return eventSubscriptions; + } + + Map> subscriptions() { + return subscriptions; + } + + boolean requiresCache() { + return !cacheSubscriptions.isEmpty(); + } + + boolean hasSubscriptions() { + return !subscriptions.isEmpty(); + } + + /** + * Returns the union of subscriptions configured for an object class and each + * of its FOM ancestors. + */ + Set effectiveAttributes(String className) { + LinkedHashSet attributes = new LinkedHashSet<>(); + FomCatalog.ObjectClassDef current = catalog.objectClass(className).orElse(null); + if (current == null) { + addAttributes(attributes, subscriptions.get(FomCatalog.localName(className))); + return Set.copyOf(attributes); + } + while (current != null) { + addAttributes(attributes, subscriptions.get(current.localName())); + current = catalog.objectClass(current.parentName()).orElse(null); + } + return Set.copyOf(attributes); + } + + private static Map> collectCacheSubscriptions( + XapiConfig xapiConfig, + FomCatalog catalog) { + Map> merged = new LinkedHashMap<>(); + QueryReferenceCollector.collect(xapiConfig.statementTriggers) + .forEach((className, attributes) -> + addAttributes(merged, className, attributes)); + addObjectDeleteTriggers(merged, xapiConfig, catalog); + addTrackedObjects(merged, xapiConfig, catalog); + return merged; + } + + private static Map> collectEventSubscriptions( + XapiConfig xapiConfig, + FomCatalog catalog) { + Map> events = new LinkedHashMap<>(); + if (xapiConfig.statementTriggers == null) { + return events; + } + for (StatementTrigger trigger : xapiConfig.statementTriggers) { + if (trigger == null + || trigger.type == null + || !trigger.type.isObjectEvent() + || trigger.clazz == null + || trigger.clazz.isBlank()) { + continue; + } + Optional clazz = catalog.objectClass(trigger.clazz); + if (clazz.isPresent()) { + FomCatalog.ObjectClassDef objectClass = clazz.orElseThrow(); + addAttributes(events, objectClass.localName(), objectClass.topLevelAttributeNames()); + } else { + addAttributes(events, trigger.clazz, Set.of("*")); + } + } + return events; + } + + private static void addObjectDeleteTriggers( + Map> merged, + XapiConfig xapiConfig, + FomCatalog catalog) { + if (xapiConfig.statementTriggers == null) { + return; + } + for (StatementTrigger trigger : xapiConfig.statementTriggers) { + if (trigger == null + || trigger.type != StatementTrigger.Type.OBJECT_DELETE + || trigger.clazz == null + || trigger.clazz.isBlank()) { + continue; + } + catalog.objectClass(trigger.clazz).ifPresent(clazz -> + addAttributes(merged, clazz.localName(), clazz.topLevelAttributeNames())); + } + } + + private static void addTrackedObjects( + Map> merged, + XapiConfig xapiConfig, + FomCatalog catalog) { + if (xapiConfig.objectCacheConfig == null + || xapiConfig.objectCacheConfig.trackedObjects == null) { + return; + } + for (TrackedObject trackedObject : xapiConfig.objectCacheConfig.trackedObjects) { + if (trackedObject == null + || trackedObject.clazz == null + || trackedObject.clazz.isBlank()) { + continue; + } + if ("*".equals(trackedObject.clazz.trim())) { + if (trackedObject.allAttributes) { + catalog.objectClasses().forEach(clazz -> + addAttributes( + merged, + clazz.localName(), + clazz.topLevelAttributeNames())); + } + continue; + } + if (trackedObject.allAttributes) { + Optional clazz = + catalog.objectClass(trackedObject.clazz); + if (clazz.isPresent()) { + FomCatalog.ObjectClassDef objectClass = clazz.orElseThrow(); + addAttributes( + merged, + objectClass.localName(), + objectClass.topLevelAttributeNames()); + } else { + addAttributes(merged, trackedObject.clazz, Set.of("*")); + } + } else { + String className = catalog.objectClass(trackedObject.clazz) + .map(FomCatalog.ObjectClassDef::localName) + .orElse(trackedObject.clazz); + addAttributes(merged, className, trackedObject.attributes); + } + } + } + + @SafeVarargs + private static Map> mergeSubscriptions( + Map>... plans) { + Map> merged = new LinkedHashMap<>(); + for (Map> plan : plans) { + plan.forEach((className, attributes) -> + addAttributes(merged, className, attributes)); + } + return copySubscriptions(merged); + } + + private static void addAttributes( + Map> subscriptions, + String className, + Iterable attributes) { + if (className == null || className.isBlank() || attributes == null) { + return; + } + Set targetAttributes = + subscriptions.computeIfAbsent(className, ignored -> new LinkedHashSet<>()); + addAttributes(targetAttributes, attributes); + if (targetAttributes.isEmpty()) { + subscriptions.remove(className); + } + } + + private static void addAttributes(Set target, Iterable attributes) { + if (attributes == null) { + return; + } + for (String attribute : attributes) { + if (attribute != null && !attribute.isBlank()) { + target.add(attribute); + } + } + } + + private static Map> copySubscriptions( + Map> subscriptions) { + Map> copy = new LinkedHashMap<>(); + subscriptions.forEach((className, attributes) -> + copy.put(className, Set.copyOf(attributes))); + return Map.copyOf(copy); + } +} diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlanTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlanTest.java new file mode 100644 index 0000000..dbf7936 --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlanTest.java @@ -0,0 +1,56 @@ +package com.yetanalytics.hlaxapi.cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.yetanalytics.hlaxapi.FOMXML; +import com.yetanalytics.hlaxapi.HLADecoderRegistry; +import com.yetanalytics.hlaxapi.SimulationConfig; +import com.yetanalytics.hlaxapi.config.XapiConfig; +import com.yetanalytics.hlaxapi.config.model.ObjectCacheConfig; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import com.yetanalytics.hlaxapi.config.model.TrackedObject; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; + +class ObjectSubscriptionPlanTest { + + private final FOMXML fomXml = new FOMXML( + new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"), + new HLADecoderRegistry(new HLA1516eEncoderFactory())); + private final FomCatalog catalog = new FomCatalog(fomXml); + + @Test + void combinesDirectAndAncestorRequirementsWithoutMutatingThePlan() { + StatementTrigger ancestorQuery = new StatementTrigger(); + ancestorQuery.statement = """ + {"name":["query","SimEntity",["FirstName"],null]} + """; + TrackedObject trackedRabbit = new TrackedObject(); + trackedRabbit.clazz = "Rabbit"; + trackedRabbit.attributes = List.of("Hunger"); + ObjectCacheConfig cacheConfig = new ObjectCacheConfig(); + cacheConfig.trackedObjects = List.of(trackedRabbit); + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(ancestorQuery); + config.objectCacheConfig = cacheConfig; + + ObjectSubscriptionPlan plan = ObjectSubscriptionPlan.from(config, catalog); + + assertEquals(Set.of("FirstName"), plan.cacheSubscriptions().get("SimEntity")); + assertEquals(Set.of("Hunger"), plan.cacheSubscriptions().get("Rabbit")); + assertTrue(plan.eventSubscriptions().isEmpty()); + assertEquals(Set.of("FirstName"), plan.effectiveAttributes("SimEntity")); + assertEquals(Set.of("FirstName", "Hunger"), plan.effectiveAttributes("Rabbit")); + assertEquals(Set.of("FirstName"), plan.effectiveAttributes("Wolf")); + assertThrows( + UnsupportedOperationException.class, + () -> plan.subscriptions().put("Wolf", Set.of("Hunger"))); + assertThrows( + UnsupportedOperationException.class, + () -> plan.subscriptions().get("Rabbit").add("EntityId")); + } +} From 05e8c0300b9c07ec098d4fc679b2b892bdb2a23d Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 15:44:36 -0400 Subject: [PATCH 16/36] use object subscription plan and combine ancestor subscriptions --- .../hlaxapi/HlaInterfaceImpl.java | 5 +- .../hlaxapi/cache/ObjectCache.java | 137 ++---------------- .../cache/HlaObjectSubscriptionTest.java | 35 +++++ 3 files changed, 49 insertions(+), 128 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index e376268..c6e8b29 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -275,8 +275,9 @@ public void discoverObjectInstance( logger.error("Error resolving discovered object {}", objectName, e); return; } - Set subscribedAttributes = objectCache.subscriptions().get(className); - if (subscribedAttributes == null || subscribedAttributes.isEmpty()) { + Set subscribedAttributes = + objectCache.effectiveSubscriptionAttributes(className); + if (subscribedAttributes.isEmpty()) { return; } if (hasObjectCreateTrigger(className)) { diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java index 2150e1f..4384e45 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java @@ -4,14 +4,10 @@ import com.yetanalytics.hlaxapi.HLADecoderRegistry; import com.yetanalytics.hlaxapi.config.XapiConfig; import com.yetanalytics.hlaxapi.config.model.Expression; -import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; -import com.yetanalytics.hlaxapi.config.model.TrackedObject; import java.sql.Connection; import java.time.Instant; import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -22,9 +18,7 @@ public class ObjectCache implements AutoCloseable { private final FomCatalog catalog; - private final Map> cacheSubscriptions; - private final Map> eventSubscriptions; - private final Map> subscriptions; + private final ObjectSubscriptionPlan subscriptionPlan; private final HlaValueFlattener valueFlattener; private final CacheQueryService queryService; private final AtomicLong sequence = new AtomicLong(); @@ -56,12 +50,10 @@ public ObjectCache(XapiConfig xapiConfig, FomCatalog catalog, FOMXML fomXml, HLA HLADecoderRegistry decoderRegistry, ObjectCacheConnectionSettings settings) { this.catalog = Objects.requireNonNull(catalog, "catalog"); - this.cacheSubscriptions = collectCacheSubscriptions(xapiConfig); - this.eventSubscriptions = collectEventSubscriptions(xapiConfig); - this.subscriptions = mergeSubscriptions(cacheSubscriptions, eventSubscriptions); + this.subscriptionPlan = ObjectSubscriptionPlan.from(xapiConfig, catalog); this.valueFlattener = new HlaValueFlattener(fomXml, decoderRegistry); this.queryService = new CacheQueryService(this); - if (!cacheSubscriptions.isEmpty()) { + if (subscriptionPlan.requiresCache()) { ObjectCacheConnectionSettings effectiveSettings = settings == null ? ObjectCacheConnectionSettings.from(System.getenv()) : settings; @@ -74,19 +66,23 @@ public boolean isEnabled() { } public Map> subscriptions() { - return subscriptions; + return subscriptionPlan.subscriptions(); } public Map> cacheSubscriptions() { - return cacheSubscriptions; + return subscriptionPlan.cacheSubscriptions(); } public Map> eventSubscriptions() { - return eventSubscriptions; + return subscriptionPlan.eventSubscriptions(); } public boolean hasSubscriptions() { - return !subscriptions.isEmpty(); + return subscriptionPlan.hasSubscriptions(); + } + + public Set effectiveSubscriptionAttributes(String className) { + return subscriptionPlan.effectiveAttributes(className); } public FomCatalog catalog() { @@ -253,115 +249,4 @@ private FomCatalog.ObjectClassDef requireClass(String className) { return catalog.objectClass(className) .orElseThrow(() -> new IllegalArgumentException("No FOM object class " + className)); } - - private Map> collectCacheSubscriptions(XapiConfig xapiConfig) { - Map> merged = new LinkedHashMap<>(); - QueryReferenceCollector.collect(xapiConfig.statementTriggers) - .forEach((className, attributes) -> addAttributes(merged, className, attributes)); - addObjectDeleteTriggers(merged, xapiConfig); - addTrackedObjects(merged, xapiConfig); - return copySubscriptions(merged); - } - - private Map> collectEventSubscriptions(XapiConfig xapiConfig) { - Map> events = new LinkedHashMap<>(); - if (xapiConfig.statementTriggers == null) { - return Map.of(); - } - for (StatementTrigger trigger : xapiConfig.statementTriggers) { - if (trigger == null - || trigger.type == null - || !trigger.type.isObjectEvent() - || trigger.clazz == null - || trigger.clazz.isBlank()) { - continue; - } - Optional clazz = catalog.objectClass(trigger.clazz); - if (clazz.isPresent()) { - FomCatalog.ObjectClassDef objectClass = clazz.orElseThrow(); - addAttributes(events, objectClass.localName(), objectClass.topLevelAttributeNames()); - } else { - addAttributes(events, trigger.clazz, Set.of("*")); - } - } - return copySubscriptions(events); - } - - private void addObjectDeleteTriggers(Map> merged, XapiConfig xapiConfig) { - if (xapiConfig.statementTriggers == null) { - return; - } - for (StatementTrigger trigger : xapiConfig.statementTriggers) { - if (trigger == null - || trigger.type != StatementTrigger.Type.OBJECT_DELETE - || trigger.clazz == null - || trigger.clazz.isBlank()) { - continue; - } - catalog.objectClass(trigger.clazz).ifPresent(clazz -> - addAttributes(merged, clazz.localName(), clazz.topLevelAttributeNames())); - } - } - - @SafeVarargs - private final Map> mergeSubscriptions(Map>... plans) { - Map> merged = new LinkedHashMap<>(); - for (Map> plan : plans) { - plan.forEach((className, attributes) -> addAttributes(merged, className, attributes)); - } - return copySubscriptions(merged); - } - - private void addTrackedObjects(Map> merged, XapiConfig xapiConfig) { - if (xapiConfig.objectCacheConfig == null || xapiConfig.objectCacheConfig.trackedObjects == null) { - return; - } - for (TrackedObject trackedObject : xapiConfig.objectCacheConfig.trackedObjects) { - if (trackedObject == null || trackedObject.clazz == null || trackedObject.clazz.isBlank()) { - continue; - } - if ("*".equals(trackedObject.clazz.trim())) { - if (trackedObject.allAttributes) { - catalog.objectClasses().forEach(clazz -> - addAttributes(merged, clazz.localName(), clazz.topLevelAttributeNames())); - } - continue; - } - if (trackedObject.allAttributes) { - Optional clazz = catalog.objectClass(trackedObject.clazz); - if (clazz.isPresent()) { - FomCatalog.ObjectClassDef objectClass = clazz.orElseThrow(); - addAttributes(merged, objectClass.localName(), objectClass.topLevelAttributeNames()); - } else { - addAttributes(merged, trackedObject.clazz, Set.of("*")); - } - } else { - String className = catalog.objectClass(trackedObject.clazz) - .map(FomCatalog.ObjectClassDef::localName) - .orElse(trackedObject.clazz); - addAttributes(merged, className, trackedObject.attributes); - } - } - } - - private void addAttributes(Map> subscriptions, String className, Iterable attributes) { - if (className == null || className.isBlank() || attributes == null) { - return; - } - Set targetAttributes = subscriptions.computeIfAbsent(className, ignored -> new LinkedHashSet<>()); - for (String attribute : attributes) { - if (attribute != null && !attribute.isBlank()) { - targetAttributes.add(attribute); - } - } - if (targetAttributes.isEmpty()) { - subscriptions.remove(className); - } - } - - private Map> copySubscriptions(Map> subscriptions) { - Map> copy = new LinkedHashMap<>(); - subscriptions.forEach((className, attributes) -> copy.put(className, Set.copyOf(attributes))); - return Map.copyOf(copy); - } } diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java index 1687f80..bec7880 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java @@ -860,6 +860,41 @@ void discoveryCachesMetadataAndRequestsMergedAttributes(@TempDir Path tempDir) t } } + @Test + void discoveryRequestsTheUnionOfChildAndAncestorSubscriptions(@TempDir Path tempDir) throws Exception { + StatementTrigger simEntityQuery = new StatementTrigger(); + simEntityQuery.statement = """ + {"name":["query","SimEntity",["FirstName"],null]} + """; + TrackedObject trackedRabbit = new TrackedObject(); + trackedRabbit.clazz = "Rabbit"; + trackedRabbit.attributes = List.of("Hunger"); + ObjectCacheConfig objectCacheConfig = new ObjectCacheConfig(); + objectCacheConfig.trackedObjects = List.of(trackedRabbit); + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(simEntityQuery); + config.objectCacheConfig = objectCacheConfig; + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("inherited-discovery.sqlite"))) { + RecordingRti rti = new RecordingRti(); + HlaInterfaceImpl hlaInterface = + hlaInterface(cache, rti.proxy(), config, new RecordingXapiClient()); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(107); + + hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Inherited"); + + assertEquals(Set.of("FirstName"), cache.subscriptions().get("SimEntity")); + assertEquals(Set.of("Hunger"), cache.subscriptions().get("Rabbit")); + assertEquals(Set.of("FirstName", "Hunger"), rti.requests.get(0).attributes()); + } + } + @Test @SuppressTestLogging({"com.yetanalytics.hlaxapi.HlaInterfaceImpl"}) void unknownObjectUpdateClassIsSkippedDuringSubscription() throws Exception { From de1e79fb454dfe1feef7c9fde9872e4ff3783e5b Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 15:50:52 -0400 Subject: [PATCH 17/36] allow optional malformed arrays and fixedrecords to return null --- .../hlaxapi/InjectionHandler.java | 19 ++++++-- .../hlaxapi/ObjectInjectionHandlerTest.java | 45 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java index cabacb7..49c3ca4 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -298,8 +298,16 @@ private byte[] extractBytesForPath(String currentType, List remainingPat } private byte[] extractArrayElementBytes(String elementType, int index, byte[] bytes) { + if (bytes.length < Integer.BYTES) { + logger.warn("Array value is too short to contain an element count"); + return null; + } ByteWrapper wrapper = new ByteWrapper(bytes); int count = wrapper.getInt(); + if (count < 0) { + logger.warn("Array value contains a negative element count: {}", count); + return null; + } if (index >= count) { return null; } @@ -309,7 +317,8 @@ private byte[] extractArrayElementBytes(String elementType, int index, byte[] by try { element.decode(wrapper); } catch (DecoderException e) { - throw new IllegalStateException("Failed to decode array element of type " + elementType, e); + logger.warn("Problem decoding array element of type {}", elementType, e); + return null; } if (i == index) { try { @@ -337,8 +346,12 @@ private byte[] extractFixedRecordFieldBytes(String recordType, String fieldName, try { element.decode(wrapper); } catch (DecoderException e) { - throw new IllegalStateException( - "Failed to decode fixed record field " + field.name + " for record " + recordType, e); + logger.warn( + "Problem decoding fixed record field {} for record {}", + field.name, + recordType, + e); + return null; } if (field.name.equals(fieldName)) { try { diff --git a/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java index 23272aa..3a4d47a 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java @@ -84,6 +84,33 @@ void reportsAbsentAndMalformedObjectAttributesAsMissingValues() { assertEquals(ValueResolution.Status.MISSING_VALUE, malformed.status()); } + @Test + @SuppressTestLogging({ + "com.yetanalytics.hlaxapi.InjectionHandler", + "com.yetanalytics.hlaxapi.TriggerProcessor" + }) + void optionalMalformedNestedValuesRenderNullForUpdateAndDelete() { + TriggerProcessor processor = new TriggerProcessor(handler(OBJECT_FOM)); + Map malformedAttributes = Map.of( + "Position", new byte[] {1}, + "PositionHistory", new byte[] {0, 0, 0, 1, 1}); + + for (StatementTrigger.Type type : List.of( + StatementTrigger.Type.OBJECT_UPDATE, + StatementTrigger.Type.OBJECT_DELETE)) { + assertOptionalMalformedValue( + processor, + type, + "[\"Position\",\"X\"]", + malformedAttributes); + assertOptionalMalformedValue( + processor, + type, + "[\"PositionHistory\",0,\"X\"]", + malformedAttributes); + } + } + @Test @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) void validatesEveryObjectEventTargetAgainstInheritedObjectAttributes() { @@ -268,6 +295,24 @@ private ObjectLookup lookup( return lookup; } + private void assertOptionalMalformedValue( + TriggerProcessor processor, + StatementTrigger.Type type, + String target, + Map attributes) { + StatementTrigger trigger = trigger(""" + {"value":["trigger",%s,{"required":false}]} + """.formatted(target)); + trigger.type = type; + + TriggerProcessor.TriggerProcessingResult result = processor.processTrigger( + trigger, + new ObjectInjectionContext("TrackedEntity", "object-17", attributes)); + + assertTrue(result.success(), type + " " + target); + assertEquals("{\"value\":null}", result.statement(), type + " " + target); + } + private Target target(Object... parts) { return new Target(List.of(parts)); } From de642fdffe6151feec883b2c2a5416b739d53836 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 16:04:15 -0400 Subject: [PATCH 18/36] docs --- README.md | 2 +- doc/xapi-config.md | 278 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 268 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 62719cd..64e921d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## HLA xAPI Adapter Federate -An HLA federate capable of converting HLA RTI events (interactions and object updates) into xAPI Statements and storing them in a Learning Record Store. +An HLA federate capable of converting HLA RTI interactions and object lifecycle events into xAPI Statements and storing them in a Learning Record Store. ### Configuration diff --git a/doc/xapi-config.md b/doc/xapi-config.md index a9fed0a..12183b2 100644 --- a/doc/xapi-config.md +++ b/doc/xapi-config.md @@ -26,7 +26,11 @@ At the top level the file supports: { "type": "Interaction", "class": "EntityAte", - "criteria": [["PredatorId"], "!=", ["PreyId"]], + "criteria": [ + ["trigger", ["PredatorId"]], + "!=", + ["trigger", ["PreyId"]] + ], "lookups": { "predator": { "class": "SimEntity", @@ -51,14 +55,41 @@ At the top level the file supports: Fields: -- `type`: Type of trigger. `Interaction` is currently wired into RTI subscriptions and statement processing. `ObjectUpdate` is parsed by the config model but object updates currently feed the object cache rather than firing statement triggers directly. -- `class`: The local HLA interaction class name. For interactions this is matched against the final segment of the RTI interaction class name. +- `type`: One of `Interaction`, `ObjectCreate`, `ObjectUpdate`, or `ObjectDelete`. +- `class`: Local HLA interaction or object class name. Matching is exact: an object trigger for `SimEntity` does not also fire for a reflection reported as `Rabbit`. - `criteria`: Optional expression evaluated before the statement template is processed. A non-matching trigger is skipped without producing an xAPI statement. A trigger without criteria always matches. - `lookups`: Optional named cache lookups loaded on first use. A lookup result, including a missing result, is reused for the rest of that trigger attempt. - `statement`: An xAPI statement template. Any JSON object accepted by the xAPI spec can be used here, with injection expressions inserted where dynamic values are needed. - `skipValidation`: Optional flag to skip boot validation for xAPI statement template and injections. **NOTE: This may result in invalid statements being sent to LRS!** Only use if startup is throwing unnecessary validation errors for your template. If you encounter validation issues that you believe to be in error, please report them in a Github Issue. -If multiple interaction triggers match the same interaction class, each trigger is processed and each resulting statement is queued for the LRS. +Every matching trigger is processed once for an eligible callback. One trigger failing its criteria, injection rendering, or enqueue does not prevent other matching triggers from being processed. + +### Event types and payloads + +| Type | Eligible RTI callback | Meaning of `trigger` | +| --- | --- | --- | +| `Interaction` | Every received interaction of the exact class | Parameters in that interaction | +| `ObjectCreate` | First successfully processed non-empty reflection after discovery | Attributes in that one reflection | +| `ObjectUpdate` | Every successfully processed non-empty reflection | Attributes in that one reflection | +| `ObjectDelete` | First removal of a known active object | Last cached attributes for the object | + +Object event triggers subscribe their configured class to all top-level FOM attributes, including inherited attributes. These event subscriptions are merged with attributes required by queries, lookups, `previous`, and `objectCache.trackedObjects`. + +`ObjectCreate` means first observed by this adapter, not necessarily created in the federation at that moment. It also fires for pre-existing objects discovered after a late join and can fire again after the adapter restarts. Discovery marks an object as pending creation and requests its subscribed values; the trigger waits for the first non-empty reflection. That reflection is independently eligible for both `ObjectCreate` and `ObjectUpdate`. + +Create payloads are not aggregated across callbacks. If the first reflection contains only `EntityId`, another attribute arriving in a later reflection is missing from the Create payload. A successful first reflection consumes the pending-create marker even when a particular Create trigger is skipped by criteria or a required injection. A cache failure retains the marker for the next successful reflection. Removal before the first reflection clears it without emitting Create. + +`ObjectDelete` reports that an object disappeared, not why it disappeared. It uses the final state retained by this adapter and therefore always activates the object cache. An object removed after discovery but before receiving attributes can still produce a static Delete statement; required missing values suppress a statement and optional values render `null`. Unknown, already removed, or duplicate removals are skipped. + +Discovery and removal can race. If the object disappears before the adapter's bootstrap `requestAttributeValueUpdate` reaches the RTI, `ObjectInstanceNotKnown` is treated as expected and logged at debug level. Cached discovery metadata remains available to the removal callback. + +### Object event ordering + +For Create and Update, matching statements are rendered against the incoming reflection and the pre-reflection cache. The adapter then commits the complete reflection as one cache transaction and enqueues staged statements only after a successful commit. If caching fails, no statements from that reflection are enqueued. An LRS enqueue failure does not roll back an already committed reflection. + +For Delete, statements and their queries/lookups are rendered while the object is still current. The adapter then marks the object removed and enqueues only after that mutation succeeds. + +This ordering relies on the RTI delivering callbacks serially, as Portico's immediate callback dispatcher currently does. The adapter does not promise pre-update snapshot semantics if callbacks are invoked concurrently. ## Targets @@ -89,7 +120,7 @@ Supported comparison operators: - `<=` - `>=` -The left or right side may be a target, primitive value, nested criterion, or an expression that reads from `trigger`, `query`, or `lookup`. +The left or right side may be a target, primitive value, nested criterion, or an expression that reads from `trigger`, `previous`, `query`, or `lookup`. ```json [["Hunger"], ">", 50] @@ -113,7 +144,7 @@ In cache queries, `=` compares numbers numerically when both sides are numeric; ### Trigger criteria -Statement-trigger criteria require an explicit value source. Use `trigger` to read the incoming event, `query` to read the first matching cached object, or `lookup` to read a named lookup. Bare targets remain reserved for the cached object being tested inside query and lookup filters. +Statement-trigger criteria require an explicit value source. Use `trigger` to read the current event, `previous` to read pre-reflection state in an ObjectUpdate trigger, `query` to read the first matching cached object, or `lookup` to read a named lookup. Bare targets remain reserved for the cached object being tested inside query and lookup filters. ```json { @@ -141,7 +172,17 @@ A query is also a value expression in trigger criteria: ] ``` -Query and lookup filters may contain cached targets, literals, nested comparisons/logical expressions, and `trigger` expressions. Nested queries and lookups are not allowed in cache filters. Injection rendering options such as `required` and `nullable` do not apply inside criteria. +ObjectUpdate change detection can compare the incoming and prior values directly: + +```json +[ + ["previous", ["Hunger"]], + ">", + ["trigger", ["Hunger"]] +] +``` + +`previous` is rejected in Interaction, ObjectCreate, and ObjectDelete triggers. Query and lookup filters may contain cached targets, literals, nested comparisons/logical expressions, and `trigger` expressions. Nested queries and lookups are not allowed in cache filters. Injection rendering options such as `required` and `nullable` do not apply inside criteria. Logical expressions short-circuit. A missing query object, lookup object, or target value resolves to `null` for comparison purposes. Other resolution errors fail trigger processing rather than being treated as a non-match. @@ -187,7 +228,22 @@ A failed required injection aborts the statement, and the trigger returns no xAP ["trigger", ["FromPosition", "X"]] ``` -For interaction triggers, `trigger` reads the interaction parameter map and decodes the value using the FOM. It supports top-level parameters, fixed-record fields, and array elements. Object-update `trigger` contexts are present in the codebase but currently return a placeholder value rather than decoded object attributes. +For Interaction triggers, `trigger` reads the interaction parameter map. For ObjectCreate and ObjectUpdate, it reads only attributes present in the current reflection payload, never an older cached value. For ObjectDelete, it reads the final cached attributes from the removal snapshot. + +All event contexts use the FOM to decode primitive values, fixed-record fields, and array elements. An absent attribute, out-of-range array element, cached null, or malformed HLA value follows the normal missing/null handling described above. + +### `previous` + +`previous` reads the value cached for the same object handle before the current ObjectUpdate reflection is committed. + +```json +["previous", ["Hunger"]] +["previous", ["Position", "X"], {"required": false}] +``` + +It supports primitive, fixed-record, and array paths. On the first observation, or when that attribute has not previously been reflected, it resolves as a missing value. Use `{"required": false}` to render `null` on that first observation. A cached null is distinct from a missing value and can be accepted with `{"nullable": true}`. + +Any `previous` reference activates the object cache and subscribes the referenced top-level attribute. All matching triggers in one reflection see the same pre-reflection state. ### `query` @@ -204,7 +260,7 @@ Arguments: - Criteria: expression evaluated against cached values for each current object. - Options: optional `{"required": false}` and/or `{"nullable": true}`. -Queries use the adapter's current object cache, not arbitrary SQL provided in the config. Removed objects are excluded. If more than one object matches, the first cached object is used. +Queries use the adapter's current object cache, not arbitrary SQL provided in the config. A class query includes active instances of that class and its FOM descendants. Removed objects are excluded. If more than one object matches, the first cached object is used. `trigger` may be used inside query criteria. It is resolved from the triggering event before the cache query runs: @@ -260,13 +316,17 @@ The alias must exist in the trigger's `lookups` map. An alias is resolved only w ## Object Cache -The object cache stores the latest reflected values for subscribed HLA object attributes in SQLite or PostgreSQL. It is enabled when either: +The object cache stores the latest reflected values for subscribed HLA object attributes in SQLite or PostgreSQL. It is enabled when any of these are configured: +- an ObjectUpdate trigger uses `previous`, - a statement template or trigger criterion contains a `query`, - a trigger defines `lookups` or uses `lookup` expressions that reference cached object attributes, or +- an ObjectDelete trigger exists for a known FOM class, or - `objectCache.trackedObjects` explicitly requests tracked attributes. -When enabled, the adapter subscribes to the top-level object attributes required by query targets, query criteria, lookup targets, lookup criteria, and explicit tracked objects. Use the `trackedObjects` array to force cacheing of simulation objects: +Incoming-only ObjectCreate and ObjectUpdate triggers do not enable SQL on their own. They still create event subscriptions for all inherited top-level attributes. + +When enabled, the adapter subscribes to the top-level object attributes required by `previous`, query targets, query criteria, lookup targets, lookup criteria, ObjectDelete snapshots, and explicit tracked objects. Requirements configured on an ancestor and a discovered child are combined for the bootstrap attribute request. Use the `trackedObjects` array to force caching of simulation objects: ```json { @@ -291,6 +351,8 @@ Backend and connection settings are runtime configuration and cannot be set in t The cache decodes reflected values using the FOM and stores both top-level values and flattened nested values for fixed records and arrays. For example, reflecting `Position` can make `Position`, `Position.X`, and `Position.Y` available to query and lookup targets. +One RTI reflection is one cache transaction: every reflected attribute is replaced with a shared observation timestamp and sequence, or the transaction rolls back without changing any of them. A partial reflection updates only the attributes it contains; it does not erase other cached attributes. Empty reflections are ignored. + ### SQLite By default SQLite uses `hla-object-cache.sqlite` in the working directory. It can be changed with: @@ -329,6 +391,200 @@ The PostgreSQL account must be able to create and use the configured schema and Both backends start fresh on initialization. The cache drops and recreates only its five owned tables, then seeds the current FOM metadata. PostgreSQL does not drop the configured schema or any unrelated tables in it. +## Object Lifecycle Recipes + +These examples use attributes from `config/HlaFedereplFOM.xml`. The full sample configuration in `config/xapi-config.json` also includes Interaction, ObjectUpdate, ObjectCreate, and ObjectDelete statements. + +### Rabbit first observed + +This statement represents a rabbit first observed by this adapter. It may represent a birth, an object that existed before a late join, or an object rediscovered after restart. + +```json +{ + "type": "ObjectCreate", + "class": "Rabbit", + "statement": { + "actor": { + "objectType": "Agent", + "account": { + "homePage": "https://hla-federepl.example/adapters", + "name": "lifecycle-monitor" + } + }, + "verb": { + "id": "https://hla-federepl.example/verbs/appeared", + "display": {"en-US": "appeared"} + }, + "object": { + "objectType": "Activity", + "id": "https://hla-federepl.example/simulation/entities/rabbit" + }, + "context": { + "extensions": { + "https://hla-federepl.example/extensions/entity-id": [ + "trigger", + ["EntityId"], + {"required": false} + ], + "https://hla-federepl.example/extensions/hunger": [ + "trigger", + ["Hunger"], + {"required": false} + ] + } + } + } +} +``` + +Because Create uses only the first reflection, either optional value can be `null` even if it arrives in a later callback. + +### Carrot reaches an age + +This ObjectUpdate statement fires only when a carrot crosses age 10. It does not fire repeatedly while the carrot remains older than 10. + +```json +{ + "type": "ObjectUpdate", + "class": "Carrot", + "criteria": [ + [ + ["previous", ["Age"]], + "<", + 10 + ], + "and", + [ + ["trigger", ["Age"]], + ">=", + 10 + ] + ], + "statement": { + "actor": { + "objectType": "Agent", + "account": { + "homePage": "https://hla-federepl.example/adapters", + "name": "lifecycle-monitor" + } + }, + "verb": { + "id": "https://hla-federepl.example/verbs/aged", + "display": {"en-US": "aged"} + }, + "object": { + "objectType": "Activity", + "id": "https://hla-federepl.example/simulation/entities/carrot" + }, + "context": { + "extensions": { + "https://hla-federepl.example/extensions/previous-age": [ + "previous", + ["Age"] + ], + "https://hla-federepl.example/extensions/current-age": [ + "trigger", + ["Age"] + ] + } + } + } +} +``` + +The first Age reflection has no `previous` value, so the ordered criterion is false and no statement is emitted. + +### Wolf becomes more full + +In this FOM, Hunger is the number of steps since the wolf last ate. A decrease therefore represents feeding: + +```json +{ + "type": "ObjectUpdate", + "class": "Wolf", + "criteria": [ + ["previous", ["Hunger"]], + ">", + ["trigger", ["Hunger"]] + ], + "statement": { + "actor": { + "objectType": "Agent", + "account": { + "homePage": "https://hla-federepl.example/adapters", + "name": "lifecycle-monitor" + } + }, + "verb": { + "id": "https://hla-federepl.example/verbs/fed", + "display": {"en-US": "fed"} + }, + "object": { + "objectType": "Activity", + "id": "https://hla-federepl.example/simulation/entities/wolf" + }, + "context": { + "extensions": { + "https://hla-federepl.example/extensions/previous-hunger": [ + "previous", + ["Hunger"] + ], + "https://hla-federepl.example/extensions/current-hunger": [ + "trigger", + ["Hunger"] + ] + } + } + } +} +``` + +A starvation threshold uses the same crossing pattern as carrot age: require `previous Hunger < threshold` and incoming `Hunger >= threshold`. That can describe the simulation's starvation condition. ObjectDelete alone cannot establish starvation as the cause. + +### Wolf disappears + +This Delete statement reports the wolf's final observed state: + +```json +{ + "type": "ObjectDelete", + "class": "Wolf", + "statement": { + "actor": { + "objectType": "Agent", + "account": { + "homePage": "https://hla-federepl.example/adapters", + "name": "lifecycle-monitor" + } + }, + "verb": { + "id": "https://hla-federepl.example/verbs/disappeared", + "display": {"en-US": "disappeared"} + }, + "object": { + "objectType": "Activity", + "id": "https://hla-federepl.example/simulation/entities/wolf" + }, + "context": { + "extensions": { + "https://hla-federepl.example/extensions/entity-id": [ + "trigger", + ["EntityId"], + {"required": false} + ], + "https://hla-federepl.example/extensions/final-hunger": [ + "trigger", + ["Hunger"], + {"required": false} + ] + } + } + } +} +``` + +This means only that the wolf disappeared from the adapter's known object set. Pair it with a threshold-crossing ObjectUpdate statement if the semantic cause matters. + ## LRS Configuration The `lrs` section configures the xAPI client. From 1e19f9ff1441c702231c93c95e56c7955691d293 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 16:11:18 -0400 Subject: [PATCH 19/36] redundant anno --- .../hlaxapi/StatementTriggerDispatcher.java | 2 - .../ObjectTriggerSpringWiringTest.java | 129 ++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/yetanalytics/hlaxapi/ObjectTriggerSpringWiringTest.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java b/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java index 152f898..a3409a1 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java +++ b/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java @@ -10,7 +10,6 @@ import java.util.function.Consumer; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component @@ -21,7 +20,6 @@ public class StatementTriggerDispatcher { private final XapiConfig xapiConfig; private final TriggerProcessor triggerProcessor; - @Autowired public StatementTriggerDispatcher(XapiConfig xapiConfig, TriggerProcessor triggerProcessor) { this.xapiConfig = xapiConfig; this.triggerProcessor = triggerProcessor; diff --git a/src/test/java/com/yetanalytics/hlaxapi/ObjectTriggerSpringWiringTest.java b/src/test/java/com/yetanalytics/hlaxapi/ObjectTriggerSpringWiringTest.java new file mode 100644 index 0000000..c0350a2 --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/ObjectTriggerSpringWiringTest.java @@ -0,0 +1,129 @@ +package com.yetanalytics.hlaxapi; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; + +import com.yetanalytics.hlaxapi.cache.FomCatalog; +import com.yetanalytics.hlaxapi.cache.ObjectCache; +import com.yetanalytics.hlaxapi.config.XapiConfig; +import com.yetanalytics.hlaxapi.config.model.LrsConfig; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import com.yetanalytics.hlaxapi.injection.ObjectInjectionContext; +import com.yetanalytics.xapi.util.StatementValidator; +import hla.rti1516e.encoding.EncoderFactory; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +class ObjectTriggerSpringWiringTest { + + @Test + void springConstructsTheObjectTriggerBeanGraph() { + try (AnnotationConfigApplicationContext context = + new AnnotationConfigApplicationContext()) { + context.register( + TestDependencies.class, + FomCatalog.class, + InjectionHandler.class, + TriggerProcessor.class, + StatementTriggerDispatcher.class, + XapiClient.class, + HlaInterfaceImpl.class); + context.refresh(); + + ObjectCache cache = context.getBean(ObjectCache.class); + InjectionHandler injectionHandler = context.getBean(InjectionHandler.class); + StatementTriggerDispatcher dispatcher = + context.getBean(StatementTriggerDispatcher.class); + + assertSame(cache, injectionHandler.objectCache()); + assertSame(context.getBean(FomCatalog.class), cache.catalog()); + assertSame( + context.getBean(HlaInterfaceImpl.class), + context.getBean(HlaInterface.class)); + assertFalse(cache.isEnabled()); + assertEquals( + 1, + dispatcher.stage( + StatementTrigger.Type.OBJECT_UPDATE, + "Rabbit", + new ObjectInjectionContext( + "Rabbit", + "object-1", + Map.of())) + .size()); + } + } + + @Configuration(proxyBeanMethods = false) + static class TestDependencies { + + @Bean + EncoderFactory encoderFactory() { + return new HLA1516eEncoderFactory(); + } + + @Bean + HLADecoderRegistry decoderRegistry(EncoderFactory encoderFactory) { + return new HLADecoderRegistry(encoderFactory); + } + + @Bean + SimulationConfig simulationConfig() { + return new SimulationConfig( + null, + null, + null, + null, + "config/HlaFedereplFOM.xml"); + } + + @Bean + FOMXML fomXml( + SimulationConfig simulationConfig, + HLADecoderRegistry decoderRegistry) { + return new FOMXML(simulationConfig, decoderRegistry); + } + + @Bean + XapiConfig xapiConfig() { + StatementTrigger trigger = new StatementTrigger(); + trigger.type = StatementTrigger.Type.OBJECT_UPDATE; + trigger.clazz = "Rabbit"; + trigger.statement = "{}"; + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(trigger); + LrsConfig lrsConfig = new LrsConfig(); + lrsConfig.host = "http://localhost:8080/xapi"; + lrsConfig.key = "test"; + lrsConfig.secret = "test"; + lrsConfig.batch = 10; + lrsConfig.maxRetries = 0; + config.lrsConfig = lrsConfig; + return config; + } + + @Bean + StatementValidator statementValidator() { + return new StatementValidator(); + } + + @Bean(destroyMethod = "close") + ObjectCache objectCache( + XapiConfig xapiConfig, + FomCatalog fomCatalog, + FOMXML fomXml, + HLADecoderRegistry decoderRegistry) { + return new ObjectCache( + xapiConfig, + fomCatalog, + fomXml, + decoderRegistry); + } + } +} From 4ed623ccfeaa10a2fa2262fb23a189dccf73acf4 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 16:11:52 -0400 Subject: [PATCH 20/36] remove silly test --- .../ObjectTriggerSpringWiringTest.java | 129 ------------------ 1 file changed, 129 deletions(-) delete mode 100644 src/test/java/com/yetanalytics/hlaxapi/ObjectTriggerSpringWiringTest.java diff --git a/src/test/java/com/yetanalytics/hlaxapi/ObjectTriggerSpringWiringTest.java b/src/test/java/com/yetanalytics/hlaxapi/ObjectTriggerSpringWiringTest.java deleted file mode 100644 index c0350a2..0000000 --- a/src/test/java/com/yetanalytics/hlaxapi/ObjectTriggerSpringWiringTest.java +++ /dev/null @@ -1,129 +0,0 @@ -package com.yetanalytics.hlaxapi; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertSame; - -import com.yetanalytics.hlaxapi.cache.FomCatalog; -import com.yetanalytics.hlaxapi.cache.ObjectCache; -import com.yetanalytics.hlaxapi.config.XapiConfig; -import com.yetanalytics.hlaxapi.config.model.LrsConfig; -import com.yetanalytics.hlaxapi.config.model.StatementTrigger; -import com.yetanalytics.hlaxapi.injection.ObjectInjectionContext; -import com.yetanalytics.xapi.util.StatementValidator; -import hla.rti1516e.encoding.EncoderFactory; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; -import org.springframework.context.annotation.AnnotationConfigApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -class ObjectTriggerSpringWiringTest { - - @Test - void springConstructsTheObjectTriggerBeanGraph() { - try (AnnotationConfigApplicationContext context = - new AnnotationConfigApplicationContext()) { - context.register( - TestDependencies.class, - FomCatalog.class, - InjectionHandler.class, - TriggerProcessor.class, - StatementTriggerDispatcher.class, - XapiClient.class, - HlaInterfaceImpl.class); - context.refresh(); - - ObjectCache cache = context.getBean(ObjectCache.class); - InjectionHandler injectionHandler = context.getBean(InjectionHandler.class); - StatementTriggerDispatcher dispatcher = - context.getBean(StatementTriggerDispatcher.class); - - assertSame(cache, injectionHandler.objectCache()); - assertSame(context.getBean(FomCatalog.class), cache.catalog()); - assertSame( - context.getBean(HlaInterfaceImpl.class), - context.getBean(HlaInterface.class)); - assertFalse(cache.isEnabled()); - assertEquals( - 1, - dispatcher.stage( - StatementTrigger.Type.OBJECT_UPDATE, - "Rabbit", - new ObjectInjectionContext( - "Rabbit", - "object-1", - Map.of())) - .size()); - } - } - - @Configuration(proxyBeanMethods = false) - static class TestDependencies { - - @Bean - EncoderFactory encoderFactory() { - return new HLA1516eEncoderFactory(); - } - - @Bean - HLADecoderRegistry decoderRegistry(EncoderFactory encoderFactory) { - return new HLADecoderRegistry(encoderFactory); - } - - @Bean - SimulationConfig simulationConfig() { - return new SimulationConfig( - null, - null, - null, - null, - "config/HlaFedereplFOM.xml"); - } - - @Bean - FOMXML fomXml( - SimulationConfig simulationConfig, - HLADecoderRegistry decoderRegistry) { - return new FOMXML(simulationConfig, decoderRegistry); - } - - @Bean - XapiConfig xapiConfig() { - StatementTrigger trigger = new StatementTrigger(); - trigger.type = StatementTrigger.Type.OBJECT_UPDATE; - trigger.clazz = "Rabbit"; - trigger.statement = "{}"; - XapiConfig config = new XapiConfig(); - config.statementTriggers = List.of(trigger); - LrsConfig lrsConfig = new LrsConfig(); - lrsConfig.host = "http://localhost:8080/xapi"; - lrsConfig.key = "test"; - lrsConfig.secret = "test"; - lrsConfig.batch = 10; - lrsConfig.maxRetries = 0; - config.lrsConfig = lrsConfig; - return config; - } - - @Bean - StatementValidator statementValidator() { - return new StatementValidator(); - } - - @Bean(destroyMethod = "close") - ObjectCache objectCache( - XapiConfig xapiConfig, - FomCatalog fomCatalog, - FOMXML fomXml, - HLADecoderRegistry decoderRegistry) { - return new ObjectCache( - xapiConfig, - fomCatalog, - fomXml, - decoderRegistry); - } - } -} From 98192e003c0328e0299126a299cf024d229ee44f Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 28 Jul 2026 16:16:51 -0400 Subject: [PATCH 21/36] revert to original creds --- config/xapi-config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/xapi-config.json b/config/xapi-config.json index 24ce6d0..f9c116f 100644 --- a/config/xapi-config.json +++ b/config/xapi-config.json @@ -202,8 +202,8 @@ ], "lrs": { "host": "http://localhost:8080/xapi", - "key": "username", - "secret": "password", + "key": "my_key", + "secret": "my_secret", "batch": 35, "maxRetries": 3 } From b75375429a50e4ae36793db46a50597a6df61209 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 29 Jul 2026 17:03:38 -0400 Subject: [PATCH 22/36] added failing test on trigger matching --- .../StatementTriggerDispatcherTest.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java b/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java index a79c9a2..83cf63b 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java @@ -98,6 +98,29 @@ void lifecycleEventsMatchTheirExactTypeAndClass() { assertEquals(List.of("rabbit-delete"), deleteStatements); } + @Test + void objectUpdateForBaseClassMatchesConcreteDescendant() { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of( + trigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity", "sim-entity-update"), + trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "rabbit-update"), + trigger(StatementTrigger.Type.OBJECT_UPDATE, "Wolf", "wolf-update")); + StatementTriggerDispatcher dispatcher = + new StatementTriggerDispatcher(config, new ControlledTriggerProcessor()); + ObjectInjectionContext rabbit = + new ObjectInjectionContext("Rabbit", "object-1", Map.of()); + + List updateStatements = dispatcher + .stage(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", rabbit) + .stream() + .map(StatementTriggerDispatcher.StagedStatement::statement) + .toList(); + + assertEquals( + List.of("sim-entity-update", "rabbit-update"), + updateStatements); + } + private StatementTrigger trigger(StatementTrigger.Type type, String className, String statement) { StatementTrigger trigger = new StatementTrigger(); trigger.type = type; From f9704c71609ca0975285bba298bea0502f447b6b Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Thu, 30 Jul 2026 11:59:53 -0400 Subject: [PATCH 23/36] hierarchy-aware dispatch --- .../hlaxapi/StatementTriggerDispatcher.java | 19 ++++++++++-- .../hlaxapi/cache/FomCatalog.java | 12 +++++++ .../hlaxapi/HlaInteractionDispatchTest.java | 8 +++-- .../StatementTriggerDispatcherTest.java | 31 ++++++++++++++----- .../hlaxapi/cache/FomCatalogTest.java | 12 +++++++ .../cache/HlaObjectSubscriptionTest.java | 5 ++- 6 files changed, 74 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java b/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java index a3409a1..57a5ee9 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java +++ b/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java @@ -1,6 +1,7 @@ package com.yetanalytics.hlaxapi; import com.yetanalytics.hlaxapi.TriggerProcessor.TriggerProcessingResult; +import com.yetanalytics.hlaxapi.cache.FomCatalog; import com.yetanalytics.hlaxapi.config.XapiConfig; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.injection.InjectionContext; @@ -19,10 +20,15 @@ public class StatementTriggerDispatcher { private final XapiConfig xapiConfig; private final TriggerProcessor triggerProcessor; + private final FomCatalog fomCatalog; - public StatementTriggerDispatcher(XapiConfig xapiConfig, TriggerProcessor triggerProcessor) { + public StatementTriggerDispatcher( + XapiConfig xapiConfig, + TriggerProcessor triggerProcessor, + FomCatalog fomCatalog) { this.xapiConfig = xapiConfig; this.triggerProcessor = triggerProcessor; + this.fomCatalog = fomCatalog; } public List stage( @@ -36,7 +42,7 @@ public List stage( for (StatementTrigger trigger : xapiConfig.statementTriggers) { if (trigger == null || trigger.type != eventType - || !Objects.equals(trigger.clazz, hlaClass)) { + || !matchesClass(eventType, trigger.clazz, hlaClass)) { continue; } try { @@ -56,6 +62,15 @@ public List stage( return List.copyOf(statements); } + private boolean matchesClass( + StatementTrigger.Type eventType, + String configuredClass, + String actualClass) { + return eventType.isObjectEvent() + ? fomCatalog.isSameOrDescendant(actualClass, configuredClass) + : Objects.equals(configuredClass, actualClass); + } + public void enqueue(List statements, Consumer statementSink) { for (StagedStatement statement : statements) { try { diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java index 602dbd8..3313809 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java @@ -65,6 +65,18 @@ public List objectClassAndDescendants(String name) { .toList(); } + /** + * Returns whether the actual object class is the configured class or one of + * its FOM descendants. + */ + public boolean isSameOrDescendant(String actualClassName, String configuredClassName) { + ObjectClassDef actualClass = objectClass(actualClassName).orElse(null); + ObjectClassDef configuredClass = objectClass(configuredClassName).orElse(null); + return actualClass != null + && configuredClass != null + && isSameOrDescendant(actualClass, configuredClass); + } + public Optional attribute(int id) { return Optional.ofNullable(attributesById.get(id)); } diff --git a/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java b/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java index 28907e5..87734e1 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java @@ -33,7 +33,8 @@ void interactionCallbackStillRendersAndEnqueuesThroughTheSharedDispatcher() thro InjectionHandler injectionHandler = new InjectionHandler(); injectionHandler.setFomXml(fomXml); injectionHandler.setHLADecoderRegistry(decoderRegistry); - injectionHandler.setFomCatalog(new FomCatalog(fomXml)); + FomCatalog catalog = new FomCatalog(fomXml); + injectionHandler.setFomCatalog(catalog); StatementTrigger trigger = new StatementTrigger(); trigger.type = StatementTrigger.Type.INTERACTION; trigger.clazz = "StepCompleted"; @@ -59,7 +60,10 @@ void interactionCallbackStillRendersAndEnqueuesThroughTheSharedDispatcher() thro setField( hlaInterface, "triggerDispatcher", - new StatementTriggerDispatcher(config, new TriggerProcessor(injectionHandler))); + new StatementTriggerDispatcher( + config, + new TriggerProcessor(injectionHandler), + catalog)); setField(hlaInterface, "xapiClient", xapiClient); ParameterHandleValueMap parameters = new HLA1516eParameterHandleValueMap(); parameters.put(stepNumber, HLAEncodingTestSupport.int32(42, ByteOrder.BIG_ENDIAN)); diff --git a/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java b/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java index 83cf63b..09edbf2 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java @@ -4,6 +4,7 @@ import com.yetanalytics.extension.SuppressTestLogging; import com.yetanalytics.hlaxapi.TriggerProcessor.TriggerProcessingResult; +import com.yetanalytics.hlaxapi.cache.FomCatalog; import com.yetanalytics.hlaxapi.config.XapiConfig; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext; @@ -12,9 +13,14 @@ import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; +import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; class StatementTriggerDispatcherTest { + private final FomCatalog catalog = new FomCatalog(new FOMXML( + new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"), + new HLADecoderRegistry(new HLA1516eEncoderFactory()))); + @Test @SuppressTestLogging({"com.yetanalytics.hlaxapi.StatementTriggerDispatcher"}) void matchesExactlyStagesOnceAndIsolatesProcessingAndEnqueueFailures() { @@ -29,7 +35,8 @@ void matchesExactlyStagesOnceAndIsolatesProcessingAndEnqueueFailures() { config.statementTriggers = List.of(first, wrongType, wrongClass, skipped, failed, throwsException, second); ControlledTriggerProcessor processor = new ControlledTriggerProcessor(); - StatementTriggerDispatcher dispatcher = new StatementTriggerDispatcher(config, processor); + StatementTriggerDispatcher dispatcher = + new StatementTriggerDispatcher(config, processor, catalog); List staged = dispatcher.stage( StatementTrigger.Type.OBJECT_UPDATE, @@ -56,9 +63,10 @@ void interactionEventsUseTheSameDispatcherWithoutMatchingObjectTriggers() { XapiConfig config = new XapiConfig(); config.statementTriggers = List.of( trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "object"), + trigger(StatementTrigger.Type.INTERACTION, "SimEntity", "ancestor-interaction"), trigger(StatementTrigger.Type.INTERACTION, "Rabbit", "interaction")); StatementTriggerDispatcher dispatcher = - new StatementTriggerDispatcher(config, new ControlledTriggerProcessor()); + new StatementTriggerDispatcher(config, new ControlledTriggerProcessor(), catalog); List enqueued = new ArrayList<>(); dispatcher.dispatch( @@ -71,15 +79,17 @@ void interactionEventsUseTheSameDispatcherWithoutMatchingObjectTriggers() { } @Test - void lifecycleEventsMatchTheirExactTypeAndClass() { + void lifecycleEventsMatchTheirTypeAndFomHierarchy() { XapiConfig config = new XapiConfig(); config.statementTriggers = List.of( + trigger(StatementTrigger.Type.OBJECT_CREATE, "SimEntity", "sim-entity-create"), trigger(StatementTrigger.Type.OBJECT_CREATE, "Rabbit", "rabbit-create"), trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "rabbit-update"), + trigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity", "sim-entity-delete"), trigger(StatementTrigger.Type.OBJECT_DELETE, "Rabbit", "rabbit-delete"), trigger(StatementTrigger.Type.OBJECT_DELETE, "Wolf", "wolf-delete")); StatementTriggerDispatcher dispatcher = - new StatementTriggerDispatcher(config, new ControlledTriggerProcessor()); + new StatementTriggerDispatcher(config, new ControlledTriggerProcessor(), catalog); ObjectInjectionContext rabbit = new ObjectInjectionContext("Rabbit", "object-1", Map.of()); @@ -94,8 +104,12 @@ void lifecycleEventsMatchTheirExactTypeAndClass() { .map(StatementTriggerDispatcher.StagedStatement::statement) .toList(); - assertEquals(List.of("rabbit-create"), createStatements); - assertEquals(List.of("rabbit-delete"), deleteStatements); + assertEquals( + List.of("sim-entity-create", "rabbit-create"), + createStatements); + assertEquals( + List.of("sim-entity-delete", "rabbit-delete"), + deleteStatements); } @Test @@ -104,9 +118,10 @@ void objectUpdateForBaseClassMatchesConcreteDescendant() { config.statementTriggers = List.of( trigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity", "sim-entity-update"), trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "rabbit-update"), - trigger(StatementTrigger.Type.OBJECT_UPDATE, "Wolf", "wolf-update")); + trigger(StatementTrigger.Type.OBJECT_UPDATE, "Wolf", "wolf-update"), + trigger(StatementTrigger.Type.OBJECT_UPDATE, "MissingObject", "unknown-update")); StatementTriggerDispatcher dispatcher = - new StatementTriggerDispatcher(config, new ControlledTriggerProcessor()); + new StatementTriggerDispatcher(config, new ControlledTriggerProcessor(), catalog); ObjectInjectionContext rabbit = new ObjectInjectionContext("Rabbit", "object-1", Map.of()); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java index 0943125..d87d787 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java @@ -60,6 +60,18 @@ void resolvesObjectClassesWithTheirDescendants() { assertEquals(List.of(), catalog.objectClassAndDescendants("MissingObject")); } + @Test + void matchesObjectClassesThroughTheirFomHierarchy() { + FomCatalog catalog = catalog("config/HlaFedereplFOM.xml"); + + assertTrue(catalog.isSameOrDescendant("Rabbit", "Rabbit")); + assertTrue(catalog.isSameOrDescendant("Rabbit", "SimEntity")); + assertFalse(catalog.isSameOrDescendant("SimEntity", "Rabbit")); + assertFalse(catalog.isSameOrDescendant("Wolf", "Rabbit")); + assertFalse(catalog.isSameOrDescendant("MissingObject", "SimEntity")); + assertFalse(catalog.isSameOrDescendant("Rabbit", "MissingObject")); + } + @Test void fomXmlReturnsHierarchyWithDeclaredAttributes() { FOMXML fomXml = fomXml("config/HlaFedereplFOM.xml"); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java index bec7880..85e69ab 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java @@ -1011,7 +1011,10 @@ private HlaInterfaceImpl hlaInterface( setField( hlaInterface, "triggerDispatcher", - new StatementTriggerDispatcher(config, new TriggerProcessor(injectionHandler))); + new StatementTriggerDispatcher( + config, + new TriggerProcessor(injectionHandler), + catalog)); setField(hlaInterface, "xapiClient", xapiClient); return hlaInterface; } From e909d62ceb5bf666055dddd82a3a5cfc9a750276 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Thu, 30 Jul 2026 13:27:31 -0400 Subject: [PATCH 24/36] delegate object lifecycle trigger matching to trigger dispatcher --- .../hlaxapi/HlaInterfaceImpl.java | 14 +-- .../hlaxapi/StatementTriggerDispatcher.java | 23 ++++- .../cache/HlaObjectSubscriptionTest.java | 95 +++++++++++++++++++ 3 files changed, 118 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index c6e8b29..e6ef0e3 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -280,7 +280,9 @@ public void discoverObjectInstance( if (subscribedAttributes.isEmpty()) { return; } - if (hasObjectCreateTrigger(className)) { + if (triggerDispatcher.hasMatchingTrigger( + StatementTrigger.Type.OBJECT_CREATE, + className)) { pendingObjectCreates.put(theObject.toString(), className); } if (objectCache.isEnabled()) { @@ -304,16 +306,6 @@ public void discoverObjectInstance( } } - private boolean hasObjectCreateTrigger(String className) { - if (xapiConfig == null || xapiConfig.statementTriggers == null) { - return false; - } - return xapiConfig.statementTriggers.stream() - .anyMatch(trigger -> trigger != null - && trigger.type == StatementTrigger.Type.OBJECT_CREATE - && className.equals(trigger.clazz)); - } - private AttributeHandleSet attributeHandles( ObjectClassHandle classHandle, Iterable attributeNames) diff --git a/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java b/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java index 57a5ee9..9085e30 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java +++ b/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java @@ -40,9 +40,7 @@ public List stage( } List statements = new ArrayList<>(); for (StatementTrigger trigger : xapiConfig.statementTriggers) { - if (trigger == null - || trigger.type != eventType - || !matchesClass(eventType, trigger.clazz, hlaClass)) { + if (!matchesTrigger(trigger, eventType, hlaClass)) { continue; } try { @@ -62,6 +60,25 @@ public List stage( return List.copyOf(statements); } + public boolean hasMatchingTrigger( + StatementTrigger.Type eventType, + String hlaClass) { + if (xapiConfig.statementTriggers == null) { + return false; + } + return xapiConfig.statementTriggers.stream() + .anyMatch(trigger -> matchesTrigger(trigger, eventType, hlaClass)); + } + + private boolean matchesTrigger( + StatementTrigger trigger, + StatementTrigger.Type eventType, + String hlaClass) { + return trigger != null + && trigger.type == eventType + && matchesClass(eventType, trigger.clazz, hlaClass); + } + private boolean matchesClass( StatementTrigger.Type eventType, String configuredClass, diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java index 85e69ab..0b51cd8 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java @@ -147,6 +147,101 @@ void firstReflectionDispatchesObjectCreateAndObjectUpdateThenOnlyUpdates() throw } } + @Test + void concreteLifecycleCallbacksDispatchEachMatchingAncestorAndConcreteTriggerOnce( + @TempDir Path tempDir) throws Exception { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of( + objectTrigger( + StatementTrigger.Type.OBJECT_CREATE, + "SimEntity", + "{\"event\":\"sim-entity-create\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_CREATE, + "Rabbit", + "{\"event\":\"rabbit-create\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_CREATE, + "Wolf", + "{\"event\":\"wolf-create\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_UPDATE, + "SimEntity", + "{\"event\":\"sim-entity-update\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_UPDATE, + "Rabbit", + "{\"event\":\"rabbit-update\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_UPDATE, + "Wolf", + "{\"event\":\"wolf-update\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_DELETE, + "SimEntity", + "{\"event\":\"sim-entity-delete\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_DELETE, + "Rabbit", + "{\"event\":\"rabbit-delete\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_DELETE, + "Wolf", + "{\"event\":\"wolf-delete\"}")); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("polymorphic-lifecycle.sqlite"))) { + RecordingRti rti = new RecordingRti(); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = hlaInterface( + cache, + rti.proxy(), + config, + xapiClient, + injectionHandler(cache)); + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(109); + AttributeHandleValueMap firstReflection = new HLA1516eAttributeHandleValueMap(); + firstReflection.put( + rti.attributeHandle(rabbitClass, "EntityId"), + HLAEncodingTestSupport.asciiString("rabbit-polymorphic")); + firstReflection.put( + rti.attributeHandle(rabbitClass, "Hunger"), + HLAEncodingTestSupport.int32(12, ByteOrder.BIG_ENDIAN)); + + hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Polymorphic"); + hlaInterface.reflectAttributeValues( + rabbit, + firstReflection, + null, + null, + null, + null); + reflect( + hlaInterface, + rabbit, + rti.attributeHandle(rabbitClass, "Hunger"), + 13); + hlaInterface.removeObjectInstance(rabbit, null, null, null); + + assertEquals( + List.of( + "{\"event\":\"sim-entity-create\"}", + "{\"event\":\"rabbit-create\"}", + "{\"event\":\"sim-entity-update\"}", + "{\"event\":\"rabbit-update\"}", + "{\"event\":\"sim-entity-update\"}", + "{\"event\":\"rabbit-update\"}", + "{\"event\":\"sim-entity-delete\"}", + "{\"event\":\"rabbit-delete\"}"), + xapiClient.statements); + } + } + @Test void emptyReflectionDoesNotDispatchCacheOrConsumePendingCreate() throws Exception { XapiConfig config = new XapiConfig(); From 5d6412dd8bc3ebc97b66d1d779c409d9c73b8e5e Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Thu, 30 Jul 2026 13:35:19 -0400 Subject: [PATCH 25/36] add methods to object subscription plan for descendent subs --- .../hlaxapi/cache/ObjectSubscriptionPlan.java | 84 ++++++++---- .../cache/HlaObjectSubscriptionTest.java | 4 +- .../cache/ObjectSubscriptionPlanTest.java | 128 +++++++++++++++++- 3 files changed, 188 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlan.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlan.java index 7426d08..b3e16fe 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlan.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlan.java @@ -7,7 +7,6 @@ import java.util.LinkedHashSet; import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.Set; /** Immutable cache and event subscription requirements derived from configuration. */ @@ -84,7 +83,11 @@ private static Map> collectCacheSubscriptions( Map> merged = new LinkedHashMap<>(); QueryReferenceCollector.collect(xapiConfig.statementTriggers) .forEach((className, attributes) -> - addAttributes(merged, className, attributes)); + addReferencedAttributesForClassAndDescendants( + merged, + catalog, + className, + attributes)); addObjectDeleteTriggers(merged, xapiConfig, catalog); addTrackedObjects(merged, xapiConfig, catalog); return merged; @@ -105,13 +108,11 @@ private static Map> collectEventSubscriptions( || trigger.clazz.isBlank()) { continue; } - Optional clazz = catalog.objectClass(trigger.clazz); - if (clazz.isPresent()) { - FomCatalog.ObjectClassDef objectClass = clazz.orElseThrow(); - addAttributes(events, objectClass.localName(), objectClass.topLevelAttributeNames()); - } else { - addAttributes(events, trigger.clazz, Set.of("*")); - } + addAllAttributesForClassAndDescendants( + events, + catalog, + trigger.clazz, + true); } return events; } @@ -130,8 +131,11 @@ private static void addObjectDeleteTriggers( || trigger.clazz.isBlank()) { continue; } - catalog.objectClass(trigger.clazz).ifPresent(clazz -> - addAttributes(merged, clazz.localName(), clazz.topLevelAttributeNames())); + addAllAttributesForClassAndDescendants( + merged, + catalog, + trigger.clazz, + false); } } @@ -160,26 +164,54 @@ private static void addTrackedObjects( continue; } if (trackedObject.allAttributes) { - Optional clazz = - catalog.objectClass(trackedObject.clazz); - if (clazz.isPresent()) { - FomCatalog.ObjectClassDef objectClass = clazz.orElseThrow(); - addAttributes( - merged, - objectClass.localName(), - objectClass.topLevelAttributeNames()); - } else { - addAttributes(merged, trackedObject.clazz, Set.of("*")); - } + addAllAttributesForClassAndDescendants( + merged, + catalog, + trackedObject.clazz, + true); } else { - String className = catalog.objectClass(trackedObject.clazz) - .map(FomCatalog.ObjectClassDef::localName) - .orElse(trackedObject.clazz); - addAttributes(merged, className, trackedObject.attributes); + addReferencedAttributesForClassAndDescendants( + merged, + catalog, + trackedObject.clazz, + trackedObject.attributes); } } } + private static void addReferencedAttributesForClassAndDescendants( + Map> subscriptions, + FomCatalog catalog, + String className, + Iterable attributes) { + var classes = catalog.objectClassAndDescendants(className); + if (classes.isEmpty()) { + addAttributes(subscriptions, className, attributes); + return; + } + classes.forEach(clazz -> + addAttributes(subscriptions, clazz.localName(), attributes)); + } + + private static void addAllAttributesForClassAndDescendants( + Map> subscriptions, + FomCatalog catalog, + String className, + boolean retainUnknownClass) { + var classes = catalog.objectClassAndDescendants(className); + if (classes.isEmpty()) { + if (retainUnknownClass) { + addAttributes(subscriptions, className, Set.of("*")); + } + return; + } + classes.forEach(clazz -> + addAttributes( + subscriptions, + clazz.localName(), + clazz.topLevelAttributeNames())); + } + @SafeVarargs private static Map> mergeSubscriptions( Map>... plans) { diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java index 0b51cd8..cb86c7c 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java @@ -985,7 +985,9 @@ void discoveryRequestsTheUnionOfChildAndAncestorSubscriptions(@TempDir Path temp hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Inherited"); assertEquals(Set.of("FirstName"), cache.subscriptions().get("SimEntity")); - assertEquals(Set.of("Hunger"), cache.subscriptions().get("Rabbit")); + assertEquals(Set.of("FirstName"), cache.subscriptions().get("Carrot")); + assertEquals(Set.of("FirstName", "Hunger"), cache.subscriptions().get("Rabbit")); + assertEquals(Set.of("FirstName"), cache.subscriptions().get("Wolf")); assertEquals(Set.of("FirstName", "Hunger"), rti.requests.get(0).attributes()); } } diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlanTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlanTest.java index dbf7936..e7829cf 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlanTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlanTest.java @@ -1,6 +1,7 @@ package com.yetanalytics.hlaxapi.cache; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -8,10 +9,16 @@ import com.yetanalytics.hlaxapi.HLADecoderRegistry; import com.yetanalytics.hlaxapi.SimulationConfig; import com.yetanalytics.hlaxapi.config.XapiConfig; +import com.yetanalytics.hlaxapi.config.model.ComparisonOperator; +import com.yetanalytics.hlaxapi.config.model.Criterion; import com.yetanalytics.hlaxapi.config.model.ObjectCacheConfig; +import com.yetanalytics.hlaxapi.config.model.ObjectLookup; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TrackedObject; +import com.yetanalytics.hlaxapi.config.model.ValueExpression; import java.util.List; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; @@ -41,7 +48,9 @@ void combinesDirectAndAncestorRequirementsWithoutMutatingThePlan() { ObjectSubscriptionPlan plan = ObjectSubscriptionPlan.from(config, catalog); assertEquals(Set.of("FirstName"), plan.cacheSubscriptions().get("SimEntity")); - assertEquals(Set.of("Hunger"), plan.cacheSubscriptions().get("Rabbit")); + assertEquals(Set.of("FirstName"), plan.cacheSubscriptions().get("Carrot")); + assertEquals(Set.of("FirstName", "Hunger"), plan.cacheSubscriptions().get("Rabbit")); + assertEquals(Set.of("FirstName"), plan.cacheSubscriptions().get("Wolf")); assertTrue(plan.eventSubscriptions().isEmpty()); assertEquals(Set.of("FirstName"), plan.effectiveAttributes("SimEntity")); assertEquals(Set.of("FirstName", "Hunger"), plan.effectiveAttributes("Rabbit")); @@ -53,4 +62,121 @@ void combinesDirectAndAncestorRequirementsWithoutMutatingThePlan() { UnsupportedOperationException.class, () -> plan.subscriptions().get("Rabbit").add("EntityId")); } + + @Test + void lifecycleBaseClassSubscribesEveryDescendantToItsCompleteAttributesWithoutCache() { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of( + objectTrigger(StatementTrigger.Type.OBJECT_CREATE, "SimEntity"), + objectTrigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity"), + objectTrigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity")); + + ObjectSubscriptionPlan plan = ObjectSubscriptionPlan.from(config, catalog); + + assertFalse(plan.requiresCache()); + assertTrue(plan.cacheSubscriptions().isEmpty()); + assertCompleteHierarchy(plan.eventSubscriptions()); + assertCompleteHierarchy(plan.subscriptions()); + } + + @Test + void deleteBaseClassCachesCompleteStateForEveryDescendant() { + XapiConfig config = new XapiConfig(); + config.statementTriggers = + List.of(objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity")); + + ObjectSubscriptionPlan plan = ObjectSubscriptionPlan.from(config, catalog); + + assertTrue(plan.requiresCache()); + assertCompleteHierarchy(plan.cacheSubscriptions()); + assertCompleteHierarchy(plan.eventSubscriptions()); + assertCompleteHierarchy(plan.subscriptions()); + } + + @Test + void previousAndLookupReferencesExpandOnlyTheirAttributesAcrossDescendants() { + StatementTrigger trigger = + objectTrigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity"); + trigger.statement = """ + { + "oldPosition":["previous",["Position"]], + "firstName":["lookup","entity",["FirstName"]] + } + """; + ObjectLookup lookup = new ObjectLookup(); + lookup.clazz = "SimEntity"; + lookup.criteria = new Criterion( + new Target(List.of("EntityId")), + ComparisonOperator.EQ, + new ValueExpression("entity-one")); + trigger.lookups = Map.of("entity", lookup); + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(trigger); + + ObjectSubscriptionPlan plan = ObjectSubscriptionPlan.from(config, catalog); + + assertTrue(plan.requiresCache()); + for (FomCatalog.ObjectClassDef clazz : + catalog.objectClassAndDescendants("SimEntity")) { + assertEquals( + Set.of("EntityId", "FirstName", "Position"), + plan.cacheSubscriptions().get(clazz.localName())); + } + assertCompleteHierarchy(plan.eventSubscriptions()); + } + + @Test + void trackedBaseClassExpandsExplicitAndAllAttributeRequirements() { + TrackedObject explicit = new TrackedObject(); + explicit.clazz = "SimEntity"; + explicit.attributes = List.of("EntityId", "Position"); + ObjectCacheConfig explicitCache = new ObjectCacheConfig(); + explicitCache.trackedObjects = List.of(explicit); + XapiConfig explicitConfig = new XapiConfig(); + explicitConfig.objectCacheConfig = explicitCache; + + ObjectSubscriptionPlan explicitPlan = + ObjectSubscriptionPlan.from(explicitConfig, catalog); + + for (FomCatalog.ObjectClassDef clazz : + catalog.objectClassAndDescendants("SimEntity")) { + assertEquals( + Set.of("EntityId", "Position"), + explicitPlan.cacheSubscriptions().get(clazz.localName())); + } + + TrackedObject all = new TrackedObject(); + all.clazz = "SimEntity"; + all.allAttributes = true; + ObjectCacheConfig allCache = new ObjectCacheConfig(); + allCache.trackedObjects = List.of(all); + XapiConfig allConfig = new XapiConfig(); + allConfig.objectCacheConfig = allCache; + + ObjectSubscriptionPlan allPlan = + ObjectSubscriptionPlan.from(allConfig, catalog); + + assertTrue(explicitPlan.requiresCache()); + assertTrue(allPlan.requiresCache()); + assertCompleteHierarchy(allPlan.cacheSubscriptions()); + } + + private StatementTrigger objectTrigger( + StatementTrigger.Type type, + String className) { + StatementTrigger trigger = new StatementTrigger(); + trigger.type = type; + trigger.clazz = className; + trigger.statement = "{}"; + return trigger; + } + + private void assertCompleteHierarchy(Map> subscriptions) { + for (FomCatalog.ObjectClassDef clazz : + catalog.objectClassAndDescendants("SimEntity")) { + assertEquals( + Set.copyOf(clazz.topLevelAttributeNames()), + subscriptions.get(clazz.localName())); + } + } } From 1220eb73626719c9b5fdb79bd9cc509a3b93af29 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Thu, 30 Jul 2026 13:42:36 -0400 Subject: [PATCH 26/36] sort subs by class depth --- .../hlaxapi/HlaInterfaceImpl.java | 10 +- .../hlaxapi/cache/FomCatalog.java | 16 +++ .../hlaxapi/cache/FomCatalogTest.java | 11 ++ .../cache/HlaObjectSubscriptionTest.java | 127 ++++++++++++++++++ 4 files changed, 163 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index e6ef0e3..fab00eb 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -4,6 +4,7 @@ import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -233,7 +234,14 @@ private void subscribeObjectClasses() if (!objectCache.hasSubscriptions()) { return; } - for (Map.Entry> subscription : objectCache.subscriptions().entrySet()) { + List>> subscriptions = + new ArrayList<>(objectCache.subscriptions().entrySet()); + subscriptions.sort(Comparator + .>>comparingInt(subscription -> + objectCache.catalog().objectClassDepth(subscription.getKey())) + .reversed() + .thenComparing(Map.Entry::getKey)); + for (Map.Entry> subscription : subscriptions) { try { FomCatalog.ObjectClassDef clazz = objectCache.catalog().objectClass(subscription.getKey()).orElseThrow( () -> new IllegalArgumentException("No FOM object class " + subscription.getKey())); diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java index 3313809..c43533d 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java @@ -77,6 +77,22 @@ public boolean isSameOrDescendant(String actualClassName, String configuredClass && isSameOrDescendant(actualClass, configuredClass); } + /** + * Returns the number of known FOM ancestors for an object class, or -1 when + * the class is unknown. + */ + public int objectClassDepth(String className) { + ObjectClassDef current = objectClass(className).orElse(null); + if (current == null) { + return -1; + } + int depth = 0; + while ((current = classesByName.get(current.parentName())) != null) { + depth++; + } + return depth; + } + public Optional attribute(int id) { return Optional.ofNullable(attributesById.get(id)); } diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java index d87d787..1b347e6 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java @@ -72,6 +72,17 @@ void matchesObjectClassesThroughTheirFomHierarchy() { assertFalse(catalog.isSameOrDescendant("Rabbit", "MissingObject")); } + @Test + void calculatesObjectClassDepthFromKnownFomAncestors() { + FomCatalog catalog = catalog("config/HlaFedereplFOM.xml"); + + assertEquals(1, catalog.objectClassDepth("SimEntity")); + assertEquals(2, catalog.objectClassDepth("Carrot")); + assertEquals(2, catalog.objectClassDepth("Rabbit")); + assertEquals(2, catalog.objectClassDepth("Wolf")); + assertEquals(-1, catalog.objectClassDepth("MissingObject")); + } + @Test void fomXmlReturnsHierarchyWithDeclaredAttributes() { FOMXML fomXml = fomXml("config/HlaFedereplFOM.xml"); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java index cb86c7c..fd21686 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java @@ -992,6 +992,133 @@ void discoveryRequestsTheUnionOfChildAndAncestorSubscriptions(@TempDir Path temp } } + @Test + void ancestorQuerySubscribesDescendantsFirstAndCachesConcreteClasses( + @TempDir Path tempDir) throws Exception { + StatementTrigger simEntityQuery = new StatementTrigger(); + simEntityQuery.statement = """ + {"entityId":["query","SimEntity",["EntityId"],null]} + """; + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(simEntityQuery); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("concrete-classes.sqlite"))) { + RecordingRti rti = new RecordingRti(); + HlaInterfaceImpl hlaInterface = + hlaInterface(cache, rti.proxy(), config, new RecordingXapiClient()); + + subscribeObjectClasses(hlaInterface); + + assertEquals( + List.of("Carrot", "Rabbit", "Wolf", "SimEntity"), + rti.subscriptions.stream() + .map(ObjectSubscription::className) + .toList()); + assertTrue(rti.subscriptions.stream() + .allMatch(subscription -> + subscription.attributes().equals(Set.of("EntityId")))); + + List concreteClasses = List.of("Carrot", "Rabbit", "Wolf"); + for (int i = 0; i < concreteClasses.size(); i++) { + String className = concreteClasses.get(i); + ObjectClassHandle classHandle = rti.classHandle(className); + ObjectInstanceHandle object = rti.objectHandle(110 + i); + hlaInterface.discoverObjectInstance( + object, + classHandle, + className + " Concrete"); + AttributeHandleValueMap reflection = + new HLA1516eAttributeHandleValueMap(); + reflection.put( + rti.attributeHandle(classHandle, "EntityId"), + HLAEncodingTestSupport.asciiString( + className.toLowerCase() + "-concrete")); + hlaInterface.reflectAttributeValues( + object, + reflection, + null, + null, + null, + null); + + assertEquals( + className, + cache.findCurrentObjectSnapshot(object.toString()) + .orElseThrow() + .className()); + assertEquals( + 1, + cache.currentObjects(className).size()); + } + + assertEquals(3, rti.requests.size()); + assertTrue(rti.requests.stream() + .allMatch(request -> request.attributes().equals(Set.of("EntityId")))); + assertEquals( + concreteClasses, + cache.currentObjects("SimEntity").stream() + .map(CachedObject::className) + .toList()); + } + } + + @Test + void overlappingAncestorAndConcreteSubscriptionsDoNotDuplicateReflectionTriggers() + throws Exception { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of( + objectUpdateTrigger( + "SimEntity", + "{\"event\":\"sim-entity-update\"}"), + objectUpdateTrigger( + "Rabbit", + "{\"event\":\"rabbit-update\"}")); + + try (ObjectCache cache = new ObjectCache(config, catalog, fomXml, decoderRegistry)) { + RecordingRti rti = new RecordingRti(); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + HlaInterfaceImpl hlaInterface = + hlaInterface(cache, rti.proxy(), config, xapiClient); + + subscribeObjectClasses(hlaInterface); + + ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(113); + hlaInterface.discoverObjectInstance( + rabbit, + rabbitClass, + "Rabbit Overlap"); + AttributeHandleValueMap reflection = + new HLA1516eAttributeHandleValueMap(); + reflection.put( + rti.attributeHandle(rabbitClass, "EntityId"), + HLAEncodingTestSupport.asciiString("rabbit-overlap")); + reflection.put( + rti.attributeHandle(rabbitClass, "Hunger"), + HLAEncodingTestSupport.int32(12, ByteOrder.BIG_ENDIAN)); + hlaInterface.reflectAttributeValues( + rabbit, + reflection, + null, + null, + null, + null); + + assertEquals(1, rti.requests.size()); + assertEquals(2, rti.attributeNameResolutions); + assertEquals( + List.of( + "{\"event\":\"sim-entity-update\"}", + "{\"event\":\"rabbit-update\"}"), + xapiClient.statements); + } + } + @Test @SuppressTestLogging({"com.yetanalytics.hlaxapi.HlaInterfaceImpl"}) void unknownObjectUpdateClassIsSkippedDuringSubscription() throws Exception { From 1907ad162fc8555591cfbcf0b70657cabd15426f Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Thu, 30 Jul 2026 14:14:02 -0400 Subject: [PATCH 27/36] document subscription polymorphism --- doc/xapi-config.md | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/doc/xapi-config.md b/doc/xapi-config.md index e6c1c68..9c08506 100644 --- a/doc/xapi-config.md +++ b/doc/xapi-config.md @@ -56,7 +56,7 @@ At the top level the file supports: Fields: - `type`: One of `Interaction`, `ObjectCreate`, `ObjectUpdate`, or `ObjectDelete`. -- `class`: Local HLA interaction or object class name. Matching is exact: an object trigger for `SimEntity` does not also fire for a reflection reported as `Rabbit`. +- `class`: Local HLA interaction or object class name. Interaction matching is exact. Object lifecycle matching is polymorphic: a trigger configured for an object class also matches instances of every FOM descendant. - `criteria`: Optional expression evaluated before the statement template is processed. A non-matching trigger is skipped without producing an xAPI statement. A trigger without criteria always matches. - `lookups`: Optional named cache lookups loaded on first use. A lookup result, including a missing result, is reused for the rest of that trigger attempt. - `statement`: An xAPI statement template. Any JSON object accepted by the xAPI spec can be used here, with injection expressions inserted where dynamic values are needed. @@ -69,16 +69,24 @@ Every matching trigger is processed once for an eligible callback. One trigger f | Type | Eligible RTI callback | Meaning of `trigger` | | --- | --- | --- | | `Interaction` | Every received interaction of the exact class | Parameters in that interaction | -| `ObjectCreate` | First successfully processed non-empty reflection after discovery | Attributes in that one reflection | -| `ObjectUpdate` | Every successfully processed non-empty reflection | Attributes in that one reflection | -| `ObjectDelete` | First removal of a known active object | Last cached attributes for the object | +| `ObjectCreate` | First successfully processed non-empty reflection after discovery of the configured class or a descendant | Attributes in that one reflection | +| `ObjectUpdate` | Every successfully processed non-empty reflection for the configured class or a descendant | Attributes in that one reflection | +| `ObjectDelete` | First removal of a known active object of the configured class or a descendant | Last cached attributes for the object | -Object event triggers subscribe their configured class to all top-level FOM attributes, including inherited attributes. These event subscriptions are merged with attributes required by queries, lookups, `previous`, and `objectCache.trackedObjects`. +Object lifecycle trigger classes are polymorphic. For example, an `ObjectUpdate` trigger configured for `SimEntity` matches reflections reported as `SimEntity`, `Carrot`, `Rabbit`, or `Wolf`, while a trigger configured for `Rabbit` matches only `Rabbit` in this FOM. Interaction trigger classes remain exact-match. + +Each configured trigger remains an independent rule. If both `SimEntity` and `Rabbit` ObjectUpdate triggers are configured, one Rabbit reflection evaluates each trigger once and can intentionally produce two statements. If only the `SimEntity` trigger is configured, that reflection evaluates it once. Subscribing both an ancestor and descendant, subscribing multiple attributes, or receiving multiple attributes in one callback does not duplicate a configured trigger's execution. + +Object trigger templates and criteria are validated against their configured class. A `SimEntity` trigger can use inherited `SimEntity` attributes such as `EntityId`, but it cannot directly reference child-only attributes such as `Rabbit.Hunger` because that path is not valid for every class the trigger matches. + +Object event subscriptions expand across the configured class and every FOM descendant. Each class is subscribed to all of its own top-level attributes, including inherited attributes, so a child-only update such as `Rabbit.Hunger` or `Carrot.Age` is eligible for a `SimEntity` trigger. These event subscriptions are merged and deduplicated with attributes required by queries, lookups, `previous`, and `objectCache.trackedObjects`. `ObjectCreate` means first observed by this adapter, not necessarily created in the federation at that moment. It also fires for pre-existing objects discovered after a late join and can fire again after the adapter restarts. Discovery marks an object as pending creation and requests its subscribed values; the trigger waits for the first non-empty reflection. That reflection is independently eligible for both `ObjectCreate` and `ObjectUpdate`. Create payloads are not aggregated across callbacks. If the first reflection contains only `EntityId`, another attribute arriving in a later reflection is missing from the Create payload. A successful first reflection consumes the pending-create marker even when a particular Create trigger is skipped by criteria or a required injection. A cache failure retains the marker for the next successful reflection. Removal before the first reflection clears it without emitting Create. +Update payloads are also callback-local. A `SimEntity` trigger activated by a Rabbit reflection containing only `Hunger` has no incoming `EntityId`, even if `EntityId` was cached earlier. A required `trigger` injection for `EntityId` suppresses that statement, while an optional injection renders `null`. Use `previous`, `query`, or `lookup` when the desired value should come from cached state. + `ObjectDelete` reports that an object disappeared, not why it disappeared. It uses the final state retained by this adapter and therefore always activates the object cache. An object removed after discovery but before receiving attributes can still produce a static Delete statement; required missing values suppress a statement and optional values render `null`. Unknown, already removed, or duplicate removals are skipped. Discovery and removal can race. If the object disappears before the adapter's bootstrap `requestAttributeValueUpdate` reaches the RTI, `ObjectInstanceNotKnown` is treated as expected and logged at debug level. Cached discovery metadata remains available to the removal callback. @@ -89,6 +97,8 @@ For Create and Update, matching statements are rendered against the incoming ref For Delete, statements and their queries/lookups are rendered while the object is still current. The adapter then marks the object removed and enqueues only after that mutation succeeds. +For ObjectUpdate, one update means one RTI `reflectAttributeValues` callback. A callback carrying several attributes is still one update and evaluates each applicable configured trigger once. Repeated callbacks and an RTI or publisher splitting attributes across callbacks are separate updates and can each emit statements. + This ordering relies on the RTI delivering callbacks serially, as Portico's immediate callback dispatcher currently does. The adapter does not promise pre-update snapshot semantics if callbacks are invoked concurrently. ## Targets @@ -324,9 +334,9 @@ The object cache stores the latest reflected values for subscribed HLA object at - an ObjectDelete trigger exists for a known FOM class, or - `objectCache.trackedObjects` explicitly requests tracked attributes. -Incoming-only ObjectCreate and ObjectUpdate triggers do not enable SQL on their own. They still create event subscriptions for all inherited top-level attributes. +Incoming-only ObjectCreate and ObjectUpdate triggers do not enable SQL on their own. They still create event subscriptions for the configured class and every descendant, using each class's complete inherited and declared top-level attribute set. -When enabled, the adapter subscribes to the top-level object attributes required by `previous`, query targets, query criteria, lookup targets, lookup criteria, ObjectDelete snapshots, and explicit tracked objects. Requirements configured on an ancestor and a discovered child are combined for the bootstrap attribute request. Use the `trackedObjects` array to force caching of simulation objects: +When enabled, the adapter subscribes to the top-level object attributes required by `previous`, query targets, query criteria, lookup targets, lookup criteria, ObjectDelete snapshots, and explicit tracked objects. A requirement configured on a FOM class is expanded to that class and its descendants. Referenced attribute lists are copied to every descendant, while ObjectDelete and `allAttributes` requirements use each descendant's complete inherited and declared top-level attribute set. Requirements configured on an ancestor and a discovered child are combined for the bootstrap attribute request. Use the `trackedObjects` array to force caching of simulation objects: ```json { @@ -343,8 +353,8 @@ When enabled, the adapter subscribes to the top-level object attributes required Tracked object fields: - `class`: Local HLA object class name. Use `*` with `allAttributes: true` to subscribe to all top-level attributes for every FOM object class with attributes. -- `attributes`: Top-level attribute names to subscribe to. -- `allAttributes`: When `true`, expands to all top-level attributes for the class. +- `attributes`: Top-level attribute names to subscribe to for the class and every descendant. +- `allAttributes`: When `true`, expands to each matching class's complete inherited and declared top-level attribute set. `HLA_OBJECT_CACHE_BACKEND` selects `sqlite` or `postgresql` case-insensitively. It defaults to `sqlite`. Backend and connection settings are runtime configuration and cannot be set in the xAPI JSON file. @@ -353,6 +363,10 @@ The cache decodes reflected values using the FOM and stores both top-level value One RTI reflection is one cache transaction: every reflected attribute is replaced with a shared observation timestamp and sequence, or the transaction rolls back without changing any of them. A partial reflection updates only the attributes it contains; it does not erase other cached attributes. Empty reflections are ignored. +Known object classes are subscribed most-specific-first, with deterministic name ordering among classes at the same FOM depth. This allows a late-joining adapter to discover pre-existing objects at the most-concrete subscribed FOM class before subscribing their ancestors. Discovery and reflection cache the class reported by the RTI, and a base-class query such as `SimEntity` still includes cached descendants. + +Here, "concrete" means the FOM class under which the publishing federate registered the object and which the RTI makes known to this adapter. An object actually registered as `SimEntity` remains `SimEntity`; the adapter does not reinterpret its `EntityType` attribute to reclassify it as `Carrot`, `Rabbit`, or `Wolf`. + ### SQLite By default SQLite uses `hla-object-cache.sqlite` in the working directory. It can be changed with: From 50372dbb8ac01901048261a48afcca61fa2ca0fb Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Fri, 31 Jul 2026 11:47:05 -0400 Subject: [PATCH 28/36] use full hla names for pitch compat --- .../com/yetanalytics/hlaxapi/HlaInterfaceImpl.java | 2 +- .../com/yetanalytics/hlaxapi/cache/FomCatalog.java | 13 ++++++++++--- .../yetanalytics/hlaxapi/cache/FomCatalogTest.java | 11 +++++++++++ .../hlaxapi/cache/HlaObjectSubscriptionTest.java | 14 +++++++++++++- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index fab00eb..05c0bee 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -245,7 +245,7 @@ private void subscribeObjectClasses() try { FomCatalog.ObjectClassDef clazz = objectCache.catalog().objectClass(subscription.getKey()).orElseThrow( () -> new IllegalArgumentException("No FOM object class " + subscription.getKey())); - ObjectClassHandle classHandle = ambassador.getObjectClassHandle(clazz.localName()); + ObjectClassHandle classHandle = ambassador.getObjectClassHandle(clazz.hlaName()); AttributeHandleSet attributeHandles = attributeHandles(classHandle, subscription.getValue()); if (attributeHandles.isEmpty()) { diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java index c43533d..d547642 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java @@ -219,6 +219,13 @@ private CatalogBuilder(FOMXML fomXml) { } private void addObjectClass(FOMXML.ObjectClassDefinition definition) { + String localClassName = localName(definition.name()); + String localParentName = localName(definition.parentName()); + ObjectClassDef parentClass = classesByName.get(localParentName); + String hlaName = parentClass == null || "HLAobjectRoot".equals(parentClass.localName()) + ? localClassName + : parentClass.hlaName() + "." + localClassName; + List allAttributes = new ArrayList<>(); if (definition.parentName() != null) { allAttributes.addAll(attributesByClassName.getOrDefault(definition.parentName(), List.of())); @@ -237,9 +244,9 @@ private void addObjectClass(FOMXML.ObjectClassDefinition definition) { ObjectClassDef classDef = new ObjectClassDef( classId, - definition.name(), - localName(definition.name()), - localName(definition.parentName()), + hlaName, + localClassName, + localParentName, flattened); classesByName.put(classDef.localName(), classDef); } diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java index 1b347e6..a8b3612 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java @@ -83,6 +83,17 @@ void calculatesObjectClassDepthFromKnownFomAncestors() { assertEquals(-1, catalog.objectClassDepth("MissingObject")); } + @Test + void buildsRtiObjectClassNamesRelativeToHlaObjectRoot() { + FomCatalog catalog = catalog("config/HlaFedereplFOM.xml"); + + assertEquals("HLAobjectRoot", catalog.objectClass("HLAobjectRoot").orElseThrow().hlaName()); + assertEquals("SimEntity", catalog.objectClass("SimEntity").orElseThrow().hlaName()); + assertEquals("SimEntity.Carrot", catalog.objectClass("Carrot").orElseThrow().hlaName()); + assertEquals("SimEntity.Rabbit", catalog.objectClass("Rabbit").orElseThrow().hlaName()); + assertEquals("SimEntity.Wolf", catalog.objectClass("Wolf").orElseThrow().hlaName()); + } + @Test void fomXmlReturnsHierarchyWithDeclaredAttributes() { FOMXML fomXml = fomXml("config/HlaFedereplFOM.xml"); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java index fd21686..4a166cc 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java @@ -1019,6 +1019,13 @@ void ancestorQuerySubscribesDescendantsFirstAndCachesConcreteClasses( rti.subscriptions.stream() .map(ObjectSubscription::className) .toList()); + assertEquals( + List.of( + "SimEntity.Carrot", + "SimEntity.Rabbit", + "SimEntity.Wolf", + "SimEntity"), + rti.objectClassHandleLookups); assertTrue(rti.subscriptions.stream() .allMatch(subscription -> subscription.attributes().equals(Set.of("EntityId")))); @@ -1363,6 +1370,7 @@ private static final class RecordingRti implements InvocationHandler { private final Map classes = new LinkedHashMap<>(); private final Map classNames = new LinkedHashMap<>(); + private final List objectClassHandleLookups = new ArrayList<>(); private final Map attributes = new LinkedHashMap<>(); private final Map attributeNames = new LinkedHashMap<>(); private final List subscriptions = new ArrayList<>(); @@ -1382,6 +1390,7 @@ private RTIambassador proxy() { } private ObjectClassHandle classHandle(String className) { + className = className.substring(className.lastIndexOf('.') + 1); ObjectClassHandle handle = classes.computeIfAbsent( className, ignored -> (ObjectClassHandle) new HLA1516eHandle(nextClassHandle++)); @@ -1406,7 +1415,10 @@ private AttributeHandle attributeHandle(ObjectClassHandle classHandle, String at @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { return switch (method.getName()) { - case "getObjectClassHandle" -> classHandle((String) args[0]); + case "getObjectClassHandle" -> { + objectClassHandleLookups.add((String) args[0]); + yield classHandle((String) args[0]); + } case "getObjectClassName" -> qualifiedClassName(classNames.get(args[0])); case "getAttributeHandleSetFactory" -> new HLA1516eAttributeHandleSetFactory(); case "getAttributeHandle" -> attributeHandle((ObjectClassHandle) args[0], (String) args[1]); From 8469ba5641bb19d66cf724940432d536dfac5209 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Mon, 3 Aug 2026 10:20:04 -0400 Subject: [PATCH 29/36] fold StatementTriggerDispatcher into TriggerProcessor --- .../hlaxapi/HlaInterfaceImpl.java | 21 ++-- .../hlaxapi/StatementTriggerDispatcher.java | 115 ------------------ .../hlaxapi/TriggerProcessor.java | 103 ++++++++++++++++ .../hlaxapi/HlaInteractionDispatchTest.java | 7 +- ...java => TriggerProcessorDispatchTest.java} | 43 ++++--- .../cache/HlaObjectSubscriptionTest.java | 20 +-- 6 files changed, 141 insertions(+), 168 deletions(-) delete mode 100644 src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java rename src/test/java/com/yetanalytics/hlaxapi/{StatementTriggerDispatcherTest.java => TriggerProcessorDispatchTest.java} (82%) diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index 05c0bee..a69d30e 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -99,9 +99,6 @@ public class HlaInterfaceImpl extends NullFederateAmbassador implements HlaInter @Autowired private TriggerProcessor triggerProcessor; - @Autowired - private StatementTriggerDispatcher triggerDispatcher; - @Autowired private StatementValidator validator; @@ -288,7 +285,7 @@ public void discoverObjectInstance( if (subscribedAttributes.isEmpty()) { return; } - if (triggerDispatcher.hasMatchingTrigger( + if (triggerProcessor.hasMatchingTrigger( StatementTrigger.Type.OBJECT_CREATE, className)) { pendingObjectCreates.put(theObject.toString(), className); @@ -382,18 +379,18 @@ private void reflectAttributeValues(ObjectInstanceHandle theObject, AttributeHan ObjectInjectionContext context = new ObjectInjectionContext(className, theObject.toString(), attributes); boolean createPending = className.equals(pendingObjectCreates.get(theObject.toString())); - List statements = new ArrayList<>(); + List statements = new ArrayList<>(); if (createPending) { statements.addAll( - triggerDispatcher.stage(StatementTrigger.Type.OBJECT_CREATE, className, context)); + triggerProcessor.stage(StatementTrigger.Type.OBJECT_CREATE, className, context)); } statements.addAll( - triggerDispatcher.stage(StatementTrigger.Type.OBJECT_UPDATE, className, context)); + triggerProcessor.stage(StatementTrigger.Type.OBJECT_UPDATE, className, context)); objectCache.reflectAttributeValues(theObject.toString(), className, attributes); if (createPending) { pendingObjectCreates.remove(theObject.toString(), className); } - triggerDispatcher.enqueue(statements, xapiClient::sendStatement); + triggerProcessor.enqueue(statements, xapiClient::sendStatement); } catch (AttributeNotDefined | InvalidAttributeHandle | InvalidObjectClassHandle | ObjectInstanceNotKnown | FederateNotExecutionMember | NotConnected | RTIinternalError | RuntimeException e) { logger.error("Error processing reflected object attributes", e); @@ -440,13 +437,13 @@ private void removeCachedObject(ObjectInstanceHandle theObject) { } try { ObjectSnapshot snapshot = objectCache.findCurrentObjectSnapshot(objectHandle).orElse(null); - List statements = List.of(); + List statements = List.of(); if (snapshot != null) { ObjectInjectionContext context = new ObjectInjectionContext( snapshot.className(), snapshot.objectHandle(), snapshot.attributes()); - statements = triggerDispatcher.stage( + statements = triggerProcessor.stage( StatementTrigger.Type.OBJECT_DELETE, snapshot.className(), context); @@ -455,7 +452,7 @@ private void removeCachedObject(ObjectInstanceHandle theObject) { return; } objectCache.removeObject(objectHandle); - triggerDispatcher.enqueue(statements, xapiClient::sendStatement); + triggerProcessor.enqueue(statements, xapiClient::sendStatement); } catch (RuntimeException e) { logger.error("Error removing cached object {}", theObject, e); } @@ -518,7 +515,7 @@ private void receiveInteraction(InteractionClassHandle interactionClass, Paramet InteractionInjectionContext context = new InteractionInjectionContext(interactionKey, getMapWithParameterNames(interactionClass, theParameters)); - triggerDispatcher.dispatch( + triggerProcessor.dispatch( StatementTrigger.Type.INTERACTION, interactionKey, context, diff --git a/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java b/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java deleted file mode 100644 index 9085e30..0000000 --- a/src/main/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcher.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.yetanalytics.hlaxapi; - -import com.yetanalytics.hlaxapi.TriggerProcessor.TriggerProcessingResult; -import com.yetanalytics.hlaxapi.cache.FomCatalog; -import com.yetanalytics.hlaxapi.config.XapiConfig; -import com.yetanalytics.hlaxapi.config.model.StatementTrigger; -import com.yetanalytics.hlaxapi.injection.InjectionContext; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.function.Consumer; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.springframework.stereotype.Component; - -@Component -public class StatementTriggerDispatcher { - - private static final Logger logger = LogManager.getLogger(StatementTriggerDispatcher.class); - - private final XapiConfig xapiConfig; - private final TriggerProcessor triggerProcessor; - private final FomCatalog fomCatalog; - - public StatementTriggerDispatcher( - XapiConfig xapiConfig, - TriggerProcessor triggerProcessor, - FomCatalog fomCatalog) { - this.xapiConfig = xapiConfig; - this.triggerProcessor = triggerProcessor; - this.fomCatalog = fomCatalog; - } - - public List stage( - StatementTrigger.Type eventType, - String hlaClass, - InjectionContext context) { - if (xapiConfig.statementTriggers == null) { - return List.of(); - } - List statements = new ArrayList<>(); - for (StatementTrigger trigger : xapiConfig.statementTriggers) { - if (!matchesTrigger(trigger, eventType, hlaClass)) { - continue; - } - try { - logger.trace("Processing {} trigger for {}", eventType, hlaClass); - TriggerProcessingResult result = triggerProcessor.processTrigger(trigger, context); - if (result == null) { - logger.error("Trigger {}.{} did not produce a processing result", eventType, hlaClass); - } else if (result.success() && result.matched()) { - statements.add(new StagedStatement(trigger, result.statement())); - } else if (!result.success()) { - logger.error("Error processing trigger {}.{}", eventType, hlaClass, result.error()); - } - } catch (RuntimeException e) { - logger.error("Error processing trigger {}.{}", eventType, hlaClass, e); - } - } - return List.copyOf(statements); - } - - public boolean hasMatchingTrigger( - StatementTrigger.Type eventType, - String hlaClass) { - if (xapiConfig.statementTriggers == null) { - return false; - } - return xapiConfig.statementTriggers.stream() - .anyMatch(trigger -> matchesTrigger(trigger, eventType, hlaClass)); - } - - private boolean matchesTrigger( - StatementTrigger trigger, - StatementTrigger.Type eventType, - String hlaClass) { - return trigger != null - && trigger.type == eventType - && matchesClass(eventType, trigger.clazz, hlaClass); - } - - private boolean matchesClass( - StatementTrigger.Type eventType, - String configuredClass, - String actualClass) { - return eventType.isObjectEvent() - ? fomCatalog.isSameOrDescendant(actualClass, configuredClass) - : Objects.equals(configuredClass, actualClass); - } - - public void enqueue(List statements, Consumer statementSink) { - for (StagedStatement statement : statements) { - try { - statementSink.accept(statement.statement()); - } catch (RuntimeException e) { - StatementTrigger trigger = statement.trigger(); - logger.error("Error enqueueing statement for trigger {}.{}", - trigger.type, - trigger.clazz, - e); - } - } - } - - public void dispatch( - StatementTrigger.Type eventType, - String hlaClass, - InjectionContext context, - Consumer statementSink) { - enqueue(stage(eventType, hlaClass, context), statementSink); - } - - public record StagedStatement(StatementTrigger trigger, String statement) { - } -} diff --git a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java index 106fb50..b422d86 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java +++ b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java @@ -1,7 +1,10 @@ package com.yetanalytics.hlaxapi; +import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -15,7 +18,9 @@ import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; +import com.yetanalytics.hlaxapi.cache.FomCatalog; import com.yetanalytics.hlaxapi.cache.ValueResolution; +import com.yetanalytics.hlaxapi.config.XapiConfig; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.injection.InjectionContext; @@ -38,6 +43,12 @@ public class TriggerProcessor { @Autowired private InjectionHandler injectionHandler; + @Autowired + private XapiConfig xapiConfig; + + @Autowired + private FomCatalog fomCatalog; + public TriggerProcessor() { } @@ -46,6 +57,16 @@ public TriggerProcessor(InjectionHandler injectionHandler) { this.injectionHandler = injectionHandler; } + // For tests and non-Spring code that exercise trigger dispatch. + public TriggerProcessor( + XapiConfig xapiConfig, + InjectionHandler injectionHandler, + FomCatalog fomCatalog) { + this.xapiConfig = xapiConfig; + this.injectionHandler = injectionHandler; + this.fomCatalog = fomCatalog; + } + public record TriggerProcessingResult(String statement, boolean matched, boolean success, Throwable error) { private static TriggerProcessingResult emitted(String statement) { @@ -61,6 +82,88 @@ private static TriggerProcessingResult failed(Throwable error) { } } + public record StagedStatement(StatementTrigger trigger, String statement) { + } + + public List stage( + StatementTrigger.Type eventType, + String hlaClass, + InjectionContext context) { + if (xapiConfig.statementTriggers == null) { + return List.of(); + } + List statements = new ArrayList<>(); + for (StatementTrigger trigger : xapiConfig.statementTriggers) { + if (!matchesTrigger(trigger, eventType, hlaClass)) { + continue; + } + try { + logger.trace("Processing {} trigger for {}", eventType, hlaClass); + TriggerProcessingResult result = processTrigger(trigger, context); + if (result == null) { + logger.error("Trigger {}.{} did not produce a processing result", eventType, hlaClass); + } else if (result.success() && result.matched()) { + statements.add(new StagedStatement(trigger, result.statement())); + } else if (!result.success()) { + logger.error("Error processing trigger {}.{}", eventType, hlaClass, result.error()); + } + } catch (RuntimeException e) { + logger.error("Error processing trigger {}.{}", eventType, hlaClass, e); + } + } + return List.copyOf(statements); + } + + public boolean hasMatchingTrigger( + StatementTrigger.Type eventType, + String hlaClass) { + if (xapiConfig.statementTriggers == null) { + return false; + } + return xapiConfig.statementTriggers.stream() + .anyMatch(trigger -> matchesTrigger(trigger, eventType, hlaClass)); + } + + private boolean matchesTrigger( + StatementTrigger trigger, + StatementTrigger.Type eventType, + String hlaClass) { + return trigger != null + && trigger.type == eventType + && matchesClass(eventType, trigger.clazz, hlaClass); + } + + private boolean matchesClass( + StatementTrigger.Type eventType, + String configuredClass, + String actualClass) { + return eventType.isObjectEvent() + ? fomCatalog.isSameOrDescendant(actualClass, configuredClass) + : Objects.equals(configuredClass, actualClass); + } + + public void enqueue(List statements, Consumer statementSink) { + for (StagedStatement statement : statements) { + try { + statementSink.accept(statement.statement()); + } catch (RuntimeException e) { + StatementTrigger trigger = statement.trigger(); + logger.error("Error enqueueing statement for trigger {}.{}", + trigger.type, + trigger.clazz, + e); + } + } + } + + public void dispatch( + StatementTrigger.Type eventType, + String hlaClass, + InjectionContext context, + Consumer statementSink) { + enqueue(stage(eventType, hlaClass, context), statementSink); + } + public TriggerProcessingResult processTrigger(StatementTrigger trigger, InjectionContext context) { return processTrigger(trigger, context, true); } diff --git a/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java b/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java index 87734e1..7f95b01 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java @@ -59,11 +59,8 @@ void interactionCallbackStillRendersAndEnqueuesThroughTheSharedDispatcher() thro setField(hlaInterface, "ambassador", ambassador); setField( hlaInterface, - "triggerDispatcher", - new StatementTriggerDispatcher( - config, - new TriggerProcessor(injectionHandler), - catalog)); + "triggerProcessor", + new TriggerProcessor(config, injectionHandler, catalog)); setField(hlaInterface, "xapiClient", xapiClient); ParameterHandleValueMap parameters = new HLA1516eParameterHandleValueMap(); parameters.put(stepNumber, HLAEncodingTestSupport.int32(42, ByteOrder.BIG_ENDIAN)); diff --git a/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorDispatchTest.java similarity index 82% rename from src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java rename to src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorDispatchTest.java index 09edbf2..31c18f2 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/StatementTriggerDispatcherTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorDispatchTest.java @@ -15,14 +15,14 @@ import org.junit.jupiter.api.Test; import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; -class StatementTriggerDispatcherTest { +class TriggerProcessorDispatchTest { private final FomCatalog catalog = new FomCatalog(new FOMXML( new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"), new HLADecoderRegistry(new HLA1516eEncoderFactory()))); @Test - @SuppressTestLogging({"com.yetanalytics.hlaxapi.StatementTriggerDispatcher"}) + @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) void matchesExactlyStagesOnceAndIsolatesProcessingAndEnqueueFailures() { StatementTrigger first = trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "first"); StatementTrigger wrongType = trigger(StatementTrigger.Type.INTERACTION, "Rabbit", "wrong-type"); @@ -34,21 +34,19 @@ void matchesExactlyStagesOnceAndIsolatesProcessingAndEnqueueFailures() { XapiConfig config = new XapiConfig(); config.statementTriggers = List.of(first, wrongType, wrongClass, skipped, failed, throwsException, second); - ControlledTriggerProcessor processor = new ControlledTriggerProcessor(); - StatementTriggerDispatcher dispatcher = - new StatementTriggerDispatcher(config, processor, catalog); + ControlledTriggerProcessor processor = new ControlledTriggerProcessor(config, catalog); - List staged = dispatcher.stage( + List staged = processor.stage( StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", new ObjectInjectionContext("Rabbit", "object-1", Map.of())); assertEquals(List.of("first", "second"), - staged.stream().map(StatementTriggerDispatcher.StagedStatement::statement).toList()); + staged.stream().map(TriggerProcessor.StagedStatement::statement).toList()); assertEquals(List.of("first", "skip", "fail", "throw", "second"), processor.processed); List enqueued = new ArrayList<>(); - dispatcher.enqueue(staged, statement -> { + processor.enqueue(staged, statement -> { if ("first".equals(statement)) { throw new IllegalStateException("first enqueue failed"); } @@ -59,17 +57,16 @@ void matchesExactlyStagesOnceAndIsolatesProcessingAndEnqueueFailures() { } @Test - void interactionEventsUseTheSameDispatcherWithoutMatchingObjectTriggers() { + void interactionEventsUseTheSameProcessorWithoutMatchingObjectTriggers() { XapiConfig config = new XapiConfig(); config.statementTriggers = List.of( trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "object"), trigger(StatementTrigger.Type.INTERACTION, "SimEntity", "ancestor-interaction"), trigger(StatementTrigger.Type.INTERACTION, "Rabbit", "interaction")); - StatementTriggerDispatcher dispatcher = - new StatementTriggerDispatcher(config, new ControlledTriggerProcessor(), catalog); + TriggerProcessor processor = new ControlledTriggerProcessor(config, catalog); List enqueued = new ArrayList<>(); - dispatcher.dispatch( + processor.dispatch( StatementTrigger.Type.INTERACTION, "Rabbit", new InteractionInjectionContext("Rabbit", Map.of()), @@ -88,20 +85,19 @@ void lifecycleEventsMatchTheirTypeAndFomHierarchy() { trigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity", "sim-entity-delete"), trigger(StatementTrigger.Type.OBJECT_DELETE, "Rabbit", "rabbit-delete"), trigger(StatementTrigger.Type.OBJECT_DELETE, "Wolf", "wolf-delete")); - StatementTriggerDispatcher dispatcher = - new StatementTriggerDispatcher(config, new ControlledTriggerProcessor(), catalog); + TriggerProcessor processor = new ControlledTriggerProcessor(config, catalog); ObjectInjectionContext rabbit = new ObjectInjectionContext("Rabbit", "object-1", Map.of()); - List createStatements = dispatcher + List createStatements = processor .stage(StatementTrigger.Type.OBJECT_CREATE, "Rabbit", rabbit) .stream() - .map(StatementTriggerDispatcher.StagedStatement::statement) + .map(TriggerProcessor.StagedStatement::statement) .toList(); - List deleteStatements = dispatcher + List deleteStatements = processor .stage(StatementTrigger.Type.OBJECT_DELETE, "Rabbit", rabbit) .stream() - .map(StatementTriggerDispatcher.StagedStatement::statement) + .map(TriggerProcessor.StagedStatement::statement) .toList(); assertEquals( @@ -120,15 +116,14 @@ void objectUpdateForBaseClassMatchesConcreteDescendant() { trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "rabbit-update"), trigger(StatementTrigger.Type.OBJECT_UPDATE, "Wolf", "wolf-update"), trigger(StatementTrigger.Type.OBJECT_UPDATE, "MissingObject", "unknown-update")); - StatementTriggerDispatcher dispatcher = - new StatementTriggerDispatcher(config, new ControlledTriggerProcessor(), catalog); + TriggerProcessor processor = new ControlledTriggerProcessor(config, catalog); ObjectInjectionContext rabbit = new ObjectInjectionContext("Rabbit", "object-1", Map.of()); - List updateStatements = dispatcher + List updateStatements = processor .stage(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", rabbit) .stream() - .map(StatementTriggerDispatcher.StagedStatement::statement) + .map(TriggerProcessor.StagedStatement::statement) .toList(); assertEquals( @@ -148,6 +143,10 @@ private static final class ControlledTriggerProcessor extends TriggerProcessor { private final List processed = new ArrayList<>(); + private ControlledTriggerProcessor(XapiConfig config, FomCatalog catalog) { + super(config, new InjectionHandler(), catalog); + } + @Override public TriggerProcessingResult processTrigger( StatementTrigger trigger, diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java index 4a166cc..faa3045 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java @@ -11,7 +11,6 @@ import com.yetanalytics.hlaxapi.HlaInterfaceImpl; import com.yetanalytics.hlaxapi.InjectionHandler; import com.yetanalytics.hlaxapi.SimulationConfig; -import com.yetanalytics.hlaxapi.StatementTriggerDispatcher; import com.yetanalytics.hlaxapi.TriggerProcessor; import com.yetanalytics.hlaxapi.XapiClient; import com.yetanalytics.hlaxapi.config.XapiConfig; @@ -345,8 +344,7 @@ void deletionBeforeFirstReflectionClearsPendingCreate() throws Exception { @Test @SuppressTestLogging({ - "com.yetanalytics.hlaxapi.TriggerProcessor", - "com.yetanalytics.hlaxapi.StatementTriggerDispatcher" + "com.yetanalytics.hlaxapi.TriggerProcessor" }) void firstReflectionConsumesCreateEvenWhenRequiredValuesAreMissing() throws Exception { StatementTrigger required = objectTrigger( @@ -459,8 +457,7 @@ void objectDeleteUsesFinalSnapshotAndEnqueuesAfterRemoval(@TempDir Path tempDir) @Test @SuppressTestLogging({ "com.yetanalytics.hlaxapi.HlaInterfaceImpl", - "com.yetanalytics.hlaxapi.TriggerProcessor", - "com.yetanalytics.hlaxapi.StatementTriggerDispatcher" + "com.yetanalytics.hlaxapi.TriggerProcessor" }) void discoveryRemovalRaceStillDispatchesStaticAndOptionalDeletes(@TempDir Path tempDir) throws Exception { StatementTrigger staticDelete = @@ -597,8 +594,7 @@ void everyLifecycleCallbackOverloadUsesTheCommonPipelines(@TempDir Path tempDir) @Test @SuppressTestLogging({ - "com.yetanalytics.hlaxapi.TriggerProcessor", - "com.yetanalytics.hlaxapi.StatementTriggerDispatcher" + "com.yetanalytics.hlaxapi.TriggerProcessor" }) void eventOnlyReflectionDispatchesMatchingTriggersOnceFromTheCompletePayload() throws Exception { StatementTrigger passing = objectUpdateTrigger( @@ -790,8 +786,7 @@ void previousCriteriaDetectChangesAndThresholdCrossings(@TempDir Path tempDir) t @Test @SuppressTestLogging({ - "com.yetanalytics.hlaxapi.TriggerProcessor", - "com.yetanalytics.hlaxapi.StatementTriggerDispatcher" + "com.yetanalytics.hlaxapi.TriggerProcessor" }) void firstObservationSupportsOptionalPreviousWithoutRetryingRequiredInjections( @TempDir Path tempDir) throws Exception { @@ -1241,11 +1236,8 @@ private HlaInterfaceImpl hlaInterface( setField(hlaInterface, "xapiConfig", config); setField( hlaInterface, - "triggerDispatcher", - new StatementTriggerDispatcher( - config, - new TriggerProcessor(injectionHandler), - catalog)); + "triggerProcessor", + new TriggerProcessor(config, injectionHandler, catalog)); setField(hlaInterface, "xapiClient", xapiClient); return hlaInterface; } From d0ed7c137e100a23d0865b273e228df7188e9eaf Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Mon, 3 Aug 2026 10:55:30 -0400 Subject: [PATCH 30/36] canonical object and interaction hierarchy extraction --- .../java/com/yetanalytics/hlaxapi/FOMXML.java | 79 +++++++- .../hlaxapi/cache/FomCatalog.java | 180 ++++++++++++++++-- .../hlaxapi/cache/FomCatalogTest.java | 61 +++++- .../config/AmbiguousClassNamesFOM.xml | 69 +++++++ 4 files changed, 373 insertions(+), 16 deletions(-) create mode 100644 src/test/resources/config/AmbiguousClassNamesFOM.xml diff --git a/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java b/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java index 3037d6a..88fde68 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java +++ b/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java @@ -252,6 +252,9 @@ public void setDecoderRegistry(HLADecoderRegistry decoderRegistry) { /** * Return the object-class hierarchy as immutable, XML-free definitions. * + *

Class and parent names are canonical, root-relative HLA names. The + * standard {@code HLAobjectRoot} prefix is omitted for its descendants. + * *

Only attributes declared directly on a class are included. Consumers that * need inherited attributes can apply inheritance using {@link * ObjectClassDefinition#parentName()} without accessing the raw FOM document. @@ -280,6 +283,7 @@ private void collectObjectClassDefinitions( if (className == null) { return; } + String canonicalName = canonicalClassName(className, parentName, "HLAobjectRoot"); List attributes = new ArrayList<>(); for (Element attribute : childElements(objectClass, "attribute")) { @@ -289,11 +293,69 @@ private void collectObjectClassDefinitions( attributes.add(new ObjectAttributeDefinition(attributeName, dataType)); } } - definitions.add(new ObjectClassDefinition(className, parentName, attributes)); + definitions.add(new ObjectClassDefinition(canonicalName, parentName, attributes)); for (Element childClass : childElements(objectClass, "objectClass")) { - collectObjectClassDefinitions(childClass, className, definitions); + collectObjectClassDefinitions(childClass, canonicalName, definitions); + } + } + + /** + * Return the interaction-class hierarchy as immutable, XML-free definitions. + * + *

Class and parent names are canonical, root-relative HLA names. The + * standard {@code HLAinteractionRoot} prefix is omitted for its descendants. + * Only parameters declared directly on a class are included. + */ + public List interactionClassDefinitions() { + if (doc == null || doc.getDocumentElement() == null) { + return List.of(); + } + Element interactions = firstChildElement(doc.getDocumentElement(), "interactions"); + if (interactions == null) { + return List.of(); + } + + List definitions = new ArrayList<>(); + for (Element interactionClass : childElements(interactions, "interactionClass")) { + collectInteractionClassDefinitions(interactionClass, null, definitions); + } + return List.copyOf(definitions); + } + + private void collectInteractionClassDefinitions( + Element interactionClass, + String parentName, + List definitions) { + String className = childText(interactionClass, "name"); + if (className == null) { + return; + } + String canonicalName = canonicalClassName(className, parentName, "HLAinteractionRoot"); + + List parameters = new ArrayList<>(); + for (Element parameter : childElements(interactionClass, "parameter")) { + String parameterName = childText(parameter, "name"); + String dataType = childText(parameter, "dataType"); + if (parameterName != null && dataType != null) { + parameters.add(new InteractionParameterDefinition(parameterName, dataType)); + } + } + definitions.add(new InteractionClassDefinition(canonicalName, parentName, parameters)); + + for (Element childClass : childElements(interactionClass, "interactionClass")) { + collectInteractionClassDefinitions(childClass, canonicalName, definitions); + } + } + + private static String canonicalClassName( + String localClassName, + String parentName, + String rootName) { + if (parentName == null || parentName.equals(rootName)) { + return localClassName; } + return parentName + "." + localClassName; } private static String childText(Element parent, String tagName) { @@ -428,4 +490,17 @@ public record ObjectClassDefinition( public record ObjectAttributeDefinition(String name, String dataType) { } + + public record InteractionClassDefinition( + String name, + String parentName, + List parameters) { + + public InteractionClassDefinition { + parameters = List.copyOf(parameters); + } + } + + public record InteractionParameterDefinition(String name, String dataType) { + } } diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java index d547642..502973b 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java @@ -10,17 +10,21 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Function; import javax.xml.xpath.XPathExpressionException; import org.springframework.stereotype.Component; /** - * FOM-derived object metadata used by the SQLite cache. + * Canonical object and interaction metadata derived from the FOM. */ @Component public final class FomCatalog { private final Map classesByName; + private final Map> classesByLocalName; + private final Map interactionsByName; + private final Map> interactionsByLocalName; private final Map classesById; private final Map attributesById; @@ -29,7 +33,15 @@ public FomCatalog(FOMXML fomXml) { for (FOMXML.ObjectClassDefinition definition : fomXml.objectClassDefinitions()) { builder.addObjectClass(definition); } - this.classesByName = builder.classesByName; + for (FOMXML.InteractionClassDefinition definition : fomXml.interactionClassDefinitions()) { + builder.addInteractionClass(definition); + } + this.classesByName = Collections.unmodifiableMap(new LinkedHashMap<>(builder.classesByName)); + this.classesByLocalName = indexByLocalName(classesByName.values(), ObjectClassDef::localName); + this.interactionsByName = + Collections.unmodifiableMap(new LinkedHashMap<>(builder.interactionsByName)); + this.interactionsByLocalName = + indexByLocalName(interactionsByName.values(), InteractionClassDef::localName); Map byId = new LinkedHashMap<>(); Map attrsById = new LinkedHashMap<>(); @@ -47,14 +59,52 @@ public Collection objectClasses() { return classesByName.values(); } + /** + * Resolves a canonical object class name exactly. + */ + public Optional canonicalObjectClass(String name) { + return Optional.ofNullable(classesByName.get(normalizeName(name))); + } + + /** + * Temporary compatibility lookup used while runtime callers migrate to canonical names. + * Canonical names resolve exactly; local names resolve only when globally unique. + */ public Optional objectClass(String name) { - return Optional.ofNullable(classesByName.get(localName(name))); + Optional canonical = canonicalObjectClass(name); + if (canonical.isPresent()) { + return canonical; + } + return uniqueLocalMatch(classesByLocalName.get(localName(name))); } public Optional objectClass(int id) { return Optional.ofNullable(classesById.get(id)); } + public Collection interactionClasses() { + return interactionsByName.values(); + } + + /** + * Resolves a canonical interaction class name exactly. + */ + public Optional canonicalInteractionClass(String name) { + return Optional.ofNullable(interactionsByName.get(normalizeName(name))); + } + + /** + * Temporary compatibility lookup used while runtime callers migrate to canonical names. + * Canonical names resolve exactly; local names resolve only when globally unique. + */ + public Optional interactionClass(String name) { + Optional canonical = canonicalInteractionClass(name); + if (canonical.isPresent()) { + return canonical; + } + return uniqueLocalMatch(interactionsByLocalName.get(localName(name))); + } + public List objectClassAndDescendants(String name) { ObjectClassDef requestedClass = objectClass(name).orElse(null); if (requestedClass == null) { @@ -141,15 +191,37 @@ static String localName(String hlaName) { if (hlaName == null) { return null; } - String trimmed = hlaName.trim(); + String trimmed = normalizeName(hlaName); int index = trimmed.lastIndexOf('.'); return index >= 0 ? trimmed.substring(index + 1) : trimmed; } + private static String normalizeName(String name) { + return name == null ? null : name.trim(); + } + + private static Optional uniqueLocalMatch(List matches) { + return matches != null && matches.size() == 1 + ? Optional.of(matches.get(0)) + : Optional.empty(); + } + + private static Map> indexByLocalName( + Collection values, + Function localName) { + Map> mutable = new LinkedHashMap<>(); + for (T value : values) { + mutable.computeIfAbsent(localName.apply(value), ignored -> new ArrayList<>()).add(value); + } + Map> immutable = new LinkedHashMap<>(); + mutable.forEach((name, matches) -> immutable.put(name, List.copyOf(matches))); + return Collections.unmodifiableMap(immutable); + } + private boolean isSameOrDescendant(ObjectClassDef candidate, ObjectClassDef requestedClass) { ObjectClassDef current = candidate; while (current != null) { - if (current.localName().equals(requestedClass.localName())) { + if (current.hlaName().equals(requestedClass.hlaName())) { return true; } current = classesByName.get(current.parentName()); @@ -206,11 +278,43 @@ public record FomAttribute( boolean leaf) { } + public record InteractionClassDef( + String hlaName, + String localName, + String parentName, + List parameters) { + + public InteractionClassDef { + parameters = List.copyOf(parameters); + } + + public Optional parameter(String pathKey) { + String localPath = pathKey == null ? null : pathKey.trim(); + String wildcardPath = wildcardArrayIndexes(localPath); + for (FomParameter parameter : parameters) { + if (parameter.pathKey().equals(localPath) || parameter.pathKey().equals(wildcardPath)) { + return Optional.of(parameter); + } + } + return Optional.empty(); + } + } + + public record FomParameter( + String parameterName, + String pathKey, + String dataType, + String primitiveType, + boolean leaf) { + } + private static final class CatalogBuilder { private final FOMXML fomXml; private final Map classesByName = new LinkedHashMap<>(); private final Map> attributesByClassName = new LinkedHashMap<>(); + private final Map interactionsByName = new LinkedHashMap<>(); + private final Map> parametersByClassName = new LinkedHashMap<>(); private int nextClassId = 1; private int nextAttributeId = 1; @@ -220,11 +324,6 @@ private CatalogBuilder(FOMXML fomXml) { private void addObjectClass(FOMXML.ObjectClassDefinition definition) { String localClassName = localName(definition.name()); - String localParentName = localName(definition.parentName()); - ObjectClassDef parentClass = classesByName.get(localParentName); - String hlaName = parentClass == null || "HLAobjectRoot".equals(parentClass.localName()) - ? localClassName - : parentClass.hlaName() + "." + localClassName; List allAttributes = new ArrayList<>(); if (definition.parentName() != null) { @@ -244,11 +343,33 @@ private void addObjectClass(FOMXML.ObjectClassDefinition definition) { ObjectClassDef classDef = new ObjectClassDef( classId, - hlaName, + definition.name(), localClassName, - localParentName, + definition.parentName(), flattened); - classesByName.put(classDef.localName(), classDef); + classesByName.put(classDef.hlaName(), classDef); + } + + private void addInteractionClass(FOMXML.InteractionClassDefinition definition) { + List allParameters = new ArrayList<>(); + if (definition.parentName() != null) { + allParameters.addAll(parametersByClassName.getOrDefault(definition.parentName(), List.of())); + } + for (FOMXML.InteractionParameterDefinition parameter : definition.parameters()) { + allParameters.add(new ParameterSource(parameter.name(), parameter.dataType())); + } + parametersByClassName.put(definition.name(), List.copyOf(allParameters)); + + List flattened = new ArrayList<>(); + for (ParameterSource parameter : allParameters) { + flattenParameter(parameter.name(), parameter.name(), parameter.dataType(), flattened); + } + InteractionClassDef classDef = new InteractionClassDef( + definition.name(), + localName(definition.name()), + definition.parentName(), + flattened); + interactionsByName.put(classDef.hlaName(), classDef); } private void flattenAttribute( @@ -285,6 +406,36 @@ private void flattenAttribute( } } + private void flattenParameter( + String parameterName, + String pathKey, + String dataType, + List parameters) { + String primitive = primitiveType(dataType); + List fields = fixedRecordFields(dataType); + String arrayElementType = arrayElementType(dataType); + boolean leaf = primitive != null || fields.isEmpty() && arrayElementType == null; + + parameters.add(new FomParameter( + parameterName, + pathKey, + dataType, + primitive, + leaf)); + + if (!fields.isEmpty()) { + for (FOMXML.FixedRecordField field : fields) { + flattenParameter( + parameterName, + pathKey + "." + field.name, + field.dataType, + parameters); + } + } else if (arrayElementType != null) { + flattenParameter(parameterName, pathKey + "[]", arrayElementType, parameters); + } + } + private String primitiveType(String dataType) { try { return fomXml.resolvePrimitiveType(dataType); @@ -318,5 +469,8 @@ private String arrayElementType(String dataType) { private record AttributeSource(String name, String dataType) { } + + private record ParameterSource(String name, String dataType) { + } } } diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java index a8b3612..075b1db 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java @@ -94,6 +94,57 @@ void buildsRtiObjectClassNamesRelativeToHlaObjectRoot() { assertEquals("SimEntity.Wolf", catalog.objectClass("Wolf").orElseThrow().hlaName()); } + @Test + void indexesObjectAndInteractionClassesByCanonicalNameWithoutLocalCollisions() { + FomCatalog catalog = catalog("src/test/resources/config/AmbiguousClassNamesFOM.xml"); + + FomCatalog.ObjectClassDef entityRabbit = + catalog.canonicalObjectClass("SimEntity.Rabbit").orElseThrow(); + FomCatalog.ObjectClassDef otherRabbit = + catalog.canonicalObjectClass("SomeOtherSuperclass.Rabbit").orElseThrow(); + + assertEquals("SimEntity", entityRabbit.parentName()); + assertTrue(entityRabbit.attribute("EntityId").isPresent()); + assertTrue(entityRabbit.attribute("Hunger").isPresent()); + assertFalse(entityRabbit.attribute("OtherId").isPresent()); + assertEquals("SomeOtherSuperclass", otherRabbit.parentName()); + assertTrue(otherRabbit.attribute("OtherId").isPresent()); + assertTrue(otherRabbit.attribute("Speed").isPresent()); + assertFalse(otherRabbit.attribute("EntityId").isPresent()); + assertTrue(catalog.objectClass("Rabbit").isEmpty()); + + FomCatalog.InteractionClassDef entityUpdated = + catalog.canonicalInteractionClass("EntityEvents.Updated").orElseThrow(); + FomCatalog.InteractionClassDef otherUpdated = + catalog.canonicalInteractionClass("OtherEvents.Updated").orElseThrow(); + + assertEquals("EntityEvents", entityUpdated.parentName()); + assertTrue(entityUpdated.parameter("EntityId").isPresent()); + assertTrue(entityUpdated.parameter("Hunger").isPresent()); + assertFalse(entityUpdated.parameter("OtherId").isPresent()); + assertEquals("OtherEvents", otherUpdated.parentName()); + assertTrue(otherUpdated.parameter("OtherId").isPresent()); + assertTrue(otherUpdated.parameter("Speed").isPresent()); + assertFalse(otherUpdated.parameter("EntityId").isPresent()); + assertTrue(catalog.interactionClass("Updated").isEmpty()); + } + + @Test + void flattensInteractionParametersAndRetainsTemporaryUniqueLocalLookup() { + FomCatalog catalog = catalog("config/HlaFedereplFOM.xml"); + + FomCatalog.InteractionClassDef entityMoved = + catalog.interactionClass("EntityMoved").orElseThrow(); + + assertEquals("EntityMoved", entityMoved.hlaName()); + assertEquals( + "HLAinteger32BE", + entityMoved.parameter("FromPosition.X").orElseThrow().primitiveType()); + assertEquals( + "GridPosition", + entityMoved.parameter("FromPosition").orElseThrow().dataType()); + } + @Test void fomXmlReturnsHierarchyWithDeclaredAttributes() { FOMXML fomXml = fomXml("config/HlaFedereplFOM.xml"); @@ -103,7 +154,7 @@ void fomXmlReturnsHierarchyWithDeclaredAttributes() { .findFirst() .orElseThrow(); FOMXML.ObjectClassDefinition rabbit = fomXml.objectClassDefinitions().stream() - .filter(definition -> definition.name().equals("Rabbit")) + .filter(definition -> definition.name().equals("SimEntity.Rabbit")) .findFirst() .orElseThrow(); @@ -114,6 +165,14 @@ void fomXmlReturnsHierarchyWithDeclaredAttributes() { assertEquals(List.of("Hunger"), rabbit.attributes().stream() .map(FOMXML.ObjectAttributeDefinition::name) .toList()); + + FOMXML.InteractionClassDefinition entityMoved = fomXml.interactionClassDefinitions().stream() + .filter(definition -> definition.name().equals("EntityMoved")) + .findFirst() + .orElseThrow(); + assertEquals("HLAinteractionRoot", entityMoved.parentName()); + assertTrue(entityMoved.parameters().stream() + .anyMatch(parameter -> parameter.name().equals("FromPosition"))); } @Test diff --git a/src/test/resources/config/AmbiguousClassNamesFOM.xml b/src/test/resources/config/AmbiguousClassNamesFOM.xml new file mode 100644 index 0000000..ecbdda6 --- /dev/null +++ b/src/test/resources/config/AmbiguousClassNamesFOM.xml @@ -0,0 +1,69 @@ + + + + + HLAobjectRoot + + SimEntity + + EntityId + HLAinteger32BE + + + Rabbit + + Hunger + HLAinteger32BE + + + + + SomeOtherSuperclass + + OtherId + HLAinteger32BE + + + Rabbit + + Speed + HLAinteger32BE + + + + + + + + HLAinteractionRoot + + EntityEvents + + EntityId + HLAinteger32BE + + + Updated + + Hunger + HLAinteger32BE + + + + + OtherEvents + + OtherId + HLAinteger32BE + + + Updated + + Speed + HLAinteger32BE + + + + + + From 5e42565c9edd24406807ac1ee395f9c26f44eb85 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Mon, 3 Aug 2026 11:23:58 -0400 Subject: [PATCH 31/36] use canonical names in config --- config/xapi-config.json | 4 ++-- src/test/java/com/yetanalytics/ConfigParserTest.java | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/config/xapi-config.json b/config/xapi-config.json index f9c116f..a41870d 100644 --- a/config/xapi-config.json +++ b/config/xapi-config.json @@ -81,7 +81,7 @@ }, { "type": "ObjectCreate", - "class": "Rabbit", + "class": "SimEntity.Rabbit", "statement": { "actor": { "objectType": "Agent", @@ -141,7 +141,7 @@ }, { "type": "ObjectDelete", - "class": "Rabbit", + "class": "SimEntity.Rabbit", "statement": { "actor": { "objectType": "Agent", diff --git a/src/test/java/com/yetanalytics/ConfigParserTest.java b/src/test/java/com/yetanalytics/ConfigParserTest.java index ad03850..5bf5f30 100644 --- a/src/test/java/com/yetanalytics/ConfigParserTest.java +++ b/src/test/java/com/yetanalytics/ConfigParserTest.java @@ -148,9 +148,9 @@ public void parsesObjectLifecycleTriggerTypes(@TempDir Path tempDir) throws IOEx Files.writeString(configPath, """ { "statementTriggers": [ - {"type":"ObjectCreate","class":"Rabbit","statement":{}}, - {"type":"objectUpdate","class":"Rabbit","statement":{}}, - {"type":"OBJECTDELETE","class":"Rabbit","statement":{}} + {"type":"ObjectCreate","class":"SimEntity.Rabbit","statement":{}}, + {"type":"objectUpdate","class":"SimEntity.Rabbit","statement":{}}, + {"type":"OBJECTDELETE","class":"SimEntity.Rabbit","statement":{}} ] } """); @@ -201,7 +201,7 @@ public void parsesPreviousCriteriaOnlyForObjectUpdate(@TempDir Path tempDir) thr { "statementTriggers": [{ "type": "ObjectUpdate", - "class": "Rabbit", + "class": "SimEntity.Rabbit", "criteria": [["previous", ["Hunger"]], "<", ["trigger", ["Hunger"]]], "statement": {} }] @@ -260,7 +260,7 @@ public void parsesObjectCacheTrackedObjects(@TempDir Path tempDir) throws IOExce { "objectCache": { "trackedObjects": [ - {"class": "Rabbit", "attributes": ["EntityId", "Hunger"]}, + {"class": "SimEntity.Rabbit", "attributes": ["EntityId", "Hunger"]}, {"class": "World", "allAttributes": true}, {"class": "*", "allAttributes": true} ] @@ -273,7 +273,7 @@ public void parsesObjectCacheTrackedObjects(@TempDir Path tempDir) throws IOExce assertNotNull(config.objectCacheConfig); assertNotNull(config.objectCacheConfig.trackedObjects); assertEquals(3, config.objectCacheConfig.trackedObjects.size()); - assertEquals("Rabbit", config.objectCacheConfig.trackedObjects.get(0).clazz); + assertEquals("SimEntity.Rabbit", config.objectCacheConfig.trackedObjects.get(0).clazz); assertEquals(List.of("EntityId", "Hunger"), config.objectCacheConfig.trackedObjects.get(0).attributes); assertTrue(config.objectCacheConfig.trackedObjects.get(1).allAttributes); assertEquals("*", config.objectCacheConfig.trackedObjects.get(2).clazz); From 37ac4ea06813825d5b1adb7c5cbaa4d79de77c56 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Mon, 3 Aug 2026 11:43:36 -0400 Subject: [PATCH 32/36] remove local_name and make hla_name unique, use for all cache purposes --- .../hlaxapi/HlaInterfaceImpl.java | 13 +- .../hlaxapi/cache/FomCatalog.java | 37 +-- .../hlaxapi/cache/JdbcObjectCacheStore.java | 11 +- .../hlaxapi/cache/ObjectCache.java | 2 +- .../hlaxapi/cache/ObjectSubscriptionPlan.java | 12 +- .../cache/PostgresqlObjectCacheQueries.java | 11 +- .../cache/SqliteObjectCacheQueries.java | 13 +- .../hlaxapi/ObjectInjectionHandlerTest.java | 22 +- .../hlaxapi/TriggerProcessorCriteriaTest.java | 10 +- .../hlaxapi/TriggerProcessorDispatchTest.java | 48 ++-- .../hlaxapi/cache/FomCatalogTest.java | 34 +-- .../cache/HlaObjectSubscriptionTest.java | 247 +++++++++++------- .../cache/ObjectCachePersistenceTest.java | 146 ++++++++--- .../hlaxapi/cache/ObjectCacheTest.java | 84 +++--- .../cache/ObjectSubscriptionPlanTest.java | 22 +- .../PostgresqlObjectCachePersistenceTest.java | 2 +- .../cache/QueryReferenceCollectorTest.java | 20 +- .../SqliteObjectCachePersistenceTest.java | 2 +- 18 files changed, 429 insertions(+), 307 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index a69d30e..f983449 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -85,6 +85,7 @@ public class HlaInterfaceImpl extends NullFederateAmbassador implements HlaInterface { private static final Logger logger = LogManager.getLogger(HlaInterfaceImpl.class); + private static final String OBJECT_ROOT_PREFIX = "HLAobjectRoot."; private RTIambassador ambassador; @@ -275,7 +276,8 @@ public void discoverObjectInstance( } String className; try { - className = StringUtils.substringAfterLast(ambassador.getObjectClassName(theObjectClass), "."); + className = rootRelativeObjectClassName( + ambassador.getObjectClassName(theObjectClass)); } catch (InvalidObjectClassHandle | FederateNotExecutionMember | NotConnected | RTIinternalError e) { logger.error("Error resolving discovered object {}", objectName, e); return; @@ -366,7 +368,8 @@ private void reflectAttributeValues(ObjectInstanceHandle theObject, AttributeHan } try { ObjectClassHandle classHandle = ambassador.getKnownObjectClassHandle(theObject); - String className = StringUtils.substringAfterLast(ambassador.getObjectClassName(classHandle), "."); + String className = rootRelativeObjectClassName( + ambassador.getObjectClassName(classHandle)); Map attributes = new HashMap<>(); for (AttributeHandle attributeHandle : theAttributes.keySet()) { String attributeName = ambassador.getAttributeName(classHandle, attributeHandle); @@ -458,6 +461,12 @@ private void removeCachedObject(ObjectInstanceHandle theObject) { } } + private String rootRelativeObjectClassName(String className) { + return className != null && className.startsWith(OBJECT_ROOT_PREFIX) + ? className.substring(OBJECT_ROOT_PREFIX.length()) + : className; + } + /* * Interactions */ diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java index 502973b..3b4e043 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java @@ -22,9 +22,8 @@ public final class FomCatalog { private final Map classesByName; - private final Map> classesByLocalName; private final Map interactionsByName; - private final Map> interactionsByLocalName; + private final Map> interactionsByShortName; private final Map classesById; private final Map attributesById; @@ -37,11 +36,12 @@ public FomCatalog(FOMXML fomXml) { builder.addInteractionClass(definition); } this.classesByName = Collections.unmodifiableMap(new LinkedHashMap<>(builder.classesByName)); - this.classesByLocalName = indexByLocalName(classesByName.values(), ObjectClassDef::localName); this.interactionsByName = Collections.unmodifiableMap(new LinkedHashMap<>(builder.interactionsByName)); - this.interactionsByLocalName = - indexByLocalName(interactionsByName.values(), InteractionClassDef::localName); + this.interactionsByShortName = + indexByShortName( + interactionsByName.values(), + definition -> shortName(definition.hlaName())); Map byId = new LinkedHashMap<>(); Map attrsById = new LinkedHashMap<>(); @@ -63,19 +63,14 @@ public Collection objectClasses() { * Resolves a canonical object class name exactly. */ public Optional canonicalObjectClass(String name) { - return Optional.ofNullable(classesByName.get(normalizeName(name))); + return Optional.ofNullable(classesByName.get(name)); } /** - * Temporary compatibility lookup used while runtime callers migrate to canonical names. - * Canonical names resolve exactly; local names resolve only when globally unique. + * Resolves an object class by its exact canonical name. */ public Optional objectClass(String name) { - Optional canonical = canonicalObjectClass(name); - if (canonical.isPresent()) { - return canonical; - } - return uniqueLocalMatch(classesByLocalName.get(localName(name))); + return canonicalObjectClass(name); } public Optional objectClass(int id) { @@ -102,7 +97,7 @@ public Optional interactionClass(String name) { if (canonical.isPresent()) { return canonical; } - return uniqueLocalMatch(interactionsByLocalName.get(localName(name))); + return uniqueLocalMatch(interactionsByShortName.get(shortName(name))); } public List objectClassAndDescendants(String name) { @@ -187,7 +182,7 @@ public static String wildcardArrayIndexes(String pathKey) { return pathKey.replaceAll("\\[[0-9]+\\]", "[]"); } - static String localName(String hlaName) { + static String shortName(String hlaName) { if (hlaName == null) { return null; } @@ -206,12 +201,12 @@ private static Optional uniqueLocalMatch(List matches) { : Optional.empty(); } - private static Map> indexByLocalName( + private static Map> indexByShortName( Collection values, - Function localName) { + Function shortName) { Map> mutable = new LinkedHashMap<>(); for (T value : values) { - mutable.computeIfAbsent(localName.apply(value), ignored -> new ArrayList<>()).add(value); + mutable.computeIfAbsent(shortName.apply(value), ignored -> new ArrayList<>()).add(value); } Map> immutable = new LinkedHashMap<>(); mutable.forEach((name, matches) -> immutable.put(name, List.copyOf(matches))); @@ -232,7 +227,6 @@ private boolean isSameOrDescendant(ObjectClassDef candidate, ObjectClassDef requ public record ObjectClassDef( int id, String hlaName, - String localName, String parentName, List attributes) { @@ -280,7 +274,6 @@ public record FomAttribute( public record InteractionClassDef( String hlaName, - String localName, String parentName, List parameters) { @@ -323,8 +316,6 @@ private CatalogBuilder(FOMXML fomXml) { } private void addObjectClass(FOMXML.ObjectClassDefinition definition) { - String localClassName = localName(definition.name()); - List allAttributes = new ArrayList<>(); if (definition.parentName() != null) { allAttributes.addAll(attributesByClassName.getOrDefault(definition.parentName(), List.of())); @@ -344,7 +335,6 @@ private void addObjectClass(FOMXML.ObjectClassDefinition definition) { new ObjectClassDef( classId, definition.name(), - localClassName, definition.parentName(), flattened); classesByName.put(classDef.hlaName(), classDef); @@ -366,7 +356,6 @@ private void addInteractionClass(FOMXML.InteractionClassDefinition definition) { } InteractionClassDef classDef = new InteractionClassDef( definition.name(), - localName(definition.name()), definition.parentName(), flattened); interactionsByName.put(classDef.hlaName(), classDef); diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java b/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java index 4025f92..a4ae6ef 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java @@ -17,7 +17,7 @@ final class JdbcObjectCacheStore implements ObjectCacheStore { - private static final int SCHEMA_VERSION = 1; + private static final int SCHEMA_VERSION = 2; private final ObjectCacheQueries queries; private final ObjectMapper mapper = new ObjectMapper(); @@ -45,7 +45,7 @@ public CachedObject ensureObject( statement.setInt(3, clazz.id()); statement.setString(4, java.time.Instant.now().toString()); statement.executeUpdate(); - return loadObject(objectHandle, clazz.localName()); + return loadObject(objectHandle, clazz.hlaName()); } catch (SQLException e) { throw new IllegalStateException("Could not upsert object instance " + objectHandle, e); } @@ -63,7 +63,7 @@ public Optional findCurrentObjectSnapshot(String objectHandle) { while (resultSet.next()) { found = true; objectName = resultSet.getString("object_name"); - className = resultSet.getString("local_name"); + className = resultSet.getString("hla_name"); String attributeName = resultSet.getString("attribute_name"); byte[] rawBytes = resultSet.getBytes("raw_bytes"); if (attributeName != null && rawBytes != null) { @@ -141,7 +141,7 @@ public List currentObjects(List classes resultSet.getLong("id"), resultSet.getString("object_handle"), resultSet.getString("object_name"), - resultSet.getString("local_name"))); + resultSet.getString("hla_name"))); } } } catch (SQLException e) { @@ -248,8 +248,7 @@ private void insertClasses(FomCatalog catalog) throws SQLException { for (FomCatalog.ObjectClassDef clazz : catalog.objectClasses()) { statement.setInt(1, clazz.id()); statement.setString(2, clazz.hlaName()); - statement.setString(3, clazz.localName()); - statement.setString(4, clazz.parentName()); + statement.setString(3, clazz.parentName()); statement.addBatch(); } statement.executeBatch(); diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java index 4384e45..972df04 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java @@ -225,7 +225,7 @@ public synchronized List currentObjects(String className) { } FomCatalog.ObjectClassDef requestedClass = requireClass(className); return store.currentObjects( - catalog.objectClassAndDescendants(requestedClass.localName())); + catalog.objectClassAndDescendants(requestedClass.hlaName())); } Connection connection() { diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlan.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlan.java index b3e16fe..f776b6f 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlan.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlan.java @@ -67,11 +67,11 @@ Set effectiveAttributes(String className) { LinkedHashSet attributes = new LinkedHashSet<>(); FomCatalog.ObjectClassDef current = catalog.objectClass(className).orElse(null); if (current == null) { - addAttributes(attributes, subscriptions.get(FomCatalog.localName(className))); + addAttributes(attributes, subscriptions.get(className)); return Set.copyOf(attributes); } while (current != null) { - addAttributes(attributes, subscriptions.get(current.localName())); + addAttributes(attributes, subscriptions.get(current.hlaName())); current = catalog.objectClass(current.parentName()).orElse(null); } return Set.copyOf(attributes); @@ -153,12 +153,12 @@ private static void addTrackedObjects( || trackedObject.clazz.isBlank()) { continue; } - if ("*".equals(trackedObject.clazz.trim())) { + if ("*".equals(trackedObject.clazz)) { if (trackedObject.allAttributes) { catalog.objectClasses().forEach(clazz -> addAttributes( merged, - clazz.localName(), + clazz.hlaName(), clazz.topLevelAttributeNames())); } continue; @@ -190,7 +190,7 @@ private static void addReferencedAttributesForClassAndDescendants( return; } classes.forEach(clazz -> - addAttributes(subscriptions, clazz.localName(), attributes)); + addAttributes(subscriptions, clazz.hlaName(), attributes)); } private static void addAllAttributesForClassAndDescendants( @@ -208,7 +208,7 @@ private static void addAllAttributesForClassAndDescendants( classes.forEach(clazz -> addAttributes( subscriptions, - clazz.localName(), + clazz.hlaName(), clazz.topLevelAttributeNames())); } diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java b/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java index ce5c0b0..c5e171d 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java @@ -44,8 +44,7 @@ CREATE TABLE object_cache_metadata ( """ CREATE TABLE fom_object_class ( id INTEGER PRIMARY KEY, - hla_name TEXT NOT NULL, - local_name TEXT NOT NULL UNIQUE, + hla_name TEXT NOT NULL UNIQUE, parent_name TEXT ) """, @@ -94,8 +93,8 @@ public String insertSchemaVersion() { @Override public String insertClass() { return """ - INSERT INTO fom_object_class (id, hla_name, local_name, parent_name) - VALUES (?, ?, ?, ?) + INSERT INTO fom_object_class (id, hla_name, parent_name) + VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING """; } @@ -140,7 +139,7 @@ public String loadObject() { @Override public String loadCurrentObjectSnapshot() { return """ - SELECT i.object_handle, i.object_name, c.local_name, a.attribute_name, v.raw_bytes + SELECT i.object_handle, i.object_name, c.hla_name, a.attribute_name, v.raw_bytes FROM object_instance i JOIN fom_object_class c ON c.id = i.class_id LEFT JOIN object_attribute_current v @@ -183,7 +182,7 @@ public String findObjectId() { public String listCurrentObjects(int classCount) { String placeholders = String.join(", ", java.util.Collections.nCopies(classCount, "?")); return """ - SELECT i.id, i.object_handle, i.object_name, c.local_name + SELECT i.id, i.object_handle, i.object_name, c.hla_name FROM object_instance i JOIN fom_object_class c ON c.id = i.class_id WHERE i.class_id IN (%s) AND i.removed_at IS NULL diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java b/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java index 0910441..c7a8b12 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java @@ -36,8 +36,7 @@ CREATE TABLE object_cache_metadata ( """ CREATE TABLE fom_object_class ( id INTEGER PRIMARY KEY, - hla_name TEXT NOT NULL, - local_name TEXT NOT NULL UNIQUE, + hla_name TEXT NOT NULL UNIQUE, parent_name TEXT ) """, @@ -76,7 +75,7 @@ attribute_id INTEGER NOT NULL REFERENCES fom_attribute(id), PRIMARY KEY(instance_id, attribute_id) ) """, - "PRAGMA user_version = 1"); + "PRAGMA user_version = 2"); } @Override @@ -87,8 +86,8 @@ public String insertSchemaVersion() { @Override public String insertClass() { return """ - INSERT OR IGNORE INTO fom_object_class (id, hla_name, local_name, parent_name) - VALUES (?, ?, ?, ?) + INSERT OR IGNORE INTO fom_object_class (id, hla_name, parent_name) + VALUES (?, ?, ?) """; } @@ -126,7 +125,7 @@ public String loadObject() { @Override public String loadCurrentObjectSnapshot() { return """ - SELECT i.object_handle, i.object_name, c.local_name, a.attribute_name, v.raw_bytes + SELECT i.object_handle, i.object_name, c.hla_name, a.attribute_name, v.raw_bytes FROM object_instance i JOIN fom_object_class c ON c.id = i.class_id LEFT JOIN object_attribute_current v @@ -169,7 +168,7 @@ public String findObjectId() { public String listCurrentObjects(int classCount) { String placeholders = String.join(", ", java.util.Collections.nCopies(classCount, "?")); return """ - SELECT i.id, i.object_handle, i.object_name, c.local_name + SELECT i.id, i.object_handle, i.object_name, c.hla_name FROM object_instance i JOIN fom_object_class c ON c.id = i.class_id WHERE i.class_id IN (%s) AND i.removed_at IS NULL diff --git a/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java index 3a4d47a..19addc6 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java @@ -34,9 +34,9 @@ class ObjectInjectionHandlerTest { void objectContextCarriesClassHandleAndIncomingAttributes() { byte[] count = HLAEncodingTestSupport.int32(4, ByteOrder.BIG_ENDIAN); ObjectInjectionContext context = - new ObjectInjectionContext("TrackedEntity", "object-17", Map.of("Count", count)); + new ObjectInjectionContext("BaseEntity.TrackedEntity", "object-17", Map.of("Count", count)); - assertEquals("TrackedEntity", context.getHlaClass()); + assertEquals("BaseEntity.TrackedEntity", context.getHlaClass()); assertEquals("object-17", context.getObjectHandle()); assertSame(count, context.getAttributeMap().get("Count")); } @@ -47,7 +47,7 @@ void decodesInheritedPrimitiveFixedRecordAndArrayPaths() { byte[] position = position(12, 18); byte[] history = HLAEncodingTestSupport.variableArray(position(1, 2), position(3, 4)); ObjectInjectionContext context = new ObjectInjectionContext( - "TrackedEntity", + "BaseEntity.TrackedEntity", "object-17", Map.of( "EntityId", HLAEncodingTestSupport.asciiString("entity-17"), @@ -72,11 +72,11 @@ void reportsAbsentAndMalformedObjectAttributesAsMissingValues() { ValueResolution absent = handler.handleTrigger( target("Count"), - new ObjectInjectionContext("TrackedEntity", "object-17", Map.of())); + new ObjectInjectionContext("BaseEntity.TrackedEntity", "object-17", Map.of())); ValueResolution malformed = handler.handleTrigger( target("Count"), new ObjectInjectionContext( - "TrackedEntity", + "BaseEntity.TrackedEntity", "object-17", Map.of("Count", new byte[] {1}))); @@ -127,7 +127,7 @@ void validatesEveryObjectEventTargetAgainstInheritedObjectAttributes() { {"object":{"id":["trigger",["Count"]]}} """); wrongType.type = type; - TestInjectionContext context = new TestInjectionContext(type, "TrackedEntity"); + TestInjectionContext context = new TestInjectionContext(type, "BaseEntity.TrackedEntity"); TriggerProcessor.TriggerProcessingResult validResult = processor.renderTemplateForValidation(valid, context); @@ -145,7 +145,7 @@ void validatesEveryObjectEventTargetAgainstInheritedObjectAttributes() { void rejectsMissingObjectTargetsInStatementsAndCriteriaEvenWhenOptional() { TriggerProcessor processor = new TriggerProcessor(handler(OBJECT_FOM)); TestInjectionContext context = - new TestInjectionContext(StatementTrigger.Type.OBJECT_UPDATE, "TrackedEntity"); + new TestInjectionContext(StatementTrigger.Type.OBJECT_UPDATE, "BaseEntity.TrackedEntity"); StatementTrigger missingTrigger = trigger(""" {"missing":["trigger",["NotAnAttribute"],{"required":false}]} """); @@ -172,7 +172,7 @@ void rejectsMissingObjectTargetsInStatementsAndCriteriaEvenWhenOptional() { void validatesQueryAndLookupPathsAgainstTheirReferencedObjectClasses() { TriggerProcessor processor = new TriggerProcessor(handler(OBJECT_FOM)); TestInjectionContext context = - new TestInjectionContext(StatementTrigger.Type.OBJECT_UPDATE, "TrackedEntity"); + new TestInjectionContext(StatementTrigger.Type.OBJECT_UPDATE, "BaseEntity.TrackedEntity"); StatementTrigger valid = trigger(""" { "result":{"score":{"raw":["query","BaseEntity",["Position","X"],null]}}, @@ -253,7 +253,7 @@ void validatesPreviousOnlyForObjectUpdateTemplates() { TriggerProcessor.TriggerProcessingResult result = processor.renderTemplateForValidation( trigger, - new TestInjectionContext(type, "TrackedEntity")); + new TestInjectionContext(type, "BaseEntity.TrackedEntity")); assertEquals(type == StatementTrigger.Type.OBJECT_UPDATE, result.success(), type.toString()); } @@ -280,7 +280,7 @@ private StatementTrigger trigger( Expression criteria) { StatementTrigger trigger = new StatementTrigger(); trigger.type = StatementTrigger.Type.OBJECT_UPDATE; - trigger.clazz = "TrackedEntity"; + trigger.clazz = "BaseEntity.TrackedEntity"; trigger.criteria = criteria; trigger.statement = statement; return trigger; @@ -307,7 +307,7 @@ private void assertOptionalMalformedValue( TriggerProcessor.TriggerProcessingResult result = processor.processTrigger( trigger, - new ObjectInjectionContext("TrackedEntity", "object-17", attributes)); + new ObjectInjectionContext("BaseEntity.TrackedEntity", "object-17", attributes)); assertTrue(result.success(), type + " " + target); assertEquals("{\"value\":null}", result.statement(), type + " " + target); diff --git a/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java index f5b928c..e84f142 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java @@ -74,7 +74,7 @@ public Optional resolveLookup(ObjectLookup lookup, InjectionContex ComparisonOperator.GT, new ValueExpression(10)))); StatementTrigger trigger = trigger(criteria, "{\"actor\":{\"name\":[\"lookup\",\"subject\",[\"Name\"]]}}"); - trigger.lookups = Map.of("subject", lookup("Rabbit")); + trigger.lookups = Map.of("subject", lookup("SimEntity.Rabbit")); TriggerProcessingResult result = new TriggerProcessor(handler).processTrigger(trigger, context()); @@ -86,7 +86,7 @@ public Optional resolveLookup(ObjectLookup lookup, InjectionContex @Test void oneLookupObjectIsSharedByCriteriaAndStatementRendering() { AtomicInteger lookupLoads = new AtomicInteger(); - CachedObject rabbit = new CachedObject(7, "handle-7", "rabbit-7", "Rabbit"); + CachedObject rabbit = new CachedObject(7, "handle-7", "rabbit-7", "SimEntity.Rabbit"); InjectionHandler handler = new InjectionHandler() { @Override public Optional resolveLookup(ObjectLookup lookup, InjectionContext context) { @@ -108,7 +108,7 @@ public ValueResolution handleLookup(CachedObject object, Target target, Injectio ComparisonOperator.GT, new ValueExpression(50)), "{\"actor\":{\"name\":[\"lookup\",\"subject\",[\"EntityId\"]]}}"); - trigger.lookups = Map.of("subject", lookup("Rabbit")); + trigger.lookups = Map.of("subject", lookup("SimEntity.Rabbit")); TriggerProcessingResult result = new TriggerProcessor(handler).processTrigger(trigger, context()); @@ -147,9 +147,9 @@ public ValueResolution handleQuery( List.of( equalsNull(new LookupExpression("subject", target("First"))), equalsNull(new LookupExpression("subject", target("Second"))), - equalsNull(new QueryExpression("Rabbit", target("Nickname"), null)))), + equalsNull(new QueryExpression("SimEntity.Rabbit", target("Nickname"), null)))), "{}"); - trigger.lookups = Map.of("subject", lookup("Rabbit")); + trigger.lookups = Map.of("subject", lookup("SimEntity.Rabbit")); TriggerProcessingResult result = new TriggerProcessor(handler).processTrigger(trigger, context()); diff --git a/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorDispatchTest.java b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorDispatchTest.java index 31c18f2..0c1d1aa 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorDispatchTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorDispatchTest.java @@ -24,13 +24,13 @@ class TriggerProcessorDispatchTest { @Test @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) void matchesExactlyStagesOnceAndIsolatesProcessingAndEnqueueFailures() { - StatementTrigger first = trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "first"); - StatementTrigger wrongType = trigger(StatementTrigger.Type.INTERACTION, "Rabbit", "wrong-type"); - StatementTrigger wrongClass = trigger(StatementTrigger.Type.OBJECT_UPDATE, "Wolf", "wrong-class"); - StatementTrigger skipped = trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "skip"); - StatementTrigger failed = trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "fail"); - StatementTrigger throwsException = trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "throw"); - StatementTrigger second = trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "second"); + StatementTrigger first = trigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity.Rabbit", "first"); + StatementTrigger wrongType = trigger(StatementTrigger.Type.INTERACTION, "SimEntity.Rabbit", "wrong-type"); + StatementTrigger wrongClass = trigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity.Wolf", "wrong-class"); + StatementTrigger skipped = trigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity.Rabbit", "skip"); + StatementTrigger failed = trigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity.Rabbit", "fail"); + StatementTrigger throwsException = trigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity.Rabbit", "throw"); + StatementTrigger second = trigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity.Rabbit", "second"); XapiConfig config = new XapiConfig(); config.statementTriggers = List.of(first, wrongType, wrongClass, skipped, failed, throwsException, second); @@ -38,8 +38,8 @@ void matchesExactlyStagesOnceAndIsolatesProcessingAndEnqueueFailures() { List staged = processor.stage( StatementTrigger.Type.OBJECT_UPDATE, - "Rabbit", - new ObjectInjectionContext("Rabbit", "object-1", Map.of())); + "SimEntity.Rabbit", + new ObjectInjectionContext("SimEntity.Rabbit", "object-1", Map.of())); assertEquals(List.of("first", "second"), staged.stream().map(TriggerProcessor.StagedStatement::statement).toList()); @@ -60,16 +60,16 @@ void matchesExactlyStagesOnceAndIsolatesProcessingAndEnqueueFailures() { void interactionEventsUseTheSameProcessorWithoutMatchingObjectTriggers() { XapiConfig config = new XapiConfig(); config.statementTriggers = List.of( - trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "object"), + trigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity.Rabbit", "object"), trigger(StatementTrigger.Type.INTERACTION, "SimEntity", "ancestor-interaction"), - trigger(StatementTrigger.Type.INTERACTION, "Rabbit", "interaction")); + trigger(StatementTrigger.Type.INTERACTION, "SimEntity.Rabbit", "interaction")); TriggerProcessor processor = new ControlledTriggerProcessor(config, catalog); List enqueued = new ArrayList<>(); processor.dispatch( StatementTrigger.Type.INTERACTION, - "Rabbit", - new InteractionInjectionContext("Rabbit", Map.of()), + "SimEntity.Rabbit", + new InteractionInjectionContext("SimEntity.Rabbit", Map.of()), enqueued::add); assertEquals(List.of("interaction"), enqueued); @@ -80,22 +80,22 @@ void lifecycleEventsMatchTheirTypeAndFomHierarchy() { XapiConfig config = new XapiConfig(); config.statementTriggers = List.of( trigger(StatementTrigger.Type.OBJECT_CREATE, "SimEntity", "sim-entity-create"), - trigger(StatementTrigger.Type.OBJECT_CREATE, "Rabbit", "rabbit-create"), - trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "rabbit-update"), + trigger(StatementTrigger.Type.OBJECT_CREATE, "SimEntity.Rabbit", "rabbit-create"), + trigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity.Rabbit", "rabbit-update"), trigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity", "sim-entity-delete"), - trigger(StatementTrigger.Type.OBJECT_DELETE, "Rabbit", "rabbit-delete"), - trigger(StatementTrigger.Type.OBJECT_DELETE, "Wolf", "wolf-delete")); + trigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity.Rabbit", "rabbit-delete"), + trigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity.Wolf", "wolf-delete")); TriggerProcessor processor = new ControlledTriggerProcessor(config, catalog); ObjectInjectionContext rabbit = - new ObjectInjectionContext("Rabbit", "object-1", Map.of()); + new ObjectInjectionContext("SimEntity.Rabbit", "object-1", Map.of()); List createStatements = processor - .stage(StatementTrigger.Type.OBJECT_CREATE, "Rabbit", rabbit) + .stage(StatementTrigger.Type.OBJECT_CREATE, "SimEntity.Rabbit", rabbit) .stream() .map(TriggerProcessor.StagedStatement::statement) .toList(); List deleteStatements = processor - .stage(StatementTrigger.Type.OBJECT_DELETE, "Rabbit", rabbit) + .stage(StatementTrigger.Type.OBJECT_DELETE, "SimEntity.Rabbit", rabbit) .stream() .map(TriggerProcessor.StagedStatement::statement) .toList(); @@ -113,15 +113,15 @@ void objectUpdateForBaseClassMatchesConcreteDescendant() { XapiConfig config = new XapiConfig(); config.statementTriggers = List.of( trigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity", "sim-entity-update"), - trigger(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", "rabbit-update"), - trigger(StatementTrigger.Type.OBJECT_UPDATE, "Wolf", "wolf-update"), + trigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity.Rabbit", "rabbit-update"), + trigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity.Wolf", "wolf-update"), trigger(StatementTrigger.Type.OBJECT_UPDATE, "MissingObject", "unknown-update")); TriggerProcessor processor = new ControlledTriggerProcessor(config, catalog); ObjectInjectionContext rabbit = - new ObjectInjectionContext("Rabbit", "object-1", Map.of()); + new ObjectInjectionContext("SimEntity.Rabbit", "object-1", Map.of()); List updateStatements = processor - .stage(StatementTrigger.Type.OBJECT_UPDATE, "Rabbit", rabbit) + .stage(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity.Rabbit", rabbit) .stream() .map(TriggerProcessor.StagedStatement::statement) .toList(); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java index 075b1db..7dfe507 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java @@ -16,7 +16,7 @@ class FomCatalogTest { @Test void flattensPrimitiveAliasesEnumsAndFixedRecords() { FomCatalog catalog = catalog("config/HlaFedereplFOM.xml"); - FomCatalog.ObjectClassDef rabbit = catalog.objectClass("Rabbit").orElseThrow(); + FomCatalog.ObjectClassDef rabbit = catalog.objectClass("SimEntity.Rabbit").orElseThrow(); assertTrue(rabbit.attribute("Hunger").orElseThrow().leaf()); assertEquals("HLAinteger32BE", rabbit.attribute("Hunger").orElseThrow().primitiveType()); @@ -37,7 +37,7 @@ void includesInheritedObjectAttributes() { assertEquals("HLAASCIIstring", simEntity.attribute("EntityId").orElseThrow().primitiveType()); assertEquals("HLAinteger32BE", simEntity.attribute("Position.X").orElseThrow().primitiveType()); - FomCatalog.ObjectClassDef rabbit = catalog.objectClass("Rabbit").orElseThrow(); + FomCatalog.ObjectClassDef rabbit = catalog.objectClass("SimEntity.Rabbit").orElseThrow(); assertEquals("SimEntity", rabbit.parentName()); assertEquals("HLAASCIIstring", rabbit.attribute("EntityId").orElseThrow().primitiveType()); assertEquals("HLAinteger32BE", rabbit.attribute("Hunger").orElseThrow().primitiveType()); @@ -50,12 +50,12 @@ void resolvesObjectClassesWithTheirDescendants() { assertEquals( List.of("SimEntity", "Carrot", "Rabbit", "Wolf"), catalog.objectClassAndDescendants("SimEntity").stream() - .map(FomCatalog.ObjectClassDef::localName) + .map(definition -> FomCatalog.shortName(definition.hlaName())) .toList()); assertEquals( List.of("Rabbit"), - catalog.objectClassAndDescendants("Rabbit").stream() - .map(FomCatalog.ObjectClassDef::localName) + catalog.objectClassAndDescendants("SimEntity.Rabbit").stream() + .map(definition -> FomCatalog.shortName(definition.hlaName())) .toList()); assertEquals(List.of(), catalog.objectClassAndDescendants("MissingObject")); } @@ -64,12 +64,12 @@ void resolvesObjectClassesWithTheirDescendants() { void matchesObjectClassesThroughTheirFomHierarchy() { FomCatalog catalog = catalog("config/HlaFedereplFOM.xml"); - assertTrue(catalog.isSameOrDescendant("Rabbit", "Rabbit")); - assertTrue(catalog.isSameOrDescendant("Rabbit", "SimEntity")); - assertFalse(catalog.isSameOrDescendant("SimEntity", "Rabbit")); - assertFalse(catalog.isSameOrDescendant("Wolf", "Rabbit")); + assertTrue(catalog.isSameOrDescendant("SimEntity.Rabbit", "SimEntity.Rabbit")); + assertTrue(catalog.isSameOrDescendant("SimEntity.Rabbit", "SimEntity")); + assertFalse(catalog.isSameOrDescendant("SimEntity", "SimEntity.Rabbit")); + assertFalse(catalog.isSameOrDescendant("SimEntity.Wolf", "SimEntity.Rabbit")); assertFalse(catalog.isSameOrDescendant("MissingObject", "SimEntity")); - assertFalse(catalog.isSameOrDescendant("Rabbit", "MissingObject")); + assertFalse(catalog.isSameOrDescendant("SimEntity.Rabbit", "MissingObject")); } @Test @@ -77,9 +77,9 @@ void calculatesObjectClassDepthFromKnownFomAncestors() { FomCatalog catalog = catalog("config/HlaFedereplFOM.xml"); assertEquals(1, catalog.objectClassDepth("SimEntity")); - assertEquals(2, catalog.objectClassDepth("Carrot")); - assertEquals(2, catalog.objectClassDepth("Rabbit")); - assertEquals(2, catalog.objectClassDepth("Wolf")); + assertEquals(2, catalog.objectClassDepth("SimEntity.Carrot")); + assertEquals(2, catalog.objectClassDepth("SimEntity.Rabbit")); + assertEquals(2, catalog.objectClassDepth("SimEntity.Wolf")); assertEquals(-1, catalog.objectClassDepth("MissingObject")); } @@ -89,9 +89,11 @@ void buildsRtiObjectClassNamesRelativeToHlaObjectRoot() { assertEquals("HLAobjectRoot", catalog.objectClass("HLAobjectRoot").orElseThrow().hlaName()); assertEquals("SimEntity", catalog.objectClass("SimEntity").orElseThrow().hlaName()); - assertEquals("SimEntity.Carrot", catalog.objectClass("Carrot").orElseThrow().hlaName()); - assertEquals("SimEntity.Rabbit", catalog.objectClass("Rabbit").orElseThrow().hlaName()); - assertEquals("SimEntity.Wolf", catalog.objectClass("Wolf").orElseThrow().hlaName()); + assertEquals("SimEntity.Carrot", catalog.objectClass("SimEntity.Carrot").orElseThrow().hlaName()); + assertEquals("SimEntity.Rabbit", catalog.objectClass("SimEntity.Rabbit").orElseThrow().hlaName()); + assertEquals("SimEntity.Wolf", catalog.objectClass("SimEntity.Wolf").orElseThrow().hlaName()); + assertTrue(catalog.objectClass("Rabbit").isEmpty()); + assertTrue(catalog.objectClass(" SimEntity.Rabbit ").isEmpty()); } @Test diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java index faa3045..517e769 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java @@ -68,9 +68,9 @@ class HlaObjectSubscriptionTest { @Test void eventOnlyConfigurationSubscribesRequestsAndProcessesReflections() throws Exception { XapiConfig config = new XapiConfig(); - config.statementTriggers = List.of(objectUpdateTrigger("Rabbit")); + config.statementTriggers = List.of(objectUpdateTrigger("SimEntity.Rabbit")); Set expectedAttributes = - Set.copyOf(catalog.objectClass("Rabbit").orElseThrow().topLevelAttributeNames()); + Set.copyOf(catalog.objectClass("SimEntity.Rabbit").orElseThrow().topLevelAttributeNames()); try (ObjectCache cache = new ObjectCache(config, catalog, fomXml, decoderRegistry)) { RecordingRti rti = new RecordingRti(); @@ -80,9 +80,9 @@ void eventOnlyConfigurationSubscribesRequestsAndProcessesReflections() throws Ex subscribeObjectClasses(hlaInterface); assertFalse(cache.isEnabled()); - assertEquals(List.of(new ObjectSubscription("Rabbit", expectedAttributes)), rti.subscriptions); + assertEquals(List.of(new ObjectSubscription("SimEntity.Rabbit", expectedAttributes)), rti.subscriptions); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(91); hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit One"); @@ -97,7 +97,7 @@ void eventOnlyConfigurationSubscribesRequestsAndProcessesReflections() throws Ex assertEquals(1, rti.knownClassResolutions); assertEquals(1, rti.attributeNameResolutions); - assertTrue(cache.currentObjects("Rabbit").isEmpty()); + assertTrue(cache.currentObjects("SimEntity.Rabbit").isEmpty()); assertEquals(List.of("{}"), xapiClient.statements); } } @@ -106,13 +106,13 @@ void eventOnlyConfigurationSubscribesRequestsAndProcessesReflections() throws Ex void firstReflectionDispatchesObjectCreateAndObjectUpdateThenOnlyUpdates() throws Exception { StatementTrigger create = objectTrigger( StatementTrigger.Type.OBJECT_CREATE, - "Rabbit", + "SimEntity.Rabbit", """ {"event":"create","hunger":["trigger",["Hunger"]]} """); StatementTrigger update = objectTrigger( StatementTrigger.Type.OBJECT_UPDATE, - "Rabbit", + "SimEntity.Rabbit", """ {"event":"update","hunger":["trigger",["Hunger"]]} """); @@ -128,7 +128,7 @@ void firstReflectionDispatchesObjectCreateAndObjectUpdateThenOnlyUpdates() throw config, xapiClient, injectionHandler(cache)); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(96); AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); @@ -157,11 +157,11 @@ void concreteLifecycleCallbacksDispatchEachMatchingAncestorAndConcreteTriggerOnc "{\"event\":\"sim-entity-create\"}"), objectTrigger( StatementTrigger.Type.OBJECT_CREATE, - "Rabbit", + "SimEntity.Rabbit", "{\"event\":\"rabbit-create\"}"), objectTrigger( StatementTrigger.Type.OBJECT_CREATE, - "Wolf", + "SimEntity.Wolf", "{\"event\":\"wolf-create\"}"), objectTrigger( StatementTrigger.Type.OBJECT_UPDATE, @@ -169,11 +169,11 @@ void concreteLifecycleCallbacksDispatchEachMatchingAncestorAndConcreteTriggerOnc "{\"event\":\"sim-entity-update\"}"), objectTrigger( StatementTrigger.Type.OBJECT_UPDATE, - "Rabbit", + "SimEntity.Rabbit", "{\"event\":\"rabbit-update\"}"), objectTrigger( StatementTrigger.Type.OBJECT_UPDATE, - "Wolf", + "SimEntity.Wolf", "{\"event\":\"wolf-update\"}"), objectTrigger( StatementTrigger.Type.OBJECT_DELETE, @@ -181,11 +181,11 @@ void concreteLifecycleCallbacksDispatchEachMatchingAncestorAndConcreteTriggerOnc "{\"event\":\"sim-entity-delete\"}"), objectTrigger( StatementTrigger.Type.OBJECT_DELETE, - "Rabbit", + "SimEntity.Rabbit", "{\"event\":\"rabbit-delete\"}"), objectTrigger( StatementTrigger.Type.OBJECT_DELETE, - "Wolf", + "SimEntity.Wolf", "{\"event\":\"wolf-delete\"}")); try (ObjectCache cache = new ObjectCache( @@ -202,7 +202,7 @@ void concreteLifecycleCallbacksDispatchEachMatchingAncestorAndConcreteTriggerOnc config, xapiClient, injectionHandler(cache)); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(109); AttributeHandleValueMap firstReflection = new HLA1516eAttributeHandleValueMap(); firstReflection.put( @@ -247,11 +247,11 @@ void emptyReflectionDoesNotDispatchCacheOrConsumePendingCreate() throws Exceptio config.statementTriggers = List.of( objectTrigger( StatementTrigger.Type.OBJECT_CREATE, - "Rabbit", + "SimEntity.Rabbit", "{\"event\":\"create\"}"), objectTrigger( StatementTrigger.Type.OBJECT_UPDATE, - "Rabbit", + "SimEntity.Rabbit", "{\"event\":\"update\"}")); try (RecordingReflectionCache cache = @@ -260,7 +260,7 @@ void emptyReflectionDoesNotDispatchCacheOrConsumePendingCreate() throws Exceptio RecordingXapiClient xapiClient = new RecordingXapiClient(); HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy(), config, xapiClient); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(106); AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); @@ -291,7 +291,7 @@ void failedCacheProcessingRetainsPendingCreateForTheNextReflection() throws Exce XapiConfig config = new XapiConfig(); config.statementTriggers = List.of(objectTrigger( StatementTrigger.Type.OBJECT_CREATE, - "Rabbit", + "SimEntity.Rabbit", """ {"hunger":["trigger",["Hunger"]]} """)); @@ -306,7 +306,7 @@ void failedCacheProcessingRetainsPendingCreateForTheNextReflection() throws Exce config, xapiClient, injectionHandler(cache)); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(97); AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); @@ -323,14 +323,14 @@ void failedCacheProcessingRetainsPendingCreateForTheNextReflection() throws Exce void deletionBeforeFirstReflectionClearsPendingCreate() throws Exception { XapiConfig config = new XapiConfig(); config.statementTriggers = - List.of(objectTrigger(StatementTrigger.Type.OBJECT_CREATE, "Rabbit", "{}")); + List.of(objectTrigger(StatementTrigger.Type.OBJECT_CREATE, "SimEntity.Rabbit", "{}")); try (ObjectCache cache = new ObjectCache(config, catalog, fomXml, decoderRegistry)) { RecordingRti rti = new RecordingRti(); RecordingXapiClient xapiClient = new RecordingXapiClient(); HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(98); AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); @@ -349,11 +349,11 @@ void deletionBeforeFirstReflectionClearsPendingCreate() throws Exception { void firstReflectionConsumesCreateEvenWhenRequiredValuesAreMissing() throws Exception { StatementTrigger required = objectTrigger( StatementTrigger.Type.OBJECT_CREATE, - "Rabbit", + "SimEntity.Rabbit", "{\"entityId\":[\"trigger\",[\"EntityId\"]]}"); StatementTrigger optional = objectTrigger( StatementTrigger.Type.OBJECT_CREATE, - "Rabbit", + "SimEntity.Rabbit", "{\"entityId\":[\"trigger\",[\"EntityId\"],{\"required\":false}]}"); XapiConfig config = new XapiConfig(); config.statementTriggers = List.of(required, optional); @@ -363,7 +363,7 @@ void firstReflectionConsumesCreateEvenWhenRequiredValuesAreMissing() throws Exce RecordingXapiClient xapiClient = new RecordingXapiClient(); HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(105); hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Partial"); @@ -382,19 +382,19 @@ void firstReflectionConsumesCreateEvenWhenRequiredValuesAreMissing() throws Exce void objectDeleteUsesFinalSnapshotAndEnqueuesAfterRemoval(@TempDir Path tempDir) throws Exception { StatementTrigger delete = objectTrigger( StatementTrigger.Type.OBJECT_DELETE, - "Rabbit", + "SimEntity.Rabbit", """ { "entityId":["trigger",["EntityId"]], "hunger":["trigger",["Hunger"]], "x":["trigger",["Position","X"]], - "queried":["query","Rabbit",["Hunger"],null], + "queried":["query","SimEntity.Rabbit",["Hunger"],null], "lookedUp":["lookup","rabbit",["Hunger"]] } """); delete.criteria = comparison("Hunger", ComparisonOperator.GT, 10); ObjectLookup lookup = new ObjectLookup(); - lookup.clazz = "Rabbit"; + lookup.clazz = "SimEntity.Rabbit"; lookup.criteria = new Criterion( new Target(List.of("EntityId")), ComparisonOperator.EQ, @@ -402,12 +402,12 @@ void objectDeleteUsesFinalSnapshotAndEnqueuesAfterRemoval(@TempDir Path tempDir) delete.lookups = Map.of("rabbit", lookup); StatementTrigger skipped = objectTrigger( StatementTrigger.Type.OBJECT_DELETE, - "Rabbit", + "SimEntity.Rabbit", "{\"skipped\":true}"); skipped.criteria = comparison("Hunger", ComparisonOperator.GT, 20); StatementTrigger wrongClass = objectTrigger( StatementTrigger.Type.OBJECT_DELETE, - "Wolf", + "SimEntity.Wolf", "{\"wrongClass\":true}"); XapiConfig config = new XapiConfig(); config.statementTriggers = List.of(delete, skipped, wrongClass); @@ -419,11 +419,11 @@ void objectDeleteUsesFinalSnapshotAndEnqueuesAfterRemoval(@TempDir Path tempDir) decoderRegistry, "jdbc:sqlite:" + tempDir.resolve("object-delete-dispatch.sqlite"))) { RecordingRti rti = new RecordingRti(); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(99); AtomicReference removedAtEnqueue = new AtomicReference<>(false); RecordingXapiClient xapiClient = new RecordingXapiClient(statement -> - removedAtEnqueue.set(cache.currentObjects("Rabbit").isEmpty())); + removedAtEnqueue.set(cache.currentObjects("SimEntity.Rabbit").isEmpty())); HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Delete"); @@ -461,14 +461,14 @@ void objectDeleteUsesFinalSnapshotAndEnqueuesAfterRemoval(@TempDir Path tempDir) }) void discoveryRemovalRaceStillDispatchesStaticAndOptionalDeletes(@TempDir Path tempDir) throws Exception { StatementTrigger staticDelete = - objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "Rabbit", "{\"deleted\":true}"); + objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity.Rabbit", "{\"deleted\":true}"); StatementTrigger requiredMissing = objectTrigger( StatementTrigger.Type.OBJECT_DELETE, - "Rabbit", + "SimEntity.Rabbit", "{\"entityId\":[\"trigger\",[\"EntityId\"]]}"); StatementTrigger optionalMissing = objectTrigger( StatementTrigger.Type.OBJECT_DELETE, - "Rabbit", + "SimEntity.Rabbit", "{\"entityId\":[\"trigger\",[\"EntityId\"],{\"required\":false}]}"); XapiConfig config = new XapiConfig(); config.statementTriggers = List.of(staticDelete, requiredMissing, optionalMissing); @@ -484,7 +484,7 @@ void discoveryRemovalRaceStillDispatchesStaticAndOptionalDeletes(@TempDir Path t RecordingXapiClient xapiClient = new RecordingXapiClient(); HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(100); hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Brief"); @@ -493,7 +493,7 @@ void discoveryRemovalRaceStillDispatchesStaticAndOptionalDeletes(@TempDir Path t assertEquals( List.of("{\"deleted\":true}", "{\"entityId\":null}"), xapiClient.statements); - assertTrue(cache.currentObjects("Rabbit").isEmpty()); + assertTrue(cache.currentObjects("SimEntity.Rabbit").isEmpty()); } } @@ -502,7 +502,7 @@ void discoveryRemovalRaceStillDispatchesStaticAndOptionalDeletes(@TempDir Path t void removalFailureSuppressesStagedDeleteStatements(@TempDir Path tempDir) throws Exception { XapiConfig config = new XapiConfig(); config.statementTriggers = - List.of(objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "Rabbit", "{}")); + List.of(objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity.Rabbit", "{}")); try (ObjectCache cache = new FailingRemovalCache( config, @@ -514,14 +514,14 @@ void removalFailureSuppressesStagedDeleteStatements(@TempDir Path tempDir) throw RecordingXapiClient xapiClient = new RecordingXapiClient(); HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(101); hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Failure"); hlaInterface.removeObjectInstance(rabbit, null, null, null); assertTrue(xapiClient.statements.isEmpty()); - assertEquals(1, cache.currentObjects("Rabbit").size()); + assertEquals(1, cache.currentObjects("SimEntity.Rabbit").size()); } } @@ -529,8 +529,8 @@ void removalFailureSuppressesStagedDeleteStatements(@TempDir Path tempDir) throw void everyLifecycleCallbackOverloadUsesTheCommonPipelines(@TempDir Path tempDir) throws Exception { XapiConfig config = new XapiConfig(); config.statementTriggers = List.of( - objectTrigger(StatementTrigger.Type.OBJECT_CREATE, "Rabbit", "{\"event\":\"create\"}"), - objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "Rabbit", "{\"event\":\"delete\"}")); + objectTrigger(StatementTrigger.Type.OBJECT_CREATE, "SimEntity.Rabbit", "{\"event\":\"create\"}"), + objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity.Rabbit", "{\"event\":\"delete\"}")); try (ObjectCache cache = new ObjectCache( config, @@ -542,7 +542,7 @@ void everyLifecycleCallbackOverloadUsesTheCommonPipelines(@TempDir Path tempDir) RecordingXapiClient xapiClient = new RecordingXapiClient(); HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); ObjectInstanceHandle first = rti.objectHandle(102); ObjectInstanceHandle second = rti.objectHandle(103); @@ -588,7 +588,7 @@ void everyLifecycleCallbackOverloadUsesTheCommonPipelines(@TempDir Path tempDir) "{\"event\":\"delete\"}", "{\"event\":\"delete\"}"), xapiClient.statements); - assertTrue(cache.currentObjects("Rabbit").isEmpty()); + assertTrue(cache.currentObjects("SimEntity.Rabbit").isEmpty()); } } @@ -598,31 +598,31 @@ void everyLifecycleCallbackOverloadUsesTheCommonPipelines(@TempDir Path tempDir) }) void eventOnlyReflectionDispatchesMatchingTriggersOnceFromTheCompletePayload() throws Exception { StatementTrigger passing = objectUpdateTrigger( - "Rabbit", + "SimEntity.Rabbit", """ {"incomingHunger":["trigger",["Hunger"]]} """); passing.criteria = comparison("Hunger", ComparisonOperator.GT, 10); StatementTrigger requiredMissing = objectUpdateTrigger( - "Rabbit", + "SimEntity.Rabbit", """ {"entityId":["trigger",["EntityId"]]} """); StatementTrigger optionalMissing = objectUpdateTrigger( - "Rabbit", + "SimEntity.Rabbit", """ {"entityId":["trigger",["EntityId"],{"required":false}]} """); StatementTrigger skipped = objectUpdateTrigger( - "Rabbit", + "SimEntity.Rabbit", """ {"skipped":true} """); skipped.criteria = comparison("Hunger", ComparisonOperator.GT, 20); - StatementTrigger wrongClass = objectUpdateTrigger("Wolf", """ + StatementTrigger wrongClass = objectUpdateTrigger("SimEntity.Wolf", """ {"wrongClass":true} """); - StatementTrigger wrongType = objectUpdateTrigger("Rabbit", """ + StatementTrigger wrongType = objectUpdateTrigger("SimEntity.Rabbit", """ {"wrongType":true} """); wrongType.type = StatementTrigger.Type.INTERACTION; @@ -640,7 +640,7 @@ void eventOnlyReflectionDispatchesMatchingTriggersOnceFromTheCompletePayload() t config, xapiClient, injectionHandler(cache)); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(93); AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); AttributeHandle position = rti.attributeHandle(rabbitClass, "Position"); @@ -666,16 +666,16 @@ void eventOnlyReflectionDispatchesMatchingTriggersOnceFromTheCompletePayload() t void cachedQueriesAndLookupsRenderBeforeTheReflectionCommitsAndEnqueueAfterItCommits( @TempDir Path tempDir) throws Exception { StatementTrigger trigger = objectUpdateTrigger( - "Rabbit", + "SimEntity.Rabbit", """ { "incoming":["trigger",["Hunger"]], - "queried":["query","Rabbit",["Hunger"],[["EntityId"],"=","rabbit-one"]], + "queried":["query","SimEntity.Rabbit",["Hunger"],[["EntityId"],"=","rabbit-one"]], "lookedUp":["lookup","rabbit",["Hunger"]] } """); ObjectLookup lookup = new ObjectLookup(); - lookup.clazz = "Rabbit"; + lookup.clazz = "SimEntity.Rabbit"; lookup.criteria = new Criterion( new Target(List.of("EntityId")), ComparisonOperator.EQ, @@ -691,11 +691,11 @@ void cachedQueriesAndLookupsRenderBeforeTheReflectionCommitsAndEnqueueAfterItCom decoderRegistry, "jdbc:sqlite:" + tempDir.resolve("object-update-query.sqlite"))) { RecordingRti rti = new RecordingRti(); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(94); cache.reflectAttributeValues( rabbit.toString(), - "Rabbit", + "SimEntity.Rabbit", Map.of( "EntityId", HLAEncodingTestSupport.asciiString("rabbit-one"), "Hunger", HLAEncodingTestSupport.int32(5, ByteOrder.BIG_ENDIAN))); @@ -725,7 +725,7 @@ void cachedQueriesAndLookupsRenderBeforeTheReflectionCommitsAndEnqueueAfterItCom @Test void previousCriteriaDetectChangesAndThresholdCrossings(@TempDir Path tempDir) throws Exception { StatementTrigger changed = objectUpdateTrigger( - "Rabbit", + "SimEntity.Rabbit", """ {"event":"changed","old":["previous",["Hunger"]],"new":["trigger",["Hunger"]]} """); @@ -734,7 +734,7 @@ void previousCriteriaDetectChangesAndThresholdCrossings(@TempDir Path tempDir) t ComparisonOperator.NEQ, new TriggerExpression(new Target(List.of("Hunger")))); StatementTrigger crossed = objectUpdateTrigger( - "Rabbit", + "SimEntity.Rabbit", """ {"event":"crossed","old":["previous",["Hunger"]],"new":["trigger",["Hunger"]]} """); @@ -762,12 +762,12 @@ void previousCriteriaDetectChangesAndThresholdCrossings(@TempDir Path tempDir) t RecordingXapiClient xapiClient = new RecordingXapiClient(); HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(106); AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); cache.reflectAttributeValue( rabbit.toString(), - "Rabbit", + "SimEntity.Rabbit", "Hunger", HLAEncodingTestSupport.int32(10, ByteOrder.BIG_ENDIAN)); @@ -791,12 +791,12 @@ void previousCriteriaDetectChangesAndThresholdCrossings(@TempDir Path tempDir) t void firstObservationSupportsOptionalPreviousWithoutRetryingRequiredInjections( @TempDir Path tempDir) throws Exception { StatementTrigger required = objectUpdateTrigger( - "Rabbit", + "SimEntity.Rabbit", """ {"event":"required","old":["previous",["Hunger"]],"new":["trigger",["Hunger"]]} """); StatementTrigger optional = objectUpdateTrigger( - "Rabbit", + "SimEntity.Rabbit", """ { "event":"optional", @@ -817,7 +817,7 @@ void firstObservationSupportsOptionalPreviousWithoutRetryingRequiredInjections( RecordingXapiClient xapiClient = new RecordingXapiClient(); HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(107); AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Previous"); @@ -838,12 +838,12 @@ void firstObservationSupportsOptionalPreviousWithoutRetryingRequiredInjections( @SuppressTestLogging({"com.yetanalytics.hlaxapi.HlaInterfaceImpl"}) void failedReflectionRetainsOnePreviousStateForEveryTrigger(@TempDir Path tempDir) throws Exception { StatementTrigger first = objectUpdateTrigger( - "Rabbit", + "SimEntity.Rabbit", """ {"trigger":1,"old":["previous",["Hunger"]],"new":["trigger",["Hunger"]]} """); StatementTrigger second = objectUpdateTrigger( - "Rabbit", + "SimEntity.Rabbit", """ {"trigger":2,"old":["previous",["Hunger"]],"new":["trigger",["Hunger"]]} """); @@ -860,13 +860,13 @@ void failedReflectionRetainsOnePreviousStateForEveryTrigger(@TempDir Path tempDi RecordingXapiClient xapiClient = new RecordingXapiClient(); HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy(), config, xapiClient, injectionHandler(cache)); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(108); AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); AttributeHandle unknown = rti.attributeHandle(rabbitClass, "NotInTheFom"); cache.reflectAttributeValue( rabbit.toString(), - "Rabbit", + "SimEntity.Rabbit", "Hunger", HLAEncodingTestSupport.int32(5, ByteOrder.BIG_ENDIAN)); AttributeHandleValueMap failedReflection = new HLA1516eAttributeHandleValueMap(); @@ -891,7 +891,7 @@ void failedReflectionRetainsOnePreviousStateForEveryTrigger(@TempDir Path tempDi @Test @SuppressTestLogging({"com.yetanalytics.hlaxapi.HlaInterfaceImpl"}) void cacheFailureSuppressesAllStatementsStagedForTheReflection(@TempDir Path tempDir) throws Exception { - XapiConfig config = trackedRabbitConfig(objectUpdateTrigger("Rabbit")); + XapiConfig config = trackedRabbitConfig(objectUpdateTrigger("SimEntity.Rabbit")); try (ObjectCache cache = new ObjectCache( config, catalog, @@ -899,11 +899,11 @@ void cacheFailureSuppressesAllStatementsStagedForTheReflection(@TempDir Path tem decoderRegistry, "jdbc:sqlite:" + tempDir.resolve("object-update-cache-failure.sqlite"))) { RecordingRti rti = new RecordingRti(); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(95); cache.reflectAttributeValue( rabbit.toString(), - "Rabbit", + "SimEntity.Rabbit", "Hunger", HLAEncodingTestSupport.int32(5, ByteOrder.BIG_ENDIAN)); RecordingXapiClient xapiClient = new RecordingXapiClient(); @@ -938,15 +938,15 @@ void discoveryCachesMetadataAndRequestsMergedAttributes(@TempDir Path tempDir) t RecordingRti rti = new RecordingRti(); HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy(), config, new RecordingXapiClient()); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(92); hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Two"); assertTrue(cache.isEnabled()); - assertEquals(1, cache.currentObjects("Rabbit").size()); - assertEquals("Rabbit Two", cache.currentObjects("Rabbit").get(0).objectName()); - assertEquals(cache.subscriptions().get("Rabbit"), rti.requests.get(0).attributes()); + assertEquals(1, cache.currentObjects("SimEntity.Rabbit").size()); + assertEquals("Rabbit Two", cache.currentObjects("SimEntity.Rabbit").get(0).objectName()); + assertEquals(cache.subscriptions().get("SimEntity.Rabbit"), rti.requests.get(0).attributes()); } } @@ -957,7 +957,7 @@ void discoveryRequestsTheUnionOfChildAndAncestorSubscriptions(@TempDir Path temp {"name":["query","SimEntity",["FirstName"],null]} """; TrackedObject trackedRabbit = new TrackedObject(); - trackedRabbit.clazz = "Rabbit"; + trackedRabbit.clazz = "SimEntity.Rabbit"; trackedRabbit.attributes = List.of("Hunger"); ObjectCacheConfig objectCacheConfig = new ObjectCacheConfig(); objectCacheConfig.trackedObjects = List.of(trackedRabbit); @@ -974,15 +974,15 @@ void discoveryRequestsTheUnionOfChildAndAncestorSubscriptions(@TempDir Path temp RecordingRti rti = new RecordingRti(); HlaInterfaceImpl hlaInterface = hlaInterface(cache, rti.proxy(), config, new RecordingXapiClient()); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(107); hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Inherited"); assertEquals(Set.of("FirstName"), cache.subscriptions().get("SimEntity")); - assertEquals(Set.of("FirstName"), cache.subscriptions().get("Carrot")); - assertEquals(Set.of("FirstName", "Hunger"), cache.subscriptions().get("Rabbit")); - assertEquals(Set.of("FirstName"), cache.subscriptions().get("Wolf")); + assertEquals(Set.of("FirstName"), cache.subscriptions().get("SimEntity.Carrot")); + assertEquals(Set.of("FirstName", "Hunger"), cache.subscriptions().get("SimEntity.Rabbit")); + assertEquals(Set.of("FirstName"), cache.subscriptions().get("SimEntity.Wolf")); assertEquals(Set.of("FirstName", "Hunger"), rti.requests.get(0).attributes()); } } @@ -1010,7 +1010,7 @@ void ancestorQuerySubscribesDescendantsFirstAndCachesConcreteClasses( subscribeObjectClasses(hlaInterface); assertEquals( - List.of("Carrot", "Rabbit", "Wolf", "SimEntity"), + List.of("SimEntity.Carrot", "SimEntity.Rabbit", "SimEntity.Wolf", "SimEntity"), rti.subscriptions.stream() .map(ObjectSubscription::className) .toList()); @@ -1025,7 +1025,7 @@ void ancestorQuerySubscribesDescendantsFirstAndCachesConcreteClasses( .allMatch(subscription -> subscription.attributes().equals(Set.of("EntityId")))); - List concreteClasses = List.of("Carrot", "Rabbit", "Wolf"); + List concreteClasses = List.of("SimEntity.Carrot", "SimEntity.Rabbit", "SimEntity.Wolf"); for (int i = 0; i < concreteClasses.size(); i++) { String className = concreteClasses.get(i); ObjectClassHandle classHandle = rti.classHandle(className); @@ -1069,6 +1069,69 @@ void ancestorQuerySubscribesDescendantsFirstAndCachesConcreteClasses( } } + @Test + void duplicateLocalObjectClassesRemainDistinctAcrossRtiCallbacks( + @TempDir Path tempDir) throws Exception { + FOMXML ambiguousFomXml = new FOMXML( + new SimulationConfig( + null, + null, + null, + null, + "src/test/resources/config/AmbiguousClassNamesFOM.xml"), + decoderRegistry); + FomCatalog ambiguousCatalog = new FomCatalog(ambiguousFomXml); + TrackedObject entityRabbit = new TrackedObject(); + entityRabbit.clazz = "SimEntity.Rabbit"; + entityRabbit.allAttributes = true; + TrackedObject otherRabbit = new TrackedObject(); + otherRabbit.clazz = "SomeOtherSuperclass.Rabbit"; + otherRabbit.allAttributes = true; + ObjectCacheConfig cacheConfig = new ObjectCacheConfig(); + cacheConfig.trackedObjects = List.of(entityRabbit, otherRabbit); + XapiConfig config = new XapiConfig(); + config.objectCacheConfig = cacheConfig; + + try (ObjectCache cache = new ObjectCache( + config, + ambiguousCatalog, + ambiguousFomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("duplicate-local-names.sqlite"))) { + RecordingRti rti = new RecordingRti(); + HlaInterfaceImpl hlaInterface = + hlaInterface(cache, rti.proxy(), config, new RecordingXapiClient()); + subscribeObjectClasses(hlaInterface); + + ObjectClassHandle entityClass = rti.classHandle("SimEntity.Rabbit"); + ObjectInstanceHandle entity = rti.objectHandle(201); + hlaInterface.discoverObjectInstance(entity, entityClass, "Entity Rabbit"); + reflect( + hlaInterface, + entity, + rti.attributeHandle(entityClass, "Hunger"), + 12); + + ObjectClassHandle otherClass = rti.classHandle("SomeOtherSuperclass.Rabbit"); + ObjectInstanceHandle other = rti.objectHandle(202); + hlaInterface.discoverObjectInstance(other, otherClass, "Other Rabbit"); + reflect( + hlaInterface, + other, + rti.attributeHandle(otherClass, "Speed"), + 34); + + assertEquals( + "SimEntity.Rabbit", + cache.findCurrentObjectSnapshot(entity.toString()).orElseThrow().className()); + assertEquals( + "SomeOtherSuperclass.Rabbit", + cache.findCurrentObjectSnapshot(other.toString()).orElseThrow().className()); + assertEquals(12, cache.findCurrentValue(entity.toString(), "Hunger").orElseThrow().value()); + assertEquals(34, cache.findCurrentValue(other.toString(), "Speed").orElseThrow().value()); + } + } + @Test void overlappingAncestorAndConcreteSubscriptionsDoNotDuplicateReflectionTriggers() throws Exception { @@ -1078,7 +1141,7 @@ void overlappingAncestorAndConcreteSubscriptionsDoNotDuplicateReflectionTriggers "SimEntity", "{\"event\":\"sim-entity-update\"}"), objectUpdateTrigger( - "Rabbit", + "SimEntity.Rabbit", "{\"event\":\"rabbit-update\"}")); try (ObjectCache cache = new ObjectCache(config, catalog, fomXml, decoderRegistry)) { @@ -1089,7 +1152,7 @@ void overlappingAncestorAndConcreteSubscriptionsDoNotDuplicateReflectionTriggers subscribeObjectClasses(hlaInterface); - ObjectClassHandle rabbitClass = rti.classHandle("Rabbit"); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); ObjectInstanceHandle rabbit = rti.objectHandle(113); hlaInterface.discoverObjectInstance( rabbit, @@ -1143,10 +1206,10 @@ void unknownObjectUpdateClassIsSkippedDuringSubscription() throws Exception { private XapiConfig configWithQueryAndObjectUpdate() { StatementTrigger query = new StatementTrigger(); query.statement = """ - {"actor":{"name":["query","Rabbit",["EntityId"],[["Hunger"],">",50]]}} + {"actor":{"name":["query","SimEntity.Rabbit",["EntityId"],[["Hunger"],">",50]]}} """; XapiConfig config = new XapiConfig(); - config.statementTriggers = List.of(query, objectUpdateTrigger("Rabbit")); + config.statementTriggers = List.of(query, objectUpdateTrigger("SimEntity.Rabbit")); return config; } @@ -1192,7 +1255,7 @@ private Criterion comparison(String attribute, ComparisonOperator operator, Obje private XapiConfig trackedRabbitConfig(StatementTrigger trigger) { TrackedObject trackedRabbit = new TrackedObject(); - trackedRabbit.clazz = "Rabbit"; + trackedRabbit.clazz = "SimEntity.Rabbit"; trackedRabbit.attributes = List.of("Hunger"); ObjectCacheConfig cacheConfig = new ObjectCacheConfig(); cacheConfig.trackedObjects = List.of(trackedRabbit); @@ -1382,7 +1445,9 @@ private RTIambassador proxy() { } private ObjectClassHandle classHandle(String className) { - className = className.substring(className.lastIndexOf('.') + 1); + if (className.startsWith("HLAobjectRoot.")) { + className = className.substring("HLAobjectRoot.".length()); + } ObjectClassHandle handle = classes.computeIfAbsent( className, ignored -> (ObjectClassHandle) new HLA1516eHandle(nextClassHandle++)); @@ -1442,11 +1507,9 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl } private String qualifiedClassName(String className) { - return switch (className) { - case "Carrot", "Rabbit", "Wolf" -> "HLAobjectRoot.SimEntity." + className; - case "SimEntity", "World" -> "HLAobjectRoot." + className; - default -> className; - }; + return "HLAobjectRoot".equals(className) + ? className + : "HLAobjectRoot." + className; } private Set names(AttributeHandleSet handles) { diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java index db339f7..8b01658 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java @@ -56,7 +56,7 @@ abstract class ObjectCachePersistenceTest { @Test void initializesSchemaAndSeedsFomMetadata() throws SQLException { try (ObjectCache cache = newCache()) { - assertEquals(1, scalarLong(cache, "SELECT schema_version FROM object_cache_metadata")); + assertEquals(2, scalarLong(cache, "SELECT schema_version FROM object_cache_metadata")); assertTrue(count(cache, "SELECT COUNT(*) FROM fom_object_class") > 0); assertTrue(count(cache, "SELECT COUNT(*) FROM fom_attribute WHERE path_key = 'Position.X'") > 0); } @@ -68,9 +68,9 @@ void upsertsLatestScalarObjectStateAndKeepsRawBytes() throws SQLException { byte[] secondHunger = encoded(encoderFactory.createHLAinteger32BE(40)); try (ObjectCache cache = newCache()) { - cache.discoverObject("object-1", "Rabbit One", "Rabbit"); - cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", firstHunger); - cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", secondHunger); + cache.discoverObject("object-1", "Rabbit One", "SimEntity.Rabbit"); + cache.reflectAttributeValue("object-1", "SimEntity.Rabbit", "Hunger", firstHunger); + cache.reflectAttributeValue("object-1", "SimEntity.Rabbit", "Hunger", secondHunger); assertEquals(40, cache.findCurrentValue("object-1", "Hunger").orElseThrow().value()); assertEquals(1, count(cache, """ @@ -92,7 +92,7 @@ void storesOneMultiAttributeReflectionWithSharedObservationMetadata() throws SQL try (ObjectCache cache = newCache()) { cache.reflectAttributeValues( "object-1", - "Rabbit", + "SimEntity.Rabbit", Map.of( "EntityId", entityId, "Hunger", hunger, @@ -117,7 +117,7 @@ void loadsCurrentObjectSnapshotWithTopLevelRawValuesAndMetadata() { try (ObjectCache cache = newCache( "object-snapshot", - enabledConfig(), + enabledConfig("Rabbit"), dynamicArrayCatalog, dynamicArrayFomXml)) { cache.discoverObject("object-1", "Rabbit One", "Rabbit"); @@ -153,13 +153,13 @@ void validatesTheCompleteReflectionBeforeWriting() { byte[] newHunger = encoded(encoderFactory.createHLAinteger32BE(75)); try (ObjectCache cache = newCache()) { - cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", oldHunger); + cache.reflectAttributeValue("object-1", "SimEntity.Rabbit", "Hunger", oldHunger); assertThrows( IllegalArgumentException.class, () -> cache.reflectAttributeValues( "object-1", - "Rabbit", + "SimEntity.Rabbit", Map.of( "Hunger", newHunger, "NotInTheFom", new byte[] { 1 }))); @@ -171,7 +171,7 @@ void validatesTheCompleteReflectionBeforeWriting() { @Test void ignoresEmptyReflections() throws SQLException { try (ObjectCache cache = newCache()) { - cache.reflectAttributeValues("object-1", "Rabbit", Map.of()); + cache.reflectAttributeValues("object-1", "SimEntity.Rabbit", Map.of()); assertEquals(0, count(cache, "SELECT COUNT(*) FROM object_instance")); assertEquals(0, count(cache, "SELECT COUNT(*) FROM object_attribute_current")); @@ -187,11 +187,11 @@ void rollsBackEveryExistingValueWhenAReflectionFails() { try (ObjectCache cache = newCache()) { cache.reflectAttributeValues( "object-1", - "Rabbit", + "SimEntity.Rabbit", Map.of( "EntityId", oldEntityId, "Hunger", oldHunger)); - FomCatalog.ObjectClassDef rabbit = catalog.objectClass("Rabbit").orElseThrow(); + FomCatalog.ObjectClassDef rabbit = catalog.objectClass("SimEntity.Rabbit").orElseThrow(); ReflectedAttributeValues hunger = new ReflectedAttributeValues( "Hunger", List.of(new DecodedAttributeValue( @@ -229,7 +229,7 @@ void rollsBackEveryExistingValueWhenAReflectionFails() { void rollsBackObjectValuesAndDynamicMetadataWhenAReflectionFails() throws SQLException { try (ObjectCache cache = newCache( "reflection-rollback", - enabledConfig(), + enabledConfig("Rabbit"), dynamicArrayCatalog, dynamicArrayFomXml)) { FomCatalog.ObjectClassDef rabbit = dynamicArrayCatalog.objectClass("Rabbit").orElseThrow(); @@ -280,8 +280,8 @@ void flattensFixedRecordValuesToNestedCurrentRows() { byte[] position = position(12, 8); try (ObjectCache cache = newCache()) { - cache.discoverObject("object-1", "Rabbit One", "Rabbit"); - cache.reflectAttributeValue("object-1", "Rabbit", "Position", position); + cache.discoverObject("object-1", "Rabbit One", "SimEntity.Rabbit"); + cache.reflectAttributeValue("object-1", "SimEntity.Rabbit", "Position", position); assertEquals(12, cache.findCurrentValue("object-1", "Position.X").orElseThrow().value()); assertEquals(8, cache.findCurrentValue("object-1", "Position.Y").orElseThrow().value()); @@ -295,7 +295,7 @@ void replacesDynamicArrayPathsWhenArrayShrinksAndReusesMetadata() throws SQLExce try (ObjectCache cache = newCache( "dynamic-array", - enabledConfig(), + enabledConfig("Rabbit"), dynamicArrayCatalog, dynamicArrayFomXml)) { cache.discoverObject("object-1", "Rabbit One", "Rabbit"); @@ -355,17 +355,17 @@ void replacesDynamicArrayPathsWhenArrayShrinksAndReusesMetadata() throws SQLExce @Test void queryServiceEvaluatesCriteriaAndExcludesRemovedObjects() { try (ObjectCache cache = newCache()) { - cache.discoverObject("object-1", "Rabbit One", "Rabbit"); - cache.reflectAttributeValue("object-1", "Rabbit", "EntityId", encoded(encoderFactory.createHLAASCIIstring( + cache.discoverObject("object-1", "Rabbit One", "SimEntity.Rabbit"); + cache.reflectAttributeValue("object-1", "SimEntity.Rabbit", "EntityId", encoded(encoderFactory.createHLAASCIIstring( "rabbit-one"))); - cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", encoded(encoderFactory.createHLAinteger32BE(75))); - cache.reflectAttributeValue("object-1", "Rabbit", "Position", position(12, 8)); + cache.reflectAttributeValue("object-1", "SimEntity.Rabbit", "Hunger", encoded(encoderFactory.createHLAinteger32BE(75))); + cache.reflectAttributeValue("object-1", "SimEntity.Rabbit", "Position", position(12, 8)); - cache.discoverObject("object-2", "Rabbit Two", "Rabbit"); - cache.reflectAttributeValue("object-2", "Rabbit", "EntityId", encoded(encoderFactory.createHLAASCIIstring( + cache.discoverObject("object-2", "Rabbit Two", "SimEntity.Rabbit"); + cache.reflectAttributeValue("object-2", "SimEntity.Rabbit", "EntityId", encoded(encoderFactory.createHLAASCIIstring( "rabbit-two"))); - cache.reflectAttributeValue("object-2", "Rabbit", "Hunger", encoded(encoderFactory.createHLAinteger32BE(20))); - cache.reflectAttributeValue("object-2", "Rabbit", "Position", position(20, 5)); + cache.reflectAttributeValue("object-2", "SimEntity.Rabbit", "Hunger", encoded(encoderFactory.createHLAinteger32BE(20))); + cache.reflectAttributeValue("object-2", "SimEntity.Rabbit", "Position", position(20, 5)); Criterion hungerCriteria = new Criterion( new Target(List.of("Hunger")), @@ -379,11 +379,11 @@ void queryServiceEvaluatesCriteriaAndExcludesRemovedObjects() { assertEquals( List.of("rabbit-one"), - cache.queryService().findValues("Rabbit", new Target(List.of("EntityId")), hungerCriteria)); + cache.queryService().findValues("SimEntity.Rabbit", new Target(List.of("EntityId")), hungerCriteria)); assertEquals( List.of(8), - cache.queryService().findValues("Rabbit", new Target(List.of("Position", "Y")), criteria)); - CachedObject matched = cache.queryService().findFirstObject("Rabbit", criteria).orElseThrow(); + cache.queryService().findValues("SimEntity.Rabbit", new Target(List.of("Position", "Y")), criteria)); + CachedObject matched = cache.queryService().findFirstObject("SimEntity.Rabbit", criteria).orElseThrow(); assertEquals("object-1", matched.objectHandle()); assertEquals( 8, @@ -391,7 +391,7 @@ void queryServiceEvaluatesCriteriaAndExcludesRemovedObjects() { cache.removeObject("object-1"); - assertFalse(cache.queryService().findFirstValue("Rabbit", new Target(List.of("Hunger")), criteria) + assertFalse(cache.queryService().findFirstValue("SimEntity.Rabbit", new Target(List.of("Hunger")), criteria) .isPresent()); } } @@ -400,14 +400,14 @@ void queryServiceEvaluatesCriteriaAndExcludesRemovedObjects() { void baseClassQueryFindsObjectsCachedAsDescendantClasses() { try (ObjectCache cache = newCache()) { cache.discoverObject("entity-1", "Entity One", "SimEntity"); - cache.discoverObject("rabbit-1", "Rabbit One", "Rabbit"); + cache.discoverObject("rabbit-1", "Rabbit One", "SimEntity.Rabbit"); cache.reflectAttributeValues( "rabbit-1", - "Rabbit", + "SimEntity.Rabbit", Map.of( "EntityId", encoded(encoderFactory.createHLAASCIIstring("rabbit-one")), "FirstName", encoded(encoderFactory.createHLAunicodeString("Alice")))); - cache.discoverObject("wolf-1", "Wolf One", "Wolf"); + cache.discoverObject("wolf-1", "Wolf One", "SimEntity.Wolf"); Criterion entityId = new Criterion( new Target(List.of("EntityId")), ComparisonOperator.EQ, @@ -416,7 +416,7 @@ void baseClassQueryFindsObjectsCachedAsDescendantClasses() { CachedObject matched = cache.queryService().findFirstObject("SimEntity", entityId).orElseThrow(); - assertEquals("Rabbit", matched.className()); + assertEquals("SimEntity.Rabbit", matched.className()); assertEquals( "Alice", cache.queryService() @@ -429,7 +429,7 @@ void baseClassQueryFindsObjectsCachedAsDescendantClasses() { .toList()); assertEquals( List.of("rabbit-1"), - cache.currentObjects("Rabbit").stream() + cache.currentObjects("SimEntity.Rabbit").stream() .map(CachedObject::objectHandle) .toList()); @@ -448,7 +448,7 @@ void entityAteLookupFindsRabbitThroughSimEntityBaseClass() throws Exception { try (ObjectCache cache = newCache()) { cache.reflectAttributeValues( "rabbit-1", - "Rabbit", + "SimEntity.Rabbit", Map.of( "EntityId", encoded(encoderFactory.createHLAASCIIstring("rabbit-one")), "FirstName", encoded(encoderFactory.createHLAunicodeString("Alice")))); @@ -487,7 +487,7 @@ void entityAteLookupFindsRabbitThroughSimEntityBaseClass() throws Exception { void previousResolutionSupportsNestedArraysCachedNullAndMissingValues() throws Exception { try (ObjectCache cache = newCache( "previous-resolution", - enabledConfig(), + enabledConfig("Rabbit"), dynamicArrayCatalog, dynamicArrayFomXml)) { cache.reflectAttributeValues( @@ -554,9 +554,9 @@ void previousResolutionSupportsNestedArraysCachedNullAndMissingValues() throws E @Test void queryServiceDistinguishesPresentNullFromMissingValue() { try (ObjectCache cache = newCache()) { - cache.discoverObject("object-1", "Rabbit One", "Rabbit"); - cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", new byte[] { 1 }); - CachedObject matched = cache.queryService().findFirstObject("Rabbit", null).orElseThrow(); + cache.discoverObject("object-1", "Rabbit One", "SimEntity.Rabbit"); + cache.reflectAttributeValue("object-1", "SimEntity.Rabbit", "Hunger", new byte[] { 1 }); + CachedObject matched = cache.queryService().findFirstObject("SimEntity.Rabbit", null).orElseThrow(); ValueResolution presentNull = cache.queryService().findValueResolution( matched, @@ -574,8 +574,8 @@ void queryServiceDistinguishesPresentNullFromMissingValue() { @Test void persistentCacheStartsFreshOnInitialization() throws SQLException { try (ObjectCache cache = newCache("fresh-start")) { - cache.discoverObject("object-1", "Rabbit One", "Rabbit"); - cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", + cache.discoverObject("object-1", "Rabbit One", "SimEntity.Rabbit"); + cache.reflectAttributeValue("object-1", "SimEntity.Rabbit", "Hunger", encoded(encoderFactory.createHLAinteger32BE(75))); assertEquals(1, count(cache, "SELECT COUNT(*) FROM object_instance")); @@ -589,6 +589,59 @@ void persistentCacheStartsFreshOnInitialization() throws SQLException { } } + @Test + void keepsDuplicateLocalClassNamesIsolatedByCanonicalIdentity() throws SQLException { + FOMXML ambiguousFomXml = new FOMXML( + new SimulationConfig( + null, + null, + null, + null, + "src/test/resources/config/AmbiguousClassNamesFOM.xml"), + decoderRegistry); + FomCatalog ambiguousCatalog = new FomCatalog(ambiguousFomXml); + ObjectCacheConfig objectCacheConfig = new ObjectCacheConfig(); + objectCacheConfig.trackedObjects = List.of( + trackedObject("SimEntity.Rabbit"), + trackedObject("SomeOtherSuperclass.Rabbit")); + XapiConfig config = new XapiConfig(); + config.objectCacheConfig = objectCacheConfig; + + try (ObjectCache cache = newCache( + "duplicate-local-names", + config, + ambiguousCatalog, + ambiguousFomXml)) { + cache.discoverObject("entity-rabbit", "Entity Rabbit", "SimEntity.Rabbit"); + cache.reflectAttributeValue( + "entity-rabbit", + "SimEntity.Rabbit", + "Hunger", + encoded(encoderFactory.createHLAinteger32BE(12))); + cache.discoverObject( + "other-rabbit", + "Other Rabbit", + "SomeOtherSuperclass.Rabbit"); + cache.reflectAttributeValue( + "other-rabbit", + "SomeOtherSuperclass.Rabbit", + "Speed", + encoded(encoderFactory.createHLAinteger32BE(34))); + + CachedObject entityRabbit = cache.currentObjects("SimEntity.Rabbit").get(0); + CachedObject otherRabbit = cache.currentObjects("SomeOtherSuperclass.Rabbit").get(0); + assertEquals("SimEntity.Rabbit", entityRabbit.className()); + assertEquals("SomeOtherSuperclass.Rabbit", otherRabbit.className()); + assertEquals(12, cache.findValue(entityRabbit, new Target(List.of("Hunger"))).orElseThrow()); + assertEquals(34, cache.findValue(otherRabbit, new Target(List.of("Speed"))).orElseThrow()); + assertEquals(2, count(cache, """ + SELECT COUNT(*) + FROM fom_object_class + WHERE hla_name IN ('SimEntity.Rabbit', 'SomeOtherSuperclass.Rabbit') + """)); + } + } + protected ObjectCache newCache() { return newCache("default"); } @@ -604,9 +657,11 @@ protected abstract ObjectCache newCache( FOMXML cacheFomXml); protected XapiConfig enabledConfig() { - TrackedObject trackedObject = new TrackedObject(); - trackedObject.clazz = "Rabbit"; - trackedObject.allAttributes = true; + return enabledConfig("SimEntity.Rabbit"); + } + + protected XapiConfig enabledConfig(String className) { + TrackedObject trackedObject = trackedObject(className); ObjectCacheConfig objectCacheConfig = new ObjectCacheConfig(); objectCacheConfig.trackedObjects = List.of(trackedObject); XapiConfig config = new XapiConfig(); @@ -614,6 +669,13 @@ protected XapiConfig enabledConfig() { return config; } + private TrackedObject trackedObject(String className) { + TrackedObject trackedObject = new TrackedObject(); + trackedObject.clazz = className; + trackedObject.allAttributes = true; + return trackedObject; + } + protected byte[] position(int x, int y) { HLAfixedRecord record = encoderFactory.createHLAfixedRecord(); record.add(encoderFactory.createHLAinteger32BE(x)); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java index 1e8352c..20104db 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java @@ -46,14 +46,14 @@ void disabledWhenNoQueryInjectionsAndDoesNotOpenSqlite(@TempDir Path tempDir) { fomXml, decoderRegistry, "jdbc:sqlite:" + databasePath)) { - cache.discoverObject("object-1", "Rabbit One", "Rabbit"); - cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", encoded(encoderFactory.createHLAinteger32BE( + cache.discoverObject("object-1", "Rabbit One", "SimEntity.Rabbit"); + cache.reflectAttributeValue("object-1", "SimEntity.Rabbit", "Hunger", encoded(encoderFactory.createHLAinteger32BE( 75))); cache.removeObject("object-1"); assertFalse(cache.isEnabled()); assertTrue(cache.subscriptions().isEmpty()); - assertFalse(cache.findFirstValue("Rabbit", new Target(List.of("Hunger")), null).isPresent()); + assertFalse(cache.findFirstValue("SimEntity.Rabbit", new Target(List.of("Hunger")), null).isPresent()); assertFalse(Files.exists(databasePath)); } } @@ -69,7 +69,7 @@ void disabledCacheDoesNotRequireConnectionSettings() { void objectUpdateSubscriptionsDoNotEnableCacheAndIncludeInheritedAttributes(@TempDir Path tempDir) { Path databasePath = tempDir.resolve("object-update-only.sqlite"); XapiConfig config = new XapiConfig(); - config.statementTriggers = List.of(objectUpdateTrigger("Rabbit")); + config.statementTriggers = List.of(objectUpdateTrigger("SimEntity.Rabbit")); try (ObjectCache cache = new ObjectCache( config, @@ -78,12 +78,12 @@ void objectUpdateSubscriptionsDoNotEnableCacheAndIncludeInheritedAttributes(@Tem decoderRegistry, "jdbc:sqlite:" + databasePath)) { Set rabbitAttributes = - Set.copyOf(catalog.objectClass("Rabbit").orElseThrow().topLevelAttributeNames()); + Set.copyOf(catalog.objectClass("SimEntity.Rabbit").orElseThrow().topLevelAttributeNames()); assertFalse(cache.isEnabled()); assertTrue(cache.cacheSubscriptions().isEmpty()); - assertEquals(rabbitAttributes, cache.eventSubscriptions().get("Rabbit")); - assertEquals(rabbitAttributes, cache.subscriptions().get("Rabbit")); + assertEquals(rabbitAttributes, cache.eventSubscriptions().get("SimEntity.Rabbit")); + assertEquals(rabbitAttributes, cache.subscriptions().get("SimEntity.Rabbit")); assertTrue(cache.hasSubscriptions()); assertFalse(Files.exists(databasePath)); } @@ -91,7 +91,7 @@ void objectUpdateSubscriptionsDoNotEnableCacheAndIncludeInheritedAttributes(@Tem @Test void objectUpdatePreviousReferencesEnableOnlyTheirCacheAttributes(@TempDir Path tempDir) { - StatementTrigger trigger = objectUpdateTrigger("Rabbit"); + StatementTrigger trigger = objectUpdateTrigger("SimEntity.Rabbit"); trigger.statement = """ { "oldHunger":["previous",["Hunger"]], @@ -108,12 +108,12 @@ void objectUpdatePreviousReferencesEnableOnlyTheirCacheAttributes(@TempDir Path decoderRegistry, "jdbc:sqlite:" + tempDir.resolve("object-update-previous.sqlite"))) { Set rabbitAttributes = - Set.copyOf(catalog.objectClass("Rabbit").orElseThrow().topLevelAttributeNames()); + Set.copyOf(catalog.objectClass("SimEntity.Rabbit").orElseThrow().topLevelAttributeNames()); assertTrue(cache.isEnabled()); - assertEquals(Set.of("Hunger", "Position"), cache.cacheSubscriptions().get("Rabbit")); - assertEquals(rabbitAttributes, cache.eventSubscriptions().get("Rabbit")); - assertEquals(rabbitAttributes, cache.subscriptions().get("Rabbit")); + assertEquals(Set.of("Hunger", "Position"), cache.cacheSubscriptions().get("SimEntity.Rabbit")); + assertEquals(rabbitAttributes, cache.eventSubscriptions().get("SimEntity.Rabbit")); + assertEquals(rabbitAttributes, cache.subscriptions().get("SimEntity.Rabbit")); } } @@ -121,7 +121,7 @@ void objectUpdatePreviousReferencesEnableOnlyTheirCacheAttributes(@TempDir Path void objectCreateSubscriptionsDoNotEnableCacheAndIncludeInheritedAttributes(@TempDir Path tempDir) { Path databasePath = tempDir.resolve("object-create-only.sqlite"); XapiConfig config = new XapiConfig(); - config.statementTriggers = List.of(objectTrigger(StatementTrigger.Type.OBJECT_CREATE, "Rabbit")); + config.statementTriggers = List.of(objectTrigger(StatementTrigger.Type.OBJECT_CREATE, "SimEntity.Rabbit")); try (ObjectCache cache = new ObjectCache( config, @@ -130,11 +130,11 @@ void objectCreateSubscriptionsDoNotEnableCacheAndIncludeInheritedAttributes(@Tem decoderRegistry, "jdbc:sqlite:" + databasePath)) { Set rabbitAttributes = - Set.copyOf(catalog.objectClass("Rabbit").orElseThrow().topLevelAttributeNames()); + Set.copyOf(catalog.objectClass("SimEntity.Rabbit").orElseThrow().topLevelAttributeNames()); assertFalse(cache.isEnabled()); assertTrue(cache.cacheSubscriptions().isEmpty()); - assertEquals(rabbitAttributes, cache.eventSubscriptions().get("Rabbit")); + assertEquals(rabbitAttributes, cache.eventSubscriptions().get("SimEntity.Rabbit")); assertFalse(Files.exists(databasePath)); } } @@ -142,7 +142,7 @@ void objectCreateSubscriptionsDoNotEnableCacheAndIncludeInheritedAttributes(@Tem @Test void objectDeleteSubscriptionsEnableCacheForAllInheritedAttributes(@TempDir Path tempDir) { XapiConfig config = new XapiConfig(); - config.statementTriggers = List.of(objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "Rabbit")); + config.statementTriggers = List.of(objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity.Rabbit")); try (ObjectCache cache = new ObjectCache( config, @@ -151,12 +151,12 @@ void objectDeleteSubscriptionsEnableCacheForAllInheritedAttributes(@TempDir Path decoderRegistry, "jdbc:sqlite:" + tempDir.resolve("object-delete.sqlite"))) { Set rabbitAttributes = - Set.copyOf(catalog.objectClass("Rabbit").orElseThrow().topLevelAttributeNames()); + Set.copyOf(catalog.objectClass("SimEntity.Rabbit").orElseThrow().topLevelAttributeNames()); assertTrue(cache.isEnabled()); - assertEquals(rabbitAttributes, cache.cacheSubscriptions().get("Rabbit")); - assertEquals(rabbitAttributes, cache.eventSubscriptions().get("Rabbit")); - assertEquals(rabbitAttributes, cache.subscriptions().get("Rabbit")); + assertEquals(rabbitAttributes, cache.cacheSubscriptions().get("SimEntity.Rabbit")); + assertEquals(rabbitAttributes, cache.eventSubscriptions().get("SimEntity.Rabbit")); + assertEquals(rabbitAttributes, cache.subscriptions().get("SimEntity.Rabbit")); } } @@ -165,8 +165,8 @@ void objectUpdateSubscriptionsMergeWithoutChangingCacheRequirements(@TempDir Pat XapiConfig config = configWithQuery(); config.statementTriggers = List.of( config.statementTriggers.get(0), - objectUpdateTrigger("Rabbit"), - objectUpdateTrigger("Rabbit")); + objectUpdateTrigger("SimEntity.Rabbit"), + objectUpdateTrigger("SimEntity.Rabbit")); try (ObjectCache cache = new ObjectCache( config, @@ -175,12 +175,12 @@ void objectUpdateSubscriptionsMergeWithoutChangingCacheRequirements(@TempDir Pat decoderRegistry, "jdbc:sqlite:" + tempDir.resolve("object-update-merged.sqlite"))) { Set rabbitAttributes = - Set.copyOf(catalog.objectClass("Rabbit").orElseThrow().topLevelAttributeNames()); + Set.copyOf(catalog.objectClass("SimEntity.Rabbit").orElseThrow().topLevelAttributeNames()); assertTrue(cache.isEnabled()); - assertEquals(Set.of("EntityId", "Hunger"), cache.cacheSubscriptions().get("Rabbit")); - assertEquals(rabbitAttributes, cache.eventSubscriptions().get("Rabbit")); - assertEquals(rabbitAttributes, cache.subscriptions().get("Rabbit")); + assertEquals(Set.of("EntityId", "Hunger"), cache.cacheSubscriptions().get("SimEntity.Rabbit")); + assertEquals(rabbitAttributes, cache.eventSubscriptions().get("SimEntity.Rabbit")); + assertEquals(rabbitAttributes, cache.subscriptions().get("SimEntity.Rabbit")); } } @@ -206,10 +206,10 @@ void enabledWhenQueryInjectionsExistAndCanQueryReflectedValues(@TempDir Path tem fomXml, decoderRegistry, "jdbc:sqlite:" + databasePath)) { - cache.discoverObject("object-1", "Rabbit One", "Rabbit"); - cache.reflectAttributeValue("object-1", "Rabbit", "EntityId", encoded(encoderFactory + cache.discoverObject("object-1", "Rabbit One", "SimEntity.Rabbit"); + cache.reflectAttributeValue("object-1", "SimEntity.Rabbit", "EntityId", encoded(encoderFactory .createHLAASCIIstring("rabbit-one"))); - cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", encoded(encoderFactory.createHLAinteger32BE( + cache.reflectAttributeValue("object-1", "SimEntity.Rabbit", "Hunger", encoded(encoderFactory.createHLAinteger32BE( 75))); Criterion criteria = new Criterion( @@ -218,10 +218,10 @@ void enabledWhenQueryInjectionsExistAndCanQueryReflectedValues(@TempDir Path tem new ValueExpression(50)); assertTrue(cache.isEnabled()); - assertEquals(Set.of("EntityId", "Hunger"), cache.subscriptions().get("Rabbit")); + assertEquals(Set.of("EntityId", "Hunger"), cache.subscriptions().get("SimEntity.Rabbit")); assertEquals( "rabbit-one", - cache.findFirstValue("Rabbit", new Target(List.of("EntityId")), criteria).orElseThrow()); + cache.findFirstValue("SimEntity.Rabbit", new Target(List.of("EntityId")), criteria).orElseThrow()); assertTrue(Files.exists(databasePath)); } } @@ -232,7 +232,7 @@ void enabledWhenQueryAppearsOnlyInTriggerCriteria(@TempDir Path tempDir) { trigger.statement = "{}"; trigger.criteria = new Criterion( new QueryExpression( - "Rabbit", + "SimEntity.Rabbit", new Target(List.of("EntityId")), new Criterion( new Target(List.of("Hunger")), @@ -250,7 +250,7 @@ void enabledWhenQueryAppearsOnlyInTriggerCriteria(@TempDir Path tempDir) { decoderRegistry, "jdbc:sqlite:" + tempDir.resolve("criteria-query.sqlite"))) { assertTrue(cache.isEnabled()); - assertEquals(Set.of("EntityId", "Hunger"), cache.subscriptions().get("Rabbit")); + assertEquals(Set.of("EntityId", "Hunger"), cache.subscriptions().get("SimEntity.Rabbit")); } } @@ -259,13 +259,13 @@ void enabledWhenTrackedObjectsExistWithoutQueryInjections(@TempDir Path tempDir) Path databasePath = tempDir.resolve("tracked.sqlite"); try (ObjectCache cache = new ObjectCache( - configWithTrackedObject("Rabbit", List.of("EntityId", "Hunger"), false), + configWithTrackedObject("SimEntity.Rabbit", List.of("EntityId", "Hunger"), false), catalog, fomXml, decoderRegistry, "jdbc:sqlite:" + databasePath)) { assertTrue(cache.isEnabled()); - assertEquals(Set.of("EntityId", "Hunger"), cache.subscriptions().get("Rabbit")); + assertEquals(Set.of("EntityId", "Hunger"), cache.subscriptions().get("SimEntity.Rabbit")); assertTrue(Files.exists(databasePath)); } } @@ -284,20 +284,20 @@ void closeIsIdempotentAndDisablesCache(@TempDir Path tempDir) { cache.close(); assertFalse(cache.isEnabled()); - assertTrue(cache.currentObjects("Rabbit").isEmpty()); + assertTrue(cache.currentObjects("SimEntity.Rabbit").isEmpty()); } @Test void trackedObjectAllAttributesExpandsTopLevelFomAttributes(@TempDir Path tempDir) { try (ObjectCache cache = new ObjectCache( - configWithTrackedObject("Rabbit", null, true), + configWithTrackedObject("SimEntity.Rabbit", null, true), catalog, fomXml, decoderRegistry, "jdbc:sqlite:" + tempDir.resolve("all-attrs.sqlite"))) { assertEquals( Set.of("EntityId", "EntityType", "Position", "Hunger"), - stableAttributes(cache.subscriptions().get("Rabbit"), "EntityId", "EntityType", "Position", + stableAttributes(cache.subscriptions().get("SimEntity.Rabbit"), "EntityId", "EntityType", "Position", "Hunger")); } } @@ -318,7 +318,7 @@ void trackedObjectWildcardExpandsEveryObjectClassWithAllAttributes(@TempDir Path stableAttributes(cache.subscriptions().get("SimEntity"), "EntityId", "EntityType", "Position")); assertEquals( Set.of("EntityId", "EntityType", "Position", "Hunger"), - stableAttributes(cache.subscriptions().get("Rabbit"), "EntityId", "EntityType", "Position", + stableAttributes(cache.subscriptions().get("SimEntity.Rabbit"), "EntityId", "EntityType", "Position", "Hunger")); assertFalse(cache.subscriptions().containsKey("HLAobjectRoot")); } @@ -327,7 +327,7 @@ void trackedObjectWildcardExpandsEveryObjectClassWithAllAttributes(@TempDir Path @Test void trackedObjectsMergeWithQueryInjections(@TempDir Path tempDir) { XapiConfig config = configWithQuery(); - config.objectCacheConfig = objectCacheConfig(trackedObject("Rabbit", List.of("Position"), false)); + config.objectCacheConfig = objectCacheConfig(trackedObject("SimEntity.Rabbit", List.of("Position"), false)); try (ObjectCache cache = new ObjectCache( config, @@ -335,14 +335,14 @@ void trackedObjectsMergeWithQueryInjections(@TempDir Path tempDir) { fomXml, decoderRegistry, "jdbc:sqlite:" + tempDir.resolve("merged.sqlite"))) { - assertEquals(Set.of("EntityId", "Hunger", "Position"), cache.subscriptions().get("Rabbit")); + assertEquals(Set.of("EntityId", "Hunger", "Position"), cache.subscriptions().get("SimEntity.Rabbit")); } } private XapiConfig configWithQuery() { StatementTrigger trigger = new StatementTrigger(); trigger.statement = """ - {"actor":{"name":["query","Rabbit",["EntityId"],[["Hunger"],">",50]]}} + {"actor":{"name":["query","SimEntity.Rabbit",["EntityId"],[["Hunger"],">",50]]}} """; XapiConfig config = new XapiConfig(); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlanTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlanTest.java index e7829cf..fab1edc 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlanTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlanTest.java @@ -37,7 +37,7 @@ void combinesDirectAndAncestorRequirementsWithoutMutatingThePlan() { {"name":["query","SimEntity",["FirstName"],null]} """; TrackedObject trackedRabbit = new TrackedObject(); - trackedRabbit.clazz = "Rabbit"; + trackedRabbit.clazz = "SimEntity.Rabbit"; trackedRabbit.attributes = List.of("Hunger"); ObjectCacheConfig cacheConfig = new ObjectCacheConfig(); cacheConfig.trackedObjects = List.of(trackedRabbit); @@ -48,19 +48,19 @@ void combinesDirectAndAncestorRequirementsWithoutMutatingThePlan() { ObjectSubscriptionPlan plan = ObjectSubscriptionPlan.from(config, catalog); assertEquals(Set.of("FirstName"), plan.cacheSubscriptions().get("SimEntity")); - assertEquals(Set.of("FirstName"), plan.cacheSubscriptions().get("Carrot")); - assertEquals(Set.of("FirstName", "Hunger"), plan.cacheSubscriptions().get("Rabbit")); - assertEquals(Set.of("FirstName"), plan.cacheSubscriptions().get("Wolf")); + assertEquals(Set.of("FirstName"), plan.cacheSubscriptions().get("SimEntity.Carrot")); + assertEquals(Set.of("FirstName", "Hunger"), plan.cacheSubscriptions().get("SimEntity.Rabbit")); + assertEquals(Set.of("FirstName"), plan.cacheSubscriptions().get("SimEntity.Wolf")); assertTrue(plan.eventSubscriptions().isEmpty()); assertEquals(Set.of("FirstName"), plan.effectiveAttributes("SimEntity")); - assertEquals(Set.of("FirstName", "Hunger"), plan.effectiveAttributes("Rabbit")); - assertEquals(Set.of("FirstName"), plan.effectiveAttributes("Wolf")); + assertEquals(Set.of("FirstName", "Hunger"), plan.effectiveAttributes("SimEntity.Rabbit")); + assertEquals(Set.of("FirstName"), plan.effectiveAttributes("SimEntity.Wolf")); assertThrows( UnsupportedOperationException.class, - () -> plan.subscriptions().put("Wolf", Set.of("Hunger"))); + () -> plan.subscriptions().put("SimEntity.Wolf", Set.of("Hunger"))); assertThrows( UnsupportedOperationException.class, - () -> plan.subscriptions().get("Rabbit").add("EntityId")); + () -> plan.subscriptions().get("SimEntity.Rabbit").add("EntityId")); } @Test @@ -120,7 +120,7 @@ void previousAndLookupReferencesExpandOnlyTheirAttributesAcrossDescendants() { catalog.objectClassAndDescendants("SimEntity")) { assertEquals( Set.of("EntityId", "FirstName", "Position"), - plan.cacheSubscriptions().get(clazz.localName())); + plan.cacheSubscriptions().get(clazz.hlaName())); } assertCompleteHierarchy(plan.eventSubscriptions()); } @@ -142,7 +142,7 @@ void trackedBaseClassExpandsExplicitAndAllAttributeRequirements() { catalog.objectClassAndDescendants("SimEntity")) { assertEquals( Set.of("EntityId", "Position"), - explicitPlan.cacheSubscriptions().get(clazz.localName())); + explicitPlan.cacheSubscriptions().get(clazz.hlaName())); } TrackedObject all = new TrackedObject(); @@ -176,7 +176,7 @@ private void assertCompleteHierarchy(Map> subscriptions) { catalog.objectClassAndDescendants("SimEntity")) { assertEquals( Set.copyOf(clazz.topLevelAttributeNames()), - subscriptions.get(clazz.localName())); + subscriptions.get(clazz.hlaName())); } } } diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCachePersistenceTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCachePersistenceTest.java index 89a9962..edc4274 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCachePersistenceTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCachePersistenceTest.java @@ -59,7 +59,7 @@ void isolatesTablesInConfiguredSchemaAndUsesPostgresqlBinaryType() throws SQLExc void synchronizesAttributeIdentityAfterExplicitFomIds() throws SQLException { try (ObjectCache cache = newCache("identity")) { long seededMaximum = scalarLong(cache, "SELECT MAX(id) FROM fom_attribute"); - int rabbitClassId = catalog.objectClass("Rabbit").orElseThrow().id(); + int rabbitClassId = catalog.objectClass("SimEntity.Rabbit").orElseThrow().id(); try (PreparedStatement statement = cache.connection().prepareStatement(""" INSERT INTO fom_attribute (class_id, attribute_name, path_key, data_type, primitive_type, is_leaf) diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java index eafbc8d..52411e7 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java @@ -25,27 +25,27 @@ class QueryReferenceCollectorTest { @Test void findsWholeNodeAndInlineQueryInjections() { StatementTrigger wholeNode = trigger(""" - {"actor":{"name":["query","Rabbit",["EntityId"],[["Hunger"],">",50]]}} + {"actor":{"name":["query","SimEntity.Rabbit",["EntityId"],[["Hunger"],">",50]]}} """); StatementTrigger inline = trigger(""" - {"result":{"response":"at=<<[\\"query\\",\\"Rabbit\\",[\\"Position\\",\\"Y\\"],[[\\"Position\\",\\"X\\"],\\"<\\",15]]>>"}} + {"result":{"response":"at=<<[\\"query\\",\\"SimEntity.Rabbit\\",[\\"Position\\",\\"Y\\"],[[\\"Position\\",\\"X\\"],\\"<\\",15]]>>"}} """); Map> references = QueryReferenceCollector.collect(List.of(wholeNode, inline)); - assertEquals(Set.of("EntityId", "Hunger", "Position"), references.get("Rabbit")); + assertEquals(Set.of("EntityId", "Hunger", "Position"), references.get("SimEntity.Rabbit")); } @Test void ignoresTriggerExpressionTargetsInsideQueryCriteria() { StatementTrigger trigger = trigger(""" - {"actor":{"name":["query","Rabbit",["EntityId"],[["Hunger"],">",["trigger",["DesiredHunger"]]]]}} + {"actor":{"name":["query","SimEntity.Rabbit",["EntityId"],[["Hunger"],">",["trigger",["DesiredHunger"]]]]}} """); Map> references = QueryReferenceCollector.collect(List.of(trigger)); - assertEquals(Set.of("EntityId", "Hunger"), references.get("Rabbit")); - assertFalse(references.get("Rabbit").contains("DesiredHunger")); + assertEquals(Set.of("EntityId", "Hunger"), references.get("SimEntity.Rabbit")); + assertFalse(references.get("SimEntity.Rabbit").contains("DesiredHunger")); } @Test @@ -116,7 +116,7 @@ void findsObjectUpdatePreviousReferencesOnly() { } """); update.type = StatementTrigger.Type.OBJECT_UPDATE; - update.clazz = "Rabbit"; + update.clazz = "SimEntity.Rabbit"; update.criteria = new Criterion( new PreviousExpression(new Target(List.of("EntityId"))), ComparisonOperator.NEQ, @@ -125,13 +125,13 @@ void findsObjectUpdatePreviousReferencesOnly() { {"invalid":["previous",["Hunger"]]} """); create.type = StatementTrigger.Type.OBJECT_CREATE; - create.clazz = "Wolf"; + create.clazz = "SimEntity.Wolf"; Map> references = QueryReferenceCollector.collect(List.of(update, create)); - assertEquals(Set.of("EntityId", "Position", "Hunger"), references.get("Rabbit")); - assertFalse(references.containsKey("Wolf")); + assertEquals(Set.of("EntityId", "Position", "Hunger"), references.get("SimEntity.Rabbit")); + assertFalse(references.containsKey("SimEntity.Wolf")); } private StatementTrigger trigger(String statement) { diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCachePersistenceTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCachePersistenceTest.java index c80701d..55e6283 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCachePersistenceTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCachePersistenceTest.java @@ -31,7 +31,7 @@ protected ObjectCache newCache( @Test void configuresSqliteSchemaVersionAndForeignKeys() throws SQLException { try (ObjectCache cache = newCache()) { - assertEquals(1, scalarLong(cache, "PRAGMA user_version")); + assertEquals(2, scalarLong(cache, "PRAGMA user_version")); assertEquals(1, scalarLong(cache, "PRAGMA foreign_keys")); } } From f93281f623446ea9aca6323f83b0d719fba370c3 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Mon, 3 Aug 2026 11:50:15 -0400 Subject: [PATCH 33/36] require root-relative canonical interaction names and remove shim --- .../hlaxapi/HlaInterfaceImpl.java | 10 +- .../hlaxapi/InjectionHandler.java | 32 ++++-- .../hlaxapi/cache/FomCatalog.java | 48 +------- .../com/yetanalytics/ConfigParserTest.java | 36 +++--- .../hlaxapi/HlaInteractionDispatchTest.java | 103 +++++++++++++++--- .../hlaxapi/cache/FomCatalogTest.java | 13 ++- .../injection/XapiValueGeneratorTest.java | 7 +- .../config/AmbiguousClassNamesFOM.xml | 3 + 8 files changed, 151 insertions(+), 101 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index f983449..79b0ec4 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -10,7 +10,6 @@ import java.util.Map; import java.util.Set; -import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.beans.factory.annotation.Autowired; @@ -86,6 +85,7 @@ public class HlaInterfaceImpl extends NullFederateAmbassador implements HlaInter private static final Logger logger = LogManager.getLogger(HlaInterfaceImpl.class); private static final String OBJECT_ROOT_PREFIX = "HLAobjectRoot."; + private static final String INTERACTION_ROOT_PREFIX = "HLAinteractionRoot."; private RTIambassador ambassador; @@ -518,7 +518,7 @@ private void receiveInteraction(InteractionClassHandle interactionClass, Paramet try { String interactionName = ambassador.getInteractionClassName(interactionClass); logger.trace("Interaction Handle: {}", interactionName); - String interactionKey = StringUtils.substringAfterLast(interactionName, "."); + String interactionKey = rootRelativeInteractionClassName(interactionName); // Create Interaction-specific injection context to pass to trigger processor InteractionInjectionContext context = new InteractionInjectionContext(interactionKey, @@ -534,6 +534,12 @@ private void receiveInteraction(InteractionClassHandle interactionClass, Paramet } } + private String rootRelativeInteractionClassName(String className) { + return className != null && className.startsWith(INTERACTION_ROOT_PREFIX) + ? className.substring(INTERACTION_ROOT_PREFIX.length()) + : className; + } + private Map getMapWithParameterNames(InteractionClassHandle interactionClass, ParameterHandleValueMap theParameters) { Map parameters = new HashMap(); diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java index 49c3ca4..6e8e015 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -11,7 +11,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import com.yetanalytics.hlaxapi.FOMXML.PathCheckResult; import com.yetanalytics.hlaxapi.cache.CachedObject; import com.yetanalytics.hlaxapi.cache.FomCatalog; import com.yetanalytics.hlaxapi.cache.ObjectCache; @@ -188,17 +187,28 @@ private IllegalArgumentException missingTarget( } private EventTargetDefinition interactionTargetDefinition(String hlaClass, Target target) { - PathCheckResult path = fomXml.checkInteractionParameterPath(hlaClass, target.parts); - String topLevelType = null; - String topLevelName = FomCatalog.topLevelTargetPart(target.parts); - if (topLevelName != null) { - try { - topLevelType = fomXml.getParameterType(hlaClass, topLevelName, true); - } catch (XPathExpressionException e) { - logger.warn("Unable to resolve interaction parameter type for {}.{}", hlaClass, topLevelName, e); - } + if (fomCatalog == null) { + throw new IllegalStateException("FOM interaction catalog is not configured"); } - return new EventTargetDefinition(path.exists, path.primitiveType, topLevelType); + Optional interactionClass = + fomCatalog.interactionClass(hlaClass); + if (interactionClass.isEmpty()) { + return EventTargetDefinition.missing(); + } + List targetParts = target == null ? null : target.parts; + String pathKey = FomCatalog.targetPath(targetParts); + String topLevelName = FomCatalog.topLevelTargetPart(targetParts); + Optional targetParameter = + interactionClass.orElseThrow().parameter(pathKey); + Optional topLevelParameter = + interactionClass.orElseThrow().parameter(topLevelName); + if (targetParameter.isEmpty() || topLevelParameter.isEmpty()) { + return EventTargetDefinition.missing(); + } + return new EventTargetDefinition( + true, + targetParameter.orElseThrow().primitiveType(), + topLevelParameter.orElseThrow().dataType()); } private EventTargetDefinition objectTargetDefinition(String hlaClass, Target target) { diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java index 3b4e043..c61c51a 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java @@ -10,7 +10,6 @@ import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.function.Function; import javax.xml.xpath.XPathExpressionException; import org.springframework.stereotype.Component; @@ -23,7 +22,6 @@ public final class FomCatalog { private final Map classesByName; private final Map interactionsByName; - private final Map> interactionsByShortName; private final Map classesById; private final Map attributesById; @@ -38,10 +36,6 @@ public FomCatalog(FOMXML fomXml) { this.classesByName = Collections.unmodifiableMap(new LinkedHashMap<>(builder.classesByName)); this.interactionsByName = Collections.unmodifiableMap(new LinkedHashMap<>(builder.interactionsByName)); - this.interactionsByShortName = - indexByShortName( - interactionsByName.values(), - definition -> shortName(definition.hlaName())); Map byId = new LinkedHashMap<>(); Map attrsById = new LinkedHashMap<>(); @@ -85,19 +79,14 @@ public Collection interactionClasses() { * Resolves a canonical interaction class name exactly. */ public Optional canonicalInteractionClass(String name) { - return Optional.ofNullable(interactionsByName.get(normalizeName(name))); + return Optional.ofNullable(interactionsByName.get(name)); } /** - * Temporary compatibility lookup used while runtime callers migrate to canonical names. - * Canonical names resolve exactly; local names resolve only when globally unique. + * Resolves an interaction class by its exact canonical name. */ public Optional interactionClass(String name) { - Optional canonical = canonicalInteractionClass(name); - if (canonical.isPresent()) { - return canonical; - } - return uniqueLocalMatch(interactionsByShortName.get(shortName(name))); + return canonicalInteractionClass(name); } public List objectClassAndDescendants(String name) { @@ -182,37 +171,6 @@ public static String wildcardArrayIndexes(String pathKey) { return pathKey.replaceAll("\\[[0-9]+\\]", "[]"); } - static String shortName(String hlaName) { - if (hlaName == null) { - return null; - } - String trimmed = normalizeName(hlaName); - int index = trimmed.lastIndexOf('.'); - return index >= 0 ? trimmed.substring(index + 1) : trimmed; - } - - private static String normalizeName(String name) { - return name == null ? null : name.trim(); - } - - private static Optional uniqueLocalMatch(List matches) { - return matches != null && matches.size() == 1 - ? Optional.of(matches.get(0)) - : Optional.empty(); - } - - private static Map> indexByShortName( - Collection values, - Function shortName) { - Map> mutable = new LinkedHashMap<>(); - for (T value : values) { - mutable.computeIfAbsent(shortName.apply(value), ignored -> new ArrayList<>()).add(value); - } - Map> immutable = new LinkedHashMap<>(); - mutable.forEach((name, matches) -> immutable.put(name, List.copyOf(matches))); - return Collections.unmodifiableMap(immutable); - } - private boolean isSameOrDescendant(ObjectClassDef candidate, ObjectClassDef requestedClass) { ObjectClassDef current = candidate; while (current != null) { diff --git a/src/test/java/com/yetanalytics/ConfigParserTest.java b/src/test/java/com/yetanalytics/ConfigParserTest.java index 5bf5f30..0b6accc 100644 --- a/src/test/java/com/yetanalytics/ConfigParserTest.java +++ b/src/test/java/com/yetanalytics/ConfigParserTest.java @@ -33,6 +33,7 @@ import com.yetanalytics.hlaxapi.SimulationConfig; import com.yetanalytics.hlaxapi.TriggerProcessor; import com.yetanalytics.hlaxapi.cache.CachedObject; +import com.yetanalytics.hlaxapi.cache.FomCatalog; import com.yetanalytics.hlaxapi.cache.ValueResolution; import com.yetanalytics.hlaxapi.config.ConfigParser; import com.yetanalytics.hlaxapi.config.XapiConfig; @@ -65,9 +66,7 @@ public void parsesConfigFile() throws IOException { SimulationConfig simConfig = new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"); HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); - InjectionHandler ih = new InjectionHandler(); - ih.setFomXml(new FOMXML(simConfig, decoderRegistry)); - ih.setHLADecoderRegistry(decoderRegistry); + InjectionHandler ih = interactionHandler(simConfig, decoderRegistry); TriggerProcessor triggerProcessor = new TriggerProcessor(ih); @@ -300,9 +299,7 @@ public void handlesFixedRecordFieldAccess() { SimulationConfig simConfig = new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"); HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); - InjectionHandler ih = new InjectionHandler(); - ih.setFomXml(new FOMXML(simConfig, decoderRegistry)); - ih.setHLADecoderRegistry(decoderRegistry); + InjectionHandler ih = interactionHandler(simConfig, decoderRegistry); byte[] gridPosition = java.nio.ByteBuffer.allocate(Integer.BYTES * 2) .order(java.nio.ByteOrder.BIG_ENDIAN) @@ -326,9 +323,7 @@ public void handlesFixedRecordFieldAccessInsideArray() { SimulationConfig simConfig = new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"); HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); - InjectionHandler ih = new InjectionHandler(); - ih.setFomXml(new FOMXML(simConfig, decoderRegistry)); - ih.setHLADecoderRegistry(decoderRegistry); + InjectionHandler ih = interactionHandler(simConfig, decoderRegistry); byte[] gridPosition = java.nio.ByteBuffer.allocate(Integer.BYTES * 2) .order(java.nio.ByteOrder.BIG_ENDIAN) @@ -354,9 +349,7 @@ public void handlesFixedRecordGridPositionFieldAccess() { SimulationConfig simConfig = new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"); HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); - InjectionHandler ih = new InjectionHandler(); - ih.setFomXml(new FOMXML(simConfig, decoderRegistry)); - ih.setHLADecoderRegistry(decoderRegistry); + InjectionHandler ih = interactionHandler(simConfig, decoderRegistry); byte[] gridPosition = java.nio.ByteBuffer.allocate(Integer.BYTES * 2) .order(java.nio.ByteOrder.BIG_ENDIAN) @@ -384,9 +377,7 @@ public void inlinePlaceholderProcessing() throws IOException { SimulationConfig simConfig = new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"); HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); - InjectionHandler ih = new InjectionHandler(); - ih.setFomXml(new FOMXML(simConfig, decoderRegistry)); - ih.setHLADecoderRegistry(decoderRegistry); + InjectionHandler ih = interactionHandler(simConfig, decoderRegistry); TriggerProcessor triggerProcessor = new TriggerProcessor(ih); @@ -412,9 +403,7 @@ public void inlinePlaceholderProcessingHandlesMultiplePlaceholders() throws IOEx SimulationConfig simConfig = new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"); HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); - InjectionHandler ih = new InjectionHandler(); - ih.setFomXml(new FOMXML(simConfig, decoderRegistry)); - ih.setHLADecoderRegistry(decoderRegistry); + InjectionHandler ih = interactionHandler(simConfig, decoderRegistry); TriggerProcessor triggerProcessor = new TriggerProcessor(ih); @@ -453,6 +442,17 @@ public ValueResolution handleTrigger(Target t, InjectionContext context) { assertTrue(out.contains("\"name\":\"[alpha, beta]\"")); } + private static InjectionHandler interactionHandler( + SimulationConfig simulationConfig, + HLADecoderRegistry decoderRegistry) { + FOMXML fomXml = new FOMXML(simulationConfig, decoderRegistry); + InjectionHandler handler = new InjectionHandler(); + handler.setFomXml(fomXml); + handler.setHLADecoderRegistry(decoderRegistry); + handler.setFomCatalog(new FomCatalog(fomXml)); + return handler; + } + @Test public void exposesStatementPathForWholeNodeInjections() { List> paths = new ArrayList<>(); diff --git a/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java b/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java index 7f95b01..624ce2a 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java @@ -12,10 +12,12 @@ import hla.rti1516e.ParameterHandleValueMap; import hla.rti1516e.RTIambassador; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.nio.ByteOrder; import java.util.ArrayList; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import org.portico.impl.hla1516e.types.HLA1516eHandle; import org.portico.impl.hla1516e.types.HLA1516eParameterHandleValueMap; @@ -24,50 +26,117 @@ class HlaInteractionDispatchTest { @Test - void interactionCallbackStillRendersAndEnqueuesThroughTheSharedDispatcher() throws Exception { + void canonicalNestedInteractionsSubscribeAndDispatchIndependently() throws Exception { HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); FOMXML fomXml = new FOMXML( - new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"), + new SimulationConfig( + null, + null, + null, + null, + "src/test/resources/config/AmbiguousClassNamesFOM.xml"), decoderRegistry); InjectionHandler injectionHandler = new InjectionHandler(); injectionHandler.setFomXml(fomXml); injectionHandler.setHLADecoderRegistry(decoderRegistry); FomCatalog catalog = new FomCatalog(fomXml); injectionHandler.setFomCatalog(catalog); - StatementTrigger trigger = new StatementTrigger(); - trigger.type = StatementTrigger.Type.INTERACTION; - trigger.clazz = "StepCompleted"; - trigger.statement = """ - {"step":["trigger",["StepNumber"]]} - """; + StatementTrigger entityTrigger = interactionTrigger( + "EntityEvents.Updated", + """ + {"branch":"entity","id":["trigger",["EntityId"]],"value":["trigger",["Hunger"]]} + """); + StatementTrigger otherTrigger = interactionTrigger( + "OtherEvents.Updated", + """ + {"branch":"other","id":["trigger",["OtherId"]],"value":["trigger",["Speed"]]} + """); XapiConfig config = new XapiConfig(); - config.statementTriggers = List.of(trigger); + config.statementTriggers = List.of(entityTrigger, otherTrigger); RecordingXapiClient xapiClient = new RecordingXapiClient(); - InteractionClassHandle interactionClass = + InteractionClassHandle entityClass = (InteractionClassHandle) new HLA1516eHandle(101); - ParameterHandle stepNumber = (ParameterHandle) new HLA1516eHandle(102); + InteractionClassHandle otherClass = + (InteractionClassHandle) new HLA1516eHandle(102); + ParameterHandle entityId = (ParameterHandle) new HLA1516eHandle(201); + ParameterHandle hunger = (ParameterHandle) new HLA1516eHandle(202); + ParameterHandle otherId = (ParameterHandle) new HLA1516eHandle(203); + ParameterHandle speed = (ParameterHandle) new HLA1516eHandle(204); + Map classHandles = Map.of( + "EntityEvents.Updated", entityClass, + "OtherEvents.Updated", otherClass); + Map classNames = Map.of( + entityClass, "EntityEvents.Updated", + otherClass, "OtherEvents.Updated"); + Map parameterNames = Map.of( + entityId, "EntityId", + hunger, "Hunger", + otherId, "OtherId", + speed, "Speed"); + List requestedClasses = new ArrayList<>(); + List subscribedClasses = new ArrayList<>(); RTIambassador ambassador = (RTIambassador) Proxy.newProxyInstance( RTIambassador.class.getClassLoader(), new Class[] {RTIambassador.class}, (proxy, method, args) -> switch (method.getName()) { - case "getInteractionClassName" -> "HLAinteractionRoot.StepCompleted"; - case "getParameterName" -> "StepNumber"; + case "getInteractionClassHandle" -> { + String className = (String) args[0]; + requestedClasses.add(className); + yield classHandles.get(className); + } + case "subscribeInteractionClass" -> { + subscribedClasses.add((InteractionClassHandle) args[0]); + yield null; + } + case "getInteractionClassName" -> + "HLAinteractionRoot." + classNames.get(args[0]); + case "getParameterName" -> parameterNames.get(args[1]); default -> defaultValue(method.getReturnType()); }); HlaInterfaceImpl hlaInterface = new HlaInterfaceImpl(); setField(hlaInterface, "ambassador", ambassador); + setField(hlaInterface, "xapiConfig", config); setField( hlaInterface, "triggerProcessor", new TriggerProcessor(config, injectionHandler, catalog)); setField(hlaInterface, "xapiClient", xapiClient); - ParameterHandleValueMap parameters = new HLA1516eParameterHandleValueMap(); - parameters.put(stepNumber, HLAEncodingTestSupport.int32(42, ByteOrder.BIG_ENDIAN)); + invokeSubscribeInteractions(hlaInterface); + + ParameterHandleValueMap entityParameters = new HLA1516eParameterHandleValueMap(); + entityParameters.put(entityId, HLAEncodingTestSupport.int32(10, ByteOrder.BIG_ENDIAN)); + entityParameters.put(hunger, HLAEncodingTestSupport.int32(11, ByteOrder.BIG_ENDIAN)); + hlaInterface.receiveInteraction(entityClass, entityParameters, null, null, null, null); - hlaInterface.receiveInteraction(interactionClass, parameters, null, null, null, null); + ParameterHandleValueMap otherParameters = new HLA1516eParameterHandleValueMap(); + otherParameters.put(otherId, HLAEncodingTestSupport.int32(20, ByteOrder.BIG_ENDIAN)); + otherParameters.put(speed, HLAEncodingTestSupport.int32(21, ByteOrder.BIG_ENDIAN)); + hlaInterface.receiveInteraction(otherClass, otherParameters, null, null, null, null); + + assertEquals( + List.of("EntityEvents.Updated", "OtherEvents.Updated"), + requestedClasses); + assertEquals(List.of(entityClass, otherClass), subscribedClasses); + assertEquals( + List.of( + "{\"branch\":\"entity\",\"id\":10,\"value\":11}", + "{\"branch\":\"other\",\"id\":20,\"value\":21}"), + xapiClient.statements); + } + + private static StatementTrigger interactionTrigger(String className, String statement) { + StatementTrigger trigger = new StatementTrigger(); + trigger.type = StatementTrigger.Type.INTERACTION; + trigger.clazz = className; + trigger.statement = statement; + return trigger; + } - assertEquals(List.of("{\"step\":42}"), xapiClient.statements); + private static void invokeSubscribeInteractions(HlaInterfaceImpl hlaInterface) throws Exception { + Method method = HlaInterfaceImpl.class.getDeclaredMethod("subscribeInteractions"); + method.setAccessible(true); + method.invoke(hlaInterface); } private static void setField(Object target, String fieldName, Object value) throws Exception { diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java index 7dfe507..83a43fa 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java @@ -48,14 +48,14 @@ void resolvesObjectClassesWithTheirDescendants() { FomCatalog catalog = catalog("config/HlaFedereplFOM.xml"); assertEquals( - List.of("SimEntity", "Carrot", "Rabbit", "Wolf"), + List.of("SimEntity", "SimEntity.Carrot", "SimEntity.Rabbit", "SimEntity.Wolf"), catalog.objectClassAndDescendants("SimEntity").stream() - .map(definition -> FomCatalog.shortName(definition.hlaName())) + .map(FomCatalog.ObjectClassDef::hlaName) .toList()); assertEquals( - List.of("Rabbit"), + List.of("SimEntity.Rabbit"), catalog.objectClassAndDescendants("SimEntity.Rabbit").stream() - .map(definition -> FomCatalog.shortName(definition.hlaName())) + .map(FomCatalog.ObjectClassDef::hlaName) .toList()); assertEquals(List.of(), catalog.objectClassAndDescendants("MissingObject")); } @@ -129,10 +129,13 @@ void indexesObjectAndInteractionClassesByCanonicalNameWithoutLocalCollisions() { assertTrue(otherUpdated.parameter("Speed").isPresent()); assertFalse(otherUpdated.parameter("EntityId").isPresent()); assertTrue(catalog.interactionClass("Updated").isEmpty()); + assertTrue(catalog.interactionClass("Created").isEmpty()); + assertTrue(catalog.interactionClass("EntityEvents.Created").isPresent()); + assertTrue(catalog.interactionClass(" HLAinteractionRoot.EntityEvents.Created ").isEmpty()); } @Test - void flattensInteractionParametersAndRetainsTemporaryUniqueLocalLookup() { + void flattensInteractionParameters() { FomCatalog catalog = catalog("config/HlaFedereplFOM.xml"); FomCatalog.InteractionClassDef entityMoved = diff --git a/src/test/java/com/yetanalytics/hlaxapi/injection/XapiValueGeneratorTest.java b/src/test/java/com/yetanalytics/hlaxapi/injection/XapiValueGeneratorTest.java index 80fda73..43f4326 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/injection/XapiValueGeneratorTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/injection/XapiValueGeneratorTest.java @@ -19,6 +19,7 @@ import com.yetanalytics.hlaxapi.SimulationConfig; import com.yetanalytics.hlaxapi.TriggerProcessor; import com.yetanalytics.hlaxapi.TriggerProcessor.TriggerProcessingResult; +import com.yetanalytics.hlaxapi.cache.FomCatalog; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.StatementTrigger.Type; import com.yetanalytics.hlaxapi.config.model.Target; @@ -223,9 +224,11 @@ void validationInjectionTests() { SimulationConfig simConfig = new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"); HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); + FOMXML fomXml = new FOMXML(simConfig, decoderRegistry); InjectionHandler ih = new InjectionHandler(); - ih.setFomXml(new FOMXML(simConfig, decoderRegistry)); + ih.setFomXml(fomXml); ih.setHLADecoderRegistry(decoderRegistry); + ih.setFomCatalog(new FomCatalog(fomXml)); StatementValidator validator = new StatementValidator(); TriggerProcessor tp = new TriggerProcessor(ih); @@ -249,5 +252,3 @@ void validationInjectionTests() { } } } - - diff --git a/src/test/resources/config/AmbiguousClassNamesFOM.xml b/src/test/resources/config/AmbiguousClassNamesFOM.xml index ecbdda6..591141c 100644 --- a/src/test/resources/config/AmbiguousClassNamesFOM.xml +++ b/src/test/resources/config/AmbiguousClassNamesFOM.xml @@ -49,6 +49,9 @@ HLAinteger32BE + + Created + OtherEvents From a77eb13b667af353e64793e72723decb9ef57403 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Mon, 3 Aug 2026 12:08:51 -0400 Subject: [PATCH 34/36] handle interaction stuff only in FOMXML --- .../java/com/yetanalytics/hlaxapi/FOMXML.java | 189 +++++++----------- .../hlaxapi/InjectionHandler.java | 28 +-- .../hlaxapi/cache/FomCatalog.java | 120 +---------- .../com/yetanalytics/ConfigParserTest.java | 36 ++-- src/test/java/com/yetanalytics/FOMTest.java | 63 ++++-- .../hlaxapi/cache/FomCatalogTest.java | 48 +---- .../injection/XapiValueGeneratorTest.java | 5 +- 7 files changed, 143 insertions(+), 346 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java b/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java index 88fde68..2841c2d 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java +++ b/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java @@ -96,7 +96,7 @@ public String toString() { public PathCheckResult checkInteractionParameterPath(String interactionName, List pathParts){ try { - return checkParameterPath(interactionName, true, pathParts); + return checkInteractionParameterPathInternal(interactionName, pathParts); } catch (XPathExpressionException e) { logger.error("Error checking interaction parameter path", e); return new PathCheckResult(false, null, null); @@ -107,25 +107,6 @@ public PathCheckResult checkInteractionParameterPath(String interactionName, Str return checkInteractionParameterPath(interactionName, List.of(param)); } - public PathCheckResult checkObjectParameterPath(String objectName, List pathParts) { - try { - return checkParameterPath(objectName, false, pathParts); - } catch (XPathExpressionException e) { - logger.error("Error checking object parameter path", e); - return new PathCheckResult(false, null, null); - } - } - - public PathCheckResult checkObjectParameterPath(String objectName, String param){ - return checkObjectParameterPath(objectName, List.of(param)); - } - - - - private final String findInteractionByNameExp = - "//interactionClass[name[text()='%s']]/parameter[name[text()='%s']]/dataType"; - private final String findObjectByNameExp = - "//objectClass[name[text()='%s']]/attribute[name[text()='%s']]/dataType"; private final String fixedRecordDataTypeExp = "//fixedRecordData[name[text()='%s']]/field[name[text()='%s']]/dataType"; private final String arrayDataTypeExp = "//arrayData[name[text()='%s']]/dataType"; @@ -137,10 +118,12 @@ public PathCheckResult checkObjectParameterPath(String objectName, String param) * root of the resolved path. * */ - private PathCheckResult checkParameterPath(String entityName, boolean isInteraction, List pathParts) + private PathCheckResult checkInteractionParameterPathInternal( + String interactionName, + List pathParts) throws XPathExpressionException { - if (entityName == null || entityName.isEmpty()) - throw new IllegalArgumentException("entity name is required"); + if (interactionName == null || interactionName.isEmpty()) + throw new IllegalArgumentException("interaction name is required"); if (pathParts == null || pathParts.isEmpty()) throw new IllegalArgumentException("First element of pathParts must be the parameter name (String)"); @@ -150,7 +133,7 @@ private PathCheckResult checkParameterPath(String entityName, boolean isInteract throw new IllegalArgumentException("First element of pathParts must be the parameter name (String)"); } - String currentTypeName = getParameterType(entityName, (String) first, isInteraction); + String currentTypeName = getInteractionParameterType(interactionName, (String) first); if (currentTypeName == null || currentTypeName.isEmpty()) { return new PathCheckResult(false, null, null); @@ -166,14 +149,10 @@ private PathCheckResult checkParameterPath(String entityName, boolean isInteract if (idx < 0) { throw new IllegalArgumentException("Array index must be 0 or greater"); } - // resolve array element dataType for currentTypeName - String exp = String.format(arrayDataTypeExp, currentTypeName); - foundType = (String) xPath.compile(exp).evaluate(doc, XPathConstants.STRING); + foundType = getArrayElementType(currentTypeName); } else if (part instanceof String) { String fieldName = (String) part; - // try fixedRecord field - String fixedRecordExp = String.format(fixedRecordDataTypeExp, currentTypeName, fieldName); - foundType = (String) xPath.compile(fixedRecordExp).evaluate(doc, XPathConstants.STRING); + foundType = getFixedRecordFieldType(currentTypeName, fieldName); } else { throw new IllegalArgumentException("Path parts must be String (field name) or Integer (array index)"); } @@ -185,20 +164,7 @@ private PathCheckResult checkParameterPath(String entityName, boolean isInteract currentTypeName = foundType; } - // currentTypeName is now the type at the end of the path. It may be - // primitive, simpleData, enumeratedData, or another custom type. - // If it's a primitive, return it. Otherwise try to resolve to a primitive via getRawType. - if (isPrim(currentTypeName)) { - return new PathCheckResult(true, currentTypeName, currentTypeName); - } - - String raw = getRawType(currentTypeName); - if (raw != null && !raw.isEmpty() && isPrim(raw)) { - return new PathCheckResult(true, raw, currentTypeName); - } - - // Not resolved to a primitive - return new PathCheckResult(true, null, currentTypeName); + return new PathCheckResult(true, resolvePrimitiveType(currentTypeName), currentTypeName); } private final String checkSimpleDataTypeExp = "//simpleData[name[text()='%s']]/representation"; @@ -283,7 +249,9 @@ private void collectObjectClassDefinitions( if (className == null) { return; } - String canonicalName = canonicalClassName(className, parentName, "HLAobjectRoot"); + String canonicalName = parentName == null || parentName.equals("HLAobjectRoot") + ? className + : parentName + "." + className; List attributes = new ArrayList<>(); for (Element attribute : childElements(objectClass, "attribute")) { @@ -300,64 +268,6 @@ private void collectObjectClassDefinitions( } } - /** - * Return the interaction-class hierarchy as immutable, XML-free definitions. - * - *

Class and parent names are canonical, root-relative HLA names. The - * standard {@code HLAinteractionRoot} prefix is omitted for its descendants. - * Only parameters declared directly on a class are included. - */ - public List interactionClassDefinitions() { - if (doc == null || doc.getDocumentElement() == null) { - return List.of(); - } - Element interactions = firstChildElement(doc.getDocumentElement(), "interactions"); - if (interactions == null) { - return List.of(); - } - - List definitions = new ArrayList<>(); - for (Element interactionClass : childElements(interactions, "interactionClass")) { - collectInteractionClassDefinitions(interactionClass, null, definitions); - } - return List.copyOf(definitions); - } - - private void collectInteractionClassDefinitions( - Element interactionClass, - String parentName, - List definitions) { - String className = childText(interactionClass, "name"); - if (className == null) { - return; - } - String canonicalName = canonicalClassName(className, parentName, "HLAinteractionRoot"); - - List parameters = new ArrayList<>(); - for (Element parameter : childElements(interactionClass, "parameter")) { - String parameterName = childText(parameter, "name"); - String dataType = childText(parameter, "dataType"); - if (parameterName != null && dataType != null) { - parameters.add(new InteractionParameterDefinition(parameterName, dataType)); - } - } - definitions.add(new InteractionClassDefinition(canonicalName, parentName, parameters)); - - for (Element childClass : childElements(interactionClass, "interactionClass")) { - collectInteractionClassDefinitions(childClass, canonicalName, definitions); - } - } - - private static String canonicalClassName( - String localClassName, - String parentName, - String rootName) { - if (parentName == null || parentName.equals(rootName)) { - return localClassName; - } - return parentName + "." + localClassName; - } - private static String childText(Element parent, String tagName) { Element element = firstChildElement(parent, tagName); if (element == null) { @@ -389,11 +299,61 @@ private static List childElements(Element parent, String tagName) { return elements; } - public String getParameterType(String entityName, String parameterName, boolean isInteraction) - throws XPathExpressionException { - String exp = String.format(isInteraction ? findInteractionByNameExp : findObjectByNameExp, - entityName, parameterName); - return (String) xPath.compile(exp).evaluate(doc, XPathConstants.STRING); + String getInteractionParameterType(String interactionName, String parameterName) { + Element interactionClass = findInteractionClass(interactionName); + while (interactionClass != null) { + Element parameter = findNamedChild(interactionClass, "parameter", parameterName); + String dataType = childText(parameter, "dataType"); + if (dataType != null) { + return dataType; + } + Node parent = interactionClass.getParentNode(); + interactionClass = parent instanceof Element element + && element.getTagName().equals("interactionClass") + ? element + : null; + } + return null; + } + + private Element findInteractionClass(String canonicalName) { + if (canonicalName == null || canonicalName.isEmpty() + || doc == null || doc.getDocumentElement() == null) { + return null; + } + Element interactions = firstChildElement(doc.getDocumentElement(), "interactions"); + Element interactionClass = findNamedChild( + interactions, + "interactionClass", + "HLAinteractionRoot"); + if (canonicalName.equals("HLAinteractionRoot")) { + return interactionClass; + } + for (String className : canonicalName.split("\\.", -1)) { + if (className.isEmpty()) { + return null; + } + interactionClass = findNamedChild( + interactionClass, + "interactionClass", + className); + if (interactionClass == null) { + return null; + } + } + return interactionClass; + } + + private static Element findNamedChild(Element parent, String tagName, String name) { + if (name == null) { + return null; + } + for (Element child : childElements(parent, tagName)) { + if (name.equals(childText(child, "name"))) { + return child; + } + } + return null; } public String getArrayElementType(String arrayType) throws XPathExpressionException { @@ -490,17 +450,4 @@ public record ObjectClassDefinition( public record ObjectAttributeDefinition(String name, String dataType) { } - - public record InteractionClassDefinition( - String name, - String parentName, - List parameters) { - - public InteractionClassDefinition { - parameters = List.copyOf(parameters); - } - } - - public record InteractionParameterDefinition(String name, String dataType) { - } } diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java index 6e8e015..20ba786 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import com.yetanalytics.hlaxapi.FOMXML.PathCheckResult; import com.yetanalytics.hlaxapi.cache.CachedObject; import com.yetanalytics.hlaxapi.cache.FomCatalog; import com.yetanalytics.hlaxapi.cache.ObjectCache; @@ -187,28 +188,17 @@ private IllegalArgumentException missingTarget( } private EventTargetDefinition interactionTargetDefinition(String hlaClass, Target target) { - if (fomCatalog == null) { - throw new IllegalStateException("FOM interaction catalog is not configured"); - } - Optional interactionClass = - fomCatalog.interactionClass(hlaClass); - if (interactionClass.isEmpty()) { - return EventTargetDefinition.missing(); - } - List targetParts = target == null ? null : target.parts; - String pathKey = FomCatalog.targetPath(targetParts); - String topLevelName = FomCatalog.topLevelTargetPart(targetParts); - Optional targetParameter = - interactionClass.orElseThrow().parameter(pathKey); - Optional topLevelParameter = - interactionClass.orElseThrow().parameter(topLevelName); - if (targetParameter.isEmpty() || topLevelParameter.isEmpty()) { + if (target == null) { return EventTargetDefinition.missing(); } + PathCheckResult path = fomXml.checkInteractionParameterPath(hlaClass, target.parts); + String topLevelType = fomXml.getInteractionParameterType( + hlaClass, + FomCatalog.topLevelTargetPart(target.parts)); return new EventTargetDefinition( - true, - targetParameter.orElseThrow().primitiveType(), - topLevelParameter.orElseThrow().dataType()); + path.exists, + path.primitiveType, + topLevelType); } private EventTargetDefinition objectTargetDefinition(String hlaClass, Target target) { diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java index c61c51a..d6914b4 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java @@ -15,13 +15,12 @@ import org.springframework.stereotype.Component; /** - * Canonical object and interaction metadata derived from the FOM. + * Canonical object metadata derived from the FOM. */ @Component public final class FomCatalog { private final Map classesByName; - private final Map interactionsByName; private final Map classesById; private final Map attributesById; @@ -30,12 +29,7 @@ public FomCatalog(FOMXML fomXml) { for (FOMXML.ObjectClassDefinition definition : fomXml.objectClassDefinitions()) { builder.addObjectClass(definition); } - for (FOMXML.InteractionClassDefinition definition : fomXml.interactionClassDefinitions()) { - builder.addInteractionClass(definition); - } this.classesByName = Collections.unmodifiableMap(new LinkedHashMap<>(builder.classesByName)); - this.interactionsByName = - Collections.unmodifiableMap(new LinkedHashMap<>(builder.interactionsByName)); Map byId = new LinkedHashMap<>(); Map attrsById = new LinkedHashMap<>(); @@ -53,42 +47,17 @@ public Collection objectClasses() { return classesByName.values(); } - /** - * Resolves a canonical object class name exactly. - */ - public Optional canonicalObjectClass(String name) { - return Optional.ofNullable(classesByName.get(name)); - } - /** * Resolves an object class by its exact canonical name. */ public Optional objectClass(String name) { - return canonicalObjectClass(name); + return Optional.ofNullable(classesByName.get(name)); } public Optional objectClass(int id) { return Optional.ofNullable(classesById.get(id)); } - public Collection interactionClasses() { - return interactionsByName.values(); - } - - /** - * Resolves a canonical interaction class name exactly. - */ - public Optional canonicalInteractionClass(String name) { - return Optional.ofNullable(interactionsByName.get(name)); - } - - /** - * Resolves an interaction class by its exact canonical name. - */ - public Optional interactionClass(String name) { - return canonicalInteractionClass(name); - } - public List objectClassAndDescendants(String name) { ObjectClassDef requestedClass = objectClass(name).orElse(null); if (requestedClass == null) { @@ -230,42 +199,11 @@ public record FomAttribute( boolean leaf) { } - public record InteractionClassDef( - String hlaName, - String parentName, - List parameters) { - - public InteractionClassDef { - parameters = List.copyOf(parameters); - } - - public Optional parameter(String pathKey) { - String localPath = pathKey == null ? null : pathKey.trim(); - String wildcardPath = wildcardArrayIndexes(localPath); - for (FomParameter parameter : parameters) { - if (parameter.pathKey().equals(localPath) || parameter.pathKey().equals(wildcardPath)) { - return Optional.of(parameter); - } - } - return Optional.empty(); - } - } - - public record FomParameter( - String parameterName, - String pathKey, - String dataType, - String primitiveType, - boolean leaf) { - } - private static final class CatalogBuilder { private final FOMXML fomXml; private final Map classesByName = new LinkedHashMap<>(); private final Map> attributesByClassName = new LinkedHashMap<>(); - private final Map interactionsByName = new LinkedHashMap<>(); - private final Map> parametersByClassName = new LinkedHashMap<>(); private int nextClassId = 1; private int nextAttributeId = 1; @@ -298,27 +236,6 @@ private void addObjectClass(FOMXML.ObjectClassDefinition definition) { classesByName.put(classDef.hlaName(), classDef); } - private void addInteractionClass(FOMXML.InteractionClassDefinition definition) { - List allParameters = new ArrayList<>(); - if (definition.parentName() != null) { - allParameters.addAll(parametersByClassName.getOrDefault(definition.parentName(), List.of())); - } - for (FOMXML.InteractionParameterDefinition parameter : definition.parameters()) { - allParameters.add(new ParameterSource(parameter.name(), parameter.dataType())); - } - parametersByClassName.put(definition.name(), List.copyOf(allParameters)); - - List flattened = new ArrayList<>(); - for (ParameterSource parameter : allParameters) { - flattenParameter(parameter.name(), parameter.name(), parameter.dataType(), flattened); - } - InteractionClassDef classDef = new InteractionClassDef( - definition.name(), - definition.parentName(), - flattened); - interactionsByName.put(classDef.hlaName(), classDef); - } - private void flattenAttribute( int classId, String attributeName, @@ -353,36 +270,6 @@ private void flattenAttribute( } } - private void flattenParameter( - String parameterName, - String pathKey, - String dataType, - List parameters) { - String primitive = primitiveType(dataType); - List fields = fixedRecordFields(dataType); - String arrayElementType = arrayElementType(dataType); - boolean leaf = primitive != null || fields.isEmpty() && arrayElementType == null; - - parameters.add(new FomParameter( - parameterName, - pathKey, - dataType, - primitive, - leaf)); - - if (!fields.isEmpty()) { - for (FOMXML.FixedRecordField field : fields) { - flattenParameter( - parameterName, - pathKey + "." + field.name, - field.dataType, - parameters); - } - } else if (arrayElementType != null) { - flattenParameter(parameterName, pathKey + "[]", arrayElementType, parameters); - } - } - private String primitiveType(String dataType) { try { return fomXml.resolvePrimitiveType(dataType); @@ -416,8 +303,5 @@ private String arrayElementType(String dataType) { private record AttributeSource(String name, String dataType) { } - - private record ParameterSource(String name, String dataType) { - } } } diff --git a/src/test/java/com/yetanalytics/ConfigParserTest.java b/src/test/java/com/yetanalytics/ConfigParserTest.java index 0b6accc..5bf5f30 100644 --- a/src/test/java/com/yetanalytics/ConfigParserTest.java +++ b/src/test/java/com/yetanalytics/ConfigParserTest.java @@ -33,7 +33,6 @@ import com.yetanalytics.hlaxapi.SimulationConfig; import com.yetanalytics.hlaxapi.TriggerProcessor; import com.yetanalytics.hlaxapi.cache.CachedObject; -import com.yetanalytics.hlaxapi.cache.FomCatalog; import com.yetanalytics.hlaxapi.cache.ValueResolution; import com.yetanalytics.hlaxapi.config.ConfigParser; import com.yetanalytics.hlaxapi.config.XapiConfig; @@ -66,7 +65,9 @@ public void parsesConfigFile() throws IOException { SimulationConfig simConfig = new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"); HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); - InjectionHandler ih = interactionHandler(simConfig, decoderRegistry); + InjectionHandler ih = new InjectionHandler(); + ih.setFomXml(new FOMXML(simConfig, decoderRegistry)); + ih.setHLADecoderRegistry(decoderRegistry); TriggerProcessor triggerProcessor = new TriggerProcessor(ih); @@ -299,7 +300,9 @@ public void handlesFixedRecordFieldAccess() { SimulationConfig simConfig = new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"); HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); - InjectionHandler ih = interactionHandler(simConfig, decoderRegistry); + InjectionHandler ih = new InjectionHandler(); + ih.setFomXml(new FOMXML(simConfig, decoderRegistry)); + ih.setHLADecoderRegistry(decoderRegistry); byte[] gridPosition = java.nio.ByteBuffer.allocate(Integer.BYTES * 2) .order(java.nio.ByteOrder.BIG_ENDIAN) @@ -323,7 +326,9 @@ public void handlesFixedRecordFieldAccessInsideArray() { SimulationConfig simConfig = new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"); HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); - InjectionHandler ih = interactionHandler(simConfig, decoderRegistry); + InjectionHandler ih = new InjectionHandler(); + ih.setFomXml(new FOMXML(simConfig, decoderRegistry)); + ih.setHLADecoderRegistry(decoderRegistry); byte[] gridPosition = java.nio.ByteBuffer.allocate(Integer.BYTES * 2) .order(java.nio.ByteOrder.BIG_ENDIAN) @@ -349,7 +354,9 @@ public void handlesFixedRecordGridPositionFieldAccess() { SimulationConfig simConfig = new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"); HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); - InjectionHandler ih = interactionHandler(simConfig, decoderRegistry); + InjectionHandler ih = new InjectionHandler(); + ih.setFomXml(new FOMXML(simConfig, decoderRegistry)); + ih.setHLADecoderRegistry(decoderRegistry); byte[] gridPosition = java.nio.ByteBuffer.allocate(Integer.BYTES * 2) .order(java.nio.ByteOrder.BIG_ENDIAN) @@ -377,7 +384,9 @@ public void inlinePlaceholderProcessing() throws IOException { SimulationConfig simConfig = new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"); HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); - InjectionHandler ih = interactionHandler(simConfig, decoderRegistry); + InjectionHandler ih = new InjectionHandler(); + ih.setFomXml(new FOMXML(simConfig, decoderRegistry)); + ih.setHLADecoderRegistry(decoderRegistry); TriggerProcessor triggerProcessor = new TriggerProcessor(ih); @@ -403,7 +412,9 @@ public void inlinePlaceholderProcessingHandlesMultiplePlaceholders() throws IOEx SimulationConfig simConfig = new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"); HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); - InjectionHandler ih = interactionHandler(simConfig, decoderRegistry); + InjectionHandler ih = new InjectionHandler(); + ih.setFomXml(new FOMXML(simConfig, decoderRegistry)); + ih.setHLADecoderRegistry(decoderRegistry); TriggerProcessor triggerProcessor = new TriggerProcessor(ih); @@ -442,17 +453,6 @@ public ValueResolution handleTrigger(Target t, InjectionContext context) { assertTrue(out.contains("\"name\":\"[alpha, beta]\"")); } - private static InjectionHandler interactionHandler( - SimulationConfig simulationConfig, - HLADecoderRegistry decoderRegistry) { - FOMXML fomXml = new FOMXML(simulationConfig, decoderRegistry); - InjectionHandler handler = new InjectionHandler(); - handler.setFomXml(fomXml); - handler.setHLADecoderRegistry(decoderRegistry); - handler.setFomCatalog(new FomCatalog(fomXml)); - return handler; - } - @Test public void exposesStatementPathForWholeNodeInjections() { List> paths = new ArrayList<>(); diff --git a/src/test/java/com/yetanalytics/FOMTest.java b/src/test/java/com/yetanalytics/FOMTest.java index 642988e..9e370b7 100644 --- a/src/test/java/com/yetanalytics/FOMTest.java +++ b/src/test/java/com/yetanalytics/FOMTest.java @@ -25,10 +25,7 @@ public class FOMTest { @BeforeEach public void setUp() { - SimulationConfig simConfig = new SimulationConfig(null, null, null, null, - "config/HlaFedereplFOM.xml"); - HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); - fomXml = new FOMXML(simConfig, decoderRegistry); + fomXml = fomXml("config/HlaFedereplFOM.xml"); } @Test @@ -83,23 +80,47 @@ public void InteractionsXML() { } @Test - public void ObjectsXML() { - - // Simple object attribute - World.WorldId is HLAASCIIstring - PathCheckResult worldIdResult = fomXml.checkObjectParameterPath("World", "WorldId"); - logger.info("World, WorldId: {}", worldIdResult); - assertTrue(worldIdResult.primitiveType.equals("HLAASCIIstring")); - - // Simple data type attribute - World.Size is CellIndex (HLAinteger32BE) - PathCheckResult worldSizeResult = fomXml.checkObjectParameterPath("World", "Size"); - logger.info("World, Size: {}", worldSizeResult); - assertTrue(worldSizeResult.primitiveType.equals("HLAinteger32BE")); - - // Entity object attribute - PathCheckResult entityIdResult = fomXml.checkObjectParameterPath("SimEntity", "EntityId"); - logger.info("SimEntity, EntityId: {}", entityIdResult); - assertTrue(entityIdResult.primitiveType.equals("HLAASCIIstring")); + public void CanonicalInteractionHierarchy() { + FOMXML duplicateNames = fomXml( + "src/test/resources/config/AmbiguousClassNamesFOM.xml"); + + assertTrue(duplicateNames + .checkInteractionParameterPath("EntityEvents.Updated", "EntityId") + .exists); + assertTrue(duplicateNames + .checkInteractionParameterPath("EntityEvents.Updated", "Hunger") + .exists); + assertTrue(!duplicateNames + .checkInteractionParameterPath("EntityEvents.Updated", "OtherId") + .exists); + assertTrue(duplicateNames + .checkInteractionParameterPath("OtherEvents.Updated", "OtherId") + .exists); + assertTrue(duplicateNames + .checkInteractionParameterPath("OtherEvents.Updated", "Speed") + .exists); + assertTrue(!duplicateNames + .checkInteractionParameterPath("OtherEvents.Updated", "EntityId") + .exists); + assertTrue(!duplicateNames + .checkInteractionParameterPath("Updated", "Hunger") + .exists); + assertTrue(!duplicateNames + .checkInteractionParameterPath("Created", "EntityId") + .exists); + assertTrue(duplicateNames + .checkInteractionParameterPath("EntityEvents.Created", "EntityId") + .exists); + assertTrue(!duplicateNames + .checkInteractionParameterPath( + "HLAinteractionRoot.EntityEvents.Updated", + "Hunger") + .exists); + } - // We won't duplicate extensive failure cases here; interactions cover them. + private static FOMXML fomXml(String path) { + SimulationConfig simConfig = new SimulationConfig(null, null, null, null, path); + HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); + return new FOMXML(simConfig, decoderRegistry); } } diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java index 83a43fa..df0130b 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java @@ -97,13 +97,13 @@ void buildsRtiObjectClassNamesRelativeToHlaObjectRoot() { } @Test - void indexesObjectAndInteractionClassesByCanonicalNameWithoutLocalCollisions() { + void indexesObjectClassesByCanonicalNameWithoutLocalCollisions() { FomCatalog catalog = catalog("src/test/resources/config/AmbiguousClassNamesFOM.xml"); FomCatalog.ObjectClassDef entityRabbit = - catalog.canonicalObjectClass("SimEntity.Rabbit").orElseThrow(); + catalog.objectClass("SimEntity.Rabbit").orElseThrow(); FomCatalog.ObjectClassDef otherRabbit = - catalog.canonicalObjectClass("SomeOtherSuperclass.Rabbit").orElseThrow(); + catalog.objectClass("SomeOtherSuperclass.Rabbit").orElseThrow(); assertEquals("SimEntity", entityRabbit.parentName()); assertTrue(entityRabbit.attribute("EntityId").isPresent()); @@ -114,40 +114,6 @@ void indexesObjectAndInteractionClassesByCanonicalNameWithoutLocalCollisions() { assertTrue(otherRabbit.attribute("Speed").isPresent()); assertFalse(otherRabbit.attribute("EntityId").isPresent()); assertTrue(catalog.objectClass("Rabbit").isEmpty()); - - FomCatalog.InteractionClassDef entityUpdated = - catalog.canonicalInteractionClass("EntityEvents.Updated").orElseThrow(); - FomCatalog.InteractionClassDef otherUpdated = - catalog.canonicalInteractionClass("OtherEvents.Updated").orElseThrow(); - - assertEquals("EntityEvents", entityUpdated.parentName()); - assertTrue(entityUpdated.parameter("EntityId").isPresent()); - assertTrue(entityUpdated.parameter("Hunger").isPresent()); - assertFalse(entityUpdated.parameter("OtherId").isPresent()); - assertEquals("OtherEvents", otherUpdated.parentName()); - assertTrue(otherUpdated.parameter("OtherId").isPresent()); - assertTrue(otherUpdated.parameter("Speed").isPresent()); - assertFalse(otherUpdated.parameter("EntityId").isPresent()); - assertTrue(catalog.interactionClass("Updated").isEmpty()); - assertTrue(catalog.interactionClass("Created").isEmpty()); - assertTrue(catalog.interactionClass("EntityEvents.Created").isPresent()); - assertTrue(catalog.interactionClass(" HLAinteractionRoot.EntityEvents.Created ").isEmpty()); - } - - @Test - void flattensInteractionParameters() { - FomCatalog catalog = catalog("config/HlaFedereplFOM.xml"); - - FomCatalog.InteractionClassDef entityMoved = - catalog.interactionClass("EntityMoved").orElseThrow(); - - assertEquals("EntityMoved", entityMoved.hlaName()); - assertEquals( - "HLAinteger32BE", - entityMoved.parameter("FromPosition.X").orElseThrow().primitiveType()); - assertEquals( - "GridPosition", - entityMoved.parameter("FromPosition").orElseThrow().dataType()); } @Test @@ -170,14 +136,6 @@ void fomXmlReturnsHierarchyWithDeclaredAttributes() { assertEquals(List.of("Hunger"), rabbit.attributes().stream() .map(FOMXML.ObjectAttributeDefinition::name) .toList()); - - FOMXML.InteractionClassDefinition entityMoved = fomXml.interactionClassDefinitions().stream() - .filter(definition -> definition.name().equals("EntityMoved")) - .findFirst() - .orElseThrow(); - assertEquals("HLAinteractionRoot", entityMoved.parentName()); - assertTrue(entityMoved.parameters().stream() - .anyMatch(parameter -> parameter.name().equals("FromPosition"))); } @Test diff --git a/src/test/java/com/yetanalytics/hlaxapi/injection/XapiValueGeneratorTest.java b/src/test/java/com/yetanalytics/hlaxapi/injection/XapiValueGeneratorTest.java index 43f4326..4ca35db 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/injection/XapiValueGeneratorTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/injection/XapiValueGeneratorTest.java @@ -19,7 +19,6 @@ import com.yetanalytics.hlaxapi.SimulationConfig; import com.yetanalytics.hlaxapi.TriggerProcessor; import com.yetanalytics.hlaxapi.TriggerProcessor.TriggerProcessingResult; -import com.yetanalytics.hlaxapi.cache.FomCatalog; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.StatementTrigger.Type; import com.yetanalytics.hlaxapi.config.model.Target; @@ -224,11 +223,9 @@ void validationInjectionTests() { SimulationConfig simConfig = new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"); HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); - FOMXML fomXml = new FOMXML(simConfig, decoderRegistry); InjectionHandler ih = new InjectionHandler(); - ih.setFomXml(fomXml); + ih.setFomXml(new FOMXML(simConfig, decoderRegistry)); ih.setHLADecoderRegistry(decoderRegistry); - ih.setFomCatalog(new FomCatalog(fomXml)); StatementValidator validator = new StatementValidator(); TriggerProcessor tp = new TriggerProcessor(ih); From d3110695aa8efe8385ee4125ad531a0170402201 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 5 Aug 2026 11:27:49 -0400 Subject: [PATCH 35/36] remove triggerType and add concrete cud contexts --- .../hlaxapi/HlaInterfaceImpl.java | 31 +++++------ .../hlaxapi/InjectionHandler.java | 41 ++++++++------ .../hlaxapi/TriggerProcessor.java | 24 +++++---- .../hlaxapi/injection/InjectionContext.java | 10 +--- .../InteractionInjectionContext.java | 5 ++ .../ObjectCreateInjectionContext.java | 22 ++++++++ .../ObjectDeleteInjectionContext.java | 22 ++++++++ .../injection/ObjectInjectionContext.java | 6 +-- .../ObjectUpdateInjectionContext.java | 22 ++++++++ .../injection/TestInjectionContext.java | 17 ++++-- .../com/yetanalytics/ConfigParserTest.java | 6 +++ .../hlaxapi/ObjectInjectionHandlerTest.java | 53 +++++++++++++++++-- .../hlaxapi/TriggerProcessorDispatchTest.java | 51 +++++++++++++----- .../cache/ObjectCachePersistenceTest.java | 7 ++- .../injection/XapiValueGeneratorTest.java | 6 +-- 15 files changed, 236 insertions(+), 87 deletions(-) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/injection/ObjectCreateInjectionContext.java create mode 100644 src/main/java/com/yetanalytics/hlaxapi/injection/ObjectDeleteInjectionContext.java create mode 100644 src/main/java/com/yetanalytics/hlaxapi/injection/ObjectUpdateInjectionContext.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index 79b0ec4..667774e 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -23,7 +23,9 @@ import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.exception.XapiConfigurationException; import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext; -import com.yetanalytics.hlaxapi.injection.ObjectInjectionContext; +import com.yetanalytics.hlaxapi.injection.ObjectCreateInjectionContext; +import com.yetanalytics.hlaxapi.injection.ObjectDeleteInjectionContext; +import com.yetanalytics.hlaxapi.injection.ObjectUpdateInjectionContext; import com.yetanalytics.hlaxapi.injection.TestInjectionContext; import com.yetanalytics.xapi.util.StatementValidator; import com.yetanalytics.xapi.util.StatementValidator.StatementValidationResult; @@ -379,16 +381,18 @@ private void reflectAttributeValues(ObjectInstanceHandle theObject, AttributeHan logger.debug("Ignoring empty reflection for object {}", theObject); return; } - ObjectInjectionContext context = - new ObjectInjectionContext(className, theObject.toString(), attributes); boolean createPending = className.equals(pendingObjectCreates.get(theObject.toString())); List statements = new ArrayList<>(); if (createPending) { - statements.addAll( - triggerProcessor.stage(StatementTrigger.Type.OBJECT_CREATE, className, context)); + statements.addAll(triggerProcessor.stage(new ObjectCreateInjectionContext( + className, + theObject.toString(), + attributes))); } - statements.addAll( - triggerProcessor.stage(StatementTrigger.Type.OBJECT_UPDATE, className, context)); + statements.addAll(triggerProcessor.stage(new ObjectUpdateInjectionContext( + className, + theObject.toString(), + attributes))); objectCache.reflectAttributeValues(theObject.toString(), className, attributes); if (createPending) { pendingObjectCreates.remove(theObject.toString(), className); @@ -442,14 +446,11 @@ private void removeCachedObject(ObjectInstanceHandle theObject) { ObjectSnapshot snapshot = objectCache.findCurrentObjectSnapshot(objectHandle).orElse(null); List statements = List.of(); if (snapshot != null) { - ObjectInjectionContext context = new ObjectInjectionContext( + ObjectDeleteInjectionContext context = new ObjectDeleteInjectionContext( snapshot.className(), snapshot.objectHandle(), snapshot.attributes()); - statements = triggerProcessor.stage( - StatementTrigger.Type.OBJECT_DELETE, - snapshot.className(), - context); + statements = triggerProcessor.stage(context); } else { logger.debug("Skipping ObjectDelete triggers for unknown or removed object {}", theObject); return; @@ -524,11 +525,7 @@ private void receiveInteraction(InteractionClassHandle interactionClass, Paramet InteractionInjectionContext context = new InteractionInjectionContext(interactionKey, getMapWithParameterNames(interactionClass, theParameters)); - triggerProcessor.dispatch( - StatementTrigger.Type.INTERACTION, - interactionKey, - context, - xapiClient::sendStatement); + triggerProcessor.dispatch(context, xapiClient::sendStatement); } catch (InvalidInteractionClassHandle | FederateNotExecutionMember | NotConnected | RTIinternalError e) { logger.error("Error ascertaining interaction details!", e); } diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java index 20ba786..274888d 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -29,6 +29,7 @@ import com.yetanalytics.hlaxapi.injection.InjectionContext; import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext; import com.yetanalytics.hlaxapi.injection.ObjectInjectionContext; +import com.yetanalytics.hlaxapi.injection.ObjectUpdateInjectionContext; import com.yetanalytics.hlaxapi.injection.TestInjectionContext; import com.yetanalytics.hlaxapi.injection.XapiValueGenerator; @@ -76,7 +77,7 @@ public ValueResolution handleTrigger(Target t, TestInjectionContext context) { EventTargetDefinition target = requireEventTargetDefinition( context.getHlaClass(), t, - context.getTriggerType() != null && context.getTriggerType().isObjectEvent(), + context.eventType().isObjectEvent(), "trigger"); return testValue(target, t, context); } @@ -373,27 +374,34 @@ public ValueResolution handleTrigger(Target t, ObjectInjectionContext context) { } public ValueResolution handlePrevious(Target target, InjectionContext context) { - if (context == null - || context.getTriggerType() != StatementTrigger.Type.OBJECT_UPDATE) { - throw new IllegalArgumentException( - "previous values are only available to ObjectUpdate triggers"); - } if (context instanceof TestInjectionContext testContext) { - EventTargetDefinition definition = requireObjectTargetDefinition( - testContext.getHlaClass(), - target, - "previous"); - return testValue(definition, target, testContext); + return handlePrevious(target, testContext); } - if (!(context instanceof ObjectInjectionContext objectContext)) { + if (context instanceof ObjectUpdateInjectionContext objectContext) { + return handlePrevious(target, objectContext); + } + throw new IllegalArgumentException( + "previous values are only available to ObjectUpdate triggers"); + } + + public ValueResolution handlePrevious(Target target, TestInjectionContext context) { + if (context.eventType() != StatementTrigger.Type.OBJECT_UPDATE) { throw new IllegalArgumentException( - "previous values require an object update context"); + "previous values are only available to ObjectUpdate triggers"); } + EventTargetDefinition definition = requireObjectTargetDefinition( + context.getHlaClass(), + target, + "previous"); + return testValue(definition, target, context); + } + + public ValueResolution handlePrevious(Target target, ObjectUpdateInjectionContext context) { if (objectCache == null) { return ValueResolution.missingObject(); } return objectCache.findCurrentValueResolution( - objectContext.getObjectHandle(), + context.getObjectHandle(), target); } @@ -468,12 +476,11 @@ public void visit(Expression candidate, ValidationSource state) { requireEventTargetDefinition( event.getHlaClass(), trigger.target, - event.getTriggerType() != null - && event.getTriggerType().isObjectEvent(), + event.eventType().isObjectEvent(), "trigger"); } else if (candidate instanceof PreviousExpression previous) { TestInjectionContext event = state.eventContext(); - if (event.getTriggerType() != StatementTrigger.Type.OBJECT_UPDATE) { + if (event.eventType() != StatementTrigger.Type.OBJECT_UPDATE) { throw new IllegalArgumentException( "previous values are only available to ObjectUpdate triggers"); } diff --git a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java index b422d86..900cda5 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java +++ b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java @@ -85,13 +85,12 @@ private static TriggerProcessingResult failed(Throwable error) { public record StagedStatement(StatementTrigger trigger, String statement) { } - public List stage( - StatementTrigger.Type eventType, - String hlaClass, - InjectionContext context) { + public List stage(InjectionContext context) { if (xapiConfig.statementTriggers == null) { return List.of(); } + StatementTrigger.Type eventType = context.eventType(); + String hlaClass = context.getHlaClass(); List statements = new ArrayList<>(); for (StatementTrigger trigger : xapiConfig.statementTriggers) { if (!matchesTrigger(trigger, eventType, hlaClass)) { @@ -157,11 +156,9 @@ public void enqueue(List statements, Consumer statement } public void dispatch( - StatementTrigger.Type eventType, - String hlaClass, InjectionContext context, Consumer statementSink) { - enqueue(stage(eventType, hlaClass, context), statementSink); + enqueue(stage(context), statementSink); } public TriggerProcessingResult processTrigger(StatementTrigger trigger, InjectionContext context) { @@ -179,9 +176,16 @@ private TriggerProcessingResult processTrigger( if (trigger == null || trigger.statement == null) { return null; } + if (context == null) { + return TriggerProcessingResult.failed( + new IllegalArgumentException("Injection context is required")); + } + if (trigger.type != context.eventType()) { + return TriggerProcessingResult.failed(new IllegalArgumentException( + "Trigger type " + trigger.type + + " does not match injection context type " + context.eventType())); + } ObjectMapper mapper = new ObjectMapper(); - StatementTrigger.Type previousTriggerType = context.getTriggerType(); - context.setTriggerType(trigger.type); try { if (!evaluateCriteria && context instanceof TestInjectionContext testContext) { injectionHandler.validateCriteriaSources(trigger, testContext); @@ -202,8 +206,6 @@ private TriggerProcessingResult processTrigger( } catch (Exception e) { logger.error("Could not process trigger {}.{}: {}", trigger.type, trigger.clazz, e.getMessage(), e); return TriggerProcessingResult.failed(e); - } finally { - context.setTriggerType(previousTriggerType); } } diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/InjectionContext.java b/src/main/java/com/yetanalytics/hlaxapi/injection/InjectionContext.java index 2a3fb2e..ddd8fdc 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/injection/InjectionContext.java +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/InjectionContext.java @@ -9,7 +9,8 @@ public abstract class InjectionContext { private List statementPath = List.of(); private boolean embedded = false; private String objectType; - private StatementTrigger.Type triggerType; + + public abstract StatementTrigger.Type eventType(); public String getHlaClass() { return hlaClass; @@ -48,11 +49,4 @@ public void setObjectType(String objectType) { this.objectType = objectType; } - public StatementTrigger.Type getTriggerType() { - return triggerType; - } - - public void setTriggerType(StatementTrigger.Type triggerType) { - this.triggerType = triggerType; - } } diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/InteractionInjectionContext.java b/src/main/java/com/yetanalytics/hlaxapi/injection/InteractionInjectionContext.java index cac0ace..06021ba 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/injection/InteractionInjectionContext.java +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/InteractionInjectionContext.java @@ -1,5 +1,6 @@ package com.yetanalytics.hlaxapi.injection; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import java.util.Map; @@ -23,4 +24,8 @@ public void setParameterMap(Map parameterMap) { this.parameterMap = parameterMap; } + @Override + public final StatementTrigger.Type eventType() { + return StatementTrigger.Type.INTERACTION; + } } diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectCreateInjectionContext.java b/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectCreateInjectionContext.java new file mode 100644 index 0000000..f50ee20 --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectCreateInjectionContext.java @@ -0,0 +1,22 @@ +package com.yetanalytics.hlaxapi.injection; + +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import java.util.Map; + +public class ObjectCreateInjectionContext extends ObjectInjectionContext { + + public ObjectCreateInjectionContext() { + } + + public ObjectCreateInjectionContext( + String hlaClass, + String objectHandle, + Map attributeMap) { + super(hlaClass, objectHandle, attributeMap); + } + + @Override + public final StatementTrigger.Type eventType() { + return StatementTrigger.Type.OBJECT_CREATE; + } +} diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectDeleteInjectionContext.java b/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectDeleteInjectionContext.java new file mode 100644 index 0000000..258b5de --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectDeleteInjectionContext.java @@ -0,0 +1,22 @@ +package com.yetanalytics.hlaxapi.injection; + +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import java.util.Map; + +public class ObjectDeleteInjectionContext extends ObjectInjectionContext { + + public ObjectDeleteInjectionContext() { + } + + public ObjectDeleteInjectionContext( + String hlaClass, + String objectHandle, + Map attributeMap) { + super(hlaClass, objectHandle, attributeMap); + } + + @Override + public final StatementTrigger.Type eventType() { + return StatementTrigger.Type.OBJECT_DELETE; + } +} diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectInjectionContext.java b/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectInjectionContext.java index 00db4aa..7b0cc99 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectInjectionContext.java +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectInjectionContext.java @@ -2,15 +2,15 @@ import java.util.Map; -public class ObjectInjectionContext extends InjectionContext { +public abstract class ObjectInjectionContext extends InjectionContext { private String objectHandle; private Map attributeMap = Map.of(); - public ObjectInjectionContext() { + protected ObjectInjectionContext() { } - public ObjectInjectionContext( + protected ObjectInjectionContext( String hlaClass, String objectHandle, Map attributeMap) { diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectUpdateInjectionContext.java b/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectUpdateInjectionContext.java new file mode 100644 index 0000000..34bb482 --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/ObjectUpdateInjectionContext.java @@ -0,0 +1,22 @@ +package com.yetanalytics.hlaxapi.injection; + +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import java.util.Map; + +public class ObjectUpdateInjectionContext extends ObjectInjectionContext { + + public ObjectUpdateInjectionContext() { + } + + public ObjectUpdateInjectionContext( + String hlaClass, + String objectHandle, + Map attributeMap) { + super(hlaClass, objectHandle, attributeMap); + } + + @Override + public final StatementTrigger.Type eventType() { + return StatementTrigger.Type.OBJECT_UPDATE; + } +} diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/TestInjectionContext.java b/src/main/java/com/yetanalytics/hlaxapi/injection/TestInjectionContext.java index 9558b62..36ea42a 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/injection/TestInjectionContext.java +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/TestInjectionContext.java @@ -1,20 +1,27 @@ package com.yetanalytics.hlaxapi.injection; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import java.util.Objects; public class TestInjectionContext extends InjectionContext { + private final StatementTrigger.Type eventType; + public TestInjectionContext() { - setTriggerType(StatementTrigger.Type.INTERACTION); + this(StatementTrigger.Type.INTERACTION, null); } public TestInjectionContext(String hlaClass) { - this(); - setHlaClass(hlaClass); + this(StatementTrigger.Type.INTERACTION, hlaClass); } - public TestInjectionContext(StatementTrigger.Type triggerType, String hlaClass) { - setTriggerType(triggerType); + public TestInjectionContext(StatementTrigger.Type eventType, String hlaClass) { + this.eventType = Objects.requireNonNull(eventType, "eventType"); setHlaClass(hlaClass); } + + @Override + public final StatementTrigger.Type eventType() { + return eventType; + } } diff --git a/src/test/java/com/yetanalytics/ConfigParserTest.java b/src/test/java/com/yetanalytics/ConfigParserTest.java index 5bf5f30..00b33c2 100644 --- a/src/test/java/com/yetanalytics/ConfigParserTest.java +++ b/src/test/java/com/yetanalytics/ConfigParserTest.java @@ -399,6 +399,7 @@ public void inlinePlaceholderProcessing() throws IOException { String stmt = "{\"actor\":{\"name\":\"predator-<<[\\\"trigger\\\", [\\\"EntityId\\\"]]>>-prey\"}}"; com.yetanalytics.hlaxapi.config.model.StatementTrigger st = new com.yetanalytics.hlaxapi.config.model.StatementTrigger(); + st.type = StatementTrigger.Type.INTERACTION; st.statement = stmt; String out = triggerProcessor.processTrigger(st, injectionContext).statement(); @@ -425,6 +426,7 @@ public void inlinePlaceholderProcessingHandlesMultiplePlaceholders() throws IOEx String stmt = "{\"actor\":{\"name\":\"from=<<[\\\"trigger\\\", [\\\"EntityId\\\"]]>>, to=<<[\\\"trigger\\\", [\\\"EntityId\\\"]]>>\"}}"; com.yetanalytics.hlaxapi.config.model.StatementTrigger st = new com.yetanalytics.hlaxapi.config.model.StatementTrigger(); + st.type = StatementTrigger.Type.INTERACTION; st.statement = stmt; String out = triggerProcessor.processTrigger(st, injectionContext).statement(); @@ -446,6 +448,7 @@ public ValueResolution handleTrigger(Target t, InjectionContext context) { String stmt = "{\"actor\":{\"name\":\"<<[\\\"trigger\\\", [\\\"Description\\\"]]>>\"}}"; com.yetanalytics.hlaxapi.config.model.StatementTrigger st = new com.yetanalytics.hlaxapi.config.model.StatementTrigger(); + st.type = StatementTrigger.Type.INTERACTION; st.statement = stmt; String out = triggerProcessor.processTrigger(st, injectionContext).statement(); @@ -464,6 +467,7 @@ public ValueResolution handleTrigger(Target t, InjectionContext context) { } }; StatementTrigger trigger = new StatementTrigger(); + trigger.type = StatementTrigger.Type.INTERACTION; trigger.statement = """ { "actor": {"name": ["trigger", ["Name"]]}, @@ -509,6 +513,7 @@ public ValueResolution handleLookup(CachedObject object, Target attrTarget, Inje TriggerProcessor triggerProcessor = new TriggerProcessor(ih); StatementTrigger trigger = new StatementTrigger(); + trigger.type = StatementTrigger.Type.INTERACTION; ObjectLookup lookup = new ObjectLookup(); lookup.clazz = "SimEntity"; lookup.criteria = new Criterion( @@ -698,6 +703,7 @@ private StatementTrigger lookupTrigger(String nameExpression) { private StatementTrigger statementTrigger(String statement) { StatementTrigger trigger = new StatementTrigger(); + trigger.type = StatementTrigger.Type.INTERACTION; trigger.statement = statement; return trigger; } diff --git a/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java index 19addc6..c132b10 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.yetanalytics.extension.SuppressTestLogging; @@ -17,7 +18,11 @@ import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TriggerExpression; import com.yetanalytics.hlaxapi.config.model.ValueExpression; +import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext; +import com.yetanalytics.hlaxapi.injection.ObjectCreateInjectionContext; +import com.yetanalytics.hlaxapi.injection.ObjectDeleteInjectionContext; import com.yetanalytics.hlaxapi.injection.ObjectInjectionContext; +import com.yetanalytics.hlaxapi.injection.ObjectUpdateInjectionContext; import com.yetanalytics.hlaxapi.injection.TestInjectionContext; import java.nio.ByteOrder; import java.util.List; @@ -34,7 +39,8 @@ class ObjectInjectionHandlerTest { void objectContextCarriesClassHandleAndIncomingAttributes() { byte[] count = HLAEncodingTestSupport.int32(4, ByteOrder.BIG_ENDIAN); ObjectInjectionContext context = - new ObjectInjectionContext("BaseEntity.TrackedEntity", "object-17", Map.of("Count", count)); + new ObjectUpdateInjectionContext( + "BaseEntity.TrackedEntity", "object-17", Map.of("Count", count)); assertEquals("BaseEntity.TrackedEntity", context.getHlaClass()); assertEquals("object-17", context.getObjectHandle()); @@ -46,7 +52,7 @@ void decodesInheritedPrimitiveFixedRecordAndArrayPaths() { InjectionHandler handler = handler(OBJECT_FOM); byte[] position = position(12, 18); byte[] history = HLAEncodingTestSupport.variableArray(position(1, 2), position(3, 4)); - ObjectInjectionContext context = new ObjectInjectionContext( + ObjectInjectionContext context = new ObjectUpdateInjectionContext( "BaseEntity.TrackedEntity", "object-17", Map.of( @@ -72,10 +78,10 @@ void reportsAbsentAndMalformedObjectAttributesAsMissingValues() { ValueResolution absent = handler.handleTrigger( target("Count"), - new ObjectInjectionContext("BaseEntity.TrackedEntity", "object-17", Map.of())); + new ObjectUpdateInjectionContext("BaseEntity.TrackedEntity", "object-17", Map.of())); ValueResolution malformed = handler.handleTrigger( target("Count"), - new ObjectInjectionContext( + new ObjectUpdateInjectionContext( "BaseEntity.TrackedEntity", "object-17", Map.of("Count", new byte[] {1}))); @@ -259,6 +265,29 @@ void validatesPreviousOnlyForObjectUpdateTemplates() { } } + @Test + void runtimePreviousOnlyAcceptsObjectUpdateContexts() { + InjectionHandler handler = handler(OBJECT_FOM); + Target count = target("Count"); + + assertEquals( + ValueResolution.Status.MISSING_OBJECT, + handler.handlePrevious( + count, + new ObjectUpdateInjectionContext( + "BaseEntity.TrackedEntity", "object-17", Map.of())) + .status()); + assertThrows(IllegalArgumentException.class, () -> handler.handlePrevious( + count, + new ObjectCreateInjectionContext("BaseEntity.TrackedEntity", "object-17", Map.of()))); + assertThrows(IllegalArgumentException.class, () -> handler.handlePrevious( + count, + new ObjectDeleteInjectionContext("BaseEntity.TrackedEntity", "object-17", Map.of()))); + assertThrows(IllegalArgumentException.class, () -> handler.handlePrevious( + count, + new InteractionInjectionContext("BaseEntity.TrackedEntity", Map.of()))); + } + private InjectionHandler handler(String fomPath) { HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); FOMXML fomXml = new FOMXML( @@ -307,12 +336,26 @@ private void assertOptionalMalformedValue( TriggerProcessor.TriggerProcessingResult result = processor.processTrigger( trigger, - new ObjectInjectionContext("BaseEntity.TrackedEntity", "object-17", attributes)); + objectContext(type, attributes)); assertTrue(result.success(), type + " " + target); assertEquals("{\"value\":null}", result.statement(), type + " " + target); } + private ObjectInjectionContext objectContext( + StatementTrigger.Type type, + Map attributes) { + return switch (type) { + case OBJECT_CREATE -> new ObjectCreateInjectionContext( + "BaseEntity.TrackedEntity", "object-17", attributes); + case OBJECT_UPDATE -> new ObjectUpdateInjectionContext( + "BaseEntity.TrackedEntity", "object-17", attributes); + case OBJECT_DELETE -> new ObjectDeleteInjectionContext( + "BaseEntity.TrackedEntity", "object-17", attributes); + case INTERACTION -> throw new IllegalArgumentException("Interaction is not an object event"); + }; + } + private Target target(Object... parts) { return new Target(List.of(parts)); } diff --git a/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorDispatchTest.java b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorDispatchTest.java index 0c1d1aa..5ae72ef 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorDispatchTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorDispatchTest.java @@ -1,6 +1,8 @@ package com.yetanalytics.hlaxapi; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import com.yetanalytics.extension.SuppressTestLogging; import com.yetanalytics.hlaxapi.TriggerProcessor.TriggerProcessingResult; @@ -8,7 +10,9 @@ import com.yetanalytics.hlaxapi.config.XapiConfig; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext; -import com.yetanalytics.hlaxapi.injection.ObjectInjectionContext; +import com.yetanalytics.hlaxapi.injection.ObjectCreateInjectionContext; +import com.yetanalytics.hlaxapi.injection.ObjectDeleteInjectionContext; +import com.yetanalytics.hlaxapi.injection.ObjectUpdateInjectionContext; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -37,9 +41,7 @@ void matchesExactlyStagesOnceAndIsolatesProcessingAndEnqueueFailures() { ControlledTriggerProcessor processor = new ControlledTriggerProcessor(config, catalog); List staged = processor.stage( - StatementTrigger.Type.OBJECT_UPDATE, - "SimEntity.Rabbit", - new ObjectInjectionContext("SimEntity.Rabbit", "object-1", Map.of())); + new ObjectUpdateInjectionContext("SimEntity.Rabbit", "object-1", Map.of())); assertEquals(List.of("first", "second"), staged.stream().map(TriggerProcessor.StagedStatement::statement).toList()); @@ -67,8 +69,6 @@ void interactionEventsUseTheSameProcessorWithoutMatchingObjectTriggers() { List enqueued = new ArrayList<>(); processor.dispatch( - StatementTrigger.Type.INTERACTION, - "SimEntity.Rabbit", new InteractionInjectionContext("SimEntity.Rabbit", Map.of()), enqueued::add); @@ -86,16 +86,13 @@ void lifecycleEventsMatchTheirTypeAndFomHierarchy() { trigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity.Rabbit", "rabbit-delete"), trigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity.Wolf", "wolf-delete")); TriggerProcessor processor = new ControlledTriggerProcessor(config, catalog); - ObjectInjectionContext rabbit = - new ObjectInjectionContext("SimEntity.Rabbit", "object-1", Map.of()); - List createStatements = processor - .stage(StatementTrigger.Type.OBJECT_CREATE, "SimEntity.Rabbit", rabbit) + .stage(new ObjectCreateInjectionContext("SimEntity.Rabbit", "object-1", Map.of())) .stream() .map(TriggerProcessor.StagedStatement::statement) .toList(); List deleteStatements = processor - .stage(StatementTrigger.Type.OBJECT_DELETE, "SimEntity.Rabbit", rabbit) + .stage(new ObjectDeleteInjectionContext("SimEntity.Rabbit", "object-1", Map.of())) .stream() .map(TriggerProcessor.StagedStatement::statement) .toList(); @@ -117,11 +114,11 @@ void objectUpdateForBaseClassMatchesConcreteDescendant() { trigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity.Wolf", "wolf-update"), trigger(StatementTrigger.Type.OBJECT_UPDATE, "MissingObject", "unknown-update")); TriggerProcessor processor = new ControlledTriggerProcessor(config, catalog); - ObjectInjectionContext rabbit = - new ObjectInjectionContext("SimEntity.Rabbit", "object-1", Map.of()); + ObjectUpdateInjectionContext rabbit = + new ObjectUpdateInjectionContext("SimEntity.Rabbit", "object-1", Map.of()); List updateStatements = processor - .stage(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity.Rabbit", rabbit) + .stage(rabbit) .stream() .map(TriggerProcessor.StagedStatement::statement) .toList(); @@ -131,6 +128,32 @@ void objectUpdateForBaseClassMatchesConcreteDescendant() { updateStatements); } + @Test + void runtimeContextTypesAreFixed() { + assertEquals( + StatementTrigger.Type.INTERACTION, + new InteractionInjectionContext().eventType()); + assertEquals( + StatementTrigger.Type.OBJECT_CREATE, + new ObjectCreateInjectionContext().eventType()); + assertEquals( + StatementTrigger.Type.OBJECT_UPDATE, + new ObjectUpdateInjectionContext().eventType()); + assertEquals( + StatementTrigger.Type.OBJECT_DELETE, + new ObjectDeleteInjectionContext().eventType()); + } + + @Test + void rejectsTriggerAndContextTypeMismatch() { + TriggerProcessingResult result = new TriggerProcessor(new InjectionHandler()).processTrigger( + trigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity.Rabbit", "{}"), + new InteractionInjectionContext("SimEntity.Rabbit", Map.of())); + + assertFalse(result.success()); + assertInstanceOf(IllegalArgumentException.class, result.error()); + } + private StatementTrigger trigger(StatementTrigger.Type type, String className, String statement) { StatementTrigger trigger = new StatementTrigger(); trigger.type = type; diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java index 8b01658..4cb49a7 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java @@ -26,7 +26,7 @@ import com.yetanalytics.hlaxapi.config.model.TriggerExpression; import com.yetanalytics.hlaxapi.config.model.ValueExpression; import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext; -import com.yetanalytics.hlaxapi.injection.ObjectInjectionContext; +import com.yetanalytics.hlaxapi.injection.ObjectUpdateInjectionContext; import hla.rti1516e.encoding.DataElement; import hla.rti1516e.encoding.EncoderException; import hla.rti1516e.encoding.EncoderFactory; @@ -502,9 +502,8 @@ void previousResolutionSupportsNestedArraysCachedNullAndMissingValues() throws E injectionHandler.setHLADecoderRegistry(decoderRegistry); injectionHandler.setFomCatalog(dynamicArrayCatalog); setField(injectionHandler, "objectCache", cache); - ObjectInjectionContext context = - new ObjectInjectionContext("Rabbit", "rabbit-1", Map.of()); - context.setTriggerType(StatementTrigger.Type.OBJECT_UPDATE); + ObjectUpdateInjectionContext context = + new ObjectUpdateInjectionContext("Rabbit", "rabbit-1", Map.of()); ValueResolution nested = injectionHandler.handlePrevious( new Target(List.of("Position", "X")), diff --git a/src/test/java/com/yetanalytics/hlaxapi/injection/XapiValueGeneratorTest.java b/src/test/java/com/yetanalytics/hlaxapi/injection/XapiValueGeneratorTest.java index 4ca35db..72c4e9d 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/injection/XapiValueGeneratorTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/injection/XapiValueGeneratorTest.java @@ -30,7 +30,7 @@ class XapiValueGeneratorTest { @Test void usesPresetUriForObjectIdPaths() { - InjectionContext ctx = new InjectionContext() {}; + InjectionContext ctx = new TestInjectionContext(); ctx.setStatementPath(List.of("object", "id")); ctx.setObjectType("Activity"); Object value = XapiValueGenerator.getTestValue(ctx, new Target(List.of("object", "id")), String.class); @@ -42,7 +42,7 @@ void usesPresetUriForObjectIdPaths() { @Test void usesPresetUuidForObjectIdPaths() { - InjectionContext ctx = new InjectionContext() {}; + InjectionContext ctx = new TestInjectionContext(); ctx.setStatementPath(List.of("object", "id")); ctx.setObjectType("StatementRef"); Object value = XapiValueGenerator.getTestValue(ctx, new Target(List.of("object", "id")), String.class); @@ -53,7 +53,7 @@ void usesPresetUuidForObjectIdPaths() { @Test void returnsRandomStringForActorNamePaths() { - InjectionContext ctx = new InjectionContext() {}; + InjectionContext ctx = new TestInjectionContext(); ctx.setStatementPath(List.of("actor", "name")); ctx.setObjectType("StatementRef"); Object value = XapiValueGenerator.getTestValue(ctx, new Target(List.of("actor", "name")), String.class); From 9c7a70cd5710c55716dff8e0e644655b091097b1 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 5 Aug 2026 13:13:59 -0400 Subject: [PATCH 36/36] separate out fom-config validation and simplify InjectionHandler --- .../hlaxapi/FomConfigValidator.java | 252 ++++++++++++++++++ .../hlaxapi/HlaInterfaceImpl.java | 20 +- .../hlaxapi/InjectionHandler.java | 173 +----------- .../hlaxapi/TriggerProcessor.java | 3 - .../hlaxapi/FomConfigValidatorTest.java | 166 ++++++++++++ .../hlaxapi/HlaConfigValidationTest.java | 99 +++++++ .../hlaxapi/ObjectInjectionHandlerTest.java | 86 +----- .../hlaxapi/TriggerProcessorCriteriaTest.java | 8 - 8 files changed, 548 insertions(+), 259 deletions(-) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/FomConfigValidator.java create mode 100644 src/test/java/com/yetanalytics/hlaxapi/FomConfigValidatorTest.java create mode 100644 src/test/java/com/yetanalytics/hlaxapi/HlaConfigValidationTest.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/FomConfigValidator.java b/src/main/java/com/yetanalytics/hlaxapi/FomConfigValidator.java new file mode 100644 index 0000000..baf69de --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/FomConfigValidator.java @@ -0,0 +1,252 @@ +package com.yetanalytics.hlaxapi; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yetanalytics.hlaxapi.cache.FomCatalog; +import com.yetanalytics.hlaxapi.config.model.Expression; +import com.yetanalytics.hlaxapi.config.model.ExpressionWalker; +import com.yetanalytics.hlaxapi.config.model.LookupExpression; +import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.PreviousExpression; +import com.yetanalytics.hlaxapi.config.model.QueryExpression; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import com.yetanalytics.hlaxapi.config.model.Target; +import com.yetanalytics.hlaxapi.config.model.TriggerExpression; +import com.yetanalytics.hlaxapi.injection.StatementInjectionParser; +import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.InlineInjection; +import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.LookupInjection; +import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.ParseResult; +import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.PreviousInjection; +import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.QueryInjection; +import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.StatementInjection; +import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.TriggerInjection; +import java.util.Map; +import org.springframework.stereotype.Component; + +/** Validates the FOM references contained in one statement trigger. */ +@Component +public class FomConfigValidator { + + private final FOMXML fomXml; + private final FomCatalog fomCatalog; + private final ObjectMapper mapper = new ObjectMapper(); + + public FomConfigValidator(FOMXML fomXml, FomCatalog fomCatalog) { + this.fomXml = fomXml; + this.fomCatalog = fomCatalog; + } + + public void validate(StatementTrigger trigger) { + Map lookups = + trigger.lookups == null ? Map.of() : trigger.lookups; + ValidationSource eventSource = new ValidationSource(trigger, lookups, null); + + validateExpressionSources(trigger.criteria, eventSource); + lookups.forEach((alias, lookup) -> { + ObjectLookup definition = requireLookupClass(alias, lookup); + validateExpressionSources( + definition.criteria, + new ValidationSource(trigger, lookups, definition.clazz)); + }); + + if (trigger.statement == null) { + return; + } + try { + validateStatementNode(mapper.readTree(trigger.statement), eventSource); + } catch (JsonProcessingException ignored) { + // Statement parsing failures remain the responsibility of the + // existing template-rendering validation pass. + } + } + + private void validateStatementNode(JsonNode node, ValidationSource source) { + if (node == null || node.isNull()) { + return; + } + if (node.isObject()) { + for (JsonNode child : node) { + validateStatementNode(child, source); + } + return; + } + if (node.isArray()) { + ParseResult parsed = StatementInjectionParser.parse(node); + if (parsed.valid()) { + validateInjection(parsed.injection(), source); + return; + } + if (parsed.recognized()) { + return; + } + for (JsonNode child : node) { + validateStatementNode(child, source); + } + return; + } + if (node.isTextual()) { + validateInlineInjections(node.asText(), source); + } + } + + private void validateInlineInjections(String text, ValidationSource source) { + for (InlineInjection inline : StatementInjectionParser.findInline(text)) { + if (inline.result().valid()) { + validateInjection(inline.result().injection(), source); + return; + } + if (inline.result().recognized()) { + return; + } + } + } + + private void validateInjection(StatementInjection injection, ValidationSource source) { + if (injection instanceof TriggerInjection triggerInjection) { + requireEventTargetDefinition( + source.trigger().clazz, + triggerInjection.target(), + source.trigger().type.isObjectEvent(), + "trigger"); + } else if (injection instanceof PreviousInjection previousInjection) { + requirePreviousTarget(source.trigger(), previousInjection.target()); + } else if (injection instanceof QueryInjection queryInjection) { + requireObjectTargetDefinition( + queryInjection.className(), + queryInjection.target(), + "query"); + validateExpressionSources( + queryInjection.criteria(), + new ValidationSource(source.trigger(), Map.of(), queryInjection.className())); + } else if (injection instanceof LookupInjection lookupInjection) { + ObjectLookup definition = requireLookupClass( + lookupInjection.alias(), + source.lookups().get(lookupInjection.alias())); + requireObjectTargetDefinition( + definition.clazz, + lookupInjection.target(), + "lookup(" + lookupInjection.alias() + ")"); + } + } + + private void validateExpressionSources(Expression expression, ValidationSource initialState) { + ExpressionWalker.walk( + expression, + initialState, + new ExpressionWalker.Visitor<>() { + @Override + public void visit(Expression candidate, ValidationSource state) { + if (candidate instanceof TriggerExpression trigger) { + StatementTrigger event = state.trigger(); + requireEventTargetDefinition( + event.clazz, + trigger.target, + event.type.isObjectEvent(), + "trigger"); + } else if (candidate instanceof PreviousExpression previous) { + requirePreviousTarget(state.trigger(), previous.target); + } else if (candidate instanceof QueryExpression query) { + requireObjectTargetDefinition(query.clazz, query.target, "query"); + } else if (candidate instanceof LookupExpression lookup) { + ObjectLookup definition = requireLookupClass( + lookup.alias, + state.lookups().get(lookup.alias)); + requireObjectTargetDefinition( + definition.clazz, + lookup.target, + "lookup(" + lookup.alias + ")"); + } else if (candidate instanceof Target target) { + if (state.cacheClass() == null) { + throw new IllegalArgumentException( + "bare target " + target.parts + + " is not scoped to a cache class"); + } + requireObjectTargetDefinition(state.cacheClass(), target, "cache"); + } + } + + @Override + public ValidationSource stateForChild( + Expression parent, + ExpressionWalker.Child child, + ValidationSource state) { + String cacheClass = child.role() == ExpressionWalker.ChildRole.QUERY_FILTER + ? ((QueryExpression) parent).clazz + : state.cacheClass(); + return new ValidationSource( + state.trigger(), + state.lookups(), + cacheClass); + } + }); + } + + private void requirePreviousTarget(StatementTrigger trigger, Target target) { + if (trigger.type != StatementTrigger.Type.OBJECT_UPDATE) { + throw new IllegalArgumentException( + "previous values are only available to ObjectUpdate triggers"); + } + requireObjectTargetDefinition(trigger.clazz, target, "previous"); + } + + private void requireEventTargetDefinition( + String hlaClass, + Target target, + boolean objectEvent, + String source) { + boolean exists = objectEvent + ? objectTargetExists(hlaClass, target) + : interactionTargetExists(hlaClass, target); + if (!exists) { + throw missingTarget(source, hlaClass, target); + } + } + + private void requireObjectTargetDefinition(String hlaClass, Target target, String source) { + if (!objectTargetExists(hlaClass, target)) { + throw missingTarget(source, hlaClass, target); + } + } + + private boolean interactionTargetExists(String hlaClass, Target target) { + return target != null + && fomXml.checkInteractionParameterPath(hlaClass, target.parts).exists; + } + + private boolean objectTargetExists(String hlaClass, Target target) { + if (target == null) { + return false; + } + return fomCatalog.objectClass(hlaClass) + .filter(clazz -> clazz.attribute(FomCatalog.targetPath(target.parts)).isPresent()) + .filter(clazz -> clazz.attribute(FomCatalog.topLevelTargetPart(target.parts)).isPresent()) + .isPresent(); + } + + private IllegalArgumentException missingTarget(String source, String hlaClass, Target target) { + return new IllegalArgumentException( + source + " target " + + (target == null ? "" : target.parts) + + " does not exist on FOM class " + + hlaClass); + } + + private ObjectLookup requireLookupClass(String alias, ObjectLookup lookup) { + if (lookup == null || lookup.clazz == null || lookup.clazz.isBlank()) { + throw new IllegalArgumentException( + "lookup alias '" + alias + "' does not define an object class"); + } + if (fomCatalog.objectClass(lookup.clazz).isEmpty()) { + throw new IllegalArgumentException( + "lookup alias '" + alias + "' references unknown FOM class " + lookup.clazz); + } + return lookup; + } + + private record ValidationSource( + StatementTrigger trigger, + Map lookups, + String cacheClass) { + } +} diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index 667774e..7dd34d6 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -105,6 +105,9 @@ public class HlaInterfaceImpl extends NullFederateAmbassador implements HlaInter @Autowired private StatementValidator validator; + @Autowired + private FomConfigValidator fomConfigValidator; + @Autowired private ObjectCache objectCache; @@ -203,15 +206,22 @@ public void stop() throws RTIinternalError { public void validateConfig() throws XapiConfigurationException { for(StatementTrigger st : xapiConfig.statementTriggers){ - if (st.skipValidation) continue; + try { + fomConfigValidator.validate(st); + } catch (RuntimeException e) { + logger.error("Invalid Statement Trigger (Invalid FOM reference): {}", st, e); + throw new XapiConfigurationException("Could not validate xAPI Configuration", e); + } TriggerProcessingResult tpr = triggerProcessor.renderTemplateForValidation( st, new TestInjectionContext(st.type, st.clazz)); if (tpr.success()) { - StatementValidationResult svr = validator.validateStatement(tpr.statement()); - if (!svr.isValid()){ - logger.error("Invalid Statement Trigger (Invalid xAPI): {}. {}", st, svr.getErrors()); - throw new XapiConfigurationException("Could not validate xAPI Configuration"); + if (!st.skipValidation) { + StatementValidationResult svr = validator.validateStatement(tpr.statement()); + if (!svr.isValid()){ + logger.error("Invalid Statement Trigger (Invalid xAPI): {}. {}", st, svr.getErrors()); + throw new XapiConfigurationException("Could not validate xAPI Configuration"); + } } } else { logger.error("Invalid Statement Trigger (Could not Process): {}. {}", st, tpr.error()); diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java index 274888d..ae846d5 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -18,11 +18,7 @@ import com.yetanalytics.hlaxapi.cache.ValueResolution; import com.yetanalytics.hlaxapi.config.model.Expression; import com.yetanalytics.hlaxapi.config.model.ExpressionWalker; -import com.yetanalytics.hlaxapi.config.model.LookupExpression; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; -import com.yetanalytics.hlaxapi.config.model.PreviousExpression; -import com.yetanalytics.hlaxapi.config.model.QueryExpression; -import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TriggerExpression; import com.yetanalytics.hlaxapi.config.model.ValueExpression; @@ -74,30 +70,13 @@ public ValueResolution handleTrigger(Target t, InjectionContext context) { } public ValueResolution handleTrigger(Target t, TestInjectionContext context) { - EventTargetDefinition target = requireEventTargetDefinition( + EventTargetDefinition target = targetDefinition( context.getHlaClass(), t, - context.eventType().isObjectEvent(), - "trigger"); + context.eventType().isObjectEvent()); return testValue(target, t, context); } - public void validateCriteriaSources( - StatementTrigger trigger, - TestInjectionContext context) { - Map lookups = - trigger.lookups == null ? Map.of() : trigger.lookups; - validateExpressionSources( - trigger.criteria, - new ValidationSource(context, lookups, null)); - lookups.forEach((alias, lookup) -> { - ObjectLookup definition = requireLookupClass(alias, lookup); - validateExpressionSources( - definition.criteria, - new ValidationSource(context, lookups, definition.clazz)); - }); - } - private ValueResolution testValue( EventTargetDefinition target, Target injectionTarget, @@ -154,40 +133,6 @@ private EventTargetDefinition targetDefinition( : interactionTargetDefinition(hlaClass, target); } - private EventTargetDefinition requireEventTargetDefinition( - String hlaClass, - Target target, - boolean objectEvent, - String source) { - EventTargetDefinition definition = targetDefinition(hlaClass, target, objectEvent); - if (!definition.exists()) { - throw missingTarget(source, hlaClass, target); - } - return definition; - } - - private EventTargetDefinition requireObjectTargetDefinition( - String hlaClass, - Target target, - String source) { - EventTargetDefinition definition = objectTargetDefinition(hlaClass, target); - if (!definition.exists()) { - throw missingTarget(source, hlaClass, target); - } - return definition; - } - - private IllegalArgumentException missingTarget( - String source, - String hlaClass, - Target target) { - return new IllegalArgumentException( - source + " target " - + (target == null ? "" : target.parts) - + " does not exist on FOM class " - + hlaClass); - } - private EventTargetDefinition interactionTargetDefinition(String hlaClass, Target target) { if (target == null) { return EventTargetDefinition.missing(); @@ -203,6 +148,9 @@ private EventTargetDefinition interactionTargetDefinition(String hlaClass, Targe } private EventTargetDefinition objectTargetDefinition(String hlaClass, Target target) { + if (target == null) { + return EventTargetDefinition.missing(); + } if (fomCatalog == null) { throw new IllegalStateException("FOM object catalog is not configured"); } @@ -385,14 +333,7 @@ public ValueResolution handlePrevious(Target target, InjectionContext context) { } public ValueResolution handlePrevious(Target target, TestInjectionContext context) { - if (context.eventType() != StatementTrigger.Type.OBJECT_UPDATE) { - throw new IllegalArgumentException( - "previous values are only available to ObjectUpdate triggers"); - } - EventTargetDefinition definition = requireObjectTargetDefinition( - context.getHlaClass(), - target, - "previous"); + EventTargetDefinition definition = objectTargetDefinition(context.getHlaClass(), target); return testValue(definition, target, context); } @@ -411,13 +352,9 @@ public ValueResolution handleQuery( Expression criteria, InjectionContext context) { - // Validation Test-Injection + // Test injection if (context instanceof TestInjectionContext testContext) { - EventTargetDefinition target = - requireObjectTargetDefinition(clazz, attrTarget, "query"); - validateExpressionSources( - criteria, - new ValidationSource(testContext, Map.of(), clazz)); + EventTargetDefinition target = objectTargetDefinition(clazz, attrTarget); return testValue(target, attrTarget, testContext); } @@ -439,10 +376,10 @@ public Optional resolveLookup(ObjectLookup lookup, InjectionContex public ValueResolution handleLookup(CachedObject object, Target attrTarget, InjectionContext context) { - // Validation Test-Injection + // Test injection if (context instanceof TestInjectionContext) { throw new IllegalArgumentException( - "lookup validation requires its lookup definition"); + "test lookup resolution requires its lookup definition"); } if (objectCache == null || object == null) { @@ -456,91 +393,12 @@ public ValueResolution handleLookup( ObjectLookup lookup, Target attrTarget, TestInjectionContext context) { - requireLookupClass(alias, lookup); - EventTargetDefinition target = - requireObjectTargetDefinition(lookup.clazz, attrTarget, "lookup(" + alias + ")"); + EventTargetDefinition target = objectTargetDefinition( + lookup == null ? null : lookup.clazz, + attrTarget); return testValue(target, attrTarget, context); } - private void validateExpressionSources( - Expression expression, - ValidationSource initialState) { - ExpressionWalker.walk( - expression, - initialState, - new ExpressionWalker.Visitor<>() { - @Override - public void visit(Expression candidate, ValidationSource state) { - if (candidate instanceof TriggerExpression trigger) { - TestInjectionContext event = state.eventContext(); - requireEventTargetDefinition( - event.getHlaClass(), - trigger.target, - event.eventType().isObjectEvent(), - "trigger"); - } else if (candidate instanceof PreviousExpression previous) { - TestInjectionContext event = state.eventContext(); - if (event.eventType() != StatementTrigger.Type.OBJECT_UPDATE) { - throw new IllegalArgumentException( - "previous values are only available to ObjectUpdate triggers"); - } - requireObjectTargetDefinition( - event.getHlaClass(), - previous.target, - "previous"); - } else if (candidate instanceof QueryExpression query) { - requireObjectTargetDefinition( - query.clazz, - query.target, - "query"); - } else if (candidate instanceof LookupExpression lookup) { - ObjectLookup definition = - requireLookupClass(lookup.alias, state.lookups().get(lookup.alias)); - requireObjectTargetDefinition( - definition.clazz, - lookup.target, - "lookup(" + lookup.alias + ")"); - } else if (candidate instanceof Target target) { - if (state.cacheClass() == null) { - throw new IllegalArgumentException( - "bare target " + target.parts - + " is not scoped to a cache class"); - } - requireObjectTargetDefinition( - state.cacheClass(), - target, - "cache"); - } - } - - @Override - public ValidationSource stateForChild( - Expression parent, - ExpressionWalker.Child child, - ValidationSource state) { - String cacheClass = child.role() == ExpressionWalker.ChildRole.QUERY_FILTER - ? ((QueryExpression) parent).clazz - : state.cacheClass(); - return new ValidationSource( - state.eventContext(), - state.lookups(), - cacheClass); - } - }); - } - - private ObjectLookup requireLookupClass(String alias, ObjectLookup lookup) { - if (lookup == null || lookup.clazz == null || lookup.clazz.isBlank()) { - throw new IllegalArgumentException( - "lookup alias '" + alias + "' does not define an object class"); - } - if (fomCatalog.objectClass(lookup.clazz).isEmpty()) { - throw new IllegalArgumentException( - "lookup alias '" + alias + "' references unknown FOM class " + lookup.clazz); - } - return lookup; - } - private Expression resolveTriggerExpressions(Expression expression, InjectionContext context) { if (expression == null || context == null) { return expression; @@ -581,9 +439,4 @@ private static EventTargetDefinition missing() { } } - private record ValidationSource( - TestInjectionContext eventContext, - Map lookups, - String cacheClass) { - } } diff --git a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java index 900cda5..2fe94bd 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java +++ b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java @@ -187,9 +187,6 @@ private TriggerProcessingResult processTrigger( } ObjectMapper mapper = new ObjectMapper(); try { - if (!evaluateCriteria && context instanceof TestInjectionContext testContext) { - injectionHandler.validateCriteriaSources(trigger, testContext); - } LazyLookupContext lookups = new LazyLookupContext(injectionHandler, context, trigger.lookups); if (evaluateCriteria && !new TriggerCriteriaMatcher(injectionHandler).matches(trigger.criteria, context, lookups)) { diff --git a/src/test/java/com/yetanalytics/hlaxapi/FomConfigValidatorTest.java b/src/test/java/com/yetanalytics/hlaxapi/FomConfigValidatorTest.java new file mode 100644 index 0000000..9694e23 --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/FomConfigValidatorTest.java @@ -0,0 +1,166 @@ +package com.yetanalytics.hlaxapi; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.yetanalytics.hlaxapi.cache.FomCatalog; +import com.yetanalytics.hlaxapi.config.model.ComparisonOperator; +import com.yetanalytics.hlaxapi.config.model.Criterion; +import com.yetanalytics.hlaxapi.config.model.Expression; +import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.PreviousExpression; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import com.yetanalytics.hlaxapi.config.model.Target; +import com.yetanalytics.hlaxapi.config.model.TriggerExpression; +import com.yetanalytics.hlaxapi.config.model.ValueExpression; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; + +class FomConfigValidatorTest { + + private static final String OBJECT_FOM = "src/test/resources/object-update-fom.xml"; + private static final String SIMULATION_FOM = "config/HlaFedereplFOM.xml"; + + @Test + void validatesInheritedObjectTargetsInStatementsAndCriteriaEvenWhenOptional() { + FomConfigValidator validator = validator(OBJECT_FOM); + StatementTrigger valid = trigger(""" + {"object":{"id":["trigger",["EntityId"]]}} + """); + StatementTrigger missingTrigger = trigger(""" + {"missing":["trigger",["NotAnAttribute"],{"required":false}]} + """); + StatementTrigger missingPrevious = trigger(""" + {"missing":["previous",["NotAnAttribute"],{"required":false}]} + """); + StatementTrigger missingTriggerCriterion = trigger("{}", new Criterion( + new TriggerExpression(target("NotAnAttribute")), + ComparisonOperator.EQ, + new ValueExpression(1))); + StatementTrigger missingPreviousCriterion = trigger("{}", new Criterion( + new PreviousExpression(target("NotAnAttribute")), + ComparisonOperator.EQ, + new ValueExpression(1))); + + assertDoesNotThrow(() -> validator.validate(valid)); + assertThrows(IllegalArgumentException.class, () -> validator.validate(missingTrigger)); + assertThrows(IllegalArgumentException.class, () -> validator.validate(missingPrevious)); + assertThrows(IllegalArgumentException.class, () -> validator.validate(missingTriggerCriterion)); + assertThrows(IllegalArgumentException.class, () -> validator.validate(missingPreviousCriterion)); + } + + @Test + void validatesQueryAndLookupPathsAgainstTheirReferencedObjectClasses() { + FomConfigValidator validator = validator(OBJECT_FOM); + StatementTrigger valid = trigger(""" + { + "query":["query","BaseEntity",["Position","X"],[["Position","Y"],">",0]], + "lookup":["lookup","base",["EntityId"]] + } + """); + valid.lookups = Map.of("base", lookup("BaseEntity", new Criterion( + target("Position", "Y"), + ComparisonOperator.GT, + new ValueExpression(0)))); + StatementTrigger missingQueryTarget = trigger(""" + {"value":["query","BaseEntity",["NotAnAttribute"],null,{"required":false}]} + """); + StatementTrigger missingQueryCriterion = trigger(""" + {"value":["query","BaseEntity",["EntityId"],[["NotAnAttribute"],"=",1]]} + """); + StatementTrigger missingLookupTarget = trigger(""" + {"value":["lookup","base",["NotAnAttribute"],{"required":false}]} + """); + missingLookupTarget.lookups = Map.of("base", lookup("BaseEntity", null)); + StatementTrigger missingLookupCriterion = trigger("{}"); + missingLookupCriterion.lookups = Map.of("base", lookup("BaseEntity", new Criterion( + target("NotAnAttribute"), + ComparisonOperator.EQ, + new ValueExpression(1)))); + + assertDoesNotThrow(() -> validator.validate(valid)); + assertThrows(IllegalArgumentException.class, () -> validator.validate(missingQueryTarget)); + assertThrows(IllegalArgumentException.class, () -> validator.validate(missingQueryCriterion)); + assertThrows(IllegalArgumentException.class, () -> validator.validate(missingLookupTarget)); + assertThrows(IllegalArgumentException.class, () -> validator.validate(missingLookupCriterion)); + } + + @Test + void validatesInteractionTargets() { + FomConfigValidator validator = validator(SIMULATION_FOM); + StatementTrigger valid = interaction(""" + {"result":{"score":{"raw":["trigger",["StepNumber"]]}}} + """); + StatementTrigger missing = interaction(""" + {"missing":["trigger",["NotAParameter"],{"required":false}]} + """); + + assertDoesNotThrow(() -> validator.validate(valid)); + assertThrows(IllegalArgumentException.class, () -> validator.validate(missing)); + } + + @Test + void validatesPreviousOnlyForObjectUpdateTriggers() { + FomConfigValidator validator = validator(OBJECT_FOM); + for (StatementTrigger.Type type : List.of( + StatementTrigger.Type.OBJECT_UPDATE, + StatementTrigger.Type.INTERACTION, + StatementTrigger.Type.OBJECT_CREATE, + StatementTrigger.Type.OBJECT_DELETE)) { + StatementTrigger trigger = trigger(""" + {"oldCount":["previous",["Count"]]} + """); + trigger.type = type; + + if (type == StatementTrigger.Type.OBJECT_UPDATE) { + assertDoesNotThrow(() -> validator.validate(trigger), type.toString()); + } else { + assertThrows( + IllegalArgumentException.class, + () -> validator.validate(trigger), + type.toString()); + } + } + } + + private FomConfigValidator validator(String fomPath) { + HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); + FOMXML fomXml = new FOMXML( + new SimulationConfig(null, null, null, null, fomPath), + decoderRegistry); + return new FomConfigValidator(fomXml, new FomCatalog(fomXml)); + } + + private StatementTrigger interaction(String statement) { + StatementTrigger trigger = trigger(statement); + trigger.type = StatementTrigger.Type.INTERACTION; + trigger.clazz = "StepCompleted"; + return trigger; + } + + private StatementTrigger trigger(String statement) { + return trigger(statement, null); + } + + private StatementTrigger trigger(String statement, Expression criteria) { + StatementTrigger trigger = new StatementTrigger(); + trigger.type = StatementTrigger.Type.OBJECT_UPDATE; + trigger.clazz = "BaseEntity.TrackedEntity"; + trigger.criteria = criteria; + trigger.statement = statement; + return trigger; + } + + private ObjectLookup lookup(String className, Expression criteria) { + ObjectLookup lookup = new ObjectLookup(); + lookup.clazz = className; + lookup.criteria = criteria; + return lookup; + } + + private Target target(Object... parts) { + return new Target(List.of(parts)); + } +} diff --git a/src/test/java/com/yetanalytics/hlaxapi/HlaConfigValidationTest.java b/src/test/java/com/yetanalytics/hlaxapi/HlaConfigValidationTest.java new file mode 100644 index 0000000..c40c4b5 --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/HlaConfigValidationTest.java @@ -0,0 +1,99 @@ +package com.yetanalytics.hlaxapi; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.yetanalytics.extension.SuppressTestLogging; +import com.yetanalytics.hlaxapi.cache.FomCatalog; +import com.yetanalytics.hlaxapi.config.XapiConfig; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import com.yetanalytics.hlaxapi.exception.XapiConfigurationException; +import com.yetanalytics.xapi.util.StatementValidator; +import java.lang.reflect.Field; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; + +class HlaConfigValidationTest { + + private static final String OBJECT_FOM = "src/test/resources/object-update-fom.xml"; + + @Test + @SuppressTestLogging({"com.yetanalytics.hlaxapi.HlaInterfaceImpl"}) + void skipValidationSkipsOnlyFinalXapiValidation() throws Exception { + StatementValidator statementValidator = new StatementValidator(); + assertFalse(statementValidator.validateStatement("{}").isValid()); + + StatementTrigger trigger = trigger("{}"); + trigger.skipValidation = true; + + assertDoesNotThrow(() -> hlaInterface(trigger, statementValidator).validateConfig()); + + trigger.skipValidation = false; + assertThrows( + XapiConfigurationException.class, + () -> hlaInterface(trigger, statementValidator).validateConfig()); + } + + @Test + @SuppressTestLogging({ + "com.yetanalytics.hlaxapi.HlaInterfaceImpl", + "com.yetanalytics.hlaxapi.TriggerProcessor" + }) + void skippedXapiValidationStillRunsFomAndRenderingChecks() throws Exception { + StatementTrigger missingTarget = trigger(""" + {"value":["trigger",["NotAnAttribute"],{"required":false}]} + """); + missingTarget.skipValidation = true; + StatementTrigger datatypeMismatch = trigger(""" + {"object":{"id":["trigger",["Count"]]}} + """); + datatypeMismatch.skipValidation = true; + + assertThrows( + XapiConfigurationException.class, + () -> hlaInterface(missingTarget, new StatementValidator()).validateConfig()); + assertThrows( + XapiConfigurationException.class, + () -> hlaInterface(datatypeMismatch, new StatementValidator()).validateConfig()); + } + + private HlaInterfaceImpl hlaInterface( + StatementTrigger trigger, + StatementValidator statementValidator) throws Exception { + HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); + FOMXML fomXml = new FOMXML( + new SimulationConfig(null, null, null, null, OBJECT_FOM), + decoderRegistry); + FomCatalog catalog = new FomCatalog(fomXml); + InjectionHandler handler = new InjectionHandler(); + handler.setFomXml(fomXml); + handler.setHLADecoderRegistry(decoderRegistry); + handler.setFomCatalog(catalog); + + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(trigger); + + HlaInterfaceImpl hlaInterface = new HlaInterfaceImpl(); + setField(hlaInterface, "xapiConfig", config); + setField(hlaInterface, "triggerProcessor", new TriggerProcessor(handler)); + setField(hlaInterface, "validator", statementValidator); + setField(hlaInterface, "fomConfigValidator", new FomConfigValidator(fomXml, catalog)); + return hlaInterface; + } + + private StatementTrigger trigger(String statement) { + StatementTrigger trigger = new StatementTrigger(); + trigger.type = StatementTrigger.Type.OBJECT_UPDATE; + trigger.clazz = "BaseEntity.TrackedEntity"; + trigger.statement = statement; + return trigger; + } + + private void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java index c132b10..0d0454a 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java @@ -13,10 +13,8 @@ import com.yetanalytics.hlaxapi.config.model.Criterion; import com.yetanalytics.hlaxapi.config.model.Expression; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; -import com.yetanalytics.hlaxapi.config.model.PreviousExpression; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; -import com.yetanalytics.hlaxapi.config.model.TriggerExpression; import com.yetanalytics.hlaxapi.config.model.ValueExpression; import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext; import com.yetanalytics.hlaxapi.injection.ObjectCreateInjectionContext; @@ -119,7 +117,7 @@ void optionalMalformedNestedValuesRenderNullForUpdateAndDelete() { @Test @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) - void validatesEveryObjectEventTargetAgainstInheritedObjectAttributes() { + void rendersInheritedObjectTargetsAndChecksTheirStatementDatatype() { TriggerProcessor processor = new TriggerProcessor(handler(OBJECT_FOM)); for (StatementTrigger.Type type : List.of( StatementTrigger.Type.OBJECT_CREATE, @@ -148,34 +146,7 @@ void validatesEveryObjectEventTargetAgainstInheritedObjectAttributes() { @Test @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) - void rejectsMissingObjectTargetsInStatementsAndCriteriaEvenWhenOptional() { - TriggerProcessor processor = new TriggerProcessor(handler(OBJECT_FOM)); - TestInjectionContext context = - new TestInjectionContext(StatementTrigger.Type.OBJECT_UPDATE, "BaseEntity.TrackedEntity"); - StatementTrigger missingTrigger = trigger(""" - {"missing":["trigger",["NotAnAttribute"],{"required":false}]} - """); - StatementTrigger missingPrevious = trigger(""" - {"missing":["previous",["NotAnAttribute"],{"required":false}]} - """); - StatementTrigger missingTriggerCriterion = trigger("{}", new Criterion( - new TriggerExpression(target("NotAnAttribute")), - ComparisonOperator.EQ, - new ValueExpression(1))); - StatementTrigger missingPreviousCriterion = trigger("{}", new Criterion( - new PreviousExpression(target("NotAnAttribute")), - ComparisonOperator.EQ, - new ValueExpression(1))); - - assertFalse(processor.renderTemplateForValidation(missingTrigger, context).success()); - assertFalse(processor.renderTemplateForValidation(missingPrevious, context).success()); - assertFalse(processor.renderTemplateForValidation(missingTriggerCriterion, context).success()); - assertFalse(processor.renderTemplateForValidation(missingPreviousCriterion, context).success()); - } - - @Test - @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) - void validatesQueryAndLookupPathsAgainstTheirReferencedObjectClasses() { + void rendersQueryAndLookupValuesAndChecksTheirStatementDatatype() { TriggerProcessor processor = new TriggerProcessor(handler(OBJECT_FOM)); TestInjectionContext context = new TestInjectionContext(StatementTrigger.Type.OBJECT_UPDATE, "BaseEntity.TrackedEntity"); @@ -192,33 +163,14 @@ void validatesQueryAndLookupPathsAgainstTheirReferencedObjectClasses() { StatementTrigger wrongQueryDatatype = trigger(""" {"result":{"score":{"raw":["query","BaseEntity",["EntityId"],null]}}} """); - StatementTrigger missingQueryTarget = trigger(""" - {"value":["query","BaseEntity",["NotAnAttribute"],null,{"required":false}]} - """); - StatementTrigger missingQueryCriterion = trigger(""" - {"value":["query","BaseEntity",["EntityId"],[["NotAnAttribute"],"=",1]]} - """); - StatementTrigger missingLookupTarget = trigger(""" - {"value":["lookup","base",["NotAnAttribute"],{"required":false}]} - """); - missingLookupTarget.lookups = Map.of("base", lookup("BaseEntity", null)); - StatementTrigger missingLookupCriterion = trigger("{}"); - missingLookupCriterion.lookups = Map.of("base", lookup("BaseEntity", new Criterion( - target("NotAnAttribute"), - ComparisonOperator.EQ, - new ValueExpression(1)))); assertTrue(processor.renderTemplateForValidation(valid, context).success()); assertFalse(processor.renderTemplateForValidation(wrongQueryDatatype, context).success()); - assertFalse(processor.renderTemplateForValidation(missingQueryTarget, context).success()); - assertFalse(processor.renderTemplateForValidation(missingQueryCriterion, context).success()); - assertFalse(processor.renderTemplateForValidation(missingLookupTarget, context).success()); - assertFalse(processor.renderTemplateForValidation(missingLookupCriterion, context).success()); } @Test @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) - void interactionValidationRemainsTheDefault() { + void rendersInteractionTestValues() { TriggerProcessor processor = new TriggerProcessor(handler(SIMULATION_FOM)); StatementTrigger interaction = trigger(""" {"result":{"score":{"raw":["trigger",["StepNumber"]]}}} @@ -229,40 +181,8 @@ void interactionValidationRemainsTheDefault() { TriggerProcessor.TriggerProcessingResult result = processor.renderTemplateForValidation( interaction, new TestInjectionContext("StepCompleted")); - StatementTrigger missing = trigger(""" - {"missing":["trigger",["NotAParameter"],{"required":false}]} - """); - missing.type = StatementTrigger.Type.INTERACTION; - missing.clazz = "StepCompleted"; - assertTrue(result.success()); assertTrue(result.statement().contains("\"raw\":0.5")); - assertFalse(processor.renderTemplateForValidation( - missing, - new TestInjectionContext("StepCompleted")).success()); - } - - @Test - @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) - void validatesPreviousOnlyForObjectUpdateTemplates() { - TriggerProcessor processor = new TriggerProcessor(handler(OBJECT_FOM)); - for (StatementTrigger.Type type : List.of( - StatementTrigger.Type.OBJECT_UPDATE, - StatementTrigger.Type.INTERACTION, - StatementTrigger.Type.OBJECT_CREATE, - StatementTrigger.Type.OBJECT_DELETE)) { - StatementTrigger trigger = trigger(""" - {"oldCount":["previous",["Count"]]} - """); - trigger.type = type; - - TriggerProcessor.TriggerProcessingResult result = - processor.renderTemplateForValidation( - trigger, - new TestInjectionContext(type, "BaseEntity.TrackedEntity")); - - assertEquals(type == StatementTrigger.Type.OBJECT_UPDATE, result.success(), type.toString()); - } } @Test diff --git a/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java index e84f142..a889169 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java @@ -165,14 +165,6 @@ void criteriaFailuresAreReportedAndValidationRenderingDoesNotEvaluateCriteria() public ValueResolution handleTrigger(Target target, InjectionContext context) { throw new IllegalStateException("cannot decode event value"); } - - @Override - public void validateCriteriaSources( - StatementTrigger trigger, - TestInjectionContext context) { - // This test isolates runtime criteria evaluation from structural - // FOM validation. - } }; StatementTrigger trigger = trigger( new TriggerExpression(target("Broken")),