172 lines
7.3 KiB
Swift
172 lines
7.3 KiB
Swift
//
|
||
// 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) }
|
||
})
|
||
}
|