refactor(calendar): 固定显示星期与黄历并简化设置

This commit is contained in:
ysicing authored and 缘生 committed 2026-09-30 23:50:16 +08:00
1 parent 01bf5c77b2
commit 0b63a4ed04
22 files changed
+72 -67

No files matched your search

+6 -1
View File
@@ -183,7 +183,12 @@ lookups. Changing to an unsupported display year clears the holiday overview bef
reuse or daily planning, so the current year's countdown cannot appear in the next year.
Plum-rain dates describe traditional calendar rules, not observed or forecast weather.
Calendar enablement, visible features and week start persist through `AppSettings` and the
Weekday headers and weekday labels in selected-day dates are always visible, matching the
month widget. Legacy `weekdays` settings are ignored without discarding other choices.
Selected-day almanac is always enabled; legacy `showAlmanac` preferences are ignored.
Details still calculate on demand, while widgets reuse the bounded eight-day summary.
The snapshot keeps `showsAlmanac = true` to invalidate old disabled-almanac caches.
Calendar enablement, optional features and week start persist through `AppSettings` and the
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
+2 -2
View File
@@ -54,8 +54,8 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布
构建固定使用 `arm64`,仅支持 Apple Silicon Mac。
有 Developer ID Application 证书时,Taskfile 会自动使用钥匙串中第一个证书的唯一指纹;否则由构建脚本逐层补齐 ad-hoc 签名与 entitlement。临时签名没有团队身份,不能加载启用团队验证的 Sparkle 动态库,因此仅此本地/CI 路径关闭 Hardened Runtime;正式 Developer ID 构建仍强制启用,且临时签名构建不能在线安装或操作特权辅助工具。
桌面小组件与主应用通过 `group.work.12306.xstats` App Group 共享只读展示摘要。
菜单栏日历在所有应用语言下均提供“日期与星期”“日期与农历”“农历”“自定义”显示方式;日期与农历按应用语言呈现,切换语言保留用户选择。自定义可自由组合 y/M/d 字段与分隔符,年份可省略,输入时显示预览并提示无效格式,独立于月历的星期开关;支持农历字号与对比度、可选悬停展开,按分钟刷新,休眠或关闭功能后停止。日程和到期提醒默认关闭,通过明确的授权按钮请求 EventKit 权限,只在日历面板可见时查询当前 42 格日期范围;只读展示,不修改日程,也不向 Widget、设置备份或服务端传递内容。列表 ID 仅保存在本机,显示偏好参与设置备份。节假日覆盖年份来自内置 Tyme 数据并随应用更新;“假期倒计时与请假建议”仅在开启“中国大陆节假日与调休”后显示在该开关下方;关闭节假日时隐藏子选项并保留原选择。假期倒计时与最多 3 天的请假建议只基于已公布的中国调休与双休安排,跨到未知年份时停止推算。切换到未收录法定安排的年份时,不查询该年的法定节假日,不计算或沿用当前年份的假期概览与请假建议,仅提示缺少该年数据;返回有数据的年份后恢复展示。
日历首次初始化按 macOS 地区设置决定默认值,不按设备销售地或应用语言判断:中国大陆(CN)默认启用农历、星期、大陆节假日与调休、传统与公历节日、节气与时令、黄历及假期概览;其他地区(含台湾、香港、澳门)默认只启用星期。藏历与回历各地区均默认关闭。“节气与时令”统一控制二十四节气、三伏、梅雨与数九;月历只显示节气当天、时令阶段起点及出梅,单日详情与单日小组件保留具体天数。旧节气或时令开关任一开启即恢复为开启,全部关闭才恢复为关闭;使用新的设置键使 Widget 旧摘要失效并重新生成。月历不再显示干支,也不提供对应开关;年月日干支只在启用黄历后的日期详情中显示,旧偏好与备份中的干支选项被忽略,其他选项保留。用户可自行启用或关闭,首次默认值保存后不随语言、地区变化重置;已有本机偏好及旧备份保持原选择。每周起始日首次采用 macOS 系统日历设置并保存,支持星期日到星期六,手动选择及备份恢复优先;后续系统设置变化不覆盖已保存的值,主应用月历与小组件使用同一周起始日。大陆节假日数据不能用于推断其他地区的法定安排。单日及整月小组件遵循日历选项,未启用大陆节假日时“明天上班吗”提示“仅支持中国大陆节假日”;黄历关闭后不再计算宜忌摘要。
菜单栏日历在所有应用语言下均提供“日期与星期”“日期与农历”“农历”“自定义”显示方式;日期与农历按应用语言呈现,切换语言保留用户选择。自定义可自由组合 y/M/d 字段与分隔符,年份可省略,输入时显示预览并提示无效格式,不影响月历与单日详情的星期显示;支持农历字号与对比度、可选悬停展开,按分钟刷新,休眠或关闭功能后停止。日程和到期提醒默认关闭,通过明确的授权按钮请求 EventKit 权限,只在日历面板可见时查询当前 42 格日期范围;只读展示,不修改日程,也不向 Widget、设置备份或服务端传递内容。列表 ID 仅保存在本机,显示偏好参与设置备份。节假日覆盖年份来自内置 Tyme 数据并随应用更新;“假期倒计时与请假建议”仅在开启“中国大陆节假日与调休”后显示在该开关下方;关闭节假日时隐藏子选项并保留原选择。假期倒计时与最多 3 天的请假建议只基于已公布的中国调休与双休安排,跨到未知年份时停止推算。切换到未收录法定安排的年份时,不查询该年的法定节假日,不计算或沿用当前年份的假期概览与请假建议,仅提示缺少该年数据;返回有数据的年份后恢复展示。
日历首次初始化按 macOS 地区设置决定默认值,不按设备销售地或应用语言判断:中国大陆(CN)默认启用农历、大陆节假日与调休、传统与公历节日、节气与时令及假期概览;其他地区(含台湾、香港、澳门)默认不启用这些地区相关的可选内容。各地区的月历和单日详情均固定显示星期,不提供独立开关;旧配置与备份中的星期开关被忽略,其他选择保留。藏历与回历各地区均默认关闭。“节气与时令”统一控制二十四节气、三伏、梅雨与数九;月历只显示节气当天、时令阶段起点及出梅,单日详情与单日小组件保留具体天数。旧节气或时令开关任一开启即恢复为开启,全部关闭才恢复为关闭;使用新的设置键使 Widget 旧摘要失效并重新生成。月历不再显示干支,也不提供对应开关;年月日干支只在日期详情中显示,旧偏好与备份中的干支选项被忽略,其他选项保留。用户可自行启用或关闭,首次默认值保存后不随语言、地区变化重置;已有本机偏好及旧备份保持原选择。每周起始日首次采用 macOS 系统日历设置并保存,支持星期日到星期六,手动选择及备份恢复优先;后续系统设置变化不覆盖已保存的值,主应用月历与小组件使用同一周起始日。大陆节假日数据不能用于推断其他地区的法定安排。单日及整月小组件遵循日历选项,未启用大陆节假日时“明天上班吗”提示“仅支持中国大陆节假日”;黄历在单日详情中固定显示,旧偏好与备份中的黄历开关被忽略;宜忌详情仍只为选中日期按需计算,小组件复用主应用预计算的八天摘要缓存。
月历使用固定六行布局,日期 ID 在生成日期时计算一次。面板打开时测量初始高度,后续翻月、详情与日程内容变化在内部滚动,不从内容几何回调反向调整窗口,避免窗口高度与懒布局估算形成反馈循环。
月历计算仅缓存最近一个月份(包含周起始日和时区),供实际面板、高度测量和重复打开共用;翻月替换缓存,避免浏览历史导致内存累积。日程日期标记在工作 actor 内随查询结果生成,界面不再逐格扫描日程列表;连续 EventKit 通知按 300 毫秒窗口合并,关闭面板时取消待执行刷新。
用于正式签名的 Apple Developer 团队须为主应用和 Widget 扩展启用同一个 App Group;
@@ -14,7 +14,6 @@ extension Translations {
星期日 Sunday
农历 Lunar calendar
黄历 Chinese almanac
星期 Weekdays
中国大陆节假日与调休 Mainland China holidays and makeup workdays
传统与公历节日 Festivals
节气与时令 Solar terms and seasons
@@ -1313,7 +1313,6 @@ Apple 智能 Apple Intelligence
星期一 الاثنين
星期日 الأحد
农历 التقويم القمري الصيني
星期 أيام الأسبوع
中国大陆节假日与调休 العطلات وأيام العمل التعويضية في البر الرئيسي للصين
传统与公历节日 المناسبات التقليدية والميلادية
节气与时令 الفصول الشمسية والفترات الموسمية
1 # Copyright (C) 2026 ysicing
1313 星期一
1314 星期日
1315 农历
星期
1316 中国大陆节假日与调休
1317 传统与公历节日
1318 节气与时令
@@ -1313,7 +1313,6 @@ Apple 智能 Apple Intelligence
星期一 Montag
星期日 Sonntag
农历 Chinesischer Mondkalender
星期 Wochentage
中国大陆节假日与调休 Feiertage und Ausgleichsarbeitstage in Festlandchina
传统与公历节日 Traditionelle und gregorianische Feste
节气与时令 Sonnenperioden und Jahreszeiten
1 # Copyright (C) 2026 ysicing
1313 星期一
1314 星期日
1315 农历
星期
1316 中国大陆节假日与调休
1317 传统与公历节日
1318 节气与时令
@@ -1313,7 +1313,6 @@ Apple 智能 Apple Intelligence
星期一 Lunes
星期日 Domingo
农历 Calendario lunar chino
星期 Días de la semana
中国大陆节假日与调休 Festivos y días laborales compensatorios de China continental
传统与公历节日 Fiestas tradicionales y gregorianas
节气与时令 Términos solares y períodos estacionales
1 # Copyright (C) 2026 ysicing
1313 星期一
1314 星期日
1315 农历
星期
1316 中国大陆节假日与调休
1317 传统与公历节日
1318 节气与时令
@@ -1313,7 +1313,6 @@ Apple 智能 Apple Intelligence
星期一 Lundi
星期日 Dimanche
农历 Calendrier lunaire chinois
星期 Jours de la semaine
中国大陆节假日与调休 Jours fériés et jours de travail compensatoires en Chine continentale
传统与公历节日 Fêtes traditionnelles et grégoriennes
节气与时令 Termes solaires et périodes saisonnières
1 # Copyright (C) 2026 ysicing
1313 星期一
1314 星期日
1315 农历
星期
1316 中国大陆节假日与调休
1317 传统与公历节日
1318 节气与时令
@@ -1313,7 +1313,6 @@ Apple 智能 Apple Intelligence
星期一 月曜日
星期日 日曜日
农历 旧暦
星期 曜日
中国大陆节假日与调休 中国本土の祝日・振替出勤日
传统与公历节日 伝統と西暦の祝祭日
节气与时令 節気と季節の節目
1 # Copyright (C) 2026 ysicing
1313 星期一
1314 星期日
1315 农历
星期
1316 中国大陆节假日与调休
1317 传统与公历节日
1318 节气与时令
@@ -1313,7 +1313,6 @@ Apple 智能 Apple Intelligence
星期一 월요일
星期日 일요일
农历 음력
星期 요일
中国大陆节假日与调休 중국 본토 공휴일 및 대체 근무일
传统与公历节日 전통 및 양력 기념일
节气与时令 절기와 계절
1 # Copyright (C) 2026 ysicing
1313 星期一
1314 星期日
1315 农历
星期
1316 中国大陆节假日与调休
1317 传统与公历节日
1318 节气与时令
@@ -1314,7 +1314,6 @@ Apple 智能 Apple Intelligence
星期一 星期一
星期日 星期日
农历 農曆
星期 星期
中国大陆节假日与调休 中國大陸節假日與調休
传统与公历节日 傳統與公曆節日
节气与时令 節氣與時令
1 # Copyright (c) 2026 GiantAccel, LLC
1314 星期一
1315 星期日
1316 农历
星期
1317 中国大陆节假日与调休
1318 传统与公历节日
1319 节气与时令
@@ -166,7 +166,7 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
public var showsLunar: Bool
/// 可选以兼容旧版摘要;单日日历仅在用户开启时显示具体时令天数。
public var showsSeasonal: Bool?
/// nil 表示旧快照尚未按黄历开关生成,主应用会按需更新一次。
/// 保留旧字段以识别黄历曾关闭的缓存;主应用固定写入 true,并重算旧的 nil / false 摘要。
public var showsAlmanac: Bool?
/// 可选以兼容旧版主应用写入的 App Group 摘要。
public var calendarDays: [CalendarSummary]?
@@ -402,7 +402,6 @@ public final class AppController: NSObject, NSApplicationDelegate {
_ = model.settings.language
_ = model.settings.calendarFirstWeekday
_ = model.settings.calendarFeatures
_ = model.settings.calendarPreferences.showAlmanac
_ = model.aiUsage.states
_ = model.aiUsage.quotaStates
_ = model.network.publicResults
@@ -427,7 +426,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
// 多预备几天,Widget 午夜换日时无需唤醒主应用;主应用运行期间按小时补足窗口。
let calendarDays: [WidgetSnapshot.CalendarSummary] = {
if reusesCalendar, previous.showsSeasonal != nil,
previous.showsAlmanac == settings.calendarPreferences.showAlmanac,
previous.showsAlmanac == true,
previous.monthSummaries?.first?.featureKeys == monthFeatures, previous.calendarDays?.count == 8,
previous.calendarDays?.first?.dateKey == todayKey {
return previous.calendarDays ?? []
@@ -435,8 +434,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
return (0..<8).compactMap { offset in
guard let date = calendar.date(byAdding: .day, value: offset, to: today),
let day = CalendarEngine.today(at: date) else { return nil }
return CalendarEngine.widgetSummary(for: day, features: settings.calendarFeatures,
showsAlmanac: settings.calendarPreferences.showAlmanac)
return CalendarEngine.widgetSummary(for: day, features: settings.calendarFeatures)
}
}()
let monthSummaries: [WidgetSnapshot.MonthSummary] = (0...1).compactMap { offset in
@@ -492,7 +490,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
showsLunar: settings.calendarFeatures.contains(.lunar),
calendarDays: calendarDays, monthSummaries: monthSummaries,
showsSeasonal: settings.calendarFeatures.contains(.seasonalInfo),
calendarDataVersion: dataVersion, showsAlmanac: settings.calendarPreferences.showAlmanac)
calendarDataVersion: dataVersion, showsAlmanac: true)
guard snapshot != previous, widgetStore.save(snapshot) else { return }
for kind in snapshot.changedWidgetKinds(from: previous) {
WidgetCenter.shared.reloadTimelines(ofKind: kind)
@@ -46,8 +46,7 @@ extension CalendarEngine {
"2026-05-04", "2026-05-05", "2026-10-05", "2026-10-06", "2026-10-07"
]
static func widgetSummary(for day: CalendarDay, features: Set<CalendarFeature> = Set(CalendarFeature.allCases),
showsAlmanac: Bool = true) -> WidgetSnapshot.CalendarSummary {
static func widgetSummary(for day: CalendarDay, features: Set<CalendarFeature> = Set(CalendarFeature.allCases)) -> WidgetSnapshot.CalendarSummary {
let schedule: WidgetSnapshot.CalendarSummary.Schedule
if !features.contains(.holidays) || !hasHolidayData(year: day.year) {
schedule = .unknown
@@ -68,7 +67,7 @@ extension CalendarEngine {
} else {
schedule = day.isWeekend ? .weekend : .work
}
let almanac = showsAlmanac ? almanac(for: day) : nil
let almanac = almanac(for: day)
let seasons = features.contains(.seasonalInfo) ? [day.dogDays, day.plumRain, day.nineDays].compactMap { $0 } : []
return .init(dateKey: day.id, festivals: features.contains(.festivals) ? day.festivals : [],
solarTerm: features.contains(.seasonalInfo) ? day.solarTerm : nil,
@@ -41,7 +41,7 @@ struct CalendarAlmanacView: View {
let formatter = DateFormatter()
formatter.locale = L10n.locale
formatter.calendar = CalendarEngine.gregorian()
formatter.setLocalizedDateFormatFromTemplate(features.contains(.weekdays) ? "yMMMMdEEEE" : "yMMMMd")
formatter.setLocalizedDateFormatFromTemplate("yMMMMdEEEE")
return formatter.string(from: day.date)
}
@@ -9,19 +9,18 @@ import WidgetData
/// 公历是月历的坐标;其余信息可独立隐藏,设置以稳定 rawValue 保存和同步。
public enum CalendarFeature: String, CaseIterable, Identifiable, Sendable {
case lunar, weekdays, holidays, festivals, seasonalInfo, tibetan, hijri
case lunar, holidays, festivals, seasonalInfo, tibetan, hijri
public var id: String { rawValue }
public static var defaults: Set<Self> { defaults(for: .current) }
/// 默认值依据使用地区,不依据应用语言或设备销售地;其他地区可自行启用。
public static func defaults(for locale: Locale) -> Set<Self> {
locale.region?.identifier == "CN" ? [.lunar, .weekdays, .holidays, .festivals, .seasonalInfo] : [.weekdays]
locale.region?.identifier == "CN" ? [.lunar, .holidays, .festivals, .seasonalInfo] : []
}
var title: String {
switch self {
case .lunar: tr("农历")
case .weekdays: tr("星期")
case .holidays: tr("中国大陆节假日与调休")
case .festivals: tr("传统与公历节日")
case .seasonalInfo: tr("节气与时令")
@@ -32,7 +31,7 @@ public enum CalendarFeature: String, CaseIterable, Identifiable, Sendable {
/// 旧偏好与备份中的节气、时令开关合并;任一项曾开启就保留用户的显示意愿。
static func restored(from rawValues: [String]) -> Set<Self> {
// 已移除的月历干支(ganzhi)与未知选项一起忽略,不影响其他已保存的选择。
// 星期固定显示;已移除的 weekdays、ganzhi 与未知选项一起忽略,不影响其他选择。
var features = Set(rawValues.compactMap(Self.init(rawValue:)))
// 使用新的设置键也使 Widget 的旧摘要失效,避免复用只包含节气或时令的旧缓存。
if rawValues.contains(where: { ["solarTerms", "seasonal", "dogDays", "plumRain"].contains($0) }) {
@@ -126,7 +126,6 @@ struct CalendarPopover: View {
refreshHolidayPlan(at: referenceDate ?? Date())
}
.onChange(of: selected?.id) { _, _ in refreshAlmanac() }
.onChange(of: model.settings.calendarPreferences.showAlmanac) { _, _ in refreshAlmanac() }
.onChange(of: model.settings.calendarFeatures) { _, _ in refreshHolidayPlan(at: referenceDate ?? Date()) }
.onChange(of: model.settings.calendarPreferences.showHolidayOverview) { _, _ in
refreshHolidayPlan(at: referenceDate ?? Date())
@@ -217,18 +216,16 @@ struct CalendarPopover: View {
let features = model.settings.calendarFeatures
let firstWeekday = model.settings.calendarFirstWeekday
return VStack(spacing: sizing.cellHeight < 50 ? 6 : 7) {
if features.contains(.weekdays) {
HStack(spacing: 4) {
ForEach(0..<7) { offset in
let weekday = (firstWeekday - 1 + offset) % 7 + 1
Text(weekdayName(weekday))
.font(.system(size: sizing.cellHeight < 50 ? 11 : 12, weight: .medium))
.foregroundStyle(weekday == 1 || weekday == 7 ? Color.red : .secondary)
.frame(maxWidth: .infinity)
}
HStack(spacing: 4) {
ForEach(0..<7) { offset in
let weekday = (firstWeekday - 1 + offset) % 7 + 1
Text(weekdayName(weekday))
.font(.system(size: sizing.cellHeight < 50 ? 11 : 12, weight: .medium))
.foregroundStyle(weekday == 1 || weekday == 7 ? Color.red : .secondary)
.frame(maxWidth: .infinity)
}
.padding(.bottom, sizing.cellHeight < 50 ? 2 : 3)
}
.padding(.bottom, sizing.cellHeight < 50 ? 2 : 3)
// 月历固定最多 42 格,无需懒布局的高度估算与反复放置。
VStack(spacing: sizing.cellHeight < 50 ? 3 : 4) {
ForEach(0..<6) { row in
@@ -272,7 +269,7 @@ struct CalendarPopover: View {
}
private func refreshAlmanac() {
guard showsDayDetails, model.settings.calendarPreferences.showAlmanac, let selected else {
guard showsDayDetails, let selected else {
almanac = nil
return
}
@@ -26,19 +26,17 @@ public struct CalendarPreferences: Codable, Equatable, Sendable {
public var strongerLunarText = false
public var openOnHover = false
public var showHolidayOverview: Bool
public var showAlmanac: Bool
public var showEvents = false
public var showReminders = false
public init(locale: Locale = .current) {
let isMainlandChina = locale.region?.identifier == "CN"
showHolidayOverview = isMainlandChina
showAlmanac = isMainlandChina
}
private enum CodingKeys: String, CodingKey {
case display, dateFormat, largeLunarText, strongerLunarText, openOnHover
case showHolidayOverview, showAlmanac, showEvents, showReminders
case showHolidayOverview, showEvents, showReminders
}
public init(from decoder: any Decoder) throws {
@@ -51,8 +49,6 @@ public struct CalendarPreferences: Codable, Equatable, Sendable {
strongerLunarText = try values.decodeIfPresent(Bool.self, forKey: .strongerLunarText) ?? false
openOnHover = try values.decodeIfPresent(Bool.self, forKey: .openOnHover) ?? false
showHolidayOverview = try values.decodeIfPresent(Bool.self, forKey: .showHolidayOverview) ?? true
// 旧偏好始终展示黄历,缺失新字段时保留已有行为;全新偏好使用地区默认值。
showAlmanac = try values.decodeIfPresent(Bool.self, forKey: .showAlmanac) ?? true
showEvents = try values.decodeIfPresent(Bool.self, forKey: .showEvents) ?? false
showReminders = try values.decodeIfPresent(Bool.self, forKey: .showReminders) ?? false
}
@@ -71,11 +71,6 @@ struct CalendarOptionsPopover: View {
}
}
}
GroupRow {
SettingRow(title: tr("黄历")) {
DSToggle(isOn: $settings.calendarPreferences.showAlmanac, label: tr("黄历"))
}
}
GroupRow {
Text(tr("公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。"))
.dsFont(.xs)
@@ -155,7 +155,7 @@ enum SnapshotRenderer {
settings.calendarPreferences.display = .dateLunar
write(CalendarOptionsPopover(), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("calendar-lunar-\(suffix).png"))
// 黄历示例明确启用;普通日历与设置截图仍使用本机地区默认值。
// 重置呈现偏好;黄历在日期详情中固定显示,其他选项仍按本机地区初始化。
settings.calendarPreferences = CalendarPreferences(locale: Locale(identifier: "zh_CN"))
write(CalendarPopover(referenceDate: september, showsDayDetails: true), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("calendar-almanac-\(suffix).png"))
@@ -10,20 +10,20 @@ struct CalendarAlmanacTests {
@Test(arguments: [(2026, 9, 25), (2026, 9, 23), (2026, 12, 23), (2026, 10, 10)])
func widgetsDoNotExposeDisabledRegionalFeatures(_ date: (Int, Int, Int)) throws {
let day = try #require(CalendarEngine.day(year: date.0, month: date.1, day: date.2))
let summary = CalendarEngine.widgetSummary(for: day, features: [.weekdays], showsAlmanac: false)
let summary = CalendarEngine.widgetSummary(for: day, features: [])
#expect(summary.festivals.isEmpty && summary.solarTerm == nil)
#expect(summary.holidayName == nil && summary.schedule.needsWork == nil)
#expect(summary.seasonalDescriptions == nil)
#expect(summary.twelveStar == nil && summary.recommends.isEmpty && summary.avoids.isEmpty)
#expect(summary.twelveStar != nil)
}
@Test func manuallyEnabledFeaturesWorkOutsideDefaultRegions() throws {
let day = try #require(CalendarEngine.day(year: 2026, month: 9, day: 25))
let summary = CalendarEngine.widgetSummary(for: day, features: [.festivals, .holidays], showsAlmanac: false)
let summary = CalendarEngine.widgetSummary(for: day, features: [.festivals, .holidays])
#expect(!summary.festivals.isEmpty && summary.holidayName != nil)
#expect(summary.schedule.needsWork == false)
#expect(summary.twelveStar == nil)
let almanacOnly = CalendarEngine.widgetSummary(for: day, features: [], showsAlmanac: true)
#expect(summary.twelveStar != nil)
let almanacOnly = CalendarEngine.widgetSummary(for: day, features: [])
#expect(almanacOnly.twelveStar != nil && !almanacOnly.recommends.isEmpty)
#expect(almanacOnly.schedule.needsWork == nil && almanacOnly.festivals.isEmpty)
}
@@ -130,10 +130,19 @@ struct CalendarEnhancementTests {
#expect(custom.largeLunarText)
}
@Test(arguments: [true, false])
func retiredAlmanacTogglePreservesOtherPreferences(enabled: Bool) throws {
let data = try JSONSerialization.data(withJSONObject: ["showAlmanac": enabled, "display": "lunar", "showEvents": true])
let preferences = try JSONDecoder().decode(CalendarPreferences.self, from: data)
#expect(preferences.display == .lunar && preferences.showEvents)
let exported = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(preferences)) as? [String: Any])
#expect(exported["showAlmanac"] == nil)
}
@Test func calendarPreferencesKeepSafeDefaultsForOlderDocuments() throws {
let old = try JSONDecoder().decode(CalendarPreferences.self, from: Data("{}".utf8))
#expect(old == CalendarPreferences(locale: Locale(identifier: "zh_CN")))
#expect(old.showAlmanac && old.showHolidayOverview)
#expect(old.showHolidayOverview)
#expect(!old.showEvents && !old.showReminders && !old.openOnHover)
let future = try JSONDecoder().decode(CalendarPreferences.self, from: Data(#"{"display":"future"}"#.utf8))
#expect(future.display == .standard)
@@ -147,11 +156,12 @@ struct CalendarEnhancementTests {
defer { defaults.removePersistentDomain(forName: name) }
let locale = Locale(identifier: localeID)
let mainland = locale.region?.identifier == "CN"
let expected: Set<CalendarFeature> = mainland ? [.lunar, .weekdays, .holidays, .festivals, .seasonalInfo] : [.weekdays]
let expected: Set<CalendarFeature> = mainland ? [.lunar, .holidays, .festivals, .seasonalInfo] : []
let settings = AppSettings(defaults: defaults, calendarLocale: locale)
#expect(settings.calendarFeatures == expected)
#expect(settings.calendarPreferences.showHolidayOverview == mainland)
#expect(settings.calendarPreferences.showAlmanac == mainland)
let day = try #require(CalendarEngine.day(year: 2026, month: 9, day: 25, timeZone: zone))
#expect(CalendarEngine.widgetSummary(for: day, features: settings.calendarFeatures).twelveStar != nil)
#expect(settings.calendarFeatures.contains(.seasonalInfo) == mainland)
// 更改地区不应重置首次保存的默认值,明确关闭的选项和空集合也须保留。
@@ -161,10 +171,9 @@ struct CalendarEnhancementTests {
#expect(restored.calendarPreferences == settings.calendarPreferences)
settings.calendarFeatures = []
settings.calendarPreferences.showHolidayOverview = false
settings.calendarPreferences.showAlmanac = false
let disabled = AppSettings(defaults: defaults, calendarLocale: Locale(identifier: "zh_CN"))
#expect(disabled.calendarFeatures.isEmpty)
#expect(!disabled.calendarPreferences.showHolidayOverview && !disabled.calendarPreferences.showAlmanac)
#expect(!disabled.calendarPreferences.showHolidayOverview)
}
@Test(arguments: Array(1...7))
@@ -219,13 +228,13 @@ struct CalendarEnhancementTests {
let settings = AppSettings(defaults: defaults, calendarLocale: Locale(identifier: "en_US"))
#expect(settings.calendarFeatures == [.lunar, .holidays, .seasonalInfo])
#expect(settings.calendarPreferences.display == .lunar)
#expect(settings.calendarPreferences.showHolidayOverview && settings.calendarPreferences.showAlmanac)
#expect(settings.calendarPreferences.showHolidayOverview)
let backup = try JSONDecoder().decode(SettingsDocument.self, from: JSONEncoder().encode(settings.exportDocument()))
settings.calendarFeatures = [.weekdays]
settings.calendarPreferences.showAlmanac = false
settings.calendarFeatures = []
settings.calendarPreferences.showHolidayOverview = false
settings.apply(backup)
#expect(settings.calendarFeatures == [.lunar, .holidays, .seasonalInfo])
#expect(settings.calendarPreferences.showAlmanac)
#expect(settings.calendarPreferences.showHolidayOverview)
}
@Test func displayPreferencesRoundTripWithoutSyncingLocalCalendarIDs() throws {
@@ -183,6 +183,21 @@ struct CalendarTests {
#expect(CalendarEngine.selection(old, in: CalendarMonth(year: 2024, month: 4), timeZone: newZone)?.id == "2024-04-01")
}
@Test func retiredWeekdayTogglePreservesOtherCalendarChoices() throws {
let name = "CalendarRetiredWeekdayTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
defaults.set(["weekdays", "lunar"], forKey: "calendarFeatures")
let settings = AppSettings(defaults: defaults)
#expect(!CalendarFeature.allCases.map(\.rawValue).contains("weekdays"))
#expect(settings.calendarFeatures == [.lunar])
var backup = SettingsDocument()
backup.calendarFeatures = ["weekdays", "hijri"]
settings.apply(backup)
#expect(settings.calendarFeatures == [.hijri])
#expect(settings.exportDocument().calendarFeatures == ["hijri"])
}
@Test func retiredGanzhiSettingPreservesOtherCalendarChoices() throws {
let name = "CalendarRetiredGanzhiTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: name))
@@ -232,15 +247,15 @@ struct CalendarTests {
let name = "CalendarMergedSeasonalTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
defaults.set(legacy + ["weekdays"], forKey: "calendarFeatures")
defaults.set(legacy + ["lunar"], forKey: "calendarFeatures")
let settings = AppSettings(defaults: defaults)
let expected = Set(legacy.isEmpty ? ["weekdays"] : ["weekdays", "seasonalInfo"])
let expected = Set(legacy.isEmpty ? ["lunar"] : ["lunar", "seasonalInfo"])
#expect(Set(settings.calendarFeatures.map(\.rawValue)) == expected)
#expect(!CalendarFeature.allCases.map(\.rawValue).contains("solarTerms"))
#expect(!CalendarFeature.allCases.map(\.rawValue).contains("seasonal"))
settings.calendarFeatures = []
var backup = SettingsDocument()
backup.calendarFeatures = legacy + ["weekdays"]
backup.calendarFeatures = legacy + ["lunar"]
settings.apply(backup)
#expect(Set(settings.exportDocument().calendarFeatures ?? []) == expected)
}
@@ -272,7 +287,7 @@ struct CalendarTests {
var oldBackup = SettingsDocument()
oldBackup.calendarFeatures = ["plumRain", "weekdays"]
settings.apply(oldBackup)
#expect(settings.calendarFeatures == [.seasonalInfo, .weekdays])
#expect(settings.exportDocument().calendarFeatures == ["seasonalInfo", "weekdays"])
#expect(settings.calendarFeatures == [.seasonalInfo])
#expect(settings.exportDocument().calendarFeatures == ["seasonalInfo"])
}
}