Files
suixinkan_uikit/suixinkanTests/ScenicQueueFeatureTests.swift
lujiuyinandCursor 6336feff27 完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:21:53 +08:00

840 lines
36 KiB
Swift

//
// ScenicQueueFeatureTests.swift
// suixinkanTests
//
import XCTest
import UIKit
@testable import suixinkan
@MainActor
/// 排队管理同步功能测试。
final class ScenicQueueFeatureTests: XCTestCase {
func testModelDecodingAndMapping() throws {
let homeJSON = """
{
"stats":{"type":1},
"list":{
"list":[{
"id":101,
"queue_code":"A12",
"mobile":"13800138000",
"status":1,
"status_text":"待叫号",
"wait_min":8,
"ahead_count":2,
"is_called":0,
"created_at":"2026-07-07 10:20:00",
"identity_tag":"VIP",
"queue_ban_label":"限制1天",
"queue_time":"2026-07-07 10:20:00",
"queue_count_today":3,
"uid":88,
"mark_as_photog":0,
"mark_as_freelance_photog":1,
"is_miss_requeue":1,
"is_miss_requeue_text":"过号重排"
}],
"total":1,
"page":1,
"page_size":10
},
"time":"2026-07-07 10:21:00"
}
""".data(using: .utf8)!
let home = try JSONDecoder().decode(ScenicQueueHomeData.self, from: homeJSON)
let ticket = try XCTUnwrap(home.list?.list.first?.toCurrentTicket())
XCTAssertEqual(ticket.recordId, 101)
XCTAssertEqual(ticket.queueNo, "A12")
XCTAssertEqual(ticket.phoneMasked, "138****8000")
XCTAssertEqual(ticket.dialPhoneDigits, "13800138000")
XCTAssertEqual(ticket.status, .waiting)
XCTAssertTrue(ticket.shouldShowIdentityTag)
XCTAssertEqual(ticket.missRequeueText, "过号重排")
}
func testHomeDecodingAcceptsBackendStatsWithoutNestedType() throws {
let json = """
{
"stats":{"avg_wait_min":0,"queue_count":0},
"list":{"list":[],"page":1,"page_size":10,"total":0},
"type":1
}
""".data(using: .utf8)!
let home = try JSONDecoder().decode(ScenicQueueHomeData.self, from: json)
XCTAssertEqual(home.stats?.type, 0)
XCTAssertEqual(home.list?.list, [])
XCTAssertEqual(home.list?.page, 1)
XCTAssertEqual(home.list?.pageSize, 10)
XCTAssertEqual(home.list?.total, 0)
}
func testSettingDecodingClampsSnapshotAndSocketMessage() throws {
let settingJSON = """
{
"exists":true,
"setting":{
"id":7,
"scenic_id":11,
"scenic_spot_id":22,
"scenic_spot_name":"东门打卡点",
"photo_estimate_min":40,
"photo_estimate_sec":70,
"first_notice_threshold_pos":60,
"first_notice_sms_enabled":1,
"first_notice_call_enabled":0,
"second_notice_threshold_pos":2,
"second_notice_sms_enabled":0,
"second_notice_call_enabled":1,
"countdown_broadcast_interval_sec":99,
"countdown_readable_threshold_sec":2,
"queue_distance_meter":10000000,
"queue_take_limit":1000000,
"miss_call_requeue_offset":0,
"show_start_shoot_button":0,
"auto_call_next_count":9,
"business_start_time":"09:30",
"business_end_time":"18:00",
"voice_broadcasts":[{"content":"请提前准备","sort_order":1}],
"status":0,
"remark":"自定义播报"
}
}
""".data(using: .utf8)!
let data = try JSONDecoder().decode(ScenicQueueSettingData.self, from: settingJSON)
let snapshot = try XCTUnwrap(data.setting).toSnapshot(fallback: ScenicQueueSettingsSnapshot())
XCTAssertEqual(snapshot.shootMinute, 30)
XCTAssertEqual(snapshot.shootSecond, 59)
XCTAssertEqual(snapshot.firstAheadCount, 50)
XCTAssertEqual(snapshot.broadcastIntervalSec, 50)
XCTAssertEqual(snapshot.countdownThresholdSec, 15)
XCTAssertEqual(snapshot.queueDistanceMeter, 9_999_990)
XCTAssertEqual(snapshot.queueTakeLimit, 999_999)
XCTAssertEqual(snapshot.missCallRequeueOffset, 1)
XCTAssertFalse(snapshot.showStartShootingButton)
XCTAssertEqual(snapshot.autoCallAheadCount, 5)
XCTAssertFalse(snapshot.businessOpen)
let socketJSON = """
{"code":100000,"data":{"action":"scenic_queue_ticket_called","params":{"scenic_spot_id":22,"record_id":101,"operator_uid":99,"event_id":"evt-1"}}}
""".data(using: .utf8)!
let message = try JSONDecoder().decode(ScenicQueueSocketMessage.self, from: socketJSON)
XCTAssertTrue(message.isQueueEvent)
XCTAssertEqual(message.data?.params?.eventId, "evt-1")
let sts = try MockScenicQueueAPI.makeSTS()
XCTAssertEqual(sts.bucket, "vipsky")
XCTAssertEqual(sts.credentials.securityToken, "security-token")
}
func testSettingsValidationSaveRequestAndPresetLimit() async {
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.settings")!
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.settings")
let store = AppStore(defaults: defaults)
store.session.userId = "u1"
store.session.accountType = .unknown("photog")
store.session.currentScenicId = 11
let api = MockScenicQueueAPI()
let tts = MockScenicQueueTTSManager()
let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts)
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
await viewModel.save(api: api)
XCTAssertEqual(viewModel.settingsTab, .basic)
XCTAssertTrue(viewModel.highlightedInvalidFieldKeys.contains("punchSpot"))
XCTAssertEqual(messages.last, "请选择打卡点")
await viewModel.setPunchSpot(ScenicQueuePunchSpotOption(id: "22", label: "东门打卡点"), api: api)
viewModel.setShootMinute(9)
viewModel.setShootSecond(30)
viewModel.setFirstAheadCount(4)
viewModel.toggleFirstSms()
viewModel.toggleSecondPhone()
viewModel.setBroadcastIntervalSec(40)
viewModel.setCountdownThresholdSec(30)
viewModel.setQueueDistanceMeter(500)
viewModel.setQueueTakeLimit(6)
viewModel.setMissCallRequeueOffset(2)
viewModel.setAutoCallAheadCount(3)
viewModel.setCustomTTSText("请准备拍摄")
viewModel.saveCurrentTextAsPresetVoiceLocal()
viewModel.setCustomTTSText("第二条")
viewModel.saveCurrentTextAsPresetVoiceLocal()
await viewModel.save(api: api)
let request = api.savedSetting
XCTAssertEqual(request?.scenicId, 11)
XCTAssertEqual(request?.scenicSpotId, 22)
XCTAssertEqual(request?.photoEstimateMin, 9)
XCTAssertEqual(request?.photoEstimateSec, 30)
XCTAssertEqual(request?.firstNoticeThresholdPos, 4)
XCTAssertEqual(request?.firstNoticeSmsEnabled, 1)
XCTAssertEqual(request?.secondNoticeCallEnabled, 1)
XCTAssertEqual(request?.countdownBroadcastIntervalSec, 40)
XCTAssertEqual(request?.countdownReadableThresholdSec, 30)
XCTAssertEqual(request?.queueDistanceMeter, 500)
XCTAssertEqual(request?.queueTakeLimit, 6)
XCTAssertEqual(request?.missCallRequeueOffset, 2)
XCTAssertEqual(request?.autoCallNextCount, 3)
XCTAssertEqual(request?.voiceBroadcasts.map(\.content), ["请准备拍摄", "第二条"])
XCTAssertEqual(store.scenicQueue.punchSpotName, "东门打卡点")
XCTAssertEqual(store.scenicQueue.settingsSnapshot()?.shootMinute, 9)
}
func testMainViewModelGatePagingActionsTTSAndSocketFiltering() async {
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.main")!
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.main")
let store = AppStore(defaults: defaults)
store.session.userId = "9"
store.session.accountType = .unknown("photog")
store.session.currentScenicId = 11
store.scenicQueue.punchSpotId = "22"
store.scenicQueue.punchSpotName = "东门打卡点"
store.scenicQueue.saveSettingsSnapshot(
ScenicQueueSettingsSnapshot(showStartShootingButton: true, quickCallButtonEnabled: true, prepareCallButtonEnabled: true)
)
let api = MockScenicQueueAPI()
api.homePages[1] = .page(
items: [.item(id: 101, queueCode: "A12", isCalled: 0), .item(id: 102, queueCode: "A13", isCalled: 0)],
total: 3,
page: 1
)
api.homePages[2] = .page(
items: [.item(id: 102, queueCode: "A13", isCalled: 0), .item(id: 103, queueCode: "A14", isCalled: 0)],
total: 3,
page: 2
)
let tts = MockScenicQueueTTSManager()
let socket = ScenicQueueSocketClient()
let viewModel = ScenicQueueViewModel(appStore: store, ttsManager: tts, socketClient: socket)
await viewModel.refreshQueueGate(api: api)
XCTAssertTrue(viewModel.queueGatePassed)
XCTAssertEqual(viewModel.selectedPunchSpotLine, "东门打卡点")
XCTAssertEqual(viewModel.summary, ScenicQueueSummary(peopleInQueue: 3, avgWaitMinutes: 6))
XCTAssertEqual(viewModel.currentQueue.map(\.recordId), [101, 102])
await viewModel.loadMoreCurrentQueue(api: api)
XCTAssertEqual(viewModel.currentQueue.map(\.recordId), [101, 102, 103])
viewModel.requestCallConfirm(recordId: 101, queueNo: "A12")
await viewModel.confirmCallTicket(api: api)
XCTAssertEqual(viewModel.currentQueue.first?.status, .called)
XCTAssertEqual(tts.events.last, "start:A12")
viewModel.requestSkipTicket(recordId: 101, queueNo: "A12")
await viewModel.confirmSkipTicket(api: api)
XCTAssertFalse(viewModel.currentQueue.contains { $0.recordId == 101 })
XCTAssertTrue(tts.events.contains("stopQueue"))
let calledMessage = ScenicQueueSocketMessage(
code: 100000,
data: ScenicQueueSocketData(
action: ScenicQueueSocketAction.ticketCalled.rawValue,
params: ScenicQueueSocketParams(scenicSpotId: 22, recordId: 103, operatorUid: 88, eventId: "evt-remote")
)
)
socket.onMessage?(calledMessage)
api.homePages[1] = .page(items: [.item(id: 103, queueCode: "A14", isCalled: 1)], total: 1, page: 1)
await viewModel.refreshCurrentQueue(api: api)
XCTAssertEqual(tts.events.last, "start:A14")
socket.onMessage?(calledMessage)
await viewModel.refreshCurrentQueue(api: api)
XCTAssertEqual(tts.events.filter { $0 == "start:A14" }.count, 1)
let ownMessage = ScenicQueueSocketMessage(
code: 100000,
data: ScenicQueueSocketData(
action: ScenicQueueSocketAction.ticketCalled.rawValue,
params: ScenicQueueSocketParams(scenicSpotId: 22, recordId: 103, operatorUid: 9, eventId: "evt-own")
)
)
socket.onMessage?(ownMessage)
await viewModel.refreshCurrentQueue(api: api)
XCTAssertEqual(tts.events.filter { $0 == "start:A14" }.count, 1)
}
func testAppStoreQueueCacheIsAccountScopedAndClearedOnLogout() {
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.store")!
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.store")
let store = AppStore(defaults: defaults)
store.session.userId = "1"
store.session.accountType = .unknown("photog")
store.session.currentScenicId = 11
store.scenicQueue.punchSpotId = "22"
store.scenicQueue.punchSpotName = "东门打卡点"
store.scenicQueue.remark = "播报"
store.scenicQueue.saveSettingsSnapshot(ScenicQueueSettingsSnapshot(shootMinute: 3))
store.scenicQueue.savePresetVoices(["a", "b", "c", "d", "e", "f"])
store.session.userId = "2"
store.session.accountType = .unknown("photog")
store.session.currentScenicId = 11
XCTAssertEqual(store.scenicQueue.punchSpotId, "")
XCTAssertNil(store.scenicQueue.settingsSnapshot())
store.session.userId = "1"
store.session.accountType = .unknown("photog")
store.session.currentScenicId = 11
XCTAssertEqual(store.scenicQueue.punchSpotId, "22")
XCTAssertEqual(store.scenicQueue.presetVoices(), ["a", "b", "c", "d", "e"])
store.logout()
store.session.userId = "1"
store.session.accountType = .unknown("photog")
store.session.currentScenicId = 11
XCTAssertEqual(store.scenicQueue.punchSpotId, "")
XCTAssertNil(store.scenicQueue.settingsSnapshot())
}
func testAliyunNLSTokenProviderBuildsSignedCreateTokenRequest() async throws {
let credentials = try MockScenicQueueAPI.makeSTS().credentials
let provider = AliyunNLSTokenProvider(
dateProvider: { Date(timeIntervalSince1970: 1_704_067_200) },
nonceProvider: { "nonce-1" },
dataLoader: { request in
let url = try XCTUnwrap(request.url)
let query = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems)
XCTAssertEqual(query.first(where: { $0.name == "Action" })?.value, "CreateToken")
XCTAssertEqual(query.first(where: { $0.name == "AccessKeyId" })?.value, "ak")
XCTAssertEqual(query.first(where: { $0.name == "SecurityToken" })?.value, "security-token")
XCTAssertEqual(query.first(where: { $0.name == "SignatureMethod" })?.value, "HMAC-SHA1")
XCTAssertNotNil(query.first(where: { $0.name == "Signature" })?.value)
return #"{"Token":{"Id":"nls-token","ExpireTime":1704068200}}"#.data(using: .utf8)!
}
)
let token = try await provider.createToken(credentials: credentials)
XCTAssertEqual(token.id, "nls-token")
XCTAssertEqual(token.expireTime, 1_704_068_200)
}
func testAliyunTTSManagerUsesNativeBridgeAndPlayerWhenPrepared() async {
let api = MockScenicQueueAPI()
let tokenProvider = MockAliyunNLSTokenProvider()
let bridge = MockAliyunNuiTTSBridge()
let player = MockAliyunPCMStreamingPlayer()
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.onlineTTS")!
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.onlineTTS")
let store = AppStore(defaults: defaults)
store.session.userId = "u1"
store.session.accountType = .unknown("photog")
store.session.currentScenicId = 11
store.scenicQueue.useOfflineTTS = false
let manager = AliyunNLSTTSManager(
appStore: store,
tokenProvider: tokenProvider,
bridge: bridge,
streamingPlayer: player
)
await manager.prepareQueueTTSEngine(api: api)
let ok = await manager.speakPlainTextOnce("A12号,请到拍摄区域,准备拍摄。")
XCTAssertTrue(ok)
XCTAssertTrue(bridge.events.contains { $0.hasPrefix("initialize:") })
XCTAssertTrue(bridge.events.contains("set:speech_rate=-120"))
XCTAssertTrue(bridge.events.contains("set:volume=2.0"))
XCTAssertTrue(bridge.events.contains("play:A12号,请到拍摄区域,准备拍摄。"))
XCTAssertEqual(Array(player.events.suffix(3)), ["start:16000", "write:4", "drain"])
}
func testAliyunTTSManagerInitializesOfflineEngineWithBundledVoice() async throws {
let api = MockScenicQueueAPI()
let tokenProvider = MockAliyunNLSTokenProvider()
let bridge = MockAliyunNuiTTSBridge()
bridge.paramValues["model_sample_rate"] = "24000"
let player = MockAliyunPCMStreamingPlayer()
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.nativeOfflineTTS")!
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.nativeOfflineTTS")
let store = AppStore(defaults: defaults)
store.session.userId = "u1"
store.session.accountType = .unknown("photog")
store.session.currentScenicId = 11
store.scenicQueue.useOfflineTTS = true
let workspace = FileManager.default.temporaryDirectory
.appendingPathComponent("ScenicQueueFeatureTests-\(UUID().uuidString)", isDirectory: true)
let voiceDirectory = workspace.appendingPathComponent("tts/voices", isDirectory: true)
try FileManager.default.createDirectory(at: voiceDirectory, withIntermediateDirectories: true)
try Data([0]).write(to: workspace.appendingPathComponent("tts/languagedata_embedded.bin"))
try Data([0]).write(to: workspace.appendingPathComponent("tts/parameter.cfg"))
try Data([0]).write(to: voiceDirectory.appendingPathComponent("aijia"))
defer { try? FileManager.default.removeItem(at: workspace) }
let manager = AliyunNLSTTSManager(
appStore: store,
tokenProvider: tokenProvider,
bridge: bridge,
streamingPlayer: player,
workspacePathProvider: { workspace.path }
)
XCTAssertTrue(manager.supportsOfflineTTS)
await manager.prepareQueueTTSEngine(api: api)
let ok = await manager.speakPlainTextOnce("请到拍摄区域")
XCTAssertTrue(ok)
let ticketData = try XCTUnwrap(bridge.initializedTickets.last?.data(using: .utf8))
let ticket = try XCTUnwrap(JSONSerialization.jsonObject(with: ticketData) as? [String: String])
XCTAssertEqual(ticket["app_key"], "gfWimmOuTku1AbIV")
XCTAssertEqual(ticket["sdk_code"], "software_nls_tts_offline_standard")
XCTAssertEqual(ticket["mode_type"], "0")
XCTAssertEqual(ticket["workspace"], workspace.path)
XCTAssertEqual(ticket["ak_id"], "ak")
XCTAssertEqual(ticket["sts_token"], "security-token")
XCTAssertTrue(bridge.events.contains { $0.hasPrefix("set:extend_font_name=") && $0.hasSuffix("/tts/voices/aijia") })
XCTAssertTrue(bridge.events.contains("set:encode_type=pcm"))
XCTAssertTrue(bridge.events.contains("set:sample_rate=24000"))
XCTAssertTrue(bridge.events.contains("set:speed_level=1.0"))
XCTAssertFalse(bridge.events.contains("set:mode_type=2"))
XCTAssertEqual(Array(player.events.suffix(3)), ["start:24000", "write:4", "drain"])
}
func testSettingsOfflineTTSSwitchReportsUnavailable() {
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.offlineTTS")!
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.offlineTTS")
let store = AppStore(defaults: defaults)
store.session.userId = "u1"
store.session.accountType = .unknown("photog")
store.session.currentScenicId = 11
XCTAssertTrue(store.scenicQueue.useOfflineTTS)
let tts = MockScenicQueueTTSManager()
let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts)
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
viewModel.setUseOfflineTTS(true)
XCTAssertFalse(store.scenicQueue.useOfflineTTS)
XCTAssertEqual(messages.last, "离线语音资源未安装")
}
func testQueueDistanceKilometerConversionAndInvalidFormats() {
XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 0), "0")
XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 500), "0.5")
XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 1_230), "1.23")
XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 9_999_990), "9999.99")
XCTAssertEqual(ScenicQueueSettingsViewModel.meters(fromKilometersText: "0.50"), 500)
XCTAssertEqual(ScenicQueueSettingsViewModel.meters(fromKilometersText: "9999.99"), 9_999_990)
XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "1.234"))
XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "10000"))
XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "公里"))
}
func testInvalidNumericPendingBlocksSaveAndSelectsMatchingTab() async {
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.invalidNumeric")!
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.invalidNumeric")
let store = AppStore(defaults: defaults)
store.session.userId = "u1"
store.session.accountType = .unknown("photog")
store.session.currentScenicId = 11
let api = MockScenicQueueAPI()
let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: MockScenicQueueTTSManager())
await viewModel.setPunchSpot(ScenicQueuePunchSpotOption(id: "22", label: "东门打卡点"), api: api)
viewModel.reportOutOfRangeNumericPending(fieldKey: ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue, pending: true)
await viewModel.save(api: api)
XCTAssertNil(api.savedSetting)
XCTAssertEqual(viewModel.settingsTab, .notify)
XCTAssertTrue(viewModel.highlightedInvalidFieldKeys.contains(ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue))
}
func testPresetVoicePlayStopAndDeleteState() async {
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.presetPlayback")!
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.presetPlayback")
let store = AppStore(defaults: defaults)
store.session.userId = "u1"
store.session.accountType = .unknown("photog")
store.session.currentScenicId = 11
store.scenicQueue.punchSpotId = "22"
store.scenicQueue.punchSpotName = "东门打卡点"
store.scenicQueue.savePresetVoices(["请到拍摄区域"])
let tts = MockScenicQueueTTSManager()
let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts)
viewModel.startPresetVoiceLoop("请到拍摄区域")
for _ in 0..<20 where viewModel.ttsLoopAnchorText == nil {
await Task.yield()
}
XCTAssertTrue(viewModel.isCustomTTSLooping)
XCTAssertEqual(viewModel.ttsLoopAnchorText, "请到拍摄区域")
XCTAssertTrue(tts.events.contains("custom:请到拍摄区域"))
viewModel.deletePresetVoice(at: 0)
XCTAssertTrue(viewModel.presetVoices.isEmpty)
XCTAssertFalse(viewModel.isCustomTTSLooping)
XCTAssertNil(viewModel.ttsLoopAnchorText)
XCTAssertEqual(tts.events.last, "stopCustom")
}
func testTicketActionStateMatchesAndroidCardMatrix() {
let waiting = makeTicket(status: .waiting, showCompleteAction: false)
let waitingState = ScenicQueueTicketActionState.make(ticket: waiting, shootingQueueNo: nil, remainSeconds: 0, showStartShootingButton: true)
XCTAssertTrue(waitingState.skipEnabled)
XCTAssertEqual(waitingState.primaryTitle, "叫号")
XCTAssertTrue(waitingState.primaryVisible)
XCTAssertFalse(waitingState.recallVisible)
XCTAssertFalse(waitingState.completeVisible)
let called = makeTicket(status: .called, showCompleteAction: true)
let hiddenStartState = ScenicQueueTicketActionState.make(ticket: called, shootingQueueNo: nil, remainSeconds: 0, showStartShootingButton: false)
XCTAssertFalse(hiddenStartState.primaryVisible)
XCTAssertTrue(hiddenStartState.recallVisible)
XCTAssertTrue(hiddenStartState.completeVisible)
let shootingState = ScenicQueueTicketActionState.make(ticket: called, shootingQueueNo: called.queueNo, remainSeconds: 17, showStartShootingButton: true)
XCTAssertFalse(shootingState.skipEnabled)
XCTAssertFalse(shootingState.primaryEnabled)
XCTAssertEqual(shootingState.primaryTitle, "剩余 17 秒")
XCTAssertFalse(shootingState.recallVisible)
XCTAssertTrue(shootingState.completeVisible)
}
func testQueueVisualTokensAndChangeLogPresentationContract() {
XCTAssertEqual(ScenicQueueTokens.radiusCard, 12)
XCTAssertEqual(ScenicQueueTokens.radiusDialog, 16)
XCTAssertEqual(ScenicQueueTokens.radiusPrimaryRowButton, 10)
XCTAssertEqual(ScenicQueueTokens.ticketIdFont.pointSize, 20)
XCTAssertEqual(ScenicQueueTokens.dialogTicketFont.pointSize, 28)
XCTAssertEqual(ScenicQueueTokens.listHorizontalInset, 12)
XCTAssertTrue(ScenicQueueTokens.timelineDot.isEqualToRGB(0x1677FF))
XCTAssertTrue(ScenicQueueTokens.tagWaitingForeground.isEqualToRGB(0x10B981))
let distanceField = ScenicQueueNumericFieldView(key: "queueDistanceMeter", unit: "千米", keyboard: .decimalPad)
XCTAssertEqual(distanceField.subviews.compactMap { ($0 as? UILabel)?.text }, ["千米"])
let item = ScenicQueueSettingChangeLogItem(
id: 1,
scenicId: 11,
scenicSpotId: 22,
scenicSpotName: "东门打卡点",
operatorId: nil,
operatorName: " ",
operatorPhoneTail: "8000",
summary: " ",
displayText: "拍摄时长由 5 分钟改为 8 分钟",
createdAt: "2026-07-07T10:20:30+08:00"
)
XCTAssertEqual(item.metadataLine, "2026-07-07 10:20 未知 尾号8000")
XCTAssertEqual(item.cardSummary, "拍摄时长由 5 分钟改为 8 分钟")
}
func testPendingDialogDismissMethodsClearEveryOperationState() async {
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.pendingDialogs")!
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.pendingDialogs")
let store = AppStore(defaults: defaults)
store.session.userId = "9"
store.session.accountType = .unknown("photog")
store.session.currentScenicId = 11
store.scenicQueue.punchSpotId = "22"
store.scenicQueue.punchSpotName = "东门打卡点"
let api = MockScenicQueueAPI()
api.homePages[1] = .page(items: [.item(id: 1, queueCode: "A01", isCalled: 0)], total: 1, page: 1)
let viewModel = ScenicQueueViewModel(appStore: store, ttsManager: MockScenicQueueTTSManager())
await viewModel.refreshQueueGate(api: api)
viewModel.requestCallConfirm(recordId: 1, queueNo: "A01")
viewModel.requestSkipTicket(recordId: 1, queueNo: "A01")
viewModel.requestFinishConfirm(recordId: 1, queueNo: "A01")
viewModel.requestRequeueConfirm(recordId: 1, queueNo: "A01")
viewModel.onTicketMarkClick(recordId: 1, queueNo: "A01", uid: 1)
XCTAssertNotNil(viewModel.pendingCall)
XCTAssertNotNil(viewModel.pendingSkip)
XCTAssertNotNil(viewModel.pendingFinish)
XCTAssertNotNil(viewModel.pendingRequeue)
XCTAssertNotNil(viewModel.pendingMark)
viewModel.dismissCallConfirmDialog()
viewModel.dismissSkipDialog()
viewModel.dismissFinishDialog()
viewModel.dismissRequeueDialog()
viewModel.dismissMarkDialog()
XCTAssertNil(viewModel.pendingCall)
XCTAssertNil(viewModel.pendingSkip)
XCTAssertNil(viewModel.pendingFinish)
XCTAssertNil(viewModel.pendingRequeue)
XCTAssertNil(viewModel.pendingMark)
}
private func makeTicket(status: ScenicQueueCallStatus, showCompleteAction: Bool) -> ScenicQueueTicket {
ScenicQueueTicket(
recordId: 1,
queueNo: "A01",
phoneMasked: "138****8000",
dialPhoneDigits: "13800138000",
status: status,
statusText: status == .waiting ? "待叫号" : "已叫号",
waitMinutes: 5,
peopleAhead: 2,
showCompleteAction: showCompleteAction,
queueBanLabel: "",
identityTag: "",
queueTimeDisplay: "07-07 10:20",
queueCountToday: 1,
uid: 8,
markAsPhotog: 0,
markAsFreelancePhotog: 0,
isMissRequeue: false,
missRequeueText: ""
)
}
}
private extension UIColor {
func isEqualToRGB(_ rgb: UInt32) -> Bool {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
let expectedRed = CGFloat((rgb >> 16) & 0xFF) / 255
let expectedGreen = CGFloat((rgb >> 8) & 0xFF) / 255
let expectedBlue = CGFloat(rgb & 0xFF) / 255
return abs(red - expectedRed) < 0.001 && abs(green - expectedGreen) < 0.001 && abs(blue - expectedBlue) < 0.001 && abs(alpha - 1) < 0.001
}
}
@MainActor
private final class MockScenicQueueAPI: ScenicQueueAPIProtocol {
var savedSetting: ScenicQueueSaveSettingRequest?
var homePages: [Int: ScenicQueueHomeData] = [:]
func saveSetting(_ request: ScenicQueueSaveSettingRequest) async throws {
savedSetting = request
}
func call(recordId: Int64) async throws -> ScenicQueueCallData {
ScenicQueueCallData(id: recordId, status: 2, statusText: "已叫号", calledAt: "2026-07-07 10:30:00")
}
func pass(recordId: Int64) async throws -> ScenicQueuePassData {
ScenicQueuePassData(id: recordId, status: 3, statusText: "已过号")
}
func finish(recordId: Int64) async throws -> ScenicQueueFinishData {
ScenicQueueFinishData(id: recordId, status: 4, statusText: "已完成")
}
func userMark(_ request: ScenicQueueUserMarkRequest) async throws {}
func requeueInsertBefore(recordId: Int64, operatorId: Int) async throws {}
func shootQueueQrcode(scenicId: Int64, scenicSpotId: Int64) async throws -> ScenicQueueShootQueueQrcodeData {
ScenicQueueShootQueueQrcodeData(qrcodeUrl: nil, scene: nil, page: nil, time: nil)
}
func setting(scenicId: Int64?, scenicSpotId: Int64?) async throws -> ScenicQueueSettingData {
ScenicQueueSettingData(exists: false, setting: nil, time: nil)
}
func stats(scenicId: Int64, scenicSpotId: Int64) async throws -> ScenicQueueStatsData {
ScenicQueueStatsData(queueCount: 3, avgWaitMin: 5.6, time: "2026-07-07 10:20:00")
}
func home(scenicId: Int64, scenicSpotId: Int64, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData {
if type == 2 {
return .page(items: [], total: 0, page: page)
}
return homePages[page] ?? .page(items: [], total: 0, page: page)
}
func settingChangeLog(scenicId: Int64, scenicSpotId: Int64?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData {
ScenicQueueSettingChangeLogData(total: 0, page: page, pageSize: pageSize, list: [])
}
func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse {
ScenicSpotListResponse(list: [ScenicSpotItem(id: 22, name: "东门打卡点")], total: 1)
}
func socketToken() async throws -> SocketTokenResponse {
SocketTokenResponse(socketToken: "socket-token")
}
func aliyunSTS(bucket: String) async throws -> AliyunOSSResponse {
try Self.makeSTS(bucket: bucket)
}
static func makeSTS(bucket: String = "vipsky") throws -> AliyunOSSResponse {
let json = """
{
"base_url":"https://vipsky.oss-cn-hangzhou.aliyuncs.com",
"endpoint":"oss-cn-hangzhou.aliyuncs.com",
"region":"cn-hangzhou",
"bucket":"\(bucket)",
"expire_seconds":3600,
"credentials":{
"access_key_id":"ak",
"access_key_secret":"secret",
"security_token":"security-token"
}
}
""".data(using: .utf8)!
return try JSONDecoder().decode(AliyunOSSResponse.self, from: json)
}
}
private final class MockScenicQueueTTSManager: ScenicQueueTTSManaging {
private(set) var events: [String] = []
private(set) var customTextLooping = false
let supportsOfflineTTS = false
func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async {
events.append("prepare")
}
func startQueueCallLoop(queueNo: String) {
events.append("start:\(queueNo)")
}
func stopQueueCallLoop() {
events.append("stopQueue")
}
func speakPlainTextOnce(_ text: String) async -> Bool {
events.append("speak:\(text)")
return true
}
func startCustomTextLoop(_ text: String) async -> Bool {
customTextLooping = true
events.append("custom:\(text)")
return true
}
func stopCustomTextLoop() {
customTextLooping = false
events.append("stopCustom")
}
func interruptShootingAnnouncement() {
events.append("interrupt")
}
func estimateAnnouncementDurationMillis(_ text: String) -> Int64 {
1_000
}
}
private final class MockAliyunNLSTokenProvider: AliyunNLSTokenProviding {
func createToken(credentials: AliyunOSSCredentials) async throws -> AliyunNLSToken {
AliyunNLSToken(id: "mock-nls-token", expireTime: 1_704_068_200)
}
}
private final class MockAliyunNuiTTSBridge: AliyunNuiTTSBridging {
weak var delegate: AliyunNuiTTSBridgeDelegate?
var sdkAvailable = true
var paramValues: [String: String] = [:]
private(set) var events: [String] = []
private(set) var initializedTickets: [String] = []
private let callbackBridge = AliyunNuiTTSBridge()
func initialize(ticket: String, logLevel: Int, saveLog: Bool) -> Int {
initializedTickets.append(ticket)
events.append("initialize:\(ticket.contains("mock-nls-token"))")
return 0
}
func setParam(_ param: String, value: String) -> Int {
events.append("set:\(param)=\(value)")
return 0
}
func play(priority: String, taskId: String, text: String) -> Int {
events.append("play:\(text)")
delegate?.aliyunNuiTTSBridge(
callbackBridge,
didReceiveAudioData: Data([0, 0, 1, 0]),
taskId: taskId,
info: nil
)
delegate?.aliyunNuiTTSBridge(callbackBridge, didReceiveEvent: 1, taskId: taskId, code: 0)
return 0
}
func cancel(taskId: String?) -> Int {
events.append("cancel:\(taskId ?? "")")
return 0
}
func releaseEngine() -> Int {
events.append("release")
return 0
}
func paramValue(for key: String) -> String? {
paramValues[key]
}
}
private final class MockAliyunPCMStreamingPlayer: AliyunPCMStreamingPlaying {
private(set) var events: [String] = []
func start(sampleRate: Double) {
events.append("start:\(Int(sampleRate))")
}
func write(_ data: Data) {
events.append("write:\(data.count)")
}
func drain(_ completion: @escaping () -> Void) {
events.append("drain")
completion()
}
func stop() {
events.append("stop")
}
}
private extension ScenicQueueHomeData {
static func page(items: [ScenicQueueHomeItem], total: Int, page: Int) -> ScenicQueueHomeData {
ScenicQueueHomeData(
stats: ScenicQueueHomeStats(type: 1),
list: ScenicQueueHomeListBlock(list: items, total: total, page: page, pageSize: 10),
time: "2026-07-07 10:20:00"
)
}
}
private extension ScenicQueueHomeItem {
static func item(id: Int64, queueCode: String, isCalled: Int) -> ScenicQueueHomeItem {
ScenicQueueHomeItem(
id: id,
queueCode: queueCode,
mobile: "13800138000",
status: isCalled == 1 ? 2 : 1,
statusText: isCalled == 1 ? "已叫号" : "待叫号",
waitMin: 5,
aheadCount: 1,
isCalled: isCalled,
createdAt: "2026-07-07 10:20:00",
calledAt: isCalled == 1 ? "2026-07-07 10:30:00" : nil,
expiredAt: nil,
finishedAt: nil,
identityTag: "",
queueBanLabel: "",
queueBanDays: nil,
queueTime: "2026-07-07 10:20:00",
queueCountToday: 1,
uid: 88,
markAsPhotog: 0,
markAsFreelancePhotog: 0,
isMissRequeue: 0,
isMissRequeueText: nil
)
}
}