use std::path::Path;
use std::time::Duration;
use super::actions::SnapshotChoice;
use super::app::{CellaApp, Dim};
use super::explore::resize_masks;
use super::render::{distinct_palette_slots, parse_hex_color};
use super::state::PendingSnapshotLoad;
use crate::demos::{
build_1d_code_n, build_1d_rule30, build_2d_life, build_2d_straightline,
build_2d_three_state_cycle,
};
use cella_lib::*;
use rfd::FileDialog;
impl CellaApp {
pub(in crate::gui) fn resize_grid(&mut self) {
let (w, h) = (self.inputs.grid_width.max(1), self.inputs.grid_height.max(1));
let dim = self.scenario.dim;
let old = match dim {
Some(Dim::D1) => self.scenario.d1.as_ref().map(|g| (g.width, 1)),
Some(Dim::D2) => self.scenario.d2.as_ref().map(|g| (g.width, g.height)),
None => None,
};
let result = match dim {
Some(Dim::D1) => self.resized_1d(w),
Some(Dim::D2) => self.resized_2d(w, h),
None => return,
};
match result {
Ok(size) => {
self.edit.undo_stack.clear();
self.edit.current_paint_batch = None;
self.explore_on_grid_replaced();
if let Some(old) = old {
let new = if dim == Some(Dim::D1) { (w, 1) } else { (w, h) };
self.remap_loaded_target_masks(old, new);
}
self.set_status(format!("Resized grid to {size}"));
}
Err(why) => self.set_status(format!("Resize failed: {why}")),
}
}
fn remap_loaded_target_masks(&mut self, old: (usize, usize), new: (usize, usize)) {
let remapped_base = self.explore.base_evolve.as_ref().and_then(|base| {
let mut v = serde_json::to_value(base).ok()?;
resize_masks(&mut v, old, new);
serde_json::from_value(v).ok()
});
if let Some(remapped_base) = remapped_base {
self.explore.base_evolve = Some(remapped_base);
}
if let Some(baseline) = &mut self.explore.panel_at_load
&& let Some(evolve) = &mut baseline.evolve
{
resize_masks(evolve, old, new);
}
}
fn resized_1d(&mut self, w: usize) -> Result<String, String> {
let Some(g) = &self.scenario.d1 else {
return Err("no 1D grid is loaded".into());
};
let mut live = g.clone();
live.resize(w).map_err(|e| e.to_string())?;
let initial = match &self.scenario.initial_state {
Some(st) => {
let mut ig = Grid1D::from_state(st).ok_or("the Reset target could not be rebuilt")?;
ig.resize(w).map_err(|e| e.to_string())?;
Some(GridState::from_grid1d(&ig))
}
None => None,
};
self.scenario.d1 = Some(live);
self.scenario.initial_state = initial;
Ok(format!("width {w}"))
}
fn resized_2d(&mut self, w: usize, h: usize) -> Result<String, String> {
let Some(g) = &self.scenario.d2 else {
return Err("no 2D grid is loaded".into());
};
let mut live = g.clone();
live.resize(w, h).map_err(|e| e.to_string())?;
let initial = match &self.scenario.initial_state {
Some(st) => {
let mut ig = Grid2D::from_state(st).ok_or("the Reset target could not be rebuilt")?;
ig.resize(w, h).map_err(|e| e.to_string())?;
Some(GridState::from_grid2d(&ig))
}
None => None,
};
self.scenario.d2 = Some(live);
self.scenario.initial_state = initial;
Ok(format!("{w}×{h}"))
}
pub(in crate::gui) fn load_demo_life(&mut self) {
let (w, h, hist) = (50usize, 30usize, 5usize);
self.scenario.d1 = None;
self.scenario.dim = Some(Dim::D2);
self.scenario.d2 = Some(build_2d_life(w, h, hist));
self.scenario.initial_state = self.scenario.d2.as_ref().map(GridState::from_grid2d);
self.view.history_1d.clear();
self.edit.undo_stack.clear();
self.edit.rule_undo.clear();
self.edit.current_paint_batch = None;
self.reset_colors_for_scenario();
self.inputs.grid_width = w;
self.inputs.grid_height = h;
self.update_selected_draw_type_default();
self.stats_clear_and_init();
self.refresh_rule_editor_from_current();
self.set_status("Loaded demo: Life (2D)");
self.explore_scenario_loaded(None, None);
}
pub(in crate::gui) fn load_demo_1d_rule30(&mut self) {
let width = 201usize;
let hist = 5usize;
self.scenario.d2 = None;
self.scenario.dim = Some(Dim::D1);
self.scenario.d1 = Some(build_1d_rule30(width, hist));
self.set_status("Loaded demo: 1D Rule 30");
if let Some(g) = &self.scenario.d1 {
self.scenario.initial_state = Some(GridState::from_grid1d(g));
}
self.view.history_1d.clear();
self.edit.undo_stack.clear();
self.edit.rule_undo.clear();
self.edit.current_paint_batch = None;
self.reset_colors_for_scenario();
self.inputs.grid_width = width;
self.inputs.grid_height = 1;
self.update_selected_draw_type_default();
self.stats_clear_and_init();
self.refresh_rule_editor_from_current();
self.explore_scenario_loaded(None, None);
}
pub(in crate::gui) fn load_demo_1d_n2(&mut self) {
let code: u128 = 0xAAAAAAAA;
let width = 201usize;
let hist = 5usize;
self.scenario.d2 = None;
self.scenario.dim = Some(Dim::D1);
self.scenario.d1 =
Some(build_1d_code_n(code, 2, width, hist).expect("n2 builder should validate"));
if let Some(g) = &self.scenario.d1 {
self.scenario.initial_state = Some(GridState::from_grid1d(g));
}
self.view.history_1d.clear();
self.edit.undo_stack.clear();
self.edit.rule_undo.clear();
self.edit.current_paint_batch = None;
self.reset_colors_for_scenario();
self.inputs.grid_width = width;
self.inputs.grid_height = 1;
self.update_selected_draw_type_default();
self.stats_clear_and_init();
self.refresh_rule_editor_from_current();
self.explore_scenario_loaded(None, None);
}
pub(in crate::gui) fn load_demo_2d_three_state_cycle(&mut self) {
let (w, h, hist) = (48usize, 27usize, 3usize);
self.scenario.d1 = None;
self.scenario.dim = Some(Dim::D2);
self.scenario.d2 = Some(build_2d_three_state_cycle(w, h, hist));
self.set_status("Loaded demo: 2D three-state cycle");
self.scenario.initial_state = self.scenario.d2.as_ref().map(GridState::from_grid2d);
self.view.history_1d.clear();
self.edit.undo_stack.clear();
self.edit.rule_undo.clear();
self.edit.current_paint_batch = None;
self.reset_colors_for_scenario();
self.inputs.grid_width = w;
self.inputs.grid_height = h;
self.update_selected_draw_type_default();
self.stats_clear_and_init();
self.refresh_rule_editor_from_current();
self.explore_scenario_loaded(None, None);
}
pub(in crate::gui) fn load_demo_2d_straightline(&mut self) {
let (w, h, hist) = (48usize, 27usize, 3usize);
self.scenario.d1 = None;
self.scenario.dim = Some(Dim::D2);
self.scenario.d2 = Some(build_2d_straightline(w, h, hist));
self.set_status("Loaded demo: 2D StraightLine");
self.scenario.initial_state = self.scenario.d2.as_ref().map(GridState::from_grid2d);
self.view.history_1d.clear();
self.edit.undo_stack.clear();
self.edit.rule_undo.clear();
self.edit.current_paint_batch = None;
self.reset_colors_for_scenario();
self.inputs.grid_width = w;
self.inputs.grid_height = h;
self.update_selected_draw_type_default();
self.stats_clear_and_init();
self.refresh_rule_editor_from_current();
self.explore_scenario_loaded(None, None);
}
pub(in crate::gui) fn load_demo_1d_custom_from_inputs(&mut self) {
let wolfram_code: u128 = self.inputs.custom_code.trim().parse().unwrap_or(30);
let n: u8 = if self.inputs.custom_n == 0 {
1
} else {
self.inputs.custom_n
};
let width = 201usize;
let hist = 5usize;
if let Ok(grid) = build_1d_code_n(wolfram_code, n, width, hist) {
self.scenario.d2 = None;
self.scenario.dim = Some(Dim::D1);
self.scenario.d1 = Some(grid);
self.set_status(format!("Loaded custom 1D: code={}, n={}", wolfram_code, n));
if let Some(g) = &self.scenario.d1 {
self.scenario.initial_state = Some(GridState::from_grid1d(g));
}
self.view.history_1d.clear();
self.edit.undo_stack.clear();
self.edit.rule_undo.clear();
self.edit.current_paint_batch = None;
self.reset_colors_for_scenario();
self.inputs.grid_width = width;
self.inputs.grid_height = 1;
self.update_selected_draw_type_default();
self.stats_clear_and_init();
self.refresh_rule_editor_from_current();
self.explore_scenario_loaded(None, None);
}
}
pub(in crate::gui) fn load_config_dialog(&mut self) {
match FileDialog::new().set_directory(std::env::current_dir().unwrap_or_default()).add_filter("json", &["json"]).pick_file() {
Some(path) => self.load_config_from_path(&path),
None => self.report_no_file_chosen("config"),
}
}
pub(in crate::gui) fn report_no_file_chosen(&mut self, what: &str) {
self.set_status(format!(
"No {what} chosen. If no dialog appeared, this desktop has no file-dialog \
service: start with --config <path>, or see docs/app.md (Troubleshooting)."
));
}
pub(in crate::gui) fn apply_startup_config(&mut self, path: Option<&Path>) {
self.load_demo_life();
if let Some(p) = path {
self.load_config_from_path(p);
}
}
pub(in crate::gui) fn load_config_from_path(&mut self, path: &Path) {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("config.json")
.to_string();
match config::CellaConfig::from_file(path) {
Ok(cfg) => match cfg.snapshot().map(|s| s.step) {
Some(step) if step > 0 => {
self.chrome.pending_snapshot_load = Some(PendingSnapshotLoad { cfg, name });
}
_ => self.finish_config_load(cfg, &name, false),
},
Err(e) => {
let msg = format!("Failed to load config: {}", e);
eprintln!("{}", msg);
self.set_status(msg);
}
}
self.refresh_rule_editor_from_current();
}
pub(in crate::gui) fn resolve_snapshot_load(&mut self, choice: SnapshotChoice) {
let Some(pending) = self.chrome.pending_snapshot_load.take() else {
return;
};
match choice {
SnapshotChoice::Cancel => {}
SnapshotChoice::Resume => self.finish_config_load(pending.cfg, &pending.name, true),
SnapshotChoice::Initial => self.finish_config_load(pending.cfg, &pending.name, false),
}
}
fn report_invalid_config(&mut self, name: &str) {
let msg = format!(
"Failed to load config: {name} does not describe a valid grid \
(check `initial` length, history_limit <= 255, width/height, model)"
);
eprintln!("{}", msg);
self.set_status(msg);
}
fn finish_config_load(&mut self, cfg: config::CellaConfig, name: &str, resume: bool) {
match cfg {
config::CellaConfig::D1(_) => {
let resumed = resume.then(|| cfg.build_grid1d_resumed()).flatten();
let resumed_ok = resumed.is_some();
let Some(g) = resumed.or_else(|| cfg.build_grid1d()) else {
self.report_invalid_config(name);
return;
};
let initial_state = cfg.build_grid1d().map(|ig| GridState::from_grid1d(&ig));
let (w, h) = (g.width, 1);
self.scenario.dim = Some(Dim::D1);
self.scenario.d1 = Some(g);
self.scenario.d2 = None;
self.scenario.initial_state = initial_state;
self.set_status(load_status_message("1D", name, resume, resumed_ok));
self.finish_scenario_load(w, h, cfg.colors());
self.explore_scenario_loaded(cfg.ensemble().cloned(), cfg.evolve().cloned());
}
config::CellaConfig::D2(_) => {
let resumed = resume.then(|| cfg.build_grid2d_resumed()).flatten();
let resumed_ok = resumed.is_some();
let Some(g) = resumed.or_else(|| cfg.build_grid2d()) else {
self.report_invalid_config(name);
return;
};
let initial_state = cfg.build_grid2d().map(|ig| GridState::from_grid2d(&ig));
let (w, h) = (g.width, g.height);
self.scenario.dim = Some(Dim::D2);
self.scenario.d2 = Some(g);
self.scenario.d1 = None;
self.scenario.initial_state = initial_state;
self.set_status(load_status_message("2D", name, resume, resumed_ok));
self.finish_scenario_load(w, h, cfg.colors());
self.explore_scenario_loaded(cfg.ensemble().cloned(), cfg.evolve().cloned());
}
}
}
fn finish_scenario_load(
&mut self,
w: usize,
h: usize,
colors: &std::collections::BTreeMap<String, String>,
) {
self.inputs.grid_width = w;
self.inputs.grid_height = h;
self.view.history_1d.clear();
self.edit.undo_stack.clear();
self.edit.rule_undo.clear();
self.edit.current_paint_batch = None;
self.reset_colors_for_scenario();
self.apply_config_colors(colors);
self.update_selected_draw_type_default();
self.stats_clear_and_init();
self.refresh_rule_editor_from_current();
}
pub(in crate::gui) fn reset_colors_for_scenario(&mut self) {
self.view.colors.clear();
self.view.config_colors.clear();
let types: Vec<CellType> = self
.declared_types()
.into_iter()
.filter(|t| *t != CellType::inactive())
.collect();
let names: Vec<&str> = types.iter().map(|t| t.as_str()).collect();
let slots = distinct_palette_slots(&names, self.view.palette.len());
for (ty, slot) in types.iter().zip(slots) {
if let Some(&c) = self.view.palette.get(slot) {
self.view.colors.insert(ty.0, c);
}
}
}
pub(in crate::gui) fn apply_config_colors(
&mut self,
colors: &std::collections::BTreeMap<String, String>,
) {
self.view.config_colors = colors.clone();
for (name, hex) in colors {
match parse_hex_color(hex) {
Some(c) => self.set_color_for(&CellType::from(name.as_str()), c),
None => self.set_status(format!(
"Config colour for '{name}' is not #rrggbb: {hex:?} (ignored)"
)),
}
}
}
pub(in crate::gui) fn reset_to_initial(&mut self) {
self.cancel_run_to();
self.playback.playing = false;
self.playback.elapsed = Duration::ZERO;
self.playback.timed_steps = 0;
self.playback.play_start = None;
self.view.history_1d.clear();
self.edit.undo_stack.clear();
self.edit.current_paint_batch = None;
if let Some(st) = &self.scenario.initial_state {
match st {
GridState::D1 { .. } => {
if let Some(g) = Grid1D::from_state(st) {
self.scenario.dim = Some(Dim::D1);
self.scenario.d1 = Some(g);
self.scenario.d2 = None;
}
}
GridState::D2 { .. } => {
if let Some(g) = Grid2D::from_state(st) {
self.scenario.dim = Some(Dim::D2);
self.scenario.d2 = Some(g);
self.scenario.d1 = None;
}
}
}
}
self.update_selected_draw_type_default();
self.stats_clear_and_init();
self.refresh_rule_editor_from_current();
self.set_status("Reset to initial state");
}
}
fn load_status_message(dim: &str, name: &str, resume: bool, resumed_ok: bool) -> String {
if resumed_ok {
format!("Loaded config ({dim}, resumed): {name}")
} else if resume {
format!("Loaded config ({dim}): {name} (snapshot invalid, started from initial)")
} else {
format!("Loaded config ({dim}): {name}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gui::actions::Action;
use crate::gui::actions::SnapshotChoice;
use crate::gui::explore::{ExploreMode, SearchChoice, WorkerCmd, WorkerMsg, spawn_monte_carlo};
use crate::gui::sim::tests::test_app;
use cella_lib::config::CellaConfig;
use cella_lib::wildfire::WildfireModel;
use std::path::Path;
fn status(app: &CellaApp) -> String {
app.chrome.status_message.clone().unwrap_or_default()
}
fn write_snapshot_config_2d(step: u64) -> std::path::PathBuf {
let a = CellType::from("A");
let b = CellType::from("B");
let rule = Rule2D {
subrules: vec![Rule2DSubrule::new(
a,
b,
0,
CountOp::Gt,
1,
Neighborhood2D::Moore,
b,
None,
None,
)],
};
let mut init = vec![a; 4];
init[0] = b;
let mut g = Grid2D::new(2, 2, 2, init, rule);
let initial = GridState::from_grid2d(&g);
for _ in 0..step {
g.step();
}
let mut colors = std::collections::BTreeMap::new();
colors.insert("A".to_string(), "#112233".to_string());
let cfg = config::CellaConfig::save_2d(&initial, &g, colors);
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"cella_snapshot_load_test_{}_{}_{}.json",
std::process::id(),
step,
stamp
));
cfg.to_file_pretty(&path).expect("write test config");
path
}
fn write_snapshot_config_2d_with_oversized_history() -> std::path::PathBuf {
let a = CellType::from("A");
let b = CellType::from("B");
let rule = Rule2D {
subrules: vec![Rule2DSubrule::new(
a,
b,
0,
CountOp::Gt,
1,
Neighborhood2D::Moore,
b,
None,
None,
)],
};
let mut init = vec![a; 4];
init[0] = b;
let mut g = Grid2D::new(2, 2, 2, init, rule);
let initial = GridState::from_grid2d(&g);
for _ in 0..3 {
g.step();
}
let mut cfg = config::CellaConfig::save_2d(&initial, &g, Default::default());
let config::CellaConfig::D2(c) = &mut cfg else {
panic!("expected D2");
};
let snap = c.snapshot.as_mut().expect("saved past step 0 has a snapshot");
snap.history[0].push("A".to_string());
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"cella_snapshot_oversized_history_test_{}_{}.json",
std::process::id(),
stamp
));
cfg.to_file_pretty(&path).expect("write test config");
path
}
#[test]
fn resolving_resume_falls_back_to_initial_when_the_snapshot_history_is_oversized() {
let mut app = test_app();
let path = write_snapshot_config_2d_with_oversized_history();
app.load_config_from_path(&path);
let _ = std::fs::remove_file(&path);
assert!(
app.chrome.pending_snapshot_load.is_some(),
"step > 0 still prompts even though the snapshot is unusable"
);
app.apply_action(Action::ResolveSnapshotLoad(SnapshotChoice::Resume));
assert!(app.chrome.pending_snapshot_load.is_none());
assert_eq!(
app.current_step(),
0,
"build_grid2d_resumed refuses the oversized history, so this falls back to initial"
);
assert!(
status(&app).contains("snapshot invalid"),
"status should explain the fallback, got {:?}",
status(&app)
);
}
#[test]
fn loading_a_step_n_config_sets_the_pending_prompt_without_swapping_the_grid() {
let mut app = test_app();
app.load_demo_life();
let path = write_snapshot_config_2d(3);
app.load_config_from_path(&path);
let _ = std::fs::remove_file(&path);
assert!(
app.chrome.pending_snapshot_load.is_some(),
"a step > 0 snapshot should prompt"
);
assert_eq!(
app.current_step(),
0,
"the Life demo is untouched until the user answers"
);
}
#[test]
fn a_step_0_config_loads_immediately_with_no_prompt() {
let mut app = test_app();
let path = write_snapshot_config_2d(0);
app.load_config_from_path(&path);
let _ = std::fs::remove_file(&path);
assert!(app.chrome.pending_snapshot_load.is_none());
assert_eq!(app.current_step(), 0);
assert!(matches!(app.scenario.dim, Some(Dim::D2)));
}
#[test]
fn resolving_resume_gives_step_n_and_the_config_colours() {
let mut app = test_app();
let path = write_snapshot_config_2d(3);
app.load_config_from_path(&path);
let _ = std::fs::remove_file(&path);
app.apply_action(Action::ResolveSnapshotLoad(SnapshotChoice::Resume));
assert!(app.chrome.pending_snapshot_load.is_none());
assert_eq!(app.current_step(), 3);
assert_eq!(
app.color_of(&CellType::from("A")),
egui::Color32::from_rgb(0x11, 0x22, 0x33)
);
app.reset_to_initial();
assert_eq!(app.current_step(), 0);
}
#[test]
fn resolving_initial_gives_step_0_and_the_config_colours() {
let mut app = test_app();
let path = write_snapshot_config_2d(3);
app.load_config_from_path(&path);
let _ = std::fs::remove_file(&path);
app.apply_action(Action::ResolveSnapshotLoad(SnapshotChoice::Initial));
assert!(app.chrome.pending_snapshot_load.is_none());
assert_eq!(app.current_step(), 0);
assert_eq!(
app.color_of(&CellType::from("A")),
egui::Color32::from_rgb(0x11, 0x22, 0x33)
);
}
#[test]
fn cancel_leaves_the_current_scenario_untouched() {
let mut app = test_app();
app.load_demo_life();
let before_dim = app.scenario.dim;
let path = write_snapshot_config_2d(3);
app.load_config_from_path(&path);
let _ = std::fs::remove_file(&path);
app.apply_action(Action::ResolveSnapshotLoad(SnapshotChoice::Cancel));
assert!(app.chrome.pending_snapshot_load.is_none());
assert_eq!(before_dim, app.scenario.dim, "the Life demo is still loaded");
assert_eq!(app.current_step(), 0);
}
#[test]
fn load_config_from_path_loads_a_2d_config_and_names_it_in_the_status() {
let mut app = test_app();
app.load_config_from_path(Path::new("configs/life.json"));
assert!(matches!(app.scenario.dim, Some(Dim::D2)));
assert!(app.scenario.d2.is_some());
assert!(
status(&app).contains("life.json"),
"status should name the file, got {:?}",
status(&app)
);
}
#[test]
fn load_config_from_path_reports_a_missing_file_in_the_status() {
let mut app = test_app();
app.load_config_from_path(Path::new("configs/does_not_exist.json"));
assert!(app.scenario.dim.is_none(), "nothing should be loaded");
assert!(
status(&app).starts_with("Failed to load config"),
"got {:?}",
status(&app)
);
}
#[test]
fn a_config_with_an_invalid_grid_reports_failure_without_touching_the_scenario() {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"cella_bad_history_limit_test_{}_{}.json",
std::process::id(),
stamp
));
std::fs::write(
&path,
r#"{"dim":"2d","width":2,"height":2,"history_limit":300,
"initial":["A","A","A","A"],
"rule":{"subrules":[]}}"#,
)
.unwrap();
let mut app = test_app();
app.load_demo_life();
let before_dim = app.scenario.dim;
let before_step = app.current_step();
let before_inputs = (app.inputs.grid_width, app.inputs.grid_height);
app.load_config_from_path(&path); let _ = std::fs::remove_file(&path);
assert!(
status(&app).starts_with("Failed to load config"),
"got {:?}",
status(&app)
);
assert_eq!(
app.scenario.dim, before_dim,
"the previous scenario (the Life demo) is untouched"
);
assert_eq!(app.current_step(), before_step);
assert_eq!(
(app.inputs.grid_width, app.inputs.grid_height),
before_inputs
);
}
#[test]
fn an_empty_dialog_result_explains_itself_in_the_status() {
let mut app = test_app();
app.report_no_file_chosen("config");
let msg = status(&app);
assert!(msg.starts_with("No config chosen"), "got {msg:?}");
assert!(
msg.contains("--config"),
"should point at the workaround, got {msg:?}"
);
assert!(
msg.contains("docs/app.md"),
"should point at the docs, got {msg:?}"
);
}
#[test]
fn apply_startup_config_loads_the_given_file_instead_of_the_demo() {
let mut app = test_app();
app.apply_startup_config(Some(Path::new("configs/1d_rule30_center.json")));
assert!(matches!(app.scenario.dim, Some(Dim::D1)));
assert!(status(&app).contains("1d_rule30_center.json"));
}
#[test]
fn apply_startup_config_keeps_the_life_demo_under_a_bad_path_and_says_why() {
let mut app = test_app();
app.apply_startup_config(Some(Path::new("configs/does_not_exist.json")));
assert!(
matches!(app.scenario.dim, Some(Dim::D2)),
"Life demo should be on screen"
);
assert_eq!((app.inputs.grid_width, app.inputs.grid_height), (50, 30));
assert!(
status(&app).starts_with("Failed to load config"),
"got {:?}",
status(&app)
);
}
#[test]
fn loading_a_scenario_gives_each_declared_type_its_own_colour() {
let mut app = test_app();
app.load_demo_2d_three_state_cycle();
let types: Vec<CellType> = app
.declared_types()
.into_iter()
.filter(|t| *t != CellType::inactive())
.collect();
assert!(types.len() >= 3, "demo declares at least three types");
let colours: Vec<_> = types.iter().map(|t| app.color_of(t)).collect();
for i in 0..colours.len() {
for j in (i + 1)..colours.len() {
assert_ne!(
colours[i],
colours[j],
"{} and {} share a colour",
types[i].as_str(),
types[j].as_str()
);
}
}
}
#[test]
fn config_colours_override_the_automatic_ones() {
let dir = std::env::temp_dir();
let path = dir.join(format!("cella_colour_test_{}.json", std::process::id()));
std::fs::write(
&path,
r##"{"dim":"2d","width":2,"height":1,"history_limit":0,
"initial":["Forest","Burning"],
"rule":{"subrules":[{"current_type":"Forest","criteria_type":"Burning","count":1,"op":"gt","range":1,"neighborhood":"Moore","randomness":null,"output_type":"Burning"}]},
"colors":{"Forest":"#2e8b57"}}"##,
)
.unwrap();
let mut app = test_app();
app.load_config_from_path(&path);
let _ = std::fs::remove_file(&path);
assert!(
matches!(app.scenario.dim, Some(Dim::D2)),
"got {:?}",
status(&app)
);
let forest = app.color_of(&CellType::from("Forest"));
let burning = app.color_of(&CellType::from("Burning"));
assert_eq!(
forest,
egui::Color32::from_rgb(0x2e, 0x8b, 0x57),
"config colour wins"
);
assert_ne!(
forest, burning,
"the other type still gets a distinct automatic colour"
);
}
#[test]
fn the_wildfire_demo_loads_with_its_four_evocative_colours() {
let mut app = test_app();
app.load_config_from_path(Path::new("configs/2d_wildfire_demo.json"));
let c = |n: &str| app.color_of(&CellType::from(n));
assert_eq!(c("Forest"), egui::Color32::from_rgb(0x2e, 0x8b, 0x57));
assert_eq!(c("Shrub"), egui::Color32::from_rgb(0x9a, 0xcd, 0x32));
assert_eq!(c("Burning"), egui::Color32::from_rgb(0xff, 0x45, 0x00));
assert_eq!(c("BurnedOut"), egui::Color32::from_rgb(0x6b, 0x6b, 0x6b));
}
#[test]
fn apply_startup_config_falls_back_to_the_life_demo_when_none() {
let mut app = test_app();
app.apply_startup_config(None);
assert!(matches!(app.scenario.dim, Some(Dim::D2)));
assert_eq!((app.inputs.grid_width, app.inputs.grid_height), (50, 30));
}
fn unique_temp_path(label: &str) -> std::path::PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("cella_gui_{label}_{}_{stamp}.json", std::process::id()))
}
#[test]
fn resize_keeps_the_model_step_colours_and_resizes_the_reset_target() {
let mut app = test_app();
app.load_config_from_path(Path::new("configs/2d_wildfire_demo.json"));
for _ in 0..10 {
app.scenario.d2.as_mut().unwrap().step();
}
let colours = app.view.colors.clone();
app.apply_action(Action::Resize { w: 70, h: 44 });
let g = app.scenario.d2.as_ref().unwrap();
assert_eq!((g.width, g.height, g.step), (70, 44, 10));
assert!(g.model.is_some(), "the model survives the resize");
assert_eq!(app.view.colors, colours);
let reset = Grid2D::from_state(app.scenario.initial_state.as_ref().unwrap()).unwrap();
assert_eq!((reset.width, reset.height, reset.step), (70, 44, 0));
assert!(status(&app).contains("70"), "got {:?}", status(&app));
app.scenario.d2.as_mut().unwrap().step();
let g = app.scenario.d2.as_ref().unwrap();
assert!((0..g.width * g.height).any(|i| g.cell_type(i) != g.inactive));
}
#[test]
fn reset_after_resize_returns_to_step_0_at_the_new_size() {
let mut app = test_app();
app.load_demo_life();
app.scenario.d2.as_mut().unwrap().step();
app.apply_action(Action::Resize { w: 20, h: 12 });
app.reset_to_initial();
let g = app.scenario.d2.as_ref().unwrap();
assert_eq!((g.width, g.height, g.step), (20, 12, 0));
}
#[test]
fn resize_1d_keeps_the_step_and_the_history_view() {
let mut app = test_app();
app.load_demo_1d_rule30();
for _ in 0..3 {
app.step_once(); }
let rows = app.view.history_1d.len();
let step = app.scenario.d1.as_ref().unwrap().step;
app.apply_action(Action::Resize { w: 40, h: 1 });
assert_eq!(app.scenario.d1.as_ref().unwrap().width, 40);
assert_eq!(app.scenario.d1.as_ref().unwrap().step, step);
assert_eq!(app.view.history_1d.len(), rows);
}
#[test]
fn save_then_load_after_a_resize_round_trips() {
let mut app = test_app();
app.load_demo_life();
app.step_once();
app.step_once();
app.apply_action(Action::Resize { w: 20, h: 12 });
let colors = app.color_map_for_save();
let initial = app.scenario.initial_state.clone().expect("reset target");
let g = app.scenario.d2.as_ref().expect("2D grid loaded");
let cfg = CellaConfig::save_2d(&initial, g, colors);
let path = unique_temp_path("resize_round_trip");
cfg.to_file_pretty(&path).expect("write test config");
let mut again = test_app();
again.load_config_from_path(&path);
again.resolve_snapshot_load(SnapshotChoice::Resume);
let _ = std::fs::remove_file(&path);
let g = again.scenario.d2.as_ref().unwrap();
assert_eq!((g.width, g.height, g.step), (20, 12, 2));
again.reset_to_initial();
let g = again.scenario.d2.as_ref().unwrap();
assert_eq!((g.width, g.height, g.step), (20, 12, 0));
}
#[test]
fn a_resize_the_library_refuses_reports_the_error_and_touches_nothing() {
let mut app = test_app();
app.load_demo_life();
app.step_once();
let g = app.scenario.d2.as_ref().unwrap();
let dims_before = (g.width, g.height);
let step_before = g.step;
let initial_before =
Grid2D::from_state(app.scenario.initial_state.as_ref().unwrap()).unwrap();
let initial_dims_before = (initial_before.width, initial_before.height);
let colours_before = app.view.colors.clone();
app.apply_action(Action::Resize { w: usize::MAX, h: 2 });
assert!(
status(&app).starts_with("Resize failed"),
"got {:?}",
status(&app)
);
let g = app.scenario.d2.as_ref().unwrap();
assert_eq!((g.width, g.height), dims_before, "the live grid is untouched");
assert_eq!(g.step, step_before);
let initial_after =
Grid2D::from_state(app.scenario.initial_state.as_ref().unwrap()).unwrap();
assert_eq!(
(initial_after.width, initial_after.height),
initial_dims_before,
"the Reset target is untouched"
);
assert_eq!(app.view.colors, colours_before);
}
#[test]
fn a_1d_resize_the_library_refuses_reports_the_error_and_touches_nothing() {
let mut app = test_app();
app.load_demo_1d_rule30();
app.step_once();
let g = app.scenario.d1.as_ref().unwrap();
assert!(
g.history_limit > 1,
"demo's history_limit changed; this test needs it > 1 to overflow"
);
let width_before = g.width;
let step_before = g.step;
let initial_before =
Grid1D::from_state(app.scenario.initial_state.as_ref().unwrap()).unwrap();
let initial_width_before = initial_before.width;
let colours_before = app.view.colors.clone();
app.apply_action(Action::Resize { w: usize::MAX, h: 1 });
assert!(
status(&app).starts_with("Resize failed"),
"got {:?}",
status(&app)
);
let g = app.scenario.d1.as_ref().unwrap();
assert_eq!(g.width, width_before, "the live grid is untouched");
assert_eq!(g.step, step_before);
let initial_after =
Grid1D::from_state(app.scenario.initial_state.as_ref().unwrap()).unwrap();
assert_eq!(
initial_after.width, initial_width_before,
"the Reset target is untouched"
);
assert_eq!(app.view.colors, colours_before);
}
#[test]
fn loading_an_ensemble_config_fills_the_panel_and_shows_a_notice() {
let mut app = test_app();
app.load_config_from_path(Path::new("configs/2d_wildfire_ensemble.json"));
let cfg = CellaConfig::from_file("configs/2d_wildfire_ensemble.json").unwrap();
let block = cfg.ensemble().unwrap();
assert_eq!(app.explore.mode, ExploreMode::MonteCarlo);
assert_eq!(app.explore.mc.members, block.members);
assert_eq!(app.explore.mc.beta, block.beta);
let ticked: Vec<&str> = app.explore.genes.iter().filter(|r| r.vary).map(|r| r.desc.key.as_str()).collect();
for g in &block.genes {
let has_row = app.explore.genes.iter().any(|r| r.desc.key == g.key);
assert_eq!(ticked.contains(&g.key.as_str()), has_row, "gene {}", g.key);
}
assert!(app.explore.base_ensemble.is_some());
let kept = app.explore.kept_ensemble.join("; ");
assert!(kept.contains("driver"), "kept: {kept}");
let notice = app.chrome.notice.as_ref().expect("a load notice");
assert!(notice.lines.iter().any(|l| l.contains("members")), "{notice:?}");
assert!(notice.lines.iter().any(|l| l.contains("Kept as-is")), "{notice:?}");
}
#[test]
fn loading_an_evolve_config_selects_evolve_and_notes_what_it_keeps() {
let mut app = test_app();
app.load_config_from_path(Path::new("configs/2d_map_elites_life_classes.json"));
assert_eq!(app.explore.mode, ExploreMode::Evolve);
assert_eq!(app.explore.evo.search, SearchChoice::MapElites);
let kept = app.explore.kept_evolve.join("; ");
assert!(kept.contains("random initial grids"), "kept: {kept}");
assert!(app.explore.base_evolve.is_some());
}
#[test]
fn a_plain_config_or_demo_clears_the_base_and_shows_no_notice() {
let mut app = test_app();
app.load_config_from_path(Path::new("configs/2d_wildfire_ensemble.json"));
app.apply_action(Action::DismissNotice);
assert!(app.chrome.notice.is_none());
app.load_demo_life();
assert!(app.explore.base_ensemble.is_none());
assert!(app.explore.panel_at_load.is_some(), "a baseline is taken on every load");
assert!(app.chrome.notice.is_none());
}
fn block_json<T: serde::Serialize>(b: &T) -> serde_json::Value {
serde_json::to_value(b).unwrap()
}
#[test]
fn saving_an_untouched_loaded_ensemble_writes_it_back_unchanged() {
let mut app = test_app();
app.load_config_from_path(Path::new("configs/2d_wildfire_ensemble.json"));
let file = CellaConfig::from_file("configs/2d_wildfire_ensemble.json").unwrap();
let saved = app.ensemble_block_for_save().expect("a loaded block is always written");
assert_eq!(block_json(&saved), block_json(file.ensemble().unwrap()));
assert!(app.current_ensemble_config().driver.is_some(), "Run uses the driver too");
}
#[test]
fn untouched_evolve_configs_round_trip() {
for path in [
"configs/2d_map_elites_life_classes.json",
"configs/1d_novelty_rule_space.json",
"configs/1d_evolve_density_classification.json",
"configs/2d_evolve_life_density.json",
] {
let mut app = test_app();
app.load_config_from_path(Path::new(path));
let file = CellaConfig::from_file(path).unwrap();
let saved = app.evolve_block_for_save().expect(path);
assert_eq!(block_json(&saved), block_json(file.evolve().unwrap()), "{path}");
}
}
#[test]
fn an_edit_overrides_only_its_field_and_unticking_drops_a_gene() {
let mut app = test_app();
app.load_config_from_path(Path::new("configs/2d_wildfire_ensemble.json"));
app.explore.mc.members = 7;
let first_row_key = app
.explore
.genes
.iter()
.find(|r| r.vary)
.map(|r| r.desc.key.clone())
.expect("at least one gene has a panel row");
app.explore.genes.iter_mut().find(|r| r.desc.key == first_row_key).unwrap().vary = false;
let saved = app.ensemble_block_for_save().unwrap();
assert_eq!(saved.members, 7);
assert!(saved.driver.is_some());
assert!(saved.genes.iter().all(|g| g.key != first_row_key), "unticked gene dropped");
let file = CellaConfig::from_file("configs/2d_wildfire_ensemble.json").unwrap();
let free: Vec<_> = file
.ensemble()
.unwrap()
.genes
.iter()
.filter(|g| !app.explore.genes.iter().any(|r| r.desc.key == g.key))
.map(|g| g.key.clone())
.collect();
for k in free {
assert!(saved.genes.iter().any(|g| g.key == k), "free gene {k} kept");
}
}
#[test]
fn a_plain_scenario_writes_a_block_only_once_touched() {
let mut app = test_app();
app.load_demo_life();
assert!(app.ensemble_block_for_save().is_none());
assert!(app.evolve_block_for_save().is_none());
app.explore.mc.members = 9;
assert_eq!(app.ensemble_block_for_save().unwrap().members, 9);
assert!(app.evolve_block_for_save().is_none(), "an ensemble edit doesn't touch evolve");
app.explore.mode = ExploreMode::Evolve;
app.explore.genes[0].vary = true;
assert!(app.evolve_block_for_save().is_some());
}
#[test]
fn save_writes_the_blocks_and_a_notice_and_load_reads_them_back() {
let mut app = test_app();
app.load_config_from_path(Path::new("configs/2d_wildfire_ensemble.json"));
app.apply_action(Action::DismissNotice);
let (cfg, notice) = app.config_for_save().unwrap();
let notice = notice.expect("a block was written, so a notice");
assert!(notice.lines.iter().any(|l| l.contains("members")), "{notice:?}");
let path = unique_temp_path("explore_blocks");
cfg.to_file_pretty(&path).unwrap();
let mut again = test_app();
again.load_config_from_path(&path);
let _ = std::fs::remove_file(&path);
let file = CellaConfig::from_file("configs/2d_wildfire_ensemble.json").unwrap();
assert_eq!(
block_json(again.explore.base_ensemble.as_ref().unwrap()),
block_json(file.ensemble().unwrap())
);
}
#[test]
fn wildfire_resize_then_save_then_load_round_trips_a_stepped_grid() {
let mut app = test_app();
app.load_config_from_path(Path::new("configs/2d_wildfire_demo.json"));
app.step_once();
app.step_once();
let step_before = app.scenario.d2.as_ref().unwrap().step;
app.apply_action(Action::Resize { w: 70, h: 44 });
assert!(
status(&app).starts_with("Resized"),
"the resize should have succeeded, got {:?}",
status(&app)
);
let (cfg, _notice) = app.config_for_save().expect("a grid is loaded");
let path = unique_temp_path("wildfire_resize_round_trip");
cfg.to_file_pretty(&path).expect("write test config");
let mut again = test_app();
again.load_config_from_path(&path);
again.resolve_snapshot_load(SnapshotChoice::Resume);
let _ = std::fs::remove_file(&path);
let g = again.scenario.d2.as_ref().unwrap();
assert_eq!((g.width, g.height, g.step), (70, 44, step_before));
assert!(g.model.is_some(), "the wildfire model survives the round trip");
let elevation_len = again
.scenario
.d2
.as_mut()
.unwrap()
.model_mut()
.unwrap()
.as_any_mut()
.downcast_mut::<WildfireModel>()
.unwrap()
.env
.elevation
.len();
assert_eq!(elevation_len, 70 * 44);
again.reset_to_initial();
let g = again.scenario.d2.as_ref().unwrap();
assert_eq!((g.width, g.height, g.step), (70, 44, 0));
assert!(g.model.is_some(), "the model is present after Reset too");
}
#[test]
fn a_loaded_driver_bearing_ensemble_runs_through_the_monte_carlo_worker() {
let mut app = test_app();
app.load_config_from_path(Path::new("configs/2d_wildfire_ensemble.json"));
app.apply_action(Action::DismissNotice);
app.explore.mc.members = 4;
let cfg = app.current_ensemble_config();
assert_eq!(cfg.members, 4);
assert!(cfg.driver.is_some(), "the loaded driver must still drive the ensemble");
let (template, sig) = app.template_sim().unwrap();
let worker = spawn_monte_carlo(template, cfg, app.tracked_types(), None, sig);
let first = worker.rx.recv_timeout(Duration::from_secs(20)).unwrap();
assert!(matches!(first, WorkerMsg::Probability { steps: 0, .. }));
assert!(matches!(
worker.rx.recv_timeout(Duration::from_secs(20)).unwrap(),
WorkerMsg::Done
));
worker.tx.send(WorkerCmd::RunSteps(3)).unwrap();
let mut saw_probability = false;
loop {
match worker.rx.recv_timeout(Duration::from_secs(30)).unwrap() {
WorkerMsg::Probability { cells, .. } => {
saw_probability = true;
assert!(!cells.is_empty());
}
WorkerMsg::Error(e) => panic!("worker reported an error: {e}"),
WorkerMsg::Done => break,
other => panic!("unexpected {other:?}"),
}
}
assert!(saw_probability, "the probability map should appear");
worker.tx.send(WorkerCmd::Stop).unwrap();
worker.join.unwrap().join().unwrap();
}
}