Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
29
suixinkan_iosTests/APIErrorTests.swift
Normal file
29
suixinkan_iosTests/APIErrorTests.swift
Normal file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// APIErrorTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
/// 网络错误测试,覆盖登录凭证失效判断。
|
||||
final class APIErrorTests: XCTestCase {
|
||||
/// 测试 HTTP 401 和 403 会被识别为登录失效。
|
||||
func testAuthenticationExpiredForUnauthorizedHTTPStatus() {
|
||||
XCTAssertTrue(APIError.isAuthenticationExpired(APIError.httpStatus(401, "Unauthorized")))
|
||||
XCTAssertTrue(APIError.isAuthenticationExpired(APIError.httpStatus(403, "Forbidden")))
|
||||
}
|
||||
|
||||
/// 测试后端业务码和中文失效文案会被识别为登录失效。
|
||||
func testAuthenticationExpiredForServerTokenMessages() {
|
||||
XCTAssertTrue(APIError.isAuthenticationExpired(APIError.serverCode(200001, "token 已过期")))
|
||||
XCTAssertTrue(APIError.isAuthenticationExpired(APIError.serverCode(0, "登录失效,请重新登录")))
|
||||
}
|
||||
|
||||
/// 测试普通网络错误不会被识别为登录失效。
|
||||
func testNetworkErrorIsNotAuthenticationExpired() {
|
||||
XCTAssertFalse(APIError.isAuthenticationExpired(APIError.networkFailed("请求超时,请稍后重试")))
|
||||
}
|
||||
}
|
||||
137
suixinkan_iosTests/AccountContextLoaderTests.swift
Normal file
137
suixinkan_iosTests/AccountContextLoaderTests.swift
Normal file
@ -0,0 +1,137 @@
|
||||
//
|
||||
// AccountContextLoaderTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 账号上下文加载器测试,覆盖权限、景区兜底和门店容错。
|
||||
final class AccountContextLoaderTests: XCTestCase {
|
||||
/// 测试权限、用户资料、景区和门店成功时会写入上下文。
|
||||
func testRefreshLoadsRolePermissionScenicAndStoreContext() async throws {
|
||||
let accountContext = AccountContext()
|
||||
let permissionContext = PermissionContext()
|
||||
let api = MockAccountContextAPI()
|
||||
api.roles = [
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: 7, name: "运营", permission: [PermissionItem(id: 1, name: "首页", uri: "/home")]),
|
||||
scenic: [ScenicInfo(id: 88, name: "东湖景区")]
|
||||
)
|
||||
]
|
||||
api.scenicResponse = ScenicListAllResponse(total: 1, list: [ScenicListItem(id: 88, name: "东湖景区")])
|
||||
api.storeResponse = ListPayload(total: 1, list: [StoreItem(id: 66, scenicId: 88, name: "东湖门店")])
|
||||
|
||||
try await AccountContextLoader().refresh(
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: MockUserProfileAPI(),
|
||||
accountContextAPI: api
|
||||
)
|
||||
|
||||
XCTAssertEqual(accountContext.profile?.displayName, "测试用户")
|
||||
XCTAssertEqual(permissionContext.currentRole?.id, 7)
|
||||
XCTAssertTrue(permissionContext.canAccess("/home"))
|
||||
XCTAssertEqual(accountContext.currentScenic?.id, 88)
|
||||
XCTAssertEqual(accountContext.currentStore?.id, 66)
|
||||
}
|
||||
|
||||
/// 测试景区接口失败时会从角色权限景区兜底。
|
||||
func testRefreshFallsBackToRoleScenicsWhenScenicAPIThrows() async throws {
|
||||
let accountContext = AccountContext()
|
||||
let permissionContext = PermissionContext()
|
||||
let api = MockAccountContextAPI()
|
||||
api.roles = [
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: 7, name: "运营"),
|
||||
scenic: [
|
||||
ScenicInfo(id: 88, name: "东湖景区"),
|
||||
ScenicInfo(id: 88, name: "东湖景区")
|
||||
]
|
||||
)
|
||||
]
|
||||
api.scenicError = APIError.networkFailed("景区接口失败")
|
||||
|
||||
try await AccountContextLoader().refresh(
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: MockUserProfileAPI(),
|
||||
accountContextAPI: api
|
||||
)
|
||||
|
||||
XCTAssertEqual(accountContext.scenicScopes.map(\.id), [88])
|
||||
}
|
||||
|
||||
/// 测试门店接口失败时不阻断上下文加载。
|
||||
func testRefreshAllowsStoreFailure() async throws {
|
||||
let accountContext = AccountContext()
|
||||
let permissionContext = PermissionContext()
|
||||
let api = MockAccountContextAPI()
|
||||
api.roles = [
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: 7, name: "运营"),
|
||||
scenic: [ScenicInfo(id: 88, name: "东湖景区")]
|
||||
)
|
||||
]
|
||||
api.storeError = APIError.networkFailed("门店接口失败")
|
||||
|
||||
try await AccountContextLoader().refresh(
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: MockUserProfileAPI(),
|
||||
accountContextAPI: api
|
||||
)
|
||||
|
||||
XCTAssertEqual(accountContext.currentScenic?.id, 88)
|
||||
XCTAssertTrue(accountContext.storeScopes.isEmpty)
|
||||
XCTAssertNil(accountContext.currentStore)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 用户资料服务测试替身,返回固定用户资料。
|
||||
private final class MockUserProfileAPI: UserProfileServing {
|
||||
/// 获取固定用户资料。
|
||||
func userInfo() async throws -> UserInfoResponse {
|
||||
UserInfoResponse(nickname: "测试用户", roleName: "运营")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 账号上下文服务测试替身,可分别控制各接口成功或失败。
|
||||
private final class MockAccountContextAPI: AccountContextServing {
|
||||
var roles: [RolePermissionResponse] = []
|
||||
var scenicResponse = ScenicListAllResponse(total: 0, list: [])
|
||||
var storeResponse = ListPayload(total: 0, list: [StoreItem]())
|
||||
var scenicError: Error?
|
||||
var storeError: Error?
|
||||
|
||||
/// 获取测试角色权限。
|
||||
func rolePermissions() async throws -> [RolePermissionResponse] {
|
||||
roles
|
||||
}
|
||||
|
||||
/// 获取测试景区列表,或按配置抛出错误。
|
||||
func scenicListAll() async throws -> ScenicListAllResponse {
|
||||
if let scenicError {
|
||||
throw scenicError
|
||||
}
|
||||
return scenicResponse
|
||||
}
|
||||
|
||||
/// 获取测试门店列表,或按配置抛出错误。
|
||||
func storeAll() async throws -> ListPayload<StoreItem> {
|
||||
if let storeError {
|
||||
throw storeError
|
||||
}
|
||||
return storeResponse
|
||||
}
|
||||
|
||||
/// 获取测试景点列表。
|
||||
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
}
|
||||
133
suixinkan_iosTests/AccountContextTests.swift
Normal file
133
suixinkan_iosTests/AccountContextTests.swift
Normal file
@ -0,0 +1,133 @@
|
||||
//
|
||||
// AccountContextTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 账号上下文测试,覆盖景区、门店和角色权限的选择恢复。
|
||||
final class AccountContextTests: XCTestCase {
|
||||
/// 测试 replaceScopes 能按缓存 ID 恢复当前景区和门店。
|
||||
func testReplaceScopesRestoresCurrentSelectionsById() {
|
||||
let context = AccountContext()
|
||||
let scenicScopes = [
|
||||
BusinessScope(id: 1, name: "默认景区", kind: .scenic),
|
||||
BusinessScope(id: 2, name: "缓存景区", kind: .scenic)
|
||||
]
|
||||
let storeScopes = [
|
||||
BusinessScope(id: 10, name: "默认门店", kind: .store),
|
||||
BusinessScope(id: 20, name: "缓存门店", kind: .store)
|
||||
]
|
||||
|
||||
context.replaceScopes(
|
||||
scenic: scenicScopes,
|
||||
stores: storeScopes,
|
||||
currentScenicId: 2,
|
||||
currentStoreId: 20
|
||||
)
|
||||
|
||||
XCTAssertEqual(context.currentScenic?.id, 2)
|
||||
XCTAssertEqual(context.currentStore?.id, 20)
|
||||
}
|
||||
|
||||
/// 测试缓存 ID 不存在时会回退到首个可用作用域。
|
||||
func testReplaceScopesFallsBackToFirstScopeWhenCachedIdIsMissing() {
|
||||
let context = AccountContext()
|
||||
|
||||
context.replaceScopes(
|
||||
scenic: [BusinessScope(id: 1, name: "默认景区", kind: .scenic)],
|
||||
stores: [BusinessScope(id: 10, name: "默认门店", kind: .store)],
|
||||
currentScenicId: 999,
|
||||
currentStoreId: 999
|
||||
)
|
||||
|
||||
XCTAssertEqual(context.currentScenic?.id, 1)
|
||||
XCTAssertEqual(context.currentStore?.id, 10)
|
||||
}
|
||||
|
||||
/// 测试切换景区时会优先选择同景区门店。
|
||||
func testSelectScenicResolvesStoreWithinSelectedScenic() {
|
||||
let context = AccountContext()
|
||||
context.replaceScopes(
|
||||
scenic: [
|
||||
BusinessScope(id: 1, name: "西湖景区", kind: .scenic),
|
||||
BusinessScope(id: 2, name: "东湖景区", kind: .scenic)
|
||||
],
|
||||
stores: [
|
||||
BusinessScope(id: 10, name: "西湖门店", kind: .store, parentScenicId: 1),
|
||||
BusinessScope(id: 20, name: "东湖门店", kind: .store, parentScenicId: 2)
|
||||
],
|
||||
currentScenicId: 1,
|
||||
currentStoreId: 10
|
||||
)
|
||||
|
||||
context.selectScenic(id: 2)
|
||||
|
||||
XCTAssertEqual(context.currentScenic?.id, 2)
|
||||
XCTAssertEqual(context.currentStore?.id, 20)
|
||||
}
|
||||
|
||||
/// 测试切换角色时会同步角色景区并重新匹配门店。
|
||||
func testSelectRoleUpdatesScenicScopesAndStoreSelection() {
|
||||
let accountContext = AccountContext()
|
||||
let permissionContext = PermissionContext()
|
||||
accountContext.replaceScopes(
|
||||
scenic: [BusinessScope(id: 1, name: "旧景区", kind: .scenic)],
|
||||
stores: [
|
||||
BusinessScope(id: 10, name: "旧门店", kind: .store, parentScenicId: 1),
|
||||
BusinessScope(id: 20, name: "新门店", kind: .store, parentScenicId: 2)
|
||||
],
|
||||
currentScenicId: 1,
|
||||
currentStoreId: 10
|
||||
)
|
||||
permissionContext.replaceRolePermissions([
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: 1, name: "旧角色"),
|
||||
scenic: [ScenicInfo(id: 1, name: "旧景区")]
|
||||
),
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: 2, name: "新角色"),
|
||||
scenic: [ScenicInfo(id: 2, name: "新景区")]
|
||||
)
|
||||
])
|
||||
|
||||
permissionContext.selectRole(id: 2, accountContext: accountContext)
|
||||
|
||||
XCTAssertEqual(permissionContext.currentRole?.id, 2)
|
||||
XCTAssertEqual(accountContext.scenicScopes.map(\.id), [2])
|
||||
XCTAssertEqual(accountContext.currentScenic?.id, 2)
|
||||
XCTAssertEqual(accountContext.currentStore?.id, 20)
|
||||
}
|
||||
|
||||
/// 测试权限上下文会递归展开权限 URI。
|
||||
func testPermissionContextFlattensPermissionURIs() {
|
||||
let permissionContext = PermissionContext()
|
||||
permissionContext.replaceRolePermissions([
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(
|
||||
id: 1,
|
||||
name: "运营",
|
||||
permission: [
|
||||
PermissionItem(
|
||||
id: 1,
|
||||
name: "首页",
|
||||
uri: "/home",
|
||||
children: [
|
||||
PermissionItem(id: 2, name: "订单", uri: "/orders")
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
scenic: []
|
||||
)
|
||||
])
|
||||
|
||||
XCTAssertTrue(permissionContext.canAccess("/home"))
|
||||
XCTAssertTrue(permissionContext.canAccess("/orders"))
|
||||
XCTAssertFalse(permissionContext.canAccess("/settings"))
|
||||
}
|
||||
}
|
||||
314
suixinkan_iosTests/Assets/AssetsAPITests.swift
Normal file
314
suixinkan_iosTests/Assets/AssetsAPITests.swift
Normal file
@ -0,0 +1,314 @@
|
||||
//
|
||||
// AssetsAPITests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 资产 API 测试,覆盖云盘和素材接口请求以及宽松模型解码。
|
||||
final class AssetsAPITests: XCTestCase {
|
||||
/// 测试云盘列表接口使用正确 path 和 query。
|
||||
func testCloudFileListUsesExpectedPathAndQuery() async throws {
|
||||
let session = AssetsRecordingURLSession(data: Self.cloudListResponse)
|
||||
let api = AssetsAPI(client: APIClient(session: session))
|
||||
|
||||
let payload = try await api.cloudFileList(parentFolderId: 9, name: " 照片 ", type: 2, orderBy: 1, page: 0, pageSize: 0)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/cloud-driver/list")
|
||||
let query = assetsQueryItems(from: request)
|
||||
XCTAssertEqual(query["parent_folder_id"], "9")
|
||||
XCTAssertEqual(query["name"], " 照片 ")
|
||||
XCTAssertEqual(query["type"], "2")
|
||||
XCTAssertEqual(query["order_by"], "1")
|
||||
XCTAssertEqual(query["page"], "1")
|
||||
XCTAssertEqual(query["page_size"], "1")
|
||||
XCTAssertEqual(payload.total, 2)
|
||||
XCTAssertEqual(payload.list.dropFirst().first?.fileSize, 2048)
|
||||
}
|
||||
|
||||
/// 测试云盘写操作使用正确 path 和 body。
|
||||
func testCloudMutationRequestsUseExpectedBodies() async throws {
|
||||
let session = AssetsRecordingURLSession(data: Self.emptyResponse)
|
||||
let api = AssetsAPI(client: APIClient(session: session))
|
||||
|
||||
try await api.cloudFolderCreate(CloudFolderCreateRequest(parentFolderId: 1, name: "新文件夹"))
|
||||
try await api.cloudFileUpload(CloudFileUploadRequest(parentFolderId: 1, fileUrl: "https://cdn/a.jpg", fileName: "a.jpg"))
|
||||
try await api.cloudFileDelete(CloudFileDeleteRequest(list: [CloudFileActionItem(id: 2, type: 99)]))
|
||||
try await api.cloudFileMove(CloudFileMoveRequest(targetFolderId: 3, list: [CloudFileActionItem(id: 2, type: 2)]))
|
||||
try await api.cloudFolderEdit(CloudFolderModifyRequest(id: 2, name: "改名"))
|
||||
try await api.cloudFileEdit(CloudFileModifyRequest(id: 4, fileName: "b.jpg"))
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/cloud-driver/folder-create",
|
||||
"/api/yf-handset-app/photog/cloud-driver/file-upload",
|
||||
"/api/yf-handset-app/photog/cloud-driver/delete",
|
||||
"/api/yf-handset-app/photog/cloud-driver/move",
|
||||
"/api/yf-handset-app/photog/cloud-driver/folder-edit",
|
||||
"/api/yf-handset-app/photog/cloud-driver/file-edit"
|
||||
])
|
||||
let uploadBody = try bodyObject(from: session.requests[1])
|
||||
XCTAssertEqual(uploadBody["parent_folder_id"] as? Int, 1)
|
||||
XCTAssertEqual(uploadBody["file_url"] as? String, "https://cdn/a.jpg")
|
||||
let moveBody = try bodyObject(from: session.requests[3])
|
||||
XCTAssertEqual(moveBody["target_folder_id"] as? Int, 3)
|
||||
}
|
||||
|
||||
/// 测试云盘上传权限接口兼容字符串布尔值。
|
||||
func testCloudUploadPermissionDecodesLossyFields() async throws {
|
||||
let session = AssetsRecordingURLSession(data: Self.permissionResponse)
|
||||
let api = AssetsAPI(client: APIClient(session: session))
|
||||
|
||||
let permission = try await api.checkCloudUploadPermission()
|
||||
|
||||
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/cloud-driver/check-upload-permission")
|
||||
XCTAssertFalse(permission.canUpload)
|
||||
XCTAssertEqual(permission.reason, "空间不足")
|
||||
}
|
||||
|
||||
/// 测试素材列表和详情接口使用正确 path 并宽松解码字段。
|
||||
func testMediaListAndDetailDecodeLossyFields() async throws {
|
||||
let session = AssetsRecordingURLSession(responses: [Self.mediaListResponse, Self.mediaDetailResponse])
|
||||
let api = AssetsAPI(client: APIClient(session: session))
|
||||
|
||||
let list = try await api.mediaAlbumList(kind: .material, keyword: "航拍", status: 1, page: 0, pageSize: 0)
|
||||
let detail = try await api.mediaAlbumDetail(id: 7, kind: .material)
|
||||
|
||||
XCTAssertEqual(session.requests.first?.url?.path, "/api/app/media-album/list")
|
||||
let query = assetsQueryItems(from: try XCTUnwrap(session.requests.first))
|
||||
XCTAssertEqual(query["type"], "1")
|
||||
XCTAssertEqual(query["status"], "1")
|
||||
XCTAssertEqual(query["page"], "1")
|
||||
XCTAssertEqual(query["page_size"], "1")
|
||||
XCTAssertEqual(list.order?.totalNum, 12)
|
||||
XCTAssertEqual(list.list.first?.likesCount, 3)
|
||||
XCTAssertEqual(session.requests.last?.url?.path, "/api/app/media-album/detail")
|
||||
XCTAssertEqual(assetsQueryItems(from: try XCTUnwrap(session.requests.last))["type"], "1")
|
||||
XCTAssertEqual(detail.mediaList.first?.size, 4096)
|
||||
XCTAssertTrue(detail.mediaList.first?.isVideo == true)
|
||||
}
|
||||
|
||||
/// 测试素材写操作 path 和请求体。
|
||||
func testMediaMutationRequestsUseExpectedBodies() async throws {
|
||||
let session = AssetsRecordingURLSession(data: Self.emptyResponse)
|
||||
let api = AssetsAPI(client: APIClient(session: session))
|
||||
let upload = MediaAlbumUploadRequest(
|
||||
name: "素材",
|
||||
type: 1,
|
||||
mediaType: 2,
|
||||
mediaList: [MediaAlbumUploadItem(originalName: "a.jpg", ossUrl: "https://cdn/a.jpg", size: 10, fileWidthSize: MediaAlbumFileSize(width: 100, height: 80))],
|
||||
coverUrl: "https://cdn/c.jpg",
|
||||
coverSize: MediaAlbumFileSize(width: 100, height: 80),
|
||||
scenicSpotId: 6,
|
||||
description: "描述",
|
||||
materialTag: "航拍",
|
||||
projectId: 0
|
||||
)
|
||||
|
||||
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: 1, type: 1, listingStatus: 1))
|
||||
try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 1, type: 1))
|
||||
try await api.mediaAlbumAddTag(MediaAlbumAddTagRequest(name: "航拍"))
|
||||
try await api.mediaAlbumUpload(upload)
|
||||
try await api.mediaAlbumEdit(MediaAlbumEditRequest(id: 2, name: "素材", type: 1, mediaType: 2, mediaList: upload.mediaList, coverUrl: upload.coverUrl, coverSize: upload.coverSize, scenicSpotId: 6, description: "描述", materialTag: "航拍"))
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/app/media-album/operation",
|
||||
"/api/app/media-album/delete",
|
||||
"/api/app/media-album/add-tag",
|
||||
"/api/app/media-album/upload",
|
||||
"/api/app/media-album/edit"
|
||||
])
|
||||
let body = try bodyObject(from: session.requests[3])
|
||||
XCTAssertEqual(body["scenic_spot_id"] as? Int, 6)
|
||||
XCTAssertEqual((body["media_list"] as? [[String: Any]])?.first?["oss_url"] as? String, "https://cdn/a.jpg")
|
||||
}
|
||||
|
||||
/// 测试样片请求携带 type=2,项目列表接口和项目字段宽松解码正确。
|
||||
func testSampleRequestsAndProjectListUseExpectedTypeAndQuery() async throws {
|
||||
let session = AssetsRecordingURLSession(responses: [
|
||||
Self.mediaListResponse,
|
||||
Self.mediaDetailResponse,
|
||||
Self.emptyResponse,
|
||||
Self.emptyResponse,
|
||||
Self.projectListResponse,
|
||||
Self.emptyResponse
|
||||
])
|
||||
let api = AssetsAPI(client: APIClient(session: session))
|
||||
let upload = MediaAlbumUploadRequest(
|
||||
name: "亲子样片",
|
||||
type: 2,
|
||||
mediaType: 2,
|
||||
mediaList: [MediaAlbumUploadItem(originalName: "sample.jpg", ossUrl: "https://cdn/sample.jpg", size: 10, fileWidthSize: MediaAlbumFileSize(width: 100, height: 80))],
|
||||
coverUrl: "https://cdn/cover.jpg",
|
||||
coverSize: MediaAlbumFileSize(width: 100, height: 80),
|
||||
scenicSpotId: 6,
|
||||
description: "描述",
|
||||
materialTag: "",
|
||||
projectId: 88
|
||||
)
|
||||
|
||||
_ = try await api.mediaAlbumList(kind: .sample, keyword: "样片", status: nil, page: 1, pageSize: 10)
|
||||
_ = try await api.mediaAlbumDetail(id: 7, kind: .sample)
|
||||
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: 7, type: 2, listingStatus: 1))
|
||||
try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 7, type: 2))
|
||||
let projects = try await api.projectList(scenicId: 9, name: "亲子", page: 0, pageSize: 0)
|
||||
try await api.mediaAlbumUpload(upload)
|
||||
|
||||
XCTAssertEqual(assetsQueryItems(from: session.requests[0])["type"], "2")
|
||||
XCTAssertEqual(assetsQueryItems(from: session.requests[1])["type"], "2")
|
||||
XCTAssertEqual((try bodyObject(from: session.requests[2]))["type"] as? Int, 2)
|
||||
XCTAssertEqual((try bodyObject(from: session.requests[3]))["type"] as? Int, 2)
|
||||
XCTAssertEqual(session.requests[4].url?.path, "/api/yf-handset-app/photog/project/list")
|
||||
let projectQuery = assetsQueryItems(from: session.requests[4])
|
||||
XCTAssertEqual(projectQuery["scenic_id"], "9")
|
||||
XCTAssertEqual(projectQuery["name"], "亲子")
|
||||
XCTAssertEqual(projectQuery["page"], "1")
|
||||
XCTAssertEqual(projectQuery["page_size"], "1")
|
||||
XCTAssertEqual(projects.list.first?.id, 88)
|
||||
XCTAssertEqual(projects.list.first?.name, "亲子旅拍")
|
||||
XCTAssertEqual((try bodyObject(from: session.requests[5]))["project_id"] as? Int, 88)
|
||||
}
|
||||
|
||||
/// 测试相册接口使用旧工程路径、query 和请求体。
|
||||
func testAlbumRequestsUseExpectedPathQueryAndBodies() async throws {
|
||||
let session = AssetsRecordingURLSession(responses: [
|
||||
Self.albumFolderListResponse,
|
||||
Self.albumFolderInfoResponse,
|
||||
Self.albumFileListResponse,
|
||||
Self.emptyResponse,
|
||||
Self.emptyResponse,
|
||||
Self.emptyResponse,
|
||||
Self.emptyResponse
|
||||
])
|
||||
let api = AssetsAPI(client: APIClient(session: session))
|
||||
|
||||
let folders = try await api.albumFolderList(
|
||||
scenicId: 9,
|
||||
page: 0,
|
||||
pageSize: 0,
|
||||
name: "旅拍",
|
||||
startTime: "2026-06-01",
|
||||
endTime: "2026-06-24"
|
||||
)
|
||||
let info = try await api.albumFolderInfo(id: 3)
|
||||
let files = try await api.albumFileList(scenicId: 9, folderId: 3, fileType: 2, page: 0, pageSize: 0)
|
||||
try await api.addAlbumFolder(scenicId: 9, name: "新相册", remark: "备注")
|
||||
try await api.editAlbumFolder(folderId: 3, coverFileId: 11, name: "改名", remark: nil)
|
||||
try await api.deleteAlbumFiles(folderId: 3, idList: [11, 12])
|
||||
try await api.albumFileUploadURL(scenicId: 9, fileURL: "https://cdn/a.jpg", folderId: 3)
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/album/folder-list",
|
||||
"/api/yf-handset-app/photog/album/folder-info",
|
||||
"/api/yf-handset-app/photog/album/file-list",
|
||||
"/api/yf-handset-app/photog/album/folder-add",
|
||||
"/api/yf-handset-app/photog/album/folder-edit",
|
||||
"/api/yf-handset-app/photog/album/file-delete",
|
||||
"/api/yf-handset-app/photog/album/file-upload-url"
|
||||
])
|
||||
let folderQuery = assetsQueryItems(from: session.requests[0])
|
||||
XCTAssertEqual(folderQuery["scenic_id"], "9")
|
||||
XCTAssertEqual(folderQuery["page"], "1")
|
||||
XCTAssertEqual(folderQuery["page_size"], "1")
|
||||
XCTAssertEqual(folderQuery["cloud_folder_type"], "1")
|
||||
XCTAssertEqual(folderQuery["name"], "旅拍")
|
||||
XCTAssertEqual(folderQuery["start_time"], "2026-06-01")
|
||||
XCTAssertEqual(folderQuery["end_time"], "2026-06-24")
|
||||
XCTAssertEqual(folders.list.first?.countImage, 4)
|
||||
XCTAssertEqual(info.coverFile?.fileSize, 2048)
|
||||
XCTAssertTrue(files.list.first?.isImage == true)
|
||||
|
||||
let addBody = try bodyObject(from: session.requests[3])
|
||||
XCTAssertEqual(addBody["scenic_id"] as? String, "9")
|
||||
XCTAssertEqual(addBody["cloud_folder_type"] as? Int, 1)
|
||||
let editBody = try bodyObject(from: session.requests[4])
|
||||
XCTAssertEqual(editBody["cover_file_id"] as? Int, 11)
|
||||
let deleteBody = try bodyObject(from: session.requests[5])
|
||||
XCTAssertEqual(deleteBody["folder_id"] as? Int, 3)
|
||||
XCTAssertEqual(deleteBody["id_list"] as? [Int], [11, 12])
|
||||
let uploadQuery = assetsQueryItems(from: session.requests[6])
|
||||
XCTAssertEqual(uploadQuery["file_url"], "https://cdn/a.jpg")
|
||||
}
|
||||
|
||||
fileprivate static let emptyResponse = #"{"code":100000,"msg":"success","data":{}}"#.data(using: .utf8)!
|
||||
fileprivate static let permissionResponse = #"{"code":100000,"msg":"success","data":{"can_upload":"0","reason":"空间不足"}}"#.data(using: .utf8)!
|
||||
fileprivate static let cloudListResponse = """
|
||||
{"code":100000,"msg":"success","data":{"total":"2","list":[
|
||||
{"id":"1","parent_folder_id":0,"file_url":"","cover_url":"","updated_at":"2026","name":"文件夹","created_at":"2026","child_num":"3","type":"99","file_size":"0"},
|
||||
{"id":2,"parent_folder_id":"0","file_url":"https://cdn/a.jpg","cover_url":"","updated_at":"2026","name":"a.jpg","created_at":"2026","child_num":0,"type":2,"file_size":"2048"}
|
||||
]}}
|
||||
""".data(using: .utf8)!
|
||||
fileprivate static let mediaListResponse = """
|
||||
{"code":100000,"msg":"success","data":{"total":"1","order":{"total_num":"12","avg_order_amount":"8.5","refund_total":1,"avg_change":"0.2"},"list":[
|
||||
{"id":"7","name":"航拍","cover_url":"https://cdn/c.jpg","created_at":"2026","status":"1","download_count":"2","likes_count":"3","collect_count":"4","share_count":"5","scenic_spot_name":"东门","project_name":"项目","listing_status":"1"}
|
||||
]}}
|
||||
""".data(using: .utf8)!
|
||||
fileprivate static let mediaDetailResponse = """
|
||||
{"code":100000,"msg":"success","data":{"id":"7","name":"航拍","cover_url":"https://cdn/c.jpg","description":"描述","status":"1","listing_status":"1","likes_count":"3","download_count":"2","collect_count":"4","share_count":"5","uploader_name":"摄影师","created_at":"2026","scenic_name":"景区","project_name":"项目","media_list":[
|
||||
{"id":"9","original_name":"v.mp4","oss_url":"https://cdn/v.mp4","thumbnail_url":"","type":"1","size":"4096","show_url":"https://cdn/v.mp4"}
|
||||
]}}
|
||||
""".data(using: .utf8)!
|
||||
fileprivate static let projectListResponse = """
|
||||
{"code":100000,"msg":"success","data":{"total":"1","list":[
|
||||
{"id":"88","type":"19","type_name":"多点旅拍","status":"1","status_name":"上架","name":"亲子旅拍","cover_project":"https://cdn/p.jpg","cover_video":"","price":"299","ot_price":"399","price_deposit":"99"}
|
||||
]}}
|
||||
""".data(using: .utf8)!
|
||||
fileprivate static let albumFolderListResponse = """
|
||||
{"code":100000,"msg":"success","data":{"total":"1","list":[
|
||||
{"id":"3","name":"旅拍","cover_file_id":"11","count_video":"2","count_image":"4","cover":{"id":"11","file_name":"a.jpg","file_type":"2","file_url":"https://cdn/a.jpg","cover_url":"","file_size":"2048","file_size_human":"2KB","remark":""},"created_at":"2026","remark":"备注"}
|
||||
]}}
|
||||
""".data(using: .utf8)!
|
||||
fileprivate static let albumFolderInfoResponse = """
|
||||
{"code":100000,"msg":"success","data":{"id":"3","name":"旅拍","cover_file_id":"11","count_video":"2","count_image":"4","cover":{"id":"11","file_name":"a.jpg","file_type":"2","file_url":"https://cdn/a.jpg","cover_url":"","file_size":"2048","file_size_human":"2KB","remark":""},"created_at":"2026","remark":"备注"}}
|
||||
""".data(using: .utf8)!
|
||||
fileprivate static let albumFileListResponse = """
|
||||
{"code":100000,"msg":"success","data":{"total":"1","list":[
|
||||
{"id":"11","file_name":"a.jpg","file_type":"2","file_url":"https://cdn/a.jpg","cover_url":"","file_size":"2048","file_size_human":"2KB","remark":""}
|
||||
]}}
|
||||
""".data(using: .utf8)!
|
||||
}
|
||||
|
||||
/// 资产 API 测试用 URLSession,记录请求并返回固定响应。
|
||||
private final class AssetsRecordingURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 初始化单一响应。
|
||||
init(data: Data) {
|
||||
responses = [data]
|
||||
}
|
||||
|
||||
/// 初始化顺序响应。
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 记录请求并返回下一份响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.isEmpty ? AssetsAPITests.emptyResponse : responses.removeFirst()
|
||||
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
return (data, response)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求中提取 query 字典。
|
||||
private func assetsQueryItems(from request: URLRequest) -> [String: String] {
|
||||
guard
|
||||
let url = request.url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") })
|
||||
}
|
||||
|
||||
/// 将请求体解析成字典。
|
||||
private func bodyObject(from request: URLRequest) throws -> [String: Any] {
|
||||
let data = try XCTUnwrap(request.httpBody)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
675
suixinkan_iosTests/Assets/AssetsViewModelTests.swift
Normal file
675
suixinkan_iosTests/Assets/AssetsViewModelTests.swift
Normal file
@ -0,0 +1,675 @@
|
||||
//
|
||||
// AssetsViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 资产 ViewModel 测试,覆盖云盘和素材管理核心业务逻辑。
|
||||
final class AssetsViewModelTests: XCTestCase {
|
||||
/// 测试云盘无上传权限时不会继续上传 OSS 或写入云盘。
|
||||
func testCloudUploadStopsWhenPermissionDenied() async {
|
||||
let api = MockAssetsService()
|
||||
api.uploadPermission = false
|
||||
api.uploadReason = "空间不足"
|
||||
let uploader = MockAssetsUploader()
|
||||
let viewModel = CloudStorageViewModel()
|
||||
|
||||
let success = await viewModel.upload(
|
||||
localFiles: [CloudLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 2)],
|
||||
scenicId: 9,
|
||||
api: api,
|
||||
uploadService: uploader,
|
||||
transferStore: CloudTransferStore()
|
||||
)
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertEqual(viewModel.errorMessage, "空间不足")
|
||||
XCTAssertEqual(uploader.uploadedFiles.count, 0)
|
||||
XCTAssertEqual(api.cloudUploadRequests.count, 0)
|
||||
}
|
||||
|
||||
/// 测试云盘上传会先上传 OSS,再登记云盘文件并刷新列表。
|
||||
func testCloudUploadUploadsOSSBeforeCloudRecord() async {
|
||||
let api = MockAssetsService()
|
||||
let uploader = MockAssetsUploader()
|
||||
let transferStore = CloudTransferStore()
|
||||
let viewModel = CloudStorageViewModel()
|
||||
|
||||
let success = await viewModel.upload(
|
||||
localFiles: [CloudLocalUploadFile(data: Data([1, 2]), fileName: "a.jpg", fileType: 2)],
|
||||
scenicId: 9,
|
||||
api: api,
|
||||
uploadService: uploader,
|
||||
transferStore: transferStore
|
||||
)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["a.jpg"])
|
||||
XCTAssertEqual(api.cloudUploadRequests.first?.fileUrl, "https://cdn.example.com/a.jpg")
|
||||
XCTAssertEqual(transferStore.records.first?.status, .success)
|
||||
}
|
||||
|
||||
/// 测试云盘分页、进入文件夹和返回文件夹。
|
||||
func testCloudPaginationAndFolderNavigation() async {
|
||||
let folder = CloudDriveFile(id: 2, parentFolderId: 0, fileUrl: "", name: "子目录", type: 99)
|
||||
let api = MockAssetsService()
|
||||
api.cloudPages = [
|
||||
ListPayload(total: 2, list: [folder]),
|
||||
ListPayload(total: 2, list: [CloudDriveFile(id: 3, parentFolderId: 0, fileUrl: "https://cdn/a.jpg", name: "a.jpg", type: 2)])
|
||||
]
|
||||
let viewModel = CloudStorageViewModel()
|
||||
|
||||
await viewModel.reload(api: api)
|
||||
await viewModel.loadMore(api: api)
|
||||
await viewModel.enterFolder(folder, api: api)
|
||||
await viewModel.popToFolder(at: 0, api: api)
|
||||
|
||||
XCTAssertEqual(api.cloudListRequests.first?.parentFolderId, 0)
|
||||
XCTAssertEqual(api.cloudListRequests[1].page, 2)
|
||||
XCTAssertEqual(api.cloudListRequests[2].parentFolderId, 2)
|
||||
XCTAssertEqual(viewModel.currentFolderId, 0)
|
||||
}
|
||||
|
||||
/// 测试素材列表筛选和分页请求参数。
|
||||
func testMediaListFilterAndPagination() async {
|
||||
let api = MockAssetsService()
|
||||
api.mediaListResponses = [
|
||||
MediaLibraryListResponse.fixture(total: 2, ids: [1]),
|
||||
MediaLibraryListResponse.fixture(total: 2, ids: [2])
|
||||
]
|
||||
let viewModel = MediaLibraryViewModel(kind: .material)
|
||||
viewModel.keyword = " 航拍 "
|
||||
viewModel.selectedAuditFilter = .approved
|
||||
|
||||
await viewModel.reload(api: api)
|
||||
await viewModel.loadMore(api: api)
|
||||
|
||||
XCTAssertEqual(api.mediaListRequests.first?.keyword, "航拍")
|
||||
XCTAssertEqual(api.mediaListRequests.first?.status, 1)
|
||||
XCTAssertEqual(api.mediaListRequests.last?.page, 2)
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
|
||||
}
|
||||
|
||||
/// 测试样片列表请求使用样片类型。
|
||||
func testSampleMediaListUsesSampleKind() async {
|
||||
let api = MockAssetsService()
|
||||
let viewModel = MediaLibraryViewModel(kind: .sample)
|
||||
|
||||
await viewModel.reload(api: api)
|
||||
|
||||
XCTAssertEqual(api.mediaListRequests.first?.kind, .sample)
|
||||
}
|
||||
|
||||
/// 测试审核未通过素材禁止上下架。
|
||||
func testRejectedMediaCannotToggleListing() async {
|
||||
let api = MockAssetsService()
|
||||
let viewModel = MediaLibraryViewModel()
|
||||
let item = MediaLibraryItem.fixture(id: 1, status: 2, listingStatus: 0)
|
||||
|
||||
let success = await viewModel.toggleListing(item, api: api)
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertEqual(api.mediaOperationRequests.count, 0)
|
||||
XCTAssertEqual(viewModel.errorMessage, "审核通过后才能上下架")
|
||||
}
|
||||
|
||||
/// 测试样片上下架会携带样片类型。
|
||||
func testSampleToggleListingUsesSampleType() async {
|
||||
let api = MockAssetsService()
|
||||
let viewModel = MediaLibraryViewModel(kind: .sample)
|
||||
let item = MediaLibraryItem.fixture(id: 1, status: 1, listingStatus: 0)
|
||||
|
||||
let success = await viewModel.toggleListing(item, api: api)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(api.mediaOperationRequests.first?.type, 2)
|
||||
}
|
||||
|
||||
/// 测试上传素材会先上传封面和媒体文件,再提交最终 OSS URL。
|
||||
func testMediaUploadUploadsFilesBeforeSubmit() async {
|
||||
let api = MockAssetsService()
|
||||
let uploader = MockAssetsUploader()
|
||||
let viewModel = MediaLibraryEditorViewModel()
|
||||
viewModel.name = "航拍素材"
|
||||
viewModel.selectedSpotId = 6
|
||||
viewModel.tagsText = "航拍,夜景"
|
||||
viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80))
|
||||
viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)])
|
||||
|
||||
let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["cover.jpg", "a.jpg"])
|
||||
XCTAssertEqual(api.mediaUploadRequests.first?.coverUrl, "https://cdn.example.com/cover.jpg")
|
||||
XCTAssertEqual(api.mediaUploadRequests.first?.mediaList.first?.ossUrl, "https://cdn.example.com/a.jpg")
|
||||
XCTAssertEqual(api.mediaUploadRequests.first?.materialTag, "航拍,夜景")
|
||||
}
|
||||
|
||||
/// 测试素材上传失败时不会提交素材接口。
|
||||
func testMediaUploadFailureDoesNotSubmit() async {
|
||||
let api = MockAssetsService()
|
||||
let uploader = MockAssetsUploader()
|
||||
uploader.shouldFail = true
|
||||
let viewModel = MediaLibraryEditorViewModel()
|
||||
viewModel.name = "航拍素材"
|
||||
viewModel.selectedSpotId = 6
|
||||
viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80))
|
||||
viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)])
|
||||
|
||||
let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader)
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertEqual(api.mediaUploadRequests.count, 0)
|
||||
}
|
||||
|
||||
/// 测试样片上传缺少关联项目时不提交。
|
||||
func testSampleUploadRequiresProject() async {
|
||||
let api = MockAssetsService()
|
||||
let uploader = MockAssetsUploader()
|
||||
let viewModel = MediaLibraryEditorViewModel()
|
||||
viewModel.name = "亲子样片"
|
||||
viewModel.selectedSpotId = 6
|
||||
viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80))
|
||||
viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)])
|
||||
|
||||
let success = await viewModel.submit(kind: .sample, scenicId: 9, api: api, uploadService: uploader)
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertEqual(viewModel.errorMessage, "请选择关联项目")
|
||||
XCTAssertEqual(api.mediaUploadRequests.count, 0)
|
||||
}
|
||||
|
||||
/// 测试样片上传会先上传文件,再提交样片类型和关联项目。
|
||||
func testSampleUploadUploadsFilesAndSubmitsProject() async {
|
||||
let api = MockAssetsService()
|
||||
let uploader = MockAssetsUploader()
|
||||
let viewModel = MediaLibraryEditorViewModel()
|
||||
viewModel.name = "亲子样片"
|
||||
viewModel.selectedSpotId = 6
|
||||
viewModel.selectedProjectId = 88
|
||||
viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80))
|
||||
viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)])
|
||||
|
||||
let success = await viewModel.submit(kind: .sample, scenicId: 9, api: api, uploadService: uploader)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["cover.jpg", "a.jpg"])
|
||||
XCTAssertEqual(api.mediaUploadRequests.first?.type, 2)
|
||||
XCTAssertEqual(api.mediaUploadRequests.first?.projectId, 88)
|
||||
}
|
||||
|
||||
/// 测试重复提交样片会被忽略。
|
||||
func testSampleDuplicateSubmitIsIgnored() async {
|
||||
let api = MockAssetsService()
|
||||
let uploader = MockAssetsUploader()
|
||||
let viewModel = MediaLibraryEditorViewModel()
|
||||
viewModel.name = "亲子样片"
|
||||
viewModel.selectedSpotId = 6
|
||||
viewModel.selectedProjectId = 88
|
||||
viewModel.isSubmitting = true
|
||||
viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80))
|
||||
viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)])
|
||||
|
||||
let success = await viewModel.submit(kind: .sample, scenicId: 9, api: api, uploadService: uploader)
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertEqual(api.mediaUploadRequests.count, 0)
|
||||
XCTAssertEqual(uploader.uploadedFiles.count, 0)
|
||||
}
|
||||
|
||||
/// 测试相册列表无景区时清空数据且不请求接口。
|
||||
func testAlbumListClearsWithoutScenic() async {
|
||||
let api = MockAssetsService()
|
||||
let viewModel = AlbumListViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: nil)
|
||||
|
||||
XCTAssertTrue(viewModel.folders.isEmpty)
|
||||
XCTAssertEqual(api.albumFolderListRequests.count, 0)
|
||||
XCTAssertEqual(viewModel.errorMessage, "缺少当前景区,无法加载相册")
|
||||
}
|
||||
|
||||
/// 测试相册列表搜索、日期筛选和分页参数。
|
||||
func testAlbumListFilterAndPagination() async {
|
||||
let api = MockAssetsService()
|
||||
api.albumFolderPages = [
|
||||
ListPayload(total: 2, list: [AlbumFolderItem.fixture(id: 1, name: "旅拍")]),
|
||||
ListPayload(total: 2, list: [AlbumFolderItem.fixture(id: 2, name: "亲子")])
|
||||
]
|
||||
let viewModel = AlbumListViewModel()
|
||||
viewModel.searchText = " 旅拍 "
|
||||
viewModel.startTime = "2026-06-01"
|
||||
viewModel.endTime = "2026-06-24"
|
||||
|
||||
await viewModel.reload(api: api, scenicId: 9)
|
||||
await viewModel.loadMore(api: api, scenicId: 9)
|
||||
|
||||
XCTAssertEqual(api.albumFolderListRequests.first?.name, "旅拍")
|
||||
XCTAssertEqual(api.albumFolderListRequests.first?.startTime, "2026-06-01")
|
||||
XCTAssertEqual(api.albumFolderListRequests.last?.page, 2)
|
||||
XCTAssertEqual(viewModel.folders.map(\.id), [1, 2])
|
||||
}
|
||||
|
||||
/// 测试新建相册成功后刷新列表。
|
||||
func testAlbumCreateRefreshesList() async {
|
||||
let api = MockAssetsService()
|
||||
api.albumFolderPages = [ListPayload(total: 1, list: [AlbumFolderItem.fixture(id: 3, name: "新相册")])]
|
||||
let viewModel = AlbumListViewModel()
|
||||
|
||||
let success = await viewModel.createFolder(name: " 新相册 ", remark: " 备注 ", scenicId: 9, api: api)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(api.albumAddRequests.first?.name, "新相册")
|
||||
XCTAssertEqual(api.albumAddRequests.first?.remark, "备注")
|
||||
XCTAssertEqual(viewModel.folders.first?.id, 3)
|
||||
}
|
||||
|
||||
/// 测试相册详情加载信息和文件列表,切换 Tab 会重置分页。
|
||||
func testAlbumDetailLoadsInfoAndSwitchesTab() async {
|
||||
let api = MockAssetsService()
|
||||
api.albumFilePages = [
|
||||
ListPayload(total: 1, list: [AlbumFileItem.fixture(id: 11, fileType: 2)]),
|
||||
ListPayload(total: 1, list: [AlbumFileItem.fixture(id: 12, fileType: 1)])
|
||||
]
|
||||
let viewModel = AlbumDetailViewModel(folderId: 3)
|
||||
|
||||
await viewModel.reload(api: api, scenicId: 9)
|
||||
await viewModel.selectTab(.video, api: api, scenicId: 9)
|
||||
|
||||
XCTAssertEqual(api.albumInfoRequests, [3, 3])
|
||||
XCTAssertEqual(api.albumFileListRequests.map { $0.fileType }, [2, 1])
|
||||
XCTAssertEqual(viewModel.files.first?.id, 12)
|
||||
XCTAssertEqual(viewModel.page, 1)
|
||||
}
|
||||
|
||||
/// 测试相册详情删除、设置封面和编辑信息会调用正确接口。
|
||||
func testAlbumDetailMutationsUseExpectedRequests() async {
|
||||
let api = MockAssetsService()
|
||||
let viewModel = AlbumDetailViewModel(folderId: 3)
|
||||
let file = AlbumFileItem.fixture(id: 11, fileType: 2)
|
||||
|
||||
_ = await viewModel.delete(file: file, scenicId: 9, api: api)
|
||||
_ = await viewModel.setCover(file: file, scenicId: 9, api: api)
|
||||
_ = await viewModel.rename(name: " 改名 ", scenicId: 9, api: api)
|
||||
_ = await viewModel.updateRemark(" 新备注 ", scenicId: 9, api: api)
|
||||
|
||||
XCTAssertEqual(api.albumDeleteRequests.first?.idList, [11])
|
||||
XCTAssertEqual(api.albumEditRequests[0].coverFileId, 11)
|
||||
XCTAssertEqual(api.albumEditRequests[1].name, "改名")
|
||||
XCTAssertEqual(api.albumEditRequests[2].remark, "新备注")
|
||||
}
|
||||
|
||||
/// 测试相册预览上传会先上传 OSS,再登记相册文件。
|
||||
func testAlbumTrailerUploadUploadsOSSBeforeRegisteringFile() async {
|
||||
let api = MockAssetsService()
|
||||
api.albumFolderPages = [ListPayload(total: 1, list: [AlbumFolderItem.fixture(id: 3, name: "旅拍")])]
|
||||
let uploader = MockAssetsUploader()
|
||||
let viewModel = AlbumTrailerViewModel()
|
||||
viewModel.addLocalFiles([AlbumLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 2)])
|
||||
await viewModel.loadFolders(api: api, scenicId: 9)
|
||||
|
||||
let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["a.jpg"])
|
||||
XCTAssertEqual(api.albumUploadURLRequests.first?.fileURL, "https://cdn.example.com/a.jpg")
|
||||
XCTAssertTrue(viewModel.localFiles.isEmpty)
|
||||
XCTAssertEqual(viewModel.uploadProgress, 100)
|
||||
}
|
||||
|
||||
/// 测试相册上传失败时不会登记文件入库。
|
||||
func testAlbumTrailerUploadFailureDoesNotRegisterFile() async {
|
||||
let api = MockAssetsService()
|
||||
let uploader = MockAssetsUploader()
|
||||
uploader.shouldFail = true
|
||||
let viewModel = AlbumTrailerViewModel()
|
||||
viewModel.selectedFolderId = 3
|
||||
viewModel.addLocalFiles([AlbumLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 2)])
|
||||
|
||||
let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader)
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertEqual(api.albumUploadURLRequests.count, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 资产服务测试替身,记录请求并返回预设数据。
|
||||
@MainActor
|
||||
private final class MockAssetsService: AssetsServing {
|
||||
struct CloudListRequest: Equatable {
|
||||
let parentFolderId: Int
|
||||
let name: String
|
||||
let type: Int
|
||||
let orderBy: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
}
|
||||
|
||||
struct MediaListRequest: Equatable {
|
||||
let kind: MediaLibraryKind
|
||||
let keyword: String
|
||||
let status: Int?
|
||||
let page: Int
|
||||
}
|
||||
|
||||
struct ProjectListRequest: Equatable {
|
||||
let scenicId: Int
|
||||
let name: String?
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
}
|
||||
|
||||
struct AlbumFolderListRequest: Equatable {
|
||||
let scenicId: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
let name: String
|
||||
let startTime: String?
|
||||
let endTime: String?
|
||||
}
|
||||
|
||||
struct AlbumUploadURLRequest: Equatable {
|
||||
let scenicId: Int
|
||||
let fileURL: String
|
||||
let folderId: Int
|
||||
}
|
||||
|
||||
var cloudPages: [ListPayload<CloudDriveFile>] = [ListPayload(total: 0, list: [])]
|
||||
var mediaListResponses: [MediaLibraryListResponse] = [MediaLibraryListResponse.fixture(total: 0, ids: [])]
|
||||
var projectPages: [ListPayload<PhotographerProjectItem>] = [ListPayload(total: 0, list: [])]
|
||||
var albumFolderPages: [ListPayload<AlbumFolderItem>] = [ListPayload(total: 0, list: [])]
|
||||
var albumFilePages: [ListPayload<AlbumFileItem>] = [ListPayload(total: 0, list: [])]
|
||||
var uploadPermission = true
|
||||
var uploadReason = ""
|
||||
var cloudListRequests: [CloudListRequest] = []
|
||||
var cloudUploadRequests: [CloudFileUploadRequest] = []
|
||||
var mediaListRequests: [MediaListRequest] = []
|
||||
var mediaOperationRequests: [MediaAlbumOperationRequest] = []
|
||||
var mediaUploadRequests: [MediaAlbumUploadRequest] = []
|
||||
var mediaEditRequests: [MediaAlbumEditRequest] = []
|
||||
var projectListRequests: [ProjectListRequest] = []
|
||||
var albumFolderListRequests: [AlbumFolderListRequest] = []
|
||||
var albumInfoRequests: [Int] = []
|
||||
var albumFileListRequests: [(scenicId: Int, folderId: Int, fileType: Int, page: Int)] = []
|
||||
var albumAddRequests: [(scenicId: Int, name: String, remark: String)] = []
|
||||
var albumEditRequests: [AlbumFolderEditRequest] = []
|
||||
var albumDeleteRequests: [AlbumFileDeleteRequest] = []
|
||||
var albumUploadURLRequests: [AlbumUploadURLRequest] = []
|
||||
|
||||
/// 返回云盘文件列表。
|
||||
func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> ListPayload<CloudDriveFile> {
|
||||
cloudListRequests.append(CloudListRequest(parentFolderId: parentFolderId, name: name, type: type, orderBy: orderBy, page: page, pageSize: pageSize))
|
||||
return cloudPages.isEmpty ? ListPayload(total: 0, list: []) : cloudPages.removeFirst()
|
||||
}
|
||||
|
||||
/// 创建文件夹。
|
||||
func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws {}
|
||||
|
||||
/// 登记云盘文件。
|
||||
func cloudFileUpload(_ request: CloudFileUploadRequest) async throws {
|
||||
cloudUploadRequests.append(request)
|
||||
}
|
||||
|
||||
/// 删除云盘文件。
|
||||
func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws {}
|
||||
|
||||
/// 移动云盘文件。
|
||||
func cloudFileMove(_ request: CloudFileMoveRequest) async throws {}
|
||||
|
||||
/// 重命名文件夹。
|
||||
func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws {}
|
||||
|
||||
/// 重命名文件。
|
||||
func cloudFileEdit(_ request: CloudFileModifyRequest) async throws {}
|
||||
|
||||
/// 检查上传权限。
|
||||
func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse {
|
||||
try JSONDecoder().decode(CheckCloudUploadResponse.self, from: #"{"can_upload":\#(uploadPermission ? 1 : 0),"reason":"\#(uploadReason)"}"#.data(using: .utf8)!)
|
||||
}
|
||||
|
||||
/// 返回素材列表。
|
||||
func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse {
|
||||
mediaListRequests.append(MediaListRequest(kind: kind, keyword: keyword, status: status, page: page))
|
||||
return mediaListResponses.isEmpty ? MediaLibraryListResponse.fixture(total: 0, ids: []) : mediaListResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 返回素材详情。
|
||||
func mediaAlbumDetail(id: Int, kind: MediaLibraryKind) async throws -> MediaLibraryDetail {
|
||||
MediaLibraryDetail.fixture(id: id)
|
||||
}
|
||||
|
||||
/// 更新上下架。
|
||||
func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws {
|
||||
mediaOperationRequests.append(request)
|
||||
}
|
||||
|
||||
/// 删除素材。
|
||||
func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws {}
|
||||
|
||||
/// 新增标签。
|
||||
func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws {}
|
||||
|
||||
/// 上传素材。
|
||||
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws {
|
||||
mediaUploadRequests.append(request)
|
||||
}
|
||||
|
||||
/// 编辑素材。
|
||||
func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws {
|
||||
mediaEditRequests.append(request)
|
||||
}
|
||||
|
||||
/// 返回样片可关联项目列表。
|
||||
func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload<PhotographerProjectItem> {
|
||||
projectListRequests.append(ProjectListRequest(scenicId: scenicId, name: name, page: page, pageSize: pageSize))
|
||||
return projectPages.isEmpty ? ListPayload(total: 0, list: []) : projectPages.removeFirst()
|
||||
}
|
||||
|
||||
/// 返回相册文件夹列表。
|
||||
func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload<AlbumFolderItem> {
|
||||
albumFolderListRequests.append(AlbumFolderListRequest(scenicId: scenicId, page: page, pageSize: pageSize, name: name, startTime: startTime, endTime: endTime))
|
||||
return albumFolderPages.isEmpty ? ListPayload(total: 0, list: []) : albumFolderPages.removeFirst()
|
||||
}
|
||||
|
||||
/// 返回相册文件夹详情。
|
||||
func albumFolderInfo(id: Int) async throws -> AlbumFolderItem {
|
||||
albumInfoRequests.append(id)
|
||||
return AlbumFolderItem.fixture(id: id, name: "相册\(id)")
|
||||
}
|
||||
|
||||
/// 返回相册文件列表。
|
||||
func albumFileList(scenicId: Int, folderId: Int, fileType: Int, page: Int, pageSize: Int) async throws -> ListPayload<AlbumFileItem> {
|
||||
albumFileListRequests.append((scenicId: scenicId, folderId: folderId, fileType: fileType, page: page))
|
||||
return albumFilePages.isEmpty ? ListPayload(total: 0, list: []) : albumFilePages.removeFirst()
|
||||
}
|
||||
|
||||
/// 新建相册。
|
||||
func addAlbumFolder(scenicId: Int, name: String, remark: String) async throws {
|
||||
albumAddRequests.append((scenicId: scenicId, name: name, remark: remark))
|
||||
}
|
||||
|
||||
/// 编辑相册。
|
||||
func editAlbumFolder(folderId: Int, coverFileId: Int?, name: String?, remark: String?) async throws {
|
||||
albumEditRequests.append(AlbumFolderEditRequest(folderId: folderId, coverFileId: coverFileId, name: name, remark: remark))
|
||||
}
|
||||
|
||||
/// 删除相册文件。
|
||||
func deleteAlbumFiles(folderId: Int, idList: [Int]) async throws {
|
||||
albumDeleteRequests.append(AlbumFileDeleteRequest(idList: idList, folderId: folderId))
|
||||
}
|
||||
|
||||
/// 登记相册上传文件。
|
||||
func albumFileUploadURL(scenicId: Int, fileURL: String, folderId: Int) async throws {
|
||||
albumUploadURLRequests.append(AlbumUploadURLRequest(scenicId: scenicId, fileURL: fileURL, folderId: folderId))
|
||||
}
|
||||
}
|
||||
|
||||
/// OSS 上传测试替身,记录上传文件并返回固定 CDN URL。
|
||||
@MainActor
|
||||
private final class MockAssetsUploader: OSSUploadServing {
|
||||
struct UploadedFile: Equatable {
|
||||
let fileName: String
|
||||
let fileType: Int
|
||||
let scenicId: Int
|
||||
}
|
||||
|
||||
var uploadedFiles: [UploadedFile] = []
|
||||
var shouldFail = false
|
||||
|
||||
/// 上传用户头像。
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
|
||||
}
|
||||
|
||||
/// 上传实名认证图片。
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
|
||||
}
|
||||
|
||||
/// 上传云盘文件。
|
||||
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress)
|
||||
}
|
||||
|
||||
/// 上传相册文件。
|
||||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress)
|
||||
}
|
||||
|
||||
/// 上传任务附件。
|
||||
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress)
|
||||
}
|
||||
|
||||
/// 上传项目图片。
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
|
||||
}
|
||||
|
||||
/// 上传打卡点图片。
|
||||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
|
||||
}
|
||||
|
||||
/// 上传景区申请图片。
|
||||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
|
||||
}
|
||||
|
||||
/// 上传银行卡图片。
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
|
||||
}
|
||||
|
||||
/// 通用测试上传流程。
|
||||
private func upload(fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
if shouldFail {
|
||||
throw APIError.networkFailed("上传失败")
|
||||
}
|
||||
uploadedFiles.append(UploadedFile(fileName: fileName, fileType: fileType, scenicId: scenicId))
|
||||
onProgress(100)
|
||||
return "https://cdn.example.com/\(fileName)"
|
||||
}
|
||||
}
|
||||
|
||||
private extension MediaLibraryListResponse {
|
||||
/// 创建素材列表测试数据。
|
||||
static func fixture(total: Int, ids: [Int]) -> MediaLibraryListResponse {
|
||||
let list = ids.map { MediaLibraryItem.fixture(id: $0, status: 1, listingStatus: 0) }
|
||||
let payload = [
|
||||
"total": total,
|
||||
"list": list.map { ["id": $0.id, "name": $0.name, "status": $0.status, "listing_status": $0.listingStatus] },
|
||||
"order": ["total_num": 0, "avg_order_amount": 0, "refund_total": 0, "avg_change": 0]
|
||||
] as [String: Any]
|
||||
let data = try! JSONSerialization.data(withJSONObject: payload)
|
||||
return try! JSONDecoder().decode(MediaLibraryListResponse.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private extension MediaLibraryItem {
|
||||
/// 创建素材列表项测试数据。
|
||||
static func fixture(id: Int, status: Int, listingStatus: Int) -> MediaLibraryItem {
|
||||
let payload: [String: Any] = [
|
||||
"id": id,
|
||||
"name": "素材\(id)",
|
||||
"cover_url": "",
|
||||
"created_at": "",
|
||||
"status": status,
|
||||
"download_count": 0,
|
||||
"likes_count": 0,
|
||||
"collect_count": 0,
|
||||
"share_count": 0,
|
||||
"scenic_spot_name": "",
|
||||
"project_name": "",
|
||||
"listing_status": listingStatus
|
||||
]
|
||||
let data = try! JSONSerialization.data(withJSONObject: payload)
|
||||
return try! JSONDecoder().decode(MediaLibraryItem.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private extension MediaLibraryDetail {
|
||||
/// 创建素材详情测试数据。
|
||||
static func fixture(id: Int) -> MediaLibraryDetail {
|
||||
let payload: [String: Any] = [
|
||||
"id": id,
|
||||
"name": "素材\(id)",
|
||||
"cover_url": "",
|
||||
"description": "",
|
||||
"status": 1,
|
||||
"listing_status": 0,
|
||||
"likes_count": 0,
|
||||
"download_count": 0,
|
||||
"collect_count": 0,
|
||||
"share_count": 0,
|
||||
"uploader_name": "",
|
||||
"created_at": "",
|
||||
"media_list": [],
|
||||
"scenic_name": "",
|
||||
"project_name": ""
|
||||
]
|
||||
let data = try! JSONSerialization.data(withJSONObject: payload)
|
||||
return try! JSONDecoder().decode(MediaLibraryDetail.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AlbumFolderItem {
|
||||
/// 创建相册文件夹测试数据。
|
||||
static func fixture(id: Int, name: String) -> AlbumFolderItem {
|
||||
let payload: [String: Any] = [
|
||||
"id": id,
|
||||
"name": name,
|
||||
"cover_file_id": 0,
|
||||
"count_video": 1,
|
||||
"count_image": 2,
|
||||
"created_at": "2026",
|
||||
"remark": "备注"
|
||||
]
|
||||
let data = try! JSONSerialization.data(withJSONObject: payload)
|
||||
return try! JSONDecoder().decode(AlbumFolderItem.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AlbumFileItem {
|
||||
/// 创建相册文件测试数据。
|
||||
static func fixture(id: Int, fileType: Int) -> AlbumFileItem {
|
||||
let fileName = fileType == 1 ? "v.mp4" : "a.jpg"
|
||||
let payload: [String: Any] = [
|
||||
"id": id,
|
||||
"file_name": fileName,
|
||||
"file_type": fileType,
|
||||
"file_url": "https://cdn.example.com/\(fileName)",
|
||||
"cover_url": "",
|
||||
"file_size": "2048",
|
||||
"file_size_human": "2KB",
|
||||
"remark": ""
|
||||
]
|
||||
let data = try! JSONSerialization.data(withJSONObject: payload)
|
||||
return try! JSONDecoder().decode(AlbumFileItem.self, from: data)
|
||||
}
|
||||
}
|
||||
95
suixinkan_iosTests/AuthModelsTests.swift
Normal file
95
suixinkan_iosTests/AuthModelsTests.swift
Normal file
@ -0,0 +1,95 @@
|
||||
//
|
||||
// AuthModelsTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
/// Auth 模块模型测试,覆盖 v9 登录响应解码和账号转换规则。
|
||||
final class AuthModelsTests: XCTestCase {
|
||||
/// 测试多账号登录 fixture 能正确解码并合并景区、门店账号。
|
||||
func testV9AuthResponseDecodesMultiAccountFixture() throws {
|
||||
let response = try TestFixture.payload(V9AuthResponse.self, named: "v9_login_multi_success")
|
||||
|
||||
XCTAssertEqual(response.token, "person-temp-token")
|
||||
XCTAssertEqual(response.scenicUsers.count, 1)
|
||||
XCTAssertEqual(response.storeUsers.count, 1)
|
||||
XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201])
|
||||
}
|
||||
|
||||
/// 测试景区账号会优先使用 ss_user_id 作为业务账号 ID。
|
||||
func testV9ScenicUserBusinessUserIdPrefersSsUserId() throws {
|
||||
let json = """
|
||||
{
|
||||
"account_type": "scenic_user",
|
||||
"id": 1,
|
||||
"user_id": 2,
|
||||
"scenic_user_id": 3,
|
||||
"ss_user_id": 101,
|
||||
"username": "scenic_admin",
|
||||
"real_name": "张三",
|
||||
"nickname": "张三",
|
||||
"phone": "13800138000",
|
||||
"scenic_id": 10,
|
||||
"scenic_name": "示例景区",
|
||||
"is_current": false
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let user = try JSONDecoder().decode(V9ScenicUser.self, from: json)
|
||||
|
||||
XCTAssertEqual(user.businessUserId, 101)
|
||||
XCTAssertEqual(user.toAccountSwitchAccount().accountType, V9ScenicUser.accountTypeValue)
|
||||
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(ssUserId: 101))
|
||||
}
|
||||
|
||||
/// 测试门店账号会优先使用 store_user_id 作为业务账号 ID。
|
||||
func testV9StoreUserBusinessUserIdPrefersStoreUserId() throws {
|
||||
let json = """
|
||||
{
|
||||
"account_type": "store_user",
|
||||
"id": 1,
|
||||
"user_id": 2,
|
||||
"store_user_id": 201,
|
||||
"username": "store_admin",
|
||||
"user_name": "store_admin",
|
||||
"real_name": "张三",
|
||||
"phone": "13800138000",
|
||||
"avatar": "",
|
||||
"scenic_id": 10,
|
||||
"scenic_name": "示例景区",
|
||||
"store_id": 20,
|
||||
"store_name": "示例门店",
|
||||
"is_current": false
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let user = try JSONDecoder().decode(V9StoreUser.self, from: json)
|
||||
|
||||
XCTAssertEqual(user.businessUserId, 201)
|
||||
XCTAssertEqual(user.toAccountSwitchAccount().accountType, V9StoreUser.accountTypeValue)
|
||||
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201))
|
||||
}
|
||||
|
||||
/// 测试登录响应会派生景区和门店作用域,并去重。
|
||||
func testV9AuthResponseDerivesScopesFromAccounts() throws {
|
||||
let response = try TestFixture.payload(V9AuthResponse.self, named: "v9_login_multi_success")
|
||||
|
||||
XCTAssertEqual(response.scenicScopes.map(\.id), [10])
|
||||
XCTAssertEqual(response.storeScopes.map(\.id), [20])
|
||||
XCTAssertEqual(response.primaryProfile?.displayName, "示例门店")
|
||||
}
|
||||
|
||||
/// 测试 LoginRequest 会填充后端要求的默认字段。
|
||||
func testLoginRequestEncodesExpectedDefaults() throws {
|
||||
let request = LoginRequest(username: "18651857230", password: "secret123")
|
||||
let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(request)) as? [String: Any]
|
||||
|
||||
XCTAssertEqual(json?["username"] as? String, "18651857230")
|
||||
XCTAssertEqual(json?["password"] as? String, "secret123")
|
||||
XCTAssertEqual(json?["type"] as? Int, 1)
|
||||
XCTAssertEqual(json?["mobile"] as? String, "")
|
||||
XCTAssertEqual(json?["code"] as? String, "")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": "6101",
|
||||
"album_id": "5101",
|
||||
"name": "五一直播相册",
|
||||
"creator": "摄影师小苏",
|
||||
"items": [
|
||||
{
|
||||
"id": "7101",
|
||||
"url": "https://cdn.example.com/alive/photo.jpg",
|
||||
"type": "1",
|
||||
"size": "204800",
|
||||
"cover_img": ""
|
||||
},
|
||||
{
|
||||
"id": 7102,
|
||||
"url": "https://cdn.example.com/alive/video.mp4",
|
||||
"type": 2,
|
||||
"size": 10485760,
|
||||
"cover_img": "https://cdn.example.com/alive/video-cover.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
34
suixinkan_iosTests/Fixtures/alive_album_list_success.json
Normal file
34
suixinkan_iosTests/Fixtures/alive_album_list_success.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": "6101",
|
||||
"album_id": "5101",
|
||||
"name": "五一直播相册",
|
||||
"creator": "摄影师小苏",
|
||||
"items": [
|
||||
{
|
||||
"id": "7101",
|
||||
"url": "https://cdn.example.com/alive/photo.jpg",
|
||||
"type": "1",
|
||||
"size": "204800",
|
||||
"cover_img": ""
|
||||
},
|
||||
{
|
||||
"id": 7102,
|
||||
"url": "https://cdn.example.com/alive/video.mp4",
|
||||
"type": 2,
|
||||
"size": 10485760,
|
||||
"cover_img": "https://cdn.example.com/alive/video-cover.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"page": "1",
|
||||
"page_size": "1",
|
||||
"total": "2",
|
||||
"total_pages": "2"
|
||||
}
|
||||
}
|
||||
18
suixinkan_iosTests/Fixtures/alive_detail_success.json
Normal file
18
suixinkan_iosTests/Fixtures/alive_detail_success.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": "8101",
|
||||
"title": "景区晨间直播",
|
||||
"cover_img": "https://cdn.example.com/alive/cover.jpg",
|
||||
"push_url": "rtmp://push.example.com/live/8101",
|
||||
"start_time": "1779505200",
|
||||
"end_time": "1779508800",
|
||||
"duration": "3600",
|
||||
"status": "2",
|
||||
"status_label": "直播中",
|
||||
"manaul_push_mode": "2",
|
||||
"manaul_push_state": "1",
|
||||
"views_count": "1234"
|
||||
}
|
||||
}
|
||||
25
suixinkan_iosTests/Fixtures/alive_list_success.json
Normal file
25
suixinkan_iosTests/Fixtures/alive_list_success.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": "8101",
|
||||
"title": "景区晨间直播",
|
||||
"cover_img": "https://cdn.example.com/alive/cover.jpg",
|
||||
"push_url": "rtmp://push.example.com/live/8101",
|
||||
"start_time": "1779505200",
|
||||
"end_time": "1779508800",
|
||||
"duration": "3600",
|
||||
"status": "2",
|
||||
"status_label": "直播中",
|
||||
"manaul_push_mode": "2",
|
||||
"manaul_push_state": "1",
|
||||
"views_count": "1234"
|
||||
}
|
||||
],
|
||||
"total": "2",
|
||||
"page": "1",
|
||||
"page_size": "1"
|
||||
}
|
||||
}
|
||||
14
suixinkan_iosTests/Fixtures/available_order_success.json
Normal file
14
suixinkan_iosTests/Fixtures/available_order_success.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": [
|
||||
{
|
||||
"project_name": "亲子跟拍",
|
||||
"order_number": "ORD-TASK-001",
|
||||
"order_status": "20",
|
||||
"order_status_label": "已支付",
|
||||
"pay_time": "2026-06-21 09:55:00",
|
||||
"user_phone": "13800000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
33
suixinkan_iosTests/Fixtures/cloud_file_list_success.json
Normal file
33
suixinkan_iosTests/Fixtures/cloud_file_list_success.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": "2",
|
||||
"list": [
|
||||
{
|
||||
"id": "501",
|
||||
"parent_folder_id": "0",
|
||||
"file_url": "",
|
||||
"cover_url": "",
|
||||
"updated_at": "2026-06-20 10:00:00",
|
||||
"name": "任务素材",
|
||||
"created_at": "2026-06-20 09:00:00",
|
||||
"child_num": "3",
|
||||
"type": "99",
|
||||
"file_size": "0"
|
||||
},
|
||||
{
|
||||
"id": 502,
|
||||
"parent_folder_id": 0,
|
||||
"file_url": "https://cdn.example.com/cloud/photo.jpg",
|
||||
"cover_url": "",
|
||||
"updated_at": "2026-06-20 10:00:00",
|
||||
"name": "photo.jpg",
|
||||
"created_at": "2026-06-20 09:00:00",
|
||||
"child_num": 0,
|
||||
"type": 2,
|
||||
"file_size": 2048
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
27
suixinkan_iosTests/Fixtures/deposit_order_list_success.json
Normal file
27
suixinkan_iosTests/Fixtures/deposit_order_list_success.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": "2",
|
||||
"list": [
|
||||
{
|
||||
"id": "4101",
|
||||
"order_number": "DEP001",
|
||||
"user_phone": "13800000000",
|
||||
"amount": 99.5,
|
||||
"status": "20",
|
||||
"status_name": "待核销",
|
||||
"created_at": "2026-05-23 09:00:00"
|
||||
},
|
||||
{
|
||||
"id": 4102,
|
||||
"order_number": "DEP002",
|
||||
"user_phone": 13900000000,
|
||||
"amount": "199.00",
|
||||
"status": 50,
|
||||
"status_name": "已退款",
|
||||
"created_at": "2026-05-22 18:30:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
4
suixinkan_iosTests/Fixtures/empty_success.json
Normal file
4
suixinkan_iosTests/Fixtures/empty_success.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success"
|
||||
}
|
||||
38
suixinkan_iosTests/Fixtures/flyer_detail_success.json
Normal file
38
suixinkan_iosTests/Fixtures/flyer_detail_success.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": "801",
|
||||
"flyer_nickname": "航拍摄影师",
|
||||
"account_id": "42",
|
||||
"realname_status": "2",
|
||||
"status": "1",
|
||||
"status_text": "审核中",
|
||||
"created_at": "2026-05-23 04:11:00",
|
||||
"updated_at": "2026-05-23 04:12:00",
|
||||
"reviewer": "审核员",
|
||||
"review_time": "2026-05-23 04:15:00",
|
||||
"reject_reason": "",
|
||||
"certificate_type": "2",
|
||||
"certificate_no": "UAS-2026-001",
|
||||
"certificate_start_date": "2026-01-01",
|
||||
"certificate_end_date": "2028-01-01",
|
||||
"certificate_image": "https://cdn.example.com/flyer/cert.jpg",
|
||||
"drone_model": "DJI Mini",
|
||||
"drone_sn": "SN20260523001",
|
||||
"contact_phone": "13800000000",
|
||||
"realname_status_text": "已实名",
|
||||
"flyers_certification_logs": [
|
||||
{
|
||||
"id": "9001",
|
||||
"flyer_id": "801",
|
||||
"operator": "审核员",
|
||||
"action": "2",
|
||||
"action_text": "通过",
|
||||
"reject_reason": "",
|
||||
"remark": "资料齐全",
|
||||
"created_at": "2026-05-23 04:15:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
30
suixinkan_iosTests/Fixtures/message_list_page2_success.json
Normal file
30
suixinkan_iosTests/Fixtures/message_list_page2_success.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"has_more": 0,
|
||||
"last_id": 99,
|
||||
"items": [
|
||||
{
|
||||
"id": 100,
|
||||
"type": 2,
|
||||
"type_name": "核销",
|
||||
"title": "",
|
||||
"content": "订单已核销",
|
||||
"push_at": "2026-05-23 02:50",
|
||||
"created_at": "2026-05-23 02:50",
|
||||
"is_read": "1"
|
||||
},
|
||||
{
|
||||
"id": 99,
|
||||
"type": 0,
|
||||
"type_name": "",
|
||||
"title": "",
|
||||
"content": "系统维护通知",
|
||||
"push_at": "2026-05-23 02:40",
|
||||
"created_at": "2026-05-23 02:40",
|
||||
"is_read": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
30
suixinkan_iosTests/Fixtures/message_list_success.json
Normal file
30
suixinkan_iosTests/Fixtures/message_list_success.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"has_more": 1,
|
||||
"last_id": "101",
|
||||
"items": [
|
||||
{
|
||||
"id": "101",
|
||||
"type": "1",
|
||||
"type_name": "订单",
|
||||
"title": "新订单提醒",
|
||||
"content": "你有新的拍摄订单",
|
||||
"push_at": "2026-05-23 02:55",
|
||||
"created_at": "2026-05-23 02:55",
|
||||
"is_read": 0
|
||||
},
|
||||
{
|
||||
"id": 100,
|
||||
"type": 2,
|
||||
"type_name": "核销",
|
||||
"title": "",
|
||||
"content": "订单已核销",
|
||||
"push_at": "2026-05-23 02:50",
|
||||
"created_at": "2026-05-23 02:50",
|
||||
"is_read": "1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"project_name": "多点旅拍",
|
||||
"project_type": "19",
|
||||
"project_type_name": "多点旅拍",
|
||||
"photog_spot_list": [
|
||||
{
|
||||
"scenic_spot_id": "901",
|
||||
"photog_uid": "42",
|
||||
"scenic_spot_name": "湖心亭",
|
||||
"photog_nickname": "小景",
|
||||
"photog_name": "测试摄影师",
|
||||
"files": [
|
||||
{
|
||||
"file_name": "spot-901.jpg",
|
||||
"file_url": "https://cdn.example.com/multi/spot-901.jpg",
|
||||
"file_type": "1",
|
||||
"file_size": "204800",
|
||||
"cover_url": "",
|
||||
"upload_time": "2026-05-23 04:22:00"
|
||||
},
|
||||
{
|
||||
"file_name": "spot-901.mp4",
|
||||
"file_url": "https://cdn.example.com/multi/spot-901.mp4",
|
||||
"file_type": 2,
|
||||
"file_size": 1048576,
|
||||
"cover_url": "https://cdn.example.com/multi/spot-901-cover.jpg",
|
||||
"upload_time": "2026-05-23 04:23:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": [
|
||||
{
|
||||
"id": 901,
|
||||
"name": "湖心亭"
|
||||
},
|
||||
{
|
||||
"id": "902",
|
||||
"name": "观景台"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
suixinkan_iosTests/Fixtures/operating_area_empty.json
Normal file
8
suixinkan_iosTests/Fixtures/operating_area_empty.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": 0,
|
||||
"list": []
|
||||
}
|
||||
}
|
||||
46
suixinkan_iosTests/Fixtures/operating_area_success.json
Normal file
46
suixinkan_iosTests/Fixtures/operating_area_success.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": 4,
|
||||
"list": [
|
||||
{
|
||||
"id": "101",
|
||||
"name": "东门门店",
|
||||
"business_map_area": [[[118.781, 32.041], [118.782, 32.041], [118.782, 32.042], [118.781, 32.041]]],
|
||||
"status_text": "启用",
|
||||
"type_text": "门店",
|
||||
"audit_status_text": "通过"
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"name": "西门门店",
|
||||
"business_map_area": "{\"type\":\"Polygon\",\"coordinates\":[[[118.791,32.051],[118.792,32.051],[118.792,32.052],[118.791,32.051]]]}",
|
||||
"status_text": "启用",
|
||||
"type_text": "门店",
|
||||
"audit_status_text": "通过"
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"name": "北区",
|
||||
"business_map_area": {
|
||||
"type": "MultiPolygon",
|
||||
"coordinates": [
|
||||
[[[118.801, 32.061], [118.802, 32.061], [118.802, 32.062], [118.801, 32.061]]]
|
||||
]
|
||||
},
|
||||
"status_text": "启用",
|
||||
"type_text": "区域",
|
||||
"audit_status_text": "通过"
|
||||
},
|
||||
{
|
||||
"id": 104,
|
||||
"name": "无效区域",
|
||||
"business_map_area": [[118.811, 32.071], [118.812, 32.071]],
|
||||
"status_text": "停用",
|
||||
"type_text": "区域",
|
||||
"audit_status_text": "待审核"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
83
suixinkan_iosTests/Fixtures/order_v2_success.json
Normal file
83
suixinkan_iosTests/Fixtures/order_v2_success.json
Normal file
@ -0,0 +1,83 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": "2",
|
||||
"list": [
|
||||
{
|
||||
"photog_uid": "42",
|
||||
"order_number": "ORDV2001",
|
||||
"remark": "需要精修",
|
||||
"scenic_area_id": "88",
|
||||
"pay_time": "2026-05-23 04:40:00",
|
||||
"complete_time": "",
|
||||
"order_status": "20",
|
||||
"order_status_name": "已支付",
|
||||
"user_id": "7001",
|
||||
"project_id": "5101",
|
||||
"phone": "13800000000",
|
||||
"order_amount": "299.50",
|
||||
"actual_pay_amount": "299.50",
|
||||
"actual_refund_amount": "0",
|
||||
"refund_amount": "0",
|
||||
"pay_type_name": "微信支付",
|
||||
"pay_type": "1",
|
||||
"project_name": "亲子跟拍",
|
||||
"order_type": "19",
|
||||
"order_type_label": "多点旅拍",
|
||||
"deposit_pay_time": "",
|
||||
"created_at": "2026-05-23 04:39:00",
|
||||
"photo_travel": {
|
||||
"order_photo_num": "9",
|
||||
"order_video_num": "1",
|
||||
"can_cancel_order": "1",
|
||||
"need_check_in": "0",
|
||||
"check_in_time": "",
|
||||
"can_gift_retouch": "1",
|
||||
"retouch_gift_time": "2026-05-24 04:40:00",
|
||||
"retouch_gift_photo_num": "2",
|
||||
"retouch_gift_video_num": "0"
|
||||
},
|
||||
"multi_travel": {
|
||||
"material_list": [
|
||||
{
|
||||
"file_name": "spot-901.jpg",
|
||||
"file_url": "https://cdn.example.com/order/spot-901.jpg",
|
||||
"file_type": "1",
|
||||
"file_size": "204800",
|
||||
"cover_url": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"isNeedEdit": "1",
|
||||
"is_refined": "1"
|
||||
},
|
||||
{
|
||||
"photog_uid": 43,
|
||||
"order_number": "ORDV2002",
|
||||
"remark": "",
|
||||
"scenic_area_id": 88,
|
||||
"pay_time": "2026-05-23 04:42:00",
|
||||
"complete_time": "2026-05-23 05:00:00",
|
||||
"order_status": 30,
|
||||
"order_status_name": "已完成",
|
||||
"user_id": 7002,
|
||||
"project_id": 5102,
|
||||
"phone": "13900000000",
|
||||
"order_amount": "99.00",
|
||||
"actual_pay_amount": "99.00",
|
||||
"actual_refund_amount": "0",
|
||||
"refund_amount": "0",
|
||||
"pay_type_name": "余额支付",
|
||||
"pay_type": 3,
|
||||
"project_name": "押金体验",
|
||||
"order_type": 4,
|
||||
"order_type_label": "押金",
|
||||
"deposit_pay_time": "2026-05-23 04:41:00",
|
||||
"created_at": "2026-05-23 04:41:00",
|
||||
"isNeedEdit": false,
|
||||
"is_refined": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
26
suixinkan_iosTests/Fixtures/real_name_info_success.json
Normal file
26
suixinkan_iosTests/Fixtures/real_name_info_success.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"real_name_info": {
|
||||
"real_name": "测试摄影师",
|
||||
"id_card_no": "320100********0012",
|
||||
"verified": "1",
|
||||
"start_date": "2026-01-01",
|
||||
"end_date": "2046-01-01",
|
||||
"is_long_valid": "0",
|
||||
"front_url": "https://cdn.example.com/front.jpg",
|
||||
"back_url": "https://cdn.example.com/back.jpg",
|
||||
"created_at": "2026-05-23 04:00:00",
|
||||
"audit_status": "2",
|
||||
"audit_status_text": "审核通过",
|
||||
"audit_at": "2026-05-23 04:05:00",
|
||||
"auditor_id": "7",
|
||||
"auditor": {
|
||||
"id": 7,
|
||||
"name": "审核员"
|
||||
},
|
||||
"reject_reason": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
63
suixinkan_iosTests/Fixtures/scenic_queue_home_success.json
Normal file
63
suixinkan_iosTests/Fixtures/scenic_queue_home_success.json
Normal file
@ -0,0 +1,63 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"stats": {
|
||||
"type": "1"
|
||||
},
|
||||
"list": {
|
||||
"total": "2",
|
||||
"page": "1",
|
||||
"page_size": "20",
|
||||
"list": [
|
||||
{
|
||||
"id": "1001",
|
||||
"queue_code": "A001",
|
||||
"mobile": "13800000000",
|
||||
"status": "1",
|
||||
"status_text": "等待中",
|
||||
"wait_min": "8",
|
||||
"ahead_count": "0",
|
||||
"is_called": "1",
|
||||
"queue_ban_label": "限制排队7天",
|
||||
"identity_tag": "打野摄影师",
|
||||
"queue_time": "2026-05-23 03:05:00",
|
||||
"queue_count_today": "2",
|
||||
"uid": "7001",
|
||||
"mark_as_photog": "0",
|
||||
"mark_as_freelance_photog": "1",
|
||||
"is_miss_requeue": "1",
|
||||
"is_miss_requeue_text": "重排",
|
||||
"created_at": "2026-05-23 03:05",
|
||||
"called_at": "2026-05-23 03:06",
|
||||
"expired_at": "",
|
||||
"finished_at": ""
|
||||
},
|
||||
{
|
||||
"id": 1002,
|
||||
"queue_code": "A002",
|
||||
"mobile": "13900000000",
|
||||
"status": 1,
|
||||
"status_text": "等待中",
|
||||
"wait_min": 10,
|
||||
"ahead_count": 1,
|
||||
"is_called": 0,
|
||||
"queue_ban_label": "",
|
||||
"identity_tag": "普通用户",
|
||||
"queue_time": "05-23 03:07",
|
||||
"queue_count_today": 1,
|
||||
"uid": 7002,
|
||||
"mark_as_photog": 1,
|
||||
"mark_as_freelance_photog": 0,
|
||||
"is_miss_requeue": 0,
|
||||
"is_miss_requeue_text": "",
|
||||
"created_at": "2026-05-23 03:07",
|
||||
"called_at": "",
|
||||
"expired_at": "",
|
||||
"finished_at": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": "2026-05-23 03:08"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"qrcode_url": "https://cdn.example.com/queue/spot-9.png"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"exists": "1",
|
||||
"setting": {
|
||||
"id": "11",
|
||||
"scenic_id": "88",
|
||||
"scenic_spot_id": "9",
|
||||
"scenic_spot_name": "东门打卡点",
|
||||
"photo_estimate_min": "2",
|
||||
"photo_estimate_sec": "30",
|
||||
"first_notice_threshold_pos": "3",
|
||||
"first_notice_sms_enabled": "1",
|
||||
"first_notice_call_enabled": "1",
|
||||
"second_notice_threshold_pos": "1",
|
||||
"second_notice_sms_enabled": "0",
|
||||
"second_notice_call_enabled": "1",
|
||||
"countdown_broadcast_interval_sec": "15",
|
||||
"countdown_readable_threshold_sec": "60",
|
||||
"business_start_time": "08:00:00",
|
||||
"business_end_time": "22:00:59",
|
||||
"status": "1",
|
||||
"remark": "自动叫号",
|
||||
"voice_broadcasts": [
|
||||
{
|
||||
"content": "A001请到拍摄点",
|
||||
"sort_order": "1"
|
||||
},
|
||||
{
|
||||
"content": "请后续游客到等候区",
|
||||
"sort_order": "2"
|
||||
}
|
||||
],
|
||||
"created_at": "2026-05-23 03:10",
|
||||
"updated_at": "2026-05-23 03:11"
|
||||
},
|
||||
"time": "2026-05-23 03:12"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"queue_count": "5",
|
||||
"avg_wait_min": "12.5",
|
||||
"time": "2026-05-23 03:10"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": "2",
|
||||
"list": [
|
||||
{
|
||||
"id": "901",
|
||||
"name": "湖心亭",
|
||||
"status": "1",
|
||||
"status_label": "启用"
|
||||
},
|
||||
{
|
||||
"id": 902,
|
||||
"name": "观景台",
|
||||
"status": 0,
|
||||
"status_label": "停用"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
4
suixinkan_iosTests/Fixtures/server_code_error.json
Normal file
4
suixinkan_iosTests/Fixtures/server_code_error.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"code": 200001,
|
||||
"msg": "登录已过期"
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": 3,
|
||||
"data": [
|
||||
{
|
||||
"date": "2026-05-23",
|
||||
"order_count": "5",
|
||||
"order_price": "1299.50",
|
||||
"refund": "0",
|
||||
"received": "1299.50"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": 3,
|
||||
"data": [
|
||||
{
|
||||
"date": "2026-05-22",
|
||||
"order_count": 3,
|
||||
"order_price": "650.00",
|
||||
"refund": "50.00",
|
||||
"received": 600
|
||||
},
|
||||
{
|
||||
"date": "2026-05-21",
|
||||
"order_count": 2,
|
||||
"order_price": "420.00",
|
||||
"refund": "0",
|
||||
"received": "420.00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
11
suixinkan_iosTests/Fixtures/statistics_summary_success.json
Normal file
11
suixinkan_iosTests/Fixtures/statistics_summary_success.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"order_amount_sum": "¥12,345.67",
|
||||
"order_count": "18",
|
||||
"order_price_avg": "685.87",
|
||||
"received_amount_sum": "12000.00",
|
||||
"refund_amount_sum": "345.67"
|
||||
}
|
||||
}
|
||||
42
suixinkan_iosTests/Fixtures/store_order_detail_success.json
Normal file
42
suixinkan_iosTests/Fixtures/store_order_detail_success.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"order_number": "ORDV2001",
|
||||
"order_type": "19",
|
||||
"order_type_label": "多点旅拍",
|
||||
"created_at": "2026-05-23 04:39:00",
|
||||
"order_status": "20",
|
||||
"order_status_name": "已支付",
|
||||
"actual_pay_amount": "299.50",
|
||||
"actual_refund_amount": 0,
|
||||
"pay_type_name": "微信支付",
|
||||
"pay_time": "2026-05-23 04:40:00",
|
||||
"complete_time": "",
|
||||
"user_id": "7001",
|
||||
"phone": "13800000000",
|
||||
"project_id": "5101",
|
||||
"project_name": "亲子跟拍",
|
||||
"remark": "需要精修",
|
||||
"multi_travel": {
|
||||
"project_info": {
|
||||
"settle_spot_num": "3",
|
||||
"single_spot_material_num": 8,
|
||||
"single_spot_photo_num": "2",
|
||||
"single_spot_video_num": 1
|
||||
},
|
||||
"shooting_list": [
|
||||
{
|
||||
"scenic_spot_id": "901",
|
||||
"photog_uid": "42",
|
||||
"scenic_spot_name": "东门",
|
||||
"staff_name": "",
|
||||
"photog_name": "张三",
|
||||
"status": "2",
|
||||
"start_avg": "4.8",
|
||||
"start": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"scenic_spot_id": "901",
|
||||
"scenic_spot_name": "湖心亭",
|
||||
"order_type": "4",
|
||||
"order_type_name": "押金订单",
|
||||
"order_comment": {
|
||||
"star_shooting": "5",
|
||||
"star_retouching": "4",
|
||||
"star_scenery": "5",
|
||||
"star_service": "5",
|
||||
"star_camera": "4",
|
||||
"content": "拍摄体验很好",
|
||||
"created_at": "2026-05-23 12:00:00"
|
||||
},
|
||||
"material_list": [
|
||||
{
|
||||
"file_name": "negative-video.mp4",
|
||||
"file_url": "https://cdn.example.com/deposit/negative-video.mp4",
|
||||
"file_type": "1",
|
||||
"file_size": "10485760",
|
||||
"cover_url": "https://cdn.example.com/deposit/negative-video.jpg"
|
||||
},
|
||||
{
|
||||
"file_name": "negative-photo.jpg",
|
||||
"file_url": "https://cdn.example.com/deposit/negative-photo.jpg",
|
||||
"file_type": 2,
|
||||
"file_size": 204800,
|
||||
"cover_url": ""
|
||||
}
|
||||
],
|
||||
"complete_list": [
|
||||
{
|
||||
"file_name": "retouch.jpg",
|
||||
"file_url": "https://cdn.example.com/deposit/retouch.jpg",
|
||||
"file_type": "2",
|
||||
"file_size": "409600",
|
||||
"cover_url": "https://cdn.example.com/deposit/retouch-thumb.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
38
suixinkan_iosTests/Fixtures/task_detail_success.json
Normal file
38
suixinkan_iosTests/Fixtures/task_detail_success.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": "101",
|
||||
"name": "游客精修任务",
|
||||
"created_at": "2026-06-21 10:00:00",
|
||||
"task_status": "2",
|
||||
"task_status_label": "处理中",
|
||||
"accepted_at": "2026-06-21 10:05:00",
|
||||
"editor_name": "小李",
|
||||
"editor_phone": "13900000000",
|
||||
"urgent_hour": "24",
|
||||
"photog_remark": "请优先处理亲子照片",
|
||||
"order": {
|
||||
"project_name": "亲子跟拍",
|
||||
"order_number": "ORD-TASK-001",
|
||||
"pay_time": "2026-06-21 09:55:00",
|
||||
"user_phone": "13800000000"
|
||||
},
|
||||
"task_result": [
|
||||
{
|
||||
"id": 9101,
|
||||
"photog_task_id": 101,
|
||||
"user_id": 7001,
|
||||
"file_name": "result.mp4",
|
||||
"file_type": 1,
|
||||
"file_url": "https://cdn.example.com/task/result.mp4",
|
||||
"file_size": 4096,
|
||||
"cover_url": "https://cdn.example.com/task/result-cover.jpg",
|
||||
"duration": 12,
|
||||
"remark": "",
|
||||
"created_at": "2026-06-21 11:00:00",
|
||||
"updated_at": "2026-06-21 11:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
26
suixinkan_iosTests/Fixtures/task_list_page2_success.json
Normal file
26
suixinkan_iosTests/Fixtures/task_list_page2_success.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": 3,
|
||||
"list": [
|
||||
{
|
||||
"id": 103,
|
||||
"name": "补传照片",
|
||||
"type": 1,
|
||||
"order_number": "ORD-TASK-003",
|
||||
"task_status": 3,
|
||||
"status_name": "已完成",
|
||||
"created_at": "2026-06-23 09:00:00",
|
||||
"updated_at": "2026-06-23 09:30:00",
|
||||
"photog_remark": "",
|
||||
"editor_name": "小张",
|
||||
"operate_time": "",
|
||||
"order_user_nickname": "",
|
||||
"order_user_phone": "",
|
||||
"order_user_id": "",
|
||||
"media": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
58
suixinkan_iosTests/Fixtures/task_list_success.json
Normal file
58
suixinkan_iosTests/Fixtures/task_list_success.json
Normal file
@ -0,0 +1,58 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": "2",
|
||||
"list": [
|
||||
{
|
||||
"id": "101",
|
||||
"name": "游客精修任务",
|
||||
"type": "1",
|
||||
"order_number": "ORD-TASK-001",
|
||||
"task_status": "2",
|
||||
"status_name": "处理中",
|
||||
"created_at": "2026-06-21 10:00:00",
|
||||
"updated_at": "2026-06-21 10:30:00",
|
||||
"photog_remark": "请优先处理亲子照片",
|
||||
"editor_name": "小李",
|
||||
"operate_time": "2026-06-21 10:31:00",
|
||||
"order_user_nickname": "王女士",
|
||||
"order_user_phone": "13800000000",
|
||||
"order_user_id": "7001",
|
||||
"media": [
|
||||
{
|
||||
"id": "9001",
|
||||
"photog_task_id": "101",
|
||||
"user_id": "7001",
|
||||
"file_name": "cover.jpg",
|
||||
"file_type": "2",
|
||||
"file_url": "https://cdn.example.com/task/cover.jpg",
|
||||
"file_size": "2048",
|
||||
"cover_url": "",
|
||||
"duration": "0",
|
||||
"remark": "",
|
||||
"created_at": "2026-06-21 10:01:00",
|
||||
"updated_at": "2026-06-21 10:01:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"name": "短视频剪辑",
|
||||
"type": 1,
|
||||
"order_number": "ORD-TASK-002",
|
||||
"task_status": 1,
|
||||
"status_name": "待接收",
|
||||
"created_at": "2026-06-22 09:00:00",
|
||||
"updated_at": "2026-06-22 09:00:00",
|
||||
"photog_remark": "",
|
||||
"editor_name": "",
|
||||
"operate_time": "",
|
||||
"order_user_nickname": "",
|
||||
"order_user_phone": "",
|
||||
"order_user_id": "",
|
||||
"media": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
13
suixinkan_iosTests/Fixtures/user_info_success.json
Normal file
13
suixinkan_iosTests/Fixtures/user_info_success.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"avatar": "https://example.com/avatar.jpg",
|
||||
"real_name": "测试摄影师",
|
||||
"phone": "13800000000",
|
||||
"nickname": "跟拍小苏",
|
||||
"role_name": "摄影师",
|
||||
"status": 1,
|
||||
"status_name": "已认证"
|
||||
}
|
||||
}
|
||||
67
suixinkan_iosTests/Fixtures/v9_login_multi_success.json
Normal file
67
suixinkan_iosTests/Fixtures/v9_login_multi_success.json
Normal file
@ -0,0 +1,67 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"token": "person-temp-token",
|
||||
"scenic_users": [
|
||||
{
|
||||
"account_type": "scenic_user",
|
||||
"id": 101,
|
||||
"user_id": 101,
|
||||
"scenic_user_id": 101,
|
||||
"ss_user_id": 101,
|
||||
"username": "scenic_admin",
|
||||
"real_name": "张三",
|
||||
"nickname": "张三",
|
||||
"phone": "13800138000",
|
||||
"scenic_id": 10,
|
||||
"scenic_name": "示例景区",
|
||||
"status": 1,
|
||||
"status_label": "正常",
|
||||
"app_role_id": 3,
|
||||
"app_role_code": "scenic_admin",
|
||||
"app_role_name": "景区管理员",
|
||||
"app_role": {
|
||||
"id": 3,
|
||||
"legacy_role_id": 53,
|
||||
"code": "scenic_admin",
|
||||
"name": "景区管理员",
|
||||
"notes": "",
|
||||
"status": 1
|
||||
},
|
||||
"is_current": false
|
||||
}
|
||||
],
|
||||
"store_users": [
|
||||
{
|
||||
"account_type": "store_user",
|
||||
"id": 201,
|
||||
"user_id": 201,
|
||||
"store_user_id": 201,
|
||||
"username": "store_admin",
|
||||
"user_name": "store_admin",
|
||||
"real_name": "张三",
|
||||
"phone": "13800138000",
|
||||
"avatar": "https://example.com/avatar.png",
|
||||
"scenic_id": 10,
|
||||
"scenic_name": "示例景区",
|
||||
"store_id": 20,
|
||||
"store_name": "示例门店",
|
||||
"status": 1,
|
||||
"status_label": "正常",
|
||||
"app_role_id": 1,
|
||||
"app_role_code": "store_admin",
|
||||
"app_role_name": "店铺管理员",
|
||||
"app_role": {
|
||||
"id": 1,
|
||||
"legacy_role_id": 46,
|
||||
"code": "store_admin",
|
||||
"name": "店铺管理员",
|
||||
"notes": "",
|
||||
"status": 1
|
||||
},
|
||||
"is_current": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"token": "person-temp-token",
|
||||
"scenic_users": [
|
||||
{
|
||||
"account_type": "scenic_user",
|
||||
"id": 101,
|
||||
"user_id": 101,
|
||||
"scenic_user_id": 101,
|
||||
"ss_user_id": 101,
|
||||
"username": "scenic_admin",
|
||||
"real_name": "张三",
|
||||
"nickname": "张三",
|
||||
"phone": "13800138000",
|
||||
"scenic_id": 10,
|
||||
"scenic_name": "示例景区",
|
||||
"is_current": false
|
||||
}
|
||||
],
|
||||
"store_users": []
|
||||
}
|
||||
}
|
||||
24
suixinkan_iosTests/Fixtures/v9_set_scenic_user_success.json
Normal file
24
suixinkan_iosTests/Fixtures/v9_set_scenic_user_success.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"token": "business-token-scenic",
|
||||
"scenic_users": [
|
||||
{
|
||||
"account_type": "scenic_user",
|
||||
"id": 101,
|
||||
"user_id": 101,
|
||||
"scenic_user_id": 101,
|
||||
"ss_user_id": 101,
|
||||
"username": "scenic_admin",
|
||||
"real_name": "张三",
|
||||
"nickname": "张三",
|
||||
"phone": "13800138000",
|
||||
"scenic_id": 10,
|
||||
"scenic_name": "示例景区",
|
||||
"is_current": true
|
||||
}
|
||||
],
|
||||
"store_users": []
|
||||
}
|
||||
}
|
||||
39
suixinkan_iosTests/Fixtures/v9_set_store_user_success.json
Normal file
39
suixinkan_iosTests/Fixtures/v9_set_store_user_success.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"token": "business-token-store",
|
||||
"scenic_users": [],
|
||||
"store_users": [
|
||||
{
|
||||
"account_type": "store_user",
|
||||
"id": 201,
|
||||
"user_id": 201,
|
||||
"store_user_id": 201,
|
||||
"username": "store_admin",
|
||||
"user_name": "store_admin",
|
||||
"real_name": "张三",
|
||||
"phone": "13800138000",
|
||||
"avatar": "https://example.com/avatar.png",
|
||||
"scenic_id": 10,
|
||||
"scenic_name": "示例景区",
|
||||
"store_id": 20,
|
||||
"store_name": "示例门店",
|
||||
"status": 1,
|
||||
"status_label": "正常",
|
||||
"app_role_id": 1,
|
||||
"app_role_code": "store_admin",
|
||||
"app_role_name": "店铺管理员",
|
||||
"app_role": {
|
||||
"id": 1,
|
||||
"legacy_role_id": 46,
|
||||
"code": "store_admin",
|
||||
"name": "店铺管理员",
|
||||
"notes": "",
|
||||
"status": 1
|
||||
},
|
||||
"is_current": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
19
suixinkan_iosTests/Fixtures/writeoff_list_success.json
Normal file
19
suixinkan_iosTests/Fixtures/writeoff_list_success.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": 1,
|
||||
"list": [
|
||||
{
|
||||
"order_number": "VERIFY001",
|
||||
"order_verification_status": "待核销",
|
||||
"pay_time": "2026-05-23 03:03",
|
||||
"project_name": "核销套餐",
|
||||
"user_phone": "13800000000",
|
||||
"order_amount": "99.00",
|
||||
"order_status_name": "已支付",
|
||||
"order_verification_time": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
113
suixinkan_iosTests/GlobalLoadingCenterTests.swift
Normal file
113
suixinkan_iosTests/GlobalLoadingCenterTests.swift
Normal file
@ -0,0 +1,113 @@
|
||||
//
|
||||
// GlobalLoadingCenterTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 全局 Loading 命令中心测试,验证展示状态不会泄漏给业务页面使用。
|
||||
final class GlobalLoadingCenterTests: XCTestCase {
|
||||
/// 测试多次 show/hide 会按引用计数控制最终显隐。
|
||||
func testShowHideUsesReferenceCount() {
|
||||
let center = GlobalLoadingCenter()
|
||||
|
||||
center.show(message: "加载账号")
|
||||
center.show(message: "加载权限")
|
||||
|
||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 2))
|
||||
|
||||
center.hide()
|
||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 1))
|
||||
|
||||
center.hide()
|
||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
||||
}
|
||||
|
||||
/// 测试 hide 调用次数超过 show 时不会产生负数计数。
|
||||
func testHideNeverDropsReferenceCountBelowZero() {
|
||||
let center = GlobalLoadingCenter()
|
||||
|
||||
center.hide()
|
||||
|
||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
||||
}
|
||||
|
||||
/// 测试更新文案只影响当前展示内容,不改变引用计数。
|
||||
func testUpdateMessageOnlyChangesVisibleMessage() {
|
||||
let center = GlobalLoadingCenter()
|
||||
|
||||
center.updateMessage("不会展示")
|
||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
||||
|
||||
center.show(message: "加载中")
|
||||
center.updateMessage("即将完成")
|
||||
|
||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "即将完成", activeCount: 1))
|
||||
}
|
||||
|
||||
/// 测试 withLoading 成功完成后自动隐藏 Loading。
|
||||
func testWithLoadingHidesAfterSuccess() async throws {
|
||||
let center = GlobalLoadingCenter()
|
||||
|
||||
let value = try await center.withLoading(message: "提交中") {
|
||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "提交中", activeCount: 1))
|
||||
return 42
|
||||
}
|
||||
|
||||
XCTAssertEqual(value, 42)
|
||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
||||
}
|
||||
|
||||
/// 测试 withLoading 抛错时也会自动隐藏 Loading。
|
||||
func testWithLoadingHidesAfterFailure() async {
|
||||
let center = GlobalLoadingCenter()
|
||||
|
||||
do {
|
||||
_ = try await center.withLoading(message: "提交中") {
|
||||
throw TestFailure.expected
|
||||
} as Int
|
||||
XCTFail("withLoading 应该向外抛出业务错误")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? TestFailure, .expected)
|
||||
}
|
||||
|
||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
||||
}
|
||||
|
||||
/// 测试 withOptionalLoading 关闭时不会改变展示状态。
|
||||
func testWithOptionalLoadingDisabledDoesNotChangeState() async throws {
|
||||
let center = GlobalLoadingCenter()
|
||||
|
||||
let value = try await center.withOptionalLoading(false, message: "不展示") {
|
||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
||||
return "done"
|
||||
}
|
||||
|
||||
XCTAssertEqual(value, "done")
|
||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
||||
}
|
||||
|
||||
/// 测试使用 Loading 不需要读取可观察展示状态,业务侧只能拿到命令中心类型。
|
||||
func testBusinessCodeOnlyDependsOnCommandCenter() {
|
||||
let center = GlobalLoadingCenter()
|
||||
|
||||
issueLoadingCommand(center)
|
||||
|
||||
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载中", activeCount: 1))
|
||||
center.hide()
|
||||
}
|
||||
|
||||
/// 模拟业务代码只接收命令中心并发出展示指令。
|
||||
private func issueLoadingCommand(_ center: GlobalLoadingCenter) {
|
||||
center.show(message: "加载中")
|
||||
}
|
||||
|
||||
/// 测试用错误类型。
|
||||
private enum TestFailure: Error, Equatable {
|
||||
case expected
|
||||
}
|
||||
}
|
||||
80
suixinkan_iosTests/HomeCommonMenuStoreTests.swift
Normal file
80
suixinkan_iosTests/HomeCommonMenuStoreTests.swift
Normal file
@ -0,0 +1,80 @@
|
||||
//
|
||||
// HomeCommonMenuStoreTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
/// 首页常用应用存储测试,覆盖默认值、过滤和别名去重。
|
||||
final class HomeCommonMenuStoreTests: XCTestCase {
|
||||
/// 测试首次无配置时会生成默认常用应用。
|
||||
func testLoadCreatesDefaultCommonMenusWhenBaselineIsMissing() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
let store = HomeCommonMenuStore(defaults: defaults)
|
||||
let menus = [
|
||||
menu("registration_invitation"),
|
||||
menu("location_report"),
|
||||
menu("pm_manager")
|
||||
]
|
||||
|
||||
let uris = store.load(menuItems: menus)
|
||||
|
||||
XCTAssertEqual(uris, ["registration_invitation", "location_report", "pm_manager"])
|
||||
XCTAssertTrue(defaults.bool(forKey: HomeCommonMenuStore.defaultBaselineKey))
|
||||
}
|
||||
|
||||
/// 测试已保存 URI 会按当前权限过滤并解析同义 URI。
|
||||
func testLoadFiltersSavedUrisByCurrentPermissions() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
defaults.set(true, forKey: HomeCommonMenuStore.defaultBaselineKey)
|
||||
defaults.set(["task_management", "unknown", "pm"], forKey: HomeCommonMenuStore.defaultStorageKey)
|
||||
let store = HomeCommonMenuStore(defaults: defaults)
|
||||
let menus = [
|
||||
menu("task_management_editor"),
|
||||
menu("project_edit")
|
||||
]
|
||||
|
||||
let uris = store.load(menuItems: menus)
|
||||
|
||||
XCTAssertEqual(uris, ["task_management_editor", "project_edit"])
|
||||
}
|
||||
|
||||
/// 测试添加常用应用时会按同义 URI 去重。
|
||||
func testAddDeduplicatesAliases() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
let store = HomeCommonMenuStore(defaults: defaults)
|
||||
let menus = [
|
||||
menu("task_management_editor")
|
||||
]
|
||||
|
||||
let uris = store.add("task_management", current: ["task_management_editor"], menuItems: menus)
|
||||
|
||||
XCTAssertEqual(uris, ["task_management_editor"])
|
||||
}
|
||||
|
||||
/// 测试移除常用应用时会移除同义 URI。
|
||||
func testRemoveDeletesAliases() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
let store = HomeCommonMenuStore(defaults: defaults)
|
||||
|
||||
let uris = store.remove("pm", current: ["project_edit", "pm_manager", "location_report"])
|
||||
|
||||
XCTAssertEqual(uris, ["pm_manager", "location_report"])
|
||||
}
|
||||
|
||||
/// 创建测试菜单实体。
|
||||
private func menu(_ uri: String) -> HomeMenuItem {
|
||||
HomeMenuItem(title: HomeMenuRouter.title(for: uri), uri: uri, iconSrc: nil)
|
||||
}
|
||||
|
||||
/// 创建独立 UserDefaults,避免测试污染真实 App 偏好。
|
||||
private func makeIsolatedDefaults() -> UserDefaults {
|
||||
let suiteName = "suixinkan.home.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
228
suixinkan_iosTests/HomeMenuRouterTests.swift
Normal file
228
suixinkan_iosTests/HomeMenuRouterTests.swift
Normal file
@ -0,0 +1,228 @@
|
||||
//
|
||||
// HomeMenuRouterTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
/// 首页菜单路由测试,覆盖 URI 映射、别名和未知路由诊断。
|
||||
final class HomeMenuRouterTests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
HomeRouteDiagnostics.resetUnknownRoutes()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
HomeRouteDiagnostics.resetUnknownRoutes()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
/// 测试已迁移的首页路由会解析到真实目标。
|
||||
func testMigratedRoutesResolveToDestinations() {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "space_settings", title: ""), .destination(.profileSpace))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicselection", title: ""), .destination(.scenicSelection))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "store", title: ""), .destination(.moreFunctions))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "wallet", title: ""), .destination(.wallet))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_collection", title: ""), .destination(.paymentCollection))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_qr", title: ""), .destination(.paymentCollection))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_code", title: ""), .destination(.paymentCollection))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "permission_apply", title: ""), .destination(.permissionApply))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "permission_apply_status", title: ""), .destination(.permissionApplyStatus))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicapplication", title: ""), .destination(.scenicApplication))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_management", title: ""), .destination(.taskManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_management_editor", title: ""), .destination(.taskManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_create", title: ""), .destination(.taskCreate))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "cloud_management", title: ""), .destination(.cloudStorage))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "cloud_storage_transit", title: ""), .destination(.cloudStorageTransit))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "asset_management", title: ""), .destination(.materialLibrary))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "material_upload", title: ""), .destination(.materialUpload))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_trailer", title: ""), .destination(.albumTrailer))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report_history", title: ""), .destination(.locationReportHistory))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm", title: ""), .destination(.projectManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "project_edit", title: ""), .destination(.projectManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm_manager", title: ""), .destination(.pmProjectManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_invite", title: ""), .destination(.photographerInvite))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "invite_record", title: ""), .destination(.inviteRecord))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_detail", title: ""), .destination(.depositOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order", title: ""), .destination(.depositOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_shooting_info", title: ""), .destination(.depositOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "withdrawal_audit", title: ""), .destination(.withdrawalAudit))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""), .destination(.scenicSettlement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""), .destination(.scenicSettlementReview))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "operating-area", title: ""), .destination(.operatingArea))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pilot_cert", title: ""), .destination(.pilotCertification))
|
||||
}
|
||||
|
||||
/// 测试 Tab 路由仍集中在 HomeMenuRouter。
|
||||
func testTabRoutesStayCentralized() {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_orders", title: ""), .orders(.storeOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-order-manage", title: ""), .orders(.storeOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .orders(.verificationOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_stats", title: ""), .tab(.statistics))
|
||||
}
|
||||
|
||||
/// 测试飞控相关入口属于已知不支持范围。
|
||||
func testFlightControlRoutesAreKnownUnsupportedScope() {
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "fly", title: ""),
|
||||
.unsupported(uri: "fly", title: "飞行管理", reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。")
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试未知 URI 会显式进入 placeholder。
|
||||
func testUnknownRouteIsExplicitPlaceholder() {
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "android_only_feature", title: "Android 专项"),
|
||||
.placeholder(uri: "android_only_feature", title: "Android 专项")
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试 canonicalURI 会使用当前权限可用的同义 URI。
|
||||
func testCanonicalURIUsesAvailableAlias() {
|
||||
let available: Set<String> = ["task_management_editor", "photographer_invite", "project_edit", "pm_manager", "payment_qr"]
|
||||
XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "task_management", availableURIs: available), "task_management_editor")
|
||||
XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "registration_invitation", availableURIs: available), "photographer_invite")
|
||||
XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "pm", availableURIs: available), "project_edit")
|
||||
XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "pm_manager", availableURIs: available), "pm_manager")
|
||||
XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "payment_code", availableURIs: available), "payment_qr")
|
||||
}
|
||||
|
||||
/// 测试 menuAliasKey 会把同义入口分到同一组。
|
||||
func testMenuAliasKeyGroupsDuplicateAndroidEntries() {
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "task_management_editor"), "task_management")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "photographer_invite"), "registration_invitation")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "project_edit"), "pm")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "pm_manager"), "pm_manager")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "payment_code"), "payment_collection")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "deposit_order_shooting_info"), "deposit_order")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "/scenic-order-manage"), "photographer_orders")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "/scenic-queue"), "queue_management")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "queue_management"), "queue_management")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "android_only_module"), "android_only_module")
|
||||
}
|
||||
|
||||
/// 测试未知路由诊断会去重并更新标题。
|
||||
func testUnknownRouteDiagnosticsDeduplicatesAndUpdatesTitle() {
|
||||
HomeRouteDiagnostics.recordUnknown(uri: "android_feature", title: "旧标题")
|
||||
HomeRouteDiagnostics.recordUnknown(uri: "android_feature", title: "新标题")
|
||||
HomeRouteDiagnostics.recordUnknown(uri: "another_feature", title: "")
|
||||
|
||||
let records = HomeRouteDiagnostics.unknownRoutes()
|
||||
|
||||
XCTAssertEqual(records.count, 2)
|
||||
XCTAssertEqual(records.first { $0.uri == "android_feature" }?.title, "新标题")
|
||||
XCTAssertEqual(records.first { $0.uri == "android_feature" }?.count, 2)
|
||||
XCTAssertEqual(records.first { $0.uri == "another_feature" }?.count, 1)
|
||||
}
|
||||
|
||||
/// 测试未知路由诊断报告是 Markdown 且按出现次数排序。
|
||||
func testUnknownRouteDiagnosticsReportIsMarkdownAndSortedByCount() {
|
||||
HomeRouteDiagnostics.recordUnknown(uri: "rare_feature", title: "低频入口")
|
||||
HomeRouteDiagnostics.recordUnknown(uri: "hot_feature", title: "高频入口")
|
||||
HomeRouteDiagnostics.recordUnknown(uri: "hot_feature", title: "高频入口")
|
||||
|
||||
let report = HomeRouteDiagnostics.unknownRouteReport(generatedAt: Date(timeIntervalSince1970: 0))
|
||||
|
||||
XCTAssertTrue(report.contains("# Home Route Diagnostics"))
|
||||
XCTAssertTrue(report.contains("| hot_feature | 高频入口 | 2 |"))
|
||||
XCTAssertLessThan(
|
||||
report.range(of: "hot_feature")!.lowerBound,
|
||||
report.range(of: "rare_feature")!.lowerBound
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试权限路由审计会区分可路由、不支持和未知入口。
|
||||
func testPermissionRouteAuditSeparatesRoutableUnsupportedAndUnknownEntries() {
|
||||
let permissions = [
|
||||
rolePermission(
|
||||
roleId: 1,
|
||||
permissions: [
|
||||
permission(id: 1, name: "任务管理", uri: "task_management", children: [
|
||||
permission(id: 2, name: "发布任务", uri: "task_create")
|
||||
]),
|
||||
permission(id: 3, name: "飞控", uri: "pilot_controller"),
|
||||
permission(id: 4, name: "Android 专项入口", uri: "android_only_module"),
|
||||
permission(id: 5, name: "重复任务", uri: "task_management")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
let audit = HomePermissionRouteAuditor.audit(permissions: permissions, currentRoleId: 1)
|
||||
|
||||
XCTAssertEqual(audit.routable.map(\.uri), ["task_management", "task_create"])
|
||||
XCTAssertEqual(audit.unsupported.map(\.uri), ["pilot_controller"])
|
||||
XCTAssertEqual(audit.unknown.map(\.uri), ["android_only_module"])
|
||||
XCTAssertTrue(audit.hasUnknownRoutes)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 测试 DEBUG 菜单列表不依赖权限,包含已迁移和未迁移入口。
|
||||
func testDebugAllMenuItemsExposeKnownMenusWithoutPermissions() {
|
||||
let items = HomeMenuRouter.debugAllMenuItems()
|
||||
let uris = items.map(\.uri)
|
||||
|
||||
XCTAssertTrue(uris.contains("album_list"))
|
||||
XCTAssertTrue(uris.contains("wallet"))
|
||||
XCTAssertTrue(uris.contains("sample_management"))
|
||||
XCTAssertTrue(uris.contains("checkin_points"))
|
||||
XCTAssertTrue(uris.contains("location_report"))
|
||||
XCTAssertTrue(uris.contains("pm"))
|
||||
XCTAssertTrue(uris.contains("pm_manager"))
|
||||
XCTAssertTrue(uris.contains("schedule_management"))
|
||||
XCTAssertTrue(uris.contains("registration_invitation"))
|
||||
XCTAssertTrue(uris.contains("invite_record"))
|
||||
XCTAssertTrue(uris.contains("deposit_order"))
|
||||
XCTAssertTrue(uris.contains("deposit_order_shooting_info"))
|
||||
XCTAssertTrue(uris.contains("message_center"))
|
||||
XCTAssertTrue(uris.contains("/scenic-queue"))
|
||||
XCTAssertTrue(uris.contains("queue_management"))
|
||||
XCTAssertTrue(uris.contains("live_stream_management"))
|
||||
XCTAssertTrue(uris.contains("live_album"))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm", title: ""), .destination(.projectManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm_manager", title: ""), .destination(.pmProjectManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "invite_record", title: ""), .destination(.inviteRecord))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order", title: ""), .destination(.depositOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_shooting_info", title: ""), .destination(.depositOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum))
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 创建角色权限测试实体。
|
||||
private func rolePermission(roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse {
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: roleId, name: "角色\(roleId)", permission: permissions),
|
||||
scenic: []
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建权限节点测试实体。
|
||||
private func permission(id: Int, name: String, uri: String, children: [PermissionItem] = []) -> PermissionItem {
|
||||
PermissionItem(id: id, name: name, uri: uri, children: children)
|
||||
}
|
||||
}
|
||||
111
suixinkan_iosTests/HomeViewModelTests.swift
Normal file
111
suixinkan_iosTests/HomeViewModelTests.swift
Normal file
@ -0,0 +1,111 @@
|
||||
//
|
||||
// HomeViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 首页 ViewModel 测试,覆盖权限菜单构建、排序和去重。
|
||||
final class HomeViewModelTests: XCTestCase {
|
||||
/// 测试当前角色缺失时清空菜单,不回退到其他角色。
|
||||
func testBuildMenusClearsStaleMenusWhenCurrentRoleIsMissing() {
|
||||
let viewModel = HomeViewModel()
|
||||
let permissions = [
|
||||
rolePermission(roleId: 1, permissions: [
|
||||
permission(id: 1, name: "消息中心", uri: "message_center")
|
||||
])
|
||||
]
|
||||
|
||||
viewModel.buildMenus(from: permissions, currentRoleId: 1)
|
||||
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["message_center"])
|
||||
|
||||
viewModel.buildMenus(from: permissions, currentRoleId: 99)
|
||||
|
||||
XCTAssertTrue(viewModel.menuItems.isEmpty)
|
||||
}
|
||||
|
||||
/// 测试当前角色为空时才回退到第一个角色。
|
||||
func testBuildMenusFallsBackToFirstRoleOnlyWhenCurrentRoleIsNil() {
|
||||
let viewModel = HomeViewModel()
|
||||
let permissions = [
|
||||
rolePermission(roleId: 1, permissions: [
|
||||
permission(id: 1, name: "任务管理", uri: "task_management")
|
||||
]),
|
||||
rolePermission(roleId: 2, permissions: [
|
||||
permission(id: 2, name: "消息中心", uri: "message_center")
|
||||
])
|
||||
]
|
||||
|
||||
viewModel.buildMenus(from: permissions, currentRoleId: nil)
|
||||
|
||||
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["task_management"])
|
||||
}
|
||||
|
||||
/// 测试权限树会递归展开为菜单。
|
||||
func testBuildMenusFlattensPermissionTree() {
|
||||
let viewModel = HomeViewModel()
|
||||
let permissions = [
|
||||
rolePermission(roleId: 1, permissions: [
|
||||
permission(id: 1, name: "任务管理", uri: "task_management", children: [
|
||||
permission(id: 2, name: "消息中心", uri: "message_center")
|
||||
])
|
||||
])
|
||||
]
|
||||
|
||||
viewModel.buildMenus(from: permissions, currentRoleId: 1)
|
||||
|
||||
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["task_management", "message_center"])
|
||||
}
|
||||
|
||||
/// 测试同义 URI 会去重,只保留首次出现的菜单。
|
||||
func testBuildMenusDeduplicatesKnownAndroidAliases() {
|
||||
let viewModel = HomeViewModel()
|
||||
let permissions = [
|
||||
rolePermission(roleId: 1, permissions: [
|
||||
permission(id: 1, name: "项目管理", uri: "pm_manager"),
|
||||
permission(id: 2, name: "项目管理旧入口", uri: "pm"),
|
||||
permission(id: 3, name: "收款码", uri: "payment_code"),
|
||||
permission(id: 4, name: "收款码旧入口", uri: "payment_qr"),
|
||||
permission(id: 5, name: "押金订单详情", uri: "deposit_order_detail"),
|
||||
permission(id: 6, name: "押金订单", uri: "deposit_order")
|
||||
])
|
||||
]
|
||||
|
||||
viewModel.buildMenus(from: permissions, currentRoleId: 1)
|
||||
|
||||
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["pm", "deposit_order_detail", "pm_manager", "payment_code"])
|
||||
}
|
||||
|
||||
/// 测试菜单按旧工程 preferred order 排序。
|
||||
func testBuildMenusSortsByPreferredOrder() {
|
||||
let viewModel = HomeViewModel()
|
||||
let permissions = [
|
||||
rolePermission(roleId: 1, permissions: [
|
||||
permission(id: 1, name: "消息中心", uri: "message_center"),
|
||||
permission(id: 2, name: "空间设置", uri: "space_settings"),
|
||||
permission(id: 3, name: "钱包", uri: "wallet")
|
||||
])
|
||||
]
|
||||
|
||||
viewModel.buildMenus(from: permissions, currentRoleId: 1)
|
||||
|
||||
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["space_settings", "wallet", "message_center"])
|
||||
}
|
||||
|
||||
/// 创建角色权限测试实体。
|
||||
private func rolePermission(roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse {
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: roleId, name: "角色\(roleId)", permission: permissions),
|
||||
scenic: []
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建权限节点测试实体。
|
||||
private func permission(id: Int, name: String, uri: String, children: [PermissionItem] = []) -> PermissionItem {
|
||||
PermissionItem(id: id, name: name, uri: uri, children: children)
|
||||
}
|
||||
}
|
||||
154
suixinkan_iosTests/Invite/InviteTests.swift
Normal file
154
suixinkan_iosTests/Invite/InviteTests.swift
Normal file
@ -0,0 +1,154 @@
|
||||
//
|
||||
// InviteTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 邀请 API 和 ViewModel 测试。
|
||||
final class InviteTests: XCTestCase {
|
||||
/// 测试邀请 API path 和分页参数。
|
||||
func testInviteAPIUsesExpectedRequests() async throws {
|
||||
let session = InviteRecordingURLSession(responses: [Self.inviteInfoResponse, Self.inviteUsersResponse])
|
||||
let api = InviteAPI(client: APIClient(session: session))
|
||||
|
||||
let info = try await api.inviteInfo()
|
||||
let users = try await api.inviteUserList(page: 0, pageSize: 0)
|
||||
|
||||
XCTAssertEqual(info.inviteCode, "ABC123")
|
||||
XCTAssertEqual(users.first?.id, 1)
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/invite-info",
|
||||
"/api/yf-handset-app/photog/invite/user-list"
|
||||
])
|
||||
XCTAssertEqual(inviteQueryItems(from: session.requests[1])["page"], "1")
|
||||
XCTAssertEqual(inviteQueryItems(from: session.requests[1])["page_size"], "1")
|
||||
}
|
||||
|
||||
/// 测试邀请页加载信息并生成二维码。
|
||||
func testInviteViewModelLoadsInfoAndGeneratesQRCode() async {
|
||||
let api = FakeInviteService()
|
||||
let viewModel = PhotographerInviteViewModel()
|
||||
|
||||
await viewModel.reload(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.inviteCode, "ABC123")
|
||||
XCTAssertEqual(viewModel.rules, ["规则1"])
|
||||
XCTAssertNotNil(viewModel.qrImage)
|
||||
}
|
||||
|
||||
/// 测试邀请记录加载邀请用户和钱包汇总。
|
||||
func testInviteRecordLoadsInviteRowsAndSummary() async {
|
||||
let inviteAPI = FakeInviteService()
|
||||
let walletAPI = FakeWalletService()
|
||||
let viewModel = InviteRecordViewModel()
|
||||
|
||||
await viewModel.reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true)
|
||||
|
||||
XCTAssertEqual(viewModel.displayRows.first?.title, "摄影师")
|
||||
XCTAssertEqual(viewModel.totalRewardText, "¥ 100.00")
|
||||
XCTAssertEqual(walletAPI.summaryTypes, [2])
|
||||
}
|
||||
|
||||
/// 测试奖励分段调用钱包收益明细。
|
||||
func testInviteRecordRewardTabLoadsWalletDetails() async {
|
||||
let inviteAPI = FakeInviteService()
|
||||
let walletAPI = FakeWalletService()
|
||||
let viewModel = InviteRecordViewModel()
|
||||
|
||||
await viewModel.selectTab(.reward, inviteAPI: inviteAPI, walletAPI: walletAPI)
|
||||
|
||||
XCTAssertEqual(walletAPI.earningPages, [1])
|
||||
XCTAssertNil(viewModel.errorMessage, viewModel.errorMessage ?? "")
|
||||
XCTAssertEqual(viewModel.displayRows.first?.amount, "8.00")
|
||||
}
|
||||
|
||||
private static let inviteInfoResponse = Data(#"{"code":100000,"message":"ok","data":{"enable_invite":"1","invite_code":"ABC123","invite_url":"https://invite.example.com","description":["规则1"]}}"#.utf8)
|
||||
private static let inviteUsersResponse = Data(#"{"code":100000,"message":"ok","data":[{"id":"1","real_name":"摄影师","phone":"13800000000","avatar":"","created_at":"2026-06-24","invite_level":"1"}]}"#.utf8)
|
||||
}
|
||||
|
||||
/// 邀请 API 测试 URLSession。
|
||||
private final class InviteRecordingURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 初始化测试 Session。
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 记录请求并返回响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.removeFirst()
|
||||
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请服务测试替身。
|
||||
@MainActor
|
||||
private final class FakeInviteService: InviteServing {
|
||||
func inviteInfo() async throws -> InviteInfoResponse {
|
||||
try JSONDecoder().decode(InviteInfoResponse.self, from: Data(#"{"enable_invite":"1","invite_code":"ABC123","invite_url":"https://invite.example.com","description":["规则1"]}"#.utf8))
|
||||
}
|
||||
|
||||
func inviteUserList(page: Int, pageSize: Int) async throws -> [InviteUserItem] {
|
||||
[try JSONDecoder().decode(InviteUserItem.self, from: Data(#"{"id":"1","real_name":"摄影师","phone":"13800000000","avatar":"","created_at":"2026-06-24","invite_level":"1"}"#.utf8))]
|
||||
}
|
||||
}
|
||||
|
||||
/// 钱包服务测试替身。
|
||||
@MainActor
|
||||
private final class FakeWalletService: WalletServing {
|
||||
var summaryTypes: [Int] = []
|
||||
var earningPages: [Int] = []
|
||||
|
||||
func walletSummary(type: Int) async throws -> WalletSummaryResponse {
|
||||
summaryTypes.append(type)
|
||||
return WalletSummaryResponse(amountTotal: "100.00", amountCurrentBalance: "20.00", amountWithdrawable: "50.00")
|
||||
}
|
||||
|
||||
func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse {
|
||||
earningPages.append(page)
|
||||
let item = WalletEarningDetailItem(
|
||||
id: 1,
|
||||
amount: "8.00",
|
||||
type: 2,
|
||||
typeLabel: "邀请奖励",
|
||||
orderNumberSuffix: "0001",
|
||||
createdAt: "2026-06-24"
|
||||
)
|
||||
return WalletEarningDetailResponse(
|
||||
totalAmount: "8.00",
|
||||
total: 1,
|
||||
list: [WalletEarningDetailGroup(date: "2026-06-24", dayAmount: "8.00", items: [item])]
|
||||
)
|
||||
}
|
||||
|
||||
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse { WalletWithdrawListResponse() }
|
||||
func withdrawInfo() async throws -> WithdrawInfoResponse { try JSONDecoder().decode(WithdrawInfoResponse.self, from: Data("{}".utf8)) }
|
||||
func withdrawSendSms() async throws {}
|
||||
func withdrawApply(amount: String, smsCode: String) async throws {}
|
||||
func bankCardInfo() async throws -> BankCardInfoResponse { try JSONDecoder().decode(BankCardInfoResponse.self, from: Data("{}".utf8)) }
|
||||
func bankList() async throws -> BankListResponse { BankListResponse(banks: []) }
|
||||
func areas() async throws -> [AreaNode] { [] }
|
||||
func bankCardVerifyCode() async throws {}
|
||||
func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {}
|
||||
func pointOverview(staffId: Int) async throws -> PointOverviewResponse { try JSONDecoder().decode(PointOverviewResponse.self, from: Data("{}".utf8)) }
|
||||
func pointWithdrawApply(points: Int, remark: String) async throws {}
|
||||
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse { PointWithdrawListResponse(total: 0, list: []) }
|
||||
}
|
||||
|
||||
/// 从请求中提取 query 字典。
|
||||
private func inviteQueryItems(from request: URLRequest) -> [String: String] {
|
||||
guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
item.value.map { (item.name, $0) }
|
||||
})
|
||||
}
|
||||
168
suixinkan_iosTests/Live/LiveAPITests.swift
Normal file
168
suixinkan_iosTests/Live/LiveAPITests.swift
Normal file
@ -0,0 +1,168 @@
|
||||
//
|
||||
// LiveAPITests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 直播 API 测试,覆盖直播管理和直播相册接口编码。
|
||||
final class LiveAPITests: XCTestCase {
|
||||
func testLiveListAndDetailDecodeFixturesAndUseExpectedQueries() async throws {
|
||||
let listSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_list_success"))
|
||||
let listAPI = LiveAPI(client: APIClient(session: listSession))
|
||||
|
||||
let list = try await listAPI.liveList(scenicId: 88, page: 0, pageSize: 0)
|
||||
|
||||
XCTAssertEqual(list.total, 2)
|
||||
XCTAssertEqual(list.page, 1)
|
||||
XCTAssertEqual(list.pageSize, 1)
|
||||
XCTAssertEqual(list.items.first?.id, 8101)
|
||||
XCTAssertEqual(list.items.first?.manualPushMode, 2)
|
||||
let listRequest = try XCTUnwrap(listSession.requests.first)
|
||||
XCTAssertEqual(listRequest.httpMethod, "GET")
|
||||
XCTAssertEqual(listRequest.url?.path, "/api/app/manual-live/list")
|
||||
XCTAssertEqual(liveQueryItems(from: listRequest)["scenic_id"], "88")
|
||||
XCTAssertEqual(liveQueryItems(from: listRequest)["page"], "1")
|
||||
XCTAssertEqual(liveQueryItems(from: listRequest)["page_size"], "1")
|
||||
|
||||
let detailSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_detail_success"))
|
||||
let detailAPI = LiveAPI(client: APIClient(session: detailSession))
|
||||
|
||||
let detail = try await detailAPI.liveDetail(liveId: 8101)
|
||||
|
||||
XCTAssertEqual(detail.id, 8101)
|
||||
XCTAssertEqual(detail.pushUrl, "rtmp://push.example.com/live/8101")
|
||||
XCTAssertEqual(detail.status, 2)
|
||||
XCTAssertEqual(detail.duration, 3600)
|
||||
let detailRequest = try XCTUnwrap(detailSession.requests.first)
|
||||
XCTAssertEqual(detailRequest.url?.path, "/api/app/manual-live/detail")
|
||||
XCTAssertEqual(liveQueryItems(from: detailRequest)["live_id"], "8101")
|
||||
}
|
||||
|
||||
func testLiveCreateAndControlsUseExpectedBodies() async throws {
|
||||
let createSession = LiveRecordingURLSession(responses: Array(repeating: try TestFixture.data(named: "empty_success"), count: 5))
|
||||
let api = LiveAPI(client: APIClient(session: createSession))
|
||||
|
||||
try await api.liveCreate(LiveCreateRequest(scenicId: "88", title: "景区晨间直播", coverImg: "https://cdn.example.com/cover.jpg"))
|
||||
|
||||
var request = try XCTUnwrap(createSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/manual-live/create")
|
||||
var json = try liveJSONBody(from: request)
|
||||
XCTAssertEqual(json["scenic_id"] as? String, "88")
|
||||
XCTAssertEqual(json["title"] as? String, "景区晨间直播")
|
||||
XCTAssertEqual(json["cover_img"] as? String, "https://cdn.example.com/cover.jpg")
|
||||
|
||||
try await api.liveStart(liveId: 8101)
|
||||
request = try XCTUnwrap(createSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/manual-live/start")
|
||||
XCTAssertEqual(try liveJSONBody(from: request)["live_id"] as? Int, 8101)
|
||||
|
||||
try await api.liveStop(liveId: 8101)
|
||||
request = try XCTUnwrap(createSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/manual-live/stop")
|
||||
XCTAssertEqual(try liveJSONBody(from: request)["live_id"] as? Int, 8101)
|
||||
|
||||
try await api.liveFinish(liveId: 8101)
|
||||
request = try XCTUnwrap(createSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/manual-live/finish")
|
||||
XCTAssertEqual(try liveJSONBody(from: request)["live_id"] as? Int, 8101)
|
||||
|
||||
try await api.liveSetPushMode(liveId: 8101, mode: 2)
|
||||
request = try XCTUnwrap(createSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/manual-live/set-push-mode")
|
||||
json = try liveJSONBody(from: request)
|
||||
XCTAssertEqual(json["live_id"] as? Int, 8101)
|
||||
XCTAssertEqual(json["manual_push_mode"] as? Int, 2)
|
||||
}
|
||||
|
||||
func testLiveAlbumAPIsUseExpectedRequests() async throws {
|
||||
let listSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_album_list_success"))
|
||||
let api = LiveAPI(client: APIClient(session: listSession))
|
||||
|
||||
let response = try await api.liveAlbumList(scenicId: 88, startTime: "2026-05-01", endTime: "2026-05-23", page: 0, pageSize: 0)
|
||||
|
||||
XCTAssertEqual(response.total, 2)
|
||||
XCTAssertEqual(response.items.first?.id, 6101)
|
||||
XCTAssertEqual(response.items.first?.items.last?.previewURL, "https://cdn.example.com/alive/video-cover.jpg")
|
||||
var request = try XCTUnwrap(listSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/view-album/folders")
|
||||
var query = liveQueryItems(from: request)
|
||||
XCTAssertEqual(query["scenic_id"], "88")
|
||||
XCTAssertEqual(query["start_time"], "2026-05-01")
|
||||
XCTAssertEqual(query["end_time"], "2026-05-23")
|
||||
XCTAssertEqual(query["page"], "1")
|
||||
XCTAssertEqual(query["page_size"], "1")
|
||||
|
||||
let mutationSession = LiveRecordingURLSession(data: try TestFixture.data(named: "empty_success"))
|
||||
let mutationAPI = LiveAPI(client: APIClient(session: mutationSession))
|
||||
try await mutationAPI.liveAlbumCreateFolder(
|
||||
LiveAlbumCreateFolderRequest(
|
||||
scenicId: "88",
|
||||
name: "五一直播相册",
|
||||
items: [LiveAlbumCreateFileItem(url: "https://cdn.example.com/a.jpg", type: 1, size: 12, coverImg: nil)]
|
||||
)
|
||||
)
|
||||
request = try XCTUnwrap(mutationSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/view-album/create-folder")
|
||||
var json = try liveJSONBody(from: request)
|
||||
XCTAssertEqual(json["scenic_id"] as? String, "88")
|
||||
XCTAssertEqual((json["items"] as? [[String: Any]])?.first?["url"] as? String, "https://cdn.example.com/a.jpg")
|
||||
|
||||
try await mutationAPI.liveAlbumDeleteFolder(folderId: 6101)
|
||||
request = try XCTUnwrap(mutationSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/view-album/delete-folder")
|
||||
XCTAssertEqual(try liveJSONBody(from: request)["folder_id"] as? Int, 6101)
|
||||
|
||||
let detailSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_album_folder_detail_success"))
|
||||
let detailAPI = LiveAPI(client: APIClient(session: detailSession))
|
||||
let folder = try await detailAPI.liveAlbumFolderDetail(folderId: 6101)
|
||||
XCTAssertEqual(folder.items.count, 2)
|
||||
request = try XCTUnwrap(detailSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/view-album/folder-detail")
|
||||
XCTAssertEqual(liveQueryItems(from: request)["folder_id"], "6101")
|
||||
|
||||
try await mutationAPI.liveAlbumDeleteFiles(folderId: 6101, fileIds: [7101, 7102])
|
||||
request = try XCTUnwrap(mutationSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/view-album/delete-files")
|
||||
json = try liveJSONBody(from: request)
|
||||
XCTAssertEqual(json["folder_id"] as? Int, 6101)
|
||||
XCTAssertEqual(json["file_ids"] as? [Int], [7101, 7102])
|
||||
}
|
||||
}
|
||||
|
||||
private final class LiveRecordingURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
init(data: Data) {
|
||||
responses = [data]
|
||||
}
|
||||
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.isEmpty ? try TestFixture.data(named: "empty_success") : responses.removeFirst()
|
||||
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
|
||||
}
|
||||
}
|
||||
|
||||
private func liveQueryItems(from request: URLRequest) -> [String: String] {
|
||||
guard let url = request.url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
else { return [:] }
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
item.value.map { (item.name, $0) }
|
||||
})
|
||||
}
|
||||
|
||||
private func liveJSONBody(from request: URLRequest) throws -> [String: Any] {
|
||||
let body = try XCTUnwrap(request.httpBody)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
}
|
||||
588
suixinkan_iosTests/Live/LiveViewModelTests.swift
Normal file
588
suixinkan_iosTests/Live/LiveViewModelTests.swift
Normal file
@ -0,0 +1,588 @@
|
||||
//
|
||||
// LiveViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 直播 ViewModel 测试,覆盖列表、详情、相册和上传流程。
|
||||
final class LiveViewModelTests: XCTestCase {
|
||||
func testLiveManagementReloadLoadMoreAndMissingScenicReset() async {
|
||||
let api = LiveMock()
|
||||
api.liveListResponses = [
|
||||
LiveListResponse(items: [live(id: 1, status: 2), live(id: 2, status: 3)], total: 3, page: 1, pageSize: 2),
|
||||
LiveListResponse(items: [live(id: 3, status: 1)], total: 3, page: 2, pageSize: 2)
|
||||
]
|
||||
let viewModel = LiveManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: 88)
|
||||
await viewModel.loadMore(api: api, scenicId: 88)
|
||||
await viewModel.loadMore(api: api, scenicId: 88)
|
||||
|
||||
XCTAssertEqual(api.liveListRequests.map(\.page), [1, 2])
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1, 2, 3])
|
||||
XCTAssertEqual(viewModel.liveRunningCount, 1)
|
||||
XCTAssertEqual(viewModel.liveFinishedCount, 1)
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
|
||||
await viewModel.reload(api: api, scenicId: nil)
|
||||
XCTAssertTrue(viewModel.items.isEmpty)
|
||||
XCTAssertNil(viewModel.detail)
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
}
|
||||
|
||||
func testLiveManagementReloadFailureAndDetailFailureClearStaleState() async {
|
||||
let api = LiveMock()
|
||||
api.liveListResponses = [LiveListResponse(items: [live(id: 1)], total: 1)]
|
||||
api.liveDetailResponses = [live(id: 1, title: "旧详情")]
|
||||
let viewModel = LiveManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: 88)
|
||||
await viewModel.loadDetail(api: api, liveId: 1)
|
||||
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1])
|
||||
XCTAssertEqual(viewModel.detail?.title, "旧详情")
|
||||
|
||||
api.liveListError = LiveTestError.sample
|
||||
await viewModel.reload(api: api, scenicId: 88)
|
||||
|
||||
XCTAssertTrue(viewModel.items.isEmpty)
|
||||
XCTAssertNil(viewModel.detail)
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
XCTAssertEqual(viewModel.errorMessage, "测试错误")
|
||||
|
||||
api.liveListError = nil
|
||||
api.liveDetailError = LiveTestError.sample
|
||||
await viewModel.loadDetail(api: api, liveId: 1)
|
||||
XCTAssertNil(viewModel.detail)
|
||||
}
|
||||
|
||||
func testLiveManagementActionsRefreshAfterSuccess() async throws {
|
||||
let api = LiveMock()
|
||||
api.liveListResponses = [
|
||||
LiveListResponse(items: [live(id: 1, status: 1)], total: 1),
|
||||
LiveListResponse(items: [live(id: 1, status: 2)], total: 1),
|
||||
LiveListResponse(items: [live(id: 1, status: 3)], total: 1),
|
||||
LiveListResponse(items: [live(id: 4, status: 1)], total: 1)
|
||||
]
|
||||
let viewModel = LiveManagementViewModel()
|
||||
await viewModel.reload(api: api, scenicId: 88)
|
||||
|
||||
try await viewModel.control(api: api, item: live(id: 1, status: 1), scenicId: 88)
|
||||
XCTAssertEqual(api.startIds, [1])
|
||||
XCTAssertEqual(viewModel.items.first?.status, 2)
|
||||
|
||||
try await viewModel.finish(api: api, item: live(id: 1, status: 2), scenicId: 88)
|
||||
XCTAssertEqual(api.finishIds, [1])
|
||||
XCTAssertEqual(viewModel.items.first?.status, 3)
|
||||
|
||||
try await viewModel.create(api: api, scenicId: 88, title: " 新直播 ", coverURL: " https://cdn.example.com/c.jpg ")
|
||||
XCTAssertEqual(api.createRequests.first?.title, "新直播")
|
||||
XCTAssertEqual(api.createRequests.first?.coverImg, "https://cdn.example.com/c.jpg")
|
||||
XCTAssertEqual(viewModel.items.first?.id, 4)
|
||||
}
|
||||
|
||||
func testLiveDetailActionsRefreshDetail() async throws {
|
||||
let api = LiveMock()
|
||||
api.liveDetailResponses = [
|
||||
live(id: 1, status: 2, manualPushMode: 1),
|
||||
live(id: 1, status: 3, manualPushMode: 1),
|
||||
live(id: 1, status: 3, manualPushMode: 2)
|
||||
]
|
||||
let viewModel = LiveDetailViewModel(detail: live(id: 1, status: 1, manualPushMode: 1))
|
||||
|
||||
try await viewModel.control(api: api)
|
||||
XCTAssertEqual(api.startIds, [1])
|
||||
XCTAssertEqual(viewModel.detail.status, 2)
|
||||
|
||||
try await viewModel.finish(api: api)
|
||||
XCTAssertEqual(api.finishIds, [1])
|
||||
XCTAssertEqual(viewModel.detail.status, 3)
|
||||
|
||||
try await viewModel.setPushMode(api: api, mode: 2)
|
||||
XCTAssertEqual(api.pushModeRequests.map(\.mode), [2])
|
||||
XCTAssertEqual(viewModel.detail.manualPushMode, 2)
|
||||
}
|
||||
|
||||
func testLiveAlbumDateValidationReloadLoadMoreAndFailureBehavior() async {
|
||||
let api = LiveMock()
|
||||
api.albumListResponses = [
|
||||
LiveAlbumFolderListResponse(items: [folder(id: 1)], page: 1, pageSize: 1, total: 2),
|
||||
LiveAlbumFolderListResponse(items: [folder(id: 2)], page: 2, pageSize: 1, total: 2)
|
||||
]
|
||||
let viewModel = LiveAlbumViewModel()
|
||||
|
||||
XCTAssertNoThrow(try viewModel.setStartDate(date("2026-05-01")))
|
||||
XCTAssertNoThrow(try viewModel.setEndDate(date("2026-05-23")))
|
||||
await viewModel.reload(api: api, scenicId: 88)
|
||||
await viewModel.loadMore(api: api, scenicId: 88)
|
||||
|
||||
XCTAssertEqual(api.albumListRequests.map(\.page), [1, 2])
|
||||
XCTAssertEqual(api.albumListRequests.first?.startTime, "2026-05-01")
|
||||
XCTAssertEqual(api.albumListRequests.first?.endTime, "2026-05-23")
|
||||
XCTAssertEqual(viewModel.folders.map(\.id), [1, 2])
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
XCTAssertThrowsError(try viewModel.setStartDate(date("2026-06-01")))
|
||||
|
||||
api.albumListError = LiveTestError.sample
|
||||
await viewModel.reload(api: api, scenicId: 88)
|
||||
XCTAssertTrue(viewModel.folders.isEmpty)
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
|
||||
await viewModel.reload(api: api, scenicId: nil)
|
||||
XCTAssertTrue(viewModel.folders.isEmpty)
|
||||
}
|
||||
|
||||
func testLiveAlbumCreateUploadsFilesAndClearsForm() async throws {
|
||||
let api = LiveMock()
|
||||
let uploader = LiveUploadMock()
|
||||
let uploadedURLs = ["https://cdn.example.com/live_albums/a.jpg", "https://cdn.example.com/live_albums/b.mp4"]
|
||||
uploader.urls = uploadedURLs
|
||||
let viewModel = LiveAlbumCreateViewModel()
|
||||
viewModel.name = " 五一相册 "
|
||||
viewModel.addLocalFiles([
|
||||
LiveAlbumLocalUploadFile(data: Data([1, 2, 3]), fileName: "a.jpg", fileType: 1),
|
||||
LiveAlbumLocalUploadFile(data: Data([4, 5, 6]), fileName: "b.mp4", fileType: 2)
|
||||
])
|
||||
|
||||
try await viewModel.submit(scenicId: 88, api: api, uploader: uploader)
|
||||
|
||||
XCTAssertEqual(uploader.uploads.map(\.fileName), ["a.jpg", "b.mp4"])
|
||||
let request = try XCTUnwrap(api.albumCreateRequests.first)
|
||||
XCTAssertEqual(request.scenicId, "88")
|
||||
XCTAssertEqual(request.name, "五一相册")
|
||||
XCTAssertEqual(request.items.map(\.url), uploadedURLs)
|
||||
XCTAssertEqual(request.items.map(\.type), [1, 2])
|
||||
XCTAssertTrue(viewModel.name.isEmpty)
|
||||
XCTAssertTrue(viewModel.localFiles.isEmpty)
|
||||
XCTAssertEqual(viewModel.uploadProgress, 100)
|
||||
}
|
||||
|
||||
func testLiveAlbumCreateFailureKeepsForm() async {
|
||||
let api = LiveMock()
|
||||
let uploader = LiveUploadMock()
|
||||
uploader.error = LiveTestError.sample
|
||||
let viewModel = LiveAlbumCreateViewModel()
|
||||
viewModel.name = "相册"
|
||||
viewModel.addLocalFiles([LiveAlbumLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 1)])
|
||||
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.submit(scenicId: 88, api: api, uploader: uploader))
|
||||
|
||||
XCTAssertEqual(viewModel.name, "相册")
|
||||
XCTAssertEqual(viewModel.localFiles.count, 1)
|
||||
XCTAssertTrue(api.albumCreateRequests.isEmpty)
|
||||
}
|
||||
|
||||
func testLiveAlbumPreviewLoadAndDeleteRefresh() async throws {
|
||||
let api = LiveMock()
|
||||
api.albumDetailResponses = [
|
||||
folder(id: 1, items: [albumFile(id: 11), albumFile(id: 12)]),
|
||||
folder(id: 1, items: [albumFile(id: 12)])
|
||||
]
|
||||
let viewModel = LiveAlbumPreviewViewModel(folderId: 1, startIndex: 0, summary: nil)
|
||||
|
||||
await viewModel.load(api: api)
|
||||
XCTAssertEqual(viewModel.files.map(\.id), [11, 12])
|
||||
|
||||
try await viewModel.deleteCurrentFile(api: api)
|
||||
XCTAssertEqual(api.albumDeleteFileRequests.first?.fileIds, [11])
|
||||
XCTAssertEqual(viewModel.files.map(\.id), [12])
|
||||
}
|
||||
|
||||
func testLiveEntityDecodesPlaybackURLsAndResolverExcludesRTMP() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": "1",
|
||||
"title": "播放测试",
|
||||
"cover_img": "https://cdn.example.com/c.jpg",
|
||||
"push_url": "rtmp://push.example.com/live/1",
|
||||
"play_url": "rtmp://pull.example.com/live/1",
|
||||
"pull_url": "https://cdn.example.com/live/1.flv",
|
||||
"hls_url": "https://cdn.example.com/live/1.m3u8",
|
||||
"live_url": "https://cdn.example.com/live/1.mp4"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let live = try JSONDecoder().decode(LiveEntity.self, from: json)
|
||||
|
||||
XCTAssertEqual(live.playUrl, "rtmp://pull.example.com/live/1")
|
||||
XCTAssertEqual(live.pullUrl, "https://cdn.example.com/live/1.flv")
|
||||
XCTAssertEqual(live.hlsUrl, "https://cdn.example.com/live/1.m3u8")
|
||||
XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.pushUrl))
|
||||
XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.playUrl))
|
||||
XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.pullUrl))
|
||||
XCTAssertEqual(LivePlaybackURLResolver.playableURL(from: live)?.absoluteString, "https://cdn.example.com/live/1.m3u8")
|
||||
}
|
||||
|
||||
func testLivePlaybackViewModelStateTransitions() {
|
||||
let viewModel = LivePlaybackViewModel(urlString: "https://cdn.example.com/live/1.m3u8")
|
||||
|
||||
XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8")
|
||||
XCTAssertFalse(viewModel.isPlaying)
|
||||
|
||||
viewModel.play()
|
||||
XCTAssertTrue(viewModel.isPlaying)
|
||||
XCTAssertNotNil(viewModel.player)
|
||||
|
||||
viewModel.pause()
|
||||
XCTAssertFalse(viewModel.isPlaying)
|
||||
|
||||
viewModel.release()
|
||||
XCTAssertNil(viewModel.player)
|
||||
XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8")
|
||||
|
||||
viewModel.load(urlString: "rtmp://push.example.com/live/1")
|
||||
XCTAssertNil(viewModel.playableURL)
|
||||
XCTAssertEqual(viewModel.errorMessage, "暂无可播放地址")
|
||||
}
|
||||
|
||||
func testLivePushReadinessPermissionsNetworkAndUnsupportedAdapter() async {
|
||||
let permissions = LivePermissionMock(camera: .granted, microphone: .granted)
|
||||
let network = LiveNetworkMock(state: .wifi)
|
||||
let adapter = LivePushAdapterMock(available: false)
|
||||
let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
|
||||
|
||||
viewModel.configure(pushURL: "rtmp://push.example.com/live/1")
|
||||
viewModel.startMonitoring()
|
||||
await viewModel.refreshPermissions()
|
||||
|
||||
XCTAssertEqual(viewModel.cameraPermission, .granted)
|
||||
XCTAssertEqual(viewModel.microphonePermission, .granted)
|
||||
XCTAssertEqual(viewModel.networkState, .wifi)
|
||||
XCTAssertEqual(viewModel.sdkStatusText, "未接入真推流 SDK")
|
||||
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.prepare())
|
||||
XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.sdkUnavailable.localizedDescription)
|
||||
}
|
||||
|
||||
func testLivePushReadinessDiagnosticsDoNotPrepareUnsupportedAdapter() async {
|
||||
let permissions = LivePermissionMock(camera: .granted, microphone: .granted)
|
||||
let network = LiveNetworkMock(state: .wifi)
|
||||
let adapter = LivePushAdapterMock(available: false)
|
||||
let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
|
||||
|
||||
viewModel.configure(pushURL: "rtmp://push.example.com/live/1")
|
||||
viewModel.startMonitoring()
|
||||
await viewModel.refreshPermissions()
|
||||
|
||||
XCTAssertThrowsError(try viewModel.runDiagnostics())
|
||||
XCTAssertFalse(adapter.prepareCalled)
|
||||
XCTAssertFalse(adapter.startCalled)
|
||||
XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.sdkUnavailable.localizedDescription)
|
||||
}
|
||||
|
||||
func testLivePushReadinessBlocksDeniedPermissionAndUnavailableNetwork() async {
|
||||
let permissions = LivePermissionMock(camera: .denied, microphone: .granted)
|
||||
let network = LiveNetworkMock(state: .unavailable)
|
||||
let adapter = LivePushAdapterMock(available: true)
|
||||
let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
|
||||
|
||||
viewModel.configure(pushURL: "rtmp://push.example.com/live/1")
|
||||
viewModel.startMonitoring()
|
||||
await viewModel.refreshPermissions()
|
||||
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.prepare())
|
||||
XCTAssertFalse(adapter.prepareCalled)
|
||||
|
||||
let missingURLViewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
|
||||
missingURLViewModel.configure(pushURL: "")
|
||||
await XCTAssertThrowsErrorAsync(try await missingURLViewModel.prepare())
|
||||
XCTAssertEqual(missingURLViewModel.errorMessage, LivePushReadinessError.missingPushURL.localizedDescription)
|
||||
}
|
||||
|
||||
/// 测试播放地址解析会跳过 RTMP/FLV 并优先选择可用 HTTP 地址。
|
||||
func testLivePlaybackResolverCandidatePriority() {
|
||||
let live = LiveEntity(
|
||||
playUrl: "rtmp://pull.example.com/live/1",
|
||||
pullUrl: "https://cdn.example.com/live/1.flv",
|
||||
hlsUrl: "https://cdn.example.com/live/1.m3u8",
|
||||
liveUrl: "https://cdn.example.com/live/1.mp4"
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
LivePlaybackURLResolver.playableURL(from: live)?.absoluteString,
|
||||
"https://cdn.example.com/live/1.m3u8"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
LivePlaybackURLResolver.playableURL(from: ["rtmp://a", "https://cdn.example.com/fallback.mp4"])?.absoluteString,
|
||||
"https://cdn.example.com/fallback.mp4"
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试播放器可从 LiveEntity 初始化并支持 reload。
|
||||
func testLivePlaybackViewModelLoadLiveEntityAndReload() {
|
||||
let live = LiveEntity(hlsUrl: "https://cdn.example.com/live/1.m3u8")
|
||||
let viewModel = LivePlaybackViewModel(live: live)
|
||||
|
||||
XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8")
|
||||
XCTAssertNotNil(viewModel.player)
|
||||
|
||||
viewModel.play()
|
||||
XCTAssertTrue(viewModel.isPlaying)
|
||||
|
||||
viewModel.reload()
|
||||
XCTAssertFalse(viewModel.isPlaying)
|
||||
XCTAssertNotNil(viewModel.player)
|
||||
XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8")
|
||||
}
|
||||
|
||||
/// 测试无效推流地址配置会提示格式错误。
|
||||
func testLivePushReadinessInvalidPushURL() {
|
||||
let viewModel = LivePushReadinessViewModel(
|
||||
permissionProvider: LivePermissionMock(camera: .granted, microphone: .granted),
|
||||
networkMonitor: LiveNetworkMock(state: .wifi),
|
||||
adapter: LivePushAdapterMock(available: true)
|
||||
)
|
||||
|
||||
viewModel.configure(pushURL: "not-a-valid-url")
|
||||
|
||||
XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.invalidPushURL.localizedDescription)
|
||||
}
|
||||
|
||||
/// 测试可用 SDK 时 prepare、start、stop 和 dispose 的完整流程。
|
||||
func testLivePushReadinessStartStopAndDispose() async throws {
|
||||
let permissions = LivePermissionMock(camera: .granted, microphone: .granted)
|
||||
let network = LiveNetworkMock(state: .wifi)
|
||||
let adapter = LivePushAdapterMock(available: true)
|
||||
let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
|
||||
|
||||
viewModel.configure(pushURL: "rtmp://push.example.com/live/1")
|
||||
viewModel.startMonitoring()
|
||||
await viewModel.refreshPermissions()
|
||||
|
||||
try viewModel.runDiagnostics()
|
||||
try await viewModel.prepare()
|
||||
try await viewModel.startPush()
|
||||
|
||||
XCTAssertTrue(adapter.prepareCalled)
|
||||
XCTAssertTrue(adapter.startCalled)
|
||||
XCTAssertTrue(viewModel.running)
|
||||
XCTAssertTrue(viewModel.prepared)
|
||||
|
||||
await viewModel.stopPush()
|
||||
XCTAssertTrue(adapter.stopCalled)
|
||||
XCTAssertFalse(viewModel.running)
|
||||
|
||||
await viewModel.dispose()
|
||||
XCTAssertFalse(viewModel.prepared)
|
||||
}
|
||||
|
||||
/// 测试权限和网络状态展示文案。
|
||||
func testLivePushStateDisplayText() {
|
||||
XCTAssertEqual(LivePushPermissionState.granted.displayText, "已授权")
|
||||
XCTAssertEqual(LivePushNetworkState.wifi.displayText, "Wi-Fi")
|
||||
XCTAssertEqual(LivePushNetworkState.unavailable.displayText, "网络不可用")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class LiveMock: LiveServing {
|
||||
var liveListResponses: [LiveListResponse] = []
|
||||
var liveDetailResponses: [LiveEntity] = []
|
||||
var albumListResponses: [LiveAlbumFolderListResponse] = []
|
||||
var albumDetailResponses: [LiveAlbumFolderItem] = []
|
||||
var liveListError: Error?
|
||||
var liveDetailError: Error?
|
||||
var albumListError: Error?
|
||||
var albumDetailError: Error?
|
||||
var createRequests: [LiveCreateRequest] = []
|
||||
var startIds: [Int] = []
|
||||
var stopIds: [Int] = []
|
||||
var finishIds: [Int] = []
|
||||
var pushModeRequests: [(liveId: Int, mode: Int)] = []
|
||||
var albumCreateRequests: [LiveAlbumCreateFolderRequest] = []
|
||||
var albumDeleteFolderIds: [Int] = []
|
||||
var albumDeleteFileRequests: [(folderId: Int, fileIds: [Int])] = []
|
||||
private(set) var liveListRequests: [(scenicId: Int, page: Int, pageSize: Int)] = []
|
||||
private(set) var albumListRequests: [(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int)] = []
|
||||
|
||||
func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse {
|
||||
liveListRequests.append((scenicId, page, pageSize))
|
||||
if let liveListError { throw liveListError }
|
||||
return liveListResponses.isEmpty ? LiveListResponse() : liveListResponses.removeFirst()
|
||||
}
|
||||
|
||||
func liveDetail(liveId: Int) async throws -> LiveEntity {
|
||||
if let liveDetailError { throw liveDetailError }
|
||||
return liveDetailResponses.isEmpty ? live(id: liveId) : liveDetailResponses.removeFirst()
|
||||
}
|
||||
|
||||
func liveCreate(_ request: LiveCreateRequest) async throws {
|
||||
createRequests.append(request)
|
||||
}
|
||||
|
||||
func liveStart(liveId: Int) async throws {
|
||||
startIds.append(liveId)
|
||||
}
|
||||
|
||||
func liveStop(liveId: Int) async throws {
|
||||
stopIds.append(liveId)
|
||||
}
|
||||
|
||||
func liveFinish(liveId: Int) async throws {
|
||||
finishIds.append(liveId)
|
||||
}
|
||||
|
||||
func liveSetPushMode(liveId: Int, mode: Int) async throws {
|
||||
pushModeRequests.append((liveId, mode))
|
||||
}
|
||||
|
||||
func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse {
|
||||
albumListRequests.append((scenicId, startTime, endTime, page, pageSize))
|
||||
if let albumListError { throw albumListError }
|
||||
return albumListResponses.isEmpty ? LiveAlbumFolderListResponse() : albumListResponses.removeFirst()
|
||||
}
|
||||
|
||||
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws {
|
||||
albumCreateRequests.append(request)
|
||||
}
|
||||
|
||||
func liveAlbumDeleteFolder(folderId: Int) async throws {
|
||||
albumDeleteFolderIds.append(folderId)
|
||||
}
|
||||
|
||||
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem {
|
||||
if let albumDetailError { throw albumDetailError }
|
||||
return albumDetailResponses.isEmpty ? folder(id: folderId) : albumDetailResponses.removeFirst()
|
||||
}
|
||||
|
||||
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws {
|
||||
albumDeleteFileRequests.append((folderId, fileIds))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class LiveUploadMock: OSSUploadServing {
|
||||
var urls: [String] = []
|
||||
var error: Error?
|
||||
private(set) var uploads: [(data: Data, fileName: String, fileType: Int, scenicId: Int)] = []
|
||||
|
||||
func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
if let error { throw error }
|
||||
uploads.append((data, fileName, fileType, scenicId))
|
||||
onProgress(100)
|
||||
return urls.isEmpty ? "https://cdn.example.com/\(fileName)" : urls.removeFirst()
|
||||
}
|
||||
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
}
|
||||
|
||||
private struct LivePermissionMock: LivePermissionProviding {
|
||||
let camera: LivePushPermissionState
|
||||
let microphone: LivePushPermissionState
|
||||
|
||||
func cameraPermission() async -> LivePushPermissionState {
|
||||
camera
|
||||
}
|
||||
|
||||
func microphonePermission() async -> LivePushPermissionState {
|
||||
microphone
|
||||
}
|
||||
}
|
||||
|
||||
private final class LiveNetworkMock: LiveNetworkMonitoring {
|
||||
private(set) var currentState: LivePushNetworkState
|
||||
private var onChange: (@Sendable (LivePushNetworkState) -> Void)?
|
||||
|
||||
init(state: LivePushNetworkState) {
|
||||
currentState = state
|
||||
}
|
||||
|
||||
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) {
|
||||
self.onChange = onChange
|
||||
onChange(currentState)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
onChange = nil
|
||||
}
|
||||
}
|
||||
|
||||
private final class LivePushAdapterMock: LivePushAdapter {
|
||||
let name = "Mock SDK"
|
||||
let isAvailable: Bool
|
||||
var prepareCalled = false
|
||||
var startCalled = false
|
||||
var stopCalled = false
|
||||
|
||||
init(available: Bool) {
|
||||
isAvailable = available
|
||||
}
|
||||
|
||||
func prepare(pushURL: URL) async throws {
|
||||
prepareCalled = true
|
||||
if !isAvailable {
|
||||
throw LivePushReadinessError.sdkUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
startCalled = true
|
||||
if !isAvailable {
|
||||
throw LivePushReadinessError.sdkUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
func stop() async throws {
|
||||
stopCalled = true
|
||||
}
|
||||
|
||||
func dispose() async {}
|
||||
}
|
||||
|
||||
private func live(id: Int, title: String = "直播", status: Int = 1, manualPushMode: Int = 1) -> LiveEntity {
|
||||
LiveEntity(
|
||||
id: id,
|
||||
title: title,
|
||||
coverImg: "https://cdn.example.com/\(id).jpg",
|
||||
pushUrl: "rtmp://push.example.com/live/\(id)",
|
||||
duration: 3600,
|
||||
status: status,
|
||||
statusLabel: status == 2 ? "直播中" : (status == 3 ? "已结束" : "待开始"),
|
||||
manualPushMode: manualPushMode,
|
||||
viewsCount: 10
|
||||
)
|
||||
}
|
||||
|
||||
private func folder(id: Int, items: [LiveAlbumFileItem] = [albumFile(id: 1)]) -> LiveAlbumFolderItem {
|
||||
LiveAlbumFolderItem(id: id, albumId: id + 100, name: "相册\(id)", creator: "摄影师", items: items)
|
||||
}
|
||||
|
||||
private func albumFile(id: Int) -> LiveAlbumFileItem {
|
||||
LiveAlbumFileItem(id: id, url: "https://cdn.example.com/\(id).jpg", type: 1, size: 100)
|
||||
}
|
||||
|
||||
private func date(_ value: String) -> Date {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
return formatter.date(from: value)!
|
||||
}
|
||||
|
||||
private enum LiveTestError: LocalizedError {
|
||||
case sample
|
||||
var errorDescription: String? { "测试错误" }
|
||||
}
|
||||
|
||||
private func XCTAssertThrowsErrorAsync<T>(
|
||||
_ expression: @autoclosure () async throws -> T,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) async {
|
||||
do {
|
||||
_ = try await expression()
|
||||
XCTFail("Expected error", file: file, line: line)
|
||||
} catch {}
|
||||
}
|
||||
179
suixinkan_iosTests/LocationReport/LocationReportTests.swift
Normal file
179
suixinkan_iosTests/LocationReport/LocationReportTests.swift
Normal file
@ -0,0 +1,179 @@
|
||||
//
|
||||
// LocationReportTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 位置上报 API 测试,覆盖旧工程接口路径、参数和宽松解码。
|
||||
final class LocationReportAPITests: XCTestCase {
|
||||
/// 测试上报接口携带旧工程需要的 query 参数。
|
||||
func testReportLocationUsesExpectedQuery() async throws {
|
||||
let session = LocationRecordingSession(data: Self.submitResponse)
|
||||
let api = LocationReportAPI(client: APIClient(session: session))
|
||||
|
||||
let response = try await api.reportLocation(staffId: 77, latitude: 30.1, longitude: 120.2, address: "入口", type: .immediate, scenicId: 88)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/loacation/report")
|
||||
let query = locationQueryItems(from: request)
|
||||
XCTAssertEqual(query["staff_id"], "77")
|
||||
XCTAssertEqual(query["latitude"], "30.1")
|
||||
XCTAssertEqual(query["longitude"], "120.2")
|
||||
XCTAssertEqual(query["address"], "入口")
|
||||
XCTAssertEqual(query["type"], "1")
|
||||
XCTAssertEqual(query["scenic_id"], "88")
|
||||
XCTAssertEqual(response.expired, 7200)
|
||||
}
|
||||
|
||||
/// 测试历史接口支持类型、日期和分页参数。
|
||||
func testHistoryUsesExpectedQueryAndDecodesLossyFields() async throws {
|
||||
let session = LocationRecordingSession(data: Self.historyResponse)
|
||||
let api = LocationReportAPI(client: APIClient(session: session))
|
||||
|
||||
let payload = try await api.locationReportList(staffId: 77, page: 0, pageSize: 0, type: .marked, startDate: "2026-06-01", endDate: "2026-06-24")
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/loacation/list")
|
||||
let query = locationQueryItems(from: request)
|
||||
XCTAssertEqual(query["staff_id"], "77")
|
||||
XCTAssertEqual(query["page"], "1")
|
||||
XCTAssertEqual(query["page_size"], "1")
|
||||
XCTAssertEqual(query["type"], "2")
|
||||
XCTAssertEqual(query["start_date"], "2026-06-01")
|
||||
XCTAssertEqual(query["end_date"], "2026-06-24")
|
||||
XCTAssertEqual(payload.total, 1)
|
||||
XCTAssertEqual(payload.list.first?.staffId, 77)
|
||||
XCTAssertEqual(payload.list.first?.latitude, "30.1")
|
||||
}
|
||||
|
||||
private static let submitResponse = Data(#"{"code":100000,"msg":"ok","data":{"staff_id":77,"expired":"7200","status":"1"}}"#.utf8)
|
||||
private static let historyResponse = Data(#"{"code":100000,"msg":"ok","data":{"total":"1","list":[{"id":"1","staff_id":"77","type":"2","latitude":30.1,"longitude":"120.2","address":"入口","ip":"127.0.0.1","remark":"ok","created_at":"2026-06-24 10:00:00"}]}}"#.utf8)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 位置上报 ViewModel 测试,覆盖提交保护、类型和历史分页。
|
||||
final class LocationReportViewModelTests: XCTestCase {
|
||||
/// 测试缺 staffId 或 scenicId 时禁止上报。
|
||||
func testSubmitRequiresStaffAndScenic() async {
|
||||
let api = MockLocationReportService()
|
||||
let viewModel = LocationReportViewModel()
|
||||
viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口")
|
||||
|
||||
let missingStaff = await viewModel.submit(type: .immediate, staffId: nil, scenicId: 88, api: api)
|
||||
let missingScenic = await viewModel.submit(type: .immediate, staffId: 77, scenicId: nil, api: api)
|
||||
|
||||
XCTAssertFalse(missingStaff)
|
||||
XCTAssertFalse(missingScenic)
|
||||
XCTAssertEqual(api.reportRequests.count, 0)
|
||||
}
|
||||
|
||||
/// 测试立即上报、标记点上报和在线状态使用正确类型。
|
||||
func testSubmitUsesExpectedTypes() async {
|
||||
let api = MockLocationReportService()
|
||||
let viewModel = LocationReportViewModel()
|
||||
viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口")
|
||||
viewModel.applyMarkedLocation(latitude: 31.1, longitude: 121.2, address: "标记点")
|
||||
|
||||
_ = await viewModel.submit(type: .immediate, staffId: 77, scenicId: 88, api: api)
|
||||
_ = await viewModel.submit(type: .marked, staffId: 77, scenicId: 88, api: api)
|
||||
_ = await viewModel.setOnline(true, staffId: 77, scenicId: 88, api: api)
|
||||
|
||||
XCTAssertEqual(api.reportRequests.map(\.type), [.immediate, .marked, .onlineStatus])
|
||||
XCTAssertEqual(api.reportRequests[1].latitude, 31.1)
|
||||
XCTAssertEqual(viewModel.secondsUntilReport, 600)
|
||||
}
|
||||
|
||||
/// 测试上报失败保留当前状态并暴露错误。
|
||||
func testSubmitFailureKeepsState() async {
|
||||
let api = MockLocationReportService()
|
||||
api.shouldFailReport = true
|
||||
let viewModel = LocationReportViewModel()
|
||||
viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口")
|
||||
|
||||
let success = await viewModel.submit(type: .immediate, staffId: 77, scenicId: 88, api: api)
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertEqual(viewModel.secondsUntilReport, 0)
|
||||
XCTAssertNotNil(viewModel.errorMessage)
|
||||
}
|
||||
|
||||
/// 测试历史筛选和分页请求正确。
|
||||
func testHistoryFilterAndPagination() async {
|
||||
let api = MockLocationReportService()
|
||||
api.historyPages = [
|
||||
ListPayload(total: 2, list: [LocationReportHistoryItem.fixture(id: 1)]),
|
||||
ListPayload(total: 2, list: [LocationReportHistoryItem.fixture(id: 2)])
|
||||
]
|
||||
let viewModel = LocationReportHistoryViewModel()
|
||||
viewModel.selectedType = .marked
|
||||
|
||||
await viewModel.reload(staffId: 77, api: api)
|
||||
await viewModel.loadMore(staffId: 77, api: api)
|
||||
await viewModel.loadMore(staffId: 77, api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
|
||||
XCTAssertEqual(api.historyRequests.map(\.page), [1, 2])
|
||||
XCTAssertEqual(api.historyRequests.first?.type, .marked)
|
||||
}
|
||||
}
|
||||
|
||||
/// 位置上报 API 测试用 URLSession。
|
||||
private final class LocationRecordingSession: URLSessionProtocol {
|
||||
let data: Data
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 初始化测试 Session。
|
||||
init(data: Data) {
|
||||
self.data = data
|
||||
}
|
||||
|
||||
/// 记录请求并返回成功响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
return (data, response)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 位置上报服务测试替身。
|
||||
private final class MockLocationReportService: LocationReportServing {
|
||||
var shouldFailReport = false
|
||||
var reportRequests: [(staffId: Int, latitude: Double, longitude: Double, address: String, type: LocationReportType, scenicId: Int)] = []
|
||||
var historyPages: [ListPayload<LocationReportHistoryItem>] = [ListPayload(total: 0, list: [])]
|
||||
var historyRequests: [(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?)] = []
|
||||
|
||||
func reportLocation(staffId: Int, latitude: Double, longitude: Double, address: String, type: LocationReportType, scenicId: Int) async throws -> LocationReportSubmitResponse {
|
||||
if shouldFailReport { throw NSError(domain: "location", code: 1) }
|
||||
reportRequests.append((staffId, latitude, longitude, address, type, scenicId))
|
||||
return LocationReportSubmitResponse(staffId: "\(staffId)", expired: 600, status: 1)
|
||||
}
|
||||
|
||||
func locationReportList(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?) async throws -> ListPayload<LocationReportHistoryItem> {
|
||||
historyRequests.append((staffId, page, pageSize, type, startDate, endDate))
|
||||
return historyPages.isEmpty ? ListPayload(total: 0, list: []) : historyPages.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
private extension LocationReportHistoryItem {
|
||||
/// 创建历史记录测试实体。
|
||||
static func fixture(id: Int) -> LocationReportHistoryItem {
|
||||
let data = Data(#"{"id":\#(id),"staff_id":77,"type":1,"latitude":"30.1","longitude":"120.2","address":"入口","ip":"","remark":"","created_at":"2026-06-24"}"#.utf8)
|
||||
return try! JSONDecoder().decode(LocationReportHistoryItem.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求中提取 query 字典。
|
||||
private func locationQueryItems(from request: URLRequest) -> [String: String] {
|
||||
guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
item.value.map { (item.name, $0) }
|
||||
})
|
||||
}
|
||||
408
suixinkan_iosTests/LoginViewModelTests.swift
Normal file
408
suixinkan_iosTests/LoginViewModelTests.swift
Normal file
@ -0,0 +1,408 @@
|
||||
//
|
||||
// LoginViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 登录页 ViewModel 测试,覆盖手机号规范化、表单校验、登录流程和账号选择。
|
||||
final class LoginViewModelTests: XCTestCase {
|
||||
/// 测试 +86 手机号会被规范化为 11 位国内手机号。
|
||||
func testNormalizeUsernameCountryCodeRemovesChinaPrefix() {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.username = "+86 186 5185 7230"
|
||||
|
||||
viewModel.normalizeUsernameCountryCodeIfNeeded()
|
||||
|
||||
XCTAssertEqual(viewModel.username, "18651857230")
|
||||
XCTAssertEqual(viewModel.normalizedUsername, "18651857230")
|
||||
XCTAssertTrue(viewModel.isValidPhone)
|
||||
}
|
||||
|
||||
/// 测试有效手机号和密码时允许提交。
|
||||
func testCanSubmitRequiresValidPhoneAndNonEmptyPassword() {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.username = "18651857230"
|
||||
viewModel.password = " secret123 "
|
||||
|
||||
XCTAssertTrue(viewModel.canSubmit)
|
||||
XCTAssertEqual(viewModel.trimmedPassword, "secret123")
|
||||
}
|
||||
|
||||
/// 测试无效手机号时不允许提交。
|
||||
func testCanSubmitIsFalseForInvalidPhone() {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.username = "12345"
|
||||
viewModel.password = "secret123"
|
||||
|
||||
XCTAssertFalse(viewModel.canSubmit)
|
||||
XCTAssertFalse(viewModel.isValidPhone)
|
||||
}
|
||||
|
||||
/// 测试登录校验会按顺序返回手机号、密码和协议错误。
|
||||
func testValidateForLoginReturnsExpectedErrorsInOrder() {
|
||||
let viewModel = LoginViewModel()
|
||||
|
||||
viewModel.username = "12345"
|
||||
viewModel.password = "secret123"
|
||||
viewModel.privacyChecked = true
|
||||
XCTAssertEqual(viewModel.validateForLogin(), .invalidPhone)
|
||||
|
||||
viewModel.username = "18651857230"
|
||||
viewModel.password = " "
|
||||
XCTAssertEqual(viewModel.validateForLogin(), .emptyPassword)
|
||||
|
||||
viewModel.password = "secret123"
|
||||
viewModel.privacyChecked = false
|
||||
XCTAssertEqual(viewModel.validateForLogin(), .privacyUnchecked)
|
||||
|
||||
viewModel.privacyChecked = true
|
||||
XCTAssertNil(viewModel.validateForLogin())
|
||||
}
|
||||
|
||||
/// 测试未勾选协议时登录校验返回隐私协议错误。
|
||||
func testValidateForLoginRequiresPrivacyAgreement() {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.username = "18651857230"
|
||||
viewModel.password = "secret123"
|
||||
viewModel.privacyChecked = false
|
||||
|
||||
XCTAssertEqual(viewModel.validateForLogin(), .privacyUnchecked)
|
||||
}
|
||||
|
||||
/// 测试登录偏好只恢复手机号和协议状态,不恢复密码。
|
||||
func testApplyPreferencesRestoresUsernameAndPrivacyOnly() {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.password = "manual-password"
|
||||
|
||||
viewModel.applyPreferences(
|
||||
LoginPreferences(
|
||||
lastUsername: "18651857230",
|
||||
privacyAgreementAccepted: true
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(viewModel.username, "18651857230")
|
||||
XCTAssertTrue(viewModel.privacyChecked)
|
||||
XCTAssertEqual(viewModel.password, "manual-password")
|
||||
}
|
||||
|
||||
/// 测试已有手机号时不会被偏好覆盖。
|
||||
func testApplyPreferencesDoesNotOverwriteExistingUsername() {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.username = "13900139000"
|
||||
|
||||
viewModel.applyPreferences(
|
||||
LoginPreferences(
|
||||
lastUsername: "18651857230",
|
||||
privacyAgreementAccepted: false
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(viewModel.username, "13900139000")
|
||||
XCTAssertFalse(viewModel.privacyChecked)
|
||||
}
|
||||
|
||||
/// 测试用户同意协议后会勾选协议并关闭弹窗。
|
||||
func testAcceptAgreementChecksPrivacyAndDismissesSheet() {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.showsAgreementSheet = true
|
||||
|
||||
viewModel.acceptAgreement()
|
||||
|
||||
XCTAssertTrue(viewModel.privacyChecked)
|
||||
XCTAssertFalse(viewModel.showsAgreementSheet)
|
||||
}
|
||||
|
||||
/// 测试单账号登录会自动调用 set-user 并返回 completed。
|
||||
func testLoginWithSingleAccountAutoCompletesSetUser() async throws {
|
||||
let session = LoginRecordingURLSession(responses: [
|
||||
try TestFixture.data(named: "v9_login_single_scenic_success"),
|
||||
try TestFixture.data(named: "v9_set_scenic_user_success")
|
||||
])
|
||||
let api = AuthAPI(client: APIClient(session: session))
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.username = "18651857230"
|
||||
viewModel.password = "secret123"
|
||||
viewModel.privacyChecked = true
|
||||
|
||||
let resolution = try await viewModel.login(authAPI: api)
|
||||
|
||||
guard case .completed(let response) = resolution else {
|
||||
return XCTFail("单账号登录应直接完成")
|
||||
}
|
||||
XCTAssertEqual(response.token, "business-token-scenic")
|
||||
XCTAssertFalse(viewModel.isLoading)
|
||||
XCTAssertNil(viewModel.pendingAccountSelection)
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/app/v9/login",
|
||||
"/api/app/v9/set-user"
|
||||
])
|
||||
let loginBody = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[0].httpBody)) as? [String: Any])
|
||||
XCTAssertEqual(loginBody["username"] as? String, "18651857230")
|
||||
let setUserBody = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[1].httpBody)) as? [String: Any])
|
||||
XCTAssertEqual(setUserBody["ss_user_id"] as? Int, 101)
|
||||
XCTAssertNil(setUserBody["store_user_id"] as? Int)
|
||||
}
|
||||
|
||||
/// 测试多账号登录会保留待选账号并返回 needsAccountSelection。
|
||||
func testLoginWithMultipleAccountsNeedsSelection() async throws {
|
||||
let session = LoginRecordingURLSession(responses: [
|
||||
try TestFixture.data(named: "v9_login_multi_success")
|
||||
])
|
||||
let api = AuthAPI(client: APIClient(session: session))
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.username = "18651857230"
|
||||
viewModel.password = "secret123"
|
||||
viewModel.privacyChecked = true
|
||||
|
||||
let resolution = try await viewModel.login(authAPI: api)
|
||||
|
||||
guard case .needsAccountSelection(let payload) = resolution else {
|
||||
return XCTFail("多账号登录应进入账号选择")
|
||||
}
|
||||
XCTAssertEqual(payload.tempToken, "person-temp-token")
|
||||
XCTAssertEqual(payload.accounts.map(\.businessUserId), [101, 201])
|
||||
XCTAssertEqual(viewModel.pendingAccountSelection, payload)
|
||||
XCTAssertEqual(session.requests.count, 1)
|
||||
XCTAssertEqual(session.requests.first?.url?.path, "/api/app/v9/login")
|
||||
}
|
||||
|
||||
/// 测试没有可用账号时抛出 noAvailableAccount。
|
||||
func testLoginThrowsWhenNoAvailableAccounts() async throws {
|
||||
let session = LoginRecordingURLSession(responses: [
|
||||
"""
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"token": "person-temp-token",
|
||||
"scenic_users": [],
|
||||
"store_users": []
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
])
|
||||
let api = AuthAPI(client: APIClient(session: session))
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.username = "18651857230"
|
||||
viewModel.password = "secret123"
|
||||
viewModel.privacyChecked = true
|
||||
|
||||
do {
|
||||
_ = try await viewModel.login(authAPI: api)
|
||||
XCTFail("没有可用账号时应抛出错误")
|
||||
} catch let error as LoginFlowError {
|
||||
guard case .noAvailableAccount = error else {
|
||||
return XCTFail("期望 noAvailableAccount,实际为 \(error)")
|
||||
}
|
||||
}
|
||||
XCTAssertFalse(viewModel.isLoading)
|
||||
}
|
||||
|
||||
/// 测试登录响应缺少 token 时抛出 missingToken。
|
||||
func testLoginThrowsWhenTokenMissing() async throws {
|
||||
let session = LoginRecordingURLSession(responses: [
|
||||
"""
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"token": "",
|
||||
"scenic_users": [
|
||||
{
|
||||
"account_type": "scenic_user",
|
||||
"id": 101,
|
||||
"user_id": 101,
|
||||
"scenic_user_id": 101,
|
||||
"ss_user_id": 101,
|
||||
"username": "scenic_admin",
|
||||
"real_name": "张三",
|
||||
"nickname": "张三",
|
||||
"phone": "13800138000",
|
||||
"scenic_id": 10,
|
||||
"scenic_name": "示例景区",
|
||||
"is_current": false
|
||||
}
|
||||
],
|
||||
"store_users": []
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
])
|
||||
let api = AuthAPI(client: APIClient(session: session))
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.username = "18651857230"
|
||||
viewModel.password = "secret123"
|
||||
viewModel.privacyChecked = true
|
||||
|
||||
do {
|
||||
_ = try await viewModel.login(authAPI: api)
|
||||
XCTFail("缺少 token 时应抛出错误")
|
||||
} catch let error as LoginFlowError {
|
||||
guard case .missingToken = error else {
|
||||
return XCTFail("期望 missingToken,实际为 \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试重复发起登录时会抛出 CancellationError。
|
||||
func testLoginWhileLoadingThrowsCancellationError() async throws {
|
||||
let session = LoginRecordingURLSession(responses: [
|
||||
try TestFixture.data(named: "v9_login_single_scenic_success"),
|
||||
try TestFixture.data(named: "v9_set_scenic_user_success")
|
||||
])
|
||||
let api = AuthAPI(client: APIClient(session: session))
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.username = "18651857230"
|
||||
viewModel.password = "secret123"
|
||||
viewModel.privacyChecked = true
|
||||
viewModel.isLoading = true
|
||||
|
||||
do {
|
||||
_ = try await viewModel.login(authAPI: api)
|
||||
XCTFail("加载中重复登录应被取消")
|
||||
} catch {
|
||||
XCTAssertTrue(error is CancellationError)
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试选择账号后会用临时 token 调用 set-user 并清空待选状态。
|
||||
func testSelectAccountCompletesWithFinalToken() async throws {
|
||||
let session = LoginRecordingURLSession(responses: [
|
||||
try TestFixture.data(named: "v9_set_store_user_success")
|
||||
])
|
||||
let api = AuthAPI(client: APIClient(session: session))
|
||||
let account = AccountSwitchAccount(
|
||||
accountType: V9StoreUser.accountTypeValue,
|
||||
businessUserId: 201,
|
||||
title: "示例门店",
|
||||
subtitle: "示例景区",
|
||||
phone: "13800138000",
|
||||
avatar: "",
|
||||
scenicName: "示例景区",
|
||||
storeId: 20,
|
||||
storeName: "示例门店",
|
||||
scenicId: 10,
|
||||
isCurrent: false
|
||||
)
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.pendingAccountSelection = AccountSelectionPayload(
|
||||
tempToken: "person-temp-token",
|
||||
accounts: [account]
|
||||
)
|
||||
|
||||
let response = try await viewModel.selectAccount(account, authAPI: api)
|
||||
|
||||
XCTAssertEqual(response.token, "business-token-store")
|
||||
XCTAssertNil(viewModel.pendingAccountSelection)
|
||||
XCTAssertFalse(viewModel.isSelectingAccount)
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/v9/set-user")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "token"), "person-temp-token")
|
||||
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any])
|
||||
XCTAssertEqual(json["store_user_id"] as? Int, 201)
|
||||
}
|
||||
|
||||
/// 测试缺少待选账号时 selectAccount 会抛出 missingToken。
|
||||
func testSelectAccountThrowsWhenPendingSelectionMissing() async {
|
||||
let api = AuthAPI(client: APIClient(session: LoginRecordingURLSession(responses: [])))
|
||||
let account = AccountSwitchAccount(
|
||||
accountType: V9StoreUser.accountTypeValue,
|
||||
businessUserId: 201,
|
||||
title: "示例门店",
|
||||
subtitle: "示例景区",
|
||||
phone: "13800138000",
|
||||
avatar: "",
|
||||
scenicName: "示例景区",
|
||||
storeId: 20,
|
||||
storeName: "示例门店",
|
||||
scenicId: 10,
|
||||
isCurrent: false
|
||||
)
|
||||
let viewModel = LoginViewModel()
|
||||
|
||||
do {
|
||||
_ = try await viewModel.selectAccount(account, authAPI: api)
|
||||
XCTFail("缺少待选账号时应抛出错误")
|
||||
} catch let error as LoginFlowError {
|
||||
guard case .missingToken = error else {
|
||||
return XCTFail("期望 missingToken,实际为 \(error)")
|
||||
}
|
||||
} catch {
|
||||
XCTFail("意外错误: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试无效业务账号 ID 时 selectAccount 会抛出 invalidAccount。
|
||||
func testSelectAccountThrowsWhenAccountInvalid() async {
|
||||
let api = AuthAPI(client: APIClient(session: LoginRecordingURLSession(responses: [])))
|
||||
let account = AccountSwitchAccount(
|
||||
accountType: V9StoreUser.accountTypeValue,
|
||||
businessUserId: 0,
|
||||
title: "无效账号",
|
||||
subtitle: "",
|
||||
phone: "",
|
||||
avatar: "",
|
||||
scenicName: "",
|
||||
storeId: nil,
|
||||
storeName: "",
|
||||
scenicId: nil,
|
||||
isCurrent: false
|
||||
)
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.pendingAccountSelection = AccountSelectionPayload(
|
||||
tempToken: "person-temp-token",
|
||||
accounts: [account]
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try await viewModel.selectAccount(account, authAPI: api)
|
||||
XCTFail("无效账号 ID 时应抛出错误")
|
||||
} catch let error as LoginFlowError {
|
||||
guard case .invalidAccount = error else {
|
||||
return XCTFail("期望 invalidAccount,实际为 \(error)")
|
||||
}
|
||||
} catch {
|
||||
XCTFail("意外错误: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试取消账号选择时会清空待选状态。
|
||||
func testClearPendingAccountSelection() {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.pendingAccountSelection = AccountSelectionPayload(
|
||||
tempToken: "person-temp-token",
|
||||
accounts: []
|
||||
)
|
||||
|
||||
viewModel.clearPendingAccountSelection()
|
||||
|
||||
XCTAssertNil(viewModel.pendingAccountSelection)
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录测试用 URLSession 替身,按顺序返回预设响应。
|
||||
private final class LoginRecordingURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 初始化顺序响应。
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 记录请求并返回下一份响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.isEmpty ? Data() : responses.removeFirst()
|
||||
return (
|
||||
data,
|
||||
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
)
|
||||
}
|
||||
}
|
||||
143
suixinkan_iosTests/MainTabBadgeViewModelTests.swift
Normal file
143
suixinkan_iosTests/MainTabBadgeViewModelTests.swift
Normal file
@ -0,0 +1,143 @@
|
||||
//
|
||||
// MainTabBadgeViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 主 Tab 角标测试,覆盖待核销数量加载、封顶和失败清理。
|
||||
final class MainTabBadgeViewModelTests: XCTestCase {
|
||||
/// 测试无景区时清空角标且不请求接口。
|
||||
func testMissingScenicClearsBadgeWithoutRequestingAPI() async {
|
||||
let api = MockMainTabOrderService()
|
||||
let viewModel = MainTabBadgeViewModel()
|
||||
|
||||
await viewModel.refreshPendingWriteOffCount(api: api, scenicId: nil, storeId: 66)
|
||||
|
||||
XCTAssertNil(viewModel.pendingWriteOffCount)
|
||||
XCTAssertTrue(api.writeOffListCalls.isEmpty)
|
||||
}
|
||||
|
||||
/// 测试接口成功时展示待核销总数。
|
||||
func testSuccessfulRefreshStoresPendingWriteOffCount() async {
|
||||
let api = MockMainTabOrderService()
|
||||
api.writeOffResponse = ListPayload<WriteOffOrderItem>(total: 8, list: [])
|
||||
let viewModel = MainTabBadgeViewModel()
|
||||
|
||||
await viewModel.refreshPendingWriteOffCount(api: api, scenicId: 88, storeId: 66)
|
||||
|
||||
XCTAssertEqual(viewModel.pendingWriteOffCount, 8)
|
||||
XCTAssertEqual(api.writeOffListCalls, [.init(scenicId: 88, storeId: 66, page: 1, pageSize: 1)])
|
||||
}
|
||||
|
||||
/// 测试待核销数量超过 99 时角标封顶。
|
||||
func testSuccessfulRefreshCapsPendingWriteOffCountAtNinetyNine() async {
|
||||
let api = MockMainTabOrderService()
|
||||
api.writeOffResponse = ListPayload<WriteOffOrderItem>(total: 120, list: [])
|
||||
let viewModel = MainTabBadgeViewModel()
|
||||
|
||||
await viewModel.refreshPendingWriteOffCount(api: api, scenicId: 88, storeId: nil)
|
||||
|
||||
XCTAssertEqual(viewModel.pendingWriteOffCount, 99)
|
||||
}
|
||||
|
||||
/// 测试接口失败时清空角标。
|
||||
func testRefreshFailureClearsBadge() async {
|
||||
let api = MockMainTabOrderService()
|
||||
api.writeOffError = APIError.httpStatus(500, "server error")
|
||||
let viewModel = MainTabBadgeViewModel()
|
||||
|
||||
await viewModel.refreshPendingWriteOffCount(api: api, scenicId: 88, storeId: 66)
|
||||
|
||||
XCTAssertNil(viewModel.pendingWriteOffCount)
|
||||
XCTAssertEqual(api.writeOffListCalls.count, 1)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 主 Tab 角标测试用订单服务,只实现角标依赖的核销列表记录。
|
||||
private final class MockMainTabOrderService: OrderServing {
|
||||
struct WriteOffListCall: Equatable {
|
||||
let scenicId: Int
|
||||
let storeId: Int?
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
}
|
||||
|
||||
var writeOffResponse = ListPayload<WriteOffOrderItem>(total: 0, list: [])
|
||||
var writeOffError: Error?
|
||||
private(set) var writeOffListCalls: [WriteOffListCall] = []
|
||||
|
||||
/// 返回空订单管理列表,角标测试不会使用该方法。
|
||||
func orderList(
|
||||
scenicId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderStatus: Int?,
|
||||
userPhone: String?,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
isRefined: Int?,
|
||||
isScenicAdmin: Bool
|
||||
) async throws -> ListPayload<OrderEntity> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 记录核销列表请求并返回预设响应。
|
||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
writeOffListCalls.append(.init(scenicId: scenicId, storeId: storeId, page: page, pageSize: pageSize))
|
||||
if let writeOffError {
|
||||
throw writeOffError
|
||||
}
|
||||
return writeOffResponse
|
||||
}
|
||||
|
||||
/// 角标测试不会触发核销操作。
|
||||
func writeOff(orderNumber: String) async throws {}
|
||||
|
||||
/// 角标测试不会请求订单详情。
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
||||
try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")
|
||||
}
|
||||
|
||||
/// 角标测试不会请求押金订单列表。
|
||||
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 角标测试不会触发押金核销。
|
||||
func depositOrderWriteOff(orderNumber: String) async throws {}
|
||||
|
||||
/// 角标测试不会触发押金退款。
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {}
|
||||
|
||||
/// 角标测试不会请求拍摄信息。
|
||||
func storeOrderShootingDetail(
|
||||
storeId: Int,
|
||||
orderNumber: String,
|
||||
scenicSpotId: Int,
|
||||
photogUid: Int
|
||||
) async throws -> StoreOrderShootingDetailResponse {
|
||||
try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success")
|
||||
}
|
||||
|
||||
/// 角标测试不会触发普通退款。
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {}
|
||||
|
||||
/// 角标测试不会请求历史拍摄。
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")
|
||||
}
|
||||
|
||||
/// 角标测试不会请求已核销打卡点。
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] {
|
||||
[]
|
||||
}
|
||||
|
||||
/// 角标测试不会提交多点旅拍素材。
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {}
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
//
|
||||
// MessageCenterViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 消息中心测试,覆盖分页、筛选、已读和删除。
|
||||
final class MessageCenterViewModelTests: XCTestCase {
|
||||
func testReloadFirstPageMapsMessagesAndPaginationState() async {
|
||||
let api = MessageCenterMock()
|
||||
api.listResponses = [
|
||||
MessageListResponse(hasMore: true, lastId: 101, items: [
|
||||
MessageEntity(id: 101, type: 1, typeName: "", title: "新订单提醒", content: "有新订单", pushAt: "2026-06-25 10:00", isRead: false),
|
||||
MessageEntity(id: 100, type: 2, typeName: "核销", title: "", content: "核销完成", pushAt: "2026-06-25 09:00", isRead: true)
|
||||
])
|
||||
]
|
||||
let viewModel = MessageCenterViewModel()
|
||||
viewModel.selectedFilter = .unread
|
||||
|
||||
await viewModel.reloadFirstPage(api: api)
|
||||
|
||||
XCTAssertEqual(api.listRequests.map(\.unread), [1])
|
||||
XCTAssertEqual(viewModel.messages.map(\.id), ["msg_101", "msg_100"])
|
||||
XCTAssertEqual(viewModel.messages[0].title, "新订单提醒")
|
||||
XCTAssertEqual(viewModel.messages[0].type, .order)
|
||||
XCTAssertEqual(viewModel.messages[1].title, "核销")
|
||||
XCTAssertEqual(viewModel.messages[1].type, .writeOff)
|
||||
XCTAssertEqual(viewModel.unreadCount, 1)
|
||||
XCTAssertTrue(viewModel.hasMoreMessages)
|
||||
XCTAssertEqual(viewModel.lastId, 101)
|
||||
}
|
||||
|
||||
func testLoadMoreDeduplicatesSortsAndStopsAtTail() async {
|
||||
let api = MessageCenterMock()
|
||||
api.listResponses = [
|
||||
MessageListResponse(hasMore: true, lastId: 101, items: [
|
||||
MessageEntity(id: 101, type: 1, title: "A", content: "A", pushAt: "2026-06-25 10:00"),
|
||||
MessageEntity(id: 100, type: 3, title: "B", content: "B", pushAt: "2026-06-25 09:00")
|
||||
]),
|
||||
MessageListResponse(hasMore: false, lastId: 99, items: [
|
||||
MessageEntity(id: 100, type: 3, title: "B2", content: "B2", pushAt: "2026-06-25 09:00"),
|
||||
MessageEntity(id: 99, type: 0, title: "C", content: "C", pushAt: "2026-06-24 08:00")
|
||||
])
|
||||
]
|
||||
let viewModel = MessageCenterViewModel()
|
||||
|
||||
await viewModel.reloadFirstPage(api: api)
|
||||
await viewModel.loadMore(api: api)
|
||||
await viewModel.loadMore(api: api)
|
||||
|
||||
XCTAssertEqual(api.listRequests.map(\.lastId), [0, 101])
|
||||
XCTAssertEqual(viewModel.messages.map(\.id), ["msg_101", "msg_100", "msg_99"])
|
||||
XCTAssertEqual(viewModel.messages[1].title, "B2")
|
||||
XCTAssertFalse(viewModel.hasMoreMessages)
|
||||
XCTAssertEqual(viewModel.lastId, 99)
|
||||
}
|
||||
|
||||
func testReloadFailureClearsStaleMessagesAndPaging() async {
|
||||
let api = MessageCenterMock()
|
||||
api.listResponses = [
|
||||
MessageListResponse(hasMore: true, lastId: 101, items: [
|
||||
MessageEntity(id: 101, type: 1, title: "A")
|
||||
])
|
||||
]
|
||||
let viewModel = MessageCenterViewModel()
|
||||
await viewModel.reloadFirstPage(api: api)
|
||||
|
||||
api.listError = TestError.sample
|
||||
await viewModel.reloadFirstPage(api: api)
|
||||
|
||||
XCTAssertTrue(viewModel.messages.isEmpty)
|
||||
XCTAssertFalse(viewModel.hasMoreMessages)
|
||||
XCTAssertEqual(viewModel.lastId, 0)
|
||||
XCTAssertTrue(viewModel.loadFailed)
|
||||
}
|
||||
|
||||
func testMarkReadAllReadAndDeleteUpdateOnlyAfterSuccess() async throws {
|
||||
let api = MessageCenterMock()
|
||||
api.listResponses = [
|
||||
MessageListResponse(items: [
|
||||
MessageEntity(id: 101, type: 1, title: "A", isRead: false),
|
||||
MessageEntity(id: 100, type: 0, title: "B", isRead: false)
|
||||
])
|
||||
]
|
||||
let viewModel = MessageCenterViewModel()
|
||||
await viewModel.reloadFirstPage(api: api)
|
||||
|
||||
try await viewModel.markAsRead(api: api, item: try XCTUnwrap(viewModel.messages.first))
|
||||
XCTAssertEqual(api.readIds, [101])
|
||||
XCTAssertEqual(viewModel.messages.map(\.isRead), [true, false])
|
||||
|
||||
api.readIds = []
|
||||
try await viewModel.markAllAsRead(api: api)
|
||||
XCTAssertEqual(api.readIds, [100])
|
||||
XCTAssertTrue(viewModel.messages.allSatisfy(\.isRead))
|
||||
|
||||
let item = try XCTUnwrap(viewModel.messages.first)
|
||||
try await viewModel.deleteMessage(api: api, item: item)
|
||||
XCTAssertEqual(api.deleteIds, [101])
|
||||
XCTAssertFalse(viewModel.messages.contains(where: { $0.id == item.id }))
|
||||
}
|
||||
|
||||
func testFailedReadAndDeleteKeepLocalState() async throws {
|
||||
let api = MessageCenterMock()
|
||||
api.listResponses = [MessageListResponse(items: [MessageEntity(id: 101, type: 1, title: "A", isRead: false)])]
|
||||
let viewModel = MessageCenterViewModel()
|
||||
await viewModel.reloadFirstPage(api: api)
|
||||
let item = try XCTUnwrap(viewModel.messages.first)
|
||||
|
||||
api.readError = TestError.sample
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.markAsRead(api: api, item: item))
|
||||
XCTAssertFalse(try XCTUnwrap(viewModel.messages.first).isRead)
|
||||
|
||||
api.readError = nil
|
||||
api.deleteError = TestError.sample
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.deleteMessage(api: api, item: item))
|
||||
XCTAssertEqual(viewModel.messages.map(\.id), [item.id])
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MessageCenterMock: MessageCenterServing {
|
||||
var listResponses: [MessageListResponse] = []
|
||||
var listError: Error?
|
||||
var readError: Error?
|
||||
var deleteError: Error?
|
||||
var readIds: [Int] = []
|
||||
var deleteIds: [Int] = []
|
||||
private(set) var listRequests: [(lastId: Int, limit: Int, unread: Int)] = []
|
||||
|
||||
func messageList(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse {
|
||||
listRequests.append((lastId, limit, unread))
|
||||
if let listError { throw listError }
|
||||
return listResponses.isEmpty ? MessageListResponse() : listResponses.removeFirst()
|
||||
}
|
||||
|
||||
func messageRead(id: Int) async throws {
|
||||
if let readError { throw readError }
|
||||
readIds.append(id)
|
||||
}
|
||||
|
||||
func messageDelete(id: Int) async throws {
|
||||
if let deleteError { throw deleteError }
|
||||
deleteIds.append(id)
|
||||
}
|
||||
}
|
||||
|
||||
private enum TestError: LocalizedError {
|
||||
case sample
|
||||
var errorDescription: String? { "测试错误" }
|
||||
}
|
||||
|
||||
private func XCTAssertThrowsErrorAsync(
|
||||
_ expression: @autoclosure () async throws -> Void,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) async {
|
||||
do {
|
||||
try await expression()
|
||||
XCTFail("Expected error", file: file, line: line)
|
||||
} catch {}
|
||||
}
|
||||
96
suixinkan_iosTests/NavigationRouterTests.swift
Normal file
96
suixinkan_iosTests/NavigationRouterTests.swift
Normal file
@ -0,0 +1,96 @@
|
||||
//
|
||||
// NavigationRouterTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
/// 主导航路由测试,覆盖 AppRoute 的跨页面导航策略。
|
||||
final class NavigationRouterTests: XCTestCase {
|
||||
/// 测试通过 NavigationStack push 的子页面默认隐藏底部 TabBar。
|
||||
func testPushedRoutesHideTabBarByDefault() {
|
||||
XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.home(.paymentCollection).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.home(.wallet).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.orders(.multiTravelTaskUpload(orderNumber: "MT001")).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.orders(.orderTrailer(orderNumber: "MT001", title: "尾片上传")).hidesTabBarWhenPushed)
|
||||
}
|
||||
|
||||
/// 测试切换订单 Tab 时会保存订单内部子入口。
|
||||
func testSelectOrdersUpdatesTabAndEntry() {
|
||||
let router = AppRouter()
|
||||
|
||||
router.selectOrders(entry: .verificationOrders)
|
||||
|
||||
XCTAssertEqual(router.selectedTab, .orders)
|
||||
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
||||
}
|
||||
|
||||
/// 测试全局扫码核销路由会切到订单核销入口并保存扫码结果。
|
||||
func testRouteToOrderVerificationStoresPendingScanCode() {
|
||||
let router = AppRouter()
|
||||
|
||||
router.routeToOrderVerification(scannedCode: "order_number=VERIFY001")
|
||||
|
||||
XCTAssertEqual(router.selectedTab, .orders)
|
||||
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
||||
XCTAssertEqual(router.pendingOrderScanCode, "order_number=VERIFY001")
|
||||
}
|
||||
|
||||
/// 测试全局扫码结果只能被消费一次。
|
||||
func testConsumePendingOrderScanCodeClearsValueAfterFirstRead() {
|
||||
let router = AppRouter()
|
||||
router.routeToOrderVerification(scannedCode: "VERIFY001")
|
||||
|
||||
XCTAssertEqual(router.consumePendingOrderScanCode(), "VERIFY001")
|
||||
XCTAssertNil(router.consumePendingOrderScanCode())
|
||||
}
|
||||
|
||||
/// 测试重置主路由时会清理未消费的扫码结果。
|
||||
func testResetClearsPendingOrderScanCode() {
|
||||
let router = AppRouter()
|
||||
router.routeToOrderVerification(scannedCode: "VERIFY001")
|
||||
|
||||
router.reset()
|
||||
|
||||
XCTAssertEqual(router.selectedTab, .home)
|
||||
XCTAssertEqual(router.selectedOrdersEntry, .storeOrders)
|
||||
XCTAssertNil(router.pendingOrderScanCode)
|
||||
}
|
||||
|
||||
/// 测试订单模块真实路由可被压入导航栈。
|
||||
func testOrderRoutesCanBePushedIntoRouterPath() throws {
|
||||
let router = RouterPath()
|
||||
let order = try Self.firstOrder()
|
||||
let writeOff = try Self.firstWriteOffOrder()
|
||||
|
||||
router.navigate(to: .orders(.storeDetail(order)))
|
||||
router.navigate(to: .orders(.writeOffDetail(writeOff)))
|
||||
router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: "MT001")))
|
||||
router.navigate(to: .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告")))
|
||||
|
||||
XCTAssertEqual(router.path.count, 4)
|
||||
XCTAssertEqual(router.path[0], .orders(.storeDetail(order)))
|
||||
XCTAssertEqual(router.path[1], .orders(.writeOffDetail(writeOff)))
|
||||
XCTAssertEqual(router.path[2], .orders(.multiTravelTaskUpload(orderNumber: "MT001")))
|
||||
XCTAssertEqual(router.path[3], .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告")))
|
||||
}
|
||||
|
||||
/// 返回测试用订单管理列表项。
|
||||
private static func firstOrder() throws -> OrderEntity {
|
||||
let payload = try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")
|
||||
return try XCTUnwrap(payload.list.first)
|
||||
}
|
||||
|
||||
/// 返回测试用核销订单列表项。
|
||||
private static func firstWriteOffOrder() throws -> WriteOffOrderItem {
|
||||
let payload = try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")
|
||||
return try XCTUnwrap(payload.list.first)
|
||||
}
|
||||
}
|
||||
171
suixinkan_iosTests/OperatingArea/OperatingAreaTests.swift
Normal file
171
suixinkan_iosTests/OperatingArea/OperatingAreaTests.swift
Normal file
@ -0,0 +1,171 @@
|
||||
//
|
||||
// OperatingAreaTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 运营区域测试,覆盖接口编码、角色分流和围栏解析。
|
||||
final class OperatingAreaTests: XCTestCase {
|
||||
func testAPIsUseExpectedQueriesAndDecodeFixture() async throws {
|
||||
let session = OperatingRecordingURLSession(data: try TestFixture.data(named: "operating_area_success"))
|
||||
let api = OperatingAreaAPI(client: APIClient(session: session))
|
||||
|
||||
let storeResponse = try await api.storeBusinessArea(storeId: 101)
|
||||
XCTAssertEqual(storeResponse.total, 4)
|
||||
XCTAssertEqual(storeResponse.list.first?.id, 101)
|
||||
var request = try XCTUnwrap(session.requests.last)
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.url?.path, "/api/app/store/business-area")
|
||||
XCTAssertEqual(queryItems(from: request)["store_id"], "101")
|
||||
|
||||
_ = try await api.scenicAdminBusinessArea(scenicId: 88)
|
||||
request = try XCTUnwrap(session.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/scenic-admin/business-area")
|
||||
XCTAssertEqual(queryItems(from: request)["scenic_id"], "88")
|
||||
}
|
||||
|
||||
func testViewModelUsesStoreAndScenicModes() async {
|
||||
let api = OperatingMock()
|
||||
api.response = ListPayload(total: 1, list: [area(id: 101)])
|
||||
|
||||
let storeViewModel = OperatingAreaViewModel()
|
||||
await storeViewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员"))
|
||||
XCTAssertEqual(api.storeIds, [101])
|
||||
XCTAssertEqual(storeViewModel.fenceRings.first?.isCurrentStore, true)
|
||||
|
||||
let scenicViewModel = OperatingAreaViewModel()
|
||||
await scenicViewModel.reload(api: api, accountContext: accountContext(scenicId: 88, storeId: nil), permissionContext: permissionContext(roleName: "景区管理员"))
|
||||
XCTAssertEqual(api.scenicIds, [88])
|
||||
XCTAssertEqual(scenicViewModel.mode, .scenicAdmin(scenicId: 88))
|
||||
}
|
||||
|
||||
func testViewModelBlocksMissingContextAndFailureClearsData() async {
|
||||
let api = OperatingMock()
|
||||
let viewModel = OperatingAreaViewModel()
|
||||
|
||||
await viewModel.reload(api: api, accountContext: accountContext(scenicId: nil, storeId: nil), permissionContext: permissionContext(roleName: "店铺管理员"))
|
||||
XCTAssertEqual(viewModel.blockReason, .missingStore)
|
||||
XCTAssertTrue(api.storeIds.isEmpty)
|
||||
|
||||
api.response = ListPayload(total: 1, list: [area(id: 101)])
|
||||
await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员"))
|
||||
XCTAssertFalse(viewModel.fenceRings.isEmpty)
|
||||
|
||||
api.error = OperatingTestError.sample
|
||||
await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员"))
|
||||
XCTAssertTrue(viewModel.items.isEmpty)
|
||||
XCTAssertTrue(viewModel.fenceRings.isEmpty)
|
||||
XCTAssertEqual(viewModel.blockReason, .backendMessage("测试错误"))
|
||||
}
|
||||
|
||||
func testParserSupportsGeoJSONStringArraysAndFiltersInvalidRings() throws {
|
||||
let response = try TestFixture.payload(ListPayload<OperatingAreaItem>.self, named: "operating_area_success")
|
||||
let rings = response.list.flatMap { OperatingAreaParser.parseToRings($0.businessMapArea) }
|
||||
|
||||
XCTAssertEqual(rings.count, 3)
|
||||
XCTAssertEqual(rings.first?.first, OperatingGeoPoint(latitude: 32.041, longitude: 118.781))
|
||||
XCTAssertEqual(rings[1].first, OperatingGeoPoint(latitude: 32.051, longitude: 118.791))
|
||||
XCTAssertEqual(rings[2].first, OperatingGeoPoint(latitude: 32.061, longitude: 118.801))
|
||||
}
|
||||
|
||||
func testEmptyAndUnparsableAreaStates() async {
|
||||
let api = OperatingMock()
|
||||
let viewModel = OperatingAreaViewModel()
|
||||
|
||||
api.response = ListPayload(total: 0, list: [])
|
||||
await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员"))
|
||||
XCTAssertEqual(viewModel.blockReason, .emptyAreaList)
|
||||
|
||||
api.response = ListPayload(total: 1, list: [OperatingAreaItem(id: 1, name: "bad", businessMapArea: .array([.array([.number(1), .number(2)])]))])
|
||||
await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员"))
|
||||
XCTAssertEqual(viewModel.blockReason, .noParsableFenceData)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class OperatingMock: OperatingAreaServing {
|
||||
var response = ListPayload<OperatingAreaItem>(total: 0, list: [])
|
||||
var error: Error?
|
||||
var storeIds: [Int] = []
|
||||
var scenicIds: [Int] = []
|
||||
|
||||
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem> {
|
||||
storeIds.append(storeId)
|
||||
if let error { throw error }
|
||||
return response
|
||||
}
|
||||
|
||||
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem> {
|
||||
scenicIds.append(scenicId)
|
||||
if let error { throw error }
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
private final class OperatingRecordingURLSession: URLSessionProtocol {
|
||||
private let data: Data
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
init(data: Data) {
|
||||
self.data = data
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
|
||||
}
|
||||
}
|
||||
|
||||
private enum OperatingTestError: LocalizedError {
|
||||
case sample
|
||||
|
||||
var errorDescription: String? { "测试错误" }
|
||||
}
|
||||
|
||||
private func area(id: Int) -> OperatingAreaItem {
|
||||
OperatingAreaItem(
|
||||
id: id,
|
||||
name: "区域\(id)",
|
||||
businessMapArea: .array([
|
||||
.array([
|
||||
.array([.number(118.781), .number(32.041)]),
|
||||
.array([.number(118.782), .number(32.041)]),
|
||||
.array([.number(118.782), .number(32.042)])
|
||||
])
|
||||
]),
|
||||
statusText: "启用",
|
||||
typeText: "门店",
|
||||
auditStatusText: "通过"
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func accountContext(scenicId: Int? = 88, storeId: Int? = 101) -> AccountContext {
|
||||
let context = AccountContext()
|
||||
let scenics = scenicId.map { [BusinessScope(id: $0, name: "测试景区", kind: .scenic)] } ?? []
|
||||
let stores = storeId.map { [BusinessScope(id: $0, name: "测试门店", kind: .store, parentScenicId: scenicId)] } ?? []
|
||||
context.replaceScopes(scenic: scenics, stores: stores, currentScenicId: scenicId, currentStoreId: storeId)
|
||||
return context
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func permissionContext(roleName: String) -> PermissionContext {
|
||||
let context = PermissionContext()
|
||||
let role = RoleInfo(id: 1, name: roleName, permission: [PermissionItem(id: 1, name: "运营区域", uri: "operating-area")])
|
||||
context.replaceRolePermissions([RolePermissionResponse(role: role, scenic: [])], currentRoleId: 1)
|
||||
return context
|
||||
}
|
||||
|
||||
private func queryItems(from request: URLRequest) -> [String: String] {
|
||||
guard let url = request.url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
else { return [:] }
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
item.value.map { (item.name, $0) }
|
||||
})
|
||||
}
|
||||
169
suixinkan_iosTests/OrderDetailViewModelTests.swift
Normal file
169
suixinkan_iosTests/OrderDetailViewModelTests.swift
Normal file
@ -0,0 +1,169 @@
|
||||
//
|
||||
// OrderDetailViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 订单详情 ViewModel 测试,覆盖详情加载、缺失上下文和错误兜底。
|
||||
final class OrderDetailViewModelTests: XCTestCase {
|
||||
/// 测试有门店 ID 时请求详情接口并展示服务端字段。
|
||||
func testLoadRequestsDetailWhenStoreIdExists() async throws {
|
||||
let item = try firstOrder()
|
||||
let api = DetailMockOrderService()
|
||||
api.detailResponses = [try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")]
|
||||
let viewModel = OrderDetailViewModel(item: item)
|
||||
|
||||
await viewModel.load(api: api, fallbackStoreId: 66)
|
||||
|
||||
XCTAssertEqual(api.detailCalls.count, 1)
|
||||
XCTAssertEqual(api.detailCalls[0].storeId, 66)
|
||||
XCTAssertEqual(api.detailCalls[0].orderNumber, "ORDV2001")
|
||||
XCTAssertEqual(viewModel.display.projectName, "亲子跟拍")
|
||||
XCTAssertEqual(viewModel.projectInfo?.settleSpotNum, 3)
|
||||
XCTAssertEqual(viewModel.shootingList.first?.scenicSpotName, "东门")
|
||||
XCTAssertNil(viewModel.errorMessage)
|
||||
}
|
||||
|
||||
/// 测试缺少门店 ID 时不请求接口并保留列表摘要。
|
||||
func testLoadWithoutStoreIdUsesSummaryOnly() async throws {
|
||||
let item = try firstOrder()
|
||||
let api = DetailMockOrderService()
|
||||
let viewModel = OrderDetailViewModel(item: item)
|
||||
|
||||
await viewModel.load(api: api, fallbackStoreId: nil)
|
||||
|
||||
XCTAssertTrue(api.detailCalls.isEmpty)
|
||||
XCTAssertEqual(viewModel.display.orderNumber, "ORDV2001")
|
||||
XCTAssertEqual(viewModel.contextMessage, "缺少门店上下文,当前展示订单摘要。")
|
||||
}
|
||||
|
||||
/// 测试详情接口失败时保留摘要并暴露错误信息。
|
||||
func testLoadFailureKeepsSummaryAndExposesError() async throws {
|
||||
let item = try firstOrder()
|
||||
let api = DetailMockOrderService()
|
||||
api.detailError = APIError.httpStatus(500, "server error")
|
||||
let viewModel = OrderDetailViewModel(item: item)
|
||||
|
||||
await viewModel.load(api: api, fallbackStoreId: 66)
|
||||
|
||||
XCTAssertEqual(api.detailCalls.count, 1)
|
||||
XCTAssertEqual(viewModel.display.orderNumber, "ORDV2001")
|
||||
XCTAssertEqual(viewModel.contextMessage, "详情加载失败,当前展示订单摘要。")
|
||||
XCTAssertNotNil(viewModel.errorMessage)
|
||||
}
|
||||
|
||||
/// 测试详情模型兼容后端数字和字符串混合字段。
|
||||
func testDetailResponseDecodesMixedNumericAndStringFields() throws {
|
||||
let detail = try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")
|
||||
|
||||
XCTAssertEqual(detail.orderType, 19)
|
||||
XCTAssertEqual(detail.actualRefundAmount, "0")
|
||||
XCTAssertEqual(detail.userId, 7001)
|
||||
XCTAssertEqual(detail.multiTravel?.projectInfo?.singleSpotVideoNum, 1)
|
||||
XCTAssertEqual(detail.multiTravel?.shootingList.first?.startAvg, 4.8)
|
||||
}
|
||||
|
||||
/// 返回订单列表 fixture 中的第一条订单。
|
||||
private func firstOrder() throws -> OrderEntity {
|
||||
let payload = try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")
|
||||
return try XCTUnwrap(payload.list.first)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 订单详情服务测试替身,专门用于详情 ViewModel 测试。
|
||||
private final class DetailMockOrderService: OrderServing {
|
||||
var detailResponses: [StoreOrderDetailResponse] = []
|
||||
var detailError: Error?
|
||||
private(set) var detailCalls: [(storeId: Int, orderNumber: String)] = []
|
||||
|
||||
/// 订单列表接口在详情测试中不会被调用。
|
||||
func orderList(
|
||||
scenicId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderStatus: Int?,
|
||||
userPhone: String?,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
isRefined: Int?,
|
||||
isScenicAdmin: Bool
|
||||
) async throws -> ListPayload<OrderEntity> {
|
||||
XCTFail("orderList should not be called")
|
||||
return ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 核销列表接口在详情测试中不会被调用。
|
||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
XCTFail("writeOffList should not be called")
|
||||
return ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 核销接口在详情测试中不会被调用。
|
||||
func writeOff(orderNumber: String) async throws {
|
||||
XCTFail("writeOff should not be called")
|
||||
}
|
||||
|
||||
/// 返回预置的订单详情响应。
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
||||
detailCalls.append((storeId: storeId, orderNumber: orderNumber))
|
||||
if let detailError {
|
||||
throw detailError
|
||||
}
|
||||
return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 押金订单列表接口在详情测试中不会被调用。
|
||||
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
|
||||
XCTFail("depositOrderList should not be called")
|
||||
return ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 押金核销接口在详情测试中不会被调用。
|
||||
func depositOrderWriteOff(orderNumber: String) async throws {
|
||||
XCTFail("depositOrderWriteOff should not be called")
|
||||
}
|
||||
|
||||
/// 押金退款接口在详情测试中不会被调用。
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {
|
||||
XCTFail("depositOrderRefund should not be called")
|
||||
}
|
||||
|
||||
/// 拍摄信息接口在详情测试中不会被调用。
|
||||
func storeOrderShootingDetail(
|
||||
storeId: Int,
|
||||
orderNumber: String,
|
||||
scenicSpotId: Int,
|
||||
photogUid: Int
|
||||
) async throws -> StoreOrderShootingDetailResponse {
|
||||
XCTFail("storeOrderShootingDetail should not be called")
|
||||
return try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success")
|
||||
}
|
||||
|
||||
/// 普通退款接口在详情测试中不会被调用。
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {
|
||||
XCTFail("orderRefund should not be called")
|
||||
}
|
||||
|
||||
/// 历史拍摄接口在详情测试中不会被调用。
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
XCTFail("multiTravelShootHistory should not be called")
|
||||
return try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")
|
||||
}
|
||||
|
||||
/// 已核销打卡点接口在详情测试中不会被调用。
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] {
|
||||
XCTFail("multiTravelVerifiedScenicSpotList should not be called")
|
||||
return []
|
||||
}
|
||||
|
||||
/// 多点旅拍素材提交接口在详情测试中不会被调用。
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {
|
||||
XCTFail("multiTravelUploadMaterial should not be called")
|
||||
}
|
||||
}
|
||||
596
suixinkan_iosTests/OrderLongTailTests.swift
Normal file
596
suixinkan_iosTests/OrderLongTailTests.swift
Normal file
@ -0,0 +1,596 @@
|
||||
//
|
||||
// OrderLongTailTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 订单长尾测试,覆盖押金订单、退款和历史拍摄的 API 与 ViewModel 规则。
|
||||
final class OrderLongTailTests: XCTestCase {
|
||||
/// 测试押金订单接口使用旧工程路径、分页兜底和请求体。
|
||||
func testDepositOrderAPIRequestsAndDecodesLossyFields() async throws {
|
||||
let session = LongTailRecordingURLSession(responses: [
|
||||
try TestFixture.data(named: "deposit_order_list_success"),
|
||||
try TestFixture.data(named: "empty_success"),
|
||||
try TestFixture.data(named: "empty_success")
|
||||
])
|
||||
let api = OrdersAPI(client: APIClient(session: session))
|
||||
|
||||
let list = try await api.depositOrderList(scenicId: 88, page: 0, pageSize: 0)
|
||||
try await api.depositOrderWriteOff(orderNumber: "DEP001")
|
||||
try await api.depositOrderRefund(orderNumber: "DEP002", refundReason: "游客取消行程")
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/order/deposit-list",
|
||||
"/api/yf-handset-app/photog/order/deposit-writeoff",
|
||||
"/api/yf-handset-app/photog/order/deposit-refund"
|
||||
])
|
||||
let query = longTailQueryItems(from: try XCTUnwrap(session.requests.first))
|
||||
XCTAssertEqual(query["scenic_id"], "88")
|
||||
XCTAssertEqual(query["page"], "1")
|
||||
XCTAssertEqual(query["page_size"], "1")
|
||||
XCTAssertEqual(list.total, 2)
|
||||
XCTAssertEqual(list.list[1].id, 4102)
|
||||
XCTAssertEqual(list.list[1].userPhone, "13900000000")
|
||||
|
||||
let writeOffBody = try longTailBodyObject(from: session.requests[1])
|
||||
XCTAssertEqual(writeOffBody["order_number"] as? String, "DEP001")
|
||||
let refundBody = try longTailBodyObject(from: session.requests[2])
|
||||
XCTAssertEqual(refundBody["order_number"] as? String, "DEP002")
|
||||
XCTAssertEqual(refundBody["refund_reason"] as? String, "游客取消行程")
|
||||
}
|
||||
|
||||
/// 测试拍摄信息、普通退款和历史拍摄接口 path、query、body 与宽松解码。
|
||||
func testShootingRefundAndHistoryAPIRequestsAndDecodes() async throws {
|
||||
let session = LongTailRecordingURLSession(responses: [
|
||||
try TestFixture.data(named: "store_order_shooting_detail_success"),
|
||||
try TestFixture.data(named: "multi_travel_shoot_history_success"),
|
||||
try TestFixture.data(named: "empty_success")
|
||||
])
|
||||
let api = OrdersAPI(client: APIClient(session: session))
|
||||
|
||||
let shooting = try await api.storeOrderShootingDetail(
|
||||
storeId: 66,
|
||||
orderNumber: "DEP001",
|
||||
scenicSpotId: 901,
|
||||
photogUid: 42
|
||||
)
|
||||
let history = try await api.multiTravelShootHistory(orderNumber: "MT001")
|
||||
try await api.orderRefund(
|
||||
orderNumber: "STORE001",
|
||||
refundType: .partial,
|
||||
refundAmount: "10.50",
|
||||
refundReason: "游客取消部分项目"
|
||||
)
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/app/store/order/shooting-detail",
|
||||
"/api/yf-handset-app/photog/order/multi-travel/shoot-history",
|
||||
"/api/yf-handset-app/photog/order/refund"
|
||||
])
|
||||
let shootingQuery = longTailQueryItems(from: session.requests[0])
|
||||
XCTAssertEqual(shootingQuery["store_id"], "66")
|
||||
XCTAssertEqual(shootingQuery["order_number"], "DEP001")
|
||||
XCTAssertEqual(shootingQuery["scenic_spot_id"], "901")
|
||||
XCTAssertEqual(shootingQuery["photog_uid"], "42")
|
||||
XCTAssertEqual(shooting.orderComment?.starShooting, 5)
|
||||
XCTAssertEqual(shooting.materialList.first?.fileSize, 10_485_760)
|
||||
XCTAssertTrue(shooting.materialList.first?.isVideo == true)
|
||||
|
||||
XCTAssertEqual(longTailQueryItems(from: session.requests[1])["order_number"], "MT001")
|
||||
XCTAssertEqual(history.projectType, 19)
|
||||
XCTAssertEqual(history.photogSpotList.first?.photographerDisplayName, "小景")
|
||||
XCTAssertEqual(history.photogSpotList.first?.files.count, 2)
|
||||
|
||||
let refundBody = try longTailBodyObject(from: session.requests[2])
|
||||
XCTAssertEqual(refundBody["order_number"] as? String, "STORE001")
|
||||
XCTAssertEqual(refundBody["refund_type"] as? Int, OrderRefundMode.partial.rawValue)
|
||||
XCTAssertEqual(refundBody["refund_amount"] as? String, "10.50")
|
||||
XCTAssertNil(refundBody["store_id"])
|
||||
}
|
||||
|
||||
/// 测试已核销打卡点和多点旅拍素材提交接口 path、query、body。
|
||||
func testMultiTravelTaskUploadAPIRequestsAndDecodes() async throws {
|
||||
let session = LongTailRecordingURLSession(responses: [
|
||||
try TestFixture.data(named: "multi_travel_verified_spots_success"),
|
||||
try TestFixture.data(named: "empty_success")
|
||||
])
|
||||
let api = OrdersAPI(client: APIClient(session: session))
|
||||
|
||||
let spots = try await api.multiTravelVerifiedScenicSpotList(orderNumber: "MT001")
|
||||
try await api.multiTravelUploadMaterial(
|
||||
MultiTravelUploadMaterialRequest(
|
||||
orderNumber: "MT001",
|
||||
scenicSpotId: 901,
|
||||
cloudFile: [MultiTravelCloudFileItem(fileId: 7001)],
|
||||
uploadFile: [MultiTravelUploadFileItem(fileName: "spot.jpg", fileUrl: "https://cdn.example.com/spot.jpg")]
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list",
|
||||
"/api/yf-handset-app/photog/order/multi-travel/upload-material"
|
||||
])
|
||||
XCTAssertEqual(longTailQueryItems(from: session.requests[0])["order_number"], "MT001")
|
||||
XCTAssertEqual(spots.map(\.id), [901, 902])
|
||||
XCTAssertEqual(spots.last?.name, "观景台")
|
||||
let body = try longTailBodyObject(from: session.requests[1])
|
||||
XCTAssertEqual(body["order_number"] as? String, "MT001")
|
||||
XCTAssertEqual(body["scenic_spot_id"] as? Int, 901)
|
||||
XCTAssertEqual((body["cloud_file"] as? [[String: Any]])?.first?["file_id"] as? Int, 7001)
|
||||
XCTAssertEqual((body["upload_file"] as? [[String: Any]])?.first?["file_url"] as? String, "https://cdn.example.com/spot.jpg")
|
||||
}
|
||||
|
||||
/// 测试押金订单无景区时清空旧数据且不请求接口。
|
||||
func testDepositListClearsWithoutScenicAndStopsAtLastPage() async throws {
|
||||
let service = LongTailMockOrderService()
|
||||
service.depositListResponses = [try Self.depositPayload()]
|
||||
let viewModel = DepositOrderListViewModel()
|
||||
|
||||
await viewModel.reload(api: service, scenicId: 88, reset: true)
|
||||
await viewModel.reload(api: service, scenicId: 88, reset: false)
|
||||
await viewModel.reload(api: service, scenicId: nil, reset: true)
|
||||
|
||||
XCTAssertTrue(viewModel.orders.isEmpty)
|
||||
XCTAssertEqual(viewModel.total, 0)
|
||||
XCTAssertEqual(viewModel.page, 1)
|
||||
XCTAssertEqual(service.depositListCalls, [
|
||||
.init(scenicId: 88, page: 1, pageSize: 10)
|
||||
])
|
||||
}
|
||||
|
||||
/// 测试押金核销和押金退款成功后刷新列表,失败时保留旧列表并清理操作状态。
|
||||
func testDepositWriteOffAndRefundRefreshOrKeepListOnFailure() async throws {
|
||||
let service = LongTailMockOrderService()
|
||||
service.depositListResponses = [
|
||||
try Self.depositPayload(),
|
||||
try Self.depositPayload(),
|
||||
try Self.depositPayload()
|
||||
]
|
||||
let viewModel = DepositOrderListViewModel()
|
||||
await viewModel.reload(api: service, scenicId: 88, reset: true)
|
||||
|
||||
let writeOffSuccess = await viewModel.writeOff(api: service, scenicId: 88, orderNumber: "DEP001")
|
||||
let emptyReason = await viewModel.refund(api: service, scenicId: 88, orderNumber: "DEP001", reason: " ")
|
||||
service.depositRefundError = APIError.serverCode(500, "退款失败")
|
||||
let refundFailure = await viewModel.refund(api: service, scenicId: 88, orderNumber: "DEP001", reason: "游客取消")
|
||||
|
||||
XCTAssertTrue(writeOffSuccess)
|
||||
XCTAssertFalse(emptyReason)
|
||||
XCTAssertFalse(refundFailure)
|
||||
XCTAssertEqual(service.depositWriteOffNumbers, ["DEP001"])
|
||||
XCTAssertEqual(service.depositRefundCalls, [.init(orderNumber: "DEP001", reason: "游客取消")])
|
||||
XCTAssertEqual(service.depositListCalls.count, 2)
|
||||
XCTAssertEqual(viewModel.orders.count, 2)
|
||||
XCTAssertNil(viewModel.operatingOrderNumber)
|
||||
XCTAssertEqual(viewModel.errorMessage, "退款失败")
|
||||
}
|
||||
|
||||
/// 测试押金详情和拍摄信息缺少门店时不请求接口。
|
||||
func testDepositDetailAndShootingSkipWithoutStore() async {
|
||||
let service = LongTailMockOrderService()
|
||||
let detailViewModel = DepositOrderDetailViewModel()
|
||||
let shootingViewModel = DepositOrderShootingInfoViewModel()
|
||||
|
||||
await detailViewModel.load(api: service, storeId: nil, orderNumber: "DEP001")
|
||||
await shootingViewModel.load(api: service, storeId: nil, orderNumber: "DEP001", scenicSpotId: 901, photogUid: 42)
|
||||
|
||||
XCTAssertTrue(service.storeDetailCalls.isEmpty)
|
||||
XCTAssertTrue(service.storeShootingCalls.isEmpty)
|
||||
XCTAssertEqual(detailViewModel.errorMessage, "当前账号缺少门店信息")
|
||||
XCTAssertEqual(shootingViewModel.errorMessage, "当前账号缺少门店信息")
|
||||
}
|
||||
|
||||
/// 测试普通退款入口规则、金额校验、重复提交保护和成功提交参数。
|
||||
func testOrderRefundValidationAndSubmit() async throws {
|
||||
let service = LongTailMockOrderService()
|
||||
let viewModel = OrderRefundViewModel()
|
||||
let refundable = try Self.orderEntity(orderStatus: 18, orderType: 4, actualPayAmount: "99.50")
|
||||
let unavailable = try Self.orderEntity(orderStatus: 50, orderType: 4, actualPayAmount: "99.50")
|
||||
|
||||
XCTAssertTrue(viewModel.canRefund(refundable))
|
||||
XCTAssertFalse(viewModel.canRefund(unavailable))
|
||||
viewModel.begin(item: refundable)
|
||||
XCTAssertEqual(viewModel.amount, "99.50")
|
||||
let missingReasonSuccess = await viewModel.submit(api: service, item: refundable)
|
||||
XCTAssertFalse(missingReasonSuccess)
|
||||
XCTAssertEqual(viewModel.errorMessage, "请输入退款原因")
|
||||
|
||||
viewModel.reason = "部分退款"
|
||||
viewModel.mode = .partial
|
||||
viewModel.amount = "12.345"
|
||||
let invalidAmountSuccess = await viewModel.submit(api: service, item: refundable)
|
||||
XCTAssertFalse(invalidAmountSuccess)
|
||||
XCTAssertEqual(viewModel.errorMessage, "请输入有效的退款金额")
|
||||
|
||||
viewModel.amount = "120.00"
|
||||
let overLimitSuccess = await viewModel.submit(api: service, item: refundable)
|
||||
XCTAssertFalse(overLimitSuccess)
|
||||
XCTAssertEqual(viewModel.errorMessage, "退款金额不能大于可退金额")
|
||||
|
||||
viewModel.amount = "12.3"
|
||||
let submitSuccess = await viewModel.submit(api: service, item: refundable)
|
||||
XCTAssertTrue(submitSuccess)
|
||||
XCTAssertEqual(service.orderRefundCalls, [
|
||||
.init(orderNumber: "STORE001", mode: .partial, amount: "12.30", reason: "部分退款")
|
||||
])
|
||||
XCTAssertNil(viewModel.errorMessage)
|
||||
XCTAssertFalse(viewModel.submitting)
|
||||
}
|
||||
|
||||
/// 测试历史拍摄空订单不请求,成功后展示项目和媒体,失败后清空旧数据。
|
||||
func testHistoricalShootingLoadSuccessAndFailure() async throws {
|
||||
let service = LongTailMockOrderService()
|
||||
service.historyResponses = [try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")]
|
||||
let viewModel = HistoricalShootingInfoViewModel()
|
||||
|
||||
await viewModel.load(api: service, orderNumber: " ")
|
||||
XCTAssertTrue(service.historyCalls.isEmpty)
|
||||
XCTAssertEqual(viewModel.errorMessage, "订单号不能为空")
|
||||
|
||||
await viewModel.load(api: service, orderNumber: "MT001")
|
||||
XCTAssertEqual(viewModel.projectName, "多点旅拍")
|
||||
XCTAssertEqual(viewModel.spots.first?.scenicSpotName, "湖心亭")
|
||||
XCTAssertEqual(viewModel.spots.first?.files.count, 2)
|
||||
|
||||
service.historyError = APIError.serverCode(500, "历史拍摄失败")
|
||||
await viewModel.load(api: service, orderNumber: "MT001")
|
||||
XCTAssertTrue(viewModel.spots.isEmpty)
|
||||
XCTAssertEqual(viewModel.errorMessage, "历史拍摄失败")
|
||||
}
|
||||
|
||||
/// 测试任务上传空订单不请求,加载成功默认选中首个打卡点,加载失败清空状态。
|
||||
func testMultiTravelTaskUploadLoadsAndRepairsSpotSelection() async throws {
|
||||
let service = LongTailMockOrderService()
|
||||
service.verifiedSpotResponses = [
|
||||
try Self.verifiedSpots(),
|
||||
[try Self.verifiedSpot(id: 902, name: "观景台")]
|
||||
]
|
||||
let viewModel = MultiTravelTaskUploadViewModel()
|
||||
|
||||
await viewModel.loadSpots(api: service)
|
||||
XCTAssertTrue(service.verifiedSpotCalls.isEmpty)
|
||||
|
||||
viewModel.orderNumber = "MT001"
|
||||
await viewModel.loadSpots(api: service)
|
||||
XCTAssertEqual(viewModel.selectedSpotId, 901)
|
||||
viewModel.selectSpot(id: 901)
|
||||
await viewModel.loadSpots(api: service)
|
||||
XCTAssertEqual(viewModel.selectedSpotId, 902)
|
||||
|
||||
service.verifiedSpotError = APIError.serverCode(500, "打卡点失败")
|
||||
await viewModel.loadSpots(api: service)
|
||||
XCTAssertTrue(viewModel.spots.isEmpty)
|
||||
XCTAssertNil(viewModel.selectedSpotId)
|
||||
XCTAssertEqual(viewModel.errorMessage, "打卡点失败")
|
||||
}
|
||||
|
||||
/// 测试任务上传提交校验和仅云盘文件直接提交。
|
||||
func testMultiTravelTaskUploadValidatesAndSubmitsCloudFiles() async throws {
|
||||
let service = LongTailMockOrderService()
|
||||
service.verifiedSpotResponses = [try Self.verifiedSpots()]
|
||||
let uploader = LongTailMockUploader()
|
||||
let viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001")
|
||||
|
||||
let missingScenicSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: nil)
|
||||
XCTAssertFalse(missingScenicSuccess)
|
||||
XCTAssertEqual(viewModel.errorMessage, "请先选择景区")
|
||||
|
||||
await viewModel.loadSpots(api: service)
|
||||
let missingFileSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88)
|
||||
XCTAssertFalse(missingFileSuccess)
|
||||
XCTAssertEqual(viewModel.errorMessage, "请至少选择一个素材文件")
|
||||
|
||||
viewModel.mergeCloudFiles([TaskCloudSelectionItem(id: 7001, fileName: "cloud.jpg", fileType: 2, remark: "")])
|
||||
let submitSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88)
|
||||
|
||||
XCTAssertTrue(submitSuccess)
|
||||
XCTAssertTrue(viewModel.didSubmitSuccessfully)
|
||||
XCTAssertEqual(service.uploadMaterialRequests, [
|
||||
MultiTravelUploadMaterialRequest(
|
||||
orderNumber: "MT001",
|
||||
scenicSpotId: 901,
|
||||
cloudFile: [MultiTravelCloudFileItem(fileId: 7001)],
|
||||
uploadFile: []
|
||||
)
|
||||
])
|
||||
XCTAssertTrue(uploader.uploadTaskFiles.isEmpty)
|
||||
}
|
||||
|
||||
/// 测试任务上传本地文件先上传 OSS,上传失败不提交业务接口。
|
||||
func testMultiTravelTaskUploadUploadsLocalFilesBeforeSubmit() async throws {
|
||||
let service = LongTailMockOrderService()
|
||||
service.verifiedSpotResponses = [try Self.verifiedSpots()]
|
||||
let uploader = LongTailMockUploader()
|
||||
uploader.uploadTaskFileURLs = ["https://cdn.example.com/local.jpg"]
|
||||
let viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001")
|
||||
await viewModel.loadSpots(api: service)
|
||||
viewModel.addLocalFile(data: Data([1, 2, 3]), fileName: "local.jpg")
|
||||
|
||||
let success = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(uploader.uploadTaskFiles.map(\.fileName), ["local.jpg"])
|
||||
XCTAssertEqual(service.uploadMaterialRequests.first?.uploadFile, [
|
||||
MultiTravelUploadFileItem(fileName: "local.jpg", fileUrl: "https://cdn.example.com/local.jpg")
|
||||
])
|
||||
|
||||
let failingService = LongTailMockOrderService()
|
||||
failingService.verifiedSpotResponses = [try Self.verifiedSpots()]
|
||||
let failingUploader = LongTailMockUploader()
|
||||
failingUploader.uploadError = APIError.serverCode(500, "上传失败")
|
||||
let failingViewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001")
|
||||
await failingViewModel.loadSpots(api: failingService)
|
||||
failingViewModel.addLocalFile(data: Data([1]), fileName: "bad.mp4")
|
||||
|
||||
let failure = await failingViewModel.submit(api: failingService, uploadService: failingUploader, scenicId: 88)
|
||||
|
||||
XCTAssertFalse(failure)
|
||||
XCTAssertTrue(failingService.uploadMaterialRequests.isEmpty)
|
||||
XCTAssertEqual(failingViewModel.errorMessage, "上传失败")
|
||||
}
|
||||
|
||||
/// 读取押金订单列表 fixture。
|
||||
private static func depositPayload() throws -> ListPayload<DepositOrderListItem> {
|
||||
try TestFixture.payload(ListPayload<DepositOrderListItem>.self, named: "deposit_order_list_success")
|
||||
}
|
||||
|
||||
/// 构建最小普通订单实体,用于退款规则测试。
|
||||
private static func orderEntity(orderStatus: Int, orderType: Int, actualPayAmount: String) throws -> OrderEntity {
|
||||
let json = """
|
||||
{
|
||||
"order_number": "STORE001",
|
||||
"order_status": "\(orderStatus)",
|
||||
"order_type": "\(orderType)",
|
||||
"actual_pay_amount": "\(actualPayAmount)",
|
||||
"actual_refund_amount": "0",
|
||||
"refund_amount": "0",
|
||||
"order_type_label": "旅拍订单",
|
||||
"order_status_name": "待退款"
|
||||
}
|
||||
"""
|
||||
return try JSONDecoder().decode(OrderEntity.self, from: Data(json.utf8))
|
||||
}
|
||||
|
||||
/// 读取已核销打卡点 fixture。
|
||||
private static func verifiedSpots() throws -> [MultiTravelVerifiedScenicSpotItem] {
|
||||
try TestFixture.payload([MultiTravelVerifiedScenicSpotItem].self, named: "multi_travel_verified_spots_success")
|
||||
}
|
||||
|
||||
/// 构建已核销打卡点测试实体。
|
||||
private static func verifiedSpot(id: Int, name: String) throws -> MultiTravelVerifiedScenicSpotItem {
|
||||
let json = #"{"id":"\#(id)","name":"\#(name)"}"#
|
||||
return try JSONDecoder().decode(MultiTravelVerifiedScenicSpotItem.self, from: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
/// 长尾订单 URLSession 测试替身,按调用顺序返回响应并记录请求。
|
||||
private final class LongTailRecordingURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 返回队列中的下一个响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.isEmpty ? Data(#"{"code":100000,"msg":"success"}"#.utf8) : responses.removeFirst()
|
||||
return (
|
||||
data,
|
||||
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 长尾订单服务测试替身,记录押金、退款和历史拍摄调用。
|
||||
private final class LongTailMockOrderService: OrderServing {
|
||||
struct DepositListCall: Equatable {
|
||||
let scenicId: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
}
|
||||
|
||||
struct DepositRefundCall: Equatable {
|
||||
let orderNumber: String
|
||||
let reason: String
|
||||
}
|
||||
|
||||
struct OrderRefundCall: Equatable {
|
||||
let orderNumber: String
|
||||
let mode: OrderRefundMode
|
||||
let amount: String
|
||||
let reason: String
|
||||
}
|
||||
|
||||
var depositListResponses: [ListPayload<DepositOrderListItem>] = []
|
||||
var detailResponses: [StoreOrderDetailResponse] = []
|
||||
var shootingResponses: [StoreOrderShootingDetailResponse] = []
|
||||
var historyResponses: [MultiTravelShootHistoryResponse] = []
|
||||
var verifiedSpotResponses: [[MultiTravelVerifiedScenicSpotItem]] = []
|
||||
var depositWriteOffError: Error?
|
||||
var depositRefundError: Error?
|
||||
var orderRefundError: Error?
|
||||
var historyError: Error?
|
||||
var verifiedSpotError: Error?
|
||||
var uploadMaterialError: Error?
|
||||
|
||||
private(set) var depositListCalls: [DepositListCall] = []
|
||||
private(set) var depositWriteOffNumbers: [String] = []
|
||||
private(set) var depositRefundCalls: [DepositRefundCall] = []
|
||||
private(set) var orderRefundCalls: [OrderRefundCall] = []
|
||||
private(set) var storeDetailCalls: [(storeId: Int, orderNumber: String)] = []
|
||||
private(set) var storeShootingCalls: [(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int)] = []
|
||||
private(set) var historyCalls: [String] = []
|
||||
private(set) var verifiedSpotCalls: [String] = []
|
||||
private(set) var uploadMaterialRequests: [MultiTravelUploadMaterialRequest] = []
|
||||
|
||||
/// 基础订单列表接口在长尾测试中不使用。
|
||||
func orderList(
|
||||
scenicId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderStatus: Int?,
|
||||
userPhone: String?,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
isRefined: Int?,
|
||||
isScenicAdmin: Bool
|
||||
) async throws -> ListPayload<OrderEntity> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 核销列表接口在长尾测试中不使用。
|
||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 普通核销接口在长尾测试中不使用。
|
||||
func writeOff(orderNumber: String) async throws {}
|
||||
|
||||
/// 返回门店订单详情。
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
||||
storeDetailCalls.append((storeId: storeId, orderNumber: orderNumber))
|
||||
return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 返回押金订单列表。
|
||||
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
|
||||
depositListCalls.append(.init(scenicId: scenicId, page: page, pageSize: pageSize))
|
||||
return depositListResponses.isEmpty ? ListPayload(total: 0, list: []) : depositListResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 记录押金核销订单号。
|
||||
func depositOrderWriteOff(orderNumber: String) async throws {
|
||||
depositWriteOffNumbers.append(orderNumber)
|
||||
if let depositWriteOffError {
|
||||
throw depositWriteOffError
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录押金退款参数。
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {
|
||||
depositRefundCalls.append(.init(orderNumber: orderNumber, reason: refundReason))
|
||||
if let depositRefundError {
|
||||
throw depositRefundError
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回拍摄信息详情。
|
||||
func storeOrderShootingDetail(
|
||||
storeId: Int,
|
||||
orderNumber: String,
|
||||
scenicSpotId: Int,
|
||||
photogUid: Int
|
||||
) async throws -> StoreOrderShootingDetailResponse {
|
||||
storeShootingCalls.append((storeId: storeId, orderNumber: orderNumber, scenicSpotId: scenicSpotId, photogUid: photogUid))
|
||||
return shootingResponses.isEmpty ? try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success") : shootingResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 记录普通退款参数。
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {
|
||||
orderRefundCalls.append(.init(orderNumber: orderNumber, mode: refundType, amount: refundAmount, reason: refundReason))
|
||||
if let orderRefundError {
|
||||
throw orderRefundError
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回历史拍摄信息。
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
historyCalls.append(orderNumber)
|
||||
if let historyError {
|
||||
throw historyError
|
||||
}
|
||||
return historyResponses.isEmpty ? try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success") : historyResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 返回已核销打卡点列表。
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] {
|
||||
verifiedSpotCalls.append(orderNumber)
|
||||
if let verifiedSpotError {
|
||||
throw verifiedSpotError
|
||||
}
|
||||
return verifiedSpotResponses.isEmpty ? [] : verifiedSpotResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 记录多点旅拍素材提交请求。
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {
|
||||
uploadMaterialRequests.append(request)
|
||||
if let uploadMaterialError {
|
||||
throw uploadMaterialError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 长尾订单 OSS 上传测试替身,只记录任务附件上传。
|
||||
private final class LongTailMockUploader: OSSUploadServing {
|
||||
struct UploadTaskFileCall: Equatable {
|
||||
let fileName: String
|
||||
let fileType: Int
|
||||
let scenicId: Int
|
||||
let dataSize: Int
|
||||
}
|
||||
|
||||
var uploadTaskFileURLs: [String] = []
|
||||
var uploadError: Error?
|
||||
private(set) var uploadTaskFiles: [UploadTaskFileCall] = []
|
||||
|
||||
/// 返回预设任务附件 URL。
|
||||
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
uploadTaskFiles.append(.init(fileName: fileName, fileType: fileType, scenicId: scenicId, dataSize: data.count))
|
||||
if let uploadError {
|
||||
throw uploadError
|
||||
}
|
||||
onProgress(100)
|
||||
return uploadTaskFileURLs.isEmpty ? "https://cdn.example.com/\(fileName)" : uploadTaskFileURLs.removeFirst()
|
||||
}
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
}
|
||||
|
||||
/// 提取请求 query 方便断言。
|
||||
private func longTailQueryItems(from request: URLRequest) -> [String: String] {
|
||||
guard let url = request.url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
item.value.map { (item.name, $0) }
|
||||
})
|
||||
}
|
||||
|
||||
/// 将 JSON body 解成字典方便断言。
|
||||
private func longTailBodyObject(from request: URLRequest) throws -> [String: Any] {
|
||||
let data = try XCTUnwrap(request.httpBody)
|
||||
let object = try JSONSerialization.jsonObject(with: data)
|
||||
return try XCTUnwrap(object as? [String: Any])
|
||||
}
|
||||
38
suixinkan_iosTests/OrderNumberParserTests.swift
Normal file
38
suixinkan_iosTests/OrderNumberParserTests.swift
Normal file
@ -0,0 +1,38 @@
|
||||
//
|
||||
// OrderNumberParserTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
/// 订单号解析器测试,覆盖扫码内容中的常见订单号格式。
|
||||
final class OrderNumberParserTests: XCTestCase {
|
||||
/// 测试从 URL query 中解析 order_number。
|
||||
func testParseOrderNumberFromURLQuery() {
|
||||
XCTAssertEqual(
|
||||
OrderNumberParser.parse("https://example.com/check?foo=1&order_number=ORD123456"),
|
||||
"ORD123456"
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试从 key-value 文本中解析订单号。
|
||||
func testParseOrderNumberFromKeyValueText() {
|
||||
XCTAssertEqual(OrderNumberParser.parse("order_number=ORD123456"), "ORD123456")
|
||||
XCTAssertEqual(OrderNumberParser.parse("order-number:ORD654321"), "ORD654321")
|
||||
}
|
||||
|
||||
/// 测试从纯文本中解析订单号。
|
||||
func testParsePlainOrderNumberText() {
|
||||
XCTAssertEqual(OrderNumberParser.parse(" VERIFY001 "), "VERIFY001")
|
||||
}
|
||||
|
||||
/// 测试无效内容不会返回订单号。
|
||||
func testParseInvalidTextReturnsNil() {
|
||||
XCTAssertNil(OrderNumberParser.parse(""))
|
||||
XCTAssertNil(OrderNumberParser.parse("abc def"))
|
||||
XCTAssertNil(OrderNumberParser.parse("12345"))
|
||||
}
|
||||
}
|
||||
392
suixinkan_iosTests/OrdersViewModelTests.swift
Normal file
392
suixinkan_iosTests/OrdersViewModelTests.swift
Normal file
@ -0,0 +1,392 @@
|
||||
//
|
||||
// OrdersViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 订单 ViewModel 测试,覆盖列表分页、筛选条件和手动核销状态。
|
||||
final class OrdersViewModelTests: XCTestCase {
|
||||
/// 测试无景区时清空订单管理旧数据且不请求接口。
|
||||
func testStoreOrdersClearStaleDataWhenScenicIsMissing() async throws {
|
||||
let api = MockOrderService()
|
||||
api.storeResponses = [try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
|
||||
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil)
|
||||
XCTAssertFalse(viewModel.storeOrders.isEmpty)
|
||||
|
||||
try await viewModel.reloadStoreOrders(api: api, scenicId: nil, roleId: nil)
|
||||
|
||||
XCTAssertTrue(viewModel.storeOrders.isEmpty)
|
||||
XCTAssertEqual(viewModel.storeTotal, 0)
|
||||
XCTAssertEqual(viewModel.storePage, 1)
|
||||
XCTAssertFalse(viewModel.storeHasMore)
|
||||
XCTAssertEqual(api.orderListCalls.count, 1)
|
||||
}
|
||||
|
||||
/// 测试订单管理最后一页不会继续请求。
|
||||
func testStoreOrdersDoNotRequestMoreAfterLastPage() async throws {
|
||||
let api = MockOrderService()
|
||||
api.storeResponses = [try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
|
||||
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil)
|
||||
try await viewModel.loadMoreStoreOrders(api: api, scenicId: 88, roleId: nil)
|
||||
|
||||
XCTAssertEqual(viewModel.storeOrders.count, 2)
|
||||
XCTAssertEqual(viewModel.storeTotal, 2)
|
||||
XCTAssertEqual(viewModel.storePage, 1)
|
||||
XCTAssertFalse(viewModel.storeHasMore)
|
||||
XCTAssertEqual(api.orderListCalls.count, 1)
|
||||
}
|
||||
|
||||
/// 测试订单管理筛选参数会传入服务层并重置到第一页。
|
||||
func testStoreOrderFilterUsesExpectedQueries() async throws {
|
||||
let api = MockOrderService()
|
||||
api.storeResponses = [
|
||||
try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success"),
|
||||
try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")
|
||||
]
|
||||
let viewModel = OrdersViewModel()
|
||||
viewModel.selectedStatus = -1
|
||||
viewModel.searchPhone = " 13800000000 "
|
||||
viewModel.filterStartDate = Self.date("2026-05-01")
|
||||
viewModel.filterEndDate = Self.date("2026-05-31")
|
||||
|
||||
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil)
|
||||
|
||||
viewModel.selectedStatus = 30
|
||||
viewModel.searchPhone = " "
|
||||
viewModel.filterStartDate = nil
|
||||
viewModel.filterEndDate = nil
|
||||
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: 53)
|
||||
|
||||
XCTAssertEqual(api.orderListCalls.count, 2)
|
||||
XCTAssertEqual(api.orderListCalls[0].page, 1)
|
||||
XCTAssertEqual(api.orderListCalls[0].isRefined, 1)
|
||||
XCTAssertEqual(api.orderListCalls[0].userPhone, "13800000000")
|
||||
XCTAssertNil(api.orderListCalls[0].orderStatus)
|
||||
XCTAssertEqual(api.orderListCalls[0].startTime, "2026-05-01")
|
||||
XCTAssertEqual(api.orderListCalls[0].endTime, "2026-05-31")
|
||||
XCTAssertFalse(api.orderListCalls[0].isScenicAdmin)
|
||||
XCTAssertEqual(api.orderListCalls[1].orderStatus, 30)
|
||||
XCTAssertNil(api.orderListCalls[1].isRefined)
|
||||
XCTAssertNil(api.orderListCalls[1].userPhone)
|
||||
XCTAssertTrue(api.orderListCalls[1].isScenicAdmin)
|
||||
XCTAssertEqual(viewModel.storePage, 1)
|
||||
}
|
||||
|
||||
/// 测试无景区时清空核销订单旧数据且不请求接口。
|
||||
func testWriteOffOrdersClearStaleDataWhenScenicIsMissing() async throws {
|
||||
let api = MockOrderService()
|
||||
api.writeOffResponses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
|
||||
try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88, storeId: 66)
|
||||
XCTAssertFalse(viewModel.writeOffOrders.isEmpty)
|
||||
|
||||
try await viewModel.reloadWriteOffOrders(api: api, scenicId: nil)
|
||||
|
||||
XCTAssertTrue(viewModel.writeOffOrders.isEmpty)
|
||||
XCTAssertEqual(viewModel.writeOffTotal, 0)
|
||||
XCTAssertEqual(viewModel.writeOffPage, 1)
|
||||
XCTAssertFalse(viewModel.writeOffHasMore)
|
||||
XCTAssertEqual(api.writeOffListCalls.count, 1)
|
||||
XCTAssertEqual(api.writeOffListCalls[0].storeId, 66)
|
||||
}
|
||||
|
||||
/// 测试核销订单最后一页不会继续请求。
|
||||
func testWriteOffOrdersDoNotRequestMoreAfterLastPage() async throws {
|
||||
let api = MockOrderService()
|
||||
api.writeOffResponses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
|
||||
try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88)
|
||||
try await viewModel.loadMoreWriteOffOrders(api: api, scenicId: 88)
|
||||
|
||||
XCTAssertEqual(viewModel.writeOffOrders.count, 1)
|
||||
XCTAssertEqual(viewModel.writeOffTotal, 1)
|
||||
XCTAssertEqual(viewModel.writeOffPage, 1)
|
||||
XCTAssertFalse(viewModel.writeOffHasMore)
|
||||
XCTAssertEqual(api.writeOffListCalls.count, 1)
|
||||
}
|
||||
|
||||
/// 测试核销成功后刷新列表并重置分页状态。
|
||||
func testVerifyRefreshesWriteOffOrdersAndResetsPagingState() async throws {
|
||||
let api = MockOrderService()
|
||||
api.writeOffResponses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
|
||||
try await viewModel.verify(api: api, scenicId: 88, storeId: 66, orderNumber: "VERIFY001")
|
||||
|
||||
XCTAssertEqual(api.verifiedOrderNumbers, ["VERIFY001"])
|
||||
XCTAssertEqual(api.writeOffListCalls.count, 1)
|
||||
XCTAssertEqual(api.writeOffListCalls[0].scenicId, 88)
|
||||
XCTAssertEqual(api.writeOffListCalls[0].storeId, 66)
|
||||
XCTAssertEqual(api.writeOffListCalls[0].page, 1)
|
||||
XCTAssertEqual(viewModel.writeOffOrders.count, 1)
|
||||
XCTAssertEqual(viewModel.writeOffPage, 1)
|
||||
XCTAssertFalse(viewModel.isVerifying)
|
||||
XCTAssertNil(viewModel.currentVerifyingOrderNumber)
|
||||
}
|
||||
|
||||
/// 测试扫码命中当前核销列表时返回可高亮订单。
|
||||
func testScanResultMatchesCurrentWriteOffOrder() async throws {
|
||||
let api = MockOrderService()
|
||||
api.writeOffResponses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88)
|
||||
|
||||
let result = try XCTUnwrap(viewModel.matchedWriteOffOrder(for: "https://example.com/scan?order_number=VERIFY001"))
|
||||
|
||||
XCTAssertEqual(result.orderNumber, "VERIFY001")
|
||||
XCTAssertEqual(result.matched?.orderNumber, "VERIFY001")
|
||||
}
|
||||
|
||||
/// 测试扫码未命中当前列表时仍保留解析出的订单号。
|
||||
func testScanResultKeepsParsedOrderNumberWhenCurrentListDoesNotContainIt() async throws {
|
||||
let api = MockOrderService()
|
||||
api.writeOffResponses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88)
|
||||
|
||||
let result = try XCTUnwrap(viewModel.matchedWriteOffOrder(for: "order-number=VERIFY999"))
|
||||
|
||||
XCTAssertEqual(result.orderNumber, "VERIFY999")
|
||||
XCTAssertNil(result.matched)
|
||||
}
|
||||
|
||||
/// 测试重复核销提交会被忽略。
|
||||
func testVerifyIgnoresDuplicateSubmitWhileAlreadyVerifying() async throws {
|
||||
let api = MockOrderService()
|
||||
let viewModel = OrdersViewModel()
|
||||
viewModel.markVerifyingForTests(orderNumber: "VERIFY001")
|
||||
|
||||
try await viewModel.verify(api: api, scenicId: 88, orderNumber: "VERIFY002")
|
||||
|
||||
XCTAssertTrue(api.verifiedOrderNumbers.isEmpty)
|
||||
XCTAssertTrue(api.writeOffListCalls.isEmpty)
|
||||
XCTAssertTrue(viewModel.isVerifying)
|
||||
XCTAssertEqual(viewModel.currentVerifyingOrderNumber, "VERIFY001")
|
||||
}
|
||||
|
||||
/// 测试核销失败时清理提交状态且不刷新列表。
|
||||
func testVerifyFailureClearsSubmittingStateWithoutRefreshingList() async throws {
|
||||
let api = MockOrderService()
|
||||
api.verifyError = APIError.httpStatus(500, "server error")
|
||||
let viewModel = OrdersViewModel()
|
||||
|
||||
do {
|
||||
try await viewModel.verify(api: api, scenicId: 88, orderNumber: "VERIFY001")
|
||||
XCTFail("Expected verify to fail")
|
||||
} catch APIError.httpStatus(let statusCode, _) {
|
||||
XCTAssertEqual(statusCode, 500)
|
||||
} catch {
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
XCTAssertEqual(api.verifiedOrderNumbers, ["VERIFY001"])
|
||||
XCTAssertTrue(api.writeOffListCalls.isEmpty)
|
||||
XCTAssertFalse(viewModel.isVerifying)
|
||||
XCTAssertNil(viewModel.currentVerifyingOrderNumber)
|
||||
}
|
||||
|
||||
/// 测试订单 API 会按角色选择正确接口路径。
|
||||
func testOrdersAPIUsesExpectedEndpointForRole() async throws {
|
||||
let session = RecordingURLSession(data: try TestFixture.data(named: "order_v2_success"))
|
||||
let api = OrdersAPI(client: APIClient(session: session))
|
||||
|
||||
_ = try await api.orderList(
|
||||
scenicId: 88,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
orderStatus: 30,
|
||||
userPhone: "13800000000",
|
||||
startTime: "2026-05-01",
|
||||
endTime: "2026-05-31",
|
||||
isRefined: nil,
|
||||
isScenicAdmin: true
|
||||
)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/scenic-admin/order/list")
|
||||
let query = queryItems(from: request)
|
||||
XCTAssertEqual(query["scenic_id"], "88")
|
||||
XCTAssertEqual(query["order_status"], "30")
|
||||
XCTAssertEqual(query["user_phone"], "13800000000")
|
||||
XCTAssertEqual(query["start_time"], "2026-05-01")
|
||||
XCTAssertEqual(query["end_time"], "2026-05-31")
|
||||
}
|
||||
|
||||
private static func date(_ text: String) -> Date {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.date(from: text)!
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 订单服务测试替身,记录请求参数并返回预设响应。
|
||||
private final class MockOrderService: OrderServing {
|
||||
struct OrderListCall: Equatable {
|
||||
let scenicId: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
let orderStatus: Int?
|
||||
let userPhone: String?
|
||||
let startTime: String?
|
||||
let endTime: String?
|
||||
let isRefined: Int?
|
||||
let isScenicAdmin: Bool
|
||||
}
|
||||
|
||||
struct WriteOffListCall: Equatable {
|
||||
let scenicId: Int
|
||||
let storeId: Int?
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
}
|
||||
|
||||
var storeResponses: [ListPayload<OrderEntity>] = []
|
||||
var writeOffResponses: [ListPayload<WriteOffOrderItem>] = []
|
||||
var detailResponses: [StoreOrderDetailResponse] = []
|
||||
var verifyError: Error?
|
||||
private(set) var orderListCalls: [OrderListCall] = []
|
||||
private(set) var writeOffListCalls: [WriteOffListCall] = []
|
||||
private(set) var detailCalls: [(storeId: Int, orderNumber: String)] = []
|
||||
private(set) var verifiedOrderNumbers: [String] = []
|
||||
|
||||
/// 返回测试订单管理列表。
|
||||
func orderList(
|
||||
scenicId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderStatus: Int?,
|
||||
userPhone: String?,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
isRefined: Int?,
|
||||
isScenicAdmin: Bool
|
||||
) async throws -> ListPayload<OrderEntity> {
|
||||
orderListCalls.append(
|
||||
OrderListCall(
|
||||
scenicId: scenicId,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
orderStatus: orderStatus,
|
||||
userPhone: userPhone,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
isRefined: isRefined,
|
||||
isScenicAdmin: isScenicAdmin
|
||||
)
|
||||
)
|
||||
return storeResponses.isEmpty ? ListPayload(total: 0, list: []) : storeResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 返回测试核销订单列表。
|
||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
writeOffListCalls.append(WriteOffListCall(scenicId: scenicId, storeId: storeId, page: page, pageSize: pageSize))
|
||||
return writeOffResponses.isEmpty ? ListPayload(total: 0, list: []) : writeOffResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 记录测试核销订单号。
|
||||
func writeOff(orderNumber: String) async throws {
|
||||
verifiedOrderNumbers.append(orderNumber)
|
||||
if let verifyError {
|
||||
throw verifyError
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回测试订单详情。
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
||||
detailCalls.append((storeId: storeId, orderNumber: orderNumber))
|
||||
return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 押金订单列表接口在基础订单测试中不会被调用。
|
||||
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
|
||||
XCTFail("depositOrderList should not be called")
|
||||
return ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 押金核销接口在基础订单测试中不会被调用。
|
||||
func depositOrderWriteOff(orderNumber: String) async throws {
|
||||
XCTFail("depositOrderWriteOff should not be called")
|
||||
}
|
||||
|
||||
/// 押金退款接口在基础订单测试中不会被调用。
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {
|
||||
XCTFail("depositOrderRefund should not be called")
|
||||
}
|
||||
|
||||
/// 拍摄信息接口在基础订单测试中不会被调用。
|
||||
func storeOrderShootingDetail(
|
||||
storeId: Int,
|
||||
orderNumber: String,
|
||||
scenicSpotId: Int,
|
||||
photogUid: Int
|
||||
) async throws -> StoreOrderShootingDetailResponse {
|
||||
XCTFail("storeOrderShootingDetail should not be called")
|
||||
return try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success")
|
||||
}
|
||||
|
||||
/// 普通退款接口在基础订单测试中不会被调用。
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {
|
||||
XCTFail("orderRefund should not be called")
|
||||
}
|
||||
|
||||
/// 历史拍摄接口在基础订单测试中不会被调用。
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
XCTFail("multiTravelShootHistory should not be called")
|
||||
return try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")
|
||||
}
|
||||
|
||||
/// 已核销打卡点接口在基础订单测试中不会被调用。
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] {
|
||||
XCTFail("multiTravelVerifiedScenicSpotList should not be called")
|
||||
return []
|
||||
}
|
||||
|
||||
/// 多点旅拍素材提交接口在基础订单测试中不会被调用。
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {
|
||||
XCTFail("multiTravelUploadMaterial should not be called")
|
||||
}
|
||||
}
|
||||
|
||||
/// URLSession 测试替身,记录请求并返回固定响应。
|
||||
private final class RecordingURLSession: URLSessionProtocol {
|
||||
let data: Data
|
||||
var statusCode: Int
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
init(data: Data, statusCode: Int = 200) {
|
||||
self.data = data
|
||||
self.statusCode = statusCode
|
||||
}
|
||||
|
||||
/// 返回固定数据和 HTTP 响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
return (
|
||||
data,
|
||||
HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func queryItems(from request: URLRequest) -> [String: String] {
|
||||
guard let url = request.url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
item.value.map { (item.name, $0) }
|
||||
})
|
||||
}
|
||||
108
suixinkan_iosTests/PaymentViewModelTests.swift
Normal file
108
suixinkan_iosTests/PaymentViewModelTests.swift
Normal file
@ -0,0 +1,108 @@
|
||||
//
|
||||
// PaymentViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 收款模块测试,覆盖收款码、动态二维码、轮询和记录分组。
|
||||
final class PaymentViewModelTests: XCTestCase {
|
||||
/// 测试无景区时清空二维码且不请求接口。
|
||||
func testLoadPayCodeWithoutScenicClearsState() async {
|
||||
let api = PaymentMockAPI()
|
||||
let viewModel = PaymentCollectionViewModel()
|
||||
viewModel.staticPayUrl = "old"
|
||||
|
||||
await viewModel.loadPayCode(api: api, scenicId: nil)
|
||||
|
||||
XCTAssertTrue(viewModel.staticPayUrl.isEmpty)
|
||||
XCTAssertEqual(api.payCodeScenicIds, [])
|
||||
XCTAssertEqual(viewModel.errorMessage, "请先选择景区")
|
||||
}
|
||||
|
||||
/// 测试收款码接口加载后生成当前二维码 URL。
|
||||
func testLoadPayCodeUsesScenicIdAndAppliesStaticURL() async {
|
||||
let api = PaymentMockAPI()
|
||||
api.payCodeResponse = PayCodeResponse(staticPayUrl: "https://pay.example.com/static", dynamicPayUrl: "https://pay.example.com/dynamic")
|
||||
let viewModel = PaymentCollectionViewModel()
|
||||
|
||||
await viewModel.loadPayCode(api: api, scenicId: 9)
|
||||
|
||||
XCTAssertEqual(api.payCodeScenicIds, [9])
|
||||
XCTAssertEqual(viewModel.currentPayUrl, "https://pay.example.com/static")
|
||||
XCTAssertTrue(viewModel.hasStaticPayCode)
|
||||
}
|
||||
|
||||
/// 测试设置金额会校验金额并生成动态收款 URL。
|
||||
func testApplyDynamicAmountBuildsDynamicURL() async {
|
||||
let viewModel = PaymentCollectionViewModel()
|
||||
viewModel.dynamicPayUrl = "https://pay.example.com/dynamic?scenic_id=9"
|
||||
viewModel.amountText = "12.345"
|
||||
viewModel.remarkText = "茶水"
|
||||
|
||||
XCTAssertTrue(viewModel.applyDynamicAmount())
|
||||
XCTAssertTrue(viewModel.currentPayUrl.contains("amount=12.35"))
|
||||
XCTAssertTrue(viewModel.currentPayUrl.contains("remark="))
|
||||
XCTAssertEqual(viewModel.status, .waiting)
|
||||
}
|
||||
|
||||
/// 测试轮询命中新收款记录后进入成功态。
|
||||
func testPollingFindsNewPaymentRecord() async {
|
||||
let api = PaymentMockAPI()
|
||||
api.recordResponses = [
|
||||
PaymentCollectionRecordResponse(list: [
|
||||
PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-22", createTime: "10:00")
|
||||
]),
|
||||
PaymentCollectionRecordResponse(list: [
|
||||
PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-22", createTime: "10:00"),
|
||||
PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.35", createDate: "2026-06-22", createTime: "10:01")
|
||||
])
|
||||
]
|
||||
let viewModel = PaymentCollectionViewModel()
|
||||
viewModel.amountText = "12.35"
|
||||
await viewModel.primePaymentRecords(api: api, scenicId: 9)
|
||||
|
||||
await viewModel.pollUntilPaymentDetected(api: api, scenicId: 9, maxAttempts: 1, intervalNanoseconds: 0)
|
||||
|
||||
XCTAssertEqual(viewModel.status, .success(PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.35", createDate: "2026-06-22", createTime: "10:01")))
|
||||
}
|
||||
|
||||
/// 测试后端缺少日汇总时能从列表兜底生成分组。
|
||||
func testRecordGroupsFallbackFromList() {
|
||||
let response = PaymentCollectionRecordResponse(list: [
|
||||
PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-21", createTime: "10:00"),
|
||||
PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.00", createDate: "2026-06-21", createTime: "10:01")
|
||||
])
|
||||
|
||||
let groups = PaymentCollectionRecordViewModel.makeGroups(from: response)
|
||||
|
||||
XCTAssertEqual(groups.count, 1)
|
||||
XCTAssertEqual(groups[0].analyse.orderCount, 2)
|
||||
XCTAssertEqual(groups[0].analyse.orderAmountSum, "20")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 收款 API 测试替身。
|
||||
private final class PaymentMockAPI: PaymentServing {
|
||||
var payCodeResponse = PayCodeResponse()
|
||||
var recordResponses: [PaymentCollectionRecordResponse] = []
|
||||
private(set) var payCodeScenicIds: [Int] = []
|
||||
private(set) var recordScenicIds: [Int] = []
|
||||
|
||||
/// 记录景区 ID 并返回收款码响应。
|
||||
func payCode(scenicId: Int) async throws -> PayCodeResponse {
|
||||
payCodeScenicIds.append(scenicId)
|
||||
return payCodeResponse
|
||||
}
|
||||
|
||||
/// 记录景区 ID 并返回下一份收款记录响应。
|
||||
func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse {
|
||||
recordScenicIds.append(scenicId)
|
||||
return recordResponses.isEmpty ? PaymentCollectionRecordResponse() : recordResponses.removeFirst()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,277 @@
|
||||
//
|
||||
// PilotCertificationTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 飞手认证测试,覆盖接口编码、加载、验证码、校验和提交流程。
|
||||
final class PilotCertificationTests: XCTestCase {
|
||||
func testAPIsUseExpectedPathsAndBodies() async throws {
|
||||
let session = PilotRecordingURLSession(responses: [
|
||||
try TestFixture.data(named: "flyer_detail_success"),
|
||||
try TestFixture.data(named: "empty_success"),
|
||||
try TestFixture.data(named: "empty_success"),
|
||||
try TestFixture.data(named: "empty_success")
|
||||
])
|
||||
let api = PilotCertificationAPI(client: APIClient(session: session))
|
||||
|
||||
let detail = try await api.flyerDetail()
|
||||
XCTAssertEqual(detail.id, 801)
|
||||
XCTAssertEqual(detail.certificateNo, "UAS-2026-001")
|
||||
var request = try XCTUnwrap(session.requests.last)
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.url?.path, "/api/app/flyer/detail")
|
||||
|
||||
try await api.flyerSendCode(phone: "13800000000")
|
||||
request = try XCTUnwrap(session.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/flyer/send-code")
|
||||
XCTAssertEqual(try pilotJSONBody(from: request)["phone"] as? String, "13800000000")
|
||||
|
||||
try await api.flyerApply(applyRequest())
|
||||
request = try XCTUnwrap(session.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/flyer/apply")
|
||||
var json = try pilotJSONBody(from: request)
|
||||
XCTAssertEqual(json["name"] as? String, "航拍摄影师")
|
||||
XCTAssertEqual(json["certificate_no"] as? String, "UAS-2026-001")
|
||||
XCTAssertEqual(json["drone_sn"] as? String, "SN20260523001")
|
||||
|
||||
try await api.flyerEdit(editRequest())
|
||||
request = try XCTUnwrap(session.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/flyer/edit")
|
||||
json = try pilotJSONBody(from: request)
|
||||
XCTAssertEqual(json["id"] as? Int, 801)
|
||||
XCTAssertEqual(json["contact_phone"] as? String, "13800000001")
|
||||
}
|
||||
|
||||
func testViewModelLoadSendCodeAndCountdown() async throws {
|
||||
let api = PilotMock()
|
||||
api.detailResponses = [try TestFixture.payload(FlyerDetailResponse.self, named: "flyer_detail_success")]
|
||||
let realName = PilotRealNameMock(response: try TestFixture.payload(RealNameInfoResponse.self, named: "real_name_info_success"))
|
||||
let viewModel = PilotCertificationViewModel()
|
||||
|
||||
await viewModel.load(api: api, realNameAPI: realName)
|
||||
|
||||
XCTAssertEqual(viewModel.name, "航拍摄影师")
|
||||
XCTAssertEqual(viewModel.certType, .caac)
|
||||
XCTAssertEqual(viewModel.droneSerialNo, "SN20260523001")
|
||||
XCTAssertTrue(viewModel.isRealNameVerified)
|
||||
XCTAssertEqual(viewModel.latestAuditLog?.operatorName, "审核员")
|
||||
|
||||
let codeViewModel = PilotCertificationViewModel()
|
||||
fillValidForm(codeViewModel)
|
||||
try await codeViewModel.sendCode(api: api)
|
||||
XCTAssertEqual(api.sendCodePhones, ["13800000000"])
|
||||
XCTAssertEqual(codeViewModel.countdown, 60)
|
||||
codeViewModel.tickCountdown()
|
||||
XCTAssertEqual(codeViewModel.countdown, 59)
|
||||
|
||||
api.sendCodeError = PilotTestError.sample
|
||||
codeViewModel.phone = "13800000001"
|
||||
while codeViewModel.countdown > 0 { codeViewModel.tickCountdown() }
|
||||
await XCTAssertThrowsErrorAsync(try await codeViewModel.sendCode(api: api))
|
||||
XCTAssertEqual(codeViewModel.countdown, 0)
|
||||
}
|
||||
|
||||
func testValidationCoversRequiredFieldsPhoneImageAndDates() {
|
||||
let viewModel = PilotCertificationViewModel()
|
||||
|
||||
XCTAssertEqual(viewModel.validationMessage, "请输入飞手昵称")
|
||||
fillValidForm(viewModel)
|
||||
viewModel.phone = "123"
|
||||
XCTAssertEqual(viewModel.validationMessage, "请输入有效的手机号码")
|
||||
|
||||
fillValidForm(viewModel)
|
||||
viewModel.certImageUrl = ""
|
||||
XCTAssertEqual(viewModel.validationMessage, "请选择证件图片")
|
||||
|
||||
fillValidForm(viewModel)
|
||||
viewModel.endDate = Date(timeInterval: -86_400, since: viewModel.startDate)
|
||||
XCTAssertEqual(viewModel.validationMessage, "截至日期不能早于起始日期")
|
||||
}
|
||||
|
||||
func testSubmitUploadsAndAppliesThenClearsPendingImage() async throws {
|
||||
let api = PilotMock()
|
||||
let uploader = PilotUploadMock()
|
||||
let viewModel = PilotCertificationViewModel()
|
||||
fillValidForm(viewModel)
|
||||
viewModel.certImageUrl = ""
|
||||
viewModel.prepareCertificateImage(data: Data([1, 2, 3]), fileName: "cert.jpg")
|
||||
uploader.url = "https://cdn.example.com/pilot/cert.jpg"
|
||||
|
||||
try await viewModel.submit(api: api, uploader: uploader, scenicId: 88)
|
||||
|
||||
XCTAssertEqual(uploader.uploads.first?.fileName, "cert.jpg")
|
||||
XCTAssertEqual(uploader.uploads.first?.scenicId, 88)
|
||||
XCTAssertEqual(api.applyRequests.first?.certificateImage, "https://cdn.example.com/pilot/cert.jpg")
|
||||
XCTAssertNil(viewModel.pendingCertificateImageData)
|
||||
}
|
||||
|
||||
func testRejectedSubmitUsesEditAndFailureKeepsForm() async throws {
|
||||
let api = PilotMock()
|
||||
api.detailResponses = [
|
||||
FlyerDetailResponse(id: 801, name: "旧资料", status: 3, certificateImage: "https://cdn.example.com/old.jpg")
|
||||
]
|
||||
let viewModel = PilotCertificationViewModel()
|
||||
await viewModel.load(api: api, realNameAPI: PilotRealNameMock())
|
||||
fillValidForm(viewModel)
|
||||
|
||||
try await viewModel.submit(api: api, uploader: PilotUploadMock(), scenicId: 88)
|
||||
XCTAssertEqual(api.editRequests.first?.id, 801)
|
||||
|
||||
let failingUploader = PilotUploadMock()
|
||||
failingUploader.error = PilotTestError.sample
|
||||
viewModel.certImageUrl = ""
|
||||
viewModel.prepareCertificateImage(data: Data([9]), fileName: "new.jpg")
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.submit(api: api, uploader: failingUploader, scenicId: 88))
|
||||
XCTAssertEqual(viewModel.pendingCertificateFileName, "new.jpg")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class PilotMock: PilotCertificationServing {
|
||||
var detailResponses: [FlyerDetailResponse] = []
|
||||
var sendCodePhones: [String] = []
|
||||
var applyRequests: [FlyerApplyRequest] = []
|
||||
var editRequests: [FlyerEditRequest] = []
|
||||
var sendCodeError: Error?
|
||||
|
||||
func flyerDetail() async throws -> FlyerDetailResponse {
|
||||
detailResponses.isEmpty ? FlyerDetailResponse() : detailResponses.removeFirst()
|
||||
}
|
||||
|
||||
func flyerSendCode(phone: String) async throws {
|
||||
if let sendCodeError { throw sendCodeError }
|
||||
sendCodePhones.append(phone)
|
||||
}
|
||||
|
||||
func flyerApply(_ request: FlyerApplyRequest) async throws {
|
||||
applyRequests.append(request)
|
||||
}
|
||||
|
||||
func flyerEdit(_ request: FlyerEditRequest) async throws {
|
||||
editRequests.append(request)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PilotRealNameMock: PilotRealNameServing {
|
||||
var response = RealNameInfoResponse(realNameInfo: nil)
|
||||
|
||||
func realNameInfo() async throws -> RealNameInfoResponse {
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class PilotUploadMock: OSSUploadServing {
|
||||
var url = "https://cdn.example.com/pilot/cert.jpg"
|
||||
var error: Error?
|
||||
var uploads: [(fileName: String, scenicId: Int)] = []
|
||||
|
||||
func uploadPilotCertificateImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
if let error { throw error }
|
||||
uploads.append((fileName, scenicId))
|
||||
onProgress(100)
|
||||
return url
|
||||
}
|
||||
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
}
|
||||
|
||||
private final class PilotRecordingURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.isEmpty ? try TestFixture.data(named: "empty_success") : responses.removeFirst()
|
||||
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
|
||||
}
|
||||
}
|
||||
|
||||
private enum PilotTestError: LocalizedError {
|
||||
case sample
|
||||
|
||||
var errorDescription: String? { "测试错误" }
|
||||
}
|
||||
|
||||
private func fillValidForm(_ viewModel: PilotCertificationViewModel) {
|
||||
viewModel.name = "航拍摄影师"
|
||||
viewModel.certType = .caac
|
||||
viewModel.certNo = "UAS-2026-001"
|
||||
viewModel.certImageUrl = "https://cdn.example.com/flyer/cert.jpg"
|
||||
viewModel.startDate = Date(timeIntervalSince1970: 1_767_225_600)
|
||||
viewModel.endDate = Date(timeIntervalSince1970: 1_830_384_000)
|
||||
viewModel.droneModel = "DJI Mini"
|
||||
viewModel.droneSerialNo = "SN20260523001"
|
||||
viewModel.phone = "13800000000"
|
||||
viewModel.verifyCode = "123456"
|
||||
}
|
||||
|
||||
private func applyRequest() -> FlyerApplyRequest {
|
||||
FlyerApplyRequest(
|
||||
name: "航拍摄影师",
|
||||
realnameStatus: 1,
|
||||
certificateType: 2,
|
||||
certificateNo: "UAS-2026-001",
|
||||
certificateStartDate: "2026-01-01",
|
||||
certificateEndDate: "2028-01-01",
|
||||
certificateImage: "https://cdn.example.com/flyer/cert.jpg",
|
||||
droneModel: "DJI Mini",
|
||||
droneSn: "SN20260523001",
|
||||
contactPhone: "13800000000",
|
||||
code: "123456"
|
||||
)
|
||||
}
|
||||
|
||||
private func editRequest() -> FlyerEditRequest {
|
||||
FlyerEditRequest(
|
||||
id: 801,
|
||||
name: "航拍摄影师",
|
||||
realnameStatus: 1,
|
||||
certificateType: 2,
|
||||
certificateNo: "UAS-2026-001",
|
||||
certificateStartDate: "2026-01-01",
|
||||
certificateEndDate: "2028-01-01",
|
||||
certificateImage: "https://cdn.example.com/flyer/cert.jpg",
|
||||
droneModel: "DJI Mini",
|
||||
droneSn: "SN20260523002",
|
||||
contactPhone: "13800000001",
|
||||
code: "654321"
|
||||
)
|
||||
}
|
||||
|
||||
private func pilotJSONBody(from request: URLRequest) throws -> [String: Any] {
|
||||
let body = try XCTUnwrap(request.httpBody)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
}
|
||||
|
||||
private func XCTAssertThrowsErrorAsync<T>(
|
||||
_ expression: @autoclosure () async throws -> T,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) async {
|
||||
do {
|
||||
_ = try await expression()
|
||||
XCTFail("Expected async expression to throw", file: file, line: line)
|
||||
} catch {
|
||||
// Expected path.
|
||||
}
|
||||
}
|
||||
393
suixinkan_iosTests/ProfileSecondaryPagesTests.swift
Normal file
393
suixinkan_iosTests/ProfileSecondaryPagesTests.swift
Normal file
@ -0,0 +1,393 @@
|
||||
//
|
||||
// ProfileSecondaryPagesTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 个人中心二级页面测试,覆盖设置、账号切换和实名认证基础逻辑。
|
||||
final class ProfileSecondaryPagesTests: XCTestCase {
|
||||
/// 测试设置页版本号展示规则。
|
||||
func testSettingsVersionTextUsesBuildNumberWhenNeeded() {
|
||||
XCTAssertEqual(
|
||||
SettingsDisplayPolicy.versionText(infoDictionary: [
|
||||
"CFBundleShortVersionString": "1.2",
|
||||
"CFBundleVersion": "45"
|
||||
]),
|
||||
"1.2.45"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SettingsDisplayPolicy.versionText(infoDictionary: [
|
||||
"CFBundleShortVersionString": "1.2.3",
|
||||
"CFBundleVersion": "45"
|
||||
]),
|
||||
"1.2.3"
|
||||
)
|
||||
XCTAssertEqual(SettingsDisplayPolicy.versionText(infoDictionary: nil), "1.0.0")
|
||||
}
|
||||
|
||||
/// 测试协议页面映射到预期 H5 路径。
|
||||
func testAgreementPagesUseExpectedPaths() {
|
||||
let pages: [(AgreementPage, String, String)] = [
|
||||
(.about, "关于我们", "/h5/app/about-us"),
|
||||
(.userAgreement, "用户协议", "/h5/app/user-agreement"),
|
||||
(.privacyPolicy, "隐私政策", "/h5/app/privacy-policy"),
|
||||
(.walletUserNotice, "钱包用户须知", "/h5/app/wallet-user-notice"),
|
||||
(.walletPrivacy, "钱包隐私政策", "/h5/app/wallet-privacy")
|
||||
]
|
||||
|
||||
for (page, title, path) in pages {
|
||||
XCTAssertEqual(page.title, title)
|
||||
XCTAssertEqual(page.id, title)
|
||||
XCTAssertEqual(page.url.path, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试账号切换列表接口使用旧工程一致的 v9 accounts 路径。
|
||||
func testSwitchableAccountsUsesExpectedEndpoint() async throws {
|
||||
let session = ProfileSecondaryURLSession(responses: [
|
||||
try TestFixture.data(named: "v9_login_multi_success")
|
||||
])
|
||||
let api = ProfileAPI(client: APIClient(session: session))
|
||||
|
||||
let response = try await api.switchableAccounts()
|
||||
|
||||
XCTAssertEqual(response.accounts.count, 2)
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.url?.path, "/api/app/v9/accounts")
|
||||
}
|
||||
|
||||
/// 测试账号切换 ViewModel 能加载账号并默认选中第一项。
|
||||
func testAccountSwitchViewModelLoadsAccounts() async throws {
|
||||
let session = ProfileSecondaryURLSession(responses: [
|
||||
try TestFixture.data(named: "v9_login_multi_success")
|
||||
])
|
||||
let api = ProfileAPI(client: APIClient(session: session))
|
||||
let viewModel = AccountSwitchViewModel()
|
||||
|
||||
try await viewModel.load(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.accounts.map(\.businessUserId), [101, 201])
|
||||
XCTAssertEqual(viewModel.selectedAccount?.businessUserId, 101)
|
||||
XCTAssertFalse(viewModel.loading)
|
||||
}
|
||||
|
||||
/// 测试账号切换提交使用 set-user 接口和账号对应请求体。
|
||||
func testAccountSwitchViewModelSubmitsSetUser() async throws {
|
||||
let session = ProfileSecondaryURLSession(responses: [
|
||||
try TestFixture.data(named: "v9_set_store_user_success")
|
||||
])
|
||||
let api = AuthAPI(client: APIClient(session: session))
|
||||
let account = AccountSwitchAccount(
|
||||
accountType: V9StoreUser.accountTypeValue,
|
||||
businessUserId: 201,
|
||||
title: "示例门店",
|
||||
subtitle: "示例景区",
|
||||
phone: "13800138000",
|
||||
avatar: "",
|
||||
scenicName: "示例景区",
|
||||
storeId: 20,
|
||||
storeName: "示例门店",
|
||||
scenicId: 10,
|
||||
isCurrent: false
|
||||
)
|
||||
let viewModel = AccountSwitchViewModel()
|
||||
|
||||
let response = try await viewModel.switchAccount(account, api: api)
|
||||
|
||||
XCTAssertFalse(response.token.isEmpty)
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "POST")
|
||||
XCTAssertEqual(request.url?.path, "/api/app/v9/set-user")
|
||||
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any])
|
||||
XCTAssertEqual(json["store_user_id"] as? Int, 201)
|
||||
XCTAssertNil(json["ss_user_id"] as? Int)
|
||||
}
|
||||
|
||||
/// 测试实名认证信息解码兼容字符串数字字段。
|
||||
func testRealNameInfoDecodesLossyFields() async throws {
|
||||
let session = ProfileSecondaryURLSession(responses: [
|
||||
try TestFixture.data(named: "real_name_info_success")
|
||||
])
|
||||
let api = ProfileAPI(client: APIClient(session: session))
|
||||
|
||||
let response = try await api.realNameInfo()
|
||||
let info = try XCTUnwrap(response.realNameInfo)
|
||||
|
||||
XCTAssertEqual(info.realName, "测试摄影师")
|
||||
XCTAssertEqual(info.idCardNo, "320100********0012")
|
||||
XCTAssertTrue(info.verified)
|
||||
XCTAssertFalse(info.isLongValid)
|
||||
XCTAssertEqual(info.auditStatus, 2)
|
||||
XCTAssertEqual(info.auditorId, 7)
|
||||
XCTAssertEqual(info.auditor?.name, "审核员")
|
||||
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/real-name/info")
|
||||
}
|
||||
|
||||
/// 测试实名认证短信和提交接口路径及请求体。
|
||||
func testRealNameSmsAndSubmitUseExpectedEndpoints() async throws {
|
||||
let session = ProfileSecondaryURLSession(responses: [
|
||||
try TestFixture.data(named: "empty_success"),
|
||||
try TestFixture.data(named: "empty_success")
|
||||
])
|
||||
let api = ProfileAPI(client: APIClient(session: session))
|
||||
|
||||
try await api.realNameSmsVerifyCode()
|
||||
try await api.realNameSubmit(
|
||||
RealNameAuthRequest(
|
||||
realName: "测试摄影师",
|
||||
idCardNo: "11010519491231002X",
|
||||
smsVerifyCode: "123456",
|
||||
startDate: "2026-01-01",
|
||||
endDate: "2046-01-01",
|
||||
isLongValid: 0,
|
||||
frontUrl: "https://cdn.example.com/front.jpg",
|
||||
backUrl: "https://cdn.example.com/back.jpg"
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/real-name/sms-verify-code",
|
||||
"/api/yf-handset-app/photog/real-name/submit"
|
||||
])
|
||||
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(session.requests.last?.httpBody)) as? [String: Any])
|
||||
XCTAssertEqual(json["real_name"] as? String, "测试摄影师")
|
||||
XCTAssertEqual(json["id_card_no"] as? String, "11010519491231002X")
|
||||
XCTAssertEqual(json["sms_verify_code"] as? String, "123456")
|
||||
XCTAssertEqual(json["is_long_valid"] as? Int, 0)
|
||||
}
|
||||
|
||||
/// 测试身份证号校验和格式归一化。
|
||||
func testRealNameIDCardValidation() {
|
||||
XCTAssertTrue(RealNameAuthViewModel.isValidMainlandIDCardNumber("11010519491231002x"))
|
||||
XCTAssertEqual(RealNameAuthViewModel.normalizedIDCardNumber(" 11010519491231002x "), "11010519491231002X")
|
||||
XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("110105194912310021"))
|
||||
XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("123"))
|
||||
}
|
||||
|
||||
/// 测试头像变更时先上传 OSS,再回写头像 URL。
|
||||
func testProfileAvatarUploadUpdatesAvatarURL() async throws {
|
||||
let session = ProfileSecondaryURLSession(responses: [
|
||||
try TestFixture.data(named: "empty_success")
|
||||
])
|
||||
let api = ProfileAPI(client: APIClient(session: session))
|
||||
let uploader = MockOSSUploader()
|
||||
let viewModel = ProfileViewModel()
|
||||
viewModel.userInfo = UserInfoResponse(avatar: "", nickname: "旧昵称")
|
||||
viewModel.beginEditing()
|
||||
try viewModel.prepareAvatarImage(data: makeJPEGData(), timestamp: 1_767_225_600)
|
||||
|
||||
try await viewModel.saveProfile(api: api, uploader: uploader, scenicId: 10)
|
||||
|
||||
XCTAssertEqual(uploader.userAvatarUploads.count, 1)
|
||||
XCTAssertEqual(uploader.userAvatarUploads.first?.scenicId, 10)
|
||||
XCTAssertEqual(viewModel.userInfo?.avatar, uploader.avatarURL)
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/yf-handset-app/userinfo-update-avatar-url"])
|
||||
}
|
||||
|
||||
/// 测试头像未变更时不会调用 OSS 上传。
|
||||
func testProfileNicknameOnlySaveDoesNotUploadAvatar() async throws {
|
||||
let session = ProfileSecondaryURLSession(responses: [
|
||||
try TestFixture.data(named: "empty_success")
|
||||
])
|
||||
let api = ProfileAPI(client: APIClient(session: session))
|
||||
let uploader = MockOSSUploader()
|
||||
let viewModel = ProfileViewModel()
|
||||
viewModel.userInfo = UserInfoResponse(avatar: "https://cdn.example.com/old.jpg", nickname: "旧昵称")
|
||||
viewModel.beginEditing()
|
||||
viewModel.editingNickname = "新昵称"
|
||||
|
||||
try await viewModel.saveProfile(api: api, uploader: uploader, scenicId: 10)
|
||||
|
||||
XCTAssertTrue(uploader.userAvatarUploads.isEmpty)
|
||||
XCTAssertEqual(viewModel.userInfo?.nickname, "新昵称")
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/yf-handset-app/userinfo-update"])
|
||||
}
|
||||
|
||||
/// 测试实名认证提交前会上传正反面图片并提交最终 URL。
|
||||
func testRealNameSubmitUploadsImagesBeforeSubmitting() async throws {
|
||||
let session = ProfileSecondaryURLSession(responses: [
|
||||
try TestFixture.data(named: "empty_success"),
|
||||
try TestFixture.data(named: "real_name_info_success")
|
||||
])
|
||||
let api = ProfileAPI(client: APIClient(session: session))
|
||||
let uploader = MockOSSUploader()
|
||||
let viewModel = RealNameAuthViewModel()
|
||||
viewModel.realName = "测试摄影师"
|
||||
viewModel.idCardNo = "11010519491231002X"
|
||||
viewModel.smsCode = "123456"
|
||||
try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .front, timestamp: 1_767_225_600)
|
||||
try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .back, timestamp: 1_767_225_601)
|
||||
|
||||
try await viewModel.submit(api: api, uploader: uploader, scenicId: 10)
|
||||
|
||||
XCTAssertEqual(uploader.realNameUploads.map(\.fileName), ["real_name_front_1767225600.jpg", "real_name_back_1767225601.jpg"])
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/real-name/submit",
|
||||
"/api/yf-handset-app/photog/real-name/info"
|
||||
])
|
||||
let body = try XCTUnwrap(session.requests.first?.httpBody)
|
||||
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
XCTAssertEqual(json["front_url"] as? String, uploader.realNameURLs[0])
|
||||
XCTAssertEqual(json["back_url"] as? String, uploader.realNameURLs[1])
|
||||
}
|
||||
|
||||
/// 测试实名认证缺少图片时阻止提交。
|
||||
func testRealNameSubmitRequiresImages() async throws {
|
||||
let api = ProfileAPI(client: APIClient(session: ProfileSecondaryURLSession(responses: [])))
|
||||
let uploader = MockOSSUploader()
|
||||
let viewModel = RealNameAuthViewModel()
|
||||
viewModel.realName = "测试摄影师"
|
||||
viewModel.idCardNo = "11010519491231002X"
|
||||
viewModel.smsCode = "123456"
|
||||
|
||||
do {
|
||||
try await viewModel.submit(api: api, uploader: uploader, scenicId: 10)
|
||||
XCTFail("缺少证件图片时应阻止提交")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? RealNameValidationError, .message("请选择身份证人像面图片"))
|
||||
}
|
||||
XCTAssertTrue(uploader.realNameUploads.isEmpty)
|
||||
}
|
||||
|
||||
/// 测试实名认证图片上传失败时不会提交资料。
|
||||
func testRealNameSubmitStopsWhenUploadFails() async throws {
|
||||
let session = ProfileSecondaryURLSession(responses: [])
|
||||
let api = ProfileAPI(client: APIClient(session: session))
|
||||
let uploader = MockOSSUploader(error: OSSUploadError.unsupportedFileType)
|
||||
let viewModel = RealNameAuthViewModel()
|
||||
viewModel.realName = "测试摄影师"
|
||||
viewModel.idCardNo = "11010519491231002X"
|
||||
viewModel.smsCode = "123456"
|
||||
try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .front)
|
||||
try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .back)
|
||||
|
||||
do {
|
||||
try await viewModel.submit(api: api, uploader: uploader, scenicId: 10)
|
||||
XCTFail("上传失败时应阻止提交")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? OSSUploadError, .unsupportedFileType)
|
||||
}
|
||||
|
||||
XCTAssertTrue(session.requests.isEmpty)
|
||||
XCTAssertFalse(viewModel.submitting)
|
||||
}
|
||||
|
||||
/// 测试首页系统设置 URI 已接入真实设置页。
|
||||
func testHomeSystemSettingsRouteResolvesToSettingsPage() {
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "system_settings", title: "设置中心"),
|
||||
.destination(.settings)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人中心测试用 URLSession 替身,按顺序返回预设响应。
|
||||
private final class ProfileSecondaryURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 初始化顺序响应。
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 记录请求并返回下一份响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.isEmpty ? Data() : responses.removeFirst()
|
||||
return (
|
||||
data,
|
||||
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人中心测试用 OSS 上传替身,记录上传请求并返回固定 URL。
|
||||
private final class MockOSSUploader: OSSUploadServing {
|
||||
struct UploadRecord: Equatable {
|
||||
let fileName: String
|
||||
let scenicId: Int
|
||||
}
|
||||
|
||||
let avatarURL = "https://cdn.example.com/avatar-new.jpg"
|
||||
let realNameURLs = [
|
||||
"https://cdn.example.com/front-new.jpg",
|
||||
"https://cdn.example.com/back-new.jpg"
|
||||
]
|
||||
private let error: Error?
|
||||
private(set) var userAvatarUploads: [UploadRecord] = []
|
||||
private(set) var realNameUploads: [UploadRecord] = []
|
||||
|
||||
/// 初始化上传替身,可选指定要抛出的错误。
|
||||
init(error: Error? = nil) {
|
||||
self.error = error
|
||||
}
|
||||
|
||||
/// 模拟上传用户头像。
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
if let error { throw error }
|
||||
userAvatarUploads.append(UploadRecord(fileName: fileName, scenicId: scenicId))
|
||||
onProgress(100)
|
||||
return avatarURL
|
||||
}
|
||||
|
||||
/// 模拟上传实名认证证件图片。
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
if let error { throw error }
|
||||
realNameUploads.append(UploadRecord(fileName: fileName, scenicId: scenicId))
|
||||
onProgress(100)
|
||||
return realNameURLs[min(realNameUploads.count - 1, realNameURLs.count - 1)]
|
||||
}
|
||||
|
||||
/// 模拟上传云盘文件。
|
||||
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
avatarURL
|
||||
}
|
||||
|
||||
/// 模拟上传相册文件。
|
||||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
avatarURL
|
||||
}
|
||||
|
||||
/// 模拟上传任务附件。
|
||||
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
avatarURL
|
||||
}
|
||||
|
||||
/// 模拟上传项目图片。
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
avatarURL
|
||||
}
|
||||
|
||||
/// 模拟上传打卡点图片。
|
||||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
avatarURL
|
||||
}
|
||||
|
||||
/// 模拟上传景区申请图片。
|
||||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
avatarURL
|
||||
}
|
||||
|
||||
/// 模拟上传银行卡照片。
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
avatarURL
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成测试用 JPEG 图片数据。
|
||||
private func makeJPEGData() throws -> Data {
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 12, height: 12))
|
||||
let image = renderer.image { context in
|
||||
UIColor.systemBlue.setFill()
|
||||
context.fill(CGRect(x: 0, y: 0, width: 12, height: 12))
|
||||
}
|
||||
return try XCTUnwrap(image.jpegData(compressionQuality: 0.9))
|
||||
}
|
||||
150
suixinkan_iosTests/Projects/ProjectAPITests.swift
Normal file
150
suixinkan_iosTests/Projects/ProjectAPITests.swift
Normal file
@ -0,0 +1,150 @@
|
||||
//
|
||||
// ProjectAPITests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 项目 API 测试,覆盖摄影师项目和店铺项目请求。
|
||||
final class ProjectAPITests: XCTestCase {
|
||||
/// 测试摄影师项目列表接口 path 和 query。
|
||||
func testProjectListUsesExpectedPathAndQuery() async throws {
|
||||
let session = ProjectRecordingURLSession(data: Self.projectListResponse)
|
||||
let api = ProjectAPI(client: APIClient(session: session))
|
||||
|
||||
let payload = try await api.projectList(scenicId: 9, name: " 亲子 ", page: 0, pageSize: 0)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/project/list")
|
||||
let query = projectQueryItems(from: request)
|
||||
XCTAssertEqual(query["scenic_id"], "9")
|
||||
XCTAssertEqual(query["name"], "亲子")
|
||||
XCTAssertEqual(query["page"], "1")
|
||||
XCTAssertEqual(query["page_size"], "1")
|
||||
XCTAssertEqual(payload.list.first?.id, 88)
|
||||
}
|
||||
|
||||
/// 测试项目详情和删除接口路径正确。
|
||||
func testProjectDetailAndDeleteUseExpectedPath() async throws {
|
||||
let session = ProjectRecordingURLSession(responses: [Self.projectDetailResponse, Self.emptyResponse])
|
||||
let api = ProjectAPI(client: APIClient(session: session))
|
||||
|
||||
let detail = try await api.projectDetail(id: 88)
|
||||
try await api.deleteProject(id: 88)
|
||||
|
||||
XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/project/info-view")
|
||||
XCTAssertEqual(projectQueryItems(from: session.requests[0])["id"], "88")
|
||||
XCTAssertEqual(detail.materialNum, 12)
|
||||
XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/project/delete")
|
||||
XCTAssertEqual(try bodyObject(from: session.requests[1])["id"] as? Int, 88)
|
||||
}
|
||||
|
||||
/// 测试创建和编辑摄影师项目请求体。
|
||||
func testCreateAndEditProjectUseExpectedBody() async throws {
|
||||
let session = ProjectRecordingURLSession(data: Self.emptyResponse)
|
||||
let api = ProjectAPI(client: APIClient(session: session))
|
||||
let request = ProjectCreateRequest(
|
||||
id: 7,
|
||||
type: 11,
|
||||
scenicId: 9,
|
||||
name: "项目",
|
||||
price: "99.00",
|
||||
otPrice: "199.00",
|
||||
coverProject: "https://cdn/p.jpg",
|
||||
coverCarousel: ["https://cdn/a.jpg"],
|
||||
description: "描述",
|
||||
attrLabel: ["亲子"],
|
||||
extra: ProjectCreateExtra(priceDeposit: "10.00", materialNum: 1, photoNum: 2, videoNum: 3, scenicSpotId: [6], photogUid: [100])
|
||||
)
|
||||
|
||||
try await api.createProject(request)
|
||||
try await api.editProject(request)
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/project/create",
|
||||
"/api/yf-handset-app/photog/project/edit"
|
||||
])
|
||||
let body = try bodyObject(from: session.requests[0])
|
||||
XCTAssertEqual(body["scenic_id"] as? Int, 9)
|
||||
XCTAssertEqual(body["cover_project"] as? String, "https://cdn/p.jpg")
|
||||
XCTAssertEqual((body["extra"] as? [String: Any])?["photo_num"] as? Int, 2)
|
||||
}
|
||||
|
||||
/// 测试店铺项目接口 path、query 和 body。
|
||||
func testStoreManagerRequestsUseExpectedPathAndBody() async throws {
|
||||
let session = ProjectRecordingURLSession(responses: [
|
||||
Self.scenicListResponse,
|
||||
Self.projectListResponse,
|
||||
Self.projectDetailResponse,
|
||||
Self.emptyResponse,
|
||||
Self.emptyResponse
|
||||
])
|
||||
let api = ProjectAPI(client: APIClient(session: session))
|
||||
|
||||
_ = try await api.storeManagerScenicList(userId: "100")
|
||||
_ = try await api.storeManagerProjectList(userId: "100", page: 0, pageSize: 0)
|
||||
_ = try await api.storeManagerProjectDetail(id: 88)
|
||||
try await api.storeManagerCreate(StoreManagerCreateRequest(name: "店铺项目", storeId: nil, type: 19, description: "描述", coverProject: "u", coverCarousel: [], projectRule: nil, scenicId: [9], settleSpotNum: 1, price: 100, priceMaterial: 1, pricePhoto: 2, priceVideo: 3, priceMaterialAll: nil, packageList: nil, userId: 100, singleSpotMaterialNum: 1, singleSpotPhotoNum: 2, singleSpotVideoNum: 3))
|
||||
try await api.storeManagerOfflineCreate(StoreManagerOfflineCreateRequest(name: "押金", scenicId: 9, storeId: 3, description: "描述", price: 20, coverProject: "u", coverCarousel: []))
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/app/store-manager/scenic-list",
|
||||
"/api/app/store-manager/list",
|
||||
"/api/app/store-manager/detail",
|
||||
"/api/app/store-manager/create",
|
||||
"/api/app/store-manager/offline-create"
|
||||
])
|
||||
XCTAssertEqual(projectQueryItems(from: session.requests[0])["user_id"], "100")
|
||||
XCTAssertEqual(projectQueryItems(from: session.requests[1])["page"], "1")
|
||||
XCTAssertEqual((try bodyObject(from: session.requests[3]))["settle_spot_num"] as? Int, 1)
|
||||
XCTAssertEqual((try bodyObject(from: session.requests[4]))["store_id"] as? Int, 3)
|
||||
}
|
||||
|
||||
private static let emptyResponse = Data(#"{"code":100000,"message":"ok","data":{}}"#.utf8)
|
||||
private static let projectListResponse = Data(#"{"code":100000,"message":"ok","data":{"total":"2","list":[{"id":"88","type":"11","type_name":"旅拍","status":"1","status_name":"运营中","name":"亲子旅拍","cover_project":"https://cdn/p.jpg","price":"99.00","ot_price":199,"price_deposit":"10","attr_label":"亲子,航拍"}]}}"#.utf8)
|
||||
private static let projectDetailResponse = Data(#"{"code":100000,"message":"ok","data":{"id":"88","scenic_id":"9","cover_project":"https://cdn/p.jpg","cover_carousel":["https://cdn/a.jpg"],"name":"亲子旅拍","type":"11","type_name":"旅拍","status":"1","status_name":"运营中","price":"99.00","ot_price":"199.00","price_deposit":"10.00","description":"描述","attr_label":["亲子"],"sold":"3","material_num":"12","photo_num":"6","video_num":"1","created_at":"2026-06-24","creator_name":"摄影师","photog_list":[{"id":"1","photog_uid":"100","nickname":"小李","avatar":"","completed_order_count":"8"}],"scenic_list":[{"id":"6","name":"大门"}]}}"#.utf8)
|
||||
private static let scenicListResponse = Data(#"{"code":100000,"message":"ok","data":{"total":1,"list":[{"id":"9","name":"测试景区"}]}}"#.utf8)
|
||||
}
|
||||
|
||||
/// 项目 API 测试 URLSession,记录请求并返回固定数据。
|
||||
private final class ProjectRecordingURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 初始化单响应测试 Session。
|
||||
init(data: Data) {
|
||||
self.responses = [data]
|
||||
}
|
||||
|
||||
/// 初始化多响应测试 Session。
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 记录请求并返回响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.count > 1 ? responses.removeFirst() : responses[0]
|
||||
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求中提取 query 字典。
|
||||
private func projectQueryItems(from request: URLRequest) -> [String: String] {
|
||||
guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
item.value.map { (item.name, $0) }
|
||||
})
|
||||
}
|
||||
|
||||
/// 将请求体解析为字典。
|
||||
private func bodyObject(from request: URLRequest) throws -> [String: Any] {
|
||||
let body = try XCTUnwrap(request.httpBody)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
}
|
||||
195
suixinkan_iosTests/Projects/ProjectViewModelTests.swift
Normal file
195
suixinkan_iosTests/Projects/ProjectViewModelTests.swift
Normal file
@ -0,0 +1,195 @@
|
||||
//
|
||||
// ProjectViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 项目 ViewModel 测试,覆盖分页、校验和上传提交顺序。
|
||||
final class ProjectViewModelTests: XCTestCase {
|
||||
/// 测试无景区时清空列表且不请求接口。
|
||||
func testProjectReloadWithoutScenicClearsListAndSkipsRequest() async {
|
||||
let api = FakeProjectService()
|
||||
let viewModel = ProjectManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: nil)
|
||||
|
||||
XCTAssertTrue(viewModel.items.isEmpty)
|
||||
XCTAssertEqual(api.projectListCalls.count, 0)
|
||||
}
|
||||
|
||||
/// 测试项目列表首屏和加载更多分页。
|
||||
func testProjectListReloadAndLoadMore() async {
|
||||
let api = FakeProjectService()
|
||||
api.projectPages = [
|
||||
ListPayload(total: 2, list: [.fixture(id: 1, name: "A")]),
|
||||
ListPayload(total: 2, list: [.fixture(id: 2, name: "B")])
|
||||
]
|
||||
let viewModel = ProjectManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: 9)
|
||||
await viewModel.loadMore(api: api, scenicId: 9)
|
||||
|
||||
XCTAssertEqual(api.projectListCalls.map(\.page), [1, 2])
|
||||
XCTAssertEqual(viewModel.items.map(\.id).sorted(), [1, 2])
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
}
|
||||
|
||||
/// 测试摄影师项目提交会先上传图片再调用创建接口。
|
||||
func testProjectEditorUploadsImagesBeforeCreate() async {
|
||||
let api = FakeProjectService()
|
||||
let uploader = FakeProjectUploader()
|
||||
let viewModel = ProjectEditorViewModel(mode: .create)
|
||||
viewModel.name = "新项目"
|
||||
viewModel.descriptionText = "描述"
|
||||
viewModel.price = "99"
|
||||
viewModel.otPrice = "199"
|
||||
viewModel.deposit = "10"
|
||||
viewModel.selectedSpotIds = [6]
|
||||
viewModel.coverImage = ProjectLocalImage(data: Data([1]), fileName: "cover.jpg", previewURL: nil)
|
||||
viewModel.carouselImages = [ProjectLocalImage(data: Data([2]), fileName: "banner.jpg", previewURL: nil)]
|
||||
|
||||
let success = await viewModel.submit(scenicId: 9, userId: 100, api: api, uploadService: uploader)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(uploader.uploadedFileNames, ["cover.jpg", "banner.jpg"])
|
||||
XCTAssertEqual(api.createdProjects.first?.coverProject, "https://cdn/cover.jpg")
|
||||
XCTAssertEqual(api.createdProjects.first?.coverCarousel, ["https://cdn/banner.jpg"])
|
||||
}
|
||||
|
||||
/// 测试上传失败时不提交业务接口。
|
||||
func testProjectEditorUploadFailureDoesNotCreateProject() async {
|
||||
let api = FakeProjectService()
|
||||
let uploader = FakeProjectUploader()
|
||||
uploader.shouldFail = true
|
||||
let viewModel = ProjectEditorViewModel(mode: .create)
|
||||
viewModel.name = "新项目"
|
||||
viewModel.descriptionText = "描述"
|
||||
viewModel.price = "99"
|
||||
viewModel.selectedSpotIds = [6]
|
||||
viewModel.coverImage = ProjectLocalImage(data: Data([1]), fileName: "cover.jpg", previewURL: nil)
|
||||
|
||||
let success = await viewModel.submit(scenicId: 9, userId: 100, api: api, uploadService: uploader)
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertTrue(api.createdProjects.isEmpty)
|
||||
}
|
||||
|
||||
/// 测试店铺项目缺用户 ID 时不提交。
|
||||
func testStoreProjectEditorRequiresUserId() async {
|
||||
let viewModel = StoreProjectEditorViewModel(mode: .create)
|
||||
let success = await viewModel.submit(userId: 0, api: FakeProjectService(), uploadService: FakeProjectUploader())
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertEqual(viewModel.errorMessage, ProjectEditorError.missingUser.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private extension PhotographerProjectItem {
|
||||
/// 创建测试项目列表项。
|
||||
static func fixture(id: Int, name: String) -> PhotographerProjectItem {
|
||||
PhotographerProjectItem(id: id, name: name, price: "99.00")
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目测试服务替身。
|
||||
@MainActor
|
||||
private final class FakeProjectService: ProjectServing {
|
||||
var projectPages: [ListPayload<PhotographerProjectItem>] = []
|
||||
var projectListCalls: [(scenicId: Int, name: String?, page: Int, pageSize: Int)] = []
|
||||
var createdProjects: [ProjectCreateRequest] = []
|
||||
|
||||
/// 获取摄影师项目列表。
|
||||
func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload<PhotographerProjectItem> {
|
||||
projectListCalls.append((scenicId, name, page, pageSize))
|
||||
return projectPages.isEmpty ? ListPayload(total: 0, list: []) : projectPages.removeFirst()
|
||||
}
|
||||
|
||||
/// 获取摄影师项目详情。
|
||||
func projectDetail(id: Int) async throws -> PhotographerProjectDetailResponse {
|
||||
try Self.detail()
|
||||
}
|
||||
|
||||
/// 创建摄影师项目。
|
||||
func createProject(_ request: ProjectCreateRequest) async throws {
|
||||
createdProjects.append(request)
|
||||
}
|
||||
|
||||
/// 编辑摄影师项目。
|
||||
func editProject(_ request: ProjectCreateRequest) async throws {}
|
||||
|
||||
/// 删除摄影师项目。
|
||||
func deleteProject(id: Int) async throws {}
|
||||
|
||||
/// 获取店铺项目可管理景区。
|
||||
func storeManagerScenicList(userId: String) async throws -> ListPayload<StoreManagerScenicItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 获取店铺项目列表。
|
||||
func storeManagerProjectList(userId: String?, page: Int, pageSize: Int) async throws -> ListPayload<PhotographerProjectItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 获取店铺项目详情。
|
||||
func storeManagerProjectDetail(id: Int) async throws -> PhotographerProjectDetailResponse {
|
||||
try Self.detail()
|
||||
}
|
||||
|
||||
/// 创建店铺多点位项目。
|
||||
func storeManagerCreate(_ request: StoreManagerCreateRequest) async throws {}
|
||||
|
||||
/// 创建店铺押金项目。
|
||||
func storeManagerOfflineCreate(_ request: StoreManagerOfflineCreateRequest) async throws {}
|
||||
|
||||
/// 更新店铺多点位项目。
|
||||
func storeManagerUpdate(_ request: StoreManagerUpdateRequest) async throws {}
|
||||
|
||||
/// 更新店铺押金项目。
|
||||
func storeManagerOfflineUpdate(_ request: StoreManagerOfflineUpdateRequest) async throws {}
|
||||
|
||||
/// 删除店铺项目。
|
||||
func storeManagerDeleteProject(id: Int) async throws {}
|
||||
|
||||
/// 获取全部门店。
|
||||
func storeAll() async throws -> ListPayload<StoreItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
private static func detail() throws -> PhotographerProjectDetailResponse {
|
||||
let data = Data(#"{"id":1,"name":"项目","type":11,"status":1,"price":"99","description":"描述","material_num":1,"photo_num":1,"video_num":1}"#.utf8)
|
||||
return try JSONDecoder().decode(PhotographerProjectDetailResponse.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目上传测试替身。
|
||||
@MainActor
|
||||
private final class FakeProjectUploader: OSSUploadServing {
|
||||
var uploadedFileNames: [String] = []
|
||||
var shouldFail = false
|
||||
|
||||
/// 上传项目图片。
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
if shouldFail { throw TestUploadError.failed }
|
||||
uploadedFileNames.append(fileName)
|
||||
return "https://cdn/\(fileName)"
|
||||
}
|
||||
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
}
|
||||
|
||||
/// 测试上传错误。
|
||||
private enum TestUploadError: Error {
|
||||
case failed
|
||||
}
|
||||
233
suixinkan_iosTests/PunchPoint/PunchPointAPITests.swift
Normal file
233
suixinkan_iosTests/PunchPoint/PunchPointAPITests.swift
Normal file
@ -0,0 +1,233 @@
|
||||
//
|
||||
// PunchPointAPITests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 打卡点 API 测试,覆盖旧工程接口路径、参数和宽松解码。
|
||||
final class PunchPointAPITests: XCTestCase {
|
||||
/// 测试打卡点列表接口使用正确 path 和 query。
|
||||
func testPunchPointListUsesExpectedPathAndQuery() async throws {
|
||||
let session = PunchPointRecordingSession(data: Self.listResponse)
|
||||
let api = PunchPointAPI(client: APIClient(session: session))
|
||||
|
||||
let payload = try await api.punchPointList(scenicId: 88, status: -1, page: 0, pageSize: 0)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/scenic-spot/list")
|
||||
let query = queryItems(from: request)
|
||||
XCTAssertEqual(query["scenic_area_id"], "88")
|
||||
XCTAssertEqual(query["status"], "0")
|
||||
XCTAssertEqual(query["page"], "1")
|
||||
XCTAssertEqual(query["page_size"], "1")
|
||||
XCTAssertEqual(payload.total, 1)
|
||||
XCTAssertEqual(payload.list.first?.id, 7)
|
||||
XCTAssertEqual(payload.list.first?.region?.lat, 30.1)
|
||||
}
|
||||
|
||||
/// 测试详情、新增、编辑、删除接口路径和请求体。
|
||||
func testPunchPointMutationRequestsUseExpectedBodies() async throws {
|
||||
let session = PunchPointRecordingSession(responses: [Self.infoResponse, Self.emptyResponse, Self.emptyResponse, Self.emptyResponse])
|
||||
let api = PunchPointAPI(client: APIClient(session: session))
|
||||
let region = PunchPointRegion(lat: 30.1, lot: 120.2, address: "入口", scenicSpotStr: "入口点")
|
||||
|
||||
_ = try await api.punchPointInfo(id: 7)
|
||||
try await api.addPunchPoint(AddPunchPointRequest(scenicAreaId: "88", name: "入口", description: "描述", region: region, scenicSpotStr: "入口点", guideImages: ["https://cdn/a.jpg"]))
|
||||
try await api.editPunchPoint(EditPunchPointRequest(id: 7, scenicAreaId: "88", name: "入口2", description: "描述2", region: region, scenicSpotStr: "入口点", guideImages: []))
|
||||
try await api.deletePunchPoint(id: 7)
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/scenic-spot/info",
|
||||
"/api/yf-handset-app/photog/scenic-spot/add",
|
||||
"/api/yf-handset-app/photog/scenic-spot/edit",
|
||||
"/api/yf-handset-app/photog/scenic-spot/delete"
|
||||
])
|
||||
XCTAssertEqual(queryItems(from: session.requests[0])["id"], "7")
|
||||
let addBody = try bodyObject(from: session.requests[1])
|
||||
XCTAssertEqual(addBody["scenic_area_id"] as? String, "88")
|
||||
XCTAssertEqual(addBody["name"] as? String, "入口")
|
||||
XCTAssertEqual((addBody["guide_imgs"] as? [String])?.first, "https://cdn/a.jpg")
|
||||
let deleteBody = try bodyObject(from: session.requests[3])
|
||||
XCTAssertEqual(deleteBody["id"] as? Int, 7)
|
||||
}
|
||||
|
||||
private static let emptyResponse = Data(#"{"code":100000,"msg":"ok","data":{}}"#.utf8)
|
||||
private static let listResponse = Data(#"{"code":100000,"msg":"ok","data":{"total":"1","list":[{"id":"7","scenic_area_id":"88","name":100,"status":"1","status_label":"运营中","description":"描述","region":{"lat":"30.1","lot":120.2,"address":"入口","scenic_spot_str":"入口点"},"scenic_spot_str":"入口点","guide_imgs":["https://cdn/a.jpg"],"mp_qrcode":"https://cdn/qr.png","created_at":"2026-06-24"}]}}"#.utf8)
|
||||
private static let infoResponse = Data(#"{"code":100000,"msg":"ok","data":{"id":7,"name":"入口","status":1,"region":{"lat":30.1,"lot":"120.2","address":"入口"},"guide_imgs":[]}}"#.utf8)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 打卡点 ViewModel 测试,覆盖分页、删除和图片上传提交。
|
||||
final class PunchPointViewModelTests: XCTestCase {
|
||||
/// 测试无景区时清空列表且不请求接口。
|
||||
func testListClearsWhenScenicMissing() async {
|
||||
let api = MockPunchPointService()
|
||||
let viewModel = PunchPointListViewModel()
|
||||
viewModel.items = [PunchPointItem(id: 1, name: "旧数据")]
|
||||
|
||||
await viewModel.reload(scenicId: nil, api: api)
|
||||
|
||||
XCTAssertTrue(viewModel.items.isEmpty)
|
||||
XCTAssertEqual(api.listRequests.count, 0)
|
||||
}
|
||||
|
||||
/// 测试列表分页和最后一页停止请求。
|
||||
func testListPaginationStopsAtLastPage() async {
|
||||
let api = MockPunchPointService()
|
||||
api.pages = [
|
||||
ListPayload(total: 2, list: [PunchPointItem(id: 1, name: "A")]),
|
||||
ListPayload(total: 2, list: [PunchPointItem(id: 2, name: "B")])
|
||||
]
|
||||
let viewModel = PunchPointListViewModel()
|
||||
|
||||
await viewModel.reload(scenicId: 88, api: api)
|
||||
await viewModel.loadMore(scenicId: 88, api: api)
|
||||
await viewModel.loadMore(scenicId: 88, api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
|
||||
XCTAssertEqual(api.listRequests.map(\.page), [1, 2])
|
||||
}
|
||||
|
||||
/// 测试删除成功后刷新列表。
|
||||
func testDeleteRefreshesList() async {
|
||||
let api = MockPunchPointService()
|
||||
let viewModel = PunchPointListViewModel()
|
||||
|
||||
let success = await viewModel.delete(PunchPointItem(id: 9, name: "A"), scenicId: 88, api: api)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(api.deletedIds, [9])
|
||||
XCTAssertEqual(api.listRequests.count, 1)
|
||||
}
|
||||
|
||||
/// 测试新建打卡点会先上传本地图片,再提交最终 OSS URL。
|
||||
func testEditorUploadsImagesBeforeSubmit() async {
|
||||
let api = MockPunchPointService()
|
||||
let uploader = MockPunchPointUploader()
|
||||
let viewModel = PunchPointEditorViewModel()
|
||||
viewModel.name = "入口"
|
||||
viewModel.applyLocation(latitude: 30.1, longitude: 120.2, address: "入口")
|
||||
viewModel.addLocalImages([PunchPointLocalImage(data: Data([1]), fileName: "a.jpg")])
|
||||
|
||||
let success = await viewModel.submit(scenicId: 88, api: api, uploadService: uploader)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(uploader.uploadedFileNames, ["a.jpg"])
|
||||
XCTAssertEqual(api.addRequests.first?.guideImages.first, "https://cdn.example.com/a.jpg")
|
||||
}
|
||||
|
||||
/// 测试上传失败时不提交打卡点接口。
|
||||
func testEditorUploadFailureDoesNotSubmit() async {
|
||||
let api = MockPunchPointService()
|
||||
let uploader = MockPunchPointUploader()
|
||||
uploader.shouldFail = true
|
||||
let viewModel = PunchPointEditorViewModel()
|
||||
viewModel.name = "入口"
|
||||
viewModel.applyLocation(latitude: 30.1, longitude: 120.2, address: "入口")
|
||||
viewModel.addLocalImages([PunchPointLocalImage(data: Data([1]), fileName: "a.jpg")])
|
||||
|
||||
let success = await viewModel.submit(scenicId: 88, api: api, uploadService: uploader)
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertEqual(api.addRequests.count, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 打卡点 API 测试用 URLSession。
|
||||
private final class PunchPointRecordingSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 初始化测试 Session。
|
||||
init(data: Data) {
|
||||
responses = [data]
|
||||
}
|
||||
|
||||
/// 初始化多个响应的测试 Session。
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 记录请求并返回响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.isEmpty ? Data(#"{"code":100000,"data":{}}"#.utf8) : responses.removeFirst()
|
||||
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
return (data, response)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 打卡点服务测试替身。
|
||||
private final class MockPunchPointService: PunchPointServing {
|
||||
var pages: [ListPayload<PunchPointItem>] = [ListPayload(total: 0, list: [])]
|
||||
var listRequests: [(scenicId: Int, status: Int, page: Int, pageSize: Int)] = []
|
||||
var addRequests: [AddPunchPointRequest] = []
|
||||
var editRequests: [EditPunchPointRequest] = []
|
||||
var deletedIds: [Int] = []
|
||||
|
||||
func punchPointList(scenicId: Int, status: Int, page: Int, pageSize: Int) async throws -> ListPayload<PunchPointItem> {
|
||||
listRequests.append((scenicId, status, page, pageSize))
|
||||
return pages.isEmpty ? ListPayload(total: 0, list: []) : pages.removeFirst()
|
||||
}
|
||||
|
||||
func punchPointInfo(id: Int) async throws -> PunchPointItem {
|
||||
PunchPointItem(id: id, name: "详情")
|
||||
}
|
||||
|
||||
func addPunchPoint(_ request: AddPunchPointRequest) async throws {
|
||||
addRequests.append(request)
|
||||
}
|
||||
|
||||
func editPunchPoint(_ request: EditPunchPointRequest) async throws {
|
||||
editRequests.append(request)
|
||||
}
|
||||
|
||||
func deletePunchPoint(id: Int) async throws {
|
||||
deletedIds.append(id)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// OSS 上传测试替身。
|
||||
private final class MockPunchPointUploader: OSSUploadServing {
|
||||
var shouldFail = false
|
||||
var uploadedFileNames: [String] = []
|
||||
|
||||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
if shouldFail { throw NSError(domain: "upload", code: 1) }
|
||||
uploadedFileNames.append(fileName)
|
||||
onProgress(100)
|
||||
return "https://cdn.example.com/\(fileName)"
|
||||
}
|
||||
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
}
|
||||
|
||||
/// 从请求中提取 query 字典。
|
||||
private func queryItems(from request: URLRequest) -> [String: String] {
|
||||
guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
item.value.map { (item.name, $0) }
|
||||
})
|
||||
}
|
||||
|
||||
/// 从请求体中解析 JSON 字典。
|
||||
private func bodyObject(from request: URLRequest) throws -> [String: Any] {
|
||||
let body = try XCTUnwrap(request.httpBody)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
}
|
||||
133
suixinkan_iosTests/PushNotificationTests.swift
Normal file
133
suixinkan_iosTests/PushNotificationTests.swift
Normal file
@ -0,0 +1,133 @@
|
||||
//
|
||||
// PushNotificationTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
/// 推送链路测试,覆盖 APNs token、payload 解析、token 上报和通知路由。
|
||||
final class PushNotificationTests: XCTestCase {
|
||||
func testAPNsDeviceTokenUsesLowercaseHexString() {
|
||||
let token = APNsDeviceToken.hexString(from: Data([0x00, 0x0f, 0x10, 0xab, 0xff]))
|
||||
|
||||
XCTAssertEqual(token, "000f10abff")
|
||||
}
|
||||
|
||||
func testPushPayloadRoutesTopLevelFields() {
|
||||
XCTAssertEqual(PushPayload(userInfo: ["type": "1"]).route, .payment)
|
||||
XCTAssertEqual(PushPayload(userInfo: ["route": "verification_order"]).route, .verificationOrder)
|
||||
XCTAssertEqual(PushPayload(userInfo: ["uri": "/scenic-queue"]).route, .queue)
|
||||
XCTAssertEqual(PushPayload(userInfo: ["action": "task_management"]).route, .task)
|
||||
XCTAssertEqual(PushPayload(userInfo: ["route": "photographer_orders"]).route, .order)
|
||||
}
|
||||
|
||||
func testPushPayloadRoutesNestedJSONString() {
|
||||
let payload = PushPayload(userInfo: [
|
||||
"extras": #"{"data":{"uri":"message_center","title":"系统消息"}}"#
|
||||
])
|
||||
|
||||
XCTAssertEqual(payload.route, .messageCenter)
|
||||
}
|
||||
|
||||
func testPushPayloadRoutesNestedDictionaryAndChineseKeywords() {
|
||||
let payload = PushPayload(userInfo: [
|
||||
"data": [
|
||||
"extra": [
|
||||
"action": "叫号提醒"
|
||||
]
|
||||
]
|
||||
])
|
||||
|
||||
XCTAssertEqual(payload.route, .queue)
|
||||
}
|
||||
|
||||
func testPushPayloadFallsBackToMessageCenter() {
|
||||
let payload = PushPayload(userInfo: ["title": "系统消息"])
|
||||
|
||||
XCTAssertEqual(payload.route, .messageCenter)
|
||||
}
|
||||
|
||||
func testRegisterPushTokenUsesCurrentBackendEndpointAndLegacyQueryName() async throws {
|
||||
let session = PushRecordingURLSession()
|
||||
let api = PushAPI(client: APIClient(session: session))
|
||||
|
||||
try await api.registerJPushId("000f10abff")
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "POST")
|
||||
XCTAssertEqual(request.url?.path, "/api/app/user/register-jpush-id")
|
||||
XCTAssertEqual(queryItems(from: request)["jpush_reg_id"], "000f10abff")
|
||||
}
|
||||
|
||||
func testPushManagerRoutesHomeDestinationsThroughHomeNavigationStack() {
|
||||
let router = AppRouter()
|
||||
let manager = pushManager(router: router)
|
||||
|
||||
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "payment_qr"]))
|
||||
XCTAssertEqual(router.selectedTab, .home)
|
||||
XCTAssertEqual(router.router(for: .home).path, [.home(.paymentCollection)])
|
||||
|
||||
router.router(for: .home).reset()
|
||||
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "task_management"]))
|
||||
XCTAssertEqual(router.router(for: .home).path, [.home(.taskManagement)])
|
||||
|
||||
router.router(for: .home).reset()
|
||||
manager.handleRemoteNotification(PushPayload(userInfo: ["uri": "/scenic-queue"]))
|
||||
XCTAssertEqual(router.router(for: .home).path, [.home(.queueManagement)])
|
||||
|
||||
router.router(for: .home).reset()
|
||||
manager.handleRemoteNotification(PushPayload(userInfo: ["title": "未知通知"]))
|
||||
XCTAssertEqual(router.router(for: .home).path, [.home(.messageCenter)])
|
||||
}
|
||||
|
||||
func testPushManagerRoutesOrdersEntry() {
|
||||
let router = AppRouter()
|
||||
let manager = pushManager(router: router)
|
||||
|
||||
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "photographer_orders"]))
|
||||
XCTAssertEqual(router.selectedTab, .orders)
|
||||
XCTAssertEqual(router.selectedOrdersEntry, .storeOrders)
|
||||
|
||||
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "verification_order"]))
|
||||
XCTAssertEqual(router.selectedTab, .orders)
|
||||
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
||||
}
|
||||
|
||||
private func pushManager(router: AppRouter) -> PushNotificationManager {
|
||||
let session = AppSession()
|
||||
let manager = PushNotificationManager()
|
||||
manager.configure(
|
||||
api: PushAPI(client: APIClient(session: PushRecordingURLSession())),
|
||||
session: session,
|
||||
router: router
|
||||
)
|
||||
return manager
|
||||
}
|
||||
}
|
||||
|
||||
private final class PushRecordingURLSession: URLSessionProtocol {
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = #"{"code":100000,"msg":"success","data":{}}"#.data(using: .utf8)!
|
||||
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
return (data, response)
|
||||
}
|
||||
}
|
||||
|
||||
private func queryItems(from request: URLRequest) -> [String: String] {
|
||||
guard let url = request.url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
guard let value = item.value else { return nil }
|
||||
return (item.name, value)
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,227 @@
|
||||
//
|
||||
// QueueManagementViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 排队管理列表测试,覆盖点位门禁、列表分页和动作。
|
||||
final class QueueManagementViewModelTests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
clearQueueDefaults()
|
||||
}
|
||||
|
||||
func testReloadWithoutScenicClearsState() async {
|
||||
let api = ScenicQueueMock()
|
||||
let viewModel = QueueManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: nil, userId: "77", spots: sampleSpots)
|
||||
|
||||
XCTAssertTrue(viewModel.items.isEmpty)
|
||||
XCTAssertNil(viewModel.selectedSpotId)
|
||||
XCTAssertFalse(viewModel.queueGatePassed)
|
||||
XCTAssertEqual(viewModel.queueCount, 0)
|
||||
}
|
||||
|
||||
func testReloadWithoutSavedSpotShowsGateAndDoesNotRequestQueue() async {
|
||||
let api = ScenicQueueMock()
|
||||
let viewModel = QueueManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
|
||||
|
||||
XCTAssertEqual(viewModel.scenicSpots.map(\.id), [901, 902])
|
||||
XCTAssertNil(viewModel.selectedSpotId)
|
||||
XCTAssertFalse(viewModel.queueGatePassed)
|
||||
XCTAssertTrue(api.homeRequests.isEmpty)
|
||||
XCTAssertTrue(api.statsRequests.isEmpty)
|
||||
}
|
||||
|
||||
func testReloadWithSavedSpotLoadsHomeAndStats() async {
|
||||
saveSelectedSpot()
|
||||
let api = ScenicQueueMock()
|
||||
api.homeResponses = [ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001), ticket(id: 1002)], total: 2), time: "2026-06-25 10:01")]
|
||||
api.statsResponse = ScenicQueueStatsData(queueCount: 5, avgWaitMin: 12.5, time: "2026-06-25 10:02")
|
||||
let viewModel = QueueManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
|
||||
|
||||
XCTAssertEqual(viewModel.selectedSpotId, 901)
|
||||
XCTAssertTrue(viewModel.queueGatePassed)
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1001, 1002])
|
||||
XCTAssertEqual(viewModel.queueCount, 5)
|
||||
XCTAssertEqual(viewModel.avgWaitMin, 12.5)
|
||||
XCTAssertEqual(viewModel.lastSyncTimeText, "2026-06-25 10:02")
|
||||
XCTAssertEqual(api.homeRequests.first?.type, QueueListType.queueing.rawValue)
|
||||
}
|
||||
|
||||
func testStatsFailureKeepsQueueList() async {
|
||||
saveSelectedSpot()
|
||||
let api = ScenicQueueMock()
|
||||
api.homeResponses = [ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001)], total: 1), time: "home-time")]
|
||||
api.statsError = QueueTestError.sample
|
||||
let viewModel = QueueManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
|
||||
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1001])
|
||||
XCTAssertEqual(viewModel.queueCount, 0)
|
||||
XCTAssertEqual(viewModel.lastSyncTimeText, "home-time")
|
||||
}
|
||||
|
||||
func testCallPassFinishRequeueAndUserMark() async throws {
|
||||
saveSelectedSpot()
|
||||
let api = ScenicQueueMock()
|
||||
api.homeResponses = Array(repeating: ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001)], total: 1)), count: 8)
|
||||
let viewModel = QueueManagementViewModel()
|
||||
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
|
||||
|
||||
try await viewModel.callQueue(api: api, id: 1001)
|
||||
XCTAssertEqual(api.callIds, [1001])
|
||||
XCTAssertEqual(viewModel.items.first?.isCalled, 1)
|
||||
|
||||
try await viewModel.passQueue(api: api, scenicId: 88, userId: "77", id: 1001)
|
||||
XCTAssertEqual(api.passIds, [1001])
|
||||
|
||||
try await viewModel.finishQueue(api: api, scenicId: 88, userId: "77", id: 1001)
|
||||
XCTAssertEqual(api.finishIds, [1001])
|
||||
|
||||
try await viewModel.requeue(api: api, scenicId: 88, userId: "77", id: 1001, operatorId: 77)
|
||||
XCTAssertEqual(api.requeueRequests.map(\.recordId), [1001])
|
||||
XCTAssertEqual(api.requeueRequests.map(\.operatorId), [77])
|
||||
XCTAssertEqual(viewModel.selectedListType, .queueing)
|
||||
|
||||
try await viewModel.userMark(
|
||||
api: api,
|
||||
scenicId: 88,
|
||||
userId: "77",
|
||||
request: ScenicQueueUserMarkRequest(uid: 7001, scenicId: 88, markAsFreelancePhotog: 1, queueBanDays: 14, operatorId: 77)
|
||||
)
|
||||
XCTAssertEqual(api.userMarkRequests.first?.uid, 7001)
|
||||
XCTAssertEqual(api.userMarkRequests.first?.queueBanDays, 14)
|
||||
}
|
||||
|
||||
func testSocketSubscriptionAndParse() {
|
||||
XCTAssertEqual(ScenicQueueSocketClient.subscriptionPayloads(scenicSpotId: 901), [
|
||||
#"{"type":306,"params":{"scenic_spot_id":901}}"#,
|
||||
#"{"type":307,"params":{"scenic_spot_id":901}}"#
|
||||
])
|
||||
|
||||
let message = ScenicQueueSocketClient.parseMessage("""
|
||||
{"code":100000,"data":{"action":"scenic_queue_ticket_called","params":{"scenic_spot_id":"901","record_id":"1002","operator_uid":"77","event_id":"evt-1"}}}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(message?.code, 100000)
|
||||
XCTAssertTrue(message?.isScenicQueueEvent == true)
|
||||
XCTAssertEqual(message?.data?.params?.scenicSpotId, 901)
|
||||
XCTAssertEqual(message?.data?.params?.recordId, 1002)
|
||||
XCTAssertEqual(message?.data?.params?.operatorUid, 77)
|
||||
XCTAssertEqual(message?.data?.params?.eventId, "evt-1")
|
||||
}
|
||||
}
|
||||
|
||||
private let sampleSpots = [
|
||||
ScenicSpotItem(id: 901, name: "东门打卡点"),
|
||||
ScenicSpotItem(id: 902, name: "西门打卡点")
|
||||
]
|
||||
|
||||
private func saveSelectedSpot() {
|
||||
ScenicQueueSettingsStore.saveSelectedSpot(id: 901, name: "东门打卡点", userId: "77", scenicId: 88)
|
||||
}
|
||||
|
||||
private func clearQueueDefaults() {
|
||||
let defaults = UserDefaults.standard
|
||||
[
|
||||
ScenicQueueLocalSettings.selectedSpotIdKey,
|
||||
ScenicQueueLocalSettings.selectedSpotNameKey,
|
||||
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: "77", scenicId: 88),
|
||||
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: "77", scenicId: 88),
|
||||
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: "77", scenicId: 88, spotId: 901)
|
||||
].forEach(defaults.removeObject)
|
||||
}
|
||||
|
||||
private func ticket(id: Int64, isCalled: Int = 0) -> ScenicQueueTicket {
|
||||
ScenicQueueTicket(
|
||||
id: id,
|
||||
queueCode: "A\(id)",
|
||||
mobile: "13800000000",
|
||||
status: 0,
|
||||
statusText: "",
|
||||
waitMin: 5,
|
||||
aheadCount: 1,
|
||||
isCalled: isCalled,
|
||||
createdAt: "2026-06-25 10:00:00",
|
||||
calledAt: "",
|
||||
expiredAt: "",
|
||||
finishedAt: "",
|
||||
queueCountToday: 2,
|
||||
uid: 7001
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ScenicQueueMock: ScenicQueueServing {
|
||||
var homeResponses: [ScenicQueueHomeData] = []
|
||||
var statsResponse = ScenicQueueStatsData()
|
||||
var statsError: Error?
|
||||
var settingResponse = ScenicQueueSettingData()
|
||||
var callIds: [Int64] = []
|
||||
var passIds: [Int64] = []
|
||||
var finishIds: [Int64] = []
|
||||
var requeueRequests: [(recordId: Int64, operatorId: Int)] = []
|
||||
var userMarkRequests: [ScenicQueueUserMarkRequest] = []
|
||||
var saveSettingRequests: [ScenicQueueSaveSettingRequest] = []
|
||||
var homeRequests: [(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int)] = []
|
||||
var statsRequests: [(scenicId: Int, scenicSpotId: Int)] = []
|
||||
var qrcodeURL = "https://example.com/qrcode.png"
|
||||
var logs = ScenicQueueSettingChangeLogData()
|
||||
|
||||
func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData {
|
||||
statsRequests.append((scenicId, scenicSpotId))
|
||||
if let statsError { throw statsError }
|
||||
return statsResponse
|
||||
}
|
||||
|
||||
func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData {
|
||||
homeRequests.append((scenicId, scenicSpotId, type, page, pageSize))
|
||||
return homeResponses.isEmpty ? ScenicQueueHomeData() : homeResponses.removeFirst()
|
||||
}
|
||||
|
||||
func socketToken() async throws -> SocketTokenResponse { SocketTokenResponse(socketToken: "token") }
|
||||
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData {
|
||||
callIds.append(id)
|
||||
return ScenicQueueCallData(id: id, status: 1, statusText: "已叫号")
|
||||
}
|
||||
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData {
|
||||
passIds.append(id)
|
||||
return ScenicQueuePassData(id: id)
|
||||
}
|
||||
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData {
|
||||
finishIds.append(id)
|
||||
return ScenicQueueFinishData(id: id)
|
||||
}
|
||||
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws {
|
||||
requeueRequests.append((recordId, operatorId))
|
||||
}
|
||||
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws {
|
||||
userMarkRequests.append(request)
|
||||
}
|
||||
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData { settingResponse }
|
||||
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws {
|
||||
saveSettingRequests.append(request)
|
||||
}
|
||||
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData {
|
||||
ScenicQueueShootQueueQRCodeData(qrcodeUrl: qrcodeURL)
|
||||
}
|
||||
func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData {
|
||||
logs
|
||||
}
|
||||
}
|
||||
|
||||
private enum QueueTestError: Error {
|
||||
case sample
|
||||
}
|
||||
@ -0,0 +1,182 @@
|
||||
//
|
||||
// ScenicQueueSettingsViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 排队设置测试,覆盖加载、保存和校验。
|
||||
final class ScenicQueueSettingsViewModelTests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
clearQueueSettingsDefaults()
|
||||
}
|
||||
|
||||
func testLoadSelectsFirstSpotAndAppliesLocalSnapshotWhenServerMissing() async {
|
||||
let snapshot = ScenicQueueSettingsSnapshot(
|
||||
shootMinute: 4,
|
||||
shootSecond: 15,
|
||||
firstAheadCount: 6,
|
||||
firstSms: false,
|
||||
firstPhone: true,
|
||||
secondAheadCount: 2,
|
||||
secondSms: true,
|
||||
secondPhone: false,
|
||||
broadcastIntervalSec: 55,
|
||||
countdownThresholdSec: 25,
|
||||
queueDistanceMeter: 8_880,
|
||||
queueTakeLimit: 11,
|
||||
missCallRequeueOffset: 5,
|
||||
showStartShootingButton: false,
|
||||
autoCallAheadCount: 3,
|
||||
quickCallButtonEnabled: true,
|
||||
prepareCallButtonEnabled: true,
|
||||
businessOpen: false,
|
||||
businessStartTime: "09:30",
|
||||
businessEndTime: "18:45"
|
||||
)
|
||||
ScenicQueueSettingsStore.saveSettingsSnapshot(snapshot, userId: "77", scenicId: 88, spotId: 901)
|
||||
let api = ScenicQueueMock()
|
||||
api.settingResponse = ScenicQueueSettingData(exists: false)
|
||||
let viewModel = ScenicQueueSettingsViewModel()
|
||||
|
||||
await viewModel.load(api: api, scenicId: 88, userId: "77", spots: settingsSpots)
|
||||
|
||||
XCTAssertEqual(viewModel.selectedSpotId, 901)
|
||||
XCTAssertEqual(viewModel.photoEstimateMin, "4")
|
||||
XCTAssertEqual(viewModel.photoEstimateSec, "15")
|
||||
XCTAssertEqual(viewModel.firstAhead, "6")
|
||||
XCTAssertTrue(viewModel.firstPhone)
|
||||
XCTAssertEqual(viewModel.secondAhead, "2")
|
||||
XCTAssertEqual(viewModel.broadcastIntervalSec, "55")
|
||||
XCTAssertEqual(viewModel.countdownThresholdSec, "25")
|
||||
XCTAssertEqual(viewModel.maxQueueRangeKm, "8.88")
|
||||
XCTAssertEqual(viewModel.localQueueLimit, "11")
|
||||
XCTAssertEqual(viewModel.localPassDelay, "5")
|
||||
XCTAssertFalse(viewModel.showStartShootingButton)
|
||||
XCTAssertEqual(viewModel.autoCallAheadCount, "3")
|
||||
XCTAssertTrue(viewModel.quickCallButtonEnabled)
|
||||
XCTAssertTrue(viewModel.prepareCallButtonEnabled)
|
||||
XCTAssertFalse(viewModel.queueOpen)
|
||||
}
|
||||
|
||||
func testSaveNormalizesBodyAndPersistsSnapshot() async throws {
|
||||
let api = ScenicQueueMock()
|
||||
let viewModel = ScenicQueueSettingsViewModel()
|
||||
viewModel.scenicSpots = settingsSpots
|
||||
viewModel.selectedSpotId = 901
|
||||
viewModel.photoEstimateMin = "1"
|
||||
viewModel.photoEstimateSec = "20"
|
||||
viewModel.firstAhead = "5"
|
||||
viewModel.secondAhead = "2"
|
||||
viewModel.broadcastIntervalSec = "45"
|
||||
viewModel.countdownThresholdSec = "20"
|
||||
viewModel.localQueueLimit = "7"
|
||||
viewModel.localPassDelay = "3"
|
||||
viewModel.showStartShootingButton = false
|
||||
viewModel.autoCallAheadCount = "4"
|
||||
viewModel.quickCallButtonEnabled = true
|
||||
viewModel.prepareCallButtonEnabled = true
|
||||
viewModel.queueOpen = false
|
||||
viewModel.customTtsText = " {number} 请到拍摄点 "
|
||||
viewModel.presetVoices = ["预设播报", " ", "第二条"]
|
||||
viewModel.businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 11, minute: 0)
|
||||
viewModel.businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 21, minute: 30)
|
||||
viewModel.updateMaxQueueRange("12.3")
|
||||
|
||||
try await viewModel.save(api: api, scenicId: 88, userId: "77")
|
||||
|
||||
let request = try XCTUnwrap(api.saveSettingRequests.first)
|
||||
XCTAssertEqual(request.scenicId, 88)
|
||||
XCTAssertEqual(request.scenicSpotId, 901)
|
||||
XCTAssertEqual(request.photoEstimateMin, 1)
|
||||
XCTAssertEqual(request.photoEstimateSec, 20)
|
||||
XCTAssertEqual(request.queueDistanceMeter, 12_300)
|
||||
XCTAssertEqual(request.queueTakeLimit, 7)
|
||||
XCTAssertEqual(request.missCallRequeueOffset, 3)
|
||||
XCTAssertEqual(request.showStartShootButton, 0)
|
||||
XCTAssertEqual(request.autoCallNextCount, 4)
|
||||
XCTAssertEqual(request.businessStartTime, "11:00")
|
||||
XCTAssertEqual(request.businessEndTime, "21:30")
|
||||
XCTAssertEqual(request.voiceBroadcasts.map(\.content), ["预设播报", "第二条"])
|
||||
XCTAssertEqual(request.status, 0)
|
||||
XCTAssertEqual(request.remark, "{number} 请到拍摄点")
|
||||
|
||||
XCTAssertEqual(ScenicQueueSettingsStore.selectedSpotId(userId: "77", scenicId: 88), 901)
|
||||
let snapshot = try XCTUnwrap(ScenicQueueSettingsStore.settingsSnapshot(userId: "77", scenicId: 88, spotId: 901))
|
||||
XCTAssertEqual(snapshot.queueDistanceMeter, 12_300)
|
||||
XCTAssertEqual(snapshot.autoCallAheadCount, 4)
|
||||
XCTAssertTrue(snapshot.quickCallButtonEnabled)
|
||||
}
|
||||
|
||||
func testValidationRejectsInvalidValues() async {
|
||||
let api = ScenicQueueMock()
|
||||
let viewModel = ScenicQueueSettingsViewModel()
|
||||
viewModel.selectedSpotId = 901
|
||||
viewModel.photoEstimateMin = "0"
|
||||
viewModel.photoEstimateSec = "0"
|
||||
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77"))
|
||||
|
||||
viewModel.photoEstimateSec = "20"
|
||||
viewModel.firstAhead = "1"
|
||||
viewModel.secondAhead = "2"
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77"))
|
||||
|
||||
viewModel.firstAhead = "2"
|
||||
viewModel.secondAhead = "1"
|
||||
viewModel.broadcastIntervalSec = "30"
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77"))
|
||||
}
|
||||
|
||||
func testFetchQRCodeRejectsEmptyURL() async {
|
||||
let api = ScenicQueueMock()
|
||||
api.qrcodeURL = ""
|
||||
let viewModel = ScenicQueueSettingsViewModel()
|
||||
viewModel.selectedSpotId = 901
|
||||
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.fetchQRCode(api: api, scenicId: 88))
|
||||
}
|
||||
}
|
||||
|
||||
private let settingsSpots = [
|
||||
ScenicSpotItem(id: 901, name: "东门打卡点"),
|
||||
ScenicSpotItem(id: 902, name: "西门打卡点")
|
||||
]
|
||||
|
||||
private func clearQueueSettingsDefaults() {
|
||||
let defaults = UserDefaults.standard
|
||||
[
|
||||
ScenicQueueLocalSettings.selectedSpotIdKey,
|
||||
ScenicQueueLocalSettings.selectedSpotNameKey,
|
||||
ScenicQueueLocalSettings.customTtsTextKey,
|
||||
ScenicQueueLocalSettings.photoEstimateSecondsKey,
|
||||
ScenicQueueLocalSettings.broadcastIntervalSecondsKey,
|
||||
ScenicQueueLocalSettings.countdownThresholdSecondsKey,
|
||||
ScenicQueueLocalSettings.showStartShootingButtonKey,
|
||||
ScenicQueueLocalSettings.autoCallAheadCountKey,
|
||||
ScenicQueueLocalSettings.quickCallButtonEnabledKey,
|
||||
ScenicQueueLocalSettings.prepareCallButtonEnabledKey,
|
||||
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: "77", scenicId: 88),
|
||||
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: "77", scenicId: 88),
|
||||
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: "77", scenicId: 88, spotId: 901),
|
||||
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.customTtsTextKey, userId: "77", scenicId: 88, spotId: 901),
|
||||
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.presetVoicesKey, userId: "77", scenicId: 88, spotId: 901)
|
||||
].forEach(defaults.removeObject)
|
||||
}
|
||||
|
||||
private func XCTAssertThrowsErrorAsync<T>(
|
||||
_ expression: @autoclosure () async throws -> T,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) async {
|
||||
do {
|
||||
try await expression()
|
||||
XCTFail("Expected error to be thrown", file: file, line: line)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
563
suixinkan_iosTests/ScenicPermissionViewModelTests.swift
Normal file
563
suixinkan_iosTests/ScenicPermissionViewModelTests.swift
Normal 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? {
|
||||
"测试错误"
|
||||
}
|
||||
}
|
||||
119
suixinkan_iosTests/ScenicSelectionViewModelTests.swift
Normal file
119
suixinkan_iosTests/ScenicSelectionViewModelTests.swift
Normal file
@ -0,0 +1,119 @@
|
||||
//
|
||||
// ScenicSelectionViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 景区选择 ViewModel 测试,覆盖搜索、定位和切换持久化。
|
||||
final class ScenicSelectionViewModelTests: XCTestCase {
|
||||
/// 测试当前景区会优先展示并标记选中。
|
||||
func testCurrentScenicMovesToFirstItem() {
|
||||
let context = AccountContext()
|
||||
context.replaceScopes(
|
||||
scenic: [
|
||||
BusinessScope(id: 1, name: "西湖景区", kind: .scenic),
|
||||
BusinessScope(id: 2, name: "东湖景区", kind: .scenic)
|
||||
],
|
||||
stores: [],
|
||||
currentScenicId: 2
|
||||
)
|
||||
let viewModel = ScenicSelectionViewModel()
|
||||
|
||||
viewModel.reload(from: context)
|
||||
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [2, 1])
|
||||
XCTAssertTrue(viewModel.items[0].isClosest)
|
||||
}
|
||||
|
||||
/// 测试搜索会按景区名称和地址过滤。
|
||||
func testSearchFiltersByNameAndAddress() {
|
||||
let viewModel = ScenicSelectionViewModel()
|
||||
let context = AccountContext()
|
||||
context.replaceScopes(
|
||||
scenic: [
|
||||
BusinessScope(id: 1, name: "西湖景区", kind: .scenic, address: "杭州"),
|
||||
BusinessScope(id: 2, name: "东湖景区", kind: .scenic, address: "武汉")
|
||||
],
|
||||
stores: []
|
||||
)
|
||||
viewModel.reload(from: context)
|
||||
|
||||
viewModel.searchQuery = "武汉"
|
||||
|
||||
XCTAssertEqual(viewModel.filteredItems.map(\.id), [2])
|
||||
}
|
||||
|
||||
/// 测试定位成功后会计算距离并标记最近景区。
|
||||
func testLocationSuccessMarksClosestScenic() {
|
||||
let context = AccountContext()
|
||||
context.replaceScopes(
|
||||
scenic: [
|
||||
BusinessScope(id: 1, name: "远景区", kind: .scenic, address: "远处", latitude: 31.0, longitude: 121.0),
|
||||
BusinessScope(id: 2, name: "近景区", kind: .scenic, address: "附近", latitude: 30.0001, longitude: 120.0001)
|
||||
],
|
||||
stores: []
|
||||
)
|
||||
let viewModel = ScenicSelectionViewModel()
|
||||
viewModel.reload(from: context)
|
||||
|
||||
viewModel.applyCurrentLocation(latitude: 30.0, longitude: 120.0)
|
||||
|
||||
XCTAssertEqual(viewModel.items.first(where: \.isClosest)?.id, 2)
|
||||
XCTAssertEqual(viewModel.currentLocationText, "附近")
|
||||
XCTAssertNotNil(viewModel.items.first(where: { $0.id == 2 })?.distanceMeters)
|
||||
}
|
||||
|
||||
/// 测试定位失败时保留列表并展示提示。
|
||||
func testLocationFailureKeepsItemsAndWarning() {
|
||||
let context = AccountContext()
|
||||
context.replaceScopes(scenic: [BusinessScope(id: 1, name: "西湖景区", kind: .scenic, address: "杭州")], stores: [])
|
||||
let viewModel = ScenicSelectionViewModel()
|
||||
viewModel.reload(from: context)
|
||||
|
||||
viewModel.applyLocationFailure("定位权限未开启")
|
||||
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1])
|
||||
XCTAssertEqual(viewModel.locationWarning, "定位权限未开启")
|
||||
}
|
||||
|
||||
/// 测试切换景区会联动门店并保存账号快照。
|
||||
func testSelectScenicUpdatesStoreAndPersistsSnapshot() {
|
||||
let suiteName = "ScenicSelectionViewModelTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let snapshotStore = AccountSnapshotStore(defaults: defaults)
|
||||
let accountContext = AccountContext()
|
||||
let permissionContext = PermissionContext()
|
||||
accountContext.replaceScopes(
|
||||
scenic: [
|
||||
BusinessScope(id: 1, name: "西湖景区", kind: .scenic),
|
||||
BusinessScope(id: 2, name: "东湖景区", kind: .scenic)
|
||||
],
|
||||
stores: [
|
||||
BusinessScope(id: 10, name: "西湖门店", kind: .store, parentScenicId: 1),
|
||||
BusinessScope(id: 20, name: "东湖门店", kind: .store, parentScenicId: 2)
|
||||
],
|
||||
currentScenicId: 1,
|
||||
currentStoreId: 10
|
||||
)
|
||||
permissionContext.replaceRolePermissions([RolePermissionResponse(role: RoleInfo(id: 7, name: "运营"), scenic: [])])
|
||||
|
||||
ScenicSelectionViewModel().select(
|
||||
scenicId: 2,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
snapshotStore: snapshotStore
|
||||
)
|
||||
|
||||
XCTAssertEqual(accountContext.currentScenic?.id, 2)
|
||||
XCTAssertEqual(accountContext.currentStore?.id, 20)
|
||||
XCTAssertEqual(snapshotStore.load()?.currentScenicId, 2)
|
||||
XCTAssertEqual(snapshotStore.load()?.currentStoreId, 20)
|
||||
XCTAssertEqual(snapshotStore.load()?.currentRoleId, 7)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,212 @@
|
||||
//
|
||||
// ScenicSettlementViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 景区结算测试,覆盖结算申请和审核记录聚合。
|
||||
final class ScenicSettlementViewModelTests: XCTestCase {
|
||||
/// 测试已有景区会从可申请结算景区中排除。
|
||||
func testSettlementLoadExcludesExistingScenics() async {
|
||||
let permissionAPI = ScenicSettlementPermissionMock()
|
||||
permissionAPI.scenicList = ScenicListAllResponse(total: 3, list: [
|
||||
ScenicListItem(id: 1, name: "已有景区"),
|
||||
ScenicListItem(id: 2, name: "新增景区"),
|
||||
ScenicListItem(id: 3, name: "另一个景区")
|
||||
])
|
||||
let viewModel = ScenicSettlementViewModel()
|
||||
|
||||
await viewModel.load(
|
||||
api: permissionAPI,
|
||||
existingScenics: [BusinessScope(id: 1, name: "已有景区", kind: .scenic)]
|
||||
)
|
||||
|
||||
XCTAssertEqual(viewModel.options.map(\.id), [2, 3])
|
||||
}
|
||||
|
||||
/// 测试金额校验覆盖空值、0、负数、超过两位小数和有效金额。
|
||||
func testSettlementAmountValidation() {
|
||||
let viewModel = ScenicSettlementViewModel()
|
||||
|
||||
XCTAssertEqual(viewModel.amountValidation, .empty)
|
||||
|
||||
viewModel.amountText = "0"
|
||||
XCTAssertEqual(viewModel.amountValidation, .notPositive)
|
||||
|
||||
viewModel.amountText = "-1"
|
||||
XCTAssertEqual(viewModel.amountValidation, .invalidFormat)
|
||||
|
||||
viewModel.amountText = "1.234"
|
||||
XCTAssertEqual(viewModel.amountValidation, .invalidFormat)
|
||||
|
||||
viewModel.amountText = "12.30"
|
||||
XCTAssertEqual(viewModel.amountValidation, .valid)
|
||||
}
|
||||
|
||||
/// 测试多景区提交 body 正确且成功后清空表单。
|
||||
func testSettlementSubmitMultipleScenicsAndClearsForm() async {
|
||||
let submitAPI = ScenicSettlementSubmitMock()
|
||||
let viewModel = ScenicSettlementViewModel()
|
||||
viewModel.options = [
|
||||
ScenicSettlementOption(id: 3, name: "三号", selected: false),
|
||||
ScenicSettlementOption(id: 2, name: "二号", selected: false)
|
||||
]
|
||||
viewModel.toggleScenic(id: 3)
|
||||
viewModel.toggleScenic(id: 2)
|
||||
viewModel.amountText = "99"
|
||||
viewModel.remarkText = " 活动结算 "
|
||||
|
||||
let success = await viewModel.submit(api: submitAPI)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(submitAPI.requests, [
|
||||
ScenicSettlementSubmitRequest(scenicId: 2, applyAmount: "99.00", applyRemark: "活动结算"),
|
||||
ScenicSettlementSubmitRequest(scenicId: 3, applyAmount: "99.00", applyRemark: "活动结算")
|
||||
])
|
||||
XCTAssertTrue(viewModel.selectedScenicIds.isEmpty)
|
||||
XCTAssertEqual(viewModel.amountText, "")
|
||||
XCTAssertEqual(viewModel.remarkText, "")
|
||||
XCTAssertEqual(viewModel.message, "提交成功,等待审核")
|
||||
}
|
||||
|
||||
/// 测试任一提交失败会保留当前表单并透出错误。
|
||||
func testSettlementSubmitFailureKeepsForm() async {
|
||||
let submitAPI = ScenicSettlementSubmitMock()
|
||||
submitAPI.errorAfterRequestCount = 1
|
||||
let viewModel = ScenicSettlementViewModel()
|
||||
viewModel.options = [
|
||||
ScenicSettlementOption(id: 1, name: "一号", selected: false),
|
||||
ScenicSettlementOption(id: 2, name: "二号", selected: false)
|
||||
]
|
||||
viewModel.toggleScenic(id: 1)
|
||||
viewModel.toggleScenic(id: 2)
|
||||
viewModel.amountText = "10"
|
||||
|
||||
let success = await viewModel.submit(api: submitAPI)
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertEqual(viewModel.selectedScenicIds, [1, 2])
|
||||
XCTAssertEqual(viewModel.amountText, "10")
|
||||
XCTAssertEqual(viewModel.message, TestError.sample.localizedDescription)
|
||||
}
|
||||
|
||||
/// 测试审核记录两个通道都成功时汇总正确。
|
||||
func testReviewLoadSuccessAggregatesRecords() async {
|
||||
let permissionAPI = ScenicSettlementPermissionMock()
|
||||
permissionAPI.scenicPendings = ScenicApplicationPendingsResponse(items: [
|
||||
ScenicApplicationPendingResponse(id: 1, scenicName: "景区", status: 1)
|
||||
])
|
||||
permissionAPI.roleApplies = [
|
||||
RoleApplyPendingResponse(id: 2, roleId: 1, roleName: "摄影师", status: 2)
|
||||
]
|
||||
let viewModel = ScenicSettlementReviewViewModel()
|
||||
|
||||
await viewModel.load(api: permissionAPI)
|
||||
|
||||
XCTAssertEqual(viewModel.scenicApplications.count, 1)
|
||||
XCTAssertEqual(viewModel.roleApplications.count, 1)
|
||||
XCTAssertEqual(viewModel.pendingCount, 1)
|
||||
XCTAssertFalse(viewModel.loadFailedAll)
|
||||
}
|
||||
|
||||
/// 测试单通道失败时保留另一通道数据。
|
||||
func testReviewSingleChannelFailureKeepsOtherChannel() async {
|
||||
let permissionAPI = ScenicSettlementPermissionMock()
|
||||
permissionAPI.scenicPendingsError = TestError.sample
|
||||
permissionAPI.roleApplies = [
|
||||
RoleApplyPendingResponse(id: 2, roleId: 1, roleName: "摄影师", status: 1)
|
||||
]
|
||||
let viewModel = ScenicSettlementReviewViewModel()
|
||||
|
||||
await viewModel.load(api: permissionAPI)
|
||||
|
||||
XCTAssertTrue(viewModel.scenicLoadFailed)
|
||||
XCTAssertFalse(viewModel.roleLoadFailed)
|
||||
XCTAssertFalse(viewModel.loadFailedAll)
|
||||
XCTAssertEqual(viewModel.roleApplications.count, 1)
|
||||
}
|
||||
|
||||
/// 测试双通道失败时进入整页失败态。
|
||||
func testReviewBothChannelsFailureMarksFullFailure() async {
|
||||
let permissionAPI = ScenicSettlementPermissionMock()
|
||||
permissionAPI.scenicPendingsError = TestError.sample
|
||||
permissionAPI.roleAppliesError = TestError.sample
|
||||
let viewModel = ScenicSettlementReviewViewModel()
|
||||
|
||||
await viewModel.load(api: permissionAPI)
|
||||
|
||||
XCTAssertTrue(viewModel.loadFailedAll)
|
||||
XCTAssertTrue(viewModel.scenicApplications.isEmpty)
|
||||
XCTAssertTrue(viewModel.roleApplications.isEmpty)
|
||||
}
|
||||
|
||||
/// 测试审核状态文案和语义映射。
|
||||
func testReviewStatusMapping() {
|
||||
let viewModel = ScenicSettlementReviewViewModel()
|
||||
|
||||
XCTAssertEqual(viewModel.statusText(1), "待审核")
|
||||
XCTAssertEqual(viewModel.statusText(2), "已通过")
|
||||
XCTAssertEqual(viewModel.statusText(3), "已驳回")
|
||||
XCTAssertEqual(viewModel.statusText(9), "已取消")
|
||||
XCTAssertEqual(viewModel.statusText(99), "未知")
|
||||
XCTAssertEqual(viewModel.statusIcon(2), "checkmark.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ScenicSettlementPermissionMock: ScenicPermissionServing {
|
||||
var scenicList = ScenicListAllResponse(total: 0, list: [])
|
||||
var scenicListError: Error?
|
||||
var scenicPendings = ScenicApplicationPendingsResponse()
|
||||
var scenicPendingsError: Error?
|
||||
var roleApplies: [RoleApplyPendingResponse] = []
|
||||
var roleAppliesError: Error?
|
||||
|
||||
func scenicListAll() async throws -> ScenicListAllResponse {
|
||||
if let scenicListError { throw scenicListError }
|
||||
return scenicList
|
||||
}
|
||||
|
||||
func areas() async throws -> [ScenicAreaNode] { [] }
|
||||
|
||||
func scenicApplicationPendingAll() async throws -> ScenicApplicationPendingsResponse {
|
||||
if let scenicPendingsError { throw scenicPendingsError }
|
||||
return scenicPendings
|
||||
}
|
||||
|
||||
func scenicSubmit(_ request: ScenicApplicationSubmitRequest) async throws {}
|
||||
func scenicApplicationUploadPlaceholder(_ items: [ScenicApplicationUploadPlaceholder]) async throws {}
|
||||
|
||||
func roleApplyAll() async throws -> [RoleApplyPendingResponse] {
|
||||
if let roleAppliesError { throw roleAppliesError }
|
||||
return roleApplies
|
||||
}
|
||||
|
||||
func roleApplySubmit(roleId: Int, scenicIds: [Int]) async throws {}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ScenicSettlementSubmitMock: ScenicSettlementServing {
|
||||
var requests: [ScenicSettlementSubmitRequest] = []
|
||||
var errorAfterRequestCount: Int?
|
||||
|
||||
func scenicSettlementSubmit(_ request: ScenicSettlementSubmitRequest) async throws {
|
||||
requests.append(request)
|
||||
if let errorAfterRequestCount, requests.count >= errorAfterRequestCount {
|
||||
throw TestError.sample
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum TestError: LocalizedError {
|
||||
case sample
|
||||
|
||||
var errorDescription: String? {
|
||||
"测试错误"
|
||||
}
|
||||
}
|
||||
76
suixinkan_iosTests/ScenicSpotContextTests.swift
Normal file
76
suixinkan_iosTests/ScenicSpotContextTests.swift
Normal file
@ -0,0 +1,76 @@
|
||||
//
|
||||
// ScenicSpotContextTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 景点上下文测试,覆盖按景区懒加载和失败隔离。
|
||||
final class ScenicSpotContextTests: XCTestCase {
|
||||
/// 测试景点列表会按当前景区 ID 请求并写入上下文。
|
||||
func testReloadRequestsSpotsForScenicId() async {
|
||||
let context = ScenicSpotContext()
|
||||
let api = MockScenicSpotAPI()
|
||||
api.spotResponse = ListPayload(total: 1, list: [ScenicSpotItem(id: 9, name: "观景台")])
|
||||
|
||||
await context.reload(scenicId: 88, api: api)
|
||||
|
||||
XCTAssertEqual(api.requestedScenicIds, [88])
|
||||
XCTAssertEqual(context.scenicId, 88)
|
||||
XCTAssertEqual(context.spots.map(\.id), [9])
|
||||
XCTAssertEqual(context.loadState, .loaded)
|
||||
}
|
||||
|
||||
/// 测试景点接口失败时只标记景点模块失败。
|
||||
func testReloadFailureOnlyAffectsScenicSpotContext() async {
|
||||
let context = ScenicSpotContext()
|
||||
let api = MockScenicSpotAPI()
|
||||
api.error = APIError.networkFailed("景点接口失败")
|
||||
|
||||
await context.reload(scenicId: 88, api: api)
|
||||
|
||||
XCTAssertEqual(api.requestedScenicIds, [88])
|
||||
XCTAssertTrue(context.spots.isEmpty)
|
||||
if case .failed = context.loadState {
|
||||
XCTAssertTrue(true)
|
||||
} else {
|
||||
XCTFail("景点接口失败时应进入 failed 状态")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 景点服务测试替身,记录请求过的景区 ID。
|
||||
private final class MockScenicSpotAPI: AccountContextServing {
|
||||
var requestedScenicIds: [Int] = []
|
||||
var spotResponse = ListPayload(total: 0, list: [ScenicSpotItem]())
|
||||
var error: Error?
|
||||
|
||||
/// 获取空角色权限,当前测试不使用。
|
||||
func rolePermissions() async throws -> [RolePermissionResponse] {
|
||||
[]
|
||||
}
|
||||
|
||||
/// 获取空景区列表,当前测试不使用。
|
||||
func scenicListAll() async throws -> ScenicListAllResponse {
|
||||
ScenicListAllResponse(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 获取空门店列表,当前测试不使用。
|
||||
func storeAll() async throws -> ListPayload<StoreItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 获取测试景点列表,并记录景区 ID。
|
||||
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem> {
|
||||
requestedScenicIds.append(scenicId)
|
||||
if let error {
|
||||
throw error
|
||||
}
|
||||
return spotResponse
|
||||
}
|
||||
}
|
||||
184
suixinkan_iosTests/Schedule/ScheduleTests.swift
Normal file
184
suixinkan_iosTests/Schedule/ScheduleTests.swift
Normal file
@ -0,0 +1,184 @@
|
||||
//
|
||||
// ScheduleTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 排班 API 和 ViewModel 测试。
|
||||
final class ScheduleTests: XCTestCase {
|
||||
/// 测试排班 API path 和 query/body。
|
||||
func testScheduleAPIUsesExpectedRequests() async throws {
|
||||
let session = ScheduleRecordingURLSession(responses: [
|
||||
Data(#"{"code":100000,"message":"ok","data":["2026-06-24"]}"#.utf8),
|
||||
Data(#"{"code":100000,"message":"ok","data":[{"id":"1","name":"拍摄","start_time":"09:00","end_time":"10:00","schedule_date":"2026-06-24"}]}"#.utf8),
|
||||
Data(#"{"code":100000,"message":"ok","data":{}}"#.utf8),
|
||||
Data(#"{"code":100000,"message":"ok","data":{}}"#.utf8)
|
||||
])
|
||||
let api = ScheduleAPI(client: APIClient(session: session))
|
||||
|
||||
let days = try await api.monthScheduleDays(scenicId: 9, yearMonth: "2026-06")
|
||||
let list = try await api.dayScheduleList(scenicId: 9, date: "2026-06-24")
|
||||
try await api.addSchedule(AddScheduleRequest(scenicId: "9", name: "拍摄", remark: "备注", startTime: "09:00", endTime: "10:00", scheduleDate: "2026-06-24", orderNumber: "ORD001"))
|
||||
try await api.deleteSchedule(id: 1)
|
||||
|
||||
XCTAssertEqual(days, ["2026-06-24"])
|
||||
XCTAssertEqual(list.first?.id, 1)
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/schedule/list-date",
|
||||
"/api/yf-handset-app/photog/schedule/list",
|
||||
"/api/yf-handset-app/photog/schedule/add",
|
||||
"/api/yf-handset-app/photog/schedule/delete"
|
||||
])
|
||||
XCTAssertEqual(scheduleQueryItems(from: session.requests[0])["date"], "2026-06")
|
||||
XCTAssertEqual(scheduleQueryItems(from: session.requests[1])["date"], "2026-06-24")
|
||||
XCTAssertEqual((try scheduleBodyObject(from: session.requests[2]))["order_number"] as? String, "ORD001")
|
||||
XCTAssertEqual((try scheduleBodyObject(from: session.requests[3]))["id"] as? Int, 1)
|
||||
}
|
||||
|
||||
/// 测试无景区时排班管理清空状态且不请求接口。
|
||||
func testScheduleManagementWithoutScenicClearsState() async {
|
||||
let api = FakeScheduleService()
|
||||
let viewModel = ScheduleManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: nil)
|
||||
|
||||
XCTAssertTrue(viewModel.items.isEmpty)
|
||||
XCTAssertTrue(viewModel.markedDays.isEmpty)
|
||||
XCTAssertEqual(api.monthCalls.count, 0)
|
||||
}
|
||||
|
||||
/// 测试排班管理加载月份和当天列表。
|
||||
func testScheduleManagementReloadLoadsMonthAndDay() async {
|
||||
let api = FakeScheduleService()
|
||||
api.monthDays = ["2026-06-24"]
|
||||
api.dayItems = [.fixture(id: 1)]
|
||||
let viewModel = ScheduleManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: 9)
|
||||
|
||||
XCTAssertEqual(api.monthCalls.first?.scenicId, 9)
|
||||
XCTAssertEqual(api.dayCalls.first?.scenicId, 9)
|
||||
XCTAssertEqual(viewModel.markedDays, ["2026-06-24"])
|
||||
XCTAssertEqual(viewModel.items.first?.id, 1)
|
||||
}
|
||||
|
||||
/// 测试新增排班优先使用选择的订单号。
|
||||
func testScheduleAddUsesSelectedOrderBeforeManualOrder() async {
|
||||
let api = FakeScheduleService()
|
||||
let viewModel = ScheduleAddViewModel()
|
||||
viewModel.draft.name = "拍摄"
|
||||
viewModel.draft.remark = ""
|
||||
viewModel.draft.manualOrderNumber = "MANUAL"
|
||||
viewModel.draft.selectedOrder = AvailableOrderResponse.fixture(orderNumber: "PICKED")
|
||||
let start = fixedDate(hour: 9)
|
||||
let end = fixedDate(hour: 10)
|
||||
|
||||
let success = await viewModel.submit(api: api, scenicId: 9, startDate: start, endDate: end)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(api.addedRequests.first?.orderNumber, "PICKED")
|
||||
XCTAssertEqual(api.addedRequests.first?.remark, "拍摄")
|
||||
}
|
||||
|
||||
/// 测试结束时间早于开始时间时禁止提交。
|
||||
func testScheduleAddRejectsInvalidTime() async {
|
||||
let api = FakeScheduleService()
|
||||
let viewModel = ScheduleAddViewModel()
|
||||
viewModel.draft.name = "拍摄"
|
||||
|
||||
let success = await viewModel.submit(api: api, scenicId: 9, startDate: fixedDate(hour: 10), endDate: fixedDate(hour: 9))
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertTrue(api.addedRequests.isEmpty)
|
||||
XCTAssertEqual(viewModel.errorMessage, ScheduleValidationError.invalidTime.localizedDescription)
|
||||
}
|
||||
|
||||
private func fixedDate(hour: Int) -> Date {
|
||||
Calendar.current.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: hour, minute: 0)) ?? Date()
|
||||
}
|
||||
}
|
||||
|
||||
private extension ScheduleItem {
|
||||
/// 创建排班测试项。
|
||||
static func fixture(id: Int) -> ScheduleItem {
|
||||
let data = Data(#"{"id":\#(id),"name":"拍摄","start_time":"09:00","end_time":"10:00","schedule_date":"2026-06-24"}"#.utf8)
|
||||
return try! JSONDecoder().decode(ScheduleItem.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AvailableOrderResponse {
|
||||
/// 创建可关联订单测试项。
|
||||
static func fixture(orderNumber: String) -> AvailableOrderResponse {
|
||||
let data = Data(#"{"project_name":"项目","order_number":"\#(orderNumber)","order_status":"1","order_status_label":"已支付","pay_time":"2026-06-24","user_phone":"13800000000"}"#.utf8)
|
||||
return try! JSONDecoder().decode(AvailableOrderResponse.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// 排班 API 测试 URLSession。
|
||||
private final class ScheduleRecordingURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 初始化测试 Session。
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 记录请求并返回响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.removeFirst()
|
||||
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
|
||||
}
|
||||
}
|
||||
|
||||
/// 排班服务测试替身。
|
||||
@MainActor
|
||||
private final class FakeScheduleService: ScheduleServing {
|
||||
var monthDays: [String] = []
|
||||
var dayItems: [ScheduleItem] = []
|
||||
var monthCalls: [(scenicId: Int, yearMonth: String)] = []
|
||||
var dayCalls: [(scenicId: Int, date: String)] = []
|
||||
var addedRequests: [AddScheduleRequest] = []
|
||||
|
||||
func monthScheduleDays(scenicId: Int, yearMonth: String) async throws -> [String] {
|
||||
monthCalls.append((scenicId, yearMonth))
|
||||
return monthDays
|
||||
}
|
||||
|
||||
func dayScheduleList(scenicId: Int, date: String) async throws -> [ScheduleItem] {
|
||||
dayCalls.append((scenicId, date))
|
||||
return dayItems
|
||||
}
|
||||
|
||||
func addSchedule(_ request: AddScheduleRequest) async throws {
|
||||
addedRequests.append(request)
|
||||
}
|
||||
|
||||
func deleteSchedule(id: Int) async throws {}
|
||||
|
||||
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求中提取 query 字典。
|
||||
private func scheduleQueryItems(from request: URLRequest) -> [String: String] {
|
||||
guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
item.value.map { (item.name, $0) }
|
||||
})
|
||||
}
|
||||
|
||||
/// 将请求体解析为字典。
|
||||
private func scheduleBodyObject(from request: URLRequest) throws -> [String: Any] {
|
||||
let body = try XCTUnwrap(request.httpBody)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
}
|
||||
238
suixinkan_iosTests/StatisticsViewModelTests.swift
Normal file
238
suixinkan_iosTests/StatisticsViewModelTests.swift
Normal file
@ -0,0 +1,238 @@
|
||||
//
|
||||
// StatisticsViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 数据统计 ViewModel 测试,覆盖汇总、日数据、分页和角色接口选择。
|
||||
final class StatisticsViewModelTests: XCTestCase {
|
||||
/// 测试首屏会加载汇总和第一页日数据。
|
||||
func testReloadLoadsSummaryAndDailyItems() async throws {
|
||||
let api = MockStatisticsService()
|
||||
api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")]
|
||||
api.dailyResponses = [try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3")]
|
||||
let viewModel = StatisticsViewModel()
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: 88, roleId: nil)
|
||||
|
||||
XCTAssertEqual(viewModel.summary.orderCount, 18)
|
||||
XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"])
|
||||
XCTAssertEqual(viewModel.totalDailyCount, 3)
|
||||
XCTAssertTrue(viewModel.hasMore)
|
||||
XCTAssertFalse(viewModel.loading)
|
||||
XCTAssertNotNil(viewModel.lastRefreshAt)
|
||||
XCTAssertEqual(api.summaryCalls.map(\.range), ["1"])
|
||||
XCTAssertEqual(api.dailyCalls.map(\.page), [1])
|
||||
}
|
||||
|
||||
/// 测试切换时间段会重置分页并使用对应 range。
|
||||
func testSelectPeriodResetsPaginationAndUsesExpectedRange() async throws {
|
||||
let api = MockStatisticsService()
|
||||
api.summaryResponses = [
|
||||
try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success"),
|
||||
try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")
|
||||
]
|
||||
api.dailyResponses = [
|
||||
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3"),
|
||||
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page2_total3"),
|
||||
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3")
|
||||
]
|
||||
let viewModel = StatisticsViewModel()
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: 88, roleId: nil)
|
||||
try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil)
|
||||
XCTAssertEqual(viewModel.dailyItems.count, 3)
|
||||
|
||||
try await viewModel.selectPeriod(.sevenDays, api: api, scenicId: 88, roleId: nil)
|
||||
|
||||
XCTAssertEqual(viewModel.selectedPeriod, .sevenDays)
|
||||
XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"])
|
||||
XCTAssertEqual(viewModel.totalDailyCount, 3)
|
||||
XCTAssertTrue(viewModel.hasMore)
|
||||
XCTAssertEqual(api.summaryCalls.last?.range, "3")
|
||||
XCTAssertEqual(api.dailyCalls.last?.page, 1)
|
||||
XCTAssertFalse(api.dailyCalls.last?.startTime.isEmpty ?? true)
|
||||
XCTAssertFalse(api.dailyCalls.last?.endTime.isEmpty ?? true)
|
||||
}
|
||||
|
||||
/// 测试加载更多会追加日数据。
|
||||
func testLoadMoreAppendsDailyItems() async throws {
|
||||
let api = MockStatisticsService()
|
||||
api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")]
|
||||
api.dailyResponses = [
|
||||
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3"),
|
||||
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page2_total3")
|
||||
]
|
||||
let viewModel = StatisticsViewModel()
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: 88, roleId: nil)
|
||||
try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil)
|
||||
|
||||
XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23", "2026-05-22", "2026-05-21"])
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
XCTAssertFalse(viewModel.loadingMore)
|
||||
XCTAssertEqual(api.dailyCalls.map(\.page), [1, 2])
|
||||
}
|
||||
|
||||
/// 测试加载更多失败时保留已有数据和下一页能力。
|
||||
func testLoadMoreFailureKeepsCurrentItems() async throws {
|
||||
let api = MockStatisticsService()
|
||||
api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")]
|
||||
api.dailyResponses = [try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3")]
|
||||
let viewModel = StatisticsViewModel()
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: 88, roleId: nil)
|
||||
api.nextDailyError = APIError.httpStatus(500, "server error")
|
||||
|
||||
do {
|
||||
try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil)
|
||||
XCTFail("Expected load more to fail")
|
||||
} catch APIError.httpStatus(let statusCode, _) {
|
||||
XCTAssertEqual(statusCode, 500)
|
||||
} catch {
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"])
|
||||
XCTAssertTrue(viewModel.hasMore)
|
||||
XCTAssertFalse(viewModel.loadingMore)
|
||||
}
|
||||
|
||||
/// 测试无景区时清空旧统计状态。
|
||||
func testReloadWithoutScenicClearsStaleStatisticsState() async throws {
|
||||
let api = MockStatisticsService()
|
||||
api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")]
|
||||
api.dailyResponses = [try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3")]
|
||||
let viewModel = StatisticsViewModel()
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: 88, roleId: nil)
|
||||
XCTAssertEqual(viewModel.summary.orderCount, 18)
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: nil, roleId: nil)
|
||||
|
||||
XCTAssertEqual(api.summaryCalls.count, 1)
|
||||
XCTAssertEqual(viewModel.summary.orderCount, 0)
|
||||
XCTAssertTrue(viewModel.dailyItems.isEmpty)
|
||||
XCTAssertEqual(viewModel.totalDailyCount, 0)
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
XCTAssertNil(viewModel.lastRefreshAt)
|
||||
}
|
||||
|
||||
/// 测试景区管理员角色使用管理员统计接口。
|
||||
func testStatisticsAPIUsesAdminEndpointForScenicAdminRole() async throws {
|
||||
let session = SequencedURLSession(responses: [
|
||||
try TestFixture.data(named: "statistics_summary_success"),
|
||||
try TestFixture.data(named: "statistics_daily_page1_total3")
|
||||
])
|
||||
let api = StatisticsAPI(client: APIClient(session: session))
|
||||
|
||||
_ = try await api.summary(scenicId: 88, range: "1", isScenicAdmin: true)
|
||||
_ = try await api.dailyList(
|
||||
scenicId: 88,
|
||||
startTime: "2026-05-01",
|
||||
endTime: "2026-05-31",
|
||||
page: 2,
|
||||
pageSize: 15,
|
||||
isScenicAdmin: true
|
||||
)
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/scenic-admin/analyse", "/api/app/scenic-admin/analyse/daily"])
|
||||
let dailyQuery = queryItems(from: try XCTUnwrap(session.requests.last))
|
||||
XCTAssertEqual(dailyQuery["scenic_id"], "88")
|
||||
XCTAssertEqual(dailyQuery["page"], "2")
|
||||
XCTAssertEqual(dailyQuery["start_time"], "2026-05-01")
|
||||
XCTAssertEqual(dailyQuery["end_time"], "2026-05-31")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 数据统计服务测试替身,记录请求参数并返回预设响应。
|
||||
private final class MockStatisticsService: StatisticsServing {
|
||||
struct SummaryCall: Equatable {
|
||||
let scenicId: Int
|
||||
let range: String
|
||||
let isScenicAdmin: Bool
|
||||
}
|
||||
|
||||
struct DailyCall: Equatable {
|
||||
let scenicId: Int
|
||||
let startTime: String
|
||||
let endTime: String
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
let isScenicAdmin: Bool
|
||||
}
|
||||
|
||||
var summaryResponses: [StatisticsSummaryResponse] = []
|
||||
var dailyResponses: [DataListPayload<StatisticsDailyItem>] = []
|
||||
var nextDailyError: Error?
|
||||
private(set) var summaryCalls: [SummaryCall] = []
|
||||
private(set) var dailyCalls: [DailyCall] = []
|
||||
|
||||
/// 返回测试汇总数据。
|
||||
func summary(scenicId: Int, range: String, isScenicAdmin: Bool) async throws -> StatisticsSummaryResponse {
|
||||
summaryCalls.append(SummaryCall(scenicId: scenicId, range: range, isScenicAdmin: isScenicAdmin))
|
||||
return summaryResponses.isEmpty ? StatisticsSummaryResponse() : summaryResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 返回测试每日明细数据。
|
||||
func dailyList(
|
||||
scenicId: Int,
|
||||
startTime: String,
|
||||
endTime: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
isScenicAdmin: Bool
|
||||
) async throws -> DataListPayload<StatisticsDailyItem> {
|
||||
dailyCalls.append(
|
||||
DailyCall(
|
||||
scenicId: scenicId,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
isScenicAdmin: isScenicAdmin
|
||||
)
|
||||
)
|
||||
if let nextDailyError {
|
||||
self.nextDailyError = nil
|
||||
throw nextDailyError
|
||||
}
|
||||
return dailyResponses.isEmpty ? DataListPayload(total: 0, data: []) : dailyResponses.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
/// URLSession 顺序响应测试替身。
|
||||
private final class SequencedURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 依次返回预设响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.isEmpty ? Data() : responses.removeFirst()
|
||||
return (
|
||||
data,
|
||||
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func queryItems(from request: URLRequest) -> [String: String] {
|
||||
guard let url = request.url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
item.value.map { (item.name, $0) }
|
||||
})
|
||||
}
|
||||
60
suixinkan_iosTests/StorageTests.swift
Normal file
60
suixinkan_iosTests/StorageTests.swift
Normal file
@ -0,0 +1,60 @@
|
||||
//
|
||||
// StorageTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
/// 本地缓存 Store 测试,覆盖 UserDefaults 中的偏好和账号快照。
|
||||
final class StorageTests: XCTestCase {
|
||||
/// 测试 AppPreferencesStore 能保存并读取上次登录手机号和协议状态。
|
||||
func testAppPreferencesStorePersistsLoginPreferences() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
let store = AppPreferencesStore(defaults: defaults)
|
||||
|
||||
store.saveLastLoginUsername(" 18651857230 ")
|
||||
store.savePrivacyAgreementAccepted(true)
|
||||
|
||||
XCTAssertEqual(store.loadLastLoginUsername(), "18651857230")
|
||||
XCTAssertTrue(store.loadPrivacyAgreementAccepted())
|
||||
}
|
||||
|
||||
/// 测试账号快照能保存并恢复账号资料、业务作用域和当前选择。
|
||||
func testAccountSnapshotStorePersistsAccountContextSnapshot() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
let store = AccountSnapshotStore(defaults: defaults)
|
||||
let snapshot = AccountSnapshot(
|
||||
profile: AccountProfile(
|
||||
userId: "101",
|
||||
displayName: "测试账号",
|
||||
phone: "18651857230",
|
||||
avatarURL: "https://cdn.example.com/avatar.jpg"
|
||||
),
|
||||
accountType: "store_user",
|
||||
businessUserId: 101,
|
||||
scenicScopes: [
|
||||
BusinessScope(id: 88, name: "东门景区", kind: .scenic)
|
||||
],
|
||||
storeScopes: [
|
||||
BusinessScope(id: 66, name: "东门门店", kind: .store)
|
||||
],
|
||||
currentScenicId: 88,
|
||||
currentStoreId: 66
|
||||
)
|
||||
|
||||
store.save(snapshot)
|
||||
|
||||
XCTAssertEqual(store.load(), snapshot)
|
||||
}
|
||||
|
||||
/// 创建独立 UserDefaults,避免测试污染真实 App 偏好。
|
||||
private func makeIsolatedDefaults() -> UserDefaults {
|
||||
let suiteName = "suixinkan.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
145
suixinkan_iosTests/TaskAPITests.swift
Normal file
145
suixinkan_iosTests/TaskAPITests.swift
Normal file
@ -0,0 +1,145 @@
|
||||
//
|
||||
// TaskAPITests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 任务 API 测试,覆盖请求参数、请求体和模型解码。
|
||||
final class TaskAPITests: XCTestCase {
|
||||
/// 测试任务列表接口使用正确 path 和 query。
|
||||
func testTaskListUsesExpectedPathAndQuery() async throws {
|
||||
let session = TaskRecordingURLSession(data: try TestFixture.data(named: "task_list_success"))
|
||||
let api = TaskAPI(client: APIClient(session: session))
|
||||
|
||||
_ = try await api.taskList(
|
||||
scenicId: 88,
|
||||
page: 0,
|
||||
pageSize: 0,
|
||||
taskStatus: -2,
|
||||
taskName: " 精修 ",
|
||||
startTime: "2026-06-01",
|
||||
endTime: "2026-06-30"
|
||||
)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/task/list")
|
||||
let query = taskQueryItems(from: request)
|
||||
XCTAssertEqual(query["scenic_id"], "88")
|
||||
XCTAssertEqual(query["page"], "1")
|
||||
XCTAssertEqual(query["page_size"], "1")
|
||||
XCTAssertEqual(query["task_status"], "0")
|
||||
XCTAssertEqual(query["task_name"], "精修")
|
||||
XCTAssertEqual(query["start_time"], "2026-06-01")
|
||||
XCTAssertEqual(query["end_time"], "2026-06-30")
|
||||
}
|
||||
|
||||
/// 测试任务详情接口使用正确 path 和 id。
|
||||
func testTaskDetailUsesExpectedPath() async throws {
|
||||
let session = TaskRecordingURLSession(data: try TestFixture.data(named: "task_detail_success"))
|
||||
let api = TaskAPI(client: APIClient(session: session))
|
||||
|
||||
let detail = try await api.taskDetail(id: 101)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/task/info")
|
||||
XCTAssertEqual(taskQueryItems(from: request)["id"], "101")
|
||||
XCTAssertEqual(detail.urgentHour, 24)
|
||||
XCTAssertEqual(detail.taskResult.first?.isVideo, true)
|
||||
}
|
||||
|
||||
/// 测试可关联订单接口使用正确景区参数。
|
||||
func testAvailableOrderUsesScenicId() async throws {
|
||||
let session = TaskRecordingURLSession(data: try TestFixture.data(named: "available_order_success"))
|
||||
let api = TaskAPI(client: APIClient(session: session))
|
||||
|
||||
let orders = try await api.availableOrderList(scenicId: 88)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/task/available-order")
|
||||
XCTAssertEqual(taskQueryItems(from: request)["scenic_id"], "88")
|
||||
XCTAssertEqual(orders.first?.orderNumber, "ORD-TASK-001")
|
||||
}
|
||||
|
||||
/// 测试发布任务接口提交正确 body。
|
||||
func testAddTaskUsesExpectedBody() async throws {
|
||||
let session = TaskRecordingURLSession(data: try TestFixture.data(named: "empty_success"))
|
||||
let api = TaskAPI(client: APIClient(session: session))
|
||||
let request = AddTaskRequest(
|
||||
scenicId: "88",
|
||||
name: "任务",
|
||||
orderNumber: "ORD001",
|
||||
remark: "备注",
|
||||
urgentHour: 12,
|
||||
cloudFile: [CloudFileItem(fileId: 1, remark: "")],
|
||||
uploadFile: [UploadFileItem(fileUrl: "https://cdn.example.com/a.jpg", fileName: "a.jpg", remark: "")]
|
||||
)
|
||||
|
||||
try await api.addTask(request)
|
||||
|
||||
let urlRequest = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(urlRequest.url?.path, "/api/yf-handset-app/photog/task/create")
|
||||
let body = try XCTUnwrap(urlRequest.httpBody)
|
||||
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
XCTAssertEqual(object["scenic_id"] as? String, "88")
|
||||
XCTAssertEqual(object["name"] as? String, "任务")
|
||||
XCTAssertEqual(object["order_number"] as? String, "ORD001")
|
||||
XCTAssertEqual(object["photog_remark"] as? String, "备注")
|
||||
XCTAssertEqual(object["urgent_hour"] as? Int, 12)
|
||||
XCTAssertEqual((object["cloud_file"] as? [[String: Any]])?.first?["file_id"] as? Int, 1)
|
||||
XCTAssertEqual((object["upload_file"] as? [[String: Any]])?.first?["file_url"] as? String, "https://cdn.example.com/a.jpg")
|
||||
}
|
||||
|
||||
/// 测试云盘文件列表接口使用正确 path 和 query。
|
||||
func testCloudFileListUsesExpectedQuery() async throws {
|
||||
let session = TaskRecordingURLSession(data: try TestFixture.data(named: "cloud_file_list_success"))
|
||||
let api = TaskAPI(client: APIClient(session: session))
|
||||
|
||||
let payload = try await api.cloudFileList(parentFolderId: 501, name: "photo", type: 2, orderBy: 2, page: 0, pageSize: 0)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/cloud-driver/list")
|
||||
let query = taskQueryItems(from: request)
|
||||
XCTAssertEqual(query["parent_folder_id"], "501")
|
||||
XCTAssertEqual(query["name"], "photo")
|
||||
XCTAssertEqual(query["type"], "2")
|
||||
XCTAssertEqual(query["page"], "1")
|
||||
XCTAssertEqual(query["page_size"], "1")
|
||||
XCTAssertEqual(payload.list.first?.isFolder, true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务 API 测试用 URLSession,记录请求并返回固定数据。
|
||||
private final class TaskRecordingURLSession: URLSessionProtocol {
|
||||
let data: Data
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 初始化测试 Session。
|
||||
init(data: Data) {
|
||||
self.data = data
|
||||
}
|
||||
|
||||
/// 记录请求并返回成功响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
return (data, response)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求中提取 query 字典。
|
||||
private func taskQueryItems(from request: URLRequest) -> [String: String] {
|
||||
guard
|
||||
let url = request.url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
item.value.map { (item.name, $0) }
|
||||
})
|
||||
}
|
||||
516
suixinkan_iosTests/TaskViewModelTests.swift
Normal file
516
suixinkan_iosTests/TaskViewModelTests.swift
Normal file
@ -0,0 +1,516 @@
|
||||
//
|
||||
// TaskViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 任务 ViewModel 测试,覆盖列表、发布任务和云盘选择逻辑。
|
||||
final class TaskViewModelTests: XCTestCase {
|
||||
/// 测试无景区时清空任务列表且不请求接口。
|
||||
func testTaskManagementClearsWithoutScenic() async throws {
|
||||
let api = TaskMockService()
|
||||
api.taskListResponses = [try TestFixture.payload(ListPayload<PhotographerTaskItem>.self, named: "task_list_success")]
|
||||
let viewModel = TaskManagementViewModel()
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: 88)
|
||||
XCTAssertFalse(viewModel.tasks.isEmpty)
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: nil)
|
||||
|
||||
XCTAssertTrue(viewModel.tasks.isEmpty)
|
||||
XCTAssertEqual(viewModel.total, 0)
|
||||
XCTAssertEqual(viewModel.page, 1)
|
||||
XCTAssertEqual(api.taskListCalls.count, 1)
|
||||
XCTAssertEqual(viewModel.errorMessage, "请先选择景区")
|
||||
}
|
||||
|
||||
/// 测试任务列表首屏和加载更多会更新分页。
|
||||
func testTaskManagementReloadAndLoadMore() async throws {
|
||||
let api = TaskMockService()
|
||||
api.taskListResponses = [
|
||||
try TestFixture.payload(ListPayload<PhotographerTaskItem>.self, named: "task_list_success"),
|
||||
try TestFixture.payload(ListPayload<PhotographerTaskItem>.self, named: "task_list_page2_success")
|
||||
]
|
||||
let viewModel = TaskManagementViewModel()
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: 88)
|
||||
viewModel.total = 3
|
||||
try await viewModel.loadMore(api: api, scenicId: 88)
|
||||
|
||||
XCTAssertEqual(viewModel.tasks.count, 3)
|
||||
XCTAssertEqual(viewModel.page, 2)
|
||||
XCTAssertEqual(api.taskListCalls.map(\.page), [1, 2])
|
||||
}
|
||||
|
||||
/// 测试筛选参数会传给任务服务层。
|
||||
func testTaskManagementUsesFilters() async throws {
|
||||
let api = TaskMockService()
|
||||
api.taskListResponses = [try TestFixture.payload(ListPayload<PhotographerTaskItem>.self, named: "task_list_success")]
|
||||
let viewModel = TaskManagementViewModel()
|
||||
viewModel.selectedStatus = .accepted
|
||||
viewModel.searchText = " 亲子 "
|
||||
viewModel.startDate = Self.date("2026-06-01")
|
||||
viewModel.endDate = Self.date("2026-06-30")
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: 88)
|
||||
|
||||
let call = try XCTUnwrap(api.taskListCalls.first)
|
||||
XCTAssertEqual(call.taskStatus, TaskStatusFilter.accepted.rawValue)
|
||||
XCTAssertEqual(call.taskName, "亲子")
|
||||
XCTAssertEqual(call.startTime, "2026-06-01")
|
||||
XCTAssertEqual(call.endTime, "2026-06-30")
|
||||
}
|
||||
|
||||
/// 测试任务详情加载成功后保存详情。
|
||||
func testTaskDetailLoadsDetail() async throws {
|
||||
let api = TaskMockService()
|
||||
api.detailResponse = try TestFixture.payload(TaskDetailResponse.self, named: "task_detail_success")
|
||||
let viewModel = TaskDetailViewModel()
|
||||
|
||||
await viewModel.load(api: api, taskId: 101)
|
||||
|
||||
XCTAssertEqual(api.detailIds, [101])
|
||||
XCTAssertEqual(viewModel.detail?.name, "游客精修任务")
|
||||
XCTAssertNil(viewModel.errorMessage)
|
||||
}
|
||||
|
||||
/// 测试发布任务校验必填项和景区。
|
||||
func testTaskCreateValidation() async throws {
|
||||
let api = TaskMockService()
|
||||
let upload = TaskMockUploadService()
|
||||
let viewModel = TaskCreateViewModel()
|
||||
|
||||
let noScenic = try await viewModel.submit(api: api, uploadService: upload, scenicId: nil)
|
||||
XCTAssertFalse(noScenic)
|
||||
XCTAssertEqual(viewModel.errorMessage, "请先选择景区")
|
||||
|
||||
viewModel.taskName = "任务"
|
||||
let noRemark = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
|
||||
XCTAssertFalse(noRemark)
|
||||
XCTAssertEqual(viewModel.errorMessage, "请输入任务备注")
|
||||
}
|
||||
|
||||
/// 测试发布任务会把云盘文件和本地上传文件写入请求。
|
||||
func testTaskCreateUploadsLocalFilesBeforeSubmit() async throws {
|
||||
let api = TaskMockService()
|
||||
let upload = TaskMockUploadService()
|
||||
upload.taskUploadURLs = ["https://cdn.example.com/task/a.jpg"]
|
||||
let viewModel = TaskCreateViewModel()
|
||||
viewModel.taskName = "任务"
|
||||
viewModel.remark = "备注"
|
||||
viewModel.urgentHourText = "6"
|
||||
viewModel.mergeCloudFiles([TaskCloudSelectionItem(id: 501, fileName: "cloud.jpg", fileType: 2, remark: "")])
|
||||
viewModel.addLocalFile(data: Data([1, 2, 3]), fileName: "local.jpg")
|
||||
|
||||
let success = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(upload.taskUploads.count, 1)
|
||||
let request = try XCTUnwrap(api.addTaskRequests.first)
|
||||
XCTAssertEqual(request.scenicId, "88")
|
||||
XCTAssertEqual(request.name, "任务")
|
||||
XCTAssertEqual(request.cloudFile.first?.fileId, 501)
|
||||
XCTAssertEqual(request.uploadFile.first?.fileUrl, "https://cdn.example.com/task/a.jpg")
|
||||
XCTAssertTrue(viewModel.selectedLocalFiles.isEmpty)
|
||||
}
|
||||
|
||||
/// 测试本地文件上传失败时不会提交发布任务。
|
||||
func testTaskCreateUploadFailureStopsSubmit() async {
|
||||
let api = TaskMockService()
|
||||
let upload = TaskMockUploadService()
|
||||
upload.uploadError = APIError.networkFailed("上传失败")
|
||||
let viewModel = TaskCreateViewModel()
|
||||
viewModel.taskName = "任务"
|
||||
viewModel.remark = "备注"
|
||||
viewModel.addLocalFile(data: Data([1]), fileName: "local.jpg")
|
||||
|
||||
do {
|
||||
_ = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
|
||||
XCTFail("Expected upload failure")
|
||||
} catch APIError.networkFailed(let message) {
|
||||
XCTAssertEqual(message, "上传失败")
|
||||
} catch {
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
XCTAssertTrue(api.addTaskRequests.isEmpty)
|
||||
XCTAssertEqual(viewModel.selectedLocalFiles.first?.errorMessage, "网络请求失败:上传失败")
|
||||
}
|
||||
|
||||
/// 测试云盘选择器进入文件夹、分页、选择文件和忽略文件夹选择。
|
||||
func testCloudSelectionNavigationAndSelection() async throws {
|
||||
let api = TaskMockService()
|
||||
let firstPage = try TestFixture.payload(ListPayload<CloudDriveFile>.self, named: "cloud_file_list_success")
|
||||
let secondPage = ListPayload(total: 3, list: [firstPage.list[1]])
|
||||
api.cloudFileResponses = [firstPage, secondPage, firstPage]
|
||||
let viewModel = TaskCloudFileSelectionViewModel()
|
||||
|
||||
try await viewModel.reload(api: api)
|
||||
viewModel.total = 3
|
||||
try await viewModel.loadMore(api: api)
|
||||
XCTAssertEqual(viewModel.files.count, 3)
|
||||
|
||||
let folder = firstPage.list[0]
|
||||
try await viewModel.enterFolder(folder, api: api)
|
||||
XCTAssertEqual(viewModel.currentFolderId, folder.id)
|
||||
|
||||
viewModel.toggleSelection(folder)
|
||||
XCTAssertTrue(viewModel.selectedFiles.isEmpty)
|
||||
|
||||
let file = firstPage.list[1]
|
||||
viewModel.toggleSelection(file)
|
||||
XCTAssertEqual(viewModel.makeSelectionItems().first?.id, file.id)
|
||||
XCTAssertEqual(api.cloudFileCalls.map(\.page), [1, 2, 1])
|
||||
}
|
||||
|
||||
/// 测试发布任务会加载可关联订单,无景区时清空列表。
|
||||
func testTaskCreateLoadAvailableOrders() async throws {
|
||||
let api = TaskMockService()
|
||||
api.availableOrders = try TestFixture.payload([AvailableOrderResponse].self, named: "available_order_success")
|
||||
let viewModel = TaskCreateViewModel()
|
||||
|
||||
await viewModel.loadAvailableOrders(api: api, scenicId: nil)
|
||||
XCTAssertTrue(viewModel.availableOrders.isEmpty)
|
||||
XCTAssertTrue(api.availableOrderScenicIds.isEmpty)
|
||||
|
||||
await viewModel.loadAvailableOrders(api: api, scenicId: 88)
|
||||
XCTAssertEqual(api.availableOrderScenicIds, [88])
|
||||
XCTAssertEqual(viewModel.availableOrders.count, 1)
|
||||
XCTAssertFalse(viewModel.isLoadingOrders)
|
||||
}
|
||||
|
||||
/// 测试加载可关联订单失败时会清空列表并提示错误。
|
||||
func testTaskCreateLoadAvailableOrdersFailure() async {
|
||||
let api = TaskMockService()
|
||||
api.availableOrderError = APIError.networkFailed("订单加载失败")
|
||||
let viewModel = TaskCreateViewModel()
|
||||
|
||||
await viewModel.loadAvailableOrders(api: api, scenicId: 88)
|
||||
|
||||
XCTAssertTrue(viewModel.availableOrders.isEmpty)
|
||||
XCTAssertEqual(viewModel.errorMessage, "网络请求失败:订单加载失败")
|
||||
}
|
||||
|
||||
/// 测试上传中的本地附件会阻止提交。
|
||||
func testTaskCreateCanSubmitBlocksWhileUploading() {
|
||||
let viewModel = TaskCreateViewModel()
|
||||
viewModel.taskName = "任务"
|
||||
viewModel.remark = "备注"
|
||||
viewModel.selectedLocalFiles = [
|
||||
TaskLocalUploadItem(
|
||||
id: UUID(),
|
||||
data: Data([1]),
|
||||
fileName: "local.jpg",
|
||||
fileType: 2,
|
||||
remark: "",
|
||||
uploadedURL: nil,
|
||||
progress: 50,
|
||||
errorMessage: nil
|
||||
)
|
||||
]
|
||||
|
||||
XCTAssertFalse(viewModel.canSubmit)
|
||||
XCTAssertTrue(viewModel.selectedLocalFiles.first?.isUploading == true)
|
||||
XCTAssertEqual(viewModel.selectedLocalFiles.first?.statusText, "上传中 50%")
|
||||
}
|
||||
|
||||
/// 测试云盘附件合并会去重,移除接口能删除指定文件。
|
||||
func testTaskCreateMergeAndRemoveAttachments() {
|
||||
let viewModel = TaskCreateViewModel()
|
||||
let file = TaskCloudSelectionItem(id: 501, fileName: "cloud.jpg", fileType: 2, remark: "备注")
|
||||
viewModel.mergeCloudFiles([file, file])
|
||||
viewModel.addLocalFile(data: Data([1]), fileName: "photo.jpg")
|
||||
guard let localID = viewModel.selectedLocalFiles.first?.id else {
|
||||
return XCTFail("应存在本地附件")
|
||||
}
|
||||
|
||||
XCTAssertEqual(viewModel.selectedCloudFiles.count, 1)
|
||||
viewModel.removeCloudFile(id: 501)
|
||||
viewModel.removeLocalFile(id: localID)
|
||||
XCTAssertTrue(viewModel.selectedCloudFiles.isEmpty)
|
||||
XCTAssertTrue(viewModel.selectedLocalFiles.isEmpty)
|
||||
}
|
||||
|
||||
/// 测试关联订单和紧急小时会写入发布任务请求。
|
||||
func testTaskCreateSubmitIncludesSelectedOrderAndUrgentHour() async throws {
|
||||
let api = TaskMockService()
|
||||
let upload = TaskMockUploadService()
|
||||
let viewModel = TaskCreateViewModel()
|
||||
viewModel.taskName = " 任务 "
|
||||
viewModel.remark = "备注"
|
||||
viewModel.urgentHourText = " 12 "
|
||||
viewModel.selectOrder(taskAvailableOrder(orderNumber: "ORD-1001"))
|
||||
|
||||
let success = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
let request = try XCTUnwrap(api.addTaskRequests.first)
|
||||
XCTAssertEqual(request.name, "任务")
|
||||
XCTAssertEqual(request.orderNumber, "ORD-1001")
|
||||
XCTAssertEqual(request.urgentHour, 12)
|
||||
}
|
||||
|
||||
/// 测试任务名称和紧急小时校验失败时不会提交。
|
||||
func testTaskCreateValidationCoversTaskNameAndUrgentHour() async throws {
|
||||
let api = TaskMockService()
|
||||
let upload = TaskMockUploadService()
|
||||
let viewModel = TaskCreateViewModel()
|
||||
viewModel.remark = "备注"
|
||||
|
||||
let missingName = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
|
||||
XCTAssertFalse(missingName)
|
||||
XCTAssertEqual(viewModel.errorMessage, "请输入任务名称")
|
||||
|
||||
viewModel.taskName = "任务"
|
||||
viewModel.urgentHourText = "abc"
|
||||
let invalidHour = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
|
||||
XCTAssertFalse(invalidHour)
|
||||
XCTAssertEqual(viewModel.errorMessage, "请输入正确的紧急小时")
|
||||
}
|
||||
|
||||
/// 测试本地视频附件会识别为视频类型。
|
||||
func testTaskCreateDetectsVideoFileType() {
|
||||
let viewModel = TaskCreateViewModel()
|
||||
viewModel.addLocalFile(data: Data([1]), fileName: "clip.mp4")
|
||||
|
||||
XCTAssertEqual(viewModel.selectedLocalFiles.first?.fileType, 1)
|
||||
}
|
||||
|
||||
/// 测试云盘选择器支持返回上级目录、搜索筛选和取消选择。
|
||||
func testCloudSelectionPopToFolderSearchAndDeselect() async throws {
|
||||
let api = TaskMockService()
|
||||
let firstPage = try TestFixture.payload(ListPayload<CloudDriveFile>.self, named: "cloud_file_list_success")
|
||||
api.cloudFileResponses = [firstPage, firstPage]
|
||||
let viewModel = TaskCloudFileSelectionViewModel()
|
||||
let folder = firstPage.list[0]
|
||||
let file = firstPage.list[1]
|
||||
|
||||
try await viewModel.enterFolder(folder, api: api)
|
||||
viewModel.searchText = " photo "
|
||||
viewModel.selectedFilter = .image
|
||||
try await viewModel.reload(api: api)
|
||||
|
||||
let call = try XCTUnwrap(api.cloudFileCalls.last)
|
||||
XCTAssertEqual(call.parentFolderId, folder.id)
|
||||
XCTAssertEqual(call.name, "photo")
|
||||
XCTAssertEqual(call.type, TaskCloudFileFilter.image.rawValue)
|
||||
|
||||
viewModel.toggleSelection(file)
|
||||
XCTAssertTrue(viewModel.isSelected(file))
|
||||
viewModel.toggleSelection(file)
|
||||
XCTAssertFalse(viewModel.isSelected(file))
|
||||
|
||||
try await viewModel.popToFolder(at: 0, api: api)
|
||||
XCTAssertEqual(viewModel.currentFolderId, 0)
|
||||
}
|
||||
|
||||
/// 测试云盘列表加载失败时会清空状态并抛出错误。
|
||||
func testCloudSelectionReloadFailureClearsState() async {
|
||||
let api = TaskMockService()
|
||||
api.cloudFileError = APIError.networkFailed("云盘失败")
|
||||
let viewModel = TaskCloudFileSelectionViewModel()
|
||||
viewModel.files = [CloudDriveFile(id: 1, name: "占位")]
|
||||
viewModel.total = 5
|
||||
viewModel.page = 2
|
||||
|
||||
do {
|
||||
try await viewModel.reload(api: api)
|
||||
XCTFail("加载失败时应抛出错误")
|
||||
} catch APIError.networkFailed(let message) {
|
||||
XCTAssertEqual(message, "云盘失败")
|
||||
} catch {
|
||||
XCTFail("意外错误: \(error)")
|
||||
}
|
||||
|
||||
XCTAssertTrue(viewModel.files.isEmpty)
|
||||
XCTAssertEqual(viewModel.total, 0)
|
||||
XCTAssertEqual(viewModel.page, 1)
|
||||
XCTAssertEqual(viewModel.errorMessage, "网络请求失败:云盘失败")
|
||||
}
|
||||
|
||||
/// 测试云盘没有更多数据时不会继续分页。
|
||||
func testCloudSelectionLoadMoreStopsWhenNoMore() async throws {
|
||||
let api = TaskMockService()
|
||||
let page = try TestFixture.payload(ListPayload<CloudDriveFile>.self, named: "cloud_file_list_success")
|
||||
api.cloudFileResponses = [page]
|
||||
let viewModel = TaskCloudFileSelectionViewModel()
|
||||
|
||||
try await viewModel.reload(api: api)
|
||||
viewModel.total = viewModel.files.count
|
||||
try await viewModel.loadMore(api: api)
|
||||
|
||||
XCTAssertEqual(api.cloudFileCalls.count, 1)
|
||||
XCTAssertFalse(viewModel.isLoadingMore)
|
||||
}
|
||||
|
||||
/// 构造固定日期。
|
||||
private static func date(_ text: String) -> Date {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.date(from: text)!
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 任务服务测试替身。
|
||||
private final class TaskMockService: TaskServing {
|
||||
struct TaskListCall {
|
||||
let scenicId: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
let taskStatus: Int
|
||||
let taskName: String?
|
||||
let startTime: String?
|
||||
let endTime: String?
|
||||
}
|
||||
|
||||
struct CloudFileCall {
|
||||
let parentFolderId: Int
|
||||
let name: String
|
||||
let type: Int
|
||||
let orderBy: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
}
|
||||
|
||||
var taskListResponses: [ListPayload<PhotographerTaskItem>] = []
|
||||
var detailResponse: TaskDetailResponse?
|
||||
var availableOrders: [AvailableOrderResponse] = []
|
||||
var availableOrderError: Error?
|
||||
var cloudFileResponses: [ListPayload<CloudDriveFile>] = []
|
||||
var cloudFileError: Error?
|
||||
private(set) var taskListCalls: [TaskListCall] = []
|
||||
private(set) var detailIds: [Int] = []
|
||||
private(set) var availableOrderScenicIds: [Int] = []
|
||||
private(set) var addTaskRequests: [AddTaskRequest] = []
|
||||
private(set) var cloudFileCalls: [CloudFileCall] = []
|
||||
|
||||
/// 记录任务列表请求并返回下一份响应。
|
||||
func taskList(
|
||||
scenicId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
taskStatus: Int,
|
||||
taskName: String?,
|
||||
startTime: String?,
|
||||
endTime: String?
|
||||
) async throws -> ListPayload<PhotographerTaskItem> {
|
||||
taskListCalls.append(
|
||||
TaskListCall(
|
||||
scenicId: scenicId,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
taskStatus: taskStatus,
|
||||
taskName: taskName,
|
||||
startTime: startTime,
|
||||
endTime: endTime
|
||||
)
|
||||
)
|
||||
return taskListResponses.isEmpty ? ListPayload(total: 0, list: []) : taskListResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 记录任务详情请求并返回响应。
|
||||
func taskDetail(id: Int) async throws -> TaskDetailResponse {
|
||||
detailIds.append(id)
|
||||
return detailResponse!
|
||||
}
|
||||
|
||||
/// 记录可关联订单请求并返回响应。
|
||||
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] {
|
||||
availableOrderScenicIds.append(scenicId)
|
||||
if let availableOrderError { throw availableOrderError }
|
||||
return availableOrders
|
||||
}
|
||||
|
||||
/// 记录发布任务请求。
|
||||
func addTask(_ request: AddTaskRequest) async throws {
|
||||
addTaskRequests.append(request)
|
||||
}
|
||||
|
||||
/// 记录云盘文件请求并返回下一份响应。
|
||||
func cloudFileList(
|
||||
parentFolderId: Int,
|
||||
name: String,
|
||||
type: Int,
|
||||
orderBy: Int,
|
||||
page: Int,
|
||||
pageSize: Int
|
||||
) async throws -> ListPayload<CloudDriveFile> {
|
||||
cloudFileCalls.append(
|
||||
CloudFileCall(
|
||||
parentFolderId: parentFolderId,
|
||||
name: name,
|
||||
type: type,
|
||||
orderBy: orderBy,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
)
|
||||
)
|
||||
if let cloudFileError { throw cloudFileError }
|
||||
return cloudFileResponses.isEmpty ? ListPayload(total: 0, list: []) : cloudFileResponses.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造可关联订单测试数据。
|
||||
private func taskAvailableOrder(orderNumber: String) -> AvailableOrderResponse {
|
||||
let data = Data(#"{"project_name":"项目","order_number":"\#(orderNumber)","order_status":"1","order_status_label":"已支付","pay_time":"2026-06-24","user_phone":"13800000000"}"#.utf8)
|
||||
return try! JSONDecoder().decode(AvailableOrderResponse.self, from: data)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// OSS 上传服务测试替身。
|
||||
private final class TaskMockUploadService: OSSUploadServing {
|
||||
struct TaskUpload {
|
||||
let data: Data
|
||||
let fileName: String
|
||||
let fileType: Int
|
||||
let scenicId: Int
|
||||
}
|
||||
|
||||
var taskUploadURLs: [String] = []
|
||||
var uploadError: Error?
|
||||
private(set) var taskUploads: [TaskUpload] = []
|
||||
|
||||
/// 模拟上传任务附件。
|
||||
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
taskUploads.append(TaskUpload(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId))
|
||||
onProgress(50)
|
||||
if let uploadError {
|
||||
throw uploadError
|
||||
}
|
||||
onProgress(100)
|
||||
return taskUploadURLs.isEmpty ? "https://cdn.example.com/task/default.jpg" : taskUploadURLs.removeFirst()
|
||||
}
|
||||
|
||||
/// 模拟上传项目图片。
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 模拟上传头像。
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 模拟上传实名认证图片。
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 模拟上传云盘文件。
|
||||
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 模拟上传相册文件。
|
||||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 模拟上传打卡点图片。
|
||||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 模拟上传景区申请图片。
|
||||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 模拟上传银行卡照片。
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
}
|
||||
68
suixinkan_iosTests/TestFixture.swift
Normal file
68
suixinkan_iosTests/TestFixture.swift
Normal file
@ -0,0 +1,68 @@
|
||||
//
|
||||
// TestFixture.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@testable import suixinkan_ios
|
||||
|
||||
/// 测试 Fixture 读取工具,用于复用旧工程迁移过来的 JSON 响应。
|
||||
enum TestFixture {
|
||||
/// 读取指定名称的 JSON fixture。
|
||||
static func data(named name: String, filePath: String = #filePath) throws -> Data {
|
||||
var directory = URL(fileURLWithPath: filePath).deletingLastPathComponent()
|
||||
while directory.path != "/" {
|
||||
let url = directory
|
||||
.appendingPathComponent("Fixtures")
|
||||
.appendingPathComponent("\(name).json")
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
directory.deleteLastPathComponent()
|
||||
}
|
||||
let fallback = URL(fileURLWithPath: filePath)
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Fixtures")
|
||||
.appendingPathComponent("\(name).json")
|
||||
return try Data(contentsOf: fallback)
|
||||
}
|
||||
|
||||
/// 读取并解包后端统一 Envelope。
|
||||
static func payload<T: Decodable>(_ type: T.Type, named name: String) throws -> T {
|
||||
let envelope = try JSONDecoder().decode(APIEnvelope<T>.self, from: data(named: name))
|
||||
guard let payload = envelope.data else {
|
||||
throw FixtureError.emptyPayload
|
||||
}
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 Fixture 错误实体。
|
||||
enum FixtureError: Error {
|
||||
case emptyPayload
|
||||
}
|
||||
|
||||
extension OSSUploadServing {
|
||||
/// 旧上传替身默认不关心直播相册入口;Live 专用测试会覆盖此方法记录调用。
|
||||
func uploadAliveAlbumFile(
|
||||
data: Data,
|
||||
fileName: String,
|
||||
fileType: Int,
|
||||
scenicId: Int,
|
||||
onProgress: @escaping (Int) -> Void
|
||||
) async throws -> String {
|
||||
""
|
||||
}
|
||||
|
||||
/// 旧上传替身默认不关心飞手认证入口;飞手认证专用测试会覆盖此方法记录调用。
|
||||
func uploadPilotCertificateImage(
|
||||
data: Data,
|
||||
fileName: String,
|
||||
scenicId: Int,
|
||||
onProgress: @escaping (Int) -> Void
|
||||
) async throws -> String {
|
||||
""
|
||||
}
|
||||
}
|
||||
60
suixinkan_iosTests/ToastCenterTests.swift
Normal file
60
suixinkan_iosTests/ToastCenterTests.swift
Normal file
@ -0,0 +1,60 @@
|
||||
//
|
||||
// ToastCenterTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 全局 Toast 状态中心测试,覆盖自动隐藏和连续展示行为。
|
||||
final class ToastCenterTests: XCTestCase {
|
||||
/// 测试 show 会立即写入当前 Toast 文案。
|
||||
func testShowStoresMessage() {
|
||||
let center = ToastCenter(autoDismissNanoseconds: 100_000_000)
|
||||
|
||||
center.show("保存成功")
|
||||
|
||||
XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: "保存成功"))
|
||||
}
|
||||
|
||||
/// 测试 Toast 到达自动隐藏时间后会清空文案。
|
||||
func testToastAutoDismissesAfterDelay() async throws {
|
||||
let center = ToastCenter(autoDismissNanoseconds: 60_000_000)
|
||||
|
||||
center.show("保存成功")
|
||||
try await Task.sleep(nanoseconds: 90_000_000)
|
||||
|
||||
XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil))
|
||||
}
|
||||
|
||||
/// 测试连续 show 会覆盖旧文案,并重新计算自动隐藏时间。
|
||||
func testRepeatedShowResetsAutoDismissTimer() async throws {
|
||||
let center = ToastCenter(autoDismissNanoseconds: 100_000_000)
|
||||
|
||||
center.show("第一条")
|
||||
try await Task.sleep(nanoseconds: 70_000_000)
|
||||
center.show("第二条")
|
||||
try await Task.sleep(nanoseconds: 60_000_000)
|
||||
|
||||
XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: "第二条"))
|
||||
|
||||
try await Task.sleep(nanoseconds: 70_000_000)
|
||||
XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil))
|
||||
}
|
||||
|
||||
/// 测试手动 dismiss 会立即清空文案,并取消当前自动隐藏任务。
|
||||
func testDismissClearsMessageImmediately() async throws {
|
||||
let center = ToastCenter(autoDismissNanoseconds: 100_000_000)
|
||||
|
||||
center.show("保存成功")
|
||||
center.dismiss()
|
||||
|
||||
XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil))
|
||||
|
||||
try await Task.sleep(nanoseconds: 130_000_000)
|
||||
XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil))
|
||||
}
|
||||
}
|
||||
125
suixinkan_iosTests/UploadTests.swift
Normal file
125
suixinkan_iosTests/UploadTests.swift
Normal file
@ -0,0 +1,125 @@
|
||||
//
|
||||
// UploadTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 上传模块测试,覆盖 OSS 上传策略和 STS 配置接口。
|
||||
final class UploadTests: XCTestCase {
|
||||
/// 测试文件校验能识别空文件、超大文件和不支持扩展名。
|
||||
func testOSSUploadPolicyValidatesFileConstraints() {
|
||||
XCTAssertThrowsError(try OSSUploadPolicy.validate(dataSize: 0, fileName: "avatar.jpg")) { error in
|
||||
XCTAssertEqual(error as? OSSUploadError, .emptyFile)
|
||||
}
|
||||
XCTAssertThrowsError(try OSSUploadPolicy.validate(dataSize: OSSUploadPolicy.maxFileSize + 1, fileName: "avatar.jpg")) { error in
|
||||
XCTAssertEqual(error as? OSSUploadError, .fileTooLarge)
|
||||
}
|
||||
XCTAssertThrowsError(try OSSUploadPolicy.validate(dataSize: 12, fileName: "avatar.txt")) { error in
|
||||
XCTAssertEqual(error as? OSSUploadError, .unsupportedFileType)
|
||||
}
|
||||
XCTAssertNoThrow(try OSSUploadPolicy.validate(dataSize: 12, fileName: "avatar.heic"))
|
||||
}
|
||||
|
||||
/// 测试 objectKey 按业务模块生成稳定路径并清洗文件名。
|
||||
func testOSSUploadPolicyBuildsObjectKeys() {
|
||||
let date = Date(timeIntervalSince1970: 1_767_225_600)
|
||||
let uuid = UUID(uuidString: "12345678-1234-1234-1234-1234567890ab")!
|
||||
let timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
|
||||
XCTAssertEqual(
|
||||
OSSUploadPolicy.objectKey(fileName: "a/b.jpg", scenicId: 9, moduleType: "user_avatar", date: date, uuid: uuid, timeZone: timeZone),
|
||||
"avatar/20260101/9/123456781234123412341234567890AB_a_b.jpg"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OSSUploadPolicy.objectKey(fileName: "id.jpg", scenicId: 9, moduleType: "real_name", date: date, uuid: uuid, timeZone: timeZone),
|
||||
"real_name/20260101/9/123456781234123412341234567890AB_id.jpg"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OSSUploadPolicy.objectKey(fileName: "video.mp4", scenicId: 9, moduleType: "task_upload", date: date, uuid: uuid, timeZone: timeZone),
|
||||
"task_upload/20260101/9/123456781234123412341234567890AB_video.mp4"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OSSUploadPolicy.objectKey(fileName: "bank.jpg", scenicId: 9, moduleType: "bank_card", date: date, uuid: uuid, timeZone: timeZone),
|
||||
"bank_card/20260101/9/123456781234123412341234567890AB_bank.jpg"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OSSUploadPolicy.objectKey(fileName: "cover.jpg", scenicId: 9, moduleType: "project", date: date, uuid: uuid, timeZone: timeZone),
|
||||
"project/20260101/9/123456781234123412341234567890AB_cover.jpg"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OSSUploadPolicy.objectKey(fileName: "live.mp4", scenicId: 9, moduleType: "alive_album", date: date, uuid: uuid, timeZone: timeZone),
|
||||
"live_albums/20260101/9/123456781234123412341234567890AB_live.mp4"
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试 MIME 类型推断和 URL 拼接规则。
|
||||
func testOSSUploadPolicyContentTypeAndURLJoin() {
|
||||
XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.jpg", fileType: 2), "image/jpeg")
|
||||
XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.png", fileType: 2), "image/png")
|
||||
XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.mp4", fileType: 1), "video/mp4")
|
||||
XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.unknown", fileType: 1), "video/mp4")
|
||||
XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.unknown", fileType: 2), "image/jpeg")
|
||||
XCTAssertEqual(OSSUploadPolicy.joinURL(baseURL: "https://cdn.example.com", objectKey: "a/b.jpg"), "https://cdn.example.com/a/b.jpg")
|
||||
XCTAssertEqual(OSSUploadPolicy.joinURL(baseURL: "https://cdn.example.com/", objectKey: "a/b.jpg"), "https://cdn.example.com/a/b.jpg")
|
||||
}
|
||||
|
||||
/// 测试 STS token 接口使用正确路径、bucket 参数并兼容宽松字段类型。
|
||||
func testUploadAPIRequestsSTSConfig() async throws {
|
||||
let response = """
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"base_url": 123,
|
||||
"endpoint": "oss-cn-hangzhou.aliyuncs.com",
|
||||
"region": "cn-hangzhou",
|
||||
"bucket": "vipsky",
|
||||
"expire_seconds": "900",
|
||||
"credentials": {
|
||||
"access_key_id": 456,
|
||||
"access_key_secret": "secret",
|
||||
"security_token": "token"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let session = UploadURLSession(responses: [response])
|
||||
let api = UploadAPI(client: APIClient(session: session))
|
||||
|
||||
let config = try await api.aliyunOSSBucket()
|
||||
|
||||
XCTAssertEqual(config.baseUrl, "123")
|
||||
XCTAssertEqual(config.expireSeconds, 900)
|
||||
XCTAssertEqual(config.credentials.accessKeyId, "456")
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.url?.path, "/api/app/config/get-sts-token")
|
||||
XCTAssertEqual(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems?.first?.value, "vipsky")
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传测试用 URLSession 替身,按顺序返回预设响应。
|
||||
private final class UploadURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 初始化顺序响应。
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 记录请求并返回下一份响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.isEmpty ? Data() : responses.removeFirst()
|
||||
return (
|
||||
data,
|
||||
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
)
|
||||
}
|
||||
}
|
||||
223
suixinkan_iosTests/WalletViewModelTests.swift
Normal file
223
suixinkan_iosTests/WalletViewModelTests.swift
Normal file
@ -0,0 +1,223 @@
|
||||
//
|
||||
// WalletViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 钱包模块测试,覆盖首页、提现资格、银行卡上传和积分兑换。
|
||||
final class WalletViewModelTests: XCTestCase {
|
||||
/// 测试钱包首页会加载汇总和收益明细。
|
||||
func testWalletInitialLoadFetchesSummaryAndEarnings() async {
|
||||
let api = WalletMockAPI()
|
||||
api.summary = WalletSummaryResponse(amountTotal: "100", amountCurrentBalance: "80", amountWithdrawable: "60")
|
||||
api.earningResponses = [WalletEarningDetailResponse(total: 1, list: [WalletEarningDetailGroup(date: "2026-06-22")])]
|
||||
let viewModel = WalletViewModel()
|
||||
|
||||
await viewModel.loadInitial(api: api, staffId: 7)
|
||||
|
||||
XCTAssertEqual(viewModel.withdrawableText, "¥ 60")
|
||||
XCTAssertEqual(api.pointOverviewStaffIds, [7])
|
||||
XCTAssertEqual(api.earningQueries.count, 1)
|
||||
XCTAssertEqual(viewModel.earningsGroups.map(\.date), ["2026-06-22"])
|
||||
}
|
||||
|
||||
/// 测试切换到提现记录 Tab 会请求提现分页。
|
||||
func testSelectWithdrawTabLoadsWithdrawRecords() async {
|
||||
let api = WalletMockAPI()
|
||||
let viewModel = WalletViewModel()
|
||||
|
||||
await viewModel.selectTab(.withdraws, api: api)
|
||||
|
||||
XCTAssertEqual(api.withdrawListPages, [1])
|
||||
}
|
||||
|
||||
/// 测试提现资格流根据实名认证和银行卡状态进入正确页面。
|
||||
func testWithdrawDecisionRoutesByRealNameAndBankCardState() async throws {
|
||||
let profile = WalletRealNameMockAPI()
|
||||
let wallet = WalletMockAPI()
|
||||
let viewModel = WalletViewModel()
|
||||
|
||||
profile.response = RealNameInfoResponse(realNameInfo: nil)
|
||||
var decision = await viewModel.resolveWithdrawDecision(profileAPI: profile, walletAPI: wallet)
|
||||
XCTAssertEqual(decision, .route(.realNameAuth))
|
||||
|
||||
profile.response = try Self.realNameInfo(status: 2)
|
||||
wallet.bankCardResponse = BankCardInfoResponse(bankCard: nil)
|
||||
decision = await viewModel.resolveWithdrawDecision(profileAPI: profile, walletAPI: wallet)
|
||||
XCTAssertEqual(decision, .route(.bankCardSettings))
|
||||
|
||||
wallet.bankCardResponse = try BankCardInfoResponse(bankCard: Self.bankCard(status: 2))
|
||||
decision = await viewModel.resolveWithdrawDecision(profileAPI: profile, walletAPI: wallet)
|
||||
XCTAssertEqual(decision, .route(.withdrawApply))
|
||||
}
|
||||
|
||||
/// 测试提现申请提交会校验金额并传递规范化金额。
|
||||
func testWithdrawApplySubmitsNormalizedAmount() async throws {
|
||||
let api = WalletMockAPI()
|
||||
api.withdrawInfoResponse = try Self.withdrawInfo(withdrawable: "100")
|
||||
let viewModel = WithdrawApplyViewModel()
|
||||
await viewModel.load(api: api)
|
||||
viewModel.amountText = "12.345"
|
||||
viewModel.smsCode = "8899"
|
||||
|
||||
let success = await viewModel.submit(api: api)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(api.withdrawApplyBodies.map(\.amount), ["12.35"])
|
||||
XCTAssertEqual(api.withdrawApplyBodies.map(\.smsCode), ["8899"])
|
||||
}
|
||||
|
||||
/// 测试银行卡设置会先上传正反面照片,再提交最终 URL。
|
||||
func testWithdrawalSettingsUploadsImagesBeforeSubmit() async {
|
||||
let api = WalletMockAPI()
|
||||
let uploader = WalletUploadMock()
|
||||
let viewModel = WithdrawalSettingsViewModel()
|
||||
viewModel.realName = "张三"
|
||||
viewModel.cardNumber = "622200"
|
||||
viewModel.bankName = "中国银行"
|
||||
viewModel.branchName = "杭州支行"
|
||||
viewModel.provinceCode = "330000"
|
||||
viewModel.cityCode = "330100"
|
||||
viewModel.smsCode = "1234"
|
||||
viewModel.frontImageData = Data([1])
|
||||
viewModel.backImageData = Data([2])
|
||||
|
||||
let success = await viewModel.submit(api: api, uploader: uploader, scenicId: 9)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(uploader.bankUploads.count, 2)
|
||||
XCTAssertEqual(api.updateBankRequests.first?.frontUrl, "https://cdn.example.com/bank_card_front.jpg")
|
||||
XCTAssertEqual(api.updateBankRequests.first?.backUrl, "https://cdn.example.com/bank_card_back.jpg")
|
||||
}
|
||||
|
||||
/// 测试积分兑换不能超过可兑换积分,成功后会刷新列表。
|
||||
func testPointsRedemptionValidationAndSubmit() async {
|
||||
let api = WalletMockAPI()
|
||||
api.pointOverviewResponse = PointOverviewResponse(withdrawnPoints: 100)
|
||||
let viewModel = PointsRedemptionViewModel()
|
||||
await viewModel.load(api: api, staffId: 7)
|
||||
viewModel.pointsText = "120"
|
||||
|
||||
var success = await viewModel.submit(api: api, staffId: 7)
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertEqual(viewModel.errorMessage, "兑换积分不能超过可兑换积分")
|
||||
|
||||
viewModel.pointsText = "80"
|
||||
success = await viewModel.submit(api: api, staffId: 7)
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(api.pointApplyBodies.map(\.points), [80])
|
||||
}
|
||||
|
||||
/// 从 JSON 创建实名认证响应。
|
||||
private static func realNameInfo(status: Int) throws -> RealNameInfoResponse {
|
||||
let data = """
|
||||
{"real_name_info":{"real_name":"张三","id_card_no":"11010519491231002X","audit_status":\(status),"is_long_valid":1}}
|
||||
""".data(using: .utf8)!
|
||||
return try JSONDecoder().decode(RealNameInfoResponse.self, from: data)
|
||||
}
|
||||
|
||||
/// 从 JSON 创建银行卡信息。
|
||||
private static func bankCard(status: Int) throws -> WalletBankCardInfo {
|
||||
let data = """
|
||||
{"real_name":"张三","bank_name":"中国银行","branch_name":"杭州支行","card_number":"622200","audit_status":\(status),"audit_status_label":"通过"}
|
||||
""".data(using: .utf8)!
|
||||
return try JSONDecoder().decode(WalletBankCardInfo.self, from: data)
|
||||
}
|
||||
|
||||
/// 从 JSON 创建提现信息。
|
||||
fileprivate static func withdrawInfo(withdrawable: String) throws -> WithdrawInfoResponse {
|
||||
let data = """
|
||||
{"amount_withdrawable":"\(withdrawable)","min_withdraw_amount":"1","max_single_withdraw_amount":"1000","max_daily_withdraw_amount":"1000","user_phone":"13800000000","bank_card":{"real_name":"张三","bank_name":"中国银行","card_number":"622200"},"withdraw_info":[]}
|
||||
""".data(using: .utf8)!
|
||||
return try JSONDecoder().decode(WithdrawInfoResponse.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 钱包 API 测试替身。
|
||||
private final class WalletMockAPI: WalletServing {
|
||||
var summary = WalletSummaryResponse()
|
||||
var earningResponses: [WalletEarningDetailResponse] = []
|
||||
var withdrawResponses: [WalletWithdrawListResponse] = []
|
||||
var withdrawInfoResponse: WithdrawInfoResponse?
|
||||
var bankCardResponse = BankCardInfoResponse(bankCard: nil)
|
||||
var pointOverviewResponse = PointOverviewResponse()
|
||||
private(set) var earningQueries: [(start: String, end: String, page: Int)] = []
|
||||
private(set) var withdrawListPages: [Int] = []
|
||||
private(set) var withdrawApplyBodies: [(amount: String, smsCode: String)] = []
|
||||
private(set) var updateBankRequests: [UpdateBankInfoRequest] = []
|
||||
private(set) var pointOverviewStaffIds: [Int] = []
|
||||
private(set) var pointApplyBodies: [(points: Int, remark: String)] = []
|
||||
|
||||
func walletSummary(type: Int) async throws -> WalletSummaryResponse { summary }
|
||||
func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse {
|
||||
earningQueries.append((startDate, endDate, page))
|
||||
return earningResponses.isEmpty ? WalletEarningDetailResponse() : earningResponses.removeFirst()
|
||||
}
|
||||
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse {
|
||||
withdrawListPages.append(page)
|
||||
return withdrawResponses.isEmpty ? WalletWithdrawListResponse() : withdrawResponses.removeFirst()
|
||||
}
|
||||
func withdrawInfo() async throws -> WithdrawInfoResponse {
|
||||
if let withdrawInfoResponse { return withdrawInfoResponse }
|
||||
return try WalletViewModelTests.withdrawInfo(withdrawable: "0")
|
||||
}
|
||||
func withdrawSendSms() async throws {}
|
||||
func withdrawApply(amount: String, smsCode: String) async throws {
|
||||
withdrawApplyBodies.append((amount, smsCode))
|
||||
}
|
||||
func bankCardInfo() async throws -> BankCardInfoResponse { bankCardResponse }
|
||||
func bankList() async throws -> BankListResponse { BankListResponse(banks: ["中国银行"]) }
|
||||
func areas() async throws -> [AreaNode] { [] }
|
||||
func bankCardVerifyCode() async throws {}
|
||||
func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {
|
||||
updateBankRequests.append(request)
|
||||
}
|
||||
func pointOverview(staffId: Int) async throws -> PointOverviewResponse {
|
||||
pointOverviewStaffIds.append(staffId)
|
||||
return pointOverviewResponse
|
||||
}
|
||||
func pointWithdrawApply(points: Int, remark: String) async throws {
|
||||
pointApplyBodies.append((points, remark))
|
||||
}
|
||||
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse {
|
||||
PointWithdrawListResponse()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 钱包实名认证测试替身。
|
||||
private final class WalletRealNameMockAPI: WalletRealNameServing {
|
||||
var response = RealNameInfoResponse(realNameInfo: nil)
|
||||
|
||||
/// 返回预设实名认证响应。
|
||||
func realNameInfo() async throws -> RealNameInfoResponse {
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 钱包上传测试替身。
|
||||
private final class WalletUploadMock: OSSUploadServing {
|
||||
private(set) var bankUploads: [String] = []
|
||||
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
bankUploads.append(fileName)
|
||||
onProgress(100)
|
||||
return "https://cdn.example.com/\(fileName)"
|
||||
}
|
||||
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
//
|
||||
// WithdrawalAuditViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan_ios
|
||||
|
||||
@MainActor
|
||||
/// 提现审核测试,覆盖分页、筛选和失败清理。
|
||||
final class WithdrawalAuditViewModelTests: XCTestCase {
|
||||
/// 测试首次加载、筛选统计和分页加载。
|
||||
func testLoadFilterAndLoadMore() async throws {
|
||||
let api = WithdrawalAuditWalletMock()
|
||||
api.withdrawResponses = [
|
||||
WalletWithdrawListResponse(total: 3, item: [
|
||||
try record(id: 1, status: "审核中"),
|
||||
try record(id: 2, status: "已到账")
|
||||
]),
|
||||
WalletWithdrawListResponse(total: 3, item: [
|
||||
try record(id: 3, status: "待打款")
|
||||
])
|
||||
]
|
||||
let viewModel = WithdrawalAuditViewModel()
|
||||
|
||||
await viewModel.reload(api: api)
|
||||
|
||||
XCTAssertEqual(api.withdrawPages, [1])
|
||||
XCTAssertEqual(viewModel.totalCount, 3)
|
||||
XCTAssertEqual(viewModel.processingCount, 1)
|
||||
XCTAssertEqual(viewModel.completedCount, 1)
|
||||
XCTAssertTrue(viewModel.hasMore)
|
||||
|
||||
viewModel.selectFilter(.processing)
|
||||
XCTAssertEqual(viewModel.filteredRecords.map(\.id), [1])
|
||||
|
||||
await viewModel.loadMore(api: api)
|
||||
|
||||
XCTAssertEqual(api.withdrawPages, [1, 2])
|
||||
XCTAssertEqual(viewModel.records.map(\.id), [1, 2, 3])
|
||||
XCTAssertEqual(viewModel.processingCount, 2)
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
}
|
||||
|
||||
/// 测试刷新失败会清空旧记录和分页状态。
|
||||
func testReloadFailureClearsStaleRecordsAndPaging() async throws {
|
||||
let api = WithdrawalAuditWalletMock()
|
||||
api.withdrawResponses = [
|
||||
WalletWithdrawListResponse(total: 2, item: [try record(id: 1, status: "审核中")])
|
||||
]
|
||||
let viewModel = WithdrawalAuditViewModel()
|
||||
|
||||
await viewModel.reload(api: api)
|
||||
XCTAssertEqual(viewModel.records.map(\.id), [1])
|
||||
XCTAssertTrue(viewModel.hasMore)
|
||||
|
||||
api.withdrawError = TestError.sample
|
||||
await viewModel.reload(api: api)
|
||||
|
||||
XCTAssertTrue(viewModel.records.isEmpty)
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
XCTAssertTrue(viewModel.loadFailed)
|
||||
XCTAssertEqual(viewModel.loadFailureReason, TestError.sample.localizedDescription)
|
||||
}
|
||||
|
||||
/// 测试空列表和尾页边界不会继续加载。
|
||||
func testEmptyListAndLastPageDoNotLoadMore() async {
|
||||
let api = WithdrawalAuditWalletMock()
|
||||
api.withdrawResponses = [WalletWithdrawListResponse(total: 0, item: [])]
|
||||
let viewModel = WithdrawalAuditViewModel()
|
||||
|
||||
await viewModel.reload(api: api)
|
||||
await viewModel.loadMore(api: api)
|
||||
|
||||
XCTAssertTrue(viewModel.records.isEmpty)
|
||||
XCTAssertEqual(api.withdrawPages, [1])
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
}
|
||||
|
||||
private func record(id: Int64, amount: String = "10", status: String) throws -> WalletWithdrawRecord {
|
||||
let data = """
|
||||
{"id":\(id),"amount":"\(amount)","created_at":"2026-06-25 10:00","status_label":"\(status)","expected_at":"2026-06-26","audit_time":"2026-06-25 11:00","completed_at":""}
|
||||
""".data(using: .utf8)!
|
||||
return try JSONDecoder().decode(WalletWithdrawRecord.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class WithdrawalAuditWalletMock: WalletServing {
|
||||
var withdrawResponses: [WalletWithdrawListResponse] = []
|
||||
var withdrawError: Error?
|
||||
private(set) var withdrawPages: [Int] = []
|
||||
|
||||
func walletSummary(type: Int) async throws -> WalletSummaryResponse { WalletSummaryResponse() }
|
||||
func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse { WalletEarningDetailResponse() }
|
||||
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse {
|
||||
withdrawPages.append(page)
|
||||
if let withdrawError { throw withdrawError }
|
||||
return withdrawResponses.isEmpty ? WalletWithdrawListResponse() : withdrawResponses.removeFirst()
|
||||
}
|
||||
func withdrawInfo() async throws -> WithdrawInfoResponse {
|
||||
let data = #"{"amount_withdrawable":"0","min_withdraw_amount":"1","max_single_withdraw_amount":"100","max_daily_withdraw_amount":"100","user_phone":"13800000000","bank_card":{},"withdraw_info":[]}"#.data(using: .utf8)!
|
||||
return try JSONDecoder().decode(WithdrawInfoResponse.self, from: data)
|
||||
}
|
||||
func withdrawSendSms() async throws {}
|
||||
func withdrawApply(amount: String, smsCode: String) async throws {}
|
||||
func bankCardInfo() async throws -> BankCardInfoResponse { BankCardInfoResponse(bankCard: nil) }
|
||||
func bankList() async throws -> BankListResponse { BankListResponse() }
|
||||
func areas() async throws -> [AreaNode] { [] }
|
||||
func bankCardVerifyCode() async throws {}
|
||||
func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {}
|
||||
func pointOverview(staffId: Int) async throws -> PointOverviewResponse { PointOverviewResponse() }
|
||||
func pointWithdrawApply(points: Int, remark: String) async throws {}
|
||||
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse { PointWithdrawListResponse() }
|
||||
}
|
||||
|
||||
private enum TestError: LocalizedError {
|
||||
case sample
|
||||
|
||||
var errorDescription: String? {
|
||||
"测试错误"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user