From 6dbfc29c7e96a0b769b7cae3400777deaa671277 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Mon, 25 May 2026 17:51:53 -0500 Subject: [PATCH 1/3] test: Migrate unit tests to gtest, integrate with CI (#2144) --- .github/workflows/ci.yml | 28 + platformio.ini | 1 + scripts/register_unit_tests_target.py | 28 + test/CMakeLists.txt | 44 ++ test/README | 20 +- test/differential_rounding/CMakeLists.txt | 17 + .../DifferentialRoundingTest.cpp | 302 +++----- test/hyphenation_eval/CMakeLists.txt | 24 + .../HyphenationEvaluationTest.cpp | 272 ++----- test/release_json_parser/CMakeLists.txt | 16 + .../ReleaseJsonParserTest.cpp | 547 +++++---------- test/run_differential_rounding_test.sh | 30 - test/run_hyphenation_eval.sh | 32 - test/run_release_json_parser_test.sh | 29 - test/run_streaming_json_parser_test.sh | 28 - test/streaming_json_parser/CMakeLists.txt | 15 + .../StreamingJsonParserTest.cpp | 661 ++++++------------ 17 files changed, 711 insertions(+), 1383 deletions(-) create mode 100644 scripts/register_unit_tests_target.py create mode 100644 test/CMakeLists.txt create mode 100644 test/differential_rounding/CMakeLists.txt create mode 100644 test/hyphenation_eval/CMakeLists.txt create mode 100644 test/release_json_parser/CMakeLists.txt delete mode 100755 test/run_differential_rounding_test.sh delete mode 100755 test/run_hyphenation_eval.sh delete mode 100755 test/run_release_json_parser_test.sh delete mode 100755 test/run_streaming_json_parser_test.sh create mode 100644 test/streaming_json_parser/CMakeLists.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f933013c40..39ece4596e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,33 @@ jobs: path: .pio/build/default/firmware.bin if-no-files-found: error + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install build tools + run: | + sudo apt-get update + sudo apt-get install -y cmake ninja-build + + - name: Cache googletest source + uses: actions/cache@v4 + with: + path: build/test/_deps/googletest-src + key: ${{ runner.os }}-googletest-${{ hashFiles('test/CMakeLists.txt') }} + + - name: Configure + run: cmake -S test -B build/test -G Ninja -DCMAKE_BUILD_TYPE=Release + + - name: Build + run: cmake --build build/test + + - name: Run tests + run: ctest --test-dir build/test --output-on-failure -j + # This job is used as the PR required actions check, allows for changes to other steps in the future without breaking # PR requirements. test-status: @@ -110,6 +137,7 @@ jobs: - build - clang-format - cppcheck + - unit-tests if: always() runs-on: ubuntu-latest steps: diff --git a/platformio.ini b/platformio.ini index 213b5f4973..80d98bd097 100644 --- a/platformio.ini +++ b/platformio.ini @@ -53,6 +53,7 @@ extra_scripts = pre:scripts/gen_i18n.py pre:scripts/git_branch.py pre:scripts/patch_jpegdec.py + post:scripts/register_unit_tests_target.py ; Libraries lib_deps = diff --git a/scripts/register_unit_tests_target.py b/scripts/register_unit_tests_target.py new file mode 100644 index 0000000000..e62bf76c78 --- /dev/null +++ b/scripts/register_unit_tests_target.py @@ -0,0 +1,28 @@ +""" +PlatformIO post-load script: register a `unit-tests` custom target so +`pio run -t unit-tests` builds and runs the host gtest suites under test/. + +The target shells out to CMake/CTest; the gtest framework is fetched and +the suites are built outside the PlatformIO/ESP-IDF toolchain (this is a +host build, not a firmware build). +""" + +import os + +Import("env") # noqa: F821 -- provided by PlatformIO at script load + +PROJECT_DIR = env["PROJECT_DIR"] # noqa: F821 +BUILD_DIR = os.path.join(PROJECT_DIR, "build", "test") +TEST_SRC_DIR = os.path.join(PROJECT_DIR, "test") + +env.AddCustomTarget( # noqa: F821 + name="unit-tests", + dependencies=None, + actions=[ + f'cmake -S "{TEST_SRC_DIR}" -B "{BUILD_DIR}" -DCMAKE_BUILD_TYPE=Release', + f'cmake --build "{BUILD_DIR}"', + f'ctest --test-dir "{BUILD_DIR}" --output-on-failure -j', + ], + title="Host unit tests", + description="Build and run gtest suites in test/ via CMake/CTest", +) diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 0000000000..31ce26bfb1 --- /dev/null +++ b/test/CMakeLists.txt @@ -0,0 +1,44 @@ +cmake_minimum_required(VERSION 3.16) +project(crosspoint_reader_tests CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +include(FetchContent) + +FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.17.0 +) + +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +set(INSTALL_GTEST OFF CACHE BOOL "" FORCE) +set(BUILD_GMOCK OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googletest) + +set(REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/..") + +add_library(crosspoint_test_common INTERFACE) +target_include_directories(crosspoint_test_common INTERFACE + ${REPO_ROOT} + ${REPO_ROOT}/lib +) +target_compile_options(crosspoint_test_common INTERFACE + -Wall + -Wextra + -pedantic +) + +enable_testing() +include(GoogleTest) + +add_subdirectory(streaming_json_parser) +add_subdirectory(release_json_parser) +add_subdirectory(differential_rounding) +add_subdirectory(hyphenation_eval) diff --git a/test/README b/test/README index 9b1e87bc67..2317a3aa9b 100644 --- a/test/README +++ b/test/README @@ -1,11 +1,15 @@ +Host-side gtest unit tests for crosspoint-reader. -This directory is intended for PlatformIO Test Runner and project tests. +Build and run: -Unit Testing is a software testing method by which individual units of -source code, sets of one or more MCU program modules together with associated -control data, usage procedures, and operating procedures, are tested to -determine whether they are fit for use. Unit testing finds problems early -in the development cycle. + cmake -S test -B build/test + cmake --build build/test + ctest --test-dir build/test --output-on-failure -j -More information about PlatformIO Unit Testing: -- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html +Run a single suite directly: + + cmake --build build/test --target StreamingJsonParserTest + build/test/streaming_json_parser/StreamingJsonParserTest --gtest_filter='*' + +Google Test is fetched via CMake FetchContent on first configure; the pinned +version lives in test/CMakeLists.txt. diff --git a/test/differential_rounding/CMakeLists.txt b/test/differential_rounding/CMakeLists.txt new file mode 100644 index 0000000000..b958f6ebfd --- /dev/null +++ b/test/differential_rounding/CMakeLists.txt @@ -0,0 +1,17 @@ +add_executable(DifferentialRoundingTest + DifferentialRoundingTest.cpp + ${REPO_ROOT}/lib/EpdFont/EpdFont.cpp + ${REPO_ROOT}/lib/Utf8/Utf8.cpp +) + +target_include_directories(DifferentialRoundingTest PRIVATE + ${REPO_ROOT}/lib/EpdFont + ${REPO_ROOT}/lib/Utf8 +) + +target_link_libraries(DifferentialRoundingTest PRIVATE + crosspoint_test_common + GTest::gtest_main +) + +gtest_discover_tests(DifferentialRoundingTest) diff --git a/test/differential_rounding/DifferentialRoundingTest.cpp b/test/differential_rounding/DifferentialRoundingTest.cpp index fd45483995..9efed6a84c 100644 --- a/test/differential_rounding/DifferentialRoundingTest.cpp +++ b/test/differential_rounding/DifferentialRoundingTest.cpp @@ -1,34 +1,11 @@ -#include +#include + #include -#include #include #include "lib/EpdFont/EpdFont.h" #include "lib/EpdFont/EpdFontData.h" -static int testsPassed = 0; -static int testsFailed = 0; - -#define ASSERT_EQ(a, b) \ - do { \ - if ((a) != (b)) { \ - fprintf(stderr, " FAIL: %s:%d: %s == %d, expected %d\n", __FILE__, __LINE__, #a, (a), (b)); \ - testsFailed++; \ - return; \ - } \ - } while (0) - -#define ASSERT_TRUE(cond) \ - do { \ - if (!(cond)) { \ - fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, #cond); \ - testsFailed++; \ - return; \ - } \ - } while (0) - -#define PASS() testsPassed++ - // ============================================================================ // Synthetic test font // @@ -43,8 +20,10 @@ static int testsFailed = 0; // o->a: -2 (-0.125px) o->o: -3 (-0.1875px) // ============================================================================ +namespace { + // clang-format off -static const EpdGlyph kGlyphs[] = { +const EpdGlyph kGlyphs[] = { // idx width height advanceX left top dataLength dataOffset /* 0 'T' */ { 8, 12, 137, 0, 12, 0, 0 }, /* 1 'a' */ { 7, 8, 130, 0, 8, 0, 0 }, @@ -52,28 +31,28 @@ static const EpdGlyph kGlyphs[] = { /* 3 'x' */ { 7, 8, 136, 0, 8, 0, 0 }, }; -static const EpdUnicodeInterval kIntervals[] = { +const EpdUnicodeInterval kIntervals[] = { { 0x54, 0x54, 0 }, // 'T' -> glyph[0] { 0x61, 0x61, 1 }, // 'a' -> glyph[1] { 0x6F, 0x6F, 2 }, // 'o' -> glyph[2] { 0x78, 0x78, 3 }, // 'x' -> glyph[3] }; -static const EpdKernClassEntry kKernLeft[] = { +const EpdKernClassEntry kKernLeft[] = { { 0x54, 1 }, // 'T' -> left class 1 { 0x6F, 2 }, // 'o' -> left class 2 }; -static const EpdKernClassEntry kKernRight[] = { +const EpdKernClassEntry kKernRight[] = { { 0x61, 1 }, // 'a' -> right class 1 { 0x6F, 2 }, // 'o' -> right class 2 }; // Flat matrix: leftClassCount(2) x rightClassCount(2), 4.4 fixed-point // [L1,R1]=kern(T,a) [L1,R2]=kern(T,o) [L2,R1]=kern(o,a) [L2,R2]=kern(o,o) -static const int8_t kKernMatrix[] = { -5, -7, -2, -3 }; +const int8_t kKernMatrix[] = { -5, -7, -2, -3 }; -static const EpdFontData kTestFontData = { +const EpdFontData kTestFontData = { .bitmap = nullptr, .glyph = kGlyphs, .intervals = kIntervals, @@ -94,62 +73,63 @@ static const EpdFontData kTestFontData = { .kernRightClassCount = 2, .ligaturePairs = nullptr, .ligaturePairCount = 0, + .glyphMissHandler = nullptr, + .glyphMissCtx = nullptr, }; // clang-format on -static EpdFont testFont(&kTestFontData); +EpdFont& testFont() { + static EpdFont font(&kTestFontData); + return font; +} -// Helper: return width from getTextDimensions -static int textWidth(const char* str) { +int textWidth(const char* str) { int w = 0, h = 0; - testFont.getTextDimensions(str, &w, &h); + testFont().getTextDimensions(str, &w, &h); return w; } -static int textHeight(const char* str) { +int textHeight(const char* str) { int w = 0, h = 0; - testFont.getTextDimensions(str, &w, &h); + testFont().getTextDimensions(str, &w, &h); return h; } -// ============================================================================ -// Part 1: Pure fp4 math tests -// ============================================================================ - // Simulate the old absolute-snap gap for comparison -static int absoluteGap(int32_t startFP, int32_t advanceFP, int32_t kernFP) { +int absoluteGap(int32_t startFP, int32_t advanceFP, int32_t kernFP) { int32_t nextFP = startFP + advanceFP + kernFP; return fp4::toPixel(nextFP) - fp4::toPixel(startFP); } -void testFp4Basics() { - printf("testFp4Basics...\n"); +} // namespace + +// ============================================================================ +// Part 1: Pure fp4 math tests +// ============================================================================ +TEST(Fp4Math, RoundTripIntegerPixels) { for (int px = 0; px < 500; px++) { - ASSERT_EQ(fp4::toPixel(fp4::fromPixel(px)), px); + EXPECT_EQ(fp4::toPixel(fp4::fromPixel(px)), px) << "px=" << px; } - - ASSERT_EQ(fp4::toPixel(0), 0); - ASSERT_EQ(fp4::toPixel(7), 0); // 0.4375 -> 0 - ASSERT_EQ(fp4::toPixel(8), 1); // 0.5 -> 1 (round half up) - ASSERT_EQ(fp4::toPixel(15), 1); // 0.9375 -> 1 - ASSERT_EQ(fp4::toPixel(16), 1); // 1.0 -> 1 - ASSERT_EQ(fp4::toPixel(24), 2); // 1.5 -> 2 - ASSERT_EQ(fp4::toPixel(-8), 0); // -0.5 -> 0 - ASSERT_EQ(fp4::toPixel(-9), -1); // -0.5625 -> -1 - ASSERT_EQ(fp4::toPixel(-16), -1); - - ASSERT_EQ(fp4::toPixel(137 + (-9)), 8); // 128 = 8.0 exact - ASSERT_EQ(fp4::toPixel(137 + (-5)), 8); // 132 = 8.25 - ASSERT_EQ(fp4::toPixel(137 + (-1)), 9); // 136 = 8.5 (half rounds up) - - printf(" All fp4 basics passed\n"); - PASS(); } -void testOldApproachInconsistency() { - printf("testOldApproachInconsistency...\n"); +TEST(Fp4Math, RoundingBoundaries) { + EXPECT_EQ(fp4::toPixel(0), 0); + EXPECT_EQ(fp4::toPixel(7), 0); // 0.4375 -> 0 + EXPECT_EQ(fp4::toPixel(8), 1); // 0.5 -> 1 (round half up) + EXPECT_EQ(fp4::toPixel(15), 1); // 0.9375 -> 1 + EXPECT_EQ(fp4::toPixel(16), 1); // 1.0 -> 1 + EXPECT_EQ(fp4::toPixel(24), 2); // 1.5 -> 2 + EXPECT_EQ(fp4::toPixel(-8), 0); // -0.5 -> 0 + EXPECT_EQ(fp4::toPixel(-9), -1); // -0.5625 -> -1 + EXPECT_EQ(fp4::toPixel(-16), -1); + + EXPECT_EQ(fp4::toPixel(137 + (-9)), 8); // 128 = 8.0 exact + EXPECT_EQ(fp4::toPixel(137 + (-5)), 8); // 132 = 8.25 + EXPECT_EQ(fp4::toPixel(137 + (-1)), 9); // 136 = 8.5 (half rounds up) +} +TEST(Fp4Math, OldApproachInconsistency) { // 'oo' pair: advance=145 (9.0625px), kern=-3 (-0.1875px), combined=142 (8.875px) const int32_t advance = 145; const int32_t kern = -3; @@ -164,76 +144,51 @@ void testOldApproachInconsistency() { } } - ASSERT_TRUE(maxGap - minGap >= 1); - printf(" Old absolute gap range: [%d, %d] -- varies by %d px\n", minGap, maxGap, maxGap - minGap); - - int diffStep = fp4::toPixel(advance + kern); - printf(" Differential step: always %d px\n", diffStep); - PASS(); + // Absolute snap produces inconsistent gaps depending on subpixel phase. + EXPECT_GE(maxGap - minGap, 1); } -void testExhaustiveKernRange() { - printf("testExhaustiveKernRange...\n"); - +TEST(Fp4Math, ExhaustiveKernRange) { const int32_t baseAdvance = 128; - int checked = 0; for (int advFrac = 0; advFrac < 16; advFrac++) { int32_t advance = baseAdvance + advFrac; for (int kern = -128; kern <= 127; kern++) { int step = fp4::toPixel(advance + static_cast(kern)); float idealPx = fp4::toFloat(advance + kern); - if (std::abs(step - idealPx) >= 1.0f) { - fprintf(stderr, " FAIL: advance=%d, kern=%d, step=%d, ideal=%.4f\n", advance, kern, step, idealPx); - testsFailed++; - return; - } - checked++; + EXPECT_LT(std::abs(step - idealPx), 1.0f) << "advance=" << advance << " kern=" << kern; } } - - printf(" Checked %d (advance, kern) combinations -- all within 1px of ideal\n", checked); - PASS(); } // ============================================================================ // Part 2: Integration tests using real EpdFont::getTextDimensions // ============================================================================ -void testKernLookup() { - printf("testKernLookup...\n"); - - ASSERT_EQ(testFont.getKerning('T', 'a'), -5); - ASSERT_EQ(testFont.getKerning('T', 'o'), -7); - ASSERT_EQ(testFont.getKerning('o', 'a'), -2); - ASSERT_EQ(testFont.getKerning('o', 'o'), -3); - ASSERT_EQ(testFont.getKerning('a', 'o'), 0); // 'a' has no left class - ASSERT_EQ(testFont.getKerning('x', 'o'), 0); // 'x' has no left class - ASSERT_EQ(testFont.getKerning('T', 'x'), 0); // 'x' has no right class - ASSERT_EQ(testFont.getKerning('T', 'T'), 0); // 'T' has no right class - - printf(" All kern lookups correct\n"); - PASS(); +TEST(EpdFont, KernLookup) { + EXPECT_EQ(testFont().getKerning('T', 'a'), -5); + EXPECT_EQ(testFont().getKerning('T', 'o'), -7); + EXPECT_EQ(testFont().getKerning('o', 'a'), -2); + EXPECT_EQ(testFont().getKerning('o', 'o'), -3); + EXPECT_EQ(testFont().getKerning('a', 'o'), 0); // 'a' has no left class + EXPECT_EQ(testFont().getKerning('x', 'o'), 0); // 'x' has no left class + EXPECT_EQ(testFont().getKerning('T', 'x'), 0); // 'x' has no right class + EXPECT_EQ(testFont().getKerning('T', 'T'), 0); // 'T' has no right class } -void testGlyphLookup() { - printf("testGlyphLookup...\n"); - - ASSERT_TRUE(testFont.getGlyph('T') != nullptr); - ASSERT_TRUE(testFont.getGlyph('a') != nullptr); - ASSERT_TRUE(testFont.getGlyph('o') != nullptr); - ASSERT_TRUE(testFont.getGlyph('x') != nullptr); - ASSERT_EQ(testFont.getGlyph('T')->advanceX, 137); - ASSERT_EQ(testFont.getGlyph('a')->advanceX, 130); - ASSERT_EQ(testFont.getGlyph('o')->advanceX, 145); - ASSERT_EQ(testFont.getGlyph('x')->advanceX, 136); +TEST(EpdFont, GlyphLookup) { + ASSERT_NE(testFont().getGlyph('T'), nullptr); + ASSERT_NE(testFont().getGlyph('a'), nullptr); + ASSERT_NE(testFont().getGlyph('o'), nullptr); + ASSERT_NE(testFont().getGlyph('x'), nullptr); + EXPECT_EQ(testFont().getGlyph('T')->advanceX, 137); + EXPECT_EQ(testFont().getGlyph('a')->advanceX, 130); + EXPECT_EQ(testFont().getGlyph('o')->advanceX, 145); + EXPECT_EQ(testFont().getGlyph('x')->advanceX, 136); // No U+FFFD in font, so unknown codepoints return nullptr - ASSERT_TRUE(testFont.getGlyph('Z') == nullptr); - ASSERT_TRUE(testFont.getGlyph('b') == nullptr); - - printf(" All glyph lookups correct\n"); - PASS(); + EXPECT_EQ(testFont().getGlyph('Z'), nullptr); + EXPECT_EQ(testFont().getGlyph('b'), nullptr); } // Known-value regression tests. Expected widths are computed by hand using @@ -245,45 +200,32 @@ void testGlyphLookup() { // // Differential step from glyph A to glyph B: // step = fp4::toPixel(advanceA + kern(A,B)) -void testKnownWidths() { - printf("testKnownWidths...\n"); - - // "o": single glyph at x=0, width=8 - // w = 0 + 8 = 8 - ASSERT_EQ(textWidth("o"), 8); +TEST(EpdFont, KnownWidths) { + // "o": single glyph at x=0, width=8 -> w = 0 + 8 = 8 + EXPECT_EQ(textWidth("o"), 8); // "oo": step = toPixel(145 + (-3)) = toPixel(142) = 9 // o1 at 0, o2 at 9. w = 9 + 8 = 17 - ASSERT_EQ(textWidth("oo"), 17); + EXPECT_EQ(textWidth("oo"), 17); - // "ooo": two steps of 9 - // o1 at 0, o2 at 9, o3 at 18. w = 18 + 8 = 26 - ASSERT_EQ(textWidth("ooo"), 26); + // "ooo": two steps of 9 -> o3 at 18, w = 18 + 8 = 26 + EXPECT_EQ(textWidth("ooo"), 26); - // "To": step = toPixel(137 + (-7)) = toPixel(130) = 8 - // T at 0, o at 8. w = 8 + 8 = 16 - ASSERT_EQ(textWidth("To"), 16); + // "To": step = toPixel(137 + (-7)) = 8 -> o at 8, w = 8 + 8 = 16 + EXPECT_EQ(textWidth("To"), 16); - // "Ta": step = toPixel(137 + (-5)) = toPixel(132) = 8 - // T at 0, a at 8. w = 8 + 7 = 15 - ASSERT_EQ(textWidth("Ta"), 15); + // "Ta": step = toPixel(137 + (-5)) = 8 -> a at 8, w = 8 + 7 = 15 + EXPECT_EQ(textWidth("Ta"), 15); - // "oa": step = toPixel(145 + (-2)) = toPixel(143) = 9 - // o at 0, a at 9. w = 9 + 7 = 16 - ASSERT_EQ(textWidth("oa"), 16); + // "oa": step = toPixel(145 + (-2)) = 9 -> a at 9, w = 9 + 7 = 16 + EXPECT_EQ(textWidth("oa"), 16); - // "Too": T at 0. - // step T->o = toPixel(137 + (-7)) = 8. o1 at 8. - // step o->o = toPixel(145 + (-3)) = 9. o2 at 17. - // w = 17 + 8 = 25 - ASSERT_EQ(textWidth("Too"), 25); + // "Too": T at 0, o1 at 8 (T->o step), o2 at 17 (o->o step). w = 17 + 8 = 25 + EXPECT_EQ(textWidth("Too"), 25); - // "xo": step = toPixel(136 + 0) = toPixel(136) = 9 (no kern: x has no left class) + // "xo": step = toPixel(136 + 0) = 9 (no kern: x has no left class) // x at 0, o at 9. w = 9 + 8 = 17 - ASSERT_EQ(textWidth("xo"), 17); - - printf(" All known widths correct\n"); - PASS(); + EXPECT_EQ(textWidth("xo"), 17); } // "oo" pair consistency: the pixel gap between two o's must be the same @@ -291,9 +233,7 @@ void testKnownWidths() { // differential rounding. With absolute snapping, "xoo" would produce a // different oo gap than "oo" because 'x' advance (136 FP) puts the first // 'o' at fractional phase 8, crossing the rounding boundary differently. -void testPairConsistencyViaFont() { - printf("testPairConsistencyViaFont...\n"); - +TEST(EpdFont, PairConsistencyViaFont) { // The oo gap = width(prefix + "oo") - width(prefix + "o") // This isolates the pixel distance contributed by the second 'o'. const int oo_gap_bare = textWidth("oo") - textWidth("o"); @@ -301,81 +241,33 @@ void testPairConsistencyViaFont() { const int oo_gap_after_T = textWidth("Too") - textWidth("To"); const int oo_gap_after_o = textWidth("ooo") - textWidth("oo"); - printf(" oo gap (bare): %d\n", oo_gap_bare); - printf(" oo gap (after x): %d\n", oo_gap_after_x); - printf(" oo gap (after T): %d\n", oo_gap_after_T); - printf(" oo gap (after o): %d\n", oo_gap_after_o); - - // All must be identical - ASSERT_EQ(oo_gap_after_x, oo_gap_bare); - ASSERT_EQ(oo_gap_after_T, oo_gap_bare); - ASSERT_EQ(oo_gap_after_o, oo_gap_bare); - - printf(" All oo gaps identical (%d px) regardless of prefix\n", oo_gap_bare); - PASS(); + EXPECT_EQ(oo_gap_after_x, oo_gap_bare); + EXPECT_EQ(oo_gap_after_T, oo_gap_bare); + EXPECT_EQ(oo_gap_after_o, oo_gap_bare); } // Null-glyph handling: when a codepoint has no glyph (and no replacement // glyph), the pending advance from the previous glyph must still be flushed. // Without the flush fix, the glyph after the null would overlap the one before. -void testNullGlyphAdvancePreserved() { - printf("testNullGlyphAdvancePreserved...\n"); - +TEST(EpdFont, NullGlyphAdvancePreserved) { // 'Z' (0x5A) is not in our font and there's no U+FFFD, so getGlyph returns null. // "oZo" should lay out as: o1 at 0, Z skipped (advance flushed), o2 at 9. // toPixel(145) = 9 (o's advance, no kern since Z resets prevCp). // w = 9 + 8 = 17 - int w = textWidth("oZo"); - printf(" width(\"oZo\") = %d\n", w); - - // Without the flush fix, o2 would land at 0 (overlapping o1), giving w = 8. - ASSERT_TRUE(w > 8); - ASSERT_EQ(w, 17); + EXPECT_EQ(textWidth("oZo"), 17); // Multi-null: "oZZo" -- two consecutive nulls, advance still preserved. - w = textWidth("oZZo"); - printf(" width(\"oZZo\") = %d\n", w); - ASSERT_EQ(w, 17); + EXPECT_EQ(textWidth("oZZo"), 17); // Null at start: "Zo" -- no pending advance to flush, o renders at 0. - w = textWidth("Zo"); - printf(" width(\"Zo\") = %d\n", w); - ASSERT_EQ(w, 8); - - printf(" Null-glyph advance correctly preserved\n"); - PASS(); + EXPECT_EQ(textWidth("Zo"), 8); } -void testHeightCalculation() { - printf("testHeightCalculation...\n"); - +TEST(EpdFont, HeightCalculation) { // 'T' is tallest: top=12, height=12 -> extent [0, 12) // 'o' and 'a': top=8, height=8 -> extent [0, 8) - ASSERT_EQ(textHeight("o"), 8); - ASSERT_EQ(textHeight("T"), 12); - ASSERT_EQ(textHeight("To"), 12); - ASSERT_EQ(textHeight("oo"), 8); - - printf(" All heights correct\n"); - PASS(); -} - -int main() { - printf("=== Differential Rounding Tests ===\n\n"); - - // Part 1: Pure fp4 math - testFp4Basics(); - testOldApproachInconsistency(); - testExhaustiveKernRange(); - - // Part 2: Integration tests against real EpdFont - testKernLookup(); - testGlyphLookup(); - testKnownWidths(); - testPairConsistencyViaFont(); - testNullGlyphAdvancePreserved(); - testHeightCalculation(); - - printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed); - return testsFailed > 0 ? 1 : 0; + EXPECT_EQ(textHeight("o"), 8); + EXPECT_EQ(textHeight("T"), 12); + EXPECT_EQ(textHeight("To"), 12); + EXPECT_EQ(textHeight("oo"), 8); } diff --git a/test/hyphenation_eval/CMakeLists.txt b/test/hyphenation_eval/CMakeLists.txt new file mode 100644 index 0000000000..7cc7ba159b --- /dev/null +++ b/test/hyphenation_eval/CMakeLists.txt @@ -0,0 +1,24 @@ +add_executable(HyphenationEvaluationTest + HyphenationEvaluationTest.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/Hyphenator.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/LanguageRegistry.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/LiangHyphenation.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp + ${REPO_ROOT}/lib/Utf8/Utf8.cpp +) + +target_include_directories(HyphenationEvaluationTest PRIVATE + ${REPO_ROOT}/lib/Epub + ${REPO_ROOT}/lib/Utf8 +) + +target_compile_definitions(HyphenationEvaluationTest PRIVATE + HYPHENATION_RESOURCES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/resources" +) + +target_link_libraries(HyphenationEvaluationTest PRIVATE + crosspoint_test_common + GTest::gtest_main +) + +gtest_discover_tests(HyphenationEvaluationTest) diff --git a/test/hyphenation_eval/HyphenationEvaluationTest.cpp b/test/hyphenation_eval/HyphenationEvaluationTest.cpp index 0ac7eabc77..45715ed87e 100644 --- a/test/hyphenation_eval/HyphenationEvaluationTest.cpp +++ b/test/hyphenation_eval/HyphenationEvaluationTest.cpp @@ -1,10 +1,8 @@ #include +#include #include -#include -#include #include -#include #include #include #include @@ -14,6 +12,12 @@ #include "lib/Epub/Epub/hyphenation/LanguageHyphenator.h" #include "lib/Epub/Epub/hyphenation/LanguageRegistry.h" +#ifndef HYPHENATION_RESOURCES_DIR +#error "HYPHENATION_RESOURCES_DIR must be defined by the build system" +#endif + +namespace { + struct TestCase { std::string word; std::string hyphenated; @@ -31,23 +35,6 @@ struct EvaluationResult { double weightedScore = 0.0; }; -struct LanguageConfig { - std::string cliName; - std::string testDataFile; - const char* primaryTag; -}; - -const std::vector kSupportedLanguages = { - {"english", "test/hyphenation_eval/resources/english_hyphenation_tests.txt", "en"}, - {"french", "test/hyphenation_eval/resources/french_hyphenation_tests.txt", "fr"}, - {"german", "test/hyphenation_eval/resources/german_hyphenation_tests.txt", "de"}, - {"russian", "test/hyphenation_eval/resources/russian_hyphenation_tests.txt", "ru"}, - {"spanish", "test/hyphenation_eval/resources/spanish_hyphenation_tests.txt", "es"}, - {"italian", "test/hyphenation_eval/resources/italian_hyphenation_tests.txt", "it"}, - {"polish", "test/hyphenation_eval/resources/polish_hyphenation_tests.txt", "pl"}, - {"swedish", "test/hyphenation_eval/resources/swedish_hyphenation_tests.txt", "sv"}, -}; - std::vector expectedPositionsFromAnnotatedWord(const std::string& annotated) { std::vector positions; const unsigned char* ptr = reinterpret_cast(annotated.c_str()); @@ -72,7 +59,6 @@ std::vector loadTestData(const std::string& filename) { std::ifstream file(filename); if (!file.is_open()) { - std::cerr << "Error: Could not open file " << filename << std::endl; return testCases; } @@ -90,14 +76,11 @@ std::vector loadTestData(const std::string& filename) { testCase.word = word; testCase.hyphenated = hyphenated; testCase.frequency = std::stoi(freqStr); - testCase.expectedPositions = expectedPositionsFromAnnotatedWord(hyphenated); - testCases.push_back(testCase); } } - file.close(); return testCases; } @@ -133,30 +116,12 @@ std::string positionsToHyphenated(const std::string& word, const std::vector hyphenateWordWithHyphenator(const std::string& word, const LanguageHyphenator& hyphenator) { auto cps = collectCodepoints(word); trimSurroundingPunctuationAndFootnote(cps); - return hyphenator.breakIndexes(cps); } -std::vector resolveLanguages(const std::string& selection) { - if (selection == "all") { - return kSupportedLanguages; - } - - for (const auto& config : kSupportedLanguages) { - if (config.cliName == selection) { - return {config}; - } - } - - return {}; -} - -EvaluationResult evaluateWord(const TestCase& testCase, - std::function(const std::string&)> hyphenateFunc) { +EvaluationResult evaluateWord(const TestCase& testCase, const std::vector& actualPositions) { EvaluationResult result; - std::vector actualPositions = hyphenateFunc(testCase.word); - std::vector expected = testCase.expectedPositions; std::vector actual = actualPositions; @@ -189,8 +154,7 @@ EvaluationResult evaluateWord(const TestCase& testCase, result.f1Score = 2 * result.precision * result.recall / (result.precision + result.recall); } - // Treat words that contain no hyphenation marks in both the expected data and the - // algorithmic output as perfect matches so they don't drag down the per-word averages. + // Treat words with no expected and no actual hyphenation marks as perfect. if (expected.empty() && actual.empty()) { result.precision = 1.0; result.recall = 1.0; @@ -199,9 +163,8 @@ EvaluationResult evaluateWord(const TestCase& testCase, double fpPenalty = 2.0; double fnPenalty = 1.0; - int totalErrors = result.falsePositives * fpPenalty + result.falseNegatives * fnPenalty; - int totalPossible = expected.size() * fpPenalty; + int totalPossible = static_cast(expected.size() * fpPenalty); if (totalPossible > 0) { result.weightedScore = 1.0 - (static_cast(totalErrors) / totalPossible); @@ -213,180 +176,59 @@ EvaluationResult evaluateWord(const TestCase& testCase, return result; } -void printResults(const std::string& language, const std::vector& testCases, - const std::vector>& worstCases, int perfectMatches, - int partialMatches, int completeMisses, double totalPrecision, double totalRecall, double totalF1, - double totalWeighted, int totalTP, int totalFP, int totalFN, - std::function(const std::string&)> hyphenateFunc) { - std::string lang_upper = language; - if (!lang_upper.empty()) { - lang_upper[0] = std::toupper(lang_upper[0]); - } - - std::cout << "================================================================================" << std::endl; - std::cout << lang_upper << " HYPHENATION EVALUATION RESULTS" << std::endl; - std::cout << "================================================================================" << std::endl; - std::cout << std::endl; - - std::cout << "Total test cases: " << testCases.size() << std::endl; - std::cout << "Perfect matches: " << perfectMatches << " (" << (perfectMatches * 100.0 / testCases.size()) << "%)" - << std::endl; - std::cout << "Partial matches: " << partialMatches << std::endl; - std::cout << "Complete misses: " << completeMisses << std::endl; - std::cout << std::endl; - - std::cout << "--- Overall Metrics (averaged per word) ---" << std::endl; - std::cout << "Average Precision: " << (totalPrecision / testCases.size() * 100.0) << "%" << std::endl; - std::cout << "Average Recall: " << (totalRecall / testCases.size() * 100.0) << "%" << std::endl; - std::cout << "Average F1 Score: " << (totalF1 / testCases.size() * 100.0) << "%" << std::endl; - std::cout << "Average Weighted Score: " << (totalWeighted / testCases.size() * 100.0) << "% (FP penalty: 2x)" - << std::endl; - std::cout << std::endl; - - std::cout << "--- Overall Metrics (total counts) ---" << std::endl; - std::cout << "True Positives: " << totalTP << std::endl; - std::cout << "False Positives: " << totalFP << " (incorrect hyphenation points)" << std::endl; - std::cout << "False Negatives: " << totalFN << " (missed hyphenation points)" << std::endl; - - double overallPrecision = totalTP + totalFP > 0 ? static_cast(totalTP) / (totalTP + totalFP) : 0.0; - double overallRecall = totalTP + totalFN > 0 ? static_cast(totalTP) / (totalTP + totalFN) : 0.0; - double overallF1 = overallPrecision + overallRecall > 0 - ? 2 * overallPrecision * overallRecall / (overallPrecision + overallRecall) - : 0.0; - - std::cout << "Overall Precision: " << (overallPrecision * 100.0) << "%" << std::endl; - std::cout << "Overall Recall: " << (overallRecall * 100.0) << "%" << std::endl; - std::cout << "Overall F1 Score: " << (overallF1 * 100.0) << "%" << std::endl; - std::cout << std::endl; - - // Filter out perfect matches from the “worst cases” section so that only actionable failures appear. - auto hasImperfection = [](const EvaluationResult& r) { return r.weightedScore < 0.999999; }; - std::vector> imperfectCases; - imperfectCases.reserve(worstCases.size()); - for (const auto& entry : worstCases) { - if (hasImperfection(entry.second)) { - imperfectCases.push_back(entry); +// Runs the evaluation for a single language and asserts the per-word average F1 +// is at or above `minF1Percent`. Thresholds are set ~1pp below measured +// baselines so unrelated tweaks don't fail CI but real regressions still trip. +void runLanguageEval(const char* langName, const char* primaryTag, const char* resourceFile, double minF1Percent) { + const auto* hyphenator = getLanguageHyphenatorForPrimaryTag(primaryTag); + ASSERT_NE(hyphenator, nullptr) << "No hyphenator registered for tag: " << primaryTag; + + std::string path = std::string(HYPHENATION_RESOURCES_DIR) + "/" + resourceFile; + std::vector testCases = loadTestData(path); + ASSERT_FALSE(testCases.empty()) << "No test cases loaded from " << path; + + double totalF1 = 0.0; + std::vector> imperfect; + + for (const auto& tc : testCases) { + std::vector actual = hyphenateWordWithHyphenator(tc.word, *hyphenator); + EvaluationResult res = evaluateWord(tc, actual); + totalF1 += res.f1Score; + if (res.weightedScore < 0.999999) { + imperfect.emplace_back(tc, res); } } - std::cout << "--- Worst Cases (lowest weighted scores) ---" << std::endl; - int showCount = std::min(10, static_cast(imperfectCases.size())); - for (int i = 0; i < showCount; i++) { - const auto& testCase = imperfectCases[i].first; - const auto& result = imperfectCases[i].second; - - std::vector actualPositions = hyphenateFunc(testCase.word); - std::string actualHyphenated = positionsToHyphenated(testCase.word, actualPositions); - - std::cout << "Word: " << testCase.word << " (freq: " << testCase.frequency << ")" << std::endl; - std::cout << " Expected: " << testCase.hyphenated << std::endl; - std::cout << " Got: " << actualHyphenated << std::endl; - std::cout << " Precision: " << (result.precision * 100.0) << "%" - << " Recall: " << (result.recall * 100.0) << "%" - << " F1: " << (result.f1Score * 100.0) << "%" - << " Weighted: " << (result.weightedScore * 100.0) << "%" << std::endl; - std::cout << " TP: " << result.truePositives << " FP: " << result.falsePositives - << " FN: " << result.falseNegatives << std::endl; - std::cout << std::endl; - } - - // Additional compact list of the worst ~100 words to aid iteration - int compactCount = std::min(100, static_cast(imperfectCases.size())); - if (compactCount > 0) { - std::cout << "--- Compact Worst Cases (" << compactCount << ") ---" << std::endl; - for (int i = 0; i < compactCount; i++) { - const auto& testCase = imperfectCases[i].first; - std::vector actualPositions = hyphenateFunc(testCase.word); - std::string actualHyphenated = positionsToHyphenated(testCase.word, actualPositions); - std::cout << testCase.word << " | exp:" << testCase.hyphenated << " | got:" << actualHyphenated << std::endl; - } - std::cout << std::endl; - } -} - -int main(int argc, char* argv[]) { - const bool summaryMode = argc <= 1; - const std::string languageSelection = summaryMode ? "all" : argv[1]; - - std::vector languages = resolveLanguages(languageSelection); - if (languages.empty()) { - std::cerr << "Unknown language: " << languageSelection << std::endl; - return 1; - } - - for (const auto& lang : languages) { - const auto* hyphenator = getLanguageHyphenatorForPrimaryTag(lang.primaryTag); - if (!hyphenator) { - std::cerr << "No hyphenator registered for tag: " << lang.primaryTag << std::endl; - continue; - } - const auto hyphenateFunc = [hyphenator](const std::string& word) { - return hyphenateWordWithHyphenator(word, *hyphenator); - }; - - if (!summaryMode) { - std::cout << "Loading test data from: " << lang.testDataFile << std::endl; - } - std::vector testCases = loadTestData(lang.testDataFile); - - if (testCases.empty()) { - std::cerr << "No test cases loaded for " << lang.cliName << ". Skipping." << std::endl; - continue; - } - - if (!summaryMode) { - std::cout << "Loaded " << testCases.size() << " test cases for " << lang.cliName << std::endl; - std::cout << std::endl; - } - - int perfectMatches = 0; - int partialMatches = 0; - int completeMisses = 0; - - double totalPrecision = 0.0; - double totalRecall = 0.0; - double totalF1 = 0.0; - double totalWeighted = 0.0; - - int totalTP = 0, totalFP = 0, totalFN = 0; + double averageF1Percent = totalF1 / testCases.size() * 100.0; + ::testing::Test::RecordProperty("avg_f1_percent", std::to_string(averageF1Percent)); + ::testing::Test::RecordProperty("test_cases", std::to_string(testCases.size())); - std::vector> worstCases; + std::cout << langName << ": F1=" << averageF1Percent << "% (threshold " << minF1Percent << "%, " << testCases.size() + << " cases)\n"; - for (const auto& testCase : testCases) { - EvaluationResult result = evaluateWord(testCase, hyphenateFunc); - - totalTP += result.truePositives; - totalFP += result.falsePositives; - totalFN += result.falseNegatives; - - totalPrecision += result.precision; - totalRecall += result.recall; - totalF1 += result.f1Score; - totalWeighted += result.weightedScore; - - if (result.f1Score == 1.0) { - perfectMatches++; - } else if (result.f1Score > 0.0) { - partialMatches++; - } else { - completeMisses++; - } - - worstCases.push_back({testCase, result}); - } - - if (summaryMode) { - const double averageF1Percent = testCases.empty() ? 0.0 : (totalF1 / testCases.size() * 100.0); - std::cout << lang.cliName << ": " << averageF1Percent << "%" << std::endl; - continue; - } - - std::sort(worstCases.begin(), worstCases.end(), + if (averageF1Percent < minF1Percent) { + std::sort(imperfect.begin(), imperfect.end(), [](const auto& a, const auto& b) { return a.second.weightedScore < b.second.weightedScore; }); - - printResults(lang.cliName, testCases, worstCases, perfectMatches, partialMatches, completeMisses, totalPrecision, - totalRecall, totalF1, totalWeighted, totalTP, totalFP, totalFN, hyphenateFunc); + std::cout << "Worst cases for " << langName << ":\n"; + int show = std::min(10, static_cast(imperfect.size())); + for (int i = 0; i < show; ++i) { + const TestCase& tc = imperfect[i].first; + std::vector actual = hyphenateWordWithHyphenator(tc.word, *hyphenator); + std::cout << " " << tc.word << " | expected=" << tc.hyphenated + << " | got=" << positionsToHyphenated(tc.word, actual) << "\n"; + } } - return 0; + EXPECT_GE(averageF1Percent, minF1Percent) << "Hyphenation quality regressed for " << langName; } + +} // namespace + +TEST(HyphenationEval, English) { runLanguageEval("english", "en", "english_hyphenation_tests.txt", 98.10); } +TEST(HyphenationEval, French) { runLanguageEval("french", "fr", "french_hyphenation_tests.txt", 99.00); } +TEST(HyphenationEval, German) { runLanguageEval("german", "de", "german_hyphenation_tests.txt", 96.73); } +TEST(HyphenationEval, Russian) { runLanguageEval("russian", "ru", "russian_hyphenation_tests.txt", 96.22); } +TEST(HyphenationEval, Spanish) { runLanguageEval("spanish", "es", "spanish_hyphenation_tests.txt", 98.02); } +TEST(HyphenationEval, Italian) { runLanguageEval("italian", "it", "italian_hyphenation_tests.txt", 98.99); } +TEST(HyphenationEval, Polish) { runLanguageEval("polish", "pl", "polish_hyphenation_tests.txt", 98.92); } +TEST(HyphenationEval, Swedish) { runLanguageEval("swedish", "sv", "swedish_hyphenation_tests.txt", 94.01); } diff --git a/test/release_json_parser/CMakeLists.txt b/test/release_json_parser/CMakeLists.txt new file mode 100644 index 0000000000..d9d45392c9 --- /dev/null +++ b/test/release_json_parser/CMakeLists.txt @@ -0,0 +1,16 @@ +add_executable(ReleaseJsonParserTest + ReleaseJsonParserTest.cpp + ${REPO_ROOT}/lib/JsonParser/ReleaseJsonParser.cpp + ${REPO_ROOT}/lib/JsonParser/StreamingJsonParser.cpp +) + +target_include_directories(ReleaseJsonParserTest PRIVATE + ${REPO_ROOT}/lib/JsonParser +) + +target_link_libraries(ReleaseJsonParserTest PRIVATE + crosspoint_test_common + GTest::gtest_main +) + +gtest_discover_tests(ReleaseJsonParserTest) diff --git a/test/release_json_parser/ReleaseJsonParserTest.cpp b/test/release_json_parser/ReleaseJsonParserTest.cpp index 130bdfbd08..ed85f04504 100644 --- a/test/release_json_parser/ReleaseJsonParserTest.cpp +++ b/test/release_json_parser/ReleaseJsonParserTest.cpp @@ -1,51 +1,13 @@ -#include -#include +#include + #include #include #include "lib/JsonParser/ReleaseJsonParser.h" -static int testsPassed = 0; -static int testsFailed = 0; - -#define ASSERT_EQ(a, b) \ - do { \ - auto _a = (a); \ - auto _b = (b); \ - if (_a != _b) { \ - fprintf(stderr, " FAIL: %s:%d: %s != expected\n", __FILE__, __LINE__, #a); \ - testsFailed++; \ - return; \ - } \ - } while (0) - -#define ASSERT_STREQ(a, b) \ - do { \ - const char* _a = (a); \ - const char* _b = (b); \ - if (strcmp(_a, _b) != 0) { \ - fprintf(stderr, " FAIL: %s:%d: \"%s\" != \"%s\"\n", __FILE__, __LINE__, _a, _b); \ - testsFailed++; \ - return; \ - } \ - } while (0) - -#define ASSERT_TRUE(cond) \ - do { \ - if (!(cond)) { \ - fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, #cond); \ - testsFailed++; \ - return; \ - } \ - } while (0) - -#define PASS() testsPassed++ - -// ============================================================================ -// Realistic GitHub release JSON payloads -// ============================================================================ - -static const char* kRealisticPretty = R"({ +namespace { + +const char* kRealisticPretty = R"({ "url": "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/12345", "assets_url": "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/12345/assets", "upload_url": "https://uploads.github.com/repos/crosspoint-reader/crosspoint-reader/releases/12345/assets{?name,label}", @@ -147,11 +109,10 @@ static const char* kRealisticPretty = R"({ } })"; -static const char* kRealisticMinified = +const char* kRealisticMinified = R"({"url":"https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/12345","assets_url":"https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/12345/assets","id":12345,"author":{"login":"releasebot","id":99887766,"node_id":"MDQ6VXNlcjk5ODg3NzY2","type":"User","site_admin":false},"tag_name":"v2.4.1","target_commitish":"main","name":"CrossPoint Reader v2.4.1","draft":false,"prerelease":false,"assets":[{"url":"https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/assets/100001","id":100001,"name":"crosspoint-reader-v2.4.1-source.zip","uploader":{"login":"releasebot","id":99887766},"content_type":"application/zip","state":"uploaded","size":2048576,"download_count":42,"browser_download_url":"https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/crosspoint-reader-v2.4.1-source.zip"},{"url":"https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/assets/100002","id":100002,"name":"firmware.bin","uploader":{"login":"releasebot","id":99887766},"content_type":"application/octet-stream","state":"uploaded","size":1572864,"download_count":187,"browser_download_url":"https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/firmware.bin"},{"url":"https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/assets/100003","id":100003,"name":"checksums.sha256","uploader":{"login":"releasebot","id":99887766},"content_type":"text/plain","state":"uploaded","size":192,"download_count":15,"browser_download_url":"https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/checksums.sha256"}],"body":"## What's Changed\n\n* Fixed orientation crash","reactions":{"url":"https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/12345/reactions","total_count":5,"+1":3}})"; -// Helper: feed JSON in fixed-size chunks -static void feedChunked(ReleaseJsonParser& p, const char* json, size_t chunkSize) { +void feedChunked(ReleaseJsonParser& p, const char* json, size_t chunkSize) { size_t len = strlen(json); for (size_t off = 0; off < len; off += chunkSize) { size_t n = len - off < chunkSize ? len - off : chunkSize; @@ -159,65 +120,50 @@ static void feedChunked(ReleaseJsonParser& p, const char* json, size_t chunkSize } } -// ============================================================================ -// Tests -// ============================================================================ - -void testRealisticPrettyPrinted() { - printf("testRealisticPrettyPrinted...\n"); +} // namespace +TEST(ReleaseJsonParser, RealisticPrettyPrinted) { ReleaseJsonParser p; p.feed(kRealisticPretty, strlen(kRealisticPretty)); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getTagName(), "v2.4.1"); - ASSERT_STREQ(p.getFirmwareUrl(), + EXPECT_TRUE(p.foundTag()); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_STREQ(p.getTagName(), "v2.4.1"); + EXPECT_STREQ(p.getFirmwareUrl(), "https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/firmware.bin"); - ASSERT_EQ(p.getFirmwareSize(), 1572864u); - - printf(" passed\n"); - PASS(); + EXPECT_EQ(p.getFirmwareSize(), 1572864u); } -void testRealisticMinified() { - printf("testRealisticMinified...\n"); - +TEST(ReleaseJsonParser, RealisticMinified) { ReleaseJsonParser p; p.feed(kRealisticMinified, strlen(kRealisticMinified)); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getTagName(), "v2.4.1"); - ASSERT_STREQ(p.getFirmwareUrl(), + EXPECT_TRUE(p.foundTag()); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_STREQ(p.getTagName(), "v2.4.1"); + EXPECT_STREQ(p.getFirmwareUrl(), "https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/firmware.bin"); - ASSERT_EQ(p.getFirmwareSize(), 1572864u); - - printf(" passed\n"); - PASS(); + EXPECT_EQ(p.getFirmwareSize(), 1572864u); } -void testPrettyAndMinifiedAgree() { - printf("testPrettyAndMinifiedAgree...\n"); - +TEST(ReleaseJsonParser, PrettyAndMinifiedAgree) { ReleaseJsonParser pretty; pretty.feed(kRealisticPretty, strlen(kRealisticPretty)); ReleaseJsonParser minified; minified.feed(kRealisticMinified, strlen(kRealisticMinified)); - ASSERT_STREQ(pretty.getTagName(), minified.getTagName()); - ASSERT_STREQ(pretty.getFirmwareUrl(), minified.getFirmwareUrl()); - ASSERT_EQ(pretty.getFirmwareSize(), minified.getFirmwareSize()); + ASSERT_TRUE(pretty.foundTag()); + ASSERT_TRUE(pretty.foundFirmware()); + ASSERT_TRUE(minified.foundTag()); + ASSERT_TRUE(minified.foundFirmware()); - printf(" passed\n"); - PASS(); + EXPECT_STREQ(pretty.getTagName(), minified.getTagName()); + EXPECT_STREQ(pretty.getFirmwareUrl(), minified.getFirmwareUrl()); + EXPECT_EQ(pretty.getFirmwareSize(), minified.getFirmwareSize()); } -void testFirmwareNotFirstAsset() { - printf("testFirmwareNotFirstAsset...\n"); - - // firmware.bin is the third of four assets +TEST(ReleaseJsonParser, FirmwareNotFirstAsset) { const char* json = R"({ "tag_name": "v1.0.0", "assets": [ @@ -231,19 +177,14 @@ void testFirmwareNotFirstAsset() { ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getTagName(), "v1.0.0"); - ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/firmware.bin"); - ASSERT_EQ(p.getFirmwareSize(), 987654u); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundTag()); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_STREQ(p.getTagName(), "v1.0.0"); + EXPECT_STREQ(p.getFirmwareUrl(), "https://example.com/firmware.bin"); + EXPECT_EQ(p.getFirmwareSize(), 987654u); } -void testFieldOrderUrlBeforeName() { - printf("testFieldOrderUrlBeforeName...\n"); - +TEST(ReleaseJsonParser, FieldOrderUrlBeforeName) { const char* json = R"({ "tag_name": "v3.0", "assets": [{ @@ -256,17 +197,12 @@ void testFieldOrderUrlBeforeName() { ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw.bin"); - ASSERT_EQ(p.getFirmwareSize(), 2222u); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_STREQ(p.getFirmwareUrl(), "https://example.com/fw.bin"); + EXPECT_EQ(p.getFirmwareSize(), 2222u); } -void testFieldOrderSizeBeforeUrl() { - printf("testFieldOrderSizeBeforeUrl...\n"); - +TEST(ReleaseJsonParser, FieldOrderSizeBeforeUrl) { const char* json = R"({ "tag_name": "v3.1", "assets": [{ @@ -279,17 +215,12 @@ void testFieldOrderSizeBeforeUrl() { ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw2.bin"); - ASSERT_EQ(p.getFirmwareSize(), 3333u); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_STREQ(p.getFirmwareUrl(), "https://example.com/fw2.bin"); + EXPECT_EQ(p.getFirmwareSize(), 3333u); } -void testFieldOrderNameFirst() { - printf("testFieldOrderNameFirst...\n"); - +TEST(ReleaseJsonParser, FieldOrderNameFirst) { const char* json = R"({ "tag_name": "v3.2", "assets": [{ @@ -302,17 +233,12 @@ void testFieldOrderNameFirst() { ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw3.bin"); - ASSERT_EQ(p.getFirmwareSize(), 4444u); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_STREQ(p.getFirmwareUrl(), "https://example.com/fw3.bin"); + EXPECT_EQ(p.getFirmwareSize(), 4444u); } -void testAssetsBeforeTagName() { - printf("testAssetsBeforeTagName...\n"); - +TEST(ReleaseJsonParser, AssetsBeforeTagName) { // tag_name appears after assets in the JSON const char* json = R"({ "name": "Release", @@ -327,69 +253,51 @@ void testAssetsBeforeTagName() { ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getTagName(), "v4.0"); - ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw.bin"); - ASSERT_EQ(p.getFirmwareSize(), 5555u); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundTag()); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_STREQ(p.getTagName(), "v4.0"); + EXPECT_STREQ(p.getFirmwareUrl(), "https://example.com/fw.bin"); + EXPECT_EQ(p.getFirmwareSize(), 5555u); } -void testChunkedFeedingRealisticSmallChunks() { - printf("testChunkedFeedingRealisticSmallChunks...\n"); - - // Simulate HTTP chunked transfer with small chunks (64 bytes) +TEST(ReleaseJsonParser, ChunkedFeedingSmallChunks) { ReleaseJsonParser p; feedChunked(p, kRealisticPretty, 64); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getTagName(), "v2.4.1"); - ASSERT_STREQ(p.getFirmwareUrl(), + EXPECT_TRUE(p.foundTag()); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_STREQ(p.getTagName(), "v2.4.1"); + EXPECT_STREQ(p.getFirmwareUrl(), "https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/firmware.bin"); - ASSERT_EQ(p.getFirmwareSize(), 1572864u); - - printf(" passed\n"); - PASS(); + EXPECT_EQ(p.getFirmwareSize(), 1572864u); } -void testChunkedFeedingByteByByte() { - printf("testChunkedFeedingByteByByte...\n"); - +TEST(ReleaseJsonParser, ChunkedFeedingByteByByte) { ReleaseJsonParser p; feedChunked(p, kRealisticMinified, 1); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getTagName(), "v2.4.1"); - ASSERT_EQ(p.getFirmwareSize(), 1572864u); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundTag()); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_STREQ(p.getTagName(), "v2.4.1"); + EXPECT_EQ(p.getFirmwareSize(), 1572864u); } -void testChunkedFeedingVariousChunkSizes() { - printf("testChunkedFeedingVariousChunkSizes...\n"); - - for (size_t chunkSize : {3, 7, 13, 31, 97, 128, 256, 512, 1024}) { +TEST(ReleaseJsonParser, ChunkedFeedingVariousChunkSizes) { + for (size_t chunkSize : {3u, 7u, 13u, 31u, 97u, 128u, 256u, 512u, 1024u}) { ReleaseJsonParser p; feedChunked(p, kRealisticPretty, chunkSize); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getTagName(), "v2.4.1"); - ASSERT_EQ(p.getFirmwareSize(), 1572864u); + EXPECT_TRUE(p.foundTag()) << "chunkSize=" << chunkSize; + EXPECT_TRUE(p.foundFirmware()) << "chunkSize=" << chunkSize; + EXPECT_STREQ(p.getTagName(), "v2.4.1") << "chunkSize=" << chunkSize; + EXPECT_STREQ(p.getFirmwareUrl(), + "https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/firmware.bin") + << "chunkSize=" << chunkSize; + EXPECT_EQ(p.getFirmwareSize(), 1572864u) << "chunkSize=" << chunkSize; } - - printf(" passed (9 chunk sizes)\n"); - PASS(); } -void testMissingTagName() { - printf("testMissingTagName...\n"); - +TEST(ReleaseJsonParser, MissingTagName) { const char* json = R"({ "name": "Some Release", "draft": false, @@ -403,17 +311,12 @@ void testMissingTagName() { ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(!p.foundTag()); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getTagName(), ""); - - printf(" passed\n"); - PASS(); + EXPECT_FALSE(p.foundTag()); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_STREQ(p.getTagName(), ""); } -void testMissingFirmwareBinAsset() { - printf("testMissingFirmwareBinAsset...\n"); - +TEST(ReleaseJsonParser, MissingFirmwareBinAsset) { const char* json = R"({ "tag_name": "v1.0.0", "assets": [ @@ -425,129 +328,88 @@ void testMissingFirmwareBinAsset() { ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(!p.foundFirmware()); - ASSERT_STREQ(p.getTagName(), "v1.0.0"); - ASSERT_STREQ(p.getFirmwareUrl(), ""); - ASSERT_EQ(p.getFirmwareSize(), 0u); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundTag()); + EXPECT_FALSE(p.foundFirmware()); + EXPECT_STREQ(p.getTagName(), "v1.0.0"); + EXPECT_STREQ(p.getFirmwareUrl(), ""); + EXPECT_EQ(p.getFirmwareSize(), 0u); } -void testEmptyAssetsArray() { - printf("testEmptyAssetsArray...\n"); - +TEST(ReleaseJsonParser, EmptyAssetsArray) { const char* json = R"({"tag_name": "v1.0.0", "assets": []})"; ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(!p.foundFirmware()); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundTag()); + EXPECT_FALSE(p.foundFirmware()); } -void testNoAssetsKey() { - printf("testNoAssetsKey...\n"); - +TEST(ReleaseJsonParser, NoAssetsKey) { const char* json = R"({"tag_name": "v1.0.0", "name": "Release"})"; ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(!p.foundFirmware()); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundTag()); + EXPECT_FALSE(p.foundFirmware()); } -void testTruncatedBeforeTagValue() { - printf("testTruncatedBeforeTagValue...\n"); - +TEST(ReleaseJsonParser, TruncatedBeforeTagValue) { const char* json = R"({"tag_name": )"; ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(!p.foundTag()); - ASSERT_TRUE(!p.foundFirmware()); - - printf(" passed\n"); - PASS(); + EXPECT_FALSE(p.foundTag()); + EXPECT_FALSE(p.foundFirmware()); } -void testTruncatedInsideTagValue() { - printf("testTruncatedInsideTagValue...\n"); - +TEST(ReleaseJsonParser, TruncatedInsideTagValue) { const char* json = R"({"tag_name": "v2.4)"; ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(!p.foundTag()); - - printf(" passed\n"); - PASS(); + EXPECT_FALSE(p.foundTag()); } -void testTruncatedInsideAssetsArray() { - printf("testTruncatedInsideAssetsArray...\n"); - +TEST(ReleaseJsonParser, TruncatedInsideAssetsArray) { const char* json = R"({"tag_name": "v2.4.1", "assets": [{"name": "firm)"; ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundTag()); - ASSERT_STREQ(p.getTagName(), "v2.4.1"); - ASSERT_TRUE(!p.foundFirmware()); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundTag()); + EXPECT_STREQ(p.getTagName(), "v2.4.1"); + EXPECT_FALSE(p.foundFirmware()); } -void testTruncatedAfterFirmwareName() { - printf("testTruncatedAfterFirmwareName...\n"); - +TEST(ReleaseJsonParser, TruncatedAfterFirmwareName) { // Found the name but connection dropped before URL/size const char* json = R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_dow)"; ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(!p.foundFirmware()); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundTag()); + EXPECT_FALSE(p.foundFirmware()); } -void testTruncatedRealisticJson() { - printf("testTruncatedRealisticJson...\n"); - - // Truncate the realistic JSON at various points; none should crash +TEST(ReleaseJsonParser, TruncatedRealisticJson) { std::string full(kRealisticPretty); for (size_t cutPoint : {10u, 50u, 100u, 200u, 500u, 1000u, 1500u, 2000u}) { if (cutPoint >= full.size()) continue; ReleaseJsonParser p; p.feed(full.c_str(), cutPoint); - // Just verify no crash; results depend on where we cut (void)p.foundTag(); (void)p.foundFirmware(); } - - printf(" passed (no crashes on truncated realistic JSON)\n"); - PASS(); + SUCCEED(); } -void testNestedObjectsInAsset() { - printf("testNestedObjectsInAsset...\n"); - +TEST(ReleaseJsonParser, NestedObjectsInAsset) { // Asset with deeply nested "uploader" object -- should not confuse depth tracking const char* json = R"({ "tag_name": "v5.0", @@ -566,17 +428,12 @@ void testNestedObjectsInAsset() { ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw5.bin"); - ASSERT_EQ(p.getFirmwareSize(), 8888u); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_STREQ(p.getFirmwareUrl(), "https://example.com/fw5.bin"); + EXPECT_EQ(p.getFirmwareSize(), 8888u); } -void testNestedObjectsAtTopLevel() { - printf("testNestedObjectsAtTopLevel...\n"); - +TEST(ReleaseJsonParser, NestedObjectsAtTopLevel) { // Multiple nested objects at the top level before/after tag_name and assets const char* json = R"({ "author": {"login": "dev", "id": 1, "nested": {"deep": true}}, @@ -589,18 +446,13 @@ void testNestedObjectsAtTopLevel() { ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getTagName(), "v6.0"); - ASSERT_EQ(p.getFirmwareSize(), 1111u); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundTag()); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_STREQ(p.getTagName(), "v6.0"); + EXPECT_EQ(p.getFirmwareSize(), 1111u); } -void testArraysAtTopLevel() { - printf("testArraysAtTopLevel...\n"); - +TEST(ReleaseJsonParser, ArraysAtTopLevel) { // A non-assets array at the top level should not interfere const char* json = R"({ "tag_name": "v7.0", @@ -611,69 +463,53 @@ void testArraysAtTopLevel() { ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getTagName(), "v7.0"); - ASSERT_EQ(p.getFirmwareSize(), 7070u); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundTag()); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_STREQ(p.getTagName(), "v7.0"); + EXPECT_EQ(p.getFirmwareSize(), 7070u); } -void testResetAndReuse() { - printf("testResetAndReuse...\n"); - +TEST(ReleaseJsonParser, ResetAndReuse) { ReleaseJsonParser p; const char* json1 = R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://a","size":1}]})"; p.feed(json1, strlen(json1)); - ASSERT_TRUE(p.foundTag()); - ASSERT_STREQ(p.getTagName(), "v1.0"); - ASSERT_STREQ(p.getFirmwareUrl(), "https://a"); - ASSERT_EQ(p.getFirmwareSize(), 1u); + EXPECT_TRUE(p.foundTag()); + EXPECT_STREQ(p.getTagName(), "v1.0"); + EXPECT_STREQ(p.getFirmwareUrl(), "https://a"); + EXPECT_EQ(p.getFirmwareSize(), 1u); p.reset(); - // Second document with different values const char* json2 = R"({"tag_name":"v2.0","assets":[{"name":"firmware.bin","browser_download_url":"https://b","size":2}]})"; p.feed(json2, strlen(json2)); - ASSERT_TRUE(p.foundTag()); - ASSERT_STREQ(p.getTagName(), "v2.0"); - ASSERT_STREQ(p.getFirmwareUrl(), "https://b"); - ASSERT_EQ(p.getFirmwareSize(), 2u); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundTag()); + EXPECT_STREQ(p.getTagName(), "v2.0"); + EXPECT_STREQ(p.getFirmwareUrl(), "https://b"); + EXPECT_EQ(p.getFirmwareSize(), 2u); } -void testResetClearsState() { - printf("testResetClearsState...\n"); - +TEST(ReleaseJsonParser, ResetClearsState) { ReleaseJsonParser p; const char* json = R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://a","size":100}]})"; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(p.foundFirmware()); + EXPECT_TRUE(p.foundTag()); + EXPECT_TRUE(p.foundFirmware()); p.reset(); - ASSERT_TRUE(!p.foundTag()); - ASSERT_TRUE(!p.foundFirmware()); - ASSERT_STREQ(p.getTagName(), ""); - ASSERT_STREQ(p.getFirmwareUrl(), ""); - ASSERT_EQ(p.getFirmwareSize(), 0u); - - printf(" passed\n"); - PASS(); + EXPECT_FALSE(p.foundTag()); + EXPECT_FALSE(p.foundFirmware()); + EXPECT_STREQ(p.getTagName(), ""); + EXPECT_STREQ(p.getFirmwareUrl(), ""); + EXPECT_EQ(p.getFirmwareSize(), 0u); } -void testPartialAssetNameMatch() { - printf("testPartialAssetNameMatch...\n"); - +TEST(ReleaseJsonParser, PartialAssetNameMatch) { // "firmware.bin.bak" should NOT match "firmware.bin" const char* json = R"({ "tag_name": "v1.0", @@ -686,16 +522,11 @@ void testPartialAssetNameMatch() { ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(!p.foundFirmware()); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundTag()); + EXPECT_FALSE(p.foundFirmware()); } -void testFirmwareBinExactMatch() { - printf("testFirmwareBinExactMatch...\n"); - +TEST(ReleaseJsonParser, FirmwareBinExactMatch) { // Only exact "firmware.bin" matches, not similar names const char* json = R"({ "tag_name": "v1.0", @@ -709,17 +540,12 @@ void testFirmwareBinExactMatch() { ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getFirmwareUrl(), "https://exact"); - ASSERT_EQ(p.getFirmwareSize(), 200u); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_STREQ(p.getFirmwareUrl(), "https://exact"); + EXPECT_EQ(p.getFirmwareSize(), 200u); } -void testLargeSize() { - printf("testLargeSize...\n"); - +TEST(ReleaseJsonParser, LargeSize) { // 16MB firmware (maximum flash size) const char* json = R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://fw","size":16777216}]})"; @@ -727,49 +553,34 @@ void testLargeSize() { ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_EQ(p.getFirmwareSize(), 16777216u); - - printf(" passed\n"); - PASS(); + EXPECT_EQ(p.getFirmwareSize(), 16777216u); } -void testSizeZero() { - printf("testSizeZero...\n"); - +TEST(ReleaseJsonParser, SizeZero) { const char* json = R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://fw","size":0}]})"; ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_EQ(p.getFirmwareSize(), 0u); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_EQ(p.getFirmwareSize(), 0u); } -void testMinimalValidJson() { - printf("testMinimalValidJson...\n"); - +TEST(ReleaseJsonParser, MinimalValidJson) { const char* json = R"({"tag_name":"v0","assets":[{"name":"firmware.bin","browser_download_url":"u","size":1}]})"; ReleaseJsonParser p; p.feed(json, strlen(json)); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getTagName(), "v0"); - ASSERT_STREQ(p.getFirmwareUrl(), "u"); - ASSERT_EQ(p.getFirmwareSize(), 1u); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(p.foundTag()); + EXPECT_TRUE(p.foundFirmware()); + EXPECT_STREQ(p.getTagName(), "v0"); + EXPECT_STREQ(p.getFirmwareUrl(), "u"); + EXPECT_EQ(p.getFirmwareSize(), 1u); } -void testChunkedRealisticEveryBoundary() { - printf("testChunkedRealisticEveryBoundary...\n"); - +TEST(ReleaseJsonParser, ChunkedRealisticEveryBoundary) { // Two-chunk split at every byte boundary on a compact JSON const char* json = R"({"tag_name":"v2.0","assets":[{"name":"firmware.bin","browser_download_url":"https://example.com/fw","size":9999}]})"; @@ -780,54 +591,10 @@ void testChunkedRealisticEveryBoundary() { if (split > 0) p.feed(json, split); if (split < len) p.feed(json + split, len - split); - ASSERT_TRUE(p.foundTag()); - ASSERT_TRUE(p.foundFirmware()); - ASSERT_STREQ(p.getTagName(), "v2.0"); - ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw"); - ASSERT_EQ(p.getFirmwareSize(), 9999u); + EXPECT_TRUE(p.foundTag()) << "split=" << split; + EXPECT_TRUE(p.foundFirmware()) << "split=" << split; + EXPECT_STREQ(p.getTagName(), "v2.0") << "split=" << split; + EXPECT_STREQ(p.getFirmwareUrl(), "https://example.com/fw") << "split=" << split; + EXPECT_EQ(p.getFirmwareSize(), 9999u) << "split=" << split; } - - printf(" passed (all %zu split points)\n", len + 1); - PASS(); -} - -// ============================================================================ - -int main() { - printf("=== ReleaseJsonParser Tests ===\n\n"); - - testRealisticPrettyPrinted(); - testRealisticMinified(); - testPrettyAndMinifiedAgree(); - testFirmwareNotFirstAsset(); - testFieldOrderUrlBeforeName(); - testFieldOrderSizeBeforeUrl(); - testFieldOrderNameFirst(); - testAssetsBeforeTagName(); - testChunkedFeedingRealisticSmallChunks(); - testChunkedFeedingByteByByte(); - testChunkedFeedingVariousChunkSizes(); - testMissingTagName(); - testMissingFirmwareBinAsset(); - testEmptyAssetsArray(); - testNoAssetsKey(); - testTruncatedBeforeTagValue(); - testTruncatedInsideTagValue(); - testTruncatedInsideAssetsArray(); - testTruncatedAfterFirmwareName(); - testTruncatedRealisticJson(); - testNestedObjectsInAsset(); - testNestedObjectsAtTopLevel(); - testArraysAtTopLevel(); - testResetAndReuse(); - testResetClearsState(); - testPartialAssetNameMatch(); - testFirmwareBinExactMatch(); - testLargeSize(); - testSizeZero(); - testMinimalValidJson(); - testChunkedRealisticEveryBoundary(); - - printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed); - return testsFailed > 0 ? 1 : 0; } diff --git a/test/run_differential_rounding_test.sh b/test/run_differential_rounding_test.sh deleted file mode 100755 index dd7aa5c7a8..0000000000 --- a/test/run_differential_rounding_test.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BUILD_DIR="$ROOT_DIR/build/differential_rounding" -BINARY="$BUILD_DIR/DifferentialRoundingTest" - -mkdir -p "$BUILD_DIR" - -SOURCES=( - "$ROOT_DIR/test/differential_rounding/DifferentialRoundingTest.cpp" - "$ROOT_DIR/lib/EpdFont/EpdFont.cpp" - "$ROOT_DIR/lib/Utf8/Utf8.cpp" -) - -CXXFLAGS=( - -std=c++20 - -O2 - -Wall - -Wextra - -pedantic - -I"$ROOT_DIR" - -I"$ROOT_DIR/lib" - -I"$ROOT_DIR/lib/EpdFont" - -I"$ROOT_DIR/lib/Utf8" -) - -c++ "${CXXFLAGS[@]}" "${SOURCES[@]}" -o "$BINARY" - -"$BINARY" "$@" diff --git a/test/run_hyphenation_eval.sh b/test/run_hyphenation_eval.sh deleted file mode 100755 index 0d4bba3d95..0000000000 --- a/test/run_hyphenation_eval.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BUILD_DIR="$ROOT_DIR/build/hyphenation_eval" -BINARY="$BUILD_DIR/HyphenationEvaluationTest" - -mkdir -p "$BUILD_DIR" - -SOURCES=( - "$ROOT_DIR/test/hyphenation_eval/HyphenationEvaluationTest.cpp" - "$ROOT_DIR/lib/Epub/Epub/hyphenation/Hyphenator.cpp" - "$ROOT_DIR/lib/Epub/Epub/hyphenation/LanguageRegistry.cpp" - "$ROOT_DIR/lib/Epub/Epub/hyphenation/LiangHyphenation.cpp" - "$ROOT_DIR/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp" - "$ROOT_DIR/lib/Utf8/Utf8.cpp" -) - -CXXFLAGS=( - -std=c++20 - -O2 - -Wall - -Wextra - -pedantic - -I"$ROOT_DIR" - -I"$ROOT_DIR/lib" - -I"$ROOT_DIR/lib/Utf8" -) - -c++ "${CXXFLAGS[@]}" "${SOURCES[@]}" -o "$BINARY" - -"$BINARY" "$@" diff --git a/test/run_release_json_parser_test.sh b/test/run_release_json_parser_test.sh deleted file mode 100755 index 1b2124457e..0000000000 --- a/test/run_release_json_parser_test.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BUILD_DIR="$ROOT_DIR/build/release_json_parser" -BINARY="$BUILD_DIR/ReleaseJsonParserTest" - -mkdir -p "$BUILD_DIR" - -SOURCES=( - "$ROOT_DIR/test/release_json_parser/ReleaseJsonParserTest.cpp" - "$ROOT_DIR/lib/JsonParser/ReleaseJsonParser.cpp" - "$ROOT_DIR/lib/JsonParser/StreamingJsonParser.cpp" -) - -CXXFLAGS=( - -std=c++20 - -O2 - -Wall - -Wextra - -pedantic - -I"$ROOT_DIR" - -I"$ROOT_DIR/lib" - -I"$ROOT_DIR/lib/JsonParser" -) - -c++ "${CXXFLAGS[@]}" "${SOURCES[@]}" -o "$BINARY" - -"$BINARY" "$@" diff --git a/test/run_streaming_json_parser_test.sh b/test/run_streaming_json_parser_test.sh deleted file mode 100755 index 90e051c411..0000000000 --- a/test/run_streaming_json_parser_test.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BUILD_DIR="$ROOT_DIR/build/streaming_json_parser" -BINARY="$BUILD_DIR/StreamingJsonParserTest" - -mkdir -p "$BUILD_DIR" - -SOURCES=( - "$ROOT_DIR/test/streaming_json_parser/StreamingJsonParserTest.cpp" - "$ROOT_DIR/lib/JsonParser/StreamingJsonParser.cpp" -) - -CXXFLAGS=( - -std=c++20 - -O2 - -Wall - -Wextra - -pedantic - -I"$ROOT_DIR" - -I"$ROOT_DIR/lib" - -I"$ROOT_DIR/lib/JsonParser" -) - -c++ "${CXXFLAGS[@]}" "${SOURCES[@]}" -o "$BINARY" - -"$BINARY" "$@" diff --git a/test/streaming_json_parser/CMakeLists.txt b/test/streaming_json_parser/CMakeLists.txt new file mode 100644 index 0000000000..7675ad3eda --- /dev/null +++ b/test/streaming_json_parser/CMakeLists.txt @@ -0,0 +1,15 @@ +add_executable(StreamingJsonParserTest + StreamingJsonParserTest.cpp + ${REPO_ROOT}/lib/JsonParser/StreamingJsonParser.cpp +) + +target_include_directories(StreamingJsonParserTest PRIVATE + ${REPO_ROOT}/lib/JsonParser +) + +target_link_libraries(StreamingJsonParserTest PRIVATE + crosspoint_test_common + GTest::gtest_main +) + +gtest_discover_tests(StreamingJsonParserTest) diff --git a/test/streaming_json_parser/StreamingJsonParserTest.cpp b/test/streaming_json_parser/StreamingJsonParserTest.cpp index 55b3a12a08..bf9832951a 100644 --- a/test/streaming_json_parser/StreamingJsonParserTest.cpp +++ b/test/streaming_json_parser/StreamingJsonParserTest.cpp @@ -1,37 +1,13 @@ -#include -#include +#include + #include #include #include #include "lib/JsonParser/StreamingJsonParser.h" -static int testsPassed = 0; -static int testsFailed = 0; - -#define ASSERT_EQ(a, b) \ - do { \ - auto _a = (a); \ - auto _b = (b); \ - if (_a != _b) { \ - fprintf(stderr, " FAIL: %s:%d: %s != expected\n", __FILE__, __LINE__, #a); \ - testsFailed++; \ - return; \ - } \ - } while (0) - -#define ASSERT_TRUE(cond) \ - do { \ - if (!(cond)) { \ - fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, #cond); \ - testsFailed++; \ - return; \ - } \ - } while (0) - -#define PASS() testsPassed++ - -// Event types for recording callback sequences +namespace { + enum class EventType { KEY, STRING, @@ -54,40 +30,36 @@ struct TestContext { std::vector events; }; -static void onKey(void* ctx, const char* key, size_t len) { +void onKey(void* ctx, const char* key, size_t len) { static_cast(ctx)->events.push_back({EventType::KEY, std::string(key, len)}); } -static void onString(void* ctx, const char* value, size_t len) { +void onString(void* ctx, const char* value, size_t len) { static_cast(ctx)->events.push_back({EventType::STRING, std::string(value, len)}); } -static void onNumber(void* ctx, const char* value, size_t len) { +void onNumber(void* ctx, const char* value, size_t len) { static_cast(ctx)->events.push_back({EventType::NUMBER, std::string(value, len)}); } -static void onBool(void* ctx, bool value) { +void onBool(void* ctx, bool value) { static_cast(ctx)->events.push_back({value ? EventType::BOOL_TRUE : EventType::BOOL_FALSE, {}}); } -static void onNull(void* ctx) { static_cast(ctx)->events.push_back({EventType::NULL_VAL, {}}); } -static void onObjectStart(void* ctx) { - static_cast(ctx)->events.push_back({EventType::OBJECT_START, {}}); -} -static void onObjectEnd(void* ctx) { static_cast(ctx)->events.push_back({EventType::OBJECT_END, {}}); } -static void onArrayStart(void* ctx) { static_cast(ctx)->events.push_back({EventType::ARRAY_START, {}}); } -static void onArrayEnd(void* ctx) { static_cast(ctx)->events.push_back({EventType::ARRAY_END, {}}); } +void onNull(void* ctx) { static_cast(ctx)->events.push_back({EventType::NULL_VAL, {}}); } +void onObjectStart(void* ctx) { static_cast(ctx)->events.push_back({EventType::OBJECT_START, {}}); } +void onObjectEnd(void* ctx) { static_cast(ctx)->events.push_back({EventType::OBJECT_END, {}}); } +void onArrayStart(void* ctx) { static_cast(ctx)->events.push_back({EventType::ARRAY_START, {}}); } +void onArrayEnd(void* ctx) { static_cast(ctx)->events.push_back({EventType::ARRAY_END, {}}); } -static JsonCallbacks makeCallbacks(TestContext* ctx) { +JsonCallbacks makeCallbacks(TestContext* ctx) { return {ctx, onKey, onString, onNumber, onBool, onNull, onObjectStart, onObjectEnd, onArrayStart, onArrayEnd}; } -// Feed entire input at once -static std::vector parse(const char* json) { +std::vector parse(const char* json) { TestContext ctx; StreamingJsonParser parser(makeCallbacks(&ctx)); parser.feed(json, strlen(json)); return ctx.events; } -// Feed input one byte at a time -static std::vector parseBytewise(const char* json) { +std::vector parseBytewise(const char* json) { TestContext ctx; StreamingJsonParser parser(makeCallbacks(&ctx)); size_t len = strlen(json); @@ -97,176 +69,132 @@ static std::vector parseBytewise(const char* json) { return ctx.events; } -// ============================================================================ -// Tests -// ============================================================================ - -void testSimpleObject() { - printf("testSimpleObject...\n"); +} // namespace +TEST(StreamingJsonParser, SimpleObject) { auto events = parse(R"({"key": "value", "num": 42})"); ASSERT_EQ(events.size(), 6u); - ASSERT_EQ(events[0].type, EventType::OBJECT_START); - ASSERT_EQ(events[1].type, EventType::KEY); - ASSERT_EQ(events[1].value, "key"); - ASSERT_EQ(events[2].type, EventType::STRING); - ASSERT_EQ(events[2].value, "value"); - ASSERT_EQ(events[3].type, EventType::KEY); - ASSERT_EQ(events[3].value, "num"); - ASSERT_EQ(events[4].type, EventType::NUMBER); - ASSERT_EQ(events[4].value, "42"); - ASSERT_EQ(events[5].type, EventType::OBJECT_END); - - printf(" passed\n"); - PASS(); -} - -void testNestedObjects() { - printf("testNestedObjects...\n"); - + EXPECT_EQ(events[0].type, EventType::OBJECT_START); + EXPECT_EQ(events[1].type, EventType::KEY); + EXPECT_EQ(events[1].value, "key"); + EXPECT_EQ(events[2].type, EventType::STRING); + EXPECT_EQ(events[2].value, "value"); + EXPECT_EQ(events[3].type, EventType::KEY); + EXPECT_EQ(events[3].value, "num"); + EXPECT_EQ(events[4].type, EventType::NUMBER); + EXPECT_EQ(events[4].value, "42"); + EXPECT_EQ(events[5].type, EventType::OBJECT_END); +} + +TEST(StreamingJsonParser, NestedObjects) { auto events = parse(R"({"a": {"b": "c"}})"); ASSERT_EQ(events.size(), 7u); - ASSERT_EQ(events[0].type, EventType::OBJECT_START); - ASSERT_EQ(events[1].type, EventType::KEY); - ASSERT_EQ(events[1].value, "a"); - ASSERT_EQ(events[2].type, EventType::OBJECT_START); - ASSERT_EQ(events[3].type, EventType::KEY); - ASSERT_EQ(events[3].value, "b"); - ASSERT_EQ(events[4].type, EventType::STRING); - ASSERT_EQ(events[4].value, "c"); - ASSERT_EQ(events[5].type, EventType::OBJECT_END); - ASSERT_EQ(events[6].type, EventType::OBJECT_END); - - printf(" passed\n"); - PASS(); -} - -void testArrayOfValues() { - printf("testArrayOfValues...\n"); - + EXPECT_EQ(events[0].type, EventType::OBJECT_START); + EXPECT_EQ(events[1].type, EventType::KEY); + EXPECT_EQ(events[1].value, "a"); + EXPECT_EQ(events[2].type, EventType::OBJECT_START); + EXPECT_EQ(events[3].type, EventType::KEY); + EXPECT_EQ(events[3].value, "b"); + EXPECT_EQ(events[4].type, EventType::STRING); + EXPECT_EQ(events[4].value, "c"); + EXPECT_EQ(events[5].type, EventType::OBJECT_END); + EXPECT_EQ(events[6].type, EventType::OBJECT_END); +} + +TEST(StreamingJsonParser, ArrayOfValues) { auto events = parse(R"({"items": [1, "two", true, false, null]})"); ASSERT_EQ(events.size(), 10u); - ASSERT_EQ(events[0].type, EventType::OBJECT_START); - ASSERT_EQ(events[1].type, EventType::KEY); - ASSERT_EQ(events[1].value, "items"); - ASSERT_EQ(events[2].type, EventType::ARRAY_START); - ASSERT_EQ(events[3].type, EventType::NUMBER); - ASSERT_EQ(events[3].value, "1"); - ASSERT_EQ(events[4].type, EventType::STRING); - ASSERT_EQ(events[4].value, "two"); - ASSERT_EQ(events[5].type, EventType::BOOL_TRUE); - ASSERT_EQ(events[6].type, EventType::BOOL_FALSE); - ASSERT_EQ(events[7].type, EventType::NULL_VAL); - ASSERT_EQ(events[8].type, EventType::ARRAY_END); - ASSERT_EQ(events[9].type, EventType::OBJECT_END); - - printf(" passed\n"); - PASS(); -} - -void testArrayOfObjects() { - printf("testArrayOfObjects...\n"); - + EXPECT_EQ(events[0].type, EventType::OBJECT_START); + EXPECT_EQ(events[1].type, EventType::KEY); + EXPECT_EQ(events[1].value, "items"); + EXPECT_EQ(events[2].type, EventType::ARRAY_START); + EXPECT_EQ(events[3].type, EventType::NUMBER); + EXPECT_EQ(events[3].value, "1"); + EXPECT_EQ(events[4].type, EventType::STRING); + EXPECT_EQ(events[4].value, "two"); + EXPECT_EQ(events[5].type, EventType::BOOL_TRUE); + EXPECT_EQ(events[6].type, EventType::BOOL_FALSE); + EXPECT_EQ(events[7].type, EventType::NULL_VAL); + EXPECT_EQ(events[8].type, EventType::ARRAY_END); + EXPECT_EQ(events[9].type, EventType::OBJECT_END); +} + +TEST(StreamingJsonParser, ArrayOfObjects) { auto events = parse(R"([{"a": 1}, {"b": 2}])"); ASSERT_EQ(events.size(), 10u); - ASSERT_EQ(events[0].type, EventType::ARRAY_START); - ASSERT_EQ(events[1].type, EventType::OBJECT_START); - ASSERT_EQ(events[2].type, EventType::KEY); - ASSERT_EQ(events[2].value, "a"); - ASSERT_EQ(events[3].type, EventType::NUMBER); - ASSERT_EQ(events[3].value, "1"); - ASSERT_EQ(events[4].type, EventType::OBJECT_END); - ASSERT_EQ(events[5].type, EventType::OBJECT_START); - ASSERT_EQ(events[6].type, EventType::KEY); - ASSERT_EQ(events[6].value, "b"); - ASSERT_EQ(events[7].type, EventType::NUMBER); - ASSERT_EQ(events[7].value, "2"); - ASSERT_EQ(events[8].type, EventType::OBJECT_END); - ASSERT_EQ(events[9].type, EventType::ARRAY_END); - - printf(" passed\n"); - PASS(); -} - -void testStringEscapes() { - printf("testStringEscapes...\n"); - + EXPECT_EQ(events[0].type, EventType::ARRAY_START); + EXPECT_EQ(events[1].type, EventType::OBJECT_START); + EXPECT_EQ(events[2].type, EventType::KEY); + EXPECT_EQ(events[2].value, "a"); + EXPECT_EQ(events[3].type, EventType::NUMBER); + EXPECT_EQ(events[3].value, "1"); + EXPECT_EQ(events[4].type, EventType::OBJECT_END); + EXPECT_EQ(events[5].type, EventType::OBJECT_START); + EXPECT_EQ(events[6].type, EventType::KEY); + EXPECT_EQ(events[6].value, "b"); + EXPECT_EQ(events[7].type, EventType::NUMBER); + EXPECT_EQ(events[7].value, "2"); + EXPECT_EQ(events[8].type, EventType::OBJECT_END); + EXPECT_EQ(events[9].type, EventType::ARRAY_END); +} + +TEST(StreamingJsonParser, StringEscapes) { auto events = parse(R"({"esc": "a\"b\\c\/d\ne\tf"})"); ASSERT_EQ(events.size(), 4u); - ASSERT_EQ(events[2].type, EventType::STRING); - ASSERT_EQ(events[2].value, std::string("a\"b\\c/d\ne\tf")); - - printf(" passed\n"); - PASS(); + EXPECT_EQ(events[2].type, EventType::STRING); + EXPECT_EQ(events[2].value, std::string("a\"b\\c/d\ne\tf")); } -void testUnicodeEscapePassthrough() { - printf("testUnicodeEscapePassthrough...\n"); - +TEST(StreamingJsonParser, UnicodeEscapePassthrough) { auto events = parse(R"({"u": "\u0041\u0042"})"); - ASSERT_EQ(events[2].type, EventType::STRING); + ASSERT_EQ(events.size(), 4u); + EXPECT_EQ(events[2].type, EventType::STRING); // \uXXXX passed through as literal \u followed by the hex digits - ASSERT_EQ(events[2].value, "\\u0041\\u0042"); - - printf(" passed\n"); - PASS(); + EXPECT_EQ(events[2].value, "\\u0041\\u0042"); } -void testNumbers() { - printf("testNumbers...\n"); - +TEST(StreamingJsonParser, Numbers) { auto events = parse(R"({"int": 42, "neg": -7, "flt": 3.14, "exp": 1e10, "nexp": -2.5E-3})"); - ASSERT_EQ(events[2].type, EventType::NUMBER); - ASSERT_EQ(events[2].value, "42"); - ASSERT_EQ(events[4].type, EventType::NUMBER); - ASSERT_EQ(events[4].value, "-7"); - ASSERT_EQ(events[6].type, EventType::NUMBER); - ASSERT_EQ(events[6].value, "3.14"); - ASSERT_EQ(events[8].type, EventType::NUMBER); - ASSERT_EQ(events[8].value, "1e10"); - ASSERT_EQ(events[10].type, EventType::NUMBER); - ASSERT_EQ(events[10].value, "-2.5E-3"); - - printf(" passed\n"); - PASS(); + ASSERT_EQ(events.size(), 12u); + EXPECT_EQ(events[2].type, EventType::NUMBER); + EXPECT_EQ(events[2].value, "42"); + EXPECT_EQ(events[4].type, EventType::NUMBER); + EXPECT_EQ(events[4].value, "-7"); + EXPECT_EQ(events[6].type, EventType::NUMBER); + EXPECT_EQ(events[6].value, "3.14"); + EXPECT_EQ(events[8].type, EventType::NUMBER); + EXPECT_EQ(events[8].value, "1e10"); + EXPECT_EQ(events[10].type, EventType::NUMBER); + EXPECT_EQ(events[10].value, "-2.5E-3"); } -void testBooleansAndNull() { - printf("testBooleansAndNull...\n"); - +TEST(StreamingJsonParser, BooleansAndNull) { auto events = parse(R"({"t": true, "f": false, "n": null})"); - ASSERT_EQ(events[2].type, EventType::BOOL_TRUE); - ASSERT_EQ(events[4].type, EventType::BOOL_FALSE); - ASSERT_EQ(events[6].type, EventType::NULL_VAL); - - printf(" passed\n"); - PASS(); + ASSERT_EQ(events.size(), 8u); + EXPECT_EQ(events[2].type, EventType::BOOL_TRUE); + EXPECT_EQ(events[4].type, EventType::BOOL_FALSE); + EXPECT_EQ(events[6].type, EventType::NULL_VAL); } -void testChunkedFeeding() { - printf("testChunkedFeeding...\n"); - +TEST(StreamingJsonParser, ChunkedFeeding) { const char* json = R"({"key": "value", "num": 42, "arr": [1, 2]})"; auto reference = parse(json); - // Feed byte-by-byte and verify identical event sequence auto bytewise = parseBytewise(json); - ASSERT_EQ(bytewise.size(), reference.size()); for (size_t i = 0; i < reference.size(); ++i) { - ASSERT_EQ(bytewise[i].type, reference[i].type); - ASSERT_EQ(bytewise[i].value, reference[i].value); + EXPECT_EQ(bytewise[i].type, reference[i].type); + EXPECT_EQ(bytewise[i].value, reference[i].value); } - // Feed in chunks of varying size for (size_t chunkSize = 2; chunkSize <= 7; ++chunkSize) { TestContext ctx; StreamingJsonParser parser(makeCallbacks(&ctx)); @@ -277,20 +205,15 @@ void testChunkedFeeding() { parser.feed(json + offset, feedLen); } - ASSERT_EQ(ctx.events.size(), reference.size()); + ASSERT_EQ(ctx.events.size(), reference.size()) << "chunkSize=" << chunkSize; for (size_t i = 0; i < reference.size(); ++i) { - ASSERT_EQ(ctx.events[i].type, reference[i].type); - ASSERT_EQ(ctx.events[i].value, reference[i].value); + EXPECT_EQ(ctx.events[i].type, reference[i].type) << "chunkSize=" << chunkSize << " event=" << i; + EXPECT_EQ(ctx.events[i].value, reference[i].value) << "chunkSize=" << chunkSize << " event=" << i; } } - - printf(" passed (byte-by-byte + chunk sizes 2-7)\n"); - PASS(); } -void testEveryByteBoundary() { - printf("testEveryByteBoundary...\n"); - +TEST(StreamingJsonParser, EveryByteBoundary) { const char* json = R"({"tag_name":"v1.2.3","assets":[{"name":"firmware.bin","size":12345}]})"; auto reference = parse(json); size_t len = strlen(json); @@ -301,137 +224,94 @@ void testEveryByteBoundary() { if (split > 0) parser.feed(json, split); if (split < len) parser.feed(json + split, len - split); - ASSERT_EQ(ctx.events.size(), reference.size()); + ASSERT_EQ(ctx.events.size(), reference.size()) << "split=" << split; for (size_t i = 0; i < reference.size(); ++i) { - if (ctx.events[i].type != reference[i].type || ctx.events[i].value != reference[i].value) { - fprintf(stderr, " FAIL at split=%zu, event %zu\n", split, i); - testsFailed++; - return; - } + EXPECT_EQ(ctx.events[i].type, reference[i].type) << "split=" << split << " event=" << i; + EXPECT_EQ(ctx.events[i].value, reference[i].value) << "split=" << split << " event=" << i; } } - - printf(" passed (all %zu split points)\n", len + 1); - PASS(); } -void testLargeTokenTruncation() { - printf("testLargeTokenTruncation...\n"); - +TEST(StreamingJsonParser, LargeTokenTruncation) { // Build a string value that exceeds TOKEN_BUF_SIZE std::string longVal(StreamingJsonParser::TOKEN_BUF_SIZE + 100, 'x'); std::string json = R"({"short": "ok", "long": ")" + longVal + R"("})"; auto events = parse(json.c_str()); - // "short" key + "ok" value should still fire - ASSERT_TRUE(events.size() >= 3); - ASSERT_EQ(events[1].type, EventType::KEY); - ASSERT_EQ(events[1].value, "short"); - ASSERT_EQ(events[2].type, EventType::STRING); - ASSERT_EQ(events[2].value, "ok"); + ASSERT_GE(events.size(), 3u); + EXPECT_EQ(events[1].type, EventType::KEY); + EXPECT_EQ(events[1].value, "short"); + EXPECT_EQ(events[2].type, EventType::STRING); + EXPECT_EQ(events[2].value, "ok"); - // The "long" key fires, but the oversized value is silently dropped bool foundLongKey = false; bool foundLongValue = false; for (auto& e : events) { if (e.type == EventType::KEY && e.value == "long") foundLongKey = true; if (e.type == EventType::STRING && e.value.size() > 500) foundLongValue = true; } - ASSERT_TRUE(foundLongKey); - ASSERT_TRUE(!foundLongValue); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(foundLongKey); + EXPECT_FALSE(foundLongValue); } -void testEmptyObject() { - printf("testEmptyObject...\n"); - +TEST(StreamingJsonParser, EmptyObject) { auto events = parse("{}"); ASSERT_EQ(events.size(), 2u); - ASSERT_EQ(events[0].type, EventType::OBJECT_START); - ASSERT_EQ(events[1].type, EventType::OBJECT_END); - - printf(" passed\n"); - PASS(); + EXPECT_EQ(events[0].type, EventType::OBJECT_START); + EXPECT_EQ(events[1].type, EventType::OBJECT_END); } -void testEmptyArray() { - printf("testEmptyArray...\n"); - +TEST(StreamingJsonParser, EmptyArray) { auto events = parse("[]"); ASSERT_EQ(events.size(), 2u); - ASSERT_EQ(events[0].type, EventType::ARRAY_START); - ASSERT_EQ(events[1].type, EventType::ARRAY_END); - - printf(" passed\n"); - PASS(); + EXPECT_EQ(events[0].type, EventType::ARRAY_START); + EXPECT_EQ(events[1].type, EventType::ARRAY_END); } -void testNestedArrays() { - printf("testNestedArrays...\n"); - +TEST(StreamingJsonParser, NestedArrays) { auto events = parse("[[1, 2], [3]]"); ASSERT_EQ(events.size(), 9u); - ASSERT_EQ(events[0].type, EventType::ARRAY_START); - ASSERT_EQ(events[1].type, EventType::ARRAY_START); - ASSERT_EQ(events[2].type, EventType::NUMBER); - ASSERT_EQ(events[2].value, "1"); - ASSERT_EQ(events[3].type, EventType::NUMBER); - ASSERT_EQ(events[3].value, "2"); - ASSERT_EQ(events[4].type, EventType::ARRAY_END); - ASSERT_EQ(events[5].type, EventType::ARRAY_START); - ASSERT_EQ(events[6].type, EventType::NUMBER); - ASSERT_EQ(events[6].value, "3"); - ASSERT_EQ(events[7].type, EventType::ARRAY_END); - ASSERT_EQ(events[8].type, EventType::ARRAY_END); - - printf(" passed\n"); - PASS(); -} - -void testTopLevelArray() { - printf("testTopLevelArray...\n"); - + EXPECT_EQ(events[0].type, EventType::ARRAY_START); + EXPECT_EQ(events[1].type, EventType::ARRAY_START); + EXPECT_EQ(events[2].type, EventType::NUMBER); + EXPECT_EQ(events[2].value, "1"); + EXPECT_EQ(events[3].type, EventType::NUMBER); + EXPECT_EQ(events[3].value, "2"); + EXPECT_EQ(events[4].type, EventType::ARRAY_END); + EXPECT_EQ(events[5].type, EventType::ARRAY_START); + EXPECT_EQ(events[6].type, EventType::NUMBER); + EXPECT_EQ(events[6].value, "3"); + EXPECT_EQ(events[7].type, EventType::ARRAY_END); + EXPECT_EQ(events[8].type, EventType::ARRAY_END); +} + +TEST(StreamingJsonParser, TopLevelArray) { auto events = parse(R"(["hello", 42, true, null])"); ASSERT_EQ(events.size(), 6u); - ASSERT_EQ(events[0].type, EventType::ARRAY_START); - ASSERT_EQ(events[1].type, EventType::STRING); - ASSERT_EQ(events[1].value, "hello"); - ASSERT_EQ(events[2].type, EventType::NUMBER); - ASSERT_EQ(events[2].value, "42"); - ASSERT_EQ(events[3].type, EventType::BOOL_TRUE); - ASSERT_EQ(events[4].type, EventType::NULL_VAL); - ASSERT_EQ(events[5].type, EventType::ARRAY_END); - - printf(" passed\n"); - PASS(); + EXPECT_EQ(events[0].type, EventType::ARRAY_START); + EXPECT_EQ(events[1].type, EventType::STRING); + EXPECT_EQ(events[1].value, "hello"); + EXPECT_EQ(events[2].type, EventType::NUMBER); + EXPECT_EQ(events[2].value, "42"); + EXPECT_EQ(events[3].type, EventType::BOOL_TRUE); + EXPECT_EQ(events[4].type, EventType::NULL_VAL); + EXPECT_EQ(events[5].type, EventType::ARRAY_END); } -void testWhitespaceVariants() { - printf("testWhitespaceVariants...\n"); - - // Minified +TEST(StreamingJsonParser, WhitespaceVariants) { auto minified = parse(R"({"a":1,"b":"x"})"); - - // Pretty-printed const char* pretty = "{\n \"a\": 1,\n \"b\": \"x\"\n}"; auto prettyEvents = parse(pretty); ASSERT_EQ(minified.size(), prettyEvents.size()); for (size_t i = 0; i < minified.size(); ++i) { - ASSERT_EQ(minified[i].type, prettyEvents[i].type); - ASSERT_EQ(minified[i].value, prettyEvents[i].value); + EXPECT_EQ(minified[i].type, prettyEvents[i].type); + EXPECT_EQ(minified[i].value, prettyEvents[i].value); } - - printf(" passed\n"); - PASS(); } -void testResetBetweenDocuments() { - printf("testResetBetweenDocuments...\n"); - +TEST(StreamingJsonParser, ResetBetweenDocuments) { TestContext ctx; StreamingJsonParser parser(makeCallbacks(&ctx)); @@ -445,52 +325,34 @@ void testResetBetweenDocuments() { const char* json2 = R"({"b": 2})"; parser.feed(json2, strlen(json2)); ASSERT_EQ(ctx.events.size(), 4u); - ASSERT_EQ(ctx.events[1].value, "b"); - ASSERT_EQ(ctx.events[2].value, "2"); - - printf(" passed\n"); - PASS(); + EXPECT_EQ(ctx.events[1].value, "b"); + EXPECT_EQ(ctx.events[2].value, "2"); } -void testNumberAtEndOfInput() { - printf("testNumberAtEndOfInput...\n"); - - // Number terminated by end of input (no trailing whitespace or structural char). - // The parser must emit the number when feed() ends (after a closing brace). +TEST(StreamingJsonParser, NumberAtEndOfInput) { auto events = parse(R"({"n": 99})"); bool found = false; for (auto& e : events) { if (e.type == EventType::NUMBER && e.value == "99") found = true; } - ASSERT_TRUE(found); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(found); } -void testArrayOfStrings() { - printf("testArrayOfStrings...\n"); - +TEST(StreamingJsonParser, ArrayOfStrings) { auto events = parse(R"(["a", "b", "c"])"); ASSERT_EQ(events.size(), 5u); - ASSERT_EQ(events[0].type, EventType::ARRAY_START); - ASSERT_EQ(events[1].type, EventType::STRING); - ASSERT_EQ(events[1].value, "a"); - ASSERT_EQ(events[2].type, EventType::STRING); - ASSERT_EQ(events[2].value, "b"); - ASSERT_EQ(events[3].type, EventType::STRING); - ASSERT_EQ(events[3].value, "c"); - ASSERT_EQ(events[4].type, EventType::ARRAY_END); - - printf(" passed\n"); - PASS(); + EXPECT_EQ(events[0].type, EventType::ARRAY_START); + EXPECT_EQ(events[1].type, EventType::STRING); + EXPECT_EQ(events[1].value, "a"); + EXPECT_EQ(events[2].type, EventType::STRING); + EXPECT_EQ(events[2].value, "b"); + EXPECT_EQ(events[3].type, EventType::STRING); + EXPECT_EQ(events[3].value, "c"); + EXPECT_EQ(events[4].type, EventType::ARRAY_END); } -void testTruncatedInputNoCrash() { - printf("testTruncatedInputNoCrash...\n"); - - // Simulates a connection drop mid-JSON. Parser must not crash. +TEST(StreamingJsonParser, TruncatedInputNoCrash) { const char* truncated[] = { R"({"key": "val)", R"({"key": )", R"({"key)", R"([1, 2, )", R"({"a": tru)", R"({"a": fal)", R"({"a": nul)", R"({"a": "hello\)", @@ -502,49 +364,36 @@ void testTruncatedInputNoCrash() { parser.feed(json, strlen(json)); // Just verify no crash; partial results are acceptable } - - printf(" passed (no crashes on %d truncated inputs)\n", 8); - PASS(); + SUCCEED(); } -void testAllEscapeSequences() { - printf("testAllEscapeSequences...\n"); - +TEST(StreamingJsonParser, AllEscapeSequences) { auto events = parse(R"({"e": "\b\f\n\r\t\"\\\/"})"); - ASSERT_EQ(events[2].type, EventType::STRING); - ASSERT_EQ(events[2].value, std::string("\b\f\n\r\t\"\\/")); - - printf(" passed\n"); - PASS(); + ASSERT_EQ(events.size(), 4u); + EXPECT_EQ(events[2].type, EventType::STRING); + EXPECT_EQ(events[2].value, std::string("\b\f\n\r\t\"\\/")); } -void testObjectInArray() { - printf("testObjectInArray...\n"); - +TEST(StreamingJsonParser, ObjectInArray) { // After an object closes inside an array, the next string after comma // should be correctly identified as a key (inside the next object) or // a string value (if directly in the array). auto events = parse(R"([{"k":"v"}, "bare"])"); ASSERT_EQ(events.size(), 7u); - ASSERT_EQ(events[0].type, EventType::ARRAY_START); - ASSERT_EQ(events[1].type, EventType::OBJECT_START); - ASSERT_EQ(events[2].type, EventType::KEY); - ASSERT_EQ(events[2].value, "k"); - ASSERT_EQ(events[3].type, EventType::STRING); - ASSERT_EQ(events[3].value, "v"); - ASSERT_EQ(events[4].type, EventType::OBJECT_END); - ASSERT_EQ(events[5].type, EventType::STRING); - ASSERT_EQ(events[5].value, "bare"); - ASSERT_EQ(events[6].type, EventType::ARRAY_END); - - printf(" passed\n"); - PASS(); -} - -void testDeeplyNested() { - printf("testDeeplyNested...\n"); - + EXPECT_EQ(events[0].type, EventType::ARRAY_START); + EXPECT_EQ(events[1].type, EventType::OBJECT_START); + EXPECT_EQ(events[2].type, EventType::KEY); + EXPECT_EQ(events[2].value, "k"); + EXPECT_EQ(events[3].type, EventType::STRING); + EXPECT_EQ(events[3].value, "v"); + EXPECT_EQ(events[4].type, EventType::OBJECT_END); + EXPECT_EQ(events[5].type, EventType::STRING); + EXPECT_EQ(events[5].value, "bare"); + EXPECT_EQ(events[6].type, EventType::ARRAY_END); +} + +TEST(StreamingJsonParser, DeeplyNested) { // 20 levels of nesting (well within MAX_NESTING=32) std::string json; for (int i = 0; i < 20; ++i) json += R"({"d":)"; @@ -555,18 +404,13 @@ void testDeeplyNested() { // 20 OBJECT_START + 20 KEY + 1 NUMBER + 20 OBJECT_END = 61 ASSERT_EQ(events.size(), 61u); - ASSERT_EQ(events[0].type, EventType::OBJECT_START); - ASSERT_EQ(events[40].type, EventType::NUMBER); - ASSERT_EQ(events[40].value, "0"); - ASSERT_EQ(events[60].type, EventType::OBJECT_END); - - printf(" passed\n"); - PASS(); + EXPECT_EQ(events[0].type, EventType::OBJECT_START); + EXPECT_EQ(events[40].type, EventType::NUMBER); + EXPECT_EQ(events[40].value, "0"); + EXPECT_EQ(events[60].type, EventType::OBJECT_END); } -void testNestingOverflow() { - printf("testNestingOverflow...\n"); - +TEST(StreamingJsonParser, NestingOverflow) { // Exceed MAX_NESTING -- parser should set error flag, not crash std::string json; for (size_t i = 0; i < StreamingJsonParser::MAX_NESTING + 5; ++i) json += "["; @@ -575,47 +419,32 @@ void testNestingOverflow() { StreamingJsonParser parser(makeCallbacks(&ctx)); parser.feed(json.c_str(), json.size()); - ASSERT_TRUE(parser.hasError()); - - printf(" passed\n"); - PASS(); + EXPECT_TRUE(parser.hasError()); } -void testNumberZero() { - printf("testNumberZero...\n"); - +TEST(StreamingJsonParser, NumberZero) { auto events = parse(R"({"z": 0})"); - ASSERT_EQ(events[2].type, EventType::NUMBER); - ASSERT_EQ(events[2].value, "0"); - - printf(" passed\n"); - PASS(); + ASSERT_EQ(events.size(), 4u); + EXPECT_EQ(events[2].type, EventType::NUMBER); + EXPECT_EQ(events[2].value, "0"); } -void testMultipleValuesInObject() { - printf("testMultipleValuesInObject...\n"); - +TEST(StreamingJsonParser, MultipleValuesInObject) { auto events = parse(R"({"a": "x", "b": "y", "c": "z"})"); ASSERT_EQ(events.size(), 8u); - ASSERT_EQ(events[1].value, "a"); - ASSERT_EQ(events[2].value, "x"); - ASSERT_EQ(events[3].value, "b"); - ASSERT_EQ(events[4].value, "y"); - ASSERT_EQ(events[5].value, "c"); - ASSERT_EQ(events[6].value, "z"); - - printf(" passed\n"); - PASS(); + EXPECT_EQ(events[1].value, "a"); + EXPECT_EQ(events[2].value, "x"); + EXPECT_EQ(events[3].value, "b"); + EXPECT_EQ(events[4].value, "y"); + EXPECT_EQ(events[5].value, "c"); + EXPECT_EQ(events[6].value, "z"); } -void testChunkedSplitInsideString() { - printf("testChunkedSplitInsideString...\n"); - +TEST(StreamingJsonParser, ChunkedSplitInsideString) { const char* json = R"({"key": "hello world"})"; auto reference = parse(json); - // Split right in the middle of "hello world" size_t splitAt = 14; // inside the string value TestContext ctx; StreamingJsonParser parser(makeCallbacks(&ctx)); @@ -624,23 +453,17 @@ void testChunkedSplitInsideString() { ASSERT_EQ(ctx.events.size(), reference.size()); for (size_t i = 0; i < reference.size(); ++i) { - ASSERT_EQ(ctx.events[i].type, reference[i].type); - ASSERT_EQ(ctx.events[i].value, reference[i].value); + EXPECT_EQ(ctx.events[i].type, reference[i].type); + EXPECT_EQ(ctx.events[i].value, reference[i].value); } - - printf(" passed\n"); - PASS(); } -void testChunkedSplitInsideEscape() { - printf("testChunkedSplitInsideEscape...\n"); - +TEST(StreamingJsonParser, ChunkedSplitInsideEscape) { const char* json = R"({"k": "a\"b"})"; auto reference = parse(json); - // Find the backslash position and split right after it const char* bs = strchr(json + 7, '\\'); - ASSERT_TRUE(bs != nullptr); + ASSERT_NE(bs, nullptr); size_t splitAt = static_cast(bs - json) + 1; // after the backslash TestContext ctx; @@ -650,22 +473,16 @@ void testChunkedSplitInsideEscape() { ASSERT_EQ(ctx.events.size(), reference.size()); for (size_t i = 0; i < reference.size(); ++i) { - ASSERT_EQ(ctx.events[i].type, reference[i].type); - ASSERT_EQ(ctx.events[i].value, reference[i].value); + EXPECT_EQ(ctx.events[i].type, reference[i].type); + EXPECT_EQ(ctx.events[i].value, reference[i].value); } - - printf(" passed\n"); - PASS(); } -void testChunkedSplitInsideLiteral() { - printf("testChunkedSplitInsideLiteral...\n"); - +TEST(StreamingJsonParser, ChunkedSplitInsideLiteral) { const char* json = R"({"a": true, "b": false, "c": null})"; auto reference = parse(json); - // Split inside "true" (at "tr|ue") - size_t splitAt = 7; + size_t splitAt = 7; // inside "true" TestContext ctx; StreamingJsonParser parser(makeCallbacks(&ctx)); parser.feed(json, splitAt); @@ -673,65 +490,17 @@ void testChunkedSplitInsideLiteral() { ASSERT_EQ(ctx.events.size(), reference.size()); for (size_t i = 0; i < reference.size(); ++i) { - ASSERT_EQ(ctx.events[i].type, reference[i].type); - ASSERT_EQ(ctx.events[i].value, reference[i].value); + EXPECT_EQ(ctx.events[i].type, reference[i].type); + EXPECT_EQ(ctx.events[i].value, reference[i].value); } - - printf(" passed\n"); - PASS(); } -void testNullCallbacksNoCrash() { - printf("testNullCallbacksNoCrash...\n"); - +TEST(StreamingJsonParser, NullCallbacksNoCrash) { JsonCallbacks nullCbs = {}; nullCbs.ctx = nullptr; StreamingJsonParser parser(nullCbs); const char* json = R"({"key": "value", "num": 42, "b": true, "n": null, "a": [1]})"; parser.feed(json, strlen(json)); - ASSERT_TRUE(!parser.hasError()); - - printf(" passed\n"); - PASS(); -} - -// ============================================================================ - -int main() { - printf("=== StreamingJsonParser Tests ===\n\n"); - - testSimpleObject(); - testNestedObjects(); - testArrayOfValues(); - testArrayOfObjects(); - testStringEscapes(); - testUnicodeEscapePassthrough(); - testNumbers(); - testBooleansAndNull(); - testChunkedFeeding(); - testEveryByteBoundary(); - testLargeTokenTruncation(); - testEmptyObject(); - testEmptyArray(); - testNestedArrays(); - testTopLevelArray(); - testWhitespaceVariants(); - testResetBetweenDocuments(); - testNumberAtEndOfInput(); - testArrayOfStrings(); - testTruncatedInputNoCrash(); - testAllEscapeSequences(); - testObjectInArray(); - testDeeplyNested(); - testNestingOverflow(); - testNumberZero(); - testMultipleValuesInObject(); - testChunkedSplitInsideString(); - testChunkedSplitInsideEscape(); - testChunkedSplitInsideLiteral(); - testNullCallbacksNoCrash(); - - printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed); - return testsFailed > 0 ? 1 : 0; + EXPECT_FALSE(parser.hasError()); } From 94d4b0c7bb292907130b8c764b182537363ba49e Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Mon, 25 May 2026 19:03:10 -0500 Subject: [PATCH 2/3] ci: Cache PlatformIO packages between runs (#2142) --- .github/workflows/ci.yml | 14 ++++++++++++++ .github/workflows/release.yml | 7 +++++++ .github/workflows/release_candidate.yml | 7 +++++++ 3 files changed, 28 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39ece4596e..cfe848fbad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,13 @@ jobs: - name: Install PlatformIO Core run: uv pip install --system -U https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip + - name: Cache PlatformIO packages + uses: actions/cache@v4 + with: + path: ~/.platformio + key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }} + restore-keys: pio-${{ runner.os }}- + - name: Run cppcheck run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high @@ -76,6 +83,13 @@ jobs: - name: Install PlatformIO Core run: uv pip install --system -U https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip + - name: Cache PlatformIO packages + uses: actions/cache@v4 + with: + path: ~/.platformio + key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }} + restore-keys: pio-${{ runner.os }}- + - name: Build CrossPoint run: | set -euo pipefail diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aae332c160..4e2ef8c75d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,13 @@ jobs: - name: Install PlatformIO Core run: uv pip install --system -U https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip + - name: Cache PlatformIO packages + uses: actions/cache@v4 + with: + path: ~/.platformio + key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }} + restore-keys: pio-${{ runner.os }}- + - name: Build CrossPoint run: pio run -e gh_release diff --git a/.github/workflows/release_candidate.yml b/.github/workflows/release_candidate.yml index ea3f10b2aa..dab341c683 100644 --- a/.github/workflows/release_candidate.yml +++ b/.github/workflows/release_candidate.yml @@ -25,6 +25,13 @@ jobs: - name: Install PlatformIO Core run: uv pip install --system -U https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip + - name: Cache PlatformIO packages + uses: actions/cache@v4 + with: + path: ~/.platformio + key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }} + restore-keys: pio-${{ runner.os }}- + - name: Extract env run: | echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV From f4e7eaa19852a05af7779a276e159de5bbb97051 Mon Sep 17 00:00:00 2001 From: Jacob Latonis Date: Mon, 25 May 2026 21:23:19 -0400 Subject: [PATCH 3/3] feat: implement CSS enumeration through OPF directory when missing from manifest (#2148) --- lib/Epub/Epub.cpp | 41 ++++++++++++++++++++++++++++++++++++- lib/Epub/Epub.h | 1 + lib/FsHelpers/FsHelpers.cpp | 2 ++ lib/FsHelpers/FsHelpers.h | 5 +++++ lib/ZipFile/ZipFile.h | 8 ++++++++ 5 files changed, 56 insertions(+), 1 deletion(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 7ca320d5f4..7cf713f635 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -255,6 +255,38 @@ bool Epub::parseTocNavFile() const { return true; } +void Epub::discoverCssFilesFromZip() { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + LOG_ERR("EBP", "Cannot discover CSS from ZIP because book metadata cache is not loaded"); + return; + } + + ZipFile zf(filepath); + + if (!zf.loadAllFileStatSlims()) { + LOG_ERR("EBP", "Failed to load ZIP file stat slims for CSS discovery"); + return; + } + + size_t lastSlash = contentBasePath.find_last_of('/'); + + std::string opfDir = (lastSlash != std::string::npos) ? contentBasePath.substr(0, lastSlash + 1) : ""; + + zf.enumerateFilePaths([&](std::string_view filePath) { + if (!opfDir.empty() && filePath.find(opfDir) != 0) { + return; // Skip files that are not in the same directory as OPF manifest, as CSS files are typically located + // there or in subfolders + } + + if (FsHelpers::hasCssExtension(filePath)) { + if (std::find(cssFiles.begin(), cssFiles.end(), filePath) == cssFiles.end()) { + LOG_DBG("EBP", "Discovered CSS file via ZIP enumeration: %.*s", (int)filePath.size(), filePath.data()); + cssFiles.push_back(std::string{filePath}); + } + } + }); +} + void Epub::parseCssFiles() const { // Maximum CSS file size we'll attempt to parse (uncompressed) // Larger files risk memory exhaustion on ESP32 @@ -329,9 +361,9 @@ void Epub::parseCssFiles() const { if (!cssParser->saveToCache()) { LOG_ERR("EBP", "Failed to save CSS rules to cache"); } - cssParser->clear(); LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size()); + cssParser->clear(); } // load in the meta data for the epub file @@ -354,6 +386,10 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) { if (!parseContentOpf(bookMetadataCache->coreMetadata)) { LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files"); // continue anyway - book will work without CSS and we'll still load any inline style CSS + } else { + // Handle case where CSS files are not listed in OPF manifest + // but are still referenced by HTML files - discover and parse them too + discoverCssFilesFromZip(); } parseCssFiles(); // Invalidate section caches so they are rebuilt with the new CSS @@ -457,6 +493,9 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) { } if (!skipLoadingCss) { + // Handle case where CSS files are not listed in OPF manifest + // but are still referenced by HTML files - discover and parse them too + discoverCssFilesFromZip(); // Parse CSS files after cache reload parseCssFiles(); Storage.removeDir((cachePath + "/sections").c_str()); diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 9ffa8d37c5..2ae95c358d 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -35,6 +35,7 @@ class Epub { bool parseTocNcxFile() const; bool parseTocNavFile() const; void parseCssFiles() const; + void discoverCssFilesFromZip(); public: explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) { diff --git a/lib/FsHelpers/FsHelpers.cpp b/lib/FsHelpers/FsHelpers.cpp index a73f890104..fe48587f16 100644 --- a/lib/FsHelpers/FsHelpers.cpp +++ b/lib/FsHelpers/FsHelpers.cpp @@ -129,6 +129,8 @@ bool hasTxtExtension(std::string_view fileName) { return checkFileExtension(file bool hasMarkdownExtension(std::string_view fileName) { return checkFileExtension(fileName, ".md"); } +bool hasCssExtension(std::string_view fileName) { return checkFileExtension(fileName, ".css"); } + std::string extractFolderPath(const std::string& filePath) { const auto lastSlash = filePath.find_last_of('/'); if (lastSlash == std::string::npos || lastSlash == 0) { diff --git a/lib/FsHelpers/FsHelpers.h b/lib/FsHelpers/FsHelpers.h index 56c2c9878f..aa9c44f44b 100644 --- a/lib/FsHelpers/FsHelpers.h +++ b/lib/FsHelpers/FsHelpers.h @@ -58,6 +58,11 @@ inline bool hasTxtExtension(const String& fileName) { // Check for .md extension (case-insensitive) bool hasMarkdownExtension(std::string_view fileName); +// Check for .css extension (case-insensitive) +bool hasCssExtension(std::string_view fileName); +inline bool hasCssExtension(const String& fileName) { + return hasCssExtension(std::string_view{fileName.c_str(), fileName.length()}); +} std::string extractFolderPath(const std::string& filePath); /** diff --git a/lib/ZipFile/ZipFile.h b/lib/ZipFile/ZipFile.h index 2a32e974e1..6c4f93961b 100644 --- a/lib/ZipFile/ZipFile.h +++ b/lib/ZipFile/ZipFile.h @@ -3,6 +3,7 @@ #include #include +#include #include class ZipFile { @@ -69,4 +70,11 @@ class ZipFile { // These functions will open and close the zip as needed uint8_t* readFileToMemory(const char* filename, size_t* size = nullptr, bool trailingNullByte = false); bool readFileToStream(const char* filename, Print& out, size_t chunkSize); + + template + void enumerateFilePaths(F&& callback) const { + for (const auto& entry : fileStatSlimCache) { + callback(std::string_view{entry.first}); + } + } };