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)) SUFFIX=$((SUFFIX + 1))
TAG="v${BASE}.${SUFFIX}" TAG="v${BASE}.${SUFFIX}"
done done
echo "version_name=$BASE" >> "$GITHUB_OUTPUT" echo "version_name=${TAG#v}" >> "$GITHUB_OUTPUT"
echo "version_tag=$TAG" >> "$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 - name: Restore keystore from secret
# Ключ подписи передаётся как base64-секрет и декодируется в файл. # Ключ подписи передаётся как base64-секрет и декодируется в файл.
@@ -102,7 +102,7 @@ jobs:
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ORG_GRADLE_JVMARGS: '-Xmx2048m' ORG_GRADLE_JVMARGS: '-Xmx2048m'
GRADLE_OPTS: '-Dorg.gradle.daemon=false' GRADLE_OPTS: '-Dorg.gradle.daemon=true'
- name: Determine APK path - name: Determine APK path
id: apk 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 versionCode = 1
// В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД). // В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
// Локально и при отсутствии переменной — fallback "1.0". // Локально и при отсутствии переменной — fallback "1.0".
versionName = System.getenv("APP_VERSION") ?: "1.0" versionName = System.getenv("APP_VERSION") ?: "debug"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -109,11 +109,7 @@ dependencies {
implementation(libs.pdfbox.android) implementation(libs.pdfbox.android)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation("org.apache.pdfbox:pdfbox:3.0.4")
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling) 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( @Database(
entities = [ entities = [
GroupEntity::class, GroupEntity::class,
GroupPeriodEntity::class,
ScheduleEntity::class, ScheduleEntity::class,
DayEntity::class, DayEntity::class,
LessonEntity::class, LessonEntity::class,
PreferenceEntity::class PreferenceEntity::class
], ],
version = 2, version = 3,
exportSchema = true exportSchema = true
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@@ -25,4 +25,13 @@ interface GroupDao {
@Query("SELECT MAX(lastSyncedAt) FROM groups") @Query("SELECT MAX(lastSyncedAt) FROM groups")
suspend fun maxSyncedAt(): Long? 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, @PrimaryKey val id: String,
val name: String, val name: String,
val faculty: String, val faculty: String,
val autumnFirst: Long?,
val autumnSecond: Long?,
val springFirst: Long?,
val springSecond: Long?,
val lastSyncedAt: 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) { for (dayCell in daysColumn.cells) {
val day = content(dayCell).split(' ') val day = content(dayCell).split(' ')
if(day.size == 1) {
Log.v(TAG, "day.first(): ${day.first()}")
}
if(day.size == 1 && day.first() == "День") { if(day.size == 1 && day.first() == "День") {
val groupCell = select(lessonsColumn, dayCell).firstOrNull() ?: continue val groupCell = select(lessonsColumn, dayCell).firstOrNull() ?: continue
group = content(groupCell) group = content(groupCell)
Log.v(TAG, "Group: $group")
continue continue
} }
if(day.size != 2) { if(day.size != 2) {
@@ -164,7 +168,7 @@ object PdfScheduleParser {
val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim() val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim()
var subgroup: String? = null var subgroup: String? = null
Log.d(TAG, "$subject $group") 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() subject = subject.substring(group.length + 1).trim()
val subgroupMatch = SUBGROUP_REGEX.find(subject) val subgroupMatch = SUBGROUP_REGEX.find(subject)
if(subgroupMatch != null) { if(subgroupMatch != null) {
@@ -197,6 +201,7 @@ object PdfScheduleParser {
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/ /** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
Log.v(SVG_TAG, "====") Log.v(SVG_TAG, "====")
Log.v(SVG_TAG+ "-" + (pageIndex+1), "====")
for(line in svg.asSvg().split("\n")) { for(line in svg.asSvg().split("\n")) {
if(line.length > 250) { if(line.length > 250) {
Log.v(SVG_TAG, "length: " + line.length) 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.GroupDao
import agzam.vyatsu.data.local.GroupEntity import agzam.vyatsu.data.local.GroupEntity
import agzam.vyatsu.data.local.GroupPeriodEntity
import agzam.vyatsu.data.remote.GroupsParser import agzam.vyatsu.data.remote.GroupsParser
import agzam.vyatsu.data.remote.VyatsuHttpClient import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.ScheduleDates import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Group import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.Season
import agzam.vyatsu.domain.repository.GroupRepository import agzam.vyatsu.domain.repository.GroupRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -45,25 +45,32 @@ class GroupRepositoryImpl(
val parsed = withContext(Dispatchers.IO) { val parsed = withContext(Dispatchers.IO) {
VyatsuHttpClient.openStream(VyatsuUrls.GROUPS_PAGE).use(GroupsParser::parseStream) VyatsuHttpClient.openStream(VyatsuUrls.GROUPS_PAGE).use(GroupsParser::parseStream)
} }
val entities = parsed.groups.map { group -> val now = now()
val range = parsed.dateRanges[group.id] groupDao.insertAll(
val autumn = range?.get(Season.AUTUMN)?.lastOrNull() parsed.groups.map { group ->
val spring = range?.get(Season.SPRING)?.lastOrNull()
GroupEntity( GroupEntity(
id = group.id, id = group.id,
name = group.name, name = group.name,
faculty = group.faculty, faculty = group.faculty,
autumnFirst = autumn?.first?.let(::epochDay), lastSyncedAt = now
autumnSecond = autumn?.second?.let(::epochDay),
springFirst = spring?.first?.let(::epochDay),
springSecond = spring?.second?.let(::epochDay),
lastSyncedAt = now()
) )
} }
groupDao.insertAll(entities) )
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.deleteAllPeriods()
groupDao.insertPeriods(periods)
} }
private fun epochDay(dateStr: String): Long = ScheduleDates.asDate(dateStr).toEpochDay()
private fun now(): Long = System.currentTimeMillis() private fun now(): Long = System.currentTimeMillis()
@@ -1,6 +1,7 @@
package agzam.vyatsu.data.repository package agzam.vyatsu.data.repository
import agzam.vyatsu.data.local.DayEntity import agzam.vyatsu.data.local.DayEntity
import agzam.vyatsu.data.local.GroupPeriodEntity
import agzam.vyatsu.data.local.LessonEntity import agzam.vyatsu.data.local.LessonEntity
import agzam.vyatsu.data.local.ScheduleDao import agzam.vyatsu.data.local.ScheduleDao
import agzam.vyatsu.data.local.ScheduleEntity import agzam.vyatsu.data.local.ScheduleEntity
@@ -51,15 +52,38 @@ class ScheduleRepositoryImpl(
Log.w(TAG, "sync: group $groupId not found in DB") Log.w(TAG, "sync: group $groupId not found in DB")
return null return null
} }
val season = ScheduleDates.currentSeason() val today = ScheduleDates.moscowToday().toEpochDay()
val range = dateRangeFor(group, season) ?: run { val periods = groupDao.getCurrentAndFuturePeriods(groupId, today)
Log.w(TAG, "sync: no date range for season $season (group ${group.id})") if (periods.isEmpty()) {
Log.w(TAG, "sync: no current/future periods for group ${group.id}")
return null return null
} }
Log.d(TAG, "sync: season=$season range=[${range.first}, ${range.second}]")
return try { for (period in periods) {
val pdfUrl = VyatsuUrls.groupPdfUrl(groupId, season, range) 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") Log.d(TAG, "sync: downloading $pdfUrl")
val bytes = VyatsuHttpClient.getBytes(pdfUrl) val bytes = VyatsuHttpClient.getBytes(pdfUrl)
Log.d(TAG, "sync: downloaded ${bytes.size} bytes") Log.d(TAG, "sync: downloaded ${bytes.size} bytes")
@@ -67,29 +91,19 @@ class ScheduleRepositoryImpl(
val days = PdfScheduleParser.parse(bytes) val days = PdfScheduleParser.parse(bytes)
Log.d(TAG, "sync: parsed ${days.size} days") Log.d(TAG, "sync: parsed ${days.size} days")
val weekStart = ScheduleDates.asDate(range.first).toEpochDay() val scheduleId = existing?.id ?: scheduleDao.insertSchedule(
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(
ScheduleEntity( ScheduleEntity(
groupId = groupId, groupId = group.id,
season = season, season = season,
weekStart = weekStart, weekStart = period.weekStart,
weekEnd = weekEnd, weekEnd = period.weekEnd,
lastSyncedAt = now() lastSyncedAt = now()
) )
) )
replaceDays(scheduleId, days) replaceDays(scheduleId, days)
Log.d(TAG, "sync: period $range synced")
scheduleDao.trim(groupId, MAX_PERIODS)
getSchedule(groupId)
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "sync: failed for $groupId (${e::class.simpleName}: ${e.message})", e) Log.e(TAG, "sync: failed for ${group.id} period $range (${e::class.simpleName}: ${e.message})", e)
null
} }
} }
@@ -187,30 +201,13 @@ class ScheduleRepositoryImpl(
teacher = teacher 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 LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
private fun now(): Long = System.currentTimeMillis() private fun now(): Long = System.currentTimeMillis()
companion object { companion object {
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L 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 const val TAG = "ScheduleRepo"
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy") private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
} }
@@ -160,6 +160,9 @@ class ScheduleViewModel(
fun refresh() { fun refresh() {
val groupId = currentGroupId ?: return val groupId = currentGroupId ?: return
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
// Обновляем список групп: так подхватываются новые PDF (периоды), появившиеся на сайте.
runCatching { groupRepository.forceRefresh() }
loadSchedule(groupId) 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" coreKtx = "1.10.1"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.1.5" junitVersion = "1.1.5"
espressoCore = "3.5.1" runner = "1.5.2"
lifecycleRuntimeKtx = "2.6.1" lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0" activityCompose = "1.8.0"
kotlin = "2.2.10" kotlin = "2.2.10"
@@ -22,7 +22,7 @@ monitor = "1.8.0"
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 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-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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } 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-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } 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-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-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } 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" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }