ALL = List.of(
+ CASE_1,
+ CASE_2,
+ CASE_3,
+ CASE_3B,
+ CASE_4,
+ CASE_5,
+ CASE_6,
+ CASE_7,
+ CASE_8,
+ CASE_9,
+ CASE_10,
+ CASE_11
+ );
+}
diff --git a/src/test/java/de/avatic/lcc/e2e/tests/AbstractE2ETest.java b/src/test/java/de/avatic/lcc/e2e/tests/AbstractE2ETest.java
new file mode 100644
index 0000000..0045991
--- /dev/null
+++ b/src/test/java/de/avatic/lcc/e2e/tests/AbstractE2ETest.java
@@ -0,0 +1,443 @@
+package de.avatic.lcc.e2e.tests;
+
+import com.microsoft.playwright.Browser;
+import com.microsoft.playwright.BrowserContext;
+import com.microsoft.playwright.BrowserType;
+import com.microsoft.playwright.Page;
+import com.microsoft.playwright.Playwright;
+import de.avatic.lcc.LccApplication;
+import de.avatic.lcc.config.DatabaseTestConfiguration;
+import de.avatic.lcc.e2e.config.TestFrontendConfig;
+import de.avatic.lcc.e2e.pages.DevLoginPage;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.TestInstance;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Logger;
+
+/**
+ * Abstract base class for E2E tests.
+ * Starts Spring Boot backend with integrated frontend and provides Playwright setup.
+ *
+ * Prerequisites:
+ *
+ * - Frontend must be built to src/main/resources/static before running tests
+ * - Run: {@code cd src/frontend && BUILD_FOR_SPRING=true npm run build}
+ *
+ *
+ * Or use Maven profile (if configured):
+ * {@code mvn test -Dtest="*E2ETest" -Pe2e}
+ */
+@SpringBootTest(
+ classes = LccApplication.class,
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
+)
+@Import({DatabaseTestConfiguration.class, TestFrontendConfig.class})
+@Testcontainers
+@ActiveProfiles({"test", "dev", "mysql", "e2e"})
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+@Tag("e2e")
+public abstract class AbstractE2ETest {
+
+ @Autowired
+ protected JdbcTemplate jdbcTemplate;
+
+ private static final Logger logger = Logger.getLogger(AbstractE2ETest.class.getName());
+
+ protected static final boolean HEADLESS = Boolean.parseBoolean(
+ System.getProperty("playwright.headless", "true")
+ );
+ protected static final double TOLERANCE = 0.03; // 3% tolerance for numeric comparisons
+
+ @LocalServerPort
+ protected int port;
+
+ protected Playwright playwright;
+ protected Browser browser;
+ protected BrowserContext context;
+ protected Page page;
+
+ protected String getBaseUrl() {
+ return "http://localhost:" + port;
+ }
+
+ @BeforeAll
+ void setupPlaywright() {
+ // Load E2E test data
+ loadTestData();
+
+ checkFrontendBuilt();
+
+ logger.info("Setting up Playwright");
+ playwright = Playwright.create();
+ browser = playwright.chromium().launch(
+ new BrowserType.LaunchOptions()
+ .setHeadless(HEADLESS)
+ .setSlowMo(HEADLESS ? 0 : 100)
+ );
+
+ // Ensure screenshot directory exists
+ try {
+ Files.createDirectories(Paths.get("target/screenshots"));
+ } catch (Exception e) {
+ logger.warning("Could not create screenshots directory");
+ }
+
+ logger.info(() -> String.format(
+ "Playwright setup complete. Headless: %s, Base URL: %s",
+ HEADLESS, getBaseUrl()
+ ));
+ }
+
+ @BeforeEach
+ void setupPage() {
+ context = browser.newContext(new Browser.NewContextOptions()
+ .setViewportSize(1920, 1080)
+ );
+ page = context.newPage();
+
+ // Login via DevLoginPage
+ DevLoginPage loginPage = new DevLoginPage(page);
+ loginPage.login(getBaseUrl(), "John");
+
+ // Navigate to home page after login
+ page.navigate(getBaseUrl());
+ page.waitForLoadState();
+
+ // Take screenshot after login
+ takeScreenshot("after_login");
+
+ logger.info(() -> "Page setup complete, logged in as John. Current URL: " + page.url());
+ }
+
+ @AfterEach
+ void teardownPage() {
+ if (context != null) {
+ context.close();
+ }
+ }
+
+ @AfterAll
+ void teardownPlaywright() {
+ if (browser != null) {
+ browser.close();
+ }
+ if (playwright != null) {
+ playwright.close();
+ }
+ logger.info("Playwright teardown complete");
+ }
+
+ /**
+ * Takes a screenshot for debugging purposes.
+ */
+ protected void takeScreenshot(String name) {
+ Path screenshotPath = Paths.get("target/screenshots/" + name + ".png");
+ page.screenshot(new Page.ScreenshotOptions().setPath(screenshotPath));
+ logger.info(() -> "Screenshot saved to: " + screenshotPath);
+ }
+
+ /**
+ * Checks if the frontend has been built to static resources.
+ * Throws an exception with instructions if not.
+ */
+ private void checkFrontendBuilt() {
+ Path staticIndex = Paths.get("src/main/resources/static/index.html");
+ if (!Files.exists(staticIndex)) {
+ // Try to build frontend automatically
+ if (tryBuildFrontend()) {
+ logger.info("Frontend built successfully");
+ } else {
+ throw new IllegalStateException(
+ "Frontend not built. Please run:\n" +
+ " cd src/frontend && BUILD_FOR_SPRING=true npm run build\n" +
+ "Or set -Dskip.frontend.check=true to skip this check."
+ );
+ }
+ } else {
+ logger.info("Frontend already built at: " + staticIndex);
+ }
+ }
+
+ /**
+ * Attempts to build the frontend automatically.
+ * Returns true if successful, false otherwise.
+ */
+ private boolean tryBuildFrontend() {
+ if (Boolean.getBoolean("skip.frontend.build")) {
+ return false;
+ }
+
+ logger.info("Attempting to build frontend...");
+
+ try {
+ File frontendDir = new File("src/frontend");
+ if (!frontendDir.exists()) {
+ logger.warning("Frontend directory not found");
+ return false;
+ }
+
+ // Check if node_modules exists
+ File nodeModules = new File(frontendDir, "node_modules");
+ if (!nodeModules.exists()) {
+ logger.info("Installing npm dependencies...");
+ ProcessBuilder npmInstall = new ProcessBuilder("npm", "install")
+ .directory(frontendDir)
+ .inheritIO();
+ Process installProcess = npmInstall.start();
+ if (!installProcess.waitFor(5, TimeUnit.MINUTES)) {
+ installProcess.destroyForcibly();
+ return false;
+ }
+ }
+
+ // Build frontend (to dist/)
+ ProcessBuilder npmBuild = new ProcessBuilder("npm", "run", "build")
+ .directory(frontendDir)
+ .inheritIO();
+
+ Process buildProcess = npmBuild.start();
+ boolean completed = buildProcess.waitFor(3, TimeUnit.MINUTES);
+
+ if (!completed) {
+ buildProcess.destroyForcibly();
+ return false;
+ }
+
+ if (buildProcess.exitValue() != 0) {
+ return false;
+ }
+
+ // Copy dist/ to src/main/resources/static/
+ return copyFrontendToStatic(frontendDir);
+
+ } catch (IOException | InterruptedException e) {
+ logger.warning("Failed to build frontend: " + e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Loads E2E test data into the database.
+ * This is called once before all tests run.
+ */
+ private void loadTestData() {
+ logger.info("Loading E2E test data...");
+
+ // Check if test users already exist
+ Integer existingUsers = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM sys_user WHERE email = 'john.doe@test.com'",
+ Integer.class
+ );
+
+ if (existingUsers != null && existingUsers > 0) {
+ logger.info("Test users already exist, checking nodes...");
+ addMissingNodes();
+ return;
+ }
+
+ // Create test users
+ jdbcTemplate.update(
+ "INSERT INTO sys_user (workday_id, email, firstname, lastname, is_active) VALUES (?, ?, ?, ?, ?)",
+ "WD001TEST", "john.doe@test.com", "John", "Doe", true
+ );
+ jdbcTemplate.update(
+ "INSERT INTO sys_user (workday_id, email, firstname, lastname, is_active) VALUES (?, ?, ?, ?, ?)",
+ "WD002TEST", "jane.smith@test.com", "Jane", "Smith", true
+ );
+ jdbcTemplate.update(
+ "INSERT INTO sys_user (workday_id, email, firstname, lastname, is_active) VALUES (?, ?, ?, ?, ?)",
+ "WD003TEST", "admin.test@test.com", "Admin", "User", true
+ );
+
+ // Assign groups to users
+ // John gets 'super' role for full E2E testing capabilities
+ jdbcTemplate.update(
+ "INSERT INTO sys_user_group_mapping (user_id, group_id) " +
+ "SELECT u.id, g.id FROM sys_user u, sys_group g " +
+ "WHERE u.email = 'john.doe@test.com' AND g.group_name = 'super'"
+ );
+ jdbcTemplate.update(
+ "INSERT INTO sys_user_group_mapping (user_id, group_id) " +
+ "SELECT u.id, g.id FROM sys_user u, sys_group g " +
+ "WHERE u.email = 'jane.smith@test.com' AND g.group_name = 'super'"
+ );
+ jdbcTemplate.update(
+ "INSERT INTO sys_user_group_mapping (user_id, group_id) " +
+ "SELECT u.id, g.id FROM sys_user u, sys_group g " +
+ "WHERE u.email = 'admin.test@test.com' AND g.group_name = 'super'"
+ );
+
+ // Add missing nodes for E2E tests
+ addMissingNodes();
+
+ logger.info("E2E test data loaded successfully");
+ }
+
+ /**
+ * Adds missing nodes needed for E2E tests.
+ */
+ private void addMissingNodes() {
+ logger.info("Adding missing nodes for E2E tests...");
+
+ // Add Ireland supplier to node table (if not exists)
+ Integer irelandCount = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM node WHERE name = 'Ireland supplier'", Integer.class);
+ if (irelandCount == null || irelandCount == 0) {
+ Integer ieCountryId = jdbcTemplate.queryForObject(
+ "SELECT id FROM country WHERE iso_code = 'IE'", Integer.class);
+ jdbcTemplate.update(
+ "INSERT INTO node (country_id, name, address, external_mapping_id, predecessor_required, " +
+ "is_destination, is_source, is_intermediate, geo_lat, geo_lng, is_deprecated) " +
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ ieCountryId, "Ireland supplier", "Dublin Ireland", "IE_SUP", false,
+ false, true, false, 53.3494, -6.2606, false
+ );
+ logger.info("Added Ireland supplier to node table");
+ }
+
+ // Get test user ID for sys_user_node entries
+ Integer testUserId = jdbcTemplate.queryForObject(
+ "SELECT id FROM sys_user WHERE email = 'john.doe@test.com'", Integer.class);
+
+ // Add Turkey supplier to sys_user_node (if not exists)
+ Integer turkeyCount = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM sys_user_node WHERE name = 'Turkey supplier'", Integer.class);
+ if (turkeyCount == null || turkeyCount == 0) {
+ Integer trCountryId = jdbcTemplate.queryForObject(
+ "SELECT id FROM country WHERE iso_code = 'TR'", Integer.class);
+ jdbcTemplate.update(
+ "INSERT INTO sys_user_node (user_id, country_id, name, address, geo_lat, geo_lng, is_deprecated) " +
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ testUserId, trCountryId, "Turkey supplier", "Antalya Türkiye",
+ 36.8864, 30.7105, false
+ );
+ logger.info("Added Turkey supplier to sys_user_node table");
+ }
+
+ // Add Yantian supplier to sys_user_node (if not exists)
+ Integer yantianCount = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM sys_user_node WHERE name = 'Yantian supplier'", Integer.class);
+ if (yantianCount == null || yantianCount == 0) {
+ Integer cnCountryId = jdbcTemplate.queryForObject(
+ "SELECT id FROM country WHERE iso_code = 'CN'", Integer.class);
+ jdbcTemplate.update(
+ "INSERT INTO sys_user_node (user_id, country_id, name, address, geo_lat, geo_lng, is_deprecated) " +
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ testUserId, cnCountryId, "Yantian supplier", "Yantian, China",
+ 22.5925, 114.2460, false
+ );
+ logger.info("Added Yantian supplier to sys_user_node table");
+ }
+
+ logger.info("Missing nodes added");
+
+ // Add test materials
+ addTestMaterials();
+ }
+
+ /**
+ * Adds test materials needed for E2E tests.
+ */
+ private void addTestMaterials() {
+ logger.info("Adding test materials...");
+
+ String[] materials = {
+ "3064540201", "003064540201", "84312000", "wheel hub",
+ "4222640104", "004222640104", "84139100", "gearbox housing blank",
+ "4222640803", "004222640803", "84139100", "planet gear carrier blank stage 1",
+ "4222640805", "004222640805", "84139100", "planet gear carrier blank stage 2",
+ "5512640106", "005512640106", "84312000", "transmission housing blank",
+ "8212640113", "008212640113", "84312000", "transmission housing blank GR2E-04",
+ "8212640827", "008212640827", "84312000", "planet gear carrier blank Stufe 1",
+ "8222640822", "008222640822", "84839089", "planet gear carrier blank stage 1",
+ "8263500575", "008263500575", "85015220", "traction motor assy"
+ };
+
+ for (int i = 0; i < materials.length; i += 4) {
+ String partNumber = materials[i];
+ String normalizedPartNumber = materials[i + 1];
+ String hsCode = materials[i + 2];
+ String name = materials[i + 3];
+
+ // Check by normalized_part_number since that has the UNIQUE constraint
+ Integer count = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM material WHERE normalized_part_number = ?",
+ Integer.class, normalizedPartNumber);
+ if (count == null || count == 0) {
+ try {
+ jdbcTemplate.update(
+ "INSERT INTO material (part_number, normalized_part_number, hs_code, name, is_deprecated) " +
+ "VALUES (?, ?, ?, ?, ?)",
+ partNumber, normalizedPartNumber, hsCode, name, false
+ );
+ logger.info(() -> "Added material: " + partNumber + " (normalized: " + normalizedPartNumber + ")");
+ } catch (Exception e) {
+ logger.warning(() -> "Failed to insert material " + partNumber + ": " + e.getMessage());
+ }
+ } else {
+ logger.info(() -> "Material already exists: " + normalizedPartNumber);
+ }
+ }
+
+ logger.info("Test materials added");
+ }
+
+ /**
+ * Copies the built frontend from dist/ to src/main/resources/static/.
+ */
+ private boolean copyFrontendToStatic(File frontendDir) {
+ Path source = frontendDir.toPath().resolve("dist");
+ Path target = Paths.get("src/main/resources/static");
+
+ if (!Files.exists(source)) {
+ logger.warning("Frontend dist directory not found: " + source);
+ return false;
+ }
+
+ try {
+ // Create target directory if needed
+ Files.createDirectories(target);
+
+ // Copy all files recursively
+ try (var walk = Files.walk(source)) {
+ walk.forEach(sourcePath -> {
+ try {
+ Path targetPath = target.resolve(source.relativize(sourcePath));
+ if (Files.isDirectory(sourcePath)) {
+ Files.createDirectories(targetPath);
+ } else {
+ Files.copy(sourcePath, targetPath,
+ java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+ }
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to copy: " + sourcePath, e);
+ }
+ });
+ }
+
+ logger.info("Frontend copied to: " + target);
+ return true;
+
+ } catch (IOException | RuntimeException e) {
+ logger.warning("Failed to copy frontend: " + e.getMessage());
+ return false;
+ }
+ }
+}
diff --git a/src/test/java/de/avatic/lcc/e2e/tests/CalculationWorkflowE2ETest.java b/src/test/java/de/avatic/lcc/e2e/tests/CalculationWorkflowE2ETest.java
new file mode 100644
index 0000000..758f172
--- /dev/null
+++ b/src/test/java/de/avatic/lcc/e2e/tests/CalculationWorkflowE2ETest.java
@@ -0,0 +1,193 @@
+package de.avatic.lcc.e2e.tests;
+
+import com.microsoft.playwright.Locator;
+import com.microsoft.playwright.options.WaitForSelectorState;
+import de.avatic.lcc.e2e.pages.AssistantPage;
+import de.avatic.lcc.e2e.pages.CalculationEditPage;
+import de.avatic.lcc.e2e.pages.ResultsPage;
+import de.avatic.lcc.e2e.testdata.DestinationInput;
+import de.avatic.lcc.e2e.testdata.TestCase;
+import de.avatic.lcc.e2e.testdata.TestCases;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.logging.Logger;
+import java.util.stream.Stream;
+
+/**
+ * End-to-end tests for the calculation workflow.
+ * Tests all scenarios from Testfälle.xlsx using Playwright.
+ *
+ *
The backend with integrated frontend is started automatically via @SpringBootTest.
+ *
+ *
Run with: {@code mvn test -Dtest=CalculationWorkflowE2ETest -Dgroups=e2e -Dspring.profiles.active=test,dev,mysql}
+ */
+@DisplayName("Calculation Workflow E2E Tests")
+class CalculationWorkflowE2ETest extends AbstractE2ETest {
+
+ private static final Logger logger = Logger.getLogger(CalculationWorkflowE2ETest.class.getName());
+
+ // Maximum time to wait for calculation to complete (in milliseconds)
+ private static final int CALCULATION_TIMEOUT_MS = 120000; // 2 minutes
+ private static final int POLL_INTERVAL_MS = 2000; // Check every 2 seconds
+
+ @ParameterizedTest(name = "Testfall {0}: {1}")
+ @MethodSource("provideTestCases")
+ @DisplayName("Calculation workflow")
+ void testCalculationWorkflow(String id, String name, TestCase testCase) {
+ logger.info(() -> "Starting test case: " + id + " - " + name);
+
+ try {
+ // 1. Navigate to assistant and search part numbers
+ AssistantPage assistant = new AssistantPage(page);
+ assistant.navigate(getBaseUrl());
+ assistant.searchPartNumbers(testCase.input().partNumber());
+
+ // 2. Select supplier
+ assistant.deletePreselectedSuppliers();
+ assistant.selectSupplier(testCase.input().supplierName());
+
+ // 3. Create calculation
+ assistant.createCalculation(testCase.input().loadFromPrevious());
+
+ // 4. Fill the calculation form
+ CalculationEditPage calcPage = new CalculationEditPage(page);
+ calcPage.fillForm(testCase.input());
+
+ // 5. Add and fill destinations
+ for (DestinationInput dest : testCase.input().destinations()) {
+ calcPage.addDestination(dest);
+ calcPage.fillDestination(dest);
+ }
+
+ // 6. Take screenshot before clicking Calculate & close
+ takeScreenshot("before_calculate_" + id);
+
+ // 7. Click "Calculate & close" button
+ Locator calcButton = page.locator("xpath=//button[contains(., 'Calculate & close')]");
+ calcButton.waitFor();
+
+ if (!calcButton.isEnabled()) {
+ throw new AssertionError("Calculate & close button is not enabled");
+ }
+
+ logger.info(() -> "Clicking Calculate & close for test case " + id);
+ calcButton.click();
+
+ // 8. Wait for navigation to calculations list
+ page.waitForURL("**/calculations**", new com.microsoft.playwright.Page.WaitForURLOptions().setTimeout(10000));
+ logger.info(() -> "Navigated to calculations page");
+
+ // 9. Wait for calculation to complete
+ boolean completed = waitForCalculationComplete(testCase.input().partNumber());
+ if (!completed) {
+ takeScreenshot("calculation_timeout_" + id);
+ throw new AssertionError("Calculation did not complete within timeout");
+ }
+
+ takeScreenshot("calculation_completed_" + id);
+ logger.info(() -> "Test case " + id + " - calculation completed!");
+
+ // 10. Navigate to Reports and verify results
+ ResultsPage resultsPage = new ResultsPage(page);
+ resultsPage.navigateToReports(getBaseUrl(), testCase.input().partNumber(), testCase.input().supplierName());
+
+ takeScreenshot("report_" + id);
+
+ // 11. Verify results match expected values
+ resultsPage.verifyResults(testCase.expected(), TOLERANCE);
+
+ logger.info(() -> "Test case " + id + " - all results verified successfully!");
+
+ } catch (Exception e) {
+ // Take screenshot on failure
+ takeScreenshot("failure_" + id);
+ logger.severe(() -> "Test case " + id + " failed: " + e.getMessage());
+ throw e;
+ }
+ }
+
+ /**
+ * Waits for a calculation to complete by polling the calculations list.
+ * Looks for a COMPLETED badge for the given part number.
+ *
+ * @param partNumber the part number to look for
+ * @return true if calculation completed, false if timeout
+ */
+ private boolean waitForCalculationComplete(String partNumber) {
+ logger.info("Waiting for calculation to complete for: " + partNumber);
+
+ long startTime = System.currentTimeMillis();
+ int attempts = 0;
+
+ while (System.currentTimeMillis() - startTime < CALCULATION_TIMEOUT_MS) {
+ attempts++;
+ final int attemptNum = attempts;
+
+ // Wait a bit for dashboard to update (it pulls every few seconds)
+ page.waitForTimeout(POLL_INTERVAL_MS);
+
+ // Check the "Running" counter in the dashboard
+ // Structure: .dashboard-box contains .dashboard-box-number (the count) and .dashboard-box-number-text (the label)
+ Locator runningBox = page.locator(".dashboard-box:has(.dashboard-box-number-text:text-is('Running'))");
+
+ if (runningBox.count() > 0) {
+ Locator runningCount = runningBox.locator(".dashboard-box-number");
+ if (runningCount.count() > 0) {
+ String runningText = runningCount.textContent().trim();
+ logger.info("Attempt " + attemptNum + ": Running calculations = " + runningText);
+
+ try {
+ int running = Integer.parseInt(runningText);
+ if (running == 0) {
+ // No more running calculations - check if ours completed or failed
+ logger.info("No running calculations. Checking final status...");
+
+ // Check the Failed counter
+ Locator failedBox = page.locator(".dashboard-box:has(.dashboard-box-number-text:text-is('Failed'))");
+ if (failedBox.count() > 0) {
+ Locator failedCount = failedBox.locator(".dashboard-box-number");
+ String failedText = failedCount.textContent().trim();
+ int failed = Integer.parseInt(failedText);
+ if (failed > 0) {
+ logger.severe("Calculation failed! Failed count: " + failed);
+ takeScreenshot("calculation_failed");
+ return false;
+ }
+ }
+
+ // Check the Completed counter increased
+ Locator completedBox = page.locator(".dashboard-box:has(.dashboard-box-number-text:text-is('Completed'))");
+ if (completedBox.count() > 0) {
+ Locator completedCount = completedBox.locator(".dashboard-box-number");
+ String completedText = completedCount.textContent().trim();
+ logger.info("Completed calculations: " + completedText);
+ }
+
+ logger.info("Calculation completed after " + attemptNum + " attempts");
+ return true;
+ }
+ } catch (NumberFormatException e) {
+ logger.warning("Could not parse running count: " + runningText);
+ }
+ }
+ } else {
+ // Dashboard not found, try refreshing
+ logger.info("Attempt " + attemptNum + ": Dashboard not found, refreshing...");
+ page.reload();
+ page.waitForLoadState();
+ }
+ }
+
+ logger.warning("Calculation did not complete within " + CALCULATION_TIMEOUT_MS + "ms");
+ takeScreenshot("calculation_timeout");
+ return false;
+ }
+
+ static Stream provideTestCases() {
+ return TestCases.ALL.stream()
+ .map(tc -> Arguments.of(tc.id(), tc.name(), tc));
+ }
+}
diff --git a/src/test/java/de/avatic/lcc/e2e/tests/DeviationAnalysisE2ETest.java b/src/test/java/de/avatic/lcc/e2e/tests/DeviationAnalysisE2ETest.java
new file mode 100644
index 0000000..f2bc4bc
--- /dev/null
+++ b/src/test/java/de/avatic/lcc/e2e/tests/DeviationAnalysisE2ETest.java
@@ -0,0 +1,183 @@
+package de.avatic.lcc.e2e.tests;
+
+import com.microsoft.playwright.Locator;
+import de.avatic.lcc.e2e.pages.AssistantPage;
+import de.avatic.lcc.e2e.pages.CalculationEditPage;
+import de.avatic.lcc.e2e.pages.ResultsPage;
+import de.avatic.lcc.e2e.testdata.DestinationInput;
+import de.avatic.lcc.e2e.testdata.TestCase;
+import de.avatic.lcc.e2e.testdata.TestCases;
+import de.avatic.lcc.e2e.util.DeviationReport;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.logging.Logger;
+
+/**
+ * Runs all test cases and generates a deviation report comparing expected vs actual values.
+ * This test does not fail on deviations - it collects them all and prints a summary.
+ */
+@DisplayName("Deviation Analysis E2E Test")
+@Tag("analysis")
+class DeviationAnalysisE2ETest extends AbstractE2ETest {
+
+ private static final Logger logger = Logger.getLogger(DeviationAnalysisE2ETest.class.getName());
+ private static final int CALCULATION_TIMEOUT_MS = 120000;
+ private static final int POLL_INTERVAL_MS = 2000;
+
+ @Test
+ @DisplayName("Analyze deviations across all test cases")
+ void analyzeDeviations() {
+ DeviationReport report = new DeviationReport();
+
+ for (TestCase testCase : TestCases.ALL) {
+ String id = testCase.id();
+ String name = testCase.name();
+
+ logger.info(() -> "\n========================================");
+ logger.info(() -> "Processing test case: " + id + " - " + name);
+ logger.info(() -> "========================================\n");
+
+ try {
+ // Run the calculation workflow
+ Map actualResults = runCalculationAndGetResults(testCase);
+
+ // Add to deviation report
+ report.addTestCase(id, name, testCase.expected(), actualResults);
+
+ logger.info(() -> "Test case " + id + " completed successfully");
+
+ } catch (Exception e) {
+ logger.severe(() -> "Test case " + id + " failed with error: " + e.getMessage());
+ report.addError(id, name, e.getMessage());
+ takeScreenshot("error_" + id);
+ }
+ }
+
+ // Print the deviation report
+ String reportContent = report.generateMarkdownTable();
+ System.out.println(reportContent);
+ logger.info(reportContent);
+
+ // Write report to file
+ try {
+ Path reportPath = Path.of("target/deviation-report.md");
+ Files.writeString(reportPath, reportContent);
+ logger.info("Deviation report written to: " + reportPath.toAbsolutePath());
+ } catch (IOException e) {
+ logger.warning("Could not write deviation report file: " + e.getMessage());
+ }
+ }
+
+ private Map runCalculationAndGetResults(TestCase testCase) {
+ // 1. Navigate to assistant and search part numbers
+ AssistantPage assistant = new AssistantPage(page);
+ assistant.navigate(getBaseUrl());
+ assistant.searchPartNumbers(testCase.input().partNumber());
+
+ // 2. Select supplier
+ assistant.deletePreselectedSuppliers();
+ assistant.selectSupplier(testCase.input().supplierName());
+
+ // 3. Create calculation
+ assistant.createCalculation(testCase.input().loadFromPrevious());
+
+ // 4. Fill the calculation form
+ CalculationEditPage calcPage = new CalculationEditPage(page);
+
+ // Enable screenshots for debugging
+ calcPage.enableScreenshots("case_" + testCase.id());
+
+ calcPage.fillForm(testCase.input());
+
+ // 5. Add and fill destinations (screenshots taken automatically for each)
+ for (DestinationInput dest : testCase.input().destinations()) {
+ calcPage.addDestination(dest);
+ calcPage.fillDestination(dest);
+ }
+
+ // 6. Take screenshot before clicking Calculate
+ calcPage.screenshotBeforeCalculate();
+
+ // 7. Click "Calculate & close" button
+ Locator calcButton = page.locator("xpath=//button[contains(., 'Calculate & close')]");
+ calcButton.waitFor();
+
+ if (!calcButton.isEnabled()) {
+ throw new AssertionError("Calculate & close button is not enabled for test case " + testCase.id());
+ }
+
+ calcButton.click();
+
+ // 8. Wait for navigation to calculations list
+ page.waitForURL("**/calculations**", new com.microsoft.playwright.Page.WaitForURLOptions().setTimeout(10000));
+
+ // 9. Wait for calculation to complete
+ boolean completed = waitForCalculationComplete(testCase.input().partNumber());
+ if (!completed) {
+ throw new AssertionError("Calculation did not complete within timeout for test case " + testCase.id());
+ }
+
+ // 10. Navigate to Reports and read results
+ ResultsPage resultsPage = new ResultsPage(page);
+ resultsPage.navigateToReports(getBaseUrl(), testCase.input().partNumber(), testCase.input().supplierName());
+
+ // 11. Take full page screenshot with all collapsible boxes expanded
+ resultsPage.takeFullPageScreenshot("report_" + testCase.id());
+
+ // 12. Read and return results (without verification)
+ return resultsPage.readResults();
+ }
+
+ private boolean waitForCalculationComplete(String partNumber) {
+ logger.info("Waiting for calculation to complete for: " + partNumber);
+
+ long startTime = System.currentTimeMillis();
+ int attempts = 0;
+
+ while (System.currentTimeMillis() - startTime < CALCULATION_TIMEOUT_MS) {
+ attempts++;
+ final int attemptNum = attempts;
+
+ page.waitForTimeout(POLL_INTERVAL_MS);
+
+ Locator runningBox = page.locator(".dashboard-box:has(.dashboard-box-number-text:text-is('Running'))");
+
+ if (runningBox.count() > 0) {
+ Locator runningCount = runningBox.locator(".dashboard-box-number");
+ if (runningCount.count() > 0) {
+ String runningText = runningCount.textContent().trim();
+
+ try {
+ int running = Integer.parseInt(runningText);
+ if (running == 0) {
+ Locator failedBox = page.locator(".dashboard-box:has(.dashboard-box-number-text:text-is('Failed'))");
+ if (failedBox.count() > 0) {
+ Locator failedCount = failedBox.locator(".dashboard-box-number");
+ String failedText = failedCount.textContent().trim();
+ int failed = Integer.parseInt(failedText);
+ if (failed > 0) {
+ logger.severe("Calculation failed! Failed count: " + failed);
+ return false;
+ }
+ }
+ return true;
+ }
+ } catch (NumberFormatException e) {
+ logger.warning("Could not parse running count: " + runningText);
+ }
+ }
+ } else {
+ page.reload();
+ page.waitForLoadState();
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/test/java/de/avatic/lcc/e2e/tests/SmokeE2ETest.java b/src/test/java/de/avatic/lcc/e2e/tests/SmokeE2ETest.java
new file mode 100644
index 0000000..15a02a0
--- /dev/null
+++ b/src/test/java/de/avatic/lcc/e2e/tests/SmokeE2ETest.java
@@ -0,0 +1,110 @@
+package de.avatic.lcc.e2e.tests;
+
+import de.avatic.lcc.e2e.pages.AssistantPage;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import java.util.logging.Logger;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Smoke tests to verify basic application functionality.
+ * These tests run quickly and verify that the application is accessible.
+ *
+ * The backend with integrated frontend is started automatically via @SpringBootTest.
+ *
+ *
Run with: {@code mvn test -Dtest=SmokeE2ETest -Dspring.profiles.active=test,mysql}
+ */
+@Tag("smoke")
+@DisplayName("Smoke E2E Tests")
+class SmokeE2ETest extends AbstractE2ETest {
+
+ private static final Logger logger = Logger.getLogger(SmokeE2ETest.class.getName());
+
+ @Test
+ @DisplayName("Application is accessible")
+ void testApplicationIsAccessible() {
+ page.navigate(getBaseUrl());
+ String title = page.title();
+
+ logger.info(() -> "Page title: " + title);
+ assertTrue(title != null && !title.isEmpty(), "Page should have a title");
+ }
+
+ @Test
+ @DisplayName("Login was successful")
+ void testLoginWasSuccessful() {
+ // Login happens in @BeforeEach via AbstractE2ETest
+ // After login, we navigate away from dev page (done in AbstractE2ETest)
+ String currentUrl = page.url();
+ assertTrue(!currentUrl.contains("/dev"), "Should not be on dev page after login");
+ }
+
+ @Test
+ @DisplayName("Navigate to assistant page")
+ void testNavigateToAssistant() {
+ // Navigate to assistant
+ AssistantPage assistant = new AssistantPage(page);
+ assistant.navigate(getBaseUrl());
+
+ String currentUrl = page.url();
+ assertTrue(currentUrl.contains("/assistant"), "Should be on assistant page");
+ }
+
+ @Test
+ @DisplayName("Part number search is functional")
+ void testPartNumberSearchFunctional() {
+ // Navigate to assistant
+ AssistantPage assistant = new AssistantPage(page);
+ assistant.navigate(getBaseUrl());
+
+ // Take screenshot to debug
+ takeScreenshot("assistant_page");
+
+ // Verify the part number modal is shown with textarea
+ boolean textAreaVisible = page.locator("textarea").isVisible();
+ logger.info(() -> "Text area visible: " + textAreaVisible);
+
+ // Verify analyze button is present (text: "Analyze input")
+ boolean analyzeButtonVisible = page.getByText("Analyze input").isVisible();
+ logger.info(() -> "Analyze button visible: " + analyzeButtonVisible);
+
+ assertTrue(textAreaVisible, "Text area for part numbers should be visible");
+ assertTrue(analyzeButtonVisible, "Analyze input button should be visible");
+ }
+
+ @Test
+ @DisplayName("Test materials exist in database")
+ void testMaterialsExistInDatabase() {
+ // Check if our test materials are in the database
+ Integer count = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM material WHERE normalized_part_number IN ('004222640104', '003064540201')",
+ Integer.class
+ );
+ logger.info(() -> "Found " + count + " test materials in database");
+
+ // List all materials for debugging
+ var materials = jdbcTemplate.queryForList(
+ "SELECT part_number, normalized_part_number, name FROM material LIMIT 20"
+ );
+ logger.info(() -> "Materials in DB: " + materials);
+
+ // Test the exact SQL that the API uses
+ var searchResult = jdbcTemplate.queryForList(
+ "SELECT * FROM material WHERE part_number IN (?) OR normalized_part_number IN (?)",
+ "003064540201", "003064540201"
+ );
+ logger.info(() -> "Search result for '003064540201': " + searchResult);
+
+ // Also test with the original part number
+ var searchResult2 = jdbcTemplate.queryForList(
+ "SELECT * FROM material WHERE part_number IN (?) OR normalized_part_number IN (?)",
+ "3064540201", "3064540201"
+ );
+ logger.info(() -> "Search result for '3064540201': " + searchResult2);
+
+ assertTrue(count != null && count >= 2, "At least 2 test materials should exist. Found: " + count);
+ }
+}
diff --git a/src/test/java/de/avatic/lcc/e2e/util/DeviationReport.java b/src/test/java/de/avatic/lcc/e2e/util/DeviationReport.java
new file mode 100644
index 0000000..ec4c123
--- /dev/null
+++ b/src/test/java/de/avatic/lcc/e2e/util/DeviationReport.java
@@ -0,0 +1,182 @@
+package de.avatic.lcc.e2e.util;
+
+import de.avatic.lcc.e2e.testdata.TestCaseExpected;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Collects and reports deviations between expected and actual values.
+ */
+public class DeviationReport {
+
+ private final List deviations = new ArrayList<>();
+
+ public void addTestCase(String testCaseId, String testCaseName, TestCaseExpected expected, Map actual) {
+ TestCaseDeviation deviation = new TestCaseDeviation(testCaseId, testCaseName);
+
+ deviation.addField("MEK_A", expected.mekA(), (Double) actual.get("mekA"));
+ deviation.addField("LOGISTIC_COST", expected.logisticCost(), (Double) actual.get("logisticCost"));
+ deviation.addField("MEK_B", expected.mekB(), (Double) actual.get("mekB"));
+ deviation.addField("FCA_FEE", expected.fcaFee(), (Double) actual.get("fcaFee"));
+ deviation.addField("TRANSPORTATION", expected.transportation(), (Double) actual.get("transportation"));
+ deviation.addField("D2D", expected.d2d(), (Double) actual.get("d2d"));
+ deviation.addField("AIR_FREIGHT", expected.airFreight(), (Double) actual.get("airFreight"));
+ deviation.addField("CUSTOM", expected.custom(), (Double) actual.get("custom"));
+ deviation.addField("REPACKAGING", expected.repackaging(), (Double) actual.get("repackaging"));
+ deviation.addField("HANDLING", expected.handling(), (Double) actual.get("handling"));
+ deviation.addField("DISPOSAL", expected.disposal(), (Double) actual.get("disposal"));
+ deviation.addField("SPACE", expected.space(), (Double) actual.get("space"));
+ deviation.addField("CAPITAL", expected.capital(), (Double) actual.get("capital"));
+
+ deviations.add(deviation);
+ }
+
+ public void addError(String testCaseId, String testCaseName, String errorMessage) {
+ TestCaseDeviation deviation = new TestCaseDeviation(testCaseId, testCaseName);
+ deviation.setError(errorMessage);
+ deviations.add(deviation);
+ }
+
+ public String generateMarkdownTable() {
+ StringBuilder sb = new StringBuilder();
+
+ sb.append("\n\n");
+ sb.append("# DEVIATION REPORT\n");
+ sb.append("================================================================================\n\n");
+
+ // Summary table per test case
+ sb.append("## Summary by Test Case\n\n");
+ sb.append("| Test | Name | Status | Max Deviation |\n");
+ sb.append("|------|------|--------|---------------|\n");
+
+ for (TestCaseDeviation dev : deviations) {
+ if (dev.hasError()) {
+ sb.append(String.format("| %s | %s | ERROR | %s |\n",
+ dev.testCaseId, truncate(dev.testCaseName, 30), dev.errorMessage));
+ } else {
+ double maxDev = dev.getMaxDeviation();
+ String status = maxDev > 5.0 ? "⚠️ HIGH" : (maxDev > 1.0 ? "⚡ MEDIUM" : "✓ OK");
+ sb.append(String.format("| %s | %s | %s | %.2f%% |\n",
+ dev.testCaseId, truncate(dev.testCaseName, 30), status, maxDev));
+ }
+ }
+
+ // Detailed deviations per field
+ sb.append("\n\n## Detailed Field Deviations\n\n");
+ sb.append("| Test | Field | Expected | Actual | Deviation |\n");
+ sb.append("|------|-------|----------|--------|----------|\n");
+
+ for (TestCaseDeviation dev : deviations) {
+ if (dev.hasError()) {
+ sb.append(String.format("| %s | ERROR | - | - | %s |\n", dev.testCaseId, dev.errorMessage));
+ } else {
+ for (FieldDeviation field : dev.fields) {
+ if (field.deviationPercent > 1.0 || field.actual == null) {
+ sb.append(String.format("| %s | %s | %.4f | %s | %.2f%% |\n",
+ dev.testCaseId,
+ field.fieldName,
+ field.expected,
+ field.actual != null ? String.format("%.4f", field.actual) : "null",
+ field.deviationPercent));
+ }
+ }
+ }
+ }
+
+ // Field summary - which fields have issues across all tests
+ sb.append("\n\n## Field Summary (Average Deviation Across All Tests)\n\n");
+ sb.append("| Field | Avg Deviation | Max Deviation | Tests with >1% |\n");
+ sb.append("|-------|---------------|---------------|----------------|\n");
+
+ String[] fieldNames = {"MEK_A", "LOGISTIC_COST", "MEK_B", "FCA_FEE", "TRANSPORTATION",
+ "D2D", "AIR_FREIGHT", "CUSTOM", "REPACKAGING", "HANDLING", "DISPOSAL", "SPACE", "CAPITAL"};
+
+ for (String fieldName : fieldNames) {
+ double sumDev = 0;
+ double maxDev = 0;
+ int countHigh = 0;
+ int count = 0;
+
+ for (TestCaseDeviation dev : deviations) {
+ if (!dev.hasError()) {
+ for (FieldDeviation field : dev.fields) {
+ if (field.fieldName.equals(fieldName)) {
+ sumDev += field.deviationPercent;
+ maxDev = Math.max(maxDev, field.deviationPercent);
+ if (field.deviationPercent > 1.0) countHigh++;
+ count++;
+ }
+ }
+ }
+ }
+
+ if (count > 0) {
+ double avgDev = sumDev / count;
+ sb.append(String.format("| %s | %.2f%% | %.2f%% | %d/%d |\n",
+ fieldName, avgDev, maxDev, countHigh, count));
+ }
+ }
+
+ sb.append("\n================================================================================\n");
+
+ return sb.toString();
+ }
+
+ private String truncate(String s, int maxLen) {
+ return s.length() > maxLen ? s.substring(0, maxLen - 3) + "..." : s;
+ }
+
+ public static class TestCaseDeviation {
+ String testCaseId;
+ String testCaseName;
+ List fields = new ArrayList<>();
+ String errorMessage;
+
+ public TestCaseDeviation(String testCaseId, String testCaseName) {
+ this.testCaseId = testCaseId;
+ this.testCaseName = testCaseName;
+ }
+
+ public void addField(String fieldName, double expected, Double actual) {
+ fields.add(new FieldDeviation(fieldName, expected, actual));
+ }
+
+ public void setError(String errorMessage) {
+ this.errorMessage = errorMessage;
+ }
+
+ public boolean hasError() {
+ return errorMessage != null;
+ }
+
+ public double getMaxDeviation() {
+ return fields.stream()
+ .mapToDouble(f -> f.deviationPercent)
+ .max()
+ .orElse(0.0);
+ }
+ }
+
+ public static class FieldDeviation {
+ String fieldName;
+ double expected;
+ Double actual;
+ double deviationPercent;
+
+ public FieldDeviation(String fieldName, double expected, Double actual) {
+ this.fieldName = fieldName;
+ this.expected = expected;
+ this.actual = actual;
+
+ if (actual == null) {
+ // Null actual - if expected is ~0, no deviation; otherwise 100%
+ this.deviationPercent = Math.abs(expected) < 0.001 ? 0.0 : 100.0;
+ } else {
+ double diff = Math.abs(expected - actual);
+ this.deviationPercent = expected != 0 ? (diff / Math.abs(expected)) * 100 : diff * 100;
+ }
+ }
+ }
+}
diff --git a/src/test/java/de/avatic/lcc/e2e/util/ResultComparator.java b/src/test/java/de/avatic/lcc/e2e/util/ResultComparator.java
new file mode 100644
index 0000000..b4f2c8a
--- /dev/null
+++ b/src/test/java/de/avatic/lcc/e2e/util/ResultComparator.java
@@ -0,0 +1,180 @@
+package de.avatic.lcc.e2e.util;
+
+import de.avatic.lcc.e2e.testdata.DestinationExpected;
+import de.avatic.lcc.e2e.testdata.TestCaseExpected;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Logger;
+
+/**
+ * Utility class for comparing actual results with expected values.
+ * Supports tolerance-based comparison for numeric values.
+ */
+public final class ResultComparator {
+
+ private static final Logger logger = Logger.getLogger(ResultComparator.class.getName());
+
+ private ResultComparator() {
+ // Utility class
+ }
+
+ /**
+ * Asserts that actual results match expected values within the given tolerance.
+ *
+ * @param actualResults Map of actual result values from the UI
+ * @param expected Expected values from test case definition
+ * @param tolerance Relative tolerance for numeric comparisons (0.01 = 1%)
+ * @throws AssertionError if any values don't match within tolerance
+ */
+ public static void assertResultsMatch(Map actualResults,
+ TestCaseExpected expected,
+ double tolerance) {
+ List failures = new ArrayList<>();
+
+ // Compare main result fields
+ compareNumeric(failures, "MEK_A", expected.mekA(), getDouble(actualResults, "mekA"), tolerance);
+ compareNumeric(failures, "LOGISTIC_COST", expected.logisticCost(), getDouble(actualResults, "logisticCost"), tolerance);
+ compareNumeric(failures, "MEK_B", expected.mekB(), getDouble(actualResults, "mekB"), tolerance);
+ compareNumeric(failures, "FCA_FEE", expected.fcaFee(), getDouble(actualResults, "fcaFee"), tolerance);
+ compareNumeric(failures, "TRANSPORTATION", expected.transportation(), getDouble(actualResults, "transportation"), tolerance);
+ compareNumeric(failures, "D2D", expected.d2d(), getDouble(actualResults, "d2d"), tolerance);
+ compareNumeric(failures, "AIR_FREIGHT", expected.airFreight(), getDouble(actualResults, "airFreight"), tolerance);
+ compareNumeric(failures, "CUSTOM", expected.custom(), getDouble(actualResults, "custom"), tolerance);
+ compareNumeric(failures, "REPACKAGING", expected.repackaging(), getDouble(actualResults, "repackaging"), tolerance);
+ compareNumeric(failures, "HANDLING", expected.handling(), getDouble(actualResults, "handling"), tolerance);
+ compareNumeric(failures, "DISPOSAL", expected.disposal(), getDouble(actualResults, "disposal"), tolerance);
+ compareNumeric(failures, "SPACE", expected.space(), getDouble(actualResults, "space"), tolerance);
+ compareNumeric(failures, "CAPITAL", expected.capital(), getDouble(actualResults, "capital"), tolerance);
+ compareNumeric(failures, "SAFETY_STOCK", (double) expected.safetyStock(), getDouble(actualResults, "safetyStock"), tolerance);
+
+ // Compare destination results
+ @SuppressWarnings("unchecked")
+ List