升级 AppStore 缓存 Key 并在覆盖安装时强制重新登录。
统一账号作用域 Key 对齐 Android,新增缓存 schema 迁移清理旧数据,并同步更新相关模块与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
169
APPSTORE_CACHE_KEY_MIGRATION.md
Normal file
169
APPSTORE_CACHE_KEY_MIGRATION.md
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
# AppStore 缓存 Key 升级对照
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
- 旧线上工程与重构版工程的主应用 Bundle ID 均为 `com.yuanzhixiang.suixinkan`。通过 App Store 覆盖升级时,系统会保留原应用容器,因此旧版 `UserDefaults` 会继续存在。
|
||||||
|
- 本次升级策略为:**强制重新登录、旧偏好全部重置、不迁移旧缓存**。
|
||||||
|
- 新 Key 只对齐 Android 的业务作用域;iOS 统一使用 `_` 作为分隔符,不复制 Android MMKV 历史实现中无分隔符拼接的写法。
|
||||||
|
- 本文中的“旧线上工程”为 `/Users/hanqiu/Desktop/android_project/suixinkan_ios`,“重构版工程”为 `/Users/hanqiu/Desktop/suixinkan`。
|
||||||
|
|
||||||
|
## Key 占位符
|
||||||
|
|
||||||
|
| 占位符 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `O` | 旧版将 `[accountType, userId, storeId, scenicId]` 中的非空值用 `_` 拼接,再清洗非字母数字字符后得到的 accountKey |
|
||||||
|
| `P` | 本次实施前重构版使用的 `<userId>_<accountType>` 账号前缀;用户 ID 为空时可能退化为 `guest` |
|
||||||
|
| `A` | 本次实施后统一使用的 `<accountType>_<userId>` 账号前缀 |
|
||||||
|
| `S` | 景区 ID(scenicId) |
|
||||||
|
| `T` | 排队点位 ID(spotId) |
|
||||||
|
| `R` | 角色编码(roleCode) |
|
||||||
|
|
||||||
|
> `suixinkan.session.v1` 是旧工程中真实存在的单个 `UserDefaults` Key,其值为 JSON Data。表格中的 `suixinkan.session.v1[token]` 等写法仅表示该 JSON 内的字段,不是独立的 `UserDefaults` Key。
|
||||||
|
|
||||||
|
## 新旧工程统一业务 Key 对照
|
||||||
|
|
||||||
|
| 业务数据 | 旧线上工程 Key | 重构版实施前 Key | 统一后的 Key | 升级处理 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 登录会话 | `suixinkan.session.v1` JSON | 拆分为多个 `key_in_*` | 保持拆分结构 | 删除旧会话,强制登录 |
|
||||||
|
| Token | `suixinkan.session.v1[token]` | `key_in_token` | `key_in_token` | 首次升级清空 |
|
||||||
|
| 用户 ID | `suixinkan.session.v1[userId]` | `key_in_user_id` | `key_in_user_id` | 首次升级清空 |
|
||||||
|
| 用户名 | `suixinkan.session.v1[userName]` | `key_in_user_name` | `key_in_user_name` | 首次升级清空 |
|
||||||
|
| 头像 | `suixinkan.session.v1[avatar]` | `key_in_avatar` | `key_in_avatar` | 首次升级清空 |
|
||||||
|
| 手机号 | `suixinkan.session.v1[phone]` | `key_in_phone` | `key_in_phone` | 首次升级清空 |
|
||||||
|
| 账号类型 | `suixinkan.session.v1[accountType]` | `key_in_account_type` | `key_in_account_type` | 首次升级清空 |
|
||||||
|
| 账号类型显示名称 | `suixinkan.session.v1[accountDisplayName]` | `key_in_account_display_name` | `key_in_account_display_name` | 首次升级清空 |
|
||||||
|
| 角色 ID | `suixinkan.session.v1[roleId]` | `P_key_in_role_id` | `A_key_in_role_id` | 不迁移;当前仅用于 legacy 权限匹配兜底 |
|
||||||
|
| 角色编码 | 无独立 Key | `key_in_role_code` | `A_key_in_role_code` | 改为账号作用域 |
|
||||||
|
| 角色名称 | `suixinkan.session.v1[roleName]` | `key_in_role_name` | `A_key_in_role_name` | 改为账号作用域 |
|
||||||
|
| 权限列表 | 无 | `P_key_role_permission_list` | `A_key_role_permission_list` | 不迁移预发布数据 |
|
||||||
|
| 当前权限 | 无 | `P_key_in_permission` | `A_key_in_permission` | 不迁移预发布数据 |
|
||||||
|
| 角色景区列表 | 无 | `P_key_current_role_scenic_list` | `A_key_current_role_scenic_list` | 不迁移预发布数据 |
|
||||||
|
| 当前景区 ID | `suixinkan.session.v1[scenicId]` | `key_in_current_scenic_id` | `A_key_in_current_scenic_id` | 改为账号作用域 |
|
||||||
|
| 当前景区名称 | `suixinkan.session.v1[scenicName]` | `key_in_current_scenic_name` | `A_key_in_current_scenic_name` | 改为账号作用域 |
|
||||||
|
| 当前门店 ID | `suixinkan.session.v1[storeId]` | `key_in_current_store_id` | `A_scenic_S_scenic_store_id` | 改为账号+景区作用域 |
|
||||||
|
| 当前门店名称 | `suixinkan.session.v1[storeName]` | 无 | 不再持久化 | 删除旧值 |
|
||||||
|
| 常用菜单 | `home.common.menu.uris` | `P_role_R_common_uris` | `A_role_R_common_uris` | 不迁移旧菜单;仅统一作用域,Android 精确后缀不同 |
|
||||||
|
| 收款语音 | `payment.voice.broadcast.enabled` | `P_key_is_open_receive_voice` | `A_key_is_open_receive_voice` | 重置为默认值 |
|
||||||
|
| 在线状态 | 无 | `P_key_online_status` | `A_key_online_status` | 改为账号作用域 |
|
||||||
|
| 上次位置上报时间 | 无 | `P_key_last_location_report_time` | `A_key_last_location_report_time` | 改为账号作用域 |
|
||||||
|
| 定位提醒分钟数 | 无 | `P_key_location_reminder_minutes` | `A_key_location_reminder_minutes` | 改为账号作用域 |
|
||||||
|
| 当前排队点位 ID | `account_O_scenic_S_scenic_queue_selected_spot_id` | `P__scenic_queue_punch_spot_id` | `A_scenic_S_scenic_queue_punch_spot_id` | 不迁移 |
|
||||||
|
| 当前排队点位名称 | `account_O_scenic_S_scenic_queue_selected_spot_name` | `P__scenic_queue_punch_spot_name` | `A_scenic_S_spot_T_scenic_queue_punch_spot_name` | 不迁移 |
|
||||||
|
| 排队播报备注 | `account_O_scenic_S_scenic_queue_custom_tts_text_spot_T` | `P__scenic_queue_remark` | `A_scenic_S_spot_T_scenic_queue_remark` | 不迁移 |
|
||||||
|
| 排队设置快照 | `account_O_scenic_S_scenic_queue_settings_snapshot_spot_T` | `P__scenic_queue_settings_snapshot` | `A_scenic_S_spot_T_scenic_queue_settings_snapshot` | 不迁移 |
|
||||||
|
| 排队预设语音 | `account_O_scenic_S_scenic_queue_preset_voices_spot_T` | `P__scenic_queue_preset_voices_v1` | `A_scenic_S_spot_T_scenic_queue_preset_voices_v1` | 不迁移 |
|
||||||
|
| 离线 TTS | 无 | `P__scenic_queue_offline_tts_v1` | `A_scenic_S_scenic_queue_offline_tts_v1` | 默认值对齐 Android 为 `true` |
|
||||||
|
|
||||||
|
## 首次升级需要清理的旧 Key
|
||||||
|
|
||||||
|
已新增整型版本标记 `suixinkan.cache.schema.version`。当版本小于目标缓存版本时执行一次清理,全部清理完成后再写入目标版本,避免每次启动重复清空用户重新登录后产生的数据。
|
||||||
|
|
||||||
|
### 会话与推送
|
||||||
|
|
||||||
|
- `suixinkan.session.v1`
|
||||||
|
- `apns_device_token`
|
||||||
|
- `apns_uploaded_token`
|
||||||
|
|
||||||
|
同时清空重构版预发布阶段可能存在的登录与账号字段,以确保升级后强制重新登录:
|
||||||
|
|
||||||
|
- `key_in_token`
|
||||||
|
- `key_last_login_username`
|
||||||
|
- `key_in_user_id`
|
||||||
|
- `key_in_user_name`
|
||||||
|
- `key_in_real_name`
|
||||||
|
- `key_in_avatar`
|
||||||
|
- `key_in_phone`
|
||||||
|
- `key_in_account_type`
|
||||||
|
- `key_in_account_display_name`
|
||||||
|
- `key_in_role_id`
|
||||||
|
- `key_in_role_code`
|
||||||
|
- `key_in_role_name`
|
||||||
|
- `key_in_current_scenic_id`
|
||||||
|
- `key_in_current_scenic_name`
|
||||||
|
- `key_in_current_store_id`
|
||||||
|
|
||||||
|
`key_privacy_agreement_accepted` 是否清理应服从“旧偏好全部重置”的既定策略,因此本次也应清理。
|
||||||
|
|
||||||
|
同时清理早期重构实现可能写入的以下未分域业务字段:
|
||||||
|
|
||||||
|
- `key_role_permission_list`
|
||||||
|
- `key_in_permission`
|
||||||
|
- `key_current_role_scenic_list`
|
||||||
|
- `key_online_status`
|
||||||
|
- `key_last_location_report_time`
|
||||||
|
- `key_location_reminder_minutes`
|
||||||
|
- `key_is_open_receive_voice`
|
||||||
|
|
||||||
|
### 首页与收款
|
||||||
|
|
||||||
|
- `home.common.menu.uris`
|
||||||
|
- `home.common.menu.android.baseline.v2`
|
||||||
|
- `home.routing.unknown.records`
|
||||||
|
- `payment.voice.broadcast.enabled`
|
||||||
|
|
||||||
|
### 排队全局偏好
|
||||||
|
|
||||||
|
- `scenic_queue_tts_enabled`
|
||||||
|
- `scenic_queue_background_poll_enabled`
|
||||||
|
- `scenic_queue_selected_spot_id`
|
||||||
|
- `scenic_queue_selected_spot_name`
|
||||||
|
- `scenic_queue_custom_tts_text`
|
||||||
|
- `scenic_queue_photo_estimate_seconds`
|
||||||
|
- `scenic_queue_broadcast_interval_seconds`
|
||||||
|
- `scenic_queue_countdown_threshold_seconds`
|
||||||
|
- `scenic_queue_show_start_shooting_button`
|
||||||
|
- `scenic_queue_auto_call_ahead_count`
|
||||||
|
- `scenic_queue_quick_call_button_enabled`
|
||||||
|
- `scenic_queue_prepare_call_button_enabled`
|
||||||
|
- `scenic_queue_config_logs`
|
||||||
|
- `scenic_queue_settings_snapshot`
|
||||||
|
- `scenic_queue_preset_voices`
|
||||||
|
|
||||||
|
### 排队动态缓存
|
||||||
|
|
||||||
|
清理符合以下已知格式的旧 Key,不应扩大为删除整个 `UserDefaults` domain:
|
||||||
|
|
||||||
|
- `account_O_scenic_S_scenic_queue_selected_spot_id`
|
||||||
|
- `account_O_scenic_S_scenic_queue_selected_spot_name`
|
||||||
|
- `account_O_scenic_S_scenic_queue_custom_tts_text_spot_T`
|
||||||
|
- `account_O_scenic_S_scenic_queue_settings_snapshot_spot_T`
|
||||||
|
- `account_O_scenic_S_scenic_queue_preset_voices_spot_T`
|
||||||
|
- `scenic_S_spot_T_scenic_queue_settings_snapshot`
|
||||||
|
- `scenic_S_scenic_queue_settings_snapshot`
|
||||||
|
- `scenic_S_spot_T_scenic_queue_preset_voices`
|
||||||
|
|
||||||
|
### 重构版预发布缓存
|
||||||
|
|
||||||
|
清理使用旧 `P` 前缀的以下 Key 家族:
|
||||||
|
|
||||||
|
- `P_key_in_role_id`
|
||||||
|
- `P_key_role_permission_list`
|
||||||
|
- `P_key_in_permission`
|
||||||
|
- `P_key_current_role_scenic_list`
|
||||||
|
- `P_key_online_status`
|
||||||
|
- `P_key_last_location_report_time`
|
||||||
|
- `P_key_location_reminder_minutes`
|
||||||
|
- `P_key_is_open_receive_voice`
|
||||||
|
- `P_role_R_common_uris`
|
||||||
|
- `P__scenic_queue_punch_spot_id`
|
||||||
|
- `P__scenic_queue_punch_spot_name`
|
||||||
|
- `P__scenic_queue_remark`
|
||||||
|
- `P__scenic_queue_settings_snapshot`
|
||||||
|
- `P__scenic_queue_offline_tts_v1`
|
||||||
|
- `P__scenic_queue_preset_voices_v1`
|
||||||
|
|
||||||
|
其中 `P__scenic_queue_*` 的双下划线来自当前 Key 生成器和自带前导下划线的后缀叠加,属于需要淘汰的错误格式。
|
||||||
|
|
||||||
|
## 不应清理的内容
|
||||||
|
|
||||||
|
- 禁止调用 `removePersistentDomain(forName:)` 清空应用的整个 `UserDefaults` domain,应只删除明确列出的 Key 和严格匹配的动态 Key。
|
||||||
|
- 不删除 `Documents/CloudDownloads`。该目录可能包含用户主动下载的文件,不属于普通缓存。
|
||||||
|
- 不删除 SDK 缓存、图片缓存或其他未列入本次升级范围的用户文件。
|
||||||
|
- 不做旧 `suixinkan.session.v1`、排队设置、常用菜单或收款偏好的解码与迁移。
|
||||||
|
|
||||||
|
## 发布前备注
|
||||||
|
|
||||||
|
- 旧线上版本为 `1.0.1 (2)`;当前覆盖升级版本已设置为 `1.1.4 (1010401)`。
|
||||||
|
- 重构版当前未保留旧工程的 APNs entitlement、远程通知后台能力、回调和设备 Token 上传流程。该问题不是缓存 Key 修改,但若线上仍需要推送,发布前必须单独恢复并验证。清理 `apns_device_token` 与 `apns_uploaded_token` 后,恢复推送时应重新绑定设备 Token。
|
||||||
|
- `Application Support/suixinkan/cloud_transfer_tasks.json` 当前为全账号共享文件,存在账号切换后读取其他账号任务的风险。该问题不是 `UserDefaults` Key 迁移的一部分,应作为独立发布风险处理。
|
||||||
|
- 本方案已在重构版中实施:应用首次创建 `AppStore` 时按 `suixinkan.cache.schema.version` 执行一次定向清理,并切换到表格中的统一 Key;不删除未列入范围的 `UserDefaults` 或用户文件。
|
||||||
@ -402,7 +402,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1010401;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
@ -426,7 +426,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.1.4;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@ -467,7 +467,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1010401;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
@ -491,7 +491,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.1.4;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
|
|||||||
@ -25,33 +25,53 @@ final class AppLocationStore {
|
|||||||
|
|
||||||
/// 是否处于位置上报在线状态。
|
/// 是否处于位置上报在线状态。
|
||||||
var onlineStatus: Bool {
|
var onlineStatus: Bool {
|
||||||
get { defaults.bool(forKey: scopedKey(.onlineStatus)) }
|
get {
|
||||||
set { defaults.set(newValue, forKey: scopedKey(.onlineStatus)) }
|
guard let key = scopedKey(.onlineStatus) else { return false }
|
||||||
|
return defaults.bool(forKey: key)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard let key = scopedKey(.onlineStatus) else { return }
|
||||||
|
defaults.set(newValue, forKey: key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 上次位置上报时间戳,单位为毫秒。
|
/// 上次位置上报时间戳,单位为毫秒。
|
||||||
var lastReportTime: Int64 {
|
var lastReportTime: Int64 {
|
||||||
get { Int64(defaults.integer(forKey: scopedKey(.lastReportTime))) }
|
get {
|
||||||
set { defaults.set(Int(newValue), forKey: scopedKey(.lastReportTime)) }
|
guard let key = scopedKey(.lastReportTime) else { return 0 }
|
||||||
|
return Int64(defaults.integer(forKey: key))
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard let key = scopedKey(.lastReportTime) else { return }
|
||||||
|
defaults.set(Int(newValue), forKey: key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 位置超时提前提醒分钟数,0 表示不提醒。
|
/// 位置超时提前提醒分钟数,0 表示不提醒。
|
||||||
var reminderMinutes: Int {
|
var reminderMinutes: Int {
|
||||||
get { defaults.integer(forKey: scopedKey(.reminderMinutes)) }
|
get {
|
||||||
set { defaults.set(newValue, forKey: scopedKey(.reminderMinutes)) }
|
guard let key = scopedKey(.reminderMinutes) else { return 0 }
|
||||||
|
return defaults.integer(forKey: key)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard let key = scopedKey(.reminderMinutes) else { return }
|
||||||
|
defaults.set(newValue, forKey: key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清除上次位置上报时间。
|
/// 清除上次位置上报时间。
|
||||||
func clearLastReportTime() {
|
func clearLastReportTime() {
|
||||||
defaults.removeObject(forKey: scopedKey(.lastReportTime))
|
guard let key = scopedKey(.lastReportTime) else { return }
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清除当前账号的位置上报状态和偏好。
|
/// 清除当前账号的位置上报状态和偏好。
|
||||||
func clear() {
|
func clear(accountScope: String) {
|
||||||
Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) }
|
guard !accountScope.isEmpty else { return }
|
||||||
|
Key.allCases.forEach { defaults.removeObject(forKey: "\(accountScope)_\($0.rawValue)") }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scopedKey(_ key: Key) -> String {
|
private func scopedKey(_ key: Key) -> String? {
|
||||||
"\(session.accountCachePrefix)_\(key.rawValue)"
|
session.accountScopedKey(key.rawValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,16 +23,23 @@ final class AppPaymentStore {
|
|||||||
|
|
||||||
/// 收款到账是否开启语音播报。
|
/// 收款到账是否开启语音播报。
|
||||||
var isOpenReceiveVoice: Bool {
|
var isOpenReceiveVoice: Bool {
|
||||||
get { defaults.bool(forKey: scopedKey(.isOpenReceiveVoice)) }
|
get {
|
||||||
set { defaults.set(newValue, forKey: scopedKey(.isOpenReceiveVoice)) }
|
guard let key = scopedKey(.isOpenReceiveVoice) else { return false }
|
||||||
|
return defaults.bool(forKey: key)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard let key = scopedKey(.isOpenReceiveVoice) else { return }
|
||||||
|
defaults.set(newValue, forKey: key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清除当前账号的收款偏好。
|
/// 清除当前账号的收款偏好。
|
||||||
func clear() {
|
func clear(accountScope: String) {
|
||||||
Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) }
|
guard !accountScope.isEmpty else { return }
|
||||||
|
Key.allCases.forEach { defaults.removeObject(forKey: "\(accountScope)_\($0.rawValue)") }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scopedKey(_ key: Key) -> String {
|
private func scopedKey(_ key: Key) -> String? {
|
||||||
"\(session.accountCachePrefix)_\(key.rawValue)"
|
session.accountScopedKey(key.rawValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,8 +26,14 @@ final class AppPermissionStore {
|
|||||||
|
|
||||||
/// 旧版 role_id,用于权限匹配兜底。
|
/// 旧版 role_id,用于权限匹配兜底。
|
||||||
var legacyRoleId: Int {
|
var legacyRoleId: Int {
|
||||||
get { defaults.integer(forKey: scopedKey(.legacyRoleId)) }
|
get {
|
||||||
set { defaults.set(newValue, forKey: scopedKey(.legacyRoleId)) }
|
guard let key = scopedKey(.legacyRoleId) else { return 0 }
|
||||||
|
return defaults.integer(forKey: key)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard let key = scopedKey(.legacyRoleId) else { return }
|
||||||
|
defaults.set(newValue, forKey: key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存完整 role-permission 列表。
|
/// 保存完整 role-permission 列表。
|
||||||
@ -71,21 +77,24 @@ final class AppPermissionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 清除当前账号的全部权限缓存。
|
/// 清除当前账号的全部权限缓存。
|
||||||
func clear() {
|
func clear(accountScope: String) {
|
||||||
Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) }
|
guard !accountScope.isEmpty else { return }
|
||||||
|
Key.allCases.forEach { defaults.removeObject(forKey: "\(accountScope)_\($0.rawValue)") }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func save<Value: Encodable>(_ value: Value, for key: Key) {
|
private func save<Value: Encodable>(_ value: Value, for key: Key) {
|
||||||
guard let data = try? JSONEncoder().encode(value) else { return }
|
guard let scopedKey = scopedKey(key),
|
||||||
defaults.set(data, forKey: scopedKey(key))
|
let data = try? JSONEncoder().encode(value) else { return }
|
||||||
|
defaults.set(data, forKey: scopedKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func load<Value: Decodable>(_ type: Value.Type, for key: Key) -> Value? {
|
private func load<Value: Decodable>(_ type: Value.Type, for key: Key) -> Value? {
|
||||||
guard let data = defaults.data(forKey: scopedKey(key)) else { return nil }
|
guard let scopedKey = scopedKey(key),
|
||||||
|
let data = defaults.data(forKey: scopedKey) else { return nil }
|
||||||
return try? JSONDecoder().decode(type, from: data)
|
return try? JSONDecoder().decode(type, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scopedKey(_ key: Key) -> String {
|
private func scopedKey(_ key: Key) -> String? {
|
||||||
"\(session.accountCachePrefix)_\(key.rawValue)"
|
session.accountScopedKey(key.rawValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,12 +9,12 @@ import Foundation
|
|||||||
final class AppScenicQueueStore {
|
final class AppScenicQueueStore {
|
||||||
|
|
||||||
private enum Key: String, CaseIterable {
|
private enum Key: String, CaseIterable {
|
||||||
case punchSpotId = "_scenic_queue_punch_spot_id"
|
case punchSpotId = "scenic_queue_punch_spot_id"
|
||||||
case punchSpotName = "_scenic_queue_punch_spot_name"
|
case punchSpotName = "scenic_queue_punch_spot_name"
|
||||||
case remark = "_scenic_queue_remark"
|
case remark = "scenic_queue_remark"
|
||||||
case settingsSnapshot = "_scenic_queue_settings_snapshot"
|
case settingsSnapshot = "scenic_queue_settings_snapshot"
|
||||||
case useOfflineTTS = "_scenic_queue_offline_tts_v1"
|
case useOfflineTTS = "scenic_queue_offline_tts_v1"
|
||||||
case presetVoices = "_scenic_queue_preset_voices_v1"
|
case presetVoices = "scenic_queue_preset_voices_v1"
|
||||||
}
|
}
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
@ -28,26 +28,39 @@ final class AppScenicQueueStore {
|
|||||||
|
|
||||||
/// 已保存打卡点 ID。
|
/// 已保存打卡点 ID。
|
||||||
var punchSpotId: String {
|
var punchSpotId: String {
|
||||||
get { string(for: .punchSpotId) }
|
get {
|
||||||
set { defaults.set(newValue, forKey: scopedKey(.punchSpotId)) }
|
guard let key = scenicKey(.punchSpotId) else { return "" }
|
||||||
|
return defaults.string(forKey: key) ?? ""
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard let key = scenicKey(.punchSpotId) else { return }
|
||||||
|
defaults.set(newValue, forKey: key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 已保存打卡点名称。
|
/// 已保存打卡点名称。
|
||||||
var punchSpotName: String {
|
var punchSpotName: String {
|
||||||
get { string(for: .punchSpotName) }
|
get { spotString(for: .punchSpotName) }
|
||||||
set { defaults.set(newValue, forKey: scopedKey(.punchSpotName)) }
|
set { setSpotString(newValue, for: .punchSpotName) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 自定义播报文案。
|
/// 自定义播报文案。
|
||||||
var remark: String {
|
var remark: String {
|
||||||
get { string(for: .remark) }
|
get { spotString(for: .remark) }
|
||||||
set { defaults.set(newValue, forKey: scopedKey(.remark)) }
|
set { setSpotString(newValue, for: .remark) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 是否优先使用离线 TTS。
|
/// 是否优先使用离线 TTS。
|
||||||
var useOfflineTTS: Bool {
|
var useOfflineTTS: Bool {
|
||||||
get { defaults.bool(forKey: scopedKey(.useOfflineTTS)) }
|
get {
|
||||||
set { defaults.set(newValue, forKey: scopedKey(.useOfflineTTS)) }
|
guard let key = scenicKey(.useOfflineTTS),
|
||||||
|
defaults.object(forKey: key) != nil else { return true }
|
||||||
|
return defaults.bool(forKey: key)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard let key = scenicKey(.useOfflineTTS) else { return }
|
||||||
|
defaults.set(newValue, forKey: key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前账号是否已经保存有效打卡点。
|
/// 当前账号是否已经保存有效打卡点。
|
||||||
@ -58,40 +71,61 @@ final class AppScenicQueueStore {
|
|||||||
|
|
||||||
/// 保存排队设置快照。
|
/// 保存排队设置快照。
|
||||||
func saveSettingsSnapshot(_ snapshot: ScenicQueueSettingsSnapshot) {
|
func saveSettingsSnapshot(_ snapshot: ScenicQueueSettingsSnapshot) {
|
||||||
guard let data = try? JSONEncoder().encode(snapshot) else { return }
|
guard let key = spotKey(.settingsSnapshot),
|
||||||
defaults.set(data, forKey: scopedKey(.settingsSnapshot))
|
let data = try? JSONEncoder().encode(snapshot) else { return }
|
||||||
|
defaults.set(data, forKey: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取排队设置快照。
|
/// 读取排队设置快照。
|
||||||
func settingsSnapshot() -> ScenicQueueSettingsSnapshot? {
|
func settingsSnapshot() -> ScenicQueueSettingsSnapshot? {
|
||||||
guard let data = defaults.data(forKey: scopedKey(.settingsSnapshot)) else { return nil }
|
guard let key = spotKey(.settingsSnapshot),
|
||||||
|
let data = defaults.data(forKey: key) else { return nil }
|
||||||
return try? JSONDecoder().decode(ScenicQueueSettingsSnapshot.self, from: data)
|
return try? JSONDecoder().decode(ScenicQueueSettingsSnapshot.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存排队预设播报文案,最多 5 条。
|
/// 保存排队预设播报文案,最多 5 条。
|
||||||
func savePresetVoices(_ voices: [String]) {
|
func savePresetVoices(_ voices: [String]) {
|
||||||
|
guard let key = spotKey(.presetVoices) else { return }
|
||||||
let normalized = voices
|
let normalized = voices
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
.prefix(5)
|
.prefix(5)
|
||||||
defaults.set(Array(normalized), forKey: scopedKey(.presetVoices))
|
defaults.set(Array(normalized), forKey: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取排队预设播报文案。
|
/// 读取排队预设播报文案。
|
||||||
func presetVoices() -> [String] {
|
func presetVoices() -> [String] {
|
||||||
defaults.stringArray(forKey: scopedKey(.presetVoices)) ?? []
|
guard let key = spotKey(.presetVoices) else { return [] }
|
||||||
|
return defaults.stringArray(forKey: key) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清除当前账号的景区排队缓存。
|
/// 清除当前账号的景区排队缓存。
|
||||||
func clear() {
|
func clear(accountScope: String) {
|
||||||
Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) }
|
guard !accountScope.isEmpty else { return }
|
||||||
|
let scenicPrefix = "\(accountScope)_scenic_"
|
||||||
|
let suffixes = Key.allCases.map { "_\($0.rawValue)" }
|
||||||
|
defaults.dictionaryRepresentation().keys
|
||||||
|
.filter { key in
|
||||||
|
key.hasPrefix(scenicPrefix) && suffixes.contains(where: key.hasSuffix)
|
||||||
|
}
|
||||||
|
.forEach { defaults.removeObject(forKey: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func string(for key: Key) -> String {
|
private func scenicKey(_ key: Key) -> String? {
|
||||||
defaults.string(forKey: scopedKey(key)) ?? ""
|
session.scenicScopedKey(key.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scopedKey(_ key: Key) -> String {
|
private func spotKey(_ key: Key) -> String? {
|
||||||
"\(session.accountCachePrefix)_\(key.rawValue)"
|
session.scenicSpotScopedKey(key.rawValue, spotId: punchSpotId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func spotString(for key: Key) -> String {
|
||||||
|
guard let scopedKey = spotKey(key) else { return "" }
|
||||||
|
return defaults.string(forKey: scopedKey) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setSpotString(_ value: String, for key: Key) {
|
||||||
|
guard let scopedKey = spotKey(key) else { return }
|
||||||
|
defaults.set(value, forKey: scopedKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,13 +44,17 @@ final class AppSessionStore {
|
|||||||
case phone = "key_in_phone"
|
case phone = "key_in_phone"
|
||||||
case accountType = "key_in_account_type"
|
case accountType = "key_in_account_type"
|
||||||
case accountDisplayName = "key_in_account_display_name"
|
case accountDisplayName = "key_in_account_display_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum AccountKey: String, CaseIterable {
|
||||||
case roleCode = "key_in_role_code"
|
case roleCode = "key_in_role_code"
|
||||||
case roleName = "key_in_role_name"
|
case roleName = "key_in_role_name"
|
||||||
case currentScenicId = "key_in_current_scenic_id"
|
case currentScenicId = "key_in_current_scenic_id"
|
||||||
case currentScenicName = "key_in_current_scenic_name"
|
case currentScenicName = "key_in_current_scenic_name"
|
||||||
case currentStoreId = "key_in_current_store_id"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let currentStoreIdSuffix = "scenic_store_id"
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
/// 使用指定 UserDefaults 创建会话存储。
|
/// 使用指定 UserDefaults 创建会话存储。
|
||||||
@ -126,41 +130,74 @@ final class AppSessionStore {
|
|||||||
|
|
||||||
/// 后端返回的原始角色编码。
|
/// 后端返回的原始角色编码。
|
||||||
var roleCode: String {
|
var roleCode: String {
|
||||||
get { string(for: .roleCode) }
|
get { accountString(for: .roleCode) }
|
||||||
set { defaults.set(newValue, forKey: Key.roleCode.rawValue) }
|
set { setAccountString(newValue, for: .roleCode) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前角色名称。
|
/// 当前角色名称。
|
||||||
var roleName: String {
|
var roleName: String {
|
||||||
get { string(for: .roleName) }
|
get { accountString(for: .roleName) }
|
||||||
set { defaults.set(newValue, forKey: Key.roleName.rawValue) }
|
set { setAccountString(newValue, for: .roleName) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前景区 ID,0 表示未选择。
|
/// 当前景区 ID,0 表示未选择。
|
||||||
var currentScenicId: Int {
|
var currentScenicId: Int {
|
||||||
get { Int(string(for: .currentScenicId)) ?? 0 }
|
get { Int(accountString(for: .currentScenicId)) ?? 0 }
|
||||||
set { defaults.set(newValue > 0 ? String(newValue) : "", forKey: Key.currentScenicId.rawValue) }
|
set { setAccountString(newValue > 0 ? String(newValue) : "", for: .currentScenicId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前景区名称。
|
/// 当前景区名称。
|
||||||
var currentScenicName: String {
|
var currentScenicName: String {
|
||||||
get { string(for: .currentScenicName) }
|
get { accountString(for: .currentScenicName) }
|
||||||
set { defaults.set(newValue, forKey: Key.currentScenicName.rawValue) }
|
set { setAccountString(newValue, for: .currentScenicName) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前店铺 ID,0 表示未选择。
|
/// 当前店铺 ID,0 表示未选择。
|
||||||
var currentStoreId: Int {
|
var currentStoreId: Int {
|
||||||
get { defaults.integer(forKey: Key.currentStoreId.rawValue) }
|
get {
|
||||||
set { defaults.set(newValue, forKey: Key.currentStoreId.rawValue) }
|
guard let key = scenicScopedKey(Self.currentStoreIdSuffix) else { return 0 }
|
||||||
|
return defaults.integer(forKey: key)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard let key = scenicScopedKey(Self.currentStoreIdSuffix) else { return }
|
||||||
|
defaults.set(newValue, forKey: key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前账号缓存前缀,对齐 Android `getAccountCachePrefix`。
|
/// 当前账号缓存前缀,对齐 Android `getAccountCachePrefix`。
|
||||||
var accountCachePrefix: String {
|
var accountCachePrefix: String {
|
||||||
let uid = userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
let uid = userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let type = accountType.rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
let type = accountType.rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if uid.isEmpty { return "guest" }
|
guard !uid.isEmpty, !type.isEmpty else { return "" }
|
||||||
if type.isEmpty { return uid }
|
return "\(type)_\(uid)"
|
||||||
return "\(uid)_\(type)"
|
}
|
||||||
|
|
||||||
|
/// 为当前完整账号生成账号作用域 Key;账号信息不完整时返回 nil。
|
||||||
|
func accountScopedKey(_ suffix: String) -> String? {
|
||||||
|
let scope = accountCachePrefix
|
||||||
|
let normalizedSuffix = suffix.trimmingCharacters(in: CharacterSet(charactersIn: "_"))
|
||||||
|
guard !scope.isEmpty, !normalizedSuffix.isEmpty else { return nil }
|
||||||
|
return "\(scope)_\(normalizedSuffix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为当前账号与景区生成作用域 Key;缺少账号或景区时返回 nil。
|
||||||
|
func scenicScopedKey(_ suffix: String, scenicId: Int? = nil) -> String? {
|
||||||
|
let resolvedScenicId = scenicId ?? currentScenicId
|
||||||
|
let normalizedSuffix = suffix.trimmingCharacters(in: CharacterSet(charactersIn: "_"))
|
||||||
|
guard !accountCachePrefix.isEmpty, resolvedScenicId > 0, !normalizedSuffix.isEmpty else { return nil }
|
||||||
|
return "\(accountCachePrefix)_scenic_\(resolvedScenicId)_\(normalizedSuffix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为当前账号、景区与点位生成作用域 Key;任一业务标识缺失时返回 nil。
|
||||||
|
func scenicSpotScopedKey(_ suffix: String, spotId: String, scenicId: Int? = nil) -> String? {
|
||||||
|
let resolvedScenicId = scenicId ?? currentScenicId
|
||||||
|
let normalizedSpotId = spotId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalizedSuffix = suffix.trimmingCharacters(in: CharacterSet(charactersIn: "_"))
|
||||||
|
guard !accountCachePrefix.isEmpty,
|
||||||
|
resolvedScenicId > 0,
|
||||||
|
!normalizedSpotId.isEmpty,
|
||||||
|
!normalizedSuffix.isEmpty else { return nil }
|
||||||
|
return "\(accountCachePrefix)_scenic_\(resolvedScenicId)_spot_\(normalizedSpotId)_\(normalizedSuffix)"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前解析后的业务角色。
|
/// 当前解析后的业务角色。
|
||||||
@ -211,23 +248,41 @@ final class AppSessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 清除登录态及账号快照,保留隐私授权和上次登录用户名。
|
/// 清除登录态及账号快照,保留隐私授权和上次登录用户名。
|
||||||
func clearAuthenticatedSession() {
|
func clearAuthenticatedSession(accountScope: String) {
|
||||||
token = ""
|
if !accountScope.isEmpty {
|
||||||
userId = ""
|
AccountKey.allCases.forEach {
|
||||||
userName = ""
|
defaults.removeObject(forKey: "\(accountScope)_\($0.rawValue)")
|
||||||
realName = ""
|
}
|
||||||
avatar = ""
|
let storePrefix = "\(accountScope)_scenic_"
|
||||||
phone = ""
|
let storeSuffix = "_\(Self.currentStoreIdSuffix)"
|
||||||
accountType = .unknown("")
|
defaults.dictionaryRepresentation().keys
|
||||||
accountDisplayName = ""
|
.filter { $0.hasPrefix(storePrefix) && $0.hasSuffix(storeSuffix) }
|
||||||
roleCode = ""
|
.forEach(defaults.removeObject(forKey:))
|
||||||
roleName = ""
|
}
|
||||||
currentScenicId = 0
|
|
||||||
currentScenicName = ""
|
[
|
||||||
currentStoreId = 0
|
Key.token,
|
||||||
|
.userId,
|
||||||
|
.userName,
|
||||||
|
.realName,
|
||||||
|
.avatar,
|
||||||
|
.phone,
|
||||||
|
.accountType,
|
||||||
|
.accountDisplayName,
|
||||||
|
].forEach { defaults.removeObject(forKey: $0.rawValue) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func string(for key: Key) -> String {
|
private func string(for key: Key) -> String {
|
||||||
defaults.string(forKey: key.rawValue) ?? ""
|
defaults.string(forKey: key.rawValue) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func accountString(for key: AccountKey) -> String {
|
||||||
|
guard let scopedKey = accountScopedKey(key.rawValue) else { return "" }
|
||||||
|
return defaults.string(forKey: scopedKey) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setAccountString(_ value: String, for key: AccountKey) {
|
||||||
|
guard let scopedKey = accountScopedKey(key.rawValue) else { return }
|
||||||
|
defaults.set(value, forKey: scopedKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ final class AppStore {
|
|||||||
|
|
||||||
/// 使用指定 UserDefaults 创建模块化应用存储。
|
/// 使用指定 UserDefaults 创建模块化应用存储。
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
AppStoreCacheMigrator.migrateIfNeeded(defaults: defaults)
|
||||||
let session = AppSessionStore(defaults: defaults)
|
let session = AppSessionStore(defaults: defaults)
|
||||||
self.session = session
|
self.session = session
|
||||||
permissions = AppPermissionStore(defaults: defaults, session: session)
|
permissions = AppPermissionStore(defaults: defaults, session: session)
|
||||||
@ -38,10 +39,11 @@ final class AppStore {
|
|||||||
|
|
||||||
/// 清除当前账号业务快照与登录态,保留隐私授权和上次登录用户名。
|
/// 清除当前账号业务快照与登录态,保留隐私授权和上次登录用户名。
|
||||||
func logout() {
|
func logout() {
|
||||||
permissions.clear()
|
let accountScope = session.accountCachePrefix
|
||||||
location.clear()
|
permissions.clear(accountScope: accountScope)
|
||||||
payment.clear()
|
location.clear(accountScope: accountScope)
|
||||||
scenicQueue.clear()
|
payment.clear(accountScope: accountScope)
|
||||||
session.clearAuthenticatedSession()
|
scenicQueue.clear(accountScope: accountScope)
|
||||||
|
session.clearAuthenticatedSession(accountScope: accountScope)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
116
suixinkan/DataStore/AppStoreCacheMigrator.swift
Normal file
116
suixinkan/DataStore/AppStoreCacheMigrator.swift
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
//
|
||||||
|
// AppStoreCacheMigrator.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 管理 AppStore 持久化结构版本,并在覆盖升级时清理不再兼容的旧缓存。
|
||||||
|
enum AppStoreCacheMigrator {
|
||||||
|
|
||||||
|
/// 当前 AppStore 缓存结构版本。
|
||||||
|
static let currentSchemaVersion = 1
|
||||||
|
|
||||||
|
/// AppStore 缓存结构版本标记。
|
||||||
|
static let schemaVersionKey = "suixinkan.cache.schema.version"
|
||||||
|
|
||||||
|
private static let exactKeysToRemove: Set<String> = [
|
||||||
|
// 旧线上会话、推送与首页缓存。
|
||||||
|
"suixinkan.session.v1",
|
||||||
|
"apns_device_token",
|
||||||
|
"apns_uploaded_token",
|
||||||
|
"home.common.menu.uris",
|
||||||
|
"home.common.menu.android.baseline.v2",
|
||||||
|
"home.routing.unknown.records",
|
||||||
|
"payment.voice.broadcast.enabled",
|
||||||
|
|
||||||
|
// 重构版预发布阶段的全局会话字段。
|
||||||
|
"key_in_token",
|
||||||
|
"key_last_login_username",
|
||||||
|
"key_privacy_agreement_accepted",
|
||||||
|
"key_in_user_id",
|
||||||
|
"key_in_user_name",
|
||||||
|
"key_in_real_name",
|
||||||
|
"key_in_avatar",
|
||||||
|
"key_in_phone",
|
||||||
|
"key_in_account_type",
|
||||||
|
"key_in_account_display_name",
|
||||||
|
"key_in_role_id",
|
||||||
|
"key_in_role_code",
|
||||||
|
"key_in_role_name",
|
||||||
|
"key_in_current_scenic_id",
|
||||||
|
"key_in_current_scenic_name",
|
||||||
|
"key_in_current_store_id",
|
||||||
|
|
||||||
|
// 可能由早期实现写入的未分域业务字段。
|
||||||
|
"key_role_permission_list",
|
||||||
|
"key_in_permission",
|
||||||
|
"key_current_role_scenic_list",
|
||||||
|
"key_online_status",
|
||||||
|
"key_last_location_report_time",
|
||||||
|
"key_location_reminder_minutes",
|
||||||
|
"key_is_open_receive_voice",
|
||||||
|
|
||||||
|
// 旧线上排队全局偏好与残留数据。
|
||||||
|
"scenic_queue_tts_enabled",
|
||||||
|
"scenic_queue_background_poll_enabled",
|
||||||
|
"scenic_queue_selected_spot_id",
|
||||||
|
"scenic_queue_selected_spot_name",
|
||||||
|
"scenic_queue_custom_tts_text",
|
||||||
|
"scenic_queue_photo_estimate_seconds",
|
||||||
|
"scenic_queue_broadcast_interval_seconds",
|
||||||
|
"scenic_queue_countdown_threshold_seconds",
|
||||||
|
"scenic_queue_show_start_shooting_button",
|
||||||
|
"scenic_queue_auto_call_ahead_count",
|
||||||
|
"scenic_queue_quick_call_button_enabled",
|
||||||
|
"scenic_queue_prepare_call_button_enabled",
|
||||||
|
"scenic_queue_config_logs",
|
||||||
|
"scenic_queue_settings_snapshot",
|
||||||
|
"scenic_queue_preset_voices",
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let preReleaseAccountScopedSuffixes = [
|
||||||
|
"key_in_role_id",
|
||||||
|
"key_role_permission_list",
|
||||||
|
"key_in_permission",
|
||||||
|
"key_current_role_scenic_list",
|
||||||
|
"key_online_status",
|
||||||
|
"key_last_location_report_time",
|
||||||
|
"key_location_reminder_minutes",
|
||||||
|
"key_is_open_receive_voice",
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let dynamicKeyPatterns = [
|
||||||
|
#"^account_.+_scenic_[^_]+_scenic_queue_selected_spot_(id|name)$"#,
|
||||||
|
#"^account_.+_scenic_[^_]+_scenic_queue_custom_tts_text_spot_[^_]+$"#,
|
||||||
|
#"^account_.+_scenic_[^_]+_scenic_queue_settings_snapshot_spot_[^_]+$"#,
|
||||||
|
#"^account_.+_scenic_[^_]+_scenic_queue_preset_voices_spot_[^_]+$"#,
|
||||||
|
#"^scenic_[^_]+_spot_[^_]+_scenic_queue_(settings_snapshot|preset_voices)$"#,
|
||||||
|
#"^scenic_[^_]+_scenic_queue_settings_snapshot$"#,
|
||||||
|
#"^.+_role_.*_common_uris$"#,
|
||||||
|
#"^.+__scenic_queue_(punch_spot_id|punch_spot_name|punch_spot_remark|remark|settings_snapshot|offline_tts_v1|preset_voices_v1)$"#,
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 在缓存结构版本落后时执行一次旧数据清理,并在成功后写入当前版本。
|
||||||
|
static func migrateIfNeeded(defaults: UserDefaults) {
|
||||||
|
guard defaults.integer(forKey: schemaVersionKey) < currentSchemaVersion else { return }
|
||||||
|
|
||||||
|
let persistedKeys = Array(defaults.dictionaryRepresentation().keys)
|
||||||
|
for key in persistedKeys where shouldRemove(key) {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
defaults.set(currentSchemaVersion, forKey: schemaVersionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shouldRemove(_ key: String) -> Bool {
|
||||||
|
if exactKeysToRemove.contains(key) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if preReleaseAccountScopedSuffixes.contains(where: { key.hasSuffix("_\($0)") }) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return dynamicKeyPatterns.contains { pattern in
|
||||||
|
key.range(of: pattern, options: .regularExpression) != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,12 +16,14 @@ final class HomeCommonMenuStore {
|
|||||||
|
|
||||||
/// 读取已保存的常用 URI;无记录时返回空数组。
|
/// 读取已保存的常用 URI;无记录时返回空数组。
|
||||||
func savedCommonURIs(accountScope: String, roleCode: String) -> [String] {
|
func savedCommonURIs(accountScope: String, roleCode: String) -> [String] {
|
||||||
defaults.stringArray(forKey: storageKey(accountScope: accountScope, roleCode: roleCode)) ?? []
|
guard let key = storageKey(accountScope: accountScope, roleCode: roleCode) else { return [] }
|
||||||
|
return defaults.stringArray(forKey: key) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存常用 URI 列表。
|
/// 保存常用 URI 列表。
|
||||||
func saveCommonURIs(_ uris: [String], accountScope: String, roleCode: String) {
|
func saveCommonURIs(_ uris: [String], accountScope: String, roleCode: String) {
|
||||||
defaults.set(uris, forKey: storageKey(accountScope: accountScope, roleCode: roleCode))
|
guard let key = storageKey(accountScope: accountScope, roleCode: roleCode) else { return }
|
||||||
|
defaults.set(uris, forKey: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根据权限生成默认常用 URI(最多 4 个)。
|
/// 根据权限生成默认常用 URI(最多 4 个)。
|
||||||
@ -54,7 +56,10 @@ final class HomeCommonMenuStore {
|
|||||||
return (common, more)
|
return (common, more)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func storageKey(accountScope: String, roleCode: String) -> String {
|
private func storageKey(accountScope: String, roleCode: String) -> String? {
|
||||||
"\(accountScope)_role_\(roleCode)_common_uris"
|
let normalizedScope = accountScope.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalizedRoleCode = roleCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !normalizedScope.isEmpty, !normalizedRoleCode.isEmpty else { return nil }
|
||||||
|
return "\(normalizedScope)_role_\(normalizedRoleCode)_common_uris"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,6 +113,9 @@ final class HomeViewModel {
|
|||||||
|
|
||||||
appStore.permissions.saveRolePermissionList(rolePermissionList)
|
appStore.permissions.saveRolePermissionList(rolePermissionList)
|
||||||
let matched = appStore.permissions.matchRolePermissionItem(in: rolePermissionList) ?? rolePermissionList[0]
|
let matched = appStore.permissions.matchRolePermissionItem(in: rolePermissionList) ?? rolePermissionList[0]
|
||||||
|
if !matched.role.roleCode.isEmpty {
|
||||||
|
appStore.session.roleCode = matched.role.roleCode
|
||||||
|
}
|
||||||
if !matched.role.name.isEmpty {
|
if !matched.role.name.isEmpty {
|
||||||
appStore.session.roleName = matched.role.name
|
appStore.session.roleName = matched.role.name
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,12 +116,12 @@ final class LoginViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
welcomeLabel.snp.makeConstraints { make in
|
welcomeLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(48)
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(50)
|
||||||
make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(24)
|
make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(24)
|
||||||
}
|
}
|
||||||
|
|
||||||
formCardShadowView.snp.makeConstraints { make in
|
formCardShadowView.snp.makeConstraints { make in
|
||||||
make.top.equalTo(welcomeLabel.snp.bottom).offset(48)
|
make.top.equalTo(welcomeLabel.snp.bottom).offset(50)
|
||||||
make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(24)
|
make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(24)
|
||||||
make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).offset(-24)
|
make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).offset(-24)
|
||||||
}
|
}
|
||||||
|
|||||||
160
suixinkanTests/AppStoreCacheMigrationTests.swift
Normal file
160
suixinkanTests/AppStoreCacheMigrationTests.swift
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
//
|
||||||
|
// AppStoreCacheMigrationTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 验证覆盖升级清理、强制退出以及缓存结构版本幂等性。
|
||||||
|
final class AppStoreCacheMigrationTests: XCTestCase {
|
||||||
|
|
||||||
|
private var suiteName: String!
|
||||||
|
private var defaults: UserDefaults!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
suiteName = "AppStoreCacheMigrationTests.\(UUID().uuidString)"
|
||||||
|
defaults = UserDefaults(suiteName: suiteName)
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
defaults = nil
|
||||||
|
suiteName = nil
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFirstInitializationClearsLegacyAndPreReleaseKeysAndForcesLogout() {
|
||||||
|
let exactKeysToRemove = [
|
||||||
|
"suixinkan.session.v1",
|
||||||
|
"apns_device_token",
|
||||||
|
"apns_uploaded_token",
|
||||||
|
"home.common.menu.uris",
|
||||||
|
"home.common.menu.android.baseline.v2",
|
||||||
|
"home.routing.unknown.records",
|
||||||
|
"payment.voice.broadcast.enabled",
|
||||||
|
"key_in_token",
|
||||||
|
"key_last_login_username",
|
||||||
|
"key_privacy_agreement_accepted",
|
||||||
|
"key_in_user_id",
|
||||||
|
"key_in_user_name",
|
||||||
|
"key_in_real_name",
|
||||||
|
"key_in_avatar",
|
||||||
|
"key_in_phone",
|
||||||
|
"key_in_account_type",
|
||||||
|
"key_in_account_display_name",
|
||||||
|
"key_in_role_id",
|
||||||
|
"key_in_role_code",
|
||||||
|
"key_in_role_name",
|
||||||
|
"key_in_current_scenic_id",
|
||||||
|
"key_in_current_scenic_name",
|
||||||
|
"key_in_current_store_id",
|
||||||
|
"key_role_permission_list",
|
||||||
|
"key_in_permission",
|
||||||
|
"key_current_role_scenic_list",
|
||||||
|
"key_online_status",
|
||||||
|
"key_last_location_report_time",
|
||||||
|
"key_location_reminder_minutes",
|
||||||
|
"key_is_open_receive_voice",
|
||||||
|
"scenic_queue_tts_enabled",
|
||||||
|
"scenic_queue_background_poll_enabled",
|
||||||
|
"scenic_queue_selected_spot_id",
|
||||||
|
"scenic_queue_selected_spot_name",
|
||||||
|
"scenic_queue_custom_tts_text",
|
||||||
|
"scenic_queue_photo_estimate_seconds",
|
||||||
|
"scenic_queue_broadcast_interval_seconds",
|
||||||
|
"scenic_queue_countdown_threshold_seconds",
|
||||||
|
"scenic_queue_show_start_shooting_button",
|
||||||
|
"scenic_queue_auto_call_ahead_count",
|
||||||
|
"scenic_queue_quick_call_button_enabled",
|
||||||
|
"scenic_queue_prepare_call_button_enabled",
|
||||||
|
"scenic_queue_config_logs",
|
||||||
|
"scenic_queue_settings_snapshot",
|
||||||
|
"scenic_queue_preset_voices",
|
||||||
|
]
|
||||||
|
let dynamicKeysToRemove = [
|
||||||
|
"account_store_user_1001_31_22_scenic_22_scenic_queue_selected_spot_id",
|
||||||
|
"account_store_user_1001_31_22_scenic_22_scenic_queue_selected_spot_name",
|
||||||
|
"account_store_user_1001_31_22_scenic_22_scenic_queue_custom_tts_text_spot_33",
|
||||||
|
"account_store_user_1001_31_22_scenic_22_scenic_queue_settings_snapshot_spot_33",
|
||||||
|
"account_store_user_1001_31_22_scenic_22_scenic_queue_preset_voices_spot_33",
|
||||||
|
"scenic_22_spot_33_scenic_queue_settings_snapshot",
|
||||||
|
"scenic_22_spot_33_scenic_queue_preset_voices",
|
||||||
|
"scenic_22_scenic_queue_settings_snapshot",
|
||||||
|
"1001_store_user_key_in_role_id",
|
||||||
|
"1001_store_user_key_role_permission_list",
|
||||||
|
"1001_store_user_key_in_permission",
|
||||||
|
"1001_store_user_key_current_role_scenic_list",
|
||||||
|
"1001_store_user_key_online_status",
|
||||||
|
"1001_store_user_key_last_location_report_time",
|
||||||
|
"1001_store_user_key_location_reminder_minutes",
|
||||||
|
"1001_store_user_key_is_open_receive_voice",
|
||||||
|
"1001_store_user_role__common_uris",
|
||||||
|
"1001_store_user_role_photographer_common_uris",
|
||||||
|
"1001_store_user__scenic_queue_punch_spot_id",
|
||||||
|
"1001_store_user__scenic_queue_punch_spot_name",
|
||||||
|
"1001_store_user__scenic_queue_punch_spot_remark",
|
||||||
|
"1001_store_user__scenic_queue_remark",
|
||||||
|
"1001_store_user__scenic_queue_settings_snapshot",
|
||||||
|
"1001_store_user__scenic_queue_offline_tts_v1",
|
||||||
|
"1001_store_user__scenic_queue_preset_voices_v1",
|
||||||
|
]
|
||||||
|
let keysToPreserve = [
|
||||||
|
"third_party.sentinel",
|
||||||
|
"home.common.menu.uris.extra",
|
||||||
|
"account_store_user_1001_31_22_scenic_22_scenic_queue_selected_spot_identifier",
|
||||||
|
"account_store_user_1001_31_22_scenic_22_scenic_queue_custom_tts_text_spot_33_backup",
|
||||||
|
"scenic_22_spot_33_scenic_queue_settings_snapshot_backup",
|
||||||
|
"unrelated_key_in_permission_extra",
|
||||||
|
"scenic_user_1001_key_in_role_code",
|
||||||
|
"scenic_user_1001_scenic_22_scenic_queue_punch_spot_id",
|
||||||
|
"scenic_user_1001_scenic_22_spot_33_scenic_queue_settings_snapshot",
|
||||||
|
]
|
||||||
|
let keysToRemove = exactKeysToRemove + dynamicKeysToRemove
|
||||||
|
keysToRemove.forEach { defaults.set("legacy", forKey: $0) }
|
||||||
|
keysToPreserve.forEach { defaults.set("keep", forKey: $0) }
|
||||||
|
defaults.set(true, forKey: "key_privacy_agreement_accepted")
|
||||||
|
|
||||||
|
let store = AppStore(defaults: defaults)
|
||||||
|
|
||||||
|
XCTAssertFalse(store.session.isLoggedIn)
|
||||||
|
XCTAssertNil(store.session.lastLoginUsername)
|
||||||
|
XCTAssertFalse(store.session.privacyAgreementAccepted)
|
||||||
|
keysToRemove.forEach { XCTAssertNil(defaults.object(forKey: $0), "未清理 Key:\($0)") }
|
||||||
|
keysToPreserve.forEach {
|
||||||
|
XCTAssertEqual(defaults.string(forKey: $0), "keep", "误清理 Key:\($0)")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(
|
||||||
|
defaults.integer(forKey: AppStoreCacheMigrator.schemaVersionKey),
|
||||||
|
AppStoreCacheMigrator.currentSchemaVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMigrationRunsOnlyOnceAndPreservesNewSession() {
|
||||||
|
defaults.set("keep", forKey: "third_party.sentinel")
|
||||||
|
var store = AppStore(defaults: defaults)
|
||||||
|
store.session.token = "new-token"
|
||||||
|
store.session.lastLoginUsername = "13800000000"
|
||||||
|
store.session.privacyAgreementAccepted = true
|
||||||
|
store.session.userId = "1001"
|
||||||
|
store.session.accountType = .scenicUser
|
||||||
|
store.session.roleCode = "photographer"
|
||||||
|
store.session.currentScenicId = 22
|
||||||
|
store.scenicQueue.punchSpotId = "33"
|
||||||
|
store.scenicQueue.punchSpotName = "东门"
|
||||||
|
|
||||||
|
store = AppStore(defaults: defaults)
|
||||||
|
|
||||||
|
XCTAssertTrue(store.session.isLoggedIn)
|
||||||
|
XCTAssertEqual(store.session.token, "new-token")
|
||||||
|
XCTAssertEqual(store.session.lastLoginUsername, "13800000000")
|
||||||
|
XCTAssertTrue(store.session.privacyAgreementAccepted)
|
||||||
|
XCTAssertEqual(store.session.roleCode, "photographer")
|
||||||
|
XCTAssertEqual(store.session.currentScenicId, 22)
|
||||||
|
XCTAssertEqual(store.scenicQueue.punchSpotId, "33")
|
||||||
|
XCTAssertEqual(store.scenicQueue.punchSpotName, "东门")
|
||||||
|
XCTAssertEqual(defaults.string(forKey: "third_party.sentinel"), "keep")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -38,7 +38,7 @@ final class AppStoreModuleTests: XCTestCase {
|
|||||||
XCTAssertEqual(AppAccountType.unknown("future_account").rawValue, "future_account")
|
XCTAssertEqual(AppAccountType.unknown("future_account").rawValue, "future_account")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSessionUsesExistingKeysAndPreservesUnknownAccountType() {
|
func testSessionUsesGlobalIdentityAndScopedBusinessContext() {
|
||||||
defaults.set("legacy-token", forKey: "key_in_token")
|
defaults.set("legacy-token", forKey: "key_in_token")
|
||||||
defaults.set("future_account", forKey: "key_in_account_type")
|
defaults.set("future_account", forKey: "key_in_account_type")
|
||||||
|
|
||||||
@ -46,11 +46,37 @@ final class AppStoreModuleTests: XCTestCase {
|
|||||||
XCTAssertEqual(appStore.session.accountType, .unknown("future_account"))
|
XCTAssertEqual(appStore.session.accountType, .unknown("future_account"))
|
||||||
|
|
||||||
appStore.session.userId = "1001"
|
appStore.session.userId = "1001"
|
||||||
|
appStore.session.roleCode = "future_role"
|
||||||
appStore.session.currentScenicId = 22
|
appStore.session.currentScenicId = 22
|
||||||
|
appStore.session.currentScenicName = "测试景区"
|
||||||
|
appStore.session.currentStoreId = 31
|
||||||
|
|
||||||
XCTAssertEqual(defaults.string(forKey: "key_in_user_id"), "1001")
|
XCTAssertEqual(defaults.string(forKey: "key_in_user_id"), "1001")
|
||||||
XCTAssertEqual(defaults.string(forKey: "key_in_current_scenic_id"), "22")
|
XCTAssertNil(defaults.object(forKey: "key_in_current_scenic_id"))
|
||||||
XCTAssertEqual(appStore.session.accountCachePrefix, "1001_future_account")
|
XCTAssertEqual(defaults.string(forKey: "future_account_1001_key_in_role_code"), "future_role")
|
||||||
|
XCTAssertEqual(defaults.string(forKey: "future_account_1001_key_in_current_scenic_id"), "22")
|
||||||
|
XCTAssertEqual(defaults.string(forKey: "future_account_1001_key_in_current_scenic_name"), "测试景区")
|
||||||
|
XCTAssertEqual(defaults.integer(forKey: "future_account_1001_scenic_22_scenic_store_id"), 31)
|
||||||
|
XCTAssertEqual(appStore.session.accountCachePrefix, "future_account_1001")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIncompleteAccountScopeDoesNotWriteBusinessCache() {
|
||||||
|
appStore.session.userId = "1001"
|
||||||
|
appStore.session.currentScenicId = 22
|
||||||
|
appStore.session.roleCode = "photographer"
|
||||||
|
appStore.permissions.savePermissionItems([HomePermissionItem(id: "1", name: "钱包", uri: "wallet")])
|
||||||
|
appStore.location.onlineStatus = true
|
||||||
|
appStore.payment.isOpenReceiveVoice = true
|
||||||
|
appStore.scenicQueue.punchSpotId = "33"
|
||||||
|
|
||||||
|
XCTAssertEqual(appStore.session.accountCachePrefix, "")
|
||||||
|
XCTAssertEqual(appStore.session.currentScenicId, 0)
|
||||||
|
XCTAssertEqual(appStore.session.roleCode, "")
|
||||||
|
XCTAssertTrue(appStore.permissions.permissionItems().isEmpty)
|
||||||
|
XCTAssertFalse(appStore.location.onlineStatus)
|
||||||
|
XCTAssertFalse(appStore.payment.isOpenReceiveVoice)
|
||||||
|
XCTAssertEqual(appStore.scenicQueue.punchSpotId, "")
|
||||||
|
XCTAssertFalse(defaults.dictionaryRepresentation().keys.contains { $0.contains("guest") })
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFeatureStoresAreIsolatedByAccountAndDecodeSnapshots() {
|
func testFeatureStoresAreIsolatedByAccountAndDecodeSnapshots() {
|
||||||
@ -68,8 +94,11 @@ final class AppStoreModuleTests: XCTestCase {
|
|||||||
appStore.payment.isOpenReceiveVoice = true
|
appStore.payment.isOpenReceiveVoice = true
|
||||||
appStore.scenicQueue.punchSpotId = "33"
|
appStore.scenicQueue.punchSpotId = "33"
|
||||||
appStore.scenicQueue.punchSpotName = "东门"
|
appStore.scenicQueue.punchSpotName = "东门"
|
||||||
|
appStore.scenicQueue.remark = "请提前准备"
|
||||||
appStore.scenicQueue.saveSettingsSnapshot(queueSettings)
|
appStore.scenicQueue.saveSettingsSnapshot(queueSettings)
|
||||||
appStore.scenicQueue.savePresetVoices([" A ", "", "B"])
|
appStore.scenicQueue.savePresetVoices([" A ", "", "B"])
|
||||||
|
XCTAssertTrue(appStore.scenicQueue.useOfflineTTS)
|
||||||
|
appStore.scenicQueue.useOfflineTTS = false
|
||||||
|
|
||||||
selectAccount(userId: "2002", type: .storeUser)
|
selectAccount(userId: "2002", type: .storeUser)
|
||||||
XCTAssertTrue(appStore.permissions.permissionItems().isEmpty)
|
XCTAssertTrue(appStore.permissions.permissionItems().isEmpty)
|
||||||
@ -80,6 +109,7 @@ final class AppStoreModuleTests: XCTestCase {
|
|||||||
XCTAssertFalse(appStore.payment.isOpenReceiveVoice)
|
XCTAssertFalse(appStore.payment.isOpenReceiveVoice)
|
||||||
XCTAssertEqual(appStore.scenicQueue.punchSpotId, "")
|
XCTAssertEqual(appStore.scenicQueue.punchSpotId, "")
|
||||||
XCTAssertNil(appStore.scenicQueue.settingsSnapshot())
|
XCTAssertNil(appStore.scenicQueue.settingsSnapshot())
|
||||||
|
XCTAssertTrue(appStore.scenicQueue.useOfflineTTS)
|
||||||
|
|
||||||
selectAccount(userId: "1001", type: .scenicUser)
|
selectAccount(userId: "1001", type: .scenicUser)
|
||||||
XCTAssertEqual(appStore.permissions.permissionItems(), permissions)
|
XCTAssertEqual(appStore.permissions.permissionItems(), permissions)
|
||||||
@ -90,24 +120,68 @@ final class AppStoreModuleTests: XCTestCase {
|
|||||||
XCTAssertTrue(appStore.payment.isOpenReceiveVoice)
|
XCTAssertTrue(appStore.payment.isOpenReceiveVoice)
|
||||||
XCTAssertTrue(appStore.scenicQueue.hasPunchSpotSaved())
|
XCTAssertTrue(appStore.scenicQueue.hasPunchSpotSaved())
|
||||||
XCTAssertEqual(appStore.scenicQueue.punchSpotName, "东门")
|
XCTAssertEqual(appStore.scenicQueue.punchSpotName, "东门")
|
||||||
|
XCTAssertEqual(appStore.scenicQueue.remark, "请提前准备")
|
||||||
XCTAssertEqual(appStore.scenicQueue.settingsSnapshot(), queueSettings)
|
XCTAssertEqual(appStore.scenicQueue.settingsSnapshot(), queueSettings)
|
||||||
XCTAssertEqual(appStore.scenicQueue.presetVoices(), ["A", "B"])
|
XCTAssertEqual(appStore.scenicQueue.presetVoices(), ["A", "B"])
|
||||||
|
XCTAssertFalse(appStore.scenicQueue.useOfflineTTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStoreAndQueueCachesAreIsolatedByScenicAndSpot() {
|
||||||
|
selectAccount(userId: "1001", type: .scenicUser, scenicId: 22)
|
||||||
|
appStore.session.currentStoreId = 220
|
||||||
|
appStore.scenicQueue.punchSpotId = "33"
|
||||||
|
appStore.scenicQueue.punchSpotName = "东门"
|
||||||
|
appStore.scenicQueue.remark = "东门备注"
|
||||||
|
appStore.scenicQueue.useOfflineTTS = false
|
||||||
|
|
||||||
|
appStore.scenicQueue.punchSpotId = "44"
|
||||||
|
XCTAssertEqual(appStore.scenicQueue.punchSpotName, "")
|
||||||
|
XCTAssertEqual(appStore.scenicQueue.remark, "")
|
||||||
|
appStore.scenicQueue.punchSpotName = "西门"
|
||||||
|
appStore.scenicQueue.remark = "西门备注"
|
||||||
|
|
||||||
|
appStore.scenicQueue.punchSpotId = "33"
|
||||||
|
XCTAssertEqual(appStore.scenicQueue.punchSpotName, "东门")
|
||||||
|
XCTAssertEqual(appStore.scenicQueue.remark, "东门备注")
|
||||||
|
|
||||||
|
appStore.session.currentScenicId = 23
|
||||||
|
XCTAssertEqual(appStore.session.currentStoreId, 0)
|
||||||
|
XCTAssertEqual(appStore.scenicQueue.punchSpotId, "")
|
||||||
|
XCTAssertTrue(appStore.scenicQueue.useOfflineTTS)
|
||||||
|
appStore.session.currentStoreId = 230
|
||||||
|
appStore.scenicQueue.punchSpotId = "55"
|
||||||
|
appStore.scenicQueue.punchSpotName = "南门"
|
||||||
|
|
||||||
|
appStore.session.currentScenicId = 22
|
||||||
|
XCTAssertEqual(appStore.session.currentStoreId, 220)
|
||||||
|
XCTAssertEqual(appStore.scenicQueue.punchSpotId, "33")
|
||||||
|
XCTAssertEqual(appStore.scenicQueue.punchSpotName, "东门")
|
||||||
|
XCTAssertFalse(appStore.scenicQueue.useOfflineTTS)
|
||||||
|
|
||||||
|
XCTAssertEqual(defaults.string(forKey: "scenic_user_1001_scenic_22_scenic_queue_punch_spot_id"), "33")
|
||||||
|
XCTAssertEqual(defaults.string(forKey: "scenic_user_1001_scenic_22_spot_33_scenic_queue_punch_spot_name"), "东门")
|
||||||
|
XCTAssertEqual(defaults.string(forKey: "scenic_user_1001_scenic_22_spot_33_scenic_queue_remark"), "东门备注")
|
||||||
|
XCTAssertFalse(defaults.dictionaryRepresentation().keys.contains { $0.contains("__scenic_queue") })
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLogoutClearsCurrentAccountModulesAndPreservesLoginPreferences() {
|
func testLogoutClearsCurrentAccountModulesAndPreservesLoginPreferences() {
|
||||||
appStore.session.lastLoginUsername = "13800000000"
|
appStore.session.lastLoginUsername = "13800000000"
|
||||||
appStore.session.privacyAgreementAccepted = true
|
appStore.session.privacyAgreementAccepted = true
|
||||||
|
|
||||||
selectAccount(userId: "other", type: .storeUser)
|
selectAccount(userId: "other", type: .storeUser, scenicId: 22)
|
||||||
appStore.scenicQueue.punchSpotId = "99"
|
appStore.scenicQueue.punchSpotId = "99"
|
||||||
|
|
||||||
selectAccount(userId: "current", type: .scenicUser)
|
selectAccount(userId: "current", type: .scenicUser, scenicId: 22)
|
||||||
appStore.session.token = "token"
|
appStore.session.token = "token"
|
||||||
appStore.permissions.legacyRoleId = 41
|
appStore.permissions.legacyRoleId = 41
|
||||||
appStore.permissions.savePermissionItems([HomePermissionItem(id: "1", name: "钱包", uri: "wallet")])
|
appStore.permissions.savePermissionItems([HomePermissionItem(id: "1", name: "钱包", uri: "wallet")])
|
||||||
appStore.location.onlineStatus = true
|
appStore.location.onlineStatus = true
|
||||||
appStore.payment.isOpenReceiveVoice = true
|
appStore.payment.isOpenReceiveVoice = true
|
||||||
appStore.scenicQueue.punchSpotId = "33"
|
appStore.scenicQueue.punchSpotId = "33"
|
||||||
|
appStore.scenicQueue.punchSpotName = "东门"
|
||||||
|
appStore.session.currentScenicId = 23
|
||||||
|
appStore.scenicQueue.punchSpotId = "44"
|
||||||
|
appStore.scenicQueue.punchSpotName = "西门"
|
||||||
|
|
||||||
appStore.logout()
|
appStore.logout()
|
||||||
|
|
||||||
@ -116,19 +190,22 @@ final class AppStoreModuleTests: XCTestCase {
|
|||||||
XCTAssertEqual(appStore.session.lastLoginUsername, "13800000000")
|
XCTAssertEqual(appStore.session.lastLoginUsername, "13800000000")
|
||||||
XCTAssertTrue(appStore.session.privacyAgreementAccepted)
|
XCTAssertTrue(appStore.session.privacyAgreementAccepted)
|
||||||
|
|
||||||
selectAccount(userId: "current", type: .scenicUser)
|
selectAccount(userId: "current", type: .scenicUser, scenicId: 22)
|
||||||
XCTAssertEqual(appStore.permissions.legacyRoleId, 0)
|
XCTAssertEqual(appStore.permissions.legacyRoleId, 0)
|
||||||
XCTAssertTrue(appStore.permissions.permissionItems().isEmpty)
|
XCTAssertTrue(appStore.permissions.permissionItems().isEmpty)
|
||||||
XCTAssertFalse(appStore.location.onlineStatus)
|
XCTAssertFalse(appStore.location.onlineStatus)
|
||||||
XCTAssertFalse(appStore.payment.isOpenReceiveVoice)
|
XCTAssertFalse(appStore.payment.isOpenReceiveVoice)
|
||||||
XCTAssertEqual(appStore.scenicQueue.punchSpotId, "")
|
XCTAssertEqual(appStore.scenicQueue.punchSpotId, "")
|
||||||
|
appStore.session.currentScenicId = 23
|
||||||
|
XCTAssertEqual(appStore.scenicQueue.punchSpotId, "")
|
||||||
|
|
||||||
selectAccount(userId: "other", type: .storeUser)
|
selectAccount(userId: "other", type: .storeUser, scenicId: 22)
|
||||||
XCTAssertEqual(appStore.scenicQueue.punchSpotId, "99")
|
XCTAssertEqual(appStore.scenicQueue.punchSpotId, "99")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func selectAccount(userId: String, type: AppAccountType) {
|
private func selectAccount(userId: String, type: AppAccountType, scenicId: Int = 22) {
|
||||||
appStore.session.userId = userId
|
appStore.session.userId = userId
|
||||||
appStore.session.accountType = type
|
appStore.session.accountType = type
|
||||||
|
appStore.session.currentScenicId = scenicId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
suixinkanTests/AppStoreTestSupport.swift
Normal file
22
suixinkanTests/AppStoreTestSupport.swift
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// AppStoreTestSupport.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
extension AppSessionStore {
|
||||||
|
|
||||||
|
/// 为需要账号作用域缓存的测试建立完整且稳定的账号上下文。
|
||||||
|
func configureTestAccount(
|
||||||
|
userId: String = "test-user",
|
||||||
|
accountType: AppAccountType = .scenicUser,
|
||||||
|
scenicId: Int? = nil
|
||||||
|
) {
|
||||||
|
self.userId = userId
|
||||||
|
self.accountType = accountType
|
||||||
|
if let scenicId {
|
||||||
|
currentScenicId = scenicId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ final class CooperationAcquirerViewModelTests: XCTestCase {
|
|||||||
defaults = UserDefaults(suiteName: "CooperationAcquirerViewModelTests")!
|
defaults = UserDefaults(suiteName: "CooperationAcquirerViewModelTests")!
|
||||||
defaults.removePersistentDomain(forName: "CooperationAcquirerViewModelTests")
|
defaults.removePersistentDomain(forName: "CooperationAcquirerViewModelTests")
|
||||||
appStore = AppStore(defaults: defaults)
|
appStore = AppStore(defaults: defaults)
|
||||||
|
appStore.session.configureTestAccount(accountType: .storeUser, scenicId: 10)
|
||||||
appStore.session.currentStoreId = 5
|
appStore.session.currentStoreId = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,9 +9,18 @@ import XCTest
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class DepositOrderListViewModelTests: XCTestCase {
|
final class DepositOrderListViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
AppStore.shared.logout()
|
||||||
|
AppStore.shared.session.configureTestAccount(
|
||||||
|
userId: "deposit-order-user",
|
||||||
|
accountType: .storeUser,
|
||||||
|
scenicId: 10
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
AppStore.shared.session.currentStoreId = 0
|
AppStore.shared.logout()
|
||||||
AppStore.shared.session.currentScenicId = 0
|
|
||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,4 +70,4 @@ final class DepositOrderListViewModelTests: XCTestCase {
|
|||||||
let other = try JSONDecoder().decode(StoreOrderItem.self, from: otherJSON)
|
let other = try JSONDecoder().decode(StoreOrderItem.self, from: otherJSON)
|
||||||
XCTAssertFalse(viewModel.canOpenDetail(for: other))
|
XCTAssertFalse(viewModel.canOpenDetail(for: other))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,14 +38,26 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
HomePermissionItem(id: "3", name: "任务", uri: "task_management"),
|
HomePermissionItem(id: "3", name: "任务", uri: "task_management"),
|
||||||
]
|
]
|
||||||
let allMenus = HomeMenuCatalog.visibleMenus(from: permissions)
|
let allMenus = HomeMenuCatalog.visibleMenus(from: permissions)
|
||||||
store.saveCommonURIs(["cloud_management"], accountScope: "u1_scenic_user", roleCode: "photographer")
|
store.saveCommonURIs(["cloud_management"], accountScope: "scenic_user_u1", roleCode: "photographer")
|
||||||
let common = store.commonMenus(
|
let common = store.commonMenus(
|
||||||
from: allMenus,
|
from: allMenus,
|
||||||
permissions: permissions,
|
permissions: permissions,
|
||||||
accountScope: "u1_scenic_user",
|
accountScope: "scenic_user_u1",
|
||||||
roleCode: "photographer"
|
roleCode: "photographer"
|
||||||
)
|
)
|
||||||
XCTAssertEqual(common.map(\.uri), ["cloud_management"])
|
XCTAssertEqual(common.map(\.uri), ["cloud_management"])
|
||||||
|
XCTAssertEqual(
|
||||||
|
defaults.stringArray(forKey: "scenic_user_u1_role_photographer_common_uris"),
|
||||||
|
["cloud_management"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIncompleteScopeDoesNotPersistCommonURIs() {
|
||||||
|
store.saveCommonURIs(["wallet"], accountScope: "", roleCode: "photographer")
|
||||||
|
store.saveCommonURIs(["wallet"], accountScope: "scenic_user_u1", roleCode: "")
|
||||||
|
|
||||||
|
XCTAssertNil(defaults.object(forKey: "_role_photographer_common_uris"))
|
||||||
|
XCTAssertNil(defaults.object(forKey: "scenic_user_u1_role__common_uris"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSplitMenusSeparatesCommonAndMore() {
|
func testSplitMenusSeparatesCommonAndMore() {
|
||||||
|
|||||||
@ -18,6 +18,7 @@ final class LocationProviderTests: XCTestCase {
|
|||||||
defaults = UserDefaults(suiteName: "LocationProviderTests")!
|
defaults = UserDefaults(suiteName: "LocationProviderTests")!
|
||||||
defaults.removePersistentDomain(forName: "LocationProviderTests")
|
defaults.removePersistentDomain(forName: "LocationProviderTests")
|
||||||
appStore = AppStore(defaults: defaults)
|
appStore = AppStore(defaults: defaults)
|
||||||
|
appStore.session.configureTestAccount()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testScenicSelectionUsesInjectedProviderSnapshot() async {
|
func testScenicSelectionUsesInjectedProviderSnapshot() async {
|
||||||
|
|||||||
@ -17,8 +17,7 @@ final class LocationReportServiceTests: XCTestCase {
|
|||||||
defaults = UserDefaults(suiteName: "LocationReportServiceTests")!
|
defaults = UserDefaults(suiteName: "LocationReportServiceTests")!
|
||||||
defaults.removePersistentDomain(forName: "LocationReportServiceTests")
|
defaults.removePersistentDomain(forName: "LocationReportServiceTests")
|
||||||
appStore = AppStore(defaults: defaults)
|
appStore = AppStore(defaults: defaults)
|
||||||
appStore.session.userId = "staff-1"
|
appStore.session.configureTestAccount(userId: "staff-1", scenicId: 100)
|
||||||
appStore.session.currentScenicId = 100
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
|
|||||||
@ -9,10 +9,14 @@ import XCTest
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class OrderListViewModelTests: XCTestCase {
|
final class OrderListViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
AppStore.shared.logout()
|
||||||
|
AppStore.shared.session.configureTestAccount(userId: "order-list-user", scenicId: 10)
|
||||||
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
AppStore.shared.session.roleCode = ""
|
AppStore.shared.logout()
|
||||||
AppStore.shared.session.roleName = ""
|
|
||||||
AppStore.shared.session.currentScenicId = 0
|
|
||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,8 +16,7 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
|||||||
let defaults = UserDefaults(suiteName: "PaymentCollectionDetailsViewModelTests")!
|
let defaults = UserDefaults(suiteName: "PaymentCollectionDetailsViewModelTests")!
|
||||||
defaults.removePersistentDomain(forName: "PaymentCollectionDetailsViewModelTests")
|
defaults.removePersistentDomain(forName: "PaymentCollectionDetailsViewModelTests")
|
||||||
appStore = AppStore(defaults: defaults)
|
appStore = AppStore(defaults: defaults)
|
||||||
appStore.session.userId = "10001"
|
appStore.session.configureTestAccount(userId: "10001", scenicId: 10)
|
||||||
appStore.session.currentScenicId = 10
|
|
||||||
appStore.session.currentScenicName = "测试景区"
|
appStore.session.currentScenicName = "测试景区"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -54,7 +54,7 @@ final class PaymentCollectionRecordViewModelTests: XCTestCase {
|
|||||||
let defaults = UserDefaults(suiteName: "PaymentCollectionRecordViewModelTests")!
|
let defaults = UserDefaults(suiteName: "PaymentCollectionRecordViewModelTests")!
|
||||||
defaults.removePersistentDomain(forName: "PaymentCollectionRecordViewModelTests")
|
defaults.removePersistentDomain(forName: "PaymentCollectionRecordViewModelTests")
|
||||||
let appStore = AppStore(defaults: defaults)
|
let appStore = AppStore(defaults: defaults)
|
||||||
appStore.session.currentScenicId = 10
|
appStore.session.configureTestAccount(scenicId: 10)
|
||||||
|
|
||||||
let recordJSON = """
|
let recordJSON = """
|
||||||
{"code":100000,"msg":"success","data":{"analyse":[{"date":"2024-01-15","order_count":1,"order_amount_sum":"50.00"}],"list":[{"order_number":"NO001","user_phone":"13800138000","order_amount":"50.00","create_date":"2024-01-15","create_time":"10:00:00"}]}}
|
{"code":100000,"msg":"success","data":{"analyse":[{"date":"2024-01-15","order_count":1,"order_amount_sum":"50.00"}],"list":[{"order_number":"NO001","user_phone":"13800138000","order_amount":"50.00","create_date":"2024-01-15","create_time":"10:00:00"}]}}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ final class PermissionApplyViewModelTests: XCTestCase {
|
|||||||
defaults = UserDefaults(suiteName: "PermissionApplyViewModelTests")!
|
defaults = UserDefaults(suiteName: "PermissionApplyViewModelTests")!
|
||||||
defaults.removePersistentDomain(forName: "PermissionApplyViewModelTests")
|
defaults.removePersistentDomain(forName: "PermissionApplyViewModelTests")
|
||||||
appStore = AppStore(defaults: defaults)
|
appStore = AppStore(defaults: defaults)
|
||||||
|
appStore.session.configureTestAccount()
|
||||||
appStore.permissions.saveRolePermissionList([
|
appStore.permissions.saveRolePermissionList([
|
||||||
RolePermissionResponse(
|
RolePermissionResponse(
|
||||||
role: RoleInfo(id: 10, name: "摄影师", roleCode: "PHOTOGRAPHER", notes: "拍摄"),
|
role: RoleInfo(id: 10, name: "摄影师", roleCode: "PHOTOGRAPHER", notes: "拍摄"),
|
||||||
|
|||||||
@ -20,14 +20,16 @@ final class ProfileSpaceSettingsViewModelTests: XCTestCase {
|
|||||||
defaults = UserDefaults(suiteName: "ProfileSpaceSettingsViewModelTests")!
|
defaults = UserDefaults(suiteName: "ProfileSpaceSettingsViewModelTests")!
|
||||||
defaults.removePersistentDomain(forName: "ProfileSpaceSettingsViewModelTests")
|
defaults.removePersistentDomain(forName: "ProfileSpaceSettingsViewModelTests")
|
||||||
appStore = AppStore(defaults: defaults)
|
appStore = AppStore(defaults: defaults)
|
||||||
appStore.session.currentScenicId = 11
|
appStore.session.configureTestAccount(scenicId: 11)
|
||||||
|
AppStore.shared.logout()
|
||||||
|
AppStore.shared.session.configureTestAccount(userId: "profile-space-shared", scenicId: 11)
|
||||||
calendar = Calendar(identifier: .gregorian)
|
calendar = Calendar(identifier: .gregorian)
|
||||||
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||||
fixedNow = calendar.date(from: DateComponents(year: 2026, month: 7, day: 8))!
|
fixedNow = calendar.date(from: DateComponents(year: 2026, month: 7, day: 8))!
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
AppStore.shared.session.currentScenicId = 0
|
AppStore.shared.logout()
|
||||||
appStore.logout()
|
appStore.logout()
|
||||||
defaults.removePersistentDomain(forName: "ProfileSpaceSettingsViewModelTests")
|
defaults.removePersistentDomain(forName: "ProfileSpaceSettingsViewModelTests")
|
||||||
super.tearDown()
|
super.tearDown()
|
||||||
|
|||||||
@ -9,6 +9,18 @@ import XCTest
|
|||||||
@MainActor
|
@MainActor
|
||||||
/// 个人信息页 ViewModel 测试。
|
/// 个人信息页 ViewModel 测试。
|
||||||
final class ProfileViewModelTests: XCTestCase {
|
final class ProfileViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
AppStore.shared.logout()
|
||||||
|
AppStore.shared.session.configureTestAccount(userId: "profile-user")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
AppStore.shared.logout()
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
func testDisplayNicknameUsesFallbackWhenEmpty() {
|
func testDisplayNicknameUsesFallbackWhenEmpty() {
|
||||||
let viewModel = ProfileViewModel()
|
let viewModel = ProfileViewModel()
|
||||||
XCTAssertEqual(viewModel.displayNickname, "未设置昵称")
|
XCTAssertEqual(viewModel.displayNickname, "未设置昵称")
|
||||||
|
|||||||
@ -250,6 +250,7 @@ final class ScenicQueueFeatureTests: XCTestCase {
|
|||||||
|
|
||||||
store.session.userId = "1"
|
store.session.userId = "1"
|
||||||
store.session.accountType = .unknown("photog")
|
store.session.accountType = .unknown("photog")
|
||||||
|
store.session.currentScenicId = 11
|
||||||
store.scenicQueue.punchSpotId = "22"
|
store.scenicQueue.punchSpotId = "22"
|
||||||
store.scenicQueue.punchSpotName = "东门打卡点"
|
store.scenicQueue.punchSpotName = "东门打卡点"
|
||||||
store.scenicQueue.remark = "播报"
|
store.scenicQueue.remark = "播报"
|
||||||
@ -258,16 +259,19 @@ final class ScenicQueueFeatureTests: XCTestCase {
|
|||||||
|
|
||||||
store.session.userId = "2"
|
store.session.userId = "2"
|
||||||
store.session.accountType = .unknown("photog")
|
store.session.accountType = .unknown("photog")
|
||||||
|
store.session.currentScenicId = 11
|
||||||
XCTAssertEqual(store.scenicQueue.punchSpotId, "")
|
XCTAssertEqual(store.scenicQueue.punchSpotId, "")
|
||||||
XCTAssertNil(store.scenicQueue.settingsSnapshot())
|
XCTAssertNil(store.scenicQueue.settingsSnapshot())
|
||||||
|
|
||||||
store.session.userId = "1"
|
store.session.userId = "1"
|
||||||
store.session.accountType = .unknown("photog")
|
store.session.accountType = .unknown("photog")
|
||||||
|
store.session.currentScenicId = 11
|
||||||
XCTAssertEqual(store.scenicQueue.punchSpotId, "22")
|
XCTAssertEqual(store.scenicQueue.punchSpotId, "22")
|
||||||
XCTAssertEqual(store.scenicQueue.presetVoices(), ["a", "b", "c", "d", "e"])
|
XCTAssertEqual(store.scenicQueue.presetVoices(), ["a", "b", "c", "d", "e"])
|
||||||
store.logout()
|
store.logout()
|
||||||
store.session.userId = "1"
|
store.session.userId = "1"
|
||||||
store.session.accountType = .unknown("photog")
|
store.session.accountType = .unknown("photog")
|
||||||
|
store.session.currentScenicId = 11
|
||||||
XCTAssertEqual(store.scenicQueue.punchSpotId, "")
|
XCTAssertEqual(store.scenicQueue.punchSpotId, "")
|
||||||
XCTAssertNil(store.scenicQueue.settingsSnapshot())
|
XCTAssertNil(store.scenicQueue.settingsSnapshot())
|
||||||
}
|
}
|
||||||
@ -323,6 +327,8 @@ final class ScenicQueueFeatureTests: XCTestCase {
|
|||||||
let store = AppStore(defaults: defaults)
|
let store = AppStore(defaults: defaults)
|
||||||
store.session.userId = "u1"
|
store.session.userId = "u1"
|
||||||
store.session.accountType = .unknown("photog")
|
store.session.accountType = .unknown("photog")
|
||||||
|
store.session.currentScenicId = 11
|
||||||
|
XCTAssertTrue(store.scenicQueue.useOfflineTTS)
|
||||||
let tts = MockScenicQueueTTSManager()
|
let tts = MockScenicQueueTTSManager()
|
||||||
let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts)
|
let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts)
|
||||||
var messages: [String] = []
|
var messages: [String] = []
|
||||||
|
|||||||
@ -16,6 +16,7 @@ final class ScenicSelectionViewModelTests: XCTestCase {
|
|||||||
defaults = UserDefaults(suiteName: "ScenicSelectionViewModelTests")!
|
defaults = UserDefaults(suiteName: "ScenicSelectionViewModelTests")!
|
||||||
defaults.removePersistentDomain(forName: "ScenicSelectionViewModelTests")
|
defaults.removePersistentDomain(forName: "ScenicSelectionViewModelTests")
|
||||||
appStore = AppStore(defaults: defaults)
|
appStore = AppStore(defaults: defaults)
|
||||||
|
appStore.session.configureTestAccount()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
|
|||||||
@ -10,11 +10,14 @@ import XCTest
|
|||||||
/// 数据统计 ViewModel 测试。
|
/// 数据统计 ViewModel 测试。
|
||||||
final class StatisticsViewModelTests: XCTestCase {
|
final class StatisticsViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
AppStore.shared.logout()
|
||||||
|
AppStore.shared.session.configureTestAccount(userId: "statistics-user", scenicId: 10)
|
||||||
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
AppStore.shared.session.roleCode = ""
|
AppStore.shared.logout()
|
||||||
AppStore.shared.session.roleName = ""
|
|
||||||
AppStore.shared.session.currentScenicId = 0
|
|
||||||
AppStore.shared.session.currentStoreId = 0
|
|
||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ final class TaskAddViewModelTests: XCTestCase {
|
|||||||
defaults = UserDefaults(suiteName: "TaskAddViewModelTests")!
|
defaults = UserDefaults(suiteName: "TaskAddViewModelTests")!
|
||||||
defaults.removePersistentDomain(forName: "TaskAddViewModelTests")
|
defaults.removePersistentDomain(forName: "TaskAddViewModelTests")
|
||||||
appStore = AppStore(defaults: defaults)
|
appStore = AppStore(defaults: defaults)
|
||||||
appStore.session.currentScenicId = 10
|
appStore.session.configureTestAccount(scenicId: 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testValidateRequiresTaskName() async {
|
func testValidateRequiresTaskName() async {
|
||||||
|
|||||||
@ -17,7 +17,7 @@ final class TaskOrderSelectViewModelTests: XCTestCase {
|
|||||||
defaults = UserDefaults(suiteName: "TaskOrderSelectViewModelTests")!
|
defaults = UserDefaults(suiteName: "TaskOrderSelectViewModelTests")!
|
||||||
defaults.removePersistentDomain(forName: "TaskOrderSelectViewModelTests")
|
defaults.removePersistentDomain(forName: "TaskOrderSelectViewModelTests")
|
||||||
appStore = AppStore(defaults: defaults)
|
appStore = AppStore(defaults: defaults)
|
||||||
appStore.session.currentScenicId = 10
|
appStore.session.configureTestAccount(scenicId: 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testToggleSelectionSelectsAndDeselects() {
|
func testToggleSelectionSelectsAndDeselects() {
|
||||||
|
|||||||
Reference in New Issue
Block a user