wip: intermediate commit. testscripts running, but with high deviations.
This commit is contained in:
parent
b708af177f
commit
26986b1131
38 changed files with 4800 additions and 664 deletions
21
.claude/settings.local.json
Normal file
21
.claude/settings.local.json
Normal 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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
49
CLAUDE.md
49
CLAUDE.md
|
|
@ -43,6 +43,55 @@ mvn clean install -DskipTests
|
|||
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
|
||||
|
||||
### Layered Architecture
|
||||
|
|
|
|||
0
mvnw
vendored
Normal file → Executable file
0
mvnw
vendored
Normal file → Executable file
8
pom.xml
8
pom.xml
|
|
@ -236,6 +236,14 @@
|
|||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Playwright for E2E testing -->
|
||||
<dependency>
|
||||
<groupId>com.microsoft.playwright</groupId>
|
||||
<artifactId>playwright</artifactId>
|
||||
<version>1.48.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
|
|
|||
|
|
@ -81,9 +81,15 @@ public class PremiseController {
|
|||
@GetMapping({"/search", "/search/"})
|
||||
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')")
|
||||
public ResponseEntity<PremiseSearchResultDTO> findMaterialsAndSuppliers(@RequestParam String search) {
|
||||
log.info("Search request received with query: '{}' (length: {})", search, search != null ? search.length() : 0);
|
||||
|
||||
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) {
|
||||
throw new BadRequestException("Bad string encoding", "Unable to decode request", e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
123
src/test/java/de/avatic/lcc/e2e/config/TestAutoLoginFilter.java
Normal file
123
src/test/java/de/avatic/lcc/e2e/config/TestAutoLoginFilter.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
221
src/test/java/de/avatic/lcc/e2e/pages/AssistantPage.java
Normal file
221
src/test/java/de/avatic/lcc/e2e/pages/AssistantPage.java
Normal 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());
|
||||
}
|
||||
}
|
||||
209
src/test/java/de/avatic/lcc/e2e/pages/BasePage.java
Normal file
209
src/test/java/de/avatic/lcc/e2e/pages/BasePage.java
Normal 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")));
|
||||
}
|
||||
}
|
||||
654
src/test/java/de/avatic/lcc/e2e/pages/CalculationEditPage.java
Normal file
654
src/test/java/de/avatic/lcc/e2e/pages/CalculationEditPage.java
Normal 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);
|
||||
}
|
||||
}
|
||||
86
src/test/java/de/avatic/lcc/e2e/pages/DevLoginPage.java
Normal file
86
src/test/java/de/avatic/lcc/e2e/pages/DevLoginPage.java
Normal 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);
|
||||
}
|
||||
}
|
||||
565
src/test/java/de/avatic/lcc/e2e/pages/ResultsPage.java
Normal file
565
src/test/java/de/avatic/lcc/e2e/pages/ResultsPage.java
Normal 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
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/test/java/de/avatic/lcc/e2e/testdata/DestinationExpected.java
vendored
Normal file
55
src/test/java/de/avatic/lcc/e2e/testdata/DestinationExpected.java
vendored
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
91
src/test/java/de/avatic/lcc/e2e/testdata/DestinationInput.java
vendored
Normal file
91
src/test/java/de/avatic/lcc/e2e/testdata/DestinationInput.java
vendored
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/test/java/de/avatic/lcc/e2e/testdata/TestCase.java
vendored
Normal file
12
src/test/java/de/avatic/lcc/e2e/testdata/TestCase.java
vendored
Normal 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
|
||||
) {
|
||||
}
|
||||
128
src/test/java/de/avatic/lcc/e2e/testdata/TestCaseExpected.java
vendored
Normal file
128
src/test/java/de/avatic/lcc/e2e/testdata/TestCaseExpected.java
vendored
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
150
src/test/java/de/avatic/lcc/e2e/testdata/TestCaseInput.java
vendored
Normal file
150
src/test/java/de/avatic/lcc/e2e/testdata/TestCaseInput.java
vendored
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
885
src/test/java/de/avatic/lcc/e2e/testdata/TestCases.java
vendored
Normal file
885
src/test/java/de/avatic/lcc/e2e/testdata/TestCases.java
vendored
Normal 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
|
||||
);
|
||||
}
|
||||
443
src/test/java/de/avatic/lcc/e2e/tests/AbstractE2ETest.java
Normal file
443
src/test/java/de/avatic/lcc/e2e/tests/AbstractE2ETest.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
110
src/test/java/de/avatic/lcc/e2e/tests/SmokeE2ETest.java
Normal file
110
src/test/java/de/avatic/lcc/e2e/tests/SmokeE2ETest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
182
src/test/java/de/avatic/lcc/e2e/util/DeviationReport.java
Normal file
182
src/test/java/de/avatic/lcc/e2e/util/DeviationReport.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
175
src/test/java/de/avatic/lcc/e2e/util/ResultComparator.java
Normal file
175
src/test/java/de/avatic/lcc/e2e/util/ResultComparator.java
Normal 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;
|
||||
}
|
||||
}
|
||||
13
src/test/resources/application-e2e.properties
Normal file
13
src/test/resources/application-e2e.properties
Normal 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
|
||||
|
|
@ -15,9 +15,10 @@ spring.flyway.baseline-on-migrate=true
|
|||
spring.flyway.clean-disabled=false
|
||||
# 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=\
|
||||
org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration,\
|
||||
org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration,\
|
||||
org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration,\
|
||||
org.springframework.boot.autoconfigure.webservices.WebServicesAutoConfiguration,\
|
||||
|
|
@ -26,6 +27,26 @@ spring.autoconfigure.exclude=\
|
|||
# Disable Azure AD for tests
|
||||
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)
|
||||
spring.task.execution.pool.core-size=0
|
||||
spring.task.scheduling.enabled=false
|
||||
|
|
|
|||
32
src/test/resources/e2e/e2e-testdata.sql
Normal file
32
src/test/resources/e2e/e2e-testdata.sql
Normal 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';
|
||||
101
test/conftest.py
101
test/conftest.py
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"])
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"))
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue