From 96f50122245d22346492a2bd1fcc278aa7f0908d Mon Sep 17 00:00:00 2001 From: Kiryl Kaveryn Date: Tue, 5 Nov 2024 14:06:19 +0400 Subject: [PATCH] [platform] implement basic Duration formatting Signed-off-by: Kiryl Kaveryn --- platform/CMakeLists.txt | 2 + platform/duration.cpp | 132 ++++++++++++++++++ platform/duration.hpp | 37 +++++ platform/platform_tests/CMakeLists.txt | 1 + platform/platform_tests/duration_tests.cpp | 109 +++++++++++++++ .../platform.xcodeproj/project.pbxproj | 14 ++ 6 files changed, 295 insertions(+) create mode 100644 platform/duration.cpp create mode 100644 platform/duration.hpp create mode 100644 platform/platform_tests/duration_tests.cpp diff --git a/platform/CMakeLists.txt b/platform/CMakeLists.txt index bd6daaf0cd..fbad0d52c0 100644 --- a/platform/CMakeLists.txt +++ b/platform/CMakeLists.txt @@ -13,6 +13,8 @@ set(SRC country_file.hpp distance.cpp distance.hpp + duration.cpp + duration.hpp downloader_defines.hpp downloader_utils.cpp downloader_utils.hpp diff --git a/platform/duration.cpp b/platform/duration.cpp new file mode 100644 index 0000000000..4bf95bfe34 --- /dev/null +++ b/platform/duration.cpp @@ -0,0 +1,132 @@ +#include "duration.hpp" + +/// @todo(KK): move the formatting code from the platform namespace +namespace platform +{ +namespace +{ +using std::chrono::duration_cast, std::chrono::seconds, std::chrono::minutes, std::chrono::hours, std::chrono::days; + +static constexpr std::string_view kNoSpace = ""; + +unsigned long SecondsToUnits(seconds duration, Duration::Units unit) +{ + switch (unit) + { + case Duration::Units::Days: return duration_cast(duration).count(); + case Duration::Units::Hours: return duration_cast(duration).count(); + case Duration::Units::Minutes: return duration_cast(duration).count(); + default: UNREACHABLE(); + } +} + +seconds UnitsToSeconds(long value, Duration::Units unit) +{ + switch (unit) + { + case Duration::Units::Days: return days(value); + case Duration::Units::Hours: return hours(value); + case Duration::Units::Minutes: return minutes(value); + default: UNREACHABLE(); + } +} + +std::string_view GetUnitSeparator(Locale const & locale) +{ + static constexpr auto kEmptyNumberUnitSeparatorLocales = std::array + { + "en", "de", "fr", "he", "fa", "ja", "ko", "mr", "th", "tr", "vi", "zh" + }; + bool const isEmptySeparator = std::find(std::begin(kEmptyNumberUnitSeparatorLocales), std::end(kEmptyNumberUnitSeparatorLocales), locale.m_language) != std::end(kEmptyNumberUnitSeparatorLocales); + return isEmptySeparator ? kNoSpace : kNarrowNonBreakingSpace; +} + +std::string_view GetUnitsGroupingSeparator(Locale const & locale) +{ + static constexpr auto kEmptyGroupingSeparatorLocales = std::array + { + "ja", "zh" + }; + bool const isEmptySeparator = std::find(std::begin(kEmptyGroupingSeparatorLocales), std::end(kEmptyGroupingSeparatorLocales), locale.m_language) != std::end(kEmptyGroupingSeparatorLocales); + return isEmptySeparator ? kNoSpace : kNonBreakingSpace; +} + +constexpr bool IsUnitsOrderValid(std::initializer_list units) { + auto it = units.begin(); + auto prev = *it; + ++it; + for (; it != units.end(); ++it) { + if (static_cast(*it) <= static_cast(prev)) + return false; + prev = *it; + } + return true; +} +} + +Duration::Duration(unsigned long seconds) : m_seconds(seconds) +{} + +std::string Duration::GetLocalizedString(std::initializer_list units, Locale const & locale) const +{ + return GetString(std::move(units), GetUnitSeparator(locale), GetUnitsGroupingSeparator(locale)); +} + +std::string Duration::GetPlatformLocalizedString() const +{ + static auto const kCurrentUnitSeparator = GetUnitSeparator(GetCurrentLocale()); + static auto const kCurrentGroupingSeparator = GetUnitsGroupingSeparator(GetCurrentLocale()); + return GetString({Units::Days, Units::Hours, Units::Minutes}, kCurrentUnitSeparator, kCurrentGroupingSeparator); +} + +std::string Duration::GetString(std::initializer_list units, std::string_view unitSeparator, std::string_view groupingSeparator) const +{ + ASSERT(units.size(), ()); + ASSERT(IsUnitsOrderValid(units), ()); + + if (m_seconds.count() == 0) + return std::to_string(0U).append(unitSeparator).append(GetUnitsString(Units::Minutes)); + + std::string formattedTime; + seconds remainingSeconds = m_seconds; + + for (auto const unit : units) + { + const unsigned long unitsCount = SecondsToUnits(remainingSeconds, unit); + if (unitsCount > 0) + { + if (!formattedTime.empty()) + formattedTime.append(groupingSeparator); + formattedTime.append(std::to_string(unitsCount).append(unitSeparator).append(GetUnitsString(unit))); + remainingSeconds -= UnitsToSeconds(unitsCount, unit); + } + } + return formattedTime; +} + +std::string Duration::GetUnitsString(Units unit) +{ + constexpr std::string_view kStringsMinute = "minute"; + constexpr std::string_view kStringsHour = "hour"; + constexpr std::string_view kStringsDay = "day"; + + switch (unit) + { + case Units::Minutes: return platform::GetLocalizedString(std::string(kStringsMinute)); + case Units::Hours: return platform::GetLocalizedString(std::string(kStringsHour)); + case Units::Days: return platform::GetLocalizedString(std::string(kStringsDay)); + default: UNREACHABLE(); + } +} + +std::string DebugPrint(Duration::Units units) +{ + switch (units) + { + case Duration::Units::Days: return "d"; + case Duration::Units::Hours: return "h"; + case Duration::Units::Minutes: return "m"; + default: UNREACHABLE(); + } +} +} // namespace platform diff --git a/platform/duration.hpp b/platform/duration.hpp new file mode 100644 index 0000000000..6ec353fd94 --- /dev/null +++ b/platform/duration.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "platform/localization.hpp" + +#include +#include +#include + +namespace platform +{ + +class Duration +{ +public: + enum class Units + { + Days = 0, + Hours = 1, + Minutes = 2, + }; + + explicit Duration(unsigned long seconds); + + static std::string GetUnitsString(Units unit); + + std::string GetLocalizedString(std::initializer_list units, Locale const & locale) const; + std::string GetPlatformLocalizedString() const; + +private: + const std::chrono::seconds m_seconds; + + std::string GetString(std::initializer_list units, std::string_view unitSeparator, std::string_view groupingSeparator) const; +}; + +std::string DebugPrint(Duration::Units units); + +} // namespace platform diff --git a/platform/platform_tests/CMakeLists.txt b/platform/platform_tests/CMakeLists.txt index 81831f3d0c..d2be24a22f 100644 --- a/platform/platform_tests/CMakeLists.txt +++ b/platform/platform_tests/CMakeLists.txt @@ -4,6 +4,7 @@ set(SRC apk_test.cpp country_file_tests.cpp distance_tests.cpp + duration_tests.cpp downloader_tests/downloader_test.cpp downloader_utils_tests.cpp get_text_by_id_tests.cpp diff --git a/platform/platform_tests/duration_tests.cpp b/platform/platform_tests/duration_tests.cpp new file mode 100644 index 0000000000..9444d4f61a --- /dev/null +++ b/platform/platform_tests/duration_tests.cpp @@ -0,0 +1,109 @@ +#include "testing/testing.hpp" + +#include "platform/duration.hpp" + +#include + +namespace platform +{ +using std::chrono::duration_cast, std::chrono::seconds, std::chrono::minutes, std::chrono::hours, std::chrono::days; + +struct TestData +{ + struct Duration + { + days m_days; + hours m_hours; + minutes m_minutes; + std::string result; + + Duration(long days, long hours, long minutes, std::string const & result) + : m_days(days), m_hours(hours), m_minutes(minutes), result(result) + {} + + long Seconds() const + { + return (duration_cast(m_days) + + duration_cast(m_hours) + + duration_cast(m_minutes)).count(); + } + }; + + Locale m_locale; + std::vector m_duration; + + constexpr TestData(Locale locale, std::vector duration) + : m_locale(locale), m_duration(duration) + {} +}; + +Locale GetLocale(std::string const & language) +{ + Locale locale; + locale.m_language = language; + return locale; +} +/* + Localized string cannot be retrieved from the app target bundle during the tests execution + and the platform::GetLocalizedString will return the same string as the input ("minute", "hour" etc). + This is why the expectation strings are not explicit. + */ + +auto const m = Duration::GetUnitsString(Duration::Units::Minutes); +auto const h = Duration::GetUnitsString(Duration::Units::Hours); +auto const d = Duration::GetUnitsString(Duration::Units::Days); + +UNIT_TEST(Duration_AllUnits) +{ + TestData const testData[] = { + {GetLocale("en"), + { + {0, 0, 0, "0" + m}, + {0, 0, 1, "1" + m}, + {0, 0, 60, "1" + h}, + {0, 0, 123, "2" + h + kNonBreakingSpace + "3" + m}, + {0, 3, 0, "3" + h}, + {0, 24, 0, "1" + d}, + {4, 0, 0, "4" + d}, + {1, 2, 3, "1" + d + kNonBreakingSpace + "2" + h + kNonBreakingSpace + "3" + m}, + {1, 0, 15, "1" + d + kNonBreakingSpace + "15" + m}, + {0, 15, 1, "15" + h + kNonBreakingSpace + "1" + m}, + {1, 15, 0, "1" + d + kNonBreakingSpace + "15" + h}, + {15, 0, 10, "15" + d + kNonBreakingSpace + "10" + m}, + {15, 15, 15, "15" + d + kNonBreakingSpace + "15" + h + kNonBreakingSpace + "15" + m} + } + }, + }; + + for (auto const & data : testData) + { + for (auto const & dataDuration : data.m_duration) + { + auto const duration = Duration(dataDuration.Seconds()); + auto durationStr = duration.GetLocalizedString({Duration::Units::Days, Duration::Units::Hours, Duration::Units::Minutes}, data.m_locale); + TEST_EQUAL(durationStr, dataDuration.result, ()); + } + } +} + +UNIT_TEST(Duration_Localization) +{ + TestData const testData[] = { + // en + {GetLocale("en"), {{1, 2, 3, "1" + d + kNonBreakingSpace + "2" + h + kNonBreakingSpace + "3" + m}}}, + // ru (narrow spacing between number and unit) + {GetLocale("ru"), {{1, 2, 3, "1" + kNarrowNonBreakingSpace + d + kNonBreakingSpace + "2" + kNarrowNonBreakingSpace + h + kNonBreakingSpace + "3" + kNarrowNonBreakingSpace + m}}}, + // zh (no spacings) + {GetLocale("zh"), {{1, 2, 3, "1" + d + "2" + h + "3" + m}}} + }; + + for (auto const & data : testData) + { + for (auto const & duration : data.m_duration) + { + auto const durationStr = Duration(duration.Seconds()).GetLocalizedString({Duration::Units::Days, Duration::Units::Hours, Duration::Units::Minutes}, data.m_locale); + TEST_EQUAL(durationStr, duration.result, ()); + } + } +} +} // namespace platform diff --git a/xcode/platform/platform.xcodeproj/project.pbxproj b/xcode/platform/platform.xcodeproj/project.pbxproj index 1ee9d96c60..1b1fa72e9b 100644 --- a/xcode/platform/platform.xcodeproj/project.pbxproj +++ b/xcode/platform/platform.xcodeproj/project.pbxproj @@ -114,6 +114,10 @@ D5B191CF2386C7E4009CD0D6 /* http_uploader_background.hpp in Headers */ = {isa = PBXBuildFile; fileRef = D5B191CE2386C7E4009CD0D6 /* http_uploader_background.hpp */; }; EB60B4DC204C130300E4953B /* network_policy_ios.mm in Sources */ = {isa = PBXBuildFile; fileRef = EB60B4DB204C130300E4953B /* network_policy_ios.mm */; }; EB60B4DE204C175700E4953B /* network_policy_ios.h in Headers */ = {isa = PBXBuildFile; fileRef = EB60B4DD204C175700E4953B /* network_policy_ios.h */; }; + ED965B252CD8F72E0049E39E /* duration.hpp in Headers */ = {isa = PBXBuildFile; fileRef = ED965B242CD8F72A0049E39E /* duration.hpp */; }; + ED965B272CD8F7810049E39E /* duration.cpp in Sources */ = {isa = PBXBuildFile; fileRef = ED965B262CD8F77D0049E39E /* duration.cpp */; }; + ED965B472CDA52DB0049E39E /* duration_tests.cpp in Sources */ = {isa = PBXBuildFile; fileRef = ED965B462CDA4EC00049E39E /* duration_tests.cpp */; }; + ED965B482CDA575B0049E39E /* duration.cpp in Sources */ = {isa = PBXBuildFile; fileRef = ED965B262CD8F77D0049E39E /* duration.cpp */; }; F6DF73581EC9EAE700D8BA0B /* string_storage_base.cpp in Sources */ = {isa = PBXBuildFile; fileRef = F6DF73561EC9EAE700D8BA0B /* string_storage_base.cpp */; }; F6DF73591EC9EAE700D8BA0B /* string_storage_base.cpp in Sources */ = {isa = PBXBuildFile; fileRef = F6DF73561EC9EAE700D8BA0B /* string_storage_base.cpp */; }; F6DF735A1EC9EAE700D8BA0B /* string_storage_base.cpp in Sources */ = {isa = PBXBuildFile; fileRef = F6DF73561EC9EAE700D8BA0B /* string_storage_base.cpp */; }; @@ -252,6 +256,9 @@ D5B191CE2386C7E4009CD0D6 /* http_uploader_background.hpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.h; path = http_uploader_background.hpp; sourceTree = ""; }; EB60B4DB204C130300E4953B /* network_policy_ios.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = network_policy_ios.mm; sourceTree = ""; }; EB60B4DD204C175700E4953B /* network_policy_ios.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = network_policy_ios.h; sourceTree = ""; }; + ED965B242CD8F72A0049E39E /* duration.hpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.h; path = duration.hpp; sourceTree = ""; }; + ED965B262CD8F77D0049E39E /* duration.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = duration.cpp; sourceTree = ""; }; + ED965B462CDA4EC00049E39E /* duration_tests.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = duration_tests.cpp; sourceTree = ""; }; F6DF73561EC9EAE700D8BA0B /* string_storage_base.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = string_storage_base.cpp; sourceTree = ""; }; F6DF73571EC9EAE700D8BA0B /* string_storage_base.hpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.h; path = string_storage_base.hpp; sourceTree = ""; }; FAA8389026BB48E9002E54C6 /* libcppjansson.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = libcppjansson.a; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -310,6 +317,7 @@ 675340DE1C58C405002CF0D9 /* platform_tests */ = { isa = PBXGroup; children = ( + ED965B462CDA4EC00049E39E /* duration_tests.cpp */, 168EFCC12A30EB7400F71EE8 /* distance_tests.cpp */, 675341001C58C4C9002CF0D9 /* apk_test.cpp */, 675341011C58C4C9002CF0D9 /* country_file_tests.cpp */, @@ -374,6 +382,8 @@ 6753437A1A3F5CF500A0A8C3 /* platform */ = { isa = PBXGroup; children = ( + ED965B242CD8F72A0049E39E /* duration.hpp */, + ED965B262CD8F77D0049E39E /* duration.cpp */, 1669C83E2A30DCD200530AD1 /* distance.cpp */, 1669C83F2A30DCD200530AD1 /* distance.hpp */, 675343861A3F5D5900A0A8C3 /* apple_location_service.mm */, @@ -516,6 +526,7 @@ 34C624BE1DABCCD100510300 /* socket.hpp in Headers */, D50B2296238591570056820A /* http_payload.hpp in Headers */, 675343C11A3F5D5A00A0A8C3 /* location_service.hpp in Headers */, + ED965B252CD8F72E0049E39E /* duration.hpp in Headers */, 67AB92DD1B7B3D7300AB5194 /* mwm_version.hpp in Headers */, 675343CA1A3F5D5A00A0A8C3 /* platform_unix_impl.hpp in Headers */, 675343D21A3F5D5A00A0A8C3 /* servers_list.hpp in Headers */, @@ -685,6 +696,7 @@ 6741250A1B4C00CC00A3E828 /* country_file.cpp in Sources */, 4564FA7F2094978D0043CCFB /* remote_file.cpp in Sources */, 674125081B4C00CC00A3E828 /* country_defines.cpp in Sources */, + ED965B272CD8F7810049E39E /* duration.cpp in Sources */, EB60B4DC204C130300E4953B /* network_policy_ios.mm in Sources */, 6741250E1B4C00CC00A3E828 /* local_country_file.cpp in Sources */, 670E8C761BB318AB00094197 /* platform_ios.mm in Sources */, @@ -727,6 +739,7 @@ 6783389E1C6DE59200FD6263 /* platform_test.cpp in Sources */, 678338961C6DE59200FD6263 /* apk_test.cpp in Sources */, 678338991C6DE59200FD6263 /* jansson_test.cpp in Sources */, + ED965B482CDA575B0049E39E /* duration.cpp in Sources */, 6783389C1C6DE59200FD6263 /* location_test.cpp in Sources */, 678338981C6DE59200FD6263 /* get_text_by_id_tests.cpp in Sources */, 6783389A1C6DE59200FD6263 /* language_test.cpp in Sources */, @@ -734,6 +747,7 @@ 678338971C6DE59200FD6263 /* country_file_tests.cpp in Sources */, 678338951C6DE59200FD6263 /* testingmain.cpp in Sources */, 1669C8412A30DCD200530AD1 /* distance.cpp in Sources */, + ED965B472CDA52DB0049E39E /* duration_tests.cpp in Sources */, 44CAB5F62A1F926800819330 /* utm_mgrs_utils_tests.cpp in Sources */, F6DF735A1EC9EAE700D8BA0B /* string_storage_base.cpp in Sources */, 168EFCC22A30EB7400F71EE8 /* distance_tests.cpp in Sources */,