wip: intermediate commit. testscripts running, but with high deviations.

This commit is contained in:
Jan 2026-02-05 13:04:17 +01:00
parent b708af177f
commit 26986b1131
38 changed files with 4800 additions and 664 deletions

View file

@ -0,0 +1,21 @@
{
"permissions": {
"allow": [
"Bash(tree:*)",
"Bash(xargs:*)",
"Bash(mvn compile:*)",
"Bash(mvn test-compile:*)",
"Bash(find:*)",
"Bash(mvn test:*)",
"Bash(tee:*)",
"Bash(export TESTCONTAINERS_RYUK_DISABLED=true)",
"Bash(echo:*)",
"Bash(pgrep:*)",
"Bash(pkill:*)",
"Bash(ls:*)",
"Bash(sleep 120 echo \"=== Screenshots generated so far ===\" ls -la target/screenshots/case_*.png)",
"Bash(wc:*)",
"Bash(export DOCKER_HOST=unix:///run/user/1000/podman/podman.sock)"
]
}
}

View file

@ -43,6 +43,55 @@ mvn clean install -DskipTests
mvn jaxb:generate mvn jaxb:generate
``` ```
## Development Environment (Distrobox)
**IMPORTANT:** This project runs inside a **Distrobox** container. This affects how TestContainers and Podman work.
### TestContainers with Distrobox + Podman
TestContainers needs access to the **host's Podman socket**, not the one inside the Distrobox. The configuration is handled via `~/.testcontainers.properties`:
```properties
docker.host=unix:///run/host/run/user/1000/podman/podman.sock
ryuk.disabled=true
```
### Troubleshooting TestContainers / Podman Issues
If tests fail with "Could not find a valid Docker environment":
1. **Check if Podman works on the host:**
```bash
distrobox-host-exec podman info
```
2. **If you see cgroup or UID/GID errors, run migration on the host:**
```bash
distrobox-host-exec podman system migrate
```
3. **Restart podman socket on host if needed:**
```bash
distrobox-host-exec systemctl --user restart podman.socket
```
4. **Verify the host socket is accessible from Distrobox:**
```bash
ls -la /run/host/run/user/1000/podman/podman.sock
```
5. **Test container execution via host:**
```bash
distrobox-host-exec podman run --rm hello-world
```
### Key Paths
| Path | Description |
|------|-------------|
| `/run/host/run/user/1000/podman/podman.sock` | Host's Podman socket (accessible from Distrobox) |
| `~/.testcontainers.properties` | TestContainers configuration file |
## Architecture ## Architecture
### Layered Architecture ### Layered Architecture

0
mvnw vendored Normal file → Executable file
View file

View file

@ -236,6 +236,14 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- Playwright for E2E testing -->
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.48.0</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>

View file

@ -81,9 +81,15 @@ public class PremiseController {
@GetMapping({"/search", "/search/"}) @GetMapping({"/search", "/search/"})
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')") @PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')")
public ResponseEntity<PremiseSearchResultDTO> findMaterialsAndSuppliers(@RequestParam String search) { public ResponseEntity<PremiseSearchResultDTO> findMaterialsAndSuppliers(@RequestParam String search) {
log.info("Search request received with query: '{}' (length: {})", search, search != null ? search.length() : 0);
try { try {
return ResponseEntity.ok(premiseSearchStringAnalyzerService.findMaterialAndSuppliers(search)); var result = premiseSearchStringAnalyzerService.findMaterialAndSuppliers(search);
log.info("Search result: {} materials, {} suppliers, {} user suppliers",
result.getMaterials() != null ? result.getMaterials().size() : 0,
result.getSupplier() != null ? result.getSupplier().size() : 0,
result.getUserSupplier() != null ? result.getUserSupplier().size() : 0);
return ResponseEntity.ok(result);
} catch (Exception e) { } catch (Exception e) {
throw new BadRequestException("Bad string encoding", "Unable to decode request", e); throw new BadRequestException("Bad string encoding", "Unable to decode request", e);
} }

View file

@ -0,0 +1,150 @@
package de.avatic.lcc.e2e.config;
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 java.nio.file.Path;
import java.nio.file.Paths;
import java.util.logging.Logger;
/**
* Configuration and factory class for Playwright browser instances.
* Provides centralized configuration for E2E tests.
*/
public class PlaywrightTestConfiguration {
private static final Logger logger = Logger.getLogger(PlaywrightTestConfiguration.class.getName());
// Default configuration values
public static final String DEFAULT_BASE_URL = "http://localhost:5173";
public static final boolean DEFAULT_HEADLESS = true;
public static final int DEFAULT_VIEWPORT_WIDTH = 1920;
public static final int DEFAULT_VIEWPORT_HEIGHT = 1080;
public static final double DEFAULT_TOLERANCE = 0.01; // 1%
public static final Path SCREENSHOTS_DIR = Paths.get("target/screenshots");
public static final Path TRACES_DIR = Paths.get("target/traces");
private Playwright playwright;
private Browser browser;
private final boolean headless;
private final String baseUrl;
private final int viewportWidth;
private final int viewportHeight;
public PlaywrightTestConfiguration() {
this(
System.getProperty("e2e.baseUrl", DEFAULT_BASE_URL),
Boolean.parseBoolean(System.getProperty("playwright.headless", String.valueOf(DEFAULT_HEADLESS))),
Integer.parseInt(System.getProperty("playwright.viewport.width", String.valueOf(DEFAULT_VIEWPORT_WIDTH))),
Integer.parseInt(System.getProperty("playwright.viewport.height", String.valueOf(DEFAULT_VIEWPORT_HEIGHT)))
);
}
public PlaywrightTestConfiguration(String baseUrl, boolean headless, int viewportWidth, int viewportHeight) {
this.baseUrl = baseUrl;
this.headless = headless;
this.viewportWidth = viewportWidth;
this.viewportHeight = viewportHeight;
}
/**
* Initializes Playwright and launches the browser.
* Must be called before creating pages.
*/
public void initialize() {
logger.info("Initializing Playwright");
playwright = Playwright.create();
browser = playwright.chromium().launch(
new BrowserType.LaunchOptions()
.setHeadless(headless)
.setSlowMo(headless ? 0 : 100)
);
logger.info(() -> String.format(
"Playwright initialized. Headless: %s, Base URL: %s, Viewport: %dx%d",
headless, baseUrl, viewportWidth, viewportHeight
));
}
/**
* Creates a new browser context with default settings.
*/
public BrowserContext createContext() {
return browser.newContext(new Browser.NewContextOptions()
.setViewportSize(viewportWidth, viewportHeight)
);
}
/**
* Creates a new browser context with tracing enabled.
*/
public BrowserContext createContextWithTracing(String traceName) {
BrowserContext context = createContext();
context.tracing().start(new com.microsoft.playwright.Tracing.StartOptions()
.setScreenshots(true)
.setSnapshots(true)
.setSources(true)
);
return context;
}
/**
* Stops tracing and saves it to a file.
*/
public void stopTracing(BrowserContext context, String traceName) {
context.tracing().stop(new com.microsoft.playwright.Tracing.StopOptions()
.setPath(TRACES_DIR.resolve(traceName + ".zip"))
);
}
/**
* Creates a new page in a new context.
*/
public Page createPage() {
BrowserContext context = createContext();
return context.newPage();
}
/**
* Closes the browser and Playwright instance.
*/
public void close() {
if (browser != null) {
browser.close();
}
if (playwright != null) {
playwright.close();
}
logger.info("Playwright closed");
}
// Getters
public String getBaseUrl() {
return baseUrl;
}
public boolean isHeadless() {
return headless;
}
public int getViewportWidth() {
return viewportWidth;
}
public int getViewportHeight() {
return viewportHeight;
}
public Browser getBrowser() {
return browser;
}
public Playwright getPlaywright() {
return playwright;
}
}

View file

@ -0,0 +1,123 @@
package de.avatic.lcc.e2e.config;
import de.avatic.lcc.config.LccOidcUser;
import de.avatic.lcc.config.filter.DevUserEmulationFilter;
import de.avatic.lcc.model.db.users.User;
import de.avatic.lcc.repositories.users.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.Instant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* Filter that auto-logins a test user when running E2E tests.
* This bypasses the need to manually select a user on the /dev page.
*/
public class TestAutoLoginFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(TestAutoLoginFilter.class);
private static final String TEST_USER_EMAIL = "john.doe@test.com";
private static final String DEV_USER_ID_SESSION_KEY = "dev.emulated.user.id";
private final UserRepository userRepository;
public TestAutoLoginFilter(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(@NotNull HttpServletRequest request,
@NotNull HttpServletResponse response,
@NotNull FilterChain filterChain) throws ServletException, IOException {
HttpSession session = request.getSession(true);
Integer emulatedUserId = (Integer) session.getAttribute(DEV_USER_ID_SESSION_KEY);
// If no user is selected, auto-login the test user
if (emulatedUserId == null) {
try {
User testUser = userRepository.getByEmail(TEST_USER_EMAIL);
if (testUser != null) {
log.debug("TestAutoLoginFilter - Auto-logging in test user: {}", TEST_USER_EMAIL);
session.setAttribute(DEV_USER_ID_SESSION_KEY, testUser.getId());
setEmulatedUser(testUser);
} else {
log.warn("TestAutoLoginFilter - Test user {} not found", TEST_USER_EMAIL);
}
} catch (Exception e) {
log.debug("TestAutoLoginFilter - Could not auto-login: {}", e.getMessage());
}
} else {
// User is already selected, set authentication
User user = userRepository.getById(emulatedUserId);
if (user != null) {
setEmulatedUser(user);
}
}
filterChain.doFilter(request, response);
}
private void setEmulatedUser(User user) {
Set<GrantedAuthority> authorities = new HashSet<>();
user.getGroups().forEach(group ->
authorities.add(new SimpleGrantedAuthority("ROLE_" + group.getName().toUpperCase()))
);
// Create a mock OIDC user
Map<String, Object> claims = new HashMap<>();
claims.put("sub", user.getId().toString());
claims.put("email", user.getEmail());
claims.put("preferred_username", user.getEmail());
claims.put("name", user.getFirstName() + " " + user.getLastName());
if (user.getWorkdayId() != null) {
claims.put("workday_id", user.getWorkdayId());
}
OidcIdToken idToken = new OidcIdToken(
"mock-token",
Instant.now(),
Instant.now().plusSeconds(3600),
claims
);
OidcUserInfo userInfo = new OidcUserInfo(claims);
LccOidcUser oidcUser = new LccOidcUser(
authorities,
idToken,
userInfo,
"preferred_username",
user.getId()
);
var authentication = new PreAuthenticatedAuthenticationToken(
oidcUser,
null,
authorities
);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
}

View file

@ -0,0 +1,44 @@
package de.avatic.lcc.e2e.config;
import org.jetbrains.annotations.NotNull;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
import java.io.IOException;
/**
* Frontend configuration for E2E tests.
* Serves index.html for Vue Router to handle SPA routes.
*/
@Configuration
@Profile("test")
public class TestFrontendConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Handle all requests by serving index.html for non-existent resources
// This allows Vue Router to handle SPA routes like /dev
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/")
.resourceChain(true)
.addResolver(new PathResourceResolver() {
@Override
protected Resource getResource(@NotNull String resourcePath, @NotNull Resource location) throws IOException {
Resource requestedResource = location.createRelative(resourcePath);
// If the resource exists, serve it
if (requestedResource.exists() && requestedResource.isReadable()) {
return requestedResource;
}
// Otherwise, serve index.html for Vue Router to handle
return new ClassPathResource("static/index.html");
}
});
}
}

View file

@ -0,0 +1,221 @@
package de.avatic.lcc.e2e.pages;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.AriaRole;
import com.microsoft.playwright.options.WaitForSelectorState;
import de.avatic.lcc.e2e.testdata.TestCaseInput;
import java.util.logging.Logger;
/**
* Page Object for the calculation assistant page.
* Handles part number entry, supplier selection, and calculation creation.
*/
public class AssistantPage extends BasePage {
private static final Logger logger = Logger.getLogger(AssistantPage.class.getName());
// Selectors - using more robust selectors
private static final String PART_NUMBER_INPUT = "textarea"; // simplified - typically only one textarea on the page
private static final String ANALYZE_BUTTON_TEXT = "Analyze input";
private static final String SUPPLIER_SEARCH_INPUT = "input[type='text']"; // fallback, may need refinement
private static final String LOAD_FROM_PREVIOUS_CHECKBOX = ".checkbox-item";
private static final String CREATE_CALCULATION_BUTTON_TEXT = "Create";
private static final String DELETE_SUPPLIER_BUTTON = ".icon-btn";
public AssistantPage(Page page) {
super(page);
}
/**
* Navigates to the assistant page.
* The part number modal opens automatically by design.
*/
public void navigate(String baseUrl) {
page.navigate(baseUrl + "/assistant");
waitForSpaNavigation("/assistant");
// Wait for the part number modal to appear (it opens automatically)
Locator modal = page.locator(".part-number-modal-container");
try {
modal.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(5000));
logger.info("Part number modal opened automatically");
} catch (Exception e) {
logger.info("Modal did not open automatically, will be opened manually when needed");
}
// Debug screenshot after navigation
page.screenshot(new com.microsoft.playwright.Page.ScreenshotOptions()
.setPath(java.nio.file.Paths.get("target/screenshots/debug_after_navigate.png")));
logger.info("Navigated to assistant page");
}
/**
* Enters part numbers and clicks analyze.
* Works with modal whether it's already open or needs to be opened.
*/
public void searchPartNumbers(String partNumber) {
// Check if modal is already visible
Locator modal = page.locator(".part-number-modal-container");
boolean modalVisible = false;
try {
modalVisible = modal.isVisible();
} catch (Exception e) {
modalVisible = false;
}
if (!modalVisible) {
// Modal not open, click "Drop part numbers" button to open it
logger.info("Modal not visible, clicking 'Drop part numbers' button");
Locator dropButton = page.locator("button:has-text('Drop part numbers')");
dropButton.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
dropButton.click();
// Wait for modal to appear
modal.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
} else {
logger.info("Modal already visible, proceeding with part number entry");
}
// Find and fill textarea inside modal - click first to focus, then type
Locator textarea = modal.locator("textarea");
textarea.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
textarea.click();
page.waitForTimeout(200);
textarea.fill(partNumber);
// Debug screenshot after filling
page.screenshot(new com.microsoft.playwright.Page.ScreenshotOptions()
.setPath(java.nio.file.Paths.get("target/screenshots/debug_after_fill.png")));
logger.info(() -> "Filled textarea with: " + partNumber);
// Click Analyze input button inside modal
Locator analyzeButton = modal.locator("button:has-text('Analyze input')");
analyzeButton.click();
logger.info("Clicked Analyze input button");
// Wait for modal to close after API response
page.waitForTimeout(2000); // Wait for API response
// Check if modal is still visible and wait for it to close
try {
Locator modalOverlay = page.locator(".modal-overlay");
if (modalOverlay.isVisible()) {
modalOverlay.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.HIDDEN)
.setTimeout(10000));
}
} catch (Exception e) {
logger.warning("Modal overlay check failed: " + e.getMessage());
}
// Wait for the part number to appear in the material list (not anywhere on page)
// The part number appears in: .item-list-element .supplier-item-address
try {
Locator partNumberInList = page.locator(".item-list-element .supplier-item-address:has-text('" + partNumber + "')");
partNumberInList.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(10000));
logger.info(() -> "Part number " + partNumber + " appeared in the material list");
} catch (Exception e) {
logger.warning(() -> "Part number " + partNumber + " not found in material list: " + e.getMessage());
// Take a screenshot to debug
page.screenshot(new com.microsoft.playwright.Page.ScreenshotOptions()
.setPath(java.nio.file.Paths.get("target/screenshots/debug_no_materials.png")));
// Log what materials are visible
int itemCount = page.locator(".item-list-element").count();
logger.info(() -> "Found " + itemCount + " item-list-elements on page");
}
logger.info(() -> "Searched for part number: " + partNumber);
}
/**
* Deletes all pre-selected suppliers.
* Uses specific selector to target only supplier items, not material items.
* SupplierItem has .supplier-content class with flag, MaterialItem has .material-item-text.
*/
public void deletePreselectedSuppliers() {
while (true) {
try {
// Target only delete buttons within supplier items (which have .supplier-content)
// This avoids deleting material items by mistake
Locator deleteButton = page.locator(".item-list-element:has(.supplier-content) .icon-btn").first();
deleteButton.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(1000));
deleteButton.click();
page.waitForTimeout(200);
} catch (Exception e) {
// No more supplier delete buttons
break;
}
}
logger.info("Deleted all pre-selected suppliers");
}
/**
* Selects a supplier by name using autosuggest.
*/
public void selectSupplier(String supplierName) {
// Find the search input - look for placeholder text or input near supplier section
Locator searchInput = page.locator("input[placeholder*='Search'], input[placeholder*='search'], .search-input").first();
searchInput.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
searchInput.clear();
searchInput.fill(supplierName);
page.waitForTimeout(1000);
// Click the first suggestion
Locator suggestion = page.locator(".suggestion-item, .autocomplete-item, [role='option']").first();
try {
suggestion.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(3000));
suggestion.click();
} catch (Exception e) {
// Try clicking text that matches the supplier name
page.getByText(supplierName).first().click();
}
page.waitForTimeout(500);
logger.info(() -> "Selected supplier: " + supplierName);
}
/**
* Sets the "load from previous" checkbox and creates the calculation.
*/
public void createCalculation(boolean loadFromPrevious) {
// Try to set checkbox if visible
try {
setCheckbox(LOAD_FROM_PREVIOUS_CHECKBOX, loadFromPrevious);
} catch (Exception e) {
logger.warning("Could not find load from previous checkbox, continuing...");
}
// Use specific role-based selector to avoid matching "Create Calculation" heading
// and "Create a new supplier" button
Locator createButton = page.getByRole(AriaRole.BUTTON,
new Page.GetByRoleOptions().setName("Create").setExact(true));
createButton.click();
page.waitForTimeout(500);
logger.info(() -> "Created calculation with loadFromPrevious: " + loadFromPrevious);
}
/**
* Performs the complete assistant workflow for a test case.
*/
public void completeAssistantWorkflow(String baseUrl, TestCaseInput input) {
navigate(baseUrl);
searchPartNumbers(input.partNumber());
deletePreselectedSuppliers();
selectSupplier(input.supplierName());
createCalculation(input.loadFromPrevious());
}
}

View file

@ -0,0 +1,209 @@
package de.avatic.lcc.e2e.pages;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.LoadState;
import com.microsoft.playwright.options.WaitForSelectorState;
import java.util.logging.Logger;
/**
* Base class for all Playwright Page Objects.
* Provides common interaction methods for UI elements.
*/
public abstract class BasePage {
private static final Logger logger = Logger.getLogger(BasePage.class.getName());
protected final Page page;
protected BasePage(Page page) {
this.page = page;
}
/**
* Waits until the SPA navigates to a route containing the expected part.
*/
protected void waitForSpaNavigation(String expectedRoutePart) {
page.waitForURL("**" + expectedRoutePart + "**");
page.waitForLoadState(LoadState.NETWORKIDLE);
}
/**
* Waits for an element to be visible.
*/
protected Locator waitForElement(String selector) {
Locator locator = page.locator(selector);
locator.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
return locator;
}
/**
* Waits for an element to be visible with a custom timeout.
*/
protected Locator waitForElement(String selector, double timeoutMs) {
Locator locator = page.locator(selector);
locator.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(timeoutMs));
return locator;
}
/**
* Clears and fills an input field.
*/
protected void fillInput(Locator locator, String text) {
locator.clear();
locator.fill(text);
logger.info(() -> "Filled input with: " + text);
}
/**
* Clears and fills an input field by selector.
*/
protected void fillInput(String selector, String text) {
Locator locator = waitForElement(selector);
fillInput(locator, text);
}
/**
* Fills an input field if it exists, returns false if element not found.
*/
protected boolean fillInputIfExists(String selector, String text, double timeoutMs) {
try {
Locator locator = page.locator(selector);
locator.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(timeoutMs));
fillInput(locator, text);
return true;
} catch (Exception e) {
logger.warning(() -> "Element not found, skipping: " + selector);
return false;
}
}
/**
* Clicks a button by selector.
*/
protected void clickButton(String selector) {
Locator button = waitForElement(selector);
button.click();
logger.info(() -> "Clicked button: " + selector);
}
/**
* Clicks a button by its visible text.
*/
protected void clickButtonByText(String buttonText) {
Locator button = page.getByText(buttonText);
button.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
button.click();
logger.info(() -> "Clicked button with text: " + buttonText);
}
/**
* Clicks a button by its visible text with custom timeout.
*/
protected void clickButtonByText(String buttonText, double timeoutMs) {
Locator button = page.getByText(buttonText);
button.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(timeoutMs));
button.click();
logger.info(() -> "Clicked button with text: " + buttonText);
}
/**
* Sets a checkbox to the desired state.
*/
protected void setCheckbox(String labelSelector, boolean checked) {
Locator label = page.locator(labelSelector);
label.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
Locator checkbox = label.locator("input[type='checkbox']");
boolean isChecked = checkbox.isChecked();
if (isChecked != checked) {
label.click();
page.waitForTimeout(300);
logger.info(() -> "Toggled checkbox to: " + checked);
}
}
/**
* Selects an option from a dropdown menu.
*/
protected void selectDropdownOption(String triggerSelector, String optionText) {
Locator dropdownTrigger = waitForElement(triggerSelector);
// Check if already has the correct value
try {
String currentValue = dropdownTrigger.locator("span.dropdown-trigger-text").textContent();
if (optionText.equals(currentValue)) {
logger.info(() -> "Dropdown already has value: " + optionText);
return;
}
} catch (Exception ignored) {
// Continue to open dropdown
}
dropdownTrigger.click();
logger.info("Opened dropdown");
Locator menu = page.locator("ul.dropdown-menu");
menu.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
String optionXPath = String.format(
"//li[contains(@class, 'dropdown-option')][normalize-space(text())='%s']",
optionText
);
Locator option = page.locator(optionXPath);
option.click();
logger.info(() -> "Selected dropdown option: " + optionText);
page.waitForTimeout(200);
}
/**
* Searches in an autosuggest input and selects the first suggestion.
*/
protected void searchAndSelectAutosuggest(String inputSelector, String searchText) {
searchAndSelectAutosuggest(inputSelector, searchText, ".suggestion-item");
}
/**
* Searches in an autosuggest input and selects from suggestions.
*/
protected void searchAndSelectAutosuggest(String inputSelector, String searchText, String suggestionSelector) {
Locator input = waitForElement(inputSelector);
input.clear();
input.fill(searchText);
page.waitForTimeout(1000);
Locator suggestion = page.locator(suggestionSelector).first();
suggestion.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
suggestion.click();
page.waitForTimeout(500);
logger.info(() -> "Selected autosuggest for: " + searchText);
}
/**
* Waits for a modal to close.
*/
protected void waitForModalToClose() {
page.locator("div.modal-container").waitFor(
new Locator.WaitForOptions().setState(WaitForSelectorState.HIDDEN)
);
}
/**
* Takes a screenshot for debugging purposes.
*/
protected void takeScreenshot(String name) {
page.screenshot(new Page.ScreenshotOptions()
.setPath(java.nio.file.Paths.get("target/screenshots/" + name + ".png")));
}
}

View file

@ -0,0 +1,654 @@
package de.avatic.lcc.e2e.pages;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.WaitForSelectorState;
import de.avatic.lcc.e2e.testdata.DestinationInput;
import de.avatic.lcc.e2e.testdata.TestCaseInput;
import java.util.logging.Logger;
/**
* Page Object for the calculation edit page.
* Handles form filling for materials, packaging, pricing, and destinations.
*/
public class CalculationEditPage extends BasePage {
private static final Logger logger = Logger.getLogger(CalculationEditPage.class.getName());
// Screenshot settings
private String screenshotPrefix = null;
private int destinationCounter = 0;
// Material section selectors (first master-data-item box)
// Note: Use [1] after following-sibling::div to get only the first following sibling
private static final String HS_CODE_INPUT = "//div[contains(@class, 'master-data-item')][1]//div[contains(@class, 'caption-column')][text()='HS code']/following-sibling::div[1]//input[@class='input-field']";
private static final String TARIFF_RATE_INPUT = "//div[contains(@class, 'master-data-item')][1]//div[contains(@class, 'caption-column')][contains(., 'Tariff rate')]/following-sibling::div[1]//input[@class='input-field']";
// Price section selectors (second master-data-item box)
// Note: Labels are "MEK_A [EUR]", "Overseas share [%]" (spelling: OverSeas, not OverSea)
private static final String PRICE_INPUT = "//div[contains(@class, 'master-data-item')][2]//div[contains(@class, 'caption-column')][contains(., 'MEK_A')]/following-sibling::div[1]//input[@class='input-field']";
private static final String OVERSEA_SHARE_INPUT = "//div[contains(@class, 'master-data-item')][2]//div[contains(@class, 'caption-column')][contains(., 'Overseas share')]/following-sibling::div[1]//input[@class='input-field']";
private static final String FCA_FEE_CHECKBOX = "//div[contains(@class, 'master-data-item')][2]//div[contains(@class, 'caption-column')][contains(., 'FCA')]/following-sibling::div[1]//label[contains(@class, 'checkbox-item')]";
// Handling Unit section selectors (third master-data-item box)
private static final String LENGTH_INPUT = "//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU length']/following-sibling::div[1]//input[@class='input-field']";
private static final String WIDTH_INPUT = "//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU width']/following-sibling::div[1]//input[@class='input-field']";
private static final String HEIGHT_INPUT = "//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU height']/following-sibling::div[1]//input[@class='input-field']";
private static final String WEIGHT_INPUT = "//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU weight']/following-sibling::div[1]//input[@class='input-field']";
private static final String PIECES_PER_UNIT_INPUT = "//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='Pieces per HU']/following-sibling::div[1]//input[@class='input-field']";
// Dropdown selectors
private static final String DIMENSION_UNIT_DROPDOWN = "//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='Dimension unit']/following-sibling::div[1]//button[contains(@class, 'dropdown-trigger')]";
private static final String WEIGHT_UNIT_DROPDOWN = "//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='Weight unit']/following-sibling::div[1]//button[contains(@class, 'dropdown-trigger')]";
// Checkbox selectors
private static final String MIXED_CHECKBOX = "//label[contains(@class, 'checkbox-item')][.//span[contains(@class, 'checkbox-label')][text()='Mixable']]";
private static final String STACKED_CHECKBOX = "//label[contains(@class, 'checkbox-item')][.//span[contains(@class, 'checkbox-label')][text()='Stackable']]";
// Destination selectors
// Note: Use contains(., 'text') instead of contains(text(), 'text') when text is inside nested elements like tooltips
private static final String DESTINATION_NAME_INPUT = "//input[@placeholder='Add new Destination ...']";
private static final String DESTINATION_QUANTITY_INPUT = "//div[contains(@class, 'destination-edit-column-caption')][contains(., 'Annual quantity')]/following-sibling::div[1]//input[@class='input-field']";
// Radio buttons are hidden and styled via label - click the label text instead
private static final String ROUTING_RADIO = "//label[contains(@class, 'radio-item')]//span[contains(@class, 'radio-label')][contains(., 'standard routing')]";
private static final String D2D_RADIO = "//label[contains(@class, 'radio-item')]//span[contains(@class, 'radio-label')][contains(., 'individual rate')]";
// Note: D2D fields use "D2D Rate [EUR]" and "Lead time [days]" as labels in the UI
private static final String D2D_COST_INPUT = "//div[contains(@class, 'destination-edit-column-caption')][contains(., 'D2D Rate')]/following-sibling::div[1]//input[@class='input-field']";
private static final String D2D_DURATION_INPUT = "//div[contains(@class, 'destination-edit-column-caption')][contains(., 'Lead time')]/following-sibling::div[1]//input[@class='input-field']";
private static final String HANDLING_TAB = "//button[contains(@class, 'tab-header')][contains(., 'Handling')]";
private static final String CUSTOM_HANDLING_CHECKBOX = "//div[contains(@class, 'destination-edit-handling-cost')]//label[contains(@class, 'checkbox-item')]";
private static final String HANDLING_COST_INPUT = "//div[contains(@class, 'destination-edit-column-caption')][contains(., 'Handling cost')]/following-sibling::div[1]//input[@class='input-field']";
private static final String REPACKING_COST_INPUT = "//div[contains(@class, 'destination-edit-column-caption')][contains(., 'Repackaging cost')]/following-sibling::div[1]//input[@class='input-field']";
private static final String DISPOSAL_COST_INPUT = "//div[contains(@class, 'destination-edit-column-caption')][contains(., 'Disposal cost')]/following-sibling::div[1]//input[@class='input-field']";
// Buttons
private static final String CALCULATE_AND_CLOSE_BUTTON = "//button[contains(., 'Calculate & close')]";
private static final String CLOSE_BUTTON = "//button[contains(., 'Close') and not(contains(., 'Calculate'))]";
public CalculationEditPage(Page page) {
super(page);
}
/**
* Enables screenshot mode with a test case prefix.
* Screenshots will be saved at key points during form filling.
*/
public void enableScreenshots(String testCaseId) {
this.screenshotPrefix = testCaseId;
this.destinationCounter = 0;
}
/**
* Takes a screenshot if screenshot mode is enabled.
*/
private void captureScreenshot(String suffix) {
if (screenshotPrefix != null) {
String filename = screenshotPrefix + "_" + suffix;
java.nio.file.Path screenshotPath = java.nio.file.Paths.get("target/screenshots/" + filename + ".png");
page.screenshot(new Page.ScreenshotOptions().setPath(screenshotPath).setFullPage(true));
logger.info(() -> "Screenshot saved: " + screenshotPath);
}
}
/**
* Takes a screenshot of the current page state before calculation.
*/
public void screenshotBeforeCalculate() {
captureScreenshot("before_calculate");
}
/**
* Fills the main calculation form with input data.
*/
public void fillForm(TestCaseInput input) {
logger.info("Filling calculation form");
// Material section (if HS code input exists)
fillInputByXPath(HS_CODE_INPUT, String.valueOf(input.hsCode()), true);
fillInputByXPath(TARIFF_RATE_INPUT, String.valueOf(input.tariffRate()), true);
// Price section
fillInputByXPath(PRICE_INPUT, String.valueOf(input.price()), false);
fillInputByXPath(OVERSEA_SHARE_INPUT, String.valueOf(input.overseaShare()), false);
setCheckboxByXPath(FCA_FEE_CHECKBOX, input.fcaFee());
// Handling Unit section
fillInputByXPath(LENGTH_INPUT, String.valueOf(input.length()), false);
fillInputByXPath(WIDTH_INPUT, String.valueOf(input.width()), false);
fillInputByXPath(HEIGHT_INPUT, String.valueOf(input.height()), false);
fillInputByXPath(WEIGHT_INPUT, String.valueOf(input.weight()), false);
fillInputByXPath(PIECES_PER_UNIT_INPUT, String.valueOf(input.piecesPerUnit()), false);
// Dropdowns
selectDropdownByXPath(DIMENSION_UNIT_DROPDOWN, input.dimensionUnit());
selectDropdownByXPath(WEIGHT_UNIT_DROPDOWN, input.weightUnit());
// Checkboxes
setCheckboxByXPath(STACKED_CHECKBOX, input.stacked());
setCheckboxByXPath(MIXED_CHECKBOX, input.mixed());
logger.info("Calculation form filled successfully");
}
/**
* Adds a new destination by name.
*/
public void addDestination(DestinationInput destination) {
searchAndSelectAutosuggestByXPath(DESTINATION_NAME_INPUT, destination.name());
page.waitForTimeout(500);
logger.info(() -> "Added destination: " + destination.name());
}
/**
* Fills destination-specific fields.
*/
public void fillDestination(DestinationInput destination) {
destinationCounter++;
String destNum = String.valueOf(destinationCounter);
// First, ensure no modal is currently open
try {
Locator existingModal = page.locator(".modal-overlay");
if (existingModal.count() > 0 && existingModal.isVisible()) {
logger.info("Closing existing modal before opening destination edit");
// Press Escape to close any open modal
page.keyboard().press("Escape");
page.waitForTimeout(500);
}
} catch (Exception e) {
// No modal open, continue
}
// Click on the destination item's edit button to open the modal
// The destination item shows the name, so we find it and click the pencil icon
String destinationName = destination.name();
Locator destinationRow = page.locator(".destination-item-row:has-text('" + destinationName + "')");
if (destinationRow.count() > 0) {
logger.info(() -> "Found destination row for: " + destinationName);
Locator editButton = destinationRow.locator("button:has([class*='pencil'])");
if (editButton.count() == 0) {
// Try alternative selector for icon button
editButton = destinationRow.locator(".destination-item-action button").first();
}
if (editButton.count() > 0) {
logger.info("Clicking edit button to open destination modal");
editButton.click();
page.waitForTimeout(1000); // Wait for modal to open
}
}
// Wait for destination edit modal to be visible
Locator quantityInput = page.locator("xpath=" + DESTINATION_QUANTITY_INPUT);
quantityInput.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(10000));
// Wait extra time for Vue component to fully initialize
// This is critical for subsequent destinations
page.waitForTimeout(1000);
// Fill quantity
fillInputByXPath(DESTINATION_QUANTITY_INPUT, String.valueOf(destination.quantity()), false);
// Select transport mode
if (destination.d2d()) {
page.locator("xpath=" + D2D_RADIO).click();
page.waitForTimeout(300);
// Fill D2D specific fields if individual rate (custom cost/duration)
if (destination.d2dCost() != null) {
fillInputByXPath(D2D_COST_INPUT, String.valueOf(destination.d2dCost()), true);
}
if (destination.d2dDuration() != null) {
fillInputByXPath(D2D_DURATION_INPUT, String.valueOf(destination.d2dDuration()), true);
}
// Note: D2D mode does NOT show route selection UI - routes are determined by the D2D provider
// If using standard routing (no cost specified), the system uses database D2D rates
if (destination.d2dCost() == null) {
logger.info("D2D with standard routing - D2D rates will be loaded from database");
}
} else {
page.locator("xpath=" + ROUTING_RADIO).click();
page.waitForTimeout(300);
// Select route - if not specified, select first available route
selectRoute(destination.route());
}
// Take screenshot of Routes tab (with route selection or D2D fields)
captureScreenshot("dest" + destNum + "_routes_tab");
// Handle custom handling costs
if (destination.customHandling()) {
// Click handling tab
try {
Locator handlingTab = page.locator("xpath=" + HANDLING_TAB);
if (handlingTab.isVisible()) {
handlingTab.click();
page.waitForTimeout(300);
}
} catch (Exception e) {
// Tab might not exist or already selected
}
setCheckboxByXPath(CUSTOM_HANDLING_CHECKBOX, true);
page.waitForTimeout(300);
if (destination.handlingCost() != null) {
fillInputByXPath(HANDLING_COST_INPUT, String.valueOf(destination.handlingCost()), true);
}
if (destination.repackingCost() != null) {
fillInputByXPath(REPACKING_COST_INPUT, String.valueOf(destination.repackingCost()), true);
}
if (destination.disposalCost() != null) {
fillInputByXPath(DISPOSAL_COST_INPUT, String.valueOf(destination.disposalCost()), true);
}
// Take screenshot of Handling tab
captureScreenshot("dest" + destNum + "_handling_tab");
} else {
// For destinations without custom handling, also take a screenshot of the handling tab for verification
try {
Locator handlingTab = page.locator("xpath=" + HANDLING_TAB);
if (handlingTab.isVisible()) {
handlingTab.click();
page.waitForTimeout(300);
captureScreenshot("dest" + destNum + "_handling_tab");
// Go back to routes tab
Locator routesTab = page.locator("//button[contains(@class, 'tab-header')][contains(., 'Routes')]");
if (routesTab.count() > 0 && routesTab.isVisible()) {
routesTab.click();
page.waitForTimeout(200);
}
}
} catch (Exception e) {
// Tab might not exist
}
}
// Close the destination edit modal by clicking OK
Locator okButton = page.locator("button:has-text('OK')");
okButton.click();
page.waitForTimeout(500);
// Wait for modal and overlay to fully close
try {
page.locator(".destination-edit-modal-container").waitFor(
new Locator.WaitForOptions()
.setState(WaitForSelectorState.HIDDEN)
.setTimeout(5000));
} catch (Exception e) {
logger.warning("Destination edit modal might not have closed: " + e.getMessage());
}
// Also wait for any modal overlay to disappear
try {
page.locator(".modal-overlay").waitFor(
new Locator.WaitForOptions()
.setState(WaitForSelectorState.HIDDEN)
.setTimeout(3000));
} catch (Exception e) {
// Overlay might not exist or already hidden
}
// Extra wait to ensure DOM is stable
page.waitForTimeout(500);
logger.info(() -> "Filled destination: " + destination.name());
}
/**
* Selects a route from the available routes.
* Routes are displayed as clickable elements in the destination edit modal.
* Each route shows external_mapping_id values like "HH", "WH HH", etc.
*
* The Vue component (DestinationEditRoutes) uses a Pinia store for route selection.
* When a route is clicked, selectRoute(id) sets route.is_selected = true.
*
* IMPORTANT: Standard DOM clicks don't reliably trigger Vue's event system.
* We need to find the Vue component and call its methods directly.
*/
private void selectRoute(String route) {
// Wait for routes to fully load
page.waitForTimeout(500);
// Wait for routes to be visible
try {
page.locator(".destination-route-container").first().waitFor(
new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE).setTimeout(5000));
} catch (Exception e) {
logger.info("No routes visible yet, continuing anyway");
}
// Check for "no routes available" warning
Locator routeWarning = page.locator(".destination-edit-route-warning");
if (routeWarning.count() > 0 && routeWarning.isVisible()) {
String warningText = routeWarning.textContent();
logger.warning(() -> "Route warning displayed: " + warningText);
logger.info("No routes available - route selection skipped.");
return;
}
// Get routes from DOM and find the Vue component
Locator allRoutes = page.locator(".destination-route-container");
int routeCount = allRoutes.count();
logger.info(() -> "Found " + routeCount + " routes in DOM");
if (routeCount == 0) {
logger.warning("No routes found");
return;
}
// Log available routes
for (int i = 0; i < routeCount; i++) {
final int idx = i;
String routeText = allRoutes.nth(i).textContent();
logger.info(() -> " Route " + idx + ": " + routeText.trim());
}
// Find best matching route index
int routeIndexToSelect = findBestMatchingRouteIndexFromDom(allRoutes, route);
logger.info(() -> "Will select route at index " + routeIndexToSelect);
// Try to find and call the Vue component's selectRoute method
// The component is mounted on the modal's routes container
Object result = page.evaluate("(routeIndex) => { " +
"try { " +
// Find the route element
" const routeElements = document.querySelectorAll('.destination-route-container'); " +
" if (!routeElements || routeElements.length === 0) return 'no_routes_in_dom'; " +
" if (routeIndex >= routeElements.length) return 'index_out_of_bounds'; " +
// Find the Vue component that handles routes - it's the parent of the routes container
" const routesCell = document.querySelector('.destination-edit-cell-routes'); " +
" if (!routesCell) return 'no_routes_cell'; " +
// Walk up to find the component with selectRoute method
" let vueComponent = null; " +
" let el = routesCell; " +
" for (let i = 0; i < 10 && el; i++) { " +
" if (el.__vueParentComponent) { " +
" let comp = el.__vueParentComponent; " +
" while (comp) { " +
" if (comp.ctx && typeof comp.ctx.selectRoute === 'function') { " +
" vueComponent = comp; " +
" break; " +
" } " +
" comp = comp.parent; " +
" } " +
" if (vueComponent) break; " +
" } " +
" el = el.parentElement; " +
" } " +
" if (!vueComponent) { " +
// Alternative: try to access pinia via window or through component
" const routeEl = routeElements[routeIndex]; " +
" let compEl = routeEl; " +
" for (let i = 0; i < 5 && compEl; i++) { " +
" if (compEl.__vueParentComponent?.ctx?.destination?.routes) { " +
" const routes = compEl.__vueParentComponent.ctx.destination.routes; " +
" if (Array.isArray(routes) && routes.length > routeIndex) { " +
" routes.forEach((r, idx) => { r.is_selected = (idx === routeIndex); }); " +
" return 'set_via_ctx_destination'; " +
" } " +
" } " +
" compEl = compEl.parentElement; " +
" } " +
" return 'no_vue_component'; " +
" } " +
// Get the route id from the component's destination.routes
" const routes = vueComponent.ctx.destination?.routes; " +
" if (!routes || routes.length === 0) return 'no_routes_in_ctx'; " +
" if (routeIndex >= routes.length) return 'route_index_exceeds_ctx'; " +
" const routeId = routes[routeIndex].id; " +
// Call the selectRoute method
" vueComponent.ctx.selectRoute(routeId); " +
" return 'called_selectRoute:' + routeId; " +
"} catch (e) { return 'error:' + e.message; } " +
"}", routeIndexToSelect);
final Object vueResult = result;
logger.info(() -> "Vue component route selection result: " + vueResult);
String resultStr = String.valueOf(result);
// If Vue approach failed, try direct Pinia manipulation
if (resultStr.startsWith("error") || resultStr.equals("no_vue_component") || resultStr.equals("no_routes_cell")) {
logger.info("Vue component approach failed, trying Pinia direct access");
Object piniaResult = tryPiniaDirectAccess(routeIndexToSelect);
final Object piniaResultFinal = piniaResult;
logger.info(() -> "Pinia direct access result: " + piniaResultFinal);
resultStr = String.valueOf(piniaResult);
}
// If still failed, try clicking with proper event simulation
if (resultStr.startsWith("error") || resultStr.startsWith("no_")) {
logger.info("Trying robust click simulation");
Locator routeToClick = allRoutes.nth(routeIndexToSelect);
simulateRobustClick(routeToClick);
}
// Wait and verify
page.waitForTimeout(300);
verifyRouteSelectionVisual(allRoutes.nth(routeIndexToSelect));
}
/**
* Try direct Pinia store access through various paths.
*/
private Object tryPiniaDirectAccess(int routeIndex) {
return page.evaluate("(routeIndex) => { " +
"try { " +
// Try different ways to find Pinia
" let pinia = null; " +
// Method 1: Through app provides
" const app = document.querySelector('#app')?.__vue_app__; " +
" if (app?._context?.provides?.pinia) { " +
" pinia = app._context.provides.pinia; " +
" } " +
// Method 2: Through window (if exposed)
" if (!pinia && window.__pinia) { " +
" pinia = window.__pinia; " +
" } " +
// Method 3: Walk through app's config
" if (!pinia && app?.config?.globalProperties?.$pinia) { " +
" pinia = app.config.globalProperties.$pinia; " +
" } " +
" if (!pinia) return 'pinia_not_found'; " +
// Access the store
" const storeState = pinia.state?.value?.['destinationSingleEdit']; " +
" if (!storeState?.destination?.routes) return 'store_not_found'; " +
" const routes = storeState.destination.routes; " +
" if (routeIndex >= routes.length) return 'index_out_of_range'; " +
// Set selection
" routes.forEach((r, idx) => { r.is_selected = (idx === routeIndex); }); " +
" return 'pinia_success'; " +
"} catch (e) { return 'pinia_error:' + e.message; } " +
"}", routeIndex);
}
/**
* Simulate a robust click that Vue should recognize.
*/
private void simulateRobustClick(Locator element) {
try {
// First, scroll into view
element.scrollIntoViewIfNeeded();
page.waitForTimeout(100);
// Try to trigger via native Playwright click
element.click(new Locator.ClickOptions().setForce(true));
page.waitForTimeout(100);
// Also dispatch events manually
element.evaluate("el => { " +
"const mousedown = new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }); " +
"const mouseup = new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }); " +
"const click = new MouseEvent('click', { bubbles: true, cancelable: true, view: window }); " +
"el.dispatchEvent(mousedown); " +
"el.dispatchEvent(mouseup); " +
"el.dispatchEvent(click); " +
"}");
logger.info("Simulated robust click on route element");
} catch (Exception e) {
logger.warning(() -> "Robust click simulation failed: " + e.getMessage());
}
}
/**
* Verify route selection is visible in the DOM.
*/
private void verifyRouteSelectionVisual(Locator routeElement) {
try {
Locator innerContainer = routeElement.locator(".destination-route-inner-container");
if (innerContainer.count() > 0) {
String classes = innerContainer.getAttribute("class");
boolean selected = classes != null && classes.contains("selected");
logger.info(() -> "Route visual verification - classes: " + classes + ", selected: " + selected);
}
} catch (Exception e) {
logger.warning(() -> "Could not verify route selection: " + e.getMessage());
}
}
/**
* Find best matching route from DOM elements.
*/
private int findBestMatchingRouteIndexFromDom(Locator allRoutes, String routeSpec) {
int routeCount = allRoutes.count();
if (routeSpec == null || routeSpec.isEmpty() || routeCount == 0) {
return 0;
}
String[] segments = routeSpec.split(",");
int bestMatchCount = 0;
int bestIndex = 0;
for (int i = 0; i < routeCount; i++) {
String routeText = allRoutes.nth(i).textContent().toLowerCase();
int matchCount = 0;
for (String segment : segments) {
String normalized = segment.trim().toLowerCase().replace("_", " ");
if (routeText.contains(normalized)) {
matchCount++;
}
}
if (matchCount > bestMatchCount) {
bestMatchCount = matchCount;
bestIndex = i;
}
}
return bestIndex;
}
/**
* Clicks the "Calculate & close" button.
*/
public void calculateAndClose() {
page.locator("xpath=" + CALCULATE_AND_CLOSE_BUTTON).click();
page.waitForTimeout(2000);
logger.info("Clicked Calculate & close");
}
/**
* Clicks the "Close" button.
*/
public void close() {
page.locator("xpath=" + CLOSE_BUTTON).click();
logger.info("Clicked Close");
}
// Helper methods for XPath-based operations
private void fillInputByXPath(String xpath, String value, boolean optional) {
try {
Locator locator = page.locator("xpath=" + xpath);
if (optional) {
locator.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(2000));
} else {
locator.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE));
}
locator.clear();
locator.fill(value);
logger.fine(() -> "Filled XPath input: " + xpath + " with value: " + value);
} catch (Exception e) {
if (!optional) {
throw e;
}
logger.warning(() -> "Optional field not found: " + xpath);
}
}
private void setCheckboxByXPath(String xpath, boolean checked) {
try {
Locator label = page.locator("xpath=" + xpath);
label.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(2000));
Locator checkbox = label.locator("input[type='checkbox']");
boolean isChecked = checkbox.isChecked();
if (isChecked != checked) {
label.click();
page.waitForTimeout(300);
}
} catch (Exception e) {
logger.warning(() -> "Could not set checkbox: " + xpath);
}
}
private void selectDropdownByXPath(String xpath, String optionText) {
try {
Locator dropdown = page.locator("xpath=" + xpath);
dropdown.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(2000));
// Check current value
try {
String currentValue = dropdown.locator("span.dropdown-trigger-text").textContent();
if (optionText.equals(currentValue)) {
return;
}
} catch (Exception ignored) {
}
dropdown.click();
Locator menu = page.locator("ul.dropdown-menu");
menu.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
String optionXPath = String.format(
"//li[contains(@class, 'dropdown-option')][normalize-space(text())='%s']",
optionText
);
page.locator("xpath=" + optionXPath).click();
page.waitForTimeout(200);
} catch (Exception e) {
logger.warning(() -> "Could not select dropdown option: " + optionText);
}
}
private void searchAndSelectAutosuggestByXPath(String xpath, String searchText) {
Locator input = page.locator("xpath=" + xpath);
input.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
input.clear();
input.fill(searchText);
page.waitForTimeout(1000);
Locator suggestion = page.locator(".suggestion-item").first();
suggestion.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
suggestion.click();
page.waitForTimeout(500);
}
}

View file

@ -0,0 +1,86 @@
package de.avatic.lcc.e2e.pages;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.WaitForSelectorState;
import java.util.logging.Logger;
/**
* Page Object for the dev login page (/dev).
* Allows selecting a user from the dev user table for testing.
*/
public class DevLoginPage extends BasePage {
private static final Logger logger = Logger.getLogger(DevLoginPage.class.getName());
private static final String MODAL_YES_BUTTON = "div.modal-dialog-actions button.btn--primary";
private static final String MODAL_CONTAINER = "div.modal-container";
public DevLoginPage(Page page) {
super(page);
}
/**
* Navigates to the dev login page and logs in as the specified user.
*
* @param baseUrl The base URL of the application
* @param userName The first name of the user to log in as (e.g., "John")
*/
public void login(String baseUrl, String userName) {
page.navigate(baseUrl + "/dev");
// Wait for the page to load
page.waitForLoadState();
// The /dev page has two tables. We need the first one (User control tab).
// Use .first() to get the first table
Locator userTable = page.locator("table.data-table").first();
userTable.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
// Wait for table rows to appear (API might take time to load data)
Locator rows = userTable.locator("tbody tr.table-row");
try {
rows.first().waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(10000));
} catch (Exception e) {
logger.warning("No table rows found after waiting. Page content: " +
page.content().substring(0, Math.min(1000, page.content().length())));
throw new RuntimeException("No users found in dev user table. Is the API working?", e);
}
int rowCount = rows.count();
logger.info(() -> "Found " + rowCount + " user rows");
boolean userFound = false;
for (int i = 0; i < rowCount; i++) {
Locator row = rows.nth(i);
Locator firstCell = row.locator("td").first();
String firstName = firstCell.textContent();
if (firstName != null && firstName.contains(userName)) {
row.click();
userFound = true;
logger.info(() -> "Selected user: " + userName);
break;
}
}
if (!userFound) {
throw new RuntimeException("User '" + userName + "' not found in dev user table");
}
// Confirm the login in the modal
Locator yesButton = page.locator(MODAL_YES_BUTTON);
yesButton.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
yesButton.click();
// Wait for modal to close
page.locator(MODAL_CONTAINER).waitFor(
new Locator.WaitForOptions().setState(WaitForSelectorState.HIDDEN)
);
logger.info(() -> "Successfully logged in as: " + userName);
}
}

View file

@ -0,0 +1,565 @@
package de.avatic.lcc.e2e.pages;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.AriaRole;
import com.microsoft.playwright.options.WaitForSelectorState;
import de.avatic.lcc.e2e.testdata.DestinationExpected;
import de.avatic.lcc.e2e.testdata.TestCaseExpected;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
/**
* Page Object for the calculation results/report page.
* Handles navigating to reports and reading calculation results.
*/
public class ResultsPage extends BasePage {
private static final Logger logger = Logger.getLogger(ResultsPage.class.getName());
// Report page selectors based on Report.vue structure
private static final String REPORT_CONTAINER = ".report-container";
private static final String CREATE_REPORT_BUTTON = "button:has-text('Create report')";
private static final String REPORT_BOX = ".box"; // Reports are shown inside Box components
public ResultsPage(Page page) {
super(page);
}
/**
* Navigates to the reports page and creates a report for the given material/supplier.
*/
public void navigateToReports(String baseUrl, String partNumber, String supplierName) {
// Navigate to reports page
page.navigate(baseUrl + "/reports");
page.waitForLoadState();
logger.info("Navigated to reports page");
// Click "Create report" button
Locator createReportBtn = page.locator(CREATE_REPORT_BUTTON);
createReportBtn.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(10000));
createReportBtn.click();
logger.info("Clicked Create report button");
// Wait for the modal to fully open
page.waitForTimeout(1000);
// The modal has an autosuggest search bar with specific placeholder
// Use the placeholder text to find the correct input inside the modal
Locator searchInput = page.locator("input[placeholder='Select material for reporting']");
searchInput.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(5000));
searchInput.click();
searchInput.fill(partNumber);
logger.info("Entered part number in search: " + partNumber);
page.waitForTimeout(1500);
// Wait for and select the material from suggestions
Locator suggestion = page.locator(".suggestion-item").first();
try {
suggestion.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(5000));
suggestion.click();
logger.info("Selected material from suggestions");
} catch (Exception e) {
logger.warning("Could not select material from suggestions: " + e.getMessage());
}
// Wait for suppliers list to load
page.waitForTimeout(1500);
// Select the supplier by clicking on its item-list-element
// The supplier name is inside a supplier-item component
try {
Locator supplierElement = page.locator(".item-list-element")
.filter(new Locator.FilterOptions().setHasText(supplierName))
.first();
if (supplierElement.count() > 0) {
supplierElement.click();
logger.info("Selected supplier: " + supplierName);
page.waitForTimeout(500);
} else {
logger.warning("Supplier not found: " + supplierName);
}
} catch (Exception e) {
logger.warning("Could not select supplier: " + e.getMessage());
}
// Click OK button inside the modal footer
Locator okButton = page.locator(".footer button:has-text('OK')");
try {
okButton.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(5000));
okButton.click();
logger.info("Clicked OK button");
} catch (Exception e) {
// Fallback: try to find any OK button
page.locator("button:has-text('OK')").first().click();
}
// Wait for the report to load
waitForResults();
}
/**
* Waits for the results to be loaded.
*/
public void waitForResults() {
// Wait for any "Prepare report" modal to disappear
try {
Locator prepareReportModal = page.locator(".modal-overlay, .modal-container, .modal-dialog");
if (prepareReportModal.count() > 0 && prepareReportModal.first().isVisible()) {
logger.info("Waiting for modal to close...");
prepareReportModal.first().waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.HIDDEN)
.setTimeout(30000));
logger.info("Modal closed");
}
} catch (Exception e) {
// Modal might not be present or already closed
}
try {
// Wait for report container or spinner to disappear
page.locator(".report-spinner, .spinner").waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.HIDDEN)
.setTimeout(30000));
} catch (Exception e) {
// Spinner might not be present
}
try {
page.locator(REPORT_CONTAINER).waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(30000));
page.waitForLoadState();
logger.info("Results loaded");
} catch (Exception e) {
logger.warning("Results container not found, continuing...");
}
}
/**
* Expands all collapsible boxes on the report page.
* The Vue CollapsibleBox component uses:
* - .box-content.collapsed for hidden content
* - .collapse-button in the header to toggle
* - The outer box element gets class "collapsible" when collapsed and clickable
*/
public void expandAllCollapsibleBoxes() {
try {
// Strategy: Keep clicking on collapsed boxes until none remain
// After each click, re-query the DOM since it changes
int maxIterations = 20; // Safety limit
int totalExpanded = 0;
for (int iteration = 0; iteration < maxIterations; iteration++) {
// Find collapsed content sections
Locator collapsedContent = page.locator(".box-content.collapsed");
int collapsedCount = collapsedContent.count();
if (collapsedCount == 0) {
break; // All expanded
}
final int iterNum = iteration + 1;
final int remaining = collapsedCount;
logger.info(() -> "Iteration " + iterNum + ": Found " + remaining + " collapsed boxes");
// Try to expand the first collapsed box
try {
Locator firstCollapsed = collapsedContent.first();
// Navigate up to find the clickable header span (the title)
// Structure: box > div > div.box-header > span (clickable)
Locator headerSpan = firstCollapsed.locator("xpath=preceding-sibling::div[contains(@class, 'box-header')]//span").first();
if (headerSpan.count() > 0 && headerSpan.isVisible()) {
headerSpan.click();
page.waitForTimeout(400); // Wait for animation
totalExpanded++;
logger.info(() -> "Expanded box via header span");
continue;
}
// Alternative: Try clicking the collapse button
Locator collapseButton = firstCollapsed.locator("xpath=preceding-sibling::div[contains(@class, 'box-header')]//button[contains(@class, 'collapse-button')]").first();
if (collapseButton.count() > 0 && collapseButton.isVisible()) {
collapseButton.click();
page.waitForTimeout(400);
totalExpanded++;
logger.info(() -> "Expanded box via collapse button");
continue;
}
// Alternative: Click on the parent box element which also has a click handler
Locator parentBox = firstCollapsed.locator("xpath=ancestor::*[contains(@class, 'collapsible')]").first();
if (parentBox.count() > 0 && parentBox.isVisible()) {
parentBox.click();
page.waitForTimeout(400);
totalExpanded++;
logger.info(() -> "Expanded box via parent collapsible element");
continue;
}
// If nothing worked, log and try next
logger.warning("Could not find clickable element for collapsed box");
} catch (Exception e) {
final String errorMsg = e.getMessage();
logger.warning(() -> "Error expanding box: " + errorMsg);
}
}
// Final check
int finalCollapsed = page.locator(".box-content.collapsed").count();
final int expanded = totalExpanded;
final int stillCollapsedFinal = finalCollapsed;
logger.info(() -> "Expanded " + expanded + " boxes, " + stillCollapsedFinal + " still collapsed");
page.waitForTimeout(500); // Wait for all animations to complete
} catch (Exception e) {
logger.warning("Could not expand all boxes: " + e.getMessage());
}
}
/**
* Takes a full page screenshot with all content visible.
* @param filename The filename without path (will be saved to target/screenshots/)
*/
public void takeFullPageScreenshot(String filename) {
try {
// First expand all collapsible sections
expandAllCollapsibleBoxes();
// Wait a moment for any animations to complete
page.waitForTimeout(500);
// Take full page screenshot
String path = "target/screenshots/" + filename + ".png";
page.screenshot(new Page.ScreenshotOptions()
.setPath(Paths.get(path))
.setFullPage(true));
logger.info(() -> "Full page screenshot saved: " + path);
} catch (Exception e) {
logger.warning("Could not take full page screenshot: " + e.getMessage());
}
}
/**
* Reads all result values from the page.
* Based on Report.vue structure with .report-content-row elements.
*/
public Map<String, Object> readResults() {
waitForResults();
Map<String, Object> results = new HashMap<>();
// Read values from the "Summary" section (first 3-col grid)
// Structure: <div class="report-content-row"><div>Label</div><div class="report-content-data-cell">Value €</div>...</div>
results.put("mekA", readValueByLabel("MEK A"));
results.put("logisticCost", readValueByLabel("Logistics cost"));
results.put("mekB", readValueByLabel("MEK B"));
// Read values from the "Weighted cost breakdown" section
results.put("fcaFee", readValueByLabel("FCA fee"));
results.put("transportation", readValueByLabel("Transportation costs"));
results.put("d2d", readValueByLabel("Door 2 door costs"));
results.put("airFreight", readValueByLabel("Air freight costs"));
results.put("custom", readValueByLabel("Custom costs"));
results.put("repackaging", readValueByLabel("Repackaging"));
results.put("handling", readValueByLabel("Handling"));
results.put("disposal", readValueByLabel("Disposal costs"));
results.put("space", readValueByLabel("Space costs"));
results.put("capital", readValueByLabel("Capital costs"));
// Read safety stock from material section
results.put("safetyStock", readIntValueByLabel("Safety stock"));
// Read destination results
results.put("destinations", readDestinationResults());
return results;
}
/**
* Reads a numeric value by finding the label in a report-content-row.
* The structure is: label | value | percentage
*/
private Double readValueByLabel(String label) {
try {
// Find the row containing the label, then get the first data cell
String xpath = String.format(
"//div[contains(@class, 'report-content-row')]/div[contains(text(), '%s')]/following-sibling::div[contains(@class, 'report-content-data-cell')][1]",
label
);
Locator locator = page.locator("xpath=" + xpath).first();
if (locator.count() == 0) {
// Try alternative: text might be in a child element
xpath = String.format(
"//div[contains(@class, 'report-content-row')]/div[contains(., '%s')]/following-sibling::div[contains(@class, 'report-content-data-cell')][1]",
label
);
locator = page.locator("xpath=" + xpath).first();
}
if (locator.count() == 0) {
logger.warning(() -> "Field not found by label: " + label);
return null;
}
String text = locator.textContent();
if (text == null || text.isEmpty()) {
return null;
}
// Remove currency symbols, percentage signs, and formatting
// Handle German number format (1.234,56) vs English (1,234.56)
text = text.replaceAll("[€$%\\s]", "").trim();
// If contains comma as decimal separator (German format)
if (text.contains(",") && !text.contains(".")) {
text = text.replace(",", ".");
} else if (text.contains(",") && text.contains(".")) {
// 1.234,56 format - remove thousands separator, replace decimal
text = text.replace(".", "").replace(",", ".");
}
return Double.parseDouble(text);
} catch (Exception e) {
logger.warning(() -> "Could not read numeric value for label: " + label + " - " + e.getMessage());
return null;
}
}
/**
* Reads integer value by label.
*/
private Integer readIntValueByLabel(String label) {
Double value = readValueByLabel(label);
return value != null ? value.intValue() : null;
}
/**
* Reads results for all destinations from the report.
* Destinations are in collapsible boxes with class containing destination info.
*/
private List<Map<String, Object>> readDestinationResults() {
List<Map<String, Object>> destinations = new ArrayList<>();
try {
// Each destination is in a collapsible-box with the destination name as title
// Look for boxes that have destination-related content
Locator destinationBoxes = page.locator(".box-gap:has(.report-content-container--2-col)");
int count = destinationBoxes.count();
logger.info(() -> "Found " + count + " potential destination boxes");
// Skip the first few boxes which are summary, cost breakdown, and material sections
// Destinations start after those
for (int i = 0; i < count; i++) {
Locator box = destinationBoxes.nth(i);
// Check if this box has destination-specific content (Transit time, Container type)
if (box.locator("div:has-text('Transit time')").count() > 0) {
Map<String, Object> destResult = new HashMap<>();
destResult.put("transitTime", readValueInBox(box, "Transit time"));
destResult.put("stackedLayers", readValueInBox(box, "Stacked layers"));
destResult.put("containerUnitCount", readValueInBox(box, "Container unit count"));
destResult.put("containerType", readStringInBox(box, "Container type"));
destResult.put("limitingFactor", readStringInBox(box, "Limiting factor"));
destinations.add(destResult);
logger.info(() -> "Read destination " + (destinations.size()) + " results");
}
}
} catch (Exception e) {
logger.warning("Could not read destination results: " + e.getMessage());
}
return destinations;
}
private Double readValueInBox(Locator box, String label) {
try {
String xpath = String.format(
".//div[contains(@class, 'report-content-row')]/div[contains(text(), '%s')]/following-sibling::div[contains(@class, 'report-content-data-cell')][1]",
label
);
Locator cell = box.locator("xpath=" + xpath);
if (cell.count() == 0) {
return null;
}
String text = cell.textContent().replaceAll("[^0-9.,\\-]", "").trim();
if (text.isEmpty() || text.equals("-")) {
return null;
}
// Handle German decimal format
if (text.contains(",") && !text.contains(".")) {
text = text.replace(",", ".");
}
return Double.parseDouble(text);
} catch (Exception e) {
return null;
}
}
private String readStringInBox(Locator box, String label) {
try {
String xpath = String.format(
".//div[contains(@class, 'report-content-row')]/div[contains(text(), '%s')]/following-sibling::div[contains(@class, 'report-content-data-cell')][1]",
label
);
Locator cell = box.locator("xpath=" + xpath);
if (cell.count() == 0) {
return null;
}
return cell.textContent().trim();
} catch (Exception e) {
return null;
}
}
/**
* Verifies that results match expected values.
*/
public void verifyResults(TestCaseExpected expected, double tolerance) {
Map<String, Object> actual = readResults();
// Log all actual values for debugging
logger.info("======== ACTUAL VALUES FROM REPORT ========");
logger.info(() -> "MEK A: " + actual.get("mekA"));
logger.info(() -> "Logistics cost: " + actual.get("logisticCost"));
logger.info(() -> "MEK B: " + actual.get("mekB"));
logger.info(() -> "FCA fee: " + actual.get("fcaFee"));
logger.info(() -> "Transportation: " + actual.get("transportation"));
logger.info(() -> "D2D: " + actual.get("d2d"));
logger.info(() -> "Air freight: " + actual.get("airFreight"));
logger.info(() -> "Custom: " + actual.get("custom"));
logger.info(() -> "Repackaging: " + actual.get("repackaging"));
logger.info(() -> "Handling: " + actual.get("handling"));
logger.info(() -> "Disposal: " + actual.get("disposal"));
logger.info(() -> "Space: " + actual.get("space"));
logger.info(() -> "Capital: " + actual.get("capital"));
logger.info(() -> "Safety stock: " + actual.get("safetyStock"));
logger.info("======== EXPECTED VALUES ========");
logger.info(() -> "MEK A: " + expected.mekA());
logger.info(() -> "Logistics cost: " + expected.logisticCost());
logger.info(() -> "MEK B: " + expected.mekB());
logger.info(() -> "FCA fee: " + expected.fcaFee());
logger.info(() -> "Transportation: " + expected.transportation());
logger.info(() -> "D2D: " + expected.d2d());
logger.info(() -> "Air freight: " + expected.airFreight());
logger.info(() -> "Custom: " + expected.custom());
logger.info(() -> "Repackaging: " + expected.repackaging());
logger.info(() -> "Handling: " + expected.handling());
logger.info(() -> "Disposal: " + expected.disposal());
logger.info(() -> "Space: " + expected.space());
logger.info(() -> "Capital: " + expected.capital());
logger.info(() -> "Safety stock: " + expected.safetyStock());
logger.info("============================================");
verifyNumericResult("MEK_A", expected.mekA(), (Double) actual.get("mekA"), tolerance);
verifyNumericResult("LOGISTIC_COST", expected.logisticCost(), (Double) actual.get("logisticCost"), tolerance);
verifyNumericResult("MEK_B", expected.mekB(), (Double) actual.get("mekB"), tolerance);
verifyNumericResult("FCA_FEE", expected.fcaFee(), (Double) actual.get("fcaFee"), tolerance);
verifyNumericResult("TRANSPORTATION", expected.transportation(), (Double) actual.get("transportation"), tolerance);
verifyNumericResult("D2D", expected.d2d(), (Double) actual.get("d2d"), tolerance);
verifyNumericResult("AIR_FREIGHT", expected.airFreight(), (Double) actual.get("airFreight"), tolerance);
verifyNumericResult("CUSTOM", expected.custom(), (Double) actual.get("custom"), tolerance);
verifyNumericResult("REPACKAGING", expected.repackaging(), (Double) actual.get("repackaging"), tolerance);
verifyNumericResult("HANDLING", expected.handling(), (Double) actual.get("handling"), tolerance);
verifyNumericResult("DISPOSAL", expected.disposal(), (Double) actual.get("disposal"), tolerance);
verifyNumericResult("SPACE", expected.space(), (Double) actual.get("space"), tolerance);
verifyNumericResult("CAPITAL", expected.capital(), (Double) actual.get("capital"), tolerance);
// Verify destinations
@SuppressWarnings("unchecked")
List<Map<String, Object>> actualDestinations = (List<Map<String, Object>>) actual.get("destinations");
List<DestinationExpected> expectedDestinations = expected.destinations();
if (expectedDestinations.size() != actualDestinations.size()) {
throw new AssertionError(String.format(
"Destination count mismatch: expected %d, got %d",
expectedDestinations.size(), actualDestinations.size()
));
}
for (int i = 0; i < expectedDestinations.size(); i++) {
DestinationExpected expDest = expectedDestinations.get(i);
Map<String, Object> actDest = actualDestinations.get(i);
String prefix = "Destination " + (i + 1) + " ";
verifyNumericResult(prefix + "TRANSIT_TIME",
(double) expDest.transitTime(),
(Double) actDest.get("transitTime"), tolerance);
verifyNumericResult(prefix + "STACKED_LAYERS",
(double) expDest.stackedLayers(),
(Double) actDest.get("stackedLayers"), tolerance);
verifyNumericResult(prefix + "CONTAINER_UNIT_COUNT",
(double) expDest.containerUnitCount(),
(Double) actDest.get("containerUnitCount"), tolerance);
String expContainerType = expDest.containerType();
String actContainerType = (String) actDest.get("containerType");
if (!expContainerType.equals(actContainerType)) {
throw new AssertionError(String.format(
"%sCONTAINER_TYPE mismatch: expected '%s', got '%s'",
prefix, expContainerType, actContainerType
));
}
String expLimitingFactor = expDest.limitingFactor();
String actLimitingFactor = (String) actDest.get("limitingFactor");
if (!expLimitingFactor.equals(actLimitingFactor)) {
throw new AssertionError(String.format(
"%sLIMITING_FACTOR mismatch: expected '%s', got '%s'",
prefix, expLimitingFactor, actLimitingFactor
));
}
}
logger.info("All results verified successfully");
}
private void verifyNumericResult(String fieldName, double expected, Double actual, double tolerance) {
// If actual is null and expected is effectively zero, treat as pass
// (some fields may not be displayed in the UI when their value is 0)
if (actual == null) {
if (Math.abs(expected) < 0.001) {
logger.info(() -> "Field '" + fieldName + "': actual is null, expected ~0 - treating as pass");
return;
}
throw new AssertionError(String.format(
"Field '%s': actual value is null, expected %f",
fieldName, expected
));
}
double diff = Math.abs(expected - actual);
double relativeDiff = expected != 0 ? diff / Math.abs(expected) : diff;
if (relativeDiff > tolerance) {
throw new AssertionError(String.format(
"Field '%s': expected %f, got %f (diff: %.4f, tolerance: %.2f%%)",
fieldName, expected, actual, relativeDiff * 100, tolerance * 100
));
}
}
}

View file

@ -0,0 +1,55 @@
package de.avatic.lcc.e2e.testdata;
/**
* Expected output values for a single destination in a test case.
*/
public record DestinationExpected(
int transitTime,
int stackedLayers,
int containerUnitCount,
String containerType,
String limitingFactor
) {
public static Builder builder() {
return new Builder();
}
public static class Builder {
private int transitTime;
private int stackedLayers;
private int containerUnitCount;
private String containerType;
private String limitingFactor;
public Builder transitTime(int transitTime) {
this.transitTime = transitTime;
return this;
}
public Builder stackedLayers(int stackedLayers) {
this.stackedLayers = stackedLayers;
return this;
}
public Builder containerUnitCount(int containerUnitCount) {
this.containerUnitCount = containerUnitCount;
return this;
}
public Builder containerType(String containerType) {
this.containerType = containerType;
return this;
}
public Builder limitingFactor(String limitingFactor) {
this.limitingFactor = limitingFactor;
return this;
}
public DestinationExpected build() {
return new DestinationExpected(
transitTime, stackedLayers, containerUnitCount, containerType, limitingFactor
);
}
}
}

View file

@ -0,0 +1,91 @@
package de.avatic.lcc.e2e.testdata;
/**
* Input data for a single destination in a test case.
*/
public record DestinationInput(
String name,
int quantity,
boolean d2d,
String route,
Double d2dCost,
Integer d2dDuration,
Double handlingCost,
Double repackingCost,
Double disposalCost,
boolean customHandling
) {
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String name;
private int quantity;
private boolean d2d;
private String route;
private Double d2dCost;
private Integer d2dDuration;
private Double handlingCost;
private Double repackingCost;
private Double disposalCost;
private boolean customHandling;
public Builder name(String name) {
this.name = name;
return this;
}
public Builder quantity(int quantity) {
this.quantity = quantity;
return this;
}
public Builder d2d(boolean d2d) {
this.d2d = d2d;
return this;
}
public Builder route(String route) {
this.route = route;
return this;
}
public Builder d2dCost(Double d2dCost) {
this.d2dCost = d2dCost;
return this;
}
public Builder d2dDuration(Integer d2dDuration) {
this.d2dDuration = d2dDuration;
return this;
}
public Builder handlingCost(Double handlingCost) {
this.handlingCost = handlingCost;
return this;
}
public Builder repackingCost(Double repackingCost) {
this.repackingCost = repackingCost;
return this;
}
public Builder disposalCost(Double disposalCost) {
this.disposalCost = disposalCost;
return this;
}
public Builder customHandling(boolean customHandling) {
this.customHandling = customHandling;
return this;
}
public DestinationInput build() {
return new DestinationInput(
name, quantity, d2d, route, d2dCost, d2dDuration,
handlingCost, repackingCost, disposalCost, customHandling
);
}
}
}

View file

@ -0,0 +1,12 @@
package de.avatic.lcc.e2e.testdata;
/**
* Represents a complete E2E test case with input data and expected output.
*/
public record TestCase(
String id,
String name,
TestCaseInput input,
TestCaseExpected expected
) {
}

View file

@ -0,0 +1,128 @@
package de.avatic.lcc.e2e.testdata;
import java.util.List;
/**
* Expected output values for a test case containing all calculated results.
*/
public record TestCaseExpected(
double mekA,
double logisticCost,
double mekB,
double fcaFee,
double transportation,
double d2d,
double airFreight,
double custom,
double repackaging,
double handling,
double disposal,
double space,
double capital,
int safetyStock,
List<DestinationExpected> destinations
) {
public static Builder builder() {
return new Builder();
}
public static class Builder {
private double mekA;
private double logisticCost;
private double mekB;
private double fcaFee;
private double transportation;
private double d2d;
private double airFreight;
private double custom;
private double repackaging;
private double handling;
private double disposal;
private double space;
private double capital;
private int safetyStock;
private List<DestinationExpected> destinations = List.of();
public Builder mekA(double mekA) {
this.mekA = mekA;
return this;
}
public Builder logisticCost(double logisticCost) {
this.logisticCost = logisticCost;
return this;
}
public Builder mekB(double mekB) {
this.mekB = mekB;
return this;
}
public Builder fcaFee(double fcaFee) {
this.fcaFee = fcaFee;
return this;
}
public Builder transportation(double transportation) {
this.transportation = transportation;
return this;
}
public Builder d2d(double d2d) {
this.d2d = d2d;
return this;
}
public Builder airFreight(double airFreight) {
this.airFreight = airFreight;
return this;
}
public Builder custom(double custom) {
this.custom = custom;
return this;
}
public Builder repackaging(double repackaging) {
this.repackaging = repackaging;
return this;
}
public Builder handling(double handling) {
this.handling = handling;
return this;
}
public Builder disposal(double disposal) {
this.disposal = disposal;
return this;
}
public Builder space(double space) {
this.space = space;
return this;
}
public Builder capital(double capital) {
this.capital = capital;
return this;
}
public Builder safetyStock(int safetyStock) {
this.safetyStock = safetyStock;
return this;
}
public Builder destinations(List<DestinationExpected> destinations) {
this.destinations = destinations;
return this;
}
public TestCaseExpected build() {
return new TestCaseExpected(
mekA, logisticCost, mekB, fcaFee, transportation, d2d, airFreight,
custom, repackaging, handling, disposal, space, capital, safetyStock, destinations
);
}
}
}

View file

@ -0,0 +1,150 @@
package de.avatic.lcc.e2e.testdata;
import java.util.List;
/**
* Input data for a test case containing all form values to be entered.
*/
public record TestCaseInput(
String partNumber,
String supplierName,
boolean loadFromPrevious,
Integer hsCode,
double tariffRate,
double price,
double overseaShare,
boolean fcaFee,
int length,
int width,
int height,
String dimensionUnit,
int weight,
String weightUnit,
int piecesPerUnit,
boolean stacked,
boolean mixed,
List<DestinationInput> destinations
) {
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String partNumber;
private String supplierName;
private boolean loadFromPrevious;
private Integer hsCode;
private double tariffRate;
private double price;
private double overseaShare;
private boolean fcaFee;
private int length;
private int width;
private int height;
private String dimensionUnit = "cm";
private int weight;
private String weightUnit = "kg";
private int piecesPerUnit;
private boolean stacked;
private boolean mixed;
private List<DestinationInput> destinations = List.of();
public Builder partNumber(String partNumber) {
this.partNumber = partNumber;
return this;
}
public Builder supplierName(String supplierName) {
this.supplierName = supplierName;
return this;
}
public Builder loadFromPrevious(boolean loadFromPrevious) {
this.loadFromPrevious = loadFromPrevious;
return this;
}
public Builder hsCode(Integer hsCode) {
this.hsCode = hsCode;
return this;
}
public Builder tariffRate(double tariffRate) {
this.tariffRate = tariffRate;
return this;
}
public Builder price(double price) {
this.price = price;
return this;
}
public Builder overseaShare(double overseaShare) {
this.overseaShare = overseaShare;
return this;
}
public Builder fcaFee(boolean fcaFee) {
this.fcaFee = fcaFee;
return this;
}
public Builder length(int length) {
this.length = length;
return this;
}
public Builder width(int width) {
this.width = width;
return this;
}
public Builder height(int height) {
this.height = height;
return this;
}
public Builder dimensionUnit(String dimensionUnit) {
this.dimensionUnit = dimensionUnit;
return this;
}
public Builder weight(int weight) {
this.weight = weight;
return this;
}
public Builder weightUnit(String weightUnit) {
this.weightUnit = weightUnit;
return this;
}
public Builder piecesPerUnit(int piecesPerUnit) {
this.piecesPerUnit = piecesPerUnit;
return this;
}
public Builder stacked(boolean stacked) {
this.stacked = stacked;
return this;
}
public Builder mixed(boolean mixed) {
this.mixed = mixed;
return this;
}
public Builder destinations(List<DestinationInput> destinations) {
this.destinations = destinations;
return this;
}
public TestCaseInput build() {
return new TestCaseInput(
partNumber, supplierName, loadFromPrevious, hsCode, tariffRate, price,
overseaShare, fcaFee, length, width, height, dimensionUnit, weight,
weightUnit, piecesPerUnit, stacked, mixed, destinations
);
}
}
}

View file

@ -0,0 +1,885 @@
package de.avatic.lcc.e2e.testdata;
import java.util.List;
/**
* Static test case definitions extracted from Testfälle.xlsx.
* These test cases cover various logistics calculation scenarios including:
* - EU and Non-EU suppliers
* - Matrix, D2D, and Container transport modes
* - Different packaging configurations
* - Single and multiple destinations
*/
public final class TestCases {
private TestCases() {
// Utility class
}
/**
* Test Case 1: EU Supplier, user - Matrix - Direkt
* Single destination, no FCA fee, standard packaging
*/
public static final TestCase CASE_1 = new TestCase(
"1",
"EU Supplier, user - Matrix - Direkt",
TestCaseInput.builder()
.partNumber("3064540201")
.supplierName("Ireland supplier")
.loadFromPrevious(false)
.hsCode(84312002)
.tariffRate(0.0)
.price(8.0)
.overseaShare(0.0)
.fcaFee(false)
.length(114)
.width(114)
.height(95)
.dimensionUnit("cm")
.weight(850)
.weightUnit("kg")
.piecesPerUnit(20)
.stacked(true)
.mixed(true)
.destinations(List.of(
DestinationInput.builder()
.name("Hamburg (KION plant)")
.quantity(5)
.d2d(false)
.route("Ireland supplier,HH")
.customHandling(false)
.build()
))
.build(),
TestCaseExpected.builder()
.mekA(8.0)
.logisticCost(33.75985361256046)
.mekB(41.75985361256046)
.fcaFee(0.0)
.transportation(4.29165)
.d2d(0.0)
.airFreight(0.0)
.custom(0.0)
.repackaging(0.0)
.handling(4.392)
.disposal(0.0)
.space(24.952728074399992)
.capital(0.12347553816046966)
.safetyStock(10)
.destinations(List.of(
DestinationExpected.builder()
.transitTime(3)
.stackedLayers(2)
.containerUnitCount(29)
.containerType("-")
.limitingFactor("Weight")
.build()
))
.build()
);
/**
* Test Case 2: EU-supplier, standard - Matrix - Über Hop
* Two destinations, with FCA fee, individual packaging
*/
public static final TestCase CASE_2 = new TestCase(
"2",
"EU-supplier, standard - Matrix - Über Hop",
TestCaseInput.builder()
.partNumber("4222640104")
.supplierName("Hamburg (KION plant)")
.loadFromPrevious(false)
.hsCode(84312002)
.tariffRate(0.0)
.price(230.0)
.overseaShare(0.0)
.fcaFee(true)
.length(120)
.width(80)
.height(95)
.dimensionUnit("cm")
.weight(1200)
.weightUnit("kg")
.piecesPerUnit(2000)
.stacked(true)
.mixed(true)
.destinations(List.of(
DestinationInput.builder()
.name("Geisa (KION plant)")
.quantity(3500)
.d2d(false)
.route("HH, WH STO,FGG")
.handlingCost(3.5)
.repackingCost(2.7)
.disposalCost(6.5)
.customHandling(true)
.build(),
DestinationInput.builder()
.name("Aschaffenburg (KION plant)")
.quantity(3500)
.d2d(false)
.customHandling(false)
.build()
))
.build(),
TestCaseExpected.builder()
.mekA(230.0)
.logisticCost(1.413250548826279)
.mekB(231.41325054882628)
.fcaFee(0.46)
.transportation(0.022133802105263157)
.d2d(0.0)
.airFreight(0.0)
.custom(0.0)
.repackaging(0.001613157894736842)
.handling(0.001530701754385965)
.disposal(0.003907894736842105)
.space(0.009627000252631579)
.capital(0.9144379920824194)
.safetyStock(10)
.destinations(List.of(
DestinationExpected.builder()
.transitTime(6)
.stackedLayers(2)
.containerUnitCount(20)
.containerType("-")
.limitingFactor("Weight")
.build(),
DestinationExpected.builder()
.transitTime(6)
.stackedLayers(2)
.containerUnitCount(20)
.containerType("-")
.limitingFactor("Weight")
.build()
))
.build()
);
/**
* Test Case 3: Non-EU supplier, user - Matrix - Direkt
* Three destinations, with customs
*/
public static final TestCase CASE_3 = new TestCase(
"3",
"Non-EU supplier, user - Matrix - Direkt",
TestCaseInput.builder()
.partNumber("4222640803")
.supplierName("Turkey supplier")
.loadFromPrevious(false)
.hsCode(84312002)
.tariffRate(1.7)
.price(11.0)
.overseaShare(0.0)
.fcaFee(true)
.length(120)
.width(100)
.height(80)
.dimensionUnit("cm")
.weight(570)
.weightUnit("kg")
.piecesPerUnit(2000)
.stacked(true)
.mixed(true)
.destinations(List.of(
DestinationInput.builder()
.name("Hamburg (KION plant)")
.quantity(60000)
.d2d(false)
.route("Turkey supplier,WH HH,HH")
.customHandling(false)
.build(),
DestinationInput.builder()
.name("Aschaffenburg (KION plant)")
.quantity(60000)
.d2d(false)
.customHandling(false)
.build(),
DestinationInput.builder()
.name("Geisa (KION plant)")
.quantity(60000)
.d2d(false)
.customHandling(false)
.build()
))
.build(),
TestCaseExpected.builder()
.mekA(11.0)
.logisticCost(0.33018468622916425)
.mekB(11.330184686229165)
.fcaFee(0.022)
.transportation(0.05941010149411764)
.d2d(0.0)
.airFreight(0.0)
.custom(0.2058839717254)
.repackaging(0.003)
.handling(0.004015905882352942)
.disposal(0.001411764705882353)
.space(0.007002467458823528)
.capital(0.02746047496258778)
.safetyStock(10)
.destinations(List.of(
DestinationExpected.builder()
.transitTime(6)
.stackedLayers(3)
.containerUnitCount(43)
.containerType("-")
.limitingFactor("Weight")
.build(),
DestinationExpected.builder()
.transitTime(6)
.stackedLayers(3)
.containerUnitCount(43)
.containerType("-")
.limitingFactor("Weight")
.build(),
DestinationExpected.builder()
.transitTime(3)
.stackedLayers(3)
.containerUnitCount(43)
.containerType("-")
.limitingFactor("Weight")
.build()
))
.build()
);
/**
* Test Case 3b: Non-EU supplier, standard - Matrix - Direkt
* Variation of case 3 with standard packaging
*/
public static final TestCase CASE_3B = new TestCase(
"3b",
"Non-EU supplier, standard - Matrix - Direkt",
TestCaseInput.builder()
.partNumber("4222640805")
.supplierName("Turkey supplier")
.loadFromPrevious(false)
.hsCode(84312002)
.tariffRate(1.7)
.price(11.0)
.overseaShare(0.0)
.fcaFee(true)
.length(120)
.width(100)
.height(80)
.dimensionUnit("cm")
.weight(570)
.weightUnit("kg")
.piecesPerUnit(2000)
.stacked(true)
.mixed(true)
.destinations(List.of(
DestinationInput.builder()
.name("Hamburg (KION plant)")
.quantity(60000)
.d2d(false)
.route("Turkey supplier,WH HH,HH")
.customHandling(false)
.build()
))
.build(),
TestCaseExpected.builder()
.mekA(11.0)
.logisticCost(0.33666333119914466)
.mekB(11.336663331199144)
.fcaFee(0.022)
.transportation(0.0644631867)
.d2d(0.0)
.airFreight(0.0)
.custom(0.2094698741739)
.repackaging(0.0)
.handling(0.0054199999999999995)
.disposal(0.0)
.space(0.0057600941999999995)
.capital(0.029550176125244645)
.safetyStock(10)
.destinations(List.of(
DestinationExpected.builder()
.transitTime(6)
.stackedLayers(3)
.containerUnitCount(43)
.containerType("-")
.limitingFactor("Weight")
.build()
))
.build()
);
/**
* Test Case 4: Non-EU supplier, standard - D2D - Über Hop
* D2D transport with customs, large volume
*/
public static final TestCase CASE_4 = new TestCase(
"4",
"Non-EU supplier, standard - D2D - Über Hop",
TestCaseInput.builder()
.partNumber("5512640106")
.supplierName("Turkey supplier")
.loadFromPrevious(false)
.hsCode(84312002)
.tariffRate(3.0)
.price(56.87)
.overseaShare(100.0)
.fcaFee(false)
.length(114)
.width(114)
.height(95)
.dimensionUnit("cm")
.weight(850)
.weightUnit("kg")
.piecesPerUnit(10000)
.stacked(true)
.mixed(true)
.destinations(List.of(
DestinationInput.builder()
.name("Hamburg (KION plant)")
.quantity(1200000)
.d2d(true)
.route("Turkey supplier,WH HH, HH")
.d2dCost(6500.0)
.d2dDuration(47)
.customHandling(false)
.build()
))
.build(),
TestCaseExpected.builder()
.mekA(56.87)
.logisticCost(2.5379307573423042)
.mekB(59.4079307573423)
.fcaFee(0.0)
.transportation(0.0)
.d2d(0.026309523809523814)
.airFreight(0.0)
.custom(1.7083476190476192)
.repackaging(0.0)
.handling(0.0008798333333333333)
.disposal(0.0006)
.space(0.0010396970030999997)
.capital(0.8007540841487281)
.safetyStock(10)
.destinations(List.of(
DestinationExpected.builder()
.transitTime(6)
.stackedLayers(3)
.containerUnitCount(43)
.containerType("-")
.limitingFactor("Weight")
.build()
))
.build()
);
/**
* Test Case 5: EU Supplier, user - D2D - Über Hop
* D2D transport with custom handling costs
*/
public static final TestCase CASE_5 = new TestCase(
"5",
"EU Supplier, user - D2D - Über Hop",
TestCaseInput.builder()
.partNumber("8212640113")
.supplierName("Ireland supplier")
.loadFromPrevious(false)
.hsCode(84312002)
.tariffRate(0.0)
.price(8.0)
.overseaShare(75.0)
.fcaFee(true)
.length(114)
.width(114)
.height(95)
.dimensionUnit("cm")
.weight(850)
.weightUnit("kg")
.piecesPerUnit(2000)
.stacked(true)
.mixed(false)
.destinations(List.of(
DestinationInput.builder()
.name("Hamburg (KION plant)")
.quantity(500)
.d2d(true)
.route("Ireland supplier,WH HH, HH")
.d2dCost(2500.0)
.d2dDuration(12)
.handlingCost(120.0)
.repackingCost(230.0)
.disposalCost(5.0)
.customHandling(true)
.build(),
DestinationInput.builder()
.name("Aschaffenburg (KION plant)")
.quantity(500)
.d2d(true)
.d2dCost(2500.0)
.d2dDuration(12)
.customHandling(false)
.build()
))
.build(),
TestCaseExpected.builder()
.mekA(8.0)
.logisticCost(8.612428329367493)
.mekB(16.612428329367493)
.fcaFee(0.016)
.transportation(0.0)
.d2d(8.0)
.airFreight(0.0)
.custom(0.0)
.repackaging(0.039999999999999994)
.handling(0.245)
.disposal(0.0028333333333333335)
.space(0.16635152049599994)
.capital(0.1422434755381605)
.safetyStock(10)
.destinations(List.of(
DestinationExpected.builder()
.transitTime(12)
.stackedLayers(2)
.containerUnitCount(24)
.containerType("-")
.limitingFactor("Weight")
.build(),
DestinationExpected.builder()
.transitTime(10)
.stackedLayers(2)
.containerUnitCount(24)
.containerType("-")
.limitingFactor("Weight")
.build()
))
.build()
);
/**
* Test Case 6: EU-supplier, standard - D2D - Über Hop
* D2D transport with custom handling, three destinations
*/
public static final TestCase CASE_6 = new TestCase(
"6",
"EU-supplier, standard - D2D - Über Hop",
TestCaseInput.builder()
.partNumber("8212640827")
.supplierName("Hamburg (KION plant)")
.loadFromPrevious(false)
.hsCode(84312002)
.tariffRate(100.0)
.price(18.2)
.overseaShare(0.0)
.fcaFee(false)
.length(1140)
.width(1140)
.height(950)
.dimensionUnit("mm")
.weight(99000)
.weightUnit("g")
.piecesPerUnit(2000)
.stacked(true)
.mixed(false)
.destinations(List.of(
DestinationInput.builder()
.name("Hamburg (KION plant)")
.quantity(4000)
.d2d(true)
.d2dCost(0.01)
.d2dDuration(1)
.handlingCost(0.0)
.repackingCost(0.0)
.disposalCost(0.0)
.customHandling(true)
.build(),
DestinationInput.builder()
.name("Aschaffenburg (KION plant)")
.quantity(4000)
.d2d(true)
.d2dCost(100.0)
.d2dDuration(2)
.customHandling(false)
.build(),
DestinationInput.builder()
.name("Geisa (KION plant)")
.quantity(4000)
.d2d(true)
.d2dCost(150.0)
.d2dDuration(3)
.customHandling(false)
.build()
))
.build(),
TestCaseExpected.builder()
.mekA(18.2)
.logisticCost(0.3853939170089231)
.mekB(18.585393917008922)
.fcaFee(0.0)
.transportation(0.0)
.d2d(0.06923307692307692)
.airFreight(0.0)
.custom(0.0)
.repackaging(0.0025984615384615386)
.handling(0.007116923076923077)
.disposal(0.002653846153846154)
.space(0.028791609316615382)
.capital(0.275)
.safetyStock(10)
.destinations(List.of(
DestinationExpected.builder()
.transitTime(1)
.stackedLayers(2)
.containerUnitCount(20)
.containerType("40 ft.")
.limitingFactor("Volume")
.build(),
DestinationExpected.builder()
.transitTime(2)
.stackedLayers(2)
.containerUnitCount(20)
.containerType("40 ft.")
.limitingFactor("Volume")
.build(),
DestinationExpected.builder()
.transitTime(3)
.stackedLayers(2)
.containerUnitCount(20)
.containerType("40 ft.")
.limitingFactor("Volume")
.build()
))
.build()
);
/**
* Test Case 7: Non-EU supplier, user - D2D - Über Hop
* D2D transport from China with customs and air freight
*/
public static final TestCase CASE_7 = new TestCase(
"7",
"Non-EU supplier, user - D2D - Über Hop",
TestCaseInput.builder()
.partNumber("8222640822")
.supplierName("Yantian supplier")
.loadFromPrevious(false)
.hsCode(84312002)
.tariffRate(3.0)
.price(56.87)
.overseaShare(100.0)
.fcaFee(true)
.length(114)
.width(114)
.height(95)
.dimensionUnit("cm")
.weight(850)
.weightUnit("kg")
.piecesPerUnit(10000)
.stacked(true)
.mixed(false)
.destinations(List.of(
DestinationInput.builder()
.name("Stříbro (KION plant)")
.quantity(50000)
.d2d(true)
.route("Yantian supplier,CNSZX,DEHAM,WH ZBU,STR")
.d2dCost(6500.0)
.d2dDuration(47)
.customHandling(false)
.build()
))
.build(),
TestCaseExpected.builder()
.mekA(56.87)
.logisticCost(5.227211225310775)
.mekB(62.09721122531077)
.fcaFee(0.11374)
.transportation(0.0)
.d2d(0.39)
.airFreight(0.0115344)
.custom(1.7247122)
.repackaging(0.00034144)
.handling(0.00047695999999999996)
.disposal(0.0006)
.space(0.007485818422319998)
.capital(2.9783204068884546)
.safetyStock(100)
.destinations(List.of(
DestinationExpected.builder()
.transitTime(47)
.stackedLayers(2)
.containerUnitCount(20)
.containerType("-")
.limitingFactor("Volume")
.build()
))
.build()
);
/**
* Test Case 8: Non-EU supplier, standard - D2D - Über Hop
* D2D from China (Baoli) with container transport
*/
public static final TestCase CASE_8 = new TestCase(
"8",
"Non-EU supplier, standard - D2D - Über Hop",
TestCaseInput.builder()
.partNumber("8212640827")
.supplierName("KION Baoli (Jiangsu) Forklift Co., Ltd. (KION plant)")
.loadFromPrevious(false)
.hsCode(84312002)
.tariffRate(3.0)
.price(18.2)
.overseaShare(0.0)
.fcaFee(false)
.length(120)
.width(100)
.height(87)
.dimensionUnit("cm")
.weight(99000)
.weightUnit("g")
.piecesPerUnit(150)
.stacked(true)
.mixed(false)
.destinations(List.of(
DestinationInput.builder()
.name("Aschaffenburg (KION plant)")
.quantity(15000)
.d2d(true)
.route("JJ,CNSHA,DEHAM,WH STO,WH ULHA,AB")
.d2dCost(4500.0)
.d2dDuration(47)
.customHandling(false)
.build()
))
.build(),
TestCaseExpected.builder()
.mekA(18.2)
.logisticCost(2.9027159030831053)
.mekB(21.102715903083105)
.fcaFee(0.0)
.transportation(0.0)
.d2d(0.9)
.airFreight(0.0)
.custom(0.58)
.repackaging(0.05173333333333333)
.handling(0.049493333333333334)
.disposal(0.04)
.space(0.3302454007999999)
.capital(0.9512438356164384)
.safetyStock(55)
.destinations(List.of(
DestinationExpected.builder()
.transitTime(47)
.stackedLayers(2)
.containerUnitCount(42)
.containerType("40 ft")
.limitingFactor("Volume")
.build()
))
.build()
);
/**
* Test Case 9: EU Supplier, user - Container - Über Hop
* Container transport with very low quantity
*/
public static final TestCase CASE_9 = new TestCase(
"9",
"EU Supplier, user - Container - Über Hop",
TestCaseInput.builder()
.partNumber("8263500575")
.supplierName("Ireland supplier")
.loadFromPrevious(false)
.hsCode(84312002)
.tariffRate(0.0)
.price(8.0)
.overseaShare(0.0)
.fcaFee(false)
.length(114)
.width(114)
.height(95)
.dimensionUnit("cm")
.weight(850)
.weightUnit("kg")
.piecesPerUnit(20)
.stacked(false)
.mixed(false)
.destinations(List.of(
DestinationInput.builder()
.name("Hamburg (KION plant)")
.quantity(5)
.d2d(false)
.route("Ireland supplier,HH")
.customHandling(false)
.build()
))
.build(),
TestCaseExpected.builder()
.mekA(8.0)
.logisticCost(866.0954912837934)
.mekB(874.0954912837934)
.fcaFee(0.0)
.transportation(836.22)
.d2d(0.0)
.airFreight(0.0)
.custom(0.0)
.repackaging(0.388)
.handling(4.392)
.disposal(0.0)
.space(24.952728074399992)
.capital(0.14276320939334639)
.safetyStock(10)
.destinations(List.of(
DestinationExpected.builder()
.transitTime(3)
.stackedLayers(2)
.containerUnitCount(29)
.containerType("-")
.limitingFactor("Weight")
.build()
))
.build()
);
/**
* Test Case 10: EU-supplier, standard - Container - Über Hop
* Container transport with custom handling costs
*/
public static final TestCase CASE_10 = new TestCase(
"10",
"EU-supplier, standard - Container - Über Hop",
TestCaseInput.builder()
.partNumber("8263500575")
.supplierName("Ireland supplier")
.loadFromPrevious(false)
.hsCode(84312002)
.tariffRate(0.0)
.price(8.0)
.overseaShare(0.0)
.fcaFee(true)
.length(114)
.width(114)
.height(95)
.dimensionUnit("cm")
.weight(850)
.weightUnit("kg")
.piecesPerUnit(20)
.stacked(false)
.mixed(false)
.destinations(List.of(
DestinationInput.builder()
.name("Hamburg (KION plant)")
.quantity(40)
.d2d(false)
.route("Ireland supplier,HH")
.handlingCost(6.0)
.repackingCost(6.0)
.disposalCost(6.0)
.customHandling(true)
.build()
))
.build(),
TestCaseExpected.builder()
.mekA(8.0)
.logisticCost(108.85563974511213)
.mekB(116.85563974511213)
.fcaFee(0.016)
.transportation(104.5275)
.d2d(0.0)
.airFreight(0.0)
.custom(0.0)
.repackaging(0.3)
.handling(0.45)
.disposal(0.3)
.space(3.119091009299999)
.capital(0.1430487358121331)
.safetyStock(10)
.destinations(List.of(
DestinationExpected.builder()
.transitTime(3)
.stackedLayers(2)
.containerUnitCount(29)
.containerType("-")
.limitingFactor("Weight")
.build()
))
.build()
);
/**
* Test Case 11: Non-EU supplier, user - Container - Über Hop
* Container transport from China with air freight
*/
public static final TestCase CASE_11 = new TestCase(
"11",
"Non-EU supplier, user - Container - Über Hop",
TestCaseInput.builder()
.partNumber("8263500575")
.supplierName("Linde (China) Forklift Truck (Supplier) (KION plant)")
.loadFromPrevious(false)
.hsCode(84312002)
.tariffRate(1.7)
.price(8.0)
.overseaShare(75.0)
.fcaFee(true)
.length(114)
.width(114)
.height(95)
.dimensionUnit("cm")
.weight(850)
.weightUnit("kg")
.piecesPerUnit(20)
.stacked(false)
.mixed(false)
.destinations(List.of(
DestinationInput.builder()
.name("Hamburg (KION plant)")
.quantity(900)
.d2d(false)
.route("LX > CNXMN > DEHAM > WH_HH > HH")
.customHandling(false)
.build()
))
.build(),
TestCaseExpected.builder()
.mekA(8.0)
.logisticCost(11.186192)
.mekB(19.186192)
.fcaFee(0.016)
.transportation(7.3)
.d2d(0.0)
.airFreight(3.4603200000000007)
.custom(0.40987200000000007)
.repackaging(0.0)
.handling(0.0)
.disposal(0.0)
.space(0.0)
.capital(0.0)
.safetyStock(10)
.destinations(List.of(
DestinationExpected.builder()
.transitTime(3)
.stackedLayers(2)
.containerUnitCount(29)
.containerType("-")
.limitingFactor("Weight")
.build()
))
.build()
);
/**
* All test cases as a list for parametrized tests.
*/
public static final List<TestCase> 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
);
}

View file

@ -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.
*
* <p>Prerequisites:
* <ul>
* <li>Frontend must be built to src/main/resources/static before running tests</li>
* <li>Run: {@code cd src/frontend && BUILD_FOR_SPRING=true npm run build}</li>
* </ul>
*
* <p>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;
}
}
}

View file

@ -0,0 +1,195 @@
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.
*
* <p>The backend with integrated frontend is started automatically via @SpringBootTest.
*
* <p>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<Arguments> provideTestCases() {
// For debugging: limit to first test case
return TestCases.ALL.stream()
.limit(1) // TODO: Remove limit after debugging result verification
.map(tc -> Arguments.of(tc.id(), tc.name(), tc));
}
}

View file

@ -0,0 +1,169 @@
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.Test;
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")
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<String, Object> 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);
}
private Map<String, Object> 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;
}
}

View file

@ -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.
*
* <p>The backend with integrated frontend is started automatically via @SpringBootTest.
*
* <p>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);
}
}

View file

@ -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<TestCaseDeviation> deviations = new ArrayList<>();
public void addTestCase(String testCaseId, String testCaseName, TestCaseExpected expected, Map<String, Object> 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<FieldDeviation> 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;
}
}
}
}

View file

@ -0,0 +1,175 @@
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<String, Object> actualResults,
TestCaseExpected expected,
double tolerance) {
List<String> 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<Map<String, Object>> actualDestinations = (List<Map<String, Object>>) actualResults.get("destinations");
List<DestinationExpected> expectedDestinations = expected.destinations();
if (actualDestinations == null) {
actualDestinations = List.of();
}
if (expectedDestinations.size() != actualDestinations.size()) {
failures.add(String.format(
"DESTINATION_COUNT: expected %d, got %d",
expectedDestinations.size(), actualDestinations.size()
));
} else {
for (int i = 0; i < expectedDestinations.size(); i++) {
DestinationExpected expDest = expectedDestinations.get(i);
Map<String, Object> actDest = actualDestinations.get(i);
String prefix = "DESTINATION_" + (i + 1) + "_";
compareNumeric(failures, prefix + "TRANSIT_TIME",
(double) expDest.transitTime(),
getDouble(actDest, "transitTime"), tolerance);
compareNumeric(failures, prefix + "STACKED_LAYERS",
(double) expDest.stackedLayers(),
getDouble(actDest, "stackedLayers"), tolerance);
compareNumeric(failures, prefix + "CONTAINER_UNIT_COUNT",
(double) expDest.containerUnitCount(),
getDouble(actDest, "containerUnitCount"), tolerance);
compareString(failures, prefix + "CONTAINER_TYPE",
expDest.containerType(),
getString(actDest, "containerType"));
compareString(failures, prefix + "LIMITING_FACTOR",
expDest.limitingFactor(),
getString(actDest, "limitingFactor"));
}
}
if (!failures.isEmpty()) {
StringBuilder message = new StringBuilder("Result comparison failed:\n");
for (String failure : failures) {
message.append(" - ").append(failure).append("\n");
}
throw new AssertionError(message.toString());
}
logger.info("All results match within tolerance");
}
/**
* Compares two numeric values with tolerance and adds failure message if they don't match.
*/
private static void compareNumeric(List<String> failures, String fieldName,
double expected, Double actual, double tolerance) {
if (actual == null) {
failures.add(String.format("%s: actual value is null, expected %.6f", fieldName, expected));
return;
}
// Handle zero expected values
if (Math.abs(expected) < 1e-10) {
if (Math.abs(actual) > tolerance) {
failures.add(String.format("%s: expected ~0, got %.6f", fieldName, actual));
}
return;
}
double relativeDiff = Math.abs(expected - actual) / Math.abs(expected);
if (relativeDiff > tolerance) {
failures.add(String.format(
"%s: expected %.6f, got %.6f (diff: %.2f%%)",
fieldName, expected, actual, relativeDiff * 100
));
}
}
/**
* Compares two string values and adds failure message if they don't match.
*/
private static void compareString(List<String> failures, String fieldName,
String expected, String actual) {
if (expected == null && actual == null) {
return;
}
if (expected == null || actual == null || !expected.equals(actual)) {
failures.add(String.format("%s: expected '%s', got '%s'", fieldName, expected, actual));
}
}
/**
* Safely gets a Double value from a map.
*/
private static Double getDouble(Map<String, Object> map, String key) {
if (map == null) {
return null;
}
Object value = map.get(key);
if (value == null) {
return null;
}
if (value instanceof Double) {
return (Double) value;
}
if (value instanceof Number) {
return ((Number) value).doubleValue();
}
try {
return Double.parseDouble(value.toString().replaceAll("[€$,\\s]", "").replace(",", "."));
} catch (NumberFormatException e) {
return null;
}
}
/**
* Safely gets a String value from a map.
*/
private static String getString(Map<String, Object> map, String key) {
if (map == null) {
return null;
}
Object value = map.get(key);
return value != null ? value.toString() : null;
}
}

View file

@ -0,0 +1,13 @@
# E2E Test Configuration
# This profile enables scheduling for the CalculationJobProcessor
# Use with: -Dspring.profiles.active=test,e2e,mysql
# Enable scheduling for E2E tests (overrides application-test.properties)
spring.task.scheduling.enabled=true
# Job processor configuration for faster E2E tests
calculation.job.processor.enabled=true
calculation.job.processor.delay=2000
# Shorter timeouts for tests
spring.task.scheduling.pool.size=2

View file

@ -15,9 +15,10 @@ spring.flyway.baseline-on-migrate=true
spring.flyway.clean-disabled=false spring.flyway.clean-disabled=false
# Note: spring.flyway.locations is set in application-mysql.properties or application-mssql.properties # Note: spring.flyway.locations is set in application-mysql.properties or application-mssql.properties
# Security disabled for integration tests # Security configuration for E2E tests
# Note: SecurityAutoConfiguration is NOT excluded - we use devSecurityFilterChain with @Profile("test")
# which permits all requests but still requires HttpSecurity bean
spring.autoconfigure.exclude=\ spring.autoconfigure.exclude=\
org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration,\
org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration,\ org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration,\
org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration,\ org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration,\
org.springframework.boot.autoconfigure.webservices.WebServicesAutoConfiguration,\ org.springframework.boot.autoconfigure.webservices.WebServicesAutoConfiguration,\
@ -26,6 +27,26 @@ spring.autoconfigure.exclude=\
# Disable Azure AD for tests # Disable Azure AD for tests
spring.cloud.azure.active-directory.enabled=false spring.cloud.azure.active-directory.enabled=false
# Azure Maps API - dummy values for E2E tests (real API not called in tests)
azure.maps.subscription.key=test-dummy-key-not-used
azure.maps.api.url=https://atlas.microsoft.com
# JWT configuration for tests
jwt.secret=test-secret-key-for-e2e-tests-must-be-long-enough
jwt.issuer=test-issuer
# LCC configuration for tests
lcc.allowed_cors=*
lcc.allowed_oauth_token_cors=*
lcc.auth.claim.email=email
lcc.auth.claim.firstname=given_name
lcc.auth.claim.lastname=family_name
lcc.auth.claim.workday=workday_id
lcc.auth.claim.ignore.workday=false
lcc.auth.identify.by=workday
lcc.bulk.sheet_password=test-password
lcc.help.static=
# Disable async processing in tests (EUTaxationApiService uses @Async) # Disable async processing in tests (EUTaxationApiService uses @Async)
spring.task.execution.pool.core-size=0 spring.task.execution.pool.core-size=0
spring.task.scheduling.enabled=false spring.task.scheduling.enabled=false

View file

@ -0,0 +1,32 @@
-- E2E Test Data
-- This file creates test users and basic data needed for E2E tests
-- Test Users
INSERT INTO sys_user (workday_id, email, firstname, lastname, is_active)
VALUES ('WD001TEST', 'john.doe@test.com', 'John', 'Doe', TRUE);
INSERT INTO sys_user (workday_id, email, firstname, lastname, is_active)
VALUES ('WD002TEST', 'jane.smith@test.com', 'Jane', 'Smith', TRUE);
INSERT INTO sys_user (workday_id, email, firstname, lastname, is_active)
VALUES ('WD003TEST', 'admin.test@test.com', 'Admin', 'User', TRUE);
-- Assign groups to test users
-- John gets 'super' role for full E2E testing capabilities
-- Jane gets 'super' role
-- Admin gets 'super' and 'right-management' roles
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';
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';
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';
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 = 'right-management';

View file

@ -1,101 +0,0 @@
# conftest.py
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.support.ui import WebDriverWait
from pathlib import Path
import openpyxl
@pytest.fixture(scope="session")
def browser_options():
options = Options()
options.add_argument("--start-maximized")
# options.add_argument("--headless") # Optional für CI/CD
return options
@pytest.fixture(scope="function")
def driver(browser_options):
"""Erstellt einen neuen Browser pro Test"""
driver = webdriver.Chrome(options=browser_options)
driver.implicitly_wait(10)
yield driver
driver.quit()
@pytest.fixture(scope="function")
def wait(driver):
"""WebDriverWait Instanz"""
return WebDriverWait(driver, 20)
@pytest.fixture
def base_url():
"""Basis-URL deiner Anwendung"""
return "http://localhost:5173"
def pytest_generate_tests(metafunc):
"""Parametrisiert Tests basierend auf Excel-Dateien im testdata Ordner"""
if "testcase_file" in metafunc.fixturenames:
testdata_dir = Path("testcases")
excel_files = list(testdata_dir.glob("*.xlsx"))
# IDs für bessere Testausgabe
ids = [f.stem for f in excel_files]
metafunc.parametrize("testcase_file", excel_files, ids=ids)
@pytest.fixture
def test_data(testcase_file):
"""Lädt Testdaten aus Excel-Datei"""
wb = openpyxl.load_workbook(testcase_file)
# Eingabedaten aus "in" Sheet
input_sheet = wb["in"]
input_data = {}
groups = {}
for row in input_sheet.iter_rows(values_only=True):
if row[0]: # Feldname in Spalte A
if '_' in row[0]:
[remainder, suffix] = row[0].rsplit('_',1)
if suffix.isdigit():
[prefix, name] = remainder.split('_',1)
if prefix not in groups:
groups[prefix] = {}
if groups[prefix].get(suffix) is None:
groups[prefix][suffix] = {}
groups[prefix][suffix][name] = row[1]
else:
input_data[row[0]] = row[1]
else:
input_data[row[0]] = row[1] # Wert in Spalte B
input_data.update(groups)
# Erwartete Ausgaben aus "out" Sheet
output_sheet = wb["out"]
expected_data = {}
expected_groups = {}
for row in input_sheet.iter_rows(values_only=True):
if row[0]: # Feldname in Spalte A
if '_' in row[0]:
[remainder, suffix] = row[0].rsplit('_',1)
if suffix.isdigit():
[prefix, name] = remainder.split('_',1)
if prefix not in expected_groups:
expected_groups[prefix] = {}
if expected_groups[prefix].get(suffix) is None:
expected_groups[prefix][suffix] = {}
expected_groups[prefix][suffix][name] = row[1]
else:
expected_data[row[0]] = row[1]
else:
expected_data[row[0]] = row[1] # Wert in Spalte B
expected_data.update(expected_groups)
return {
"input": input_data,
"expected": expected_data,
"filename": testcase_file.name
}

View file

@ -1,46 +0,0 @@
import time
from selenium.webdriver.common.by import By
from conftest import driver
from pages.base_page import BasePage
class Assistant(BasePage):
"""Page Object für den Assistant"""
# Mapping von Excel-Feldnamen zu Locators
FIELD_MAPPING = {
"PART_NUMBER": (By.CSS_SELECTOR, "textarea[name='partNumbers']"),
"ANALYZE_BUTTON": (By.CSS_SELECTOR, ".part-number-modal-action > .btn--primary"),
"SUPPLIER_NAME": (By.CSS_SELECTOR, ".supplier-headers-searchbar-container .search-input"),
"LOAD_FROM_PREVIOUS": (By.CSS_SELECTOR, ".start-calculation-footer-container .checkbox-item"),
"CREATE_CALCULATION_BUTTON": (By.CSS_SELECTOR, ".start-calculation-footer-container .btn--secondary"),
"DELETE_SUPPLIER_BUTTON": (By.CSS_SELECTOR, ".supplier-headers ~ .item-list .item-list-element .icon-btn-container .icon-btn"),
}
def search_part_numbers(self, data_dict):
"""Füllt das Formular mit Daten aus dem Excel"""
self.fill_input(*self.FIELD_MAPPING["PART_NUMBER"], data_dict["PART_NUMBER"])
self.click_button(*self.FIELD_MAPPING["ANALYZE_BUTTON"])
def delete_preselected_suppliers(self):
while True:
try:
button = self.wait_for_clickable(
*self.FIELD_MAPPING["DELETE_SUPPLIER_BUTTON"],
timeout=1
)
button.click()
time.sleep(0.2)
except:
# Keine Buttons mehr vorhanden
break
def select_supplier(self, data_dict):
self.search_and_select_autosuggest(*self.FIELD_MAPPING["SUPPLIER_NAME"], data_dict["SUPPLIER_NAME"])
def create_calculation(self, data_dict):
self.set_checkbox(*self.FIELD_MAPPING["LOAD_FROM_PREVIOUS"], data_dict["LOAD_FROM_PREVIOUS"])
self.click_button(*self.FIELD_MAPPING["CREATE_CALCULATION_BUTTON"])

View file

@ -1,147 +0,0 @@
# pages/base_page.py
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
from selenium.common.exceptions import TimeoutException
import time
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class BasePage:
"""Basis-Klasse für alle Page Objects"""
def __init__(self, driver, wait):
self.driver = driver
self.wait = wait
def wait_for_spa_navigation(self, expected_route_part, timeout=2):
"""Wartet bis SPA zur erwarteten Route navigiert hat"""
WebDriverWait(self.driver, timeout).until(
lambda d: expected_route_part in d.current_url
)
# Zusätzlich auf Vue-Rendering warten
time.sleep(0.5)
def wait_for_element(self, by, value, timeout=2):
"""Wartet auf ein Element"""
start_time = time.time()
logger.info(f"Waiting for element: {by}={value}")
result = WebDriverWait(self.driver, timeout).until(
EC.presence_of_element_located((by, value))
)
elapsed = time.time() - start_time
logger.info(f"Found element after {elapsed:.2f}s")
return result
def wait_for_clickable(self, by, value, timeout=2):
"""Wartet bis Element klickbar ist"""
return WebDriverWait(self.driver, timeout).until(
EC.element_to_be_clickable((by, value))
)
def fill_input(self, by, value, text, check_existence=False, timeout=2):
if check_existence:
# Prüfe ob Element existiert
try:
element = WebDriverWait(self.driver, timeout).until(
EC.presence_of_element_located((by, value))
)
logger.info(f"Element exists, filling...")
except TimeoutException:
logger.warning(f"Element does not exist, skipping (check_existence=True)")
return False
else:
# Normaler Modus - erwarte dass Element existiert
element = self.wait_for_element(by, value, timeout)
# Element existiert - jetzt füllen
element.clear()
element.send_keys(text)
logger.info(f"Filled input with: {text}")
return True
def click_button(self, by, value):
"""Klickt einen Button"""
start_time = time.time()
logger.info(f"Clicking button: {by}={value}")
button = self.wait_for_clickable(by, value)
button.click()
elapsed = time.time() - start_time
logger.info(f"Clicked after {elapsed:.2f}s")
def set_checkbox(self, by, value, checked, timeout=2):
label = self.wait_for_clickable(by, value, timeout)
checkbox_input = label.find_element(By.CSS_SELECTOR, "input[type='checkbox']")
is_checked = checkbox_input.is_selected()
if is_checked != checked:
label.click()
time.sleep(0.3)
def select_dropdown_option(self, by, value, option_text, timeout=10):
dropdown_button = self.wait_for_element(by, value, timeout=timeout)
try:
current_value = dropdown_button.find_element(
By.CSS_SELECTOR,
"span.dropdown-trigger-text"
).text
if current_value == option_text:
logger.info(f"Dropdown already has value: {option_text}")
return
except:
pass # Falls kein Text gefunden wurde, öffne das Dropdown
dropdown_button.click()
logger.info("Opened dropdown")
menu = WebDriverWait(self.driver, timeout).until(
EC.visibility_of_element_located((By.CSS_SELECTOR, "ul.dropdown-menu"))
)
logger.info("Dropdown menu visible")
option_xpath = f"//li[contains(@class, 'dropdown-option')][normalize-space(text())='{option_text}']"
option = WebDriverWait(self.driver, timeout).until(
EC.element_to_be_clickable((By.XPATH, option_xpath))
)
logger.info(f"Clicking option: {option_text}")
option.click()
time.sleep(0.2)
def search_and_select_autosuggest(self, by_or_selector, value_or_search_text,
search_text=None,
suggestion_selector=".suggestion-item",
timeout=2):
if search_text is not None:
# Fall: (By.CSS_SELECTOR, ".selector", "search_text")
search_input = self.wait_for_element(by_or_selector, value_or_search_text, timeout)
text_to_search = search_text
else:
# Fall: (".selector", "search_text")
search_input = self.wait_for_element(By.CSS_SELECTOR, by_or_selector, timeout)
text_to_search = value_or_search_text
search_input.clear()
search_input.send_keys(text_to_search)
time.sleep(1)
suggestion = WebDriverWait(self.driver, timeout).until(
EC.element_to_be_clickable((By.CSS_SELECTOR, suggestion_selector))
)
suggestion.click()
time.sleep(0.5)

View file

@ -1,157 +0,0 @@
# pages/calculation_page.py
from selenium.webdriver.common.by import By
from pages.base_page import BasePage
import logging
logger = logging.getLogger(__name__)
class CalculationPage(BasePage):
"""Page Object für die Berechnungsformulare"""
# WICHTIG: Verwende data-v-* Attribute NUR wenn sie WIRKLICH stabil sind
# Besser: Positionsbasierte Selektoren mit aussagekräftigen Parent-Elementen
FIELD_MAPPING = {
# Material-Sektion (erste Box)
"HS_CODE": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][1]//div[contains(@class, 'caption-column')][text()='HS code']"
"/following-sibling::div//input[@class='input-field']"
),
"TARIFF_RATE": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][1]//div[contains(@class, 'caption-column')][contains(text(), 'Tariff rate')]"
"/following-sibling::div//input[@class='input-field']"
),
# Price-Sektion (zweite Box)
"PRICE": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][2]//div[contains(@class, 'caption-column')][text()='MEK_A [EUR]']"
"/following-sibling::div//input[@class='input-field']"
),
"OVERSEA_SHARE": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][2]//div[contains(@class, 'caption-column')][contains(text(), 'Oversea share')]"
"/following-sibling::div//input[@class='input-field']"
),
# Handling Unit-Sektion (dritte Box)
"LENGTH": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU length']"
"/following-sibling::div//input[@class='input-field']"
),
"WIDTH": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU width']"
"/following-sibling::div//input[@class='input-field']"
),
"HEIGHT": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU height']"
"/following-sibling::div//input[@class='input-field']"
),
"WEIGHT": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU weight']"
"/following-sibling::div//input[@class='input-field']"
),
"PIECES_UNIT": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='Pieces per HU']"
"/following-sibling::div//input[@class='input-field']"
),
# Dropdowns
"DIMENSION_UNIT": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='Dimension unit']"
"/following-sibling::div//button[contains(@class, 'dropdown-trigger')]"
),
"WEIGHT_UNIT": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='Weight unit']"
"/following-sibling::div//button[contains(@class, 'dropdown-trigger')]"
),
# Checkboxen
"FBA_FEE": (
By.XPATH,
"//div[contains(@class, 'master-data-item')][2]"
"//label[contains(@class, 'checkbox-item')]"
"[.//span[contains(@class, 'checkbox-label')][normalize-space(text())='']]"
),
"MIXED": (
By.XPATH,
"//label[contains(@class, 'checkbox-item')]"
"[.//span[contains(@class, 'checkbox-label')][text()='Mixable']]"
),
"STACKED": (
By.XPATH,
"//label[contains(@class, 'checkbox-item')]"
"[.//span[contains(@class, 'checkbox-label')][text()='Stackable']]"
),
}
DEST_FIELD_MAPPING = {
"NAME": (By.XPATH, "//input[@placeholder='Add new Destination ...']"),
"QUANTITY": (By.XPATH,
"//div[contains(@class, 'destination-edit-column-caption') and contains(text(), 'Annual quantity')]/following-sibling::div[1]//input[@class='input-field']")
"ROUTING": (By.XPATH,
"//input[@type='radio' and @name='model' and @value='routing']"),
"D2D": (By.XPATH,
"//input[@type='radio' and @name='model' and @value='d2d']"),
"ROUTE": (By.XPATH,
"//div[@class='destination-route-container']//div[contains(@class, 'destination-route-inner-container')][.//span[contains(text(), 'Ireland Su')] and .//span[contains(text(), 'WH ULHA')] and .//span[contains(text(), 'AB')]]")
"HANDLING_TAB": (By.XPATH, "//button[@class='tab-header' and text()='Handling & Repackaging']"),
"CUSTOM_HANDLING": (By.XPATH,
"//div[@class='destination-edit-handling-cost']//label[@class='checkbox-item']/input[@type='checkbox']"),
"REPACKING": (By.XPATH,
"//div[@class='destination-edit-column-caption' and contains(text(), 'Repackaging cost')]/following-sibling::div[@class='destination-edit-column-data'][1]//input[@class='input-field']"),
"HANDLING": (By.XPATH,
"//div[@class='destination-edit-column-caption' and contains(text(), 'Handling cost')]/following-sibling::div[@class='destination-edit-column-data'][1]//input[@class='input-field']"),
"DISPOSAL": (By.XPATH,
"//div[@class='destination-edit-column-caption' and contains(text(), 'Disposal cost')]/following-sibling::div[@class='destination-edit-column-data'][1]//input[@class='input-field']"),
}
# Buttons
CALCULATE_CLOSE_BUTTON = (By.XPATH, "//button[contains(., 'Calculate & close')]")
CLOSE_BUTTON = (By.XPATH, "//button[contains(., 'Close') and not(contains(., 'Calculate'))]")
def fill_form(self, data_dict):
"""Füllt das Formular mit Daten aus dem Excel"""
for field_name, locator in self.FIELD_MAPPING.items():
value = data_dict[field_name]
logger.info(f"Filling field: {field_name} = {value}")
try:
if field_name in ["FBA_FEE", "STACKED", "MIXED"]:
self.set_checkbox(*locator, str(value) == 'True')
elif field_name in ["DIMENSION_UNIT", "WEIGHT_UNIT"]:
self.select_dropdown_option(*locator, str(value))
else:
self.fill_input(*locator, str(value), check_existence=field_name in ["HS_CODE", "TARIFF_RATE"])
except Exception as e:
logger.error(f"Failed to fill field {field_name}: {e}")
self.driver.save_screenshot(f"failed_field_{field_name}.png")
raise Exception(f"Could not fill field '{field_name}': {e}") from e
def add_destination(self, data_dict):
self.search_and_select_autosuggest(*self.DEST_FIELD_MAPPING["NAME"], data_dict["NAME"])
def fill_destination(self, data_dict):
self.wait_for_element(*self.DEST_FIELD_MAPPING["QUANTITY"])
pass
def click_calculate_and_close(self):
"""Klickt auf 'Calculate & close' Button"""
self.click_button(*self.CALCULATE_CLOSE_BUTTON)
def click_close(self):
"""Klickt auf 'Close' Button"""
self.click_button(*self.CLOSE_BUTTON)

View file

@ -1,53 +0,0 @@
# pages/results_page.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
import time
class DevPage:
MODAL_YES_BUTTON = (By.CSS_SELECTOR, "div.modal-dialog-actions button.btn--primary")
MODAL_NO_BUTTON = (By.CSS_SELECTOR, "div.modal-dialog-actions button.btn--secondary")
def __init__(self, driver, wait):
self.driver = driver
self.wait = wait
def dev_login(self, base_url, user_name="John"):
"""
Simuliert Login über /dev Seite
Args:
base_url: Basis-URL der Anwendung
user_name: Vorname des Users (z.B. "John", "Sarah", "Mike")
"""
# Navigiere zur Dev-Seite
self.driver.get(f"{base_url}/dev")
# Warte bis Tabelle geladen ist
self.wait.until(
EC.presence_of_element_located((By.CSS_SELECTOR, "table.data-table"))
)
# Finde die User-Row anhand des Vornamens
# Die Tabelle hat Spalten: First name | Last name | E-Mail | ...
rows = self.driver.find_elements(By.CSS_SELECTOR, "table.data-table tbody tr.table-row")
for row in rows:
cells = row.find_elements(By.TAG_NAME, "td")
if cells and user_name in cells[0].text: # cells[0] ist "First name"
# Klicke auf die Row
self.wait.until(EC.element_to_be_clickable(row))
row.click()
break
else:
raise Exception(f"User '{user_name}' nicht in der Dev-User-Tabelle gefunden")
# Warte auf Modal und klicke "Yes"
yes_button = self.wait.until(
EC.element_to_be_clickable(self.MODAL_YES_BUTTON)
)
yes_button.click()
# Warte bis Modal geschlossen ist
self.wait.until(
EC.invisibility_of_element_located((By.CSS_SELECTOR, "div.modal-container"))
)

View file

@ -1,29 +0,0 @@
# pages/navigation.py
from selenium.webdriver.common.by import By
from pages.assistant import Assistant
from pages.base_page import BasePage
from pages.calculation_page import CalculationPage
from pages.results_page import ResultsPage
class Navigation(BasePage):
"""Handhabt die SPA-Navigation"""
# Locators für Navigationselemente
MENU_BUTTON = (By.CSS_SELECTOR, "button.menu-toggle")
NEW_CALCULATION_LINK = (By.CSS_SELECTOR, "a[href*='/assistant']")
RESULTS_LINK = (By.CSS_SELECTOR, "a[href*='/results']")
def start_calculation(self, base_url):
"""Navigiert zur Berechnungsseite"""
self.driver.get(base_url+"/assistant")
self.wait_for_spa_navigation("/assistant")
return Assistant(self.driver, self.wait)
def navigate_to_results(self):
"""Navigiert zur Ergebnisseite"""
self.click_button(*self.RESULTS_LINK)
self.wait_for_spa_navigation("/results")
return ResultsPage(self.driver, self.wait)

View file

@ -1,35 +0,0 @@
# pages/results_page.py
from selenium.webdriver.common.by import By
from pages.base_page import BasePage
class ResultsPage(BasePage):
"""Page Object für die Ergebnisseite"""
# Mapping von Excel-Feldnamen zu Locators
RESULT_MAPPING = {
"gesamtkosten": (By.ID, "result-total-cost"),
"zollgebuehr": (By.ID, "result-customs-fee"),
"transportkosten": (By.ID, "result-transport-cost"),
"mehrwertsteuer": (By.ID, "result-vat"),
# Weitere Ergebnisfelder...
}
def wait_for_results(self):
"""Wartet bis Ergebnisse geladen sind"""
self.wait_for_element(By.CSS_SELECTOR, ".results-container.loaded")
def read_results(self):
"""Liest alle Ergebniswerte aus"""
self.wait_for_results()
results = {}
for field_name, locator in self.RESULT_MAPPING.items():
try:
element = self.wait_for_element(*locator)
results[field_name] = element.text
except Exception as e:
print(f"Fehler beim Lesen von '{field_name}': {e}")
results[field_name] = None
return results

View file

@ -1,18 +0,0 @@
[pytest]
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)s] %(message)s
log_cli_date_format = %H:%M:%S
testpaths = .
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--tb=short
--capture=no
--html=reports/report.html
--self-contained-html
markers =
smoke: Smoke tests
regression: Regression tests

View file

@ -1,5 +0,0 @@
pytest==9.0.2
selenium==4.39.0
openpyxl==3.1.5
pytest-html==4.1.1
webdriver-manager==4.0.2

View file

@ -1,70 +0,0 @@
# tests/test_calculation.py
import pytest
from pages.dev_page import DevPage
from pages.navigation import Navigation
from pages.calculation_page import CalculationPage
from pages.results_page import ResultsPage
class TestCalculation:
"""Testklasse für Berechnungslogik"""
def test_calculation_workflow(self, driver, wait, base_url, test_data):
"""
Haupttest: Führt einen kompletten Berechnungsdurchlauf durch
Wird automatisch für jede Excel-Datei parametrisiert
"""
login_page = DevPage(driver, wait)
login_page.dev_login(base_url, user_name="John")
# Setup assistant
nav = Navigation(driver, wait)
assistant = nav.start_calculation(base_url)
assistant.search_part_numbers(test_data["input"])
assistant.delete_preselected_suppliers()
assistant.select_supplier(test_data["input"])
assistant.create_calculation(test_data["input"])
# Fill calculation page
calc_page = CalculationPage(driver, wait)
calc_page.fill_form(test_data["input"])
for destination in test_data["input"]["DESTINATION"].values():
calc_page.add_destination(destination)
calc_page.fill_destination(destination)
# Ergebnisse lesen
results_page = ResultsPage(driver, wait)
actual_results = results_page.read_results()
# Assertions: Vergleich mit erwarteten Werten aus "out" Sheet
expected = test_data["expected"]
for field_name, expected_value in expected.items():
actual_value = actual_results.get(field_name)
# Numerische Werte mit Toleranz vergleichen
if self._is_numeric(expected_value):
expected_num = float(expected_value)
actual_num = float(actual_value.replace("", "").replace(",", ".").strip())
assert pytest.approx(expected_num, rel=0.01) == actual_num, \
f"Feld '{field_name}': Erwartet {expected_num}, erhalten {actual_num}"
else:
# String-Vergleich
assert str(actual_value).strip() == str(expected_value).strip(), \
f"Feld '{field_name}': Erwartet '{expected_value}', erhalten '{actual_value}'"
print(f"✓ Test erfolgreich für {test_data['filename']}")
@staticmethod
def _is_numeric(value):
"""Prüft ob Wert numerisch ist"""
try:
float(str(value).replace(",", "."))
return True
except (ValueError, AttributeError):
return False