diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd8f680..d3232b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,15 +34,17 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt-get install -y lcov - lcov --capture --directory . --output-file=coverage.info - lcov --extract coverage.info '*/Xclox/include/*' '*/Xclox/test/ntp/tools/*' --output-file coverage.txt + lcov --capture --directory . --output-file=coverage.info \ + --include '*/Xclox/include/*' \ + --include '*/Xclox/test/ntp/tools/*' \ + --exclude '*/Xclox/test/ntp/tools/helper.hpp' - name: Upload Coverage to Codecov if: matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} - file: ./coverage.txt + file: ./coverage.info verbose: true deploy: diff --git a/README.md b/README.md index 5b8b19d..129c84f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ It has been thoroughly tested, as its development has been test-driven (TDD). ## Usage -The following example shows only the basic functionalities of the library. For further details, please see the documentation. +The following example shows only the basic functionalities of the library. For further details, please see the [documentation](https://laateef.github.io/Xclox/index.html). ### Example diff --git a/include/xclox/datetime.hpp b/include/xclox/datetime.hpp index 2c8685c..26b4215 100644 --- a/include/xclox/datetime.hpp +++ b/include/xclox/datetime.hpp @@ -86,23 +86,16 @@ class DateTime { /// Move-constructs a DateTime object from \p other. DateTime(DateTime&& other) = default; - // const auto& microseconds = std::chrono::duration_cast(timePoint.time_since_epoch() % std::chrono::seconds(1)); - // const auto& floatingSecond = std::chrono::seconds(timePoint.time_since_epoch().count() < 0 && microseconds.count() != 0 ? 1 : 0); - // const auto t {std::chrono::system_clock::to_time_t(timePoint - floatingSecond)}; - // stream << std::put_time(std::gmtime(&t), "%F %T") << "." << std::setfill('0') << std::setw(6) << (floatingSecond + microseconds).count(); - /** * Constructs a DateTime object from \p duration since the epoch "1970-01-01 00:00:00 UTC". * The constructed datetime has whatever precision it is given, down to nanoseconds. */ explicit DateTime(const Duration& duration) - // : m_date(std::chrono::duration_cast(duration)) - // , m_time(duration % Days(1)) { - const auto& subday = duration % Days(1); - const auto& floatingDay = Days(duration.count() < 0 && subday.count() != 0 ? 1 : 0); + const auto& subDay = duration % Days(1); + const auto& floatingDay = Days(duration.count() < 0 && subDay.count() != 0 ? 1 : 0); m_date = Date(std::chrono::duration_cast(duration - floatingDay)); - m_time = Time(subday + floatingDay); + m_time = Time(subDay + floatingDay); } /// Constructs a DateTime object from the standard library's chrono time point, \p timePoint. @@ -622,7 +615,7 @@ class DateTime { * ----------- | ----------------------------------------------------- * # | era of year as a positive sign(+) or negative sign(-) * E | era of year as CE or BCE - * y | year as one digit or more (1, 9999) + * y | year as one digit or more (1, 9999+) * yy | year of era as two digits (00, 99) * yyyy | year as four digits (0000, 9999) * M | month of year as one digit or more (1, 12) @@ -657,12 +650,17 @@ class DateTime { * If the datetime is invalid, an empty string will be returned. * @see dayOfWeekName(), monthName() */ - std::string toString(const std::string& format) const + std::string toString(const std::string& format = "yyyy-MM-dd hh:mm:ss") const { - if (!isValid()) + if (!isValid() || format.empty()) return std::string(); - - return m_date.toString(m_time.toString(format)); + std::stringstream output; + for (size_t pos = 0; pos < format.size(); ++pos) { + const auto count = internal::countIdenticalCharsFrom(pos, format); + output << (internal::isPattern(format.at(pos), count) ? stringify(format.at(pos), count) : format.substr(pos, count)); + pos += count - 1; + } + return output.str(); } /// @} @@ -673,7 +671,7 @@ class DateTime { */ static DateTime current() { - return DateTime(Date::current(), Time::current()); + return DateTime(std::chrono::system_clock::now()); } /// Returns a DateTime object set to the epoch "1970-1-1T00:00:00". @@ -848,6 +846,55 @@ class DateTime { /// @} private: + std::string stringify(char flag, size_t count) const + { + std::stringstream output; + output << std::setfill('0') << std::setw(count); + if (flag == '#') { + output << (year() < 0 ? "-" : "+"); + } else if (flag == 'E') { + output << (year() < 0 ? "BCE" : "CE"); + } else if (flag == 'y') { + const int y = std::abs(year()); + if (count == 1) { + output << y; + } else if (count == 2) { + output << y - (y / 100 * 100); + } else if (count == 4) { + output << y - (y / 10000 * 10000); + } + } else if (flag == 'M') { + if (count == 1 || count == 2) { + output << month(); + } else if (count == 3) { + output << internal::getShortMonthName(month()); + } else if (count == 4) { + output << internal::getLongMonthName(month()); + } + } else if (flag == 'd') { + if (count == 1 || count == 2) { + output << day(); + } else if (count == 3) { + output << internal::getShortWeekdayName(dayOfWeek()); + } else if (count == 4) { + output << internal::getLongWeekdayName(dayOfWeek()); + } + } else if (flag == 'h') { + output << hour(); + } else if (flag == 'm') { + output << minute(); + } else if (flag == 's') { + output << second(); + } else if (flag == 'f') { + output << nanosecond() / static_cast(std::pow(10, 9 - count)); + } else if (flag == 'a') { + output << (hour() < 12 ? "am" : "pm"); + } else if (flag == 'A') { + output << (hour() < 12 ? "AM" : "PM"); + } + return output.str(); + } + Date m_date; Time m_time; }; diff --git a/include/xclox/internal.hpp b/include/xclox/internal.hpp index 250b5aa..754b493 100644 --- a/include/xclox/internal.hpp +++ b/include/xclox/internal.hpp @@ -8,11 +8,12 @@ #ifndef XCLOX_INTERNAL_HPP #define XCLOX_INTERNAL_HPP +#include +#include #include #include #include -#include -#include +#include namespace xclox { @@ -39,7 +40,7 @@ namespace internal { return std::stoi(intStr); } - inline std::string getShortWeekdayName(int index) + inline std::string getShortWeekdayName(int day) { static const std::string weekdayNameArray[] = { "Mon", @@ -50,10 +51,10 @@ namespace internal { "Sat", "Sun" }; - return weekdayNameArray[index - 1]; + return weekdayNameArray[day - 1]; } - inline std::string getLongWeekdayName(int index) + inline std::string getLongWeekdayName(int day) { static const std::string weekdayNameArray[] = { "Monday", @@ -64,7 +65,7 @@ namespace internal { "Saturday", "Sunday" }; - return weekdayNameArray[index - 1]; + return weekdayNameArray[day - 1]; } inline const std::array& getShortMonthNameArray() @@ -125,6 +126,25 @@ namespace internal { return static_cast(std::distance(getLongMonthNameArray().cbegin(), std::find(getLongMonthNameArray().cbegin(), getLongMonthNameArray().cend(), month)) + 1); } + inline bool isPattern(char flag, size_t count) + { + static const std::unordered_map patternMap { + { '#', 1 }, + { 'E', 1 }, + { 'y', (1 | 1 << 1 | 1 << 3) }, + { 'M', (1 | 1 << 1 | 1 << 2 | 1 << 3) }, + { 'd', (1 | 1 << 1 | 1 << 2 | 1 << 3) }, + { 'h', (1 | 1 << 1) }, + { 'm', (1 | 1 << 1) }, + { 's', (1 | 1 << 1) }, + { 'f', (1 | 1 << 1 | 1 << 2 | 1 << 3 | 1 << 4 | 1 << 5 | 1 << 6 | 1 << 7 | 1 << 8) }, + { 'a', 1 }, + { 'A', 1 } + }; + const auto iter = patternMap.find(flag); + return iter != patternMap.cend() && iter->second & (1 << count - 1); + } + } // namespace internal } // namespace xclox diff --git a/test/date.h b/test/date.h index 586e825..bf5afc8 100644 --- a/test/date.h +++ b/test/date.h @@ -287,6 +287,8 @@ TEST_SUITE("Date") } SUBCASE("days in month") { + CHECK(Date::daysInMonthOfYear(1, 0) == -1); + CHECK(Date::daysInMonthOfYear(1, 32) == -1); CHECK(Date::daysInMonthOfYear(1970, 1) == 31); CHECK(Date(1970, 1, 1).daysInMonth() == 31); CHECK(Date(1970, 2, 1).daysInMonth() == 28); diff --git a/test/datetime.h b/test/datetime.h index 829d0e2..51c583d 100644 --- a/test/datetime.h +++ b/test/datetime.h @@ -184,16 +184,7 @@ TEST_SUITE("DateTime") TEST_CASE("current datetime") { - // DateTime dt = DateTime::current(); - // std::time_t tTime = std::time(nullptr); - // std::tm* tmTime = std::gmtime(&tTime); - - // CHECK(dt.year() == tmTime->tm_year + 1900); - // CHECK(dt.month() == tmTime->tm_mon + 1); - // CHECK(dt.day() == tmTime->tm_mday); - // CHECK(dt.hour() == tmTime->tm_hour); - // CHECK(dt.minute() == tmTime->tm_min); - // CHECK(dt.second() == tmTime->tm_sec); + CHECK(abs(duration_cast(DateTime::current().toStdTimePoint() - system_clock::now()).count()) < 100); } TEST_CASE("epoch") @@ -237,12 +228,202 @@ TEST_SUITE("DateTime") TEST_CASE("formatting") { - CHECK(DateTime().toString("d/M/yyyy, hh:mm:ss.fffffffff") == ""); - CHECK(DateTime(Date(1999, 5, 18), Time(23, 55, 57, Time::Nanoseconds(123456789))).toString("d/M/yyyy, hh:mm:ss.fffffffff") == "18/5/1999, 23:55:57.123456789"); - CHECK(DateTime(Date(1969, 12, 31), Time(23, 59, 59, 1)).toString("yyyy-MM-dd hh:mm:ss.fff") == "1969-12-31 23:59:59.001"); + SUBCASE("empty format") + { + CHECK(DateTime(Date(2024, 2, 15), Time(2, 3, 1, 4)).toString("") == ""); + } + SUBCASE("era of year as a positive(+) or negative(-) sign") + { + CHECK(DateTime(Date(-1, 2, 3), Time(4, 5, 6)).toString("#") == "-"); + CHECK(DateTime(Date(1, 2, 3), Time(4, 5, 6)).toString("#") == "+"); + } + SUBCASE("era of year as CE or BCE") + { + CHECK(DateTime(Date(-1, 2, 3), Time(4, 5, 6)).toString("E") == "BCE"); + CHECK(DateTime(Date(1, 2, 3), Time(4, 5, 6)).toString("E") == "CE"); + } + SUBCASE("year as one digit or more (1, 9999+)") + { + CHECK(DateTime(Date(-1, 2, 3), Time(4, 5, 6)).toString("y") == "1"); + CHECK(DateTime(Date(11, 2, 3), Time(4, 5, 6)).toString("y") == "11"); + } + SUBCASE("year of era as two digits (00, 99)") + { + CHECK(DateTime(Date(1, 2, 3), Time(4, 5, 6)).toString("yy") == "01"); + CHECK(DateTime(Date(-1, 2, 3), Time(4, 5, 6)).toString("yy") == "01"); + CHECK(DateTime(Date(123, 2, 3), Time(4, 5, 6)).toString("yy") == "23"); + CHECK(DateTime(Date(-1234, 2, 3), Time(4, 5, 6)).toString("yy") == "34"); + } + SUBCASE("year as four digits (0000, 9999)") + { + CHECK(DateTime(Date(-1, 2, 3), Time(4, 5, 6)).toString("yyyy") == "0001"); + CHECK(DateTime(Date(12345, 2, 3), Time(4, 5, 6)).toString("yyyy") == "2345"); + } + SUBCASE("month of year as one digit or more (1, 12)") + { + CHECK(DateTime(Date(1, 1, 3), Time(4, 5, 6)).toString("M") == "1"); + } + SUBCASE("month of year as two digits (01, 12)") + { + CHECK(DateTime(Date(1, 1, 3), Time(4, 5, 6)).toString("MM") == "01"); + } + SUBCASE("month of year as short name (e.g. Feb)") + { + CHECK(DateTime(Date(1, 1, 3), Time(4, 5, 6)).toString("MMM") == "Jan"); + } + SUBCASE("month of year as short name (e.g. February)") + { + CHECK(DateTime(Date(1, 1, 3), Time(4, 5, 6)).toString("MMMM") == "January"); + } + SUBCASE("day of month as one digit or more (1, 31)") + { + CHECK(DateTime(Date(1, 1, 1), Time(4, 5, 6)).toString("d") == "1"); + } + SUBCASE("day of month as two digits (00, 31)") + { + CHECK(DateTime(Date(1, 1, 1), Time(4, 5, 6)).toString("dd") == "01"); + } + SUBCASE("day of week as short name (e.g. Fri)") + { + CHECK(DateTime(Date(2024, 2, 18), Time(4, 5, 6)).toString("ddd") == "Sun"); + } + SUBCASE("day of week as long name (e.g. Friday)") + { + CHECK(DateTime(Date(2024, 2, 18), Time(4, 5, 6)).toString("dddd") == "Sunday"); + } + SUBCASE("one-digit hour (0, 23)") + { + CHECK(DateTime(Date(1, 2, 3), Time(4, 5, 6)).toString("h") == "4"); + } + SUBCASE("two-digit hour (00, 23)") + { + CHECK(DateTime(Date(1, 2, 3), Time(4, 5, 6)).toString("hh") == "04"); + } + SUBCASE("one-digit minute (0, 59)") + { + CHECK(DateTime(Date(1, 2, 3), Time(4, 5, 6)).toString("m") == "5"); + } + SUBCASE("two-digit minute (00, 59)") + { + CHECK(DateTime(Date(1, 2, 3), Time(4, 5, 6)).toString("mm") == "05"); + } + SUBCASE("one-digit second (0, 59)") + { + CHECK(DateTime(Date(1, 2, 3), Time(4, 5, 6)).toString("s") == "6"); + } + SUBCASE("two-digit second (00, 59)") + { + CHECK(DateTime(Date(1, 2, 3), Time(4, 5, 6)).toString("ss") == "06"); + } + SUBCASE("subsecond") + { + DateTime dt1(Date(1, 2, 3), Time(4, 5, 6, 0)); + DateTime dt2(Date(1, 2, 3), Time(4, 5, 6, DateTime::Nanoseconds(987654321))); + + SUBCASE("one-digit subsecond (0, 9)") + { + CHECK(dt1.toString("f") == "0"); + CHECK(dt2.toString("f") == "9"); + } + SUBCASE("two-digit subsecond (00, 99)") + { + CHECK(dt1.toString("ff") == "00"); + CHECK(dt2.toString("ff") == "98"); + } + SUBCASE("three-digit subsecond (000, 999)") + { + CHECK(dt1.toString("fff") == "000"); + CHECK(dt2.toString("fff") == "987"); + } + SUBCASE("four-digit subsecond (0000, 9999)") + { + CHECK(dt1.toString("ffff") == "0000"); + CHECK(dt2.toString("ffff") == "9876"); + } + SUBCASE("five-digit subsecond (00000, 99999)") + { + CHECK(dt1.toString("fffff") == "00000"); + CHECK(dt2.toString("fffff") == "98765"); + } + SUBCASE("six-digit subsecond (000000, 999999)") + { + CHECK(dt1.toString("ffffff") == "000000"); + CHECK(dt2.toString("ffffff") == "987654"); + } + SUBCASE("seven-digit subsecond (0000000, 9999999)") + { + CHECK(dt1.toString("fffffff") == "0000000"); + CHECK(dt2.toString("fffffff") == "9876543"); + } + SUBCASE("eight-digit subsecond (00000000, 99999999)") + { + CHECK(dt1.toString("ffffffff") == "00000000"); + CHECK(dt2.toString("ffffffff") == "98765432"); + } + SUBCASE("nine-digit subsecond (000000000, 999999999)") + { + CHECK(dt1.toString("fffffffff") == "000000000"); + CHECK(dt2.toString("fffffffff") == "987654321"); + } + } + SUBCASE("before/after noon indicator (i.e. am or pm)") + { + Date d(1, 2, 3); + DateTime dt1(d, Time(0, 0, 0)); + DateTime dt2(d, Time(11, 59, 59)); + DateTime dt3(d, Time(12, 0, 0)); + DateTime dt4(d, Time(23, 59, 59)); + + SUBCASE("am / pm") + { + CHECK(dt1.toString("a") == "am"); + CHECK(dt2.toString("a") == "am"); + CHECK(dt3.toString("a") == "pm"); + CHECK(dt4.toString("a") == "pm"); + } + SUBCASE("AM / PM)") + { + CHECK(dt1.toString("A") == "AM"); + CHECK(dt2.toString("A") == "AM"); + CHECK(dt3.toString("A") == "PM"); + CHECK(dt4.toString("A") == "PM"); + } + } + SUBCASE("unrecognized flags are preserved") + { + const auto& text = "- GNU'S NOT UNIX!"; + CHECK(DateTime(Date(1, 2, 3), Time(4, 5, 6, DateTime::Nanoseconds(987654321))).toString(text) == text); + } + SUBCASE("combinations of format specifiers") + { + DateTime dt(Date(2024, 2, 18), Time(21, 46, 7, DateTime::Nanoseconds(987654321))); + CHECK_EQ(dt.toString(" # E y-yy-yyyy M-MM-MMM-MMMM d-dd-ddd-dddd h-hh-m-mm-s-ss f-ff-fff-ffff-fffff-ffffff-fffffff-ffffffff-fffffffff a A "), + " + CE 2024-24-2024 2-02-Feb-February 18-18-Sun-Sunday 21-21-46-46-7-07 9-98-987-9876-98765-987654-9876543-98765432-987654321 pm PM "); + } + SUBCASE("flags with unrecognized length are preserved") + { + DateTime dt(Date(2024, 2, 18), Time(21, 46, 7, DateTime::Nanoseconds(987654321))); + CHECK(dt.toString(" yyy yyyyy ddddd MMMMM hhh mmm sss ffffffffff ") == " yyy yyyyy ddddd MMMMM hhh mmm sss ffffffffff "); + } + SUBCASE("default format") + { + CHECK(DateTime(Date(2024, 2, 18), Time(21, 46, 7, DateTime::Nanoseconds(987654321))).toString() == "2024-02-18 21:46:07"); + } + SUBCASE("invalid date or time") + { + CHECK(DateTime().toString("d/M/yyyy, hh:mm:ss.fffffffff") == ""); + CHECK(DateTime(Date(0, 0, 0), Time(-4, -5, 66)).toString("#y-M-d h:m:s.f") == ""); + CHECK(DateTime(Date(1, -2, -3), Time(4, 5, 6)).toString("E yy-MMM-ddd h-mm-ss A") == ""); + CHECK(DateTime(Date(0, 2, 3), Time(4, 5, 6, -9)).toString("@ yyyy-MM-dd hh:mm:ss.fff a") == ""); + } // // additional tests. // + // ISO format + CHECK(DateTime(Date(2024, 2, 18), Time(21, 46, 7, DateTime::Nanoseconds(987654321))).toString("yyyy-MM-ddThh:mm:ss") == "2024-02-18T21:46:07"); + // Web format + CHECK(DateTime(Date(2024, 2, 18), Time(21, 46, 7, DateTime::Nanoseconds(987654321))).toString("ddd, dd MMM yyyy hh:mm:ss") == "Sun, 18 Feb 2024 21:46:07"); + // const auto& format = "yyyy-MM-dd hh:mm:ss.fffffffff"; // NTP epoch CHECK(DateTime(-NtpDeltaSeconds - seconds(1)).toString(format) == "1899-12-31 23:59:59.000000000"); diff --git a/test/time.h b/test/time.h index 7438266..91ee565 100644 --- a/test/time.h +++ b/test/time.h @@ -140,7 +140,7 @@ TEST_SUITE("Time") TEST_CASE("current time") { - CHECK(abs(duration_cast(Time::current() - Time(system_clock::now())).count()) < 50); + CHECK(abs(duration_cast(Time::current() - Time(system_clock::now())).count()) < 100); } TEST_CASE("midnight") @@ -200,6 +200,16 @@ TEST_SUITE("Time") TEST_CASE("formatting") { + SUBCASE("empty format") + { + CHECK(Time(1, 2, 3).toString("") == ""); + } + SUBCASE("invalid time") + { + CHECK(Time().toString("H:m:s") == ""); + CHECK(Time(0, 0, -1).toString("H:m:s") == ""); + CHECK(Time(Time::Hours(24)).toString("HH:mm:ss") == ""); + } SUBCASE("12 hours") { CHECK(Time(23, 45, 2).toString("H:m:s") == "11:45:2");