Skip to content

Commit fa64920

Browse files
jkaliasclaude
andcommitted
Add failing tests for non-BMP text round-tripping (#25)
StringUtilities::ToUtf8/FromUtf8 use std::codecvt_utf8<wchar_t>, which converts UTF-8 to and from UCS-2 when wchar_t is 16 bits. Code points above U+FFFF therefore do not round-trip on Windows, while the same code is correct on Linux and macOS where wchar_t is 32 bits. These tests reproduce that: they are expected to fail on Windows and pass on Linux and macOS. That asymmetry is the signature of the defect - if the tests were to fail everywhere they would be measuring something else, and if they were to pass everywhere they would not reproduce the bug at all. Every wide literal uses universal-character escapes instead of literal non-ASCII source bytes. MSVC is not passed /utf-8, so it decodes such bytes in the system codepage; the pre-existing Greek literals in database_test.cc compare a mangled literal against the same mangled literal and so pass without testing anything. Expected UTF-8 is asserted as exact bytes, and the end-to-end test matches on hex(name) in SQL, so a conversion that is wrong in both directions cannot pass. Tests reach src/internal/string_utilities.h via a new include directory, so the conversion can be exercised directly and not only through Save/Fetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c35dc2a commit fa64920

3 files changed

Lines changed: 152 additions & 0 deletions

File tree

tests/CMakeLists.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ include(GoogleTest)
88
include_directories ("${PROJECT_SOURCE_DIR}/include")
99
include_directories ("./include")
1010

11+
# Internal headers (src/internal/...) so the UTF-8 conversion helpers can be tested directly
12+
# rather than only through the public wstring API
13+
include_directories ("${PROJECT_SOURCE_DIR}/src")
14+
1115
# Collect test sources into the variable TEST_SOURCES
1216
file (GLOB TEST_SOURCES
1317
"*.cpp"

tests/database_test.cc

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,3 +874,26 @@ TEST_F(DatabaseTest, RawSqlQueryForPersistedRecord) {
874874
EXPECT_EQ(52, fetched_persons[0].id);
875875
EXPECT_EQ(L"johnie", fetched_persons[0].first_name);
876876
}
877+
878+
TEST_F(DatabaseTest, TextOutsideTheBasicMultilingualPlaneSurvivesSaveAndFetch) {
879+
const auto db = Database::Instance();
880+
881+
// Code points above U+FFFF need a surrogate pair where wchar_t is 16 bits (Windows) and a
882+
// single code unit where it is 32 bits (Linux, macOS). The stored UTF-8 and the fetched
883+
// wstring must be identical on every platform. Written with universal-character escapes
884+
// so the test does not depend on how the compiler decodes this file's source bytes.
885+
const std::wstring name = L"\U0001F600";
886+
const std::wstring address = L"\U00010000\U0010FFFF";
887+
888+
db->Save(Company{name, 30, address, 50000.0, 1});
889+
890+
const auto fetched = db->Fetch<Company>(1);
891+
EXPECT_EQ(name, fetched.name);
892+
EXPECT_EQ(address, fetched.address);
893+
894+
// The comparisons above are self-consistent: a conversion wrong in both directions would
895+
// satisfy them. Match on the stored bytes instead - U+1F600 is F0 9F 98 80 in UTF-8 - so
896+
// the row only disappears if what SQLite actually holds is correct UTF-8.
897+
db->UnsafeSql("DELETE FROM Company WHERE hex(name) = 'F09F9880'");
898+
EXPECT_EQ(0, db->FetchAll<Company>().size());
899+
}

tests/utf8_test.cc

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// MIT License
2+
//
3+
// Copyright (c) 2026 Ioannis Kaliakatsos
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in all
13+
// copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// SOFTWARE.
22+
23+
#include <gtest/gtest.h>
24+
25+
#include <string>
26+
27+
#include "internal/string_utilities.h"
28+
29+
using namespace sqlite_reflection;
30+
31+
// Every wide literal in this file is written with universal-character escapes rather than
32+
// literal non-ASCII source bytes. MSVC is not passed /utf-8, so it decodes non-ASCII source
33+
// bytes in the system codepage; a test written with literal characters would compare a
34+
// mangled literal against the same mangled literal and pass while proving nothing. Escapes
35+
// are charset-independent, and the expected UTF-8 is asserted as exact bytes rather than by
36+
// round-tripping, so a conversion that is wrong in both directions cannot pass either.
37+
38+
namespace {
39+
// A code point is encoded as a surrogate pair in a 16-bit wchar_t (Windows) and as a single
40+
// code unit in a 32-bit wchar_t (Linux, macOS). The expected UTF-8 is identical on both.
41+
const char* const kEmojiUtf8 = "\xF0\x9F\x98\x80"; // U+1F600 GRINNING FACE
42+
const char* const kFirstSupplementaryUtf8 = "\xF0\x90\x80\x80"; // U+10000, first non-BMP
43+
const char* const kLastCodePointUtf8 = "\xF4\x8F\xBF\xBF"; // U+10FFFF, highest valid
44+
const char* const kLastBmpUtf8 = "\xEF\xBF\xBF"; // U+FFFF, last BMP code point
45+
46+
std::wstring FromUtf8(const std::string& utf8) {
47+
return StringUtilities::FromUtf8(utf8.data(), utf8.size());
48+
}
49+
} // namespace
50+
51+
TEST(Utf8Test, EncodesAsciiToExactBytes) {
52+
EXPECT_EQ(std::string("Appleseed"), StringUtilities::ToUtf8(L"Appleseed"));
53+
}
54+
55+
TEST(Utf8Test, EncodesEmptyString) {
56+
EXPECT_EQ(std::string(), StringUtilities::ToUtf8(std::wstring()));
57+
}
58+
59+
TEST(Utf8Test, EncodesBmpToExactBytes) {
60+
// U+03C0 GREEK SMALL LETTER PI, U+03B1 GREEK SMALL LETTER ALPHA: two-byte sequences.
61+
EXPECT_EQ(std::string("\xCF\x80\xCE\xB1"), StringUtilities::ToUtf8(L"\u03C0\u03B1"));
62+
}
63+
64+
TEST(Utf8Test, EncodesLastBmpCodePointToExactBytes) {
65+
EXPECT_EQ(std::string(kLastBmpUtf8), StringUtilities::ToUtf8(L"\uFFFF"));
66+
}
67+
68+
TEST(Utf8Test, EncodesFirstSupplementaryCodePointToExactBytes) {
69+
// U+10000 is the low side of the surrogate boundary: the first code point that needs a
70+
// surrogate pair in UTF-16 and a four-byte UTF-8 sequence.
71+
EXPECT_EQ(std::string(kFirstSupplementaryUtf8), StringUtilities::ToUtf8(L"\U00010000"));
72+
}
73+
74+
TEST(Utf8Test, EncodesEmojiToExactBytes) {
75+
EXPECT_EQ(std::string(kEmojiUtf8), StringUtilities::ToUtf8(L"\U0001F600"));
76+
}
77+
78+
TEST(Utf8Test, EncodesLastValidCodePointToExactBytes) {
79+
EXPECT_EQ(std::string(kLastCodePointUtf8), StringUtilities::ToUtf8(L"\U0010FFFF"));
80+
}
81+
82+
TEST(Utf8Test, EncodesSupplementaryCodePointMixedWithAscii) {
83+
EXPECT_EQ(std::string("a") + kEmojiUtf8 + "b", StringUtilities::ToUtf8(L"a\U0001F600b"));
84+
}
85+
86+
TEST(Utf8Test, DecodesAscii) {
87+
EXPECT_EQ(std::wstring(L"Appleseed"), FromUtf8("Appleseed"));
88+
}
89+
90+
TEST(Utf8Test, DecodesEmptyString) {
91+
EXPECT_EQ(std::wstring(), FromUtf8(std::string()));
92+
}
93+
94+
TEST(Utf8Test, DecodesBmp) {
95+
EXPECT_EQ(std::wstring(L"\u03C0\u03B1"), FromUtf8("\xCF\x80\xCE\xB1"));
96+
}
97+
98+
TEST(Utf8Test, DecodesLastBmpCodePoint) {
99+
EXPECT_EQ(std::wstring(L"\uFFFF"), FromUtf8(kLastBmpUtf8));
100+
}
101+
102+
TEST(Utf8Test, DecodesFirstSupplementaryCodePoint) {
103+
EXPECT_EQ(std::wstring(L"\U00010000"), FromUtf8(kFirstSupplementaryUtf8));
104+
}
105+
106+
TEST(Utf8Test, DecodesEmoji) {
107+
EXPECT_EQ(std::wstring(L"\U0001F600"), FromUtf8(kEmojiUtf8));
108+
}
109+
110+
TEST(Utf8Test, DecodesLastValidCodePoint) {
111+
EXPECT_EQ(std::wstring(L"\U0010FFFF"), FromUtf8(kLastCodePointUtf8));
112+
}
113+
114+
TEST(Utf8Test, RoundTripsSupplementaryCodePoints) {
115+
const std::wstring original = L"\U0001F600\U00010000\U0010FFFF";
116+
EXPECT_EQ(original, FromUtf8(StringUtilities::ToUtf8(original)));
117+
}
118+
119+
TEST(Utf8Test, SupplementaryCodePointSurvivesLengthAndContent) {
120+
// Guards against a conversion that silently drops or truncates the pair rather than
121+
// producing a wrong-but-present result.
122+
const std::wstring decoded = FromUtf8(kEmojiUtf8);
123+
EXPECT_FALSE(decoded.empty());
124+
EXPECT_EQ(std::wstring(L"\U0001F600"), decoded);
125+
}

0 commit comments

Comments
 (0)