Merge pull request 'feat: in-game theme store — server catalog + verified downloads + install UI' (#154) from feat/theme-store into master
Build and Deploy / build-and-push (push) Successful in 6m2s
Test / test (push) Failing after 8m38s
Web E2E / web-e2e (push) Successful in 6m55s
Web WASM Rebuild / rebuild (push) Successful in 9m25s

This commit was merged in pull request #154.
This commit is contained in:
2026-07-07 20:23:52 +00:00
23 changed files with 1718 additions and 3 deletions
+2
View File
@@ -25,6 +25,7 @@ use crate::{
#[cfg(not(target_arch = "wasm32"))]
use crate::{
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
ThemeStorePlugin,
};
/// Groups all Ferrous Solitaire gameplay plugins.
@@ -126,6 +127,7 @@ impl Plugin for CoreGamePlugin {
.add_plugins(AudioPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(SyncSetupPlugin)
.add_plugins(ThemeStorePlugin)
.add_plugins(AnalyticsPlugin)
.add_plugins(LeaderboardPlugin);
}
+6
View File
@@ -134,6 +134,12 @@ pub struct ManualSyncRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct SyncConfigureRequestEvent;
/// Request to open the in-game theme-store modal. Fired by the
/// "Browse theme store" button in the Settings cosmetic section;
/// handled by `theme_store_plugin`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ThemeStoreOpenRequestEvent;
/// Request to disconnect from the current sync backend, clear stored
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
/// button in the Settings sync section.
+4
View File
@@ -54,6 +54,8 @@ pub mod sync_plugin;
pub mod sync_setup_plugin;
pub mod table_plugin;
pub mod theme;
#[cfg(not(target_arch = "wasm32"))]
pub mod theme_store_plugin;
pub mod time_attack_plugin;
pub mod touch_selection_plugin;
pub mod ui_focus;
@@ -176,6 +178,8 @@ pub use theme::{
ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
ThemeRegistryPlugin, set_theme,
};
#[cfg(not(target_arch = "wasm32"))]
pub use theme_store_plugin::{ThemeStorePlugin, ThemeStoreScreen};
pub use time_attack_plugin::{
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
};
@@ -407,6 +407,10 @@ pub(super) fn handle_settings_buttons(
| SettingsButton::DeleteAccount => {
// Handled by `handle_sync_buttons`.
}
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => {
// Handled by `handle_sync_buttons`.
}
SettingsButton::Done => {
screen.0 = false;
}
@@ -423,6 +427,9 @@ pub(super) fn handle_sync_buttons(
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
#[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter<
crate::events::ThemeStoreOpenRequestEvent,
>,
mut screen: ResMut<SettingsScreen>,
) {
for (interaction, button) in &interaction_query {
@@ -439,6 +446,13 @@ pub(super) fn handle_sync_buttons(
screen.0 = false;
configure_sync.write(SyncConfigureRequestEvent);
}
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => {
// Same shape as ConnectSync: close settings first so the
// store modal's own-scrim guard doesn't block.
screen.0 = false;
open_theme_store.write(crate::events::ThemeStoreOpenRequestEvent);
}
SettingsButton::DisconnectSync => {
logout_sync.write(SyncLogoutRequestEvent);
}
@@ -250,6 +250,9 @@ enum SettingsButton {
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
#[cfg(not(target_arch = "wasm32"))]
ScanThemes,
/// Open the in-game theme-store modal (closes Settings first).
#[cfg(not(target_arch = "wasm32"))]
OpenThemeStore,
SyncNow,
/// Open the sync-server Connect modal (shown when backend = Local).
ConnectSync,
@@ -313,6 +316,8 @@ impl SettingsButton {
SettingsButton::SelectTheme(_) => 85,
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::ScanThemes => 86,
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => 87,
// Sync section
SettingsButton::SyncNow => 90,
SettingsButton::ConnectSync => 91,
@@ -371,6 +376,7 @@ impl Plugin for SettingsPlugin {
.add_message::<SyncLogoutRequestEvent>()
.add_message::<DeleteAccountRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
.add_message::<crate::events::ThemeStoreOpenRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<MouseWheel>()
.add_message::<TouchInput>()
@@ -239,6 +239,8 @@ pub(super) fn spawn_settings_panel(
}
#[cfg(not(target_arch = "wasm32"))]
import_themes_row(body, font_res);
#[cfg(not(target_arch = "wasm32"))]
theme_store_row(body, font_res);
// --- Privacy (only shown when a Matomo URL is configured) ---
if settings.matomo_url.is_some() {
@@ -1193,6 +1195,31 @@ pub(super) fn import_themes_row(
});
}
/// "Theme store" row: one pill button that closes Settings and opens
/// the in-game theme-store modal (`theme_store_plugin`). Sits directly
/// under the Import row so both install paths live together.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn theme_store_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
parent
.spawn((
FocusRow,
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
..default()
},
))
.with_children(|row| {
pill_button(
row,
SettingsButton::OpenThemeStore,
"Browse theme store",
"Download themes from your sync server without leaving the game.",
font_res,
);
});
}
pub(super) fn icon_button(
parent: &mut ChildSpawnerCommands,
label: &str,
+657
View File
@@ -0,0 +1,657 @@
//! In-game theme store: browse the sync server's theme catalog and
//! install themes without leaving the app.
//!
//! Settings → Cosmetic → "Browse theme store" fires
//! [`crate::events::ThemeStoreOpenRequestEvent`]; this plugin opens a
//! modal listing the catalog (fetched on [`AsyncComputeTaskPool`]) and
//! handles per-theme Install buttons. An install downloads the archive
//! (SHA-256-verified by [`ThemeStoreClient`]), writes it atomically
//! into `user_theme_dir()`, and runs it through the same hardened
//! [`import_theme`] pipeline as a manually dropped zip — the store
//! never bypasses the importer's validation.
//!
//! Native-only: downloads need `reqwest`/Tokio and the importer is
//! filesystem-based; the plugin is gated out on wasm32 alongside
//! `SyncPlugin`.
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use thiserror::Error;
use solitaire_data::theme_store_client::{ThemeStoreClient, ThemeStoreError};
use solitaire_data::{Settings, SyncBackend};
use solitaire_sync::ThemeCatalogEntry;
use crate::assets::user_theme_dir;
use crate::events::{InfoToastEvent, ThemeStoreOpenRequestEvent, WarningToastEvent};
use crate::font_plugin::FontResource;
use crate::resources::TokioRuntimeResource;
use crate::settings_plugin::{SettingsPanel, SettingsResource};
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
Z_MODAL_PANEL,
};
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// Everything that can go wrong between clicking Install and the theme
/// appearing in the picker.
#[derive(Debug, Error)]
enum ThemeInstallError {
/// Catalog fetch or archive download failed (network, HTTP status,
/// size/checksum verification).
#[error(transparent)]
Download(#[from] ThemeStoreError),
/// The verified archive could not be written into the themes dir.
#[error("could not save archive: {0}")]
Io(#[from] std::io::Error),
/// The downloaded archive failed importer validation.
#[error(transparent)]
Import(#[from] ImportError),
}
// ---------------------------------------------------------------------------
// Components & resources
// ---------------------------------------------------------------------------
/// Marker on the theme-store modal's scrim root.
#[derive(Component)]
pub struct ThemeStoreScreen;
/// Marker on the modal's Close button.
#[derive(Component)]
struct ThemeStoreCloseButton;
/// Per-row Install button carrying its catalog entry.
#[derive(Component)]
struct ThemeStoreInstallButton(ThemeCatalogEntry);
/// Catalog fetch lifecycle. The modal renders directly from this state
/// and is rebuilt (despawn + respawn, mirroring the leaderboard panel)
/// whenever it changes.
#[derive(Resource, Default)]
enum CatalogState {
/// No fetch has run since the modal was last opened.
#[default]
Idle,
Loading,
Loaded(Vec<ThemeCatalogEntry>),
Error(String),
}
/// In-flight catalog fetch, polled without blocking.
#[derive(Resource, Default)]
struct CatalogTask(Option<Task<Result<Vec<ThemeCatalogEntry>, ThemeStoreError>>>);
/// Result of one download + import: `Ok(Some(id))` on a fresh install,
/// `Ok(None)` when the theme was already installed (id collision) —
/// informational, not an error.
type InstallResult = Result<Option<String>, ThemeInstallError>;
/// In-flight download + import. `String` is the theme id, for toasts.
#[derive(Resource, Default)]
struct InstallTask(Option<(String, Task<InstallResult>)>);
/// Server base URL captured when the modal opens. Catalog
/// `download_url`s are server-relative, so installs join them onto
/// this. `None` while the modal has never been opened.
#[derive(Resource, Default)]
struct StoreBaseUrl(Option<String>);
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Bevy plugin for the in-game theme store. See the module docs.
pub struct ThemeStorePlugin;
impl Plugin for ThemeStorePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<CatalogState>()
.init_resource::<CatalogTask>()
.init_resource::<InstallTask>()
.init_resource::<StoreBaseUrl>()
.add_message::<ThemeStoreOpenRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<WarningToastEvent>()
.add_systems(
Update,
(
handle_open_request,
poll_catalog_task,
handle_install_buttons,
poll_install_task,
handle_close_button,
)
.chain(),
);
}
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Resolves the store's server base URL from the player's sync
/// backend. The store rides the same self-hosted server as sync;
/// without one configured there is nothing to browse.
fn store_base_url(settings: &Settings) -> Option<String> {
match &settings.sync_backend {
SyncBackend::SolitaireServer { url, .. } => Some(url.clone()),
_ => None,
}
}
/// Opens the store modal and kicks off the catalog fetch.
///
/// Mirrors `open_sync_setup_modal`: the Settings button closes the
/// settings panel in the same frame it fires the event, and despawns
/// are deferred, so the settings scrim is excluded from the
/// another-modal-is-open guard.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn handle_open_request(
mut events: MessageReader<ThemeStoreOpenRequestEvent>,
existing: Query<(), With<ThemeStoreScreen>>,
other_modal_scrims: Query<
(),
(
With<ModalScrim>,
Without<ThemeStoreScreen>,
Without<SettingsPanel>,
),
>,
settings: Option<Res<SettingsResource>>,
rt: Option<Res<TokioRuntimeResource>>,
mut catalog_state: ResMut<CatalogState>,
mut catalog_task: ResMut<CatalogTask>,
mut store_base: ResMut<StoreBaseUrl>,
mut warning_toast: MessageWriter<WarningToastEvent>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
if events.is_empty() {
return;
}
events.clear();
if !existing.is_empty() || !other_modal_scrims.is_empty() {
return;
}
let Some(base_url) = settings.as_deref().and_then(|s| store_base_url(&s.0)) else {
warning_toast.write(WarningToastEvent(
"Theme store needs a server — connect one in Settings → Sync.".to_string(),
));
return;
};
let Some(rt) = rt else {
warning_toast.write(WarningToastEvent(
"Theme store unavailable — no network runtime.".to_string(),
));
return;
};
store_base.0 = Some(base_url.clone());
*catalog_state = CatalogState::Loading;
let rt = rt.0.clone();
catalog_task.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
}));
spawn_store_modal(&mut commands, &catalog_state, None, font_res.as_deref());
}
/// Polls the catalog fetch; on completion updates [`CatalogState`] and
/// rebuilds the modal if it is still open.
fn poll_catalog_task(
mut catalog_task: ResMut<CatalogTask>,
mut catalog_state: ResMut<CatalogState>,
screens: Query<Entity, With<ThemeStoreScreen>>,
registry: Option<Res<ThemeRegistry>>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
let Some(task) = catalog_task.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
catalog_task.0 = None;
*catalog_state = match result {
Ok(entries) => CatalogState::Loaded(entries),
Err(e) => {
warn!("theme store: catalog fetch failed: {e}");
CatalogState::Error(e.to_string())
}
};
rebuild_open_modal(
&screens,
&mut commands,
&catalog_state,
registry.as_deref(),
font_res.as_deref(),
);
}
/// Starts a download + import task when an Install button is pressed.
/// One install at a time; repeat clicks while busy are ignored.
fn handle_install_buttons(
interactions: Query<(&Interaction, &ThemeStoreInstallButton), Changed<Interaction>>,
rt: Option<Res<TokioRuntimeResource>>,
store_base: Res<StoreBaseUrl>,
mut install_task: ResMut<InstallTask>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
for (interaction, button) in &interactions {
if *interaction != Interaction::Pressed || install_task.0.is_some() {
continue;
}
let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.clone()) else {
continue;
};
let entry = button.0.clone();
info_toast.write(InfoToastEvent(format!("Downloading '{}'…", entry.name)));
let rt = rt.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
let bytes = rt
.block_on(async { ThemeStoreClient::new(base_url).download_theme(&entry).await })?;
install_verified_archive(&entry.id, &bytes)
});
install_task.0 = Some((button.0.id.clone(), task));
}
}
/// Downloads land here (already SHA-256-verified): write the archive
/// atomically into the user themes dir (`.tmp` + rename, §2.5), then
/// run the standard importer. Returns `Ok(None)` when the theme was
/// already installed.
fn install_verified_archive(id: &str, bytes: &[u8]) -> Result<Option<String>, ThemeInstallError> {
let themes_dir = user_theme_dir();
std::fs::create_dir_all(&themes_dir)?;
let final_path = themes_dir.join(format!("{id}.zip"));
let tmp_path = themes_dir.join(format!("{id}.zip.tmp"));
std::fs::write(&tmp_path, bytes)?;
std::fs::rename(&tmp_path, &final_path)?;
match import_theme(&final_path) {
Ok(theme_id) => Ok(Some(theme_id.as_str().to_string())),
Err(ImportError::IdCollision { .. }) => Ok(None),
Err(e) => {
// Don't leave a zip that fails validation lying around for
// the folder-scan flow to re-try on every scan.
let _ = std::fs::remove_file(&final_path);
Err(e.into())
}
}
}
/// Polls the install task; on completion refreshes the theme registry,
/// toasts the outcome, and rebuilds the modal so the row flips to
/// "Installed".
#[allow(clippy::too_many_arguments)]
fn poll_install_task(
mut install_task: ResMut<InstallTask>,
mut registry: Option<ResMut<ThemeRegistry>>,
catalog_state: Res<CatalogState>,
screens: Query<Entity, With<ThemeStoreScreen>>,
mut info_toast: MessageWriter<InfoToastEvent>,
mut warning_toast: MessageWriter<WarningToastEvent>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
let Some((id, task)) = install_task.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
let id = id.clone();
install_task.0 = None;
match result {
Ok(Some(theme_id)) => {
if let Some(registry) = registry.as_deref_mut() {
refresh_registry(registry, &user_theme_dir());
}
info_toast.write(InfoToastEvent(format!(
"Theme '{theme_id}' installed — pick it in Settings → Cosmetic."
)));
}
Ok(None) => {
info_toast.write(InfoToastEvent(format!("Theme '{id}' already installed.")));
}
Err(e) => {
warn!("theme store: install of '{id}' failed: {e}");
warning_toast.write(WarningToastEvent(format!("Install failed: {e}")));
}
}
rebuild_open_modal(
&screens,
&mut commands,
&catalog_state,
registry.as_deref(),
font_res.as_deref(),
);
}
/// Despawns the store modal when Close is pressed.
fn handle_close_button(
interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>,
screens: Query<Entity, With<ThemeStoreScreen>>,
mut commands: Commands,
) {
for interaction in &interactions {
if *interaction != Interaction::Pressed {
continue;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
}
// ---------------------------------------------------------------------------
// Modal rendering
// ---------------------------------------------------------------------------
/// Despawn + respawn the modal for every open screen (Bevy despawns
/// are deferred, so this is safe within one frame).
fn rebuild_open_modal(
screens: &Query<Entity, With<ThemeStoreScreen>>,
commands: &mut Commands,
catalog_state: &CatalogState,
registry: Option<&ThemeRegistry>,
font_res: Option<&FontResource>,
) {
for entity in screens {
commands.entity(entity).despawn();
spawn_store_modal(commands, catalog_state, registry, font_res);
}
}
/// Spawns the store modal for the current catalog state.
fn spawn_store_modal(
commands: &mut Commands,
catalog_state: &CatalogState,
registry: Option<&ThemeRegistry>,
font_res: Option<&FontResource>,
) {
let body_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_BODY,
..default()
};
let caption_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
let scrim = spawn_modal(commands, ThemeStoreScreen, Z_MODAL_PANEL + 1, |card| {
spawn_modal_header(card, "Theme Store", font_res);
match catalog_state {
CatalogState::Idle | CatalogState::Loading => {
card.spawn((
Text::new("Loading catalog…"),
body_font.clone(),
TextColor(TEXT_SECONDARY),
));
}
CatalogState::Error(msg) => {
card.spawn((
Text::new(format!("Could not load the catalog: {msg}")),
body_font.clone(),
TextColor(TEXT_SECONDARY),
));
}
CatalogState::Loaded(entries) if entries.is_empty() => {
card.spawn((
Text::new("The server has no themes yet."),
body_font.clone(),
TextColor(TEXT_SECONDARY),
));
}
CatalogState::Loaded(entries) => {
for entry in entries {
let installed =
registry.is_some_and(|registry| registry.find(&entry.id).is_some());
spawn_store_row(card, entry, installed, &body_font, &caption_font, font_res);
}
}
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
ThemeStoreCloseButton,
"Close",
None,
ButtonVariant::Primary,
font_res,
);
});
});
commands.entity(scrim).insert(ScrimDismissible);
}
/// One catalog row: name + author/size caption on the left, Install
/// button (or "Installed" caption) on the right.
fn spawn_store_row(
parent: &mut ChildSpawnerCommands,
entry: &ThemeCatalogEntry,
installed: bool,
body_font: &TextFont,
caption_font: &TextFont,
font_res: Option<&FontResource>,
) {
parent
.spawn((
Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
padding: UiRect::vertical(VAL_SPACE_2),
border: UiRect::bottom(Val::Px(1.0)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|row| {
row.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
..default()
})
.with_children(|col| {
col.spawn((
Text::new(entry.name.clone()),
body_font.clone(),
TextColor(TEXT_PRIMARY),
));
col.spawn((
Text::new(format!(
"by {}{} KB",
entry.author,
entry.size_bytes.div_ceil(1024)
)),
caption_font.clone(),
TextColor(TEXT_SECONDARY),
));
});
if installed {
row.spawn((
Text::new("Installed"),
caption_font.clone(),
TextColor(TEXT_SECONDARY),
));
} else {
spawn_modal_button(
row,
ThemeStoreInstallButton(entry.clone()),
"Install",
None,
ButtonVariant::Secondary,
font_res,
);
}
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::message::Messages;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(ThemeStorePlugin);
app.update();
app
}
fn settings_with_server() -> SettingsResource {
SettingsResource(Settings {
sync_backend: SyncBackend::SolitaireServer {
url: "http://127.0.0.1:1".to_string(),
username: "tester".to_string(),
avatar_url: None,
},
..Settings::default()
})
}
fn screen_count(app: &mut App) -> usize {
app.world_mut()
.query::<&ThemeStoreScreen>()
.iter(app.world())
.count()
}
#[test]
fn store_base_url_requires_server_backend() {
assert_eq!(store_base_url(&Settings::default()), None);
assert_eq!(
store_base_url(&settings_with_server().0).as_deref(),
Some("http://127.0.0.1:1")
);
}
#[test]
fn open_request_without_server_warns_and_spawns_nothing() {
let mut app = headless_app();
// Default settings → SyncBackend::Local → no store.
app.insert_resource(SettingsResource(Settings::default()));
app.world_mut()
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
.write(ThemeStoreOpenRequestEvent);
app.update();
assert_eq!(screen_count(&mut app), 0);
assert!(
!app.world()
.resource::<Messages<WarningToastEvent>>()
.is_empty(),
"player must be told why the store did not open"
);
}
#[test]
fn open_request_without_runtime_warns_and_spawns_nothing() {
let mut app = headless_app();
app.insert_resource(settings_with_server());
// No TokioRuntimeResource inserted.
app.world_mut()
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
.write(ThemeStoreOpenRequestEvent);
app.update();
assert_eq!(screen_count(&mut app), 0);
assert!(
!app.world()
.resource::<Messages<WarningToastEvent>>()
.is_empty()
);
}
#[test]
fn open_request_with_server_spawns_modal_in_loading_state() {
let mut app = headless_app();
app.insert_resource(settings_with_server());
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
app.world_mut()
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
.write(ThemeStoreOpenRequestEvent);
app.update();
assert_eq!(screen_count(&mut app), 1);
assert!(matches!(
*app.world().resource::<CatalogState>(),
CatalogState::Loading | CatalogState::Error(_)
));
assert_eq!(
app.world().resource::<StoreBaseUrl>().0.as_deref(),
Some("http://127.0.0.1:1")
);
}
#[test]
fn second_open_request_does_not_stack_a_second_modal() {
let mut app = headless_app();
app.insert_resource(settings_with_server());
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
for _ in 0..2 {
app.world_mut()
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
.write(ThemeStoreOpenRequestEvent);
app.update();
}
assert_eq!(screen_count(&mut app), 1);
}
#[test]
fn failed_catalog_fetch_rebuilds_modal_with_error_state() {
let mut app = headless_app();
app.insert_resource(settings_with_server());
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
app.world_mut()
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
.write(ThemeStoreOpenRequestEvent);
app.update();
// 127.0.0.1:1 refuses connections; the fetch task must resolve
// to an error within a bounded number of frames and the modal
// must survive the rebuild.
for _ in 0..200 {
app.update();
if matches!(
*app.world().resource::<CatalogState>(),
CatalogState::Error(_)
) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(matches!(
*app.world().resource::<CatalogState>(),
CatalogState::Error(_)
));
assert_eq!(screen_count(&mut app), 1);
}
}