Compare commits

..
16 Commits
Author SHA1 Message Date
vantablackTea 1f58466d25 fix: removed unnecessary text from pdf test file
Unit Tests / unit-tests (push) Failing after 47s
Unit Tests / unit-tests (pull_request) Successful in 4m55s
2026-09-18 08:53:28 +03:00
vantablackTea c6f280ff72 tests: pdf parser test based on prepared files
Unit Tests / unit-tests (push) Successful in 6m6s
Unit Tests / unit-tests (pull_request) Successful in 5m43s
2026-09-17 14:30:33 +03:00
vantablackTea f8b4e8cfd3 fix: added back deleted libraries for testing, assumed they weren't used
Unit Tests / unit-tests (push) Successful in 7m4s
Unit Tests / unit-tests (pull_request) Successful in 7m39s
2026-09-16 21:07:51 +03:00
vantablackTea 1fa2f53d35 fix: slight tests optimisation
Unit Tests / unit-tests (push) Successful in 6m37s
Unit Tests / unit-tests (pull_request) Successful in 6m50s
2026-09-16 20:45:17 +03:00
vantablackTea 5e22c3a391 fix: changed incompatibility with old gitea runner
Unit Tests / unit-tests (push) Successful in 4m49s
Unit Tests / unit-tests (pull_request) Successful in 4m37s
2026-09-16 00:49:42 +03:00
vantablackTea 8df7f7c74c fix: version control fix, could work
Unit Tests / unit-tests (push) Failing after 5m2s
2026-09-16 00:37:14 +03:00
vantablackTea 45139640eb ci: added mandatory tests to build release
Unit Tests / unit-tests (push) Failing after 5m24s
2026-09-16 00:32:47 +03:00
vantablackTea aa568cb628 tests: added last unit tests, removed unused ui test dependencies 2026-09-16 00:15:33 +03:00
vantablackTea 4160a79574 tests: added ConvertersTest and LessonTypeTest 2026-09-15 23:50:41 +03:00
vantablackTea 8d04c10f48 tests: added GroupsParserTests 2026-09-14 16:33:39 +03:00
vantablackTea 02bfe60e8b tests: added pdf example, transcription, basic 11 tests 2026-09-14 15:41:42 +03:00
vantablackTea 4ff7b1a5ea tests: removed blanks, removed dependency 2026-09-14 14:23:48 +03:00
Agzamus 6cd211f744 fix: no more group prefix 2026-09-11 02:34:30 +03:00
Agzamus 019cf5da21 feat: also refresh groups list in schedule 2026-09-11 02:05:43 +03:00
Agzamus a24748ec04 ci: "debug" if no APP_VERSION 2026-09-11 02:04:36 +03:00
Agzamus caf3debb3f feat: 2-week select 2026-09-11 01:55:52 +03:00
25 changed files with 1331 additions and 118 deletions
+3 -3
View File
@@ -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
+51
View File
@@ -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'
+2 -6
View File
@@ -19,7 +19,7 @@ android {
versionCode = 1
// В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
// Локально и при отсутствии переменной — fallback "1.0".
versionName = System.getenv("APP_VERSION") ?: "1.0"
versionName = System.getenv("APP_VERSION") ?: "debug"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -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)
}
@@ -0,0 +1,427 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "3fd5eda9ac5e29fc96371ecf246e437c",
"entities": [
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "faculty",
"columnName": "faculty",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "group_periods",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "groupId",
"columnName": "groupId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "season",
"columnName": "season",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weekStart",
"columnName": "weekStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weekEnd",
"columnName": "weekEnd",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_group_periods_groupId",
"unique": false,
"columnNames": [
"groupId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_group_periods_groupId` ON `${TABLE_NAME}` (`groupId`)"
}
],
"foreignKeys": [
{
"table": "groups",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"groupId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "schedules",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "groupId",
"columnName": "groupId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "season",
"columnName": "season",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weekStart",
"columnName": "weekStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weekEnd",
"columnName": "weekEnd",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_schedules_groupId",
"unique": false,
"columnNames": [
"groupId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_schedules_groupId` ON `${TABLE_NAME}` (`groupId`)"
},
{
"name": "index_schedules_groupId_season_weekStart",
"unique": true,
"columnNames": [
"groupId",
"season",
"weekStart"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_schedules_groupId_season_weekStart` ON `${TABLE_NAME}` (`groupId`, `season`, `weekStart`)"
}
],
"foreignKeys": [
{
"table": "groups",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"groupId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "days",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, FOREIGN KEY(`scheduleId`) REFERENCES `schedules`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduleId",
"columnName": "scheduleId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_days_scheduleId",
"unique": false,
"columnNames": [
"scheduleId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)"
},
{
"name": "index_days_date",
"unique": false,
"columnNames": [
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_date` ON `${TABLE_NAME}` (`date`)"
}
],
"foreignKeys": [
{
"table": "schedules",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"scheduleId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "lessons",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, `raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, `subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, `teacher` TEXT, FOREIGN KEY(`dayId`) REFERENCES `days`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dayId",
"columnName": "dayId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "slotIndex",
"columnName": "slotIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "start",
"columnName": "start",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "end",
"columnName": "end",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lessonIndex",
"columnName": "lessonIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "raw",
"columnName": "raw",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "room",
"columnName": "room",
"affinity": "TEXT"
},
{
"fieldPath": "groupName",
"columnName": "groupName",
"affinity": "TEXT"
},
{
"fieldPath": "subgroup",
"columnName": "subgroup",
"affinity": "TEXT"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "teacher",
"columnName": "teacher",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_lessons_dayId",
"unique": false,
"columnNames": [
"dayId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)"
},
{
"name": "index_lessons_date",
"unique": false,
"columnNames": [
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_date` ON `${TABLE_NAME}` (`date`)"
},
{
"name": "index_lessons_subject",
"unique": false,
"columnNames": [
"subject"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_subject` ON `${TABLE_NAME}` (`subject`)"
},
{
"name": "index_lessons_room",
"unique": false,
"columnNames": [
"room"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_room` ON `${TABLE_NAME}` (`room`)"
}
],
"foreignKeys": [
{
"table": "days",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"dayId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "preferences",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3fd5eda9ac5e29fc96371ecf246e437c')"
]
}
}
+95
View File
@@ -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 - <пусто>
Binary file not shown.
@@ -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)
}
}
@@ -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"))
}
}
@@ -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<Day> 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<Lesson> =
day(date).intervals
.first { it.start == LocalTime.parse(start) }
.lessons
.filter { it.raw.isNotBlank() }
private fun allLessons(date: String): List<Lesson> =
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<Lesson>(), 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() }
)
}
}
@@ -7,12 +7,13 @@ import androidx.room.TypeConverters
@Database(
entities = [
GroupEntity::class,
GroupPeriodEntity::class,
ScheduleEntity::class,
DayEntity::class,
LessonEntity::class,
PreferenceEntity::class
],
version = 2,
version = 3,
exportSchema = true
)
@TypeConverters(Converters::class)
@@ -25,4 +25,13 @@ interface GroupDao {
@Query("SELECT MAX(lastSyncedAt) FROM groups")
suspend fun maxSyncedAt(): Long?
@Query("SELECT * FROM group_periods WHERE groupId = :groupId AND weekEnd >= :fromEpochDay ORDER BY weekStart")
suspend fun getCurrentAndFuturePeriods(groupId: String, fromEpochDay: Long): List<GroupPeriodEntity>
@Query("DELETE FROM group_periods")
suspend fun deleteAllPeriods()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPeriods(periods: List<GroupPeriodEntity>)
}
@@ -8,9 +8,5 @@ data class GroupEntity(
@PrimaryKey val id: String,
val name: String,
val faculty: String,
val autumnFirst: Long?,
val autumnSecond: Long?,
val springFirst: Long?,
val springSecond: Long?,
val lastSyncedAt: Long
)
@@ -0,0 +1,28 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Season
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
// Все опубликованные на сайте 2-недельные периоды группы, включая ещё не начавшиеся.
@Entity(
tableName = "group_periods",
foreignKeys = [
ForeignKey(
entity = GroupEntity::class,
parentColumns = ["id"],
childColumns = ["groupId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("groupId")]
)
data class GroupPeriodEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val groupId: String,
val season: Season,
val weekStart: Long,
val weekEnd: Long
)
@@ -108,9 +108,13 @@ object PdfScheduleParser {
for (dayCell in daysColumn.cells) {
val day = content(dayCell).split(' ')
if(day.size == 1) {
Log.v(TAG, "day.first(): ${day.first()}")
}
if(day.size == 1 && day.first() == "День") {
val groupCell = select(lessonsColumn, dayCell).firstOrNull() ?: continue
group = content(groupCell)
Log.v(TAG, "Group: $group")
continue
}
if(day.size != 2) {
@@ -164,7 +168,7 @@ object PdfScheduleParser {
val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim()
var subgroup: String? = null
Log.d(TAG, "$subject $group")
if(group != null && subject.startsWith("$group,")) {
if(group != null && (subject.startsWith("$group,") || subject.startsWith("$group "))) {
subject = subject.substring(group.length + 1).trim()
val subgroupMatch = SUBGROUP_REGEX.find(subject)
if(subgroupMatch != null) {
@@ -197,6 +201,7 @@ object PdfScheduleParser {
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
Log.v(SVG_TAG, "====")
Log.v(SVG_TAG+ "-" + (pageIndex+1), "====")
for(line in svg.asSvg().split("\n")) {
if(line.length > 250) {
Log.v(SVG_TAG, "length: " + line.length)
@@ -2,12 +2,12 @@ package agzam.vyatsu.data.repository
import agzam.vyatsu.data.local.GroupDao
import agzam.vyatsu.data.local.GroupEntity
import agzam.vyatsu.data.local.GroupPeriodEntity
import agzam.vyatsu.data.remote.GroupsParser
import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.Season
import agzam.vyatsu.domain.repository.GroupRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -45,26 +45,33 @@ class GroupRepositoryImpl(
val parsed = withContext(Dispatchers.IO) {
VyatsuHttpClient.openStream(VyatsuUrls.GROUPS_PAGE).use(GroupsParser::parseStream)
}
val entities = parsed.groups.map { group ->
val range = parsed.dateRanges[group.id]
val autumn = range?.get(Season.AUTUMN)?.lastOrNull()
val spring = range?.get(Season.SPRING)?.lastOrNull()
GroupEntity(
id = group.id,
name = group.name,
faculty = group.faculty,
autumnFirst = autumn?.first?.let(::epochDay),
autumnSecond = autumn?.second?.let(::epochDay),
springFirst = spring?.first?.let(::epochDay),
springSecond = spring?.second?.let(::epochDay),
lastSyncedAt = now()
)
val now = now()
groupDao.insertAll(
parsed.groups.map { group ->
GroupEntity(
id = group.id,
name = group.name,
faculty = group.faculty,
lastSyncedAt = now
)
}
)
val periods = parsed.groups.flatMap { group ->
parsed.dateRanges[group.id].orEmpty().flatMap { (season, ranges) ->
ranges.map { range ->
GroupPeriodEntity(
groupId = group.id,
season = season,
weekStart = ScheduleDates.asDate(range.first).toEpochDay(),
weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
)
}
}
}
groupDao.insertAll(entities)
groupDao.deleteAllPeriods()
groupDao.insertPeriods(periods)
}
private fun epochDay(dateStr: String): Long = ScheduleDates.asDate(dateStr).toEpochDay()
private fun now(): Long = System.currentTimeMillis()
private fun GroupEntity.toDomain(): Group = Group(
@@ -1,6 +1,7 @@
package agzam.vyatsu.data.repository
import agzam.vyatsu.data.local.DayEntity
import agzam.vyatsu.data.local.GroupPeriodEntity
import agzam.vyatsu.data.local.LessonEntity
import agzam.vyatsu.data.local.ScheduleDao
import agzam.vyatsu.data.local.ScheduleEntity
@@ -51,15 +52,38 @@ class ScheduleRepositoryImpl(
Log.w(TAG, "sync: group $groupId not found in DB")
return null
}
val season = ScheduleDates.currentSeason()
val range = dateRangeFor(group, season) ?: run {
Log.w(TAG, "sync: no date range for season $season (group ${group.id})")
val today = ScheduleDates.moscowToday().toEpochDay()
val periods = groupDao.getCurrentAndFuturePeriods(groupId, today)
if (periods.isEmpty()) {
Log.w(TAG, "sync: no current/future periods for group ${group.id}")
return null
}
Log.d(TAG, "sync: season=$season range=[${range.first}, ${range.second}]")
return try {
val pdfUrl = VyatsuUrls.groupPdfUrl(groupId, season, range)
for (period in periods) {
syncPeriod(group, period)
}
scheduleDao.trim(groupId, MAX_PERIODS)
return getSchedule(groupId)
}
private suspend fun syncPeriod(group: GroupEntity, period: GroupPeriodEntity) {
val season = period.season
val range = DateRange(
LocalDate.ofEpochDay(period.weekStart).format(RANGE_FMT),
LocalDate.ofEpochDay(period.weekEnd).format(RANGE_FMT)
)
val existing = scheduleDao.getSchedules(group.id)
.firstOrNull { it.season == season && it.weekStart == period.weekStart }
val isCurrent = ScheduleDates.moscowToday().toEpochDay() in period.weekStart..period.weekEnd
if (existing != null && !isCurrent && now() - existing.lastSyncedAt < STALE_WINDOW_MS) {
Log.d(TAG, "sync: period $range up to date, skip")
return
}
try {
val pdfUrl = VyatsuUrls.groupPdfUrl(group.id, season, range)
Log.d(TAG, "sync: downloading $pdfUrl")
val bytes = VyatsuHttpClient.getBytes(pdfUrl)
Log.d(TAG, "sync: downloaded ${bytes.size} bytes")
@@ -67,29 +91,19 @@ class ScheduleRepositoryImpl(
val days = PdfScheduleParser.parse(bytes)
Log.d(TAG, "sync: parsed ${days.size} days")
val weekStart = ScheduleDates.asDate(range.first).toEpochDay()
val weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
val existing = scheduleDao.getSchedules(groupId)
.firstOrNull { it.season == season && it.weekStart == weekStart }
val scheduleId = if (existing != null) existing.id
else scheduleDao.insertSchedule(
val scheduleId = existing?.id ?: scheduleDao.insertSchedule(
ScheduleEntity(
groupId = groupId,
groupId = group.id,
season = season,
weekStart = weekStart,
weekEnd = weekEnd,
weekStart = period.weekStart,
weekEnd = period.weekEnd,
lastSyncedAt = now()
)
)
replaceDays(scheduleId, days)
scheduleDao.trim(groupId, MAX_PERIODS)
getSchedule(groupId)
Log.d(TAG, "sync: period $range synced")
} catch (e: Throwable) {
Log.e(TAG, "sync: failed for $groupId (${e::class.simpleName}: ${e.message})", e)
null
Log.e(TAG, "sync: failed for ${group.id} period $range (${e::class.simpleName}: ${e.message})", e)
}
}
@@ -187,30 +201,13 @@ class ScheduleRepositoryImpl(
teacher = teacher
)
private fun dateRangeFor(group: GroupEntity, season: Season): DateRange? {
return when (season) {
Season.AUTUMN -> if (group.autumnFirst != null && group.autumnSecond != null) {
DateRange(
LocalDate.ofEpochDay(group.autumnFirst).format(RANGE_FMT),
LocalDate.ofEpochDay(group.autumnSecond).format(RANGE_FMT)
)
} else null
Season.SPRING -> if (group.springFirst != null && group.springSecond != null) {
DateRange(
LocalDate.ofEpochDay(group.springFirst).format(RANGE_FMT),
LocalDate.ofEpochDay(group.springSecond).format(RANGE_FMT)
)
} else null
}
}
private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
private fun now(): Long = System.currentTimeMillis()
companion object {
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
private const val MAX_PERIODS = 3
private const val MAX_PERIODS = 24
private const val TAG = "ScheduleRepo"
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
}
@@ -160,6 +160,9 @@ class ScheduleViewModel(
fun refresh() {
val groupId = currentGroupId ?: return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
// Обновляем список групп: так подхватываются новые PDF (периоды), появившиеся на сайте.
runCatching { groupRepository.forceRefresh() }
loadSchedule(groupId)
}
}
@@ -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)
}
}
@@ -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"))
}
}
@@ -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 = """<div class="fak_name">$name</div>"""
private fun groupDiv(periodId: String, name: String): String =
"""<div class="grpPeriod" data-grp_period_id="$periodId">$name</div>"""
private fun pdfLink(url: String): String = """<a href="$url">x</a>"""
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),
"""<div class="grpPeriod" data-grp_period_id="555" title="a>b">Группа Б</div>"""
)
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))
}
}
@@ -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 = """<a href="$url">x</a>"""
val parsed = GroupsParser.parseStream(html.byteInputStream(Charsets.UTF_8))
assertEquals(listOf(autumnRange), parsed.dateRanges["12345"]?.get(Season.AUTUMN))
}
}
@@ -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)))
}
}
@@ -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))
}
}
}
@@ -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<Day>(), schedule.daysOf(emptyPeriod))
}
@Test
fun daysOf_neverReturnsDayOutsideEveryPeriod() {
val collected = schedule.periods.flatMap { schedule.daysOf(it) }
assertFalse(collected.contains(dayOutsidePeriods))
}
}
+2 -4
View File
@@ -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" }