diff --git a/bundles/org.openhab.core.io.rest.sse/src/main/java/org/openhab/core/io/rest/sse/internal/SseItemStatesEventBuilder.java b/bundles/org.openhab.core.io.rest.sse/src/main/java/org/openhab/core/io/rest/sse/internal/SseItemStatesEventBuilder.java index 1cda27283..184eb5b25 100644 --- a/bundles/org.openhab.core.io.rest.sse/src/main/java/org/openhab/core/io/rest/sse/internal/SseItemStatesEventBuilder.java +++ b/bundles/org.openhab.core.io.rest.sse/src/main/java/org/openhab/core/io/rest/sse/internal/SseItemStatesEventBuilder.java @@ -14,6 +14,7 @@ package org.openhab.core.io.rest.sse.internal; import java.time.DateTimeException; import java.util.HashMap; +import java.util.IllegalFormatException; import java.util.Locale; import java.util.Map; import java.util.Set; @@ -41,7 +42,6 @@ import org.openhab.core.types.StateDescription; import org.openhab.core.types.StateOption; import org.openhab.core.types.UnDefType; import org.openhab.core.types.util.UnitUtils; -import org.osgi.framework.BundleContext; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; @@ -61,15 +61,13 @@ public class SseItemStatesEventBuilder { private final Logger logger = LoggerFactory.getLogger(SseItemStatesEventBuilder.class); - private final BundleContext bundleContext; private final ItemRegistry itemRegistry; private final LocaleService localeService; private final StartLevelService startLevelService; @Activate - public SseItemStatesEventBuilder(final BundleContext bundleContext, final @Reference ItemRegistry itemRegistry, + public SseItemStatesEventBuilder(final @Reference ItemRegistry itemRegistry, final @Reference LocaleService localeService, final @Reference StartLevelService startLevelService) { - this.bundleContext = bundleContext; this.itemRegistry = itemRegistry; this.localeService = localeService; this.startLevelService = startLevelService; @@ -110,71 +108,78 @@ public class SseItemStatesEventBuilder { return null; } - private @Nullable String getDisplayState(Item item, Locale locale) { + protected @Nullable String getDisplayState(Item item, Locale locale) { StateDescription stateDescription = item.getStateDescription(locale); State state = item.getState(); String displayState = state.toString(); + // NULL/UNDEF state is returned as "NULL"/"UNDEF" without considering anything else if (!(state instanceof UnDefType)) { if (stateDescription != null) { - if (!stateDescription.getOptions().isEmpty()) { - // Look for a state option with a label corresponding to the state + boolean transformUsed = false; + boolean optionMatched = false; + String pattern = stateDescription.getPattern(); + // If there's a pattern, first check if it's a transformation + if (pattern != null && TransformationHelper.isTransform(pattern)) { + transformUsed = true; + try { + displayState = TransformationHelper.transform(pattern, state.toString()); + } catch (TransformationException e) { + logger.warn("Failed transforming the state '{}' on item '{}' with pattern '{}': {}", state, + item.getName(), pattern, e.getMessage()); + } + } else if (!stateDescription.getOptions().isEmpty()) { + // Look for a state option with a value corresponding to the state for (StateOption option : stateDescription.getOptions()) { - if (option.getValue().equals(state.toString()) && option.getLabel() != null) { - displayState = option.getLabel(); + String label = option.getLabel(); + if (option.getValue().equals(state.toString()) && label != null) { + optionMatched = true; + try { + displayState = pattern == null ? label : String.format(pattern, label); + } catch (IllegalFormatException e) { + logger.debug( + "Unable to format option label '{}' of item {} using format pattern '{}': {}, displaying option label", + label, item.getName(), pattern, e.getMessage()); + displayState = label; + } break; } } - } else { - // If there's a pattern, first check if it's a transformation - String pattern = stateDescription.getPattern(); - if (pattern != null) { - if (TransformationHelper.isTransform(pattern)) { - try { - displayState = TransformationHelper.transform(bundleContext, pattern, state.toString()); - } catch (NoClassDefFoundError ex) { - // TransformationHelper is optional dependency, so ignore if class not found - // return state as it is without transformation - } catch (TransformationException e) { - logger.warn("Failed transforming the state '{}' on item '{}' with pattern '{}': {}", - state, item.getName(), pattern, e.getMessage()); - } - } else { - // if it's not a transformation pattern, then it must be a format string - - if (state instanceof QuantityType quantityState) { - // sanity convert current state to the item state description unit in case it was - // updated in the meantime. The item state is still in the "original" unit while the - // state description will display the new unit: - Unit patternUnit = UnitUtils.parseUnit(pattern); - if (patternUnit != null && !quantityState.getUnit().equals(patternUnit)) { - quantityState = quantityState.toInvertibleUnit(patternUnit); - } - - if (quantityState != null) { - state = quantityState; - } - } else if (state instanceof DateTimeType type) { - // Translate a DateTimeType state to the local time zone - try { - state = type.toLocaleZone(); - } catch (DateTimeException e) { - } - } - - // The following exception handling has been added to work around a Java bug with formatting - // numbers. See http://bugs.sun.com/view_bug.do?bug_id=6476425 - // This also handles IllegalFormatConversionException, which is a subclass of - // IllegalArgument. - try { - displayState = state.format(pattern); - } catch (IllegalArgumentException e) { - logger.debug( - "Unable to format value '{}' of item {} with format '{}': {}, displaying raw state", - state, item.getName(), pattern, e.getMessage()); - displayState = state.toString(); - } + } + if (pattern != null && !transformUsed && !optionMatched) { + // if it's not a transformation pattern and there is no matching state option, + // then it must be a format string + if (state instanceof QuantityType quantityState) { + // sanity convert current state to the item state description unit in case it was + // updated in the meantime. The item state is still in the "original" unit while the + // state description will display the new unit: + Unit patternUnit = UnitUtils.parseUnit(pattern); + if (patternUnit != null && !quantityState.getUnit().equals(patternUnit)) { + quantityState = quantityState.toInvertibleUnit(patternUnit); } + + if (quantityState != null) { + state = quantityState; + } + } else if (state instanceof DateTimeType type) { + // Translate a DateTimeType state to the local time zone + try { + state = type.toLocaleZone(); + } catch (DateTimeException e) { + } + } + + // The following exception handling has been added to work around a Java bug with formatting + // numbers. See http://bugs.sun.com/view_bug.do?bug_id=6476425 + // This also handles IllegalFormatConversionException, which is a subclass of + // IllegalArgument. + try { + displayState = state.format(pattern); + } catch (IllegalArgumentException e) { + logger.debug( + "Unable to format value '{}' of item {} using format pattern '{}': {}, displaying raw state", + state, item.getName(), pattern, e.getMessage()); + displayState = state.toString(); } } } diff --git a/bundles/org.openhab.core.io.rest.sse/src/test/java/org/openhab/core/io/rest/sse/internal/SseItemStatesEventBuilderTest.java b/bundles/org.openhab.core.io.rest.sse/src/test/java/org/openhab/core/io/rest/sse/internal/SseItemStatesEventBuilderTest.java new file mode 100644 index 000000000..fc4ee065b --- /dev/null +++ b/bundles/org.openhab.core.io.rest.sse/src/test/java/org/openhab/core/io/rest/sse/internal/SseItemStatesEventBuilderTest.java @@ -0,0 +1,240 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.sse.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.*; + +import java.util.Locale; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.openhab.core.io.rest.LocaleService; +import org.openhab.core.items.Item; +import org.openhab.core.items.ItemRegistry; +import org.openhab.core.library.types.StringType; +import org.openhab.core.service.StartLevelService; +import org.openhab.core.transform.TransformationException; +import org.openhab.core.transform.TransformationHelper; +import org.openhab.core.transform.TransformationService; +import org.openhab.core.types.StateDescription; +import org.openhab.core.types.StateDescriptionFragmentBuilder; +import org.openhab.core.types.StateOption; +import org.openhab.core.types.UnDefType; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceReference; + +/** + * The {@link SseItemStatesEventBuilderTest} contains tests for the method getDisplayState from + * {@link SseItemStatesEventBuilder} + * + * @author Laurent Garnier - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@NonNullByDefault +public class SseItemStatesEventBuilderTest { + private static final String ITEM_NAME = "test"; + private static final String ITEM_STATE_VALUE = "value"; + private static final String ITEM_STATE_VALUE2 = "other"; + private static final String ITEM_STATE_OPTION_LABEL = "The value"; + + private static final String PATTERN = "__ %s __"; + private static final String WRONG_PATTERN = "__ %d __"; + + private static final String TRANSFORM_NAME = "TRANSFORM"; + private static final String TRANSFORM_PATTERN = "Pattern"; + private static final String TRANSFORM_FORMAT = "%s-1"; + private static final String TRANSFORM_INPUT = String.format(TRANSFORM_FORMAT, ITEM_STATE_VALUE); + private static final String TRANSFORM_RESULT = "Result"; + + private @Mock @NonNullByDefault({}) ItemRegistry itemRegistryMock; + private @Mock @NonNullByDefault({}) LocaleService localeServiceMock; + private @Mock @NonNullByDefault({}) StartLevelService startLevelServiceMock; + + private @Mock @NonNullByDefault({}) Item itemMock; + + private @Mock @NonNullByDefault({}) TransformationService transformationServiceMock; + + private @Mock @NonNullByDefault({}) BundleContext bundleContextMock; + private @Mock @NonNullByDefault({}) ServiceReference serviceRefMock; + + private @NonNullByDefault({}) TransformationHelper transformationHelper; + + private @NonNullByDefault({}) SseItemStatesEventBuilder sseItemStatesEventBuilder; + + @BeforeEach + public void init() throws TransformationException { + Mockito.when(transformationServiceMock.transform(eq(TRANSFORM_PATTERN), eq(TRANSFORM_INPUT))) + .thenAnswer(answer -> TRANSFORM_RESULT); + + Mockito.when(serviceRefMock.getProperty(any())).thenReturn(TRANSFORM_NAME); + + Mockito.when(bundleContextMock.getService(serviceRefMock)).thenReturn(transformationServiceMock); + + transformationHelper = new TransformationHelper(bundleContextMock); + transformationHelper.setTransformationService(serviceRefMock); + + Mockito.when(itemMock.getName()).thenReturn(ITEM_NAME); + + sseItemStatesEventBuilder = new SseItemStatesEventBuilder(itemRegistryMock, localeServiceMock, + startLevelServiceMock); + } + + @AfterEach + public void tearDown() { + transformationHelper.deactivate(); + } + + @Test + public void getDisplayStateWhenMatchingStateOptionAndNoPattern() { + StateDescription stateDescription = StateDescriptionFragmentBuilder.create() + .withOption(new StateOption(ITEM_STATE_VALUE, ITEM_STATE_OPTION_LABEL)).build().toStateDescription(); + Mockito.when(itemMock.getStateDescription(eq(Locale.ENGLISH))).thenReturn(stateDescription); + + Mockito.when(itemMock.getState()).thenReturn(new StringType(ITEM_STATE_VALUE)); + String result = sseItemStatesEventBuilder.getDisplayState(itemMock, Locale.ENGLISH); + assertEquals(ITEM_STATE_OPTION_LABEL, result); + } + + @Test + public void getDisplayStateWhenNoMatchingStateOptionAndNoPattern() { + StateDescription stateDescription = StateDescriptionFragmentBuilder.create() + .withOption(new StateOption(ITEM_STATE_VALUE, ITEM_STATE_OPTION_LABEL)).build().toStateDescription(); + Mockito.when(itemMock.getStateDescription(eq(Locale.ENGLISH))).thenReturn(stateDescription); + + Mockito.when(itemMock.getState()).thenReturn(new StringType(ITEM_STATE_VALUE2)); + String result = sseItemStatesEventBuilder.getDisplayState(itemMock, Locale.ENGLISH); + assertEquals(ITEM_STATE_VALUE2, result); + } + + @Test + public void getDisplayStateWhenMatchingStateOptionAndPattern() { + StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withPattern(PATTERN) + .withOption(new StateOption(ITEM_STATE_VALUE, ITEM_STATE_OPTION_LABEL)).build().toStateDescription(); + Mockito.when(itemMock.getStateDescription(eq(Locale.ENGLISH))).thenReturn(stateDescription); + + Mockito.when(itemMock.getState()).thenReturn(new StringType(ITEM_STATE_VALUE)); + String result = sseItemStatesEventBuilder.getDisplayState(itemMock, Locale.ENGLISH); + assertEquals(String.format(PATTERN, ITEM_STATE_OPTION_LABEL), result); + } + + @Test + public void getDisplayStateWhenMatchingStateOptionAndWrongPattern() { + StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withPattern(WRONG_PATTERN) + .withOption(new StateOption(ITEM_STATE_VALUE, ITEM_STATE_OPTION_LABEL)).build().toStateDescription(); + Mockito.when(itemMock.getStateDescription(eq(Locale.ENGLISH))).thenReturn(stateDescription); + + Mockito.when(itemMock.getState()).thenReturn(new StringType(ITEM_STATE_VALUE)); + String result = sseItemStatesEventBuilder.getDisplayState(itemMock, Locale.ENGLISH); + assertEquals(ITEM_STATE_OPTION_LABEL, result); + } + + @Test + public void getDisplayStateWhenNoMatchingStateOptionAndPattern() { + StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withPattern(PATTERN) + .withOption(new StateOption(ITEM_STATE_VALUE, ITEM_STATE_OPTION_LABEL)).build().toStateDescription(); + Mockito.when(itemMock.getStateDescription(eq(Locale.ENGLISH))).thenReturn(stateDescription); + + Mockito.when(itemMock.getState()).thenReturn(new StringType(ITEM_STATE_VALUE2)); + String result = sseItemStatesEventBuilder.getDisplayState(itemMock, Locale.ENGLISH); + assertEquals(String.format(PATTERN, ITEM_STATE_VALUE2), result); + } + + @Test + public void getDisplayStateWhenTransformAndNoStateOption() { + StateDescription stateDescription = StateDescriptionFragmentBuilder.create() + .withPattern(TRANSFORM_NAME + "(" + TRANSFORM_PATTERN + "):" + TRANSFORM_FORMAT).build() + .toStateDescription(); + Mockito.when(itemMock.getStateDescription(eq(Locale.ENGLISH))).thenReturn(stateDescription); + Mockito.when(itemMock.getState()).thenReturn(new StringType(ITEM_STATE_VALUE)); + String result = sseItemStatesEventBuilder.getDisplayState(itemMock, Locale.ENGLISH); + assertEquals(TRANSFORM_RESULT, result); + } + + @Test + public void getDisplayStateWhenTransformAndMatchingStateOption() { + StateDescription stateDescription = StateDescriptionFragmentBuilder.create() + .withPattern(TRANSFORM_NAME + "(" + TRANSFORM_PATTERN + "):" + TRANSFORM_FORMAT) + .withOption(new StateOption(ITEM_STATE_VALUE, ITEM_STATE_OPTION_LABEL)).build().toStateDescription(); + Mockito.when(itemMock.getStateDescription(eq(Locale.ENGLISH))).thenReturn(stateDescription); + Mockito.when(itemMock.getState()).thenReturn(new StringType(ITEM_STATE_VALUE)); + String result = sseItemStatesEventBuilder.getDisplayState(itemMock, Locale.ENGLISH); + assertEquals(TRANSFORM_RESULT, result); + } + + @Test + public void getDisplayStateWhenStateUndef() { + StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withPattern(PATTERN) + .withOption(new StateOption(ITEM_STATE_VALUE, ITEM_STATE_OPTION_LABEL)).build().toStateDescription(); + Mockito.when(itemMock.getStateDescription(eq(Locale.ENGLISH))).thenReturn(stateDescription); + Mockito.when(itemMock.getState()).thenReturn(UnDefType.UNDEF); + String result = sseItemStatesEventBuilder.getDisplayState(itemMock, Locale.ENGLISH); + assertEquals("UNDEF", result); + } + + @Test + public void getDisplayStateWhenStateNull() { + StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withPattern(PATTERN) + .withOption(new StateOption(ITEM_STATE_VALUE, ITEM_STATE_OPTION_LABEL)).build().toStateDescription(); + Mockito.when(itemMock.getStateDescription(eq(Locale.ENGLISH))).thenReturn(stateDescription); + Mockito.when(itemMock.getState()).thenReturn(UnDefType.NULL); + String result = sseItemStatesEventBuilder.getDisplayState(itemMock, Locale.ENGLISH); + assertEquals("NULL", result); + } + + @Test + public void getDisplayStateWhenPatternProvided() { + StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withPattern(PATTERN).build() + .toStateDescription(); + Mockito.when(itemMock.getStateDescription(eq(Locale.ENGLISH))).thenReturn(stateDescription); + Mockito.when(itemMock.getState()).thenReturn(new StringType(ITEM_STATE_VALUE)); + String result = sseItemStatesEventBuilder.getDisplayState(itemMock, Locale.ENGLISH); + assertEquals(String.format(PATTERN, ITEM_STATE_VALUE), result); + } + + @Test + public void getDisplayStateWhenWrongPatternProvided() { + StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withPattern(WRONG_PATTERN).build() + .toStateDescription(); + Mockito.when(itemMock.getStateDescription(eq(Locale.ENGLISH))).thenReturn(stateDescription); + Mockito.when(itemMock.getState()).thenReturn(new StringType(ITEM_STATE_VALUE)); + String result = sseItemStatesEventBuilder.getDisplayState(itemMock, Locale.ENGLISH); + assertEquals(ITEM_STATE_VALUE, result); + } + + @Test + public void getDisplayStateWhenNoPatternProvided() { + StateDescription stateDescription = StateDescriptionFragmentBuilder.create().build().toStateDescription(); + Mockito.when(itemMock.getStateDescription(eq(Locale.ENGLISH))).thenReturn(stateDescription); + Mockito.when(itemMock.getState()).thenReturn(new StringType(ITEM_STATE_VALUE)); + String result = sseItemStatesEventBuilder.getDisplayState(itemMock, Locale.ENGLISH); + assertEquals(ITEM_STATE_VALUE, result); + } + + @Test + public void getDisplayStateWhenNoStateDescription() { + Mockito.when(itemMock.getStateDescription(eq(Locale.ENGLISH))).thenReturn(null); + Mockito.when(itemMock.getState()).thenReturn(new StringType(ITEM_STATE_VALUE)); + String result = sseItemStatesEventBuilder.getDisplayState(itemMock, Locale.ENGLISH); + assertEquals(ITEM_STATE_VALUE, result); + } +}