diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index d9dc729..2b976b4 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -74,9 +74,9 @@ jobs: SUFFIX=$((SUFFIX + 1)) TAG="v${BASE}.${SUFFIX}" done - echo "version_name=$BASE" >> "$GITHUB_OUTPUT" + echo "version_name=${TAG#v}" >> "$GITHUB_OUTPUT" echo "version_tag=$TAG" >> "$GITHUB_OUTPUT" - echo "Generated version: $BASE (tag: $TAG)" + echo "Generated version: ${TAG#v} (tag: $TAG)" - name: Restore keystore from secret # Ключ подписи передаётся как base64-секрет и декодируется в файл. @@ -102,7 +102,7 @@ jobs: ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} ORG_GRADLE_JVMARGS: '-Xmx2048m' - GRADLE_OPTS: '-Dorg.gradle.daemon=false' + GRADLE_OPTS: '-Dorg.gradle.daemon=true' - name: Determine APK path id: apk diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml new file mode 100644 index 0000000..8730768 --- /dev/null +++ b/.gitea/workflows/tests.yml @@ -0,0 +1,51 @@ +name: Unit Tests + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + unit-tests: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + run: | + set -euxo pipefail + cd /tmp + wget -q https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.12%2B7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz + tar -xzf OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz -C /opt + mv /opt/jdk-17.0.12+7 /opt/java17 + export JAVA_HOME=/opt/java17 + export PATH=$JAVA_HOME/bin:$PATH + echo "JAVA_HOME=/opt/java17" >> $GITHUB_ENV + echo "$JAVA_HOME/bin" >> $GITHUB_PATH + + - name: Install Android SDK + run: | + set -euxo pipefail + echo "ANDROID_HOME=/opt/android-sdk" >> "$GITHUB_ENV" + mkdir -p /opt/android-sdk/cmdline-tools + wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/clt.zip + unzip -q /tmp/clt.zip -d /tmp/clt + mv /tmp/clt/cmdline-tools /opt/android-sdk/cmdline-tools/latest + yes | /opt/android-sdk/cmdline-tools/latest/bin/sdkmanager --licenses >/dev/null 2>&1 || true + /opt/android-sdk/cmdline-tools/latest/bin/sdkmanager \ + "platform-tools" \ + "platforms;android-36.1" \ + "build-tools;36.1.0" + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run JVM unit tests + run: ./gradlew :app:testDebugUnitTest --console=plain + env: + ANDROID_HOME: /opt/android-sdk + GRADLE_OPTS: '-Dorg.gradle.daemon=false' \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 39def9a..a5faefa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -109,11 +109,7 @@ dependencies { implementation(libs.pdfbox.android) testImplementation(libs.junit) - testImplementation("org.apache.pdfbox:pdfbox:3.0.4") androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.test.runner) debugImplementation(libs.androidx.compose.ui.tooling) - debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/app/src/androidTest/assets/expected.txt b/app/src/androidTest/assets/expected.txt new file mode 100644 index 0000000..39bf71a --- /dev/null +++ b/app/src/androidTest/assets/expected.txt @@ -0,0 +1,95 @@ +Факультет автоматики и вычислительной техники (ОРУ) +Группа: ИВТб-3304-05-00 + +Понедельник (14.09.26): <пусто, пар нет> +Вторник (15.09.26): 08:20-09:50 - <пусто> + 10:00-11:30 - ИВТб-3304-05-00 Объектно-ориентированный анализ и проектирование Лекция Преподаватель -.П. 2-202; ИВТб-3304-05-00 Разработка Web-приложений Лекция Преподаватель -.П. 1-236 + 11:45-13:15 - ИВТб-3304-05-00 Инструментальные средства проектирования компонентов вычислительной техники Лекция Семеновых В.И. 2-201; ИВТб-3304-05-00 Предметно-ориентированные автоматизированные информационные системы Лекция Прозорова Ю.А. 1-313; ИВТб-3304-05-00 Сценарное и функциональное программирование Лекция Преподаватель -.П. 1-311 + 14:00-15:30 - ИВТб-3304-05-00 Инструментальные средства проектирования компонентов вычислительной техники Лабораторная работа Семеновых В.И. 2-201; ИВТб-3304-05-00 Предметно-ориентированные автоматизированные информационные системы Лабораторная работа Прозорова Ю.А. 1-236; ИВТб-3304-05-00 Сценарное и функциональное программирование Лабораторная работа Преподаватель -.П. 1-114 + 15:45-17:15 - ИВТб-3304-05-00 Инструментальные средства проектирования компонентов вычислительной техники Лабораторная работа Семеновых В.И. 2-201; ИВТб-3304-05-00 Предметно-ориентированные автоматизированные информационные системы Лабораторная работа Прозорова Ю.А. 1-236; ИВТб-3304-05-00 Сценарное и функциональное программирование Лабораторная работа Преподаватель -.П. 1-114 + 17:20-18:50 - <пусто> + 18:55-20:25 - <пусто> +Среда (16.09.26): 08:20-09:50 - Введение в архитектуру ЭВМ Лекция Страбыкин Д.А. 1-242 + 10:00-11:30 - Теория компиляторов и формальные грамматики Лекция Мельцов В.Ю. 1-242 + 11:45-13:15 - <пусто> + 14:00-15:30 - <пусто> + 15:45-17:15 - <пусто> + 17:20-18:50 - <пусто> + 18:55-20:25 - <пусто> +Четверг (17.09.26): 08:20-09:50 - <пусто> + 10:00-11:30 - <пусто> + 11:45-13:15 - ИВТб-3304-05-00, 01 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 131) Практическое занятие Трушкова Е.Л. 9-115; ИВТб-3304-05-00, 01 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 039) Практическое занятие Салопин О.М. 9-301 ИВТб-3304-05-00, 02 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 039) Практическое занятие Салопин О.М. 9-301; ИВТб-3304-05-00, 02 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (21 285) Практическое занятие Хвостенко С.Ю. 9-209.; ИВТб-3304-05-00, 01 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 124) Практическое занятие Пластинина В.Б. 9-208 + 14:00-15:30 - Архитектура операционных систем и сервисное программное обеспечение Лекция Преподаватель -.П. 1-439 + 15:45-17:15 - <пусто> + 17:20-18:50 - <пусто> + 18:55-20:25 - <пусто> +Пятница (18.09.26): 08:20-09:50 - ИВТб-3304-05-00, 01 подгруппа Введение в архитектуру ЭВМ Лабораторная работа Страбыкин Д.А. 1-117; ИВТб-3304-05-00, 02 подгруппа Введение в архитектуру ЭВМ Лабораторная работа Страбыкин Д.А. 1-117_ + 10:00-11:30 - ИВТб-3304-05-00, 01 подгруппа Введение в архитектуру ЭВМ Лабораторная работа Страбыкин Д.А. 1-117; ИВТб-3304-05-00, 02 подгруппа Введение в архитектуру ЭВМ Лабораторная работа Страбыкин Д.А. 1-117_ + 11:45-13:15 - Введение в архитектуру ЭВМ Практическое занятие Страбыкин Д.А. 2-207 + 14:00-15:30 - Философия Практическое занятие Коротышева Н.Н. 2-407 + 15:45-17:15 - <пусто> + 17:20-18:50 - <пусто> + 18:55-20:25 - <пусто> +Суббота (19.09.26): 08:20-09:50 - <пусто> + 10:00-11:30 - <пусто> + 11:45-13:15 - <пусто> + 14:00-15:30 - <пусто> + 15:45-17:15 - <пусто> + 17:20-18:50 - <пусто> + 18:55-20:25 - <пусто> +Воскресенье (20.09.26): 08:20-09:50 - <пусто> + 10:00-11:30 - <пусто> + 11:45-13:15 - <пусто> + 14:00-15:30 - <пусто> + 15:45-17:15 - <пусто> + 17:20-18:50 - <пусто> + 18:55-20:25 - <пусто> +Понедельник (21.09.26): 08:20-09:50 - <пусто> + 10:00-11:30 - ИВТб-3304-05-00, 01 подгруппа Теория компиляторов и формальные грамматики Лабораторная работа Караваева О.В. 2-308; ИВТб-3304-05-00, 02 подгруппа Теория компиляторов и формальные грамматики Лабораторная работа Караваева О.В. 2-308 + 11:45-13:15 - ИВТб-3304-05-00, 01 подгруппа Теория компиляторов и формальные грамматики Лабораторная работа Караваева О.В. 2-308; ИВТб-3304-05-00, 02 подгруппа Теория компиляторов и формальные грамматики Лабораторная работа Караваева О.В. 2-308_ + 14:00-15:30 - <пусто> + 15:45-17:15 - <пусто> + 17:20-18:50 - <пусто> + 18:55-20:25 - <пусто> +Вторник (22.09.26): 08:20-09:50 - Объектно-ориентированный анализ и проектирование Практическое занятие Шмакова Н.А. 2-202 + 10:00-11:30 - ИВТб-3304-05-00 Инструментальные средства проектирования компонентов вычислительной техники Лекция Семеновых В.И. 2-201; ИВТб-3304-05-00 Предметно-ориентированные автоматизированные информационные системы Лекция Прозорова Ю.А. 1-313; ИВТб-3304-05-00 Сценарное и функциональное программирование Лекция Преподаватель -.П. 1-311 + 11:45-13:15 - ИВТб-3304-05-00 Объектно-ориентированный анализ и проектирование Лекция Преподаватель -.П. 2-202; ИВТб-3304-05-00 Проектирование систем на ПЛИС Лекция Крутиков А.К. 1-313; ИВТб-3304-05-00 Разработка Web-приложений Лекция Преподаватель -.П. 1-236 + 14:00-15:30 - ИВТб-3304-05-00 Проектирование систем на ПЛИС Практическое занятие Крутиков А.К. 1-109; ИВТб-3304-05-00 Разработка Web-приложений Практическое занятие Пащенко Д.Э. 1-116 + 15:45-17:15 - <пусто> + 17:20-18:50 - <пусто> + 18:55-20:25 - <пусто> +Среда (23.09.26): 08:20-09:50 - Теория компиляторов и формальные грамматики Практическое занятие Караваева О.В. 2-201 + 10:00-11:30 - Введение в архитектуру ЭВМ Лекция Страбыкин Д.А. 1-242 + 11:45-13:15 - Архитектура операционных систем и сервисное программное обеспечение Лекция Преподаватель -.П. 1-242 + 14:00-15:30 - <пусто> + 15:45-17:15 - <пусто> + 17:20-18:50 - <пусто> + 18:55-20:25 - <пусто> +Четверг (24.09.26): 08:20-09:50 - <пусто> + 10:00-11:30 - <пусто> + 11:45-13:15 - ИВТб-3304-05-00, 01 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 131) Практическое занятие Трушкова Е.Л. 9-115; ИВТб-3304-05-00, 01 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 039) Практическое занятие Салопин О.М. 9-301 ИВТб-3304-05-00, 02 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 039) Практическое занятие Салопин О.М. 9-301; ИВТб-3304-05-00, 02 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (21 285) Практическое занятие Хвостенко С.Ю. 9-101; ИВТб-3304-05-00, 01 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 124) Практическое занятие Пластинина В.Б. 9-208 + 14:00-15:30 - ИВТб-3304-05-00 Объектно-ориентированный анализ и проектирование Лабораторная работа Шмакова Н.А. 2-202; ИВТб-3304-05-00 Проектирование систем на ПЛИС Лабораторная работа Крутиков А.К. 1-239; ИВТб-3304-05-00 Разработка Web-приложений Лабораторная работа Пащенко Д.Э. 1-109 + 15:45-17:15 - ИВТб-3304-05-00 Объектно-ориентированный анализ и проектирование Лабораторная работа Шмакова Н.А. 2-202; ИВТб-3304-05-00 Проектирование систем на ПЛИС Лабораторная работа Крутиков А.К. 1-239; ИВТб-3304-05-00 Разработка Web-приложений Лабораторная работа Пащенко Д.Э. 1-109 + 17:20-18:50 - <пусто> + 18:55-20:25 - <пусто> +Пятница (25.09.26): 08:20-09:50 - <пусто> + 10:00-11:30 - <пусто> + 11:45-13:15 - <пусто> + 14:00-15:30 - Теория компиляторов и формальные грамматики Лекция Мельцов В.Ю. 1-242 + 15:45-17:15 - Архитектура операционных систем и сервисное программное обеспечение Лабораторная работа Караваева О.В. 2-201 + 17:20-18:50 - Архитектура операционных систем и сервисное программное обеспечение Лабораторная работа Караваева О.В. 2-201 + 18:55-20:25 - <пусто> +Суббота (26.09.26): 08:20-09:50 - <пусто> + 10:00-11:30 - <пусто> + 11:45-13:15 - <пусто> + 14:00-15:30 - <пусто> + 15:45-17:15 - <пусто> + 17:20-18:50 - <пусто> + 18:55-20:25 - <пусто> +Воскресенье (27.09.26): 08:20-09:50 - <пусто> + 10:00-11:30 - <пусто> + 11:45-13:15 - <пусто> + 14:00-15:30 - <пусто> + 15:45-17:15 - <пусто> + 17:20-18:50 - <пусто> + 18:55-20:25 - <пусто> diff --git a/app/src/androidTest/assets/schedule.pdf b/app/src/androidTest/assets/schedule.pdf new file mode 100644 index 0000000..f2eae9f Binary files /dev/null and b/app/src/androidTest/assets/schedule.pdf differ diff --git a/app/src/androidTest/java/agzam/vyatsu/ExampleInstrumentedTest.kt b/app/src/androidTest/java/agzam/vyatsu/ExampleInstrumentedTest.kt deleted file mode 100644 index 4ac1fec..0000000 --- a/app/src/androidTest/java/agzam/vyatsu/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package agzam.vyatsu - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("agzam.vyatsu", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/agzam/vyatsu/data/remote/pdf/PdfDumpTest.kt b/app/src/androidTest/java/agzam/vyatsu/data/remote/pdf/PdfDumpTest.kt new file mode 100644 index 0000000..8245d77 --- /dev/null +++ b/app/src/androidTest/java/agzam/vyatsu/data/remote/pdf/PdfDumpTest.kt @@ -0,0 +1,21 @@ +package agzam.vyatsu.data.remote.pdf + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PdfDumpTest { + + @Test + fun dumpParsedSchedule() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val bytes = instrumentation.context.assets.open("schedule.pdf").use { it.readBytes() } + + val days = PdfScheduleParser.parse(bytes) + + val target = instrumentation.targetContext.getExternalFilesDir(null)!!.resolve("parsed.txt") + target.writeText(days.joinToString(separator = "\n")) + } +} diff --git a/app/src/androidTest/java/agzam/vyatsu/data/remote/pdf/PdfScheduleParserTest.kt b/app/src/androidTest/java/agzam/vyatsu/data/remote/pdf/PdfScheduleParserTest.kt new file mode 100644 index 0000000..ea84b92 --- /dev/null +++ b/app/src/androidTest/java/agzam/vyatsu/data/remote/pdf/PdfScheduleParserTest.kt @@ -0,0 +1,164 @@ +package agzam.vyatsu.data.remote.pdf + +import agzam.vyatsu.domain.model.Day +import agzam.vyatsu.domain.model.Lesson +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.time.LocalDate +import java.time.LocalTime +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PdfScheduleParserTest { + + companion object { + private val days: List by lazy { + val bytes = InstrumentationRegistry.getInstrumentation().context.assets + .open("schedule.pdf") + .use { it.readBytes() } + PdfScheduleParser.parse(bytes) + } + } + + private fun day(date: String): Day = days.first { it.date == LocalDate.parse(date) } + + private fun lessons(date: String, start: String): List = + day(date).intervals + .first { it.start == LocalTime.parse(start) } + .lessons + .filter { it.raw.isNotBlank() } + + private fun allLessons(date: String): List = + day(date).intervals.flatMap { it.lessons }.filter { it.raw.isNotBlank() } + + private fun Lesson.canonical(): String = + listOf(subject, type.name, room, teacher, subgroup).joinToString("|") + + @Test + fun parsesWholeTwoWeekPeriod() { + val expectedDates = (0..13L).map { LocalDate.of(2026, 9, 14).plusDays(it) } + + assertEquals(expectedDates, days.map { it.date }) + } + + @Test + fun everyDayHasSevenStandardIntervals() { + val expectedTimes = listOf( + LocalTime.of(8, 20) to LocalTime.of(9, 50), + LocalTime.of(10, 0) to LocalTime.of(11, 30), + LocalTime.of(11, 45) to LocalTime.of(13, 15), + LocalTime.of(14, 0) to LocalTime.of(15, 30), + LocalTime.of(15, 45) to LocalTime.of(17, 15), + LocalTime.of(17, 20) to LocalTime.of(18, 50), + LocalTime.of(18, 55) to LocalTime.of(20, 25) + ) + + for (day in days) { + assertEquals(day.date.toString(), expectedTimes, day.intervals.map { it.start to it.end }) + } + } + + @Test + fun emptyDaysHaveNoLessons() { + val emptyDays = listOf("2026-09-14", "2026-09-19", "2026-09-20", "2026-09-26", "2026-09-27") + + for (date in emptyDays) { + assertEquals(date, emptyList(), allLessons(date)) + } + } + + @Test + fun parsesTwoLecturesInOneSlot() { + assertEquals( + listOf( + "Объектно-ориентированный анализ и проектирование|LECTURE|2-202|Преподаватель -.П.|null", + "Разработка Web-приложений|LECTURE|1-236|Преподаватель -.П.|null" + ), + lessons("2026-09-15", "10:00").map { it.canonical() } + ) + } + + @Test + fun parsesThreeLecturesInOneSlot() { + assertEquals( + listOf( + "Инструментальные средства проектирования компонентов вычислительной техники|LECTURE|2-201|Семеновых В.И.|null", + "Предметно-ориентированные автоматизированные информационные системы|LECTURE|1-313|Прозорова Ю.А.|null", + "Сценарное и функциональное программирование|LECTURE|1-311|Преподаватель -.П.|null" + ), + lessons("2026-09-15", "11:45").map { it.canonical() } + ) + } + + @Test + fun parsesLaboratoryWork() { + assertEquals( + listOf( + "Инструментальные средства проектирования компонентов вычислительной техники|LABORATORY|2-201|Семеновых В.И.|null", + "Предметно-ориентированные автоматизированные информационные системы|LABORATORY|1-236|Прозорова Ю.А.|null", + "Сценарное и функциональное программирование|LABORATORY|1-114|Преподаватель -.П.|null" + ), + lessons("2026-09-15", "14:00").map { it.canonical() } + ) + } + + @Test + fun parsesWednesdayLectures() { + assertEquals( + listOf("Введение в архитектуру ЭВМ|LECTURE|1-242|Страбыкин Д.А.|null"), + lessons("2026-09-16", "08:20").map { it.canonical() } + ) + assertEquals( + listOf("Теория компиляторов и формальные грамматики|LECTURE|1-242|Мельцов В.Ю.|null"), + lessons("2026-09-16", "10:00").map { it.canonical() } + ) + } + + @Test + fun parsesPhysicalEducationSubgroups() { + val parsed = lessons("2026-09-17", "11:45").filter { it.room != null } + + assertEquals( + listOf( + "Элективные дисциплины (модули) по физической культуре и спорту|PRACTICE|9-115|Трушкова Е.Л.|01 подгруппа", + "Элективные дисциплины (модули) по физической культуре и спорту|PRACTICE|9-301|Салопин О.М.|01 подгруппа", + "Элективные дисциплины (модули) по физической культуре и спорту|PRACTICE|9-301|Салопин О.М.|02 подгруппа" + ), + parsed.map { it.canonical() } + ) + } + + @Test + fun keepsRawTextWhenRoomHasTrailingPunctuation() { + val unparsed = lessons("2026-09-17", "11:45").filter { it.room == null } + + assertEquals(2, unparsed.size) + assertTrue(unparsed.any { it.raw.contains("9-209.") }) + assertTrue(unparsed.any { it.raw.contains("9-208__") }) + } + + @Test + fun parsesFridaySubgroupLaboratories() { + assertEquals( + listOf( + "Введение в архитектуру ЭВМ|LABORATORY|1-117|Страбыкин Д.А.|01 подгруппа", + "Введение в архитектуру ЭВМ|LABORATORY|1-117_|Страбыкин Д.А.|02 подгруппа" + ), + lessons("2026-09-18", "08:20").map { it.canonical() } + ) + } + + @Test + fun parsesSecondWeekMondayLaboratories() { + assertEquals( + listOf( + "Теория компиляторов и формальные грамматики|LABORATORY|2-308|Караваева О.В.|01 подгруппа", + "Теория компиляторов и формальные грамматики|LABORATORY|2-308_|Караваева О.В.|02 подгруппа" + ), + lessons("2026-09-21", "10:00").map { it.canonical() } + ) + } +} diff --git a/app/src/test/java/agzam/vyatsu/ExampleUnitTest.kt b/app/src/test/java/agzam/vyatsu/ExampleUnitTest.kt deleted file mode 100644 index 18f60e6..0000000 --- a/app/src/test/java/agzam/vyatsu/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package agzam.vyatsu - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/app/src/test/java/agzam/vyatsu/data/local/ConvertersTest.kt b/app/src/test/java/agzam/vyatsu/data/local/ConvertersTest.kt new file mode 100644 index 0000000..8ca659d --- /dev/null +++ b/app/src/test/java/agzam/vyatsu/data/local/ConvertersTest.kt @@ -0,0 +1,57 @@ +package agzam.vyatsu.data.local + +import agzam.vyatsu.domain.model.LessonType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.LocalDate +import java.time.LocalTime + +class ConvertersTest { + + private val converters = Converters() + + @Test + fun localDate_roundTripsThroughEpochDay() { + val date = LocalDate.of(2026, 9, 14) + assertEquals(date, converters.epochDayToLocalDate(converters.localDateToEpochDay(date))) + } + + @Test + fun nullDate_staysNull() { + assertNull(converters.localDateToEpochDay(null)) + assertNull(converters.epochDayToLocalDate(null)) + } + + @Test + fun localTime_roundTripsThroughMinutes() { + assertEquals( + LocalTime.of(8, 20), + converters.minutesToLocalTime(converters.localTimeToMinutes(LocalTime.of(8, 20))) + ) + } + + @Test + fun localTime_boundariesMapToMinutes() { + assertEquals(0, converters.localTimeToMinutes(LocalTime.of(0, 0))) + assertEquals(1439, converters.localTimeToMinutes(LocalTime.of(23, 59))) + } + + @Test + fun nullTime_staysNull() { + assertNull(converters.localTimeToMinutes(null)) + assertNull(converters.minutesToLocalTime(null)) + } + + @Test + fun lessonType_roundTripsEveryValue() { + for (type in LessonType.entries) { + assertEquals(type, converters.stringToLessonType(converters.lessonTypeToString(type))) + } + } + + @Test + fun unknownLessonType_isOther() { + assertEquals(LessonType.OTHER, converters.stringToLessonType("NOPE")) + } +} \ No newline at end of file diff --git a/app/src/test/java/agzam/vyatsu/data/remote/GroupsParserTest.kt b/app/src/test/java/agzam/vyatsu/data/remote/GroupsParserTest.kt new file mode 100644 index 0000000..05d9f77 --- /dev/null +++ b/app/src/test/java/agzam/vyatsu/data/remote/GroupsParserTest.kt @@ -0,0 +1,162 @@ +package agzam.vyatsu.data.remote + +import agzam.vyatsu.domain.model.DateRange +import agzam.vyatsu.domain.model.Season +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.InputStream + +class GroupsParserTest { + + private val faculty = "Факультет автоматики и вычислительной техники (ОРУ)" + private val facultyShort = "ФАВТ (Факультет автоматики и вычислительной техники)" + + private fun page(vararg blocks: String): String = blocks.joinToString("\n") + + private fun facultyDiv(name: String): String = """
$name
""" + + private fun groupDiv(periodId: String, name: String): String = + """
$name
""" + + private fun pdfLink(url: String): String = """x""" + + private fun parse(html: String): ParsedGroups = + GroupsParser.parseStream(html.byteInputStream(Charsets.UTF_8)) + + private fun parseLineByLine(html: String): ParsedGroups { + val lines = html.split("\n").map { "$it\n" } + var index = 0 + val stream = object : InputStream() { + override fun read(): Int = -1 + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (index >= lines.size) return -1 + val bytes = lines[index].toByteArray(Charsets.UTF_8) + val n = minOf(len, bytes.size) + System.arraycopy(bytes, 0, b, off, n) + index++ + return n + } + } + return GroupsParser.parseStream(stream) + } + + @Test + fun dropsLastDigitFromPeriodId() { + val html = page( + facultyDiv(faculty), + groupDiv("123456", "ИВТб-3304-05-00 (бакалавриат)") + ) + + val group = parse(html).groups.single() + + assertEquals("12345", group.id) + assertEquals("ИВТб-3304-05-00", group.name) + assertEquals(facultyShort, group.faculty) + } + + @Test + fun removesParenthesizedSuffixFromGroupName() { + val html = page(facultyDiv(faculty), groupDiv("777", "Группа А (подгруппа)")) + + val group = parse(html).groups.single() + + assertEquals("77", group.id) + assertEquals("Группа А", group.name) + } + + @Test + fun keepsFacultyNameWhenNoShorthand() { + val html = page(facultyDiv("Некий институт (ОРУ)"), groupDiv("5555", "Гр")) + + val group = parse(html).groups.single() + + assertEquals("Некий институт", group.faculty) + } + + @Test + fun facultyAppliesToGroupsUntilChanged() { + val html = page( + facultyDiv(faculty), + groupDiv("1111", "А-1"), + groupDiv("2222", "А-2"), + facultyDiv("Юридический институт (факультет) (ОРУ)"), + groupDiv("3333", "Ю-1") + ) + + val faculties = parse(html).groups.map { it.faculty } + + assertEquals( + listOf(facultyShort, facultyShort, "ЮИ (Юридический институт)"), + faculties + ) + } + + @Test + fun parsesDateRangesGroupedBySeason() { + val html = page( + pdfLink("/reports/schedule/Group/12345_1_14092026_27092026.pdf"), + pdfLink("/reports/schedule/Group/12345_1_28092026_11102026.pdf"), + pdfLink("/reports/schedule/Group/12345_2_09022027_22022027.pdf") + ) + + val ranges = parse(html).dateRanges + + assertEquals( + listOf(DateRange("14092026", "27092026"), DateRange("28092026", "11102026")), + ranges["12345"]?.get(Season.AUTUMN) + ) + assertEquals( + listOf(DateRange("09022027", "22022027")), + ranges["12345"]?.get(Season.SPRING) + ) + } + + @Test + fun ignoresUnrelatedLinks() { + val html = page( + pdfLink("/other/12345_1_14092026_27092026.pdf"), + pdfLink("/reports/schedule/Group/123_1_14092026_27092026.pdf") + ) + + assertTrue(parse(html).dateRanges.isEmpty()) + } + + @Test + fun extractsRangesFromTextBeforeTag() { + val html = "/reports/schedule/Group/12345_1_14092026_27092026.pdf " + pdfLink("#") + + val ranges = parse(html).dateRanges + + assertEquals( + listOf(DateRange("14092026", "27092026")), + ranges["12345"]?.get(Season.AUTUMN) + ) + } + + @Test + fun greaterThanInsideQuotedAttributeDoesNotBreakTag() { + val html = page( + facultyDiv(faculty), + """
Группа Б
""" + ) + + val group = parse(html).groups.single() + + assertEquals("55", group.id) + assertEquals("Группа Б", group.name) + } + + @Test + fun multipleReadsProduceSameResultAsSingleRead() { + val html = page( + facultyDiv(faculty), + groupDiv("123456", "ИВТб-3304-05-00 (бакалавриат)"), + pdfLink("/reports/schedule/Group/12345_1_14092026_27092026.pdf"), + groupDiv("7654321", "ИВТб-3304-05-01"), + pdfLink("/reports/schedule/Group/765432_2_09022027_22022027.pdf") + ) + + assertEquals(parse(html), parseLineByLine(html)) + } +} diff --git a/app/src/test/java/agzam/vyatsu/data/remote/VyatsuUrlsTest.kt b/app/src/test/java/agzam/vyatsu/data/remote/VyatsuUrlsTest.kt new file mode 100644 index 0000000..f6a4392 --- /dev/null +++ b/app/src/test/java/agzam/vyatsu/data/remote/VyatsuUrlsTest.kt @@ -0,0 +1,43 @@ +package agzam.vyatsu.data.remote + +import agzam.vyatsu.domain.model.DateRange +import agzam.vyatsu.domain.model.Season +import org.junit.Assert.assertEquals +import org.junit.Test + +class VyatsuUrlsTest { + + private val autumnRange = DateRange("14092026", "27092026") + + @Test + fun groupPdfUrl_usesAutumnKey() { + assertEquals( + "https://www.vyatsu.ru/reports/schedule/Group/12345_1_14092026_27092026.pdf", + VyatsuUrls.groupPdfUrl("12345", Season.AUTUMN, autumnRange) + ) + } + + @Test + fun groupPdfUrl_usesSpringKey() { + assertEquals( + "https://www.vyatsu.ru/reports/schedule/Group/765432_2_09022027_22022027.pdf", + VyatsuUrls.groupPdfUrl("765432", Season.SPRING, DateRange("09022027", "22022027")) + ) + } + + @Test + fun season_apiKeysMatchUrlScheme() { + assertEquals("1", Season.AUTUMN.apiKey) + assertEquals("2", Season.SPRING.apiKey) + } + + @Test + fun generatedUrl_isRecognizedByGroupsParser() { + val url = VyatsuUrls.groupPdfUrl("12345", Season.AUTUMN, autumnRange) + val html = """x""" + + val parsed = GroupsParser.parseStream(html.byteInputStream(Charsets.UTF_8)) + + assertEquals(listOf(autumnRange), parsed.dateRanges["12345"]?.get(Season.AUTUMN)) + } +} \ No newline at end of file diff --git a/app/src/test/java/agzam/vyatsu/domain/ScheduleDatesTest.kt b/app/src/test/java/agzam/vyatsu/domain/ScheduleDatesTest.kt new file mode 100644 index 0000000..5d63d8d --- /dev/null +++ b/app/src/test/java/agzam/vyatsu/domain/ScheduleDatesTest.kt @@ -0,0 +1,82 @@ +package agzam.vyatsu.domain + +import agzam.vyatsu.domain.model.Season +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import java.time.LocalDate +import java.time.format.DateTimeParseException + +class ScheduleDatesTest { + + private val firstMonday = "14092026" + + @Test + fun asDate_parsesDdMmYyyy() { + assertEquals(LocalDate.of(2026, 9, 14), ScheduleDates.asDate("14092026")) + assertEquals(LocalDate.of(2026, 9, 1), ScheduleDates.asDate("01092026")) + } + + @Test + fun asDate_wrongFormat_throws() { + assertThrows(DateTimeParseException::class.java) { + ScheduleDates.asDate("2026-09-14") + } + } + + @Test + fun dayIndexes_mondayOfFirstWeek_isZeroZero() { + assertEquals(0 to 0, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 14))) + } + + @Test + fun dayIndexes_tuesdayToSaturday_areSameWeek() { + assertEquals(0 to 1, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 15))) + assertEquals(0 to 2, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 16))) + assertEquals(0 to 3, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 17))) + assertEquals(0 to 4, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 18))) + assertEquals(0 to 5, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 19))) + } + + @Test + fun dayIndexes_sunday_movesToNextMonday() { + assertEquals(1 to 0, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 20))) + } + + @Test + fun dayIndexes_secondWeek() { + assertEquals(1 to 0, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 21))) + assertEquals(1 to 1, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 22))) + assertEquals(1 to 5, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 26))) + } + + @Test + fun dayIndexes_thirdCalendarWeek_wrapsToFirstScheduleWeek() { + assertEquals(0 to 0, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 28))) + } + + @Test + fun dayIndexes_dateBeforeFirstMonday_isSecondScheduleWeek() { + assertEquals(1 to 0, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 7))) + } + + @Test + fun dateByIndexes_composesFromFirstDate() { + assertEquals(LocalDate.of(2026, 9, 14), ScheduleDates.dateByIndexes(firstMonday, 0, 0)) + assertEquals(LocalDate.of(2026, 9, 15), ScheduleDates.dateByIndexes(firstMonday, 0, 1)) + assertEquals(LocalDate.of(2026, 9, 21), ScheduleDates.dateByIndexes(firstMonday, 1, 0)) + assertEquals(LocalDate.of(2026, 9, 27), ScheduleDates.dateByIndexes(firstMonday, 1, 6)) + } + + @Test + fun currentSeason_augustToDecember_isAutumn() { + assertEquals(Season.AUTUMN, ScheduleDates.currentSeason(LocalDate.of(2026, 8, 1))) + assertEquals(Season.AUTUMN, ScheduleDates.currentSeason(LocalDate.of(2026, 12, 31))) + } + + @Test + fun currentSeason_januaryToJuly_isSpring() { + assertEquals(Season.SPRING, ScheduleDates.currentSeason(LocalDate.of(2026, 1, 1))) + assertEquals(Season.SPRING, ScheduleDates.currentSeason(LocalDate.of(2026, 7, 31))) + } +} diff --git a/app/src/test/java/agzam/vyatsu/domain/model/LessonTypeTest.kt b/app/src/test/java/agzam/vyatsu/domain/model/LessonTypeTest.kt new file mode 100644 index 0000000..395e7dc --- /dev/null +++ b/app/src/test/java/agzam/vyatsu/domain/model/LessonTypeTest.kt @@ -0,0 +1,40 @@ +package agzam.vyatsu.domain.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LessonTypeTest { + + @Test + fun fromRaw_recognizesExactLabels() { + assertEquals(LessonType.LECTURE, LessonType.fromRaw("Лекция")) + assertEquals(LessonType.PRACTICE, LessonType.fromRaw("Практическое занятие")) + assertEquals(LessonType.LABORATORY, LessonType.fromRaw("Лабораторная работа")) + } + + @Test + fun fromRaw_trimsSurroundingWhitespace() { + assertEquals(LessonType.LECTURE, LessonType.fromRaw(" Лекция ")) + } + + @Test + fun fromRaw_unknownValue_isOther() { + assertEquals(LessonType.OTHER, LessonType.fromRaw("Семинар")) + assertEquals(LessonType.OTHER, LessonType.fromRaw("")) + } + + @Test + fun labels_matchParserVocabulary() { + assertEquals("Лекция", LessonType.LECTURE.label) + assertEquals("Практическое занятие", LessonType.PRACTICE.label) + assertEquals("Лабораторная работа", LessonType.LABORATORY.label) + assertEquals("Занятие", LessonType.OTHER.label) + } + + @Test + fun fromRaw_roundTripsEveryLabel() { + for (type in LessonType.entries) { + assertEquals(type, LessonType.fromRaw(type.label)) + } + } +} \ No newline at end of file diff --git a/app/src/test/java/agzam/vyatsu/domain/model/ScheduleTest.kt b/app/src/test/java/agzam/vyatsu/domain/model/ScheduleTest.kt new file mode 100644 index 0000000..f071a14 --- /dev/null +++ b/app/src/test/java/agzam/vyatsu/domain/model/ScheduleTest.kt @@ -0,0 +1,72 @@ +package agzam.vyatsu.domain.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.LocalDate + +class ScheduleTest { + + private val autumnPeriod = SchedulePeriod( + weekStart = LocalDate.of(2026, 9, 14), + weekEnd = LocalDate.of(2026, 9, 27), + season = Season.AUTUMN + ) + private val nextPeriod = SchedulePeriod( + weekStart = LocalDate.of(2026, 9, 28), + weekEnd = LocalDate.of(2026, 10, 11), + season = Season.AUTUMN + ) + private val dayInFirstPeriod = Day(LocalDate.of(2026, 9, 15), emptyList()) + private val dayInSecondPeriod = Day(LocalDate.of(2026, 9, 29), emptyList()) + private val dayOutsidePeriods = Day(LocalDate.of(2026, 10, 20), emptyList()) + + private val schedule = Schedule( + groupId = "12345", + periods = listOf(autumnPeriod, nextPeriod), + days = listOf(dayInFirstPeriod, dayInSecondPeriod, dayOutsidePeriods) + ) + + @Test + fun periodOf_matchesDateInsidePeriod() { + assertEquals(autumnPeriod, schedule.periodOf(LocalDate.of(2026, 9, 15))) + } + + @Test + fun periodOf_includesBothBounds() { + assertEquals(autumnPeriod, schedule.periodOf(autumnPeriod.weekStart)) + assertEquals(autumnPeriod, schedule.periodOf(autumnPeriod.weekEnd)) + assertEquals(nextPeriod, schedule.periodOf(nextPeriod.weekStart)) + assertEquals(nextPeriod, schedule.periodOf(nextPeriod.weekEnd)) + } + + @Test + fun periodOf_returnsNullForDateOutsideAllPeriods() { + assertNull(schedule.periodOf(LocalDate.of(2026, 10, 20))) + } + + @Test + fun daysOf_returnsOnlyDaysWithinPeriod() { + assertEquals(listOf(dayInFirstPeriod), schedule.daysOf(autumnPeriod)) + assertEquals(listOf(dayInSecondPeriod), schedule.daysOf(nextPeriod)) + } + + @Test + fun daysOf_returnsEmptyWhenPeriodHasNoDays() { + val emptyPeriod = SchedulePeriod( + weekStart = LocalDate.of(2027, 1, 4), + weekEnd = LocalDate.of(2027, 1, 17), + season = Season.SPRING + ) + + assertEquals(emptyList(), schedule.daysOf(emptyPeriod)) + } + + @Test + fun daysOf_neverReturnsDayOutsideEveryPeriod() { + val collected = schedule.periods.flatMap { schedule.daysOf(it) } + + assertFalse(collected.contains(dayOutsidePeriods)) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9732167..07648c1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ agp = "9.1.1" coreKtx = "1.10.1" junit = "4.13.2" junitVersion = "1.1.5" -espressoCore = "3.5.1" +runner = "1.5.2" lifecycleRuntimeKtx = "2.6.1" activityCompose = "1.8.0" kotlin = "2.2.10" @@ -22,7 +22,7 @@ monitor = "1.8.0" androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } @@ -30,8 +30,6 @@ androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } -androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }