diff --git a/forms/base_rack_form.py b/forms/base_rack_form.py new file mode 100644 index 0000000..17fa5af --- /dev/null +++ b/forms/base_rack_form.py @@ -0,0 +1,469 @@ +"""Базовый модуль для работы с формами стойки.""" + +import time +from dataclasses import dataclass +from typing import Optional, List, Dict, Any, Tuple +from abc import ABC, abstractmethod +from playwright.sync_api import Page +from tools.logger import get_logger +from elements.text_input_element import TextInput +from components.base_component import BaseComponent +from components.dropdown_list_component import DropdownList + +logger = get_logger("BASE_RACK_FORM") +logger.setLevel("INFO") + +@dataclass +class BaseRackData: + """Базовый класс для хранения данных стойки.""" + + # Основные поля + name: str = "" + serial: str = "" + inventory: str = "" + comment: str = "" + + # Combobox поля + cable_entry: str = "" + state: str = "" + depth: str = "" + usize: str = "" + + # Combobox поля (справочники) + owner: str = "" + service_org: str = "" + project: str = "" + + +class BaseRackForm(BaseComponent, ABC): + """Базовый компонент для работы с формами стойки.""" + + # Маппинг текстовых полей (должен быть переопределен в наследниках) + TEXT_FIELDS_MAPPING: Dict[str, Tuple[str, str]] = {} + TEXT_FIELDS_LOCATORS: Dict[str, str] = {} + + # Маппинг combobox полей (должен быть переопределен в наследниках) + COMBOBOX_FIELDS_MAPPING: Dict[str, Tuple[str, str, str]] = {} + COMBOBOX_FIELDS_LOCATORS: Dict[str, str] = {} + + # Дополнительные типы полей (checkbox и т.д.) - опционально + CHECKBOX_FIELDS_MAPPING: Dict[str, Tuple[str, str]] = {} + CHECKBOX_FIELDS_LOCATORS: Dict[str, str] = {} + + def __init__(self, page: Page, form_container_locator: str) -> None: + """Инициализирует базовый компонент формы стойки. + + Args: + page: Экземпляр страницы Playwright + form_container_locator: Локатор контейнера формы + """ + super().__init__(page) + self.page = page + self.form_container_locator = form_container_locator + self.content_items: Dict[str, Any] = {} + self.available_fields = None + + # Инициализация полей формы + self._init_form_fields() + + def _init_form_fields(self) -> None: + """Инициализирует все поля формы.""" + container_locator = self.page.locator(self.form_container_locator) + if container_locator.count() > 0: + self.available_fields = self.get_input_fields_locators(container_locator) + + self._init_text_fields() + self._init_combobox_fields() + self._init_checkbox_fields() + + def _init_text_fields(self) -> None: + """Инициализирует текстовые поля формы.""" + for field_label, (attr_name, widget_name) in self.TEXT_FIELDS_MAPPING.items(): + locator = self.TEXT_FIELDS_LOCATORS.get(field_label) + if not locator: + continue + self._init_single_text_field(field_label, locator, widget_name) + + def _init_single_text_field(self, field_label: str, locator: str, widget_name: str) -> None: + """Инициализирует одно текстовое поле.""" + try: + element = self.page.locator(locator).first + if element.count() > 0 and element.is_visible(): + field_input = TextInput(self.page, element, widget_name) + self.content_items[widget_name] = field_input + logger.debug(f"Initialized text field: '{field_label}'") + except Exception as e: + logger.error(f"Error initializing text field '{field_label}': {e}") + + def _init_combobox_fields(self) -> None: + """Инициализирует combobox поля формы.""" + for field_label, (attr_name, input_name, list_name) in self.COMBOBOX_FIELDS_MAPPING.items(): + locator = self.COMBOBOX_FIELDS_LOCATORS.get(field_label) + if not locator: + continue + self._init_single_combobox_field(field_label, locator, input_name, list_name) + + def _init_single_combobox_field( + self, field_label: str, locator: str, input_name: str, list_name: str + ) -> None: + """Инициализирует одно combobox поле.""" + try: + element = self.page.locator(locator).first + if element.count() > 0 and element.is_visible(): + field_input = TextInput(self.page, element, input_name) + self.content_items[input_name] = field_input + self.content_items[list_name] = DropdownList(self.page) + logger.debug(f"Initialized combobox field: '{field_label}'") + except Exception as e: + logger.error(f"Error initializing combobox field '{field_label}': {e}") + + def _init_checkbox_fields(self) -> None: + """Инициализирует checkbox поля формы (опционально).""" + if not self.CHECKBOX_FIELDS_MAPPING: + return + + for field_label, (attr_name, widget_name) in self.CHECKBOX_FIELDS_MAPPING.items(): + locator = self.CHECKBOX_FIELDS_LOCATORS.get(field_label) + if not locator: + continue + self._init_single_checkbox_field(field_label, locator, widget_name) + + def _init_single_checkbox_field(self, field_label: str, locator: str, widget_name: str) -> None: + """Инициализирует одно checkbox поле.""" + try: + checkbox_input = self.page.locator(locator).first + if checkbox_input.count() == 0: + logger.debug(f"Checkbox '{field_label}' not found") + return + + # Импортируем здесь чтобы избежать циклических импортов + from elements.checkbox_element import Checkbox + + checkbox = Checkbox(self.page, checkbox_input, widget_name) + self.content_items[widget_name] = checkbox + logger.debug(f"Initialized checkbox field: '{field_label}'") + except Exception as e: + logger.error(f"Error initializing checkbox '{field_label}': {e}") + + def get_content_item(self, item_name: str) -> Any: + """Возвращает элемент контента по имени.""" + return self.content_items.get(item_name) + + def clear_field(self, field_name: str) -> None: + """Очищает указанное поле.""" + logger.debug(f"Clearing field: '{field_name}'") + + # Проверяем, не является ли поле чекбоксом + if field_name in self.CHECKBOX_FIELDS_LOCATORS: + logger.debug(f"Field '{field_name}' is a checkbox, skipping clear operation") + return + + # Получаем локатор поля + locator = self._get_field_locator(field_name) + if not locator: + logger.warning(f"Unknown field: {field_name}") + return + + field_element = self.page.locator(locator).first + if field_element.count() == 0: + logger.debug(f"Field '{field_name}' not found") + return + + # Очистка в зависимости от типа поля + if field_name in self.TEXT_FIELDS_LOCATORS: + self._clear_text_field(field_element, field_name) + elif field_name in self.COMBOBOX_FIELDS_LOCATORS: + self._clear_combobox_field(field_element, field_name) + + def _get_field_locator(self, field_name: str) -> Optional[str]: + """Получает локатор поля по его названию.""" + if field_name in self.COMBOBOX_FIELDS_LOCATORS: + return self.COMBOBOX_FIELDS_LOCATORS[field_name] + elif field_name in self.TEXT_FIELDS_LOCATORS: + return self.TEXT_FIELDS_LOCATORS[field_name] + elif field_name in self.CHECKBOX_FIELDS_LOCATORS: + return self.CHECKBOX_FIELDS_LOCATORS[field_name] + return None + + def _clear_text_field(self, field_element, field_name: str) -> None: + """Очищает текстовое поле.""" + try: + field_element.click() + field_element.page.keyboard.press("Control+A") + field_element.page.keyboard.press("Backspace") + self.wait_for_timeout(200) + logger.debug(f"Text field '{field_name}' cleared") + except Exception as e: + logger.debug(f"Could not clear text field '{field_name}': {e}") + + def _clear_combobox_field(self, field_element, field_name: str) -> None: + """Очищает combobox поле.""" + try: + parent_container = field_element.locator( + "xpath=ancestor::div[contains(@class, 'v-input')]" + ).first + + if parent_container.count() == 0: + logger.debug(f"Parent container not found for field '{field_name}'") + return + + clear_button = parent_container.locator( + ".v-input__icon--clear button, .v-input__icon--append button, i.mdi-close-circle, i.mdi-close" + ).first + + if clear_button.count() > 0 and clear_button.is_visible(): + clear_button.click() + self.wait_for_timeout(300) + logger.debug(f"Combobox field '{field_name}' cleared") + else: + logger.debug(f"Clear button not found for field '{field_name}'") + except Exception as e: + logger.debug(f"Error clearing combobox field '{field_name}': {e}") + + def _scroll_to_element_in_dropdown(self, value: str) -> bool: + """Скроллит выпадающий список до элемента с нужным текстом.""" + logger.debug(f"Scrolling to find element with text: '{value}'") + + dropdown_menu = self.page.locator("div.menuable__content__active").first + if dropdown_menu.count() == 0: + logger.error("Active dropdown menu not found") + return False + + max_attempts = 10 + attempts = 0 + + while attempts < max_attempts: + visible_items = dropdown_menu.locator("a.v-list__tile, div[role='listitem']").all() + + if visible_items: + for item in visible_items: + item_text = item.text_content() or "" + if value in item_text: + logger.debug(f"Found element with text '{value}'") + item.scroll_into_view_if_needed() + self.wait_for_timeout(300) + return True + + last_item = visible_items[-1] + last_item_text = last_item.text_content() or "" + logger.debug(f"Scrolling to last visible item: '{last_item_text}'") + last_item.scroll_into_view_if_needed() + self.wait_for_timeout(500) + else: + dropdown_menu.evaluate("(el) => el.scrollTop += 200") + self.wait_for_timeout(300) + + attempts += 1 + + logger.warning(f"Element with text '{value}' not found after {max_attempts} attempts") + return False + + def _fill_text_fields(self, rack_data: BaseRackData, results: Dict[str, int]) -> None: + """Заполняет текстовые поля.""" + for field_label, (attr_name, field_name) in self.TEXT_FIELDS_MAPPING.items(): + value = getattr(rack_data, attr_name, "") + if not value or not str(value).strip(): + continue + self._fill_single_text_field(field_label, field_name, value, results) + + def _fill_single_text_field( + self, field_label: str, field_name: str, value: str, results: Dict[str, int] + ) -> None: + """Заполняет одно текстовое поле.""" + try: + input_field = self.get_content_item(field_name) + if input_field: + input_field.input_value(value) + results["text_fields_filled"] += 1 + logger.debug(f"Field '{field_label}' filled: '{value}'") + except Exception as e: + logger.error(f"Error filling field '{field_label}': {e}") + + def _fill_combobox_fields(self, rack_data: BaseRackData, results: Dict[str, int]) -> None: + """Заполняет combobox поля.""" + for field_label, (attr_name, input_name, list_name) in self.COMBOBOX_FIELDS_MAPPING.items(): + value = getattr(rack_data, attr_name, "") + if not value or not str(value).strip(): + continue + self._fill_single_combobox_field(field_label, input_name, list_name, value, results) + + def _fill_single_combobox_field( + self, field_label: str, input_name: str, list_name: str, value: str, results: Dict[str, int] + ) -> None: + """Заполняет одно combobox поле.""" + try: + combobox_field = self.get_content_item(input_name) + if not combobox_field: + logger.warning(f"Field '{field_label}' input not found") + return + + combobox_field.click(force=True) + self.wait_for_timeout(1000) + + if not self._scroll_to_element_in_dropdown(value): + logger.error(f"Could not find element with text '{value}' after scrolling") + self.page.mouse.click(10, 10) + self.wait_for_timeout(300) + return + + dropdown_menu = self.page.locator("div.menuable__content__active").first + item_locator = self._find_dropdown_item(dropdown_menu, value) + + if item_locator and item_locator.count() > 0: + item_locator.scroll_into_view_if_needed() + self.wait_for_timeout(300) + item_locator.click() + results["combobox_fields_filled"] += 1 + logger.debug(f"Field '{field_label}' set: '{value}'") + self.wait_for_timeout(500) + else: + logger.error(f"Item with text '{value}' not found in dropdown for field '{field_label}'") + self.page.mouse.click(10, 10) + self.wait_for_timeout(300) + + except Exception as e: + logger.error(f"Error filling combobox '{field_label}': {e}") + self.page.mouse.click(10, 10) + + def _find_dropdown_item(self, dropdown_menu, value: str): + """Находит элемент в выпадающем списке.""" + item_locator = dropdown_menu.locator(f"a.v-list__tile:has-text('{value}')").first + if item_locator.count() == 0: + item_locator = dropdown_menu.locator(f"span:has-text('{value}')").first + if item_locator.count() == 0: + item_locator = dropdown_menu.locator(f"div[role='listitem']:has-text('{value}')").first + return item_locator + + def _fill_checkbox_fields(self, rack_data: BaseRackData, results: Dict[str, int]) -> None: + """Заполняет checkbox поля (опционально).""" + if not hasattr(self, 'CHECKBOX_FIELDS_MAPPING'): + return + + for field_label, (attr_name, widget_name) in self.CHECKBOX_FIELDS_MAPPING.items(): + value = getattr(rack_data, attr_name, None) + if value is None: + continue + self._fill_single_checkbox_field(field_label, widget_name, value, results) + + def _fill_single_checkbox_field( + self, field_label: str, widget_name: str, value: bool, results: Dict[str, int] + ) -> None: + """Заполняет одно checkbox поле.""" + try: + checkbox = self.get_content_item(widget_name) + if not checkbox: + logger.warning(f"Checkbox '{field_label}' not found") + return + + if value: + checkbox.check(force=True) + logger.debug(f"Checkbox '{field_label}' checked") + else: + checkbox.uncheck(force=True) + logger.debug(f"Checkbox '{field_label}' unchecked") + + results["checkboxes_set"] += 1 + + except Exception as e: + logger.error(f"Error setting checkbox '{field_label}': {e}") + + @abstractmethod + def fill_rack_data(self, rack_data: BaseRackData) -> Dict[str, int]: + """Абстрактный метод для заполнения данных стойки.""" + pass + + def is_field_highlighted_as_error(self, field_name: str) -> bool: + """Проверяет, подсвечено ли поле как ошибочное.""" + # Для чекбоксов не проверяем ошибки + if field_name in self.CHECKBOX_FIELDS_LOCATORS: + return False + + locator = self._get_field_locator(field_name) + if not locator: + return False + + field_element = self.page.locator(locator).first + if field_element.count() == 0: + logger.debug(f"Field '{field_name}' not found") + return False + + parent_input = field_element.locator( + "xpath=ancestor::div[contains(@class, 'v-input')]" + ).first + + if parent_input.count() > 0: + class_attr = parent_input.get_attribute("class") or "" + is_error = "v-input--error" in class_attr or "error--text" in class_attr + logger.debug(f"Field '{field_name}' error state: {is_error}") + return is_error + + return False + + def verify_required_fields_highlighted(self, field_names: List[str]) -> Dict[str, bool]: + """Проверяет, что указанные поля подсвечены как обязательные.""" + results = {} + for field_name in field_names: + results[field_name] = self.is_field_highlighted_as_error(field_name) + logger.debug(f"Field '{field_name}' highlighted: {results[field_name]}") + return results + + def wait_for_field_error(self, field_name: str, timeout: int = 5000) -> bool: + """Ожидает появления подсветки ошибки на поле.""" + if field_name in self.CHECKBOX_FIELDS_LOCATORS: + return False + + start_time = time.time() + while (time.time() - start_time) * 1000 < timeout: + if self.is_field_highlighted_as_error(field_name): + return True + self.wait_for_timeout(200) + return False + + def get_field_value(self, field_name: str) -> Optional[str]: + """Получает значение поля.""" + # Для чекбоксов + if field_name in self.CHECKBOX_FIELDS_LOCATORS: + for field_label, (attr_name, widget_name) in self.CHECKBOX_FIELDS_MAPPING.items(): + if attr_name == field_name or field_label == field_name: + checkbox = self.get_content_item(widget_name) + if checkbox: + return str(checkbox.is_checked()) + return None + + # Для текстовых полей + if field_name in self.TEXT_FIELDS_LOCATORS: + for field_label, (attr_name, widget_name) in self.TEXT_FIELDS_MAPPING.items(): + if attr_name == field_name or field_label == field_name: + input_field = self.get_content_item(widget_name) + if input_field: + return input_field.get_input_value() + return None + + # Для combobox полей + return self._get_combobox_value(field_name) + + def _get_combobox_value(self, field_name: str) -> Optional[str]: + """Получает значение combobox поля.""" + locator = self.COMBOBOX_FIELDS_LOCATORS.get(field_name) + if not locator: + for field_label, (attr_name, input_name, _) in self.COMBOBOX_FIELDS_MAPPING.items(): + if attr_name == field_name or field_label == field_name: + input_field = self.get_content_item(input_name) + if input_field: + selections = input_field.element.locator( + "xpath=ancestor::div[contains(@class, 'v-select__selections')]" + ).first + if selections.count() > 0: + value_span = selections.locator("span").first + return value_span.text_content() or "" + return None + + element = self.page.locator(locator).first + if element.count() > 0: + selections = element.locator( + "xpath=ancestor::div[contains(@class, 'v-select__selections')]" + ).first + if selections.count() > 0: + value_span = selections.locator("span").first + return value_span.text_content() or "" + return None