// // PaymentCollectionDetailsViewModelTests.swift // suixinkanTests // import XCTest import UIKit @testable import suixinkan /// 收款详情 ViewModel 测试。 @MainActor final class PaymentCollectionDetailsViewModelTests: XCTestCase { private var appStore: AppStore! override func setUp() { let defaults = UserDefaults(suiteName: "PaymentCollectionDetailsViewModelTests")! defaults.removePersistentDomain(forName: "PaymentCollectionDetailsViewModelTests") appStore = AppStore(defaults: defaults) appStore.session.configureTestAccount(userId: "10001", scenicId: 10) appStore.session.currentScenicName = "测试景区" } override func tearDown() { appStore.logout() super.tearDown() } func testPaymentAmountValidatorRejectsEmptyAmount() { XCTAssertEqual(PaymentAmountValidator.validationMessage(for: ""), "请输入收款金额") } func testPaymentAmountValidatorRejectsZeroAmount() { XCTAssertEqual(PaymentAmountValidator.validationMessage(for: "0"), "请输入有效的收款金额") } func testPaymentAmountValidatorRejectsMoreThanTwoDecimals() { XCTAssertEqual(PaymentAmountValidator.validationMessage(for: "1.234"), "金额最多支持两位小数") } func testPaymentAmountValidatorAcceptsValidAmount() { XCTAssertNil(PaymentAmountValidator.validationMessage(for: "12.50")) } func testShowSetAmountDialogRequiresPayCode() { let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) var message: String? viewModel.onShowMessage = { message = $0 } viewModel.showSetAmountDialog() XCTAssertEqual(message, "请先获取收款码") XCTAssertFalse(viewModel.showAmountDialog) } func testConfirmAmountBuildsDynamicURL() async throws { let payCodeJSON = """ {"code":100000,"msg":"success","data":{"static_pay_url":"https://pay.example.com/static","dynamic_pay_url":"https://pay.example.com/dynamic"}} """.data(using: .utf8)! let session = MockURLSession(responses: [payCodeJSON]) let api = PaymentAPI(client: APIClient(environment: .testing, session: session)) let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) await viewModel.loadPayCode(api: api) viewModel.updateAmount("88.50") viewModel.updateRemark("测试备注") viewModel.confirmAmount() XCTAssertTrue(viewModel.hasPayCode) XCTAssertFalse(viewModel.showAmountDialog) } func testPayCodeLoadingStateCoversInitialRenderAndRequest() async { let payCodeJSON = """ {"code":100000,"msg":"success","data":{"static_pay_url":"https://pay.example.com/static","dynamic_pay_url":"https://pay.example.com/dynamic"}} """.data(using: .utf8)! let session = MockURLSession(responses: [payCodeJSON]) let api = PaymentAPI(client: APIClient(environment: .testing, session: session)) let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) var loadingStates = [viewModel.isPayCodeLoading] viewModel.onStateChange = { loadingStates.append(viewModel.isPayCodeLoading) } await viewModel.loadPayCode(api: api) XCTAssertEqual(loadingStates.first, true) XCTAssertTrue(loadingStates.dropLast().allSatisfy { $0 }) XCTAssertEqual(loadingStates.last, false) XCTAssertTrue(viewModel.hasPayCode) } func testToggleReceiveVoicePersistsInAppStore() { let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) viewModel.refreshLocalState() XCTAssertFalse(viewModel.isVoiceBroadcastOpen) viewModel.toggleReceiveVoice() XCTAssertTrue(appStore.payment.isOpenReceiveVoice) XCTAssertTrue(viewModel.isVoiceBroadcastOpen) } func testNalatiAccountInfoUsesPhotographerRealNameAndAllPermissionStores() { appStore.session.userName = "摄影师昵称" appStore.session.realName = "摄影师真实姓名" appStore.permissions.saveRolePermissionList([ RolePermissionResponse( role: RoleInfo(id: 41, name: "摄影师", roleCode: "photographer"), store: [ RolePermissionStoreInfo(id: 1, name: "空中草原店"), RolePermissionStoreInfo(id: 2, name: "河谷草原店"), ] ), ]) let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) XCTAssertEqual(viewModel.displayPhotographerName, "摄影师真实姓名") XCTAssertEqual(viewModel.displayStoreNames, "空中草原店、河谷草原店") } func testPhotographerNameDoesNotFallBackToNicknameWhenRealNameMissing() { appStore.session.userName = "摄影师昵称" appStore.session.realName = " " let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) XCTAssertEqual(viewModel.displayPhotographerName, "-") } func testLoadPayCodeClearsQRWhenScenicMissing() async { appStore.session.currentScenicId = 0 let session = MockURLSession(responses: []) let api = PaymentAPI(client: APIClient(environment: .testing, session: session)) let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) await viewModel.loadPayCode(api: api) XCTAssertFalse(viewModel.isPayCodeLoading) XCTAssertFalse(viewModel.hasPayCode) XCTAssertEqual(session.requests.count, 0) } func testNalatiLoadsCachedConfigBeforeRefreshingRemoteConfig() async throws { let defaults = UserDefaults(suiteName: "PaymentCollectionDetailsViewModelTests.Config")! defaults.removePersistentDomain(forName: "PaymentCollectionDetailsViewModelTests.Config") let cache = PayPageConfigCache(defaults: defaults, environment: .testing) let cached = PayPageConfig( background: "https://cache.example.com/background.png", logo: "https://cache.example.com/logo.png", title: "缓存标题", subtitle: "缓存副标题" ) cache.save(cached, scenicId: 128) appStore.session.currentScenicId = 128 appStore.session.currentScenicName = "那拉提景区" let responseJSON = """ {"code":100000,"msg":"success","data":{ "background":"https://new.example.com/background.png", "logo":"", "title":"最新标题", "subtitle":"" }} """.data(using: .utf8)! let session = MockURLSession(responses: [responseJSON]) let api = PaymentAPI(client: APIClient(environment: .testing, session: session)) let viewModel = PaymentCollectionDetailsViewModel( appStore: appStore, payPageConfigCache: cache ) XCTAssertEqual(viewModel.payPageConfig, cached) await viewModel.loadPayPageConfig(api: api) XCTAssertEqual(viewModel.payPageConfig.background, "https://new.example.com/background.png") XCTAssertEqual(viewModel.payPageConfig.logo, cached.logo) XCTAssertEqual(viewModel.payPageConfig.title, "最新标题") XCTAssertEqual(viewModel.payPageConfig.subtitle, cached.subtitle) XCTAssertEqual(viewModel.brandingRefreshVersion, 1) XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/pay-page-config") XCTAssertEqual( URLComponents(url: try XCTUnwrap(session.requests.first?.url), resolvingAgainstBaseURL: false)? .queryItems? .first(where: { $0.name == "scenic_id" })? .value, "128" ) } func testUnbrandedScenicDoesNotRequestPayPageConfig() async { let session = MockURLSession(responses: []) let api = PaymentAPI(client: APIClient(environment: .testing, session: session)) let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) await viewModel.loadPayPageConfig(api: api) XCTAssertFalse(viewModel.usesBranding) XCTAssertEqual(session.requests.count, 0) } func testPayPageConfigCacheIsEnvironmentScoped() { let defaults = UserDefaults(suiteName: "PaymentCollectionDetailsViewModelTests.Environment")! defaults.removePersistentDomain(forName: "PaymentCollectionDetailsViewModelTests.Environment") let production = PayPageConfigCache(defaults: defaults, environment: .production) let testing = PayPageConfigCache(defaults: defaults, environment: .testing) production.save(.nalatiDefault, scenicId: 128) XCTAssertEqual(production.config(scenicId: 128), .nalatiDefault) XCTAssertNil(testing.config(scenicId: 128)) } func testSailimuRefreshPersistsOnlyItsOwnConfigAndReusesBrandUI() async throws { let suite = "PaymentCollectionDetailsViewModelTests.Sailimu.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let cache = PayPageConfigCache(defaults: defaults, environment: .testing) cache.save(.nalatiDefault, scenicId: 128) let cached = PayPageConfig( background: "https://cache.example.com/sailimu.png", logo: "https://cache.example.com/sailimu-logo.png", title: "缓存赛里木湖标题", subtitle: "赛里木湖景区" ) cache.save(cached, scenicId: 125) appStore.session.currentScenicId = 125 let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore, payPageConfigCache: cache) XCTAssertTrue(viewModel.usesBranding) XCTAssertEqual(viewModel.payPageConfig, cached) let response = """ {"code":100000,"msg":"success","data":{ "background":"https://new.example.com/sailimu.png", "logo":"", "title":"最新赛里木湖标题", "subtitle":"" }} """.data(using: .utf8)! let session = MockURLSession(responses: [response]) let api = PaymentAPI(client: APIClient(environment: .testing, session: session)) await viewModel.loadPayPageConfig(api: api) let url = try XCTUnwrap(session.requests.first?.url) XCTAssertEqual(url.path, "/api/yf-handset-app/photog/pay-page-config") XCTAssertEqual(URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems?.first(where: { $0.name == "scenic_id" })?.value, "125") XCTAssertEqual(viewModel.payPageConfig.title, "最新赛里木湖标题") XCTAssertEqual(viewModel.payPageConfig.background, "https://new.example.com/sailimu.png") XCTAssertEqual(viewModel.payPageConfig.logo, cached.logo) XCTAssertEqual(viewModel.payPageConfig.subtitle, cached.subtitle) XCTAssertEqual(viewModel.brandingRefreshVersion, 1) XCTAssertEqual(cache.config(scenicId: 128), .nalatiDefault) XCTAssertEqual(cache.config(scenicId: 125), viewModel.payPageConfig) let reopened = PaymentCollectionDetailsViewModel(appStore: appStore, payPageConfigCache: cache) XCTAssertEqual(reopened.payPageConfig, viewModel.payPageConfig) appStore.session.currentScenicId = 128 viewModel.refreshLocalState() XCTAssertEqual(viewModel.payPageConfig, .nalatiDefault) appStore.session.currentScenicId = 125 viewModel.refreshLocalState() XCTAssertEqual(viewModel.payPageConfig, reopened.payPageConfig) } func testSailimuRequestFailurePreservesDefaultsOrItsOwnCache() async { let suite = "PaymentCollectionDetailsViewModelTests.SailimuFailure.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let cache = PayPageConfigCache(defaults: defaults, environment: .testing) cache.save(.nalatiDefault, scenicId: 128) appStore.session.currentScenicId = 125 let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore, payPageConfigCache: cache) let session = MockURLSession(responses: []) let api = PaymentAPI(client: APIClient(environment: .testing, session: session)) XCTAssertEqual(viewModel.payPageConfig, .sailimuDefault) await viewModel.loadPayPageConfig(api: api) XCTAssertEqual(viewModel.payPageConfig, .sailimuDefault) XCTAssertNil(cache.config(scenicId: 125)) let cached = PayPageConfig(background: "https://cache.example.com/lake.png", logo: "", title: "缓存标题", subtitle: "赛里木湖") cache.save(cached, scenicId: 125) viewModel.refreshLocalState() await viewModel.loadPayPageConfig(api: api) XCTAssertEqual(viewModel.payPageConfig, cached) XCTAssertEqual(cache.config(scenicId: 125), cached) XCTAssertEqual(cache.config(scenicId: 128), .nalatiDefault) XCTAssertEqual(viewModel.brandingRefreshVersion, 0) } func testBrandPresentationUsesMatchingBundledAssetsAndTitleLines() { let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) for (scenicId, prefix, lines) in [(125, "payment_sailimu", 2), (128, "payment_nalati", 1)] { appStore.session.currentScenicId = scenicId viewModel.refreshLocalState() XCTAssertTrue(viewModel.usesBranding) XCTAssertEqual(viewModel.brandBackgroundAssetName, "\(prefix)_background") XCTAssertEqual(viewModel.brandLogoAssetName, "\(prefix)_logo") XCTAssertEqual(viewModel.brandTitleNumberOfLines, lines) XCTAssertNotNil(UIImage(named: viewModel.brandBackgroundAssetName)) XCTAssertNotNil(UIImage(named: viewModel.brandLogoAssetName)) } } }