同步个人空间配置页面

This commit is contained in:
2026-07-08 12:31:03 +08:00
parent 8a65d3c104
commit e88cd4a9a4
6 changed files with 2280 additions and 1 deletions

View File

@ -0,0 +1,334 @@
//
// ProfileSpaceSettingsViewModelTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
@MainActor
/// ViewModel
final class ProfileSpaceSettingsViewModelTests: XCTestCase {
private var defaults: UserDefaults!
private var appStore: AppStore!
private var calendar: Calendar!
private var fixedNow: Date!
override func setUp() {
super.setUp()
defaults = UserDefaults(suiteName: "ProfileSpaceSettingsViewModelTests")!
defaults.removePersistentDomain(forName: "ProfileSpaceSettingsViewModelTests")
appStore = AppStore(defaults: defaults)
appStore.currentScenicId = 11
calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
fixedNow = calendar.date(from: DateComponents(year: 2026, month: 7, day: 8))!
}
override func tearDown() {
AppStore.shared.currentScenicId = 0
appStore.logout()
defaults.removePersistentDomain(forName: "ProfileSpaceSettingsViewModelTests")
super.tearDown()
}
func testProfileInfoDecodeSupportsEmptyStringsArraysAndScheduleMap() throws {
let json = """
{
"id": "9",
"scenic_id": "11",
"photog_uid": "",
"real_name": "",
"nickname": "",
"avatar": "",
"scenic_certification": ["西", ""],
"description": "",
"attr_label": ["", 8],
"shoot_label": "",
"camera_device": ["Sony A7"],
"business_start_time": "09:00:00",
"business_end_time": "18:00:00",
"holiday_start_time": "",
"holiday_end_time": "20:00:00",
"business_range": ["1", 2],
"accept_order_status": "1",
"virtual_phone": "",
"schedule": {
"2026-07-08": [{
"id": "22",
"name": "",
"remark": "",
"start_time": "10:00",
"end_time": "11:00",
"schedule_date": "2026-07-08",
"order_number": "NO1",
"user_nickname": "",
"user_phone": "13800000000",
"order_status": "2",
"order_status_name": ""
}]
}
}
""".data(using: .utf8)!
let response = try JSONDecoder().decode(PhotographerProfileInfoResponse.self, from: json)
XCTAssertEqual(response.id, 9)
XCTAssertEqual(response.photogUid, 0)
XCTAssertEqual(response.attrLabel, ["航拍", "8"])
XCTAssertEqual(response.shootLabel, [])
XCTAssertEqual(response.businessRange, [1, 2])
XCTAssertEqual(response.schedule["2026-07-08"]?.first?.id, 22)
XCTAssertEqual(response.schedule["2026-07-08"]?.first?.userPhone, "13800000000")
}
func testLabelValidationCoversEmptyTooLongDuplicateAndLimit() {
let viewModel = makeViewModel()
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
viewModel.addAttrLabel(" ")
XCTAssertEqual(messages.last, "标签不能为空")
viewModel.addAttrLabel(String(repeating: "a", count: 21))
XCTAssertEqual(messages.last, "标签最多20个字符")
viewModel.addAttrLabel("航拍")
viewModel.addAttrLabel("航拍")
XCTAssertEqual(viewModel.attrLabels, ["航拍"])
XCTAssertEqual(messages.last, "标签已存在")
for index in 1...7 {
viewModel.addAttrLabel("标签\(index)")
}
viewModel.addAttrLabel("第九个")
XCTAssertEqual(viewModel.attrLabels.count, 8)
XCTAssertEqual(messages.last, "最多8个标签")
}
func testBusinessTimeValidationRejectsInvalidRanges() throws {
let viewModel = makeViewModel()
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
viewModel.updateBusinessEndTime("10:00")
viewModel.updateBusinessStartTime("11:00")
XCTAssertNil(viewModel.businessStartTime)
XCTAssertEqual(messages.last, "开始时间不能大于结束时间")
viewModel.updateHolidayStartTime("13:00")
viewModel.updateHolidayEndTime("12:00")
XCTAssertNil(viewModel.holidayEndTime)
XCTAssertEqual(messages.last, "结束时间不能小于开始时间")
viewModel.updateBusinessStartTime("09:00")
viewModel.updateBusinessEndTime("18:00")
viewModel.updateHolidayStartTime("08:00")
viewModel.updateHolidayEndTime("20:00")
viewModel.updateBusinessEndTime("08:00")
XCTAssertThrowsError(try viewModel.makeSaveRequest())
}
func testHasChangesAndSaveRequestFieldsMatchAndroid() async throws {
let api = FakeProfileSpaceSettingsAPI(response: .loaded())
let viewModel = makeViewModel()
await viewModel.load(api: api)
XCTAssertFalse(viewModel.hasChanges)
viewModel.updateIntroduction("更新后的简介")
viewModel.addDevice("Nikon Z8")
viewModel.updateAcceptOrderStatus(2)
XCTAssertTrue(viewModel.hasChanges)
await viewModel.save(api: api)
let request = try XCTUnwrap(api.lastUpdateRequest)
XCTAssertEqual(request.scenicId, "11")
XCTAssertEqual(request.description, "更新后的简介")
XCTAssertEqual(request.cameraDevice, ["Sony A7", "Nikon Z8"])
XCTAssertEqual(request.attrLabel, ["航拍"])
XCTAssertEqual(request.shootLabel, ["亲子"])
XCTAssertEqual(request.businessStartTime, "09:00:00")
XCTAssertEqual(request.businessEndTime, "18:00:00")
XCTAssertEqual(request.holidayStartTime, "10:00:00")
XCTAssertEqual(request.holidayEndTime, "20:00:00")
XCTAssertEqual(request.businessRange, [])
XCTAssertEqual(request.acceptOrderStatus, 2)
XCTAssertFalse(viewModel.hasChanges)
}
func testAddScheduleValidationAndDeleteId() async throws {
let api = FakeProfileSpaceSettingsAPI(response: .loaded())
let viewModel = makeViewModel()
let scheduleDate = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 7, day: 9)))
XCTAssertThrowsError(try viewModel.makeAddScheduleRequest(
name: "",
remark: "内容",
startTime: "10:00",
endTime: "11:00",
scheduleDate: scheduleDate,
orderNumber: nil
)) { error in
XCTAssertEqual(error as? ProfileSpaceSettingsValidationError, .emptyScheduleName)
}
XCTAssertThrowsError(try viewModel.makeAddScheduleRequest(
name: "日程",
remark: "内容",
startTime: "12:00",
endTime: "11:00",
scheduleDate: scheduleDate,
orderNumber: nil
)) { error in
XCTAssertEqual(error as? ProfileSpaceSettingsValidationError, .invalidScheduleTime)
}
let didAdd = await viewModel.addSchedule(
name: " 日程 ",
remark: "内容",
startTime: "10:00",
endTime: "11:00",
scheduleDate: scheduleDate,
orderNumber: " ",
api: api
)
XCTAssertTrue(didAdd)
XCTAssertEqual(api.lastAddScheduleRequest?.scenicId, "11")
XCTAssertEqual(api.lastAddScheduleRequest?.name, "日程")
XCTAssertEqual(api.lastAddScheduleRequest?.scheduleDate, "2026-07-09")
XCTAssertNil(api.lastAddScheduleRequest?.orderNumber)
await viewModel.deleteSchedule(id: 33, api: api)
XCTAssertEqual(api.lastDeletedScheduleId, 33)
}
func testSpaceSettingsRoutePushesProfileSpaceSettingsPage() {
AppStore.shared.currentScenicId = 11
let root = UIViewController()
let navigationController = UINavigationController(rootViewController: root)
HomeRouteHandler.open(uri: "space_settings", from: root, storeItem: nil)
XCTAssertTrue(navigationController.topViewController is ProfileSpaceSettingsViewController)
}
func testSystemSettingsRouteStillSelectsProfileTab() {
AppStore.shared.currentScenicId = 11
let tabBarController = MainTabBarController()
tabBarController.loadViewIfNeeded()
guard
let navigationController = tabBarController.selectedViewController as? UINavigationController,
let root = navigationController.viewControllers.first
else {
XCTFail("首页 Tab 未正确初始化")
return
}
HomeRouteHandler.open(uri: "system_settings", from: root, storeItem: nil)
XCTAssertEqual(tabBarController.selectedIndex, 4)
}
private func makeViewModel() -> ProfileSpaceSettingsViewModel {
ProfileSpaceSettingsViewModel(
appStore: appStore,
calendar: calendar,
nowProvider: { self.fixedNow }
)
}
}
@MainActor
private final class FakeProfileSpaceSettingsAPI: ProfileSpaceSettingsAPI {
var response: PhotographerProfileInfoResponse
var lastUpdateRequest: UpdatePhotographerProfileInfoRequest?
var lastAddScheduleRequest: AddPhotographerScheduleRequest?
var lastDeletedScheduleId: Int?
init(response: PhotographerProfileInfoResponse) {
self.response = response
}
func profileInfo(scenicId: Int) async throws -> PhotographerProfileInfoResponse {
response
}
func updateProfileInfo(_ request: UpdatePhotographerProfileInfoRequest) async throws {
lastUpdateRequest = request
response = PhotographerProfileInfoResponse(
id: response.id,
scenicId: Int(request.scenicId) ?? 0,
photogUid: response.photogUid,
realName: response.realName,
nickname: response.nickname,
avatar: response.avatar,
scenicCertification: response.scenicCertification,
description: request.description,
attrLabel: request.attrLabel,
shootLabel: request.shootLabel,
cameraDevice: request.cameraDevice,
businessStartTime: request.businessStartTime,
businessEndTime: request.businessEndTime,
holidayStartTime: request.holidayStartTime,
holidayEndTime: request.holidayEndTime,
businessRange: request.businessRange,
acceptOrderStatus: request.acceptOrderStatus,
schedule: response.schedule,
virtualPhone: response.virtualPhone
)
}
func updateUserInfo(nickname: String?, password: String?, avatar: String?) async throws {}
func addSchedule(_ request: AddPhotographerScheduleRequest) async throws {
lastAddScheduleRequest = request
}
func deleteSchedule(id: Int) async throws {
lastDeletedScheduleId = id
}
}
private extension PhotographerProfileInfoResponse {
static func loaded() -> PhotographerProfileInfoResponse {
PhotographerProfileInfoResponse(
id: 1,
scenicId: 11,
photogUid: 100,
realName: "林摄影",
nickname: "小林",
avatar: "https://example.com/a.jpg",
scenicCertification: ["西湖"],
description: "简介",
attrLabel: ["航拍"],
shootLabel: ["亲子"],
cameraDevice: ["Sony A7"],
businessStartTime: "09:00:00",
businessEndTime: "18:00:00",
holidayStartTime: "10:00:00",
holidayEndTime: "20:00:00",
businessRange: [],
acceptOrderStatus: 1,
schedule: [
"2026-07-08": [
PhotographerSchedule(
id: 22,
name: "旅拍",
remark: "西湖",
startTime: "10:00",
endTime: "11:00",
scheduleDate: "2026-07-08",
userNickname: "游客",
userPhone: "13800000000"
),
],
],
virtualPhone: ""
)
}
}