test: Migrate unit tests to gtest, integrate with CI (#2144)

This commit is contained in:
Zach Nelson
2026-05-25 15:51:53 -07:00
committed by GitHub
parent 4ee406897b
commit 6dbfc29c7e
17 changed files with 696 additions and 1368 deletions
+28
View File
@@ -102,6 +102,33 @@ jobs:
path: .pio/build/default/firmware.bin path: .pio/build/default/firmware.bin
if-no-files-found: error 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 # This job is used as the PR required actions check, allows for changes to other steps in the future without breaking
# PR requirements. # PR requirements.
test-status: test-status:
@@ -110,6 +137,7 @@ jobs:
- build - build
- clang-format - clang-format
- cppcheck - cppcheck
- unit-tests
if: always() if: always()
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+1
View File
@@ -53,6 +53,7 @@ extra_scripts =
pre:scripts/gen_i18n.py pre:scripts/gen_i18n.py
pre:scripts/git_branch.py pre:scripts/git_branch.py
pre:scripts/patch_jpegdec.py pre:scripts/patch_jpegdec.py
post:scripts/register_unit_tests_target.py
; Libraries ; Libraries
lib_deps = lib_deps =
+28
View File
@@ -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",
)
+44
View File
@@ -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)
+12 -8
View File
@@ -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 cmake -S test -B build/test
source code, sets of one or more MCU program modules together with associated cmake --build build/test
control data, usage procedures, and operating procedures, are tested to ctest --test-dir build/test --output-on-failure -j
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing: Run a single suite directly:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
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.
+17
View File
@@ -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)
@@ -1,34 +1,11 @@
#include <cassert> #include <gtest/gtest.h>
#include <cmath> #include <cmath>
#include <cstdio>
#include <cstdlib> #include <cstdlib>
#include "lib/EpdFont/EpdFont.h" #include "lib/EpdFont/EpdFont.h"
#include "lib/EpdFont/EpdFontData.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 // Synthetic test font
// //
@@ -43,8 +20,10 @@ static int testsFailed = 0;
// o->a: -2 (-0.125px) o->o: -3 (-0.1875px) // o->a: -2 (-0.125px) o->o: -3 (-0.1875px)
// ============================================================================ // ============================================================================
namespace {
// clang-format off // clang-format off
static const EpdGlyph kGlyphs[] = { const EpdGlyph kGlyphs[] = {
// idx width height advanceX left top dataLength dataOffset // idx width height advanceX left top dataLength dataOffset
/* 0 'T' */ { 8, 12, 137, 0, 12, 0, 0 }, /* 0 'T' */ { 8, 12, 137, 0, 12, 0, 0 },
/* 1 'a' */ { 7, 8, 130, 0, 8, 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 }, /* 3 'x' */ { 7, 8, 136, 0, 8, 0, 0 },
}; };
static const EpdUnicodeInterval kIntervals[] = { const EpdUnicodeInterval kIntervals[] = {
{ 0x54, 0x54, 0 }, // 'T' -> glyph[0] { 0x54, 0x54, 0 }, // 'T' -> glyph[0]
{ 0x61, 0x61, 1 }, // 'a' -> glyph[1] { 0x61, 0x61, 1 }, // 'a' -> glyph[1]
{ 0x6F, 0x6F, 2 }, // 'o' -> glyph[2] { 0x6F, 0x6F, 2 }, // 'o' -> glyph[2]
{ 0x78, 0x78, 3 }, // 'x' -> glyph[3] { 0x78, 0x78, 3 }, // 'x' -> glyph[3]
}; };
static const EpdKernClassEntry kKernLeft[] = { const EpdKernClassEntry kKernLeft[] = {
{ 0x54, 1 }, // 'T' -> left class 1 { 0x54, 1 }, // 'T' -> left class 1
{ 0x6F, 2 }, // 'o' -> left class 2 { 0x6F, 2 }, // 'o' -> left class 2
}; };
static const EpdKernClassEntry kKernRight[] = { const EpdKernClassEntry kKernRight[] = {
{ 0x61, 1 }, // 'a' -> right class 1 { 0x61, 1 }, // 'a' -> right class 1
{ 0x6F, 2 }, // 'o' -> right class 2 { 0x6F, 2 }, // 'o' -> right class 2
}; };
// Flat matrix: leftClassCount(2) x rightClassCount(2), 4.4 fixed-point // 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) // [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, .bitmap = nullptr,
.glyph = kGlyphs, .glyph = kGlyphs,
.intervals = kIntervals, .intervals = kIntervals,
@@ -94,62 +73,63 @@ static const EpdFontData kTestFontData = {
.kernRightClassCount = 2, .kernRightClassCount = 2,
.ligaturePairs = nullptr, .ligaturePairs = nullptr,
.ligaturePairCount = 0, .ligaturePairCount = 0,
.glyphMissHandler = nullptr,
.glyphMissCtx = nullptr,
}; };
// clang-format on // clang-format on
static EpdFont testFont(&kTestFontData); EpdFont& testFont() {
static EpdFont font(&kTestFontData);
return font;
}
// Helper: return width from getTextDimensions int textWidth(const char* str) {
static int textWidth(const char* str) {
int w = 0, h = 0; int w = 0, h = 0;
testFont.getTextDimensions(str, &w, &h); testFont().getTextDimensions(str, &w, &h);
return w; return w;
} }
static int textHeight(const char* str) { int textHeight(const char* str) {
int w = 0, h = 0; int w = 0, h = 0;
testFont.getTextDimensions(str, &w, &h); testFont().getTextDimensions(str, &w, &h);
return h; return h;
} }
// Simulate the old absolute-snap gap for comparison
int absoluteGap(int32_t startFP, int32_t advanceFP, int32_t kernFP) {
int32_t nextFP = startFP + advanceFP + kernFP;
return fp4::toPixel(nextFP) - fp4::toPixel(startFP);
}
} // namespace
// ============================================================================ // ============================================================================
// Part 1: Pure fp4 math tests // Part 1: Pure fp4 math tests
// ============================================================================ // ============================================================================
// Simulate the old absolute-snap gap for comparison TEST(Fp4Math, RoundTripIntegerPixels) {
static 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");
for (int px = 0; px < 500; px++) { 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() { TEST(Fp4Math, RoundingBoundaries) {
printf("testOldApproachInconsistency...\n"); 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) // 'oo' pair: advance=145 (9.0625px), kern=-3 (-0.1875px), combined=142 (8.875px)
const int32_t advance = 145; const int32_t advance = 145;
const int32_t kern = -3; const int32_t kern = -3;
@@ -164,76 +144,51 @@ void testOldApproachInconsistency() {
} }
} }
ASSERT_TRUE(maxGap - minGap >= 1); // Absolute snap produces inconsistent gaps depending on subpixel phase.
printf(" Old absolute gap range: [%d, %d] -- varies by %d px\n", minGap, maxGap, maxGap - minGap); EXPECT_GE(maxGap - minGap, 1);
int diffStep = fp4::toPixel(advance + kern);
printf(" Differential step: always %d px\n", diffStep);
PASS();
} }
void testExhaustiveKernRange() { TEST(Fp4Math, ExhaustiveKernRange) {
printf("testExhaustiveKernRange...\n");
const int32_t baseAdvance = 128; const int32_t baseAdvance = 128;
int checked = 0;
for (int advFrac = 0; advFrac < 16; advFrac++) { for (int advFrac = 0; advFrac < 16; advFrac++) {
int32_t advance = baseAdvance + advFrac; int32_t advance = baseAdvance + advFrac;
for (int kern = -128; kern <= 127; kern++) { for (int kern = -128; kern <= 127; kern++) {
int step = fp4::toPixel(advance + static_cast<int32_t>(kern)); int step = fp4::toPixel(advance + static_cast<int32_t>(kern));
float idealPx = fp4::toFloat(advance + kern); float idealPx = fp4::toFloat(advance + kern);
if (std::abs(step - idealPx) >= 1.0f) { EXPECT_LT(std::abs(step - idealPx), 1.0f) << "advance=" << advance << " kern=" << kern;
fprintf(stderr, " FAIL: advance=%d, kern=%d, step=%d, ideal=%.4f\n", advance, kern, step, idealPx);
testsFailed++;
return;
}
checked++;
} }
} }
printf(" Checked %d (advance, kern) combinations -- all within 1px of ideal\n", checked);
PASS();
} }
// ============================================================================ // ============================================================================
// Part 2: Integration tests using real EpdFont::getTextDimensions // Part 2: Integration tests using real EpdFont::getTextDimensions
// ============================================================================ // ============================================================================
void testKernLookup() { TEST(EpdFont, KernLookup) {
printf("testKernLookup...\n"); EXPECT_EQ(testFont().getKerning('T', 'a'), -5);
EXPECT_EQ(testFont().getKerning('T', 'o'), -7);
ASSERT_EQ(testFont.getKerning('T', 'a'), -5); EXPECT_EQ(testFont().getKerning('o', 'a'), -2);
ASSERT_EQ(testFont.getKerning('T', 'o'), -7); EXPECT_EQ(testFont().getKerning('o', 'o'), -3);
ASSERT_EQ(testFont.getKerning('o', 'a'), -2); EXPECT_EQ(testFont().getKerning('a', 'o'), 0); // 'a' has no left class
ASSERT_EQ(testFont.getKerning('o', 'o'), -3); EXPECT_EQ(testFont().getKerning('x', 'o'), 0); // 'x' has no left class
ASSERT_EQ(testFont.getKerning('a', 'o'), 0); // 'a' has no left class EXPECT_EQ(testFont().getKerning('T', 'x'), 0); // 'x' has no right class
ASSERT_EQ(testFont.getKerning('x', 'o'), 0); // 'x' has no left class EXPECT_EQ(testFont().getKerning('T', 'T'), 0); // 'T' has no right 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();
} }
void testGlyphLookup() { TEST(EpdFont, GlyphLookup) {
printf("testGlyphLookup...\n"); ASSERT_NE(testFont().getGlyph('T'), nullptr);
ASSERT_NE(testFont().getGlyph('a'), nullptr);
ASSERT_TRUE(testFont.getGlyph('T') != nullptr); ASSERT_NE(testFont().getGlyph('o'), nullptr);
ASSERT_TRUE(testFont.getGlyph('a') != nullptr); ASSERT_NE(testFont().getGlyph('x'), nullptr);
ASSERT_TRUE(testFont.getGlyph('o') != nullptr); EXPECT_EQ(testFont().getGlyph('T')->advanceX, 137);
ASSERT_TRUE(testFont.getGlyph('x') != nullptr); EXPECT_EQ(testFont().getGlyph('a')->advanceX, 130);
ASSERT_EQ(testFont.getGlyph('T')->advanceX, 137); EXPECT_EQ(testFont().getGlyph('o')->advanceX, 145);
ASSERT_EQ(testFont.getGlyph('a')->advanceX, 130); EXPECT_EQ(testFont().getGlyph('x')->advanceX, 136);
ASSERT_EQ(testFont.getGlyph('o')->advanceX, 145);
ASSERT_EQ(testFont.getGlyph('x')->advanceX, 136);
// No U+FFFD in font, so unknown codepoints return nullptr // No U+FFFD in font, so unknown codepoints return nullptr
ASSERT_TRUE(testFont.getGlyph('Z') == nullptr); EXPECT_EQ(testFont().getGlyph('Z'), nullptr);
ASSERT_TRUE(testFont.getGlyph('b') == nullptr); EXPECT_EQ(testFont().getGlyph('b'), nullptr);
printf(" All glyph lookups correct\n");
PASS();
} }
// Known-value regression tests. Expected widths are computed by hand using // 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: // Differential step from glyph A to glyph B:
// step = fp4::toPixel(advanceA + kern(A,B)) // step = fp4::toPixel(advanceA + kern(A,B))
void testKnownWidths() { TEST(EpdFont, KnownWidths) {
printf("testKnownWidths...\n"); // "o": single glyph at x=0, width=8 -> w = 0 + 8 = 8
EXPECT_EQ(textWidth("o"), 8);
// "o": single glyph at x=0, width=8
// w = 0 + 8 = 8
ASSERT_EQ(textWidth("o"), 8);
// "oo": step = toPixel(145 + (-3)) = toPixel(142) = 9 // "oo": step = toPixel(145 + (-3)) = toPixel(142) = 9
// o1 at 0, o2 at 9. w = 9 + 8 = 17 // 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 // "ooo": two steps of 9 -> o3 at 18, w = 18 + 8 = 26
// o1 at 0, o2 at 9, o3 at 18. w = 18 + 8 = 26 EXPECT_EQ(textWidth("ooo"), 26);
ASSERT_EQ(textWidth("ooo"), 26);
// "To": step = toPixel(137 + (-7)) = toPixel(130) = 8 // "To": step = toPixel(137 + (-7)) = 8 -> o at 8, w = 8 + 8 = 16
// T at 0, o at 8. w = 8 + 8 = 16 EXPECT_EQ(textWidth("To"), 16);
ASSERT_EQ(textWidth("To"), 16);
// "Ta": step = toPixel(137 + (-5)) = toPixel(132) = 8 // "Ta": step = toPixel(137 + (-5)) = 8 -> a at 8, w = 8 + 7 = 15
// T at 0, a at 8. w = 8 + 7 = 15 EXPECT_EQ(textWidth("Ta"), 15);
ASSERT_EQ(textWidth("Ta"), 15);
// "oa": step = toPixel(145 + (-2)) = toPixel(143) = 9 // "oa": step = toPixel(145 + (-2)) = 9 -> a at 9, w = 9 + 7 = 16
// o at 0, a at 9. w = 9 + 7 = 16 EXPECT_EQ(textWidth("oa"), 16);
ASSERT_EQ(textWidth("oa"), 16);
// "Too": T at 0. // "Too": T at 0, o1 at 8 (T->o step), o2 at 17 (o->o step). w = 17 + 8 = 25
// step T->o = toPixel(137 + (-7)) = 8. o1 at 8. EXPECT_EQ(textWidth("Too"), 25);
// step o->o = toPixel(145 + (-3)) = 9. o2 at 17.
// w = 17 + 8 = 25
ASSERT_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 // x at 0, o at 9. w = 9 + 8 = 17
ASSERT_EQ(textWidth("xo"), 17); EXPECT_EQ(textWidth("xo"), 17);
printf(" All known widths correct\n");
PASS();
} }
// "oo" pair consistency: the pixel gap between two o's must be the same // "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 // differential rounding. With absolute snapping, "xoo" would produce a
// different oo gap than "oo" because 'x' advance (136 FP) puts the first // different oo gap than "oo" because 'x' advance (136 FP) puts the first
// 'o' at fractional phase 8, crossing the rounding boundary differently. // 'o' at fractional phase 8, crossing the rounding boundary differently.
void testPairConsistencyViaFont() { TEST(EpdFont, PairConsistencyViaFont) {
printf("testPairConsistencyViaFont...\n");
// The oo gap = width(prefix + "oo") - width(prefix + "o") // The oo gap = width(prefix + "oo") - width(prefix + "o")
// This isolates the pixel distance contributed by the second 'o'. // This isolates the pixel distance contributed by the second 'o'.
const int oo_gap_bare = textWidth("oo") - textWidth("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_T = textWidth("Too") - textWidth("To");
const int oo_gap_after_o = textWidth("ooo") - textWidth("oo"); const int oo_gap_after_o = textWidth("ooo") - textWidth("oo");
printf(" oo gap (bare): %d\n", oo_gap_bare); EXPECT_EQ(oo_gap_after_x, oo_gap_bare);
printf(" oo gap (after x): %d\n", oo_gap_after_x); EXPECT_EQ(oo_gap_after_T, oo_gap_bare);
printf(" oo gap (after T): %d\n", oo_gap_after_T); EXPECT_EQ(oo_gap_after_o, oo_gap_bare);
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();
} }
// Null-glyph handling: when a codepoint has no glyph (and no replacement // Null-glyph handling: when a codepoint has no glyph (and no replacement
// glyph), the pending advance from the previous glyph must still be flushed. // 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. // Without the flush fix, the glyph after the null would overlap the one before.
void testNullGlyphAdvancePreserved() { TEST(EpdFont, NullGlyphAdvancePreserved) {
printf("testNullGlyphAdvancePreserved...\n");
// 'Z' (0x5A) is not in our font and there's no U+FFFD, so getGlyph returns null. // '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. // "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). // toPixel(145) = 9 (o's advance, no kern since Z resets prevCp).
// w = 9 + 8 = 17 // w = 9 + 8 = 17
int w = textWidth("oZo"); EXPECT_EQ(textWidth("oZo"), 17);
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);
// Multi-null: "oZZo" -- two consecutive nulls, advance still preserved. // Multi-null: "oZZo" -- two consecutive nulls, advance still preserved.
w = textWidth("oZZo"); EXPECT_EQ(textWidth("oZZo"), 17);
printf(" width(\"oZZo\") = %d\n", w);
ASSERT_EQ(w, 17);
// Null at start: "Zo" -- no pending advance to flush, o renders at 0. // Null at start: "Zo" -- no pending advance to flush, o renders at 0.
w = textWidth("Zo"); EXPECT_EQ(textWidth("Zo"), 8);
printf(" width(\"Zo\") = %d\n", w);
ASSERT_EQ(w, 8);
printf(" Null-glyph advance correctly preserved\n");
PASS();
} }
void testHeightCalculation() { TEST(EpdFont, HeightCalculation) {
printf("testHeightCalculation...\n");
// 'T' is tallest: top=12, height=12 -> extent [0, 12) // 'T' is tallest: top=12, height=12 -> extent [0, 12)
// 'o' and 'a': top=8, height=8 -> extent [0, 8) // 'o' and 'a': top=8, height=8 -> extent [0, 8)
ASSERT_EQ(textHeight("o"), 8); EXPECT_EQ(textHeight("o"), 8);
ASSERT_EQ(textHeight("T"), 12); EXPECT_EQ(textHeight("T"), 12);
ASSERT_EQ(textHeight("To"), 12); EXPECT_EQ(textHeight("To"), 12);
ASSERT_EQ(textHeight("oo"), 8); EXPECT_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;
} }
+24
View File
@@ -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)
@@ -1,10 +1,8 @@
#include <Utf8.h> #include <Utf8.h>
#include <gtest/gtest.h>
#include <algorithm> #include <algorithm>
#include <cctype>
#include <cmath>
#include <fstream> #include <fstream>
#include <functional>
#include <iostream> #include <iostream>
#include <sstream> #include <sstream>
#include <string> #include <string>
@@ -14,6 +12,12 @@
#include "lib/Epub/Epub/hyphenation/LanguageHyphenator.h" #include "lib/Epub/Epub/hyphenation/LanguageHyphenator.h"
#include "lib/Epub/Epub/hyphenation/LanguageRegistry.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 { struct TestCase {
std::string word; std::string word;
std::string hyphenated; std::string hyphenated;
@@ -31,23 +35,6 @@ struct EvaluationResult {
double weightedScore = 0.0; double weightedScore = 0.0;
}; };
struct LanguageConfig {
std::string cliName;
std::string testDataFile;
const char* primaryTag;
};
const std::vector<LanguageConfig> 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<size_t> expectedPositionsFromAnnotatedWord(const std::string& annotated) { std::vector<size_t> expectedPositionsFromAnnotatedWord(const std::string& annotated) {
std::vector<size_t> positions; std::vector<size_t> positions;
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(annotated.c_str()); const unsigned char* ptr = reinterpret_cast<const unsigned char*>(annotated.c_str());
@@ -72,7 +59,6 @@ std::vector<TestCase> loadTestData(const std::string& filename) {
std::ifstream file(filename); std::ifstream file(filename);
if (!file.is_open()) { if (!file.is_open()) {
std::cerr << "Error: Could not open file " << filename << std::endl;
return testCases; return testCases;
} }
@@ -90,14 +76,11 @@ std::vector<TestCase> loadTestData(const std::string& filename) {
testCase.word = word; testCase.word = word;
testCase.hyphenated = hyphenated; testCase.hyphenated = hyphenated;
testCase.frequency = std::stoi(freqStr); testCase.frequency = std::stoi(freqStr);
testCase.expectedPositions = expectedPositionsFromAnnotatedWord(hyphenated); testCase.expectedPositions = expectedPositionsFromAnnotatedWord(hyphenated);
testCases.push_back(testCase); testCases.push_back(testCase);
} }
} }
file.close();
return testCases; return testCases;
} }
@@ -133,30 +116,12 @@ std::string positionsToHyphenated(const std::string& word, const std::vector<siz
std::vector<size_t> hyphenateWordWithHyphenator(const std::string& word, const LanguageHyphenator& hyphenator) { std::vector<size_t> hyphenateWordWithHyphenator(const std::string& word, const LanguageHyphenator& hyphenator) {
auto cps = collectCodepoints(word); auto cps = collectCodepoints(word);
trimSurroundingPunctuationAndFootnote(cps); trimSurroundingPunctuationAndFootnote(cps);
return hyphenator.breakIndexes(cps); return hyphenator.breakIndexes(cps);
} }
std::vector<LanguageConfig> resolveLanguages(const std::string& selection) { EvaluationResult evaluateWord(const TestCase& testCase, const std::vector<size_t>& actualPositions) {
if (selection == "all") {
return kSupportedLanguages;
}
for (const auto& config : kSupportedLanguages) {
if (config.cliName == selection) {
return {config};
}
}
return {};
}
EvaluationResult evaluateWord(const TestCase& testCase,
std::function<std::vector<size_t>(const std::string&)> hyphenateFunc) {
EvaluationResult result; EvaluationResult result;
std::vector<size_t> actualPositions = hyphenateFunc(testCase.word);
std::vector<size_t> expected = testCase.expectedPositions; std::vector<size_t> expected = testCase.expectedPositions;
std::vector<size_t> actual = actualPositions; std::vector<size_t> actual = actualPositions;
@@ -189,8 +154,7 @@ EvaluationResult evaluateWord(const TestCase& testCase,
result.f1Score = 2 * result.precision * result.recall / (result.precision + result.recall); 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 // Treat words with no expected and no actual hyphenation marks as perfect.
// algorithmic output as perfect matches so they don't drag down the per-word averages.
if (expected.empty() && actual.empty()) { if (expected.empty() && actual.empty()) {
result.precision = 1.0; result.precision = 1.0;
result.recall = 1.0; result.recall = 1.0;
@@ -199,9 +163,8 @@ EvaluationResult evaluateWord(const TestCase& testCase,
double fpPenalty = 2.0; double fpPenalty = 2.0;
double fnPenalty = 1.0; double fnPenalty = 1.0;
int totalErrors = result.falsePositives * fpPenalty + result.falseNegatives * fnPenalty; int totalErrors = result.falsePositives * fpPenalty + result.falseNegatives * fnPenalty;
int totalPossible = expected.size() * fpPenalty; int totalPossible = static_cast<int>(expected.size() * fpPenalty);
if (totalPossible > 0) { if (totalPossible > 0) {
result.weightedScore = 1.0 - (static_cast<double>(totalErrors) / totalPossible); result.weightedScore = 1.0 - (static_cast<double>(totalErrors) / totalPossible);
@@ -213,180 +176,59 @@ EvaluationResult evaluateWord(const TestCase& testCase,
return result; return result;
} }
void printResults(const std::string& language, const std::vector<TestCase>& testCases, // Runs the evaluation for a single language and asserts the per-word average F1
const std::vector<std::pair<TestCase, EvaluationResult>>& worstCases, int perfectMatches, // is at or above `minF1Percent`. Thresholds are set ~1pp below measured
int partialMatches, int completeMisses, double totalPrecision, double totalRecall, double totalF1, // baselines so unrelated tweaks don't fail CI but real regressions still trip.
double totalWeighted, int totalTP, int totalFP, int totalFN, void runLanguageEval(const char* langName, const char* primaryTag, const char* resourceFile, double minF1Percent) {
std::function<std::vector<size_t>(const std::string&)> hyphenateFunc) { const auto* hyphenator = getLanguageHyphenatorForPrimaryTag(primaryTag);
std::string lang_upper = language; ASSERT_NE(hyphenator, nullptr) << "No hyphenator registered for tag: " << primaryTag;
if (!lang_upper.empty()) {
lang_upper[0] = std::toupper(lang_upper[0]);
}
std::cout << "================================================================================" << std::endl; std::string path = std::string(HYPHENATION_RESOURCES_DIR) + "/" + resourceFile;
std::cout << lang_upper << " HYPHENATION EVALUATION RESULTS" << std::endl; std::vector<TestCase> testCases = loadTestData(path);
std::cout << "================================================================================" << std::endl; ASSERT_FALSE(testCases.empty()) << "No test cases loaded from " << path;
std::cout << std::endl;
std::cout << "Total test cases: " << testCases.size() << std::endl; double totalF1 = 0.0;
std::cout << "Perfect matches: " << perfectMatches << " (" << (perfectMatches * 100.0 / testCases.size()) << "%)" std::vector<std::pair<TestCase, EvaluationResult>> imperfect;
<< 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; for (const auto& tc : testCases) {
std::cout << "Average Precision: " << (totalPrecision / testCases.size() * 100.0) << "%" << std::endl; std::vector<size_t> actual = hyphenateWordWithHyphenator(tc.word, *hyphenator);
std::cout << "Average Recall: " << (totalRecall / testCases.size() * 100.0) << "%" << std::endl; EvaluationResult res = evaluateWord(tc, actual);
std::cout << "Average F1 Score: " << (totalF1 / testCases.size() * 100.0) << "%" << std::endl; totalF1 += res.f1Score;
std::cout << "Average Weighted Score: " << (totalWeighted / testCases.size() * 100.0) << "% (FP penalty: 2x)" if (res.weightedScore < 0.999999) {
<< std::endl; imperfect.emplace_back(tc, res);
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<double>(totalTP) / (totalTP + totalFP) : 0.0;
double overallRecall = totalTP + totalFN > 0 ? static_cast<double>(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<std::pair<TestCase, EvaluationResult>> imperfectCases;
imperfectCases.reserve(worstCases.size());
for (const auto& entry : worstCases) {
if (hasImperfection(entry.second)) {
imperfectCases.push_back(entry);
} }
} }
std::cout << "--- Worst Cases (lowest weighted scores) ---" << std::endl; double averageF1Percent = totalF1 / testCases.size() * 100.0;
int showCount = std::min(10, static_cast<int>(imperfectCases.size())); ::testing::Test::RecordProperty("avg_f1_percent", std::to_string(averageF1Percent));
for (int i = 0; i < showCount; i++) { ::testing::Test::RecordProperty("test_cases", std::to_string(testCases.size()));
const auto& testCase = imperfectCases[i].first;
const auto& result = imperfectCases[i].second;
std::vector<size_t> actualPositions = hyphenateFunc(testCase.word); std::cout << langName << ": F1=" << averageF1Percent << "% (threshold " << minF1Percent << "%, " << testCases.size()
std::string actualHyphenated = positionsToHyphenated(testCase.word, actualPositions); << " cases)\n";
std::cout << "Word: " << testCase.word << " (freq: " << testCase.frequency << ")" << std::endl; if (averageF1Percent < minF1Percent) {
std::cout << " Expected: " << testCase.hyphenated << std::endl; std::sort(imperfect.begin(), imperfect.end(),
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<int>(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<size_t> 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<LanguageConfig> 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<TestCase> 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;
std::vector<std::pair<TestCase, EvaluationResult>> worstCases;
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(),
[](const auto& a, const auto& b) { return a.second.weightedScore < b.second.weightedScore; }); [](const auto& a, const auto& b) { return a.second.weightedScore < b.second.weightedScore; });
std::cout << "Worst cases for " << langName << ":\n";
printResults(lang.cliName, testCases, worstCases, perfectMatches, partialMatches, completeMisses, totalPrecision, int show = std::min<int>(10, static_cast<int>(imperfect.size()));
totalRecall, totalF1, totalWeighted, totalTP, totalFP, totalFN, hyphenateFunc); for (int i = 0; i < show; ++i) {
const TestCase& tc = imperfect[i].first;
std::vector<size_t> 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); }
+16
View File
@@ -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)
+156 -389
View File
@@ -1,51 +1,13 @@
#include <cassert> #include <gtest/gtest.h>
#include <cstdio>
#include <cstring> #include <cstring>
#include <string> #include <string>
#include "lib/JsonParser/ReleaseJsonParser.h" #include "lib/JsonParser/ReleaseJsonParser.h"
static int testsPassed = 0; namespace {
static int testsFailed = 0;
#define ASSERT_EQ(a, b) \ const char* kRealisticPretty = R"({
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"({
"url": "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/12345", "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", "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}", "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}})"; 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 void feedChunked(ReleaseJsonParser& p, const char* json, size_t chunkSize) {
static void feedChunked(ReleaseJsonParser& p, const char* json, size_t chunkSize) {
size_t len = strlen(json); size_t len = strlen(json);
for (size_t off = 0; off < len; off += chunkSize) { for (size_t off = 0; off < len; off += chunkSize) {
size_t n = len - off < chunkSize ? 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
} }
} }
// ============================================================================ } // namespace
// Tests
// ============================================================================
void testRealisticPrettyPrinted() {
printf("testRealisticPrettyPrinted...\n");
TEST(ReleaseJsonParser, RealisticPrettyPrinted) {
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(kRealisticPretty, strlen(kRealisticPretty)); p.feed(kRealisticPretty, strlen(kRealisticPretty));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_STREQ(p.getTagName(), "v2.4.1"); EXPECT_STREQ(p.getTagName(), "v2.4.1");
ASSERT_STREQ(p.getFirmwareUrl(), EXPECT_STREQ(p.getFirmwareUrl(),
"https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/firmware.bin"); "https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/firmware.bin");
ASSERT_EQ(p.getFirmwareSize(), 1572864u); EXPECT_EQ(p.getFirmwareSize(), 1572864u);
printf(" passed\n");
PASS();
} }
void testRealisticMinified() { TEST(ReleaseJsonParser, RealisticMinified) {
printf("testRealisticMinified...\n");
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(kRealisticMinified, strlen(kRealisticMinified)); p.feed(kRealisticMinified, strlen(kRealisticMinified));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_STREQ(p.getTagName(), "v2.4.1"); EXPECT_STREQ(p.getTagName(), "v2.4.1");
ASSERT_STREQ(p.getFirmwareUrl(), EXPECT_STREQ(p.getFirmwareUrl(),
"https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/firmware.bin"); "https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/firmware.bin");
ASSERT_EQ(p.getFirmwareSize(), 1572864u); EXPECT_EQ(p.getFirmwareSize(), 1572864u);
printf(" passed\n");
PASS();
} }
void testPrettyAndMinifiedAgree() { TEST(ReleaseJsonParser, PrettyAndMinifiedAgree) {
printf("testPrettyAndMinifiedAgree...\n");
ReleaseJsonParser pretty; ReleaseJsonParser pretty;
pretty.feed(kRealisticPretty, strlen(kRealisticPretty)); pretty.feed(kRealisticPretty, strlen(kRealisticPretty));
ReleaseJsonParser minified; ReleaseJsonParser minified;
minified.feed(kRealisticMinified, strlen(kRealisticMinified)); minified.feed(kRealisticMinified, strlen(kRealisticMinified));
ASSERT_STREQ(pretty.getTagName(), minified.getTagName()); ASSERT_TRUE(pretty.foundTag());
ASSERT_STREQ(pretty.getFirmwareUrl(), minified.getFirmwareUrl()); ASSERT_TRUE(pretty.foundFirmware());
ASSERT_EQ(pretty.getFirmwareSize(), minified.getFirmwareSize()); ASSERT_TRUE(minified.foundTag());
ASSERT_TRUE(minified.foundFirmware());
printf(" passed\n"); EXPECT_STREQ(pretty.getTagName(), minified.getTagName());
PASS(); EXPECT_STREQ(pretty.getFirmwareUrl(), minified.getFirmwareUrl());
EXPECT_EQ(pretty.getFirmwareSize(), minified.getFirmwareSize());
} }
void testFirmwareNotFirstAsset() { TEST(ReleaseJsonParser, FirmwareNotFirstAsset) {
printf("testFirmwareNotFirstAsset...\n");
// firmware.bin is the third of four assets
const char* json = R"({ const char* json = R"({
"tag_name": "v1.0.0", "tag_name": "v1.0.0",
"assets": [ "assets": [
@@ -231,19 +177,14 @@ void testFirmwareNotFirstAsset() {
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_STREQ(p.getTagName(), "v1.0.0"); EXPECT_STREQ(p.getTagName(), "v1.0.0");
ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/firmware.bin"); EXPECT_STREQ(p.getFirmwareUrl(), "https://example.com/firmware.bin");
ASSERT_EQ(p.getFirmwareSize(), 987654u); EXPECT_EQ(p.getFirmwareSize(), 987654u);
printf(" passed\n");
PASS();
} }
void testFieldOrderUrlBeforeName() { TEST(ReleaseJsonParser, FieldOrderUrlBeforeName) {
printf("testFieldOrderUrlBeforeName...\n");
const char* json = R"({ const char* json = R"({
"tag_name": "v3.0", "tag_name": "v3.0",
"assets": [{ "assets": [{
@@ -256,17 +197,12 @@ void testFieldOrderUrlBeforeName() {
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw.bin"); EXPECT_STREQ(p.getFirmwareUrl(), "https://example.com/fw.bin");
ASSERT_EQ(p.getFirmwareSize(), 2222u); EXPECT_EQ(p.getFirmwareSize(), 2222u);
printf(" passed\n");
PASS();
} }
void testFieldOrderSizeBeforeUrl() { TEST(ReleaseJsonParser, FieldOrderSizeBeforeUrl) {
printf("testFieldOrderSizeBeforeUrl...\n");
const char* json = R"({ const char* json = R"({
"tag_name": "v3.1", "tag_name": "v3.1",
"assets": [{ "assets": [{
@@ -279,17 +215,12 @@ void testFieldOrderSizeBeforeUrl() {
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw2.bin"); EXPECT_STREQ(p.getFirmwareUrl(), "https://example.com/fw2.bin");
ASSERT_EQ(p.getFirmwareSize(), 3333u); EXPECT_EQ(p.getFirmwareSize(), 3333u);
printf(" passed\n");
PASS();
} }
void testFieldOrderNameFirst() { TEST(ReleaseJsonParser, FieldOrderNameFirst) {
printf("testFieldOrderNameFirst...\n");
const char* json = R"({ const char* json = R"({
"tag_name": "v3.2", "tag_name": "v3.2",
"assets": [{ "assets": [{
@@ -302,17 +233,12 @@ void testFieldOrderNameFirst() {
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw3.bin"); EXPECT_STREQ(p.getFirmwareUrl(), "https://example.com/fw3.bin");
ASSERT_EQ(p.getFirmwareSize(), 4444u); EXPECT_EQ(p.getFirmwareSize(), 4444u);
printf(" passed\n");
PASS();
} }
void testAssetsBeforeTagName() { TEST(ReleaseJsonParser, AssetsBeforeTagName) {
printf("testAssetsBeforeTagName...\n");
// tag_name appears after assets in the JSON // tag_name appears after assets in the JSON
const char* json = R"({ const char* json = R"({
"name": "Release", "name": "Release",
@@ -327,69 +253,51 @@ void testAssetsBeforeTagName() {
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_STREQ(p.getTagName(), "v4.0"); EXPECT_STREQ(p.getTagName(), "v4.0");
ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw.bin"); EXPECT_STREQ(p.getFirmwareUrl(), "https://example.com/fw.bin");
ASSERT_EQ(p.getFirmwareSize(), 5555u); EXPECT_EQ(p.getFirmwareSize(), 5555u);
printf(" passed\n");
PASS();
} }
void testChunkedFeedingRealisticSmallChunks() { TEST(ReleaseJsonParser, ChunkedFeedingSmallChunks) {
printf("testChunkedFeedingRealisticSmallChunks...\n");
// Simulate HTTP chunked transfer with small chunks (64 bytes)
ReleaseJsonParser p; ReleaseJsonParser p;
feedChunked(p, kRealisticPretty, 64); feedChunked(p, kRealisticPretty, 64);
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_STREQ(p.getTagName(), "v2.4.1"); EXPECT_STREQ(p.getTagName(), "v2.4.1");
ASSERT_STREQ(p.getFirmwareUrl(), EXPECT_STREQ(p.getFirmwareUrl(),
"https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/firmware.bin"); "https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/firmware.bin");
ASSERT_EQ(p.getFirmwareSize(), 1572864u); EXPECT_EQ(p.getFirmwareSize(), 1572864u);
printf(" passed\n");
PASS();
} }
void testChunkedFeedingByteByByte() { TEST(ReleaseJsonParser, ChunkedFeedingByteByByte) {
printf("testChunkedFeedingByteByByte...\n");
ReleaseJsonParser p; ReleaseJsonParser p;
feedChunked(p, kRealisticMinified, 1); feedChunked(p, kRealisticMinified, 1);
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_STREQ(p.getTagName(), "v2.4.1"); EXPECT_STREQ(p.getTagName(), "v2.4.1");
ASSERT_EQ(p.getFirmwareSize(), 1572864u); EXPECT_EQ(p.getFirmwareSize(), 1572864u);
printf(" passed\n");
PASS();
} }
void testChunkedFeedingVariousChunkSizes() { TEST(ReleaseJsonParser, ChunkedFeedingVariousChunkSizes) {
printf("testChunkedFeedingVariousChunkSizes...\n"); for (size_t chunkSize : {3u, 7u, 13u, 31u, 97u, 128u, 256u, 512u, 1024u}) {
for (size_t chunkSize : {3, 7, 13, 31, 97, 128, 256, 512, 1024}) {
ReleaseJsonParser p; ReleaseJsonParser p;
feedChunked(p, kRealisticPretty, chunkSize); feedChunked(p, kRealisticPretty, chunkSize);
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag()) << "chunkSize=" << chunkSize;
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware()) << "chunkSize=" << chunkSize;
ASSERT_STREQ(p.getTagName(), "v2.4.1"); EXPECT_STREQ(p.getTagName(), "v2.4.1") << "chunkSize=" << chunkSize;
ASSERT_EQ(p.getFirmwareSize(), 1572864u); 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() { TEST(ReleaseJsonParser, MissingTagName) {
printf("testMissingTagName...\n");
const char* json = R"({ const char* json = R"({
"name": "Some Release", "name": "Some Release",
"draft": false, "draft": false,
@@ -403,17 +311,12 @@ void testMissingTagName() {
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(!p.foundTag()); EXPECT_FALSE(p.foundTag());
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_STREQ(p.getTagName(), ""); EXPECT_STREQ(p.getTagName(), "");
printf(" passed\n");
PASS();
} }
void testMissingFirmwareBinAsset() { TEST(ReleaseJsonParser, MissingFirmwareBinAsset) {
printf("testMissingFirmwareBinAsset...\n");
const char* json = R"({ const char* json = R"({
"tag_name": "v1.0.0", "tag_name": "v1.0.0",
"assets": [ "assets": [
@@ -425,129 +328,88 @@ void testMissingFirmwareBinAsset() {
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_TRUE(!p.foundFirmware()); EXPECT_FALSE(p.foundFirmware());
ASSERT_STREQ(p.getTagName(), "v1.0.0"); EXPECT_STREQ(p.getTagName(), "v1.0.0");
ASSERT_STREQ(p.getFirmwareUrl(), ""); EXPECT_STREQ(p.getFirmwareUrl(), "");
ASSERT_EQ(p.getFirmwareSize(), 0u); EXPECT_EQ(p.getFirmwareSize(), 0u);
printf(" passed\n");
PASS();
} }
void testEmptyAssetsArray() { TEST(ReleaseJsonParser, EmptyAssetsArray) {
printf("testEmptyAssetsArray...\n");
const char* json = R"({"tag_name": "v1.0.0", "assets": []})"; const char* json = R"({"tag_name": "v1.0.0", "assets": []})";
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_TRUE(!p.foundFirmware()); EXPECT_FALSE(p.foundFirmware());
printf(" passed\n");
PASS();
} }
void testNoAssetsKey() { TEST(ReleaseJsonParser, NoAssetsKey) {
printf("testNoAssetsKey...\n");
const char* json = R"({"tag_name": "v1.0.0", "name": "Release"})"; const char* json = R"({"tag_name": "v1.0.0", "name": "Release"})";
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_TRUE(!p.foundFirmware()); EXPECT_FALSE(p.foundFirmware());
printf(" passed\n");
PASS();
} }
void testTruncatedBeforeTagValue() { TEST(ReleaseJsonParser, TruncatedBeforeTagValue) {
printf("testTruncatedBeforeTagValue...\n");
const char* json = R"({"tag_name": )"; const char* json = R"({"tag_name": )";
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(!p.foundTag()); EXPECT_FALSE(p.foundTag());
ASSERT_TRUE(!p.foundFirmware()); EXPECT_FALSE(p.foundFirmware());
printf(" passed\n");
PASS();
} }
void testTruncatedInsideTagValue() { TEST(ReleaseJsonParser, TruncatedInsideTagValue) {
printf("testTruncatedInsideTagValue...\n");
const char* json = R"({"tag_name": "v2.4)"; const char* json = R"({"tag_name": "v2.4)";
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(!p.foundTag()); EXPECT_FALSE(p.foundTag());
printf(" passed\n");
PASS();
} }
void testTruncatedInsideAssetsArray() { TEST(ReleaseJsonParser, TruncatedInsideAssetsArray) {
printf("testTruncatedInsideAssetsArray...\n");
const char* json = R"({"tag_name": "v2.4.1", "assets": [{"name": "firm)"; const char* json = R"({"tag_name": "v2.4.1", "assets": [{"name": "firm)";
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_STREQ(p.getTagName(), "v2.4.1"); EXPECT_STREQ(p.getTagName(), "v2.4.1");
ASSERT_TRUE(!p.foundFirmware()); EXPECT_FALSE(p.foundFirmware());
printf(" passed\n");
PASS();
} }
void testTruncatedAfterFirmwareName() { TEST(ReleaseJsonParser, TruncatedAfterFirmwareName) {
printf("testTruncatedAfterFirmwareName...\n");
// Found the name but connection dropped before URL/size // Found the name but connection dropped before URL/size
const char* json = R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_dow)"; const char* json = R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_dow)";
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_TRUE(!p.foundFirmware()); EXPECT_FALSE(p.foundFirmware());
printf(" passed\n");
PASS();
} }
void testTruncatedRealisticJson() { TEST(ReleaseJsonParser, TruncatedRealisticJson) {
printf("testTruncatedRealisticJson...\n");
// Truncate the realistic JSON at various points; none should crash
std::string full(kRealisticPretty); std::string full(kRealisticPretty);
for (size_t cutPoint : {10u, 50u, 100u, 200u, 500u, 1000u, 1500u, 2000u}) { for (size_t cutPoint : {10u, 50u, 100u, 200u, 500u, 1000u, 1500u, 2000u}) {
if (cutPoint >= full.size()) continue; if (cutPoint >= full.size()) continue;
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(full.c_str(), cutPoint); p.feed(full.c_str(), cutPoint);
// Just verify no crash; results depend on where we cut
(void)p.foundTag(); (void)p.foundTag();
(void)p.foundFirmware(); (void)p.foundFirmware();
} }
SUCCEED();
printf(" passed (no crashes on truncated realistic JSON)\n");
PASS();
} }
void testNestedObjectsInAsset() { TEST(ReleaseJsonParser, NestedObjectsInAsset) {
printf("testNestedObjectsInAsset...\n");
// Asset with deeply nested "uploader" object -- should not confuse depth tracking // Asset with deeply nested "uploader" object -- should not confuse depth tracking
const char* json = R"({ const char* json = R"({
"tag_name": "v5.0", "tag_name": "v5.0",
@@ -566,17 +428,12 @@ void testNestedObjectsInAsset() {
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw5.bin"); EXPECT_STREQ(p.getFirmwareUrl(), "https://example.com/fw5.bin");
ASSERT_EQ(p.getFirmwareSize(), 8888u); EXPECT_EQ(p.getFirmwareSize(), 8888u);
printf(" passed\n");
PASS();
} }
void testNestedObjectsAtTopLevel() { TEST(ReleaseJsonParser, NestedObjectsAtTopLevel) {
printf("testNestedObjectsAtTopLevel...\n");
// Multiple nested objects at the top level before/after tag_name and assets // Multiple nested objects at the top level before/after tag_name and assets
const char* json = R"({ const char* json = R"({
"author": {"login": "dev", "id": 1, "nested": {"deep": true}}, "author": {"login": "dev", "id": 1, "nested": {"deep": true}},
@@ -589,18 +446,13 @@ void testNestedObjectsAtTopLevel() {
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_STREQ(p.getTagName(), "v6.0"); EXPECT_STREQ(p.getTagName(), "v6.0");
ASSERT_EQ(p.getFirmwareSize(), 1111u); EXPECT_EQ(p.getFirmwareSize(), 1111u);
printf(" passed\n");
PASS();
} }
void testArraysAtTopLevel() { TEST(ReleaseJsonParser, ArraysAtTopLevel) {
printf("testArraysAtTopLevel...\n");
// A non-assets array at the top level should not interfere // A non-assets array at the top level should not interfere
const char* json = R"({ const char* json = R"({
"tag_name": "v7.0", "tag_name": "v7.0",
@@ -611,69 +463,53 @@ void testArraysAtTopLevel() {
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_STREQ(p.getTagName(), "v7.0"); EXPECT_STREQ(p.getTagName(), "v7.0");
ASSERT_EQ(p.getFirmwareSize(), 7070u); EXPECT_EQ(p.getFirmwareSize(), 7070u);
printf(" passed\n");
PASS();
} }
void testResetAndReuse() { TEST(ReleaseJsonParser, ResetAndReuse) {
printf("testResetAndReuse...\n");
ReleaseJsonParser p; ReleaseJsonParser p;
const char* json1 = const char* json1 =
R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://a","size":1}]})"; R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://a","size":1}]})";
p.feed(json1, strlen(json1)); p.feed(json1, strlen(json1));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_STREQ(p.getTagName(), "v1.0"); EXPECT_STREQ(p.getTagName(), "v1.0");
ASSERT_STREQ(p.getFirmwareUrl(), "https://a"); EXPECT_STREQ(p.getFirmwareUrl(), "https://a");
ASSERT_EQ(p.getFirmwareSize(), 1u); EXPECT_EQ(p.getFirmwareSize(), 1u);
p.reset(); p.reset();
// Second document with different values
const char* json2 = const char* json2 =
R"({"tag_name":"v2.0","assets":[{"name":"firmware.bin","browser_download_url":"https://b","size":2}]})"; R"({"tag_name":"v2.0","assets":[{"name":"firmware.bin","browser_download_url":"https://b","size":2}]})";
p.feed(json2, strlen(json2)); p.feed(json2, strlen(json2));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_STREQ(p.getTagName(), "v2.0"); EXPECT_STREQ(p.getTagName(), "v2.0");
ASSERT_STREQ(p.getFirmwareUrl(), "https://b"); EXPECT_STREQ(p.getFirmwareUrl(), "https://b");
ASSERT_EQ(p.getFirmwareSize(), 2u); EXPECT_EQ(p.getFirmwareSize(), 2u);
printf(" passed\n");
PASS();
} }
void testResetClearsState() { TEST(ReleaseJsonParser, ResetClearsState) {
printf("testResetClearsState...\n");
ReleaseJsonParser p; ReleaseJsonParser p;
const char* json = const char* json =
R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://a","size":100}]})"; R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://a","size":100}]})";
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
p.reset(); p.reset();
ASSERT_TRUE(!p.foundTag()); EXPECT_FALSE(p.foundTag());
ASSERT_TRUE(!p.foundFirmware()); EXPECT_FALSE(p.foundFirmware());
ASSERT_STREQ(p.getTagName(), ""); EXPECT_STREQ(p.getTagName(), "");
ASSERT_STREQ(p.getFirmwareUrl(), ""); EXPECT_STREQ(p.getFirmwareUrl(), "");
ASSERT_EQ(p.getFirmwareSize(), 0u); EXPECT_EQ(p.getFirmwareSize(), 0u);
printf(" passed\n");
PASS();
} }
void testPartialAssetNameMatch() { TEST(ReleaseJsonParser, PartialAssetNameMatch) {
printf("testPartialAssetNameMatch...\n");
// "firmware.bin.bak" should NOT match "firmware.bin" // "firmware.bin.bak" should NOT match "firmware.bin"
const char* json = R"({ const char* json = R"({
"tag_name": "v1.0", "tag_name": "v1.0",
@@ -686,16 +522,11 @@ void testPartialAssetNameMatch() {
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_TRUE(!p.foundFirmware()); EXPECT_FALSE(p.foundFirmware());
printf(" passed\n");
PASS();
} }
void testFirmwareBinExactMatch() { TEST(ReleaseJsonParser, FirmwareBinExactMatch) {
printf("testFirmwareBinExactMatch...\n");
// Only exact "firmware.bin" matches, not similar names // Only exact "firmware.bin" matches, not similar names
const char* json = R"({ const char* json = R"({
"tag_name": "v1.0", "tag_name": "v1.0",
@@ -709,17 +540,12 @@ void testFirmwareBinExactMatch() {
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_STREQ(p.getFirmwareUrl(), "https://exact"); EXPECT_STREQ(p.getFirmwareUrl(), "https://exact");
ASSERT_EQ(p.getFirmwareSize(), 200u); EXPECT_EQ(p.getFirmwareSize(), 200u);
printf(" passed\n");
PASS();
} }
void testLargeSize() { TEST(ReleaseJsonParser, LargeSize) {
printf("testLargeSize...\n");
// 16MB firmware (maximum flash size) // 16MB firmware (maximum flash size)
const char* json = const char* json =
R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://fw","size":16777216}]})"; R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://fw","size":16777216}]})";
@@ -727,49 +553,34 @@ void testLargeSize() {
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_EQ(p.getFirmwareSize(), 16777216u); EXPECT_EQ(p.getFirmwareSize(), 16777216u);
printf(" passed\n");
PASS();
} }
void testSizeZero() { TEST(ReleaseJsonParser, SizeZero) {
printf("testSizeZero...\n");
const char* json = const char* json =
R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://fw","size":0}]})"; R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://fw","size":0}]})";
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_EQ(p.getFirmwareSize(), 0u); EXPECT_EQ(p.getFirmwareSize(), 0u);
printf(" passed\n");
PASS();
} }
void testMinimalValidJson() { TEST(ReleaseJsonParser, MinimalValidJson) {
printf("testMinimalValidJson...\n");
const char* json = R"({"tag_name":"v0","assets":[{"name":"firmware.bin","browser_download_url":"u","size":1}]})"; const char* json = R"({"tag_name":"v0","assets":[{"name":"firmware.bin","browser_download_url":"u","size":1}]})";
ReleaseJsonParser p; ReleaseJsonParser p;
p.feed(json, strlen(json)); p.feed(json, strlen(json));
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag());
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware());
ASSERT_STREQ(p.getTagName(), "v0"); EXPECT_STREQ(p.getTagName(), "v0");
ASSERT_STREQ(p.getFirmwareUrl(), "u"); EXPECT_STREQ(p.getFirmwareUrl(), "u");
ASSERT_EQ(p.getFirmwareSize(), 1u); EXPECT_EQ(p.getFirmwareSize(), 1u);
printf(" passed\n");
PASS();
} }
void testChunkedRealisticEveryBoundary() { TEST(ReleaseJsonParser, ChunkedRealisticEveryBoundary) {
printf("testChunkedRealisticEveryBoundary...\n");
// Two-chunk split at every byte boundary on a compact JSON // Two-chunk split at every byte boundary on a compact JSON
const char* json = const char* json =
R"({"tag_name":"v2.0","assets":[{"name":"firmware.bin","browser_download_url":"https://example.com/fw","size":9999}]})"; 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 > 0) p.feed(json, split);
if (split < len) p.feed(json + split, len - split); if (split < len) p.feed(json + split, len - split);
ASSERT_TRUE(p.foundTag()); EXPECT_TRUE(p.foundTag()) << "split=" << split;
ASSERT_TRUE(p.foundFirmware()); EXPECT_TRUE(p.foundFirmware()) << "split=" << split;
ASSERT_STREQ(p.getTagName(), "v2.0"); EXPECT_STREQ(p.getTagName(), "v2.0") << "split=" << split;
ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw"); EXPECT_STREQ(p.getFirmwareUrl(), "https://example.com/fw") << "split=" << split;
ASSERT_EQ(p.getFirmwareSize(), 9999u); 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;
} }
-30
View File
@@ -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" "$@"
-32
View File
@@ -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" "$@"
-29
View File
@@ -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" "$@"
-28
View File
@@ -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" "$@"
+15
View File
@@ -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)
@@ -1,37 +1,13 @@
#include <cassert> #include <gtest/gtest.h>
#include <cstdio>
#include <cstring> #include <cstring>
#include <string> #include <string>
#include <vector> #include <vector>
#include "lib/JsonParser/StreamingJsonParser.h" #include "lib/JsonParser/StreamingJsonParser.h"
static int testsPassed = 0; namespace {
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
enum class EventType { enum class EventType {
KEY, KEY,
STRING, STRING,
@@ -54,40 +30,36 @@ struct TestContext {
std::vector<Event> events; std::vector<Event> events;
}; };
static void onKey(void* ctx, const char* key, size_t len) { void onKey(void* ctx, const char* key, size_t len) {
static_cast<TestContext*>(ctx)->events.push_back({EventType::KEY, std::string(key, len)}); static_cast<TestContext*>(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<TestContext*>(ctx)->events.push_back({EventType::STRING, std::string(value, len)}); static_cast<TestContext*>(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<TestContext*>(ctx)->events.push_back({EventType::NUMBER, std::string(value, len)}); static_cast<TestContext*>(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<TestContext*>(ctx)->events.push_back({value ? EventType::BOOL_TRUE : EventType::BOOL_FALSE, {}}); static_cast<TestContext*>(ctx)->events.push_back({value ? EventType::BOOL_TRUE : EventType::BOOL_FALSE, {}});
} }
static void onNull(void* ctx) { static_cast<TestContext*>(ctx)->events.push_back({EventType::NULL_VAL, {}}); } void onNull(void* ctx) { static_cast<TestContext*>(ctx)->events.push_back({EventType::NULL_VAL, {}}); }
static void onObjectStart(void* ctx) { void onObjectStart(void* ctx) { static_cast<TestContext*>(ctx)->events.push_back({EventType::OBJECT_START, {}}); }
static_cast<TestContext*>(ctx)->events.push_back({EventType::OBJECT_START, {}}); void onObjectEnd(void* ctx) { static_cast<TestContext*>(ctx)->events.push_back({EventType::OBJECT_END, {}}); }
} void onArrayStart(void* ctx) { static_cast<TestContext*>(ctx)->events.push_back({EventType::ARRAY_START, {}}); }
static void onObjectEnd(void* ctx) { static_cast<TestContext*>(ctx)->events.push_back({EventType::OBJECT_END, {}}); } void onArrayEnd(void* ctx) { static_cast<TestContext*>(ctx)->events.push_back({EventType::ARRAY_END, {}}); }
static void onArrayStart(void* ctx) { static_cast<TestContext*>(ctx)->events.push_back({EventType::ARRAY_START, {}}); }
static void onArrayEnd(void* ctx) { static_cast<TestContext*>(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}; return {ctx, onKey, onString, onNumber, onBool, onNull, onObjectStart, onObjectEnd, onArrayStart, onArrayEnd};
} }
// Feed entire input at once std::vector<Event> parse(const char* json) {
static std::vector<Event> parse(const char* json) {
TestContext ctx; TestContext ctx;
StreamingJsonParser parser(makeCallbacks(&ctx)); StreamingJsonParser parser(makeCallbacks(&ctx));
parser.feed(json, strlen(json)); parser.feed(json, strlen(json));
return ctx.events; return ctx.events;
} }
// Feed input one byte at a time std::vector<Event> parseBytewise(const char* json) {
static std::vector<Event> parseBytewise(const char* json) {
TestContext ctx; TestContext ctx;
StreamingJsonParser parser(makeCallbacks(&ctx)); StreamingJsonParser parser(makeCallbacks(&ctx));
size_t len = strlen(json); size_t len = strlen(json);
@@ -97,176 +69,132 @@ static std::vector<Event> parseBytewise(const char* json) {
return ctx.events; return ctx.events;
} }
// ============================================================================ } // namespace
// Tests
// ============================================================================
void testSimpleObject() {
printf("testSimpleObject...\n");
TEST(StreamingJsonParser, SimpleObject) {
auto events = parse(R"({"key": "value", "num": 42})"); auto events = parse(R"({"key": "value", "num": 42})");
ASSERT_EQ(events.size(), 6u); ASSERT_EQ(events.size(), 6u);
ASSERT_EQ(events[0].type, EventType::OBJECT_START); EXPECT_EQ(events[0].type, EventType::OBJECT_START);
ASSERT_EQ(events[1].type, EventType::KEY); EXPECT_EQ(events[1].type, EventType::KEY);
ASSERT_EQ(events[1].value, "key"); EXPECT_EQ(events[1].value, "key");
ASSERT_EQ(events[2].type, EventType::STRING); EXPECT_EQ(events[2].type, EventType::STRING);
ASSERT_EQ(events[2].value, "value"); EXPECT_EQ(events[2].value, "value");
ASSERT_EQ(events[3].type, EventType::KEY); EXPECT_EQ(events[3].type, EventType::KEY);
ASSERT_EQ(events[3].value, "num"); EXPECT_EQ(events[3].value, "num");
ASSERT_EQ(events[4].type, EventType::NUMBER); EXPECT_EQ(events[4].type, EventType::NUMBER);
ASSERT_EQ(events[4].value, "42"); EXPECT_EQ(events[4].value, "42");
ASSERT_EQ(events[5].type, EventType::OBJECT_END); EXPECT_EQ(events[5].type, EventType::OBJECT_END);
printf(" passed\n");
PASS();
} }
void testNestedObjects() { TEST(StreamingJsonParser, NestedObjects) {
printf("testNestedObjects...\n");
auto events = parse(R"({"a": {"b": "c"}})"); auto events = parse(R"({"a": {"b": "c"}})");
ASSERT_EQ(events.size(), 7u); ASSERT_EQ(events.size(), 7u);
ASSERT_EQ(events[0].type, EventType::OBJECT_START); EXPECT_EQ(events[0].type, EventType::OBJECT_START);
ASSERT_EQ(events[1].type, EventType::KEY); EXPECT_EQ(events[1].type, EventType::KEY);
ASSERT_EQ(events[1].value, "a"); EXPECT_EQ(events[1].value, "a");
ASSERT_EQ(events[2].type, EventType::OBJECT_START); EXPECT_EQ(events[2].type, EventType::OBJECT_START);
ASSERT_EQ(events[3].type, EventType::KEY); EXPECT_EQ(events[3].type, EventType::KEY);
ASSERT_EQ(events[3].value, "b"); EXPECT_EQ(events[3].value, "b");
ASSERT_EQ(events[4].type, EventType::STRING); EXPECT_EQ(events[4].type, EventType::STRING);
ASSERT_EQ(events[4].value, "c"); EXPECT_EQ(events[4].value, "c");
ASSERT_EQ(events[5].type, EventType::OBJECT_END); EXPECT_EQ(events[5].type, EventType::OBJECT_END);
ASSERT_EQ(events[6].type, EventType::OBJECT_END); EXPECT_EQ(events[6].type, EventType::OBJECT_END);
printf(" passed\n");
PASS();
} }
void testArrayOfValues() { TEST(StreamingJsonParser, ArrayOfValues) {
printf("testArrayOfValues...\n");
auto events = parse(R"({"items": [1, "two", true, false, null]})"); auto events = parse(R"({"items": [1, "two", true, false, null]})");
ASSERT_EQ(events.size(), 10u); ASSERT_EQ(events.size(), 10u);
ASSERT_EQ(events[0].type, EventType::OBJECT_START); EXPECT_EQ(events[0].type, EventType::OBJECT_START);
ASSERT_EQ(events[1].type, EventType::KEY); EXPECT_EQ(events[1].type, EventType::KEY);
ASSERT_EQ(events[1].value, "items"); EXPECT_EQ(events[1].value, "items");
ASSERT_EQ(events[2].type, EventType::ARRAY_START); EXPECT_EQ(events[2].type, EventType::ARRAY_START);
ASSERT_EQ(events[3].type, EventType::NUMBER); EXPECT_EQ(events[3].type, EventType::NUMBER);
ASSERT_EQ(events[3].value, "1"); EXPECT_EQ(events[3].value, "1");
ASSERT_EQ(events[4].type, EventType::STRING); EXPECT_EQ(events[4].type, EventType::STRING);
ASSERT_EQ(events[4].value, "two"); EXPECT_EQ(events[4].value, "two");
ASSERT_EQ(events[5].type, EventType::BOOL_TRUE); EXPECT_EQ(events[5].type, EventType::BOOL_TRUE);
ASSERT_EQ(events[6].type, EventType::BOOL_FALSE); EXPECT_EQ(events[6].type, EventType::BOOL_FALSE);
ASSERT_EQ(events[7].type, EventType::NULL_VAL); EXPECT_EQ(events[7].type, EventType::NULL_VAL);
ASSERT_EQ(events[8].type, EventType::ARRAY_END); EXPECT_EQ(events[8].type, EventType::ARRAY_END);
ASSERT_EQ(events[9].type, EventType::OBJECT_END); EXPECT_EQ(events[9].type, EventType::OBJECT_END);
printf(" passed\n");
PASS();
} }
void testArrayOfObjects() { TEST(StreamingJsonParser, ArrayOfObjects) {
printf("testArrayOfObjects...\n");
auto events = parse(R"([{"a": 1}, {"b": 2}])"); auto events = parse(R"([{"a": 1}, {"b": 2}])");
ASSERT_EQ(events.size(), 10u); ASSERT_EQ(events.size(), 10u);
ASSERT_EQ(events[0].type, EventType::ARRAY_START); EXPECT_EQ(events[0].type, EventType::ARRAY_START);
ASSERT_EQ(events[1].type, EventType::OBJECT_START); EXPECT_EQ(events[1].type, EventType::OBJECT_START);
ASSERT_EQ(events[2].type, EventType::KEY); EXPECT_EQ(events[2].type, EventType::KEY);
ASSERT_EQ(events[2].value, "a"); EXPECT_EQ(events[2].value, "a");
ASSERT_EQ(events[3].type, EventType::NUMBER); EXPECT_EQ(events[3].type, EventType::NUMBER);
ASSERT_EQ(events[3].value, "1"); EXPECT_EQ(events[3].value, "1");
ASSERT_EQ(events[4].type, EventType::OBJECT_END); EXPECT_EQ(events[4].type, EventType::OBJECT_END);
ASSERT_EQ(events[5].type, EventType::OBJECT_START); EXPECT_EQ(events[5].type, EventType::OBJECT_START);
ASSERT_EQ(events[6].type, EventType::KEY); EXPECT_EQ(events[6].type, EventType::KEY);
ASSERT_EQ(events[6].value, "b"); EXPECT_EQ(events[6].value, "b");
ASSERT_EQ(events[7].type, EventType::NUMBER); EXPECT_EQ(events[7].type, EventType::NUMBER);
ASSERT_EQ(events[7].value, "2"); EXPECT_EQ(events[7].value, "2");
ASSERT_EQ(events[8].type, EventType::OBJECT_END); EXPECT_EQ(events[8].type, EventType::OBJECT_END);
ASSERT_EQ(events[9].type, EventType::ARRAY_END); EXPECT_EQ(events[9].type, EventType::ARRAY_END);
printf(" passed\n");
PASS();
} }
void testStringEscapes() { TEST(StreamingJsonParser, StringEscapes) {
printf("testStringEscapes...\n");
auto events = parse(R"({"esc": "a\"b\\c\/d\ne\tf"})"); auto events = parse(R"({"esc": "a\"b\\c\/d\ne\tf"})");
ASSERT_EQ(events.size(), 4u); ASSERT_EQ(events.size(), 4u);
ASSERT_EQ(events[2].type, EventType::STRING); EXPECT_EQ(events[2].type, EventType::STRING);
ASSERT_EQ(events[2].value, std::string("a\"b\\c/d\ne\tf")); EXPECT_EQ(events[2].value, std::string("a\"b\\c/d\ne\tf"));
printf(" passed\n");
PASS();
} }
void testUnicodeEscapePassthrough() { TEST(StreamingJsonParser, UnicodeEscapePassthrough) {
printf("testUnicodeEscapePassthrough...\n");
auto events = parse(R"({"u": "\u0041\u0042"})"); 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 // \uXXXX passed through as literal \u followed by the hex digits
ASSERT_EQ(events[2].value, "\\u0041\\u0042"); EXPECT_EQ(events[2].value, "\\u0041\\u0042");
printf(" passed\n");
PASS();
} }
void testNumbers() { TEST(StreamingJsonParser, Numbers) {
printf("testNumbers...\n");
auto events = parse(R"({"int": 42, "neg": -7, "flt": 3.14, "exp": 1e10, "nexp": -2.5E-3})"); 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.size(), 12u);
ASSERT_EQ(events[2].value, "42"); EXPECT_EQ(events[2].type, EventType::NUMBER);
ASSERT_EQ(events[4].type, EventType::NUMBER); EXPECT_EQ(events[2].value, "42");
ASSERT_EQ(events[4].value, "-7"); EXPECT_EQ(events[4].type, EventType::NUMBER);
ASSERT_EQ(events[6].type, EventType::NUMBER); EXPECT_EQ(events[4].value, "-7");
ASSERT_EQ(events[6].value, "3.14"); EXPECT_EQ(events[6].type, EventType::NUMBER);
ASSERT_EQ(events[8].type, EventType::NUMBER); EXPECT_EQ(events[6].value, "3.14");
ASSERT_EQ(events[8].value, "1e10"); EXPECT_EQ(events[8].type, EventType::NUMBER);
ASSERT_EQ(events[10].type, EventType::NUMBER); EXPECT_EQ(events[8].value, "1e10");
ASSERT_EQ(events[10].value, "-2.5E-3"); EXPECT_EQ(events[10].type, EventType::NUMBER);
EXPECT_EQ(events[10].value, "-2.5E-3");
printf(" passed\n");
PASS();
} }
void testBooleansAndNull() { TEST(StreamingJsonParser, BooleansAndNull) {
printf("testBooleansAndNull...\n");
auto events = parse(R"({"t": true, "f": false, "n": null})"); auto events = parse(R"({"t": true, "f": false, "n": null})");
ASSERT_EQ(events[2].type, EventType::BOOL_TRUE); ASSERT_EQ(events.size(), 8u);
ASSERT_EQ(events[4].type, EventType::BOOL_FALSE); EXPECT_EQ(events[2].type, EventType::BOOL_TRUE);
ASSERT_EQ(events[6].type, EventType::NULL_VAL); EXPECT_EQ(events[4].type, EventType::BOOL_FALSE);
EXPECT_EQ(events[6].type, EventType::NULL_VAL);
printf(" passed\n");
PASS();
} }
void testChunkedFeeding() { TEST(StreamingJsonParser, ChunkedFeeding) {
printf("testChunkedFeeding...\n");
const char* json = R"({"key": "value", "num": 42, "arr": [1, 2]})"; const char* json = R"({"key": "value", "num": 42, "arr": [1, 2]})";
auto reference = parse(json); auto reference = parse(json);
// Feed byte-by-byte and verify identical event sequence
auto bytewise = parseBytewise(json); auto bytewise = parseBytewise(json);
ASSERT_EQ(bytewise.size(), reference.size()); ASSERT_EQ(bytewise.size(), reference.size());
for (size_t i = 0; i < reference.size(); ++i) { for (size_t i = 0; i < reference.size(); ++i) {
ASSERT_EQ(bytewise[i].type, reference[i].type); EXPECT_EQ(bytewise[i].type, reference[i].type);
ASSERT_EQ(bytewise[i].value, reference[i].value); EXPECT_EQ(bytewise[i].value, reference[i].value);
} }
// Feed in chunks of varying size
for (size_t chunkSize = 2; chunkSize <= 7; ++chunkSize) { for (size_t chunkSize = 2; chunkSize <= 7; ++chunkSize) {
TestContext ctx; TestContext ctx;
StreamingJsonParser parser(makeCallbacks(&ctx)); StreamingJsonParser parser(makeCallbacks(&ctx));
@@ -277,20 +205,15 @@ void testChunkedFeeding() {
parser.feed(json + offset, feedLen); 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) { for (size_t i = 0; i < reference.size(); ++i) {
ASSERT_EQ(ctx.events[i].type, reference[i].type); EXPECT_EQ(ctx.events[i].type, reference[i].type) << "chunkSize=" << chunkSize << " event=" << i;
ASSERT_EQ(ctx.events[i].value, reference[i].value); 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() { TEST(StreamingJsonParser, EveryByteBoundary) {
printf("testEveryByteBoundary...\n");
const char* json = R"({"tag_name":"v1.2.3","assets":[{"name":"firmware.bin","size":12345}]})"; const char* json = R"({"tag_name":"v1.2.3","assets":[{"name":"firmware.bin","size":12345}]})";
auto reference = parse(json); auto reference = parse(json);
size_t len = strlen(json); size_t len = strlen(json);
@@ -301,137 +224,94 @@ void testEveryByteBoundary() {
if (split > 0) parser.feed(json, split); if (split > 0) parser.feed(json, split);
if (split < len) parser.feed(json + split, len - 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) { for (size_t i = 0; i < reference.size(); ++i) {
if (ctx.events[i].type != reference[i].type || ctx.events[i].value != reference[i].value) { EXPECT_EQ(ctx.events[i].type, reference[i].type) << "split=" << split << " event=" << i;
fprintf(stderr, " FAIL at split=%zu, event %zu\n", split, i); EXPECT_EQ(ctx.events[i].value, reference[i].value) << "split=" << split << " event=" << i;
testsFailed++;
return;
}
} }
} }
printf(" passed (all %zu split points)\n", len + 1);
PASS();
} }
void testLargeTokenTruncation() { TEST(StreamingJsonParser, LargeTokenTruncation) {
printf("testLargeTokenTruncation...\n");
// Build a string value that exceeds TOKEN_BUF_SIZE // Build a string value that exceeds TOKEN_BUF_SIZE
std::string longVal(StreamingJsonParser::TOKEN_BUF_SIZE + 100, 'x'); std::string longVal(StreamingJsonParser::TOKEN_BUF_SIZE + 100, 'x');
std::string json = R"({"short": "ok", "long": ")" + longVal + R"("})"; std::string json = R"({"short": "ok", "long": ")" + longVal + R"("})";
auto events = parse(json.c_str()); auto events = parse(json.c_str());
// "short" key + "ok" value should still fire ASSERT_GE(events.size(), 3u);
ASSERT_TRUE(events.size() >= 3); EXPECT_EQ(events[1].type, EventType::KEY);
ASSERT_EQ(events[1].type, EventType::KEY); EXPECT_EQ(events[1].value, "short");
ASSERT_EQ(events[1].value, "short"); EXPECT_EQ(events[2].type, EventType::STRING);
ASSERT_EQ(events[2].type, EventType::STRING); EXPECT_EQ(events[2].value, "ok");
ASSERT_EQ(events[2].value, "ok");
// The "long" key fires, but the oversized value is silently dropped
bool foundLongKey = false; bool foundLongKey = false;
bool foundLongValue = false; bool foundLongValue = false;
for (auto& e : events) { for (auto& e : events) {
if (e.type == EventType::KEY && e.value == "long") foundLongKey = true; if (e.type == EventType::KEY && e.value == "long") foundLongKey = true;
if (e.type == EventType::STRING && e.value.size() > 500) foundLongValue = true; if (e.type == EventType::STRING && e.value.size() > 500) foundLongValue = true;
} }
ASSERT_TRUE(foundLongKey); EXPECT_TRUE(foundLongKey);
ASSERT_TRUE(!foundLongValue); EXPECT_FALSE(foundLongValue);
printf(" passed\n");
PASS();
} }
void testEmptyObject() { TEST(StreamingJsonParser, EmptyObject) {
printf("testEmptyObject...\n");
auto events = parse("{}"); auto events = parse("{}");
ASSERT_EQ(events.size(), 2u); ASSERT_EQ(events.size(), 2u);
ASSERT_EQ(events[0].type, EventType::OBJECT_START); EXPECT_EQ(events[0].type, EventType::OBJECT_START);
ASSERT_EQ(events[1].type, EventType::OBJECT_END); EXPECT_EQ(events[1].type, EventType::OBJECT_END);
printf(" passed\n");
PASS();
} }
void testEmptyArray() { TEST(StreamingJsonParser, EmptyArray) {
printf("testEmptyArray...\n");
auto events = parse("[]"); auto events = parse("[]");
ASSERT_EQ(events.size(), 2u); ASSERT_EQ(events.size(), 2u);
ASSERT_EQ(events[0].type, EventType::ARRAY_START); EXPECT_EQ(events[0].type, EventType::ARRAY_START);
ASSERT_EQ(events[1].type, EventType::ARRAY_END); EXPECT_EQ(events[1].type, EventType::ARRAY_END);
printf(" passed\n");
PASS();
} }
void testNestedArrays() { TEST(StreamingJsonParser, NestedArrays) {
printf("testNestedArrays...\n");
auto events = parse("[[1, 2], [3]]"); auto events = parse("[[1, 2], [3]]");
ASSERT_EQ(events.size(), 9u); ASSERT_EQ(events.size(), 9u);
ASSERT_EQ(events[0].type, EventType::ARRAY_START); EXPECT_EQ(events[0].type, EventType::ARRAY_START);
ASSERT_EQ(events[1].type, EventType::ARRAY_START); EXPECT_EQ(events[1].type, EventType::ARRAY_START);
ASSERT_EQ(events[2].type, EventType::NUMBER); EXPECT_EQ(events[2].type, EventType::NUMBER);
ASSERT_EQ(events[2].value, "1"); EXPECT_EQ(events[2].value, "1");
ASSERT_EQ(events[3].type, EventType::NUMBER); EXPECT_EQ(events[3].type, EventType::NUMBER);
ASSERT_EQ(events[3].value, "2"); EXPECT_EQ(events[3].value, "2");
ASSERT_EQ(events[4].type, EventType::ARRAY_END); EXPECT_EQ(events[4].type, EventType::ARRAY_END);
ASSERT_EQ(events[5].type, EventType::ARRAY_START); EXPECT_EQ(events[5].type, EventType::ARRAY_START);
ASSERT_EQ(events[6].type, EventType::NUMBER); EXPECT_EQ(events[6].type, EventType::NUMBER);
ASSERT_EQ(events[6].value, "3"); EXPECT_EQ(events[6].value, "3");
ASSERT_EQ(events[7].type, EventType::ARRAY_END); EXPECT_EQ(events[7].type, EventType::ARRAY_END);
ASSERT_EQ(events[8].type, EventType::ARRAY_END); EXPECT_EQ(events[8].type, EventType::ARRAY_END);
printf(" passed\n");
PASS();
} }
void testTopLevelArray() { TEST(StreamingJsonParser, TopLevelArray) {
printf("testTopLevelArray...\n");
auto events = parse(R"(["hello", 42, true, null])"); auto events = parse(R"(["hello", 42, true, null])");
ASSERT_EQ(events.size(), 6u); ASSERT_EQ(events.size(), 6u);
ASSERT_EQ(events[0].type, EventType::ARRAY_START); EXPECT_EQ(events[0].type, EventType::ARRAY_START);
ASSERT_EQ(events[1].type, EventType::STRING); EXPECT_EQ(events[1].type, EventType::STRING);
ASSERT_EQ(events[1].value, "hello"); EXPECT_EQ(events[1].value, "hello");
ASSERT_EQ(events[2].type, EventType::NUMBER); EXPECT_EQ(events[2].type, EventType::NUMBER);
ASSERT_EQ(events[2].value, "42"); EXPECT_EQ(events[2].value, "42");
ASSERT_EQ(events[3].type, EventType::BOOL_TRUE); EXPECT_EQ(events[3].type, EventType::BOOL_TRUE);
ASSERT_EQ(events[4].type, EventType::NULL_VAL); EXPECT_EQ(events[4].type, EventType::NULL_VAL);
ASSERT_EQ(events[5].type, EventType::ARRAY_END); EXPECT_EQ(events[5].type, EventType::ARRAY_END);
printf(" passed\n");
PASS();
} }
void testWhitespaceVariants() { TEST(StreamingJsonParser, WhitespaceVariants) {
printf("testWhitespaceVariants...\n");
// Minified
auto minified = parse(R"({"a":1,"b":"x"})"); auto minified = parse(R"({"a":1,"b":"x"})");
// Pretty-printed
const char* pretty = "{\n \"a\": 1,\n \"b\": \"x\"\n}"; const char* pretty = "{\n \"a\": 1,\n \"b\": \"x\"\n}";
auto prettyEvents = parse(pretty); auto prettyEvents = parse(pretty);
ASSERT_EQ(minified.size(), prettyEvents.size()); ASSERT_EQ(minified.size(), prettyEvents.size());
for (size_t i = 0; i < minified.size(); ++i) { for (size_t i = 0; i < minified.size(); ++i) {
ASSERT_EQ(minified[i].type, prettyEvents[i].type); EXPECT_EQ(minified[i].type, prettyEvents[i].type);
ASSERT_EQ(minified[i].value, prettyEvents[i].value); EXPECT_EQ(minified[i].value, prettyEvents[i].value);
} }
printf(" passed\n");
PASS();
} }
void testResetBetweenDocuments() { TEST(StreamingJsonParser, ResetBetweenDocuments) {
printf("testResetBetweenDocuments...\n");
TestContext ctx; TestContext ctx;
StreamingJsonParser parser(makeCallbacks(&ctx)); StreamingJsonParser parser(makeCallbacks(&ctx));
@@ -445,52 +325,34 @@ void testResetBetweenDocuments() {
const char* json2 = R"({"b": 2})"; const char* json2 = R"({"b": 2})";
parser.feed(json2, strlen(json2)); parser.feed(json2, strlen(json2));
ASSERT_EQ(ctx.events.size(), 4u); ASSERT_EQ(ctx.events.size(), 4u);
ASSERT_EQ(ctx.events[1].value, "b"); EXPECT_EQ(ctx.events[1].value, "b");
ASSERT_EQ(ctx.events[2].value, "2"); EXPECT_EQ(ctx.events[2].value, "2");
printf(" passed\n");
PASS();
} }
void testNumberAtEndOfInput() { TEST(StreamingJsonParser, NumberAtEndOfInput) {
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).
auto events = parse(R"({"n": 99})"); auto events = parse(R"({"n": 99})");
bool found = false; bool found = false;
for (auto& e : events) { for (auto& e : events) {
if (e.type == EventType::NUMBER && e.value == "99") found = true; if (e.type == EventType::NUMBER && e.value == "99") found = true;
} }
ASSERT_TRUE(found); EXPECT_TRUE(found);
printf(" passed\n");
PASS();
} }
void testArrayOfStrings() { TEST(StreamingJsonParser, ArrayOfStrings) {
printf("testArrayOfStrings...\n");
auto events = parse(R"(["a", "b", "c"])"); auto events = parse(R"(["a", "b", "c"])");
ASSERT_EQ(events.size(), 5u); ASSERT_EQ(events.size(), 5u);
ASSERT_EQ(events[0].type, EventType::ARRAY_START); EXPECT_EQ(events[0].type, EventType::ARRAY_START);
ASSERT_EQ(events[1].type, EventType::STRING); EXPECT_EQ(events[1].type, EventType::STRING);
ASSERT_EQ(events[1].value, "a"); EXPECT_EQ(events[1].value, "a");
ASSERT_EQ(events[2].type, EventType::STRING); EXPECT_EQ(events[2].type, EventType::STRING);
ASSERT_EQ(events[2].value, "b"); EXPECT_EQ(events[2].value, "b");
ASSERT_EQ(events[3].type, EventType::STRING); EXPECT_EQ(events[3].type, EventType::STRING);
ASSERT_EQ(events[3].value, "c"); EXPECT_EQ(events[3].value, "c");
ASSERT_EQ(events[4].type, EventType::ARRAY_END); EXPECT_EQ(events[4].type, EventType::ARRAY_END);
printf(" passed\n");
PASS();
} }
void testTruncatedInputNoCrash() { TEST(StreamingJsonParser, TruncatedInputNoCrash) {
printf("testTruncatedInputNoCrash...\n");
// Simulates a connection drop mid-JSON. Parser must not crash.
const char* truncated[] = { const char* truncated[] = {
R"({"key": "val)", R"({"key": )", R"({"key)", R"([1, 2, )", R"({"key": "val)", R"({"key": )", R"({"key)", R"([1, 2, )",
R"({"a": tru)", R"({"a": fal)", R"({"a": nul)", R"({"a": "hello\)", R"({"a": tru)", R"({"a": fal)", R"({"a": nul)", R"({"a": "hello\)",
@@ -502,49 +364,36 @@ void testTruncatedInputNoCrash() {
parser.feed(json, strlen(json)); parser.feed(json, strlen(json));
// Just verify no crash; partial results are acceptable // Just verify no crash; partial results are acceptable
} }
SUCCEED();
printf(" passed (no crashes on %d truncated inputs)\n", 8);
PASS();
} }
void testAllEscapeSequences() { TEST(StreamingJsonParser, AllEscapeSequences) {
printf("testAllEscapeSequences...\n");
auto events = parse(R"({"e": "\b\f\n\r\t\"\\\/"})"); auto events = parse(R"({"e": "\b\f\n\r\t\"\\\/"})");
ASSERT_EQ(events[2].type, EventType::STRING); ASSERT_EQ(events.size(), 4u);
ASSERT_EQ(events[2].value, std::string("\b\f\n\r\t\"\\/")); EXPECT_EQ(events[2].type, EventType::STRING);
EXPECT_EQ(events[2].value, std::string("\b\f\n\r\t\"\\/"));
printf(" passed\n");
PASS();
} }
void testObjectInArray() { TEST(StreamingJsonParser, ObjectInArray) {
printf("testObjectInArray...\n");
// After an object closes inside an array, the next string after comma // After an object closes inside an array, the next string after comma
// should be correctly identified as a key (inside the next object) or // should be correctly identified as a key (inside the next object) or
// a string value (if directly in the array). // a string value (if directly in the array).
auto events = parse(R"([{"k":"v"}, "bare"])"); auto events = parse(R"([{"k":"v"}, "bare"])");
ASSERT_EQ(events.size(), 7u); ASSERT_EQ(events.size(), 7u);
ASSERT_EQ(events[0].type, EventType::ARRAY_START); EXPECT_EQ(events[0].type, EventType::ARRAY_START);
ASSERT_EQ(events[1].type, EventType::OBJECT_START); EXPECT_EQ(events[1].type, EventType::OBJECT_START);
ASSERT_EQ(events[2].type, EventType::KEY); EXPECT_EQ(events[2].type, EventType::KEY);
ASSERT_EQ(events[2].value, "k"); EXPECT_EQ(events[2].value, "k");
ASSERT_EQ(events[3].type, EventType::STRING); EXPECT_EQ(events[3].type, EventType::STRING);
ASSERT_EQ(events[3].value, "v"); EXPECT_EQ(events[3].value, "v");
ASSERT_EQ(events[4].type, EventType::OBJECT_END); EXPECT_EQ(events[4].type, EventType::OBJECT_END);
ASSERT_EQ(events[5].type, EventType::STRING); EXPECT_EQ(events[5].type, EventType::STRING);
ASSERT_EQ(events[5].value, "bare"); EXPECT_EQ(events[5].value, "bare");
ASSERT_EQ(events[6].type, EventType::ARRAY_END); EXPECT_EQ(events[6].type, EventType::ARRAY_END);
printf(" passed\n");
PASS();
} }
void testDeeplyNested() { TEST(StreamingJsonParser, DeeplyNested) {
printf("testDeeplyNested...\n");
// 20 levels of nesting (well within MAX_NESTING=32) // 20 levels of nesting (well within MAX_NESTING=32)
std::string json; std::string json;
for (int i = 0; i < 20; ++i) json += R"({"d":)"; 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 // 20 OBJECT_START + 20 KEY + 1 NUMBER + 20 OBJECT_END = 61
ASSERT_EQ(events.size(), 61u); ASSERT_EQ(events.size(), 61u);
ASSERT_EQ(events[0].type, EventType::OBJECT_START); EXPECT_EQ(events[0].type, EventType::OBJECT_START);
ASSERT_EQ(events[40].type, EventType::NUMBER); EXPECT_EQ(events[40].type, EventType::NUMBER);
ASSERT_EQ(events[40].value, "0"); EXPECT_EQ(events[40].value, "0");
ASSERT_EQ(events[60].type, EventType::OBJECT_END); EXPECT_EQ(events[60].type, EventType::OBJECT_END);
printf(" passed\n");
PASS();
} }
void testNestingOverflow() { TEST(StreamingJsonParser, NestingOverflow) {
printf("testNestingOverflow...\n");
// Exceed MAX_NESTING -- parser should set error flag, not crash // Exceed MAX_NESTING -- parser should set error flag, not crash
std::string json; std::string json;
for (size_t i = 0; i < StreamingJsonParser::MAX_NESTING + 5; ++i) json += "["; for (size_t i = 0; i < StreamingJsonParser::MAX_NESTING + 5; ++i) json += "[";
@@ -575,47 +419,32 @@ void testNestingOverflow() {
StreamingJsonParser parser(makeCallbacks(&ctx)); StreamingJsonParser parser(makeCallbacks(&ctx));
parser.feed(json.c_str(), json.size()); parser.feed(json.c_str(), json.size());
ASSERT_TRUE(parser.hasError()); EXPECT_TRUE(parser.hasError());
printf(" passed\n");
PASS();
} }
void testNumberZero() { TEST(StreamingJsonParser, NumberZero) {
printf("testNumberZero...\n");
auto events = parse(R"({"z": 0})"); auto events = parse(R"({"z": 0})");
ASSERT_EQ(events[2].type, EventType::NUMBER); ASSERT_EQ(events.size(), 4u);
ASSERT_EQ(events[2].value, "0"); EXPECT_EQ(events[2].type, EventType::NUMBER);
EXPECT_EQ(events[2].value, "0");
printf(" passed\n");
PASS();
} }
void testMultipleValuesInObject() { TEST(StreamingJsonParser, MultipleValuesInObject) {
printf("testMultipleValuesInObject...\n");
auto events = parse(R"({"a": "x", "b": "y", "c": "z"})"); auto events = parse(R"({"a": "x", "b": "y", "c": "z"})");
ASSERT_EQ(events.size(), 8u); ASSERT_EQ(events.size(), 8u);
ASSERT_EQ(events[1].value, "a"); EXPECT_EQ(events[1].value, "a");
ASSERT_EQ(events[2].value, "x"); EXPECT_EQ(events[2].value, "x");
ASSERT_EQ(events[3].value, "b"); EXPECT_EQ(events[3].value, "b");
ASSERT_EQ(events[4].value, "y"); EXPECT_EQ(events[4].value, "y");
ASSERT_EQ(events[5].value, "c"); EXPECT_EQ(events[5].value, "c");
ASSERT_EQ(events[6].value, "z"); EXPECT_EQ(events[6].value, "z");
printf(" passed\n");
PASS();
} }
void testChunkedSplitInsideString() { TEST(StreamingJsonParser, ChunkedSplitInsideString) {
printf("testChunkedSplitInsideString...\n");
const char* json = R"({"key": "hello world"})"; const char* json = R"({"key": "hello world"})";
auto reference = parse(json); auto reference = parse(json);
// Split right in the middle of "hello world"
size_t splitAt = 14; // inside the string value size_t splitAt = 14; // inside the string value
TestContext ctx; TestContext ctx;
StreamingJsonParser parser(makeCallbacks(&ctx)); StreamingJsonParser parser(makeCallbacks(&ctx));
@@ -624,23 +453,17 @@ void testChunkedSplitInsideString() {
ASSERT_EQ(ctx.events.size(), reference.size()); ASSERT_EQ(ctx.events.size(), reference.size());
for (size_t i = 0; i < reference.size(); ++i) { for (size_t i = 0; i < reference.size(); ++i) {
ASSERT_EQ(ctx.events[i].type, reference[i].type); EXPECT_EQ(ctx.events[i].type, reference[i].type);
ASSERT_EQ(ctx.events[i].value, reference[i].value); EXPECT_EQ(ctx.events[i].value, reference[i].value);
} }
printf(" passed\n");
PASS();
} }
void testChunkedSplitInsideEscape() { TEST(StreamingJsonParser, ChunkedSplitInsideEscape) {
printf("testChunkedSplitInsideEscape...\n");
const char* json = R"({"k": "a\"b"})"; const char* json = R"({"k": "a\"b"})";
auto reference = parse(json); auto reference = parse(json);
// Find the backslash position and split right after it
const char* bs = strchr(json + 7, '\\'); const char* bs = strchr(json + 7, '\\');
ASSERT_TRUE(bs != nullptr); ASSERT_NE(bs, nullptr);
size_t splitAt = static_cast<size_t>(bs - json) + 1; // after the backslash size_t splitAt = static_cast<size_t>(bs - json) + 1; // after the backslash
TestContext ctx; TestContext ctx;
@@ -650,22 +473,16 @@ void testChunkedSplitInsideEscape() {
ASSERT_EQ(ctx.events.size(), reference.size()); ASSERT_EQ(ctx.events.size(), reference.size());
for (size_t i = 0; i < reference.size(); ++i) { for (size_t i = 0; i < reference.size(); ++i) {
ASSERT_EQ(ctx.events[i].type, reference[i].type); EXPECT_EQ(ctx.events[i].type, reference[i].type);
ASSERT_EQ(ctx.events[i].value, reference[i].value); EXPECT_EQ(ctx.events[i].value, reference[i].value);
} }
printf(" passed\n");
PASS();
} }
void testChunkedSplitInsideLiteral() { TEST(StreamingJsonParser, ChunkedSplitInsideLiteral) {
printf("testChunkedSplitInsideLiteral...\n");
const char* json = R"({"a": true, "b": false, "c": null})"; const char* json = R"({"a": true, "b": false, "c": null})";
auto reference = parse(json); auto reference = parse(json);
// Split inside "true" (at "tr|ue") size_t splitAt = 7; // inside "true"
size_t splitAt = 7;
TestContext ctx; TestContext ctx;
StreamingJsonParser parser(makeCallbacks(&ctx)); StreamingJsonParser parser(makeCallbacks(&ctx));
parser.feed(json, splitAt); parser.feed(json, splitAt);
@@ -673,65 +490,17 @@ void testChunkedSplitInsideLiteral() {
ASSERT_EQ(ctx.events.size(), reference.size()); ASSERT_EQ(ctx.events.size(), reference.size());
for (size_t i = 0; i < reference.size(); ++i) { for (size_t i = 0; i < reference.size(); ++i) {
ASSERT_EQ(ctx.events[i].type, reference[i].type); EXPECT_EQ(ctx.events[i].type, reference[i].type);
ASSERT_EQ(ctx.events[i].value, reference[i].value); EXPECT_EQ(ctx.events[i].value, reference[i].value);
} }
printf(" passed\n");
PASS();
} }
void testNullCallbacksNoCrash() { TEST(StreamingJsonParser, NullCallbacksNoCrash) {
printf("testNullCallbacksNoCrash...\n");
JsonCallbacks nullCbs = {}; JsonCallbacks nullCbs = {};
nullCbs.ctx = nullptr; nullCbs.ctx = nullptr;
StreamingJsonParser parser(nullCbs); StreamingJsonParser parser(nullCbs);
const char* json = R"({"key": "value", "num": 42, "b": true, "n": null, "a": [1]})"; const char* json = R"({"key": "value", "num": 42, "b": true, "n": null, "a": [1]})";
parser.feed(json, strlen(json)); parser.feed(json, strlen(json));
ASSERT_TRUE(!parser.hasError()); EXPECT_FALSE(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;
} }