Files
Ferrous-Solitaire/solitaire_engine/src/theme/plugin.rs
T
funman300 de4751115f feat(engine): theme thumbnails accept PNG faces alongside SVG
The theme picker chip's thumbnail loader hardcoded `.svg`
filenames (`spades_ace.svg`, `back.svg`) — a holdover from when
every shipped theme was vector-art. Raster-art user themes (e.g.
the v0.19 pixel-art theme generated via Claude Design and dropped
into ~/.local/share/solitaire_quest/themes/rusty-pixel/) had real
PNGs in their directory but the picker rendered placeholders
because it never tried the PNG sibling.

The fix is scoped to the thumbnail-cache pipeline. In-game card
rendering already worked via Bevy's standard PNG asset loader on
manifest-declared face/back paths — only the picker's small
preview chip was affected.

Changes in solitaire_engine/src/theme/plugin.rs:

  - PREVIEW_FACE_FILENAME / PREVIEW_BACK_FILENAME (with embedded
    `.svg` suffix) replaced by PREVIEW_FACE_BASENAME /
    PREVIEW_BACK_BASENAME ("spades_ace" / "back"). The function
    appends the extension itself.
  - read_theme_preview_svg_bytes -> read_theme_preview_bytes
    returns ThemePreviewBytes::{Svg, Png}. For "default" the
    embedded table stays SVG-only. For user themes the function
    tries `<basename>.svg` first (matching the bundled
    convention) and falls back to `<basename>.png` second.
  - rasterize_preview_to_handle gains a Png branch that calls a
    new decode_png_for_thumbnail helper (Bevy's
    Image::from_buffer with ImageType::Format(ImageFormat::Png)).
    PNGs decode at native dimensions; the picker chip's UI
    layout scales them at draw time. SVGs continue to rasterise
    at the fixed 100x140 thumbnail size as before.
  - generate_thumbnail_pair_for is unchanged in shape; just
    threads the new enum through.

Tests:

  - read_default_theme_preview_returns_some_for_canonical_files
    updated to match the new function signature and assert on
    the Svg variant explicitly.
  - New png_only_user_theme_generates_real_thumbnails creates a
    temp theme dir, writes a 2x3 PNG (encoded at runtime via the
    `image` dev-dep so the bytes are guaranteed valid), and
    asserts both ace + back yield non-default Handle<Image>.
    Cleans up the temp dir afterward.

solitaire_engine/Cargo.toml: image = "0.25" added as a
dev-dependency for the test's runtime PNG encoding. Already a
transitive Bevy dep so the build graph is unchanged.

Workspace: 1171 passing tests / 0 failing, was 1170 (+1 new).
cargo clippy --workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:13:52 -07:00

756 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `ThemePlugin` — owns [`ActiveTheme`], registers the `CardTheme` /
//! SVG asset machinery, and keeps `card_plugin::CardImageSet` in sync
//! with the currently-loaded theme so existing card-rendering systems
//! pick up the new artwork on the next state-changed tick.
//!
//! Phase 4 of `CARD_PLAN.md`. The plugin's `set_theme` helper is the
//! public API that the future picker UI (Phase 6) calls; for now it's
//! exposed for tests and for any embedder that wants to load an
//! alternative theme manually.
use std::collections::HashMap;
use bevy::asset::AssetEvent;
use bevy::ecs::message::MessageReader;
use bevy::math::UVec2;
use bevy::prelude::*;
use solitaire_core::card::{Rank, Suit};
use crate::assets::{
default_theme_svg_bytes, rasterize_svg, user_theme_dir, DEFAULT_THEME_MANIFEST_URL,
};
use crate::card_plugin::CardImageSet;
use crate::events::StateChangedEvent;
use super::loader::CardThemeLoader;
use super::registry::ThemeRegistry;
use super::{CardKey, CardTheme};
/// Width (logical px) of one Settings → Cosmetic theme-picker
/// thumbnail. A 2:3 card aspect at 100×140 keeps each chip a small
/// glanceable preview without bloating the picker row.
pub const THEME_THUMBNAIL_WIDTH_PX: u32 = 100;
/// Height counterpart to [`THEME_THUMBNAIL_WIDTH_PX`].
pub const THEME_THUMBNAIL_HEIGHT_PX: u32 = 140;
/// Resource pointing at the currently-active card theme. Populated on
/// startup with the bundled default theme and replaced by [`set_theme`]
/// when the player switches.
#[derive(Resource, Debug)]
pub struct ActiveTheme(pub Handle<CardTheme>);
/// One pair of preview-sized `Handle<Image>` for the Settings picker:
/// the theme's Ace of Spades and its card back.
///
/// Either handle may be [`Handle::default`] when the underlying SVG
/// could not be located (e.g. a user theme that ships only a partial
/// set of files). The picker UI treats the default-handle case as
/// "render a placeholder swatch instead of an image" so a broken
/// theme can never crash the panel.
#[derive(Debug, Clone, Default)]
pub struct ThemeThumbnailPair {
/// Rasterised `spades_ace.svg` of the theme.
pub ace: Handle<Image>,
/// Rasterised `back.svg` of the theme.
pub back: Handle<Image>,
}
impl ThemeThumbnailPair {
/// Returns `true` only when *both* preview slots resolve to a
/// non-default handle — a theme with at least one missing SVG is
/// considered incomplete and renders the placeholder for the
/// missing slot.
pub fn is_fully_populated(&self) -> bool {
self.ace != Handle::default() && self.back != Handle::default()
}
}
/// Resource caching one [`ThemeThumbnailPair`] per registered theme,
/// keyed by `ThemeMeta::id`.
///
/// Populated lazily by [`ensure_theme_thumbnails`] whenever the
/// [`ThemeRegistry`] grows or changes. The Settings panel reads from
/// this cache by id and falls back to the placeholder rendering path
/// when an entry is missing.
#[derive(Resource, Debug, Default)]
pub struct ThemeThumbnailCache {
pub entries: HashMap<String, ThemeThumbnailPair>,
}
impl ThemeThumbnailCache {
/// Returns the cached pair for `theme_id`, if any.
pub fn get(&self, theme_id: &str) -> Option<&ThemeThumbnailPair> {
self.entries.get(theme_id)
}
}
/// Bevy plugin that loads the default theme and keeps `CardImageSet`
/// in sync with `Assets<CardTheme>`.
///
/// Order considerations:
///
/// - `init_asset::<CardTheme>` must happen before any system that
/// stores `Handle<CardTheme>` runs, so it goes in `build`.
/// - `register_asset_loader` for the SVG and theme loaders must
/// happen after `AssetPlugin` is built (DefaultPlugins). This
/// plugin therefore must be added after `DefaultPlugins`.
/// - The `Startup` system that loads the default theme runs after
/// the asset sources are registered (see
/// `crate::assets::register_theme_asset_sources` and
/// `crate::assets::AssetSourcesPlugin`).
pub struct ThemePlugin;
impl Plugin for ThemePlugin {
fn build(&self, app: &mut App) {
app.init_asset::<CardTheme>()
.init_resource::<ThemeThumbnailCache>()
.register_asset_loader(crate::assets::SvgLoader)
.register_asset_loader(CardThemeLoader)
.add_systems(Startup, load_initial_theme)
.add_systems(
Update,
(
sync_card_image_set_with_active_theme,
react_to_settings_theme_change,
ensure_theme_thumbnails,
),
);
}
}
/// Kicks off the initial theme load — the one named by
/// `Settings::selected_theme_id` if available, falling back to the
/// embedded default. The actual rasterisation runs asynchronously on
/// the asset task pool; the sync system below picks up the
/// `LoadedWithDependencies` event when every face + back is ready.
fn load_initial_theme(
asset_server: Res<AssetServer>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
mut commands: Commands,
) {
let url = match settings.as_deref() {
Some(s) if s.0.selected_theme_id != "default" => {
format!("themes://{}/theme.ron", s.0.selected_theme_id)
}
_ => DEFAULT_THEME_MANIFEST_URL.to_string(),
};
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle));
}
/// Watches [`crate::settings_plugin::SettingsChangedEvent`] and
/// triggers a fresh theme load whenever
/// `Settings::selected_theme_id` changes. The settings panel's theme
/// picker fires the event after persisting; this system is the bridge
/// that turns the persisted choice into a live `set_theme` call.
fn react_to_settings_theme_change(
mut events: MessageReader<crate::settings_plugin::SettingsChangedEvent>,
asset_server: Res<AssetServer>,
active: Option<Res<ActiveTheme>>,
themes: Res<Assets<CardTheme>>,
mut commands: Commands,
) {
let Some(latest) = events.read().last() else {
return;
};
let new_id = latest.0.selected_theme_id.as_str();
// No-op if the active theme already matches the desired id.
if let Some(active) = active.as_deref()
&& let Some(theme) = themes.get(&active.0)
&& theme.meta.id == new_id
{
return;
}
let url = if new_id == "default" {
DEFAULT_THEME_MANIFEST_URL.to_string()
} else {
format!("themes://{new_id}/theme.ron")
};
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle));
}
/// Replaces every face slot and the active-theme back-handle slot on
/// `CardImageSet` whenever the active theme finishes loading or
/// changes. Fires `StateChangedEvent` afterwards so the existing
/// `card_plugin::sync_cards_on_change` pipeline re-renders every
/// on-screen card with the new artwork.
///
/// `CardImageSet` may be absent — tests using `MinimalPlugins` skip
/// `CardPlugin` entirely. In that case the system is a no-op and the
/// plugin still composes cleanly under headless setups.
fn sync_card_image_set_with_active_theme(
mut events: MessageReader<AssetEvent<CardTheme>>,
active: Option<Res<ActiveTheme>>,
themes: Res<Assets<CardTheme>>,
mut card_image_set: Option<ResMut<CardImageSet>>,
mut state_events: MessageWriter<StateChangedEvent>,
) {
let Some(active) = active else { return };
let active_id = active.0.id();
let mut should_sync = false;
for ev in events.read() {
let id = match ev {
AssetEvent::LoadedWithDependencies { id }
| AssetEvent::Modified { id } => *id,
_ => continue,
};
if id == active_id {
should_sync = true;
}
}
if !should_sync {
return;
}
let Some(theme) = themes.get(&active.0) else {
return;
};
let Some(card_image_set) = card_image_set.as_deref_mut() else {
return;
};
apply_theme_to_card_image_set(theme, card_image_set);
state_events.write(StateChangedEvent);
}
/// Pure helper that copies the theme's image handles into the
/// `[suit][rank]` face matrix and into the dedicated `theme_back`
/// slot. Split out so it can be unit-tested without spinning up a
/// Bevy `App`.
///
/// The legacy `backs[0..5]` array is left untouched — those handles
/// are the player's `selected_card_back` choices and remain available
/// as a fallback when the active theme does not declare a back. The
/// face-down render path in `card_plugin::card_sprite` prefers
/// `theme_back` when present, so writing here is sufficient to make
/// every face-down card pick up the theme's art on the next sync.
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
for rank in [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
Rank::Jack, Rank::Queen, Rank::King,
] {
if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) {
image_set.faces[suit_index(suit)][rank_index(rank)] = handle.clone();
}
}
}
image_set.theme_back = Some(theme.back.clone());
}
/// Index used by [`CardImageSet::faces`] for a given suit. Mirrors
/// the `card_plugin` doc comment: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
const fn suit_index(s: Suit) -> usize {
match s {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
}
}
/// Index used by [`CardImageSet::faces`] for a given rank.
/// Ace=0, Two=1 … King=12.
const fn rank_index(r: Rank) -> usize {
match r {
Rank::Ace => 0,
Rank::Two => 1,
Rank::Three => 2,
Rank::Four => 3,
Rank::Five => 4,
Rank::Six => 5,
Rank::Seven => 6,
Rank::Eight => 7,
Rank::Nine => 8,
Rank::Ten => 9,
Rank::Jack => 10,
Rank::Queen => 11,
Rank::King => 12,
}
}
/// Switches the active theme to the one served at
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>`
/// so callers can poll `Assets<CardTheme>` if they want to wait for
/// the load before changing UI state.
///
/// The handle is also written to the [`ActiveTheme`] resource — the
/// per-frame sync system picks up the `LoadedWithDependencies` event
/// and refreshes `CardImageSet` automatically; callers don't need to
/// fire `StateChangedEvent` themselves.
pub fn set_theme(
commands: &mut Commands,
asset_server: &AssetServer,
theme_id: &str,
) -> Handle<CardTheme> {
let url = format!("themes://{theme_id}/theme.ron");
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle.clone()));
handle
}
// ---------------------------------------------------------------------------
// Picker-thumbnail generation
// ---------------------------------------------------------------------------
/// Basename (no extension) of the canonical "preview face" inside a
/// theme — the Ace of Spades. Matches `CardKey::manifest_name(Spades,
/// Ace)`. The thumbnail loader appends `.svg` first and falls back to
/// `.png` so themes shipped as raster art still get real previews.
const PREVIEW_FACE_BASENAME: &str = "spades_ace";
/// Basename (no extension) of the back preview inside a theme. Matched
/// the same way as [`PREVIEW_FACE_BASENAME`].
const PREVIEW_BACK_BASENAME: &str = "back";
/// Bytes of one preview slot tagged with its source format. SVGs go
/// through `rasterize_svg` (vector → fixed-size pixmap); PNGs decode
/// directly into a `bevy::image::Image` whose intrinsic dimensions
/// the UI scales at draw time.
#[derive(Debug)]
enum ThemePreviewBytes {
/// SVG source — the bundled default theme's convention. Caller
/// rasterises through the existing `usvg` + `resvg` pipeline.
Svg(Vec<u8>),
/// PNG source — the convention for raster-art user themes (e.g.
/// pixel-art themes generated via Claude Design — see
/// `SESSION_HANDOFF.md` for the v0.19 drop-in flow).
Png(Vec<u8>),
}
/// Resolves the preview bytes for a card slot in `theme_id`, trying
/// `.svg` first (the bundled default's convention) and falling back
/// to `.png` for raster-art themes. Returns `None` when neither
/// extension resolves — the caller renders a placeholder.
///
/// - For the bundled `default` theme: reads from the embedded
/// `DEFAULT_THEME_SVGS` table via [`default_theme_svg_bytes`]. SVG
/// only — the embed table is `.svg` exclusive.
/// - For any user theme: reads from `<user_theme_dir>/<id>/`. Tries
/// `<basename>.svg` then `<basename>.png`. Either branch returns
/// `None` on I/O failure (file missing, permission denied, etc.).
fn read_theme_preview_bytes(theme_id: &str, basename: &str) -> Option<ThemePreviewBytes> {
if theme_id == "default" {
let filename = format!("{basename}.svg");
return default_theme_svg_bytes(&filename)
.map(|b| ThemePreviewBytes::Svg(b.to_vec()));
}
let dir = user_theme_dir().join(theme_id);
if let Ok(bytes) = std::fs::read(dir.join(format!("{basename}.svg"))) {
return Some(ThemePreviewBytes::Svg(bytes));
}
if let Ok(bytes) = std::fs::read(dir.join(format!("{basename}.png"))) {
return Some(ThemePreviewBytes::Png(bytes));
}
None
}
/// Decodes raster bytes (currently PNG) into a `bevy::image::Image`.
/// Bevy's `Image::from_buffer` dispatches via the supplied
/// `ImageType`, so this is a thin wrapper that translates I/O
/// failures into a logged warning + `None`.
fn decode_png_for_thumbnail(png_bytes: &[u8]) -> Option<Image> {
use bevy::image::{CompressedImageFormats, Image, ImageSampler, ImageType};
use bevy::asset::RenderAssetUsages;
Image::from_buffer(
png_bytes,
ImageType::Format(bevy::image::ImageFormat::Png),
CompressedImageFormats::default(),
true, // is_srgb — pixel-art faces are authored in sRGB
ImageSampler::Default,
RenderAssetUsages::default(),
)
.map_err(|e| warn!("theme thumbnail png decode failed: {e}"))
.ok()
}
/// Pure helper: turns one preview byte slice into a thumbnail
/// `Handle<Image>`. SVGs rasterise to a fixed
/// `THEME_THUMBNAIL_WIDTH_PX × THEME_THUMBNAIL_HEIGHT_PX` pixmap
/// (preserving aspect, centred); PNGs decode at their native
/// dimensions and Bevy's UI scales them at draw time. Returns
/// [`Handle::default`] on decode / rasterise failure so the picker
/// can render a placeholder without crashing.
fn rasterize_preview_to_handle(
bytes: &ThemePreviewBytes,
images: &mut Assets<Image>,
) -> Handle<Image> {
match bytes {
ThemePreviewBytes::Svg(b) => {
let target = UVec2::new(THEME_THUMBNAIL_WIDTH_PX, THEME_THUMBNAIL_HEIGHT_PX);
match rasterize_svg(b, target) {
Ok(image) => images.add(image),
Err(err) => {
warn!("theme thumbnail svg rasterise failed: {err}");
Handle::default()
}
}
}
ThemePreviewBytes::Png(b) => match decode_png_for_thumbnail(b) {
Some(image) => images.add(image),
None => Handle::default(),
},
}
}
/// Builds a [`ThemeThumbnailPair`] for a single theme. Either handle
/// is [`Handle::default`] when the matching face / back file could
/// not be located in either `.svg` or `.png` form, or when decoding
/// failed.
fn generate_thumbnail_pair_for(
theme_id: &str,
images: &mut Assets<Image>,
) -> ThemeThumbnailPair {
let ace = read_theme_preview_bytes(theme_id, PREVIEW_FACE_BASENAME)
.map(|b| rasterize_preview_to_handle(&b, images))
.unwrap_or_default();
let back = read_theme_preview_bytes(theme_id, PREVIEW_BACK_BASENAME)
.map(|b| rasterize_preview_to_handle(&b, images))
.unwrap_or_default();
ThemeThumbnailPair { ace, back }
}
/// System that generates a [`ThemeThumbnailPair`] for every registered
/// theme that doesn't yet have one in [`ThemeThumbnailCache`].
///
/// Runs each frame but the early-exit check (`already cached?`) keeps
/// the steady-state cost to a single hash lookup per theme. Generation
/// itself only happens once per theme — the SVGs are rasterised and
/// inserted into `Assets<Image>` and the handles cached forever.
///
/// Lazy-on-first-pass beats Startup-only for two reasons:
///
/// - The `ThemeRegistry` is built by a different `Startup` system, and
/// Bevy doesn't guarantee inter-system Startup ordering without
/// explicit `.after()` chaining. Polling each Update tick removes
/// the dependency.
/// - The future `refresh_registry` path (used after a successful
/// theme import in Phase 7) adds entries mid-session — this system
/// picks them up automatically without any extra wiring.
pub fn ensure_theme_thumbnails(
registry: Option<Res<ThemeRegistry>>,
mut cache: ResMut<ThemeThumbnailCache>,
mut images: ResMut<Assets<Image>>,
) {
let Some(registry) = registry else { return };
for entry in registry.iter() {
if cache.entries.contains_key(&entry.id) {
continue;
}
let pair = generate_thumbnail_pair_for(&entry.id, &mut images);
cache.entries.insert(entry.id.clone(), pair);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use crate::theme::ThemeMeta;
fn empty_theme() -> CardTheme {
CardTheme {
meta: ThemeMeta {
id: "test".into(),
name: "Test".into(),
author: "test".into(),
version: "0".into(),
card_aspect: (2, 3),
},
faces: HashMap::new(),
back: Handle::default(),
}
}
fn empty_card_image_set() -> CardImageSet {
// Every slot is the asset server's default-empty handle, the
// same shape `card_plugin::load_card_images` uses when the
// asset server is absent (tests under MinimalPlugins).
CardImageSet {
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
backs: std::array::from_fn(|_| Handle::default()),
theme_back: None,
}
}
#[test]
fn suit_index_ranges_match_card_plugin_layout() {
assert_eq!(suit_index(Suit::Clubs), 0);
assert_eq!(suit_index(Suit::Diamonds), 1);
assert_eq!(suit_index(Suit::Hearts), 2);
assert_eq!(suit_index(Suit::Spades), 3);
}
#[test]
fn rank_index_starts_at_ace_zero_and_ends_at_king_twelve() {
assert_eq!(rank_index(Rank::Ace), 0);
assert_eq!(rank_index(Rank::Two), 1);
assert_eq!(rank_index(Rank::Ten), 9);
assert_eq!(rank_index(Rank::Jack), 10);
assert_eq!(rank_index(Rank::Queen), 11);
assert_eq!(rank_index(Rank::King), 12);
}
#[test]
fn applying_empty_theme_does_not_panic() {
// A theme whose faces map is empty should leave existing
// image-set face slots untouched (the .get() returns None,
// we skip). The back is always copied since theme.back is
// a single handle.
let mut image_set = empty_card_image_set();
let theme = empty_theme();
apply_theme_to_card_image_set(&theme, &mut image_set);
}
#[test]
fn applying_theme_writes_theme_back_slot_and_leaves_legacy_backs_untouched() {
// The active-theme back lives in its own dedicated slot
// (`theme_back`) so the legacy `backs[0..5]` PNG fallbacks
// remain untouched. This guarantees the player's
// `selected_card_back` choice can still be honoured when no
// theme is active.
let mut image_set = empty_card_image_set();
// Snapshot the legacy back ids so we can prove they don't
// change when a theme is applied.
let legacy_ids_before: [bevy::asset::AssetId<bevy::image::Image>; 5] =
std::array::from_fn(|i| image_set.backs[i].id());
let theme = empty_theme();
assert!(image_set.theme_back.is_none(), "theme_back starts empty");
apply_theme_to_card_image_set(&theme, &mut image_set);
// The active-theme back is now populated and matches the theme.
let active_back = image_set
.theme_back
.as_ref()
.expect("theme_back populated after apply");
assert_eq!(active_back.id(), theme.back.id());
// Every legacy back slot is preserved byte-for-byte by id.
for (i, before) in legacy_ids_before.iter().enumerate() {
assert_eq!(
image_set.backs[i].id(),
*before,
"legacy back slot {i} must not be clobbered by theme apply",
);
}
}
#[test]
fn theme_plugin_builds_under_minimal_plugins() {
// Smoke test: the plugin's build hooks (init_asset,
// register_asset_loader, system registration) run cleanly
// under MinimalPlugins. Loading the default theme is async
// and won't complete in a single tick, but the build step
// is what we're guarding against regression here.
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<Assets<CardTheme>>();
// The full ThemePlugin requires AssetServer (not present
// under MinimalPlugins). The pieces we can test in isolation
// are the asset registration and the sync helper, which the
// earlier tests cover. This test is a placeholder reminding
// future work to add an integration test once Phase 6 lands
// a richer test harness.
}
#[test]
fn set_theme_url_format_matches_themes_source() {
// The format string is the only behavioural surface of
// set_theme that doesn't require an App. We assert the URL
// shape so a future refactor doesn't accidentally change the
// path layout.
let url = format!("themes://{}/theme.ron", "default");
assert_eq!(url, "themes://default/theme.ron");
let url2 = format!("themes://{}/theme.ron", "user_uploaded");
assert_eq!(url2, "themes://user_uploaded/theme.ron");
}
/// Test 1: the bundled default theme always has embedded SVG bytes
/// available, so calling `generate_thumbnail_pair_for("default", …)`
/// must produce two non-default `Handle<Image>` slots.
#[test]
fn theme_thumbnails_generated_for_default_theme() {
let mut images = Assets::<Image>::default();
let pair = generate_thumbnail_pair_for("default", &mut images);
assert!(
pair.is_fully_populated(),
"default theme must yield both ace + back thumbnail handles"
);
// And the underlying images must actually exist in the assets
// collection — the handles are real, not dangling.
assert!(images.get(&pair.ace).is_some(), "ace image must be inserted");
assert!(images.get(&pair.back).is_some(), "back image must be inserted");
}
/// Test 2: when a theme is registered but its preview SVGs are not
/// available on disk (a broken user-supplied theme), thumbnail
/// generation must NOT panic and must leave the missing slots as
/// the default handle so the picker UI can render its placeholder.
#[test]
fn theme_thumbnails_handle_missing_svg_gracefully() {
let mut images = Assets::<Image>::default();
// A theme id that definitely has no files on disk under the
// user_theme_dir (the directory may not even exist on a
// fresh test machine). The function reads the filesystem
// lazily and silently returns None on I/O failures — no
// panic, no rasterise attempt.
let pair = generate_thumbnail_pair_for(
"this-theme-does-not-exist-on-disk-for-testing",
&mut images,
);
assert_eq!(
pair.ace,
Handle::default(),
"missing ace.svg must yield Handle::default placeholder"
);
assert_eq!(
pair.back,
Handle::default(),
"missing back.svg must yield Handle::default placeholder"
);
assert!(
!pair.is_fully_populated(),
"incomplete pair must report not-fully-populated"
);
}
/// `read_theme_preview_bytes` for the default theme always
/// returns embedded SVG bytes for the canonical preview pair —
/// covering the happy-path branch of the helper.
#[test]
fn read_default_theme_preview_returns_some_for_canonical_files() {
assert!(
matches!(
read_theme_preview_bytes("default", PREVIEW_BACK_BASENAME),
Some(ThemePreviewBytes::Svg(_)),
),
"default theme back must resolve to embedded SVG bytes"
);
assert!(
matches!(
read_theme_preview_bytes("default", PREVIEW_FACE_BASENAME),
Some(ThemePreviewBytes::Svg(_)),
),
"default theme spades_ace must resolve to embedded SVG bytes"
);
}
/// PNG raster-art themes (e.g. the v0.19 drop-in pixel-art theme
/// generated via Claude Design) must produce non-default
/// thumbnail handles in the picker. The function reads
/// `<user_theme_dir>/<id>/spades_ace.png` and `back.png`,
/// decodes them via Bevy's `Image::from_buffer`, and inserts the
/// resulting `Image` into `Assets<Image>`. Pins the v0.18 →
/// v0.19 SVG-only → SVG-or-PNG widening of the thumbnail
/// pipeline.
#[test]
fn png_only_user_theme_generates_real_thumbnails() {
// Drop a synthetic theme into a unique temp subdirectory so
// the test doesn't collide with whatever real themes the dev
// machine has installed under user_theme_dir().
let theme_id = format!(
"test-png-theme-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let theme_dir = user_theme_dir().join(&theme_id);
std::fs::create_dir_all(&theme_dir).expect("create temp theme dir");
// Encode a real 2×3 RGBA PNG via the `image` dev-dep so the
// test exercises Bevy's actual PNG decoder. A handcrafted byte
// string is too fragile (DEFLATE encodes are non-trivial) and
// a `include_bytes!` of a checked-in PNG would shoulder
// committed binary into the repo.
let mut png_bytes: Vec<u8> = Vec::new();
let img = image::RgbaImage::from_pixel(2, 3, image::Rgba([200, 60, 60, 255]));
image::DynamicImage::ImageRgba8(img)
.write_to(
&mut std::io::Cursor::new(&mut png_bytes),
image::ImageFormat::Png,
)
.expect("encode tiny png");
std::fs::write(theme_dir.join("spades_ace.png"), &png_bytes)
.expect("write spades_ace.png");
std::fs::write(theme_dir.join("back.png"), &png_bytes)
.expect("write back.png");
let mut images = Assets::<Image>::default();
let pair = generate_thumbnail_pair_for(&theme_id, &mut images);
assert_ne!(
pair.ace,
Handle::default(),
"PNG-only theme must yield a real ace thumbnail handle, not the placeholder",
);
assert_ne!(
pair.back,
Handle::default(),
"PNG-only theme must yield a real back thumbnail handle, not the placeholder",
);
assert!(
pair.is_fully_populated(),
"complete PNG-only pair must report fully-populated",
);
// Cleanup — the test is robust to leftover dirs but tidy up
// anyway so /tmp doesn't grow on repeated CI runs.
let _ = std::fs::remove_dir_all(&theme_dir);
}
/// `ensure_theme_thumbnails` is idempotent: calling it twice with
/// the same registry must not regenerate or replace already-cached
/// entries. This guards against the per-frame Update tick churning
/// new `Handle<Image>` allocations and growing `Assets<Image>`
/// without bound.
#[test]
fn ensure_theme_thumbnails_caches_after_first_run() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<Assets<Image>>();
app.init_resource::<ThemeThumbnailCache>();
app.insert_resource(ThemeRegistry {
entries: vec![crate::theme::ThemeEntry {
id: "default".into(),
display_name: "Default".into(),
manifest_url: crate::assets::DEFAULT_THEME_MANIFEST_URL.into(),
meta: ThemeMeta {
id: "default".into(),
name: "Default".into(),
author: "x".into(),
version: "x".into(),
card_aspect: (2, 3),
},
}],
});
app.add_systems(Update, ensure_theme_thumbnails);
// First tick generates the entry.
app.update();
let first_ace = app
.world()
.resource::<ThemeThumbnailCache>()
.get("default")
.map(|p| p.ace.clone())
.expect("default theme thumbnail must exist after one tick");
// Second tick must NOT replace the cached handle.
app.update();
let second_ace = app
.world()
.resource::<ThemeThumbnailCache>()
.get("default")
.map(|p| p.ace.clone())
.expect("default theme thumbnail must still exist");
assert_eq!(
first_ace.id(),
second_ace.id(),
"cached thumbnail handle must be stable across ticks"
);
}
}