Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,563 @@
//
// ScenicPermissionViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/23.
//
import Foundation
import XCTest
@testable import suixinkan_ios
@MainActor
///
final class ScenicPermissionViewModelTests: XCTestCase {
/// ID
func testPermissionApplyDeduplicatesRoles() {
let viewModel = PermissionApplyViewModel()
viewModel.bootstrap(rolePermissions: [
RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: []),
RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师重复"), scenic: [])
])
XCTAssertEqual(viewModel.roleOptions.map(\.id), [1])
XCTAssertEqual(viewModel.roleOptions.first?.name, "摄影师")
}
///
func testPermissionApplyDisablesExistingScenics() async {
let api = ScenicPermissionServingFake()
api.scenicList = ScenicListAllResponse(total: 2, list: [
ScenicListItem(id: 1, name: "已有景区"),
ScenicListItem(id: 2, name: "新增景区")
])
let viewModel = PermissionApplyViewModel()
viewModel.bootstrap(rolePermissions: [
RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: [ScenicInfo(id: 1, name: "已有景区")])
])
viewModel.selectRole(id: 1)
await viewModel.loadScenicListIfNeeded(api: api)
viewModel.toggleScenic(id: 1)
viewModel.toggleScenic(id: 2)
XCTAssertTrue(viewModel.scenicOptions.first { $0.id == 1 }?.disabled == true)
XCTAssertEqual(viewModel.selectedScenicIds, [2])
XCTAssertTrue(viewModel.canSubmit)
}
///
func testPermissionApplyChangingRoleClearsSelection() async {
let api = ScenicPermissionServingFake()
api.scenicList = ScenicListAllResponse(total: 1, list: [ScenicListItem(id: 2, name: "新增景区")])
let viewModel = PermissionApplyViewModel()
viewModel.bootstrap(rolePermissions: [
RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: []),
RolePermissionResponse(role: RoleInfo(id: 2, name: "店长"), scenic: [])
])
viewModel.selectRole(id: 1)
await viewModel.loadScenicListIfNeeded(api: api)
viewModel.toggleScenic(id: 2)
viewModel.selectRole(id: 2)
XCTAssertTrue(viewModel.selectedScenicIds.isEmpty)
XCTAssertTrue(viewModel.scenicOptions.isEmpty)
}
///
func testPermissionApplyRequiresRoleAndScenicBeforeSubmit() {
let viewModel = PermissionApplyViewModel()
XCTAssertFalse(viewModel.canSubmit)
viewModel.bootstrap(rolePermissions: [RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: [])])
viewModel.selectRole(id: 1)
XCTAssertFalse(viewModel.canSubmit)
}
///
func testPermissionApplySubmitSuccessAndFailure() async {
let api = ScenicPermissionServingFake()
let viewModel = PermissionApplyViewModel()
viewModel.bootstrap(rolePermissions: [RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: [])])
viewModel.selectRole(id: 1)
viewModel.scenicOptions = [PermissionScenicOption(id: 2, name: "新增景区", selected: false, disabled: false)]
viewModel.toggleScenic(id: 2)
await viewModel.submit(api: api)
XCTAssertEqual(api.submittedRoleId, 1)
XCTAssertEqual(api.submittedScenicIds, [2])
XCTAssertEqual(viewModel.message, "提交成功,等待审核")
api.roleApplySubmitError = TestError.sample
await viewModel.submit(api: api)
XCTAssertEqual(viewModel.message, TestError.sample.localizedDescription)
}
///
func testScenicApplicationAreaFailureMarksLoadFailed() async {
let api = ScenicPermissionServingFake()
api.areasError = TestError.sample
let viewModel = ScenicApplicationViewModel()
await viewModel.loadInitial(api: api)
XCTAssertTrue(viewModel.loadFailed)
XCTAssertTrue(viewModel.provinces.isEmpty)
}
///
func testScenicApplicationPendingRefillsForm() async {
let api = ScenicPermissionServingFake()
api.areas = [ScenicAreaNode(id: "zj", name: "浙江", children: [ScenicAreaNode(id: "hz", name: "杭州")])]
api.scenicPendings = ScenicApplicationPendingsResponse(items: [
ScenicApplicationPendingResponse(
id: 1,
code: "A001",
scenicId: 9,
scenicName: "新景区",
scenicImages: "https://a.com/1.jpg,https://a.com/2.jpg",
scenicProvince: "浙江",
scenicCity: "杭州",
coopType: 3,
remark: "备注",
status: 3
)
])
let viewModel = ScenicApplicationViewModel()
await viewModel.loadInitial(api: api)
XCTAssertEqual(viewModel.scenicName, "新景区")
XCTAssertEqual(viewModel.remoteImageURLs.count, 2)
XCTAssertEqual(viewModel.selectedProvince, "浙江")
XCTAssertEqual(viewModel.selectedCity, "杭州")
XCTAssertEqual(viewModel.coopType, 3)
XCTAssertFalse(viewModel.isReadOnly)
}
///
func testScenicApplicationValidation() {
let viewModel = ScenicApplicationViewModel()
XCTAssertEqual(viewModel.validationMessage, "请输入景区名称")
viewModel.scenicName = "新景区"
XCTAssertEqual(viewModel.validationMessage, "请至少上传一张景区图片")
viewModel.imageURLs = "https://a.com/1.jpg"
XCTAssertEqual(viewModel.validationMessage, "请选择省份")
viewModel.selectedProvince = "浙江"
XCTAssertEqual(viewModel.validationMessage, "请选择城市")
viewModel.selectedCity = "杭州"
XCTAssertEqual(viewModel.validationMessage, "请先阅读并同意用户须知与隐私政策")
viewModel.agreed = true
XCTAssertNil(viewModel.validationMessage)
}
/// OSS URL
func testScenicApplicationUploadsBeforeSubmit() async {
let api = ScenicPermissionServingFake()
let uploader = OSSUploadServingFake()
uploader.uploadedURLs = ["https://oss.com/scenic.jpg"]
let viewModel = ScenicApplicationViewModel()
viewModel.scenicName = "新景区"
viewModel.selectedProvince = "浙江"
viewModel.selectedCity = "杭州"
viewModel.agreed = true
viewModel.addLocalImage(data: Data([1, 2, 3]), fileName: "local.jpg")
await viewModel.submit(api: api, uploader: uploader)
XCTAssertEqual(uploader.uploadedFileNames, ["local.jpg"])
XCTAssertEqual(api.submittedScenicRequest?.scenicImages, ["https://oss.com/scenic.jpg"])
XCTAssertEqual(api.uploadPlaceholders.first?.fileName, "local.jpg")
XCTAssertEqual(viewModel.message, "提交成功")
}
///
func testScenicApplicationUploadFailureDoesNotSubmit() async {
let api = ScenicPermissionServingFake()
let uploader = OSSUploadServingFake()
uploader.uploadError = TestError.sample
let viewModel = ScenicApplicationViewModel()
viewModel.scenicName = "新景区"
viewModel.selectedProvince = "浙江"
viewModel.selectedCity = "杭州"
viewModel.agreed = true
viewModel.addLocalImage(data: Data([1]), fileName: "local.jpg")
await viewModel.submit(api: api, uploader: uploader)
XCTAssertNil(api.submittedScenicRequest)
XCTAssertEqual(viewModel.message, TestError.sample.localizedDescription)
}
///
func testPermissionApplyBootstrapWithRejectedPending() {
let pending = RoleApplyPendingResponse(
id: 9,
code: "AP-001",
roleId: 2,
roleName: "店长",
scenicList: [RoleApplyScenicItem(id: 3, name: "驳回景区")],
status: 3
)
let viewModel = PermissionApplyViewModel(initialPending: pending)
viewModel.bootstrap(rolePermissions: [
RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师", notes: "拍摄"), scenic: []),
RolePermissionResponse(role: RoleInfo(id: 2, name: "店长", notes: "门店管理"), scenic: [])
])
XCTAssertEqual(viewModel.selectedRoleId, 2)
XCTAssertEqual(viewModel.selectedScenicIds, [3])
XCTAssertEqual(viewModel.selectedRoleName, "店长")
XCTAssertEqual(viewModel.selectedRoleNotes, "门店管理")
}
///
func testPermissionApplySelectedScenicSummary() {
let viewModel = PermissionApplyViewModel()
viewModel.scenicOptions = [
PermissionScenicOption(id: 1, name: "景区A", selected: false, disabled: false),
PermissionScenicOption(id: 2, name: "景区B", selected: false, disabled: false)
]
viewModel.toggleScenic(id: 1)
viewModel.toggleScenic(id: 2)
XCTAssertEqual(viewModel.selectedScenicSummary, "已选择 2 个景区")
viewModel.toggleScenic(id: 2)
XCTAssertEqual(viewModel.selectedScenicSummary, "景区A")
}
///
func testPermissionApplyExistingRolesAndScenicLoadFailure() async {
let api = ScenicPermissionServingFake()
api.scenicListError = TestError.sample
let viewModel = PermissionApplyViewModel()
viewModel.bootstrap(rolePermissions: [
RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: [ScenicInfo(id: 1, name: "已有景区")])
])
viewModel.selectRole(id: 1)
await viewModel.loadScenicListIfNeeded(api: api)
XCTAssertEqual(viewModel.existingRoleInfos.first?.scenics.map(\.id), [1])
XCTAssertTrue(viewModel.scenicLoadFailed)
XCTAssertEqual(viewModel.scenicLoadFailureReason, TestError.sample.localizedDescription)
XCTAssertEqual(viewModel.message, "景区列表加载失败,请重试")
XCTAssertTrue(viewModel.selectedScenicIds.isEmpty)
}
///
func testPermissionApplyStatusLoadByApplyCode() async {
let api = ScenicPermissionServingFake()
api.roleApplies = [
RoleApplyPendingResponse(id: 1, code: "AP-001", roleId: 1, roleName: "摄影师", status: 2),
RoleApplyPendingResponse(id: 2, code: "AP-002", roleId: 2, roleName: "店长", status: 1)
]
let viewModel = PermissionApplyStatusViewModel()
await viewModel.load(api: api, applyCode: " AP-002 ")
XCTAssertEqual(viewModel.pending?.code, "AP-002")
XCTAssertFalse(viewModel.loadFailed)
}
///
func testPermissionApplyStatusLoadPendingOrRejectedWithoutCode() async {
let api = ScenicPermissionServingFake()
api.roleApplies = [
RoleApplyPendingResponse(id: 1, code: "AP-001", roleId: 1, roleName: "摄影师", status: 2),
RoleApplyPendingResponse(id: 2, code: "AP-002", roleId: 2, roleName: "店长", status: 3)
]
let viewModel = PermissionApplyStatusViewModel()
await viewModel.load(api: api, applyCode: nil)
XCTAssertEqual(viewModel.pending?.status, 3)
}
///
func testPermissionApplyStatusLoadFailure() async {
let api = ScenicPermissionServingFake()
api.roleApplyAllError = TestError.sample
let viewModel = PermissionApplyStatusViewModel()
await viewModel.load(api: api, applyCode: "AP-001")
XCTAssertNil(viewModel.pending)
XCTAssertTrue(viewModel.loadFailed)
XCTAssertEqual(viewModel.loadFailureReason, TestError.sample.localizedDescription)
}
///
func testScenicApplicationReadOnlyWhenPendingReview() async {
let api = ScenicPermissionServingFake()
api.areas = [ScenicAreaNode(id: "zj", name: "浙江", children: [ScenicAreaNode(id: "hz", name: "杭州")])]
api.scenicPendings = ScenicApplicationPendingsResponse(items: [
ScenicApplicationPendingResponse(
id: 1,
code: "A001",
scenicId: 9,
scenicName: "审核中景区",
scenicImages: "https://a.com/1.jpg",
scenicProvince: "浙江",
scenicCity: "杭州",
coopType: 1,
remark: nil,
status: 1
)
])
let viewModel = ScenicApplicationViewModel()
await viewModel.loadInitial(api: api)
XCTAssertTrue(viewModel.isReadOnly)
XCTAssertTrue(viewModel.isSubmitLocked)
XCTAssertFalse(viewModel.canSubmit)
}
///
func testScenicApplicationOnProvinceChangeClearsInvalidCity() {
let viewModel = ScenicApplicationViewModel()
viewModel.provinces = [
ScenicAreaNode(id: "zj", name: "浙江", children: [ScenicAreaNode(id: "hz", name: "杭州")]),
ScenicAreaNode(id: "js", name: "江苏", children: [ScenicAreaNode(id: "nj", name: "南京")])
]
viewModel.selectedProvince = "浙江"
viewModel.onProvinceChange()
viewModel.selectedCity = "杭州"
viewModel.selectedProvince = "江苏"
viewModel.onProvinceChange()
XCTAssertEqual(viewModel.cities.map(\.name), ["南京"])
XCTAssertEqual(viewModel.selectedCity, "")
}
///
func testScenicApplicationRemoteImageParsingAndRemoval() {
let viewModel = ScenicApplicationViewModel()
viewModel.imageURLs = "https://a.com/1.jpg,https://a.com/2.jpg\nhttps://a.com/3.jpg"
XCTAssertEqual(viewModel.remoteImageURLs, [
"https://a.com/1.jpg",
"https://a.com/2.jpg",
"https://a.com/3.jpg"
])
XCTAssertEqual(viewModel.uploadPlaceholderCount, 3)
viewModel.removeRemoteImage("https://a.com/2.jpg")
XCTAssertEqual(viewModel.remoteImageURLs, ["https://a.com/1.jpg", "https://a.com/3.jpg"])
}
/// 20
func testScenicApplicationMaxImageLimit() {
let viewModel = ScenicApplicationViewModel()
viewModel.imageURLs = Array(repeating: "https://a.com/x.jpg", count: 19).joined(separator: "\n")
viewModel.addLocalImage(data: Data([1]), fileName: "local.jpg")
XCTAssertEqual(viewModel.uploadPlaceholderCount, 20)
viewModel.addLocalImage(data: Data([2]), fileName: "overflow.jpg")
XCTAssertEqual(viewModel.uploadPlaceholderCount, 20)
XCTAssertEqual(viewModel.message, "最多上传20张景区图片")
}
/// URL
func testScenicUploadPlaceholderPolicy() {
XCTAssertEqual(
ScenicUploadPlaceholderPolicy.fileName(from: "https://cdn.example.com/scenic/a.jpg?x=1#preview", fallbackPrefix: "scenic"),
"a.jpg"
)
XCTAssertEqual(ScenicUploadPlaceholderPolicy.fileType(from: "clip.mp4"), "video")
XCTAssertEqual(ScenicUploadPlaceholderPolicy.fileType(from: "doc.pdf"), "file")
XCTAssertEqual(ScenicUploadPlaceholderPolicy.fileType(from: "photo.heic"), "image")
}
/// OSS
func testScenicApplicationSubmitWithRemoteImagesOnly() async {
let api = ScenicPermissionServingFake()
let uploader = OSSUploadServingFake()
let viewModel = ScenicApplicationViewModel()
viewModel.scenicName = "新景区"
viewModel.imageURLs = "https://a.com/1.jpg"
viewModel.selectedProvince = "浙江"
viewModel.selectedCity = "杭州"
viewModel.agreed = true
await viewModel.submit(api: api, uploader: uploader)
XCTAssertTrue(uploader.uploadedFileNames.isEmpty)
XCTAssertEqual(api.submittedScenicRequest?.scenicImages, ["https://a.com/1.jpg"])
XCTAssertEqual(api.uploadPlaceholders.count, 1)
XCTAssertEqual(viewModel.message, "提交成功")
}
///
func testScenicApplicationClearPendingWhenNoMatchingStatus() async {
let api = ScenicPermissionServingFake()
api.areas = [ScenicAreaNode(id: "zj", name: "浙江", children: [ScenicAreaNode(id: "hz", name: "杭州")])]
api.scenicPendings = ScenicApplicationPendingsResponse(items: [
ScenicApplicationPendingResponse(
id: 1,
code: "A001",
scenicId: 9,
scenicName: "已通过",
scenicImages: "",
scenicProvince: "浙江",
scenicCity: "杭州",
coopType: 1,
remark: nil,
status: 2
)
])
let viewModel = ScenicApplicationViewModel()
viewModel.scenicName = "草稿"
await viewModel.loadInitial(api: api)
XCTAssertNil(viewModel.pending)
XCTAssertEqual(viewModel.scenicName, "")
XCTAssertFalse(viewModel.agreed)
}
}
@MainActor
/// ViewModel
private final class ScenicPermissionServingFake: ScenicPermissionServing {
var scenicList = ScenicListAllResponse(total: 0, list: [])
var scenicListError: Error?
var areas: [ScenicAreaNode] = []
var areasError: Error?
var scenicPendings = ScenicApplicationPendingsResponse()
var scenicPendingsError: Error?
var submittedScenicRequest: ScenicApplicationSubmitRequest?
var uploadPlaceholders: [ScenicApplicationUploadPlaceholder] = []
var roleApplies: [RoleApplyPendingResponse] = []
var roleApplyAllError: Error?
var submittedRoleId: Int?
var submittedScenicIds: [Int] = []
var roleApplySubmitError: Error?
///
func scenicListAll() async throws -> ScenicListAllResponse {
if let scenicListError { throw scenicListError }
return scenicList
}
///
func areas() async throws -> [ScenicAreaNode] {
if let areasError { throw areasError }
return areas
}
///
func scenicApplicationPendingAll() async throws -> ScenicApplicationPendingsResponse {
if let scenicPendingsError { throw scenicPendingsError }
return scenicPendings
}
///
func scenicSubmit(_ request: ScenicApplicationSubmitRequest) async throws {
submittedScenicRequest = request
}
///
func scenicApplicationUploadPlaceholder(_ items: [ScenicApplicationUploadPlaceholder]) async throws {
uploadPlaceholders = items
}
///
func roleApplyAll() async throws -> [RoleApplyPendingResponse] {
if let roleApplyAllError { throw roleApplyAllError }
return roleApplies
}
///
func roleApplySubmit(roleId: Int, scenicIds: [Int]) async throws {
if let roleApplySubmitError { throw roleApplySubmitError }
submittedRoleId = roleId
submittedScenicIds = scenicIds
}
}
@MainActor
/// OSS
private final class OSSUploadServingFake: OSSUploadServing {
var uploadedURLs: [String] = []
var uploadedFileNames: [String] = []
var uploadError: Error?
///
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress)
}
/// URL
private func upload(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
if let uploadError { throw uploadError }
uploadedFileNames.append(fileName)
onProgress(100)
return uploadedURLs.removeFirst()
}
}
///
private enum TestError: LocalizedError {
case sample
var errorDescription: String? {
"测试错误"
}
}