diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..55cbf47 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,101 @@ +# 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 + } \ No newline at end of file diff --git a/test/pages/assistant.py b/test/pages/assistant.py new file mode 100644 index 0000000..e5d07ba --- /dev/null +++ b/test/pages/assistant.py @@ -0,0 +1,46 @@ +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"]) diff --git a/test/pages/base_page.py b/test/pages/base_page.py new file mode 100644 index 0000000..4a19b18 --- /dev/null +++ b/test/pages/base_page.py @@ -0,0 +1,147 @@ +# 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) diff --git a/test/pages/calculation_page.py b/test/pages/calculation_page.py new file mode 100644 index 0000000..56b1fea --- /dev/null +++ b/test/pages/calculation_page.py @@ -0,0 +1,157 @@ +# 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) diff --git a/test/pages/dev_page.py b/test/pages/dev_page.py new file mode 100644 index 0000000..66de67d --- /dev/null +++ b/test/pages/dev_page.py @@ -0,0 +1,53 @@ +# 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")) + ) \ No newline at end of file diff --git a/test/pages/navigation.py b/test/pages/navigation.py new file mode 100644 index 0000000..7c8ea82 --- /dev/null +++ b/test/pages/navigation.py @@ -0,0 +1,29 @@ +# 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) \ No newline at end of file diff --git a/test/pages/results_page.py b/test/pages/results_page.py new file mode 100644 index 0000000..9d76774 --- /dev/null +++ b/test/pages/results_page.py @@ -0,0 +1,35 @@ +# 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 \ No newline at end of file diff --git a/test/pytest.ini b/test/pytest.ini new file mode 100644 index 0000000..e74e39e --- /dev/null +++ b/test/pytest.ini @@ -0,0 +1,18 @@ +[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 \ No newline at end of file diff --git a/test/requirements.txt b/test/requirements.txt new file mode 100644 index 0000000..323adac --- /dev/null +++ b/test/requirements.txt @@ -0,0 +1,5 @@ +pytest==9.0.2 +selenium==4.39.0 +openpyxl==3.1.5 +pytest-html==4.1.1 +webdriver-manager==4.0.2 \ No newline at end of file diff --git a/test/test_calculation.py b/test/test_calculation.py new file mode 100644 index 0000000..dd7d95a --- /dev/null +++ b/test/test_calculation.py @@ -0,0 +1,70 @@ +# 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