diff --git a/CMakeLists_files.cmake b/CMakeLists_files.cmake index 5b684b52fb9..9e319b7f313 100644 --- a/CMakeLists_files.cmake +++ b/CMakeLists_files.cmake @@ -585,6 +585,7 @@ list(APPEND TEST_SOURCE_FILES tests/test_Summary_Group.cpp tests/test_Summary_GSatProd.cpp tests/test_Tables.cpp + tests/test_TimeService.cpp tests/test_uniformtablelinear.cpp tests/test_Uns2CPG.cpp tests/test_Visitor.cpp diff --git a/opm/common/utility/TimeService.cpp b/opm/common/utility/TimeService.cpp index a54ba38afb0..cb6932e9482 100644 --- a/opm/common/utility/TimeService.cpp +++ b/opm/common/utility/TimeService.cpp @@ -25,7 +25,6 @@ #include #include -#include #include #include #include @@ -68,36 +67,36 @@ namespace { - // The days_from_civil() function is from Howard Hinnant, http://howardhinnant.github.io/date_algorithms.html - // The website states: "Consider these donated to the public domain." - - // Returns number of days since civil 1970-01-01. Negative values indicate - // days prior to 1970-01-01. - // Preconditions: y-m-d represents a date in the civil (Gregorian) calendar - // m is in [1, 12] - // d is in [1, last_day_of_month(y, m)] - // y is "approximately" in - // [numeric_limits::min()/366, numeric_limits::max()/366] - // Exact range of validity is: - // [civil_from_days(numeric_limits::min()), - // civil_from_days(numeric_limits::max()-719468)] - template - constexpr - Int - days_from_civil(Int y, unsigned m, unsigned d) noexcept - { - static_assert(std::numeric_limits::digits >= 18, - "This algorithm has not been ported to a 16 bit unsigned integer"); - static_assert(std::numeric_limits::digits >= 20, - "This algorithm has not been ported to a 16 bit signed integer"); - y -= m <= 2; - const Int era = (y >= 0 ? y : y-399) / 400; - const unsigned yoe = static_cast(y - era * 400); // [0, 399] - const unsigned doy = (153*(m > 2 ? m-3 : m+9) + 2)/5 + d-1; // [0, 365] - const unsigned doe = yoe * 365 + yoe/4 - yoe/100 + doy; // [0, 146096] - return era * 146097 + static_cast(doe) - 719468; + // The days std::chrono::year spans, -32767-01-01 to 32767-12-31, taken + // from the types themselves rather than restated, and the seconds those + // days hold. Both conversions accept exactly this and refuse the rest. + // + // long long, not std::time_t: the second counts are near 1e12, and a + // 32-bit time_t would not merely hold the wrong value - a constant + // expression that overflows is ill-formed, so the file would not compile. + // Leaving the arithmetic at the width of long is what the old conversion + // got wrong; a fixed 64 bits here is what keeps that from recurring. + // Comparing a time_t against these widens it, which loses nothing. + namespace calendar_bounds { + constexpr long long first_day = + std::chrono::sys_days{std::chrono::year::min() / std::chrono::January / 1} + .time_since_epoch().count(); + constexpr long long last_day = + std::chrono::sys_days{std::chrono::year::max() / std::chrono::December / 31} + .time_since_epoch().count(); + constexpr long long first_second = first_day * 86400; + constexpr long long last_second = last_day * 86400 + 86399; } + // The arithmetic above is a fixed 64 bits, but a std::time_t is what + // both conversions take and return, and a narrow one would silently + // truncate every date the calendar bounds admit. A schedule runs past + // 2038 as a matter of course, so require the width rather than lose the + // dates quietly. Nothing OPM Flow builds on has a narrower time_t. + static_assert(sizeof(std::time_t) >= 8, + "OPM Flow schedules run past 2038; std::time_t must be 64-bit"); + + } // anonymous namespace @@ -175,23 +174,132 @@ std::time_t mkdate(int in_year, int in_month, int in_day) { return mkdatetime(in_year , in_month , in_day, 0,0,0); } -// The portable_timegm() function is based on -// https://stackoverflow.com/questions/16647819/timegm-cross-platform -// answer by Sergey D. +// timegm() is POSIX, not C++, and the Windows spelling _mkgmtime() stops at +// year 3000. This is the conversion written with the C++20 calendar +// types instead, valid wherever they are: a std::tm's year, month, day and +// time of day to seconds since the epoch, in UTC. +// +// Only the month is normalised into the year, as the earlier version of this +// function did. A day outside the month counts on from its first (33 January +// is 2 February) and the time of day is added as it stands, which is the +// wrap-around mkdatetime() relies on to reject such input. std::time_t portable_timegm(const std::tm* t) { - int year = t->tm_year + 1900; - int month = t->tm_mon; // 0-11 + namespace ch = std::chrono; + + // Everything in long long before any arithmetic: a std::tm's fields are + // ints, and a caller's absurd value must end in the refusal below, not in + // an overflow on the way there. + long long yr = static_cast(t->tm_year) + 1900; + long long month = t->tm_mon; // 0-11 if (month > 11) { - year += month / 12; + yr += month / 12; month %= 12; } else if (month < 0) { - int years_diff = (11 - month) / 12; - year -= years_diff; + const long long years_diff = (11 - month) / 12; + yr -= years_diff; month += 12 * years_diff; } - int days_from_1970 = days_from_civil(year, month + 1, t->tm_mday); - return 60 * (60 * (24L * days_from_1970 + t->tm_hour) + t->tm_min) + t->tm_sec; + + // std::chrono::year runs from -32767 to 32767. No schedule is anywhere + // near either end; refuse the rest rather than hand back a wrong instant. + if (yr < static_cast(ch::year::min()) || yr > static_cast(ch::year::max())) { + throw std::out_of_range { + "Calendar year " + std::to_string(yr) + + " is outside the range std::chrono::year can represent" + }; + } + + const ch::sys_days first_of_month = + ch::year{static_cast(yr)} / ch::month{static_cast(month + 1)} / 1; + // long long, not int: 86400 * days overflows a 32-bit type for any date + // outside roughly 1902-2038, and widening the day count promotes the + // whole expression. Not std::time_t either, for the reason the bounds + // above are not. + const long long days_from_1970 = + static_cast(first_of_month.time_since_epoch().count()) + + (static_cast(t->tm_mday) - 1); + const long long result = + 60 * (60 * (24 * days_from_1970 + t->tm_hour) + t->tm_min) + t->tm_sec; + + // The day of the month and the time of day may have carried the instant + // past the calendar's end even though the year was inside it - + // 32767-12-32, or 24:00 on the last day - and portable_gmtime() would + // refuse what came back. Refuse it here instead, against the same bounds. + if (result < calendar_bounds::first_second || result > calendar_bounds::last_second) { + throw std::out_of_range { + "Date " + std::to_string(yr) + "-" + std::to_string(month + 1) + "-" + + std::to_string(t->tm_mday) + " with the time of day added lies outside " + + "the range std::chrono::year can represent" + }; + } + return static_cast(result); +} + +/* + Break a time_t into UTC civil time without std::gmtime(), the inverse of + portable_timegm() above. + + std::gmtime() is unsuitable here on two counts. It returns nullptr for + time points it cannot represent -- some C runtimes refuse dates beyond + year 3000, which simulation schedules legitimately reach -- and + dereferencing that is a crash. It also returns a pointer to a static + buffer, so two threads converting timestamps concurrently overwrite each + other's result. + + The C++20 calendar types have neither problem and are the exact + inverse of what portable_timegm() does in the other direction, so use + them unconditionally: every platform then exercises the same code. + + Every field std::gmtime() sets is set here too, so this is a drop-in + replacement: DoubHEAD's day-of-year calculation reads tm_yday. + tm_isdst is 0, as it is for UTC. +*/ +std::tm portable_gmtime(const std::time_t t) +{ + namespace ch = std::chrono; + + // Floor division, without the overflow that t - 86399 has at the + // bottom of the range: take the quotient and remainder first, then + // carry a negative remainder into the day. Neither operation can + // overflow for any representable time_t. + auto days = t / 86400; + auto secs = t % 86400; + if (secs < 0) { + secs += 86400; + --days; + } + + // A 64-bit time_t exceeds the days std::chrono::year spans by a wide + // margin in either direction; no schedule holds such an instant, and + // a day count outside them does not fit the calendar arithmetic, so + // refuse it - as std::gmtime() refuses, with nullptr, what its + // runtime cannot represent - rather than hand back a wrong date. + if ((days < calendar_bounds::first_day) || (days > calendar_bounds::last_day)) { + throw std::out_of_range { + "Time point " + std::to_string(static_cast(t)) + + " is outside the range of years std::chrono::year can represent" + }; + } + + const ch::sys_days day{ch::days{static_cast(days)}}; + const ch::year_month_day ymd{day}; + + std::tm tm{}; + tm.tm_year = static_cast(ymd.year()) - 1900; + tm.tm_mon = static_cast(static_cast(ymd.month())) - 1; + tm.tm_mday = static_cast(static_cast(ymd.day())); + tm.tm_hour = static_cast(secs / 3600); secs %= 3600; + tm.tm_min = static_cast(secs / 60); + tm.tm_sec = static_cast(secs % 60); + + // Day of year, counted from 0 as std::tm wants it, and the weekday + // with Sunday as 0, as std::tm and 's c_encoding() agree. + tm.tm_yday = static_cast((day - ch::sys_days{ymd.year() / ch::January / 1}).count()); + tm.tm_wday = static_cast(ch::weekday{day}.c_encoding()); + + tm.tm_isdst = 0; // UTC never has one + return tm; } std::time_t timeFromEclipse(const DeckRecord &dateRecord) { @@ -239,13 +347,11 @@ namespace { return timePoint; } - } Opm::TimeStampUTC::TimeStampUTC(const std::time_t tp) { - auto t = tp; - const auto tm = *std::gmtime(&t); + const auto tm = TimeService::portable_gmtime(tp); this->ymd_ = YMD { tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday }; @@ -263,8 +369,7 @@ Opm::TimeStampUTC::TimeStampUTC(const Opm::TimeStampUTC::YMD& ymd, Opm::TimeStampUTC& Opm::TimeStampUTC::operator=(const std::time_t tp) { - auto t = tp; - const auto tm = *std::gmtime(&t); + const auto tm = TimeService::portable_gmtime(tp); this->ymd_ = YMD { tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday }; diff --git a/opm/common/utility/TimeService.hpp b/opm/common/utility/TimeService.hpp index bb829af4ca2..bf2207066ea 100644 --- a/opm/common/utility/TimeService.hpp +++ b/opm/common/utility/TimeService.hpp @@ -46,7 +46,33 @@ namespace Opm { std::time_t mkdatetime(int in_year, int in_month, int in_day, int hour, int minute, int second); std::time_t mkdate(int in_year, int in_month, int in_day); + /// Seconds since the epoch for a std::tm read as UTC civil time: POSIX + /// timegm(), written with the C++20 calendar types so it is the + /// same everywhere (Windows' _mkgmtime() stops at year 3000). + /// + /// The month is normalised into the year; a day beyond the month and the + /// time of day are added as they stand, so 33 January is 2 February. + /// + /// Throws std::out_of_range for an instant outside what std::chrono::year + /// can represent, -32767-01-01T00:00:00Z to 32767-12-31T23:59:59Z. That + /// is a year past either end, and also an in-range year whose day of the + /// month or time of day carries the instant past it -- 32767-12-32, or + /// 24:00 on the last day. What this returns, portable_gmtime() always + /// takes back. std::time_t portable_timegm(const std::tm* t); + + /// Break a time_t into UTC civil time. + /// + /// The inverse of portable_timegm(), and a replacement for std::gmtime(): + /// that returns nullptr for time points its C runtime cannot represent -- + /// MSVC's refuses everything before 1970 and after year 3000, both of + /// which simulation schedules legitimately reach -- and hands back a + /// pointer to a static buffer that concurrent callers overwrite. + /// + /// Throws std::out_of_range for an instant whose calendar year lies + /// outside std::chrono::year, -32767 to 32767; within it, every field + /// std::gmtime() fills is filled, tm_yday and tm_wday included. + std::tm portable_gmtime(std::time_t t); std::time_t timeFromEclipse(const DeckRecord &dateRecord); } diff --git a/opm/io/eclipse/OutputStream.cpp b/opm/io/eclipse/OutputStream.cpp index e116e660930..693a1403b2f 100644 --- a/opm/io/eclipse/OutputStream.cpp +++ b/opm/io/eclipse/OutputStream.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include @@ -667,7 +668,7 @@ namespace { std::tm startTimeToGmtime(const SummarySpecification::StartTime start) { const auto timepoint = std::chrono::system_clock::to_time_t(start); - return *std::gmtime(&timepoint); + return TimeService::portable_gmtime(timepoint); } std::vector diff --git a/opm/output/eclipse/DoubHEAD.cpp b/opm/output/eclipse/DoubHEAD.cpp index 2af0705ecc4..1953c1a9426 100644 --- a/opm/output/eclipse/DoubHEAD.cpp +++ b/opm/output/eclipse/DoubHEAD.cpp @@ -22,6 +22,8 @@ #include // Opm::RestartIO::makeUTCTime() #include +#include + #include #include #include @@ -350,7 +352,7 @@ namespace { double toDateNum(const std::chrono::time_point tp) { const auto t0 = std::chrono::system_clock::to_time_t(tp); - const auto tm0 = *std::gmtime(&t0); + const auto tm0 = Opm::TimeService::portable_gmtime(t0); // Set clock to 01:00:00+0000 on 2001-- to get // "accurate" day-of-year calculation (no leap year, no DST offset, @@ -368,7 +370,7 @@ namespace { const auto t1 = Opm::TimeService::makeUTCTime(tm1); if (t1 != static_cast(-1)) { - tm1 = *std::gmtime(&t1); // Get new tm_yday. + tm1 = Opm::TimeService::portable_gmtime(t1); // Get new tm_yday. return toDateNum(tm0.tm_year, tm1.tm_yday); } diff --git a/opm/output/eclipse/InteHEAD.cpp b/opm/output/eclipse/InteHEAD.cpp index 3b66514c02f..0206149e62e 100644 --- a/opm/output/eclipse/InteHEAD.cpp +++ b/opm/output/eclipse/InteHEAD.cpp @@ -887,7 +887,7 @@ Opm::RestartIO::getSimulationTimePoint(const std::time_t start, const double elapsed) { const auto now = TimeService::advance(start, elapsed); - const auto tp = *std::gmtime(&now); + const auto tp = TimeService::portable_gmtime(now); auto sec = 0.0; // Not really used here. auto usec = std::floor(1.0e6 * std::modf(elapsed, &sec)); diff --git a/tests/test_TimeService.cpp b/tests/test_TimeService.cpp new file mode 100644 index 00000000000..2ba776085b6 --- /dev/null +++ b/tests/test_TimeService.cpp @@ -0,0 +1,267 @@ +/* + Copyright 2026 Equinor ASA. + + This file is part of the Open Porous Media project (OPM). + + OPM is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + OPM is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with OPM. If not, see . +*/ + +#include "config.h" + +#define BOOST_TEST_MODULE Test TimeService +#include + +#include + +#include +#include +#include +#include + +// TimeStampUTC(std::time_t) breaks a time_t into civil time itself rather than +// calling std::gmtime, so these check the range std::gmtime would not have +// covered: dates a C runtime may refuse, and instants before the epoch. + +BOOST_AUTO_TEST_CASE(FromTimeT_Epoch) +{ + const auto ts = Opm::TimeStampUTC{ std::time_t{0} }; + + BOOST_CHECK_EQUAL(ts.year(), 1970); + BOOST_CHECK_EQUAL(ts.month(), 1); + BOOST_CHECK_EQUAL(ts.day(), 1); + BOOST_CHECK_EQUAL(ts.hour(), 0); + BOOST_CHECK_EQUAL(ts.minutes(), 0); + BOOST_CHECK_EQUAL(ts.seconds(), 0); +} + +BOOST_AUTO_TEST_CASE(FromTimeT_BeforeEpoch) +{ + // One second before the epoch. Needs floor division of a negative time_t: + // truncation towards zero would land on 1970-01-01. + const auto ts = Opm::TimeStampUTC{ std::time_t{-1} }; + + BOOST_CHECK_EQUAL(ts.year(), 1969); + BOOST_CHECK_EQUAL(ts.month(), 12); + BOOST_CHECK_EQUAL(ts.day(), 31); + BOOST_CHECK_EQUAL(ts.hour(), 23); + BOOST_CHECK_EQUAL(ts.minutes(), 59); + BOOST_CHECK_EQUAL(ts.seconds(), 59); +} + +BOOST_AUTO_TEST_CASE(FromTimeT_LeapDay) +{ + // 2000-02-29, the century leap year the 400-year rule keeps. + const auto ts = Opm::TimeStampUTC{ std::time_t{951'782'400} }; + + BOOST_CHECK_EQUAL(ts.year(), 2000); + BOOST_CHECK_EQUAL(ts.month(), 2); + BOOST_CHECK_EQUAL(ts.day(), 29); +} + +BOOST_AUTO_TEST_CASE(FromTimeT_BeyondYear3000) +{ + // 3001-01-01T00:00:00Z: the first instant past MSVC's _gmtime64 range, + // which ends with year 3000. That refusal is why the conversion no longer + // goes through std::gmtime. Schedules do reach here. + const auto ts = Opm::TimeStampUTC{ std::time_t{32'535'216'000} }; + + BOOST_CHECK_EQUAL(ts.year(), 3001); + BOOST_CHECK_EQUAL(ts.month(), 1); + BOOST_CHECK_EQUAL(ts.day(), 1); + BOOST_CHECK_EQUAL(ts.hour(), 0); +} + +BOOST_AUTO_TEST_CASE(RoundTripThroughTimeT) +{ + // asTimeT() and TimeStampUTC(time_t) go through portable_timegm() and + // portable_gmtime() respectively; they must be exact inverses. + for (const auto& ymd : { Opm::TimeStampUTC::YMD{1901, 1, 1}, + Opm::TimeStampUTC::YMD{1969, 12, 31}, + Opm::TimeStampUTC::YMD{1970, 1, 1}, + Opm::TimeStampUTC::YMD{2000, 2, 29}, + Opm::TimeStampUTC::YMD{2026, 8, 5}, + Opm::TimeStampUTC::YMD{2100, 3, 1}, + Opm::TimeStampUTC::YMD{3000, 1, 1} }) + { + const auto stamp = Opm::TimeStampUTC{ ymd }.hour(13).minutes(37).seconds(7); + const auto back = Opm::TimeStampUTC{ Opm::asTimeT(stamp) }; + + BOOST_CHECK_EQUAL(back.year(), ymd.year); + BOOST_CHECK_EQUAL(back.month(), ymd.month); + BOOST_CHECK_EQUAL(back.day(), ymd.day); + BOOST_CHECK_EQUAL(back.hour(), 13); + BOOST_CHECK_EQUAL(back.minutes(), 37); + BOOST_CHECK_EQUAL(back.seconds(), 7); + } +} + +// mkdatetime() rejects an impossible date by converting it and converting it +// back, and that check only works because portable_timegm() lets a day beyond +// the month carry into the next one instead of normalising it away or +// refusing it outright. Nothing else in the suite covers that, so a later +// rewrite of portable_timegm() -- to year_month_day_last, say, or to an ok() +// check on the date -- would quietly make OPM accept 30 FEB in a DATES record +// with every other test still passing. + +BOOST_AUTO_TEST_CASE(MkDate_RejectsImpossibleDates) +{ + // 30 February 1983 counts on to 2 March, 33 January 2026 to 2 February, + // and month 13 of 2026 to January 2027: in each case mkdate() is handed + // back a date it did not ask for, and says so. + BOOST_CHECK_THROW(Opm::TimeService::mkdate(1983, 2, 30), std::invalid_argument); + BOOST_CHECK_THROW(Opm::TimeService::mkdate(2026, 1, 33), std::invalid_argument); + BOOST_CHECK_THROW(Opm::TimeService::mkdate(2026, 13, 1), std::invalid_argument); + + // 29 February is not wrap-around in a leap year, and is in every other. + BOOST_CHECK_NO_THROW(Opm::TimeService::mkdate(2000, 2, 29)); + BOOST_CHECK_THROW(Opm::TimeService::mkdate(1900, 2, 29), std::invalid_argument); + BOOST_CHECK_THROW(Opm::TimeService::mkdate(2026, 2, 29), std::invalid_argument); +} + +// portable_gmtime() is the std::gmtime() replacement on the restart output +// path, and that path reads more than the date: DoubHEAD takes tm_yday, and +// a drop-in replacement has to agree on tm_wday too. Dates with a known +// weekday and day of year, on both sides of the epoch and beyond year 3000. + +BOOST_AUTO_TEST_CASE(PortableGmtime_DayOfYearAndWeekday) +{ + struct Case { std::time_t t; int year; int mon; int mday; int yday; int wday; }; + for (const auto& c : { Case{ 0, 1970, 1, 1, 0, 4 }, // Thursday + Case{ -1, 1969, 12, 31, 364, 3 }, // Wednesday + Case{ 951'782'400, 2000, 2, 29, 59, 2 }, // Tuesday + // Sunday, and a leap year's last day + Case{ 978'220'800, 2000, 12, 31, 365, 0 }, + // Thursday, past _gmtime64's range + Case{ 32'535'216'000, 3001, 1, 1, 0, 4 } }) + { + const auto tm = Opm::TimeService::portable_gmtime(c.t); + + BOOST_CHECK_EQUAL(tm.tm_year + 1900, c.year); + BOOST_CHECK_EQUAL(tm.tm_mon + 1, c.mon); + BOOST_CHECK_EQUAL(tm.tm_mday, c.mday); + BOOST_CHECK_EQUAL(tm.tm_yday, c.yday); + BOOST_CHECK_EQUAL(tm.tm_wday, c.wday); + BOOST_CHECK_EQUAL(tm.tm_isdst, 0); + } +} + +BOOST_AUTO_TEST_CASE(PortableGmtime_FarFromEpoch) +{ + // 0001-01-01T00:00:00Z, a Monday: 62 billion seconds before the epoch, + // so the floor division of a negative time_t is exercised well away + // from anything a C runtime handles, with a checkable answer. + const auto tm = Opm::TimeService::portable_gmtime(std::time_t{-62'135'596'800}); + + BOOST_CHECK_EQUAL(tm.tm_year + 1900, 1); + BOOST_CHECK_EQUAL(tm.tm_mon + 1, 1); + BOOST_CHECK_EQUAL(tm.tm_mday, 1); + BOOST_CHECK_EQUAL(tm.tm_hour, 0); + BOOST_CHECK_EQUAL(tm.tm_yday, 0); + BOOST_CHECK_EQUAL(tm.tm_wday, 1); +} + +BOOST_AUTO_TEST_CASE(PortableGmtime_EndsOfTheCalendar) +{ + // The last and the first instant std::chrono::year can represent: + // 32767-12-31T23:59:59Z and -32767-01-01T00:00:00Z. Both convert, with + // every field, and what one direction hands back the other takes back. + { + const auto t = std::time_t{971'890'963'199}; + auto tm = Opm::TimeService::portable_gmtime(t); + + BOOST_CHECK_EQUAL(tm.tm_year + 1900, 32767); + BOOST_CHECK_EQUAL(tm.tm_mon + 1, 12); + BOOST_CHECK_EQUAL(tm.tm_mday, 31); + BOOST_CHECK_EQUAL(tm.tm_hour, 23); + BOOST_CHECK_EQUAL(tm.tm_min, 59); + BOOST_CHECK_EQUAL(tm.tm_sec, 59); + BOOST_CHECK_EQUAL(tm.tm_yday, 364); + BOOST_CHECK_EQUAL(Opm::TimeService::portable_timegm(&tm), t); + } + { + // Taken from itself, and checked against the arithmetic: + // 12'687'428 days before the epoch. + namespace ch = std::chrono; + const auto first_day = ch::sys_days{ch::year::min() / ch::January / 1} + .time_since_epoch().count(); + const auto t = std::time_t{first_day} * 86400; + BOOST_CHECK_EQUAL(t, std::time_t{-1'096'193'779'200}); + auto tm = Opm::TimeService::portable_gmtime(t); + + BOOST_CHECK_EQUAL(tm.tm_year + 1900, -32767); + BOOST_CHECK_EQUAL(tm.tm_mon + 1, 1); + BOOST_CHECK_EQUAL(tm.tm_mday, 1); + BOOST_CHECK_EQUAL(tm.tm_hour, 0); + BOOST_CHECK_EQUAL(tm.tm_yday, 0); + BOOST_CHECK_EQUAL(Opm::TimeService::portable_timegm(&tm), t); + } +} + +BOOST_AUTO_TEST_CASE(PortableGmtime_YearOutsideTm) +{ + // The ends of the 64-bit range. The floor division there used to + // overflow (t - 86399 at the minimum); now it does not, and what is + // refused is only what comes after it: a year outside std::chrono::year. + // That refusal is the defined behaviour, in place of a silently wrong + // date, and it starts one second past either end of the calendar. + BOOST_CHECK_THROW(Opm::TimeService::portable_gmtime(std::numeric_limits::min()), + std::out_of_range); + BOOST_CHECK_THROW(Opm::TimeService::portable_gmtime(std::numeric_limits::max()), + std::out_of_range); + BOOST_CHECK_THROW(Opm::TimeService::portable_gmtime(std::time_t{971'890'963'199} + 1), + std::out_of_range); + BOOST_CHECK_THROW(Opm::TimeService::portable_gmtime(std::time_t{-1'096'193'779'200} - 1), + std::out_of_range); + + // And the other direction refuses a std::tm whose year is past the end, + // after month normalisation: December of 32767 plus one month is 32768. + std::tm past{}; + past.tm_year = 32767 - 1900; + past.tm_mon = 12; + past.tm_mday = 1; + BOOST_CHECK_THROW(Opm::TimeService::portable_timegm(&past), std::out_of_range); + + // Or whose year is inside it but whose day of the month or time of day + // carries the instant past the end: 32767-12-32, 24:00 on 32767-12-31, + // and an hour before -32767-01-01. The last valid instants beside them + // convert. + std::tm edge{}; + edge.tm_year = 32767 - 1900; + edge.tm_mon = 11; + edge.tm_mday = 31; + edge.tm_hour = 23; edge.tm_min = 59; edge.tm_sec = 59; + BOOST_CHECK_EQUAL(Opm::TimeService::portable_timegm(&edge), std::time_t{971'890'963'199}); + edge.tm_mday = 32; edge.tm_hour = 0; edge.tm_min = 0; edge.tm_sec = 0; + BOOST_CHECK_THROW(Opm::TimeService::portable_timegm(&edge), std::out_of_range); + edge.tm_mday = 31; edge.tm_hour = 24; + BOOST_CHECK_THROW(Opm::TimeService::portable_timegm(&edge), std::out_of_range); + + edge = std::tm{}; + edge.tm_year = -32767 - 1900; + edge.tm_mon = 0; + edge.tm_mday = 1; + BOOST_CHECK_EQUAL(Opm::TimeService::portable_timegm(&edge), std::time_t{-1'096'193'779'200}); + edge.tm_hour = -1; + BOOST_CHECK_THROW(Opm::TimeService::portable_timegm(&edge), std::out_of_range); + + // An absurd field is refused too, rather than overflowing on the way. + edge = std::tm{}; + edge.tm_year = 2026 - 1900; + edge.tm_mon = std::numeric_limits::min(); + edge.tm_mday = 1; + BOOST_CHECK_THROW(Opm::TimeService::portable_timegm(&edge), std::out_of_range); + edge.tm_mon = 0; + edge.tm_mday = std::numeric_limits::max(); + BOOST_CHECK_THROW(Opm::TimeService::portable_timegm(&edge), std::out_of_range); +}