[Разработка под Android, Тестирование мобильных приложений, Kotlin] Паттерн PageObject в Kotlin для UI-тестирования Android (перевод)
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Это перевод статьиведущего Android & iOS разработчика Yahoo (Verizon Media) Брама Йе. Он рассказывает о внедрении паттерна PageObject в свои инструментальные тесты, который делает их более гибкими и легко модифицируемым в зависимости от изменений пользовательского интерфейса. Более того, по словам Брама, благодаря DSL в Kotlin, паттерн PageObject стал более содержательным и более читабельным в тест-кейсах.
TL;DR
Определите базовый класс Page, у которого есть функция fun <reified T : Page> on(): T, которая создает экземпляр PageObject по типу T:
open class Page {
companion object {
inline fun <reified T : Page> on(): T {
return Page().on()
}
}
inline fun <reified T : Page> on(): T {
val page = T::class.constructors.first().call()
page.verify()
return page
}
}
Затем все остальные объекты страницы наследуются от Page:
class ItemPage : Page() {
fun withTitle(keyword: String): ItemPage {
Espresso.onView(withId(R.id.productitem_name))
.check(matches(ViewMatchers.withText(keyword)))
return this
После этого мы можем написать наш тест-кейс следующим образом:
@Test
fun testSearchById() {
Page.on<DiscoveryPage>()
.on<SearchBoxPage>()
.click()
.on<SearchViewPage>()
.searchKeyword("7882691")
.on<ItemPage>()
.withTitle("A1NJ5J02")
}
UI-тестирование Android
Тесты пользовательского интерфейса Android обычно выполняются на физических устройствах и эмуляторах (мы, например, пользуемся Espresso) – и в нашем проекте их очень много. Раньше мы настраивали множество вспомогательных методов для реализации UI-тестов. Это делало нашу тестовую функцию краткой, но трудной для понимания процессов поведения, навигации и пользовательского интерфейса, который мы тестировали. А когда UI приложения часто обновляется, его тестирование становится кошмаром для поддержки.
Вспомогательные методы полезны, но когда UI меняется очень часто, становится очень непросто определить, какие именно из этих методов соответствуют обновленному пользовательскому интерфейсу, если только UI-тесты не проваливаются или мы не отслеживаем код со всей осторожностью.
Паттерн PageObject
Основное правило для PageObject состоит в том, что он должен позволять программному клиенту делать и видеть все то же, что и пользователь. Они также должны предоставлять простой программный интерфейс, скрывая детали реализации экрана. – Мартин Фаулер
Я не объясню концепцию PageObject лучше, чем Мартин, даже несмотря на то, что он описал его использование при Web-разработке. Поэтому я настоятельно рекомендую прочитать его статью здесь.
Преимущества PageObject
- Уменьшение количества дублируемого кода
Несмотря на то, что вспомогательные методы тоже уменьшают дублирование кода, PageObject инкапсулирует и скрывает детали UI-структуры и виджетов от тест-кейсов. Таким образом, мы фокусируемся на поведении тест-кейсов отдельно от деталей пользовательского интерфейса и делаем их более читабельными.
- Повышение удобства сопровождения тест-кейсов, особенно для проектов с частыми изменениями UI
C паттерном PageObject нам всего лишь нужно настроить один или несколько объектов страницы, когда пользовательский интерфейс меняется. Кроме того, мы можем легко узнать, какие объекты страницы должны быть также модифицированы. В итоге разработчики экономят много времени, не отслеживая код теста и выясняя, почему этот тестовый случай завершился неудачей.
С другой стороны, при разработке нового фрагмента диалога или какого-либо сложного UI-компонента нам также нужно было бы написать соответствующий класс PageObject , который содержит соответствующие проверки по умолчанию и требуемую механику. После этого любой инженер может быстро написать новые тест-кейсы, следуя последовательности операций пользовательского интерфейса.
- Улучшение читабельности тест-кейсов
Я объясню наши сценарии и детали позже, а сейчас я хотел бы поделиться реальным кейсом.
@Test
fun testSearchById() {
Page.on<DiscoveryPage>()
.on<SearchBoxPage>()
.click()
.on<SearchViewPage>()
.searchKeyword("7882691")
.on<ItemPage>()
.withTitle("A1NJ5J02")
}
Легко понять, что этот тест проходит через фрагмент Discovery, кликает на представление SearchBox, вводит ключевое слово в редактируемый SearchView, а затем показывает фрагмент Item с указанным заголовком.
- PageObject может наследоваться от другого PageObject
Например, многие фрагменты содержат RecyclerView, поддерживающие общие функции, но отличающиеся проверками и некоторыми специальными функциями. Чтобы реализовать ScrollablePageObject, который проверяет наличие recyclerview и общих методов, таких как «щелкните n-й элемент», другому PageObject нужно расширить ScrollablePageObject и адаптировать (настроить) их.
class ScrollablePage : Page() {
@IdRes
open val recyclerViewId: Int = R.id.recycler_view
fun clickItem(index: Int): Page {
Espresso.onView(withId(recyclerViewId))
.perform(RecyclerViewActions.scrollToPosition(index)
Espresso.onView(withId(recyclerViewId))
.perform(
RecyclerViewActions.actionOnHolderItem(
ItemMatcher(),
click())
.atPosition(index)
)
return this
}
}
class SearchResultPage: ScrollablePage() {
…
}
Еще один частный случай, которым стоит поделиться: у нас есть много разных типов фрагментов, которые содержат различные компоненты пользовательского интерфейса, но мы их реализуем в идентичном XML-макете. Это подходит для реализаций разных объектов страницы, наследуемых от одного базового объекта.
Более того, это привнесет читабельность, потому что вы увидите .on<NormalItemPage>() и .on<LimitedTimeSaleItemPage>() внутри тест-кейсов и не будете их путать.
Предпосылки
Распространенная реализация заключается в том, что каждый метод объекта страницы определяет, каким будет следующий и возвращает его. Однако это вызывает некоторые проблемы:
- Разная навигация через одну и ту же операцию
Обычно одна и та же операция заставляет приложение переходить в разные фрагменты. Например, SearchViewPage.searchKeyword({id}) переходит к фрагменту товара, но .searchKeyword({brand name}) должен переходить к фрагменту бренда.
Одним из решений является разделение на разные методы, например searchById(id: String): ItemPage и searchByBrand(brand: String): BrandPage, но суть обоих реализуется идентично:
Espresso.onView(allOf(withId(R.id.search_input), isDisplayed()))
.perform(clearText())
.perform(replaceText(keyword))
.perform(pressImeActionButton())
Этот код является дублированным, поэтому мы объединяем конечный объект страницы с нашим фактическим кейсом, который будет выглядеть так:
@Test
fun testSearchById() {
Page.on<DiscoveryPage>()
.on<SearchBoxPage>()
.click()
.on<SearchViewPage>()
.searchKeyword("7882691")
.on<ItemPage>()
.withTitle("A1NJ5J02")
}
@Test
fun testSearchByBrand() {
Page.on<DiscoveryPage>()
.on<SearchBoxPage>()
.click()
.on<SearchViewPage>()
.searchKeyword("timberland")
.on<BrandPage>()
.withTitle("Timberland"
- Навигация «Назад» из разных entry points (*entry points — адрес в оперативной памяти, с которого начинается выполнение программы, другими словами: адрес, по которому хранится первая команда программы)
Некоторые фрагменты будут созданы из разных точек входа — например, страница товара из фрагмента поиска или страница бренда по клику на товар из списка. А иногда одни и те же фрагменты должны возвращаться в другой стек после различных результатов поведения. Как упоминалось выше, мы не позволим back() реагировать как-то определенно:
@Test
fun testItemDetail() {
Page.on<ItemPage>()
.clickDetail()
.on<WebPage>()
.withTitle("The Product Details")
.back()
.on<ItemPage>()
}
@Test
fun testBrandDetail() {
Page.on<BrandPage>()
.clickDetail()
.on<WebPage>()
.withTitle("The Brand Details")
.back()
.on<BrandPage>()
}
- Шаг в дочерний компонент без каких-либо действий
PageObject будет не только создавать объекты страницы для каждого фрагмента, но и для элементов фрагмента и диалоговых окон. Объект страницы не обязательно должен отображать всю страницу, так как в следующем примере SearchBoxPage представляет дочерний компонент пользовательского интерфейса внутри DiscoveryPage, что представляет фрагмент discovery.
@Test
fun testSearchById() {
Page.on<DiscoveryPage>()
.on<SearchBoxPage>()
.click()
.on<SearchViewPage>()
.searchKeyword("7882691")
.on<ItemPage>()
.withTitle("A1NJ5J02")
}
Дизайн архитектуры
Мы определяем базовый класс Page, от которого наследуются все остальные объекты страницы. Базовый класс имеет второстепенную функцию fun <reified T : Page> on(): T, которая возвращает экземпляр PageObject по типу T, таким образом, мы можем объединить Page.on<{PageObject}>() в любое время и определить текущий объект страницы, полностью опираясь на тестовые операции, независимо от выполняемого им метода.
Эта идея возникла в результате доклада Вивиан Ли (Vivian Liu) Design Patterns in XCUITest. Благодарим Вивиан за то, что она поделилась им на iPlayground 2018 на Тайване.
Page.on() получает дженерик T и возвращает фактический экземпляр T. Мы могли бы сделать каждый объект страницы синглтоном и найти соответствующий, но это будет модифицировать Page.on() каждый раз, когда создается новый PageObject, это плохо поддерживается, поэтому мы используем T :: class.constructors.first().call(), чтобы получить конструкторы дженериков и получить первый, как правило непараметрический, конструктор для создания экземпляра T.
open class Page {
companion object {
inline fun <reified T : Page> on(): T {
return Page().on()
}
}
inline fun <reified T : Page> on(): T {
val page = T::class.constructors.first().call()
page.verify()
return page
}
open fun verify(): Page {
// Each subpage should have its default assurances here
return this
}
fun back(): Page {
Espresso.pressBack()
return this
}
}
Reified в Kotlin полезен, чтобы сделать тест-кейс более содержательным. Иначе нам приходилось бы писать его, создавая объекты страницы. Это не будет неправильным, но не будет иметь связи между действиями.
// with reified
Page.on<DiscoveryPage>()
.on<SearchBoxPage>().click()
.on<SearchViewPage>().searchKeyword("7882691")
// without reified
DiscoveryPage()
SearchBoxPage().click()
SearchViewPage().searchKeyword("7882691")
Page также реализует функцию fun back(): Page которая возвращает базовый Page-класс, потому что нам не нужно, чтобы back() реагировал на определенный объект страницы. Это решение позволяет нам легко указывать, какой объект страницы нам возвращается после действия назад.
И не забывайте, что другие объекты страницы должны наследовать Page и настраивать verify() для выполнения проверок по умолчанию.
class ItemPage : Page() {
override fun verify(): Page {
Espresso.onView(withId(R.id.productitem_name))
.check(matches(withEffectiveVisibility(VISIBLE)))
return this
}
fun withTitle(title: String): ItemPage {
Espresso.onView(withId(R.id.productitem_name))
.check(matches(ViewMatchers.withText(keyword)))
return this
}
}
class SearchViewOage : Page() {
override fun verify(): SearchView {
Espresso.onView(withId(R.id.search_input))
.check(matches(withEffectiveVisibility(VISIBLE)))
return this
}
fun searchKeyWord(keyword: String): Page {
Espresso.onView(allOf(
withId(R.id.search_input),
isDisplayed()
))
.perform(clearText())
.perform(replaceText(keyword))
.perform(pressImeActionButton())
return this
}
}
===========
Источник:
habr.com
===========
===========
Автор оригинала: Bram Yeh
===========Похожие новости:
- [Gradle, Java, Kotlin, Разработка мобильных приложений, Разработка под Android] Встраиваем геолокацию от Huawei в Android приложение
- [Разработка мобильных приложений, Разработка под Android, Kotlin, Учебный процесс в IT] Android Academy Fundamentals: теперь прямо у тебя дома
- [Программирование, Разработка под Android] Избегайте внедрения внешних библиотек в свой проект
- [Тестирование IT-систем, Программирование, Виртуализация, TDD] Язык тестовых сценариев Testo Lang: простая автоматизация сложных тестов
- [Разработка под Android] Автоматический Code Improvement при коммите в Android Studio
- [Разработка под Android] Compose. Jetpack Compose
- [Монетизация мобильных приложений, Платежные системы, Разработка мобильных приложений, Разработка под Android] Таргетирование уведомлений, управление ценами в разных регионах и другие возможности HMS для интернет-платежей
- [Тестирование IT-систем, Тестирование веб-сервисов, Тестирование мобильных приложений, Тестирование игр] Тестирование со всех сторон: о чём расскажут на Heisenbug
- [Тестирование IT-систем, PHP, Отладка, Тестирование мобильных приложений] Ловим баги на клиенте: как мы написали свою систему для сбора клиентских ошибок
- [Тестирование мобильных приложений, Разработка под iOS] Cucumber и BDD. Пишем UI-автотесты на iOS
Теги для поиска: #_razrabotka_pod_android (Разработка под Android), #_testirovanie_mobilnyh_prilozhenij (Тестирование мобильных приложений), #_kotlin, #_android_development, #_kotlin, #_ui_testing, #_mobileup, #_razrabotka_pod_android (
Разработка под Android
), #_testirovanie_mobilnyh_prilozhenij (
Тестирование мобильных приложений
), #_kotlin
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 23-Ноя 09:16
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Это перевод статьиведущего Android & iOS разработчика Yahoo (Verizon Media) Брама Йе. Он рассказывает о внедрении паттерна PageObject в свои инструментальные тесты, который делает их более гибкими и легко модифицируемым в зависимости от изменений пользовательского интерфейса. Более того, по словам Брама, благодаря DSL в Kotlin, паттерн PageObject стал более содержательным и более читабельным в тест-кейсах. TL;DR Определите базовый класс Page, у которого есть функция fun <reified T : Page> on(): T, которая создает экземпляр PageObject по типу T: open class Page {
companion object { inline fun <reified T : Page> on(): T { return Page().on() } } inline fun <reified T : Page> on(): T { val page = T::class.constructors.first().call() page.verify() return page } } Затем все остальные объекты страницы наследуются от Page: class ItemPage : Page() {
fun withTitle(keyword: String): ItemPage { Espresso.onView(withId(R.id.productitem_name)) .check(matches(ViewMatchers.withText(keyword))) return this После этого мы можем написать наш тест-кейс следующим образом: @Test
fun testSearchById() { Page.on<DiscoveryPage>() .on<SearchBoxPage>() .click() .on<SearchViewPage>() .searchKeyword("7882691") .on<ItemPage>() .withTitle("A1NJ5J02") } UI-тестирование Android Тесты пользовательского интерфейса Android обычно выполняются на физических устройствах и эмуляторах (мы, например, пользуемся Espresso) – и в нашем проекте их очень много. Раньше мы настраивали множество вспомогательных методов для реализации UI-тестов. Это делало нашу тестовую функцию краткой, но трудной для понимания процессов поведения, навигации и пользовательского интерфейса, который мы тестировали. А когда UI приложения часто обновляется, его тестирование становится кошмаром для поддержки. Вспомогательные методы полезны, но когда UI меняется очень часто, становится очень непросто определить, какие именно из этих методов соответствуют обновленному пользовательскому интерфейсу, если только UI-тесты не проваливаются или мы не отслеживаем код со всей осторожностью. Паттерн PageObject Основное правило для PageObject состоит в том, что он должен позволять программному клиенту делать и видеть все то же, что и пользователь. Они также должны предоставлять простой программный интерфейс, скрывая детали реализации экрана. – Мартин Фаулер Я не объясню концепцию PageObject лучше, чем Мартин, даже несмотря на то, что он описал его использование при Web-разработке. Поэтому я настоятельно рекомендую прочитать его статью здесь. Преимущества PageObject
Несмотря на то, что вспомогательные методы тоже уменьшают дублирование кода, PageObject инкапсулирует и скрывает детали UI-структуры и виджетов от тест-кейсов. Таким образом, мы фокусируемся на поведении тест-кейсов отдельно от деталей пользовательского интерфейса и делаем их более читабельными.
C паттерном PageObject нам всего лишь нужно настроить один или несколько объектов страницы, когда пользовательский интерфейс меняется. Кроме того, мы можем легко узнать, какие объекты страницы должны быть также модифицированы. В итоге разработчики экономят много времени, не отслеживая код теста и выясняя, почему этот тестовый случай завершился неудачей. С другой стороны, при разработке нового фрагмента диалога или какого-либо сложного UI-компонента нам также нужно было бы написать соответствующий класс PageObject , который содержит соответствующие проверки по умолчанию и требуемую механику. После этого любой инженер может быстро написать новые тест-кейсы, следуя последовательности операций пользовательского интерфейса.
Я объясню наши сценарии и детали позже, а сейчас я хотел бы поделиться реальным кейсом. @Test
fun testSearchById() { Page.on<DiscoveryPage>() .on<SearchBoxPage>() .click() .on<SearchViewPage>() .searchKeyword("7882691") .on<ItemPage>() .withTitle("A1NJ5J02") } Легко понять, что этот тест проходит через фрагмент Discovery, кликает на представление SearchBox, вводит ключевое слово в редактируемый SearchView, а затем показывает фрагмент Item с указанным заголовком.
Например, многие фрагменты содержат RecyclerView, поддерживающие общие функции, но отличающиеся проверками и некоторыми специальными функциями. Чтобы реализовать ScrollablePageObject, который проверяет наличие recyclerview и общих методов, таких как «щелкните n-й элемент», другому PageObject нужно расширить ScrollablePageObject и адаптировать (настроить) их. class ScrollablePage : Page() {
@IdRes open val recyclerViewId: Int = R.id.recycler_view fun clickItem(index: Int): Page { Espresso.onView(withId(recyclerViewId)) .perform(RecyclerViewActions.scrollToPosition(index) Espresso.onView(withId(recyclerViewId)) .perform( RecyclerViewActions.actionOnHolderItem( ItemMatcher(), click()) .atPosition(index) ) return this } } class SearchResultPage: ScrollablePage() { … } Еще один частный случай, которым стоит поделиться: у нас есть много разных типов фрагментов, которые содержат различные компоненты пользовательского интерфейса, но мы их реализуем в идентичном XML-макете. Это подходит для реализаций разных объектов страницы, наследуемых от одного базового объекта. Более того, это привнесет читабельность, потому что вы увидите .on<NormalItemPage>() и .on<LimitedTimeSaleItemPage>() внутри тест-кейсов и не будете их путать. Предпосылки Распространенная реализация заключается в том, что каждый метод объекта страницы определяет, каким будет следующий и возвращает его. Однако это вызывает некоторые проблемы:
Обычно одна и та же операция заставляет приложение переходить в разные фрагменты. Например, SearchViewPage.searchKeyword({id}) переходит к фрагменту товара, но .searchKeyword({brand name}) должен переходить к фрагменту бренда. Одним из решений является разделение на разные методы, например searchById(id: String): ItemPage и searchByBrand(brand: String): BrandPage, но суть обоих реализуется идентично: Espresso.onView(allOf(withId(R.id.search_input), isDisplayed()))
.perform(clearText()) .perform(replaceText(keyword)) .perform(pressImeActionButton()) Этот код является дублированным, поэтому мы объединяем конечный объект страницы с нашим фактическим кейсом, который будет выглядеть так: @Test
fun testSearchById() { Page.on<DiscoveryPage>() .on<SearchBoxPage>() .click() .on<SearchViewPage>() .searchKeyword("7882691") .on<ItemPage>() .withTitle("A1NJ5J02") } @Test fun testSearchByBrand() { Page.on<DiscoveryPage>() .on<SearchBoxPage>() .click() .on<SearchViewPage>() .searchKeyword("timberland") .on<BrandPage>() .withTitle("Timberland"
Некоторые фрагменты будут созданы из разных точек входа — например, страница товара из фрагмента поиска или страница бренда по клику на товар из списка. А иногда одни и те же фрагменты должны возвращаться в другой стек после различных результатов поведения. Как упоминалось выше, мы не позволим back() реагировать как-то определенно: @Test
fun testItemDetail() { Page.on<ItemPage>() .clickDetail() .on<WebPage>() .withTitle("The Product Details") .back() .on<ItemPage>() } @Test fun testBrandDetail() { Page.on<BrandPage>() .clickDetail() .on<WebPage>() .withTitle("The Brand Details") .back() .on<BrandPage>() }
PageObject будет не только создавать объекты страницы для каждого фрагмента, но и для элементов фрагмента и диалоговых окон. Объект страницы не обязательно должен отображать всю страницу, так как в следующем примере SearchBoxPage представляет дочерний компонент пользовательского интерфейса внутри DiscoveryPage, что представляет фрагмент discovery. @Test
fun testSearchById() { Page.on<DiscoveryPage>() .on<SearchBoxPage>() .click() .on<SearchViewPage>() .searchKeyword("7882691") .on<ItemPage>() .withTitle("A1NJ5J02") } Дизайн архитектуры Мы определяем базовый класс Page, от которого наследуются все остальные объекты страницы. Базовый класс имеет второстепенную функцию fun <reified T : Page> on(): T, которая возвращает экземпляр PageObject по типу T, таким образом, мы можем объединить Page.on<{PageObject}>() в любое время и определить текущий объект страницы, полностью опираясь на тестовые операции, независимо от выполняемого им метода. Эта идея возникла в результате доклада Вивиан Ли (Vivian Liu) Design Patterns in XCUITest. Благодарим Вивиан за то, что она поделилась им на iPlayground 2018 на Тайване. Page.on() получает дженерик T и возвращает фактический экземпляр T. Мы могли бы сделать каждый объект страницы синглтоном и найти соответствующий, но это будет модифицировать Page.on() каждый раз, когда создается новый PageObject, это плохо поддерживается, поэтому мы используем T :: class.constructors.first().call(), чтобы получить конструкторы дженериков и получить первый, как правило непараметрический, конструктор для создания экземпляра T. open class Page {
companion object { inline fun <reified T : Page> on(): T { return Page().on() } } inline fun <reified T : Page> on(): T { val page = T::class.constructors.first().call() page.verify() return page } open fun verify(): Page { // Each subpage should have its default assurances here return this } fun back(): Page { Espresso.pressBack() return this } } Reified в Kotlin полезен, чтобы сделать тест-кейс более содержательным. Иначе нам приходилось бы писать его, создавая объекты страницы. Это не будет неправильным, но не будет иметь связи между действиями. // with reified
Page.on<DiscoveryPage>() .on<SearchBoxPage>().click() .on<SearchViewPage>().searchKeyword("7882691") // without reified DiscoveryPage() SearchBoxPage().click() SearchViewPage().searchKeyword("7882691") Page также реализует функцию fun back(): Page которая возвращает базовый Page-класс, потому что нам не нужно, чтобы back() реагировал на определенный объект страницы. Это решение позволяет нам легко указывать, какой объект страницы нам возвращается после действия назад. И не забывайте, что другие объекты страницы должны наследовать Page и настраивать verify() для выполнения проверок по умолчанию. class ItemPage : Page() {
override fun verify(): Page { Espresso.onView(withId(R.id.productitem_name)) .check(matches(withEffectiveVisibility(VISIBLE))) return this } fun withTitle(title: String): ItemPage { Espresso.onView(withId(R.id.productitem_name)) .check(matches(ViewMatchers.withText(keyword))) return this } } class SearchViewOage : Page() { override fun verify(): SearchView { Espresso.onView(withId(R.id.search_input)) .check(matches(withEffectiveVisibility(VISIBLE))) return this } fun searchKeyWord(keyword: String): Page { Espresso.onView(allOf( withId(R.id.search_input), isDisplayed() )) .perform(clearText()) .perform(replaceText(keyword)) .perform(pressImeActionButton()) return this } } =========== Источник: habr.com =========== =========== Автор оригинала: Bram Yeh ===========Похожие новости:
Разработка под Android ), #_testirovanie_mobilnyh_prilozhenij ( Тестирование мобильных приложений ), #_kotlin |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 23-Ноя 09:16
Часовой пояс: UTC + 5