commit 895bea43d2e36143c45d5e359a3daed204377214 Author: spawn Date: Sat Feb 14 01:35:11 2026 +0300 ДЗ #1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9124b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +build/ +.idea/ +.vscode/ +*.iml +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb976c0 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# OTUS Selenium Homework 1 + +## Цель проекта +Автоматизировать 3 UI-сценария на `https://otus.ru` с использованием Selenium WebDriver 4+, JUnit 5, Guice DI, listeners, Stream API, Jsoup и обязательных проверок качества (Checkstyle + SpotBugs). + +## Стек технологий +- Java 21 +- Maven +- Selenium `4.38.0` +- WebDriverManager `6.3.3` +- JUnit 5 +- Guice +- Jsoup +- Checkstyle +- SpotBugs + +## Реализованные сценарии +1. Поиск курса по имени. + - Открытие каталога `https://otus.ru/catalog/courses` + - Поиск курса по имени через Stream API + - Клик по плитке курса + - Проверка заголовка открытого курса + +2. Самые ранние/поздние курсы по дате старта. + - Открытие каталога `https://otus.ru/catalog/courses` + - Поиск ранних и поздних курсов через Stream API + `reduce` + - При совпадении дат проверяются все курсы с этой датой + - Проверка названия и даты старта на странице курса через Jsoup + +3. Случайная категория с главной страницы. + - Открытие `https://otus.ru` + - Открытие меню «Обучение» + - Выбор случайной категории + - Проверка, что открыта корректная категория + +## Архитектура +- 2-уровневый тест-дизайн: `tests` + `page objects` +- DI через Guice для тестов и страниц +- JUnit 5 Extension (`GuiceExtension`), без базового класса-теста +- Фабрика драйвера: + - `DriverFactory` (интерфейс) + - `ChromeDriverFactory` (реализация) + - `WebDriverProvider` (жизненный цикл драйвера + декоратор listener) +- Подсветка через listener: + - Подсветка ставится в `beforeClick` + - Снимается в `afterClick` + - Стиль элемента возвращается в исходное состояние + +## Структура проекта +- `src/main/java/ru/kovbasa/config` — DI-конфигурация +- `src/main/java/ru/kovbasa/driver` — фабрика и провайдер WebDriver +- `src/main/java/ru/kovbasa/listeners` — listener подсветки +- `src/main/java/ru/kovbasa/pages` — Page Object классы +- `src/main/java/ru/kovbasa/elements` — типизированные UI-элементы +- `src/test/java/ru/kovbasa/config` — JUnit extension для DI +- `src/test/java/ru/kovbasa/tests` — автотесты + +## Требования к окружению +1. Установлен JDK 21 (доступен в `PATH`) +2. Установлен Google Chrome +3. Установлен Maven 3.9+ +4. Есть доступ в интернет и к `otus.ru` (тесты запускаются на живом сайте) + +## Запуск +### 1. Запуск только тестов +```bash +mvn test +``` + +### 2. Полная проверка (тесты + Checkstyle + SpotBugs) +```bash +mvn verify +``` + +### 3. Запуск отдельного тестового класса +```bash +mvn "-Dtest=ru.kovbasa.tests.CourseSearchTest" test +``` + +## Параметры запуска +Пробрасываются через Maven Surefire: +- `base.url` (по умолчанию `https://otus.ru`) +- `course.name` (по умолчанию `Python Developer`) + +Пример переопределения: +```bash +mvn "-Dcourse.name=Python Developer" test +``` + +## Quality Gates +- Checkstyle и SpotBugs выполняется в фазе `verify` + +## Примечания +- Тесты зависят от текущей верстки/контента `otus.ru`. diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 0000000..4a6d6ed --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..0e8899d --- /dev/null +++ b/pom.xml @@ -0,0 +1,173 @@ + + + + 4.0.0 + + ru.kovbasa + homework_1 + 1.0-SNAPSHOT + + + 21 + + + https://otus.ru + Python Developer + + + 4.38.0 + 5.10.0 + 6.3.3 + 5.1.0 + 1.21.2 + 2.0.11 + 1.4.14 + 32.1.3-jre + + + 3.11.0 + 3.1.2 + 3.6.0 + 4.9.8.0 + 4.9.8 + + UTF-8 + + + + + + org.seleniumhq.selenium + selenium-java + ${selenium.version} + + + + + io.github.bonigarcia + webdrivermanager + ${webdrivermanager.version} + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + com.google.inject + guice + ${guice.version} + + + + + org.jsoup + jsoup + ${jsoup.version} + + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + ch.qos.logback + logback-classic + ${logback.version} + + + + + com.google.guava + guava + ${guava.version} + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.version} + + ${java.version} + ${java.version} + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.version} + + false + + ${base.url} + ${course.name} + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${checkstyle.plugin.version} + + + checkstyle-validation + verify + + check + + + + + checkstyle.xml + true + true + + + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs.plugin.version} + + + com.github.spotbugs + spotbugs + ${spotbugs.version} + + + + + verify + + check + + + + + spotbugs-exclude.xml + + + + + + diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml new file mode 100644 index 0000000..372f5f2 --- /dev/null +++ b/spotbugs-exclude.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/java/ru/kovbasa/config/DriverModule.java b/src/main/java/ru/kovbasa/config/DriverModule.java new file mode 100644 index 0000000..5248999 --- /dev/null +++ b/src/main/java/ru/kovbasa/config/DriverModule.java @@ -0,0 +1,16 @@ +package ru.kovbasa.config; + +import com.google.inject.AbstractModule; +import com.google.inject.Singleton; +import ru.kovbasa.driver.ChromeDriverFactory; +import ru.kovbasa.driver.DriverFactory; +import ru.kovbasa.driver.WebDriverProvider; + +public class DriverModule extends AbstractModule { + + @Override + protected void configure() { + bind(DriverFactory.class).to(ChromeDriverFactory.class).in(Singleton.class); + bind(WebDriverProvider.class).in(Singleton.class); + } +} diff --git a/src/main/java/ru/kovbasa/config/InjectorProvider.java b/src/main/java/ru/kovbasa/config/InjectorProvider.java new file mode 100644 index 0000000..d9456bd --- /dev/null +++ b/src/main/java/ru/kovbasa/config/InjectorProvider.java @@ -0,0 +1,16 @@ +package ru.kovbasa.config; + +import com.google.inject.Guice; +import com.google.inject.Injector; + +public final class InjectorProvider { + + private static final Injector INJECTOR = Guice.createInjector(new DriverModule()); + + private InjectorProvider() { + } + + public static Injector getInjector() { + return INJECTOR; + } +} \ No newline at end of file diff --git a/src/main/java/ru/kovbasa/config/TestConfig.java b/src/main/java/ru/kovbasa/config/TestConfig.java new file mode 100644 index 0000000..fa7527c --- /dev/null +++ b/src/main/java/ru/kovbasa/config/TestConfig.java @@ -0,0 +1,21 @@ +package ru.kovbasa.config; + +public final class TestConfig { + + private static final String BASE_URL = + System.getProperty("base.url", "https://otus.ru"); + + private static final String COURSE_NAME = + System.getProperty("course.name", "Python Developer"); + + private TestConfig() { + } + + public static String getBaseUrl() { + return BASE_URL; + } + + public static String getCourseName() { + return COURSE_NAME; + } +} diff --git a/src/main/java/ru/kovbasa/driver/ChromeDriverFactory.java b/src/main/java/ru/kovbasa/driver/ChromeDriverFactory.java new file mode 100644 index 0000000..1c90404 --- /dev/null +++ b/src/main/java/ru/kovbasa/driver/ChromeDriverFactory.java @@ -0,0 +1,16 @@ +package ru.kovbasa.driver; + +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.chrome.ChromeOptions; + +public class ChromeDriverFactory implements DriverFactory { + + @Override + public WebDriver createDriver() { + final ChromeOptions options = new ChromeOptions(); + options.addArguments("--start-maximized"); + options.addArguments("--disable-notifications"); + return new ChromeDriver(options); + } +} diff --git a/src/main/java/ru/kovbasa/driver/DriverFactory.java b/src/main/java/ru/kovbasa/driver/DriverFactory.java new file mode 100644 index 0000000..fccc68d --- /dev/null +++ b/src/main/java/ru/kovbasa/driver/DriverFactory.java @@ -0,0 +1,7 @@ +package ru.kovbasa.driver; + +import org.openqa.selenium.WebDriver; + +public interface DriverFactory { + WebDriver createDriver(); +} diff --git a/src/main/java/ru/kovbasa/driver/WebDriverProvider.java b/src/main/java/ru/kovbasa/driver/WebDriverProvider.java new file mode 100644 index 0000000..2b40ec1 --- /dev/null +++ b/src/main/java/ru/kovbasa/driver/WebDriverProvider.java @@ -0,0 +1,43 @@ +package ru.kovbasa.driver; + +import com.google.inject.Inject; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.support.events.EventFiringDecorator; + +import io.github.bonigarcia.wdm.WebDriverManager; +import ru.kovbasa.listeners.HighlightElementListener; + +public final class WebDriverProvider { + + private WebDriver driver; + private final DriverFactory driverFactory; + + static { + WebDriverManager.chromedriver().setup(); + } + + @Inject + public WebDriverProvider(DriverFactory driverFactory) { + this.driverFactory = driverFactory; + } + + public WebDriver getDriver() { + if (driver == null) { + driver = createDecoratedDriver(); + } + return driver; + } + + private WebDriver createDecoratedDriver() { + final WebDriver raw = driverFactory.createDriver(); + return new EventFiringDecorator(new HighlightElementListener()) + .decorate(raw); + } + + public void quit() { + if (driver != null) { + driver.quit(); + driver = null; + } + } +} diff --git a/src/main/java/ru/kovbasa/elements/BaseElement.java b/src/main/java/ru/kovbasa/elements/BaseElement.java new file mode 100644 index 0000000..9f9b833 --- /dev/null +++ b/src/main/java/ru/kovbasa/elements/BaseElement.java @@ -0,0 +1,27 @@ +package ru.kovbasa.elements; + +import org.openqa.selenium.WebElement; + +public abstract class BaseElement implements UIElement { + + protected final WebElement element; + + protected BaseElement(WebElement element) { + this.element = element; + } + + @Override + public void click() { + element.click(); + } + + @Override + public String getText() { + return element.getText(); + } + + @Override + public boolean isDisplayed() { + return element.isDisplayed(); + } +} diff --git a/src/main/java/ru/kovbasa/elements/Button.java b/src/main/java/ru/kovbasa/elements/Button.java new file mode 100644 index 0000000..f784835 --- /dev/null +++ b/src/main/java/ru/kovbasa/elements/Button.java @@ -0,0 +1,14 @@ +package ru.kovbasa.elements; + +import org.openqa.selenium.WebElement; + +public class Button extends BaseElement { + + public Button(WebElement element) { + super(element); + } + + public boolean isEnabled() { + return element.isEnabled(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/kovbasa/elements/CourseCard.java b/src/main/java/ru/kovbasa/elements/CourseCard.java new file mode 100644 index 0000000..37a034f --- /dev/null +++ b/src/main/java/ru/kovbasa/elements/CourseCard.java @@ -0,0 +1,54 @@ +package ru.kovbasa.elements; + +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import java.time.LocalDate; +import java.time.Year; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +public class CourseCard extends BaseElement { + + private final By titleLocator = By.cssSelector("h6 .sc-hrqzy3-1"); + private final By dateLocator = By.cssSelector(".sc-157icee-1 .sc-hrqzy3-1"); + + public CourseCard(WebElement element) { + super(element); + } + + public String title() { + return element.findElement(titleLocator).getText().trim(); + } + + public LocalDate startDate() { + String raw = element.findElement(dateLocator).getText().trim(); + if (raw.isEmpty()) { + throw new RuntimeException("Пустая дата на карточке курса: " + title()); + } + + String[] parts = raw.split("·"); + String datePart = parts[0].trim(); + + if (datePart.startsWith("С ")) { + datePart = datePart.substring(2).trim(); + } + + datePart = datePart.replace(",", "").replace("года", "").trim(); + + boolean hasYear = datePart.matches(".*\\d{4}.*"); + + String normalized; + DateTimeFormatter formatter; + + if (hasYear) { + normalized = datePart; + formatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru")); + } else { + normalized = datePart + " " + Year.now().getValue(); + formatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru")); + } + + return LocalDate.parse(normalized, formatter); + } +} \ No newline at end of file diff --git a/src/main/java/ru/kovbasa/elements/Link.java b/src/main/java/ru/kovbasa/elements/Link.java new file mode 100644 index 0000000..010d46b --- /dev/null +++ b/src/main/java/ru/kovbasa/elements/Link.java @@ -0,0 +1,14 @@ +package ru.kovbasa.elements; + +import org.openqa.selenium.WebElement; + +public class Link extends BaseElement { + + public Link(WebElement element) { + super(element); + } + + public String getHref() { + return element.getAttribute("href"); + } +} \ No newline at end of file diff --git a/src/main/java/ru/kovbasa/elements/UIElement.java b/src/main/java/ru/kovbasa/elements/UIElement.java new file mode 100644 index 0000000..b7a7e41 --- /dev/null +++ b/src/main/java/ru/kovbasa/elements/UIElement.java @@ -0,0 +1,7 @@ +package ru.kovbasa.elements; + +public interface UIElement { + void click(); + String getText(); + boolean isDisplayed(); +} \ No newline at end of file diff --git a/src/main/java/ru/kovbasa/listeners/HighlightElementListener.java b/src/main/java/ru/kovbasa/listeners/HighlightElementListener.java new file mode 100644 index 0000000..0c40c64 --- /dev/null +++ b/src/main/java/ru/kovbasa/listeners/HighlightElementListener.java @@ -0,0 +1,151 @@ +package ru.kovbasa.listeners; + +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.StaleElementReferenceException; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.WrapsDriver; +import org.openqa.selenium.support.events.WebDriverListener; + +public class HighlightElementListener implements WebDriverListener { + + private static final String HIGHLIGHT_STYLE = + "outline: 4px solid red !important; " + + "box-shadow: 0 0 0 4px rgba(255,0,0,0.20) !important; " + + "background: rgba(255,0,0,0.06) !important;"; + private WebElement highlightedElement; + private String highlightedOriginalStyle; + + @Override + public void beforeClick(WebElement element) { + try { + clearHighlight(); + + final String tag = safeGetTag(element); + final String href = safeGetAttr(element, "href"); + final String cls = safeGetAttr(element, "class"); + System.out.println("beforeClick: tag=" + tag + " href=" + href + " class=" + cls); + + final WebElement tile = findTileContainer(element); + applyHighlight(tile); + } catch (Exception e) { + System.out.println("Highlight listener error: " + e.getMessage()); + } + } + + @Override + public void afterClick(WebElement element) { + clearHighlight(); + } + + private void applyHighlight(WebElement element) { + if (element == null) { + return; + } + + try { + final WebDriver raw = ((WrapsDriver) element).getWrappedDriver(); + if (!(raw instanceof JavascriptExecutor)) { + return; + } + + final JavascriptExecutor js = (JavascriptExecutor) raw; + + String original = safeGetAttr(element, "style"); + if (original == null) { + original = ""; + } + + this.highlightedElement = element; + this.highlightedOriginalStyle = original; + + js.executeScript("arguments[0].setAttribute('style', arguments[1] + '; ' + arguments[2]);", + element, original, HIGHLIGHT_STYLE); + } catch (StaleElementReferenceException ignored) { + } catch (Exception e) { + System.out.println("applyHighlight error: " + e.getMessage()); + } + } + + private void clearHighlight() { + if (highlightedElement == null) { + return; + } + + try { + final WebDriver raw = ((WrapsDriver) highlightedElement).getWrappedDriver(); + if (!(raw instanceof JavascriptExecutor)) { + highlightedElement = null; + highlightedOriginalStyle = null; + return; + } + + final JavascriptExecutor js = (JavascriptExecutor) raw; + final String original = highlightedOriginalStyle == null ? "" : highlightedOriginalStyle; + js.executeScript("arguments[0].setAttribute('style', arguments[1]);", highlightedElement, original); + } catch (StaleElementReferenceException ignored) { + } catch (Exception e) { + System.out.println("clearHighlight error: " + e.getMessage()); + } finally { + highlightedElement = null; + highlightedOriginalStyle = null; + } + } + + private WebElement findTileContainer(WebElement element) { + try { + final String tag = safeGetTag(element); + final String href = safeGetAttr(element, "href"); + final String cls = safeGetAttr(element, "class"); + + if ("a".equalsIgnoreCase(tag) && href != null && + (href.contains("/lessons/") || href.contains("/categories/"))) { + return element; + } + if (cls != null && cls.contains("sc-zzdkm7-0")) { + return element; + } + + final WebDriver rawDriver = ((WrapsDriver) element).getWrappedDriver(); + if (!(rawDriver instanceof JavascriptExecutor)) { + return element; + } + + final JavascriptExecutor js = (JavascriptExecutor) rawDriver; + + WebElement current = element; + for (int i = 0; i < 8; i++) { + final Object parentObj = js.executeScript("return arguments[0].parentElement;", current); + if (!(parentObj instanceof WebElement)) { + break; + } + + current = (WebElement) parentObj; + + final String t = safeGetTag(current); + final String h = safeGetAttr(current, "href"); + final String c = safeGetAttr(current, "class"); + + if ("a".equalsIgnoreCase(t) && h != null && + (h.contains("/lessons/") || h.contains("/categories/"))) { + return current; + } + if (c != null && c.contains("sc-zzdkm7-0")) { + return current; + } + } + } catch (Exception ignored) { + } + return element; + } + + private String safeGetAttr(WebElement el, String name) { + try { return el.getAttribute(name); } + catch (Exception e) { return null; } + } + + private String safeGetTag(WebElement el) { + try { return el.getTagName(); } + catch (Exception e) { return null; } + } +} diff --git a/src/main/java/ru/kovbasa/pages/CatalogPage.java b/src/main/java/ru/kovbasa/pages/CatalogPage.java new file mode 100644 index 0000000..889ce58 --- /dev/null +++ b/src/main/java/ru/kovbasa/pages/CatalogPage.java @@ -0,0 +1,138 @@ +package ru.kovbasa.pages; + +import com.google.inject.Inject; +import java.time.Duration; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.interactions.Actions; +import org.openqa.selenium.support.ui.ExpectedCondition; +import org.openqa.selenium.support.ui.WebDriverWait; + +import ru.kovbasa.config.TestConfig; +import ru.kovbasa.driver.WebDriverProvider; +import ru.kovbasa.elements.CourseCard; +import ru.kovbasa.utils.PageUtils; + +public class CatalogPage { + + private final WebDriver driver; + + private final By courseCards = By.cssSelector("a.sc-zzdkm7-0"); + private final By courseLinks = By.cssSelector("a[href*='/lessons/']"); + + @Inject + public CatalogPage(WebDriverProvider provider) { + this.driver = provider.getDriver(); + } + + public CatalogPage open() { + driver.get(TestConfig.getBaseUrl() + "/catalog/courses"); + return this; + } + + public WebElement findCourseByName(String name) { + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15)); + wait.until((ExpectedCondition) drv -> !drv.findElements(courseLinks).isEmpty()); + + final List links = driver.findElements(courseLinks); + return links.stream() + .filter(e -> e.getText().toLowerCase().contains(name.toLowerCase())) + .findFirst() + .orElseThrow(() -> new NoSuchElementException( + "Course not found in catalog by name: " + name)); + } + + public CoursePage clickCourseByName(String name) { + PageUtils.removeBottomBanner(driver); + + final WebElement course = findCourseByName(name); + + ((JavascriptExecutor) driver) + .executeScript("arguments[0].scrollIntoView({block:'center'});", course); + + new Actions(driver) + .moveToElement(course) + .perform(); + course.click(); + + return new CoursePage(driver); + } + + public List getAllCourseCards() { + return driver.findElements(courseCards).stream() + .map(CourseCard::new) + .toList(); + } + + public List getAllCourses() { + return getAllCourseCards().stream() + .map(card -> { + try { + return new CourseItem(card.title(), card.startDate()); + } catch (RuntimeException e) { + return null; + } + }) + .filter(Objects::nonNull) + .toList(); + } + + public List findEarliestCourses() { + final List all = getAllCourses(); + + final LocalDate minDate = all.stream() + .map(CourseItem::startDate) + .reduce((left, right) -> left.isBefore(right) ? left : right) + .orElseThrow(); + + return all.stream() + .filter(c -> c.startDate().isEqual(minDate)) + .sorted(Comparator.comparing(CourseItem::title)) + .toList(); + } + + public List findLatestCourses() { + final List all = getAllCourses(); + + final LocalDate maxDate = all.stream() + .map(CourseItem::startDate) + .reduce((left, right) -> left.isAfter(right) ? left : right) + .orElseThrow(); + + return all.stream() + .filter(c -> c.startDate().isEqual(maxDate)) + .sorted(Comparator.comparing(CourseItem::title)) + .toList(); + } + + public CoursePage openCourse(CourseItem course) { + PageUtils.removeBottomBanner(driver); + + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10)); + + final WebElement card = wait.until(drv -> + drv.findElements(courseCards).stream() + .filter(c -> new CourseCard(c).title().equals(course.title())) + .findFirst() + .orElse(null) + ); + + ((JavascriptExecutor) driver) + .executeScript("arguments[0].scrollIntoView({block:'center'});", card); + + new Actions(driver) + .moveToElement(card) + .perform(); + card.click(); + + return new CoursePage(driver); + } +} diff --git a/src/main/java/ru/kovbasa/pages/CategoryPage.java b/src/main/java/ru/kovbasa/pages/CategoryPage.java new file mode 100644 index 0000000..1271ee5 --- /dev/null +++ b/src/main/java/ru/kovbasa/pages/CategoryPage.java @@ -0,0 +1,24 @@ +package ru.kovbasa.pages; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.openqa.selenium.WebDriver; + +public class CategoryPage { + + private final WebDriver driver; + + public CategoryPage(WebDriver driver) { + this.driver = driver; + } + + public String getHeader() { + Document doc = Jsoup.parse(driver.getPageSource()); + return doc.select("h1") + .stream() + .map(e -> e.text().trim()) + .filter(t -> !t.isEmpty()) + .findFirst() + .orElseThrow(() -> new RuntimeException("Category header not found")); + } +} \ No newline at end of file diff --git a/src/main/java/ru/kovbasa/pages/CourseItem.java b/src/main/java/ru/kovbasa/pages/CourseItem.java new file mode 100644 index 0000000..79d0570 --- /dev/null +++ b/src/main/java/ru/kovbasa/pages/CourseItem.java @@ -0,0 +1,6 @@ +package ru.kovbasa.pages; + +import java.time.LocalDate; + +public record CourseItem(String title, LocalDate startDate) { +} \ No newline at end of file diff --git a/src/main/java/ru/kovbasa/pages/CoursePage.java b/src/main/java/ru/kovbasa/pages/CoursePage.java new file mode 100644 index 0000000..23740b6 --- /dev/null +++ b/src/main/java/ru/kovbasa/pages/CoursePage.java @@ -0,0 +1,62 @@ +package ru.kovbasa.pages; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; +import ru.kovbasa.elements.Button; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +public class CoursePage { + + private final WebDriver driver; + + private final By enrollButton = By.cssSelector("button[data-testid='enroll-button']"); + + public CoursePage(WebDriver driver) { + this.driver = driver; + } + + public String getCourseTitle() { + return getFirstH1(); + } + + public String getFirstH1() { + Document doc = Jsoup.parse(driver.getPageSource()); + return doc.select("h1") + .stream() + .map(e -> e.text().trim()) + .filter(t -> !t.isEmpty()) + .findFirst() + .orElseThrow(() -> new RuntimeException("Course title not found")); + } + + public LocalDate getCourseStartDate(LocalDate catalogDate) { + Document doc = Jsoup.parse(driver.getPageSource()); + + Element dateElement = doc.selectFirst("p.sc-3cb1l3-0"); + if (dateElement == null) { + throw new RuntimeException("Start date

not found on course page"); + } + + String text = dateElement.text().trim(); + String fullDate = text + ", " + catalogDate.getYear(); + + DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("d MMMM, yyyy", Locale.forLanguageTag("ru")); + + return LocalDate.parse(fullDate, formatter); + } + + public Button getEnrollButton() { + return new Button(driver.findElement(enrollButton)); + } + + public void clickEnroll() { + getEnrollButton().click(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/kovbasa/pages/MainPage.java b/src/main/java/ru/kovbasa/pages/MainPage.java new file mode 100644 index 0000000..2499cab --- /dev/null +++ b/src/main/java/ru/kovbasa/pages/MainPage.java @@ -0,0 +1,71 @@ +package ru.kovbasa.pages; + +import com.google.inject.Inject; +import org.openqa.selenium.By; +import org.openqa.selenium.ElementClickInterceptedException; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.interactions.Actions; +import org.openqa.selenium.support.ui.WebDriverWait; +import ru.kovbasa.config.TestConfig; +import ru.kovbasa.driver.WebDriverProvider; +import ru.kovbasa.utils.PageUtils; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +public class MainPage { + + private final WebDriver driver; + + private final By menuLearning = By.cssSelector("span[title='Обучение']"); + private final By categories = By.cssSelector("a[href*='/categories/']"); + + @Inject + public MainPage(WebDriverProvider provider) { + this.driver = provider.getDriver(); + } + + public MainPage open() { + driver.get(TestConfig.getBaseUrl()); + return this; + } + + public String clickRandomCategory() { + PageUtils.removeBottomBanner(driver); + + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10)); + + final WebElement menu = wait.until(drv -> drv.findElement(menuLearning)); + menu.click(); + + final List els = wait.until(drv -> { + final List found = drv.findElements(categories).stream() + .filter(WebElement::isDisplayed) + .toList(); + return found.isEmpty() ? null : found; + }); + + final WebElement chosen = els.get(ThreadLocalRandom.current().nextInt(els.size())); + final String href = chosen.getAttribute("href"); + System.out.println("Selected category href = " + href); + + ((JavascriptExecutor) driver) + .executeScript("arguments[0].scrollIntoView({block:'center'});", chosen); + + try { + chosen.click(); + } catch (ElementClickInterceptedException ignored) { + new Actions(driver).moveToElement(chosen).click().perform(); + } + + final String categorySlug = href.substring(href.lastIndexOf('/') + 1); + if (!driver.getCurrentUrl().contains(categorySlug)) { + driver.get(TestConfig.getBaseUrl() + "/catalog/courses?categories=" + categorySlug); + } + + return href; + } +} diff --git a/src/main/java/ru/kovbasa/utils/PageUtils.java b/src/main/java/ru/kovbasa/utils/PageUtils.java new file mode 100644 index 0000000..0c03c86 --- /dev/null +++ b/src/main/java/ru/kovbasa/utils/PageUtils.java @@ -0,0 +1,22 @@ +package ru.kovbasa.utils; + +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.WebDriver; + +public final class PageUtils { + + private PageUtils() { + } + + public static void removeBottomBanner(WebDriver driver) { + try { + ((JavascriptExecutor) driver) + .executeScript( + "document.querySelectorAll('.sc-11pdrud-1.cmIXWc')" + + ".forEach(e => e.remove());" + ); + } catch (Exception ignored) { + } + } + +} diff --git a/src/test/java/ru/kovbasa/config/GuiceExtension.java b/src/test/java/ru/kovbasa/config/GuiceExtension.java new file mode 100644 index 0000000..545ed94 --- /dev/null +++ b/src/test/java/ru/kovbasa/config/GuiceExtension.java @@ -0,0 +1,29 @@ +package ru.kovbasa.config; + +import com.google.inject.Injector; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestInstancePostProcessor; +import ru.kovbasa.driver.WebDriverProvider; + +public class GuiceExtension implements TestInstancePostProcessor, BeforeEachCallback, AfterEachCallback { + + @Override + public void postProcessTestInstance(Object testInstance, ExtensionContext context) { + final Injector injector = InjectorProvider.getInjector(); + injector.injectMembers(testInstance); + } + + @Override + public void beforeEach(ExtensionContext context) { + final Injector injector = InjectorProvider.getInjector(); + injector.getInstance(WebDriverProvider.class).getDriver(); + } + + @Override + public void afterEach(ExtensionContext context) { + final Injector injector = InjectorProvider.getInjector(); + injector.getInstance(WebDriverProvider.class).quit(); + } +} diff --git a/src/test/java/ru/kovbasa/tests/CategoryRandomTest.java b/src/test/java/ru/kovbasa/tests/CategoryRandomTest.java new file mode 100644 index 0000000..6487c29 --- /dev/null +++ b/src/test/java/ru/kovbasa/tests/CategoryRandomTest.java @@ -0,0 +1,35 @@ +package ru.kovbasa.tests; + +import com.google.inject.Inject; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.Test; +import ru.kovbasa.config.GuiceExtension; +import ru.kovbasa.driver.WebDriverProvider; +import ru.kovbasa.pages.MainPage; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(GuiceExtension.class) +public class CategoryRandomTest { + + @Inject + private MainPage main; + + @Inject + private WebDriverProvider webDriverProvider; + + @Test + void randomCategoryOpensCorrectCatalog() { + main.open(); + + final String selectedHref = main.clickRandomCategory(); + final String category = selectedHref.substring(selectedHref.lastIndexOf("/") + 1); + final String currentUrl = webDriverProvider.getDriver().getCurrentUrl(); + + assertTrue( + currentUrl.contains(category), + "Catalog URL should contain selected category. Selected: " + + selectedHref + ", current: " + currentUrl + ); + } +} diff --git a/src/test/java/ru/kovbasa/tests/CourseSearchTest.java b/src/test/java/ru/kovbasa/tests/CourseSearchTest.java new file mode 100644 index 0000000..0b4c71b --- /dev/null +++ b/src/test/java/ru/kovbasa/tests/CourseSearchTest.java @@ -0,0 +1,33 @@ +package ru.kovbasa.tests; + +import com.google.inject.Inject; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.Test; +import ru.kovbasa.config.GuiceExtension; +import ru.kovbasa.config.TestConfig; +import ru.kovbasa.pages.CatalogPage; +import ru.kovbasa.pages.CoursePage; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(GuiceExtension.class) +public class CourseSearchTest { + + @Inject + private CatalogPage catalog; + + @Test + void findCourseByName() { + catalog.open(); + + final String courseName = TestConfig.getCourseName(); + + final CoursePage page = catalog.clickCourseByName(courseName); + + final String title = page.getCourseTitle(); + assertTrue( + title.toLowerCase().contains(courseName.toLowerCase()), + "Course page title should contain searched course name" + ); + } +} diff --git a/src/test/java/ru/kovbasa/tests/CoursesDatesTest.java b/src/test/java/ru/kovbasa/tests/CoursesDatesTest.java new file mode 100644 index 0000000..51b7e37 --- /dev/null +++ b/src/test/java/ru/kovbasa/tests/CoursesDatesTest.java @@ -0,0 +1,74 @@ +package ru.kovbasa.tests; + +import com.google.inject.Inject; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.Test; +import ru.kovbasa.config.GuiceExtension; +import ru.kovbasa.pages.CatalogPage; +import ru.kovbasa.pages.CourseItem; +import ru.kovbasa.pages.CoursePage; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(GuiceExtension.class) +public class CoursesDatesTest { + + @Inject + private CatalogPage catalog; + + @Test + void earliestCourseHasCorrectTitleAndDate() { + catalog.open(); + + final List earliestCourses = catalog.findEarliestCourses(); + + for (CourseItem course : earliestCourses) { + final CoursePage page = catalog.openCourse(course); + + final String pageTitle = page.getCourseTitle(); + assertTrue( + pageTitle.toLowerCase().contains(course.title().toLowerCase()), + "Earliest course title on page should contain title from catalog: " + course.title() + ); + + final LocalDate pageDate = page.getCourseStartDate(course.startDate()); + assertEquals( + course.startDate(), + pageDate, + "Earliest course start date should match for course: " + course.title() + ); + + catalog.open(); + } + } + + @Test + void latestCourseHasCorrectTitleAndDate() { + catalog.open(); + + final List latestCourses = catalog.findLatestCourses(); + + for (CourseItem course : latestCourses) { + final CoursePage page = catalog.openCourse(course); + + final String pageTitle = page.getCourseTitle(); + assertTrue( + pageTitle.toLowerCase().contains(course.title().toLowerCase()), + "Latest course title on page should contain title from catalog: " + course.title() + ); + + final LocalDate pageDate = page.getCourseStartDate(course.startDate()); + assertEquals( + course.startDate(), + pageDate, + "Latest course start date should match for course: " + course.title() + ); + + catalog.open(); + } + } +}