diff --git a/FEHLERANALYSE.md b/FEHLERANALYSE.md
index 04c2aa5..f201a0d 100644
--- a/FEHLERANALYSE.md
+++ b/FEHLERANALYSE.md
@@ -15,6 +15,10 @@ die bei jedem Push in der GitHub-Actions-Pipeline (`.github/workflows/ci.yml`, `
| Crash-/Robustheitsfehler (Abschnitt 3 + 4.1) | "Fix crash bugs and robustness issues" | `RobustnessTest` |
| LONG-NAME-Heuristik (4.2) → konfigurierbare Strategie | "Make spec object type classification pluggable" | `TypeClassifierTest` |
| Attributbasierte Klassifizierung (Implementor-Guide-Profil) | "Add attribute-based ReqIF Implementation Guide classifier" | `ImplementationGuideClassifierTest` |
+| `SpecRelation`-Typsemantik + Relationsattribute (4.3) | "Fix SpecRelation type semantics and parse relation attributes" | `SpecRelationTest` |
+| Tabellen-/Listen-Deconstruction (4.7) | "Derive XHTML token list from the node tree" | `XHTMLDeconstructionTest` |
+| XHTML-Ausgabe: Escaping, Attribute, Inhaltsverlust (4.8) | "Fix XHTML rendering" | `XHTMLRenderingTest` |
+| Code-Qualität (Abschnitt 5) | "Clean up code quality issues" | `CodeQualityFixesTest` |
Zur Heuristik (4.2): Die Klassifizierung ist jetzt eine Strategie (`TypeClassifier`),
die das fertig geparste `SpecObject` (inkl. Attributwerte) erhält.
@@ -30,10 +34,55 @@ Hinweis: `REQ`/`SUB-REQ`/`HEADLINE`/`TEXT` sind **keine** offiziellen ReqIF-Type
sondern parserinterne Inhaltskategorien. Der OMG-Standard definiert nur strukturelle
Typen (`SPEC-OBJECT-TYPE` usw.) mit frei vergebenen Namen/Attributen.
-Bewusst (noch) nicht angefasst, da Verhaltensänderungen für bestehende Nutzer:
-die `type`-Semantik von `SpecRelation` (4.3),
-HTML-Escaping/Attribut-Erhalt in `toString()` (4.8) sowie die kosmetischen Punkte aus Abschnitt 5
-(bis auf den entfernten `javax.xml.crypto.Data`-Import).
+Zu `SpecRelation` (4.3): Das geerbte Feld `type` trägt eine Inhaltskategorie und wurde
+mit der Relationstyp-Referenz überschrieben. Es enthält jetzt `UNDEFINED` (eine Relation
+hat keine Inhaltskategorie); die Referenz liegt in `getRelationTypeRef()`, der aufgelöste
+Name in `getRelationTypeName()`, die strukturelle Information weiterhin in `getSpecType()`
+(`SPEC-RELATION-TYPE`). Die Attributwerte der Relation werden jetzt geparst.
+**Breaking Change:** `getType()` liefert für Relationen `"UNDEFINED"` statt der Referenz-ID.
+
+Zur Deconstruction (4.7): Die Token-Liste wird jetzt aus dem bereits geparsten
+Knotenbaum erzeugt statt ein zweites Mal aus dem rohen DOM. Damit entfällt die
+doppelte Parse-Logik, und die Liste erbt die Namespace-Korrektheit des Baums.
+Behoben: Zellen mit mehreren Kindelementen verlieren keinen Inhalt mehr (der letzte
+`item(1)`-Fehler), Kopfzellen werden als `TH` ausgewiesen, Bilder in Zellen bleiben
+erhalten, `L`/`/L`-Marker sind ausbalanciert, `ol`-Listen werden nicht mehr ignoriert.
+Die Token-Grammatik ist im Javadoc von `AttributeValueXHTML` dokumentiert.
+
+Zur XHTML-Ausgabe (4.8): Der Punkt war in der Erstanalyse als reine Ausgabetreue
+beschrieben — tatsächlich lag **Inhaltsverlust** vor. Behoben:
+1. Text und Attributwerte werden escaped; die Ausgabe war zuvor **nicht wohlgeformt**
+ (aus `<` wurde `<`) und damit weder erneut parsebar noch sicher einbettbar.
+2. Alle XML-Attribute bleiben erhalten (`style`, `colspan`, `href` …); Namespace-
+ Deklarationen werden ausgelassen, da Tag-Namen ohne Präfix ausgegeben werden.
+3. Void-Elemente werden selbstschließend gerendert (`
` statt `
`,
+ das HTML5-Parser als zwei Umbrüche lesen).
+4. Elemente ohne eigene Klasse (`a`, `em`, `strong`, `b`, `i`, …) verloren zuvor
+ ihren **kompletten Inhalt**, weil der `default`-Zweig ein blattartiges `XHTMLNode`
+ erzeugte; sie werden jetzt als `XHTMLElement` mit Kindern geparst.
+5. Inline-Abstände bleiben erhalten (vorher lief „Siehe Link" zu „SieheLink"
+ zusammen); `getTextContent()` liefert weiterhin den getrimmten Text.
+
+**Breaking Change:** `getValue()` liefert für XHTML-Attribute einen anderen (korrekten)
+String als zuvor. Baum (`getDivValue()`) und Token-Liste sind nicht betroffen.
+
+Zu Abschnitt 5: alle Punkte erledigt.
+- 5.1 Feld-Verschattung in `DatatypeBoolean`/`DatatypeXHTML` entfernt (die Getter der
+ Basisklasse liefern dieselben Werte).
+- 5.2 unbenutzter `javax.xml.crypto.Data`-Import entfernt.
+- 5.3 `ExceptionSpecObject`: lesbare Meldung mit Zeilenumbrüchen, Datentyp als Name
+ statt Objekt-Dump, verträgt eine fehlende Definition.
+- 5.4 durch das Java-17-Target gegenstandslos.
+- 5.5 `AttributeValueDate` parst den Wert zusätzlich nach `OffsetDateTime`
+ (`getDateTime()`, `getDate()`); `getValue()` liefert unverändert den Rohstring,
+ unparsbare Werte ergeben `null` statt einer Exception.
+- 5.6 `_Template`-Hack entfernt: `getTitle()` liefert den Titel wie im Dokument.
+ **Breaking Change** für Nutzer, die sich auf das Abschneiden verlassen haben.
+- 5.7 auskommentierter Code entfernt; `getComment()` ist reaktiviert (der `COMMENT`
+ wurde ohnehin gelesen). Die Tippfehler in den Methodennamen sind mit der
+ Neuimplementierung der Deconstruction entfallen.
+
+Damit sind alle Punkte der Analyse abgearbeitet.
Die ursprüngliche Analyse folgt unverändert. Datei- und Zeilenangaben beziehen sich auf den
Stand **vor** den Fixes (Quellen liegen inzwischen unter `src/main/java/`).
diff --git a/README.md b/README.md
index c9c30ff..6df054a 100644
--- a/README.md
+++ b/README.md
@@ -101,3 +101,136 @@ TypeClassifier classifier = new TypeClassifier() {
ReqIF reqif = new ReqIF("spec.reqif", classifier); // .reqif
ReqIFz reqifz = new ReqIFz("archive.reqifz", classifier); // .reqifz
```
+
+# Spec relations
+`SpecRelation` extends `SpecObject`, but a relation has no *content*
+category, so the inherited `getType()` returns `ReqIFConst.UNDEFINED`.
+The relation's own type and its endpoints are exposed separately:
+
+```java
+SpecRelation rel = reqif.getReqIFCoreContent().getSpecRelation("sr-1");
+
+rel.getSourceObjID(); // "so-1"
+rel.getTargetObjID(); // "so-2"
+rel.getRelationTypeRef(); // "st-rel" (SPEC-RELATION-TYPE-REF)
+rel.getRelationTypeName(); // "satisfies" (resolved LONG-NAME)
+rel.getSpecType(); // "SPEC-RELATION-TYPE"
+rel.getAttribute("LinkComment"); // relation attribute values are parsed
+```
+
+`isReq()`, `isSubReq()`, `isHeadline()` and `isText()` all return `false`
+for relations.
+
+# XHTML content
+XHTML attribute values are available in two representations that are always
+in sync — the token list is derived from the node tree:
+
+```java
+AttributeValueXHTML desc = (AttributeValueXHTML) specObject.getAttributes().get("Description");
+
+desc.getDivValue(); // typed node tree (XHTMLElementTbl, XHTMLElementTh, ...)
+desc.getElementList(); // flat token list
+desc.getValue(); // rendered XHTML string
+```
+
+Token grammar of `getElementList()`:
+
+| element type | content tokens |
+|---|---|
+| `P` / `H` | `TXT` text \| `VAR` name [guid] \| `BR` \| `OBJ` path |
+| `TBL` | `TR` ( (`TH`\|`TC`) text (`OBJ` path)* )* |
+| `L` | `LE` ( inline \| `L` … `/L` \| `TBL` … )* |
+| `OBJ` | path |
+
+Header cells are reported as `TH`, data cells as `TC`; the cell text holds
+the complete cell content and images inside a cell follow as `OBJ` pairs.
+Nested lists use balanced `L` / `/L` markers. Ordered lists (`ol`) are
+treated like unordered ones.
+
+## Rendering (`getValue()`)
+
+`getValue()` returns the XHTML as a string. Text and attribute values are
+escaped, so the result is well-formed and can be parsed again or embedded
+safely. All XML attributes are preserved, void elements are self-closing,
+and elements without a dedicated node class (`a`, `em`, `strong`, …) keep
+their content:
+
+```html
+
The system shall boot.
")) + .specObject("so-2", "st-req") + .specRelation("sr-1", "st-rel", "so-1", "so-2") + .specification("spec-1", "Main Spec", "st-spec", s -> s + .child("sh-1", "so-1", c -> c.child("sh-2", "so-2"))) + .build(); + +new ReqIFWriter().write(document, Path.of("out.reqif")); +``` + +XHTML values may be passed with or without a surrounding `div`; the +markup is parsed, so the node tree and the token list work on generated +values too. The content category of generated spec objects is derived by +the same `TypeClassifier` used when reading (`typeClassifier(...)` to +override). + +Not covered yet: writing .reqifz archives and ReqIF elements the parser +does not model (alternative ids, relation groups, tool extensions). diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java index 433f586..32c10c3 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java @@ -40,6 +40,18 @@ public String getDefaultValue() { + /** + * Creates an attribute definition from plain values, for documents that are + * generated instead of parsed. + */ + public AttributeDefinition(String id, String name, Datatype type, String defaultValue) { + + this.id = id; + this.name = name; + this.type = type; + this.defaultValue = defaultValue; + } + public AttributeDefinition(Node attributeDefinition, Map
+ * element type content tokens
+ * ------------ ----------------------------------------------------------
+ * "P" / "H" "TXT" text | "VAR" name [guid] | "BR" | "OBJ" path
+ * "TBL" "TR" ( ("TH"|"TC") text ("OBJ" path)* )*
+ * "L" "LE" ( inline | "L" ... "/L" | "TBL" ... )*
+ * "OBJ" path
+ *
+ *
+ * Header cells are reported as {@code "TH"}, data cells as {@code "TC"}; the
+ * cell text contains the complete cell content, and images inside a cell are
+ * appended as {@code "OBJ"} pairs. Nested lists are wrapped in balanced
+ * {@code "L"} / {@code "/L"} markers. Ordered lists ({@code ol}) are treated
+ * like unordered ones.
+ */
public class AttributeValueXHTML extends AttributeValue {
+ // element type tokens
+ private static final String T_PARAGRAPH = "P";
+ private static final String T_TABLE = "TBL";
+ private static final String T_LIST = "L";
+ private static final String T_HEADING = "H";
+ private static final String T_OBJECT = "OBJ";
+
+ // content tokens
+ private static final String T_TEXT = "TXT";
+ private static final String T_VAR = "VAR";
+ private static final String T_BREAK = "BR";
+ private static final String T_ROW = "TR";
+ private static final String T_HEADER_CELL = "TH";
+ private static final String T_CELL = "TC";
+ private static final String T_LIST_ENTRY = "LE";
+ private static final String T_LIST_END = "/L";
+
+ private static final String VARIABLE_NAME_MISSING = "VARIABLE_NAME_MISSING";
+ private static final String XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
+
XHTMLElementDiv divValue;
@Override
@@ -29,15 +82,40 @@ public AttributeValueXHTML(Node xhtmlContent, AttributeDefinition type) {
// so it has to be located by local name.
Node div = XmlUtils.firstDescendantByLocalName(xhtmlContent, XHTML.DIV);
this.divValue = div == null ? null : new XHTMLElementDiv(div);
- this.value = deconstructXHTML(xhtmlContent);
+ this.value = deconstructXHTML(this.divValue);
}
-
+
+ /**
+ * Creates an XHTML value from markup, for documents that are generated
+ * instead of parsed. The markup is parsed so the node tree and the token
+ * list are available just like for a parsed value; it may be given with or
+ * without a surrounding div.
+ */
public AttributeValueXHTML(String value, AttributeDefinition type) {
super(value, type);
-
-
+
+ Node div = value == null || value.isBlank() ? null : parseDiv(value);
+ this.divValue = div == null ? null : new XHTMLElementDiv(div);
+ this.value = deconstructXHTML(this.divValue);
}
-
+
+ private static Node parseDiv(String markup) {
+
+ String trimmed = markup.trim();
+ String document = trimmed.startsWith("
+ * ReqIFDocument document = ReqIFBuilder.create()
+ * .header(h -> h.id("hdr-1").title("My Spec").toolID("reqif4j"))
+ * .stringDatatype("dt-string", "String", 4096)
+ * .xhtmlDatatype("dt-xhtml", "XHTML")
+ * .specObjectType("st-req", "Requirement Type", t -> t
+ * .stringAttribute("ad-title", "ReqIF.Name", "dt-string")
+ * .xhtmlAttribute("ad-text", "ReqIF.Text", "dt-xhtml"))
+ * .specificationType("st-spec", "Specification Type", t -> {})
+ * .specObject("so-1", "st-req", o -> o
+ * .set("ad-title", "First requirement")
+ * .setXhtml("ad-text", "<p>The system shall boot.</p>"))
+ * .specification("spec-1", "Main Spec", "st-spec", s -> s
+ * .child("sh-1", "so-1"))
+ * .build();
+ *
+ * new ReqIFWriter().write(document, Path.of("out.reqif"));
+ *
+ *
+ * Identifiers are validated while building: referencing an unknown datatype,
+ * spec type or spec object fails immediately with a
+ * {@link ReqIFBuildException} instead of producing a broken document.
+ */
+public class ReqIFBuilder {
+
+ private final Map
+ * ReqIF reqif = new ReqIF("in.reqif");
+ * new ReqIFWriter().write(reqif.getReqIFDocument(), Path.of("out.reqif"));
+ *
+ *
+ * Attribute values are emitted sorted by their definition id so the output is
+ * reproducible. XHTML values keep their original nodes when the document was
+ * read from a file, otherwise the rendered markup is parsed back in.
+ */
+public class ReqIFWriter {
+
+ public static final String REQIF_NAMESPACE = "http://www.omg.org/spec/ReqIF/20110401/reqif.xsd";
+ public static final String XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
+
+ private static final String XHTML_PREFIX = "xhtml";
+
+ private boolean indent = false;
+
+ /**
+ * Enables pretty-printing.
+ *
+ * Off by default on purpose: the indenter inserts whitespace into mixed
+ * content, which changes XHTML attribute values (a paragraph would gain
+ * leading and trailing blanks). Only switch it on when readability of the
+ * output matters more than exact XHTML content.
+ */
+ public ReqIFWriter setIndent(boolean indent) {
+ this.indent = indent;
+ return this;
+ }
+
+
+ /**
+ * Writes the document to the given file.
+ */
+ public void write(ReqIFDocument document, Path file) throws IOException {
+ try (OutputStream out = Files.newOutputStream(file)) {
+ write(document, out);
+ }
+ }
+
+ /**
+ * Writes the document to the given stream. The stream is not closed.
+ */
+ public void write(ReqIFDocument document, OutputStream out) throws IOException {
+ try {
+ transformer().transform(new DOMSource(buildDocument(document)), new StreamResult(out));
+ } catch (TransformerException e) {
+ throw new IOException("Failed to write ReqIF document", e);
+ }
+ }
+
+ /**
+ * @return the document serialized as an XML string
+ */
+ public String toXml(ReqIFDocument document) {
+ try {
+ StringWriter writer = new StringWriter();
+ transformer().transform(new DOMSource(buildDocument(document)), new StreamResult(writer));
+ return writer.toString();
+ } catch (TransformerException e) {
+ throw new ReqIFWriteException("Failed to serialize ReqIF document", e);
+ }
+ }
+
+
+ /**
+ * Builds the output DOM from the object model.
+ */
+ public Document buildDocument(ReqIFDocument document) {
+
+ if (document == null) {
+ throw new ReqIFWriteException("Cannot write a null ReqIF document");
+ }
+
+ Document xml = newDocument();
+ Element root = xml.createElementNS(REQIF_NAMESPACE, "REQ-IF");
+ root.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:" + XHTML_PREFIX, XHTML_NAMESPACE);
+ xml.appendChild(root);
+
+ if (document.getHeader() != null) {
+ root.appendChild(header(xml, document.getHeader()));
+ }
+ root.appendChild(coreContent(xml, document.getCoreContent()));
+
+ return xml;
+ }
+
+
+ private Element header(Document xml, ReqIFHeader header) {
+
+ Element theHeader = element(xml, ReqIFConst.THE_HEADER);
+ Element reqifHeader = element(xml, ReqIFConst.REQ_IF_HEADER);
+ reqifHeader.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(header.getID()));
+ theHeader.appendChild(reqifHeader);
+
+ appendTextIfPresent(xml, reqifHeader, ReqIFConst.COMMENT, header.getComment());
+ appendTextIfPresent(xml, reqifHeader, ReqIFConst.CREATION_TIME, header.getCreationTime());
+ appendTextIfPresent(xml, reqifHeader, ReqIFConst.REQ_IF_TOOL_ID, header.getToolID());
+ appendTextIfPresent(xml, reqifHeader, ReqIFConst.REQ_IF_VERSION, header.getReqIFVersion());
+ appendTextIfPresent(xml, reqifHeader, ReqIFConst.SOURCE_TOOL_ID, header.getSourceToolID());
+ appendTextIfPresent(xml, reqifHeader, ReqIFConst.TITLE, header.getTitle());
+
+ return theHeader;
+ }
+
+ private Element coreContent(Document xml, ReqIFCoreContent content) {
+
+ Element coreContent = element(xml, ReqIFConst.CORE_CONTENT);
+ Element reqifContent = element(xml, "REQ-IF-CONTENT");
+ coreContent.appendChild(reqifContent);
+
+ if (content == null) {
+ return coreContent;
+ }
+
+ Element datatypes = element(xml, ReqIFConst.DATATYPES);
+ for (Datatype datatype : content.getDatatypes().values()) {
+ Element written = datatype(xml, datatype);
+ if (written != null) {
+ datatypes.appendChild(written);
+ }
+ }
+ reqifContent.appendChild(datatypes);
+
+ Element specTypes = element(xml, ReqIFConst.SPEC_TYPES);
+ for (SpecType specType : content.getSpecTypes().values()) {
+ Element written = specType(xml, specType);
+ if (written != null) {
+ specTypes.appendChild(written);
+ }
+ }
+ reqifContent.appendChild(specTypes);
+
+ Element specObjects = element(xml, ReqIFConst.SPEC_OBJECTS);
+ for (SpecObject specObject : content.getSpecObjects().values()) {
+ specObjects.appendChild(specObject(xml, specObject));
+ }
+ reqifContent.appendChild(specObjects);
+
+ Element specRelations = element(xml, ReqIFConst.SPEC_RELATIONS);
+ for (SpecRelation specRelation : content.getSpecRelation().values()) {
+ specRelations.appendChild(specRelation(xml, specRelation));
+ }
+ reqifContent.appendChild(specRelations);
+
+ Element specifications = element(xml, ReqIFConst.SPECIFICATIONS);
+ for (Specification specification : content.getSpecifications().values()) {
+ specifications.appendChild(specification(xml, specification));
+ }
+ reqifContent.appendChild(specifications);
+
+ return coreContent;
+ }
+
+
+ private Element datatype(Document xml, Datatype datatype) {
+
+ String elementName = ReqIFElements.datatypeDefinition(datatype.getType());
+ if (elementName == null) {
+ // datatype kinds the parser does not model are written back under
+ // their original element name
+ elementName = datatype.getSourceElementName();
+ }
+ if (elementName == null) {
+ return null;
+ }
+
+ Element definition = element(xml, elementName);
+ definition.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(datatype.getID()));
+ definition.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(datatype.getName()));
+
+ if (datatype instanceof DatatypeInteger) {
+ DatatypeInteger integer = (DatatypeInteger) datatype;
+ definition.setAttribute(ReqIFConst.MIN, Long.toString(integer.getMin()));
+ definition.setAttribute(ReqIFConst.MAX, Long.toString(integer.getMax()));
+
+ } else if (datatype instanceof DatatypeString) {
+ definition.setAttribute(ReqIFConst.MAX_LENGTH,
+ Integer.toString(((DatatypeString) datatype).getMaxLength()));
+
+ } else if (datatype instanceof DatatypeEnumeration) {
+ Element specifiedValues = element(xml, ReqIFConst.SPECIFIED_VALUES);
+ for (DatatypeEnumerationValue value : ((DatatypeEnumeration) datatype).getEnumValues().values()) {
+ specifiedValues.appendChild(enumValue(xml, value));
+ }
+ definition.appendChild(specifiedValues);
+ }
+
+ return definition;
+ }
+
+ private Element enumValue(Document xml, DatatypeEnumerationValue value) {
+
+ Element enumValue = element(xml, ReqIFConst.ENUM_VALUE);
+ enumValue.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(value.getID()));
+ enumValue.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(value.getName()));
+
+ Element embedded = element(xml, ReqIFConst.EMBEDDED_VALUE);
+ embedded.setAttribute(ReqIFConst.KEY, nullToEmpty(value.getKey()));
+ if (value.getOtherContent() != null && !value.getOtherContent().isEmpty()) {
+ embedded.setAttribute(ReqIFConst.OTHER_CONTENT, value.getOtherContent());
+ }
+
+ Element properties = element(xml, ReqIFConst.PROPERTIES);
+ properties.appendChild(embedded);
+ enumValue.appendChild(properties);
+
+ return enumValue;
+ }
+
+
+ private Element specType(Document xml, SpecType specType) {
+
+ String elementName = specType.getType();
+ if (elementName == null || ReqIFConst.UNDEFINED.equals(elementName)) {
+ elementName = ReqIFConst.SPEC_OBJECT_TYPE;
+ }
+
+ Element type = element(xml, elementName);
+ type.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(specType.getID()));
+ type.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(specType.getName()));
+
+ Element specAttributes = element(xml, ReqIFConst.SPEC_ATTRIBUTES);
+ for (AttributeDefinition definition : specType.getAttributeDefinitions().values()) {
+ Element written = attributeDefinition(xml, definition);
+ if (written != null) {
+ specAttributes.appendChild(written);
+ }
+ }
+ type.appendChild(specAttributes);
+
+ return type;
+ }
+
+ private Element attributeDefinition(Document xml, AttributeDefinition definition) {
+
+ Datatype datatype = definition.getDataType();
+ if (datatype == null) {
+ return null;
+ }
+ String elementName = ReqIFElements.attributeDefinition(datatype.getType());
+ if (elementName == null) {
+ return null;
+ }
+
+ Element attributeDefinition = element(xml, elementName);
+ attributeDefinition.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(definition.getID()));
+ attributeDefinition.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(definition.getName()));
+
+ if (definition instanceof AttributeDefinitionEnumeration
+ && ((AttributeDefinitionEnumeration) definition).isMultiValued()) {
+ attributeDefinition.setAttribute(ReqIFConst.MULTI_VALUED, "true");
+ }
+
+ Element type = element(xml, ReqIFConst.TYPE);
+ Element datatypeRef = element(xml, ReqIFElements.datatypeDefinitionRef(
+ ReqIFElements.datatypeDefinition(datatype.getType())));
+ datatypeRef.setTextContent(nullToEmpty(datatype.getID()));
+ type.appendChild(datatypeRef);
+ attributeDefinition.appendChild(type);
+
+ Element defaultValue = defaultValue(xml, definition, datatype);
+ if (defaultValue != null) {
+ attributeDefinition.appendChild(defaultValue);
+ }
+
+ return attributeDefinition;
+ }
+
+ private Element defaultValue(Document xml, AttributeDefinition definition, Datatype datatype) {
+
+ if (definition instanceof AttributeDefinitionEnumeration) {
+ ListThe system shall boot within 5 seconds.
")) + .specObject("so-2", "st-req", o -> o + .set("ad-title", "Second requirement")) + .specRelation("sr-1", "st-rel", "so-1", "so-2", r -> r + .set("ad-comment", "derived during review")) + .specification("spec-1", "Main Spec", "st-spec", s -> s + .set("ad-owner", "Tester") + .child("sh-1", "so-1", c -> c + .child("sh-2", "so-2"))) + .build(); + } + + @BeforeEach + void buildWriteAndRead(@TempDir Path tempDir) throws Exception { + this.tempDir = tempDir; + Path file = tempDir.resolve("generated.reqif"); + new ReqIFWriter().write(buildDocument(), file); + this.written = new ReqIF(file.toString()); + } + + + @Test + void generatedDocumentIsReadableAgain() { + assertNotNull(written.getReqIFHeader()); + assertNotNull(written.getReqIFCoreContent()); + } + + @Test + void headerIsWrittenFromTheBuilder() { + assertEquals("hdr-1", written.getReqIFHeader().getID()); + assertEquals("Generated Spec", written.getReqIFHeader().getTitle()); + assertEquals("reqif4j", written.getReqIFHeader().getToolID()); + assertEquals("unit test", written.getReqIFHeader().getSourceToolID()); + assertEquals("2026-07-23T10:00:00Z", written.getReqIFHeader().getCreationTime()); + assertEquals("23.07.2026", written.getReqIFHeader().getCreationDate(), + "the formatted date is derived like when parsing"); + assertEquals("reqif4j", written.getReqIFHeader().getAuthor(), + "the author is derived from the comment like when parsing"); + } + + @Test + void datatypesAreWritten() { + assertEquals(List.of("dt-string", "dt-int", "dt-bool", "dt-date", "dt-real", "dt-xhtml", "dt-enum"), + List.copyOf(written.getReqIFCoreContent().getDatatypes().keySet())); + + DatatypeEnumeration colors = (DatatypeEnumeration) written.getReqIFCoreContent().getDatatype("dt-enum"); + assertEquals("Red", colors.getEnumValueName("ev-red")); + assertEquals("#ff0000", colors.getEnumValueOtherContent("ev-red")); + } + + @Test + void scalarValuesOfEveryKindSurvive() { + SpecObject so1 = written.getReqIFCoreContent().getSpecObject("so-1"); + + assertEquals("First requirement", so1.getAttribute("ReqIF.Name")); + assertEquals(5, so1.getAttribute("Priority")); + assertEquals(true, so1.getAttribute("Done")); + assertEquals("2026-12-31", so1.getAttribute("Due")); + assertEquals(2.5, so1.getAttribute("Effort")); + } + + @Test + void multiselectEnumValuesSurvive() { + SpecObject so1 = written.getReqIFCoreContent().getSpecObject("so-1"); + AttributeValueEnumeration colors = (AttributeValueEnumeration) so1.getAttributes().get("Colors"); + + assertEquals(List.of("ev-red", "ev-green"), colors.getValueRefs()); + assertEquals(List.of("Red", "Green"), colors.getValues()); + } + + @Test + void enumDefaultIsAppliedToObjectsWithoutAValue() { + SpecObject so2 = written.getReqIFCoreContent().getSpecObject("so-2"); + AttributeValueEnumeration colors = (AttributeValueEnumeration) so2.getAttributes().get("Colors"); + + assertEquals(List.of("Blue"), colors.getValues(), + "the enum DEFAULT-VALUE declared in the builder must be written"); + } + + @Test + void xhtmlValueGivenAsMarkupIsWritten() { + String text = (String) written.getReqIFCoreContent().getSpecObject("so-1").getAttribute("ReqIF.Text"); + + assertEquals("The system shall boot within 5 seconds.
Text with"),
+ "indenting injects whitespace into mixed content - this is why it is opt-in: " + withIndent);
+ }
+
+ @Test
+ void writingIsStableAcrossRepeatedRoundTrips() throws Exception {
+ String once = new ReqIFWriter().toXml(original.getReqIFDocument());
+ String twice = new ReqIFWriter().toXml(roundTripped.getReqIFDocument());
+
+ assertEquals(once, twice, "writing an already round-tripped document must be idempotent");
+ }
+}
diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/SpecRelationTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/SpecRelationTest.java
new file mode 100644
index 0000000..de3c785
--- /dev/null
+++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/SpecRelationTest.java
@@ -0,0 +1,72 @@
+package de.uni_stuttgart.ils.reqif4j;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF;
+import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst;
+import de.uni_stuttgart.ils.reqif4j.specification.SpecRelation;
+
+/**
+ * Bug: SpecRelation overwrote the inherited {@code type} field — which carries
+ * a content category (REQ/HEADLINE/TEXT) — with the raw
+ * SPEC-RELATION-TYPE-REF id, so the inherited category methods returned
+ * nonsense. The relation's own attribute values were not parsed at all.
+ */
+class SpecRelationTest {
+
+ private SpecRelation relation;
+
+ @BeforeEach
+ void parseFixture(@TempDir Path tempDir) throws Exception {
+ ReqIF reqif = new ReqIF(TestFixtures.writeDefaultFixture(tempDir).toString());
+ relation = reqif.getReqIFCoreContent().getSpecRelation("sr-1");
+ assertNotNull(relation, "the fixture's spec relation must be parsed");
+ }
+
+ @Test
+ void sourceAndTargetAreParsed() {
+ // regression guard for the original DOORS relationship reader
+ assertEquals("so-1", relation.getSourceObjID());
+ assertEquals("so-2", relation.getTargetObjID());
+ }
+
+ @Test
+ void inheritedTypeIsUndefinedInsteadOfTheRelationTypeRef() {
+ assertEquals(ReqIFConst.UNDEFINED, relation.getType(),
+ "a relation has no content category; the type ref must not leak into type");
+ }
+
+ @Test
+ void relationTypeIsExposedSeparately() {
+ assertEquals("st-rel", relation.getRelationTypeRef());
+ assertEquals("satisfies", relation.getRelationTypeName());
+ }
+
+ @Test
+ void structuralKindIsStillAvailable() {
+ assertEquals(ReqIFConst.SPEC_RELATION_TYPE, relation.getSpecType(),
+ "the information 'this is a relation' lives in getSpecType()");
+ }
+
+ @Test
+ void contentCategoryMethodsAreAllFalse() {
+ assertFalse(relation.isReq());
+ assertFalse(relation.isSubReq());
+ assertFalse(relation.isHeadline());
+ assertFalse(relation.isText(), "a relation is not text (formerly reported true)");
+ }
+
+ @Test
+ void relationAttributeValuesAreParsed() {
+ assertEquals("derived during review", relation.getAttribute("LinkComment"),
+ "attribute values of a relation were formerly ignored");
+ }
+}
diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java
index 0287d2e..e7b6cb4 100644
--- a/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java
+++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java
@@ -82,6 +82,13 @@ private TestFixtures() {
+
}, elements without a
+ * dedicated class lost their entire content, and trimming ran words together
+ * across inline elements.
+ */
+class XHTMLRenderingTest {
+
+ private static final String RENDER_FIXTURE = """
+
+
"), "br must be self-closing, got: " + rendered);
+ assertFalse(rendered.contains(""),
+ "
is read as two line breaks by HTML5 parsers: " + rendered);
+ }
+
+ @Test
+ void elementsWithoutDedicatedClassKeepTheirContent() {
+ assertTrue(rendered.contains(">diesen Link"),
+ "link text must not be dropped, got: " + rendered);
+ assertTrue(rendered.contains("Betonung"),
+ "emphasized text must not be dropped, got: " + rendered);
+ }
+
+ @Test
+ void inlineSpacingIsPreserved() {
+ assertTrue(rendered.contains("Siehe und "), "spaces around inline elements must survive: " + rendered);
+ }
+
+ @Test
+ void renderedMarkupMatchesExpectedShape() {
+ assertEquals("", rendered);
+ }
+}