diff --git a/lib/JsonParser/ReleaseJsonParser.cpp b/lib/JsonParser/ReleaseJsonParser.cpp new file mode 100644 index 00000000..1f1d8ab3 --- /dev/null +++ b/lib/JsonParser/ReleaseJsonParser.cpp @@ -0,0 +1,208 @@ +#include "ReleaseJsonParser.h" + +#include +#include + +namespace { + +void safeCopy(char* dst, size_t dstSize, const char* src, size_t srcLen) { + size_t n = srcLen < dstSize - 1 ? srcLen : dstSize - 1; + memcpy(dst, src, n); + dst[n] = '\0'; +} + +} // namespace + +ReleaseJsonParser::ReleaseJsonParser() + : parser(JsonCallbacks{this, sOnKey, sOnString, sOnNumber, sOnBool, sOnNull, sOnObjectStart, sOnObjectEnd, + sOnArrayStart, sOnArrayEnd}) { + reset(); +} + +void ReleaseJsonParser::reset() { + parser.reset(); + position = Position::TOP_LEVEL; + lastKey = LastKey::NONE; + depth = 0; + assetDepth = 0; + tagName[0] = '\0'; + firmwareUrl[0] = '\0'; + firmwareSize = 0; + tagFound = false; + firmwareFound = false; + currentAssetName[0] = '\0'; + currentAssetUrl[0] = '\0'; + currentAssetSize = 0; +} + +void ReleaseJsonParser::feed(const char* data, size_t len) { parser.feed(data, len); } + +bool ReleaseJsonParser::foundTag() const { return tagFound; } +bool ReleaseJsonParser::foundFirmware() const { return firmwareFound; } +const char* ReleaseJsonParser::getTagName() const { return tagName; } +const char* ReleaseJsonParser::getFirmwareUrl() const { return firmwareUrl; } +size_t ReleaseJsonParser::getFirmwareSize() const { return firmwareSize; } + +void ReleaseJsonParser::commitAsset() { + if (strcmp(currentAssetName, "firmware.bin") == 0) { + memcpy(firmwareUrl, currentAssetUrl, sizeof(firmwareUrl)); + firmwareSize = currentAssetSize; + firmwareFound = true; + } + currentAssetName[0] = '\0'; + currentAssetUrl[0] = '\0'; + currentAssetSize = 0; +} + +// -- SAX callbacks (static trampolines) ------------------------------------- + +void ReleaseJsonParser::sOnKey(void* ctx, const char* key, size_t len) { + auto* self = static_cast(ctx); + + switch (self->position) { + case Position::TOP_LEVEL: + if (self->depth == 1) { + if (len == 8 && memcmp(key, "tag_name", 8) == 0) + self->lastKey = LastKey::TAG_NAME; + else if (len == 6 && memcmp(key, "assets", 6) == 0) + self->lastKey = LastKey::ASSETS; + else + self->lastKey = LastKey::NONE; + } + break; + case Position::IN_ASSET_OBJECT: + if (self->assetDepth == 1) { + if (len == 4 && memcmp(key, "name", 4) == 0) + self->lastKey = LastKey::ASSET_NAME; + else if (len == 20 && memcmp(key, "browser_download_url", 20) == 0) + self->lastKey = LastKey::ASSET_URL; + else if (len == 4 && memcmp(key, "size", 4) == 0) + self->lastKey = LastKey::ASSET_SIZE; + else + self->lastKey = LastKey::NONE; + } + break; + default: + break; + } +} + +void ReleaseJsonParser::sOnString(void* ctx, const char* value, size_t len) { + auto* self = static_cast(ctx); + + switch (self->lastKey) { + case LastKey::TAG_NAME: + if (self->position == Position::TOP_LEVEL && self->depth == 1) { + safeCopy(self->tagName, sizeof(self->tagName), value, len); + self->tagFound = true; + } + break; + case LastKey::ASSET_NAME: + if (self->position == Position::IN_ASSET_OBJECT && self->assetDepth == 1) + safeCopy(self->currentAssetName, sizeof(self->currentAssetName), value, len); + break; + case LastKey::ASSET_URL: + if (self->position == Position::IN_ASSET_OBJECT && self->assetDepth == 1) + safeCopy(self->currentAssetUrl, sizeof(self->currentAssetUrl), value, len); + break; + default: + break; + } + self->lastKey = LastKey::NONE; +} + +void ReleaseJsonParser::sOnNumber(void* ctx, const char* value, size_t /*len*/) { + auto* self = static_cast(ctx); + + if (self->lastKey == LastKey::ASSET_SIZE && self->position == Position::IN_ASSET_OBJECT && self->assetDepth == 1) { + self->currentAssetSize = static_cast(strtoul(value, nullptr, 10)); + } + self->lastKey = LastKey::NONE; +} + +void ReleaseJsonParser::sOnBool(void* ctx, bool /*value*/) { + static_cast(ctx)->lastKey = LastKey::NONE; +} + +void ReleaseJsonParser::sOnNull(void* ctx) { static_cast(ctx)->lastKey = LastKey::NONE; } + +void ReleaseJsonParser::sOnObjectStart(void* ctx) { + auto* self = static_cast(ctx); + + switch (self->position) { + case Position::TOP_LEVEL: + self->depth++; + self->lastKey = LastKey::NONE; + break; + case Position::IN_ASSETS_ARRAY: + self->position = Position::IN_ASSET_OBJECT; + self->assetDepth = 1; + self->currentAssetName[0] = '\0'; + self->currentAssetUrl[0] = '\0'; + self->currentAssetSize = 0; + self->lastKey = LastKey::NONE; + break; + case Position::IN_ASSET_OBJECT: + self->assetDepth++; + self->lastKey = LastKey::NONE; + break; + } +} + +void ReleaseJsonParser::sOnObjectEnd(void* ctx) { + auto* self = static_cast(ctx); + + switch (self->position) { + case Position::TOP_LEVEL: + if (self->depth > 0) self->depth--; + break; + case Position::IN_ASSET_OBJECT: + self->assetDepth--; + if (self->assetDepth == 0) { + self->commitAsset(); + self->position = Position::IN_ASSETS_ARRAY; + } + self->lastKey = LastKey::NONE; + break; + default: + break; + } +} + +void ReleaseJsonParser::sOnArrayStart(void* ctx) { + auto* self = static_cast(ctx); + + switch (self->position) { + case Position::TOP_LEVEL: + if (self->lastKey == LastKey::ASSETS && self->depth == 1) { + self->position = Position::IN_ASSETS_ARRAY; + } else { + self->depth++; + } + self->lastKey = LastKey::NONE; + break; + case Position::IN_ASSET_OBJECT: + self->assetDepth++; + self->lastKey = LastKey::NONE; + break; + default: + break; + } +} + +void ReleaseJsonParser::sOnArrayEnd(void* ctx) { + auto* self = static_cast(ctx); + + switch (self->position) { + case Position::TOP_LEVEL: + if (self->depth > 0) self->depth--; + break; + case Position::IN_ASSETS_ARRAY: + self->position = Position::TOP_LEVEL; + break; + case Position::IN_ASSET_OBJECT: + self->assetDepth--; + self->lastKey = LastKey::NONE; + break; + } +} diff --git a/lib/JsonParser/ReleaseJsonParser.h b/lib/JsonParser/ReleaseJsonParser.h new file mode 100644 index 00000000..d5841922 --- /dev/null +++ b/lib/JsonParser/ReleaseJsonParser.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include + +#include "StreamingJsonParser.h" + +class ReleaseJsonParser { + public: + ReleaseJsonParser(); + + ReleaseJsonParser(const ReleaseJsonParser&) = delete; + ReleaseJsonParser& operator=(const ReleaseJsonParser&) = delete; + + void reset(); + void feed(const char* data, size_t len); + + bool foundTag() const; + bool foundFirmware() const; + const char* getTagName() const; + const char* getFirmwareUrl() const; + size_t getFirmwareSize() const; + + private: + enum class Position : uint8_t { + TOP_LEVEL, + IN_ASSETS_ARRAY, + IN_ASSET_OBJECT, + }; + + enum class LastKey : uint8_t { + NONE, + TAG_NAME, + ASSETS, + ASSET_NAME, + ASSET_URL, + ASSET_SIZE, + }; + + static void sOnKey(void* ctx, const char* key, size_t len); + static void sOnString(void* ctx, const char* value, size_t len); + static void sOnNumber(void* ctx, const char* value, size_t len); + static void sOnBool(void* ctx, bool value); + static void sOnNull(void* ctx); + static void sOnObjectStart(void* ctx); + static void sOnObjectEnd(void* ctx); + static void sOnArrayStart(void* ctx); + static void sOnArrayEnd(void* ctx); + + void commitAsset(); + + StreamingJsonParser parser; + + Position position; + LastKey lastKey; + uint8_t depth; + uint8_t assetDepth; + + char tagName[32]; + char firmwareUrl[512]; + size_t firmwareSize; + bool tagFound; + bool firmwareFound; + + char currentAssetName[32]; + char currentAssetUrl[512]; + size_t currentAssetSize; +}; diff --git a/lib/JsonParser/StreamingJsonParser.cpp b/lib/JsonParser/StreamingJsonParser.cpp new file mode 100644 index 00000000..45d9b435 --- /dev/null +++ b/lib/JsonParser/StreamingJsonParser.cpp @@ -0,0 +1,249 @@ +#include "StreamingJsonParser.h" + +#include + +StreamingJsonParser::StreamingJsonParser(const JsonCallbacks& callbacks) : cb(callbacks) { reset(); } + +void StreamingJsonParser::reset() { + tokenLen = 0; + state = State::SCANNING; + expectingValue = false; + escaped = false; + tokenOverflow = false; + error = false; + nestingDepth = 0; + literalLen = 0; + literalPos = 0; +} + +void StreamingJsonParser::feed(const char* data, size_t len) { + for (size_t i = 0; i < len && !error; ++i) { + char c = data[i]; + switch (state) { + case State::SCANNING: + handleScanning(c); + break; + case State::IN_STRING_KEY: + case State::IN_STRING_VALUE: + handleStringChar(c); + break; + case State::IN_NUMBER: + handleNumber(c); + break; + case State::IN_LITERAL: + handleLiteral(c); + break; + case State::SKIP_STRING: + handleSkipString(c); + break; + } + } +} + +void StreamingJsonParser::handleScanning(char c) { + switch (c) { + case '"': + tokenLen = 0; + tokenOverflow = false; + if (expectingValue || inArray()) { + state = State::IN_STRING_VALUE; + } else { + state = State::IN_STRING_KEY; + } + break; + case '{': + if (nestingDepth < MAX_NESTING) { + nestingStack[nestingDepth++] = Container::OBJECT; + } else { + error = true; + return; + } + if (cb.onObjectStart) cb.onObjectStart(cb.ctx); + expectingValue = false; + break; + case '}': + if (cb.onObjectEnd) cb.onObjectEnd(cb.ctx); + if (nestingDepth > 0) --nestingDepth; + expectingValue = false; + break; + case '[': + if (nestingDepth < MAX_NESTING) { + nestingStack[nestingDepth++] = Container::ARRAY; + } else { + error = true; + return; + } + if (cb.onArrayStart) cb.onArrayStart(cb.ctx); + expectingValue = false; + break; + case ']': + if (cb.onArrayEnd) cb.onArrayEnd(cb.ctx); + if (nestingDepth > 0) --nestingDepth; + expectingValue = false; + break; + case ':': + expectingValue = true; + break; + case ',': + expectingValue = false; + break; + case 't': + if (expectingValue || inArray()) { + memcpy(literalExpected, "true", 4); + literalLen = 4; + literalPos = 1; + state = State::IN_LITERAL; + } + break; + case 'f': + if (expectingValue || inArray()) { + memcpy(literalExpected, "false", 5); + literalLen = 5; + literalPos = 1; + state = State::IN_LITERAL; + } + break; + case 'n': + if (expectingValue || inArray()) { + memcpy(literalExpected, "null", 4); + literalLen = 4; + literalPos = 1; + state = State::IN_LITERAL; + } + break; + default: + if ((expectingValue || inArray()) && (c == '-' || (c >= '0' && c <= '9'))) { + tokenLen = 0; + tokenOverflow = false; + appendToken(c); + state = State::IN_NUMBER; + } + break; + } +} + +void StreamingJsonParser::handleStringChar(char c) { + if (escaped) { + escaped = false; + switch (c) { + case '"': + case '\\': + case '/': + appendToken(c); + break; + case 'b': + appendToken('\b'); + break; + case 'f': + appendToken('\f'); + break; + case 'n': + appendToken('\n'); + break; + case 'r': + appendToken('\r'); + break; + case 't': + appendToken('\t'); + break; + case 'u': + // Pass \uXXXX through as literal characters -- we don't decode + // Unicode escapes since our use case only needs ASCII field matching. + appendToken('\\'); + appendToken('u'); + break; + default: + appendToken('\\'); + appendToken(c); + break; + } + return; + } + + if (c == '\\') { + escaped = true; + return; + } + + if (c == '"') { + emitToken(); + return; + } + + appendToken(c); +} + +void StreamingJsonParser::handleNumber(char c) { + if ((c >= '0' && c <= '9') || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E') { + appendToken(c); + return; + } + + if (!tokenOverflow && cb.onNumber) { + tokenBuf[tokenLen] = '\0'; + cb.onNumber(cb.ctx, tokenBuf, tokenLen); + } + state = State::SCANNING; + expectingValue = false; + + handleScanning(c); +} + +void StreamingJsonParser::handleLiteral(char c) { + if (c == literalExpected[literalPos]) { + ++literalPos; + if (literalPos == literalLen) { + if (literalExpected[0] == 't') { + if (cb.onBool) cb.onBool(cb.ctx, true); + } else if (literalExpected[0] == 'f') { + if (cb.onBool) cb.onBool(cb.ctx, false); + } else { + if (cb.onNull) cb.onNull(cb.ctx); + } + state = State::SCANNING; + expectingValue = false; + } + } else { + error = true; + } +} + +void StreamingJsonParser::handleSkipString(char c) { + if (escaped) { + escaped = false; + return; + } + if (c == '\\') { + escaped = true; + return; + } + if (c == '"') { + state = State::SCANNING; + expectingValue = false; + } +} + +void StreamingJsonParser::appendToken(char c) { + if (tokenLen < TOKEN_BUF_SIZE - 1) { + tokenBuf[tokenLen++] = c; + } else { + tokenOverflow = true; + } +} + +void StreamingJsonParser::emitToken() { + if (state == State::IN_STRING_KEY) { + if (!tokenOverflow && cb.onKey) { + tokenBuf[tokenLen] = '\0'; + cb.onKey(cb.ctx, tokenBuf, tokenLen); + } + state = State::SCANNING; + } else { + if (!tokenOverflow && cb.onString) { + tokenBuf[tokenLen] = '\0'; + cb.onString(cb.ctx, tokenBuf, tokenLen); + } + state = State::SCANNING; + expectingValue = false; + } +} diff --git a/lib/JsonParser/StreamingJsonParser.h b/lib/JsonParser/StreamingJsonParser.h new file mode 100644 index 00000000..6c360342 --- /dev/null +++ b/lib/JsonParser/StreamingJsonParser.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include + +struct JsonCallbacks { + void* ctx; + void (*onKey)(void* ctx, const char* key, size_t len); + void (*onString)(void* ctx, const char* value, size_t len); + void (*onNumber)(void* ctx, const char* value, size_t len); + void (*onBool)(void* ctx, bool value); + void (*onNull)(void* ctx); + void (*onObjectStart)(void* ctx); + void (*onObjectEnd)(void* ctx); + void (*onArrayStart)(void* ctx); + void (*onArrayEnd)(void* ctx); +}; + +class StreamingJsonParser { + public: + static constexpr size_t TOKEN_BUF_SIZE = 512; + static constexpr size_t MAX_NESTING = 32; + + explicit StreamingJsonParser(const JsonCallbacks& callbacks); + + void reset(); + void feed(const char* data, size_t len); + + bool hasError() const { return error; } + + private: + enum class State : uint8_t { + SCANNING, + IN_STRING_KEY, + IN_STRING_VALUE, + IN_NUMBER, + IN_LITERAL, + SKIP_STRING, + }; + + enum class Container : uint8_t { + NONE, + OBJECT, + ARRAY, + }; + + void handleScanning(char c); + void handleStringChar(char c); + void handleNumber(char c); + void handleLiteral(char c); + void handleSkipString(char c); + + void appendToken(char c); + void emitToken(); + + bool inArray() const { return nestingDepth > 0 && nestingStack[nestingDepth - 1] == Container::ARRAY; } + + JsonCallbacks cb; + char tokenBuf[TOKEN_BUF_SIZE]; + size_t tokenLen; + State state; + bool expectingValue; + bool escaped; + bool tokenOverflow; + bool error; + + Container nestingStack[MAX_NESTING]; + uint8_t nestingDepth; + + char literalExpected[6]; + uint8_t literalLen; + uint8_t literalPos; +}; diff --git a/src/network/OtaUpdater.cpp b/src/network/OtaUpdater.cpp index 4f742559..6c05d553 100644 --- a/src/network/OtaUpdater.cpp +++ b/src/network/OtaUpdater.cpp @@ -1,91 +1,48 @@ #include "OtaUpdater.h" -#include #include - -#include "esp_http_client.h" -#include "esp_https_ota.h" -#include "esp_wifi.h" +#include +#include +#include +#include +#include namespace { constexpr char latestReleaseUrl[] = "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/latest"; -/* This is buffer and size holder to keep upcoming data from latestReleaseUrl */ -char* local_buf; -int output_len; - -/* - * When esp_crt_bundle.h included, it is pointing wrong header file - * which is something under WifiClientSecure because of our framework based on arduno platform. - * To manage this obstacle, don't include anything, just extern and it will point correct one. - */ -extern "C" { -extern esp_err_t esp_crt_bundle_attach(void* conf); -} - esp_err_t http_client_set_header_cb(esp_http_client_handle_t http_client) { return esp_http_client_set_header(http_client, "User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); } +size_t totalBytesReceived = 0; + esp_err_t event_handler(esp_http_client_event_t* event) { - /* We do interested in only HTTP_EVENT_ON_DATA event only */ if (event->event_id != HTTP_EVENT_ON_DATA) return ESP_OK; - - if (!esp_http_client_is_chunked_response(event->client)) { - int content_len = esp_http_client_get_content_length(event->client); - int copy_len = 0; - - if (local_buf == NULL) { - /* local_buf life span is tracked by caller checkForUpdate */ - local_buf = static_cast(calloc(content_len + 1, sizeof(char))); - output_len = 0; - if (local_buf == NULL) { - LOG_ERR("OTA", "HTTP Client Out of Memory Failed, Allocation %d", content_len); - return ESP_ERR_NO_MEM; - } - } - copy_len = min(event->data_len, (content_len - output_len)); - if (copy_len) { - memcpy(local_buf + output_len, event->data, copy_len); - } - output_len += copy_len; - } else { - /* Code might be hits here, It happened once (for version checking) but I need more logs to handle that */ - int chunked_len; - esp_http_client_get_chunk_length(event->client, &chunked_len); - LOG_DBG("OTA", "esp_http_client_is_chunked_response failed, chunked_len: %d", chunked_len); - } - + totalBytesReceived += event->data_len; + LOG_DBG("OTA", "HTTP chunk: %d bytes (total: %zu)", event->data_len, totalBytesReceived); + auto* parser = static_cast(event->user_data); + parser->feed(static_cast(event->data), event->data_len); return ESP_OK; -} /* event_handler */ -} /* namespace */ +} +} // namespace OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { - JsonDocument filter; esp_err_t esp_err; - JsonDocument doc; + ReleaseJsonParser releaseParser; esp_http_client_config_t client_config = { .url = latestReleaseUrl, .event_handler = event_handler, - /* Default HTTP client buffer size 512 byte only */ .buffer_size = 8192, .buffer_size_tx = 8192, + .user_data = &releaseParser, .skip_cert_common_name_check = true, .crt_bundle_attach = esp_crt_bundle_attach, .keep_alive_enable = true, }; - /* To track life time of local_buf, dtor will be called on exit from that function */ - struct localBufCleaner { - char** bufPtr; - ~localBufCleaner() { - if (*bufPtr) { - free(*bufPtr); - *bufPtr = NULL; - } - } - } localBufCleaner = {&local_buf}; + totalBytesReceived = 0; + LOG_DBG("OTA", "Checking for update (current: %s)", CROSSPOINT_VERSION); esp_http_client_handle_t client_handle = esp_http_client_init(&client_config); if (!client_handle) { @@ -107,51 +64,34 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { return HTTP_ERROR; } - /* esp_http_client_close will be called inside cleanup as well*/ esp_err = esp_http_client_cleanup(client_handle); if (esp_err != ESP_OK) { LOG_ERR("OTA", "esp_http_client_cleanup Failed : %s", esp_err_to_name(esp_err)); return INTERNAL_UPDATE_ERROR; } - filter["tag_name"] = true; - filter["assets"][0]["name"] = true; - filter["assets"][0]["browser_download_url"] = true; - filter["assets"][0]["size"] = true; - const DeserializationError error = deserializeJson(doc, local_buf, DeserializationOption::Filter(filter)); - if (error) { - LOG_ERR("OTA", "JSON parse failed: %s", error.c_str()); + LOG_DBG("OTA", "Response received: %zu bytes total", totalBytesReceived); + LOG_DBG("OTA", "Parser results: tag=%s firmware=%s", releaseParser.foundTag() ? "yes" : "no", + releaseParser.foundFirmware() ? "yes" : "no"); + + if (!releaseParser.foundTag()) { + LOG_ERR("OTA", "No tag_name in release JSON"); return JSON_PARSE_ERROR; } - if (!doc["tag_name"].is()) { - LOG_ERR("OTA", "No tag_name found"); - return JSON_PARSE_ERROR; - } - - if (!doc["assets"].is()) { - LOG_ERR("OTA", "No assets found"); - return JSON_PARSE_ERROR; - } - - latestVersion = doc["tag_name"].as(); - - for (int i = 0; i < doc["assets"].size(); i++) { - if (doc["assets"][i]["name"] == "firmware.bin") { - otaUrl = doc["assets"][i]["browser_download_url"].as(); - otaSize = doc["assets"][i]["size"].as(); - totalSize = otaSize; - updateAvailable = true; - break; - } - } - - if (!updateAvailable) { + if (!releaseParser.foundFirmware()) { LOG_ERR("OTA", "No firmware.bin asset found"); return NO_UPDATE; } - LOG_DBG("OTA", "Found update: %s", latestVersion.c_str()); + latestVersion = releaseParser.getTagName(); + otaUrl = releaseParser.getFirmwareUrl(); + otaSize = releaseParser.getFirmwareSize(); + totalSize = otaSize; + updateAvailable = true; + + LOG_DBG("OTA", "Found update: tag=%s size=%zu", latestVersion.c_str(), otaSize); + LOG_DBG("OTA", "Firmware URL: %s", otaUrl.c_str()); return OK; } diff --git a/test/release_json_parser/ReleaseJsonParserTest.cpp b/test/release_json_parser/ReleaseJsonParserTest.cpp new file mode 100644 index 00000000..130bdfbd --- /dev/null +++ b/test/release_json_parser/ReleaseJsonParserTest.cpp @@ -0,0 +1,833 @@ +#include +#include +#include +#include + +#include "lib/JsonParser/ReleaseJsonParser.h" + +static int testsPassed = 0; +static int testsFailed = 0; + +#define ASSERT_EQ(a, b) \ + do { \ + auto _a = (a); \ + auto _b = (b); \ + if (_a != _b) { \ + fprintf(stderr, " FAIL: %s:%d: %s != expected\n", __FILE__, __LINE__, #a); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define ASSERT_STREQ(a, b) \ + do { \ + const char* _a = (a); \ + const char* _b = (b); \ + if (strcmp(_a, _b) != 0) { \ + fprintf(stderr, " FAIL: %s:%d: \"%s\" != \"%s\"\n", __FILE__, __LINE__, _a, _b); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define ASSERT_TRUE(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define PASS() testsPassed++ + +// ============================================================================ +// Realistic GitHub release JSON payloads +// ============================================================================ + +static const char* kRealisticPretty = R"({ + "url": "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/12345", + "assets_url": "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/12345/assets", + "upload_url": "https://uploads.github.com/repos/crosspoint-reader/crosspoint-reader/releases/12345/assets{?name,label}", + "html_url": "https://github.com/crosspoint-reader/crosspoint-reader/releases/tag/v2.4.1", + "id": 12345, + "author": { + "login": "releasebot", + "id": 99887766, + "node_id": "MDQ6VXNlcjk5ODg3NzY2", + "avatar_url": "https://avatars.githubusercontent.com/u/99887766?v=4", + "url": "https://api.github.com/users/releasebot", + "type": "User", + "site_admin": false + }, + "node_id": "RE_kwDOAbCdEf4AADBN", + "tag_name": "v2.4.1", + "target_commitish": "main", + "name": "CrossPoint Reader v2.4.1", + "draft": false, + "prerelease": false, + "created_at": "2026-04-28T10:00:00Z", + "published_at": "2026-04-28T10:30:00Z", + "assets": [ + { + "url": "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/assets/100001", + "id": 100001, + "node_id": "RA_kwDOAbCdEf4AAGHR", + "name": "crosspoint-reader-v2.4.1-source.zip", + "label": null, + "uploader": { + "login": "releasebot", + "id": 99887766, + "node_id": "MDQ6VXNlcjk5ODg3NzY2", + "type": "User" + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 2048576, + "download_count": 42, + "created_at": "2026-04-28T10:15:00Z", + "updated_at": "2026-04-28T10:15:30Z", + "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, + "node_id": "RA_kwDOAbCdEf4AAGHS", + "name": "firmware.bin", + "label": "ESP32-C3 Firmware", + "uploader": { + "login": "releasebot", + "id": 99887766, + "node_id": "MDQ6VXNlcjk5ODg3NzY2", + "type": "User" + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 1572864, + "download_count": 187, + "created_at": "2026-04-28T10:16:00Z", + "updated_at": "2026-04-28T10:16:45Z", + "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, + "node_id": "RA_kwDOAbCdEf4AAGHR", + "name": "checksums.sha256", + "label": null, + "uploader": { + "login": "releasebot", + "id": 99887766, + "node_id": "MDQ6VXNlcjk5ODg3NzY2", + "type": "User" + }, + "content_type": "text/plain", + "state": "uploaded", + "size": 192, + "download_count": 15, + "created_at": "2026-04-28T10:17:00Z", + "updated_at": "2026-04-28T10:17:10Z", + "browser_download_url": "https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/checksums.sha256" + } + ], + "tarball_url": "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/tarball/v2.4.1", + "zipball_url": "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/zipball/v2.4.1", + "body": "## What's Changed\n\n* Fixed orientation crash (#123)\n* Improved EPUB rendering performance\n* Added Serbian translation\n\n**Full Changelog**: https://github.com/crosspoint-reader/crosspoint-reader/compare/v2.4.0...v2.4.1", + "reactions": { + "url": "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/12345/reactions", + "total_count": 5, + "+1": 3, + "-1": 0, + "laugh": 1, + "hooray": 1, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + } +})"; + +static const char* kRealisticMinified = + R"({"url":"https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/12345","assets_url":"https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/12345/assets","id":12345,"author":{"login":"releasebot","id":99887766,"node_id":"MDQ6VXNlcjk5ODg3NzY2","type":"User","site_admin":false},"tag_name":"v2.4.1","target_commitish":"main","name":"CrossPoint Reader v2.4.1","draft":false,"prerelease":false,"assets":[{"url":"https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/assets/100001","id":100001,"name":"crosspoint-reader-v2.4.1-source.zip","uploader":{"login":"releasebot","id":99887766},"content_type":"application/zip","state":"uploaded","size":2048576,"download_count":42,"browser_download_url":"https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/crosspoint-reader-v2.4.1-source.zip"},{"url":"https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/assets/100002","id":100002,"name":"firmware.bin","uploader":{"login":"releasebot","id":99887766},"content_type":"application/octet-stream","state":"uploaded","size":1572864,"download_count":187,"browser_download_url":"https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/firmware.bin"},{"url":"https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/assets/100003","id":100003,"name":"checksums.sha256","uploader":{"login":"releasebot","id":99887766},"content_type":"text/plain","state":"uploaded","size":192,"download_count":15,"browser_download_url":"https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/checksums.sha256"}],"body":"## What's Changed\n\n* Fixed orientation crash","reactions":{"url":"https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/12345/reactions","total_count":5,"+1":3}})"; + +// Helper: feed JSON in fixed-size chunks +static void feedChunked(ReleaseJsonParser& p, const char* json, size_t chunkSize) { + size_t len = strlen(json); + for (size_t off = 0; off < len; off += chunkSize) { + size_t n = len - off < chunkSize ? len - off : chunkSize; + p.feed(json + off, n); + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +void testRealisticPrettyPrinted() { + printf("testRealisticPrettyPrinted...\n"); + + ReleaseJsonParser p; + p.feed(kRealisticPretty, strlen(kRealisticPretty)); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getTagName(), "v2.4.1"); + ASSERT_STREQ(p.getFirmwareUrl(), + "https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/firmware.bin"); + ASSERT_EQ(p.getFirmwareSize(), 1572864u); + + printf(" passed\n"); + PASS(); +} + +void testRealisticMinified() { + printf("testRealisticMinified...\n"); + + ReleaseJsonParser p; + p.feed(kRealisticMinified, strlen(kRealisticMinified)); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getTagName(), "v2.4.1"); + ASSERT_STREQ(p.getFirmwareUrl(), + "https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/firmware.bin"); + ASSERT_EQ(p.getFirmwareSize(), 1572864u); + + printf(" passed\n"); + PASS(); +} + +void testPrettyAndMinifiedAgree() { + printf("testPrettyAndMinifiedAgree...\n"); + + ReleaseJsonParser pretty; + pretty.feed(kRealisticPretty, strlen(kRealisticPretty)); + + ReleaseJsonParser minified; + minified.feed(kRealisticMinified, strlen(kRealisticMinified)); + + ASSERT_STREQ(pretty.getTagName(), minified.getTagName()); + ASSERT_STREQ(pretty.getFirmwareUrl(), minified.getFirmwareUrl()); + ASSERT_EQ(pretty.getFirmwareSize(), minified.getFirmwareSize()); + + printf(" passed\n"); + PASS(); +} + +void testFirmwareNotFirstAsset() { + printf("testFirmwareNotFirstAsset...\n"); + + // firmware.bin is the third of four assets + const char* json = R"({ + "tag_name": "v1.0.0", + "assets": [ + {"name": "source.tar.gz", "browser_download_url": "https://example.com/src.tar.gz", "size": 500000}, + {"name": "docs.pdf", "browser_download_url": "https://example.com/docs.pdf", "size": 120000}, + {"name": "firmware.bin", "browser_download_url": "https://example.com/firmware.bin", "size": 987654}, + {"name": "checksums.txt", "browser_download_url": "https://example.com/checksums.txt", "size": 256} + ] + })"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getTagName(), "v1.0.0"); + ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/firmware.bin"); + ASSERT_EQ(p.getFirmwareSize(), 987654u); + + printf(" passed\n"); + PASS(); +} + +void testFieldOrderUrlBeforeName() { + printf("testFieldOrderUrlBeforeName...\n"); + + const char* json = R"({ + "tag_name": "v3.0", + "assets": [{ + "browser_download_url": "https://example.com/fw.bin", + "name": "firmware.bin", + "size": 2222 + }] + })"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw.bin"); + ASSERT_EQ(p.getFirmwareSize(), 2222u); + + printf(" passed\n"); + PASS(); +} + +void testFieldOrderSizeBeforeUrl() { + printf("testFieldOrderSizeBeforeUrl...\n"); + + const char* json = R"({ + "tag_name": "v3.1", + "assets": [{ + "size": 3333, + "browser_download_url": "https://example.com/fw2.bin", + "name": "firmware.bin" + }] + })"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw2.bin"); + ASSERT_EQ(p.getFirmwareSize(), 3333u); + + printf(" passed\n"); + PASS(); +} + +void testFieldOrderNameFirst() { + printf("testFieldOrderNameFirst...\n"); + + const char* json = R"({ + "tag_name": "v3.2", + "assets": [{ + "name": "firmware.bin", + "size": 4444, + "browser_download_url": "https://example.com/fw3.bin" + }] + })"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw3.bin"); + ASSERT_EQ(p.getFirmwareSize(), 4444u); + + printf(" passed\n"); + PASS(); +} + +void testAssetsBeforeTagName() { + printf("testAssetsBeforeTagName...\n"); + + // tag_name appears after assets in the JSON + const char* json = R"({ + "name": "Release", + "assets": [{ + "name": "firmware.bin", + "browser_download_url": "https://example.com/fw.bin", + "size": 5555 + }], + "tag_name": "v4.0" + })"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getTagName(), "v4.0"); + ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw.bin"); + ASSERT_EQ(p.getFirmwareSize(), 5555u); + + printf(" passed\n"); + PASS(); +} + +void testChunkedFeedingRealisticSmallChunks() { + printf("testChunkedFeedingRealisticSmallChunks...\n"); + + // Simulate HTTP chunked transfer with small chunks (64 bytes) + ReleaseJsonParser p; + feedChunked(p, kRealisticPretty, 64); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getTagName(), "v2.4.1"); + ASSERT_STREQ(p.getFirmwareUrl(), + "https://github.com/crosspoint-reader/crosspoint-reader/releases/download/v2.4.1/firmware.bin"); + ASSERT_EQ(p.getFirmwareSize(), 1572864u); + + printf(" passed\n"); + PASS(); +} + +void testChunkedFeedingByteByByte() { + printf("testChunkedFeedingByteByByte...\n"); + + ReleaseJsonParser p; + feedChunked(p, kRealisticMinified, 1); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getTagName(), "v2.4.1"); + ASSERT_EQ(p.getFirmwareSize(), 1572864u); + + printf(" passed\n"); + PASS(); +} + +void testChunkedFeedingVariousChunkSizes() { + printf("testChunkedFeedingVariousChunkSizes...\n"); + + for (size_t chunkSize : {3, 7, 13, 31, 97, 128, 256, 512, 1024}) { + ReleaseJsonParser p; + feedChunked(p, kRealisticPretty, chunkSize); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getTagName(), "v2.4.1"); + ASSERT_EQ(p.getFirmwareSize(), 1572864u); + } + + printf(" passed (9 chunk sizes)\n"); + PASS(); +} + +void testMissingTagName() { + printf("testMissingTagName...\n"); + + const char* json = R"({ + "name": "Some Release", + "draft": false, + "assets": [{ + "name": "firmware.bin", + "browser_download_url": "https://example.com/fw.bin", + "size": 1000 + }] + })"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(!p.foundTag()); + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getTagName(), ""); + + printf(" passed\n"); + PASS(); +} + +void testMissingFirmwareBinAsset() { + printf("testMissingFirmwareBinAsset...\n"); + + const char* json = R"({ + "tag_name": "v1.0.0", + "assets": [ + {"name": "source.zip", "browser_download_url": "https://example.com/src.zip", "size": 1000}, + {"name": "docs.tar.gz", "browser_download_url": "https://example.com/docs.tar.gz", "size": 2000} + ] + })"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(!p.foundFirmware()); + ASSERT_STREQ(p.getTagName(), "v1.0.0"); + ASSERT_STREQ(p.getFirmwareUrl(), ""); + ASSERT_EQ(p.getFirmwareSize(), 0u); + + printf(" passed\n"); + PASS(); +} + +void testEmptyAssetsArray() { + printf("testEmptyAssetsArray...\n"); + + const char* json = R"({"tag_name": "v1.0.0", "assets": []})"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(!p.foundFirmware()); + + printf(" passed\n"); + PASS(); +} + +void testNoAssetsKey() { + printf("testNoAssetsKey...\n"); + + const char* json = R"({"tag_name": "v1.0.0", "name": "Release"})"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(!p.foundFirmware()); + + printf(" passed\n"); + PASS(); +} + +void testTruncatedBeforeTagValue() { + printf("testTruncatedBeforeTagValue...\n"); + + const char* json = R"({"tag_name": )"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(!p.foundTag()); + ASSERT_TRUE(!p.foundFirmware()); + + printf(" passed\n"); + PASS(); +} + +void testTruncatedInsideTagValue() { + printf("testTruncatedInsideTagValue...\n"); + + const char* json = R"({"tag_name": "v2.4)"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(!p.foundTag()); + + printf(" passed\n"); + PASS(); +} + +void testTruncatedInsideAssetsArray() { + printf("testTruncatedInsideAssetsArray...\n"); + + const char* json = R"({"tag_name": "v2.4.1", "assets": [{"name": "firm)"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundTag()); + ASSERT_STREQ(p.getTagName(), "v2.4.1"); + ASSERT_TRUE(!p.foundFirmware()); + + printf(" passed\n"); + PASS(); +} + +void testTruncatedAfterFirmwareName() { + printf("testTruncatedAfterFirmwareName...\n"); + + // Found the name but connection dropped before URL/size + const char* json = R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_dow)"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(!p.foundFirmware()); + + printf(" passed\n"); + PASS(); +} + +void testTruncatedRealisticJson() { + printf("testTruncatedRealisticJson...\n"); + + // Truncate the realistic JSON at various points; none should crash + std::string full(kRealisticPretty); + for (size_t cutPoint : {10u, 50u, 100u, 200u, 500u, 1000u, 1500u, 2000u}) { + if (cutPoint >= full.size()) continue; + + ReleaseJsonParser p; + p.feed(full.c_str(), cutPoint); + // Just verify no crash; results depend on where we cut + (void)p.foundTag(); + (void)p.foundFirmware(); + } + + printf(" passed (no crashes on truncated realistic JSON)\n"); + PASS(); +} + +void testNestedObjectsInAsset() { + printf("testNestedObjectsInAsset...\n"); + + // Asset with deeply nested "uploader" object -- should not confuse depth tracking + const char* json = R"({ + "tag_name": "v5.0", + "assets": [{ + "name": "firmware.bin", + "uploader": { + "login": "bot", + "id": 42, + "permissions": {"admin": false, "push": true, "pull": true} + }, + "browser_download_url": "https://example.com/fw5.bin", + "size": 8888 + }] + })"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw5.bin"); + ASSERT_EQ(p.getFirmwareSize(), 8888u); + + printf(" passed\n"); + PASS(); +} + +void testNestedObjectsAtTopLevel() { + printf("testNestedObjectsAtTopLevel...\n"); + + // Multiple nested objects at the top level before/after tag_name and assets + const char* json = R"({ + "author": {"login": "dev", "id": 1, "nested": {"deep": true}}, + "tag_name": "v6.0", + "reactions": {"url": "https://reactions", "total_count": 0, "+1": 0}, + "assets": [{"name": "firmware.bin", "browser_download_url": "https://fw6", "size": 1111}], + "mentions_count": 3 + })"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getTagName(), "v6.0"); + ASSERT_EQ(p.getFirmwareSize(), 1111u); + + printf(" passed\n"); + PASS(); +} + +void testArraysAtTopLevel() { + printf("testArraysAtTopLevel...\n"); + + // A non-assets array at the top level should not interfere + const char* json = R"({ + "tag_name": "v7.0", + "labels": ["release", "stable"], + "assets": [{"name": "firmware.bin", "browser_download_url": "https://fw7", "size": 7070}] + })"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getTagName(), "v7.0"); + ASSERT_EQ(p.getFirmwareSize(), 7070u); + + printf(" passed\n"); + PASS(); +} + +void testResetAndReuse() { + printf("testResetAndReuse...\n"); + + ReleaseJsonParser p; + + const char* json1 = + R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://a","size":1}]})"; + p.feed(json1, strlen(json1)); + ASSERT_TRUE(p.foundTag()); + ASSERT_STREQ(p.getTagName(), "v1.0"); + ASSERT_STREQ(p.getFirmwareUrl(), "https://a"); + ASSERT_EQ(p.getFirmwareSize(), 1u); + + p.reset(); + + // Second document with different values + const char* json2 = + R"({"tag_name":"v2.0","assets":[{"name":"firmware.bin","browser_download_url":"https://b","size":2}]})"; + p.feed(json2, strlen(json2)); + ASSERT_TRUE(p.foundTag()); + ASSERT_STREQ(p.getTagName(), "v2.0"); + ASSERT_STREQ(p.getFirmwareUrl(), "https://b"); + ASSERT_EQ(p.getFirmwareSize(), 2u); + + printf(" passed\n"); + PASS(); +} + +void testResetClearsState() { + printf("testResetClearsState...\n"); + + ReleaseJsonParser p; + + const char* json = + R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://a","size":100}]})"; + p.feed(json, strlen(json)); + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(p.foundFirmware()); + + p.reset(); + + ASSERT_TRUE(!p.foundTag()); + ASSERT_TRUE(!p.foundFirmware()); + ASSERT_STREQ(p.getTagName(), ""); + ASSERT_STREQ(p.getFirmwareUrl(), ""); + ASSERT_EQ(p.getFirmwareSize(), 0u); + + printf(" passed\n"); + PASS(); +} + +void testPartialAssetNameMatch() { + printf("testPartialAssetNameMatch...\n"); + + // "firmware.bin.bak" should NOT match "firmware.bin" + const char* json = R"({ + "tag_name": "v1.0", + "assets": [ + {"name": "firmware.bin.bak", "browser_download_url": "https://bak", "size": 100}, + {"name": "firmware.bin.old", "browser_download_url": "https://old", "size": 200} + ] + })"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(!p.foundFirmware()); + + printf(" passed\n"); + PASS(); +} + +void testFirmwareBinExactMatch() { + printf("testFirmwareBinExactMatch...\n"); + + // Only exact "firmware.bin" matches, not similar names + const char* json = R"({ + "tag_name": "v1.0", + "assets": [ + {"name": "FIRMWARE.BIN", "browser_download_url": "https://upper", "size": 100}, + {"name": "firmware.bin", "browser_download_url": "https://exact", "size": 200}, + {"name": "firmware.bin2", "browser_download_url": "https://suffix", "size": 300} + ] + })"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getFirmwareUrl(), "https://exact"); + ASSERT_EQ(p.getFirmwareSize(), 200u); + + printf(" passed\n"); + PASS(); +} + +void testLargeSize() { + printf("testLargeSize...\n"); + + // 16MB firmware (maximum flash size) + const char* json = + R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://fw","size":16777216}]})"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_EQ(p.getFirmwareSize(), 16777216u); + + printf(" passed\n"); + PASS(); +} + +void testSizeZero() { + printf("testSizeZero...\n"); + + const char* json = + R"({"tag_name":"v1.0","assets":[{"name":"firmware.bin","browser_download_url":"https://fw","size":0}]})"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundFirmware()); + ASSERT_EQ(p.getFirmwareSize(), 0u); + + printf(" passed\n"); + PASS(); +} + +void testMinimalValidJson() { + printf("testMinimalValidJson...\n"); + + const char* json = R"({"tag_name":"v0","assets":[{"name":"firmware.bin","browser_download_url":"u","size":1}]})"; + + ReleaseJsonParser p; + p.feed(json, strlen(json)); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getTagName(), "v0"); + ASSERT_STREQ(p.getFirmwareUrl(), "u"); + ASSERT_EQ(p.getFirmwareSize(), 1u); + + printf(" passed\n"); + PASS(); +} + +void testChunkedRealisticEveryBoundary() { + printf("testChunkedRealisticEveryBoundary...\n"); + + // Two-chunk split at every byte boundary on a compact JSON + const char* json = + R"({"tag_name":"v2.0","assets":[{"name":"firmware.bin","browser_download_url":"https://example.com/fw","size":9999}]})"; + size_t len = strlen(json); + + for (size_t split = 0; split <= len; ++split) { + ReleaseJsonParser p; + if (split > 0) p.feed(json, split); + if (split < len) p.feed(json + split, len - split); + + ASSERT_TRUE(p.foundTag()); + ASSERT_TRUE(p.foundFirmware()); + ASSERT_STREQ(p.getTagName(), "v2.0"); + ASSERT_STREQ(p.getFirmwareUrl(), "https://example.com/fw"); + ASSERT_EQ(p.getFirmwareSize(), 9999u); + } + + printf(" passed (all %zu split points)\n", len + 1); + PASS(); +} + +// ============================================================================ + +int main() { + printf("=== ReleaseJsonParser Tests ===\n\n"); + + testRealisticPrettyPrinted(); + testRealisticMinified(); + testPrettyAndMinifiedAgree(); + testFirmwareNotFirstAsset(); + testFieldOrderUrlBeforeName(); + testFieldOrderSizeBeforeUrl(); + testFieldOrderNameFirst(); + testAssetsBeforeTagName(); + testChunkedFeedingRealisticSmallChunks(); + testChunkedFeedingByteByByte(); + testChunkedFeedingVariousChunkSizes(); + testMissingTagName(); + testMissingFirmwareBinAsset(); + testEmptyAssetsArray(); + testNoAssetsKey(); + testTruncatedBeforeTagValue(); + testTruncatedInsideTagValue(); + testTruncatedInsideAssetsArray(); + testTruncatedAfterFirmwareName(); + testTruncatedRealisticJson(); + testNestedObjectsInAsset(); + testNestedObjectsAtTopLevel(); + testArraysAtTopLevel(); + testResetAndReuse(); + testResetClearsState(); + testPartialAssetNameMatch(); + testFirmwareBinExactMatch(); + testLargeSize(); + testSizeZero(); + testMinimalValidJson(); + testChunkedRealisticEveryBoundary(); + + printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed); + return testsFailed > 0 ? 1 : 0; +} diff --git a/test/run_release_json_parser_test.sh b/test/run_release_json_parser_test.sh new file mode 100755 index 00000000..1b212445 --- /dev/null +++ b/test/run_release_json_parser_test.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/build/release_json_parser" +BINARY="$BUILD_DIR/ReleaseJsonParserTest" + +mkdir -p "$BUILD_DIR" + +SOURCES=( + "$ROOT_DIR/test/release_json_parser/ReleaseJsonParserTest.cpp" + "$ROOT_DIR/lib/JsonParser/ReleaseJsonParser.cpp" + "$ROOT_DIR/lib/JsonParser/StreamingJsonParser.cpp" +) + +CXXFLAGS=( + -std=c++20 + -O2 + -Wall + -Wextra + -pedantic + -I"$ROOT_DIR" + -I"$ROOT_DIR/lib" + -I"$ROOT_DIR/lib/JsonParser" +) + +c++ "${CXXFLAGS[@]}" "${SOURCES[@]}" -o "$BINARY" + +"$BINARY" "$@" diff --git a/test/run_streaming_json_parser_test.sh b/test/run_streaming_json_parser_test.sh new file mode 100755 index 00000000..90e051c4 --- /dev/null +++ b/test/run_streaming_json_parser_test.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/build/streaming_json_parser" +BINARY="$BUILD_DIR/StreamingJsonParserTest" + +mkdir -p "$BUILD_DIR" + +SOURCES=( + "$ROOT_DIR/test/streaming_json_parser/StreamingJsonParserTest.cpp" + "$ROOT_DIR/lib/JsonParser/StreamingJsonParser.cpp" +) + +CXXFLAGS=( + -std=c++20 + -O2 + -Wall + -Wextra + -pedantic + -I"$ROOT_DIR" + -I"$ROOT_DIR/lib" + -I"$ROOT_DIR/lib/JsonParser" +) + +c++ "${CXXFLAGS[@]}" "${SOURCES[@]}" -o "$BINARY" + +"$BINARY" "$@" diff --git a/test/streaming_json_parser/StreamingJsonParserTest.cpp b/test/streaming_json_parser/StreamingJsonParserTest.cpp new file mode 100644 index 00000000..55b3a12a --- /dev/null +++ b/test/streaming_json_parser/StreamingJsonParserTest.cpp @@ -0,0 +1,737 @@ +#include +#include +#include +#include +#include + +#include "lib/JsonParser/StreamingJsonParser.h" + +static int testsPassed = 0; +static int testsFailed = 0; + +#define ASSERT_EQ(a, b) \ + do { \ + auto _a = (a); \ + auto _b = (b); \ + if (_a != _b) { \ + fprintf(stderr, " FAIL: %s:%d: %s != expected\n", __FILE__, __LINE__, #a); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define ASSERT_TRUE(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define PASS() testsPassed++ + +// Event types for recording callback sequences +enum class EventType { + KEY, + STRING, + NUMBER, + BOOL_TRUE, + BOOL_FALSE, + NULL_VAL, + OBJECT_START, + OBJECT_END, + ARRAY_START, + ARRAY_END, +}; + +struct Event { + EventType type; + std::string value; +}; + +struct TestContext { + std::vector events; +}; + +static void onKey(void* ctx, const char* key, size_t len) { + static_cast(ctx)->events.push_back({EventType::KEY, std::string(key, len)}); +} +static void onString(void* ctx, const char* value, size_t len) { + static_cast(ctx)->events.push_back({EventType::STRING, std::string(value, len)}); +} +static void onNumber(void* ctx, const char* value, size_t len) { + static_cast(ctx)->events.push_back({EventType::NUMBER, std::string(value, len)}); +} +static void onBool(void* ctx, bool value) { + static_cast(ctx)->events.push_back({value ? EventType::BOOL_TRUE : EventType::BOOL_FALSE, {}}); +} +static void onNull(void* ctx) { static_cast(ctx)->events.push_back({EventType::NULL_VAL, {}}); } +static void onObjectStart(void* ctx) { + static_cast(ctx)->events.push_back({EventType::OBJECT_START, {}}); +} +static void onObjectEnd(void* ctx) { static_cast(ctx)->events.push_back({EventType::OBJECT_END, {}}); } +static void onArrayStart(void* ctx) { static_cast(ctx)->events.push_back({EventType::ARRAY_START, {}}); } +static void onArrayEnd(void* ctx) { static_cast(ctx)->events.push_back({EventType::ARRAY_END, {}}); } + +static JsonCallbacks makeCallbacks(TestContext* ctx) { + return {ctx, onKey, onString, onNumber, onBool, onNull, onObjectStart, onObjectEnd, onArrayStart, onArrayEnd}; +} + +// Feed entire input at once +static std::vector parse(const char* json) { + TestContext ctx; + StreamingJsonParser parser(makeCallbacks(&ctx)); + parser.feed(json, strlen(json)); + return ctx.events; +} + +// Feed input one byte at a time +static std::vector parseBytewise(const char* json) { + TestContext ctx; + StreamingJsonParser parser(makeCallbacks(&ctx)); + size_t len = strlen(json); + for (size_t i = 0; i < len; ++i) { + parser.feed(json + i, 1); + } + return ctx.events; +} + +// ============================================================================ +// Tests +// ============================================================================ + +void testSimpleObject() { + printf("testSimpleObject...\n"); + + auto events = parse(R"({"key": "value", "num": 42})"); + + ASSERT_EQ(events.size(), 6u); + ASSERT_EQ(events[0].type, EventType::OBJECT_START); + ASSERT_EQ(events[1].type, EventType::KEY); + ASSERT_EQ(events[1].value, "key"); + ASSERT_EQ(events[2].type, EventType::STRING); + ASSERT_EQ(events[2].value, "value"); + ASSERT_EQ(events[3].type, EventType::KEY); + ASSERT_EQ(events[3].value, "num"); + ASSERT_EQ(events[4].type, EventType::NUMBER); + ASSERT_EQ(events[4].value, "42"); + ASSERT_EQ(events[5].type, EventType::OBJECT_END); + + printf(" passed\n"); + PASS(); +} + +void testNestedObjects() { + printf("testNestedObjects...\n"); + + auto events = parse(R"({"a": {"b": "c"}})"); + + ASSERT_EQ(events.size(), 7u); + ASSERT_EQ(events[0].type, EventType::OBJECT_START); + ASSERT_EQ(events[1].type, EventType::KEY); + ASSERT_EQ(events[1].value, "a"); + ASSERT_EQ(events[2].type, EventType::OBJECT_START); + ASSERT_EQ(events[3].type, EventType::KEY); + ASSERT_EQ(events[3].value, "b"); + ASSERT_EQ(events[4].type, EventType::STRING); + ASSERT_EQ(events[4].value, "c"); + ASSERT_EQ(events[5].type, EventType::OBJECT_END); + ASSERT_EQ(events[6].type, EventType::OBJECT_END); + + printf(" passed\n"); + PASS(); +} + +void testArrayOfValues() { + printf("testArrayOfValues...\n"); + + auto events = parse(R"({"items": [1, "two", true, false, null]})"); + + ASSERT_EQ(events.size(), 10u); + ASSERT_EQ(events[0].type, EventType::OBJECT_START); + ASSERT_EQ(events[1].type, EventType::KEY); + ASSERT_EQ(events[1].value, "items"); + ASSERT_EQ(events[2].type, EventType::ARRAY_START); + ASSERT_EQ(events[3].type, EventType::NUMBER); + ASSERT_EQ(events[3].value, "1"); + ASSERT_EQ(events[4].type, EventType::STRING); + ASSERT_EQ(events[4].value, "two"); + ASSERT_EQ(events[5].type, EventType::BOOL_TRUE); + ASSERT_EQ(events[6].type, EventType::BOOL_FALSE); + ASSERT_EQ(events[7].type, EventType::NULL_VAL); + ASSERT_EQ(events[8].type, EventType::ARRAY_END); + ASSERT_EQ(events[9].type, EventType::OBJECT_END); + + printf(" passed\n"); + PASS(); +} + +void testArrayOfObjects() { + printf("testArrayOfObjects...\n"); + + auto events = parse(R"([{"a": 1}, {"b": 2}])"); + + ASSERT_EQ(events.size(), 10u); + ASSERT_EQ(events[0].type, EventType::ARRAY_START); + ASSERT_EQ(events[1].type, EventType::OBJECT_START); + ASSERT_EQ(events[2].type, EventType::KEY); + ASSERT_EQ(events[2].value, "a"); + ASSERT_EQ(events[3].type, EventType::NUMBER); + ASSERT_EQ(events[3].value, "1"); + ASSERT_EQ(events[4].type, EventType::OBJECT_END); + ASSERT_EQ(events[5].type, EventType::OBJECT_START); + ASSERT_EQ(events[6].type, EventType::KEY); + ASSERT_EQ(events[6].value, "b"); + ASSERT_EQ(events[7].type, EventType::NUMBER); + ASSERT_EQ(events[7].value, "2"); + ASSERT_EQ(events[8].type, EventType::OBJECT_END); + ASSERT_EQ(events[9].type, EventType::ARRAY_END); + + printf(" passed\n"); + PASS(); +} + +void testStringEscapes() { + printf("testStringEscapes...\n"); + + auto events = parse(R"({"esc": "a\"b\\c\/d\ne\tf"})"); + + ASSERT_EQ(events.size(), 4u); + ASSERT_EQ(events[2].type, EventType::STRING); + ASSERT_EQ(events[2].value, std::string("a\"b\\c/d\ne\tf")); + + printf(" passed\n"); + PASS(); +} + +void testUnicodeEscapePassthrough() { + printf("testUnicodeEscapePassthrough...\n"); + + auto events = parse(R"({"u": "\u0041\u0042"})"); + + ASSERT_EQ(events[2].type, EventType::STRING); + // \uXXXX passed through as literal \u followed by the hex digits + ASSERT_EQ(events[2].value, "\\u0041\\u0042"); + + printf(" passed\n"); + PASS(); +} + +void testNumbers() { + printf("testNumbers...\n"); + + auto events = parse(R"({"int": 42, "neg": -7, "flt": 3.14, "exp": 1e10, "nexp": -2.5E-3})"); + + ASSERT_EQ(events[2].type, EventType::NUMBER); + ASSERT_EQ(events[2].value, "42"); + ASSERT_EQ(events[4].type, EventType::NUMBER); + ASSERT_EQ(events[4].value, "-7"); + ASSERT_EQ(events[6].type, EventType::NUMBER); + ASSERT_EQ(events[6].value, "3.14"); + ASSERT_EQ(events[8].type, EventType::NUMBER); + ASSERT_EQ(events[8].value, "1e10"); + ASSERT_EQ(events[10].type, EventType::NUMBER); + ASSERT_EQ(events[10].value, "-2.5E-3"); + + printf(" passed\n"); + PASS(); +} + +void testBooleansAndNull() { + printf("testBooleansAndNull...\n"); + + auto events = parse(R"({"t": true, "f": false, "n": null})"); + + ASSERT_EQ(events[2].type, EventType::BOOL_TRUE); + ASSERT_EQ(events[4].type, EventType::BOOL_FALSE); + ASSERT_EQ(events[6].type, EventType::NULL_VAL); + + printf(" passed\n"); + PASS(); +} + +void testChunkedFeeding() { + printf("testChunkedFeeding...\n"); + + const char* json = R"({"key": "value", "num": 42, "arr": [1, 2]})"; + auto reference = parse(json); + + // Feed byte-by-byte and verify identical event sequence + auto bytewise = parseBytewise(json); + + ASSERT_EQ(bytewise.size(), reference.size()); + for (size_t i = 0; i < reference.size(); ++i) { + ASSERT_EQ(bytewise[i].type, reference[i].type); + ASSERT_EQ(bytewise[i].value, reference[i].value); + } + + // Feed in chunks of varying size + for (size_t chunkSize = 2; chunkSize <= 7; ++chunkSize) { + TestContext ctx; + StreamingJsonParser parser(makeCallbacks(&ctx)); + size_t len = strlen(json); + for (size_t offset = 0; offset < len; offset += chunkSize) { + size_t remaining = len - offset; + size_t feedLen = remaining < chunkSize ? remaining : chunkSize; + parser.feed(json + offset, feedLen); + } + + ASSERT_EQ(ctx.events.size(), reference.size()); + for (size_t i = 0; i < reference.size(); ++i) { + ASSERT_EQ(ctx.events[i].type, reference[i].type); + ASSERT_EQ(ctx.events[i].value, reference[i].value); + } + } + + printf(" passed (byte-by-byte + chunk sizes 2-7)\n"); + PASS(); +} + +void testEveryByteBoundary() { + printf("testEveryByteBoundary...\n"); + + const char* json = R"({"tag_name":"v1.2.3","assets":[{"name":"firmware.bin","size":12345}]})"; + auto reference = parse(json); + size_t len = strlen(json); + + for (size_t split = 0; split <= len; ++split) { + TestContext ctx; + StreamingJsonParser parser(makeCallbacks(&ctx)); + if (split > 0) parser.feed(json, split); + if (split < len) parser.feed(json + split, len - split); + + ASSERT_EQ(ctx.events.size(), reference.size()); + for (size_t i = 0; i < reference.size(); ++i) { + if (ctx.events[i].type != reference[i].type || ctx.events[i].value != reference[i].value) { + fprintf(stderr, " FAIL at split=%zu, event %zu\n", split, i); + testsFailed++; + return; + } + } + } + + printf(" passed (all %zu split points)\n", len + 1); + PASS(); +} + +void testLargeTokenTruncation() { + printf("testLargeTokenTruncation...\n"); + + // Build a string value that exceeds TOKEN_BUF_SIZE + std::string longVal(StreamingJsonParser::TOKEN_BUF_SIZE + 100, 'x'); + std::string json = R"({"short": "ok", "long": ")" + longVal + R"("})"; + + auto events = parse(json.c_str()); + + // "short" key + "ok" value should still fire + ASSERT_TRUE(events.size() >= 3); + ASSERT_EQ(events[1].type, EventType::KEY); + ASSERT_EQ(events[1].value, "short"); + ASSERT_EQ(events[2].type, EventType::STRING); + ASSERT_EQ(events[2].value, "ok"); + + // The "long" key fires, but the oversized value is silently dropped + bool foundLongKey = false; + bool foundLongValue = false; + for (auto& e : events) { + if (e.type == EventType::KEY && e.value == "long") foundLongKey = true; + if (e.type == EventType::STRING && e.value.size() > 500) foundLongValue = true; + } + ASSERT_TRUE(foundLongKey); + ASSERT_TRUE(!foundLongValue); + + printf(" passed\n"); + PASS(); +} + +void testEmptyObject() { + printf("testEmptyObject...\n"); + + auto events = parse("{}"); + ASSERT_EQ(events.size(), 2u); + ASSERT_EQ(events[0].type, EventType::OBJECT_START); + ASSERT_EQ(events[1].type, EventType::OBJECT_END); + + printf(" passed\n"); + PASS(); +} + +void testEmptyArray() { + printf("testEmptyArray...\n"); + + auto events = parse("[]"); + ASSERT_EQ(events.size(), 2u); + ASSERT_EQ(events[0].type, EventType::ARRAY_START); + ASSERT_EQ(events[1].type, EventType::ARRAY_END); + + printf(" passed\n"); + PASS(); +} + +void testNestedArrays() { + printf("testNestedArrays...\n"); + + auto events = parse("[[1, 2], [3]]"); + ASSERT_EQ(events.size(), 9u); + ASSERT_EQ(events[0].type, EventType::ARRAY_START); + ASSERT_EQ(events[1].type, EventType::ARRAY_START); + ASSERT_EQ(events[2].type, EventType::NUMBER); + ASSERT_EQ(events[2].value, "1"); + ASSERT_EQ(events[3].type, EventType::NUMBER); + ASSERT_EQ(events[3].value, "2"); + ASSERT_EQ(events[4].type, EventType::ARRAY_END); + ASSERT_EQ(events[5].type, EventType::ARRAY_START); + ASSERT_EQ(events[6].type, EventType::NUMBER); + ASSERT_EQ(events[6].value, "3"); + ASSERT_EQ(events[7].type, EventType::ARRAY_END); + ASSERT_EQ(events[8].type, EventType::ARRAY_END); + + printf(" passed\n"); + PASS(); +} + +void testTopLevelArray() { + printf("testTopLevelArray...\n"); + + auto events = parse(R"(["hello", 42, true, null])"); + ASSERT_EQ(events.size(), 6u); + ASSERT_EQ(events[0].type, EventType::ARRAY_START); + ASSERT_EQ(events[1].type, EventType::STRING); + ASSERT_EQ(events[1].value, "hello"); + ASSERT_EQ(events[2].type, EventType::NUMBER); + ASSERT_EQ(events[2].value, "42"); + ASSERT_EQ(events[3].type, EventType::BOOL_TRUE); + ASSERT_EQ(events[4].type, EventType::NULL_VAL); + ASSERT_EQ(events[5].type, EventType::ARRAY_END); + + printf(" passed\n"); + PASS(); +} + +void testWhitespaceVariants() { + printf("testWhitespaceVariants...\n"); + + // Minified + auto minified = parse(R"({"a":1,"b":"x"})"); + + // Pretty-printed + const char* pretty = "{\n \"a\": 1,\n \"b\": \"x\"\n}"; + auto prettyEvents = parse(pretty); + + ASSERT_EQ(minified.size(), prettyEvents.size()); + for (size_t i = 0; i < minified.size(); ++i) { + ASSERT_EQ(minified[i].type, prettyEvents[i].type); + ASSERT_EQ(minified[i].value, prettyEvents[i].value); + } + + printf(" passed\n"); + PASS(); +} + +void testResetBetweenDocuments() { + printf("testResetBetweenDocuments...\n"); + + TestContext ctx; + StreamingJsonParser parser(makeCallbacks(&ctx)); + + const char* json1 = R"({"a": 1})"; + parser.feed(json1, strlen(json1)); + ASSERT_EQ(ctx.events.size(), 4u); + + ctx.events.clear(); + parser.reset(); + + const char* json2 = R"({"b": 2})"; + parser.feed(json2, strlen(json2)); + ASSERT_EQ(ctx.events.size(), 4u); + ASSERT_EQ(ctx.events[1].value, "b"); + ASSERT_EQ(ctx.events[2].value, "2"); + + printf(" passed\n"); + PASS(); +} + +void testNumberAtEndOfInput() { + printf("testNumberAtEndOfInput...\n"); + + // Number terminated by end of input (no trailing whitespace or structural char). + // The parser must emit the number when feed() ends (after a closing brace). + auto events = parse(R"({"n": 99})"); + bool found = false; + for (auto& e : events) { + if (e.type == EventType::NUMBER && e.value == "99") found = true; + } + ASSERT_TRUE(found); + + printf(" passed\n"); + PASS(); +} + +void testArrayOfStrings() { + printf("testArrayOfStrings...\n"); + + auto events = parse(R"(["a", "b", "c"])"); + + ASSERT_EQ(events.size(), 5u); + ASSERT_EQ(events[0].type, EventType::ARRAY_START); + ASSERT_EQ(events[1].type, EventType::STRING); + ASSERT_EQ(events[1].value, "a"); + ASSERT_EQ(events[2].type, EventType::STRING); + ASSERT_EQ(events[2].value, "b"); + ASSERT_EQ(events[3].type, EventType::STRING); + ASSERT_EQ(events[3].value, "c"); + ASSERT_EQ(events[4].type, EventType::ARRAY_END); + + printf(" passed\n"); + PASS(); +} + +void testTruncatedInputNoCrash() { + printf("testTruncatedInputNoCrash...\n"); + + // Simulates a connection drop mid-JSON. Parser must not crash. + const char* truncated[] = { + R"({"key": "val)", R"({"key": )", R"({"key)", R"([1, 2, )", + R"({"a": tru)", R"({"a": fal)", R"({"a": nul)", R"({"a": "hello\)", + }; + + for (auto* json : truncated) { + TestContext ctx; + StreamingJsonParser parser(makeCallbacks(&ctx)); + parser.feed(json, strlen(json)); + // Just verify no crash; partial results are acceptable + } + + printf(" passed (no crashes on %d truncated inputs)\n", 8); + PASS(); +} + +void testAllEscapeSequences() { + printf("testAllEscapeSequences...\n"); + + auto events = parse(R"({"e": "\b\f\n\r\t\"\\\/"})"); + ASSERT_EQ(events[2].type, EventType::STRING); + ASSERT_EQ(events[2].value, std::string("\b\f\n\r\t\"\\/")); + + printf(" passed\n"); + PASS(); +} + +void testObjectInArray() { + printf("testObjectInArray...\n"); + + // After an object closes inside an array, the next string after comma + // should be correctly identified as a key (inside the next object) or + // a string value (if directly in the array). + auto events = parse(R"([{"k":"v"}, "bare"])"); + + ASSERT_EQ(events.size(), 7u); + ASSERT_EQ(events[0].type, EventType::ARRAY_START); + ASSERT_EQ(events[1].type, EventType::OBJECT_START); + ASSERT_EQ(events[2].type, EventType::KEY); + ASSERT_EQ(events[2].value, "k"); + ASSERT_EQ(events[3].type, EventType::STRING); + ASSERT_EQ(events[3].value, "v"); + ASSERT_EQ(events[4].type, EventType::OBJECT_END); + ASSERT_EQ(events[5].type, EventType::STRING); + ASSERT_EQ(events[5].value, "bare"); + ASSERT_EQ(events[6].type, EventType::ARRAY_END); + + printf(" passed\n"); + PASS(); +} + +void testDeeplyNested() { + printf("testDeeplyNested...\n"); + + // 20 levels of nesting (well within MAX_NESTING=32) + std::string json; + for (int i = 0; i < 20; ++i) json += R"({"d":)"; + json += "0"; + for (int i = 0; i < 20; ++i) json += "}"; + + auto events = parse(json.c_str()); + + // 20 OBJECT_START + 20 KEY + 1 NUMBER + 20 OBJECT_END = 61 + ASSERT_EQ(events.size(), 61u); + ASSERT_EQ(events[0].type, EventType::OBJECT_START); + ASSERT_EQ(events[40].type, EventType::NUMBER); + ASSERT_EQ(events[40].value, "0"); + ASSERT_EQ(events[60].type, EventType::OBJECT_END); + + printf(" passed\n"); + PASS(); +} + +void testNestingOverflow() { + printf("testNestingOverflow...\n"); + + // Exceed MAX_NESTING -- parser should set error flag, not crash + std::string json; + for (size_t i = 0; i < StreamingJsonParser::MAX_NESTING + 5; ++i) json += "["; + + TestContext ctx; + StreamingJsonParser parser(makeCallbacks(&ctx)); + parser.feed(json.c_str(), json.size()); + + ASSERT_TRUE(parser.hasError()); + + printf(" passed\n"); + PASS(); +} + +void testNumberZero() { + printf("testNumberZero...\n"); + + auto events = parse(R"({"z": 0})"); + ASSERT_EQ(events[2].type, EventType::NUMBER); + ASSERT_EQ(events[2].value, "0"); + + printf(" passed\n"); + PASS(); +} + +void testMultipleValuesInObject() { + printf("testMultipleValuesInObject...\n"); + + auto events = parse(R"({"a": "x", "b": "y", "c": "z"})"); + + ASSERT_EQ(events.size(), 8u); + ASSERT_EQ(events[1].value, "a"); + ASSERT_EQ(events[2].value, "x"); + ASSERT_EQ(events[3].value, "b"); + ASSERT_EQ(events[4].value, "y"); + ASSERT_EQ(events[5].value, "c"); + ASSERT_EQ(events[6].value, "z"); + + printf(" passed\n"); + PASS(); +} + +void testChunkedSplitInsideString() { + printf("testChunkedSplitInsideString...\n"); + + const char* json = R"({"key": "hello world"})"; + auto reference = parse(json); + + // Split right in the middle of "hello world" + size_t splitAt = 14; // inside the string value + TestContext ctx; + StreamingJsonParser parser(makeCallbacks(&ctx)); + parser.feed(json, splitAt); + parser.feed(json + splitAt, strlen(json) - splitAt); + + ASSERT_EQ(ctx.events.size(), reference.size()); + for (size_t i = 0; i < reference.size(); ++i) { + ASSERT_EQ(ctx.events[i].type, reference[i].type); + ASSERT_EQ(ctx.events[i].value, reference[i].value); + } + + printf(" passed\n"); + PASS(); +} + +void testChunkedSplitInsideEscape() { + printf("testChunkedSplitInsideEscape...\n"); + + const char* json = R"({"k": "a\"b"})"; + auto reference = parse(json); + + // Find the backslash position and split right after it + const char* bs = strchr(json + 7, '\\'); + ASSERT_TRUE(bs != nullptr); + size_t splitAt = static_cast(bs - json) + 1; // after the backslash + + TestContext ctx; + StreamingJsonParser parser(makeCallbacks(&ctx)); + parser.feed(json, splitAt); + parser.feed(json + splitAt, strlen(json) - splitAt); + + ASSERT_EQ(ctx.events.size(), reference.size()); + for (size_t i = 0; i < reference.size(); ++i) { + ASSERT_EQ(ctx.events[i].type, reference[i].type); + ASSERT_EQ(ctx.events[i].value, reference[i].value); + } + + printf(" passed\n"); + PASS(); +} + +void testChunkedSplitInsideLiteral() { + printf("testChunkedSplitInsideLiteral...\n"); + + const char* json = R"({"a": true, "b": false, "c": null})"; + auto reference = parse(json); + + // Split inside "true" (at "tr|ue") + size_t splitAt = 7; + TestContext ctx; + StreamingJsonParser parser(makeCallbacks(&ctx)); + parser.feed(json, splitAt); + parser.feed(json + splitAt, strlen(json) - splitAt); + + ASSERT_EQ(ctx.events.size(), reference.size()); + for (size_t i = 0; i < reference.size(); ++i) { + ASSERT_EQ(ctx.events[i].type, reference[i].type); + ASSERT_EQ(ctx.events[i].value, reference[i].value); + } + + printf(" passed\n"); + PASS(); +} + +void testNullCallbacksNoCrash() { + printf("testNullCallbacksNoCrash...\n"); + + JsonCallbacks nullCbs = {}; + nullCbs.ctx = nullptr; + StreamingJsonParser parser(nullCbs); + + const char* json = R"({"key": "value", "num": 42, "b": true, "n": null, "a": [1]})"; + parser.feed(json, strlen(json)); + ASSERT_TRUE(!parser.hasError()); + + printf(" passed\n"); + PASS(); +} + +// ============================================================================ + +int main() { + printf("=== StreamingJsonParser Tests ===\n\n"); + + testSimpleObject(); + testNestedObjects(); + testArrayOfValues(); + testArrayOfObjects(); + testStringEscapes(); + testUnicodeEscapePassthrough(); + testNumbers(); + testBooleansAndNull(); + testChunkedFeeding(); + testEveryByteBoundary(); + testLargeTokenTruncation(); + testEmptyObject(); + testEmptyArray(); + testNestedArrays(); + testTopLevelArray(); + testWhitespaceVariants(); + testResetBetweenDocuments(); + testNumberAtEndOfInput(); + testArrayOfStrings(); + testTruncatedInputNoCrash(); + testAllEscapeSequences(); + testObjectInArray(); + testDeeplyNested(); + testNestingOverflow(); + testNumberZero(); + testMultipleValuesInObject(); + testChunkedSplitInsideString(); + testChunkedSplitInsideEscape(); + testChunkedSplitInsideLiteral(); + testNullCallbacksNoCrash(); + + printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed); + return testsFailed > 0 ? 1 : 0; +}