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);
}
}