fix(calendar): 修复日程刷新并优化假期计算
This commit is contained in:
1 parent
ffb2bba651
commit
cb758c134c
14 files changed
+102
-46
No files matched your search
@@ -150,10 +150,6 @@ extension Translations {
|
||||
允许访问 Allow access
|
||||
打开系统设置 Open System Settings
|
||||
读取失败,请重试 Could not load. Try again.
|
||||
节假日数据 Holiday data
|
||||
最新数据年份:{} Latest data year: {}
|
||||
假日数据随应用更新;未收录年份不推断调休。 Holiday data is updated with the app. Makeup workdays are not inferred for uncovered years.
|
||||
查看更新 View updates
|
||||
日程尚未授权 Calendar access not granted
|
||||
提醒事项尚未授权 Reminders access not granted
|
||||
无标题 Untitled
|
||||
@@ -169,5 +165,6 @@ extension Translations {
|
||||
请假日期:{} Leave dates: {}
|
||||
按双休及已公布的中国调休安排计算,实际请假以单位批准为准。 Based on a two-day weekend and published Chinese makeup workdays. Leave requires your employer’s approval.
|
||||
有日程或提醒 Has events or reminders
|
||||
续 cont.
|
||||
"""#
|
||||
}
|
||||
@@ -1584,10 +1584,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
|
||||
允许访问 السماح بالوصول
|
||||
打开系统设置 فتح إعدادات النظام
|
||||
读取失败,请重试 تعذر التحميل. أعد المحاولة.
|
||||
节假日数据 بيانات العطلات
|
||||
最新数据年份:{} أحدث سنة للبيانات: {}
|
||||
假日数据随应用更新;未收录年份不推断调休。 تُحدَّث بيانات العطلات مع التطبيق. لا تُخمَّن أيام العمل التعويضية للسنوات غير المشمولة.
|
||||
查看更新 عرض التحديثات
|
||||
日程尚未授权 لم يُمنح الوصول إلى التقويم
|
||||
提醒事项尚未授权 لم يُمنح الوصول إلى التذكيرات
|
||||
无标题 بلا عنوان
|
||||
@@ -1609,3 +1605,4 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
|
||||
例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 أمثلة: yyyy-MM-dd، M/d، dd/MM. السنة اختيارية.
|
||||
请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 أدرج M للشهر وd لليوم، واستخدم y للسنة. يُعرض التنسيق غير الصالح مؤقتًا كشهر/يوم.
|
||||
按住 ⌘ 拖动菜单栏图标可调整位置。 اضغط على ⌘ أثناء سحب أيقونة شريط القوائم لتغيير موضعها.
|
||||
续 متابعة
|
||||
|
@@ -1584,10 +1584,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
|
||||
允许访问 Zugriff erlauben
|
||||
打开系统设置 Systemeinstellungen öffnen
|
||||
读取失败,请重试 Laden fehlgeschlagen. Erneut versuchen.
|
||||
节假日数据 Feiertagsdaten
|
||||
最新数据年份:{} Aktuellstes Datenjahr: {}
|
||||
假日数据随应用更新;未收录年份不推断调休。 Feiertagsdaten werden mit der App aktualisiert. Ausgleichsarbeitstage werden für fehlende Jahre nicht geschätzt.
|
||||
查看更新 Updates ansehen
|
||||
日程尚未授权 Kalenderzugriff nicht erlaubt
|
||||
提醒事项尚未授权 Erinnerungszugriff nicht erlaubt
|
||||
无标题 Ohne Titel
|
||||
@@ -1609,3 +1605,4 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
|
||||
例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 Beispiele: yyyy-MM-dd, M/d, dd/MM. Das Jahr ist optional.
|
||||
请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 M für Monat und d für Tag angeben; y für das Jahr verwenden. Ungültige Formate erscheinen vorübergehend als Monat/Tag.
|
||||
按住 ⌘ 拖动菜单栏图标可调整位置。 Zum Verschieben das Menüleistensymbol bei gedrückter ⌘-Taste ziehen.
|
||||
续 Forts.
|
||||
|
@@ -1584,10 +1584,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
|
||||
允许访问 Permitir acceso
|
||||
打开系统设置 Abrir Ajustes del Sistema
|
||||
读取失败,请重试 No se pudo cargar. Inténtalo de nuevo.
|
||||
节假日数据 Datos de festivos
|
||||
最新数据年份:{} Último año de datos: {}
|
||||
假日数据随应用更新;未收录年份不推断调休。 Los datos se actualizan con la app. No se deducen días laborables compensatorios para años sin datos.
|
||||
查看更新 Ver actualizaciones
|
||||
日程尚未授权 Acceso al calendario no concedido
|
||||
提醒事项尚未授权 Acceso a recordatorios no concedido
|
||||
无标题 Sin título
|
||||
@@ -1609,3 +1605,4 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
|
||||
例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 Ejemplos: yyyy-MM-dd, M/d, dd/MM. El año es opcional.
|
||||
请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 Incluye M para el mes y d para el día; usa y para el año. Los formatos inválidos se muestran temporalmente como mes/día.
|
||||
按住 ⌘ 拖动菜单栏图标可调整位置。 Mantén pulsada ⌘ y arrastra el icono de la barra de menús para moverlo.
|
||||
续 cont.
|
||||
|
@@ -1584,10 +1584,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
|
||||
允许访问 Autoriser l’accès
|
||||
打开系统设置 Ouvrir Réglages Système
|
||||
读取失败,请重试 Échec du chargement. Réessayez.
|
||||
节假日数据 Données des jours fériés
|
||||
最新数据年份:{} Année des dernières données : {}
|
||||
假日数据随应用更新;未收录年份不推断调休。 Les données suivent les mises à jour de l’app. Aucun jour de travail compensatoire n’est déduit pour les années manquantes.
|
||||
查看更新 Voir les mises à jour
|
||||
日程尚未授权 Accès au calendrier non autorisé
|
||||
提醒事项尚未授权 Accès aux rappels non autorisé
|
||||
无标题 Sans titre
|
||||
@@ -1609,3 +1605,4 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
|
||||
例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 Exemples : yyyy-MM-dd, M/d, dd/MM. L’année est facultative.
|
||||
请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 Incluez M pour le mois et d pour le jour ; utilisez y pour l’année. Un format invalide affiche provisoirement mois/jour.
|
||||
按住 ⌘ 拖动菜单栏图标可调整位置。 Maintenez ⌘ en faisant glisser l’icône de la barre des menus pour la déplacer.
|
||||
续 suite
|
||||
|
@@ -1584,10 +1584,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
|
||||
允许访问 アクセスを許可
|
||||
打开系统设置 システム設定を開く
|
||||
读取失败,请重试 読み込めませんでした。再試行してください。
|
||||
节假日数据 祝日データ
|
||||
最新数据年份:{} 最新データの年:{}
|
||||
假日数据随应用更新;未收录年份不推断调休。 祝日データはアプリの更新で配信します。未収録の年の振替出勤日は推測しません。
|
||||
查看更新 更新を確認
|
||||
日程尚未授权 カレンダーへのアクセス未許可
|
||||
提醒事项尚未授权 リマインダーへのアクセス未許可
|
||||
无标题 名称未設定
|
||||
@@ -1609,3 +1605,4 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
|
||||
例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 例:yyyy-MM-dd、M月d日、dd/MM。年は省略できます。
|
||||
请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 月の M と日の d を含め、年には y を使ってください。無効な書式は一時的に月/日で表示します。
|
||||
按住 ⌘ 拖动菜单栏图标可调整位置。 ⌘ を押しながらメニューバーのアイコンをドラッグすると移動できます。
|
||||
续 続き
|
||||
|
@@ -1584,10 +1584,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
|
||||
允许访问 접근 허용
|
||||
打开系统设置 시스템 설정 열기
|
||||
读取失败,请重试 불러오지 못했습니다. 다시 시도하세요.
|
||||
节假日数据 공휴일 데이터
|
||||
最新数据年份:{} 최신 데이터 연도: {}
|
||||
假日数据随应用更新;未收录年份不推断调休。 공휴일 데이터는 앱과 함께 업데이트됩니다. 미수록 연도의 대체 근무일은 추정하지 않습니다.
|
||||
查看更新 업데이트 보기
|
||||
日程尚未授权 캘린더 접근이 허용되지 않음
|
||||
提醒事项尚未授权 미리 알림 접근이 허용되지 않음
|
||||
无标题 제목 없음
|
||||
@@ -1609,3 +1605,4 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
|
||||
例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 예: yyyy-MM-dd, M/d, dd/MM. 연도는 생략할 수 있습니다.
|
||||
请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 월 M과 일 d를 포함하고 연도에는 y를 사용하세요. 잘못된 형식은 임시로 월/일로 표시됩니다.
|
||||
按住 ⌘ 拖动菜单栏图标可调整位置。 ⌘ 키를 누른 채 메뉴 막대 아이콘을 드래그하여 위치를 조정하세요.
|
||||
续 계속
|
||||
|
@@ -1585,10 +1585,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
|
||||
允许访问 允許存取
|
||||
打开系统设置 開啟系統設定
|
||||
读取失败,请重试 讀取失敗,請重試
|
||||
节假日数据 節假日資料
|
||||
最新数据年份:{} 最新資料年份:{}
|
||||
假日数据随应用更新;未收录年份不推断调休。 假日資料隨應用程式更新;未收錄年份不推斷調休。
|
||||
查看更新 查看更新
|
||||
日程尚未授权 行程尚未授權
|
||||
提醒事项尚未授权 提醒事項尚未授權
|
||||
无标题 無標題
|
||||
@@ -1610,3 +1606,4 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
|
||||
例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
|
||||
请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 請包含月份 M 和日期 d;年份使用 y。格式無效時暫以月/日顯示。
|
||||
按住 ⌘ 拖动菜单栏图标可调整位置。 按住 ⌘ 拖移選單列圖示可調整位置。
|
||||
续 續
|
||||
|
@@ -20,6 +20,13 @@ struct CalendarAgendaItem: Identifiable, Sendable {
|
||||
let isAllDay: Bool
|
||||
let isReminder: Bool
|
||||
|
||||
/// 在这一天显示的时间:跨天日程从前一天延续过来时显示“续”,而不是前一天的开始时间
|
||||
func timeLabel(on date: Date, calendar: Calendar) -> String {
|
||||
if isAllDay { return tr("全天") }
|
||||
if let day = calendar.dateInterval(of: .day, for: date), start < day.start { return tr("续") }
|
||||
return start.formatted(.dateTime.hour().minute().locale(L10n.locale))
|
||||
}
|
||||
|
||||
/// 全天及跨天事件的结束时间是开区间,不能在次日多画一个日程标记。
|
||||
func occurs(on date: Date, calendar: Calendar) -> Bool {
|
||||
guard let day = calendar.dateInterval(of: .day, for: date) else { return false }
|
||||
@@ -40,18 +47,32 @@ struct CalendarAgendaQuery: Equatable, Sendable {
|
||||
|
||||
/// EventKit 对象留在工作 actor 内;界面只接收值类型,不在主线程同步查询日程。
|
||||
private actor CalendarAgendaReader {
|
||||
private lazy var store = EKEventStore()
|
||||
private var pending: [UUID: (Any, CheckedContinuation<[CalendarAgendaItem]?, Never>)] = [:]
|
||||
private var cachedStore: EKEventStore?
|
||||
private var storeAccess: (EKAuthorizationStatus, EKAuthorizationStatus)?
|
||||
private var pending: [UUID: (EKEventStore, Any, CheckedContinuation<[CalendarAgendaItem]?, Never>)] = [:]
|
||||
|
||||
/// 授权变化后换用新的 store:授权前创建的实例看不到之后才获准访问的日历或提醒事项。
|
||||
/// 每次使用前按当前授权判断,不依赖调用方的通知顺序。
|
||||
private var store: EKEventStore {
|
||||
let access = (EKEventStore.authorizationStatus(for: .event), EKEventStore.authorizationStatus(for: .reminder))
|
||||
if let cachedStore, let storeAccess, storeAccess == access { return cachedStore }
|
||||
let fresh = EKEventStore()
|
||||
cachedStore = fresh
|
||||
storeAccess = access
|
||||
return fresh
|
||||
}
|
||||
|
||||
func sources(reminders: Bool) -> [CalendarAgendaSource] {
|
||||
let type: EKEntityType = reminders ? .reminder : .event
|
||||
guard EKEventStore.authorizationStatus(for: type) == .fullAccess else { return [] }
|
||||
let store = self.store
|
||||
return store.calendars(for: type).map { .init(id: $0.calendarIdentifier, title: $0.title) }
|
||||
.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
|
||||
}
|
||||
|
||||
func load(_ query: CalendarAgendaQuery) async -> [CalendarAgendaItem]? {
|
||||
guard !Task.isCancelled else { return nil }
|
||||
let store = self.store
|
||||
var result: [CalendarAgendaItem] = []
|
||||
if query.events && EKEventStore.authorizationStatus(for: .event) == .fullAccess {
|
||||
let calendars = store.calendars(for: .event).filter { query.eventIDs?.contains($0.calendarIdentifier) ?? true }
|
||||
@@ -68,7 +89,7 @@ private actor CalendarAgendaReader {
|
||||
if query.reminders && EKEventStore.authorizationStatus(for: .reminder) == .fullAccess {
|
||||
let calendars = store.calendars(for: .reminder).filter { query.reminderIDs?.contains($0.calendarIdentifier) ?? true }
|
||||
if !calendars.isEmpty {
|
||||
guard let reminders = await reminders(query, calendars: calendars) else { return nil }
|
||||
guard let reminders = await reminders(query, calendars: calendars, store: store) else { return nil }
|
||||
result += reminders
|
||||
}
|
||||
}
|
||||
@@ -78,7 +99,7 @@ private actor CalendarAgendaReader {
|
||||
}
|
||||
}
|
||||
|
||||
private func reminders(_ query: CalendarAgendaQuery, calendars: [EKCalendar]) async -> [CalendarAgendaItem]? {
|
||||
private func reminders(_ query: CalendarAgendaQuery, calendars: [EKCalendar], store: EKEventStore) async -> [CalendarAgendaItem]? {
|
||||
let id = UUID()
|
||||
let predicate = store.predicateForIncompleteReminders(withDueDateStarting: query.start, ending: query.end, calendars: calendars)
|
||||
return await withTaskCancellationHandler {
|
||||
@@ -100,7 +121,7 @@ private actor CalendarAgendaReader {
|
||||
let reader = self
|
||||
Task { await reader?.finish(id, items: items) }
|
||||
}
|
||||
pending[id] = (token, continuation)
|
||||
pending[id] = (store, token, continuation)
|
||||
}
|
||||
} onCancel: {
|
||||
Task { await self.cancel(id) }
|
||||
@@ -108,11 +129,11 @@ private actor CalendarAgendaReader {
|
||||
}
|
||||
|
||||
private func finish(_ id: UUID, items: [CalendarAgendaItem]?) {
|
||||
pending.removeValue(forKey: id)?.1.resume(returning: items)
|
||||
pending.removeValue(forKey: id)?.2.resume(returning: items)
|
||||
}
|
||||
|
||||
private func cancel(_ id: UUID) {
|
||||
guard let (token, continuation) = pending.removeValue(forKey: id) else { return }
|
||||
guard let (store, token, continuation) = pending.removeValue(forKey: id) else { return }
|
||||
store.cancelFetchRequest(token)
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
@@ -133,9 +154,19 @@ final class CalendarAgendaController {
|
||||
@ObservationIgnored private let reader = CalendarAgendaReader()
|
||||
@ObservationIgnored private var generation = UUID()
|
||||
|
||||
/// 打开面板、应用激活时调用;授权确实变化才重新查询,避免每次打开都取消刚开始的查询再重来
|
||||
func refreshAuthorization() {
|
||||
eventsAccess = EKEventStore.authorizationStatus(for: .event)
|
||||
remindersAccess = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let events = EKEventStore.authorizationStatus(for: .event)
|
||||
let reminders = EKEventStore.authorizationStatus(for: .reminder)
|
||||
guard events != eventsAccess || reminders != remindersAccess else { return }
|
||||
eventsAccess = events
|
||||
remindersAccess = reminders
|
||||
revision += 1
|
||||
}
|
||||
|
||||
/// 日历或提醒事项内容变化(EKEventStoreChanged)时重新查询
|
||||
func storeChanged() {
|
||||
refreshAuthorization()
|
||||
revision += 1
|
||||
}
|
||||
|
||||
@@ -164,9 +195,9 @@ final class CalendarAgendaController {
|
||||
func load(_ query: CalendarAgendaQuery) async {
|
||||
let current = UUID()
|
||||
generation = current
|
||||
items = []
|
||||
failed = false
|
||||
guard query.events || query.reminders else { isLoading = false; return }
|
||||
// 新结果返回前保留旧列表,刷新时日程和日期圆点不会先消失再出现;按日期过滤,跨月也不会显示错位
|
||||
guard query.events || query.reminders else { items = []; isLoading = false; return }
|
||||
isLoading = true
|
||||
let result = await reader.load(query)
|
||||
guard generation == current, !Task.isCancelled else { return }
|
||||
|
||||
@@ -42,7 +42,7 @@ struct CalendarAgendaView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(item.source).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Text(item.isAllDay ? tr("全天") : item.start.formatted(.dateTime.hour().minute().locale(L10n.locale)))
|
||||
Text(item.timeLabel(on: day.date, calendar: CalendarEngine.gregorian()))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,6 @@ enum CalendarEngine {
|
||||
|
||||
/// 年份覆盖来自固定版本的数据本身;未收录年份不能把 nil 解读为“无需调休”。
|
||||
static func hasHolidayData(year: Int) -> Bool { holidayYears.contains(year) }
|
||||
static var latestHolidayYear: Int? { holidayYears.max() }
|
||||
private static let holidayYears: Set<Int> = {
|
||||
let data = Array(LegalHoliday.DATA.utf8)
|
||||
return Set(stride(from: 0, to: data.count, by: 13).compactMap { index in
|
||||
|
||||
@@ -66,9 +66,20 @@ extension CalendarHolidayPlan {
|
||||
}
|
||||
|
||||
extension CalendarEngine {
|
||||
/// 同一天、同一时区的结果不变;面板每次测量都会新建视图,缓存避免在主线程上反复逐日查询约 390 天
|
||||
private static var holidayPlanCache: (key: String, plan: CalendarHolidayPlan?)?
|
||||
|
||||
static func holidayPlan(from date: Date, timeZone: TimeZone = .autoupdatingCurrent) -> CalendarHolidayPlan? {
|
||||
let calendar = gregorian(timeZone: timeZone)
|
||||
let today = calendar.startOfDay(for: date)
|
||||
let key = "\(timeZone.identifier)|\(today.timeIntervalSince1970)"
|
||||
if let cached = holidayPlanCache, cached.key == key { return cached.plan }
|
||||
let plan = computeHolidayPlan(today: today, calendar: calendar)
|
||||
holidayPlanCache = (key, plan)
|
||||
return plan
|
||||
}
|
||||
|
||||
private static func computeHolidayPlan(today: Date, calendar: Calendar) -> CalendarHolidayPlan? {
|
||||
var days: [CalendarWorkday] = []
|
||||
for offset in -21...370 {
|
||||
guard let date = calendar.date(byAdding: .day, value: offset, to: today) else { continue }
|
||||
|
||||
@@ -126,7 +126,7 @@ struct CalendarPopover: View {
|
||||
}
|
||||
.onDisappear { if !isSnapshot { model.calendarAgenda.clear() } }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .EKEventStoreChanged)) { _ in
|
||||
if !isSnapshot { model.calendarAgenda.refreshAuthorization() }
|
||||
if !isSnapshot { model.calendarAgenda.storeChanged() }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||
if !isSnapshot { model.calendarAgenda.refreshAuthorization() }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Foundation
|
||||
import Localization
|
||||
import Testing
|
||||
@testable import XStatsUI
|
||||
|
||||
@@ -115,6 +116,44 @@ struct CalendarEnhancementTests {
|
||||
#expect(reminder.occurs(on: next, calendar: calendar))
|
||||
}
|
||||
|
||||
@Test func overnightEventShowsContinuationInsteadOfYesterdaysStartTime() throws {
|
||||
let calendar = CalendarEngine.gregorian(timeZone: zone)
|
||||
let start = try #require(calendar.date(from: DateComponents(year: 2026, month: 9, day: 28, hour: 22)))
|
||||
let nextDay = try #require(calendar.date(from: DateComponents(year: 2026, month: 9, day: 29)))
|
||||
let night = CalendarAgendaItem(id: "night", title: "", source: "", start: start,
|
||||
end: start.addingTimeInterval(4 * 3600), isAllDay: false, isReminder: false)
|
||||
let first = night.timeLabel(on: start, calendar: calendar)
|
||||
#expect(first != tr("续") && first != tr("全天"), "start day shows the start time: \(first)")
|
||||
#expect(night.timeLabel(on: nextDay, calendar: calendar) == tr("续"))
|
||||
let allDay = CalendarAgendaItem(id: "all", title: "", source: "", start: nextDay,
|
||||
end: nextDay.addingTimeInterval(86_400), isAllDay: true, isReminder: false)
|
||||
#expect(allDay.timeLabel(on: nextDay, calendar: calendar) == tr("全天"))
|
||||
}
|
||||
|
||||
@Test func refreshingUnchangedAuthorizationDoesNotRestartTheQuery() {
|
||||
let agenda = CalendarAgendaController()
|
||||
let before = agenda.revision
|
||||
// 打开面板、应用激活都会调用;授权没变时不能改动查询的 id,否则刚开始的查询会被取消重来
|
||||
agenda.refreshAuthorization()
|
||||
agenda.refreshAuthorization()
|
||||
#expect(agenda.revision == before)
|
||||
agenda.storeChanged()
|
||||
#expect(agenda.revision == before + 1, "calendar content changes must trigger a reload")
|
||||
}
|
||||
|
||||
@Test func holidayPlanCacheStillFollowsDayAndTimeZone() throws {
|
||||
let before = try #require(CalendarEngine.day(year: 2026, month: 9, day: 23, timeZone: zone))
|
||||
let first = try #require(CalendarEngine.holidayPlan(from: before.date, timeZone: zone))
|
||||
let repeated = try #require(CalendarEngine.holidayPlan(from: before.date.addingTimeInterval(3600), timeZone: zone))
|
||||
#expect(repeated.daysUntil == first.daysUntil && repeated.start == first.start)
|
||||
// 换日或换时区都不能复用上一次的结果
|
||||
let later = try #require(CalendarEngine.day(year: 2026, month: 9, day: 24, timeZone: zone))
|
||||
#expect(try #require(CalendarEngine.holidayPlan(from: later.date, timeZone: zone)).daysUntil == first.daysUntil - 1)
|
||||
let utc = TimeZone(secondsFromGMT: 0)!
|
||||
let utcPlan = try #require(CalendarEngine.holidayPlan(from: before.date, timeZone: utc))
|
||||
#expect(utcPlan.start != first.start, "a different time zone must be recomputed")
|
||||
}
|
||||
|
||||
@Test func eventDayMatchingRespectsDSTInsteadOfFixed24Hours() throws {
|
||||
let zone = try #require(TimeZone(identifier: "America/Los_Angeles"))
|
||||
let calendar = CalendarEngine.gregorian(timeZone: zone)
|
||||
|
||||
Reference in new issue
Block a user