From e5f3d6bd9d884eb0bf24b1cf8454f0211166b886 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 15:51:18 -0500 Subject: [PATCH] feat(dashboard): UGC settings beside what they change on the UGC page, their own Settings category The settings catalog gives the UGC server a category of its own (serving, processing, models, storage, icons). The UGC page shows those sections next to the server status, the purge tools and the icon presets, read from the catalog and saved through the Settings page's own path; each links to its entry on the Settings page, which now opens a #file/name link at that setting. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/routes/SettingsCatalog.cpp | 20 ++- dDashboardServer/static/js/settings.js | 6 +- dDashboardServer/static/js/ugc-settings.js | 78 +++++++++++ dDashboardServer/templates/ugc.jinja2 | 8 +- tests/dWebTests/UgcLookupTests.cpp | 128 +++++++++++++++++- .../dashboard_smoke_test.cpython-314.pyc | Bin 37771 -> 37798 bytes 6 files changed, 226 insertions(+), 14 deletions(-) create mode 100644 dDashboardServer/static/js/ugc-settings.js diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index afd2dc98c..431554f7c 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -130,7 +130,6 @@ namespace { c.AddSection("Startup"); c.Add(Bool(MASTER, "prestart_servers", "Start auth, chat and char servers", "Master starts the other servers itself.", true, true)); c.Add(Bool(MASTER, "enable_dashboard", "Start the web dashboard", "", false, true)); - c.Add(Bool(MASTER, "enable_ugc_server", "Start the UGC server", "Makes and serves the meshes and icons of what players build (docs/UgcServer.md).", false, true)); c.Add(Bool(SHARED, "skip_account_creation", "Skip the first-account prompt", "For non-interactive setups: master doesn't ask for an account when there are none.", false, true)); c.AddSection("Game client"); @@ -298,8 +297,6 @@ namespace { c.Add(Port(DASHBOARD, "port", "Web port", "Where the dashboard listens for browsers.", "2006")); c.Add(Format(Text(DASHBOARD, "listen_ip", "Listen address", "127.0.0.1 for this machine only (put a reverse proxy in front); 0.0.0.0 for everyone.", "127.0.0.1", true), eFormat::HOST)); c.Add(Port(DASHBOARD, "net_port", "Server link port", "UDP port for the connection to master (the next one is used too). Keep clear of other servers' ports.", "2010")); - c.Add(Format(Text(DASHBOARD, "ugc_internal_url", "UGC server address (internal)", "Where the dashboard itself reaches the UGC server for its status and files. Empty: http://127.0.0.1:2008.", ""), eFormat::URL)); - c.Add(Format(Text(DASHBOARD, "ugc_public_url", "UGC server address (public)", "Only for the \"open on the UGC server\" links, e.g. https://ugc.example.com. The dashboard's own pages don't need the browser to reach it.", ""), eFormat::URL)); c.Add(Format(Text(DASHBOARD, "dashboard_url", "Public address", "For links in emails and alerts, e.g. https://dashboard.example.com.", ""), eFormat::URL)); c.Add(Bool(DASHBOARD, "secure_cookies", "HTTPS only cookies", "Turn on when the dashboard is served over HTTPS.", false)); c.Add(Bool(DASHBOARD, "behind_proxy", "Behind a reverse proxy", "Use the proxy's X-Forwarded-For for rate limits. Only when the dashboard can't be reached directly.", false)); @@ -412,14 +409,17 @@ namespace { c.AddSection("Character history"); c.Add(Days("snapshot_days", "Snapshots", "Older snapshots are deleted, but each character keeps its newest few (below).", "90", 1)); c.Add(Unit(Int(DASHBOARD, "snapshot_keep", "Always keep per character", "", "10", 1, 1000), "snapshots")); - c.AddSection("UGC server", "Makes the meshes and icons of what players build and serves them to the game client. See docs/UgcServer.md.", - eLayout::ROWS, Condition{ MASTER, "enable_ugc_server", { "1" } }); + // The UGC server's settings, also shown beside what they change on the UGC page (/ugc reads these sections) + c.AddCategory("ugc", "UGC server", "Makes the meshes and icons of what players build and serves them to the game client. See docs/UgcServer.md; the UGC page shows these beside what they change."); + c.AddSection("UGC serving", "Where the game client downloads from and where the dashboard reaches the UGC server."); + c.Add(Bool(MASTER, "enable_ugc_server", "Start the UGC server", "Makes and serves the meshes and icons of what players build (docs/UgcServer.md).", false, true)); c.Add(Port(UGC, "port", "Download port", "The game client's UGCSERVERPORT.", "2008")); c.Add(Format(Text(UGC, "listen_ip", "Listen address", "The game client has to reach it.", "0.0.0.0", true), eFormat::HOST)); c.Add(Port(UGC, "net_port", "Master connection port", "UDP; the next port is used too.", "2012")); c.Add(Text(UGC, "client_path", "Download path", "The game client's UGCSERVERDIR.", "/ugc", true)); - c.Add(Format(Text(UGC, "ugc_output_dir", "Files folder", "Relative to the server binaries.", "ugc", true), eFormat::PATH)); - c.Add(Unit(Int(UGC, "ugc_max_storage_mb", "Most space for files", "The files used longest ago are deleted past this and made again when asked for. 0: no limit.", "2048", 0, std::nullopt, true), "MB")); + c.Add(Format(Text(DASHBOARD, "ugc_internal_url", "UGC server address (internal)", "Where the dashboard itself reaches the UGC server for its status and files. Empty: http://127.0.0.1:2008.", ""), eFormat::URL)); + c.Add(Format(Text(DASHBOARD, "ugc_public_url", "UGC server address (public)", "Only for the \"open on the UGC server\" links, e.g. https://ugc.example.com. The dashboard's own pages don't need the browser to reach it.", ""), eFormat::URL)); + c.AddSection("UGC processing", "When models are made and how much of the machine the workers may use.", eLayout::ROWS, Condition{ MASTER, "enable_ugc_server", { "1" } }); c.Add(Unit(Int(SHARED, "ugc_debounce_seconds", "Wait after a save", "A saved model is made this long after the owner's last save (their other waiting models wait too), or as soon as a game client asks for it or the owner leaves. 0: right away.", "120", 0, 86400), "seconds")); c.Add(Int(UGC, "worker_threads", "Worker threads", "0: half the CPU cores.", "0", 0, 64, true)); c.Add(Unit(Float(UGC, "max_cpu_percent", "Most CPU", "The workers together average at most this share of all CPU cores; they pause between bits of work to stay under it. 0: no limit.", "0", 0, 100), "%")); @@ -430,6 +430,7 @@ namespace { c.Add(Unit(Int(UGC, "poll_interval_ms", "Look for new models every", "", "2000", 100, 600000, true), "ms")); c.Add(Int(UGC, "poll_batch", "Models taken at once", "", "32", 1, 1000, true)); c.Add(Int(UGC, "max_attempts", "Attempts before giving up", "", "3", 1, 100, true)); + c.AddSection("UGC models", "How a model's mesh is made from its bricks (LU Toolbox's steps).", eLayout::ROWS, Condition{ MASTER, "enable_ugc_server", { "1" } }); c.Add(Labels(Choice(UGC, "color_palette", "Colors", "LU Toolbox's LU palette (unknown colors black), or the client's Materials.xml.", "lu_toolbox", { "lu_toolbox", "brickdb" }), { "LU Toolbox", "Brick database" })); c.Add(Unit(Float(UGC, "color_variation", "Color variation", "Each brick's brightness is shifted by up to this much (the same every time the model is made). 0: none.", "5", 0, 100), "%")); c.Add(Unit(Float(UGC, "transparent_opacity", "Transparent opacity", "", "58.82", 0, 100), "%")); @@ -449,6 +450,11 @@ namespace { c.Add(Float(UGC, "ao_distance", "Occlusion distance", "", "5", 0, 1000)); c.Add(Float(UGC, "ao_strength", "Darkening strength", "0 to 1.", "1", 0, 1)); c.Add(Float(UGC, "glow_strength", "Glow strength", "What glowing colors add to the baked light.", "6", 0, 100)); + c.AddSection("UGC storage", "The files made, kept on disk.", eLayout::ROWS, Condition{ MASTER, "enable_ugc_server", { "1" } }); + c.Add(Format(Text(UGC, "ugc_output_dir", "Files folder", "Relative to the server binaries.", "ugc", true), eFormat::PATH)); + c.Add(Unit(Int(UGC, "ugc_max_storage_mb", "Most space for files", "The files used longest ago are deleted past this and made again when asked for. 0: no limit.", "2048", 0, std::nullopt, true), "MB")); + c.AddSection("UGC icons", "The icons' size and the defaults of their framing and light. Presets per type and values for single items are set in the UGC page's icon editor.", + eLayout::ROWS, Condition{ MASTER, "enable_ugc_server", { "1" } }); c.Add(Unit(Int(UGC, "icon_size", "Icon size", "", "128", 16, 1024), "pixels")); // The icon's framing and light, from the one list of them (UgcIconParams); presets and overrides are set on the // UGC page's icon editor diff --git a/dDashboardServer/static/js/settings.js b/dDashboardServer/static/js/settings.js index 96683b933..1d324ad55 100644 --- a/dDashboardServer/static/js/settings.js +++ b/dDashboardServer/static/js/settings.js @@ -535,10 +535,12 @@ d.sections.forEach(function (sec) { if (sec.condition) state.controllers[sec.condition.file + '/' + sec.condition.key] = true; }); // Drop edits to settings that no longer exist Object.keys(state.pending).forEach(function (key) { if (!state.byKey[key]) delete state.pending[key]; }); - var fromHash = location.hash.slice(1); - if (!state.category) state.category = state.categories.some(function (c) { return c.id === fromHash; }) ? fromHash : state.categories[0].id; + // #, or #/ for one setting (the UGC page's "Open in Settings" links) + var fromHash = decodeURIComponent(location.hash.slice(1)), linked = !state.category && state.byKey[fromHash]; + if (!state.category) state.category = state.categories.some(function (c) { return c.id === fromHash; }) ? fromHash : linked ? linked.category : state.categories[0].id; document.getElementById('addFile').innerHTML = d.files.map(function (f) { return ''; }).join(''); render(); + if (linked) goTo(fromHash); }); } diff --git a/dDashboardServer/static/js/ugc-settings.js b/dDashboardServer/static/js/ugc-settings.js new file mode 100644 index 000000000..f0c7f6f1f --- /dev/null +++ b/dDashboardServer/static/js/ugc-settings.js @@ -0,0 +1,78 @@ +/** + * The UGC server's settings on the UGC page, beside what they change: any element with data-ugc-settings="
, + *
" shows those sections of the settings catalog (the same entries, values and save path as the Settings + * page, /api/settings, so a change in either place is the other's too; the servers reload at once). Settings only read + * at start say so. Each has a link to it on the Settings page. Only for those with the settings permission. + */ +(function () { + 'use strict'; + + if (!window.DASH || !DASH.can('settings')) return; + var boxes = document.querySelectorAll('[data-ugc-settings]'); + if (!boxes.length) return; + var settings = null; + + function key(s) { return s.file + '/' + s.name; } + function sourceText(s) { + if (s.source === 'web') return 'set here'; + if (s.source === 'file') return 'from ' + s.file; + if (s.source === 'env') return 'from the environment'; + return 'default'; + } + function input(s) { + var id = 'ugcset-' + key(s).replace(/[^a-z0-9_]/gi, '-'), value = s.value === undefined || s.value === null ? '' : String(s.value); + var attrs = ' id="' + id + '" data-ugc-setting="' + esc(key(s)) + '"' + (s.fileOnly ? ' disabled' : ''); + if (s.type === 'bool') { + return '
'; + } + if (s.type === 'choice') { + return ''; + } + var number = s.type === 'int' || s.type === 'float'; + return ''; + } + function row(s) { + return '
Open in Settings
' + + '
' + input(s) + (s.unit ? '' + esc(s.unit) + '' : '') + '
' + + '
' + (s.description ? esc(s.description) + ' ' : '') + 'Default ' + esc(s.default === '' ? '(empty)' : s.default) + '; ' + + '' + esc(sourceText(s)) + '.' + (s.restart ? ' ' + fmt.badge('restart', 'warning') : '') + '
'; + } + function render(box) { + var sections = box.dataset.ugcSettings.split(',').map(function (x) { return x.trim(); }); + var shown = settings.filter(function (s) { return sections.indexOf(s.section) !== -1 && !s.secret; }); + var open = box.querySelector('details[open]') ? ' open' : ''; + box.innerHTML = '
' + esc(box.dataset.title || 'Settings') + ' (' + shown.length + ')' + + '
The same settings as on the Settings page; a change saves at once and the servers reload. ' + + 'Those marked restart are only read when the UGC server starts.
' + shown.map(row).join('') + '
'; + } + function load() { + return api.get('/api/settings').then(function (d) { + if (!d.success) return; + settings = d.settings; + boxes.forEach(render); + }); + } + + document.addEventListener('change', function (e) { + var el = e.target.closest && e.target.closest('[data-ugc-setting]'); + if (!el || !settings) return; + var s = settings.find(function (x) { return key(x) === el.dataset.ugcSetting; }); + if (!s) return; + var value = s.type === 'bool' ? el.checked : el.value; + // A value the .ini file (or environment) sets would win over one set here unless this one is told to win + api.post('/api/settings', { file: s.file, name: s.name, value: value, webWins: s.source === 'file' || s.source === 'env' || s.webWins }).then(function (d) { + toast(d.success ? d.message : (d.error || 'Not saved'), d.success ? 'success' : 'danger'); + if (!d.success) return; + s.value = d.value; + s.source = 'web'; + document.querySelectorAll('[data-ugc-source="' + CSS.escape(key(s)) + '"]').forEach(function (x) { x.textContent = sourceText(s); }); + }); + }); + if (window.Live) Live.on('settings', load); + load(); +})(); diff --git a/dDashboardServer/templates/ugc.jinja2 b/dDashboardServer/templates/ugc.jinja2 index b737769ba..202529380 100644 --- a/dDashboardServer/templates/ugc.jinja2 +++ b/dDashboardServer/templates/ugc.jinja2 @@ -14,7 +14,10 @@
-
Asking the UGC server…
+
Asking the UGC server…
+
+
+
@@ -23,6 +26,7 @@

Each type of icon (player models, and each car or rocket build type in the client's data) has a preset over the default settings. Open one to set its camera, the model's turn, the light and the border on an example.

Loading…
+
@@ -41,6 +45,7 @@
+
@@ -205,6 +210,7 @@ } + {% endblock %} diff --git a/tests/dWebTests/UgcLookupTests.cpp b/tests/dWebTests/UgcLookupTests.cpp index 7e7119727..e0d519523 100644 --- a/tests/dWebTests/UgcLookupTests.cpp +++ b/tests/dWebTests/UgcLookupTests.cpp @@ -4,6 +4,7 @@ #include +#include "UgcAssemblies.h" #include "UgcLookup.h" #include "UgcLookupSql.h" @@ -103,14 +104,17 @@ namespace { Exec("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT);" "CREATE TABLE charinfo (id BIGINT PRIMARY KEY, account_id INTEGER, name TEXT);" "CREATE TABLE properties (id BIGINT PRIMARY KEY, owner_id BIGINT, name TEXT, zone_id INTEGER);" - "CREATE TABLE ugc (id INTEGER PRIMARY KEY, account_id INTEGER, character_id BIGINT, is_optimized INTEGER, filename TEXT, process_error TEXT DEFAULT '');" - "CREATE TABLE ugc_modular_build (ugc_id BIGINT PRIMARY KEY, character_id BIGINT, ldf_config TEXT, is_optimized INTEGER DEFAULT 0, process_error TEXT DEFAULT '');" + "CREATE TABLE ugc (id INTEGER PRIMARY KEY, account_id INTEGER, character_id BIGINT, is_optimized INTEGER, filename TEXT, process_error TEXT DEFAULT '', " + "process_attempts INTEGER DEFAULT 0, processed_at BIGINT DEFAULT 0, process_after BIGINT DEFAULT 0, bake_ao INTEGER DEFAULT 0, brick_count INTEGER DEFAULT 0, triangle_count INTEGER DEFAULT 0);" + "CREATE TABLE ugc_modular_build (ugc_id BIGINT PRIMARY KEY, character_id BIGINT, ldf_config TEXT, is_optimized INTEGER DEFAULT 0, process_error TEXT DEFAULT '', " + "process_attempts INTEGER DEFAULT 0, processed_at BIGINT DEFAULT 0);" "CREATE TABLE properties_contents (id BIGINT PRIMARY KEY, property_id BIGINT, ugc_id BIGINT, lot INTEGER, model_name TEXT DEFAULT '', model_description TEXT DEFAULT '');" "INSERT INTO accounts VALUES (1, 'builder'), (2, 'racer');" "INSERT INTO charinfo VALUES (10, 1, 'Bricky'), (20, 2, 'Speedy');" "INSERT INTO properties VALUES (500, 10, 'Castle Hill', 1150);" - "INSERT INTO ugc VALUES (1000, 1, 10, 1, 'tower.lxfml', ''), (1001, 1, 10, 0, 'boat.lxfml', '');" - "INSERT INTO ugc_modular_build VALUES (2000, 20, '1:8129+1:8130+1:9330', 1, '');" + "INSERT INTO ugc (id, account_id, character_id, is_optimized, filename, process_error, brick_count, triangle_count) VALUES " + "(1000, 1, 10, 1, 'tower.lxfml', '', 40, 900), (1001, 1, 10, 0, 'boat.lxfml', '', 0, 0);" + "INSERT INTO ugc_modular_build (ugc_id, character_id, ldf_config, is_optimized, process_error) VALUES (2000, 20, '1:8129+1:8130+1:9330', 1, '');" "INSERT INTO properties_contents VALUES (7000, 500, 1000, 14, 'Big tower', 'the tallest');"); } @@ -132,9 +136,125 @@ namespace { sqlite3_finalize(stmt); return ids; } + + // A list page's ids in order, and the count, as ListUgc runs them (all, or a search) + std::pair, int64_t> List(const IUgcLookup::UgcListQuery& query, bool modular) { + const bool searching = UgcLookupSql::Searching(query.search); + const auto where = (searching ? UgcLookupSql::Where(query.search, modular) : std::string("WHERE 1=1 ")) + UgcLookupSql::ListFilter(query, modular); + const auto pattern = "%" + query.search.text + "%"; + const auto run = [&](const std::string& sql, bool page) { + std::vector out; + sqlite3_stmt* stmt = nullptr; + EXPECT_EQ(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), SQLITE_OK) << sqlite3_errmsg(db) << "\n" << sql; + int i = 1; + if (searching) for (; i <= UgcLookupSql::TEXT_BINDS; i++) sqlite3_bind_text(stmt, i, pattern.c_str(), -1, SQLITE_TRANSIENT); + if (page) { + sqlite3_bind_int(stmt, i++, static_cast(query.limit)); + sqlite3_bind_int(stmt, i++, static_cast(query.offset)); + } + while (sqlite3_step(stmt) == SQLITE_ROW) out.push_back(sqlite3_column_int64(stmt, 0)); + sqlite3_finalize(stmt); + return out; + }; + const auto ids = run(UgcLookupSql::Select(modular) + where + UgcLookupSql::ListOrder(query, modular) + "LIMIT ? OFFSET ?;", true); + const auto count = run("SELECT COUNT(*) " + UgcLookupSql::From(modular) + where + ";", false); + return { ids, count.empty() ? -1 : count.front() }; + } }; } +TEST_F(UgcLookupSqlTests, ListsPagesWithCounts) { + IUgcLookup::UgcListQuery query; + auto [ids, total] = List(query, false); + EXPECT_EQ(ids, (std::vector{ 1001, 1000 })); // newest first + EXPECT_EQ(total, 2); + query.sort = IUgcLookup::eSort::BRICKS; + EXPECT_EQ(List(query, false).first, (std::vector{ 1000, 1001 })); + query.sort = IUgcLookup::eSort::NAME; // boat before tower + EXPECT_EQ(List(query, false).first, (std::vector{ 1001, 1000 })); + query.limit = 1; + query.offset = 1; + std::tie(ids, total) = List(query, false); + EXPECT_EQ(ids, (std::vector{ 1000 })); + EXPECT_EQ(total, 2); // the count is of every match, not the page + query = {}; + query.state = IUgc::eProcessState::DONE; + EXPECT_EQ(List(query, false), (std::pair, int64_t>{ { 1000 }, 1 })); + query.search = ParseQuery("boat"); // searched and in a state: none + EXPECT_EQ(List(query, false).second, 0); + query.state.reset(); + EXPECT_EQ(List(query, false), (std::pair, int64_t>{ { 1001 }, 1 })); + EXPECT_EQ(List({}, true), (std::pair, int64_t>{ { 2000 }, 1 })); +} + +TEST(UgcAssemblies, GroupsBuildsByTheirModules) { + using State = IUgc::eProcessState; + const auto build = [](LWOOBJID id, LWOOBJID owner, const std::string& ldf, State state) { + IUgcLookup::UgcEntry e; + e.kind = IUgcLookup::eUgcKind::MODULAR; + e.id = id; + e.characterId = owner; + e.detail = ldf; + e.state = state; + if (state == State::FAILED) e.error = "broken"; + return e; + }; + const std::map modules = { + { 4713, { 3, "Classic Rocket Nose Cone" } }, { 4714, { 3, "Classic Rocket Cockpit" } }, { 4715, { 3, "Classic Rocket Engine" } }, + { 8129, { 6, "Racing Car Chassis" } }, { 8130, { 6, "Racing Car Engine" } }, + }; + const std::vector builds = { + build(10, 1, "1:4713+1:4714+1:4715", State::DONE), + build(11, 2, "1:4715+1:4714+1:4713", State::PENDING), // the same modules written differently + build(12, 2, "1:8129+1:8130", State::FAILED), + build(13, 3, "", State::PENDING), // no modules: left out + build(14, 1, "1:4713+1:4714+1:4715", State::FAILED), + }; + auto list = UgcAssemblies::Group(builds, modules); + ASSERT_EQ(list.size(), 2u); + const auto rocket = std::find_if(list.begin(), list.end(), [](const auto& a) { return a.key == "4713-4714-4715"; }); + ASSERT_NE(rocket, list.end()); + EXPECT_EQ(rocket->builds, (std::vector{ 14, 11, 10 })); + EXPECT_EQ(rocket->owners.size(), 2u); + EXPECT_EQ(rocket->buildType, 3); + EXPECT_EQ(rocket->state, State::DONE); // one build's icon is made: the combination's is + EXPECT_EQ(rocket->iconBuild, 10); + EXPECT_TRUE(rocket->error.empty()); + const auto car = std::find_if(list.begin(), list.end(), [](const auto& a) { return a.key == "8129-8130"; }); + EXPECT_EQ(car->state, State::FAILED); + EXPECT_EQ(car->error, "broken"); + + UgcAssemblies::Filter filter; + filter.buildType = 6; + EXPECT_FALSE(UgcAssemblies::Matches(*rocket, filter, modules)); + EXPECT_TRUE(UgcAssemblies::Matches(*car, filter, modules)); + filter = {}; + filter.moduleText = "cockpit"; + EXPECT_TRUE(UgcAssemblies::Matches(*rocket, filter, modules)); + EXPECT_FALSE(UgcAssemblies::Matches(*car, filter, modules)); + filter = {}; + filter.moduleLot = 8130; + EXPECT_TRUE(UgcAssemblies::Matches(*car, filter, modules)); + filter = {}; + filter.builds = std::set{ 11 }; // an owner's build + EXPECT_TRUE(UgcAssemblies::Matches(*rocket, filter, modules)); + EXPECT_FALSE(UgcAssemblies::Matches(*car, filter, modules)); + filter = {}; + filter.state = State::FAILED; + EXPECT_FALSE(UgcAssemblies::Matches(*rocket, filter, modules)); + + UgcAssemblies::Sort(list, UgcAssemblies::eSort::REFERENCES, modules); + EXPECT_EQ(list.front().key, "4713-4714-4715"); + UgcAssemblies::Sort(list, UgcAssemblies::eSort::NEWEST, modules); + EXPECT_EQ(list.front().key, "4713-4714-4715"); // build 14 + UgcAssemblies::Sort(list, UgcAssemblies::eSort::NAME, modules); + EXPECT_EQ(list.front().key, "4713-4714-4715"); // Classic before Racing + UgcAssemblies::Sort(list, UgcAssemblies::eSort::OLDEST, modules); + EXPECT_EQ(list.front().key, "4713-4714-4715"); // build 10 + EXPECT_EQ(UgcAssemblies::ParseSort("uses"), UgcAssemblies::eSort::REFERENCES); + EXPECT_FALSE(UgcAssemblies::ParseSort("nonsense")); +} + TEST_F(UgcLookupSqlTests, FindsByIds) { EXPECT_EQ(Find("1000", false), (std::set{ 1000 })); // the blueprint id EXPECT_EQ(Find("7000", false), (std::set{ 1000 })); // the placed model's object id diff --git a/tests/smoke/__pycache__/dashboard_smoke_test.cpython-314.pyc b/tests/smoke/__pycache__/dashboard_smoke_test.cpython-314.pyc index ad11cc7a15e875cb0c895423da09c1b245dbff57..b248eeeaa42d19b79c52d5f50a8a289babe68216 100644 GIT binary patch delta 60 zcmeBP&a`Yf6Sp=WFBbz4%&OSAk$XBjW9H<=?AlU#$vKIoDXIG9`9;|!MX9O9`lac~ Px&`^gshiKT@9PEtF47dk delta 37 scmZ3soT+;`6Sp=WFBbz4WS`l-k$XBjqu=Dk?AnueGb?ZA;h5D80LlalJ^%m!