feat: add serializer support for std::chrono::sys_seconds, std::u8string, std::filesystem::path (#8364)

* chore: rename display-mode-tests.cc as converter-tests.cc

* feat: support std::chrono::sys_seconds in serializers

* feat: support std::u8string, std::filesystem::path in serializer

* build: address review feedback

* chore: remove unnecessary helper function

* Revert "chore: remove unnecessary helper function"

This reverts commit 69ea907836.

std::to_chars() unavailable on macOS < 13.3

We can remove this hack if/when we drop support for macOS < 13.3
This commit is contained in:
Charles Kerr
2026-02-09 21:04:18 -06:00
committed by GitHub
parent bea234b8ed
commit df16feaa36
9 changed files with 415 additions and 5 deletions

View File

@@ -4,7 +4,7 @@ add_executable(libtransmission-app-test)
target_sources(libtransmission-app-test
PRIVATE
display-mode-tests.cc
converter-tests.cc
test-fixtures.h)
set_property(

View File

@@ -4,6 +4,9 @@
// License text can be found in the licenses/ folder.
#include <array>
#include <chrono>
#include <ctime>
#include <regex>
#include <string_view>
#include <gtest/gtest.h>
@@ -15,12 +18,36 @@
#include "test-fixtures.h"
using DisplayModeTest = TransmissionTest;
using ConverterTest = TransmissionTest;
using namespace std::literals;
using tr::serializer::Converters;
namespace
{
constexpr int64_t days_from_civil(int year, unsigned month, unsigned day)
{
year -= month <= 2;
auto const era = (year >= 0 ? year : year - 399) / 400;
auto const yoe = static_cast<unsigned>(year - era * 400);
auto const doy = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1;
auto const doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
return static_cast<int64_t>(era) * 146097 + static_cast<int64_t>(doe) - 719468;
}
constexpr std::chrono::sys_seconds make_sys_seconds(int year, int month, int day, int hour, int minute, int second)
{
return std::chrono::sys_seconds{ std::chrono::seconds{ days_from_civil(year, month, day) * 86400 } } +
std::chrono::hours{ hour } + std::chrono::minutes{ minute } + std::chrono::seconds{ second };
}
void expect_sys_seconds_eq(std::optional<std::chrono::sys_seconds> const& actual, std::chrono::sys_seconds expected)
{
EXPECT_TRUE(actual.has_value());
if (actual.has_value())
{
EXPECT_EQ(actual->time_since_epoch().count(), expected.time_since_epoch().count());
}
}
template<typename T, size_t N>
void testModeRoundtrip(std::array<std::pair<std::string_view, T>, N> const& items)
@@ -39,7 +66,7 @@ void testModeRoundtrip(std::array<std::pair<std::string_view, T>, N> const& item
} // namespace
TEST_F(DisplayModeTest, showModeStringsRoundtrip)
TEST_F(ConverterTest, showModeStringsRoundtrip)
{
auto constexpr Items = std::array<std::pair<std::string_view, tr::app::ShowMode>, tr::app::ShowModeCount>{ {
{ "show_active", tr::app::ShowMode::ShowActive },
@@ -55,7 +82,7 @@ TEST_F(DisplayModeTest, showModeStringsRoundtrip)
testModeRoundtrip(Items);
}
TEST_F(DisplayModeTest, sortModeStringsRoundtrip)
TEST_F(ConverterTest, sortModeStringsRoundtrip)
{
auto constexpr Items = std::array<std::pair<std::string_view, tr::app::SortMode>, tr::app::SortModeCount>{ {
{ "sort_by_activity", tr::app::SortMode::SortByActivity },
@@ -73,7 +100,7 @@ TEST_F(DisplayModeTest, sortModeStringsRoundtrip)
testModeRoundtrip(Items);
}
TEST_F(DisplayModeTest, statsModeStringsRoundtrip)
TEST_F(ConverterTest, statsModeStringsRoundtrip)
{
auto constexpr Items = std::array<std::pair<std::string_view, tr::app::StatsMode>, tr::app::StatsModeCount>{ {
{ "total_ratio", tr::app::StatsMode::TotalRatio },
@@ -84,3 +111,33 @@ TEST_F(DisplayModeTest, statsModeStringsRoundtrip)
testModeRoundtrip(Items);
}
TEST_F(ConverterTest, sysSecondsRoundtrip)
{
using namespace std::chrono;
using namespace tr::serializer;
auto constexpr Expected = make_sys_seconds(2024, 2, 3, 4, 5, 6);
auto const var = Converters::serialize(Expected);
EXPECT_TRUE(var.holds_alternative<std::string_view>());
auto const serialized = var.value_if<std::string_view>().value_or(""sv);
static auto const re = std::regex(R"(^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{4})$)");
EXPECT_TRUE(std::regex_match(std::string{ serialized }, re));
auto actual = to_value<std::chrono::sys_seconds>(tr_variant{ serialized });
expect_sys_seconds_eq(actual, Expected);
actual = to_value<std::chrono::sys_seconds>(tr_variant{ "2024-02-03T04:05:06Z"sv });
expect_sys_seconds_eq(actual, Expected);
actual = to_value<std::chrono::sys_seconds>(tr_variant{ "2024-02-03T04:05:06+00:00"sv });
expect_sys_seconds_eq(actual, Expected);
actual = to_value<std::chrono::sys_seconds>(tr_variant{ "2024-02-03T04:05:06+02:30"sv });
expect_sys_seconds_eq(actual, Expected - (hours{ 2 } + minutes{ 30 }));
auto constexpr Epoch = int64_t{ 1700000000 };
auto const epoch_seconds = time_point_cast<seconds>(system_clock::from_time_t(static_cast<time_t>(Epoch)));
actual = to_value<std::chrono::sys_seconds>(tr_variant{ Epoch });
expect_sys_seconds_eq(actual, epoch_seconds);
}

View File

@@ -5,6 +5,7 @@
#include <climits>
#include <cstdint>
#include <filesystem>
#include <list>
#include <mutex>
#include <optional>
@@ -13,6 +14,7 @@
#include <vector>
#include <libtransmission/net.h>
#include <libtransmission/log.h>
#include <libtransmission/quark.h>
#include <libtransmission/serializer.h>
#include <libtransmission/variant.h>
@@ -26,6 +28,11 @@ using tr::serializer::Converters;
namespace
{
[[nodiscard]] std::string toString(std::u8string const& value)
{
return { reinterpret_cast<char const*>(std::data(value)), std::size(value) };
}
struct Rect
{
int x = 0;
@@ -147,6 +154,61 @@ TEST_F(SerializerTest, usesBuiltins)
}
}
TEST_F(SerializerTest, usesU8String)
{
auto const expected = std::u8string{ u8"hello" };
auto const var = Converters::serialize(expected);
EXPECT_TRUE(var.holds_alternative<std::string_view>());
EXPECT_EQ(var.value_if<std::string_view>().value_or(""sv), "hello"sv);
auto actual = std::u8string{};
EXPECT_TRUE(Converters::deserialize(var, &actual));
EXPECT_EQ(toString(actual), toString(expected));
}
TEST_F(SerializerTest, usesFsPath)
{
auto const expected = std::filesystem::path{ std::u8string{ u8"foo/βar" } };
auto const var = Converters::serialize(expected);
EXPECT_TRUE(var.holds_alternative<std::string_view>());
EXPECT_EQ(var.value_if<std::string_view>().value_or(""sv), "foo/βar"sv);
auto actual = std::filesystem::path{};
EXPECT_TRUE(Converters::deserialize(var, &actual));
EXPECT_EQ(toString(actual.u8string()), toString(expected.u8string()));
}
TEST_F(SerializerTest, u8StringWarnsOnInvalidUtf8)
{
auto const bad = std::string{ static_cast<char>(0xC3), static_cast<char>(0x28) };
auto const var = tr_variant{ std::string_view{ bad } };
auto const old_level = tr_logGetLevel();
tr_logSetLevel(TR_LOG_WARN);
tr_logSetQueueEnabled(true);
tr_logFreeQueue(tr_logGetQueue());
auto actual = std::u8string{};
EXPECT_TRUE(Converters::deserialize(var, &actual));
auto* const msgs = tr_logGetQueue();
auto warned = false;
for (auto* msg = msgs; msg != nullptr; msg = msg->next)
{
if (msg->level == TR_LOG_WARN && msg->message.find("contains invalid UTF-8") != std::string::npos)
{
warned = true;
break;
}
}
tr_logFreeQueue(msgs);
tr_logSetQueueEnabled(false);
tr_logSetLevel(old_level);
EXPECT_TRUE(warned);
}
TEST_F(SerializerTest, usesCustomTypes)
{
registerRectConverter();

View File

@@ -204,6 +204,18 @@ TEST_F(UtilsTest, strvReplaceInvalid)
EXPECT_EQ(out, tr_strv_replace_invalid(out));
}
TEST_F(UtilsTest, strvFindInvalidUtf8)
{
EXPECT_EQ(std::string_view::npos, tr_strv_find_invalid_utf8("hello"sv));
EXPECT_EQ(std::string_view::npos, tr_strv_find_invalid_utf8("Трудно быть Богом"sv));
auto const invalid = std::string{ static_cast<char>(0xC3), static_cast<char>(0x28) };
EXPECT_EQ(0U, tr_strv_find_invalid_utf8(invalid));
auto const mixed = std::string{ "ok " } + invalid + " end";
EXPECT_EQ(3U, tr_strv_find_invalid_utf8(mixed));
}
TEST_F(UtilsTest, strvConvertUtf8Fuzz)
{
auto buf = std::vector<char>{};

View File

@@ -45,6 +45,9 @@ Q_DECLARE_METATYPE(Style)
.arg(dir)
};
}
abort();
return {};
}
class SessionTest