// // 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( _ 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 { } }