use crate::cli::{AttractorArg, ObstacleArg, SpeciesArg};
use crate::profile_overrides::ProfileOverrides;
use crate::render::charset::Charset;
use crate::render::palette::Palette;
use crate::simulation::config::{SimConfig, TerrainType};
use crate::terminal::control::RuntimeState;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
const CONFIG_DIR: &str = ".config/tslime";
const CONFIG_FILE: &str = "presets.toml";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NamedProfile {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(flatten)]
pub overrides: ProfileOverrides,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ConfigFile {
#[serde(rename = "preset")]
presets: Vec<NamedProfile>,
}
fn terrain_name(t: TerrainType) -> Option<String> {
match t {
TerrainType::None => Some("none".to_string()),
TerrainType::Smooth => Some("smooth".to_string()),
TerrainType::Turbulent => Some("turbulent".to_string()),
TerrainType::Mixed => Some("mixed".to_string()),
}
}
pub fn capture_overrides(
sim_config: &SimConfig,
palette: Palette,
charset: Charset,
rs: &RuntimeState,
) -> ProfileOverrides {
let brightness_gain = crate::config_defaults::trail::brightness_gain(sim_config.max_brightness);
let temporal_accent = rs.temporal_accent;
let palette_cycle = if rs.palette_cycle.is_identity() {
None
} else {
Some(rs.palette_cycle)
};
let (glyph_selection, glyph_edge_threshold) = match rs.glyph.selection {
None => (None, None),
Some(sel) => (Some(sel), Some(rs.glyph.edge_threshold)),
};
let color_aa = Some(rs.color_aa[rs.charset_index % rs.color_aa.len()]);
let intensity_mapping = Some(rs.intensity_mapping.clone());
ProfileOverrides {
preset: None,
seed: None,
sensor_angle: Some(sim_config.sensor_angle),
sensor_distance: Some(sim_config.sensor_distance),
rotation_angle: Some(sim_config.rotation_angle),
step_size: Some(sim_config.step_size),
decay_factor: Some(sim_config.decay_factor),
deposit_amount: Some(sim_config.deposit_amount),
brightness: Some(brightness_gain),
diffusion_kernel: Some(sim_config.diffusion_kernel),
diffusion_sigma: Some(sim_config.diffusion_sigma),
time_scale: Some(rs.time_scale),
population: Some(sim_config.total_population()),
fps: None,
food_image_path: None,
food_image_invert: None,
food_image_scale: None,
attractors: sim_config
.attractors
.iter()
.map(|a| AttractorArg {
x: a.x,
y: a.y,
strength: a.strength,
})
.collect(),
attractor_strength: Some(sim_config.attractor_strength),
obstacles: sim_config
.obstacles
.iter()
.map(|o| ObstacleArg {
obstacle: o.clone(),
})
.collect(),
species: sim_config
.species_configs
.iter()
.map(|s| SpeciesArg {
name: s.name.clone(),
count: s.count,
sensor_angle: s.sensor_angle,
rotation_angle: s.rotation_angle,
step_size: s.step_size,
deposit_amount: s.deposit_amount,
color: s.color,
trail_modulation: s.trail_modulation,
})
.collect(),
separate_species_trails: sim_config.separate_species_trails,
species_colors: false,
use_simd: None,
wind: rs.wind.map(|w| crate::cli::WindArg { dx: w.dx, dy: w.dy }),
terrain: terrain_name(sim_config.terrain),
terrain_strength: Some(sim_config.terrain_strength),
background_color: sim_config.background_color.clone(),
boundary_mode: Some(sim_config.boundary_mode),
window_frame: Some(sim_config.window_frame),
chrome_style: Some(sim_config.chrome_style),
transition_style: Some(sim_config.transition_style),
transition_tagline: Some(sim_config.transition_tagline),
aspect: Some(sim_config.aspect),
window_padding: Some(sim_config.window_padding),
frame_matte_cols: Some(sim_config.frame_matte_cols),
frame_matte_rows: Some(sim_config.frame_matte_rows),
show_status_bar: Some(sim_config.show_status_bar),
min_sim_size: Some(sim_config.min_sim_size),
min_frame_size: Some(sim_config.min_frame_size),
respawn_interval: None,
respawn_config: Some(sim_config.respawn_config),
decay_gamma: Some(rs.decay_gamma),
diffuse_weight: Some(rs.diffuse_weight),
deposit_curve: Some(rs.deposit_curve),
deposit_scale: Some(rs.deposit_scale),
deposit_gamma: Some(rs.deposit_gamma),
deposit_cap: Some(rs.deposit_cap),
palette: Some(palette),
charset: Some(charset),
color_aa,
hue_shift: None,
auto_normalize: Some(rs.auto_normalize),
intensity_mapping,
palette_cycle,
glyph_selection,
glyph_edge_threshold,
temporal_color: Some(rs.temporal_color),
temporal_lag_frames: Some(rs.temporal_lag_frames),
temporal_mode: Some(rs.temporal_mode),
temporal_accent,
afterglow: Some(rs.afterglow),
afterglow_rate: Some(rs.afterglow_rate),
reverse_palette: Some(rs.reverse_palette),
invert_palette: Some(rs.invert_palette),
food_persist: Some(rs.food_persist_enabled),
color_aa_all: Some(rs.color_aa.to_vec()),
init_mode: sim_config.preferred_init_mode,
warmup_frames: Some(rs.app.warmup_frames),
skip_warmup: Some(rs.app.skip_warmup),
warmup_brightness_multiplier: Some(rs.app.warmup_brightness_multiplier),
auto_reset: Some(rs.app.auto_reset),
auto_reset_entropy_threshold: Some(rs.app.auto_reset_entropy_threshold),
auto_reset_duration_frames: Some(rs.app.auto_reset_duration_frames),
grid: Some(rs.app.grid),
grid_style: Some(rs.app.grid_style),
grid_size: Some(rs.app.grid_size),
grid_color: Some(rs.app.grid_color),
grid_opacity: Some(rs.app.grid_opacity),
grid_adaptive: Some(rs.app.grid_adaptive),
food_persist_strength: Some(rs.app.food_persist_strength),
food_persist_radius: Some(rs.app.food_persist_radius),
food_persist_duration: Some(rs.app.food_persist_duration),
}
}
pub fn get_config_path() -> Result<PathBuf, String> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| "Could not determine home directory".to_string())?;
let config_dir = PathBuf::from(home).join(CONFIG_DIR);
if !config_dir.exists() {
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
}
Ok(config_dir.join(CONFIG_FILE))
}
fn load_config_file() -> Result<ConfigFile, String> {
let path = get_config_path()?;
if !path.exists() {
return Ok(ConfigFile {
presets: Vec::new(),
});
}
let contents =
fs::read_to_string(&path).map_err(|e| format!("Failed to read config file: {}", e))?;
parse_config_file(&contents)
}
fn parse_config_file(contents: &str) -> Result<ConfigFile, String> {
toml::from_str(contents).map_err(|e| format!("Failed to parse config file: {}", e))
}
fn save_config_file(config_file: &ConfigFile) -> Result<(), String> {
let path = get_config_path()?;
let toml_string = toml::to_string_pretty(config_file)
.map_err(|e| format!("Failed to serialize config: {}", e))?;
fs::write(&path, toml_string).map_err(|e| format!("Failed to write config file: {}", e))?;
Ok(())
}
pub fn save_config(profile: NamedProfile) -> Result<Option<String>, String> {
let (mut config_file, warning) = match load_config_file() {
Ok(cf) => (cf, None),
Err(_) => {
let bak = back_up_config_file()?;
(
ConfigFile {
presets: Vec::new(),
},
Some(format!(
"Existing config was unreadable; backed up to {bak}"
)),
)
}
};
config_file.presets.retain(|c| c.name != profile.name);
config_file.presets.push(profile);
save_config_file(&config_file)?;
Ok(warning)
}
fn back_up_config_file() -> Result<String, String> {
let path = get_config_path()?;
let bak = path.with_extension("toml.bak");
fs::rename(&path, &bak).map_err(|e| format!("Failed to back up unreadable config: {}", e))?;
Ok(bak
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("presets.toml.bak")
.to_string())
}
pub fn load_config(name: &str) -> Result<NamedProfile, String> {
let config_file = load_config_file()?;
config_file
.presets
.iter()
.find(|c| c.name == name)
.cloned()
.ok_or_else(|| format!("Config '{}' not found", name))
}
pub fn list_configs() -> Result<Vec<NamedProfile>, String> {
let config_file = load_config_file()?;
Ok(config_file.presets)
}
pub fn delete_config(name: &str) -> Result<(), String> {
let mut config_file = load_config_file()?;
let original_len = config_file.presets.len();
config_file.presets.retain(|c| c.name != name);
if config_file.presets.len() == original_len {
return Err(format!("Config '{}' not found", name));
}
save_config_file(&config_file)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::PauseStyle;
use crate::config_defaults::trail;
use crate::render::charset::Charset;
use crate::render::palette::{Palette, PaletteCycle, PaletteCycleMode};
use crate::simulation::config::{DepositCurve, DiffusionKernel, Preset, SimConfig};
use crate::terminal::control::RuntimeState;
fn create_test_runtime_state() -> RuntimeState {
RuntimeState::new(
42,
crate::simulation::config::InitMode::Random,
Preset::Organic,
crate::terminal::control::MouseInteractionMode::Disabled,
3.0,
&SimConfig::default(),
PauseStyle::Vignette,
false,
false,
)
}
fn resolved(ov: &ProfileOverrides) -> crate::profile::Profile {
ov.resolve().expect("resolve must succeed")
}
fn minimal_overrides(name: &str) -> NamedProfile {
let sim = SimConfig::default();
let rs = create_test_runtime_state();
NamedProfile {
name: name.to_string(),
description: None,
overrides: capture_overrides(&sim, Palette::Organic, Charset::HalfBlock, &rs),
}
}
#[test]
fn test_named_profile_serialization() {
let profile = minimal_overrides("test");
let toml_str = toml::to_string(&profile).unwrap();
let deserialized: NamedProfile = toml::from_str(&toml_str).unwrap();
assert_eq!(profile.name, deserialized.name);
assert_eq!(profile.overrides.palette, deserialized.overrides.palette);
}
#[test]
fn test_config_file_serialization_roundtrip() {
let profile = minimal_overrides("roundtrip");
let config_file = ConfigFile {
presets: vec![profile.clone()],
};
let toml_str = toml::to_string_pretty(&config_file).unwrap();
let deserialized: ConfigFile = toml::from_str(&toml_str).unwrap();
assert_eq!(deserialized.presets.len(), 1);
assert_eq!(deserialized.presets[0].name, "roundtrip");
}
#[test]
fn test_apply_palette_to_runtime_state() {
let sim = SimConfig::default();
let rs = create_test_runtime_state();
let profile = NamedProfile {
name: "test_palette".to_string(),
description: None,
overrides: capture_overrides(&sim, Palette::Heat, Charset::HalfBlock, &rs),
};
let p = resolved(&profile.overrides);
assert_eq!(p.render.palette, Palette::Heat);
}
#[test]
fn diffusion_sigma_resolves_through_saved_config() {
let sim = SimConfig {
diffusion_sigma: 2.75,
..SimConfig::default()
};
let rs = create_test_runtime_state();
let profile = NamedProfile {
name: "test_sigma".to_string(),
description: None,
overrides: capture_overrides(&sim, Palette::Heat, Charset::HalfBlock, &rs),
};
let p = resolved(&profile.overrides);
assert!((p.sim.diffusion_sigma - 2.75).abs() < 1e-6);
}
#[test]
fn intensity_mapping_resolves_to_default_when_none() {
use crate::render::palette::IntensityMapping;
let rs = create_test_runtime_state();
let mut overrides = capture_overrides(
&SimConfig::default(),
Palette::Heat,
Charset::HalfBlock,
&rs,
);
overrides.intensity_mapping = None;
let p = resolved(&overrides);
assert_eq!(p.render.intensity_mapping, IntensityMapping::default());
}
#[test]
fn test_full_config_roundtrip() {
let mut state = create_test_runtime_state();
state.palette_index = 5; state.reverse_palette = true;
state.invert_palette = true;
state.sensor_angle = 35.0;
state.rotation_angle = 55.0;
state.step_size = 1.5;
state.decay_factor = 0.92;
state.deposit_amount = 6.5;
state.max_brightness = 30.0;
state.diffusion_kernel = DiffusionKernel::Gaussian;
let sim_config = SimConfig {
sensor_angle: state.sensor_angle,
sensor_distance: 9.0,
rotation_angle: state.rotation_angle,
step_size: state.step_size,
decay_factor: state.decay_factor,
deposit_amount: state.deposit_amount,
diffusion_kernel: state.diffusion_kernel,
diffusion_sigma: 1.0,
diffuse_weight: trail::DEFAULT_DIFFUSE_WEIGHT,
decay_gamma: trail::DEFAULT_DECAY_GAMMA,
deposit_curve: DepositCurve::default(),
deposit_scale: trail::DEFAULT_DEPOSIT_SCALE,
deposit_gamma: trail::DEFAULT_DEPOSIT_GAMMA,
deposit_cap: trail::DEFAULT_DEPOSIT_CAP,
max_brightness: state.max_brightness,
..SimConfig::default()
};
let overrides = capture_overrides(&sim_config, Palette::Neon, Charset::HalfBlock, &state);
let p = resolved(&overrides);
assert_eq!(p.render.palette, Palette::Neon);
assert_eq!(overrides.reverse_palette, Some(true));
assert_eq!(overrides.invert_palette, Some(true));
assert_eq!(p.sim.sensor_angle, state.sensor_angle);
assert_eq!(p.sim.rotation_angle, state.rotation_angle);
assert_eq!(p.sim.step_size, state.step_size);
assert_eq!(p.sim.decay_factor, state.decay_factor);
assert_eq!(p.sim.deposit_amount, state.deposit_amount);
assert!((p.sim.max_brightness - state.max_brightness).abs() < 1e-3);
assert_eq!(p.sim.diffusion_kernel, state.diffusion_kernel);
}
#[test]
fn temporal_fields_round_trip_and_default_off() {
let mut overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&create_test_runtime_state(),
);
overrides.temporal_color = None;
let p = resolved(&overrides);
assert_eq!(p.render.temporal_color, 0.0);
}
#[test]
fn temporal_fields_full_round_trip() {
use crate::render::palette::TemporalMode;
let mut state = create_test_runtime_state();
state.temporal_color = 0.7;
state.temporal_lag_frames = 12.0;
state.temporal_mode = TemporalMode::Accent;
let overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&state,
);
let toml_str = toml::to_string(&overrides).expect("serialize must succeed");
let reloaded: ProfileOverrides =
toml::from_str(&toml_str).expect("deserialize must succeed");
let p = resolved(&reloaded);
assert!((p.render.temporal_color - 0.7).abs() < 1e-6);
assert!((p.render.temporal_lag_frames - 12.0).abs() < 1e-6);
assert_eq!(p.render.temporal_mode, TemporalMode::Accent);
}
#[test]
fn diffusion_decay_art_knobs_round_trip() {
let mut state = create_test_runtime_state();
state.afterglow = 0.4;
state.afterglow_rate = 0.03;
state.decay_gamma = 0.6;
state.diffuse_weight = 0.5;
state.diffusion_sigma = 3.0;
let sim = SimConfig {
diffusion_sigma: state.diffusion_sigma,
diffuse_weight: state.diffuse_weight,
decay_gamma: state.decay_gamma,
..SimConfig::default()
};
let overrides = capture_overrides(&sim, Palette::Organic, Charset::HalfBlock, &state);
let toml_str = toml::to_string(&overrides).expect("serialize must succeed");
let reloaded: ProfileOverrides =
toml::from_str(&toml_str).expect("deserialize must succeed");
let p = resolved(&reloaded);
assert!(
(p.render.afterglow - 0.4).abs() < 1e-6,
"afterglow must survive round-trip (got {})",
p.render.afterglow
);
assert!(
(p.render.afterglow_rate - 0.03).abs() < 1e-6,
"afterglow_rate must survive round-trip (got {})",
p.render.afterglow_rate
);
assert!(
(p.sim.decay_gamma - 0.6).abs() < 1e-6,
"decay_gamma must survive round-trip (got {})",
p.sim.decay_gamma
);
assert!(
(p.sim.diffuse_weight - 0.5).abs() < 1e-6,
"diffuse_weight must survive round-trip (got {})",
p.sim.diffuse_weight
);
assert!(
(p.sim.diffusion_sigma - 3.0).abs() < 1e-6,
"diffusion_sigma must survive round-trip (got {})",
p.sim.diffusion_sigma
);
}
#[test]
fn old_toml_without_art_knobs_loads_with_defaults() {
let toml = r#"
palette = "organic"
charset = "halfblock"
"#;
let cfg: ProfileOverrides = toml::from_str(toml).expect("minimal overrides must load");
assert!(cfg.afterglow.is_none());
assert!(cfg.afterglow_rate.is_none());
assert!(cfg.decay_gamma.is_none());
assert!(cfg.diffuse_weight.is_none());
let p = resolved(&cfg);
assert_eq!(p.render.afterglow, 0.0, "default afterglow must be 0.0");
assert!(
(p.render.afterglow_rate - 0.05).abs() < 1e-6,
"default afterglow_rate must be 0.05"
);
assert_eq!(p.sim.decay_gamma, 1.0, "default decay_gamma must be 1.0");
assert_eq!(
p.sim.diffuse_weight, 1.0,
"default diffuse_weight must be 1.0"
);
assert!(cfg.deposit_curve.is_none());
assert!(cfg.deposit_scale.is_none());
assert!(cfg.deposit_gamma.is_none());
assert!(cfg.deposit_cap.is_none());
assert_eq!(
p.sim.deposit_curve,
DepositCurve::default(),
"default deposit_curve must be Linear"
);
assert_eq!(
p.sim.deposit_scale, 1.0,
"default deposit_scale must be 1.0"
);
assert_eq!(
p.sim.deposit_gamma, 1.0,
"default deposit_gamma must be 1.0"
);
assert_eq!(p.sim.deposit_cap, 0.0, "default deposit_cap must be 0.0");
}
#[test]
fn deposit_fields_round_trip_through_saved_config() {
let mut state = create_test_runtime_state();
state.deposit_curve = DepositCurve::Pow;
state.deposit_scale = 2.5;
state.deposit_gamma = 0.5;
state.deposit_cap = 7.0;
let overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&state,
);
let toml_str = toml::to_string(&overrides).expect("serialize must succeed");
let reloaded: ProfileOverrides =
toml::from_str(&toml_str).expect("deserialize must succeed");
let p = resolved(&reloaded);
assert_eq!(
p.sim.deposit_curve,
DepositCurve::Pow,
"deposit_curve must survive round-trip"
);
assert!(
(p.sim.deposit_scale - 2.5).abs() < 1e-6,
"deposit_scale must survive round-trip (got {})",
p.sim.deposit_scale
);
assert!(
(p.sim.deposit_gamma - 0.5).abs() < 1e-6,
"deposit_gamma must survive round-trip (got {})",
p.sim.deposit_gamma
);
assert!(
(p.sim.deposit_cap - 7.0).abs() < 1e-6,
"deposit_cap must survive round-trip (got {})",
p.sim.deposit_cap
);
}
#[test]
fn palette_cycle_round_trips_through_saved_config() {
let mut state = create_test_runtime_state();
state.palette_cycle = PaletteCycle {
cycles: 4,
mode: PaletteCycleMode::Wrap,
};
let overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&state,
);
assert_eq!(
overrides.palette_cycle,
Some(PaletteCycle {
cycles: 4,
mode: PaletteCycleMode::Wrap
})
);
let toml_str = toml::to_string(&overrides).expect("serialize must succeed");
let reloaded: ProfileOverrides =
toml::from_str(&toml_str).expect("deserialize must succeed");
let p = resolved(&reloaded);
assert_eq!(
p.render.palette_cycle,
PaletteCycle {
cycles: 4,
mode: PaletteCycleMode::Wrap
}
);
}
#[test]
fn glyph_round_trips_through_saved_config() {
use crate::render::charset::{GlyphConfig, GlyphSelection};
let mut rs = create_test_runtime_state();
rs.glyph = GlyphConfig {
selection: Some(GlyphSelection::Hybrid),
edge_threshold: 0.25,
};
let overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&rs,
);
assert_eq!(overrides.glyph_selection, Some(GlyphSelection::Hybrid));
assert_eq!(overrides.glyph_edge_threshold, Some(0.25));
let p = resolved(&overrides);
assert_eq!(p.render.glyph.selection, Some(GlyphSelection::Hybrid));
assert_eq!(p.render.glyph.edge_threshold, 0.25);
}
#[test]
fn glyph_identity_serializes_to_none() {
use crate::render::charset::GlyphConfig;
let mut rs = create_test_runtime_state();
rs.glyph = GlyphConfig::default();
let overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&rs,
);
assert_eq!(overrides.glyph_selection, None);
assert_eq!(overrides.glyph_edge_threshold, None);
}
#[test]
fn missing_palette_cycle_loads_identity() {
let toml = r#"
palette = "organic"
charset = "halfblock"
"#;
let cfg: ProfileOverrides =
toml::from_str(toml).expect("config without palette_cycle must load");
assert!(
cfg.palette_cycle.is_none(),
"missing key must deserialize as None"
);
let p = resolved(&cfg);
assert!(
p.render.palette_cycle.is_identity(),
"missing palette_cycles must default to identity"
);
assert_eq!(
p.render.palette_cycle,
PaletteCycle::default(),
"palette_cycle must equal default"
);
}
#[test]
fn missing_glyph_loads_identity() {
use crate::render::charset::GlyphConfig;
let toml = r#"
palette = "organic"
charset = "halfblock"
"#;
let cfg: ProfileOverrides =
toml::from_str(toml).expect("config without glyph fields must load");
assert!(
cfg.glyph_selection.is_none(),
"missing glyph_selection must deserialize as None"
);
assert!(cfg.glyph_edge_threshold.is_none());
let p = resolved(&cfg);
assert_eq!(
p.render.glyph,
GlyphConfig::default(),
"missing glyph keys must default to GlyphConfig::default()"
);
assert!(
p.render.glyph.selection.is_none(),
"missing glyph_selection must default to None"
);
}
#[test]
fn temporal_accent_round_trips_and_back_compat() {
use crate::render::palette::RgbColor;
let old_toml = r#"
palette = "organic"
charset = "halfblock"
"#;
let old: ProfileOverrides = toml::from_str(old_toml).unwrap();
assert_eq!(old.temporal_accent, None);
let accent = RgbColor::new(0xff, 0xb3, 0x47);
let mut rs = create_test_runtime_state();
rs.temporal_accent = Some(accent);
let overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&rs,
);
assert_eq!(overrides.temporal_accent, Some(accent));
let toml_str = toml::to_string(&overrides).expect("serialize must succeed");
let reloaded: ProfileOverrides =
toml::from_str(&toml_str).expect("deserialize must succeed");
let p = resolved(&reloaded);
assert_eq!(
p.render.temporal_accent,
Some(accent),
"temporal_accent must survive round-trip"
);
}
#[test]
fn color_aa_round_trips_through_saved_config() {
use crate::render::antialiasing::AaStrength;
let mut rs = create_test_runtime_state();
rs.color_aa[0] = AaStrength::Subtle;
let overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&rs,
);
assert_eq!(overrides.color_aa, Some(AaStrength::Subtle));
let mut rs2 = create_test_runtime_state();
rs2.apply_color_aa_all(&overrides);
assert_eq!(rs2.color_aa[0], AaStrength::Subtle);
}
#[test]
fn color_aa_absent_keeps_defaults() {
use crate::render::antialiasing::AaStrength;
let rs = create_test_runtime_state();
let mut overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&rs,
);
overrides.color_aa = None;
let mut rs2 = create_test_runtime_state();
rs2.apply_color_aa_all(&overrides);
assert_eq!(rs2.color_aa[3], AaStrength::Strong);
}
#[test]
fn color_aa_all_full_per_charset_array_round_trips() {
use crate::render::antialiasing::AaStrength;
use crate::render::charset::NUM_CHARSETS;
let mut rs = create_test_runtime_state();
let values = [AaStrength::Off, AaStrength::Subtle, AaStrength::Strong];
for i in 0..NUM_CHARSETS {
rs.color_aa[i] = values[i % values.len()];
}
let overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&rs,
);
let captured = overrides
.color_aa_all
.as_ref()
.expect("color_aa_all must be Some");
assert_eq!(
captured.len(),
NUM_CHARSETS,
"must capture all charset slots"
);
for i in 0..NUM_CHARSETS {
assert_eq!(
captured[i],
values[i % values.len()],
"slot {i} must match the source value"
);
}
let toml_str = toml::to_string(&overrides).expect("serialize must succeed");
let reloaded: ProfileOverrides =
toml::from_str(&toml_str).expect("deserialize must succeed");
let mut rs2 = create_test_runtime_state();
rs2.apply_color_aa_all(&reloaded);
for i in 0..NUM_CHARSETS {
assert_eq!(
rs2.color_aa[i],
values[i % values.len()],
"slot {i} must be restored after round-trip"
);
}
}
#[test]
fn color_aa_all_fallback_to_scalar_when_absent() {
use crate::render::antialiasing::AaStrength;
let mut rs = create_test_runtime_state();
rs.color_aa[0] = AaStrength::Subtle;
let mut overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&rs,
);
overrides.color_aa_all = None;
assert_eq!(overrides.color_aa, Some(AaStrength::Subtle));
let mut rs2 = create_test_runtime_state();
rs2.apply_color_aa_all(&overrides);
assert_eq!(rs2.color_aa[0], AaStrength::Subtle);
}
#[test]
fn reverse_invert_food_persist_round_trip() {
let mut rs = create_test_runtime_state();
rs.reverse_palette = true;
rs.invert_palette = true;
rs.food_persist_enabled = true;
let overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&rs,
);
assert_eq!(overrides.reverse_palette, Some(true));
assert_eq!(overrides.invert_palette, Some(true));
assert_eq!(overrides.food_persist, Some(true));
let toml_str = toml::to_string(&overrides).expect("serialize must succeed");
let reloaded: ProfileOverrides =
toml::from_str(&toml_str).expect("deserialize must succeed");
assert_eq!(reloaded.reverse_palette, Some(true));
assert_eq!(reloaded.invert_palette, Some(true));
assert_eq!(reloaded.food_persist, Some(true));
assert!(reloaded.reverse_palette.unwrap_or(false));
assert!(reloaded.invert_palette.unwrap_or(false));
assert!(reloaded.food_persist.unwrap_or(false));
}
#[test]
fn reverse_invert_default_false_when_absent() {
let toml = r#"
palette = "organic"
charset = "halfblock"
"#;
let cfg: ProfileOverrides = toml::from_str(toml).unwrap();
assert!(!cfg.reverse_palette.unwrap_or(false));
assert!(!cfg.invert_palette.unwrap_or(false));
assert!(!cfg.food_persist.unwrap_or(false));
}
#[test]
fn capture_overrides_brightness_gain_roundtrip() {
let sim = SimConfig {
max_brightness: 50.0,
..SimConfig::default()
};
let rs = create_test_runtime_state();
let overrides = capture_overrides(&sim, Palette::Organic, Charset::HalfBlock, &rs);
let expected_gain = crate::config_defaults::trail::brightness_gain(50.0);
assert!(
(overrides.brightness.unwrap() - expected_gain).abs() < 1e-5,
"brightness gain should round-trip"
);
let p = resolved(&overrides);
assert!(
(p.sim.max_brightness - 50.0).abs() < 1e-3,
"max_brightness must round-trip through gain (got {})",
p.sim.max_brightness
);
}
#[test]
fn capture_reads_live_reverse_flag() {
let mut rs = create_test_runtime_state();
rs.reverse_palette = true;
let overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&rs,
);
assert_eq!(
overrides.reverse_palette,
Some(true),
"capture must read live rs.reverse_palette, not a frozen startup arg"
);
}
#[test]
fn capture_reads_live_auto_normalize_flag() {
let mut rs = create_test_runtime_state();
rs.auto_normalize = true;
let overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&rs,
);
assert_eq!(
overrides.auto_normalize,
Some(true),
"capture must read live rs.auto_normalize, not a frozen startup arg"
);
}
#[test]
fn capture_reads_live_app_lever_warmup_frames() {
use crate::app_config::AppRuntimeConfig;
let mut rs = create_test_runtime_state();
rs.app = AppRuntimeConfig {
warmup_frames: 120,
..AppRuntimeConfig::default()
};
let overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&rs,
);
assert_eq!(overrides.warmup_frames, Some(120));
let profile = overrides.resolve().expect("resolve must succeed");
assert_eq!(profile.app.warmup_frames, 120);
}
#[test]
fn apply_color_aa_all_restores_all_slots() {
use crate::render::antialiasing::AaStrength;
use crate::render::charset::NUM_CHARSETS;
let mut rs = create_test_runtime_state();
let values: Vec<AaStrength> = (0..NUM_CHARSETS)
.map(|i| [AaStrength::Off, AaStrength::Subtle, AaStrength::Strong][i % 3])
.collect();
let ov = crate::profile_overrides::ProfileOverrides {
color_aa_all: Some(values.clone()),
..crate::profile_overrides::ProfileOverrides::default()
};
rs.apply_color_aa_all(&ov);
for (i, expected) in values.iter().enumerate() {
assert_eq!(
rs.color_aa[i], *expected,
"slot {i} must be restored from color_aa_all"
);
}
}
#[test]
fn apply_color_aa_all_falls_back_to_scalar_active_slot() {
use crate::render::antialiasing::AaStrength;
let mut rs = create_test_runtime_state();
rs.charset_index = 0;
let ov = crate::profile_overrides::ProfileOverrides {
color_aa_all: None,
color_aa: Some(AaStrength::Subtle),
..crate::profile_overrides::ProfileOverrides::default()
};
rs.apply_color_aa_all(&ov);
assert_eq!(
rs.color_aa[0],
AaStrength::Subtle,
"scalar fallback must set the active charset slot"
);
}
#[test]
fn capture_roundtrip_obstacle_attractor_and_terrain() {
use crate::simulation::config::{Attractor, TerrainType};
let obstacle = crate::simulation::config::Obstacle::Circle {
x: 200.0,
y: 100.0,
radius: 90.0,
};
let attractor = Attractor::new(10.0, 20.0, 0.8);
let sim = SimConfig {
obstacles: vec![obstacle.clone()],
attractors: vec![attractor],
attractor_strength: 1.5,
terrain: TerrainType::Smooth,
terrain_strength: 2.0,
..SimConfig::default()
};
let rs = create_test_runtime_state();
let overrides = capture_overrides(&sim, Palette::Organic, Charset::HalfBlock, &rs);
assert_eq!(
overrides.obstacles.len(),
1,
"obstacles must be captured from sim_config"
);
assert_eq!(overrides.obstacles[0].obstacle, obstacle);
assert_eq!(
overrides.attractors.len(),
1,
"attractors must be captured from sim_config"
);
assert!((overrides.attractors[0].x - 10.0).abs() < 1e-6);
assert!((overrides.attractors[0].y - 20.0).abs() < 1e-6);
assert!((overrides.attractors[0].strength - 0.8).abs() < 1e-6);
assert_eq!(overrides.attractor_strength, Some(1.5));
assert_eq!(overrides.terrain, Some("smooth".to_string()));
assert_eq!(overrides.terrain_strength, Some(2.0));
let profile = overrides.resolve().expect("overrides must resolve");
assert_eq!(
profile.sim.obstacles,
vec![obstacle],
"resolved obstacles must match capture source"
);
assert_eq!(
profile.sim.attractors.len(),
1,
"resolved attractors must match capture source"
);
assert!((profile.sim.attractors[0].x - 10.0).abs() < 1e-6);
assert!((profile.sim.attractor_strength - 1.5).abs() < 1e-6);
assert_eq!(profile.sim.terrain, TerrainType::Smooth);
assert!((profile.sim.terrain_strength - 2.0).abs() < 1e-6);
}
#[test]
fn terrain_name_covers_all_variants() {
use crate::simulation::config::TerrainType;
assert_eq!(terrain_name(TerrainType::None), Some("none".to_string()));
assert_eq!(
terrain_name(TerrainType::Smooth),
Some("smooth".to_string())
);
assert_eq!(
terrain_name(TerrainType::Turbulent),
Some("turbulent".to_string())
);
assert_eq!(terrain_name(TerrainType::Mixed), Some("mixed".to_string()));
for (t, s) in [
(TerrainType::None, "none"),
(TerrainType::Smooth, "smooth"),
(TerrainType::Turbulent, "turbulent"),
(TerrainType::Mixed, "mixed"),
] {
let parsed: TerrainType = s.parse().expect("must parse");
assert_eq!(parsed, t, "terrain_name/FromStr must be inverse for {s}");
}
}
const LEGACY_PRESETS_TOML: &str = r#"
[[preset]]
name = "Mossy Roots"
population = 50000
decay_factor = 0.5
palette = "moss"
charset = "halfblock"
init_mode = "food"
food_path = "assets/tslime_logo.png"
window_frame = ""
chrome_style = "minimal"
aspect = "3:2"
window_padding = "auto"
show_status_bar = false
min_sim_size = "20x10"
min_frame_size = "12x6"
[[preset]]
name = "warm-1"
population = 50000
palette = "warm"
charset = "ascii"
window_frame = "glow"
chrome_style = "minimal"
food_path = "assets/tslime_logo.png"
"#;
#[test]
fn trail_modulation_round_trips_through_saved_config() {
use crate::simulation::config::{PointConfig, SpeciesConfig};
let rs = create_test_runtime_state();
let modulation = PointConfig {
sensor_angle_multiplier: 0.5,
step_size_base: 2.5,
trail_rescale: 0.25,
..PointConfig::default()
};
let sim = SimConfig {
species_configs: vec![SpeciesConfig {
trail_modulation: Some(modulation),
..SpeciesConfig::default()
}],
..SimConfig::default()
};
let overrides = capture_overrides(&sim, Palette::Organic, Charset::HalfBlock, &rs);
assert_eq!(
overrides.species[0].trail_modulation,
Some(modulation),
"capture must carry trail_modulation into SpeciesArg"
);
let named = NamedProfile {
name: "t".to_string(),
description: None,
overrides,
};
let toml_str = toml::to_string(&named).expect("serialize");
let reparsed: NamedProfile = toml::from_str(&toml_str).expect("deserialize");
let profile = reparsed.overrides.resolve().expect("resolve");
assert_eq!(
profile.sim.species_configs[0].trail_modulation,
Some(modulation),
"trail_modulation must survive save -> reload -> resolve"
);
}
#[test]
fn legacy_presets_file_parses_without_poisoning() {
let cf = parse_config_file(LEGACY_PRESETS_TOML)
.expect("stale-schema presets.toml must not hard-fail parsing");
assert_eq!(cf.presets.len(), 2, "both entries survive");
assert_eq!(cf.presets[0].overrides.window_frame, None);
assert_eq!(
cf.presets[1].overrides.window_frame,
Some(crate::simulation::config::WindowFrame::Glow)
);
assert_eq!(cf.presets[0].name, "Mossy Roots");
assert_eq!(cf.presets[0].overrides.population, Some(50000));
}
#[test]
fn time_scale_captured_from_live_runtime_state() {
let mut rs = create_test_runtime_state();
rs.time_scale = 2.5;
let overrides = capture_overrides(
&SimConfig::default(),
Palette::Organic,
Charset::HalfBlock,
&rs,
);
assert_eq!(
overrides.time_scale,
Some(2.5),
"capture_overrides must capture the live time_scale, not hardcode None"
);
let p = resolved(&overrides);
assert!(
(p.sim.time_scale - 2.5).abs() < 1e-6,
"time_scale must survive round-trip through resolve (got {})",
p.sim.time_scale
);
}
}