fix(calendar): 保留农历选择并完善周起始日设置

This commit is contained in:
ysicing authored and 缘生 committed 2026-09-30 23:50:16 +08:00
1 parent 678303187d
commit 222b2baa89
19 files changed
+116 -59

No files matched your search

+4 -1
View File
@@ -180,7 +180,10 @@ not 24-hour intervals. Tibetan conversion is guarded to the upstream Gregorian c
Plum-rain dates describe traditional calendar rules, not observed or forecast weather. Plum-rain dates describe traditional calendar rules, not observed or forecast weather.
Calendar enablement, visible features and week start persist through `AppSettings` and the Calendar enablement, visible features and week start persist through `AppSettings` and the
optional fields in `SettingsDocument`. Older backups preserve those settings. Additional optional fields in `SettingsDocument`. Week start initially uses `Calendar.current.firstWeekday`,
then persists independently of later system changes; all seven weekdays are supported by the
settings picker, backups, month grids and widgets. Menu-bar lunar modes remain available in
every app language and use localized lunar dates. Older backups preserve those settings. Additional
calendars default off and appear only in selected-day details. There is no event access or calendars default off and appear only in selected-day details. There is no event access or
network request at runtime. Use `--snapshot <directory> --calendar-only` for deterministic network request at runtime. Use `--snapshot <directory> --calendar-only` for deterministic
light/dark calendar screenshots without starting unrelated samplers or scans. light/dark calendar screenshots without starting unrelated samplers or scans.
+2 -2
View File
@@ -54,8 +54,8 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布
构建固定使用 `arm64`,仅支持 Apple Silicon Mac。 构建固定使用 `arm64`,仅支持 Apple Silicon Mac。
有 Developer ID Application 证书时,Taskfile 会自动使用钥匙串中第一个证书的唯一指纹;否则由构建脚本逐层补齐 ad-hoc 签名与 entitlement。临时签名没有团队身份,不能加载启用团队验证的 Sparkle 动态库,因此仅此本地/CI 路径关闭 Hardened Runtime;正式 Developer ID 构建仍强制启用,且临时签名构建不能在线安装或操作特权辅助工具。 有 Developer ID Application 证书时,Taskfile 会自动使用钥匙串中第一个证书的唯一指纹;否则由构建脚本逐层补齐 ad-hoc 签名与 entitlement。临时签名没有团队身份,不能加载启用团队验证的 Sparkle 动态库,因此仅此本地/CI 路径关闭 Hardened Runtime;正式 Developer ID 构建仍强制启用,且临时签名构建不能在线安装或操作特权辅助工具。
桌面小组件与主应用通过 `group.work.12306.xstats` App Group 共享只读展示摘要。 桌面小组件与主应用通过 `group.work.12306.xstats` App Group 共享只读展示摘要。
菜单栏日历提供“日期与星期”“自定义”显示方式;应用语言为简体中文或繁体中文(含自动检测为中文)时,额外提供“日期与农历”“农历”。切到非中文时,已保存的农历方式暂以“日期与星期”呈现,切回中文恢复原选择,不改写备份。自定义可自由组合 y/M/d 字段与分隔符,年份可省略,输入时显示预览并提示无效格式,独立于月历的星期开关;支持农历字号与对比度、可选悬停展开,按分钟刷新,休眠或关闭功能后停止。日程和到期提醒默认关闭,通过明确的授权按钮请求 EventKit 权限,只在日历面板可见时查询当前 42 格日期范围;只读展示,不修改日程,也不向 Widget、设置备份或服务端传递内容。列表 ID 仅保存在本机,显示偏好参与设置备份。节假日覆盖年份来自内置 Tyme 数据并随应用更新;假期倒计时与最多 3 天的请假建议只基于已公布的中国调休与双休安排,跨到未知年份时停止推算。 菜单栏日历在所有应用语言下均提供“日期与星期”“日期与农历”“农历”“自定义”显示方式;日期与农历按应用语言呈现,切换语言保留用户选择。自定义可自由组合 y/M/d 字段与分隔符,年份可省略,输入时显示预览并提示无效格式,独立于月历的星期开关;支持农历字号与对比度、可选悬停展开,按分钟刷新,休眠或关闭功能后停止。日程和到期提醒默认关闭,通过明确的授权按钮请求 EventKit 权限,只在日历面板可见时查询当前 42 格日期范围;只读展示,不修改日程,也不向 Widget、设置备份或服务端传递内容。列表 ID 仅保存在本机,显示偏好参与设置备份。节假日覆盖年份来自内置 Tyme 数据并随应用更新;假期倒计时与最多 3 天的请假建议只基于已公布的中国调休与双休安排,跨到未知年份时停止推算。
日历首次初始化按 macOS 地区设置决定默认值,不按设备销售地或应用语言判断:中国大陆(CN)默认启用农历、星期、大陆节假日与调休、传统与公历节日、节气、黄历及假期概览;其他地区(含台湾、香港、澳门)默认只启用星期。干支、三伏/梅雨/数九、藏历与回历各地区均默认关闭。用户可自行启用或关闭,首次默认值保存后不随语言、地区变化重置;已有本机偏好及旧备份保持原选择。大陆节假日数据不能用于推断其他地区的法定安排。单日及整月小组件遵循日历选项,未启用大陆节假日时“明天上班吗”显示无法判断;黄历关闭后不再计算宜忌摘要。 日历首次初始化按 macOS 地区设置决定默认值,不按设备销售地或应用语言判断:中国大陆(CN)默认启用农历、星期、大陆节假日与调休、传统与公历节日、节气、黄历及假期概览;其他地区(含台湾、香港、澳门)默认只启用星期。干支、三伏/梅雨/数九、藏历与回历各地区均默认关闭。用户可自行启用或关闭,首次默认值保存后不随语言、地区变化重置;已有本机偏好及旧备份保持原选择。每周起始日首次采用 macOS 系统日历设置并保存,支持星期日到星期六,手动选择及备份恢复优先;后续系统设置变化不覆盖已保存的值,主应用月历与小组件使用同一周起始日。大陆节假日数据不能用于推断其他地区的法定安排。单日及整月小组件遵循日历选项,未启用大陆节假日时“明天上班吗”提示“仅支持中国大陆节假日”;黄历关闭后不再计算宜忌摘要。
月历使用固定六行布局,日期 ID 在生成日期时计算一次。面板打开时测量初始高度,后续翻月、详情与日程内容变化在内部滚动,不从内容几何回调反向调整窗口,避免窗口高度与懒布局估算形成反馈循环。 月历使用固定六行布局,日期 ID 在生成日期时计算一次。面板打开时测量初始高度,后续翻月、详情与日程内容变化在内部滚动,不从内容几何回调反向调整窗口,避免窗口高度与懒布局估算形成反馈循环。
月历计算仅缓存最近一个月份(包含周起始日和时区),供实际面板、高度测量和重复打开共用;翻月替换缓存,避免浏览历史导致内存累积。日程日期标记在工作 actor 内随查询结果生成,界面不再逐格扫描日程列表;连续 EventKit 通知按 300 毫秒窗口合并,关闭面板时取消待执行刷新。 月历计算仅缓存最近一个月份(包含周起始日和时区),供实际面板、高度测量和重复打开共用;翻月替换缓存,避免浏览历史导致内存累积。日程日期标记在工作 actor 内随查询结果生成,界面不再逐格扫描日程列表;连续 EventKit 通知按 300 毫秒窗口合并,关闭面板时取消待执行刷新。
用于正式签名的 Apple Developer 团队须为主应用和 Widget 扩展启用同一个 App Group; 用于正式签名的 Apple Developer 团队须为主应用和 Widget 扩展启用同一个 App Group;
@@ -54,7 +54,7 @@ extension Translations {
上班 Workday 上班 Workday
不上班 Day off 不上班 Day off
暂无安排 No schedule data 暂无安排 No schedule data
暂无法判断 Cannot determine yet 仅支持中国大陆节假日 Only Mainland China holidays are supported
工作日 Weekday 工作日 Weekday
周末 Weekend 周末 Weekend
节假日 Public holiday 节假日 Public holiday
@@ -1439,7 +1439,7 @@ Sub2API 账号平台与所选来源不匹配 منصة حساب Sub2API لا تط
上班 يوم عمل 上班 يوم عمل
不上班 يوم عطلة 不上班 يوم عطلة
暂无安排 لا توجد بيانات 暂无安排 لا توجد بيانات
暂无法判断 لا يمكن التحديد حاليًا 仅支持中国大陆节假日 يدعم العطلات الرسمية في البر الرئيسي للصين فقط
工作日 يوم عمل 工作日 يوم عمل
周末 عطلة نهاية الأسبوع 周末 عطلة نهاية الأسبوع
节假日 عطلة رسمية 节假日 عطلة رسمية
1 # Copyright (C) 2026 ysicing
1439 上班
1440 不上班
1441 暂无安排
1442 暂无法判断 仅支持中国大陆节假日
1443 工作日
1444 周末
1445 节假日
@@ -1439,7 +1439,7 @@ Sub2API 账号平台与所选来源不匹配 Die Plattform des Sub2API-Kontos st
上班 Arbeitstag 上班 Arbeitstag
不上班 Frei 不上班 Frei
暂无安排 Keine Daten 暂无安排 Keine Daten
暂无法判断 Derzeit nicht bestimmbar 仅支持中国大陆节假日 Nur Feiertage in Festlandchina werden unterstützt
工作日 Werktag 工作日 Werktag
周末 Wochenende 周末 Wochenende
节假日 Feiertag 节假日 Feiertag
1 # Copyright (C) 2026 ysicing
1439 上班
1440 不上班
1441 暂无安排
1442 暂无法判断 仅支持中国大陆节假日
1443 工作日
1444 周末
1445 节假日
@@ -1439,7 +1439,7 @@ Sub2API 账号平台与所选来源不匹配 La plataforma de la cuenta de Sub2A
上班 Día laborable 上班 Día laborable
不上班 Día libre 不上班 Día libre
暂无安排 Sin datos 暂无安排 Sin datos
暂无法判断 Aún no se puede determinar 仅支持中国大陆节假日 Solo se admiten los festivos de China continental
工作日 Día laborable 工作日 Día laborable
周末 Fin de semana 周末 Fin de semana
节假日 Festivo 节假日 Festivo
1 # Copyright (C) 2026 ysicing
1439 上班
1440 不上班
1441 暂无安排
1442 暂无法判断 仅支持中国大陆节假日
1443 工作日
1444 周末
1445 节假日
@@ -1439,7 +1439,7 @@ Sub2API 账号平台与所选来源不匹配 La plateforme du compte Sub2API ne
上班 Jour travaillé 上班 Jour travaillé
不上班 Repos 不上班 Repos
暂无安排 Aucune donnée 暂无安排 Aucune donnée
暂无法判断 Impossible à déterminer pour le moment 仅支持中国大陆节假日 Seuls les jours fériés de Chine continentale sont pris en charge
工作日 Jour ouvré 工作日 Jour ouvré
周末 Week-end 周末 Week-end
节假日 Jour férié 节假日 Jour férié
1 # Copyright (C) 2026 ysicing
1439 上班
1440 不上班
1441 暂无安排
1442 暂无法判断 仅支持中国大陆节假日
1443 工作日
1444 周末
1445 节假日
@@ -1439,7 +1439,7 @@ Sub2API 账号平台与所选来源不匹配 Sub2API アカウントのプラッ
上班 勤務日 上班 勤務日
不上班 休み 不上班 休み
暂无安排 予定データなし 暂无安排 予定データなし
暂无法判断 判定できません 仅支持中国大陆节假日 中国本土の祝日のみ対応
工作日 平日 工作日 平日
周末 週末 周末 週末
节假日 祝日 节假日 祝日
1 # Copyright (C) 2026 ysicing
1439 上班
1440 不上班
1441 暂无安排
1442 暂无法判断 仅支持中国大陆节假日
1443 工作日
1444 周末
1445 节假日
@@ -1439,7 +1439,7 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 계정 플랫폼이 선택
上班 근무 上班 근무
不上班 쉬는 날 不上班 쉬는 날
暂无安排 일정 데이터 없음 暂无安排 일정 데이터 없음
暂无法判断 아직 판단할 수 없음 仅支持中国大陆节假日 중국 본토 공휴일만 지원
工作日 평일 工作日 평일
周末 주말 周末 주말
节假日 공휴일 节假日 공휴일
1 # Copyright (C) 2026 ysicing
1439 上班
1440 不上班
1441 暂无安排
1442 暂无法判断 仅支持中国大陆节假日
1443 工作日
1444 周末
1445 节假日
@@ -1440,7 +1440,7 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 帳號平台與所選來源
上班 上班 上班 上班
不上班 不上班 不上班 不上班
暂无安排 暫無安排 暂无安排 暫無安排
暂无法判断 暫時無法判斷 仅支持中国大陆节假日 僅支援中國大陸節假日
工作日 工作日 工作日 工作日
周末 週末 周末 週末
节假日 節假日 节假日 節假日
1 # Copyright (c) 2026 GiantAccel, LLC
1440 上班
1441 不上班
1442 暂无安排
1443 暂无法判断 仅支持中国大陆节假日
1444 工作日
1445 周末
1446 节假日
@@ -178,7 +178,7 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
localUsageEnabled: Bool? = nil, localUsageEnabled: Bool? = nil,
publicIPEnabled: Bool = false, publicIPEnabled: Bool = false,
addresses: [Address] = [], language: String = "system", addresses: [Address] = [], language: String = "system",
calendarFirstWeekday: Int = 2, showsLunar: Bool = Locale.current.region?.identifier == "CN", calendarFirstWeekday: Int = Calendar.current.firstWeekday, showsLunar: Bool = Locale.current.region?.identifier == "CN",
calendarDays: [CalendarSummary]? = nil, monthSummaries: [MonthSummary]? = nil, calendarDays: [CalendarSummary]? = nil, monthSummaries: [MonthSummary]? = nil,
showsSeasonal: Bool? = nil, calendarDataVersion: String? = nil, showsAlmanac: Bool? = nil) { showsSeasonal: Bool? = nil, calendarDataVersion: String? = nil, showsAlmanac: Bool? = nil) {
self.aiEnabled = aiEnabled self.aiEnabled = aiEnabled
@@ -147,7 +147,7 @@ enum CalendarEngine {
static func month(year: Int, month: Int, firstWeekday: Int, static func month(year: Int, month: Int, firstWeekday: Int,
timeZone: TimeZone = .autoupdatingCurrent) -> [CalendarDay] { timeZone: TimeZone = .autoupdatingCurrent) -> [CalendarDay] {
let requested = CalendarMonth(year: year, month: month) let requested = CalendarMonth(year: year, month: month)
let weekStart = firstWeekday == 1 ? 1 : 2 let weekStart = (1...7).contains(firstWeekday) ? firstWeekday : Calendar.current.firstWeekday
// 固定自动更新时区的当前值,避免缓存里的时区也跟着系统变化,误判为可复用。 // 固定自动更新时区的当前值,避免缓存里的时区也跟着系统变化,误判为可复用。
let zone = TimeZone(identifier: timeZone.identifier) ?? timeZone let zone = TimeZone(identifier: timeZone.identifier) ?? timeZone
if let cached = monthCache, cached.month == requested, if let cached = monthCache, cached.month == requested,
@@ -10,17 +10,6 @@ public struct CalendarPreferences: Codable, Equatable, Sendable {
case standard, dateLunar, lunar, custom case standard, dateLunar, lunar, custom
public var id: String { rawValue } public var id: String { rawValue }
static func available(for locale: Locale) -> [Self] {
locale.language.languageCode?.identifier == "zh" ? allCases : [.standard, .custom]
}
/// 切换语言只改变呈现,不覆盖已保存的农历选择,切回中文后可恢复。
func effective(for locale: Locale) -> Self {
if (self == .dateLunar || self == .lunar), locale.language.languageCode?.identifier != "zh" {
return .standard
}
return self
}
var title: String { var title: String {
switch self { switch self {
case .standard: tr("日期与星期") case .standard: tr("日期与星期")
@@ -92,7 +81,7 @@ public struct CalendarPreferences: Codable, Equatable, Sendable {
calendar.timeZone = timeZone calendar.timeZone = timeZone
formatter.calendar = calendar formatter.calendar = calendar
formatter.timeZone = timeZone formatter.timeZone = timeZone
switch display.effective(for: locale) { switch display {
case .standard: formatter.setLocalizedDateFormatFromTemplate("MdEEE") case .standard: formatter.setLocalizedDateFormatFromTemplate("MdEEE")
case .custom: case .custom:
// 用户明确选择顺序,不能再用本地化模板把日月重新排回月日。 // 用户明确选择顺序,不能再用本地化模板把日月重新排回月日。
@@ -36,15 +36,19 @@ struct CalendarOptionsPopover: View {
var body: some View { var body: some View {
@Bindable var settings = model.settings @Bindable var settings = model.settings
PageScroll { var calendar = Calendar(identifier: .gregorian)
calendar.locale = L10n.locale
let weekdays = calendar.weekdaySymbols
return PageScroll {
VStack(spacing: DS.Space.s4) { VStack(spacing: DS.Space.s4) {
presentationOptions presentationOptions
SettingsGroup(caption: tr("日历")) { SettingsGroup(caption: tr("日历")) {
GroupRow(showsDivider: false) { GroupRow(showsDivider: false) {
SettingRow(title: tr("每周开始于")) { SettingRow(title: tr("每周开始于")) {
Picker(tr("每周开始于"), selection: $settings.calendarFirstWeekday) { Picker(tr("每周开始于"), selection: $settings.calendarFirstWeekday) {
Text(tr("星期一")).tag(2) ForEach(1...7, id: \.self) { weekday in
Text(tr("星期日")).tag(1) Text(weekdays[weekday - 1]).tag(weekday)
}
} }
.labelsHidden() .labelsHidden()
.fixedSize() .fixedSize()
@@ -113,11 +117,8 @@ struct CalendarOptionsPopover: View {
return SettingsGroup(caption: tr("菜单栏")) { return SettingsGroup(caption: tr("菜单栏")) {
GroupRow(showsDivider: false) { GroupRow(showsDivider: false) {
SettingRow(title: tr("显示方式")) { SettingRow(title: tr("显示方式")) {
Picker(tr("显示方式"), selection: Binding( Picker(tr("显示方式"), selection: $settings.calendarPreferences.display) {
get: { settings.calendarPreferences.display.effective(for: L10n.locale) }, ForEach(CalendarPreferences.Display.allCases) { Text($0.title).tag($0) }
set: { settings.calendarPreferences.display = $0 }
)) {
ForEach(CalendarPreferences.Display.available(for: L10n.locale)) { Text($0.title).tag($0) }
}.labelsHidden().fixedSize() }.labelsHidden().fixedSize()
} }
} }
@@ -733,7 +733,7 @@ public final class AppSettings {
public static let alertLoadOptions = [70, 80, 90] public static let alertLoadOptions = [70, 80, 90]
public static let cpuChartOptions = [60, 180, 300] public static let cpuChartOptions = [60, 180, 300]
public init(defaults: UserDefaults = .standard, calendarLocale: Locale = .current) { public init(defaults: UserDefaults = .standard, calendarLocale: Locale = .current, calendar: Calendar = .current) {
self.defaults = defaults self.defaults = defaults
let isRestEnabled = defaults.bool(forKey: Keys.restEnabled) let isRestEnabled = defaults.bool(forKey: Keys.restEnabled)
restEnabled = isRestEnabled restEnabled = isRestEnabled
@@ -758,7 +758,9 @@ public final class AppSettings {
let savedCalendarFeatures = defaults.stringArray(forKey: Keys.calendarFeatures) let savedCalendarFeatures = defaults.stringArray(forKey: Keys.calendarFeatures)
calendarFeatures = savedCalendarFeatures calendarFeatures = savedCalendarFeatures
.map(CalendarFeature.restored(from:)) ?? CalendarFeature.defaults(for: calendarLocale) .map(CalendarFeature.restored(from:)) ?? CalendarFeature.defaults(for: calendarLocale)
calendarFirstWeekday = defaults.integer(forKey: Keys.calendarFirstWeekday) == 1 ? 1 : 2 let savedCalendarFirstWeekday = (defaults.object(forKey: Keys.calendarFirstWeekday) as? Int)
.flatMap { (1...7).contains($0) ? $0 : nil }
calendarFirstWeekday = savedCalendarFirstWeekday ?? calendar.firstWeekday
let items = defaults.stringArray(forKey: Keys.menuBarItems)?.compactMap(MenuBarItem.init(rawValue:)) let items = defaults.stringArray(forKey: Keys.menuBarItems)?.compactMap(MenuBarItem.init(rawValue:))
menuBarItems = Set(items ?? [.cpu, .memory, .network]) menuBarItems = Set(items ?? [.cpu, .memory, .network])
menuBarStyle = defaults.string(forKey: Keys.menuBarStyle).flatMap(MenuBarStyle.init(rawValue:)) ?? .stacked menuBarStyle = defaults.string(forKey: Keys.menuBarStyle).flatMap(MenuBarStyle.init(rawValue:)) ?? .stacked
@@ -837,6 +839,10 @@ public final class AppSettings {
if savedCalendarFeatures == nil { if savedCalendarFeatures == nil {
defaults.set(calendarFeatures.map(\.rawValue).sorted(), forKey: Keys.calendarFeatures) defaults.set(calendarFeatures.map(\.rawValue).sorted(), forKey: Keys.calendarFeatures)
} }
// 首次采用系统的周起始日并保存,之后系统设置变化不覆盖用户选择。
if savedCalendarFirstWeekday == nil {
defaults.set(calendarFirstWeekday, forKey: Keys.calendarFirstWeekday)
}
} }
/// 按固定顺序返回当前实际显示的菜单栏项目;可选模块关闭时保留展示偏好但不渲染。 /// 按固定顺序返回当前实际显示的菜单栏项目;可选模块关闭时保留展示偏好但不渲染。
@@ -144,7 +144,7 @@ extension AppSettings {
assign(\.restSound, doc.restSound.flatMap(RestSound.init(rawValue:))) assign(\.restSound, doc.restSound.flatMap(RestSound.init(rawValue:)))
assign(\.restHUDStyle, doc.restHUDStyle.flatMap(RestHUDStyle.init(rawValue:))) assign(\.restHUDStyle, doc.restHUDStyle.flatMap(RestHUDStyle.init(rawValue:)))
assign(\.calendarFeatures, doc.calendarFeatures.map(CalendarFeature.restored(from:))) assign(\.calendarFeatures, doc.calendarFeatures.map(CalendarFeature.restored(from:)))
assign(\.calendarFirstWeekday, option(doc.calendarFirstWeekday, in: [1, 2])) assign(\.calendarFirstWeekday, option(doc.calendarFirstWeekday, in: Array(1...7)))
assign(\.menuBarItems, doc.menuBarItems.map { Set($0.compactMap(MenuBarItem.init(rawValue:))) }) assign(\.menuBarItems, doc.menuBarItems.map { Set($0.compactMap(MenuBarItem.init(rawValue:))) })
assign(\.menuBarStyle, doc.menuBarStyle.flatMap(MenuBarStyle.init(rawValue:))) assign(\.menuBarStyle, doc.menuBarStyle.flatMap(MenuBarStyle.init(rawValue:)))
assign(\.networkStyle, doc.networkStyle.flatMap(NetworkMenuStyle.init(rawValue:))) assign(\.networkStyle, doc.networkStyle.flatMap(NetworkMenuStyle.init(rawValue:)))
@@ -31,6 +31,15 @@ import WidgetData
#expect(disabled.visibleAddresses.isEmpty) #expect(disabled.visibleAddresses.isEmpty)
} }
@Test(arguments: Array(1...7))
func snapshotPreservesEveryWeekStartAndReloadsCalendars(firstWeekday: Int) throws {
let previous = WidgetSnapshot(calendarFirstWeekday: firstWeekday % 7 + 1)
let snapshot = WidgetSnapshot(calendarFirstWeekday: firstWeekday)
let restored = try JSONDecoder().decode(WidgetSnapshot.self, from: JSONEncoder().encode(snapshot))
#expect(restored.calendarFirstWeekday == firstWeekday)
#expect(restored.changedWidgetKinds(from: previous) == [WidgetKind.calendar, WidgetKind.tomorrowWork, WidgetKind.calendarMonth])
}
@Test func calendarSummaryUsesLocalCivilDayAndOldCacheStillDecodes() throws { @Test func calendarSummaryUsesLocalCivilDayAndOldCacheStillDecodes() throws {
let legacy = Data(#"{"aiEnabled":false,"quotas":[],"publicIPEnabled":false,"addresses":[],"language":"system","calendarFirstWeekday":2,"showsLunar":true}"#.utf8) let legacy = Data(#"{"aiEnabled":false,"quotas":[],"publicIPEnabled":false,"addresses":[],"language":"system","calendarFirstWeekday":2,"showsLunar":true}"#.utf8)
let oldSnapshot = try JSONDecoder().decode(WidgetSnapshot.self, from: legacy) let oldSnapshot = try JSONDecoder().decode(WidgetSnapshot.self, from: legacy)
@@ -63,29 +63,32 @@ struct CalendarEnhancementTests {
#expect(CalendarEngine.lunarDateTitle(at: newYear, locale: Locale(identifier: "zh_Hans_CN"), timeZone: zone) == "正月初一") #expect(CalendarEngine.lunarDateTitle(at: newYear, locale: Locale(identifier: "zh_Hans_CN"), timeZone: zone) == "正月初一")
} }
@Test(arguments: ["zh_Hans_CN", "zh_Hans_SG", "zh_Hant_TW", "zh_Hant_HK"]) @Test(arguments: ["en_CN", "en_US", "ja_JP", "ko_KR", "de_DE", "es_ES", "fr_FR", "ar_SA"])
func chineseLanguagesOfferLunarDisplayRegardlessOfRegion(localeID: String) throws { func manualLunarDisplaySurvivesEveryLanguage(localeID: String) throws {
let locale = Locale(identifier: localeID) let locale = Locale(identifier: localeID)
#expect(CalendarPreferences.Display.available(for: locale) == [.standard, .dateLunar, .lunar, .custom]) let date = try #require(ISO8601DateFormatter().date(from: "2026-09-28T04:00:00Z"))
#expect(CalendarPreferences.Display.lunar.effective(for: locale) == .lunar) let expected = try #require(CalendarEngine.lunarDateTitle(at: date, locale: locale, timeZone: zone))
#expect(CalendarPreferences.Display.dateLunar.effective(for: locale) == .dateLunar) let standard = CalendarPreferences().title(at: date, locale: locale, timeZone: zone)
var preferences = CalendarPreferences()
preferences.display = .lunar
#expect(preferences.title(at: date, locale: locale, timeZone: zone) == expected)
#expect(preferences.title(at: date, locale: locale, timeZone: zone) != standard)
preferences.display = .dateLunar
#expect(preferences.title(at: date, locale: locale, timeZone: zone).contains(expected))
let restored = try JSONDecoder().decode(CalendarPreferences.self, from: JSONEncoder().encode(preferences))
#expect(restored.display == .dateLunar)
#expect(restored.title(at: date, locale: locale, timeZone: zone) == preferences.title(at: date, locale: locale, timeZone: zone))
} }
@Test(arguments: ["en_CN", "en_US", "ja_JP", "ko_KR", "de_DE", "es_ES", "fr_FR", "ar_SA"]) @Test(arguments: Array(1...7))
func nonChineseLanguagesTemporarilyUseStandardDisplay(localeID: String) throws { func monthGridAndWidgetHonorEveryWeekStart(firstWeekday: Int) throws {
let locale = Locale(identifier: localeID) let days = CalendarEngine.month(year: 2026, month: 9, firstWeekday: firstWeekday, timeZone: zone)
#expect(CalendarPreferences.Display.available(for: locale) == [.standard, .custom]) let first = try #require(days.first)
let date = try #require(ISO8601DateFormatter().date(from: "2026-09-28T04:00:00Z")) #expect(days.count == 42 && first.weekday == firstWeekday)
let standard = CalendarPreferences().title(at: date, locale: locale, timeZone: zone) let summary = CalendarEngine.widgetMonthSummary(year: 2026, month: 9, firstWeekday: firstWeekday,
for display in [CalendarPreferences.Display.lunar, .dateLunar] { features: [], timeZone: zone)
var preferences = CalendarPreferences() #expect(summary.firstWeekday == firstWeekday)
preferences.display = display #expect(summary.days.first?.dateKey == first.id)
#expect(preferences.title(at: date, locale: locale, timeZone: zone) == standard)
// 呈现回退不应被写入备份,切回繁体中文仍使用原选择。
let restored = try JSONDecoder().decode(CalendarPreferences.self, from: JSONEncoder().encode(preferences))
#expect(restored.display == display)
#expect(restored.display.effective(for: Locale(identifier: "zh_Hant_TW")) == display)
}
} }
@Test(arguments: ["en_US", "en_GB", "zh_Hans_CN"]) @Test(arguments: ["en_US", "en_GB", "zh_Hans_CN"])
@@ -164,6 +167,49 @@ struct CalendarEnhancementTests {
#expect(!disabled.calendarPreferences.showHolidayOverview && !disabled.calendarPreferences.showAlmanac) #expect(!disabled.calendarPreferences.showHolidayOverview && !disabled.calendarPreferences.showAlmanac)
} }
@Test(arguments: Array(1...7))
func weekStartUsesSystemDefaultOnceAndPreservesManualChoice(firstWeekday: Int) throws {
let name = "CalendarWeekStartTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
var calendar = Calendar(identifier: .gregorian)
calendar.firstWeekday = firstWeekday
let settings = AppSettings(defaults: defaults, calendarLocale: Locale(identifier: "en_US"), calendar: calendar)
#expect(settings.calendarFirstWeekday == firstWeekday)
#expect(defaults.integer(forKey: "calendarFirstWeekday") == firstWeekday)
calendar.firstWeekday = firstWeekday % 7 + 1
let restored = AppSettings(defaults: defaults, calendarLocale: Locale(identifier: "zh_CN"), calendar: calendar)
#expect(restored.calendarFirstWeekday == firstWeekday)
settings.calendarFirstWeekday = 3
#expect(AppSettings(defaults: defaults, calendar: calendar).calendarFirstWeekday == 3)
}
@Test(arguments: Array(1...7))
func backupsRestoreEveryWeekStartAndRejectInvalidValues(firstWeekday: Int) throws {
let name = "CalendarWeekBackupTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
var calendar = Calendar(identifier: .gregorian)
calendar.firstWeekday = 7
defaults.set(99, forKey: "calendarFirstWeekday")
let settings = AppSettings(defaults: defaults, calendar: calendar)
#expect(settings.calendarFirstWeekday == 7)
#expect(defaults.integer(forKey: "calendarFirstWeekday") == 7)
settings.calendarFirstWeekday = firstWeekday
let document = try JSONDecoder().decode(SettingsDocument.self, from: JSONEncoder().encode(settings.exportDocument()))
settings.calendarFirstWeekday = firstWeekday % 7 + 1
settings.apply(document)
#expect(settings.calendarFirstWeekday == firstWeekday)
var invalid = SettingsDocument()
invalid.calendarFirstWeekday = 99
settings.apply(invalid)
#expect(settings.calendarFirstWeekday == firstWeekday)
settings.apply(SettingsDocument())
#expect(settings.calendarFirstWeekday == firstWeekday)
}
@Test func existingCalendarChoicesAndBackupsSurviveRegionalDefaults() throws { @Test func existingCalendarChoicesAndBackupsSurviveRegionalDefaults() throws {
let name = "CalendarExistingRegionTests.\(UUID())" let name = "CalendarExistingRegionTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: name)) let defaults = try #require(UserDefaults(suiteName: name))
+6 -3
View File
@@ -18,7 +18,8 @@ struct CalendarWidgetEntry: TimelineEntry {
struct CalendarWidgetProvider: TimelineProvider { struct CalendarWidgetProvider: TimelineProvider {
func placeholder(in context: Context) -> CalendarWidgetEntry { func placeholder(in context: Context) -> CalendarWidgetEntry {
.init(date: .now, showsLunar: WidgetSnapshot().showsLunar, showsSeasonal: false, firstWeekday: 2, let snapshot = WidgetSnapshot()
return .init(date: .now, showsLunar: snapshot.showsLunar, showsSeasonal: false, firstWeekday: snapshot.calendarFirstWeekday,
today: nil, tomorrow: nil, month: nil) today: nil, tomorrow: nil, month: nil)
} }
@@ -175,7 +176,9 @@ struct CalendarWidget: Widget {
private struct MonthCalendarWidgetView: View { private struct MonthCalendarWidgetView: View {
let entry: CalendarWidgetEntry let entry: CalendarWidgetEntry
private var firstWeekday: Int { entry.firstWeekday == 1 ? 1 : 2 } private var firstWeekday: Int {
(1...7).contains(entry.firstWeekday) ? entry.firstWeekday : Calendar.current.firstWeekday
}
private var calendar: Calendar { WidgetSnapshot.keyCalendar } private var calendar: Calendar { WidgetSnapshot.keyCalendar }
private var monthKey: String { WidgetSnapshot.monthKey(for: entry.date) } private var monthKey: String { WidgetSnapshot.monthKey(for: entry.date) }
@@ -320,7 +323,7 @@ private struct TomorrowWorkWidgetView: View {
.minimumScaleFactor(0.75) .minimumScaleFactor(0.75)
} }
} else { } else {
Text(tr("暂无法判断")) Text(tr("仅支持中国大陆节假日"))
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }