Merge pull request 'feature/systemtests' (#109) from feature/systemtests into dev
Reviewed-on: #109
This commit is contained in:
commit
75060714b5
40 changed files with 5021 additions and 665 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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
74
.gitea/workflows/test.yml
Normal file
74
.gitea/workflows/test.yml
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
ALLURE_SERVER: "http://10.80.0.6:5050"
|
||||||
|
ALLURE_PROJECT: "lcc"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Java 23
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '23'
|
||||||
|
cache: 'maven'
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
run: mvn verify -B --no-transfer-progress
|
||||||
|
env:
|
||||||
|
TESTCONTAINERS_RYUK_DISABLED: "true"
|
||||||
|
|
||||||
|
- name: Prepare Allure Results
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
cat > target/allure-results/executor.json << EOF
|
||||||
|
{
|
||||||
|
"name": "Gitea Actions",
|
||||||
|
"type": "gitea",
|
||||||
|
"buildName": "#${{ gitea.run_number }}",
|
||||||
|
"buildOrder": ${{ gitea.run_number }},
|
||||||
|
"buildUrl": "${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Upload to Allure
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
# Projekt anlegen falls nicht vorhanden
|
||||||
|
curl -s -o /dev/null \
|
||||||
|
-u admin:${{ secrets.ALLURE_PASSWORD }} \
|
||||||
|
-X POST "${ALLURE_SERVER}/allure-docker-service/projects" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"id": "'${ALLURE_PROJECT}'"}' || true
|
||||||
|
|
||||||
|
# Results aufräumen
|
||||||
|
curl -s \
|
||||||
|
-u admin:${{ secrets.ALLURE_PASSWORD }} \
|
||||||
|
"${ALLURE_SERVER}/allure-docker-service/clean-results?project_id=${ALLURE_PROJECT}"
|
||||||
|
|
||||||
|
# Results hochladen
|
||||||
|
for f in target/allure-results/*; do
|
||||||
|
[ -f "$f" ] && curl -s \
|
||||||
|
-u admin:${{ secrets.ALLURE_PASSWORD }} \
|
||||||
|
-X POST "${ALLURE_SERVER}/allure-docker-service/send-results?project_id=${ALLURE_PROJECT}" \
|
||||||
|
-F "results[]=@$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Report generieren
|
||||||
|
curl -s \
|
||||||
|
-u admin:${{ secrets.ALLURE_PASSWORD }} \
|
||||||
|
"${ALLURE_SERVER}/allure-docker-service/generate-report?project_id=${ALLURE_PROJECT}"
|
||||||
49
CLAUDE.md
49
CLAUDE.md
|
|
@ -43,6 +43,55 @@ mvn clean install -DskipTests
|
||||||
mvn jaxb:generate
|
mvn jaxb:generate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Development Environment (Distrobox)
|
||||||
|
|
||||||
|
**IMPORTANT:** This project runs inside a **Distrobox** container. This affects how TestContainers and Podman work.
|
||||||
|
|
||||||
|
### TestContainers with Distrobox + Podman
|
||||||
|
|
||||||
|
TestContainers needs access to the **host's Podman socket**, not the one inside the Distrobox. The configuration is handled via `~/.testcontainers.properties`:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
docker.host=unix:///run/host/run/user/1000/podman/podman.sock
|
||||||
|
ryuk.disabled=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Troubleshooting TestContainers / Podman Issues
|
||||||
|
|
||||||
|
If tests fail with "Could not find a valid Docker environment":
|
||||||
|
|
||||||
|
1. **Check if Podman works on the host:**
|
||||||
|
```bash
|
||||||
|
distrobox-host-exec podman info
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **If you see cgroup or UID/GID errors, run migration on the host:**
|
||||||
|
```bash
|
||||||
|
distrobox-host-exec podman system migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Restart podman socket on host if needed:**
|
||||||
|
```bash
|
||||||
|
distrobox-host-exec systemctl --user restart podman.socket
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Verify the host socket is accessible from Distrobox:**
|
||||||
|
```bash
|
||||||
|
ls -la /run/host/run/user/1000/podman/podman.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Test container execution via host:**
|
||||||
|
```bash
|
||||||
|
distrobox-host-exec podman run --rm hello-world
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Paths
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `/run/host/run/user/1000/podman/podman.sock` | Host's Podman socket (accessible from Distrobox) |
|
||||||
|
| `~/.testcontainers.properties` | TestContainers configuration file |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Layered Architecture
|
### Layered Architecture
|
||||||
|
|
|
||||||
0
mvnw
vendored
Normal file → Executable file
0
mvnw
vendored
Normal file → Executable file
38
pom.xml
38
pom.xml
|
|
@ -31,8 +31,16 @@
|
||||||
<spring-cloud-azure.version>5.24.1</spring-cloud-azure.version>
|
<spring-cloud-azure.version>5.24.1</spring-cloud-azure.version>
|
||||||
<mockito.version>5.20.0</mockito.version>
|
<mockito.version>5.20.0</mockito.version>
|
||||||
<flyway.version>11.18.0</flyway.version>
|
<flyway.version>11.18.0</flyway.version>
|
||||||
|
<surefire.excludedGroups>analysis</surefire.excludedGroups>
|
||||||
</properties>
|
</properties>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<!-- Allure -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.qameta.allure</groupId>
|
||||||
|
<artifactId>allure-junit5</artifactId>
|
||||||
|
<version>2.29.0</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||||
|
|
@ -236,6 +244,21 @@
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Playwright for E2E testing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.microsoft.playwright</groupId>
|
||||||
|
<artifactId>playwright</artifactId>
|
||||||
|
<version>1.48.0</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.aspectj</groupId>
|
||||||
|
<artifactId>aspectjweaver</artifactId>
|
||||||
|
<version>1.9.21</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
|
@ -251,6 +274,7 @@
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.codehaus.mojo</groupId>
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
<artifactId>versions-maven-plugin</artifactId>
|
<artifactId>versions-maven-plugin</artifactId>
|
||||||
|
|
@ -276,15 +300,27 @@
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
|
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
<version>3.5.4</version>
|
<version>3.5.4</version>
|
||||||
<configuration>
|
<configuration>
|
||||||
<argLine>
|
<argLine>
|
||||||
-javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar
|
-javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar
|
||||||
|
-javaagent:${settings.localRepository}/org/aspectj/aspectjweaver/1.9.22/aspectjweaver-1.9.22.jar
|
||||||
</argLine>
|
</argLine>
|
||||||
|
<systemPropertyVariables>
|
||||||
|
<allure.results.directory>${project.build.directory}/allure-results</allure.results.directory>
|
||||||
|
</systemPropertyVariables>
|
||||||
|
<!-- Exclude analysis tests by default -->
|
||||||
|
<excludedGroups>${surefire.excludedGroups}</excludedGroups>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.aspectj</groupId>
|
||||||
|
<artifactId>aspectjweaver</artifactId>
|
||||||
|
<version>1.9.21</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,15 @@ public class PremiseController {
|
||||||
@GetMapping({"/search", "/search/"})
|
@GetMapping({"/search", "/search/"})
|
||||||
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')")
|
@PreAuthorize("hasAnyRole('SUPER', 'CALCULATION')")
|
||||||
public ResponseEntity<PremiseSearchResultDTO> findMaterialsAndSuppliers(@RequestParam String search) {
|
public ResponseEntity<PremiseSearchResultDTO> findMaterialsAndSuppliers(@RequestParam String search) {
|
||||||
|
log.info("Search request received with query: '{}' (length: {})", search, search != null ? search.length() : 0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return ResponseEntity.ok(premiseSearchStringAnalyzerService.findMaterialAndSuppliers(search));
|
var result = premiseSearchStringAnalyzerService.findMaterialAndSuppliers(search);
|
||||||
|
log.info("Search result: {} materials, {} suppliers, {} user suppliers",
|
||||||
|
result.getMaterials() != null ? result.getMaterials().size() : 0,
|
||||||
|
result.getSupplier() != null ? result.getSupplier().size() : 0,
|
||||||
|
result.getUserSupplier() != null ? result.getUserSupplier().size() : 0);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new BadRequestException("Bad string encoding", "Unable to decode request", e);
|
throw new BadRequestException("Bad string encoding", "Unable to decode request", e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
679
src/test/java/de/avatic/lcc/e2e/pages/CalculationEditPage.java
Normal file
679
src/test/java/de/avatic/lcc/e2e/pages/CalculationEditPage.java
Normal file
|
|
@ -0,0 +1,679 @@
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Always try click simulation as the primary method - it's most reliable
|
||||||
|
logger.info("Using click simulation to select route");
|
||||||
|
Locator routeToClick = allRoutes.nth(routeIndexToSelect);
|
||||||
|
simulateRobustClick(routeToClick);
|
||||||
|
|
||||||
|
// Wait for UI update
|
||||||
|
page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify selection worked
|
||||||
|
boolean selected = verifyRouteSelectionVisual(allRoutes.nth(routeIndexToSelect));
|
||||||
|
|
||||||
|
// If click didn't work, try Pinia as fallback
|
||||||
|
if (!selected) {
|
||||||
|
logger.info("Click simulation didn't select route, trying Pinia direct access");
|
||||||
|
Object piniaResult = tryPiniaDirectAccess(routeIndexToSelect);
|
||||||
|
final Object piniaResultFinal = piniaResult;
|
||||||
|
logger.info(() -> "Pinia direct access result: " + piniaResultFinal);
|
||||||
|
page.waitForTimeout(300);
|
||||||
|
selected = verifyRouteSelectionVisual(allRoutes.nth(routeIndexToSelect));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
logger.warning(() -> "Route selection may have failed for index " + 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.
|
||||||
|
* @return true if the route appears selected, false otherwise
|
||||||
|
*/
|
||||||
|
private boolean 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);
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warning(() -> "Could not verify route selection: " + e.getMessage());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find exact matching route from DOM elements.
|
||||||
|
* The route must contain all spec segments in order, and the route text
|
||||||
|
* (when normalized) should match the concatenated spec segments.
|
||||||
|
*
|
||||||
|
* @throws IllegalStateException if no exact match is found
|
||||||
|
*/
|
||||||
|
private int findBestMatchingRouteIndexFromDom(Locator allRoutes, String routeSpec) {
|
||||||
|
int routeCount = allRoutes.count();
|
||||||
|
if (routeSpec == null || routeSpec.isEmpty()) {
|
||||||
|
return 0; // No route specified, use first available
|
||||||
|
}
|
||||||
|
if (routeCount == 0) {
|
||||||
|
throw new IllegalStateException("No routes available, but route spec was: " + routeSpec);
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] specSegments = routeSpec.split(",");
|
||||||
|
|
||||||
|
// Build expected route text by concatenating segments (routes display without separators)
|
||||||
|
StringBuilder expectedBuilder = new StringBuilder();
|
||||||
|
for (String segment : specSegments) {
|
||||||
|
expectedBuilder.append(segment.trim().toLowerCase().replace("_", " "));
|
||||||
|
}
|
||||||
|
String expectedRouteText = expectedBuilder.toString();
|
||||||
|
|
||||||
|
// Find exact match
|
||||||
|
for (int i = 0; i < routeCount; i++) {
|
||||||
|
String routeText = allRoutes.nth(i).textContent().toLowerCase().trim();
|
||||||
|
// Remove common whitespace/separator variations
|
||||||
|
String normalizedRouteText = routeText.replaceAll("\\s+", "").replace(">", "");
|
||||||
|
String normalizedExpected = expectedRouteText.replaceAll("\\s+", "");
|
||||||
|
|
||||||
|
if (normalizedRouteText.equals(normalizedExpected)) {
|
||||||
|
final int matchedIndex = i;
|
||||||
|
final String matchedRoute = routeText;
|
||||||
|
logger.info(() -> "Exact route match found at index " + matchedIndex + ": " + matchedRoute);
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No exact match found - log available routes and fail
|
||||||
|
StringBuilder availableRoutes = new StringBuilder("Available routes:\n");
|
||||||
|
for (int i = 0; i < routeCount; i++) {
|
||||||
|
availableRoutes.append(" ").append(i).append(": ").append(allRoutes.nth(i).textContent().trim()).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"No exact route match found for spec: '" + routeSpec + "' (expected: '" + expectedRouteText + "')\n" +
|
||||||
|
availableRoutes.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
620
src/test/java/de/avatic/lcc/e2e/pages/ResultsPage.java
Normal file
620
src/test/java/de/avatic/lcc/e2e/pages/ResultsPage.java
Normal file
|
|
@ -0,0 +1,620 @@
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Expand all collapsible boxes to ensure all content is visible
|
||||||
|
expandAllCollapsibleBoxes();
|
||||||
|
|
||||||
|
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 [days], 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 [days]"));
|
||||||
|
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);
|
||||||
|
final int destCount = destinations.size();
|
||||||
|
logger.info(() -> "Read destination " + destCount + " results: " + destResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warning("Could not read destination results: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return destinations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Double readValueInBox(Locator box, String label) {
|
||||||
|
try {
|
||||||
|
// Try exact text match first, then contains match
|
||||||
|
Locator cell = box.locator(".report-content-row")
|
||||||
|
.filter(new Locator.FilterOptions().setHasText(label))
|
||||||
|
.locator(".report-content-data-cell")
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (cell.count() == 0) {
|
||||||
|
logger.warning(() -> "Could not find cell for label: " + label);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String text = cell.textContent().replaceAll("[^0-9.,\\-]", "").trim();
|
||||||
|
final String logText = text;
|
||||||
|
logger.info(() -> "Read value for '" + label + "': " + logText);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
logger.warning(() -> "Error reading value for label '" + label + "': " + e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readStringInBox(Locator box, String label) {
|
||||||
|
try {
|
||||||
|
Locator cell = box.locator(".report-content-row")
|
||||||
|
.filter(new Locator.FilterOptions().setHasText(label))
|
||||||
|
.locator(".report-content-data-cell")
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (cell.count() == 0) {
|
||||||
|
logger.warning(() -> "Could not find string cell for label: " + label);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String text = cell.textContent().trim();
|
||||||
|
logger.info(() -> "Read string for '" + label + "': " + text);
|
||||||
|
return text;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warning(() -> "Error reading string for label '" + label + "': " + e.getMessage());
|
||||||
|
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) + " ";
|
||||||
|
|
||||||
|
// Verify transit time (always expected to have a value)
|
||||||
|
if (expDest.transitTime() != null) {
|
||||||
|
verifyNumericResult(prefix + "TRANSIT_TIME",
|
||||||
|
expDest.transitTime().doubleValue(),
|
||||||
|
(Double) actDest.get("transitTime"), tolerance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify stacked layers (null expected = "-" in UI)
|
||||||
|
verifyNullableNumericResult(prefix + "STACKED_LAYERS",
|
||||||
|
expDest.stackedLayers(),
|
||||||
|
(Double) actDest.get("stackedLayers"), tolerance);
|
||||||
|
|
||||||
|
// Verify container unit count (null expected = "-" in UI)
|
||||||
|
verifyNullableNumericResult(prefix + "CONTAINER_UNIT_COUNT",
|
||||||
|
expDest.containerUnitCount(),
|
||||||
|
(Double) actDest.get("containerUnitCount"), tolerance);
|
||||||
|
|
||||||
|
// Verify container type (null or "-" expected = "-" in UI)
|
||||||
|
String expContainerType = expDest.containerType();
|
||||||
|
String actContainerType = (String) actDest.get("containerType");
|
||||||
|
verifyStringResult(prefix + "CONTAINER_TYPE", expContainerType, actContainerType);
|
||||||
|
|
||||||
|
// Verify limiting factor (null or "-" expected = "-" in UI)
|
||||||
|
String expLimitingFactor = expDest.limitingFactor();
|
||||||
|
String actLimitingFactor = (String) actDest.get("limitingFactor");
|
||||||
|
verifyStringResult(prefix + "LIMITING_FACTOR", 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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a nullable numeric result. If expected is null, actual should also be null.
|
||||||
|
*/
|
||||||
|
private void verifyNullableNumericResult(String fieldName, Integer expected, Double actual, double tolerance) {
|
||||||
|
if (expected == null) {
|
||||||
|
// Expected null means UI shows "-"
|
||||||
|
if (actual != null) {
|
||||||
|
throw new AssertionError(String.format(
|
||||||
|
"Field '%s': expected null (UI shows '-'), got %f",
|
||||||
|
fieldName, actual
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expected has a value, verify it
|
||||||
|
verifyNumericResult(fieldName, expected.doubleValue(), actual, tolerance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a string result. Handles null/"-" as equivalent.
|
||||||
|
*/
|
||||||
|
private void verifyStringResult(String fieldName, String expected, String actual) {
|
||||||
|
// Normalize "-" to null for comparison
|
||||||
|
String normExpected = (expected == null || "-".equals(expected)) ? null : expected;
|
||||||
|
String normActual = (actual == null || "-".equals(actual)) ? null : actual;
|
||||||
|
|
||||||
|
if (normExpected == null && normActual == null) {
|
||||||
|
return; // Both null/"-" = match
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normExpected == null || normActual == null || !normExpected.equals(normActual)) {
|
||||||
|
throw new AssertionError(String.format(
|
||||||
|
"Field '%s': expected '%s', got '%s'",
|
||||||
|
fieldName, expected, actual
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/test/java/de/avatic/lcc/e2e/testdata/DestinationExpected.java
vendored
Normal file
56
src/test/java/de/avatic/lcc/e2e/testdata/DestinationExpected.java
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package de.avatic.lcc.e2e.testdata;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expected output values for a single destination in a test case.
|
||||||
|
* Nullable fields (Integer, String) indicate the UI shows "-" when no main run/D2D is configured.
|
||||||
|
*/
|
||||||
|
public record DestinationExpected(
|
||||||
|
Integer transitTime,
|
||||||
|
Integer stackedLayers,
|
||||||
|
Integer containerUnitCount,
|
||||||
|
String containerType,
|
||||||
|
String limitingFactor
|
||||||
|
) {
|
||||||
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Builder {
|
||||||
|
private Integer transitTime;
|
||||||
|
private Integer stackedLayers;
|
||||||
|
private Integer containerUnitCount;
|
||||||
|
private String containerType;
|
||||||
|
private String limitingFactor;
|
||||||
|
|
||||||
|
public Builder transitTime(Integer transitTime) {
|
||||||
|
this.transitTime = transitTime;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder stackedLayers(Integer stackedLayers) {
|
||||||
|
this.stackedLayers = stackedLayers;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder containerUnitCount(Integer 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
901
src/test/java/de/avatic/lcc/e2e/testdata/TestCases.java
vendored
Normal file
901
src/test/java/de/avatic/lcc/e2e/testdata/TestCases.java
vendored
Normal file
|
|
@ -0,0 +1,901 @@
|
||||||
|
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("IE SUP,HH")
|
||||||
|
.customHandling(false)
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build(),
|
||||||
|
TestCaseExpected.builder()
|
||||||
|
.mekA(8.0)
|
||||||
|
.logisticCost(33.76)
|
||||||
|
.mekB(41.76)
|
||||||
|
.fcaFee(0.0)
|
||||||
|
.transportation(4.18)
|
||||||
|
.d2d(0.0)
|
||||||
|
.airFreight(0.0)
|
||||||
|
.custom(0.0)
|
||||||
|
.repackaging(0.0)
|
||||||
|
.handling(4.392)
|
||||||
|
.disposal(0.0)
|
||||||
|
.space(24.95)
|
||||||
|
.capital(0.13)
|
||||||
|
.safetyStock(10)
|
||||||
|
.destinations(List.of(
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(3)
|
||||||
|
.stackedLayers(null)
|
||||||
|
.containerUnitCount(null)
|
||||||
|
.containerType(null)
|
||||||
|
.limitingFactor(null)
|
||||||
|
.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(25000)
|
||||||
|
.d2d(false)
|
||||||
|
.route("HH,WH ULHA,AB")
|
||||||
|
.handlingCost(3.0)
|
||||||
|
.repackingCost(3.3)
|
||||||
|
.disposalCost(8.0)
|
||||||
|
.customHandling(true)
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build(),
|
||||||
|
TestCaseExpected.builder()
|
||||||
|
.mekA(230.0)
|
||||||
|
.logisticCost(1.50)
|
||||||
|
.mekB(231.50)
|
||||||
|
.fcaFee(0.46)
|
||||||
|
.transportation(0.02)
|
||||||
|
.d2d(0.0)
|
||||||
|
.airFreight(0.0)
|
||||||
|
.custom(0.0)
|
||||||
|
.repackaging(0.00)
|
||||||
|
.handling(0.00)
|
||||||
|
.disposal(0.00)
|
||||||
|
.space(0.01)
|
||||||
|
.capital(1.00)
|
||||||
|
.safetyStock(10)
|
||||||
|
.destinations(List.of(
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(6)
|
||||||
|
.stackedLayers(null)
|
||||||
|
.containerUnitCount(null)
|
||||||
|
.containerType(null)
|
||||||
|
.limitingFactor(null)
|
||||||
|
.build(),
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(6)
|
||||||
|
.stackedLayers(null)
|
||||||
|
.containerUnitCount(null)
|
||||||
|
.containerType(null)
|
||||||
|
.limitingFactor(null)
|
||||||
|
.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 sup ...,WH HH,HH")
|
||||||
|
.customHandling(false)
|
||||||
|
.build(),
|
||||||
|
DestinationInput.builder()
|
||||||
|
.name("Aschaffenburg (KION plant)")
|
||||||
|
.quantity(80000)
|
||||||
|
.d2d(false)
|
||||||
|
.route("Turkey sup ...,WH ULHA,AB")
|
||||||
|
.handlingCost(6.0)
|
||||||
|
.repackingCost(6.0)
|
||||||
|
.disposalCost(6.0)
|
||||||
|
.customHandling(true)
|
||||||
|
.build(),
|
||||||
|
DestinationInput.builder()
|
||||||
|
.name("Luzzara (KION plant)")
|
||||||
|
.quantity(30000)
|
||||||
|
.d2d(false)
|
||||||
|
.route("Turkey sup ...,LZZ")
|
||||||
|
.customHandling(false)
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build(),
|
||||||
|
TestCaseExpected.builder()
|
||||||
|
.mekA(11.0)
|
||||||
|
.logisticCost(0.33)
|
||||||
|
.mekB(11.33)
|
||||||
|
.fcaFee(0.02)
|
||||||
|
.transportation(0.06)
|
||||||
|
.d2d(0.0)
|
||||||
|
.airFreight(0.0)
|
||||||
|
.custom(0.21)
|
||||||
|
.repackaging(0.00)
|
||||||
|
.handling(0.00)
|
||||||
|
.disposal(0.00)
|
||||||
|
.space(0.00)
|
||||||
|
.capital(0.03)
|
||||||
|
.safetyStock(10)
|
||||||
|
.destinations(List.of(
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(6)
|
||||||
|
.stackedLayers(null)
|
||||||
|
.containerUnitCount(null)
|
||||||
|
.containerType(null)
|
||||||
|
.limitingFactor(null)
|
||||||
|
.build(),
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(6)
|
||||||
|
.stackedLayers(null)
|
||||||
|
.containerUnitCount(null)
|
||||||
|
.containerType(null)
|
||||||
|
.limitingFactor(null)
|
||||||
|
.build(),
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(3)
|
||||||
|
.stackedLayers(null)
|
||||||
|
.containerUnitCount(null)
|
||||||
|
.containerType(null)
|
||||||
|
.limitingFactor(null)
|
||||||
|
.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 sup ...,WH HH,HH")
|
||||||
|
.customHandling(false)
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build(),
|
||||||
|
TestCaseExpected.builder()
|
||||||
|
.mekA(11.0)
|
||||||
|
.logisticCost(0.33)
|
||||||
|
.mekB(11.33)
|
||||||
|
.fcaFee(0.02)
|
||||||
|
.transportation(0.06)
|
||||||
|
.d2d(0.0)
|
||||||
|
.airFreight(0.0)
|
||||||
|
.custom(0.21)
|
||||||
|
.repackaging(0.0)
|
||||||
|
.handling(0.01)
|
||||||
|
.disposal(0.0)
|
||||||
|
.space(0.01)
|
||||||
|
.capital(0.03)
|
||||||
|
.safetyStock(10)
|
||||||
|
.destinations(List.of(
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(6)
|
||||||
|
.stackedLayers(null)
|
||||||
|
.containerUnitCount(null)
|
||||||
|
.containerType(null)
|
||||||
|
.limitingFactor(null)
|
||||||
|
.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 sup ...,WH HH,HH")
|
||||||
|
.d2dCost(6500.0)
|
||||||
|
.d2dDuration(47)
|
||||||
|
.customHandling(false)
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build(),
|
||||||
|
TestCaseExpected.builder()
|
||||||
|
.mekA(56.87)
|
||||||
|
.logisticCost(2.61)
|
||||||
|
.mekB(59.48)
|
||||||
|
.fcaFee(0.0)
|
||||||
|
.transportation(0.0)
|
||||||
|
.d2d(0.03)
|
||||||
|
.airFreight(0.0)
|
||||||
|
.custom(1.71)
|
||||||
|
.repackaging(0.0)
|
||||||
|
.handling(0.00)
|
||||||
|
.disposal(0.00)
|
||||||
|
.space(0.00)
|
||||||
|
.capital(0.87)
|
||||||
|
.safetyStock(10)
|
||||||
|
.destinations(List.of(
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(47)
|
||||||
|
.stackedLayers(2)
|
||||||
|
.containerUnitCount(240000)
|
||||||
|
.containerType("40 ft. GP")
|
||||||
|
.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("IE SUP,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(1000)
|
||||||
|
.d2d(true)
|
||||||
|
.route("IE SUP,WH ULHA,AB")
|
||||||
|
.d2dCost(1500.0)
|
||||||
|
.d2dDuration(10)
|
||||||
|
.handlingCost(2.5)
|
||||||
|
.repackingCost(5.0)
|
||||||
|
.disposalCost(6.0)
|
||||||
|
.customHandling(true)
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build(),
|
||||||
|
TestCaseExpected.builder()
|
||||||
|
.mekA(8.0)
|
||||||
|
.logisticCost(8.61)
|
||||||
|
.mekB(16.61)
|
||||||
|
.fcaFee(0.02)
|
||||||
|
.transportation(0.0)
|
||||||
|
.d2d(8.0)
|
||||||
|
.airFreight(0.0)
|
||||||
|
.custom(0.0)
|
||||||
|
.repackaging(0.04)
|
||||||
|
.handling(0.24)
|
||||||
|
.disposal(0.00)
|
||||||
|
.space(0.17)
|
||||||
|
.capital(0.16)
|
||||||
|
.safetyStock(10)
|
||||||
|
.destinations(List.of(
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(12)
|
||||||
|
.stackedLayers(2)
|
||||||
|
.containerUnitCount(48000)
|
||||||
|
.containerType("40 ft. GP")
|
||||||
|
.limitingFactor("Weight")
|
||||||
|
.build(),
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(10)
|
||||||
|
.stackedLayers(2)
|
||||||
|
.containerUnitCount(48000)
|
||||||
|
.containerType("40 ft. GP")
|
||||||
|
.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(6000)
|
||||||
|
.d2d(true)
|
||||||
|
.d2dCost(100.0)
|
||||||
|
.d2dDuration(2)
|
||||||
|
.customHandling(false)
|
||||||
|
.build(),
|
||||||
|
DestinationInput.builder()
|
||||||
|
.name("Luzzara (KION plant)")
|
||||||
|
.quantity(3000)
|
||||||
|
.d2d(true)
|
||||||
|
.d2dCost(200.0)
|
||||||
|
.d2dDuration(3)
|
||||||
|
.handlingCost(20.0)
|
||||||
|
.repackingCost(7.0)
|
||||||
|
.disposalCost(11.0)
|
||||||
|
.customHandling(true)
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build(),
|
||||||
|
TestCaseExpected.builder()
|
||||||
|
.mekA(18.2)
|
||||||
|
.logisticCost(0.41)
|
||||||
|
.mekB(18.61)
|
||||||
|
.fcaFee(0.0)
|
||||||
|
.transportation(0.0)
|
||||||
|
.d2d(0.07)
|
||||||
|
.airFreight(0.0)
|
||||||
|
.custom(0.0)
|
||||||
|
.repackaging(0.00)
|
||||||
|
.handling(0.01)
|
||||||
|
.disposal(0.00)
|
||||||
|
.space(0.03)
|
||||||
|
.capital(0.30)
|
||||||
|
.safetyStock(10)
|
||||||
|
.destinations(List.of(
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(1)
|
||||||
|
.stackedLayers(2)
|
||||||
|
.containerUnitCount(80000)
|
||||||
|
.containerType("40 ft. GP")
|
||||||
|
.limitingFactor("Volume")
|
||||||
|
.build(),
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(2)
|
||||||
|
.stackedLayers(2)
|
||||||
|
.containerUnitCount(80000)
|
||||||
|
.containerType("40 ft. GP")
|
||||||
|
.limitingFactor("Volume")
|
||||||
|
.build(),
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(3)
|
||||||
|
.stackedLayers(2)
|
||||||
|
.containerUnitCount(80000)
|
||||||
|
.containerType("40 ft. GP")
|
||||||
|
.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 s ...,CNSZX,DEHAM,WH ZBU,STR")
|
||||||
|
.d2dCost(6500.0)
|
||||||
|
.d2dDuration(47)
|
||||||
|
.customHandling(false)
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build(),
|
||||||
|
TestCaseExpected.builder()
|
||||||
|
.mekA(56.87)
|
||||||
|
.logisticCost(5.48)
|
||||||
|
.mekB(62.35)
|
||||||
|
.fcaFee(0.11)
|
||||||
|
.transportation(0.0)
|
||||||
|
.d2d(0.39)
|
||||||
|
.airFreight(0.0)
|
||||||
|
.custom(1.72)
|
||||||
|
.repackaging(0.00)
|
||||||
|
.handling(0.00)
|
||||||
|
.disposal(0.00)
|
||||||
|
.space(0.01)
|
||||||
|
.capital(3.25)
|
||||||
|
.safetyStock(100)
|
||||||
|
.destinations(List.of(
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(47)
|
||||||
|
.stackedLayers(2)
|
||||||
|
.containerUnitCount(240000)
|
||||||
|
.containerType("40 ft. GP")
|
||||||
|
.limitingFactor("Weight")
|
||||||
|
.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.99)
|
||||||
|
.mekB(21.19)
|
||||||
|
.fcaFee(0.0)
|
||||||
|
.transportation(0.0)
|
||||||
|
.d2d(0.9)
|
||||||
|
.airFreight(0.0)
|
||||||
|
.custom(0.58)
|
||||||
|
.repackaging(0.05)
|
||||||
|
.handling(0.05)
|
||||||
|
.disposal(0.04)
|
||||||
|
.space(0.33)
|
||||||
|
.capital(1.04)
|
||||||
|
.safetyStock(55)
|
||||||
|
.destinations(List.of(
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(47)
|
||||||
|
.stackedLayers(2)
|
||||||
|
.containerUnitCount(6300)
|
||||||
|
.containerType("40 ft. GP")
|
||||||
|
.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("IE SUP,HH")
|
||||||
|
.customHandling(false)
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build(),
|
||||||
|
TestCaseExpected.builder()
|
||||||
|
.mekA(8.0)
|
||||||
|
.logisticCost(1505.46)
|
||||||
|
.mekB(1513.46)
|
||||||
|
.fcaFee(0.0)
|
||||||
|
.transportation(1475.98)
|
||||||
|
.d2d(0.0)
|
||||||
|
.airFreight(0.0)
|
||||||
|
.custom(0.0)
|
||||||
|
.repackaging(0.0)
|
||||||
|
.handling(4.39)
|
||||||
|
.disposal(0.0)
|
||||||
|
.space(24.95)
|
||||||
|
.capital(0.13)
|
||||||
|
.safetyStock(10)
|
||||||
|
.destinations(List.of(
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(3)
|
||||||
|
.stackedLayers(null)
|
||||||
|
.containerUnitCount(null)
|
||||||
|
.containerType(null)
|
||||||
|
.limitingFactor(null)
|
||||||
|
.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("IE SUP,HH")
|
||||||
|
.handlingCost(6.0)
|
||||||
|
.repackingCost(6.0)
|
||||||
|
.disposalCost(6.0)
|
||||||
|
.customHandling(true)
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build(),
|
||||||
|
TestCaseExpected.builder()
|
||||||
|
.mekA(8.0)
|
||||||
|
.logisticCost(188.82)
|
||||||
|
.mekB(196.82)
|
||||||
|
.fcaFee(0.02)
|
||||||
|
.transportation(184.50)
|
||||||
|
.d2d(0.0)
|
||||||
|
.airFreight(0.0)
|
||||||
|
.custom(0.0)
|
||||||
|
.repackaging(0.3)
|
||||||
|
.handling(0.45)
|
||||||
|
.disposal(0.3)
|
||||||
|
.space(3.12)
|
||||||
|
.capital(0.14)
|
||||||
|
.safetyStock(10)
|
||||||
|
.destinations(List.of(
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(3)
|
||||||
|
.stackedLayers(null)
|
||||||
|
.containerUnitCount(null)
|
||||||
|
.containerType(null)
|
||||||
|
.limitingFactor(null)
|
||||||
|
.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(9.50)
|
||||||
|
.mekB(17.50)
|
||||||
|
.fcaFee(0.02)
|
||||||
|
.transportation(4.87)
|
||||||
|
.d2d(0.0)
|
||||||
|
.airFreight(0.0)
|
||||||
|
.custom(0.32)
|
||||||
|
.repackaging(0.39)
|
||||||
|
.handling(0.38)
|
||||||
|
.disposal(0.30)
|
||||||
|
.space(2.77)
|
||||||
|
.capital(0.46)
|
||||||
|
.safetyStock(10)
|
||||||
|
.destinations(List.of(
|
||||||
|
DestinationExpected.builder()
|
||||||
|
.transitTime(47)
|
||||||
|
.stackedLayers(2)
|
||||||
|
.containerUnitCount(400)
|
||||||
|
.containerType("20 ft. GP")
|
||||||
|
.limitingFactor("Volume")
|
||||||
|
.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,193 @@
|
||||||
|
package de.avatic.lcc.e2e.tests;
|
||||||
|
|
||||||
|
import com.microsoft.playwright.Locator;
|
||||||
|
import com.microsoft.playwright.options.WaitForSelectorState;
|
||||||
|
import de.avatic.lcc.e2e.pages.AssistantPage;
|
||||||
|
import de.avatic.lcc.e2e.pages.CalculationEditPage;
|
||||||
|
import de.avatic.lcc.e2e.pages.ResultsPage;
|
||||||
|
import de.avatic.lcc.e2e.testdata.DestinationInput;
|
||||||
|
import de.avatic.lcc.e2e.testdata.TestCase;
|
||||||
|
import de.avatic.lcc.e2e.testdata.TestCases;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.Arguments;
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
|
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end tests for the calculation workflow.
|
||||||
|
* Tests all scenarios from Testfälle.xlsx using Playwright.
|
||||||
|
*
|
||||||
|
* <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() {
|
||||||
|
return TestCases.ALL.stream()
|
||||||
|
.map(tc -> Arguments.of(tc.id(), tc.name(), tc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
package de.avatic.lcc.e2e.tests;
|
||||||
|
|
||||||
|
import com.microsoft.playwright.Locator;
|
||||||
|
import de.avatic.lcc.e2e.pages.AssistantPage;
|
||||||
|
import de.avatic.lcc.e2e.pages.CalculationEditPage;
|
||||||
|
import de.avatic.lcc.e2e.pages.ResultsPage;
|
||||||
|
import de.avatic.lcc.e2e.testdata.DestinationInput;
|
||||||
|
import de.avatic.lcc.e2e.testdata.TestCase;
|
||||||
|
import de.avatic.lcc.e2e.testdata.TestCases;
|
||||||
|
import de.avatic.lcc.e2e.util.DeviationReport;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs all test cases and generates a deviation report comparing expected vs actual values.
|
||||||
|
* This test does not fail on deviations - it collects them all and prints a summary.
|
||||||
|
*/
|
||||||
|
@DisplayName("Deviation Analysis E2E Test")
|
||||||
|
@Tag("analysis")
|
||||||
|
class DeviationAnalysisE2ETest extends AbstractE2ETest {
|
||||||
|
|
||||||
|
private static final Logger logger = Logger.getLogger(DeviationAnalysisE2ETest.class.getName());
|
||||||
|
private static final int CALCULATION_TIMEOUT_MS = 120000;
|
||||||
|
private static final int POLL_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Analyze deviations across all test cases")
|
||||||
|
void analyzeDeviations() {
|
||||||
|
DeviationReport report = new DeviationReport();
|
||||||
|
|
||||||
|
for (TestCase testCase : TestCases.ALL) {
|
||||||
|
String id = testCase.id();
|
||||||
|
String name = testCase.name();
|
||||||
|
|
||||||
|
logger.info(() -> "\n========================================");
|
||||||
|
logger.info(() -> "Processing test case: " + id + " - " + name);
|
||||||
|
logger.info(() -> "========================================\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Run the calculation workflow
|
||||||
|
Map<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);
|
||||||
|
|
||||||
|
// Write report to file
|
||||||
|
try {
|
||||||
|
Path reportPath = Path.of("target/deviation-report.md");
|
||||||
|
Files.writeString(reportPath, reportContent);
|
||||||
|
logger.info("Deviation report written to: " + reportPath.toAbsolutePath());
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.warning("Could not write deviation report file: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
180
src/test/java/de/avatic/lcc/e2e/util/ResultComparator.java
Normal file
180
src/test/java/de/avatic/lcc/e2e/util/ResultComparator.java
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
package de.avatic.lcc.e2e.util;
|
||||||
|
|
||||||
|
import de.avatic.lcc.e2e.testdata.DestinationExpected;
|
||||||
|
import de.avatic.lcc.e2e.testdata.TestCaseExpected;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class for comparing actual results with expected values.
|
||||||
|
* Supports tolerance-based comparison for numeric values.
|
||||||
|
*/
|
||||||
|
public final class ResultComparator {
|
||||||
|
|
||||||
|
private static final Logger logger = Logger.getLogger(ResultComparator.class.getName());
|
||||||
|
|
||||||
|
private ResultComparator() {
|
||||||
|
// Utility class
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that actual results match expected values within the given tolerance.
|
||||||
|
*
|
||||||
|
* @param actualResults Map of actual result values from the UI
|
||||||
|
* @param expected Expected values from test case definition
|
||||||
|
* @param tolerance Relative tolerance for numeric comparisons (0.01 = 1%)
|
||||||
|
* @throws AssertionError if any values don't match within tolerance
|
||||||
|
*/
|
||||||
|
public static void assertResultsMatch(Map<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) {
|
||||||
|
// Handle zero expected values - if expected is ~0 and actual is null, treat as pass
|
||||||
|
// (some fields are not displayed in the UI when their value is 0)
|
||||||
|
if (Math.abs(expected) < 1e-10) {
|
||||||
|
if (actual == null) {
|
||||||
|
// Expected ~0 and actual is null (field not shown) - this is acceptable
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Math.abs(actual) > tolerance) {
|
||||||
|
failures.add(String.format("%s: expected ~0, got %.6f", fieldName, actual));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actual == null) {
|
||||||
|
failures.add(String.format("%s: actual value is null, expected %.6f", fieldName, expected));
|
||||||
|
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
|
spring.flyway.clean-disabled=false
|
||||||
# Note: spring.flyway.locations is set in application-mysql.properties or application-mssql.properties
|
# Note: spring.flyway.locations is set in application-mysql.properties or application-mssql.properties
|
||||||
|
|
||||||
# Security disabled for integration tests
|
# Security configuration for E2E tests
|
||||||
|
# Note: SecurityAutoConfiguration is NOT excluded - we use devSecurityFilterChain with @Profile("test")
|
||||||
|
# which permits all requests but still requires HttpSecurity bean
|
||||||
spring.autoconfigure.exclude=\
|
spring.autoconfigure.exclude=\
|
||||||
org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration,\
|
|
||||||
org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration,\
|
org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration,\
|
||||||
org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration,\
|
org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration,\
|
||||||
org.springframework.boot.autoconfigure.webservices.WebServicesAutoConfiguration,\
|
org.springframework.boot.autoconfigure.webservices.WebServicesAutoConfiguration,\
|
||||||
|
|
@ -26,6 +27,26 @@ spring.autoconfigure.exclude=\
|
||||||
# Disable Azure AD for tests
|
# Disable Azure AD for tests
|
||||||
spring.cloud.azure.active-directory.enabled=false
|
spring.cloud.azure.active-directory.enabled=false
|
||||||
|
|
||||||
|
# Azure Maps API - dummy values for E2E tests (real API not called in tests)
|
||||||
|
azure.maps.subscription.key=test-dummy-key-not-used
|
||||||
|
azure.maps.api.url=https://atlas.microsoft.com
|
||||||
|
|
||||||
|
# JWT configuration for tests
|
||||||
|
jwt.secret=test-secret-key-for-e2e-tests-must-be-long-enough
|
||||||
|
jwt.issuer=test-issuer
|
||||||
|
|
||||||
|
# LCC configuration for tests
|
||||||
|
lcc.allowed_cors=*
|
||||||
|
lcc.allowed_oauth_token_cors=*
|
||||||
|
lcc.auth.claim.email=email
|
||||||
|
lcc.auth.claim.firstname=given_name
|
||||||
|
lcc.auth.claim.lastname=family_name
|
||||||
|
lcc.auth.claim.workday=workday_id
|
||||||
|
lcc.auth.claim.ignore.workday=false
|
||||||
|
lcc.auth.identify.by=workday
|
||||||
|
lcc.bulk.sheet_password=test-password
|
||||||
|
lcc.help.static=
|
||||||
|
|
||||||
# Disable async processing in tests (EUTaxationApiService uses @Async)
|
# Disable async processing in tests (EUTaxationApiService uses @Async)
|
||||||
spring.task.execution.pool.core-size=0
|
spring.task.execution.pool.core-size=0
|
||||||
spring.task.scheduling.enabled=false
|
spring.task.scheduling.enabled=false
|
||||||
|
|
|
||||||
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';
|
||||||
|
|
@ -12,6 +12,10 @@ INSERT INTO sys_user_group_mapping (group_id, user_id)
|
||||||
VALUES ((SELECT id FROM sys_group WHERE group_name = 'super'),
|
VALUES ((SELECT id FROM sys_group WHERE group_name = 'super'),
|
||||||
(SELECT id FROM sys_user WHERE email = 'john.doe@company.com'));
|
(SELECT id FROM sys_user WHERE email = 'john.doe@company.com'));
|
||||||
|
|
||||||
|
INSERT INTO sys_user_group_mapping (group_id, user_id)
|
||||||
|
VALUES ((SELECT id FROM sys_group WHERE group_name = 'right-management'),
|
||||||
|
(SELECT id FROM sys_user WHERE email = 'john.doe@company.com'));
|
||||||
|
|
||||||
INSERT INTO sys_user_group_mapping (group_id, user_id)
|
INSERT INTO sys_user_group_mapping (group_id, user_id)
|
||||||
VALUES ((SELECT id FROM sys_group WHERE group_name = 'basic'),
|
VALUES ((SELECT id FROM sys_group WHERE group_name = 'basic'),
|
||||||
(SELECT id FROM sys_user WHERE email = 'sarah.smith@company.com'));
|
(SELECT id FROM sys_user WHERE email = 'sarah.smith@company.com'));
|
||||||
|
|
|
||||||
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