M13 vollständig abgeschlossen: V2.0-Freigabe (AP-001 bis AP-009)

- AP-001: Betriebs- und Startdokumentation für GUI und headless
  konsolidiert (betrieb.md, README.md)
- AP-002: Endbenutzer-Bedienanleitung gui-bedienanleitung.md angelegt
  (deskriptiv, 13 Kapitel, deutsch, Windows-Hinweise)
- AP-003: Konfigurationsbeispiele docs/examples/application.properties
  und docs/examples/prompt.txt konsolidiert, konsistent mit Standardvorlage
- AP-004: Regressionstests für headless Abwärtskompatibilität
  (JAR-Smoke-IT mit --config-Varianten und JavaFX-Freiheit)
- AP-005: GUI-Smoke-Tests für V2.0-Kernumfang vervollständigt
  (Startup-Notice-Sichtbarkeit im Header)
- AP-006: Build- und Packaging-Dokumentation im Abschnitt
  "Build und Packaging" in betrieb.md, README-Artefaktnamen korrigiert
- AP-007: Integrierte Gesamtprüfung durchgeführt, V2.0-Abschnitt in
  befundliste.md — keine Release-Blocker, zwei nicht blockierende
  Restpunkte (R1 ByteBuddy-Warning, R2 fehlender visueller GUI-Render-Test)
- AP-008: entfiel (keine Release-Blocker zu beheben)
- AP-009: Finale Gesamtprüfung, Freigabedokument docs/freigabe-v2_0.md
  mit Git-HEAD, Build-/Test-Ergebnissen, Freigabeaussage. Ein während
  der Stichprobe entdeckter Doku-Defekt (R3: API-Key-Legacy-Variable)
  wurde unmittelbar in gui-bedienanleitung.md korrigiert.

V2.0 ist freigabefähig. 1.403 Tests grün, 0 Failures, 0 Errors.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 22:50:51 +02:00
parent 1bb7a42735
commit 016da8318d
10 changed files with 1399 additions and 21 deletions
@@ -1,6 +1,7 @@
package de.gecheckt.pdf.umbenenner.bootstrap;
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;
@@ -19,6 +20,14 @@ import org.junit.jupiter.api.io.TempDir;
* <p>
* These tests verify that the shaded executable JAR can be run via {@code java -jar}
* and behaves correctly for both success and invalid configuration scenarios.
* Regression scenarios included:
* <ul>
* <li>Headless start without {@code --config}: uses default path, exits 0</li>
* <li>Headless start with valid {@code --config}: uses supplied path, exits 0</li>
* <li>Headless start with non-existent {@code --config}: hard startup failure, exits 1</li>
* <li>Invalid configuration: controlled failure, exits 1</li>
* <li>Headless output free of JavaFX initialisation markers</li>
* </ul>
* <p>
* Tests are executed by the maven-failsafe-plugin after the package phase.
* The *IT suffix ensures failsafe picks them up as integration tests.
@@ -277,4 +286,282 @@ class ExecutableJarSmokeTestIT {
"Output should indicate configuration/validation error. Got: " + outputText
);
}
// =========================================================================
// Regression: headless start with explicit valid --config path
// =========================================================================
/**
* Regression test: {@code --headless --config <valid-path>} must locate the supplied
* configuration file and complete successfully with exit code 0.
* <p>
* This guards against a regression where the explicit config path would be silently ignored
* and the default path would be used instead (or vice-versa causing a startup failure).
*/
@Test
void jar_headlessWithExplicitValidConfigPath_exitCode0(@TempDir Path workDir) throws Exception {
Path configDir = Files.createDirectory(workDir.resolve("mycfg"));
Path sourceDir = Files.createDirectory(workDir.resolve("source"));
Path targetDir = Files.createDirectory(workDir.resolve("target"));
Path logsDir = Files.createDirectory(workDir.resolve("logs"));
Path dbParent = Files.createDirectory(workDir.resolve("data"));
Path promptDir = Files.createDirectory(workDir.resolve("mycfg/prompts"));
Path sqliteFile = Files.createFile(dbParent.resolve("pdf-umbenenner.db"));
Path promptTemplateFile = Files.createFile(promptDir.resolve("template.txt"));
Files.writeString(promptTemplateFile, "Test prompt template for smoke test.");
// Store the config in a non-default location (not config/application.properties)
Path configFile = configDir.resolve("custom.properties");
String validConfig = """
source.folder=%s
target.folder=%s
sqlite.file=%s
ai.provider.active=openai-compatible
ai.provider.openai-compatible.baseUrl=http://localhost:8080/api
ai.provider.openai-compatible.model=gpt-4o-mini
ai.provider.openai-compatible.timeoutSeconds=30
ai.provider.openai-compatible.apiKey=test-api-key-for-smoke-test
max.retries.transient=3
max.pages=10
max.text.characters=5000
prompt.template.file=%s
runtime.lock.file=%s/lock.pid
log.directory=%s
log.level=INFO
""".formatted(
sourceDir.toAbsolutePath(),
targetDir.toAbsolutePath(),
sqliteFile.toAbsolutePath(),
promptTemplateFile.toAbsolutePath(),
workDir.toAbsolutePath(),
logsDir.toAbsolutePath()
);
Files.writeString(configFile, validConfig);
Path shadedJar = findShadedJar();
List<String> command = new ArrayList<>();
command.add(JAVA_EXECUTABLE);
command.add("-jar");
command.add(shadedJar.toString());
command.add("--headless");
command.add("--config");
command.add(configFile.toAbsolutePath().toString());
// Run in a fresh empty directory — no default config/application.properties present,
// so the process must load via the explicit --config path.
Path emptyWorkDir = Files.createDirectory(workDir.resolve("empty-cwd"));
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(emptyWorkDir.toFile());
pb.redirectErrorStream(true);
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Command: " + String.join(" ", command));
Process process = pb.start();
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
byte[] outputBytes = process.getInputStream().readAllBytes();
String outputText = new String(outputBytes);
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Exit code: " + process.exitValue());
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Output:\n" + outputText);
assertTrue(completed, "Process should complete within timeout");
assertEquals(0, process.exitValue(),
"Headless start with explicit valid --config path must exit 0. Output: " + outputText);
}
// =========================================================================
// Regression: headless start with non-existent --config path → hard failure
// =========================================================================
/**
* Regression test: {@code --headless --config <non-existent-path>} must be treated as
* a hard startup error and produce exit code 1.
* <p>
* According to the startup semantics, a missing {@code --config} target in headless mode
* is never a silent fallback but always a configuration error that blocks the run.
* The output must contain a keyword that helps the operator diagnose the root cause.
*/
@Test
void jar_headlessWithNonExistentConfigPath_exitCode1(@TempDir Path workDir) throws Exception {
Path shadedJar = findShadedJar();
String missingPath = workDir.resolve("does-not-exist.properties").toAbsolutePath().toString();
List<String> command = new ArrayList<>();
command.add(JAVA_EXECUTABLE);
command.add("-jar");
command.add(shadedJar.toString());
command.add("--headless");
command.add("--config");
command.add(missingPath);
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(workDir.toFile());
pb.redirectErrorStream(true);
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Command: " + String.join(" ", command));
Process process = pb.start();
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
byte[] outputBytes = process.getInputStream().readAllBytes();
String outputText = new String(outputBytes);
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Exit code: " + process.exitValue());
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Output:\n" + outputText);
assertTrue(completed, "Process should complete within timeout");
assertEquals(1, process.exitValue(),
"Headless start with non-existent --config path must exit 1. Output: " + outputText);
// Verify that the output contains a diagnostic keyword so operators can trace the cause.
// Only stable keywords are checked; exact message text may evolve.
assertTrue(
outputText.toLowerCase().contains("not found")
|| outputText.toLowerCase().contains("does not exist")
|| outputText.toLowerCase().contains("missing")
|| outputText.toLowerCase().contains("error")
|| outputText.toLowerCase().contains("config"),
"Output must contain a diagnostic keyword for the missing config file. Got: " + outputText
);
}
// =========================================================================
// Regression: headless output must be free of JavaFX initialisation markers
// =========================================================================
/**
* Regression test verifying that a headless start does not trigger JavaFX initialisation.
* <p>
* The headless path must not require a JavaFX runtime. This test runs the JAR in headless
* mode and checks that no JavaFX-specific strings appear in the combined stdout/stderr.
* Strings checked: "JavaFX", "Platform.startup", "Monocle", "javafx".
* <p>
* This is a process-level complement to the unit-level proof in
* {@link BootstrapRunnerStartupDispatchTest#headlessStart_doesNotInvokeGuiAdapterFactory()}.
* That unit test proves the GuiAdapterFactory is never called; this test proves the
* observable JAR output is likewise free of JavaFX initialisation noise.
*/
@Test
void jar_headlessStart_outputFreeOfJavaFxInitialisationMarkers(@TempDir Path workDir) throws Exception {
Path configDir = Files.createDirectory(workDir.resolve("config"));
Path sourceDir = Files.createDirectory(workDir.resolve("source"));
Path targetDir = Files.createDirectory(workDir.resolve("target"));
Path logsDir = Files.createDirectory(workDir.resolve("logs"));
Path dbParent = Files.createDirectory(workDir.resolve("data"));
Path promptDir = Files.createDirectory(workDir.resolve("config/prompts"));
Path sqliteFile = Files.createFile(dbParent.resolve("pdf-umbenenner.db"));
Path promptTemplateFile = Files.createFile(promptDir.resolve("template.txt"));
Files.writeString(promptTemplateFile, "Test prompt template for headless JavaFX-freedom check.");
Path configFile = configDir.resolve("application.properties");
String validConfig = """
source.folder=%s
target.folder=%s
sqlite.file=%s
ai.provider.active=openai-compatible
ai.provider.openai-compatible.baseUrl=http://localhost:8080/api
ai.provider.openai-compatible.model=gpt-4o-mini
ai.provider.openai-compatible.timeoutSeconds=30
ai.provider.openai-compatible.apiKey=test-api-key-javafx-check
max.retries.transient=3
max.pages=10
max.text.characters=5000
prompt.template.file=%s
runtime.lock.file=%s/lock.pid
log.directory=%s
log.level=INFO
""".formatted(
sourceDir.toAbsolutePath(),
targetDir.toAbsolutePath(),
sqliteFile.toAbsolutePath(),
promptTemplateFile.toAbsolutePath(),
workDir.toAbsolutePath(),
logsDir.toAbsolutePath()
);
Files.writeString(configFile, validConfig);
Path shadedJar = findShadedJar();
List<String> command = new ArrayList<>();
command.add(JAVA_EXECUTABLE);
command.add("-jar");
command.add(shadedJar.toString());
command.add("--headless");
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(workDir.toFile());
pb.redirectErrorStream(true);
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Command: " + String.join(" ", command));
Process process = pb.start();
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
byte[] outputBytes = process.getInputStream().readAllBytes();
String outputText = new String(outputBytes);
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Exit code: " + process.exitValue());
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Output:\n" + outputText);
assertTrue(completed, "Process should complete within timeout");
assertEquals(0, process.exitValue(),
"Headless start must exit 0 for the JavaFX-freedom check to be meaningful. "
+ "Output: " + outputText);
// JavaFX initialisation would produce one of these markers in stdout/stderr.
// Their absence is the evidence that the headless path is JavaFX-free at runtime.
assertFalse(
outputText.contains("Platform.startup")
|| outputText.contains("Monocle")
|| outputText.contains("com.sun.javafx")
|| outputText.contains("javafx.application"),
"Headless output must not contain JavaFX initialisation markers. Got:\n" + outputText
);
}
// =========================================================================
// Shared helper: locate the shaded JAR
// =========================================================================
/**
* Locates the shaded executable JAR produced by the {@code maven-shade-plugin}.
* <p>
* Searches in {@code pdf-umbenenner-bootstrap/target} relative to the Maven reactor root
* ({@code user.dir}) first, then falls back to the local {@code target/} directory.
*
* @return absolute path to the shaded JAR
* @throws AssertionError if no matching JAR can be found
*/
private Path findShadedJar() {
Path projectRoot = Paths.get(System.getProperty("user.dir"));
Path bootstrapTarget = projectRoot.resolve("pdf-umbenenner-bootstrap/target");
if (!Files.exists(bootstrapTarget)) {
bootstrapTarget = Paths.get("target");
}
assertTrue(Files.exists(bootstrapTarget),
"Bootstrap target directory must exist: " + bootstrapTarget);
File[] jars = bootstrapTarget.toFile().listFiles(
(dir, name) -> name.endsWith(".jar")
&& !name.contains("original")
&& !name.contains("tests"));
assertNotNull(jars, "JAR files should exist in target directory");
assertTrue(jars.length > 0, "At least one JAR should exist in target directory");
Path shadedJar = Paths.get(jars[0].getAbsolutePath());
for (File jar : jars) {
if (jar.getName().contains("shaded")
|| jar.getName().equals("pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar")) {
shadedJar = jar.toPath().toAbsolutePath();
break;
}
}
assertTrue(Files.exists(shadedJar), "Shaded JAR file must exist: " + shadedJar);
return shadedJar;
}
}