802 lines
25 KiB
Rust
802 lines
25 KiB
Rust
use crate::{
|
|
control::{
|
|
CatalogSnapshot, CatalogSource, CreativeSnapshotSummary, GroupSummary, HostCommandError,
|
|
PanelTarget, PresetSummary, SceneTransitionStyle,
|
|
},
|
|
scene::{PatternRegistry, SceneRuntime},
|
|
};
|
|
use infinity_config::{PanelPosition, ProjectConfig};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
collections::{BTreeMap, BTreeSet},
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
time::{SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
pub const RUNTIME_STATE_SCHEMA_VERSION: u16 = 1;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct StoredPreset {
|
|
pub preset_id: String,
|
|
pub scene: SceneRuntime,
|
|
pub transition_duration_ms: u32,
|
|
pub transition_style: SceneTransitionStyle,
|
|
pub source: CatalogSource,
|
|
pub updated_at_unix_ms: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct StoredGroup {
|
|
pub group_id: String,
|
|
pub tags: Vec<String>,
|
|
pub members: Vec<PanelTarget>,
|
|
pub source: CatalogSource,
|
|
pub updated_at_unix_ms: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct StoredCreativeSnapshot {
|
|
pub snapshot_id: String,
|
|
pub label: Option<String>,
|
|
pub scene: SceneRuntime,
|
|
pub transition_duration_ms: u32,
|
|
pub transition_style: SceneTransitionStyle,
|
|
pub saved_at_unix_ms: u64,
|
|
pub source_preset_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct PersistedGlobalState {
|
|
pub blackout: bool,
|
|
pub master_brightness: f32,
|
|
pub transition_duration_ms: u32,
|
|
pub transition_style: SceneTransitionStyle,
|
|
}
|
|
|
|
impl Default for PersistedGlobalState {
|
|
fn default() -> Self {
|
|
Self {
|
|
blackout: false,
|
|
master_brightness: 0.20,
|
|
transition_duration_ms: 150,
|
|
transition_style: SceneTransitionStyle::Crossfade,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
|
pub struct PersistedRuntimeState {
|
|
pub active_scene: Option<SceneRuntime>,
|
|
pub global: PersistedGlobalState,
|
|
pub user_presets: Vec<StoredPreset>,
|
|
pub user_groups: Vec<StoredGroup>,
|
|
pub creative_snapshots: Vec<StoredCreativeSnapshot>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
struct RuntimeStateEnvelope {
|
|
schema_version: u16,
|
|
saved_at_unix_ms: u64,
|
|
runtime: PersistedRuntimeState,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct RuntimeStateStorage {
|
|
path: PathBuf,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RuntimeStateLoadWarning {
|
|
pub code: String,
|
|
pub message: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct RuntimeStateLoadResult {
|
|
pub runtime: PersistedRuntimeState,
|
|
pub loaded_from_disk: bool,
|
|
pub warnings: Vec<RuntimeStateLoadWarning>,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ShowStoreError {
|
|
#[error("runtime state I/O failed: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("runtime state parse failed: {0}")]
|
|
Parse(#[from] serde_json::Error),
|
|
#[error("{0}")]
|
|
Validation(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ShowStore {
|
|
presets: Vec<StoredPreset>,
|
|
groups: Vec<StoredGroup>,
|
|
creative_snapshots: Vec<StoredCreativeSnapshot>,
|
|
}
|
|
|
|
impl RuntimeStateStorage {
|
|
pub fn new(path: impl Into<PathBuf>) -> Self {
|
|
Self { path: path.into() }
|
|
}
|
|
|
|
pub fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
|
|
pub fn load(&self) -> Result<PersistedRuntimeState, ShowStoreError> {
|
|
if !self.path.exists() {
|
|
return Ok(PersistedRuntimeState::default());
|
|
}
|
|
|
|
let raw = fs::read_to_string(&self.path)?;
|
|
parse_runtime_state(&raw, &self.path)
|
|
}
|
|
|
|
pub fn load_with_recovery(&self) -> RuntimeStateLoadResult {
|
|
if !self.path.exists() {
|
|
return RuntimeStateLoadResult {
|
|
runtime: PersistedRuntimeState::default(),
|
|
loaded_from_disk: false,
|
|
warnings: Vec::new(),
|
|
};
|
|
}
|
|
|
|
let raw = match fs::read_to_string(&self.path) {
|
|
Ok(raw) => raw,
|
|
Err(error) => {
|
|
return RuntimeStateLoadResult {
|
|
runtime: PersistedRuntimeState::default(),
|
|
loaded_from_disk: false,
|
|
warnings: vec![RuntimeStateLoadWarning::new(
|
|
"runtime_state_read_failed",
|
|
format!(
|
|
"runtime state at {} could not be read and was reset to defaults: {error}",
|
|
self.path.display()
|
|
),
|
|
)],
|
|
};
|
|
}
|
|
};
|
|
|
|
if raw.trim().is_empty() {
|
|
return RuntimeStateLoadResult {
|
|
runtime: PersistedRuntimeState::default(),
|
|
loaded_from_disk: false,
|
|
warnings: vec![RuntimeStateLoadWarning::new(
|
|
"runtime_state_empty",
|
|
format!(
|
|
"runtime state at {} was empty and was reset to defaults",
|
|
self.path.display()
|
|
),
|
|
)],
|
|
};
|
|
}
|
|
|
|
match parse_runtime_state(&raw, &self.path) {
|
|
Ok(runtime) => RuntimeStateLoadResult {
|
|
runtime,
|
|
loaded_from_disk: true,
|
|
warnings: Vec::new(),
|
|
},
|
|
Err(ShowStoreError::Parse(error)) => RuntimeStateLoadResult {
|
|
runtime: PersistedRuntimeState::default(),
|
|
loaded_from_disk: false,
|
|
warnings: vec![RuntimeStateLoadWarning::new(
|
|
"runtime_state_parse_failed",
|
|
format!(
|
|
"runtime state at {} could not be parsed and was reset to defaults: {error}",
|
|
self.path.display()
|
|
),
|
|
)],
|
|
},
|
|
Err(ShowStoreError::Validation(message)) => RuntimeStateLoadResult {
|
|
runtime: PersistedRuntimeState::default(),
|
|
loaded_from_disk: false,
|
|
warnings: vec![RuntimeStateLoadWarning::new(
|
|
"runtime_state_schema_unsupported",
|
|
format!("{message}; runtime state was reset to defaults"),
|
|
)],
|
|
},
|
|
Err(ShowStoreError::Io(error)) => RuntimeStateLoadResult {
|
|
runtime: PersistedRuntimeState::default(),
|
|
loaded_from_disk: false,
|
|
warnings: vec![RuntimeStateLoadWarning::new(
|
|
"runtime_state_read_failed",
|
|
format!(
|
|
"runtime state at {} could not be read and was reset to defaults: {error}",
|
|
self.path.display()
|
|
),
|
|
)],
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn save(&self, runtime: &PersistedRuntimeState) -> Result<(), ShowStoreError> {
|
|
if let Some(parent) = self.path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
let envelope = RuntimeStateEnvelope {
|
|
schema_version: RUNTIME_STATE_SCHEMA_VERSION,
|
|
saved_at_unix_ms: now_unix_ms(),
|
|
runtime: runtime.clone(),
|
|
};
|
|
let payload = serde_json::to_string_pretty(&envelope)?;
|
|
fs::write(&self.path, payload)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl RuntimeStateLoadWarning {
|
|
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
|
Self {
|
|
code: code.into(),
|
|
message: message.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_runtime_state(raw: &str, path: &Path) -> Result<PersistedRuntimeState, ShowStoreError> {
|
|
let envelope = serde_json::from_str::<RuntimeStateEnvelope>(raw)?;
|
|
if envelope.schema_version != RUNTIME_STATE_SCHEMA_VERSION {
|
|
return Err(ShowStoreError::Validation(format!(
|
|
"unsupported runtime state schema version {} at {}",
|
|
envelope.schema_version,
|
|
path.display()
|
|
)));
|
|
}
|
|
|
|
Ok(envelope.runtime)
|
|
}
|
|
|
|
impl ShowStore {
|
|
pub fn from_project(project: &ProjectConfig, registry: &PatternRegistry) -> Self {
|
|
let presets = project
|
|
.presets
|
|
.iter()
|
|
.map(|preset| StoredPreset {
|
|
preset_id: preset.preset_id.clone(),
|
|
scene: registry.scene_from_preset_config(preset),
|
|
transition_duration_ms: preset.transition_ms,
|
|
transition_style: crate::scene::transition_style_from_duration(
|
|
preset.transition_ms,
|
|
),
|
|
source: CatalogSource::BuiltIn,
|
|
updated_at_unix_ms: None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let groups = project
|
|
.topology
|
|
.groups
|
|
.iter()
|
|
.map(|group| StoredGroup {
|
|
group_id: group.group_id.clone(),
|
|
tags: group.tags.clone(),
|
|
members: group
|
|
.members
|
|
.iter()
|
|
.map(|member| PanelTarget {
|
|
node_id: member.node_id.clone(),
|
|
panel_position: member.panel_position.clone(),
|
|
})
|
|
.collect(),
|
|
source: CatalogSource::BuiltIn,
|
|
updated_at_unix_ms: None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
Self {
|
|
presets,
|
|
groups,
|
|
creative_snapshots: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn apply_persisted(&mut self, runtime: PersistedRuntimeState) {
|
|
for preset in runtime.user_presets {
|
|
replace_or_append_by(&mut self.presets, preset, |left, right| {
|
|
left.preset_id == right.preset_id
|
|
});
|
|
}
|
|
|
|
for group in runtime.user_groups {
|
|
replace_or_append_by(&mut self.groups, group, |left, right| {
|
|
left.group_id == right.group_id
|
|
});
|
|
}
|
|
|
|
self.creative_snapshots = runtime.creative_snapshots;
|
|
}
|
|
|
|
pub fn catalog(&self, registry: &PatternRegistry) -> CatalogSnapshot {
|
|
CatalogSnapshot {
|
|
patterns: registry.pattern_definitions(),
|
|
presets: self
|
|
.presets
|
|
.iter()
|
|
.map(|preset| PresetSummary {
|
|
preset_id: preset.preset_id.clone(),
|
|
pattern_id: preset.scene.pattern_id.clone(),
|
|
target_group: preset.scene.target_group.clone(),
|
|
transition_duration_ms: preset.transition_duration_ms,
|
|
transition_style: preset.transition_style,
|
|
source: preset.source,
|
|
})
|
|
.collect(),
|
|
groups: self
|
|
.groups
|
|
.iter()
|
|
.map(|group| GroupSummary {
|
|
group_id: group.group_id.clone(),
|
|
member_count: group.members.len(),
|
|
tags: group.tags.clone(),
|
|
source: group.source,
|
|
})
|
|
.collect(),
|
|
creative_snapshots: self
|
|
.creative_snapshots
|
|
.iter()
|
|
.map(|snapshot| CreativeSnapshotSummary {
|
|
snapshot_id: snapshot.snapshot_id.clone(),
|
|
label: snapshot.label.clone(),
|
|
pattern_id: snapshot.scene.pattern_id.clone(),
|
|
target_group: snapshot.scene.target_group.clone(),
|
|
transition_duration_ms: snapshot.transition_duration_ms,
|
|
transition_style: snapshot.transition_style,
|
|
saved_at_unix_ms: snapshot.saved_at_unix_ms,
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
pub fn initial_scene(&self, registry: &PatternRegistry) -> SceneRuntime {
|
|
self.presets
|
|
.first()
|
|
.map(|preset| preset.scene.clone())
|
|
.unwrap_or_else(|| {
|
|
registry.scene_for_pattern(
|
|
"solid_color",
|
|
Some("bootstrap-solid-color".to_string()),
|
|
None,
|
|
1,
|
|
vec!["#ffffff".to_string()],
|
|
false,
|
|
)
|
|
})
|
|
}
|
|
|
|
pub fn available_patterns(&self, registry: &PatternRegistry) -> Vec<String> {
|
|
registry
|
|
.pattern_definitions()
|
|
.into_iter()
|
|
.map(|pattern| pattern.pattern_id)
|
|
.collect()
|
|
}
|
|
|
|
pub fn scene_from_preset_id(&self, preset_id: &str) -> Option<SceneRuntime> {
|
|
self.presets
|
|
.iter()
|
|
.find(|preset| preset.preset_id == preset_id)
|
|
.map(|preset| preset.scene.clone())
|
|
}
|
|
|
|
pub fn transition_for_preset(&self, preset_id: &str) -> Option<(u32, SceneTransitionStyle)> {
|
|
self.presets
|
|
.iter()
|
|
.find(|preset| preset.preset_id == preset_id)
|
|
.map(|preset| (preset.transition_duration_ms, preset.transition_style))
|
|
}
|
|
|
|
pub fn recall_creative_snapshot(&self, snapshot_id: &str) -> Option<StoredCreativeSnapshot> {
|
|
self.creative_snapshots
|
|
.iter()
|
|
.find(|snapshot| snapshot.snapshot_id == snapshot_id)
|
|
.cloned()
|
|
}
|
|
|
|
pub fn has_group(&self, group_id: &str) -> bool {
|
|
self.groups.iter().any(|group| group.group_id == group_id)
|
|
}
|
|
|
|
pub fn group_members_map(&self) -> BTreeMap<String, BTreeSet<String>> {
|
|
self.groups
|
|
.iter()
|
|
.map(|group| {
|
|
let members = group
|
|
.members
|
|
.iter()
|
|
.map(|member| {
|
|
format!(
|
|
"{}:{}",
|
|
member.node_id,
|
|
panel_position_key(&member.panel_position)
|
|
)
|
|
})
|
|
.collect();
|
|
(group.group_id.clone(), members)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn save_preset_from_scene(
|
|
&mut self,
|
|
preset_id: &str,
|
|
scene: &SceneRuntime,
|
|
transition_duration_ms: u32,
|
|
transition_style: SceneTransitionStyle,
|
|
overwrite: bool,
|
|
) -> Result<(), HostCommandError> {
|
|
if preset_id.trim().is_empty() {
|
|
return Err(HostCommandError::new(
|
|
"invalid_preset_id",
|
|
"preset_id must not be empty",
|
|
));
|
|
}
|
|
|
|
if let Some(existing) = self
|
|
.presets
|
|
.iter()
|
|
.find(|preset| preset.preset_id == preset_id)
|
|
{
|
|
if !overwrite {
|
|
return Err(HostCommandError::new(
|
|
"preset_exists",
|
|
format!("preset '{preset_id}' already exists"),
|
|
));
|
|
}
|
|
|
|
if existing.source == CatalogSource::BuiltIn {
|
|
// Overwriting a built-in preset becomes a runtime overlay with the same id.
|
|
}
|
|
}
|
|
|
|
let preset = StoredPreset {
|
|
preset_id: preset_id.to_string(),
|
|
scene: scene.clone(),
|
|
transition_duration_ms,
|
|
transition_style,
|
|
source: CatalogSource::RuntimeUser,
|
|
updated_at_unix_ms: Some(now_unix_ms()),
|
|
};
|
|
replace_or_append_by(&mut self.presets, preset, |left, right| {
|
|
left.preset_id == right.preset_id
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
pub fn save_creative_snapshot(
|
|
&mut self,
|
|
snapshot_id: &str,
|
|
label: Option<String>,
|
|
scene: &SceneRuntime,
|
|
transition_duration_ms: u32,
|
|
transition_style: SceneTransitionStyle,
|
|
overwrite: bool,
|
|
) -> Result<(), HostCommandError> {
|
|
if snapshot_id.trim().is_empty() {
|
|
return Err(HostCommandError::new(
|
|
"invalid_snapshot_id",
|
|
"snapshot_id must not be empty",
|
|
));
|
|
}
|
|
|
|
if self
|
|
.creative_snapshots
|
|
.iter()
|
|
.any(|snapshot| snapshot.snapshot_id == snapshot_id)
|
|
&& !overwrite
|
|
{
|
|
return Err(HostCommandError::new(
|
|
"snapshot_exists",
|
|
format!("creative snapshot '{snapshot_id}' already exists"),
|
|
));
|
|
}
|
|
|
|
let snapshot = StoredCreativeSnapshot {
|
|
snapshot_id: snapshot_id.to_string(),
|
|
label,
|
|
scene: scene.clone(),
|
|
transition_duration_ms,
|
|
transition_style,
|
|
saved_at_unix_ms: now_unix_ms(),
|
|
source_preset_id: scene.preset_id.clone(),
|
|
};
|
|
replace_or_append_by(&mut self.creative_snapshots, snapshot, |left, right| {
|
|
left.snapshot_id == right.snapshot_id
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
pub fn upsert_group(
|
|
&mut self,
|
|
group_id: &str,
|
|
tags: Vec<String>,
|
|
members: Vec<PanelTarget>,
|
|
overwrite: bool,
|
|
) -> Result<(), HostCommandError> {
|
|
if group_id.trim().is_empty() {
|
|
return Err(HostCommandError::new(
|
|
"invalid_group_id",
|
|
"group_id must not be empty",
|
|
));
|
|
}
|
|
if members.is_empty() {
|
|
return Err(HostCommandError::new(
|
|
"invalid_group_members",
|
|
"group must contain at least one panel target",
|
|
));
|
|
}
|
|
|
|
if self.groups.iter().any(|group| group.group_id == group_id) && !overwrite {
|
|
return Err(HostCommandError::new(
|
|
"group_exists",
|
|
format!("group '{group_id}' already exists"),
|
|
));
|
|
}
|
|
|
|
let group = StoredGroup {
|
|
group_id: group_id.to_string(),
|
|
tags,
|
|
members,
|
|
source: CatalogSource::RuntimeUser,
|
|
updated_at_unix_ms: Some(now_unix_ms()),
|
|
};
|
|
replace_or_append_by(&mut self.groups, group, |left, right| {
|
|
left.group_id == right.group_id
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
pub fn persisted_runtime(
|
|
&self,
|
|
active_scene: &SceneRuntime,
|
|
global: PersistedGlobalState,
|
|
) -> PersistedRuntimeState {
|
|
PersistedRuntimeState {
|
|
active_scene: Some(active_scene.clone()),
|
|
global,
|
|
user_presets: self
|
|
.presets
|
|
.iter()
|
|
.filter(|preset| preset.source == CatalogSource::RuntimeUser)
|
|
.cloned()
|
|
.collect(),
|
|
user_groups: self
|
|
.groups
|
|
.iter()
|
|
.filter(|group| group.source == CatalogSource::RuntimeUser)
|
|
.cloned()
|
|
.collect(),
|
|
creative_snapshots: self.creative_snapshots.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn panel_position_key(position: &PanelPosition) -> &'static str {
|
|
match position {
|
|
PanelPosition::Top => "top",
|
|
PanelPosition::Middle => "middle",
|
|
PanelPosition::Bottom => "bottom",
|
|
}
|
|
}
|
|
|
|
fn replace_or_append_by<T, F>(items: &mut Vec<T>, item: T, predicate: F)
|
|
where
|
|
F: Fn(&T, &T) -> bool,
|
|
{
|
|
if let Some(index) = items.iter().position(|existing| predicate(existing, &item)) {
|
|
items[index] = item;
|
|
} else {
|
|
items.push(item);
|
|
}
|
|
}
|
|
|
|
fn now_unix_ms() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.as_millis() as u64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
fn sample_project() -> ProjectConfig {
|
|
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
|
|
.expect("project config must parse")
|
|
}
|
|
|
|
#[test]
|
|
fn show_store_builds_runtime_catalog() {
|
|
let registry = PatternRegistry::new();
|
|
let store = ShowStore::from_project(&sample_project(), ®istry);
|
|
let catalog = store.catalog(®istry);
|
|
assert!(catalog
|
|
.presets
|
|
.iter()
|
|
.any(|preset| preset.preset_id == "ocean_gradient"));
|
|
assert!(catalog
|
|
.groups
|
|
.iter()
|
|
.any(|group| group.group_id == "top_panels"));
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_presets_and_snapshots_can_be_saved() {
|
|
let registry = PatternRegistry::new();
|
|
let mut store = ShowStore::from_project(&sample_project(), ®istry);
|
|
let scene = registry.scene_for_pattern(
|
|
"gradient",
|
|
None,
|
|
Some("top_panels".to_string()),
|
|
77,
|
|
vec!["#112233".to_string(), "#445566".to_string()],
|
|
false,
|
|
);
|
|
|
|
store
|
|
.save_preset_from_scene(
|
|
"user_gradient",
|
|
&scene,
|
|
420,
|
|
SceneTransitionStyle::Crossfade,
|
|
false,
|
|
)
|
|
.expect("preset save should succeed");
|
|
store
|
|
.save_creative_snapshot(
|
|
"variant_a",
|
|
Some("Variant A".to_string()),
|
|
&scene,
|
|
240,
|
|
SceneTransitionStyle::Chase,
|
|
false,
|
|
)
|
|
.expect("snapshot save should succeed");
|
|
|
|
assert!(store.scene_from_preset_id("user_gradient").is_some());
|
|
assert!(store.recall_creative_snapshot("variant_a").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_state_storage_roundtrip_preserves_scene_and_library() {
|
|
let registry = PatternRegistry::new();
|
|
let mut store = ShowStore::from_project(&sample_project(), ®istry);
|
|
let scene = registry.scene_for_pattern(
|
|
"noise",
|
|
None,
|
|
Some("bottom_panels".to_string()),
|
|
99,
|
|
vec!["#AA8844".to_string()],
|
|
false,
|
|
);
|
|
store
|
|
.save_preset_from_scene(
|
|
"roundtrip_noise",
|
|
&scene,
|
|
220,
|
|
SceneTransitionStyle::Chase,
|
|
false,
|
|
)
|
|
.expect("preset save should succeed");
|
|
|
|
let path = std::env::temp_dir().join(format!(
|
|
"infinity_vis_show_store_{}.json",
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("system time")
|
|
.as_millis()
|
|
));
|
|
let storage = RuntimeStateStorage::new(&path);
|
|
let runtime = store.persisted_runtime(
|
|
&scene,
|
|
PersistedGlobalState {
|
|
blackout: false,
|
|
master_brightness: 0.42,
|
|
transition_duration_ms: 220,
|
|
transition_style: SceneTransitionStyle::Chase,
|
|
},
|
|
);
|
|
storage.save(&runtime).expect("save should work");
|
|
let loaded = storage.load().expect("load should work");
|
|
|
|
assert_eq!(loaded.active_scene, Some(scene));
|
|
assert!(loaded
|
|
.user_presets
|
|
.iter()
|
|
.any(|preset| preset.preset_id == "roundtrip_noise"));
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_state_storage_recovers_from_empty_file() {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"infinity_vis_show_store_empty_{}.json",
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("system time")
|
|
.as_millis()
|
|
));
|
|
let storage = RuntimeStateStorage::new(&path);
|
|
std::fs::write(&path, "").expect("empty file should write");
|
|
|
|
let loaded = storage.load_with_recovery();
|
|
|
|
assert_eq!(loaded.runtime, PersistedRuntimeState::default());
|
|
assert!(!loaded.loaded_from_disk);
|
|
assert_eq!(loaded.warnings.len(), 1);
|
|
assert_eq!(loaded.warnings[0].code, "runtime_state_empty");
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_state_storage_recovers_from_invalid_json() {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"infinity_vis_show_store_invalid_{}.json",
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("system time")
|
|
.as_millis()
|
|
));
|
|
let storage = RuntimeStateStorage::new(&path);
|
|
std::fs::write(&path, "{ definitely not json").expect("invalid file should write");
|
|
|
|
let loaded = storage.load_with_recovery();
|
|
|
|
assert_eq!(loaded.runtime, PersistedRuntimeState::default());
|
|
assert!(!loaded.loaded_from_disk);
|
|
assert_eq!(loaded.warnings.len(), 1);
|
|
assert_eq!(loaded.warnings[0].code, "runtime_state_parse_failed");
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_state_storage_recovers_from_unsupported_schema() {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"infinity_vis_show_store_schema_{}.json",
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("system time")
|
|
.as_millis()
|
|
));
|
|
let storage = RuntimeStateStorage::new(&path);
|
|
std::fs::write(
|
|
&path,
|
|
r#"{
|
|
"schema_version": 99,
|
|
"saved_at_unix_ms": 1,
|
|
"runtime": {
|
|
"active_scene": null,
|
|
"global": {
|
|
"blackout": false,
|
|
"master_brightness": 0.2,
|
|
"transition_duration_ms": 150,
|
|
"transition_style": "crossfade"
|
|
},
|
|
"user_presets": [],
|
|
"user_groups": [],
|
|
"creative_snapshots": []
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("schema file should write");
|
|
|
|
let loaded = storage.load_with_recovery();
|
|
|
|
assert_eq!(loaded.runtime, PersistedRuntimeState::default());
|
|
assert!(!loaded.loaded_from_disk);
|
|
assert_eq!(loaded.warnings.len(), 1);
|
|
assert_eq!(loaded.warnings[0].code, "runtime_state_schema_unsupported");
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
}
|