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
This commit is contained in:
Niklas Aumueller 2026-03-06 14:16:40 +01:00
parent 4eca8d6e91
commit 9fed2cd420
17 changed files with 956 additions and 237 deletions

View File

@ -28,15 +28,7 @@ Impact:
- log output may indicate startup intent, but process is not actually launched via that path - log output may indicate startup intent, but process is not actually launched via that path
## 4. Legacy Mockito Dependency ## 4. Encoding Artifacts in Logs/Strings
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
Some source/log text contains mojibake characters in comments/messages. Some source/log text contains mojibake characters in comments/messages.

View File

@ -51,9 +51,16 @@ Package-level map of all Java source files in `src/main/java` and tests in `src/
## Test Sources ## Test Sources
### `vassistent.model`
- `AppStateTest.java`
### `vassistent.service` ### `vassistent.service`
- `AnimationFileServiceTest.java`
- `BinaryEventServiceTest.java` - `BinaryEventServiceTest.java`
- `DataPersistenceServiceTest.java` - `DataPersistenceServiceTest.java`
- `EvaluationServiceTest.java` - `EvaluationServiceTest.java`
- `MqttClientServiceTest.java`
- `ProcessManagerServiceTest.java`
- `StatisticsServiceTest.java` - `StatisticsServiceTest.java`

View File

@ -8,32 +8,36 @@ mvn test
## Current Test Suite ## Current Test Suite
Located in `src/test/java/vassistent/service`: Located in:
- `BinaryEventServiceTest` - `src/test/java/vassistent/model/AppStateTest`
- `DataPersistenceServiceTest` - `src/test/java/vassistent/service/AnimationFileServiceTest`
- `EvaluationServiceTest` - `src/test/java/vassistent/service/BinaryEventServiceTest`
- `StatisticsServiceTest` - `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) ## 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` ## Notes
- this version relies on CGLIB behavior that conflicts with modern Java module access
- result: Mockito-based tests fail at initialization (`InaccessibleObjectException`)
Tests not depending on Mockito are expected to run successfully. - some tests are integration-style and use temporary SQLite databases
- test output may include logger lines because services log during execution
## 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
```

19
pom.xml
View File

@ -26,14 +26,14 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId> <artifactId>junit-jupiter</artifactId>
<version>5.10.2</version> <version>5.10.2</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.mockito</groupId> <groupId>org.mockito</groupId>
<artifactId>mockito-all</artifactId> <artifactId>mockito-junit-jupiter</artifactId>
<version>2.0.2-beta</version> <version>5.12.0</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency> <dependency>
@ -59,4 +59,17 @@
</dependency> </dependency>
</dependencies> </dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<useModulePath>false</useModulePath>
</configuration>
</plugin>
</plugins>
</build>
</project> </project>

View File

@ -127,8 +127,9 @@ mvn test
Current state in this repository on Java 17: Current state in this repository on Java 17:
- `DataPersistenceServiceTest` and `StatisticsServiceTest` execute. - unit and integration tests are available for state, event processing, evaluation, persistence, and statistics
- Mockito-based tests fail because dependency is `mockito-all:2.0.2-beta` (legacy CGLIB + Java module access issue on modern JDKs). - test stack uses JUnit 5 + Mockito JUnit Jupiter
- full suite runs successfully with `mvn test`
## Project Structure ## Project Structure

View File

@ -6,15 +6,29 @@ import vassistent.util.Logger;
import java.io.File; import java.io.File;
import java.io.FileWriter; import java.io.FileWriter;
import java.io.IOException; 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. * Writes the current problem level as animation state JSON for Unreal integration.
*/ */
public class AnimationFileService { public class AnimationFileService {
private static final String PATH = "C:\\Users\\Student\\Documents\\Dannick\\Prototyp1\\Saved\\animation.json"; 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. * 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); String animation = mapLevelToAnimation(level);
File file = new File(PATH); File file = new File(outputPath);
try { try {
File parent = file.getParentFile();
if (parent != null) {
parent.mkdirs();
}
String json = """ String json = """
{ {
"animation": "%s" "animation": "%s"

View File

@ -52,7 +52,7 @@ public class BinaryEventService {
if (lastId != null && lastId == id) { if (lastId != null && lastId == id) {
Logger.warn("EVENT", "Payload ID bereits verarbeitet: " + id); Logger.warn("EVENT", "Payload ID bereits verarbeitet: " + id);
//return; return;
} }
lastId = id; lastId = id;

View File

@ -30,29 +30,55 @@ public class MqttClientService implements MqttCallback {
* @param appState shared state updated with connection status * @param appState shared state updated with connection status
*/ */
public MqttClientService(AppState appState) { 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; this.appState = appState;
try { try {
client = new MqttClient( client = injectedClient != null
? injectedClient
: new MqttClient(
BROKER_URL, BROKER_URL,
CLIENT_ID, CLIENT_ID,
new MemoryPersistence() new MemoryPersistence()
); );
client.setCallback(this); client.setCallback(this);
MqttConnectOptions options = new MqttConnectOptions(); if (connectOnStart) {
options.setCleanSession(true); connectClient();
options.setAutomaticReconnect(true); }
Logger.info("MQTT", "Verbinde mit Broker " + BROKER_URL);
client.connect(options);
appState.setMqttConnected(true);
Logger.info("MQTT", "Verbindung hergestellt");
} catch (MqttException e) { } catch (MqttException e) {
Logger.error("MQTT", "Fehler beim Verbinden", 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. * Disconnects the MQTT client when connected.
*/ */

View File

@ -6,6 +6,7 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Objects;
import java.util.Properties; import java.util.Properties;
/** /**
@ -13,7 +14,21 @@ import java.util.Properties;
*/ */
public class ProcessManagerService { 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 Properties config;
private final ProcessLauncher processLauncher;
private final String unrealPidFile;
private final String signallingPidFile;
private Process pythonProcess; private Process pythonProcess;
private Process unrealProcess; private Process unrealProcess;
@ -25,7 +40,32 @@ public class ProcessManagerService {
* @param config loaded application properties * @param config loaded application properties
*/ */
public ProcessManagerService(Properties config) { 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); ProcessBuilder pb = new ProcessBuilder(python, script);
pb.redirectErrorStream(true); pb.redirectErrorStream(true);
pythonProcess = pb.start(); pythonProcess = processLauncher.launch(pb);
Logger.info("PROCESS", "Mqtt Simulator gestartet"); Logger.info("PROCESS", "Mqtt Simulator gestartet");
} catch (IOException e) { } catch (IOException e) {
@ -123,7 +163,7 @@ public class ProcessManagerService {
unrealPsScript unrealPsScript
); );
unrealProcess = pb.start(); unrealProcess = processLauncher.launch(pb);
//pb.directory(new File(exe).getParentFile()); //pb.directory(new File(exe).getParentFile());
@ -144,13 +184,8 @@ public class ProcessManagerService {
terminateProcess(unrealProcess); terminateProcess(unrealProcess);
terminateProcess(unrealSignallingProcess); terminateProcess(unrealSignallingProcess);
killProcessFromPidFile( killProcessFromPidFile(unrealPidFile);
"C:\\Users\\Student\\Documents\\Dannick\\avatar\\unreal.pid" killProcessFromPidFile(signallingPidFile);
);
killProcessFromPidFile(
"C:\\Users\\Student\\Documents\\Dannick\\avatar\\signalling.pid"
);
Logger.info("PROCESS", "Externe Prozesse beendet"); Logger.info("PROCESS", "Externe Prozesse beendet");
} }
@ -177,7 +212,7 @@ public class ProcessManagerService {
"/F" "/F"
); );
pb.start().waitFor(); processLauncher.launch(pb).waitFor();
Logger.info("PROCESS", Logger.info("PROCESS",
"Process Tree beendet → PID " + pid); "Process Tree beendet → PID " + pid);
@ -209,10 +244,37 @@ public class ProcessManagerService {
"/F" "/F"
); );
pb.start().waitFor(); processLauncher.launch(pb).waitFor();
} catch (Exception e) { } catch (Exception e) {
Logger.error("PROCESS", "PID Kill fehlgeschlagen", 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;
}
} }

View File

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

View File

@ -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\""));
}
}

View File

@ -1,67 +1,116 @@
package vassistent.service; package vassistent.service;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; 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 { class BinaryEventServiceTest {
@Mock
private DataPersistenceService persistenceService; private DataPersistenceService persistenceService;
@Mock
private EvaluationService evaluationService; private EvaluationService evaluationService;
private BinaryEventService binaryEventService; private BinaryEventService binaryEventService;
/** /**
* Creates mocked dependencies and the service under test. * Creates the service under test with mocked dependencies.
*/ */
@BeforeEach @BeforeEach
void setUp() { void setUp() {
persistenceService = mock(DataPersistenceService.class); binaryEventService = new BinaryEventService(
evaluationService = mock(EvaluationService.class); persistenceService,
binaryEventService = new BinaryEventService(persistenceService, evaluationService); evaluationService
} );
/**
* Clears references after each test run.
*/
@AfterEach
void tearDown() {
persistenceService = null;
evaluationService = null;
binaryEventService = null;
} }
/** /**
* Verifies that a valid payload triggers persistence and evaluation. * Verifies that a valid payload triggers persistence and evaluation.
*/ */
@Test @Test
void handlePayloadValid() { void handlePayload_validPayload_persistsAndEvaluates() {
binaryEventService.handlePayload("1"); String payload = "{\"valid\":true,\"_id\":1,\"prediction\":1}";
verify(persistenceService).store(anyInt());
binaryEventService.handlePayload(payload);
verify(persistenceService).store(1);
verify(evaluationService).evaluate(); verify(evaluationService).evaluate();
} }
/** /**
* Verifies that an invalid numeric payload value is ignored. * Verifies that an invalid "valid" flag prevents processing.
*/ */
@Test @Test
void handlePayloadInvalidValue() { void handlePayload_invalidFlag_doesNotProcess() {
binaryEventService.handlePayload("5"); String payload = "{\"valid\":false,\"_id\":1,\"prediction\":1}";
verify(persistenceService, never()).store(anyInt());
binaryEventService.handlePayload(payload);
verify(persistenceService, never()).store(org.mockito.ArgumentMatchers.anyInt());
verify(evaluationService, never()).evaluate(); verify(evaluationService, never()).evaluate();
} }
/** /**
* Verifies that a non-numeric payload does not trigger downstream actions. * Verifies that payloads missing required fields are ignored.
*/ */
@Test @Test
void handlePayloadNonNumeric() { void handlePayload_missingPrediction_doesNotProcess() {
binaryEventService.handlePayload("abc"); String payload = "{\"valid\":true,\"_id\":1}";
verify(persistenceService, never()).store(anyInt());
binaryEventService.handlePayload(payload);
verify(persistenceService, never()).store(org.mockito.ArgumentMatchers.anyInt());
verify(evaluationService, never()).evaluate(); 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();
}
} }

View File

@ -1,89 +1,88 @@
package vassistent.service; package vassistent.service;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; 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.nio.file.Path;
import java.sql.Connection; import java.util.List;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
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 { class DataPersistenceServiceTest {
@TempDir
Path tempDir;
private DataPersistenceService service; private DataPersistenceService service;
private static final String TEST_DB = "jdbc:sqlite:data/test_health.db"; private String dbUrl;
private static final String DB_FILE = "data/test_health.db";
/** /**
* Initializes a persistence service backed by a test database. * Creates an isolated test database for each test.
*/ */
@BeforeEach @BeforeEach
void setup() { void setUp() {
service = new DataPersistenceService(TEST_DB); 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 @Test
void cleanup() { void getDbUrl_returnsConfiguredUrl() {
deleteTestDatabase(); assertEquals(dbUrl, service.getDbUrl());
}
/**
* 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");
}
} }
/** /**
* Verifies that the database folder exists after service initialization. * Verifies that stored entries can be read back in chronological order.
*/
@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.
* *
* @throws Exception if direct JDBC validation fails * @throws InterruptedException if the sleep used to separate timestamps is interrupted
*/ */
@org.junit.jupiter.api.Test @Test
void store() throws Exception { void store_andGetLastEntries_returnsChronologicalEntries() throws InterruptedException {
service.store(1);
Thread.sleep(5);
service.store(0);
List<DatabaseEntry> 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); service.store(1);
try (Connection c = DriverManager.getConnection(TEST_DB); List<DatabaseEntry> entries = service.getLastEntries(2);
Statement s = c.createStatement();
ResultSet rs = s.executeQuery(
"SELECT COUNT(*) FROM binary_event")) {
assertTrue(rs.next()); assertEquals(2, entries.size());
int count = rs.getInt(1); assertFalse(entries.isEmpty());
assertTrue(count > 0,
"Es sollte mindestens ein Eintrag existieren");
}
} }
/** /**
* Verifies that the service returns its configured JDBC URL. * Verifies that initializing the service ensures the data folder exists.
*/ */
@org.junit.jupiter.api.Test @Test
void getDbUrl() { void constructor_createsDataFolderIfMissing() {
assertEquals(TEST_DB, service.getDbUrl()); assertTrue(java.nio.file.Files.exists(Path.of("data")));
} }
} }

View File

@ -1,64 +1,83 @@
package vassistent.service; package vassistent.service;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; 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.AppState;
import vassistent.model.ProblemLevel; 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 { class EvaluationServiceTest {
@Mock
private StatisticsService statisticsService; private StatisticsService statisticsService;
@Mock
private AnimationFileService animationFileService;
private AppState appState; private AppState appState;
private AnimationFileService unrealService;
private EvaluationService evaluationService; private EvaluationService evaluationService;
/** /**
* Creates mocks and initializes the evaluation service under test. * Creates the service under test with mocked dependencies.
*/ */
@BeforeEach @BeforeEach
void setUp() { void setUp() {
statisticsService = mock(StatisticsService.class);
appState = new AppState(); appState = new AppState();
unrealService = mock(AnimationFileService.class); evaluationService = new EvaluationService(
evaluationService = new EvaluationService(statisticsService, appState, unrealService); 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 @ParameterizedTest
void tearDown() { @CsvSource({
statisticsService = null; "0.00, NONE",
appState = null; "0.49, NONE",
unrealService = null; "0.50, WARNING",
evaluationService = null; "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(); 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 @Test
void testEvaluateNoChange() { void evaluate_usesFixedRatioWindow() {
appState.setProblemLevel(ProblemLevel.NONE); when(statisticsService.getRatio(20)).thenReturn(0.1);
when(statisticsService.getRatio(anyInt())).thenReturn(0.1);
evaluationService.evaluate(); evaluationService.evaluate();
//verify(unrealService, never()).speak(anyString());
verify(statisticsService).getRatio(20);
} }
} }

View File

@ -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<String> 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<MqttMessage> 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");
}
}

View File

@ -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<List<String>> 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")));
}
}

View File

@ -1,97 +1,110 @@
package vassistent.service; package vassistent.service;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; 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.nio.file.Path;
import java.sql.SQLException; 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 { class StatisticsServiceTest {
private String DB_URL; @TempDir
private DataPersistenceService persistenceService; Path tempDir;
private StatisticsService statisticsService;
private File tempDbFile; @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 @BeforeEach
void setUp() { void setUp() {
try { unitService = new StatisticsService(mockedPersistenceService);
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);
} }
/** /**
* Deletes the temporary test database file. * Verifies rolling average output for a deterministic input sequence.
*/
@AfterEach
void tearDown() {
if (tempDbFile != null && tempDbFile.exists()) {
tempDbFile.delete();
}
}
/**
* Verifies ratio behavior when no events are available.
*/ */
@Test @Test
void testGetRatioNoData() { void getLastNAverages_computesExpectedRollingValues() {
double ratio = statisticsService.getRatio(10); LocalDateTime t0 = LocalDateTime.now();
assertEquals(0.0, ratio, "Ratio sollte 0 sein, wenn keine Daten vorhanden"); List<DatabaseEntry> 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<RatioPoint> 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<RatioPoint> 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 @Test
void testGetRatioSingleValue() throws SQLException { void getRatio_returnsAverageOfLastN() throws InterruptedException {
persistenceService.store(1); 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); integrationPersistence.store(1);
assertEquals(1.0, ratio, "Ratio sollte 1 sein, wenn nur 1 gespeichert wurde"); 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 @Test
void testGetRatioMultipleValues() { void getRatio_withNoData_returnsZero() {
persistenceService.store(1); String dbUrl = "jdbc:sqlite:" + tempDir.resolve("ratio-empty.db").toAbsolutePath();
persistenceService.store(0); DataPersistenceService integrationPersistence = new DataPersistenceService(dbUrl);
persistenceService.store(1); StatisticsService integrationService = new StatisticsService(integrationPersistence);
double ratio = statisticsService.getRatio(10); double ratio = integrationService.getRatio(20);
assertEquals((1 + 0 + 1) / 3.0, ratio, 0.0001, "Ratio sollte Durchschnitt der letzten Werte sein");
}
/** assertEquals(0.0, ratio, 0.0001);
* 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");
} }
} }