修复合作获客扫码绑定崩溃与 Toast 层级,并接入分成比例修改。

将扫码页提升到列表层弹出并补齐 AppDelegate.window,避免扫码时 UIKit 取主窗口崩溃;Toast 改用独立 UIWindow 显示在 sheet 之上;合作获客员支持短信验证修改分成比例,同时优化冷启动根视图创建时机并补充相关测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-03 20:37:24 +08:00
parent 00c3cd0a93
commit 49e997ddba
20 changed files with 905 additions and 70 deletions

View File

@ -0,0 +1,133 @@
//
// BindAcquirerViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/7/3.
//
import XCTest
@testable import suixinkan
/// ViewModel
@MainActor
final class BindAcquirerViewModelTests: XCTestCase {
/// 3
func testUpdateCommissionRateDraftFiltersInput() {
let viewModel = BindAcquirerViewModel()
viewModel.updateCommissionRateDraft("12a3.4")
XCTAssertEqual(viewModel.commissionRateDraft, "123")
viewModel.updateCommissionRateDraft("1234")
XCTAssertEqual(viewModel.commissionRateDraft, "123")
}
///
func testAcceptBindRequiresCommissionRate() async {
let viewModel = BindAcquirerViewModel()
let api = BindAcquirerMockAPI()
await viewModel.load(api: api, saleUserId: 101)
do {
try await viewModel.acceptBind(api: api)
XCTFail("Expected validation error")
} catch let error as CooperationOrderCommissionRateValidationError {
XCTAssertEqual(error.message, "请输入分账比例")
} catch {
XCTFail("Unexpected error: \(error)")
}
XCTAssertEqual(api.acceptBindCalls.count, 0)
}
///
func testAcceptBindRejectsOutOfRangeCommissionRate() async {
let viewModel = BindAcquirerViewModel()
let api = BindAcquirerMockAPI()
await viewModel.load(api: api, saleUserId: 101)
viewModel.updateCommissionRateDraft("101")
do {
try await viewModel.acceptBind(api: api)
XCTFail("Expected validation error")
} catch let error as CooperationOrderCommissionRateValidationError {
XCTAssertEqual(error.message, "分账比例需在0到100之间")
} catch {
XCTFail("Unexpected error: \(error)")
}
XCTAssertEqual(api.acceptBindCalls.count, 0)
}
/// accept-bind
func testAcceptBindPassesCommissionRateToAPI() async throws {
let viewModel = BindAcquirerViewModel()
let api = BindAcquirerMockAPI()
await viewModel.load(api: api, saleUserId: 101)
viewModel.updateCommissionRateDraft("20")
try await viewModel.acceptBind(api: api)
XCTAssertEqual(api.acceptBindCalls.count, 1)
XCTAssertEqual(api.acceptBindCalls.first?.saleUserId, 101)
XCTAssertEqual(api.acceptBindCalls.first?.commissionRate, 20)
}
}
@MainActor
private final class BindAcquirerMockAPI: CooperationOrderServing {
struct AcceptBindCall {
let saleUserId: Int
let commissionRate: Int
}
var acceptBindCalls: [AcceptBindCall] = []
func cooperativeSalers(storeId: Int?, page: Int, pageSize: Int) async throws -> CooperativeSalerListPayload {
fatalError("Not used in BindAcquirerViewModelTests")
}
func saleUserInviteInfo(saleUserId: Int) async throws -> SaleUserInviteInfo {
SaleUserInviteInfo(
saleUserId: saleUserId,
salerName: "测试获客员",
salerPhone: "13800138000"
)
}
func saleUserAcceptBind(saleUserId: Int, commissionRate: Int) async throws {
acceptBindCalls.append(AcceptBindCall(saleUserId: saleUserId, commissionRate: commissionRate))
}
func saleUserReferralLeads(page: Int, pageSize: Int) async throws -> ListPayload<ReferralLead> {
fatalError("Not used in BindAcquirerViewModelTests")
}
func saleUserAcquisitionOrders(keyword: String?, page: Int, pageSize: Int) async throws -> ListPayload<AcquisitionOrder> {
fatalError("Not used in BindAcquirerViewModelTests")
}
func saleUserReferralOrders(saleUserId: Int, page: Int, pageSize: Int) async throws -> ReferralOrderListPayload {
fatalError("Not used in BindAcquirerViewModelTests")
}
func saleUserBindReferralOrder(orderNumber: String, referralOrderNo: String) async throws {
fatalError("Not used in BindAcquirerViewModelTests")
}
func saleUserUpdateReferralOrderStatus(id: Int, status: Int) async throws {
fatalError("Not used in BindAcquirerViewModelTests")
}
func saleUserUpdateRemark(saleUserId: Int, photogRemarkName: String) async throws {
fatalError("Not used in BindAcquirerViewModelTests")
}
func saleUserCommissionRateSendSmsCode(phone: String) async throws {
fatalError("Not used in BindAcquirerViewModelTests")
}
func saleUserUpdateCommissionRate(
saleUserId: Int,
commissionRate: Int,
smsCode: String
) async throws -> CooperativeSaler {
fatalError("Not used in BindAcquirerViewModelTests")
}
}

View File

@ -28,6 +28,9 @@ final class CooperationOrderAPITests: XCTestCase {
XCTAssertEqual(response.list.first?.displayPhone, "19281871413")
XCTAssertEqual(response.list.first?.bindingId, 5)
XCTAssertEqual(response.list.first?.shareCode, "INV-000101")
XCTAssertEqual(response.list.first?.commissionRate, 15)
XCTAssertEqual(response.list.first?.commissionRateLabel, "15%")
XCTAssertEqual(response.list.first?.displayCommissionRate, "15%")
let request = try XCTUnwrap(session.requests.last)
XCTAssertEqual(request.httpMethod, "GET")
@ -70,6 +73,7 @@ final class CooperationOrderAPITests: XCTestCase {
XCTAssertEqual(response.total, 1)
XCTAssertEqual(response.list.first?.displayId, "501")
XCTAssertEqual(response.list.first?.displayPhone, "13900000002")
XCTAssertEqual(response.list.first?.displaySalerName, "张哥")
XCTAssertEqual(response.list.first?.remark, "景区门口获客")
XCTAssertEqual(response.list.first?.displayImages.first, "https://example.com/lead.jpg")
@ -130,12 +134,14 @@ final class CooperationOrderAPITests: XCTestCase {
let acceptSession = CooperationRecordingURLSession(data: emptyData)
let acceptAPI = CooperationOrderAPI(client: APIClient(session: acceptSession))
try await acceptAPI.saleUserAcceptBind(saleUserId: 101)
try await acceptAPI.saleUserAcceptBind(saleUserId: 101, commissionRate: 20)
let acceptRequest = try XCTUnwrap(acceptSession.requests.last)
XCTAssertEqual(acceptRequest.httpMethod, "POST")
XCTAssertEqual(acceptRequest.url?.path, "/api/yf-handset-app/photog/sale-user/accept-bind")
XCTAssertEqual(cooperationQueryItems(from: acceptRequest)["sale_user_id"], "101")
let acceptQuery = cooperationQueryItems(from: acceptRequest)
XCTAssertEqual(acceptQuery["sale_user_id"], "101")
XCTAssertEqual(acceptQuery["commission_rate"], "20")
XCTAssertNil(acceptRequest.httpBody)
let bindSession = CooperationRecordingURLSession(data: emptyData)
@ -181,6 +187,72 @@ final class CooperationOrderAPITests: XCTestCase {
XCTAssertEqual(json["sale_user_id"] as? Int, 123)
XCTAssertEqual(json["photog_remark_name"] as? String, "张哥")
}
///
func testSaleUserCommissionRateSendSmsCodeUsesPostBody() async throws {
let emptyData = try TestFixture.data(named: "empty_success")
let session = CooperationRecordingURLSession(data: emptyData)
let api = CooperationOrderAPI(client: APIClient(session: session))
try await api.saleUserCommissionRateSendSmsCode(phone: "19281871413")
let request = try XCTUnwrap(session.requests.last)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/commission-rate/send-sms-code")
let body = try XCTUnwrap(request.httpBody)
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
XCTAssertEqual(json["phone"] as? String, "19281871413")
}
///
func testSaleUserUpdateCommissionRateUsesPostBodyAndDecodesResponse() async throws {
let responseData = Data(
"""
{
"code": 100000,
"msg": "success",
"data": {
"binding_id": 5,
"sale_user_id": 101,
"share_code": "INV-000101",
"name": "",
"saler_name": "",
"phone": "19281871413",
"phone_masked": "192****1413",
"saler_phone": "19281871413",
"photog_commission_rate": 20,
"commission_rate": 20,
"commission_rate_label": "20%",
"bind_time": "2026-06-25 17:11:01",
"bound_at": "2026-06-25 17:11:01",
"store_id": 37
}
}
""".utf8
)
let session = CooperationRecordingURLSession(data: responseData)
let api = CooperationOrderAPI(client: APIClient(session: session))
let updated = try await api.saleUserUpdateCommissionRate(
saleUserId: 101,
commissionRate: 20,
smsCode: "889900"
)
XCTAssertEqual(updated.saleUserId, 101)
XCTAssertEqual(updated.commissionRate, 20)
XCTAssertEqual(updated.commissionRateLabel, "20%")
XCTAssertEqual(updated.displayCommissionRate, "20%")
let request = try XCTUnwrap(session.requests.last)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/commission-rate")
let body = try XCTUnwrap(request.httpBody)
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
XCTAssertEqual(json["sale_user_id"] as? Int, 101)
XCTAssertEqual(json["commission_rate"] as? Int, 20)
XCTAssertEqual(json["sms_code"] as? String, "889900")
}
}
/// URLSession API

View File

@ -18,6 +18,9 @@
"sale_user_id": 101,
"saler_name": "管铜彬",
"saler_phone": "19281871413",
"photog_commission_rate": 0,
"commission_rate": 15,
"commission_rate_label": "15%",
"share_code": "INV-000101",
"store_id": 37
}

View File

@ -6,6 +6,10 @@
"list": [
{
"lead_id": 501,
"display_name": "张哥",
"photog_remark_name": "张哥",
"name": "管铜彬",
"saler_name": "管铜彬",
"user_phone": "13900000002",
"remark": "景区门口获客",
"created_at": "2026-05-21 14:00:00",

View File

@ -0,0 +1,42 @@
//
// RootViewDisplayStateTests.swift
// suixinkanTests
//
// Created by Codex on 2026/7/3.
//
import XCTest
@testable import suixinkan
///
final class RootViewDisplayStateTests: XCTestCase {
/// bootstrap
func testBootstrapPendingLoggedOutUsesPlaceholder() {
let state = RootContentDisplayState.resolve(
isColdStartBootstrapPending: true,
appPhase: .loggedOut
)
XCTAssertEqual(state, .bootstrapPlaceholder)
}
/// bootstrap
func testBootstrapCompletedLoggedOutShowsLogin() {
let state = RootContentDisplayState.resolve(
isColdStartBootstrapPending: false,
appPhase: .loggedOut
)
XCTAssertEqual(state, .login)
}
/// bootstrap Tab
func testBootstrapCompletedLoggedInShowsMainTabs() {
let state = RootContentDisplayState.resolve(
isColdStartBootstrapPending: false,
appPhase: .loggedIn
)
XCTAssertEqual(state, .mainTabs)
}
}