diff --git a/src/corelib/text/qlocale.cpp b/src/corelib/text/qlocale.cpp index 34b6b9d52eb..0d41ec2239a 100644 --- a/src/corelib/text/qlocale.cpp +++ b/src/corelib/text/qlocale.cpp @@ -3882,8 +3882,15 @@ QString QCalendarBackend::dateTimeToString(QStringView format, const QDateTime & text = when.timeRepresentation().displayName(when, mode, locale); if (!text.isEmpty()) return text; - // else fall back to an unlocalized one if we can manage it: - } // else: prefer QDateTime's abbreviation, for backwards-compatibility. + // else fall back to an unlocalized one if we can find one. + } + if (type == Long) { + // If no long name found, use IANA ID: + text = QString::fromLatin1(when.timeZone().id()); + if (!text.isEmpty()) + return text; + } + // else: prefer QDateTime's abbreviation, for backwards-compatibility. #endif // else, make do with non-localized abbreviation: // Absent timezone_locale data, Offset might still reach here: if (type == Offset) // Our prior failure might not have tried this: diff --git a/src/corelib/time/qdatetime.cpp b/src/corelib/time/qdatetime.cpp index 5c3c88e92ee..a5ac987c701 100644 --- a/src/corelib/time/qdatetime.cpp +++ b/src/corelib/time/qdatetime.cpp @@ -2210,13 +2210,13 @@ QString QTime::toString(Qt::DateFormat format) const \li The timezone's offset from UTC with a colon between the hours and minutes (for example "+02:00"). \row \li tttt - \li The timezone name (for example "Europe/Berlin"). Note that this - gives no indication of whether the datetime was in daylight-saving - time or standard time, which may lead to ambiguity if the datetime - falls in an hour repeated by a transition between the two. The name - used is the one provided by \l QTimeZone::displayName() with the \l - QTimeZone::LongName type. This may depend on the operating system - in use. + \li The timezone name, as provided by \l QTimeZone::displayName() with + the \l QTimeZone::LongName type. This may depend on the operating + system in use. If no such name is available, the IANA ID of the + zone (such as "Europe/Berlin") may be used. It may give no + indication of whether the datetime was in daylight-saving time or + standard time, which may lead to ambiguity if the datetime falls in + an hour repeated by a transition between the two. \endtable \note To get localized forms of AM or PM (the \c{AP}, \c{ap}, \c{A}, \c{a}, @@ -5879,9 +5879,10 @@ QDateTime QDateTime::fromString(QStringView string, Qt::DateFormat format) \li the timezone in offset format with a colon between hours and minutes (for example "+02:00") \row \li tttt - \li the timezone name (for example "Europe/Berlin"). The name - recognized are those known to \l QTimeZone, which may depend on the - operating system in use. + \li the timezone name, either what \l QTimeZone::displayName() reports + for \l QTimeZone::LongName or the IANA ID of the zone (for example + "Europe/Berlin"). The names recognized are those known to \l + QTimeZone, which may depend on the operating system in use. \endtable If no 't' format specifier is present, the system's local time-zone is used. diff --git a/src/corelib/time/qdatetimeparser.cpp b/src/corelib/time/qdatetimeparser.cpp index 9af92941d6b..ec9c3b4a13c 100644 --- a/src/corelib/time/qdatetimeparser.cpp +++ b/src/corelib/time/qdatetimeparser.cpp @@ -13,6 +13,9 @@ #include "qtimezone.h" #include "qvarlengtharray.h" #include "private/qlocale_p.h" +#if QT_CONFIG(timezone) +#include "private/qtimezoneprivate_p.h" +#endif #include "private/qstringiterator_p.h" #include "private/qtenvironmentvariables_p.h" @@ -1216,6 +1219,27 @@ static int startsWithLocalTimeZone(QStringView name, const QDateTime &when, cons return int(longest); } +#if QT_CONFIG(timezone) +static auto findZoneByLongName(QStringView str, const QLocale &locale, const QDateTime &when) +{ + struct R + { + QTimeZone zone; + qsizetype nameLength = 0; + bool isValid() const { return nameLength > 0 && zone.isValid(); } + } result; + auto pfx = QTimeZonePrivate::findLongNamePrefix(str, locale, when.toMSecsSinceEpoch()); + if (!pfx.nameLength) // Incomplete data in when: try without time-point. + pfx = QTimeZonePrivate::findLongNamePrefix(str, locale); + if (pfx.nameLength > 0) { + result = R{ QTimeZone(pfx.ianaId), pfx.nameLength }; + Q_ASSERT(result.zone.isValid()); + // TODO: we should be able to take pfx.timeType into account. + } + return result; +} +#endif // timezone + /*! \internal */ @@ -1301,9 +1325,14 @@ QDateTimeParser::scanString(const QDateTime &defaultValue, bool fixup) const timeZone = QTimeZone::fromSecondsAheadOfUtc(sect.value); #if QT_CONFIG(timezone) } else if (startsWithLocalTimeZone(zoneName, usedDateTime, locale()) != sect.used) { - QTimeZone namedZone = QTimeZone(zoneName.toLatin1()); - Q_ASSERT(namedZone.isValid()); - timeZone = namedZone; + if (QTimeZone namedZone = QTimeZone(zoneName.toLatin1()); namedZone.isValid()) { + timeZone = namedZone; + } else { + auto found = findZoneByLongName(zoneName, locale(), usedDateTime); + Q_ASSERT(found.isValid()); + Q_ASSERT(found.nameLength == zoneName.length()); + timeZone = found.zone; + } #endif } else { timeZone = QTimeZone::LocalTime; @@ -1841,12 +1870,17 @@ QDateTimeParser::findTimeZoneName(QStringView str, const QDateTime &when) const lastSlash = slash; } - for (; index > systemLength; --index) { // Find longest match - str.truncate(index); - QTimeZone zone(str.toLatin1()); + // Find longest IANA ID match: + for (QStringView copy = str; index > systemLength; --index) { + copy.truncate(index); + QTimeZone zone(copy.toLatin1()); if (zone.isValid()) return ParsedSection(Acceptable, zone.offsetFromUtc(when), index); } + // Not a known IANA ID. + + if (auto found = findZoneByLongName(str, locale(), when); found.isValid()) + return ParsedSection(Acceptable, found.zone.offsetFromUtc(when), found.nameLength); #endif if (systemLength > 0) // won't actually use the offset, but need it to be valid return ParsedSection(Acceptable, when.toLocalTime().offsetFromUtc(), systemLength); diff --git a/src/corelib/time/qtimezonelocale.cpp b/src/corelib/time/qtimezonelocale.cpp index dd22e68c5e6..c27050648f6 100644 --- a/src/corelib/time/qtimezonelocale.cpp +++ b/src/corelib/time/qtimezonelocale.cpp @@ -7,6 +7,7 @@ #if !QT_CONFIG(icu) # include # include +# include // Use data generated from CLDR: # include "qtimezonelocale_data_p.h" # include "qtimezoneprivate_data_p.h" @@ -229,6 +230,22 @@ quint16 metaZoneAt(QByteArrayView zoneId, qint64 atMSecsSinceEpoch) return it != stop && it->begin <= dt ? it->metaZoneKey : 0; } +// True if the named zone is ever part of the specified metazone: +bool zoneEverInMeta(QByteArrayView zoneId, quint16 metaKey) +{ + for (auto it = std::lower_bound(std::begin(zoneHistoryTable), std::end(zoneHistoryTable), + zoneId, + [](const ZoneMetaHistory &record, QByteArrayView id) { + return record.ianaId().compare(id, Qt::CaseInsensitive) < 0; + }); + it != std::end(zoneHistoryTable) && it->ianaId().compare(zoneId, Qt::CaseInsensitive) == 0; + ++it) { + if (it->metaZoneKey == metaKey) + return true; + } + return false; +} + constexpr bool dataBeforeMeta(const MetaZoneData &row, quint16 metaKey) noexcept { return row.metaZoneKey < metaKey; @@ -361,6 +378,147 @@ QString formatOffset(QStringView format, int offsetMinutes, const QLocale &local return result; } +struct OffsetFormatMatch +{ + qsizetype size = 0; + int offset = 0; + operator bool() { return size != 0; } +}; + +OffsetFormatMatch matchOffsetText(QStringView text, QStringView format, const QLocale &locale, + QLocale::FormatType scale) +{ + // Sign is taken care of by caller. + // TODO (QTBUG-77948): rework in terms of text pattern matchers. + // For now, don't try to be general, it gets too tricky. + OffsetFormatMatch res; + // At least at CLDR v46: + // Amharic in Ethiopia has ±HHmm formats; all others use separators. + // None have single m. All have H or HH before mm. (None has anything after mm.) + // In narrow format, mm and its preceding separator are elided for 0 + // minutes; and hour may be single digit even if the format says HH. + qsizetype cut = format.indexOf(u'H'); + if (cut < 0 || !text.startsWith(format.first(cut)) || !format.endsWith(u"mm")) + return res; + text = text.sliced(cut); + QStringView sep = format.sliced(cut).chopped(2); // Prune prefix and "mm". + int hlen = 1; // We already know we have one 'H' at the start of sep. + while (hlen < sep.size() && sep[hlen] == u'H') + ++hlen; + sep = sep.sliced(hlen); + + int digits = 0; + while (digits < text.size() && digits < 4 && text[digits].isDigit()) + ++digits; + + // See zoneOffsetFormat() for the eccentric meaning of scale. + QStringView minStr; + if (sep.isEmpty()) { + if (digits > hlen) { + // Long and Short formats allow two-digit match when hlen < 2. + if (scale == QLocale::NarrowFormat || (hlen < 2 && text[0] != u'0')) + hlen = digits - 2; + else if (digits < hlen + 2) + return res; + minStr = text.sliced(hlen).first(2); + } else if (scale == QLocale::NarrowFormat) { + hlen = digits; + } else if (hlen != digits) { + return res; + } + } else { + const qsizetype sepAt = text.indexOf(sep); // May be -1; digits isn't < -1. + if (digits < sepAt) // Separator doesn't immediately follow hour. + return res; + if (scale == QLocale::NarrowFormat || (hlen < 2 && text[0] != u'0')) + hlen = digits; + else if (digits != hlen) + return res; + if (sepAt >= 0 && text.size() >= sepAt + sep.size() + 2) + minStr = text.sliced(sepAt + sep.size()).first(2); + else if (scale != QLocale::NarrowFormat) + return res; + else if (sepAt >= 0) // Allow minutes without zero-padding in narrow format. + minStr = text.sliced(sepAt + sep.size()); + } + if (hlen < 1) + return res; + + bool ok = true; + uint minute = minStr.isEmpty() ? 0 : locale.toUInt(minStr, &ok); + if (!ok && scale == QLocale::NarrowFormat) { + // Fall back to matching hour-only form: + minStr = {}; + ok = true; + } + if (ok && minute < 60) { + uint hour = locale.toUInt(text.first(hlen), &ok); + if (ok) { + res.offset = (hour * 60 + minute) * 60; + res.size = cut + hlen; + if (!minStr.isEmpty()) + res.size += sep.size() + minStr.size(); + } + } + return res; +} + +OffsetFormatMatch matchOffsetFormat(QStringView text, const QLocale &locale, qsizetype locInd, + QLocale::FormatType scale) +{ + const LocaleZoneData &locData = localeZoneData[locInd]; + const QStringView posHourForm = locData.posHourFormat().viewData(hourFormatTable); + const QStringView negHourForm = locData.negHourFormat().viewData(hourFormatTable); + // For the negative format, allow U+002d to match U+2212 or locale.negativeSign(); + const bool mapNeg = text.contains(u'-') + && (negHourForm.contains(u'\u2212') || negHourForm.contains(locale.negativeSign())); + // See zoneOffsetFormat() for the eccentric meaning of scale. + if (scale == QLocale::ShortFormat) { + if (auto match = matchOffsetText(text, posHourForm, locale, scale)) + return match; + if (auto match = matchOffsetText(text, negHourForm, locale, scale)) { + return { match.size, -match.offset }; + } else if (mapNeg) { + const QString mapped = negHourForm.toString() + .replace(u'\u2212', u'-').replace(locale.negativeSign(), "-"_L1); + if (auto match = matchOffsetText(text, mapped, locale, scale)) + return { match.size, -match.offset }; + } + } else { + const QStringView offsetFormat = locData.offsetGmtFormat().viewData(gmtFormatTable); + qsizetype cut = offsetFormat.indexOf(u"%0"); // Should be present + if (cut >= 0) { + const QStringView gmtPrefix = offsetFormat.first(cut); + const QStringView gmtSuffix = offsetFormat.sliced(cut + 2); // After %0 + const qsizetype gmtSize = cut + gmtSuffix.size(); + // Cheap pre-test: check suffix does appear after prefix, albeit we must + // later check it actually appears right after the offset text: + if ((gmtPrefix.isEmpty() || text.startsWith(gmtPrefix)) + && (gmtSuffix.isEmpty() || text.sliced(cut).indexOf(gmtSuffix) >= 0)) { + if (auto match = matchOffsetText(text.sliced(cut), posHourForm, locale, scale)) { + if (text.sliced(cut + match.size).startsWith(gmtSuffix)) // too sliced ? + return { gmtSize + match.size, match.offset }; + } + if (auto match = matchOffsetText(text.sliced(cut), negHourForm, locale, scale)) { + if (text.sliced(cut + match.size).startsWith(gmtSuffix)) + return { gmtSize + match.size, -match.offset }; + } else if (mapNeg) { + const QString mapped = negHourForm.toString() + .replace(u'\u2212', u'-').replace(locale.negativeSign(), "-"_L1); + if (auto match = matchOffsetText(text.sliced(cut), mapped, locale, scale)) { + if (text.sliced(cut + match.size).startsWith(gmtSuffix)) + return { gmtSize + match.size, -match.offset }; + } + } + // Match empty offset as UTC (unless that'd be an empty match): + if (gmtSize > 0 && text.sliced(cut).startsWith(gmtSuffix)) + return { gmtSize, 0 }; + } + } + } + return {}; +} + } // nameless namespace namespace QtTimeZoneLocale { @@ -427,6 +585,7 @@ QString QTimeZonePrivate::localeName(qint64 atMSecsSinceEpoch, int offsetFromUtc return QtTimeZoneLocale::zoneOffsetFormat(locale, locale.d->m_index, QLocale::LongFormat, QDateTime(), offsetFromUtc); } + // Handling of long names must stay in sync with findLongNamePrefix(), below. // An IANA ID may give clues to fall back on for abbreviation or exemplar city: QByteArray ianaAbbrev, ianaTail; @@ -636,6 +795,223 @@ QString QTimeZonePrivate::localeName(qint64 atMSecsSinceEpoch, int offsetFromUtc return QtTimeZoneLocale::zoneOffsetFormat(locale, locale.d->m_index, QLocale::NarrowFormat, QDateTime(), offsetFromUtc); } + +// Match what the above might return at the start of a text (usually a tail of a +// datetime string). +QTimeZonePrivate::NamePrefixMatch +QTimeZonePrivate::findLongNamePrefix(QStringView text, const QLocale &locale, + std::optional atEpochMillis) +{ + constexpr std::size_t invalidMetaId = std::size(metaIdData); + constexpr std::size_t invalidIanaId = std::size(ianaIdData); + constexpr QTimeZone::TimeType timeTypes[] = { + // In preference order, should more than one match: + QTimeZone::GenericTime, + QTimeZone::StandardTime, + QTimeZone::DaylightTime, + }; + struct { + qsizetype nameLength = 0; + QTimeZone::TimeType timeType = QTimeZone::GenericTime; + quint16 ianaIdIndex = invalidIanaId; + quint16 metaIdIndex = invalidMetaId; + QLocale::Territory where = QLocale::AnyTerritory; + } best; +#define localeRows(table, member) QSpan(table).first(nextData.member).sliced(locData.member) + + const QList indices = fallbackLocalesFor(locale.d->m_index); + for (const qsizetype locInd : indices) { + const LocaleZoneData &locData = localeZoneData[locInd]; + // After the row for the last actual locale, there's a terminal row: + Q_ASSERT(std::size_t(locInd) < std::size(localeZoneData) - 1); + const LocaleZoneData &nextData = localeZoneData[locInd + 1]; + + const auto metaRows = localeRows(localeMetaZoneLongNameTable, m_metaLongTableStart); + for (const LocaleMetaZoneLongNames &row : metaRows) { + for (const QTimeZone::TimeType type : timeTypes) { + QLocaleData::DataRange range = row.longName(type); + if (range.size > best.nameLength) { + QStringView name = range.viewData(longMetaZoneNameTable); + if (text.startsWith(name)) { + best = { range.size, type, invalidIanaId, row.metaIdIndex }; + if (best.nameLength >= text.size()) + break; + } + } + } + if (best.nameLength >= text.size()) + break; + } + + const auto ianaRows = localeRows(localeZoneNameTable, m_zoneTableStart); + for (const LocaleZoneNames &row : ianaRows) { + for (const QTimeZone::TimeType type : timeTypes) { + QLocaleData::DataRange range = row.longName(type); + if (range.size > best.nameLength) { + QStringView name = range.viewData(longZoneNameTable); + // Save potentially expensive "zone is supported" check when possible: + bool gotZone = row.ianaIdIndex == best.ianaIdIndex + || QTimeZone::isTimeZoneIdAvailable(row.ianaId().toByteArray()); + if (text.startsWith(name) && gotZone) + best = { range.size, type, row.ianaIdIndex }; + } + } + } + } + // That's found us our best match, possibly as a meta-zone + if (best.metaIdIndex != invalidMetaId) { + const auto metaIdBefore = [](auto &row, quint16 key) { return row.metaIdIndex < key; }; + // Find the standard IANA ID for this meta-zone (or one for another + // supported zone using the meta-zone at the specified time). + const MetaZoneData *metaRow = + std::lower_bound(std::begin(metaZoneTable), std::end(metaZoneTable), + best.metaIdIndex, metaIdBefore); + // Table is sorted by metazone, then territory. + for (; metaRow < std::end(metaZoneTable) + && metaRow->metaIdIndex == best.metaIdIndex; ++metaRow) { + auto metaLand = QLocale::Territory(metaRow->territory); + // World entry is the "standard" zone for this metazone, so always + // prefer it over any territory-specific one (from an earlier row): + if ((best.where == QLocale::AnyTerritory || metaLand == QLocale::World) + && (atEpochMillis + ? metaRow->metaZoneKey == metaZoneAt(metaRow->ianaId(), *atEpochMillis) + : zoneEverInMeta(metaRow->ianaId(), metaRow->metaZoneKey))) { + if (metaRow->ianaIdIndex == best.ianaIdIndex + || QTimeZone::isTimeZoneIdAvailable(metaRow->ianaId().toByteArray())) { + best.ianaIdIndex = metaRow->ianaIdIndex; + best.where = metaLand; + if (best.where == QLocale::World) + break; + } + } + } + } + if (best.ianaIdIndex != invalidIanaId) + return { QByteArray(ianaIdData + best.ianaIdIndex), best.nameLength, best.timeType }; + + // Now try for a region format: + best = {}; + for (const qsizetype locInd : indices) { + const LocaleZoneData &locData = localeZoneData[locInd]; + const LocaleZoneData &nextData = localeZoneData[locInd + 1]; + for (const QTimeZone::TimeType timeType : timeTypes) { + QStringView regionFormat + = locData.regionFormatRange(timeType).viewData(regionFormatTable); + // "%0 [Season] Time", "Time in %0 [during Season]" &c. + const qsizetype cut = regionFormat.indexOf(u"%0"); + if (cut < 0) // Shouldn't happen unless empty. + continue; + + QStringView prefix = regionFormat.first(cut); + // Any text before %0 must appear verbatim at the start of our text: + if (cut > 0 && !text.startsWith(prefix)) + continue; + QStringView suffix = regionFormat.sliced(cut + 2); // after %0 + // This must start with an exemplar city or territory, followed by suffix: + QStringView tail = text.sliced(cut); + + // Cheap pretest - any text after %0 must appear *somewhere* in our text: + if (suffix.size() && tail.indexOf(suffix) < 0) + continue; // No match possible + + // Of course, particularly if just punctuation, a copy of our suffix + // might appear within the city or territory name. + const auto textMatches = [tail, suffix](QStringView where) { + return (where.isEmpty() || tail.startsWith(where)) + && (suffix.isEmpty() || tail.sliced(where.size()).startsWith(suffix)); + }; + + const auto cityRows = localeRows(localeZoneExemplarTable, m_exemplarTableStart); + for (const LocaleZoneExemplar &row : cityRows) { + QStringView city = row.exemplarCity().viewData(exemplarCityTable); + if (textMatches(city)) { + qsizetype length = cut + city.size() + suffix.size(); + if (length > best.nameLength) { + bool gotZone = row.ianaIdIndex == best.ianaIdIndex + || QTimeZone::isTimeZoneIdAvailable(row.ianaId().toByteArray()); + if (gotZone) + best = { length, timeType, row.ianaIdIndex }; + } + } + } + // In localeName() we fall back to the last part of the IANA ID: + const QList allZones = QTimeZone::availableTimeZoneIds(); + for (const auto &iana : allZones) { + Q_ASSERT(!iana.isEmpty()); + qsizetype slash = iana.lastIndexOf('/'); + QByteArray local = slash > 0 ? iana.sliced(slash + 1) : iana; + QString city = QString::fromLatin1(local.replace('_', ' ')); + if (textMatches(city)) { + qsizetype length = cut + city.size() + suffix.size(); + if (length > best.nameLength) { + // Have to find iana in ianaIdData. Although its entries + // from locale-independent data are nicely sorted, the + // rest are (sadly) not. + QByteArrayView run(ianaIdData, qstrlen(ianaIdData)); + // std::size includes the trailing '\0', so subtract one: + const char *stop = ianaIdData + std::size(ianaIdData) - 1; + while (run != iana) { + if (run.end() < stop) { // Step to the next: + run = QByteArrayView(run.end() + 1); + } else { + run = QByteArrayView(); + break; + } + } + if (!run.isEmpty()) { + Q_ASSERT(run == iana); + const auto ianaIdIndex = run.begin() - ianaIdData; + Q_ASSERT(ianaIdIndex <= (std::numeric_limits::max)()); + best = { length, timeType, quint16(ianaIdIndex) }; + } + } + } + } + // TODO: similar for territories, at least once localeName() does so. + } + } + if (best.ianaIdIndex != invalidIanaId) + return { QByteArray(ianaIdData + best.ianaIdIndex), best.nameLength, best.timeType }; +#undef localeRows + + // (We don't want offset format to match 'tttt', so do need to limit this.) + // The final fall-back for localeName() is a zoneOffsetFormat(,,NarrowFormat,,): + if (auto match = matchOffsetFormat(text, locale, locale.d->m_index, QLocale::NarrowFormat)) { + // Check offset is sane: + if (QTimeZone::MinUtcOffsetSecs <= match.offset + && match.offset <= QTimeZone::MaxUtcOffsetSecs) { + + // Although we don't have an IANA ID, the ISO offset format text + // should match what the QLocale(ianaId) constructor accepts, which + // is good enough for our purposes. + return { isoOffsetFormat(match.offset, QTimeZone::OffsetName).toLatin1(), + match.size, QTimeZone::GenericTime }; + } + } + + // Match the unlocalized long form of QUtcTimeZonePrivate: + if (text.startsWith(u"UTC")) { + if (text.size() > 4 && (text[3] == u'+' || text[3] == u'-')) { + // Compare QUtcTimeZonePrivate::offsetFromUtcString() + using QtMiscUtils::isAsciiDigit; + qsizetype length = 3; + int groups = 0; // Number of groups of digits seen (allow up to three). + do { + // text[length] is sign or the colon after last digit-group. + Q_ASSERT(length < text.size()); + if (length + 1 >= text.size() || !isAsciiDigit(text[length + 1].unicode())) + break; + length += + (length + 2 < text.size() && isAsciiDigit(text[length + 2].unicode())) ? 3 : 2; + } while (++groups < 3 && length < text.size() && text[length] == u':'); + if (length > 4) + return { text.sliced(length).toLatin1(), length, QTimeZone::GenericTime }; + } + return { utcQByteArray(), 3, QTimeZone::GenericTime }; + } + + return {}; // No match found. +} #endif // ICU or not QT_END_NAMESPACE diff --git a/src/corelib/time/qtimezoneprivate.cpp b/src/corelib/time/qtimezoneprivate.cpp index 491fba84e3b..4ef990bde7c 100644 --- a/src/corelib/time/qtimezoneprivate.cpp +++ b/src/corelib/time/qtimezoneprivate.cpp @@ -16,6 +16,9 @@ #include #include +#if QT_CONFIG(icu) || !QT_CONFIG(timezone_locale) +# include +#endif #include #include @@ -798,6 +801,162 @@ QString QTimeZonePrivate::isoOffsetFormat(int offsetFromUtc, QTimeZone::NameType return result; } +#if QT_CONFIG(icu) || !QT_CONFIG(timezone_locale) +static QTimeZonePrivate::NamePrefixMatch +findUtcOffsetPrefix(QStringView text, const QLocale &locale) +{ + // First, see if we have a {UTC,GMT}+offset. This would ideally use + // locale-appropriate versions of the offset format, but we don't know those. + qsizetype signLen = 0; + char sign = '\0'; + auto signStart = [&signLen, &sign, locale](QStringView str) { + QString signStr = locale.negativeSign(); + if (str.startsWith(signStr)) { + sign = '-'; + signLen = signStr.size(); + return true; + } + // Special case: U+2212 MINUS SIGN (cf. qlocale.cpp's NumericTokenizer) + if (str.startsWith(u'\u2212')) { + sign = '-'; + signLen = 1; + return true; + } + signStr = locale.positiveSign(); + if (str.startsWith(signStr)) { + sign = '+'; + signLen = signStr.size(); + return true; + } + return false; + }; + // Should really use locale-appropriate + if (!((text.startsWith(u"UTC") || text.startsWith(u"GMT")) && signStart(text.sliced(3)))) + return {}; + + QStringView offset = text.sliced(3 + signLen); + QStringIterator iter(offset); + qsizetype hourEnd = 0, hmMid = 0, minEnd = 0; + int digits = 0; + char32_t ch; + while (iter.hasNext()) { + ch = iter.next(); + if (!QChar::isDigit(ch)) + break; + + ++digits; + // Have hourEnd keep track of the end of the last-but-two digit, if + // we have that many; use hmMid to hold the last-but-one. + hourEnd = std::exchange(hmMid, std::exchange(minEnd, iter.index())); + } + if (digits < 1 || digits > 4) // No offset or something other than an offset. + return {}; + + QStringView hourStr, minStr; + if (digits < 3 && iter.hasNext() && QChar::isPunct(ch)) { + hourEnd = minEnd; // Use all digits seen thus far for hour. + hmMid = iter.index(); // Reuse as minStart, in effect. + int mindig = 0; + while (mindig < 2 && iter.hasNext() && QChar::isDigit(iter.next())) { + ++mindig; + minEnd = iter.index(); + } + if (mindig == 2) + minStr = offset.first(minEnd).sliced(hmMid); + else + minEnd = hourEnd; // Ignore punctuator and beyond + } else { + minStr = offset.first(minEnd).sliced(hourEnd); + } + hourStr = offset.first(hourEnd); + + bool ok = false; + uint hour = 0, minute = 0; + if (!hourStr.isEmpty()) + hour = locale.toUInt(hourStr, &ok); + if (ok && !minStr.isEmpty()) { + minute = locale.toUInt(minStr, &ok); + // If the part after a punctuator is bad, pretend we never saw it: + if ((!ok || minute >= 60) && minEnd > hourEnd + minStr.size()) { + minEnd = hourEnd; + minute = 0; + ok = true; + } + // but if we had too many digits for just an hour, and its tail + // isn't minutes, then this isn't an offset form. + } + + constexpr int MaxOffsetSeconds + = qMax(QTimeZone::MaxUtcOffsetSecs, -QTimeZone::MinUtcOffsetSecs); + if (!ok || (hour * 60 + minute) * 60 > MaxOffsetSeconds) + return {}; // Let the zone-name scan find UTC or GMT prefix as a zone name. + + // Transform offset into the form the QTimeZone constructor prefers: + char buffer[26]; + // We need: 3 for "UTC", 1 for sign, 2+2 for digits, 1 for colon between, 1 + // for '\0'; but gcc [-Werror=format-truncation=] doesn't know the %02u + // fields can't be longer than 2 digits, so complains if we don't have space + // for 10 digits in each. + if (minute) + std::snprintf(buffer, sizeof(buffer), "UTC%c%02u:%02u", sign, hour, minute); + else + std::snprintf(buffer, sizeof(buffer), "UTC%c%02u", sign, hour); + + return { QByteArray(buffer, qstrnlen(buffer, sizeof(buffer))), + 3 + signLen + minEnd, + QTimeZone::GenericTime }; +} + +QTimeZonePrivate::NamePrefixMatch +QTimeZonePrivate::findLongNamePrefix(QStringView text, const QLocale &locale, + std::optional atEpochMillis) +{ + // Search all known zones for one that matches a prefix of text in our locale. + const auto when = atEpochMillis + ? QDateTime::fromMSecsSinceEpoch(*atEpochMillis, QTimeZone::UTC) + : QDateTime(); + const auto typeFor = [when](QTimeZone zone) { + if (when.isValid() && zone.isDaylightTime(when)) + return QTimeZone::DaylightTime; + // Assume standard time name applies equally as generic: + return QTimeZone::GenericTime; + }; + QTimeZonePrivate::NamePrefixMatch best = findUtcOffsetPrefix(text, locale); + constexpr QTimeZone::TimeType types[] + = { QTimeZone::GenericTime, QTimeZone::StandardTime, QTimeZone::DaylightTime }; + const auto improves = [text, &best](const QString &name) { + return text.startsWith(name, Qt::CaseInsensitive) && name.size() > best.nameLength; + }; + const QList allZones = QTimeZone::availableTimeZoneIds(); + for (const QByteArray &iana : allZones) { + QTimeZone zone(iana); + if (!zone.isValid()) + continue; + if (when.isValid()) { + QString name = zone.displayName(when, QTimeZone::LongName, locale); + if (improves(name)) + best = { iana, name.size(), typeFor(zone) }; + } else { + for (const QTimeZone::TimeType type : types) { + QString name = zone.displayName(type, QTimeZone::LongName, locale); + if (improves(name)) + best = { iana, name.size(), type }; + } + } + // If we have a match for all of text, we can't get any better: + if (best.nameLength >= text.size()) + break; + } + // This has the problem of selecting the first IANA ID of a zone with a + // match; where several IANA IDs share a long name, this may not be the + // natural one to pick. Hopefully a backend that does its own name L10n will + // at least produce one with the same offsets as the most natural choice. + return best; +} +#else +// Implemented in qtimezonelocale.cpp +#endif // icu || !timezone_locale + QByteArray QTimeZonePrivate::aliasToIana(QByteArrayView alias) { const auto data = std::lower_bound(std::begin(aliasMappingTable), std::end(aliasMappingTable), @@ -1080,8 +1239,49 @@ QString QUtcTimeZonePrivate::displayName(QTimeZone::TimeType timeType, QTimeZone::NameType nameType, const QLocale &locale) const { +#if QT_CONFIG(timezone_locale) + QString name = QTimeZonePrivate::displayName(timeType, nameType, locale); + // That may fall back to standard offset format, in which case we'd sooner + // use m_name if it's non-empty (for the benefit of custom zones). + // However, a localized fallback is better than ignoring the locale, so only + // consider the fallback a match if it matches modulo reading GMT as UTC, + // U+2212 as MINUS SIGN and the narrow form of offset the fallback uses. + const auto matchesFallback = [](int offset, QStringView name) { + // Fallback rounds offset to nearest minute: + int seconds = offset % 60; + int rounded = offset + + (seconds > 30 || (seconds == 30 && (offset / 60) % 2) + ? 60 - seconds // Round up to next minute + : (seconds < -30 || (seconds == -30 && (offset / 60) % 2) + ? -(60 + seconds) // Round down to previous minute + : -seconds)); + const QString avoid = isoOffsetFormat(rounded); + if (name == avoid) + return true; + Q_ASSERT(avoid.startsWith("UTC"_L1)); + Q_ASSERT(avoid.size() == 9); + // Fallback may use GMT in place of UTC, but always has sign plus at + // least one hour digit, even for +0: + if (!(name.startsWith("GMT"_L1) || name.startsWith("UTC"_L1)) || name.size() < 5) + return false; + // Fallback drops trailing ":00" minute: + QStringView tail{avoid}; + tail = tail.sliced(3); + if (tail.endsWith(":00"_L1)) + tail = tail.chopped(3); + if (name.sliced(3) == tail) + return true; + // Accept U+2212 as minus sign: + const QChar sign = name[3] == u'\u2212' ? u'-' : name[3]; + // Fallback doesn't zero-pad hour: + return sign == tail[0] && tail.sliced(tail[1] == u'0' ? 2 : 1) == name.sliced(4); + }; + if (!name.isEmpty() && (m_name.isEmpty() || !matchesFallback(m_offsetFromUtc, name))) + return name; +#else // No L10N :-( Q_UNUSED(timeType); Q_UNUSED(locale); +#endif if (nameType == QTimeZone::ShortName) return m_abbreviation; if (nameType == QTimeZone::OffsetName) diff --git a/src/corelib/time/qtimezoneprivate_p.h b/src/corelib/time/qtimezoneprivate_p.h index 8889e648963..1846f5a4ab1 100644 --- a/src/corelib/time/qtimezoneprivate_p.h +++ b/src/corelib/time/qtimezoneprivate_p.h @@ -25,6 +25,7 @@ #if QT_CONFIG(timezone_tzdb) #include #endif +#include #if QT_CONFIG(icu) #include @@ -156,6 +157,14 @@ public: static QList windowsIdToIanaIds(const QByteArray &windowsId); static QList windowsIdToIanaIds(const QByteArray &windowsId, QLocale::Territory territory); + struct NamePrefixMatch + { + QByteArray ianaId; + qsizetype nameLength = 0; + QTimeZone::TimeType timeType = QTimeZone::GenericTime; + }; + static NamePrefixMatch findLongNamePrefix(QStringView text, const QLocale &locale, + std::optional atEpochMillis = std::nullopt); // returns "UTC" QString and QByteArray [[nodiscard]] static inline QString utcQString() @@ -168,6 +177,14 @@ public: return QByteArrayLiteral("UTC"); } + +#ifdef QT_BUILD_INTERNAL // For the benefit of a test + [[nodiscard]] static inline const QTimeZonePrivate *extractPrivate(const QTimeZone &zone) + { + return zone.d.operator->(); + } +#endif + protected: // Zones CLDR data says match a condition. // Use to filter what the backend has available. diff --git a/tests/auto/corelib/text/qlocale/tst_qlocale.cpp b/tests/auto/corelib/text/qlocale/tst_qlocale.cpp index ed7b8febb51..9fe5353f378 100644 --- a/tests/auto/corelib/text/qlocale/tst_qlocale.cpp +++ b/tests/auto/corelib/text/qlocale/tst_qlocale.cpp @@ -85,6 +85,8 @@ private slots: void formatTimeZone(); void toDateTime_data(); void toDateTime(); + void roundtripDateTimeFormat_data(); + void roundtripDateTimeFormat(); void toDate_data(); void toDate(); void toTime_data(); @@ -2387,6 +2389,79 @@ void tst_QLocale::toDateTime() QCOMPARE(l.toDateTime(string, QLocale::ShortFormat), result); } +void tst_QLocale::roundtripDateTimeFormat_data() +{ + QTest::addColumn("locale"); + QTest::addColumn("when"); + QTest::addColumn("cal"); + QTest::addColumn("format"); + QTest::addColumn("baseYear"); + const QCalendar greg; + +#if QT_CONFIG(timezone) + qsizetype count = 0; + const QTimeZone westOz("Australia/Perth"); + if (westOz.isValid()) { + QTest::newRow("de_DE/LongFormat/2024-05-06T12:34/AWT") // QTBUG-130278 + << QLocale(QLocale::German, QLocale::Germany) + << QDateTime(QDate(2024, 5, 6, greg), QTime(12, 34), westOz) + << greg << QLocale::LongFormat << 2000; + ++count; + } + + const QTimeZone nepal("Asia/Katmandu"); + if (nepal.isValid()) { + // Triggers the region-format code-path: + QTest::newRow("en_US/LongFormat/2025-02-06T20:20/Katmandu") + << QLocale(QLocale::English, QLocale::UnitedStates) + << QDateTime(QDate(2025, 2, 6, greg), QTime(20, 20), nepal) + << greg << QLocale::LongFormat << 2000; + ++count; + } + + if (!count) + QSKIP("Missing zones for both test-cases"); +#else + QSKIP("The only test-case depends on feature timezone"); +#endif +} + +void tst_QLocale::roundtripDateTimeFormat() +{ + QFETCH(const QLocale, locale); + QFETCH(const QDateTime, when); + QFETCH(const QCalendar, cal); + QFETCH(const QLocale::FormatType, format); + QFETCH(const int, baseYear); + + const QString text = locale.toString(when, format, cal); + auto report = qScopeGuard([=]() { + qDebug() << "Went via:" << text; + qDebug() << "Used format:" << locale.dateTimeFormat(format); + QDateTime parsed = locale.toDateTime(text, format, cal, baseYear); + if (parsed.isValid()) { + switch (parsed.timeSpec()) { +#if QT_CONFIG(timezone) + case Qt::TimeZone: + qDebug() << "Used zone:" << parsed.timeZone().id(); + break; +#endif + case Qt::OffsetFromUTC: + qDebug() << "Used fixed UTC offset:" << parsed.offsetFromUtc(); + break; + case Qt::LocalTime: + qDebug("Used local time"); + break; + case Qt::UTC: + qDebug("Used plain UTC"); + break; + } + } + }); + QCOMPARE(locale.toDateTime(text, format, cal, baseYear), when); + report.dismiss(); +} + void tst_QLocale::toDate_data() { QTest::addColumn("locale"); diff --git a/tests/auto/corelib/time/qdatetime/tst_qdatetime.cpp b/tests/auto/corelib/time/qdatetime/tst_qdatetime.cpp index fc7b0c45412..236aa59e0af 100644 --- a/tests/auto/corelib/time/qdatetime/tst_qdatetime.cpp +++ b/tests/auto/corelib/time/qdatetime/tst_qdatetime.cpp @@ -1261,7 +1261,15 @@ void tst_QDateTime::toString_strformat() QCOMPARE(testDateTime.toString("yyyy-MM-dd hh:mm:ss t"), QString("2013-01-01 01:02:03 UTC")); QCOMPARE(testDateTime.toString("yyyy-MM-dd hh:mm:ss tt"), QString("2013-01-01 01:02:03 +0000")); QCOMPARE(testDateTime.toString("yyyy-MM-dd hh:mm:ss ttt"), QString("2013-01-01 01:02:03 +00:00")); - QCOMPARE(testDateTime.toString("yyyy-MM-dd hh:mm:ss tttt"), QString("2013-01-01 01:02:03 UTC")); + +#if QT_CONFIG(icu) && !defined(Q_STL_DINKUMWARE) + // The Dinkum (VxWorks) exception may just be because it has an old ICU. + // Hopefully other timezone backends shall eventually agree with this: + const QString longForm = u"2013-01-01 01:02:03 Coordinated Universal Time"_s; +#else + const QString longForm = u"2013-01-01 01:02:03 UTC"_s; +#endif // (Note: #if-ery is not allowed within a macro's parameter list.) + QCOMPARE(testDateTime.toString(u"yyyy-MM-dd hh:mm:ss tttt"_s), longForm); } #endif // datestring diff --git a/tests/auto/corelib/time/qtimezone/tst_qtimezone.cpp b/tests/auto/corelib/time/qtimezone/tst_qtimezone.cpp index f144838b163..91b9096a918 100644 --- a/tests/auto/corelib/time/qtimezone/tst_qtimezone.cpp +++ b/tests/auto/corelib/time/qtimezone/tst_qtimezone.cpp @@ -64,6 +64,8 @@ private Q_SLOTS: void winTest(); void localeSpecificDisplayName_data(); void localeSpecificDisplayName(); + void roundtripDisplayNames_data(); + void roundtripDisplayNames(); void stdCompatibility_data(); void stdCompatibility(); #endif // timezone backends @@ -1315,6 +1317,11 @@ void tst_QTimeZone::malformed() void tst_QTimeZone::utcTest() { +#if QT_CONFIG(icu) // || hopefully various other cases, eventually + const QString utcLongName = u"Coordinated Universal Time"_s; +#else + const QString utcLongName = u"UTC"_s; +#endif #ifdef QT_BUILD_INTERNAL // Test default UTC constructor QUtcTimeZonePrivate tzp; @@ -1322,7 +1329,7 @@ void tst_QTimeZone::utcTest() QCOMPARE(tzp.id(), QByteArray("UTC")); QCOMPARE(tzp.territory(), QLocale::AnyTerritory); QCOMPARE(tzp.abbreviation(0), QString("UTC")); - QCOMPARE(tzp.displayName(QTimeZone::StandardTime, QTimeZone::LongName, QLocale()), QString("UTC")); + QCOMPARE(tzp.displayName(QTimeZone::StandardTime, QTimeZone::LongName, QLocale()), utcLongName); QCOMPARE(tzp.offsetFromUtc(0), 0); QCOMPARE(tzp.standardTimeOffset(0), 0); QCOMPARE(tzp.daylightTimeOffset(0), 0); @@ -1337,7 +1344,7 @@ void tst_QTimeZone::utcTest() QCOMPARE(tz.id(), QByteArrayLiteral("UTC")); QCOMPARE(tz.territory(), QLocale::AnyTerritory); QCOMPARE(tz.abbreviation(now), QStringLiteral("UTC")); - QCOMPARE(tz.displayName(QTimeZone::StandardTime, QTimeZone::LongName, QLocale()), QStringLiteral("UTC")); + QCOMPARE(tz.displayName(QTimeZone::StandardTime, QTimeZone::LongName, QLocale()), utcLongName); QCOMPARE(tz.offsetFromUtc(now), 0); QCOMPARE(tz.standardTimeOffset(now), 0); QCOMPARE(tz.daylightTimeOffset(now), 0); @@ -1770,6 +1777,7 @@ void tst_QTimeZone::localeSpecificDisplayName_data() QTest::addColumn("locale"); QTest::addColumn("timeType"); QTest::addColumn("expectedName"); + QTest::addColumn("when"); QStringList names; QLocale locale; @@ -1786,10 +1794,12 @@ void tst_QTimeZone::localeSpecificDisplayName_data() qsizetype index = 0; QTest::newRow("Berlin, standard time") - << "Europe/Berlin"_ba << locale << QTimeZone::StandardTime << names.at(index++); + << "Europe/Berlin"_ba << locale << QTimeZone::StandardTime << names.at(index++) + << QDateTime(QDate(2024, 1, 1), QTime(12, 0)); QTest::newRow("Berlin, summer time") - << "Europe/Berlin"_ba << locale << QTimeZone::DaylightTime << names.at(index++); + << "Europe/Berlin"_ba << locale << QTimeZone::DaylightTime << names.at(index++) + << QDateTime(QDate(2024, 7, 1), QTime(12, 0)); } void tst_QTimeZone::localeSpecificDisplayName() @@ -1806,6 +1816,163 @@ void tst_QTimeZone::localeSpecificDisplayName() const QString localeName = zone.displayName(timeType, QTimeZone::LongName, locale); QCOMPARE(localeName, expectedName); +#ifdef QT_BUILD_INTERNAL + QFETCH(QDateTime, when); + // Check that round-trips: + auto match = QTimeZonePrivate::findLongNamePrefix(localeName, locale, when.toMSecsSinceEpoch()); + QCOMPARE(match.nameLength, localeName.size()); + auto report = qScopeGuard([=]() { + auto typeName = [](QTimeZone::TimeType type) { + return (type == QTimeZone::StandardTime ? "std" + : type == QTimeZone::GenericTime ? "gen" : "dst"); + }; + qDebug("Long name round-tripped %s (%s) to %s (%s) via %s", + zoneName.constData(), typeName(timeType), + match.ianaId.constData(), typeName(match.timeType), + localeName.toUtf8().constData()); + }); + // We may have found a different zone in the same metazone. + // Ideally prefer canonical, but the ICU-based version doesn't. + // At least check offsets match: + const QTimeZone actual(match.ianaId); + if (when.isValid() && actual.isValid()) + QCOMPARE(actual.offsetFromUtc(when), zone.offsetFromUtc(when)); + // GenericTime gets preferred and may be a synonym for StandardTime: + if (timeType != QTimeZone::StandardTime || match.timeType != QTimeZone::GenericTime) + QCOMPARE(match.timeType, timeType); + + // Let report happen when names don't match: + if (match.ianaId == zoneName) + report.dismiss(); +#endif +} + +void tst_QTimeZone::roundtripDisplayNames_data() +{ +#ifdef QT_BUILD_INTERNAL + QTest::addColumn("zone"); + QTest::addColumn("locale"); + QTest::addColumn("type"); + + constexpr QTimeZone::TimeType types[] = { + QTimeZone::GenericTime, QTimeZone::StandardTime, QTimeZone::DaylightTime + }; + const auto typeName = [](QTimeZone::TimeType type) { + switch (type) { + case QTimeZone::GenericTime: return "Gen"; + case QTimeZone::StandardTime: return "Std"; + case QTimeZone::DaylightTime: return "DST"; + } + Q_UNREACHABLE_RETURN("Unrecognised"); + }; + const QList allList = (QTimeZone::availableTimeZoneIds() << "Vulcan/ShiKahr"_ba); +#ifdef EXHAUSTIVE_ZONE_DISPLAY + const QList idList = allList; +#else + const QList idList = { + "Africa/Casablanca"_ba, "Africa/Lagos"_ba, "Africa/Tunis"_ba, + "America/Caracas"_ba, "America/Indiana/Tell_City"_ba, "America/Managua"_ba, + "Asia/Bangkok"_ba, "Asia/Colombo"_ba, "Asia/Tokyo"_ba, + "Atlantic/Bermuda"_ba, "Atlantic/Faroe"_ba, "Atlantic/Madeira"_ba, + "Australia/Broken_Hill"_ba, "Australia/NSW"_ba, "Australia/Tasmania"_ba, + "Brazil/Acre"_ba, "CST6CDT"_ba, "Canada/Atlantic"_ba, + "Chile/EasterIsland"_ba, "Etc/Greenwich"_ba, "Etc/Universal"_ba, + "Europe/Guernsey"_ba, "Europe/Kaliningrad"_ba, "Europe/Kyiv"_ba, + "Europe/Prague"_ba, "Europe/Vatican"_ba, + "Indian/Comoro"_ba, "Mexico/BajaSur"_ba, + "Pacific/Bougainville"_ba, "Pacific/Midway"_ba, "Pacific/Wallis"_ba, + "US/Aleutian"_ba, + "UTC"_ba, + // Those named overtly in tst_QDateTime - special cases first: + "UTC-02:00"_ba, "UTC+02:00"_ba, "UTC+12:00"_ba, + "Etc/GMT+3"_ba, "GMT-2"_ba, "GMT"_ba, + // ... then ordinary names in alphabetic order: + "America/New_York"_ba, "America/Sao_Paulo"_ba, "America/Vancouver"_ba, + "Asia/Kathmandu"_ba, "Asia/Singapore"_ba, + "Australia/Brisbane"_ba, "Australia/Eucla"_ba, "Australia/Sydney"_ba, + "Europe/Berlin"_ba, "Europe/Helsinki"_ba, "Europe/Rome"_ba, "Europe/Oslo"_ba, + "Pacific/Apia"_ba, "Pacific/Auckland"_ba, "Pacific/Kiritimati"_ba, + "Vulcan/ShiKahr"_ba // Invalid: also worth testing. + }; + // Some valid zones in that list may be absent from the platform's + // availableTimeZoneIds(), yet in fact work when used as it's asked to + // instantiate them (e.g. Etc/Universal on macOS). This can give them a + // displayName() that we fail to decode, without timezone_locale, due to + // only trying the availableTimeZoneIds() in findLongNamePrefix(). So we + // have to filter on membership of allList when creating rows. +#endif // Exhaustive + const QLocale fr(QLocale::French, QLocale::France); + const QLocale hi(QLocale::Hindi, QLocale::India); + for (const QByteArray &id : idList) { + if (id == "localtime"_ba || id == "posixrules"_ba || !allList.contains(id)) + continue; + QTimeZone zone = QTimeZone(id); + if (!zone.isValid()) + continue; + for (const auto type : types) { + QTest::addRow("%s@fr_FR/%s", id.constData(), typeName(type)) + << zone << fr << type; + QTest::addRow("%s@hi_IN/%s", id.constData(), typeName(type)) + << zone << hi << type; + } + } +#else + QSKIP("Test needs access to internal APIs"); +#endif +} + +void tst_QTimeZone::roundtripDisplayNames() +{ +#ifdef QT_BUILD_INTERNAL + QFETCH(const QTimeZone, zone); + QFETCH(const QLocale, locale); + QFETCH(const QTimeZone::TimeType, type); + static const QDateTime jan = QDateTime(QDate(2015, 1, 1), QTime(12, 0), QTimeZone::UTC); + static const QDateTime jul = QDateTime(QDate(2015, 7, 1), QTime(12, 0), QTimeZone::UTC); + const QDateTime dt = zone.isDaylightTime(jul) == (type == QTimeZone::DaylightTime) ? jul : jan; + + // Some zones exercise region format. + const QString name = zone.displayName(type, QTimeZone::LongName, locale); + if (!name.isEmpty()) { + const auto tran = QTimeZonePrivate::extractPrivate(zone)->data(type); + const qint64 when = tran.atMSecsSinceEpoch == QTimeZonePrivate::invalidMSecs() + ? dt.toMSecsSinceEpoch() : tran.atMSecsSinceEpoch; + const QString extended = name + "some spurious cruft"_L1; + auto match = + QTimeZonePrivate::findLongNamePrefix(extended, locale, when); + if (!match.nameLength) + match = QTimeZonePrivate::findLongNamePrefix(extended, locale); + auto report = qScopeGuard([=]() { + qDebug() << "At" << QDateTime::fromMSecsSinceEpoch(when, QTimeZone::UTC) + << "via" << name; + }); + QCOMPARE(match.nameLength, name.size()); + report.dismiss(); +#if 0 + if (match.ianaId != zone.id()) { + const QTimeZone found = QTimeZone(match.ianaId); + if (QTimeZonePrivate::extractPrivate(found)->offsetFromUtc(when) + != QTimeZonePrivate::extractPrivate(zone)->offsetFromUtc(when)) { + // For DST, some zones haven't done it in ages, so tran may be ancient. + // Meanwhile, match.ianaId is typically the canonical zone for a metazone. + // That, in turn, may not have been doing DST when zone was. + // So we can't rely on a match, but can report the mismatches. + qDebug() << "Long name" << name << "on" + << QTimeZonePrivate::extractPrivate(zone)->offsetFromUtc(when) + << "at" << QDateTime::fromMSecsSinceEpoch(when, QTimeZone::UTC) + << "got" << match.ianaId << "on" + << QTimeZonePrivate::extractPrivate(found)->offsetFromUtc(when); + // There are also some absurdly over-generic names, that lead to + // ambiguities, e.g. "heure : West" + } + } +#endif // Debug code + } else if (type != QTimeZone::DaylightTime) { /* Zones with no DST have no DST-name */ + qDebug("Empty display name"); + } +#else + Q_ASSERT(!"Should be skipped when building data table"); +#endif } #ifdef QT_BUILD_INTERNAL