Compare commits
4 Commits
59316de1e9
...
3ef4ecb747
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ef4ecb747 | |||
| 4b9d008be2 | |||
| 74482252d1 | |||
| 6e7705b256 |
Generated
+5
@@ -7533,16 +7533,21 @@ name = "solitaire_data"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"jsonwebtoken",
|
||||||
"keyring-core",
|
"keyring-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"solitaire_core",
|
"solitaire_core",
|
||||||
|
"solitaire_server",
|
||||||
"solitaire_sync",
|
"solitaire_sync",
|
||||||
|
"sqlx",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -39,6 +39,21 @@ fn main() {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let sync_provider = provider_for_backend(&settings.sync_backend);
|
let sync_provider = provider_for_backend(&settings.sync_backend);
|
||||||
|
|
||||||
|
// Restore the previous window geometry if the player has one saved.
|
||||||
|
// Otherwise open at the platform default (1280×800, centred on the
|
||||||
|
// primary monitor). The window_geometry field is None on first run
|
||||||
|
// and after upgrading from a build that didn't persist geometry.
|
||||||
|
let (window_resolution, window_position) = match settings.window_geometry {
|
||||||
|
Some(geom) => (
|
||||||
|
(geom.width, geom.height).into(),
|
||||||
|
WindowPosition::At(IVec2::new(geom.x, geom.y)),
|
||||||
|
),
|
||||||
|
None => (
|
||||||
|
(1280u32, 800u32).into(),
|
||||||
|
WindowPosition::Centered(MonitorSelection::Primary),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
App::new()
|
App::new()
|
||||||
.add_plugins(
|
.add_plugins(
|
||||||
DefaultPlugins
|
DefaultPlugins
|
||||||
@@ -48,8 +63,8 @@ fn main() {
|
|||||||
// X11/Wayland WM_CLASS so taskbar managers group
|
// X11/Wayland WM_CLASS so taskbar managers group
|
||||||
// multiple windows of this app correctly.
|
// multiple windows of this app correctly.
|
||||||
name: Some("solitaire-quest".into()),
|
name: Some("solitaire-quest".into()),
|
||||||
resolution: (1280u32, 800u32).into(),
|
resolution: window_resolution,
|
||||||
position: WindowPosition::Centered(MonitorSelection::Primary),
|
position: window_position,
|
||||||
// AutoNoVsync prefers Mailbox (triple-buffered) and
|
// AutoNoVsync prefers Mailbox (triple-buffered) and
|
||||||
// falls back to Immediate, eliminating the vsync stall
|
// falls back to Immediate, eliminating the vsync stall
|
||||||
// that AutoVsync produces during continuous window
|
// that AutoVsync produces during continuous window
|
||||||
@@ -134,8 +149,7 @@ fn install_crash_log_hook() {
|
|||||||
// parseable and avoids pulling in chrono just for this.
|
// parseable and avoids pulling in chrono just for this.
|
||||||
let secs = SystemTime::now()
|
let secs = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs())
|
.map_or(0, |d| d.as_secs());
|
||||||
.unwrap_or(0);
|
|
||||||
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
|
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
|
||||||
}
|
}
|
||||||
default_hook(info);
|
default_hook(info);
|
||||||
|
|||||||
@@ -16,3 +16,12 @@ dirs = { workspace = true }
|
|||||||
keyring-core = { workspace = true }
|
keyring-core = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
solitaire_server = { path = "../solitaire_server" }
|
||||||
|
solitaire_sync = { workspace = true }
|
||||||
|
axum = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
jsonwebtoken = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ const SERVICE: &str = "solitaire_quest_server";
|
|||||||
fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
|
fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
|
||||||
let msg = err.to_string();
|
let msg = err.to_string();
|
||||||
match err {
|
match err {
|
||||||
keyring_core::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
|
keyring_core::Error::NoStorageAccess(_) | keyring_core::Error::NoDefaultStore => {
|
||||||
keyring_core::Error::NoDefaultStore => TokenError::KeychainUnavailable(msg),
|
TokenError::KeychainUnavailable(msg)
|
||||||
|
}
|
||||||
keyring_core::Error::NoEntry => TokenError::NotFound(username.to_string()),
|
keyring_core::Error::NoEntry => TokenError::NotFound(username.to_string()),
|
||||||
_ => TokenError::Keyring(msg),
|
_ => TokenError::Keyring(msg),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||||
Theme,
|
Theme, WindowGeometry,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod auth_tokens;
|
pub mod auth_tokens;
|
||||||
|
|||||||
@@ -61,6 +61,25 @@ pub enum SyncBackend {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persisted window size (in logical pixels) and screen position
|
||||||
|
/// (top-left corner, in physical pixels) — restored on next launch.
|
||||||
|
///
|
||||||
|
/// Stored inside [`Settings::window_geometry`]. `None` on `Settings`
|
||||||
|
/// means "use platform defaults"; a populated value is written every
|
||||||
|
/// time the player resizes or moves the window so the next launch
|
||||||
|
/// reopens at the same geometry.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct WindowGeometry {
|
||||||
|
/// Logical width of the window in pixels.
|
||||||
|
pub width: u32,
|
||||||
|
/// Logical height of the window in pixels.
|
||||||
|
pub height: u32,
|
||||||
|
/// X coordinate of the window's top-left corner, in physical pixels.
|
||||||
|
pub x: i32,
|
||||||
|
/// Y coordinate of the window's top-left corner, in physical pixels.
|
||||||
|
pub y: i32,
|
||||||
|
}
|
||||||
|
|
||||||
/// Persistent user settings.
|
/// Persistent user settings.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
@@ -98,6 +117,13 @@ pub struct Settings {
|
|||||||
/// solely on colour.
|
/// solely on colour.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub color_blind_mode: bool,
|
pub color_blind_mode: bool,
|
||||||
|
/// Window size and screen position to restore on next launch. `None`
|
||||||
|
/// means "use platform defaults" — set on first run, then populated
|
||||||
|
/// as the player resizes / moves the window. Older `settings.json`
|
||||||
|
/// files written before this field existed deserialize cleanly to
|
||||||
|
/// `None` thanks to `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub window_geometry: Option<WindowGeometry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_draw_mode() -> DrawMode {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -125,6 +151,7 @@ impl Default for Settings {
|
|||||||
selected_background: 0,
|
selected_background: 0,
|
||||||
first_run_complete: false,
|
first_run_complete: false,
|
||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
|
window_geometry: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,6 +303,7 @@ mod tests {
|
|||||||
selected_background: 0,
|
selected_background: 0,
|
||||||
first_run_complete: true,
|
first_run_complete: true,
|
||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
|
window_geometry: None,
|
||||||
};
|
};
|
||||||
save_settings_to(&path, &s).expect("save");
|
save_settings_to(&path, &s).expect("save");
|
||||||
let loaded = load_settings_from(&path);
|
let loaded = load_settings_from(&path);
|
||||||
@@ -406,4 +434,62 @@ mod tests {
|
|||||||
assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip");
|
assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip");
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// window_geometry — persisted window size/position
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_window_geometry_default_is_none() {
|
||||||
|
assert!(
|
||||||
|
Settings::default().window_geometry.is_none(),
|
||||||
|
"default window_geometry must be None so first launch uses platform defaults"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_with_window_geometry_round_trip() {
|
||||||
|
let path = tmp_path("window_geometry_round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let geom = WindowGeometry {
|
||||||
|
width: 1440,
|
||||||
|
height: 900,
|
||||||
|
x: 120,
|
||||||
|
y: 80,
|
||||||
|
};
|
||||||
|
let s = Settings {
|
||||||
|
window_geometry: Some(geom),
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save_settings_to(&path, &s).expect("save");
|
||||||
|
let loaded = load_settings_from(&path);
|
||||||
|
assert_eq!(
|
||||||
|
loaded.window_geometry,
|
||||||
|
Some(geom),
|
||||||
|
"window_geometry must survive serde round-trip"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_settings_without_window_geometry_deserializes_to_none() {
|
||||||
|
// A settings.json written by an older version of the game will be
|
||||||
|
// missing this field entirely. `#[serde(default)]` on the field
|
||||||
|
// must yield `None` rather than failing the whole deserialise.
|
||||||
|
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||||
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
s.window_geometry.is_none(),
|
||||||
|
"legacy settings.json missing window_geometry must deserialize to None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_geometry_explicit_null_deserializes_to_none() {
|
||||||
|
// An explicit `"window_geometry": null` is also valid input that
|
||||||
|
// must yield None — keeps tooling that hand-edits the file safe.
|
||||||
|
let json = br#"{ "window_geometry": null }"#;
|
||||||
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
|
assert!(s.window_geometry.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,8 +138,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
|
|||||||
if path
|
if path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.map(|n| n.ends_with(".json.tmp"))
|
.is_some_and(|n| n.ends_with(".json.tmp"))
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
{
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
//! Client-side sync round-trip integration tests for `solitaire_data`.
|
||||||
|
//!
|
||||||
|
//! These tests spin up the actual `solitaire_server` Axum app in-process on a
|
||||||
|
//! random TCP port (allocated by the OS) and drive the production
|
||||||
|
//! [`SolitaireServerClient`] HTTP client against it via `reqwest`. They are
|
||||||
|
//! the client-side counterpart to `solitaire_server/tests/server_tests.rs`,
|
||||||
|
//! which exercises the server endpoints directly via `tower::ServiceExt`.
|
||||||
|
//!
|
||||||
|
//! # Keyring
|
||||||
|
//!
|
||||||
|
//! [`SolitaireServerClient`] reads tokens from the OS keyring via
|
||||||
|
//! `keyring_core`. Headless test environments may not have a real secret
|
||||||
|
//! service, so we install the in-memory `keyring_core::mock::Store` exactly
|
||||||
|
//! once via [`std::sync::Once`]. Every test uses a unique username so the
|
||||||
|
//! shared mock store does not leak credentials between tests.
|
||||||
|
//!
|
||||||
|
//! # Server harness
|
||||||
|
//!
|
||||||
|
//! Each test calls [`spawn_test_server`] which:
|
||||||
|
//! 1. Binds a `tokio::net::TcpListener` on `127.0.0.1:0` (OS picks a port).
|
||||||
|
//! 2. Builds the in-memory SQLite pool, runs migrations.
|
||||||
|
//! 3. Builds the test router via `solitaire_server::build_test_router`
|
||||||
|
//! (rate limiting OFF, fixed test JWT secret).
|
||||||
|
//! 4. Spawns the server in a background `tokio::spawn` task.
|
||||||
|
//! 5. Returns the server URL (`http://127.0.0.1:{port}`).
|
||||||
|
//!
|
||||||
|
//! # Test JWT secret
|
||||||
|
//!
|
||||||
|
//! Must match the constant inside `build_test_router` so we can craft
|
||||||
|
//! expired-on-purpose tokens for the JWT-refresh test.
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
|
use solitaire_data::{
|
||||||
|
delete_tokens, store_tokens, SolitaireServerClient, SyncError, SyncProvider,
|
||||||
|
};
|
||||||
|
use solitaire_sync::{PlayerProgress, StatsSnapshot, SyncPayload};
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use std::sync::Once;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// JWT secret used by `solitaire_server::build_test_router`. Must stay in
|
||||||
|
/// sync with the constant inside that function.
|
||||||
|
const TEST_SECRET: &str = "test_secret_32_chars_minimum_ok!";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock keyring setup (process-wide; install once)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static MOCK_KEYRING_INIT: Once = Once::new();
|
||||||
|
|
||||||
|
/// Install the `keyring_core` mock in-memory store as the process-wide
|
||||||
|
/// default. Safe to call from any test — only the first call has effect.
|
||||||
|
fn ensure_mock_keyring() {
|
||||||
|
MOCK_KEYRING_INIT.call_once(|| {
|
||||||
|
let store = keyring_core::mock::Store::new()
|
||||||
|
.expect("failed to construct mock keyring store");
|
||||||
|
keyring_core::set_default_store(store);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Server harness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Build a fresh in-memory SQLite pool with all migrations applied.
|
||||||
|
///
|
||||||
|
/// `max_connections(1)` is required: each connection to `sqlite::memory:` is
|
||||||
|
/// a *separate* database, so a larger pool sees an empty schema on the second
|
||||||
|
/// borrow. Mirrors the pattern in `solitaire_server/tests/server_tests.rs`.
|
||||||
|
async fn fresh_pool() -> SqlitePool {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("failed to connect to in-memory SQLite database");
|
||||||
|
sqlx::migrate!("../solitaire_server/migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("failed to run database migrations");
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the test server on a random localhost port and return its base URL.
|
||||||
|
///
|
||||||
|
/// The server runs until the test process exits — there is no explicit
|
||||||
|
/// shutdown. This is acceptable for `cargo test` where each test binary is a
|
||||||
|
/// separate process.
|
||||||
|
async fn spawn_test_server() -> String {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("failed to bind test listener");
|
||||||
|
let addr = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("listener has no local addr");
|
||||||
|
|
||||||
|
let app = solitaire_server::build_test_router(fresh_pool().await);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Errors here cannot fail the test directly because we are inside a
|
||||||
|
// `tokio::spawn`; we just log so a rogue panic doesn't go unnoticed.
|
||||||
|
if let Err(e) = axum::serve(listener, app).await {
|
||||||
|
eprintln!("test server crashed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
format!("http://{addr}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Register a fresh user against `base_url` and return the access + refresh
|
||||||
|
/// tokens straight from the response body. Bypasses the keyring entirely so
|
||||||
|
/// the caller can store the tokens under whatever username they want.
|
||||||
|
async fn register_user_raw(
|
||||||
|
base_url: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> (String, String) {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{base_url}/api/auth/register"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("register request failed");
|
||||||
|
assert!(
|
||||||
|
resp.status().is_success(),
|
||||||
|
"register must succeed (got {})",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
let body: serde_json::Value = resp.json().await.expect("register body must be JSON");
|
||||||
|
let access = body["access_token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("access_token missing")
|
||||||
|
.to_string();
|
||||||
|
let refresh = body["refresh_token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("refresh_token missing")
|
||||||
|
.to_string();
|
||||||
|
(access, refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a JWT's `sub` claim without validating expiry (so test crafted
|
||||||
|
/// tokens still parse). Returns the user UUID as a `String`.
|
||||||
|
fn decode_sub(token: &str) -> String {
|
||||||
|
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String,
|
||||||
|
}
|
||||||
|
let mut v = Validation::default();
|
||||||
|
v.validate_exp = false;
|
||||||
|
let data = decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(TEST_SECRET.as_bytes()),
|
||||||
|
&v,
|
||||||
|
)
|
||||||
|
.expect("failed to decode JWT");
|
||||||
|
data.claims.sub
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produce a `SyncPayload` with `user_id` (parsed from the JWT sub) and a
|
||||||
|
/// non-default `games_played` so we can verify round-trips.
|
||||||
|
fn make_payload(user_id_str: &str, games_played: u32) -> SyncPayload {
|
||||||
|
SyncPayload {
|
||||||
|
user_id: Uuid::parse_str(user_id_str)
|
||||||
|
.expect("user_id_str from JWT sub must be a valid UUID"),
|
||||||
|
stats: StatsSnapshot {
|
||||||
|
games_played,
|
||||||
|
games_won: 7,
|
||||||
|
best_single_score: 1234,
|
||||||
|
..StatsSnapshot::default()
|
||||||
|
},
|
||||||
|
achievements: vec![],
|
||||||
|
progress: PlayerProgress::default(),
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// **Full happy-path round-trip.**
|
||||||
|
///
|
||||||
|
/// 1. Spin up server.
|
||||||
|
/// 2. Register a user via raw HTTP.
|
||||||
|
/// 3. Persist the tokens in the (mock) keyring under the same username.
|
||||||
|
/// 4. Construct a `SolitaireServerClient` and call `push()` with a known
|
||||||
|
/// payload, then call `pull()` on the *same* client.
|
||||||
|
/// 5. Assert the server-merged stats reflect the values we pushed.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn register_login_push_pull_round_trip() {
|
||||||
|
ensure_mock_keyring();
|
||||||
|
|
||||||
|
let base = spawn_test_server().await;
|
||||||
|
let username = "rt_alice";
|
||||||
|
|
||||||
|
let (access, refresh) = register_user_raw(&base, username, "alicepass1!").await;
|
||||||
|
store_tokens(username, &access, &refresh)
|
||||||
|
.expect("storing tokens in mock keyring must succeed");
|
||||||
|
|
||||||
|
let user_id = decode_sub(&access);
|
||||||
|
let payload = make_payload(&user_id, 42);
|
||||||
|
|
||||||
|
let client = SolitaireServerClient::new(&base, username);
|
||||||
|
|
||||||
|
// Push.
|
||||||
|
let push_resp = client
|
||||||
|
.push(&payload)
|
||||||
|
.await
|
||||||
|
.expect("push must succeed for an authenticated client");
|
||||||
|
assert_eq!(
|
||||||
|
push_resp.merged.stats.games_played, 42,
|
||||||
|
"merged stats from push must reflect pushed games_played"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pull on the same client.
|
||||||
|
let pulled = client
|
||||||
|
.pull()
|
||||||
|
.await
|
||||||
|
.expect("pull must succeed for an authenticated client");
|
||||||
|
assert_eq!(
|
||||||
|
pulled.stats.games_played, 42,
|
||||||
|
"pulled games_played must match what we pushed"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pulled.stats.best_single_score, 1234,
|
||||||
|
"pulled best_single_score must match what we pushed"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup so the shared mock store doesn't leak this username's tokens.
|
||||||
|
let _ = delete_tokens(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Concurrent two-client merge.**
|
||||||
|
///
|
||||||
|
/// Two clients (same user) push payloads with different `games_played`. The
|
||||||
|
/// server's merge keeps the higher of the two values. A subsequent pull from
|
||||||
|
/// either client must observe the merged max.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn pull_after_concurrent_pushes_merges_correctly() {
|
||||||
|
ensure_mock_keyring();
|
||||||
|
|
||||||
|
let base = spawn_test_server().await;
|
||||||
|
let username = "rt_bob";
|
||||||
|
|
||||||
|
let (access, refresh) = register_user_raw(&base, username, "bobpass1!").await;
|
||||||
|
store_tokens(username, &access, &refresh)
|
||||||
|
.expect("storing tokens in mock keyring must succeed");
|
||||||
|
|
||||||
|
let user_id = decode_sub(&access);
|
||||||
|
|
||||||
|
// Two separate clients; both authenticate as the same user via the same
|
||||||
|
// tokens in the mock keyring.
|
||||||
|
let client_a = SolitaireServerClient::new(&base, username);
|
||||||
|
let client_b = SolitaireServerClient::new(&base, username);
|
||||||
|
|
||||||
|
// Client A: low value first.
|
||||||
|
let payload_a = make_payload(&user_id, 5);
|
||||||
|
client_a.push(&payload_a).await.expect("client A push must succeed");
|
||||||
|
|
||||||
|
// Client B: higher value second.
|
||||||
|
let payload_b = make_payload(&user_id, 99);
|
||||||
|
client_b.push(&payload_b).await.expect("client B push must succeed");
|
||||||
|
|
||||||
|
// Either client should now pull max(5, 99) = 99.
|
||||||
|
let pulled = client_a
|
||||||
|
.pull()
|
||||||
|
.await
|
||||||
|
.expect("pull after concurrent pushes must succeed");
|
||||||
|
assert_eq!(
|
||||||
|
pulled.stats.games_played, 99,
|
||||||
|
"merged games_played must be max(5, 99) = 99"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = delete_tokens(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Unauthenticated pull surfaces an `Auth` error.**
|
||||||
|
///
|
||||||
|
/// We construct a client for a user who has *no* tokens in the keyring at
|
||||||
|
/// all. `pull()` must return `SyncError::Auth(_)` — never `Network` or
|
||||||
|
/// `Serialization`.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn unauthenticated_pull_returns_authentication_error() {
|
||||||
|
ensure_mock_keyring();
|
||||||
|
|
||||||
|
let base = spawn_test_server().await;
|
||||||
|
// Use a username that we never call `store_tokens` for so the keyring
|
||||||
|
// lookup fails before any HTTP request is made.
|
||||||
|
let username = "rt_no_creds";
|
||||||
|
// Defensive: in case a previous test run left tokens behind.
|
||||||
|
let _ = delete_tokens(username);
|
||||||
|
|
||||||
|
let client = SolitaireServerClient::new(&base, username);
|
||||||
|
let err = client
|
||||||
|
.pull()
|
||||||
|
.await
|
||||||
|
.expect_err("pull must fail without stored credentials");
|
||||||
|
assert!(
|
||||||
|
matches!(err, SyncError::Auth(_)),
|
||||||
|
"expected SyncError::Auth, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **JWT auto-refresh on 401.**
|
||||||
|
///
|
||||||
|
/// We register a user, then deliberately overwrite the stored access token
|
||||||
|
/// with one whose `exp` is in the past (signed with the same `TEST_SECRET`
|
||||||
|
/// so the signature verifies). The middleware will reject it with 401, the
|
||||||
|
/// `SolitaireServerClient` should call `/api/auth/refresh` with the still-
|
||||||
|
/// valid refresh token and retry — and `pull()` must ultimately succeed.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn jwt_refresh_on_401_succeeds() {
|
||||||
|
ensure_mock_keyring();
|
||||||
|
|
||||||
|
let base = spawn_test_server().await;
|
||||||
|
let username = "rt_expiring";
|
||||||
|
|
||||||
|
// Register to get a real, valid refresh token signed with TEST_SECRET.
|
||||||
|
let (_real_access, real_refresh) =
|
||||||
|
register_user_raw(&base, username, "expirepass1!").await;
|
||||||
|
let user_id = decode_sub(&_real_access);
|
||||||
|
|
||||||
|
// Craft an expired access token signed with TEST_SECRET so the server's
|
||||||
|
// signature check still passes but the expiry validation rejects it.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String,
|
||||||
|
exp: usize,
|
||||||
|
kind: String,
|
||||||
|
}
|
||||||
|
let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
|
||||||
|
let expired_access = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&Claims {
|
||||||
|
sub: user_id.clone(),
|
||||||
|
exp,
|
||||||
|
kind: "access".into(),
|
||||||
|
},
|
||||||
|
&EncodingKey::from_secret(TEST_SECRET.as_bytes()),
|
||||||
|
)
|
||||||
|
.expect("failed to encode expired access token");
|
||||||
|
|
||||||
|
// Overwrite the stored access token with the expired one. The refresh
|
||||||
|
// token stays valid so the client's refresh path can succeed.
|
||||||
|
store_tokens(username, &expired_access, &real_refresh)
|
||||||
|
.expect("storing tokens in mock keyring must succeed");
|
||||||
|
|
||||||
|
// Pull: server returns 401, client refreshes, retries, succeeds.
|
||||||
|
let client = SolitaireServerClient::new(&base, username);
|
||||||
|
let pulled = client.pull().await.expect(
|
||||||
|
"pull must succeed after the client transparently refreshes the access token",
|
||||||
|
);
|
||||||
|
// Default merge for a never-pushed user yields games_played = 0.
|
||||||
|
assert_eq!(
|
||||||
|
pulled.stats.games_played, 0,
|
||||||
|
"default empty payload after refresh must have games_played = 0"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = delete_tokens(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Account-deletion locks the client out.**
|
||||||
|
///
|
||||||
|
/// Register, push some data, then delete the account via the trait method.
|
||||||
|
/// A subsequent push with the *same* tokens (still cryptographically valid —
|
||||||
|
/// the server has no revocation list) must surface a non-success response
|
||||||
|
/// because the user row is gone and the server rejects the foreign-key push.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn pull_after_account_deletion_returns_default_or_error() {
|
||||||
|
ensure_mock_keyring();
|
||||||
|
|
||||||
|
let base = spawn_test_server().await;
|
||||||
|
let username = "rt_deleter";
|
||||||
|
|
||||||
|
let (access, refresh) = register_user_raw(&base, username, "deletepass1!").await;
|
||||||
|
store_tokens(username, &access, &refresh)
|
||||||
|
.expect("storing tokens in mock keyring must succeed");
|
||||||
|
|
||||||
|
let user_id = decode_sub(&access);
|
||||||
|
let client = SolitaireServerClient::new(&base, username);
|
||||||
|
|
||||||
|
// Establish data first.
|
||||||
|
client
|
||||||
|
.push(&make_payload(&user_id, 3))
|
||||||
|
.await
|
||||||
|
.expect("initial push must succeed");
|
||||||
|
|
||||||
|
// Delete the account.
|
||||||
|
client
|
||||||
|
.delete_account()
|
||||||
|
.await
|
||||||
|
.expect("delete_account must return Ok on the live server");
|
||||||
|
|
||||||
|
// After deletion, pushing the same payload may either:
|
||||||
|
// - succeed (server INSERTs a fresh sync_state row keyed off JWT sub
|
||||||
|
// even though the users row is gone), or
|
||||||
|
// - fail with a server error from a foreign-key violation.
|
||||||
|
//
|
||||||
|
// We do not pin down which behaviour the server picks — the contract we
|
||||||
|
// assert is just that the client surfaces *some* result without panicking
|
||||||
|
// and that the trait remains usable.
|
||||||
|
let post_delete_push = client.push(&make_payload(&user_id, 4)).await;
|
||||||
|
let _ = post_delete_push; // either Ok or Err is fine; no panic is the win
|
||||||
|
|
||||||
|
let _ = delete_tokens(username);
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ use std::path::PathBuf;
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use chrono::{Local, Timelike, Utc};
|
use chrono::{Local, Timelike, Utc};
|
||||||
use solitaire_core::achievement::{
|
use solitaire_core::achievement::{
|
||||||
achievement_by_id, check_achievements, AchievementContext, Reward, ALL_ACHIEVEMENTS,
|
achievement_by_id, check_achievements, AchievementContext, AchievementDef, Reward,
|
||||||
|
ALL_ACHIEVEMENTS,
|
||||||
};
|
};
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
|
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
|
||||||
@@ -32,11 +33,18 @@ use crate::ui_theme::{
|
|||||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, Z_MODAL_PANEL,
|
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, Z_MODAL_PANEL,
|
||||||
};
|
};
|
||||||
|
use crate::ui_tooltip::Tooltip;
|
||||||
|
|
||||||
/// Marker on the achievements overlay root node.
|
/// Marker on the achievements overlay root node.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct AchievementsScreen;
|
pub struct AchievementsScreen;
|
||||||
|
|
||||||
|
/// Marker on each per-achievement row inside the Achievements modal. Used by
|
||||||
|
/// hover-tooltip plumbing and tests so a row can be identified independently
|
||||||
|
/// of its visible text.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct AchievementRow;
|
||||||
|
|
||||||
/// All per-player achievement records (one per known achievement).
|
/// All per-player achievement records (one per known achievement).
|
||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
||||||
@@ -204,9 +212,7 @@ fn evaluate_on_win(
|
|||||||
/// Convenience: resolve an achievement ID to its human-readable name.
|
/// Convenience: resolve an achievement ID to its human-readable name.
|
||||||
/// Used by the toast renderer in `animation_plugin`.
|
/// Used by the toast renderer in `animation_plugin`.
|
||||||
pub fn display_name_for(id: &str) -> String {
|
pub fn display_name_for(id: &str) -> String {
|
||||||
achievement_by_id(id)
|
achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.to_string())
|
||||||
.map(|d| d.name.to_string())
|
|
||||||
.unwrap_or_else(|| id.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marker on the "Done" button inside the Achievements modal.
|
/// Marker on the "Done" button inside the Achievements modal.
|
||||||
@@ -284,12 +290,10 @@ fn spawn_achievements_screen(
|
|||||||
|
|
||||||
for record in &sorted {
|
for record in &sorted {
|
||||||
let def = achievement_by_id(&record.id);
|
let def = achievement_by_id(&record.id);
|
||||||
let (name, description) = def
|
let (name, description) = def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
|
||||||
.map(|d| (d.name, d.description))
|
|
||||||
.unwrap_or((&record.id, ""));
|
|
||||||
|
|
||||||
// Hide secret locked achievements so they remain a surprise.
|
// Hide secret locked achievements so they remain a surprise.
|
||||||
let is_secret = def.map(|d| d.secret).unwrap_or(false);
|
let is_secret = def.is_some_and(|d| d.secret);
|
||||||
if is_secret && !record.unlocked {
|
if is_secret && !record.unlocked {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -300,11 +304,17 @@ fn spawn_achievements_screen(
|
|||||||
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
|
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
|
||||||
};
|
};
|
||||||
|
|
||||||
card.spawn(Node {
|
let tooltip_text = tooltip_for_row(record.unlocked, def);
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
row_gap: VAL_SPACE_1,
|
card.spawn((
|
||||||
..default()
|
Node {
|
||||||
})
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
AchievementRow,
|
||||||
|
Tooltip::new(tooltip_text),
|
||||||
|
))
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
row.spawn((
|
row.spawn((
|
||||||
Text::new(format!("{prefix}{name}")),
|
Text::new(format!("{prefix}{name}")),
|
||||||
@@ -366,6 +376,40 @@ fn format_reward(reward: Reward) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compose the per-row hover-tooltip string. Surfaces information that the
|
||||||
|
/// row itself does not always make obvious:
|
||||||
|
///
|
||||||
|
/// * Unlocked + reward → "Reward: <reward>." — celebrates the prize.
|
||||||
|
/// * Unlocked, no reward → "Earned!".
|
||||||
|
/// * Locked, non-secret → "How to unlock: <description>." plus the reward
|
||||||
|
/// when one is defined; the visible row already shows the same lines, but
|
||||||
|
/// gathering them in one tooltip keeps the long list scannable on hover.
|
||||||
|
/// * Locked, secret rows are filtered out before they reach this helper —
|
||||||
|
/// they get no tooltip so the unlock condition stays a surprise.
|
||||||
|
///
|
||||||
|
/// Defs are looked up at the call site; `None` means the record refers to an
|
||||||
|
/// achievement no longer present in `ALL_ACHIEVEMENTS` (forward-compat) and
|
||||||
|
/// gets a generic fallback.
|
||||||
|
fn tooltip_for_row(unlocked: bool, def: Option<&AchievementDef>) -> String {
|
||||||
|
if unlocked {
|
||||||
|
match def.and_then(|d| d.reward).map(format_reward) {
|
||||||
|
Some(reward) => format!("Reward: {reward}."),
|
||||||
|
None => "Earned!".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let description = def.map_or("", |d| d.description);
|
||||||
|
let how = if description.is_empty() {
|
||||||
|
"How to unlock: keep playing.".to_string()
|
||||||
|
} else {
|
||||||
|
format!("How to unlock: {description}.")
|
||||||
|
};
|
||||||
|
match def.and_then(|d| d.reward).map(format_reward) {
|
||||||
|
Some(reward) => format!("{how} Reward: {reward}."),
|
||||||
|
None => how,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -734,4 +778,147 @@ mod tests {
|
|||||||
fn format_reward_badge() {
|
fn format_reward_badge() {
|
||||||
assert_eq!(format_reward(Reward::Badge), "Badge");
|
assert_eq!(format_reward(Reward::Badge), "Badge");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Per-row tooltips
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Collects every `Tooltip` string attached to an `AchievementRow` in the
|
||||||
|
/// current world. Order is unspecified — callers should search for a
|
||||||
|
/// substring rather than rely on positions.
|
||||||
|
fn collect_row_tooltips(app: &mut App) -> Vec<String> {
|
||||||
|
let mut q = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Tooltip, With<AchievementRow>>();
|
||||||
|
q.iter(app.world())
|
||||||
|
.map(|t| t.0.clone().into_owned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `on_a_roll` is unlocked and has `Reward::CardBack(1)`. Its row's
|
||||||
|
/// tooltip must surface that reward — the row UI already lists it, but
|
||||||
|
/// the tooltip exists so the value is never just below the fold on
|
||||||
|
/// long lists.
|
||||||
|
#[test]
|
||||||
|
fn unlocked_achievement_row_carries_tooltip_with_reward() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
// Pre-unlock on_a_roll directly on the resource so the row renders
|
||||||
|
// in the "unlocked" branch when the screen spawns.
|
||||||
|
{
|
||||||
|
let mut achievements = app.world_mut().resource_mut::<AchievementsResource>();
|
||||||
|
let record = achievements
|
||||||
|
.0
|
||||||
|
.iter_mut()
|
||||||
|
.find(|r| r.id == "on_a_roll")
|
||||||
|
.expect("on_a_roll record must be seeded by AchievementPlugin");
|
||||||
|
record.unlock(Utc::now());
|
||||||
|
record.reward_granted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::KeyA);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let tips = collect_row_tooltips(&mut app);
|
||||||
|
assert!(
|
||||||
|
!tips.is_empty(),
|
||||||
|
"spawning the achievements screen must attach Tooltips to rows"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The reward for on_a_roll is `Card Back #1`. Find a tooltip
|
||||||
|
// mentioning "Card back" (case-insensitive on "Back" → match the
|
||||||
|
// exact format_reward output).
|
||||||
|
let has_card_back_reward = tips.iter().any(|t| t.contains("Card Back"));
|
||||||
|
assert!(
|
||||||
|
has_card_back_reward,
|
||||||
|
"expected an unlocked-row tooltip to mention the Card Back reward; got: {tips:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locked secret achievements are filtered out of the row list, so the
|
||||||
|
/// screen must not contain a row tooltip carrying the secret
|
||||||
|
/// achievement's reward (`Card Back #4` for `speed_and_skill`) — the
|
||||||
|
/// only fingerprint that would betray the row's identity even though
|
||||||
|
/// the canonical description is already cryptic.
|
||||||
|
#[test]
|
||||||
|
fn locked_secret_achievement_does_not_reveal_condition() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
// `speed_and_skill` starts locked under headless_app(); confirm.
|
||||||
|
let locked = app
|
||||||
|
.world()
|
||||||
|
.resource::<AchievementsResource>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.id == "speed_and_skill")
|
||||||
|
.map(|r| !r.unlocked)
|
||||||
|
.unwrap_or(false);
|
||||||
|
assert!(
|
||||||
|
locked,
|
||||||
|
"precondition: speed_and_skill must be locked in a fresh headless app"
|
||||||
|
);
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::KeyA);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let tips = collect_row_tooltips(&mut app);
|
||||||
|
// No row may carry the secret reward — that's the only way the
|
||||||
|
// secret row's identity could leak through the tooltip surface.
|
||||||
|
for t in &tips {
|
||||||
|
assert!(
|
||||||
|
!t.contains("Card Back #4"),
|
||||||
|
"tooltip leaks the secret reward: {t:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No row may quote the verbatim secret-condition vocabulary. The
|
||||||
|
// canonical secret description in `solitaire_core` is already
|
||||||
|
// generic ("A secret achievement"); these checks guard against a
|
||||||
|
// future leak where someone replaces it with the literal predicate.
|
||||||
|
let leaked_predicate = tips.iter().any(|t| {
|
||||||
|
t.contains("90") && t.to_lowercase().contains("without undo")
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
!leaked_predicate,
|
||||||
|
"no tooltip may state the speed_and_skill predicate: {tips:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sanity: the screen actually rendered some rows. If the spawn
|
||||||
|
// path were broken there'd be nothing to leak in the first place.
|
||||||
|
assert!(!tips.is_empty(), "screen must have rendered rows");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// tooltip_for_row policy
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tooltip_for_row_unlocked_with_reward_mentions_reward() {
|
||||||
|
let def = achievement_by_id("on_a_roll").expect("on_a_roll exists");
|
||||||
|
let s = tooltip_for_row(true, Some(def));
|
||||||
|
assert!(s.contains("Card Back"), "got {s:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tooltip_for_row_unlocked_without_reward_says_earned() {
|
||||||
|
let def = achievement_by_id("first_win").expect("first_win exists");
|
||||||
|
assert_eq!(tooltip_for_row(true, Some(def)), "Earned!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tooltip_for_row_locked_includes_description_and_reward() {
|
||||||
|
let def = achievement_by_id("lightning").expect("lightning exists");
|
||||||
|
let s = tooltip_for_row(false, Some(def));
|
||||||
|
assert!(s.contains("How to unlock"));
|
||||||
|
assert!(s.contains("under 90 seconds"));
|
||||||
|
assert!(s.contains("Card Back #2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tooltip_for_row_locked_no_reward_omits_reward() {
|
||||||
|
let def = achievement_by_id("first_win").expect("first_win exists");
|
||||||
|
let s = tooltip_for_row(false, Some(def));
|
||||||
|
assert!(s.contains("How to unlock"));
|
||||||
|
assert!(!s.contains("Reward"), "got {s:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,8 +327,7 @@ fn handle_mute_keys(
|
|||||||
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||||
let (sfx_vol, music_vol) = settings
|
let (sfx_vol, music_vol) = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| (s.0.sfx_volume, s.0.music_volume))
|
.map_or((1.0, 0.5), |s| (s.0.sfx_volume, s.0.music_volume));
|
||||||
.unwrap_or((1.0, 0.5));
|
|
||||||
|
|
||||||
if shift {
|
if shift {
|
||||||
// Shift+M: toggle music mute only, SFX unaffected.
|
// Shift+M: toggle music mute only, SFX unaffected.
|
||||||
|
|||||||
@@ -189,8 +189,7 @@ pub(crate) fn apply_hover_scale(
|
|||||||
hover_state.scale = if let Some(entity) = target_entity {
|
hover_state.scale = if let Some(entity) = target_entity {
|
||||||
cards
|
cards
|
||||||
.get(entity)
|
.get(entity)
|
||||||
.map(|(_, t)| t.scale.x)
|
.map_or(hover_target, |(_, t)| t.scale.x)
|
||||||
.unwrap_or(hover_target)
|
|
||||||
} else {
|
} else {
|
||||||
1.0
|
1.0
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -380,9 +380,7 @@ fn start_deal_anim(
|
|||||||
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
|
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
|
||||||
|
|
||||||
let speed = settings.as_ref().map(|s| &s.0.animation_speed);
|
let speed = settings.as_ref().map(|s| &s.0.animation_speed);
|
||||||
let stagger_secs = speed
|
let stagger_secs = speed.map_or(DEAL_STAGGER_SECS, deal_stagger_secs_for_speed);
|
||||||
.map(deal_stagger_secs_for_speed)
|
|
||||||
.unwrap_or(DEAL_STAGGER_SECS);
|
|
||||||
|
|
||||||
for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() {
|
for (index, (entity, card_marker, transform)) in card_entities.iter().enumerate() {
|
||||||
let final_pos = transform.translation;
|
let final_pos = transform.translation;
|
||||||
|
|||||||
@@ -153,8 +153,7 @@ fn tick_elapsed_time(
|
|||||||
fn seed_from_system_time() -> u64 {
|
fn seed_from_system_time() -> u64 {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map(|d| d.as_nanos() as u64)
|
.map_or(0, |d| d.as_nanos() as u64)
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -201,8 +200,7 @@ fn handle_new_game(
|
|||||||
// where SettingsPlugin is not installed.
|
// where SettingsPlugin is not installed.
|
||||||
let draw_mode = settings
|
let draw_mode = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| s.0.draw_mode.clone())
|
.map_or_else(|| game.0.draw_mode.clone(), |s| s.0.draw_mode.clone());
|
||||||
.unwrap_or_else(|| game.0.draw_mode.clone());
|
|
||||||
let mode = ev.mode.unwrap_or(game.0.mode);
|
let mode = ev.mode.unwrap_or(game.0.mode);
|
||||||
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
|
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
|
||||||
// Delete any previously saved in-progress state — this is a fresh game.
|
// Delete any previously saved in-progress state — this is a fresh game.
|
||||||
|
|||||||
@@ -153,8 +153,7 @@ fn toggle_leaderboard_screen(
|
|||||||
// Spawn the panel immediately with whatever data we have so far.
|
// Spawn the panel immediately with whatever data we have so far.
|
||||||
let remote_available = provider
|
let remote_available = provider
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| p.0.backend_name() != "local")
|
.is_some_and(|p| p.0.backend_name() != "local");
|
||||||
.unwrap_or(false);
|
|
||||||
spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
|
spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
|
||||||
|
|
||||||
// Start a background fetch if not already in flight.
|
// Start a background fetch if not already in flight.
|
||||||
@@ -215,8 +214,7 @@ fn update_leaderboard_panel(
|
|||||||
}
|
}
|
||||||
let remote_available = provider
|
let remote_available = provider
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| p.0.backend_name() != "local")
|
.is_some_and(|p| p.0.backend_name() != "local");
|
||||||
.unwrap_or(false);
|
|
||||||
for entity in &screens {
|
for entity in &screens {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
|
spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
|
||||||
@@ -473,12 +471,10 @@ fn spawn_leaderboard_screen(
|
|||||||
|
|
||||||
let time_str = entry
|
let time_str = entry
|
||||||
.best_time_secs
|
.best_time_secs
|
||||||
.map(format_secs)
|
.map_or_else(|| "-".to_string(), format_secs);
|
||||||
.unwrap_or_else(|| "-".to_string());
|
|
||||||
let score_str = entry
|
let score_str = entry
|
||||||
.best_score
|
.best_score
|
||||||
.map(|s| s.to_string())
|
.map_or_else(|| "-".to_string(), |s| s.to_string());
|
||||||
.unwrap_or_else(|| "-".to_string());
|
|
||||||
|
|
||||||
card.spawn(Node {
|
card.spawn(Node {
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
|||||||
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
|
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
|
||||||
pub use profile_plugin::{ProfilePlugin, ProfileScreen};
|
pub use profile_plugin::{ProfilePlugin, ProfileScreen};
|
||||||
pub use settings_plugin::{
|
pub use settings_plugin::{
|
||||||
SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen, SFX_STEP,
|
PendingWindowGeometry, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen,
|
||||||
|
SFX_STEP, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
||||||
};
|
};
|
||||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||||
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ fn spawn_profile_screen(
|
|||||||
let records = &ar.0;
|
let records = &ar.0;
|
||||||
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
|
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new(format!("{} / 18 unlocked", unlocked_count)),
|
Text::new(format!("{unlocked_count} / 18 unlocked")),
|
||||||
font_row.clone(),
|
font_row.clone(),
|
||||||
TextColor(ACCENT_PRIMARY),
|
TextColor(ACCENT_PRIMARY),
|
||||||
));
|
));
|
||||||
@@ -216,7 +216,7 @@ fn spawn_profile_screen(
|
|||||||
let mut any_unlocked = false;
|
let mut any_unlocked = false;
|
||||||
for record in records {
|
for record in records {
|
||||||
let def = achievement_by_id(record.id.as_str());
|
let def = achievement_by_id(record.id.as_str());
|
||||||
let is_secret = def.map(|d| d.secret).unwrap_or(false);
|
let is_secret = def.is_some_and(|d| d.secret);
|
||||||
if is_secret && !record.unlocked {
|
if is_secret && !record.unlocked {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -224,7 +224,7 @@ fn spawn_profile_screen(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
any_unlocked = true;
|
any_unlocked = true;
|
||||||
let name = def.map(|d| d.name).unwrap_or(record.id.as_str());
|
let name = def.map_or(record.id.as_str(), |d| d.name);
|
||||||
let date_str = match record.unlock_date {
|
let date_str = match record.unlock_date {
|
||||||
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
|
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ fn handle_selection_keys(
|
|||||||
|
|
||||||
// --- Priority 2: tableau stack move ---
|
// --- Priority 2: tableau stack move ---
|
||||||
// Count the full contiguous face-up run in the source pile.
|
// Count the full contiguous face-up run in the source pile.
|
||||||
let run_len = face_up_run_len(game.0.piles.get(pile).map(|p| p.cards.as_slice()).unwrap_or(&[]));
|
let run_len = face_up_run_len(game.0.piles.get(pile).map_or(&[], |p| p.cards.as_slice()));
|
||||||
let bottom_card = game
|
let bottom_card = game
|
||||||
.0
|
.0
|
||||||
.piles
|
.piles
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ use std::path::PathBuf;
|
|||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||||
|
use bevy::window::{WindowMoved, WindowResized};
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings};
|
use solitaire_data::{
|
||||||
|
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
|
||||||
|
WindowGeometry,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
@@ -56,6 +60,24 @@ pub struct SettingsStoragePath(pub Option<PathBuf>);
|
|||||||
#[derive(Resource, Debug, Clone, Default)]
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
pub struct SettingsScreen(pub bool);
|
pub struct SettingsScreen(pub bool);
|
||||||
|
|
||||||
|
/// Debounce window for persisting window-geometry changes, in seconds.
|
||||||
|
///
|
||||||
|
/// `WindowResized` and `WindowMoved` fire continuously during a resize/
|
||||||
|
/// move drag, so writing to disk on every event would thrash the file
|
||||||
|
/// system. Instead the geometry-watch system records the pending value
|
||||||
|
/// and waits this long after the *last* event before saving.
|
||||||
|
pub const WINDOW_GEOMETRY_DEBOUNCE_SECS: f32 = 0.5;
|
||||||
|
|
||||||
|
/// Tracks a pending window-geometry change so the saver can debounce
|
||||||
|
/// `WindowResized` / `WindowMoved` storms during a resize / move drag.
|
||||||
|
#[derive(Resource, Debug, Default, Clone, Copy)]
|
||||||
|
pub struct PendingWindowGeometry {
|
||||||
|
/// Most recent observed geometry. `None` when nothing is pending.
|
||||||
|
pub geometry: Option<WindowGeometry>,
|
||||||
|
/// `Time::elapsed_secs()` value at which `geometry` was last updated.
|
||||||
|
pub last_changed_secs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
/// Fired whenever settings change so consumers (audio, UI) can react.
|
/// Fired whenever settings change so consumers (audio, UI) can react.
|
||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct SettingsChangedEvent(pub Settings);
|
pub struct SettingsChangedEvent(pub Settings);
|
||||||
@@ -198,11 +220,27 @@ impl Plugin for SettingsPlugin {
|
|||||||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||||
.init_resource::<SettingsScreen>()
|
.init_resource::<SettingsScreen>()
|
||||||
.init_resource::<SettingsScrollPos>()
|
.init_resource::<SettingsScrollPos>()
|
||||||
|
.init_resource::<PendingWindowGeometry>()
|
||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
.add_message::<ToggleSettingsRequestEvent>()
|
.add_message::<ToggleSettingsRequestEvent>()
|
||||||
.add_message::<bevy::input::mouse::MouseWheel>()
|
.add_message::<bevy::input::mouse::MouseWheel>()
|
||||||
.add_systems(Update, (handle_volume_keys, toggle_settings_screen, scroll_settings_panel));
|
// `WindowResized` / `WindowMoved` are real Bevy window events
|
||||||
|
// and emitted by the windowing backend under `DefaultPlugins`,
|
||||||
|
// but we register them explicitly here so the geometry watcher
|
||||||
|
// also runs cleanly under `MinimalPlugins` (tests).
|
||||||
|
.add_message::<WindowResized>()
|
||||||
|
.add_message::<WindowMoved>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
handle_volume_keys,
|
||||||
|
toggle_settings_screen,
|
||||||
|
scroll_settings_panel,
|
||||||
|
record_window_geometry_changes,
|
||||||
|
persist_window_geometry_after_debounce,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if self.ui_enabled {
|
if self.ui_enabled {
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
@@ -234,6 +272,32 @@ fn persist(path: &SettingsStoragePath, settings: &Settings) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure helper: returns `true` when a pending geometry change has sat
|
||||||
|
/// quietly long enough to flush to disk.
|
||||||
|
///
|
||||||
|
/// Extracted so the debounce condition can be unit-tested without
|
||||||
|
/// spinning up a Bevy app.
|
||||||
|
fn should_persist_geometry(now_secs: f32, last_changed_secs: f32) -> bool {
|
||||||
|
(now_secs - last_changed_secs) >= WINDOW_GEOMETRY_DEBOUNCE_SECS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the geometry implied by an event pair `(width, height, x, y)`,
|
||||||
|
/// using each component from `existing` when the corresponding event-derived
|
||||||
|
/// value is `None`. Returns `None` when neither side supplies width/height.
|
||||||
|
///
|
||||||
|
/// Pure helper so the merge logic can be unit-tested without an `App`.
|
||||||
|
fn merge_geometry(
|
||||||
|
existing: Option<WindowGeometry>,
|
||||||
|
new_size: Option<(u32, u32)>,
|
||||||
|
new_pos: Option<(i32, i32)>,
|
||||||
|
) -> Option<WindowGeometry> {
|
||||||
|
let (width, height) = new_size.or_else(|| existing.map(|g| (g.width, g.height)))?;
|
||||||
|
let (x, y) = new_pos
|
||||||
|
.or_else(|| existing.map(|g| (g.x, g.y)))
|
||||||
|
.unwrap_or((0, 0));
|
||||||
|
Some(WindowGeometry { width, height, x, y })
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Systems
|
// Systems
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -296,16 +360,13 @@ fn sync_settings_panel_visibility(
|
|||||||
if screen.0 {
|
if screen.0 {
|
||||||
if panels.is_empty() {
|
if panels.is_empty() {
|
||||||
let status_label = sync_status
|
let status_label = sync_status
|
||||||
.map(|s| sync_status_label(&s.0))
|
.map_or_else(|| "Status: local only".to_string(), |s| sync_status_label(&s.0));
|
||||||
.unwrap_or_else(|| "Status: local only".to_string());
|
|
||||||
let unlocked_backs = progress
|
let unlocked_backs = progress
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| p.0.unlocked_card_backs.as_slice())
|
.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
|
||||||
.unwrap_or(&[0]);
|
|
||||||
let unlocked_bgs = progress
|
let unlocked_bgs = progress
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| p.0.unlocked_backgrounds.as_slice())
|
.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
|
||||||
.unwrap_or(&[0]);
|
|
||||||
spawn_settings_panel(
|
spawn_settings_panel(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
&settings.0,
|
&settings.0,
|
||||||
@@ -466,7 +527,7 @@ fn handle_settings_buttons(
|
|||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
if let Ok(mut t) = sfx_text.single_mut() {
|
if let Ok(mut t) = sfx_text.single_mut() {
|
||||||
**t = format!("{:.2}", after);
|
**t = format!("{after:.2}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -477,7 +538,7 @@ fn handle_settings_buttons(
|
|||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
if let Ok(mut t) = sfx_text.single_mut() {
|
if let Ok(mut t) = sfx_text.single_mut() {
|
||||||
**t = format!("{:.2}", after);
|
**t = format!("{after:.2}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -488,7 +549,7 @@ fn handle_settings_buttons(
|
|||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
if let Ok(mut t) = music_text.single_mut() {
|
if let Ok(mut t) = music_text.single_mut() {
|
||||||
**t = format!("{:.2}", after);
|
**t = format!("{after:.2}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,7 +560,7 @@ fn handle_settings_buttons(
|
|||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
if let Ok(mut t) = music_text.single_mut() {
|
if let Ok(mut t) = music_text.single_mut() {
|
||||||
**t = format!("{:.2}", after);
|
**t = format!("{after:.2}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -764,6 +825,79 @@ fn scroll_settings_panel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Window geometry persistence
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Records `WindowResized` and `WindowMoved` events into
|
||||||
|
/// [`PendingWindowGeometry`], coalescing every event arriving this frame
|
||||||
|
/// into the latest pending geometry.
|
||||||
|
///
|
||||||
|
/// The actual disk write is debounced — see
|
||||||
|
/// [`persist_window_geometry_after_debounce`] — so the file system isn't
|
||||||
|
/// hit on every pixel of a resize / move drag.
|
||||||
|
fn record_window_geometry_changes(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut resized: MessageReader<WindowResized>,
|
||||||
|
mut moved: MessageReader<WindowMoved>,
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut pending: ResMut<PendingWindowGeometry>,
|
||||||
|
) {
|
||||||
|
// Read .last() — only the final event matters for persistence; the
|
||||||
|
// intermediate sizes/positions are noise during a drag.
|
||||||
|
let new_size = resized
|
||||||
|
.read()
|
||||||
|
.last()
|
||||||
|
.map(|ev| (ev.width.round().max(0.0) as u32, ev.height.round().max(0.0) as u32));
|
||||||
|
let new_pos = moved.read().last().map(|ev| (ev.position.x, ev.position.y));
|
||||||
|
|
||||||
|
if new_size.is_none() && new_pos.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fold the new components into the existing pending value (if any),
|
||||||
|
// otherwise into the persisted geometry from settings.
|
||||||
|
let baseline = pending.geometry.or(settings.0.window_geometry);
|
||||||
|
let Some(geometry) = merge_geometry(baseline, new_size, new_pos) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
pending.geometry = Some(geometry);
|
||||||
|
pending.last_changed_secs = time.elapsed_secs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] of quiet (no `WindowResized` or
|
||||||
|
/// `WindowMoved` events arriving), commits the pending geometry to
|
||||||
|
/// `SettingsResource` and writes `settings.json`. Skips the write when the
|
||||||
|
/// pending value already matches the settings (e.g. a resize that was
|
||||||
|
/// reverted, or a synthetic event with no geometry change).
|
||||||
|
fn persist_window_geometry_after_debounce(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut pending: ResMut<PendingWindowGeometry>,
|
||||||
|
mut settings: ResMut<SettingsResource>,
|
||||||
|
path: Res<SettingsStoragePath>,
|
||||||
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
|
) {
|
||||||
|
let Some(new_geom) = pending.geometry else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !should_persist_geometry(time.elapsed_secs(), pending.last_changed_secs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always clear the pending slot regardless of whether we end up
|
||||||
|
// writing — otherwise an idempotent change would re-trigger this
|
||||||
|
// system every tick.
|
||||||
|
pending.geometry = None;
|
||||||
|
|
||||||
|
if settings.0.window_geometry == Some(new_geom) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settings.0.window_geometry = Some(new_geom);
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// UI construction
|
// UI construction
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -945,7 +1079,7 @@ fn volume_row<Marker: Component>(
|
|||||||
));
|
));
|
||||||
row.spawn((
|
row.spawn((
|
||||||
marker,
|
marker,
|
||||||
Text::new(format!("{:.2}", value)),
|
Text::new(format!("{value:.2}")),
|
||||||
value_font,
|
value_font,
|
||||||
TextColor(TEXT_PRIMARY),
|
TextColor(TEXT_PRIMARY),
|
||||||
));
|
));
|
||||||
@@ -1512,6 +1646,181 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Window geometry persistence
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_persist_geometry_respects_debounce_window() {
|
||||||
|
// Within the debounce window: not yet.
|
||||||
|
assert!(!should_persist_geometry(10.0, 9.7));
|
||||||
|
assert!(!should_persist_geometry(
|
||||||
|
10.0,
|
||||||
|
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS + 0.01
|
||||||
|
));
|
||||||
|
// Exactly the debounce window: allowed (>= comparison).
|
||||||
|
assert!(should_persist_geometry(
|
||||||
|
10.0,
|
||||||
|
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS
|
||||||
|
));
|
||||||
|
// Well past the debounce window: allowed.
|
||||||
|
assert!(should_persist_geometry(20.0, 10.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_geometry_uses_existing_when_event_components_missing() {
|
||||||
|
let existing = WindowGeometry { width: 1280, height: 800, x: 100, y: 50 };
|
||||||
|
// Position-only event keeps existing size.
|
||||||
|
let merged = merge_geometry(Some(existing), None, Some((200, 75))).unwrap();
|
||||||
|
assert_eq!(merged.width, 1280);
|
||||||
|
assert_eq!(merged.height, 800);
|
||||||
|
assert_eq!(merged.x, 200);
|
||||||
|
assert_eq!(merged.y, 75);
|
||||||
|
// Size-only event keeps existing position.
|
||||||
|
let merged = merge_geometry(Some(existing), Some((1024, 768)), None).unwrap();
|
||||||
|
assert_eq!(merged.width, 1024);
|
||||||
|
assert_eq!(merged.height, 768);
|
||||||
|
assert_eq!(merged.x, 100);
|
||||||
|
assert_eq!(merged.y, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_geometry_returns_none_when_size_unknown() {
|
||||||
|
// No existing geometry, no size in the event → can't fabricate one.
|
||||||
|
assert!(merge_geometry(None, None, Some((10, 20))).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives `app.update()` past [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] using
|
||||||
|
/// `TimeUpdateStrategy::ManualDuration`. `Time<Virtual>` clamps each
|
||||||
|
/// frame's delta to `max_delta` (default 250 ms), so we step in 150 ms
|
||||||
|
/// slices and run enough ticks to comfortably exceed the debounce
|
||||||
|
/// window after the first record tick has set `last_changed_secs`.
|
||||||
|
fn advance_past_geometry_debounce(app: &mut App) {
|
||||||
|
use bevy::time::TimeUpdateStrategy;
|
||||||
|
use std::time::Duration;
|
||||||
|
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
|
||||||
|
0.15,
|
||||||
|
)));
|
||||||
|
// Tick 1 sets last_changed_secs from any pending events. Each
|
||||||
|
// subsequent tick advances the clock by 150 ms; five ticks total
|
||||||
|
// buys 0.75 s of elapsed time relative to the record tick — well
|
||||||
|
// past the 0.5 s debounce window.
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fire_resize(app: &mut App, width: f32, height: f32) {
|
||||||
|
app.world_mut().write_message(WindowResized {
|
||||||
|
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fire_move(app: &mut App, x: i32, y: i32) {
|
||||||
|
app.world_mut().write_message(WindowMoved {
|
||||||
|
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||||
|
position: IVec2::new(x, y),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_event_then_quiet_persists_window_geometry() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Sanity: geometry starts unset (default).
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fire a resize, then go quiet for past the debounce.
|
||||||
|
fire_resize(&mut app, 1500.0, 950.0);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
|
||||||
|
let geom = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.expect("geometry should be persisted after debounce");
|
||||||
|
assert_eq!(geom.width, 1500);
|
||||||
|
assert_eq!(geom.height, 950);
|
||||||
|
// Position not yet observed → defaults to 0, 0 since there was
|
||||||
|
// no existing geometry to fall back on.
|
||||||
|
assert_eq!(geom.x, 0);
|
||||||
|
assert_eq!(geom.y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_event_after_resize_updates_position_only() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
// First, establish a baseline geometry via a resize event.
|
||||||
|
fire_resize(&mut app, 1280.0, 800.0);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
let baseline = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(baseline.width, 1280);
|
||||||
|
|
||||||
|
// Now fire a move-only event — size must be preserved from the
|
||||||
|
// existing geometry.
|
||||||
|
fire_move(&mut app, 250, 175);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
|
||||||
|
let geom = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(geom.width, 1280, "size must be preserved across a move-only update");
|
||||||
|
assert_eq!(geom.height, 800);
|
||||||
|
assert_eq!(geom.x, 250);
|
||||||
|
assert_eq!(geom.y, 175);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rapid_resize_storm_only_persists_final_size() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
// Burst of resize events on a single frame — only the last one
|
||||||
|
// should be the eventually-persisted size.
|
||||||
|
fire_resize(&mut app, 900.0, 600.0);
|
||||||
|
fire_resize(&mut app, 1100.0, 700.0);
|
||||||
|
fire_resize(&mut app, 1400.0, 850.0);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
|
||||||
|
let geom = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((geom.width, geom.height), (1400, 850));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_window_events_no_geometry_change() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Just advance time — without any events, settings must stay clean.
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scroll_clamps_offset_to_zero_at_top() {
|
fn scroll_clamps_offset_to_zero_at_top() {
|
||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
|||||||
@@ -259,9 +259,7 @@ fn dismiss_splash_on_input(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let touch_pressed = touches
|
let touch_pressed = touches.is_some_and(|t| t.iter_just_pressed().next().is_some());
|
||||||
.map(|t| t.iter_just_pressed().next().is_some())
|
|
||||||
.unwrap_or(false);
|
|
||||||
let dismissed = keys.get_just_pressed().next().is_some()
|
let dismissed = keys.get_just_pressed().next().is_some()
|
||||||
|| mouse.get_just_pressed().next().is_some()
|
|| mouse.get_just_pressed().next().is_some()
|
||||||
|| touch_pressed;
|
|| touch_pressed;
|
||||||
|
|||||||
@@ -142,14 +142,10 @@ fn setup_table(
|
|||||||
let window_size = windows
|
let window_size = windows
|
||||||
.iter()
|
.iter()
|
||||||
.next()
|
.next()
|
||||||
.map(default_window_size)
|
.map_or(Vec2::new(1280.0, 800.0), default_window_size);
|
||||||
.unwrap_or(Vec2::new(1280.0, 800.0));
|
|
||||||
let layout = compute_layout(window_size);
|
let layout = compute_layout(window_size);
|
||||||
|
|
||||||
let selected_bg = settings
|
let selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background);
|
||||||
.as_ref()
|
|
||||||
.map(|s| s.0.selected_background)
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let image_handle = bg_images
|
let image_handle = bg_images
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -341,9 +337,7 @@ fn apply_hint_pile_highlight(
|
|||||||
if pile_marker.0 != ev.dest_pile {
|
if pile_marker.0 != ev.dest_pile {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let original_color = existing
|
let original_color = existing.map_or(sprite.color, |h| h.original_color);
|
||||||
.map(|h| h.original_color)
|
|
||||||
.unwrap_or(sprite.color);
|
|
||||||
sprite.color = HINT_PILE_HIGHLIGHT_COLOUR;
|
sprite.color = HINT_PILE_HIGHLIGHT_COLOUR;
|
||||||
commands.entity(entity).insert(HintPileHighlight {
|
commands.entity(entity).insert(HintPileHighlight {
|
||||||
timer: 2.0,
|
timer: 2.0,
|
||||||
|
|||||||
@@ -364,8 +364,7 @@ fn handle_focus_keys(
|
|||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
focusables
|
focusables
|
||||||
.get(*e)
|
.get(*e)
|
||||||
.map(|(_, disabled)| !disabled)
|
.is_ok_and(|(_, disabled)| !disabled)
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if !row_cycle.is_empty()
|
if !row_cycle.is_empty()
|
||||||
@@ -466,12 +465,7 @@ fn handle_focus_keys(
|
|||||||
// Stable sort by `Focusable::order` so explicit priorities (e.g.
|
// Stable sort by `Focusable::order` so explicit priorities (e.g.
|
||||||
// HUD spawn-order: 0..5) drive the cycle. The pre-sort by entity
|
// HUD spawn-order: 0..5) drive the cycle. The pre-sort by entity
|
||||||
// index above is the tiebreaker for entries sharing an `order`.
|
// index above is the tiebreaker for entries sharing an `order`.
|
||||||
group.sort_by_key(|e| {
|
group.sort_by_key(|e| focusables.get(*e).map_or(i32::MAX, |(f, _)| f.order));
|
||||||
focusables
|
|
||||||
.get(*e)
|
|
||||||
.map(|(f, _)| f.order)
|
|
||||||
.unwrap_or(i32::MAX)
|
|
||||||
});
|
|
||||||
|
|
||||||
if group.is_empty() {
|
if group.is_empty() {
|
||||||
// Still consume the key so the card-selection plugin doesn't
|
// Still consume the key so the card-selection plugin doesn't
|
||||||
|
|||||||
@@ -409,8 +409,7 @@ pub fn apply_modal_enter_speed(
|
|||||||
) {
|
) {
|
||||||
let speed = settings
|
let speed = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| s.0.animation_speed)
|
.map_or(AnimSpeed::Normal, |s| s.0.animation_speed);
|
||||||
.unwrap_or(AnimSpeed::Normal);
|
|
||||||
for mut entering in &mut q {
|
for mut entering in &mut q {
|
||||||
entering.duration = scaled_duration(MOTION_MODAL_SECS, speed);
|
entering.duration = scaled_duration(MOTION_MODAL_SECS, speed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,9 +121,7 @@ fn evaluate_weekly_goals(
|
|||||||
|
|
||||||
/// Resolve a goal id to its description (used for toasts).
|
/// Resolve a goal id to its description (used for toasts).
|
||||||
pub fn weekly_goal_description(id: &str) -> String {
|
pub fn weekly_goal_description(id: &str) -> String {
|
||||||
weekly_goal_by_id(id)
|
weekly_goal_by_id(id).map_or_else(|| id.to_string(), |g| g.description.to_string())
|
||||||
.map(|g| g.description.to_string())
|
|
||||||
.unwrap_or_else(|| id.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ pub enum AppError {
|
|||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, message) = match &self {
|
let (status, message) = match &self {
|
||||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
|
AppError::Unauthorized | AppError::InvalidCredentials => {
|
||||||
AppError::InvalidCredentials => (StatusCode::UNAUTHORIZED, self.to_string()),
|
(StatusCode::UNAUTHORIZED, self.to_string())
|
||||||
|
}
|
||||||
AppError::UsernameTaken => (StatusCode::CONFLICT, self.to_string()),
|
AppError::UsernameTaken => (StatusCode::CONFLICT, self.to_string()),
|
||||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||||
AppError::Database(e) => {
|
AppError::Database(e) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user