diff --git a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/entity/bean/PointValueTest.java b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/entity/bean/PointValueTest.java
new file mode 100644
index 000000000..c186e7e6f
--- /dev/null
+++ b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/entity/bean/PointValueTest.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2016-present the IoT DC3 original author or authors.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package io.github.pnoker.common.driver.entity.bean;
+
+import io.github.pnoker.common.driver.entity.bo.DeviceBO;
+import io.github.pnoker.common.driver.entity.bo.PointBO;
+import io.github.pnoker.common.enums.PointTypeFlagEnum;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link PointValue} verifying that the RValue constructor
+ * correctly propagates calValue and numValue.
+ */
+class PointValueTest {
+
+ private DeviceBO device;
+ private PointBO point;
+
+ @BeforeEach
+ void setUp() {
+ device = new DeviceBO();
+ device.setId(1L);
+ point = new PointBO();
+ point.setId(10L);
+ point.setBaseValue(BigDecimal.ZERO);
+ point.setMultiple(BigDecimal.ONE);
+ point.setValueDecimal((byte) 6);
+ }
+
+ @Test
+ void numericTypePopulatesNumValue() {
+ point.setPointTypeFlag(PointTypeFlagEnum.DOUBLE);
+ RValue rValue = new RValue(device, point, "42.5");
+ PointValue pv = new PointValue(rValue);
+
+ assertThat(pv.getCalValue()).isEqualTo("42.5");
+ assertThat(pv.getNumValue()).isEqualTo(42.5);
+ assertThat(pv.getDeviceId()).isEqualTo(1L);
+ assertThat(pv.getPointId()).isEqualTo(10L);
+ assertThat(pv.getCreateTime()).isNotNull();
+ }
+
+ @Test
+ void stringTypeLeavesNumValueNull() {
+ point.setPointTypeFlag(PointTypeFlagEnum.STRING);
+ RValue rValue = new RValue(device, point, "hello");
+ PointValue pv = new PointValue(rValue);
+
+ assertThat(pv.getCalValue()).isEqualTo("hello");
+ assertThat(pv.getNumValue()).isNull();
+ }
+
+ @Test
+ void booleanTrueMapsNumValueToOne() {
+ point.setPointTypeFlag(PointTypeFlagEnum.BOOLEAN);
+ RValue rValue = new RValue(device, point, "true");
+ PointValue pv = new PointValue(rValue);
+
+ assertThat(pv.getCalValue()).isEqualTo("true");
+ assertThat(pv.getNumValue()).isEqualTo(1.0);
+ }
+
+ @Test
+ void booleanFalseMapsNumValueToZero() {
+ point.setPointTypeFlag(PointTypeFlagEnum.BOOLEAN);
+ RValue rValue = new RValue(device, point, "false");
+ PointValue pv = new PointValue(rValue);
+
+ assertThat(pv.getCalValue()).isEqualTo("false");
+ assertThat(pv.getNumValue()).isEqualTo(0.0);
+ }
+
+ @Test
+ void intTypePopulatesNumValue() {
+ point.setPointTypeFlag(PointTypeFlagEnum.INT);
+ RValue rValue = new RValue(device, point, "100");
+ PointValue pv = new PointValue(rValue);
+
+ assertThat(pv.getCalValue()).isEqualTo("100");
+ assertThat(pv.getNumValue()).isEqualTo(100.0);
+ }
+
+ @Test
+ void builderHasNumValueField() {
+ PointValue pv = PointValue.builder()
+ .deviceId(1L)
+ .pointId(10L)
+ .rawValue("raw")
+ .calValue("cal")
+ .numValue(42.0)
+ .build();
+
+ assertThat(pv.getNumValue()).isEqualTo(42.0);
+ assertThat(pv.getCalValue()).isEqualTo("cal");
+ }
+
+ @Test
+ void nullNumValueIsSerializedAsNull() {
+ PointValue pv = PointValue.builder()
+ .deviceId(1L)
+ .pointId(10L)
+ .calValue("text")
+ .build();
+
+ assertThat(pv.getNumValue()).isNull();
+ }
+}
diff --git a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/entity/bean/RValueTest.java b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/entity/bean/RValueTest.java
new file mode 100644
index 000000000..ad1da959b
--- /dev/null
+++ b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/entity/bean/RValueTest.java
@@ -0,0 +1,292 @@
+/*
+ * Copyright 2016-present the IoT DC3 original author or authors.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package io.github.pnoker.common.driver.entity.bean;
+
+import io.github.pnoker.common.driver.entity.bo.DeviceBO;
+import io.github.pnoker.common.driver.entity.bo.PointBO;
+import io.github.pnoker.common.enums.PointTypeFlagEnum;
+import io.github.pnoker.common.exception.EmptyException;
+import io.github.pnoker.common.exception.OutRangeException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link RValue} covering type conversion, numeric projection,
+ * scaling, and edge cases.
+ */
+class RValueTest {
+
+ private DeviceBO device;
+ private PointBO point;
+
+ @BeforeEach
+ void setUp() {
+ device = new DeviceBO();
+ device.setId(1L);
+ point = new PointBO();
+ point.setId(10L);
+ }
+
+ private PointBO pointWithType(PointTypeFlagEnum type) {
+ point.setPointTypeFlag(type);
+ point.setBaseValue(BigDecimal.ZERO);
+ point.setMultiple(BigDecimal.ONE);
+ point.setValueDecimal((byte) 6);
+ return point;
+ }
+
+ private PointBO pointWithTypeAndScale(PointTypeFlagEnum type, BigDecimal base, BigDecimal multiple, byte decimal) {
+ point.setPointTypeFlag(type);
+ point.setBaseValue(base);
+ point.setMultiple(multiple);
+ point.setValueDecimal(decimal);
+ return point;
+ }
+
+ @Nested
+ class StringType {
+
+ @Test
+ void stringValueReturnsRawInput() {
+ pointWithType(PointTypeFlagEnum.STRING);
+ RValue rValue = new RValue(device, point, "hello world");
+
+ assertThat(rValue.getFinalValue()).isEqualTo("hello world");
+ assertThat(rValue.getNumericValue()).isNull();
+ }
+
+ @Test
+ void stringNumericTextStillNull() {
+ pointWithType(PointTypeFlagEnum.STRING);
+ RValue rValue = new RValue(device, point, "42");
+
+ assertThat(rValue.getFinalValue()).isEqualTo("42");
+ assertThat(rValue.getNumericValue()).isNull();
+ }
+
+ @Test
+ void defaultTypeIsString() {
+ point.setPointTypeFlag(null);
+ point.setBaseValue(BigDecimal.ZERO);
+ point.setMultiple(BigDecimal.ONE);
+ RValue rValue = new RValue(device, point, "anything");
+
+ assertThat(rValue.getFinalValue()).isEqualTo("anything");
+ assertThat(rValue.getNumericValue()).isNull();
+ }
+ }
+
+ @Nested
+ class NumericTypes {
+
+ @Test
+ void byteType() {
+ pointWithType(PointTypeFlagEnum.BYTE);
+ RValue rValue = new RValue(device, point, "100");
+
+ assertThat(rValue.getFinalValue()).isEqualTo("100");
+ assertThat(rValue.getNumericValue()).isEqualTo(100.0);
+ }
+
+ @Test
+ void shortType() {
+ pointWithType(PointTypeFlagEnum.SHORT);
+ RValue rValue = new RValue(device, point, "1000");
+
+ assertThat(rValue.getFinalValue()).isEqualTo("1000");
+ assertThat(rValue.getNumericValue()).isEqualTo(1000.0);
+ }
+
+ @Test
+ void intType() {
+ pointWithType(PointTypeFlagEnum.INT);
+ RValue rValue = new RValue(device, point, "42");
+
+ assertThat(rValue.getFinalValue()).isEqualTo("42");
+ assertThat(rValue.getNumericValue()).isEqualTo(42.0);
+ }
+
+ @Test
+ void longType() {
+ pointWithType(PointTypeFlagEnum.LONG);
+ RValue rValue = new RValue(device, point, "9999999999");
+
+ assertThat(rValue.getFinalValue()).isEqualTo("9999999999");
+ assertThat(rValue.getNumericValue()).isEqualTo(9999999999.0);
+ }
+
+ @Test
+ void floatType() {
+ pointWithType(PointTypeFlagEnum.FLOAT);
+ RValue rValue = new RValue(device, point, "3.14");
+
+ assertThat(rValue.getFinalValue()).isEqualTo("3.14");
+ assertThat(rValue.getNumericValue()).isCloseTo(3.14, org.assertj.core.data.Offset.offset(0.001));
+ }
+
+ @Test
+ void doubleType() {
+ pointWithType(PointTypeFlagEnum.DOUBLE);
+ RValue rValue = new RValue(device, point, "2.718281828");
+
+ // ArithmeticUtil.round with decimal=6 rounds to 6 decimal places
+ assertThat(rValue.getFinalValue()).isEqualTo("2.718282");
+ assertThat(rValue.getNumericValue()).isCloseTo(2.718282, org.assertj.core.data.Offset.offset(0.000001));
+ }
+
+ @Test
+ void negativeDouble() {
+ pointWithType(PointTypeFlagEnum.DOUBLE);
+ RValue rValue = new RValue(device, point, "-42.5");
+
+ assertThat(rValue.getFinalValue()).isEqualTo("-42.5");
+ assertThat(rValue.getNumericValue()).isEqualTo(-42.5);
+ }
+
+ @Test
+ void zero() {
+ pointWithType(PointTypeFlagEnum.INT);
+ RValue rValue = new RValue(device, point, "0");
+
+ assertThat(rValue.getFinalValue()).isEqualTo("0");
+ assertThat(rValue.getNumericValue()).isEqualTo(0.0);
+ }
+ }
+
+ @Nested
+ class BooleanType {
+
+ @Test
+ void trueMapsToOne() {
+ pointWithType(PointTypeFlagEnum.BOOLEAN);
+ RValue rValue = new RValue(device, point, "true");
+
+ assertThat(rValue.getFinalValue()).isEqualTo("true");
+ assertThat(rValue.getNumericValue()).isEqualTo(1.0);
+ }
+
+ @Test
+ void falseMapsToZero() {
+ pointWithType(PointTypeFlagEnum.BOOLEAN);
+ RValue rValue = new RValue(device, point, "false");
+
+ assertThat(rValue.getFinalValue()).isEqualTo("false");
+ assertThat(rValue.getNumericValue()).isEqualTo(0.0);
+ }
+ }
+
+ @Nested
+ class Scaling {
+
+ @Test
+ void offsetApplied() {
+ pointWithTypeAndScale(PointTypeFlagEnum.INT, new BigDecimal("10"), BigDecimal.ONE, (byte) 0);
+ RValue rValue = new RValue(device, point, "5");
+
+ assertThat(rValue.getFinalValue()).isEqualTo("15");
+ assertThat(rValue.getNumericValue()).isEqualTo(15.0);
+ }
+
+ @Test
+ void multiplierApplied() {
+ pointWithTypeAndScale(PointTypeFlagEnum.DOUBLE, BigDecimal.ZERO, new BigDecimal("2.5"), (byte) 2);
+ RValue rValue = new RValue(device, point, "10");
+
+ assertThat(rValue.getFinalValue()).isEqualTo("25.0");
+ assertThat(rValue.getNumericValue()).isEqualTo(25.0);
+ }
+
+ @Test
+ void linearTransformApplied() {
+ pointWithTypeAndScale(PointTypeFlagEnum.DOUBLE, new BigDecimal("32"), new BigDecimal("1.8"), (byte) 2);
+ RValue rValue = new RValue(device, point, "100");
+
+ assertThat(rValue.getFinalValue()).isEqualTo("212.0");
+ assertThat(rValue.getNumericValue()).isEqualTo(212.0);
+ }
+ }
+
+ @Nested
+ class ErrorHandling {
+
+ @Test
+ void nullPointThrowsEmptyException() {
+ RValue rValue = RValue.builder().device(device).point(null).value("42").build();
+
+ assertThatThrownBy(rValue::getFinalValue).isInstanceOf(EmptyException.class);
+ }
+
+ @Test
+ void outOfRangeByteThrows() {
+ pointWithType(PointTypeFlagEnum.BYTE);
+ RValue rValue = new RValue(device, point, "200");
+
+ assertThatThrownBy(rValue::getFinalValue).isInstanceOf(OutRangeException.class);
+ }
+
+ @Test
+ void outOfRangeShortThrows() {
+ pointWithType(PointTypeFlagEnum.SHORT);
+ RValue rValue = new RValue(device, point, "100000");
+
+ assertThatThrownBy(rValue::getFinalValue).isInstanceOf(OutRangeException.class);
+ }
+
+ @Test
+ void invalidNumberForIntThrows() {
+ pointWithType(PointTypeFlagEnum.INT);
+ RValue rValue = new RValue(device, point, "not-a-number");
+
+ // BigDecimal constructor throws NumberFormatException, wrapped by OutRangeException
+ assertThatThrownBy(rValue::getFinalValue).isInstanceOf(OutRangeException.class);
+ }
+ }
+
+ @Nested
+ class NumericValueCaching {
+
+ @Test
+ void numericValueIsCachedAfterGetFinalValue() {
+ pointWithType(PointTypeFlagEnum.DOUBLE);
+ RValue rValue = new RValue(device, point, "3.14");
+
+ Double first = rValue.getNumericValue();
+ // Before calling getFinalValue, numericValue may be null (convenience constructor doesn't compute)
+ rValue.getFinalValue();
+ Double after = rValue.getNumericValue();
+
+ assertThat(after).isCloseTo(3.14, org.assertj.core.data.Offset.offset(0.001));
+ }
+
+ @Test
+ void numericValueIsNullBeforeGetFinalValueWithBuilder() {
+ pointWithType(PointTypeFlagEnum.DOUBLE);
+ RValue rValue = RValue.builder().device(device).point(point).value("3.14").build();
+
+ // Builder doesn't trigger getFinalValue, so numericValue should be null
+ assertThat(rValue.getNumericValue()).isNull();
+ rValue.getFinalValue();
+ assertThat(rValue.getNumericValue()).isCloseTo(3.14, org.assertj.core.data.Offset.offset(0.001));
+ }
+ }
+}