Add order tail flows and migrate queue, message, settlement, and audit modules.

Wire deposit orders, historical shooting, and multi-travel uploads into Orders, and replace home placeholders for queue management, message center, scenic settlement, and withdrawal audit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 13:39:02 +08:00
parent fb8430889b
commit 1a0d1c25f4
63 changed files with 9823 additions and 58 deletions

View File

@ -0,0 +1,227 @@
//
// QueueManagementViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/25.
//
import XCTest
@testable import suixinkan
@MainActor
///
final class QueueManagementViewModelTests: XCTestCase {
override func setUp() {
super.setUp()
clearQueueDefaults()
}
func testReloadWithoutScenicClearsState() async {
let api = ScenicQueueMock()
let viewModel = QueueManagementViewModel()
await viewModel.reload(api: api, scenicId: nil, userId: "77", spots: sampleSpots)
XCTAssertTrue(viewModel.items.isEmpty)
XCTAssertNil(viewModel.selectedSpotId)
XCTAssertFalse(viewModel.queueGatePassed)
XCTAssertEqual(viewModel.queueCount, 0)
}
func testReloadWithoutSavedSpotShowsGateAndDoesNotRequestQueue() async {
let api = ScenicQueueMock()
let viewModel = QueueManagementViewModel()
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
XCTAssertEqual(viewModel.scenicSpots.map(\.id), [901, 902])
XCTAssertNil(viewModel.selectedSpotId)
XCTAssertFalse(viewModel.queueGatePassed)
XCTAssertTrue(api.homeRequests.isEmpty)
XCTAssertTrue(api.statsRequests.isEmpty)
}
func testReloadWithSavedSpotLoadsHomeAndStats() async {
saveSelectedSpot()
let api = ScenicQueueMock()
api.homeResponses = [ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001), ticket(id: 1002)], total: 2), time: "2026-06-25 10:01")]
api.statsResponse = ScenicQueueStatsData(queueCount: 5, avgWaitMin: 12.5, time: "2026-06-25 10:02")
let viewModel = QueueManagementViewModel()
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
XCTAssertEqual(viewModel.selectedSpotId, 901)
XCTAssertTrue(viewModel.queueGatePassed)
XCTAssertEqual(viewModel.items.map(\.id), [1001, 1002])
XCTAssertEqual(viewModel.queueCount, 5)
XCTAssertEqual(viewModel.avgWaitMin, 12.5)
XCTAssertEqual(viewModel.lastSyncTimeText, "2026-06-25 10:02")
XCTAssertEqual(api.homeRequests.first?.type, QueueListType.queueing.rawValue)
}
func testStatsFailureKeepsQueueList() async {
saveSelectedSpot()
let api = ScenicQueueMock()
api.homeResponses = [ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001)], total: 1), time: "home-time")]
api.statsError = QueueTestError.sample
let viewModel = QueueManagementViewModel()
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
XCTAssertEqual(viewModel.items.map(\.id), [1001])
XCTAssertEqual(viewModel.queueCount, 0)
XCTAssertEqual(viewModel.lastSyncTimeText, "home-time")
}
func testCallPassFinishRequeueAndUserMark() async throws {
saveSelectedSpot()
let api = ScenicQueueMock()
api.homeResponses = Array(repeating: ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001)], total: 1)), count: 8)
let viewModel = QueueManagementViewModel()
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
try await viewModel.callQueue(api: api, id: 1001)
XCTAssertEqual(api.callIds, [1001])
XCTAssertEqual(viewModel.items.first?.isCalled, 1)
try await viewModel.passQueue(api: api, scenicId: 88, userId: "77", id: 1001)
XCTAssertEqual(api.passIds, [1001])
try await viewModel.finishQueue(api: api, scenicId: 88, userId: "77", id: 1001)
XCTAssertEqual(api.finishIds, [1001])
try await viewModel.requeue(api: api, scenicId: 88, userId: "77", id: 1001, operatorId: 77)
XCTAssertEqual(api.requeueRequests.map(\.recordId), [1001])
XCTAssertEqual(api.requeueRequests.map(\.operatorId), [77])
XCTAssertEqual(viewModel.selectedListType, .queueing)
try await viewModel.userMark(
api: api,
scenicId: 88,
userId: "77",
request: ScenicQueueUserMarkRequest(uid: 7001, scenicId: 88, markAsFreelancePhotog: 1, queueBanDays: 14, operatorId: 77)
)
XCTAssertEqual(api.userMarkRequests.first?.uid, 7001)
XCTAssertEqual(api.userMarkRequests.first?.queueBanDays, 14)
}
func testSocketSubscriptionAndParse() {
XCTAssertEqual(ScenicQueueSocketClient.subscriptionPayloads(scenicSpotId: 901), [
#"{"type":306,"params":{"scenic_spot_id":901}}"#,
#"{"type":307,"params":{"scenic_spot_id":901}}"#
])
let message = ScenicQueueSocketClient.parseMessage("""
{"code":100000,"data":{"action":"scenic_queue_ticket_called","params":{"scenic_spot_id":"901","record_id":"1002","operator_uid":"77","event_id":"evt-1"}}}
""")
XCTAssertEqual(message?.code, 100000)
XCTAssertTrue(message?.isScenicQueueEvent == true)
XCTAssertEqual(message?.data?.params?.scenicSpotId, 901)
XCTAssertEqual(message?.data?.params?.recordId, 1002)
XCTAssertEqual(message?.data?.params?.operatorUid, 77)
XCTAssertEqual(message?.data?.params?.eventId, "evt-1")
}
}
private let sampleSpots = [
ScenicSpotItem(id: 901, name: "东门打卡点"),
ScenicSpotItem(id: 902, name: "西门打卡点")
]
private func saveSelectedSpot() {
ScenicQueueSettingsStore.saveSelectedSpot(id: 901, name: "东门打卡点", userId: "77", scenicId: 88)
}
private func clearQueueDefaults() {
let defaults = UserDefaults.standard
[
ScenicQueueLocalSettings.selectedSpotIdKey,
ScenicQueueLocalSettings.selectedSpotNameKey,
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: "77", scenicId: 88),
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: "77", scenicId: 88),
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: "77", scenicId: 88, spotId: 901)
].forEach(defaults.removeObject)
}
private func ticket(id: Int64, isCalled: Int = 0) -> ScenicQueueTicket {
ScenicQueueTicket(
id: id,
queueCode: "A\(id)",
mobile: "13800000000",
status: 0,
statusText: "",
waitMin: 5,
aheadCount: 1,
isCalled: isCalled,
createdAt: "2026-06-25 10:00:00",
calledAt: "",
expiredAt: "",
finishedAt: "",
queueCountToday: 2,
uid: 7001
)
}
@MainActor
final class ScenicQueueMock: ScenicQueueServing {
var homeResponses: [ScenicQueueHomeData] = []
var statsResponse = ScenicQueueStatsData()
var statsError: Error?
var settingResponse = ScenicQueueSettingData()
var callIds: [Int64] = []
var passIds: [Int64] = []
var finishIds: [Int64] = []
var requeueRequests: [(recordId: Int64, operatorId: Int)] = []
var userMarkRequests: [ScenicQueueUserMarkRequest] = []
var saveSettingRequests: [ScenicQueueSaveSettingRequest] = []
var homeRequests: [(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int)] = []
var statsRequests: [(scenicId: Int, scenicSpotId: Int)] = []
var qrcodeURL = "https://example.com/qrcode.png"
var logs = ScenicQueueSettingChangeLogData()
func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData {
statsRequests.append((scenicId, scenicSpotId))
if let statsError { throw statsError }
return statsResponse
}
func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData {
homeRequests.append((scenicId, scenicSpotId, type, page, pageSize))
return homeResponses.isEmpty ? ScenicQueueHomeData() : homeResponses.removeFirst()
}
func socketToken() async throws -> SocketTokenResponse { SocketTokenResponse(socketToken: "token") }
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData {
callIds.append(id)
return ScenicQueueCallData(id: id, status: 1, statusText: "已叫号")
}
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData {
passIds.append(id)
return ScenicQueuePassData(id: id)
}
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData {
finishIds.append(id)
return ScenicQueueFinishData(id: id)
}
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws {
requeueRequests.append((recordId, operatorId))
}
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws {
userMarkRequests.append(request)
}
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData { settingResponse }
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws {
saveSettingRequests.append(request)
}
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData {
ScenicQueueShootQueueQRCodeData(qrcodeUrl: qrcodeURL)
}
func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData {
logs
}
}
private enum QueueTestError: Error {
case sample
}

View File

@ -0,0 +1,182 @@
//
// ScenicQueueSettingsViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/25.
//
import XCTest
@testable import suixinkan
@MainActor
///
final class ScenicQueueSettingsViewModelTests: XCTestCase {
override func setUp() {
super.setUp()
clearQueueSettingsDefaults()
}
func testLoadSelectsFirstSpotAndAppliesLocalSnapshotWhenServerMissing() async {
let snapshot = ScenicQueueSettingsSnapshot(
shootMinute: 4,
shootSecond: 15,
firstAheadCount: 6,
firstSms: false,
firstPhone: true,
secondAheadCount: 2,
secondSms: true,
secondPhone: false,
broadcastIntervalSec: 55,
countdownThresholdSec: 25,
queueDistanceMeter: 8_880,
queueTakeLimit: 11,
missCallRequeueOffset: 5,
showStartShootingButton: false,
autoCallAheadCount: 3,
quickCallButtonEnabled: true,
prepareCallButtonEnabled: true,
businessOpen: false,
businessStartTime: "09:30",
businessEndTime: "18:45"
)
ScenicQueueSettingsStore.saveSettingsSnapshot(snapshot, userId: "77", scenicId: 88, spotId: 901)
let api = ScenicQueueMock()
api.settingResponse = ScenicQueueSettingData(exists: false)
let viewModel = ScenicQueueSettingsViewModel()
await viewModel.load(api: api, scenicId: 88, userId: "77", spots: settingsSpots)
XCTAssertEqual(viewModel.selectedSpotId, 901)
XCTAssertEqual(viewModel.photoEstimateMin, "4")
XCTAssertEqual(viewModel.photoEstimateSec, "15")
XCTAssertEqual(viewModel.firstAhead, "6")
XCTAssertTrue(viewModel.firstPhone)
XCTAssertEqual(viewModel.secondAhead, "2")
XCTAssertEqual(viewModel.broadcastIntervalSec, "55")
XCTAssertEqual(viewModel.countdownThresholdSec, "25")
XCTAssertEqual(viewModel.maxQueueRangeKm, "8.88")
XCTAssertEqual(viewModel.localQueueLimit, "11")
XCTAssertEqual(viewModel.localPassDelay, "5")
XCTAssertFalse(viewModel.showStartShootingButton)
XCTAssertEqual(viewModel.autoCallAheadCount, "3")
XCTAssertTrue(viewModel.quickCallButtonEnabled)
XCTAssertTrue(viewModel.prepareCallButtonEnabled)
XCTAssertFalse(viewModel.queueOpen)
}
func testSaveNormalizesBodyAndPersistsSnapshot() async throws {
let api = ScenicQueueMock()
let viewModel = ScenicQueueSettingsViewModel()
viewModel.scenicSpots = settingsSpots
viewModel.selectedSpotId = 901
viewModel.photoEstimateMin = "1"
viewModel.photoEstimateSec = "20"
viewModel.firstAhead = "5"
viewModel.secondAhead = "2"
viewModel.broadcastIntervalSec = "45"
viewModel.countdownThresholdSec = "20"
viewModel.localQueueLimit = "7"
viewModel.localPassDelay = "3"
viewModel.showStartShootingButton = false
viewModel.autoCallAheadCount = "4"
viewModel.quickCallButtonEnabled = true
viewModel.prepareCallButtonEnabled = true
viewModel.queueOpen = false
viewModel.customTtsText = " {number} 请到拍摄点 "
viewModel.presetVoices = ["预设播报", " ", "第二条"]
viewModel.businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 11, minute: 0)
viewModel.businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 21, minute: 30)
viewModel.updateMaxQueueRange("12.3")
try await viewModel.save(api: api, scenicId: 88, userId: "77")
let request = try XCTUnwrap(api.saveSettingRequests.first)
XCTAssertEqual(request.scenicId, 88)
XCTAssertEqual(request.scenicSpotId, 901)
XCTAssertEqual(request.photoEstimateMin, 1)
XCTAssertEqual(request.photoEstimateSec, 20)
XCTAssertEqual(request.queueDistanceMeter, 12_300)
XCTAssertEqual(request.queueTakeLimit, 7)
XCTAssertEqual(request.missCallRequeueOffset, 3)
XCTAssertEqual(request.showStartShootButton, 0)
XCTAssertEqual(request.autoCallNextCount, 4)
XCTAssertEqual(request.businessStartTime, "11:00")
XCTAssertEqual(request.businessEndTime, "21:30")
XCTAssertEqual(request.voiceBroadcasts.map(\.content), ["预设播报", "第二条"])
XCTAssertEqual(request.status, 0)
XCTAssertEqual(request.remark, "{number} 请到拍摄点")
XCTAssertEqual(ScenicQueueSettingsStore.selectedSpotId(userId: "77", scenicId: 88), 901)
let snapshot = try XCTUnwrap(ScenicQueueSettingsStore.settingsSnapshot(userId: "77", scenicId: 88, spotId: 901))
XCTAssertEqual(snapshot.queueDistanceMeter, 12_300)
XCTAssertEqual(snapshot.autoCallAheadCount, 4)
XCTAssertTrue(snapshot.quickCallButtonEnabled)
}
func testValidationRejectsInvalidValues() async {
let api = ScenicQueueMock()
let viewModel = ScenicQueueSettingsViewModel()
viewModel.selectedSpotId = 901
viewModel.photoEstimateMin = "0"
viewModel.photoEstimateSec = "0"
await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77"))
viewModel.photoEstimateSec = "20"
viewModel.firstAhead = "1"
viewModel.secondAhead = "2"
await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77"))
viewModel.firstAhead = "2"
viewModel.secondAhead = "1"
viewModel.broadcastIntervalSec = "30"
await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77"))
}
func testFetchQRCodeRejectsEmptyURL() async {
let api = ScenicQueueMock()
api.qrcodeURL = ""
let viewModel = ScenicQueueSettingsViewModel()
viewModel.selectedSpotId = 901
await XCTAssertThrowsErrorAsync(try await viewModel.fetchQRCode(api: api, scenicId: 88))
}
}
private let settingsSpots = [
ScenicSpotItem(id: 901, name: "东门打卡点"),
ScenicSpotItem(id: 902, name: "西门打卡点")
]
private func clearQueueSettingsDefaults() {
let defaults = UserDefaults.standard
[
ScenicQueueLocalSettings.selectedSpotIdKey,
ScenicQueueLocalSettings.selectedSpotNameKey,
ScenicQueueLocalSettings.customTtsTextKey,
ScenicQueueLocalSettings.photoEstimateSecondsKey,
ScenicQueueLocalSettings.broadcastIntervalSecondsKey,
ScenicQueueLocalSettings.countdownThresholdSecondsKey,
ScenicQueueLocalSettings.showStartShootingButtonKey,
ScenicQueueLocalSettings.autoCallAheadCountKey,
ScenicQueueLocalSettings.quickCallButtonEnabledKey,
ScenicQueueLocalSettings.prepareCallButtonEnabledKey,
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: "77", scenicId: 88),
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: "77", scenicId: 88),
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: "77", scenicId: 88, spotId: 901),
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.customTtsTextKey, userId: "77", scenicId: 88, spotId: 901),
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.presetVoicesKey, userId: "77", scenicId: 88, spotId: 901)
].forEach(defaults.removeObject)
}
private func XCTAssertThrowsErrorAsync<T>(
_ expression: @autoclosure () async throws -> T,
file: StaticString = #filePath,
line: UInt = #line
) async {
do {
try await expression()
XCTFail("Expected error to be thrown", file: file, line: line)
} catch {
}
}