// // OperatingAreaTests.swift // suixinkanTests // // Created by Codex on 2026/6/25. // import XCTest @testable import suixinkan @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.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(total: 0, list: []) var error: Error? var storeIds: [Int] = [] var scenicIds: [Int] = [] func storeBusinessArea(storeId: Int) async throws -> ListPayload { storeIds.append(storeId) if let error { throw error } return response } func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload { 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 roleCode = AppRoleCode.fromRoleName(roleName)?.rawValue ?? "" let role = RoleInfo( id: 1, name: roleName, roleCode: roleCode, permission: [PermissionItem(id: 1, name: "运营区域", uri: "operating-area")] ) context.replaceRolePermissions( [RolePermissionResponse(role: role, scenic: [])], currentRoleCode: roleCode ) 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) } }) }