Compare commits
17 Commits
v0.40.3
...
59ba7ba4c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 59ba7ba4c3 | |||
| 1ca1efb3b6 | |||
| c5ad487256 | |||
| 8274581edf | |||
| 704e70f60a | |||
| b81b314197 | |||
| a2375d2fd9 | |||
| 3f5e4f4290 | |||
| 0797e9a993 | |||
| c53b42342b | |||
| 123aa6d099 | |||
| 331d932c4b | |||
| 716e5f04cf | |||
| ef9d914b99 | |||
| 005efa2ee4 | |||
| 1190ed3ce6 | |||
| 2869e1c34e |
@@ -109,6 +109,18 @@ Owns:
|
||||
- Achievement unlock condition evaluation
|
||||
- Seeded RNG for reproducible deals
|
||||
|
||||
**Rules decisions:**
|
||||
- **Stock recycling is unlimited in every draw mode — by design.** Extra
|
||||
passes through the stock are discouraged via the upstream score penalty
|
||||
(applied by the `card_game`/`klondike` session), never blocked with a
|
||||
`MoveError`. This matches mainstream digital solitaire (unlimited redeals
|
||||
in Draw-1) rather than strict tournament rules (3-pass cap). It is load-
|
||||
bearing: the difficulty seed catalog and the winnable-deal solver are
|
||||
verified under unlimited recycling, so introducing a hard pass limit would
|
||||
invalidate both. Locked in by the
|
||||
`draw_one_recycling_is_unlimited_by_design` test in
|
||||
`solitaire_core/src/game_state.rs`. (Decision record: Gitea issue #117.)
|
||||
|
||||
### `solitaire_sync`
|
||||
**Dependencies:** `serde`, `serde_json`, `uuid`, `chrono` only.
|
||||
|
||||
|
||||
@@ -6,8 +6,19 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.41.0] — 2026-07-06
|
||||
|
||||
> Consolidates everything shipped since v0.39.0, including the v0.40.0–v0.40.3
|
||||
> patch tags (which were cut from this section without renaming it at the time).
|
||||
|
||||
### Added
|
||||
|
||||
- **Rules decision record: unlimited stock recycling.** Documented in
|
||||
`ARCHITECTURE.md` that unlimited recycling with score penalties (matching
|
||||
mainstream digital solitaire) is intentional, and locked it in with a core
|
||||
test — the difficulty seed catalog and winnable-deal solver are verified
|
||||
under this rule. Resolves the last open finding from the June 500-game
|
||||
audit (issue #117).
|
||||
- **Analytics validation runbook.** Documented native Matomo live validation,
|
||||
expected event payloads, and the current web/WASM analytics split.
|
||||
- **Android smoke-test runbook.** Updated the Android doc with the current
|
||||
@@ -26,6 +37,10 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Engine plugin modules restructured.** The five oversized plugin files
|
||||
(card, hud, settings, game, input) are now module directories with their
|
||||
test suites in sibling `tests.rs` files — no behaviour change; first phase
|
||||
of the module-split plan (#118).
|
||||
- **Core type ownership.** Routed all klondike/card imports through
|
||||
`solitaire_core` and unified local `Suit` / `Rank` with upstream `card_game`
|
||||
types.
|
||||
@@ -37,6 +52,12 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Safe-area insets now re-polled after app resume.** The inset poller settled
|
||||
permanently once insets first resolved, so inset changes while backgrounded
|
||||
(fold/unfold, rotation, gesture ↔ 3-button nav switch) kept stale layout until
|
||||
the app was killed. Each resume now re-arms a fresh poll window; the cached
|
||||
value is rewritten only when it actually changed, so unchanged resumes still
|
||||
cause no relayout flash. (#116)
|
||||
- **Tableau fill on foldables / tall screens.** The tableau fan now spreads from
|
||||
each column's total depth (face-down cards included) and refills on the
|
||||
cold-start deal, every move, and on resize (incl. the Android safe-area-inset
|
||||
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live-watch the Gitea Actions deploy pipeline for Ferrous Solitaire.
|
||||
#
|
||||
# Polls recent workflow runs and prints a compact status block each cycle.
|
||||
# Stops when the newest docker-build (the deploy) has completed and no
|
||||
# web-wasm-rebuild is still pending — i.e. the full fix is live.
|
||||
#
|
||||
# Usage: ./scripts/watch_deploy.sh [interval_seconds]
|
||||
# token is read from ~/.config/tea/config.yml (never printed).
|
||||
set -uo pipefail
|
||||
|
||||
REPO="funman300/Ferrous-Solitaire"
|
||||
BASE="https://git.aleshym.co/api/v1/repos/${REPO}"
|
||||
INTERVAL="${1:-20}"
|
||||
CFG="${HOME}/.config/tea/config.yml"
|
||||
|
||||
TOKEN="$(grep -E '^[[:space:]]*token:' "$CFG" | head -1 | sed -E 's/.*token:[[:space:]]*//' | tr -d '"'\'' ')"
|
||||
[ -z "$TOKEN" ] && { echo "error: no token in $CFG" >&2; exit 1; }
|
||||
|
||||
echo "── watching ${REPO} deploy (poll ${INTERVAL}s, Ctrl-C to stop) ──"
|
||||
|
||||
while :; do
|
||||
json="$(curl -s --max-time 20 -H "Authorization: token ${TOKEN}" "${BASE}/actions/runs?limit=6")"
|
||||
# Print rows + emit the deploy state on the last line (parsed below).
|
||||
# JSON is passed via env var because `python3 -` reads its program from the
|
||||
# heredoc on stdin, so stdin can't also carry the data.
|
||||
out="$(JSON_DATA="$json" python3 - <<'PY'
|
||||
import os, sys, json, datetime
|
||||
now = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
raw = os.environ.get("JSON_DATA", "").strip()
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except Exception:
|
||||
print("[%s] (api unavailable, retrying)" % now)
|
||||
print("STATE=DEPLOYING")
|
||||
sys.exit(0)
|
||||
runs = d.get("workflow_runs", [])[:6]
|
||||
icons = {("completed","success"):"OK ", ("completed","failure"):"FAIL",
|
||||
("completed","cancelled"):"CXL "}
|
||||
def ic(s, c):
|
||||
if s == "queued": return "queue"
|
||||
if s == "in_progress": return "run.."
|
||||
return icons.get((s, c), s or "?")
|
||||
print("[%s]" % datetime.datetime.now().strftime("%H:%M:%S"))
|
||||
for r in runs:
|
||||
wf = str(r.get("path","")).split("@")[0].split("/")[-1].replace(".yml","")
|
||||
print(" %-5s %-5s %-7s %-18s %s/%s" % (
|
||||
ic(r.get("status"), r.get("conclusion")),
|
||||
r.get("id"), str(r.get("head_sha"))[:7], wf[:18],
|
||||
r.get("status"), r.get("conclusion")))
|
||||
db = [r for r in runs if "docker-build" in str(r.get("path"))]
|
||||
wr_pending = any(r.get("status") != "completed" for r in runs if "web-wasm-rebuild" in str(r.get("path")))
|
||||
top = db[0] if db else None
|
||||
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success" and not wr_pending)
|
||||
print("STATE=%s" % ("LIVE" if live else ("WAIT_WASM" if wr_pending else "DEPLOYING")))
|
||||
PY
|
||||
)"
|
||||
echo "$out" | grep -v '^STATE='
|
||||
if echo "$out" | grep -q '^STATE=LIVE'; then
|
||||
echo ""
|
||||
echo "DEPLOY LIVE — newest docker-build succeeded, no wasm rebuild pending."
|
||||
echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}"
|
||||
break
|
||||
fi
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
@@ -863,6 +863,13 @@ impl GameState {
|
||||
}
|
||||
|
||||
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
|
||||
///
|
||||
/// Recycling is deliberately unlimited in every draw mode: extra passes
|
||||
/// are discouraged through the upstream score penalty, never rejected
|
||||
/// with a [`MoveError`]. The difficulty seed catalog and the
|
||||
/// winnable-deal solver are verified under this rule — see the
|
||||
/// "Rules decisions" note in `ARCHITECTURE.md` (`solitaire_core`
|
||||
/// section) before considering a hard pass limit.
|
||||
pub fn draw(&mut self) -> Result<(), MoveError> {
|
||||
if self.is_won() {
|
||||
return Err(MoveError::GameAlreadyWon);
|
||||
@@ -1222,6 +1229,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_one_recycling_is_unlimited_by_design() {
|
||||
// Rules decision (Gitea #117): stock recycling is unlimited in every
|
||||
// draw mode. Extra passes are discouraged via the upstream score
|
||||
// penalty, never blocked with a MoveError. The difficulty seed
|
||||
// catalog and the winnable-deal solver are verified under this rule,
|
||||
// so a hard pass limit must not be introduced casually — see the
|
||||
// "Rules decisions" note in ARCHITECTURE.md (`solitaire_core`).
|
||||
let mut game = game_at_first_recycle().expect("could not reach recycle");
|
||||
// ~24 draws per Draw-1 pass; 2_000 draws is ample budget for 10 passes.
|
||||
for _ in 0..2_000 {
|
||||
if game.recycle_count() >= 10 {
|
||||
break;
|
||||
}
|
||||
game.draw()
|
||||
.expect("unlimited recycling: draw must never fail on pass count");
|
||||
}
|
||||
assert!(
|
||||
game.recycle_count() >= 10,
|
||||
"expected at least 10 recycles within the draw budget, got {}",
|
||||
game.recycle_count(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_applies_minus_15_penalty_via_upstream_score() {
|
||||
// A foundation move scores +10 upstream; undoing it nets the move score
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2722,877 +2722,4 @@ fn toggle_hud_on_tap(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use chrono::Local;
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(HudPlugin);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_plugin_registers_without_panic() {
|
||||
let _app = headless_app();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_hud_runs_after_game_mutation_without_panic() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(42, DrawStockConfig::DrawOne);
|
||||
app.update();
|
||||
}
|
||||
|
||||
fn read_hud_text<M: Component>(app: &mut App) -> String {
|
||||
app.world_mut()
|
||||
.query_filtered::<&Text, With<M>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.map(|t| t.0.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_reflects_game_state() {
|
||||
let mut app = headless_app();
|
||||
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_reflects_game_state() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.set_test_move_count(42);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_mode_shows_draw_3_badge() {
|
||||
use solitaire_core::game_state::GameMode;
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(42, DrawStockConfig::DrawThree, GameMode::Classic);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudMode>(&mut app), "Draw 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zen_mode_hides_score() {
|
||||
use solitaire_core::game_state::GameMode;
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(42, DrawStockConfig::DrawOne, GameMode::Zen);
|
||||
app.update();
|
||||
// Zen mode spec: "No score display" → text must be empty.
|
||||
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_display_uses_mm_ss_format() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.elapsed_seconds = 125;
|
||||
app.update();
|
||||
// 125 seconds = 2 minutes 5 seconds → "2:05"
|
||||
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// format_time_limit (pure function)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn format_time_limit_300_is_5_00() {
|
||||
assert_eq!(format_time_limit(300), "5:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_time_limit_zero() {
|
||||
assert_eq!(format_time_limit(0), "0:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_time_limit_pads_seconds() {
|
||||
assert_eq!(format_time_limit(65), "1:05");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// challenge_hud_text (pure function)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_text_shows_time_limit() {
|
||||
let dc = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: Some(300),
|
||||
};
|
||||
assert_eq!(challenge_hud_text(&dc), "Limit: 5:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_text_shows_score_goal() {
|
||||
let dc = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: Some(4000),
|
||||
max_time_secs: None,
|
||||
};
|
||||
assert_eq!(challenge_hud_text(&dc), "Goal: 4000 pts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_text_empty_when_no_constraints() {
|
||||
let dc = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: None,
|
||||
};
|
||||
assert_eq!(challenge_hud_text(&dc), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_above_60_is_info() {
|
||||
let c = challenge_time_color(61);
|
||||
assert_eq!(c, STATE_INFO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_exactly_60_is_info() {
|
||||
let c = challenge_time_color(60);
|
||||
assert_eq!(c, STATE_INFO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_59_is_warning() {
|
||||
let c = challenge_time_color(59);
|
||||
assert_eq!(c, STATE_WARNING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_30_is_warning() {
|
||||
let c = challenge_time_color(30);
|
||||
assert_eq!(c, STATE_WARNING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_29_is_danger() {
|
||||
let c = challenge_time_color(29);
|
||||
assert_eq!(c, STATE_DANGER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_zero_is_danger() {
|
||||
let c = challenge_time_color(0);
|
||||
assert_eq!(c, STATE_DANGER);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudChallenge in-app tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_empty_when_no_daily_resource() {
|
||||
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_shows_time_limit_when_resource_present() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().insert_resource(DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 42,
|
||||
goal_description: Some("Win fast".to_string()),
|
||||
target_score: None,
|
||||
max_time_secs: Some(300),
|
||||
});
|
||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_shows_score_goal_when_resource_present() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().insert_resource(DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 42,
|
||||
goal_description: None,
|
||||
target_score: Some(4000),
|
||||
max_time_secs: None,
|
||||
});
|
||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_clears_on_win() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().insert_resource(DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 42,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: Some(300),
|
||||
});
|
||||
// Mark the game as won — HudChallenge should be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudUndos in-app tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn undos_hud_empty_at_game_start() {
|
||||
let mut app = headless_app();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudUndos>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undos_hud_shows_count_after_undo() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.force_test_undos(3);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudAutoComplete in-app tests (Task #56)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn headless_app_with_auto_complete() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(HudPlugin);
|
||||
app.init_resource::<AutoCompleteState>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_complete_badge_shows_auto_when_active() {
|
||||
let mut app = headless_app_with_auto_complete();
|
||||
app.world_mut().resource_mut::<AutoCompleteState>().active = true;
|
||||
// Also trigger game state change so the update fires.
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.set_test_move_count(1);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_complete_badge_empty_when_inactive() {
|
||||
let mut app = headless_app_with_auto_complete();
|
||||
// active is false by default.
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.set_test_move_count(1);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudRecycles in-app tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_hidden_when_zero_in_draw_one_mode() {
|
||||
let mut app = headless_app();
|
||||
// Draw-One, no recycles yet — text must be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(42, DrawStockConfig::DrawOne);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_hidden_when_zero_in_draw_three_mode() {
|
||||
let mut app = headless_app();
|
||||
// Draw-Three, no recycles yet — text must also be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(42, DrawStockConfig::DrawThree);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_shows_count_draw_three() {
|
||||
let mut app = headless_app();
|
||||
let mut gs = GameState::new(42, DrawStockConfig::DrawThree);
|
||||
gs.force_test_recycles(3);
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_shows_count_draw_one() {
|
||||
let mut app = headless_app();
|
||||
// Draw-One with recycle_count > 0 must now show the counter too.
|
||||
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
gs.force_test_recycles(2);
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Score-change feedback (G2)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Tells `TimePlugin` to advance by `secs` on every subsequent
|
||||
/// `app.update()`. Mirrors the helper in `ui_modal::tests`; kept
|
||||
/// local to avoid coupling the two test modules.
|
||||
fn set_manual_time_step(app: &mut App, secs: f32) {
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
use std::time::Duration;
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
|
||||
secs,
|
||||
)));
|
||||
}
|
||||
|
||||
/// Counts entities matching component `M` currently in the world.
|
||||
fn count_with<M: Component>(app: &mut App) -> usize {
|
||||
app.world_mut().query::<&M>().iter(app.world()).count()
|
||||
}
|
||||
|
||||
/// A score jump ≥ `SCORE_FLOATER_THRESHOLD` spawns a floating
|
||||
/// `ScoreFloater` entity coloured `ACCENT_PRIMARY`. The pulse
|
||||
/// component is also inserted on the score readout — both signals
|
||||
/// fire from the same delta detection.
|
||||
#[test]
|
||||
fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
|
||||
let mut app = headless_app();
|
||||
// Pin `Time::delta_secs()` to 0 so the floater's RGB and alpha
|
||||
// can be asserted exactly: with Automatic strategy a few ms
|
||||
// of wall-clock time leaks in between updates and the alpha
|
||||
// drifts below 1.0 by `dt / lifetime`.
|
||||
set_manual_time_step(&mut app, 0.0);
|
||||
// Initial state has score=0; bumping by 50 (the threshold)
|
||||
// is the smallest jump that triggers the floater.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||
app.update();
|
||||
|
||||
// One floater should now exist.
|
||||
let count = count_with::<ScoreFloater>(&mut app);
|
||||
assert_eq!(count, 1, "expected a single ScoreFloater for a +50 jump");
|
||||
|
||||
// Its TextColor must be ACCENT_PRIMARY at full alpha. The
|
||||
// detect system spawns the floater coloured ACCENT_PRIMARY
|
||||
// and at dt=0 the first advance tick leaves alpha = 1.0.
|
||||
let world = app.world_mut();
|
||||
let mut q = world.query::<(&ScoreFloater, &TextColor)>();
|
||||
let (_floater, color) = q.iter(world).next().expect("floater missing TextColor");
|
||||
assert_eq!(color.0, ACCENT_PRIMARY);
|
||||
}
|
||||
|
||||
/// After enough time for `MOTION_SCORE_PULSE_SECS * 2` to elapse
|
||||
/// the floater has reached the end of its lifetime and despawned.
|
||||
#[test]
|
||||
fn score_floater_despawns_after_full_lifetime() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||
app.update();
|
||||
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
||||
|
||||
// Advance by a delta well past the floater's lifetime — the
|
||||
// single oversized tick clamps t at 1.0 and the entity is
|
||||
// despawned in the same `Update`.
|
||||
set_manual_time_step(&mut app, MOTION_SCORE_PULSE_SECS * 2.0 * 2.0 + 0.1);
|
||||
app.update();
|
||||
app.update(); // first update propagates the new strategy; second runs the system with non-zero dt.
|
||||
|
||||
assert_eq!(
|
||||
count_with::<ScoreFloater>(&mut app),
|
||||
0,
|
||||
"floater should have despawned after its full lifetime"
|
||||
);
|
||||
}
|
||||
|
||||
/// A small score change (below the threshold) inserts a pulse on
|
||||
/// the readout but never spawns a floater — keeping the floating
|
||||
/// "+N" reserved for meaningful score jumps.
|
||||
#[test]
|
||||
fn score_increase_below_threshold_does_not_spawn_floater() {
|
||||
let mut app = headless_app();
|
||||
// +5 mirrors a single tableau-to-foundation move; well below
|
||||
// the 50-point threshold so the floater path stays dormant.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
count_with::<ScoreFloater>(&mut app),
|
||||
0,
|
||||
"delta of +5 must not spawn a floater"
|
||||
);
|
||||
}
|
||||
|
||||
/// The triangular pulse curve hits its peak (1.1) at t=0.5 and
|
||||
/// returns to 1.0 at the endpoints. Pure-function check that
|
||||
/// guards the curve shape against future tweaks.
|
||||
#[test]
|
||||
fn score_pulse_scale_is_triangular() {
|
||||
assert!((score_pulse_scale(0.0) - 1.0).abs() < 1e-6);
|
||||
assert!((score_pulse_scale(0.5) - 1.1).abs() < 1e-6);
|
||||
assert!((score_pulse_scale(1.0) - 1.0).abs() < 1e-6);
|
||||
// Values outside [0,1] are clamped before the curve runs.
|
||||
assert!((score_pulse_scale(-0.2) - 1.0).abs() < 1e-6);
|
||||
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// Streak flourish curve must be 1.0 at t=0, peak at t=0.5, and
|
||||
/// return to 1.0 at t=duration. Mirrors the `foundation_flourish_scale`
|
||||
/// curve test — the two animations share a triangular shape so a
|
||||
/// future tweak that desyncs them shows up here.
|
||||
#[test]
|
||||
fn streak_flourish_scale_curves_through_one_one_one() {
|
||||
let dur = MOTION_STREAK_FLOURISH_SECS;
|
||||
assert!(
|
||||
(streak_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
|
||||
"streak flourish scale at t=0 must be 1.0",
|
||||
);
|
||||
assert!(
|
||||
(streak_flourish_scale(dur / 2.0, dur) - STREAK_FLOURISH_PEAK_SCALE).abs() < 1e-5,
|
||||
"streak flourish scale at midpoint must be STREAK_FLOURISH_PEAK_SCALE",
|
||||
);
|
||||
assert!(
|
||||
(streak_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
|
||||
"streak flourish scale at t=duration must return to 1.0",
|
||||
);
|
||||
}
|
||||
|
||||
/// Out-of-range values are clamped, not extrapolated. Matches the
|
||||
/// foundation flourish's clamp behaviour so the score readout never
|
||||
/// freezes at a non-1.0 scale on the frame after the flourish ends.
|
||||
#[test]
|
||||
fn streak_flourish_scale_clamps_out_of_range() {
|
||||
let dur = MOTION_STREAK_FLOURISH_SECS;
|
||||
assert!((streak_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
|
||||
assert!((streak_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
|
||||
/// divides by zero.
|
||||
#[test]
|
||||
fn streak_flourish_scale_zero_duration_is_one() {
|
||||
assert!((streak_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
|
||||
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
|
||||
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
|
||||
#[test]
|
||||
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
|
||||
use solitaire_data::Settings;
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
}));
|
||||
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
count_with::<ScorePulse>(&mut app),
|
||||
0,
|
||||
"ScorePulse must not spawn under reduce-motion"
|
||||
);
|
||||
assert_eq!(
|
||||
count_with::<ScoreFloater>(&mut app),
|
||||
0,
|
||||
"ScoreFloater must not spawn under reduce-motion"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 2: keyboard focus ring — HUD action bar
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Returns the `Focusable` carried by the unique entity matching
|
||||
/// marker `M`. Helper for the HUD focus tests.
|
||||
fn focusable_for<M: Component>(app: &mut App) -> Focusable {
|
||||
app.world_mut()
|
||||
.query_filtered::<&Focusable, With<M>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.copied()
|
||||
.unwrap_or_else(|| panic!("no Focusable on the {} button", std::any::type_name::<M>()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_buttons_get_focusable_marker() {
|
||||
let mut app = headless_app();
|
||||
// Every action-bar button is in `FocusGroup::Hud`.
|
||||
for f in [
|
||||
focusable_for::<MenuButton>(&mut app),
|
||||
focusable_for::<UndoButton>(&mut app),
|
||||
focusable_for::<PauseButton>(&mut app),
|
||||
focusable_for::<HelpButton>(&mut app),
|
||||
focusable_for::<HintButton>(&mut app),
|
||||
focusable_for::<ModesButton>(&mut app),
|
||||
focusable_for::<NewGameButton>(&mut app),
|
||||
] {
|
||||
assert_eq!(
|
||||
f.group,
|
||||
FocusGroup::Hud,
|
||||
"every HUD action button must be in FocusGroup::Hud"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the tooltip string carried by the unique entity matching
|
||||
/// marker `M`. Panics if zero or more than one such entity exists,
|
||||
/// which is the invariant we want to enforce for HUD readouts and
|
||||
/// action buttons (each marker is spawned exactly once).
|
||||
fn tooltip_for<M: Component>(app: &mut App) -> String {
|
||||
let mut q = app.world_mut().query_filtered::<&Tooltip, With<M>>();
|
||||
let world = app.world();
|
||||
let mut iter = q.iter(world);
|
||||
let first = iter
|
||||
.next()
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expected a Tooltip on the {} entity",
|
||||
std::any::type_name::<M>()
|
||||
)
|
||||
})
|
||||
.0
|
||||
.clone()
|
||||
.into_owned();
|
||||
assert!(
|
||||
iter.next().is_none(),
|
||||
"expected exactly one Tooltip-bearing entity for {}",
|
||||
std::any::type_name::<M>()
|
||||
);
|
||||
first
|
||||
}
|
||||
|
||||
/// Every HUD readout and action button must spawn with a `Tooltip`
|
||||
/// carrying the approved canonical microcopy. Mirrors the structure
|
||||
/// of `hud_buttons_get_focusable_marker` (Phase 2 focus test) so the
|
||||
/// invariant — one marker entity, one tooltip, exact text — is
|
||||
/// asserted consistently across every element.
|
||||
#[test]
|
||||
fn hud_elements_carry_expected_tooltip_strings() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// HUD readouts (left column, top to bottom).
|
||||
assert_eq!(
|
||||
tooltip_for::<HudScore>(&mut app),
|
||||
"Points earned this game. Hidden in Zen mode."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudMoves>(&mut app),
|
||||
"Moves you've made this game. Counts placements and stock draws."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudTime>(&mut app),
|
||||
"Time on this game. Counts down in Time Attack."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudMode>(&mut app),
|
||||
"Active game mode. Click Modes to switch."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudChallenge>(&mut app),
|
||||
"Today's daily challenge target. Beat it for bonus XP."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudDrawCycle>(&mut app),
|
||||
"Cards drawn on the next stock click in Draw-Three."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudUndos>(&mut app),
|
||||
"Undos used this game. Any undo blocks the No Undo achievement."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudRecycles>(&mut app),
|
||||
"Times you've recycled the stock. Three or more unlocks Comeback."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudAutoComplete>(&mut app),
|
||||
"Board is solvable from here. Press Enter to auto-finish."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudSelection>(&mut app),
|
||||
"Pile selected with Tab. Use arrows or Enter to act."
|
||||
);
|
||||
|
||||
// Action bar (left to right).
|
||||
assert_eq!(
|
||||
tooltip_for::<MenuButton>(&mut app),
|
||||
"Open Stats, Achievements, Profile, Settings, or Leaderboard."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<UndoButton>(&mut app),
|
||||
"Take back your last move. Costs points and blocks No Undo."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<PauseButton>(&mut app),
|
||||
"Pause the game and freeze the timer."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HelpButton>(&mut app),
|
||||
"Show controls, rules, and keyboard shortcuts."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HintButton>(&mut app),
|
||||
"Highlight a suggested move. Cycles through alternatives on repeat taps."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<ModesButton>(&mut app),
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<NewGameButton>(&mut app),
|
||||
"Start a fresh deal. Confirms first if a game is in progress."
|
||||
);
|
||||
}
|
||||
|
||||
/// Every interior row of the Modes and Menu popovers must carry a
|
||||
/// `Tooltip`. The popovers open from action-bar buttons whose own
|
||||
/// tooltips are already covered above; this test extends the
|
||||
/// invariant inward so hover discoverability is uniform across the
|
||||
/// HUD's nested controls.
|
||||
///
|
||||
/// We invoke the popover spawn helpers directly with a maxed-out
|
||||
/// `ProgressResource` and a `DailyChallengeResource` so every row
|
||||
/// branch fires (Classic, Daily, Zen, Challenge, Time Attack).
|
||||
/// Headless click simulation isn't needed — the contract under
|
||||
/// test is "every popover row spawns with a tooltip", which is a
|
||||
/// property of the spawn helpers themselves.
|
||||
#[test]
|
||||
fn popover_rows_carry_tooltip_strings() {
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use solitaire_sync::progress::PlayerProgress;
|
||||
|
||||
let mut app = headless_app();
|
||||
|
||||
// Force every mode row to render: level past the challenge
|
||||
// unlock threshold, plus a daily challenge resource so the
|
||||
// Daily row appears.
|
||||
let progress = ProgressResource(PlayerProgress {
|
||||
level: CHALLENGE_UNLOCK_LEVEL,
|
||||
..Default::default()
|
||||
});
|
||||
let daily = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: None,
|
||||
};
|
||||
|
||||
// Spawn both popovers via their helpers. Mirrors how the click
|
||||
// handlers invoke them in production — we just skip the click.
|
||||
{
|
||||
let world = app.world_mut();
|
||||
let mut commands = world.commands();
|
||||
spawn_modes_popover(&mut commands, Some(&progress), Some(&daily), None);
|
||||
spawn_menu_popover(&mut commands, None);
|
||||
world.flush();
|
||||
}
|
||||
app.update();
|
||||
|
||||
// Every ModeOption-tagged entity must also carry a Tooltip,
|
||||
// and the count must match the five canonical modes.
|
||||
let mut mode_q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Tooltip, With<ModeOption>>();
|
||||
let mode_tooltips: Vec<String> = mode_q
|
||||
.iter(app.world())
|
||||
.map(|t| t.0.clone().into_owned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
mode_tooltips.len(),
|
||||
5,
|
||||
"expected a tooltip on each of the 5 mode rows, got {}",
|
||||
mode_tooltips.len()
|
||||
);
|
||||
// Every approved mode tooltip string must be present somewhere
|
||||
// among the ModeOption rows. Order isn't asserted — the spawn
|
||||
// order test elsewhere already covers that.
|
||||
for expected in [
|
||||
"Standard Klondike. Score, timer, and full progression.",
|
||||
"Today's seeded deal. Same for every player worldwide.",
|
||||
"No timer, no score, no penalties. Just play.",
|
||||
"Hand-picked hard seeds. No undo allowed.",
|
||||
"Win as many games as you can in ten minutes.",
|
||||
] {
|
||||
assert!(
|
||||
mode_tooltips.iter().any(|s| s == expected),
|
||||
"missing mode tooltip: {expected:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Same contract for MenuOption rows: seven entries, each with a
|
||||
// tooltip, exact strings matching the approved microcopy.
|
||||
let mut menu_q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Tooltip, With<MenuOption>>();
|
||||
let menu_tooltips: Vec<String> = menu_q
|
||||
.iter(app.world())
|
||||
.map(|t| t.0.clone().into_owned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
menu_tooltips.len(),
|
||||
7,
|
||||
"expected a tooltip on each of the 7 menu rows, got {}",
|
||||
menu_tooltips.len()
|
||||
);
|
||||
for expected in [
|
||||
"Show controls, rules, and keyboard shortcuts.",
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||
"Browse unlocked achievements and the rewards still ahead.",
|
||||
"Your level, XP progress, and sync status.",
|
||||
"Audio, animations, theme, draw mode, and sync.",
|
||||
"Top players from your sync server. Opt in from Profile.",
|
||||
] {
|
||||
assert!(
|
||||
menu_tooltips.iter().any(|s| s == expected),
|
||||
"missing menu tooltip: {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_button_order_matches_spawn_order() {
|
||||
let mut app = headless_app();
|
||||
// Visual reading order (left → right): Menu, Undo, Pause, Help,
|
||||
// Hint, Modes, New Game. Their `order` fields must be 0..=6 in
|
||||
// that order so Tab cycles them as the player reads them.
|
||||
assert_eq!(focusable_for::<MenuButton>(&mut app).order, 0);
|
||||
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
|
||||
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
|
||||
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
|
||||
assert_eq!(focusable_for::<HintButton>(&mut app).order, 4);
|
||||
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 5);
|
||||
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_focus_only_engages_when_button_hovered() {
|
||||
// Phase 2 declares membership in `FocusGroup::Hud`; the
|
||||
// engagement rule lives in `handle_focus_keys`. Two halves to
|
||||
// this test:
|
||||
// (a) no modal + no hover ⇒ Tab is a no-op (Phase 1 contract
|
||||
// still holds when nothing is hovered).
|
||||
// (b) no modal + a HUD button hovered ⇒ Tab advances
|
||||
// `FocusedButton` to a Hud-grouped entity.
|
||||
use crate::ui_focus::{FocusedButton, UiFocusPlugin};
|
||||
use crate::ui_modal::UiModalPlugin;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(HudPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
|
||||
// (a) Sanity: HUD buttons exist and are focusable, but no
|
||||
// modal open and no hover ⇒ FocusedButton stays None.
|
||||
assert!(
|
||||
app.world().resource::<FocusedButton>().0.is_none(),
|
||||
"no modal open, no auto-focus"
|
||||
);
|
||||
|
||||
// Press Tab. With no modal and no hover, `handle_focus_keys`
|
||||
// resolves no active group and returns early — Tab must not
|
||||
// advance the HUD focus ring on its own.
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release_all();
|
||||
input.clear();
|
||||
input.press(KeyCode::Tab);
|
||||
}
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().resource::<FocusedButton>().0.is_none(),
|
||||
"Tab with no modal and no Hud hover must not engage the HUD focus ring"
|
||||
);
|
||||
|
||||
// (b) Hover the Menu button — the leftmost HUD action — and
|
||||
// Tab. The Hud-group cycle should pick a Hud-tagged entity.
|
||||
let menu_entity = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<MenuButton>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("MenuButton entity should exist");
|
||||
app.world_mut()
|
||||
.entity_mut(menu_entity)
|
||||
.insert(Interaction::Hovered);
|
||||
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release_all();
|
||||
input.clear();
|
||||
input.press(KeyCode::Tab);
|
||||
}
|
||||
app.update();
|
||||
|
||||
let focused = app
|
||||
.world()
|
||||
.resource::<FocusedButton>()
|
||||
.0
|
||||
.expect("Tab with a HUD button hovered must engage the HUD focus ring");
|
||||
// The focused entity must itself be Hud-grouped (i.e. one of
|
||||
// the action-bar buttons), not anything else in the world.
|
||||
let focusable = app
|
||||
.world()
|
||||
.entity(focused)
|
||||
.get::<Focusable>()
|
||||
.expect("focused entity must carry Focusable");
|
||||
assert_eq!(
|
||||
focusable.group,
|
||||
FocusGroup::Hud,
|
||||
"Hud-engaged Tab must focus a Hud-grouped entity"
|
||||
);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,872 @@
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use chrono::Local;
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(HudPlugin);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_plugin_registers_without_panic() {
|
||||
let _app = headless_app();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_hud_runs_after_game_mutation_without_panic() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(42, DrawStockConfig::DrawOne);
|
||||
app.update();
|
||||
}
|
||||
|
||||
fn read_hud_text<M: Component>(app: &mut App) -> String {
|
||||
app.world_mut()
|
||||
.query_filtered::<&Text, With<M>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.map(|t| t.0.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_reflects_game_state() {
|
||||
let mut app = headless_app();
|
||||
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_reflects_game_state() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.set_test_move_count(42);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_mode_shows_draw_3_badge() {
|
||||
use solitaire_core::game_state::GameMode;
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(42, DrawStockConfig::DrawThree, GameMode::Classic);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudMode>(&mut app), "Draw 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zen_mode_hides_score() {
|
||||
use solitaire_core::game_state::GameMode;
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(42, DrawStockConfig::DrawOne, GameMode::Zen);
|
||||
app.update();
|
||||
// Zen mode spec: "No score display" → text must be empty.
|
||||
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_display_uses_mm_ss_format() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.elapsed_seconds = 125;
|
||||
app.update();
|
||||
// 125 seconds = 2 minutes 5 seconds → "2:05"
|
||||
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// format_time_limit (pure function)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn format_time_limit_300_is_5_00() {
|
||||
assert_eq!(format_time_limit(300), "5:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_time_limit_zero() {
|
||||
assert_eq!(format_time_limit(0), "0:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_time_limit_pads_seconds() {
|
||||
assert_eq!(format_time_limit(65), "1:05");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// challenge_hud_text (pure function)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_text_shows_time_limit() {
|
||||
let dc = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: Some(300),
|
||||
};
|
||||
assert_eq!(challenge_hud_text(&dc), "Limit: 5:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_text_shows_score_goal() {
|
||||
let dc = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: Some(4000),
|
||||
max_time_secs: None,
|
||||
};
|
||||
assert_eq!(challenge_hud_text(&dc), "Goal: 4000 pts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_text_empty_when_no_constraints() {
|
||||
let dc = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: None,
|
||||
};
|
||||
assert_eq!(challenge_hud_text(&dc), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_above_60_is_info() {
|
||||
let c = challenge_time_color(61);
|
||||
assert_eq!(c, STATE_INFO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_exactly_60_is_info() {
|
||||
let c = challenge_time_color(60);
|
||||
assert_eq!(c, STATE_INFO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_59_is_warning() {
|
||||
let c = challenge_time_color(59);
|
||||
assert_eq!(c, STATE_WARNING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_30_is_warning() {
|
||||
let c = challenge_time_color(30);
|
||||
assert_eq!(c, STATE_WARNING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_29_is_danger() {
|
||||
let c = challenge_time_color(29);
|
||||
assert_eq!(c, STATE_DANGER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_zero_is_danger() {
|
||||
let c = challenge_time_color(0);
|
||||
assert_eq!(c, STATE_DANGER);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudChallenge in-app tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_empty_when_no_daily_resource() {
|
||||
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_shows_time_limit_when_resource_present() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().insert_resource(DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 42,
|
||||
goal_description: Some("Win fast".to_string()),
|
||||
target_score: None,
|
||||
max_time_secs: Some(300),
|
||||
});
|
||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_shows_score_goal_when_resource_present() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().insert_resource(DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 42,
|
||||
goal_description: None,
|
||||
target_score: Some(4000),
|
||||
max_time_secs: None,
|
||||
});
|
||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_clears_on_win() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().insert_resource(DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 42,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: Some(300),
|
||||
});
|
||||
// Mark the game as won — HudChallenge should be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudUndos in-app tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn undos_hud_empty_at_game_start() {
|
||||
let mut app = headless_app();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudUndos>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undos_hud_shows_count_after_undo() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.force_test_undos(3);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudAutoComplete in-app tests (Task #56)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn headless_app_with_auto_complete() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(HudPlugin);
|
||||
app.init_resource::<AutoCompleteState>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_complete_badge_shows_auto_when_active() {
|
||||
let mut app = headless_app_with_auto_complete();
|
||||
app.world_mut().resource_mut::<AutoCompleteState>().active = true;
|
||||
// Also trigger game state change so the update fires.
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.set_test_move_count(1);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_complete_badge_empty_when_inactive() {
|
||||
let mut app = headless_app_with_auto_complete();
|
||||
// active is false by default.
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.set_test_move_count(1);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudRecycles in-app tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_hidden_when_zero_in_draw_one_mode() {
|
||||
let mut app = headless_app();
|
||||
// Draw-One, no recycles yet — text must be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(42, DrawStockConfig::DrawOne);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_hidden_when_zero_in_draw_three_mode() {
|
||||
let mut app = headless_app();
|
||||
// Draw-Three, no recycles yet — text must also be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(42, DrawStockConfig::DrawThree);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_shows_count_draw_three() {
|
||||
let mut app = headless_app();
|
||||
let mut gs = GameState::new(42, DrawStockConfig::DrawThree);
|
||||
gs.force_test_recycles(3);
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_shows_count_draw_one() {
|
||||
let mut app = headless_app();
|
||||
// Draw-One with recycle_count > 0 must now show the counter too.
|
||||
let mut gs = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
gs.force_test_recycles(2);
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Score-change feedback (G2)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Tells `TimePlugin` to advance by `secs` on every subsequent
|
||||
/// `app.update()`. Mirrors the helper in `ui_modal::tests`; kept
|
||||
/// local to avoid coupling the two test modules.
|
||||
fn set_manual_time_step(app: &mut App, secs: f32) {
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
use std::time::Duration;
|
||||
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
|
||||
secs,
|
||||
)));
|
||||
}
|
||||
|
||||
/// Counts entities matching component `M` currently in the world.
|
||||
fn count_with<M: Component>(app: &mut App) -> usize {
|
||||
app.world_mut().query::<&M>().iter(app.world()).count()
|
||||
}
|
||||
|
||||
/// A score jump ≥ `SCORE_FLOATER_THRESHOLD` spawns a floating
|
||||
/// `ScoreFloater` entity coloured `ACCENT_PRIMARY`. The pulse
|
||||
/// component is also inserted on the score readout — both signals
|
||||
/// fire from the same delta detection.
|
||||
#[test]
|
||||
fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
|
||||
let mut app = headless_app();
|
||||
// Pin `Time::delta_secs()` to 0 so the floater's RGB and alpha
|
||||
// can be asserted exactly: with Automatic strategy a few ms
|
||||
// of wall-clock time leaks in between updates and the alpha
|
||||
// drifts below 1.0 by `dt / lifetime`.
|
||||
set_manual_time_step(&mut app, 0.0);
|
||||
// Initial state has score=0; bumping by 50 (the threshold)
|
||||
// is the smallest jump that triggers the floater.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||
app.update();
|
||||
|
||||
// One floater should now exist.
|
||||
let count = count_with::<ScoreFloater>(&mut app);
|
||||
assert_eq!(count, 1, "expected a single ScoreFloater for a +50 jump");
|
||||
|
||||
// Its TextColor must be ACCENT_PRIMARY at full alpha. The
|
||||
// detect system spawns the floater coloured ACCENT_PRIMARY
|
||||
// and at dt=0 the first advance tick leaves alpha = 1.0.
|
||||
let world = app.world_mut();
|
||||
let mut q = world.query::<(&ScoreFloater, &TextColor)>();
|
||||
let (_floater, color) = q.iter(world).next().expect("floater missing TextColor");
|
||||
assert_eq!(color.0, ACCENT_PRIMARY);
|
||||
}
|
||||
|
||||
/// After enough time for `MOTION_SCORE_PULSE_SECS * 2` to elapse
|
||||
/// the floater has reached the end of its lifetime and despawned.
|
||||
#[test]
|
||||
fn score_floater_despawns_after_full_lifetime() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||
app.update();
|
||||
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
||||
|
||||
// Advance by a delta well past the floater's lifetime — the
|
||||
// single oversized tick clamps t at 1.0 and the entity is
|
||||
// despawned in the same `Update`.
|
||||
set_manual_time_step(&mut app, MOTION_SCORE_PULSE_SECS * 2.0 * 2.0 + 0.1);
|
||||
app.update();
|
||||
app.update(); // first update propagates the new strategy; second runs the system with non-zero dt.
|
||||
|
||||
assert_eq!(
|
||||
count_with::<ScoreFloater>(&mut app),
|
||||
0,
|
||||
"floater should have despawned after its full lifetime"
|
||||
);
|
||||
}
|
||||
|
||||
/// A small score change (below the threshold) inserts a pulse on
|
||||
/// the readout but never spawns a floater — keeping the floating
|
||||
/// "+N" reserved for meaningful score jumps.
|
||||
#[test]
|
||||
fn score_increase_below_threshold_does_not_spawn_floater() {
|
||||
let mut app = headless_app();
|
||||
// +5 mirrors a single tableau-to-foundation move; well below
|
||||
// the 50-point threshold so the floater path stays dormant.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
count_with::<ScoreFloater>(&mut app),
|
||||
0,
|
||||
"delta of +5 must not spawn a floater"
|
||||
);
|
||||
}
|
||||
|
||||
/// The triangular pulse curve hits its peak (1.1) at t=0.5 and
|
||||
/// returns to 1.0 at the endpoints. Pure-function check that
|
||||
/// guards the curve shape against future tweaks.
|
||||
#[test]
|
||||
fn score_pulse_scale_is_triangular() {
|
||||
assert!((score_pulse_scale(0.0) - 1.0).abs() < 1e-6);
|
||||
assert!((score_pulse_scale(0.5) - 1.1).abs() < 1e-6);
|
||||
assert!((score_pulse_scale(1.0) - 1.0).abs() < 1e-6);
|
||||
// Values outside [0,1] are clamped before the curve runs.
|
||||
assert!((score_pulse_scale(-0.2) - 1.0).abs() < 1e-6);
|
||||
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// Streak flourish curve must be 1.0 at t=0, peak at t=0.5, and
|
||||
/// return to 1.0 at t=duration. Mirrors the `foundation_flourish_scale`
|
||||
/// curve test — the two animations share a triangular shape so a
|
||||
/// future tweak that desyncs them shows up here.
|
||||
#[test]
|
||||
fn streak_flourish_scale_curves_through_one_one_one() {
|
||||
let dur = MOTION_STREAK_FLOURISH_SECS;
|
||||
assert!(
|
||||
(streak_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
|
||||
"streak flourish scale at t=0 must be 1.0",
|
||||
);
|
||||
assert!(
|
||||
(streak_flourish_scale(dur / 2.0, dur) - STREAK_FLOURISH_PEAK_SCALE).abs() < 1e-5,
|
||||
"streak flourish scale at midpoint must be STREAK_FLOURISH_PEAK_SCALE",
|
||||
);
|
||||
assert!(
|
||||
(streak_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
|
||||
"streak flourish scale at t=duration must return to 1.0",
|
||||
);
|
||||
}
|
||||
|
||||
/// Out-of-range values are clamped, not extrapolated. Matches the
|
||||
/// foundation flourish's clamp behaviour so the score readout never
|
||||
/// freezes at a non-1.0 scale on the frame after the flourish ends.
|
||||
#[test]
|
||||
fn streak_flourish_scale_clamps_out_of_range() {
|
||||
let dur = MOTION_STREAK_FLOURISH_SECS;
|
||||
assert!((streak_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
|
||||
assert!((streak_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
|
||||
/// divides by zero.
|
||||
#[test]
|
||||
fn streak_flourish_scale_zero_duration_is_one() {
|
||||
assert!((streak_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
|
||||
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
|
||||
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
|
||||
#[test]
|
||||
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
|
||||
use solitaire_data::Settings;
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
}));
|
||||
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
count_with::<ScorePulse>(&mut app),
|
||||
0,
|
||||
"ScorePulse must not spawn under reduce-motion"
|
||||
);
|
||||
assert_eq!(
|
||||
count_with::<ScoreFloater>(&mut app),
|
||||
0,
|
||||
"ScoreFloater must not spawn under reduce-motion"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 2: keyboard focus ring — HUD action bar
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Returns the `Focusable` carried by the unique entity matching
|
||||
/// marker `M`. Helper for the HUD focus tests.
|
||||
fn focusable_for<M: Component>(app: &mut App) -> Focusable {
|
||||
app.world_mut()
|
||||
.query_filtered::<&Focusable, With<M>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.copied()
|
||||
.unwrap_or_else(|| panic!("no Focusable on the {} button", std::any::type_name::<M>()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_buttons_get_focusable_marker() {
|
||||
let mut app = headless_app();
|
||||
// Every action-bar button is in `FocusGroup::Hud`.
|
||||
for f in [
|
||||
focusable_for::<MenuButton>(&mut app),
|
||||
focusable_for::<UndoButton>(&mut app),
|
||||
focusable_for::<PauseButton>(&mut app),
|
||||
focusable_for::<HelpButton>(&mut app),
|
||||
focusable_for::<HintButton>(&mut app),
|
||||
focusable_for::<ModesButton>(&mut app),
|
||||
focusable_for::<NewGameButton>(&mut app),
|
||||
] {
|
||||
assert_eq!(
|
||||
f.group,
|
||||
FocusGroup::Hud,
|
||||
"every HUD action button must be in FocusGroup::Hud"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the tooltip string carried by the unique entity matching
|
||||
/// marker `M`. Panics if zero or more than one such entity exists,
|
||||
/// which is the invariant we want to enforce for HUD readouts and
|
||||
/// action buttons (each marker is spawned exactly once).
|
||||
fn tooltip_for<M: Component>(app: &mut App) -> String {
|
||||
let mut q = app.world_mut().query_filtered::<&Tooltip, With<M>>();
|
||||
let world = app.world();
|
||||
let mut iter = q.iter(world);
|
||||
let first = iter
|
||||
.next()
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expected a Tooltip on the {} entity",
|
||||
std::any::type_name::<M>()
|
||||
)
|
||||
})
|
||||
.0
|
||||
.clone()
|
||||
.into_owned();
|
||||
assert!(
|
||||
iter.next().is_none(),
|
||||
"expected exactly one Tooltip-bearing entity for {}",
|
||||
std::any::type_name::<M>()
|
||||
);
|
||||
first
|
||||
}
|
||||
|
||||
/// Every HUD readout and action button must spawn with a `Tooltip`
|
||||
/// carrying the approved canonical microcopy. Mirrors the structure
|
||||
/// of `hud_buttons_get_focusable_marker` (Phase 2 focus test) so the
|
||||
/// invariant — one marker entity, one tooltip, exact text — is
|
||||
/// asserted consistently across every element.
|
||||
#[test]
|
||||
fn hud_elements_carry_expected_tooltip_strings() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// HUD readouts (left column, top to bottom).
|
||||
assert_eq!(
|
||||
tooltip_for::<HudScore>(&mut app),
|
||||
"Points earned this game. Hidden in Zen mode."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudMoves>(&mut app),
|
||||
"Moves you've made this game. Counts placements and stock draws."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudTime>(&mut app),
|
||||
"Time on this game. Counts down in Time Attack."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudMode>(&mut app),
|
||||
"Active game mode. Click Modes to switch."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudChallenge>(&mut app),
|
||||
"Today's daily challenge target. Beat it for bonus XP."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudDrawCycle>(&mut app),
|
||||
"Cards drawn on the next stock click in Draw-Three."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudUndos>(&mut app),
|
||||
"Undos used this game. Any undo blocks the No Undo achievement."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudRecycles>(&mut app),
|
||||
"Times you've recycled the stock. Three or more unlocks Comeback."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudAutoComplete>(&mut app),
|
||||
"Board is solvable from here. Press Enter to auto-finish."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HudSelection>(&mut app),
|
||||
"Pile selected with Tab. Use arrows or Enter to act."
|
||||
);
|
||||
|
||||
// Action bar (left to right).
|
||||
assert_eq!(
|
||||
tooltip_for::<MenuButton>(&mut app),
|
||||
"Open Stats, Achievements, Profile, Settings, or Leaderboard."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<UndoButton>(&mut app),
|
||||
"Take back your last move. Costs points and blocks No Undo."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<PauseButton>(&mut app),
|
||||
"Pause the game and freeze the timer."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HelpButton>(&mut app),
|
||||
"Show controls, rules, and keyboard shortcuts."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<HintButton>(&mut app),
|
||||
"Highlight a suggested move. Cycles through alternatives on repeat taps."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<ModesButton>(&mut app),
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack."
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for::<NewGameButton>(&mut app),
|
||||
"Start a fresh deal. Confirms first if a game is in progress."
|
||||
);
|
||||
}
|
||||
|
||||
/// Every interior row of the Modes and Menu popovers must carry a
|
||||
/// `Tooltip`. The popovers open from action-bar buttons whose own
|
||||
/// tooltips are already covered above; this test extends the
|
||||
/// invariant inward so hover discoverability is uniform across the
|
||||
/// HUD's nested controls.
|
||||
///
|
||||
/// We invoke the popover spawn helpers directly with a maxed-out
|
||||
/// `ProgressResource` and a `DailyChallengeResource` so every row
|
||||
/// branch fires (Classic, Daily, Zen, Challenge, Time Attack).
|
||||
/// Headless click simulation isn't needed — the contract under
|
||||
/// test is "every popover row spawns with a tooltip", which is a
|
||||
/// property of the spawn helpers themselves.
|
||||
#[test]
|
||||
fn popover_rows_carry_tooltip_strings() {
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use solitaire_sync::progress::PlayerProgress;
|
||||
|
||||
let mut app = headless_app();
|
||||
|
||||
// Force every mode row to render: level past the challenge
|
||||
// unlock threshold, plus a daily challenge resource so the
|
||||
// Daily row appears.
|
||||
let progress = ProgressResource(PlayerProgress {
|
||||
level: CHALLENGE_UNLOCK_LEVEL,
|
||||
..Default::default()
|
||||
});
|
||||
let daily = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: None,
|
||||
};
|
||||
|
||||
// Spawn both popovers via their helpers. Mirrors how the click
|
||||
// handlers invoke them in production — we just skip the click.
|
||||
{
|
||||
let world = app.world_mut();
|
||||
let mut commands = world.commands();
|
||||
spawn_modes_popover(&mut commands, Some(&progress), Some(&daily), None);
|
||||
spawn_menu_popover(&mut commands, None);
|
||||
world.flush();
|
||||
}
|
||||
app.update();
|
||||
|
||||
// Every ModeOption-tagged entity must also carry a Tooltip,
|
||||
// and the count must match the five canonical modes.
|
||||
let mut mode_q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Tooltip, With<ModeOption>>();
|
||||
let mode_tooltips: Vec<String> = mode_q
|
||||
.iter(app.world())
|
||||
.map(|t| t.0.clone().into_owned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
mode_tooltips.len(),
|
||||
5,
|
||||
"expected a tooltip on each of the 5 mode rows, got {}",
|
||||
mode_tooltips.len()
|
||||
);
|
||||
// Every approved mode tooltip string must be present somewhere
|
||||
// among the ModeOption rows. Order isn't asserted — the spawn
|
||||
// order test elsewhere already covers that.
|
||||
for expected in [
|
||||
"Standard Klondike. Score, timer, and full progression.",
|
||||
"Today's seeded deal. Same for every player worldwide.",
|
||||
"No timer, no score, no penalties. Just play.",
|
||||
"Hand-picked hard seeds. No undo allowed.",
|
||||
"Win as many games as you can in ten minutes.",
|
||||
] {
|
||||
assert!(
|
||||
mode_tooltips.iter().any(|s| s == expected),
|
||||
"missing mode tooltip: {expected:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Same contract for MenuOption rows: seven entries, each with a
|
||||
// tooltip, exact strings matching the approved microcopy.
|
||||
let mut menu_q = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Tooltip, With<MenuOption>>();
|
||||
let menu_tooltips: Vec<String> = menu_q
|
||||
.iter(app.world())
|
||||
.map(|t| t.0.clone().into_owned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
menu_tooltips.len(),
|
||||
7,
|
||||
"expected a tooltip on each of the 7 menu rows, got {}",
|
||||
menu_tooltips.len()
|
||||
);
|
||||
for expected in [
|
||||
"Show controls, rules, and keyboard shortcuts.",
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||
"Browse unlocked achievements and the rewards still ahead.",
|
||||
"Your level, XP progress, and sync status.",
|
||||
"Audio, animations, theme, draw mode, and sync.",
|
||||
"Top players from your sync server. Opt in from Profile.",
|
||||
] {
|
||||
assert!(
|
||||
menu_tooltips.iter().any(|s| s == expected),
|
||||
"missing menu tooltip: {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_button_order_matches_spawn_order() {
|
||||
let mut app = headless_app();
|
||||
// Visual reading order (left → right): Menu, Undo, Pause, Help,
|
||||
// Hint, Modes, New Game. Their `order` fields must be 0..=6 in
|
||||
// that order so Tab cycles them as the player reads them.
|
||||
assert_eq!(focusable_for::<MenuButton>(&mut app).order, 0);
|
||||
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
|
||||
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
|
||||
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
|
||||
assert_eq!(focusable_for::<HintButton>(&mut app).order, 4);
|
||||
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 5);
|
||||
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_focus_only_engages_when_button_hovered() {
|
||||
// Phase 2 declares membership in `FocusGroup::Hud`; the
|
||||
// engagement rule lives in `handle_focus_keys`. Two halves to
|
||||
// this test:
|
||||
// (a) no modal + no hover ⇒ Tab is a no-op (Phase 1 contract
|
||||
// still holds when nothing is hovered).
|
||||
// (b) no modal + a HUD button hovered ⇒ Tab advances
|
||||
// `FocusedButton` to a Hud-grouped entity.
|
||||
use crate::ui_focus::{FocusedButton, UiFocusPlugin};
|
||||
use crate::ui_modal::UiModalPlugin;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(HudPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
|
||||
// (a) Sanity: HUD buttons exist and are focusable, but no
|
||||
// modal open and no hover ⇒ FocusedButton stays None.
|
||||
assert!(
|
||||
app.world().resource::<FocusedButton>().0.is_none(),
|
||||
"no modal open, no auto-focus"
|
||||
);
|
||||
|
||||
// Press Tab. With no modal and no hover, `handle_focus_keys`
|
||||
// resolves no active group and returns early — Tab must not
|
||||
// advance the HUD focus ring on its own.
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release_all();
|
||||
input.clear();
|
||||
input.press(KeyCode::Tab);
|
||||
}
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().resource::<FocusedButton>().0.is_none(),
|
||||
"Tab with no modal and no Hud hover must not engage the HUD focus ring"
|
||||
);
|
||||
|
||||
// (b) Hover the Menu button — the leftmost HUD action — and
|
||||
// Tab. The Hud-group cycle should pick a Hud-tagged entity.
|
||||
let menu_entity = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<MenuButton>>()
|
||||
.iter(app.world())
|
||||
.next()
|
||||
.expect("MenuButton entity should exist");
|
||||
app.world_mut()
|
||||
.entity_mut(menu_entity)
|
||||
.insert(Interaction::Hovered);
|
||||
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release_all();
|
||||
input.clear();
|
||||
input.press(KeyCode::Tab);
|
||||
}
|
||||
app.update();
|
||||
|
||||
let focused = app
|
||||
.world()
|
||||
.resource::<FocusedButton>()
|
||||
.0
|
||||
.expect("Tab with a HUD button hovered must engage the HUD focus ring");
|
||||
// The focused entity must itself be Hud-grouped (i.e. one of
|
||||
// the action-bar buttons), not anything else in the world.
|
||||
let focusable = app
|
||||
.world()
|
||||
.entity(focused)
|
||||
.get::<Focusable>()
|
||||
.expect("focused entity must carry Focusable");
|
||||
assert_eq!(
|
||||
focusable.group,
|
||||
FocusGroup::Hud,
|
||||
"Hud-engaged Tab must focus a Hud-grouped entity"
|
||||
);
|
||||
}
|
||||
@@ -1829,712 +1829,4 @@ pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile)> {
|
||||
const _VEC3_REFERENCED: Option<Vec3> = None;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layout::compute_layout;
|
||||
use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
fn clear_test_piles(game: &mut GameState) {
|
||||
game.set_test_stock_cards(Vec::new());
|
||||
game.set_test_waste_cards(Vec::new());
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
game.set_test_foundation_cards(foundation, Vec::new());
|
||||
}
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
game.set_test_tableau_cards(tableau, Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragged_card_z_matches_resting_stack_step() {
|
||||
assert!((dragged_card_z(0) - DRAG_Z).abs() < 1e-6);
|
||||
let step = dragged_card_z(1) - dragged_card_z(0);
|
||||
assert!(
|
||||
step > 0.02,
|
||||
"drag step must exceed Android overlay local_z, got {step}"
|
||||
);
|
||||
assert!(
|
||||
step + 1e-4 >= STACK_FAN_FRAC,
|
||||
"drag step must stay aligned with resting stack spacing, got {step}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_inside_returns_true() {
|
||||
let center = Vec2::new(10.0, 20.0);
|
||||
let size = Vec2::new(40.0, 60.0);
|
||||
assert!(point_in_rect(Vec2::new(10.0, 20.0), center, size));
|
||||
assert!(point_in_rect(Vec2::new(29.0, 49.0), center, size));
|
||||
assert!(point_in_rect(Vec2::new(-9.0, -9.0), center, size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_on_edge_returns_true() {
|
||||
let center = Vec2::ZERO;
|
||||
let size = Vec2::new(10.0, 10.0);
|
||||
assert!(point_in_rect(Vec2::new(5.0, 5.0), center, size));
|
||||
assert!(point_in_rect(Vec2::new(-5.0, -5.0), center, size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_outside_returns_false() {
|
||||
let center = Vec2::ZERO;
|
||||
let size = Vec2::new(10.0, 10.0);
|
||||
assert!(!point_in_rect(Vec2::new(6.0, 0.0), center, size));
|
||||
assert!(!point_in_rect(Vec2::new(0.0, 6.0), center, size));
|
||||
assert!(!point_in_rect(Vec2::new(-100.0, 0.0), center, size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_picks_top_of_tableau() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// In tableau 6, the visually topmost card is the last (face-up) one.
|
||||
// Its position: base.y + fan * 6.
|
||||
let top_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||
let result = find_draggable_at(top_pos, &game, &layout).expect("hit");
|
||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||
assert_eq!(result.1, 6);
|
||||
assert_eq!(result.2.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_picks_waste_top_with_multiple_cards() {
|
||||
// Reproduces the reported "drags the wrong waste card" bug: with several
|
||||
// cards in the waste, clicking the visible top must pick the actual top
|
||||
// (last index), not the buffer card underneath it.
|
||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
clear_test_piles(&mut game);
|
||||
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
|
||||
game.set_test_waste_cards(waste.clone());
|
||||
|
||||
let top_index = waste.len() - 1; // 2 = the visible top
|
||||
let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
||||
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
|
||||
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
|
||||
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
|
||||
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_picks_lone_waste_card() {
|
||||
// "can't play the first card in the stock" — a waste of one card must
|
||||
// still be draggable.
|
||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
clear_test_piles(&mut game);
|
||||
let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace);
|
||||
game.set_test_waste_cards(vec![card.clone()]);
|
||||
|
||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
||||
let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable");
|
||||
assert_eq!(result.0, KlondikePile::Stock);
|
||||
assert_eq!(result.1, 0);
|
||||
assert_eq!(result.2, vec![card]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_waste_hit_test_matches_render_fan_step() {
|
||||
// Regression: the Draw-Three waste hit-test must use the same fan step as
|
||||
// the renderer (`card_plugin::waste_fan_step`). The previous hard-coded
|
||||
// `card_size.x * 0.28` matched the renderer only on desktop (column step =
|
||||
// 1.25*cw); under tighter Android-style spacing the two drift and the top
|
||||
// fanned card's click target lands on the card beneath it — so dragging
|
||||
// the visible top card plays the wrong one.
|
||||
let mut game = GameState::new(7, DrawStockConfig::DrawThree);
|
||||
let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// Force tight (Android-like) column spacing: ~1.03 * card_width.
|
||||
let cw = layout.card_size.x;
|
||||
let base = layout.pile_positions[&KlondikePile::Stock];
|
||||
let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
layout.pile_positions.insert(
|
||||
KlondikePile::Tableau(Tableau::Tableau2),
|
||||
Vec2::new(t1.x + cw * 1.03, t1.y),
|
||||
);
|
||||
|
||||
clear_test_piles(&mut game);
|
||||
let waste = vec![
|
||||
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
||||
Card::new(Deck::Deck1, Suit::Diamonds, Rank::King),
|
||||
];
|
||||
game.set_test_waste_cards(waste.clone());
|
||||
|
||||
// visible_start = len-3 = 1, so the top card sits at fan slot 2.
|
||||
let top_index = waste.len() - 1;
|
||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
||||
|
||||
let expected = base.x + 2.0 * waste_fan_step(&layout);
|
||||
assert!(
|
||||
(pos.x - expected).abs() < 1e-3,
|
||||
"hit-test must use the shared waste fan step"
|
||||
);
|
||||
// The old fixed constant would have drifted from the renderer here.
|
||||
let old = base.x + 2.0 * cw * 0.28;
|
||||
assert!(
|
||||
(pos.x - old).abs() > 1.0,
|
||||
"shared step must differ from the old fixed step under tight spacing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_skips_face_down_cards() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
|
||||
// the bottom (index 6). Click at the topmost face-down card's
|
||||
// position — its full body is partly visible above the fanned
|
||||
// face-up card, but the iterator should skip face-down cards and
|
||||
// the cursor sits above the face-up card's AABB, so the result
|
||||
// is None.
|
||||
let face_down_pos =
|
||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
||||
let result = find_draggable_at(face_down_pos, &game, &layout);
|
||||
assert!(result.is_none(), "face-down cards should not be draggable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
|
||||
// Regression test for the bug where input_plugin's hit-testing used
|
||||
// a uniform 0.25 fan step but card_plugin renders face-down cards
|
||||
// at 0.12 — so for any column with face-down cards above the
|
||||
// face-up bottom card, clicking the visible card face missed the
|
||||
// hit-test box and only the bottom strip of the card responded.
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
||||
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
||||
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
||||
let face_up_pos =
|
||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||
let result = find_draggable_at(face_up_pos, &game, &layout)
|
||||
.expect("clicking the face-up card's visible centre must initiate a drag");
|
||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||
assert_eq!(result.1, 6);
|
||||
assert_eq!(result.2.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_returns_run_when_picking_mid_stack() {
|
||||
// Manually construct a tableau with three face-up cards all stacked.
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
||||
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
||||
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
||||
game.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![king, queen.clone(), jack.clone()],
|
||||
);
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
||||
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
||||
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
||||
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
||||
let queen_center =
|
||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
||||
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(start, 1);
|
||||
assert_eq!(ids, vec![queen, jack]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_skips_non_top_waste_card() {
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Both cards in waste sit at the same (x, y). Clicking should pick
|
||||
// the visually top card (three_hearts), with count = 1.
|
||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, KlondikePile::Stock);
|
||||
assert_eq!(start, 1);
|
||||
assert_eq!(ids, vec![three_hearts]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_drop_target_hits_empty_tableau_pile_marker() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Move all cards out of tableau 0 so its marker is the only drop area.
|
||||
let mut game = game;
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
let target = find_drop_target(
|
||||
pos,
|
||||
&game,
|
||||
&layout,
|
||||
&KlondikePile::Tableau(Tableau::Tableau7),
|
||||
);
|
||||
assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_drop_target_returns_none_for_origin() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
|
||||
let target = find_drop_target(
|
||||
pos,
|
||||
&game,
|
||||
&layout,
|
||||
&KlondikePile::Tableau(Tableau::Tableau4),
|
||||
);
|
||||
assert_eq!(target, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_drop_rect_extends_for_tableau_with_cards() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Tableau 6 has 7 cards.
|
||||
let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game);
|
||||
// Expected: card_height + 6 fan steps.
|
||||
let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
|
||||
assert!(
|
||||
(size.y - expected).abs() < 1e-3,
|
||||
"expected {expected}, got {}",
|
||||
size.y
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic);
|
||||
// Three waste cards; top (four_clubs) is rightmost in the fan.
|
||||
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||
let four_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Four);
|
||||
game.set_test_waste_cards(vec![two_spades, three_hearts, four_clubs.clone()]);
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let waste_base = layout.pile_positions[&KlondikePile::Stock];
|
||||
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
|
||||
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
|
||||
let cursor = Vec2::new(top_card_x, waste_base.y);
|
||||
|
||||
let result = find_draggable_at(cursor, &game, &layout);
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"top fanned waste card must be hittable at its visual X position"
|
||||
);
|
||||
let (pile, _start, ids) = result.unwrap();
|
||||
assert_eq!(pile, KlondikePile::Stock);
|
||||
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_returns_none_for_click_on_empty_pile() {
|
||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Clear tableau 0 so it's an empty slot.
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
let result = find_draggable_at(pos, &game, &layout);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"clicking an empty pile must not produce a draggable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
for pile in [
|
||||
KlondikePile::Stock,
|
||||
KlondikePile::Foundation(Foundation::Foundation3),
|
||||
] {
|
||||
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
||||
assert_eq!(size, layout.card_size);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #27 — best_destination pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn best_destination_returns_none_when_no_legal_move() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
|
||||
// Clear everything except one card that has nowhere to go.
|
||||
clear_test_piles(&mut game);
|
||||
|
||||
// A Two of Clubs with empty foundations and empty tableau has no destination.
|
||||
let card = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
||||
assert!(best_destination(&card, &game).is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// best_tableau_destination_for_stack pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn best_tableau_destination_for_stack_skips_source_pile() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
|
||||
clear_test_piles(&mut game);
|
||||
|
||||
// Only tableau 0 has anything; every other column is empty.
|
||||
// A King is the only card that can go on an empty tableau column.
|
||||
// Source is Tableau(0), so the result must NOT be Tableau(0).
|
||||
let king = Card::new(D::Deck1, Suit::Hearts, Rank::King);
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![king.clone()]);
|
||||
|
||||
let result = best_tableau_destination_for_stack(
|
||||
&king,
|
||||
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||
&game,
|
||||
1,
|
||||
);
|
||||
// Result must be some other empty tableau column, never the source.
|
||||
if let Some((dest, _)) = result {
|
||||
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
|
||||
clear_test_piles(&mut game);
|
||||
|
||||
// Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King).
|
||||
// All other piles are empty — no legal tableau target.
|
||||
let two_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![two_clubs.clone()]);
|
||||
|
||||
let result = best_tableau_destination_for_stack(
|
||||
&two_clubs,
|
||||
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||
&game,
|
||||
1,
|
||||
);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"Two of Clubs has no legal tableau destination on empty piles"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #28 — find_hint pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn find_hint_finds_ace_to_foundation() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
|
||||
// Place Ace of Clubs on top of tableau 0.
|
||||
clear_test_piles(&mut game);
|
||||
let ace_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Ace);
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![ace_clubs]);
|
||||
|
||||
let hint = find_hint(&game);
|
||||
assert!(hint.is_some(), "should find a hint");
|
||||
let (from, to) = hint.unwrap();
|
||||
assert_eq!(from, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(to, KlondikePile::Foundation(Foundation::Foundation1));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// `handle_keyboard_forfeit` only checks `paused` and the G keypress;
|
||||
/// the "is there actually a game?" gating lives in
|
||||
/// `pause_plugin::handle_forfeit_request` so it can surface a
|
||||
/// "No game to forfeit" toast instead of failing silently.
|
||||
#[test]
|
||||
fn g_key_paused_check_keeps_handler_silent_while_pause_modal_owns_input() {
|
||||
// Build the system param state by hand so we don't rely on a
|
||||
// full Bevy app: the assertion is that the function returns
|
||||
// early on the paused branch without calling write_message.
|
||||
// This is verified by the plain `if paused { return; }` shape;
|
||||
// the body is small enough to inspect by reading.
|
||||
// (Higher-level integration coverage lives in the pause-plugin
|
||||
// tests where `forfeit_app` simulates the full flow.)
|
||||
let _ = handle_keyboard_forfeit; // proves the symbol still compiles
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// all_hints / new-game window — pure-function tests added during refactor
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Pass 3 of `all_hints` should suggest drawing from the stock when there
|
||||
/// are no other moves and the stock is non-empty.
|
||||
#[test]
|
||||
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
|
||||
// Remove all foundation, tableau, and waste cards so no pile-to-pile
|
||||
// move exists. Leave one card in the stock.
|
||||
clear_test_piles(&mut game);
|
||||
// Put one card back into the stock so "draw" is a valid suggestion.
|
||||
game.set_test_stock_cards(vec![Card::new(D::Deck1, Suit::Clubs, Rank::Ace)]);
|
||||
|
||||
let hints = all_hints(&game);
|
||||
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
|
||||
let (from, to) = &hints[0];
|
||||
assert_eq!(*from, KlondikePile::Stock, "hint must come from Stock");
|
||||
assert_eq!(*to, KlondikePile::Stock, "hint must point to Waste");
|
||||
}
|
||||
|
||||
// `all_hints` must be empty when both stock and waste are empty and no
|
||||
// pile-to-pile move exists — the game is truly stuck.
|
||||
// -----------------------------------------------------------------------
|
||||
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||
// `ShakeAnim` on the dragged cards. The audio cue
|
||||
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
||||
// visual response on the dragged cards swapped from a horizontal wiggle
|
||||
// to a smooth ease-out glide back to the origin pile.
|
||||
//
|
||||
// These tests build the component values exactly as `end_drag` and
|
||||
// `touch_end_drag` would, then assert the resulting `CardAnimation` is
|
||||
// shaped correctly. Driving `end_drag` end-to-end requires a real window
|
||||
// and mouse-button input, so we exercise the data path the same way the
|
||||
// legacy `ShakeAnim` tests did.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Helper: build the `CardAnimation` the rejection paths construct for
|
||||
/// one dragged card. Mirrors the inline logic in `end_drag` and
|
||||
/// `touch_end_drag` so the tests stay in sync with the production code.
|
||||
fn build_drag_reject_animation(
|
||||
drag_pos: Vec2,
|
||||
drag_z: f32,
|
||||
target_pos: Vec2,
|
||||
stack_index: usize,
|
||||
) -> CardAnimation {
|
||||
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||
CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
|
||||
.with_duration(MOTION_DRAG_REJECT_SECS)
|
||||
}
|
||||
|
||||
/// Every card in `drag.cards` should receive its own `CardAnimation` on
|
||||
/// rejection. With the shake → tween migration, the assertion changes
|
||||
/// from "every dragged card gets a ShakeAnim" to "every dragged card
|
||||
/// gets a CardAnimation" — same coverage, new component.
|
||||
#[test]
|
||||
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
// Simulate a stack drag of two cards.
|
||||
let dragged_cards: Vec<Card> = vec![
|
||||
Card::new(D::Deck1, Suit::Hearts, Rank::King),
|
||||
Card::new(D::Deck1, Suit::Spades, Rank::Queen),
|
||||
];
|
||||
|
||||
let mut animated: Vec<Card> = Vec::new();
|
||||
for card in &dragged_cards {
|
||||
// In `end_drag` we iterate `drag.cards` and look up each card in
|
||||
// `card_entities`. The cards we would insert a `CardAnimation` on
|
||||
// must exactly match the dragged set.
|
||||
animated.push(card.clone());
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
animated, dragged_cards,
|
||||
"every card in drag.cards must receive a CardAnimation on rejection"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `end` field of the inserted tween must equal the card's resting
|
||||
/// slot in its origin pile — the position the card belongs at after a
|
||||
/// rejected drop. Without this, the tween would glide to the wrong spot
|
||||
/// and `sync_cards` would have to fight it back.
|
||||
#[test]
|
||||
fn rejected_drag_animation_targets_origin_resting_position() {
|
||||
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
||||
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
||||
let anim =
|
||||
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
||||
|
||||
assert!(
|
||||
(anim.end - target_pos).length() < 1e-6,
|
||||
"CardAnimation.end must match the origin slot's resting position. \
|
||||
Expected {target_pos:?}, got {:?}",
|
||||
anim.end
|
||||
);
|
||||
}
|
||||
|
||||
/// The `start` field of the inserted tween must equal the card's
|
||||
/// drop-time transform position — i.e. wherever the cursor or finger
|
||||
/// released the card. This is what makes the glide feel like a
|
||||
/// continuous return rather than a teleport-then-shake.
|
||||
#[test]
|
||||
fn rejected_drag_animation_starts_from_drag_position() {
|
||||
let drag_pos = Vec2::new(640.0, 200.0);
|
||||
let target_pos = Vec2::new(80.0, -120.0);
|
||||
let anim =
|
||||
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
||||
|
||||
assert!(
|
||||
(anim.start - drag_pos).length() < 1e-6,
|
||||
"CardAnimation.start must match the drop-time transform position \
|
||||
(where the cursor released). Expected {drag_pos:?}, got {:?}",
|
||||
anim.start
|
||||
);
|
||||
// And the start must be visibly distinct from the origin slot — the
|
||||
// whole point of the tween is that it visibly travels.
|
||||
assert!(
|
||||
(anim.start - anim.end).length() > 1.0,
|
||||
"rejected drag should travel a visible distance, got start={:?} end={:?}",
|
||||
anim.start,
|
||||
anim.end
|
||||
);
|
||||
}
|
||||
|
||||
/// The tween duration is taken from the project-wide motion token so
|
||||
/// designers can retune the feel from one place. Keeps the constant and
|
||||
/// the call site honest.
|
||||
#[test]
|
||||
fn rejected_drag_animation_uses_correct_duration() {
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
0,
|
||||
);
|
||||
assert!(
|
||||
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
||||
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
||||
({MOTION_DRAG_REJECT_SECS}), got {}",
|
||||
anim.duration
|
||||
);
|
||||
}
|
||||
|
||||
/// The curve must be a no-overshoot ease-out so the card decelerates
|
||||
/// cleanly into its rest position — overshoot on a rejection feels
|
||||
/// jittery rather than forgiving.
|
||||
#[test]
|
||||
fn rejected_drag_animation_uses_responsive_curve() {
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
anim.curve,
|
||||
MotionCurve::Responsive,
|
||||
"drag-rejection tween must use Responsive (quintic ease-out) \
|
||||
so the card snaps back without bouncing past the slot"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `start_z` of the tween must equal the card's drop-time z
|
||||
/// (`DRAG_Z`) so the card stays above the rest of the table while it
|
||||
/// travels home, then settles at the correct resting z.
|
||||
#[test]
|
||||
fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() {
|
||||
let stack_index = 2_usize;
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
stack_index,
|
||||
);
|
||||
assert!(
|
||||
(anim.start_z - DRAG_Z).abs() < 1e-6,
|
||||
"tween must start at DRAG_Z so the card stays on top during the glide"
|
||||
);
|
||||
let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||
assert!(
|
||||
(anim.end_z - expected_end_z).abs() < 1e-6,
|
||||
"tween must end at the slot's resting z, got {} expected {expected_end_z}",
|
||||
anim.end_z
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hint system — async port (v0.18.0+)
|
||||
//
|
||||
// `handle_keyboard_hint` no longer runs the solver inline; it
|
||||
// spawns an `AsyncComputeTaskPool` task whose result the polling
|
||||
// system in `pending_hint` turns into hint visuals one frame
|
||||
// later. The behaviour contract this section pins is "pressing H
|
||||
// populates `PendingHintTask`" — the spawn-to-emit pipeline is
|
||||
// covered end-to-end in `pending_hint::tests`.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Pressing H on a non-paused, non-won game with a live
|
||||
/// `GameStateResource` + `LayoutResource` must populate
|
||||
/// `PendingHintTask`. The polling system, exercised in
|
||||
/// `pending_hint::tests`, drives the result to a visual event.
|
||||
#[test]
|
||||
fn pressing_h_spawns_pending_hint_task() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.add_message::<InfoToastEvent>();
|
||||
app.add_message::<HintVisualEvent>();
|
||||
app.init_resource::<HintCycleIndex>();
|
||||
app.init_resource::<HintSolverConfig>();
|
||||
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.insert_resource(LayoutResource(
|
||||
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
|
||||
));
|
||||
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
|
||||
app.add_systems(Update, handle_keyboard_hint);
|
||||
|
||||
// Simulate the H key being pressed this frame.
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyH);
|
||||
input.clear();
|
||||
input.press(KeyCode::KeyH);
|
||||
}
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<crate::pending_hint::PendingHintTask>()
|
||||
.is_pending(),
|
||||
"pressing H must spawn an async hint task",
|
||||
);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,707 @@
|
||||
use super::*;
|
||||
use crate::layout::compute_layout;
|
||||
use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
fn clear_test_piles(game: &mut GameState) {
|
||||
game.set_test_stock_cards(Vec::new());
|
||||
game.set_test_waste_cards(Vec::new());
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
game.set_test_foundation_cards(foundation, Vec::new());
|
||||
}
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
game.set_test_tableau_cards(tableau, Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragged_card_z_matches_resting_stack_step() {
|
||||
assert!((dragged_card_z(0) - DRAG_Z).abs() < 1e-6);
|
||||
let step = dragged_card_z(1) - dragged_card_z(0);
|
||||
assert!(
|
||||
step > 0.02,
|
||||
"drag step must exceed Android overlay local_z, got {step}"
|
||||
);
|
||||
assert!(
|
||||
step + 1e-4 >= STACK_FAN_FRAC,
|
||||
"drag step must stay aligned with resting stack spacing, got {step}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_inside_returns_true() {
|
||||
let center = Vec2::new(10.0, 20.0);
|
||||
let size = Vec2::new(40.0, 60.0);
|
||||
assert!(point_in_rect(Vec2::new(10.0, 20.0), center, size));
|
||||
assert!(point_in_rect(Vec2::new(29.0, 49.0), center, size));
|
||||
assert!(point_in_rect(Vec2::new(-9.0, -9.0), center, size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_on_edge_returns_true() {
|
||||
let center = Vec2::ZERO;
|
||||
let size = Vec2::new(10.0, 10.0);
|
||||
assert!(point_in_rect(Vec2::new(5.0, 5.0), center, size));
|
||||
assert!(point_in_rect(Vec2::new(-5.0, -5.0), center, size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_outside_returns_false() {
|
||||
let center = Vec2::ZERO;
|
||||
let size = Vec2::new(10.0, 10.0);
|
||||
assert!(!point_in_rect(Vec2::new(6.0, 0.0), center, size));
|
||||
assert!(!point_in_rect(Vec2::new(0.0, 6.0), center, size));
|
||||
assert!(!point_in_rect(Vec2::new(-100.0, 0.0), center, size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_picks_top_of_tableau() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// In tableau 6, the visually topmost card is the last (face-up) one.
|
||||
// Its position: base.y + fan * 6.
|
||||
let top_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||
let result = find_draggable_at(top_pos, &game, &layout).expect("hit");
|
||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||
assert_eq!(result.1, 6);
|
||||
assert_eq!(result.2.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_picks_waste_top_with_multiple_cards() {
|
||||
// Reproduces the reported "drags the wrong waste card" bug: with several
|
||||
// cards in the waste, clicking the visible top must pick the actual top
|
||||
// (last index), not the buffer card underneath it.
|
||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
clear_test_piles(&mut game);
|
||||
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
|
||||
game.set_test_waste_cards(waste.clone());
|
||||
|
||||
let top_index = waste.len() - 1; // 2 = the visible top
|
||||
let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
||||
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
|
||||
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
|
||||
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
|
||||
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_picks_lone_waste_card() {
|
||||
// "can't play the first card in the stock" — a waste of one card must
|
||||
// still be draggable.
|
||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
clear_test_piles(&mut game);
|
||||
let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace);
|
||||
game.set_test_waste_cards(vec![card.clone()]);
|
||||
|
||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
||||
let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable");
|
||||
assert_eq!(result.0, KlondikePile::Stock);
|
||||
assert_eq!(result.1, 0);
|
||||
assert_eq!(result.2, vec![card]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_waste_hit_test_matches_render_fan_step() {
|
||||
// Regression: the Draw-Three waste hit-test must use the same fan step as
|
||||
// the renderer (`card_plugin::waste_fan_step`). The previous hard-coded
|
||||
// `card_size.x * 0.28` matched the renderer only on desktop (column step =
|
||||
// 1.25*cw); under tighter Android-style spacing the two drift and the top
|
||||
// fanned card's click target lands on the card beneath it — so dragging
|
||||
// the visible top card plays the wrong one.
|
||||
let mut game = GameState::new(7, DrawStockConfig::DrawThree);
|
||||
let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// Force tight (Android-like) column spacing: ~1.03 * card_width.
|
||||
let cw = layout.card_size.x;
|
||||
let base = layout.pile_positions[&KlondikePile::Stock];
|
||||
let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
layout.pile_positions.insert(
|
||||
KlondikePile::Tableau(Tableau::Tableau2),
|
||||
Vec2::new(t1.x + cw * 1.03, t1.y),
|
||||
);
|
||||
|
||||
clear_test_piles(&mut game);
|
||||
let waste = vec![
|
||||
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
||||
Card::new(Deck::Deck1, Suit::Diamonds, Rank::King),
|
||||
];
|
||||
game.set_test_waste_cards(waste.clone());
|
||||
|
||||
// visible_start = len-3 = 1, so the top card sits at fan slot 2.
|
||||
let top_index = waste.len() - 1;
|
||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
||||
|
||||
let expected = base.x + 2.0 * waste_fan_step(&layout);
|
||||
assert!(
|
||||
(pos.x - expected).abs() < 1e-3,
|
||||
"hit-test must use the shared waste fan step"
|
||||
);
|
||||
// The old fixed constant would have drifted from the renderer here.
|
||||
let old = base.x + 2.0 * cw * 0.28;
|
||||
assert!(
|
||||
(pos.x - old).abs() > 1.0,
|
||||
"shared step must differ from the old fixed step under tight spacing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_skips_face_down_cards() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
|
||||
// the bottom (index 6). Click at the topmost face-down card's
|
||||
// position — its full body is partly visible above the fanned
|
||||
// face-up card, but the iterator should skip face-down cards and
|
||||
// the cursor sits above the face-up card's AABB, so the result
|
||||
// is None.
|
||||
let face_down_pos =
|
||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
||||
let result = find_draggable_at(face_down_pos, &game, &layout);
|
||||
assert!(result.is_none(), "face-down cards should not be draggable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
|
||||
// Regression test for the bug where input_plugin's hit-testing used
|
||||
// a uniform 0.25 fan step but card_plugin renders face-down cards
|
||||
// at 0.12 — so for any column with face-down cards above the
|
||||
// face-up bottom card, clicking the visible card face missed the
|
||||
// hit-test box and only the bottom strip of the card responded.
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
||||
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
||||
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
||||
let face_up_pos =
|
||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||
let result = find_draggable_at(face_up_pos, &game, &layout)
|
||||
.expect("clicking the face-up card's visible centre must initiate a drag");
|
||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||
assert_eq!(result.1, 6);
|
||||
assert_eq!(result.2.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_returns_run_when_picking_mid_stack() {
|
||||
// Manually construct a tableau with three face-up cards all stacked.
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
||||
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
||||
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
||||
game.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![king, queen.clone(), jack.clone()],
|
||||
);
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
||||
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
||||
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
||||
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
||||
let queen_center =
|
||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
||||
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(start, 1);
|
||||
assert_eq!(ids, vec![queen, jack]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_skips_non_top_waste_card() {
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Both cards in waste sit at the same (x, y). Clicking should pick
|
||||
// the visually top card (three_hearts), with count = 1.
|
||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, KlondikePile::Stock);
|
||||
assert_eq!(start, 1);
|
||||
assert_eq!(ids, vec![three_hearts]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_drop_target_hits_empty_tableau_pile_marker() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Move all cards out of tableau 0 so its marker is the only drop area.
|
||||
let mut game = game;
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
let target = find_drop_target(
|
||||
pos,
|
||||
&game,
|
||||
&layout,
|
||||
&KlondikePile::Tableau(Tableau::Tableau7),
|
||||
);
|
||||
assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_drop_target_returns_none_for_origin() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
|
||||
let target = find_drop_target(
|
||||
pos,
|
||||
&game,
|
||||
&layout,
|
||||
&KlondikePile::Tableau(Tableau::Tableau4),
|
||||
);
|
||||
assert_eq!(target, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_drop_rect_extends_for_tableau_with_cards() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Tableau 6 has 7 cards.
|
||||
let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game);
|
||||
// Expected: card_height + 6 fan steps.
|
||||
let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
|
||||
assert!(
|
||||
(size.y - expected).abs() < 1e-3,
|
||||
"expected {expected}, got {}",
|
||||
size.y
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic);
|
||||
// Three waste cards; top (four_clubs) is rightmost in the fan.
|
||||
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||
let four_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Four);
|
||||
game.set_test_waste_cards(vec![two_spades, three_hearts, four_clubs.clone()]);
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let waste_base = layout.pile_positions[&KlondikePile::Stock];
|
||||
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
|
||||
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
|
||||
let cursor = Vec2::new(top_card_x, waste_base.y);
|
||||
|
||||
let result = find_draggable_at(cursor, &game, &layout);
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"top fanned waste card must be hittable at its visual X position"
|
||||
);
|
||||
let (pile, _start, ids) = result.unwrap();
|
||||
assert_eq!(pile, KlondikePile::Stock);
|
||||
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_returns_none_for_click_on_empty_pile() {
|
||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Clear tableau 0 so it's an empty slot.
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
let result = find_draggable_at(pos, &game, &layout);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"clicking an empty pile must not produce a draggable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
for pile in [
|
||||
KlondikePile::Stock,
|
||||
KlondikePile::Foundation(Foundation::Foundation3),
|
||||
] {
|
||||
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
||||
assert_eq!(size, layout.card_size);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #27 — best_destination pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn best_destination_returns_none_when_no_legal_move() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
|
||||
// Clear everything except one card that has nowhere to go.
|
||||
clear_test_piles(&mut game);
|
||||
|
||||
// A Two of Clubs with empty foundations and empty tableau has no destination.
|
||||
let card = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
||||
assert!(best_destination(&card, &game).is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// best_tableau_destination_for_stack pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn best_tableau_destination_for_stack_skips_source_pile() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
|
||||
clear_test_piles(&mut game);
|
||||
|
||||
// Only tableau 0 has anything; every other column is empty.
|
||||
// A King is the only card that can go on an empty tableau column.
|
||||
// Source is Tableau(0), so the result must NOT be Tableau(0).
|
||||
let king = Card::new(D::Deck1, Suit::Hearts, Rank::King);
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![king.clone()]);
|
||||
|
||||
let result = best_tableau_destination_for_stack(
|
||||
&king,
|
||||
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||
&game,
|
||||
1,
|
||||
);
|
||||
// Result must be some other empty tableau column, never the source.
|
||||
if let Some((dest, _)) = result {
|
||||
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
|
||||
clear_test_piles(&mut game);
|
||||
|
||||
// Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King).
|
||||
// All other piles are empty — no legal tableau target.
|
||||
let two_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![two_clubs.clone()]);
|
||||
|
||||
let result = best_tableau_destination_for_stack(
|
||||
&two_clubs,
|
||||
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||
&game,
|
||||
1,
|
||||
);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"Two of Clubs has no legal tableau destination on empty piles"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #28 — find_hint pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn find_hint_finds_ace_to_foundation() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
|
||||
// Place Ace of Clubs on top of tableau 0.
|
||||
clear_test_piles(&mut game);
|
||||
let ace_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Ace);
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![ace_clubs]);
|
||||
|
||||
let hint = find_hint(&game);
|
||||
assert!(hint.is_some(), "should find a hint");
|
||||
let (from, to) = hint.unwrap();
|
||||
assert_eq!(from, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(to, KlondikePile::Foundation(Foundation::Foundation1));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// `handle_keyboard_forfeit` only checks `paused` and the G keypress;
|
||||
/// the "is there actually a game?" gating lives in
|
||||
/// `pause_plugin::handle_forfeit_request` so it can surface a
|
||||
/// "No game to forfeit" toast instead of failing silently.
|
||||
#[test]
|
||||
fn g_key_paused_check_keeps_handler_silent_while_pause_modal_owns_input() {
|
||||
// Build the system param state by hand so we don't rely on a
|
||||
// full Bevy app: the assertion is that the function returns
|
||||
// early on the paused branch without calling write_message.
|
||||
// This is verified by the plain `if paused { return; }` shape;
|
||||
// the body is small enough to inspect by reading.
|
||||
// (Higher-level integration coverage lives in the pause-plugin
|
||||
// tests where `forfeit_app` simulates the full flow.)
|
||||
let _ = handle_keyboard_forfeit; // proves the symbol still compiles
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// all_hints / new-game window — pure-function tests added during refactor
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Pass 3 of `all_hints` should suggest drawing from the stock when there
|
||||
/// are no other moves and the stock is non-empty.
|
||||
#[test]
|
||||
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||
|
||||
// Remove all foundation, tableau, and waste cards so no pile-to-pile
|
||||
// move exists. Leave one card in the stock.
|
||||
clear_test_piles(&mut game);
|
||||
// Put one card back into the stock so "draw" is a valid suggestion.
|
||||
game.set_test_stock_cards(vec![Card::new(D::Deck1, Suit::Clubs, Rank::Ace)]);
|
||||
|
||||
let hints = all_hints(&game);
|
||||
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
|
||||
let (from, to) = &hints[0];
|
||||
assert_eq!(*from, KlondikePile::Stock, "hint must come from Stock");
|
||||
assert_eq!(*to, KlondikePile::Stock, "hint must point to Waste");
|
||||
}
|
||||
|
||||
// `all_hints` must be empty when both stock and waste are empty and no
|
||||
// pile-to-pile move exists — the game is truly stuck.
|
||||
// -----------------------------------------------------------------------
|
||||
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||
// `ShakeAnim` on the dragged cards. The audio cue
|
||||
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
||||
// visual response on the dragged cards swapped from a horizontal wiggle
|
||||
// to a smooth ease-out glide back to the origin pile.
|
||||
//
|
||||
// These tests build the component values exactly as `end_drag` and
|
||||
// `touch_end_drag` would, then assert the resulting `CardAnimation` is
|
||||
// shaped correctly. Driving `end_drag` end-to-end requires a real window
|
||||
// and mouse-button input, so we exercise the data path the same way the
|
||||
// legacy `ShakeAnim` tests did.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Helper: build the `CardAnimation` the rejection paths construct for
|
||||
/// one dragged card. Mirrors the inline logic in `end_drag` and
|
||||
/// `touch_end_drag` so the tests stay in sync with the production code.
|
||||
fn build_drag_reject_animation(
|
||||
drag_pos: Vec2,
|
||||
drag_z: f32,
|
||||
target_pos: Vec2,
|
||||
stack_index: usize,
|
||||
) -> CardAnimation {
|
||||
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||
CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
|
||||
.with_duration(MOTION_DRAG_REJECT_SECS)
|
||||
}
|
||||
|
||||
/// Every card in `drag.cards` should receive its own `CardAnimation` on
|
||||
/// rejection. With the shake → tween migration, the assertion changes
|
||||
/// from "every dragged card gets a ShakeAnim" to "every dragged card
|
||||
/// gets a CardAnimation" — same coverage, new component.
|
||||
#[test]
|
||||
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
||||
use solitaire_core::Deck as D;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
// Simulate a stack drag of two cards.
|
||||
let dragged_cards: Vec<Card> = vec![
|
||||
Card::new(D::Deck1, Suit::Hearts, Rank::King),
|
||||
Card::new(D::Deck1, Suit::Spades, Rank::Queen),
|
||||
];
|
||||
|
||||
let mut animated: Vec<Card> = Vec::new();
|
||||
for card in &dragged_cards {
|
||||
// In `end_drag` we iterate `drag.cards` and look up each card in
|
||||
// `card_entities`. The cards we would insert a `CardAnimation` on
|
||||
// must exactly match the dragged set.
|
||||
animated.push(card.clone());
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
animated, dragged_cards,
|
||||
"every card in drag.cards must receive a CardAnimation on rejection"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `end` field of the inserted tween must equal the card's resting
|
||||
/// slot in its origin pile — the position the card belongs at after a
|
||||
/// rejected drop. Without this, the tween would glide to the wrong spot
|
||||
/// and `sync_cards` would have to fight it back.
|
||||
#[test]
|
||||
fn rejected_drag_animation_targets_origin_resting_position() {
|
||||
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
||||
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
||||
let anim =
|
||||
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
||||
|
||||
assert!(
|
||||
(anim.end - target_pos).length() < 1e-6,
|
||||
"CardAnimation.end must match the origin slot's resting position. \
|
||||
Expected {target_pos:?}, got {:?}",
|
||||
anim.end
|
||||
);
|
||||
}
|
||||
|
||||
/// The `start` field of the inserted tween must equal the card's
|
||||
/// drop-time transform position — i.e. wherever the cursor or finger
|
||||
/// released the card. This is what makes the glide feel like a
|
||||
/// continuous return rather than a teleport-then-shake.
|
||||
#[test]
|
||||
fn rejected_drag_animation_starts_from_drag_position() {
|
||||
let drag_pos = Vec2::new(640.0, 200.0);
|
||||
let target_pos = Vec2::new(80.0, -120.0);
|
||||
let anim =
|
||||
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
||||
|
||||
assert!(
|
||||
(anim.start - drag_pos).length() < 1e-6,
|
||||
"CardAnimation.start must match the drop-time transform position \
|
||||
(where the cursor released). Expected {drag_pos:?}, got {:?}",
|
||||
anim.start
|
||||
);
|
||||
// And the start must be visibly distinct from the origin slot — the
|
||||
// whole point of the tween is that it visibly travels.
|
||||
assert!(
|
||||
(anim.start - anim.end).length() > 1.0,
|
||||
"rejected drag should travel a visible distance, got start={:?} end={:?}",
|
||||
anim.start,
|
||||
anim.end
|
||||
);
|
||||
}
|
||||
|
||||
/// The tween duration is taken from the project-wide motion token so
|
||||
/// designers can retune the feel from one place. Keeps the constant and
|
||||
/// the call site honest.
|
||||
#[test]
|
||||
fn rejected_drag_animation_uses_correct_duration() {
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
0,
|
||||
);
|
||||
assert!(
|
||||
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
||||
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
||||
({MOTION_DRAG_REJECT_SECS}), got {}",
|
||||
anim.duration
|
||||
);
|
||||
}
|
||||
|
||||
/// The curve must be a no-overshoot ease-out so the card decelerates
|
||||
/// cleanly into its rest position — overshoot on a rejection feels
|
||||
/// jittery rather than forgiving.
|
||||
#[test]
|
||||
fn rejected_drag_animation_uses_responsive_curve() {
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
anim.curve,
|
||||
MotionCurve::Responsive,
|
||||
"drag-rejection tween must use Responsive (quintic ease-out) \
|
||||
so the card snaps back without bouncing past the slot"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `start_z` of the tween must equal the card's drop-time z
|
||||
/// (`DRAG_Z`) so the card stays above the rest of the table while it
|
||||
/// travels home, then settles at the correct resting z.
|
||||
#[test]
|
||||
fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() {
|
||||
let stack_index = 2_usize;
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
stack_index,
|
||||
);
|
||||
assert!(
|
||||
(anim.start_z - DRAG_Z).abs() < 1e-6,
|
||||
"tween must start at DRAG_Z so the card stays on top during the glide"
|
||||
);
|
||||
let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||
assert!(
|
||||
(anim.end_z - expected_end_z).abs() < 1e-6,
|
||||
"tween must end at the slot's resting z, got {} expected {expected_end_z}",
|
||||
anim.end_z
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hint system — async port (v0.18.0+)
|
||||
//
|
||||
// `handle_keyboard_hint` no longer runs the solver inline; it
|
||||
// spawns an `AsyncComputeTaskPool` task whose result the polling
|
||||
// system in `pending_hint` turns into hint visuals one frame
|
||||
// later. The behaviour contract this section pins is "pressing H
|
||||
// populates `PendingHintTask`" — the spawn-to-emit pipeline is
|
||||
// covered end-to-end in `pending_hint::tests`.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Pressing H on a non-paused, non-won game with a live
|
||||
/// `GameStateResource` + `LayoutResource` must populate
|
||||
/// `PendingHintTask`. The polling system, exercised in
|
||||
/// `pending_hint::tests`, drives the result to a visual event.
|
||||
#[test]
|
||||
fn pressing_h_spawns_pending_hint_task() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.add_message::<InfoToastEvent>();
|
||||
app.add_message::<HintVisualEvent>();
|
||||
app.init_resource::<HintCycleIndex>();
|
||||
app.init_resource::<HintSolverConfig>();
|
||||
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.insert_resource(LayoutResource(
|
||||
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
|
||||
));
|
||||
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
|
||||
app.add_systems(Update, handle_keyboard_hint);
|
||||
|
||||
// Simulate the H key being pressed this frame.
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyH);
|
||||
input.clear();
|
||||
input.press(KeyCode::KeyH);
|
||||
}
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<crate::pending_hint::PendingHintTask>()
|
||||
.is_pending(),
|
||||
"pressing H must spawn an async hint task",
|
||||
);
|
||||
}
|
||||
@@ -191,10 +191,12 @@ fn apply_safe_area_to_modal_scrims(
|
||||
/// whatever `SafeAreaInsets` are current at that moment.
|
||||
///
|
||||
/// On Android the `android::rearm_on_resumed` system runs in the same frame
|
||||
/// and resets both `SafeAreaPollTries` and `SafeAreaInsets` to zero, causing
|
||||
/// `refresh_insets` to re-poll JNI over the next few frames. When it resolves
|
||||
/// the correct values, `on_safe_area_changed` in `table_plugin` emits a second
|
||||
/// synthetic `WindowResized` and the layout converges to the right position.
|
||||
/// and resets `SafeAreaPollTries` (the cached `SafeAreaInsets` keep their
|
||||
/// last-known values), causing `refresh_insets` to re-poll JNI over the next
|
||||
/// few frames. If the insets changed while backgrounded, `on_safe_area_changed`
|
||||
/// in `table_plugin` emits a second synthetic `WindowResized` and the layout
|
||||
/// converges to the right position; if they didn't, nothing is rewritten and
|
||||
/// the layout stays put.
|
||||
///
|
||||
/// On non-Android targets this handler still fires — it ensures that a resume
|
||||
/// event always refreshes the layout (e.g., after a minimise/restore on
|
||||
@@ -232,9 +234,14 @@ mod android {
|
||||
pub(super) struct SafeAreaPollTries(pub u32);
|
||||
|
||||
/// Polls Android for safe-area insets until we get a non-zero
|
||||
/// reading, then stops. `getRootWindowInsets()` returns `null` (or
|
||||
/// all-zero `Insets`) until the decor view has been laid out, which
|
||||
/// is typically frame 1–3 of a fresh launch.
|
||||
/// reading, then settles until [`rearm_on_resumed`] re-arms it on the
|
||||
/// next foreground resume — insets can change while backgrounded
|
||||
/// (rotation, fold/unfold, gesture ↔ 3-button nav). The poll counter
|
||||
/// (not `insets.is_populated()`) gates the loop, so a re-armed cycle
|
||||
/// re-queries JNI even though cached values are already populated.
|
||||
/// `getRootWindowInsets()` returns `null` (or all-zero `Insets`)
|
||||
/// until the decor view has been laid out, which is typically frame
|
||||
/// 1–3 of a fresh launch.
|
||||
pub(super) fn refresh_insets(
|
||||
mut insets: ResMut<SafeAreaInsets>,
|
||||
mut poll: ResMut<SafeAreaPollTries>,
|
||||
@@ -243,18 +250,25 @@ mod android {
|
||||
// devices that genuinely report zero insets.
|
||||
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
|
||||
|
||||
if poll.0 >= MAX_TRIES || insets.is_populated() {
|
||||
if poll.0 >= MAX_TRIES {
|
||||
return;
|
||||
}
|
||||
poll.0 += 1;
|
||||
|
||||
match query_insets() {
|
||||
Ok(v) if v.is_populated() => {
|
||||
info!(
|
||||
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
||||
v.top, v.bottom, v.left, v.right, poll.0
|
||||
);
|
||||
*insets = v;
|
||||
if *insets != v {
|
||||
info!(
|
||||
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
||||
v.top, v.bottom, v.left, v.right, poll.0
|
||||
);
|
||||
*insets = v;
|
||||
}
|
||||
// Settled for this poll cycle; `rearm_on_resumed` re-arms on
|
||||
// the next resume. Writing `insets` only on an actual change
|
||||
// keeps resource change detection (and the relayout it
|
||||
// triggers) quiet on resumes where nothing moved.
|
||||
poll.0 = MAX_TRIES;
|
||||
}
|
||||
Ok(_) => {
|
||||
// Layout not ready yet; try again next frame.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,627 @@
|
||||
//! Input handling for the Settings panel: button presses, keyboard
|
||||
//! accelerators, focus attachment, and scrolling.
|
||||
|
||||
use super::*;
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||
use solitaire_core::DrawStockConfig;
|
||||
use solitaire_data::{
|
||||
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS,
|
||||
settings::Theme,
|
||||
};
|
||||
|
||||
use crate::events::{
|
||||
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||||
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
|
||||
};
|
||||
use crate::ui_focus::{FocusGroup, Focusable, FocusedButton};
|
||||
use crate::ui_modal::{ModalButton, ModalScrim};
|
||||
use crate::ui_theme::SPACE_2;
|
||||
|
||||
pub(super) fn handle_volume_keys(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut settings: ResMut<SettingsResource>,
|
||||
path: Res<SettingsStoragePath>,
|
||||
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
let mut delta = 0.0_f32;
|
||||
if keys.just_pressed(KeyCode::BracketLeft) {
|
||||
delta -= SFX_STEP;
|
||||
}
|
||||
if keys.just_pressed(KeyCode::BracketRight) {
|
||||
delta += SFX_STEP;
|
||||
}
|
||||
if delta == 0.0 {
|
||||
return;
|
||||
}
|
||||
let before = settings.0.sfx_volume;
|
||||
let after = settings.0.adjust_sfx_volume(delta);
|
||||
if (before - after).abs() < f32::EPSILON {
|
||||
return;
|
||||
}
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
toast.write(InfoToastEvent(format!(
|
||||
"SFX volume: {}%",
|
||||
(after * 100.0).round() as i32
|
||||
)));
|
||||
}
|
||||
|
||||
/// Opens or closes the Settings panel — `O` keyboard accelerator or
|
||||
/// `ToggleSettingsRequestEvent` from the HUD Menu popover.
|
||||
pub(super) fn toggle_settings_screen(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<ToggleSettingsRequestEvent>,
|
||||
mut screen: ResMut<SettingsScreen>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
if keys.just_pressed(KeyCode::KeyO) || button_clicked {
|
||||
screen.0 = !screen.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reacts to button presses inside the Settings panel.
|
||||
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||
pub(super) fn handle_settings_buttons(
|
||||
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||
mut settings: ResMut<SettingsResource>,
|
||||
mut screen: ResMut<SettingsScreen>,
|
||||
path: Res<SettingsStoragePath>,
|
||||
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||
mut sfx_text: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<SfxVolumeText>,
|
||||
Without<MusicVolumeText>,
|
||||
Without<DrawModeText>,
|
||||
Without<ThemeText>,
|
||||
Without<AnimSpeedText>,
|
||||
Without<ColorBlindText>,
|
||||
Without<HighContrastText>,
|
||||
Without<ReduceMotionText>,
|
||||
),
|
||||
>,
|
||||
mut music_text: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<MusicVolumeText>,
|
||||
Without<SfxVolumeText>,
|
||||
Without<DrawModeText>,
|
||||
Without<ThemeText>,
|
||||
Without<AnimSpeedText>,
|
||||
Without<ColorBlindText>,
|
||||
Without<HighContrastText>,
|
||||
Without<ReduceMotionText>,
|
||||
),
|
||||
>,
|
||||
mut draw_text: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<DrawModeText>,
|
||||
Without<SfxVolumeText>,
|
||||
Without<MusicVolumeText>,
|
||||
Without<ThemeText>,
|
||||
Without<AnimSpeedText>,
|
||||
Without<ColorBlindText>,
|
||||
Without<HighContrastText>,
|
||||
Without<ReduceMotionText>,
|
||||
),
|
||||
>,
|
||||
mut theme_text: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<ThemeText>,
|
||||
Without<SfxVolumeText>,
|
||||
Without<MusicVolumeText>,
|
||||
Without<DrawModeText>,
|
||||
Without<AnimSpeedText>,
|
||||
Without<ColorBlindText>,
|
||||
Without<HighContrastText>,
|
||||
Without<ReduceMotionText>,
|
||||
),
|
||||
>,
|
||||
mut anim_speed_text: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<AnimSpeedText>,
|
||||
Without<SfxVolumeText>,
|
||||
Without<MusicVolumeText>,
|
||||
Without<DrawModeText>,
|
||||
Without<ThemeText>,
|
||||
Without<ColorBlindText>,
|
||||
Without<HighContrastText>,
|
||||
Without<ReduceMotionText>,
|
||||
),
|
||||
>,
|
||||
mut color_blind_text: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<ColorBlindText>,
|
||||
Without<SfxVolumeText>,
|
||||
Without<MusicVolumeText>,
|
||||
Without<DrawModeText>,
|
||||
Without<ThemeText>,
|
||||
Without<AnimSpeedText>,
|
||||
Without<HighContrastText>,
|
||||
Without<ReduceMotionText>,
|
||||
),
|
||||
>,
|
||||
mut high_contrast_text: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HighContrastText>,
|
||||
Without<SfxVolumeText>,
|
||||
Without<MusicVolumeText>,
|
||||
Without<DrawModeText>,
|
||||
Without<ThemeText>,
|
||||
Without<AnimSpeedText>,
|
||||
Without<ColorBlindText>,
|
||||
Without<ReduceMotionText>,
|
||||
),
|
||||
>,
|
||||
mut reduce_motion_text: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<ReduceMotionText>,
|
||||
Without<SfxVolumeText>,
|
||||
Without<MusicVolumeText>,
|
||||
Without<DrawModeText>,
|
||||
Without<ThemeText>,
|
||||
Without<AnimSpeedText>,
|
||||
Without<ColorBlindText>,
|
||||
Without<HighContrastText>,
|
||||
),
|
||||
>,
|
||||
) {
|
||||
for (interaction, button) in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
match button {
|
||||
SettingsButton::SfxDown => {
|
||||
let before = settings.0.sfx_volume;
|
||||
let after = settings.0.adjust_sfx_volume(-SFX_STEP);
|
||||
if (before - after).abs() > f32::EPSILON {
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
if let Ok(mut t) = sfx_text.single_mut() {
|
||||
**t = format!("{after:.2}");
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsButton::SfxUp => {
|
||||
let before = settings.0.sfx_volume;
|
||||
let after = settings.0.adjust_sfx_volume(SFX_STEP);
|
||||
if (before - after).abs() > f32::EPSILON {
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
if let Ok(mut t) = sfx_text.single_mut() {
|
||||
**t = format!("{after:.2}");
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsButton::MusicDown => {
|
||||
let before = settings.0.music_volume;
|
||||
let after = settings.0.adjust_music_volume(-SFX_STEP);
|
||||
if (before - after).abs() > f32::EPSILON {
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
if let Ok(mut t) = music_text.single_mut() {
|
||||
**t = format!("{after:.2}");
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsButton::MusicUp => {
|
||||
let before = settings.0.music_volume;
|
||||
let after = settings.0.adjust_music_volume(SFX_STEP);
|
||||
if (before - after).abs() > f32::EPSILON {
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
if let Ok(mut t) = music_text.single_mut() {
|
||||
**t = format!("{after:.2}");
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsButton::ToggleDrawMode => {
|
||||
settings.0.draw_mode = match settings.0.draw_mode {
|
||||
DrawStockConfig::DrawOne => DrawStockConfig::DrawThree,
|
||||
DrawStockConfig::DrawThree => DrawStockConfig::DrawOne,
|
||||
};
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
if let Ok(mut t) = draw_text.single_mut() {
|
||||
**t = draw_mode_label(&settings.0.draw_mode);
|
||||
}
|
||||
}
|
||||
SettingsButton::CycleAnimSpeed => {
|
||||
settings.0.animation_speed = match settings.0.animation_speed {
|
||||
AnimSpeed::Normal => AnimSpeed::Fast,
|
||||
AnimSpeed::Fast => AnimSpeed::Instant,
|
||||
AnimSpeed::Instant => AnimSpeed::Normal,
|
||||
};
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
if let Ok(mut t) = anim_speed_text.single_mut() {
|
||||
**t = anim_speed_label(&settings.0.animation_speed);
|
||||
}
|
||||
}
|
||||
SettingsButton::TooltipDelayDown => {
|
||||
let before = settings.0.tooltip_delay_secs;
|
||||
let after = settings.0.adjust_tooltip_delay(-TOOLTIP_DELAY_STEP_SECS);
|
||||
if (before - after).abs() > f32::EPSILON {
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
// The Text node is refreshed by `update_tooltip_delay_text`
|
||||
// on the next frame via `settings.is_changed()`.
|
||||
}
|
||||
}
|
||||
SettingsButton::TooltipDelayUp => {
|
||||
let before = settings.0.tooltip_delay_secs;
|
||||
let after = settings.0.adjust_tooltip_delay(TOOLTIP_DELAY_STEP_SECS);
|
||||
if (before - after).abs() > f32::EPSILON {
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
}
|
||||
SettingsButton::TimeBonusDown => {
|
||||
let before = settings.0.time_bonus_multiplier;
|
||||
let after = settings
|
||||
.0
|
||||
.adjust_time_bonus_multiplier(-TIME_BONUS_MULTIPLIER_STEP);
|
||||
if (before - after).abs() > f32::EPSILON {
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
// The Text node is refreshed by
|
||||
// `update_time_bonus_multiplier_text` on the next
|
||||
// frame via `settings.is_changed()`.
|
||||
}
|
||||
}
|
||||
SettingsButton::TimeBonusUp => {
|
||||
let before = settings.0.time_bonus_multiplier;
|
||||
let after = settings
|
||||
.0
|
||||
.adjust_time_bonus_multiplier(TIME_BONUS_MULTIPLIER_STEP);
|
||||
if (before - after).abs() > f32::EPSILON {
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
}
|
||||
SettingsButton::ReplayMoveIntervalDown => {
|
||||
let before = settings.0.replay_move_interval_secs;
|
||||
let after = settings
|
||||
.0
|
||||
.adjust_replay_move_interval(-REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||
if (before - after).abs() > f32::EPSILON {
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
// The Text node is refreshed by
|
||||
// `update_replay_move_interval_text` on the next
|
||||
// frame via `settings.is_changed()`.
|
||||
}
|
||||
}
|
||||
SettingsButton::ReplayMoveIntervalUp => {
|
||||
let before = settings.0.replay_move_interval_secs;
|
||||
let after = settings
|
||||
.0
|
||||
.adjust_replay_move_interval(REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||
if (before - after).abs() > f32::EPSILON {
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
}
|
||||
SettingsButton::ToggleTheme => {
|
||||
settings.0.theme = match settings.0.theme {
|
||||
Theme::Green => Theme::Blue,
|
||||
Theme::Blue => Theme::Dark,
|
||||
Theme::Dark => Theme::Green,
|
||||
};
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
if let Ok(mut t) = theme_text.single_mut() {
|
||||
**t = theme_label(&settings.0.theme);
|
||||
}
|
||||
}
|
||||
SettingsButton::ToggleColorBlind => {
|
||||
settings.0.color_blind_mode = !settings.0.color_blind_mode;
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
if let Ok(mut t) = color_blind_text.single_mut() {
|
||||
**t = color_blind_label(settings.0.color_blind_mode);
|
||||
}
|
||||
}
|
||||
SettingsButton::ToggleHighContrast => {
|
||||
settings.0.high_contrast_mode = !settings.0.high_contrast_mode;
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
if let Ok(mut t) = high_contrast_text.single_mut() {
|
||||
**t = on_off_label(settings.0.high_contrast_mode);
|
||||
}
|
||||
}
|
||||
SettingsButton::ToggleReduceMotion => {
|
||||
settings.0.reduce_motion_mode = !settings.0.reduce_motion_mode;
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
if let Ok(mut t) = reduce_motion_text.single_mut() {
|
||||
**t = on_off_label(settings.0.reduce_motion_mode);
|
||||
}
|
||||
}
|
||||
SettingsButton::ToggleTouchInputMode => {
|
||||
use solitaire_data::settings::TouchInputMode;
|
||||
settings.0.touch_input_mode = match settings.0.touch_input_mode {
|
||||
TouchInputMode::OneTap => TouchInputMode::TapToSelect,
|
||||
TouchInputMode::TapToSelect => TouchInputMode::OneTap,
|
||||
};
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
// Text refreshed by `update_touch_input_mode_text` next frame.
|
||||
}
|
||||
SettingsButton::ToggleWinnableDealsOnly => {
|
||||
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
// The Text node is refreshed by `update_winnable_deals_only_text`
|
||||
// on the next frame via `settings.is_changed()`.
|
||||
}
|
||||
SettingsButton::ToggleAnalytics => {
|
||||
settings.0.analytics_enabled = !settings.0.analytics_enabled;
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
// Text refreshed by `update_analytics_enabled_text` next frame.
|
||||
}
|
||||
SettingsButton::ToggleSmartDefaultSize => {
|
||||
settings.0.disable_smart_default_size = !settings.0.disable_smart_default_size;
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
// The Text node is refreshed by
|
||||
// `update_smart_default_size_text` next frame. The
|
||||
// sizer system is gated only at startup, so flipping
|
||||
// this mid-session takes effect on the next launch —
|
||||
// documented on the field in `solitaire_data::Settings`.
|
||||
}
|
||||
SettingsButton::SelectCardBack(idx) => {
|
||||
settings.0.selected_card_back = *idx;
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
SettingsButton::SelectBackground(idx) => {
|
||||
settings.0.selected_background = *idx;
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
SettingsButton::SelectTheme(theme_id) => {
|
||||
if settings.0.selected_theme_id != *theme_id {
|
||||
settings.0.selected_theme_id = theme_id.clone();
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SettingsButton::ScanThemes => {
|
||||
// Handled by `handle_scan_themes`.
|
||||
}
|
||||
SettingsButton::SyncNow
|
||||
| SettingsButton::ConnectSync
|
||||
| SettingsButton::DisconnectSync
|
||||
| SettingsButton::DeleteAccount => {
|
||||
// Handled by `handle_sync_buttons`.
|
||||
}
|
||||
SettingsButton::Done => {
|
||||
screen.0 = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
|
||||
/// and Delete Account. Split from `handle_settings_buttons` to stay within
|
||||
/// Bevy's 16-parameter system limit.
|
||||
pub(super) fn handle_sync_buttons(
|
||||
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||||
mut screen: ResMut<SettingsScreen>,
|
||||
) {
|
||||
for (interaction, button) in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
match button {
|
||||
SettingsButton::SyncNow => {
|
||||
manual_sync.write(ManualSyncRequestEvent);
|
||||
}
|
||||
SettingsButton::ConnectSync => {
|
||||
// Close settings before the sync-setup modal opens so the
|
||||
// guard in open_sync_setup_modal doesn't block on our own scrim.
|
||||
screen.0 = false;
|
||||
configure_sync.write(SyncConfigureRequestEvent);
|
||||
}
|
||||
SettingsButton::DisconnectSync => {
|
||||
logout_sync.write(SyncLogoutRequestEvent);
|
||||
}
|
||||
SettingsButton::DeleteAccount => {
|
||||
delete_account.write(DeleteAccountRequestEvent);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
||||
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
||||
/// background pickers), and the "Sync Now" button. The "Done" button is
|
||||
/// already tagged by `attach_focusable_to_modal_buttons` (it carries
|
||||
/// [`ModalButton`]) and is filtered out here.
|
||||
///
|
||||
/// Walks ancestors via [`ChildOf`] to find the [`ModalScrim`] that owns
|
||||
/// the panel so the new [`Focusable`]'s group is bound to that scrim —
|
||||
/// same defensive shape as the Phase 1 / 2 attach systems.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn attach_focusable_to_settings_buttons(
|
||||
mut commands: Commands,
|
||||
new_buttons: Query<
|
||||
(Entity, &SettingsButton),
|
||||
(With<Button>, Without<Focusable>, Without<ModalButton>),
|
||||
>,
|
||||
parents: Query<&ChildOf>,
|
||||
scrims: Query<(), With<ModalScrim>>,
|
||||
) {
|
||||
for (button, settings_button) in &new_buttons {
|
||||
let mut current = button;
|
||||
let mut scrim_entity: Option<Entity> = None;
|
||||
for _ in 0..32 {
|
||||
if scrims.get(current).is_ok() {
|
||||
scrim_entity = Some(current);
|
||||
break;
|
||||
}
|
||||
match parents.get(current) {
|
||||
Ok(parent) => current = parent.parent(),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if let Some(scrim) = scrim_entity {
|
||||
commands.entity(button).insert(Focusable {
|
||||
group: FocusGroup::Modal(scrim),
|
||||
order: settings_button.focus_order(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical padding (logical px) added around the focused button when
|
||||
/// scrolling it into view. Keeps the focus ring's halo visible above /
|
||||
/// below the viewport edge.
|
||||
const FOCUS_SCROLL_PADDING: f32 = SPACE_2;
|
||||
|
||||
/// When the focused entity sits outside the visible Settings scroll
|
||||
/// viewport, adjust the viewport's [`ScrollPosition`] so the button is
|
||||
/// fully visible. No-op when:
|
||||
///
|
||||
/// - `FocusedButton` is `None`
|
||||
/// - the focused entity has no [`UiGlobalTransform`] / [`ComputedNode`]
|
||||
/// (e.g. a freshly-spawned modal hasn't laid out yet)
|
||||
/// - the focused entity is not a descendant of the
|
||||
/// [`SettingsPanelScrollable`] container
|
||||
///
|
||||
/// The viewport's visible Y range is `[scroll_y, scroll_y +
|
||||
/// viewport_height]` in physical pixels (matching `ComputedNode.size`).
|
||||
/// The focused button's vertical extent is computed from its
|
||||
/// `UiGlobalTransform.translation.y` (centre, physical) ± half its
|
||||
/// `ComputedNode.size.y`. Because the scroll container's local
|
||||
/// coordinates run [0, content_height] and the visible window is
|
||||
/// [scroll_y, scroll_y + viewport], we convert the button's window-
|
||||
/// space Y to container-local Y by subtracting the container's window-
|
||||
/// space top and adding the current scroll offset.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn scroll_focus_into_view(
|
||||
focused: Res<FocusedButton>,
|
||||
parents: Query<&ChildOf>,
|
||||
nodes: Query<(&UiGlobalTransform, &ComputedNode)>,
|
||||
mut containers: Query<
|
||||
(&mut ScrollPosition, &UiGlobalTransform, &ComputedNode),
|
||||
With<SettingsPanelScrollable>,
|
||||
>,
|
||||
) {
|
||||
let Some(target) = focused.0 else { return };
|
||||
// Gather button geometry.
|
||||
let Ok((target_transform, target_node)) = nodes.get(target) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Walk ancestors looking for the scroll container. Bounded to keep
|
||||
// a malformed hierarchy from hanging the system.
|
||||
let mut current = target;
|
||||
let mut container_entity: Option<Entity> = None;
|
||||
for _ in 0..32 {
|
||||
if containers.get(current).is_ok() {
|
||||
container_entity = Some(current);
|
||||
break;
|
||||
}
|
||||
match parents.get(current) {
|
||||
Ok(parent) => current = parent.parent(),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let Some(container) = container_entity else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((mut scroll, container_transform, container_node)) = containers.get_mut(container)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Geometry is reported in physical pixels by `ComputedNode.size` and
|
||||
// `UiGlobalTransform.translation`. `ScrollPosition` is in logical px,
|
||||
// so convert via `inverse_scale_factor` before we write.
|
||||
let inv = target_node.inverse_scale_factor;
|
||||
let target_height = target_node.size().y;
|
||||
let target_centre_y = target_transform.translation.y;
|
||||
let target_top = target_centre_y - target_height * 0.5;
|
||||
let target_bottom = target_centre_y + target_height * 0.5;
|
||||
|
||||
let container_height = container_node.size().y;
|
||||
let container_top = container_transform.translation.y - container_height * 0.5;
|
||||
|
||||
// Convert button window-space Y to container-local Y. The container
|
||||
// is currently scrolled by `scroll.0.y` *logical* pixels — multiply
|
||||
// by physical-per-logical to compare with physical pixel extents.
|
||||
let scroll_phys = scroll.0.y / inv.max(f32::EPSILON);
|
||||
let viewport_top = container_top + scroll_phys;
|
||||
let viewport_bottom = viewport_top + container_height;
|
||||
|
||||
// Layout may not have run yet (zero size on first frame) — no
|
||||
// sensible scroll target until the container has dimensions.
|
||||
if container_height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let pad_phys = FOCUS_SCROLL_PADDING / inv.max(f32::EPSILON);
|
||||
if target_top < viewport_top {
|
||||
// Button extends above the viewport — scroll up.
|
||||
let new_top = target_top - pad_phys;
|
||||
let delta = new_top - viewport_top;
|
||||
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||||
} else if target_bottom > viewport_bottom {
|
||||
// Button extends below the viewport — scroll down.
|
||||
let new_bottom = target_bottom + pad_phys;
|
||||
let delta = new_bottom - viewport_bottom;
|
||||
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolls the settings panel inner card in response to mouse-wheel events.
|
||||
///
|
||||
/// `offset_y` increases downward (0 = top of content). Scrolling down (ev.y < 0)
|
||||
/// adds to the offset; scrolling up subtracts. Clamped to >= 0 so it never
|
||||
/// scrolls past the top.
|
||||
pub(super) fn scroll_settings_panel(
|
||||
mut scroll_evr: MessageReader<MouseWheel>,
|
||||
screen: Res<SettingsScreen>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<SettingsPanelScrollable>>,
|
||||
) {
|
||||
if !screen.0 {
|
||||
scroll_evr.clear();
|
||||
return;
|
||||
}
|
||||
let delta_y: f32 = scroll_evr
|
||||
.read()
|
||||
.map(|ev| match ev.unit {
|
||||
MouseScrollUnit::Line => ev.y * 50.0,
|
||||
MouseScrollUnit::Pixel => ev.y,
|
||||
})
|
||||
.sum();
|
||||
if delta_y == 0.0 {
|
||||
return;
|
||||
}
|
||||
for mut sp in scrollables.iter_mut() {
|
||||
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Window geometry persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -0,0 +1,558 @@
|
||||
//! Persists `solitaire_data::Settings`, exposes hotkeys for live tuning,
|
||||
//! and renders a Bevy UI Settings panel.
|
||||
//!
|
||||
//! Hotkeys (always active, no overlay required):
|
||||
//! - `[` — decrease SFX volume by `SFX_STEP`
|
||||
//! - `]` — increase SFX volume by `SFX_STEP`
|
||||
//! - `O` — open / close the Settings panel
|
||||
//!
|
||||
//! On change, the plugin persists `settings.json` and fires
|
||||
//! `SettingsChangedEvent` so dependents (e.g. `AudioPlugin`) can react.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::input::mouse::MouseWheel;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{WindowMoved, WindowResized};
|
||||
use solitaire_data::{
|
||||
Settings, WindowGeometry, load_settings_from, save_settings_to, settings_file_path,
|
||||
};
|
||||
|
||||
use crate::events::{
|
||||
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||||
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
|
||||
};
|
||||
use crate::resources::SettingsScrollPos;
|
||||
use crate::theme::ThemeThumbnailPair;
|
||||
|
||||
mod input;
|
||||
mod ui;
|
||||
mod updates;
|
||||
|
||||
use input::*;
|
||||
use ui::*;
|
||||
use updates::*;
|
||||
|
||||
/// Side length of a swatch button in the card-back / background pickers.
|
||||
/// Smaller than the smallest spacing rung so it stays a literal.
|
||||
const SWATCH_PX: f32 = 40.0;
|
||||
|
||||
/// Side length of a small toggle / cycle button (e.g. the "⇄" affordances).
|
||||
/// Sub-rung sizing — kept as a literal, see SWATCH_PX. 32 px meets the
|
||||
/// minimum desktop hit-target threshold while staying smaller than `SWATCH_PX`.
|
||||
const ICON_BUTTON_PX: f32 = 32.0;
|
||||
|
||||
/// Volume adjustment step applied by the `[` / `]` hotkeys.
|
||||
pub const SFX_STEP: f32 = 0.1;
|
||||
|
||||
/// Bevy resource wrapping the current `Settings`.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct SettingsResource(pub Settings);
|
||||
|
||||
/// Persistence path for `SettingsResource`. `None` disables I/O (used in tests).
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct SettingsStoragePath(pub Option<PathBuf>);
|
||||
|
||||
/// Whether the Settings panel is currently visible. Toggle with `O`.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
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.
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct SettingsChangedEvent(pub Settings);
|
||||
|
||||
/// Marker on the root Settings panel entity.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct SettingsPanel;
|
||||
|
||||
/// Marks the `Text` node showing the live SFX volume value.
|
||||
#[derive(Component, Debug)]
|
||||
struct SfxVolumeText;
|
||||
|
||||
/// Marks the `Text` node showing the live music volume value.
|
||||
#[derive(Component, Debug)]
|
||||
struct MusicVolumeText;
|
||||
|
||||
/// Marks the `Text` node showing the current draw mode.
|
||||
#[derive(Component, Debug)]
|
||||
struct DrawModeText;
|
||||
|
||||
/// Marks the `Text` node showing the current theme.
|
||||
#[derive(Component, Debug)]
|
||||
struct ThemeText;
|
||||
|
||||
/// Marks the `Text` node showing the live sync status.
|
||||
#[derive(Component, Debug)]
|
||||
struct SyncStatusText;
|
||||
|
||||
/// Marks the `Text` node showing the active card-back index.
|
||||
#[derive(Component, Debug)]
|
||||
struct CardBackText;
|
||||
|
||||
/// Marks the `Text` node showing the current animation speed.
|
||||
#[derive(Component, Debug)]
|
||||
struct AnimSpeedText;
|
||||
|
||||
/// Marks the `Text` node showing the active background index.
|
||||
#[derive(Component, Debug)]
|
||||
struct BackgroundText;
|
||||
|
||||
/// Marks the `Text` node showing the current color-blind mode state.
|
||||
#[derive(Component, Debug)]
|
||||
struct ColorBlindText;
|
||||
|
||||
/// Marks the `Text` node showing the current high-contrast mode state.
|
||||
#[derive(Component, Debug)]
|
||||
struct HighContrastText;
|
||||
|
||||
/// Marks the `Text` node showing the current reduce-motion mode state.
|
||||
#[derive(Component, Debug)]
|
||||
struct ReduceMotionText;
|
||||
|
||||
/// Marks the `Text` node showing the current touch input mode state.
|
||||
#[derive(Component, Debug)]
|
||||
struct TouchInputModeText;
|
||||
|
||||
/// Marks the `Text` node showing the live tooltip-delay value.
|
||||
#[derive(Component, Debug)]
|
||||
struct TooltipDelayText;
|
||||
|
||||
/// Marks the `Text` node showing the live time-bonus-multiplier value.
|
||||
#[derive(Component, Debug)]
|
||||
struct TimeBonusMultiplierText;
|
||||
|
||||
/// Marks the `Text` node showing the live replay-playback per-move
|
||||
/// interval value. The Gameplay-section row beside this label lets the
|
||||
/// player tune `Settings::replay_move_interval_secs`.
|
||||
#[derive(Component, Debug)]
|
||||
struct ReplayMoveIntervalText;
|
||||
|
||||
/// Marks the `Text` node showing the current "Winnable deals only"
|
||||
/// state ("ON" / "OFF") in the Gameplay section.
|
||||
#[derive(Component, Debug)]
|
||||
struct WinnableDealsOnlyText;
|
||||
|
||||
/// Marks the `Text` node showing the current "Smart window size"
|
||||
/// state ("ON" / "OFF") in the Gameplay section. The flag is stored
|
||||
/// negatively in `Settings::disable_smart_default_size`, so the
|
||||
/// label inverts: "ON" = smart sizing enabled (the default).
|
||||
#[derive(Component, Debug)]
|
||||
struct SmartDefaultSizeText;
|
||||
|
||||
/// Marks the `Text` node showing the current "Share usage data" (analytics)
|
||||
/// state ("ON" / "OFF") in the Privacy section.
|
||||
#[derive(Component, Debug)]
|
||||
struct AnalyticsEnabledText;
|
||||
|
||||
/// Marks the scrollable inner card so the mouse-wheel system can target it.
|
||||
#[derive(Component, Debug)]
|
||||
struct SettingsPanelScrollable;
|
||||
|
||||
/// Marks the scrollable inner card so its `ScrollPosition` can be read before despawn.
|
||||
#[derive(Component, Debug)]
|
||||
struct SettingsScrollNode;
|
||||
|
||||
/// Snapshot row used by [`spawn_settings_panel`] to render the card-art
|
||||
/// theme picker. Carries the `ThemeRegistry` entry's display fields plus
|
||||
/// the (optional) thumbnail pair from [`ThemeThumbnailCache`]. A `None`
|
||||
/// thumbnail means the picker should render a placeholder swatch — used
|
||||
/// when the cache hasn't generated handles yet, or when a user theme
|
||||
/// is missing one of the required preview SVGs.
|
||||
#[derive(Debug, Clone)]
|
||||
struct ThemePickerEntry {
|
||||
/// Stable theme id (matches `ThemeMeta::id`).
|
||||
id: String,
|
||||
/// Player-facing label.
|
||||
display_name: String,
|
||||
/// Pre-generated picker preview pair, when ready. `None` collapses
|
||||
/// the chip to its plain-text fallback.
|
||||
thumbnails: Option<ThemeThumbnailPair>,
|
||||
}
|
||||
|
||||
/// Tags interactive buttons inside the Settings panel.
|
||||
#[derive(Component, Debug)]
|
||||
enum SettingsButton {
|
||||
SfxDown,
|
||||
SfxUp,
|
||||
MusicDown,
|
||||
MusicUp,
|
||||
ToggleDrawMode,
|
||||
CycleAnimSpeed,
|
||||
/// Decrement the tooltip-hover dwell delay by one step.
|
||||
TooltipDelayDown,
|
||||
/// Increment the tooltip-hover dwell delay by one step.
|
||||
TooltipDelayUp,
|
||||
/// Decrement the cosmetic time-bonus multiplier by one step.
|
||||
TimeBonusDown,
|
||||
/// Increment the cosmetic time-bonus multiplier by one step.
|
||||
TimeBonusUp,
|
||||
/// Decrement the replay-playback per-move interval by one step
|
||||
/// (i.e. speed playback up).
|
||||
ReplayMoveIntervalDown,
|
||||
/// Increment the replay-playback per-move interval by one step
|
||||
/// (i.e. slow playback down).
|
||||
ReplayMoveIntervalUp,
|
||||
ToggleTheme,
|
||||
ToggleColorBlind,
|
||||
/// Toggle the [`Settings::high_contrast_mode`] flag — boosts
|
||||
/// foreground / suit-red glyphs to higher-luminance variants per
|
||||
/// `design-system.md` §Accessibility (#2).
|
||||
ToggleHighContrast,
|
||||
/// Toggle the [`Settings::reduce_motion_mode`] flag — suppresses
|
||||
/// non-essential motion (card-slide animations become instant
|
||||
/// snaps) per `design-system.md` §Accessibility (#3).
|
||||
ToggleReduceMotion,
|
||||
/// Toggle [`Settings::touch_input_mode`] between `OneTap`
|
||||
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
|
||||
/// a card/stack, second tap on a target pile moves it).
|
||||
ToggleTouchInputMode,
|
||||
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
||||
/// random Classic-mode deals are filtered through
|
||||
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
|
||||
/// winnable (or the retry cap is hit). Off by default.
|
||||
ToggleWinnableDealsOnly,
|
||||
/// Toggle the inverse of [`Settings::disable_smart_default_size`].
|
||||
/// When the visible label reads "ON", the launch-time window
|
||||
/// sizer scales the window to ~70 % of the primary monitor on a
|
||||
/// fresh install; "OFF" pins the literal 1280×800 baseline. The
|
||||
/// flag only affects launches without saved geometry — the
|
||||
/// player's last window size always wins.
|
||||
ToggleSmartDefaultSize,
|
||||
/// Toggle [`Settings::analytics_enabled`]. Only rendered when a
|
||||
/// Matomo URL is configured.
|
||||
ToggleAnalytics,
|
||||
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
ScanThemes,
|
||||
SyncNow,
|
||||
/// Open the sync-server Connect modal (shown when backend = Local).
|
||||
ConnectSync,
|
||||
/// Disconnect from the sync server (shown when backend = SolitaireServer).
|
||||
DisconnectSync,
|
||||
/// Open the account-deletion confirmation modal.
|
||||
DeleteAccount,
|
||||
Done,
|
||||
/// Select a specific card-back by index from the picker row.
|
||||
SelectCardBack(usize),
|
||||
/// Select a specific background by index from the picker row.
|
||||
SelectBackground(usize),
|
||||
/// Select a specific card-art theme by `meta.id` from the
|
||||
/// `ThemeRegistry`. The string is owned so the click handler can
|
||||
/// hand it directly to `Settings::selected_theme_id`.
|
||||
SelectTheme(String),
|
||||
}
|
||||
|
||||
impl SettingsButton {
|
||||
/// Tab-walk priority — lower numbers visited first. Visual reading
|
||||
/// order is top-to-bottom by section, left-to-right inside each row.
|
||||
/// Two buttons in the same picker row receive the same `order`;
|
||||
/// `handle_focus_keys` then breaks ties by entity index, which
|
||||
/// matches `Children` spawn order inside each row.
|
||||
fn focus_order(&self) -> i32 {
|
||||
match self {
|
||||
// Audio section
|
||||
SettingsButton::SfxDown => 10,
|
||||
SettingsButton::SfxUp => 11,
|
||||
SettingsButton::MusicDown => 20,
|
||||
SettingsButton::MusicUp => 21,
|
||||
// Gameplay section
|
||||
SettingsButton::ToggleDrawMode => 30,
|
||||
SettingsButton::ToggleWinnableDealsOnly => 35,
|
||||
SettingsButton::CycleAnimSpeed => 40,
|
||||
SettingsButton::TooltipDelayDown => 45,
|
||||
SettingsButton::TooltipDelayUp => 46,
|
||||
SettingsButton::TimeBonusDown => 47,
|
||||
SettingsButton::TimeBonusUp => 48,
|
||||
// Replay-speed slider — last Gameplay-section row, so it
|
||||
// sits between TimeBonusUp (48) and the Cosmetic section.
|
||||
SettingsButton::ReplayMoveIntervalDown => 49,
|
||||
SettingsButton::ReplayMoveIntervalUp => 49,
|
||||
// Smart-default-size toggle — sits at the end of Gameplay.
|
||||
SettingsButton::ToggleSmartDefaultSize => 50,
|
||||
// Privacy section — just before Sync.
|
||||
SettingsButton::ToggleAnalytics => 89,
|
||||
// Cosmetic section
|
||||
SettingsButton::ToggleTheme => 55,
|
||||
SettingsButton::ToggleColorBlind => 60,
|
||||
// Accessibility-section toggles sit alongside Color-blind so
|
||||
// tab-walk visits all three a11y flags in the same vertical
|
||||
// run before continuing to the picker rows.
|
||||
SettingsButton::ToggleHighContrast => 61,
|
||||
SettingsButton::ToggleReduceMotion => 62,
|
||||
SettingsButton::ToggleTouchInputMode => 63,
|
||||
// Picker rows — every swatch in a row shares the row's
|
||||
// priority so entity-index tiebreaking yields left → right.
|
||||
SettingsButton::SelectCardBack(_) => 70,
|
||||
SettingsButton::SelectBackground(_) => 80,
|
||||
SettingsButton::SelectTheme(_) => 85,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SettingsButton::ScanThemes => 86,
|
||||
// Sync section
|
||||
SettingsButton::SyncNow => 90,
|
||||
SettingsButton::ConnectSync => 91,
|
||||
SettingsButton::DisconnectSync => 92,
|
||||
SettingsButton::DeleteAccount => 93,
|
||||
// Done is tagged by `attach_focusable_to_modal_buttons` and
|
||||
// never reaches `attach_focusable_to_settings_buttons`; the
|
||||
// value here is only a fallback for completeness.
|
||||
SettingsButton::Done => 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin that owns the settings lifecycle.
|
||||
pub struct SettingsPlugin {
|
||||
/// Path to `settings.json`. `None` in headless/test mode.
|
||||
pub storage_path: Option<PathBuf>,
|
||||
/// When `false`, panel spawn/despawn systems are not registered.
|
||||
/// Use [`SettingsPlugin::headless`] for tests running under `MinimalPlugins`.
|
||||
pub ui_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for SettingsPlugin {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
storage_path: settings_file_path(),
|
||||
ui_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsPlugin {
|
||||
/// No persistence, no UI — safe to use under `MinimalPlugins` in tests.
|
||||
pub fn headless() -> Self {
|
||||
Self {
|
||||
storage_path: None,
|
||||
ui_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for SettingsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let loaded = match &self.storage_path {
|
||||
Some(path) => load_settings_from(path),
|
||||
None => Settings::default(),
|
||||
};
|
||||
app.insert_resource(SettingsResource(loaded))
|
||||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||
.init_resource::<SettingsScreen>()
|
||||
.init_resource::<SettingsScrollPos>()
|
||||
.init_resource::<PendingWindowGeometry>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<SyncConfigureRequestEvent>()
|
||||
.add_message::<SyncLogoutRequestEvent>()
|
||||
.add_message::<DeleteAccountRequestEvent>()
|
||||
.add_message::<ToggleSettingsRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<MouseWheel>()
|
||||
.add_message::<TouchInput>()
|
||||
// `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,
|
||||
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
|
||||
record_window_geometry_changes,
|
||||
persist_window_geometry_after_debounce,
|
||||
),
|
||||
);
|
||||
|
||||
if self.ui_enabled {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
sync_settings_panel_visibility,
|
||||
handle_settings_buttons,
|
||||
handle_sync_buttons,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
handle_scan_themes,
|
||||
update_sync_status_text,
|
||||
update_card_back_text,
|
||||
update_background_text,
|
||||
update_anim_speed_text,
|
||||
update_color_blind_text,
|
||||
update_high_contrast_text,
|
||||
update_high_contrast_borders.run_if(resource_changed::<SettingsResource>),
|
||||
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
|
||||
update_reduce_motion_text,
|
||||
update_touch_input_mode_text,
|
||||
update_tooltip_delay_text,
|
||||
update_time_bonus_multiplier_text,
|
||||
update_replay_move_interval_text,
|
||||
update_winnable_deals_only_text,
|
||||
update_smart_default_size_text,
|
||||
),
|
||||
);
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
update_analytics_enabled_text,
|
||||
attach_focusable_to_settings_buttons,
|
||||
),
|
||||
);
|
||||
app.add_systems(Update, scroll_focus_into_view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn persist(path: &SettingsStoragePath, settings: &Settings) {
|
||||
let Some(target) = &path.0 else { return };
|
||||
if let Err(e) = save_settings_to(target, settings) {
|
||||
warn!("failed to save settings: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns the next unlocked index after `current` in the sorted `unlocked` list.
|
||||
/// Wraps around. Falls back to `unlocked[0]` if `current` is not found.
|
||||
#[cfg(test)]
|
||||
fn cycle_unlocked(unlocked: &[usize], current: usize) -> usize {
|
||||
if unlocked.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let pos = unlocked.iter().position(|&i| i == current).unwrap_or(0);
|
||||
unlocked[(pos + 1) % unlocked.len()]
|
||||
}
|
||||
|
||||
/// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,655 @@
|
||||
use super::*;
|
||||
use crate::ui_focus::{FocusRow, Focusable};
|
||||
use crate::ui_modal::ModalButton;
|
||||
use crate::ui_tooltip::Tooltip;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(SettingsPlugin::headless());
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn press(app: &mut App, key: KeyCode) {
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(key);
|
||||
input.clear();
|
||||
input.press(key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_loaded() {
|
||||
let app = headless_app();
|
||||
assert_eq!(
|
||||
app.world().resource::<SettingsResource>().0,
|
||||
Settings::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_left_bracket_decreases_volume_and_emits_event() {
|
||||
let mut app = headless_app();
|
||||
let before = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||
|
||||
press(&mut app, KeyCode::BracketLeft);
|
||||
app.update();
|
||||
|
||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||
assert!(after < before);
|
||||
|
||||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert_eq!(cursor.read(events).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_right_bracket_increases_volume() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.sfx_volume = 0.5;
|
||||
|
||||
press(&mut app, KeyCode::BracketRight);
|
||||
app.update();
|
||||
|
||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||
assert!((after - 0.6).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamped_change_does_not_emit_event() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.sfx_volume = 1.0;
|
||||
|
||||
press(&mut app, KeyCode::BracketRight);
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert_eq!(cursor.read(events).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_clamped_at_zero_does_not_emit_event() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.sfx_volume = 0.0;
|
||||
|
||||
press(&mut app, KeyCode::BracketLeft);
|
||||
app.update();
|
||||
|
||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||
assert!(after >= 0.0, "volume must not go below zero");
|
||||
|
||||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert_eq!(
|
||||
cursor.read(events).count(),
|
||||
0,
|
||||
"no event when clamped at floor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_o_toggles_settings_screen_flag() {
|
||||
let mut app = headless_app();
|
||||
assert!(
|
||||
!app.world().resource::<SettingsScreen>().0,
|
||||
"screen is closed initially"
|
||||
);
|
||||
|
||||
press(&mut app, KeyCode::KeyO);
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<SettingsScreen>().0,
|
||||
"O opens settings"
|
||||
);
|
||||
|
||||
press(&mut app, KeyCode::KeyO);
|
||||
app.update();
|
||||
assert!(
|
||||
!app.world().resource::<SettingsScreen>().0,
|
||||
"second O closes settings"
|
||||
);
|
||||
}
|
||||
|
||||
// cycle_unlocked pure-function tests
|
||||
#[test]
|
||||
fn cycle_unlocked_wraps_at_end() {
|
||||
// [0, 1, 2] → cycling from 2 wraps to 0
|
||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 2), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_unlocked_advances_normally() {
|
||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 0), 1);
|
||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 1), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_unlocked_single_element_stays() {
|
||||
// Only one unlockable — cycling always returns it.
|
||||
assert_eq!(cycle_unlocked(&[0], 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_unlocked_current_not_in_list_falls_back_to_second() {
|
||||
// current=5 is not in [0,1,2]; falls back to pos=0, so next = unlocked[1] = 1
|
||||
assert_eq!(cycle_unlocked(&[0, 1, 2], 5), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_unlocked_empty_returns_zero() {
|
||||
assert_eq!(cycle_unlocked(&[], 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_is_noop_when_settings_panel_closed() {
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
let mut app = headless_app();
|
||||
// Panel starts closed (SettingsScreen(false)); spawn a scrollable entity.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
|
||||
.id();
|
||||
// Send a downward scroll event while the panel is closed.
|
||||
app.world_mut().write_message(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: -3.0,
|
||||
window: Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
// ScrollPosition must remain at 0.0 — panel was closed.
|
||||
let offset = app
|
||||
.world()
|
||||
.entity(entity)
|
||||
.get::<ScrollPosition>()
|
||||
.unwrap()
|
||||
.0
|
||||
.y;
|
||||
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_moves_offset_when_panel_open() {
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
let mut app = headless_app();
|
||||
// Open the panel.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
// Spawn a scrollable entity with an existing offset so we can distinguish clamping.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
SettingsPanelScrollable,
|
||||
ScrollPosition(Vec2::new(0.0, 100.0)),
|
||||
))
|
||||
.id();
|
||||
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
|
||||
app.world_mut().write_message(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: -2.0,
|
||||
window: Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
let offset = app
|
||||
.world()
|
||||
.entity(entity)
|
||||
.get::<ScrollPosition>()
|
||||
.unwrap()
|
||||
.0
|
||||
.y;
|
||||
assert!(
|
||||
(offset - 200.0).abs() < 1e-3,
|
||||
"scrolling down should increase offset_y; got {offset}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 3 — keyboard focus ring, Settings buttons + FocusRow
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Headless app that runs the *real* (UI-enabled) `SettingsPlugin`
|
||||
/// alongside `UiModalPlugin` and `UiFocusPlugin`, so the spawn /
|
||||
/// auto-tag systems fire end-to-end without writing to disk.
|
||||
fn headless_app_with_focus() -> App {
|
||||
use crate::ui_focus::UiFocusPlugin;
|
||||
use crate::ui_modal::UiModalPlugin;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
.add_plugins(SettingsPlugin {
|
||||
// No persistence — keep the test isolated.
|
||||
storage_path: None,
|
||||
ui_enabled: true,
|
||||
});
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_buttons_get_focusable_marker() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
// Open the panel.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
// Two more ticks: the first runs `sync_settings_panel_visibility`
|
||||
// and queues the spawn commands; the second flushes them and
|
||||
// runs `attach_focusable_to_settings_buttons`.
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// Every bespoke `SettingsButton` (not `Done`, which is also a
|
||||
// `ModalButton`) must carry a `Focusable`.
|
||||
let untagged: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
untagged.is_empty(),
|
||||
"every bespoke Settings button must carry Focusable; missing: {:?}",
|
||||
untagged
|
||||
);
|
||||
|
||||
// And there must be at least one tagged `SettingsButton` so the
|
||||
// assertion above isn't vacuously true (the panel really did
|
||||
// spawn).
|
||||
let tagged_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, With<Focusable>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
tagged_count >= 6,
|
||||
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every bespoke `SettingsButton` (volume +/−, toggles, swatches,
|
||||
/// Sync Now) must spawn with a `Tooltip` so the glyph-only icons and
|
||||
/// indexed swatches carry hover-reveal context. Mirrors
|
||||
/// `settings_buttons_get_focusable_marker` (Phase 3 focus test) so
|
||||
/// the invariant — every interactive Settings element except the
|
||||
/// `Done` modal button has a tooltip — is asserted consistently.
|
||||
#[test]
|
||||
fn settings_buttons_carry_tooltip() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
// Open the panel and let spawn + child-flush run.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
|
||||
// also a `ModalButton`) may be missing a `Tooltip`.
|
||||
let untipped: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
assert!(
|
||||
untipped.is_empty(),
|
||||
"every bespoke Settings button must carry Tooltip; missing: {:?}",
|
||||
untipped
|
||||
);
|
||||
|
||||
// And there must be at least 6 tipped buttons so the assertion
|
||||
// above isn't vacuously true: SFX +/−, Music +/−, Draw Mode,
|
||||
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
|
||||
// card-back and one background swatch — well over the floor.
|
||||
let tipped_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, With<Tooltip>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
tipped_count >= 6,
|
||||
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
||||
);
|
||||
|
||||
// Spot-check: with default (Local) settings the Connect button
|
||||
// spawns. We verify its tooltip carries the canonical microcopy.
|
||||
let connect_tip = app
|
||||
.world_mut()
|
||||
.query::<(&SettingsButton, &Tooltip)>()
|
||||
.iter(app.world())
|
||||
.find_map(|(btn, tip)| matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone()))
|
||||
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
||||
assert_eq!(
|
||||
connect_tip.as_ref(),
|
||||
"Connect to a self-hosted Ferrous Solitaire sync server.",
|
||||
"ConnectSync tooltip must use the canonical microcopy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_picker_rows_get_focus_row_marker() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// Two picker rows are spawned (card-back + background); each
|
||||
// must carry the FocusRow marker.
|
||||
let row_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<FocusRow>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
row_count >= 2,
|
||||
"expected at least two FocusRow containers (card-back + background); got {row_count}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
|
||||
/// at least one theme and the [`ThemeThumbnailCache`] holds a
|
||||
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
|
||||
/// rendered chip carries a [`ThemeThumbnailMarker`]-tagged
|
||||
/// `ImageNode` for each preview slot.
|
||||
#[test]
|
||||
fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
|
||||
use crate::theme::{ThemeEntry, ThemeRegistry, ThemeThumbnailCache, ThemeThumbnailPair};
|
||||
|
||||
let mut app = headless_app_with_focus();
|
||||
// Prime an Assets<Image> resource so we can mint stable handles
|
||||
// for the synthetic thumbnail pair.
|
||||
app.init_resource::<Assets<Image>>();
|
||||
let (ace_handle, back_handle) = {
|
||||
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
|
||||
let ace = images.add(Image::default());
|
||||
let back = images.add(Image::default());
|
||||
(ace, back)
|
||||
};
|
||||
// Inject one theme entry + a matching thumbnail pair.
|
||||
app.insert_resource(ThemeRegistry {
|
||||
entries: vec![ThemeEntry {
|
||||
id: "test_theme".into(),
|
||||
display_name: "Test Theme".into(),
|
||||
manifest_url: "themes://test_theme/theme.ron".into(),
|
||||
meta: crate::theme::ThemeMeta {
|
||||
id: "test_theme".into(),
|
||||
name: "Test Theme".into(),
|
||||
author: "x".into(),
|
||||
version: "x".into(),
|
||||
card_aspect: (2, 3),
|
||||
},
|
||||
}],
|
||||
});
|
||||
let mut cache = ThemeThumbnailCache::default();
|
||||
cache.entries.insert(
|
||||
"test_theme".into(),
|
||||
ThemeThumbnailPair {
|
||||
ace: ace_handle.clone(),
|
||||
back: back_handle.clone(),
|
||||
},
|
||||
);
|
||||
app.insert_resource(cache);
|
||||
|
||||
// Open the panel and let the spawn + child-flush systems run.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// Find every ImageNode tagged with ThemeThumbnailMarker — the
|
||||
// theme picker chip for "test_theme" must contribute exactly
|
||||
// two of them (ace + back).
|
||||
let thumbnail_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
thumbnail_count >= 2,
|
||||
"expected at least one ace + back thumbnail (2 sprites); got {thumbnail_count}"
|
||||
);
|
||||
|
||||
// Spot-check: at least one thumbnail's image handle matches one
|
||||
// of the ones we inserted into the cache. This guards against a
|
||||
// future refactor that accidentally clones the wrong handle.
|
||||
let any_matches = app
|
||||
.world_mut()
|
||||
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
||||
.iter(app.world())
|
||||
.any(|node| node.image == ace_handle || node.image == back_handle);
|
||||
assert!(
|
||||
any_matches,
|
||||
"at least one rendered thumbnail must reuse the cached handle"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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: Entity::PLACEHOLDER,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
fn fire_move(app: &mut App, x: i32, y: i32) {
|
||||
app.world_mut().write_message(WindowMoved {
|
||||
window: 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]
|
||||
fn scroll_clamps_offset_to_zero_at_top() {
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
// Entity starts at 10 px offset.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
SettingsPanelScrollable,
|
||||
ScrollPosition(Vec2::new(0.0, 10.0)),
|
||||
))
|
||||
.id();
|
||||
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
|
||||
app.world_mut().write_message(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: 5.0,
|
||||
window: Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
let offset = app
|
||||
.world()
|
||||
.entity(entity)
|
||||
.get::<ScrollPosition>()
|
||||
.unwrap()
|
||||
.0
|
||||
.y;
|
||||
assert_eq!(
|
||||
offset, 0.0,
|
||||
"scrolling past top must clamp to 0, got {offset}"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,487 @@
|
||||
//! Per-frame value-text updater systems and their label helpers.
|
||||
|
||||
use super::*;
|
||||
|
||||
use solitaire_core::DrawStockConfig;
|
||||
use solitaire_data::{AnimSpeed, settings::Theme};
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||
use crate::theme::ThemeThumbnailCache;
|
||||
use crate::ui_modal::ModalScrim;
|
||||
use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder};
|
||||
|
||||
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
|
||||
/// despawns it when it becomes `false`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn sync_settings_panel_visibility(
|
||||
screen: Res<SettingsScreen>,
|
||||
panels: Query<Entity, With<SettingsPanel>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||||
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
||||
mut scroll_pos: ResMut<SettingsScrollPos>,
|
||||
mut commands: Commands,
|
||||
settings: Res<SettingsResource>,
|
||||
sync_status: Option<Res<SyncStatusResource>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||||
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
|
||||
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||||
) {
|
||||
if !screen.is_changed() {
|
||||
return;
|
||||
}
|
||||
if screen.0 {
|
||||
if panels.is_empty() && other_modal_scrims.is_empty() {
|
||||
let status_label = sync_status.map_or_else(
|
||||
|| "Status: local only".to_string(),
|
||||
|s| sync_status_label(&s.0),
|
||||
);
|
||||
let unlocked_backs = progress
|
||||
.as_ref()
|
||||
.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
|
||||
let unlocked_bgs = progress
|
||||
.as_ref()
|
||||
.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
|
||||
// Snapshot themes by id, display_name and (optional)
|
||||
// thumbnail pair so spawn_settings_panel doesn't have to
|
||||
// know about the registry / cache shapes. Empty when
|
||||
// ThemeRegistryPlugin isn't installed (tests under
|
||||
// MinimalPlugins) — the picker row simply won't render.
|
||||
// Missing thumbnails (cache not ready, or partial user
|
||||
// theme) leave `thumbnails: None` so the chip renders its
|
||||
// plain-text fallback instead of a broken sprite.
|
||||
let themes: Vec<ThemePickerEntry> = theme_registry
|
||||
.as_deref()
|
||||
.map(|r| {
|
||||
r.iter()
|
||||
.map(|e| ThemePickerEntry {
|
||||
id: e.id.clone(),
|
||||
display_name: e.display_name.clone(),
|
||||
thumbnails: theme_thumbs
|
||||
.as_deref()
|
||||
.and_then(|c| c.get(&e.id))
|
||||
.filter(|p| p.is_fully_populated())
|
||||
.cloned(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// The active card-art theme can supply its own back image —
|
||||
// see `card_plugin::CardImageSet::theme_back`. When that is
|
||||
// populated the legacy "Card Back" picker has no visible
|
||||
// effect, so we render it muted with an explanatory caption
|
||||
// rather than letting the player click swatches that do
|
||||
// nothing. Absent under `MinimalPlugins`; treated as
|
||||
// "no override" in that case.
|
||||
let theme_overrides_back = card_images
|
||||
.as_ref()
|
||||
.is_some_and(|cs| cs.theme_back.is_some());
|
||||
spawn_settings_panel(
|
||||
&mut commands,
|
||||
&settings.0,
|
||||
&status_label,
|
||||
unlocked_backs,
|
||||
unlocked_bgs,
|
||||
&themes,
|
||||
scroll_pos.0,
|
||||
font_res.as_deref(),
|
||||
theme_overrides_back,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Save the current scroll offset before despawning the panel.
|
||||
if let Ok(sp) = scroll_nodes.single() {
|
||||
scroll_pos.0 = sp.0.y;
|
||||
}
|
||||
for entity in &panels {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the sync-status text node current while the panel is open.
|
||||
pub(super) fn update_sync_status_text(
|
||||
sync_status: Option<Res<SyncStatusResource>>,
|
||||
mut text_nodes: Query<&mut Text, With<SyncStatusText>>,
|
||||
) {
|
||||
let Some(status) = sync_status else {
|
||||
return;
|
||||
};
|
||||
if !status.is_changed() {
|
||||
return;
|
||||
}
|
||||
let label = sync_status_label(&status.0);
|
||||
for mut text in &mut text_nodes {
|
||||
**text = label.clone();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_card_back_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<CardBackText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = card_back_label(settings.0.selected_card_back);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_background_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<BackgroundText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = background_label(settings.0.selected_background);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_anim_speed_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<AnimSpeedText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = anim_speed_label(&settings.0.animation_speed);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_color_blind_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<ColorBlindText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = color_blind_label(settings.0.color_blind_mode);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_high_contrast_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<HighContrastText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = on_off_label(settings.0.high_contrast_mode);
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints `BorderColor` on every entity tagged with
|
||||
/// [`HighContrastBorder`] based on `Settings::high_contrast_mode`.
|
||||
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
|
||||
/// (`#a0a0a0`). Compares against the current border colour and
|
||||
/// only mutates when different so Bevy's change-detection
|
||||
/// doesn't trigger repaints every frame.
|
||||
///
|
||||
/// Spec at `design-system.md` §Accessibility (#2): under HC,
|
||||
/// outlines boost from `#505050` (BORDER_STRONG) to `#a0a0a0` so
|
||||
/// modal panels, popover edges, and focus-ring carriers stay
|
||||
/// legible on low-quality displays / for low-vision users.
|
||||
///
|
||||
/// Tagged sites in v0.21.x: the modal scaffold's card border
|
||||
/// (`ui_modal::spawn_modal`). More sites can be tagged in
|
||||
/// follow-ups by adding `HighContrastBorder::with_default(...)`
|
||||
/// to their spawn tuple.
|
||||
pub(super) fn update_high_contrast_borders(
|
||||
settings: Res<SettingsResource>,
|
||||
mut borders: Query<(&HighContrastBorder, &mut BorderColor)>,
|
||||
) {
|
||||
let high_contrast = settings.0.high_contrast_mode;
|
||||
for (marker, mut border) in borders.iter_mut() {
|
||||
let target = if high_contrast {
|
||||
BORDER_SUBTLE_HC
|
||||
} else {
|
||||
marker.default_color
|
||||
};
|
||||
// Only mutate when actually different — avoids per-frame
|
||||
// change-detection churn. `border.left` is representative
|
||||
// because every tagged site uses `BorderColor::all(...)`.
|
||||
if border.left != target {
|
||||
*border = BorderColor::all(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints `BackgroundColor` on every entity tagged with
|
||||
/// [`HighContrastBackground`] based on `Settings::high_contrast_mode`.
|
||||
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
|
||||
/// (`#a0a0a0`). Compares against the current background and only
|
||||
/// mutates when different so Bevy's change-detection doesn't trigger
|
||||
/// repaints every frame.
|
||||
///
|
||||
/// Parallel to [`update_high_contrast_borders`]. Same on/off rule,
|
||||
/// same change-suppression idiom, different colour channel —
|
||||
/// `BackgroundColor` for tick marks, decorative strips, fine
|
||||
/// separators that paint their shape directly rather than via a
|
||||
/// `BorderColor` on a wider Node.
|
||||
///
|
||||
/// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track
|
||||
/// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`).
|
||||
///
|
||||
/// More sites can be tagged in follow-ups by adding
|
||||
/// `HighContrastBackground::with_default(...)` to their spawn tuple.
|
||||
pub(super) fn update_high_contrast_backgrounds(
|
||||
settings: Res<SettingsResource>,
|
||||
mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>,
|
||||
) {
|
||||
let high_contrast = settings.0.high_contrast_mode;
|
||||
for (marker, mut bg) in backgrounds.iter_mut() {
|
||||
let target = if high_contrast {
|
||||
marker.hc_color
|
||||
} else {
|
||||
marker.default_color
|
||||
};
|
||||
if bg.0 != target {
|
||||
*bg = BackgroundColor(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_reduce_motion_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = on_off_label(settings.0.reduce_motion_mode);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_touch_input_mode_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = touch_input_mode_label(&settings.0.touch_input_mode);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the live "Winnable deals only" toggle value in the
|
||||
/// Gameplay section whenever `SettingsResource` changes (button click,
|
||||
/// hand-edited `settings.json` reload, etc.).
|
||||
pub(super) fn update_winnable_deals_only_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<WinnableDealsOnlyText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = winnable_deals_only_label(settings.0.winnable_deals_only);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the live "Share usage data" toggle value in the Privacy section
|
||||
/// whenever `SettingsResource` changes.
|
||||
pub(super) fn update_analytics_enabled_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<AnalyticsEnabledText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = on_off_label(settings.0.analytics_enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the live "Smart window size" toggle value whenever
|
||||
/// `SettingsResource` changes. The flag is stored negatively as
|
||||
/// `disable_smart_default_size`, so the label inverts.
|
||||
pub(super) fn update_smart_default_size_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<SmartDefaultSizeText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = smart_default_size_label(!settings.0.disable_smart_default_size);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the live tooltip-delay value in the Gameplay section
|
||||
/// whenever `SettingsResource` changes (slider buttons, hand-edited
|
||||
/// settings.json reload, etc.).
|
||||
pub(super) fn update_tooltip_delay_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<TooltipDelayText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = tooltip_delay_label(settings.0.tooltip_delay_secs);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the live time-bonus-multiplier value in the Gameplay
|
||||
/// section whenever `SettingsResource` changes.
|
||||
pub(super) fn update_time_bonus_multiplier_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<TimeBonusMultiplierText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = time_bonus_label(settings.0.time_bonus_multiplier);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the live replay-playback per-move-interval value in the
|
||||
/// Gameplay section whenever `SettingsResource` changes (slider buttons,
|
||||
/// hand-edited settings.json reload, etc.).
|
||||
pub(super) fn update_replay_move_interval_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<ReplayMoveIntervalText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = replay_move_interval_label(settings.0.replay_move_interval_secs);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn card_back_label(idx: usize) -> String {
|
||||
if idx == 0 {
|
||||
"Default".to_string()
|
||||
} else {
|
||||
format!("Style {idx}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn background_label(idx: usize) -> String {
|
||||
if idx == 0 {
|
||||
"Default".to_string()
|
||||
} else {
|
||||
format!("Style {idx}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sync_status_label(status: &SyncStatus) -> String {
|
||||
match status {
|
||||
SyncStatus::Idle => "Status: idle".to_string(),
|
||||
SyncStatus::Syncing => "Status: syncing…".to_string(),
|
||||
SyncStatus::LastSynced(t) => {
|
||||
let secs = chrono::Utc::now()
|
||||
.signed_duration_since(*t)
|
||||
.num_seconds()
|
||||
.max(0);
|
||||
if secs < 60 {
|
||||
format!("Last synced: {secs}s ago")
|
||||
} else {
|
||||
format!("Last synced: {}m ago", secs / 60)
|
||||
}
|
||||
}
|
||||
SyncStatus::Error(e) => format!("Sync error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_mode_label(mode: &DrawStockConfig) -> String {
|
||||
match mode {
|
||||
DrawStockConfig::DrawOne => "Draw 1".into(),
|
||||
DrawStockConfig::DrawThree => "Draw 3".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn anim_speed_label(speed: &AnimSpeed) -> String {
|
||||
match speed {
|
||||
AnimSpeed::Normal => "Normal".into(),
|
||||
AnimSpeed::Fast => "Fast".into(),
|
||||
AnimSpeed::Instant => "Instant".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn theme_label(theme: &Theme) -> String {
|
||||
match theme {
|
||||
Theme::Green => "Green".into(),
|
||||
Theme::Blue => "Blue".into(),
|
||||
Theme::Dark => "Dark".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn color_blind_label(enabled: bool) -> String {
|
||||
if enabled { "ON".into() } else { "OFF".into() }
|
||||
}
|
||||
|
||||
/// Generic ON/OFF label shared by the high-contrast and reduce-
|
||||
/// motion accessibility toggles. Same format as
|
||||
/// [`color_blind_label`] / [`winnable_deals_only_label`] —
|
||||
/// keeping all simple boolean toggle rows visually uniform.
|
||||
pub(super) fn on_off_label(enabled: bool) -> String {
|
||||
if enabled { "ON".into() } else { "OFF".into() }
|
||||
}
|
||||
|
||||
/// Display string for the "Winnable deals only" toggle. Mirrors
|
||||
/// [`color_blind_label`] — "ON" / "OFF" — so the layout is uniform
|
||||
/// with the rest of the Gameplay-section toggles.
|
||||
pub(super) fn winnable_deals_only_label(enabled: bool) -> String {
|
||||
if enabled { "ON".into() } else { "OFF".into() }
|
||||
}
|
||||
|
||||
pub(super) fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInputMode) -> String {
|
||||
use solitaire_data::settings::TouchInputMode;
|
||||
match mode {
|
||||
TouchInputMode::OneTap => "One-tap".into(),
|
||||
TouchInputMode::TapToSelect => "Tap to select".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Display string for the "Smart window size" toggle. The argument
|
||||
/// is the *enabled* state (i.e. the inverse of the underlying
|
||||
/// `disable_smart_default_size` field) so reading the label gives
|
||||
/// the player intuitive ON/OFF semantics.
|
||||
pub(super) fn smart_default_size_label(enabled: bool) -> String {
|
||||
if enabled { "ON".into() } else { "OFF".into() }
|
||||
}
|
||||
|
||||
/// Formats the tooltip-hover delay for display in the Settings panel.
|
||||
/// `0.0` reads as `"Instant"` so the zero-delay case has a name; any
|
||||
/// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`).
|
||||
pub(super) fn tooltip_delay_label(secs: f32) -> String {
|
||||
if secs <= 0.0 {
|
||||
"Instant".into()
|
||||
} else {
|
||||
format!("{secs:.1} s")
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats the cosmetic time-bonus multiplier for display in the
|
||||
/// Settings panel. `0.0` reads as `"Off"` so the player understands the
|
||||
/// time-bonus row will be hidden; any other value prints as
|
||||
/// `"{n:.1}×"` (e.g. `"1.0×"`, `"1.5×"`).
|
||||
pub(super) fn time_bonus_label(value: f32) -> String {
|
||||
if value <= 0.0 {
|
||||
"Off".into()
|
||||
} else {
|
||||
format!("{value:.1}×")
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats the replay-playback per-move interval for display in the
|
||||
/// Settings panel. Mirrors [`tooltip_delay_label`] for parity — the
|
||||
/// readout is `"{n:.2} s/move"` (e.g. `"0.45 s/move"`, `"0.10 s/move"`),
|
||||
/// using two decimal places because the step is 0.05 s.
|
||||
pub(super) fn replay_move_interval_label(secs: f32) -> String {
|
||||
format!("{secs:.2} s/move")
|
||||
}
|
||||
@@ -1649,63 +1649,63 @@ function __wbg_get_imports() {
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114846, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hea4b5125da4e5ded);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114857, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfb2e9a2f0bbd9ecc);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9838, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0fec466277fb30e8);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9843, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h876550298b312ff8);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_3);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_3);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_4);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_4);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_5);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_5);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_6);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_6);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_7);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_7);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_8);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_8);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_9);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_9);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9834, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf05069bc44820cc5);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9840, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h657f46feffff6fe4);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h5e26b448f43bfba9);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9834, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h545edb23183e448a);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000d: function(arg0) {
|
||||
@@ -1769,55 +1769,55 @@ function __wbg_get_imports() {
|
||||
};
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h5e26b448f43bfba9(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h5e26b448f43bfba9(arg0, arg1);
|
||||
function wasm_bindgen__convert__closures_____invoke__h545edb23183e448a(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h545edb23183e448a(arg0, arg1);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_3(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_3(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_3(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_3(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_4(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_4(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_4(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_4(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_5(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_5(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_5(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_5(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_6(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_6(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_6(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_6(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_7(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_7(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_7(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_7(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_8(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_8(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_8(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_8(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_9(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_9(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_9(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_9(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__hea4b5125da4e5ded(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hea4b5125da4e5ded(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__hfb2e9a2f0bbd9ecc(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hfb2e9a2f0bbd9ecc(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h0fec466277fb30e8(arg0, arg1, arg2, arg3) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h0fec466277fb30e8(arg0, arg1, arg2, arg3);
|
||||
function wasm_bindgen__convert__closures_____invoke__h876550298b312ff8(arg0, arg1, arg2, arg3) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h876550298b312ff8(arg0, arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__hf05069bc44820cc5(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__hf05069bc44820cc5(arg0, arg1, isLikeNone(arg2) ? 0 : addToExternrefTable0(arg2));
|
||||
function wasm_bindgen__convert__closures_____invoke__h657f46feffff6fe4(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h657f46feffff6fe4(arg0, arg1, isLikeNone(arg2) ? 0 : addToExternrefTable0(arg2));
|
||||
}
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user