// // PunchPointViewModelTests.swift // suixinkanTests // import CoreLocation import XCTest @testable import suixinkan /// 打卡点列表与表单 ViewModel 行为测试。 @MainActor final class PunchPointViewModelTests: XCTestCase { func testFilterRefreshAndPaginationUseAndroidStatusAndPageSize() async { let api = MockPunchPointAPI() api.listResponses = [ PunchPointListResponse(total: 12, list: (1...10).map { item(id: Int64($0)) }), PunchPointListResponse(total: 12, list: [item(id: 11), item(id: 12)]), PunchPointListResponse(total: 1, list: [item(id: 21)]), ] let viewModel = PunchPointListViewModel(currentScenicIdProvider: { 9 }) await viewModel.loadInitial(api: api) await viewModel.loadMoreIfNeeded(lastVisibleIndex: 8, api: api) await viewModel.selectFilter(.paused, api: api) XCTAssertEqual(viewModel.items.map(\.id), [21]) XCTAssertEqual(api.listCalls.map(\.status), [0, 0, 2]) XCTAssertEqual(api.listCalls.map(\.page), [1, 2, 1]) XCTAssertEqual(api.listCalls.map(\.pageSize), [10, 10, 10]) XCTAssertEqual(api.listCalls.map(\.scenicAreaId), ["9", "9", "9"]) } func testDeleteSuccessRemovesItemAndQRCodeValidationMatchesAndroid() async { let api = MockPunchPointAPI() api.listResponses = [PunchPointListResponse(total: 2, list: [ item(id: 1, status: 1, mpQrcode: "https://cdn/qr.png"), item(id: 2, status: 3, mpQrcode: nil), ])] let viewModel = PunchPointListViewModel(currentScenicIdProvider: { 9 }) var messages: [String] = [] var qrcode: String? viewModel.onShowMessage = { messages.append($0) } viewModel.onShowQRCode = { qrcode = $0 } await viewModel.loadInitial(api: api) viewModel.showQRCode(for: viewModel.items[1]) viewModel.showQRCode(for: PunchPointItem(id: 3, status: 1, mpQrcode: " ")) viewModel.showQRCode(for: viewModel.items[0]) await viewModel.delete(item: viewModel.items[0], api: api) XCTAssertEqual(messages, ["未审核通过不可查看二维码", "暂无二维码"]) XCTAssertEqual(qrcode, "https://cdn/qr.png") XCTAssertEqual(viewModel.items.map(\.id), [2]) XCTAssertEqual(api.deletedIds, [1]) } func testFormValidationUploadAndCreateRequestBody() async { let api = MockPunchPointAPI() let uploader = MockPunchPointUploader(resultURL: "https://cdn/upload.jpg") let location = MockPunchPointLocationProvider(reverseAddress: "地图地址") let viewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 }, locationProvider: location) var messages: [String] = [] viewModel.onShowMessage = { messages.append($0) } await viewModel.submit(api: api) viewModel.updateName("湖边打卡") await viewModel.selectCoordinate(CLLocationCoordinate2D(latitude: 30.1, longitude: 120.2)) viewModel.updateDescription(String(repeating: "景", count: 80)) await viewModel.addLocalImages([PunchPointImageState(data: Data([1, 2]), fileName: "a.jpg")], uploader: uploader) await viewModel.submit(api: api) XCTAssertEqual(messages.first, "请输入打卡点名称") XCTAssertEqual(viewModel.description.count, 50) XCTAssertEqual(uploader.uploads.map(\.moduleFileName), ["a.jpg"]) let request = api.addRequests.first XCTAssertEqual(request?.scenicAreaId, "7") XCTAssertEqual(request?.name, "湖边打卡") XCTAssertEqual(request?.region.lat, 30.1) XCTAssertEqual(request?.region.lot, 120.2) XCTAssertEqual(request?.region.address, "地图地址") XCTAssertEqual(request?.scenicSpotString, "地图地址") XCTAssertEqual(request?.guideImages, ["https://cdn/upload.jpg"]) } func testEditInitializationAndSubmitUsesEditAPI() async { let api = MockPunchPointAPI() let viewModel = PunchPointFormViewModel(mode: .edit(id: 66), currentScenicIdProvider: { 8 }, locationProvider: MockPunchPointLocationProvider()) var changedCoordinate: CLLocationCoordinate2D? viewModel.onCoordinateChange = { changedCoordinate = $0 } viewModel.initialize(with: PunchPointDetail( id: 66, name: "旧打卡点", region: PunchPointRegion(lat: 31.1, lot: 121.1, address: "旧地址"), description: "说明", guideImages: ["https://cdn/old.jpg"] )) viewModel.updateName("新打卡点") await viewModel.submit(api: api) XCTAssertEqual(changedCoordinate?.latitude, 31.1) XCTAssertEqual(viewModel.images.first?.uploadedURL, "https://cdn/old.jpg") XCTAssertEqual(api.editRequests.first?.id, 66) XCTAssertEqual(api.editRequests.first?.name, "新打卡点") XCTAssertEqual(api.editRequests.first?.guideImages, ["https://cdn/old.jpg"]) } func testUploadingAndFailedImagesBlockSubmit() async { let api = MockPunchPointAPI() let slowUploader = SlowPunchPointUploader() let location = MockPunchPointLocationProvider(reverseAddress: "地址") let uploadingViewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 }, locationProvider: location) uploadingViewModel.updateName("上传中") await uploadingViewModel.selectCoordinate(CLLocationCoordinate2D(latitude: 1, longitude: 2)) let started = expectation(description: "upload started") slowUploader.onStart = { started.fulfill() } let task = Task { await uploadingViewModel.addLocalImages([PunchPointImageState(data: Data([1]), fileName: "slow.jpg")], uploader: slowUploader) } await fulfillment(of: [started], timeout: 1) var uploadMessages: [String] = [] uploadingViewModel.onShowMessage = { uploadMessages.append($0) } await uploadingViewModel.submit(api: api) task.cancel() let failedViewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 }, locationProvider: location) failedViewModel.updateName("失败图") await failedViewModel.selectCoordinate(CLLocationCoordinate2D(latitude: 1, longitude: 2)) await failedViewModel.addLocalImages( [PunchPointImageState(data: Data([1]), fileName: "fail.jpg")], uploader: MockPunchPointUploader(error: URLError(.cannotConnectToHost)) ) var failedMessages: [String] = [] failedViewModel.onShowMessage = { failedMessages.append($0) } await failedViewModel.submit(api: api) XCTAssertEqual(uploadMessages.last, "图片正在上传,请稍后提交") XCTAssertEqual(failedMessages.last, "请先完成图片上传") XCTAssertTrue(api.addRequests.isEmpty) } private func item(id: Int64, status: Int = 1, mpQrcode: String? = nil) -> PunchPointItem { PunchPointItem( id: id, name: "点\(id)", status: status, statusLabel: status == 1 ? "运营中" : "未审核", region: PunchPointRegion(lat: 30, lot: 120, address: "地址\(id)"), guideImages: ["https://cdn/\(id).jpg"], mpQrcode: mpQrcode ) } } @MainActor private final class MockPunchPointAPI: PunchPointAPIProtocol { var listResponses: [PunchPointListResponse] = [] var listCalls: [(scenicAreaId: String, status: Int, page: Int, pageSize: Int)] = [] var addRequests: [PunchPointSaveRequest] = [] var editRequests: [PunchPointSaveRequest] = [] var deletedIds: [Int64] = [] func list(scenicAreaId: String, status: Int, page: Int, pageSize: Int) async throws -> PunchPointListResponse { listCalls.append((scenicAreaId, status, page, pageSize)) guard !listResponses.isEmpty else { return PunchPointListResponse() } return listResponses.removeFirst() } func info(id: Int64) async throws -> PunchPointDetail { PunchPointDetail(id: id) } func add(_ request: PunchPointSaveRequest) async throws { addRequests.append(request) } func edit(_ request: PunchPointSaveRequest) async throws { editRequests.append(request) } func delete(id: Int64) async throws { deletedIds.append(id) } } @MainActor private final class MockPunchPointUploader: PunchPointImageUploading { struct Upload { let moduleFileName: String let scenicId: Int } var uploads: [Upload] = [] let resultURL: String let error: Error? init(resultURL: String = "https://cdn/default.jpg", error: Error? = nil) { self.resultURL = resultURL self.error = error } func uploadPunchPointImage( data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void ) async throws -> String { uploads.append(Upload(moduleFileName: fileName, scenicId: scenicId)) onProgress(100) if let error { throw error } return resultURL } } @MainActor private final class SlowPunchPointUploader: PunchPointImageUploading { var onStart: (() -> Void)? func uploadPunchPointImage( data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void ) async throws -> String { onProgress(10) onStart?() try await Task.sleep(nanoseconds: 1_000_000_000) onProgress(100) return "https://cdn/slow.jpg" } } private final class MockPunchPointLocationProvider: LocationProviding, @unchecked Sendable { let snapshot: HomeLocationSnapshot let reverseAddress: String init( snapshot: HomeLocationSnapshot = HomeLocationSnapshot(latitude: 30.2, longitude: 120.3, address: "当前位置"), reverseAddress: String = "反查地址" ) { self.snapshot = snapshot self.reverseAddress = reverseAddress } func requestSnapshot() async throws -> HomeLocationSnapshot { snapshot } func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: snapshot.latitude, longitude: snapshot.longitude) } func reverseGeocode(latitude: Double, longitude: Double) async -> String { reverseAddress } }