//! Strict, bounded boot-time presentation preferences. //! //! This module is temporarily test-only until the production CLI wiring lands. use hexwork_render::camera::View; use hexwork_render::palette::{self, Palette}; use hexwork_render::FrameBuffer; use hexwork_save::artifact::{durable_replace, ArtifactStore, ReplaceError, ReplaceOutcome}; use hexwork_ui::settings::Settings; use serde::{Deserialize, Serialize}; use std::fmt; use std::fs::{self, File}; use std::io::{self, Read}; use std::path::Path; const FILE_NAME: &str = "presentation.json"; const SCHEMA_VERSION: u8 = 1; const MAX_PREFERENCES_BYTES: usize = 16 * 1024; const GAMMA_MIN: f32 = 0.5; const GAMMA_MAX: f32 = 2.0; const FOV_MIN_DEG: f32 = 1.0; const FOV_MAX_DEG: f32 = 75.0; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PaletteChoice { Default, Protan, Deutan, Tritan, HighContrast, } impl PaletteChoice { pub fn parse(value: &str) -> Result { match value { "default" => Ok(Self::Default), "protan" => Ok(Self::Protan), "deutan" => Ok(Self::Deutan), "tritan" => Ok(Self::Tritan), "high-contrast" => Ok(Self::HighContrast), _ => Err(PresentationLoadError::Invalid( "palette must be one of default, protan, deutan, tritan, high-contrast".into(), )), } } fn name(self) -> &'static str { match self { Self::Default => "default", Self::Protan => "protan", Self::Deutan => "deutan", Self::Tritan => "tritan", Self::HighContrast => "high-contrast", } } fn palette(self) -> &'static Palette { match self { Self::Default => &palette::DEFAULT, Self::Protan => &palette::PROTAN, Self::Deutan => &palette::DEUTAN, Self::Tritan => &palette::TRITAN, Self::HighContrast => &palette::HIGH_CONTRAST, } } } #[derive(Clone, Debug, PartialEq)] pub struct PresentationPreferences { gamma: f32, fov_deg: f32, palette: PaletteChoice, } impl Default for PresentationPreferences { fn default() -> Self { Self { gamma: 1.0, fov_deg: 75.0, palette: PaletteChoice::Default, } } } impl PresentationPreferences { pub fn gamma(&self) -> f32 { self.gamma } pub fn fov_deg(&self) -> f32 { self.fov_deg } pub fn palette_choice(&self) -> PaletteChoice { self.palette } pub fn with_overrides( &self, overrides: &PresentationOverrides, ) -> Result { let gamma = overrides.gamma.unwrap_or(self.gamma); let fov_deg = overrides.fov_deg.unwrap_or(self.fov_deg); validate_gamma(gamma)?; validate_fov(fov_deg)?; Ok(Self { gamma, fov_deg, palette: overrides.palette.unwrap_or(self.palette), }) } pub fn render_tuning(&self) -> RenderTuning { RenderTuning::new(self) } } #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct PresentationOverrides { pub gamma: Option, pub fov_deg: Option, pub palette: Option, } impl PresentationOverrides { pub fn is_requested(&self) -> bool { self.gamma.is_some() || self.fov_deg.is_some() || self.palette.is_some() } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PreferenceSource { Defaults, File, } #[derive(Clone, Debug)] pub struct LoadedPreferences { preferences: PresentationPreferences, original_bytes: Option>, } impl LoadedPreferences { pub fn preferences(&self) -> &PresentationPreferences { &self.preferences } pub fn source(&self) -> PreferenceSource { if self.original_bytes.is_some() { PreferenceSource::File } else { PreferenceSource::Defaults } } } #[derive(Debug)] pub enum PresentationLoadError { Io(io::Error), NotRegular, TooLarge { limit: usize }, Invalid(String), } impl fmt::Display for PresentationLoadError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(error) => write!(formatter, "presentation preferences I/O failed: {error}"), Self::NotRegular => { formatter.write_str("presentation preferences are not a regular file") } Self::TooLarge { limit } => write!( formatter, "presentation preferences exceed the {limit}-byte limit" ), Self::Invalid(detail) => { write!(formatter, "invalid presentation preferences: {detail}") } } } } impl std::error::Error for PresentationLoadError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(error) => Some(error), _ => None, } } } #[derive(Debug)] pub(crate) enum PresentationCommitError { Io(io::Error), SourceChanged, Encode(String), Write(ReplaceError), } impl fmt::Display for PresentationCommitError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(error) => write!(formatter, "presentation source recheck failed: {error}"), Self::SourceChanged => formatter.write_str("presentation preference source changed"), Self::Encode(detail) => write!( formatter, "presentation preference encoding failed: {detail}" ), Self::Write(error) => { write!(formatter, "presentation preference write failed: {error}") } } } } impl std::error::Error for PresentationCommitError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(error) => Some(error), Self::Write(error) => Some(error), _ => None, } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CommitOutcome { Unchanged, Durable, } #[derive(Clone, Debug)] pub struct RenderTuning { gamma_lut: [u8; 256], fov_deg: f32, palette: PaletteChoice, } impl RenderTuning { fn new(preferences: &PresentationPreferences) -> Self { let mut settings = Settings::default(); settings.set_gamma(preferences.gamma); let mut gamma_lut = [0; 256]; for (value, mapped) in gamma_lut.iter_mut().enumerate() { *mapped = settings.apply_gamma([value as u8; 3])[0]; } Self { gamma_lut, fov_deg: preferences.fov_deg, palette: preferences.palette, } } pub fn view(&self, mut view: View) -> View { if !view.is_orthographic() { view.fov_deg = self.fov_deg; } view } pub fn palette(&self) -> &'static Palette { self.palette.palette() } pub fn apply_gamma(&self, frame: &mut FrameBuffer) { for cell in &mut frame.cells { cell.fg = cell.fg.map(|channel| self.gamma_lut[usize::from(channel)]); cell.bg = cell.bg.map(|channel| self.gamma_lut[usize::from(channel)]); } } } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct FileV1Input { schema_version: u8, gamma: f32, fov_deg: f32, palette: String, } #[derive(Serialize)] struct FileV1Output { schema_version: u8, gamma: f32, fov_deg: f32, palette: &'static str, } pub fn parse_gamma(value: &str) -> Result { let gamma = value.parse::().map_err(|_| { PresentationLoadError::Invalid("gamma must be a finite number from 0.5 through 2".into()) })?; validate_gamma(gamma)?; Ok(gamma) } pub fn parse_fov(value: &str) -> Result { let fov = value.parse::().map_err(|_| { PresentationLoadError::Invalid("fov must be a finite number from 1 through 75".into()) })?; validate_fov(fov)?; Ok(fov) } fn validate_gamma(gamma: f32) -> Result<(), PresentationLoadError> { if gamma.is_finite() && (GAMMA_MIN..=GAMMA_MAX).contains(&gamma) { Ok(()) } else { Err(PresentationLoadError::Invalid( "gamma must be a finite number from 0.5 through 2".into(), )) } } fn validate_fov(fov_deg: f32) -> Result<(), PresentationLoadError> { if fov_deg.is_finite() && (FOV_MIN_DEG..=FOV_MAX_DEG).contains(&fov_deg) { Ok(()) } else { Err(PresentationLoadError::Invalid( "fov must be a finite number from 1 through 75".into(), )) } } pub fn load(data_root: &Path) -> Result { let path = data_root.join(FILE_NAME); let metadata = match fs::symlink_metadata(&path) { Ok(metadata) => metadata, Err(error) if error.kind() == io::ErrorKind::NotFound => { return Ok(LoadedPreferences { preferences: PresentationPreferences::default(), original_bytes: None, }); } Err(error) => return Err(PresentationLoadError::Io(error)), }; if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { return Err(PresentationLoadError::NotRegular); } if metadata.len() > MAX_PREFERENCES_BYTES as u64 { return Err(PresentationLoadError::TooLarge { limit: MAX_PREFERENCES_BYTES, }); } let file = File::open(&path).map_err(PresentationLoadError::Io)?; let opened_metadata = file.metadata().map_err(PresentationLoadError::Io)?; if !opened_metadata.file_type().is_file() { return Err(PresentationLoadError::NotRegular); } if opened_metadata.len() > MAX_PREFERENCES_BYTES as u64 { return Err(PresentationLoadError::TooLarge { limit: MAX_PREFERENCES_BYTES, }); } let bytes = read_cap_plus_one(file).map_err(PresentationLoadError::Io)?; if bytes.len() > MAX_PREFERENCES_BYTES { return Err(PresentationLoadError::TooLarge { limit: MAX_PREFERENCES_BYTES, }); } let input: FileV1Input = serde_json::from_slice(&bytes) .map_err(|error| PresentationLoadError::Invalid(error.to_string()))?; if input.schema_version != SCHEMA_VERSION { return Err(PresentationLoadError::Invalid( "unsupported presentation schema version".into(), )); } validate_gamma(input.gamma)?; validate_fov(input.fov_deg)?; let palette = PaletteChoice::parse(&input.palette)?; Ok(LoadedPreferences { preferences: PresentationPreferences { gamma: input.gamma, fov_deg: input.fov_deg, palette, }, original_bytes: Some(bytes), }) } fn read_cap_plus_one(file: File) -> io::Result> { let mut bytes = Vec::new(); file.take((MAX_PREFERENCES_BYTES + 1) as u64) .read_to_end(&mut bytes)?; Ok(bytes) } pub(crate) fn commit( store: &ArtifactStore, expected: &LoadedPreferences, effective: &PresentationPreferences, explicitly_requested: bool, ) -> Result { commit_with_writer( store, expected, effective, explicitly_requested, |path, bytes| durable_replace(path, bytes), ) } fn commit_with_writer( store: &ArtifactStore, expected: &LoadedPreferences, effective: &PresentationPreferences, explicitly_requested: bool, writer: F, ) -> Result where F: FnOnce(&Path, &[u8]) -> Result, { let path = store.root().join(FILE_NAME); let current = recheck_source(&path)?; if current != expected.original_bytes { return Err(PresentationCommitError::SourceChanged); } if !explicitly_requested || (expected.original_bytes.is_some() && expected.preferences == *effective) { return Ok(CommitOutcome::Unchanged); } validate_gamma(effective.gamma).map_err(|error| match error { PresentationLoadError::Invalid(detail) => PresentationCommitError::Encode(detail), _ => unreachable!("numeric validation only returns Invalid"), })?; validate_fov(effective.fov_deg).map_err(|error| match error { PresentationLoadError::Invalid(detail) => PresentationCommitError::Encode(detail), _ => unreachable!("numeric validation only returns Invalid"), })?; let output = FileV1Output { schema_version: SCHEMA_VERSION, gamma: effective.gamma, fov_deg: effective.fov_deg, palette: effective.palette.name(), }; let bytes = serde_json::to_vec(&output) .map_err(|error| PresentationCommitError::Encode(error.to_string()))?; if bytes.len() > MAX_PREFERENCES_BYTES { return Err(PresentationCommitError::Encode( "encoded preferences exceed internal size bound".into(), )); } match writer(&path, &bytes).map_err(PresentationCommitError::Write)? { ReplaceOutcome::Durable => Ok(CommitOutcome::Durable), } } fn recheck_source(path: &Path) -> Result>, PresentationCommitError> { let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(PresentationCommitError::Io(error)), }; if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { return Err(PresentationCommitError::SourceChanged); } if metadata.len() > MAX_PREFERENCES_BYTES as u64 { return Err(PresentationCommitError::SourceChanged); } let file = File::open(path).map_err(PresentationCommitError::Io)?; let opened_metadata = file.metadata().map_err(PresentationCommitError::Io)?; if !opened_metadata.file_type().is_file() { return Err(PresentationCommitError::SourceChanged); } if opened_metadata.len() > MAX_PREFERENCES_BYTES as u64 { return Err(PresentationCommitError::SourceChanged); } let bytes = read_cap_plus_one(file).map_err(PresentationCommitError::Io)?; if bytes.len() > MAX_PREFERENCES_BYTES { return Err(PresentationCommitError::SourceChanged); } Ok(Some(bytes)) } #[cfg(test)] mod tests { use super::*; use hexwork_render::camera::{first_person, top_tactical}; use hexwork_render::{Camera, Cell}; use hexwork_save::artifact::{ArtifactErrorClass, DurabilityStage}; use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; const VALID: &[u8] = br#"{"schema_version":1,"gamma":1.25,"fov_deg":60.0,"palette":"protan"}"#; struct Scratch(PathBuf); impl Scratch { fn new(label: &str) -> Self { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let path = PathBuf::from(format!( "/var/tmp/hexwork-presentation-{label}-{}-{nonce}", std::process::id() )); fs::create_dir_all(&path).unwrap(); Self(path) } fn preferences_path(&self) -> PathBuf { self.0.join(FILE_NAME) } } impl Drop for Scratch { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); let _ = fs::remove_file(&self.0); } } fn store(root: &Scratch) -> ArtifactStore { ArtifactStore::open(&root.0).unwrap().0 } #[test] fn presentation_defaults_missing_without_writes() { let root = Scratch::new("defaults"); let loaded = load(&root.0).unwrap(); assert_eq!(loaded.source(), PreferenceSource::Defaults); assert_eq!(loaded.preferences(), &PresentationPreferences::default()); assert!(!root.preferences_path().exists()); } #[test] fn presentation_v1_strict_fields_version_ranges() { let root = Scratch::new("strict"); fs::write(root.preferences_path(), VALID).unwrap(); let loaded = load(&root.0).unwrap(); assert_eq!(loaded.source(), PreferenceSource::File); assert_eq!(loaded.preferences().gamma(), 1.25); assert_eq!(loaded.preferences().fov_deg(), 60.0); assert_eq!(loaded.preferences().palette_choice(), PaletteChoice::Protan); let invalid = [ r#"{"schema_version":2,"gamma":1.0,"fov_deg":75.0,"palette":"default"}"#, r#"{"schema_version":1,"fov_deg":75.0,"palette":"default"}"#, r#"{"schema_version":1,"gamma":1.0,"fov_deg":75.0,"palette":"default","extra":0}"#, r#"{"schema_version":1,"gamma":1.0,"gamma":1.5,"fov_deg":75.0,"palette":"default"}"#, r#"{"schema_version":1,"gamma":0.49,"fov_deg":75.0,"palette":"default"}"#, r#"{"schema_version":1,"gamma":2.01,"fov_deg":75.0,"palette":"default"}"#, r#"{"schema_version":1,"gamma":1e400,"fov_deg":75.0,"palette":"default"}"#, r#"{"schema_version":1,"gamma":1.0,"fov_deg":0.99,"palette":"default"}"#, r#"{"schema_version":1,"gamma":1.0,"fov_deg":75.01,"palette":"default"}"#, r#"{"schema_version":1,"gamma":1.0,"fov_deg":75.0,"palette":"no-flicker"}"#, "not json", ]; for contents in invalid { fs::write(root.preferences_path(), contents).unwrap(); assert!(matches!( load(&root.0), Err(PresentationLoadError::Invalid(_)) )); } fs::write(root.preferences_path(), [0xff]).unwrap(); assert!(matches!( load(&root.0), Err(PresentationLoadError::Invalid(_)) )); } #[test] fn presentation_cap_plus_one_and_nonregular_reject() { let root = Scratch::new("bounds"); fs::write( root.preferences_path(), vec![b' '; MAX_PREFERENCES_BYTES + 1], ) .unwrap(); assert!(matches!( load(&root.0), Err(PresentationLoadError::TooLarge { limit: MAX_PREFERENCES_BYTES }) )); fs::remove_file(root.preferences_path()).unwrap(); fs::create_dir(root.preferences_path()).unwrap(); assert!(matches!( load(&root.0), Err(PresentationLoadError::NotRegular) )); #[cfg(unix)] { use std::os::unix::fs::symlink; use std::os::unix::net::UnixListener; fs::remove_dir(root.preferences_path()).unwrap(); let target = root.0.join("target"); fs::write(&target, VALID).unwrap(); symlink(&target, root.preferences_path()).unwrap(); assert!(matches!( load(&root.0), Err(PresentationLoadError::NotRegular) )); fs::remove_file(root.preferences_path()).unwrap(); let _listener = UnixListener::bind(root.preferences_path()).unwrap(); assert!(matches!( load(&root.0), Err(PresentationLoadError::NotRegular) )); } } #[cfg(unix)] #[test] fn presentation_fifo_child_helper() { let Some(root) = std::env::var_os("HEXWORK_PRESENTATION_FIFO_HELPER") else { return; }; assert!(matches!( load(Path::new(&root)), Err(PresentationLoadError::NotRegular) )); } #[cfg(unix)] #[test] fn presentation_fifo_is_rejected_without_opening() { use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; let root = Scratch::new("fifo"); let status = Command::new("mkfifo") .arg(root.preferences_path()) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .expect("required mkfifo test tool is unavailable"); assert!(status.success()); let mut child = Command::new(std::env::current_exe().unwrap()) .arg("--exact") .arg("presentation_settings::tests::presentation_fifo_child_helper") .arg("--nocapture") .env("HEXWORK_PRESENTATION_FIFO_HELPER", &root.0) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .unwrap(); let deadline = Instant::now() + Duration::from_secs(5); loop { if let Some(status) = child.try_wait().unwrap() { assert!(status.success()); break; } if Instant::now() >= deadline { let _ = child.kill(); let _ = child.wait(); panic!("FIFO rejection helper blocked while opening a nonregular source"); } std::thread::yield_now(); } } #[test] fn presentation_overrides_validate_and_merge() { assert_eq!(parse_gamma("0.5").unwrap(), 0.5); assert_eq!(parse_gamma("2").unwrap(), 2.0); assert_eq!(parse_fov("1").unwrap(), 1.0); assert_eq!(parse_fov("75").unwrap(), 75.0); for invalid in ["NaN", "inf", "0.49", "2.01", "not-a-number"] { assert!(matches!( parse_gamma(invalid), Err(PresentationLoadError::Invalid(_)) )); } for invalid in ["NaN", "inf", "0.99", "75.01", "not-a-number"] { assert!(matches!( parse_fov(invalid), Err(PresentationLoadError::Invalid(_)) )); } for (name, expected) in [ ("default", PaletteChoice::Default), ("protan", PaletteChoice::Protan), ("deutan", PaletteChoice::Deutan), ("tritan", PaletteChoice::Tritan), ("high-contrast", PaletteChoice::HighContrast), ] { assert_eq!(PaletteChoice::parse(name).unwrap(), expected); } assert!(PaletteChoice::parse("no-flicker").is_err()); let overrides = PresentationOverrides { gamma: Some(1.5), fov_deg: None, palette: Some(PaletteChoice::Tritan), }; assert!(overrides.is_requested()); let merged = PresentationPreferences::default() .with_overrides(&overrides) .unwrap(); assert_eq!(merged.gamma(), 1.5); assert_eq!(merged.fov_deg(), 75.0); assert_eq!(merged.palette_choice(), PaletteChoice::Tritan); assert!(!PresentationOverrides::default().is_requested()); } #[test] fn presentation_source_changed_refuses_overwrite() { let root = Scratch::new("changed"); fs::write(root.preferences_path(), VALID).unwrap(); let loaded = load(&root.0).unwrap(); let store = store(&root); let effective = loaded .preferences() .with_overrides(&PresentationOverrides { gamma: Some(1.5), ..PresentationOverrides::default() }) .unwrap(); let changed = b"concurrent editor"; fs::write(root.preferences_path(), changed).unwrap(); assert!(matches!( commit(&store, &loaded, &effective, true), Err(PresentationCommitError::SourceChanged) )); assert_eq!(fs::read(root.preferences_path()).unwrap(), changed); fs::write( root.preferences_path(), vec![b'x'; MAX_PREFERENCES_BYTES + 1], ) .unwrap(); assert!(matches!( commit(&store, &loaded, &effective, true), Err(PresentationCommitError::SourceChanged) )); } #[test] fn presentation_commit_requires_owned_store_and_preserves_source() { let root = Scratch::new("commit"); fs::write(root.preferences_path(), VALID).unwrap(); let loaded = load(&root.0).unwrap(); let store = store(&root); let contention = match ArtifactStore::open(&root.0) { Ok(_) => panic!("second store unexpectedly acquired the held root lock"), Err(error) => error, }; assert_eq!(contention.class(), ArtifactErrorClass::Contention); let effective = loaded .preferences() .with_overrides(&PresentationOverrides { fov_deg: Some(55.0), palette: Some(PaletteChoice::Deutan), ..PresentationOverrides::default() }) .unwrap(); assert_eq!( commit(&store, &loaded, &effective, true).unwrap(), CommitOutcome::Durable ); let saved = load(&root.0).unwrap(); assert_eq!(saved.preferences(), &effective); assert_eq!(loaded.original_bytes.as_deref(), Some(VALID)); } #[test] fn presentation_before_publish_failure_retains_old_bytes() { let root = Scratch::new("before-publish"); fs::write(root.preferences_path(), VALID).unwrap(); let loaded = load(&root.0).unwrap(); let store = store(&root); let effective = loaded .preferences() .with_overrides(&PresentationOverrides { gamma: Some(1.5), ..PresentationOverrides::default() }) .unwrap(); let error = commit_with_writer(&store, &loaded, &effective, true, |path, _| { Err(ReplaceError::BeforePublish { stage: DurabilityStage::WriteTemp, path: path.to_owned(), detail: "injected".into(), }) }) .unwrap_err(); assert!(matches!( error, PresentationCommitError::Write(ReplaceError::BeforePublish { .. }) )); assert_eq!(fs::read(root.preferences_path()).unwrap(), VALID); } #[test] fn presentation_sync_uncertainty_aborts_without_compensation() { let root = Scratch::new("sync-failed"); fs::write(root.preferences_path(), VALID).unwrap(); let loaded = load(&root.0).unwrap(); let store = store(&root); let effective = loaded .preferences() .with_overrides(&PresentationOverrides { gamma: Some(1.5), ..PresentationOverrides::default() }) .unwrap(); let calls = AtomicUsize::new(0); let error = commit_with_writer(&store, &loaded, &effective, true, |path, bytes| { calls.fetch_add(1, Ordering::SeqCst); fs::write(path, bytes).unwrap(); Err(ReplaceError::PublishedSyncFailed { stage: DurabilityStage::SyncParent, path: path.to_owned(), detail: "injected".into(), }) }) .unwrap_err(); assert!(matches!( error, PresentationCommitError::Write(ReplaceError::PublishedSyncFailed { .. }) )); assert_eq!(calls.load(Ordering::SeqCst), 1); let saved = load(&root.0).unwrap(); assert_eq!(saved.preferences(), &effective); } #[test] fn presentation_unchanged_or_no_override_never_writes() { let root = Scratch::new("no-write"); fs::write(root.preferences_path(), VALID).unwrap(); let loaded = load(&root.0).unwrap(); let store = store(&root); let no_writer = |_: &Path, _: &[u8]| -> Result { panic!("unchanged commit invoked writer") }; assert_eq!( commit_with_writer(&store, &loaded, loaded.preferences(), false, no_writer).unwrap(), CommitOutcome::Unchanged ); assert_eq!( commit_with_writer(&store, &loaded, loaded.preferences(), true, no_writer).unwrap(), CommitOutcome::Unchanged ); assert_eq!(fs::read(root.preferences_path()).unwrap(), VALID); } #[test] fn presentation_explicit_defaults_on_missing_publish_full_v1() { let root = Scratch::new("publish-defaults"); let loaded = load(&root.0).unwrap(); let store = store(&root); assert_eq!( commit(&store, &loaded, &PresentationPreferences::default(), true).unwrap(), CommitOutcome::Durable ); let saved = load(&root.0).unwrap(); assert_eq!(saved.source(), PreferenceSource::File); assert_eq!(saved.preferences(), &PresentationPreferences::default()); } #[test] fn tuning_getters_curve_and_different_colors() { let preferences = PresentationPreferences::default() .with_overrides(&PresentationOverrides { gamma: Some(2.0), palette: Some(PaletteChoice::HighContrast), ..PresentationOverrides::default() }) .unwrap(); let tuning = preferences.render_tuning(); assert_eq!(tuning.palette(), &palette::HIGH_CONTRAST); assert_ne!(tuning.palette(), &palette::DEFAULT); let mut frame = FrameBuffer::new(1, 1); frame.cells[0] = Cell { glyph: '@', fg: [64, 128, 255], bg: [1, 16, 0], }; let mut settings = Settings::default(); settings.set_gamma(2.0); let expected_fg = settings.apply_gamma(frame.cells[0].fg); let expected_bg = settings.apply_gamma(frame.cells[0].bg); tuning.apply_gamma(&mut frame); assert_eq!(frame.cells[0].fg, expected_fg); assert_eq!(frame.cells[0].bg, expected_bg); assert_ne!(frame.cells[0].fg, [64, 128, 255]); let identity = PresentationPreferences::default().render_tuning(); let before = frame.cells[0]; identity.apply_gamma(&mut frame); assert_eq!(frame.cells[0], before); } #[test] fn tuning_view_preserves_all_fields_except_perspective_fov() { let tuning = PresentationPreferences::default() .with_overrides(&PresentationOverrides { fov_deg: Some(42.0), ..PresentationOverrides::default() }) .unwrap() .render_tuning(); let perspective = first_person([1.0, 2.0, 3.0], 0.25, -0.5); let changed = tuning.view(perspective); assert_eq!(changed.fov_deg, 42.0); let mut restored = changed; restored.fov_deg = perspective.fov_deg; assert_eq!(restored, perspective); assert_eq!(changed.mode, Camera::FirstPerson); assert_eq!(changed.tactical_slice_z, perspective.tactical_slice_z); let tactical = top_tactical([8.0, 9.0, -3.0], -7, 1.5); assert!(tactical.is_orthographic()); assert_eq!(tuning.view(tactical), tactical); } }