完善景区排队设置页与全局按钮配置迁移。

重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 16:21:53 +08:00
parent efb3925257
commit 6336feff27
49 changed files with 1996 additions and 590 deletions

View File

@ -4,6 +4,7 @@
//
import XCTest
import UIKit
@testable import suixinkan
@MainActor
@ -55,6 +56,24 @@ final class ScenicQueueFeatureTests: XCTestCase {
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 = """
{
@ -399,6 +418,191 @@ final class ScenicQueueFeatureTests: XCTestCase {
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

View File

@ -68,4 +68,19 @@ final class UIButtonConfigurationTests: XCTestCase {
XCTAssertEqual(button.currentTitle, "更新标题")
XCTAssertEqual(button.currentTitleColor, .red)
}
func testImagePlacementPreservesExistingConfiguration() {
var configuration = UIButton.Configuration.plain()
configuration.title = "电话"
configuration.contentInsets = NSDirectionalEdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4)
let button = UIButton(configuration: configuration)
button.setConfigurationImagePlacement(.trailing, padding: 6)
XCTAssertEqual(button.configuration?.title, "电话")
XCTAssertEqual(button.configuration?.contentInsets.leading, 2)
XCTAssertEqual(button.configuration?.contentInsets.trailing, 4)
XCTAssertEqual(button.configuration?.imagePlacement, .trailing)
XCTAssertEqual(button.configuration?.imagePadding, 6)
}
}

View File

@ -800,6 +800,21 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(viewModel.reportLocation, "瘦西湖路")
}
func testSubmitTextInputUpdatesWithoutRequestingStructuralRefresh() {
let viewModel = WildPhotographerReportSubmitViewModel(
homeViewModel: WildPhotographerReportHomeViewModel()
)
var stateChangeCount = 0
viewModel.onStateChange = { stateChangeCount += 1 }
viewModel.updateDescription("现场说明")
viewModel.updateContact("wx-test")
XCTAssertEqual(viewModel.description, "现场说明")
XCTAssertEqual(viewModel.contact, "wx-test")
XCTAssertEqual(stateChangeCount, 0)
}
func testSubmitFailsWithoutEvidence() async {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)