From 9fed2cd420267c0843df046ed143996479585c81 Mon Sep 17 00:00:00 2001 From: naumueller Date: Fri, 6 Mar 2026 14:16:40 +0100 Subject: [PATCH] test: expand service coverage and modernize test infrastructure - replace legacy mockito-all with mockito-junit-jupiter - add tests for AppState, MQTT service, process manager, and animation file service - rewrite service tests for stronger edge-case coverage - refactor services for testability (MQTT client/process launcher/output path injection) - fix duplicate payload ID handling in BinaryEventService - update README and docs testing/source-map sections --- docs/KNOWN_ISSUES.md | 10 +- docs/SOURCE_MAP.md | 7 + docs/TESTING.md | 50 +++-- pom.xml | 21 +- readme.md | 5 +- .../service/AnimationFileService.java | 27 ++- .../service/BinaryEventService.java | 2 +- .../vassistent/service/MqttClientService.java | 44 +++- .../service/ProcessManagerService.java | 86 ++++++-- .../java/vassistent/model/AppStateTest.java | 82 ++++++++ .../service/AnimationFileServiceTest.java | 79 +++++++ .../service/BinaryEventServiceTest.java | 105 ++++++--- .../service/DataPersistenceServiceTest.java | 111 +++++----- .../service/EvaluationServiceTest.java | 77 ++++--- .../service/MqttClientServiceTest.java | 155 ++++++++++++++ .../service/ProcessManagerServiceTest.java | 199 ++++++++++++++++++ .../service/StatisticsServiceTest.java | 133 ++++++------ 17 files changed, 956 insertions(+), 237 deletions(-) create mode 100644 src/test/java/vassistent/model/AppStateTest.java create mode 100644 src/test/java/vassistent/service/AnimationFileServiceTest.java create mode 100644 src/test/java/vassistent/service/MqttClientServiceTest.java create mode 100644 src/test/java/vassistent/service/ProcessManagerServiceTest.java diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 3c8e4d9..8f2aeb1 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -28,15 +28,7 @@ Impact: - log output may indicate startup intent, but process is not actually launched via that path -## 4. Legacy Mockito Dependency - -Mockito-based tests fail on Java 17 due to old `mockito-all:2.0.2-beta`. - -Impact: - -- full CI-style green test run is blocked until dependency modernization - -## 5. Encoding Artifacts in Logs/Strings +## 4. Encoding Artifacts in Logs/Strings Some source/log text contains mojibake characters in comments/messages. diff --git a/docs/SOURCE_MAP.md b/docs/SOURCE_MAP.md index 11db99e..e3aede6 100644 --- a/docs/SOURCE_MAP.md +++ b/docs/SOURCE_MAP.md @@ -51,9 +51,16 @@ Package-level map of all Java source files in `src/main/java` and tests in `src/ ## Test Sources +### `vassistent.model` + +- `AppStateTest.java` + ### `vassistent.service` +- `AnimationFileServiceTest.java` - `BinaryEventServiceTest.java` - `DataPersistenceServiceTest.java` - `EvaluationServiceTest.java` +- `MqttClientServiceTest.java` +- `ProcessManagerServiceTest.java` - `StatisticsServiceTest.java` diff --git a/docs/TESTING.md b/docs/TESTING.md index 4712ca9..14ac7dc 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -8,32 +8,36 @@ mvn test ## Current Test Suite -Located in `src/test/java/vassistent/service`: +Located in: -- `BinaryEventServiceTest` -- `DataPersistenceServiceTest` -- `EvaluationServiceTest` -- `StatisticsServiceTest` +- `src/test/java/vassistent/model/AppStateTest` +- `src/test/java/vassistent/service/AnimationFileServiceTest` +- `src/test/java/vassistent/service/BinaryEventServiceTest` +- `src/test/java/vassistent/service/DataPersistenceServiceTest` +- `src/test/java/vassistent/service/EvaluationServiceTest` +- `src/test/java/vassistent/service/MqttClientServiceTest` +- `src/test/java/vassistent/service/ProcessManagerServiceTest` +- `src/test/java/vassistent/service/StatisticsServiceTest` + +Coverage focus: + +- state change notifications +- payload validation and processing flow +- level evaluation thresholds +- MQTT lifecycle and topic routing +- process launch/shutdown command sequencing +- animation file output mapping +- SQLite persistence behavior +- rolling averages and ratio calculations ## Current Status (Java 17) -The suite does not fully pass on Java 17 because of legacy Mockito dependency: +- full suite passes with `mvn test` +- test stack: + - `org.junit.jupiter:junit-jupiter` + - `org.mockito:mockito-junit-jupiter` -- project uses `mockito-all:2.0.2-beta` -- this version relies on CGLIB behavior that conflicts with modern Java module access -- result: Mockito-based tests fail at initialization (`InaccessibleObjectException`) +## Notes -Tests not depending on Mockito are expected to run successfully. - -## Recommended Fix - -Replace legacy dependency with modern Mockito stack, for example: - -- `org.mockito:mockito-core` (or `mockito-junit-jupiter`) -- ensure JUnit Jupiter engine is configured by Surefire - -After dependency update, rerun: - -```powershell -mvn test -``` +- some tests are integration-style and use temporary SQLite databases +- test output may include logger lines because services log during execution diff --git a/pom.xml b/pom.xml index 1df5f30..297b313 100644 --- a/pom.xml +++ b/pom.xml @@ -26,14 +26,14 @@ org.junit.jupiter - junit-jupiter-api + junit-jupiter 5.10.2 test org.mockito - mockito-all - 2.0.2-beta + mockito-junit-jupiter + 5.12.0 test @@ -59,4 +59,17 @@ - \ No newline at end of file + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + false + + + + + + diff --git a/readme.md b/readme.md index d6abfe4..b78678b 100644 --- a/readme.md +++ b/readme.md @@ -127,8 +127,9 @@ mvn test Current state in this repository on Java 17: -- `DataPersistenceServiceTest` and `StatisticsServiceTest` execute. -- Mockito-based tests fail because dependency is `mockito-all:2.0.2-beta` (legacy CGLIB + Java module access issue on modern JDKs). +- unit and integration tests are available for state, event processing, evaluation, persistence, and statistics +- test stack uses JUnit 5 + Mockito JUnit Jupiter +- full suite runs successfully with `mvn test` ## Project Structure diff --git a/src/main/java/vassistent/service/AnimationFileService.java b/src/main/java/vassistent/service/AnimationFileService.java index ed8b2f9..9f63390 100644 --- a/src/main/java/vassistent/service/AnimationFileService.java +++ b/src/main/java/vassistent/service/AnimationFileService.java @@ -6,15 +6,29 @@ import vassistent.util.Logger; import java.io.File; import java.io.FileWriter; import java.io.IOException; -import java.net.URI; -import java.util.concurrent.atomic.AtomicBoolean; -import jakarta.websocket.*; /** * Writes the current problem level as animation state JSON for Unreal integration. */ public class AnimationFileService { private static final String PATH = "C:\\Users\\Student\\Documents\\Dannick\\Prototyp1\\Saved\\animation.json"; + private final String outputPath; + + /** + * Creates the animation file service with default output path. + */ + public AnimationFileService() { + this(PATH); + } + + /** + * Creates the animation file service with an explicit output path. + * + * @param outputPath target JSON file path + */ + AnimationFileService(String outputPath) { + this.outputPath = outputPath; + } /** * Writes the mapped animation state for the given level to the configured JSON file. @@ -25,10 +39,15 @@ public class AnimationFileService { String animation = mapLevelToAnimation(level); - File file = new File(PATH); + File file = new File(outputPath); try { + File parent = file.getParentFile(); + if (parent != null) { + parent.mkdirs(); + } + String json = """ { "animation": "%s" diff --git a/src/main/java/vassistent/service/BinaryEventService.java b/src/main/java/vassistent/service/BinaryEventService.java index 6950743..aafde68 100644 --- a/src/main/java/vassistent/service/BinaryEventService.java +++ b/src/main/java/vassistent/service/BinaryEventService.java @@ -52,7 +52,7 @@ public class BinaryEventService { if (lastId != null && lastId == id) { Logger.warn("EVENT", "Payload ID bereits verarbeitet: " + id); - //return; + return; } lastId = id; diff --git a/src/main/java/vassistent/service/MqttClientService.java b/src/main/java/vassistent/service/MqttClientService.java index b082b34..110b17e 100644 --- a/src/main/java/vassistent/service/MqttClientService.java +++ b/src/main/java/vassistent/service/MqttClientService.java @@ -30,29 +30,55 @@ public class MqttClientService implements MqttCallback { * @param appState shared state updated with connection status */ public MqttClientService(AppState appState) { + this(appState, null, true); + } + + /** + * Creates an MQTT service with an optionally injected client (primarily for tests). + * + * @param appState shared state updated with connection status + * @param injectedClient externally provided MQTT client, or {@code null} to create a default client + * @param connectOnStart whether the service should connect immediately + */ + MqttClientService(AppState appState, MqttClient injectedClient, boolean connectOnStart) { this.appState = appState; try { - client = new MqttClient( + client = injectedClient != null + ? injectedClient + : new MqttClient( BROKER_URL, CLIENT_ID, new MemoryPersistence() ); + client.setCallback(this); - MqttConnectOptions options = new MqttConnectOptions(); - options.setCleanSession(true); - options.setAutomaticReconnect(true); - - Logger.info("MQTT", "Verbinde mit Broker " + BROKER_URL); - client.connect(options); - appState.setMqttConnected(true); - Logger.info("MQTT", "Verbindung hergestellt"); + if (connectOnStart) { + connectClient(); + } } catch (MqttException e) { Logger.error("MQTT", "Fehler beim Verbinden", e); + appState.setMqttConnected(false); } } + /** + * Connects the configured MQTT client with default options. + * + * @throws MqttException if connection fails + */ + private void connectClient() throws MqttException { + MqttConnectOptions options = new MqttConnectOptions(); + options.setCleanSession(true); + options.setAutomaticReconnect(true); + + Logger.info("MQTT", "Verbinde mit Broker " + BROKER_URL); + client.connect(options); + appState.setMqttConnected(true); + Logger.info("MQTT", "Verbindung hergestellt"); + } + /** * Disconnects the MQTT client when connected. */ diff --git a/src/main/java/vassistent/service/ProcessManagerService.java b/src/main/java/vassistent/service/ProcessManagerService.java index eecb695..614251d 100644 --- a/src/main/java/vassistent/service/ProcessManagerService.java +++ b/src/main/java/vassistent/service/ProcessManagerService.java @@ -6,6 +6,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Objects; import java.util.Properties; /** @@ -13,7 +14,21 @@ import java.util.Properties; */ public class ProcessManagerService { + @FunctionalInterface + interface ProcessLauncher { + Process launch(ProcessBuilder processBuilder) throws IOException; + } + + private static final String DEFAULT_UNREAL_PID_FILE = + "C:\\Users\\Student\\Documents\\Dannick\\avatar\\unreal.pid"; + + private static final String DEFAULT_SIGNALLING_PID_FILE = + "C:\\Users\\Student\\Documents\\Dannick\\avatar\\signalling.pid"; + private final Properties config; + private final ProcessLauncher processLauncher; + private final String unrealPidFile; + private final String signallingPidFile; private Process pythonProcess; private Process unrealProcess; @@ -25,7 +40,32 @@ public class ProcessManagerService { * @param config loaded application properties */ public ProcessManagerService(Properties config) { - this.config = config; + this( + config, + ProcessBuilder::start, + DEFAULT_UNREAL_PID_FILE, + DEFAULT_SIGNALLING_PID_FILE + ); + } + + /** + * Creates a process manager with injectable process launcher and PID file paths. + * + * @param config loaded application properties + * @param processLauncher strategy used to launch process builders + * @param unrealPidFile PID file path for Unreal process cleanup + * @param signallingPidFile PID file path for signalling process cleanup + */ + ProcessManagerService( + Properties config, + ProcessLauncher processLauncher, + String unrealPidFile, + String signallingPidFile + ) { + this.config = Objects.requireNonNull(config); + this.processLauncher = Objects.requireNonNull(processLauncher); + this.unrealPidFile = Objects.requireNonNull(unrealPidFile); + this.signallingPidFile = Objects.requireNonNull(signallingPidFile); } /** @@ -55,7 +95,7 @@ public class ProcessManagerService { ProcessBuilder pb = new ProcessBuilder(python, script); pb.redirectErrorStream(true); - pythonProcess = pb.start(); + pythonProcess = processLauncher.launch(pb); Logger.info("PROCESS", "Mqtt Simulator gestartet"); } catch (IOException e) { @@ -123,7 +163,7 @@ public class ProcessManagerService { unrealPsScript ); - unrealProcess = pb.start(); + unrealProcess = processLauncher.launch(pb); //pb.directory(new File(exe).getParentFile()); @@ -144,13 +184,8 @@ public class ProcessManagerService { terminateProcess(unrealProcess); terminateProcess(unrealSignallingProcess); - killProcessFromPidFile( - "C:\\Users\\Student\\Documents\\Dannick\\avatar\\unreal.pid" - ); - - killProcessFromPidFile( - "C:\\Users\\Student\\Documents\\Dannick\\avatar\\signalling.pid" - ); + killProcessFromPidFile(unrealPidFile); + killProcessFromPidFile(signallingPidFile); Logger.info("PROCESS", "Externe Prozesse beendet"); } @@ -177,7 +212,7 @@ public class ProcessManagerService { "/F" ); - pb.start().waitFor(); + processLauncher.launch(pb).waitFor(); Logger.info("PROCESS", "Process Tree beendet → PID " + pid); @@ -209,10 +244,37 @@ public class ProcessManagerService { "/F" ); - pb.start().waitFor(); + processLauncher.launch(pb).waitFor(); } catch (Exception e) { Logger.error("PROCESS", "PID Kill fehlgeschlagen", e); } } + + /** + * Returns the currently tracked Python simulator process. + * + * @return Python process or {@code null} + */ + Process getPythonProcess() { + return pythonProcess; + } + + /** + * Returns the currently tracked Unreal process. + * + * @return Unreal process or {@code null} + */ + Process getUnrealProcess() { + return unrealProcess; + } + + /** + * Returns the currently tracked Unreal signalling process. + * + * @return signalling process or {@code null} + */ + Process getUnrealSignallingProcess() { + return unrealSignallingProcess; + } } diff --git a/src/test/java/vassistent/model/AppStateTest.java b/src/test/java/vassistent/model/AppStateTest.java new file mode 100644 index 0000000..1997d56 --- /dev/null +++ b/src/test/java/vassistent/model/AppStateTest.java @@ -0,0 +1,82 @@ +package vassistent.model; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link AppState}. + */ +class AppStateTest { + + private AppState appState; + + /** + * Creates a fresh app state instance for each test. + */ + @BeforeEach + void setUp() { + appState = new AppState(); + } + + /** + * Verifies problem level defaults to NONE. + */ + @Test + void defaultProblemLevel_isNone() { + assertEquals(ProblemLevel.NONE, appState.getProblemLevel()); + } + + /** + * Verifies MQTT status defaults to disconnected. + */ + @Test + void defaultMqttState_isDisconnected() { + assertFalse(appState.isMqttConnected()); + } + + /** + * Verifies listeners are notified when problem level changes. + */ + @Test + void setProblemLevel_notifiesListeners() { + AtomicInteger notifications = new AtomicInteger(0); + appState.addListener(state -> notifications.incrementAndGet()); + + appState.setProblemLevel(ProblemLevel.HIGH); + + assertEquals(1, notifications.get()); + assertEquals(ProblemLevel.HIGH, appState.getProblemLevel()); + } + + /** + * Verifies MQTT listener notifications happen only when value changes. + */ + @Test + void setMqttConnected_notifiesOnlyOnChange() { + AtomicInteger notifications = new AtomicInteger(0); + appState.addListener(state -> notifications.incrementAndGet()); + + appState.setMqttConnected(true); + appState.setMqttConnected(true); + appState.setMqttConnected(false); + + assertEquals(2, notifications.get()); + assertFalse(appState.isMqttConnected()); + } + + /** + * Verifies setting MQTT state to true updates the state value. + */ + @Test + void setMqttConnected_updatesValue() { + appState.setMqttConnected(true); + + assertTrue(appState.isMqttConnected()); + } +} diff --git a/src/test/java/vassistent/service/AnimationFileServiceTest.java b/src/test/java/vassistent/service/AnimationFileServiceTest.java new file mode 100644 index 0000000..6089282 --- /dev/null +++ b/src/test/java/vassistent/service/AnimationFileServiceTest.java @@ -0,0 +1,79 @@ +package vassistent.service; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import vassistent.model.ProblemLevel; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration tests for {@link AnimationFileService}. + */ +class AnimationFileServiceTest { + + @TempDir + Path tempDir; + + /** + * Verifies level-to-animation mapping is correctly serialized into JSON output. + * + * @param level problem level name + * @param expectedAnimation expected serialized animation value + * @throws Exception if file access fails + */ + @ParameterizedTest + @CsvSource({ + "NONE, no", + "WARNING, warning", + "HIGH, high", + "DISASTER, disaster" + }) + void wirteAnimationState_writesMappedAnimation(String level, String expectedAnimation) throws Exception { + Path outputFile = tempDir.resolve("animation.json"); + AnimationFileService service = new AnimationFileService(outputFile.toString()); + + service.wirteAnimationState(ProblemLevel.valueOf(level)); + + assertTrue(Files.exists(outputFile)); + String content = Files.readString(outputFile); + assertTrue(content.contains("\"animation\": \"" + expectedAnimation + "\"")); + } + + /** + * Verifies parent directories are created automatically when missing. + * + * @throws Exception if file access fails + */ + @Test + void wirteAnimationState_createsParentDirectory() throws Exception { + Path nestedFile = tempDir.resolve("nested/path/animation.json"); + AnimationFileService service = new AnimationFileService(nestedFile.toString()); + + service.wirteAnimationState(ProblemLevel.WARNING); + + assertTrue(Files.exists(nestedFile)); + } + + /** + * Verifies subsequent writes overwrite prior animation state. + * + * @throws Exception if file access fails + */ + @Test + void wirteAnimationState_overwritesPreviousContent() throws Exception { + Path outputFile = tempDir.resolve("animation.json"); + AnimationFileService service = new AnimationFileService(outputFile.toString()); + + service.wirteAnimationState(ProblemLevel.NONE); + service.wirteAnimationState(ProblemLevel.DISASTER); + + String content = Files.readString(outputFile); + assertTrue(content.contains("\"animation\": \"disaster\"")); + assertTrue(!content.contains("\"animation\": \"no\"")); + } +} diff --git a/src/test/java/vassistent/service/BinaryEventServiceTest.java b/src/test/java/vassistent/service/BinaryEventServiceTest.java index 8286a55..ef006fb 100644 --- a/src/test/java/vassistent/service/BinaryEventServiceTest.java +++ b/src/test/java/vassistent/service/BinaryEventServiceTest.java @@ -1,67 +1,116 @@ package vassistent.service; -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.junit.jupiter.MockitoExtension; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; /** - * Tests for payload validation and processing behavior in {@link BinaryEventService}. + * Unit tests for {@link BinaryEventService}. */ +@ExtendWith(MockitoExtension.class) class BinaryEventServiceTest { + @Mock private DataPersistenceService persistenceService; + + @Mock private EvaluationService evaluationService; + private BinaryEventService binaryEventService; /** - * Creates mocked dependencies and the service under test. + * Creates the service under test with mocked dependencies. */ @BeforeEach void setUp() { - persistenceService = mock(DataPersistenceService.class); - evaluationService = mock(EvaluationService.class); - binaryEventService = new BinaryEventService(persistenceService, evaluationService); - } - - /** - * Clears references after each test run. - */ - @AfterEach - void tearDown() { - persistenceService = null; - evaluationService = null; - binaryEventService = null; + binaryEventService = new BinaryEventService( + persistenceService, + evaluationService + ); } /** * Verifies that a valid payload triggers persistence and evaluation. */ @Test - void handlePayloadValid() { - binaryEventService.handlePayload("1"); - verify(persistenceService).store(anyInt()); + void handlePayload_validPayload_persistsAndEvaluates() { + String payload = "{\"valid\":true,\"_id\":1,\"prediction\":1}"; + + binaryEventService.handlePayload(payload); + + verify(persistenceService).store(1); verify(evaluationService).evaluate(); } /** - * Verifies that an invalid numeric payload value is ignored. + * Verifies that an invalid "valid" flag prevents processing. */ @Test - void handlePayloadInvalidValue() { - binaryEventService.handlePayload("5"); - verify(persistenceService, never()).store(anyInt()); + void handlePayload_invalidFlag_doesNotProcess() { + String payload = "{\"valid\":false,\"_id\":1,\"prediction\":1}"; + + binaryEventService.handlePayload(payload); + + verify(persistenceService, never()).store(org.mockito.ArgumentMatchers.anyInt()); verify(evaluationService, never()).evaluate(); } /** - * Verifies that a non-numeric payload does not trigger downstream actions. + * Verifies that payloads missing required fields are ignored. */ @Test - void handlePayloadNonNumeric() { - binaryEventService.handlePayload("abc"); - verify(persistenceService, never()).store(anyInt()); + void handlePayload_missingPrediction_doesNotProcess() { + String payload = "{\"valid\":true,\"_id\":1}"; + + binaryEventService.handlePayload(payload); + + verify(persistenceService, never()).store(org.mockito.ArgumentMatchers.anyInt()); verify(evaluationService, never()).evaluate(); } + + /** + * Verifies that predictions outside the binary domain are ignored. + */ + @Test + void handlePayload_invalidPrediction_doesNotProcess() { + String payload = "{\"valid\":true,\"_id\":1,\"prediction\":3}"; + + binaryEventService.handlePayload(payload); + + verify(persistenceService, never()).store(org.mockito.ArgumentMatchers.anyInt()); + verify(evaluationService, never()).evaluate(); + } + + /** + * Verifies that malformed JSON payloads do not trigger downstream processing. + */ + @Test + void handlePayload_malformedJson_doesNotProcess() { + String payload = "not-json"; + + binaryEventService.handlePayload(payload); + + verify(persistenceService, never()).store(org.mockito.ArgumentMatchers.anyInt()); + verify(evaluationService, never()).evaluate(); + } + + /** + * Verifies current behavior for duplicate IDs. + */ + @Test + void handlePayload_duplicateId_isIgnoredAfterFirstMessage() { + String payload = "{\"valid\":true,\"_id\":7,\"prediction\":0}"; + + binaryEventService.handlePayload(payload); + binaryEventService.handlePayload(payload); + + verify(persistenceService, times(1)).store(0); + verify(evaluationService, times(1)).evaluate(); + } } diff --git a/src/test/java/vassistent/service/DataPersistenceServiceTest.java b/src/test/java/vassistent/service/DataPersistenceServiceTest.java index 91f9512..e3363ca 100644 --- a/src/test/java/vassistent/service/DataPersistenceServiceTest.java +++ b/src/test/java/vassistent/service/DataPersistenceServiceTest.java @@ -1,89 +1,88 @@ package vassistent.service; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import vassistent.model.DatabaseEntry; -import java.io.File; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.ResultSet; -import java.sql.Statement; +import java.nio.file.Path; +import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Tests basic database initialization and persistence operations. + * Integration tests for {@link DataPersistenceService} using an isolated temp SQLite database. */ class DataPersistenceServiceTest { + @TempDir + Path tempDir; + private DataPersistenceService service; - private static final String TEST_DB = "jdbc:sqlite:data/test_health.db"; - private static final String DB_FILE = "data/test_health.db"; + private String dbUrl; /** - * Initializes a persistence service backed by a test database. + * Creates an isolated test database for each test. */ @BeforeEach - void setup() { - service = new DataPersistenceService(TEST_DB); + void setUp() { + dbUrl = "jdbc:sqlite:" + tempDir.resolve("health-test.db").toAbsolutePath(); + service = new DataPersistenceService(dbUrl); } /** - * Removes test database artifacts after each test. + * Verifies the configured JDBC URL is exposed unchanged. */ - @AfterEach - void cleanup() { - deleteTestDatabase(); - } - - - /** - * Deletes the test database file when present. - */ - private void deleteTestDatabase() { - File db = new File(DB_FILE); - if (db.exists()) { - assertTrue(db.delete(), "Testdatenbank konnte nicht gelöscht werden"); - } + @Test + void getDbUrl_returnsConfiguredUrl() { + assertEquals(dbUrl, service.getDbUrl()); } /** - * Verifies that the database folder exists after service initialization. - */ - @org.junit.jupiter.api.Test - void createDatabaseFolder() { - File folder = new File("data"); - - assertTrue(folder.exists(), "Datenbankordner sollte existieren"); - } - - /** - * Verifies that storing a value inserts at least one row. + * Verifies that stored entries can be read back in chronological order. * - * @throws Exception if direct JDBC validation fails + * @throws InterruptedException if the sleep used to separate timestamps is interrupted */ - @org.junit.jupiter.api.Test - void store() throws Exception { + @Test + void store_andGetLastEntries_returnsChronologicalEntries() throws InterruptedException { + service.store(1); + Thread.sleep(5); + service.store(0); + + List entries = service.getLastEntries(10); + + assertEquals(2, entries.size()); + assertEquals(1.0, entries.get(0).getValue()); + assertEquals(0.0, entries.get(1).getValue()); + assertNotNull(entries.get(0).getTimestamp()); + assertNotNull(entries.get(1).getTimestamp()); + } + + /** + * Verifies that entry retrieval respects the requested limit. + */ + @Test + void getLastEntries_respectsLimit() throws InterruptedException { + service.store(1); + Thread.sleep(5); + service.store(0); + Thread.sleep(5); service.store(1); - try (Connection c = DriverManager.getConnection(TEST_DB); - Statement s = c.createStatement(); - ResultSet rs = s.executeQuery( - "SELECT COUNT(*) FROM binary_event")) { + List entries = service.getLastEntries(2); - assertTrue(rs.next()); - int count = rs.getInt(1); - - assertTrue(count > 0, - "Es sollte mindestens ein Eintrag existieren"); - } + assertEquals(2, entries.size()); + assertFalse(entries.isEmpty()); } /** - * Verifies that the service returns its configured JDBC URL. + * Verifies that initializing the service ensures the data folder exists. */ - @org.junit.jupiter.api.Test - void getDbUrl() { - assertEquals(TEST_DB, service.getDbUrl()); + @Test + void constructor_createsDataFolderIfMissing() { + assertTrue(java.nio.file.Files.exists(Path.of("data"))); } } diff --git a/src/test/java/vassistent/service/EvaluationServiceTest.java b/src/test/java/vassistent/service/EvaluationServiceTest.java index 4141324..2a590ee 100644 --- a/src/test/java/vassistent/service/EvaluationServiceTest.java +++ b/src/test/java/vassistent/service/EvaluationServiceTest.java @@ -1,64 +1,83 @@ package vassistent.service; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import vassistent.model.AppState; import vassistent.model.ProblemLevel; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; /** - * Tests mapping of computed ratios to {@link ProblemLevel} values. + * Unit tests for {@link EvaluationService}. */ +@ExtendWith(MockitoExtension.class) class EvaluationServiceTest { + @Mock private StatisticsService statisticsService; + + @Mock + private AnimationFileService animationFileService; + private AppState appState; - private AnimationFileService unrealService; private EvaluationService evaluationService; /** - * Creates mocks and initializes the evaluation service under test. + * Creates the service under test with mocked dependencies. */ @BeforeEach void setUp() { - statisticsService = mock(StatisticsService.class); appState = new AppState(); - unrealService = mock(AnimationFileService.class); - evaluationService = new EvaluationService(statisticsService, appState, unrealService); + evaluationService = new EvaluationService( + statisticsService, + appState, + animationFileService + ); } /** - * Clears test references. + * Verifies threshold mapping and animation side-effect for multiple ratio values. + * + * @param ratio ratio returned by statistics service + * @param expectedLevel expected mapped problem level name */ - @org.junit.jupiter.api.Test - void tearDown() { - statisticsService = null; - appState = null; - unrealService = null; - evaluationService = null; - } + @ParameterizedTest + @CsvSource({ + "0.00, NONE", + "0.49, NONE", + "0.50, WARNING", + "0.79, WARNING", + "0.80, HIGH", + "0.89, HIGH", + "0.90, DISASTER", + "1.00, DISASTER" + }) + void evaluate_mapsThresholdsAndWritesAnimation(double ratio, String expectedLevel) { + ProblemLevel level = ProblemLevel.valueOf(expectedLevel); + when(statisticsService.getRatio(20)).thenReturn(ratio); - /** - * Verifies that high ratios map to {@link ProblemLevel#DISASTER}. - */ - @org.junit.jupiter.api.Test - void testEvaluateDisasterLevel() { - when(statisticsService.getRatio(anyInt())).thenReturn(0.95); evaluationService.evaluate(); - Assertions.assertEquals(ProblemLevel.DISASTER, appState.getProblemLevel()); - //verify(unrealService).speak("DISASTER"); + + assertEquals(level, appState.getProblemLevel()); + verify(animationFileService).wirteAnimationState(level); } /** - * Verifies that low ratios keep the state at {@link ProblemLevel#NONE}. + * Verifies that evaluation always queries the ratio with a fixed window size of 20. */ @Test - void testEvaluateNoChange() { - appState.setProblemLevel(ProblemLevel.NONE); - when(statisticsService.getRatio(anyInt())).thenReturn(0.1); + void evaluate_usesFixedRatioWindow() { + when(statisticsService.getRatio(20)).thenReturn(0.1); + evaluationService.evaluate(); - //verify(unrealService, never()).speak(anyString()); + + verify(statisticsService).getRatio(20); } } diff --git a/src/test/java/vassistent/service/MqttClientServiceTest.java b/src/test/java/vassistent/service/MqttClientServiceTest.java new file mode 100644 index 0000000..adc3940 --- /dev/null +++ b/src/test/java/vassistent/service/MqttClientServiceTest.java @@ -0,0 +1,155 @@ +package vassistent.service; + +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vassistent.model.AppState; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link MqttClientService}. + */ +@ExtendWith(MockitoExtension.class) +class MqttClientServiceTest { + + @Mock + private MqttClient mqttClient; + + private AppState appState; + + /** + * Creates a fresh app state per test. + */ + @BeforeEach + void setUp() { + appState = new AppState(); + } + + /** + * Verifies constructor behavior when auto-connect is enabled. + * + * @throws Exception if mock interaction setup fails + */ + @Test + void constructor_withConnectOnStart_connectsAndSetsState() throws Exception { + MqttClientService service = new MqttClientService(appState, mqttClient, true); + + verify(mqttClient).setCallback(service); + verify(mqttClient).connect(any()); + assertTrue(appState.isMqttConnected()); + } + + /** + * Verifies constructor handles connection failures without throwing. + * + * @throws Exception if mock interaction setup fails + */ + @Test + void constructor_connectFailure_keepsStateDisconnected() throws Exception { + doThrow(new MqttException(0)).when(mqttClient).connect(any()); + + MqttClientService service = new MqttClientService(appState, mqttClient, true); + + verify(mqttClient).setCallback(service); + verify(mqttClient).connect(any()); + assertFalse(appState.isMqttConnected()); + } + + /** + * Verifies subscribe registers the listener and routes arriving payloads. + * + * @throws Exception if callback invocation fails + */ + @Test + void subscribe_thenMessageArrived_routesPayloadToListener() throws Exception { + MqttClientService service = new MqttClientService(appState, mqttClient, false); + List payloads = new ArrayList<>(); + + service.subscribe("PREDICTION", payloads::add); + service.messageArrived("PREDICTION", new MqttMessage("data".getBytes(StandardCharsets.UTF_8))); + + verify(mqttClient).subscribe("PREDICTION"); + assertEquals(List.of("data"), payloads); + } + + /** + * Verifies publish forwards a message with the configured QoS and payload. + * + * @throws Exception if mock interaction setup fails + */ + @Test + void publish_forwardsMessageWithQos() throws Exception { + MqttClientService service = new MqttClientService(appState, mqttClient, false); + ArgumentCaptor captor = ArgumentCaptor.forClass(MqttMessage.class); + + service.publish("PREDICTION", "hello", 1); + + verify(mqttClient).publish(org.mockito.ArgumentMatchers.eq("PREDICTION"), captor.capture()); + MqttMessage published = captor.getValue(); + assertEquals("hello", new String(published.getPayload(), StandardCharsets.UTF_8)); + assertEquals(1, published.getQos()); + } + + /** + * Verifies disconnect only calls client disconnect when connection is active. + * + * @throws Exception if mock interaction setup fails + */ + @Test + void disconnect_onlyWhenConnected() throws Exception { + MqttClientService service = new MqttClientService(appState, mqttClient, false); + + when(mqttClient.isConnected()).thenReturn(false); + service.disconnect(); + verify(mqttClient, never()).disconnect(); + + when(mqttClient.isConnected()).thenReturn(true); + service.disconnect(); + verify(mqttClient).disconnect(); + } + + /** + * Verifies connection loss updates app state to disconnected. + */ + @Test + void connectionLost_setsMqttStateFalse() { + appState.setMqttConnected(true); + MqttClientService service = new MqttClientService(appState, mqttClient, false); + + service.connectionLost(new RuntimeException("lost")); + + assertFalse(appState.isMqttConnected()); + } + + /** + * Verifies unknown topics do not fail when a message arrives. + * + * @throws Exception if callback invocation fails + */ + @Test + void messageArrived_unknownTopic_doesNotThrow() throws Exception { + MqttClientService service = new MqttClientService(appState, mqttClient, false); + + service.messageArrived("UNKNOWN", new MqttMessage("x".getBytes(StandardCharsets.UTF_8))); + + verify(mqttClient, never()).subscribe("UNKNOWN"); + } +} diff --git a/src/test/java/vassistent/service/ProcessManagerServiceTest.java b/src/test/java/vassistent/service/ProcessManagerServiceTest.java new file mode 100644 index 0000000..fc2eb7b --- /dev/null +++ b/src/test/java/vassistent/service/ProcessManagerServiceTest.java @@ -0,0 +1,199 @@ +package vassistent.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +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 static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link ProcessManagerService} using an injected process launcher. + */ +@ExtendWith(MockitoExtension.class) +class ProcessManagerServiceTest { + + @TempDir + Path tempDir; + + @Mock + private Process process; + + private Properties config; + private List> launchedCommands; + + /** + * Initializes test configuration and command capture. + * + * @throws Exception if process mock setup fails + */ + @BeforeEach + void setUp() throws Exception { + config = new Properties(); + launchedCommands = new ArrayList<>(); + } + + /** + * Creates a service instance with command capture and test PID files. + * + * @return configured process manager service + */ + private ProcessManagerService createService() { + ProcessManagerService.ProcessLauncher launcher = processBuilder -> { + launchedCommands.add(new ArrayList<>(processBuilder.command())); + return process; + }; + + return new ProcessManagerService( + config, + launcher, + tempDir.resolve("unreal.pid").toString(), + tempDir.resolve("signalling.pid").toString() + ); + } + + /** + * Stubs process wait behavior for taskkill command execution. + * + * @throws Exception if mock stubbing fails + */ + private void stubProcessWaitFor() throws Exception { + when(process.waitFor()).thenReturn(0); + } + + /** + * Stubs process metadata needed by shutdown of tracked processes. + * + * @throws Exception if mock stubbing fails + */ + private void stubProcessForTrackedShutdown() throws Exception { + stubProcessWaitFor(); + when(process.pid()).thenReturn(123L); + } + + /** + * Verifies that no processes are launched when all features are disabled. + */ + @Test + void startProcesses_whenDisabled_launchesNothing() { + config.setProperty("mqtt_sim.enabled", "false"); + config.setProperty("unreal.enabled", "false"); + + ProcessManagerService service = createService(); + service.startProcesses(); + + assertTrue(launchedCommands.isEmpty()); + assertNull(service.getPythonProcess()); + assertNull(service.getUnrealProcess()); + assertNull(service.getUnrealSignallingProcess()); + } + + /** + * Verifies MQTT simulator launch command when simulator is enabled. + */ + @Test + void startProcesses_mqttEnabled_launchesPythonSimulator() { + config.setProperty("mqtt_sim.enabled", "true"); + config.setProperty("mqtt_sim.script", "sim.py"); + config.setProperty("python.path", "python"); + config.setProperty("unreal.enabled", "false"); + + ProcessManagerService service = createService(); + service.startProcesses(); + + assertEquals(1, launchedCommands.size()); + assertEquals(List.of("python", "sim.py"), launchedCommands.get(0)); + assertSame(process, service.getPythonProcess()); + } + + /** + * Verifies Unreal launch command when Unreal integration is enabled. + */ + @Test + void startProcesses_unrealEnabled_launchesPowerShell() { + config.setProperty("mqtt_sim.enabled", "false"); + config.setProperty("unreal.enabled", "true"); + config.setProperty("unreal.executable", "avatar.ps1"); + config.setProperty("unreal.signalling_server.script", "start_signal.bat"); + + ProcessManagerService service = createService(); + service.startProcesses(); + + assertEquals(1, launchedCommands.size()); + assertEquals( + List.of("powershell.exe", "-ExecutionPolicy", "Bypass", "-File", "avatar.ps1"), + launchedCommands.get(0) + ); + assertSame(process, service.getUnrealProcess()); + assertNull(service.getUnrealSignallingProcess()); + } + + /** + * Verifies shutdown issues taskkill commands for tracked process handles. + */ + @Test + void shutdown_withTrackedProcesses_issuesTaskkillCommands() throws Exception { + stubProcessForTrackedShutdown(); + + config.setProperty("mqtt_sim.enabled", "true"); + config.setProperty("mqtt_sim.script", "sim.py"); + config.setProperty("python.path", "python"); + config.setProperty("unreal.enabled", "true"); + config.setProperty("unreal.executable", "avatar.ps1"); + config.setProperty("unreal.signalling_server.script", "start_signal.bat"); + + ProcessManagerService service = createService(); + service.startProcesses(); + service.shutdown(); + + long taskkillCount = launchedCommands.stream() + .filter(command -> !command.isEmpty() && "taskkill".equals(command.get(0))) + .count(); + + assertEquals(2, taskkillCount); + } + + /** + * Verifies shutdown uses PID files when present. + * + * @throws Exception if temporary PID files cannot be written + */ + @Test + void shutdown_withPidFiles_issuesPidTaskkillCommands() throws Exception { + stubProcessWaitFor(); + + Path unrealPid = tempDir.resolve("unreal.pid"); + Path signallingPid = tempDir.resolve("signalling.pid"); + Files.writeString(unrealPid, "777"); + Files.writeString(signallingPid, "888"); + + ProcessManagerService.ProcessLauncher launcher = processBuilder -> { + launchedCommands.add(new ArrayList<>(processBuilder.command())); + return process; + }; + + ProcessManagerService service = new ProcessManagerService( + config, + launcher, + unrealPid.toString(), + signallingPid.toString() + ); + + service.shutdown(); + + assertTrue(launchedCommands.stream().anyMatch(command -> command.contains("777"))); + assertTrue(launchedCommands.stream().anyMatch(command -> command.contains("888"))); + } +} diff --git a/src/test/java/vassistent/service/StatisticsServiceTest.java b/src/test/java/vassistent/service/StatisticsServiceTest.java index 81e65ae..078b2d8 100644 --- a/src/test/java/vassistent/service/StatisticsServiceTest.java +++ b/src/test/java/vassistent/service/StatisticsServiceTest.java @@ -1,97 +1,110 @@ package vassistent.service; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vassistent.model.DatabaseEntry; +import vassistent.model.RatioPoint; -import java.io.File; -import java.sql.SQLException; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; /** - * Tests ratio calculations over persisted binary events. + * Unit and integration tests for {@link StatisticsService}. */ +@ExtendWith(MockitoExtension.class) class StatisticsServiceTest { - private String DB_URL; - private DataPersistenceService persistenceService; - private StatisticsService statisticsService; - private File tempDbFile; + @TempDir + Path tempDir; + + @Mock + private DataPersistenceService mockedPersistenceService; + + private StatisticsService unitService; /** - * Creates an isolated temporary SQLite database for each test. + * Creates unit-level service instance with mocked persistence dependency. */ @BeforeEach void setUp() { - try { - tempDbFile = File.createTempFile("testdb", ".db"); - } catch (Exception e) { - throw new RuntimeException("Kann temporäre DB-Datei nicht erstellen", e); - } - - DB_URL = "jdbc:sqlite:" + tempDbFile.getAbsolutePath(); - persistenceService = new DataPersistenceService(DB_URL); - statisticsService = new StatisticsService(persistenceService); + unitService = new StatisticsService(mockedPersistenceService); } /** - * Deletes the temporary test database file. - */ - @AfterEach - void tearDown() { - if (tempDbFile != null && tempDbFile.exists()) { - tempDbFile.delete(); - } - } - - /** - * Verifies ratio behavior when no events are available. + * Verifies rolling average output for a deterministic input sequence. */ @Test - void testGetRatioNoData() { - double ratio = statisticsService.getRatio(10); - assertEquals(0.0, ratio, "Ratio sollte 0 sein, wenn keine Daten vorhanden"); + void getLastNAverages_computesExpectedRollingValues() { + LocalDateTime t0 = LocalDateTime.now(); + List entries = List.of( + new DatabaseEntry(t0, 1), + new DatabaseEntry(t0.plusSeconds(1), 0), + new DatabaseEntry(t0.plusSeconds(2), 1) + ); + when(mockedPersistenceService.getLastEntries(3)).thenReturn(entries); + + List result = unitService.getLastNAverages(2); + + assertEquals(3, result.size()); + assertEquals(0.0, result.get(0).getRatio(), 0.0001); + assertEquals(1.0, result.get(1).getRatio(), 0.0001); + assertEquals(0.5, result.get(2).getRatio(), 0.0001); } /** - * Verifies ratio behavior with exactly one stored value. + * Verifies that empty persistence result yields an empty chart result. + */ + @Test + void getLastNAverages_withNoEntries_returnsEmptyList() { + when(mockedPersistenceService.getLastEntries(6)).thenReturn(List.of()); + + List result = unitService.getLastNAverages(5); + + assertTrue(result.isEmpty()); + } + + /** + * Verifies average ratio over newest values using real SQLite persistence. * - * @throws SQLException retained for compatibility with current method signature + * @throws InterruptedException if sleeps used to separate timestamps are interrupted */ @Test - void testGetRatioSingleValue() throws SQLException { - persistenceService.store(1); + void getRatio_returnsAverageOfLastN() throws InterruptedException { + String dbUrl = "jdbc:sqlite:" + tempDir.resolve("ratio-test.db").toAbsolutePath(); + DataPersistenceService integrationPersistence = new DataPersistenceService(dbUrl); + StatisticsService integrationService = new StatisticsService(integrationPersistence); - double ratio = statisticsService.getRatio(1); - assertEquals(1.0, ratio, "Ratio sollte 1 sein, wenn nur 1 gespeichert wurde"); + integrationPersistence.store(1); + Thread.sleep(5); + integrationPersistence.store(0); + Thread.sleep(5); + integrationPersistence.store(1); + + double ratio = integrationService.getRatio(3); + + assertEquals(2.0 / 3.0, ratio, 0.0001); } /** - * Verifies ratio behavior across multiple stored values. + * Verifies that requesting a ratio on an empty dataset returns zero. */ @Test - void testGetRatioMultipleValues() { - persistenceService.store(1); - persistenceService.store(0); - persistenceService.store(1); + void getRatio_withNoData_returnsZero() { + String dbUrl = "jdbc:sqlite:" + tempDir.resolve("ratio-empty.db").toAbsolutePath(); + DataPersistenceService integrationPersistence = new DataPersistenceService(dbUrl); + StatisticsService integrationService = new StatisticsService(integrationPersistence); - double ratio = statisticsService.getRatio(10); - assertEquals((1 + 0 + 1) / 3.0, ratio, 0.0001, "Ratio sollte Durchschnitt der letzten Werte sein"); - } + double ratio = integrationService.getRatio(20); - /** - * Verifies that ratio calculation only considers the latest N values. - */ - @Test - void testGetRatioLimitLastN() { - persistenceService.store(1); - persistenceService.store(1); - persistenceService.store(0); - persistenceService.store(0); - persistenceService.store(1); - - double ratio = statisticsService.getRatio(3); - assertEquals((1 + 0 + 0) / 3.0, ratio, 0.0001, "Ratio sollte Durchschnitt der letzten Werte sein"); + assertEquals(0.0, ratio, 0.0001); } }