Initial commit: suixinkan_ios UIKit rewrite project.

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

View File

@ -0,0 +1,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("请求超时,请稍后重试")))
}
}

View 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: [])
}
}

View 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"))
}
}

View 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])
}

View 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)
}
}

View 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, "")
}
}

View File

@ -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"
}
]
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
]
}

View 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
}
]
}
}

View 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"
}
]
}
}

View File

@ -0,0 +1,4 @@
{
"code": 100000,
"msg": "success"
}

View 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"
}
]
}
}

View 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
}
]
}
}

View 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"
}
]
}
}

View File

@ -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"
}
]
}
]
}
}

View File

@ -0,0 +1,14 @@
{
"code": 100000,
"msg": "success",
"data": [
{
"id": 901,
"name": "湖心亭"
},
{
"id": "902",
"name": "观景台"
}
]
}

View File

@ -0,0 +1,8 @@
{
"code": 100000,
"msg": "success",
"data": {
"total": 0,
"list": []
}
}

View 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": "待审核"
}
]
}
}

View 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
}
]
}
}

View 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": ""
}
}
}

View 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"
}
}

View File

@ -0,0 +1,7 @@
{
"code": 100000,
"msg": "success",
"data": {
"qrcode_url": "https://cdn.example.com/queue/spot-9.png"
}
}

View File

@ -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"
}
}

View File

@ -0,0 +1,9 @@
{
"code": 100000,
"msg": "success",
"data": {
"queue_count": "5",
"avg_wait_min": "12.5",
"time": "2026-05-23 03:10"
}
}

View File

@ -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": "停用"
}
]
}
}

View File

@ -0,0 +1,4 @@
{
"code": 200001,
"msg": "登录已过期"
}

View File

@ -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"
}
]
}
}

View File

@ -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"
}
]
}
}

View 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"
}
}

View 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
}
]
}
}
}

View File

@ -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"
}
]
}
}

View 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"
}
]
}
}

View 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": []
}
]
}
}

View 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": []
}
]
}
}

View 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": "已认证"
}
}

View 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
}
]
}
}

View File

@ -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": []
}
}

View 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": []
}
}

View 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
}
]
}
}

View 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": ""
}
]
}
}

View 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
}
}

View 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
}
}

View 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)
}
}

View 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)
}
}

View 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) }
})
}

View 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])
}

View 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 preparestartstop 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 {}
}

View 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) }
})
}

View 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)!
)
}
}

View 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 {}
}

View File

@ -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 {}
}

View 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)
}
}

View 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) }
})
}

View 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")
}
}

View 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, "游客取消行程")
}
/// 退 pathquerybody
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"])
}
/// pathquerybody
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])
}

View 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"))
}
}

View 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) }
})
}

View 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()
}
}

View File

@ -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.
}
}

View 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))
}

View 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)
}
/// pathquery 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])
}

View 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
}

View 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])
}

View File

@ -0,0 +1,133 @@
//
// PushNotificationTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/26.
//
import Foundation
import XCTest
@testable import suixinkan_ios
/// APNs tokenpayload 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)
})
}

View File

@ -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
}

View File

@ -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 {
}
}

View File

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

View 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)
}
}

View File

@ -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? {
"测试错误"
}
}

View 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
}
}

View 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])
}

View 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) }
})
}

View 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
}
}

View 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) }
})
}

View 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 { "" }
}

View 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 {
""
}
}

View 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))
}
}

View 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)!
)
}
}

View 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 { "" }
}

View File

@ -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? {
"测试错误"
}
}