diff --git a/src/main/java/io/mapsmessaging/state/n2k/listener/AbstractN2kJsonListener.java b/src/main/java/io/mapsmessaging/state/n2k/listener/AbstractN2kJsonListener.java index 1f85adaf9..f2d074872 100644 --- a/src/main/java/io/mapsmessaging/state/n2k/listener/AbstractN2kJsonListener.java +++ b/src/main/java/io/mapsmessaging/state/n2k/listener/AbstractN2kJsonListener.java @@ -21,8 +21,10 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; import io.mapsmessaging.state.drone.core.TwinUpdateContext; +import java.math.BigDecimal; import java.time.Instant; public abstract class AbstractN2kJsonListener implements N2kJsonListener { @@ -50,47 +52,63 @@ protected boolean hasAny(JsonObject packet, String... names) { } protected Double getDouble(JsonObject packet, String... names) { - JsonElement jsonElement = getElement(packet, names); - if (jsonElement == null) { + JsonPrimitive primitive = getPrimitive(packet, names); + if (primitive == null || primitive.isBoolean()) { + return null; + } + + try { + double value = primitive.getAsDouble(); + return Double.isFinite(value) ? value : null; + } catch (NumberFormatException | UnsupportedOperationException exception) { return null; } - return jsonElement.getAsDouble(); } protected Integer getInteger(JsonObject packet, String... names) { - JsonElement jsonElement = getElement(packet, names); - if (jsonElement == null) { + BigDecimal value = getDecimal(packet, names); + if (value == null) { + return null; + } + + try { + return value.intValueExact(); + } catch (ArithmeticException exception) { return null; } - return jsonElement.getAsInt(); } protected Long getLong(JsonObject packet, String... names) { - JsonElement jsonElement = getElement(packet, names); - if (jsonElement == null) { + BigDecimal value = getDecimal(packet, names); + if (value == null) { + return null; + } + + try { + return value.longValueExact(); + } catch (ArithmeticException exception) { return null; } - return jsonElement.getAsLong(); } protected String getString(JsonObject packet, String... names) { - JsonElement jsonElement = getElement(packet, names); - if (jsonElement == null) { + JsonPrimitive primitive = getPrimitive(packet, names); + if (primitive == null || !primitive.isString()) { return null; } - return jsonElement.getAsString(); + return primitive.getAsString(); } protected boolean isValidLatitude(Double latitude) { - return latitude != null && latitude >= -90.0d && latitude <= 90.0d; + return latitude != null && Double.isFinite(latitude) && latitude >= -90.0d && latitude <= 90.0d; } protected boolean isValidLongitude(Double longitude) { - return longitude != null && longitude >= -180.0d && longitude <= 180.0d; + return longitude != null && Double.isFinite(longitude) && longitude >= -180.0d && longitude <= 180.0d; } protected Double normalizeDegrees(Double degrees) { - if (degrees == null) { + if (degrees == null || !Double.isFinite(degrees)) { return null; } @@ -103,12 +121,33 @@ protected Double normalizeDegrees(Double degrees) { } protected Double radiansToDegrees(Double radians) { - if (radians == null) { + if (radians == null || !Double.isFinite(radians)) { return null; } return Math.toDegrees(radians); } + private BigDecimal getDecimal(JsonObject packet, String... names) { + JsonPrimitive primitive = getPrimitive(packet, names); + if (primitive == null || primitive.isBoolean()) { + return null; + } + + try { + return new BigDecimal(primitive.getAsString().trim()); + } catch (NumberFormatException exception) { + return null; + } + } + + private JsonPrimitive getPrimitive(JsonObject packet, String... names) { + JsonElement jsonElement = getElement(packet, names); + if (jsonElement == null || !jsonElement.isJsonPrimitive()) { + return null; + } + return jsonElement.getAsJsonPrimitive(); + } + private JsonElement getElement(JsonObject packet, String... names) { if (packet == null) { return null; @@ -123,4 +162,4 @@ private JsonElement getElement(JsonObject packet, String... names) { return null; } -} \ No newline at end of file +} diff --git a/src/main/java/io/mapsmessaging/state/n2k/listener/N2kJsonListenerRegistry.java b/src/main/java/io/mapsmessaging/state/n2k/listener/N2kJsonListenerRegistry.java index 38eeaeb94..4bf68b1ea 100644 --- a/src/main/java/io/mapsmessaging/state/n2k/listener/N2kJsonListenerRegistry.java +++ b/src/main/java/io/mapsmessaging/state/n2k/listener/N2kJsonListenerRegistry.java @@ -21,27 +21,33 @@ import java.util.HashMap; import java.util.Map; +import java.util.Objects; public class N2kJsonListenerRegistry { private final Map listeners; public N2kJsonListenerRegistry() { - listeners = new HashMap<>(); - - register(new N2kPositionJsonListener()); - register(new N2kGnssJsonListener()); - register(new N2kMotionJsonListener()); - register(new N2kHeadingJsonListener()); - register(new N2kAttitudeJsonListener()); - - register(new N2kRateOfTurnJsonListener()); - register(new N2kGnssDopsJsonListener()); - register(new N2kBatteryStatusJsonListener()); - register(new N2kMagneticVariationJsonListener()); - register(new N2kWindJsonListener()); - register(new N2kEnvironmentalParametersJsonListener()); - register(new N2kInverterStatusJsonListener()); + this( + new N2kPositionJsonListener(), + new N2kGnssJsonListener(), + new N2kMotionJsonListener(), + new N2kHeadingJsonListener(), + new N2kAttitudeJsonListener(), + new N2kRateOfTurnJsonListener(), + new N2kGnssDopsJsonListener(), + new N2kBatteryStatusJsonListener(), + new N2kMagneticVariationJsonListener(), + new N2kWindJsonListener(), + new N2kEnvironmentalParametersJsonListener(), + new N2kInverterStatusJsonListener()); + } + + N2kJsonListenerRegistry(N2kJsonListener... listeners) { + this.listeners = new HashMap<>(); + for (N2kJsonListener listener : listeners) { + register(listener); + } } public N2kJsonListener getListener(int pgn) { @@ -52,7 +58,11 @@ public boolean hasListener(int pgn) { return listeners.containsKey(pgn); } - private void register(N2kJsonListener listener) { - listeners.put(listener.getPgn(), listener); + void register(N2kJsonListener listener) { + Objects.requireNonNull(listener, "listener must not be null"); + N2kJsonListener existing = listeners.putIfAbsent(listener.getPgn(), listener); + if (existing != null) { + throw new IllegalArgumentException("Duplicate N2K JSON listener for PGN " + listener.getPgn()); + } } -} \ No newline at end of file +} diff --git a/src/main/java/io/mapsmessaging/state/n2k/msg/AisMappingSupport.java b/src/main/java/io/mapsmessaging/state/n2k/msg/AisMappingSupport.java index 52ac3e596..2023d670c 100644 --- a/src/main/java/io/mapsmessaging/state/n2k/msg/AisMappingSupport.java +++ b/src/main/java/io/mapsmessaging/state/n2k/msg/AisMappingSupport.java @@ -33,8 +33,8 @@ public static boolean hasCorePosition(DroneTwin droneTwin) { return droneTwin != null && droneTwin.getMmsi() != null && droneTwin.getGeoPosition() != null - && droneTwin.getGeoPosition().getLatitude() != null - && droneTwin.getGeoPosition().getLongitude() != null; + && isValidLatitude(droneTwin.getGeoPosition().getLatitude()) + && isValidLongitude(droneTwin.getGeoPosition().getLongitude()); } public static Long toSecondOfMinute(Instant instant) { @@ -45,12 +45,19 @@ public static Long toSecondOfMinute(Instant instant) { } public static Double toRadians(Double degrees) { - if (degrees == null) { + if (degrees == null || !Double.isFinite(degrees)) { return null; } return Math.toRadians(normalizeDegrees(degrees)); } + public static Double nonNegativeFinite(Double value) { + if (value == null || !Double.isFinite(value) || value < 0.0d) { + return null; + } + return value; + } + public static double normalizeDegrees(double degrees) { double normalized = degrees % 360.0d; if (normalized < 0.0d) { @@ -104,11 +111,22 @@ public static String resolveCallsign(DroneTwin droneTwin, String configuredCalls return null; } if (droneTwin.getCallSign() != null && !droneTwin.getCallSign().isBlank()) { - return truncate(droneTwin.getCallSign(), 7); + return truncate(droneTwin.getCallSign().toUpperCase(Locale.ROOT), 7); + } + if (configuredCallsign != null && !configuredCallsign.isBlank()) { + return truncate(configuredCallsign.toUpperCase(Locale.ROOT), 7); } return null; } + private static boolean isValidLatitude(Double latitude) { + return latitude != null && Double.isFinite(latitude) && latitude >= -90.0d && latitude <= 90.0d; + } + + private static boolean isValidLongitude(Double longitude) { + return longitude != null && Double.isFinite(longitude) && longitude >= -180.0d && longitude <= 180.0d; + } + public static String truncate(String value, int maxLength) { if (value == null) { return null; diff --git a/src/main/java/io/mapsmessaging/state/n2k/msg/mapper/AisClassBExtendedPositionMapper.java b/src/main/java/io/mapsmessaging/state/n2k/msg/mapper/AisClassBExtendedPositionMapper.java index f07bfc5c0..281e801bc 100644 --- a/src/main/java/io/mapsmessaging/state/n2k/msg/mapper/AisClassBExtendedPositionMapper.java +++ b/src/main/java/io/mapsmessaging/state/n2k/msg/mapper/AisClassBExtendedPositionMapper.java @@ -63,7 +63,7 @@ public Optional map(DroneTwin droneTwin) { report.setRaim(config.getRaim()); report.setTimeStamp(AisMappingSupport.toSecondOfMinute(droneTwin.getNavigationUpdatedAt())); report.setCog(AisMappingSupport.toRadians(droneTwin.getCourseOverGroundDegrees())); - report.setSog(droneTwin.getGroundSpeedMetersPerSecond()); + report.setSog(AisMappingSupport.nonNegativeFinite(droneTwin.getGroundSpeedMetersPerSecond())); report.setRegionalApplication(0L); report.setRegionalApplicationB(0L); report.setTypeOfShip(config.getShipType()); diff --git a/src/main/java/io/mapsmessaging/state/n2k/msg/mapper/AisClassBPositionMapper.java b/src/main/java/io/mapsmessaging/state/n2k/msg/mapper/AisClassBPositionMapper.java index 424cb093f..7247bc8e3 100644 --- a/src/main/java/io/mapsmessaging/state/n2k/msg/mapper/AisClassBPositionMapper.java +++ b/src/main/java/io/mapsmessaging/state/n2k/msg/mapper/AisClassBPositionMapper.java @@ -25,7 +25,6 @@ import io.mapsmessaging.state.n2k.msg.AisClassBPositionReport; import io.mapsmessaging.state.n2k.msg.AisMappingSupport; -import java.time.Instant; import java.util.Optional; public class AisClassBPositionMapper { @@ -54,13 +53,13 @@ public Optional map(DroneTwin droneTwin) { ? config.getPositionAccuracy() : (Boolean.TRUE.equals(droneTwin.getGpsValid()) ? 1L : 0L)); report.setRaim(config.getRaim()); - report.setTimeStamp(toSecondOfMinute(droneTwin.getNavigationUpdatedAt())); + report.setTimeStamp(AisMappingSupport.toSecondOfMinute(droneTwin.getNavigationUpdatedAt())); - report.setCog(toRadians(droneTwin.getCourseOverGroundDegrees())); - report.setSog(droneTwin.getGroundSpeedMetersPerSecond()); + report.setCog(AisMappingSupport.toRadians(droneTwin.getCourseOverGroundDegrees())); + report.setSog(AisMappingSupport.nonNegativeFinite(droneTwin.getGroundSpeedMetersPerSecond())); report.setCommunicationStateInformation(config.getCommunicationStateInformation()); report.setAisTransceiverInformation(config.getAisTransceiverInformation()); - report.setHeading(toRadians(droneTwin.getHeadingDegrees())); + report.setHeading(AisMappingSupport.toRadians(droneTwin.getHeadingDegrees())); report.setRegionalApplication(0L); report.setRegionalApplicationB(0L); @@ -77,42 +76,10 @@ public Optional map(DroneTwin droneTwin) { } private boolean isEligible(DroneTwin droneTwin) { - if (droneTwin == null) { - return false; - } - if (droneTwin.getLifecycleStatus() != TwinLifecycleStatus.ACTIVE) { - return false; - } - if (droneTwin.getMmsi() == null) { - return false; - } - if (droneTwin.getGeoPosition() == null) { - return false; - } - if (droneTwin.getGeoPosition().getLatitude() == null) { - return false; - } - if (droneTwin.getGeoPosition().getLongitude() == null) { - return false; - } - if (!Boolean.TRUE.equals(droneTwin.getGpsValid())) { - return false; - } - return droneTwin.getNavigationUpdatedAt() != null; + return AisMappingSupport.hasCorePosition(droneTwin) + && droneTwin.getLifecycleStatus() == TwinLifecycleStatus.ACTIVE + && Boolean.TRUE.equals(droneTwin.getGpsValid()) + && droneTwin.getNavigationUpdatedAt() != null; } - private static Long toSecondOfMinute(Instant instant) { - return AisMappingSupport.toSecondOfMinute(instant); - } - - private static Double toRadians(Double degrees) { - if (degrees == null) { - return null; - } - return Math.toRadians(normalizeDegrees(degrees)); - } - - private static double normalizeDegrees(double degrees) { - return AisMappingSupport.normalizeDegrees(degrees); - } -} \ No newline at end of file +} diff --git a/src/test/java/io/mapsmessaging/state/n2k/listener/AbstractN2kJsonListenerTest.java b/src/test/java/io/mapsmessaging/state/n2k/listener/AbstractN2kJsonListenerTest.java new file mode 100644 index 000000000..077570dae --- /dev/null +++ b/src/test/java/io/mapsmessaging/state/n2k/listener/AbstractN2kJsonListenerTest.java @@ -0,0 +1,265 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * https://commonsclause.com/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.mapsmessaging.state.n2k.listener; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import io.mapsmessaging.state.drone.core.TwinUpdateContext; +import io.mapsmessaging.state.drone.drone.DroneTwin; +import java.time.Instant; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class AbstractN2kJsonListenerTest { + + private final TestListener listener = new TestListener(); + + @ParameterizedTest + @MethodSource("unusableNumericValues") + void getDouble_unusableValue_returnsNull(JsonElement value) { + JsonObject packet = new JsonObject(); + if (value != null) { + packet.add("value", value); + } + + assertNull(listener.readDouble(packet, "value")); + } + + static Stream unusableNumericValues() { + JsonArray array = new JsonArray(); + array.add(1); + return Stream.of( + Arguments.of((JsonElement) null), + Arguments.of(JsonNull.INSTANCE), + Arguments.of(new JsonPrimitive(true)), + Arguments.of(new JsonObject()), + Arguments.of(array), + Arguments.of(new JsonPrimitive("not-a-number")), + Arguments.of(new JsonPrimitive("NaN")), + Arguments.of(new JsonPrimitive("Infinity")), + Arguments.of(new JsonPrimitive("-Infinity"))); + } + + @ParameterizedTest + @MethodSource("usableDoubleValues") + void getDouble_numericPrimitiveOrString_returnsValue(JsonElement value, double expected) { + JsonObject packet = new JsonObject(); + packet.add("value", value); + + assertEquals(expected, listener.readDouble(packet, "value")); + } + + static Stream usableDoubleValues() { + return Stream.of( + Arguments.of(new JsonPrimitive(0), 0.0d), + Arguments.of(new JsonPrimitive(-12.75d), -12.75d), + Arguments.of(new JsonPrimitive("42.5"), 42.5d)); + } + + @Test + void getDouble_firstAbsentOrNullAlias_usesLaterAlias() { + JsonObject packet = new JsonObject(); + packet.add("first", JsonNull.INSTANCE); + packet.addProperty("second", "3.25"); + + assertEquals(3.25d, listener.readDouble(packet, "missing", "first", "second")); + } + + @ParameterizedTest + @MethodSource("integerValues") + void getInteger_onlyExactInRangeValuesAreAccepted(JsonElement value, Integer expected) { + JsonObject packet = new JsonObject(); + packet.add("value", value); + + assertEquals(expected, listener.readInteger(packet, "value")); + } + + static Stream integerValues() { + return Stream.of( + Arguments.of(new JsonPrimitive(0), 0), + Arguments.of(new JsonPrimitive("2147483647"), Integer.MAX_VALUE), + Arguments.of(new JsonPrimitive("-2147483648"), Integer.MIN_VALUE), + Arguments.of(new JsonPrimitive("1.5"), null), + Arguments.of(new JsonPrimitive("2147483648"), null), + Arguments.of(new JsonPrimitive(true), null), + Arguments.of(new JsonObject(), null)); + } + + @ParameterizedTest + @MethodSource("longValues") + void getLong_onlyExactInRangeValuesAreAccepted(JsonElement value, Long expected) { + JsonObject packet = new JsonObject(); + packet.add("value", value); + + assertEquals(expected, listener.readLong(packet, "value")); + } + + static Stream longValues() { + return Stream.of( + Arguments.of(new JsonPrimitive("0"), 0L), + Arguments.of(new JsonPrimitive(Long.MAX_VALUE), Long.MAX_VALUE), + Arguments.of(new JsonPrimitive("2.1"), null), + Arguments.of(new JsonPrimitive("9223372036854775808"), null), + Arguments.of(new JsonPrimitive(false), null)); + } + + @Test + void getString_acceptsOnlyStringPrimitive() { + JsonObject packet = new JsonObject(); + packet.addProperty("string", "alpha"); + packet.addProperty("number", 12); + packet.addProperty("boolean", true); + + assertEquals("alpha", listener.readString(packet, "string")); + assertNull(listener.readString(packet, "number")); + assertNull(listener.readString(packet, "boolean")); + } + + @Test + void hasAny_distinguishesAbsentNullAndZero() { + JsonObject packet = new JsonObject(); + packet.add("nullValue", JsonNull.INSTANCE); + packet.addProperty("zero", 0); + + assertFalse(listener.any(null, "zero")); + assertFalse(listener.any(packet, "missing", "nullValue")); + assertTrue(listener.any(packet, "missing", "zero")); + } + + @ParameterizedTest + @MethodSource("latitudeValues") + void latitudeValidation_appliesFiniteProtocolRange(Double latitude, boolean expected) { + assertEquals(expected, listener.validLatitude(latitude)); + } + + static Stream latitudeValues() { + return Stream.of( + Arguments.of(null, false), + Arguments.of(Double.NaN, false), + Arguments.of(Double.POSITIVE_INFINITY, false), + Arguments.of(-90.0d, true), + Arguments.of(90.0d, true), + Arguments.of(-90.0001d, false), + Arguments.of(90.0001d, false)); + } + + @ParameterizedTest + @MethodSource("longitudeValues") + void longitudeValidation_appliesFiniteProtocolRange(Double longitude, boolean expected) { + assertEquals(expected, listener.validLongitude(longitude)); + } + + static Stream longitudeValues() { + return Stream.of( + Arguments.of(null, false), + Arguments.of(Double.NaN, false), + Arguments.of(Double.NEGATIVE_INFINITY, false), + Arguments.of(-180.0d, true), + Arguments.of(180.0d, true), + Arguments.of(-180.0001d, false), + Arguments.of(180.0001d, false)); + } + + @ParameterizedTest + @MethodSource("degreeValues") + void normalizeDegrees_wrapsFiniteAngles(Double input, Double expected) { + assertEquals(expected, listener.normalize(input)); + } + + static Stream degreeValues() { + return Stream.of( + Arguments.of(null, null), + Arguments.of(Double.NaN, null), + Arguments.of(Double.POSITIVE_INFINITY, null), + Arguments.of(0.0d, 0.0d), + Arguments.of(360.0d, 0.0d), + Arguments.of(-90.0d, 270.0d), + Arguments.of(810.0d, 90.0d)); + } + + @Test + void resolveTimestamp_usesReceiveTimeFromContext() { + Instant receivedTime = Instant.parse("2026-07-28T10:15:30Z"); + TwinUpdateContext context = new TwinUpdateContext(); + context.setReceivedTime(receivedTime); + + assertSame(receivedTime, listener.timestamp(context)); + } + + private static final class TestListener extends AbstractN2kJsonListener { + + @Override + public int getPgn() { + return 1; + } + + @Override + public void handle(DroneTwin droneTwin, JsonObject packet, TwinUpdateContext context) { + } + + private Double readDouble(JsonObject packet, String... names) { + return getDouble(packet, names); + } + + private Integer readInteger(JsonObject packet, String... names) { + return getInteger(packet, names); + } + + private Long readLong(JsonObject packet, String... names) { + return getLong(packet, names); + } + + private String readString(JsonObject packet, String... names) { + return getString(packet, names); + } + + private boolean any(JsonObject packet, String... names) { + return hasAny(packet, names); + } + + private boolean validLatitude(Double value) { + return isValidLatitude(value); + } + + private boolean validLongitude(Double value) { + return isValidLongitude(value); + } + + private Double normalize(Double value) { + return normalizeDegrees(value); + } + + private Instant timestamp(TwinUpdateContext context) { + return resolveTimestamp(context); + } + } +} diff --git a/src/test/java/io/mapsmessaging/state/n2k/listener/N2kJsonDispatcherTest.java b/src/test/java/io/mapsmessaging/state/n2k/listener/N2kJsonDispatcherTest.java new file mode 100644 index 000000000..0bfef40aa --- /dev/null +++ b/src/test/java/io/mapsmessaging/state/n2k/listener/N2kJsonDispatcherTest.java @@ -0,0 +1,192 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * https://commonsclause.com/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.mapsmessaging.state.n2k.listener; + +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.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import io.mapsmessaging.state.drone.core.TwinUpdateContext; +import io.mapsmessaging.state.drone.drone.DroneTwin; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.time.Instant; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class N2kJsonDispatcherTest { + + @Test + void registry_containsExpectedListenerForEachSupportedPgn() { + N2kJsonListenerRegistry registry = new N2kJsonListenerRegistry(); + Map> expected = Map.ofEntries( + Map.entry(N2kPgns.POSITION_RAPID_UPDATE, N2kPositionJsonListener.class), + Map.entry(N2kPgns.GNSS_POSITION_DATA, N2kGnssJsonListener.class), + Map.entry(N2kPgns.COG_SOG_RAPID_UPDATE, N2kMotionJsonListener.class), + Map.entry(N2kPgns.VESSEL_HEADING, N2kHeadingJsonListener.class), + Map.entry(N2kPgns.ATTITUDE, N2kAttitudeJsonListener.class), + Map.entry(N2kPgns.RATE_OF_TURN, N2kRateOfTurnJsonListener.class), + Map.entry(N2kPgns.GNSS_DOPS, N2kGnssDopsJsonListener.class), + Map.entry(N2kPgns.BATTERY_STATUS, N2kBatteryStatusJsonListener.class), + Map.entry(N2kPgns.MAGNETIC_VARIATION, N2kMagneticVariationJsonListener.class), + Map.entry(N2kPgns.WIND_DATA, N2kWindJsonListener.class), + Map.entry(N2kPgns.ENVIRONMENTAL_PARAMETERS, N2kEnvironmentalParametersJsonListener.class), + Map.entry(N2kPgns.INVERTER_STATUS, N2kInverterStatusJsonListener.class)); + + expected.forEach((pgn, listenerClass) -> { + assertTrue(registry.hasListener(pgn)); + assertInstanceOf(listenerClass, registry.getListener(pgn)); + }); + assertFalse(registry.hasListener(999_999)); + } + + @Test + void registry_duplicatePgn_throwsInsteadOfSilentlyReplacingListener() throws ReflectiveOperationException { + N2kJsonListenerRegistry registry = new N2kJsonListenerRegistry(); + Method register = N2kJsonListenerRegistry.class.getDeclaredMethod("register", N2kJsonListener.class); + register.setAccessible(true); + RecordingListener duplicate = new RecordingListener(N2kPgns.VESSEL_HEADING); + + InvocationTargetException exception = assertThrows( + InvocationTargetException.class, + () -> register.invoke(registry, duplicate)); + + IllegalArgumentException cause = assertInstanceOf(IllegalArgumentException.class, exception.getCause()); + assertTrue(cause.getMessage().contains(String.valueOf(N2kPgns.VESSEL_HEADING))); + } + + @Test + void dispatch_supportedPgn_passesOriginalObjectsToListener() throws ReflectiveOperationException { + RecordingListener listener = new RecordingListener(129_998); + N2kJsonListenerRegistry registry = new N2kJsonListenerRegistry(); + listenerMap(registry).put(listener.getPgn(), listener); + N2kJsonDispatcher dispatcher = new N2kJsonDispatcher(registry); + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.addProperty("value", 12); + TwinUpdateContext context = context(); + + dispatcher.dispatch(droneTwin, listener.getPgn(), packet, context); + + assertEquals(1, listener.invocations); + assertSame(droneTwin, listener.droneTwin); + assertSame(packet, listener.packet); + assertSame(context, listener.context); + } + + @Test + void dispatch_ignoredUnknownPgn_doesNotWriteDiagnostic() { + N2kJsonDispatcher dispatcher = new N2kJsonDispatcher(new N2kJsonListenerRegistry()); + ByteArrayOutputStream errorBytes = new ByteArrayOutputStream(); + PrintStream originalError = System.err; + + try { + System.setErr(new PrintStream(errorBytes, true, StandardCharsets.UTF_8)); + dispatcher.dispatch(new DroneTwin(), N2kPgns.HEARTBEAT, new JsonObject(), context()); + } finally { + System.setErr(originalError); + } + + assertEquals("", errorBytes.toString(StandardCharsets.UTF_8)); + } + + @Test + void dispatch_unhandledPgn_writesDiagnosticAndReturns() { + N2kJsonDispatcher dispatcher = new N2kJsonDispatcher(new N2kJsonListenerRegistry()); + ByteArrayOutputStream errorBytes = new ByteArrayOutputStream(); + PrintStream originalError = System.err; + + try { + System.setErr(new PrintStream(errorBytes, true, StandardCharsets.UTF_8)); + dispatcher.dispatch(new DroneTwin(), 129_999, new JsonObject(), context()); + } finally { + System.setErr(originalError); + } + + assertTrue(errorBytes.toString(StandardCharsets.UTF_8).contains("No listener for 129999")); + } + + @Test + void dispatch_malformedField_allowsListenerToUseOtherFields() { + N2kJsonDispatcher dispatcher = new N2kJsonDispatcher(new N2kJsonListenerRegistry()); + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.add("headingSensorReading", new JsonObject()); + packet.addProperty("variation", Math.PI / 6.0d); + + assertDoesNotThrow(() -> dispatcher.dispatch( + droneTwin, + N2kPgns.VESSEL_HEADING, + packet, + context())); + + assertEquals("29.999999999999996", droneTwin.getAttributes().get("n2k.heading.variationDegrees")); + } + + + @SuppressWarnings("unchecked") + private static Map listenerMap(N2kJsonListenerRegistry registry) + throws ReflectiveOperationException { + Field field = N2kJsonListenerRegistry.class.getDeclaredField("listeners"); + field.setAccessible(true); + return (Map) field.get(registry); + } + + private static TwinUpdateContext context() { + TwinUpdateContext context = new TwinUpdateContext(); + context.setReceivedTime(Instant.parse("2026-07-28T10:15:30Z")); + return context; + } + + private static final class RecordingListener implements N2kJsonListener { + + private final int pgn; + private int invocations; + private DroneTwin droneTwin; + private JsonObject packet; + private TwinUpdateContext context; + + private RecordingListener(int pgn) { + this.pgn = pgn; + } + + @Override + public int getPgn() { + return pgn; + } + + @Override + public void handle(DroneTwin droneTwin, JsonObject packet, TwinUpdateContext context) { + invocations++; + this.droneTwin = droneTwin; + this.packet = packet; + this.context = context; + } + } +} diff --git a/src/test/java/io/mapsmessaging/state/n2k/listener/N2kNavigationListenersTest.java b/src/test/java/io/mapsmessaging/state/n2k/listener/N2kNavigationListenersTest.java new file mode 100644 index 000000000..3f53ed66e --- /dev/null +++ b/src/test/java/io/mapsmessaging/state/n2k/listener/N2kNavigationListenersTest.java @@ -0,0 +1,279 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * https://commonsclause.com/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.mapsmessaging.state.n2k.listener; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import io.mapsmessaging.state.drone.core.TwinUpdateContext; +import io.mapsmessaging.state.drone.drone.DroneTwin; +import io.mapsmessaging.state.drone.model.GeoPosition; +import java.time.Instant; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class N2kNavigationListenersTest { + + private static final Instant RECEIVED_TIME = Instant.parse("2026-07-28T10:15:30Z"); + private static final double DELTA = 0.0000001d; + + @Test + void heading_numericStringAndNegativeAngle_updatesConvertedValues() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.addProperty("headingSensorReading", String.valueOf(-Math.PI / 2.0d)); + packet.addProperty("variation", Math.PI / 6.0d); + packet.add("deviation", JsonNull.INSTANCE); + packet.addProperty("headingSensorReference", 2); + + new N2kHeadingJsonListener().handle(droneTwin, packet, context()); + + assertEquals(270.0d, droneTwin.getHeadingDegrees(), DELTA); + assertEquals(30.0d, attributeDouble(droneTwin, "n2k.heading.variationDegrees"), DELTA); + assertNull(droneTwin.getAttributes().get("n2k.heading.deviationDegrees")); + assertEquals("2", droneTwin.getAttributes().get("n2k.heading.sensorReference")); + assertSame(RECEIVED_TIME, droneTwin.getNavigationUpdatedAt()); + assertSame(RECEIVED_TIME, droneTwin.getLastSeenAt()); + } + + @Test + void heading_malformedHeading_preservesUsableVariationAndDeviation() { + DroneTwin droneTwin = new DroneTwin(); + droneTwin.setHeadingDegrees(45.0d); + JsonObject packet = new JsonObject(); + packet.add("headingSensorReading", new JsonObject()); + packet.addProperty("variation", Math.PI / 4.0d); + packet.addProperty("deviation", -Math.PI / 18.0d); + + new N2kHeadingJsonListener().handle(droneTwin, packet, context()); + + assertEquals(45.0d, droneTwin.getHeadingDegrees()); + assertEquals(45.0d, attributeDouble(droneTwin, "n2k.heading.variationDegrees"), DELTA); + assertEquals(-10.0d, attributeDouble(droneTwin, "n2k.heading.deviationDegrees"), DELTA); + } + + @Test + void motion_wrapsCourseAndPreservesZeroSpeedAndReference() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.addProperty("courseOverGround", Math.toRadians(450.0d)); + packet.addProperty("speedOverGround", "0"); + packet.addProperty("cogReference", 1); + + new N2kMotionJsonListener().handle(droneTwin, packet, context()); + + assertEquals(90.0d, droneTwin.getCourseOverGroundDegrees(), DELTA); + assertEquals(0.0d, droneTwin.getGroundSpeedMetersPerSecond(), DELTA); + assertEquals("1", droneTwin.getAttributes().get("n2k.motion.courseReference")); + assertSame(RECEIVED_TIME, droneTwin.getOperationalUpdatedAt()); + assertSame(RECEIVED_TIME, droneTwin.getMotionUpdatedAt()); + } + + @Test + void motion_malformedCourse_stillUpdatesSpeed() { + DroneTwin droneTwin = new DroneTwin(); + droneTwin.setCourseOverGroundDegrees(12.0d); + JsonObject packet = new JsonObject(); + packet.addProperty("courseOverGround", "not-a-number"); + packet.addProperty("speedOverGround", 4.25d); + + new N2kMotionJsonListener().handle(droneTwin, packet, context()); + + assertEquals(12.0d, droneTwin.getCourseOverGroundDegrees()); + assertEquals(4.25d, droneTwin.getGroundSpeedMetersPerSecond()); + } + + @ParameterizedTest + @MethodSource("invalidPositions") + void rapidPosition_invalidOrPartialCoordinates_doNotOverwritePosition(Double latitude, Double longitude) { + DroneTwin droneTwin = new DroneTwin(); + GeoPosition existing = new GeoPosition(-33.8d, 151.2d, null, null); + droneTwin.setGeoPosition(existing); + droneTwin.setGpsValid(false); + JsonObject packet = new JsonObject(); + if (latitude != null) { + packet.addProperty("latitude", latitude); + } + if (longitude != null) { + packet.addProperty("longitude", longitude); + } + + new N2kPositionJsonListener().handle(droneTwin, packet, context()); + + assertSame(existing, droneTwin.getGeoPosition()); + assertFalse(droneTwin.getGpsValid()); + assertNull(droneTwin.getNavigationUpdatedAt()); + } + + static Stream invalidPositions() { + return Stream.of( + Arguments.of(null, 151.2d), + Arguments.of(-33.8d, null), + Arguments.of(-90.0001d, 151.2d), + Arguments.of(90.0001d, 151.2d), + Arguments.of(-33.8d, -180.0001d), + Arguments.of(-33.8d, 180.0001d), + Arguments.of(Double.NaN, 151.2d), + Arguments.of(-33.8d, Double.POSITIVE_INFINITY)); + } + + @Test + void rapidPosition_validBoundaryCoordinates_updatesPosition() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.addProperty("latitude", -90.0d); + packet.addProperty("longitude", 180.0d); + + new N2kPositionJsonListener().handle(droneTwin, packet, context()); + + assertEquals(-90.0d, droneTwin.getGeoPosition().getLatitude()); + assertEquals(180.0d, droneTwin.getGeoPosition().getLongitude()); + assertNull(droneTwin.getGeoPosition().getAltitudeMslMeters()); + assertTrue(droneTwin.getGpsValid()); + } + + @Test + void gnssPosition_validCoordinates_preservesAltitudeInMetres() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.addProperty("latitude", "-33.8688"); + packet.addProperty("longitude", "151.2093"); + packet.addProperty("altitude", "12.75"); + + new N2kGnssJsonListener().handle(droneTwin, packet, context()); + + assertEquals(-33.8688d, droneTwin.getGeoPosition().getLatitude()); + assertEquals(151.2093d, droneTwin.getGeoPosition().getLongitude()); + assertEquals(12.75d, droneTwin.getGeoPosition().getAltitudeMslMeters()); + assertTrue(droneTwin.getGpsValid()); + assertSame(RECEIVED_TIME, droneTwin.getNavigationUpdatedAt()); + } + + @Test + void gnssDops_malformedHdop_stillUpdatesVdopAndMetadata() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.add("hdop", new JsonObject()); + packet.addProperty("vdop", "1.25"); + packet.addProperty("tdop", 2.5d); + packet.addProperty("setMode", 3); + packet.addProperty("opMode", 2); + + new N2kGnssDopsJsonListener().handle(droneTwin, packet, context()); + + assertNull(droneTwin.getFixInfo().getHdop()); + assertEquals(1.25d, droneTwin.getFixInfo().getVdop()); + assertEquals("2.5", droneTwin.getAttributes().get("n2k.gnss.tdop")); + assertEquals("3", droneTwin.getAttributes().get("n2k.gnss.setMode")); + assertEquals("2", droneTwin.getAttributes().get("n2k.gnss.operationMode")); + assertTrue(droneTwin.getGpsValid()); + } + + @Test + void attitude_partialAndMalformedFields_preservesNullForUnusableAxis() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.addProperty("roll", "bad"); + packet.addProperty("pitch", Math.PI / 6.0d); + packet.addProperty("yaw", -Math.PI / 2.0d); + + new N2kAttitudeJsonListener().handle(droneTwin, packet, context()); + + assertNull(droneTwin.getOrientation().getRollDegrees()); + assertEquals(30.0d, droneTwin.getOrientation().getPitchDegrees(), DELTA); + assertEquals(-90.0d, droneTwin.getOrientation().getYawDegrees(), DELTA); + assertSame(RECEIVED_TIME, droneTwin.getMotionUpdatedAt()); + } + + @Test + void rateOfTurn_convertsRadiansPerSecondToDegreesPerSecond() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.addProperty("rateOfTurn", Math.PI / 3.0d); + + new N2kRateOfTurnJsonListener().handle(droneTwin, packet, context()); + + assertEquals(60.0d, attributeDouble(droneTwin, "n2k.rateOfTurnDegreesPerSecond"), DELTA); + assertSame(RECEIVED_TIME, droneTwin.getMotionUpdatedAt()); + } + + @Test + void wind_convertsDirectionAndPreservesZeroSpeed() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.addProperty("windSpeed", 0.0d); + packet.addProperty("windDirection", Math.PI); + packet.addProperty("windReference", 4); + + new N2kWindJsonListener().handle(droneTwin, packet, context()); + + assertEquals(0.0d, attributeDouble(droneTwin, "n2k.windSpeedMetersPerSecond")); + assertEquals(180.0d, attributeDouble(droneTwin, "n2k.windDirectionDegrees"), DELTA); + assertEquals("4", droneTwin.getAttributes().get("n2k.windReference")); + assertSame(RECEIVED_TIME, droneTwin.getOperationalUpdatedAt()); + } + + @Test + void magneticVariation_malformedVariation_stillStoresSourceAndAge() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.addProperty("variation", "unknown"); + packet.addProperty("variationSource", 5); + packet.addProperty("ageOfServiceDate", 20_300); + + new N2kMagneticVariationJsonListener().handle(droneTwin, packet, context()); + + assertNull(droneTwin.getAttributes().get("n2k.magneticVariationDegrees")); + assertEquals("5", droneTwin.getAttributes().get("n2k.magneticVariationSource")); + assertEquals("20300", droneTwin.getAttributes().get("n2k.magneticVariationAgeOfServiceDate")); + assertSame(RECEIVED_TIME, droneTwin.getNavigationUpdatedAt()); + } + + @Test + void emptyOrNullOnlyPacket_doesNotAdvanceTimestamps() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.add("courseOverGround", JsonNull.INSTANCE); + + new N2kMotionJsonListener().handle(droneTwin, packet, context()); + + assertNull(droneTwin.getMotionUpdatedAt()); + assertNull(droneTwin.getOperationalUpdatedAt()); + assertNull(droneTwin.getLastSeenAt()); + } + + private static TwinUpdateContext context() { + TwinUpdateContext context = new TwinUpdateContext(); + context.setReceivedTime(RECEIVED_TIME); + return context; + } + + private static double attributeDouble(DroneTwin droneTwin, String key) { + return Double.parseDouble(droneTwin.getAttributes().get(key)); + } +} diff --git a/src/test/java/io/mapsmessaging/state/n2k/listener/N2kPowerEnvironmentalListenersTest.java b/src/test/java/io/mapsmessaging/state/n2k/listener/N2kPowerEnvironmentalListenersTest.java new file mode 100644 index 000000000..aff2c6d9e --- /dev/null +++ b/src/test/java/io/mapsmessaging/state/n2k/listener/N2kPowerEnvironmentalListenersTest.java @@ -0,0 +1,163 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * https://commonsclause.com/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.mapsmessaging.state.n2k.listener; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import io.mapsmessaging.state.drone.core.TwinUpdateContext; +import io.mapsmessaging.state.drone.drone.DroneTwin; +import io.mapsmessaging.state.drone.model.BatteryState; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class N2kPowerEnvironmentalListenersTest { + + private static final Instant RECEIVED_TIME = Instant.parse("2026-07-28T11:20:00Z"); + private static final double DELTA = 0.0000001d; + + @Test + void batteryStatus_convertsKelvinAndPreservesZeroValues() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.addProperty("batteryInstance", 0); + packet.addProperty("batteryVoltage", 0.0d); + packet.addProperty("batteryCurrent", -4.5d); + packet.addProperty("batteryCaseTemperature", 273.15d); + + new N2kBatteryStatusJsonListener().handle(droneTwin, packet, context()); + + assertEquals(0.0d, droneTwin.getBatteryState().getVoltageVolts()); + assertEquals(-4.5d, droneTwin.getBatteryState().getCurrentAmps()); + assertEquals(0.0d, droneTwin.getBatteryState().getTemperatureCelsius(), DELTA); + assertEquals("0", droneTwin.getAttributes().get("n2k.battery.instance")); + assertSame(RECEIVED_TIME, droneTwin.getPowerUpdatedAt()); + assertSame(RECEIVED_TIME, droneTwin.getLastSeenAt()); + } + + @Test + void batteryStatus_malformedCurrent_stillUpdatesVoltageAndExistingState() { + DroneTwin droneTwin = new DroneTwin(); + BatteryState batteryState = new BatteryState(); + batteryState.setCurrentAmps(1.5d); + droneTwin.setBatteryState(batteryState); + JsonObject packet = new JsonObject(); + packet.addProperty("batteryVoltage", "24.75"); + packet.add("batteryCurrent", new JsonObject()); + + new N2kBatteryStatusJsonListener().handle(droneTwin, packet, context()); + + assertSame(batteryState, droneTwin.getBatteryState()); + assertEquals(24.75d, batteryState.getVoltageVolts()); + assertEquals(1.5d, batteryState.getCurrentAmps()); + } + + @Test + void environmentalParameters_convertsTemperatureAndStoresMetricValues() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.addProperty("temperature", 293.15d); + packet.addProperty("humidity", 0.0d); + packet.addProperty("atmosphericPressure", 101_325.0d); + packet.addProperty("temperatureInstance", 1); + packet.addProperty("humidityInstance", 2); + + new N2kEnvironmentalParametersJsonListener().handle(droneTwin, packet, context()); + + assertEquals(20.0d, attributeDouble(droneTwin, "n2k.temperatureCelsius"), DELTA); + assertEquals(0.0d, attributeDouble(droneTwin, "n2k.humidityPercent")); + assertEquals(101_325.0d, attributeDouble(droneTwin, "n2k.atmosphericPressurePascals")); + assertEquals("1", droneTwin.getAttributes().get("n2k.temperatureInstance")); + assertEquals("2", droneTwin.getAttributes().get("n2k.humidityInstance")); + assertSame(RECEIVED_TIME, droneTwin.getOperationalUpdatedAt()); + } + + @Test + void environmentalParameters_malformedTemperature_stillStoresPressure() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.addProperty("temperature", "NaN"); + packet.addProperty("atmosphericPressure", 90_000.0d); + + new N2kEnvironmentalParametersJsonListener().handle(droneTwin, packet, context()); + + assertNull(droneTwin.getAttributes().get("n2k.temperatureCelsius")); + assertEquals(90_000.0d, attributeDouble(droneTwin, "n2k.atmosphericPressurePascals")); + } + + @Test + void inverterStatus_partialPayload_preservesSupportedNumericValues() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.addProperty("inverterInstance", 0); + packet.addProperty("acInstance", "1"); + packet.add("dcInstance", JsonNull.INSTANCE); + packet.addProperty("operatingState", 4); + packet.addProperty("inverterEnabledisable", 1); + + new N2kInverterStatusJsonListener().handle(droneTwin, packet, context()); + + assertEquals("0", droneTwin.getAttributes().get("n2k.inverter.instance")); + assertEquals("1", droneTwin.getAttributes().get("n2k.inverter.acInstance")); + assertNull(droneTwin.getAttributes().get("n2k.inverter.dcInstance")); + assertEquals("4", droneTwin.getAttributes().get("n2k.inverter.operatingState")); + assertEquals("1", droneTwin.getAttributes().get("n2k.inverter.enabled")); + assertSame(RECEIVED_TIME, droneTwin.getPowerUpdatedAt()); + } + + @Test + void inverterStatus_malformedField_doesNotBlockOtherFields() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.addProperty("operatingState", true); + packet.addProperty("inverterEnabledisable", 0); + + new N2kInverterStatusJsonListener().handle(droneTwin, packet, context()); + + assertNull(droneTwin.getAttributes().get("n2k.inverter.operatingState")); + assertEquals("0", droneTwin.getAttributes().get("n2k.inverter.enabled")); + } + + @Test + void nullOnlyPayload_doesNotCreateStateOrAdvanceTimestamp() { + DroneTwin droneTwin = new DroneTwin(); + JsonObject packet = new JsonObject(); + packet.add("batteryVoltage", JsonNull.INSTANCE); + + new N2kBatteryStatusJsonListener().handle(droneTwin, packet, context()); + + assertNull(droneTwin.getBatteryState()); + assertNull(droneTwin.getPowerUpdatedAt()); + assertNull(droneTwin.getLastSeenAt()); + } + + private static TwinUpdateContext context() { + TwinUpdateContext context = new TwinUpdateContext(); + context.setReceivedTime(RECEIVED_TIME); + return context; + } + + private static double attributeDouble(DroneTwin droneTwin, String key) { + return Double.parseDouble(droneTwin.getAttributes().get(key)); + } +} diff --git a/src/test/java/io/mapsmessaging/state/n2k/msg/AisMappingSupportTest.java b/src/test/java/io/mapsmessaging/state/n2k/msg/AisMappingSupportTest.java new file mode 100644 index 000000000..6b9059f40 --- /dev/null +++ b/src/test/java/io/mapsmessaging/state/n2k/msg/AisMappingSupportTest.java @@ -0,0 +1,167 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * https://commonsclause.com/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.mapsmessaging.state.n2k.msg; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.mapsmessaging.state.drone.drone.DroneTwin; +import io.mapsmessaging.state.drone.model.GeoPosition; +import java.time.Instant; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class AisMappingSupportTest { + + private static final double DELTA = 0.0000001d; + + @ParameterizedTest + @MethodSource("corePositionValues") + void hasCorePosition_requiresMmsiAndFiniteCoordinates( + Long mmsi, + Double latitude, + Double longitude, + boolean expected) { + DroneTwin droneTwin = new DroneTwin(); + droneTwin.setMmsi(mmsi); + if (latitude != null || longitude != null) { + droneTwin.setGeoPosition(new GeoPosition(latitude, longitude, null, null)); + } + + assertEquals(expected, AisMappingSupport.hasCorePosition(droneTwin)); + } + + static Stream corePositionValues() { + return Stream.of( + Arguments.of(null, -33.8d, 151.2d, false), + Arguments.of(123_456_789L, null, null, false), + Arguments.of(123_456_789L, null, 151.2d, false), + Arguments.of(123_456_789L, -33.8d, null, false), + Arguments.of(123_456_789L, Double.NaN, 151.2d, false), + Arguments.of(123_456_789L, -33.8d, Double.POSITIVE_INFINITY, false), + Arguments.of(123_456_789L, -90.0001d, 151.2d, false), + Arguments.of(123_456_789L, 90.0001d, 151.2d, false), + Arguments.of(123_456_789L, -33.8d, -180.0001d, false), + Arguments.of(123_456_789L, -33.8d, 180.0001d, false), + Arguments.of(123_456_789L, -90.0d, 180.0d, true), + Arguments.of(123_456_789L, 90.0d, -180.0d, true)); + } + + @Test + void hasCorePosition_nullTwin_returnsFalse() { + assertFalse(AisMappingSupport.hasCorePosition(null)); + } + + @Test + void toSecondOfMinute_handlesPositiveNegativeAndNullInstants() { + assertNull(AisMappingSupport.toSecondOfMinute(null)); + assertEquals(5L, AisMappingSupport.toSecondOfMinute(Instant.ofEpochSecond(65L))); + assertEquals(59L, AisMappingSupport.toSecondOfMinute(Instant.ofEpochSecond(-1L))); + } + + @ParameterizedTest + @MethodSource("angleValues") + void toRadians_normalizesFiniteDegreesAndRejectsNonFinite(Double degrees, Double expectedRadians) { + Double actual = AisMappingSupport.toRadians(degrees); + if (expectedRadians == null) { + assertNull(actual); + } else { + assertEquals(expectedRadians, actual, DELTA); + } + } + + static Stream angleValues() { + return Stream.of( + Arguments.of(null, null), + Arguments.of(Double.NaN, null), + Arguments.of(Double.POSITIVE_INFINITY, null), + Arguments.of(-90.0d, Math.toRadians(270.0d)), + Arguments.of(360.0d, 0.0d), + Arguments.of(450.0d, Math.toRadians(90.0d))); + } + + @Test + void resolveName_appliesConfiguredThenTwinFallbacksAndSanitises() { + DroneTwin droneTwin = new DroneTwin(); + droneTwin.setTwinId("twin-id"); + droneTwin.setRegistrationId("VH-DRN-01"); + droneTwin.setDisplayName("Survey@Drone Alpha"); + + assertEquals("Configured Name", AisMappingSupport.resolveName(droneTwin, "Configured_Name")); + assertEquals("Survey Drone Alpha", AisMappingSupport.resolveName(droneTwin, null)); + + droneTwin.setDisplayName(" "); + assertEquals("VH DRN 01", AisMappingSupport.resolveName(droneTwin, null)); + + droneTwin.setRegistrationId(null); + assertEquals("twin id", AisMappingSupport.resolveName(droneTwin, null)); + } + + @Test + void resolveName_truncatesToTwentyCharacters() { + DroneTwin droneTwin = new DroneTwin(); + droneTwin.setDisplayName("1234567890123456789012345"); + + assertEquals("12345678901234567890", AisMappingSupport.resolveName(droneTwin, null)); + } + + @Test + void resolveCallsign_prefersTwinThenConfiguredFallbackAndUppercases() { + DroneTwin droneTwin = new DroneTwin(); + droneTwin.setCallSign("uxv-42"); + + assertEquals("UXV 42", AisMappingSupport.resolveCallsign(droneTwin, "config")); + + droneTwin.setCallSign(" "); + assertEquals("CONFIG", AisMappingSupport.resolveCallsign(droneTwin, "config")); + assertNull(AisMappingSupport.resolveCallsign(droneTwin, null)); + assertEquals("CONFIG", AisMappingSupport.resolveCallsign(null, "config")); + } + + @Test + void resolveVendorId_uppercasesSanitisesAndTruncates() { + assertNull(AisMappingSupport.resolveVendorId(null)); + assertNull(AisMappingSupport.resolveVendorId(" ")); + assertEquals("MAPS BV", AisMappingSupport.resolveVendorId("maps@bv")); + assertEquals("ABCDEFG", AisMappingSupport.resolveVendorId("abcdefghij")); + } + + @Test + void deriveSequenceId_usesConfiguredValueOrStableTwinHash() { + DroneTwin droneTwin = new DroneTwin(); + droneTwin.setTwinId("drone-alpha"); + + assertEquals(12L, AisMappingSupport.deriveSequenceId(droneTwin, 12L)); + Long derived = AisMappingSupport.deriveSequenceId(droneTwin, null); + assertTrue(derived >= 0L && derived <= 252L); + assertEquals(derived, AisMappingSupport.deriveSequenceId(droneTwin, null)); + assertEquals(0L, AisMappingSupport.deriveSequenceId(null, null)); + } + + @Test + void truncate_emptyAfterSanitising_returnsEmptyForFieldSourceToOmit() { + assertEquals("", AisMappingSupport.truncate("@@@", 7)); + } +} diff --git a/src/test/java/io/mapsmessaging/state/n2k/msg/mapper/AisClassBMappersTest.java b/src/test/java/io/mapsmessaging/state/n2k/msg/mapper/AisClassBMappersTest.java new file mode 100644 index 000000000..1f6e4df36 --- /dev/null +++ b/src/test/java/io/mapsmessaging/state/n2k/msg/mapper/AisClassBMappersTest.java @@ -0,0 +1,253 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * https://commonsclause.com/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.mapsmessaging.state.n2k.msg.mapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.mapsmessaging.state.drone.core.TwinLifecycleStatus; +import io.mapsmessaging.state.drone.drone.DroneTwin; +import io.mapsmessaging.state.drone.model.GeoPosition; +import io.mapsmessaging.state.n2k.msg.AisClassBEmitterConfig; +import io.mapsmessaging.state.n2k.msg.AisClassBExtendedPositionReport; +import io.mapsmessaging.state.n2k.msg.AisClassBPositionReport; +import io.mapsmessaging.state.n2k.msg.AisClassBStaticDataPartAReport; +import io.mapsmessaging.state.n2k.msg.AisClassBStaticDataPartBReport; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class AisClassBMappersTest { + + private static final double DELTA = 0.0000001d; + + @Test + void positionMapper_completeTwin_mapsProtocolFieldsAndUnits() { + AisClassBEmitterConfig config = AisClassBEmitterConfig.getDefaults(); + DroneTwin droneTwin = eligibleTwin(); + droneTwin.setCourseOverGroundDegrees(-90.0d); + droneTwin.setHeadingDegrees(450.0d); + droneTwin.setGroundSpeedMetersPerSecond(3.5d); + + AisClassBPositionReport report = new AisClassBPositionMapper(config).map(droneTwin).orElseThrow(); + + assertEquals(18L, report.getMessageId()); + assertEquals(123_456_789L, report.getUserId()); + assertEquals(151.2093d, report.getLongitude()); + assertEquals(-33.8688d, report.getLatitude()); + assertEquals(1L, report.getPositionAccuracy()); + assertEquals(5L, report.getTimeStamp()); + assertEquals(Math.toRadians(270.0d), report.getCog(), DELTA); + assertEquals(3.5d, report.getSog()); + assertEquals(Math.toRadians(90.0d), report.getHeading(), DELTA); + assertEquals(0L, report.getRegionalApplication()); + assertEquals(0L, report.getRegionalApplicationB()); + assertEquals(config.getAisCommunicationState(), report.getAisCommunicationState()); + } + + @Test + void positionMapper_nullOptionalMotionFields_remainAbsent() { + DroneTwin droneTwin = eligibleTwin(); + droneTwin.setCourseOverGroundDegrees(null); + droneTwin.setHeadingDegrees(null); + droneTwin.setGroundSpeedMetersPerSecond(null); + + AisClassBPositionReport report = new AisClassBPositionMapper(new AisClassBEmitterConfig()) + .map(droneTwin) + .orElseThrow(); + + assertNull(report.getCog()); + assertNull(report.getHeading()); + assertNull(report.getSog()); + assertNull(report.getRepeatIndicator()); + assertEquals(1L, report.getPositionAccuracy()); + } + + @Test + void positionMapper_invalidOptionalMotionFields_remainAbsent() { + DroneTwin droneTwin = eligibleTwin(); + droneTwin.setCourseOverGroundDegrees(Double.POSITIVE_INFINITY); + droneTwin.setHeadingDegrees(Double.NaN); + droneTwin.setGroundSpeedMetersPerSecond(-0.1d); + + AisClassBPositionReport report = new AisClassBPositionMapper(AisClassBEmitterConfig.getDefaults()) + .map(droneTwin) + .orElseThrow(); + + assertNull(report.getCog()); + assertNull(report.getHeading()); + assertNull(report.getSog()); + } + + @Test + void positionMapper_invalidCoreState_returnsEmpty() { + AisClassBPositionMapper mapper = new AisClassBPositionMapper(AisClassBEmitterConfig.getDefaults()); + + assertTrue(mapper.map(null).isEmpty()); + + DroneTwin missingMmsi = eligibleTwin(); + missingMmsi.setMmsi(null); + assertTrue(mapper.map(missingMmsi).isEmpty()); + + DroneTwin invalidLatitude = eligibleTwin(); + invalidLatitude.getGeoPosition().setLatitude(91.0d); + assertTrue(mapper.map(invalidLatitude).isEmpty()); + + DroneTwin invalidLongitude = eligibleTwin(); + invalidLongitude.getGeoPosition().setLongitude(Double.NaN); + assertTrue(mapper.map(invalidLongitude).isEmpty()); + + DroneTwin gpsInvalid = eligibleTwin(); + gpsInvalid.setGpsValid(false); + assertTrue(mapper.map(gpsInvalid).isEmpty()); + + DroneTwin stale = eligibleTwin(); + stale.setLifecycleStatus(TwinLifecycleStatus.STALE); + assertTrue(mapper.map(stale).isEmpty()); + + DroneTwin noTimestamp = eligibleTwin(); + noTimestamp.setNavigationUpdatedAt(null); + assertTrue(mapper.map(noTimestamp).isEmpty()); + } + + @Test + void extendedPositionMapper_completeTwin_mapsStaticAndDynamicFields() { + AisClassBEmitterConfig config = AisClassBEmitterConfig.getDefaults(); + config.setName(null); + DroneTwin droneTwin = eligibleTwin(); + droneTwin.setDisplayName("Survey Vessel Alpha"); + droneTwin.setHeadingDegrees(180.0d); + droneTwin.setCourseOverGroundDegrees(45.0d); + droneTwin.setGroundSpeedMetersPerSecond(2.25d); + + AisClassBExtendedPositionReport report = new AisClassBExtendedPositionMapper(config) + .map(droneTwin) + .orElseThrow(); + + assertEquals(19L, report.getMessageId()); + assertEquals(123_456_789L, report.getUserId()); + assertEquals(Math.toRadians(45.0d), report.getCog(), DELTA); + assertEquals(2.25d, report.getSog()); + assertEquals(Math.PI, report.getTrueHeading(), DELTA); + assertEquals("Survey Vessel Alpha", report.getName()); + assertEquals(config.getShipType(), report.getTypeOfShip()); + assertEquals(config.getLengthMeters(), report.getLength()); + assertEquals(config.getBeamMeters(), report.getBeam()); + } + + @Test + void extendedPositionMapper_invalidCoreOrMotionFields_areRejectedOrOmitted() { + AisClassBExtendedPositionMapper mapper = new AisClassBExtendedPositionMapper(AisClassBEmitterConfig.getDefaults()); + DroneTwin invalidCore = eligibleTwin(); + invalidCore.getGeoPosition().setLongitude(181.0d); + assertTrue(mapper.map(invalidCore).isEmpty()); + + DroneTwin validCore = eligibleTwin(); + validCore.setCourseOverGroundDegrees(Double.NaN); + validCore.setHeadingDegrees(Double.NEGATIVE_INFINITY); + validCore.setGroundSpeedMetersPerSecond(-1.0d); + + AisClassBExtendedPositionReport report = mapper.map(validCore).orElseThrow(); + assertNull(report.getCog()); + assertNull(report.getTrueHeading()); + assertNull(report.getSog()); + } + + @Test + void staticDataPartA_usesNameFallbackAndDerivedSequence() { + AisClassBEmitterConfig config = new AisClassBEmitterConfig(); + DroneTwin droneTwin = eligibleTwin(); + droneTwin.setDisplayName("Drone @ Alpha"); + droneTwin.setTwinId("drone-alpha"); + + AisClassBStaticDataPartAReport report = new AisClassBStaticDataPartAMapper(config) + .map(droneTwin) + .orElseThrow(); + + assertEquals(24L, report.getMessageId()); + assertEquals("Drone Alpha", report.getName()); + assertTrue(report.getSequenceId() >= 0L && report.getSequenceId() <= 252L); + assertNull(report.getRepeatIndicator()); + assertTrue(new AisClassBStaticDataPartAMapper(config).map(null).isEmpty()); + } + + @Test + void staticDataPartB_usesTwinCallsignThenConfiguredFallback() { + AisClassBEmitterConfig config = AisClassBEmitterConfig.getDefaults(); + DroneTwin droneTwin = eligibleTwin(); + droneTwin.setCallSign("uxv-01"); + + AisClassBStaticDataPartBReport report = new AisClassBStaticDataPartBMapper(config) + .map(droneTwin) + .orElseThrow(); + assertEquals("UXV 01", report.getCallsign()); + + droneTwin.setCallSign(null); + report = new AisClassBStaticDataPartBMapper(config).map(droneTwin).orElseThrow(); + assertEquals("DRONE", report.getCallsign()); + assertEquals("MAPS", report.getVendorId()); + } + + @Test + void staticDataPartB_nullOptionalConfigFields_remainNull() { + AisClassBEmitterConfig config = new AisClassBEmitterConfig(); + DroneTwin droneTwin = eligibleTwin(); + + AisClassBStaticDataPartBReport report = new AisClassBStaticDataPartBMapper(config) + .map(droneTwin) + .orElseThrow(); + + assertNull(report.getTypeOfShip()); + assertNull(report.getVendorId()); + assertNull(report.getCallsign()); + assertNull(report.getLength()); + assertNull(report.getBeam()); + assertNull(report.getMothershipUserId()); + assertFalse(new AisClassBStaticDataPartBMapper(config).map(new DroneTwin()).isPresent()); + } + + @Test + void emitterDefaults_areInternallyConsistentForSmallClassBVessel() { + AisClassBEmitterConfig config = AisClassBEmitterConfig.getDefaults(); + + assertEquals(0L, config.getRepeatIndicator()); + assertEquals(1L, config.getPositionAccuracy()); + assertEquals(55L, config.getShipType()); + assertEquals(1.0d, config.getLengthMeters()); + assertEquals(1.0d, config.getBeamMeters()); + assertEquals(0.5d, config.getPositionReferenceFromStarboardMeters()); + assertEquals(0.5d, config.getPositionReferenceFromBowMeters()); + assertEquals("MAPS", config.getVendorId()); + assertEquals("DRONE", config.getCallsign()); + } + + private static DroneTwin eligibleTwin() { + DroneTwin droneTwin = new DroneTwin(); + droneTwin.setTwinId("drone-alpha"); + droneTwin.setMmsi(123_456_789L); + droneTwin.setLifecycleStatus(TwinLifecycleStatus.ACTIVE); + droneTwin.setGpsValid(true); + droneTwin.setGeoPosition(new GeoPosition(-33.8688d, 151.2093d, 12.0d, null)); + droneTwin.setNavigationUpdatedAt(Instant.ofEpochSecond(65L)); + return droneTwin; + } +} diff --git a/src/test/java/io/mapsmessaging/state/n2k/msg/source/AisFieldValueSourceTest.java b/src/test/java/io/mapsmessaging/state/n2k/msg/source/AisFieldValueSourceTest.java new file mode 100644 index 000000000..c2918f553 --- /dev/null +++ b/src/test/java/io/mapsmessaging/state/n2k/msg/source/AisFieldValueSourceTest.java @@ -0,0 +1,163 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * https://commonsclause.com/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.mapsmessaging.state.n2k.msg.source; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.mapsmessaging.canbus.j1939.n2k.codec.FieldValueSource; +import io.mapsmessaging.state.n2k.msg.AisClassBExtendedPositionReport; +import io.mapsmessaging.state.n2k.msg.AisClassBPositionReport; +import io.mapsmessaging.state.n2k.msg.AisClassBStaticDataPartAReport; +import io.mapsmessaging.state.n2k.msg.AisClassBStaticDataPartBReport; +import org.junit.jupiter.api.Test; + +class AisFieldValueSourceTest { + + @Test + void positionSource_completeReport_exposesExpectedProtocolFieldIds() { + AisClassBPositionReport report = new AisClassBPositionReport( + 18L, + 0L, + 123_456_789L, + 151.2d, + -33.8d, + 1L, + 0L, + 5L, + 1.5d, + 2.5d, + 3L, + 4L, + 0.75d, + 0L, + 0L, + 1L, + 0L, + 1L, + 0L, + 1L, + 0L, + 1L); + + FieldValueSource source = new AisClassBPositionFieldValueSource(report); + + assertEquals(18L, source.getLong("messageId")); + assertEquals(123_456_789L, source.getLong("userId")); + assertEquals(151.2d, source.getDouble("longitude")); + assertEquals(-33.8d, source.getDouble("latitude")); + assertEquals(1.5d, source.getDouble("cog")); + assertEquals(2.5d, source.getDouble("sog")); + assertEquals(0.75d, source.getDouble("trueHeading")); + assertEquals(1L, source.getLong("communicationStateSelectorFlag")); + assertTrue(source.has("classBUnitFlag")); + assertNull(source.getString("messageId")); + } + + @Test + void positionSource_nullOptionalFields_areAbsentWhileZeroIsPresent() { + AisClassBPositionReport report = new AisClassBPositionReport(); + report.setMessageId(18L); + report.setRepeatIndicator(0L); + report.setUserId(123_456_789L); + report.setLongitude(0.0d); + report.setLatitude(null); + report.setSog(null); + report.setHeading(null); + + FieldValueSource source = new AisClassBPositionFieldValueSource(report); + + assertTrue(source.has("repeatIndicator")); + assertEquals(0L, source.getLong("repeatIndicator")); + assertTrue(source.has("longitude")); + assertEquals(0.0d, source.getDouble("longitude")); + assertFalse(source.has("latitude")); + assertFalse(source.has("sog")); + assertFalse(source.has("trueHeading")); + assertNull(source.getLong("latitude")); + } + + @Test + void extendedPositionSource_partialReport_omitsNullAndEmptyOptionalFields() { + AisClassBExtendedPositionReport report = new AisClassBExtendedPositionReport(); + report.setMessageId(19L); + report.setUserId(123_456_789L); + report.setLongitude(0.0d); + report.setLatitude(-33.8d); + report.setName(""); + report.setLength(null); + report.setDte(0L); + + FieldValueSource source = new AisClassBExtendedPositionFieldValueSource(report); + + assertEquals(19L, source.getLong("messageId")); + assertEquals(0.0d, source.getDouble("longitude")); + assertEquals(-33.8d, source.getDouble("latitude")); + assertFalse(source.has("name")); + assertFalse(source.has("shipLength")); + assertTrue(source.has("dataTerminalEquipmentDte")); + assertEquals(0L, source.getLong("dataTerminalEquipmentDte")); + } + + @Test + void staticPartASource_exposesStringAndOmitsNullSequence() { + AisClassBStaticDataPartAReport report = new AisClassBStaticDataPartAReport( + 24L, + 0L, + 123_456_789L, + "SURVEY ONE", + 2L, + null); + + FieldValueSource source = new AisClassBStaticDataPartAFieldValueSource(report); + + assertEquals("SURVEY ONE", source.getString("name")); + assertEquals(2L, source.getLong("aisTransceiverInformation")); + assertFalse(source.has("sequenceId")); + } + + @Test + void staticPartBSource_partialReport_preservesFieldNamesAndOptionalAbsence() { + AisClassBStaticDataPartBReport report = new AisClassBStaticDataPartBReport(); + report.setMessageId(24L); + report.setRepeatIndicator(0L); + report.setUserId(123_456_789L); + report.setTypeOfShip(55L); + report.setVendorId("MAPS"); + report.setCallsign(null); + report.setLength(1.0d); + report.setBeam(0.0d); + report.setMothershipUserId(null); + report.setSequenceId(0L); + + FieldValueSource source = new AisClassBStaticDataPartBFieldValueSource(report); + + assertEquals(55L, source.getLong("typeOfShipAndCargo")); + assertEquals("MAPS", source.getString("vendorId")); + assertFalse(source.has("callSign")); + assertEquals(1.0d, source.getDouble("shipLength")); + assertEquals(0.0d, source.getDouble("shipBeam")); + assertFalse(source.has("motherShipMmsi")); + assertTrue(source.has("sequenceId")); + assertEquals(0L, source.getLong("sequenceId")); + } +}