diff --git a/app/src/main/java/com/ahu/ahutong/appwidget/AdaptiveTestWidgetProvider.kt b/app/src/main/java/com/ahu/ahutong/appwidget/AdaptiveTestWidgetProvider.kt index e9eb926f..3f3bfe22 100644 --- a/app/src/main/java/com/ahu/ahutong/appwidget/AdaptiveTestWidgetProvider.kt +++ b/app/src/main/java/com/ahu/ahutong/appwidget/AdaptiveTestWidgetProvider.kt @@ -121,17 +121,21 @@ class ScheduleAdaptiveWidgetProvider : AppWidgetProvider() { runBlocking { AHURepository.getSchedule(false) } }.getOrNull()?.getOrNull().orEmpty() val currentMinutes = DebugClock.currentMinutes() - val todayCourses = schedule - .filter { currentWeek in it.startWeek..it.endWeek } - .filter { it.weekday == weekDay } - .filter { - if (currentWeek in it.weekIndexes) { - true - } else { - currentWeek % 2 == it.startWeek % 2 + val todayCourses = if (scheduleConfig.isInSemester) { + schedule + .filter { currentWeek in it.startWeek..it.endWeek } + .filter { it.weekday == weekDay } + .filter { + if (currentWeek in it.weekIndexes) { + true + } else { + currentWeek % 2 == it.startWeek % 2 + } } - } - .sortedBy { it.startTime } + .sortedBy { it.startTime } + } else { + emptyList() + } val remainingCourses = todayCourses.filter { currentMinutes <= ScheduleViewModel.getCourseTimeRangeInMinutes(it).last } diff --git a/app/src/main/java/com/ahu/ahutong/appwidget/ScheduleAppWidget.kt b/app/src/main/java/com/ahu/ahutong/appwidget/ScheduleAppWidget.kt index bc37fc22..fcd324e2 100644 --- a/app/src/main/java/com/ahu/ahutong/appwidget/ScheduleAppWidget.kt +++ b/app/src/main/java/com/ahu/ahutong/appwidget/ScheduleAppWidget.kt @@ -66,17 +66,21 @@ class ScheduleAppWidget : GlanceAppWidget() { val schedule = runCatching { AHURepository.getSchedule(false) }.getOrNull()?.getOrNull().orEmpty() val currentMinutes = DebugClock.currentMinutes() - val todayCourses = schedule - .filter { currentWeek in it.startWeek..it.endWeek } - .filter { it.weekday == weekDay } - .filter { - if (currentWeek in it.weekIndexes) { - true - } else { - currentWeek % 2 == it.startWeek % 2 + val todayCourses = if (scheduleConfig.isInSemester) { + schedule + .filter { currentWeek in it.startWeek..it.endWeek } + .filter { it.weekday == weekDay } + .filter { + if (currentWeek in it.weekIndexes) { + true + } else { + currentWeek % 2 == it.startWeek % 2 + } } - } - .sortedBy { it.startTime } + .sortedBy { it.startTime } + } else { + emptyList() + } val keyColor = resolveWidgetKeyColor(context) provideContent { ScheduleWidgetContent( diff --git a/app/src/main/java/com/ahu/ahutong/data/crawler/CrawlerDataSource.kt b/app/src/main/java/com/ahu/ahutong/data/crawler/CrawlerDataSource.kt index b093db68..12c42862 100644 --- a/app/src/main/java/com/ahu/ahutong/data/crawler/CrawlerDataSource.kt +++ b/app/src/main/java/com/ahu/ahutong/data/crawler/CrawlerDataSource.kt @@ -30,11 +30,120 @@ import com.google.gson.JsonParser import okhttp3.FormBody import okhttp3.ResponseBody import org.jsoup.Jsoup -import retrofit2.Response -import kotlin.text.Regex - - -class CrawlerDataSource : BaseDataSource { +import retrofit2.Response +import kotlin.text.Regex + +internal suspend fun fetchCurrentSemester(): CurrentSemester { + val basicInfo = JwxtApi.API.fetchCourseTableBasicInfo() + val body = basicInfo.body()?.string() + ?: throw IllegalStateException("Cannot load current semester page") + val currentSemesterJson = Jsoup.parse(body) + .select("script") + .asSequence() + .map { it.data() } + .mapNotNull(::extractCurrentSemesterJson) + .firstOrNull() + ?: throw IllegalStateException("Cannot parse current semester") + + return Gson().fromJson( + currentSemesterJson, + CurrentSemester::class.java + ) +} + +internal fun extractCurrentSemesterJson(script: String): String? { + val assignment = Regex("(?:var|let|const)\\s+currentSemester\\s*=\\s*") + .find(script) + ?: return null + var cursor = assignment.range.last + 1 + while (cursor < script.length && script[cursor].isWhitespace()) cursor += 1 + + if (script.startsWith("JSON.parse", cursor)) { + cursor += "JSON.parse".length + while (cursor < script.length && script[cursor].isWhitespace()) cursor += 1 + if (cursor >= script.length || script[cursor] != '(') return null + cursor += 1 + while (cursor < script.length && script[cursor].isWhitespace()) cursor += 1 + val parsed = parseJavaScriptString(script, cursor) ?: return null + cursor = parsed.nextIndex + while (cursor < script.length && script[cursor].isWhitespace()) cursor += 1 + return parsed.value.takeIf { cursor < script.length && script[cursor] == ')' } + } + + return extractBalancedJsonObject(script, cursor) +} + +private fun extractBalancedJsonObject(source: String, startIndex: Int): String? { + if (startIndex >= source.length || source[startIndex] != '{') return null + var depth = 0 + var quote: Char? = null + var escaped = false + for (index in startIndex until source.length) { + val character = source[index] + if (quote != null) { + when { + escaped -> escaped = false + character == '\\' -> escaped = true + character == quote -> quote = null + } + continue + } + when (character) { + '\'', '"' -> quote = character + '{' -> depth += 1 + '}' -> { + depth -= 1 + if (depth == 0) return source.substring(startIndex, index + 1) + if (depth < 0) return null + } + } + } + return null +} + +private data class ParsedJavaScriptString(val value: String, val nextIndex: Int) + +private fun parseJavaScriptString(source: String, startIndex: Int): ParsedJavaScriptString? { + if (startIndex >= source.length || source[startIndex] !in charArrayOf('\'', '"')) return null + val quote = source[startIndex] + val result = StringBuilder() + var cursor = startIndex + 1 + while (cursor < source.length) { + val character = source[cursor++] + if (character == quote) return ParsedJavaScriptString(result.toString(), cursor) + if (character != '\\') { + result.append(character) + continue + } + if (cursor >= source.length) return null + when (val escaped = source[cursor++]) { + '\\', '\'', '"', '/' -> result.append(escaped) + 'b' -> result.append('\b') + 'f' -> result.append('\u000C') + 'n' -> result.append('\n') + 'r' -> result.append('\r') + 't' -> result.append('\t') + 'u' -> { + if (cursor + 4 > source.length) return null + val codePoint = source.substring(cursor, cursor + 4).toIntOrNull(16) ?: return null + result.append(codePoint.toChar()) + cursor += 4 + } + 'x' -> { + if (cursor + 2 > source.length) return null + val codePoint = source.substring(cursor, cursor + 2).toIntOrNull(16) ?: return null + result.append(codePoint.toChar()) + cursor += 2 + } + '\n' -> Unit + '\r' -> if (cursor < source.length && source[cursor] == '\n') cursor += 1 + else -> result.append(escaped) + } + } + return null +} + +class CrawlerDataSource : BaseDataSource { val TAG = this::class.java.simpleName @@ -46,7 +155,7 @@ class CrawlerDataSource : BaseDataSource { } override suspend fun getSchedule(): AHUResponse> { - val currentSemesterJson = getCurrentSemester() + val currentSemesterJson = fetchCurrentSemester() val courseTable = JwxtApi.API.getCourse(currentSemesterJson.id, currentSemesterJson.id) AHUCache.saveSchoolTerm(currentSemesterJson.name) @@ -59,7 +168,7 @@ class CrawlerDataSource : BaseDataSource { } override suspend fun getNextSchedule(): AHUResponse> { - val currentSemesterJson = getCurrentSemester() + val currentSemesterJson = fetchCurrentSemester() val nextCourseTable = JwxtApi.API.getCourse(currentSemesterJson.id + 20, currentSemesterJson.id) return AHUResponse>().apply { @@ -69,33 +178,6 @@ class CrawlerDataSource : BaseDataSource { } } - private suspend fun getCurrentSemester(): CurrentSemester { - val basicInfo = JwxtApi.API.fetchCourseTableBasicInfo() - val doc = Jsoup.parse(basicInfo.body()!!.string()) - - val element = doc.select("script") - .map { it.data() } - .firstOrNull { it.contains("var semesters = JSON.parse") && it.contains("var currentSemester") } - - - if (element == null) { - throw IllegalStateException("Cannot find current semester script") - } - val currentSemesterPattern = Regex("var\\s+currentSemester\\s*=\\s*(\\{.*?\\});") - val currentSemester = currentSemesterPattern.find(element) - ?: throw IllegalStateException("Cannot parse current semester") - - - val gson = Gson() - - val currentSemesterJson = gson.fromJson( - currentSemester!!.groups[1]!!.value.replace("\\\"", "\""), - CurrentSemester::class.java - ) - - return currentSemesterJson - } - private fun CourseTable.toCourseList(): List { val courseList = ArrayList() studentTableVms.firstOrNull()?.activities.orEmpty().forEach { diff --git a/app/src/main/java/com/ahu/ahutong/data/dao/AHUCache.kt b/app/src/main/java/com/ahu/ahutong/data/dao/AHUCache.kt index b9fa7afc..9f96deff 100644 --- a/app/src/main/java/com/ahu/ahutong/data/dao/AHUCache.kt +++ b/app/src/main/java/com/ahu/ahutong/data/dao/AHUCache.kt @@ -284,6 +284,33 @@ object AHUCache { kv.encode("startTime-$schoolYear-$schoolTerm", startTime) } + fun getSchoolTermInSemester(schoolYear: String, schoolTerm: String): Boolean? { + val key = "inSemester-$schoolYear-$schoolTerm" + return userGetStringOrMigrate(key) { kv.decodeString(key) } + ?.toBooleanStrictOrNull() + } + + fun getSchoolTermInSemesterObservedOn(schoolYear: String, schoolTerm: String): String? { + val key = "inSemesterObservedOn-$schoolYear-$schoolTerm" + return userGetStringOrMigrate(key) { kv.decodeString(key) } + } + + fun saveSchoolTermInSemester( + schoolYear: String, + schoolTerm: String, + isInSemester: Boolean, + observedOn: String + ) { + val key = "inSemester-$schoolYear-$schoolTerm" + val value = isInSemester.toString() + userPutString(key, value) + kv.encode(key, value) + + val observedOnKey = "inSemesterObservedOn-$schoolYear-$schoolTerm" + userPutString(observedOnKey, observedOn) + kv.encode(observedOnKey, observedOn) + } + /** * 获取默认的学年 * @return String? diff --git a/app/src/main/java/com/ahu/ahutong/data/model/ScheduleConfigBean.java b/app/src/main/java/com/ahu/ahutong/data/model/ScheduleConfigBean.java index e81db359..37beee8a 100644 --- a/app/src/main/java/com/ahu/ahutong/data/model/ScheduleConfigBean.java +++ b/app/src/main/java/com/ahu/ahutong/data/model/ScheduleConfigBean.java @@ -7,6 +7,7 @@ public class ScheduleConfigBean { private int week; // 当前周 private int weekDay; // 周几 private boolean isShowAll; // 是否显示非本周 + private boolean isInSemester = true; // 是否处于教学周 public Date getStartTime() { return startTime; @@ -39,4 +40,12 @@ public boolean isShowAll() { public void setShowAll(boolean showAll) { isShowAll = showAll; } + + public boolean isInSemester() { + return isInSemester; + } + + public void setInSemester(boolean inSemester) { + isInSemester = inSemester; + } } diff --git a/app/src/main/java/com/ahu/ahutong/data/schedule/CurrentWeekResolver.kt b/app/src/main/java/com/ahu/ahutong/data/schedule/CurrentWeekResolver.kt index bf2cfb9b..048d7b7b 100644 --- a/app/src/main/java/com/ahu/ahutong/data/schedule/CurrentWeekResolver.kt +++ b/app/src/main/java/com/ahu/ahutong/data/schedule/CurrentWeekResolver.kt @@ -1,12 +1,12 @@ package com.ahu.ahutong.data.schedule import com.ahu.ahutong.data.crawler.api.jwxt.JwxtApi +import com.ahu.ahutong.data.crawler.fetchCurrentSemester import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.debug.DebugClock import com.ahu.ahutong.data.model.ScheduleConfigBean import java.time.LocalDate import java.time.ZoneId -import java.time.temporal.ChronoUnit import java.util.Calendar import java.util.Date import java.util.Locale @@ -75,24 +75,45 @@ object CurrentWeekResolver { semesterKey.schoolTerm ) ?: return null val startDate = runCatching { LocalDate.parse(startTime) }.getOrNull() ?: return null - val days = ChronoUnit.DAYS.between(startDate, now) - val week = (days.coerceAtLeast(0) / 7L).toInt() + 1 + val week = TeachingWeekPolicy.weekForDate(startDate, now) + val cachedState = AHUCache.getSchoolTermInSemester( + semesterKey.schoolYear, + semesterKey.schoolTerm + ) + val stateObservedOn = AHUCache.getSchoolTermInSemesterObservedOn( + semesterKey.schoolYear, + semesterKey.schoolTerm + )?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + val isInSemester = TeachingWeekPolicy.resolveLocalSemesterState( + startDate = startDate, + date = now, + cachedState = cachedState, + stateObservedOn = stateObservedOn + ) return ResolvedConfig( config = buildScheduleConfig( week = week, weekDay = now.dayOfWeek.value, - startDate = startDate + startDate = startDate, + isInSemester = isInSemester ), source = Source.LOCAL ) } suspend fun resolveLocalFirst(now: LocalDate = DebugClock.nowLocalDate()): ResolvedConfig { + val localConfig = resolveLocalConfig(now) if (DebugClock.isMocked()) { - return resolveLocalConfig(now) ?: defaultConfig(now) + return localConfig ?: defaultConfig(now) + } + val cachedSemester = getCachedSemesterKey() + val stateObservedOn = cachedSemester?.let { + AHUCache.getSchoolTermInSemesterObservedOn(it.schoolYear, it.schoolTerm) } - return resolveLocalConfig(now) - ?: runCatching { syncRemoteConfig(now) }.getOrNull() + if (localConfig != null && stateObservedOn == now.toString()) return localConfig + + return runCatching { syncRemoteConfig(now) }.getOrNull() + ?: localConfig ?: defaultConfig(now) } @@ -108,24 +129,46 @@ object CurrentWeekResolver { AHUCache.saveSchoolTerm(it.raw) } ?: AHUCache.saveSchoolTerm(response.currentSemester) - val weekDay = now.dayOfWeek.value - val mondayOfCurrentWeek = now.minusDays((weekDay - 1).toLong()) - val currentWeek = response.weekIndex.coerceAtLeast(1) - val startDate = mondayOfCurrentWeek.minusDays((currentWeek - 1).toLong() * 7L) + val cachedStartDate = semesterKey?.let { + AHUCache.getSchoolTermStartTime(it.schoolYear, it.schoolTerm) + ?.let { value -> runCatching { LocalDate.parse(value) }.getOrNull() } + } + val remoteIsInSemester = response.isInSemester && response.weekIndex > 0 + val officialStartDate = if (!remoteIsInSemester && semesterKey != null) { + runCatching { getOfficialStartDate(semesterKey) }.getOrNull() + } else { + null + } + val position = TeachingWeekPolicy.resolveRemotePosition( + now = now, + remoteWeekIndex = response.weekIndex, + remoteIsInSemester = response.isInSemester, + cachedStartDate = cachedStartDate, + officialStartDate = officialStartDate + ) semesterKey?.let { - AHUCache.saveSchoolTermStartTime( - it.schoolYear, - it.schoolTerm, - startDate.toString() + AHUCache.saveSchoolTermInSemester( + schoolYear = it.schoolYear, + schoolTerm = it.schoolTerm, + isInSemester = position.isInSemester, + observedOn = now.toString() ) + if (position.isInSemester || officialStartDate != null) { + AHUCache.saveSchoolTermStartTime( + it.schoolYear, + it.schoolTerm, + position.startDate.toString() + ) + } } return ResolvedConfig( config = buildScheduleConfig( - week = currentWeek, - weekDay = weekDay, - startDate = startDate + week = position.week, + weekDay = now.dayOfWeek.value, + startDate = position.startDate, + isInSemester = position.isInSemester ), source = Source.REMOTE ) @@ -138,7 +181,8 @@ object CurrentWeekResolver { config = buildScheduleConfig( week = 1, weekDay = weekDay, - startDate = mondayOfCurrentWeek + startDate = mondayOfCurrentWeek, + isInSemester = true ), source = Source.DEFAULT ) @@ -147,13 +191,30 @@ object CurrentWeekResolver { private fun buildScheduleConfig( week: Int, weekDay: Int, - startDate: LocalDate + startDate: LocalDate, + isInSemester: Boolean ): ScheduleConfigBean { return ScheduleConfigBean().apply { this.week = week this.weekDay = weekDay this.startTime = Date.from(startDate.atStartOfDay(ZoneId.systemDefault()).toInstant()) this.isShowAll = AHUCache.isShowAllCourse() + this.isInSemester = isInSemester } } + + private suspend fun getOfficialStartDate(semesterKey: SemesterKey): LocalDate? { + val semester = fetchCurrentSemester() + val officialKey = sequenceOf(semester.name, semester.code, semester.nameZh) + .mapNotNull(::parseSemesterKey) + .firstOrNull { + it.schoolYear == semesterKey.schoolYear && + it.schoolTerm == semesterKey.schoolTerm + } ?: return null + return LocalDate.of( + semester.startDate.year, + semester.startDate.monthOfYear, + semester.startDate.dayOfMonth + ) + } } diff --git a/app/src/main/java/com/ahu/ahutong/data/schedule/TeachingWeekPolicy.kt b/app/src/main/java/com/ahu/ahutong/data/schedule/TeachingWeekPolicy.kt new file mode 100644 index 00000000..0bb2476e --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/data/schedule/TeachingWeekPolicy.kt @@ -0,0 +1,57 @@ +package com.ahu.ahutong.data.schedule + +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +internal object TeachingWeekPolicy { + const val SCHEDULE_WEEK_COUNT = 20 + + data class Position( + val week: Int, + val isInSemester: Boolean, + val startDate: LocalDate + ) + + fun resolveRemotePosition( + now: LocalDate, + remoteWeekIndex: Int, + remoteIsInSemester: Boolean, + cachedStartDate: LocalDate?, + officialStartDate: LocalDate? + ): Position { + val mondayOfCurrentWeek = now.minusDays((now.dayOfWeek.value - 1).toLong()) + val isInSemester = remoteIsInSemester && remoteWeekIndex > 0 + val startDate = when { + isInSemester -> mondayOfCurrentWeek.minusWeeks((remoteWeekIndex - 1).toLong()) + officialStartDate != null -> officialStartDate + cachedStartDate != null -> cachedStartDate + else -> mondayOfCurrentWeek + } + val week = if (isInSemester) { + remoteWeekIndex + } else { + weekForDate(startDate, now) + } + + return Position( + week = week.coerceIn(1, SCHEDULE_WEEK_COUNT), + isInSemester = isInSemester, + startDate = startDate + ) + } + + fun weekForDate(startDate: LocalDate, date: LocalDate): Int { + val days = ChronoUnit.DAYS.between(startDate, date) + return ((days / 7L).toInt() + 1).coerceIn(1, SCHEDULE_WEEK_COUNT) + } + + fun resolveLocalSemesterState( + startDate: LocalDate, + date: LocalDate, + cachedState: Boolean?, + stateObservedOn: LocalDate? + ): Boolean { + if (cachedState != null && stateObservedOn == date) return cachedState + return !date.isBefore(startDate) && date.isBefore(startDate.plusWeeks(SCHEDULE_WEEK_COUNT.toLong())) + } +} diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Home.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Home.kt index 4da233ae..97c2a465 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Home.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Home.kt @@ -82,20 +82,25 @@ fun Home( val density = LocalDensity.current val schedule = scheduleViewModel.schedule.observeAsState().value?.getOrNull() ?: emptyList() val scheduleConfig by scheduleViewModel.scheduleConfig.observeAsState() - val effectiveScheduleConfig = CurrentWeekResolver.resolveLocalConfig()?.config ?: scheduleConfig + val effectiveScheduleConfig = scheduleConfig ?: CurrentWeekResolver.resolveLocalConfig()?.config + val isInSemester = effectiveScheduleConfig?.isInSemester != false val currentWeek = effectiveScheduleConfig?.week ?: 1 val mockRefreshRevision by MockScenarioController.refreshRevisions().collectAsState() - val todayCourses = schedule - .filter { effectiveScheduleConfig?.week in it.startWeek..it.endWeek } - .filter { it.weekday == (effectiveScheduleConfig?.weekDay ?: 1) } - .filter { - if (currentWeek in it.weekIndexes) { - true - } else { - currentWeek % 2 == it.startWeek % 2 + val todayCourses = if (isInSemester) { + schedule + .filter { effectiveScheduleConfig?.week in it.startWeek..it.endWeek } + .filter { it.weekday == (effectiveScheduleConfig?.weekDay ?: 1) } + .filter { + if (currentWeek in it.weekIndexes) { + true + } else { + currentWeek % 2 == it.startWeek % 2 + } } - } - .sortedBy { it.startTime } + .sortedBy { it.startTime } + } else { + emptyList() + } var currentMinutes by remember { mutableIntStateOf(DebugClock.currentMinutes()) } var isEditingHome by remember { mutableStateOf(false) } var homeWidgetSlots by remember { @@ -298,6 +303,7 @@ fun Home( todayCourses = todayCourses, currentMinutes = currentMinutes, navController = navController, + isInSemester = isInSemester, enabled = !isEditingHome, trailingContent = { if ( diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Schedule.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Schedule.kt index 3c5e2c61..0b900a92 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Schedule.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Schedule.kt @@ -118,9 +118,15 @@ fun Schedule(scheduleViewModel: ScheduleViewModel = hiltViewModel()) { ) } - LaunchedEffect(scheduleConfig?.week) { + LaunchedEffect(scheduleConfig?.week, isPreviewNextSemester) { if (!isPreviewNextSemester) { - scheduleConfig?.week?.let { currentWeek = it } + scheduleConfig?.week?.let { resolvedWeek -> + currentWeek = resolvedWeek + val targetPage = (resolvedWeek - 1).coerceIn(0, 19) + if (pagerState.currentPage != targetPage) { + pagerState.scrollToPage(targetPage) + } + } } } @@ -326,7 +332,10 @@ fun Schedule(scheduleViewModel: ScheduleViewModel = hiltViewModel()) { weekDates.forEachIndexed { index, date -> val isCurrentWeekday = - !isPreviewNextSemester && pageWeek == scheduleConfig?.week && index + 1 == currentWeekday + !isPreviewNextSemester && + scheduleConfig?.isInSemester == true && + pageWeek == scheduleConfig?.week && + index + 1 == currentWeekday Column( modifier = with(CourseCardSpec) { Modifier diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/AtAGlance.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/AtAGlance.kt index 1d4b1836..282cee74 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/AtAGlance.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/AtAGlance.kt @@ -35,6 +35,7 @@ fun AtAGlance( todayCourses: List, currentMinutes: Int, navController: NavHostController, + isInSemester: Boolean = true, enabled: Boolean = true, trailingContent: @Composable RowScope.() -> Unit = {} ) { @@ -87,22 +88,25 @@ fun AtAGlance( .padding(horizontal = 32.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { + if (isInSemester) { + Text( + text = when { + currentCourse != null -> "正在上课" + hasRemainingCourses -> "下节课是" + else -> "今日课程" + }, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLarge + ) + } Text( text = when { - currentCourse != null -> "正在上课" - hasRemainingCourses -> "下节课是" - else -> "今日课程" - }, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyLarge - ) - Text( - text = when { + !isInSemester -> "假期中" currentCourse != null -> currentCourse.name hasRemainingCourses -> todayCourses[currentCourseIndex].name else -> "已全部上完" }, - modifier = if (currentCourse != null || hasRemainingCourses) { + modifier = if (isInSemester && (currentCourse != null || hasRemainingCourses)) { Modifier .composed { val color = 50.a1 withNight 90.a1 diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/ScheduleViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/ScheduleViewModel.kt index 6e2ebee7..b04d149f 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/ScheduleViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/ScheduleViewModel.kt @@ -119,6 +119,7 @@ class ScheduleViewModel () : ViewModel() { // 修改当前的开学时间和周数 val configBean = (scheduleConfig.value ?: ScheduleConfigBean()).apply { isShowAll = AHUCache.isShowAllCourse() + isInSemester = true startTime = instance.time this.week = week weekDay = CurrentWeekResolver.getCurrentWeekDay() @@ -129,6 +130,12 @@ class ScheduleViewModel () : ViewModel() { schoolTerm, SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(instance.time) ) + AHUCache.saveSchoolTermInSemester( + schoolYear = schoolYear, + schoolTerm = schoolTerm, + isInSemester = true, + observedOn = DebugClock.nowLocalDate().toString() + ) CourseReminderScheduler.reschedule(AHUApplication.getApp()) } diff --git a/app/src/test/java/com/ahu/ahutong/data/crawler/CurrentSemesterScriptParserTest.kt b/app/src/test/java/com/ahu/ahutong/data/crawler/CurrentSemesterScriptParserTest.kt new file mode 100644 index 00000000..56abaa3b --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/data/crawler/CurrentSemesterScriptParserTest.kt @@ -0,0 +1,39 @@ +package com.ahu.ahutong.data.crawler + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class CurrentSemesterScriptParserTest { + + @Test + fun `extracts nested object literal without truncating it`() { + val script = """ + var currentSemester = {"id":42,"calendarAssoc":{"id":7},"name":"2025-2026-2"}; + var unrelated = true; + """.trimIndent() + + assertEquals( + """{"id":42,"calendarAssoc":{"id":7},"name":"2025-2026-2"}""", + extractCurrentSemesterJson(script) + ) + } + + @Test + fun `extracts and decodes JSON parse string`() { + val script = """ + const currentSemester = JSON.parse('{\"id\":42,\"name\":\"2025-2026-2\",\"label\":\"\\u5b66\\u671f\"}'); + """.trimIndent() + + assertEquals( + """{"id":42,"name":"2025-2026-2","label":"\u5b66\u671f"}""", + extractCurrentSemesterJson(script) + ) + } + + @Test + fun `rejects incomplete assignments`() { + assertNull(extractCurrentSemesterJson("var currentSemester = JSON.parse('{\\\"id\\\":42}'")) + assertNull(extractCurrentSemesterJson("var currentSemester = {\\\"id\\\":42")) + } +} diff --git a/app/src/test/java/com/ahu/ahutong/data/schedule/TeachingWeekPolicyTest.kt b/app/src/test/java/com/ahu/ahutong/data/schedule/TeachingWeekPolicyTest.kt new file mode 100644 index 00000000..36ccece1 --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/data/schedule/TeachingWeekPolicyTest.kt @@ -0,0 +1,92 @@ +package com.ahu.ahutong.data.schedule + +import java.time.LocalDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TeachingWeekPolicyTest { + + @Test + fun `in-semester response infers term start from current teaching week`() { + val position = TeachingWeekPolicy.resolveRemotePosition( + now = LocalDate.of(2026, 3, 18), + remoteWeekIndex = 3, + remoteIsInSemester = true, + cachedStartDate = null, + officialStartDate = null + ) + + assertTrue(position.isInSemester) + assertEquals(3, position.week) + assertEquals(LocalDate.of(2026, 3, 2), position.startDate) + } + + @Test + fun `holiday response uses official date and never becomes week one`() { + val position = TeachingWeekPolicy.resolveRemotePosition( + now = LocalDate.of(2026, 7, 21), + remoteWeekIndex = 0, + remoteIsInSemester = false, + cachedStartDate = LocalDate.of(2026, 7, 20), + officialStartDate = LocalDate.of(2026, 3, 2) + ) + + assertFalse(position.isInSemester) + assertEquals(TeachingWeekPolicy.SCHEDULE_WEEK_COUNT, position.week) + assertEquals(LocalDate.of(2026, 3, 2), position.startDate) + } + + @Test + fun `pre-semester holiday opens the first week with its real dates`() { + val position = TeachingWeekPolicy.resolveRemotePosition( + now = LocalDate.of(2026, 8, 15), + remoteWeekIndex = 0, + remoteIsInSemester = false, + cachedStartDate = null, + officialStartDate = LocalDate.of(2026, 9, 7) + ) + + assertFalse(position.isInSemester) + assertEquals(1, position.week) + assertEquals(LocalDate.of(2026, 9, 7), position.startDate) + } + + @Test + fun `stale holiday state expires when the semester begins`() { + assertTrue( + TeachingWeekPolicy.resolveLocalSemesterState( + startDate = LocalDate.of(2026, 9, 7), + date = LocalDate.of(2026, 9, 7), + cachedState = false, + stateObservedOn = LocalDate.of(2026, 9, 6) + ) + ) + } + + @Test + fun `stale in-semester state expires after the schedule range`() { + assertFalse( + TeachingWeekPolicy.resolveLocalSemesterState( + startDate = LocalDate.of(2026, 3, 2), + date = LocalDate.of(2026, 7, 20), + cachedState = true, + stateObservedOn = LocalDate.of(2026, 7, 19) + ) + ) + } + + @Test + fun `same-day remote holiday state remains authoritative`() { + val date = LocalDate.of(2026, 10, 1) + assertFalse( + TeachingWeekPolicy.resolveLocalSemesterState( + startDate = LocalDate.of(2026, 9, 7), + date = date, + cachedState = false, + stateObservedOn = date + ) + ) + } +}