Swift MeasurementFormatter игнорирует пользовательскую локаль на CI (Github Deciates), но работает локально - всегда испIOS

Программируем под IOS
Ответить
Anonymous
 Swift MeasurementFormatter игнорирует пользовательскую локаль на CI (Github Deciates), но работает локально - всегда исп

Сообщение Anonymous »

Я создал следующий класс, который является пользовательским форматированием, поддерживающим форматирование как приписанных, так и стандартных строк: < /p>
import Foundation
import SwiftUI

/// A formatter that converts a distance value (in meters) into a localized, styled `AttributedString`.
///
/// This formatter:
/// - Automatically switches between metric (km) and imperial (mi) based on the user's locale.
/// - Applies custom fonts and colors to the numeric value and unit label separately.
/// - Uses increased precision for shorter distances (< 200 km or mi).
final class AttributedDistanceFormatter {
private let style: AttributedFormatterStyle
let locale: Locale

init(style: AttributedFormatterStyle, locale: Locale = .current) {
self.style = style
self.locale = locale
}

/// Formatter for distances ≥ 200 km/mi (shows up to 1 decimal place).
private lazy var measurementFormatter: MeasurementFormatter = {
let formatter = MeasurementFormatter()
formatter.locale = locale
formatter.unitOptions = .providedUnit
formatter.unitStyle = .medium

let numberFormatter = NumberFormatter()
numberFormatter.minimumFractionDigits = 0
numberFormatter.maximumFractionDigits = 1
formatter.numberFormatter = numberFormatter

return formatter
}()

/// Formatter for distances < 200 km/mi (shows up to 2 decimal places).
private lazy var shortMeasurementFormatter: MeasurementFormatter = {
let formatter = MeasurementFormatter()
formatter.locale = locale
formatter.unitOptions = .providedUnit
formatter.unitStyle = .medium

let numberFormatter = NumberFormatter()
numberFormatter.minimumFractionDigits = 0
numberFormatter.maximumFractionDigits = 2
formatter.numberFormatter = numberFormatter

return formatter
}()

/// Converts a distance (in meters) into a localized and styled `AttributedString`.
///
/// - Parameter distance: The distance in meters.
/// - Returns: A styled string like “3.2 km” or “0.75 mi”.
func attributedString(from distance: Double) -> AttributedString {
let formatted = formattedDistanceString(from: distance)
let components = formatted.split(separator: " ", maxSplits: 1).map(
String.init)

guard components.count == 2 else {
// Fallback: apply value style to the entire string if format fails
return attributedValueString(formatted)
}

let number = components[0]
let unit = components[1]

var result = AttributedString()
result += attributedValueString(number)
result += attributedUnitLabel(" \(unit)")
return result
}

/// Converts a distance (in meters) into a plain localized string (e.g., "1.8 mi").
func string(from distance: Double) -> String {
formattedDistanceString(from: distance)
}

/// Internal: Computes the raw distance string using appropriate precision based on value.
func formattedDistanceString(from distance: Double) -> String {
let measurement = Measurement(value: distance, unit: UnitLength.meters)
let usesMetric = locale.measurementSystem == .metric
let unit = usesMetric ? UnitLength.kilometers : UnitLength.miles
let converted = measurement.converted(to: unit)

return (converted.value < 200)
? shortMeasurementFormatter.string(from: converted)
: measurementFormatter.string(from: converted)
}

/// Applies the value font/color style to the numeric part of the distance.
private func attributedValueString(_ string: String) -> AttributedString {
var attributed = AttributedString(string)
attributed.font = style.valueFont
attributed.foregroundColor = style.valueColor
return attributed
}

/// Applies the label font/color style to the unit part of the distance.
private func attributedUnitLabel(_ string: String) -> AttributedString {
var attributed = AttributedString(string)
attributed.font = style.labelFont
attributed.foregroundColor = style.labelColor
return attributed
}
}

struct AttributedFormatterStyle {

/// The color to apply to the numeric value part of the formatted string.
let valueColor: Color

/// The font to apply to the numeric value part of the formatted string.
let valueFont: Font

/// The color to apply to the unit label part of the formatted string.
let labelColor: Color

/// The font to apply to the unit label part of the formatted string.
let labelFont: Font

/// Initializes a new style configuration for an attributed formatter.
///
/// - Parameters:
/// - valueColor: The color to use for value text.
/// - valueFont: The font to use for value text.
/// - labelColor: The color to use for label text.
/// - labelFont: The font to use for label text.
init(
valueColor: Color,
valueFont: Font,
labelColor: Color,
labelFont: Font
) {
self.valueColor = valueColor
self.valueFont = valueFont
self.labelColor = labelColor
self.labelFont = labelFont
}
}
< /code>
Форматер локализован, так что он уважает предпочтение пользователя десятичных, тысячи сепараторов, метрической или имперской системы измерений и других.import SwiftUI
import XCTest

@testable import App

final class AttributedDistanceFormatterTests: XCTestCase {

let style = AttributedFormatterStyle(
valueColor: .red,
valueFont: .system(size: 10, weight: .bold),
labelColor: .blue,
labelFont: .system(size: 8, weight: .light)
)

private func decimalDigitsCount(in string: String) -> Int {
// Normalize non-breaking space
let normalized = string.replacingOccurrences(of: "\u{00a0}", with: " ")

guard
let numberPart = normalized.split(separator: " ").first,
let separatorIndex = numberPart.firstIndex(where: { $0 == "." || $0 == "," })
else {
return 0
}

return numberPart.distance(from: separatorIndex, to: numberPart.endIndex) - 1
}

func testMetricLocaleShortDistanceUsesTwoDecimalPlacesAndKmForFrenchLocale() {
let formatter = AttributedDistanceFormatter(
style: style, locale: Locale(identifier: "fr_FR"))

let distanceMeters = 1234.56 // ~1.23456 km < 200

let string = formatter.string(from: distanceMeters)

let trimmedString = string.trimmingCharacters(in: .whitespacesAndNewlines)
XCTAssertTrue(
trimmedString.contains("km"),
"Expected suffix 'km' in: \(trimmedString)"
)
XCTAssertTrue(
trimmedString.contains(","),
"Expected comma as decimal separator in: \(trimmedString)" // French uses comma
)

XCTAssertLessThanOrEqual(decimalDigitsCount(in: string), 2)

let attributed = formatter.attributedString(from: distanceMeters)
XCTAssertEqual(attributed.runs.count, 2)

let runs = attributed.runs
let firstRun = runs[runs.startIndex]
XCTAssertEqual(firstRun.foregroundColor, style.valueColor)
XCTAssertEqual(firstRun.font, style.valueFont)

let secondRunIndex = runs.index(after: runs.startIndex)
let secondRun = runs[secondRunIndex]
XCTAssertEqual(secondRun.foregroundColor, style.labelColor)
XCTAssertEqual(secondRun.font, style.labelFont)
}
}

< /code>
Этот тест отлично выполняется на моей машине локально.
Однако, когда я выдвигаю этот тест на Github, их CI терпит неудачу очень странно: < /p>
error: -[tests.AttributedDistanceFormatterTests testMetricLocaleShortDistanceUsesTwoDecimalPlacesAndKmForFrenchLocale] : XCTAssertTrue failed - Expected comma as decimal separator in: 1.23 km
Test Case '-[tests.AttributedDistanceFormatterTests testMetricLocaleShortDistanceUsesTwoDecimalPlacesAndKmForFrenchLocale]' failed (0.383 seconds).
Test Suite 'AttributedDistanceFormatterTests' failed at 2025-05-28 14:25:50.175.
< /code>
Пока я проверяю локаль в этом бегуне, я получаю следующее: < /p>
🟢 Test Locale Info (fr_FR):
• Identifier: fr_FR
• Decimal Separator: ','
• Grouping Separator: ' '
• Measurement System: metric
• Region Identifier: FR
• Language Code: fr
< /code>
Итак, у нас есть следующая ситуация: < /p>

Локаль правильный, разделитель верен. Почему это работает на моей машине?🟡 Current Locale Info:
• Identifier: en_US@rg=fizzzz
• Decimal Separator: ','
• Grouping Separator: ' '
• Measurement System: metric
• Region Identifier: FI
• Language Code: en
< /code>
и на GitHub: < /p>
🟡 Current Locale Info:
• Identifier: en_US
• Decimal Separator: '.'
• Grouping Separator: ','
• Measurement System: ussystem
• Region Identifier: US
• Language Code: en
< /code>
Мое предположение состоит в том, что локали, установленная в тесте, фактически не используется, и используется локаль по умолчанию. Поскольку на моей машине десятичный сепаратор - «», то есть запятая, тест проходит, а на Github это период, и этот тест преодолел. Независимо от того, какой локаль я использую, это всегда период, который используется на GitHub. И есть ли способ исправить это? let currentLocale = Locale.current
let testLocale = Locale(identifier: "fr_FR")

// Print system current locale info
print("🟡 Current Locale Info:")
print(" • Identifier: \(currentLocale.identifier)")
print(" • Decimal Separator: '\(currentLocale.decimalSeparator ?? "nil")'")
print(" • Grouping Separator: '\(currentLocale.groupingSeparator ?? "nil")'")
print(" • Measurement System: \(currentLocale.measurementSystem)")
print(" • Region Identifier: \(currentLocale.region?.identifier ?? "nil")")
print(" • Language Code: \(currentLocale.language.languageCode?.identifier ?? "nil")")

// Print test locale info
print("\n🟢 Test Locale Info (fr_FR):")
print(" • Identifier: \(testLocale.identifier)")
print(" • Decimal Separator: '\(testLocale.decimalSeparator ?? "nil")'")
print(" • Grouping Separator: '\(testLocale.groupingSeparator ?? "nil")'")
print(" • Measurement System: \(testLocale.measurementSystem)")
print(" • Region Identifier: \(testLocale.region?.identifier ?? "nil")")
print(" • Language Code: \(testLocale.language.languageCode?.identifier ?? "nil")")

// Print all available locales (no trimming)
let availableLocales = Locale.availableIdentifiers.sorted()
print("\n📦 Available Locales (\(availableLocales.count)):")
for localeID in availableLocales {
print(" • \(localeID)")
}



Подробнее здесь: https://stackoverflow.com/questions/796 ... s-but-work
Ответить

Быстрый ответ

Изменение регистра текста: 
Смайлики
:) :( :oops: :roll: :wink: :muza: :clever: :sorry: :angel: :read: *x)
Ещё смайлики…
   
К этому ответу прикреплено по крайней мере одно вложение.

Если вы не хотите добавлять вложения, оставьте поля пустыми.

Максимально разрешённый размер вложения: 15 МБ.

Вернуться в «IOS»