95df5421c9
Standard Klondike behaviour: any Ace can land in any empty foundation,
and that slot then claims the suit until the pile empties. The
previous PileType::Foundation(Suit) variant pre-assigned each of the
four foundations to a fixed suit ("C / D / H / S" placeholders) and
rejected mismatched Aces — non-standard and (per the smoke-test
feedback) confusing.
Replaces the variant payload with a slot index Foundation(u8) (0..=3)
and derives the claimed suit from the bottom card via a new
Pile::claimed_suit() method. The bottom card is, by construction,
the Ace that established the claim; using it directly eliminates an
entire class of "stuck claim after undo" bugs that a separate
claimed_suit field would have introduced.
can_place_on_foundation drops its suit parameter — the rule reduces
to "empty pile accepts any Ace; non-empty pile accepts the next
rank up of the bottom card's suit." Iteration sites across
input_plugin, cursor_plugin, selection_plugin, card_plugin,
auto_complete_plugin, game_plugin, layout, and hud_plugin all swap
the four-suit list for `(0..4u8).map(PileType::Foundation)`.
next_auto_complete_move now prefers a slot whose claimed_suit matches
the candidate card before falling back to the first empty slot for
an Ace — so the same suit consistently auto-targets the same slot
across the whole game, matching player expectations.
The HUD selection label and the hint toast read claimed_suit() and
fall back to "Foundation N" / "move to foundation" only when the
slot is empty. Empty foundation pile markers no longer render the
suit-letter children — they're plain translucent rectangles, matching
empty tableau placeholders.
Save-format invalidation: GameState gains a schema_version field
(serde-default to 1 for back-compat parsing of old files), the
constant is bumped to 2, and load_game_state_from rejects mismatched
schemas. Old in-progress saves silently fall through to "fresh game
on launch" — the user accepted this loss given the mechanic change.
Stats / progress / achievements / settings live in separate files,
contain no PileType data, and are unaffected.
9 new tests pin the contract:
- Pile::claimed_suit returns None for empty / non-foundation, Some
for non-empty foundation
- Any Ace lands in the first empty foundation; successive Aces
distribute across slots 0..3
- Claim drops when the slot is emptied via undo
- Auto-complete picks the slot with a matching claim, not the first
empty slot
- A v1-format game_state.json is rejected; sibling stats save/load
is unaffected
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
555 lines
20 KiB
Rust
555 lines
20 KiB
Rust
//! Renders the static table: felt background and empty pile markers.
|
||
//!
|
||
//! Pile markers are translucent rectangles that sit beneath any cards. They
|
||
//! remain visible only where a pile is empty, so the player can see where to
|
||
//! drop cards. All geometry comes from `LayoutResource`.
|
||
|
||
use bevy::prelude::*;
|
||
use bevy::window::WindowResized;
|
||
use solitaire_core::card::Suit;
|
||
use solitaire_core::pile::PileType;
|
||
|
||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||
use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem};
|
||
#[cfg(test)]
|
||
use crate::layout::TABLE_COLOUR;
|
||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||
#[cfg(test)]
|
||
use solitaire_data::Theme;
|
||
|
||
/// Holds pre-loaded [`Handle<Image>`]s for the 5 selectable table backgrounds.
|
||
///
|
||
/// Loaded once at startup by [`load_background_images`]. Index 0 is the
|
||
/// default; indices 1–4 are unlockable.
|
||
#[derive(Resource)]
|
||
pub struct BackgroundImageSet {
|
||
/// One handle per background slot (indices 0–4).
|
||
pub handles: Vec<Handle<Image>>,
|
||
}
|
||
|
||
/// Z-depth used for the background — below everything.
|
||
const Z_BACKGROUND: f32 = -10.0;
|
||
/// Z-depth used for pile markers — below cards (which start at 0) but above
|
||
/// the background.
|
||
const Z_PILE_MARKER: f32 = -1.0;
|
||
|
||
/// Marker component for the table felt background.
|
||
#[derive(Component, Debug)]
|
||
pub struct TableBackground;
|
||
|
||
/// Marker component attached to each of the 13 empty-pile placeholders.
|
||
#[derive(Component, Debug, Clone)]
|
||
pub struct PileMarker(pub PileType);
|
||
|
||
/// Attached to a `PileMarker` entity when it has been temporarily tinted gold
|
||
/// as a hint destination. Stores the remaining countdown and the original sprite
|
||
/// colour so it can be restored when the timer expires.
|
||
#[derive(Component, Debug, Clone)]
|
||
pub struct HintPileHighlight {
|
||
/// Seconds remaining before the pile marker colour is restored.
|
||
pub timer: f32,
|
||
/// The sprite colour the marker had before the hint tint was applied.
|
||
pub original_color: Color,
|
||
}
|
||
|
||
/// Registers the table background and pile-marker rendering.
|
||
pub struct TablePlugin;
|
||
|
||
impl Plugin for TablePlugin {
|
||
fn build(&self, app: &mut App) {
|
||
// Register WindowResized so the plugin works under MinimalPlugins in
|
||
// tests. Under DefaultPlugins, bevy_window has already registered it
|
||
// and this call is a no-op.
|
||
app.add_message::<WindowResized>()
|
||
.add_message::<SettingsChangedEvent>()
|
||
.add_message::<HintVisualEvent>()
|
||
.add_message::<StateChangedEvent>()
|
||
.add_systems(Startup, load_background_images.before(setup_table))
|
||
.add_systems(Startup, setup_table)
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
|
||
apply_theme_on_settings_change,
|
||
apply_hint_pile_highlight,
|
||
tick_hint_pile_highlights,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Loads the 5 background PNG files at startup via the Bevy `AssetServer` and
|
||
/// stores their [`Handle<Image>`]s in [`BackgroundImageSet`].
|
||
fn load_background_images(asset_server: Option<Res<AssetServer>>, mut commands: Commands) {
|
||
let Some(asset_server) = asset_server else {
|
||
// AssetServer absent (e.g. MinimalPlugins in tests) — insert an
|
||
// empty set so setup_table can proceed using a default handle.
|
||
commands.insert_resource(BackgroundImageSet { handles: Vec::new() });
|
||
return;
|
||
};
|
||
let handles = (0..5)
|
||
.map(|i| asset_server.load(format!("backgrounds/bg_{i}.png")))
|
||
.collect();
|
||
commands.insert_resource(BackgroundImageSet { handles });
|
||
}
|
||
|
||
/// Returns the felt colour for a given theme.
|
||
///
|
||
/// Only used in tests — the runtime path now picks a PNG image via
|
||
/// [`BackgroundImageSet`] rather than a solid colour.
|
||
#[cfg(test)]
|
||
fn theme_colour(theme: &Theme) -> Color {
|
||
match theme {
|
||
Theme::Green => Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]),
|
||
Theme::Blue => Color::srgb(0.059, 0.196, 0.322),
|
||
Theme::Dark => Color::srgb(0.08, 0.08, 0.10),
|
||
}
|
||
}
|
||
|
||
/// Effective table background colour: unlocked background index overrides the
|
||
/// Theme when `selected_background > 0`.
|
||
///
|
||
/// Only used in tests — the runtime path now picks a PNG image via
|
||
/// [`BackgroundImageSet`] rather than a solid colour.
|
||
#[cfg(test)]
|
||
fn effective_background_colour(theme: &Theme, selected_background: usize) -> Color {
|
||
match selected_background {
|
||
0 => theme_colour(theme),
|
||
1 => Color::srgb(0.25, 0.18, 0.10), // dark wood
|
||
2 => Color::srgb(0.05, 0.08, 0.22), // navy
|
||
3 => Color::srgb(0.30, 0.05, 0.08), // burgundy
|
||
_ => Color::srgb(0.12, 0.12, 0.14), // charcoal (4+)
|
||
}
|
||
}
|
||
|
||
fn default_window_size(window: &Window) -> Vec2 {
|
||
Vec2::new(window.resolution.width(), window.resolution.height())
|
||
}
|
||
|
||
fn setup_table(
|
||
mut commands: Commands,
|
||
windows: Query<&Window>,
|
||
existing_camera: Query<(), With<Camera>>,
|
||
settings: Option<Res<SettingsResource>>,
|
||
bg_images: Option<Res<BackgroundImageSet>>,
|
||
) {
|
||
// Only spawn a camera if one does not already exist (e.g. a parent app
|
||
// may have added one in tests).
|
||
if existing_camera.is_empty() {
|
||
commands.spawn(Camera2d);
|
||
}
|
||
|
||
let window_size = windows
|
||
.iter()
|
||
.next()
|
||
.map_or(Vec2::new(1280.0, 800.0), default_window_size);
|
||
let layout = compute_layout(window_size);
|
||
|
||
let selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background);
|
||
|
||
let image_handle = bg_images
|
||
.as_ref()
|
||
.and_then(|set| set.handles.get(selected_bg).cloned())
|
||
.unwrap_or_default();
|
||
|
||
spawn_background(&mut commands, window_size, image_handle);
|
||
spawn_pile_markers(&mut commands, &layout);
|
||
commands.insert_resource(LayoutResource(layout));
|
||
}
|
||
|
||
/// Spawns the felt background sprite using a PNG image handle.
|
||
///
|
||
/// The sprite covers the window at twice the window size so brief resize gaps
|
||
/// are never visible. The image is tinted `Color::WHITE` (no tint) so the PNG
|
||
/// pixel data is rendered as-is.
|
||
fn spawn_background(commands: &mut Commands, window_size: Vec2, image: Handle<Image>) {
|
||
// Spawn a sprite covering the window. We give it the window size plus
|
||
// headroom so resizing up doesn't expose edges before the resize handler
|
||
// runs.
|
||
commands.spawn((
|
||
Sprite {
|
||
image,
|
||
color: Color::WHITE,
|
||
custom_size: Some(window_size * 2.0),
|
||
..default()
|
||
},
|
||
Transform::from_xyz(0.0, 0.0, Z_BACKGROUND),
|
||
TableBackground,
|
||
));
|
||
}
|
||
|
||
/// Reacts to settings changes by updating the background sprite's image handle.
|
||
///
|
||
/// When [`BackgroundImageSet`] is available the selected PNG handle is applied
|
||
/// directly (color is kept at `Color::WHITE` so the PNG pixel data shows
|
||
/// unmodified). If the resource is not yet ready the sprite is left unchanged.
|
||
fn apply_theme_on_settings_change(
|
||
mut events: MessageReader<SettingsChangedEvent>,
|
||
mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
|
||
bg_images: Option<Res<BackgroundImageSet>>,
|
||
) {
|
||
let Some(ev) = events.read().last() else {
|
||
return;
|
||
};
|
||
let Some(set) = bg_images else {
|
||
// BackgroundImageSet not ready yet — leave sprite unchanged.
|
||
return;
|
||
};
|
||
let selected = ev.0.selected_background;
|
||
let Some(handle) = set.handles.get(selected).cloned() else {
|
||
return;
|
||
};
|
||
for mut sprite in &mut backgrounds {
|
||
sprite.image = handle.clone();
|
||
sprite.color = Color::WHITE;
|
||
}
|
||
}
|
||
|
||
/// Returns the single-letter suit symbol used on empty foundation markers.
|
||
///
|
||
/// Matches the same ASCII convention used by `CardPlugin` for card labels.
|
||
pub fn suit_symbol(suit: &Suit) -> &'static str {
|
||
match suit {
|
||
Suit::Spades => "S",
|
||
Suit::Hearts => "H",
|
||
Suit::Diamonds => "D",
|
||
Suit::Clubs => "C",
|
||
}
|
||
}
|
||
|
||
fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||
let marker_colour = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||
let marker_size = layout.card_size;
|
||
let font_size = layout.card_size.x * 0.28;
|
||
|
||
let mut piles: Vec<PileType> = Vec::with_capacity(13);
|
||
piles.push(PileType::Stock);
|
||
piles.push(PileType::Waste);
|
||
for slot in 0..4_u8 {
|
||
piles.push(PileType::Foundation(slot));
|
||
}
|
||
for i in 0..7 {
|
||
piles.push(PileType::Tableau(i));
|
||
}
|
||
|
||
for pile in piles {
|
||
let pos = layout.pile_positions[&pile];
|
||
let mut entity = commands.spawn((
|
||
Sprite {
|
||
color: marker_colour,
|
||
custom_size: Some(marker_size),
|
||
..default()
|
||
},
|
||
Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER),
|
||
PileMarker(pile.clone()),
|
||
));
|
||
|
||
// Foundation slots no longer carry a suit letter — any Ace can claim
|
||
// any empty slot, so a fixed C/D/H/S badge would be misleading. Empty
|
||
// foundation markers render as plain translucent rectangles.
|
||
|
||
// Task #43 — King indicator on empty tableau placeholders.
|
||
if let PileType::Tableau(_) = &pile {
|
||
entity.with_children(|b| {
|
||
b.spawn((
|
||
Text2d::new("K"),
|
||
TextFont { font_size, ..default() },
|
||
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.35)),
|
||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||
));
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::type_complexity)]
|
||
fn on_window_resized(
|
||
mut events: MessageReader<WindowResized>,
|
||
mut layout_res: Option<ResMut<LayoutResource>>,
|
||
mut backgrounds: Query<
|
||
(&mut Sprite, &mut Transform),
|
||
(With<TableBackground>, Without<PileMarker>),
|
||
>,
|
||
mut markers: Query<(&PileMarker, &mut Sprite, &mut Transform), Without<TableBackground>>,
|
||
) {
|
||
let Some(ev) = events.read().last() else {
|
||
return;
|
||
};
|
||
let window_size = Vec2::new(ev.width, ev.height);
|
||
let new_layout = compute_layout(window_size);
|
||
|
||
if let Some(layout_res) = layout_res.as_deref_mut() {
|
||
layout_res.0 = new_layout.clone();
|
||
}
|
||
|
||
for (mut sprite, mut transform) in &mut backgrounds {
|
||
sprite.custom_size = Some(window_size * 2.0);
|
||
transform.translation.x = 0.0;
|
||
transform.translation.y = 0.0;
|
||
}
|
||
|
||
for (marker, mut sprite, mut transform) in &mut markers {
|
||
if let Some(pos) = new_layout.pile_positions.get(&marker.0) {
|
||
sprite.custom_size = Some(new_layout.card_size);
|
||
transform.translation.x = pos.x;
|
||
transform.translation.y = pos.y;
|
||
}
|
||
}
|
||
|
||
// Card sprites are repositioned by `card_plugin::snap_cards_on_window_resize`
|
||
// running `.after(LayoutSystem::UpdateOnResize)` — that system snaps card
|
||
// transforms directly to the new layout instead of going through
|
||
// `StateChangedEvent → sync_cards → CardAnim` which would retarget the
|
||
// slide tween every frame during a corner drag (the visible "snap back
|
||
// and forth" jitter).
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Task #6 — Hint pile-marker highlight
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Gold tint applied to a `PileMarker` sprite when it is the current hint
|
||
/// destination.
|
||
const HINT_PILE_HIGHLIGHT_COLOUR: Color = Color::srgb(1.0, 0.85, 0.1);
|
||
|
||
/// Listens for `HintVisualEvent` and tints the matching `PileMarker` entity
|
||
/// gold for 2 s, storing the original colour in `HintPileHighlight` so it can
|
||
/// be restored when the timer expires.
|
||
///
|
||
/// If the pile marker already has a `HintPileHighlight` from a previous hint
|
||
/// press, the timer is reset to 2 s without changing `original_color`.
|
||
fn apply_hint_pile_highlight(
|
||
mut events: MessageReader<HintVisualEvent>,
|
||
mut commands: Commands,
|
||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite, Option<&HintPileHighlight>)>,
|
||
) {
|
||
for ev in events.read() {
|
||
for (entity, pile_marker, mut sprite, existing) in pile_markers.iter_mut() {
|
||
if pile_marker.0 != ev.dest_pile {
|
||
continue;
|
||
}
|
||
let original_color = existing.map_or(sprite.color, |h| h.original_color);
|
||
sprite.color = HINT_PILE_HIGHLIGHT_COLOUR;
|
||
commands.entity(entity).insert(HintPileHighlight {
|
||
timer: 2.0,
|
||
original_color,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Counts down `HintPileHighlight::timer` each frame and restores the original
|
||
/// pile marker colour when the timer expires.
|
||
fn tick_hint_pile_highlights(
|
||
mut commands: Commands,
|
||
time: Res<Time>,
|
||
mut pile_markers: Query<(Entity, &mut Sprite, &mut HintPileHighlight)>,
|
||
) {
|
||
let dt = time.delta_secs();
|
||
for (entity, mut sprite, mut highlight) in pile_markers.iter_mut() {
|
||
highlight.timer -= dt;
|
||
if highlight.timer <= 0.0 {
|
||
sprite.color = highlight.original_color;
|
||
commands.entity(entity).remove::<HintPileHighlight>();
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::game_plugin::GamePlugin;
|
||
|
||
/// Minimal headless app — omits windowing so pile markers are spawned with
|
||
/// the default 1280×800 layout and no camera is created.
|
||
fn headless_app() -> App {
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins)
|
||
.add_plugins(GamePlugin)
|
||
.add_plugins(TablePlugin);
|
||
app.update();
|
||
app
|
||
}
|
||
|
||
#[test]
|
||
fn table_plugin_spawns_thirteen_pile_markers() {
|
||
let mut app = headless_app();
|
||
let count = app
|
||
.world_mut()
|
||
.query::<&PileMarker>()
|
||
.iter(app.world())
|
||
.count();
|
||
assert_eq!(count, 13);
|
||
}
|
||
|
||
#[test]
|
||
fn table_plugin_spawns_one_background() {
|
||
let mut app = headless_app();
|
||
let count = app
|
||
.world_mut()
|
||
.query::<&TableBackground>()
|
||
.iter(app.world())
|
||
.count();
|
||
assert_eq!(count, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn table_plugin_inserts_layout_resource() {
|
||
let app = headless_app();
|
||
assert!(app.world().get_resource::<LayoutResource>().is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn every_pile_marker_has_unique_type() {
|
||
let mut app = headless_app();
|
||
let mut types: Vec<PileType> = app
|
||
.world_mut()
|
||
.query::<&PileMarker>()
|
||
.iter(app.world())
|
||
.map(|m| m.0.clone())
|
||
.collect();
|
||
types.sort_by_key(|p| format!("{p:?}"));
|
||
types.dedup();
|
||
assert_eq!(types.len(), 13);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Pure-function tests (no Bevy app required)
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn all_three_themes_produce_distinct_colours() {
|
||
let green = theme_colour(&Theme::Green);
|
||
let blue = theme_colour(&Theme::Blue);
|
||
let dark = theme_colour(&Theme::Dark);
|
||
assert_ne!(green, blue, "Green and Blue must differ");
|
||
assert_ne!(green, dark, "Green and Dark must differ");
|
||
assert_ne!(blue, dark, "Blue and Dark must differ");
|
||
}
|
||
|
||
#[test]
|
||
fn effective_background_index_0_matches_theme_colour() {
|
||
for theme in [Theme::Green, Theme::Blue, Theme::Dark] {
|
||
let expected = theme_colour(&theme);
|
||
let actual = effective_background_colour(&theme, 0);
|
||
assert_eq!(
|
||
expected, actual,
|
||
"index 0 must always return the theme colour for {:?}",
|
||
theme
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn effective_background_indices_1_through_3_are_distinct_from_theme() {
|
||
// Non-zero indices override the theme with a fixed colour.
|
||
let theme_green = theme_colour(&Theme::Green);
|
||
for idx in 1..=3 {
|
||
let eff = effective_background_colour(&Theme::Green, idx);
|
||
assert_ne!(eff, theme_green, "index {idx} must override the theme colour");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn effective_background_index_4_falls_through_to_charcoal() {
|
||
// All indices ≥ 4 share the same charcoal fallback.
|
||
let c4 = effective_background_colour(&Theme::Green, 4);
|
||
let c5 = effective_background_colour(&Theme::Green, 5);
|
||
let c99 = effective_background_colour(&Theme::Green, 99);
|
||
assert_eq!(c4, c5, "indices 4 and 5 must share the charcoal fallback");
|
||
assert_eq!(c4, c99, "index 99 must share the charcoal fallback");
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// suit_symbol pure-function tests (Task #35)
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn suit_symbol_returns_correct_letters() {
|
||
assert_eq!(suit_symbol(&Suit::Spades), "S");
|
||
assert_eq!(suit_symbol(&Suit::Hearts), "H");
|
||
assert_eq!(suit_symbol(&Suit::Diamonds), "D");
|
||
assert_eq!(suit_symbol(&Suit::Clubs), "C");
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Task #6 — HintPileHighlight timer and colour pure-function tests
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// The HINT_PILE_HIGHLIGHT_COLOUR constant must be visibly distinct from the
|
||
/// default pile marker colour so the player can see which pile is highlighted.
|
||
#[test]
|
||
fn hint_pile_highlight_colour_is_distinct_from_default() {
|
||
let default = Color::srgba(1.0, 1.0, 1.0, 0.08); // PILE_MARKER_DEFAULT_COLOUR
|
||
assert_ne!(
|
||
HINT_PILE_HIGHLIGHT_COLOUR, default,
|
||
"HINT_PILE_HIGHLIGHT_COLOUR must differ from the default pile marker colour"
|
||
);
|
||
}
|
||
|
||
/// A freshly-created HintPileHighlight has a positive timer countdown.
|
||
#[test]
|
||
fn hint_pile_highlight_timer_starts_positive() {
|
||
let h = HintPileHighlight {
|
||
timer: 2.0,
|
||
original_color: Color::srgba(1.0, 1.0, 1.0, 0.08),
|
||
};
|
||
assert!(
|
||
h.timer > 0.0,
|
||
"HintPileHighlight timer must start positive, got {}",
|
||
h.timer
|
||
);
|
||
}
|
||
|
||
/// Ticking the timer past its initial value results in a non-positive (expired)
|
||
/// countdown.
|
||
#[test]
|
||
fn hint_pile_highlight_timer_expires_after_full_duration() {
|
||
let mut remaining = 2.0_f32;
|
||
remaining -= 2.5; // 2.5 s elapsed on a 2.0 s timer
|
||
assert!(
|
||
remaining <= 0.0,
|
||
"timer must be expired after ticking past its initial value, got {}",
|
||
remaining
|
||
);
|
||
}
|
||
|
||
/// `original_color` is preserved through the highlight lifecycle so colour
|
||
/// can be correctly restored on expiry.
|
||
#[test]
|
||
fn hint_pile_highlight_preserves_original_color() {
|
||
let original = Color::srgb(0.1, 0.3, 0.5);
|
||
let h = HintPileHighlight {
|
||
timer: 2.0,
|
||
original_color: original,
|
||
};
|
||
assert_eq!(
|
||
h.original_color, original,
|
||
"original_color must be stored without modification"
|
||
);
|
||
}
|
||
|
||
/// The gold hint colour must have a strong yellow component (r ≥ 0.9, g ≥ 0.8,
|
||
/// b ≤ 0.3) to be clearly visible as a "destination" indicator.
|
||
#[test]
|
||
fn hint_pile_highlight_colour_is_gold() {
|
||
// Extract linear components. srgb(1.0, 0.85, 0.1) is the expected gold.
|
||
// We test the channel values rather than exact equality so future tweaks
|
||
// to the shade do not break the test, as long as the colour remains golden.
|
||
let Srgba { red, green, blue, .. } = HINT_PILE_HIGHLIGHT_COLOUR.to_srgba();
|
||
assert!(red >= 0.9, "gold hint colour must have red ≥ 0.9, got {red}");
|
||
assert!(green >= 0.7, "gold hint colour must have green ≥ 0.7, got {green}");
|
||
assert!(blue <= 0.3, "gold hint colour must have blue ≤ 0.3, got {blue}");
|
||
}
|
||
|
||
#[test]
|
||
fn suit_symbol_all_four_are_distinct() {
|
||
let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs]
|
||
.iter()
|
||
.map(suit_symbol)
|
||
.collect();
|
||
let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
|
||
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
|
||
}
|
||
}
|