Skip to content

Commit 18ac5ac

Browse files
authored
Do not attempt to cache based on a signature that uses a Locale (#334)
* Fix a bug where use of autoupdatingCurrentLocale with number formatters resulted in not-autoupdating behavior * Remove tests for Measurement in package
1 parent 4387ce0 commit 18ac5ac

File tree

3 files changed

+90
-26
lines changed

3 files changed

+90
-26
lines changed

Sources/FoundationEssentials/Locale/Locale_Cache.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,15 @@ struct LocaleCache : Sendable {
250250
lock.withLock { $0.reset() }
251251
}
252252

253+
/// For testing of `autoupdatingCurrent` only. If you want to test `current`, create a custom `Locale` with the appropriate settings using `localeAsIfCurrent(name:overrides:disableBundleMatching:)` and use that instead.
254+
/// This mutates global state of the current locale, so it is not safe to use in concurrent testing.
255+
func resetCurrent(to preferences: LocalePreferences) {
256+
lock.withLock {
257+
$0.reset()
258+
let _ = $0.current(preferences: preferences, cache: true)
259+
}
260+
}
261+
253262
var current: any _LocaleProtocol {
254263
var result = lock.withLock {
255264
$0.current(preferences: nil, cache: false)

Sources/FoundationInternationalization/Formatting/Number/ICUNumberFormatter.swift

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,23 +33,24 @@ internal func resetAllNumberFormatterCaches() {
3333
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
3434
internal class ICUNumberFormatterBase {
3535
internal let uformatter: OpaquePointer
36+
/// Stored for testing purposes only
3637
internal let skeleton: String
3738

38-
init?(skeleton: String, locale: Locale) {
39+
init?(skeleton: String, localeIdentifier: String, preferences: LocalePreferences?) {
3940
self.skeleton = skeleton
4041
let ustr = Array(skeleton.utf16)
4142
var status = U_ZERO_ERROR
42-
let formatter = unumf_openForSkeletonAndLocale(ustr, Int32(ustr.count), locale.identifierCapturingPreferences, &status)
43-
43+
let formatter = unumf_openForSkeletonAndLocale(ustr, Int32(ustr.count), localeIdentifier, &status)
44+
4445
guard let formatter else {
4546
return nil
4647
}
47-
48+
4849
guard status.isSuccess else {
4950
unumf_close(formatter)
5051
return nil
5152
}
52-
53+
5354
uformatter = formatter
5455
}
5556

@@ -246,27 +247,28 @@ internal class ICUNumberFormatterBase {
246247
final class ICUNumberFormatter : ICUNumberFormatterBase {
247248
fileprivate struct Signature : Hashable {
248249
let collection: NumberFormatStyleConfiguration.Collection
249-
let locale: Locale
250+
let localeIdentifier: String
251+
let localePreferences: LocalePreferences?
250252
}
251253

252254
fileprivate static let cache = FormatterCache<Signature, ICUNumberFormatter?>()
253255

254256
private static func _create(with signature: Signature) -> ICUNumberFormatter? {
255257
Self.cache.formatter(for: signature) {
256-
.init(skeleton: signature.collection.skeleton, locale: signature.locale)
258+
.init(skeleton: signature.collection.skeleton, localeIdentifier: signature.localeIdentifier, preferences: signature.localePreferences)
257259
}
258260
}
259261

260262
static func create<T: BinaryInteger>(for style: IntegerFormatStyle<T>) -> ICUNumberFormatter? {
261-
_create(with: .init(collection: style.collection, locale: style.locale))
263+
_create(with: .init(collection: style.collection, localeIdentifier: style.locale.identifierCapturingPreferences, localePreferences: style.locale.prefs))
262264
}
263265

264266
static func create(for style: Decimal.FormatStyle) -> ICUNumberFormatter? {
265-
_create(with: .init(collection: style.collection, locale: style.locale))
267+
_create(with: .init(collection: style.collection, localeIdentifier: style.locale.identifierCapturingPreferences, localePreferences: style.locale.prefs))
266268
}
267269

268270
static func create<T: BinaryFloatingPoint>(for style: FloatingPointFormatStyle<T>) -> ICUNumberFormatter? {
269-
_create(with: .init(collection: style.collection, locale: style.locale))
271+
_create(with: .init(collection: style.collection, localeIdentifier: style.locale.identifierCapturingPreferences, localePreferences: style.locale.prefs))
270272
}
271273

272274
func attributedFormat(_ v: Value) -> AttributedString {
@@ -283,7 +285,8 @@ final class ICUCurrencyNumberFormatter : ICUNumberFormatterBase {
283285
fileprivate struct Signature : Hashable {
284286
let collection: CurrencyFormatStyleConfiguration.Collection
285287
let currencyCode: String
286-
let locale: Locale
288+
let localeIdentifier: String
289+
let localePreferences: LocalePreferences?
287290
}
288291

289292
private static func skeleton(for signature: Signature) -> String {
@@ -301,20 +304,20 @@ final class ICUCurrencyNumberFormatter : ICUNumberFormatterBase {
301304

302305
static private func _create(with signature: Signature) -> ICUCurrencyNumberFormatter? {
303306
return Self.cache.formatter(for: signature) {
304-
.init(skeleton: Self.skeleton(for: signature), locale: signature.locale)
307+
.init(skeleton: Self.skeleton(for: signature), localeIdentifier: signature.localeIdentifier, preferences: signature.localePreferences)
305308
}
306309
}
307310

308311
static func create<T: BinaryInteger>(for style: IntegerFormatStyle<T>.Currency) -> ICUCurrencyNumberFormatter? {
309-
_create(with: .init(collection: style.collection, currencyCode: style.currencyCode, locale: style.locale))
312+
_create(with: .init(collection: style.collection, currencyCode: style.currencyCode, localeIdentifier: style.locale.identifierCapturingPreferences, localePreferences: style.locale.prefs))
310313
}
311314

312315
static func create(for style: Decimal.FormatStyle.Currency) -> ICUCurrencyNumberFormatter? {
313-
_create(with: .init(collection: style.collection, currencyCode: style.currencyCode, locale: style.locale))
316+
_create(with: .init(collection: style.collection, currencyCode: style.currencyCode, localeIdentifier: style.locale.identifierCapturingPreferences, localePreferences: style.locale.prefs))
314317
}
315318

316319
static func create<T: BinaryFloatingPoint>(for style: FloatingPointFormatStyle<T>.Currency) -> ICUCurrencyNumberFormatter? {
317-
_create(with: .init(collection: style.collection, currencyCode: style.currencyCode, locale: style.locale))
320+
_create(with: .init(collection: style.collection, currencyCode: style.currencyCode, localeIdentifier: style.locale.identifierCapturingPreferences, localePreferences: style.locale.prefs))
318321
}
319322

320323
func attributedFormat(_ v: Value) -> AttributedString {
@@ -330,7 +333,8 @@ final class ICUCurrencyNumberFormatter : ICUNumberFormatterBase {
330333
final class ICUPercentNumberFormatter : ICUNumberFormatterBase {
331334
fileprivate struct Signature : Hashable {
332335
let collection: NumberFormatStyleConfiguration.Collection
333-
let locale: Locale
336+
let localeIdentifier: String
337+
let localePreferences: LocalePreferences?
334338
}
335339

336340
private static func skeleton(for signature: Signature) -> String {
@@ -346,20 +350,20 @@ final class ICUPercentNumberFormatter : ICUNumberFormatterBase {
346350

347351
private static func _create(with signature: Signature) -> ICUPercentNumberFormatter? {
348352
return Self.cache.formatter(for: signature) {
349-
.init(skeleton: Self.skeleton(for: signature), locale: signature.locale)
353+
.init(skeleton: Self.skeleton(for: signature), localeIdentifier: signature.localeIdentifier, preferences: signature.localePreferences)
350354
}
351355
}
352356

353357
static func create<T: BinaryInteger>(for style: IntegerFormatStyle<T>.Percent) -> ICUPercentNumberFormatter? {
354-
_create(with: .init(collection: style.collection, locale: style.locale))
358+
_create(with: .init(collection: style.collection, localeIdentifier: style.locale.identifierCapturingPreferences, localePreferences: style.locale.prefs))
355359
}
356360

357361
static func create(for style: Decimal.FormatStyle.Percent) -> ICUPercentNumberFormatter? {
358-
_create(with: .init(collection: style.collection, locale: style.locale))
362+
_create(with: .init(collection: style.collection, localeIdentifier: style.locale.identifierCapturingPreferences, localePreferences: style.locale.prefs))
359363
}
360364

361365
static func create<T: BinaryFloatingPoint>(for style: FloatingPointFormatStyle<T>.Percent) -> ICUPercentNumberFormatter? {
362-
_create(with: .init(collection: style.collection, locale: style.locale))
366+
_create(with: .init(collection: style.collection, localeIdentifier: style.locale.identifierCapturingPreferences, localePreferences: style.locale.prefs))
363367
}
364368

365369
func attributedFormat(_ v: Value) -> AttributedString {
@@ -375,15 +379,16 @@ final class ICUPercentNumberFormatter : ICUNumberFormatterBase {
375379
final class ICUByteCountNumberFormatter : ICUNumberFormatterBase {
376380
fileprivate struct Signature : Hashable {
377381
let skeleton: String
378-
let locale: Locale
382+
let localeIdentifier: String
383+
let localePreferences: LocalePreferences?
379384
}
380385

381386
fileprivate static let cache = FormatterCache<Signature, ICUByteCountNumberFormatter?>()
382387

383388
static func create(for skeleton: String, locale: Locale) -> ICUByteCountNumberFormatter? {
384-
let signature = Signature(skeleton: skeleton, locale: locale)
389+
let signature = Signature(skeleton: skeleton, localeIdentifier: locale.identifierCapturingPreferences, localePreferences: locale.prefs)
385390
return Self.cache.formatter(for: signature) {
386-
.init(skeleton: skeleton, locale: locale)
391+
.init(skeleton: skeleton, localeIdentifier: locale.identifierCapturingPreferences, preferences: locale.prefs)
387392
}
388393
}
389394

@@ -433,15 +438,16 @@ final class ICUByteCountNumberFormatter : ICUNumberFormatterBase {
433438
final class ICUMeasurementNumberFormatter : ICUNumberFormatterBase {
434439
fileprivate struct Signature : Hashable {
435440
let skeleton: String
436-
let locale: Locale
441+
let localeIdentifier: String
442+
let localePreferences: LocalePreferences?
437443
}
438444

439445
fileprivate static let cache = FormatterCache<Signature, ICUMeasurementNumberFormatter?>()
440446

441447
static func create(for skeleton: String, locale: Locale) -> ICUMeasurementNumberFormatter? {
442-
let signature = Signature(skeleton: skeleton, locale: locale)
448+
let signature = Signature(skeleton: skeleton, localeIdentifier: locale.identifierCapturingPreferences, localePreferences: locale.prefs)
443449
return Self.cache.formatter(for: signature) {
444-
.init(skeleton: skeleton, locale: locale)
450+
.init(skeleton: skeleton, localeIdentifier: locale.identifierCapturingPreferences, preferences: locale.prefs)
445451
}
446452
}
447453

Tests/FoundationInternationalizationTests/Formatting/NumberFormatStyleTests.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,55 @@ final class NumberFormatStyleTests: XCTestCase {
441441
XCTAssertEqual((-3.14159 as Decimal).formatted(.currency(code:"USD")), currencyStyle.format(-3.14159))
442442
XCTAssertEqual((-3000.14159 as Decimal).formatted(.currency(code:"USD")), currencyStyle.format(-3000.14159))
443443
}
444+
445+
func test_autoupdatingCurrentChangesFormatResults() {
446+
let locale = Locale.autoupdatingCurrent
447+
let number = 50_000
448+
#if FOUNDATION_FRAMEWORK
449+
// Measurement is not yet available in the package
450+
let measurement = Measurement(value: 0.8, unit: UnitLength.meters)
451+
#endif
452+
let currency = Decimal(123.45)
453+
let percent = 54.32
454+
let bytes = 1_234_567_890
455+
456+
// Get a formatted result from es-ES
457+
var prefs = LocalePreferences()
458+
prefs.languages = ["es-ES"]
459+
prefs.locale = "es_ES"
460+
LocaleCache.cache.resetCurrent(to: prefs)
461+
let formattedSpanishNumber = number.formatted(.number.locale(locale))
462+
#if FOUNDATION_FRAMEWORK
463+
let formattedSpanishMeasurement = measurement.formatted(.measurement(width: .narrow).locale(locale))
464+
#endif
465+
let formattedSpanishCurrency = currency.formatted(.currency(code: "USD").locale(locale))
466+
let formattedSpanishPercent = percent.formatted(.percent.locale(locale))
467+
let formattedSpanishBytes = bytes.formatted(.byteCount(style: .decimal).locale(locale))
468+
469+
// Get a formatted result from en-US
470+
prefs.languages = ["en-US"]
471+
prefs.locale = "en_US"
472+
LocaleCache.cache.resetCurrent(to: prefs)
473+
let formattedEnglishNumber = number.formatted(.number.locale(locale))
474+
#if FOUNDATION_FRAMEWORK
475+
let formattedEnglishMeasurement = measurement.formatted(.measurement(width: .narrow).locale(locale))
476+
#endif
477+
let formattedEnglishCurrency = currency.formatted(.currency(code: "USD").locale(locale))
478+
let formattedEnglishPercent = percent.formatted(.percent.locale(locale))
479+
let formattedEnglishBytes = bytes.formatted(.byteCount(style: .decimal).locale(locale))
480+
481+
// Reset to current preferences before any possibility of failing this test
482+
LocaleCache.cache.reset()
483+
484+
// No matter what 'current' was before this test was run, formattedSpanish and formattedEnglish should be different.
485+
XCTAssertNotEqual(formattedSpanishNumber, formattedEnglishNumber)
486+
#if FOUNDATION_FRAMEWORK
487+
XCTAssertNotEqual(formattedSpanishMeasurement, formattedEnglishMeasurement)
488+
#endif
489+
XCTAssertNotEqual(formattedSpanishCurrency, formattedEnglishCurrency)
490+
XCTAssertNotEqual(formattedSpanishPercent, formattedEnglishPercent)
491+
XCTAssertNotEqual(formattedSpanishBytes, formattedEnglishBytes)
492+
}
444493
}
445494

446495
extension NumberFormatStyleConfiguration.Collection {

0 commit comments

Comments
 (0)