新增线下收款记录和 ai 修图优化
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 注销账号功能的业务规则测试,覆盖资产确认、七天冷静期与账号恢复。
|
||||
final class AccountDeletionFeatureTests: XCTestCase {
|
||||
private var suiteName = ""
|
||||
private var defaults: UserDefaults!
|
||||
private var currentDate: Date!
|
||||
private var service: AccountDeletionMockService!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "AccountDeletionFeatureTests.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
currentDate = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
service = AccountDeletionMockService(
|
||||
defaults: defaults,
|
||||
now: { [weak self] in self?.currentDate ?? Date() },
|
||||
responseDelayNanoseconds: 0
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
service = nil
|
||||
currentDate = nil
|
||||
defaults = nil
|
||||
suiteName = ""
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
/// 预检查应返回全部资产项,并保持中性展示文案。
|
||||
func testPrecheckReturnsAllAccountAssetsWithoutAbandonmentLabel() async throws {
|
||||
let precheck = try await service.loadPrecheck(username: "18651857230")
|
||||
|
||||
XCTAssertEqual(precheck.assets.map(\.kind), AccountDeletionAssetSummary.Kind.allCases)
|
||||
XCTAssertEqual(precheck.assets.map(\.valueText), ["¥286.50", "36个", "4个", "8.6 GB"])
|
||||
XCTAssertFalse(precheck.assets.map(\.title).contains(where: { $0.contains("可放弃") }))
|
||||
XCTAssertFalse(precheck.assets.map(\.valueText).contains(where: { $0.contains("可放弃") }))
|
||||
}
|
||||
|
||||
/// 用户勾选确认前不允许进入验证页。
|
||||
func testViewModelRequiresExplicitAssetAcknowledgement() async throws {
|
||||
let viewModel = AccountDeletionViewModel(username: "18651857230", service: service)
|
||||
|
||||
XCTAssertFalse(viewModel.isContinueEnabled)
|
||||
await viewModel.load()
|
||||
XCTAssertFalse(viewModel.isContinueEnabled)
|
||||
XCTAssertTrue(viewModel.isContinueButtonEnabled)
|
||||
|
||||
XCTAssertThrowsError(try viewModel.makeVerificationContext()) { error in
|
||||
XCTAssertEqual(error as? AccountDeletionError, .incompleteAcknowledgement)
|
||||
}
|
||||
|
||||
viewModel.toggleAcknowledgement()
|
||||
XCTAssertTrue(viewModel.isContinueEnabled)
|
||||
|
||||
let context = try viewModel.makeVerificationContext()
|
||||
XCTAssertEqual(context.maskedPhone, "186****7230")
|
||||
XCTAssertEqual(Set(context.assets.map(\.kind)), Set(AccountDeletionAssetSummary.Kind.allCases))
|
||||
}
|
||||
|
||||
/// 注销申请必须校验验证码,并确认全部资产项。
|
||||
func testSubmitRequiresCorrectCodeAndAllAssetKinds() async throws {
|
||||
do {
|
||||
_ = try await service.submitDeletion(
|
||||
username: "18651857230",
|
||||
verificationCode: "000000",
|
||||
acknowledgedAssetKinds: Set(AccountDeletionAssetSummary.Kind.allCases),
|
||||
clientRequestID: UUID()
|
||||
)
|
||||
XCTFail("错误验证码不应提交成功")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? AccountDeletionError, .invalidVerificationCode)
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await service.submitDeletion(
|
||||
username: "18651857230",
|
||||
verificationCode: AccountDeletionMockService.verificationCode,
|
||||
acknowledgedAssetKinds: [.wallet, .works, .projects],
|
||||
clientRequestID: UUID()
|
||||
)
|
||||
XCTFail("未确认全部资产项不应提交成功")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? AccountDeletionError, .incompleteAcknowledgement)
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交后进入七天冷静期,期间再次登录可以取消注销。
|
||||
func testPendingDeletionCanBeCanceledWithinSevenDays() async throws {
|
||||
let request = try await submitDeletion(username: "18651857230")
|
||||
|
||||
XCTAssertEqual(request.scheduledDeletionAt, currentDate.addingTimeInterval(7 * 24 * 60 * 60))
|
||||
guard case let .pending(savedRequest) = service.loginState(for: "18651857230") else {
|
||||
return XCTFail("应进入待注销状态")
|
||||
}
|
||||
XCTAssertEqual(savedRequest, request)
|
||||
|
||||
currentDate = currentDate.addingTimeInterval(6 * 24 * 60 * 60)
|
||||
_ = try service.cancelDeletion(username: "18651857230")
|
||||
XCTAssertEqual(service.loginState(for: "18651857230"), .none)
|
||||
}
|
||||
|
||||
/// 七天后账号进入已注销状态,不能再通过登录恢复。
|
||||
func testDeletionBecomesCompletedAfterSevenDays() async throws {
|
||||
_ = try await submitDeletion(username: "18651857230")
|
||||
currentDate = currentDate.addingTimeInterval(7 * 24 * 60 * 60 + 1)
|
||||
|
||||
guard case .completed = service.loginState(for: "18651857230") else {
|
||||
return XCTFail("冷静期结束后应为已注销状态")
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try service.cancelDeletion(username: "18651857230")
|
||||
XCTFail("已超过七天不应恢复账号")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? AccountDeletionError, .cancellationExpired)
|
||||
}
|
||||
}
|
||||
|
||||
/// 手机号展示仅保留前三位和后四位。
|
||||
func testPhoneMaskingNormalizesStoredUsername() {
|
||||
XCTAssertEqual(AccountDeletionIdentity.normalizedUsername("+86 186-5185-7230"), "18651857230")
|
||||
XCTAssertEqual(AccountDeletionIdentity.maskedPhone("+86 186-5185-7230"), "186****7230")
|
||||
}
|
||||
|
||||
private func submitDeletion(username: String) async throws -> AccountDeletionRequest {
|
||||
try await service.submitDeletion(
|
||||
username: username,
|
||||
verificationCode: AccountDeletionMockService.verificationCode,
|
||||
acknowledgedAssetKinds: Set(AccountDeletionAssetSummary.Kind.allCases),
|
||||
clientRequestID: UUID()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -200,4 +200,91 @@ final class LoginViewModelTests: XCTestCase {
|
||||
XCTFail("意外错误: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 密码校验成功后发现账号处于注销冷静期,应先确认恢复,再继续原有登录流程。
|
||||
func testPendingAccountDeletionCanBeCanceledAndResumeLogin() async throws {
|
||||
let loginJSON = """
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"token": "pending-token",
|
||||
"scenic_users": [
|
||||
{
|
||||
"account_type": "scenic_user",
|
||||
"id": 101,
|
||||
"user_id": 101,
|
||||
"scenic_user_id": 101,
|
||||
"ss_user_id": 101,
|
||||
"username": "scenic_admin",
|
||||
"real_name": "张三",
|
||||
"nickname": "张三",
|
||||
"phone": "13800138000",
|
||||
"scenic_id": 10,
|
||||
"scenic_name": "示例景区",
|
||||
"is_current": false
|
||||
}
|
||||
],
|
||||
"store_users": []
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let setUserJSON = """
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"token": "business-token-after-restore",
|
||||
"scenic_users": [],
|
||||
"store_users": []
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let suiteName = "LoginViewModelTests.AccountDeletion.\(UUID().uuidString)"
|
||||
let deletionDefaults = UserDefaults(suiteName: suiteName)!
|
||||
deletionDefaults.removePersistentDomain(forName: suiteName)
|
||||
defer { deletionDefaults.removePersistentDomain(forName: suiteName) }
|
||||
let fixedDate = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let deletionService = AccountDeletionMockService(
|
||||
defaults: deletionDefaults,
|
||||
now: { fixedDate },
|
||||
responseDelayNanoseconds: 0
|
||||
)
|
||||
_ = try await deletionService.submitDeletion(
|
||||
username: "18651857230",
|
||||
verificationCode: AccountDeletionMockService.verificationCode,
|
||||
acknowledgedAssetKinds: Set(AccountDeletionAssetSummary.Kind.allCases),
|
||||
clientRequestID: UUID()
|
||||
)
|
||||
|
||||
let session = MockURLSession(responses: [loginJSON, setUserJSON])
|
||||
let api = AuthAPI(client: APIClient(environment: .testing, session: session))
|
||||
let viewModel = LoginViewModel(accountDeletionService: deletionService)
|
||||
viewModel.updateAccount("18651857230")
|
||||
viewModel.updatePassword("secret123")
|
||||
viewModel.togglePrivacyChecked()
|
||||
|
||||
let initialResolution = try await viewModel.login(authAPI: api)
|
||||
guard case let .accountDeletionPending(verifiedResponse, request) = initialResolution else {
|
||||
return XCTFail("应先提示账号正在注销中")
|
||||
}
|
||||
XCTAssertEqual(request.username, "18651857230")
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login"])
|
||||
|
||||
let resumedResolution = try await viewModel.resumeLoginAfterCancelingDeletion(
|
||||
verifiedResponse: verifiedResponse,
|
||||
authAPI: api
|
||||
)
|
||||
guard case let .completed(response, account) = resumedResolution else {
|
||||
return XCTFail("取消注销后应完成登录")
|
||||
}
|
||||
XCTAssertEqual(response.token, "business-token-after-restore")
|
||||
XCTAssertEqual(account.businessUserId, 101)
|
||||
XCTAssertEqual(deletionService.loginState(for: "18651857230"), .none)
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/app/v9/login",
|
||||
"/api/app/v9/set-user",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//
|
||||
// OfflineCollectionFeatureTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 线下收款登记、日汇总、失败回滚与幂等补缴流程测试。
|
||||
final class OfflineCollectionFeatureTests: XCTestCase {
|
||||
private var defaults: UserDefaults!
|
||||
private var calendar: Calendar!
|
||||
private var fixedNow: Date!
|
||||
private var service: OfflineCollectionMockService!
|
||||
private let context = OfflineCollectionContext(
|
||||
collectorId: "U10086",
|
||||
collectorName: "张三",
|
||||
storeId: "S1001",
|
||||
storeName: "那拉提旅拍一店",
|
||||
scenicId: "SC001",
|
||||
scenicName: "那拉提景区"
|
||||
)
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let suiteName = "OfflineCollectionFeatureTests"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")!
|
||||
fixedNow = calendar.date(from: DateComponents(
|
||||
calendar: calendar,
|
||||
timeZone: calendar.timeZone,
|
||||
year: 2026,
|
||||
month: 8,
|
||||
day: 19,
|
||||
hour: 20,
|
||||
minute: 5
|
||||
))!
|
||||
let now = fixedNow!
|
||||
service = OfflineCollectionMockService(
|
||||
defaults: defaults,
|
||||
calendar: calendar,
|
||||
nowProvider: { now },
|
||||
requestDelayNanoseconds: 0
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: "OfflineCollectionFeatureTests")
|
||||
service = nil
|
||||
calendar = nil
|
||||
fixedNow = nil
|
||||
defaults = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testMoneyParserUsesIntegerFenAndEnforcesRange() {
|
||||
XCTAssertEqual(OfflineCollectionMoney.parseFen(".5"), 50)
|
||||
XCTAssertEqual(OfflineCollectionMoney.parseFen("299"), 29_900)
|
||||
XCTAssertEqual(OfflineCollectionMoney.parseFen("99999.99"), 9_999_999)
|
||||
XCTAssertNil(OfflineCollectionMoney.parseFen(""))
|
||||
XCTAssertNil(OfflineCollectionMoney.parseFen("0"))
|
||||
XCTAssertNil(OfflineCollectionMoney.parseFen("-1"))
|
||||
XCTAssertNil(OfflineCollectionMoney.parseFen("1.234"))
|
||||
XCTAssertNil(OfflineCollectionMoney.parseFen("100000"))
|
||||
XCTAssertNil(OfflineCollectionMoney.parseFen("1e2"))
|
||||
XCTAssertEqual(OfflineCollectionMoney.display(29_900), "¥299.00")
|
||||
}
|
||||
|
||||
func testDefaultMockDataContainsTodayAndHistoricalOverdueRecords() {
|
||||
let today = service.getDailySummary(date: "2026-08-19", context: context)
|
||||
let overdue = service.getOverdueSummaries(context: context)
|
||||
|
||||
XCTAssertEqual(today.totalCount, 3)
|
||||
XCTAssertEqual(today.totalAmountFen, 89_700)
|
||||
XCTAssertEqual(today.pendingAmountFen, 89_700)
|
||||
XCTAssertEqual(overdue.count, 1)
|
||||
XCTAssertEqual(overdue.first?.businessDate, "2026-08-18")
|
||||
XCTAssertEqual(overdue.first?.pendingCount, 2)
|
||||
XCTAssertEqual(overdue.first?.pendingAmountFen, 50_000)
|
||||
XCTAssertEqual(overdue.first?.hasOverdue, true)
|
||||
}
|
||||
|
||||
func testCreateCollectionUpdatesSummaryAndPersists() async throws {
|
||||
let receipt = try await service.createOfflineCollection(
|
||||
amountFen: 29_900,
|
||||
paymentMethod: .wechat,
|
||||
context: context
|
||||
)
|
||||
|
||||
XCTAssertEqual(receipt.record.status, .pending)
|
||||
XCTAssertEqual(receipt.summary.totalCount, 4)
|
||||
XCTAssertEqual(receipt.summary.totalAmountFen, 119_600)
|
||||
XCTAssertEqual(receipt.summary.pendingAmountFen, 119_600)
|
||||
|
||||
let now = fixedNow!
|
||||
let reloaded = OfflineCollectionMockService(
|
||||
defaults: defaults,
|
||||
calendar: calendar,
|
||||
nowProvider: { now },
|
||||
requestDelayNanoseconds: 0
|
||||
)
|
||||
XCTAssertEqual(reloaded.getDailySummary(date: "2026-08-19", context: context), receipt.summary)
|
||||
}
|
||||
|
||||
func testSuccessfulSettlementGeneratesIndependentBatchAndSettlesSnapshot() async throws {
|
||||
let records = service.getDailyRecords(date: "2026-08-19", context: context)
|
||||
let request = OfflineSettlementRequest(
|
||||
businessDate: "2026-08-19",
|
||||
recordIds: records.map(\.id),
|
||||
amountFen: records.reduce(0) { $0 + $1.amountFen },
|
||||
clientRequestId: "LOCAL-SUCCESS-001"
|
||||
)
|
||||
|
||||
let batch = try await service.settleDailyCollections(request: request, context: context)
|
||||
let summary = service.getDailySummary(date: "2026-08-19", context: context)
|
||||
let settledRecords = service.getDailyRecords(date: "2026-08-19", context: context)
|
||||
|
||||
XCTAssertEqual(batch.amountFen, 89_700)
|
||||
XCTAssertEqual(batch.recordCount, 3)
|
||||
XCTAssertEqual(batch.transactionType, "OFFLINE_COLLECTION_SETTLEMENT")
|
||||
XCTAssertEqual(summary.totalAmountFen, summary.settledAmountFen + summary.pendingAmountFen)
|
||||
XCTAssertEqual(summary.pendingAmountFen, 0)
|
||||
XCTAssertTrue(settledRecords.allSatisfy { $0.status == .settled })
|
||||
XCTAssertTrue(settledRecords.allSatisfy { $0.settlementBatchId == batch.id })
|
||||
}
|
||||
|
||||
func testFailedSettlementDoesNotCreateBatchOrChangeRecords() async throws {
|
||||
let before = service.getDailyRecords(date: "2026-08-19", context: context)
|
||||
let request = OfflineSettlementRequest(
|
||||
businessDate: "2026-08-19",
|
||||
recordIds: before.map(\.id),
|
||||
amountFen: before.reduce(0) { $0 + $1.amountFen },
|
||||
clientRequestId: "LOCAL-FAILURE-001"
|
||||
)
|
||||
service.simulateNextSettlementFailure()
|
||||
|
||||
do {
|
||||
_ = try await service.settleDailyCollections(request: request, context: context)
|
||||
XCTFail("应返回 Mock 补缴失败")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? OfflineCollectionError, .settlementFailed)
|
||||
}
|
||||
|
||||
XCTAssertEqual(service.getDailyRecords(date: "2026-08-19", context: context), before)
|
||||
XCTAssertTrue(service.getSettlementBatches(date: "2026-08-19", context: context).isEmpty)
|
||||
XCTAssertEqual(service.getDailySummary(date: "2026-08-19", context: context).pendingAmountFen, 89_700)
|
||||
}
|
||||
|
||||
func testSettlementOnlySettlesFrozenRecordSnapshot() async throws {
|
||||
let frozenRecords = service.getDailyRecords(date: "2026-08-19", context: context)
|
||||
let request = OfflineSettlementRequest(
|
||||
businessDate: "2026-08-19",
|
||||
recordIds: frozenRecords.map(\.id),
|
||||
amountFen: frozenRecords.reduce(0) { $0 + $1.amountFen },
|
||||
clientRequestId: "LOCAL-SNAPSHOT-001"
|
||||
)
|
||||
let newReceipt = try await service.createOfflineCollection(
|
||||
amountFen: 12_300,
|
||||
paymentMethod: .cash,
|
||||
context: context
|
||||
)
|
||||
|
||||
_ = try await service.settleDailyCollections(request: request, context: context)
|
||||
|
||||
let records = service.getDailyRecords(date: "2026-08-19", context: context)
|
||||
let newRecord = try XCTUnwrap(records.first(where: { $0.id == newReceipt.record.id }))
|
||||
XCTAssertEqual(newRecord.status, .pending)
|
||||
XCTAssertEqual(service.getDailySummary(date: "2026-08-19", context: context).pendingAmountFen, 12_300)
|
||||
}
|
||||
|
||||
func testSameClientRequestIdDoesNotCreateDuplicateBatch() async throws {
|
||||
let records = service.getDailyRecords(date: "2026-08-19", context: context)
|
||||
let request = OfflineSettlementRequest(
|
||||
businessDate: "2026-08-19",
|
||||
recordIds: records.map(\.id),
|
||||
amountFen: records.reduce(0) { $0 + $1.amountFen },
|
||||
clientRequestId: "LOCAL-IDEMPOTENT-001"
|
||||
)
|
||||
|
||||
let first = try await service.settleDailyCollections(request: request, context: context)
|
||||
let second = try await service.settleDailyCollections(request: request, context: context)
|
||||
|
||||
XCTAssertEqual(first, second)
|
||||
XCTAssertEqual(service.getSettlementBatches(date: "2026-08-19", context: context).count, 1)
|
||||
}
|
||||
|
||||
func testRegistrationViewModelValidatesAndSubmitsForm() async {
|
||||
let viewModel = OfflineCollectionRegistrationViewModel(context: context, service: service)
|
||||
var successReceipt: OfflineCollectionRegistrationReceipt?
|
||||
viewModel.onRegistrationSuccess = { successReceipt = $0 }
|
||||
|
||||
XCTAssertEqual(viewModel.paymentMethod, .wechat)
|
||||
viewModel.updateAmount("299")
|
||||
XCTAssertTrue(viewModel.canSubmit)
|
||||
|
||||
await viewModel.submit()
|
||||
|
||||
XCTAssertEqual(successReceipt?.record.amountFen, 29_900)
|
||||
XCTAssertEqual(successReceipt?.record.paymentMethod, .wechat)
|
||||
XCTAssertFalse(viewModel.isSubmitting)
|
||||
XCTAssertEqual(successReceipt?.summary.totalCount, 4)
|
||||
}
|
||||
}
|
||||
@@ -60,12 +60,44 @@ final class TravelAlbumOTGStorageTests: XCTestCase {
|
||||
XCTAssertEqual(store.absoluteURL(for: loaded.first?.localPath ?? "", albumId: 33), file)
|
||||
}
|
||||
|
||||
func testLoadNormalizesInterruptedRetouchToUploadedFailure() throws {
|
||||
let root = try makeTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = makeStore(root: root)
|
||||
let file = try store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 33)
|
||||
try Data([1, 2, 3]).write(to: file)
|
||||
let record = TravelAlbumOTGPhotoRecord(
|
||||
id: "retouching",
|
||||
fileName: "A.JPG",
|
||||
localPath: file.path,
|
||||
capturedAt: "2026-07-07 10:00:00",
|
||||
fileSizeBytes: 3,
|
||||
status: .uploading,
|
||||
progress: 100,
|
||||
albumId: 33,
|
||||
userId: "u1",
|
||||
remoteUrl: "https://cdn/a.jpg",
|
||||
materialId: 10,
|
||||
autoRetouchState: .processing,
|
||||
autoRetouchTemplateId: "brocade"
|
||||
)
|
||||
|
||||
store.save([record], albumId: 33)
|
||||
let loaded = try XCTUnwrap(store.load(albumId: 33).first)
|
||||
|
||||
XCTAssertEqual(loaded.status, .uploaded)
|
||||
XCTAssertEqual(loaded.progress, 100)
|
||||
XCTAssertEqual(loaded.autoRetouchState, .failed)
|
||||
XCTAssertEqual(loaded.autoRetouchTemplateId, "brocade")
|
||||
}
|
||||
|
||||
func testRemoveAndClearAlbumDeleteFiles() throws {
|
||||
let root = try makeTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = makeStore(root: root)
|
||||
let file = try store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 33)
|
||||
try Data([1]).write(to: file)
|
||||
let retouchedFile = try store.writeRetouchedImage(Data([2]), filename: "A.JPG", albumId: 33)
|
||||
let record = TravelAlbumOTGPhotoRecord(
|
||||
id: "1",
|
||||
fileName: "A.JPG",
|
||||
@@ -74,13 +106,17 @@ final class TravelAlbumOTGStorageTests: XCTestCase {
|
||||
fileSizeBytes: 1,
|
||||
status: .pending,
|
||||
albumId: 33,
|
||||
userId: "u1"
|
||||
userId: "u1",
|
||||
autoRetouchState: .completed,
|
||||
autoRetouchTemplateId: "brocade",
|
||||
retouchedPath: retouchedFile.path
|
||||
)
|
||||
store.save([record], albumId: 33)
|
||||
|
||||
store.remove(albumId: 33, photoId: "1")
|
||||
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: file.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: retouchedFile.path))
|
||||
XCTAssertTrue(store.load(albumId: 33).isEmpty)
|
||||
|
||||
let another = try store.uniqueOriginalFileURL(filename: "B.JPG", albumId: 33)
|
||||
|
||||
@@ -59,6 +59,55 @@ final class TravelAlbumEntryViewModelTests: XCTestCase {
|
||||
XCTAssertFalse(viewModel.isCreateSheetVisible)
|
||||
}
|
||||
|
||||
func testCreateSuccessPersistsSelectedAutoRetouchTemplate() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.createResponse = TravelAlbumCreateResponse(id: 19)
|
||||
let defaults = makeOTGTestDefaults()
|
||||
let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults)
|
||||
let viewModel = TravelAlbumEntryViewModel(
|
||||
currentScenicIdProvider: { 1 },
|
||||
autoRetouchConfigurationStore: configurationStore
|
||||
)
|
||||
|
||||
await viewModel.confirmCreate(
|
||||
mode: .preShoot,
|
||||
freeCount: "2",
|
||||
singlePrice: "12.5",
|
||||
packagePrice: "88",
|
||||
autoRetouchConfiguration: .enabled(templateID: "brocade"),
|
||||
order: nil,
|
||||
api: api
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
configurationStore.configuration(albumID: 19),
|
||||
.enabled(templateID: "brocade")
|
||||
)
|
||||
}
|
||||
|
||||
func testCreateRejectsEnabledAutoRetouchWithoutValidTemplate() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
|
||||
var message = ""
|
||||
viewModel.onShowMessage = { message = $0 }
|
||||
|
||||
await viewModel.confirmCreate(
|
||||
mode: .preShoot,
|
||||
freeCount: "",
|
||||
singlePrice: "12.5",
|
||||
packagePrice: "",
|
||||
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
|
||||
isEnabled: true,
|
||||
templateID: "missing-template"
|
||||
),
|
||||
order: nil,
|
||||
api: api
|
||||
)
|
||||
|
||||
XCTAssertEqual(message, "请选择修图模板")
|
||||
XCTAssertTrue(api.createRequests.isEmpty)
|
||||
}
|
||||
|
||||
func testOrderAlreadyBoundReloadsOrders() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.createError = APIError.serverCode(100001, "订单已关联")
|
||||
@@ -73,6 +122,31 @@ final class TravelAlbumEntryViewModelTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 自动修图选择弹窗的界面状态测试。
|
||||
final class TravelAlbumAutoRetouchSettingSheetTests: XCTestCase {
|
||||
func testSelectingTemplateImmediatelyRefreshesSelectedAppearance() throws {
|
||||
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
|
||||
configuration: .disabled,
|
||||
startsWithModeSelection: false
|
||||
)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 402, height: 874)
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
let tableView = try XCTUnwrap(
|
||||
controller.view.subviews.compactMap { $0 as? UITableView }.first
|
||||
)
|
||||
let selectedIndexPath = IndexPath(row: 1, section: 0)
|
||||
|
||||
tableView.delegate?.tableView?(tableView, didSelectRowAt: selectedIndexPath)
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
XCTAssertEqual(tableView.cellForRow(at: selectedIndexPath)?.accessibilityValue, "已选择")
|
||||
XCTAssertEqual(tableView.cellForRow(at: IndexPath(row: 0, section: 0))?.accessibilityValue, "未选择")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 旅拍相册详情 ViewModel 测试。
|
||||
final class TravelAlbumDetailViewModelTests: XCTestCase {
|
||||
@@ -785,6 +859,134 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
XCTAssertTrue(records.allSatisfy { UUID(uuidString: $0.clientPhotoId) != nil })
|
||||
}
|
||||
|
||||
func testAutoRetouchConfigurationLoadsAndUpdatesUploadChip() {
|
||||
let context = makeOTGTestContext()
|
||||
let defaults = makeOTGTestDefaults()
|
||||
let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults)
|
||||
configurationStore.save(.enabled(templateID: "brocade"), albumID: 9)
|
||||
let viewModel = makeWiredViewModel(
|
||||
context: context,
|
||||
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||
userDefaults: defaults,
|
||||
autoRetouchConfigurationStore: configurationStore
|
||||
)
|
||||
|
||||
XCTAssertEqual(viewModel.retouchOption, "AI 修图")
|
||||
|
||||
viewModel.updateAutoRetouchConfiguration(.disabled)
|
||||
|
||||
XCTAssertEqual(viewModel.retouchOption, "不修图")
|
||||
XCTAssertEqual(configurationStore.configuration(albumID: 9), .disabled)
|
||||
}
|
||||
|
||||
func testAutoRetouchFirstLevelOnlyContainsNoRetouchAndAIRetouch() {
|
||||
XCTAssertEqual(TravelAlbumRetouchMode.allCases, [.disabled, .aiRetouch])
|
||||
XCTAssertEqual(TravelAlbumRetouchMode.disabled.title, "不修图")
|
||||
XCTAssertEqual(TravelAlbumRetouchMode.aiRetouch.title, "AI 修图")
|
||||
}
|
||||
|
||||
func testUploadAutomaticallyRetouchesAndPersistsEditedResult() async throws {
|
||||
let context = makeOTGTestContext()
|
||||
defer { try? FileManager.default.removeItem(at: context.root) }
|
||||
let defaults = makeOTGTestDefaults()
|
||||
let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults)
|
||||
configurationStore.save(.enabled(templateID: "brocade"), albumID: 9)
|
||||
let materialID = 990_901
|
||||
let uploader = MockTravelAlbumOTGUploader(materialID: materialID)
|
||||
let processor = MockTravelAlbumAutoRetouchProcessor()
|
||||
let resultStore = TravelAlbumAIEditResultStore.shared
|
||||
resultStore.remove(materialID: materialID)
|
||||
defer { resultStore.remove(materialID: materialID) }
|
||||
let record = try makePersistedOTGRecord(
|
||||
context: context,
|
||||
id: "auto-retouch",
|
||||
capturedAt: "2026-07-08 12:05:00",
|
||||
status: .pending
|
||||
)
|
||||
context.store.save([record], albumId: 9)
|
||||
let viewModel = makeWiredViewModel(
|
||||
context: context,
|
||||
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||
uploader: uploader,
|
||||
userDefaults: defaults,
|
||||
autoRetouchConfigurationStore: configurationStore,
|
||||
autoRetouchProcessor: processor
|
||||
)
|
||||
|
||||
viewModel.reloadLocalPhotos()
|
||||
viewModel.onSpecifyUploadOptionSelected(.allPending)
|
||||
await waitUntil(timeout: 2) {
|
||||
context.store.load(albumId: 9).first?.autoRetouchState == .completed
|
||||
}
|
||||
|
||||
let saved = try XCTUnwrap(context.store.load(albumId: 9).first)
|
||||
XCTAssertEqual(saved.status, .uploaded)
|
||||
XCTAssertEqual(saved.materialId, materialID)
|
||||
XCTAssertEqual(saved.autoRetouchState, .completed)
|
||||
XCTAssertEqual(saved.autoRetouchTemplateId, "brocade")
|
||||
XCTAssertTrue(context.store.fileExists(relativePath: saved.retouchedPath, albumId: 9))
|
||||
XCTAssertEqual(processor.processedPresetIDs, ["brocade"])
|
||||
XCTAssertEqual(resultStore.state(for: materialID).appliedPresetID, "brocade")
|
||||
XCTAssertEqual(viewModel.photos.first?.autoRetouchState, .completed)
|
||||
|
||||
viewModel.deletePhoto(photoId: saved.id)
|
||||
|
||||
XCTAssertFalse(context.store.fileExists(relativePath: saved.retouchedPath, albumId: 9))
|
||||
XCTAssertFalse(resultStore.state(for: materialID).hasEditedResult)
|
||||
}
|
||||
|
||||
func testAutoRetouchFailureKeepsUploadSuccessfulAndCanRetryWithoutReupload() async throws {
|
||||
let context = makeOTGTestContext()
|
||||
defer { try? FileManager.default.removeItem(at: context.root) }
|
||||
let defaults = makeOTGTestDefaults()
|
||||
let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults)
|
||||
configurationStore.save(.enabled(templateID: "mist"), albumID: 9)
|
||||
let materialID = 990_902
|
||||
let resultStore = TravelAlbumAIEditResultStore.shared
|
||||
resultStore.remove(materialID: materialID)
|
||||
defer { resultStore.remove(materialID: materialID) }
|
||||
let uploader = MockTravelAlbumOTGUploader(materialID: materialID)
|
||||
let processor = MockTravelAlbumAutoRetouchProcessor()
|
||||
processor.shouldFail = true
|
||||
let record = try makePersistedOTGRecord(
|
||||
context: context,
|
||||
id: "retry-retouch",
|
||||
capturedAt: "2026-07-08 12:05:00",
|
||||
status: .pending
|
||||
)
|
||||
context.store.save([record], albumId: 9)
|
||||
let viewModel = makeWiredViewModel(
|
||||
context: context,
|
||||
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||
uploader: uploader,
|
||||
userDefaults: defaults,
|
||||
autoRetouchConfigurationStore: configurationStore,
|
||||
autoRetouchProcessor: processor
|
||||
)
|
||||
|
||||
viewModel.reloadLocalPhotos()
|
||||
viewModel.onSpecifyUploadOptionSelected(.allPending)
|
||||
await waitUntil(timeout: 2) {
|
||||
context.store.load(albumId: 9).first?.autoRetouchState == .failed
|
||||
}
|
||||
|
||||
var saved = try XCTUnwrap(context.store.load(albumId: 9).first)
|
||||
XCTAssertEqual(saved.status, .uploaded)
|
||||
XCTAssertEqual(viewModel.tabCounts[TravelAlbumOTGTransferTab.failed.rawValue], 0)
|
||||
XCTAssertEqual(uploader.uploadCallCount, 1)
|
||||
|
||||
processor.shouldFail = false
|
||||
viewModel.retryAutoRetouch(photoId: saved.id)
|
||||
await waitUntil(timeout: 2) {
|
||||
context.store.load(albumId: 9).first?.autoRetouchState == .completed
|
||||
}
|
||||
|
||||
saved = try XCTUnwrap(context.store.load(albumId: 9).first)
|
||||
XCTAssertEqual(saved.autoRetouchState, .completed)
|
||||
XCTAssertEqual(uploader.uploadCallCount, 1)
|
||||
XCTAssertEqual(processor.processedPresetIDs, ["mist", "mist"])
|
||||
}
|
||||
|
||||
func testStartSynchronizesPersistedUploadStatusesFromServerClientPhotoIds() async {
|
||||
let context = makeOTGTestContext()
|
||||
let api = TravelAlbumMockAPI()
|
||||
@@ -1013,9 +1215,13 @@ private func makeWiredViewModel(
|
||||
manager: MockWiredCameraConnectionManager,
|
||||
uploader: MockTravelAlbumOTGUploader? = nil,
|
||||
api: TravelAlbumMockAPI? = nil,
|
||||
userDefaults: UserDefaults = makeOTGTestDefaults()
|
||||
userDefaults: UserDefaults = makeOTGTestDefaults(),
|
||||
autoRetouchConfigurationStore: (any TravelAlbumAutoRetouchConfigurationStoring)? = nil,
|
||||
autoRetouchProcessor: (any TravelAlbumAutoRetouchProcessing)? = nil
|
||||
) -> WiredCameraTransferViewModel {
|
||||
prepareAppStoreForOTGTests()
|
||||
let configurationStore = autoRetouchConfigurationStore
|
||||
?? TravelAlbumAutoRetouchConfigurationStore(userDefaults: userDefaults)
|
||||
return WiredCameraTransferViewModel(
|
||||
albumId: albumId,
|
||||
albumTitle: "相册",
|
||||
@@ -1024,7 +1230,9 @@ private func makeWiredViewModel(
|
||||
storage: context.store,
|
||||
uploader: uploader ?? MockTravelAlbumOTGUploader(),
|
||||
api: api ?? TravelAlbumMockAPI(),
|
||||
userDefaults: userDefaults
|
||||
userDefaults: userDefaults,
|
||||
autoRetouchConfigurationStore: configurationStore,
|
||||
autoRetouchProcessor: autoRetouchProcessor
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1054,7 +1262,9 @@ private func makeOTGPhotoItem(
|
||||
progress: 0,
|
||||
errorMessage: nil,
|
||||
localPath: "originals/\(id).JPG",
|
||||
remoteUrl: ""
|
||||
remoteUrl: "",
|
||||
autoRetouchState: .none,
|
||||
autoRetouchTemplateId: nil
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1225,6 +1435,11 @@ private final class MockCameraDriver: CameraDriver {
|
||||
private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
||||
private(set) var uploadCallCount = 0
|
||||
private(set) var uploadedRecordIds: [String] = []
|
||||
private let materialID: Int
|
||||
|
||||
init(materialID: Int = 1) {
|
||||
self.materialID = materialID
|
||||
}
|
||||
|
||||
func upload(
|
||||
record: TravelAlbumOTGPhotoRecord,
|
||||
@@ -1233,7 +1448,22 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
||||
) async throws -> TravelAlbumMaterial {
|
||||
uploadCallCount += 1
|
||||
uploadedRecordIds.append(record.id)
|
||||
return TravelAlbumMaterial(id: 1, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)")
|
||||
return TravelAlbumMaterial(id: materialID, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动修图测试替身,可切换失败以验证重试闭环。
|
||||
@MainActor
|
||||
private final class MockTravelAlbumAutoRetouchProcessor: TravelAlbumAutoRetouchProcessing {
|
||||
var shouldFail = false
|
||||
private(set) var processedPresetIDs: [String] = []
|
||||
|
||||
func process(sourceURL: URL, preset: TravelAlbumEditPreset) async throws -> Data {
|
||||
processedPresetIDs.append(preset.id)
|
||||
if shouldFail {
|
||||
throw TravelAlbumAutoRetouchError.resultEncodingFailed
|
||||
}
|
||||
return Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6rE8AAAAASUVORK5CYII=")!
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user