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 +

a < b & c

+

Siehe diesen Link und Betonung.

+
merged
+``` + +Namespace declarations are omitted because tag names are rendered without +their prefix. + +# Writing ReqIF (experimental) +Beyond reading, the library can serialize the object model back to ReqIF +XML. This is the foundation for generating documents; it works on the +model, so a document read from a file and one modified programmatically +are written the same way. + +```java +ReqIF reqif = new ReqIF("in.reqif"); + +new ReqIFWriter().write(reqif.getReqIFDocument(), Path.of("out.reqif")); +String xml = new ReqIFWriter().toXml(reqif.getReqIFDocument()); +``` + +Round-tripping (parse -> write -> parse) preserves the header, all +datatypes including enumerations, spec types with attribute definitions +and defaults, spec objects with all attribute value kinds (multiselect +enumerations and XHTML included), spec relations with their attributes, +and the specification hierarchy. Attribute values are written sorted by +definition id so the output is reproducible. + +Indenting is **off by default**: the XML indenter inserts whitespace into +mixed content, which would change XHTML attribute values. Enable it with +`new ReqIFWriter().setIndent(true)` only when readable output matters more +than exact XHTML content. + +## Creating documents from scratch + +`ReqIFBuilder` assembles the same object model the parser produces, so a +generated document is written exactly like a parsed one. Identifiers are +validated while building: referencing an unknown datatype, spec type, +spec object, attribute definition or enum value fails immediately with a +`ReqIFBuildException` instead of producing a broken document. + +```java +ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr-1").title("My Spec").toolID("reqif4j") + .creationTime("2026-07-23T10:00:00Z")) + .stringDatatype("dt-string", "String", 4096) + .xhtmlDatatype("dt-xhtml", "XHTML") + .enumerationDatatype("dt-enum", "Color", e -> e + .value("ev-red", "Red", "1", "#ff0000") + .value("ev-blue", "Blue", "2")) + .specObjectType("st-req", "Requirement Type", t -> t + .stringAttribute("ad-title", "ReqIF.Name", "dt-string") + .xhtmlAttribute("ad-text", "ReqIF.Text", "dt-xhtml") + .enumerationAttribute("ad-color", "Colors", "dt-enum", + true, List.of("ev-blue"))) + .specificationType("st-spec", "Specification Type", t -> {}) + .specRelationType("st-rel", "satisfies", t -> {}) + .specObject("so-1", "st-req", o -> o + .set("ad-title", "First requirement") + .setEnum("ad-color", "ev-red", "ev-blue") // multiselect + .setXhtml("ad-text", "

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 dataTypes) { this.id = attributeDefinition.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinitionEnumeration.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinitionEnumeration.java index c555a0d..eba9f3e 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinitionEnumeration.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinitionEnumeration.java @@ -19,6 +19,25 @@ public class AttributeDefinitionEnumeration extends AttributeDefinition { private final List defaultValues = new ArrayList(); private final boolean multiValued; + /** + * Creates an enumeration attribute definition from plain values, for + * documents that are generated instead of parsed. + * + * @param defaultValueRefs IDENTIFIERs of the default enum values + */ + public AttributeDefinitionEnumeration(String id, String name, Datatype type, boolean multiValued, + List defaultValueRefs) { + super(id, name, type, null); + + this.multiValued = multiValued; + if (defaultValueRefs != null) { + for (String ref : defaultValueRefs) { + this.defaultValueRefs.add(ref); + this.defaultValues.add(resolveName(ref)); + } + } + } + public AttributeDefinitionEnumeration(Node attributeDefinition, Map dataTypes) { super(attributeDefinition, dataTypes); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueDate.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueDate.java index 48bb849..7a6920b 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueDate.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueDate.java @@ -1,14 +1,73 @@ package de.uni_stuttgart.ils.reqif4j.attributes; -public class AttributeValueDate extends AttributeValue{ +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; + +/** + * Value of a DATE attribute. ReqIF declares DATE as {@code xsd:dateTime}, so + * the raw string is additionally parsed into a {@link OffsetDateTime}. + * + * {@link #getValue()} keeps returning the raw string; use + * {@link #getDateTime()} for the parsed value. + */ +public class AttributeValueDate extends AttributeValue { + + private final OffsetDateTime dateTime; + public AttributeValueDate(String value, AttributeDefinition type) { super(value, type); - + this.dateTime = parse(value); } + /** + * @return the raw value as written in the document (may be null) + */ @Override public Object getValue() { - return (String)this.value; + return (String) this.value; + } + + /** + * @return the parsed timestamp, or null if the attribute has no value or + * the value is not a valid date. Values without a zone offset are + * read as UTC, date-only values as start of day UTC. + */ + public OffsetDateTime getDateTime() { + return this.dateTime; + } + + /** + * @return the parsed date without time, or null if unparseable + */ + public LocalDate getDate() { + return this.dateTime == null ? null : this.dateTime.toLocalDate(); + } + + private static OffsetDateTime parse(String value) { + + if (value == null || value.isBlank()) { + return null; + } + String date = value.trim(); + + try { + return OffsetDateTime.parse(date); + } catch (DateTimeParseException withoutOffset) { + // fall through + } + try { + return LocalDateTime.parse(date).atOffset(ZoneOffset.UTC); + } catch (DateTimeParseException notADateTime) { + // fall through + } + try { + return LocalDate.parse(date).atStartOfDay().atOffset(ZoneOffset.UTC); + } catch (DateTimeParseException notADate) { + return null; + } } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueXHTML.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueXHTML.java index da46d33..4759e48 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueXHTML.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueXHTML.java @@ -1,17 +1,70 @@ package de.uni_stuttgart.ils.reqif4j.attributes; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Collections; import java.util.List; -import org.w3c.dom.Element; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + import org.w3c.dom.Node; +import org.xml.sax.SAXException; -import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; +import de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLElement; import de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLElementDiv; +import de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLElementObject; +import de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLElementText; +import de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLNode; +/** + * XHTML attribute value. The content is available in two representations: + * the node tree ({@link #getDivValue()}) and a flat token list + * ({@link #getElementList()}). + * + * The token list is derived from the node tree, so both representations always + * agree. Its grammar is: + * + *
+ * 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("" + trimmed + ""; + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + return factory.newDocumentBuilder() + .parse(new ByteArrayInputStream(document.getBytes(StandardCharsets.UTF_8))) + .getDocumentElement(); + } catch (SAXException | IOException | ParserConfigurationException e) { + throw new IllegalArgumentException("XHTML value is not well-formed: " + markup, e); + } + } + public XHTMLElementDiv getDivValue() { return this.divValue; } @@ -52,221 +130,262 @@ public AttributeValueXHTMLElementList getElementList() { } return new AttributeValueXHTMLElementList(); } - - - - - private AttributeValueXHTMLElementList deconstructXHTML(Node xhtmlContent) { - AttributeValueXHTMLElementList xhtmlElementList = new AttributeValueXHTMLElementList(); - Node div = XmlUtils.firstDescendantByLocalName(xhtmlContent, XHTML.DIV); + + + /** + * Builds the token list from the already parsed node tree, so it shares the + * tree's namespace handling and never re-interprets the raw DOM. + */ + private AttributeValueXHTMLElementList deconstructXHTML(XHTMLElementDiv div) { + + AttributeValueXHTMLElementList xhtmlElementList = new AttributeValueXHTMLElementList(); if(div == null) { return xhtmlElementList; } - for(int e=0; e < div.getChildNodes().getLength(); e++) { - Node xhtmlElement = div.getChildNodes().item(e); - // Map the element's local name to the content token expected by - // decostructXHTMLElement. The former implementation passed the raw - // (prefixed) node name, which never matched, so all content lists - // stayed empty. - String token = elementToken(XmlUtils.localName(xhtmlElement)); - if(token != null) { - xhtmlElementList.add(token, decostructXHTMLElement(token, xhtmlElement)); + for(XHTMLNode child: childrenOf(div)) { + String elementType = elementToken(child.getTagName()); + if(elementType != null) { + xhtmlElementList.add(elementType, deconstructElement(elementType, child)); } } return xhtmlElementList; } - private static String elementToken(String localName) { - switch(localName.replaceAll("[0-9]", "")) { - case "p": return "P"; - case "table": return "TBL"; - case "ul": return "L"; - case "h": return "H"; - case "object": return "OBJ"; - default: return null; + private static String elementToken(String tagName) { + + switch(tagName.replaceAll("[0-9]", "")) { + case XHTML.P: return T_PARAGRAPH; + case XHTML.TBL: return T_TABLE; + case XHTML.UL: + case XHTML.OL: return T_LIST; + case XHTML.H: return T_HEADING; + case XHTML.OBJECT: return T_OBJECT; + default: return null; } } - - private List decostructXHTMLElement(String elementType, Node xhtmlElement) { - + + private static List deconstructElement(String elementType, XHTMLNode element) { + List content = new ArrayList(); - - if(elementType.equals("P")) { - for(int childNode=0; childNode < xhtmlElement.getChildNodes().getLength(); childNode++) { - Node child = xhtmlElement.getChildNodes().item(childNode); - if(child.getNodeName().contains("span")) { - content.add("TXT"); - content.add(child.getTextContent().trim()); - - }else if(child.getNodeName().equals(ReqIFConst._TEXT) && !child.getTextContent().isEmpty()) { - content.add("TXT"); - content.add(child.getTextContent().trim()); - - }else if(child.getNodeName().contains("var")) { // TODO - //System.out.println("VAR:\n" + child.getTextContent().trim()); - content.add("VAR"); - if(child.getTextContent().trim().equals("")) { - content.add("VARIABLE_NAME_MISSING"); - - }else{ - content.add(child.getTextContent().trim()); - if(child.getAttributes().getNamedItem("GUID") != null) { - content.add(child.getAttributes().getNamedItem("GUID").getTextContent()); - } - } - - }else if(child.getNodeName().contains("br")) { - //System.out.println(); - content.add("BR"); - + + if(elementType.equals(T_PARAGRAPH) || elementType.equals(T_HEADING)) { + for(XHTMLNode child: childrenOf(element)) { + appendInline(child, content); + } + + }else if(elementType.equals(T_TABLE)) { + content.addAll(table(element)); + + }else if(elementType.equals(T_LIST)) { + content.addAll(list(element, false)); + + }else if(elementType.equals(T_OBJECT)) { + String data = objectData(element); + if(data != null) { + content.add(data); + } + } + + return content; + } + + /** + * Emits the inline tokens of a single node (text, span, var, br, object). + */ + private static void appendInline(XHTMLNode node, List content) { + + String tagName = node.getTagName(); + + if(tagName.equals(XHTML.TEXT)) { + String text = node instanceof XHTMLElementText + ? ((XHTMLElementText) node).getTextContent() + : textOf(node); + if(!text.isEmpty()) { + content.add(T_TEXT); + content.add(text); + } + + }else if(tagName.equals(XHTML.SPAN)) { + String text = textOf(node); + if(!text.isEmpty()) { + content.add(T_TEXT); + content.add(text); + } + + }else if(tagName.equals(XHTML.VAR)) { + content.add(T_VAR); + String text = textOf(node); + if(text.isEmpty()) { + content.add(VARIABLE_NAME_MISSING); + }else{ + content.add(text); + String guid = XmlUtils.attribute(node.getNode(), "GUID"); + if(guid != null) { + content.add(guid); } } - }else if(elementType.equals("TBL")) { - - content.addAll(decontructTable(xhtmlElement)); - - }else if(elementType.equals("L")) { // TODO: nested lists - - content.addAll(list(xhtmlElement)); - - }else if(elementType.equals("H")) { - //System.out.println("\n"+xhtmlElement.getTextContent().trim()); - //content.add(xhtmlElement.getTextContent().trim()); - // TODO - for(int childNode=0; childNode < xhtmlElement.getChildNodes().getLength(); childNode++) { - Node child = xhtmlElement.getChildNodes().item(childNode); - - if(child.getNodeName().contains("span")) { - content.add("TXT"); - content.add(child.getTextContent().trim()); - - }else if(child.getNodeName().equals(ReqIFConst._TEXT) && !child.getTextContent().isEmpty()) { - content.add("TXT"); - content.add(child.getTextContent().trim()); - - }else if(child.getNodeName().contains("var")) { - content.add("VAR"); - if(child.getTextContent().trim().equals("")) { - content.add("VARIABLE_NAME_MISSING"); - }else{ - content.add(child.getTextContent().trim()); - if(child.getAttributes().getNamedItem("GUID") != null) { - content.add(child.getAttributes().getNamedItem("GUID").getTextContent()); - } + + }else if(tagName.equals(XHTML.BR)) { + content.add(T_BREAK); + + }else if(tagName.equals(XHTML.OBJECT)) { + String data = objectData(node); + if(data != null) { + content.add(T_OBJECT); + content.add(data); + } + } + } + + /** + * Deconstructs a table. Rows are found both directly below the table and + * inside thead/tbody/tfoot sections; caption and colgroup are skipped. + */ + private static List table(XHTMLNode table) { + + List content = new ArrayList(); + + for(XHTMLNode section: childrenOf(table)) { + String tagName = section.getTagName(); + + if(tagName.equals(XHTML.TR)) { + content.addAll(row(section)); + + }else if(tagName.equals(XHTML.THEAD) || tagName.equals(XHTML.TBODY) || tagName.equals("tfoot")) { + for(XHTMLNode row: childrenOf(section)) { + if(row.getTagName().equals(XHTML.TR)) { + content.addAll(row(row)); } } } - - }else if(elementType.equals("OBJ")) { - // Keep the original URI (forward slashes) so it matches the picture - // entries of the .reqifz archive on every platform. - String path = XmlUtils.attribute(xhtmlElement, "data"); - if(path != null) { - content.add(path.trim()); - } } - - /*// - for(int i=0; i row(XHTMLNode row) { + + List content = new ArrayList(); + content.add(T_ROW); + + for(XHTMLNode cell: childrenOf(row)) { + String tagName = cell.getTagName(); + + if(tagName.equals(XHTML.TH) || tagName.equals(XHTML.TD)) { + content.add(tagName.equals(XHTML.TH) ? T_HEADER_CELL : T_CELL); + content.add(cellText(cell)); + + // images inside the cell would otherwise be lost + for(XHTMLNode image: objectsIn(cell)) { + content.add(T_OBJECT); + content.add(objectData(image)); + } + } } - //*/ - + return content; } - - private List list(Node listNode) { - - List list = new ArrayList(); - - /// - for(int childNode=0; childNode < listNode.getChildNodes().getLength(); childNode++) { - Node child = listNode.getChildNodes().item(childNode); - - if(child.getNodeName().endsWith(":li")) { - list.add("LE"); - - for(int listElement=0; listElement < child.getChildNodes().getLength(); listElement++) { - Node listChild = child.getChildNodes().item(listElement); - String leName = listChild.getNodeName(); - - if(leName.endsWith("span")) { - list.add("TXT"); - list.add(listChild.getTextContent().trim()); - - }else if(leName.equals(ReqIFConst._TEXT) && !listChild.getTextContent().trim().isEmpty()) { - list.add("TXT"); - list.add(listChild.getTextContent().trim()); - - }else if(leName.endsWith("var")) { - list.add("VAR"); - if(listChild.getTextContent().trim().equals("")) { - list.add("VARIABLE_NAME_MISSING"); - }else{ - list.add(listChild.getTextContent().trim()); - } - - /// - }else if(leName.endsWith("ul")) { - list.add("L"); - list.addAll(list(listChild)); - //list.add("_L"); - //*/ - - }else if(leName.endsWith("table")) { - list.add("TBL"); - list.addAll(decontructTable(listChild)); - - } + + /** + * @return the complete text of a cell; the texts of several child elements + * are joined with a blank (the former implementation read only the + * second child node and dropped the rest) + */ + private static String cellText(XHTMLNode cell) { + + List children = childrenOf(cell); + if(children.isEmpty()) { + return textOf(cell); + } + + StringBuilder text = new StringBuilder(); + for(XHTMLNode child: children) { + String childText = textOf(child); + if(!childText.isEmpty()) { + if(text.length() > 0) { + text.append(' '); } + text.append(childText); } } - //*/ - - list.add("/L"); - - return list; + return text.toString(); } - - private List decontructTable(Node tableNode) { - - List tblContent = new ArrayList(); - - for(int tbl=0; tbl < tableNode.getChildNodes().getLength(); tbl++) { - Node tbody = tableNode.getChildNodes().item(tbl); - - if(!tbody.getNodeName().equals(ReqIFConst._TEXT)) { - - for(int tr=0; tr < tbody.getChildNodes().getLength(); tr++) { - Node trow = tbody.getChildNodes().item(tr); - - if(!trow.getNodeName().equals(ReqIFConst._TEXT)) { - //System.out.println("\nTR"); - tblContent.add("TR"); - - for(int tc=0; tc < trow.getChildNodes().getLength(); tc++) { - Node tcoloumn = trow.getChildNodes().item(tc); - - if(!tcoloumn.getNodeName().equals(ReqIFConst._TEXT)) { - //System.out.println("TC"); - tblContent.add("TC"); - if(tcoloumn.getChildNodes().getLength() <= 1) { - //System.out.println(tcoloumn.getTextContent()); - tblContent.add(tcoloumn.getTextContent().trim()); - }else{ - tblContent.add(tcoloumn.getChildNodes().item(1).getTextContent().trim()); - } - } - } - } + + /** + * Deconstructs a list. Nested lists are wrapped in balanced "L" / "/L" + * markers; the outermost list needs none because its element type already + * says "L". + */ + private static List list(XHTMLNode listNode, boolean nested) { + + List content = new ArrayList(); + if(nested) { + content.add(T_LIST); + } + + for(XHTMLNode listEntry: childrenOf(listNode)) { + + if(!listEntry.getTagName().equals(XHTML.LI)) { + continue; + } + content.add(T_LIST_ENTRY); + + for(XHTMLNode child: childrenOf(listEntry)) { + String tagName = child.getTagName(); + + if(tagName.equals(XHTML.UL) || tagName.equals(XHTML.OL)) { + content.addAll(list(child, true)); + + }else if(tagName.equals(XHTML.TBL)) { + content.add(T_TABLE); + content.addAll(table(child)); + + }else{ + appendInline(child, content); } } } - - return tblContent; + + if(nested) { + content.add(T_LIST_END); + } + + return content; + } + + + + + private static List childrenOf(XHTMLNode node) { + + if(node instanceof XHTMLElement) { + List children = ((XHTMLElement) node).getChildren(); + return children == null ? Collections.emptyList() : children; + } + return Collections.emptyList(); + } + + private static List objectsIn(XHTMLNode node) { + + List objects = new ArrayList(); + for(XHTMLNode child: childrenOf(node)) { + if(child.getTagName().equals(XHTML.OBJECT)) { + objects.add(child); + } + objects.addAll(objectsIn(child)); + } + return objects; + } + + private static String objectData(XHTMLNode node) { + return node instanceof XHTMLElementObject ? ((XHTMLElementObject) node).getData() : null; + } + + private static String textOf(XHTMLNode node) { + return node.getNode() == null ? "" : node.getNode().getTextContent().trim(); } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/XHTML.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/XHTML.java index 1b8d5e8..0238a02 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/XHTML.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/XHTML.java @@ -11,6 +11,7 @@ public class XHTML { // equals public static final String LI = "li"; public static final String OBJECT = "object"; + public static final String OL = "ol"; public static final String P = "p"; public static final String SPAN = "span"; public static final String TBODY = "tbody"; diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuildException.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuildException.java new file mode 100644 index 0000000..e1eb8c8 --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuildException.java @@ -0,0 +1,16 @@ +package de.uni_stuttgart.ils.reqif4j.build; + +/** + * Thrown when a ReqIF document cannot be assembled, e.g. because an unknown + * identifier is referenced. + */ +public class ReqIFBuildException extends RuntimeException { + + public ReqIFBuildException(String message) { + super(message); + } + + public ReqIFBuildException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java new file mode 100644 index 0000000..a276faa --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java @@ -0,0 +1,402 @@ +package de.uni_stuttgart.ils.reqif4j.build; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeBoolean; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeDate; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeDouble; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumeration; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumerationValue; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeInteger; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeString; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeXHTML; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFCoreContent; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFHeader; +import de.uni_stuttgart.ils.reqif4j.specification.SpecHierarchy; +import de.uni_stuttgart.ils.reqif4j.specification.SpecObject; +import de.uni_stuttgart.ils.reqif4j.specification.SpecObjectType; +import de.uni_stuttgart.ils.reqif4j.specification.SpecRelation; +import de.uni_stuttgart.ils.reqif4j.specification.SpecRelationType; +import de.uni_stuttgart.ils.reqif4j.specification.SpecType; +import de.uni_stuttgart.ils.reqif4j.specification.Specification; +import de.uni_stuttgart.ils.reqif4j.specification.SpecificationType; +import de.uni_stuttgart.ils.reqif4j.specification.TypeClassifier; + +/** + * Fluent builder for creating ReqIF documents from scratch. The result is the + * same object model the parser produces, so it can be written with + * {@code ReqIFWriter}: + * + *
+ * 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 datatypes = new LinkedHashMap(); + private final Map specTypes = new LinkedHashMap(); + private final Map specObjects = new LinkedHashMap(); + private final List specRelations = new ArrayList(); + private final List specifications = new ArrayList(); + + private final HeaderBuilder headerBuilder = new HeaderBuilder(); + private TypeClassifier typeClassifier = TypeClassifier.defaultClassifier(); + + + public static ReqIFBuilder create() { + return new ReqIFBuilder(); + } + + /** + * Sets the classifier used to derive the content category of the created + * spec objects; defaults to the LONG-NAME heuristic. + */ + public ReqIFBuilder typeClassifier(TypeClassifier typeClassifier) { + this.typeClassifier = typeClassifier == null ? TypeClassifier.defaultClassifier() : typeClassifier; + return this; + } + + public ReqIFBuilder header(Consumer header) { + header.accept(this.headerBuilder); + return this; + } + + + // ---------------------------------------------------------------- datatypes + + /** Adds a datatype the builder has no shorthand for. */ + public ReqIFBuilder datatype(Datatype datatype) { + this.datatypes.put(datatype.getID(), datatype); + return this; + } + + public ReqIFBuilder stringDatatype(String id, String name, int maxLength) { + return datatype(new DatatypeString(id, name, Integer.toString(maxLength))); + } + + public ReqIFBuilder integerDatatype(String id, String name, long min, long max) { + return datatype(new DatatypeInteger(id, name, Long.toString(min), Long.toString(max))); + } + + public ReqIFBuilder booleanDatatype(String id, String name) { + return datatype(new DatatypeBoolean(id, name)); + } + + public ReqIFBuilder dateDatatype(String id, String name) { + return datatype(new DatatypeDate(id, name)); + } + + /** Written as DATATYPE-DEFINITION-REAL. */ + public ReqIFBuilder realDatatype(String id, String name) { + return datatype(new DatatypeDouble(id, name)); + } + + public ReqIFBuilder xhtmlDatatype(String id, String name) { + return datatype(new DatatypeXHTML(id, name)); + } + + public ReqIFBuilder enumerationDatatype(String id, String name, Consumer values) { + + EnumerationBuilder builder = new EnumerationBuilder(); + values.accept(builder); + return datatype(new DatatypeEnumeration(id, name, builder.values)); + } + + + // --------------------------------------------------------------- spec types + + public ReqIFBuilder specObjectType(String id, String name, Consumer attributes) { + return specType(new SpecObjectType(id, name), attributes); + } + + public ReqIFBuilder specificationType(String id, String name, Consumer attributes) { + return specType(new SpecificationType(id, name), attributes); + } + + public ReqIFBuilder specRelationType(String id, String name, Consumer attributes) { + return specType(new SpecRelationType(id, name), attributes); + } + + private ReqIFBuilder specType(SpecType specType, Consumer attributes) { + + attributes.accept(new SpecTypeBuilder(specType, this.datatypes)); + this.specTypes.put(specType.getID(), specType); + return this; + } + + + // ------------------------------------------------------------- spec objects + + public ReqIFBuilder specObject(String id, String specTypeID, Consumer values) { + + SpecType specType = requireSpecType(specTypeID); + ValuesBuilder builder = new ValuesBuilder(specType); + values.accept(builder); + + this.specObjects.put(id, new SpecObject(id, specType, builder.build(), this.typeClassifier)); + return this; + } + + public ReqIFBuilder specObject(String id, String specTypeID) { + return specObject(id, specTypeID, values -> { }); + } + + + // ----------------------------------------------------------- spec relations + + public ReqIFBuilder specRelation(String id, String specTypeID, String sourceID, String targetID, + Consumer values) { + + SpecType specType = requireSpecType(specTypeID); + requireSpecObject(sourceID); + requireSpecObject(targetID); + + ValuesBuilder builder = new ValuesBuilder(specType); + values.accept(builder); + + this.specRelations.add(new SpecRelation(id, specType, sourceID, targetID, builder.build())); + return this; + } + + public ReqIFBuilder specRelation(String id, String specTypeID, String sourceID, String targetID) { + return specRelation(id, specTypeID, sourceID, targetID, values -> { }); + } + + + // ----------------------------------------------------------- specifications + + public ReqIFBuilder specification(String id, String name, String specTypeID, + Consumer content) { + + SpecType specType = requireSpecType(specTypeID); + SpecificationBuilder builder = new SpecificationBuilder(specType); + content.accept(builder); + + this.specifications.add(new Specification(id, name, specType, builder.values.build(), + builder.children(1, 0))); + return this; + } + + + /** + * @return the assembled document, ready to be written + */ + public ReqIFDocument build() { + + ReqIFCoreContent content = new ReqIFCoreContent(); + for (Datatype datatype : this.datatypes.values()) { + content.addDatatype(datatype); + } + for (SpecType specType : this.specTypes.values()) { + content.addSpecType(specType); + } + for (SpecObject specObject : this.specObjects.values()) { + content.addSpecObject(specObject); + } + for (SpecRelation relation : this.specRelations) { + content.addSpecRelation(relation); + } + for (Specification specification : this.specifications) { + content.addSpecification(specification); + } + + return new ReqIFDocument(this.headerBuilder.build(), content); + } + + + private SpecType requireSpecType(String specTypeID) { + + SpecType specType = this.specTypes.get(specTypeID); + if (specType == null) { + throw new ReqIFBuildException("Unknown spec type: " + specTypeID); + } + return specType; + } + + private void requireSpecObject(String specObjectID) { + + if (!this.specObjects.containsKey(specObjectID)) { + throw new ReqIFBuildException("Unknown spec object: " + specObjectID); + } + } + + + // ------------------------------------------------------------ nested builders + + /** Collects the enum values of an enumeration datatype. */ + public static class EnumerationBuilder { + + private final List values = new ArrayList(); + + public EnumerationBuilder value(String id, String name, String key) { + return value(id, name, key, ""); + } + + public EnumerationBuilder value(String id, String name, String key, String otherContent) { + this.values.add(new DatatypeEnumerationValue(id, name, key, otherContent)); + return this; + } + } + + /** Builds the specification hierarchy of a specification. */ + public class SpecificationBuilder { + + private final ValuesBuilder values; + private final List hierarchies = new ArrayList(); + + private SpecificationBuilder(SpecType specType) { + this.values = new ValuesBuilder(specType); + } + + /** Sets an attribute value of the specification itself. */ + public SpecificationBuilder set(String attributeDefinitionID, Object value) { + this.values.set(attributeDefinitionID, value); + return this; + } + + public SpecificationBuilder child(String hierarchyID, String specObjectID) { + return child(hierarchyID, specObjectID, child -> { }); + } + + public SpecificationBuilder child(String hierarchyID, String specObjectID, + Consumer children) { + + HierarchyBuilder hierarchy = new HierarchyBuilder(hierarchyID, specObjectID); + children.accept(hierarchy); + this.hierarchies.add(hierarchy); + return this; + } + + private List children(int level, int sectionOffset) { + + List children = new ArrayList(); + int section = sectionOffset; + for (HierarchyBuilder hierarchy : this.hierarchies) { + children.add(hierarchy.build(level, ++section)); + } + return children; + } + } + + /** Builds one node of the specification hierarchy. */ + public class HierarchyBuilder { + + private final String hierarchyID; + private final String specObjectID; + private final List children = new ArrayList(); + + private HierarchyBuilder(String hierarchyID, String specObjectID) { + this.hierarchyID = hierarchyID; + this.specObjectID = specObjectID; + } + + public HierarchyBuilder child(String hierarchyID, String specObjectID) { + return child(hierarchyID, specObjectID, child -> { }); + } + + public HierarchyBuilder child(String hierarchyID, String specObjectID, + Consumer children) { + + HierarchyBuilder hierarchy = new HierarchyBuilder(hierarchyID, specObjectID); + children.accept(hierarchy); + this.children.add(hierarchy); + return this; + } + + private SpecHierarchy build(int level, int section) { + + requireSpecObject(this.specObjectID); + + List nested = new ArrayList(); + for (HierarchyBuilder child : this.children) { + nested.add(child.build(level + 1, section)); + } + return new SpecHierarchy(this.hierarchyID, level, section, + ReqIFBuilder.this.specObjects.get(this.specObjectID), nested); + } + } + + /** Collects the header fields. */ + public static class HeaderBuilder { + + private String id = ""; + private String title = ""; + private String comment = ""; + private String creationTime = ""; + private String toolID = ""; + private String sourceToolID = ""; + private String reqifVersion = "1.0"; + + public HeaderBuilder id(String id) { + this.id = id; + return this; + } + + public HeaderBuilder title(String title) { + this.title = title; + return this; + } + + public HeaderBuilder comment(String comment) { + this.comment = comment; + return this; + } + + /** @param creationTime xsd:dateTime, e.g. 2026-07-23T10:00:00Z */ + public HeaderBuilder creationTime(String creationTime) { + this.creationTime = creationTime; + return this; + } + + public HeaderBuilder toolID(String toolID) { + this.toolID = toolID; + return this; + } + + public HeaderBuilder sourceToolID(String sourceToolID) { + this.sourceToolID = sourceToolID; + return this; + } + + public HeaderBuilder reqifVersion(String reqifVersion) { + this.reqifVersion = reqifVersion; + return this; + } + + private ReqIFHeader build() { + return new ReqIFHeader(this.id, this.title, this.comment, this.creationTime, + this.toolID, this.sourceToolID, this.reqifVersion); + } + } + + /** Convenience for enumeration values. */ + public static List enumValues(String... enumValueIDs) { + return new ArrayList(Arrays.asList(enumValueIDs)); + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/SpecTypeBuilder.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/SpecTypeBuilder.java new file mode 100644 index 0000000..68e111b --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/SpecTypeBuilder.java @@ -0,0 +1,104 @@ +package de.uni_stuttgart.ils.reqif4j.build; + +import java.util.List; +import java.util.Map; + +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinition; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinitionEnumeration; +import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.specification.SpecType; + +/** + * Adds attribute definitions to a spec type while building a document. The + * referenced datatype must already be declared; otherwise a + * {@link ReqIFBuildException} is thrown. + */ +public class SpecTypeBuilder { + + private final SpecType specType; + private final Map datatypes; + + SpecTypeBuilder(SpecType specType, Map datatypes) { + this.specType = specType; + this.datatypes = datatypes; + } + + + public SpecTypeBuilder stringAttribute(String id, String name, String datatypeID) { + return attribute(id, name, datatypeID, ReqIFConst.STRING, null); + } + + public SpecTypeBuilder stringAttribute(String id, String name, String datatypeID, String defaultValue) { + return attribute(id, name, datatypeID, ReqIFConst.STRING, defaultValue); + } + + public SpecTypeBuilder integerAttribute(String id, String name, String datatypeID) { + return attribute(id, name, datatypeID, ReqIFConst.INTEGER, null); + } + + public SpecTypeBuilder booleanAttribute(String id, String name, String datatypeID) { + return attribute(id, name, datatypeID, ReqIFConst.BOOLEAN, null); + } + + public SpecTypeBuilder dateAttribute(String id, String name, String datatypeID) { + return attribute(id, name, datatypeID, ReqIFConst.DATE, null); + } + + public SpecTypeBuilder realAttribute(String id, String name, String datatypeID) { + return attribute(id, name, datatypeID, ReqIFConst.DOUBLE, null); + } + + public SpecTypeBuilder xhtmlAttribute(String id, String name, String datatypeID) { + return attribute(id, name, datatypeID, ReqIFConst.XHTML, null); + } + + /** + * Adds an enumeration attribute. + * + * @param multiValued whether more than one enum value may be selected + * @param defaultValueRefs IDENTIFIERs of the default enum values, may be empty + */ + public SpecTypeBuilder enumerationAttribute(String id, String name, String datatypeID, boolean multiValued, + List defaultValueRefs) { + + Datatype datatype = requireDatatype(datatypeID, ReqIFConst.ENUMERATION); + this.specType.addAttributeDefinition( + new AttributeDefinitionEnumeration(id, name, datatype, multiValued, defaultValueRefs)); + return this; + } + + public SpecTypeBuilder enumerationAttribute(String id, String name, String datatypeID, boolean multiValued) { + return enumerationAttribute(id, name, datatypeID, multiValued, null); + } + + /** Adds an attribute definition for a datatype without a shorthand. */ + public SpecTypeBuilder attribute(String id, String name, String datatypeID, String defaultValue) { + + Datatype datatype = requireDatatype(datatypeID, null); + this.specType.addAttributeDefinition(new AttributeDefinition(id, name, datatype, defaultValue)); + return this; + } + + + private SpecTypeBuilder attribute(String id, String name, String datatypeID, String expectedCategory, + String defaultValue) { + + Datatype datatype = requireDatatype(datatypeID, expectedCategory); + this.specType.addAttributeDefinition(new AttributeDefinition(id, name, datatype, defaultValue)); + return this; + } + + private Datatype requireDatatype(String datatypeID, String expectedCategory) { + + Datatype datatype = this.datatypes.get(datatypeID); + if (datatype == null) { + throw new ReqIFBuildException("Unknown datatype: " + datatypeID); + } + if (expectedCategory != null && !expectedCategory.equals(datatype.getType())) { + throw new ReqIFBuildException("Datatype " + datatypeID + " is of kind " + datatype.getType() + + ", but " + expectedCategory + " was expected"); + } + return datatype; + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ValuesBuilder.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ValuesBuilder.java new file mode 100644 index 0000000..69159d4 --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ValuesBuilder.java @@ -0,0 +1,136 @@ +package de.uni_stuttgart.ils.reqif4j.build; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinition; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueBoolean; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueDate; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueDouble; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueEnumeration; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueInteger; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueString; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTML; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumeration; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.specification.SpecType; + +/** + * Collects the attribute values of a spec object, relation or specification. + * The attribute definition must belong to the given spec type, and enum value + * references must exist; otherwise a {@link ReqIFBuildException} is thrown. + */ +public class ValuesBuilder { + + private final SpecType specType; + private final List values = new ArrayList(); + + ValuesBuilder(SpecType specType) { + this.specType = specType; + } + + + /** + * Sets a scalar value; the value class is chosen from the attribute's + * datatype. + */ + public ValuesBuilder set(String attributeDefinitionID, Object value) { + + AttributeDefinition definition = requireDefinition(attributeDefinitionID); + String category = definition.getDataType() == null ? null : definition.getDataType().getType(); + String text = value == null ? null : String.valueOf(value); + + if (ReqIFConst.ENUMERATION.equals(category)) { + throw new ReqIFBuildException("Use setEnum(...) for the enumeration attribute " + attributeDefinitionID); + } + if (ReqIFConst.XHTML.equals(category)) { + throw new ReqIFBuildException("Use setXhtml(...) for the XHTML attribute " + attributeDefinitionID); + } + + this.values.add(scalarValue(category, text, definition)); + return this; + } + + /** + * Sets an XHTML value from markup, with or without a surrounding div. + */ + public ValuesBuilder setXhtml(String attributeDefinitionID, String markup) { + + AttributeDefinition definition = requireDefinition(attributeDefinitionID); + requireCategory(definition, ReqIFConst.XHTML, attributeDefinitionID); + + this.values.add(new AttributeValueXHTML(markup, definition)); + return this; + } + + /** + * Sets one or more enum values by their IDENTIFIER (multiselect). + */ + public ValuesBuilder setEnum(String attributeDefinitionID, String... enumValueIDs) { + return setEnum(attributeDefinitionID, Arrays.asList(enumValueIDs)); + } + + public ValuesBuilder setEnum(String attributeDefinitionID, List enumValueIDs) { + + AttributeDefinition definition = requireDefinition(attributeDefinitionID); + requireCategory(definition, ReqIFConst.ENUMERATION, attributeDefinitionID); + + DatatypeEnumeration datatype = (DatatypeEnumeration) definition.getDataType(); + List names = new ArrayList(); + for (String enumValueID : enumValueIDs) { + String name = datatype.getEnumValueName(enumValueID); + if (name == null) { + throw new ReqIFBuildException("Unknown enum value " + enumValueID + + " for attribute " + attributeDefinitionID); + } + names.add(name); + } + + this.values.add(new AttributeValueEnumeration(names, new ArrayList(enumValueIDs), definition)); + return this; + } + + + Collection build() { + return this.values; + } + + + private AttributeValue scalarValue(String category, String text, AttributeDefinition definition) { + + if (category == null) { + return new AttributeValue(text, definition); + } + switch (category) { + case ReqIFConst.BOOLEAN: return new AttributeValueBoolean(text, definition); + case ReqIFConst.INTEGER: return new AttributeValueInteger(text, definition); + case ReqIFConst.STRING: return new AttributeValueString(text, definition); + case ReqIFConst.DATE: return new AttributeValueDate(text, definition); + case ReqIFConst.DOUBLE: + case ReqIFConst.REAL: return new AttributeValueDouble(text, definition); + default: return new AttributeValue(text, definition); + } + } + + private AttributeDefinition requireDefinition(String attributeDefinitionID) { + + AttributeDefinition definition = this.specType.getAttributeDefinition(attributeDefinitionID); + if (definition == null) { + throw new ReqIFBuildException("Spec type " + this.specType.getID() + + " has no attribute definition " + attributeDefinitionID); + } + return definition; + } + + private static void requireCategory(AttributeDefinition definition, String expected, String attributeDefinitionID) { + + String category = definition.getDataType() == null ? null : definition.getDataType().getType(); + if (!expected.equals(category)) { + throw new ReqIFBuildException("Attribute " + attributeDefinitionID + " is of kind " + category + + ", but " + expected + " was expected"); + } + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/Datatype.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/Datatype.java index a200df9..ebc9b88 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/Datatype.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/Datatype.java @@ -6,30 +6,46 @@ public class Datatype { private String id; private String name; private String type; - - - - + private String sourceElementName; + + + + public String getID() { return this.id; } - + public String getName() { return this.name; } - + public String getType() { return this.type; } - - - - + + /** + * @return the DATATYPE-DEFINITION-* element name this datatype was read + * from, or null when it was not created from a document. Needed to + * write back datatypes the parser does not model explicitly. + */ + public String getSourceElementName() { + return this.sourceElementName; + } + + + + public Datatype(String id, String name, String type) { - + this.id = id; this.name = name; this.type = type; } + public Datatype(String id, String name, String type, String sourceElementName) { + + this(id, name, type); + this.sourceElementName = sourceElementName; + } + } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeBoolean.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeBoolean.java index d228083..d7a249f 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeBoolean.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeBoolean.java @@ -3,29 +3,10 @@ import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; public class DatatypeBoolean extends Datatype { - - private String id; - private String name; - - - - - public String getID() { - return this.id; - } - - public String getName() { - return this.name; - } - - - - + + public DatatypeBoolean(String id, String name) { super(id, name, ReqIFConst.BOOLEAN); - - this.id = id; - this.name = name; } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumeration.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumeration.java index e63ea60..76fed6a 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumeration.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumeration.java @@ -49,6 +49,20 @@ public String getEnumValueOtherContent(String id) { } + /** + * Creates an enumeration datatype from plain values, for documents that are + * generated instead of parsed. + */ + public DatatypeEnumeration(String id, String name, java.util.List values) { + super(id, name, ReqIFConst.ENUMERATION); + + if (values != null) { + for (DatatypeEnumerationValue value : values) { + this.enumValues.put(value.getID(), value); + } + } + } + public DatatypeEnumeration(String id, String name, Node enumeration) { super(id, name, ReqIFConst.ENUMERATION); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeXHTML.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeXHTML.java index 493ad2e..091a718 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeXHTML.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeXHTML.java @@ -3,30 +3,10 @@ import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; public class DatatypeXHTML extends Datatype { - - - private String id; - private String name; - - - - - public String getID() { - return this.id; - } - - public String getName() { - return this.name; - } - - - - + + public DatatypeXHTML(String id, String name) { super(id, name, ReqIFConst.XHTML); - - this.id = id; - this.name = name; } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java index 90eb496..8e9883f 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java @@ -81,6 +81,39 @@ public List getOrderedSpecHierarchyList() { } + /** + * Creates empty content, to be filled for documents that are generated + * instead of parsed. + */ + public ReqIFCoreContent() { + } + + public ReqIFCoreContent addDatatype(Datatype datatype) { + this.dataTypes.put(datatype.getID(), datatype); + return this; + } + + public ReqIFCoreContent addSpecType(SpecType specType) { + this.specTypes.put(specType.getID(), specType); + return this; + } + + public ReqIFCoreContent addSpecObject(SpecObject specObject) { + this.specObjects.put(specObject.getID(), specObject); + return this; + } + + public ReqIFCoreContent addSpecRelation(SpecRelation relation) { + this.specRelation.put(relation.getID(), relation); + return this; + } + + public ReqIFCoreContent addSpecification(Specification specification) { + this.specifications.put(specification.getID(), specification); + return this; + } + + public ReqIFCoreContent(Element coreContent) { this(coreContent, TypeClassifier.defaultClassifier()); } @@ -143,7 +176,7 @@ public ReqIFCoreContent(Element coreContent, TypeClassifier typeClassifier) { default: // Register unknown datatypes under their ID (formerly the // key was null, making them unresolvable). - this.dataTypes.put(dataTypeID, new Datatype(dataTypeID, dataTypeName, ReqIFConst.UNDEFINED)); + this.dataTypes.put(dataTypeID, new Datatype(dataTypeID, dataTypeName, ReqIFConst.UNDEFINED, dataTypeNodeName)); break; } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java index f45c582..38c79b5 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java @@ -44,6 +44,18 @@ public ReqIFCoreContent getCoreContent() { } + /** + * Creates a document from an object model, for documents that are generated + * instead of parsed. + */ + public ReqIFDocument(ReqIFHeader header, ReqIFCoreContent content) { + + this.header = header; + this.content = content; + this.fileName = header == null || header.getTitle().isEmpty() ? "generated.reqif" : header.getTitle() + ".reqif"; + this.filePath = this.fileName; + } + public ReqIFDocument(String filePath) throws FileNotFoundException { this(filePath, TypeClassifier.defaultClassifier()); } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFHeader.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFHeader.java index 60502dc..32c07f4 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFHeader.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFHeader.java @@ -11,8 +11,9 @@ public class ReqIFHeader { private String toolID; private String sourceToolID = ""; private String reqifVersion = ""; - //private String comment = ""; + private String comment = ""; private String creationDate = ""; + private String creationTime = ""; @@ -41,13 +42,26 @@ public String getReqIFVersion() { return this.reqifVersion; } - /*public String getComment() { + /** + * @return the COMMENT of the header, or "" if the document declares none + */ + public String getComment() { return this.comment; - }*/ + } + /** + * @return the creation date formatted as dd.MM.yyyy + */ public String getCreationDate() { return this.creationDate; } + + /** + * @return the CREATION-TIME exactly as written in the document (xsd:dateTime) + */ + public String getCreationTime() { + return this.creationTime; + } @@ -64,27 +78,64 @@ public ReqIFHeader(Element theHeader) { this.reqifVersion = theHeader.getElementsByTagName(ReqIFConst.REQ_IF_VERSION).item(0).getTextContent(); } if(theHeader.getElementsByTagName(ReqIFConst.COMMENT).getLength() > 0) { - // The "Created by: " convention is tool-specific; a comment without - // it must not crash the parser. - String comment = theHeader.getElementsByTagName(ReqIFConst.COMMENT).item(0).getTextContent(); - int createdBy = comment.indexOf("Created by: "); - if(createdBy >= 0) { - this.author = comment.substring(createdBy + "Created by: ".length()).trim(); - } + this.comment = theHeader.getElementsByTagName(ReqIFConst.COMMENT).item(0).getTextContent(); + this.author = authorOf(this.comment); } if(theHeader.getElementsByTagName(ReqIFConst.CREATION_TIME).getLength() > 0) { - String creationTime = theHeader.getElementsByTagName(ReqIFConst.CREATION_TIME).item(0).getTextContent(); - String[] date = creationTime.split("T")[0].split("-"); - if(date.length >= 3) { - this.creationDate = date[2] + "." + date[1] + "." + date[0]; - }else{ - // unexpected format: keep the raw value instead of crashing - this.creationDate = creationTime; - } + this.creationTime = theHeader.getElementsByTagName(ReqIFConst.CREATION_TIME).item(0).getTextContent(); + this.creationDate = creationDateOf(this.creationTime); } if(theHeader.getElementsByTagName(ReqIFConst.TITLE).getLength() > 0) { - this.title = theHeader.getElementsByTagName(ReqIFConst.TITLE).item(0).getTextContent().replace("_Template", ""); + // The title is returned as written in the document; stripping a + // "_Template" suffix was a tool-specific hack in a generic parser. + this.title = theHeader.getElementsByTagName(ReqIFConst.TITLE).item(0).getTextContent(); + } + } + + /** + * Creates a header from plain values, for documents that are generated + * instead of parsed. The author is derived from the comment and the + * formatted creation date from the creation time, exactly as when reading. + * + * @param creationTime xsd:dateTime, e.g. 2026-07-23T10:00:00Z + */ + public ReqIFHeader(String id, String title, String comment, String creationTime, + String toolID, String sourceToolID, String reqifVersion) { + + this.id = id; + this.title = nullToEmpty(title); + this.comment = nullToEmpty(comment); + this.creationTime = nullToEmpty(creationTime); + this.toolID = toolID; + this.sourceToolID = nullToEmpty(sourceToolID); + this.reqifVersion = nullToEmpty(reqifVersion); + + this.author = authorOf(this.comment); + this.creationDate = creationDateOf(this.creationTime); + } + + /** + * The "Created by: " convention is tool-specific; a comment without it must + * not crash the parser. + */ + private static String authorOf(String comment) { + + int createdBy = comment == null ? -1 : comment.indexOf("Created by: "); + return createdBy < 0 ? "" : comment.substring(createdBy + "Created by: ".length()).trim(); + } + + private static String creationDateOf(String creationTime) { + + if(creationTime == null || creationTime.isEmpty()) { + return ""; } + String[] date = creationTime.split("T")[0].split("-"); + // unexpected format: keep the raw value instead of crashing + return date.length >= 3 ? date[2] + "." + date[1] + "." + date[0] : creationTime; + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/ExceptionSpecObject.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/ExceptionSpecObject.java index 0ebcd01..5f98559 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/ExceptionSpecObject.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/ExceptionSpecObject.java @@ -5,6 +5,20 @@ public class ExceptionSpecObject extends RuntimeException { public ExceptionSpecObject(String message, AttributeDefinition attributeDefinition) { - super(message + "Attribute Definition:\nID: " + attributeDefinition.getID() + "Name: " + attributeDefinition.getName() + "Type: " + attributeDefinition.getDataType()); + super(buildMessage(message, attributeDefinition)); + } + + private static String buildMessage(String message, AttributeDefinition attributeDefinition) { + + if (attributeDefinition == null) { + return message; + } + return message + + "Attribute Definition:\n" + + "ID: " + attributeDefinition.getID() + "\n" + + "Name: " + attributeDefinition.getName() + "\n" + + "Type: " + (attributeDefinition.getDataType() == null + ? "" + : attributeDefinition.getDataType().getType()); } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java index 610998f..7126f7b 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java @@ -152,6 +152,25 @@ public List getAllChildren() { + /** + * Creates a hierarchy node from plain values, for documents that are + * generated instead of parsed. + */ + public SpecHierarchy(String specHierarchyID, int hierarchyLvl, int section, SpecObject specObject, + List children) { + + this.specHierarchyID = specHierarchyID; + this.hierarchyLvl = hierarchyLvl; + this.section = section; + this.specObject = specObject; + + if(children != null) { + for(SpecHierarchy child: children) { + this.children.put(child.getSpecHierarchyID(), child); + } + } + } + public SpecHierarchy(int hierarchyLvl, int section, Node specHierarchy, Map specObjects) { this.hierarchyLvl = hierarchyLvl; diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java index 72d7645..76a7989 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java @@ -1,6 +1,7 @@ package de.uni_stuttgart.ils.reqif4j.specification; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -37,10 +38,18 @@ public String getType() { public String getSpecType() { return this.specType.getType(); } - + public String getSpecTypeName() { return this.specType.getName(); } + + /** + * @return the IDENTIFIER of the spec type this object refers to, or null if + * the reference could not be resolved + */ + public String getSpecTypeID() { + return this.specType == null ? null : this.specType.getID(); + } public Map getAttributes() { return this.attributeValues; @@ -82,6 +91,30 @@ public SpecObject(Node specObject){ this.id = specObject.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); } + /** + * Creates a spec object from plain values, for documents that are generated + * instead of parsed. The content category is derived by the classifier, as + * when reading. + */ + public SpecObject(String id, SpecType specType, Collection attributeValues, + TypeClassifier typeClassifier) { + + this.id = id; + this.specType = specType; + this.typeClassifier = typeClassifier == null ? TypeClassifier.defaultClassifier() : typeClassifier; + + if(attributeValues != null) { + for(AttributeValue attributeValue: attributeValues) { + this.attributeValues.put(attributeValue.getName(), attributeValue); + } + } + this.type = this.typeClassifier.classify(this); + } + + public SpecObject(String id, SpecType specType, Collection attributeValues) { + this(id, specType, attributeValues, TypeClassifier.defaultClassifier()); + } + public SpecObject(Node specObject, SpecType specType) { this(specObject, specType, TypeClassifier.defaultClassifier()); } @@ -92,6 +125,21 @@ public SpecObject(Node specObject, SpecType specType, TypeClassifier typeClassif this.specType = specType; this.typeClassifier = typeClassifier == null ? TypeClassifier.defaultClassifier() : typeClassifier; + readAttributeValues(specObject, specType); + + // Classify only after all attribute values are available, so an + // attribute-based classifier (e.g. the ReqIF Implementation Guide + // profile) can inspect them. + this.type = this.typeClassifier.classify(this); + } + + /** + * Reads all attribute values of the given node and fills in default values + * for attribute definitions that carry no explicit value. Shared by + * {@link SpecObject} and {@link SpecRelation}. + */ + protected void readAttributeValues(Node specObject, SpecType specType) { + if( ((Element)specObject).getElementsByTagName(ReqIFConst.VALUES).getLength() > 0 && ((Element)specObject).getElementsByTagName(ReqIFConst.VALUES).item(0).hasChildNodes() ) { @@ -215,11 +263,6 @@ public SpecObject(Node specObject, SpecType specType, TypeClassifier typeClassif } } } - - // Classify only after all attribute values are available, so an - // attribute-based classifier (e.g. the ReqIF Implementation Guide - // profile) can inspect them. - this.type = this.typeClassifier.classify(this); } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObjectType.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObjectType.java index e0b2289..39daf5d 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObjectType.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObjectType.java @@ -12,8 +12,16 @@ public class SpecObjectType extends SpecType { public SpecObjectType(Node specType, Map dataTypes) { super(specType, dataTypes); - + this.type = ReqIFConst.SPEC_OBJECT_TYPE; } + /** + * Creates a spec object type from plain values, for documents that are + * generated instead of parsed. + */ + public SpecObjectType(String id, String name) { + super(id, name, ReqIFConst.SPEC_OBJECT_TYPE); + } + } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java index e4454be..3a2a053 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java @@ -1,32 +1,116 @@ package de.uni_stuttgart.ils.reqif4j.specification; +import java.util.Collection; + +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; import org.w3c.dom.Element; import org.w3c.dom.Node; -public class SpecRelation extends SpecObject{ +/** + * A relation (link) between two spec objects. + * + * The inherited {@code type} field carries a content category + * (REQ/SUB-REQ/HEADLINE/TEXT) which does not apply to a relation, so it is set + * to {@link ReqIFConst#UNDEFINED}. The structural information "this is a + * relation" is available via {@link #getSpecType()} ("SPEC-RELATION-TYPE"), + * the relation's own type via {@link #getRelationTypeRef()} and + * {@link #getRelationTypeName()}. + */ +public class SpecRelation extends SpecObject { + private String sourceObjID; private String targetObjID; + private String relationTypeRef; + + /** + * Creates a relation from plain values, for documents that are generated + * instead of parsed. + */ + public SpecRelation(String id, SpecType specType, String sourceObjID, String targetObjID, + Collection attributeValues) { + super(id, specType, attributeValues); + + this.sourceObjID = sourceObjID; + this.targetObjID = targetObjID; + this.relationTypeRef = specType == null ? null : specType.getID(); + // a relation has no content category + this.type = ReqIFConst.UNDEFINED; + } public SpecRelation(Node specRelation, SpecType specType) { super(specRelation); this.specType = specType; - Element eSpecRelation = (Element)specRelation; + // Get target and source node - Element sourceNode = (Element) eSpecRelation.getElementsByTagName(ReqIFConst.SOURCE).item(0); - Element targetNode = (Element) eSpecRelation.getElementsByTagName(ReqIFConst.TARGET).item(0); - sourceObjID = sourceNode.getElementsByTagName(ReqIFConst.SPEC_OBJECT_REF).item(0).getTextContent(); - targetObjID = targetNode.getElementsByTagName(ReqIFConst.SPEC_OBJECT_REF).item(0).getTextContent(); - // Get relationship type - Element typeNode = (Element) eSpecRelation.getElementsByTagName(ReqIFConst.TYPE).item(0); - this.type = typeNode.getElementsByTagName(ReqIFConst.SPEC_RELATION_TYPE_REF).item(0).getTextContent(); + Element sourceNode = XmlUtils.firstChildElementByLocalName(specRelation, ReqIFConst.SOURCE); + Element targetNode = XmlUtils.firstChildElementByLocalName(specRelation, ReqIFConst.TARGET); + this.sourceObjID = specObjectRef(sourceNode); + this.targetObjID = specObjectRef(targetNode); + + // Get relationship type reference + Element typeNode = XmlUtils.firstChildElementByLocalName(specRelation, ReqIFConst.TYPE); + Element typeRef = XmlUtils.firstDescendantByLocalName(typeNode, ReqIFConst.SPEC_RELATION_TYPE_REF); + this.relationTypeRef = typeRef == null ? null : typeRef.getTextContent().trim(); + + // A relation has no content category; the relation type lives in + // relationTypeRef, not in the inherited type field. + this.type = ReqIFConst.UNDEFINED; + + if (specType != null) { + readAttributeValues(specRelation, specType); + } } public String getSourceObjID() { - return sourceObjID; + return this.sourceObjID; } public String getTargetObjID() { - return targetObjID; + return this.targetObjID; + } + + /** + * @return the IDENTIFIER of the SPEC-RELATION-TYPE this relation refers to + * (SPEC-RELATION-TYPE-REF), or null if the document declares none + */ + public String getRelationTypeRef() { + return this.relationTypeRef; + } + + /** + * @return the LONG-NAME of the relation type (e.g. "satisfies"), or null if + * the reference could not be resolved + */ + public String getRelationTypeName() { + return this.specType == null ? null : this.specType.getName(); + } + + // A relation is none of the content categories. + + @Override + public boolean isReq() { + return false; + } + + @Override + public boolean isSubReq() { + return false; + } + + @Override + public boolean isHeadline() { + return false; + } + + @Override + public boolean isText() { + return false; + } + + private static String specObjectRef(Element sourceOrTarget) { + Element ref = XmlUtils.firstDescendantByLocalName(sourceOrTarget, ReqIFConst.SPEC_OBJECT_REF); + return ref == null ? null : ref.getTextContent().trim(); } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelationType.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelationType.java index a0ff879..f1a3445 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelationType.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelationType.java @@ -12,8 +12,16 @@ public class SpecRelationType extends SpecType { public SpecRelationType(Node specType, Map dataTypes) { super(specType, dataTypes); - + this.type = ReqIFConst.SPEC_RELATION_TYPE; } + /** + * Creates a spec relation type from plain values, for documents that are + * generated instead of parsed. + */ + public SpecRelationType(String id, String name) { + super(id, name, ReqIFConst.SPEC_RELATION_TYPE); + } + } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java index a63d504..c63b6f9 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java @@ -100,6 +100,28 @@ public String getEnumValueOtherContent(String id) { + /** + * Creates a spec type from plain values, for documents that are generated + * instead of parsed. + * + * @param kind SPEC-OBJECT-TYPE, SPECIFICATION-TYPE or SPEC-RELATION-TYPE + */ + public SpecType(String id, String name, String kind) { + + this.id = id; + this.name = name; + this.type = kind; + } + + /** + * Adds an attribute definition; used when building a document. + */ + public SpecType addAttributeDefinition(AttributeDefinition attributeDefinition) { + + this.attributeDefinitions.put(attributeDefinition.getID(), attributeDefinition); + return this; + } + public SpecType(Node specType, Map dataTypes) { this.id = specType.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java index 0d0c860..5d10bd3 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java @@ -34,7 +34,6 @@ public class Specification { private Map attributeValues = new HashMap(); private Map children = new LinkedHashMap(); private List allSpecHierarchies = new ArrayList(); - //private Map> allSpecObjects = new HashMap>(); // TODO @@ -43,12 +42,14 @@ public String getID() { return this.id; } - /// + /** + * @return the value of an attribute named "Description", or null if the + * specification has no such attribute + */ public String getDescription() { AttributeValue description = this.attributeValues.get("Description"); return description == null ? null : (String) description.getValue(); } - //*/ public String getName() { return this.name; @@ -61,6 +62,14 @@ public String getSpecType() { public String getSpecTypeName() { return this.type.getName(); } + + /** + * @return the IDENTIFIER of the specification type, or null if the + * reference could not be resolved + */ + public String getSpecTypeID() { + return this.type == null ? null : this.type.getID(); + } public Map getAttributes() { return this.attributeValues; @@ -101,6 +110,39 @@ public List getAllSpecHierarchies() { + /** + * Creates a specification from plain values, for documents that are + * generated instead of parsed. + */ + public Specification(String id, String name, SpecType specType, + java.util.Collection attributeValues, List children) { + + this.id = id; + this.name = name; + this.type = specType; + + if(attributeValues != null) { + for(AttributeValue attributeValue: attributeValues) { + this.attributeValues.put(attributeValue.getName(), attributeValue); + } + } + + if(children != null) { + for(SpecHierarchy child: children) { + this.children.put(child.getSpecHierarchyID(), child); + this.sectionCounter++; + } + } + + List allChildren = new ArrayList(); + for(SpecHierarchy specHierarchy: this.children.values()) { + allChildren.add(specHierarchy); + allChildren.addAll(specHierarchy.getAllChildren()); + } + this.allSpecHierarchies.addAll(allChildren); + this.numberOfSpecObjects = allChildren.size(); + } + public Specification(Node specification, SpecType specType, Map specObjects) { this.id = specification.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecificationType.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecificationType.java index c3cce3e..d81202c 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecificationType.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecificationType.java @@ -12,8 +12,16 @@ public class SpecificationType extends SpecType { public SpecificationType(Node specType, Map dataTypes) { super(specType, dataTypes); - + this.type = ReqIFConst.SPECIFICATION_TYPE; } + /** + * Creates a specification type from plain values, for documents that are + * generated instead of parsed. + */ + public SpecificationType(String id, String name) { + super(id, name, ReqIFConst.SPECIFICATION_TYPE); + } + } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFElements.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFElements.java new file mode 100644 index 0000000..c3a73fe --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFElements.java @@ -0,0 +1,80 @@ +package de.uni_stuttgart.ils.reqif4j.write; + +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; + +/** + * Maps the parser's datatype categories to the ReqIF element names used when + * writing a document. + * + * Note that the parser normalizes {@code DATATYPE-DEFINITION-REAL} to the + * category {@link ReqIFConst#DOUBLE}; on the way out the standard name REAL is + * emitted again. + */ +final class ReqIFElements { + + private ReqIFElements() { + } + + /** + * @return the ReqIF name of a datatype category (e.g. "REAL" for DOUBLE) + */ + static String datatypeSuffix(String datatypeCategory) { + + if (datatypeCategory == null) { + return null; + } + switch (datatypeCategory) { + case ReqIFConst.DOUBLE: + case ReqIFConst.REAL: return ReqIFConst.REAL; + case ReqIFConst.BOOLEAN: + case ReqIFConst.INTEGER: + case ReqIFConst.STRING: + case ReqIFConst.DATE: + case ReqIFConst.XHTML: + case ReqIFConst.ENUMERATION: return datatypeCategory; + default: return null; + } + } + + /** @return e.g. DATATYPE-DEFINITION-STRING */ + static String datatypeDefinition(String datatypeCategory) { + String suffix = datatypeSuffix(datatypeCategory); + return suffix == null ? null : "DATATYPE-DEFINITION-" + suffix; + } + + /** @return e.g. DATATYPE-DEFINITION-STRING-REF */ + static String datatypeDefinitionRef(String sourceElementName) { + return sourceElementName + "-REF"; + } + + /** @return e.g. ATTRIBUTE-DEFINITION-STRING */ + static String attributeDefinition(String datatypeCategory) { + String suffix = datatypeSuffix(datatypeCategory); + return suffix == null ? null : "ATTRIBUTE-DEFINITION-" + suffix; + } + + /** @return e.g. ATTRIBUTE-DEFINITION-STRING-REF */ + static String attributeDefinitionRef(String datatypeCategory) { + String definition = attributeDefinition(datatypeCategory); + return definition == null ? null : definition + "-REF"; + } + + /** @return e.g. ATTRIBUTE-VALUE-STRING */ + static String attributeValue(String datatypeCategory) { + String suffix = datatypeSuffix(datatypeCategory); + return suffix == null ? null : "ATTRIBUTE-VALUE-" + suffix; + } + + /** + * @return the *-TYPE-REF element name belonging to a spec type kind + * (SPEC-OBJECT-TYPE, SPECIFICATION-TYPE, SPEC-RELATION-TYPE) + */ + static String specTypeRef(String specTypeKind) { + + if (ReqIFConst.SPECIFICATION_TYPE.equals(specTypeKind)) { + // the constant deliberately differs from the element name + return ReqIFConst.SPEC_TYPE_REF; + } + return specTypeKind + "-REF"; + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriteException.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriteException.java new file mode 100644 index 0000000..54196a3 --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriteException.java @@ -0,0 +1,15 @@ +package de.uni_stuttgart.ils.reqif4j.write; + +/** + * Thrown when a ReqIF document cannot be serialized. + */ +public class ReqIFWriteException extends RuntimeException { + + public ReqIFWriteException(String message, Throwable cause) { + super(message, cause); + } + + public ReqIFWriteException(String message) { + super(message); + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java new file mode 100644 index 0000000..2d03975 --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java @@ -0,0 +1,609 @@ +package de.uni_stuttgart.ils.reqif4j.write; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.xml.sax.SAXException; + +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinition; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinitionEnumeration; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueEnumeration; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTML; +import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumeration; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumerationValue; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeInteger; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeString; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFCoreContent; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFHeader; +import de.uni_stuttgart.ils.reqif4j.specification.SpecHierarchy; +import de.uni_stuttgart.ils.reqif4j.specification.SpecObject; +import de.uni_stuttgart.ils.reqif4j.specification.SpecRelation; +import de.uni_stuttgart.ils.reqif4j.specification.SpecType; +import de.uni_stuttgart.ils.reqif4j.specification.Specification; + +/** + * Serializes a parsed {@link ReqIFDocument} back to ReqIF XML. + * + * The writer works on the object model, not on the source DOM, so a document + * that was assembled or modified programmatically can be written just like one + * that was read from a file: + * + *
+ * 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) { + List refs = ((AttributeDefinitionEnumeration) definition).getDefaultValueRefs(); + if (refs.isEmpty()) { + return null; + } + Element defaultValue = element(xml, ReqIFConst.DEFAULT_VALUE); + defaultValue.appendChild(enumerationValue(xml, definition, refs)); + return defaultValue; + } + + if (definition.getDefaultValue() == null) { + return null; + } + String elementName = ReqIFElements.attributeValue(datatype.getType()); + if (elementName == null) { + return null; + } + + Element value = element(xml, elementName); + value.setAttribute(ReqIFConst.THE_VALUE, definition.getDefaultValue()); + value.appendChild(definitionRef(xml, definition)); + + Element defaultValue = element(xml, ReqIFConst.DEFAULT_VALUE); + defaultValue.appendChild(value); + return defaultValue; + } + + + private Element specObject(Document xml, SpecObject specObject) { + + Element element = element(xml, ReqIFConst.SPEC_OBJECT); + element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(specObject.getID())); + element.appendChild(values(xml, specObject.getAttributes())); + element.appendChild(typeRef(xml, ReqIFConst.SPEC_OBJECT_TYPE, specObject.getSpecTypeID())); + return element; + } + + private Element specRelation(Document xml, SpecRelation specRelation) { + + Element element = element(xml, ReqIFConst.SPEC_RELATION); + element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(specRelation.getID())); + element.appendChild(values(xml, specRelation.getAttributes())); + element.appendChild(typeRef(xml, ReqIFConst.SPEC_RELATION_TYPE, specRelation.getRelationTypeRef())); + element.appendChild(objectRef(xml, ReqIFConst.SOURCE, specRelation.getSourceObjID())); + element.appendChild(objectRef(xml, ReqIFConst.TARGET, specRelation.getTargetObjID())); + return element; + } + + private Element specification(Document xml, Specification specification) { + + Element element = element(xml, ReqIFConst.SPECIFICATION); + element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(specification.getID())); + element.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(specification.getName())); + element.appendChild(values(xml, specification.getAttributes())); + element.appendChild(typeRef(xml, ReqIFConst.SPECIFICATION_TYPE, specification.getSpecTypeID())); + + List children = specification.getChildren(); + if (!children.isEmpty()) { + element.appendChild(children(xml, children)); + } + return element; + } + + private Element children(Document xml, List hierarchies) { + + Element children = element(xml, ReqIFConst.CHILDREN); + for (SpecHierarchy hierarchy : hierarchies) { + children.appendChild(specHierarchy(xml, hierarchy)); + } + return children; + } + + private Element specHierarchy(Document xml, SpecHierarchy hierarchy) { + + Element element = element(xml, ReqIFConst.SPEC_HIERARCHY); + element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(hierarchy.getSpecHierarchyID())); + + if (hierarchy.getSpecObject() != null) { + element.appendChild(objectRef(xml, ReqIFConst.OBJECT, hierarchy.getSpecObjectID())); + } + List nested = hierarchy.getChildren(); + if (!nested.isEmpty()) { + element.appendChild(children(xml, nested)); + } + return element; + } + + + private Element values(Document xml, java.util.Map attributeValues) { + + Element values = element(xml, ReqIFConst.VALUES); + + List sorted = new ArrayList(attributeValues.values()); + // deterministic output regardless of the map implementation + sorted.sort(Comparator.comparing(value -> { + AttributeDefinition definition = value.getAttributeDefinitionType(); + return definition == null || definition.getID() == null ? "" : definition.getID(); + })); + + for (AttributeValue attributeValue : sorted) { + Element written = attributeValue(xml, attributeValue); + if (written != null) { + values.appendChild(written); + } + } + return values; + } + + private Element attributeValue(Document xml, AttributeValue attributeValue) { + + AttributeDefinition definition = attributeValue.getAttributeDefinitionType(); + if (definition == null || definition.getDataType() == null) { + return null; + } + String datatypeCategory = definition.getDataType().getType(); + + if (ReqIFConst.ENUMERATION.equals(datatypeCategory)) { + if (!(attributeValue instanceof AttributeValueEnumeration)) { + return null; + } + List refs = ((AttributeValueEnumeration) attributeValue).getValueRefs(); + if (refs.isEmpty()) { + return null; + } + return enumerationValue(xml, definition, refs); + } + + if (ReqIFConst.XHTML.equals(datatypeCategory)) { + return xhtmlValue(xml, definition, attributeValue); + } + + String elementName = ReqIFElements.attributeValue(datatypeCategory); + if (elementName == null) { + return null; + } + Object value = attributeValue.getValue(); + if (value == null) { + return null; + } + + Element element = element(xml, elementName); + element.setAttribute(ReqIFConst.THE_VALUE, String.valueOf(value)); + element.appendChild(definitionRef(xml, definition)); + return element; + } + + private Element enumerationValue(Document xml, AttributeDefinition definition, List enumValueRefs) { + + Element element = element(xml, ReqIFElements.attributeValue(ReqIFConst.ENUMERATION)); + element.appendChild(definitionRef(xml, definition)); + + Element values = element(xml, ReqIFConst.VALUES); + for (String ref : enumValueRefs) { + Element enumRef = element(xml, ReqIFConst.ENUM_VALUE_REF); + enumRef.setTextContent(ref); + values.appendChild(enumRef); + } + element.appendChild(values); + return element; + } + + private Element xhtmlValue(Document xml, AttributeDefinition definition, AttributeValue attributeValue) { + + Element element = element(xml, ReqIFElements.attributeValue(ReqIFConst.XHTML)); + element.appendChild(definitionRef(xml, definition)); + + Element theValue = element(xml, ReqIFConst.THE_VALUE); + Node content = xhtmlContent(xml, attributeValue); + if (content != null) { + theValue.appendChild(content); + } + element.appendChild(theValue); + return element; + } + + /** + * Reuses the source nodes of a parsed document; for programmatically built + * values the rendered markup is parsed back into the xhtml namespace. + */ + private Node xhtmlContent(Document xml, AttributeValue attributeValue) { + + if (attributeValue instanceof AttributeValueXHTML) { + AttributeValueXHTML xhtmlValue = (AttributeValueXHTML) attributeValue; + if (xhtmlValue.getDivValue() != null && xhtmlValue.getDivValue().getNode() != null) { + return xml.importNode(xhtmlValue.getDivValue().getNode(), true); + } + } + + Object value = attributeValue.getValue(); + if (value == null || value.toString().isEmpty()) { + return null; + } + return xml.importNode(parseXhtml(value.toString()), true); + } + + private Node parseXhtml(String markup) { + + String namespaced = markup.startsWith("" + markup + ""; + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + return factory.newDocumentBuilder() + .parse(new ByteArrayInputStream(namespaced.getBytes(StandardCharsets.UTF_8))) + .getDocumentElement(); + } catch (SAXException | IOException | ParserConfigurationException e) { + throw new ReqIFWriteException("XHTML attribute value is not well-formed: " + markup, e); + } + } + + + private Element definitionRef(Document xml, AttributeDefinition definition) { + + Element definitionElement = element(xml, ReqIFConst.DEFINITION); + Element ref = element(xml, ReqIFElements.attributeDefinitionRef(definition.getDataType().getType())); + ref.setTextContent(nullToEmpty(definition.getID())); + definitionElement.appendChild(ref); + return definitionElement; + } + + private Element typeRef(Document xml, String specTypeKind, String specTypeID) { + + Element type = element(xml, ReqIFConst.TYPE); + Element ref = element(xml, ReqIFElements.specTypeRef(specTypeKind)); + ref.setTextContent(nullToEmpty(specTypeID)); + type.appendChild(ref); + return type; + } + + private Element objectRef(Document xml, String wrapper, String specObjectID) { + + Element element = element(xml, wrapper); + Element ref = element(xml, ReqIFConst.SPEC_OBJECT_REF); + ref.setTextContent(nullToEmpty(specObjectID)); + element.appendChild(ref); + return element; + } + + private Element element(Document xml, String name) { + return xml.createElementNS(REQIF_NAMESPACE, name); + } + + private void appendTextIfPresent(Document xml, Element parent, String name, String text) { + + if (text == null || text.isEmpty()) { + return; + } + Element element = element(xml, name); + element.setTextContent(text); + parent.appendChild(element); + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + private Document newDocument() { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.newDocument(); + } catch (ParserConfigurationException e) { + throw new ReqIFWriteException("Failed to create an XML document", e); + } + } + + private Transformer transformer() { + try { + Transformer transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.ENCODING, StandardCharsets.UTF_8.name()); + transformer.setOutputProperty(OutputKeys.INDENT, this.indent ? "yes" : "no"); + if (this.indent) { + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); + } + return transformer; + } catch (TransformerException e) { + throw new ReqIFWriteException("Failed to create an XML transformer", e); + } + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLElement.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLElement.java index 608f3c2..3b82a7c 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLElement.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLElement.java @@ -65,6 +65,9 @@ private void addChildren(Node xhtmlElement) { case XHTML.OBJECT: this.children.add(new XHTMLElementObject(childNode, this)); break; + + case XHTML.OL: this.children.add(new XHTMLElementOl(childNode, this)); + break; case XHTML.P: this.children.add(new XHTMLElementP(childNode, this)); break; @@ -72,7 +75,9 @@ private void addChildren(Node xhtmlElement) { case XHTML.SPAN: this.children.add(new XHTMLElementSpan(childNode, this)); break; - case XHTML._TEXT: if(!childNode.getTextContent().trim().isEmpty()) { + // Whitespace-only text nodes are kept: they carry the + // spacing between adjacent inline elements. + case XHTML._TEXT: if(!childNode.getTextContent().isEmpty()) { this.children.add(new XHTMLElementText(childNode, this)); } break; @@ -101,7 +106,16 @@ private void addChildren(Node xhtmlElement) { case XHTML.VAR: this.children.add(new XHTMLElementVar(childNode, this)); break; - default: this.children.add(new XHTMLNode(childNode, this)); + // Elements without a dedicated class (a, em, strong, ...) + // must still be parsed as elements, otherwise their + // whole content is lost on output. + default: if(childNode.getNodeType() == Node.ELEMENT_NODE) { + this.children.add(new XHTMLElement(childNode, this)); + + }else if(childNode.getNodeType() == Node.CDATA_SECTION_NODE) { + this.children.add(new XHTMLElementText(childNode, this)); + } + // comments and processing instructions are dropped break; } } @@ -110,8 +124,13 @@ private void addChildren(Node xhtmlElement) { @Override public String toString() { StringBuilder sb = new StringBuilder(); - sb.append('<').append(tagName).append('>'); - sb.append((!children.isEmpty() ? this.listToString(children) : "")); + sb.append('<').append(tagName).append(attributesToString()); + if(children.isEmpty() && isVoidElement()) { + sb.append("/>"); + return sb.toString(); + } + sb.append('>'); + sb.append(this.listToString(children)); sb.append("'); return sb.toString(); } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLElementObject.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLElementObject.java index c8e32cc..7395376 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLElementObject.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLElementObject.java @@ -57,20 +57,7 @@ private void readAttributes(Node xhtmlElement) { this.mimeType = XmlUtils.attribute(xhtmlElement, "type"); } - @Override - public String toString() { - StringBuilder sb = new StringBuilder(); - sb.append('<').append(tagName); - if (data != null) { - sb.append(" data=\"").append(data).append('"'); - } - if (mimeType != null) { - sb.append(" type=\"").append(mimeType).append('"'); - } - sb.append('>'); - sb.append((!children.isEmpty() ? this.listToString(children) : "")); - sb.append("'); - return sb.toString(); - } + // toString() is inherited: XHTMLElement renders all attributes generically, + // so data, type, width, height and alt are preserved. } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLElementOl.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLElementOl.java new file mode 100644 index 0000000..a85fb38 --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLElementOl.java @@ -0,0 +1,15 @@ +package de.uni_stuttgart.ils.reqif4j.xhtml; + +import org.w3c.dom.Node; + +public class XHTMLElementOl extends XHTMLElement { + + public XHTMLElementOl (Node xhtmlElement) { + super(xhtmlElement); + } + + public XHTMLElementOl(Node xhtmlElement, XHTMLNode parent) { + super(xhtmlElement, parent); + } + +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLElementText.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLElementText.java index 9f8cb10..342b13d 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLElementText.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLElementText.java @@ -5,32 +5,45 @@ import de.uni_stuttgart.ils.reqif4j.attributes.XHTML; public class XHTMLElementText extends XHTMLLeaf { - + private String textContent; - + private String renderedText; + + /** + * @return the text with surrounding whitespace removed + */ public String getTextContent() { return this.textContent; } - - + + public XHTMLElementText (Node xhtmlElement) { super(xhtmlElement); - + this.tagName = XHTML.TEXT; - this.textContent = xhtmlElement.getTextContent().trim(); + readText(xhtmlElement); } public XHTMLElementText(Node xhtmlElement, XHTMLNode parent) { super(xhtmlElement, parent); - + this.tagName = XHTML.TEXT; - this.textContent = xhtmlElement.getTextContent().trim(); + readText(xhtmlElement); } - + + private void readText(Node xhtmlElement) { + + String raw = xhtmlElement.getTextContent(); + this.textContent = raw.trim(); + // Keep the spacing around the text for rendering; trimming it would run + // words together across inline elements ("Siehe Link"). + this.renderedText = raw.replaceAll("\\s+", " "); + } + @Override public String toString() { - return textContent; + return escapeText(this.renderedText); } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLNode.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLNode.java index 610ec0d..d822535 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLNode.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLNode.java @@ -1,14 +1,26 @@ package de.uni_stuttgart.ils.reqif4j.xhtml; +import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; public class XHTMLNode { + /** + * Elements that must be rendered self-closing; a void element written as + * {@code

} is read by HTML5 parsers as two line breaks. + */ + private static final Set VOID_ELEMENTS = new HashSet(Arrays.asList( + "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", + "source", "track", "wbr")); + protected String tagName; protected XHTMLNode parent = null; protected Node node; @@ -20,16 +32,16 @@ public class XHTMLNode { public String getTagName() { return this.tagName; } - + /** * @return the parent XHTMLNode of this XHTMLNode */ public XHTMLNode getParent() { return this.parent; } - - - + + + public XHTMLNode(Node xhtmlElement) { this.node = xhtmlElement; @@ -41,12 +53,16 @@ public XHTMLNode(Node xhtmlElement, XHTMLNode parent) { this.tagName = XmlUtils.localName(xhtmlElement); this.parent = parent; } - + @Override public String toString() { StringBuilder sb = new StringBuilder(); - sb.append("<").append(tagName).append(">"); - sb.append(""); + sb.append('<').append(tagName).append(attributesToString()); + if(isVoidElement()) { + sb.append("/>"); + }else{ + sb.append(">'); + } return sb.toString(); } @@ -61,4 +77,54 @@ protected String listToString(List list){ } return sb.toString(); } + + /** + * @return true if this element must be rendered self-closing + */ + protected boolean isVoidElement() { + return VOID_ELEMENTS.contains(this.tagName.toLowerCase()); + } + + /** + * Renders all XML attributes of this node. Namespace declarations are + * skipped because tag names are emitted without their prefix. + */ + protected String attributesToString() { + + if(this.node == null || this.node.getAttributes() == null) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + NamedNodeMap attributes = this.node.getAttributes(); + for(int attribute = 0; attribute < attributes.getLength(); attribute++) { + + Node attributeNode = attributes.item(attribute); + String name = attributeNode.getNodeName(); + if(name.equals("xmlns") || name.startsWith("xmlns:")) { + continue; + } + sb.append(' ').append(XmlUtils.localName(attributeNode)) + .append("=\"").append(escapeAttribute(attributeNode.getNodeValue())).append('"'); + } + return sb.toString(); + } + + /** + * Escapes the characters that must not appear literally in element content. + */ + protected static String escapeText(String text) { + if(text == null) { + return ""; + } + return text.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + /** + * Escapes the characters that must not appear literally in a quoted + * attribute value. + */ + protected static String escapeAttribute(String value) { + return escapeText(value).replace("\"", """); + } } diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/CodeQualityFixesTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/CodeQualityFixesTest.java new file mode 100644 index 0000000..6e4d643 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/CodeQualityFixesTest.java @@ -0,0 +1,135 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +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.attributes.AttributeDefinition; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueDate; +import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFHeader; +import de.uni_stuttgart.ils.reqif4j.specification.ExceptionSpecObject; +import de.uni_stuttgart.ils.reqif4j.specification.Specification; + +/** + * Section 5 of the analysis: field shadowing in the datatype classes, the + * unparsed DATE value, the tool-specific "_Template" hack in the header, the + * unreadable ExceptionSpecObject message and the commented-out getComment(). + */ +class CodeQualityFixesTest { + + private Path tempDir; + private ReqIF reqif; + + @BeforeEach + void parseFixture(@TempDir Path tempDir) throws Exception { + this.tempDir = tempDir; + this.reqif = parse(TestFixtures.REQIF_FIXTURE); + } + + private ReqIF parse(String content) throws Exception { + return new ReqIF(TestFixtures.write(tempDir, "test.reqif", content).toString()); + } + + /** @return the DATE attribute definition of the fixture's specification type */ + private AttributeDefinition dateDefinition() { + return reqif.getReqIFCoreContent().getSpecType("st-spec").getAttributeDefinition("ad-review"); + } + + @Test + void datatypesReportIdAndNameAfterRemovingShadowedFields() { + Datatype bool = reqif.getReqIFCoreContent().getDatatype("dt-bool"); + assertEquals("dt-bool", bool.getID()); + assertEquals("Bool", bool.getName()); + + Datatype xhtml = reqif.getReqIFCoreContent().getDatatype("dt-xhtml"); + assertEquals("dt-xhtml", xhtml.getID()); + assertEquals("XhtmlType", xhtml.getName()); + } + + @Test + void dateValueIsParsedInAdditionToTheRawString() { + Specification spec = reqif.getReqIFCoreContent().getSpecification("spec-1"); + AttributeValueDate reviewDate = (AttributeValueDate) spec.getAttributes().get("ReviewDate"); + + assertEquals("2026-01-01", reviewDate.getValue(), "the raw string stays available"); + assertEquals(OffsetDateTime.of(2026, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC), reviewDate.getDateTime()); + assertEquals("2026-01-01", reviewDate.getDate().toString()); + } + + @Test + void dateValueAcceptsFullTimestamps() { + AttributeDefinition definition = dateDefinition(); + + assertEquals(OffsetDateTime.of(2026, 1, 1, 10, 30, 0, 0, ZoneOffset.ofHours(2)), + new AttributeValueDate("2026-01-01T10:30:00+02:00", definition).getDateTime()); + assertEquals(OffsetDateTime.of(2026, 1, 1, 10, 30, 0, 0, ZoneOffset.UTC), + new AttributeValueDate("2026-01-01T10:30:00", definition).getDateTime(), + "a timestamp without offset is read as UTC"); + } + + @Test + void unparseableDateValuesYieldNullInsteadOfThrowing() { + AttributeDefinition definition = dateDefinition(); + + assertNull(new AttributeValueDate("not a date", definition).getDateTime()); + assertNull(new AttributeValueDate(null, definition).getDateTime()); + assertNull(new AttributeValueDate("", definition).getDateTime()); + assertNull(new AttributeValueDate("not a date", definition).getDate()); + } + + @Test + void headerReturnsTheTitleAsWritten() throws Exception { + ReqIFHeader header = parse(TestFixtures.REQIF_FIXTURE + .replace("TestDoc", "TestDoc_Template")).getReqIFHeader(); + + assertEquals("TestDoc_Template", header.getTitle(), + "the generic parser must not strip a tool-specific suffix"); + } + + @Test + void headerExposesTheComment() { + ReqIFHeader header = reqif.getReqIFHeader(); + + assertEquals("Created by: Tester", header.getComment()); + assertEquals("Tester", header.getAuthor(), "the author is still derived from the comment"); + } + + @Test + void headerWithoutCommentReturnsEmptyString() throws Exception { + ReqIFHeader header = parse(TestFixtures.REQIF_FIXTURE + .replace("Created by: Tester", "")).getReqIFHeader(); + + assertEquals("", header.getComment()); + } + + @Test + void exceptionMessageSeparatesTheFields() { + AttributeDefinition definition = reqif.getReqIFCoreContent() + .getSpecType("st-req").getAttributeDefinition("ad-title"); + assertNotNull(definition); + + String message = new ExceptionSpecObject("Broken\n", definition).getMessage(); + + assertTrue(message.contains("ID: ad-title\n"), "fields must be separated by line breaks: " + message); + assertTrue(message.contains("Name: Title\n"), "fields must be separated by line breaks: " + message); + assertTrue(message.contains("Type: STRING"), + "the datatype must be readable, not an object dump: " + message); + } + + @Test + void exceptionMessageToleratesAMissingDefinition() { + assertEquals("Attribute definition not existing\n", + new ExceptionSpecObject("Attribute definition not existing\n", null).getMessage()); + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFBuilderTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFBuilderTest.java new file mode 100644 index 0000000..569b557 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFBuilderTest.java @@ -0,0 +1,260 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.List; + +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.attributes.AttributeValueEnumeration; +import de.uni_stuttgart.ils.reqif4j.build.ReqIFBuildException; +import de.uni_stuttgart.ils.reqif4j.build.ReqIFBuilder; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumeration; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; +import de.uni_stuttgart.ils.reqif4j.specification.SpecHierarchy; +import de.uni_stuttgart.ils.reqif4j.specification.SpecObject; +import de.uni_stuttgart.ils.reqif4j.specification.SpecRelation; +import de.uni_stuttgart.ils.reqif4j.specification.Specification; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter; + +/** + * Documents created from scratch with the builder must be writable and read + * back identically - that is what turns the parser into a generator. + */ +class ReqIFBuilderTest { + + private Path tempDir; + private ReqIF written; + + /** A document covering every value kind, built entirely in code. */ + private static ReqIFDocument buildDocument() { + return ReqIFBuilder.create() + .header(h -> h + .id("hdr-1") + .title("Generated Spec") + .comment("Created by: reqif4j") + .creationTime("2026-07-23T10:00:00Z") + .toolID("reqif4j") + .sourceToolID("unit test") + .reqifVersion("1.0")) + .stringDatatype("dt-string", "String", 4096) + .integerDatatype("dt-int", "Integer", 0, 9223372036854775807L) + .booleanDatatype("dt-bool", "Boolean") + .dateDatatype("dt-date", "Date") + .realDatatype("dt-real", "Real") + .xhtmlDatatype("dt-xhtml", "XHTML") + .enumerationDatatype("dt-enum", "Color", e -> e + .value("ev-red", "Red", "1", "#ff0000") + .value("ev-green", "Green", "2") + .value("ev-blue", "Blue", "3")) + .specObjectType("st-req", "Requirement Type", t -> t + .stringAttribute("ad-title", "ReqIF.Name", "dt-string") + .integerAttribute("ad-prio", "Priority", "dt-int") + .booleanAttribute("ad-done", "Done", "dt-bool") + .dateAttribute("ad-due", "Due", "dt-date") + .realAttribute("ad-effort", "Effort", "dt-real") + .xhtmlAttribute("ad-text", "ReqIF.Text", "dt-xhtml") + .enumerationAttribute("ad-color", "Colors", "dt-enum", true, + List.of("ev-blue"))) + .specificationType("st-spec", "Specification Type", t -> t + .stringAttribute("ad-owner", "Owner", "dt-string")) + .specRelationType("st-rel", "satisfies", t -> t + .stringAttribute("ad-comment", "LinkComment", "dt-string")) + .specObject("so-1", "st-req", o -> o + .set("ad-title", "First requirement") + .set("ad-prio", 5) + .set("ad-done", true) + .set("ad-due", "2026-12-31") + .set("ad-effort", 2.5) + .setEnum("ad-color", "ev-red", "ev-green") + .setXhtml("ad-text", "

The 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, + "markup passed without a surrounding div must be wrapped"); + } + + @Test + void relationsAndTheirAttributesAreWritten() { + SpecRelation relation = written.getReqIFCoreContent().getSpecRelation("sr-1"); + + assertNotNull(relation); + assertEquals("so-1", relation.getSourceObjID()); + assertEquals("so-2", relation.getTargetObjID()); + assertEquals("st-rel", relation.getRelationTypeRef()); + assertEquals("satisfies", relation.getRelationTypeName()); + assertEquals("derived during review", relation.getAttribute("LinkComment")); + } + + @Test + void specificationHierarchyIsWritten() { + Specification spec = written.getReqIFCoreContent().getSpecification("spec-1"); + + assertEquals("Main Spec", spec.getName()); + assertEquals("Tester", spec.getAttribute("Owner")); + + List children = spec.getChildren(); + assertEquals(1, children.size()); + assertEquals("sh-1", children.get(0).getSpecHierarchyID()); + assertEquals("so-1", children.get(0).getSpecObjectID()); + + List nested = children.get(0).getChildren(); + assertEquals(1, nested.size()); + assertEquals("sh-2", nested.get(0).getSpecHierarchyID()); + assertEquals("so-2", nested.get(0).getSpecObjectID()); + } + + @Test + void classificationRunsOnGeneratedObjects() { + // "Requirement Type" contains "req", so the default heuristic applies + assertEquals("REQ", written.getReqIFCoreContent().getSpecObject("so-1").getType()); + } + + @Test + void generatedDocumentRoundTripsAgain() throws Exception { + Path second = tempDir.resolve("second.reqif"); + new ReqIFWriter().write(written.getReqIFDocument(), second); + + assertEquals(new ReqIFWriter().toXml(written.getReqIFDocument()), + new ReqIFWriter().toXml(new ReqIF(second.toString()).getReqIFDocument()), + "generated documents must be stable across further round trips"); + } + + + @Test + void unknownReferencesFailWhileBuilding() { + assertThrows(ReqIFBuildException.class, () -> ReqIFBuilder.create() + .specObjectType("st", "T", t -> t.stringAttribute("ad", "A", "does-not-exist")), + "an unknown datatype must be rejected"); + + assertThrows(ReqIFBuildException.class, () -> ReqIFBuilder.create() + .specObject("so", "does-not-exist"), + "an unknown spec type must be rejected"); + + assertThrows(ReqIFBuildException.class, () -> ReqIFBuilder.create() + .stringDatatype("dt", "S", 10) + .specObjectType("st", "T", t -> t.stringAttribute("ad", "A", "dt")) + .specObject("so-1", "st", o -> o.set("nope", "x")), + "an unknown attribute definition must be rejected"); + } + + @Test + void wrongValueKindIsRejected() { + assertThrows(ReqIFBuildException.class, () -> ReqIFBuilder.create() + .xhtmlDatatype("dt-xhtml", "X") + .specObjectType("st", "T", t -> t.xhtmlAttribute("ad", "A", "dt-xhtml")) + .specObject("so-1", "st", o -> o.set("ad", "plain text")), + "an XHTML attribute must be set with setXhtml"); + + assertThrows(ReqIFBuildException.class, () -> ReqIFBuilder.create() + .enumerationDatatype("dt-enum", "E", e -> e.value("ev-1", "One", "1")) + .specObjectType("st", "T", t -> t.enumerationAttribute("ad", "A", "dt-enum", false)) + .specObject("so-1", "st", o -> o.setEnum("ad", "ev-unknown")), + "an unknown enum value must be rejected"); + } + + @Test + void datatypeKindMismatchIsRejected() { + assertThrows(ReqIFBuildException.class, () -> ReqIFBuilder.create() + .stringDatatype("dt-string", "S", 10) + .specObjectType("st", "T", t -> t.integerAttribute("ad", "A", "dt-string")), + "using a string datatype for an integer attribute must be rejected"); + } + + @Test + void writtenXmlIsWellFormedReqIF() { + String xml = new ReqIFWriter().toXml(buildDocument()); + + assertTrue(xml.contains("st-rel")); + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFWriterTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFWriterTest.java new file mode 100644 index 0000000..b6a347f --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFWriterTest.java @@ -0,0 +1,185 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +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.attributes.AttributeValueEnumeration; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumeration; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeInteger; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.specification.SpecHierarchy; +import de.uni_stuttgart.ils.reqif4j.specification.SpecObject; +import de.uni_stuttgart.ils.reqif4j.specification.SpecRelation; +import de.uni_stuttgart.ils.reqif4j.specification.Specification; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter; + +/** + * The writer serializes the object model back to ReqIF XML. Round-tripping + * (parse -> write -> parse) must preserve the content, which is the basis for + * generating documents rather than only reading them. + */ +class ReqIFWriterTest { + + private Path tempDir; + private ReqIF original; + private ReqIF roundTripped; + + @BeforeEach + void roundTrip(@TempDir Path tempDir) throws Exception { + this.tempDir = tempDir; + this.original = new ReqIF(TestFixtures.writeDefaultFixture(tempDir).toString()); + this.roundTripped = writeAndReadBack(this.original); + } + + private ReqIF writeAndReadBack(ReqIF reqif) throws Exception { + Path out = tempDir.resolve("written.reqif"); + new ReqIFWriter().write(reqif.getReqIFDocument(), out); + return new ReqIF(out.toString()); + } + + @Test + void writtenDocumentIsReadableAgain() throws Exception { + Path out = tempDir.resolve("readable.reqif"); + new ReqIFWriter().write(original.getReqIFDocument(), out); + + assertTrue(Files.size(out) > 0, "the writer must produce output"); + assertNotNull(new ReqIF(out.toString()).getReqIFCoreContent()); + } + + @Test + void headerSurvivesTheRoundTrip() { + assertEquals(original.getReqIFHeader().getID(), roundTripped.getReqIFHeader().getID()); + assertEquals(original.getReqIFHeader().getTitle(), roundTripped.getReqIFHeader().getTitle()); + assertEquals(original.getReqIFHeader().getToolID(), roundTripped.getReqIFHeader().getToolID()); + assertEquals(original.getReqIFHeader().getSourceToolID(), roundTripped.getReqIFHeader().getSourceToolID()); + assertEquals(original.getReqIFHeader().getReqIFVersion(), roundTripped.getReqIFHeader().getReqIFVersion()); + assertEquals(original.getReqIFHeader().getComment(), roundTripped.getReqIFHeader().getComment()); + assertEquals(original.getReqIFHeader().getCreationTime(), roundTripped.getReqIFHeader().getCreationTime(), + "the raw CREATION-TIME must be written, not the reformatted date"); + } + + @Test + void datatypesSurviveTheRoundTrip() { + assertEquals(original.getReqIFCoreContent().getDatatypes().keySet(), + roundTripped.getReqIFCoreContent().getDatatypes().keySet()); + + DatatypeInteger integer = (DatatypeInteger) roundTripped.getReqIFCoreContent().getDatatype("dt-int"); + assertEquals(0L, integer.getMin()); + assertEquals(Long.MAX_VALUE, integer.getMax()); + + // a datatype kind the parser does not model keeps its element name + assertEquals(ReqIFConst.UNDEFINED, + roundTripped.getReqIFCoreContent().getDatatype("dt-custom").getType()); + } + + @Test + void enumerationValuesSurviveTheRoundTrip() { + DatatypeEnumeration colors = (DatatypeEnumeration) roundTripped.getReqIFCoreContent().getDatatype("dt-enum"); + + assertEquals(List.of("ev-red", "ev-green", "ev-blue"), List.copyOf(colors.getEnumValues().keySet())); + assertEquals("Red", colors.getEnumValueName("ev-red")); + assertEquals("1", colors.getEnumValueKey("ev-red")); + assertEquals("#ff0000", colors.getEnumValueOtherContent("ev-red")); + } + + @Test + void multiselectEnumValuesSurviveTheRoundTrip() { + SpecObject so1 = roundTripped.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 enumDefaultValueSurvivesTheRoundTrip() { + SpecObject so2 = roundTripped.getReqIFCoreContent().getSpecObject("so-2"); + AttributeValueEnumeration colors = (AttributeValueEnumeration) so2.getAttributes().get("Colors"); + + assertEquals(List.of("Blue"), colors.getValues(), + "the DEFAULT-VALUE enum reference must be written back"); + } + + @Test + void scalarAttributeValuesSurviveTheRoundTrip() { + SpecObject so1 = roundTripped.getReqIFCoreContent().getSpecObject("so-1"); + + assertEquals("First requirement", so1.getAttribute("Title")); + assertEquals(5, so1.getAttribute("Priority")); + } + + @Test + void xhtmlContentSurvivesTheRoundTrip() { + String before = (String) original.getReqIFCoreContent().getSpecObject("so-1").getAttribute("Description"); + String after = (String) roundTripped.getReqIFCoreContent().getSpecObject("so-1").getAttribute("Description"); + + assertEquals(before, after, "XHTML content including image, table and lists must be preserved"); + assertTrue(after.contains("files/image.png")); + assertTrue(after.contains("Header A")); + } + + @Test + void specRelationsSurviveTheRoundTrip() { + SpecRelation relation = roundTripped.getReqIFCoreContent().getSpecRelation("sr-1"); + + assertNotNull(relation); + assertEquals("so-1", relation.getSourceObjID()); + assertEquals("so-2", relation.getTargetObjID()); + assertEquals("st-rel", relation.getRelationTypeRef()); + assertEquals("derived during review", relation.getAttribute("LinkComment"), + "relation attribute values must be written too"); + } + + @Test + void specificationHierarchySurvivesTheRoundTrip() { + Specification spec = roundTripped.getReqIFCoreContent().getSpecification("spec-1"); + + assertEquals("Main Spec", spec.getName()); + assertEquals("2026-01-01", spec.getAttribute("ReviewDate")); + + List children = spec.getChildren(); + assertEquals(1, children.size()); + assertEquals("sh-1", children.get(0).getSpecHierarchyID()); + assertEquals("so-1", children.get(0).getSpecObjectID()); + + List nested = children.get(0).getChildren(); + assertEquals(1, nested.size(), "nested spec hierarchies must be written"); + assertEquals("sh-2", nested.get(0).getSpecHierarchyID()); + assertEquals("so-2", nested.get(0).getSpecObjectID()); + } + + @Test + void indentingIsOffByDefaultBecauseItAltersXhtmlContent() throws Exception { + Path indented = tempDir.resolve("indented.reqif"); + new ReqIFWriter().setIndent(true).write(original.getReqIFDocument(), indented); + + String withIndent = (String) new ReqIF(indented.toString()).getReqIFCoreContent() + .getSpecObject("so-1").getAttribute("Description"); + String withoutIndent = (String) roundTripped.getReqIFCoreContent() + .getSpecObject("so-1").getAttribute("Description"); + + assertEquals(withoutIndent, + (String) original.getReqIFCoreContent().getSpecObject("so-1").getAttribute("Description"), + "the default (no indent) must preserve XHTML exactly"); + assertTrue(withIndent.contains("

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() { + + + + dt-string + + + @@ -110,7 +117,7 @@ private TestFixtures() { ad-desc - Intro Text with span contentalternative text + Intro Text with span contentalternative textHeader AHeader BFirst paraSecond paracell imageOuter itemInner itemNumbered item @@ -124,7 +131,18 @@ private TestFixtures() { - + + + + + ad-linkcomment + + + st-rel + so-1 + so-2 + + st-spec diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/XHTMLDeconstructionTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/XHTMLDeconstructionTest.java new file mode 100644 index 0000000..2d70a17 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/XHTMLDeconstructionTest.java @@ -0,0 +1,138 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +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.attributes.AttributeValueXHTML; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTMLElementList; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; + +/** + * Bugs in the XHTML deconstruction: table cells were read via a fixed child + * index (losing content or reading the wrong child), header cells were + * indistinguishable from data cells, images inside cells were dropped, list + * detection required a namespace prefix, the "/L" markers were unbalanced and + * ordered lists were ignored entirely. + * + * The token list is now derived from the node tree, so both representations + * agree. + */ +class XHTMLDeconstructionTest { + + private AttributeValueXHTMLElementList elements; + + @BeforeEach + void parseFixture(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF(TestFixtures.writeDefaultFixture(tempDir).toString()); + AttributeValueXHTML description = (AttributeValueXHTML) reqif.getReqIFCoreContent() + .getSpecObject("so-1").getAttributes().get("Description"); + elements = description.getElementList(); + assertNotNull(elements); + } + + /** @return the content token list of the first element of the given type */ + private List contentOf(String elementType) { + for (int i = 0; i < elements.size(); i++) { + if (elements.getElementType(i).equals(elementType)) { + return elements.getElementContentList(i); + } + } + return null; + } + + private List allContentOf(String elementType) { + List all = new ArrayList<>(); + for (int i = 0; i < elements.size(); i++) { + if (elements.getElementType(i).equals(elementType)) { + all.addAll(elements.getElementContentList(i)); + } + } + return all; + } + + @Test + void paragraphContentIsExtracted() { + List paragraph = contentOf("P"); + + assertNotNull(paragraph, "the paragraph must appear in the element list"); + assertEquals(List.of("TXT", "Text with", "TXT", "span content"), paragraph); + } + + @Test + void headerCellsAreDistinguishableFromDataCells() { + List table = contentOf("TBL"); + + assertNotNull(table, "the table must appear in the element list"); + assertEquals(List.of("TR", "TH", "Header A", "TH", "Header B"), table.subList(0, 5), + "header cells must be reported as TH"); + assertTrue(table.contains("TC"), "data cells must be reported as TC"); + } + + @Test + void cellWithSeveralParagraphsKeepsAllContent() { + List table = contentOf("TBL"); + int cell = table.indexOf("TC"); + + assertEquals("First para Second para", table.get(cell + 1), + "a cell with two paragraphs must not lose the second one"); + } + + @Test + void imageInsideCellIsPreserved() { + List table = contentOf("TBL"); + + assertTrue(table.contains("files/cell.png"), + "an image inside a table cell must not be dropped, got: " + table); + } + + @Test + void nestedListsUseBalancedMarkers() { + List list = contentOf("L"); + + assertNotNull(list, "the unordered list must appear in the element list"); + assertEquals(List.of("LE", "TXT", "Outer item", "L", "LE", "TXT", "Inner item", "/L"), list); + + long opening = list.stream().filter("L"::equals).count(); + long closing = list.stream().filter("/L"::equals).count(); + assertEquals(opening, closing, "L and /L markers must be balanced"); + } + + @Test + void orderedListsAreNotIgnored() { + assertTrue(allContentOf("L").contains("Numbered item"), + "ordered lists (ol) must be deconstructed too"); + } + + @Test + void topLevelImageIsStillExtracted() { + assertEquals(List.of("files/image.png"), contentOf("OBJ")); + } + + @Test + void minifiedXmlYieldsTheSameTokens(@TempDir Path tempDir) throws Exception { + ReqIF minified = new ReqIF(TestFixtures + .write(tempDir, "min.reqif", TestFixtures.minified(TestFixtures.REQIF_FIXTURE)).toString()); + AttributeValueXHTML description = (AttributeValueXHTML) minified.getReqIFCoreContent() + .getSpecObject("so-1").getAttributes().get("Description"); + List table = null; + AttributeValueXHTMLElementList minifiedElements = description.getElementList(); + for (int i = 0; i < minifiedElements.size(); i++) { + if (minifiedElements.getElementType(i).equals("TBL")) { + table = minifiedElements.getElementContentList(i); + } + } + + assertNotNull(table); + assertEquals("First para Second para", table.get(table.indexOf("TC") + 1), + "minified XML must not shift the cell content"); + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/XHTMLRenderingTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/XHTMLRenderingTest.java new file mode 100644 index 0000000..b8df0db --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/XHTMLRenderingTest.java @@ -0,0 +1,113 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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 java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import javax.xml.parsers.DocumentBuilderFactory; + +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; + +/** + * Bugs in the XHTML rendering ({@code getValue()} / {@code toString()}): + * text was not escaped (making the output malformed), all attributes were + * dropped, void elements were written as {@code

}, 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 = """ + + + + + + + dt + + + st + + ad + a < b & cSiehe diesen Link und Betonung.merged + + + + """; + + private String rendered; + + @BeforeEach + void parseFixture(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "render.reqif", RENDER_FIXTURE).toString()); + rendered = (String) reqif.getReqIFCoreContent().getSpecObject("so-1").getAttribute("Description"); + } + + @Test + void specialCharactersAreEscaped() { + assertTrue(rendered.contains("a < b & c"), + "text must be escaped, got: " + rendered); + } + + @Test + void outputIsWellFormedXml() { + // Unescaped text used to make the output unparseable. + assertDoesNotThrow(() -> DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(new ByteArrayInputStream(rendered.getBytes(StandardCharsets.UTF_8))), + "the rendered XHTML must be parseable again: " + rendered); + } + + @Test + void attributesArePreserved() { + assertTrue(rendered.contains("style=\"color:red\""), "style attribute lost: " + rendered); + assertTrue(rendered.contains("colspan=\"2\""), "colspan attribute lost: " + rendered); + assertTrue(rendered.contains("href=\"http://x.y\""), "href attribute lost: " + rendered); + } + + @Test + void namespaceDeclarationsAreNotEmitted() { + assertFalse(rendered.contains("xmlns"), + "tag names are rendered without prefix, so xmlns declarations must be skipped: " + rendered); + } + + @Test + void voidElementsAreSelfClosing() { + assertTrue(rendered.contains("
"), "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("

" + + "

a < b & c

" + + "

Siehe diesen Link und Betonung.

" + + "
" + + "
merged
" + + "
", rendered); + } +}