Software-only show-control readiness baseline
This commit is contained in:
801
crates/infinity_host/src/show_store.rs
Normal file
801
crates/infinity_host/src/show_store.rs
Normal file
@@ -0,0 +1,801 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user