tests: add LeastRecentlyUsedCache regression tests (#671)

* launcher: keep primary update feed answer when the fallback request fails

When the primary feed is reachable but its tag differs from the build tag,
the result was discarded and the GitHub API fallback was consulted. If the
fallback request failed (api.github.com rate-limits unauthenticated
requests), the launcher reported an error even though the primary feed had
already returned a usable update answer with a working release URL. Keep
the primary feed answer and show it if the fallback fails.

* emulator: validate numeric CLI arguments

--screen-width, --screen-height, --gpu, and --vblank-frequency were parsed
with Common::ToInt32/ToInt64, which ignores std::from_chars errors and
returns 0, so values like "--screen-width abc" or "--gpu xyz" were
silently accepted: zero screen size, or GPU 0 selected instead of auto
(-1). Reject invalid values with an error message like the other options,
using from_chars-based helpers (ParseUint32/ParseInt32); reject zero
screen dimensions.

* tests: add LeastRecentlyUsedCache regression tests

Cover the LRU cache walk order, tick filtering in ForEachItemBelow,
early-exit boolean callbacks, Touch reordering and same-tick skips,
Free, and freed-id reuse by later inserts. Modeled on BitArrayTests.

* tests: use a tick cutoff that detects a stale newest-item tick

Collect(cache, 35) also visits c when Touch(c, 35) failed to update
its tick (30 <= 35). Use a cutoff of 30 and expect only b.

* Run LRU cache tests in CI

---------

Co-authored-by: nmzik <Nmzik@mail.ru>
This commit is contained in:
Saadi 2026-09-25 23:42:31 +01:00 committed by GitHub
parent 1e438136fa
commit 4e8d8c849d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 236 additions and 16 deletions

View File

@ -112,12 +112,12 @@ jobs:
- name: Build
shell: cmd
run: |
cmake --build _Build/windows --target launcher audio_out2_port_tests ime_dialog_tests virtual_memory_allocation_tests --parallel
cmake --build _Build/windows --target launcher audio_out2_port_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests --parallel
- name: Test
shell: cmd
run: |
ctest --test-dir _Build/windows --output-on-failure -R "^(audio_out2_port|ime_dialog|virtual_memory_allocation)$"
ctest --test-dir _Build/windows --output-on-failure -R "^(audio_out2_port|ime_dialog|lru_cache|virtual_memory_allocation)$"
- name: Install
shell: cmd
@ -198,14 +198,14 @@ jobs:
shell: bash
run: |
cmake --build _Build/macos \
--target launcher audio_out2_port_tests ime_dialog_tests virtual_memory_allocation_tests \
--target launcher audio_out2_port_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
--parallel
- name: Test
shell: bash
run: |
ctest --test-dir _Build/macos --output-on-failure \
-R '^(audio_out2_port|ime_dialog|virtual_memory_allocation)$'
-R '^(audio_out2_port|ime_dialog|lru_cache|virtual_memory_allocation)$'
- name: Install
shell: bash
@ -379,14 +379,14 @@ jobs:
run: |
cmake --build _Build/linux \
--target launcher page_manager_tests memory_tracker_tests \
audio_out2_port_tests ime_dialog_tests virtual_memory_allocation_tests \
audio_out2_port_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
--parallel
- name: Test
shell: bash
run: |
ctest --test-dir _Build/linux --output-on-failure \
-R '^(audio_out2_port|ime_dialog|page_manager|memory_tracker|virtual_memory_allocation)$'
-R '^(audio_out2_port|ime_dialog|lru_cache|page_manager|memory_tracker|virtual_memory_allocation)$'
- name: Install
shell: bash

View File

@ -411,6 +411,11 @@ add_executable(bit_array_tests EXCLUDE_FROM_ALL
)
target_include_directories(bit_array_tests PRIVATE ${inc_headers})
add_executable(lru_cache_tests EXCLUDE_FROM_ALL
"${KYTY_TESTS_DIR}/LruCacheTests.cpp"
)
target_include_directories(lru_cache_tests PRIVATE ${inc_headers})
add_executable(ime_dialog_tests EXCLUDE_FROM_ALL
"${KYTY_TESTS_DIR}/ImeDialogTests.cpp"
"${KYTY_SOURCE_DIR}/libs/imeCommon.cpp"
@ -555,6 +560,7 @@ if(BUILD_TESTING)
add_test(NAME memory_tracker COMMAND $<TARGET_FILE:memory_tracker_tests>)
add_test(NAME page_manager COMMAND $<TARGET_FILE:page_manager_tests>)
add_test(NAME bit_array COMMAND $<TARGET_FILE:bit_array_tests>)
add_test(NAME lru_cache COMMAND $<TARGET_FILE:lru_cache_tests>)
add_test(NAME shader_vertex_metadata COMMAND $<TARGET_FILE:shader_vertex_metadata_tests>)
add_test(NAME resource_materialization COMMAND $<TARGET_FILE:resource_materialization_tests>)
add_test(NAME resource_tracking COMMAND $<TARGET_FILE:resource_tracking_tests>)
@ -619,6 +625,7 @@ if(BUILD_TESTING)
memory_tracker_tests
page_manager_tests
bit_array_tests
lru_cache_tests
shader_vertex_metadata_tests
resource_materialization_tests
resource_tracking_tests

View File

@ -3,6 +3,8 @@
#include <QNetworkAccessManager>
#include <QObject>
#include <QString>
#include <QUrl>
class QByteArray;
class QWidget;
@ -29,6 +31,11 @@ private:
QWidget* m_parent = nullptr;
QNetworkAccessManager m_network;
bool m_checking_updates = false;
// Answer from the primary update feed, kept while the GitHub fallback feed is
// being consulted, so it can still be shown if the fallback request fails.
QString m_primary_tag;
QUrl m_primary_page_url;
};
#endif // UPDATE_CHECKER_H

View File

@ -84,12 +84,30 @@ void UpdateChecker::FetchUpdateInfo(const char* url, bool fallback, bool manual)
}
reply->deleteLater();
if (!fallback &&
(!info.error.isEmpty() || info.tag != QString::fromLatin1(KYTY_RELEASE_TAG))) {
FetchUpdateInfo(FALLBACK_FEED_URL, true, manual);
return;
if (!fallback) {
const bool up_to_date =
info.error.isEmpty() && info.tag == QString::fromLatin1(KYTY_RELEASE_TAG);
if (!up_to_date) {
if (info.error.isEmpty()) {
// The primary feed is reachable but disagrees with the build tag
// (e.g. a stale mirror); remember its answer in case the fallback
// request also fails.
m_primary_tag = info.tag;
m_primary_page_url = info.page_url;
}
FetchUpdateInfo(FALLBACK_FEED_URL, true, manual);
return;
}
}
if (fallback && !info.error.isEmpty() && !m_primary_tag.isEmpty()) {
// The fallback feed failed, but the primary feed already returned a
// usable answer; prefer it over reporting a network error.
info = {m_primary_tag, m_primary_page_url, {}};
}
m_primary_tag.clear();
m_primary_page_url.clear();
m_checking_updates = false;
emit CheckingChanged(false);
ShowUpdateResult(info, manual);

View File

@ -133,6 +133,26 @@ static bool ParseConsoleLanguage(const std::string& value, uint32_t& out) {
return true;
}
static bool ParseUint32(const std::string& value, uint32_t& out) {
uint32_t number = 0;
auto [end, error] = std::from_chars(value.data(), value.data() + value.size(), number);
if (error != std::errc {} || end != value.data() + value.size()) {
return false;
}
out = number;
return true;
}
static bool ParseInt32(const std::string& value, int32_t& out) {
int32_t number = 0;
auto [end, error] = std::from_chars(value.data(), value.data() + value.size(), number);
if (error != std::errc {} || end != value.data() + value.size()) {
return false;
}
out = number;
return true;
}
static bool ParseUserId(const std::string& value, int32_t& out) {
int32_t user_id = 0;
auto [end, error] = std::from_chars(value.data(), value.data() + value.size(), user_id);
@ -246,9 +266,17 @@ static bool ParseArgs(int argc, char* argv[], RunOptions& options, bool& show_he
}
options.game_patch = path;
} else if (arg == "--screen-width") {
options.config.screen_width = static_cast<uint32_t>(Common::ToInt32(value));
if (!ParseUint32(value, options.config.screen_width) ||
options.config.screen_width == 0) {
::printf("invalid screen width: %s\n", value.c_str());
return false;
}
} else if (arg == "--screen-height") {
options.config.screen_height = static_cast<uint32_t>(Common::ToInt32(value));
if (!ParseUint32(value, options.config.screen_height) ||
options.config.screen_height == 0) {
::printf("invalid screen height: %s\n", value.c_str());
return false;
}
} else if (arg == "--user-name") {
if (value.empty() || value.size() > Config::MAX_USER_NAME_LENGTH) {
::printf("invalid user name: must contain 1-%zu bytes\n",
@ -269,11 +297,15 @@ static bool ParseArgs(int argc, char* argv[], RunOptions& options, bool& show_he
return false;
}
} else if (arg == "--gpu") {
options.config.gpu_index = Common::ToInt32(value);
if (!ParseInt32(value, options.config.gpu_index)) {
::printf("invalid gpu index: %s\n", value.c_str());
return false;
}
} else if (arg == "--vblank-frequency") {
const int32_t vblank_frequency = Common::ToInt32(value);
options.config.vblank_frequency =
static_cast<uint32_t>(vblank_frequency < 0 ? 0 : vblank_frequency);
if (!ParseUint32(value, options.config.vblank_frequency)) {
::printf("invalid vblank frequency: %s\n", value.c_str());
return false;
}
} else if (arg == "--console-language") {
if (!ParseConsoleLanguage(value, options.config.console_language)) {
::printf("invalid console language: %s\n", value.c_str());

156
tests/LruCacheTests.cpp Normal file
View File

@ -0,0 +1,156 @@
#include "common/lruCache.h"
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
namespace {
void Check(bool value, const char *message) {
if (!value) {
std::fprintf(stderr, "LruCacheTests: failed: %s\n", message);
std::abort();
}
}
// Collect the objects reachable through ForEachItemBelow(), in visit order.
std::vector<std::string> Collect(Common::LeastRecentlyUsedCache<std::string, uint64_t> &cache,
uint64_t tick) {
std::vector<std::string> visited;
cache.ForEachItemBelow(tick, [&](const std::string &object) { visited.push_back(object); });
return visited;
}
void TestInsertAndVisitOrder() {
Common::LeastRecentlyUsedCache<std::string, uint64_t> cache;
const auto a = cache.Insert("a", 10);
const auto b = cache.Insert("b", 20);
const auto c = cache.Insert("c", 30);
Check(a != b && b != c && a != c, "Insert returned duplicate ids");
const auto all = Collect(cache, UINT64_MAX);
Check(all.size() == 3 && all[0] == "a" && all[1] == "b" && all[2] == "c",
"inserted items are not visited in insertion order");
const auto below = Collect(cache, 20);
Check(below.size() == 2 && below[0] == "a" && below[1] == "b",
"ForEachItemBelow visited an item above the tick");
const auto none = Collect(cache, 5);
Check(none.empty(), "ForEachItemBelow visited items below an empty tick");
}
void TestEarlyExitCallback() {
Common::LeastRecentlyUsedCache<std::string, uint64_t> cache;
(void)cache.Insert("a", 10);
(void)cache.Insert("b", 20);
(void)cache.Insert("c", 30);
std::vector<std::string> visited;
cache.ForEachItemBelow(UINT64_MAX, [&](const std::string &object) {
visited.push_back(object);
return true; // stop after the first item
});
Check(visited.size() == 1 && visited[0] == "a", "boolean callback did not stop the walk");
std::vector<std::string> all_visited;
cache.ForEachItemBelow(UINT64_MAX, [&](const std::string &object) {
all_visited.push_back(object);
return false;
});
Check(all_visited.size() == 3, "false-returning callback did not visit every item");
}
void TestTouchReordersAndSkips() {
Common::LeastRecentlyUsedCache<std::string, uint64_t> cache;
const auto a = cache.Insert("a", 10);
const auto b = cache.Insert("b", 20);
const auto c = cache.Insert("c", 30);
// Touching the last item must not change the order.
cache.Touch(c, 35);
auto order = Collect(cache, UINT64_MAX);
Check(order.size() == 3 && order[0] == "a" && order[1] == "b" && order[2] == "c",
"touching the newest item changed the order");
// An older or equal tick must be ignored.
cache.Touch(a, 10);
cache.Touch(a, 5);
order = Collect(cache, UINT64_MAX);
Check(order[0] == "a" && order[1] == "b" && order[2] == "c",
"an ignored touch changed the order");
// Touching the oldest item moves it to the newest end.
cache.Touch(a, 40);
order = Collect(cache, UINT64_MAX);
Check(order.size() == 3 && order[0] == "b" && order[1] == "c" && order[2] == "a",
"touching an item did not move it to the newest end");
// The touched item now carries its new tick for tick filtering
// (a=40, b=20, c=35 after the touches above). A cutoff of 30 detects
// a stale c tick: c is visited only if Touch(c, 35) updated it.
const auto below = Collect(cache, 30);
Check(below.size() == 1 && below[0] == "b",
"touched item did not adopt its new tick");
// Touch the middle item.
cache.Touch(b, 45);
order = Collect(cache, UINT64_MAX);
Check(order.size() == 3 && order[0] == "c" && order[1] == "a" && order[2] == "b",
"touching a middle item did not move it to the end");
}
void TestFreeAndIdReuse() {
Common::LeastRecentlyUsedCache<std::string, uint64_t> cache;
const auto a = cache.Insert("a", 10);
const auto b = cache.Insert("b", 20);
const auto c = cache.Insert("c", 30);
// Free the first and the last items.
cache.Free(a);
cache.Free(c);
auto order = Collect(cache, UINT64_MAX);
Check(order.size() == 1 && order[0] == "b", "Free left a freed item in the walk");
// Freed ids must be reused by later inserts.
const auto d = cache.Insert("d", 40);
Check(d == a || d == c, "Insert did not reuse a freed id");
const auto e = cache.Insert("e", 50);
Check(e != d && (e == a || e == c), "Insert reused the same freed id twice");
order = Collect(cache, UINT64_MAX);
Check(order.size() == 3 && order[0] == "b" && order[1] == "d" && order[2] == "e",
"reused ids are not visited in tick order");
// Touching a reused id must not disturb the other items.
cache.Touch(d, 60);
order = Collect(cache, UINT64_MAX);
Check(order.size() == 3 && order[0] == "b" && order[1] == "e" && order[2] == "d",
"touching a reused id changed the wrong items");
// Free everything, then rebuild.
cache.Free(b);
cache.Free(d);
cache.Free(e);
Check(Collect(cache, UINT64_MAX).empty(), "freeing every item left items in the walk");
const auto again = cache.Insert("again", 70);
const auto order2 = Collect(cache, UINT64_MAX);
Check(order2.size() == 1 && order2[0] == "again", "insert after a full free failed");
cache.Touch(again, 70);
Check(Collect(cache, 70).size() == 1, "touching a rebuilt item failed");
}
} // namespace
int main() {
TestInsertAndVisitOrder();
TestEarlyExitCallback();
TestTouchReordersAndSkips();
TestFreeAndIdReuse();
std::puts("LruCacheTests: all cases passed");
return 0;
}