// // 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 ) } }