diff --git a/src/dusk/asset/loader/locale/assetlocaleloader.c b/src/dusk/asset/loader/locale/assetlocaleloader.c index c8374eb4..a4e0eb84 100644 --- a/src/dusk/asset/loader/locale/assetlocaleloader.c +++ b/src/dusk/asset/loader/locale/assetlocaleloader.c @@ -71,6 +71,12 @@ errorret_t assetLocaleDispose(assetentry_t *entry) { assertIsMainThread("Must be called from the main thread."); assetlocalefile_t *localeFile = &entry->data.locale; + + if(localeFile->cache != NULL) { + memoryFree(localeFile->cache); + localeFile->cache = NULL; + } + errorChain(assetFileClose(&localeFile->file)); return assetFileDispose(&localeFile->file); } @@ -470,6 +476,83 @@ errorret_t assetLocaleLineUnbuffer( errorOk(); } +errorret_t assetLocaleCacheFind( + assetlocalefile_t *file, + const char_t *messageId, + const int32_t pluralCount, + char_t *stringBuffer, + const size_t stringBufferSize, + bool_t *outHit +) { + assertNotNull(file, "Asset file cannot be NULL."); + assertNotNull(messageId, "Message ID cannot be NULL."); + assertNotNull(outHit, "outHit cannot be NULL."); + + *outHit = false; + if(file->cache == NULL) errorOk(); + + assetlocalecache_t *cache = file->cache; + for(uint8_t i = 0; i < ASSET_LOCALE_CACHE_COUNT; i++) { + assetlocalecacheentry_t *entry = &cache->entries[i]; + if(entry->messageId[0] == '\0') break;// unused tail - nothing further + if(entry->pluralCount != pluralCount) continue; + if(!stringEquals(entry->messageId, messageId)) continue; + + // Move to the front of the LRU order (a no-op when already there). + if(i > 0) { + assetlocalecacheentry_t hit = *entry; + memoryMove( + &cache->entries[1], &cache->entries[0], i * sizeof(assetlocalecacheentry_t) + ); + cache->entries[0] = hit; + } + + size_t len = strlen(cache->entries[0].value); + if(len >= stringBufferSize) errorThrow("String buffer overflow"); + memoryCopy(stringBuffer, cache->entries[0].value, len + 1); + *outHit = true; + errorOk(); + } + + errorOk(); +} + +void assetLocaleCacheInsert( + assetlocalefile_t *file, + const char_t *messageId, + const int32_t pluralCount, + const char_t *value +) { + assertNotNull(file, "Asset file cannot be NULL."); + assertNotNull(messageId, "Message ID cannot be NULL."); + assertNotNull(value, "Value cannot be NULL."); + + // Caching is a pure optimization - a message ID or value too long for a + // fixed cache slot is just never cached, not an error. + if(strlen(messageId) >= ASSET_LOCALE_CACHE_KEY_MAX) return; + if(strlen(value) >= ASSET_LOCALE_CACHE_VALUE_MAX) return; + + if(file->cache == NULL) { + file->cache = (assetlocalecache_t *)memoryAllocate(sizeof(assetlocalecache_t)); + memoryZero(file->cache, sizeof(assetlocalecache_t)); + } + + // Shift every entry down one slot, dropping the least-recently-used one + // off the end, to make room for the new entry at the front. This assumes + // messageId is never already present elsewhere in the cache, which holds + // as long as callers only insert after a confirmed assetLocaleCacheFind + // miss (a hit would have returned before reaching this point). + memoryMove( + &file->cache->entries[1], &file->cache->entries[0], + (ASSET_LOCALE_CACHE_COUNT - 1) * sizeof(assetlocalecacheentry_t) + ); + + assetlocalecacheentry_t *entry = &file->cache->entries[0]; + stringCopy(entry->messageId, messageId, ASSET_LOCALE_CACHE_KEY_MAX); + entry->pluralCount = pluralCount; + stringCopy(entry->value, value, ASSET_LOCALE_CACHE_VALUE_MAX); +} + errorret_t assetLocaleGetString( assetlocalefile_t *file, const char_t *messageId, @@ -482,6 +565,13 @@ errorret_t assetLocaleGetString( assertTrue(pluralCount >= 0, "Plural index cannot be negative."); assertNotNull(stringBuffer, "String buffer cannot be NULL."); assertTrue(stringBufferSize > 0, "String buffer size must be > 0"); + + bool_t cacheHit = false; + errorChain(assetLocaleCacheFind( + file, messageId, pluralCount, stringBuffer, stringBufferSize, &cacheHit + )); + if(cacheHit) errorOk(); + assetfilelinereader_t reader; bool_t msgidFound = false, msgidPluralFound = false, msgstrFound = false; @@ -625,6 +715,8 @@ errorret_t assetLocaleGetString( errorThrow("Failed to find msgstr for message ID: %s", messageId); } + assetLocaleCacheInsert(file, messageId, pluralCount, stringBuffer); + errorOk(); } diff --git a/src/dusk/asset/loader/locale/assetlocaleloader.h b/src/dusk/asset/loader/locale/assetlocaleloader.h index 79dfa31e..44d1fcce 100644 --- a/src/dusk/asset/loader/locale/assetlocaleloader.h +++ b/src/dusk/asset/loader/locale/assetlocaleloader.h @@ -70,6 +70,41 @@ typedef struct { }; } assetlocalearg_t; +/** Number of recently resolved strings @ref assetlocalecache_t remembers. */ +#define ASSET_LOCALE_CACHE_COUNT 128 + +/** Max length (excluding null terminator) of a cacheable message ID. */ +#define ASSET_LOCALE_CACHE_KEY_MAX 64 + +/** Max length (excluding null terminator) of a cacheable resolved string. */ +#define ASSET_LOCALE_CACHE_VALUE_MAX 256 + +/** One (messageId, pluralCount) -> resolved string cache slot. */ +typedef struct { + char_t messageId[ASSET_LOCALE_CACHE_KEY_MAX]; + int32_t pluralCount; + char_t value[ASSET_LOCALE_CACHE_VALUE_MAX]; +} assetlocalecacheentry_t; + +/** + * Fixed-size move-to-front LRU cache of recently resolved locale strings. + * assetLocaleGetString() re-scans and re-decompresses the whole PO file on + * every miss, which is expensive to repeat for strings a screen fetches + * every time it opens (menu labels, etc) - this cache lets identical + * (messageId, pluralCount) lookups skip that entirely. + * + * Lazily allocated on the first cache insert (see assetlocalefile_t.cache) + * so locale entries that are never queried, or a file that's disposed + * before anything is cached, never pay for it. + */ +typedef struct { + /** + * Entries ordered most-recently-used first. An entry with + * messageId[0] == '\0' (and every entry after it) is unused. + */ + assetlocalecacheentry_t entries[ASSET_LOCALE_CACHE_COUNT]; +} assetlocalecache_t; + /** * Runtime state for an open locale file. * @@ -98,6 +133,16 @@ typedef struct { /** Form index used when no conditional clause matches. */ uint8_t pluralDefaultIndex; + + /** + * Recently resolved string cache, or NULL if nothing has been cached yet. + * Heap-allocated rather than embedded because assetlocalefile_t lives + * inside the shared assetloaderoutput_t union alongside every other + * asset type - embedding a 128-entry cache there would size that union + * (and therefore every one of the ASSET_ENTRY_COUNT_MAX asset slots, + * regardless of what type of asset occupies it) up by the same amount. + */ + assetlocalecache_t *cache; } assetlocalefile_t; /** Convenience alias - the loaded output type of a locale asset entry. */ @@ -203,12 +248,64 @@ errorret_t assetLocaleLineUnbuffer( const size_t stringBufferSize ); +/** + * Searches a locale file's cache for a previously resolved + * (messageId, pluralCount) pair. + * + * On a hit, copies the cached value into `stringBuffer` (erroring if it + * doesn't fit, same contract as @ref assetLocaleGetString) and moves the + * entry to the front of the cache's move-to-front LRU order. On a miss, + * `stringBuffer` is left untouched. + * + * @param file Locale file whose cache to search. Its cache may be NULL + * (nothing cached yet), which is treated as a miss. + * @param messageId Message ID to look up. + * @param pluralCount Plural count the original lookup used. + * @param stringBuffer Destination buffer, filled only on a hit. + * @param stringBufferSize Capacity of `stringBuffer` in bytes. + * @param outHit Set to true on a cache hit, false on a miss. + * @return OK on success (hit or miss), error if a hit's cached value does + * not fit `stringBuffer`. + */ +errorret_t assetLocaleCacheFind( + assetlocalefile_t *file, + const char_t *messageId, + const int32_t pluralCount, + char_t *stringBuffer, + const size_t stringBufferSize, + bool_t *outHit +); + +/** + * Records a resolved (messageId, pluralCount) -> value string at the front + * of the cache's move-to-front LRU order, evicting the least-recently-used + * entry if the cache is already full. Lazily allocates the cache (see + * assetlocalefile_t.cache) on the first call. + * + * Caching is a pure optimization: if `messageId` or `value` is too long to + * fit the cache's fixed-size slots, this silently does nothing rather than + * erroring or growing the cache. + * + * @param file Locale file whose cache to insert into. + * @param messageId Message ID that was looked up. + * @param pluralCount Plural count the lookup was made with. + * @param value Resolved string to cache. + */ +void assetLocaleCacheInsert( + assetlocalefile_t *file, + const char_t *messageId, + const int32_t pluralCount, + const char_t *value +); + /** * 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. + * Checks the file's cache first (see @ref assetLocaleCacheFind); on a miss, + * rewinds the file and scans from the beginning, then caches the result + * (see @ref assetLocaleCacheInsert) before returning. 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). diff --git a/test/asset/test_assetlocale.c b/test/asset/test_assetlocale.c index 8a7d44d2..69b11d2c 100644 --- a/test/asset/test_assetlocale.c +++ b/test/asset/test_assetlocale.c @@ -9,6 +9,7 @@ #include "asset/loader/locale/assetlocaleloader.h" #include "asset/asset.h" #include "util/memory.h" +#include "util/string.h" #include // ============================================================ @@ -128,6 +129,7 @@ static int locale_teardown(void **state) { } ASSET.zip = NULL; + if(g_locale.cache != NULL) memoryFree(g_locale.cache); memoryZero(&g_locale, sizeof(g_locale)); return 0; } @@ -311,7 +313,10 @@ static void test_getString_simple(void **state) { assert_true(errorIsOk(ret)); assert_string_equal(result, "Hello, World!"); - assert_int_equal(memoryGetAllocatedCount(), 0); + // 1, not 0: the locale-string cache is lazily allocated on first use (the + // header lookup in locale_setup already triggered it) and lives for the + // rest of this file's lifetime - it's a bounded one-time cost, not a leak. + assert_int_equal(memoryGetAllocatedCount(), 1); } static void test_getString_plural_singular(void **state) { @@ -320,7 +325,7 @@ static void test_getString_plural_singular(void **state) { assert_true(errorIsOk(ret)); assert_string_equal(result, "one item"); - assert_int_equal(memoryGetAllocatedCount(), 0); + assert_int_equal(memoryGetAllocatedCount(), 1);// see test_getString_simple } static void test_getString_plural_many(void **state) { @@ -329,7 +334,7 @@ static void test_getString_plural_many(void **state) { assert_true(errorIsOk(ret)); assert_string_equal(result, "many items"); - assert_int_equal(memoryGetAllocatedCount(), 0); + assert_int_equal(memoryGetAllocatedCount(), 1);// see test_getString_simple } static void test_getString_multiple_calls(void **state) { @@ -337,12 +342,13 @@ static void test_getString_multiple_calls(void **state) { 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. + // Second call is now served straight from the cache the first call + // populated, rather than rewinding the file and re-scanning 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); + assert_int_equal(memoryGetAllocatedCount(), 1);// see test_getString_simple } static void test_getString_missing_id(void **state) { @@ -351,9 +357,272 @@ static void test_getString_missing_id(void **state) { assert_true(errorIsNotOk(ret)); errorCatch(ret); + assert_int_equal(memoryGetAllocatedCount(), 1);// see test_getString_simple +} + +// ============================================================ +// assetLocaleCacheFind / assetLocaleCacheInsert - pure tests +// ============================================================ +// These call the cache directly rather than through assetLocaleGetString - +// no open file is ever needed since the cache never touches file->file. + +static void test_cache_findMiss_onEmptyCache(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + char_t result[64]; + bool_t hit = true; + errorret_t ret = assetLocaleCacheFind(&locale, "greeting", 0, result, sizeof(result), &hit); + + assert_true(errorIsOk(ret)); + assert_false(hit); + assert_null(locale.cache);// a pure miss must not allocate anything assert_int_equal(memoryGetAllocatedCount(), 0); } +static void test_cache_insertThenFind_hits(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + assetLocaleCacheInsert(&locale, "greeting", 0, "Hello, World!"); + assert_non_null(locale.cache); + + char_t result[64]; + bool_t hit = false; + errorret_t ret = assetLocaleCacheFind(&locale, "greeting", 0, result, sizeof(result), &hit); + + assert_true(errorIsOk(ret)); + assert_true(hit); + assert_string_equal(result, "Hello, World!"); + + memoryFree(locale.cache); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_cache_distinguishesByPluralCount(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + assetLocaleCacheInsert(&locale, "item", 1, "one item"); + assetLocaleCacheInsert(&locale, "item", 5, "many items"); + + char_t result[64]; + bool_t hit = false; + + errorret_t ret = assetLocaleCacheFind(&locale, "item", 1, result, sizeof(result), &hit); + assert_true(errorIsOk(ret)); + assert_true(hit); + assert_string_equal(result, "one item"); + + ret = assetLocaleCacheFind(&locale, "item", 5, result, sizeof(result), &hit); + assert_true(errorIsOk(ret)); + assert_true(hit); + assert_string_equal(result, "many items"); + + memoryFree(locale.cache); +} + +static void test_cache_find_missOnDifferentMessageId(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + assetLocaleCacheInsert(&locale, "greeting", 0, "Hello, World!"); + + char_t result[64]; + bool_t hit = true; + errorret_t ret = assetLocaleCacheFind(&locale, "farewell", 0, result, sizeof(result), &hit); + + assert_true(errorIsOk(ret)); + assert_false(hit); + + memoryFree(locale.cache); +} + +static void test_cache_find_bufferTooSmall_errors(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + assetLocaleCacheInsert(&locale, "greeting", 0, "Hello, World!"); + + char_t result[4];// too small for "Hello, World!" + bool_t hit = false; + errorret_t ret = assetLocaleCacheFind(&locale, "greeting", 0, result, sizeof(result), &hit); + + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + + memoryFree(locale.cache); +} + +static void test_cache_insert_tooLongMessageId_notCached(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + char_t longId[ASSET_LOCALE_CACHE_KEY_MAX + 1]; + memorySet(longId, 'a', sizeof(longId) - 1); + longId[sizeof(longId) - 1] = '\0'; + + assetLocaleCacheInsert(&locale, longId, 0, "value"); + + // Nothing should have been cached - the cache itself is never even + // allocated, since this was the only insert attempted. + assert_null(locale.cache); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_cache_insert_tooLongValue_notCached(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + char_t longValue[ASSET_LOCALE_CACHE_VALUE_MAX + 1]; + memorySet(longValue, 'a', sizeof(longValue) - 1); + longValue[sizeof(longValue) - 1] = '\0'; + + assetLocaleCacheInsert(&locale, "greeting", 0, longValue); + + assert_null(locale.cache); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_cache_insert_evictsLeastRecentlyUsed(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + // Fill the cache completely, "id0" first (so it's the first to become + // least-recently-used) through "id127" last (most-recently-used). + char_t key[32], value[32]; + for(int32_t i = 0; i < ASSET_LOCALE_CACHE_COUNT; i++) { + stringFormat(key, sizeof(key), "id%d", i); + stringFormat(value, sizeof(value), "value%d", i); + assetLocaleCacheInsert(&locale, key, 0, value); + } + + // One more insert should evict "id0", the least-recently-used entry. + assetLocaleCacheInsert(&locale, "newcomer", 0, "new value"); + + char_t result[32]; + bool_t hit = true; + errorret_t ret = assetLocaleCacheFind(&locale, "id0", 0, result, sizeof(result), &hit); + assert_true(errorIsOk(ret)); + assert_false(hit); + + // But "id1" (and everything after it) should have survived. + ret = assetLocaleCacheFind(&locale, "id1", 0, result, sizeof(result), &hit); + assert_true(errorIsOk(ret)); + assert_true(hit); + assert_string_equal(result, "value1"); + + memoryFree(locale.cache); +} + +static void test_cache_find_movesHitToFront(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + + // Fill the cache, then immediately re-touch "id0" so it becomes + // most-recently-used instead of least-recently-used. + char_t key[32], value[32]; + for(int32_t i = 0; i < ASSET_LOCALE_CACHE_COUNT; i++) { + stringFormat(key, sizeof(key), "id%d", i); + stringFormat(value, sizeof(value), "value%d", i); + assetLocaleCacheInsert(&locale, key, 0, value); + } + + char_t result[32]; + bool_t hit = false; + errorret_t ret = assetLocaleCacheFind(&locale, "id0", 0, result, sizeof(result), &hit); + assert_true(errorIsOk(ret)); + assert_true(hit); + + // Without the touch above, this insert would have evicted "id0" (the + // original least-recently-used entry) - it should now evict "id1" instead. + assetLocaleCacheInsert(&locale, "newcomer", 0, "new value"); + + ret = assetLocaleCacheFind(&locale, "id0", 0, result, sizeof(result), &hit); + assert_true(errorIsOk(ret)); + assert_true(hit); + + ret = assetLocaleCacheFind(&locale, "id1", 0, result, sizeof(result), &hit); + assert_true(errorIsOk(ret)); + assert_false(hit); + + memoryFree(locale.cache); +} + +static void test_cache_nullAsserts(void **state) { + assetlocalefile_t locale; + memoryZero(&locale, sizeof(locale)); + char_t result[64]; + bool_t hit; + + expect_assert_failure( + assetLocaleCacheFind(NULL, "greeting", 0, result, sizeof(result), &hit) + ); + expect_assert_failure( + assetLocaleCacheFind(&locale, NULL, 0, result, sizeof(result), &hit) + ); + expect_assert_failure( + assetLocaleCacheFind(&locale, "greeting", 0, result, sizeof(result), NULL) + ); + + expect_assert_failure(assetLocaleCacheInsert(NULL, "greeting", 0, "value")); + expect_assert_failure(assetLocaleCacheInsert(&locale, NULL, 0, "value")); + expect_assert_failure(assetLocaleCacheInsert(&locale, "greeting", 0, NULL)); +} + +static void test_localeDispose_freesCache(void **state) { + zip_error_t err; + zip_error_init(&err); + zip_source_t *write_src = zip_source_buffer_create(NULL, 0, 1, &err); + assert_non_null(write_src); + zip_t *za = zip_open_from_source(write_src, ZIP_TRUNCATE, &err); + assert_non_null(za); + size_t flen = strlen(LOCALE_EN); + zip_source_t *fs = zip_source_buffer(za, LOCALE_EN, flen, 0); + assert_true(zip_file_add(za, "en.locale", fs, ZIP_FL_OVERWRITE) >= 0); + zip_source_keep(write_src); + assert_int_equal(zip_close(za), 0); + + zip_stat_t zs; + memset(&zs, 0, sizeof(zs)); + assert_int_equal(zip_source_stat(write_src, &zs), 0); + void *zipbuf = malloc((size_t)zs.size); + assert_non_null(zipbuf); + assert_int_equal(zip_source_open(write_src), 0); + zip_source_read(write_src, zipbuf, (zip_uint64_t)zs.size); + zip_source_close(write_src); + zip_source_free(write_src); + + zip_error_init(&err); + zip_source_t *read_src = zip_source_buffer_create(zipbuf, (zip_uint64_t)zs.size, 1, &err); + assert_non_null(read_src); + zip_t *zip = zip_open_from_source(read_src, 0, &err); + assert_non_null(zip); + ASSET.zip = zip; + + assetentry_t entry; + memoryZero(&entry, sizeof(entry)); + entry.type = ASSET_LOADER_TYPE_LOCALE; + + errorret_t ret = assetFileInit(&entry.data.locale.file, "en.locale", NULL, NULL); + assert_true(errorIsOk(ret)); + ret = assetFileOpen(&entry.data.locale.file); + assert_true(errorIsOk(ret)); + + char_t header[512]; + ret = assetLocaleGetString(&entry.data.locale, "", 0, header, sizeof(header)); + assert_true(errorIsOk(ret)); + assert_non_null(entry.data.locale.cache);// the header lookup populated it + + ret = assetLocaleDispose(&entry); + assert_true(errorIsOk(ret)); + assert_null(entry.data.locale.cache); + assert_int_equal(memoryGetAllocatedCount(), 0); + + zip_close(zip); + ASSET.zip = NULL; +} + // ============================================================ // assetLocaleGetStringWithArgs - ZIP-based tests // ============================================================ @@ -369,7 +638,7 @@ static void test_getStringWithArgs_int(void **state) { assert_true(errorIsOk(ret)); assert_string_equal(result, "Score: 42"); - assert_int_equal(memoryGetAllocatedCount(), 0); + assert_int_equal(memoryGetAllocatedCount(), 1);// see test_getString_simple } static void test_getStringWithArgs_string(void **state) { @@ -383,7 +652,7 @@ static void test_getStringWithArgs_string(void **state) { assert_true(errorIsOk(ret)); assert_string_equal(result, "Player: Alice"); - assert_int_equal(memoryGetAllocatedCount(), 0); + assert_int_equal(memoryGetAllocatedCount(), 1);// see test_getString_simple } // ============================================================ @@ -391,6 +660,7 @@ static void test_getStringWithArgs_string(void **state) { // ============================================================ int main(void) { + assertInit(); const struct CMUnitTest tests[] = { // parseHeader - pure cmocka_unit_test(test_parseHeader_english), @@ -418,6 +688,21 @@ int main(void) { // 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), + + // assetLocaleCacheFind / assetLocaleCacheInsert - pure + cmocka_unit_test(test_cache_findMiss_onEmptyCache), + cmocka_unit_test(test_cache_insertThenFind_hits), + cmocka_unit_test(test_cache_distinguishesByPluralCount), + cmocka_unit_test(test_cache_find_missOnDifferentMessageId), + cmocka_unit_test(test_cache_find_bufferTooSmall_errors), + cmocka_unit_test(test_cache_insert_tooLongMessageId_notCached), + cmocka_unit_test(test_cache_insert_tooLongValue_notCached), + cmocka_unit_test(test_cache_insert_evictsLeastRecentlyUsed), + cmocka_unit_test(test_cache_find_movesHitToFront), + cmocka_unit_test(test_cache_nullAsserts), + + // assetLocaleDispose - in-memory ZIP + cmocka_unit_test(test_localeDispose_freesCache), }; return cmocka_run_group_tests(tests, NULL, NULL); }