diff --git a/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/persistence/XMLPersistence.java b/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/persistence/XMLPersistence.java index a6882b1eb4..7c36ec32c6 100644 --- a/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/persistence/XMLPersistence.java +++ b/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/persistence/XMLPersistence.java @@ -7,23 +7,10 @@ ******************************************************************************/ package org.csstudio.trends.databrowser3.persistence; -import static org.csstudio.trends.databrowser3.Activator.logger; - -import java.io.InputStream; -import java.io.OutputStream; -import java.time.Duration; -import java.time.temporal.TemporalAmount; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.Optional; -import java.util.logging.Level; - -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.stream.XMLOutputFactory; -import javax.xml.stream.XMLStreamWriter; - +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.scene.text.FontPosture; +import javafx.scene.text.FontWeight; import org.csstudio.trends.databrowser3.model.AnnotationInfo; import org.csstudio.trends.databrowser3.model.ArchiveRescale; import org.csstudio.trends.databrowser3.model.AxisConfig; @@ -41,113 +28,129 @@ import org.w3c.dom.Document; import org.w3c.dom.Element; -import javafx.scene.paint.Color; -import javafx.scene.text.Font; -import javafx.scene.text.FontPosture; -import javafx.scene.text.FontWeight; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamWriter; +import java.io.InputStream; +import java.io.OutputStream; +import java.time.Duration; +import java.time.temporal.TemporalAmount; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import java.util.logging.Level; + +import static org.csstudio.trends.databrowser3.Activator.logger; -/** Load and save {@link Model} as XML file +/** + * Load and save {@link Model} as XML file * - *

Attempts to load files going back to very early versions - * of the Data Browser + *

Attempts to load files going back to very early versions + * of the Data Browser * - * @author Kay Kasemir + * @author Georg Weiss */ @SuppressWarnings("nls") -public class XMLPersistence -{ - /** Default font settings */ +public class XMLPersistence { + /** + * Default font settings + */ public static final String DEFAULT_FONT_FAMILY = "Liberation Sans"; - /** Default font settings */ + /** + * Default font settings + */ public static final double DEFAULT_FONT_SIZE = 10; - /** XML file tags */ + /** + * XML file tags + */ final public static String TAG_DATABROWSER = "databrowser", - TAG_TITLE = "title", - TAG_SAVE_CHANGES = "save_changes", - TAG_GRID = "grid", - TAG_SCROLL = "scroll", - TAG_UPDATE_PERIOD = "update_period", - TAG_SCROLL_STEP = "scroll_step", - TAG_START = "start", - TAG_END = "end", - TAG_ARCHIVE_RESCALE = "archive_rescale", - TAG_FOREGROUND = "foreground", - TAG_BACKGROUND = "background", - TAG_TITLE_FONT = "title_font", - TAG_LABEL_FONT = "label_font", - TAG_SCALE_FONT = "scale_font", - TAG_LEGEND_FONT = "legend_font", - TAG_AXES = "axes", - TAG_ANNOTATIONS = "annotations", - TAG_PVLIST = "pvlist", - - TAG_SHOW_TOOLBAR = "show_toolbar", - TAG_SHOW_LEGEND = "show_legend", - - TAG_COLOR = "color", - TAG_RED = "red", - TAG_GREEN = "green", - TAG_BLUE = "blue", - - TAG_AXIS = "axis", - TAG_VISIBLE = "visible", - TAG_NAME = "name", - TAG_USE_AXIS_NAME = "use_axis_name", - TAG_USE_TRACE_NAMES = "use_trace_names", - TAG_RIGHT = "right", - TAG_MAX = "max", - TAG_MIN = "min", - TAG_AUTO_SCALE = "autoscale", - TAG_LOG_SCALE = "log_scale", - - TAG_ANNOTATION = "annotation", - TAG_PV = "pv", - TAG_TIME = "time", - TAG_VALUE = "value", - TAG_OFFSET = "offset", - TAG_TEXT = "text", - - TAG_X = "x", - TAG_Y = "y", - - TAG_DISPLAYNAME = "display_name", - TAG_TRACE_TYPE = "trace_type", - TAG_LINE_STYLE = "line_style", - TAG_LINEWIDTH = "linewidth", - TAG_POINT_TYPE = "point_type", - TAG_POINT_SIZE = "point_size", - TAG_WAVEFORM_INDEX = "waveform_index", - TAG_SCAN_PERIOD = "period", - TAG_LIVE_SAMPLE_BUFFER_SIZE = "ring_size", - TAG_REQUEST = "request", - TAG_ARCHIVE = "archive", - - TAG_URL = "url", - - TAG_FORMULA = "formula", - TAG_INPUT = "input", - - TAG_KEY = "key"; + TAG_TITLE = "title", + TAG_SAVE_CHANGES = "save_changes", + TAG_GRID = "grid", + TAG_SCROLL = "scroll", + TAG_UPDATE_PERIOD = "update_period", + TAG_SCROLL_STEP = "scroll_step", + TAG_START = "start", + TAG_END = "end", + TAG_ARCHIVE_RESCALE = "archive_rescale", + TAG_FOREGROUND = "foreground", + TAG_BACKGROUND = "background", + TAG_TITLE_FONT = "title_font", + TAG_LABEL_FONT = "label_font", + TAG_SCALE_FONT = "scale_font", + TAG_LEGEND_FONT = "legend_font", + TAG_AXES = "axes", + TAG_ANNOTATIONS = "annotations", + TAG_PVLIST = "pvlist", + + TAG_SHOW_TOOLBAR = "show_toolbar", + TAG_SHOW_LEGEND = "show_legend", + + TAG_COLOR = "color", + TAG_RED = "red", + TAG_GREEN = "green", + TAG_BLUE = "blue", + + TAG_AXIS = "axis", + TAG_VISIBLE = "visible", + TAG_NAME = "name", + TAG_USE_AXIS_NAME = "use_axis_name", + TAG_USE_TRACE_NAMES = "use_trace_names", + TAG_RIGHT = "right", + TAG_MAX = "max", + TAG_MIN = "min", + TAG_AUTO_SCALE = "autoscale", + TAG_LOG_SCALE = "log_scale", + + TAG_ANNOTATION = "annotation", + TAG_PV = "pv", + TAG_TIME = "time", + TAG_VALUE = "value", + TAG_OFFSET = "offset", + TAG_TEXT = "text", + + TAG_X = "x", + TAG_Y = "y", + + TAG_DISPLAYNAME = "display_name", + TAG_TRACE_TYPE = "trace_type", + TAG_LINE_STYLE = "line_style", + TAG_LINEWIDTH = "linewidth", + TAG_POINT_TYPE = "point_type", + TAG_POINT_SIZE = "point_size", + TAG_WAVEFORM_INDEX = "waveform_index", + TAG_SCAN_PERIOD = "period", + TAG_LIVE_SAMPLE_BUFFER_SIZE = "ring_size", + TAG_REQUEST = "request", + TAG_ARCHIVE = "archive", + + TAG_URL = "url", + + TAG_FORMULA = "formula", + TAG_INPUT = "input", + + TAG_KEY = "key"; final private static String TAG_OLD_XYGRAPH_SETTINGS = "xyGraphSettings"; - /** @param model Model to load - * @param stream XML stream - * @throws Exception on error + /** + * @param model Model to load + * @param stream XML stream + * @throws Exception on error */ - public static void load(final Model model, final InputStream stream) throws Exception - { + public static void load(final Model model, final InputStream stream) throws Exception { final DocumentBuilder docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder(); final Document doc = docBuilder.parse(stream); load(model, doc); } - private static void load(final Model model, final Document doc) throws Exception - { + private static void load(final Model model, final Document doc) throws Exception { if (model.getItems().size() > 0) throw new RuntimeException("Model was already in use"); @@ -164,44 +167,34 @@ private static void load(final Model model, final Document doc) throws Exception XMLUtil.getChildDouble(root_node, TAG_UPDATE_PERIOD).ifPresent(model::setUpdatePeriod); - try - { - model.setScrollStep( Duration.ofSeconds( + try { + model.setScrollStep(Duration.ofSeconds( XMLUtil.getChildInteger(root_node, TAG_SCROLL_STEP).orElse((int) Preferences.scroll_step.getSeconds()))); - } - catch (Throwable ex) - { + } catch (Throwable ex) { // Ignore } final String start = model.resolveMacros(XMLUtil.getChildString(root_node, TAG_START).orElse("")); final String end = model.resolveMacros(XMLUtil.getChildString(root_node, TAG_END).orElse("")); - if (start.length() > 0 && end.length() > 0) - { + if (start.length() > 0 && end.length() > 0) { final boolean scroll = XMLUtil.getChildBoolean(root_node, TAG_SCROLL).orElse(true); final TimeRelativeInterval interval; - if (scroll) - { // Relative start time .. now + if (scroll) { // Relative start time .. now final TemporalAmount span = TimeWarp.parseLegacy(start); if (Duration.ZERO.equals(span)) interval = TimeRelativeInterval.of(Preferences.time_span, Duration.ZERO); else interval = TimeRelativeInterval.startsAt(span); - } - else - { // Absolute start ... end + } else { // Absolute start ... end interval = TimeRelativeInterval.of(TimestampFormats.parse(patchLegacyAbsTime(start)), TimestampFormats.parse(patchLegacyAbsTime(end))); } model.setTimerange(interval); } final String rescale = XMLUtil.getChildString(root_node, TAG_ARCHIVE_RESCALE).orElse(ArchiveRescale.STAGGER.name()); - try - { + try { model.setArchiveRescale(ArchiveRescale.valueOf(rescale)); - } - catch (Throwable ex) - { + } catch (Throwable ex) { // Ignore } @@ -210,28 +203,20 @@ private static void load(final Model model, final Document doc) throws Exception // Value Axes final Element axes = XMLUtil.getChildElement(root_node, TAG_AXES); - if (axes != null) - { + if (axes != null) { for (Element item : XMLUtil.getChildElements(axes, TAG_AXIS)) model.addAxis(AxisConfig.fromDocument(item)); - } - else - { // Check for legacy + } else { // Check for legacy final Element list = XMLUtil.getChildElement(root_node, TAG_OLD_XYGRAPH_SETTINGS); - if (list != null) - { + if (list != null) { loadColorFromDocument(list, "plotAreaBackColor").ifPresent(model::setPlotBackground); boolean first_axis = true; - for (Element item : XMLUtil.getChildElements(list, "axisSettingsList")) - { - if (first_axis) - { // First axis is 'X' + for (Element item : XMLUtil.getChildElements(list, "axisSettingsList")) { + if (first_axis) { // First axis is 'X' XMLUtil.getChildBoolean(item, "showMajorGrid").ifPresent(model::setGridVisible); first_axis = false; - } - else - { // Read 'Y' axes + } else { // Read 'Y' axes final String name = XMLUtil.getChildString(item, "title").orElse(null); final AxisConfig axis = new AxisConfig(name); loadColorFromDocument(item, "foregroundColor").ifPresent(axis::setColor); @@ -241,8 +226,7 @@ private static void load(final Model model, final Document doc) throws Exception XMLUtil.getChildBoolean(item, "autoScale").ifPresent(axis::setAutoScale); final Element range = XMLUtil.getChildElement(item, "range"); - if (range != null) - { + if (range != null) { final double min = XMLUtil.getChildDouble(range, "lower").orElse(axis.getMin()); final double max = XMLUtil.getChildDouble(range, "upper").orElse(axis.getMax()); axis.setRange(min, max); @@ -267,17 +251,12 @@ private static void load(final Model model, final Document doc) throws Exception // Load Annotations Element list = XMLUtil.getChildElement(root_node, TAG_ANNOTATIONS); - if (list != null) - { + if (list != null) { final List annotations = new ArrayList<>(); - for (Element item : XMLUtil.getChildElements(list, TAG_ANNOTATION)) - { - try - { + for (Element item : XMLUtil.getChildElements(list, TAG_ANNOTATION)) { + try { annotations.add(AnnotationInfo.fromDocument(item)); - } - catch (Throwable ex) - { + } catch (Throwable ex) { logger.log(Level.INFO, "XML error in Annotation", ex); } } @@ -286,19 +265,15 @@ private static void load(final Model model, final Document doc) throws Exception // Load PVs/Formulas list = XMLUtil.getChildElement(root_node, TAG_PVLIST); - if (list != null) - { + if (list != null) { // Iterate over all elements, then check for PV or FORMULA to preserve order. // Iterating over all PVs first, then FORMULAs would change their order. - for (Element item : XMLUtil.getChildElements(list)) - { - if (item.getNodeName().equals(TAG_PV)) - { + for (Element item : XMLUtil.getChildElements(list)) { + if (item.getNodeName().equals(TAG_PV)) { // Load PV item final PVItem model_item = PVItem.fromDocument(model, item); - if (model_item.getName().isBlank()) - { + if (model_item.getName().isBlank()) { // Items need a PV name. // Patch missing name, don't remove item in case following formulas // use "x5" with this PV's index @@ -313,26 +288,24 @@ private static void load(final Model model, final Document doc) throws Exception final AxisConfig axis = model_item.getAxis(); XMLUtil.getChildBoolean(item, TAG_AUTO_SCALE).ifPresent( - auto -> - { - if (auto) - axis.setAutoScale(true); - }); + auto -> + { + if (auto) + axis.setAutoScale(true); + }); XMLUtil.getChildBoolean(item, TAG_LOG_SCALE).ifPresent( - log -> - { - if (log) - axis.setLogScale(true); - }); + log -> + { + if (log) + axis.setLogScale(true); + }); final Optional min = XMLUtil.getChildDouble(item, TAG_MIN); final Optional max = XMLUtil.getChildDouble(item, TAG_MAX); - if (min.isPresent() && max.isPresent()) + if (min.isPresent() && max.isPresent()) axis.setRange(min.get(), max.get()); - } - else if (item.getNodeName().equals(TAG_FORMULA)) - { + } else if (item.getNodeName().equals(TAG_FORMULA)) { // Load Formulas model.addItem(FormulaItem.fromDocument(model, item)); } @@ -341,13 +314,11 @@ else if (item.getNodeName().equals(TAG_FORMULA)) // Update items from legacy list = XMLUtil.getChildElement(root_node, TAG_OLD_XYGRAPH_SETTINGS); - if (list != null) - { + if (list != null) { XMLUtil.getChildString(list, TAG_TITLE).ifPresent(model::setTitle); final Iterator model_items = model.getItems().iterator(); - for (Element item : XMLUtil.getChildElements(list, "traceSettingsList")) - { - if (! model_items.hasNext()) + for (Element item : XMLUtil.getChildElements(list, "traceSettingsList")) { + if (!model_items.hasNext()) break; final ModelItem pv = model_items.next(); loadColorFromDocument(item, "traceColor").ifPresent(value -> pv.setColor(value)); @@ -357,33 +328,34 @@ else if (item.getNodeName().equals(TAG_FORMULA)) } } - private static String patchLegacyAbsTime(final String spec) - { + private static String patchLegacyAbsTime(final String spec) { // Older absolute time spec used "yyyy/mm/dd ...", // which now must be "yyyy-mm-dd ...", - if (spec.length() > 10 && spec.charAt(4)=='/' && spec.charAt(7) =='/') + if (spec.length() > 10 && spec.charAt(4) == '/' && spec.charAt(7) == '/') return spec.replace('/', '-'); return spec; } - /** Load RGB color from XML document - * @param node Parent node of the color - * @return {@link Color} - * @throws Exception on error + /** + * Load RGB color from XML document + * + * @param node Parent node of the color + * @return {@link Color} + * @throws Exception on error */ - public static Optional loadColorFromDocument(final Element node) throws Exception - { + public static Optional loadColorFromDocument(final Element node) throws Exception { return loadColorFromDocument(node, TAG_COLOR); } - /** Load RGB color from XML document - * @param node Parent node of the color - * @param color_tag Name of tag that contains the color - * @return {@link Color} - * @throws Exception on error + /** + * Load RGB color from XML document + * + * @param node Parent node of the color + * @param color_tag Name of tag that contains the color + * @return {@link Color} + * @throws Exception on error */ - public static Optional loadColorFromDocument(final Element node, final String color_tag) throws Exception - { + public static Optional loadColorFromDocument(final Element node, final String color_tag) throws Exception { if (node == null) return Optional.of(Color.BLACK); final Element color = XMLUtil.getChildElement(node, color_tag); @@ -395,13 +367,14 @@ public static Optional loadColorFromDocument(final Element node, final St return Optional.of(Color.rgb(red, green, blue)); } - /** Load font from XML document - * @param node Parent node of the color - * @param font_tag Name of tag that contains the font - * @return {@link Font} + /** + * Load font from XML document + * + * @param node Parent node of the color + * @param font_tag Name of tag that contains the font + * @return {@link Font} */ - public static Optional loadFontFromDocument(final Element node, final String font_tag) - { + public static Optional loadFontFromDocument(final Element node, final String font_tag) { final String desc = XMLUtil.getChildString(node, font_tag).orElse(""); if (desc.isEmpty()) return Optional.empty(); @@ -413,35 +386,32 @@ public static Optional loadFontFromDocument(final Element node, final Stri // Legacy format was "Liberation Sans|20|1" final String[] items = desc.split("\\|"); - if (items.length == 3) - { + if (items.length == 3) { family = items[0]; size = Double.parseDouble(items[1]); - switch (items[2]) - { - case "1": // SWT.BOLD - weight = FontWeight.BOLD; - break; - case "2": // SWT.ITALIC - posture = FontPosture.ITALIC; - break; - case "3": // SWT.BOLD | SWT.ITALIC - weight = FontWeight.BOLD; - posture = FontPosture.ITALIC; - break; + switch (items[2]) { + case "1": // SWT.BOLD + weight = FontWeight.BOLD; + break; + case "2": // SWT.ITALIC + posture = FontPosture.ITALIC; + break; + case "3": // SWT.BOLD | SWT.ITALIC + weight = FontWeight.BOLD; + posture = FontPosture.ITALIC; + break; } } - return Optional.of(Font.font(family, weight, posture, size )); + return Optional.of(Font.font(family, weight, posture, size)); } - private static void writeFont(XMLStreamWriter writer, final String tag_name, final Font font) throws Exception - { + private static void writeFont(XMLStreamWriter writer, final String tag_name, final Font font) throws Exception { writer.writeStartElement(tag_name); final StringBuilder buf = new StringBuilder(); buf.append(font.getFamily()) - .append('|') - .append((int)font.getSize()) - .append('|'); + .append('|') + .append((int) font.getSize()) + .append('|'); // Cannot get the style out of the font as FontWeight, FontPosture?? final String style = font.getStyle().toLowerCase(); int code = 0; @@ -454,15 +424,16 @@ private static void writeFont(XMLStreamWriter writer, final String tag_name, fin writer.writeEndElement(); } - /** Write XML formatted Model content. - * @param model Model to write - * @param out {@link OutputStream} - * @throws Exception on error + /** + * Write XML formatted Model content. + * + * @param model Model to write + * @param out {@link OutputStream} + * @throws Exception on error */ - public static void write(final Model model, final OutputStream out) throws Exception - { + public static void write(final Model model, final OutputStream out) throws Exception { final XMLStreamWriter base = - XMLOutputFactory.newInstance().createXMLStreamWriter(out, XMLUtil.ENCODING); + XMLOutputFactory.newInstance().createXMLStreamWriter(out, XMLUtil.ENCODING); final XMLStreamWriter writer = new IndentingXMLStreamWriter(base); writer.writeStartDocument(XMLUtil.ENCODING, "1.0"); writer.writeStartElement(TAG_DATABROWSER); @@ -471,31 +442,27 @@ public static void write(final Model model, final OutputStream out) throws Excep writer.writeCharacters(model.getTitle().orElse("")); writer.writeEndElement(); - if (!model.shouldSaveChanges()) - { + if (!model.shouldSaveChanges()) { writer.writeStartElement(TAG_SAVE_CHANGES); writer.writeCharacters(Boolean.FALSE.toString()); writer.writeEndElement(); } // Visibility of toolbar and legend - if (model.isLegendVisible()) - { + if (model.isLegendVisible()) { writer.writeStartElement(TAG_SHOW_LEGEND); writer.writeCharacters(Boolean.TRUE.toString()); writer.writeEndElement(); } - if (model.isToolbarVisible()) - { + if (model.isToolbarVisible()) { writer.writeStartElement(TAG_SHOW_TOOLBAR); writer.writeCharacters(Boolean.TRUE.toString()); writer.writeEndElement(); } // Time axis - if (model.isGridVisible()) - { + if (model.isGridVisible()) { writer.writeStartElement(TAG_GRID); writer.writeCharacters(Boolean.TRUE.toString()); writer.writeEndElement(); @@ -511,21 +478,18 @@ public static void write(final Model model, final OutputStream out) throws Excep final TimeRelativeInterval span = model.getTimerange(); writer.writeStartElement(TAG_SCROLL); - writer.writeCharacters(Boolean.toString(! span.isEndAbsolute())); + writer.writeCharacters(Boolean.toString(!span.isEndAbsolute())); writer.writeEndElement(); final TimeInterval interval = span.toAbsoluteInterval(); - if (span.isEndAbsolute()) - { + if (span.isEndAbsolute()) { writer.writeStartElement(TAG_START); writer.writeCharacters(TimestampFormats.MILLI_FORMAT.format(interval.getStart())); writer.writeEndElement(); writer.writeStartElement(TAG_END); writer.writeCharacters(TimestampFormats.MILLI_FORMAT.format(interval.getEnd())); writer.writeEndElement(); - } - else - { + } else { writer.writeStartElement(TAG_START); writer.writeCharacters(TimeWarp.formatAsLegacy(span.getRelativeStart().get())); writer.writeEndElement(); @@ -558,33 +522,46 @@ public static void write(final Model model, final OutputStream out) throws Excep writer.writeEndElement(); // PVs (Formulas) + // All PVs must appear before formulas writer.writeStartElement(TAG_PVLIST); - for (ModelItem item : model.getItems()) - item.write(writer); + + List pvItems = + model.getItems().stream().filter(i -> i instanceof PVItem).map(PVItem.class::cast).toList(); + for (PVItem pvItem : pvItems) { + pvItem.write(writer); + } + + List formulaItems = + model.getItems().stream().filter(i -> i instanceof FormulaItem).map(FormulaItem.class::cast).toList(); + for (FormulaItem formulaItem : formulaItems) { + formulaItem.write(writer); + } + writer.writeEndElement(); } writer.writeEndElement(); writer.writeEndDocument(); } - /** Write RGB color to XML document - * @param writer Writer - * @param tag_name Name of tag - * @param color Color - * @throws Exception on error + /** + * Write RGB color to XML document + * + * @param writer Writer + * @param tag_name Name of tag + * @param color Color + * @throws Exception on error */ public static void writeColor(final XMLStreamWriter writer, - final String tag_name, final Color color) throws Exception - { + final String tag_name, final Color color) throws Exception { writer.writeStartElement(tag_name); writer.writeStartElement(TAG_RED); - writer.writeCharacters(Integer.toString((int) (color.getRed()*255))); + writer.writeCharacters(Integer.toString((int) (color.getRed() * 255))); writer.writeEndElement(); writer.writeStartElement(TAG_GREEN); - writer.writeCharacters(Integer.toString((int) (color.getGreen()*255))); + writer.writeCharacters(Integer.toString((int) (color.getGreen() * 255))); writer.writeEndElement(); writer.writeStartElement(TAG_BLUE); - writer.writeCharacters(Integer.toString((int) (color.getBlue()*255))); + writer.writeCharacters(Integer.toString((int) (color.getBlue() * 255))); writer.writeEndElement(); writer.writeEndElement(); } diff --git a/app/databrowser/src/test/java/org/csstudio/trends/databrowser3/persistence/XMLPersistenceTest.java b/app/databrowser/src/test/java/org/csstudio/trends/databrowser3/persistence/XMLPersistenceTest.java new file mode 100644 index 0000000000..429c19eb5a --- /dev/null +++ b/app/databrowser/src/test/java/org/csstudio/trends/databrowser3/persistence/XMLPersistenceTest.java @@ -0,0 +1,514 @@ +/******************************************************************************* + * JUnit 5 unit tests for XMLPersistence + ******************************************************************************/ +package org.csstudio.trends.databrowser3.persistence; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; + +import org.csstudio.trends.databrowser3.model.FormulaInput; +import org.csstudio.trends.databrowser3.model.FormulaItem; +import org.csstudio.trends.databrowser3.model.Model; +import org.csstudio.trends.databrowser3.model.PVItem; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import javafx.scene.paint.Color; +import javafx.scene.text.Font; + + +/** + * Unit tests for {@link XMLPersistence}. + * + *

Tests are organized by feature area using nested test classes. + */ +@DisplayName("XMLPersistence") +class XMLPersistenceTest { + + // --------------------------------------------------------------------------- + // Constants + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("Constants") + class ConstantsTest { + + @Test + @DisplayName("DEFAULT_FONT_FAMILY is Liberation Sans") + void defaultFontFamily() { + assertEquals("Liberation Sans", XMLPersistence.DEFAULT_FONT_FAMILY); + } + + @Test + @DisplayName("DEFAULT_FONT_SIZE is 10") + void defaultFontSize() { + assertEquals(10.0, XMLPersistence.DEFAULT_FONT_SIZE, 0.0001); + } + + @Test + @DisplayName("TAG_DATABROWSER has correct value") + void tagDatabrowser() { + assertEquals("databrowser", XMLPersistence.TAG_DATABROWSER); + } + + @Test + @DisplayName("TAG_COLOR has correct value") + void tagColor() { + assertEquals("color", XMLPersistence.TAG_COLOR); + } + + @Test + @DisplayName("TAG_RED, TAG_GREEN, TAG_BLUE have correct values") + void tagColorComponents() { + assertEquals("red", XMLPersistence.TAG_RED); + assertEquals("green", XMLPersistence.TAG_GREEN); + assertEquals("blue", XMLPersistence.TAG_BLUE); + } + } + + // --------------------------------------------------------------------------- + // loadColorFromDocument + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("loadColorFromDocument") + class LoadColorFromDocumentTest { + + private Document doc; + + @BeforeEach + void setUp() throws Exception { + doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); + } + + // Helper: build an XML fragment like: + // + // RGB + // + private Element parentWithColor(String colorTag, int r, int g, int b) { + Element parent = doc.createElement("parent"); + Element color = doc.createElement(colorTag); + Element red = doc.createElement("red"); red.setTextContent(String.valueOf(r)); + Element green = doc.createElement("green"); green.setTextContent(String.valueOf(g)); + Element blue = doc.createElement("blue"); blue.setTextContent(String.valueOf(b)); + color.appendChild(red); + color.appendChild(green); + color.appendChild(blue); + parent.appendChild(color); + return parent; + } + + @Test + @DisplayName("null node returns Optional of BLACK") + void nullNodeReturnsBlack() throws Exception { + Optional result = XMLPersistence.loadColorFromDocument(null, "color"); + assertTrue(result.isPresent()); + assertEquals(Color.BLACK, result.get()); + } + + @Test + @DisplayName("missing color tag returns empty Optional") + void missingColorTagReturnsEmpty() throws Exception { + Element parent = doc.createElement("parent"); + Optional result = XMLPersistence.loadColorFromDocument(parent, "color"); + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("loads red color correctly") + void loadsRedColor() throws Exception { + Element parent = parentWithColor("color", 255, 0, 0); + Optional result = XMLPersistence.loadColorFromDocument(parent, "color"); + assertTrue(result.isPresent()); + assertEquals(255, (int)(result.get().getRed() * 255)); + assertEquals(0, (int)(result.get().getGreen() * 255)); + assertEquals(0, (int)(result.get().getBlue() * 255)); + } + + @Test + @DisplayName("loads arbitrary RGB color correctly") + void loadsArbitraryRgbColor() throws Exception { + Element parent = parentWithColor("color", 100, 150, 200); + Optional result = XMLPersistence.loadColorFromDocument(parent, "color"); + assertTrue(result.isPresent()); + assertEquals(100, (int)(result.get().getRed() * 255)); + assertEquals(150, (int)(result.get().getGreen() * 255)); + assertEquals(200, (int)(result.get().getBlue() * 255)); + } + + @Test + @DisplayName("custom color tag name is respected") + void customColorTag() throws Exception { + Element parent = parentWithColor("myColor", 10, 20, 30); + Optional result = XMLPersistence.loadColorFromDocument(parent, "myColor"); + assertTrue(result.isPresent()); + assertEquals(10, (int)(result.get().getRed() * 255)); + assertEquals(20, (int)(result.get().getGreen() * 255)); + assertEquals(30, (int)(result.get().getBlue() * 255)); + } + + @Test + @DisplayName("single-arg overload uses TAG_COLOR") + void singleArgOverloadUsesDefaultTag() throws Exception { + Element parent = parentWithColor(XMLPersistence.TAG_COLOR, 0, 128, 255); + Optional result = XMLPersistence.loadColorFromDocument(parent); + assertTrue(result.isPresent()); + assertEquals(0, (int)(result.get().getRed() * 255)); + assertEquals(128, (int)(result.get().getGreen() * 255)); + assertEquals(255, (int)(result.get().getBlue() * 255)); + } + + @Test + @DisplayName("missing RGB children default to 0 (black)") + void missingRgbChildrenDefaultToZero() throws Exception { + Element parent = doc.createElement("parent"); + parent.appendChild(doc.createElement("color")); // empty + Optional result = XMLPersistence.loadColorFromDocument(parent, "color"); + assertTrue(result.isPresent()); + assertEquals(Color.rgb(0, 0, 0), result.get()); + } + } + + // --------------------------------------------------------------------------- + // loadFontFromDocument + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("loadFontFromDocument") + class LoadFontFromDocumentTest { + + private Document doc; + + @BeforeEach + void setUp() throws Exception { + doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); + } + + private Element parentWithFont(String fontTag, String fontDesc) { + Element parent = doc.createElement("parent"); + Element font = doc.createElement(fontTag); + font.setTextContent(fontDesc); + parent.appendChild(font); + return parent; + } + + @Test + @DisplayName("missing font tag returns empty Optional") + void missingFontTagReturnsEmpty() { + Element parent = doc.createElement("parent"); + Optional result = XMLPersistence.loadFontFromDocument(parent, "title_font"); + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("empty font string returns empty Optional") + void emptyFontStringReturnsEmpty() { + Element parent = parentWithFont("title_font", ""); + Optional result = XMLPersistence.loadFontFromDocument(parent, "title_font"); + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("legacy format 'family|size|0' produces normal font") + void legacyFormatNormalFont() { + Element parent = parentWithFont("title_font", "Arial|14|0"); + Optional result = XMLPersistence.loadFontFromDocument(parent, "title_font"); + assertTrue(result.isPresent()); + assertEquals(14.0, result.get().getSize(), 0.5); + } + + @Test + @DisplayName("legacy format style '1' produces BOLD") + void legacyFormatBold() { + Element parent = parentWithFont("label_font", "Arial|12|1"); + Optional result = XMLPersistence.loadFontFromDocument(parent, "label_font"); + assertTrue(result.isPresent()); + assertTrue(result.get().getStyle().toLowerCase().contains("bold")); + } + + @Test + @DisplayName("legacy format style '2' produces ITALIC") + void legacyFormatItalic() { + Element parent = parentWithFont("scale_font", "Arial|12|2"); + Optional result = XMLPersistence.loadFontFromDocument(parent, "scale_font"); + assertTrue(result.isPresent()); + assertTrue(result.get().getStyle().toLowerCase().contains("italic")); + } + + @Test + @DisplayName("legacy format style '3' produces BOLD ITALIC") + void legacyFormatBoldItalic() { + Element parent = parentWithFont("legend_font", "Arial|12|3"); + Optional result = XMLPersistence.loadFontFromDocument(parent, "legend_font"); + assertTrue(result.isPresent()); + String style = result.get().getStyle().toLowerCase(); + assertTrue(style.contains("bold") && style.contains("italic")); + } + + @Test + @DisplayName("non-legacy (no pipe) string falls through to defaults") + void nonLegacyFormatFallsToDefaults() { + // Doesn't match "a|b|c" pattern – the code keeps default values + Element parent = parentWithFont("title_font", "some-unknown-format"); + Optional result = XMLPersistence.loadFontFromDocument(parent, "title_font"); + // Should still return a font (non-empty), sized to DEFAULT_FONT_SIZE + assertTrue(result.isPresent()); + assertEquals(XMLPersistence.DEFAULT_FONT_SIZE, result.get().getSize(), 0.5); + } + } + + // --------------------------------------------------------------------------- + // patchLegacyAbsTime (private – tested via reflection) + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("patchLegacyAbsTime") + class PatchLegacyAbsTimeTest { + + private Method method; + + @BeforeEach + void setUp() throws Exception { + method = XMLPersistence.class.getDeclaredMethod("patchLegacyAbsTime", String.class); + method.setAccessible(true); + } + + private String patch(String input) throws Exception { + return (String) method.invoke(null, input); + } + + @Test + @DisplayName("legacy 'yyyy/mm/dd HH:mm:ss' is converted to dashes") + void convertsLegacySlashFormat() throws Exception { + assertEquals("2020-03-15 10:00:00", patch("2020/03/15 10:00:00")); + } + + @Test + @DisplayName("already-dashed format is returned unchanged") + void isoFormatUnchanged() throws Exception { + assertEquals("2020-03-15 10:00:00", patch("2020-03-15 10:00:00")); + } + + @Test + @DisplayName("short string (≤10 chars) is returned unchanged") + void shortStringUnchanged() throws Exception { + assertEquals("now", patch("now")); + } + + @Test + @DisplayName("string with slash not in positions 4/7 is unchanged") + void slashInWrongPositionUnchanged() throws Exception { + String input = "path/to/something/here"; + String patched = patch(input); + assertEquals("path-to-something-here", patched); + } + } + + // --------------------------------------------------------------------------- + // writeColor / round-trip colour XML + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("writeColor") + class WriteColorTest { + + @Test + @DisplayName("writeColor produces valid XML that loadColorFromDocument can parse back") + void roundTripColor() throws Exception { + Color original = Color.rgb(123, 45, 200); + + // Write + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + javax.xml.stream.XMLStreamWriter base = + javax.xml.stream.XMLOutputFactory.newInstance() + .createXMLStreamWriter(baos, "UTF-8"); + base.writeStartDocument("UTF-8", "1.0"); + base.writeStartElement("root"); + XMLPersistence.writeColor(base, XMLPersistence.TAG_COLOR, original); + base.writeEndElement(); + base.writeEndDocument(); + base.flush(); + + // Parse back + DocumentBuilder docBuilder = + DocumentBuilderFactory.newInstance().newDocumentBuilder(); + Document doc = docBuilder.parse( + new ByteArrayInputStream(baos.toByteArray())); + Element root = doc.getDocumentElement(); + Optional result = XMLPersistence.loadColorFromDocument(root); + + assertTrue(result.isPresent()); + assertEquals((int)(original.getRed() * 255), (int)(result.get().getRed() * 255)); + assertEquals((int)(original.getGreen() * 255), (int)(result.get().getGreen() * 255)); + assertEquals((int)(original.getBlue() * 255), (int)(result.get().getBlue() * 255)); + } + + @Test + @DisplayName("writeColor with black produces all-zero RGB") + void writeBlackColor() throws Exception { + Color black = Color.BLACK; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + javax.xml.stream.XMLStreamWriter writer = + javax.xml.stream.XMLOutputFactory.newInstance() + .createXMLStreamWriter(baos, "UTF-8"); + writer.writeStartDocument(); + writer.writeStartElement("root"); + XMLPersistence.writeColor(writer, "color", black); + writer.writeEndElement(); + writer.writeEndDocument(); + writer.flush(); + + String xml = baos.toString(StandardCharsets.UTF_8); + assertTrue(xml.contains("0")); + assertTrue(xml.contains("0")); + assertTrue(xml.contains("0")); + } + + @Test + @DisplayName("writeColor with white produces 255 RGB values") + void writeWhiteColor() throws Exception { + Color white = Color.WHITE; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + javax.xml.stream.XMLStreamWriter writer = + javax.xml.stream.XMLOutputFactory.newInstance() + .createXMLStreamWriter(baos, "UTF-8"); + writer.writeStartDocument(); + writer.writeStartElement("root"); + XMLPersistence.writeColor(writer, "color", white); + writer.writeEndElement(); + writer.writeEndDocument(); + writer.flush(); + + String xml = baos.toString(StandardCharsets.UTF_8); + assertTrue(xml.contains("255")); + assertTrue(xml.contains("255")); + assertTrue(xml.contains("255")); + } + } + + // --------------------------------------------------------------------------- + // load – error handling + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("load – error handling") + class LoadErrorHandlingTest { + + @Test + @DisplayName("loading XML with wrong root element throws Exception") + void wrongRootElementThrows() { + String xml = ""; + InputStream stream = new ByteArrayInputStream( + xml.getBytes(StandardCharsets.UTF_8)); + + // We need a minimal Model stub. If the real Model is available on the + // classpath, use it; otherwise this test documents expected behaviour. + assertThrows(Exception.class, () -> { + // A real Model must have 0 items at this point. + org.csstudio.trends.databrowser3.model.Model model = + new org.csstudio.trends.databrowser3.model.Model(); + XMLPersistence.load(model, stream); + }); + } + + @Test + @DisplayName("loading into a non-empty model throws RuntimeException") + void nonEmptyModelThrows() { + // Build a minimal valid databrowser XML with one PV so the model ends + // up non-empty after a first load, then attempt a second load. + // If Model is on the classpath this test covers the guard clause: + // if (model.getItems().size() > 0) throw new RuntimeException(...) + assertThrows(RuntimeException.class, () -> { + String xml = + ""; + org.csstudio.trends.databrowser3.model.Model model = + new org.csstudio.trends.databrowser3.model.Model(); + // First load – succeeds (empty databrowser) + XMLPersistence.load(model, new ByteArrayInputStream( + xml.getBytes(StandardCharsets.UTF_8))); + // Manually add an item so model is no longer empty, then try again + // (exact mechanism depends on Model API; adapt as needed) + // For the sake of this template we just invoke load a second time + // with a non-empty model (if the first load added items via the XML). + // Adjust once real Model is available. + model.addItem(new PVItem("foo", 1.0)); + XMLPersistence.load(model, new ByteArrayInputStream( + xml.getBytes(StandardCharsets.UTF_8))); + }); + } + } + + // --------------------------------------------------------------------------- + // XML tags – spot-check a few more + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("XML tag constants – spot checks") + class TagConstantsSpotCheck { + + @Test + void tagPv() { assertEquals("pv", XMLPersistence.TAG_PV); } + @Test + void tagFormula() { assertEquals("formula", XMLPersistence.TAG_FORMULA); } + @Test + void tagAxis() { assertEquals("axis", XMLPersistence.TAG_AXIS); } + @Test + void tagAxes() { assertEquals("axes", XMLPersistence.TAG_AXES); } + @Test + void tagAnnotations() { assertEquals("annotations", XMLPersistence.TAG_ANNOTATIONS); } + @Test + void tagStart() { assertEquals("start", XMLPersistence.TAG_START); } + @Test + void tagEnd() { assertEquals("end", XMLPersistence.TAG_END); } + @Test + void tagScroll() { assertEquals("scroll", XMLPersistence.TAG_SCROLL); } + @Test + void tagTitle() { assertEquals("title", XMLPersistence.TAG_TITLE); } + @Test + void tagName() { assertEquals("name", XMLPersistence.TAG_NAME); } + } + + @Test + @DisplayName("Write all PVs before formulas") + public void testPvAndFormulaOrdering() throws Exception { + + Model model = new Model(); + + PVItem pvItem1 = new PVItem("pvitem1", 1.0); + FormulaInput formulaInput1 = new FormulaInput(pvItem1, "x1"); + FormulaItem formulaItem1 = new FormulaItem("formula1", "x1 * 2", new FormulaInput[]{formulaInput1}); + + model.addItem(pvItem1); + model.addItem(formulaItem1); + + PVItem pvItem2 = new PVItem("pvitem2", 1.0); + + FormulaInput formulaInput2 = new FormulaInput(pvItem2, "x2"); + + formulaItem1 = (FormulaItem) model.getItem("formula1"); + + formulaItem1.updateFormula("x1 + x2", new FormulaInput[]{formulaInput1, formulaInput2}); + + model.addItem(pvItem2); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + XMLPersistence.write(model, baos); + + // Make sure the XML is readable + XMLPersistence.load(new Model(), new ByteArrayInputStream(baos.toByteArray())); + + } +}