diff --git a/src/dusk/asset/asset.c b/src/dusk/asset/asset.c index f0e400bd..76850bf6 100644 --- a/src/dusk/asset/asset.c +++ b/src/dusk/asset/asset.c @@ -184,6 +184,7 @@ errorret_t assetUpdate(void) { loading->entry->state == ASSET_ENTRY_STATE_ERROR, "Loader did not set entry state to error on failed load." ); + errorCatch(errorPrint(ret)); } threadMutexUnlock(&loading->mutex); diff --git a/src/dusk/asset/loader/locale/assetlocaleloader.c b/src/dusk/asset/loader/locale/assetlocaleloader.c index bc6db674..e4ba51ef 100644 --- a/src/dusk/asset/loader/locale/assetlocaleloader.c +++ b/src/dusk/asset/loader/locale/assetlocaleloader.c @@ -303,16 +303,24 @@ errorret_t assetLocaleLineSkipBlanks( while(!reader->eof) { // Skip blank lines if(lineBuffer[0] == '\0') { - errorChain(assetFileLineReaderNext(reader)); + errorret_t r = assetFileLineReaderNext(reader); + if(errorIsNotOk(r)) { + errorCatch(r); + break; + } continue; } // Skip comment lines if(lineBuffer[0] == '#') { - errorChain(assetFileLineReaderNext(reader)); + errorret_t r = assetFileLineReaderNext(reader); + if(errorIsNotOk(r)) { + errorCatch(r); + break; + } continue; } - + // Is line only spaces? size_t lineLength = strlen((char_t *)lineBuffer); size_t i; @@ -325,7 +333,11 @@ errorret_t assetLocaleLineSkipBlanks( } if(onlySpaces) { - errorChain(assetFileLineReaderNext(reader)); + errorret_t r = assetFileLineReaderNext(reader); + if(errorIsNotOk(r)) { + errorCatch(r); + break; + } continue; } break; @@ -361,10 +373,18 @@ errorret_t assetLocaleLineUnbuffer( // Now start buffering lines out while(!reader->eof) { - errorChain(assetFileLineReaderNext(reader)); + errorret_t r = assetFileLineReaderNext(reader); + if(errorIsNotOk(r)) { + errorCatch(r); + break; + } // Skip blank lines - errorChain(assetLocaleLineSkipBlanks(reader, lineBuffer)); + r = assetLocaleLineSkipBlanks(reader, lineBuffer); + if(errorIsNotOk(r)) { + errorCatch(r); + break; + } // Skip starting spaces char_t *ptr = (char_t *)lineBuffer; diff --git a/src/dusk/asset/loader/locale/assetlocaleloader.h b/src/dusk/asset/loader/locale/assetlocaleloader.h index 0e90a51f..34079393 100644 --- a/src/dusk/asset/loader/locale/assetlocaleloader.h +++ b/src/dusk/asset/loader/locale/assetlocaleloader.h @@ -1,6 +1,6 @@ /** * Copyright (c) 2026 Dominic Masters - * + * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ @@ -11,11 +11,22 @@ typedef struct assetloading_s assetloading_t; typedef struct assetentry_s assetentry_t; +/** Input passed to the locale loader — currently unused. */ typedef struct { void *nothing; } assetlocaleloaderinput_t; + +/** Per-slot scratch data used while the locale file is loading. */ typedef struct { void *nothing; } assetlocaleloaderloading_t; +/** Maximum number of distinct plural forms a locale file may declare. */ #define ASSET_LOCALE_FILE_PLURAL_FORM_COUNT 6 +/** + * Comparison operator used in a plural-form expression. + * + * Each condition in the plural-form header is evaluated as + * `n value` + * where `n` is the runtime plural count. + */ typedef enum { ASSET_LOCALE_PLURAL_OP_EQUAL, ASSET_LOCALE_PLURAL_OP_NOT_EQUAL, @@ -25,13 +36,24 @@ typedef enum { ASSET_LOCALE_PLURAL_OP_GREATER_EQUAL } assetlocalepluraloperation_t; +/** + * Discriminator tag for a locale string argument. + * @see assetlocalearg_t + */ typedef enum { ASSET_LOCALE_ARG_STRING, ASSET_LOCALE_ARG_INT, ASSET_LOCALE_ARG_FLOAT } assetlocaleargtype_t; +/** + * A single typed argument for locale string substitution. + * + * Used with @ref assetLocaleGetStringWithArgs to fill `%s`, `%d`, or `%f` + * placeholders inside a translated string. + */ typedef struct { + /** Which union member is active. */ assetlocaleargtype_t type; union { const char_t *stringValue; @@ -40,20 +62,72 @@ typedef struct { }; } assetlocalearg_t; +/** + * Runtime state for an open locale file. + * + * Loaded once by @ref assetLocaleLoaderSync and kept alive for the lifetime + * of the asset entry. The plural-form fields are populated from the PO header + * by @ref assetLocaleParseHeader. + * + * Plural evaluation works as a linear scan over `pluralStateCount - 1` + * conditions; if none match, `pluralDefaultIndex` is returned. + */ typedef struct { + /** Underlying file handle used to rewind and re-read the PO data. */ assetfile_t file; + + /** Comparison operator for each conditional plural clause. */ assetlocalepluraloperation_t pluralOps[ASSET_LOCALE_FILE_PLURAL_FORM_COUNT]; + + /** Right-hand value for each conditional plural clause. */ int32_t pluralValues[ASSET_LOCALE_FILE_PLURAL_FORM_COUNT]; + + /** Form index returned when the corresponding condition is true. */ int32_t pluralIndices[ASSET_LOCALE_FILE_PLURAL_FORM_COUNT]; + + /** Total number of plural forms declared by `nplurals=`. */ uint8_t pluralStateCount; + + /** Form index used when no conditional clause matches. */ uint8_t pluralDefaultIndex; } assetlocalefile_t; +/** Convenience alias — the loaded output type of a locale asset entry. */ typedef assetlocalefile_t assetlocaleoutput_t; +/** + * Synchronous loader callback. Opens and validates the locale file, reads the + * PO header, and parses plural-form rules. Sets entry state to + * `ASSET_ENTRY_STATE_LOADED` on success or `ASSET_ENTRY_STATE_ERROR` on + * failure. + * + * @param loading The loading slot for this asset entry. + * @return OK on success, error otherwise. + */ errorret_t assetLocaleLoaderSync(assetloading_t *loading); + +/** + * Dispose callback. Closes the open file handle and zeros the locale data. + * + * @param entry The asset entry to dispose. + * @return OK on success, error otherwise. + */ errorret_t assetLocaleDispose(assetentry_t *entry); +/** + * Parses the `Plural-Forms:` line from a PO header string and populates the + * plural-form fields of `localeFile`. If no `Plural-Forms:` key is present + * the function returns OK without modifying the struct. + * + * Supports the `nplurals=N; plural=();` syntax where `` is a + * chain of `n value ? index : ...` ternary conditions ending with a + * fallback index. + * + * @param localeFile Struct whose plural fields will be filled in. + * @param headerBuffer The raw PO header string (msgstr of msgid ""). + * @param headerBufferSize Size of `headerBuffer` in bytes. + * @return OK on success, error if the plural expression is malformed. + */ errorret_t assetLocaleParseHeader( assetlocalefile_t *localeFile, char_t *headerBuffer, @@ -61,11 +135,29 @@ errorret_t assetLocaleParseHeader( ); /** - * Skips blank lines and comment lines in the line reader. - * - * @param reader Line reader to read from. - * @param lineBuffer Buffer to use for reading lines. - * @return Any error that occurs during skipping. + * Evaluates which plural form index to use for a given count. + * + * Walks the conditions stored in `file` in order; returns the index for the + * first condition that matches `pluralCount`. Falls back to + * `file->pluralDefaultIndex` if none match. + * + * @param file Locale file with parsed plural rules. + * @param pluralCount The runtime count (e.g. number of items). + * @return Zero-based plural form index. + */ +uint8_t assetLocaleEvaluatePlural( + assetlocalefile_t *file, + const int32_t pluralCount +); + +/** + * Advances the line reader past blank lines, comment lines (starting with + * `#`), and lines containing only spaces. Stops at the first content line or + * end of file. + * + * @param reader Line reader positioned at the current line. + * @param lineBuffer Output buffer that the reader writes each line into. + * @return OK on success, error if reading fails. */ errorret_t assetLocaleLineSkipBlanks( assetfilelinereader_t *reader, @@ -73,16 +165,19 @@ errorret_t assetLocaleLineSkipBlanks( ); /** - * Unbuffers a potentially multi-line quoted string from the line reader. - * - * This will read lines until it finds a line that starts with a quote, then - * read until the closing quote. - * - * @param reader Line reader to read from. - * @param lineBuffer Buffer to use for reading lines. - * @param stringBuffer Buffer to write the unbuffered string to. - * @param stringBufferSize Size of the string buffer. - * @return Any error that occurs during unbuffering. + * Reads a PO quoted string value from the current line and any immediately + * following continuation lines that begin with `"`. + * + * Handles standard C escape sequences (`\n`, `\t`, `\\`, `\"`). + * + * @param reader Line reader positioned at the line containing the opening + * quote (e.g. `msgstr "..."`). + * @param lineBuffer Buffer the reader fills on each @ref assetFileLineReaderNext + * call; also used to detect continuation lines. + * @param stringBuffer Destination for the unescaped string content. + * @param stringBufferSize Capacity of `stringBuffer` in bytes. + * @return OK on success, error if a quote or escape sequence is malformed or + * the buffer would overflow. */ errorret_t assetLocaleLineUnbuffer( assetfilelinereader_t *reader, @@ -92,14 +187,19 @@ errorret_t assetLocaleLineUnbuffer( ); /** - * Test function for locale asset loading. - * - * @param file Asset file to test loading from. - * @param messageId The message ID to retrieve. - * @param pluralCount Count for formulating the plural variant. - * @param stringBuffer Buffer to write the retrieved string to. - * @param stringBufferSize Size of the string buffer. - * @return Any error that occurs during testing. + * Looks up a translated string by message ID from the open locale file. + * + * Rewinds the file and scans from the beginning on every call. For plural + * entries (`msgid_plural`) the `pluralCount` is evaluated against the loaded + * plural rules to select the correct `msgstr[N]` form. + * + * @param file Locale file to search. Must be open. + * @param messageId PO message ID to find (`""` retrieves the header entry). + * @param pluralCount Count used to select the plural form (ignored for + * singular entries). + * @param stringBuffer Buffer to receive the translated string. + * @param stringBufferSize Capacity of `stringBuffer` in bytes. + * @return OK on success, error if the message ID is not found or I/O fails. */ errorret_t assetLocaleGetString( assetlocalefile_t *file, @@ -110,15 +210,21 @@ errorret_t assetLocaleGetString( ); /** - * Test function for locale asset loading with a variable argument list. - * - * @param file Asset file to test loading from. - * @param messageId The message ID to retrieve. - * @param pluralCount Count for formulating the plural variant. - * @param buffer Buffer to write the retrieved string to. - * @param bufferSize Size of the buffer. - * @param ... Additional arguments for formatting the string. - * @return Any error that occurs during testing. + * Looks up a translated string and formats it with a `printf`-style variadic + * argument list. + * + * Retrieves the raw format string via @ref assetLocaleGetString then applies + * `vsnprintf` with the provided arguments. + * + * @param file Locale file to search. Must be open. + * @param messageId PO message ID to find. + * @param pluralCount Count used to select the plural form. + * @param buffer Buffer to receive the formatted string. + * @param bufferSize Capacity of `buffer` in bytes. + * @param ... Format arguments matching the specifiers in the translated + * string. + * @return OK on success, error if the message is not found or formatting + * fails. */ errorret_t assetLocaleGetStringWithVA( assetlocalefile_t *file, @@ -130,16 +236,22 @@ errorret_t assetLocaleGetStringWithVA( ); /** - * Test function for locale asset loading with a list of arguments. - * - * @param file Asset file to test loading from. - * @param messageId The message ID to retrieve. - * @param pluralCount Count for formulating the plural variant. - * @param buffer Buffer to write the retrieved string to. - * @param bufferSize Size of the buffer. - * @param args List of arguments for formatting the string. - * @param argCount Number of arguments in the list. - * @return Any error that occurs during testing. + * Looks up a translated string and substitutes typed arguments into its + * `%s`, `%d`, and `%f` placeholders. + * + * Unlike @ref assetLocaleGetStringWithVA this variant accepts an explicit + * array of @ref assetlocalearg_t values, which is safer to use from + * generated or scripted call sites. + * + * @param file Locale file to search. Must be open. + * @param messageId PO message ID to find. + * @param pluralCount Count used to select the plural form. + * @param buffer Buffer to receive the formatted string. + * @param bufferSize Capacity of `buffer` in bytes. + * @param args Array of typed arguments; may be NULL when `argCount` is 0. + * @param argCount Number of elements in `args`. + * @return OK on success, error if the message is not found, an argument type + * mismatches the specifier, or the buffer would overflow. */ errorret_t assetLocaleGetStringWithArgs( assetlocalefile_t *file, @@ -149,4 +261,4 @@ errorret_t assetLocaleGetStringWithArgs( const size_t bufferSize, const assetlocalearg_t *args, const size_t argCount -); \ No newline at end of file +); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index c172972d..226d9346 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -4,6 +4,7 @@ # https://opensource.org/licenses/MIT add_subdirectory(assert) +add_subdirectory(asset) add_subdirectory(error) add_subdirectory(thread) add_subdirectory(display) diff --git a/test/asset/CMakeLists.txt b/test/asset/CMakeLists.txt new file mode 100644 index 00000000..8439d153 --- /dev/null +++ b/test/asset/CMakeLists.txt @@ -0,0 +1,9 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +include(dusktest) + +dusktest(test_assetlocale.c) +dusktest(test_asset.c) diff --git a/test/asset/test_asset.c b/test/asset/test_asset.c new file mode 100644 index 00000000..8eaad51a --- /dev/null +++ b/test/asset/test_asset.c @@ -0,0 +1,242 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "asset/asset.h" +#include "asset/loader/assetloader.h" +#include "asset/loader/assetentry.h" +#include "util/memory.h" +#include "util/string.h" + +// ============================================================ +// Stub loader callbacks +// ============================================================ + +static errorret_t stub_load_success(assetloading_t *loading) { + loading->entry->state = ASSET_ENTRY_STATE_LOADED; + errorOk(); +} + +static errorret_t stub_load_fail(assetloading_t *loading) { + loading->entry->state = ASSET_ENTRY_STATE_ERROR; + errorThrow("Stub loader failed"); +} + +static errorret_t stub_dispose(assetentry_t *entry) { + errorOk(); +} + +// ============================================================ +// Per-test setup / teardown +// ============================================================ + +static assetloadercallbacks_t saved_callbacks[ASSET_LOADER_TYPE_COUNT]; + +static int asset_setup(void **state) { + // Save real callbacks so we can restore them in teardown. + memoryCopy(saved_callbacks, ASSET_LOADER_CALLBACKS, sizeof(saved_callbacks)); + + // Manually init ASSET — no thread, no ZIP. + memoryZero(&ASSET, sizeof(ASSET)); + for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { + threadMutexInit(&ASSET.loading[i].mutex); + } + + // Replace all loader callbacks with stubs. + for(int i = 0; i < ASSET_LOADER_TYPE_COUNT; i++) { + ASSET_LOADER_CALLBACKS[i].loadSync = stub_load_success; + ASSET_LOADER_CALLBACKS[i].dispose = stub_dispose; + } + + return 0; +} + +static int asset_teardown(void **state) { + // Dispose any entries that tests left behind. + for(int i = 0; i < ASSET_ENTRY_COUNT_MAX; i++) { + if(ASSET.entries[i].type != ASSET_LOADER_TYPE_NULL) { + errorret_t ret = assetEntryDispose(&ASSET.entries[i]); + if(errorIsNotOk(ret)) errorCatch(ret); + } + } + + for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { + threadMutexDispose(&ASSET.loading[i].mutex); + } + + // Restore real callbacks before zeroing state. + memoryCopy(ASSET_LOADER_CALLBACKS, saved_callbacks, sizeof(saved_callbacks)); + memoryZero(&ASSET, sizeof(ASSET)); + return 0; +} + +// ============================================================ +// Helper: find which loading slot owns a given entry +// ============================================================ + +static bool_t loading_slot_has_entry(const assetentry_t *entry) { + for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { + if(ASSET.loading[i].entry == entry) return true; + } + return false; +} + +// ============================================================ +// assetGetEntry tests +// ============================================================ + +static void test_getEntry_creates_new(void **state) { + assetentry_t *entry = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); + + assert_non_null(entry); + assert_int_equal(entry->state, ASSET_ENTRY_STATE_NOT_STARTED); + assert_int_equal(entry->type, ASSET_LOADER_TYPE_LOCALE); + assert_true(stringEquals(entry->name, "test.locale")); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_getEntry_dedup(void **state) { + assetentry_t *a = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); + assetentry_t *b = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); + + assert_ptr_equal(a, b); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_getEntry_distinct_names(void **state) { + assetentry_t *a = assetGetEntry("a.locale", ASSET_LOADER_TYPE_LOCALE, NULL); + assetentry_t *b = assetGetEntry("b.locale", ASSET_LOADER_TYPE_LOCALE, NULL); + + assert_ptr_not_equal(a, b); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// ============================================================ +// assetUpdate — state machine tests +// ============================================================ + +static void test_update_entry_reaches_loaded(void **state) { + assetentry_t *entry = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); + assert_int_equal(entry->state, ASSET_ENTRY_STATE_NOT_STARTED); + + errorret_t ret = assetUpdate(); + assert_true(errorIsOk(ret)); + assert_int_equal(entry->state, ASSET_ENTRY_STATE_LOADED); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_update_slot_occupied_after_first_update(void **state) { + assetentry_t *entry = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); + + assetUpdate(); + assert_int_equal(entry->state, ASSET_ENTRY_STATE_LOADED); + + // Slot not cleared until the second update processes the LOADED case. + assert_true(loading_slot_has_entry(entry)); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_update_slot_cleared_after_second_update(void **state) { + assetentry_t *entry = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); + + assetUpdate(); + assetUpdate(); + + assert_false(loading_slot_has_entry(entry)); + assert_int_equal(entry->state, ASSET_ENTRY_STATE_LOADED); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_update_four_slots_fill_independently(void **state) { + // ASSET_LOADING_COUNT_MAX concurrent entries should all load in one pass. + assetentry_t *entries[ASSET_LOADING_COUNT_MAX]; + for(int i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { + char_t name[ASSET_FILE_NAME_MAX]; + snprintf(name, sizeof(name), "asset%d.locale", i); + entries[i] = assetGetEntry(name, ASSET_LOADER_TYPE_LOCALE, NULL); + } + + errorret_t ret = assetUpdate(); + assert_true(errorIsOk(ret)); + + for(int i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { + assert_int_equal(entries[i]->state, ASSET_ENTRY_STATE_LOADED); + } + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_update_error_state(void **state) { + ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_LOCALE].loadSync = stub_load_fail; + + assetentry_t *entry = assetGetEntry("fail.locale", ASSET_LOADER_TYPE_LOCALE, NULL); + + // First update: dispatches and calls the failing stub. + // assetUpdate itself returns OK here; the error from loadSync is caught internally. + errorret_t ret = assetUpdate(); + assert_true(errorIsOk(ret)); + assert_int_equal(entry->state, ASSET_ENTRY_STATE_ERROR); + + // Second update: sees ERROR state and propagates it. + ret = assetUpdate(); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// ============================================================ +// assetRequireLoaded tests +// ============================================================ + +static void test_requireLoaded_already_loaded(void **state) { + assetentry_t *entry = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); + assetUpdate(); + assert_int_equal(entry->state, ASSET_ENTRY_STATE_LOADED); + + // Should return immediately without calling assetUpdate again. + errorret_t ret = assetRequireLoaded(entry); + assert_true(errorIsOk(ret)); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_requireLoaded_spins_to_loaded(void **state) { + assetentry_t *entry = assetGetEntry("test.locale", ASSET_LOADER_TYPE_LOCALE, NULL); + assert_int_equal(entry->state, ASSET_ENTRY_STATE_NOT_STARTED); + + // requireLoaded calls assetUpdate internally until LOADED. + errorret_t ret = assetRequireLoaded(entry); + assert_true(errorIsOk(ret)); + assert_int_equal(entry->state, ASSET_ENTRY_STATE_LOADED); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// ============================================================ +// main +// ============================================================ + +int main(void) { + const struct CMUnitTest tests[] = { + cmocka_unit_test_setup_teardown(test_getEntry_creates_new, asset_setup, asset_teardown), + cmocka_unit_test_setup_teardown(test_getEntry_dedup, asset_setup, asset_teardown), + cmocka_unit_test_setup_teardown(test_getEntry_distinct_names, asset_setup, asset_teardown), + cmocka_unit_test_setup_teardown(test_update_entry_reaches_loaded, asset_setup, asset_teardown), + cmocka_unit_test_setup_teardown(test_update_slot_occupied_after_first_update, asset_setup, asset_teardown), + cmocka_unit_test_setup_teardown(test_update_slot_cleared_after_second_update, asset_setup, asset_teardown), + cmocka_unit_test_setup_teardown(test_update_four_slots_fill_independently, asset_setup, asset_teardown), + cmocka_unit_test_setup_teardown(test_update_error_state, asset_setup, asset_teardown), + cmocka_unit_test_setup_teardown(test_requireLoaded_already_loaded, asset_setup, asset_teardown), + cmocka_unit_test_setup_teardown(test_requireLoaded_spins_to_loaded, asset_setup, asset_teardown), + }; + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/asset/test_assetlocale.c b/test/asset/test_assetlocale.c new file mode 100644 index 00000000..fd17d9fc --- /dev/null +++ b/test/asset/test_assetlocale.c @@ -0,0 +1,423 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "asset/loader/locale/assetlocaleloader.h" +#include "asset/asset.h" +#include "util/memory.h" +#include + +// ============================================================ +// Test locale file (gettext PO format) +// ============================================================ + +static const char_t *LOCALE_EN = + "msgid \"\"\n" + "msgstr \"\"\n" + "\"Plural-Forms: nplurals=2; plural=(n != 1 ? 1 : 0);\\n\"\n" + "\n" + "msgid \"greeting\"\n" + "msgstr \"Hello, World!\"\n" + "\n" + "msgid \"item\"\n" + "msgid_plural \"items\"\n" + "msgstr[0] \"one item\"\n" + "msgstr[1] \"many items\"\n" + "\n" + "msgid \"score\"\n" + "msgstr \"Score: %d\"\n" + "\n" + "msgid \"player\"\n" + "msgstr \"Player: %s\"\n"; + +// ============================================================ +// In-memory ZIP fixture (shared across all ZIP-based tests) +// ============================================================ + +static zip_t *g_zip = NULL; +static assetlocalefile_t g_locale; + +static int locale_setup(void **state) { + zip_error_t err; + zip_error_init(&err); + + // Phase 1: write the zip to a growable buffer source + zip_source_t *write_src = zip_source_buffer_create(NULL, 0, 1, &err); + if(!write_src) return -1; + + zip_t *za = zip_open_from_source(write_src, ZIP_TRUNCATE, &err); + if(!za) { zip_source_free(write_src); return -1; } + + size_t flen = strlen(LOCALE_EN); + zip_source_t *fs = zip_source_buffer(za, LOCALE_EN, flen, 0); + if(zip_file_add(za, "en.locale", fs, ZIP_FL_OVERWRITE) < 0) { + zip_close(za); return -1; + } + + // Keep write_src alive after zip_close so we can read the bytes back out + zip_source_keep(write_src); + if(zip_close(za) != 0) { zip_source_free(write_src); return -1; } + + // Phase 2: extract the raw zip bytes from the write buffer. + // zip_source_stat must be called before zip_source_open on a written source. + zip_stat_t zs; + memset(&zs, 0, sizeof(zs)); + if(zip_source_stat(write_src, &zs) != 0 || !(zs.valid & ZIP_STAT_SIZE)) { + zip_source_free(write_src); return -1; + } + + void *zipbuf = malloc((size_t)zs.size); + if(!zipbuf) { zip_source_free(write_src); return -1; } + + if(zip_source_open(write_src) != 0) { + free(zipbuf); zip_source_free(write_src); return -1; + } + zip_source_read(write_src, zipbuf, (zip_uint64_t)zs.size); + zip_source_close(write_src); + zip_source_free(write_src); + + // Phase 3: open a fresh read-only archive from the extracted bytes. + // The archive takes ownership of the source (and thus zipbuf via freep=1). + zip_error_init(&err); + zip_source_t *read_src = zip_source_buffer_create( + zipbuf, (zip_uint64_t)zs.size, 1, &err + ); + if(!read_src) { free(zipbuf); return -1; } + + g_zip = zip_open_from_source(read_src, 0, &err); + if(!g_zip) { zip_source_free(read_src); return -1; } + + ASSET.zip = g_zip; + + // Init locale file and parse the header + memoryZero(&g_locale, sizeof(g_locale)); + errorret_t ret = assetFileInit(&g_locale.file, "en.locale", NULL, NULL); + if(errorIsNotOk(ret)) { errorCatch(ret); goto fail; } + + ret = assetFileOpen(&g_locale.file); + if(errorIsNotOk(ret)) { errorCatch(ret); goto fail; } + + char_t header[512]; + ret = assetLocaleGetString(&g_locale, "", 0, header, sizeof(header)); + if(errorIsNotOk(ret)) { errorCatch(ret); assetFileClose(&g_locale.file); goto fail; } + + ret = assetLocaleParseHeader(&g_locale, header, sizeof(header)); + if(errorIsNotOk(ret)) { errorCatch(ret); assetFileClose(&g_locale.file); goto fail; } + + return 0; + +fail: + zip_close(g_zip); g_zip = NULL; + ASSET.zip = NULL; + return -1; +} + +static int locale_teardown(void **state) { + if(g_locale.file.zipFile != NULL) { + errorret_t ret = assetFileClose(&g_locale.file); + if(errorIsNotOk(ret)) errorCatch(ret); + } + + if(g_zip != NULL) { + zip_close(g_zip); // also frees the read_src and zipbuf + g_zip = NULL; + } + + ASSET.zip = NULL; + memoryZero(&g_locale, sizeof(g_locale)); + return 0; +} + +// ============================================================ +// assetLocaleParseHeader — pure tests (no ZIP required) +// ============================================================ + +static void test_parseHeader_english(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + char_t hdr[] = "Plural-Forms: nplurals=2; plural=(n != 1 ? 1 : 0);\n"; + errorret_t ret = assetLocaleParseHeader(&locale, hdr, sizeof(hdr)); + + assert_true(errorIsOk(ret)); + assert_int_equal(locale.pluralStateCount, 2); + assert_int_equal(locale.pluralDefaultIndex, 0); + assert_int_equal(locale.pluralOps[0], ASSET_LOCALE_PLURAL_OP_NOT_EQUAL); + assert_int_equal(locale.pluralValues[0], 1); + assert_int_equal(locale.pluralIndices[0], 1); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_parseHeader_singular(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + char_t hdr[] = "Plural-Forms: nplurals=1; plural=(0);\n"; + errorret_t ret = assetLocaleParseHeader(&locale, hdr, sizeof(hdr)); + + assert_true(errorIsOk(ret)); + assert_int_equal(locale.pluralStateCount, 1); + assert_int_equal(locale.pluralDefaultIndex, 0); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_parseHeader_less_than(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + char_t hdr[] = "Plural-Forms: nplurals=2; plural=(n < 2 ? 0 : 1);\n"; + errorret_t ret = assetLocaleParseHeader(&locale, hdr, sizeof(hdr)); + + assert_true(errorIsOk(ret)); + assert_int_equal(locale.pluralStateCount, 2); + assert_int_equal(locale.pluralOps[0], ASSET_LOCALE_PLURAL_OP_LESS); + assert_int_equal(locale.pluralValues[0], 2); + assert_int_equal(locale.pluralIndices[0], 0); + assert_int_equal(locale.pluralDefaultIndex, 1); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_parseHeader_greater_equal(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + char_t hdr[] = "Plural-Forms: nplurals=2; plural=(n >= 2 ? 1 : 0);\n"; + errorret_t ret = assetLocaleParseHeader(&locale, hdr, sizeof(hdr)); + + assert_true(errorIsOk(ret)); + assert_int_equal(locale.pluralStateCount, 2); + assert_int_equal(locale.pluralOps[0], ASSET_LOCALE_PLURAL_OP_GREATER_EQUAL); + assert_int_equal(locale.pluralValues[0], 2); + assert_int_equal(locale.pluralIndices[0], 1); + assert_int_equal(locale.pluralDefaultIndex, 0); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_parseHeader_no_plural_forms(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + char_t hdr[] = "Content-Type: text/plain; charset=UTF-8\n"; + errorret_t ret = assetLocaleParseHeader(&locale, hdr, sizeof(hdr)); + + assert_true(errorIsOk(ret)); + assert_int_equal(locale.pluralStateCount, 0); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_parseHeader_error_nplurals_zero(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + char_t hdr[] = "Plural-Forms: nplurals=0; plural=(0);\n"; + errorret_t ret = assetLocaleParseHeader(&locale, hdr, sizeof(hdr)); + + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_parseHeader_error_nplurals_too_large(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + char_t hdr[] = "Plural-Forms: nplurals=7; plural=(0);\n"; + errorret_t ret = assetLocaleParseHeader(&locale, hdr, sizeof(hdr)); + + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_parseHeader_error_missing_nplurals(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + char_t hdr[] = "Plural-Forms: plural=(0);\n"; + errorret_t ret = assetLocaleParseHeader(&locale, hdr, sizeof(hdr)); + + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// ============================================================ +// assetLocaleEvaluatePlural — pure tests +// ============================================================ + +static void test_evaluatePlural_english_singular(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + char_t hdr[] = "Plural-Forms: nplurals=2; plural=(n != 1 ? 1 : 0);\n"; + assetLocaleParseHeader(&locale, hdr, sizeof(hdr)); + + assert_int_equal(assetLocaleEvaluatePlural(&locale, 1), 0); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_evaluatePlural_english_plural(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + char_t hdr[] = "Plural-Forms: nplurals=2; plural=(n != 1 ? 1 : 0);\n"; + assetLocaleParseHeader(&locale, hdr, sizeof(hdr)); + + assert_int_equal(assetLocaleEvaluatePlural(&locale, 0), 1); + assert_int_equal(assetLocaleEvaluatePlural(&locale, 2), 1); + assert_int_equal(assetLocaleEvaluatePlural(&locale, 100), 1); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_evaluatePlural_singular_only(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + char_t hdr[] = "Plural-Forms: nplurals=1; plural=(0);\n"; + assetLocaleParseHeader(&locale, hdr, sizeof(hdr)); + + assert_int_equal(assetLocaleEvaluatePlural(&locale, 0), 0); + assert_int_equal(assetLocaleEvaluatePlural(&locale, 1), 0); + assert_int_equal(assetLocaleEvaluatePlural(&locale, 99), 0); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_evaluatePlural_less_than_boundary(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + char_t hdr[] = "Plural-Forms: nplurals=2; plural=(n < 2 ? 0 : 1);\n"; + assetLocaleParseHeader(&locale, hdr, sizeof(hdr)); + + assert_int_equal(assetLocaleEvaluatePlural(&locale, 0), 0); + assert_int_equal(assetLocaleEvaluatePlural(&locale, 1), 0); + assert_int_equal(assetLocaleEvaluatePlural(&locale, 2), 1); + assert_int_equal(assetLocaleEvaluatePlural(&locale, 10), 1); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// ============================================================ +// assetLocaleGetString — ZIP-based tests +// ============================================================ + +static void test_getString_simple(void **state) { + char_t result[256]; + errorret_t ret = assetLocaleGetString(&g_locale, "greeting", 0, result, sizeof(result)); + + assert_true(errorIsOk(ret)); + assert_string_equal(result, "Hello, World!"); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_getString_plural_singular(void **state) { + char_t result[256]; + errorret_t ret = assetLocaleGetString(&g_locale, "item", 1, result, sizeof(result)); + + assert_true(errorIsOk(ret)); + assert_string_equal(result, "one item"); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_getString_plural_many(void **state) { + char_t result[256]; + errorret_t ret = assetLocaleGetString(&g_locale, "item", 5, result, sizeof(result)); + + assert_true(errorIsOk(ret)); + assert_string_equal(result, "many items"); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_getString_multiple_calls(void **state) { + char_t a[256], b[256]; + errorret_t ret = assetLocaleGetString(&g_locale, "greeting", 0, a, sizeof(a)); + assert_true(errorIsOk(ret)); + + // Second call rewinds the file and re-reads from scratch. + ret = assetLocaleGetString(&g_locale, "greeting", 0, b, sizeof(b)); + assert_true(errorIsOk(ret)); + + assert_string_equal(a, b); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_getString_missing_id(void **state) { + char_t result[256]; + errorret_t ret = assetLocaleGetString(&g_locale, "nonexistent", 0, result, sizeof(result)); + + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// ============================================================ +// assetLocaleGetStringWithArgs — ZIP-based tests +// ============================================================ + +static void test_getStringWithArgs_int(void **state) { + assetlocalearg_t args[] = { + { .type = ASSET_LOCALE_ARG_INT, .intValue = 42 } + }; + char_t result[256]; + errorret_t ret = assetLocaleGetStringWithArgs( + &g_locale, "score", 0, result, sizeof(result), args, 1 + ); + + assert_true(errorIsOk(ret)); + assert_string_equal(result, "Score: 42"); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_getStringWithArgs_string(void **state) { + assetlocalearg_t args[] = { + { .type = ASSET_LOCALE_ARG_STRING, .stringValue = "Alice" } + }; + char_t result[256]; + errorret_t ret = assetLocaleGetStringWithArgs( + &g_locale, "player", 0, result, sizeof(result), args, 1 + ); + + assert_true(errorIsOk(ret)); + assert_string_equal(result, "Player: Alice"); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// ============================================================ +// main +// ============================================================ + +int main(void) { + const struct CMUnitTest tests[] = { + // parseHeader — pure + cmocka_unit_test(test_parseHeader_english), + cmocka_unit_test(test_parseHeader_singular), + cmocka_unit_test(test_parseHeader_less_than), + cmocka_unit_test(test_parseHeader_greater_equal), + cmocka_unit_test(test_parseHeader_no_plural_forms), + cmocka_unit_test(test_parseHeader_error_nplurals_zero), + cmocka_unit_test(test_parseHeader_error_nplurals_too_large), + cmocka_unit_test(test_parseHeader_error_missing_nplurals), + + // evaluatePlural — pure + cmocka_unit_test(test_evaluatePlural_english_singular), + cmocka_unit_test(test_evaluatePlural_english_plural), + cmocka_unit_test(test_evaluatePlural_singular_only), + cmocka_unit_test(test_evaluatePlural_less_than_boundary), + + // getString — in-memory ZIP + cmocka_unit_test_setup_teardown(test_getString_simple, locale_setup, locale_teardown), + cmocka_unit_test_setup_teardown(test_getString_plural_singular, locale_setup, locale_teardown), + cmocka_unit_test_setup_teardown(test_getString_plural_many, locale_setup, locale_teardown), + cmocka_unit_test_setup_teardown(test_getString_multiple_calls, locale_setup, locale_teardown), + cmocka_unit_test_setup_teardown(test_getString_missing_id, locale_setup, locale_teardown), + + // getStringWithArgs — in-memory ZIP + cmocka_unit_test_setup_teardown(test_getStringWithArgs_int, locale_setup, locale_teardown), + cmocka_unit_test_setup_teardown(test_getStringWithArgs_string, locale_setup, locale_teardown), + }; + return cmocka_run_group_tests(tests, NULL, NULL); +}