完善首页权限申请流程与队列播报体验。

对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 13:46:45 +08:00
parent c083f1d4b3
commit efb3925257
50 changed files with 2585 additions and 455 deletions

View File

@ -78,10 +78,12 @@ final class HomeCommonMenuStoreTests: XCTestCase {
XCTAssertEqual(item?.iconName, "shield.fill")
}
func testHomeMenuCatalogUsesSyncedSymbols() {
XCTAssertEqual(HomeMenuCatalog.item(for: "space_settings")?.iconName, "person.crop.square.fill")
XCTAssertEqual(HomeMenuCatalog.item(for: "wallet")?.iconName, "creditcard.fill")
func testHomeMenuCatalogUsesIOS16CompatibleIcons() {
XCTAssertEqual(HomeMenuCatalog.item(for: "space_settings")?.iconName, "home_menu_space")
XCTAssertEqual(HomeMenuCatalog.item(for: "wallet")?.iconName, "home_menu_wallet")
XCTAssertEqual(HomeMenuCatalog.item(for: "sample_management")?.iconName, "sample_ic_home_menu_route")
XCTAssertEqual(HomeMenuCatalog.item(for: "travel_album")?.iconName, "rectangle.stack.badge.plus")
XCTAssertEqual(HomeMenuCatalog.item(for: "store")?.iconName, "building.2.fill")
XCTAssertEqual(HomeMenuCatalog.item(for: "/scenic-queue")?.iconName, "person.3.fill")
XCTAssertEqual(HomeMenuItem.moreFunctions.iconName, "ellipsis")
}

View File

@ -123,6 +123,53 @@ final class HomeViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.commonMenus.isEmpty)
}
func testPermissionApplyDestinationRoutesReviewingAndRejectedToStatus() async throws {
let reviewingJSON = """
{"code":100000,"msg":"success","data":[{"code":"AP001","role_id":1,"role_name":"","scenic_list":[],"status":1,"status_label":"","created_at":"","audit_note":"","audited_at":null,"audited_by":null}]}
""".data(using: .utf8)!
let rejectedJSON = """
{"code":100000,"msg":"success","data":[{"code":"AP002","role_id":1,"role_name":"","scenic_list":[],"status":3,"status_label":"","created_at":"","audit_note":"","audited_at":null,"audited_by":null}]}
""".data(using: .utf8)!
let session = MockURLSession(responses: [reviewingJSON, rejectedJSON])
let api = HomeAPI(client: APIClient(environment: .testing, session: session))
let viewModel = HomeViewModel(appStore: appStore)
let reviewingDestination = await viewModel.resolvePermissionApplyDestination(api: api)
let rejectedDestination = await viewModel.resolvePermissionApplyDestination(api: api)
XCTAssertEqual(reviewingDestination, .status(applyCode: "AP001"))
XCTAssertEqual(rejectedDestination, .status(applyCode: "AP002"))
}
func testPermissionApplyDestinationFallsBackToApply() async throws {
let completedJSON = """
{"code":100000,"msg":"success","data":[{"code":"AP003","role_id":1,"role_name":"","scenic_list":[],"status":2,"status_label":"","created_at":"","audit_note":"","audited_at":null,"audited_by":null}]}
""".data(using: .utf8)!
let emptyJSON = #"{"code":100000,"msg":"success","data":[]}"#.data(using: .utf8)!
let session = MockURLSession(responses: [completedJSON, emptyJSON])
let api = HomeAPI(client: APIClient(environment: .testing, session: session))
let viewModel = HomeViewModel(appStore: appStore)
let completedDestination = await viewModel.resolvePermissionApplyDestination(api: api)
let emptyDestination = await viewModel.resolvePermissionApplyDestination(api: api)
XCTAssertEqual(completedDestination, .apply)
XCTAssertEqual(emptyDestination, .apply)
}
func testPermissionApplyDestinationFallsBackToApplyOnError() async {
let response = HTTPURLResponse(
url: URL(string: "https://api-test.zhifly.cn/mock")!,
statusCode: 500,
httpVersion: nil,
headerFields: nil
)!
let session = MockURLSession(results: [.success((Data(), response))])
let api = HomeAPI(client: APIClient(environment: .testing, session: session))
let viewModel = HomeViewModel(appStore: appStore)
let destination = await viewModel.resolvePermissionApplyDestination(api: api)
XCTAssertEqual(destination, .apply)
}
func testRolePermissionBuildsCommonMenusAndStoreCardRole() async throws {
appStore.session.roleCode = AppRoleCode.storeAdmin.rawValue
let permissionResponse = RolePermissionResponse(

View File

@ -53,4 +53,30 @@ final class PermissionApplyStatusViewModelTests: XCTestCase {
XCTAssertNil(edited)
}
func testCompletedApplicationIsNotDisplayedAsPending() async throws {
let json = """
{"code":100000,"msg":"success","data":[{"code":"AP004","role_id":1,"role_name":"","scenic_list":[],"status":2,"status_label":"","created_at":"","audit_note":"","audited_at":null,"audited_by":null}]}
""".data(using: .utf8)!
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [json])))
let viewModel = PermissionApplyStatusViewModel(applyCode: "AP004")
await viewModel.loadPendingData(api: api)
XCTAssertNil(viewModel.pendingData)
XCTAssertFalse(viewModel.isLoading)
}
func testScenicListDialogState() {
let viewModel = PermissionApplyStatusViewModel(applyCode: "AP005")
let scenic = ScenicInfo(id: 12, name: "西湖景区", status: 1)
viewModel.showScenicList(roleName: "摄影师", scenics: [scenic])
XCTAssertTrue(viewModel.showScenicListDialog)
XCTAssertEqual(viewModel.dialogRoleName, "摄影师")
XCTAssertEqual(viewModel.dialogScenicList, [scenic])
viewModel.hideScenicList()
XCTAssertFalse(viewModel.showScenicListDialog)
}
}

View File

@ -46,6 +46,8 @@ final class PermissionApplyViewModelTests: XCTestCase {
XCTAssertEqual(existing?.isDisabled, true)
XCTAssertEqual(existing?.isSelected, true)
XCTAssertEqual(fresh?.isDisabled, false)
if let existing { viewModel.toggleScenic(existing) }
XCTAssertTrue(viewModel.selectedScenicIds.isEmpty)
}
func testSubmitValidationRequiresRoleAndScenic() async {
@ -55,21 +57,24 @@ final class PermissionApplyViewModelTests: XCTestCase {
viewModel.onShowMessage = { message = $0 }
await viewModel.submit(api: api)
XCTAssertEqual(message, "请选择角色")
XCTAssertEqual(message, "请选择景区和角色")
XCTAssertFalse(viewModel.canSubmit)
viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true))
await viewModel.submit(api: api)
XCTAssertEqual(message, "至少选择一个景区")
XCTAssertEqual(message, "选择景区和角色")
XCTAssertFalse(viewModel.canSubmit)
}
func testSubmitSuccessReturnsApplyCode() async throws {
func testSubmitSuccessUsesAndroidPayloadAndCompletion() async throws {
let scenicJSON = """
{"code":100000,"msg":"success","data":{"total":1,"list":[{"id":101,"name":""}]}}
""".data(using: .utf8)!
let submitJSON = """
{"code":100000,"msg":"success","data":{"code":"AP999"}}
""".data(using: .utf8)!
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [scenicJSON, submitJSON])))
let session = MockURLSession(responses: [scenicJSON, submitJSON])
let api = HomeAPI(client: APIClient(environment: .testing, session: session))
let viewModel = PermissionApplyViewModel(appStore: appStore)
viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true))
await viewModel.loadAvailableScenics(api: api)
@ -77,10 +82,138 @@ final class PermissionApplyViewModelTests: XCTestCase {
viewModel.toggleScenic(scenic)
}
var applyCode: String?
viewModel.onSubmitSuccess = { applyCode = $0 }
var didSubmit = false
viewModel.onSubmitSuccess = { didSubmit = true }
XCTAssertTrue(viewModel.canSubmit)
await viewModel.submit(api: api)
XCTAssertEqual(applyCode, "AP999")
XCTAssertTrue(didSubmit)
XCTAssertFalse(viewModel.isSubmitting)
XCTAssertEqual(session.requests.last?.url?.path, "/api/yf-handset-app/photog/role-apply/submit")
let body = try XCTUnwrap(session.requests.last?.httpBody)
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
XCTAssertEqual(object["role_id"] as? Int, 10)
XCTAssertEqual(object["scenic_id"] as? [Int], [101])
}
func testSubmitFailureRestoresEnabledButtonAndKeepsSelection() async throws {
let scenicJSON = """
{"code":100000,"msg":"success","data":{"total":1,"list":[{"id":101,"name":""}]}}
""".data(using: .utf8)!
let failureJSON = #"{"code":100001,"msg":"failed","data":null}"#.data(using: .utf8)!
let api = HomeAPI(
client: APIClient(
environment: .testing,
session: MockURLSession(responses: [scenicJSON, failureJSON])
)
)
let viewModel = PermissionApplyViewModel(appStore: appStore)
viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true))
await viewModel.loadAvailableScenics(api: api)
viewModel.toggleScenic(try XCTUnwrap(viewModel.availableScenics.first))
var message: String?
viewModel.onShowMessage = { message = $0 }
await viewModel.submit(api: api)
XCTAssertEqual(message, "提交失败,请稍后重试")
XCTAssertFalse(viewModel.isSubmitting)
XCTAssertTrue(viewModel.canSubmit)
XCTAssertEqual(viewModel.selectedScenicIds, [101])
}
func testSwitchingRoleClearsScenicSelectionAndLoadedOptions() async throws {
let scenicJSON = """
{"code":100000,"msg":"success","data":{"total":1,"list":[{"id":101,"name":""}]}}
""".data(using: .utf8)!
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [scenicJSON])))
let viewModel = PermissionApplyViewModel(appStore: appStore)
viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true))
await viewModel.loadAvailableScenics(api: api)
viewModel.toggleScenic(try XCTUnwrap(viewModel.availableScenics.first))
viewModel.selectRole(RoleOption(id: 11, name: "门店管理员", description: "", isSelected: true))
XCTAssertEqual(viewModel.selectedRoleId, 11)
XCTAssertTrue(viewModel.selectedScenicIds.isEmpty)
XCTAssertTrue(viewModel.availableScenics.isEmpty)
}
func testAvailableRolesAreDeduplicatedByRoleID() {
appStore.permissions.saveRolePermissionList([
RolePermissionResponse(
role: RoleInfo(id: 10, name: "摄影师", roleCode: "PHOTOGRAPHER", notes: "拍摄"),
scenic: [ScenicInfo(id: 100, name: "景区A", status: 1)]
),
RolePermissionResponse(
role: RoleInfo(id: 10, name: "摄影师", roleCode: "PHOTOGRAPHER", notes: "拍摄"),
scenic: [ScenicInfo(id: 101, name: "景区B", status: 1)]
),
])
let viewModel = PermissionApplyViewModel(appStore: appStore)
XCTAssertEqual(viewModel.availableRoles.map(\.id), [10])
XCTAssertEqual(viewModel.rolePermissionList.count, 2)
}
func testScenicSearchAndDeletionConfirmation() async throws {
let scenicJSON = """
{"code":100000,"msg":"success","data":{"total":3,"list":[{"id":100,"name":""},{"id":101,"name":"西"},{"id":102,"name":""}]}}
""".data(using: .utf8)!
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [scenicJSON])))
let viewModel = PermissionApplyViewModel(appStore: appStore)
viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true))
await viewModel.loadAvailableScenics(api: api)
viewModel.updateScenicSearchQuery("西湖")
XCTAssertEqual(viewModel.filteredScenics.map(\.id), [101])
let scenic = try XCTUnwrap(viewModel.filteredScenics.first)
viewModel.toggleScenic(scenic)
XCTAssertTrue(viewModel.canSubmit)
viewModel.removeSelectedScenic(id: scenic.id, name: scenic.name)
XCTAssertEqual(viewModel.pendingDeletion, .scenic(id: 101, name: "西湖景区"))
viewModel.cancelDeletion()
XCTAssertEqual(viewModel.selectedScenicIds, [101])
viewModel.removeSelectedScenic(id: scenic.id, name: scenic.name)
viewModel.confirmDeletion()
XCTAssertTrue(viewModel.selectedScenicIds.isEmpty)
XCTAssertFalse(viewModel.canSubmit)
}
func testRoleDeletionRequiresConfirmationAndClearsScenics() async throws {
let scenicJSON = """
{"code":100000,"msg":"success","data":{"total":1,"list":[{"id":101,"name":""}]}}
""".data(using: .utf8)!
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [scenicJSON])))
let viewModel = PermissionApplyViewModel(appStore: appStore)
viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true))
await viewModel.loadAvailableScenics(api: api)
viewModel.toggleScenic(try XCTUnwrap(viewModel.availableScenics.first))
viewModel.clearRoleSelection()
XCTAssertEqual(viewModel.pendingDeletion, .role(name: "摄影师"))
XCTAssertEqual(viewModel.selectedRoleId, 10)
viewModel.confirmDeletion()
XCTAssertNil(viewModel.selectedRoleId)
XCTAssertTrue(viewModel.selectedScenicIds.isEmpty)
XCTAssertTrue(viewModel.availableScenics.isEmpty)
}
func testExistingRoleListAndScenicDialogState() {
let viewModel = PermissionApplyViewModel(appStore: appStore)
XCTAssertEqual(viewModel.rolePermissionList.count, 1)
XCTAssertEqual(viewModel.availableRoles.first?.description, "拍摄")
let scenics = viewModel.rolePermissionList[0].scenic
viewModel.showScenicList(roleName: "摄影师", scenics: scenics)
XCTAssertTrue(viewModel.showScenicListDialog)
XCTAssertEqual(viewModel.dialogRoleName, "摄影师")
XCTAssertEqual(viewModel.dialogScenicList.map(\.id), [100])
viewModel.hideScenicList()
XCTAssertFalse(viewModel.showScenicListDialog)
}
}

View File

@ -0,0 +1,25 @@
//
// PermissionApplyViewsTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
///
@MainActor
final class PermissionApplyViewsTests: XCTestCase {
func testSelectedChipsAreLeftAlignedAndWrapWithAndroidSpacing() {
let frames = PermissionChipLayoutMetrics.makeFrames(
itemWidths: [80, 120, 90],
containerWidth: 260
)
XCTAssertEqual(frames[0].minX, 0, accuracy: 0.5)
XCTAssertEqual(frames[1].minX, frames[0].maxX + 12, accuracy: 0.5)
XCTAssertEqual(frames[2].minX, 0, accuracy: 0.5)
XCTAssertEqual(frames[2].minY, frames[0].maxY + 2, accuracy: 0.5)
XCTAssertTrue(frames.allSatisfy { $0.maxX <= 260.5 })
}
}

View File

@ -304,7 +304,15 @@ final class ScenicQueueFeatureTests: XCTestCase {
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
@ -321,6 +329,58 @@ final class ScenicQueueFeatureTests: XCTestCase {
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")
@ -471,10 +531,13 @@ private final class MockAliyunNLSTokenProvider: AliyunNLSTokenProviding {
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
}
@ -507,7 +570,7 @@ private final class MockAliyunNuiTTSBridge: AliyunNuiTTSBridging {
}
func paramValue(for key: String) -> String? {
nil
paramValues[key]
}
}

View File

@ -49,6 +49,25 @@ final class StatisticsViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.rangeValue(for: "本月"), "4")
}
func testPeriodButtonCapsuleCornerRadiusUsesHalfHeight() {
let button = StatisticsPeriodButton(frame: CGRect(x: 0, y: 0, width: 80, height: 32))
button.setNeedsLayout()
button.layoutIfNeeded()
XCTAssertEqual(button.layer.cornerRadius, 16)
}
func testPeriodButtonFixedCornerRadiusDoesNotFollowHeight() {
let button = StatisticsPeriodButton(frame: CGRect(x: 0, y: 0, width: 80, height: 32))
button.cornerStyle = .fixed(10)
button.setNeedsLayout()
button.layoutIfNeeded()
XCTAssertEqual(button.layer.cornerRadius, 10)
}
func testFormattedSelectedPeriodTimeForToday() {
let viewModel = StatisticsViewModel()
let text = viewModel.formattedSelectedPeriodTime(for: "今日")

View File

@ -0,0 +1,71 @@
//
// UIButtonConfigurationTests.swift
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
/// `UIButton` configuration
@MainActor
final class UIButtonConfigurationTests: XCTestCase {
func testContentInsetsCreatePlainConfiguration() {
let button = UIButton(type: .system)
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 2, leading: 4, bottom: 6, trailing: 8)
)
let contentInsets = button.configuration?.contentInsets
XCTAssertEqual(contentInsets?.top, 2)
XCTAssertEqual(contentInsets?.leading, 4)
XCTAssertEqual(contentInsets?.bottom, 6)
XCTAssertEqual(contentInsets?.trailing, 8)
}
func testContentInsetsPreserveExistingConfiguration() {
let image = UIImage(systemName: "star.fill")
var configuration = UIButton.Configuration.filled()
configuration.title = "保留标题"
configuration.image = image
configuration.imagePlacement = .trailing
configuration.imagePadding = 7
configuration.baseForegroundColor = .red
let button = UIButton(configuration: configuration)
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 1, leading: 3, bottom: 5, trailing: 9)
)
XCTAssertEqual(button.configuration?.title, "保留标题")
XCTAssertEqual(button.configuration?.image, image)
XCTAssertEqual(button.configuration?.imagePlacement, .trailing)
XCTAssertEqual(button.configuration?.imagePadding, 7)
XCTAssertEqual(button.configuration?.baseForegroundColor, .red)
XCTAssertEqual(button.configuration?.contentInsets.leading, 3)
XCTAssertEqual(button.configuration?.contentInsets.trailing, 9)
}
func testContentInsetsPreserveLegacyButtonContentAndAllowUpdates() {
let image = UIImage(systemName: "star")
let button = UIButton(type: .system)
button.setTitle("原始标题", for: .normal)
button.setTitleColor(.blue, for: .normal)
button.setImage(image, for: .normal)
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4)
)
XCTAssertEqual(button.currentTitle, "原始标题")
XCTAssertEqual(button.currentTitleColor, .blue)
XCTAssertEqual(button.currentImage, image)
button.setTitle("更新标题", for: .normal)
button.setTitleColor(.red, for: .normal)
XCTAssertEqual(button.currentTitle, "更新标题")
XCTAssertEqual(button.currentTitleColor, .red)
}
}