From c16a42acd91198c9d3fab2d0300f85bbe55b040d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ren=C3=A9=20Sch=C3=B6ne?= <rene.schoene@tu-dresden.de>
Date: Fri, 4 Feb 2022 15:47:58 +0100
Subject: [PATCH] Resolve "Feature: Send endpoint for attributes"

- change syntax in connect file, avoid colon
- observer-entry now has list of connect-tokens and attributeCall is invoked once when attribute is flushed
- INC_FLUSH_START and INC_FLUSH_END can now be nested
- fix capitalize for null and empty strings, caused problems when used for empty entityName of context-free-endpoints
- add documentation on finding bugs
- consolidated use of awaitility
- add documentation for attributes and their types
- rename entityIsNormalAttribute to hasAttributeResetMethod (and flip boolean value)
---
 pages/docs/dsl.md                             |   8 +-
 pages/docs/inner-workings.md                  |  32 ++-
 .../src/main/jastadd/Analysis.jrag            |  27 +-
 .../src/main/jastadd/Intermediate.jadd        |  29 +-
 .../src/main/jastadd/Intermediate.relast      |   1 +
 .../src/main/jastadd/IntermediateToYAML.jrag  |   2 +-
 .../src/main/jastadd/Mappings.jrag            |  17 +-
 .../src/main/jastadd/Navigation.jrag          |  13 +
 .../src/main/jastadd/RagConnect.relast        |   5 +-
 ragconnect.base/src/main/jastadd/Util.jadd    |   2 +
 .../main/jastadd/parser/ParserRewrites.jrag   |  15 +-
 .../src/main/jastadd/parser/RagConnect.parser |   6 +-
 .../src/main/jastadd/scanner/Keywords.flex    |   2 +
 .../src/main/resources/handler.mustache       |   8 +
 .../src/main/resources/ragconnect.mustache    |  84 ++++--
 .../main/resources/sendDefinition.mustache    |  10 +-
 ragconnect.tests/build.gradle                 |  27 +-
 .../src/test/01-input/attribute/README.md     |   3 +
 .../src/test/01-input/attribute/Test.connect  |  31 +++
 .../src/test/01-input/attribute/Test.jadd     |  39 +++
 .../src/test/01-input/attribute/Test.relast   |  14 +
 .../src/test/01-input/errors/Standard.connect |   9 +
 .../test/01-input/errors/Standard.expected    |   2 +
 .../ragconnect/tests/AttributeTest.java       | 251 ++++++++++++++++++
 .../ragconnect/tests/ForwardingTest.java      |  12 +-
 .../ragconnect/tests/IndexedSendTest.java     |   6 +-
 .../jastadd/ragconnect/tests/TestUtils.java   |   9 +-
 .../singleList/AbstractSingleListTest.java    |   6 +-
 28 files changed, 586 insertions(+), 84 deletions(-)
 create mode 100644 ragconnect.tests/src/test/01-input/attribute/README.md
 create mode 100644 ragconnect.tests/src/test/01-input/attribute/Test.connect
 create mode 100644 ragconnect.tests/src/test/01-input/attribute/Test.jadd
 create mode 100644 ragconnect.tests/src/test/01-input/attribute/Test.relast
 create mode 100644 ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/AttributeTest.java

diff --git a/pages/docs/dsl.md b/pages/docs/dsl.md
index 19125a8..e8b6b2b 100644
--- a/pages/docs/dsl.md
+++ b/pages/docs/dsl.md
@@ -13,7 +13,7 @@ The kind of the element determines, whether an endpoint for it can be receiving,
 To declare a new endpoints, use the following syntax:
 
 ```
-("send"|"receive") ["indexed"] ["with add"] <Non-Terminal>[.<Target>["()"]] ["using" <Mapping-Name> (, <Mapping-Name>)*] ";"
+("send"|"receive") ["indexed"] ["with add"] <Non-Terminal>[.<Target>["(<AttributeType>)"]] ["using" <Mapping-Name> (, <Mapping-Name>)*] ";"
 ```
 
 A breakdown of the parts of that syntax:
@@ -28,10 +28,12 @@ A breakdown of the parts of that syntax:
 - The second optional keyword `with add` can also be used only for receiving endpoints targeting a list children.
   As described above, it can be combined with `indexed`.
   If used on its own, the incoming data is interpreted as a complete list and its elements will be appended to the current list.
-- The `<Non-Terminal>[.<Target>["()"]]` notation describes the actual affected node.
+- The `<Non-Terminal>[.<Target>["(<AttributeType>)"]]` notation describes the actual affected node.
   - If the target is omitted, all nodes of that non-terminal type can be connected, irrespective of their context. This is a context-free endpoint definition.
   - The target can be any child on the right-hand side of a production rule, a role of a relation, or an attribute.
-    The brackets `()` after the target must be used in case of an attribute, and only then.
+    The brackets `(<AttributeType>)` after the target must be used in case of an attribute, and only then.
+    Here, the return type of the attribute has to be specified, as aspect files are not parsed by RagConnect.
+    Hence, RagConnect can not and will not verify the existence of the attribute, and the possible non-existence of an attribute will be found by the Java compiler.
 - Optionally, an endpoint can use one or more [mappings](#mappings).
   They will be applied before sending, or after receiving a message.
   Mappings will always be applied in the order they are listed after `using`.
diff --git a/pages/docs/inner-workings.md b/pages/docs/inner-workings.md
index bd9986a..1cb22a5 100644
--- a/pages/docs/inner-workings.md
+++ b/pages/docs/inner-workings.md
@@ -1,4 +1,4 @@
-# Inner workings of `RagConnect`
+# Inner Workings of `RagConnect`
 
 Please see [API documentation](ragdoc/index.html) for more details.
 
@@ -20,11 +20,11 @@ The other main aspect (which is currently not really used) is `IntermediateToYAM
 This is used to generate a YAML file containing the data used by mustache.
 It can be used by the default mustache implementation together with the templates.
 
-# Implementation details
+# Implementation Details
 
 In the following, details for special implementation topics are discussed.
 
-## forwarding
+## Forwarding
 
 When a nonterminal is used in a send endpoints, it needs an implicit forwarding attribute to work, because only _computed elements_ can be sent.
 Since the nonterminal itself should be sent, the generated attribute simply returns this nonterminal.
@@ -33,3 +33,29 @@ However, changing any token within the whole subtree or changing the structure o
 This way, the dependency tracking registers a dependency between structure and tokens to the attribute.
 
 The attribute (as well as any other generated element) is prefixed with `_ragconnect_` to avoid potential name conflicts with user-specified elements.
+
+# Implementation Hints
+
+## Debugging Tests and Finding Bugs
+
+To help with finding errors/bugs when tests fail, there are several things to find the correct spot.
+
+- **Look closely**. Analyze the error message closely, and possible any previous error message(s) that could have caused the test to fail.
+- **Focus on single error**
+  - To only inspect one test, mark them with `@Tag("New")` and use the gradle task "newTests".
+  - Use `Assumptions.assumeTrue(false);` to abort unneeded test cases early.
+  - When editing RagConnect itself and force recreating source for the affected test, e.g., `compileForwardingIncremental.outputs.upToDateWhen { false }`
+  - _Remember to undo all changes, once the bug is fixed._
+- **Activate logs**. Add the following to the `ragconnect` specification of the compile-task of the affected test:
+  ```
+  logReads = true
+  logWrites = true
+  logIncremental = true
+  ```
+  _Remember to remove those lines, once the bug is fixed._
+- **Trace incremental events**. Add the following right after create the root node (named `model` here):
+  ```java
+  model.trace().setReceiver(TestUtils::logEvent);
+  ```
+  This will output every event fired by the incremental evaluation engine. _Remember to remove this line, once the bug is fixed._
+- **Add log statements**. As there will be quite some log output, add some identifying log statement (i.e., using `logger.fatal("---")`) right before the suspicious statement to inspect only the relevant log message after that.
diff --git a/ragconnect.base/src/main/jastadd/Analysis.jrag b/ragconnect.base/src/main/jastadd/Analysis.jrag
index 85ff38d..d4e166a 100644
--- a/ragconnect.base/src/main/jastadd/Analysis.jrag
+++ b/ragconnect.base/src/main/jastadd/Analysis.jrag
@@ -2,6 +2,19 @@ aspect Analysis {
   // --- isAlreadyDefined ---
   syn boolean EndpointDefinition.isAlreadyDefined() = getEndpointTarget().isAlreadyDefined();
   syn boolean EndpointTarget.isAlreadyDefined();
+  eq AttributeEndpointTarget.isAlreadyDefined() {
+    // define lookup here, as not used elsewhere
+    int numberOfSameDefs = 0;
+    for (EndpointTarget target : ragconnect().givenEndpointTargetList()) {
+      if (target.isAttributeEndpointTarget()) {
+        AttributeEndpointTarget other = target.asAttributeEndpointTarget();
+        if (other.getParentTypeDecl().equals(this.getParentTypeDecl()) && other.getName().equals(this.getName())) {
+          numberOfSameDefs += 1;
+        }
+      }
+    }
+    return numberOfSameDefs > 1;
+  }
   eq TokenEndpointTarget.isAlreadyDefined() {
     return lookupTokenEndpointDefinitions(getToken()).stream()
         .filter(containingEndpointDefinition()::matchesType)
@@ -63,13 +76,17 @@ aspect Analysis {
     }
   }
 
-  syn boolean EndpointTarget.entityIsNormalAttribute();
-  eq TokenEndpointTarget.entityIsNormalAttribute() = !getToken().getNTA();
-  eq TypeEndpointTarget.entityIsNormalAttribute() = !getType().getNTA();
-  eq ContextFreeTypeEndpointTarget.entityIsNormalAttribute() = false;
+  syn boolean EndpointTarget.hasAttributeResetMethod();
+  eq AttributeEndpointTarget.hasAttributeResetMethod() = false;
+  eq TokenEndpointTarget.hasAttributeResetMethod() = getToken().getNTA();
+  eq TypeEndpointTarget.hasAttributeResetMethod() = getType().getNTA();
+  eq ContextFreeTypeEndpointTarget.hasAttributeResetMethod() = false;
 
   // --- needProxyToken ---
-  syn boolean TokenComponent.needProxyToken() = !getDependencySourceDefinitionList().isEmpty() || getTokenEndpointTargetList().stream().map(EndpointTarget::containingEndpointDefinition).anyMatch(EndpointDefinition::shouldSendValue);
+  syn boolean TokenComponent.needProxyToken() = !getDependencySourceDefinitionList().isEmpty() ||
+          getTokenEndpointTargetList().stream()
+                  .map(EndpointTarget::containingEndpointDefinition)
+                  .anyMatch(EndpointDefinition::shouldNotResetValue);
 
   // --- effectiveUsedAt ---
   coll Set<EndpointDefinition> MappingDefinition.effectiveUsedAt()
diff --git a/ragconnect.base/src/main/jastadd/Intermediate.jadd b/ragconnect.base/src/main/jastadd/Intermediate.jadd
index bcd4cc4..47f3aa4 100644
--- a/ragconnect.base/src/main/jastadd/Intermediate.jadd
+++ b/ragconnect.base/src/main/jastadd/Intermediate.jadd
@@ -168,6 +168,10 @@ aspect MustacheMappingApplicationAndDefinition {
   syn String MEndpointDefinition.preemptiveReturn();
   syn String MEndpointDefinition.firstInputVarName();
 
+  eq MAttributeSendDefinition.firstInputVarName() = getterMethodCall();
+  eq MAttributeSendDefinition.preemptiveExpectedValue() = lastValueGetterCall();
+  eq MAttributeSendDefinition.preemptiveReturn() = "return false;";
+
   eq MTokenReceiveDefinition.firstInputVarName() = "message";
   eq MTokenReceiveDefinition.preemptiveExpectedValue() = getterMethodCall();
   eq MTokenReceiveDefinition.preemptiveReturn() = "return;";
@@ -323,7 +327,7 @@ aspect MustacheRagConnect {
 
 aspect MustacheReceiveAndSendAndHandleUri {
   // === EndpointDefinition ===
-  syn String EndpointDefinition.connectMethodName() = "connect" + entityName();
+  syn String EndpointDefinition.connectMethodName() = "connect" + capitalize(entityName());
 
   syn String EndpointDefinition.connectParameterName() = "uriString";
 
@@ -348,7 +352,7 @@ aspect MustacheReceiveAndSendAndHandleUri {
     } else {
       extra = "";
     }
-    return "disconnect" + extra + entityName();
+    return "disconnect" + extra + capitalize(entityName());
   }
 
   syn String EndpointDefinition.entityName() = getEndpointTarget().entityName();
@@ -371,6 +375,10 @@ aspect MustacheReceiveAndSendAndHandleUri {
   syn String EndpointTarget.parentTypeName();
   syn String EndpointTarget.entityName();
 
+  eq AttributeEndpointTarget.getterMethodName() = getName();
+  eq AttributeEndpointTarget.parentTypeName() = getParentTypeDecl().getName();
+  eq AttributeEndpointTarget.entityName() = getName();
+
   eq TokenEndpointTarget.getterMethodName() = "get" + getToken().getName();
   eq TokenEndpointTarget.parentTypeName() = getToken().containingTypeDecl().getName();
   eq TokenEndpointTarget.entityName() = getToken().getName();
@@ -429,7 +437,7 @@ aspect MustacheSendDefinition {
 
   syn String EndpointDefinition.senderName() = getEndpointTarget().senderName();
 
-  syn boolean EndpointDefinition.shouldSendValue() = getSend() && getEndpointTarget().entityIsNormalAttribute();
+  syn boolean EndpointDefinition.shouldNotResetValue() = getSend() && !getEndpointTarget().hasAttributeResetMethod();
 
   syn String EndpointDefinition.tokenResetMethodName() = getterMethodName() + "_reset";
 
@@ -456,14 +464,15 @@ aspect MustacheSendDefinition {
           getTypeDecl().getName() :
           ragconnect().configJastAddList() + "<" + getTypeDecl().getName() + ">";
 
-  syn String EndpointTarget.senderName();
-  eq TokenEndpointTarget.senderName() = ragconnect().internalRagConnectPrefix() + "_sender_" + getToken().getName();
-  eq TypeEndpointTarget.senderName() = ragconnect().internalRagConnectPrefix() + "_sender_" + getType().getName();
+  syn String EndpointTarget.senderName() = ragconnect().internalRagConnectPrefix() + "_sender_" + entityName();
   eq ContextFreeTypeEndpointTarget.senderName() = null;
 
   syn String MEndpointDefinition.updateMethodName();
   syn String MEndpointDefinition.writeMethodName();
 
+  eq MAttributeSendDefinition.updateMethodName() = ragconnect().internalRagConnectPrefix() + "_update_attr_" + getEndpointDefinition().entityName();
+  eq MAttributeSendDefinition.writeMethodName() = ragconnect().internalRagConnectPrefix() + "_writeLastValue_attr_" + getEndpointDefinition().entityName();
+
   eq MTokenReceiveDefinition.updateMethodName() = null;
   eq MTokenReceiveDefinition.writeMethodName() = null;
 
@@ -497,7 +506,7 @@ aspect MustacheTokenComponent {
 
   syn EndpointDefinition TokenComponent.normalTokenSendDef() {
     for (EndpointTarget target : getTokenEndpointTargetList()) {
-      if (target.isTokenEndpointTarget() && target.containingEndpointDefinition().shouldSendValue()) {
+      if (target.isTokenEndpointTarget() && target.containingEndpointDefinition().shouldNotResetValue()) {
         return target.containingEndpointDefinition();
       }
     }
@@ -580,6 +589,12 @@ aspect AttributesForMustache {
     return result;
   }
   abstract MEndpointDefinition EndpointTarget.createMEndpointDefinition(boolean isSend);
+  MEndpointDefinition AttributeEndpointTarget.createMEndpointDefinition(boolean isSend) {
+    if (!isSend) {
+      throw new IllegalArgumentException("AttributeEndpointTarget can only be sent!");
+    }
+    return new MAttributeSendDefinition();
+  }
   MEndpointDefinition TokenEndpointTarget.createMEndpointDefinition(boolean isSend) {
     return isSend ? new MTokenSendDefinition() : new MTokenReceiveDefinition();
   }
diff --git a/ragconnect.base/src/main/jastadd/Intermediate.relast b/ragconnect.base/src/main/jastadd/Intermediate.relast
index 7909750..486158a 100644
--- a/ragconnect.base/src/main/jastadd/Intermediate.relast
+++ b/ragconnect.base/src/main/jastadd/Intermediate.relast
@@ -1,6 +1,7 @@
 abstract MEndpointDefinition ::= InnerMappingDefinition:MInnerMappingDefinition*;
 rel MEndpointDefinition.EndpointDefinition -> EndpointDefinition;
 
+MAttributeSendDefinition : MEndpointDefinition;
 abstract MTokenEndpointDefinition : MEndpointDefinition;
 MTokenReceiveDefinition : MTokenEndpointDefinition;
 MTokenSendDefinition : MTokenEndpointDefinition;
diff --git a/ragconnect.base/src/main/jastadd/IntermediateToYAML.jrag b/ragconnect.base/src/main/jastadd/IntermediateToYAML.jrag
index fb198ce..49c2693 100644
--- a/ragconnect.base/src/main/jastadd/IntermediateToYAML.jrag
+++ b/ragconnect.base/src/main/jastadd/IntermediateToYAML.jrag
@@ -103,7 +103,7 @@ aspect IntermediateToYAML {
       result.put("lastValueGetterCall" , lastValueGetterCall());
       result.put("lastValueSetter" , lastValueSetter());
       result.put("senderName" , senderName());
-      result.put("shouldSendValue" , shouldSendValue());
+      result.put("shouldNotResetValue" , shouldNotResetValue());
       result.put("tokenResetMethodName" , tokenResetMethodName());
       result.put("updateMethodName" , updateMethodName());
       result.put("writeMethodName" , writeMethodName());
diff --git a/ragconnect.base/src/main/jastadd/Mappings.jrag b/ragconnect.base/src/main/jastadd/Mappings.jrag
index 738028b..aa07069 100644
--- a/ragconnect.base/src/main/jastadd/Mappings.jrag
+++ b/ragconnect.base/src/main/jastadd/Mappings.jrag
@@ -191,13 +191,6 @@ aspect Mappings {
 
   // --- suitableReceiveDefaultMapping ---
   syn DefaultMappingDefinition EndpointDefinition.suitableReceiveDefaultMapping() {
-    if (getEndpointTarget().isTypeEndpointTarget()) {
-      try {
-        TypeDecl typeDecl = program().resolveTypeDecl(targetTypeName());
-        return typeIsList() && !getIndexBasedListAccess() ? ragconnect().defaultBytesToListTreeMapping(typeDecl.getName()) : ragconnect().defaultBytesToTreeMapping(typeDecl.getName());
-      } catch (Exception ignore) {
-      }
-    }
     switch (targetTypeName()) {
       case "boolean":
       case "Boolean":
@@ -225,8 +218,7 @@ aspect Mappings {
       default:
         try {
           TypeDecl typeDecl = program().resolveTypeDecl(targetTypeName());
-          // TODO: also support list-types, if list is first type
-          return ragconnect().defaultBytesToTreeMapping(typeDecl.getName());
+          return getEndpointTarget().isTypeEndpointTarget() && typeIsList() && !getIndexBasedListAccess() ? ragconnect().defaultBytesToListTreeMapping(typeDecl.getName()) : ragconnect().defaultBytesToTreeMapping(typeDecl.getName());
         } catch (Exception ignore) {
         }
         System.err.println("Could not find suitable default mapping for " + targetTypeName() + " on " + this);
@@ -236,9 +228,6 @@ aspect Mappings {
 
   // --- suitableSendDefaultMapping ---
   syn DefaultMappingDefinition EndpointDefinition.suitableSendDefaultMapping() {
-    if (getEndpointTarget().isTypeEndpointTarget() && typeIsList() && !getIndexBasedListAccess()) {
-      return ragconnect().defaultListTreeToBytesMapping();
-    }
     switch (targetTypeName()) {
       case "boolean":
       case "Boolean":
@@ -264,6 +253,9 @@ aspect Mappings {
       case "String":
         return ragconnect().defaultStringToBytesMapping();
       default:
+        if (getEndpointTarget().isTypeEndpointTarget() && typeIsList() && !getIndexBasedListAccess()) {
+          return ragconnect().defaultListTreeToBytesMapping();
+        }
         try {
           TypeDecl typeDecl = program().resolveTypeDecl(targetTypeName());
           return ragconnect().defaultTreeToBytesMapping(typeDecl.getName());
@@ -288,6 +280,7 @@ aspect Mappings {
     }
   }
   syn String EndpointTarget.targetTypeName();
+  eq AttributeEndpointTarget.targetTypeName() = getTypeName();
   eq TokenEndpointTarget.targetTypeName() = getToken().effectiveJavaTypeUse().getName();
   eq TypeEndpointTarget.targetTypeName() = getType().getTypeDecl().getName();
   eq ContextFreeTypeEndpointTarget.targetTypeName() = getTypeDecl().getName();
diff --git a/ragconnect.base/src/main/jastadd/Navigation.jrag b/ragconnect.base/src/main/jastadd/Navigation.jrag
index a65ab72..dac1931 100644
--- a/ragconnect.base/src/main/jastadd/Navigation.jrag
+++ b/ragconnect.base/src/main/jastadd/Navigation.jrag
@@ -24,6 +24,12 @@ aspect NewStuff {
   syn boolean EndpointTarget.isUntypedEndpointTarget() = false;
   eq UntypedEndpointTarget.isUntypedEndpointTarget() = true;
 
+  /** Tests if EndpointTarget is a AttributeEndpointTarget.
+  *  @return 'true' if this is a AttributeEndpointTarget, otherwise 'false'
+  */
+  syn boolean EndpointTarget.isAttributeEndpointTarget() = false;
+  eq AttributeEndpointTarget.isAttributeEndpointTarget() = true;
+
   /** casts a EndpointTarget into a TokenEndpointTarget if possible.
    *  @return 'this' cast to a TokenEndpointTarget or 'null'
    */
@@ -51,6 +57,13 @@ aspect NewStuff {
   syn UntypedEndpointTarget EndpointTarget.asUntypedEndpointTarget();
   eq EndpointTarget.asUntypedEndpointTarget() = null;
   eq UntypedEndpointTarget.asUntypedEndpointTarget() = this;
+
+  /** casts a EndpointTarget into a AttributeEndpointTarget if possible.
+   *  @return 'this' cast to a AttributeEndpointTarget or 'null'
+   */
+  syn AttributeEndpointTarget EndpointTarget.asAttributeEndpointTarget();
+  eq EndpointTarget.asAttributeEndpointTarget() = null;
+  eq AttributeEndpointTarget.asAttributeEndpointTarget() = this;
 }
 aspect RagConnectNavigation {
 
diff --git a/ragconnect.base/src/main/jastadd/RagConnect.relast b/ragconnect.base/src/main/jastadd/RagConnect.relast
index 01a0f2f..f6cf0dd 100644
--- a/ragconnect.base/src/main/jastadd/RagConnect.relast
+++ b/ragconnect.base/src/main/jastadd/RagConnect.relast
@@ -13,9 +13,10 @@ TypeEndpointTarget : EndpointTarget;
 rel TypeEndpointTarget.Type <-> TypeComponent.TypeEndpointTarget*;
 ContextFreeTypeEndpointTarget : EndpointTarget;
 rel ContextFreeTypeEndpointTarget.TypeDecl <-> TypeDecl.ContextFreeTypeEndpointTarget*;
-UntypedEndpointTarget : EndpointTarget ::= <TypeName> <ChildName>;  // only used by parser
+UntypedEndpointTarget : EndpointTarget ::= <TypeName> <ChildName> <IsAttribute:boolean>;  // only used by parser
 // to be integrated:
-//AttributeEndpointTarget : EndpointTarget ::= <Name> ;
+AttributeEndpointTarget : EndpointTarget ::= <Name> <TypeName> ;
+rel AttributeEndpointTarget.ParentTypeDecl <-> TypeDecl.AttributeEndpointTarget*;
 //RelationEndpointTarget : EndpointTarget ;
 //rel RelationEndpointTarget.Role <-> Role.RelationEndpointTarget* ;
 
diff --git a/ragconnect.base/src/main/jastadd/Util.jadd b/ragconnect.base/src/main/jastadd/Util.jadd
index 81b820c..31b15ba 100644
--- a/ragconnect.base/src/main/jastadd/Util.jadd
+++ b/ragconnect.base/src/main/jastadd/Util.jadd
@@ -1,5 +1,7 @@
 aspect Util {
   static String ASTNode.capitalize(String s) {
+    if (s == null) return null;
+    if (s.isEmpty()) return "";
     return Character.toUpperCase(s.charAt(0)) + s.substring(1);
   }
   protected T JastAddList.firstChild() { return getChild(0); }
diff --git a/ragconnect.base/src/main/jastadd/parser/ParserRewrites.jrag b/ragconnect.base/src/main/jastadd/parser/ParserRewrites.jrag
index f4a8912..04e8cad 100644
--- a/ragconnect.base/src/main/jastadd/parser/ParserRewrites.jrag
+++ b/ragconnect.base/src/main/jastadd/parser/ParserRewrites.jrag
@@ -23,6 +23,19 @@ aspect ParserRewrites {
       result.setTypeDecl(TypeDecl.createRef(getTypeName()));
       return result;
     }
+
+    when (getIsAttribute())
+    to AttributeEndpointTarget {
+      AttributeEndpointTarget result = new AttributeEndpointTarget();
+      String[] tokens = this.getChildName().split(":");
+      String attributeName = tokens[0];
+      String attributeTypeName = tokens[1];
+      result.copyOtherValuesFrom(this);
+      result.setName(attributeName);
+      result.setTypeName(attributeTypeName);
+      result.setParentTypeDecl(TypeDecl.createRef(getTypeName()));
+      return result;
+    }
   }
 
   syn String UntypedEndpointTarget.combinedName() = getTypeName() + "." + getChildName();
@@ -37,7 +50,7 @@ aspect ParserRewrites {
   eq UntypedEndpointTarget.parentTypeName() = "<untyped.parentTypeName>";
   eq UntypedEndpointTarget.entityName() = "<untyped.entityName>";
   eq UntypedEndpointTarget.isAlreadyDefined() = false;
-  eq UntypedEndpointTarget.entityIsNormalAttribute() = false;
+  eq UntypedEndpointTarget.hasAttributeResetMethod() = false;
   eq UntypedEndpointTarget.targetTypeName() = "<untyped.targetTypeName>";
   eq UntypedEndpointTarget.isTypeEndpointTarget() = false;
 }
diff --git a/ragconnect.base/src/main/jastadd/parser/RagConnect.parser b/ragconnect.base/src/main/jastadd/parser/RagConnect.parser
index 54a388e..f822bf0 100644
--- a/ragconnect.base/src/main/jastadd/parser/RagConnect.parser
+++ b/ragconnect.base/src/main/jastadd/parser/RagConnect.parser
@@ -62,8 +62,10 @@ EndpointDefinition endpoint_definition_type
 ;
 
 EndpointTarget endpoint_target
-  = ID.type_name DOT ID.child_name    {: return new UntypedEndpointTarget(type_name, child_name); :}
-  | ID.type_name                      {: return new UntypedEndpointTarget(type_name, ""); :}
+  = ID.type_name DOT ID.child_name    {: return new UntypedEndpointTarget(type_name, child_name, false); :}
+  | ID.type_name DOT ID.child_name BRACKET_LEFT ID.attribute_type_name BRACKET_RIGHT
+     {: return new UntypedEndpointTarget(type_name, child_name + ":" + attribute_type_name, true); :}
+  | ID.type_name                      {: return new UntypedEndpointTarget(type_name, "", false); :}
 ;
 
 ArrayList string_list
diff --git a/ragconnect.base/src/main/jastadd/scanner/Keywords.flex b/ragconnect.base/src/main/jastadd/scanner/Keywords.flex
index 8a1eaec..cda59f5 100644
--- a/ragconnect.base/src/main/jastadd/scanner/Keywords.flex
+++ b/ragconnect.base/src/main/jastadd/scanner/Keywords.flex
@@ -8,3 +8,5 @@
 "with"       { return sym(Terminals.WITH); }
 "indexed"    { return sym(Terminals.INDEXED); }
 "add"        { return sym(Terminals.ADD); }
+"("          { return sym(Terminals.BRACKET_LEFT); }
+")"          { return sym(Terminals.BRACKET_RIGHT); }
diff --git a/ragconnect.base/src/main/resources/handler.mustache b/ragconnect.base/src/main/resources/handler.mustache
index a7ef597..0d36873 100644
--- a/ragconnect.base/src/main/resources/handler.mustache
+++ b/ragconnect.base/src/main/resources/handler.mustache
@@ -110,6 +110,10 @@ aspect RagConnectHandler {
       senders.forEach(Runnable::run);
     }
 
+    void run(RagConnectToken token) {
+      tokenToSender.get(token).run();
+    }
+
     byte[] getLastValue() {
       return lastValue;
     }
@@ -150,6 +154,10 @@ aspect RagConnectHandler {
       java.util.Optional.ofNullable(publishers.get(index)).ifPresent(RagConnectPublisher::run);
     }
 
+    void run(int index, RagConnectToken token) {
+      java.util.Optional.ofNullable(publishers.get(index)).ifPresent(publisher -> publisher.run(token));
+    }
+
     byte[] getLastValue(int index) {
       RagConnectPublisher publisher = publishers.get(index);
       if (publisher == null) {
diff --git a/ragconnect.base/src/main/resources/ragconnect.mustache b/ragconnect.base/src/main/resources/ragconnect.mustache
index fbe3dd5..dc6b839 100644
--- a/ragconnect.base/src/main/resources/ragconnect.mustache
+++ b/ragconnect.base/src/main/resources/ragconnect.mustache
@@ -87,22 +87,33 @@ aspect RagConnectObserver {
   class RagConnectObserver implements ASTState.Trace.Receiver {
 
     class RagConnectObserverEntry {
-      final RagConnectToken connectToken;
       final ASTNode node;
       final String attributeString;
       final boolean compareParams;
       final Object params;
       final Runnable attributeCall;
+      final java.util.List<RagConnectToken> connectList = new java.util.ArrayList<>();
 
-      RagConnectObserverEntry(RagConnectToken connectToken, ASTNode node, String attributeString,
+      RagConnectObserverEntry(ASTNode node, String attributeString,
                               boolean compareParams, Object params, Runnable attributeCall) {
-        this.connectToken = connectToken;
         this.node = node;
         this.attributeString = attributeString;
         this.compareParams = compareParams;
         this.params = params;
         this.attributeCall = attributeCall;
       }
+
+      boolean baseMembersEqualTo(RagConnectObserverEntry other) {
+        return baseMembersEqualTo(other.node, other.attributeString, other.compareParams, other.params);
+      }
+
+      boolean baseMembersEqualTo(ASTNode otherNode, String otherAttributeString,
+          boolean otherCompareParams, Object otherParams) {
+        return this.node.equals(otherNode) &&
+            this.attributeString.equals(otherAttributeString) &&
+            this.compareParams == otherCompareParams &&
+            (!this.compareParams || java.util.Objects.equals(this.params, otherParams));
+      }
     }
 
 {{#configExperimentalJastAdd329}}
@@ -124,7 +135,7 @@ aspect RagConnectObserver {
 
 {{#configExperimentalJastAdd329}}
     java.util.Set<RagConnectObserverEntry> entryQueue = new java.util.HashSet<>();
-    RagConnectObserverStartEntry startEntry = null;
+    java.util.Deque<RagConnectObserverStartEntry> startEntries = new java.util.LinkedList<>();
 {{/configExperimentalJastAdd329}}
 
     RagConnectObserver(ASTNode node) {
@@ -145,42 +156,73 @@ aspect RagConnectObserver {
       {{#configLoggingEnabledForIncremental}}
       System.out.println("** observer add: " + node + " on " + attributeString + (compareParams ? " (parameterized)" : ""));
       {{/configLoggingEnabledForIncremental}}
-      observedNodes.add(new RagConnectObserverEntry(connectToken, node, attributeString,
-                                                    compareParams, params, attributeCall));
+      // either add to an existing entry (with same node, attribute) or create new entry
+      boolean needNewEntry = true;
+      for (RagConnectObserverEntry entry : observedNodes) {
+        if (entry.baseMembersEqualTo(node, attributeString, compareParams, params)) {
+          entry.connectList.add(connectToken);
+          needNewEntry = false;
+          break;
+        }
+      }
+      if (needNewEntry) {
+        RagConnectObserverEntry newEntry = new RagConnectObserverEntry(node, attributeString,
+            compareParams, params, attributeCall);
+        newEntry.connectList.add(connectToken);
+        observedNodes.add(newEntry);
+      }
     }
 
     void remove(RagConnectToken connectToken) {
-      observedNodes.removeIf(entry -> entry.connectToken.equals(connectToken));
+      RagConnectObserverEntry entryToDelete = null;
+      for (RagConnectObserverEntry entry : observedNodes) {
+        entry.connectList.remove(connectToken);
+        if (entry.connectList.isEmpty()) {
+          entryToDelete = entry;
+        }
+      }
+      if (entryToDelete != null) {
+        observedNodes.remove(entryToDelete);
+      }
     }
+
     @Override
     public void accept(ASTState.Trace.Event event, ASTNode node, String attribute, Object params, Object value) {
       oldReceiver.accept(event, node, attribute, params, value);
 {{#configExperimentalJastAdd329}}
       // react to INC_FLUSH_START and remember entry
-      if (event == ASTState.Trace.Event.INC_FLUSH_START && startEntry == null) {
+      if (event == ASTState.Trace.Event.INC_FLUSH_START) {
         {{#configLoggingEnabledForIncremental}}
         System.out.println("** observer start: " + node + " on " + attribute);
         {{/configLoggingEnabledForIncremental}}
-        startEntry = new RagConnectObserverStartEntry(node, attribute, value);
+        startEntries.addFirst(new RagConnectObserverStartEntry(node, attribute, value));
         return;
       }
 
       // react to INC_FLUSH_END and process queued entries, if it matches start entry
-      if (event == ASTState.Trace.Event.INC_FLUSH_END &&
-            node == startEntry.node &&
+      if (event == ASTState.Trace.Event.INC_FLUSH_END) {
+        if (startEntries.isEmpty()) {
+          {{#configLoggingEnabledForIncremental}}
+          System.out.println("** observer end without start! for " + node + " on " + attribute);
+          {{/configLoggingEnabledForIncremental}}
+          return;
+        }
+        RagConnectObserverStartEntry startEntry = startEntries.peekFirst();
+        if (node == startEntry.node &&
             attribute == startEntry.attributeString &&
             value == startEntry.flushIncToken) {
-        // create a copy of the queue to avoid entering this again causing an endless recursion
-        RagConnectObserverEntry[] entriesToProcess = entryQueue.toArray(new RagConnectObserverEntry[entryQueue.size()]);
-        entryQueue.clear();
-        startEntry = null;
-        {{#configLoggingEnabledForIncremental}}
-        System.out.println("** observer process (entries: " + entriesToProcess.length + "): " + node + " on " + attribute);
-        {{/configLoggingEnabledForIncremental}}
-        for (RagConnectObserverEntry entry : entriesToProcess) {
-          entry.attributeCall.run();
+          // create a copy of the queue to avoid entering this again causing an endless recursion
+          RagConnectObserverEntry[] entriesToProcess = entryQueue.toArray(new RagConnectObserverEntry[entryQueue.size()]);
+          entryQueue.clear();
+          startEntries.removeFirst();
+          {{#configLoggingEnabledForIncremental}}
+          System.out.println("** observer process (entries: " + entriesToProcess.length + "): " + node + " on " + attribute);
+          {{/configLoggingEnabledForIncremental}}
+          for (RagConnectObserverEntry entry : entriesToProcess) {
+            entry.attributeCall.run();
+          }
+          return;
         }
-        return;
       }
 
 {{/configExperimentalJastAdd329}}
diff --git a/ragconnect.base/src/main/resources/sendDefinition.mustache b/ragconnect.base/src/main/resources/sendDefinition.mustache
index 293b37c..18d48ca 100644
--- a/ragconnect.base/src/main/resources/sendDefinition.mustache
+++ b/ragconnect.base/src/main/resources/sendDefinition.mustache
@@ -18,7 +18,7 @@ public boolean {{parentTypeName}}.{{connectMethodName}}(String {{connectParamete
         }{{#IndexBasedListAccess}}, index{{/IndexBasedListAccess}}, connectToken);
       {{updateMethodName}}({{#IndexBasedListAccess}}index{{/IndexBasedListAccess}});
       if (writeCurrentValue) {
-        {{writeMethodName}}({{#IndexBasedListAccess}}index{{/IndexBasedListAccess}});
+        {{writeMethodName}}({{#IndexBasedListAccess}}index, {{/IndexBasedListAccess}}connectToken);
       }
       success = true;
       break;
@@ -97,9 +97,9 @@ public boolean {{parentTypeName}}.{{disconnectMethodName}}(String {{connectParam
 }
 
 protected boolean {{parentTypeName}}.{{updateMethodName}}({{#IndexBasedListAccess}}int index{{/IndexBasedListAccess}}) {
-  {{^shouldSendValue}}
+  {{^shouldNotResetValue}}
   {{tokenResetMethodName}}();
-  {{/shouldSendValue}}
+  {{/shouldNotResetValue}}
   {{> mappingApplication}}
   {{lastValueSetter}}({{#IndexBasedListAccess}}index, {{/IndexBasedListAccess}}{{lastResult}});
   // normally we would return true here. unless no connect method was called so far to initialize {{senderName}} yet
@@ -110,6 +110,10 @@ protected void {{parentTypeName}}.{{writeMethodName}}({{#IndexBasedListAccess}}i
   {{senderName}}.run({{#IndexBasedListAccess}}index{{/IndexBasedListAccess}});
 }
 
+protected void {{parentTypeName}}.{{writeMethodName}}({{#IndexBasedListAccess}}int index, {{/IndexBasedListAccess}}RagConnectToken token) {
+  {{senderName}}.run({{#IndexBasedListAccess}}index, {{/IndexBasedListAccess}}token);
+}
+
 {{#needForwardingNTA}}
 syn {{{forwardingNTA_Type}}} {{parentTypeName}}.{{forwardingNTA_Name}}({{#IndexBasedListAccess}}int index{{/IndexBasedListAccess}}) = {{realGetterMethodCall}}.{{touchedTerminalsMethodName}}();
 {{/needForwardingNTA}}
diff --git a/ragconnect.tests/build.gradle b/ragconnect.tests/build.gradle
index 8d073b5..8bc120a 100644
--- a/ragconnect.tests/build.gradle
+++ b/ragconnect.tests/build.gradle
@@ -370,7 +370,6 @@ task compileTreeIncremental(type: RagConnectTest) {
         inputFiles = [file('src/test/01-input/tree/Test.relast'),
                       file('src/test/01-input/tree/Test.connect')]
         rootNode = 'Root'
-        logWrites = true
     }
     relast {
         useJastAddNames = true
@@ -413,7 +412,6 @@ task compileTreeAllowedTokensIncremental(type: RagConnectTest) {
         inputFiles = [file('src/test/01-input/treeAllowedTokens/Test.relast'),
                       file('src/test/01-input/treeAllowedTokens/Test.connect')]
         rootNode = 'Root'
-        logWrites = true
     }
     relast {
         useJastAddNames = true
@@ -604,9 +602,6 @@ task compileIndexedSendIncremental(type: RagConnectTest, dependsOn: ':ragconnect
         inputFiles = [file('src/test/01-input/indexedSend/Test.relast'),
                       file('src/test/01-input/indexedSend/Test.connect')]
         rootNode = 'Root'
-        logWrites = true
-        logReads = true
-        logIncremental = true
         extraOptions = ['--experimental-jastadd-329']
     }
     relast {
@@ -621,3 +616,25 @@ task compileIndexedSendIncremental(type: RagConnectTest, dependsOn: ':ragconnect
         extraOptions = JASTADD_INCREMENTAL_OPTIONS_TRACING_FULL
     }
 }
+
+// --- Test: attribute-incremental ---
+task compileAttributeIncremental(type: RagConnectTest, dependsOn: ':ragconnect.base:jar') {
+    ragconnect {
+        outputDir = file('src/test/02-after-ragconnect/attributeInc')
+        inputFiles = [file('src/test/01-input/attribute/Test.relast'),
+                      file('src/test/01-input/attribute/Test.connect')]
+        rootNode = 'Root'
+        extraOptions = ['--experimental-jastadd-329']
+    }
+    relast {
+        useJastAddNames = true
+        grammarName = 'src/test/03-after-relast/attributeInc/attributeInc'
+        serializer = 'jackson'
+    }
+    jastadd {
+        jastAddList = 'JastAddList'
+        packageName = 'attributeInc.ast'
+        inputFiles = [file('src/test/01-input/attribute/Test.jadd')]
+        extraOptions = JASTADD_INCREMENTAL_OPTIONS_TRACING_FULL
+    }
+}
diff --git a/ragconnect.tests/src/test/01-input/attribute/README.md b/ragconnect.tests/src/test/01-input/attribute/README.md
new file mode 100644
index 0000000..08481b7
--- /dev/null
+++ b/ragconnect.tests/src/test/01-input/attribute/README.md
@@ -0,0 +1,3 @@
+# Attribute
+
+Idea: Use send definitions for attributes.
diff --git a/ragconnect.tests/src/test/01-input/attribute/Test.connect b/ragconnect.tests/src/test/01-input/attribute/Test.connect
new file mode 100644
index 0000000..03688a9
--- /dev/null
+++ b/ragconnect.tests/src/test/01-input/attribute/Test.connect
@@ -0,0 +1,31 @@
+send SenderRoot.basic(String) ;
+send SenderRoot.simple(String) ;
+send SenderRoot.transformed(int) ;
+send SenderRoot.toReferenceType(A) ;
+send SenderRoot.toNTA(A) ;
+
+AddSuffix maps A a to A {:
+  A result = new A();
+  String changedValue = a.getValue() + "post";
+  result.setValue(changedValue);
+  result.setInner(new Inner("inner" + a.getInner().getInnerValue()));
+  return result;
+:}
+
+AddStringSuffix maps String s to String {:
+  return s + "post";
+:}
+
+AddPlusOne maps int i to int {:
+  return i + 1;
+:}
+
+receive ReceiverRoot.FromBasic;
+receive ReceiverRoot.FromSimpleNoMapping;
+receive ReceiverRoot.FromSimpleWithMapping using AddStringSuffix;
+receive ReceiverRoot.FromTransformedNoMapping;
+receive ReceiverRoot.FromTransformedWithMapping using AddPlusOne;
+receive ReceiverRoot.FromReferenceTypeNoMapping;
+receive ReceiverRoot.FromReferenceTypeWithMapping using AddSuffix;
+receive ReceiverRoot.FromNTANoMapping;
+receive ReceiverRoot.FromNTAWithMapping using AddSuffix;
diff --git a/ragconnect.tests/src/test/01-input/attribute/Test.jadd b/ragconnect.tests/src/test/01-input/attribute/Test.jadd
new file mode 100644
index 0000000..34b1b45
--- /dev/null
+++ b/ragconnect.tests/src/test/01-input/attribute/Test.jadd
@@ -0,0 +1,39 @@
+aspect Computation {
+  syn String SenderRoot.basic() = getInput();
+  syn String SenderRoot.simple() = getInput() + "Post";
+  syn int SenderRoot.transformed() = Integer.parseInt(getInput());
+  syn A SenderRoot.toReferenceType() {
+    A result = new A();
+    result.setValue(getInput());
+    Inner inner = new Inner();
+    inner.setInnerValue("1");
+    result.setInner(inner);
+    return result;
+  }
+  syn nta A SenderRoot.toNTA() {
+    A result = new A();
+    result.setValue(getInput());
+    Inner inner = new Inner();
+    inner.setInnerValue("2");
+    result.setInner(inner);
+    return result;
+  }
+}
+aspect MakeCodeCompile {
+
+}
+aspect MakeCodeWork {
+
+}
+aspect NameResolution {
+  // overriding customID guarantees to produce the same JSON representation for equal lists
+  // otherwise, the value for id is different each time
+  @Override
+  protected String A.customID() {
+    return getClass().getSimpleName() + getValue();
+  }
+  @Override
+  protected String Inner.customID() {
+    return getClass().getSimpleName() + getInnerValue();
+  }
+}
diff --git a/ragconnect.tests/src/test/01-input/attribute/Test.relast b/ragconnect.tests/src/test/01-input/attribute/Test.relast
new file mode 100644
index 0000000..7afc9b7
--- /dev/null
+++ b/ragconnect.tests/src/test/01-input/attribute/Test.relast
@@ -0,0 +1,14 @@
+Root ::= SenderRoot* ReceiverRoot;
+SenderRoot ::= <Input> ;
+ReceiverRoot ::=
+    <FromBasic>
+    <FromSimpleNoMapping>
+    <FromSimpleWithMapping>
+    <FromTransformedNoMapping:int>
+    <FromTransformedWithMapping:int>
+    FromReferenceTypeNoMapping:A
+    FromReferenceTypeWithMapping:A
+    FromNTANoMapping:A
+    FromNTAWithMapping:A ;
+A ::= <Value> Inner ;
+Inner ::= <InnerValue> ;
diff --git a/ragconnect.tests/src/test/01-input/errors/Standard.connect b/ragconnect.tests/src/test/01-input/errors/Standard.connect
index 6224df4..fb2c7cb 100644
--- a/ragconnect.tests/src/test/01-input/errors/Standard.connect
+++ b/ragconnect.tests/src/test/01-input/errors/Standard.connect
@@ -60,6 +60,15 @@ D.SourceDoubledValue canDependOn D.TargetDoubledValue as DoubledValue ;
 D.SourceDoubledValue canDependOn D.TargetDoubledValue as DoubledValue ;
 send D.TargetDoubledValue;
 
+// non-existence of attributes is not checked by RagConnect
+send A.nonExistingAttribute(int);
+
+// Already defined endpoints for attributes will be reported, however
+send A.nonExistingAttribute(int);
+
+// mappings are not checked, here string would not match
+send A.anotherIntAttribute(int) using StringToString;
+
 // --- mapping definitions ---
 ListToList maps java.util.List<String> list to java.util.List<String> {:
   return list;
diff --git a/ragconnect.tests/src/test/01-input/errors/Standard.expected b/ragconnect.tests/src/test/01-input/errors/Standard.expected
index 9b3d23a..1f2b02d 100644
--- a/ragconnect.tests/src/test/01-input/errors/Standard.expected
+++ b/ragconnect.tests/src/test/01-input/errors/Standard.expected
@@ -11,3 +11,5 @@ Standard.connect Line 43, column 1: Endpoint definition already defined for C.Do
 Standard.connect Line 44, column 1: Endpoint definition already defined for C.DoubledValue
 Standard.connect Line 55, column 1: The name of a dependency definition must not be equal to a list-node on the source
 Standard.connect Line 60, column 1: Dependency definition already defined for D with name DoubledValue
+Standard.connect Line 64, column 1: Endpoint definition already defined for A.nonExistingAttribute
+Standard.connect Line 67, column 1: Endpoint definition already defined for A.nonExistingAttribute
diff --git a/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/AttributeTest.java b/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/AttributeTest.java
new file mode 100644
index 0000000..2e7e0c7
--- /dev/null
+++ b/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/AttributeTest.java
@@ -0,0 +1,251 @@
+package org.jastadd.ragconnect.tests;
+
+import attributeInc.ast.*;
+import org.junit.jupiter.api.Tag;
+
+import java.io.IOException;
+import java.util.Objects;
+import java.util.concurrent.Callable;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+import static java.util.function.Predicate.isEqual;
+import static org.jastadd.ragconnect.tests.TestUtils.*;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test case "attribute".
+ *
+ * @author rschoene - Initial contribution
+ */
+@Tag("Incremental")
+public class AttributeTest extends AbstractMqttTest {
+
+  private static final String TOPIC_WILDCARD = "attr/#";
+  private static final String TOPIC_BASIC = "attr/string/basic";
+  private static final String TOPIC_SIMPLE_NO_MAPPING = "attr/string/simple/plain";
+  private static final String TOPIC_SIMPLE_WITH_MAPPING = "attr/string/simple/mapped";
+  private static final String TOPIC_TRANSFORMED_NO_MAPPING = "attr/int/transformed/plain";
+  private static final String TOPIC_TRANSFORMED_WITH_MAPPING = "attr/int/transformed/mapped";
+  private static final String TOPIC_REFERENCE_TYPE_NO_MAPPING = "attr/a/ref/plain";
+  private static final String TOPIC_REFERENCE_TYPE_WITH_MAPPING = "attr/a/ref/mapped";
+  private static final String TOPIC_NTA_NO_MAPPING = "attr/a/nta/plain";
+  private static final String TOPIC_NTA_WITH_MAPPING = "attr/a/nta/mapped";
+
+  private static final String INITIAL_STRING = "initial";
+  private static final String INITIAL_STRING_FOR_INT = "1";
+
+  private MqttHandler handler;
+  private ReceiverData data;
+
+  private Root model;
+  private SenderRoot senderString;
+  private SenderRoot senderInt;
+  private SenderRoot senderA;
+  private ReceiverRoot receiverRoot;
+
+  @Override
+  protected void createModel() {
+    model = new Root();
+//    model.trace().setReceiver(TestUtils::logEvent);
+    senderString = new SenderRoot().setInput(INITIAL_STRING);
+    senderInt = new SenderRoot().setInput(INITIAL_STRING_FOR_INT);
+    senderA = new SenderRoot().setInput(INITIAL_STRING);
+    receiverRoot = new ReceiverRoot();
+    model.addSenderRoot(senderString);
+    model.addSenderRoot(senderInt);
+    model.addSenderRoot(senderA);
+    model.setReceiverRoot(receiverRoot);
+  }
+
+  @Override
+  protected void setupReceiverAndConnect() throws IOException, InterruptedException {
+    model.ragconnectSetupMqttWaitUntilReady(2, TimeUnit.SECONDS);
+    handler = new MqttHandler().setHost(TestUtils.getMqttHost()).dontSendWelcomeMessage();
+    assertTrue(handler.waitUntilReady(2, TimeUnit.SECONDS));
+
+    data = new ReceiverData();
+    assertTrue(handler.newConnection(TOPIC_WILDCARD, bytes -> data.numberOfValues += 1));
+
+    // connect receive
+    assertTrue(receiverRoot.connectFromBasic(mqttUri(TOPIC_BASIC)));
+    assertTrue(receiverRoot.connectFromSimpleNoMapping(mqttUri(TOPIC_SIMPLE_NO_MAPPING)));
+    assertTrue(receiverRoot.connectFromSimpleWithMapping(mqttUri(TOPIC_SIMPLE_WITH_MAPPING)));
+    assertTrue(receiverRoot.connectFromTransformedNoMapping(mqttUri(TOPIC_TRANSFORMED_NO_MAPPING)));
+    assertTrue(receiverRoot.connectFromTransformedWithMapping(mqttUri(TOPIC_TRANSFORMED_WITH_MAPPING)));
+    assertTrue(receiverRoot.connectFromReferenceTypeNoMapping(mqttUri(TOPIC_REFERENCE_TYPE_NO_MAPPING)));
+    assertTrue(receiverRoot.connectFromReferenceTypeWithMapping(mqttUri(TOPIC_REFERENCE_TYPE_WITH_MAPPING)));
+    assertTrue(receiverRoot.connectFromNTANoMapping(mqttUri(TOPIC_NTA_NO_MAPPING)));
+    assertTrue(receiverRoot.connectFromNTAWithMapping(mqttUri(TOPIC_NTA_WITH_MAPPING)));
+
+    // connect send, and wait to receive (if writeCurrentValue is set)
+    assertTrue(senderString.connectBasic(mqttUri(TOPIC_BASIC), isWriteCurrentValue()));
+    assertTrue(senderString.connectSimple(mqttUri(TOPIC_SIMPLE_NO_MAPPING), isWriteCurrentValue()));
+    assertTrue(senderString.connectSimple(mqttUri(TOPIC_SIMPLE_WITH_MAPPING), isWriteCurrentValue()));
+
+    assertTrue(senderInt.connectTransformed(mqttUri(TOPIC_TRANSFORMED_NO_MAPPING), isWriteCurrentValue()));
+    assertTrue(senderInt.connectTransformed(mqttUri(TOPIC_TRANSFORMED_WITH_MAPPING), isWriteCurrentValue()));
+
+    assertTrue(senderA.connectToReferenceType(mqttUri(TOPIC_REFERENCE_TYPE_NO_MAPPING), isWriteCurrentValue()));
+    assertTrue(senderA.connectToReferenceType(mqttUri(TOPIC_REFERENCE_TYPE_WITH_MAPPING), isWriteCurrentValue()));
+    assertTrue(senderA.connectToNTA(mqttUri(TOPIC_NTA_NO_MAPPING), isWriteCurrentValue()));
+    assertTrue(senderA.connectToNTA(mqttUri(TOPIC_NTA_WITH_MAPPING), isWriteCurrentValue()));
+
+    waitForValue(senderString.basic(), receiverRoot::getFromBasic);
+    waitForValue(senderString.simple(), receiverRoot::getFromSimpleNoMapping);
+    waitForValue(senderInt.transformed(), receiverRoot::getFromTransformedNoMapping);
+    waitForNonNull(receiverRoot::getFromReferenceTypeNoMapping);
+    waitForNonNull(receiverRoot::getFromNTANoMapping);
+  }
+
+  private <T> void waitForValue(T expectedValue, Callable<T> callable) {
+    if (isWriteCurrentValue()) {
+      awaitMqtt().until(callable, isEqual(expectedValue));
+    }
+  }
+
+  private <T> void waitForNonNull(Callable<T> callable) {
+    if (isWriteCurrentValue()) {
+      awaitMqtt().until(callable, Predicate.not(isEqual(null)));
+    }
+  }
+
+  @Override
+  protected void communicateSendInitialValue() throws IOException, InterruptedException {
+    // basic, simple(2)    <-- senderString
+    // transformed(2)      <-- senderInt
+    // ref-type(2), nta(2) <-- senderA
+    check(9, INITIAL_STRING, INITIAL_STRING + "Post", INITIAL_STRING_FOR_INT, INITIAL_STRING, INITIAL_STRING);
+
+    senderString.setInput("test-01");
+    check(12, "test-01", "test-01Post", INITIAL_STRING_FOR_INT, INITIAL_STRING, INITIAL_STRING);
+
+    senderString.setInput("test-01");
+    check(12, "test-01", "test-01Post", INITIAL_STRING_FOR_INT, INITIAL_STRING, INITIAL_STRING);
+
+    senderInt.setInput("20");
+    check(14, "test-01", "test-01Post", "20", INITIAL_STRING, INITIAL_STRING);
+
+    senderA.setInput("test-03");
+    check(18, "test-01", "test-01Post", "20", "test-03", "test-03");
+
+    assertTrue(senderString.disconnectSimple(mqttUri(TOPIC_SIMPLE_NO_MAPPING)));
+    assertTrue(senderString.disconnectSimple(mqttUri(TOPIC_SIMPLE_WITH_MAPPING)));
+    senderString.setInput("test-04");
+    check(19, "test-04", "test-01Post", "20", "test-03", "test-03");
+
+    assertTrue(senderA.disconnectToNTA(mqttUri(TOPIC_NTA_NO_MAPPING)));
+    senderA.setInput("test-05");
+    check(22, "test-04", "test-01Post", "20", "test-05", "test-03");
+  }
+
+  @Override
+  protected void communicateOnlyUpdatedValue() throws IOException, InterruptedException {
+    waitForMqtt();
+    // basic, simple(2)    <-- senderString
+    // transformed(2)      <-- senderInt
+    // ref-type(2), nta(2) <-- senderA
+    check(0, null, null, null, null, null);
+
+    senderString.setInput("test-01");
+    check(3, "test-01", "test-01Post", null, null, null);
+
+    senderString.setInput("test-01");
+    check(3, "test-01", "test-01Post", null, null, null);
+
+    senderInt.setInput("20");
+    check(5, "test-01", "test-01Post", "20", null, null);
+
+    senderA.setInput("test-03");
+    check(9, "test-01", "test-01Post", "20", "test-03", "test-03");
+
+    assertTrue(senderString.disconnectSimple(mqttUri(TOPIC_SIMPLE_NO_MAPPING)));
+    assertTrue(senderString.disconnectSimple(mqttUri(TOPIC_SIMPLE_WITH_MAPPING)));
+    senderString.setInput("test-04");
+    check(10, "test-04", "test-01Post", "20", "test-03", "test-03");
+
+    assertTrue(senderA.disconnectToNTA(mqttUri(TOPIC_NTA_NO_MAPPING)));
+    senderA.setInput("test-05");
+    check(13, "test-04", "test-01Post", "20", "test-05", "test-03");
+  }
+
+  private void check(int numberOfValues, String basic, String simple, String transformed,
+                     String a, String ntaNoMapping) {
+    awaitEquals(numberOfValues, () -> data.numberOfValues, "numberOfValues");
+
+    awaitEquals(Objects.requireNonNullElse(basic, ""),
+            receiverRoot::getFromBasic, "basic");
+
+    if (simple != null) {
+      awaitEquals(simple,
+              receiverRoot::getFromSimpleNoMapping, "simple");
+      awaitEquals(simple + "post",
+              receiverRoot::getFromSimpleWithMapping, "simple mapped");
+    } else {
+      awaitEquals("",
+              receiverRoot::getFromSimpleNoMapping, "simple null");
+      awaitEquals("",
+              receiverRoot::getFromSimpleWithMapping, "simple mapped null");
+    }
+
+    if (transformed != null) {
+      awaitEquals(Integer.parseInt(transformed),
+              receiverRoot::getFromTransformedNoMapping, "transformed");
+      awaitEquals(Integer.parseInt(transformed) + 1,
+              receiverRoot::getFromTransformedWithMapping, "transformed mapped");
+    } else {
+      awaitEquals(0,
+              receiverRoot::getFromTransformedNoMapping, "transformed null");
+      awaitEquals(0,
+              receiverRoot::getFromTransformedWithMapping, "transformed mapped null");
+    }
+
+    if (a != null) {
+      awaitA(a, "1",
+              receiverRoot.getFromReferenceTypeNoMapping(), "ref-type");
+      awaitA(a + "post", "inner1",
+              receiverRoot.getFromReferenceTypeWithMapping(), "ref-type mapped");
+      awaitA(a + "post", "inner2",
+              receiverRoot.getFromNTAWithMapping(), "nta mapped");
+    } else {
+      awaitNull(receiverRoot::getFromReferenceTypeNoMapping, "manual ref-type null");
+      awaitNull(receiverRoot::getFromReferenceTypeWithMapping, "ref-type mapped null");
+      awaitNull(receiverRoot::getFromNTAWithMapping, "nta mapped null");
+    }
+
+    if (ntaNoMapping != null) {
+      awaitA(ntaNoMapping, "2",
+              receiverRoot.getFromNTANoMapping(), "nta");
+    } else {
+      awaitNull(receiverRoot::getFromNTANoMapping, "nta null");
+    }
+  }
+
+  private void awaitNull(Supplier<A> actual, String alias) {
+    awaitMqtt().alias(alias).until(() -> actual.get() == null);
+  }
+
+  private <T> void awaitEquals(T expected, Callable<T> actual, String alias) {
+    awaitMqtt().alias(alias).until(actual, isEqual(expected));
+  }
+
+  private void awaitA(String expectedValue, String expectedInner, A actual, String message) {
+    awaitEquals(expectedValue, actual::getValue, message + " value");
+    awaitEquals(expectedInner, actual.getInner()::getInnerValue, message + " inner");
+  }
+
+  @Override
+  protected void closeConnections() {
+    if (handler != null) {
+      handler.close();
+    }
+    if (model != null) {
+      model.ragconnectCloseConnections();
+    }
+  }
+
+  private static class ReceiverData {
+    int numberOfValues = 0;
+  }
+}
diff --git a/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/ForwardingTest.java b/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/ForwardingTest.java
index 64430eb..570c4d0 100644
--- a/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/ForwardingTest.java
+++ b/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/ForwardingTest.java
@@ -1,8 +1,6 @@
 package org.jastadd.ragconnect.tests;
 
 import forwardingInc.ast.*;
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
 import org.assertj.core.groups.Tuple;
 import org.junit.jupiter.api.Tag;
 
@@ -14,9 +12,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.groups.Tuple.tuple;
-import static org.awaitility.Awaitility.await;
-import static org.jastadd.ragconnect.tests.TestUtils.mqttUri;
-import static org.jastadd.ragconnect.tests.TestUtils.waitForMqtt;
+import static org.jastadd.ragconnect.tests.TestUtils.*;
 import static org.junit.jupiter.api.Assertions.*;
 
 /**
@@ -183,7 +179,7 @@ public class ForwardingTest extends AbstractMqttTest {
 
   private void waitForValue() {
     if (isWriteCurrentValue()) {
-      await().until(() -> data.valueSentSinceLastCheck.getAndSet(false));
+      awaitMqtt().until(() -> data.valueSentSinceLastCheck.getAndSet(false));
     }
   }
 
@@ -524,9 +520,7 @@ public class ForwardingTest extends AbstractMqttTest {
     if (model != null) {
       model.ragconnectCloseConnections();
     }
-    if (observer != null) {
-      observer.init();
-    }
+    observer.init();
   }
 
   static class Values<T> {
diff --git a/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/IndexedSendTest.java b/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/IndexedSendTest.java
index a4575fe..75d7d16 100644
--- a/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/IndexedSendTest.java
+++ b/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/IndexedSendTest.java
@@ -11,9 +11,7 @@ import java.util.concurrent.TimeUnit;
 import java.util.function.Predicate;
 
 import static org.assertj.core.groups.Tuple.tuple;
-import static org.awaitility.Awaitility.await;
-import static org.jastadd.ragconnect.tests.TestUtils.mqttUri;
-import static org.jastadd.ragconnect.tests.TestUtils.waitForMqtt;
+import static org.jastadd.ragconnect.tests.TestUtils.*;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -99,7 +97,7 @@ public class IndexedSendTest extends AbstractMqttTest {
 
   private void waitForValue(Callable<Integer> callable, int expectedValue) {
     if (isWriteCurrentValue()) {
-      await().until(callable, Predicate.isEqual(expectedValue));
+      awaitMqtt().until(callable, Predicate.isEqual(expectedValue));
     }
   }
 
diff --git a/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/TestUtils.java b/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/TestUtils.java
index 937c930..9a6c22b 100644
--- a/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/TestUtils.java
+++ b/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/TestUtils.java
@@ -5,6 +5,8 @@ import com.fasterxml.jackson.core.JsonFactory;
 import com.fasterxml.jackson.core.JsonGenerator;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
+import org.awaitility.Awaitility;
+import org.awaitility.core.ConditionFactory;
 import org.jastadd.ragconnect.compiler.Compiler;
 import org.junit.jupiter.api.Assertions;
 
@@ -24,7 +26,6 @@ import java.util.stream.Collectors;
 import static java.util.Collections.addAll;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.util.Lists.newArrayList;
-import static org.awaitility.Awaitility.await;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 
@@ -145,6 +146,10 @@ public class TestUtils {
     TimeUnit.MILLISECONDS.sleep(1500);
   }
 
+  public static ConditionFactory awaitMqtt() {
+    return Awaitility.await().atMost(1500, TimeUnit.MILLISECONDS);
+  }
+
   static <T_Event, T_ASTNode> void logEvent(T_Event event, T_ASTNode node, String attribute, Object params, Object value) {
     logger.info("event: {}, node: {}, attribute: {}, params: {}, value: {}",
             event, node, attribute, params, value);
@@ -388,7 +393,7 @@ public class TestUtils {
     }
 
     void awaitChange() {
-      await().until(hasChanged);
+      awaitMqtt().until(hasChanged);
       updatePrevious();
     }
 
diff --git a/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/singleList/AbstractSingleListTest.java b/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/singleList/AbstractSingleListTest.java
index 832ba76..cdcb41c 100644
--- a/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/singleList/AbstractSingleListTest.java
+++ b/ragconnect.tests/src/test/java/org/jastadd/ragconnect/tests/singleList/AbstractSingleListTest.java
@@ -14,10 +14,8 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.Function;
 
-import static org.awaitility.Awaitility.await;
+import static org.jastadd.ragconnect.tests.TestUtils.*;
 import static org.jastadd.ragconnect.tests.TestUtils.IntList.list;
-import static org.jastadd.ragconnect.tests.TestUtils.mqttUri;
-import static org.jastadd.ragconnect.tests.TestUtils.testJaddContainReferenceToJackson;
 import static org.junit.jupiter.api.Assertions.*;
 
 /**
@@ -167,7 +165,7 @@ public abstract class AbstractSingleListTest extends AbstractMqttTest {
 
   private void waitForValue() {
     if (isWriteCurrentValue()) {
-      await().until(() -> data.valueSentSinceLastCheck.getAndSet(false));
+      awaitMqtt().until(() -> data.valueSentSinceLastCheck.getAndSet(false));
     }
   }
 
-- 
GitLab