feat(engine): playability improvements — input intelligence, audio, HUD, onboarding (#27–#30, #37, #39–#40, #44, #48–#49)

Task #27: Double-click auto-move — best_destination() finds optimal target
(foundation over tableau); handle_double_click() fires MoveRequestEvent.

Task #28: Hint system — find_hint() returns first legal from/to/count triple;
H key tints the source stack HintHighlight (yellow pulse via tick_hint_highlight).

Task #29: No-moves detection — has_legal_moves() checks stock/waste/all face-up
cards; check_no_moves system fires InfoToastEvent("No moves available") once per
stalemate (debounced so it fires only once until the state changes).

Task #30: Forfeit — G key fires ForfeitEvent; StatsPlugin records abandoned game,
persists stats, starts a new deal.

Task #37: Mute-all (M) and mute-music (Shift+M) toggles; MuteState resource
applied in apply_volume_on_change.

Task #39: Daily challenge HUD constraint label (time limit / target score).

Task #40: Undo-count HUD label; amber colour when undos > 0.

Task #44: Win-streak and level line on pause screen.

Task #48: Undo sound routes UndoRequestEvent → lib.flip audio channel.

Task #49: Onboarding banner rich-text key highlights — D and H rendered as
orange KeyHighlightSpan children so they stand out from body text.

Also registers CursorPlugin in solitaire_app (tasks #31/#32 wire-up).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-27 19:11:47 +00:00
parent c3ee7c45a7
commit ddd7502a06
16 changed files with 1269 additions and 46 deletions
+59
View File
@@ -231,6 +231,65 @@ pub async fn delete_account(
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{decode, DecodingKey, Validation};
const TEST_SECRET: &str = "test_secret_for_unit_tests_only";
fn decode_token(token: &str) -> Claims {
let mut validation = Validation::default();
validation.leeway = 60;
decode::<Claims>(
token,
&DecodingKey::from_secret(TEST_SECRET.as_bytes()),
&validation,
)
.unwrap()
.claims
}
#[test]
fn make_access_token_decodes_with_correct_claims() {
let token = make_access_token("user-123", TEST_SECRET).unwrap();
let claims = decode_token(&token);
assert_eq!(claims.sub, "user-123");
assert_eq!(claims.kind, "access");
let now = Utc::now().timestamp() as usize;
// expiry should be roughly 24 hours in the future (allow ±60s for test execution)
assert!(claims.exp > now + 86_400 - 60);
assert!(claims.exp < now + 86_400 + 60);
}
#[test]
fn make_refresh_token_decodes_with_correct_claims() {
let token = make_refresh_token("user-456", TEST_SECRET).unwrap();
let claims = decode_token(&token);
assert_eq!(claims.sub, "user-456");
assert_eq!(claims.kind, "refresh");
let now = Utc::now().timestamp() as usize;
// expiry should be roughly 30 days in the future (allow ±60s for test execution)
assert!(claims.exp > now + 30 * 86_400 - 60);
assert!(claims.exp < now + 30 * 86_400 + 60);
}
#[test]
fn make_access_token_wrong_secret_fails_decode() {
let token = make_access_token("user-789", TEST_SECRET).unwrap();
let result = decode::<Claims>(
&token,
&DecodingKey::from_secret(b"wrong_secret"),
&Validation::default(),
);
assert!(result.is_err(), "decoding with wrong secret must fail");
}
#[test]
fn access_and_refresh_tokens_have_different_kinds() {
let access = make_access_token("u", TEST_SECRET).unwrap();
let refresh = make_refresh_token("u", TEST_SECRET).unwrap();
let a_claims = decode_token(&access);
let r_claims = decode_token(&refresh);
assert_ne!(a_claims.kind, r_claims.kind);
}
#[test]
fn username_chars_ok_accepts_alphanumeric_and_underscore() {
+20
View File
@@ -194,4 +194,24 @@ mod tests {
assert!(g.target_score.is_none());
assert!(g.max_time_secs.is_none());
}
#[test]
fn generate_goal_all_variants_have_sane_ranges() {
for variant_idx in 0u64..6 {
let g = generate_goal("2026-04-26", variant_idx);
assert!(!g.description.is_empty(), "variant {variant_idx}: description must not be empty");
if let Some(t) = g.max_time_secs {
assert!(
(60..=3600).contains(&t),
"variant {variant_idx}: max_time_secs {t} outside [60, 3600]"
);
}
if let Some(s) = g.target_score {
assert!(
(1_000..=10_000).contains(&s),
"variant {variant_idx}: target_score {s} outside [1000, 10000]"
);
}
}
}
}
+86 -1
View File
@@ -16,7 +16,7 @@ use axum::{
response::Response,
};
use chrono::Utc;
use jsonwebtoken::{decode, DecodingKey, Validation};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::Deserialize;
use serde_json::Value;
use solitaire_server::build_test_router;
@@ -1052,3 +1052,88 @@ async fn login_trims_whitespace_from_username() {
"login with whitespace-padded username must succeed"
);
}
// ---------------------------------------------------------------------------
// Security tests
// ---------------------------------------------------------------------------
/// `POST /api/sync/push` with a body exceeding the 1 MB limit must return 413.
#[tokio::test]
async fn push_oversized_body_returns_413() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "sizetest", "password1!").await;
// 1_100_000-byte string embedded in JSON comfortably exceeds the 1 MB limit.
let big_string = "x".repeat(1_100_000);
let body_bytes =
serde_json::to_vec(&serde_json::json!({ "garbage": big_string })).unwrap();
let req = Request::builder()
.method("POST")
.uri("/api/sync/push")
.header("content-type", "application/json")
.header("Authorization", format!("Bearer {access}"))
.header("x-forwarded-for", TEST_CLIENT_IP)
.body(Body::from(body_bytes))
.expect("failed to build oversized request");
let resp = app.oneshot(req).await.expect("oneshot failed");
assert_eq!(
resp.status(),
StatusCode::PAYLOAD_TOO_LARGE,
"oversized body must be rejected with 413"
);
}
/// A JWT whose `exp` is in the past must be rejected with 401 on protected routes.
#[tokio::test]
async fn expired_access_token_returns_401() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
// Craft a token that expired 2 hours ago — well past jsonwebtoken's 60 s leeway.
#[derive(serde::Serialize)]
struct ExpiredClaims {
sub: String,
exp: usize,
kind: String,
}
let exp = (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
let expired_token = encode(
&Header::default(),
&ExpiredClaims {
sub: "00000000-0000-0000-0000-000000000000".into(),
exp,
kind: "access".into(),
},
&EncodingKey::from_secret(TEST_SECRET.as_bytes()),
)
.unwrap();
let resp = get_authed(app, "/api/sync/pull", &expired_token).await;
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"expired JWT must be rejected with 401"
);
}
/// A refresh token must be rejected when used as a Bearer token on protected routes.
#[tokio::test]
async fn refresh_token_rejected_on_protected_routes() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (_, refresh) = register_user(app.clone(), "kindtest", "password1!").await;
// Using the refresh token (kind = "refresh") as a Bearer on a protected route
// must return 401 because the middleware requires kind = "access".
let resp = get_authed(app, "/api/sync/pull", &refresh).await;
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"refresh token must be rejected on protected endpoints"
);
}