diff --git a/src/main/java/vassistent/bootstrap/ApplicationInitializer.java b/src/main/java/vassistent/bootstrap/ApplicationInitializer.java index 37e2eeb..b1d686e 100644 --- a/src/main/java/vassistent/bootstrap/ApplicationInitializer.java +++ b/src/main/java/vassistent/bootstrap/ApplicationInitializer.java @@ -98,7 +98,14 @@ public class ApplicationInitializer { ); context.setProcessManagerService(new ProcessManagerService(config)); - context.getProcessManagerService().startProcesses(); + + ProcessManagerService.StartupReport startupReport = + context.getProcessManagerService().startProcesses(); + + if (startupReport.hasFailures()) { + context.getProcessManagerService().shutdown(); + throw new IllegalStateException(startupReport.buildFailureMessage()); + } return context; } diff --git a/src/main/java/vassistent/service/DataPersistenceService.java b/src/main/java/vassistent/service/DataPersistenceService.java index 754a6a5..aa3fc1f 100644 --- a/src/main/java/vassistent/service/DataPersistenceService.java +++ b/src/main/java/vassistent/service/DataPersistenceService.java @@ -107,7 +107,7 @@ public class DataPersistenceService { String query = """ SELECT value, timestamp FROM binary_event - ORDER BY timestamp DESC + ORDER BY id DESC LIMIT ? """; try (Connection c = DriverManager.getConnection(DB_URL); PreparedStatement ps = c.prepareStatement(query)) { diff --git a/src/main/java/vassistent/service/ProcessManagerService.java b/src/main/java/vassistent/service/ProcessManagerService.java index 6ee8da1..f8ce40d 100644 --- a/src/main/java/vassistent/service/ProcessManagerService.java +++ b/src/main/java/vassistent/service/ProcessManagerService.java @@ -13,6 +13,205 @@ import java.util.Properties; */ public class ProcessManagerService { + /** + * Represents startup status for a single managed process component. + */ + public static final class ProcessStartStatus { + + private final String componentName; + private final boolean enabled; + private final boolean started; + private final String errorMessage; + + /** + * Creates an immutable process start status instance. + * + * @param componentName logical component name + * @param enabled whether component startup was enabled + * @param started whether startup completed without launch error + * @param errorMessage optional startup error details + */ + private ProcessStartStatus( + String componentName, + boolean enabled, + boolean started, + String errorMessage + ) { + this.componentName = componentName; + this.enabled = enabled; + this.started = started; + this.errorMessage = errorMessage; + } + + /** + * Creates a disabled status for a component. + * + * @param componentName logical component name + * @return disabled status instance + */ + static ProcessStartStatus disabled(String componentName) { + return new ProcessStartStatus(componentName, false, false, null); + } + + /** + * Creates a successful startup status for a component. + * + * @param componentName logical component name + * @return successful status instance + */ + static ProcessStartStatus started(String componentName) { + return new ProcessStartStatus(componentName, true, true, null); + } + + /** + * Creates a failed startup status for a component. + * + * @param componentName logical component name + * @param errorMessage startup failure details + * @return failure status instance + */ + static ProcessStartStatus failed(String componentName, String errorMessage) { + return new ProcessStartStatus(componentName, true, false, errorMessage); + } + + /** + * Returns whether startup failed for an enabled component. + * + * @return {@code true} when startup failed + */ + public boolean hasFailed() { + return enabled && !started; + } + + /** + * Returns logical component name. + * + * @return component name + */ + public String getComponentName() { + return componentName; + } + + /** + * Returns whether startup was enabled. + * + * @return enabled flag + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Returns whether startup completed without launch error. + * + * @return started flag + */ + public boolean isStarted() { + return started; + } + + /** + * Returns startup error details when available. + * + * @return error details or {@code null} + */ + public String getErrorMessage() { + return errorMessage; + } + } + + /** + * Aggregates startup status for all managed process components. + */ + public static final class StartupReport { + + private final ProcessStartStatus mqttSimulator; + private final ProcessStartStatus unreal; + + /** + * Creates a startup report. + * + * @param mqttSimulator startup status of MQTT simulator component + * @param unreal startup status of Unreal launcher component + */ + StartupReport( + ProcessStartStatus mqttSimulator, + ProcessStartStatus unreal + ) { + this.mqttSimulator = Objects.requireNonNull(mqttSimulator); + this.unreal = Objects.requireNonNull(unreal); + } + + /** + * Returns startup status of MQTT simulator component. + * + * @return MQTT simulator startup status + */ + public ProcessStartStatus getMqttSimulator() { + return mqttSimulator; + } + + /** + * Returns startup status of Unreal launcher component. + * + * @return Unreal startup status + */ + public ProcessStartStatus getUnreal() { + return unreal; + } + + /** + * Returns whether at least one enabled process failed to launch. + * + * @return {@code true} when startup has failures + */ + public boolean hasFailures() { + return mqttSimulator.hasFailed() || unreal.hasFailed(); + } + + /** + * Builds a compact startup failure message suitable for exceptions. + * + * @return failure message + */ + public String buildFailureMessage() { + StringBuilder message = + new StringBuilder("External process startup failed:"); + + appendFailure(message, mqttSimulator); + appendFailure(message, unreal); + + return message.toString(); + } + + /** + * Appends one failed component summary to a message builder. + * + * @param message target message builder + * @param status component startup status + */ + private static void appendFailure( + StringBuilder message, + ProcessStartStatus status + ) { + if (!status.hasFailed()) { + return; + } + + message.append(System.lineSeparator()) + .append("- ") + .append(status.getComponentName()) + .append(": "); + + String error = status.getErrorMessage(); + if (error == null || error.isBlank()) { + message.append("unknown startup error"); + } else { + message.append(error); + } + } + } + @FunctionalInterface interface ProcessLauncher { Process launch(ProcessBuilder processBuilder) throws IOException; @@ -78,23 +277,31 @@ public class ProcessManagerService { /** * Starts optional helper processes according to configuration flags. + * + * @return startup status report for all managed components */ - public void startProcesses() { + public StartupReport startProcesses() { - startPythonIfEnabled(); - startUnrealIfEnabled(); + ProcessStartStatus mqttSimulator = startPythonIfEnabled(); + ProcessStartStatus unreal = startUnrealIfEnabled(); + + return new StartupReport(mqttSimulator, unreal); } - /** + /** * Starts the Python MQTT simulator if enabled. + * + * @return startup status for the simulator component */ - private void startPythonIfEnabled() { + private ProcessStartStatus startPythonIfEnabled() { boolean enabled = Boolean.parseBoolean( config.getProperty("mqtt_sim.enabled", "false") ); - if (!enabled) return; + if (!enabled) { + return ProcessStartStatus.disabled("mqtt_sim"); + } try { String script = cleanConfigValue(config.getProperty("mqtt_sim.script")); @@ -107,25 +314,39 @@ public class ProcessManagerService { pythonProcess = processLauncher.launch(pb); Logger.info("PROCESS", "Mqtt Simulator gestartet"); - } catch (IOException e) { + return ProcessStartStatus.started("mqtt_sim"); + } catch (Exception e) { Logger.error("PROCESS", "Mqtt Simulator Start fehlgeschlagen", e); + return ProcessStartStatus.failed( + "mqtt_sim", + exceptionMessage(e) + ); } } - /** + /** * Starts Unreal-related processes if enabled. + * + * @return startup status for the Unreal component */ - private void startUnrealIfEnabled() { + private ProcessStartStatus startUnrealIfEnabled() { boolean enabled = Boolean.parseBoolean(config.getProperty("unreal.enabled", "false")); - if (!enabled) return; + if (!enabled) { + return ProcessStartStatus.disabled("unreal"); + } try { startUnrealEngine(); + return ProcessStartStatus.started("unreal"); - } catch (IOException e) { + } catch (Exception e) { Logger.error("PROCESS", "Unreal Start fehlgeschlagen", e); + return ProcessStartStatus.failed( + "unreal", + exceptionMessage(e) + ); } } @@ -273,6 +494,23 @@ public class ProcessManagerService { return trimmed; } + /** + * Returns a compact message for startup exception reporting. + * + * @param exception startup exception + * @return compact exception details + */ + private static String exceptionMessage(Exception exception) { + String type = exception.getClass().getSimpleName(); + String message = exception.getMessage(); + + if (message == null || message.isBlank()) { + return type; + } + + return type + ": " + message; + } + /** * Passes optional simulator settings from app config to the Python process environment. * diff --git a/src/main/java/vassistent/service/StatisticsService.java b/src/main/java/vassistent/service/StatisticsService.java index 97698d6..4fe3f79 100644 --- a/src/main/java/vassistent/service/StatisticsService.java +++ b/src/main/java/vassistent/service/StatisticsService.java @@ -5,7 +5,6 @@ import vassistent.model.RatioPoint; import vassistent.util.Logger; import java.sql.*; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -31,6 +30,10 @@ public class StatisticsService { * @return ratio in range {@code [0, 1]} or {@code 0.0} when unavailable */ public double getRatio(int lastN) { + if (lastN <= 0) { + return 0.0; + } + String query = """ SELECT AVG(value) FROM ( @@ -58,30 +61,40 @@ public class StatisticsService { /** * Computes rolling averages for chart rendering. * - * @param windowSize number of previous entries considered for each point + * The rolling window includes the current entry and up to {@code windowSize - 1} + * previous entries. + * + * @param windowSize number of entries considered per rolling average window * @return chronological list of ratio points */ public List getLastNAverages(int windowSize) { + if (windowSize <= 0) { + return List.of(); + } + List entries = persistenceService.getLastEntries(windowSize + 1); List result = new ArrayList<>(); - if (entries.isEmpty()) + if (entries.isEmpty()) { return result; + } + + double rollingSum = 0.0; + int windowStart = 0; for (int i = 0; i < entries.size(); i++) { - int start = Math.max(0, i - windowSize); + rollingSum += entries.get(i).getValue(); - double sum = 0; - int count = 0; - - for (int j = start; j < i; j++) { - sum += entries.get(j).getValue(); - count++; + if (i - windowStart + 1 > windowSize) { + rollingSum -= entries.get(windowStart).getValue(); + windowStart++; } - double avg = count == 0 ? 0 : sum / count; + int windowCount = i - windowStart + 1; + double avg = rollingSum / windowCount; + result.add(new RatioPoint( entries.get(i).getTimestamp(), diff --git a/src/test/java/vassistent/service/ProcessManagerServiceTest.java b/src/test/java/vassistent/service/ProcessManagerServiceTest.java index 2e6a414..1a32c45 100644 --- a/src/test/java/vassistent/service/ProcessManagerServiceTest.java +++ b/src/test/java/vassistent/service/ProcessManagerServiceTest.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Properties; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -92,11 +93,14 @@ class ProcessManagerServiceTest { config.setProperty("unreal.enabled", "false"); ProcessManagerService service = createService(); - service.startProcesses(); + ProcessManagerService.StartupReport report = service.startProcesses(); assertTrue(launchedCommands.isEmpty()); assertNull(service.getPythonProcess()); assertNull(service.getUnrealProcess()); + assertFalse(report.hasFailures()); + assertFalse(report.getMqttSimulator().isEnabled()); + assertFalse(report.getUnreal().isEnabled()); } /** @@ -110,11 +114,14 @@ class ProcessManagerServiceTest { config.setProperty("unreal.enabled", "false"); ProcessManagerService service = createService(); - service.startProcesses(); + ProcessManagerService.StartupReport report = service.startProcesses(); assertEquals(1, launchedCommands.size()); assertEquals(List.of("python", "sim.py"), launchedCommands.get(0)); assertSame(process, service.getPythonProcess()); + assertFalse(report.hasFailures()); + assertTrue(report.getMqttSimulator().isStarted()); + assertFalse(report.getUnreal().isEnabled()); } /** @@ -128,7 +135,7 @@ class ProcessManagerServiceTest { config.setProperty("unreal.signalling_server.script", "start_signal.bat"); ProcessManagerService service = createService(); - service.startProcesses(); + ProcessManagerService.StartupReport report = service.startProcesses(); assertEquals(1, launchedCommands.size()); assertEquals( @@ -140,6 +147,9 @@ class ProcessManagerServiceTest { .noneMatch(command -> !command.isEmpty() && "cmd".equalsIgnoreCase(command.get(0))) ); assertSame(process, service.getUnrealProcess()); + assertFalse(report.hasFailures()); + assertTrue(report.getUnreal().isStarted()); + assertFalse(report.getMqttSimulator().isEnabled()); } /** @@ -157,7 +167,7 @@ class ProcessManagerServiceTest { config.setProperty("unreal.signalling_server.script", "start_signal.bat"); ProcessManagerService service = createService(); - service.startProcesses(); + ProcessManagerService.StartupReport report = service.startProcesses(); service.shutdown(); long taskkillCount = launchedCommands.stream() @@ -165,6 +175,7 @@ class ProcessManagerServiceTest { .count(); assertEquals(2, taskkillCount); + assertFalse(report.hasFailures()); } /** @@ -198,4 +209,61 @@ class ProcessManagerServiceTest { assertTrue(launchedCommands.stream().anyMatch(command -> command.contains("777"))); assertTrue(launchedCommands.stream().anyMatch(command -> command.contains("888"))); } + + /** + * Verifies a failed simulator launch is reported as startup failure. + */ + @Test + void startProcesses_mqttLaunchFails_reportsFailure() { + config.setProperty("mqtt_sim.enabled", "true"); + config.setProperty("mqtt_sim.script", "sim.py"); + config.setProperty("python.path", "python"); + config.setProperty("unreal.enabled", "false"); + + ProcessManagerService.ProcessLauncher failingLauncher = processBuilder -> { + throw new java.io.IOException("python not found"); + }; + + ProcessManagerService service = new ProcessManagerService( + config, + failingLauncher, + tempDir.resolve("unreal.pid").toString(), + tempDir.resolve("signalling.pid").toString() + ); + + ProcessManagerService.StartupReport report = service.startProcesses(); + + assertTrue(report.hasFailures()); + assertTrue(report.getMqttSimulator().hasFailed()); + assertTrue(report.buildFailureMessage().contains("mqtt_sim")); + assertTrue(report.buildFailureMessage().contains("python not found")); + } + + /** + * Verifies a failed Unreal launch is reported as startup failure. + */ + @Test + void startProcesses_unrealLaunchFails_reportsFailure() { + config.setProperty("mqtt_sim.enabled", "false"); + config.setProperty("unreal.enabled", "true"); + config.setProperty("unreal.executable", "avatar.ps1"); + + ProcessManagerService.ProcessLauncher failingLauncher = processBuilder -> { + throw new java.io.IOException("powershell unavailable"); + }; + + ProcessManagerService service = new ProcessManagerService( + config, + failingLauncher, + tempDir.resolve("unreal.pid").toString(), + tempDir.resolve("signalling.pid").toString() + ); + + ProcessManagerService.StartupReport report = service.startProcesses(); + + assertTrue(report.hasFailures()); + assertTrue(report.getUnreal().hasFailed()); + assertTrue(report.buildFailureMessage().contains("unreal")); + assertTrue(report.buildFailureMessage().contains("powershell unavailable")); + } } diff --git a/src/test/java/vassistent/service/StatisticsServiceTest.java b/src/test/java/vassistent/service/StatisticsServiceTest.java index 078b2d8..dd796a2 100644 --- a/src/test/java/vassistent/service/StatisticsServiceTest.java +++ b/src/test/java/vassistent/service/StatisticsServiceTest.java @@ -14,6 +14,7 @@ import java.time.LocalDateTime; 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.Mockito.when; @@ -55,8 +56,8 @@ class StatisticsServiceTest { 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(1.0, result.get(0).getRatio(), 0.0001); + assertEquals(0.5, result.get(1).getRatio(), 0.0001); assertEquals(0.5, result.get(2).getRatio(), 0.0001); } @@ -72,6 +73,16 @@ class StatisticsServiceTest { assertTrue(result.isEmpty()); } + /** + * Verifies that non-positive window sizes yield an empty result. + */ + @Test + void getLastNAverages_withNonPositiveWindow_returnsEmptyList() { + List result = unitService.getLastNAverages(0); + + assertTrue(result.isEmpty()); + } + /** * Verifies average ratio over newest values using real SQLite persistence. * @@ -107,4 +118,28 @@ class StatisticsServiceTest { assertEquals(0.0, ratio, 0.0001); } + + /** + * Verifies that the newest rolling average matches {@link StatisticsService#getRatio(int)} + * for the same window size. + */ + @Test + void getLastNAverages_lastPointMatchesGetRatio() { + String dbUrl = "jdbc:sqlite:" + tempDir.resolve("ratio-rolling.db").toAbsolutePath(); + DataPersistenceService integrationPersistence = new DataPersistenceService(dbUrl); + StatisticsService integrationService = new StatisticsService(integrationPersistence); + + integrationPersistence.store(1); + integrationPersistence.store(0); + integrationPersistence.store(1); + integrationPersistence.store(1); + + int window = 3; + + List rolling = integrationService.getLastNAverages(window); + double ratio = integrationService.getRatio(window); + + assertFalse(rolling.isEmpty()); + assertEquals(ratio, rolling.get(rolling.size() - 1).getRatio(), 0.0001); + } }