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/config/xapi-config.json b/config/xapi-config.json index 55fbf6b..a41870d 100644 --- a/config/xapi-config.json +++ b/config/xapi-config.json @@ -40,6 +40,164 @@ } } } + }, + { + "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": [ + "previous", + ["StepNumber"], + {"required": false} + ], + "https://hla-federepl.example/extensions/current-step-number": [ + "trigger", + ["StepNumber"] + ] + } + } + } + }, + { + "type": "ObjectCreate", + "class": "SimEntity.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": "SimEntity.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/doc/xapi-config.md b/doc/xapi-config.md index d0c661d..9c08506 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,51 @@ 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. 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. - `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 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 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. + +### 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. + +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 @@ -89,7 +130,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 +154,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 +182,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 +238,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 +270,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 +326,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 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. 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 { @@ -283,14 +353,20 @@ 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. 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. + +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: @@ -329,6 +405,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. diff --git a/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java b/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java index 3037d6a..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"; @@ -252,6 +218,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 +249,9 @@ private void collectObjectClassDefinitions( if (className == null) { return; } + String canonicalName = parentName == null || parentName.equals("HLAobjectRoot") + ? className + : parentName + "." + className; List attributes = new ArrayList<>(); for (Element attribute : childElements(objectClass, "attribute")) { @@ -289,10 +261,10 @@ 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); } } @@ -327,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 { 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 5498505..7dd34d6 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -3,11 +3,13 @@ import java.io.File; 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; 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; @@ -16,10 +18,14 @@ 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; import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext; +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; @@ -80,9 +86,13 @@ 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 static final String INTERACTION_ROOT_PREFIX = "HLAinteractionRoot."; private RTIambassador ambassador; + private final Map pendingObjectCreates = new HashMap<>(); + @Autowired private XapiConfig xapiConfig; @@ -95,6 +105,9 @@ public class HlaInterfaceImpl extends NullFederateAmbassador implements HlaInter @Autowired private StatementValidator validator; + @Autowired + private FomConfigValidator fomConfigValidator; + @Autowired private ObjectCache objectCache; @@ -193,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.clazz)); + 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()); @@ -221,18 +241,23 @@ 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()) { + 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())); - ObjectClassHandle classHandle = ambassador.getObjectClassHandle(clazz.localName()); - AttributeHandleSet attributeHandles = ambassador.getAttributeHandleSetFactory().create(); - for (String attributeName : subscription.getValue()) { - attributeHandles.add(ambassador.getAttributeHandle(classHandle, attributeName)); - } + ObjectClassHandle classHandle = ambassador.getObjectClassHandle(clazz.hlaName()); + AttributeHandleSet attributeHandles = + attributeHandles(classHandle, subscription.getValue()); if (attributeHandles.isEmpty()) { continue; } @@ -258,17 +283,57 @@ public void discoverObjectInstance( ObjectClassHandle theObjectClass, String objectName, hla.rti1516e.FederateHandle producingFederate) throws FederateInternalError { - if (!objectCache.isEnabled()) { + if (!objectCache.hasSubscriptions()) { return; } + String className; + try { + className = rootRelativeObjectClassName( + ambassador.getObjectClassName(theObjectClass)); + } catch (InvalidObjectClassHandle | FederateNotExecutionMember | NotConnected | RTIinternalError e) { + logger.error("Error resolving discovered object {}", objectName, e); + return; + } + Set subscribedAttributes = + objectCache.effectiveSubscriptionAttributes(className); + if (subscribedAttributes.isEmpty()) { + return; + } + if (triggerProcessor.hasMatchingTrigger( + StatementTrigger.Type.OBJECT_CREATE, + className)) { + pendingObjectCreates.put(theObject.toString(), className); + } + 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 - | RuntimeException e) { - logger.error("Error caching discovered object {}", objectName, 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 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 @@ -310,23 +375,42 @@ public void reflectAttributeValues( } private void reflectAttributeValues(ObjectInstanceHandle theObject, AttributeHandleValueMap theAttributes) { - if (!objectCache.isEnabled()) { + if (!objectCache.hasSubscriptions()) { return; } 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); - objectCache.reflectAttributeValue( - theObject.toString(), + attributes.put(attributeName, theAttributes.get(attributeHandle)); + } + if (attributes.isEmpty()) { + logger.debug("Ignoring empty reflection for object {}", theObject); + return; + } + boolean createPending = className.equals(pendingObjectCreates.get(theObject.toString())); + List statements = new ArrayList<>(); + if (createPending) { + statements.addAll(triggerProcessor.stage(new ObjectCreateInjectionContext( className, - attributeName, - theAttributes.get(attributeHandle)); + theObject.toString(), + attributes))); + } + statements.addAll(triggerProcessor.stage(new ObjectUpdateInjectionContext( + className, + theObject.toString(), + attributes))); + objectCache.reflectAttributeValues(theObject.toString(), className, attributes); + if (createPending) { + pendingObjectCreates.remove(theObject.toString(), className); } + triggerProcessor.enqueue(statements, xapiClient::sendStatement); } 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); } } @@ -363,16 +447,37 @@ 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) { + ObjectDeleteInjectionContext context = new ObjectDeleteInjectionContext( + snapshot.className(), + snapshot.objectHandle(), + snapshot.attributes()); + statements = triggerProcessor.stage(context); + } else { + logger.debug("Skipping ObjectDelete triggers for unknown or removed object {}", theObject); + return; + } + objectCache.removeObject(objectHandle); + triggerProcessor.enqueue(statements, xapiClient::sendStatement); } catch (RuntimeException e) { logger.error("Error removing cached object {}", theObject, e); } } + private String rootRelativeObjectClassName(String className) { + return className != null && className.startsWith(OBJECT_ROOT_PREFIX) + ? className.substring(OBJECT_ROOT_PREFIX.length()) + : className; + } + /* * Interactions */ @@ -424,36 +529,24 @@ 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, 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()); - } - }); + triggerProcessor.dispatch(context, xapiClient::sendStatement); } catch (InvalidInteractionClassHandle | FederateNotExecutionMember | NotConnected | RTIinternalError e) { logger.error("Error ascertaining interaction details!", e); } } + 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 77dca75..ae846d5 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -13,6 +13,7 @@ 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; @@ -24,6 +25,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; @@ -49,6 +51,9 @@ public class InjectionHandler { @Autowired private HLADecoderRegistry hlaDecoderRegistry; + @Autowired + private FomCatalog fomCatalog; + public InjectionHandler() { } @@ -65,31 +70,52 @@ 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; - Object result = XapiValueGenerator.getTestValue(context, t, hlaJavaType); + EventTargetDefinition target = targetDefinition( + context.getHlaClass(), + t, + context.eventType().isObjectEvent()); + return testValue(target, t, context); + } + + private ValueResolution testValue( + EventTargetDefinition target, + Target injectionTarget, + TestInjectionContext context) { + Class hlaJavaType = + target.exists() ? hlaDecoderRegistry.getClassForType(target.primitiveType()) : null; + Object result = XapiValueGenerator.getTestValue(context, injectionTarget, 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 +124,59 @@ 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) { + 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( + path.exists, + path.primitiveType, + topLevelType); + } + + 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"); + } + 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 +192,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) { @@ -178,8 +247,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; } @@ -189,7 +266,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 { @@ -217,8 +295,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 { @@ -232,8 +314,36 @@ 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 handlePrevious(Target target, InjectionContext context) { + if (context instanceof TestInjectionContext testContext) { + return handlePrevious(target, testContext); + } + 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) { + EventTargetDefinition definition = objectTargetDefinition(context.getHlaClass(), target); + return testValue(definition, target, context); + } + + public ValueResolution handlePrevious(Target target, ObjectUpdateInjectionContext context) { + if (objectCache == null) { + return ValueResolution.missingObject(); + } + return objectCache.findCurrentValueResolution( + context.getObjectHandle(), + target); } public ValueResolution handleQuery( @@ -242,12 +352,10 @@ public ValueResolution handleQuery( Expression criteria, 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); + // Test injection + if (context instanceof TestInjectionContext testContext) { + EventTargetDefinition target = objectTargetDefinition(clazz, attrTarget); + return testValue(target, attrTarget, testContext); } if (objectCache == null) { @@ -268,12 +376,10 @@ 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); + // Test injection + if (context instanceof TestInjectionContext) { + throw new IllegalArgumentException( + "test lookup resolution requires its lookup definition"); } if (objectCache == null || object == null) { @@ -282,6 +388,17 @@ public ValueResolution handleLookup(CachedObject object, Target attrTarget, Inje return objectCache.findValueResolution(object, attrTarget); } + public ValueResolution handleLookup( + String alias, + ObjectLookup lookup, + Target attrTarget, + TestInjectionContext context) { + EventTargetDefinition target = objectTargetDefinition( + lookup == null ? null : lookup.clazz, + attrTarget); + return testValue(target, attrTarget, context); + } + private Expression resolveTriggerExpressions(Expression expression, InjectionContext context) { if (expression == null || context == null) { return expression; @@ -304,7 +421,22 @@ 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/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/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..2fe94bd 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; @@ -25,6 +30,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; @@ -37,6 +43,12 @@ public class TriggerProcessor { @Autowired private InjectionHandler injectionHandler; + @Autowired + private XapiConfig xapiConfig; + + @Autowired + private FomCatalog fomCatalog; + public TriggerProcessor() { } @@ -45,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) { @@ -60,6 +82,85 @@ private static TriggerProcessingResult failed(Throwable error) { } } + public record StagedStatement(StatementTrigger trigger, String statement) { + } + + 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)) { + 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( + InjectionContext context, + Consumer statementSink) { + enqueue(stage(context), statementSink); + } + public TriggerProcessingResult processTrigger(StatementTrigger trigger, InjectionContext context) { return processTrigger(trigger, context, true); } @@ -75,6 +176,15 @@ 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(); try { LazyLookupContext lookups = new LazyLookupContext(injectionHandler, context, trigger.lookups); @@ -218,6 +328,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/FomCatalog.java b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java index abf6543..d6914b4 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java @@ -15,7 +15,7 @@ import org.springframework.stereotype.Component; /** - * FOM-derived object metadata used by the SQLite cache. + * Canonical object metadata derived from the FOM. */ @Component public final class FomCatalog { @@ -29,7 +29,7 @@ public FomCatalog(FOMXML fomXml) { for (FOMXML.ObjectClassDefinition definition : fomXml.objectClassDefinitions()) { builder.addObjectClass(definition); } - this.classesByName = builder.classesByName; + this.classesByName = Collections.unmodifiableMap(new LinkedHashMap<>(builder.classesByName)); Map byId = new LinkedHashMap<>(); Map attrsById = new LinkedHashMap<>(); @@ -47,14 +47,55 @@ public Collection objectClasses() { return classesByName.values(); } + /** + * Resolves an object class by its exact canonical name. + */ public Optional objectClass(String name) { - return Optional.ofNullable(classesByName.get(localName(name))); + return Optional.ofNullable(classesByName.get(name)); } 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(); + } + + /** + * 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); + } + + /** + * 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)); } @@ -99,19 +140,20 @@ public static String wildcardArrayIndexes(String pathKey) { return pathKey.replaceAll("\\[[0-9]+\\]", "[]"); } - static String localName(String hlaName) { - if (hlaName == null) { - return null; + private boolean isSameOrDescendant(ObjectClassDef candidate, ObjectClassDef requestedClass) { + ObjectClassDef current = candidate; + while (current != null) { + if (current.hlaName().equals(requestedClass.hlaName())) { + return true; + } + current = classesByName.get(current.parentName()); } - String trimmed = hlaName.trim(); - int index = trimmed.lastIndexOf('.'); - return index >= 0 ? trimmed.substring(index + 1) : trimmed; + return false; } public record ObjectClassDef( int id, String hlaName, - String localName, String parentName, List attributes) { @@ -189,10 +231,9 @@ private void addObjectClass(FOMXML.ObjectClassDefinition definition) { new ObjectClassDef( classId, definition.name(), - localName(definition.name()), - localName(definition.parentName()), + definition.parentName(), flattened); - classesByName.put(classDef.localName(), classDef); + classesByName.put(classDef.hlaName(), classDef); } private void flattenAttribute( diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java b/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java index 45d9304..a4ae6ef 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/JdbcObjectCacheStore.java @@ -9,13 +9,15 @@ 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; 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(); @@ -43,12 +45,40 @@ 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); } } + @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("hla_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())) { @@ -95,54 +125,64 @@ 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("hla_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; } @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); } @@ -208,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 57b622f..972df04 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java @@ -5,11 +5,9 @@ import com.yetanalytics.hlaxapi.config.XapiConfig; import com.yetanalytics.hlaxapi.config.model.Expression; 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.LinkedHashMap; -import java.util.LinkedHashSet; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; @@ -20,7 +18,7 @@ public class ObjectCache implements AutoCloseable { private final FomCatalog catalog; - private final Map> subscriptions; + private final ObjectSubscriptionPlan subscriptionPlan; private final HlaValueFlattener valueFlattener; private final CacheQueryService queryService; private final AtomicLong sequence = new AtomicLong(); @@ -52,10 +50,10 @@ public ObjectCache(XapiConfig xapiConfig, FomCatalog catalog, FOMXML fomXml, HLA HLADecoderRegistry decoderRegistry, ObjectCacheConnectionSettings settings) { this.catalog = Objects.requireNonNull(catalog, "catalog"); - this.subscriptions = collectSubscriptions(xapiConfig); + this.subscriptionPlan = ObjectSubscriptionPlan.from(xapiConfig, catalog); this.valueFlattener = new HlaValueFlattener(fomXml, decoderRegistry); this.queryService = new CacheQueryService(this); - if (!subscriptions.isEmpty()) { + if (subscriptionPlan.requiresCache()) { ObjectCacheConnectionSettings effectiveSettings = settings == null ? ObjectCacheConnectionSettings.from(System.getenv()) : settings; @@ -68,7 +66,23 @@ public boolean isEnabled() { } public Map> subscriptions() { - return subscriptions; + return subscriptionPlan.subscriptions(); + } + + public Map> cacheSubscriptions() { + return subscriptionPlan.cacheSubscriptions(); + } + + public Map> eventSubscriptions() { + return subscriptionPlan.eventSubscriptions(); + } + + public boolean hasSubscriptions() { + return subscriptionPlan.hasSubscriptions(); + } + + public Set effectiveSubscriptionAttributes(String className) { + return subscriptionPlan.effectiveAttributes(className); } public FomCatalog catalog() { @@ -121,27 +135,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); } @@ -153,6 +183,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(); @@ -167,18 +204,38 @@ 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(); } - FomCatalog.ObjectClassDef clazz = requireClass(className); - return store.currentObjects(clazz); + FomCatalog.ObjectClassDef requestedClass = requireClass(className); + return store.currentObjects( + catalog.objectClassAndDescendants(requestedClass.hlaName())); } Connection connection() { return store == null ? null : store.connection(); } + ObjectCacheStore store() { + return store; + } + @Override public synchronized void close() { if (store == null) { @@ -192,65 +249,4 @@ private FomCatalog.ObjectClassDef requireClass(String className) { return catalog.objectClass(className) .orElseThrow(() -> new IllegalArgumentException("No FOM object class " + className)); } - - private Map> collectSubscriptions(XapiConfig xapiConfig) { - Map> merged = new LinkedHashMap<>(); - QueryReferenceCollector.collect(xapiConfig.statementTriggers) - .forEach((className, attributes) -> addAttributes(merged, className, attributes)); - addTrackedObjects(merged, xapiConfig); - 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/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheQueries.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheQueries.java index 6e46ff4..23c860b 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheQueries.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheQueries.java @@ -24,13 +24,15 @@ interface ObjectCacheQueries { String loadObject(); + String loadCurrentObjectSnapshot(); + String removeObject(); String findCurrentValue(); 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 94a5219..18cfb47 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheStore.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCacheStore.java @@ -9,19 +9,20 @@ 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); Optional findCurrentValue(String objectHandle, String pathKey); - List currentObjects(FomCatalog.ObjectClassDef clazz); + List currentObjects(List classes); 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/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); + } +} 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..f776b6f --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlan.java @@ -0,0 +1,259 @@ +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.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(className)); + return Set.copyOf(attributes); + } + while (current != null) { + addAttributes(attributes, subscriptions.get(current.hlaName())); + 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) -> + addReferencedAttributesForClassAndDescendants( + merged, + catalog, + 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; + } + addAllAttributesForClassAndDescendants( + events, + catalog, + trigger.clazz, + true); + } + 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; + } + addAllAttributesForClassAndDescendants( + merged, + catalog, + trigger.clazz, + false); + } + } + + 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)) { + if (trackedObject.allAttributes) { + catalog.objectClasses().forEach(clazz -> + addAttributes( + merged, + clazz.hlaName(), + clazz.topLevelAttributeNames())); + } + continue; + } + if (trackedObject.allAttributes) { + addAllAttributesForClassAndDescendants( + merged, + catalog, + trackedObject.clazz, + true); + } else { + 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.hlaName(), 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.hlaName(), + clazz.topLevelAttributeNames())); + } + + @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/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java b/src/main/java/com/yetanalytics/hlaxapi/cache/PostgresqlObjectCacheQueries.java index 2859121..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 """; } @@ -137,6 +136,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.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 + 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 = ?"; @@ -156,17 +175,19 @@ 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 - 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.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 + ORDER BY i.id + """.formatted(placeholders); } @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/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/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java b/src/main/java/com/yetanalytics/hlaxapi/cache/SqliteObjectCacheQueries.java index 08e883d..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 (?, ?, ?) """; } @@ -123,6 +122,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.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 + 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 = ?"; @@ -142,17 +161,19 @@ 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 - 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.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 + ORDER BY i.id + """.formatted(placeholders); } @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/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; } } diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/InjectionContext.java b/src/main/java/com/yetanalytics/hlaxapi/injection/InjectionContext.java index 21b5df5..ddd8fdc 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 { @@ -9,6 +10,8 @@ public abstract class InjectionContext { private boolean embedded = false; private String objectType; + public abstract StatementTrigger.Type eventType(); + public String getHlaClass() { return hlaClass; } @@ -45,4 +48,5 @@ public String getObjectType() { public void setObjectType(String objectType) { this.objectType = objectType; } + } 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 a4900bb..7b0cc99 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; -public class ObjectInjectionContext extends InjectionContext { +import java.util.Map; +public abstract class ObjectInjectionContext extends InjectionContext { + + private String objectHandle; + private Map attributeMap = Map.of(); + + protected ObjectInjectionContext() { + } + + protected 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/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/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 ce5a648..36ea42a 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/injection/TestInjectionContext.java +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/TestInjectionContext.java @@ -1,12 +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() { + this(StatementTrigger.Type.INTERACTION, null); } public TestInjectionContext(String hlaClass) { + this(StatementTrigger.Type.INTERACTION, hlaClass); + } + + 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 5998679..00b33c2 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":"SimEntity.Rabbit","statement":{}}, + {"type":"objectUpdate","class":"SimEntity.Rabbit","statement":{}}, + {"type":"OBJECTDELETE","class":"SimEntity.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"); @@ -168,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": "SimEntity.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"); @@ -197,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} ] @@ -210,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); @@ -336,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(); @@ -362,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(); @@ -383,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(); @@ -401,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"]]}, @@ -446,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( @@ -635,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/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/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/HlaInteractionDispatchTest.java b/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java new file mode 100644 index 0000000..624ce2a --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/HlaInteractionDispatchTest.java @@ -0,0 +1,186 @@ +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.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; +import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; + +class HlaInteractionDispatchTest { + + @Test + void canonicalNestedInteractionsSubscribeAndDispatchIndependently() throws Exception { + HLADecoderRegistry decoderRegistry = + new HLADecoderRegistry(new HLA1516eEncoderFactory()); + FOMXML fomXml = new FOMXML( + 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 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(entityTrigger, otherTrigger); + RecordingXapiClient xapiClient = new RecordingXapiClient(); + InteractionClassHandle entityClass = + (InteractionClassHandle) new HLA1516eHandle(101); + 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 "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); + 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); + + 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; + } + + 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 { + 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/ObjectInjectionHandlerTest.java b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java new file mode 100644 index 0000000..0d0454a --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/ObjectInjectionHandlerTest.java @@ -0,0 +1,288 @@ +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.assertThrows; +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.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.StatementTrigger; +import com.yetanalytics.hlaxapi.config.model.Target; +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; +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 ObjectUpdateInjectionContext( + "BaseEntity.TrackedEntity", "object-17", Map.of("Count", count)); + + assertEquals("BaseEntity.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 ObjectUpdateInjectionContext( + "BaseEntity.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 ObjectUpdateInjectionContext("BaseEntity.TrackedEntity", "object-17", Map.of())); + ValueResolution malformed = handler.handleTrigger( + target("Count"), + new ObjectUpdateInjectionContext( + "BaseEntity.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.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 rendersInheritedObjectTargetsAndChecksTheirStatementDatatype() { + TriggerProcessor processor = new TriggerProcessor(handler(OBJECT_FOM)); + 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, "BaseEntity.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 + @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) + void rendersQueryAndLookupValuesAndChecksTheirStatementDatatype() { + TriggerProcessor processor = new TriggerProcessor(handler(OBJECT_FOM)); + TestInjectionContext context = + new TestInjectionContext(StatementTrigger.Type.OBJECT_UPDATE, "BaseEntity.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]}}} + """); + + assertTrue(processor.renderTemplateForValidation(valid, context).success()); + assertFalse(processor.renderTemplateForValidation(wrongQueryDatatype, context).success()); + } + + @Test + @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) + void rendersInteractionTestValues() { + 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")); + assertTrue(result.success()); + assertTrue(result.statement().contains("\"raw\":0.5")); + } + + @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( + 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) { + 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 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, + 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)); + } + + 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/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java index 3a400e6..a889169 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 new file mode 100644 index 0000000..5ae72ef --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorDispatchTest.java @@ -0,0 +1,190 @@ +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; +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; +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; +import org.junit.jupiter.api.Test; +import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; + +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.TriggerProcessor"}) + void matchesExactlyStagesOnceAndIsolatesProcessingAndEnqueueFailures() { + 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); + ControlledTriggerProcessor processor = new ControlledTriggerProcessor(config, catalog); + + List staged = processor.stage( + new ObjectUpdateInjectionContext("SimEntity.Rabbit", "object-1", Map.of())); + + assertEquals(List.of("first", "second"), + staged.stream().map(TriggerProcessor.StagedStatement::statement).toList()); + assertEquals(List.of("first", "skip", "fail", "throw", "second"), processor.processed); + + List enqueued = new ArrayList<>(); + processor.enqueue(staged, statement -> { + if ("first".equals(statement)) { + throw new IllegalStateException("first enqueue failed"); + } + enqueued.add(statement); + }); + + assertEquals(List.of("second"), enqueued); + } + + @Test + void interactionEventsUseTheSameProcessorWithoutMatchingObjectTriggers() { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of( + trigger(StatementTrigger.Type.OBJECT_UPDATE, "SimEntity.Rabbit", "object"), + trigger(StatementTrigger.Type.INTERACTION, "SimEntity", "ancestor-interaction"), + trigger(StatementTrigger.Type.INTERACTION, "SimEntity.Rabbit", "interaction")); + TriggerProcessor processor = new ControlledTriggerProcessor(config, catalog); + List enqueued = new ArrayList<>(); + + processor.dispatch( + new InteractionInjectionContext("SimEntity.Rabbit", Map.of()), + enqueued::add); + + assertEquals(List.of("interaction"), enqueued); + } + + @Test + void lifecycleEventsMatchTheirTypeAndFomHierarchy() { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of( + trigger(StatementTrigger.Type.OBJECT_CREATE, "SimEntity", "sim-entity-create"), + 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, "SimEntity.Rabbit", "rabbit-delete"), + trigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity.Wolf", "wolf-delete")); + TriggerProcessor processor = new ControlledTriggerProcessor(config, catalog); + List createStatements = processor + .stage(new ObjectCreateInjectionContext("SimEntity.Rabbit", "object-1", Map.of())) + .stream() + .map(TriggerProcessor.StagedStatement::statement) + .toList(); + List deleteStatements = processor + .stage(new ObjectDeleteInjectionContext("SimEntity.Rabbit", "object-1", Map.of())) + .stream() + .map(TriggerProcessor.StagedStatement::statement) + .toList(); + + assertEquals( + List.of("sim-entity-create", "rabbit-create"), + createStatements); + assertEquals( + List.of("sim-entity-delete", "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, "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); + ObjectUpdateInjectionContext rabbit = + new ObjectUpdateInjectionContext("SimEntity.Rabbit", "object-1", Map.of()); + + List updateStatements = processor + .stage(rabbit) + .stream() + .map(TriggerProcessor.StagedStatement::statement) + .toList(); + + assertEquals( + List.of("sim-entity-update", "rabbit-update"), + 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; + trigger.clazz = className; + trigger.statement = statement; + return trigger; + } + + 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, + 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/FomCatalogTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java index 4d31b4f..df0130b 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,12 +37,85 @@ 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()); } + @Test + void resolvesObjectClassesWithTheirDescendants() { + FomCatalog catalog = catalog("config/HlaFedereplFOM.xml"); + + assertEquals( + List.of("SimEntity", "SimEntity.Carrot", "SimEntity.Rabbit", "SimEntity.Wolf"), + catalog.objectClassAndDescendants("SimEntity").stream() + .map(FomCatalog.ObjectClassDef::hlaName) + .toList()); + assertEquals( + List.of("SimEntity.Rabbit"), + catalog.objectClassAndDescendants("SimEntity.Rabbit").stream() + .map(FomCatalog.ObjectClassDef::hlaName) + .toList()); + assertEquals(List.of(), catalog.objectClassAndDescendants("MissingObject")); + } + + @Test + void matchesObjectClassesThroughTheirFomHierarchy() { + FomCatalog catalog = catalog("config/HlaFedereplFOM.xml"); + + 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("SimEntity.Rabbit", "MissingObject")); + } + + @Test + void calculatesObjectClassDepthFromKnownFomAncestors() { + FomCatalog catalog = catalog("config/HlaFedereplFOM.xml"); + + assertEquals(1, catalog.objectClassDepth("SimEntity")); + assertEquals(2, catalog.objectClassDepth("SimEntity.Carrot")); + assertEquals(2, catalog.objectClassDepth("SimEntity.Rabbit")); + assertEquals(2, catalog.objectClassDepth("SimEntity.Wolf")); + 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("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 + void indexesObjectClassesByCanonicalNameWithoutLocalCollisions() { + FomCatalog catalog = catalog("src/test/resources/config/AmbiguousClassNamesFOM.xml"); + + FomCatalog.ObjectClassDef entityRabbit = + catalog.objectClass("SimEntity.Rabbit").orElseThrow(); + FomCatalog.ObjectClassDef otherRabbit = + catalog.objectClass("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()); + } + @Test void fomXmlReturnsHierarchyWithDeclaredAttributes() { FOMXML fomXml = fomXml("config/HlaFedereplFOM.xml"); @@ -52,7 +125,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(); 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..517e769 --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectSubscriptionTest.java @@ -0,0 +1,1536 @@ +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.InjectionHandler; +import com.yetanalytics.hlaxapi.SimulationConfig; +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.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; +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; +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; +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 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; +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("SimEntity.Rabbit")); + Set expectedAttributes = + Set.copyOf(catalog.objectClass("SimEntity.Rabbit").orElseThrow().topLevelAttributeNames()); + + 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); + + assertFalse(cache.isEnabled()); + assertEquals(List.of(new ObjectSubscription("SimEntity.Rabbit", expectedAttributes)), rti.subscriptions); + + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.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("SimEntity.Rabbit").isEmpty()); + assertEquals(List.of("{}"), xapiClient.statements); + } + } + + @Test + void firstReflectionDispatchesObjectCreateAndObjectUpdateThenOnlyUpdates() throws Exception { + StatementTrigger create = objectTrigger( + StatementTrigger.Type.OBJECT_CREATE, + "SimEntity.Rabbit", + """ + {"event":"create","hunger":["trigger",["Hunger"]]} + """); + StatementTrigger update = objectTrigger( + StatementTrigger.Type.OBJECT_UPDATE, + "SimEntity.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("SimEntity.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 + 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, + "SimEntity.Rabbit", + "{\"event\":\"rabbit-create\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_CREATE, + "SimEntity.Wolf", + "{\"event\":\"wolf-create\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_UPDATE, + "SimEntity", + "{\"event\":\"sim-entity-update\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_UPDATE, + "SimEntity.Rabbit", + "{\"event\":\"rabbit-update\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_UPDATE, + "SimEntity.Wolf", + "{\"event\":\"wolf-update\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_DELETE, + "SimEntity", + "{\"event\":\"sim-entity-delete\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_DELETE, + "SimEntity.Rabbit", + "{\"event\":\"rabbit-delete\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_DELETE, + "SimEntity.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("SimEntity.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(); + config.statementTriggers = List.of( + objectTrigger( + StatementTrigger.Type.OBJECT_CREATE, + "SimEntity.Rabbit", + "{\"event\":\"create\"}"), + objectTrigger( + StatementTrigger.Type.OBJECT_UPDATE, + "SimEntity.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("SimEntity.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 { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(objectTrigger( + StatementTrigger.Type.OBJECT_CREATE, + "SimEntity.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("SimEntity.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, "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("SimEntity.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" + }) + void firstReflectionConsumesCreateEvenWhenRequiredValuesAreMissing() throws Exception { + StatementTrigger required = objectTrigger( + StatementTrigger.Type.OBJECT_CREATE, + "SimEntity.Rabbit", + "{\"entityId\":[\"trigger\",[\"EntityId\"]]}"); + StatementTrigger optional = objectTrigger( + StatementTrigger.Type.OBJECT_CREATE, + "SimEntity.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("SimEntity.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, + "SimEntity.Rabbit", + """ + { + "entityId":["trigger",["EntityId"]], + "hunger":["trigger",["Hunger"]], + "x":["trigger",["Position","X"]], + "queried":["query","SimEntity.Rabbit",["Hunger"],null], + "lookedUp":["lookup","rabbit",["Hunger"]] + } + """); + delete.criteria = comparison("Hunger", ComparisonOperator.GT, 10); + ObjectLookup lookup = new ObjectLookup(); + lookup.clazz = "SimEntity.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, + "SimEntity.Rabbit", + "{\"skipped\":true}"); + skipped.criteria = comparison("Hunger", ComparisonOperator.GT, 20); + StatementTrigger wrongClass = objectTrigger( + StatementTrigger.Type.OBJECT_DELETE, + "SimEntity.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("SimEntity.Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(99); + AtomicReference removedAtEnqueue = new AtomicReference<>(false); + RecordingXapiClient xapiClient = new RecordingXapiClient(statement -> + removedAtEnqueue.set(cache.currentObjects("SimEntity.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" + }) + void discoveryRemovalRaceStillDispatchesStaticAndOptionalDeletes(@TempDir Path tempDir) throws Exception { + StatementTrigger staticDelete = + objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity.Rabbit", "{\"deleted\":true}"); + StatementTrigger requiredMissing = objectTrigger( + StatementTrigger.Type.OBJECT_DELETE, + "SimEntity.Rabbit", + "{\"entityId\":[\"trigger\",[\"EntityId\"]]}"); + StatementTrigger optionalMissing = objectTrigger( + StatementTrigger.Type.OBJECT_DELETE, + "SimEntity.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("SimEntity.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("SimEntity.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, "SimEntity.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("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("SimEntity.Rabbit").size()); + } + } + + @Test + void everyLifecycleCallbackOverloadUsesTheCommonPipelines(@TempDir Path tempDir) throws Exception { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of( + objectTrigger(StatementTrigger.Type.OBJECT_CREATE, "SimEntity.Rabbit", "{\"event\":\"create\"}"), + objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity.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("SimEntity.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("SimEntity.Rabbit").isEmpty()); + } + } + + @Test + @SuppressTestLogging({ + "com.yetanalytics.hlaxapi.TriggerProcessor" + }) + void eventOnlyReflectionDispatchesMatchingTriggersOnceFromTheCompletePayload() throws Exception { + StatementTrigger passing = objectUpdateTrigger( + "SimEntity.Rabbit", + """ + {"incomingHunger":["trigger",["Hunger"]]} + """); + passing.criteria = comparison("Hunger", ComparisonOperator.GT, 10); + StatementTrigger requiredMissing = objectUpdateTrigger( + "SimEntity.Rabbit", + """ + {"entityId":["trigger",["EntityId"]]} + """); + StatementTrigger optionalMissing = objectUpdateTrigger( + "SimEntity.Rabbit", + """ + {"entityId":["trigger",["EntityId"],{"required":false}]} + """); + StatementTrigger skipped = objectUpdateTrigger( + "SimEntity.Rabbit", + """ + {"skipped":true} + """); + skipped.criteria = comparison("Hunger", ComparisonOperator.GT, 20); + StatementTrigger wrongClass = objectUpdateTrigger("SimEntity.Wolf", """ + {"wrongClass":true} + """); + StatementTrigger wrongType = objectUpdateTrigger("SimEntity.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("SimEntity.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( + "SimEntity.Rabbit", + """ + { + "incoming":["trigger",["Hunger"]], + "queried":["query","SimEntity.Rabbit",["Hunger"],[["EntityId"],"=","rabbit-one"]], + "lookedUp":["lookup","rabbit",["Hunger"]] + } + """); + ObjectLookup lookup = new ObjectLookup(); + lookup.clazz = "SimEntity.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("SimEntity.Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(94); + cache.reflectAttributeValues( + rabbit.toString(), + "SimEntity.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 + void previousCriteriaDetectChangesAndThresholdCrossings(@TempDir Path tempDir) throws Exception { + StatementTrigger changed = objectUpdateTrigger( + "SimEntity.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( + "SimEntity.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("SimEntity.Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(106); + AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); + cache.reflectAttributeValue( + rabbit.toString(), + "SimEntity.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" + }) + void firstObservationSupportsOptionalPreviousWithoutRetryingRequiredInjections( + @TempDir Path tempDir) throws Exception { + StatementTrigger required = objectUpdateTrigger( + "SimEntity.Rabbit", + """ + {"event":"required","old":["previous",["Hunger"]],"new":["trigger",["Hunger"]]} + """); + StatementTrigger optional = objectUpdateTrigger( + "SimEntity.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("SimEntity.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( + "SimEntity.Rabbit", + """ + {"trigger":1,"old":["previous",["Hunger"]],"new":["trigger",["Hunger"]]} + """); + StatementTrigger second = objectUpdateTrigger( + "SimEntity.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("SimEntity.Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(108); + AttributeHandle hunger = rti.attributeHandle(rabbitClass, "Hunger"); + AttributeHandle unknown = rti.attributeHandle(rabbitClass, "NotInTheFom"); + cache.reflectAttributeValue( + rabbit.toString(), + "SimEntity.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 { + XapiConfig config = trackedRabbitConfig(objectUpdateTrigger("SimEntity.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("SimEntity.Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(95); + cache.reflectAttributeValue( + rabbit.toString(), + "SimEntity.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()); + } + } + + @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(), config, new RecordingXapiClient()); + ObjectClassHandle rabbitClass = rti.classHandle("SimEntity.Rabbit"); + ObjectInstanceHandle rabbit = rti.objectHandle(92); + + hlaInterface.discoverObjectInstance(rabbit, rabbitClass, "Rabbit Two"); + + assertTrue(cache.isEnabled()); + 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()); + } + } + + @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 = "SimEntity.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("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("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()); + } + } + + @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("SimEntity.Carrot", "SimEntity.Rabbit", "SimEntity.Wolf", "SimEntity"), + 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")))); + + 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); + 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 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 { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of( + objectUpdateTrigger( + "SimEntity", + "{\"event\":\"sim-entity-update\"}"), + objectUpdateTrigger( + "SimEntity.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("SimEntity.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 { + 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(), + config, + new RecordingXapiClient())); + + assertTrue(rti.subscriptions.isEmpty()); + } + } + + private XapiConfig configWithQueryAndObjectUpdate() { + StatementTrigger query = new StatementTrigger(); + query.statement = """ + {"actor":{"name":["query","SimEntity.Rabbit",["EntityId"],[["Hunger"],">",50]]}} + """; + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(query, objectUpdateTrigger("SimEntity.Rabbit")); + return config; + } + + private StatementTrigger objectUpdateTrigger(String className) { + return objectUpdateTrigger(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 = 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))), + operator, + new ValueExpression(value)); + } + + private XapiConfig trackedRabbitConfig(StatementTrigger trigger) { + TrackedObject trackedRabbit = new TrackedObject(); + trackedRabbit.clazz = "SimEntity.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, "xapiConfig", config); + setField( + hlaInterface, + "triggerProcessor", + new TriggerProcessor(config, injectionHandler, catalog)); + setField(hlaInterface, "xapiClient", xapiClient); + 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 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 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( + 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<>(); + 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<>(); + 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<>(); + 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 boolean failAttributeRequestsForUnknownObjects; + + private RTIambassador proxy() { + return (RTIambassador) Proxy.newProxyInstance( + RTIambassador.class.getClassLoader(), + new Class[] {RTIambassador.class}, + this); + } + + private ObjectClassHandle classHandle(String className) { + if (className.startsWith("HLAobjectRoot.")) { + className = className.substring("HLAobjectRoot.".length()); + } + 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) throws Throwable { + return switch (method.getName()) { + 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]); + case "subscribeObjectClassAttributes" -> { + subscriptions.add(new ObjectSubscription( + classNames.get(args[0]), + names((AttributeHandleSet) args[1]))); + yield null; + } + case "requestAttributeValueUpdate" -> { + if (failAttributeRequestsForUnknownObjects) { + throw new ObjectInstanceNotKnown("object disappeared"); + } + 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 "HLAobjectRoot".equals(className) + ? className + : "HLAobjectRoot." + 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/ObjectCachePersistenceTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java index 6c1ed37..4cb49a7 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java @@ -4,29 +4,39 @@ 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; 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 com.yetanalytics.hlaxapi.injection.ObjectUpdateInjectionContext; 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; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; @@ -46,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); } @@ -58,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, """ @@ -74,12 +84,204 @@ SELECT COUNT(*) } @Test - void flattensFixedRecordValuesToNestedCurrentRows() { + 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", + "SimEntity.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 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("Rabbit"), + dynamicArrayCatalog, + dynamicArrayFomXml)) { cache.discoverObject("object-1", "Rabbit One", "Rabbit"); - cache.reflectAttributeValue("object-1", "Rabbit", "Position", position); + 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)); + byte[] newHunger = encoded(encoderFactory.createHLAinteger32BE(75)); + + try (ObjectCache cache = newCache()) { + cache.reflectAttributeValue("object-1", "SimEntity.Rabbit", "Hunger", oldHunger); + + assertThrows( + IllegalArgumentException.class, + () -> cache.reflectAttributeValues( + "object-1", + "SimEntity.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", "SimEntity.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", + "SimEntity.Rabbit", + Map.of( + "EntityId", oldEntityId, + "Hunger", oldHunger)); + FomCatalog.ObjectClassDef rabbit = catalog.objectClass("SimEntity.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("Rabbit"), + 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); + + try (ObjectCache cache = newCache()) { + 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()); @@ -93,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"); @@ -153,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")), @@ -177,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, @@ -189,17 +391,171 @@ 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()); } } + @Test + void baseClassQueryFindsObjectsCachedAsDescendantClasses() { + try (ObjectCache cache = newCache()) { + cache.discoverObject("entity-1", "Entity One", "SimEntity"); + cache.discoverObject("rabbit-1", "Rabbit One", "SimEntity.Rabbit"); + cache.reflectAttributeValues( + "rabbit-1", + "SimEntity.Rabbit", + Map.of( + "EntityId", encoded(encoderFactory.createHLAASCIIstring("rabbit-one")), + "FirstName", encoded(encoderFactory.createHLAunicodeString("Alice")))); + cache.discoverObject("wolf-1", "Wolf One", "SimEntity.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("SimEntity.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("SimEntity.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", + "SimEntity.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 previousResolutionSupportsNestedArraysCachedNullAndMissingValues() throws Exception { + try (ObjectCache cache = newCache( + "previous-resolution", + enabledConfig("Rabbit"), + 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); + ObjectUpdateInjectionContext context = + new ObjectUpdateInjectionContext("Rabbit", "rabbit-1", Map.of()); + + 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()) { - 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, @@ -217,8 +573,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")); @@ -232,6 +588,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"); } @@ -247,9 +656,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(); @@ -257,6 +668,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)); @@ -287,6 +705,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 diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java index 78b3962..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)); } } @@ -65,6 +65,137 @@ 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("SimEntity.Rabbit")); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + databasePath)) { + Set rabbitAttributes = + Set.copyOf(catalog.objectClass("SimEntity.Rabbit").orElseThrow().topLevelAttributeNames()); + + assertFalse(cache.isEnabled()); + assertTrue(cache.cacheSubscriptions().isEmpty()); + assertEquals(rabbitAttributes, cache.eventSubscriptions().get("SimEntity.Rabbit")); + assertEquals(rabbitAttributes, cache.subscriptions().get("SimEntity.Rabbit")); + assertTrue(cache.hasSubscriptions()); + assertFalse(Files.exists(databasePath)); + } + } + + @Test + void objectUpdatePreviousReferencesEnableOnlyTheirCacheAttributes(@TempDir Path tempDir) { + StatementTrigger trigger = objectUpdateTrigger("SimEntity.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("SimEntity.Rabbit").orElseThrow().topLevelAttributeNames()); + + assertTrue(cache.isEnabled()); + assertEquals(Set.of("Hunger", "Position"), cache.cacheSubscriptions().get("SimEntity.Rabbit")); + assertEquals(rabbitAttributes, cache.eventSubscriptions().get("SimEntity.Rabbit")); + assertEquals(rabbitAttributes, cache.subscriptions().get("SimEntity.Rabbit")); + } + } + + @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, "SimEntity.Rabbit")); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + databasePath)) { + Set rabbitAttributes = + Set.copyOf(catalog.objectClass("SimEntity.Rabbit").orElseThrow().topLevelAttributeNames()); + + assertFalse(cache.isEnabled()); + assertTrue(cache.cacheSubscriptions().isEmpty()); + assertEquals(rabbitAttributes, cache.eventSubscriptions().get("SimEntity.Rabbit")); + assertFalse(Files.exists(databasePath)); + } + } + + @Test + void objectDeleteSubscriptionsEnableCacheForAllInheritedAttributes(@TempDir Path tempDir) { + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(objectTrigger(StatementTrigger.Type.OBJECT_DELETE, "SimEntity.Rabbit")); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("object-delete.sqlite"))) { + Set rabbitAttributes = + Set.copyOf(catalog.objectClass("SimEntity.Rabbit").orElseThrow().topLevelAttributeNames()); + + assertTrue(cache.isEnabled()); + assertEquals(rabbitAttributes, cache.cacheSubscriptions().get("SimEntity.Rabbit")); + assertEquals(rabbitAttributes, cache.eventSubscriptions().get("SimEntity.Rabbit")); + assertEquals(rabbitAttributes, cache.subscriptions().get("SimEntity.Rabbit")); + } + } + + @Test + void objectUpdateSubscriptionsMergeWithoutChangingCacheRequirements(@TempDir Path tempDir) { + XapiConfig config = configWithQuery(); + config.statementTriggers = List.of( + config.statementTriggers.get(0), + objectUpdateTrigger("SimEntity.Rabbit"), + objectUpdateTrigger("SimEntity.Rabbit")); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("object-update-merged.sqlite"))) { + Set rabbitAttributes = + Set.copyOf(catalog.objectClass("SimEntity.Rabbit").orElseThrow().topLevelAttributeNames()); + + assertTrue(cache.isEnabled()); + assertEquals(Set.of("EntityId", "Hunger"), cache.cacheSubscriptions().get("SimEntity.Rabbit")); + assertEquals(rabbitAttributes, cache.eventSubscriptions().get("SimEntity.Rabbit")); + assertEquals(rabbitAttributes, cache.subscriptions().get("SimEntity.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"); @@ -75,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( @@ -87,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)); } } @@ -101,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")), @@ -119,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")); } } @@ -128,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)); } } @@ -153,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")); } } @@ -187,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")); } @@ -196,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, @@ -204,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(); @@ -225,6 +356,18 @@ private XapiConfig configWithTrackedObject(String className, List attrib return config; } + 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 = type; + trigger.clazz = className; + trigger.statement = "{}"; + return trigger; + } + private ObjectCacheConfig objectCacheConfig(TrackedObject... trackedObjects) { ObjectCacheConfig objectCacheConfig = new ObjectCacheConfig(); objectCacheConfig.trackedObjects = List.of(trackedObjects); 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..fab1edc --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectSubscriptionPlanTest.java @@ -0,0 +1,182 @@ +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; + +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.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; + +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 = "SimEntity.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("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("SimEntity.Rabbit")); + assertEquals(Set.of("FirstName"), plan.effectiveAttributes("SimEntity.Wolf")); + assertThrows( + UnsupportedOperationException.class, + () -> plan.subscriptions().put("SimEntity.Wolf", Set.of("Hunger"))); + assertThrows( + UnsupportedOperationException.class, + () -> plan.subscriptions().get("SimEntity.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.hlaName())); + } + 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.hlaName())); + } + + 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.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 0d0baa0..52411e7 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; @@ -24,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 @@ -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 = "SimEntity.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 = "SimEntity.Wolf"; + + Map> references = + QueryReferenceCollector.collect(List.of(update, create)); + + assertEquals(Set.of("EntityId", "Position", "Hunger"), references.get("SimEntity.Rabbit")); + assertFalse(references.containsKey("SimEntity.Wolf")); + } + private StatementTrigger trigger(String statement) { StatementTrigger trigger = new StatementTrigger(); trigger.statement = 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")); } } 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\",{}]]")) { diff --git a/src/test/java/com/yetanalytics/hlaxapi/injection/XapiValueGeneratorTest.java b/src/test/java/com/yetanalytics/hlaxapi/injection/XapiValueGeneratorTest.java index 80fda73..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); @@ -249,5 +249,3 @@ void validationInjectionTests() { } } } - - diff --git a/src/test/resources/config/AmbiguousClassNamesFOM.xml b/src/test/resources/config/AmbiguousClassNamesFOM.xml new file mode 100644 index 0000000..591141c --- /dev/null +++ b/src/test/resources/config/AmbiguousClassNamesFOM.xml @@ -0,0 +1,72 @@ + + + + + HLAobjectRoot + + SimEntity + + EntityId + HLAinteger32BE + + + Rabbit + + Hunger + HLAinteger32BE + + + + + SomeOtherSuperclass + + OtherId + HLAinteger32BE + + + Rabbit + + Speed + HLAinteger32BE + + + + + + + + HLAinteractionRoot + + EntityEvents + + EntityId + HLAinteger32BE + + + Updated + + Hunger + HLAinteger32BE + + + + Created + + + + OtherEvents + + OtherId + HLAinteger32BE + + + Updated + + Speed + HLAinteger32BE + + + + + + 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 + + + + +